This commit is contained in:
2025-08-13 18:31:52 +08:00
parent 17ad88608b
commit 275713f893
77 changed files with 10658 additions and 178 deletions

231
extend/alei/Export.php Normal file
View File

@@ -0,0 +1,231 @@
<?php
namespace alei;
use PhpOffice\PhpSpreadsheet\Cell\DataType;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Border;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
/**
* 导出类
*/
class Export
{
//导出选项
private $option = [
//导出文件路径
'file_path' => '',
//导出文件名
'file_name' => '导出文件',
//首行是否加粗
'header_bold' => true,
//垂直居中
'header_vertical_center' => true,
//水平居中
'header_horizontal_center' => true,
//首列高度
'header_row_height' => 30
];
private $column;
private $data;
/**
* @params Array|String $option_or_file_name 导出选项或者文件名
* @params String $file_path 文件保存路径
*/
public function __construct($option_or_file_name = null, $file_path = null)
{
if(!is_null($option_or_file_name)){
if(is_array($option_or_file_name)) {
//设置导出选项
$this->option = array_merge($this->option, $option_or_file_name);
}else if(is_string($option_or_file_name) && strlen($option_or_file_name) > 0){
//设置保存的文件名
$this->option['file_name'] = $option_or_file_name;
}
}
//设置保存的文件路径
if (!is_null($file_path) && is_string($file_path) && strlen($file_path) > 0) {
$this->option['file_path'] = $file_path;
}
}
/**
* 设置列参数
* @param String field 字段名
* @param String title 标题
* @param Int width 列宽
* @param Function|String formater [格式化器参数1字段值参数2行数据参数3行号] 或 [预定义方法:'time':格式化时间]
* @param String type 类型 default 'text'
* @param Boole vertical_center 是否垂直居中default false
* @param Boole horizontal_center 是否水平居中default false
*/
public function setColumn($column)
{
$this->column = $column;
}
/**
* 格式化行参数
*/
private function formatRowData($data)
{
$tmp = [];
foreach ($this->column as $key => $column) {
if (isset($data[$column['field']])) {
$tmp[$column['field']] = $data[$column['field']];
} else {
$tmp[$column['field']] = '';
}
if (isset($column['formater'])) {
if (gettype($column['formater']) === 'object' && is_callable($column['formater'])) {
$tmp[$column['field']] = $column['formater']($tmp[$column['field']], $data, $key);
} else if (is_string($column['formater'])) {
//格式化
switch($column['formater']){
case 'time': //时间戳转时间
$format_time = $column['format_time'] ?? 'Y-m-d H:i:s';
if (empty($tmp[$column['field']])) {
$tmp[$column['field']] = '';
} else {
$tmp[$column['field']] = date($format_time, $tmp[$column['field']]);
}
break;
default :
}
}
}
}
return $tmp;
}
/**
* 设置数据
*/
public function setData($list)
{
if (empty($this->column)) throw new \think\Exception('Please set the column parameters first!');
$data = [];
foreach ($list as $key => $val) {
$data[] = $this->formatRowData($val);
unset($list[$key]);
}
$this->data = $data;
}
/**
* 渲染表格并返回
* @params $column 列参数
* @params $data 导出数据
*/
public function build()
{
if (empty($this->column)) {
throw new \think\Exception('Please set the column parameters first!');
}
$spreadsheet = new Spreadsheet();
// 1获取活动工作薄
$sheet = $spreadsheet->getActiveSheet();
//最后一列列号
$end_row = count($this->column);
// 首行选择器
$first_cell = [1, 1, $end_row, 1];
//设置背景色
$sheet->getStyle($first_cell)->getFill()->setFillType(Fill::FILL_SOLID);
$sheet->getStyle($first_cell)->getFill()->getStartColor()->setARGB('FFeeeeee');
//首行加边框
$sheet->getStyle($first_cell)->getBorders()->applyFromArray([
'allBorders' => [
'borderStyle' => Border::BORDER_HAIR,
'color' => ['argb' => '666666']
]
]);
//加粗
if ($this->option['header_bold']) {
$sheet->getStyle($first_cell)->getFont()->setBold(true);
}
//垂直居中
if ($this->option['header_vertical_center']) {
$sheet->getStyle($first_cell)->getAlignment()->setVertical(Alignment::VERTICAL_CENTER);
}
//水平居中
if ($this->option['header_horizontal_center']) {
$sheet->getStyle($first_cell)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
}
//首行高度
$sheet->getRowDimension(1)->setRowHeight($this->option['header_row_height']);
foreach ($this->column as $key => $column) {
$sheet->setCellValue([$key + 1, 1], $column['title']);
$sheet->getColumnDimensionByColumn($key + 1)->setAutoSize(true);
}
foreach ($this->data as $key => $row) {
foreach ($this->column as $k => $column) {
$value = $row[$column['field']];
$cell = [$k + 1, $key + 2];
//换行
if (mb_strpos($value, PHP_EOL) !== false) {
$sheet->getStyle($cell)->getAlignment()->setWrapText(true);
}
//垂直居中
if (!empty($column['vertical_center'])) {
$sheet->getStyle($cell)->getAlignment()->setVertical(Alignment::VERTICAL_CENTER);
}
//水平居中
if (!empty($column['horizontal_center'])) {
$sheet->getStyle($cell)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
}
//设置文字
$sheet->setCellValueExplicit($cell, $value, DataType::TYPE_STRING);
}
unset($this->data[$key]);
}
//设置工作表标题
$sheet->setTitle($this->option['file_name']);
$this->option['file_path'] = $this->option['file_path'] == '' ? '' : ($this->option['file_path'] . '/' );
$url = '/export/' . $this->option['file_path'] . $this->option['file_name'] . '.xlsx';
$filePath = public_path() . str_replace('/', DIRECTORY_SEPARATOR, $url);
if(is_file($filePath) ){
//如果文件已经导出过则删除旧文件
// unlink($filePath);
}else if (!is_dir(dirname($filePath))) {
mkdir(dirname($filePath), 0700, true);
}
// 保存电子表格
$writer = new Xlsx($spreadsheet);
$writer->save($filePath);
return $url;
}
}

316
extend/ba/Auth.php Normal file
View File

@@ -0,0 +1,316 @@
<?php
namespace ba;
use app\common\library\DatabaseRoute;
use Throwable;
use think\facade\Db;
/**
* 权限规则类
*/
class Auth
{
/**
* 默认配置
* @var array|string[]
*/
protected array $config = [
'auth_group' => 'admin_group', // 用户组数据表名
'auth_group_access' => 'admin_group_access', // 用户-用户组关系表
'auth_rule' => 'admin_rule', // 权限规则表
];
protected object $db;
/**
* 子菜单规则数组
* @var array
*/
protected array $children = [];
/**
* 构造方法
* @param array $config
*/
public function __construct(array $config = [])
{
$this->config = array_merge($this->config, $config);
$this->db = Db::connect(config('database.search_library'));
}
/**
* 魔术方法-获取当前配置
* @param $name
* @return mixed
*/
public function __get($name): mixed
{
return $this->config[$name];
}
/**
* 获取菜单规则列表
* @access public
* @param int $uid 用户ID
* @return array
* @throws Throwable
*/
public function getMenus(int $uid): array
{
$this->children = [];
$originAuthRules = $this->getOriginAuthRules($uid);
foreach ($originAuthRules as $rule) {
$this->children[$rule['pid']][] = $rule;
}
// 没有根菜单规则
if (!isset($this->children[0])) return [];
return $this->getChildren($this->children[0]);
}
/**
* 获取传递的菜单规则的子规则
* @param array $rules 菜单规则
* @return array
*/
private function getChildren(array $rules): array
{
foreach ($rules as $key => $rule) {
if (array_key_exists($rule['id'], $this->children)) {
$rules[$key]['children'] = $this->getChildren($this->children[$rule['id']]);
}
}
return $rules;
}
/**
* 检查是否有某权限
* @param string $name 菜单规则的 name可以传递两个以','号隔开
* @param int $uid 用户ID
* @param string $relation 如果出现两个 name,是两个都通过(and)还是一个通过即可(or)
* @param string $mode 如果不使用 url 则菜单规则name匹配到即通过
* @return bool
* @throws Throwable
*/
public function check(string $name, int $uid, string $relation = 'or', string $mode = 'url'): bool
{
// 获取用户需要验证的所有有效规则列表
$ruleList = $this->getRuleList($uid);
if (in_array('*', $ruleList)) {
return true;
}
if ($name) {
$name = strtolower($name);
if (str_contains($name, ',')) {
$name = explode(',', $name);
} else {
$name = [$name];
}
}
$list = []; //保存验证通过的规则名
if ('url' == $mode) {
$REQUEST = json_decode(strtolower(json_encode(request()->param(), JSON_UNESCAPED_UNICODE)), true);
}
foreach ($ruleList as $rule) {
$query = preg_replace('/^.+\?/U', '', $rule);
if ('url' == $mode && $query != $rule) {
parse_str($query, $param); //解析规则中的param
$intersect = array_intersect_assoc($REQUEST, $param);
$rule = preg_replace('/\?.*$/U', '', $rule);
if (in_array($rule, $name) && $intersect == $param) {
// 如果节点相符且url参数满足
$list[] = $rule;
}
} elseif (in_array($rule, $name)) {
$list[] = $rule;
}
}
if ('or' == $relation && !empty($list)) {
return true;
}
$diff = array_diff($name, $list);
if ('and' == $relation && empty($diff)) {
return true;
}
return false;
}
/**
* 获得权限规则列表
* @param int $uid 用户id
* @return array
* @throws Throwable
*/
public function getRuleList(int $uid): array
{
// 读取用户规则节点
$ids = $this->getRuleIds($uid);
if (empty($ids)) return [];
$originAuthRules = $this->getOriginAuthRules($uid);
// 用户规则
$rules = [];
if (in_array('*', $ids)) {
$rules[] = "*";
}
foreach ($originAuthRules as $rule) {
$rules[$rule['id']] = strtolower($rule['name']);
}
return array_unique($rules);
}
/**
* 获得权限规则原始数据
* @param int $uid 用户id
* @return array
* @throws Throwable
*/
public function getOriginAuthRules(int $uid): array
{
$ids = $this->getRuleIds($uid);
if (empty($ids)) return [];
$where = [];
$where[] = ['status', '=', '1'];
// 如果没有 * 则只获取用户拥有的规则
if (!in_array('*', $ids)) {
$where[] = ['id', 'in', $ids];
}
$rules = Db::name($this->config['auth_rule'])
->withoutField(['remark', 'status', 'weigh', 'update_time', 'create_time'])
->where($where)
->order('weigh desc,id asc')
->select()
->toArray();
foreach ($rules as $key => $rule) {
if (!empty($rule['keepalive'])) {
$rules[$key]['keepalive'] = $rule['name'];
}
}
return $rules;
}
/**
* 获取权限规则ids
* @param int $uid
* @return array
* @throws Throwable
*/
public function getRuleIds(int $uid): array
{
// 用户的组别和规则ID
$groups = $this->getGroups($uid);
$ids = [];
foreach ($groups as $g) {
$ids = array_merge($ids, explode(',', trim($g['rules'], ',')));
}
return array_unique($ids);
}
/**
* 获取用户所有分组和对应权限规则
* @param int $uid
* @return array
* @throws Throwable
*/
public function getGroups(int $uid): array
{
$dbName = $this->config['auth_group_access'] ?: 'user';
if ($this->config['auth_group_access']) {
$userGroups = Db::name($dbName)
->alias('aga')
->join($this->config['auth_group'] . ' ag', 'aga.group_id = ag.id', 'LEFT')
->field('aga.uid,aga.group_id,ag.id,ag.pid,ag.name,ag.rules')
->where("aga.uid='$uid' and ag.status='1'")
->select()
->toArray();
} else {
$userGroups = Db::name($dbName)
->alias('u')
->join($this->config['auth_group'] . ' ag', 'u.group_id = ag.id', 'LEFT')
->field('u.id as uid,u.group_id,ag.id,ag.name,ag.rules')
->where("u.id='$uid' and ag.status='1'")
->select()
->toArray();
}
return $userGroups;
}
/**
* 获取角色系统规则
* @param 角色
*/
public function getMenu($role):array
{
$role_menu = $this->db->name('sys_role_menu')->where('role_id', $role['role_id'])->field('menu_id')->select()->toArray();
$ids = [];
foreach ($role_menu as $k => $v) {
$ids[] = $v['menu_id'];
}
return $this->db->name('sys_menu')->whereIn('menu_id', $ids)->select()->toArray();
}
/**
* 获取用户角色
* @return void
*/
public function getUserRole($uid):array
{
return $this->db->name('sys_user_role')->where('user_id', $uid)->find();
}
/**
* 获取用户所有菜单
*/
public function getUserMenus($uid):array
{
return $this->getMenu($this->getUserRole($uid));
}
/**
* 验证权限
* @return void
*/
public function checkMenus($url, $uid):bool
{
$menus = $this->getUserMenus($uid);
$file = root_path() . '/jsondata/peion.json';
// 检查数组
$par_info = get_file_info($file);
$par_info = json_decode($par_info, true);
// 超管拥有全部权限
if($uid == 1) {
return true;
}
foreach ($par_info as $k => $v) {
if(!empty($v['peion']) && strpos($url, $v['peion']) !== false) {
if($v['url'] == '*') {
return true;
}
foreach ($menus as $mk => $mv) {
if($mv['url'] == $v['url'] || $mv['perms'] == $v['url']) {
return true;
}
}
}
}
return false;
}
}

443
extend/ba/Captcha.php Normal file
View File

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

338
extend/ba/ClickCaptcha.php Normal file
View File

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

195
extend/ba/Date.php Normal file
View File

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

212
extend/ba/Depends.php Normal file
View File

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

17
extend/ba/Exception.php Normal file
View File

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

248
extend/ba/Filesystem.php Normal file
View File

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

87
extend/ba/Random.php Normal file
View File

@@ -0,0 +1,87 @@
<?php
namespace ba;
class Random
{
/**
* 获取全球唯一标识
* @return string
*/
public static function uuid(): string
{
return sprintf(
'%04x%04x-%04x-%04x-%04x-',
mt_rand(0, 0xffff),
mt_rand(0, 0xffff),
mt_rand(0, 0xffff),
mt_rand(0, 0x0fff) | 0x4000,
mt_rand(0, 0x3fff) | 0x8000
) . substr(md5(uniqid(mt_rand(), true)), 0, 12);
}
/**
* 随机字符生成
* @param string $type 类型 alpha/alnum/numeric/noZero/unique/md5/encrypt/sha1
* @param int $len 长度
* @return string
*/
public static function build(string $type = 'alnum', int $len = 8): string
{
switch ($type) {
case 'alpha':
case 'alnum':
case 'numeric':
case 'noZero':
$pool = match ($type) {
'alpha' => 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ',
'alnum' => '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ',
'numeric' => '0123456789',
'noZero' => '123456789',
default => '',
};
return substr(str_shuffle(str_repeat($pool, ceil($len / strlen($pool)))), 0, $len);
case 'unique':
case 'md5':
return md5(uniqid(mt_rand()));
case 'encrypt':
case 'sha1':
return sha1(uniqid(mt_rand(), true));
}
return '';
}
/**
* 生成带随机前缀的唯一ID
* @param int $length ID总长度
* @return string 唯一ID
*/
public static function generateRandomPrefixedId(int $length = 16): string {
$length = min($length, 18);
$prefixLength = 4;
$timestamp = floor(microtime(true) * 1000); // 毫秒级时间戳
$randomPart = '';
// 生成随机前缀
for ($i = 0; $i < $prefixLength; $i++) {
$randomPart .= random_int(0, 9);
}
// 计算剩余长度用于时间戳
$timestampLength = $length - $prefixLength;
$timestampStr = (string) $timestamp;
// 截取或补零时间戳部分
if (strlen($timestampStr) > $timestampLength) {
$timestampStr = substr($timestampStr, -$timestampLength);
} else {
$timestampStr = str_pad($timestampStr, $timestampLength, '0', STR_PAD_LEFT);
}
return $randomPart . $timestampStr;
}
}

186
extend/ba/TableManager.php Normal file
View File

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

509
extend/ba/Terminal.php Normal file
View File

@@ -0,0 +1,509 @@
<?php
// +----------------------------------------------------------------------
// | BuildAdmin [ Quickly create commercial-grade management system using popular technology stack ]
// +----------------------------------------------------------------------
// | Copyright (c) 2022~2022 http://buildadmin.com All rights reserved.
// +----------------------------------------------------------------------
// | Licensed ( http://www.apache.org/licenses/LICENSE-2.0 )
// +----------------------------------------------------------------------
// | Author: 妙码生花 <hi@buildadmin.com>
// +----------------------------------------------------------------------
namespace ba;
use Throwable;
use think\Response;
use think\facade\Config;
use app\admin\library\Auth;
use app\admin\library\module\Manage;
use think\exception\HttpResponseException;
use app\common\library\token\TokenExpirationException;
class Terminal
{
/**
* @var ?Terminal 对象实例
*/
protected static ?Terminal $instance = null;
/**
* @var string 当前执行的命令 $command 的 key
*/
protected string $commandKey = '';
/**
* @var array proc_open 的参数
*/
protected array $descriptorsPec = [];
/**
* @var resource|bool proc_open 返回的 resource
*/
protected $process = false;
/**
* @var array proc_open 的管道
*/
protected array $pipes = [];
/**
* @var int proc执行状态:0=未执行,1=执行中,2=执行完毕
*/
protected int $procStatusMark = 0;
/**
* @var array proc执行状态数据
*/
protected array $procStatusData = [];
/**
* @var string 命令在前台的uuid
*/
protected string $uuid = '';
/**
* @var string 扩展信息
*/
protected string $extend = '';
/**
* @var string 命令执行输出文件
*/
protected string $outputFile = '';
/**
* @var string 命令执行实时输出内容
*/
protected string $outputContent = '';
/**
* @var string 自动构建的前端文件的 outDir相对于根目录
*/
protected static string $distDir = 'web' . DIRECTORY_SEPARATOR . 'dist';
/**
* @var array 状态标识
*/
protected array $flag = [
// 连接成功
'link-success' => 'command-link-success',
// 执行成功
'exec-success' => 'command-exec-success',
// 执行完成
'exec-completed' => 'command-exec-completed',
// 执行出错
'exec-error' => 'command-exec-error',
];
/**
* 初始化
*/
public static function instance(): Terminal
{
if (is_null(self::$instance)) {
self::$instance = new static();
}
return self::$instance;
}
/**
* 构造函数
*/
public function __construct()
{
$this->uuid = request()->param('uuid', '');
$this->extend = request()->param('extend', '');
// 初始化日志文件
$outputDir = root_path() . 'runtime' . DIRECTORY_SEPARATOR . 'terminal';
$this->outputFile = $outputDir . DIRECTORY_SEPARATOR . 'exec.log';
if (!is_dir($outputDir)) {
mkdir($outputDir, 0755, true);
}
file_put_contents($this->outputFile, '');
/**
* 命令执行结果输出到文件而不是管道
* 因为输出到管道时有延迟,而文件虽然需要频繁读取和对比内容,但是输出实时的
*/
$this->descriptorsPec = [0 => ['pipe', 'r'], 1 => ['file', $this->outputFile, 'w'], 2 => ['file', $this->outputFile, 'w']];
}
/**
* 获取命令
* @param string $key 命令key
* @return array|bool
*/
public static function getCommand(string $key): bool|array
{
if (!$key) {
return false;
}
$commands = Config::get('terminal.commands');
if (stripos($key, '.')) {
$key = explode('.', $key);
if (!array_key_exists($key[0], $commands) || !is_array($commands[$key[0]]) || !array_key_exists($key[1], $commands[$key[0]])) {
return false;
}
$command = $commands[$key[0]][$key[1]];
} else {
if (!array_key_exists($key, $commands)) {
return false;
}
$command = $commands[$key];
}
if (!is_array($command)) {
$command = [
'cwd' => root_path(),
'command' => $command,
];
} else {
$command['cwd'] = root_path() . $command['cwd'];
}
if (str_contains($command['command'], '%')) {
$args = request()->param('extend', '');
$args = explode('~~', $args);
array_unshift($args, $command['command']);
$command['command'] = call_user_func_array('sprintf', $args);
}
$command['cwd'] = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $command['cwd']);
return $command;
}
/**
* 执行命令
* @param bool $authentication 是否鉴权
* @throws Throwable
*/
public function exec(bool $authentication = true): void
{
$this->sendHeader();
while (ob_get_level()) {
ob_end_clean();
}
if (!ob_get_level()) ob_start();
$this->commandKey = request()->param('command');
$command = self::getCommand($this->commandKey);
if (!$command) {
$this->execError('The command was not allowed to be executed', true);
}
if ($authentication) {
try {
$token = get_auth_token();
$auth = Auth::instance();
$auth->init($token);
if (!$auth->isLogin() || !$auth->isSuperAdmin()) {
$this->execError("You are not super administrator or not logged in", true);
}
} catch (TokenExpirationException) {
$this->execError(__('Token expiration'));
}
}
$this->beforeExecution();
$this->outputFlag('link-success');
if (!empty($command['notes'])) {
$this->output('> ' . __($command['notes']), false);
}
$this->output('> ' . $command['command'], false);
$this->process = proc_open($command['command'], $this->descriptorsPec, $this->pipes, $command['cwd']);
if (!is_resource($this->process)) {
$this->execError('Failed to execute', true);
}
while ($this->getProcStatus()) {
$contents = file_get_contents($this->outputFile);
if (strlen($contents) && $this->outputContent != $contents) {
$newOutput = str_replace($this->outputContent, '', $contents);
$this->checkOutput($contents, $newOutput);
if (preg_match('/\r\n|\r|\n/', $newOutput)) {
$this->output($newOutput);
$this->outputContent = $contents;
}
}
// 输出执行状态信息
if ($this->procStatusMark === 2) {
$this->output('exitCode: ' . $this->procStatusData['exitcode']);
if ($this->procStatusData['exitcode'] === 0) {
if ($this->successCallback()) {
$this->outputFlag('exec-success');
} else {
$this->output('Error: Command execution succeeded, but callback execution failed');
$this->outputFlag('exec-error');
}
} else {
$this->outputFlag('exec-error');
}
}
usleep(500000);
}
foreach ($this->pipes as $pipe) {
fclose($pipe);
}
proc_close($this->process);
$this->outputFlag('exec-completed');
}
/**
* 获取执行状态
* @throws Throwable
*/
public function getProcStatus(): bool
{
$this->procStatusData = proc_get_status($this->process);
if ($this->procStatusData['running']) {
$this->procStatusMark = 1;
return true;
} elseif ($this->procStatusMark === 1) {
$this->procStatusMark = 2;
return true;
} else {
return false;
}
}
/**
* 输出 EventSource 数据
* @param string $data
* @param bool $callback
*/
public function output(string $data, bool $callback = true): void
{
$data = self::outputFilter($data);
$data = [
'data' => $data,
'uuid' => $this->uuid,
'extend' => $this->extend,
'key' => $this->commandKey,
];
$data = json_encode($data, JSON_UNESCAPED_UNICODE);
if ($data) {
$this->finalOutput($data);
if ($callback) $this->outputCallback($data);
@ob_flush();// 刷新浏览器缓冲区
}
}
/**
* 检查输出
* @param string $outputs 全部输出内容
* @param string $rowOutput 当前输出内容(行)
*/
public function checkOutput(string $outputs, string $rowOutput): void
{
if (str_contains($rowOutput, '(Y/n)')) {
$this->execError('Interactive output detected, please manually execute the command to confirm the situation.', true);
}
}
/**
* 输出状态标记
* @param string $flag
*/
public function outputFlag(string $flag): void
{
$this->output($this->flag[$flag], false);
}
/**
* 输出后回调
*/
public function outputCallback($data): void
{
}
/**
* 成功后回调
* @return bool
* @throws Throwable
*/
public function successCallback(): bool
{
if (stripos($this->commandKey, '.')) {
$commandKeyArr = explode('.', $this->commandKey);
$commandPKey = $commandKeyArr[0] ?? '';
} else {
$commandPKey = $this->commandKey;
}
if ($commandPKey == 'web-build') {
if (!self::mvDist()) {
$this->output('Build succeeded, but move file failed. Please operate manually.');
return false;
}
} elseif ($commandPKey == 'web-install' && $this->extend) {
[$type, $value] = explode(':', $this->extend);
if ($type == 'module-install' && $value) {
Manage::instance($value)->dependentInstallComplete('npm');
}
} elseif ($commandPKey == 'composer' && $this->extend) {
[$type, $value] = explode(':', $this->extend);
if ($type == 'module-install' && $value) {
Manage::instance($value)->dependentInstallComplete('composer');
}
} elseif ($commandPKey == 'nuxt-install' && $this->extend) {
[$type, $value] = explode(':', $this->extend);
if ($type == 'module-install' && $value) {
Manage::instance($value)->dependentInstallComplete('nuxt_npm');
}
}
return true;
}
/**
* 执行前埋点
*/
public function beforeExecution(): void
{
if ($this->commandKey == 'test.pnpm') {
@unlink(root_path() . 'public' . DIRECTORY_SEPARATOR . 'npm-install-test' . DIRECTORY_SEPARATOR . 'pnpm-lock.yaml');
} elseif ($this->commandKey == 'web-install.pnpm') {
@unlink(root_path() . 'web' . DIRECTORY_SEPARATOR . 'pnpm-lock.yaml');
}
}
/**
* 输出过滤
*/
public static function outputFilter($str): string
{
$str = trim($str);
$preg = '/\[(.*?)m/i';
$str = preg_replace($preg, '', $str);
$str = str_replace(["\r\n", "\r", "\n"], "\n", $str);
return mb_convert_encoding($str, 'UTF-8', 'UTF-8,GBK,GB2312,BIG5');
}
/**
* 执行错误
*/
public function execError($error, $break = false): void
{
$this->output('Error:' . $error);
$this->outputFlag('exec-error');
if ($break) $this->break();
}
/**
* 退出执行
*/
public function break(): void
{
throw new HttpResponseException(Response::create()->contentType('text/event-stream'));
}
/**
* 执行一个命令并以字符串的方式返回执行输出
* 代替 exec 使用,这样就只需要解除 proc_open 的函数禁用了
* @param $commandKey
* @return string|bool
*/
public static function getOutputFromProc($commandKey): bool|string
{
if (!function_exists('proc_open') || !function_exists('proc_close')) {
return false;
}
$command = self::getCommand($commandKey);
if (!$command) {
return false;
}
$descriptorsPec = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
$process = proc_open($command['command'], $descriptorsPec, $pipes, null, null);
if (is_resource($process)) {
$info = stream_get_contents($pipes[1]);
$info .= stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
proc_close($process);
return self::outputFilter($info);
}
return '';
}
public static function mvDist(): bool
{
$distPath = root_path() . self::$distDir . DIRECTORY_SEPARATOR;
$indexHtmlPath = $distPath . 'index.html';
$assetsPath = $distPath . 'assets';
if (!file_exists($indexHtmlPath) || !file_exists($assetsPath)) {
return false;
}
$toIndexHtmlPath = root_path() . 'public' . DIRECTORY_SEPARATOR . 'index.html';
$toAssetsPath = root_path() . 'public' . DIRECTORY_SEPARATOR . 'assets';
@unlink($toIndexHtmlPath);
Filesystem::delDir($toAssetsPath);
if (rename($indexHtmlPath, $toIndexHtmlPath) && rename($assetsPath, $toAssetsPath)) {
Filesystem::delDir($distPath);
return true;
} else {
return false;
}
}
public static function changeTerminalConfig($config = []): bool
{
// 不保存在数据库中,因为切换包管理器时,数据库资料可能还未配置
$oldPackageManager = Config::get('terminal.npm_package_manager');
$newPackageManager = request()->post('manager', $config['manager'] ?? $oldPackageManager);
if ($oldPackageManager == $newPackageManager) {
return true;
}
$buildConfigFile = config_path() . 'terminal.php';
$buildConfigContent = @file_get_contents($buildConfigFile);
$buildConfigContent = preg_replace("/'npm_package_manager'(\s+)=>(\s+)'$oldPackageManager'/", "'npm_package_manager'\$1=>\$2'$newPackageManager'", $buildConfigContent);
$result = @file_put_contents($buildConfigFile, $buildConfigContent);
return (bool)$result;
}
/**
* 最终输出
*/
public function finalOutput(string $data): void
{
$app = app();
if (!empty($app->worker) && !empty($app->connection)) {
$app->connection->send(new \Workerman\Protocols\Http\ServerSentEvents(['event' => 'message', 'data' => $data]));
} else {
echo 'data: ' . $data . "\n\n";
}
}
/**
* 发送响应头
*/
public function sendHeader(): void
{
$headers = array_merge(request()->allowCrossDomainHeaders ?? [], [
'X-Accel-Buffering' => 'no',
'Content-Type' => 'text/event-stream',
'Cache-Control' => 'no-cache',
]);
$app = app();
if (!empty($app->worker) && !empty($app->connection)) {
$app->connection->send(new \Workerman\Protocols\Http\Response(200, $headers, "\r\n"));
} else {
foreach ($headers as $name => $val) {
header($name . (!is_null($val) ? ':' . $val : ''));
}
}
}
}

145
extend/ba/Tree.php Normal file
View File

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

133
extend/ba/Version.php Normal file
View File

@@ -0,0 +1,133 @@
<?php
namespace ba;
/**
* 版本类
*/
class Version
{
/**
* 比较两个版本号
* @param $v1 string 要求的版本号
* @param $v2 bool | string 被比较版本号
* @return bool 是否达到要求的版本号
*/
public static function compare(string $v1, bool|string $v2): bool
{
if (!$v2) {
return false;
}
// 删除开头的 V
if (strtolower($v1[0]) == 'v') {
$v1 = substr($v1, 1);
}
if (strtolower($v2[0]) == 'v') {
$v2 = substr($v2, 1);
}
if ($v1 == "*" || $v1 == $v2) {
return true;
}
// 丢弃'-'后面的内容
if (str_contains($v1, '-')) $v1 = explode('-', $v1)[0];
if (str_contains($v2, '-')) $v2 = explode('-', $v2)[0];
$v1 = explode('.', $v1);
$v2 = explode('.', $v2);
// 将号码逐个进行比较
for ($i = 0; $i < count($v1); $i++) {
if (!isset($v2[$i])) {
break;
}
if ($v1[$i] == $v2[$i]) {
continue;
}
if ($v1[$i] > $v2[$i]) {
return false;
}
if ($v1[$i] < $v2[$i]) {
return true;
}
}
if (count($v1) != count($v2)) {
return !(count($v1) > count($v2));
}
return false;
}
/**
* 是否是一个数字版本号
* @param $version
* @return bool
*/
public static function checkDigitalVersion($version): bool
{
if (!$version) {
return false;
}
if (strtolower($version[0]) == 'v') {
$version = substr($version, 1);
}
$rule1 = '/\.{2,10}/'; // 是否有两个的`.`
$rule2 = '/^\d+(\.\d+){0,10}$/';
if (!preg_match($rule1, (string)$version)) {
return !!preg_match($rule2, (string)$version);
}
return false;
}
/**
* @return string
*/
public static function getCnpmVersion(): string
{
$execOut = Terminal::getOutputFromProc('version.cnpm');
if ($execOut) {
$preg = '/cnpm@(.+?) \(/is';
preg_match($preg, $execOut, $result);
return $result[1] ?? '';
} else {
return '';
}
}
/**
* 获取依赖版本号
* @param string $name 支持npm、cnpm、yarn、pnpm、node
* @return string
*/
public static function getVersion(string $name): string
{
if ($name == 'cnpm') {
return self::getCnpmVersion();
} elseif (in_array($name, ['npm', 'yarn', 'pnpm', 'node'])) {
$execOut = Terminal::getOutputFromProc('version.' . $name);
if ($execOut) {
if (strripos($execOut, 'npm WARN') !== false) {
$preg = '/\d+(\.\d+){0,2}/';
preg_match($preg, $execOut, $matches);
if (isset($matches[0]) && self::checkDigitalVersion($matches[0])) {
return $matches[0];
}
}
$execOut = preg_split('/\r\n|\r|\n/', $execOut);
// 检测两行,第一行可能会是个警告消息
for ($i = 0; $i < 2; $i++) {
if (isset($execOut[$i]) && self::checkDigitalVersion($execOut[$i])) {
return $execOut[$i];
}
}
} else {
return '';
}
}
return '';
}
}