初始化

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

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();
}
}