初始化

This commit is contained in:
2026-03-09 17:35:53 +08:00
commit 74f322b7c2
577 changed files with 57404 additions and 0 deletions

1
app/.htaccess Normal file
View File

@@ -0,0 +1 @@
deny from all

22
app/AppService.php Normal file
View File

@@ -0,0 +1,22 @@
<?php
declare (strict_types=1);
namespace app;
use think\Service;
/**
* 应用服务类
*/
class AppService extends Service
{
public function register()
{
// 服务注册
}
public function boot()
{
// 服务启动
}
}

93
app/BaseController.php Normal file
View File

@@ -0,0 +1,93 @@
<?php
declare (strict_types=1);
namespace app;
use think\App;
use think\Request;
use think\Validate;
use think\exception\ValidateException;
/**
* 控制器基础类
*/
abstract class BaseController
{
/**
* Request实例
* @var Request
*/
protected Request $request;
/**
* 是否批量验证
* @var bool
*/
protected bool $batchValidate = false;
/**
* 控制器中间件
* @var array
*/
protected array $middleware = [];
/**
* 构造方法
* @access public
* @param App $app 应用对象
*/
public function __construct(protected App $app)
{
$this->request = $this->app->request;
$this->request->controllerPath = str_replace('.', '/', $this->request->controller(true));
// 控制器初始化
$this->initialize();
}
/**
* 初始化
* @access protected
*/
protected function initialize(): void
{
}
/**
* 验证数据
* @access protected
* @param array $data 数据
* @param array|string $validate 验证器名或者验证规则数组
* @param array $message 提示信息
* @param bool $batch 是否批量验证
* @return array|string|true
* @throws ValidateException
*/
protected function validate(array $data, array|string $validate, array $message = [], bool $batch = false): bool|array|string
{
if (is_array($validate)) {
$v = new Validate();
$v->rule($validate);
} else {
if (strpos($validate, '.')) {
// 支持场景
[$validate, $scene] = explode('.', $validate);
}
$class = str_contains($validate, '\\') ? $validate : $this->app->parseClass('validate', $validate);
$v = new $class();
if (!empty($scene)) {
$v->scene($scene);
}
}
$v->message($message);
// 是否批量验证
if ($batch || $this->batchValidate) {
$v->batch();
}
return $v->failException()->check($data);
}
}

119
app/ExceptionHandle.php Normal file
View File

@@ -0,0 +1,119 @@
<?php
namespace app;
use think\db\exception\DataNotFoundException;
use think\db\exception\ModelNotFoundException;
use think\exception\Handle;
use think\exception\HttpException;
use think\exception\HttpResponseException;
use think\exception\ValidateException;
use think\Response;
use think\Request;
use Throwable;
/**
* 应用异常处理类
*/
class ExceptionHandle extends Handle
{
/**
* 不需要记录信息(日志)的异常类列表
* @var array
*/
protected $ignoreReport = [
HttpException::class,
HttpResponseException::class,
ModelNotFoundException::class,
DataNotFoundException::class,
ValidateException::class,
];
/**
* 记录异常信息(包括日志或者其它方式记录)
*
* @access public
* @param Throwable $exception
* @return void
*/
public function report(Throwable $exception): void
{
// 使用内置的方式记录异常日志
parent::report($exception);
}
/**
* Render an exception into an HTTP response.
*
* @access public
* @param Request $request
* @param Throwable $e
* @return Response
*/
public function render(Request $request, Throwable $e): Response
{
// 添加自定义异常处理机制
// 其他错误交给系统处理
return parent::render($request, $e);
}
/**
* 收集异常数据
*/
protected function convertExceptionToArray(Throwable $exception): array
{
if ($this->app->isDebug()) {
// 调试模式,获取详细的错误信息
$traces = [];
$nextException = $exception;
do {
$traces[] = [
'name' => $nextException::class,
'file' => $nextException->getFile(),
'line' => $nextException->getLine(),
'code' => $this->getCode($nextException),
'message' => $this->getMessage($nextException),
'trace' => $nextException->getTrace(),
'source' => $this->getSourceCode($nextException),
];
} while ($nextException = $nextException->getPrevious());
// 循环引用检测并直接置空 traces比起循环引用导致的报错置空后开发者能得到更多真实的错误信息
if ($this->app->request->isJson()) {
$json = json_encode($traces, JSON_UNESCAPED_UNICODE);
if (false === $json && in_array(json_last_error(), [JSON_ERROR_DEPTH, JSON_ERROR_RECURSION])) {
$traces = [];
}
}
$data = [
'code' => $this->getCode($exception),
'message' => $this->getMessage($exception),
'traces' => $traces,
'datas' => $this->getExtendData($exception),
'tables' => [
'GET Data' => $this->app->request->get(),
'POST Data' => $this->app->request->post(),
'Files' => $this->app->request->file(),
'Cookies' => $this->app->request->cookie(),
'Session' => $this->app->exists('session') ? $this->app->session->all() : [],
'Server/Request Data' => $this->app->request->server(),
],
];
} else {
// 部署模式仅显示 Code 和 Message
$data = [
'code' => $this->getCode($exception),
'message' => $this->getMessage($exception),
];
if (!$this->app->config->get('app.show_error_msg')) {
// 不显示详细错误信息
$data['message'] = $this->app->config->get('app.error_message');
}
}
return $data;
}
}

26
app/Request.php Normal file
View File

@@ -0,0 +1,26 @@
<?php
namespace app;
/**
* 应用请求对象类
*/
class Request extends \think\Request
{
/**
* 全局过滤规则
* app/common.php 的 filter 函数
*/
protected $filter = 'filter';
public function __construct()
{
parent::__construct();
// 从配置文件读取代理服务器ip并设置给 \think\Request
$proxyServerIp = config('buildadmin.proxy_server_ip');
if (is_array($proxyServerIp) && $proxyServerIp) {
$this->proxyServerIp = $proxyServerIp;
}
}
}

34
app/admin/common.php Normal file
View File

@@ -0,0 +1,34 @@
<?php
use ba\Filesystem;
use GuzzleHttp\Client;
if (!function_exists('get_controller_list')) {
function get_controller_list($app = 'admin'): array
{
$controllerDir = root_path() . 'app' . DIRECTORY_SEPARATOR . $app . DIRECTORY_SEPARATOR . 'controller' . DIRECTORY_SEPARATOR;
return Filesystem::getDirFiles($controllerDir);
}
}
if (!function_exists('get_ba_client')) {
/**
* 获取一个请求 BuildAdmin 开源社区的 Client
* @throws Throwable
*/
function get_ba_client(): Client
{
return new Client([
'base_uri' => config('buildadmin.api_url'),
'timeout' => 30,
'connect_timeout' => 30,
'verify' => false,
'http_errors' => false,
'headers' => [
'X-REQUESTED-WITH' => 'XMLHttpRequest',
'Referer' => dirname(request()->root(true)),
'User-Agent' => 'BuildAdminClient',
]
]);
}
}

View File

@@ -0,0 +1,217 @@
<?php
namespace app\admin\controller;
use Throwable;
use ba\Terminal;
use think\Response;
use ba\TableManager;
use think\facade\Db;
use think\facade\Cache;
use think\facade\Event;
use app\admin\model\AdminLog;
use app\common\library\Upload;
use app\common\controller\Backend;
class Ajax extends Backend
{
protected array $noNeedPermission = ['*'];
/**
* 无需登录的方法
* terminal 内部自带验权
*/
protected array $noNeedLogin = ['terminal'];
public function initialize(): void
{
parent::initialize();
}
public function upload(): void
{
AdminLog::instance()->setTitle(__('upload'));
$file = $this->request->file('file');
$driver = $this->request->param('driver', 'local');
$topic = $this->request->param('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) {
$this->error($e->getMessage());
}
$this->success(__('File uploaded successfully'), [
'file' => $attachment ?? []
]);
}
/**
* 获取省市区数据
* @throws Throwable
*/
public function area(): void
{
$this->success('', get_area());
}
public function buildSuffixSvg(): Response
{
$suffix = $this->request->param('suffix', 'file');
$background = $this->request->param('background');
$content = build_suffix_svg((string)$suffix, (string)$background);
return response($content, 200, ['Content-Length' => strlen($content)])->contentType('image/svg+xml');
}
/**
* 获取已脱敏的数据库连接配置列表
* @throws Throwable
*/
public function getDatabaseConnectionList(): void
{
$quickSearch = $this->request->get("quickSearch/s", '');
$connections = config('database.connections');
$desensitization = [];
foreach ($connections as $key => $connection) {
$connection = TableManager::getConnectionConfig($key);
$desensitization[] = [
'type' => $connection['type'],
'database' => substr_replace($connection['database'], '****', 1, strlen($connection['database']) > 4 ? 2 : 1),
'key' => $key,
];
}
if ($quickSearch) {
$desensitization = array_filter($desensitization, function ($item) use ($quickSearch) {
return preg_match("/$quickSearch/i", $item['key']);
});
$desensitization = array_values($desensitization);
}
$this->success('', [
'list' => $desensitization,
]);
}
/**
* 获取表主键字段
* @param ?string $table
* @param ?string $connection
* @throws Throwable
*/
public function getTablePk(?string $table = null, ?string $connection = null): void
{
if (!$table) {
$this->error(__('Parameter error'));
}
$table = TableManager::tableName($table, true, $connection);
if (!TableManager::phinxAdapter(false, $connection)->hasTable($table)) {
$this->error(__('Data table does not exist'));
}
$tablePk = Db::connect(TableManager::getConnection($connection))
->table($table)
->getPk();
$this->success('', ['pk' => $tablePk]);
}
/**
* 获取数据表列表
* @throws Throwable
*/
public function getTableList(): void
{
$quickSearch = $this->request->get("quickSearch/s", '');
$connection = $this->request->request('connection');// 数据库连接配置标识
$samePrefix = $this->request->request('samePrefix/b', true);// 是否仅返回项目数据表(前缀同项目一致的)
$excludeTable = $this->request->request('excludeTable/a', []);// 要排除的数据表数组(表名无需带前缀)
$outTables = [];
$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 = '/^' . $dbConfig['prefix'] . '/i';
foreach ($tables as $table => $comment) {
if ($samePrefix && !preg_match($pattern, $table)) continue;
$table = preg_replace($pattern, '', $table);
if (!in_array($table, $excludeTable)) {
$outTables[] = [
'table' => $table,
'comment' => $comment,
'connection' => $connection,
'prefix' => $dbConfig['prefix'],
];
}
}
$this->success('', [
'list' => $outTables,
]);
}
/**
* 获取数据表字段列表
* @throws Throwable
*/
public function getTableFieldList(): void
{
$table = $this->request->param('table');
$clean = $this->request->param('clean', true);
$connection = $this->request->request('connection');
if (!$table) {
$this->error(__('Parameter error'));
}
$connection = TableManager::getConnection($connection);
$tablePk = Db::connect($connection)->name($table)->getPk();
$this->success('', [
'pk' => $tablePk,
'fieldList' => TableManager::getTableColumns($table, $clean, $connection),
]);
}
public function changeTerminalConfig(): void
{
AdminLog::instance()->setTitle(__('Change terminal config'));
if (Terminal::changeTerminalConfig()) {
$this->success();
} else {
$this->error(__('Failed to modify the terminal configuration. Please modify the configuration file manually:%s', ['/config/buildadmin.php']));
}
}
public function clearCache(): void
{
AdminLog::instance()->setTitle(__('Clear cache'));
$type = $this->request->post('type');
if ($type == 'tp' || $type == 'all') {
Cache::clear();
} else {
$this->error(__('Parameter error'));
}
Event::trigger('cacheClearAfter', $this->app);
$this->success(__('Cache cleaned~'));
}
/**
* 终端
* @throws Throwable
*/
public function terminal(): void
{
(new Terminal())->exec();
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace app\admin\controller;
use app\common\controller\Backend;
class Dashboard extends Backend
{
public function initialize(): void
{
parent::initialize();
}
public function index(): void
{
$this->success('', [
'remark' => get_route_remark()
]);
}
}

View File

@@ -0,0 +1,133 @@
<?php
declare (strict_types=1);
namespace app\admin\controller;
use Throwable;
use ba\ClickCaptcha;
use think\facade\Config;
use think\facade\Validate;
use app\common\facade\Token;
use app\admin\model\AdminLog;
use app\common\controller\Backend;
class Index extends Backend
{
protected array $noNeedLogin = ['logout', 'login'];
protected array $noNeedPermission = ['index'];
/**
* 后台初始化请求
* @return void
* @throws Throwable
*/
public function index(): void
{
$adminInfo = $this->auth->getInfo();
$adminInfo['super'] = $this->auth->isSuperAdmin();
unset($adminInfo['token'], $adminInfo['refresh_token']);
$menus = $this->auth->getMenus();
if (!$menus) {
$this->error(__('No background menu, please contact super administrator!'));
}
$this->success('', [
'adminInfo' => $adminInfo,
'menus' => $menus,
'siteConfig' => [
'siteName' => get_sys_config('site_name'),
'version' => get_sys_config('version'),
'apiUrl' => Config::get('buildadmin.api_url'),
'upload' => keys_to_camel_case(get_upload_config(), ['max_size', 'save_name', 'allowed_suffixes', 'allowed_mime_types']),
'cdnUrl' => full_url(),
'cdnUrlParams' => Config::get('buildadmin.cdn_url_params'),
],
'terminal' => [
'phpDevelopmentServer' => str_contains($_SERVER['SERVER_SOFTWARE'], 'Development Server'),
'npmPackageManager' => Config::get('terminal.npm_package_manager'),
]
]);
}
/**
* 管理员登录
* @return void
* @throws Throwable
*/
public function login(): void
{
// 检查登录态
if ($this->auth->isLogin()) {
$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::get('buildadmin.admin_login_captcha');
// 检查提交
if ($this->request->isPost()) {
$username = $this->request->post('username');
$password = $this->request->post('password');
$keep = $this->request->post('keep');
$rule = [
'username|' . __('Username') => 'require|length:3,30',
'password|' . __('Password') => 'require|regex:^(?!.*[&<>"\'\n\r]).{6,32}$',
];
$data = [
'username' => $username,
'password' => $password,
];
if ($captchaSwitch) {
$rule['captchaId|' . __('CaptchaId')] = 'require';
$rule['captchaInfo|' . __('Captcha')] = 'require';
$data['captchaId'] = $this->request->post('captchaId');
$data['captchaInfo'] = $this->request->post('captchaInfo');
}
$validate = Validate::rule($rule);
if (!$validate->check($data)) {
$this->error($validate->getError());
}
if ($captchaSwitch) {
$captchaObj = new ClickCaptcha();
if (!$captchaObj->check($data['captchaId'], $data['captchaInfo'])) {
$this->error(__('Captcha error'));
}
}
AdminLog::instance()->setTitle(__('Login'));
$res = $this->auth->login($username, $password, (bool)$keep);
if ($res === true) {
$this->success(__('Login succeeded!'), [
'userInfo' => $this->auth->getInfo()
]);
} else {
$msg = $this->auth->getError();
$msg = $msg ?: __('Incorrect user name or password!');
$this->error($msg);
}
}
$this->success('', [
'captcha' => $captchaSwitch
]);
}
/**
* 管理员注销
* @return void
*/
public function logout(): void
{
if ($this->request->isPost()) {
$refreshToken = $this->request->post('refreshToken', '');
if ($refreshToken) Token::delete((string)$refreshToken);
$this->auth->logout();
$this->success();
}
}
}

View File

@@ -0,0 +1,137 @@
<?php
namespace app\admin\controller;
use Throwable;
use ba\Exception;
use think\facade\Config;
use app\admin\model\AdminLog;
use app\admin\library\module\Server;
use app\admin\library\module\Manage;
use app\common\controller\Backend;
class Module extends Backend
{
protected array $noNeedPermission = ['state', 'dependentInstallComplete'];
public function initialize(): void
{
parent::initialize();
}
public function index(): void
{
$this->success('', [
'installed' => Server::installedList(root_path() . 'modules' . DIRECTORY_SEPARATOR),
'sysVersion' => Config::get('buildadmin.version'),
'nuxtVersion' => Server::getNuxtVersion(),
]);
}
public function state(): void
{
$uid = $this->request->get("uid/s", '');
if (!$uid) {
$this->error(__('Parameter error'));
}
$this->success('', [
'state' => Manage::instance($uid)->getInstallState()
]);
}
public function install(): void
{
AdminLog::instance()->setTitle(__('Install module'));
$uid = $this->request->param("uid/s", '');
$update = $this->request->param("update/b", false);
if (!$uid) {
$this->error(__('Parameter error'));
}
$res = [];
try {
$res = Manage::instance($uid)->install($update);
} catch (Exception $e) {
$this->error(__($e->getMessage()), $e->getData(), $e->getCode());
} catch (Throwable $e) {
$this->error(__($e->getMessage()));
}
$this->success('', [
'data' => $res,
]);
}
public function dependentInstallComplete(): void
{
$uid = $this->request->get("uid/s", '');
if (!$uid) {
$this->error(__('Parameter error'));
}
try {
Manage::instance($uid)->dependentInstallComplete('all');
} catch (Exception $e) {
$this->error(__($e->getMessage()), $e->getData(), $e->getCode());
} catch (Throwable $e) {
$this->error(__($e->getMessage()));
}
$this->success();
}
public function changeState(): void
{
AdminLog::instance()->setTitle(__('Change module state'));
$uid = $this->request->post("uid/s", '');
$state = $this->request->post("state/b", false);
if (!$uid) {
$this->error(__('Parameter error'));
}
$info = [];
try {
$info = Manage::instance($uid)->changeState($state);
} catch (Exception $e) {
$this->error(__($e->getMessage()), $e->getData(), $e->getCode());
} catch (Throwable $e) {
$this->error(__($e->getMessage()));
}
$this->success('', [
'info' => $info,
]);
}
public function uninstall(): void
{
AdminLog::instance()->setTitle(__('Unload module'));
$uid = $this->request->get("uid/s", '');
if (!$uid) {
$this->error(__('Parameter error'));
}
try {
Manage::instance($uid)->uninstall();
} catch (Exception $e) {
$this->error(__($e->getMessage()), $e->getData(), $e->getCode());
} catch (Throwable $e) {
$this->error(__($e->getMessage()));
}
$this->success();
}
public function upload(): void
{
AdminLog::instance()->setTitle(__('Upload install module'));
$file = $this->request->get("file/s", '');
$token = $this->request->get("token/s", '');
if (!$file) $this->error(__('Parameter error'));
if (!$token) $this->error(__('Please login to the official website account first'));
$info = [];
try {
$info = Manage::instance()->upload($token, $file);
} catch (Exception $e) {
$this->error(__($e->getMessage()), $e->getData(), $e->getCode());
} catch (Throwable $e) {
$this->error(__($e->getMessage()));
}
$this->success('', [
'info' => $info
]);
}
}

View File

@@ -0,0 +1,261 @@
<?php
namespace app\admin\controller\auth;
use Throwable;
use think\facade\Db;
use app\common\controller\Backend;
use app\admin\model\Admin as AdminModel;
class Admin extends Backend
{
/**
* 模型
* @var object
* @phpstan-var AdminModel
*/
protected object $model;
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';
public function initialize(): void
{
parent::initialize();
$this->model = new AdminModel();
}
/**
* 查看
* @throws Throwable
*/
public function index(): void
{
if ($this->request->param('select')) {
$this->select();
}
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);
$this->success('', [
'list' => $res->items(),
'total' => $res->total(),
'remark' => get_route_remark(),
]);
}
/**
* 添加
* @throws Throwable
*/
public function add(): void
{
if ($this->request->isPost()) {
$data = $this->request->post();
if (!$data) {
$this->error(__('Parameter %s can not be empty', ['']));
}
if ($this->modelValidate) {
try {
$validate = str_replace("\\model\\", "\\validate\\", get_class($this->model));
$validate = new $validate();
$validate->scene('add')->check($data);
} catch (Throwable $e) {
$this->error($e->getMessage());
}
}
$passwd = $data['password']; // 密码将被排除不直接入库
$data = $this->excludeFields($data);
$result = false;
if ($data['group_arr']) $this->checkGroupAuth($data['group_arr']);
$this->model->startTrans();
try {
$result = $this->model->save($data);
if ($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();
$this->error($e->getMessage());
}
if ($result !== false) {
$this->success(__('Added successfully'));
} else {
$this->error(__('No rows were added'));
}
}
$this->error(__('Parameter error'));
}
/**
* 编辑
* @throws Throwable
*/
public function edit(): void
{
$pk = $this->model->getPk();
$id = $this->request->param($pk);
$row = $this->model->find($id);
if (!$row) {
$this->error(__('Record not found'));
}
$dataLimitAdminIds = $this->getDataLimitAdminIds();
if ($dataLimitAdminIds && !in_array($row[$this->dataLimitField], $dataLimitAdminIds)) {
$this->error(__('You have no permission'));
}
if ($this->request->isPost()) {
$data = $this->request->post();
if (!$data) {
$this->error(__('Parameter %s can not be empty', ['']));
}
if ($this->modelValidate) {
try {
$validate = str_replace("\\model\\", "\\validate\\", get_class($this->model));
$validate = new $validate();
$validate->scene('edit')->check($data);
} catch (Throwable $e) {
$this->error($e->getMessage());
}
}
if ($this->auth->id == $data['id'] && $data['status'] == 'disable') {
$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 ($data['group_arr']) {
$checkGroups = [];
foreach ($data['group_arr'] as $datum) {
if (!in_array($datum, $row->group_arr)) {
$checkGroups[] = $datum;
}
$groupAccess[] = [
'uid' => $id,
'group_id' => $datum,
];
}
$this->checkGroupAuth($checkGroups);
}
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();
$this->error($e->getMessage());
}
if ($result !== false) {
$this->success(__('Update successful'));
} else {
$this->error(__('No rows updated'));
}
}
unset($row['salt'], $row['login_failure']);
$row['password'] = '';
$this->success('', [
'row' => $row
]);
}
/**
* 删除
* @throws Throwable
*/
public function del(): void
{
$where = [];
$dataLimitAdminIds = $this->getDataLimitAdminIds();
if ($dataLimitAdminIds) {
$where[] = [$this->dataLimitField, 'in', $dataLimitAdminIds];
}
$ids = $this->request->param('ids/a', []);
$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();
$this->error($e->getMessage());
}
if ($count) {
$this->success(__('Deleted successfully'));
} else {
$this->error(__('No rows were deleted'));
}
}
/**
* 检查分组权限
* @throws Throwable
*/
private function checkGroupAuth(array $groups): void
{
if ($this->auth->isSuperAdmin()) {
return;
}
$authGroups = $this->auth->getAllAuthGroups('allAuthAndOthers');
foreach ($groups as $group) {
if (!in_array($group, $authGroups)) {
$this->error(__('You have no permission to add an administrator to this group!'));
}
}
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace app\admin\controller\auth;
use Throwable;
use app\common\controller\Backend;
use app\admin\model\AdminLog as AdminLogModel;
class AdminLog extends Backend
{
/**
* @var object
* @phpstan-var AdminLogModel
*/
protected object $model;
protected string|array $preExcludeFields = ['create_time', 'admin_id', 'username'];
protected string|array $quickSearchField = ['title'];
public function initialize(): void
{
parent::initialize();
$this->model = new AdminLogModel();
}
/**
* 查看
* @throws Throwable
*/
public function index(): void
{
if ($this->request->param('select')) {
$this->select();
}
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);
$this->success('', [
'list' => $res->items(),
'total' => $res->total(),
'remark' => get_route_remark(),
]);
}
}

View File

@@ -0,0 +1,379 @@
<?php
namespace app\admin\controller\auth;
use ba\Tree;
use Throwable;
use think\facade\Db;
use app\admin\model\AdminRule;
use app\admin\model\AdminGroup;
use app\common\controller\Backend;
class Group extends Backend
{
/**
* 修改、删除分组时对操作管理员进行鉴权
* 本管理功能部分场景对数据权限有要求,修改此值请额外确定以下的 absoluteAuth 实现的功能
* allAuthAndOthers=管理员拥有该分组所有权限并拥有额外权限时允许
*/
protected string $authMethod = 'allAuthAndOthers';
/**
* 数据模型
* @var object
* @phpstan-var AdminGroup
*/
protected object $model;
protected string|array $preExcludeFields = ['create_time', 'update_time'];
protected string|array $quickSearchField = 'name';
/**
* @var Tree
*/
protected Tree $tree;
/**
* 远程select初始化传值
* @var array
*/
protected array $initValue;
/**
* 搜索关键词
* @var string
*/
protected string $keyword;
/**
* 是否组装Tree
* @var bool
*/
protected bool $assembleTree;
/**
* 登录管理员的角色组
* @var array
*/
protected array $adminGroups = [];
public function initialize(): void
{
parent::initialize();
$this->model = new AdminGroup();
$this->tree = Tree::instance();
$isTree = $this->request->param('isTree', true);
$this->initValue = $this->request->get("initValue/a", []);
$this->initValue = array_filter($this->initValue);
$this->keyword = $this->request->request("quickSearch", '');
// 有初始化值时不组装树状(初始化出来的值更好看)
$this->assembleTree = $isTree && !$this->initValue;
$this->adminGroups = Db::name('admin_group_access')->where('uid', $this->auth->id)->column('group_id');
}
public function index(): void
{
if ($this->request->param('select')) {
$this->select();
}
$this->success('', [
'list' => $this->getGroups(),
'group' => $this->adminGroups,
'remark' => get_route_remark(),
]);
}
/**
* 添加
* @throws Throwable
*/
public function add(): void
{
if ($this->request->isPost()) {
$data = $this->request->post();
if (!$data) {
$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) {
$validate = str_replace("\\model\\", "\\validate\\", get_class($this->model));
if (class_exists($validate)) {
$validate = new $validate();
$validate->scene('add')->check($data);
}
}
$result = $this->model->save($data);
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
$this->error($e->getMessage());
}
if ($result !== false) {
$this->success(__('Added successfully'));
} else {
$this->error(__('No rows were added'));
}
}
$this->error(__('Parameter error'));
}
/**
* 编辑
* @throws Throwable
*/
public function edit(): void
{
$pk = $this->model->getPk();
$id = $this->request->param($pk);
$row = $this->model->find($id);
if (!$row) {
$this->error(__('Record not found'));
}
$this->checkAuth($id);
if ($this->request->isPost()) {
$data = $this->request->post();
if (!$data) {
$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)) {
$this->error(__('You cannot modify your own management group!'));
}
$data = $this->excludeFields($data);
$data = $this->handleRules($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();
$validate->scene('edit')->check($data);
}
}
$result = $row->save($data);
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
$this->error($e->getMessage());
}
if ($result !== false) {
$this->success(__('Update successful'));
} else {
$this->error(__('No rows updated'));
}
}
// 读取所有pid全部从节点数组移除父级选择状态由子级决定
$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);
$this->success('', [
'row' => $row
]);
}
/**
* 删除
* @throws Throwable
*/
public function del(): void
{
$ids = $this->request->param('ids/a', []);
$data = $this->model->where($this->model->getPk(), 'in', $ids)->select();
foreach ($data as $v) {
$this->checkAuth($v->id);
}
$subData = $this->model->where('pid', 'in', $ids)->column('pid', 'id');
foreach ($subData as $key => $subDatum) {
if (!in_array($key, $ids)) {
$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();
$this->error($e->getMessage());
}
if ($count) {
$this->success(__('Deleted successfully'));
} else {
$this->error(__('No rows were deleted'));
}
}
/**
* 远程下拉
* @return void
* @throws Throwable
*/
public function select(): void
{
$data = $this->getGroups([['status', '=', 1]]);
if ($this->assembleTree) {
$data = $this->tree->assembleTree($this->tree->getTreeArray($data));
}
$this->success('', [
'options' => $data
]);
}
/**
* 权限节点入库前处理
* @throws Throwable
*/
private function handleRules(array &$data): array
{
if (!empty($data['rules']) && is_array($data['rules'])) {
$superAdmin = true;
$checkedRules = [];
$allRuleIds = AdminRule::column('id');
// 遍历检查权限ID是否存在以免传递了可预测的未来权限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)) {
$this->error(__('Role group has all your rights, please contact the upper administrator to add or do not need to add!'));
}
// 检查分组权限是否超出了自己的权限(超管的 $ownedRuleIds 为 ['*'],不便且可以不做此项检查)
if (array_diff($checkedRules, $ownedRuleIds) && !$this->auth->isSuperAdmin()) {
$this->error(__('The group permission node exceeds the range that can be allocated'));
}
$data['rules'] = implode(',', $checkedRules);
}
} else {
unset($data['rules']);
}
return $data;
}
/**
* 获取分组
* @param array $where
* @return array
* @throws Throwable
*/
private function getGroups(array $where = []): array
{
$pk = $this->model->getPk();
$initKey = $this->request->get("initKey/s", $pk);
// 下拉选择时只获取:拥有所有权限并且有额外权限的分组
$absoluteAuth = $this->request->get('absoluteAuth/b', 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();
// 获取第一个权限的名称供列表显示-s
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');
}
}
// 获取第一个权限的名称供列表显示-e
// 如果要求树状,此处先组装好 children
return $this->assembleTree ? $this->tree->assembleChild($data) : $data;
}
/**
* 检查权限
* @param $groupId
* @return void
* @throws Throwable
*/
private function checkAuth($groupId): void
{
$authGroups = $this->auth->getAllAuthGroups($this->authMethod, []);
if (!$this->auth->isSuperAdmin() && !in_array($groupId, $authGroups)) {
$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~'));
}
}
}

View File

@@ -0,0 +1,307 @@
<?php
namespace app\admin\controller\auth;
use ba\Tree;
use Throwable;
use app\common\library\Menu;
use app\admin\model\AdminRule;
use app\admin\model\AdminGroup;
use app\common\controller\Backend;
use app\admin\library\crud\Helper;
class Rule extends Backend
{
protected string|array $preExcludeFields = ['create_time', 'update_time'];
protected string|array $defaultSortField = ['weigh' => 'desc'];
protected string|array $quickSearchField = 'title';
/**
* @var object
* @phpstan-var AdminRule
*/
protected object $model;
/**
* @var Tree
*/
protected Tree $tree;
/**
* 远程select初始化传值
* @var array
*/
protected array $initValue;
/**
* 搜索关键词
* @var string
*/
protected string $keyword;
/**
* 是否组装Tree
* @var bool
*/
protected bool $assembleTree;
/**
* 开启模型验证
* @var bool
*/
protected bool $modelValidate = false;
public function initialize(): void
{
parent::initialize();
// 防止 URL 中的特殊符号被默认的 filter 函数转义
$this->request->filter('clean_xss');
$this->model = new AdminRule();
$this->tree = Tree::instance();
$isTree = $this->request->param('isTree', true);
$this->initValue = $this->request->get('initValue/a', []);
$this->initValue = array_filter($this->initValue);
$this->keyword = $this->request->request('quickSearch', '');
$this->assembleTree = $isTree && !$this->initValue; // 有初始化值时不组装树状(初始化出来的值更好看)
}
public function index(): void
{
if ($this->request->param('select')) {
$this->select();
}
$this->success('', [
'list' => $this->getMenus(),
'remark' => get_route_remark(),
]);
}
/**
* 添加
*/
public function add(): void
{
if ($this->request->isPost()) {
$data = $this->request->post();
if (!$data) {
$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, $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();
$this->error($e->getMessage());
}
if ($result !== false) {
$this->success(__('Added successfully'));
} else {
$this->error(__('No rows were added'));
}
}
$this->error(__('Parameter error'));
}
/**
* 编辑
* @throws Throwable
*/
public function edit(): void
{
$id = $this->request->param($this->model->getPk());
$row = $this->model->find($id);
if (!$row) {
$this->error(__('Record not found'));
}
$dataLimitAdminIds = $this->getDataLimitAdminIds();
if ($dataLimitAdminIds && !in_array($row[$this->dataLimitField], $dataLimitAdminIds)) {
$this->error(__('You have no permission'));
}
if ($this->request->isPost()) {
$data = $this->request->post();
if (!$data) {
$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['pid'] == $row['id']) {
$parent->pid = 0;
$parent->save();
}
}
$result = $row->save($data);
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
$this->error($e->getMessage());
}
if ($result !== false) {
$this->success(__('Update successful'));
} else {
$this->error(__('No rows updated'));
}
}
$this->success('', [
'row' => $row
]);
}
/**
* 删除
* @throws Throwable
*/
public function del(): void
{
$ids = $this->request->param('ids/a', []);
// 子级元素检查
$subData = $this->model->where('pid', 'in', $ids)->column('pid', 'id');
foreach ($subData as $key => $subDatum) {
if (!in_array($key, $ids)) {
$this->error(__('Please delete the child element first, or use batch deletion'));
}
}
parent::del();
}
/**
* 重写select方法
* @throws Throwable
*/
public function select(): void
{
$data = $this->getMenus([['type', 'in', ['menu_dir', 'menu']], ['status', '=', 1]]);
if ($this->assembleTree) {
$data = $this->tree->assembleTree($this->tree->getTreeArray($data, 'title'));
}
$this->success('', [
'options' => $data
]);
}
/**
* 获取菜单列表
* @throws Throwable
*/
protected function getMenus($where = []): array
{
$pk = $this->model->getPk();
$initKey = $this->request->get("initKey/s", $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();
// 如果要求树状,此处先组装好 children
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) {
$rules = explode(',', $group->rules);
if (in_array($pid, $rules) && !in_array($id, $rules)) {
$rules[] = $id;
$group->rules = implode(',', $rules);
$group->save();
}
}
}
}

View File

@@ -0,0 +1,1002 @@
<?php
namespace app\admin\controller\crud;
use Throwable;
use ba\Exception;
use ba\Filesystem;
use think\facade\Db;
use ba\TableManager;
use app\admin\model\CrudLog;
use app\common\library\Menu;
use app\admin\model\AdminLog;
use app\admin\model\AdminRule;
use app\common\controller\Backend;
use app\admin\library\crud\Helper;
class Crud extends Backend
{
/**
* 模型文件数据
* @var array
*/
protected array $modelData = [];
/**
* 控制器文件数据
* @var array
*/
protected array $controllerData = [];
/**
* index.vue文件数据
* @var array
*/
protected array $indexVueData = [];
/**
* form.vue文件数据
* @var array
*/
protected array $formVueData = [];
/**
* 语言翻译前缀
* @var string
*/
protected string $webTranslate = '';
/**
* 语言包数据
* @var array
*/
protected array $langTsData = [];
/**
* 当designType为以下值时:
* 1. 出入库字符串到数组转换
* 2. 默认值转数组
* @var array
*/
protected array $dtStringToArray = ['checkbox', 'selects', 'remoteSelects', 'city', 'images', 'files'];
protected array $noNeedPermission = ['logStart', 'getFileData', 'parseFieldData', 'generateCheck', 'uploadCompleted'];
public function initialize(): void
{
parent::initialize();
}
/**
* 开始生成
* @throws Throwable
*/
public function generate(): void
{
$type = $this->request->post('type', '');
$table = $this->request->post('table', []);
$fields = $this->request->post('fields', [], 'clean_xss,htmlspecialchars_decode_improve');
if (!$table || !$fields || !isset($table['name']) || !$table['name']) {
$this->error(__('Parameter error'));
}
try {
// 记录日志
$crudLogId = Helper::recordCrudStatus([
'table' => $table,
'fields' => $fields,
'status' => 'start',
]);
// 表名称
$tableName = TableManager::tableName($table['name'], false, $table['databaseConnection']);
if ($type == 'create' || $table['rebuild'] == 'Yes') {
// 数据表存在则删除
TableManager::phinxTable($tableName, [], true, $table['databaseConnection'])->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'] ? 'common' : 'admin', $tableName, 'model', $table['modelFile']);
$validateFile = Helper::parseNameData($table['isCommonModel'] ? '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']) . '.';
// 快速搜索字段
if (!in_array($tablePk, $table['quickSearchField'])) {
$table['quickSearchField'][] = $tablePk;
}
$quickSearchFieldZhCnTitle = [];
// 模型数据
$this->modelData['append'] = [];
$this->modelData['methods'] = [];
$this->modelData['fieldType'] = [];
$this->modelData['createTime'] = '';
$this->modelData['updateTime'] = '';
$this->modelData['beforeInsertMixins'] = [];
$this->modelData['beforeInsert'] = '';
$this->modelData['afterInsert'] = '';
$this->modelData['connection'] = $table['databaseConnection'];
$this->modelData['name'] = $tableName;
$this->modelData['className'] = $modelFile['lastName'];
$this->modelData['namespace'] = $modelFile['namespace'];
$this->modelData['relationMethodList'] = [];
// 控制器数据
$this->controllerData['use'] = [];
$this->controllerData['attr'] = [];
$this->controllerData['methods'] = [];
$this->controllerData['filterRule'] = '';
$this->controllerData['className'] = $controllerFile['lastName'];
$this->controllerData['namespace'] = $controllerFile['namespace'];
$this->controllerData['tableComment'] = $tableComment;
$this->controllerData['modelName'] = $modelFile['lastName'];
$this->controllerData['modelNamespace'] = $modelFile['namespace'];
// index.vue数据
$this->indexVueData['enableDragSort'] = false;
$this->indexVueData['defaultItems'] = [];
$this->indexVueData['tableColumn'] = [
[
'type' => 'selection',
'align' => 'center',
'operator' => 'false',
],
];
$this->indexVueData['dblClickNotEditColumn'] = ['undefined'];
$this->indexVueData['optButtons'] = ['edit', 'delete'];
$this->indexVueData['defaultOrder'] = '';
// form.vue数据
$this->formVueData['bigDialog'] = false;
$this->formVueData['formFields'] = [];
$this->formVueData['formValidatorRules'] = [];
$this->formVueData['imports'] = [];
// 语言包数据
$this->langTsData = [
'en' => [],
'zh-cn' => [],
];
// 简化的字段数据
$fieldsMap = [];
foreach ($fields as $key => $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'], $table['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']);
}
// 表格列
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(',', $table['quickSearchField']);
$this->langTsData['zh-cn']['quick Search Fields'] = implode('、', $quickSearchFieldZhCnTitle);
$this->controllerData['attr']['quickSearchField'] = $table['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);
// 写入index.vue代码
$this->indexVueData['tablePk'] = $tablePk;
$this->indexVueData['webTranslate'] = $this->webTranslate;
Helper::writeIndexFile($this->indexVueData, $webViewsDir, $controllerFile);
// 写入form.vue代码
Helper::writeFormFile($this->formVueData, $webViewsDir, $fields, $this->webTranslate);
// 生成菜单
Helper::createMenu($webViewsDir, $tableComment);
Helper::recordCrudStatus([
'id' => $crudLogId,
'status' => 'success',
]);
} catch (Exception $e) {
Helper::recordCrudStatus([
'id' => $crudLogId ?? 0,
'status' => 'error',
]);
$this->error($e->getMessage());
} catch (Throwable $e) {
Helper::recordCrudStatus([
'id' => $crudLogId ?? 0,
'status' => 'error',
]);
if (env('app_debug', false)) throw $e;
$this->error($e->getMessage());
}
$this->success('', [
'crudLog' => CrudLog::find($crudLogId),
]);
}
/**
* 从log开始
* @throws Throwable
*/
public function logStart(): void
{
$id = $this->request->post('id');
$type = $this->request->post('type', '');
if ($type == 'Cloud history') {
// 云端 历史记录
$client = get_ba_client();
$response = $client->request('GET', '/api/v6.Crud/info', [
'query' => [
'id' => $id,
'server' => 1,
'ba-user-token' => $this->request->post('token', ''),
]
]);
$body = $response->getBody();
$statusCode = $response->getStatusCode();
$content = $body->getContents();
if ($content == '' || stripos($content, '<title>系统发生错误</title>') !== false || $statusCode != 200) {
$this->error(__('Failed to load cloud data'));
}
$json = json_decode($content, true);
if (json_last_error() != JSON_ERROR_NONE) {
$this->error(__('Failed to load cloud data'));
}
if (is_array($json)) {
if ($json['code'] != 1) {
$this->error($json['msg']);
}
$info = $json['data']['info'];
}
} else {
// 本地记录
$info = CrudLog::find($id)->toArray();
}
if (!isset($info) || !$info) {
$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()->setTitle(__('Log start'));
$this->success('', [
'table' => $info['table'],
'fields' => $info['fields'],
'sync' => $info['sync'],
]);
}
/**
* 删除CRUD记录和生成的文件
* @throws Throwable
*/
public function delete(): void
{
$id = $this->request->post('id');
$info = CrudLog::find($id)->toArray();
if (!$info) {
$this->error(__('Record not found'));
}
$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) {
$this->error($e->getMessage());
}
$this->success(__('Deleted successfully'));
}
/**
* 获取文件路径数据
* @throws Throwable
*/
public function getFileData(): void
{
$table = $this->request->get('table');
$commonModel = $this->request->get('commonModel/b');
if (!$table) {
$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) {
$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)) {
continue;
}
$item = Filesystem::fsFit('app/admin/controller/' . $item);
$controllerFiles[$item] = $item;
}
$this->success('', [
'modelFile' => $modelFile['rootFileName'],
'controllerFile' => $controllerFile['rootFileName'],
'validateFile' => $validateFile['rootFileName'],
'controllerFileList' => $controllerFiles,
'modelFileList' => $modelFileList,
'webViewsDir' => $webViewsDir['views'],
]);
}
/**
* 检查是否已有CRUD记录
* @throws Throwable
*/
public function checkCrudLog(): void
{
$table = $this->request->get('table');
$connection = $this->request->get('connection');
$connection = $connection ?: config('database.default');
$crudLog = Db::name('crud_log')
->where('table_name', $table)
->where('connection', $connection)
->order('create_time desc')
->find();
$this->success('', [
'id' => ($crudLog && $crudLog['status'] == 'success') ? $crudLog['id'] : 0,
]);
}
/**
* 解析字段数据
* @throws Throwable
*/
public function parseFieldData(): void
{
AdminLog::instance()->setTitle(__('Parse field data'));
$type = $this->request->post('type');
$table = $this->request->post('table');
$connection = $this->request->post('connection');
$connection = TableManager::getConnection($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) {
$this->error(__('Record not found'));
}
// 数据表是否有数据
$adapter = TableManager::phinxAdapter(false, $connection);
if ($adapter->hasTable($table)) {
$empty = Db::connect($connection)
->table($table)
->limit(1)
->select()
->isEmpty();
} else {
$empty = true;
}
$this->success('', [
'columns' => Helper::parseTableColumns($table, false, $connection),
'comment' => $tableInfo[0]['TABLE_COMMENT'] ?? '',
'empty' => $empty,
]);
}
}
/**
* 生成前检查
* @throws Throwable
*/
public function generateCheck(): void
{
$table = $this->request->post('table');
$connection = $this->request->post('connection');
$webViewsDir = $this->request->post('webViewsDir', '');
$controllerFile = $this->request->post('controllerFile', '');
if (!$table) {
$this->error(__('Parameter error'));
}
AdminLog::instance()->setTitle(__('Generate check'));
try {
$webViewsDir = Helper::parseWebDirNameData($table, 'views', $webViewsDir);
$controllerFile = Helper::parseNameData('admin', $table, 'controller', $controllerFile)['rootFileName'];
} catch (Throwable $e) {
$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) {
$this->error('', [
'menu' => $menuExist,
'table' => $tableExist,
'controller' => $controllerExist,
], -1);
}
$this->success();
}
/**
* CRUD 设计记录上传成功标记
* @throws Throwable
*/
public function uploadCompleted(): void
{
$syncIds = $this->request->post('syncIds/a', []);
$cancelSync = $this->request->post('cancelSync/b', false);
$crudLogModel = new CrudLog();
if ($cancelSync) {
$logData = $crudLogModel->where('id', 'in', array_keys($syncIds))->select();
foreach ($logData as $logDatum) {
if ($logDatum->sync == $syncIds[$logDatum->id]) {
$logDatum->sync = 0;
$logDatum->save();
}
}
$this->success();
}
$saveData = [];
foreach ($syncIds as $key => $syncId) {
$saveData[] = [
'id' => $key,
'sync' => $syncId,
];
}
$crudLogModel->saveAll($saveData);
$this->success();
}
/**
* 关联表数据解析
* @param $field
* @param $table
* @throws Throwable
*/
private function parseJoinData($field, $table): void
{
$dictEn = [];
$dictZhCn = [];
if ($field['form']['relation-fields'] && $field['form']['remote-table']) {
$columns = Helper::parseTableColumns($field['form']['remote-table'], true, $table['databaseConnection']);
$relationFields = explode(',', $field['form']['relation-fields']);
$tableName = TableManager::tableName($field['form']['remote-table'], false, $table['databaseConnection']);
$rnPattern = '/(.*)(_ids|_id)$/';
if (preg_match($rnPattern, $field['name'])) {
$relationName = parse_name(preg_replace($rnPattern, '$1', $field['name']), 1, false);
} else {
$relationName = parse_name($field['name'] . '_table', 1, false);
}
// 建立关联模型代码文件
if (!$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'] = [];
$joinModelData['methods'] = [];
$joinModelData['fieldType'] = [];
$joinModelData['createTime'] = '';
$joinModelData['updateTime'] = '';
$joinModelData['beforeInsertMixins'] = [];
$joinModelData['beforeInsert'] = '';
$joinModelData['afterInsert'] = '';
$joinModelData['connection'] = $table['databaseConnection'];
$joinModelData['name'] = $tableName;
$joinModelData['className'] = $joinModelFile['lastName'];
$joinModelData['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' => $joinFieldsMap[$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
{
// fieldType
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';
}
// beforeInsertMixins
if ($field['designType'] == 'spk') {
$modelData['beforeInsertMixins']['snowflake'] = Helper::assembleStub('mixins/model/mixins/beforeInsertWithSnowflake', []);
}
// methods
$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; // 加宽 dialog
$this->controllerData['filterRule'] = "\n" . Helper::tab(2) . '$this->request->filter(\'clean_xss\');'; // 修改变量过滤规则
}
// 默认排序字段
if ($table['defaultSortField'] && $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'];
}
}
/**
* 组装前台表单的数据
* @throws Throwable
*/
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 ($field['designType'] == 'remoteSelect' || $field['designType'] == '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)';
}
// placeholder
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'];
}
// 无意义的默认值
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';
if (!str_contains($pk, '.')) {
if ($field['form']['remote-source-config-type'] == 'crud' && $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' && $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, $fieldNamePrefix = '', $translationPrefix = ''): array
{
$column = [
'label' => "t('" . $this->webTranslate . $translationPrefix . $field['name'] . "')",
'prop' => $fieldNamePrefix . $field['name'] . ($field['designType'] == 'city' ? '_text' : ''),
'align' => 'center',
];
// 模糊搜索增加一个placeholder
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, $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,37 @@
<?php
namespace app\admin\controller\crud;
use app\admin\model\CrudLog;
use app\common\controller\Backend;
/**
* crud记录
*
*/
class Log extends Backend
{
/**
* Log模型对象
* @var object
* @phpstan-var CrudLog
*/
protected object $model;
protected string|array $preExcludeFields = ['id', 'create_time'];
protected string|array $quickSearchField = ['id', 'table_name', 'comment'];
protected array $noNeedPermission = ['index'];
public function initialize(): void
{
parent::initialize();
$this->model = new CrudLog();
if (!$this->auth->check('crud/crud/index')) {
$this->error(__('You have no permission'), [], 401);
}
}
}

View File

@@ -0,0 +1,90 @@
<?php
namespace app\admin\controller\routine;
use Throwable;
use app\admin\model\Admin;
use app\common\controller\Backend;
class AdminInfo extends Backend
{
/**
* @var object
* @phpstan-var Admin
*/
protected object $model;
protected string|array $preExcludeFields = ['username', 'last_login_time', 'password', 'salt', 'status'];
protected array $authAllowFields = ['id', 'username', 'nickname', 'avatar', 'email', 'mobile', 'motto', 'last_login_time'];
public function initialize(): void
{
parent::initialize();
$this->auth->setAllowFields($this->authAllowFields);
$this->model = $this->auth->getAdmin();
}
public function index(): void
{
$info = $this->auth->getInfo();
$this->success('', [
'info' => $info
]);
}
public function edit(): void
{
$pk = $this->model->getPk();
$id = $this->request->param($pk);
$row = $this->model->find($id);
if (!$row) {
$this->error(__('Record not found'));
}
if ($this->request->isPost()) {
$data = $this->request->post();
if (!$data) {
$this->error(__('Parameter %s can not be empty', ['']));
}
if (!empty($data['avatar'])) {
$row->avatar = $data['avatar'];
if ($row->save()) {
$this->success(__('Avatar modified successfully!'));
}
}
// 数据验证
if ($this->modelValidate) {
try {
$validate = str_replace("\\model\\", "\\validate\\", get_class($this->model));
$validate = new $validate();
$validate->scene('info')->check($data);
} catch (Throwable $e) {
$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();
$this->error($e->getMessage());
}
if ($result !== false) {
$this->success(__('Update successful'));
} else {
$this->error(__('No rows updated'));
}
}
}
}

View File

@@ -0,0 +1,59 @@
<?php
namespace app\admin\controller\routine;
use Throwable;
use app\common\controller\Backend;
use app\common\model\Attachment as AttachmentModel;
class Attachment extends Backend
{
/**
* @var object
* @phpstan-var AttachmentModel
*/
protected object $model;
protected string|array $quickSearchField = 'name';
protected array $withJoinTable = ['admin', 'user'];
protected string|array $defaultSortField = 'last_upload_time,desc';
public function initialize(): void
{
parent::initialize();
$this->model = new AttachmentModel();
}
/**
* 删除
* @throws Throwable
*/
public function del(): void
{
$where = [];
$dataLimitAdminIds = $this->getDataLimitAdminIds();
if ($dataLimitAdminIds) {
$where[] = [$this->dataLimitField, 'in', $dataLimitAdminIds];
}
$ids = $this->request->param('ids/a', []);
$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) {
$this->error(__('%d records and files have been deleted', [$count]) . $e->getMessage());
}
if ($count) {
$this->success(__('%d records and files have been deleted', [$count]));
} else {
$this->error(__('No rows were deleted'));
}
}
}

View File

@@ -0,0 +1,246 @@
<?php
namespace app\admin\controller\routine;
use Throwable;
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;
class Config extends Backend
{
/**
* @var object
* @phpstan-var ConfigModel
*/
protected object $model;
protected array $filePath = [
'appConfig' => 'config/app.php',
'webAdminBase' => 'web/src/router/static/adminBase.ts',
'backendEntranceStub' => 'app/admin/library/stubs/backendEntrance.stub',
];
public function initialize(): void
{
parent::initialize();
$this->model = new ConfigModel();
}
public function index(): void
{
$configGroup = get_sys_config('config_group');
$config = $this->model->order('weigh desc')->select()->toArray();
$list = [];
$newConfigGroup = [];
foreach ($configGroup as $item) {
$list[$item['key']]['name'] = $item['key'];
$list[$item['key']]['title'] = __($item['value']);
$newConfigGroup[$item['key']] = $list[$item['key']]['title'];
}
foreach ($config as $item) {
if (array_key_exists($item['group'], $newConfigGroup)) {
$item['title'] = __($item['title']);
$list[$item['group']]['list'][] = $item;
}
}
$this->success('', [
'list' => $list,
'remark' => get_route_remark(),
'configGroup' => $newConfigGroup ?? [],
'quickEntrance' => get_sys_config('config_quick_entrance'),
]);
}
/**
* 编辑
* @throws Throwable
*/
public function edit(): void
{
$all = $this->model->select();
foreach ($all as $item) {
if ($item['type'] == 'editor') {
$this->request->filter('clean_xss');
break;
}
}
if ($this->request->isPost()) {
$this->modelValidate = false;
$data = $this->request->post();
if (!$data) {
$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]
];
// 自定义后台入口
if ($item->name == 'backend_entrance') {
$backendEntrance = get_sys_config('backend_entrance');
if ($backendEntrance == $data[$item->name]) continue;
if (!preg_match("/^\/[a-zA-Z0-9]+$/", $data[$item->name])) {
$this->error(__('Backend entrance rule'));
}
// 修改 adminBaseRoutePath
$adminBaseFilePath = Filesystem::fsFit(root_path() . $this->filePath['webAdminBase']);
$adminBaseContent = @file_get_contents($adminBaseFilePath);
if (!$adminBaseContent) $this->error(__('Configuration write failed: %s', [$this->filePath['webAdminBase']]));
$adminBaseContent = str_replace("export const adminBaseRoutePath = '$backendEntrance'", "export const adminBaseRoutePath = '{$data[$item->name]}'", $adminBaseContent);
$result = @file_put_contents($adminBaseFilePath, $adminBaseContent);
if (!$result) $this->error(__('Configuration write failed: %s', [$this->filePath['webAdminBase']]));
// 去除后台入口开头的斜杠
$oldBackendEntrance = ltrim($backendEntrance, '/');
$newBackendEntrance = ltrim($data[$item->name], '/');
// 设置应用别名映射
$appMap = config('app.app_map');
$adminMapKey = array_search('admin', $appMap);
if ($adminMapKey !== false) {
unset($appMap[$adminMapKey]);
}
if ($newBackendEntrance != 'admin') {
$appMap[$newBackendEntrance] = 'admin';
}
$appConfigFilePath = Filesystem::fsFit(root_path() . $this->filePath['appConfig']);
$appConfigContent = @file_get_contents($appConfigFilePath);
if (!$appConfigContent) $this->error(__('Configuration write failed: %s', [$this->filePath['appConfig']]));
$appMapStr = '';
foreach ($appMap as $newAppName => $oldAppName) {
$appMapStr .= "'$newAppName' => '$oldAppName', ";
}
$appMapStr = rtrim($appMapStr, ', ');
$appMapStr = "[$appMapStr]";
$appConfigContent = preg_replace("/'app_map'(\s+)=>(\s+)(.*)\/\/ 域名/s", "'app_map'\$1=>\$2$appMapStr,\n // 域名", $appConfigContent);
$result = @file_put_contents($appConfigFilePath, $appConfigContent);
if (!$result) $this->error(__('Configuration write failed: %s', [$this->filePath['appConfig']]));
// 建立API入口文件
$oldBackendEntranceFile = Filesystem::fsFit(public_path() . $oldBackendEntrance . '.php');
$newBackendEntranceFile = Filesystem::fsFit(public_path() . $newBackendEntrance . '.php');
if (file_exists($oldBackendEntranceFile)) @unlink($oldBackendEntranceFile);
if ($newBackendEntrance != 'admin') {
$backendEntranceStub = @file_get_contents(Filesystem::fsFit(root_path() . $this->filePath['backendEntranceStub']));
if (!$backendEntranceStub) $this->error(__('Configuration write failed: %s', [$this->filePath['backendEntranceStub']]));
$result = @file_put_contents($newBackendEntranceFile, $backendEntranceStub);
if (!$result) $this->error(__('Configuration write failed: %s', [$newBackendEntranceFile]));
}
}
}
}
$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);
}
}
$result = $this->model->saveAll($configValue);
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
$this->error($e->getMessage());
}
if ($result !== false) {
$this->success(__('The current page configuration item was updated successfully'));
} else {
$this->error(__('No rows updated'));
}
}
}
public function add(): void
{
if ($this->request->isPost()) {
$data = $this->request->post();
if (!$data) {
$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('add');
$validate->check($data);
}
}
$result = $this->model->save($data);
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
$this->error($e->getMessage());
}
if ($result !== false) {
$this->success(__('Added successfully'));
} else {
$this->error(__('No rows were added'));
}
}
$this->error(__('Parameter error'));
}
/**
* 发送邮件测试
* @throws Throwable
*/
public function sendTestMail(): void
{
$data = $this->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'];
$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) {
$this->error($mail->ErrorInfo);
}
$this->success(__('Test mail sent successfully~'));
}
}

View File

@@ -0,0 +1,150 @@
<?php
namespace app\admin\controller\security;
use Throwable;
use app\common\controller\Backend;
use app\admin\model\DataRecycle as DataRecycleModel;
class DataRecycle extends Backend
{
/**
* @var object
* @phpstan-var DataRecycleModel
*/
protected object $model;
// 排除字段
protected string|array $preExcludeFields = ['update_time', 'create_time'];
protected string|array $quickSearchField = 'name';
public function initialize(): void
{
parent::initialize();
$this->model = new DataRecycleModel();
}
/**
* 添加
* @throws Throwable
*/
public function add(): void
{
if ($this->request->isPost()) {
$data = $this->request->post();
if (!$data) {
$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) {
$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();
$this->error($e->getMessage());
}
if ($result !== false) {
$this->success(__('Added successfully'));
} else {
$this->error(__('No rows were added'));
}
}
// 放在add方法内就不需要额外添加权限节点了
$this->success('', [
'controllers' => $this->getControllerList(),
]);
}
/**
* 编辑
* @throws Throwable
*/
public function edit(): void
{
$pk = $this->model->getPk();
$id = $this->request->param($pk);
$row = $this->model->find($id);
if (!$row) {
$this->error(__('Record not found'));
}
if ($this->request->isPost()) {
$data = $this->request->post();
if (!$data) {
$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) {
$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);
}
}
$result = $row->save($data);
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
$this->error($e->getMessage());
}
if ($result !== false) {
$this->success(__('Update successful'));
} else {
$this->error(__('No rows updated'));
}
}
$this->success('', [
'row' => $row
]);
}
protected 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,106 @@
<?php
namespace app\admin\controller\security;
use Throwable;
use ba\TableManager;
use think\facade\Db;
use app\common\controller\Backend;
use app\admin\model\DataRecycleLog as DataRecycleLogModel;
class DataRecycleLog extends Backend
{
/**
* @var object
* @phpstan-var DataRecycleLogModel
*/
protected object $model;
// 排除字段
protected string|array $preExcludeFields = [];
protected string|array $quickSearchField = 'recycle.name';
protected array $withJoinTable = ['recycle', 'admin'];
public function initialize(): void
{
parent::initialize();
$this->model = new DataRecycleLogModel();
}
/**
* 还原
* @throws Throwable
*/
public function restore(): void
{
$ids = $this->request->param('ids/a', []);
$data = $this->model->where('id', 'in', $ids)->select();
if (!$data) {
$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();
$this->error($e->getMessage());
}
if ($count) {
$this->success();
} else {
$this->error(__('No rows were restore'));
}
}
/**
* 详情
* @throws Throwable
*/
public function info(): void
{
$pk = $this->model->getPk();
$id = $this->request->param($pk);
$row = $this->model
->withJoin($this->withJoinTable, $this->withJoinType)
->where('data_recycle_log.id', $id)
->find();
if (!$row) {
$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;
$this->success('', [
'row' => $row
]);
}
protected function jsonToArray($value = '')
{
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,204 @@
<?php
namespace app\admin\controller\security;
use Throwable;
use app\common\controller\Backend;
use app\admin\model\SensitiveData as SensitiveDataModel;
class SensitiveData extends Backend
{
/**
* @var object
* @phpstan-var SensitiveDataModel
*/
protected object $model;
// 排除字段
protected string|array $preExcludeFields = ['update_time', 'create_time'];
protected string|array $quickSearchField = 'controller';
public function initialize(): void
{
parent::initialize();
$this->model = new SensitiveDataModel();
}
/**
* 查看
* @throws Throwable
*/
public function index(): void
{
if ($this->request->param('select')) {
$this->select();
}
list($where, $alias, $limit, $order) = $this->queryBuilder();
$res = $this->model
->withJoin($this->withJoinTable, $this->withJoinType)
->alias($alias)
->where($where)
->order($order)
->paginate($limit);
foreach ($res->items() as $item) {
if ($item->data_fields) {
$fields = [];
foreach ($item->data_fields as $key => $field) {
$fields[] = $field ?: $key;
}
$item->data_fields = $fields;
}
}
$this->success('', [
'list' => $res->items(),
'total' => $res->total(),
'remark' => get_route_remark(),
]);
}
/**
* 添加重写
* @throws Throwable
*/
public function add(): void
{
if ($this->request->isPost()) {
$data = $this->request->post();
if (!$data) {
$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) {
$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);
}
}
if (is_array($data['fields'])) {
$data['data_fields'] = [];
foreach ($data['fields'] as $field) {
$data['data_fields'][$field['name']] = $field['value'];
}
}
$result = $this->model->save($data);
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
$this->error($e->getMessage());
}
if ($result !== false) {
$this->success(__('Added successfully'));
} else {
$this->error(__('No rows were added'));
}
}
// 放在add方法内就不需要额外添加权限节点了
$this->success('', [
'controllers' => $this->getControllerList(),
]);
}
/**
* 编辑重写
* @throws Throwable
*/
public function edit(): void
{
$pk = $this->model->getPk();
$id = $this->request->param($pk);
$row = $this->model->find($id);
if (!$row) {
$this->error(__('Record not found'));
}
if ($this->request->isPost()) {
$data = $this->request->post();
if (!$data) {
$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) {
$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 (is_array($data['fields'])) {
$data['data_fields'] = [];
foreach ($data['fields'] as $field) {
$data['data_fields'][$field['name']] = $field['value'];
}
}
$result = $row->save($data);
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
$this->error($e->getMessage());
}
if ($result !== false) {
$this->success(__('Update successful'));
} else {
$this->error(__('No rows updated'));
}
}
$this->success('', [
'row' => $row,
'controllers' => $this->getControllerList(),
]);
}
protected 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,117 @@
<?php
namespace app\admin\controller\security;
use Throwable;
use ba\TableManager;
use think\facade\Db;
use app\common\controller\Backend;
use app\admin\model\SensitiveDataLog as SensitiveDataLogModel;
class SensitiveDataLog extends Backend
{
/**
* @var object
* @phpstan-var SensitiveDataLogModel
*/
protected object $model;
// 排除字段
protected string|array $preExcludeFields = [];
protected string|array $quickSearchField = 'sensitive.name';
protected array $withJoinTable = ['sensitive', 'admin'];
public function initialize(): void
{
parent::initialize();
$this->model = new SensitiveDataLogModel();
}
/**
* 查看
* @throws Throwable
*/
public function index(): void
{
if ($this->request->param('select')) {
$this->select();
}
list($where, $alias, $limit, $order) = $this->queryBuilder();
$res = $this->model
->withJoin($this->withJoinTable, $this->withJoinType)
->alias($alias)
->where($where)
->order($order)
->paginate($limit);
foreach ($res->items() as $item) {
$item->id_value = $item['primary_key'] . '=' . $item->id_value;
}
$this->success('', [
'list' => $res->items(),
'total' => $res->total(),
'remark' => get_route_remark(),
]);
}
/**
* 详情
* @throws Throwable
*/
public function info(): void
{
$pk = $this->model->getPk();
$id = $this->request->param($pk);
$row = $this->model
->withJoin($this->withJoinTable, $this->withJoinType)
->where('sensitive_data_log.id', $id)
->find();
if (!$row) {
$this->error(__('Record not found'));
}
$this->success('', [
'row' => $row
]);
}
/**
* 回滚
* @throws Throwable
*/
public function rollback(): void
{
$ids = $this->request->param('ids/a', []);
$data = $this->model->where('id', 'in', $ids)->select();
if (!$data) {
$this->error(__('Record not found'));
}
$count = 0;
$this->model->startTrans();
try {
foreach ($data as $row) {
if (Db::connect(TableManager::getConnection($row->connection))->name($row->data_table)->where($row->primary_key, $row->id_value)->update([
$row->data_field => $row->before
])) {
$row->delete();
$count++;
}
}
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
$this->error($e->getMessage());
}
if ($count) {
$this->success();
} else {
$this->error(__('No rows were rollback'));
}
}
}

View File

@@ -0,0 +1,163 @@
<?php
namespace app\admin\controller\user;
use Throwable;
use app\admin\model\UserRule;
use app\admin\model\UserGroup;
use app\common\controller\Backend;
class Group extends Backend
{
/**
* @var object
* @phpstan-var UserGroup
*/
protected object $model;
// 排除字段
protected string|array $preExcludeFields = ['update_time', 'create_time'];
protected string|array $quickSearchField = 'name';
public function initialize(): void
{
parent::initialize();
$this->model = new UserGroup();
}
/**
* 添加
* @throws Throwable
*/
public function add(): void
{
if ($this->request->isPost()) {
$data = $this->request->post();
if (!$data) {
$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) {
$validate = str_replace("\\model\\", "\\validate\\", get_class($this->model));
if (class_exists($validate)) {
$validate = new $validate();
$validate->scene('add')->check($data);
}
}
$result = $this->model->save($data);
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
$this->error($e->getMessage());
}
if ($result !== false) {
$this->success(__('Added successfully'));
} else {
$this->error(__('No rows were added'));
}
}
$this->error(__('Parameter error'));
}
/**
* 编辑
* @throws Throwable
*/
public function edit(): void
{
$pk = $this->model->getPk();
$id = $this->request->param($pk);
$row = $this->model->find($id);
if (!$row) {
$this->error(__('Record not found'));
}
if ($this->request->isPost()) {
$data = $this->request->post();
if (!$data) {
$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) {
$validate = str_replace("\\model\\", "\\validate\\", get_class($this->model));
if (class_exists($validate)) {
$validate = new $validate();
$validate->scene('edit')->check($data);
}
}
$result = $row->save($data);
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
$this->error($e->getMessage());
}
if ($result !== false) {
$this->success(__('Update successful'));
} else {
$this->error(__('No rows updated'));
}
}
// 读取所有pid全部从节点数组移除父级选择状态由子级决定
$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);
$this->success('', [
'row' => $row
]);
}
/**
* 权限规则入库前处理
* @param array $data 接受到的数据
* @return array
* @throws Throwable
*/
private function handleRules(array &$data): array
{
if (is_array($data['rules']) && $data['rules']) {
$rules = UserRule::select();
$super = true;
foreach ($rules as $rule) {
if (!in_array($rule['id'], $data['rules'])) {
$super = false;
}
}
if ($super) {
$data['rules'] = '*';
} else {
$data['rules'] = implode(',', $data['rules']);
}
} else {
unset($data['rules']);
}
return $data;
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace app\admin\controller\user;
use Throwable;
use app\admin\model\User;
use app\admin\model\UserMoneyLog;
use app\common\controller\Backend;
class MoneyLog extends Backend
{
/**
* @var object
* @phpstan-var UserMoneyLog
*/
protected object $model;
protected array $withJoinTable = ['user'];
// 排除字段
protected string|array $preExcludeFields = ['create_time'];
protected string|array $quickSearchField = ['user.username', 'user.nickname'];
public function initialize(): void
{
parent::initialize();
$this->model = new UserMoneyLog();
}
/**
* 添加
* @param int $userId
* @throws Throwable
*/
public function add(int $userId = 0): void
{
if ($this->request->isPost()) {
parent::add();
}
$user = User::where('id', $userId)->find();
if (!$user) {
$this->error(__("The user can't find it"));
}
$this->success('', [
'user' => $user
]);
}
}

View File

@@ -0,0 +1,260 @@
<?php
namespace app\admin\controller\user;
use ba\Tree;
use Throwable;
use app\admin\model\UserRule;
use app\admin\model\UserGroup;
use app\common\controller\Backend;
class Rule extends Backend
{
/**
* @var object
* @phpstan-var UserRule
*/
protected object $model;
/**
* @var Tree
*/
protected Tree $tree;
protected string|array $preExcludeFields = ['create_time', 'update_time'];
protected string|array $defaultSortField = ['weigh' => 'desc'];
protected string|array $quickSearchField = 'title';
/**
* 远程select初始化传值
* @var array
*/
protected array $initValue;
/**
* 是否组装Tree
* @var bool
*/
protected bool $assembleTree;
/**
* 搜索关键词
* @var string
*/
protected string $keyword;
public function initialize(): void
{
parent::initialize();
// 防止 URL 中的特殊符号被默认的 filter 函数转义
$this->request->filter('clean_xss');
$this->model = new UserRule();
$this->tree = Tree::instance();
$isTree = $this->request->param('isTree', true);
$this->initValue = $this->request->get("initValue/a", []);
$this->initValue = array_filter($this->initValue);
$this->keyword = $this->request->request('quickSearch', '');
$this->assembleTree = $isTree && !$this->initValue; // 有初始化值时不组装树状(初始化出来的值更好看)
}
public function index(): void
{
if ($this->request->param('select')) {
$this->select();
}
$this->success('', [
'list' => $this->getRules(),
'remark' => get_route_remark(),
]);
}
/**
* 添加
*/
public function add(): void
{
if ($this->request->isPost()) {
$data = $this->request->post();
if (!$data) {
$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'])) {
$groups = UserGroup::where('rules', '<>', '*')->select();
foreach ($groups as $group) {
$rules = explode(',', $group->rules);
if (in_array($data['pid'], $rules) && !in_array($this->model->id, $rules)) {
$rules[] = $this->model->id;
$group->rules = implode(',', $rules);
$group->save();
}
}
}
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
$this->error($e->getMessage());
}
if ($result !== false) {
$this->success(__('Added successfully'));
} else {
$this->error(__('No rows were added'));
}
}
$this->error(__('Parameter error'));
}
/**
* 编辑
* @throws Throwable
*/
public function edit(): void
{
$id = $this->request->param($this->model->getPk());
$row = $this->model->find($id);
if (!$row) {
$this->error(__('Record not found'));
}
$dataLimitAdminIds = $this->getDataLimitAdminIds();
if ($dataLimitAdminIds && !in_array($row[$this->dataLimitField], $dataLimitAdminIds)) {
$this->error(__('You have no permission'));
}
if ($this->request->isPost()) {
$data = $this->request->post();
if (!$data) {
$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['pid'] == $row['id']) {
$parent->pid = 0;
$parent->save();
}
}
$result = $row->save($data);
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
$this->error($e->getMessage());
}
if ($result !== false) {
$this->success(__('Update successful'));
} else {
$this->error(__('No rows updated'));
}
}
$this->success('', [
'row' => $row
]);
}
/**
* 删除
* @throws Throwable
*/
public function del(): void
{
$ids = $this->request->param('ids/a', []);
// 子级元素检查
$subData = $this->model->where('pid', 'in', $ids)->column('pid', 'id');
foreach ($subData as $key => $subDatum) {
if (!in_array($key, $ids)) {
$this->error(__('Please delete the child element first, or use batch deletion'));
}
}
parent::del();
}
/**
* 远程下拉
* @throws Throwable
*/
public function select(): void
{
$data = $this->getRules([['status', '=', 1]]);
if ($this->assembleTree) {
$data = $this->tree->assembleTree($this->tree->getTreeArray($data, 'title'));
}
$this->success('', [
'options' => $data
]);
}
/**
* 获取菜单规则
* @throws Throwable
*/
private function getRules(array $where = []): array
{
$pk = $this->model->getPk();
$initKey = $this->request->get("initKey/s", $pk);
if ($this->keyword) {
$keyword = explode(' ', $this->keyword);
foreach ($keyword as $item) {
$where[] = [$this->quickSearchField, 'like', '%' . $item . '%'];
}
}
if ($this->initValue) {
$where[] = [$initKey, 'in', $this->initValue];
}
$data = $this->model
->where($where)
->order($this->queryOrderBuilder())
->select()
->toArray();
return $this->assembleTree ? $this->tree->assembleChild($data) : $data;
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace app\admin\controller\user;
use Throwable;
use app\admin\model\User;
use app\admin\model\UserScoreLog;
use app\common\controller\Backend;
class ScoreLog extends Backend
{
/**
* @var object
* @phpstan-var UserScoreLog
*/
protected object $model;
protected array $withJoinTable = ['user'];
// 排除字段
protected string|array $preExcludeFields = ['create_time'];
protected string|array $quickSearchField = ['user.username', 'user.nickname'];
public function initialize(): void
{
parent::initialize();
$this->model = new UserScoreLog();
}
/**
* 添加
* @param int $userId
* @throws Throwable
*/
public function add(int $userId = 0): void
{
if ($this->request->isPost()) {
parent::add();
}
$user = User::where('id', $userId)->find();
if (!$user) {
$this->error(__("The user can't find it"));
}
$this->success('', [
'user' => $user
]);
}
}

View File

@@ -0,0 +1,156 @@
<?php
namespace app\admin\controller\user;
use Throwable;
use app\common\controller\Backend;
use app\admin\model\User as UserModel;
class User extends Backend
{
/**
* @var object
* @phpstan-var UserModel
*/
protected object $model;
protected array $withJoinTable = ['userGroup'];
// 排除字段
protected string|array $preExcludeFields = ['last_login_time', 'login_failure', 'password', 'salt'];
protected string|array $quickSearchField = ['username', 'nickname', 'id'];
public function initialize(): void
{
parent::initialize();
$this->model = new UserModel();
}
/**
* 查看
* @throws Throwable
*/
public function index(): void
{
if ($this->request->param('select')) {
$this->select();
}
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);
$this->success('', [
'list' => $res->items(),
'total' => $res->total(),
'remark' => get_route_remark(),
]);
}
/**
* 添加
* @throws Throwable
*/
public function add(): void
{
if ($this->request->isPost()) {
$data = $this->request->post();
if (!$data) {
$this->error(__('Parameter %s can not be empty', ['']));
}
$result = false;
$passwd = $data['password']; // 密码将被排除不直接入库
$data = $this->excludeFields($data);
$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();
if (!empty($passwd)) {
$this->model->resetPassword($this->model->id, $passwd);
}
} catch (Throwable $e) {
$this->model->rollback();
$this->error($e->getMessage());
}
if ($result !== false) {
$this->success(__('Added successfully'));
} else {
$this->error(__('No rows were added'));
}
}
$this->error(__('Parameter error'));
}
/**
* 编辑
* @throws Throwable
*/
public function edit(): void
{
$pk = $this->model->getPk();
$id = $this->request->param($pk);
$row = $this->model->find($id);
if (!$row) {
$this->error(__('Record not found'));
}
if ($this->request->isPost()) {
$password = $this->request->post('password', '');
if ($password) {
$this->model->resetPassword($id, $password);
}
parent::edit();
}
unset($row->salt);
$row->password = '';
$this->success('', [
'row' => $row
]);
}
/**
* 重写select
* @throws Throwable
*/
public function select(): void
{
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);
foreach ($res as $re) {
$re->nickname_text = $re->username . '(ID:' . $re->id . ')';
}
$this->success('', [
'list' => $res->items(),
'total' => $res->total(),
'remark' => get_route_remark(),
]);
}
}

16
app/admin/event.php Normal file
View File

@@ -0,0 +1,16 @@
<?php
// 事件定义文件
return [
'bind' => [
],
'listen' => [
'AppInit' => [],
'HttpRun' => [],
'HttpEnd' => [],
'LogLevel' => [],
'LogWrite' => [],
'backendInit' => [app\common\event\Security::class],
],
'subscribe' => [
],
];

38
app/admin/lang/en.php Normal file
View File

@@ -0,0 +1,38 @@
<?php
return [
'Please login first' => 'Please login first',
'You have no permission' => 'You have no permission to operate',
'Username' => 'Username',
'Password' => 'Password',
'Nickname' => 'Nickname',
'Email' => 'Email',
'Mobile' => 'Mobile Number',
'Captcha' => 'Captcha',
'CaptchaId' => 'Captcha Id',
'Please enter the correct verification code' => 'Please enter the correct Captcha!',
'Captcha error' => 'Captcha error!',
'Parameter %s can not be empty' => 'Parameter %s can not be empty',
'Record not found' => 'Record not found',
'No rows were added' => 'No rows were added',
'No rows were deleted' => 'No rows were deleted',
'No rows updated' => 'No rows updated',
'Update successful' => 'Update successful!',
'Added successfully' => 'Added successfully!',
'Deleted successfully' => 'Deleted successfully!',
'Parameter error' => 'Parameter error!',
'File uploaded successfully' => 'File uploaded successfully',
'No files were uploaded' => 'No files were uploaded',
'The uploaded file format is not allowed' => 'The uploaded file format is no allowance.',
'The uploaded image file is not a valid image' => 'The uploaded image file is not a valid image',
'The uploaded file is too large (%sMiB), Maximum file size:%sMiB' => 'The uploaded file is too large (%sMiB), maximum file size:%sMiB',
'No files have been uploaded or the file size exceeds the upload limit of the server' => 'No files have been uploaded or the file size exceeds the server upload limit.',
'Unknown' => 'Unknown',
'Account not exist' => 'Account does not exist',
'Account disabled' => 'Account is disabled',
'Token login failed' => 'Token login failed',
'Username is incorrect' => 'Incorrect username',
'Please try again after 1 day' => 'The number of login failures exceeds the limit, please try again after 24 hours',
'Password is incorrect' => 'Wrong password',
'You are not logged in' => 'You are not logged in',
'Cache cleaned~' => 'The cache has been cleaned up, please refresh the page.',
];

View File

@@ -0,0 +1,9 @@
<?php
return [
'Failed to switch package manager. Please modify the configuration file manually:%s' => 'Failed to switch package manager, please modify the configuration file manually:%s',
'Failed to modify the terminal configuration. Please modify the configuration file manually:%s' => 'Failed to modify the terminal configuration, please modify the configuration file manually:%s',
'upload' => 'Upload files',
'Change terminal config' => 'Modify terminal configuration',
'Clear cache' => 'Clear cache',
'Data table does not exist' => 'Data table does not exist',
];

View File

@@ -0,0 +1,5 @@
<?php
return [
'Group Name Arr' => 'Administrator Grouping ',
'Please use another administrator account to disable the current account!' => 'Disable the current account, please use another administrator account!',
];

View File

@@ -0,0 +1,6 @@
<?php
return [
'Super administrator' => 'Super administrator',
'No permission' => 'No permission',
'You cannot modify your own management group!' => 'You cannot modify your own management group!',
];

View File

@@ -0,0 +1,6 @@
<?php
return [
'type' => 'Rule type',
'title' => 'Rule title',
'name' => 'Rule name',
];

View File

@@ -0,0 +1,7 @@
<?php
return [
'change-field-name fail not exist' => 'Field %s failed to be renamed because the field does not exist in the data table',
'del-field fail not exist' => 'Failed to delete field %s because the field does not exist in the data table',
'change-field-attr fail not exist' => 'Description Failed to modify the properties of field %s because the field does not exist in the data table',
'add-field fail exist' => 'Failed to add field %s because the field already exists in the data table',
];

View File

@@ -0,0 +1,4 @@
<?php
return [
'Remark lang' => "Open source equals mutual assistance, and needs everyone's support. There are many ways to support it, such as using, recommending, writing tutorials, protecting the ecology, contributing code, answering questions, sharing experiences, donation, sponsorship and so on. Welcome to join us!",
];

View File

@@ -0,0 +1,9 @@
<?php
return [
'No background menu, please contact super administrator!' => 'No background menu, please contact the super administrator!',
'You have already logged in. There is no need to log in again~' => 'You have already logged in, no need to log in again.',
'Login succeeded!' => 'Login successful!',
'Incorrect user name or password!' => 'Incorrect username or password!',
'Login' => 'Login',
'Logout' => 'Logout',
];

View File

@@ -0,0 +1,6 @@
<?php
return [
'Please input correct username' => 'Please enter the correct username',
'Please input correct password' => 'Please enter the correct password',
'Avatar modified successfully!' => 'Profile picture modified successfully',
];

View File

@@ -0,0 +1,5 @@
<?php
return [
'%d records and files have been deleted' => '%d records and files have been deleted',
'remark_text' => 'When the same file is uploaded multiple times, only one copy will be saved to the disk and an attachment record will be added; Deleting an attachment record will automatically delete the corresponding file!',
];

View File

@@ -0,0 +1,23 @@
<?php
return [
'Basics' => 'Basic configuration',
'Mail' => 'Mail configuration',
'Config group' => 'Configure grouping',
'Site Name' => 'Site name',
'Config Quick entrance' => 'Quick configuration entrance',
'Record number' => 'Record Number',
'Version number' => 'Version Number',
'time zone' => 'Time zone',
'No access ip' => 'No access IP',
'smtp server' => 'SMTP server',
'smtp port' => 'SMTP port',
'smtp user' => 'SMTP username',
'smtp pass' => 'SMTP password',
'smtp verification' => 'SMTP verification mode',
'smtp sender mail' => 'SMTP sender mailbox',
'Variable name' => 'variable name',
'Test mail sent successfully~' => 'Test message sent successfully',
'This is a test email' => 'This is a test email',
'Congratulations, receiving this email means that your email service has been configured correctly' => "Congratulations, when you receive this email, it means that your mail service is configures correctly. This is the email subject, <b>you can use HtmlL!</b> in the main body.",
'Backend entrance rule' => 'The background entry must start with / and contain only numbers and letters.',
];

View File

@@ -0,0 +1,7 @@
<?php
return [
'Name' => 'Rule Name',
'Controller' => 'Controller',
'Data Table' => 'Corresponding data table',
'Primary Key' => 'Data table primary key',
];

View File

@@ -0,0 +1,4 @@
<?php
return [
'No rows were restore' => 'No records have been restored',
];

View File

@@ -0,0 +1,8 @@
<?php
return [
'Name' => 'Rule name',
'Controller' => 'Controller',
'Data Table' => 'Corresponding data table',
'Primary Key' => 'Data table primary key',
'Data Fields' => 'Sensitive data fields',
];

View File

@@ -0,0 +1,4 @@
<?php
return [
'No rows were rollback' => 'No records have been roll-back',
];

View File

@@ -0,0 +1,8 @@
<?php
return [
'user_id' => 'User',
'money' => 'Change amount',
'memo' => 'Change Notes',
"The user can't find it" => "User does not exist",
'Change note cannot be blank' => 'Change Notes cannot be empty',
];

View File

@@ -0,0 +1,8 @@
<?php
return [
'user_id' => 'User',
'score' => 'Change points',
'memo' => 'Change Notes',
"The user can't find it" => 'User does not exist',
'Change note cannot be blank' => 'Change notes cannot be empty',
];

62
app/admin/lang/zh-cn.php Normal file
View File

@@ -0,0 +1,62 @@
<?php
return [
'Please login first' => '请先登录!',
'You have no permission' => '没有权限操作!',
'Username' => '用户名',
'Password' => '密码',
'Nickname' => '昵称',
'Email' => '邮箱',
'Mobile' => '手机号',
'Captcha' => '验证码',
'CaptchaId' => '验证码ID',
'Please enter the correct verification code' => '请输入正确的验证码!',
'Captcha error' => '验证码错误!',
'Parameter %s can not be empty' => '参数%s不能为空',
'Record not found' => '记录未找到',
'No rows were added' => '未添加任何行',
'No rows were deleted' => '未删除任何行',
'No rows updated' => '未更新任何行',
'Update successful' => '更新成功!',
'Added successfully' => '添加成功!',
'Deleted successfully' => '删除成功!',
'Parameter error' => '参数错误!',
'Please use the %s field to sort before operating' => '请使用 %s 字段排序后再操作',
'File uploaded successfully' => '文件上传成功!',
'No files were uploaded' => '没有文件被上传',
'The uploaded file format is not allowed' => '上传的文件格式未被允许',
'The uploaded image file is not a valid image' => '上传的图片文件不是有效的图像',
'The uploaded file is too large (%sMiB), Maximum file size:%sMiB' => '上传的文件太大(%sM),最大文件大小:%sM',
'No files have been uploaded or the file size exceeds the upload limit of the server' => '没有文件被上传或文件大小超出服务器上传限制!',
'Topic format error' => '上传存储子目录格式错误!',
'Driver %s not supported' => '不支持的驱动:%s',
'Unknown' => '未知',
// 权限类语言包-s
'Account not exist' => '帐户不存在',
'Account disabled' => '帐户已禁用',
'Token login failed' => '令牌登录失败',
'Username is incorrect' => '用户名不正确',
'Please try again after 1 day' => '登录失败次数超限请在1天后再试',
'Password is incorrect' => '密码不正确',
'You are not logged in' => '你没有登录',
// 权限类语言包-e
// 时间格式化-s
'%d second%s ago' => '%d秒前',
'%d minute%s ago' => '%d分钟前',
'%d hour%s ago' => '%d小时前',
'%d day%s ago' => '%d天前',
'%d week%s ago' => '%d周前',
'%d month%s ago' => '%d月前',
'%d year%s ago' => '%d年前',
'%d second%s after' => '%d秒后',
'%d minute%s after' => '%d分钟后',
'%d hour%s after' => '%d小时后',
'%d day%s after' => '%d天后',
'%d week%s after' => '%d周后',
'%d month%s after' => '%d月后',
'%d year%s after' => '%d年后',
// 时间格式化-e
'Cache cleaned~' => '缓存已清理,请刷新后台~',
'Please delete the child element first, or use batch deletion' => '请首先删除子元素,或使用批量删除!',
'Configuration write failed: %s' => '配置写入失败:%s',
'Token expiration' => '登录态过期,请重新登录!',
];

View File

@@ -0,0 +1,12 @@
<?php
return [
'Start the database migration' => '开始进行数据库迁移',
'Start formatting the web project code' => '开始格式化前端代码(失败无影响,代码编辑器内按需的手动格式化即可)',
'Start installing the composer dependencies' => '开始安装服务端依赖',
'Start executing the build command of the web project' => '开始执行 web 工程的 build 命令,成功后会自动将构建产物移动至 根目录/public 目录下',
'Failed to modify the terminal configuration. Please modify the configuration file manually:%s' => '修改终端配置失败,请手动修改配置文件:%s',
'upload' => '上传文件',
'Change terminal config' => '修改终端配置',
'Clear cache' => '清理缓存',
'Data table does not exist' => '数据表不存在~',
];

View File

@@ -0,0 +1,6 @@
<?php
return [
'Group Name Arr' => '管理员分组',
'Please use another administrator account to disable the current account!' => '请使用另外的管理员账户禁用当前账户!',
'You have no permission to add an administrator to this group!' => '您没有权限向此分组添加管理员!',
];

View File

@@ -0,0 +1,13 @@
<?php
return [
'name' => '组别名称',
'Please select rules' => '请选择权限',
'Super administrator' => '超级管理员',
'No permission' => '无权限',
'You cannot modify your own management group!' => '不能修改自己所在的管理组!',
'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~' => '您需要拥有该分组的所有权限且还有额外权限时,才可以操作该分组~',
'Role group has all your rights, please contact the upper administrator to add or do not need to add!' => '角色组拥有您的全部权限,请联系上级管理员添加或无需添加!',
'The group permission node exceeds the range that can be allocated' => '分组权限节点超出可分配范围,请刷新重试~',
'Remark lang' => '为保障系统安全,角色组本身的上下级关系仅供参考,系统的实际上下级划分是根据`权限多寡`来确定的,两位管理员的权限节点:相同被认为是`同级`、包含且有额外权限才被认为是`上级`,同级不可管理同级,上级可为下级分配自己拥有的权限节点;若有特殊情况管理员需转`上级`,可建立一个虚拟权限节点',
];

View File

@@ -0,0 +1,6 @@
<?php
return [
'type' => '规则类型',
'title' => '规则标题',
'name' => '规则名称',
];

View File

@@ -0,0 +1,11 @@
<?php
return [
'Parse field data' => 'CRUD代码生成-解析字段数据',
'Log start' => 'CRUD代码生成-从历史记录开始',
'Generate check' => 'CRUD代码生成-生成前预检',
'change-field-name fail not exist' => '字段 %s 改名失败,数据表内不存在该字段',
'del-field fail not exist' => '字段 %s 删除失败,数据表内不存在该字段',
'change-field-attr fail not exist' => '修改字段 %s 的属性失败,数据表内不存在该字段',
'add-field fail exist' => '添加字段 %s 失败,数据表内已经存在该字段',
'Failed to load cloud data' => '加载云端数据失败,请稍后重试!',
];

View File

@@ -0,0 +1,4 @@
<?php
return [
'Remark lang' => '开源等于互助;开源需要大家一起来支持,支持的方式有很多种,比如使用、推荐、写教程、保护生态、贡献代码、回答问题、分享经验、打赏赞助等;欢迎您加入我们!',
];

View File

@@ -0,0 +1,9 @@
<?php
return [
'No background menu, please contact super administrator!' => '无后台菜单,请联系超级管理员!',
'You have already logged in. There is no need to log in again~' => '您已经登录过了,无需重复登录~',
'Login succeeded!' => '登录成功!',
'Incorrect user name or password!' => '用户名或密码不正确!',
'Login' => '登录',
'Logout' => '注销登录',
];

View File

@@ -0,0 +1,29 @@
<?php
return [
'Order not found' => '订单找不到啦!',
'Module already exists' => '模块已存在!',
'package download failed' => '包下载失败!',
'package check failed' => '包检查失败!',
'No permission to write temporary files' => '没有权限写入临时文件!',
'Zip file not found' => '找不到压缩包文件',
'Unable to open the zip file' => '无法打开压缩包文件',
'Unable to extract ZIP file' => '无法提取ZIP文件',
'Unable to package zip file' => '无法打包zip文件',
'Basic configuration of the Module is incomplete' => '模块基础配置不完整',
'Module package file does not exist' => '模块包文件不存在',
'Module file conflicts' => '模块文件存在冲突,请手动处理!',
'Configuration file has no write permission' => '配置文件无写入权限',
'The current state of the module cannot be set to disabled' => '模块当前状态无法设定为禁用',
'The current state of the module cannot be set to enabled' => '模块当前状态无法设定为启用',
'Module file updated' => '模块文件有更新',
'Please disable the module first' => '请先禁用模块',
'Please disable the module before updating' => '更新前请先禁用模块',
'The directory required by the module is occupied' => '模块所需目录已被占用',
'Install module' => '安装模块',
'Unload module' => '卸载模块',
'Update module' => '更新模块',
'Change module state' => '改变模块状态',
'Upload install module' => '上传安装模块',
'Please login to the official website account first' => '请先使用BuildAdmin官网账户登录到模块市场~',
'composer config %s conflict' => 'composer 配置项 %s 存在冲突',
];

View File

@@ -0,0 +1,6 @@
<?php
return [
'Please input correct username' => '请输入正确的用户名',
'Please input correct password' => '请输入正确的密码',
'Avatar modified successfully!' => '头像修改成功!',
];

View File

@@ -0,0 +1,5 @@
<?php
return [
'Remark lang' => '同一文件被多次上传时,只会保存一份至磁盘和增加一条附件记录;删除附件记录,将自动删除对应文件!',
'%d records and files have been deleted' => '删除了%d条记录和文件',
];

View File

@@ -0,0 +1,25 @@
<?php
return [
'Basics' => '基础配置',
'Mail' => '邮件配置',
'Config group' => '配置分组',
'Site Name' => '站点名称',
'Backend entrance' => '自定义后台入口',
'Config Quick entrance' => '快捷配置入口',
'Record number' => '备案号',
'Version number' => '版本号',
'time zone' => '时区',
'No access ip' => '禁止访问IP',
'smtp server' => 'SMTP 服务器',
'smtp port' => 'SMTP 端口',
'smtp user' => 'SMTP 用户名',
'smtp pass' => 'SMTP 密码',
'smtp verification' => 'SMTP 验证方式',
'smtp sender mail' => 'SMTP 发件人邮箱',
'Variable name' => '变量名',
'Test mail sent successfully~' => '测试邮件发送成功~',
'This is a test email' => '这是一封测试邮件',
'Congratulations, receiving this email means that your email service has been configured correctly' => '恭喜您,收到此邮件代表您的邮件服务已配置正确;这是邮件主体 <b>在主体中可以使用Html!</b>',
'The current page configuration item was updated successfully' => '当前页配置项更新成功!',
'Backend entrance rule' => '后台入口请以 / 开头,且只包含数字和字母。',
];

View File

@@ -0,0 +1,8 @@
<?php
return [
'Name' => '规则名称',
'Controller' => '控制器',
'Data Table' => '对应数据表',
'Primary Key' => '数据表主键',
'Remark lang' => '在此定义需要回收的数据,实现数据自动统一回收',
];

View File

@@ -0,0 +1,4 @@
<?php
return [
'No rows were restore' => '没有记录被还原',
];

View File

@@ -0,0 +1,9 @@
<?php
return [
'Name' => '规则名称',
'Controller' => '控制器',
'Data Table' => '对应数据表',
'Primary Key' => '数据表主键',
'Data Fields' => '敏感数据字段',
'Remark lang' => '在此定义需要保护的敏感字段,随后系统将自动监听该字段的修改操作,并提供了敏感字段的修改回滚功能',
];

View File

@@ -0,0 +1,4 @@
<?php
return [
'No rows were rollback' => '没有记录被回滚',
];

View File

@@ -0,0 +1,8 @@
<?php
return [
'user_id' => '用户',
'money' => '变更金额',
'memo' => '变更备注',
"The user can't find it" => '用户找不到啦',
'Change note cannot be blank' => '变更备注不能为空',
];

View File

@@ -0,0 +1,8 @@
<?php
return [
'user_id' => '用户',
'score' => '变更积分',
'memo' => '变更备注',
"The user can't find it" => '用户找不到啦',
'Change note cannot be blank' => '变更备注不能为空',
];

525
app/admin/library/Auth.php Normal file
View File

@@ -0,0 +1,525 @@
<?php
namespace app\admin\library;
use Throwable;
use ba\Random;
use think\facade\Db;
use think\facade\Config;
use app\admin\model\Admin;
use app\common\facade\Token;
use app\admin\model\AdminGroup;
/**
* 管理员权限类
* @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;
/**
* 需要登录标记 - 前台应清理 token、记录当前路由 path、跳转到登录页
*/
public const NEED_LOGIN = 'need login';
/**
* 已经登录标记 - 前台应跳转到基础路由
*/
public const LOGGED_IN = 'logged in';
/**
* token 入库 type
*/
public const TOKEN_TYPE = 'admin';
/**
* 是否登录
* @var bool
*/
protected bool $loginEd = false;
/**
* 错误消息
* @var string
*/
protected string $error = '';
/**
* Model实例
* @var ?Admin
*/
protected ?Admin $model = null;
/**
* 令牌
* @var string
*/
protected string $token = '';
/**
* 刷新令牌
* @var string
*/
protected string $refreshToken = '';
/**
* 令牌默认有效期
* 可在 config/buildadmin.php 内修改默认值
* @var int
*/
protected int $keepTime = 86400;
/**
* 刷新令牌有效期
* @var int
*/
protected int $refreshTokenKeepTime = 2592000;
/**
* 允许输出的字段
* @var array
*/
protected array $allowFields = ['id', 'username', 'nickname', 'avatar', 'last_login_time'];
public function __construct(array $config = [])
{
parent::__construct($config);
$this->setKeepTime((int)Config::get('buildadmin.admin_token_keep_time'));
}
/**
* 魔术方法-管理员信息字段
* @param $name
* @return mixed 字段信息
*/
public function __get($name): mixed
{
return $this->model?->$name;
}
/**
* 初始化
* @access public
* @param array $options 传递到 /ba/Auth 的配置信息
* @return Auth
*/
public static function instance(array $options = []): Auth
{
$request = request();
if (!isset($request->adminAuth)) {
$request->adminAuth = new static($options);
}
return $request->adminAuth;
}
/**
* 根据Token初始化管理员登录态
* @param string $token
* @return bool
* @throws Throwable
*/
public function init(string $token): bool
{
$tokenData = Token::get($token);
if ($tokenData) {
/**
* 过期检查,过期则抛出 @see TokenExpirationException
*/
Token::tokenExpirationCheck($tokenData);
$userId = intval($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;
}
/**
* 管理员登录
* @param string $username 用户名
* @param string $password 密码
* @param bool $keep 是否保持登录
* @return bool
* @throws Throwable
*/
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;
}
// 登录失败重试检查
$lastLoginTime = $this->model->getData('last_login_time');
$adminLoginRetry = Config::get('buildadmin.admin_login_retry');
if ($adminLoginRetry && $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;
}
// 清理 token
if (Config::get('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;
}
/**
* 设置刷新Token
* @param int $keepTime
*/
public function setRefreshToken(int $keepTime = 0): void
{
$this->refreshToken = Random::uuid();
Token::set($this->refreshToken, self::TOKEN_TYPE . '-refresh', $this->model->id, $keepTime);
}
/**
* 管理员登录成功
* @return bool
*/
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 = request()->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;
}
/**
* 管理员登录失败
* @return bool
*/
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 = request()->ip();
$this->model->save();
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
$this->setError($e->getMessage());
return false;
}
return $this->reset();
}
/**
* 退出登录
* @return bool
*/
public function logout(): bool
{
if (!$this->loginEd) {
$this->setError('You are not logged in');
return false;
}
return $this->reset();
}
/**
* 是否登录
* @return bool
*/
public function isLogin(): bool
{
return $this->loginEd;
}
/**
* 获取管理员模型
* @return Admin
*/
public function getAdmin(): Admin
{
return $this->model;
}
/**
* 获取管理员Token
* @return string
*/
public function getToken(): string
{
return $this->token;
}
/**
* 获取管理员刷新Token
* @return string
*/
public function getRefreshToken(): string
{
return $this->refreshToken;
}
/**
* 获取管理员信息 - 只输出允许输出的字段
* @return array
*/
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;
}
/**
* 获取允许输出字段
* @return array
*/
public function getAllowFields(): array
{
return $this->allowFields;
}
/**
* 设置允许输出字段
* @param $fields
* @return void
*/
public function setAllowFields($fields): void
{
$this->allowFields = $fields;
}
/**
* 设置Token有效期
* @param int $keepTime
* @return void
*/
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);
}
/**
* 是否是超级管理员
* @throws Throwable
*/
public function isSuperAdmin(): bool
{
return in_array('*', $this->getRuleIds());
}
/**
* 获取管理员所在分组的所有子级分组
* @return array
* @throws Throwable
*/
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);
}
/**
* 获取一个分组下的子分组
* @param int $groupId 分组ID
* @param array $children 存放子分组的变量
* @return void
* @throws Throwable
*/
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);
}
}
/**
* 获取分组内的管理员
* @param array $groups
* @return array 管理员数组
*/
public function getGroupAdmins(array $groups): array
{
return Db::name('admin_group_access')
->where('group_id', 'in', $groups)
->column('uid');
}
/**
* 获取拥有 `所有权限` 的分组
* @param string $dataLimit 数据权限
* @param array $groupQueryWhere 分组查询条件(默认查询启用的分组:[['status','=',1]]
* @return array 分组数组
* @throws Throwable
*/
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']);
// 及时break, array_diff 等没有 in_array 快
$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;
}
/**
* 设置错误消息
* @param $error
* @return Auth
*/
public function setError($error): Auth
{
$this->error = $error;
return $this;
}
/**
* 获取错误消息
* @return string
*/
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::get('buildadmin.admin_token_keep_time'));
return true;
}
}

View File

@@ -0,0 +1,1299 @@
<?php
namespace app\admin\library\crud;
use Throwable;
use ba\Filesystem;
use think\Exception;
use ba\TableManager;
use think\facade\Db;
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;
class Helper
{
/**
* 内部保留词
* @var array
*/
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',
];
/**
* 预设控制器和模型文件位置
* @var array
*/
protected static array $parseNamePresets = [
'controller' => [
'user' => ['user', 'user'],
'admin' => ['auth', 'admin'],
'admin_group' => ['auth', 'group'],
'attachment' => ['routine', 'attachment'],
'admin_rule' => ['auth', 'rule'],
],
'model' => [],
'validate' => [],
];
/**
* 子级菜单数组(权限节点)
* @var array
*/
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],
];
/**
* 输入框类型的识别规则
* @var array
*/
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',
],
// 富文本-识别规则和textarea重合,优先识别为富文本
[
'type' => ['longtext', 'text', 'mediumtext', 'smalltext', 'tinytext', 'bigtext'],
'suffix' => ['content', 'editor'],
'value' => 'editor',
],
// textarea
[
'type' => ['varchar'],
'suffix' => ['textarea', 'multiline', 'rows'],
'value' => 'textarea',
],
// Array
[
'suffix' => ['array'],
'value' => 'array',
],
// 时间选择器-字段类型为int同时以['time', 'datetime']结尾
[
'type' => ['int'],
'suffix' => ['time', 'datetime'],
'value' => 'timestamp',
],
[
'type' => ['datetime', 'timestamp'],
'value' => 'datetime',
],
[
'type' => ['date'],
'value' => 'date',
],
[
'type' => ['year'],
'value' => 'year',
],
[
'type' => ['time'],
'value' => 'time',
],
// 单选select
[
'suffix' => ['select', 'list', 'data'],
'value' => 'select',
],
// 多选select
[
'suffix' => ['selects', 'multi', 'lists'],
'value' => 'selects',
],
// 远程select
[
'suffix' => ['_id'],
'value' => 'remoteSelect',
],
// 远程selects
[
'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',
],
// icon选择器
[
'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',
],
];
/**
* 预设WEB端文件位置
* @var array
*/
protected static array $parseWebDirPresets = [
'lang' => [],
'views' => [
'user' => ['user', 'user'],
'admin' => ['auth', 'admin'],
'admin_group' => ['auth', 'group'],
'attachment' => ['routine', 'attachment'],
'admin_rule' => ['auth', 'rule'],
],
];
/**
* 添加时间字段
* @var string
*/
protected static string $createTimeField = 'create_time';
/**
* 更新时间字段
* @var string
*/
protected static string $updateTimeField = 'update_time';
/**
* 属性的类型对照表
* @var array
*/
protected static array $attrType = [
'controller' => [
'preExcludeFields' => 'array|string',
'quickSearchField' => 'string|array',
'withJoinTable' => 'array',
'defaultSortField' => 'string|array',
'weighField' => 'string',
],
];
/**
* 获取字段字典数据
* @param array $dict 存储字典数据的变量
* @param array $field 字段数据
* @param string $lang 语言
* @param string $translationPrefix 翻译前缀
*/
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;
}
/**
* 记录CRUD状态
* @param array $data CRUD记录数据
* @return int 记录ID
*/
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('database.default');
$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;
}
/**
* 获取 Phinx 的字段类型数据
* @param string $type 字段类型
* @param array $field 字段数据
* @return array
*/
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];
}
/**
* 分析字段limit和精度
* @param string $type 字段类型
* @param array $field 字段数据
* @return array ['limit' => 10, 'precision' => null, 'scale' => 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']) {
'EMPTY STRING' => '',
'NULL' => null,
default => $field['default'],
};
}
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;
}
/**
* 获取 Phinx 格式的字段数据
* @param array $field
* @return array
*/
public static function getPhinxFieldData(array $field): array
{
$conciseType = self::analyseFieldType($field);
$phinxTypeData = self::getPhinxFieldType($conciseType, $field);
$phinxColumnOptions = self::analyseFieldLimit($conciseType, $field);
if (!is_null($phinxTypeData['limit'])) {
$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'];
$phinxColumnOptions['comment'] = $field['comment'];
$phinxColumnOptions['signed'] = !$field['unsigned'];
$phinxColumnOptions['identity'] = $field['autoIncrement'];
return [
'type' => $phinxTypeData['type'],
'options' => $phinxColumnOptions,
];
}
/**
* 表字段排序
* @param string $tableName 表名
* @param array $fields 字段数据
* @param array $designChange 前端字段改变数据
* @param ?string $connection 数据库连接标识
* @return void
* @throws Throwable
*/
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, function ($field) use ($fieldName) {
return $field['name'] == $fieldName;
});
$phinxFieldData = self::getPhinxFieldData($field);
// 字段顺序调整
if ($item['after'] == 'FIRST FIELD') {
$phinxFieldData['options']['after'] = MysqlAdapter::FIRST;
} else {
$phinxFieldData['options']['after'] = $item['after'];
}
$table->changeColumn($fieldName, $phinxFieldData['type'], $phinxFieldData['options']);
}
}
$table->update();
}
}
/**
* 表设计处理
* @param array $table 表数据
* @param array $fields 字段数据
* @return array
* @throws Throwable
*/
public static function handleTableDesign(array $table, array $fields): array
{
$name = TableManager::tableName($table['name'], true, $table['databaseConnection']);
$comment = $table['comment'] ?? '';
$designChange = $table['designChange'] ?? [];
$adapter = TableManager::phinxAdapter(false, $table['databaseConnection']);
$pk = self::searchArray($fields, function ($item) {
return $item['primaryKey'];
});
$pk = $pk ? $pk['name'] : '';
if ($adapter->hasTable($name)) {
// 更新表
if ($designChange) {
$tableManager = TableManager::phinxTable($name, [], false, $table['databaseConnection']);
$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']]));
}
$phinxFieldData = self::getPhinxFieldData(self::searchArray($fields, function ($field) use ($item) {
return $field['name'] == $item['oldName'];
}));
$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']]));
}
$phinxFieldData = self::getPhinxFieldData(self::searchArray($fields, function ($field) use ($item) {
return $field['name'] == $item['newName'];
}));
$tableManager->addColumn($item['newName'], $phinxFieldData['type'], $phinxFieldData['options']);
}
}
$tableManager->update();
// 表更新结构完成再处理字段排序
self::updateFieldOrder($name, $fields, $designChange, $table['databaseConnection']);
}
} else {
// 创建表
$tableManager = TableManager::phinxTable($name, [
'id' => false,
'comment' => $comment,
'row_format' => 'DYNAMIC',
'primary_key' => $pk,
'collation' => 'utf8mb4_unicode_ci',
], false, $table['databaseConnection']);
foreach ($fields as $field) {
$phinxFieldData = self::getPhinxFieldData($field);
$tableManager->addColumn($field['name'], $phinxFieldData['type'], $phinxFieldData['options']);
}
$tableManager->create();
}
return [$pk];
}
/**
* 解析文件数据
* @throws Throwable
*/
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 = app()->getBasePath() . $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 (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]);
$langDir = ['en', 'zh-cn'];
foreach ($langDir 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;
}
/**
* 获取菜单name、path
* @param array $webDir
* @return string
*/
public static function getMenuName(array $webDir): string
{
return ($webDir['path'] ? implode('/', $webDir['path']) . '/' : '') . $webDir['originalLastName'];
}
/**
* 获取基础模板文件路径
* @param string $name
* @return string
*/
public static function getStubFilePath(string $name): string
{
return app_path() . 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);
}
/**
* 组装模板
* @param string $name
* @param array $data
* @param bool $escape
* @return string
*/
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;
}
/**
* 获取转义编码后的值
* @param array|string $value
* @return string
*/
public static function escape(array|string $value): string
{
if (is_array($value)) {
$value = json_encode($value, JSON_UNESCAPED_UNICODE);
}
return htmlspecialchars($value, ENT_QUOTES, 'UTF-8', false);
}
public static function tab(int $num = 1): string
{
return str_pad('', 4 * $num);
}
/**
* 根据数据表解析字段数据
* @throws Throwable
*/
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';
$columns = [];
$tableColumn = Db::connect($connection)->query($sql, [$connectionConfig['database'], TableManager::tableName($table, true, $connection)]);
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 && is_null($item['COLUMN_DEFAULT'])) {
$defaultType = 'NULL';
} elseif ($item['COLUMN_DEFAULT'] == '' && in_array($item['DATA_TYPE'], ['varchar', 'char'])) {
$defaultType = 'EMPTY STRING';
} elseif (!$isNullAble && is_null($item['COLUMN_DEFAULT'])) {
$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
{
// 预留
}
/**
* 分析字段数据类型
* @param array $field 字段数据
* @return string 字段类型
*/
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);
}
/**
* 分析字段的完整数据类型定义
* @param array $field 字段数据
* @return string
*/
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'])) {
$dataType = "$conciseType({$limit['precision']}, {$limit['scale']})";
} elseif (isset($limit['values'])) {
$values = implode(',', $limit['values']);
$dataType = "$conciseType($values)";
} else {
$dataType = "$conciseType({$limit['limit']})";
}
return $dataType;
}
/**
* 分析字段
*/
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)
{
if (stripos($column['COLUMN_NAME'], 'id') !== false && stripos($column['EXTRA'], 'auto_increment') !== false) {
return 'pk';
} elseif ($column['COLUMN_NAME'] == 'weigh') {
return 'weigh';
} elseif (in_array($column['COLUMN_NAME'], ['createtime', 'updatetime', 'create_time', 'update_time'])) {
return 'timestamp';
}
foreach (self::$inputTypeRule as $item) {
$typeBool = true;
$suffixBool = true;
$columnTypeBool = true;
if (isset($item['type']) && $item['type'] && !in_array($column['DATA_TYPE'], $item['type'])) {
$typeBool = false;
}
if (isset($item['suffix']) && $item['suffix']) {
$suffixBool = self::isMatchSuffix($column['COLUMN_NAME'], $item['suffix']);
}
if (isset($item['column_type']) && $item['column_type'] && !in_array($column['COLUMN_TYPE'], $item['column_type'])) {
$columnTypeBool = false;
}
if ($typeBool && $suffixBool && $columnTypeBool) {
return $item['value'];
}
}
return 'string';
}
/**
* 判断是否符合指定后缀
*
* @param string $field 字段名称
* @param string|array $suffixArr 后缀
* @return bool
*/
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;
}
/**
* 创建菜单
* @throws Throwable
*/
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 '';
$append = self::buildFormatSimpleArray($append);
return "\n" . self::tab() . "// 追加属性" . "\n" . self::tab() . "protected \$append = $append;\n";
}
public static function buildModelFieldType(array $fieldType): string
{
if (!$fieldType) return '';
$maxStrLang = 0;
foreach ($fieldType as $key => $item) {
$strLang = strlen($key);
$maxStrLang = max($strLang, $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('database.default')) {
$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']);
// 生成雪花ID
if (isset($modelData['beforeInsertMixins']['snowflake'])) {
// beforeInsert 组装
$modelData['beforeInsert'] = Helper::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, ', ');
$relationVisibleFields .= '])';
// 重写index
$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 = '';
if (array_key_exists($key, self::$attrType['controller'])) {
$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 = '';
if (isset($field['form']['validatorMsg']) && $field['form']['validatorMsg']) {
$message = ", 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']);
$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, ',');
$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, ',');
$itemJson .= ' },';
} elseif ($item === 'false' || $item === 'true') {
$itemJson = ' ' . $key . ': ' . $item . ',';
} elseif (in_array($key, ['label', 'width', 'buttons'], true) || str_starts_with($item, "t('") || str_starts_with($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;
} else {
$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($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($item);
$str .= "$quote$item$quote, ";
}
}
return '[' . rtrim($str, ", ") . ']';
}
public static function buildDefaultOrder(string $field, string $type): string
{
if ($field && $type) {
$defaultOrderStub = [
'prop' => $field,
'order' => $type,
];
$defaultOrderStub = self::getJsonFromArray($defaultOrderStub);
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($item, "t('") || str_starts_with($item, "t(\"") || $item == '[]' || in_array(gettype($item), ['integer', 'double'])) {
$jsonStr .= $keyStr . $item . ',';
} elseif (isset($item[0]) && $item[0] == '[' && str_ends_with($item, ']')) {
$jsonStr .= $keyStr . $item . ',';
} else {
$quote = self::getQuote($item);
$jsonStr .= $keyStr . "$quote$item$quote,";
}
}
return $jsonStr ? '{' . rtrim($jsonStr, ',') . ' }' : '{}';
} else {
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,971 @@
<?php
namespace app\admin\library\module;
use Throwable;
use ba\Version;
use ba\Depends;
use ba\Exception;
use ba\Filesystem;
use FilesystemIterator;
use think\facade\Config;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
/**
* 模块管理类
*/
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;
/**
* @var ?Manage 对象实例
*/
protected static ?Manage $instance = null;
/**
* @var string 安装目录
*/
protected string $installDir;
/**
* @var string 备份目录
*/
protected string $backupsDir;
/**
* @var string 模板唯一标识
*/
protected string $uid;
/**
* @var string 模板根目录
*/
protected string $modulesDir;
/**
* 初始化
* @access public
* @param string $uid
* @return Manage
*/
public static function instance(string $uid = ''): Manage
{
if (is_null(self::$instance)) {
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);
}
}
public function getInstallState()
{
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;
}
/**
* 下载模块文件
* @return string 已下载文件路径
* @throws Throwable
*/
public function download(): string
{
$token = request()->param("token/s");
$version = request()->param('version/s');
$orderId = request()->param("orderId/d");
if (!$orderId) {
throw new Exception('Order not found');
}
// 下载 - 系统版本号要求、已安装模块的互斥和依赖检测
$zipFile = Server::download($this->uid, $this->installDir, [
'version' => $version,
'orderId' => $orderId,
'nuxtVersion' => Server::getNuxtVersion(),
'sysVersion' => Config::get('buildadmin.version'),
'installed' => Server::getInstalledIds($this->installDir),
'ba-user-token' => $token,
]);
// 删除旧版本代码
Filesystem::delDir($this->modulesDir);
// 解压
Filesystem::unzip($zipFile);
// 删除下载的zip
@unlink($zipFile);
// 检查是否完整
$this->checkPackage();
// 设置为待安装状态
$this->setInfo([
'state' => self::WAIT_INSTALL,
]);
return $zipFile;
}
/**
* 上传安装
* @param string $file 已经上传完成的文件
* @return array 模块的基本信息
* @throws Throwable
*/
public function upload(string $token, string $file): array
{
$file = Filesystem::fsFit(root_path() . 'public' . $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;
// 删除zip
@unlink($file);
@unlink($copyTo);
// 读取ini
$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']);
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::get('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;
}
/**
* 安装模块
* @return array 模块基本信息
* @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');
}
/**
* self::WAIT_INSTALL=待安装
* 即本地上传文件进行升级的安装流程,文件上传成功后将被标记为待安装,免去此处的下载
*/
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();
}
}
// 导入sql
Server::importSql($this->modulesDir);
// 如果是更新,先执行更新脚本
$info = $this->getInfo();
if ($update) {
$info['update'] = 1;
Server::execEvent($this->uid, 'update');
}
// 执行安装脚本 - 排除冲突处理时会重复提交至此的请求
$extend = request()->post('extend/a', []);
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'] != self::DISABLE) {
throw new Exception('Please disable the module first', 0, [
'uid' => $this->uid,
]);
}
// 执行卸载脚本
Server::execEvent($this->uid, 'uninstall');
Filesystem::delDir($this->modulesDir);
}
/**
* 修改模块状态
* @param bool $state 新状态
* @return array 模块基本信息
* @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'], $canDisable)) {
throw new Exception('The current state of the module cannot be set to disabled', 0, [
'uid' => $this->uid,
'state' => $info['state'],
]);
}
return $this->disable();
}
if ($info['state'] != self::DISABLE) {
throw new Exception('The current state of the module cannot be set to enabled', 0, [
'uid' => $this->uid,
'state' => $info['state'],
]);
}
$this->setInfo([
'state' => self::WAIT_INSTALL,
]);
return $info;
}
/**
* 启用
* @param string $trigger 触发启用的标志,比如:install=安装
* @throws Throwable
*/
public function enable(string $trigger): void
{
// 安装 WebBootstrap
Server::installWebBootstrap($this->uid, $this->modulesDir);
// 建立 .runtime
Server::createRuntime($this->modulesDir);
// 冲突检查
$this->conflictHandle($trigger);
// 执行启用脚本
Server::execEvent($this->uid, 'enable');
$this->dependUpdateHandle();
}
/**
* 禁用
* @return array 模块基本信息
* @throws Throwable
*/
public function disable(): array
{
$update = request()->post("update/b", false);
$confirmConflict = request()->post("confirmConflict/b", false);
$dependConflictSolution = request()->post("dependConflictSolution/a", []);
$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);
}
}
// 删除 composer.json 中的 config
$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()));
// 在模块包中,同时不在 $protectedFiles 列表的文件不恢复,这些文件可能是模块升级时备份的
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, $backupsFile);
}
}
}
// 删除解压后的备份文件
Filesystem::delDir($zipDir);
// 卸载 WebBootstrap
Server::uninstallWebBootstrap($this->uid);
$this->setInfo([
'state' => self::DISABLE,
]);
if ($update) {
throw new Exception('update', -3, [
'uid' => $this->uid,
]);
}
if ($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 ($info['state'] != self::WAIT_INSTALL && $info['state'] != 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');
if ($fileConflict || !self::isEmptyArray($dependConflict)) {
$extend = request()->post('extend/a', []);
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;
// composer config 更新
$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;
}
if ($info['state'] != 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, $destDirItem);
}
}
// 纯净模式
if (Config::get('buildadmin.module_pure_install')) {
Filesystem::delDir($baseDir);
}
}
return true;
}
/**
* 依赖升级处理
* @throws Throwable
*/
public function dependUpdateHandle(): void
{
$info = $this->getInfo();
if ($info['state'] == 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'] == 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);
}
}
/**
* 禁用依赖检查
* @throws Throwable
*/
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);
} elseif ($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)) {
$empty = self::isEmptyArray($item);
if (!$empty) 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,590 @@
<?php
namespace app\admin\library\module;
use Throwable;
use ba\Depends;
use ba\Exception;
use ba\Filesystem;
use think\facade\Db;
use FilesystemIterator;
use think\facade\Config;
use RecursiveIteratorIterator;
use RecursiveDirectoryIterator;
use think\db\exception\PDOException;
use app\admin\library\crud\Helper;
use GuzzleHttp\Exception\TransferException;
/**
* 模块服务类
*/
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;
}
/**
* 获取模块[冲突]文件列表
* @param string $dir 模块目录
* @param bool $onlyConflict 是否只获取冲突文件
*/
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 = '';
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__', Config::get('database.connections.mysql.prefix'), $tempLine);
$tempLine = str_ireplace('INSERT INTO ', 'INSERT IGNORE INTO ', $tempLine);
try {
Db::execute($tempLine);
} catch (PDOException) {
// $e->getMessage();
}
$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;
}
/**
* 获取模块ini
* @param string $dir 模块目录路径
*/
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;
}
/**
* 设置模块ini
* @param string $dir 模块目录路径
* @param array $arr 新的ini数据
* @return bool
* @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);
}
}
}
/**
* 分析 WebBootstrap 代码
*/
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;
}
/**
* 安装 WebBootstrap
*/
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])) {
$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);
}
}
}
}
/**
* 卸载 WebBootstrap
*/
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 (!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 || $moduleMultiLineMarkStartPos) {
file_put_contents($filePaths[$item], $content);
}
}
}
/**
* 构建 WebBootstrap 需要的各种标记字符串
* @param string $type
* @param string $uid 模块UID
* @param string $extend 扩展数据
* @return string
*/
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()
{
$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;
}
/**
* 创建 .runtime
*/
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::get('buildadmin.module_pure_install'),
]));
}
/**
* 读取 .runtime
*/
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] ?? [];
} else {
return $runtimeContentArr;
}
}
}

View File

@@ -0,0 +1,24 @@
<?php
// +----------------------------------------------------------------------
// | BUILDADMIN
// +----------------------------------------------------------------------
// | Copyright (c) 2022-2023 https://buildadmin.com All rights reserved.
// +----------------------------------------------------------------------
// | Licensed ( http://www.apache.org/licenses/LICENSE-2.0 )
// +----------------------------------------------------------------------
// | Author: 妙码生花 <hi@buildadmin.com>
// +----------------------------------------------------------------------
// [ 应用入口文件 ]
namespace think;
require __DIR__ . '/../vendor/autoload.php';
// 执行HTTP应用并响应
$http = (new App())->http;
$response = $http->name('admin')->run();
$response->send();
$http->end($response);

View File

@@ -0,0 +1,301 @@
<?php
namespace app\admin\library\traits;
use Throwable;
/**
* 后台控制器trait类
* 已导入到 @see \app\common\controller\Backend 中
* 若需修改此类方法:请复制方法至对应控制器后进行重写
*/
trait Backend
{
/**
* 排除入库字段
* @param array $params
* @return array
*/
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;
}
/**
* 查看
* @throws Throwable
*/
public function index(): void
{
if ($this->request->param('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);
$this->success('', [
'list' => $res->items(),
'total' => $res->total(),
'remark' => get_route_remark(),
]);
}
/**
* 添加
*/
public function add(): void
{
if ($this->request->isPost()) {
$data = $this->request->post();
if (!$data) {
$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();
$this->error($e->getMessage());
}
if ($result !== false) {
$this->success(__('Added successfully'));
} else {
$this->error(__('No rows were added'));
}
}
$this->error(__('Parameter error'));
}
/**
* 编辑
* @throws Throwable
*/
public function edit(): void
{
$pk = $this->model->getPk();
$id = $this->request->param($pk);
$row = $this->model->find($id);
if (!$row) {
$this->error(__('Record not found'));
}
$dataLimitAdminIds = $this->getDataLimitAdminIds();
if ($dataLimitAdminIds && !in_array($row[$this->dataLimitField], $dataLimitAdminIds)) {
$this->error(__('You have no permission'));
}
if ($this->request->isPost()) {
$data = $this->request->post();
if (!$data) {
$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();
$this->error($e->getMessage());
}
if ($result !== false) {
$this->success(__('Update successful'));
} else {
$this->error(__('No rows updated'));
}
}
$this->success('', [
'row' => $row
]);
}
/**
* 删除
* @throws Throwable
*/
public function del(): void
{
$where = [];
$dataLimitAdminIds = $this->getDataLimitAdminIds();
if ($dataLimitAdminIds) {
$where[] = [$this->dataLimitField, 'in', $dataLimitAdminIds];
}
$ids = $this->request->param('ids/a', []);
$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();
$this->error($e->getMessage());
}
if ($count) {
$this->success(__('Deleted successfully'));
} else {
$this->error(__('No rows were deleted'));
}
}
/**
* 排序 - 增量重排法
* @throws Throwable
*/
public function sortable(): void
{
$pk = $this->model->getPk();
$move = $this->request->param('move');
$target = $this->request->param('target');
$order = $this->request->param("order/s") ?: $this->defaultSortField;
$direction = $this->request->param('direction');
$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) {
$this->error(__('Record not found'));
}
// 当前是否以权重字段排序(只检查当前排序和默认排序字段,不检查有序保证字段)
if ($order && is_string($order)) {
$order = explode(',', $order);
$order = [$order[0] => $order[1] ?? 'asc'];
}
if (!array_key_exists($this->weighField, $order)) {
$this->error(__('Please use the %s field to sort before operating', [$this->weighField]));
}
// 开始增量重排
$order = $this->queryOrderBuilder();
$weigh = $targetRow[$this->weighField];
// 波及行的权重值向上增加还是向下减少
if ($order[$this->weighField] == 'desc') {
$updateMethod = $direction == 'up' ? 'dec' : 'inc';
} else {
$updateMethod = $direction == 'up' ? 'inc' : 'dec';
}
// 与目标行权重相同的行
$weighRowIds = $this->model
->where($dataLimitWhere)
->where($this->weighField, $weigh)
->order($order)
->column($pk);
$weighRowsCount = count($weighRowIds);
// 单个 SQL 查询中完成大于目标权重行的修改
$this->model->where($dataLimitWhere)
->where($this->weighField, $updateMethod == 'dec' ? '<' : '>', $weigh)
->whereNotIn($pk, [$moveRow->$pk])
->$updateMethod($this->weighField, $weighRowsCount)
->save();
// 遍历与目标行权重相同的行,每出现一行权重值将额外 +1保证权重相同行的顺序位置不变
if ($direction == 'down') {
$weighRowIds = array_reverse($weighRowIds);
}
$moveComplete = 0;
$weighRowIds = implode(',', $weighRowIds);
$weighRows = $this->model->where($dataLimitWhere)
->where($pk, 'in', $weighRowIds)
->orderRaw("field($pk,$weighRowIds)")
->select();
// 权重相等行
foreach ($weighRows as $key => $weighRow) {
// 跳过当前拖动行(相等权重数据之间的拖动时,被拖动行会出现在 $weighRows 内)
if ($moveRow[$pk] == $weighRow[$pk]) {
continue;
}
if ($updateMethod == 'dec') {
$rowWeighVal = $weighRow[$this->weighField] - $key;
} else {
$rowWeighVal = $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();
}
$this->success();
}
/**
* 加载为select(远程下拉选择框)数据,默认还是走$this->index()方法
* 必要时请在对应控制器类中重写
*/
public function select(): void
{
}
}

6
app/admin/middleware.php Normal file
View File

@@ -0,0 +1,6 @@
<?php
return [
\app\common\middleware\AllowCrossDomain::class,
\app\common\middleware\AdminLog::class,
\think\middleware\LoadLangPack::class,
];

72
app/admin/model/Admin.php Normal file
View File

@@ -0,0 +1,72 @@
<?php
namespace app\admin\model;
use think\Model;
use think\facade\Db;
/**
* Admin模型
* @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=禁用,...(string存储可自定义其他)
*/
class Admin extends Model
{
/**
* @var string 自动写入时间戳
*/
protected $autoWriteTimestamp = true;
/**
* 追加属性
*/
protected $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;
}
/**
* 重置用户密码
* @param int|string $uid 管理员ID
* @param string $newPassword 新密码
* @return int|Admin
*/
public function resetPassword(int|string $uid, string $newPassword): int|Admin
{
return $this->where(['id' => $uid])->update(['password' => hash_password($newPassword), 'salt' => '']);
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace app\admin\model;
use think\Model;
/**
* AdminGroup模型
*/
class AdminGroup extends Model
{
protected $autoWriteTimestamp = true;
}

View File

@@ -0,0 +1,160 @@
<?php
namespace app\admin\model;
use Throwable;
use think\Model;
use app\admin\library\Auth;
use think\model\relation\BelongsTo;
/**
* AdminLog模型
*/
class AdminLog extends Model
{
protected $autoWriteTimestamp = true;
protected $updateTime = false;
/**
* 自定义日志标题
* @var string
*/
protected string $title = '';
/**
* 自定义日志内容
* @var string|array
*/
protected string|array $data = '';
/**
* 忽略的链接正则列表
* @var array
*/
protected array $urlIgnoreRegex = [
'/^(.*)\/(select|index|logout)$/i',
];
protected array $desensitizationRegex = [
'/(password|salt|token)/i'
];
public static function instance()
{
$request = request();
if (!isset($request->adminLog)) {
$request->adminLog = new static();
}
return $request->adminLog;
}
/**
* 设置标题
* @param string $title
*/
public function setTitle(string $title): void
{
$this->title = $title;
}
/**
* 设置日志内容
* @param string|array $data
*/
public function setData(string|array $data): void
{
$this->data = $data;
}
/**
* 设置忽略的链接正则列表
* @param array|string $regex
*/
public function setUrlIgnoreRegex(array|string $regex = []): void
{
$regex = is_array($regex) ? $regex : [$regex];
$this->urlIgnoreRegex = array_merge($this->urlIgnoreRegex, $regex);
}
/**
* 设置需要进行数据脱敏的正则列表
* @param array|string $regex
*/
public function setDesensitizationRegex(array|string $regex = []): void
{
$regex = is_array($regex) ? $regex : [$regex];
$this->desensitizationRegex = array_merge($this->desensitizationRegex, $regex);
}
/**
* 数据脱敏(只数组,根据数组 key 脱敏)
* @param array|string $data
* @return array|string
*/
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, $index)) {
$item = "***";
} elseif (is_array($item)) {
$item = $this->desensitization($item);
}
}
}
return $data;
}
/**
* 写入日志
* @param string $title
* @param string|array|null $data
* @throws Throwable
*/
public function record(string $title = '', string|array|null $data = null): void
{
$auth = Auth::instance();
$adminId = $auth->isLogin() ? $auth->id : 0;
$username = $auth->isLogin() ? $auth->username : request()->param('username', __('Unknown'));
$controller = str_replace('.', '/', request()->controller(true));
$action = request()->action(true);
$path = $controller . '/' . $action;
if ($this->urlIgnoreRegex) {
foreach ($this->urlIgnoreRegex as $item) {
if (preg_match($item, $path)) {
return;
}
}
}
$data = $data ?: $this->data;
if (!$data) {
$data = request()->param('', null, 'trim,strip_tags,htmlspecialchars');
}
$data = $this->desensitization($data);
$title = $title ?: $this->title;
if (!$title) {
$controllerTitle = AdminRule::where('name', $controller)->value('title');
$title = AdminRule::where('name', $path)->value('title');
$title = $title ?: __('Unknown') . '(' . $action . ')';
$title = $controllerTitle ? ($controllerTitle . '-' . $title) : $title;
}
self::create([
'admin_id' => $adminId,
'username' => $username,
'url' => substr(request()->url(), 0, 1500),
'title' => $title,
'data' => !is_scalar($data) ? json_encode($data) : $data,
'ip' => request()->ip(),
'useragent' => substr(request()->server('HTTP_USER_AGENT'), 0, 255),
]);
}
public function admin(): BelongsTo
{
return $this->belongsTo(Admin::class);
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace app\admin\model;
use think\Model;
/**
* AdminRule 模型
* @property int $status 状态:0=禁用,1=启用
*/
class AdminRule extends Model
{
protected $autoWriteTimestamp = true;
public function setComponentAttr($value)
{
if ($value) $value = str_replace('\\', '/', $value);
return $value;
}
}

133
app/admin/model/Config.php Normal file
View File

@@ -0,0 +1,133 @@
<?php
namespace app\admin\model;
use Throwable;
use think\Model;
use think\facade\Cache;
/**
* 系统配置模型
* @property mixed $content
* @property mixed $rule
* @property mixed $extend
* @property mixed $allow_del
*/
class Config extends Model
{
public static string $cacheTag = 'sys_config';
protected $append = [
'value',
'content',
'extend',
'input_extend',
];
protected array $jsonDecodeType = ['checkbox', 'array', 'selects'];
protected array $needContent = ['radio', 'checkbox', 'select', 'selects'];
/**
* 入库前
* @throws Throwable
*/
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
{
// 清理配置缓存
Cache::tag(self::$cacheTag)->clear();
}
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);
} elseif ($row['type'] == 'switch') {
return (bool)$value;
} elseif ($row['type'] == 'editor') {
return !$value ? '' : htmlspecialchars_decode($value);
} elseif (in_array($row['type'], ['city', 'remoteSelects'])) {
if (!$value) return [];
if (!is_array($value)) return explode(',', $value);
return $value;
} else {
return $value ?: '';
}
}
public function setValueAttr(mixed $value, $row): mixed
{
if (in_array($row['type'], $this->jsonDecodeType)) {
return $value ? json_encode($value) : '';
} elseif ($row['type'] == 'switch') {
return $value ? '1' : '0';
} elseif ($row['type'] == 'time') {
return $value ? date('H:i:s', strtotime($value)) : '';
} elseif ($row['type'] == 'city') {
if ($value && is_array($value)) {
return implode(',', $value);
}
return $value ?: '';
} elseif (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 ?: [];
} else {
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,24 @@
<?php
namespace app\admin\model;
use think\Model;
/**
* Log
*/
class CrudLog extends Model
{
// 表名
protected $name = 'crud_log';
// 自动写入时间戳字段
protected $autoWriteTimestamp = true;
protected $updateTime = false;
protected $type = [
'table' => 'array',
'fields' => 'array',
];
}

View File

@@ -0,0 +1,15 @@
<?php
namespace app\admin\model;
use think\Model;
/**
* DataRecycle 模型
*/
class DataRecycle extends Model
{
protected $name = 'security_data_recycle';
protected $autoWriteTimestamp = true;
}

View File

@@ -0,0 +1,27 @@
<?php
namespace app\admin\model;
use think\Model;
use think\model\relation\BelongsTo;
/**
* DataRecycleLog 模型
*/
class DataRecycleLog extends Model
{
protected $name = 'security_data_recycle_log';
protected $autoWriteTimestamp = true;
protected $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,19 @@
<?php
namespace app\admin\model;
use think\Model;
/**
* SensitiveData 模型
*/
class SensitiveData extends Model
{
protected $name = 'security_sensitive_data';
protected $autoWriteTimestamp = true;
protected $type = [
'data_fields' => 'array',
];
}

View File

@@ -0,0 +1,27 @@
<?php
namespace app\admin\model;
use think\Model;
use think\model\relation\BelongsTo;
/**
* SensitiveDataLog 模型
*/
class SensitiveDataLog extends Model
{
protected $name = 'security_sensitive_data_log';
protected $autoWriteTimestamp = true;
protected $updateTime = false;
public function sensitive(): BelongsTo
{
return $this->belongsTo(SensitiveData::class, 'sensitive_id');
}
public function admin(): BelongsTo
{
return $this->belongsTo(Admin::class, 'admin_id');
}
}

52
app/admin/model/User.php Normal file
View File

@@ -0,0 +1,52 @@
<?php
namespace app\admin\model;
use think\Model;
use think\model\relation\BelongsTo;
/**
* User 模型
* @property int $id 用户ID
* @property string password 密码密文
*/
class User extends Model
{
protected $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($value, 100, 2);
}
public function setMoneyAttr($value): string
{
return bcmul($value, 100, 2);
}
public function userGroup(): BelongsTo
{
return $this->belongsTo(UserGroup::class, 'group_id');
}
/**
* 重置用户密码
* @param int|string $uid 用户ID
* @param string $newPassword 新密码
* @return int|User
*/
public function resetPassword(int|string $uid, string $newPassword): int|User
{
return $this->where(['id' => $uid])->update(['password' => hash_password($newPassword), 'salt' => '']);
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace app\admin\model;
use think\Model;
/**
* UserGroup 模型
*/
class UserGroup extends Model
{
protected $autoWriteTimestamp = true;
}

View File

@@ -0,0 +1,80 @@
<?php
namespace app\admin\model;
use Throwable;
use think\model;
use think\Exception;
use think\model\relation\BelongsTo;
/**
* UserMoneyLog 模型
* 1. 创建余额日志自动完成会员余额的添加
* 2. 创建余额日志时,请开启事务
*/
class UserMoneyLog extends model
{
protected $autoWriteTimestamp = true;
protected $updateTime = false;
/**
* 入库前
* @throws Throwable
*/
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($value, 100, 2);
}
public function setMoneyAttr($value): string
{
return bcmul($value, 100, 2);
}
public function getBeforeAttr($value): string
{
return bcdiv($value, 100, 2);
}
public function setBeforeAttr($value): string
{
return bcmul($value, 100, 2);
}
public function getAfterAttr($value): string
{
return bcdiv($value, 100, 2);
}
public function setAfterAttr($value): string
{
return bcmul($value, 100, 2);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace app\admin\model;
use think\model;
/**
* UserRule 模型
* @property int $status 状态:0=禁用,1=启用
*/
class UserRule extends model
{
protected $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,50 @@
<?php
namespace app\admin\model;
use Throwable;
use think\model;
use think\Exception;
use think\model\relation\BelongsTo;
/**
* UserScoreLog 模型
* 1. 创建积分日志自动完成会员积分的添加
* 2. 创建积分日志时,请开启事务
*/
class UserScoreLog extends model
{
protected $autoWriteTimestamp = true;
protected $updateTime = false;
/**
* 入库前
* @throws Throwable
*/
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,73 @@
<?php
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',
];
/**
* 验证提示信息
* @var array
*/
protected $message = [];
/**
* 字段描述
*/
protected $field = [
];
/**
* 验证场景
*/
protected $scene = [
'add' => ['username', 'nickname', 'password', 'email', 'mobile', 'group_arr'],
];
/**
* 验证场景-前台自己修改自己资料
*/
public function sceneInfo(): Admin
{
return $this->only(['nickname', 'password', 'email', 'mobile'])
->remove('password', 'require');
}
/**
* 验证场景-编辑资料
*/
public function sceneEdit(): Admin
{
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,46 @@
<?php
namespace app\admin\validate;
use think\Validate;
class AdminGroup extends Validate
{
protected $failException = true;
protected $rule = [
'name' => 'require',
'rules' => 'require',
];
/**
* 验证提示信息
* @var array
*/
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,46 @@
<?php
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',
];
/**
* 验证提示信息
* @var array
*/
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,41 @@
<?php
namespace app\admin\validate;
use think\Validate;
class Config extends Validate
{
protected $failException = true;
protected $rule = [
'name' => 'require|unique:config',
];
/**
* 验证提示信息
* @var array
*/
protected $message = [];
/**
* 字段描述
*/
protected $field = [
];
/**
* 验证场景
*/
protected $scene = [
'add' => ['name'],
];
public function __construct()
{
$this->field = [
'name' => __('Variable name'),
];
parent::__construct();
}
}

View File

@@ -0,0 +1,48 @@
<?php
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',
];
/**
* 验证提示信息
* @var array
*/
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,50 @@
<?php
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',
];
/**
* 验证提示信息
* @var array
*/
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,46 @@
<?php
namespace app\admin\validate;
use think\Validate;
class UserMoneyLog extends Validate
{
protected $failException = true;
protected $rule = [
'user_id' => 'require',
'money' => 'require',
'memo' => 'require',
];
/**
* 验证提示信息
* @var array
*/
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,46 @@
<?php
namespace app\admin\validate;
use think\Validate;
class UserScoreLog extends Validate
{
protected $failException = true;
protected $rule = [
'user_id' => 'require',
'score' => 'require',
'memo' => 'require',
];
/**
* 验证提示信息
* @var array
*/
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();
}
}

38
app/api/common.php Normal file
View File

@@ -0,0 +1,38 @@
<?php
use ba\Filesystem;
use app\admin\library\module\Server;
if (!function_exists('get_account_verification_type')) {
/**
* 获取可用的账户验证方式
* 用于:用户找回密码|用户注册
* @return string[] email=电子邮件,mobile=手机短信验证
* @throws Throwable
*/
function get_account_verification_type(): array
{
$types = [];
// 电子邮件,检查后台系统邮件配置是否全部填写
$sysMailConfig = get_sys_config('', 'mail');
$configured = true;
foreach ($sysMailConfig as $item) {
if (!$item) {
$configured = false;
}
}
if ($configured) {
$types[] = 'email';
}
// 手机号,检查是否安装短信模块
$sms = Server::getIni(Filesystem::fsFit(root_path() . 'modules/sms/'));
if ($sms && $sms['state'] == 1) {
$types[] = 'mobile';
}
return $types;
}
}

View File

@@ -0,0 +1,259 @@
<?php
namespace app\api\controller;
use ba\Date;
use Throwable;
use ba\Captcha;
use ba\Random;
use app\common\model\User;
use think\facade\Validate;
use app\common\facade\Token;
use app\common\model\UserScoreLog;
use app\common\model\UserMoneyLog;
use app\common\controller\Frontend;
use app\api\validate\Account as AccountValidate;
class Account extends Frontend
{
protected array $noNeedLogin = ['retrievePassword'];
protected array $noNeedPermission = ['verification', 'changeBind'];
public function initialize(): void
{
parent::initialize();
}
public function overview(): void
{
$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($userMoneyTemp, 100, 2);
}
$this->success('', [
'days' => $days,
'score' => $score,
'money' => $money,
]);
}
/**
* 会员资料
* @throws Throwable
*/
public function profile(): void
{
if ($this->request->isPost()) {
$model = $this->auth->getUser();
$data = $this->request->only(['avatar', 'username', 'nickname', 'gender', 'birthday', 'motto']);
$data['id'] = $this->auth->id;
if (!isset($data['birthday'])) {
$data['birthday'] = null;
}
try {
$validate = new AccountValidate();
$validate->scene('edit')->check($data);
} catch (Throwable $e) {
$this->error($e->getMessage());
}
$model->startTrans();
try {
$model->save($data);
$model->commit();
} catch (Throwable $e) {
$model->rollback();
$this->error($e->getMessage());
}
$this->success(__('Data updated successfully~'));
}
$this->success('', [
'accountVerificationType' => get_account_verification_type()
]);
}
/**
* 通过手机号或邮箱验证账户
* 此处检查的验证码是通过 api/Ems或api/Sms发送的
* 验证成功后,向前端返回一个 email-pass Token或着 mobile-pass Token
* 在 changBind 方法中,通过 pass Token来确定用户已经通过了账户验证用户未绑定邮箱/手机时通过账户密码验证)
* @throws Throwable
*/
public function verification(): void
{
$captcha = new Captcha();
$params = $this->request->only(['type', 'captcha']);
if ($captcha->check($params['captcha'], ($params['type'] == 'email' ? $this->auth->email : $this->auth->mobile) . "user_{$params['type']}_verify")) {
$uuid = Random::uuid();
Token::set($uuid, $params['type'] . '-pass', $this->auth->id, 600);
$this->success('', [
'type' => $params['type'],
'accountVerificationToken' => $uuid,
]);
}
$this->error(__('Please enter the correct verification code'));
}
/**
* 修改绑定信息(手机号、邮箱)
* 通过 pass Token来确定用户已经通过了账户验证也就是以上的 verification 方法,同时用户未绑定邮箱/手机时通过账户密码验证
* @throws Throwable
*/
public function changeBind(): void
{
$captcha = new Captcha();
$params = $this->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)) {
$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])) {
$this->error(__('Password error'));
}
// 检查验证码
if ($captcha->check($params['captcha'], $params[$params['type']] . "user_change_{$params['type']}")) {
if ($params['type'] == 'email') {
$validate = Validate::rule(['email' => 'require|email|unique:user'])->message([
'email.require' => 'email format error',
'email.email' => 'email format error',
'email.unique' => 'email is occupied',
]);
if (!$validate->check(['email' => $params['email']])) {
$this->error(__($validate->getError()));
}
$user->email = $params['email'];
} elseif ($params['type'] == 'mobile') {
$validate = Validate::rule(['mobile' => 'require|mobile|unique:user'])->message([
'mobile.require' => 'mobile format error',
'mobile.mobile' => 'mobile format error',
'mobile.unique' => 'mobile is occupied',
]);
if (!$validate->check(['mobile' => $params['mobile']])) {
$this->error(__($validate->getError()));
}
$user->mobile = $params['mobile'];
}
Token::delete($params['accountVerificationToken']);
$user->save();
$this->success();
}
$this->error(__('Please enter the correct verification code'));
}
public function changePassword(): void
{
if ($this->request->isPost()) {
$model = $this->auth->getUser();
$params = $this->request->only(['oldPassword', 'newPassword']);
if (!verify_password($params['oldPassword'], $model->password, ['salt' => $model->salt])) {
$this->error(__('Old password error'));
}
$model->startTrans();
try {
$validate = new AccountValidate();
$validate->scene('changePassword')->check(['password' => $params['newPassword']]);
$model->resetPassword($this->auth->id, $params['newPassword']);
$model->commit();
} catch (Throwable $e) {
$model->rollback();
$this->error($e->getMessage());
}
$this->auth->logout();
$this->success(__('Password has been changed, please login again~'));
}
}
/**
* 积分日志
* @throws Throwable
*/
public function integral(): void
{
$limit = $this->request->request('limit');
$integralModel = new UserScoreLog();
$res = $integralModel->where('user_id', $this->auth->id)
->order('create_time desc')
->paginate($limit);
$this->success('', [
'list' => $res->items(),
'total' => $res->total(),
]);
}
/**
* 余额日志
* @throws Throwable
*/
public function balance(): void
{
$limit = $this->request->request('limit');
$moneyModel = new UserMoneyLog();
$res = $moneyModel->where('user_id', $this->auth->id)
->order('create_time desc')
->paginate($limit);
$this->success('', [
'list' => $res->items(),
'total' => $res->total(),
]);
}
/**
* 找回密码
* @throws Throwable
*/
public function retrievePassword(): void
{
$params = $this->request->only(['type', 'account', 'captcha', 'password']);
try {
$validate = new AccountValidate();
$validate->scene('retrievePassword')->check($params);
} catch (Throwable $e) {
$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) {
$this->error(__('Account does not exist~'));
}
$captchaObj = new Captcha();
if (!$captchaObj->check($params['captcha'], $params['account'] . 'user_retrieve_pwd')) {
$this->error(__('Please enter the correct verification code'));
}
if ($user->resetPassword($user->id, $params['password'])) {
$this->success(__('Password has been changed~'));
} else {
$this->error(__('Failed to modify password, please try again later~'));
}
}
}

View File

@@ -0,0 +1,59 @@
<?php
namespace app\api\controller;
use Throwable;
use think\Response;
use app\common\library\Upload;
use app\common\controller\Frontend;
class Ajax extends Frontend
{
protected array $noNeedLogin = ['area', 'buildSuffixSvg'];
protected array $noNeedPermission = ['upload'];
public function initialize(): void
{
parent::initialize();
}
public function upload(): void
{
$file = $this->request->file('file');
$driver = $this->request->param('driver', 'local');
$topic = $this->request->param('topic', 'default');
try {
$upload = new Upload();
$attachment = $upload
->setFile($file)
->setDriver($driver)
->setTopic($topic)
->upload(null, 0, $this->auth->id);
unset($attachment['create_time'], $attachment['quote']);
} catch (Throwable $e) {
$this->error($e->getMessage());
}
$this->success(__('File uploaded successfully'), [
'file' => $attachment ?? []
]);
}
/**
* 省份地区数据
* @throws Throwable
*/
public function area(): void
{
$this->success('', get_area());
}
public function buildSuffixSvg(): Response
{
$suffix = $this->request->param('suffix', 'file');
$background = $this->request->param('background');
$content = build_suffix_svg((string)$suffix, (string)$background);
return response($content, 200, ['Content-Length' => strlen($content)])->contentType('image/svg+xml');
}
}

View File

@@ -0,0 +1,92 @@
<?php
namespace app\api\controller;
use ba\Random;
use Throwable;
use ba\Captcha;
use think\Response;
use ba\ClickCaptcha;
use think\facade\Config;
use app\common\facade\Token;
use app\common\controller\Api;
use app\admin\library\Auth as AdminAuth;
use app\common\library\Auth as UserAuth;
class Common extends Api
{
/**
* 图形验证码
* @throws Throwable
*/
public function captcha(): Response
{
$captchaId = $this->request->request('id');
$config = array(
'codeSet' => '123456789', // 验证码字符集合
'fontSize' => 22, // 验证码字体大小(px)
'useCurve' => false, // 是否画混淆曲线
'useNoise' => true, // 是否添加杂点
'length' => 4, // 验证码位数
'bg' => array(255, 255, 255), // 背景颜色
);
$captcha = new Captcha($config);
return $captcha->entry($captchaId);
}
/**
* 点选验证码
*/
public function clickCaptcha(): void
{
$id = $this->request->request('id/s');
$captcha = new ClickCaptcha();
$this->success('', $captcha->creat($id));
}
/**
* 点选验证码检查
* @throws Throwable
*/
public function checkClickCaptcha(): void
{
$id = $this->request->post('id/s');
$info = $this->request->post('info/s');
$unset = $this->request->post('unset/b', false);
$captcha = new ClickCaptcha();
if ($captcha->check($id, $info, $unset)) $this->success();
$this->error();
}
/**
* 刷新 token
* 无需主动删除原 token由 token 驱动自行实现过期 token 清理,可避免并发场景下无法获取到过期 token 数据
*/
public function refreshToken(): void
{
$refreshToken = $this->request->post('refreshToken');
$refreshToken = Token::get($refreshToken);
if (!$refreshToken || $refreshToken['expire_time'] < time()) {
$this->error(__('Login expired, please login again.'));
}
$newToken = Random::uuid();
// 管理员token刷新
if ($refreshToken['type'] == AdminAuth::TOKEN_TYPE . '-refresh') {
Token::set($newToken, AdminAuth::TOKEN_TYPE, $refreshToken['user_id'], (int)Config::get('buildadmin.admin_token_keep_time'));
}
// 会员token刷新
if ($refreshToken['type'] == UserAuth::TOKEN_TYPE . '-refresh') {
Token::set($newToken, UserAuth::TOKEN_TYPE, $refreshToken['user_id'], (int)Config::get('buildadmin.user_token_keep_time'));
}
$this->success('', [
'type' => $refreshToken['type'],
'token' => $newToken
]);
}
}

108
app/api/controller/Ems.php Normal file
View File

@@ -0,0 +1,108 @@
<?php
namespace app\api\controller;
use Throwable;
use ba\Captcha;
use ba\ClickCaptcha;
use think\facade\Validate;
use app\common\model\User;
use app\common\library\Email;
use app\common\controller\Frontend;
use PHPMailer\PHPMailer\Exception as PHPMailerException;
class Ems extends Frontend
{
protected array $noNeedLogin = ['send'];
public function initialize(): void
{
parent::initialize();
}
/**
* 发送邮件
* event 事件:user_register=用户注册,user_change_email=用户修改邮箱,user_retrieve_pwd=用户找回密码,user_email_verify=验证账户
* 不同的事件,会自动做各种必要检查,其中 验证账户 要求用户输入当前密码才能发送验证码邮件
* @throws Throwable
*/
public function send(): void
{
$params = $this->request->post(['email', 'event', 'captchaId', 'captchaInfo']);
$mail = new Email();
if (!$mail->configured) {
$this->error(__('Mail sending service unavailable'));
}
$validate = Validate::rule([
'email' => 'require|email',
'event' => 'require',
'captchaId' => 'require',
'captchaInfo' => 'require'
])->message([
'email' => 'email format error',
'event' => 'Parameter error',
'captchaId' => 'Captcha error',
'captchaInfo' => 'Captcha error'
]);
if (!$validate->check($params)) {
$this->error(__($validate->getError()));
}
// 检查验证码
$captchaObj = new Captcha();
$clickCaptcha = new ClickCaptcha();
if (!$clickCaptcha->check($params['captchaId'], $params['captchaInfo'])) {
$this->error(__('Captcha error'));
}
// 检查频繁发送
$captcha = $captchaObj->getCaptchaData($params['email'] . $params['event']);
if ($captcha && time() - $captcha['create_time'] < 60) {
$this->error(__('Frequent email sending'));
}
// 检查邮箱
$userInfo = User::where('email', $params['email'])->find();
if ($params['event'] == 'user_register' && $userInfo) {
$this->error(__('Email has been registered, please log in directly'));
} elseif ($params['event'] == 'user_change_email' && $userInfo) {
$this->error(__('The email has been occupied'));
} elseif (in_array($params['event'], ['user_retrieve_pwd', 'user_email_verify']) && !$userInfo) {
$this->error(__('Email not registered'));
}
// 通过邮箱验证账户
if ($params['event'] == 'user_email_verify') {
if (!$this->auth->isLogin()) {
$this->error(__('Please login first'));
}
if ($this->auth->email != $params['email']) {
$this->error(__('Please use the account registration email to send the verification code'));
}
// 验证账户密码
$password = $this->request->post('password');
if (!verify_password($password, $this->auth->password, ['salt' => $this->auth->salt])) {
$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) {
$this->error($mail->ErrorInfo);
}
$this->success(__('Mail sent successfully~'));
}
}

View File

@@ -0,0 +1,84 @@
<?php
namespace app\api\controller;
use ba\Tree;
use Throwable;
use think\facade\Db;
use think\facade\Config;
use app\common\controller\Frontend;
use app\common\library\token\TokenExpirationException;
class Index extends Frontend
{
protected array $noNeedLogin = ['index'];
public function initialize(): void
{
parent::initialize();
}
/**
* 前台和会员中心的初始化请求
* @throws Throwable
*/
public function index(): void
{
$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 = $this->request->get('requiredLogin/b', false);
if ($requiredLogin) {
// 触发可能的 token 过期异常
try {
$token = get_auth_token(['ba', 'user', 'token']);
$this->auth->init($token);
} catch (TokenExpirationException) {
$this->error(__('Token expiration'), [], 409);
}
$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);
}
$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(), ['max_size', 'save_name', 'allowed_suffixes', 'allowed_mime_types']),
'recordNumber' => get_sys_config('record_number'),
'cdnUrlParams' => Config::get('buildadmin.cdn_url_params'),
],
'openMemberCenter' => Config::get('buildadmin.open_member_center'),
'userInfo' => $this->auth->getUserInfo(),
'rules' => $rules,
'menus' => $menus,
]);
}
}

View File

@@ -0,0 +1,671 @@
<?php
declare (strict_types=1);
namespace app\api\controller;
use Throwable;
use ba\Random;
use ba\Version;
use think\App;
use ba\Terminal;
use ba\Filesystem;
use think\facade\Db;
use think\facade\Config;
use app\common\controller\Api;
use think\db\exception\PDOException;
use app\admin\model\Admin as AdminModel;
use app\admin\model\User as UserModel;
/**
* 安装控制器
*/
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 = 'database.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',
];
/**
* 安装完成标记
* 配置完成则建立lock文件
* 执行命令成功执行再写入标记到lock文件
* 实现命令执行失败,重载页面可重新执行
*/
static string $InstallationCompletionMark = 'install-end';
/**
* 构造方法
* @param App $app
*/
public function __construct(App $app)
{
parent::__construct($app);
}
/**
* 命令执行窗口
* @throws Throwable
*/
public function terminal(): void
{
if ($this->isInstallComplete()) {
return;
}
(new Terminal())->exec(false);
}
public function changePackageManager(): void
{
if ($this->isInstallComplete()) {
return;
}
$newPackageManager = request()->post('manager', Config::get('terminal.npm_package_manager'));
if (Terminal::changeTerminalConfig()) {
$this->success('', [
'manager' => $newPackageManager
]);
} else {
$this->error(__('Failed to switch package manager. Please modify the configuration file manually:%s', ['根目录/config/buildadmin.php']));
}
}
/**
* 环境基础检查
*/
public function envBaseCheck(): void
{
if ($this->isInstallComplete()) {
$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')) {
$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 = [
[
// 需要PHP版本
'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 = [
[
// 需要 64 位 PHP
'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
$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(): void
{
if ($this->isInstallComplete()) {
$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'
]
];
}
// 包管理器
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[] = [
// 请先安装npm
'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') {
$pmVersion = __('nothing');
$pmVersionCompare = true;
} else {
$pmVersion = __('nothing');
$pmVersionCompare = false;
}
// 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'
]
];
}
$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(): void
{
$database = [
'hostname' => $this->request->post('hostname'),
'username' => $this->request->post('username'),
'password' => $this->request->post('password'),
'hostport' => $this->request->post('hostport'),
'database' => '',
];
$conn = $this->connectDb($database);
if ($conn['code'] == 0) {
$this->error($conn['msg']);
} else {
$this->success('', [
'databases' => $conn['databases']
]);
}
}
/**
* 系统基础配置
* post请求=开始安装
*/
public function baseConfig(): void
{
if ($this->isInstallComplete()) {
$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 ($this->request->isGet()) {
$this->success('', [
'rootPath' => $rootPath,
'executionWebCommand' => $envOk
]);
}
$connectData = $databaseParam = $this->request->only(['hostname', 'username', 'password', 'hostport', 'database', 'prefix']);
// 数据库配置测试
$connectData['database'] = '';
$connect = $this->connectDb($connectData, true);
if ($connect['code'] == 0) {
$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);
}
// 写入数据库配置文件
$dbConfigFile = config_path() . self::$dbConfigFileName;
$dbConfigContent = @file_get_contents($dbConfigFile);
$callback = function ($matches) use ($databaseParam) {
$value = $databaseParam[$matches[1]] ?? '';
return "'$matches[1]'$matches[2]=>$matches[3]env('database.$matches[1]', '$value'),";
};
$dbConfigText = preg_replace_callback("/'(hostname|database|username|password|hostport|prefix)'(\s+)=>(\s+)env\('database\.(.*)',\s+'(.*)'\),/", $callback, $dbConfigContent);
$result = @file_put_contents($dbConfigFile, $dbConfigText);
if (!$result) {
$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) {
$this->error(__('File has no write permission:%s', ['/' . $envFile]));
}
}
// 设置新的Token随机密钥key
$oldTokenKey = Config::get('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) {
$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) {
$this->error(__('File has no write permission:%s', ['public/' . self::$lockFileName]));
}
$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(): void
{
if ($this->isInstallComplete()) {
$this->error(__('The system has completed installation. If you need to reinstall, please delete the %s file first', ['public/' . self::$lockFileName]));
}
$param = $this->request->only(['type', 'adminname', 'adminpassword', 'sitename']);
if ($param['type'] == 'web') {
$result = @file_put_contents(public_path() . self::$lockFileName, self::$InstallationCompletionMark);
if (!$result) {
$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());
// 修改站点名称
\app\admin\model\Config::where('name', 'site_name')->update([
'value' => $param['sitename']
]);
}
$this->success();
}
/**
* 获取命令执行检查的结果
* @return bool 是否拥有执行命令的条件
*/
private function commandExecutionCheck(): bool
{
$pm = Config::get('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(): void
{
$this->success('', [
'webPath' => str_replace('\\', '/', root_path() . 'web')
]);
}
public function mvDist(): void
{
if (!is_file(root_path() . self::$distDir . DIRECTORY_SEPARATOR . 'index.html')) {
$this->error(__('No built front-end file found, please rebuild manually!'));
}
if (Terminal::mvDist()) {
$this->success();
} else {
$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');
}
/**
* 数据库连接-获取数据表列表
* @param array $database
* @param bool $returnPdo
* @return array
*/
private function connectDb(array $database, bool $returnPdo = false): array
{
try {
$dbConfig = Config::get('database');
$dbConfig['connections']['mysql'] = array_merge($dbConfig['connections']['mysql'], $database);
Config::set(['connections' => $dbConfig['connections']], 'database');
$connect = Db::connect('mysql');
$connect->execute("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'];
$res = $connect->query("SHOW DATABASES");
foreach ($res as $row) {
if (!in_array($row['Database'], $databasesExclude)) {
$databases[] = $row['Database'];
}
}
return [
'code' => 1,
'msg' => '',
'databases' => $databases,
'pdo' => $returnPdo ? $connect->getPdo() : '',
];
}
}

100
app/api/controller/User.php Normal file
View File

@@ -0,0 +1,100 @@
<?php
namespace app\api\controller;
use Throwable;
use ba\Captcha;
use ba\ClickCaptcha;
use think\facade\Config;
use app\common\facade\Token;
use app\common\controller\Frontend;
use app\api\validate\User as UserValidate;
class User extends Frontend
{
protected array $noNeedLogin = ['checkIn', 'logout'];
public function initialize(): void
{
parent::initialize();
}
/**
* 会员签入(登录和注册)
* @throws Throwable
*/
public function checkIn(): void
{
$openMemberCenter = Config::get('buildadmin.open_member_center');
if (!$openMemberCenter) {
$this->error(__('Member center disabled'));
}
// 检查登录态
if ($this->auth->isLogin()) {
$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::get('buildadmin.user_login_captcha');
if ($this->request->isPost()) {
$params = $this->request->post(['tab', 'email', 'mobile', 'username', 'password', 'keep', 'captcha', 'captchaId', 'captchaInfo', 'registerType']);
// 提前检查 tab ,然后将以 tab 值作为数据验证场景
if (!in_array($params['tab'] ?? '', ['login', 'register'])) {
$this->error(__('Unknown operation'));
}
$validate = new UserValidate();
try {
$validate->scene($params['tab'])->check($params);
} catch (Throwable $e) {
$this->error($e->getMessage());
}
if ($params['tab'] == 'login') {
if ($userLoginCaptchaSwitch) {
$captchaObj = new ClickCaptcha();
if (!$captchaObj->check($params['captchaId'], $params['captchaInfo'])) {
$this->error(__('Captcha error'));
}
}
$res = $this->auth->login($params['username'], $params['password'], !empty($params['keep']));
} elseif ($params['tab'] == 'register') {
$captchaObj = new Captcha();
if (!$captchaObj->check($params['captcha'], $params[$params['registerType']] . 'user_register')) {
$this->error(__('Please enter the correct verification code'));
}
$res = $this->auth->register($params['username'], $params['password'], $params['mobile'], $params['email']);
}
if (isset($res) && $res === true) {
$this->success(__('Login succeeded!'), [
'userInfo' => $this->auth->getUserInfo(),
'routePath' => '/user'
]);
} else {
$msg = $this->auth->getError();
$msg = $msg ?: __('Check in failed, please try again or contact the website administrator~');
$this->error($msg);
}
}
$this->success('', [
'userLoginCaptchaSwitch' => $userLoginCaptchaSwitch,
'accountVerificationType' => get_account_verification_type()
]);
}
public function logout(): void
{
if ($this->request->isPost()) {
$refreshToken = $this->request->post('refreshToken', '');
if ($refreshToken) Token::delete((string)$refreshToken);
$this->auth->logout();
$this->success();
}
}
}

15
app/api/lang/en.php Normal file
View File

@@ -0,0 +1,15 @@
<?php
return [
'Login expired, please login again.' => 'Login expired, please login again.',
'Account not exist' => 'Account does not exist',
'Account disabled' => 'Account is disabled',
'Token login failed' => 'Token login failed',
'Please try again after 1 day' => 'The number of failed login attempts has exceeded the limit, please try again after 24 hours.',
'Password is incorrect' => 'Incorrect password',
'You are not logged in' => 'You are not logged in.',
'Unknown operation' => 'Unknown operation',
'No action available, please contact the administrator~' => 'There is no action available, please contact the administrator~',
'Please login first' => 'Please login first',
'You have no permission' => 'No permission to operate',
'Captcha error' => 'Captcha error!',
];

View File

@@ -0,0 +1,16 @@
<?php
return [
'nickname' => 'Nickname',
'birthday' => 'Birthday',
'captcha' => 'Captcha',
'Old password error' => 'Old password error',
'Data updated successfully~' => 'Data updated successfully',
'Please input correct password' => 'Please enter the correct password',
'nicknameChsDash' => 'Usernames can only be Chinese characters, letters, numbers, underscores_ and dashes-.',
'Password has been changed~' => 'Password has been changed~',
'Password has been changed, please login again~' => 'Password has been changed, please login again~',
'Account does not exist~' => 'Account does not exist',
'Failed to modify password, please try again later~' => 'Failed to modify password, please try again later~',
'Please enter the correct verification code' => 'Please enter the correct Captcha',
'%s has been registered' => '%s has been registered, please login directly.',
];

16
app/api/lang/en/ems.php Normal file
View File

@@ -0,0 +1,16 @@
<?php
return [
'email format error' => 'email format error',
'user_register' => 'Member registration verification',
'user_retrieve_pwd' => 'Retrieve password verification',
'user_change_email' => 'Modify mailbox validation',
'user_email_verify' => 'Member Email Verification',
'Your verification code is: %s' => 'Your Captcha is: %svalid for 10 minutes~',
'Mail sent successfully~' => 'Mail sent successfully',
'Account does not exist~' => 'Account does not exist',
'Mail sending service unavailable' => 'The mail sending service is not working, please contact the webmaster to configure it.',
'Frequent email sending' => 'Frequent email sending',
'Email has been registered, please log in directly' => 'Email has been registered, please log in directly~',
'The email has been occupied' => 'The email has been occupied',
'Email not registered' => 'Email not registered',
];

View File

@@ -0,0 +1,44 @@
<?php
return [
'Install the controller' => 'Install the controller',
'need' => 'Need',
'Click to see how to solve it' => 'Click to see how to solve.',
'Please check the config directory permissions' => 'Please check the Config directory permissions',
'Please check the public directory permissions' => 'Please check the Public directory permissions',
'open' => 'Open',
'close' => 'Close',
'The installation can continue, and some operations need to be completed manually' => 'You can continue to install, and some operations need to be completed manually ',
'Allow execution' => 'Allow execution',
'disabled' => 'Disabled',
'Allow operation' => 'Allow operation',
'Acquisition failed' => 'Access failed',
'Click Install %s' => 'Click Install %s',
'Writable' => 'Writable',
'No write permission' => 'No write permissions',
'already installed' => 'Installed',
'Not installed' => 'Not installed',
'File has no write permission:%s' => 'File has no write permission:%s',
'The system has completed installation. If you need to reinstall, please delete the %s file first' => 'The system has been installed, if you need to reinstall, please delete the %s file first.',
'Database connection failed:%s' => 'Database connection failure%s',
'Failed to install SQL execution:%s' => 'Installation SQL execution failed%s',
'unknown' => 'Unknown',
'Database does not exist' => 'Database does not exist!',
'No built front-end file found, please rebuild manually!' => 'No built front-end file found, please rebuild manually.',
'Failed to move the front-end file, please move it manually!' => 'Failed to move the front-end file, please move manually',
'How to solve?' => 'How to solve?',
'View reason' => 'View reasons',
'Click to view the reason' => 'Click to see the reason',
'PDO extensions need to be installed' => 'pdo_mysql extensions need to be installed.',
'proc_open or proc_close functions in PHP Ini is disabled' => 'proc_open and proc_close functions in PHP.Ini is disabled.',
'How to modify' => 'How to modify?',
'Click to view how to modify' => 'Click to see how to modify.',
'Security assurance?' => 'Security assurance?',
'Using the installation service correctly will not cause any potential security problems. Click to view the details' => 'The correct use of the installation service will not cause any potential security issues. Click to view the details.',
'Please install NPM first' => 'Please install NPM first.',
'Installation error:%s' => 'Installation error%s',
'Failed to switch package manager. Please modify the configuration file manually:%s' => 'Package manager switch failed, please modify the configuration file manually%s.',
'Please upgrade %s version' => 'Please upgrade the %s version',
'nothing' => 'Nothing',
'The gd extension and freeType library need to be installed' => 'The gd2 extension and freeType library need to be installed',
'The .env file with database configuration was detected. Please clean up and try again!' => 'The .env file with database configuration was detected. Please clean up and try again!',
];

13
app/api/lang/en/user.php Normal file
View File

@@ -0,0 +1,13 @@
<?php
return [
'captcha' => 'Captcha',
'captchaId' => 'Captcha ID',
'Please input correct username' => 'Please enter the correct username.',
'Please input correct password' => 'Please enter the correct password.',
'Registration parameter error' => 'Wrong registration parameter',
'Login succeeded!' => 'Login succeeded!',
'Please enter the correct verification code' => 'Please enter the correct Captcha.',
'You have already logged in. There is no need to log in again~' => 'You have already logged in, no need to log in again.',
'Check in failed, please try again or contact the website administrator~' => 'Check in failedplease try again or contact the webmaster.',
'Member center disabled' => 'The member centre has been disabled, please contact the webmaster to turn it on.',
];

47
app/api/lang/zh-cn.php Normal file
View File

@@ -0,0 +1,47 @@
<?php
return [
// 时间格式化-s
'%d second%s ago' => '%d秒前',
'%d minute%s ago' => '%d分钟前',
'%d hour%s ago' => '%d小时前',
'%d day%s ago' => '%d天前',
'%d week%s ago' => '%d周前',
'%d month%s ago' => '%d月前',
'%d year%s ago' => '%d年前',
'%d second%s after' => '%d秒后',
'%d minute%s after' => '%d分钟后',
'%d hour%s after' => '%d小时后',
'%d day%s after' => '%d天后',
'%d week%s after' => '%d周后',
'%d month%s after' => '%d月后',
'%d year%s after' => '%d年后',
// 时间格式化-e
// 文件上传-s
'File uploaded successfully' => '文件上传成功!',
'No files were uploaded' => '没有文件被上传',
'The uploaded file format is not allowed' => '上传的文件格式未被允许',
'The uploaded image file is not a valid image' => '上传的图片文件不是有效的图像',
'The uploaded file is too large (%sMiB), Maximum file size:%sMiB' => '上传的文件太大(%sM),最大文件大小:%sM',
'No files have been uploaded or the file size exceeds the upload limit of the server' => '没有文件被上传或文件大小超出服务器上传限制!',
'Topic format error' => '上传存储子目录格式错误!',
'Driver %s not supported' => '不支持的驱动:%s',
// 文件上传-e
'Username' => '用户名',
'Email' => '邮箱',
'Mobile' => '手机号',
'Password' => '密码',
'Login expired, please login again.' => '登录过期,请重新登录。',
'Account not exist' => '帐户不存在',
'Account disabled' => '帐户已禁用',
'Token login failed' => '令牌登录失败',
'Please try again after 1 day' => '登录失败次数超限请在1天后再试',
'Password is incorrect' => '密码不正确',
'You are not logged in' => '你没有登录',
'Unknown operation' => '未知操作',
'No action available, please contact the administrator~' => '没有可用操作,请联系管理员~',
'Please login first' => '请先登录!',
'You have no permission' => '没有权限操作!',
'Parameter error' => '参数错误!',
'Token expiration' => '登录态过期,请重新登录!',
'Captcha error' => '验证码错误!',
];

View File

@@ -0,0 +1,22 @@
<?php
return [
'nickname' => '昵称',
'birthday' => '生日',
'captcha' => '验证码',
'Old password error' => '旧密码错误',
'Data updated successfully~' => '资料更新成功~',
'Please input correct password' => '请输入正确的密码',
'nicknameChsDash' => '用户名只能是汉字、字母、数字和下划线_及破折号-',
'Password has been changed~' => '密码已修改~',
'Password has been changed, please login again~' => '密码已修改,请重新登录~',
'Account does not exist~' => '账户不存在~',
'Failed to modify password, please try again later~' => '修改密码失败,请稍后重试~',
'Please enter the correct verification code' => '请输入正确的验证码!',
'%s has been registered' => '%s已被注册请直接登录~',
'email format error' => '电子邮箱格式错误!',
'mobile format error' => '手机号格式错误!',
'You need to verify your account before modifying the binding information' => '您需要先通过账户验证才能修改绑定信息!',
'Password error' => '密码错误!',
'email is occupied' => '电子邮箱地址已被占用!',
'mobile is occupied' => '手机号已被占用!',
];

View File

@@ -0,0 +1,18 @@
<?php
return [
'email format error' => '电子邮箱格式错误',
'user_register' => '会员注册验证',
'user_change_email' => '修改邮箱验证',
'user_retrieve_pwd' => '找回密码验证',
'user_email_verify' => '会员身份验证',
'Your verification code is: %s' => '您的验证码是:%s十分钟内有效~',
'Mail sent successfully~' => '邮件发送成功~',
'Account does not exist~' => '账户不存在~',
'Mail sending service unavailable' => '邮件发送服务不可用,请联系网站管理员进行配置~',
'Frequent email sending' => '频繁发送电子邮件',
'Email has been registered, please log in directly' => '电子邮箱已注册,请直接登录~',
'The email has been occupied' => '电子邮箱已被占用!',
'Email not registered' => '电子邮箱未注册',
'Please use the account registration email to send the verification code' => '请使用账户注册邮箱发送验证码!',
'Password error' => '密码错误!',
];

View File

@@ -0,0 +1,44 @@
<?php
return [
'Install the controller' => '安装控制器',
'need' => '需要',
'Click to see how to solve it' => '点击查看如何解决',
'Please check the config directory permissions' => '请检查 config 目录权限',
'Please check the public directory permissions' => '请检查 public 目录权限',
'open' => '开启',
'close' => '关闭',
'The installation can continue, and some operations need to be completed manually' => '可以继续安装,部分操作需手动完成',
'Allow execution' => '允许执行',
'disabled' => '已禁用',
'Allow operation' => '允许操作',
'Acquisition failed' => '获取失败',
'Click Install %s' => '点击安装%s',
'Writable' => '可写',
'No write permission' => '无写权限',
'already installed' => '已安装',
'Not installed' => '未安装',
'File has no write permission:%s' => '文件无写入权限:%s',
'The system has completed installation. If you need to reinstall, please delete the %s file first' => '系统已完成安装。如果需要重新安装,请先删除 %s 文件',
'Database connection failed:%s' => '数据库连接失败:%s',
'Failed to install SQL execution:%s' => '安装SQL执行失败%s',
'unknown' => '未知',
'Database does not exist' => '数据库不存在!',
'No built front-end file found, please rebuild manually!' => '没有找到构建好的前端文件,请手动重新构建!',
'Failed to move the front-end file, please move it manually!' => '移动前端文件失败,请手动移动!',
'How to solve?' => '如何解决?',
'View reason' => '查看原因',
'Click to view the reason' => '点击查看原因',
'PDO extensions need to be installed' => '需要安装 pdo_mysql 扩展',
'proc_open or proc_close functions in PHP Ini is disabled' => 'proc_open和proc_close函数在php.ini中被禁用掉了',
'How to modify' => '如何修改',
'Click to view how to modify' => '点击查看如何修改',
'Security assurance?' => '安全保证?',
'Using the installation service correctly will not cause any potential security problems. Click to view the details' => '安装服务使用正确不会造成任何潜在安全问题,点击查看详情',
'Please install NPM first' => '请先安装npm',
'Installation error:%s' => '安装出错:%s',
'Failed to switch package manager. Please modify the configuration file manually:%s' => '包管理器切换失败,请手动修改配置文件:%s',
'Please upgrade %s version' => '请升级%s版本',
'nothing' => '无',
'The gd extension and freeType library need to be installed' => '需要gd2扩展和freeType库',
'The .env file with database configuration was detected. Please clean up and try again!' => '检测到带有数据库配置的 .env 文件。请清理后再试一次!',
];

View File

@@ -0,0 +1,14 @@
<?php
return [
'captcha' => '验证码',
'captchaId' => '验证码标识',
'Register type' => '注册类型',
'Please input correct username' => '请输入正确的用户名',
'Please input correct password' => '请输入正确的密码',
'Registration parameter error' => '注册参数错误',
'Login succeeded!' => '登录成功',
'Please enter the correct verification code' => '请输入正确的验证码',
'You have already logged in. There is no need to log in again~' => '您已经登录过了,无需重复登录~',
'Check in failed, please try again or contact the website administrator~' => '签入失败,请重试或联系网站管理员~',
'Member center disabled' => '会员中心已禁用,请联系网站管理员开启。',
];

5
app/api/middleware.php Normal file
View File

@@ -0,0 +1,5 @@
<?php
return [
\app\common\middleware\AllowCrossDomain::class,
\think\middleware\LoadLangPack::class,
];

View File

@@ -0,0 +1,47 @@
<?php
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();
}
}

67
app/api/validate/User.php Normal file
View File

@@ -0,0 +1,67 @@
<?php
namespace app\api\validate;
use think\Validate;
use think\facade\Config;
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(): User
{
$fields = ['username', 'password'];
// 根据系统配置的登录验证码开关调整验证场景的字段
$userLoginCaptchaSwitch = Config::get('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();
}
}

508
app/common.php Normal file
View File

@@ -0,0 +1,508 @@
<?php
// 应用公共文件
use think\App;
use ba\Filesystem;
use think\Response;
use think\facade\Db;
use think\facade\Lang;
use think\facade\Event;
use think\facade\Config;
use voku\helper\AntiXSS;
use app\admin\model\Config as configModel;
use think\exception\HttpResponseException;
use Symfony\Component\HttpFoundation\IpUtils;
if (!function_exists('__')) {
/**
* 语言翻译
* @param string $name 被翻译字符
* @param array $vars 替换字符数组
* @param string $lang 翻译语言
* @return mixed
*/
function __(string $name, array $vars = [], string $lang = ''): mixed
{
if (is_numeric($name) || !$name) {
return $name;
}
return Lang::get($name, $vars, $lang);
}
}
if (!function_exists('filter')) {
/**
* 输入过滤
* 富文本反XSS请使用 clean_xss也就不需要及不能再 filter 了
* @param string $string 要过滤的字符串
* @return string
*/
function filter(string $string): string
{
// 去除字符串两端空格(对防代码注入有一定作用)
$string = trim($string);
// 过滤html和php标签
$string = strip_tags($string);
// 特殊字符转实体
return htmlspecialchars($string, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401, 'UTF-8');
}
}
if (!function_exists('clean_xss')) {
/**
* 清理XSS
* 通常只用于富文本,比 filter 慢
* @param string $string
* @return string
*/
function clean_xss(string $string): string
{
$antiXss = new AntiXSS();
// 允许 style 属性style="list-style-image: url(javascript:alert(0))" 任然可被正确过滤)
$antiXss->removeEvilAttributes(['style']);
// 检查到 xss 代码之后使用 cleanXss 替换它
$antiXss->setReplacement('cleanXss');
return $antiXss->xss_clean($string);
}
}
if (!function_exists('htmlspecialchars_decode_improve')) {
/**
* html解码增强
* 被 filter函数 内的 htmlspecialchars 编码的字符串,需要用此函数才能完全解码
* @param string $string
* @param int $flags
* @return string
*/
function htmlspecialchars_decode_improve(string $string, int $flags = ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401): string
{
return htmlspecialchars_decode($string, $flags);
}
}
if (!function_exists('get_sys_config')) {
/**
* 获取站点的系统配置,不传递参数则获取所有配置项
* @param string $name 变量名
* @param string $group 变量分组,传递此参数来获取某个分组的所有配置项
* @param bool $concise 是否开启简洁模式,简洁模式下,获取多项配置时只返回配置的键值对
* @return mixed
* @throws Throwable
*/
function get_sys_config(string $name = '', string $group = '', bool $concise = true): mixed
{
if ($name) {
// 直接使用->value('value')不能使用到模型的类型格式化
$config = configModel::cache($name, null, configModel::$cacheTag)->where('name', $name)->find();
if ($config) $config = $config['value'];
} else {
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'];
}
} else {
$config = $temp;
}
}
return $config;
}
}
if (!function_exists('get_route_remark')) {
/**
* 获取当前路由后台菜单规则的备注信息
* @return string
*/
function get_route_remark(): string
{
$controllerName = request()->controller(true);
$actionName = request()->action(true);
$path = str_replace('.', '/', $controllerName);
$remark = Db::name('admin_rule')
->where('name', $path)
->whereOr('name', $path . '/' . $actionName)
->value('remark');
return __((string)$remark);
}
}
if (!function_exists('full_url')) {
/**
* 获取资源完整url地址若安装了云存储或 config/buildadmin.php 配置了CdnUrl则自动使用对应的CdnUrl
* @param string $relativeUrl 资源相对地址 不传入则获取域名
* @param string|bool $domain 是否携带域名 或者直接传入域名
* @param string $default 默认值
* @return string
*/
function full_url(string $relativeUrl = '', string|bool $domain = true, string $default = ''): string
{
// 存储/上传资料配置
Event::trigger('uploadConfigInit', App::getInstance());
$cdnUrl = Config::get('buildadmin.cdn_url');
if (!$cdnUrl) {
$cdnUrl = request()->upload['cdn'] ?? '//' . request()->host();
}
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::get('buildadmin.cdn_url_params');
if ($domain === $cdnUrl && $cdnUrlParams) {
$separator = str_contains($url, '?') ? '&' : '?';
$url .= $separator . $cdnUrlParams;
}
return $url;
}
}
if (!function_exists('encrypt_password')) {
/**
* 加密密码
* @deprecated 使用 hash_password 代替
*/
function encrypt_password($password, $salt = '', $encrypt = 'md5')
{
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')) {
/**
* 验证密码是否和散列值匹配
* @param string $password 密码
* @param string $hash 散列值
* @param array $extend 扩展数据
*/
function verify_password(string $password, string $hash, array $extend = []): bool
{
// 第一个表达式直接检查是否为 password_hash 函数创建的 hash 的典型格式,即:$algo$cost$salt.hash
if (str_starts_with($hash, '$') || password_get_info($hash)['algoName'] != 'unknown') {
return password_verify($password, $hash);
} else {
// 兼容旧版 md5 加密的密码
return encrypt_password($password, $extend['salt'] ?? '') === $hash;
}
}
}
if (!function_exists('str_attr_to_array')) {
/**
* 将字符串属性列表转为数组
* @param string $attr 属性一行一个无需引号比如class=input-class
* @return 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], '.')) {
$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('action_in_arr')) {
/**
* 检测一个方法是否在传递的数组内
* @param array $arr
* @return bool
*/
function action_in_arr(array $arr = []): bool
{
$arr = is_array($arr) ? $arr : explode(',', $arr);
if (!$arr) {
return false;
}
$arr = array_map('strtolower', $arr);
if (in_array(strtolower(request()->action()), $arr) || in_array('*', $arr)) {
return true;
}
return false;
}
}
if (!function_exists('build_suffix_svg')) {
/**
* 构建文件后缀的svg图片
* @param string $suffix 文件后缀
* @param ?string $background 背景颜色rgb(255,255,255)
* @return string
*/
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_area')) {
/**
* 获取省份地区数据
* @throws Throwable
*/
function get_area(): array
{
$province = request()->get('province', '');
$city = 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 Db::name('area')
->where($where)
->field('id as value,name as label')
->select()
->toArray();
}
}
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('ip_check')) {
/**
* IP检查
* @throws Throwable
*/
function ip_check($ip = null): void
{
$ip = is_null($ip) ? request()->ip() : $ip;
$noAccess = get_sys_config('no_access_ip');
$noAccess = !$noAccess ? [] : array_filter(explode("\n", str_replace("\r\n", "\n", $noAccess)));
if ($noAccess && IpUtils::checkIp($ip, $noAccess)) {
$response = Response::create(['msg' => 'No permission request'], 'json', 403);
throw new HttpResponseException($response);
}
}
}
if (!function_exists('set_timezone')) {
/**
* 设置时区
* @throws Throwable
*/
function set_timezone($timezone = null): void
{
$defaultTimezone = Config::get('app.default_timezone');
$timezone = is_null($timezone) ? get_sys_config('time_zone') : $timezone;
if ($timezone && $defaultTimezone != $timezone) {
Config::set([
'app.default_timezone' => $timezone
]);
date_default_timezone_set($timezone);
}
}
}
if (!function_exists('get_upload_config')) {
/**
* 获取上传配置
* @return array
*/
function get_upload_config(): array
{
// 存储/上传资料配置
Event::trigger('uploadConfigInit', App::getInstance());
$uploadConfig = Config::get('upload');
$uploadConfig['max_size'] = Filesystem::fileUnitToByte($uploadConfig['max_size']);
$upload = request()->upload;
if (!$upload) {
$uploadConfig['mode'] = 'local';
return $uploadConfig;
}
unset($upload['cdn']);
return array_merge($upload, $uploadConfig);
}
}
if (!function_exists('get_auth_token')) {
/**
* 获取鉴权 token
* @param array $names
* @return string
*/
function get_auth_token(array $names = ['ba', 'token']): string
{
$separators = [
'header' => ['', '-'], // batoken、ba-token【ba_token 不在 header 的接受列表内因为兼容性不高,改用 http_ba_token】
'param' => ['', '-', '_'], // batoken、ba-token、ba_token
'server' => ['_'], // http_ba_token
];
$tokens = [];
$request = request();
foreach ($separators as $fun => $sps) {
foreach ($sps as $sp) {
$tokens[] = $request->$fun(($fun == 'server' ? 'http_' : '') . implode($sp, $names));
}
}
$tokens = array_filter($tokens);
return array_values($tokens)[0] ?? '';
}
}
if (!function_exists('keys_to_camel_case')) {
/**
* 将数组 key 的命名方式转换为小写驼峰
* @param array $array 被转换的数组
* @param array $keys 要转换的 key默认所有
* @return array
*/
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, false) : $key;
if (is_array($value)) {
// 如果值是数组,递归转换
$result[$camelCaseKey] = keys_to_camel_case($value);
} else {
$result[$camelCaseKey] = $value;
}
}
return $result;
}
}

View File

@@ -0,0 +1,117 @@
<?php
namespace app\common\controller;
use Throwable;
use think\App;
use think\Response;
use think\facade\Db;
use app\BaseController;
use think\db\exception\PDOException;
use think\exception\HttpResponseException;
/**
* API控制器基类
*/
class Api extends BaseController
{
/**
* 默认响应输出类型,支持json/xml/jsonp
* @var string
*/
protected string $responseType = 'json';
/**
* 应用站点系统设置
* @var bool
*/
protected bool $useSystemSettings = true;
public function __construct(App $app)
{
parent::__construct($app);
}
/**
* 控制器初始化方法
* @access protected
* @throws Throwable
*/
protected function initialize(): void
{
// 系统站点配置
if ($this->useSystemSettings) {
// 检查数据库连接
try {
Db::execute("SELECT 1");
} catch (PDOException $e) {
$this->error(mb_convert_encoding($e->getMessage(), 'UTF-8', 'UTF-8,GBK,GB2312,BIG5'));
}
ip_check(); // ip检查
set_timezone(); // 时区设定
}
parent::initialize();
// 加载控制器语言包
$langSet = $this->app->lang->getLangSet();
$this->app->lang->load([
app_path() . 'lang' . DIRECTORY_SEPARATOR . $langSet . DIRECTORY_SEPARATOR . (str_replace('/', DIRECTORY_SEPARATOR, $this->app->request->controllerPath)) . '.php'
]);
}
/**
* 操作成功
* @param string $msg 提示消息
* @param mixed $data 返回数据
* @param int $code 错误码
* @param string|null $type 输出类型
* @param array $header 发送的 header 信息
* @param array $options Response 输出参数
*/
protected function success(string $msg = '', mixed $data = null, int $code = 1, ?string $type = null, array $header = [], array $options = []): void
{
$this->result($msg, $data, $code, $type, $header, $options);
}
/**
* 操作失败
* @param string $msg 提示消息
* @param mixed $data 返回数据
* @param int $code 错误码
* @param string|null $type 输出类型
* @param array $header 发送的 header 信息
* @param array $options Response 输出参数
*/
protected function error(string $msg = '', mixed $data = null, int $code = 0, ?string $type = null, array $header = [], array $options = []): void
{
$this->result($msg, $data, $code, $type, $header, $options);
}
/**
* 返回 API 数据
* @param string $msg 提示消息
* @param mixed $data 返回数据
* @param int $code 错误码
* @param string|null $type 输出类型
* @param array $header 发送的 header 信息
* @param array $options Response 输出参数
*/
public function result(string $msg, mixed $data = null, int $code = 0, ?string $type = null, array $header = [], array $options = [])
{
$result = [
'code' => $code,
'msg' => $msg,
'time' => $this->request->server('REQUEST_TIME'),
'data' => $data,
];
$type = $type ?: $this->responseType;
$code = $header['statusCode'] ?? 200;
$response = Response::create($result, $type, $code)->header($header)->options($options);
throw new HttpResponseException($response);
}
}

View File

@@ -0,0 +1,383 @@
<?php
namespace app\common\controller;
use Throwable;
use think\Model;
use think\facade\Event;
use app\admin\library\Auth;
use app\common\library\token\TokenExpirationException;
class Backend extends Api
{
/**
* 无需登录的方法,访问本控制器的此方法,无需管理员登录
* @var array
*/
protected array $noNeedLogin = [];
/**
* 无需鉴权的方法
* @var array
*/
protected array $noNeedPermission = [];
/**
* 新增/编辑时,对前端发送的字段进行排除(忽略不入库)
* @var array|string
*/
protected array|string $preExcludeFields = [];
/**
* 权限类实例
* @var Auth
*/
protected Auth $auth;
/**
* 模型类实例
* @var object
* @phpstan-var Model
*/
protected object $model;
/**
* 权重字段
* @var string
*/
protected string $weighField = 'weigh';
/**
* 默认排序
* @var string|array id,desc 或 ['id' => 'desc']
*/
protected string|array $defaultSortField = [];
/**
* 有序保证
* 查询数据时总是需要指定 ORDER BY 子句,否则 MySQL 不保证排序,即先查到哪行就输出哪行且不保证多次查询中的输出顺序
* 将以下配置作为数据有序保证(用于无排序字段时、默认排序字段相同时继续保持数据有序),不设置将自动使用 pk 字段
* @var string|array id,desc 或 ['id' => 'desc'](有更方便的格式,此处为了保持和 $defaultSortField 属性的配置格式一致)
*/
protected string|array $orderGuarantee = [];
/**
* 快速搜索字段
* @var string|array
*/
protected string|array $quickSearchField = 'id';
/**
* 是否开启模型验证
* @var bool
*/
protected bool $modelValidate = true;
/**
* 是否开启模型场景验证
* @var bool
*/
protected bool $modelSceneValidate = false;
/**
* 关联查询方法名,方法应定义在模型中
* @var array
*/
protected array $withJoinTable = [];
/**
* 关联查询JOIN方式
* @var string
*/
protected string $withJoinType = 'LEFT';
/**
* 开启数据限制
* false=关闭
* personal=仅限个人
* allAuth=拥有某管理员所有的权限时
* allAuthAndOthers=拥有某管理员所有的权限并且还有其他权限时
* parent=上级分组中的管理员可查
* 指定分组中的管理员可查,比如 $dataLimit = 2;
* 启用请确保数据表内存在 admin_id 字段,可以查询/编辑数据的管理员为admin_id对应的管理员+数据限制所表示的管理员们
* @var bool|string|int
*/
protected bool|string|int $dataLimit = false;
/**
* 数据限制字段
* @var string
*/
protected string $dataLimitField = 'admin_id';
/**
* 数据限制开启时自动填充字段值为当前管理员id
* @var bool
*/
protected bool $dataLimitFieldAutoFill = true;
/**
* 查看请求返回的主表字段控制
* @var string|array
*/
protected string|array $indexField = ['*'];
/**
* 引入traits
* traits内实现了index、add、edit等方法
*/
use \app\admin\library\traits\Backend;
/**
* 初始化
* @throws Throwable
*/
public function initialize(): void
{
parent::initialize();
$needLogin = !action_in_arr($this->noNeedLogin);
try {
// 初始化管理员鉴权实例
$this->auth = Auth::instance();
$token = get_auth_token();
if ($token) $this->auth->init($token);
} catch (TokenExpirationException) {
if ($needLogin) {
$this->error(__('Token expiration'), [], 409);
}
}
if ($needLogin) {
if (!$this->auth->isLogin()) {
$this->error(__('Please login first'), [
'type' => $this->auth::NEED_LOGIN
], $this->auth::LOGIN_RESPONSE_CODE);
}
if (!action_in_arr($this->noNeedPermission)) {
$routePath = ($this->app->request->controllerPath ?? '') . '/' . $this->request->action(true);
if (!$this->auth->check($routePath)) {
$this->error(__('You have no permission'), [], 401);
}
}
}
// 管理员验权和登录标签位
Event::trigger('backendInit', $this->auth);
}
/**
* 查询参数构建器
* @throws Throwable
*/
public function queryBuilder(): array
{
if (empty($this->model)) {
return [];
}
$pk = $this->model->getPk();
$quickSearch = $this->request->get("quickSearch/s", '');
$limit = $this->request->get("limit/d", 10);
$search = $this->request->get("search/a", []);
$initKey = $this->request->get("initKey/s", $pk);
$initValue = $this->request->get("initValue", '');
$initOperator = $this->request->get("initOperator/s", 'in');
$where = [];
$modelTable = strtolower($this->model->getTable());
$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'], (string)$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()
{
$pk = $this->model->getPk();
$order = $this->request->get("order/s") ?: $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)) {
$this->orderGuarantee = explode(',', $this->orderGuarantee);
$this->orderGuarantee = [$this->orderGuarantee[0] => $this->orderGuarantee[1] ?? 'asc'];
}
$orderGuaranteeKey = array_key_first($this->orderGuarantee);
if (!array_key_exists($orderGuaranteeKey, $order)) {
$order[$orderGuaranteeKey] = $this->orderGuarantee[$orderGuaranteeKey];
}
return $order;
}
/**
* 数据权限控制-获取有权限访问的管理员Ids
* @throws Throwable
*/
protected function getDataLimitAdminIds(): array
{
if (!$this->dataLimit || $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);
}
/**
* 从别名获取原始的逻辑运算符
* @param string $operator 逻辑运算符别名
* @return string 原始的逻辑运算符,无别名则原样返回
*/
protected function getOperatorByAlias(string $operator): string
{
$alias = [
'ne' => '<>',
'eq' => '=',
'gt' => '>',
'egt' => '>=',
'lt' => '<',
'elt' => '<=',
];
return $alias[$operator] ?? $operator;
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace app\common\controller;
use Throwable;
use think\facade\Event;
use app\common\library\Auth;
use think\exception\HttpResponseException;
use app\common\library\token\TokenExpirationException;
class Frontend extends Api
{
/**
* 无需登录的方法
* 访问本控制器的此方法,无需会员登录
* @var array
*/
protected array $noNeedLogin = [];
/**
* 无需鉴权的方法
* @var array
*/
protected array $noNeedPermission = [];
/**
* 权限类实例
* @var Auth
*/
protected Auth $auth;
/**
* 初始化
* @throws Throwable
* @throws HttpResponseException
*/
public function initialize(): void
{
parent::initialize();
$needLogin = !action_in_arr($this->noNeedLogin);
try {
// 初始化会员鉴权实例
$this->auth = Auth::instance();
$token = get_auth_token(['ba', 'user', 'token']);
if ($token) $this->auth->init($token);
} catch (TokenExpirationException) {
if ($needLogin) {
$this->error(__('Token expiration'), [], 409);
}
}
if ($needLogin) {
if (!$this->auth->isLogin()) {
$this->error(__('Please login first'), [
'type' => $this->auth::NEED_LOGIN
], $this->auth::LOGIN_RESPONSE_CODE);
}
if (!action_in_arr($this->noNeedPermission)) {
$routePath = ($this->app->request->controllerPath ?? '') . '/' . $this->request->action(true);
if (!$this->auth->check($routePath)) {
$this->error(__('You have no permission'), [], 401);
}
}
}
// 会员验权和登录标签位
Event::trigger('frontendInit', $this->auth);
}
}

View File

@@ -0,0 +1,139 @@
<?php
namespace app\common\event;
use Throwable;
use think\Request;
use ba\TableManager;
use think\facade\Db;
use think\facade\Log;
use app\admin\library\Auth;
use app\admin\model\SensitiveDataLog;
use app\admin\model\DataRecycle;
use app\admin\model\DataRecycleLog;
use app\admin\model\SensitiveData;
class Security
{
protected array $listenAction = ['edit', 'del'];
public function handle(Request $request): bool
{
$action = $request->action(true);
if (!in_array($action, $this->listenAction) || (!$request->isPost() && !$request->isDelete())) {
return true;
}
if ($action == 'del') {
$dataIds = $request->param('ids');
try {
$recycle = DataRecycle::where('status', 1)
->where('controller_as', $request->controllerPath)
->find();
if (!$recycle) {
return true;
}
$recycleData = Db::connect(TableManager::getConnection($recycle['connection']))
->name($recycle['data_table'])
->whereIn($recycle['primary_key'], $dataIds)
->select()
->toArray();
$recycleDataArr = [];
$auth = Auth::instance();
$adminId = $auth->isLogin() ? $auth->id : 0;
foreach ($recycleData as $recycleDatum) {
$recycleDataArr[] = [
'admin_id' => $adminId,
'recycle_id' => $recycle['id'],
'data' => json_encode($recycleDatum, JSON_UNESCAPED_UNICODE),
'connection' => $recycle['connection'],
'data_table' => $recycle['data_table'],
'primary_key' => $recycle['primary_key'],
'ip' => $request->ip(),
'useragent' => substr($request->server('HTTP_USER_AGENT'), 0, 255),
];
}
if (!$recycleDataArr) {
return true;
}
// saveAll 方法自带事务
$dataRecycleLogModel = new DataRecycleLog();
if (!$dataRecycleLogModel->saveAll($recycleDataArr)) {
Log::record('[ DataSecurity ] Failed to recycle data:' . var_export($recycleDataArr, true), 'warning');
}
} catch (Throwable $e) {
Log::record('[ DataSecurity ]' . $e->getMessage(), 'warning');
}
return true;
}
try {
$sensitiveData = SensitiveData::where('status', 1)
->where('controller_as', $request->controllerPath)
->find();
if (!$sensitiveData) {
return true;
}
$sensitiveData = $sensitiveData->toArray();
$dataId = $request->param($sensitiveData['primary_key']);
$editData = Db::connect(TableManager::getConnection($sensitiveData['connection']))
->name($sensitiveData['data_table'])
->field(array_keys($sensitiveData['data_fields']))
->where($sensitiveData['primary_key'], $dataId)
->find();
if (!$editData) {
return true;
}
$auth = Auth::instance();
$adminId = $auth->isLogin() ? $auth->id : 0;
$newData = $request->post();
foreach ($sensitiveData['data_fields'] as $field => $title) {
if (isset($editData[$field]) && isset($newData[$field]) && $editData[$field] != $newData[$field]) {
/*
* 其他跳过规则可添加至此处
* 1. 如果字段名中包含 password修改值为空则忽略修改值不为空则密码记录为 ******
*/
if (stripos('password', $field) !== false) {
if (!$newData[$field]) {
continue;
} else {
$newData[$field] = "******";
}
}
$sensitiveDataLog[] = [
'admin_id' => $adminId,
'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' => $newData[$field],
'ip' => $request->ip(),
'useragent' => substr($request->server('HTTP_USER_AGENT'), 0, 255),
];
}
}
if (!isset($sensitiveDataLog) || !$sensitiveDataLog) {
return true;
}
$sensitiveDataLogModel = new SensitiveDataLog();
if (!$sensitiveDataLogModel->saveAll($sensitiveDataLog)) {
Log::record('[ DataSecurity ] Sensitive data recording failed:' . var_export($sensitiveDataLog, true), 'warning');
}
} catch (Throwable $e) {
Log::record('[ DataSecurity ]' . $e->getMessage(), 'warning');
}
return true;
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace app\common\facade;
use think\Facade;
use app\common\library\token\Driver;
/**
* Token 门面类
* @see Driver
* @method array get(string $token) static 获取 token 的数据
* @method bool set(string $token, string $type, int $userId, int $expire = null) static 设置 token
* @method bool check(string $token, string $type, int $userId) static 检查token是否有效
* @method bool delete(string $token) static 删除一个token
* @method bool clear(string $type, int $userId) static 清理一个用户的所有token
* @method void tokenExpirationCheck(array $token) static 检查一个token是否过期过期则抛出token过期异常
*/
class Token extends Facade
{
protected static function getFacadeClass(): string
{
return 'app\common\library\Token';
}
}

562
app/common/library/Auth.php Normal file
View File

@@ -0,0 +1,562 @@
<?php
namespace app\common\library;
use Throwable;
use ba\Random;
use think\facade\Db;
use think\facade\Event;
use think\facade\Config;
use app\common\model\User;
use think\facade\Validate;
use app\common\facade\Token;
/**
* 公共权限类(会员权限类)
* @property int $id 会员ID
* @property string $username 会员用户名
* @property string $nickname 会员昵称
* @property string $email 会员邮箱
* @property string $mobile 会员手机号
* @property string $password 密码密文
* @property string $salt 密码盐
*/
class Auth extends \ba\Auth
{
/**
* 需要登录时/无需登录时的响应状态代码
*/
public const LOGIN_RESPONSE_CODE = 303;
/**
* 需要登录标记 - 前台应清理 token、记录当前路由 path、跳转到登录页
*/
public const NEED_LOGIN = 'need login';
/**
* 已经登录标记 - 前台应跳转到基础路由
*/
public const LOGGED_IN = 'logged in';
/**
* token 入库 type
*/
public const TOKEN_TYPE = 'user';
/**
* 是否登录
* @var bool
*/
protected bool $loginEd = false;
/**
* 错误消息
* @var string
*/
protected string $error = '';
/**
* Model实例
* @var ?User
*/
protected ?User $model = null;
/**
* 令牌
* @var string
*/
protected string $token = '';
/**
* 刷新令牌
* @var string
*/
protected string $refreshToken = '';
/**
* 令牌默认有效期
* 可在 config/buildadmin.php 内修改默认值
* @var int
*/
protected int $keepTime = 86400;
/**
* 刷新令牌有效期
* @var int
*/
protected int $refreshTokenKeepTime = 2592000;
/**
* 允许输出的字段
* @var array
*/
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::get('buildadmin.user_token_keep_time'));
}
/**
* 魔术方法-会员信息字段
* @param $name
* @return mixed 字段信息
*/
public function __get($name): mixed
{
return $this->model?->$name;
}
/**
* 初始化
* @access public
* @param array $options 传递给 /ba/Auth 的参数
* @return Auth
*/
public static function instance(array $options = []): Auth
{
$request = request();
if (!isset($request->userAuth)) {
$request->userAuth = new static($options);
}
return $request->userAuth;
}
/**
* 根据Token初始化会员登录态
* @param $token
* @return bool
* @throws Throwable
*/
public function init($token): bool
{
$tokenData = Token::get($token);
if ($tokenData) {
/**
* 过期检查,过期则抛出 @see TokenExpirationException
*/
Token::tokenExpirationCheck($tokenData);
$userId = intval($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;
}
/**
* 会员注册,可使用关键词参数方式调用:$auth->register('u18888888888', email: 'test@qq.com')
* @param string $username
* @param string $password
* @param string $mobile
* @param string $email
* @param int $group 会员分组 ID 号
* @param array $extend 扩展数据,如 ['status' => 'disable']
* @return bool
*/
public function register(string $username, string $password = '', string $mobile = '', string $email = '', int $group = 1, array $extend = []): bool
{
$validate = Validate::rule([
'email|' . __('Email') => 'email|unique:user',
'mobile|' . __('Mobile') => 'mobile|unique:user',
'username|' . __('Username') => 'require|regex:^[a-zA-Z][a-zA-Z0-9_]{2,15}$|unique:user',
'password|' . __('Password') => 'regex:^(?!.*[&<>"\'\n\r]).{6,32}$',
]);
$params = [
'username' => $username,
'password' => $password,
'mobile' => $mobile,
'email' => $email,
];
if (!$validate->check($params)) {
$this->setError($validate->getError());
return false;
}
// 用户昵称
$nickname = preg_replace_callback('/1[3-9]\d{9}/', function ($matches) {
// 对 username 中出现的所有手机号进行脱敏处理
$mobile = $matches[0];
return substr($mobile, 0, 3) . '****' . substr($mobile, 7);
}, $username);
$ip = request()->ip();
$time = time();
$data = [
'group_id' => $group,
'nickname' => $nickname,
'join_ip' => $ip,
'join_time' => $time,
'last_login_ip' => $ip,
'last_login_time' => $time,
'status' => 'enable', // 状态:enable=启用,disable=禁用,使用 string 存储可以自定义其他状态
];
$data = array_merge($params, $data);
$data = array_merge($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;
}
/**
* 会员登录
* @param string $username 用户名
* @param string $password 密码
* @param bool $keep 是否保持登录
* @return bool
* @throws Throwable
*/
public function login(string $username, string $password, bool $keep): bool
{
// 判断账户类型
$accountType = false;
$validate = Validate::rule([
'mobile' => 'mobile',
'email' => 'email',
'username' => 'regex:^[a-zA-Z][a-zA-Z0-9_]{2,15}$',
]);
if ($validate->check(['mobile' => $username])) $accountType = 'mobile';
if ($validate->check(['email' => $username])) $accountType = 'email';
if ($validate->check(['username' => $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::get('buildadmin.user_login_retry');
if ($userLoginRetry && $this->model->last_login_time) {
// 重置失败次数
if ($this->model->login_failure > 0 && time() - $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;
}
// 清理 token
if (Config::get('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;
}
/**
* 直接登录会员账号
* @param int $userId 用户ID
* @return bool
* @throws Throwable
*/
public function direct(int $userId): bool
{
$this->model = User::find($userId);
if (!$this->model) return false;
if (Config::get('buildadmin.user_sso')) {
Token::clear(self::TOKEN_TYPE, $this->model->id);
Token::clear(self::TOKEN_TYPE . '-refresh', $this->model->id);
}
return $this->loginSuccessful();
}
/**
* 检查旧密码是否正确
* @param $password
* @return bool
* @deprecated 请使用 verify_password 公共函数代替
*/
public function checkPassword($password): bool
{
return verify_password($password, $this->model->password, ['salt' => $this->model->salt]);
}
/**
* 登录成功
* @return bool
*/
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 = request()->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;
}
/**
* 登录失败
* @return bool
*/
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 = request()->ip();
$this->model->save();
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
$this->setError($e->getMessage());
return false;
}
return $this->reset();
}
/**
* 退出登录
* @return bool
*/
public function logout(): bool
{
if (!$this->loginEd) {
$this->setError('You are not logged in');
return false;
}
return $this->reset();
}
/**
* 是否登录
* @return bool
*/
public function isLogin(): bool
{
return $this->loginEd;
}
/**
* 获取会员模型
* @return User
*/
public function getUser(): User
{
return $this->model;
}
/**
* 获取会员Token
* @return string
*/
public function getToken(): string
{
return $this->token;
}
/**
* 设置刷新Token
* @param int $keepTime
* @return void
*/
public function setRefreshToken(int $keepTime = 0): void
{
$this->refreshToken = Random::uuid();
Token::set($this->refreshToken, self::TOKEN_TYPE . '-refresh', $this->model->id, $keepTime);
}
/**
* 获取会员刷新Token
* @return string
*/
public function getRefreshToken(): string
{
return $this->refreshToken;
}
/**
* 获取会员信息 - 只输出允许输出的字段
* @return array
*/
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;
}
/**
* 获取允许输出字段
* @return array
*/
public function getAllowFields(): array
{
return $this->allowFields;
}
/**
* 设置允许输出字段
* @param $fields
* @return void
*/
public function setAllowFields($fields): void
{
$this->allowFields = $fields;
}
/**
* 设置Token有效期
* @param int $keepTime
* @return void
*/
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);
}
/**
* 是否是拥有所有权限的会员
* @return bool
* @throws Throwable
*/
public function isSuperUser(): bool
{
return in_array('*', $this->getRuleIds());
}
/**
* 设置错误消息
* @param string $error
* @return Auth
*/
public function setError(string $error): Auth
{
$this->error = $error;
return $this;
}
/**
* 获取错误消息
* @return string
*/
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::get('buildadmin.user_token_keep_time'));
return true;
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace app\common\library;
use Throwable;
use think\facade\Lang;
use PHPMailer\PHPMailer\PHPMailer;
/**
* 邮件类
* 继承PHPMailer并初始化好了站点系统配置中的邮件配置信息
*/
class Email extends PHPMailer
{
/**
* 是否已在管理后台配置好邮件服务
* @var bool
*/
public bool $configured = false;
/**
* 默认配置
* @var array
*/
public array $options = [
'charset' => 'utf-8', //编码格式
'debug' => true, //调式模式
'lang' => 'zh_cn',
];
/**
* 构造函数
* @param array $options
* @throws Throwable
*/
public function __construct(array $options = [])
{
$this->options = array_merge($this->options, $options);
parent::__construct($this->options['debug']);
$langSet = Lang::getLangSet();
if ($langSet == 'zh-cn' || !$langSet) $langSet = 'zh_cn';
$this->options['lang'] = $this->options['lang'] ?: $langSet;
$this->setLanguage($this->options['lang'], root_path() . 'vendor' . DIRECTORY_SEPARATOR . 'phpmailer' . DIRECTORY_SEPARATOR . 'phpmailer' . DIRECTORY_SEPARATOR . 'language' . DIRECTORY_SEPARATOR);
$this->CharSet = $this->options['charset'];
$sysMailConfig = get_sys_config('', 'mail');
$this->configured = true;
foreach ($sysMailConfig as $item) {
if (!$item) {
$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'];
$this->setFrom($sysMailConfig['smtp_sender_mail'], $sysMailConfig['smtp_user']);
}
}
public function setSubject($subject): void
{
$this->Subject = "=?utf-8?B?" . base64_encode($subject) . "?=";
}
}

156
app/common/library/Menu.php Normal file
View File

@@ -0,0 +1,156 @@
<?php
namespace app\common\library;
use Throwable;
use app\admin\model\AdminRule;
use app\admin\model\UserRule;
/**
* 菜单规则管理类
*/
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') {
// 忽略同名菜单时,当前 pid 下没有同名菜单,则创建同名新菜单,以保证所有新增菜单的上下级结构
$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);
}
}
}
/**
* 删菜单
* @param string|int $id 规则name或id
* @param bool $recursion 是否递归删除子级菜单、是否删除自身,是否删除上级空菜单
* @param string $position 位置:backend=后台,frontend=前台
* @return bool
* @throws Throwable
*/
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;
}
/**
* 启用菜单
* @param string|int $id 规则name或id
* @param string $position 位置:backend=后台,frontend=前台
* @return bool
* @throws Throwable
*/
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;
}
/**
* 禁用菜单
* @param string|int $id 规则name或id
* @param string $position 位置:backend=后台,frontend=前台
* @return bool
* @throws Throwable
*/
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,87 @@
<?php
namespace app\common\library;
/**
* 雪花ID生成类
*/
class SnowFlake
{
/**
* 起始时间戳
* @var int
*/
private const EPOCH = 1672502400000;
/**
* @var int
*/
private const max41bit = 1099511627775;
/**
* 机器节点 10bit
* @var int
*/
protected static int $machineId = 1;
/**
* 序列号
* @var int
*/
protected static int $count = 0;
/**
* 最后一次生成ID的时间偏移量
* @var int
*/
protected static int $last = 0;
/**
* 设置机器节点
* @param int $mId 机器节点id
* @return void
*/
public static function setMachineId(int $mId): void
{
self::$machineId = $mId;
}
/**
* 生成雪花ID
* @return float|int
*/
public static function generateParticle(): float|int
{
// 当前时间 42bit
$time = (int)floor(microtime(true) * 1000);
// 时间偏移量
$time -= self::EPOCH;
// 起始时间戳加上时间偏移量并转为二进制
$base = decbin(self::max41bit + $time);
// 追加节点机器id
if (!is_null(self::$machineId)) {
$machineId = str_pad(decbin(self::$machineId), 10, "0", STR_PAD_LEFT);
$base .= $machineId;
}
// 序列号
if ($time == self::$last) {
self::$count++;
} else {
self::$count = 0;
}
// 追加序列号部分
$sequence = str_pad(decbin(self::$count), 12, "0", STR_PAD_LEFT);
$base .= $sequence;
// 保存生成ID的时间偏移量
self::$last = $time;
// 返回64bit二进制数的十进制标识
return bindec($base);
}
}

View File

@@ -0,0 +1,232 @@
<?php
namespace app\common\library;
use think\helper\Arr;
use think\helper\Str;
use think\facade\Config;
use InvalidArgumentException;
use app\common\library\token\TokenExpirationException;
/**
* Token 管理类
*/
class Token
{
/**
* Token 实例
* @var array
* @uses Token 数组项
*/
public array $instance = [];
/**
* token驱动类句柄
* @var ?object
*/
public ?object $handler = null;
/**
* 驱动类命名空间
* @var string
*/
protected string $namespace = '\\app\\common\\library\\token\\driver\\';
/**
* 获取驱动句柄
* @param string|null $name
* @return object
*/
public function getDriver(?string $name = null): object
{
if (!is_null($this->handler)) {
return $this->handler;
}
$name = $name ?: $this->getDefaultDriver();
if (is_null($name)) {
throw new InvalidArgumentException(
sprintf(
'Unable to resolve NULL driver for [%s].',
static::class
)
);
}
return $this->createDriver($name);
}
/**
* 创建驱动句柄
* @param string $name
* @return object
*/
protected function createDriver(string $name): object
{
$type = $this->resolveType($name);
$method = 'create' . Str::studly($type) . 'Driver';
$params = $this->resolveParams($name);
if (method_exists($this, $method)) {
return $this->$method(...$params);
}
$class = $this->resolveClass($type);
if (isset($this->instance[$type])) {
return $this->instance[$type];
}
return new $class(...$params);
}
/**
* 默认驱动
* @return string
*/
protected function getDefaultDriver(): string
{
return $this->getConfig('default');
}
/**
* 获取驱动配置
* @param string|null $name 要获取的配置项不传递获取完整token配置
* @param mixed $default
* @return array|string
*/
protected function getConfig(?string $name = null, $default = null): array|string
{
if (!is_null($name)) {
return Config::get('buildadmin.token.' . $name, $default);
}
return Config::get('buildadmin.token');
}
/**
* 获取驱动配置参数
* @param $name
* @return array
*/
protected function resolveParams($name): array
{
$config = $this->getStoreConfig($name);
return [$config];
}
/**
* 获取驱动类
* @param string $type
* @return string
*/
protected function resolveClass(string $type): string
{
if ($this->namespace || str_contains($type, '\\')) {
$class = str_contains($type, '\\') ? $type : $this->namespace . Str::studly($type);
if (class_exists($class)) {
return $class;
}
}
throw new InvalidArgumentException("Driver [$type] not supported.");
}
/**
* 获取驱动配置
* @param string $store
* @param string|null $name
* @param mixed $default
* @return array|string
*/
protected function getStoreConfig(string $store, ?string $name = null, $default = null): array|string
{
if ($config = $this->getConfig("stores.$store")) {
return Arr::get($config, $name, $default);
}
throw new InvalidArgumentException("Store [$store] not found.");
}
/**
* 获取驱动类型
* @param string $name
* @return string
*/
protected function resolveType(string $name): string
{
return $this->getStoreConfig($name, 'type', 'Mysql');
}
/**
* 设置token
* @param string $token
* @param string $type
* @param int $user_id
* @param int|null $expire
* @return bool
*/
public function set(string $token, string $type, int $user_id, ?int $expire = null): bool
{
return $this->getDriver()->set($token, $type, $user_id, $expire);
}
/**
* 获取token
* @param string $token
* @param bool $expirationException
* @return array
*/
public function get(string $token, bool $expirationException = true): array
{
return $this->getDriver()->get($token, $expirationException);
}
/**
* 检查token
* @param string $token
* @param string $type
* @param int $user_id
* @param bool $expirationException
* @return bool
*/
public function check(string $token, string $type, int $user_id, bool $expirationException = true): bool
{
return $this->getDriver()->check($token, $type, $user_id, $expirationException);
}
/**
* 删除token
* @param string $token
* @return bool
*/
public function delete(string $token): bool
{
return $this->getDriver()->delete($token);
}
/**
* 清理指定用户token
* @param string $type
* @param int $user_id
* @return bool
*/
public function clear(string $type, int $user_id): bool
{
return $this->getDriver()->clear($type, $user_id);
}
/**
* 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,341 @@
<?php
namespace app\common\library;
use Throwable;
use ba\Random;
use ba\Filesystem;
use think\Exception;
use think\helper\Str;
use think\facade\Config;
use think\facade\Validate;
use think\file\UploadedFile;
use InvalidArgumentException;
use think\validate\ValidateRule;
use app\common\model\Attachment;
use app\common\library\upload\Driver;
/**
* 上传
*/
class Upload
{
/**
* 上传配置
*/
protected array $config = [];
/**
* 被上传文件
*/
protected ?UploadedFile $file = null;
/**
* 是否是图片
*/
protected bool $isImage = false;
/**
* 文件信息
*/
protected array $fileInfo;
/**
* 上传驱动
*/
protected array $driver = [
'name' => 'local', // 默认驱动:local=本地
'handler' => [], // 驱动句柄
'namespace' => '\\app\\common\\library\\upload\\driver\\', // 驱动类的命名空间
];
/**
* 存储子目录
*/
protected string $topic = 'default';
/**
* 构造方法
* @param ?UploadedFile $file 上传的文件
* @param array $config 配置
* @throws Throwable
*/
public function __construct(?UploadedFile $file = null, array $config = [])
{
$upload = Config::get('upload');
$this->config = array_merge($upload, $config);
if ($file) {
$this->setFile($file);
}
}
/**
* 设置上传文件
* @param ?UploadedFile $file
* @return Upload
* @throws Throwable
*/
public function setFile(?UploadedFile $file): Upload
{
if (empty($file)) {
throw new Exception(__('No files were uploaded'));
}
$suffix = strtolower($file->extension());
$suffix = $suffix && preg_match("/^[a-zA-Z0-9]+$/", $suffix) ? $suffix : 'file';
$fileInfo['suffix'] = $suffix;
$fileInfo['type'] = $file->getMime();
$fileInfo['size'] = $file->getSize();
$fileInfo['name'] = $file->getOriginalName();
$fileInfo['sha1'] = $file->sha1();
$this->file = $file;
$this->fileInfo = $fileInfo;
return $this;
}
/**
* 设置上传驱动
*/
public function setDriver(string $driver): Upload
{
$this->driver['name'] = $driver;
return $this;
}
/**
* 获取上传驱动句柄
* @param ?string $driver 驱动名称
* @param bool $noDriveException 找不到驱动是否抛出异常
* @return bool|Driver
*/
public function getDriver(?string $driver = null, bool $noDriveException = true): bool|Driver
{
if (is_null($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): bool|string
{
if ($this->driver['namespace'] || str_contains($driver, '\\')) {
$class = str_contains($driver, '\\') ? $driver : $this->driver['namespace'] . Str::studly($driver);
if (class_exists($class)) {
return $class;
}
}
return false;
}
/**
* 设置存储子目录
*/
public function setTopic(string $topic): Upload
{
$this->topic = $topic;
return $this;
}
/**
* 检查是否是图片并设置好相关属性
* @return bool
* @throws Throwable
*/
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'])) {
$imgInfo = getimagesize($this->file->getPathname());
if (!$imgInfo || !isset($imgInfo[0]) || !isset($imgInfo[1])) {
throw new Exception(__('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;
}
/**
* 上传的文件是否为图片
* @return bool
*/
public function isImage(): bool
{
return $this->isImage;
}
/**
* 获取文件后缀
* @return string
*/
public function getSuffix(): string
{
return $this->fileInfo['suffix'] ?: 'file';
}
/**
* 获取文件保存路径和名称
* @param ?string $saveName
* @param ?string $filename
* @param ?string $sha1
* @return string
*/
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));
}
/**
* 验证文件是否符合上传配置要求
* @throws Throwable
*/
public function validates(): void
{
if (empty($this->file)) {
throw new Exception(__('No files have been uploaded or the file size exceeds the upload limit of the server'));
}
$size = Filesystem::fileUnitToByte($this->config['max_size']);
$mime = $this->checkConfig($this->config['allowed_mime_types']);
$suffix = $this->checkConfig($this->config['allowed_suffixes']);
// 文件大小
$fileValidateRule = ValidateRule::fileSize($size, __('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) {
$fileValidateRule->fileExt($suffix, __('The uploaded file format is not allowed'));
}
// 文件 MIME 类型
if ($mime) {
$fileValidateRule->fileMime($mime, __('The uploaded file format is not allowed'));
}
// 图片文件利用tp内置规则做一些额外检查
if ($this->checkIsImage()) {
$fileValidateRule->image("{$this->fileInfo['width']},{$this->fileInfo['height']}", __('The uploaded image file is not a valid image'));
}
Validate::failException()
->rule([
'file' => $fileValidateRule,
'topic' => ValidateRule::is('alphaDash', __('Topic format error')),
'driver' => ValidateRule::is('alphaDash', __('Driver %s not supported', [$this->driver['name']])),
])
->check([
'file' => $this->file,
'topic' => $this->topic,
'driver' => $this->driver['name'],
]);
}
/**
* 上传文件
* @param ?string $saveName
* @param int $adminId
* @param int $userId
* @return array
* @throws Throwable
*/
public function upload(?string $saveName = null, int $adminId = 0, int $userId = 0): array
{
$this->validates();
$driver = $this->getDriver();
$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
{
// 对 $fileName 中不利于传输的字符串进行过滤
$pattern = "/[\s:@#?&\/=',+]+/u";
$fileName = str_replace(".$suffix", '', $fileName);
$fileName = preg_replace($pattern, '', $fileName);
return mb_substr(htmlspecialchars(strip_tags($fileName)), 0, $length);
}
/**
* 检查配置项,将 string 类型的配置转换为 array并且将所有字母转换为小写
*/
protected function checkConfig($configItem): array
{
if (is_array($configItem)) {
return array_map('strtolower', $configItem);
} else {
return explode(',', strtolower($configItem));
}
}
}

View File

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

View File

@@ -0,0 +1,16 @@
<?php
namespace app\common\library\token;
use think\Exception;
/**
* Token过期异常
*/
class TokenExpirationException extends Exception
{
public function __construct(protected $message = '', protected $code = 409, protected $data = [])
{
parent::__construct($message, $code);
}
}

View File

@@ -0,0 +1,109 @@
<?php
namespace app\common\library\token\driver;
use Throwable;
use think\facade\Db;
use think\facade\Cache;
use app\common\library\token\Driver;
/**
* @see Driver
*/
class Mysql extends Driver
{
/**
* 默认配置
* @var array
*/
protected array $options = [];
/**
* 构造函数
* @access public
* @param array $options 参数
*/
public function __construct(array $options = [])
{
if (!empty($options)) {
$this->options = array_merge($this->options, $options);
}
if ($this->options['name']) {
$this->handler = Db::connect($this->options['name'])->name($this->options['table']);
} else {
$this->handler = Db::name($this->options['table']);
}
}
/**
* @throws Throwable
*/
public function set(string $token, string $type, int $userId, ?int $expire = null): bool
{
if (is_null($expire)) {
$expire = $this->options['expire'];
}
$expireTime = $expire !== 0 ? time() + $expire : 0;
$token = $this->getEncryptedToken($token);
$this->handler->insert([
'token' => $token,
'type' => $type,
'user_id' => $userId,
'create_time' => time(),
'expire_time' => $expireTime,
]);
// 每隔48小时清理一次过期Token
$time = time();
$lastCacheCleanupTime = Cache::get('last_cache_cleanup_time');
if (!$lastCacheCleanupTime || $lastCacheCleanupTime < $time - 172800) {
Cache::set('last_cache_cleanup_time', $time);
$this->handler->where('expire_time', '<', time())->where('expire_time', '>', 0)->delete();
}
return true;
}
/**
* @throws Throwable
*/
public function get(string $token): array
{
$data = $this->handler->where('token', $this->getEncryptedToken($token))->find();
if (!$data) {
return [];
}
$data['token'] = $token; // 返回未加密的token给客户端使用
$data['expires_in'] = $this->getExpiredIn($data['expire_time'] ?? 0); // 返回剩余有效时间
return $data;
}
/**
* @throws Throwable
*/
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;
}
/**
* @throws Throwable
*/
public function delete(string $token): bool
{
$this->handler->where('token', $this->getEncryptedToken($token))->delete();
return true;
}
/**
* @throws Throwable
*/
public function clear(string $type, int $userId): bool
{
$this->handler->where('type', $type)->where('user_id', $userId)->delete();
return true;
}
}

View File

@@ -0,0 +1,146 @@
<?php
namespace app\common\library\token\driver;
use Throwable;
use BadFunctionCallException;
use app\common\library\token\Driver;
/**
* @see Driver
*/
class Redis extends Driver
{
/**
* 默认配置
* @var array
*/
protected array $options = [];
/**
* Token 过期后缓存继续保留的时间(s)
*/
protected int $expiredHold = 60 * 60 * 24 * 2;
/**
* 构造函数
* @access public
* @param array $options 参数
* @throws Throwable
*/
public function __construct(array $options = [])
{
if (!extension_loaded('redis')) {
throw new BadFunctionCallException('未安装redis扩展');
}
if (!empty($options)) {
$this->options = array_merge($this->options, $options);
}
$this->handler = new \Redis();
if ($this->options['persistent']) {
$this->handler->pconnect($this->options['host'], $this->options['port'], $this->options['timeout'], 'persistent_id_' . $this->options['select']);
} else {
$this->handler->connect($this->options['host'], $this->options['port'], $this->options['timeout']);
}
if ('' != $this->options['password']) {
$this->handler->auth($this->options['password']);
}
if (false !== $this->options['select']) {
$this->handler->select($this->options['select']);
}
}
/**
* @throws Throwable
*/
public function set(string $token, string $type, int $userId, ?int $expire = null): bool
{
if (is_null($expire)) {
$expire = $this->options['expire'];
}
$expireTime = $expire !== 0 ? time() + $expire : 0;
$token = $this->getEncryptedToken($token);
$tokenInfo = [
'token' => $token,
'type' => $type,
'user_id' => $userId,
'create_time' => time(),
'expire_time' => $expireTime,
];
$tokenInfo = json_encode($tokenInfo, JSON_UNESCAPED_UNICODE);
if ($expire) {
$expire += $this->expiredHold;
$result = $this->handler->setex($token, $expire, $tokenInfo);
} else {
$result = $this->handler->set($token, $tokenInfo);
}
$this->handler->sAdd($this->getUserKey($type, $userId), $token);
return $result;
}
/**
* @throws Throwable
*/
public function get(string $token): array
{
$key = $this->getEncryptedToken($token);
$data = $this->handler->get($key);
if (is_null($data) || false === $data) {
return [];
}
$data = json_decode($data, true);
$data['token'] = $token; // 返回未加密的token给客户端使用
$data['expires_in'] = $this->getExpiredIn($data['expire_time'] ?? 0); // 过期时间
return $data;
}
/**
* @throws Throwable
*/
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;
}
/**
* @throws Throwable
*/
public function delete(string $token): bool
{
$data = $this->get($token);
if ($data) {
$key = $this->getEncryptedToken($token);
$this->handler->del($key);
$this->handler->sRem($this->getUserKey($data['type'], $data['user_id']), $key);
}
return true;
}
/**
* @throws Throwable
*/
public function clear(string $type, int $userId): bool
{
$userKey = $this->getUserKey($type, $userId);
$keys = $this->handler->sMembers($userKey);
$this->handler->del($userKey);
$this->handler->del($keys);
return true;
}
/**
* 获取会员的key
* @param $type
* @param $userId
* @return string
*/
protected function getUserKey($type, $userId): string
{
return $this->options['prefix'] . $type . '-' . $userId;
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace app\common\library\upload;
use think\file\UploadedFile;
/**
* 上传驱动抽象类
*/
abstract class Driver
{
/**
* @var array 配置数据
*/
protected array $options = [];
/**
* 保存文件
* @param UploadedFile $file
* @param string $saveName
* @return bool
*/
abstract public function save(UploadedFile $file, string $saveName): bool;
/**
* 删除文件
* @param string $saveName
* @return bool
*/
abstract public function delete(string $saveName): bool;
/**
* 获取资源 URL 地址;
* @param string $saveName 资源保存名称
* @param string|bool $domain 是否携带域名 或者直接传入域名
* @param string $default 默认值
* @return string
*/
abstract public function url(string $saveName, string|bool $domain = true, string $default = ''): string;
/**
* 文件是否存在
* @param string $saveName
* @return bool
*/
abstract public function exists(string $saveName): bool;
}

View File

@@ -0,0 +1,148 @@
<?php
namespace app\common\library\upload\driver;
use ba\Filesystem;
use think\facade\Config;
use think\file\UploadedFile;
use think\exception\FileException;
use app\common\library\upload\Driver;
/**
* 上传到本地磁盘的驱动
* @see Driver
*/
class Local extends Driver
{
protected array $options = [];
public function __construct(array $options = [])
{
$this->options = Config::get('filesystem.disks.public');
if (!empty($options)) {
$this->options = array_merge($this->options, $options);
}
}
/**
* 保存文件
* @param UploadedFile $file
* @param string $saveName
* @return bool
*/
public function save(UploadedFile $file, string $saveName): bool
{
$savePathInfo = pathinfo($saveName);
$saveFullPath = $this->getFullPath($saveName);
// cgi 直接 move
if (request()->isCgi()) {
$file->move($saveFullPath, $savePathInfo['basename']);
return true;
}
set_error_handler(function ($type, $msg) use (&$error) {
$error = $msg;
});
// 建立文件夹
if (!is_dir($saveFullPath) && !mkdir($saveFullPath, 0755, true)) {
restore_error_handler();
throw new FileException(sprintf('Unable to create the "%s" directory (%s)', $saveFullPath, strip_tags($error)));
}
// cli 使用 rename
$saveName = $this->getFullPath($saveName, true);
if (!rename($file->getPathname(), $saveName)) {
restore_error_handler();
throw new FileException(sprintf('Could not move the file "%s" to "%s" (%s)', $file->getPathname(), $saveName, strip_tags($error)));
}
restore_error_handler();
@chmod($saveName, 0666 & ~umask());
return true;
}
/**
* 删除文件
* @param string $saveName
* @return bool
*/
public function delete(string $saveName): bool
{
$saveFullName = $this->getFullPath($saveName, true);
if ($this->exists($saveFullName)) {
@unlink($saveFullName);
}
Filesystem::delEmptyDir(dirname($saveFullName));
return true;
}
/**
* 获取资源 URL 地址
* @param string $saveName 资源保存名称
* @param string|bool $domain 是否携带域名 或者直接传入域名
* @param string $default 默认值
* @return string
*/
public function url(string $saveName, string|bool $domain = true, string $default = ''): string
{
$saveName = $this->clearRootPath($saveName);
if ($domain === true) {
$domain = '//' . request()->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);
}
/**
* 文件是否存在
* @param string $saveName
* @return bool
*/
public function exists(string $saveName): bool
{
$saveFullName = $this->getFullPath($saveName, true);
return file_exists($saveFullName);
}
/**
* 获取文件的完整存储路径
* @param string $saveName
* @param bool $baseName 是否包含文件名
* @return string
*/
public function getFullPath(string $saveName, bool $baseName = false): string
{
$savePathInfo = pathinfo($saveName);
$root = $this->getRootPath();
$dirName = $savePathInfo['dirname'] . '/';
// 以 root 路径开始时单独返回,避免重复调用此方法时造成 $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
{
return Filesystem::fsFit(str_replace($this->options['url'], '', $this->options['root']));
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace app\common\middleware;
use Closure;
use Throwable;
use think\facade\Config;
use app\admin\model\AdminLog as AdminLogModel;
class AdminLog
{
/**
* 写入管理日志
* @throws Throwable
*/
public function handle($request, Closure $next)
{
$response = $next($request);
if (($request->isPost() || $request->isDelete()) && Config::get('buildadmin.auto_write_admin_log')) {
AdminLogModel::instance()->record();
}
return $response;
}
}

View File

@@ -0,0 +1,66 @@
<?php
// +----------------------------------------------------------------------
// | ThinkPHP [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2006~2021 http://thinkphp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Licensed ( http://www.apache.org/licenses/LICENSE-2.0 )
// +----------------------------------------------------------------------
// | Author: liu21st <liu21st@gmail.com>
// +----------------------------------------------------------------------
declare (strict_types=1);
namespace app\common\middleware;
use Closure;
use think\Request;
use think\Response;
use think\facade\Config;
/**
* 跨域请求支持
* 安全起见,只支持了配置中的域名
*/
class AllowCrossDomain
{
protected array $header = [
'Access-Control-Allow-Credentials' => 'true',
'Access-Control-Max-Age' => 1800,
'Access-Control-Allow-Methods' => '*',
'Access-Control-Allow-Headers' => '*',
];
/**
* 跨域请求检测
* @access public
* @param Request $request
* @param Closure $next
* @param array|null $header
* @return Response
*/
public function handle(Request $request, Closure $next, ?array $header = []): Response
{
$header = !empty($header) ? array_merge($this->header, $header) : $this->header;
$origin = $request->header('origin');
if ($origin && !isset($header['Access-Control-Allow-Origin'])) {
$info = parse_url($origin);
// 获取跨域配置
$corsDomain = explode(',', Config::get('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->isOptions()) {
return response('', 204, $header);
}
$request->allowCrossDomainHeaders = $header;
return $next($request)->header($header);
}
}

View File

@@ -0,0 +1,115 @@
<?php
namespace app\common\model;
use Throwable;
use think\Model;
use think\facade\Event;
use app\admin\model\Admin;
use app\common\library\Upload;
use think\model\relation\BelongsTo;
/**
* Attachment模型
* @property string url 文件物理路径
* @property int quote 上传(引用)次数
* @property int last_upload_time 最后上传时间
*/
class Attachment extends Model
{
protected $autoWriteTimestamp = true;
protected $updateTime = false;
protected $append = [
'suffix',
'full_url'
];
/**
* 上传类实例,可以通过它调用上传文件驱动,且驱动类具有静态缓存
*/
protected static Upload $upload;
protected static function init(): void
{
self::$upload = new Upload();
}
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
{
$driver = self::$upload->getDriver($row['storage'], false);
return $driver ? $driver->url($row['url']) : full_url($row['url']);
}
/**
* 新增前
* @throws Throwable
*/
protected static function onBeforeInsert($model): bool
{
$repeat = $model->where([
['sha1', '=', $model->sha1],
['topic', '=', $model->topic],
['storage', '=', $model->storage],
])->find();
if ($repeat) {
$driver = self::$upload->getDriver($repeat->storage, false);
if ($driver && !$driver->exists($repeat->url)) {
$repeat->delete();
return true;
} else {
$repeat->quote++;
$repeat->last_upload_time = time();
$repeat->save();
return false;
}
}
return true;
}
/**
* 新增后
*/
protected static function onAfterInsert($model): void
{
Event::trigger('AttachmentInsert', $model);
if (!$model->last_upload_time) {
$model->quote = 1;
$model->last_upload_time = time();
$model->save();
}
}
/**
* 删除后
*/
protected static function onAfterDelete($model): void
{
Event::trigger('AttachmentDel', $model);
$driver = self::$upload->getDriver($model->storage, false);
if ($driver && $driver->exists($model->url)) {
$driver->delete($model->url);
}
}
public function admin(): BelongsTo
{
return $this->belongsTo(Admin::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -0,0 +1,83 @@
<?php
namespace app\common\model;
use Throwable;
use think\Model;
use app\admin\model\Config as adminConfigModel;
class Config extends Model
{
/**
* 添加系统配置分组
* @throws Throwable
*/
public static function addConfigGroup(string $key, string $value): bool
{
return self::addArrayItem('config_group', $key, $value);
}
/**
* 删除系统配置分组
* @throws Throwable
*/
public static function removeConfigGroup(string $key): bool
{
if (adminConfigModel::where('group', $key)->find()) return false;
return self::removeArrayItem('config_group', $key);
}
/**
* 添加系统快捷配置入口
* @throws Throwable
*/
public static function addQuickEntrance(string $key, string $value): bool
{
return self::addArrayItem('config_quick_entrance', $key, $value);
}
/**
* 删除系统快捷配置入口
* @throws Throwable
*/
public static function removeQuickEntrance(string $key): bool
{
return self::removeArrayItem('config_quick_entrance', $key);
}
/**
* 为Array类型的配置项添加元素
* @throws Throwable
*/
public static function addArrayItem(string $name, string $key, string $value): bool
{
$configRow = adminConfigModel::where('name', $name)->find();
foreach ($configRow->value as $item) {
if ($item['key'] == $key) {
return false;
}
}
$configRow->value = array_merge($configRow->value, [['key' => $key, 'value' => $value]]);
$configRow->save();
return true;
}
/**
* 删除Array类型配置项的一个元素
* @throws Throwable
*/
public static function removeArrayItem(string $name, string $key): bool
{
$configRow = adminConfigModel::where('name', $name)->find();
$configRowValue = $configRow->value;
foreach ($configRowValue as $iKey => $item) {
if ($item['key'] == $key) {
unset($configRowValue[$iKey]);
}
}
$configRow->value = $configRowValue;
$configRow->save();
return true;
}
}

51
app/common/model/User.php Normal file
View File

@@ -0,0 +1,51 @@
<?php
namespace app\common\model;
use think\Model;
/**
* 会员公共模型
* @property int $id 会员ID
* @property string $password 密码密文
* @property string $salt 密码盐(废弃待删)
* @property int $login_failure 登录失败次数
* @property string $last_login_time 上次登录时间
* @property string $last_login_ip 上次登录IP
* @property string $email 会员邮箱
* @property string $mobile 会员手机号
* @property string $status 状态:enable=启用,disable=禁用,...(string存储可自定义其他)
*/
class User extends Model
{
protected $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): int|User
{
return $this->where(['id' => $uid])->update(['password' => hash_password($newPassword), 'salt' => '']);
}
public function getMoneyAttr($value): string
{
return bcdiv($value, 100, 2);
}
/**
* 用户的余额是不可以直接进行修改的,请通过 UserMoneyLog 模型插入记录来实现自动修改余额
* 此处定义上 money 的修改器仅为防止直接对余额的修改造成数据错乱
*/
public function setMoneyAttr($value): string
{
return bcmul($value, 100, 2);
}
}

View File

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

View File

@@ -0,0 +1,11 @@
<?php
namespace app\common\model;
use think\model;
class UserScoreLog extends model
{
protected $autoWriteTimestamp = true;
protected $updateTime = false;
}

View File

@@ -0,0 +1,34 @@
<?php
namespace app\common\service;
use think\Service;
use think\facade\Event;
use app\admin\library\module\Server;
class moduleService extends Service
{
public function register(): void
{
$this->moduleAppInit();
}
public function moduleAppInit(): void
{
$installed = Server::installedList(root_path() . 'modules' . DIRECTORY_SEPARATOR);
foreach ($installed as $item) {
if ($item['state'] != 1) {
continue;
}
$moduleClass = Server::getClass($item['uid']);
if (class_exists($moduleClass)) {
if (method_exists($moduleClass, 'AppInit')) {
Event::listen('AppInit', function () use ($moduleClass) {
$handle = new $moduleClass();
$handle->AppInit();
});
}
}
}
}
}

17
app/event.php Normal file
View File

@@ -0,0 +1,17 @@
<?php
// 事件定义文件
return [
'bind' => [
],
'listen' => [
'AppInit' => [],
'HttpRun' => [],
'HttpEnd' => [],
'LogLevel' => [],
'LogWrite' => [],
],
'subscribe' => [
],
];

11
app/middleware.php Normal file
View File

@@ -0,0 +1,11 @@
<?php
// 全局中间件定义文件
return [
// 全局请求缓存
// \think\middleware\CheckRequestCache::class,
// 多语言加载
// \think\middleware\LoadLangPack::class,
// Session初始化
// \think\middleware\SessionInit::class,
\think\middleware\Throttle::class,
];

10
app/provider.php Normal file
View File

@@ -0,0 +1,10 @@
<?php
use app\ExceptionHandle;
use app\Request;
// 容器Provider定义文件
return [
'think\Request' => Request::class,
'think\exception\Handle' => ExceptionHandle::class,
];

11
app/service.php Normal file
View File

@@ -0,0 +1,11 @@
<?php
use app\AppService;
use app\common\service\moduleService;
// 系统服务定义文件
// 服务在完成全局初始化之后执行
return [
AppService::class,
moduleService::class,
];