diff --git a/app/admin/Service.php b/app/admin/Service.php
deleted file mode 100644
index 0604ac38e..000000000
--- a/app/admin/Service.php
+++ /dev/null
@@ -1,69 +0,0 @@
- '系统配置',
- 'subs' => [
- ['name' => '系统参数配置', 'icon' => 'layui-icon layui-icon-set', 'node' => 'admin/config/index'],
- ['name' => '系统任务管理', 'icon' => 'layui-icon layui-icon-log', 'node' => 'admin/queue/index'],
- ['name' => '系统日志管理', 'icon' => 'layui-icon layui-icon-form', 'node' => 'admin/oplog/index'],
- ['name' => '数据字典管理', 'icon' => 'layui-icon layui-icon-code-circle', 'node' => 'admin/base/index'],
- ['name' => '系统文件管理', 'icon' => 'layui-icon layui-icon-carousel', 'node' => 'admin/file/index'],
- ['name' => '系统菜单管理', 'icon' => 'layui-icon layui-icon-layouts', 'node' => 'admin/menu/index'],
- ],
- ],
- [
- 'name' => '权限管理',
- 'subs' => [
- ['name' => '系统权限管理', 'icon' => 'layui-icon layui-icon-vercode', 'node' => 'admin/auth/index'],
- ['name' => '系统用户管理', 'icon' => 'layui-icon layui-icon-username', 'node' => 'admin/user/index'],
- ],
- ],
- ];
- }
-}
diff --git a/app/admin/controller/Auth.php b/app/admin/controller/Auth.php
deleted file mode 100644
index ec7949864..000000000
--- a/app/admin/controller/Auth.php
+++ /dev/null
@@ -1,141 +0,0 @@
-layTable(function () {
- $this->title = '系统权限管理';
- }, static function (QueryHelper $query) {
- $query->like('title,desc')->equal('status,utype')->dateBetween('create_at');
- });
- }
-
- /**
- * 修改权限状态
- * @auth true
- */
- public function state()
- {
- SystemAuth::mSave($this->_vali([
- 'status.in:0,1' => '状态值范围异常!',
- 'status.require' => '状态值不能为空!',
- ]));
- }
-
- /**
- * 删除系统权限.
- * @auth true
- */
- public function remove()
- {
- SystemAuth::mDelete();
- }
-
- /**
- * 添加系统权限.
- * @auth true
- */
- public function add()
- {
- SystemAuth::mForm('form');
- }
-
- /**
- * 编辑系统权限.
- * @auth true
- */
- public function edit()
- {
- SystemAuth::mForm('form');
- }
-
- /**
- * 表单后置数据处理.
- */
- protected function _form_filter(array $data)
- {
- if ($this->request->isGet()) {
- $this->title = empty($data['title']) ? '添加访问授权' : "编辑【{$data['title']}】授权";
- } elseif ($this->request->post('action') === 'json') {
- if ($this->app->isDebug()) {
- AdminService::clear();
- }
- $ztree = AdminService::getTree(empty($data['id']) ? [] : SystemNode::mk()->where(['auth' => $data['id']])->column('node'));
- usort($ztree, static function ($a, $b) {
- if (explode('-', $a['node'])[0] !== explode('-', $b['node'])[0]) {
- if (stripos($a['node'], 'plugin-') === 0) {
- return 1;
- }
- }
- return $a['node'] === $b['node'] ? 0 : ($a['node'] > $b['node'] ? 1 : -1);
- });
- [$ps, $cs] = [Plugin::get(), (array)$this->app->config->get('app.app_names', [])];
- foreach ($ztree as &$n) {
- $n['title'] = lang($cs[$n['node']] ?? (($ps[$n['node']] ?? [])['name'] ?? $n['title']));
- }
- $this->success('获取权限节点成功!', $ztree);
- } elseif (empty($data['nodes'])) {
- $this->error('未配置功能节点!');
- }
- }
-
- /**
- * 节点更新处理.
- */
- protected function _form_result(bool $state, array $post)
- {
- if ($state && $this->request->post('action') === 'save') {
- [$map, $data] = [['auth' => $post['id']], []];
- foreach ($post['nodes'] ?? [] as $node) {
- $data[] = $map + ['node' => $node];
- }
- SystemNode::mk()->where($map)->delete();
- count($data) > 0 && SystemNode::mk()->insertAll($data);
- sysoplog('系统权限管理', "配置系统权限[{$map['auth']}]授权成功");
- $this->success('权限修改成功!', 'javascript:history.back()');
- }
- }
-}
diff --git a/app/admin/controller/Base.php b/app/admin/controller/Base.php
deleted file mode 100644
index c9f7fdb89..000000000
--- a/app/admin/controller/Base.php
+++ /dev/null
@@ -1,116 +0,0 @@
-layTable(function () {
- $this->title = '数据字典管理';
- $this->types = SystemBase::types();
- $this->type = $this->get['type'] ?? ($this->types[0] ?? '-');
- }, static function (QueryHelper $query) {
- $query->where(['deleted' => 0])->equal('type');
- $query->like('code,name,status')->dateBetween('create_at');
- });
- }
-
- /**
- * 添加数据字典.
- * @auth true
- */
- public function add()
- {
- SystemBase::mForm('form');
- }
-
- /**
- * 编辑数据字典.
- * @auth true
- */
- public function edit()
- {
- SystemBase::mForm('form');
- }
-
- /**
- * 修改数据状态
- * @auth true
- */
- public function state()
- {
- SystemBase::mSave($this->_vali([
- 'status.in:0,1' => '状态值范围异常!',
- 'status.require' => '状态值不能为空!',
- ]));
- }
-
- /**
- * 删除数据记录.
- * @auth true
- */
- public function remove()
- {
- SystemBase::mDelete();
- }
-
- /**
- * 表单数据处理.
- * @throws DbException
- */
- protected function _form_filter(array &$data)
- {
- if ($this->request->isGet()) {
- $this->types = SystemBase::types();
- $this->types[] = '--- ' . lang('新增类型') . ' ---';
- $this->type = $this->get['type'] ?? ($this->types[0] ?? '-');
- } else {
- $map = [];
- $map[] = ['deleted', '=', 0];
- $map[] = ['code', '=', $data['code']];
- $map[] = ['type', '=', $data['type']];
- $map[] = ['id', '<>', $data['id'] ?? 0];
- if (SystemBase::mk()->where($map)->count() > 0) {
- $this->error('数据编码已经存在!');
- }
- }
- }
-}
diff --git a/app/admin/controller/Config.php b/app/admin/controller/Config.php
deleted file mode 100644
index 05821b768..000000000
--- a/app/admin/controller/Config.php
+++ /dev/null
@@ -1,157 +0,0 @@
- '默认色0',
- 'white' => '简约白0',
- 'red-1' => '玫瑰红1',
- 'blue-1' => '深空蓝1',
- 'green-1' => '小草绿1',
- 'black-1' => '经典黑1',
- 'red-2' => '玫瑰红2',
- 'blue-2' => '深空蓝2',
- 'green-2' => '小草绿2',
- 'black-2' => '经典黑2',
- ];
-
- /**
- * 系统参数配置.
- * @auth true
- * @menu true
- */
- public function index()
- {
- $this->title = '系统参数配置';
- $this->files = Storage::types();
- $this->plugins = Plugin::get(null, true);
- $this->issuper = AdminService::isSuper();
- $this->systemid = ModuleService::getRunVar('uni');
- $this->framework = ModuleService::getLibrarys('topthink/framework');
- $this->thinkadmin = ModuleService::getLibrarys('zoujingli/think-library');
- if (AdminService::isSuper() && $this->app->session->get('user.password') === md5('admin')) {
- $url = url('admin/index/pass', ['id' => AdminService::getUserId()]);
- $this->showErrorMessage = lang("超级管理员账号的密码未修改,建议立即修改密码!", [$url]);
- }
- uasort($this->plugins, static function ($a, $b) {
- if ($a['space'] === $b['space']) {
- return 0;
- }
- return $a['space'] > $b['space'] ? 1 : -1;
- });
- $this->fetch();
- }
-
- /**
- * 修改系统参数.
- * @auth true
- * @throws \think\admin\Exception
- */
- public function system()
- {
- if ($this->request->isGet()) {
- $this->title = '修改系统参数';
- $this->themes = static::themes;
- $this->fetch();
- } else {
- $post = $this->request->post();
- // 修改网站后台入口路径
- if (!empty($post['xpath'])) {
- if (!preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $post['xpath'])) {
- $this->error('后台入口格式错误!');
- }
- if ($post['xpath'] !== 'admin') {
- if (is_dir(syspath("app/{$post['xpath']}")) || !empty(Plugin::get($post['xpath']))) {
- $this->error(lang('已存在 %s 应用!', [$post['xpath']]));
- }
- }
- RuntimeService::set(null, [$post['xpath'] => 'admin']);
- }
- // 修改网站 ICON 图标,替换 public/favicon.ico
- if (preg_match('#^https?://#', $post['site_icon'] ?? '')) {
- try {
- SystemService::setFavicon($post['site_icon'] ?? '');
- } catch (\Exception $exception) {
- trace_file($exception);
- }
- }
- // 数据数据到系统配置表
- foreach ($post as $k => $v) {
- sysconf($k, $v);
- }
- sysoplog('系统配置管理', '修改系统参数成功');
- $this->success('数据保存成功!', admuri('admin/config/index'));
- }
- }
-
- /**
- * 修改文件存储.
- * @auth true
- * @throws \think\admin\Exception
- */
- public function storage()
- {
- $this->_applyFormToken();
- if ($this->request->isGet()) {
- $this->type = input('type', 'local');
- if ($this->type === 'alioss') {
- $this->points = AliossStorage::region();
- } elseif ($this->type === 'qiniu') {
- $this->points = QiniuStorage::region();
- } elseif ($this->type === 'txcos') {
- $this->points = TxcosStorage::region();
- }
- $this->fetch("storage-{$this->type}");
- } else {
- $post = $this->request->post();
- if (!empty($post['storage']['allow_exts'])) {
- $deny = ['sh', 'asp', 'bat', 'cmd', 'exe', 'php'];
- $exts = array_unique(str2arr(strtolower($post['storage']['allow_exts'])));
- if (count(array_intersect($deny, $exts)) > 0) {
- $this->error('禁止上传可执行的文件!');
- }
- $post['storage']['allow_exts'] = join(',', $exts);
- }
- foreach ($post as $name => $value) {
- sysconf($name, $value);
- }
- sysoplog('系统配置管理', '修改系统存储参数');
- $this->success('修改文件存储成功!');
- }
- }
-}
diff --git a/app/admin/controller/File.php b/app/admin/controller/File.php
deleted file mode 100644
index 1340396a5..000000000
--- a/app/admin/controller/File.php
+++ /dev/null
@@ -1,118 +0,0 @@
-layTable(function () {
- $this->title = '系统文件管理';
- $this->xexts = SystemFile::mk()->distinct()->column('xext');
- }, static function (QueryHelper $query) {
- $query->like('name,hash,xext')->equal('type')->dateBetween('create_at');
- $query->where(['issafe' => 0, 'status' => 2, 'uuid' => AdminService::getUserId()]);
- });
- }
-
- /**
- * 编辑系统文件.
- * @auth true
- */
- public function edit()
- {
- SystemFile::mForm('form');
- }
-
- /**
- * 删除系统文件.
- * @auth true
- */
- public function remove()
- {
- if (!AdminService::isSuper()) {
- $where = ['uuid' => AdminService::getUserId()];
- }
- SystemFile::mDelete('', $where ?? []);
- }
-
- /**
- * 清理重复文件.
- * @auth true
- * @throws DbException
- */
- public function distinct()
- {
- $map = ['issafe' => 0, 'uuid' => AdminService::getUserId()];
- // 使用派生表包装子查询,避免直接引用同一表
- $keepSubQuery = SystemFile::mk()->fieldRaw('MAX(id) AS id')->where($map)->group('type, xkey')->buildSql();
- // 使用 whereNotExists 配合派生表子查询删除,避免 1093 错误和 whereIn
- SystemFile::mk()->where($map)->whereNotExists(function ($query) use ($keepSubQuery) {
- $query->table("({$keepSubQuery})")->alias('f2')->whereRaw('f2.id = system_file.id');
- })->delete();
- $this->success('清理重复文件成功!');
- }
-
- /**
- * 控制器初始化.
- */
- protected function initialize()
- {
- $this->types = Storage::types();
- }
-
- /**
- * 数据列表处理.
- */
- protected function _page_filter(array &$data)
- {
- foreach ($data as &$vo) {
- $vo['ctype'] = $this->types[$vo['type']] ?? $vo['type'];
- }
- }
-}
diff --git a/app/admin/controller/Index.php b/app/admin/controller/Index.php
deleted file mode 100644
index 7e3716647..000000000
--- a/app/admin/controller/Index.php
+++ /dev/null
@@ -1,161 +0,0 @@
-app->isDebug());
- /* ! 读取当前用户权限菜单树 */
- $this->menus = MenuService::getTree();
- /* ! 判断当前用户的登录状态 */
- $this->login = AdminService::isLogin();
- /* ! 菜单为空且未登录跳转到登录页 */
- if (empty($this->menus) && empty($this->login)) {
- $this->redirect(sysuri('admin/login/index'));
- } else {
- $this->title = '系统管理后台';
- $this->super = AdminService::isSuper();
- $this->theme = AdminService::getUserTheme();
- $this->fetch();
- }
- }
-
- /**
- * 后台主题切换.
- * @login true
- * @throws Exception
- */
- public function theme()
- {
- if ($this->request->isGet()) {
- $this->theme = AdminService::getUserTheme();
- $this->themes = Config::themes;
- $this->fetch();
- } else {
- $data = $this->_vali(['site_theme.require' => '主题名称不能为空!']);
- if (AdminService::setUserTheme($data['site_theme'])) {
- $this->success('主题配置保存成功!');
- } else {
- $this->error('主题配置保存失败!');
- }
- }
- }
-
- /**
- * 修改用户资料.
- * @login true
- */
- public function info()
- {
- $id = $this->request->param('id');
- if (AdminService::getUserId() == intval($id)) {
- SystemUser::mForm('user/form', 'id', [], ['id' => $id]);
- } else {
- $this->error('只能修改自己的资料!');
- }
- }
-
- /**
- * 修改当前用户密码
- * @login true
- * @throws DataNotFoundException
- * @throws DbException
- * @throws ModelNotFoundException
- */
- public function pass()
- {
- $id = $this->request->param('id');
- if (AdminService::getUserId() !== intval($id)) {
- $this->error('禁止修改他人密码!');
- }
- if ($this->app->request->isGet()) {
- $this->verify = true;
- SystemUser::mForm('user/pass', 'id', [], ['id' => $id]);
- } else {
- $data = $this->_vali([
- 'password.require' => '登录密码不能为空!',
- 'repassword.require' => '重复密码不能为空!',
- 'oldpassword.require' => '旧的密码不能为空!',
- 'password.confirm:repassword' => '两次输入的密码不一致!',
- ]);
- $user = SystemUser::mk()->find($id);
- if (empty($user)) {
- $this->error('用户不存在!');
- }
- if (md5($data['oldpassword']) !== $user['password']) {
- $this->error('旧密码验证失败,请重新输入!');
- }
- if ($user->save(['password' => md5($data['password'])])) {
- sysoplog('系统用户管理', "修改用户[{$user['id']}]密码成功");
- // 修改密码同步事件处理
- $this->app->event->trigger('PluginAdminChangePassword', [
- 'uuid' => intval($user['id']), 'pass' => $data['password'],
- ]);
- $this->success('密码修改成功,下次请使用新密码登录!', '');
- } else {
- $this->error('密码修改失败,请稍候再试!');
- }
- }
- }
-
- /**
- * 资料修改表单处理.
- */
- protected function _info_form_filter(array &$data)
- {
- if ($this->request->isPost()) {
- unset($data['username'], $data['authorize']);
- }
- }
-
- /**
- * 资料修改结果处理.
- */
- protected function _info_form_result(bool $status)
- {
- if ($status) {
- $this->success('用户资料修改成功!', 'javascript:location.reload()');
- }
- }
-}
diff --git a/app/admin/controller/Login.php b/app/admin/controller/Login.php
deleted file mode 100644
index 84012dd52..000000000
--- a/app/admin/controller/Login.php
+++ /dev/null
@@ -1,137 +0,0 @@
-app->request->isGet()) {
- if (AdminService::isLogin()) {
- $this->redirect(sysuri('admin/index/index'));
- } else {
- // 加载登录模板
- $this->title = '系统登录';
- // 登录验证令牌
- $this->captchaType = 'LoginCaptcha';
- $this->captchaToken = CodeExtend::uuid();
- // 当前运行模式
- $this->runtimeMode = RuntimeService::check();
- // 后台背景处理
- $images = str2arr(sysconf('login_image|raw') ?: '', '|');
- if (empty($images)) {
- $images = [
- SystemService::uri('/static/theme/img/login/bg1.jpg'),
- SystemService::uri('/static/theme/img/login/bg2.jpg'),
- ];
- }
- $this->loginStyle = sprintf('style="background-image:url(%s)" data-bg-transition="%s"', $images[0], join(',', $images));
- // 更新后台主域名,用于部分无法获取域名的场景调用
- if ($this->request->domain() !== sysconf('base.site_host|raw')) {
- sysconf('base.site_host', $this->request->domain());
- }
- $this->fetch();
- }
- } else {
- $data = $this->_vali([
- 'username.require' => '登录账号不能为空!',
- 'username.min:4' => '账号不能少于4位字符!',
- 'password.require' => '登录密码不能为空!',
- 'password.min:4' => '密码不能少于4位字符!',
- 'verify.require' => '图形验证码不能为空!',
- 'uniqid.require' => '图形验证标识不能为空!',
- ]);
- if (!CaptchaService::instance()->check($data['verify'], $data['uniqid'])) {
- $this->error('图形验证码验证失败,请重新输入!');
- }
- /* ! 用户信息验证 */
- $map = ['username' => $data['username'], 'is_deleted' => 0];
- $user = SystemUser::mk()->where($map)->findOrEmpty();
- if ($user->isEmpty()) {
- $this->app->session->set('LoginInputSessionError', true);
- $this->error('登录账号或密码错误,请重新输入!');
- }
- if (empty($user['status'])) {
- $this->app->session->set('LoginInputSessionError', true);
- $this->error('账号已经被禁用,请联系管理员!');
- }
- if (md5("{$user['password']}{$data['uniqid']}") !== $data['password']) {
- $this->app->session->set('LoginInputSessionError', true);
- $this->error('登录账号或密码错误,请重新输入!');
- }
- $user->hidden(['sort', 'status', 'password', 'is_deleted']);
- $this->app->session->set('user', $user->toArray());
- $this->app->session->delete('LoginInputSessionError');
- // 更新登录次数
- $user->where(['id' => $user->getAttr('id')])->inc('login_num')->update([
- 'login_at' => date('Y-m-d H:i:s'), 'login_ip' => $this->app->request->ip(),
- ]);
- // 刷新用户权限
- AdminService::apply(true);
- sysoplog('系统用户登录', '登录系统后台成功');
- $this->success('登录成功', sysuri('admin/index/index'));
- }
- }
-
- /**
- * 生成验证码
- */
- public function captcha()
- {
- $input = $this->_vali([
- 'type.require' => '类型不能为空!',
- 'token.require' => '标识不能为空!',
- ]);
- $image = CaptchaService::instance()->initialize();
- $captcha = ['image' => $image->getData(), 'uniqid' => $image->getUniqid()];
- // 未发生异常时,直接返回验证码内容
- if (!$this->app->session->get('LoginInputSessionError')) {
- $captcha['code'] = $image->getCode();
- }
- $this->success('生成验证码成功', $captcha);
- }
-
- /**
- * 退出登录.
- */
- public function out()
- {
- $this->app->session->destroy();
- $this->success('退出登录成功!', sysuri('admin/login/index'));
- }
-}
diff --git a/app/admin/controller/Menu.php b/app/admin/controller/Menu.php
deleted file mode 100644
index 63e1784c5..000000000
--- a/app/admin/controller/Menu.php
+++ /dev/null
@@ -1,189 +0,0 @@
-title = '系统菜单管理';
- $this->type = $this->get['type'] ?? 'index';
- // 获取顶级菜单ID
- $this->pid = $this->get['pid'] ?? '';
-
- // 查询顶级菜单集合
- $this->menupList = SystemMenu::mk()->where(['pid' => 0, 'status' => 1])->order('sort desc,id asc')->column('id,pid,title', 'id');
-
- SystemMenu::mQuery()->layTable();
- }
-
- /**
- * 添加系统菜单.
- * @auth true
- */
- public function add()
- {
- $this->_applyFormToken();
- SystemMenu::mForm('form');
- }
-
- /**
- * 编辑系统菜单.
- * @auth true
- */
- public function edit()
- {
- $this->_applyFormToken();
- SystemMenu::mForm('form');
- }
-
- /**
- * 修改菜单状态
- * @auth true
- */
- public function state()
- {
- SystemMenu::mSave($this->_vali([
- 'status.in:0,1' => '状态值范围异常!',
- 'status.require' => '状态值不能为空!',
- ]));
- }
-
- /**
- * 删除系统菜单.
- * @auth true
- */
- public function remove()
- {
- SystemMenu::mDelete();
- }
-
- /**
- * 列表数据处理.
- */
- protected function _index_page_filter(array &$data)
- {
- $data = DataExtend::arr2tree($data);
- // 回收站过滤有效菜单
- if ($this->type === 'recycle') {
- foreach ($data as $k1 => &$p1) {
- if (!empty($p1['sub'])) {
- foreach ($p1['sub'] as $k2 => &$p2) {
- if (!empty($p2['sub'])) {
- foreach ($p2['sub'] as $k3 => $p3) {
- if ($p3['status'] > 0) {
- unset($p2['sub'][$k3]);
- }
- }
- }
- if (empty($p2['sub']) && ($p2['url'] === '#' or $p2['status'] > 0)) {
- unset($p1['sub'][$k2]);
- }
- }
- }
- if (empty($p1['sub']) && ($p1['url'] === '#' or $p1['status'] > 0)) {
- unset($data[$k1]);
- }
- }
- }
- // 菜单数据树数据变平化
- $data = DataExtend::arr2table($data);
-
- // 过滤非当前顶级菜单的下级菜单,并重新索引数组
- if ($this->type === 'index' && $this->pid) {
- $data = array_values(array_filter($data, function ($item) {
- return strpos($item['spp'], ",{$this->pid},") !== false;
- }));
- }
-
- foreach ($data as &$vo) {
- if ($vo['url'] !== '#' && !preg_match('/^(https?:)?(\/\/|\\\)/i', $vo['url'])) {
- $vo['url'] = trim(url($vo['url']) . ($vo['params'] ? "?{$vo['params']}" : ''), '\/');
- }
- }
- }
-
- /**
- * 表单数据处理.
- */
- protected function _form_filter(array &$vo)
- {
- if ($this->request->isGet()) {
- $debug = $this->app->isDebug();
- /* 清理权限节点 */
- $debug && AdminService::clear();
- /* 读取系统功能节点 */
- $this->auths = [];
- $this->nodes = MenuService::getList($debug);
- foreach (NodeService::getMethods($debug) as $node => $item) {
- if ($item['isauth'] && substr_count($node, '/') >= 2) {
- $this->auths[] = ['node' => $node, 'title' => $item['title']];
- }
- }
- /* 选择自己上级菜单 */
- $vo['pid'] = $vo['pid'] ?? input('pid', '0');
- /* 列出可选上级菜单 */
- $menus = SystemMenu::mk()->order('sort desc,id asc')->column('id,pid,icon,url,node,title,params', 'id');
- $this->menus = DataExtend::arr2table(array_merge($menus, [['id' => '0', 'pid' => '-1', 'url' => '#', 'title' => '顶部菜单']]));
- if (isset($vo['id'])) {
- foreach ($this->menus as $menu) {
- if ($menu['id'] === $vo['id']) {
- $vo = $menu;
- }
- }
- }
- foreach ($this->menus as $key => $menu) {
- if ($menu['spt'] >= 3 || $menu['url'] !== '#') {
- unset($this->menus[$key]);
- }
- }
- if (isset($vo['spt'], $vo['spc']) && in_array($vo['spt'], [1, 2]) && $vo['spc'] > 0) {
- foreach ($this->menus as $key => $menu) {
- if ($vo['spt'] <= $menu['spt']) {
- unset($this->menus[$key]);
- }
- }
- }
- }
- }
-}
diff --git a/app/admin/controller/Queue.php b/app/admin/controller/Queue.php
deleted file mode 100644
index 4d6176c38..000000000
--- a/app/admin/controller/Queue.php
+++ /dev/null
@@ -1,126 +0,0 @@
-layTable(function () {
- $this->title = '系统任务管理';
- $this->iswin = ProcessService::iswin();
- if ($this->super = AdminService::isSuper()) {
- $this->command = ProcessService::think('xadmin:queue start');
- if (!$this->iswin && !empty($_SERVER['USER'])) {
- $this->command = "sudo -u {$_SERVER['USER']} {$this->command}";
- }
- }
- }, static function (QueryHelper $query) {
- $query->equal('status')->like('code|title#title,command');
- $query->timeBetween('enter_time,exec_time')->dateBetween('create_at');
- });
- }
-
- /**
- * 重启系统任务
- * @auth true
- */
- public function redo()
- {
- try {
- $data = $this->_vali(['code.require' => '任务编号不能为空!']);
- $queue = QueueService::instance()->initialize($data['code'])->reset();
- $queue->progress(1, '>>> 任务重置成功 <<<', '0.00');
- $this->success('任务重置成功!', $queue->code);
- } catch (HttpResponseException $exception) {
- throw $exception;
- } catch (\Exception $exception) {
- trace_file($exception);
- $this->error($exception->getMessage());
- }
- }
-
- /**
- * 清理运行数据.
- * @auth true
- */
- public function clean()
- {
- $this->_queue('定时清理系统运行数据', 'xadmin:queue clean', 0, [], 0, 3600);
- }
-
- /**
- * 删除系统任务
- * @auth true
- */
- public function remove()
- {
- SystemQueue::mDelete();
- }
-
- /**
- * 分页数据回调处理.
- * @throws DataNotFoundException
- * @throws DbException
- * @throws ModelNotFoundException
- */
- protected function _index_page_filter(array $data, array &$result)
- {
- $result['extra'] = ['dos' => 0, 'pre' => 0, 'oks' => 0, 'ers' => 0];
- SystemQueue::mk()->field('status,count(1) count')->group('status')->select()->map(static function ($item) use (&$result) {
- if (intval($item['status']) === 1) {
- $result['extra']['pre'] = $item['count'];
- }
- if (intval($item['status']) === 2) {
- $result['extra']['dos'] = $item['count'];
- }
- if (intval($item['status']) === 3) {
- $result['extra']['oks'] = $item['count'];
- }
- if (intval($item['status']) === 4) {
- $result['extra']['ers'] = $item['count'];
- }
- });
- }
-}
diff --git a/app/admin/controller/User.php b/app/admin/controller/User.php
deleted file mode 100644
index d8d62e251..000000000
--- a/app/admin/controller/User.php
+++ /dev/null
@@ -1,186 +0,0 @@
-type = $this->get['type'] ?? 'index';
- SystemUser::mQuery()->layTable(function () {
- $this->title = '系统用户管理';
- $this->bases = SystemBase::items('身份权限');
- }, function (QueryHelper $query) {
- // 加载对应数据列表
- $query->where(['is_deleted' => 0, 'status' => intval($this->type === 'index')]);
-
- // 关联用户身份资料
- /* @var \think\model\Relation|\think\db\Query $query */
- $query->with(['userinfo' => static function ($query) {
- $query->field('code,name,content');
- }]);
-
- // 数据列表搜索过滤
- $query->equal('status,usertype')->dateBetween('login_at,create_at');
- $query->like('username|nickname#username,contact_phone#phone,contact_mail#mail');
- });
- }
-
- /**
- * 添加系统用户.
- * @auth true
- */
- public function add()
- {
- SystemUser::mForm('form');
- }
-
- /**
- * 编辑系统用户.
- * @auth true
- */
- public function edit()
- {
- SystemUser::mForm('form');
- }
-
- /**
- * 修改用户密码
- * @auth true
- */
- public function pass()
- {
- $this->_applyFormToken();
- if ($this->request->isGet()) {
- $this->verify = false;
- SystemUser::mForm('pass');
- } else {
- $data = $this->_vali([
- 'id.require' => '用户ID不能为空!',
- 'password.require' => '登录密码不能为空!',
- 'repassword.require' => '重复密码不能为空!',
- 'repassword.confirm:password' => '两次输入的密码不一致!',
- ]);
- $user = SystemUser::mk()->findOrEmpty($data['id']);
- if ($user->isExists() && $user->save(['password' => md5($data['password'])])) {
- // 修改密码同步事件处理
- $this->app->event->trigger('PluginAdminChangePassword', [
- 'uuid' => $data['id'], 'pass' => $data['password'],
- ]);
- sysoplog('系统用户管理', "修改用户[{$data['id']}]密码成功");
- $this->success('密码修改成功,请使用新密码登录!', '');
- } else {
- $this->error('密码修改失败,请稍候再试!');
- }
- }
- }
-
- /**
- * 修改用户状态
- * @auth true
- */
- public function state()
- {
- $this->_checkInput();
- SystemUser::mSave($this->_vali([
- 'status.in:0,1' => '状态值范围异常!',
- 'status.require' => '状态值不能为空!',
- ]));
- }
-
- /**
- * 删除系统用户.
- * @auth true
- */
- public function remove()
- {
- $this->_checkInput();
- SystemUser::mDelete();
- }
-
- /**
- * 表单数据处理.
- * @throws DataNotFoundException
- * @throws DbException
- * @throws ModelNotFoundException
- */
- protected function _form_filter(array &$data)
- {
- if ($this->request->isPost()) {
- // 检查资料是否完整
- empty($data['username']) && $this->error('登录账号不能为空!');
- if ($data['username'] !== AdminService::getSuperName()) {
- empty($data['authorize']) && $this->error('未配置权限!');
- }
- // 处理上传的权限格式
- $data['authorize'] = arr2str($data['authorize'] ?? []);
- if (empty($data['id'])) {
- // 检查账号是否重复
- $map = ['username' => $data['username'], 'is_deleted' => 0];
- if (SystemUser::mk()->where($map)->count() > 0) {
- $this->error('账号已经存在,请使用其它账号!');
- }
- // 新添加的用户密码与账号相同
- $data['password'] = md5($data['username']);
- } else {
- unset($data['username']);
- }
- } else {
- // 权限绑定处理
- $data['authorize'] = str2arr($data['authorize'] ?? '');
- $this->auths = SystemAuth::items();
- $this->bases = SystemBase::items('身份权限');
- $this->super = AdminService::getSuperName();
- }
- }
-
- /**
- * 检查输入变量.
- */
- private function _checkInput()
- {
- if (in_array('10000', str2arr(input('id', '')))) {
- $this->error('系统超级账号禁止删除!');
- }
- }
-}
diff --git a/app/admin/controller/api/Queue.php b/app/admin/controller/api/Queue.php
deleted file mode 100644
index ebc34be76..000000000
--- a/app/admin/controller/api/Queue.php
+++ /dev/null
@@ -1,125 +0,0 @@
-app->console->call('xadmin:queue', ['stop'])->fetch();
- if (stripos($message, 'sent end signal to process')) {
- sysoplog('系统运维管理', '尝试停止任务监听服务');
- $this->success('停止任务监听服务成功!');
- } elseif (stripos($message, 'processes to stop')) {
- $this->success('没有找到需要停止的服务!');
- } else {
- $this->error(nl2br($message));
- }
- } catch (HttpResponseException $exception) {
- throw $exception;
- } catch (\Exception $exception) {
- trace_file($exception);
- $this->error($exception->getMessage());
- }
- } else {
- $this->error('请使用超管账号操作!');
- }
- }
-
- /**
- * 启动监听服务
- * @login true
- */
- public function start()
- {
- if (AdminService::isSuper()) {
- try {
- $message = $this->app->console->call('xadmin:queue', ['start'])->fetch();
- if (stripos($message, 'daemons started successfully for pid')) {
- sysoplog('系统运维管理', '尝试启动任务监听服务');
- $this->success('任务监听服务启动成功!');
- } elseif (stripos($message, 'daemons already exist for pid')) {
- $this->success('任务监听服务已经启动!');
- } else {
- $this->error(nl2br($message));
- }
- } catch (HttpResponseException $exception) {
- throw $exception;
- } catch (\Exception $exception) {
- trace_file($exception);
- $this->error($exception->getMessage());
- }
- } else {
- $this->error('请使用超管账号操作!');
- }
- }
-
- /**
- * 检查监听服务
- * @login true
- */
- public function status()
- {
- if (AdminService::isSuper()) {
- try {
- $message = $this->app->console->call('xadmin:queue', ['status'])->fetch();
- if (preg_match('/process.*?\d+.*?running/', $message)) {
- echo "{$this->app->lang->get('已启动')}";
- } else {
- echo "{$this->app->lang->get('未启动')}";
- }
- } catch (\Error|\Exception $exception) {
- echo "{$this->app->lang->get('异 常')}";
- }
- } else {
- $message = lang('只有超级管理员才能操作!');
- echo "{$this->app->lang->get('无权限')}";
- }
- }
-
- /**
- * 查询任务进度.
- * @login true
- */
- public function progress()
- {
- $input = $this->_vali(['code.require' => '任务编号不能为空!']);
- $this->app->db->setLog(new NullLogger()); /* 关闭数据库请求日志 */
- $message = SystemQueue::mk()->where($input)->value('message', '');
- $this->success('获取任务进度成功d!', json_decode($message, true));
- }
-}
diff --git a/app/admin/lang/en-us.php b/app/admin/lang/en-us.php
deleted file mode 100644
index 2a1ee14ad..000000000
--- a/app/admin/lang/en-us.php
+++ /dev/null
@@ -1,396 +0,0 @@
-修改密码!"] = "The super administrator password has not been changed. Suggest changing password.";
-
-$extra['等待处理'] = 'Pending';
-$extra['正在处理'] = 'Processing';
-$extra['处理完成'] = 'Completed';
-$extra['处理失败'] = 'Failed';
-
-$extra['条件搜索'] = 'Search';
-$extra['批量删除'] = 'Batch Delete';
-
-$extra['上传进度 %s'] = 'Upload progress %s';
-$extra['文件上传出错!'] = 'File upload error.';
-$extra['文件上传失败!'] = 'File upload failed.';
-$extra['大小超出限制!'] = 'Size exceeds limit.';
-$extra['文件秒传成功!'] = 'Successfully transmitted the file in seconds.';
-$extra['上传接口异常!'] = 'Abnormal upload interface.';
-$extra['文件上传成功!'] = 'File uploaded successfully.';
-$extra['图片压缩失败!'] = 'Image compression failed.';
-$extra['无效的文件上传对象!'] = 'Invalid file upload object.';
-
-return array_merge($extra, [
- // 系统操作
- '基本资料' => 'Basic information',
- '安全设置' => 'Security setting',
- '缓存加速' => 'Cache acceleration',
- '清理缓存' => 'Clean cache',
- '配色方案' => 'Color scheme',
- '立即登录' => 'Login',
- '退出登录' => 'Logout',
- '系统提示:' => 'System Notify: ',
- '清空日志缓存成功!' => 'Successfully cleared the log cache.',
- '获取任务进度成功!' => 'Successfully obtained task progress.',
- '网站缓存加速成功!' => 'Website cache acceleration successful.',
- '请使用超管账号操作!' => 'Please use a super managed account to operate.',
- '停止任务监听服务成功!' => 'Successfully stopped task listening service.',
- '任务监听服务启动成功!' => 'Task monitoring service started successfully.',
- '任务监听服务已经启动!' => 'The task monitoring service has started.',
- '没有找到需要停止的服务!' => 'No services found that need to be stopped.',
- '已切换后台编辑器!' => 'Switched to background editor.',
- // 其他搜索器提示
- '请选择登录时间' => 'Please select the Login time',
- '请选择创建时间' => 'Please select the creation time',
- '请输入账号或名称' => 'Please enter an account or name',
- '请输入权限名称' => 'Please enter the permission name',
- '请输入数据编码' => 'Please enter the data code',
- '请输入数据名称' => 'Please enter the data name',
- '请输入文件名称' => 'Please enter the file name',
- '请输入文件哈希' => 'Please enter the file hash',
- '请输入操作节点' => 'Please enter the operate node',
- '请输入操作内容' => 'Please enter the operate content',
- '请输入访问地址' => 'Please enter the access Geoip',
- // 系统配置
- '运行模式' => 'Running Mode',
- '生产模式' => 'Production mode',
- '开发模式' => 'Development mode',
- '以开发模式运行' => 'Running in Development mode',
- '以生产模式运行' => 'Running in Production mode',
- '清理无效配置' => 'Clean up Invalid Configurations',
- '修改系统参数' => 'Modify System Parameters',
- '清理系统配置成功!' => 'Successfully cleaned.',
- '自适应模式' => 'Adaptive Mode',
- '富编辑器' => 'RichText Editor',
- '存储引擎' => 'Storage Engine',
- '系统参数' => 'System Parameter',
- '网站名称' => 'Site Name',
- '管理程序名称' => 'Program Name',
- '管理程序版本' => 'Program Version',
- '公安备案号' => 'Public security registration number',
- '网站备案号' => 'Website registration number',
- '网站版权信息' => 'Website copyright information',
- '系统信息' => 'System Information',
- '应用插件' => 'Plugin Information',
- '核心框架' => 'Core Framework',
- '平台框架' => 'Platform Framework',
- '操作系统' => 'Operating System',
- '运行环境' => 'Runtime Environment',
- '仅开发模式可见' => 'Visible only in Development mode',
- '仅生产模式可见' => 'Visible only in Production mode',
- '插件名称' => 'Plugin Name',
- '应用名称' => 'App Name',
- '插件包名' => 'Package Name',
- '插件版本' => 'Plugin Version',
- '授权协议' => 'License',
- '文件默认存储方式' => 'Default storage method for file upload',
- '当前系统配置参数' => 'Current system configuration parameters',
- '仅超级管理员可配置' => 'Only super administrators can configure',
-
- // 系统任务管理
- '优化数据库' => 'Optimize Database',
- '开启服务' => 'Start Service',
- '关闭服务' => 'Shutdown Service',
- '定时清理' => 'Regular cleaning',
- '服务状态' => 'Service',
- '任务统计' => 'Total',
- '编号名称' => 'Name',
- '任务指令' => 'Command',
- '任务状态' => 'Status',
- '计划时间' => 'scheduled time',
- '任务名称' => 'Name',
- '检查中' => 'Checking',
- '任务计划' => 'Scheduled',
- '重 置' => 'Reset',
- '日 志' => 'Logs',
- '异 常' => 'Abnormal',
- '无权限' => 'Denied',
- '已启动' => 'Started',
- '未启动' => 'Stopped',
- // 数据字典管理
- '数据编码' => 'Code',
- '数据名称' => 'Name',
- '操作账号' => 'User',
- '操作节点' => 'Node',
- '操作行为' => 'Action',
- '操作内容' => 'Content',
- '访问地址' => 'Geo IP',
- '网络服务商' => 'ISP.',
- '日志清理成功!' => 'Logger Clear Complate.',
- '成功清理所有日志' => 'Successfully cleared all logs.',
- // 系统文件管理
- '文件名称' => 'Name',
- '文件哈希' => 'HASH',
- '文件大小' => 'Size',
- '文件后缀' => 'Exts',
- '存储方式' => 'Storage Type',
- '清理重复' => 'Clear Replace',
- '上传方式' => 'Upload Type',
- '查看文件' => 'View',
- '文件链接' => 'Link',
- '秒传' => 'Speedy',
- '普通' => 'Normal',
- // 系统菜单管理
- '图 标' => 'Icon',
- '添加菜单' => 'Add',
- '禁用菜单' => 'Forbid',
- '激活菜单' => 'Resume',
- '系统菜单' => 'Menus',
- '菜单名称' => 'Name',
- '跳转链接' => 'Link',
- '上级菜单' => 'Parent',
- '菜单链接' => 'Link',
- '链接参数' => 'Params',
- '权限节点' => 'Node',
- '菜单图标' => 'Icon',
- '选择图标' => 'Select Icon',
- // 系统权限管理
- '授 权' => 'Auth',
- '添加权限' => 'Add',
- '权限名称' => 'Name',
- '权限描述' => 'Description',
- '请输入权限描述' => 'Please enter a permission description',
- // 系统用户管理
- '账号名称' => 'Username',
- '添加用户' => 'Add User',
- '最后登录' => 'Last Login Time',
- '头像' => 'Head',
- '登录账号' => 'Username',
- '用户名称' => 'Nickname',
- '登录次数' => 'Login Times',
- '系统用户' => 'System User',
- '密 码' => 'Password',
- '系统用户管理' => 'Users',
-
- // 通用操作
- '回 收 站' => 'Recycle Bin',
- '排序权重' => 'Sort Weight',
- '使用状态' => 'Status',
- '操作面板' => 'Actions',
- '已激活' => 'Activated',
- '已禁用' => 'Disabled',
- '已启用' => 'Enabled',
- '添 加' => 'Add',
- '编 辑' => 'Edit',
- '删 除' => 'Delete',
- '全部' => 'All',
- '搜 索' => 'Search',
- '导 出' => 'Export',
- '保存数据' => 'Save Data',
- '取消编辑' => 'Cancel Edit',
- '操作日志' => 'Operation Log',
- '创建时间' => 'Create Time',
-
- // 用户管理
- '批量禁用' => 'Batch Disable',
- '批量恢复' => 'Batch Restore',
- '编辑用户' => 'Edit User',
- '设置密码' => 'Set Password',
- '角色身份' => 'Role Identity',
- '全部菜单' => 'All Menus',
-
- // 权限管理
- '确定要批量删除权限吗?' => 'Are you sure you want to batch delete permissions?',
- '确定要删除权限吗?' => 'Are you sure you want to delete the permission?',
- '功能节点' => 'Function Node',
- '访问权限名称需要保持不重复,在给用户授权时需要根据名称选择!' => 'Access permission names must be unique. When authorizing users, select based on the name!',
- '已禁用记录' => 'Disabled Records',
- '已激活记录' => 'Activated Records',
-
- // 菜单管理
- '确定要删除菜单吗?' => 'Are you sure you want to delete the menu?',
- '添加系统菜单' => 'Add System Menu',
- '编辑系统菜单' => 'Edit System Menu',
-
- // 文件管理
- '确定删除这些记录吗?' => 'Are you sure you want to delete these records?',
- '播放视频' => 'Play Video',
- '播放音频' => 'Play Audio',
- '查看下载' => 'View/Download',
- '编辑文件信息' => 'Edit File Info',
-
- // 任务管理
- '确定批量删除记录吗?' => 'Are you sure you want to batch delete records?',
- '确定要重置该任务吗?' => 'Are you sure you want to reset this task?',
- '确定要删除该记录吗?' => 'Are you sure you want to delete this record?',
- '请选择计划时间' => 'Please select scheduled time',
- '请输入名称或编号' => 'Please enter name or code',
- '请输入任务指令' => 'Please enter task command',
-
- // 确认提示
- '确定要永久删除吗?' => 'Are you sure you want to permanently delete?',
- '确定要取消编辑吗?' => 'Are you sure you want to cancel editing?',
- '确定要取消修改吗?' => 'Are you sure you want to cancel the modification?',
- '确定要禁用这些用户吗?' => 'Are you sure you want to disable these users?',
- '确定要恢复这些账号吗?' => 'Are you sure you want to restore these accounts?',
- '确定永久删除这些账号吗?' => 'Are you sure you want to permanently delete these accounts?',
-
- // 系统提示
- '新增类型' => 'New Type',
- '只有超级管理员才能操作!' => 'Only super administrators can operate!',
- '日志清理失败,%s' => 'Log cleanup failed, %s',
- '已存在 %s 应用!' => 'Application %s already exists!',
-
- // 演示环境提示
- '演示环境禁止修改用户密码!' => 'Demo environment prohibits modifying user passwords!',
- '演示环境禁止修改系统配置!' => 'Demo environment prohibits modifying system configuration!',
- '演示环境禁止给菜单排序!' => 'Demo environment prohibits menu sorting!',
- '演示环境禁止添加菜单!' => 'Demo environment prohibits adding menus!',
- '演示环境禁止编辑菜单!' => 'Demo environment prohibits editing menus!',
- '演示环境禁止禁用菜单!' => 'Demo environment prohibits disabling menus!',
- '演示环境禁止删除菜单!' => 'Demo environment prohibits deleting menus!',
- '演示环境禁止修改密码!' => 'Demo environment prohibits modifying passwords!',
-
- // 表单字段
- '用户账号' => 'User Account',
- '用户权限' => 'User Permissions',
- '用户资料' => 'User Profile',
- '登录账号' => 'Login Account',
- '用户名称' => 'User Name',
- '角色身份' => 'Role Identity',
- '访问权限' => 'Access Permissions',
- '联系邮箱' => 'Contact Email',
- '联系手机' => 'Contact Mobile',
- '联系QQ' => 'Contact QQ',
- '用户描述' => 'User Description',
- '登录用户账号' => 'Login Username',
- '旧的登录密码' => 'Old Password',
- '新的登录密码' => 'New Password',
- '重复登录密码' => 'Repeat Password',
- '验证密码' => 'Verify Password',
- '登录密码' => 'Login Password',
- '重复密码' => 'Repeat Password',
-
- // 表单提示
- '登录账号不能少于4位字符,创建后不能再次修改.' => 'Login account must be at least 4 characters and cannot be modified after creation.',
- '用于区分用户数据的用户名称,请尽量不要重复.' => 'User name used to distinguish user data, please try not to duplicate.',
- '超级用户拥所有访问权限,不需要配置权限。' => 'Super users have all access permissions and do not need to configure permissions.',
- '可选,请填写用户常用的电子邮箱' => 'Optional, please fill in the user\'s commonly used email address',
- '可选,请填写用户常用的联系手机号' => 'Optional, please fill in the user\'s commonly used mobile phone number',
- '可选,请填写用户常用的联系QQ号' => 'Optional, please fill in the user\'s commonly used QQ number',
- '请输入用户描述' => 'Please enter user description',
- '登录用户账号创建后,不允许再次修改。' => 'Login username cannot be modified after creation.',
- '请输入旧密码来验证修改权限,旧密码不限制格式。' => 'Please enter the old password to verify modification permission. The old password format is not restricted.',
- '密码必须包含大小写字母、数字、符号的任意两者组合。' => 'Password must contain any combination of uppercase letters, lowercase letters, numbers, and symbols.',
- '请重复输入登录密码' => 'Please repeat the login password',
-
- // 系统配置表单
- '登录表单标题' => 'Login Form Title',
- '后台登录入口' => 'Backend Login Entry',
- '后台默认配色' => 'Backend Default Theme',
- '登录背景图片' => 'Login Background Image',
- 'JWT 接口密钥' => 'JWT API Key',
- '浏览器小图标' => 'Browser Icon',
- '后台程序名称' => 'Backend Program Name',
- '后台程序版本' => 'Backend Program Version',
- '公安安备号' => 'Public Security Registration Number',
- '保存配置' => 'Save Configuration',
- '取消修改' => 'Cancel Modification',
- '登录标题' => 'Login Title',
- '登录入口' => 'Login Entry',
- '接口密钥' => 'API Key',
- '图标文件' => 'Icon File',
- '程序名称' => 'Program Name',
- '版权信息' => 'Copyright Information',
-
- // 菜单表单提示
- '必选' => 'Required',
- '可选' => 'Optional',
- '请选择上级菜单或顶级菜单 ( 目前最多支持三级菜单 )' => 'Please select parent menu or top-level menu (currently supports up to 3 levels)',
- '请填写菜单名称 ( 如:系统管理 ),建议字符不要太长,一般 4-6 个汉字' => 'Please fill in the menu name (e.g., System Management), it is recommended not to be too long, generally 4-6 Chinese characters',
- '请填写链接地址或选择系统节点 ( 如:https://domain.com/admin/user/index.html 或 admin/user/index )' => 'Please fill in the link address or select a system node (e.g., https://domain.com/admin/user/index.html or admin/user/index)',
- '当填写链接地址时,以下面的 "权限节点" 来判断菜单自动隐藏或显示,注意未填写 "权限节点" 时将不会隐藏该菜单哦' => 'When filling in the link address, use the "Permission Node" below to determine whether the menu is automatically hidden or displayed. Note that if the "Permission Node" is not filled in, the menu will not be hidden',
- '设置菜单链接的 GET 访问参数 ( 如:name=1&age=3 )' => 'Set GET access parameters for menu links (e.g., name=1&age=3)',
- '请填写系统权限节点 ( 如:admin/user/index ),未填写时默认解释"菜单链接"判断是否拥有访问权限;' => 'Please fill in the system permission node (e.g., admin/user/index). If not filled in, the "Menu Link" will be used by default to determine access permissions',
- '设置菜单选项前置图标,目前支持 layui 字体图标及 iconfont 定制字体图标。' => 'Set the prefix icon for menu options. Currently supports layui font icons and iconfont custom font icons.',
- '请输入或选择图标' => 'Please enter or select an icon',
- '请输入菜单名称' => 'Please enter menu name',
- '请输入菜单链接' => 'Please enter menu link',
- '请输入链接参数' => 'Please enter link parameters',
- '请输入权限节点' => 'Please enter permission node',
- '请输入登录账号' => 'Please enter login account',
- '请输入用户名称' => 'Please enter user name',
- '请输入联系电子邮箱' => 'Please enter contact email',
- '请输入用户联系手机' => 'Please enter user contact mobile',
- '请输入常用的联系QQ' => 'Please enter commonly used contact QQ',
- '请输入旧的登录密码' => 'Please enter old login password',
- '请输入新的登录密码' => 'Please enter new login password',
-
- // 系统配置表单提示
- '请输入登录页面的表单标题' => 'Please enter login form title',
- '后台登录入口是由英文字母开头,且不能有相同名称的模块,设置之后原地址不能继续访问,请谨慎配置 ~' => 'Backend login entry must start with English letters and cannot have modules with the same name. After setting, the original address cannot be accessed. Please configure carefully.',
- '请输入32位JWT接口密钥' => 'Please enter 32-bit JWT API key',
- '请输入 32 位 JWT 接口密钥,在使用 JWT 接口时需要使用此密钥进行加密及签名!' => 'Please enter a 32-bit JWT API key. This key is required for encryption and signing when using JWT interfaces!',
- '请上传浏览器图标' => 'Please upload browser icon',
- '建议上传 128x128 或 256x256 的 JPG,PNG,JPEG 图片,保存后会自动生成 48x48 的 ICO 文件 ~' => 'It is recommended to upload JPG, PNG, or JPEG images of 128x128 or 256x256. After saving, a 48x48 ICO file will be automatically generated.',
- '请输入网站名称' => 'Please enter site name',
- '网站名称将显示在浏览器的标签上 ~' => 'Site name will be displayed on the browser tab.',
- '请输入程序名称' => 'Please enter program name',
- '管理程序名称显示在后台左上标题处 ~' => 'Management program name is displayed in the top left title of the backend.',
- '请输入程序版本' => 'Please enter program version',
- '管理程序版本显示在后台左上标题处 ~' => 'Management program version is displayed in the top left title of the backend.',
- '请输入公安安备号' => 'Please enter public security registration number',
- '请输入网站备案号' => 'Please enter website registration number',
- '请输入版权信息' => 'Please enter copyright information',
- '网站备案号和公安备案号可以在备案管理中心查询并获取,网站上线时必需配置备案号,备案号会链接到信息备案管理系统 ~' => 'Website registration number and public security registration number can be queried and obtained at the Registration Management Center. Registration numbers must be configured when the website goes online, and will be linked to the information registration management system.',
-
- // 数据字典表单提示
- '数据类型' => 'Data Type',
- '请选择数据类型,数据创建后不能再次修改哦 ~' => 'Please select data type. Data type cannot be modified after creation.',
- '请输入数据类型' => 'Please enter data type',
- '请输入新的数据类型,数据创建后不能再次修改哦 ~' => 'Please enter new data type. Data type cannot be modified after creation.',
- '数据编码' => 'Data Code',
- '请输入新的数据编码,数据创建后不能再次修改,同种数据类型的数据编码不能出现重复 ~' => 'Please enter new data code. Data code cannot be modified after creation, and duplicate codes are not allowed for the same data type.',
- '数据名称' => 'Data Name',
- '请输入当前数据名称,请尽量保持名称的唯一性,数据名称尽量不要出现重复 ~' => 'Please enter data name. Try to keep the name unique and avoid duplicates.',
- '数据内容' => 'Data Content',
- '请输入数据内容' => 'Please enter data content',
-
- // 数据字典列表
- '添加数据' => 'Add Data',
- '确定要批量删除数据吗?' => 'Are you sure you want to batch delete data?',
- '数据状态' => 'Data Status',
- '数据操作' => 'Actions',
- '编辑数据' => 'Edit Data',
- '确定要删除数据吗?' => 'Are you sure you want to delete data?',
-]);
diff --git a/app/admin/route/demo.php b/app/admin/route/demo.php
deleted file mode 100644
index af132e079..000000000
--- a/app/admin/route/demo.php
+++ /dev/null
@@ -1,55 +0,0 @@
-route->post('index/pass', static function () {
- return json(['code' => 0, 'info' => lang('演示环境禁止修改用户密码!')]);
- });
- Library::$sapp->route->post('config/system', static function () {
- return json(['code' => 0, 'info' => lang('演示环境禁止修改系统配置!')]);
- });
- Library::$sapp->route->post('config/storage', static function () {
- return json(['code' => 0, 'info' => lang('演示环境禁止修改系统配置!')]);
- });
- Library::$sapp->route->post('menu', static function () {
- return json(['code' => 0, 'info' => lang('演示环境禁止给菜单排序!')]);
- });
- Library::$sapp->route->post('menu/index', static function () {
- return json(['code' => 0, 'info' => lang('演示环境禁止给菜单排序!')]);
- });
- Library::$sapp->route->post('menu/add', static function () {
- return json(['code' => 0, 'info' => lang('演示环境禁止添加菜单!')]);
- });
- Library::$sapp->route->post('menu/edit', static function () {
- return json(['code' => 0, 'info' => lang('演示环境禁止编辑菜单!')]);
- });
- Library::$sapp->route->post('menu/state', static function () {
- return json(['code' => 0, 'info' => lang('演示环境禁止禁用菜单!')]);
- });
- Library::$sapp->route->post('menu/remove', static function () {
- return json(['code' => 0, 'info' => lang('演示环境禁止删除菜单!')]);
- });
- Library::$sapp->route->post('user/pass', static function () {
- return json(['code' => 0, 'info' => lang('演示环境禁止修改密码!')]);
- });
-}
diff --git a/app/admin/view/api/icon.html b/app/admin/view/api/icon.html
deleted file mode 100644
index 4ed6d426a..000000000
--- a/app/admin/view/api/icon.html
+++ /dev/null
@@ -1,134 +0,0 @@
-
-
-
-
- {block name="title"}{$title|default=''}{if !empty($title)} · {/if}{:sysconf('site_name')}{/block}
-
-
-
-
-
-
-
-
- {if file_exists(syspath("public/static/extra/icon/iconfont.css"))}
-
- {/if}
-
-
-
-
-
- {foreach $extraIcons??[] as $icon}
- -
-
-
{$icon}
-
- {/foreach}
- {foreach $layuiIcons??[] as $icon}
- -
-
-
{$icon}
-
- {/foreach}
- {foreach $thinkIcons??[] as $icon}
- -
-
-
{$icon}
-
- {/foreach}
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/admin/view/api/upload/image.html b/app/admin/view/api/upload/image.html
deleted file mode 100644
index 9bc6456ff..000000000
--- a/app/admin/view/api/upload/image.html
+++ /dev/null
@@ -1,161 +0,0 @@
-
-
-
-
-
-
-
- {{x.xext.toUpperCase()}}
- {{formatSize(x.size)}}
- {if auth('admin/file/remove')}
-
- {/if}
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/admin/view/auth/form.html b/app/admin/view/auth/form.html
deleted file mode 100644
index b1961ba06..000000000
--- a/app/admin/view/auth/form.html
+++ /dev/null
@@ -1,143 +0,0 @@
-{extend name='main'}
-
-{block name="button"}
-
-
-{/block}
-
-{block name="content"}
-
-{/block}
-
-{block name="script"}
-
-{/block}
-
-{block name="style"}
-
-{/block}
\ No newline at end of file
diff --git a/app/admin/view/auth/index.html b/app/admin/view/auth/index.html
deleted file mode 100644
index ee0995c89..000000000
--- a/app/admin/view/auth/index.html
+++ /dev/null
@@ -1,76 +0,0 @@
-{extend name='table'}
-
-{block name="button"}
-
-
-
-
-
-
-
-{/block}
-
-{block name="content"}
-
- {include file='auth/index_search'}
-
-
-{/block}
-
-{block name='script'}
-
-
-
-
-
-
-
-
-
-
-{/block}
\ No newline at end of file
diff --git a/app/admin/view/base/form.html b/app/admin/view/base/form.html
deleted file mode 100644
index 59b60da72..000000000
--- a/app/admin/view/base/form.html
+++ /dev/null
@@ -1,68 +0,0 @@
-
\ No newline at end of file
diff --git a/app/admin/view/base/index.html b/app/admin/view/base/index.html
deleted file mode 100644
index 3e90f79d1..000000000
--- a/app/admin/view/base/index.html
+++ /dev/null
@@ -1,86 +0,0 @@
-{extend name='table'}
-
-{block name="button"}
-
-
-
-
-
-
-
-{/block}
-
-{block name="content"}
-
-
- {foreach $types as $t}{if isset($type) and $type eq $t}
- - {$t}
- {else}
- - {$t}
- {/if}{/foreach}
-
-
- {include file='base/index_search'}
-
-
-
-{/block}
-
-{block name='script'}
-
-
-
-
-
-
-
-
-
-
-{/block}
\ No newline at end of file
diff --git a/app/admin/view/base/index_search.html b/app/admin/view/base/index_search.html
deleted file mode 100644
index 9ea566ebf..000000000
--- a/app/admin/view/base/index_search.html
+++ /dev/null
@@ -1,42 +0,0 @@
-
\ No newline at end of file
diff --git a/app/admin/view/config/index.html b/app/admin/view/config/index.html
deleted file mode 100644
index 3b543f34e..000000000
--- a/app/admin/view/config/index.html
+++ /dev/null
@@ -1,234 +0,0 @@
-{extend name="main"}
-
-{block name="button"}
-
-{:lang('清理无效配置')}
-
-
-
-{:lang('修改系统参数')}
-
-{/block}
-
-{block name="content"}
-
-
-
-
-
-
-
{:lang('开发模式')}:{:lang('开发人员或在功能调试时使用,系统异常时会显示详细的错误信息,同时还会记录操作日志及数据库 SQL 语句信息。')}
-
{:lang('生产模式')}:{:lang('项目正式部署上线后使用,系统异常时统一显示 “%s”,只记录重要的异常日志信息,强烈推荐上线后使用此模式。',[config('app.error_message')])}
-
-
-
-
-
-
-
-
- {if !in_array(sysconf('base.editor'),['ckeditor4','ckeditor5','wangEditor','auto'])}{php}sysconf('base.editor','ckeditor4');{/php}{/if}
- {foreach ['ckeditor4'=>'CKEditor4','ckeditor5'=>'CKEditor5','wangEditor'=>'wangEditor','auto'=>lang('自适应模式')] as $k => $v}{if sysconf('base.editor') eq $k}
- {if auth('storage')}
{$v}{else}
{$v}{/if}
- {else}
- {if auth('storage')}
{$v}{else}
{$v}{/if}
- {/if}{/foreach}
-
-
-
CKEditor4:{:lang('旧版本编辑器,对浏览器兼容较好,但内容编辑体验稍有不足。')}
-
CKEditor5:{:lang('新版本编辑器,只支持新特性浏览器,对内容编辑体验较好,推荐使用。')}
-
wangEditor:{:lang('国产优质富文本编辑器,对于小程序及App内容支持会更友好,推荐使用。')}
-
{:lang('自适应模式')}:{:lang('优先使用新版本编辑器,若浏览器不支持新版本时自动降级为旧版本编辑器。')}
-
-
-
-
-
-
-
-
- {if !sysconf('storage.type')}{php}sysconf('storage.type','local');{/php}{/if}
- {if !sysconf('storage.link_type')}{php}sysconf('storage.link_type','none');{/php}{/if}
- {if !sysconf('storage.name_type')}{php}sysconf('storage.name_type','xmd5');{/php}{/if}
- {if !sysconf('storage.allow_exts')}{php}sysconf('storage.allow_exts','doc,gif,ico,jpg,mp3,mp4,p12,pem,png,rar,xls,xlsx');{/php}{/if}
- {if !sysconf('storage.local_http_protocol')}{php}sysconf('storage.local_http_protocol','follow');{/php}{/if}
-
-
- {foreach $files as $k => $v}{if sysconf('storage.type') eq $k}
- {if auth('storage')}
{$v}{else}
{$v}{/if}
- {else}
- {if auth('storage')}
{$v}{else}
{$v}{/if}
- {/if}{/foreach}
-
-
-
{:lang('本地服务器存储')}:{:lang('文件上传到本地服务器的 `static/upload` 目录,不支持大文件上传,占用服务器磁盘空间,访问时消耗服务器带宽流量。')}
-
{:lang('自建Alist存储')}:{:lang('文件上传到 Alist 存储的服务器或云存储空间,根据服务配置可支持大文件上传,不占用本身服务器空间及服务器带宽流量。')}
-
{:lang('七牛云对象存储')}:{:lang('文件上传到七牛云存储空间,支持大文件上传,不占用服务器空间及服务器带宽流量,支持 CDN 加速访问,访问量大时推荐使用。')}
-
{:lang('又拍云USS存储')}:{:lang('文件上传到又拍云 USS 存储空间,支持大文件上传,不占用服务器空间及服务器带宽流量,支持 CDN 加速访问,访问量大时推荐使用。')}
-
{:lang('阿里云OSS存储')}:{:lang('文件上传到阿里云 OSS 存储空间,支持大文件上传,不占用服务器空间及服务器带宽流量,支持 CDN 加速访问,访问量大时推荐使用。')}
-
{:lang('腾讯云COS存储')}:{:lang('文件上传到腾讯云 COS 存储空间,支持大文件上传,不占用服务器空间及服务器带宽流量,支持 CDN 加速访问,访问量大时推荐使用。')}
-
-
-
-
-
-
-
-
-
-{notempty name='plugins'}
-
-
-
-
-
-
- | {:lang('应用名称')} |
- {:lang('插件名称')} |
- {:lang('插件包名')} |
- {:lang('插件版本')} |
- {:lang('授权协议')} |
-
-
-
- {foreach $plugins as $key=>$plugin}
-
- | {$key} |
- {$plugin.name|lang} |
-
- {if empty($plugin.install.document)}{$plugin.package}
- {else}{$plugin.package}{/if}
- |
- {$plugin.install.version|default='unknow'} |
-
- {if empty($plugin.install.license)} -
- {elseif is_array($plugin.install.license)}{$plugin.install.license|join='、',###}
- {else}{$plugin.install.license|default='-'}{/if}
- |
-
- {/foreach}
-
-
-
-
-{/notempty}
-
-{/block}
\ No newline at end of file
diff --git a/app/admin/view/config/storage-0.html b/app/admin/view/config/storage-0.html
deleted file mode 100644
index b74b121ab..000000000
--- a/app/admin/view/config/storage-0.html
+++ /dev/null
@@ -1,49 +0,0 @@
-
-
-
-
-
\ No newline at end of file
diff --git a/app/admin/view/config/storage-alioss.html b/app/admin/view/config/storage-alioss.html
deleted file mode 100644
index 63b7efa80..000000000
--- a/app/admin/view/config/storage-alioss.html
+++ /dev/null
@@ -1,98 +0,0 @@
-
\ No newline at end of file
diff --git a/app/admin/view/config/storage-alist.html b/app/admin/view/config/storage-alist.html
deleted file mode 100644
index 33982d283..000000000
--- a/app/admin/view/config/storage-alist.html
+++ /dev/null
@@ -1,81 +0,0 @@
-
\ No newline at end of file
diff --git a/app/admin/view/config/storage-local.html b/app/admin/view/config/storage-local.html
deleted file mode 100644
index 1b1386f64..000000000
--- a/app/admin/view/config/storage-local.html
+++ /dev/null
@@ -1,51 +0,0 @@
-
\ No newline at end of file
diff --git a/app/admin/view/config/storage-qiniu.html b/app/admin/view/config/storage-qiniu.html
deleted file mode 100644
index 9bc4aaf03..000000000
--- a/app/admin/view/config/storage-qiniu.html
+++ /dev/null
@@ -1,97 +0,0 @@
-
\ No newline at end of file
diff --git a/app/admin/view/config/storage-txcos.html b/app/admin/view/config/storage-txcos.html
deleted file mode 100644
index 06d8d72ba..000000000
--- a/app/admin/view/config/storage-txcos.html
+++ /dev/null
@@ -1,96 +0,0 @@
-
\ No newline at end of file
diff --git a/app/admin/view/config/storage-upyun.html b/app/admin/view/config/storage-upyun.html
deleted file mode 100644
index 71043fe12..000000000
--- a/app/admin/view/config/storage-upyun.html
+++ /dev/null
@@ -1,81 +0,0 @@
-
\ No newline at end of file
diff --git a/app/admin/view/config/system.html b/app/admin/view/config/system.html
deleted file mode 100644
index bdc7385a6..000000000
--- a/app/admin/view/config/system.html
+++ /dev/null
@@ -1,129 +0,0 @@
-
-
-
\ No newline at end of file
diff --git a/app/admin/view/file/form.html b/app/admin/view/file/form.html
deleted file mode 100644
index afdcecb46..000000000
--- a/app/admin/view/file/form.html
+++ /dev/null
@@ -1,40 +0,0 @@
-
\ No newline at end of file
diff --git a/app/admin/view/file/index.html b/app/admin/view/file/index.html
deleted file mode 100644
index 349c35a58..000000000
--- a/app/admin/view/file/index.html
+++ /dev/null
@@ -1,64 +0,0 @@
-{extend name='table'}
-
-{block name="button"}
-
-{:lang('清理重复')}
-
-
-{:lang('批量删除')}
-
-{/block}
-
-{block name="content"}
-
- {include file='file/index_search'}
-
-
-
-
-
-{/block}
diff --git a/app/admin/view/file/index_search.html b/app/admin/view/file/index_search.html
deleted file mode 100644
index 9cf0a9eea..000000000
--- a/app/admin/view/file/index_search.html
+++ /dev/null
@@ -1,58 +0,0 @@
-
\ No newline at end of file
diff --git a/app/admin/view/full.html b/app/admin/view/full.html
deleted file mode 100644
index 75a4f243c..000000000
--- a/app/admin/view/full.html
+++ /dev/null
@@ -1,35 +0,0 @@
-
-
-
- {block name="title"}{$title|default=''}{if !empty($title)} · {/if}{:sysconf('site_name')}{/block}
-
-
-
-
-
-
-
-
-
-
-
- {if file_exists(syspath("public/static/extra/icon/iconfont.css"))}
-
- {/if}
- {block name="style"}{/block}
-
-
-
-
-{block name='body'}
-
-
{block name='content'}{/block}
-
-{/block}
-
-
-
-
-{block name='script'}{/block}
-
-
\ No newline at end of file
diff --git a/app/admin/view/index/index-top.html b/app/admin/view/index/index-top.html
deleted file mode 100644
index 2f4f35cf9..000000000
--- a/app/admin/view/index/index-top.html
+++ /dev/null
@@ -1,44 +0,0 @@
-
\ No newline at end of file
diff --git a/app/admin/view/index/theme.html b/app/admin/view/index/theme.html
deleted file mode 100644
index 870d7caff..000000000
--- a/app/admin/view/index/theme.html
+++ /dev/null
@@ -1,36 +0,0 @@
-
-
-
\ No newline at end of file
diff --git a/app/admin/view/login/index.html b/app/admin/view/login/index.html
deleted file mode 100644
index c78dc64f8..000000000
--- a/app/admin/view/login/index.html
+++ /dev/null
@@ -1,57 +0,0 @@
-{extend name="index/index"}
-
-{block name='style'}
-
-
-
-{/block}
-
-{block name="body"}
-
-
-
-
-
-{/block}
-
-{block name='script'}
-
-{/block}
\ No newline at end of file
diff --git a/app/admin/view/menu/form.html b/app/admin/view/menu/form.html
deleted file mode 100644
index abb4c1a8b..000000000
--- a/app/admin/view/menu/form.html
+++ /dev/null
@@ -1,97 +0,0 @@
-
-
-
\ No newline at end of file
diff --git a/app/admin/view/menu/index.html b/app/admin/view/menu/index.html
deleted file mode 100644
index 4dd759085..000000000
--- a/app/admin/view/menu/index.html
+++ /dev/null
@@ -1,119 +0,0 @@
-{extend name='table'}
-
-{block name="button"}
-
-
-
-
-
-
-
-
-
-
-
-{/block}
-
-{block name="content"}
-
-
- {foreach ['index'=>lang('全部菜单'),'recycle'=>lang('回 收 站')] as $k=>$v}
- {if isset($type) and $type eq $k and isset($pid) and $pid == ''}
- - {$v}
- {else}
- - {$v}
- {/if}{/foreach}
-
-
-
- - {$v.title}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-{/block}
\ No newline at end of file
diff --git a/app/admin/view/oplog/index.html b/app/admin/view/oplog/index.html
deleted file mode 100644
index f14488bbd..000000000
--- a/app/admin/view/oplog/index.html
+++ /dev/null
@@ -1,47 +0,0 @@
-{extend name='table'}
-
-{block name="button"}
-
-
-
-
-
-
-
-{/block}
-
-{block name="content"}
-
- {include file='oplog/index_search'}
-
-
-{/block}
-
-{block name='script'}
-
-
-
-{/block}
\ No newline at end of file
diff --git a/app/admin/view/oplog/index_search.html b/app/admin/view/oplog/index_search.html
deleted file mode 100644
index c955ed81a..000000000
--- a/app/admin/view/oplog/index_search.html
+++ /dev/null
@@ -1,87 +0,0 @@
-
-
-
\ No newline at end of file
diff --git a/app/admin/view/queue/index.html b/app/admin/view/queue/index.html
deleted file mode 100644
index bd1c0f5fb..000000000
--- a/app/admin/view/queue/index.html
+++ /dev/null
@@ -1,131 +0,0 @@
-{extend name='table'}
-
-{block name="button"}
-
-{if isset($super) and $super}
-
-{:lang('优化数据库')}
-
-{if isset($iswin) and ($iswin or php_sapi_name() eq 'cli')}
-
-
-{/if}
-
-{if auth("clean")}
-
-{/if}
-
-{/if}
-
-{if auth("remove")}
-
-{/if}
-
-{/block}
-
-{block name="content"}
-
-
-
- {:lang('服务状态')}:{:lang('检查中')}
-
-
-
-
- {:lang('任务统计')}:{:lang('待处理 %s 个任务,处理中 %s 个任务,已完成 %s 个任务,已失败 %s 个任务。', [
- '..',
- '..',
- '..',
- '..'
- ])}
-
-
-
-
- {include file='queue/index_search'}
-
-
-{/block}
-
-{block name='script'}
-
-
-
-{/block}
diff --git a/app/admin/view/queue/index_search.html b/app/admin/view/queue/index_search.html
deleted file mode 100644
index bd79d6bbb..000000000
--- a/app/admin/view/queue/index_search.html
+++ /dev/null
@@ -1,45 +0,0 @@
-
\ No newline at end of file
diff --git a/app/admin/view/user/form.html b/app/admin/view/user/form.html
deleted file mode 100644
index 54936e871..000000000
--- a/app/admin/view/user/form.html
+++ /dev/null
@@ -1,114 +0,0 @@
-
\ No newline at end of file
diff --git a/app/admin/view/user/index_search.html b/app/admin/view/user/index_search.html
deleted file mode 100644
index 793f1e00a..000000000
--- a/app/admin/view/user/index_search.html
+++ /dev/null
@@ -1,44 +0,0 @@
-
\ No newline at end of file
diff --git a/app/admin/view/user/pass.html b/app/admin/view/user/pass.html
deleted file mode 100644
index d281ad904..000000000
--- a/app/admin/view/user/pass.html
+++ /dev/null
@@ -1,40 +0,0 @@
-
\ No newline at end of file
diff --git a/app/wechat/Service.php b/app/wechat/Service.php
deleted file mode 100644
index 272671f81..000000000
--- a/app/wechat/Service.php
+++ /dev/null
@@ -1,109 +0,0 @@
-commands([Fans::class, Auto::class, Clear::class]);
-
- // 注册粉丝关注事件
- $this->app->event->listen('WechatFansSubscribe', static function ($openid) {
- AutoService::register($openid);
- });
-
- // 注册支付通知路由
- $this->app->route->any('/plugin-wxpay-notify/:vars', static function (Request $request) {
- try {
- $data = json_decode(CodeExtend::deSafe64($request->param('vars')), true);
- return PaymentService::notify($data);
- } catch (\Error|\Exception $exception) {
- return "Error: {$exception->getMessage()}";
- }
- });
- }
-
- /**
- * 增加微信配置.
- * @return array[]
- */
- public static function menu(): array
- {
- $code = app(static::class)->appCode;
- // 设置插件菜单
- return [
- [
- 'name' => '微信管理',
- 'subs' => [
- ['name' => '微信接口配置', 'icon' => 'layui-icon layui-icon-set', 'node' => "{$code}/config/options"],
- ['name' => '微信支付配置', 'icon' => 'layui-icon layui-icon-rmb', 'node' => "{$code}/config/payment"],
- ],
- ],
- [
- 'name' => '微信定制',
- 'subs' => [
- ['name' => '微信粉丝管理', 'icon' => 'layui-icon layui-icon-username', 'node' => "{$code}/fans/index"],
- ['name' => '微信图文管理', 'icon' => 'layui-icon layui-icon-template-1', 'node' => "{$code}/news/index"],
- ['name' => '微信菜单配置', 'icon' => 'layui-icon layui-icon-cellphone', 'node' => "{$code}/menu/index"],
- ['name' => '回复规则管理', 'icon' => 'layui-icon layui-icon-engine', 'node' => "{$code}/keys/index"],
- ['name' => '关注自动回复', 'icon' => 'layui-icon layui-icon-release', 'node' => "{$code}/auto/index"],
- ],
- ],
- [
- 'name' => '微信支付',
- 'subs' => [
- ['name' => '微信支付行为', 'icon' => 'layui-icon layui-icon-rmb', 'node' => "{$code}/payment.record/index"],
- ['name' => '微信退款管理', 'icon' => 'layui-icon layui-icon-engine', 'node' => "{$code}/payment.refund/index"],
- ],
- ],
- ];
- }
-}
diff --git a/app/wechat/model/WechatAuto.php b/app/wechat/model/WechatAuto.php
deleted file mode 100644
index 67286adfc..000000000
--- a/app/wechat/model/WechatAuto.php
+++ /dev/null
@@ -1,59 +0,0 @@
-hasOne(WechatFans::class, 'openid', 'openid');
- }
-
- /**
- * 绑定用户粉丝数据.
- */
- public function bindFans(): HasOne
- {
- return $this->fans()->bind([
- 'fans_headimg' => 'headimgurl',
- 'fans_nickname' => 'nickname',
- ]);
- }
-
- /**
- * 格式化输出时间格式.
- * @param mixed $value
- */
- public function getCreateTimeAttr($value): string
- {
- return $value ? format_datetime($value) : '';
- }
-
- /**
- * 格式化输出时间格式.
- * @param mixed $value
- */
- public function getUpdateTimeAttr($value): string
- {
- return $value ? format_datetime($value) : '';
- }
-
- /**
- * 格式化输出时间格式.
- * @param mixed $value
- */
- public function getPaymentTimeAttr($value): string
- {
- return $value ? format_datetime($value) : '';
- }
-
- /**
- * 转换数据类型.
- */
- public function toArray(): array
- {
- $data = parent::toArray();
- $data['type_name'] = PaymentService::tradeTypeNames[$data['type']] ?? $data['type'];
- return $data;
- }
-}
diff --git a/app/wechat/model/WechatPaymentRefund.php b/app/wechat/model/WechatPaymentRefund.php
deleted file mode 100644
index c15c343d6..000000000
--- a/app/wechat/model/WechatPaymentRefund.php
+++ /dev/null
@@ -1,81 +0,0 @@
-hasOne(WechatPaymentRecord::class, 'code', 'record_code')->with('bindfans');
- }
-
- /**
- * 格式化输出时间格式.
- * @param mixed $value
- */
- public function getCreateTimeAttr($value): string
- {
- return $value ? format_datetime($value) : '';
- }
-
- /**
- * 格式化输出时间格式.
- * @param mixed $value
- */
- public function getUpdateTimeAttr($value): string
- {
- return $value ? format_datetime($value) : '';
- }
-
- /**
- * 格式化输出时间格式.
- * @param mixed $value
- */
- public function getRefundTimeAttr($value): string
- {
- return $value ? format_datetime($value) : '';
- }
-}
diff --git a/app/wechat/service/WechatService.php b/app/wechat/service/WechatService.php
deleted file mode 100644
index 915095109..000000000
--- a/app/wechat/service/WechatService.php
+++ /dev/null
@@ -1,367 +0,0 @@
-
-// +----------------------------------------------------------------------
-// | 官方网站: https://thinkadmin.top
-// +----------------------------------------------------------------------
-// | 开源协议 ( https://mit-license.org )
-// | 免责声明 ( https://thinkadmin.top/disclaimer )
-// +----------------------------------------------------------------------
-// || github 代码仓库:https://github.com/zoujingli/think-plugs-wechat
-// +----------------------------------------------------------------------
-
-namespace app\wechat\service;
-
-use think\admin\Exception;
-use think\admin\extend\JsonRpcClient;
-use think\admin\Library;
-use think\admin\Service;
-use think\admin\storage\LocalStorage;
-use think\exception\HttpResponseException;
-use think\Response;
-use WeChat\Exceptions\InvalidResponseException;
-use WeChat\Exceptions\LocalCacheException;
-
-/**
- * 微信接口调度服务
- * @class WechatService
- *
- * @method \WeChat\Card WeChatCard() static 微信卡券管理
- * @method \WeChat\Custom WeChatCustom() static 微信客服消息
- * @method \WeChat\Limit WeChatLimit() static 接口调用频次限制
- * @method \WeChat\Media WeChatMedia() static 微信素材管理
- * @method \WeChat\Draft WeChatDraft() static 微信草稿箱管理
- * @method \WeChat\Menu WeChatMenu() static 微信菜单管理
- * @method \WeChat\Oauth WeChatOauth() static 微信网页授权
- * @method \WeChat\Pay WeChatPay() static 微信支付商户
- * @method \WeChat\Product WeChatProduct() static 微信商店管理
- * @method \WeChat\Qrcode WeChatQrcode() static 微信二维码管理
- * @method \WeChat\Receive WeChatReceive() static 微信推送管理
- * @method \WeChat\Scan WeChatScan() static 微信扫一扫接入管理
- * @method \WeChat\Script WeChatScript() static 微信前端支持
- * @method \WeChat\Shake WeChatShake() static 微信揺一揺周边
- * @method \WeChat\Tags WeChatTags() static 微信用户标签管理
- * @method \WeChat\Template WeChatTemplate() static 微信模板消息
- * @method \WeChat\User WeChatUser() static 微信粉丝管理
- * @method \WeChat\Wifi WeChatWifi() static 微信门店WIFI管理
- * @method \WeChat\Freepublish WeChatFreepublish() static 发布能力
- *
- * ----- WeMini -----
- * @method \WeMini\Account WeMiniAccount() static 小程序账号管理
- * @method \WeMini\Basic WeMiniBasic() static 小程序基础信息设置
- * @method \WeMini\Code WeMiniCode() static 小程序代码管理
- * @method \WeMini\Domain WeMiniDomain() static 小程序域名管理
- * @method \WeMini\Tester WeMinitester() static 小程序成员管理
- * @method \WeMini\User WeMiniUser() static 小程序帐号管理
- * --------------------
- * @method \WeMini\Crypt WeMiniCrypt() static 小程序数据加密处理
- * @method \WeMini\Delivery WeMiniDelivery() static 小程序即时配送
- * @method \WeMini\Guide WeMiniGuide() static 小程序导购助手
- * @method \WeMini\Image WeMiniImage() static 小程序图像处理
- * @method \WeMini\Live WeMiniLive() static 小程序直播接口
- * @method \WeMini\Logistics WeMiniLogistics() static 小程序物流助手
- * @method \WeMini\Newtmpl WeMiniNewtmpl() static 公众号小程序订阅消息支持
- * @method \WeMini\Message WeMiniMessage() static 小程序动态消息
- * @method \WeMini\Operation WeMiniOperation() static 小程序运维中心
- * @method \WeMini\Ocr WeMiniOcr() static 小程序ORC服务
- * @method \WeMini\Plugs WeMiniPlugs() static 小程序插件管理
- * @method \WeMini\Poi WeMiniPoi() static 小程序地址管理
- * @method \WeMini\Qrcode WeMiniQrcode() static 小程序二维码管理
- * @method \WeMini\Security WeMiniSecurity() static 小程序内容安全
- * @method \WeMini\Soter WeMiniSoter() static 小程序生物认证
- * @method \WeMini\Template WeMiniTemplate() static 小程序模板消息支持
- * @method \WeMini\Total WeMiniTotal() static 小程序数据接口
- * @method \WeMini\Scheme WeMiniScheme() static 小程序URL-Scheme
- * @method \WeMini\Search WeMiniSearch() static 小程序搜索
- * @method \WeMini\Shipping WeMiniShipping() static 小程序发货信息管理服务
- *
- * ----- WePay -----
- * @method \WePay\Bill WePayBill() static 微信商户账单及评论
- * @method \WePay\Order WePayOrder() static 微信商户订单
- * @method \WePay\Refund WePayRefund() static 微信商户退款
- * @method \WePay\Coupon WePayCoupon() static 微信商户代金券
- * @method \WePay\Custom WePayCustom() static 微信扩展上报海关
- * @method \WePay\ProfitSharing WePayProfitSharing() static 微信分账
- * @method \WePay\Redpack WePayRedpack() static 微信红包支持
- * @method \WePay\Transfers WePayTransfers() static 微信商户打款到零钱
- * @method \WePay\TransfersBank WePayTransfersBank() static 微信商户打款到银行卡
- *
- * ----- WePayV3 -----
- * @method \WePayV3\Order WePayV3Order() static 直连商户|订单支付接口
- * @method \WePayV3\Transfers WePayV3Transfers() static 微信商家转账到零钱
- * @method \WePayV3\ProfitSharing WePayV3ProfitSharing() static 微信商户分账
- *
- * ----- WeOpen -----
- * @method \WeOpen\Login WeOpenLogin() static 第三方微信登录
- * @method \WeOpen\Service WeOpenService() static 第三方服务
- *
- * ----- ThinkService -----
- * @method mixed ThinkServiceConfig() static 平台服务配置
- */
-class WechatService extends Service
-{
- /**
- * 静态初始化对象
- * @return mixed
- * @throws Exception
- */
- public static function __callStatic(string $name, array $arguments)
- {
- [$type, $base, $class] = static::parseName($name);
- if ("{$type}{$base}" !== $name) {
- throw new Exception("抱歉,实例 {$name} 不符合规则!");
- }
- if (sysconf('wechat.type') === 'api' || in_array($type, ['WePay', 'WePayV3'])) {
- if (class_exists($class)) {
- return new $class($type === 'WeMini' ? static::getWxconf() : static::getConfig());
- }
- throw new Exception("抱歉,接口模式无法实例 {$class} 对象!");
- } else {
- [$appid, $appkey] = [sysconf('wechat.thr_appid'), sysconf('wechat.thr_appkey')];
- $data = ['class' => $name, 'appid' => $appid, 'time' => time(), 'nostr' => uniqid()];
- $data['sign'] = md5("{$data['class']}#{$appid}#{$appkey}#{$data['time']}#{$data['nostr']}");
- // 创建远程连接,默认使用 JSON-RPC 方式调用接口
- $token = enbase64url(json_encode($data, JSON_UNESCAPED_UNICODE));
- $jsonrpc = sysconf('wechat.service_jsonrpc|raw') ?: 'https://open.cuci.cc/plugin-wechat-service/api.client/jsonrpc?token=TOKEN';
- return new JsonRpcClient(str_replace('token=TOKEN', "token={$token}", $jsonrpc));
- }
- }
-
- /**
- * 获取当前微信APPID.
- * @throws Exception
- */
- public static function getAppid(): string
- {
- if (static::getType() === 'api') {
- return sysconf('wechat.appid');
- }
- return sysconf('wechat.thr_appid');
- }
-
- /**
- * 获取接口授权模式.
- * @throws Exception
- */
- public static function getType(): string
- {
- $type = strtolower(sysconf('wechat.type'));
- if (in_array($type, ['api', 'thr'])) {
- return $type;
- }
- throw new Exception('请在后台配置微信对接授权模式');
- }
-
- /**
- * 获取公众号配置参数.
- * @param bool $ispay 获取支付参数
- * @throws Exception
- */
- public static function getConfig(bool $ispay = false): array
- {
- $config = [
- 'appid' => static::getAppid(),
- 'token' => sysconf('wechat.token'),
- 'appsecret' => sysconf('wechat.appsecret'),
- 'encodingaeskey' => sysconf('wechat.encodingaeskey'),
- 'cache_path' => syspath('runtime/wechat'),
- ];
- return $ispay ? static::withWxpayCert($config) : $config;
- }
-
- /**
- * 获取小程序配置参数.
- * @param bool $ispay 获取支付参数
- * @throws Exception
- */
- public static function getWxconf(bool $ispay = false): array
- {
- $wxapp = sysdata('plugin.wechat.wxapp');
- $config = [
- 'appid' => $wxapp['appid'] ?? '',
- 'appsecret' => $wxapp['appkey'] ?? '',
- 'cache_path' => syspath('runtime/wechat'),
- ];
- return $ispay ? static::withWxpayCert($config) : $config;
- }
-
- /**
- * 处理支付证书配置.
- * @throws Exception
- */
- public static function withWxpayCert(array $options): array
- {
- // 文本模式主要是为了解决分布式部署
- $data = sysdata('plugin.wechat.payment');
- if (empty($data['mch_id'])) {
- throw new Exception('无效的支付配置!');
- }
- $name1 = sprintf('wxpay/%s_%s_cer.pem', $data['mch_id'], md5($data['ssl_cer_text']));
- $name2 = sprintf('wxpay/%s_%s_key.pem', $data['mch_id'], md5($data['ssl_key_text']));
- $local = LocalStorage::instance();
- if ($local->has($name1, true) && $local->has($name2, true)) {
- $sslCer = $local->path($name1, true);
- $sslKey = $local->path($name2, true);
- } else {
- $sslCer = $local->set($name1, $data['ssl_cer_text'], true)['file'];
- $sslKey = $local->set($name2, $data['ssl_key_text'], true)['file'];
- }
- $options['mch_id'] = $data['mch_id'];
- $options['mch_key'] = $data['mch_key'];
- $options['mch_v3_key'] = $data['mch_v3_key'];
- $options['ssl_cer'] = $sslCer;
- $options['ssl_key'] = $sslKey;
- $options['cert_public'] = $sslCer;
- $options['cert_private'] = $sslKey;
- $options['mp_cert_serial'] = $data['mch_pay_sid'] ?? '';
- $options['mp_cert_content'] = $data['ssl_pay_text'] ?? '';
- return $options;
- }
-
- /**
- * 获取会话名称.
- */
- public static function getSsid(): string
- {
- $conf = Library::$sapp->session->getConfig();
- $ssid = Library::$sapp->request->get($conf['name'] ?? 'ssid');
- return empty($ssid) ? Library::$sapp->session->getId() : $ssid;
- }
-
- /**
- * 通过网页授权获取粉丝信息.
- * @param string $source 回跳URL地址
- * @param int $isfull 获取资料模式
- * @param bool $redirect 是否直接跳转
- * @throws InvalidResponseException
- * @throws LocalCacheException
- * @throws Exception
- */
- public static function getWebOauthInfo(string $source, int $isfull = 0, bool $redirect = true): array
- {
- [$ssid, $appid] = [static::getSsid(), static::getAppid()];
- $openid = Library::$sapp->cache->get("{$ssid}_openid");
- $userinfo = Library::$sapp->cache->get("{$ssid}_fansinfo");
- if ((empty($isfull) && !empty($openid)) || (!empty($isfull) && !empty($openid) && !empty($userinfo))) {
- empty($userinfo) || FansService::set($userinfo, $appid);
- return ['openid' => $openid, 'fansinfo' => $userinfo];
- }
- if (static::getType() === 'api') {
- // 解析 GET 参数
- parse_str(parse_url($source, PHP_URL_QUERY), $params);
- $getVars = [
- 'code' => $params['code'] ?? input('code', ''),
- 'rcode' => $params['rcode'] ?? input('rcode', ''),
- 'state' => $params['state'] ?? input('state', ''),
- ];
- $wechat = static::WeChatOauth();
- if ($getVars['state'] !== $appid || empty($getVars['code'])) {
- $params['rcode'] = enbase64url($source);
- $location = strstr("{$source}?", '?', true) . '?' . http_build_query($params);
- $oauthurl = $wechat->getOauthRedirect($location, $appid, $isfull ? 'snsapi_userinfo' : 'snsapi_base');
- throw new HttpResponseException(static::createRedirect($oauthurl, $redirect));
- }
- if (($token = $wechat->getOauthAccessToken($getVars['code'])) && isset($token['openid'])) {
- $openid = $token['openid'];
- // 如果是虚拟账号,不保存会话信息,下次重新授权
- if (empty($token['is_snapshotuser'])) {
- Library::$sapp->cache->set("{$ssid}_openid", $openid, 3600);
- }
- if ($isfull && isset($token['access_token'])) {
- $userinfo = $wechat->getUserInfo($token['access_token'], $openid);
- // 如果是虚拟账号,不保存会话信息,下次重新授权
- if (empty($token['is_snapshotuser'])) {
- $userinfo['is_snapshotuser'] = 0;
- // 缓存用户信息
- Library::$sapp->cache->set("{$ssid}_fansinfo", $userinfo, 3600);
- empty($userinfo) || FansService::set($userinfo, $appid);
- } else {
- $userinfo['is_snapshotuser'] = 1;
- }
- }
- }
- if ($getVars['rcode']) {
- throw new HttpResponseException(static::createRedirect(debase64url($getVars['rcode']), $redirect));
- }
- if ((empty($isfull) && !empty($openid)) || (!empty($isfull) && !empty($openid) && !empty($userinfo))) {
- return ['openid' => $openid, 'fansinfo' => $userinfo];
- }
- throw new Exception('Query params [rcode] not find.');
- } else {
- $result = static::ThinkServiceConfig()->oauth(self::getSsid(), $source, $isfull);
- [$openid, $userinfo] = [$result['openid'] ?? '', $result['fans'] ?? []];
- // 如果是虚拟账号,不保存会话信息,下次重新授权
- if (empty($result['token']['is_snapshotuser'])) {
- Library::$sapp->cache->set("{$ssid}_openid", $openid, 3600);
- Library::$sapp->cache->set("{$ssid}_fansinfo", $userinfo, 3600);
- }
- if ((empty($isfull) && !empty($openid)) || (!empty($isfull) && !empty($openid) && !empty($userinfo))) {
- empty($result['token']['is_snapshotuser']) && empty($userinfo) || FansService::set($userinfo, $appid);
- return ['openid' => $openid, 'fansinfo' => $userinfo];
- }
- throw new HttpResponseException(static::createRedirect($result['url'], $redirect));
- }
- }
-
- /**
- * 获取微信网页JSSDK签名参数.
- * @param null|string $location 签名地址
- * @throws InvalidResponseException
- * @throws LocalCacheException
- * @throws Exception
- */
- public static function getWebJssdkSign(?string $location = null): array
- {
- $location = $location ?: Library::$sapp->request->url(true);
- if (static::getType() === 'api') {
- return static::WeChatScript()->getJsSign($location);
- }
- return static::ThinkServiceConfig()->jsSign($location);
- }
-
- /**
- * 解析调用对象名称.
- */
- private static function parseName(string $name): array
- {
- foreach (['WeChat', 'WeMini', 'WeOpen', 'WePayV3', 'WePay', 'ThinkService'] as $type) {
- if (strpos($name, $type) === 0) {
- [, $base] = explode($type, $name);
- return [$type, $base, "\\{$type}\\{$base}"];
- }
- }
- return ['-', '-', $name];
- }
-
- /**
- * 网页授权链接跳转.
- * @param string $location 跳转链接
- * @param bool $redirect 强制跳转
- */
- private static function createRedirect(string $location, bool $redirect = true): Response
- {
- return $redirect ? redirect($location) : response(join(";\n", [
- sprintf("sessionStorage.setItem('wechat.session','%s')", self::getSsid()),
- sprintf("location.replace('%s')", $location), '',
- ]));
- }
-}
diff --git a/app/wechat/view/config/options.html b/app/wechat/view/config/options.html
deleted file mode 100644
index 46beaa849..000000000
--- a/app/wechat/view/config/options.html
+++ /dev/null
@@ -1,53 +0,0 @@
-{extend name="main"}
-
-{block name="button"}
-
-
-
-
-
-
-
-
-
-{/block}
-
-{block name="content"}
-
-
-
-
{include file='config/options_form_api'}
-
{include file='config/options_form_thr'}
-
-
-{/block}
-
-{block name='script'}
-
-{/block}
diff --git a/app/wechat/view/config/options_test.html b/app/wechat/view/config/options_test.html
deleted file mode 100644
index 6298de1ff..000000000
--- a/app/wechat/view/config/options_test.html
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
-
-
})
-
OAUTH 网页授权
-
-
-
})
-
JSSDK 接口签名
-
-
-
-
-
-
diff --git a/app/wechat/view/config/payment_test.html b/app/wechat/view/config/payment_test.html
deleted file mode 100644
index 18fdd2c8d..000000000
--- a/app/wechat/view/config/payment_test.html
+++ /dev/null
@@ -1,40 +0,0 @@
-
-
-
-
-
-
})
-
微信 JSAPI 支付
-
-
-
})
-
微信扫码支付①
-
-
-
})
-
微信扫码支付②
-
-
-
-
-
-
diff --git a/database/migrations/.published.json b/database/migrations/.published.json
new file mode 100644
index 000000000..bcce25e3f
--- /dev/null
+++ b/database/migrations/.published.json
@@ -0,0 +1,34 @@
+{
+ "20241010000005_install_account20241010.php": {
+ "source": "plugin/think-plugs-account/stc/database/20241010000005_install_account20241010.php",
+ "mtime": 1778063923
+ },
+ "20241010000006_install_payment20241010.php": {
+ "source": "plugin/think-plugs-payment/stc/database/20241010000006_install_payment20241010.php",
+ "mtime": 1778063923
+ },
+ "20241010000001_install_system20241010.php": {
+ "source": "plugin/think-plugs-system/stc/database/20241010000001_install_system20241010.php",
+ "mtime": 1778063812
+ },
+ "20241010000003_install_wechat20241010.php": {
+ "source": "plugin/think-plugs-wechat-client/stc/database/20241010000003_install_wechat20241010.php",
+ "mtime": 1778063923
+ },
+ "20241010000009_install_wechat_service20241010.php": {
+ "source": "plugin/think-plugs-wechat-service/stc/database/20241010000009_install_wechat_service20241010.php",
+ "mtime": 1778063923
+ },
+ "20241010000007_install_wemall20241010.php": {
+ "source": "plugin/think-plugs-wemall/stc/database/20241010000007_install_wemall20241010.php",
+ "mtime": 1778063923
+ },
+ "20241010000008_install_worker20241010.php": {
+ "source": "plugin/think-plugs-worker/stc/database/20241010000008_install_worker20241010.php",
+ "mtime": 1774236276
+ },
+ "20241010000010_install_wuma20241010.php": {
+ "source": "plugin/think-plugs-wuma/stc/database/20241010000010_install_wuma20241010.php",
+ "mtime": 1778063923
+ }
+}
diff --git a/plugin/think-library/.github/workflows/release.yml b/plugin/think-library/.github/workflows/release.yml
new file mode 100644
index 000000000..39d622da8
--- /dev/null
+++ b/plugin/think-library/.github/workflows/release.yml
@@ -0,0 +1,75 @@
+####### 可解析的提交前缀 ########
+# ci: 持续集成
+# fix: 修改
+# feat: 新增
+# refactor: 重构
+# docs: 文档
+# style: 样式
+# chore: 其他
+# build: 构建
+# pref: 优化
+# test: 测试
+###############################
+
+on:
+ push:
+ tags:
+ - 'v*' # 仅匹配 v* 版本标签,如 v1.0、v20.15.10
+
+name: Create Release
+permissions: write-all
+
+jobs:
+ release:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 18
+
+ - name: Install dependencies
+ run: npm install -g gen-git-log
+
+ - name: Find Last Tag
+ id: last_tag
+ run: |
+ # 获取所有标签,按版本号降序排序
+ all_tags=$(git tag --list --sort=-version:refname)
+
+ # 获取最新的标签
+ LATEST_TAG=$(echo "$all_tags" | head -n 1)
+
+ # 获取倒数第二个标签(如果有)
+ SECOND_LATEST_TAG=$(echo "$all_tags" | sed -n '2p')
+
+ # 如果没有任何标签,默认 v1.0.0
+ LATEST_TAG=${LATEST_TAG:-v1.0.0}
+ SECOND_LATEST_TAG=${SECOND_LATEST_TAG:-v1.0.0}
+
+ # 设置环境变量
+ echo "LATEST_TAG=$LATEST_TAG" >> $GITHUB_ENV
+ echo "SECOND_LATEST_TAG=$SECOND_LATEST_TAG" >> $GITHUB_ENV
+
+ - name: Generate Release Notes
+ run: |
+ rm -rf log
+ mkdir -p log
+ git-log -m tag -f -S $SECOND_LATEST_TAG -v ${LATEST_TAG#v}
+
+ - name: Create Release
+ id: create_release
+ uses: actions/create-release@v1
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ with:
+ tag_name: ${{ env.LATEST_TAG }}
+ release_name: Release ${{ env.LATEST_TAG }}
+ body_path: log/${{ env.LATEST_TAG }}.md
+ draft: false
+ prerelease: false
\ No newline at end of file
diff --git a/plugin/think-library/.gitignore b/plugin/think-library/.gitignore
new file mode 100644
index 000000000..5f92c81a4
--- /dev/null
+++ b/plugin/think-library/.gitignore
@@ -0,0 +1,8 @@
+.git
+.svn
+.idea
+*.cache
+/vendor
+/composer.lock
+!.gitignore
+!composer.json
diff --git a/plugin/think-library/.php-cs-fixer.php b/plugin/think-library/.php-cs-fixer.php
new file mode 100644
index 000000000..2e44859af
--- /dev/null
+++ b/plugin/think-library/.php-cs-fixer.php
@@ -0,0 +1,120 @@
+setRiskyAllowed(true)->setParallelConfig(new ParallelConfig(8, 24));
+$finder = Finder::create()->in(__DIR__)->exclude(['vendor', 'public', 'runtime']);
+return $config->setFinder($finder)->setUsingCache(false)->setRules([
+ '@PSR2' => true,
+ '@Symfony' => true,
+ '@DoctrineAnnotation' => true,
+ '@PhpCsFixer' => true,
+ 'header_comment' => [
+ 'comment_type' => 'PHPDoc',
+ 'header' => $header,
+ 'separate' => 'none',
+ 'location' => 'after_declare_strict',
+ ],
+ 'array_syntax' => [
+ 'syntax' => 'short',
+ ],
+ 'list_syntax' => [
+ 'syntax' => 'short',
+ ],
+ 'blank_line_before_statement' => [
+ 'statements' => [
+ 'declare',
+ ],
+ ],
+ 'general_phpdoc_annotation_remove' => [
+ 'annotations' => [
+ 'author',
+ ],
+ ],
+ 'ordered_imports' => [
+ 'imports_order' => [
+ 'class', 'function', 'const',
+ ],
+ 'sort_algorithm' => 'alpha',
+ ],
+ 'single_line_comment_style' => [
+ 'comment_types' => [
+ ],
+ ],
+ 'yoda_style' => [
+ 'always_move_variable' => false,
+ 'equal' => false,
+ 'identical' => false,
+ ],
+ 'phpdoc_align' => [
+ 'align' => 'left',
+ ],
+ 'multiline_whitespace_before_semicolons' => [
+ 'strategy' => 'no_multi_line',
+ ],
+ 'constant_case' => [
+ 'case' => 'lower',
+ ],
+ 'encoding' => true, // PHP代码必须只使用没有BOM的UTF-8
+ 'line_ending' => true, // 所有的PHP文件编码必须一致
+ 'single_quote' => true, // 简单字符串应该使用单引号代替双引号
+ 'no_empty_statement' => true, // 不应该存在空的结构体
+ 'standardize_not_equals' => true, // 使用 <> 代替 !=
+ 'blank_line_after_namespace' => true, // 命名空间之后空一行
+ 'no_empty_phpdoc' => true, // 不应该存在空的 phpdoc
+ 'no_empty_comment' => true, // 不应该存在空注释
+ 'no_singleline_whitespace_before_semicolons' => true, // 禁止在关闭分号前使用单行空格
+ 'concat_space' => ['spacing' => 'one'], // 连接字符是否需要空格,可选配置项 none:不需要 one:一个空格
+ 'no_leading_import_slash' => true, // use 语句中取消前置斜杠
+ 'cast_spaces' => ['space' => 'none'],
+ 'class_attributes_separation' => true,
+ 'combine_consecutive_unsets' => true,
+ 'declare_strict_types' => true,
+ 'lowercase_static_reference' => true,
+ 'linebreak_after_opening_tag' => true,
+ 'multiline_comment_opening_closing' => true,
+ 'no_useless_else' => true,
+ 'no_unused_imports' => true,
+ 'not_operator_with_successor_space' => false,
+ 'not_operator_with_space' => false,
+ 'ordered_class_elements' => true,
+ 'php_unit_strict' => false,
+ 'phpdoc_separation' => false,
+]);
diff --git a/plugin/think-library/composer.json b/plugin/think-library/composer.json
new file mode 100644
index 000000000..7b4a8ef74
--- /dev/null
+++ b/plugin/think-library/composer.json
@@ -0,0 +1,60 @@
+{
+ "name": "zoujingli/think-library",
+ "version": "8.0.x-dev",
+ "license": "MIT",
+ "homepage": "https://thinkadmin.top",
+ "description": "Library for ThinkAdmin",
+ "authors": [
+ {
+ "name": "Anyon",
+ "email": "zoujingli@qq.com"
+ }
+ ],
+ "support": {
+ "email": "zoujingli@qq.com",
+ "wiki": "https://thinkadmin.top",
+ "forum": "https://thinkadmin.top",
+ "source": "https://gitee.com/zoujingli/ThinkLibrary",
+ "issues": "https://gitee.com/zoujingli/ThinkLibrary/issues"
+ },
+ "require": {
+ "php": "^8.1",
+ "ext-gd": "*",
+ "ext-curl": "*",
+ "ext-json": "*",
+ "ext-zlib": "*",
+ "ext-iconv": "*",
+ "ext-openssl": "*",
+ "ext-mbstring": "*",
+ "topthink/think-orm": "^4.0",
+ "topthink/framework": "^8.1"
+ },
+ "autoload": {
+ "files": [
+ "src/common.php"
+ ],
+ "psr-4": {
+ "think\\admin\\": "src"
+ }
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.5|^10.0"
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "think\\admin\\tests\\": "tests"
+ }
+ },
+ "extra": {
+ "think": {
+ "services": [
+ "think\\admin\\Library"
+ ]
+ }
+ },
+ "prefer-stable": true,
+ "minimum-stability": "dev",
+ "config": {
+ "sort-packages": true
+ }
+}
diff --git a/plugin/think-library/license b/plugin/think-library/license
new file mode 100644
index 000000000..2aba58015
--- /dev/null
+++ b/plugin/think-library/license
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2014~2025 邹景立
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/plugin/think-library/phpunit.xml.dist b/plugin/think-library/phpunit.xml.dist
new file mode 100644
index 000000000..6ea0d19c7
--- /dev/null
+++ b/plugin/think-library/phpunit.xml.dist
@@ -0,0 +1,19 @@
+
+
+
+
+ ./tests
+
+
+
diff --git a/plugin/think-library/readme.md b/plugin/think-library/readme.md
new file mode 100644
index 000000000..e077d038a
--- /dev/null
+++ b/plugin/think-library/readme.md
@@ -0,0 +1,386 @@
+# ThinkLibrary
+
+核心基础库,为 ThinkAdmin 提供运行时基础设施。
+
+## 功能定位
+
+- 提供运行时服务、认证会话、路由适配、队列契约等核心能力
+- 定义标准控制器、模型、命令等基础类型
+- 提供 Helper 工具集(查询、表单、页面构建器)
+- 实现 JWT 令牌、CacheSession 等认证机制
+- 提供 Storage 门面和标准契约
+
+## 安装
+
+```bash
+composer require zoujingli/think-library
+```
+
+## 配置
+
+在 `composer.json` 中注册服务:
+
+```json
+{
+ "extra": {
+ "think": {
+ "services": ["think\\admin\\Library"]
+ }
+ }
+}
+```
+
+## 核心功能
+
+### 1. 运行时服务
+
+- **RuntimeService**: 运行时环境配置同步,处理 PHAR 兼容、目录初始化
+- **AppService**: 应用管理、插件发现、服务注册、配置管理
+- **NodeService**: 节点管理、权限判断、菜单节点处理
+- **QueueService**: 队列门面服务(真实实现在 Worker 插件)
+
+### 2. 认证会话
+
+- **JwtToken**: JWT 令牌生成、验证、刷新
+- **CacheSession**: 基于 Token SID 的缓存会话管理
+- **RequestTokenService**: 请求级令牌识别服务
+- **SystemContext**: 系统上下文接口(运行时实现)
+- **NullSystemContext**: 空实现上下文(未认证状态)
+
+### 3. 路由适配
+
+- **Route**: 自定义路由对象,支持插件路由注册
+- **Url**: URL 构建工具,支持插件 URL 生成
+- **MultAccess**: 多应用访问中间件,处理插件前缀切换
+
+### 4. 基础类型
+
+- **Controller**: 标准控制器基类,提供通用控制器方法
+- **Model**: 标准模型基类,扩展软删除、时间戳等能力
+- **Command**: 标准命令基类,提供命令通用方法
+- **Plugin**: 插件管理类,处理插件元数据加载
+- **Service**: 服务基类,提供基础服务方法
+- **Exception**: 框架异常类,统一异常处理
+
+### 5. Helper 工具
+
+- **QueryHelper**: 数据查询构建器,支持分页、筛选、排序
+- **FormBuilder**: 表单构建器,支持表单元素快速生成
+- **PageBuilder**: 列表页面构建器,支持表格、筛选、操作列
+- **ValidateHelper**: 数据验证器,支持规则验证、错误提示
+
+### 6. 扩展工具
+
+- **CodeToolkit**: 编码工具(加密/解密/Base64/Hash)
+- **FileTools**: 文件操作工具(目录创建、文件复制、权限管理)
+- **HttpClient**: HTTP 客户端(cURL 封装、请求构建)
+- **ArrayTree**: 数组树工具(树形结构构建、扁平化)
+- **FaviconBuilder**: 网站图标生成器
+- **ImageSliderVerify**: 图片滑块验证码
+- **JsonRpcHttpClient**: JSON-RPC HTTP 客户端
+- **JsonRpcHttpServer**: JSON-RPC HTTP 服务端
+
+## 使用示例
+
+### JWT 令牌
+
+```php
+use think\admin\service\JwtToken;
+
+// 生成令牌
+$token = JwtToken::token([
+ 'user_id' => 1,
+ 'username' => 'admin',
+ 'exp' => time() + 7200
+]);
+
+// 验证令牌
+try {
+ $data = JwtToken::verify($token);
+ // $data['user_id'], $data['username']
+} catch (\think\admin\Exception $e) {
+ // 验证失败
+}
+```
+
+### CacheSession
+
+```php
+use think\admin\service\CacheSession;
+
+// 写入会话
+CacheSession::set('key', 'value', 3600);
+
+// 读取会话
+$value = CacheSession::get('key', 'default');
+
+// 批量写入
+CacheSession::put([
+ 'key1' => 'value1',
+ 'key2' => 'value2'
+], 3600);
+
+// 删除会话
+CacheSession::delete('key');
+
+// 清空会话
+CacheSession::clear();
+```
+
+### 控制器基类
+
+```php
+success('操作成功', ['id' => 1]);
+
+ // 返回失败
+ $this->error('操作失败');
+
+ // 返回视图
+ $this->fetch('index/index', ['title' => '首页']);
+
+ // 数据验证
+ $data = $this->_vali([
+ 'username.require' => '用户名不能为空',
+ 'email.email' => '邮箱格式不正确'
+ ], 'post');
+
+ // 创建异步任务
+ $this->_queue('导出数据', 'php think export:users');
+ }
+}
+```
+
+### 查询构建器
+
+```php
+use think\admin\helper\QueryHelper;
+
+// 基础查询
+$query = QueryHelper::init(User::class)
+ ->where('status', 1)
+ ->order('id', 'DESC')
+ ->paginate(20);
+
+// 分页查询
+$page = $query->getPage();
+$list = $query->getList();
+
+// 条件筛选
+$query->addWhere($request->get('keyword'), 'username', 'like');
+$query->addFilter($request->get('status'), 'status');
+```
+
+### 表单构建器
+
+```php
+use think\admin\helper\FormBuilder;
+
+$form = FormBuilder::create();
+
+// 文本输入
+$form->text('username', '用户名')
+ ->required()
+ ->placeholder('请输入用户名');
+
+// 下拉选择
+$form->select('status', '状态')
+ ->options([1 => '正常', 0 => '禁用'])
+ ->default(1);
+
+// 日期选择
+$form->date('birthday', '生日');
+
+// 文件上传
+$form->file('avatar', '头像')
+ ->image()
+ ->size(2); // 限制 2MB
+
+// 保存数据
+if ($form->isPost()) {
+ $data = $form->getData();
+ // 保存逻辑
+}
+```
+
+## 目录结构
+
+```
+think-library/
+├── src/
+│ ├── contract/ # 标准契约接口
+│ │ ├── SystemContextInterface.php
+│ │ └── ...
+│ ├── extend/ # 扩展工具类
+│ │ ├── ArrayTree.php
+│ │ ├── CodeToolkit.php
+│ │ ├── FileTools.php
+│ │ └── HttpClient.php
+│ ├── helper/ # 构建器工具
+│ │ ├── FormBuilder.php
+│ │ ├── PageBuilder.php
+│ │ ├── QueryHelper.php
+│ │ └── ValidateHelper.php
+│ ├── middleware/ # 中间件
+│ │ └── MultAccess.php
+│ ├── model/ # 模型扩展
+│ │ └── QueryFactory.php
+│ ├── route/ # 路由相关
+│ │ ├── Route.php
+│ │ └── Url.php
+│ ├── runtime/ # 运行时上下文
+│ │ ├── RequestContext.php
+│ │ ├── RequestTokenService.php
+│ │ ├── SystemContext.php
+│ │ └── NullSystemContext.php
+│ ├── service/ # 核心服务
+│ │ ├── AppService.php
+│ │ ├── CacheSession.php
+│ │ ├── JwtToken.php
+│ │ ├── NodeService.php
+│ │ ├── QueueService.php
+│ │ └── RuntimeService.php
+│ ├── common.php # 全局函数
+│ ├── Controller.php # 标准控制器
+│ ├── Model.php # 标准模型
+│ ├── Command.php # 标准命令
+│ ├── Plugin.php # 插件管理
+│ ├── Service.php # 服务基类
+│ ├── Library.php # 服务注册类
+│ └── Exception.php # 异常类
+└── tests/ # 单元测试
+```
+
+## 全局函数
+
+ThinkLibrary 提供以下全局函数:
+
+### 全局函数
+
+ThinkLibrary 提供以下全局函数:
+
+#### 应用相关
+
+- `syspath($path)`: 获取系统根目录路径(PHAR 环境下返回包内路径)
+- `runpath($path)`: 获取运行时目录路径(PHAR 环境下返回外部可写目录)
+- `sysconf($name, $default = null)`: 读取系统配置
+- `sysvar($key, $value = null)`: 读写系统变量
+- `isOnline()`: 判断是否生产环境(非调试模式)
+
+#### URL 相关
+
+- `sysuri($node, $vars = [], $suffix = true)`: 生成系统 URL(后台页面)
+- `apiuri($node, $vars = [], $suffix = true)`: 生成 API URL(接口调用)
+- `plguri($node, $vars = [], $suffix = true)`: 生成插件工作台 URL(由 ThinkPlugsSystem 提供)
+
+#### 认证相关
+
+- `auth($node)`: 检查权限(判断当前用户是否有指定节点权限)
+- `admin_user()`: 获取当前管理员信息(已认证的系统用户)
+- `tsession($name = null, $default = null)`: 读写 Token 会话(基于 CacheSession)
+
+#### 工具函数
+
+- `xss_safe($str)`: XSS 安全过滤(过滤危险 HTML/JS 标签)
+- `str2arr($str)`: 字符串转数组(支持逗号、分号、换行分隔)
+- `arr2str($arr)`: 数组转字符串(逗号连接)
+- `data_save($dbQuery, $data, $key = 'id', $where = [])`: 数据保存(新增或更新)
+- `normalize($text)`: 文本标准化(全角转半角、统一空格等)
+
+## 依赖要求
+
+- PHP >= 8.1
+- ThinkPHP >= 8.1
+- 扩展:gd, curl, json, zlib, iconv, openssl, mbstring, fileinfo
+- 推荐:redis (用于 CacheSession 和缓存驱动)
+
+## 开发规范
+
+### 命名空间
+
+所有类使用 `think\admin\` 命名空间
+
+### 代码风格
+
+遵循 PSR-12 规范,使用 PHP-CS-Fixer 统一代码风格
+
+```bash
+# 运行代码风格修复
+vendor/bin/php-cs-fixer fix
+```
+
+### 测试规范
+
+每个核心功能都应该有对应的单元测试
+
+```bash
+# 运行 ThinkLibrary 测试
+vendor/bin/phpunit plugin/think-library/tests/
+```
+
+### 静态分析
+
+使用 PHPStan 进行静态代码分析
+
+```bash
+# 运行代码分析
+composer analyse
+```
+
+## 认证说明
+
+### JWT 令牌
+
+- 后台认证统一使用 `Authorization: Bearer `
+- JWT 有效期由 `config/app.php` 中的 `system_token_expire` 配置
+- JWT 内包含 `sid` 用于绑定 CacheSession
+- 不再使用标准 PHP Session 承载后台登录态
+
+### Token Session
+
+- 基于 `CacheSession` 实现
+- 统一入口为 `tsession()` 函数
+- 支持 file 和 redis 等多种缓存驱动
+- 临时用户态绑定到 Token 的 `sid`
+
+## PHAR 兼容性
+
+当使用 PHAR 打包运行时:
+
+- `syspath()` 返回 PHAR 包内路径(只读资源)
+- `runpath()` 返回 PHAR 外部路径(可写目录)
+- 字体、SQLite、缓存等落盘操作必须使用 `runpath()`
+- GD 的 `imagettftext()` 不支持 `phar://` 路径
+
+## 测试覆盖
+
+当前测试覆盖包括:
+
+- 代码加密解密工具测试
+- JWT 令牌生成验证测试
+- 通用函数测试
+- 架构边界测试
+- 插件依赖边界测试
+- 迁移归属测试
+- 表单/页面构建器测试
+- 请求令牌服务测试
+- 多应用访问调度测试
+
+## 许可证
+
+MIT License
+
+## 相关链接
+
+- 官网文档:https://thinkadmin.top
+- Gitee: https://gitee.com/zoujingli/ThinkLibrary
+- Github: https://github.com/zoujingli/ThinkLibrary
diff --git a/plugin/think-library/src/Builder.php b/plugin/think-library/src/Builder.php
new file mode 100644
index 000000000..e31e5eaea
--- /dev/null
+++ b/plugin/think-library/src/Builder.php
@@ -0,0 +1,71 @@
+builder = $builder;
+ }
+
+ public static function mk(string $type = 'form', string $mode = 'modal'): self
+ {
+ return new self(FormBuilder::make($type, $mode));
+ }
+
+ public function addTextInput(string $name, string $title, string $substr = '', bool $required = false, string $remark = '', ?string $pattern = null, array $attrs = []): self
+ {
+ $this->builder->addTextInput($name, $title, $substr, $required, $remark, $pattern, $attrs);
+ return $this;
+ }
+
+ public function addSubmitButton(string $name = '保存数据', string $confirm = '', array $attrs = [], string $class = ''): self
+ {
+ $this->builder->addSubmitButton($name, $confirm, $attrs, $class);
+ return $this;
+ }
+
+ public function addCancelButton(string $name = '取消编辑', string $confirm = '确定要取消编辑吗?', array $attrs = [], string $class = 'layui-btn-danger'): self
+ {
+ $this->builder->addCancelButton($name, $confirm, $attrs, $class);
+ return $this;
+ }
+
+ public function fetch(array $vars = []): mixed
+ {
+ return $this->builder->fetch($vars);
+ }
+
+ public function __call(string $name, array $arguments): mixed
+ {
+ $result = $this->builder->{$name}(...$arguments);
+ return $result === $this->builder ? $this : $result;
+ }
+}
diff --git a/plugin/think-library/src/Command.php b/plugin/think-library/src/Command.php
new file mode 100644
index 000000000..3e627af29
--- /dev/null
+++ b/plugin/think-library/src/Command.php
@@ -0,0 +1,112 @@
+queue->message($total, $count, $message, $backline);
+ return $this;
+ }
+
+ /**
+ * Command initialization.
+ */
+ protected function initialize(Input $input, Output $output): static
+ {
+ $this->queue = QueueService::instance();
+ if (($code = QueueService::currentCode()) !== '' && $this->queue->getCode() !== $code) {
+ $this->queue->initialize($code);
+ }
+
+ return $this;
+ }
+
+ /**
+ * 设置队列错误消息.
+ */
+ protected function setQueueError(string $message): void
+ {
+ if (QueueService::inContext()) {
+ $this->queue->error($message);
+ } else {
+ $this->writeConsoleMessage($message);
+ exit(0);
+ }
+ }
+
+ /**
+ * 设置队列成功消息.
+ */
+ protected function setQueueSuccess(string $message): void
+ {
+ if (QueueService::inContext()) {
+ $this->queue->success($message);
+ } else {
+ $this->writeConsoleMessage($message);
+ exit(0);
+ }
+ }
+
+ /**
+ * 设置队列进度消息.
+ */
+ protected function setQueueProgress(?string $message = null, ?string $progress = null, int $backline = 0): static
+ {
+ if (QueueService::inContext()) {
+ $this->queue->progress(QueueService::STATE_LOCK, $message, $progress, $backline);
+ } elseif (is_string($message)) {
+ $this->writeConsoleMessage($message, $backline);
+ }
+
+ return $this;
+ }
+
+ /**
+ * 输出控制台消息.
+ */
+ protected function writeConsoleMessage(string $message, int $backline = 0): void
+ {
+ while ($backline-- > 0) {
+ $message = "\033[1A\r\033[K{$message}";
+ }
+
+ $this->output->write($message . PHP_EOL);
+ }
+}
diff --git a/plugin/think-library/src/Controller.php b/plugin/think-library/src/Controller.php
new file mode 100644
index 000000000..6c4b768e4
--- /dev/null
+++ b/plugin/think-library/src/Controller.php
@@ -0,0 +1,348 @@
+request->action(), get_class_methods(__CLASS__))) {
+ $this->error('禁止访问内置方法!');
+ }
+ $this->get = $app->request->get();
+ $this->app = $app->bind('think\admin\Controller', $this);
+ $this->node = NodeService::getCurrent();
+ $this->request = $this->app->request;
+ $this->initialize();
+ }
+
+ /**
+ * 返回失败的内容.
+ * @param mixed $info 消息内容
+ * @param mixed $data 返回数据
+ * @param mixed $code 返回代码
+ */
+ public function error(mixed $info, mixed $data = '{-null-}', mixed $code = 500): never
+ {
+ $this->respond($info, $data, $code, 500);
+ }
+
+ /**
+ * 返回成功的内容.
+ * @param mixed $info 消息内容
+ * @param mixed $data 返回数据
+ * @param mixed $code 返回代码
+ */
+ public function success(mixed $info, mixed $data = '{-null-}', mixed $code = 200): never
+ {
+ $this->respond($info, $data, $code, 200);
+ }
+
+ /**
+ * 输出标准 JSON 响应.
+ * @param mixed $info 消息内容
+ * @param mixed $data 返回数据
+ * @param mixed $code 返回代码
+ * @param int $default 默认状态
+ */
+ protected function respond(mixed $info, mixed $data, mixed $code, int $default): never
+ {
+ if ($data === '{-null-}') {
+ $data = new \stdClass();
+ }
+ $status = $this->normalizeResponseCode($code, $default);
+ $result = [
+ 'code' => $status,
+ 'info' => is_string($info) ? lang($info) : $info,
+ 'data' => $data,
+ ];
+ if (JwtToken::isRejwt()) {
+ $result['token'] = JwtToken::token();
+ } elseif ($token = SystemContext::instance()->buildToken()) {
+ $result['token'] = $token;
+ SystemContext::instance()->syncTokenCookie($token);
+ }
+ throw new HttpResponseException(json($result)->code(200));
+ }
+
+ /**
+ * 兼容旧版 1/0 返回码,并收敛到业务状态码.
+ */
+ protected function normalizeResponseCode(mixed $code, int $default): int
+ {
+ $status = intval($code);
+ if ($status === 1) {
+ return 200;
+ }
+ if ($status === 0) {
+ return 500;
+ }
+ if ($status < 100 || $status > 599) {
+ return $default;
+ }
+ return $status;
+ }
+
+ /**
+ * URL重定向.
+ * @param string $url 跳转链接
+ * @param int $code 跳转代码
+ */
+ public function redirect(string $url, int $code = 302): void
+ {
+ throw new HttpResponseException(redirect($url, $code));
+ }
+
+ /**
+ * 返回视图内容.
+ * @param string $tpl 模板名称
+ * @param array $vars 模板变量
+ * @param null|string $node 授权节点
+ */
+ public function fetch(string $tpl = '', array $vars = [], ?string $node = null): void
+ {
+ if (function_exists('system_view_context')) {
+ $vars = array_merge(system_view_context(), $vars);
+ }
+ foreach (get_object_vars($this) as $name => $value) {
+ $vars[$name] = $value;
+ }
+ $vars['staticRoot'] = strval($vars['staticRoot'] ?? AppService::uri('static'));
+ if (!isset($vars['pageTitle']) || !is_scalar($vars['pageTitle']) || strval($vars['pageTitle']) === '') {
+ $vars['pageTitle'] = isset($vars['title']) && is_scalar($vars['title']) ? strval($vars['title']) : '';
+ }
+ throw new HttpResponseException(view($tpl, $vars));
+ }
+
+ /**
+ * 获取当前控制器表现层模式.
+ */
+ public function presentationMode(): string
+ {
+ return ResponseModeService::resolve($this->request, static::class);
+ }
+
+ /**
+ * 当前控制器是否走 API 模式.
+ */
+ public function usesApiPresentation(): bool
+ {
+ return ResponseModeService::prefersApi($this->request, static::class);
+ }
+
+ /**
+ * 兼容旧版控制器在表单页显式启用 token 的调用。
+ * 新 Builder 已内建请求防重逻辑,这里保留空实现避免老入口报错。
+ */
+ protected function _applyFormToken(): void
+ {
+ }
+
+ /**
+ * 响应页面 Builder.
+ * @param array $context
+ * @param array $payload
+ * @param null|callable(PageBuilder,array):void $view
+ */
+ protected function respondWithPageBuilder(PageBuilder $builder, array $context = [], ?callable $view = null, array $payload = []): void
+ {
+ $mode = $this->presentationMode();
+ if ($mode === ResponseModeService::MODE_API) {
+ $this->success('获取页面成功!', array_merge([
+ 'driver' => 'builder',
+ 'scene' => 'page',
+ 'mode' => $mode,
+ 'token' => ['header' => ResponseModeService::apiHeader()],
+ 'builder' => [
+ 'type' => 'page',
+ 'schema' => $builder->toArray(),
+ ],
+ 'context' => $context,
+ ], $payload));
+ }
+
+ if (is_callable($view)) {
+ $view($builder, $context);
+ return;
+ }
+
+ $builder->fetch($context);
+ }
+
+ /**
+ * 响应表单 Builder.
+ * @param array $context
+ * @param array $data
+ * @param array $payload
+ * @param null|callable(FormBuilder,array,array):void $view
+ */
+ protected function respondWithFormBuilder(FormBuilder $builder, array $context = [], array $data = [], ?callable $view = null, array $payload = []): void
+ {
+ $mode = $this->presentationMode();
+ if ($mode === ResponseModeService::MODE_API) {
+ $this->success('获取表单成功!', array_merge([
+ 'driver' => 'builder',
+ 'scene' => 'form',
+ 'mode' => $mode,
+ 'token' => ['header' => ResponseModeService::apiHeader()],
+ 'builder' => [
+ 'type' => 'form',
+ 'schema' => $builder->toArray(),
+ 'rules' => $builder->getValidateRules(),
+ ],
+ 'context' => $context,
+ 'data' => $data,
+ ], $payload));
+ }
+
+ if (is_callable($view)) {
+ $view($builder, $context, $data);
+ return;
+ }
+
+ $builder->fetch(array_merge($context, ['vo' => $data]));
+ }
+
+ /**
+ * 模板变量赋值
+ * @param mixed $name 要显示的模板变量
+ * @param mixed $value 变量的值
+ * @return $this
+ */
+ public function assign(mixed $name, mixed $value = ''): static
+ {
+ if (is_string($name)) {
+ $this->{$name} = $value;
+ } elseif (is_array($name)) {
+ foreach ($name as $k => $v) {
+ if (is_string($k)) {
+ $this->{$k} = $v;
+ }
+ }
+ }
+ return $this;
+ }
+
+ /**
+ * 数据回调处理机制.
+ * @param string $name 回调方法名称
+ * @param mixed $one 回调引用参数1
+ * @param mixed $two 回调引用参数2
+ * @param mixed $thr 回调引用参数3
+ */
+ public function callback(string $name, mixed &$one = [], mixed &$two = [], mixed &$thr = []): bool
+ {
+ if (is_callable($name)) {
+ return call_user_func($name, $this, $one, $two, $thr);
+ }
+ foreach (["_{$this->app->request->action()}{$name}", $name] as $method) {
+ if (method_exists($this, $method) && $this->{$method}($one, $two, $thr) === false) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * 控制器初始化.
+ */
+ protected function initialize() {}
+
+ /**
+ * 快捷输入并验证( 支持 规则 # 别名 ).
+ * @param array $rules 验证规则( 验证信息数组 )
+ * @param array|string $type 输入方式 ( post. 或 get. )
+ * @param null|callable $callable 异常处理操作
+ */
+ protected function _vali(array $rules, array|string $type = '', ?callable $callable = null): array
+ {
+ return ValidateHelper::instance()->init($rules, $type, $callable);
+ }
+
+ /**
+ * 创建异步任务并返回任务编号.
+ * @param string $title 任务名称
+ * @param string $command 执行内容
+ * @param int $later 延时执行时间
+ * @param array $data 任务附加数据
+ * @param int $loops 循环等待时间
+ * @param ?int $legacyLoops 兼容旧调用的循环等待时间参数
+ */
+ protected function _queue(string $title, string $command, int $later = 0, array $data = [], int $loops = 0, ?int $legacyLoops = null): void
+ {
+ try {
+ $queue = QueueService::register($title, $command, $later, $data, $loops, $legacyLoops);
+ $this->success('创建任务成功!', $queue->getCode());
+ } catch (Exception $exception) {
+ $code = $exception->getData();
+ if (is_string($code) && stripos($code, 'Q') === 0) {
+ $this->success('任务已经存在,无需再次创建!', $code);
+ } else {
+ $this->error($exception->getMessage());
+ }
+ } catch (HttpResponseException $exception) {
+ throw $exception;
+ } catch (\Exception $exception) {
+ trace_file($exception);
+ $this->error(lang('创建任务失败,%s', [$exception->getMessage()]));
+ }
+ }
+}
diff --git a/plugin/think-library/src/Exception.php b/plugin/think-library/src/Exception.php
new file mode 100644
index 000000000..3bdb7b9ee
--- /dev/null
+++ b/plugin/think-library/src/Exception.php
@@ -0,0 +1,63 @@
+code = $code;
+ $this->data = $data;
+ $this->message = $message;
+ }
+
+ /**
+ * 获取异常停止数据.
+ * @return mixed
+ */
+ public function getData()
+ {
+ return $this->data;
+ }
+
+ /**
+ * 设置异常停止数据.
+ * @param mixed $data
+ */
+ public function setData($data)
+ {
+ $this->data = $data;
+ }
+}
diff --git a/plugin/think-library/src/Helper.php b/plugin/think-library/src/Helper.php
new file mode 100644
index 000000000..58b4153b1
--- /dev/null
+++ b/plugin/think-library/src/Helper.php
@@ -0,0 +1,82 @@
+app = $app;
+ $this->class = $class;
+ // 计算指定输出格式
+ $output = strval($app->request->request('output', 'default'));
+ $method = $app->request->method() ?: ($app->runningInConsole() ? 'cli' : 'nil');
+ $this->method = strtolower($method);
+ $this->output = "{$this->method}." . strtolower($output);
+ }
+
+ /**
+ * 实例化 Helper 对象(支持依赖注入).
+ *
+ * @param mixed ...$args 构造函数参数
+ */
+ public static function instance(...$args): static
+ {
+ return Container::getInstance()->invokeClass(static::class, $args);
+ }
+}
diff --git a/plugin/think-library/src/Library.php b/plugin/think-library/src/Library.php
new file mode 100644
index 000000000..24178cb76
--- /dev/null
+++ b/plugin/think-library/src/Library.php
@@ -0,0 +1,158 @@
+app;
+
+ // 动态应用运行参数
+ RuntimeService::apply();
+
+ // 请求初始化处理
+ $this->app->event->listen('HttpRun', function (Request $request) {
+ // Worker 常驻模式下,请求开始前先清理上次上下文。
+ RequestContext::clear();
+
+ // 运行环境配置同步
+ RuntimeService::sync();
+
+ // 配置默认输入过滤
+ $request->filter([static function ($value) {
+ return is_string($value) ? xss_safe($value) : $value;
+ }]);
+
+ // 判断访问模式兼容处理
+ if ($this->app->runningInConsole()) {
+ // 兼容 CLI 访问控制器
+ if (empty($_SERVER['REQUEST_URI']) && isset($_SERVER['argv'][1])) {
+ $request->setPathinfo($_SERVER['argv'][1]);
+ }
+ } else {
+ // 兼容 HTTP 调用 Console 后 URL 问题
+ $request->setHost($request->host());
+ }
+
+ // 注册多应用中间键
+ $this->app->middleware->add(MultAccess::class);
+ });
+
+ // 请求结束后处理
+ $this->app->event->listen('HttpEnd', static function () {
+ RequestContext::clear();
+ function_exists('sysvar') && sysvar('', '');
+ });
+ }
+
+ /**
+ * 初始化服务
+ */
+ public function register(): void
+ {
+ // 动态加载全局配置
+ [$dir, $ext] = [$this->app->getBasePath(), $this->app->getConfigExt()];
+ FileTools::find($dir, 2, function (\SplFileInfo $info) use ($ext) {
+ $info->isFile() && $info->getBasename() === "sys{$ext}" && Library::load($info->getPathname());
+ });
+ if (is_file($file = "{$dir}common{$ext}")) {
+ Library::load($file);
+ }
+ if (is_file($file = "{$dir}provider{$ext}")) {
+ $this->app->bind(include $file);
+ }
+ if (is_file($file = "{$dir}event{$ext}")) {
+ $this->app->loadEvent(include $file);
+ }
+ if (is_file($file = "{$dir}middleware{$ext}")) {
+ $this->app->middleware->import(include $file, 'app');
+ }
+
+ // 终端 HTTP 访问时特殊处理
+ if (!$this->app->runningInConsole()) {
+ // 动态注释 CORS 跨域处理
+ $this->app->middleware->add(function (Request $request, \Closure $next): Response {
+ $header = ['X-Frame-Options' => $this->app->config->get('app.cors_frame') ?: 'sameorigin'];
+ // HTTP.CORS 跨域规则配置
+ if ($this->app->config->get('app.cors_on', true) && ($origin = $request->header('origin', '-')) !== '-') {
+ if (is_string($hosts = $this->app->config->get('app.cors_host', []))) {
+ $hosts = str2arr($hosts);
+ }
+ if (empty($hosts) || in_array(parse_url(strtolower($origin), PHP_URL_HOST), $hosts)) {
+ $headers = str2arr(strval($this->app->config->get('app.cors_headers', 'X-Device-Code,X-Device-Type')));
+ $headers = array_values(array_filter(array_unique(array_map('trim', $headers))));
+ $header['Access-Control-Allow-Origin'] = $origin;
+ $header['Access-Control-Allow-Methods'] = $this->app->config->get('app.cors_methods', 'GET,PUT,POST,PATCH,DELETE');
+ $allow = array_merge(['Authorization', 'Content-Type', 'If-Match', 'If-Modified-Since', 'If-None-Match', 'If-Unmodified-Since', 'X-Requested-With'], $headers);
+ $header['Access-Control-Allow-Headers'] = join(',', array_unique($allow));
+ if ($this->app->config->get('app.cors_credentials', false)) {
+ $header['Access-Control-Allow-Credentials'] = 'true';
+ }
+ if (!empty($headers)) {
+ $header['Access-Control-Expose-Headers'] = join(',', $headers);
+ }
+ }
+ }
+ // 跨域预请求状态处理
+ if ($request->isOptions()) {
+ throw new HttpResponseException(response()->code(204)->header($header));
+ }
+ return $next($request)->header($header);
+ });
+ }
+ }
+
+ /**
+ * 动态加载文件.
+ * @return mixed
+ */
+ public static function load(string $file)
+ {
+ try {
+ return include $file;
+ } catch (\Error|\Throwable $error) {
+ trace_file($error);
+ throw new HttpException(500, $error->getMessage());
+ }
+ }
+}
diff --git a/plugin/think-library/src/Model.php b/plugin/think-library/src/Model.php
new file mode 100644
index 000000000..cce5f0677
--- /dev/null
+++ b/plugin/think-library/src/Model.php
@@ -0,0 +1,138 @@
+ '修改%s[%s]状态',
+ 'onAdminUpdate' => '更新%s[%s]记录',
+ 'onAdminInsert' => '增加%s[%s]成功',
+ 'onAdminDelete' => '删除%s[%s]成功',
+ ];
+ if (isset($oplogs[$method])) {
+ if ($this->oplogType && $this->oplogName) {
+ $changeIds = $args[0] ?? '';
+ if (is_callable(static::$oplogCall)) {
+ $changeIds = call_user_func(static::$oplogCall, $method, $changeIds, $this);
+ }
+ sysoplog($this->oplogType, lang($oplogs[$method], [lang($this->oplogName), $changeIds]));
+ }
+ return $this;
+ }
+ return parent::__call($method, $args);
+ }
+
+ /**
+ * 创建查询实例。
+ */
+ public static function mq(array $data = []): BaseQuery
+ {
+ return QueryFactory::build(static::mk($data)->newQuery());
+ }
+
+ /**
+ * 创建模型实例。
+ */
+ public static function mk(array $data = []): static
+ {
+ return new static($data);
+ }
+
+ /**
+ * 追加模型数据并标记为待持久化变更。
+ */
+ public function appendData(array $data, bool $overwrite = false): static
+ {
+ foreach ($data as $name => $value) {
+ if ($overwrite || !$this->hasData($name)) {
+ $this->setAttr($name, $value);
+ }
+ }
+
+ return $this;
+ }
+}
diff --git a/plugin/think-library/src/Plugin.php b/plugin/think-library/src/Plugin.php
new file mode 100644
index 000000000..0ceeb5337
--- /dev/null
+++ b/plugin/think-library/src/Plugin.php
@@ -0,0 +1,423 @@
+composer = $this->resolveComposerManifest($ref);
+ $this->hydrateComposerManifest($this->composer);
+
+ // 应用服务注册类
+ if (empty($this->appService)) {
+ $this->appService = static::class;
+ }
+
+ // 应用命名空间名
+ if (empty($this->appSpace)) {
+ $this->appSpace = $ref->getNamespaceName();
+ }
+
+ // 应用插件路径计算
+ if (empty($this->appPath) || !is_dir($this->appPath)) {
+ $this->appPath = dirname($ref->getFileName());
+ }
+
+ // 应用插件包名计算
+ if ($this->appAlias !== '' && $this->appCode === $this->appAlias) {
+ $this->appAlias = '';
+ }
+
+ if (is_dir($this->appPath)) {
+ $prefixes = $this->normalizePrefixes();
+ // 解析插件路径:phar 内 realpath 常为 false,需回退为原路径并保证末尾分隔符
+ $resolved = realpath($this->appPath);
+ $path = ($resolved !== false ? $resolved : rtrim(str_replace('\\', '/', $this->appPath), '/')) . DIRECTORY_SEPARATOR;
+ // 写入插件参数信息
+ self::$addons[$this->appCode] = [
+ 'name' => $this->appName,
+ 'type' => 'plugin',
+ 'path' => $path,
+ 'alias' => $this->appAlias,
+ 'prefix' => $prefixes[0] ?? '',
+ 'prefixes' => $prefixes,
+ 'space' => $this->appSpace ?: NodeService::space($this->appCode),
+ 'package' => $this->package,
+ 'service' => $this->appService,
+ 'document' => $this->appDocument,
+ 'description' => $this->appDescription,
+ 'platforms' => $this->normalizeArray($this->appPlatforms),
+ 'license' => $this->normalizeArray($this->appLicense),
+ 'version' => $this->appVersion,
+ 'homepage' => $this->appHomepage,
+ 'show' => $this->appMenuShow,
+ ];
+ AppService::clear();
+ }
+ }
+
+ /**
+ * 获取插件编号。
+ */
+ public static function getAppCode(): string
+ {
+ return static::plugin()->appCode;
+ }
+
+ /**
+ * 获取插件名称。
+ */
+ public static function getAppName(): string
+ {
+ return static::plugin()->appName;
+ }
+
+ /**
+ * 获取插件路径。
+ */
+ public static function getAppPath(): string
+ {
+ return static::plugin()->appPath;
+ }
+
+ /**
+ * 获取插件命名空间。
+ */
+ public static function getAppSpace(): string
+ {
+ return static::plugin()->appSpace;
+ }
+
+ /**
+ * 获取插件安装包名。
+ */
+ public static function getAppPackage(): string
+ {
+ return static::plugin()->package;
+ }
+
+ /**
+ * 获取插件主访问前缀。
+ */
+ public static function getAppPrefix(): string
+ {
+ return static::plugin()->appPrefix;
+ }
+
+ /**
+ * 获取插件全部访问前缀。
+ * @return string[]
+ */
+ public static function getAppPrefixes(): array
+ {
+ return static::plugin()->appPrefixes;
+ }
+
+ /**
+ * 获取插件菜单根节点配置。
+ */
+ public static function getMenuRoot(): array
+ {
+ return static::plugin()->appMenuRoot;
+ }
+
+ /**
+ * 获取插件菜单存在检测条件。
+ */
+ public static function getMenuExists(): array
+ {
+ return static::plugin()->appMenuExists;
+ }
+
+ /**
+ * 获取插件菜单显示配置。
+ */
+ public static function getMenuShow(): bool
+ {
+ return static::plugin()->appMenuShow;
+ }
+
+ /**
+ * 获取插件菜单项配置。
+ */
+ public static function getMenus(): array
+ {
+ return static::plugin()->appMenus;
+ }
+
+ /**
+ * 获取插件及安装信息.
+ * @param ?string $code 指定插件编号
+ * @param bool $append 关联安装数据
+ */
+ public static function get(?string $code = null, bool $append = false): ?array
+ {
+ // 读取插件原始信息
+ $data = empty($code) ? self::$addons : (self::$addons[$code] ?? null);
+ if (empty($data) || empty($append)) {
+ return $data;
+ }
+ // 关联插件安装信息
+ $versions = AppService::getPluginLibrarys();
+ return empty($code) ? array_map(static function ($item) use ($versions) {
+ $item['install'] = $versions[$item['package']] ?? [];
+ if (empty($item['name'])) {
+ $item['name'] = $item['install']['name'] ?? '';
+ }
+ return $item;
+ }, $data) : $data + ['install' => $versions[$data['package']] ?? []];
+ }
+
+ /**
+ * 注册应用启动.
+ */
+ public function boot(): void {}
+
+ /**
+ * 获取当前插件服务实例。
+ */
+ protected static function plugin(): static
+ {
+ return app(static::class);
+ }
+
+ /**
+ * 解析 Composer 配置.
+ */
+ private function resolveComposerManifest(\ReflectionClass $ref): array
+ {
+ if (!($path = $ref->getFileName())) {
+ return [];
+ }
+
+ for ($level = 1; $level <= 3; ++$level) {
+ $file = dirname($path, $level) . DIRECTORY_SEPARATOR . 'composer.json';
+ if (is_file($file)) {
+ return json_decode(file_get_contents($file), true) ?: [];
+ }
+ }
+
+ return [];
+ }
+
+ /**
+ * 同步 Composer 元数据.
+ */
+ private function hydrateComposerManifest(array $manifest): void
+ {
+ $app = (array)($manifest['extra']['xadmin']['app'] ?? []);
+ $menu = (array)($manifest['extra']['xadmin']['menu'] ?? []);
+
+ $this->package = strval($manifest['name'] ?? '');
+ $this->appCode = strval($app['code'] ?? '');
+ $this->appName = strval($app['name'] ?? '');
+ $this->appAlias = strval($app['alias'] ?? '');
+ $this->appPrefix = strval($app['prefix'] ?? '');
+ $this->appPrefixes = array_key_exists('prefixes', $app) ? (array)$app['prefixes'] : [];
+ $this->appSpace = array_key_exists('space', $app) ? strval($app['space']) : '';
+ $this->appType = 'plugin';
+ $this->appDocument = strval($app['document'] ?? '');
+ $this->appDescription = array_key_exists('description', $app) ? strval($app['description']) : strval($manifest['description'] ?? '');
+ $this->appPlatforms = array_key_exists('platforms', $app) ? (array)$app['platforms'] : [];
+ $this->appLicense = array_key_exists('license', $app) ? (array)$app['license'] : (array)($manifest['license'] ?? []);
+ $this->appVersion = strval($manifest['version'] ?? '');
+ $this->appHomepage = strval($manifest['homepage'] ?? '');
+ $this->appMenuRoot = array_key_exists('root', $menu) ? (array)$menu['root'] : [];
+ $this->appMenuExists = array_key_exists('exists', $menu) ? (array)$menu['exists'] : [];
+ $this->appMenuShow = array_key_exists('show', $menu) ? boolval($menu['show']) : true;
+ $this->appMenus = array_key_exists('items', $menu) ? (array)$menu['items'] : [];
+ }
+
+ /**
+ * 获取标准化前缀列表。
+ * @return string[]
+ */
+ private function normalizePrefixes(): array
+ {
+ $items = [];
+ foreach ([$this->appPrefix, $this->appPrefixes, $this->appAlias, $this->appCode] as $value) {
+ foreach ((array)$value as $prefix) {
+ $prefix = trim((string)$prefix, " \t\n\r\0\x0B\\/");
+ if ($prefix === '') {
+ continue;
+ }
+ if (strpos($prefix, '/')) {
+ $prefix = strstr($prefix, '/', true) ?: $prefix;
+ }
+ if (strpos($prefix, '.')) {
+ $prefix = strstr($prefix, '.', true) ?: $prefix;
+ }
+ if ($prefix !== '' && !in_array($prefix, $items, true)) {
+ $items[] = $prefix;
+ }
+ }
+ }
+
+ $this->appPrefix = $items[0] ?? '';
+ $this->appPrefixes = $items;
+ return $items;
+ }
+
+ /**
+ * 标准化字符串数组.
+ * @return string[]
+ */
+ private function normalizeArray(array $items): array
+ {
+ $result = [];
+ foreach ($items as $item) {
+ $value = trim(strval($item));
+ if ($value !== '' && !in_array($value, $result, true)) {
+ $result[] = $value;
+ }
+ }
+
+ return $result;
+ }
+}
diff --git a/plugin/think-library/src/Service.php b/plugin/think-library/src/Service.php
new file mode 100644
index 000000000..0b343a54c
--- /dev/null
+++ b/plugin/think-library/src/Service.php
@@ -0,0 +1,60 @@
+app = $app;
+ $this->initialize();
+ }
+
+ /**
+ * 静态实例对象
+ * @param array $var 实例参数
+ * @param bool $new 创建新实例
+ */
+ public static function instance(array $var = [], bool $new = false): static
+ {
+ return Container::getInstance()->make(static::class, $var, $new);
+ }
+
+ /**
+ * 初始化服务
+ */
+ protected function initialize() {}
+}
diff --git a/plugin/think-library/src/Storage.php b/plugin/think-library/src/Storage.php
new file mode 100644
index 000000000..ae887292c
--- /dev/null
+++ b/plugin/think-library/src/Storage.php
@@ -0,0 +1,235 @@
+{$method}()");
+ }
+
+ /**
+ * 实例化存储操作对象
+ * @param ?string $name 驱动名称
+ * @param ?string $class 驱动类名
+ * @throws Exception
+ */
+ public static function instance(?string $name = null, ?string $class = null): StorageInterface
+ {
+ try {
+ if (is_null($class)) {
+ $class = static::manager()->driverClass($name);
+ }
+ if (class_exists($class)) {
+ /* @var StorageInterface */
+ return Container::getInstance()->make($class);
+ }
+ throw new Exception("Storage driver [{$class}] does not exist.");
+ } catch (Exception $exception) {
+ throw $exception;
+ } catch (\Exception $exception) {
+ throw new Exception($exception->getMessage());
+ }
+ }
+
+ /**
+ * 下载文件到本地.
+ * @param string $url 文件URL地址
+ * @param bool $force 是否强制下载
+ * @param int $expire 文件保留时间
+ */
+ public static function down(string $url, bool $force = false, int $expire = 0): array
+ {
+ try {
+ /** @var StorageInterface $local */
+ $local = static::instance('local');
+ $filename = static::name($url, '', 'down/');
+ if (empty($force) && $local->has($filename)) {
+ if ($expire < 1 || filemtime($local->path($filename)) + $expire > time()) {
+ return $local->info($filename);
+ }
+ }
+ return $local->set($filename, static::curlGet($url));
+ } catch (\Exception $exception) {
+ return ['url' => $url, 'hash' => md5($url), 'key' => $url, 'file' => $url];
+ }
+ }
+
+ /**
+ * 获取文件相对名称.
+ * @param string $url 文件访问链接
+ * @param string $ext 文件后缀名称
+ * @param string $pre 文件存储前缀
+ * @param string $fun 名称规则方法
+ */
+ public static function name(string $url, string $ext = '', string $pre = '', string $fun = 'md5'): string
+ {
+ [$hah, $ext] = [$fun($url), trim($ext ?: pathinfo($url, 4), '.\/')];
+ $attr = [trim($pre, '.\/'), substr($hah, 0, 2), substr($hah, 2, 30)];
+ return trim(join('/', $attr), '/') . '.' . strtolower($ext ?: 'tmp');
+ }
+
+ /**
+ * 使用CURL读取网络资源.
+ * @param string $url 资源地址
+ */
+ public static function curlGet(string $url): string
+ {
+ $ch = curl_init();
+ curl_setopt($ch, CURLOPT_URL, $url);
+ curl_setopt($ch, CURLOPT_HEADER, 0);
+ curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
+ curl_setopt($ch, CURLOPT_FOLLOWLOCATION, 1);
+ curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
+ curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
+ $body = curl_exec($ch) ?: '';
+ curl_close($ch);
+ return $body;
+ }
+
+ /**
+ * 获取后缀类型.
+ * @param array|string $exts 文件后缀
+ * @param array $mime 文件信息
+ */
+ public static function mime(array|string $exts, array $mime = []): string
+ {
+ $mimes = static::mimes();
+ foreach (str2arr($exts) as $ext) {
+ $mime[] = $mimes[strtolower($ext)] ?? 'application/octet-stream';
+ }
+ return join(',', array_unique($mime));
+ }
+
+ /**
+ * 获取所有类型.
+ */
+ public static function mimes(): array
+ {
+ return static::manager()->mimes();
+ }
+
+ /**
+ * 获取存储类型.
+ */
+ public static function types(): array
+ {
+ return static::manager()->types();
+ }
+
+ /**
+ * 获取驱动区域列表.
+ */
+ public static function regions(string $name): array
+ {
+ return static::manager()->regions($name);
+ }
+
+ /**
+ * 获取驱动配置模板名.
+ */
+ public static function template(string $name): string
+ {
+ return static::manager()->template($name);
+ }
+
+ /**
+ * 获取前端上传授权参数.
+ */
+ public static function authorize(string $name, string $key, bool $safe = false, ?string $attname = null, string $hash = ''): array
+ {
+ return static::manager()->authorize($name, $key, $safe, $attname, $hash);
+ }
+
+ /**
+ * 图片数据存储.
+ * @param string $base64 图片内容
+ * @param string $prefix 保存前缀
+ * @param bool $safemode 安全模式
+ * @return array [ url => URL ]
+ * @throws Exception
+ */
+ public static function saveImage(string $base64, string $prefix = 'image', bool $safemode = false): array
+ {
+ if (preg_match('|^data:image/(.*?);base64,|i', $base64)) {
+ [$ext, $img] = explode('|||', preg_replace('|^data:image/(.*?);base64,|i', '$1|||', $base64));
+ $name = static::name($img, $ext, $prefix);
+ if (empty($ext) || !in_array(strtolower($ext), ['gif', 'png', 'jpg', 'jpeg'])) {
+ throw new Exception('内容格式异常!');
+ }
+ if ($safemode) {
+ return static::instance('local')->set($name, base64_decode($img), true);
+ }
+ return static::instance()->set($name, base64_decode($img));
+ }
+ return ['url' => $base64];
+ }
+
+ /**
+ * 获取存储组件管理器.
+ * @throws Exception
+ */
+ protected static function manager(): StorageManagerInterface
+ {
+ $container = Container::getInstance();
+ if ($container->bound(StorageManagerInterface::class)) {
+ /** @var StorageManagerInterface */
+ return $container->make(StorageManagerInterface::class);
+ }
+ $class = '';
+ if ($container->bound('app')) {
+ $app = $container->make('app');
+ $class = strval($app->config->get('app.storage_manager_class', ''));
+ }
+ if ($class !== '' && class_exists($class)) {
+ /** @var StorageManagerInterface */
+ return $container->make($class);
+ }
+ throw new Exception('System storage manager is not available.');
+ }
+}
diff --git a/plugin/think-library/src/builder/BuilderLang.php b/plugin/think-library/src/builder/BuilderLang.php
new file mode 100644
index 000000000..2216fbb49
--- /dev/null
+++ b/plugin/think-library/src/builder/BuilderLang.php
@@ -0,0 +1,129 @@
+
+ */
+ private const TRANSLATABLE_ATTRS = [
+ 'placeholder',
+ 'title',
+ 'data-title',
+ 'data-confirm',
+ 'data-tips-text',
+ 'required-error',
+ 'pattern-error',
+ 'lay-text',
+ ];
+
+ public static function text(string $text): string
+ {
+ if ($text === '' || self::looksLikeHtml($text) || !function_exists('lang')) {
+ return $text;
+ }
+
+ try {
+ return strval(lang($text));
+ } catch (\Throwable) {
+ return $text;
+ }
+ }
+
+ /**
+ * @param array $vars
+ */
+ public static function format(string $text, array $vars = []): string
+ {
+ if ($text === '') {
+ return '';
+ }
+ if (!function_exists('lang')) {
+ return self::fallbackFormat($text, $vars);
+ }
+
+ try {
+ return strval(lang($text, $vars));
+ } catch (\Throwable) {
+ return self::fallbackFormat($text, $vars);
+ }
+ }
+
+ public static function pipeText(string $text, string $separator = '|'): string
+ {
+ if ($text === '' || !str_contains($text, $separator)) {
+ return self::text($text);
+ }
+
+ return join($separator, array_map(
+ static fn(string $item): string => self::text(trim($item)),
+ explode($separator, $text)
+ ));
+ }
+
+ /**
+ * @param array $attrs
+ * @return array
+ */
+ public static function attrs(array $attrs): array
+ {
+ foreach ($attrs as $name => $value) {
+ if (!is_string($name) || !is_string($value) || !in_array($name, self::TRANSLATABLE_ATTRS, true)) {
+ continue;
+ }
+ $attrs[$name] = $name === 'lay-text' ? self::pipeText($value) : self::text($value);
+ }
+
+ return $attrs;
+ }
+
+ /**
+ * @param array $options
+ * @return array
+ */
+ public static function options(array $options): array
+ {
+ $translated = [];
+ foreach ($options as $value => $label) {
+ if (is_string($label)) {
+ $translated[$value] = self::text($label);
+ continue;
+ }
+ if (is_array($label)) {
+ $translated[$value] = self::options($label);
+ continue;
+ }
+ $translated[$value] = $label;
+ }
+
+ return $translated;
+ }
+
+ private static function looksLikeHtml(string $text): bool
+ {
+ return str_contains($text, '<') && str_contains($text, '>');
+ }
+
+ /**
+ * @param array $vars
+ */
+ private static function fallbackFormat(string $text, array $vars): string
+ {
+ if (count($vars) < 1) {
+ return $text;
+ }
+
+ try {
+ return vsprintf($text, $vars);
+ } catch (\Throwable) {
+ return $text;
+ }
+ }
+}
diff --git a/plugin/think-library/src/builder/base/BuilderAttributeBag.php b/plugin/think-library/src/builder/base/BuilderAttributeBag.php
new file mode 100644
index 000000000..e1cd03e6a
--- /dev/null
+++ b/plugin/think-library/src/builder/base/BuilderAttributeBag.php
@@ -0,0 +1,174 @@
+): array
+ */
+ private $syncHandler = null;
+
+ /**
+ * @param array $attrs
+ */
+ public function __construct(
+ private mixed $owner = null,
+ private array $attrs = [],
+ private bool $detachedClass = false,
+ private string $className = ''
+ ) {
+ }
+
+ /**
+ * @param callable(array): array $syncHandler
+ */
+ public function attach(callable $syncHandler): self
+ {
+ $this->syncHandler = $syncHandler;
+ return $this;
+ }
+
+ public function attr(string $name, mixed $value = null): self
+ {
+ $name = trim($name);
+ if ($name === '') {
+ return $this;
+ }
+ if ($name === 'class') {
+ return $this->assignClass($value);
+ }
+ $this->attrs[$name] = $value;
+ return $this->sync();
+ }
+
+ /**
+ * @param array $attrs
+ */
+ public function attrs(array $attrs): self
+ {
+ foreach ($attrs as $name => $value) {
+ if (is_string($name)) {
+ $this->attr($name, $value);
+ }
+ }
+ return $this;
+ }
+
+ public function class(string|array $class): self
+ {
+ if ($this->detachedClass) {
+ $this->className = BuilderAttributes::mergeClassNames($this->className, $class);
+ } else {
+ $this->attrs = BuilderAttributes::make($this->attrs)->class($class)->all();
+ }
+ return $this->sync();
+ }
+
+ public function removeClass(string|array $class): self
+ {
+ if ($this->detachedClass) {
+ $attrs = BuilderAttributes::make(['class' => $this->className])->removeClass($class)->all();
+ $this->className = trim(strval($attrs['class'] ?? ''));
+ } else {
+ $this->attrs = BuilderAttributes::make($this->attrs)->removeClass($class)->all();
+ }
+ return $this->sync();
+ }
+
+ public function toggleClass(string|array $class, ?bool $force = null): self
+ {
+ if ($this->detachedClass) {
+ $attrs = BuilderAttributes::make(['class' => $this->className])->toggleClass($class, $force)->all();
+ $this->className = trim(strval($attrs['class'] ?? ''));
+ } else {
+ $this->attrs = BuilderAttributes::make($this->attrs)->toggleClass($class, $force)->all();
+ }
+ return $this->sync();
+ }
+
+ public function data(string $name, mixed $value = null): self
+ {
+ $name = trim($name);
+ if ($name !== '') {
+ $this->attr('data-' . ltrim($name, '-'), $value);
+ }
+ return $this;
+ }
+
+ public function id(string $id): self
+ {
+ return $this->attr('id', $id);
+ }
+
+ public function remove(string $name): self
+ {
+ $name = trim($name);
+ if ($name === '') {
+ return $this;
+ }
+ if ($name === 'class') {
+ if ($this->detachedClass) {
+ $this->className = '';
+ } else {
+ unset($this->attrs['class']);
+ }
+ return $this->sync();
+ }
+ if (array_key_exists($name, $this->attrs)) {
+ unset($this->attrs[$name]);
+ $this->sync();
+ }
+ return $this;
+ }
+
+ /**
+ * @return array
+ */
+ public function export(): array
+ {
+ $state = ['attrs' => $this->attrs];
+ if ($this->detachedClass) {
+ $state['class'] = $this->className;
+ }
+ return $state;
+ }
+
+ public function end(): mixed
+ {
+ return $this->owner;
+ }
+
+ private function assignClass(mixed $value): self
+ {
+ $class = is_array($value) ? BuilderAttributes::mergeClassNames('', $value) : trim(strval($value));
+ if ($this->detachedClass) {
+ $this->className = $class;
+ } elseif ($class === '') {
+ unset($this->attrs['class']);
+ } else {
+ $this->attrs['class'] = $class;
+ }
+ return $this->sync();
+ }
+
+ private function sync(): self
+ {
+ if (is_callable($this->syncHandler)) {
+ $state = ($this->syncHandler)($this->export());
+ $this->attrs = is_array($state['attrs'] ?? null) ? $state['attrs'] : $this->attrs;
+ if ($this->detachedClass) {
+ $this->className = trim(strval($state['class'] ?? $this->className));
+ }
+ }
+ return $this;
+ }
+}
diff --git a/plugin/think-library/src/builder/base/BuilderModule.php b/plugin/think-library/src/builder/base/BuilderModule.php
new file mode 100644
index 000000000..5fc0d174f
--- /dev/null
+++ b/plugin/think-library/src/builder/base/BuilderModule.php
@@ -0,0 +1,118 @@
+): array
+ */
+ private $syncHandler = null;
+
+ /**
+ * @param array $config
+ */
+ public function __construct(
+ private string $name,
+ private array $config = [],
+ private mixed $owner = null
+ ) {
+ $this->name = trim($this->name);
+ }
+
+ /**
+ * @param array $module
+ * @param callable(int, array): array $syncHandler
+ */
+ public function attach(int $index, array $module, callable $syncHandler): self
+ {
+ $this->index = $index;
+ $this->syncHandler = $syncHandler;
+ $this->name = trim(strval($module['name'] ?? $this->name));
+ $this->config = is_array($module['config'] ?? null) ? $module['config'] : $this->config;
+ return $this;
+ }
+
+ public function name(string $name): self
+ {
+ $name = trim($name);
+ if ($name !== '') {
+ $this->name = $name;
+ $this->sync();
+ }
+ return $this;
+ }
+
+ /**
+ * @param array $config
+ */
+ public function config(array $config): self
+ {
+ $this->config = $config;
+ return $this->sync();
+ }
+
+ public function option(string $name, mixed $value): self
+ {
+ $name = trim($name);
+ if ($name !== '') {
+ $this->config[$name] = $value;
+ $this->sync();
+ }
+ return $this;
+ }
+
+ /**
+ * @param array $config
+ */
+ public function options(array $config): self
+ {
+ foreach ($config as $name => $value) {
+ if (is_string($name) && trim($name) !== '') {
+ $this->config[trim($name)] = $value;
+ }
+ }
+ return $this->sync();
+ }
+
+ public function remove(string $name): self
+ {
+ $name = trim($name);
+ if ($name !== '' && array_key_exists($name, $this->config)) {
+ unset($this->config[$name]);
+ $this->sync();
+ }
+ return $this;
+ }
+
+ /**
+ * @return array
+ */
+ public function export(): array
+ {
+ return ['name' => $this->name, 'config' => $this->config];
+ }
+
+ public function end(): mixed
+ {
+ return $this->owner;
+ }
+
+ private function sync(): self
+ {
+ if ($this->index !== null && is_callable($this->syncHandler)) {
+ $module = ($this->syncHandler)($this->index, $this->export());
+ $this->name = trim(strval($module['name'] ?? $this->name));
+ $this->config = is_array($module['config'] ?? null) ? $module['config'] : $this->config;
+ }
+ return $this;
+ }
+}
diff --git a/plugin/think-library/src/builder/base/BuilderNode.php b/plugin/think-library/src/builder/base/BuilderNode.php
new file mode 100644
index 000000000..f9dc2b7e6
--- /dev/null
+++ b/plugin/think-library/src/builder/base/BuilderNode.php
@@ -0,0 +1,429 @@
+
+ */
+ protected array $attrs = [];
+
+ /**
+ * 模块配置.
+ * @var array>
+ */
+ protected array $modules = [];
+
+ /**
+ * 子节点.
+ * @var array
+ */
+ protected array $children = [];
+
+ /**
+ * 原始 HTML.
+ */
+ protected string $html = '';
+
+ /**
+ * 父级节点.
+ */
+ protected ?self $parentNode = null;
+
+ public function __construct(
+ protected object $builder,
+ protected string $type = 'element',
+ protected string $tag = 'div'
+ ) {
+ }
+
+ public function attr(string $name, mixed $value = null): static
+ {
+ $name = trim($name);
+ if ($name !== '') {
+ $this->attrs[$name] = $value;
+ $this->afterMutate();
+ }
+ return $this;
+ }
+
+ public function removeAttr(string $name): static
+ {
+ $name = trim($name);
+ if ($name !== '' && array_key_exists($name, $this->attrs)) {
+ unset($this->attrs[$name]);
+ $this->afterMutate();
+ }
+ return $this;
+ }
+
+ public function attrs(array $attrs): static
+ {
+ foreach ($attrs as $name => $value) {
+ if ($name === 'class') {
+ $this->class($value);
+ } else {
+ $this->attr(strval($name), $value);
+ }
+ }
+ return $this;
+ }
+
+ public function class(string|array $class): static
+ {
+ $this->attrs = BuilderAttributes::make($this->attrs)->class($class)->all();
+ $this->afterMutate();
+ return $this;
+ }
+
+ public function removeClass(string|array $class): static
+ {
+ $this->attrs = BuilderAttributes::make($this->attrs)->removeClass($class)->all();
+ $this->afterMutate();
+ return $this;
+ }
+
+ public function toggleClass(string|array $class, ?bool $force = null): static
+ {
+ $this->attrs = BuilderAttributes::make($this->attrs)->toggleClass($class, $force)->all();
+ $this->afterMutate();
+ return $this;
+ }
+
+ public function data(string $name, mixed $value = null): static
+ {
+ $name = trim($name);
+ if ($name !== '') {
+ $this->attr('data-' . ltrim($name, '-'), $value);
+ }
+ return $this;
+ }
+
+ public function removeData(string $name): static
+ {
+ $name = trim($name);
+ if ($name !== '') {
+ $this->removeAttr('data-' . ltrim($name, '-'));
+ }
+ return $this;
+ }
+
+ public function id(string $id): static
+ {
+ return $this->attr('id', $id);
+ }
+
+ public function attrsItem(): BuilderAttributeBag
+ {
+ return $this->attachAttributes($this->createAttributes());
+ }
+
+ public function module(string $name, array $config = []): static
+ {
+ $this->attachModule($this->createModule($name, $config));
+ return $this;
+ }
+
+ public function moduleItem(string $name, array $config = []): BuilderModule
+ {
+ return $this->attachModule($this->createModule($name, $config));
+ }
+
+ public function clear(): static
+ {
+ foreach ($this->children as $child) {
+ if ($child instanceof self) {
+ $child->parentNode = null;
+ $child->onDetached();
+ }
+ $this->onChildDetached($child);
+ }
+ $this->children = [];
+ $this->afterMutate();
+ return $this;
+ }
+
+ public function parentNode(): ?self
+ {
+ return $this->parentNode;
+ }
+
+ /**
+ * @return array
+ */
+ public function children(): array
+ {
+ return $this->children;
+ }
+
+ public function firstChild(): ?object
+ {
+ return $this->children[0] ?? null;
+ }
+
+ public function lastChild(): ?object
+ {
+ return $this->children[count($this->children) - 1] ?? null;
+ }
+
+ public function appendNode(object $node): object
+ {
+ return $this->appendChild($node);
+ }
+
+ public function prependNode(object $node): object
+ {
+ return $this->prependChild($node);
+ }
+
+ public function beforeNode(object $node): object
+ {
+ return $this->insertSibling($node, false);
+ }
+
+ public function afterNode(object $node): object
+ {
+ return $this->insertSibling($node, true);
+ }
+
+ public function remove(): static
+ {
+ return $this->detachFromParent();
+ }
+
+ /**
+ * 追加子节点.
+ */
+ protected function appendChild(object $node): object
+ {
+ if (!$this->canAttachNode($node)) {
+ return $node;
+ }
+ if ($node instanceof self) {
+ $node->detachFromParent(true);
+ $node->parentNode = $this;
+ }
+ $this->children[] = $node;
+ $this->afterMutate();
+ return $node;
+ }
+
+ /**
+ * 前置插入子节点.
+ */
+ protected function prependChild(object $node): object
+ {
+ if (!$this->canAttachNode($node)) {
+ return $node;
+ }
+ if ($node instanceof self) {
+ $node->detachFromParent(true);
+ $node->parentNode = $this;
+ }
+ array_unshift($this->children, $node);
+ $this->afterMutate();
+ return $node;
+ }
+
+ protected function createAttributes(): BuilderAttributeBag
+ {
+ return new BuilderAttributeBag($this, $this->attrs);
+ }
+
+ protected function attachAttributes(BuilderAttributeBag $attributes): BuilderAttributeBag
+ {
+ return $attributes->attach(fn(array $state): array => $this->replaceAttributes($state));
+ }
+
+ /**
+ * @param array $state
+ * @return array
+ */
+ protected function replaceAttributes(array $state): array
+ {
+ $this->attrs = is_array($state['attrs'] ?? null) ? BuilderAttributes::make($state['attrs'])->all() : [];
+ $this->afterMutate();
+ return ['attrs' => $this->attrs];
+ }
+
+ protected function createModule(string $name, array $config = []): BuilderModule
+ {
+ return new BuilderModule($name, $config, $this);
+ }
+
+ protected function attachModule(BuilderModule $module): BuilderModule
+ {
+ $normalized = $this->normalizeModule($module->export());
+ if ($normalized['name'] === '') {
+ return $module;
+ }
+ $index = count($this->modules);
+ $this->modules[$index] = $normalized;
+ $this->afterMutate();
+ return $module->attach($index, $normalized, fn(int $index, array $module): array => $this->replaceModule($index, $module));
+ }
+
+ /**
+ * @param array $module
+ * @return array
+ */
+ protected function replaceModule(int $index, array $module): array
+ {
+ $normalized = $this->normalizeModule($module);
+ if ($normalized['name'] !== '') {
+ $this->modules[$index] = $normalized;
+ $this->afterMutate();
+ }
+ return $this->modules[$index] ?? $normalized;
+ }
+
+ /**
+ * 导出 HTML 节点数组.
+ * @return array
+ */
+ protected function exportHtmlNode(): array
+ {
+ return ['type' => 'html', 'html' => $this->html];
+ }
+
+ /**
+ * 导出普通节点数组.
+ * @return array
+ */
+ protected function exportElementNode(): array
+ {
+ return [
+ 'type' => $this->type,
+ 'tag' => $this->tag,
+ 'attrs' => $this->buildAttrs(),
+ 'modules' => $this->modules,
+ 'children' => $this->exportChildren(),
+ ];
+ }
+
+ /**
+ * 导出子节点数组.
+ * @return array>
+ */
+ public function exportChildren(): array
+ {
+ return array_map(static fn(object $node) => $node->export(), $this->children);
+ }
+
+ /**
+ * 获取节点属性.
+ * @return array
+ */
+ protected function buildAttrs(): array
+ {
+ return BuilderAttributes::make($this->attrs)->modules($this->modules)->all();
+ }
+
+ /**
+ * @param array $module
+ * @return array
+ */
+ protected function normalizeModule(array $module): array
+ {
+ return [
+ 'name' => trim(strval($module['name'] ?? '')),
+ 'config' => is_array($module['config'] ?? null) ? $module['config'] : [],
+ ];
+ }
+
+ protected function removeChild(object $node, bool $moving = false): bool
+ {
+ foreach ($this->children as $index => $child) {
+ if ($child === $node) {
+ if ($child instanceof self) {
+ $child->parentNode = null;
+ if (!$moving) {
+ $child->onDetached();
+ }
+ }
+ array_splice($this->children, $index, 1);
+ $this->onChildDetached($child);
+ $this->afterMutate();
+ return true;
+ }
+ }
+ return false;
+ }
+
+ protected function insertSibling(object $node, bool $after = true): object
+ {
+ if (!$this->parentNode instanceof self) {
+ return $node;
+ }
+ return $this->parentNode->insertChildAround($this, $node, $after);
+ }
+
+ protected function insertChildAround(object $pivot, object $node, bool $after = true): object
+ {
+ if (!$this->canAttachNode($node)) {
+ return $node;
+ }
+ foreach ($this->children as $index => $child) {
+ if ($child === $pivot) {
+ if ($node instanceof self) {
+ $node->detachFromParent(true);
+ $node->parentNode = $this;
+ }
+ array_splice($this->children, $after ? $index + 1 : $index, 0, [$node]);
+ $this->afterMutate();
+ return $node;
+ }
+ }
+ return $after ? $this->appendChild($node) : $this->prependChild($node);
+ }
+
+ /**
+ * 节点变更后同步.
+ */
+ protected function afterMutate(): void
+ {
+ }
+
+ protected function onDetached(): void
+ {
+ }
+
+ protected function onChildDetached(object $child): void
+ {
+ }
+
+ protected function detachFromParent(bool $moving = false): static
+ {
+ if ($this->parentNode instanceof self) {
+ $this->parentNode->removeChild($this, $moving);
+ }
+ return $this;
+ }
+
+ private function canAttachNode(object $node): bool
+ {
+ return !$node instanceof self || ($node !== $this && !$this->isDescendantOf($node));
+ }
+
+ private function isDescendantOf(self $node): bool
+ {
+ $parent = $this->parentNode;
+ while ($parent instanceof self) {
+ if ($parent === $node) {
+ return true;
+ }
+ $parent = $parent->parentNode;
+ }
+ return false;
+ }
+}
diff --git a/plugin/think-library/src/builder/base/BuilderOptionSource.php b/plugin/think-library/src/builder/base/BuilderOptionSource.php
new file mode 100644
index 000000000..dd8847bd1
--- /dev/null
+++ b/plugin/think-library/src/builder/base/BuilderOptionSource.php
@@ -0,0 +1,106 @@
+): array
+ */
+ private $syncHandler = null;
+
+ /**
+ * @param array $options
+ */
+ public function __construct(
+ private string $sourceKey = 'source',
+ private array $options = [],
+ private string $source = '',
+ private mixed $owner = null
+ ) {
+ $this->sourceKey = trim($this->sourceKey) ?: 'source';
+ }
+
+ /**
+ * @param callable(array): array $syncHandler
+ */
+ public function attach(callable $syncHandler): static
+ {
+ $this->syncHandler = $syncHandler;
+ return $this;
+ }
+
+ public function source(string $source): static
+ {
+ $this->source = trim($source);
+ return $this->sync();
+ }
+
+ public function variable(string $source): static
+ {
+ return $this->source($source);
+ }
+
+ /**
+ * @param array $options
+ */
+ public function options(array $options): static
+ {
+ $this->options = $options;
+ return $this->sync();
+ }
+
+ public function option(string|int $value, mixed $label): static
+ {
+ $this->options[(string)$value] = $label;
+ return $this->sync();
+ }
+
+ public function removeOption(string|int $value): static
+ {
+ $key = (string)$value;
+ if (array_key_exists($key, $this->options)) {
+ unset($this->options[$key]);
+ $this->sync();
+ }
+ return $this;
+ }
+
+ public function clearOptions(): static
+ {
+ $this->options = [];
+ return $this->sync();
+ }
+
+ /**
+ * @return array
+ */
+ public function export(): array
+ {
+ return [
+ 'options' => $this->options,
+ $this->sourceKey => $this->source,
+ ];
+ }
+
+ public function end(): mixed
+ {
+ return $this->owner;
+ }
+
+ private function sync(): static
+ {
+ if (is_callable($this->syncHandler)) {
+ $state = ($this->syncHandler)($this->export());
+ $this->options = is_array($state['options'] ?? null) ? $state['options'] : $this->options;
+ $this->source = trim(strval($state[$this->sourceKey] ?? $this->source));
+ }
+ return $this;
+ }
+}
diff --git a/plugin/think-library/src/builder/base/render/BuilderActionRenderer.php b/plugin/think-library/src/builder/base/render/BuilderActionRenderer.php
new file mode 100644
index 000000000..4e0e88a33
--- /dev/null
+++ b/plugin/think-library/src/builder/base/render/BuilderActionRenderer.php
@@ -0,0 +1,29 @@
+attributesRenderer = $attributesRenderer ?? new BuilderAttributesRenderer();
+ }
+
+ /**
+ * @param array $attrs
+ */
+ public function render(string $label, array $attrs = [], string $tag = 'a'): string
+ {
+ $tag = trim($tag) ?: 'a';
+ $attrsHtml = $this->attributesRenderer->render($attrs);
+ return sprintf('<%s%s>%s%s>', $tag, $attrsHtml === '' ? '' : ' ' . $attrsHtml, $label, $tag);
+ }
+}
diff --git a/plugin/think-library/src/builder/base/render/BuilderAttributes.php b/plugin/think-library/src/builder/base/render/BuilderAttributes.php
new file mode 100644
index 000000000..b2bbabb77
--- /dev/null
+++ b/plugin/think-library/src/builder/base/render/BuilderAttributes.php
@@ -0,0 +1,165 @@
+ $attrs
+ */
+ public function __construct(private array $attrs = [])
+ {
+ }
+
+ /**
+ * @param array $attrs
+ */
+ public static function make(array $attrs = []): self
+ {
+ return new self($attrs);
+ }
+
+ /**
+ * @param array $attrs
+ */
+ public function merge(array $attrs): self
+ {
+ foreach ($attrs as $name => $value) {
+ if ($name === 'class') {
+ $this->class(is_array($value) ? $value : strval($value));
+ } else {
+ $this->attrs[$name] = $value;
+ }
+ }
+ return $this;
+ }
+
+ public function class(string|array $class): self
+ {
+ $merged = self::mergeClassNames(strval($this->attrs['class'] ?? ''), $class);
+ if ($merged === '') {
+ unset($this->attrs['class']);
+ } else {
+ $this->attrs['class'] = $merged;
+ }
+ return $this;
+ }
+
+ public function removeClass(string|array $class): self
+ {
+ $current = self::normalizeClasses(strval($this->attrs['class'] ?? ''));
+ $remove = self::normalizeClasses($class);
+ if ($current === [] || $remove === []) {
+ return $this;
+ }
+ $merged = array_values(array_diff($current, $remove));
+ if ($merged === []) {
+ unset($this->attrs['class']);
+ } else {
+ $this->attrs['class'] = join(' ', $merged);
+ }
+ return $this;
+ }
+
+ public function toggleClass(string|array $class, ?bool $force = null): self
+ {
+ $classes = self::normalizeClasses($class);
+ if ($classes === []) {
+ return $this;
+ }
+
+ $current = self::normalizeClasses(strval($this->attrs['class'] ?? ''));
+ $active = count(array_intersect($current, $classes)) === count($classes);
+ $enabled = $force ?? !$active;
+
+ return $enabled ? $this->class($classes) : $this->removeClass($classes);
+ }
+
+ public function default(string $name, mixed $value): self
+ {
+ if (!array_key_exists($name, $this->attrs)) {
+ $this->attrs[$name] = $value;
+ }
+ return $this;
+ }
+
+ /**
+ * @param array> $modules
+ */
+ public function modules(array $modules): self
+ {
+ if (count($modules) > 0) {
+ $this->attrs['data-builder-modules'] = json_encode($modules, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+ }
+ return $this;
+ }
+
+ /**
+ * @return array
+ */
+ public function all(): array
+ {
+ $attrs = $this->attrs;
+ if (isset($attrs['class']) && trim(strval($attrs['class'])) === '') {
+ unset($attrs['class']);
+ }
+ return $attrs;
+ }
+
+ public function html(): string
+ {
+ $html = '';
+ foreach ($this->all() as $key => $value) {
+ $name = self::escape((string)$key);
+ $html .= is_null($value)
+ ? sprintf(' %s', $name)
+ : sprintf(' %s="%s"', $name, self::escape((string)$value));
+ }
+ return ltrim($html);
+ }
+
+ public static function escape(string $value): string
+ {
+ return htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
+ }
+
+ public static function mergeClassNames(string|array $origin, string|array $append): string
+ {
+ $classes = [];
+ foreach ([self::normalizeClasses($origin), self::normalizeClasses($append)] as $items) {
+ foreach ($items as $class) {
+ if ($class !== '' && !in_array($class, $classes, true)) {
+ $classes[] = $class;
+ }
+ }
+ }
+ return join(' ', $classes);
+ }
+
+ /**
+ * @return array
+ */
+ public static function normalizeClasses(string|array $classes): array
+ {
+ if (is_array($classes)) {
+ $items = array_map('strval', $classes);
+ } else {
+ $items = preg_split('/\s+/', trim($classes)) ?: [];
+ }
+
+ $result = [];
+ foreach ($items as $class) {
+ $class = trim($class);
+ if ($class !== '') {
+ $result[] = $class;
+ }
+ }
+ return $result;
+ }
+}
diff --git a/plugin/think-library/src/builder/base/render/BuilderAttributesRenderContext.php b/plugin/think-library/src/builder/base/render/BuilderAttributesRenderContext.php
new file mode 100644
index 000000000..40a41b12a
--- /dev/null
+++ b/plugin/think-library/src/builder/base/render/BuilderAttributesRenderContext.php
@@ -0,0 +1,38 @@
+): string $attrsRenderer
+ */
+ public function __construct(
+ private $attrsRenderer,
+ ) {
+ }
+
+ /**
+ * @param array $attrs
+ */
+ public function attrs(array $attrs): string
+ {
+ return ($this->attrsRenderer)($attrs);
+ }
+
+ public function escape(string $value): string
+ {
+ return BuilderAttributes::escape($value);
+ }
+
+ public function mergeClass(string|array $origin, string|array $append): string
+ {
+ return BuilderAttributes::mergeClassNames($origin, $append);
+ }
+}
diff --git a/plugin/think-library/src/builder/base/render/BuilderAttributesRenderer.php b/plugin/think-library/src/builder/base/render/BuilderAttributesRenderer.php
new file mode 100644
index 000000000..870d5875e
--- /dev/null
+++ b/plugin/think-library/src/builder/base/render/BuilderAttributesRenderer.php
@@ -0,0 +1,20 @@
+ $attrs
+ */
+ public function render(array $attrs): string
+ {
+ return BuilderAttributes::make($attrs)->html();
+ }
+}
diff --git a/plugin/think-library/src/builder/base/render/BuilderCallbackNodeRenderer.php b/plugin/think-library/src/builder/base/render/BuilderCallbackNodeRenderer.php
new file mode 100644
index 000000000..3c5227cf8
--- /dev/null
+++ b/plugin/think-library/src/builder/base/render/BuilderCallbackNodeRenderer.php
@@ -0,0 +1,17 @@
+ $node
+ */
+ protected function renderElement(array $node, BuilderNodeRenderContext $context): string
+ {
+ $tag = trim(strval($node['tag'] ?? 'div')) ?: 'div';
+ $attrs = is_array($node['attrs'] ?? null) ? $node['attrs'] : [];
+ $children = is_array($node['children'] ?? null) ? $node['children'] : [];
+ return $this->wrapElement($tag, $attrs, $context->renderChildren($children), $context);
+ }
+
+ /**
+ * @param array $attrs
+ */
+ protected function wrapElement(string $tag, array $attrs, string $content, BuilderNodeRenderContext $context): string
+ {
+ $attrsHtml = count($attrs) > 0 ? ' ' . ltrim($context->attrs($attrs)) : '';
+ return sprintf('<%s%s>%s%s>', $tag, $attrsHtml, $content, $tag);
+ }
+}
diff --git a/plugin/think-library/src/builder/base/render/BuilderHtmlNodeRenderer.php b/plugin/think-library/src/builder/base/render/BuilderHtmlNodeRenderer.php
new file mode 100644
index 000000000..41d48e8c2
--- /dev/null
+++ b/plugin/think-library/src/builder/base/render/BuilderHtmlNodeRenderer.php
@@ -0,0 +1,22 @@
+ $node
+ */
+ protected function renderHtml(array $node): string
+ {
+ return BuilderLang::text(strval($node['html'] ?? ''));
+ }
+}
diff --git a/plugin/think-library/src/builder/base/render/BuilderNodeRenderContext.php b/plugin/think-library/src/builder/base/render/BuilderNodeRenderContext.php
new file mode 100644
index 000000000..c328363b3
--- /dev/null
+++ b/plugin/think-library/src/builder/base/render/BuilderNodeRenderContext.php
@@ -0,0 +1,32 @@
+>): string $contentRenderer
+ * @param callable(array): string $attrsRenderer
+ */
+ public function __construct(
+ private $contentRenderer,
+ callable $attrsRenderer,
+ ) {
+ parent::__construct($attrsRenderer);
+ }
+
+ /**
+ * @param array> $nodes
+ */
+ public function renderChildren(array $nodes): string
+ {
+ return ($this->contentRenderer)($nodes);
+ }
+}
diff --git a/plugin/think-library/src/builder/base/render/BuilderNodeRendererFactory.php b/plugin/think-library/src/builder/base/render/BuilderNodeRendererFactory.php
new file mode 100644
index 000000000..ea13d8e9b
--- /dev/null
+++ b/plugin/think-library/src/builder/base/render/BuilderNodeRendererFactory.php
@@ -0,0 +1,40 @@
+ $node
+ */
+ public function create(array $node): object
+ {
+ $class = $this->resolveRendererClass($node);
+ return new $class();
+ }
+
+ /**
+ * @param array $node
+ */
+ protected function resolveRendererClass(array $node): string
+ {
+ $type = strval($node['type'] ?? '');
+ return $this->rendererMap()[$type] ?? $this->fallbackRendererClass();
+ }
+
+ /**
+ * @return array
+ */
+ abstract protected function rendererMap(): array;
+
+ /**
+ * @return class-string
+ */
+ abstract protected function fallbackRendererClass(): string;
+}
diff --git a/plugin/think-library/src/builder/base/render/BuilderRenderPipeline.php b/plugin/think-library/src/builder/base/render/BuilderRenderPipeline.php
new file mode 100644
index 000000000..e86fd2a24
--- /dev/null
+++ b/plugin/think-library/src/builder/base/render/BuilderRenderPipeline.php
@@ -0,0 +1,41 @@
+> $nodes
+ */
+ protected function renderNodeContent(array $nodes, BuilderRenderState $state, string $separator = "\n"): string
+ {
+ $html = [];
+ $context = $state->nodeRenderContext();
+ $factory = $state->nodeRendererFactory();
+ foreach ($nodes as $node) {
+ if (!is_array($node)) {
+ continue;
+ }
+ $renderer = $factory->create($node);
+ if (!method_exists($renderer, 'render')) {
+ continue;
+ }
+ $item = $renderer->render($node, $context);
+ if ($item !== '') {
+ $html[] = $item;
+ }
+ }
+ return join($separator, $html);
+ }
+
+ protected function renderSchemaScript(BuilderRenderState $state, string $className): string
+ {
+ return (new JsonScriptRenderer())->render($state->schema(), $className);
+ }
+}
diff --git a/plugin/think-library/src/builder/base/render/BuilderRenderState.php b/plugin/think-library/src/builder/base/render/BuilderRenderState.php
new file mode 100644
index 000000000..083bc08d5
--- /dev/null
+++ b/plugin/think-library/src/builder/base/render/BuilderRenderState.php
@@ -0,0 +1,40 @@
+ $schema
+ */
+ public function __construct(
+ private array $schema,
+ private BuilderNodeRendererFactory $nodeRendererFactory,
+ private BuilderNodeRenderContext $nodeRenderContext,
+ ) {
+ }
+
+ /**
+ * @return array
+ */
+ public function schema(): array
+ {
+ return $this->schema;
+ }
+
+ public function nodeRendererFactory(): BuilderNodeRendererFactory
+ {
+ return $this->nodeRendererFactory;
+ }
+
+ public function nodeRenderContext(): BuilderNodeRenderContext
+ {
+ return $this->nodeRenderContext;
+ }
+}
diff --git a/plugin/think-library/src/builder/base/render/InlineScriptRenderer.php b/plugin/think-library/src/builder/base/render/InlineScriptRenderer.php
new file mode 100644
index 000000000..03c2d8148
--- /dev/null
+++ b/plugin/think-library/src/builder/base/render/InlineScriptRenderer.php
@@ -0,0 +1,28 @@
+ $scripts
+ */
+ public function render(array $scripts): string
+ {
+ if (count($scripts) < 1) {
+ return '';
+ }
+
+ $html = '';
+ foreach ($scripts as $script) {
+ $html .= "\n";
+ }
+ return $html;
+ }
+}
diff --git a/plugin/think-library/src/builder/base/render/JsonScriptRenderer.php b/plugin/think-library/src/builder/base/render/JsonScriptRenderer.php
new file mode 100644
index 000000000..22a9d201e
--- /dev/null
+++ b/plugin/think-library/src/builder/base/render/JsonScriptRenderer.php
@@ -0,0 +1,21 @@
+ $payload
+ */
+ public function render(array $payload, string $className): string
+ {
+ $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT);
+ return $json ? sprintf('', BuilderAttributes::escape($className), $json) : '';
+ }
+}
diff --git a/plugin/think-library/src/builder/form/FormActionBar.php b/plugin/think-library/src/builder/form/FormActionBar.php
new file mode 100644
index 000000000..81d9c5d1f
--- /dev/null
+++ b/plugin/think-library/src/builder/form/FormActionBar.php
@@ -0,0 +1,18 @@
+class('layui-form-item text-center');
+ }
+}
diff --git a/plugin/think-library/src/builder/form/FormActions.php b/plugin/think-library/src/builder/form/FormActions.php
new file mode 100644
index 000000000..6f3cbc73a
--- /dev/null
+++ b/plugin/think-library/src/builder/form/FormActions.php
@@ -0,0 +1,40 @@
+builder->addSubmitButtonToNode($this->parent, $name, $confirm, $attrs, $class);
+ return $this;
+ }
+
+ public function cancel(string $name = '取消编辑', string $confirm = '确定要取消编辑吗?', array $attrs = [], string $class = 'layui-btn-danger'): self
+ {
+ $this->builder->addCancelButtonToNode($this->parent, $name, $confirm, $attrs, $class);
+ return $this;
+ }
+
+ public function button(string $name, string $type = 'button', string $confirm = '', array $attrs = [], string $class = ''): self
+ {
+ $this->builder->addActionButtonToNode($this->parent, $name, $type, $confirm, $attrs, $class);
+ return $this;
+ }
+
+ public function html(string $html, array $schema = []): self
+ {
+ $this->builder->addButtonHtmlToNode($this->parent, $html, $schema);
+ return $this;
+ }
+}
diff --git a/plugin/think-library/src/builder/form/FormBlocks.php b/plugin/think-library/src/builder/form/FormBlocks.php
new file mode 100644
index 000000000..551b2c2c9
--- /dev/null
+++ b/plugin/think-library/src/builder/form/FormBlocks.php
@@ -0,0 +1,114 @@
+fieldset();
+ $node->class('layui-bg-gray')->attrs($attrs);
+ $node->node('legend')->html(sprintf('%s', self::escape($title)));
+ $callback($node);
+ return $node;
+ }
+
+ public static function row(FormNode $parent, callable $callback, string $class = 'layui-row layui-col-space15'): FormNode
+ {
+ $node = $parent->div()->class($class);
+ $callback($node);
+ return $node;
+ }
+
+ public static function col(FormNode $parent, string $class, callable $callback): FormNode
+ {
+ $node = $parent->div()->class($class);
+ $callback($node);
+ return $node;
+ }
+
+ public static function selectFilter(FormNode $parent, string $name, string $filter, array $groups, string $title, string $subtitle, string $remark): FormNode
+ {
+ $node = $parent->div()->class('layui-form-item');
+ $node->div()->class('help-label')->html(sprintf('%s%s', self::escape($title), self::escape($subtitle)));
+
+ $bar = $node->div()->class('mb10');
+ $options = [sprintf('', self::escape('全部插件'))];
+ foreach ($groups as $group) {
+ $code = self::escape(strval($group['code'] ?? ''));
+ $nameText = self::escape(strval($group['name'] ?? $code));
+ $options[] = sprintf('', $code, $nameText);
+ }
+ $bar->div()->class('layui-input-inline')->html(sprintf(
+ '',
+ self::escape($name),
+ self::escape($filter),
+ implode('', $options)
+ ));
+ $bar->div()->class('layui-form-mid color-desc')->html(self::escape($remark));
+ return $node;
+ }
+
+ public static function groupedTemplateChoices(
+ FormNode $parent,
+ array $groups,
+ string $type,
+ string $name,
+ string $groupClass,
+ string $dataAttribute,
+ string $selectedField
+ ): FormNode {
+ $node = $parent->div()->class('layui-textarea help-checks');
+
+ foreach ($groups as $group) {
+ $groupNode = $node->div()->class($groupClass)->data($dataAttribute, strval($group['code'] ?? ''));
+ $groupNode->div()->class('pl5 mb5')->html(sprintf(
+ '%s',
+ self::escape(strval($group['name'] ?? ''))
+ ));
+
+ foreach ((array)($group['items'] ?? []) as $item) {
+ $value = strval($item['code'] ?? $item['id'] ?? '');
+ $label = strval($item['name'] ?? $item['title'] ?? $value);
+ $pluginText = trim(strval($item['plugin_text'] ?? ''));
+ $extra = '';
+ if ($pluginText !== '' && intval($item['plugin_count'] ?? 0) > 1) {
+ $extra = sprintf('(%s)', self::escape($pluginText));
+ }
+
+ if ($type === 'radio') {
+ $checked = sprintf('{if isset($vo.%s) and $vo.%s eq \'%s\'} checked{/if}', $selectedField, $selectedField, addslashes($value));
+ $inputName = $name;
+ } else {
+ $checked = sprintf('{if in_array(\'%s\', $vo.%s)} checked{/if}', addslashes($value), $selectedField);
+ $inputName = $name . '[]';
+ }
+
+ $groupNode->html(sprintf(
+ '',
+ self::escape($type),
+ self::escape($inputName),
+ self::escape($value),
+ $checked,
+ self::escape($label),
+ $extra === '' ? '' : (' ' . $extra)
+ ));
+ }
+ }
+
+ return $node;
+ }
+
+ private static function escape(string $content): string
+ {
+ return htmlentities(BuilderLang::text($content), ENT_QUOTES, 'UTF-8');
+ }
+}
diff --git a/plugin/think-library/src/builder/form/FormBuilder.php b/plugin/think-library/src/builder/form/FormBuilder.php
new file mode 100644
index 000000000..3400eddd0
--- /dev/null
+++ b/plugin/think-library/src/builder/form/FormBuilder.php
@@ -0,0 +1,1874 @@
+ 'regex:^[1-9][0-9]{4,11}$',
+ 'ip' => 'ip',
+ 'url' => 'url',
+ 'phone' => 'mobile',
+ 'mobile' => 'mobile',
+ 'email' => 'email',
+ 'wechat' => 'regex:^[a-zA-Z]([-_a-zA-Z0-9]{5,19})+$',
+ 'cardid' => 'idCard',
+ 'userame' => 'regex:^[a-zA-Z0-9_-]{4,16}$',
+ 'username' => 'regex:^[a-zA-Z0-9_-]{4,16}$',
+ ];
+
+ /**
+ * 生成类型.
+ */
+ private string $type;
+
+ /**
+ * 显示方式.
+ */
+ private string $mode;
+
+ /**
+ * 表单预设.
+ */
+ private string $preset;
+
+ /**
+ * 当前控制器.
+ */
+ private Controller $class;
+
+ /**
+ * 提交地址
+ */
+ private string $action;
+
+ /**
+ * 表单变量.
+ */
+ private string $variable = '$vo';
+
+ /**
+ * 表单标题.
+ */
+ private string $title = '';
+
+ /**
+ * 表单项目 HTML.
+ */
+ private array $fields = [];
+
+ /**
+ * 表单内容节点.
+ */
+ private array $contentNodes = [];
+
+ /**
+ * 表单项目规则.
+ */
+ private array $items = [];
+
+ /**
+ * 按钮 HTML.
+ */
+ private array $buttons = [];
+
+ /**
+ * 按钮配置.
+ */
+ private array $buttonItems = [];
+
+ /**
+ * 标题栏按钮 HTML.
+ */
+ private array $headerButtons = [];
+
+ /**
+ * 标题栏按钮配置.
+ */
+ private array $headerButtonItems = [];
+
+ /**
+ * 附加脚本.
+ */
+ private array $scripts = [];
+
+ /**
+ * 手动附加的 _vali 兼容规则.
+ */
+ private array $rules = [];
+
+ /**
+ * 表单附加属性.
+ */
+ private array $formAttrs = [];
+
+ /**
+ * 表单主体附加属性.
+ */
+ private array $bodyAttrs = [];
+
+ /**
+ * 表单模块配置.
+ */
+ private array $formModules = [];
+
+ /**
+ * 当前布局根节点.
+ */
+ private ?FormLayout $layout = null;
+
+ /**
+ * 当前节点渲染上下文.
+ */
+ private ?FormRenderState $renderState = null;
+
+ /**
+ * 构造函数.
+ *
+ * @param string $type 页面类型 (form/add/edit 等)
+ * @param string $mode 页面模式 (modal/default 等)
+ * @param Controller $class 控制器实例
+ */
+ public function __construct(string $type, string $mode, Controller $class)
+ {
+ $this->type = $type;
+ $this->mode = $mode;
+ $this->preset = $mode === 'page' ? 'page-form' : 'dialog-form';
+ $this->class = $class;
+ }
+
+ /**
+ * 创建表单生成器实例.
+ *
+ * @param string $type 页面类型 (form=add/edit 等)
+ * @param string $mode 页面模式 (modal=弹窗,default=默认)
+ */
+ public static function make(string $type = 'form', string $mode = 'modal'): self
+ {
+ return Library::$sapp->invokeClass(static::class, ['type' => $type, 'mode' => $mode]);
+ }
+
+ /**
+ * 创建弹层表单.
+ */
+ public static function dialogForm(string $type = 'form'): self
+ {
+ return self::make($type, 'modal')->preset('dialog-form');
+ }
+
+ /**
+ * 创建整页表单.
+ */
+ public static function pageForm(string $type = 'form'): self
+ {
+ return self::make($type, 'page')->preset('page-form');
+ }
+
+ /**
+ * 定义表单结构.
+ * @param callable(FormLayout): void $callback
+ * @return $this
+ */
+ public function define(callable $callback): self
+ {
+ $layout = new FormLayout($this);
+ $this->layout = $layout;
+ $callback($layout);
+ $this->contentNodes = array_merge($this->contentNodes, $layout->exportChildren());
+ return $this;
+ }
+
+ /**
+ * 完成表单构建.
+ * @return $this
+ */
+ public function build(): self
+ {
+ return $this;
+ }
+
+ public function preset(string $preset): self
+ {
+ $preset = trim($preset);
+ if ($preset !== '') {
+ $this->preset = $preset;
+ }
+ return $this;
+ }
+
+ public function getPreset(): string
+ {
+ return $this->preset;
+ }
+
+ /**
+ * 设置表单提交地址
+ *
+ * @param string $url 提交地址
+ * @return $this
+ */
+ public function setAction(string $url): self
+ {
+ $this->action = $url;
+ return $this;
+ }
+
+ /**
+ * 设置表单变量名称.
+ *
+ * @param string $name 变量名称 (如 'vo', 'data' 等)
+ * @return $this
+ */
+ public function setVariable(string $name): self
+ {
+ $name = trim($name);
+ $this->variable = $name === '' ? '$vo' : ('$' . ltrim($name, '$'));
+ return $this;
+ }
+
+ /**
+ * 设置表单标题.
+ */
+ public function setTitle(string $title): self
+ {
+ $this->title = trim($title);
+ return $this;
+ }
+
+ /**
+ * 设置表单属性.
+ *
+ * @param array $attrs 表单属性数组
+ * @return $this
+ */
+ public function setFormAttrs(array $attrs): self
+ {
+ $merged = [];
+ foreach ($attrs as $name => $value) {
+ $name = is_string($name) ? trim($name) : '';
+ if ($name !== '') {
+ $merged[$name] = $value;
+ }
+ }
+ $this->formAttrs = BuilderAttributes::make($this->formAttrs)->merge($merged)->all();
+ return $this;
+ }
+
+ /**
+ * @return array
+ */
+ public function getFormAttrs(): array
+ {
+ return $this->formAttrs;
+ }
+
+ public function createFormAttributes(): BuilderAttributeBag
+ {
+ return new BuilderAttributeBag($this->layout, $this->formAttrs);
+ }
+
+ public function createBodyAttributes(): BuilderAttributeBag
+ {
+ return new BuilderAttributeBag($this->layout, $this->bodyAttrs);
+ }
+
+ public function attachFormAttributes(BuilderAttributeBag $attributes): BuilderAttributeBag
+ {
+ return $attributes->attach(fn (array $state): array => $this->replaceFormAttributes($state));
+ }
+
+ public function attachBodyAttributes(BuilderAttributeBag $attributes): BuilderAttributeBag
+ {
+ return $attributes->attach(fn (array $state): array => $this->replaceBodyAttributes($state));
+ }
+
+ /**
+ * @param array $state
+ * @return array
+ */
+ public function replaceFormAttributes(array $state): array
+ {
+ $this->formAttrs = is_array($state['attrs'] ?? null) ? BuilderAttributes::make($state['attrs'])->all() : [];
+ return ['attrs' => $this->formAttrs];
+ }
+
+ /**
+ * @param array $state
+ * @return array
+ */
+ public function replaceBodyAttributes(array $state): array
+ {
+ $this->bodyAttrs = is_array($state['attrs'] ?? null) ? BuilderAttributes::make($state['attrs'])->all() : [];
+ return ['attrs' => $this->bodyAttrs];
+ }
+
+ /**
+ * 设置表单属性.
+ *
+ * @param string $name 属性名称
+ * @param mixed $value 属性值
+ * @return $this
+ */
+ public function setFormAttr(string $name, mixed $value = null): self
+ {
+ $name = trim($name);
+ if ($name === '') {
+ return $this;
+ }
+ if ($name === 'class') {
+ $this->formAttrs = BuilderAttributes::make($this->formAttrs)->class(is_array($value) ? $value : strval($value))->all();
+ return $this;
+ }
+ $this->formAttrs = BuilderAttributes::make($this->formAttrs)->merge([$name => $value])->all();
+ return $this;
+ }
+
+ public function removeFormAttr(string $name): self
+ {
+ $name = trim($name);
+ if ($name === '') {
+ return $this;
+ }
+ if ($name === 'class') {
+ unset($this->formAttrs['class']);
+ return $this;
+ }
+ if (array_key_exists($name, $this->formAttrs)) {
+ unset($this->formAttrs[$name]);
+ }
+ return $this;
+ }
+
+ /**
+ * 添加表单样式类.
+ *
+ * @param array|string $class 样式类
+ * @return $this
+ */
+ public function addFormClass(array|string $class): self
+ {
+ $this->formAttrs = BuilderAttributes::make($this->formAttrs)->class($class)->all();
+ return $this;
+ }
+
+ public function removeFormClass(array|string $class): self
+ {
+ $this->formAttrs = BuilderAttributes::make($this->formAttrs)->removeClass($class)->all();
+ return $this;
+ }
+
+ /**
+ * 设置表单主体属性.
+ *
+ * @param array $attrs 表单主体属性数组
+ * @return $this
+ */
+ public function setBodyAttrs(array $attrs): self
+ {
+ $merged = [];
+ foreach ($attrs as $name => $value) {
+ $name = is_string($name) ? trim($name) : '';
+ if ($name !== '') {
+ $merged[$name] = $value;
+ }
+ }
+ $this->bodyAttrs = BuilderAttributes::make($this->bodyAttrs)->merge($merged)->all();
+ return $this;
+ }
+
+ /**
+ * @return array
+ */
+ public function getBodyAttrs(): array
+ {
+ return $this->bodyAttrs;
+ }
+
+ /**
+ * 设置表单主体属性.
+ *
+ * @param string $name 属性名称
+ * @param mixed $value 属性值
+ * @return $this
+ */
+ public function setBodyAttr(string $name, mixed $value = null): self
+ {
+ $name = trim($name);
+ if ($name === '') {
+ return $this;
+ }
+ if ($name === 'class') {
+ $this->bodyAttrs = BuilderAttributes::make($this->bodyAttrs)->class(is_array($value) ? $value : strval($value))->all();
+ return $this;
+ }
+ $this->bodyAttrs = BuilderAttributes::make($this->bodyAttrs)->merge([$name => $value])->all();
+ return $this;
+ }
+
+ public function removeBodyAttr(string $name): self
+ {
+ $name = trim($name);
+ if ($name === '') {
+ return $this;
+ }
+ if ($name === 'class') {
+ unset($this->bodyAttrs['class']);
+ return $this;
+ }
+ if (array_key_exists($name, $this->bodyAttrs)) {
+ unset($this->bodyAttrs[$name]);
+ }
+ return $this;
+ }
+
+ /**
+ * 添加表单主体样式类.
+ *
+ * @param array|string $class 样式类
+ * @return $this
+ */
+ public function addBodyClass(array|string $class): self
+ {
+ $this->bodyAttrs = BuilderAttributes::make($this->bodyAttrs)->class($class)->all();
+ return $this;
+ }
+
+ public function removeBodyClass(array|string $class): self
+ {
+ $this->bodyAttrs = BuilderAttributes::make($this->bodyAttrs)->removeClass($class)->all();
+ return $this;
+ }
+
+ /**
+ * 设置表单主体 data 属性.
+ *
+ * @param string $name 属性名称
+ * @param mixed $value 属性值
+ * @return $this
+ */
+ public function setBodyData(string $name, mixed $value = null): self
+ {
+ $name = trim($name);
+ if ($name !== '') {
+ $this->setBodyAttr('data-' . ltrim($name, '-'), $value);
+ }
+ return $this;
+ }
+
+ /**
+ * 设置表单 data 属性.
+ *
+ * @param string $name 属性名称
+ * @param mixed $value 属性值
+ * @return $this
+ */
+ public function setFormData(string $name, mixed $value = null): self
+ {
+ $name = trim($name);
+ if ($name !== '') {
+ $this->setFormAttr('data-' . ltrim($name, '-'), $value);
+ }
+ return $this;
+ }
+
+ public function removeBodyData(string $name): self
+ {
+ $name = trim($name);
+ if ($name !== '') {
+ $this->removeBodyAttr('data-' . ltrim($name, '-'));
+ }
+ return $this;
+ }
+
+ public function removeFormData(string $name): self
+ {
+ $name = trim($name);
+ if ($name !== '') {
+ $this->removeFormAttr('data-' . ltrim($name, '-'));
+ }
+ return $this;
+ }
+
+ /**
+ * 添加表单模块.
+ *
+ * @param string $name 模块名称
+ * @param array $config 模块配置
+ * @return $this
+ */
+ public function addFormModule(string $name, array $config = []): self
+ {
+ $this->attachFormModule($this->createFormModule($name, $config));
+ return $this;
+ }
+
+ public function createFormModule(string $name, array $config = []): BuilderModule
+ {
+ return new BuilderModule($name, $config, $this->layout);
+ }
+
+ public function attachFormModule(BuilderModule $module): BuilderModule
+ {
+ $normalized = $this->normalizeFormModule($module->export());
+ if ($normalized['name'] === '') {
+ return $module;
+ }
+ $index = count($this->formModules);
+ $this->formModules[$index] = $normalized;
+ return $module->attach($index, $normalized, fn (int $index, array $module): array => $this->replaceFormModule($index, $module));
+ }
+
+ /**
+ * @param array $module
+ * @return array
+ */
+ public function replaceFormModule(int $index, array $module): array
+ {
+ $normalized = $this->normalizeFormModule($module);
+ if ($normalized['name'] !== '') {
+ $this->formModules[$index] = $normalized;
+ }
+ return $this->formModules[$index] ?? $normalized;
+ }
+
+ /**
+ * 添加页面脚本.
+ *
+ * @param string $script JavaScript 脚本代码
+ * @return $this
+ */
+ public function addScript(string $script): self
+ {
+ $script = trim($script);
+ if ($script !== '') {
+ $this->scripts[] = $script;
+ }
+ return $this;
+ }
+
+ /**
+ * 使用收集到的规则验证请求数据.
+ *
+ * @param array|string $input 输入数据 (默认为空,自动从请求获取)
+ * @param null|callable $callable 自定义验证回调
+ * @return array 验证后的数据
+ * @throws Exception
+ */
+ public function validate(array|string $input = '', ?callable $callable = null): array
+ {
+ return ValidateHelper::instance()->init($this->getRequestRules(), $input, $callable);
+ }
+
+ /**
+ * 获取可直接用于 _vali 的请求规则.
+ *
+ * @return array 验证规则数组
+ */
+ public function getRequestRules(): array
+ {
+ $rules = [];
+ foreach ($this->getFields() as $field) {
+ $rules[sprintf('%s.default', $field['name'])] = $this->resolveFieldDefault($field);
+ }
+ return array_merge($rules, $this->getValidateRules());
+ }
+
+ /**
+ * 获取 _vali 兼容规则.
+ */
+ public function getValidateRules(): array
+ {
+ $rules = [];
+ foreach ($this->getFields() as $field) {
+ foreach ($this->buildFieldRules($field) as $rule => $message) {
+ $rules[$rule] = $message;
+ }
+ }
+ return array_merge($rules, $this->rules);
+ }
+
+ /**
+ * 批量添加 _vali 验证规则.
+ * @return $this
+ */
+ public function addValidateRules(array $rules): self
+ {
+ foreach ($rules as $key => $value) {
+ if (is_string($key)) {
+ $this->rules[$key] = $value;
+ }
+ }
+ return $this;
+ }
+
+ /**
+ * 动态添加多个字段.
+ * @return $this
+ */
+ public function addFields(array $fields): self
+ {
+ foreach ($fields as $field) {
+ is_array($field) && $this->addField($field);
+ }
+ return $this;
+ }
+
+ /**
+ * 向指定节点追加字段.
+ * @param array $field
+ */
+ public function addFieldToNode(FormNode $parent, array $field): FormField
+ {
+ $field = $this->normalizeField($field);
+ $this->collectField($field);
+ if (!$this->layout instanceof FormLayout) {
+ $this->fields[] = $this->renderField($field);
+ }
+ $node = $this->createFieldNode($parent, $field);
+ $parent->append($node);
+ return $node;
+ }
+
+ /**
+ * 动态添加单个字段.
+ * @return $this
+ */
+ public function addField(array $field): self
+ {
+ if ($this->layout instanceof FormLayout) {
+ $this->addFieldToNode($this->layout, $field);
+ return $this;
+ }
+ $field = $this->normalizeField($field);
+ $this->collectField($field);
+ $this->fields[] = $this->renderField($field);
+ return $this;
+ }
+
+ /**
+ * 添加单条 _vali 验证规则.
+ * @return $this
+ */
+ public function addValidateRule(string $name, string $rule, string $message): self
+ {
+ $name = trim($name);
+ $rule = trim($rule);
+ if ($name !== '' && $rule !== '') {
+ $this->rules["{$name}.{$rule}"] = $message;
+ }
+ return $this;
+ }
+
+ /**
+ * 创建文本输入框架.
+ * @param string $name 字段名称
+ * @param string $title 字段标题
+ * @param string $substr 字段子标题
+ * @param mixed $remark
+ * @param array $attrs 附加属性
+ * @return $this
+ */
+ public function addTextArea(string $name, string $title, string $substr = '', bool $required = false, $remark = '', array $attrs = []): self
+ {
+ return $this->addField([
+ 'type' => 'textarea',
+ 'name' => $name,
+ 'title' => $title,
+ 'subtitle' => $substr,
+ 'required' => $required,
+ 'remark' => (string)$remark,
+ 'attrs' => $attrs,
+ ]);
+ }
+
+ /**
+ * 创建密钥输入框.
+ * @param string $name 字段名称
+ * @param string $title 字段标题
+ * @param string $substr 字段子标题
+ * @param string $remark 字段备注
+ * @param bool $required 是否必填
+ * @param ?string $pattern 验证规则
+ * @param array $attrs 附加属性
+ * @return $this
+ */
+ public function addPassInput(string $name, string $title, string $substr = '', bool $required = false, string $remark = '', ?string $pattern = null, array $attrs = []): self
+ {
+ $attrs['type'] = 'password';
+ return $this->addTextInput($name, $title, $substr, $required, $remark, $pattern, $attrs);
+ }
+
+ /**
+ * 创建 Text 输入.
+ * @param string $name 字段名称
+ * @param string $title 字段标题
+ * @param string $substr 字段子标题
+ * @param string $remark 字段备注
+ * @param bool $required 是否必填
+ * @param ?string $pattern 验证规则
+ * @param array $attrs 附加属性
+ * @return $this
+ */
+ public function addTextInput(string $name, string $title, string $substr = '', bool $required = false, string $remark = '', ?string $pattern = null, array $attrs = []): self
+ {
+ return $this->addField([
+ 'type' => $attrs['type'] ?? 'text',
+ 'name' => $name,
+ 'title' => $title,
+ 'subtitle' => $substr,
+ 'required' => $required,
+ 'remark' => $remark,
+ 'pattern' => $pattern,
+ 'attrs' => $attrs,
+ ]);
+ }
+
+ /**
+ * 添加取消按钮.
+ * @param string $name 按钮名称
+ * @param string $confirm 确认提示
+ * @return $this
+ */
+ public function addCancelButton(string $name = '取消编辑', string $confirm = '确定要取消编辑吗?', array $attrs = [], string $class = 'layui-btn-danger'): self
+ {
+ return $this->addButton($name, $confirm, 'button', $class, array_merge($this->buildCancelAttrs(), $attrs));
+ }
+
+ /**
+ * 向指定动作条追加取消按钮.
+ */
+ public function addCancelButtonToNode(FormActionBar $parent, string $name = '取消编辑', string $confirm = '确定要取消编辑吗?', array $attrs = [], string $class = 'layui-btn-danger'): self
+ {
+ return $this->addButtonToNode($parent, $name, $confirm, 'button', $class, array_merge($this->buildCancelAttrs(), $attrs));
+ }
+
+ /**
+ * 添加提交按钮.
+ * @param string $name 按钮名称
+ * @param string $confirm 确认提示
+ * @return $this
+ */
+ public function addSubmitButton(string $name = '保存数据', string $confirm = '', array $attrs = [], string $class = ''): self
+ {
+ return $this->addButton($name, $confirm, 'submit', $class, $attrs);
+ }
+
+ /**
+ * 向指定动作条追加提交按钮.
+ */
+ public function addSubmitButtonToNode(FormActionBar $parent, string $name = '保存数据', string $confirm = '', array $attrs = [], string $class = ''): self
+ {
+ return $this->addButtonToNode($parent, $name, $confirm, 'submit', $class, $attrs);
+ }
+
+ /**
+ * 添加通用动作按钮.
+ * @return $this
+ */
+ public function addActionButton(string $name, string $type = 'button', string $confirm = '', array $attrs = [], string $class = ''): self
+ {
+ return $this->addButton($name, $confirm, $type, $class, $attrs);
+ }
+
+ /**
+ * 向指定动作条追加通用按钮.
+ */
+ public function addActionButtonToNode(FormActionBar $parent, string $name, string $type = 'button', string $confirm = '', array $attrs = [], string $class = ''): self
+ {
+ return $this->addButtonToNode($parent, $name, $confirm, $type, $class, $attrs);
+ }
+
+ /**
+ * 添加上传单图字段.
+ * @param string $name 字段名称
+ * @param string $title 字段标题
+ * @param string $substr 字段子标题
+ * @param bool $required 必填字段
+ * @param array $attrs 附加属性
+ * @return $this
+ */
+ public function addUploadOneImage(string $name, string $title, string $substr = '', bool $required = false, array $attrs = []): self
+ {
+ return $this->addField([
+ 'type' => 'image',
+ 'name' => $name,
+ 'title' => $title,
+ 'subtitle' => $substr,
+ 'required' => $required,
+ 'attrs' => $attrs,
+ ]);
+ }
+
+ /**
+ * 添加上传视频字段.
+ * @param string $name 字段名称
+ * @param string $title 字段标题
+ * @param string $substr 字段子标题
+ * @param bool $required 必填字段
+ * @param array $attrs 附加属性
+ * @return $this
+ */
+ public function addUploadOneVideo(string $name, string $title, string $substr = '', bool $required = false, array $attrs = []): self
+ {
+ return $this->addField([
+ 'type' => 'video',
+ 'name' => $name,
+ 'title' => $title,
+ 'subtitle' => $substr,
+ 'required' => $required,
+ 'attrs' => $attrs,
+ ]);
+ }
+
+ /**
+ * 创建上传多图字段.
+ * @param string $name 字段名称
+ * @param string $title 字段标题
+ * @param string $substr 字段子标题
+ * @param bool $required 必填字段
+ * @param array $attrs 附加属性
+ * @return $this
+ */
+ public function addUploadMulImage(string $name, string $title, string $substr = '', bool $required = false, array $attrs = []): self
+ {
+ return $this->addField([
+ 'type' => 'images',
+ 'name' => $name,
+ 'title' => $title,
+ 'subtitle' => $substr,
+ 'required' => $required,
+ 'attrs' => $attrs,
+ ]);
+ }
+
+ /**
+ * 添加单选框架字段.
+ * @param string $name 字段名称
+ * @param string $title 字段标题
+ * @param string $substr 字段子标题
+ * @param string $vname 变量名称
+ * @param bool $required 是否必选
+ * @param array $attrs 附加属性
+ * @return $this
+ */
+ public function addRadioInput(string $name, string $title, string $substr, string $vname, bool $required = false, array $attrs = []): self
+ {
+ return $this->addCheckInput($name, $title, $substr, $vname, $required, $attrs, 'radio');
+ }
+
+ /**
+ * 创建复选框字段.
+ * @param string $name 字段名称
+ * @param string $title 字段标题
+ * @param string $substr 字段子标题
+ * @param string $vname 变量名称
+ * @param bool $required 是否必选
+ * @param array $attrs 附加属性
+ * @return $this
+ */
+ public function addCheckInput(string $name, string $title, string $substr, string $vname, bool $required = false, array $attrs = [], string $type = 'checkbox'): self
+ {
+ return $this->addField([
+ 'type' => $type,
+ 'name' => $name,
+ 'title' => $title,
+ 'subtitle' => $substr,
+ 'required' => $required,
+ 'attrs' => $attrs,
+ 'vname' => $vname,
+ ]);
+ }
+
+ /**
+ * 添加下拉选择字段.
+ * @param string $name 字段名称
+ * @param string $title 字段标题
+ * @param string $substr 字段子标题
+ * @param bool $required 是否必填
+ * @param string $remark 字段备注
+ * @param array $options 静态选项
+ * @param string $vname 变量名称
+ * @param array $attrs 附加属性
+ * @return $this
+ */
+ public function addSelectInput(string $name, string $title, string $substr = '', bool $required = false, string $remark = '', array $options = [], string $vname = '', array $attrs = []): self
+ {
+ return $this->addField([
+ 'type' => 'select',
+ 'name' => $name,
+ 'title' => $title,
+ 'subtitle' => $substr,
+ 'required' => $required,
+ 'remark' => $remark,
+ 'options' => $options,
+ 'vname' => $vname,
+ 'attrs' => $attrs,
+ ]);
+ }
+
+ /**
+ * 显示模板内容.
+ * @return mixed
+ */
+ public function fetch(array $vars = [])
+ {
+ $html = '';
+ $type = "{$this->type}.{$this->mode}";
+ if ($type === 'form.page') {
+ $html = $this->_buildFormPage();
+ } elseif ($type === 'form.modal') {
+ $html = $this->_buildFormModal();
+ }
+ $vars['formBuilder'] = $vars['formBuilder'] ?? $this;
+ $vars['formSchema'] = $vars['formSchema'] ?? $this->toArray();
+ $vars['formRules'] = $vars['formRules'] ?? $this->getValidateRules();
+ $vars['staticRoot'] = strval($vars['staticRoot'] ?? AppService::uri('static'));
+ foreach (get_object_vars($this->class) as $k => $v) {
+ $vars[$k] = $v;
+ }
+ $html = $this->renderRuntimeTemplate($html, $vars);
+ throw new HttpResponseException(display($html, $vars));
+ }
+
+ /**
+ * 添加按钮 HTML.
+ * @return $this
+ */
+ public function addButtonHtml(string $html, array $schema = []): self
+ {
+ $this->buttons[] = $html;
+ $this->buttonItems[] = array_merge(['type' => 'html', 'html' => $html], $schema);
+ if ($this->layout instanceof FormLayout) {
+ $bar = $this->layout->actionBar();
+ $bar->append(new FormButton($this, array_merge(['type' => 'html', 'html' => $html], $schema), $html));
+ }
+ return $this;
+ }
+
+ /**
+ * 添加标题栏按钮 HTML.
+ */
+ public function addHeaderButtonHtml(string $html, array $schema = []): self
+ {
+ $this->headerButtons[] = $html;
+ $this->headerButtonItems[] = array_merge(['type' => 'html', 'html' => $html], $schema);
+ return $this;
+ }
+
+ /**
+ * 向指定动作条追加按钮 HTML.
+ */
+ public function addButtonHtmlToNode(FormActionBar $parent, string $html, array $schema = []): self
+ {
+ $button = array_merge(['type' => 'html', 'html' => $html], $schema);
+ $this->buttons[] = $html;
+ $this->buttonItems[] = $button;
+ $parent->append(new FormButton($this, $button, $html));
+ return $this;
+ }
+
+ /**
+ * 获取构建数据.
+ */
+ public function toArray(): array
+ {
+ $content = $this->currentContentNodes();
+ return [
+ 'type' => $this->type,
+ 'mode' => $this->mode,
+ 'preset' => $this->preset,
+ 'title' => BuilderLang::text($this->title),
+ 'action' => $this->action ?? '',
+ 'variable' => $this->variable,
+ 'attrs' => $this->buildFormAttrs(false),
+ 'body_attrs' => $this->buildBodyAttrs(),
+ 'modules' => $this->formModules,
+ 'content' => $this->normalizeSchemaValue($content),
+ 'fields' => $this->getFields(),
+ 'buttons' => $this->getButtons(),
+ 'header_buttons' => $this->headerButtonItems,
+ 'rules' => $this->getValidateRules(),
+ ];
+ }
+
+ /**
+ * 获取字段规则配置.
+ */
+ public function getFields(): array
+ {
+ if ($this->layout instanceof FormLayout) {
+ return $this->extractFieldsFromNodes($this->currentContentNodes());
+ }
+ return array_values($this->items);
+ }
+
+ /**
+ * @return array>
+ */
+ public function getButtons(): array
+ {
+ if ($this->layout instanceof FormLayout) {
+ return $this->extractButtonsFromNodes($this->currentContentNodes());
+ }
+ return $this->buttonItems;
+ }
+
+ /**
+ * 添加标题栏按钮.
+ */
+ public function addHeaderButton(string $name, string $type = 'button', string $confirm = '', array $attrs = [], string $class = ''): self
+ {
+ $renderer = new BuilderAttributesRenderer();
+ $name = BuilderLang::text($name);
+ $confirm = BuilderLang::text($confirm);
+ $attrs['type'] = $type;
+ if ($confirm !== '') {
+ $attrs['data-confirm'] = $confirm;
+ }
+ $attrs = BuilderLang::attrs(BuilderAttributes::make($attrs)->class(trim("layui-btn {$class}"))->all());
+ $html = sprintf('', $renderer->render($attrs), $name);
+ $button = [
+ 'name' => $name,
+ 'confirm' => $confirm,
+ 'type' => $type,
+ 'class' => trim("layui-btn {$class}"),
+ 'attrs' => $attrs,
+ ];
+ $this->headerButtons[] = $html;
+ $this->headerButtonItems[] = $button;
+ return $this;
+ }
+
+ /**
+ * 添加表单按钮.
+ * @param string $name 按钮名称
+ * @param string $confirm 确认提示
+ * @param string $type 按钮类型
+ * @param string $class 按钮样式
+ * @param array $attrs 附加属性
+ * @return $this
+ */
+ protected function addButton(string $name, string $confirm, string $type, string $class = '', array $attrs = []): self
+ {
+ $renderer = new BuilderAttributesRenderer();
+ $name = BuilderLang::text($name);
+ $confirm = BuilderLang::text($confirm);
+ $attrs['type'] = $type;
+ if ($confirm !== '') {
+ $attrs['data-confirm'] = $confirm;
+ }
+ $attrs = BuilderLang::attrs(BuilderAttributes::make($attrs)->class(trim("layui-btn {$class}"))->all());
+ $html = sprintf('', $renderer->render($attrs), $name);
+ $button = [
+ 'name' => $name,
+ 'confirm' => $confirm,
+ 'type' => $type,
+ 'class' => trim("layui-btn {$class}"),
+ 'attrs' => $attrs,
+ ];
+ $this->buttons[] = $html;
+ $this->buttonItems[] = $button;
+ if ($this->layout instanceof FormLayout) {
+ $bar = $this->layout->actionBar();
+ $bar->append(new FormButton($this, $button, $html));
+ }
+ return $this;
+ }
+
+ /**
+ * 向指定动作条追加按钮.
+ */
+ protected function addButtonToNode(FormActionBar $parent, string $name, string $confirm, string $type, string $class = '', array $attrs = []): self
+ {
+ $renderer = new BuilderAttributesRenderer();
+ $name = BuilderLang::text($name);
+ $confirm = BuilderLang::text($confirm);
+ $attrs['type'] = $type;
+ if ($confirm !== '') {
+ $attrs['data-confirm'] = $confirm;
+ }
+ $attrs = BuilderLang::attrs(BuilderAttributes::make($attrs)->class(trim("layui-btn {$class}"))->all());
+ $html = sprintf('', $renderer->render($attrs), $name);
+ $button = [
+ 'name' => $name,
+ 'confirm' => $confirm,
+ 'type' => $type,
+ 'class' => trim("layui-btn {$class}"),
+ 'attrs' => $attrs,
+ ];
+ $this->buttons[] = $html;
+ $this->buttonItems[] = $button;
+ $parent->append(new FormButton($this, $button, $html));
+ return $this;
+ }
+
+ /**
+ * 兼容旧版受保护输入方法.
+ * @param string $name 字段名称
+ * @param string $title 字段标题
+ * @param string $subtitle 字段子标题
+ * @param string $remark 字段备注
+ * @param array $attrs 附加属性
+ * @return $this
+ */
+ protected function addInput(string $name, string $title, string $subtitle = '', string $remark = '', array $attrs = []): self
+ {
+ return $this->addField([
+ 'type' => $attrs['type'] ?? 'text',
+ 'name' => $name,
+ 'title' => $title,
+ 'subtitle' => $subtitle,
+ 'remark' => $remark,
+ 'required' => !empty($attrs['required']),
+ 'pattern' => $attrs['pattern'] ?? null,
+ 'attrs' => $attrs,
+ ]);
+ }
+
+ /**
+ * 创建字段节点对象.
+ * @param array $field
+ */
+ private function createFieldNode(FormNode $parent, array $field): FormField
+ {
+ return match ($field['type']) {
+ 'text', 'password', 'textarea' => new FormTextField($this, $parent, $field),
+ 'select' => new FormSelectField($this, $parent, $field),
+ 'checkbox', 'radio' => new FormChoiceField($this, $parent, $field),
+ 'image', 'video', 'images' => new FormUploadField($this, $parent, $field),
+ default => new FormField($this, $parent, $field),
+ };
+ }
+
+ /**
+ * @param array $module
+ * @return array
+ */
+ private function normalizeFormModule(array $module): array
+ {
+ return [
+ 'name' => trim(strval($module['name'] ?? '')),
+ 'config' => is_array($module['config'] ?? null) ? $module['config'] : [],
+ ];
+ }
+
+ /**
+ * 解析字段默认值,确保可回填全部输入字段.
+ */
+ private function resolveFieldDefault(array $field): array|string
+ {
+ if (array_key_exists('default', $field) && $field['default'] !== null) {
+ $default = $field['default'];
+ return $field['type'] === 'checkbox'
+ ? array_values(array_map('strval', is_array($default) ? $default : str2arr(strval($default))))
+ : strval($default);
+ }
+ return $field['type'] === 'checkbox' ? [] : '';
+ }
+
+ /**
+ * 规范字段配置.
+ */
+ private function normalizeField(array $field): array
+ {
+ $field = array_merge([
+ 'type' => 'text',
+ 'name' => '',
+ 'title' => '',
+ 'subtitle' => '',
+ 'substr' => '',
+ 'remark' => '',
+ 'required' => false,
+ 'pattern' => null,
+ 'attrs' => [],
+ 'rules' => [],
+ 'vname' => '',
+ 'options' => [],
+ 'default' => null,
+ 'upload' => [],
+ 'parts' => [],
+ ], $field);
+ if ($field['subtitle'] === '' && $field['substr'] !== '') {
+ $field['subtitle'] = (string)$field['substr'];
+ }
+ $field['type'] = $this->normalizeType((string)$field['type']);
+ $field['name'] = trim((string)$field['name']);
+ $field['title'] = trim(BuilderLang::text((string)$field['title']));
+ $field['subtitle'] = BuilderLang::text((string)$field['subtitle']);
+ $field['remark'] = BuilderLang::text((string)$field['remark']);
+ $field['required'] = !empty($field['required']);
+ $field['pattern'] = $field['pattern'] === null || $field['pattern'] === '' ? null : (string)$field['pattern'];
+ $field['attrs'] = BuilderLang::attrs(is_array($field['attrs']) ? $field['attrs'] : []);
+ $field['rules'] = is_array($field['rules']) ? $field['rules'] : [];
+ $field['vname'] = trim((string)$field['vname']);
+ $field['options'] = BuilderLang::options(is_array($field['options']) ? $field['options'] : []);
+ if ($field['default'] === null && array_key_exists('value', $field['attrs'])) {
+ $field['default'] = $field['attrs']['value'];
+ unset($field['attrs']['value']);
+ }
+ $field['upload'] = is_array($field['upload']) ? $field['upload'] : [];
+ $field['parts'] = is_array($field['parts']) ? $field['parts'] : [];
+ if ($field['name'] === '' || $field['title'] === '') {
+ throw new \InvalidArgumentException('FormBuilder 字段 name 与 title 不能为空');
+ }
+
+ $field['attrs'] = $this->normalizeFieldAttrs($field);
+ $field['validate'] = [
+ 'messages' => array_filter([
+ 'required' => $field['attrs']['required-error'] ?? '',
+ 'pattern' => $field['attrs']['pattern-error'] ?? '',
+ ]),
+ 'portable' => array_filter($this->buildFieldPortableRules($field)),
+ ];
+
+ return $field;
+ }
+
+ /**
+ * 规范组件类型.
+ */
+ private function normalizeType(string $type): string
+ {
+ $type = strtolower(trim($type));
+ return match ($type) {
+ '', 'input' => 'text',
+ 'pass' => 'password',
+ 'upload-image', 'upload-one-image' => 'image',
+ 'upload-video', 'upload-one-video' => 'video',
+ 'upload-images', 'upload-mul-image' => 'images',
+ default => $type,
+ };
+ }
+
+ /**
+ * 解析 pattern 对应的后端规则.
+ */
+ private function resolvePatternRule(string $pattern): ?string
+ {
+ $pattern = trim($pattern);
+ if ($pattern === '') {
+ return null;
+ }
+ if (isset(self::PATTERN_RULES[$pattern])) {
+ return self::PATTERN_RULES[$pattern];
+ }
+ if (str_contains($pattern, '|')) {
+ return null;
+ }
+
+ $regex = preg_replace('~(? $field
+ * @return array
+ */
+ private function buildFieldRules(array $field): array
+ {
+ $field = $this->normalizeField($field);
+ return $this->buildFieldPortableRules($field);
+ }
+
+ /**
+ * 根据已规范化字段构建 _vali 规则.
+ * @param array $field
+ * @return array
+ */
+ private function buildFieldPortableRules(array $field): array
+ {
+ $rules = [];
+ if (!empty($field['required'])) {
+ $rules["{$field['name']}.require"] = strval($field['attrs']['required-error'] ?? BuilderLang::format('%s不能为空!', [$field['title']]));
+ }
+ if (is_string($field['pattern']) && ($rule = $this->resolvePatternRule($field['pattern']))) {
+ $rules["{$field['name']}.{$rule}"] = strval($field['attrs']['pattern-error'] ?? BuilderLang::format('%s格式错误!', [$field['title']]));
+ }
+ foreach ($field['rules'] as $rule => $message) {
+ if (is_string($rule) && $rule !== '') {
+ $rules["{$field['name']}.{$rule}"] = strval($message);
+ }
+ }
+ return $rules;
+ }
+
+ /**
+ * 收集字段配置.
+ */
+ private function collectField(array $field): void
+ {
+ $this->items[] = [
+ 'name' => $field['name'],
+ 'type' => $field['type'],
+ 'title' => $field['title'],
+ 'subtitle' => $field['subtitle'],
+ 'remark' => $field['remark'],
+ 'required' => $field['required'],
+ 'pattern' => $field['pattern'],
+ 'attrs' => $field['attrs'],
+ 'vname' => $field['vname'],
+ 'options' => $field['options'],
+ 'default' => $field['default'],
+ 'upload' => $field['upload'],
+ 'parts' => $field['parts'],
+ 'validate' => $field['validate'],
+ ];
+ }
+
+ /**
+ * 渲染字段 HTML.
+ */
+ private function renderField(array $field): string
+ {
+ $field = $this->normalizeField($field);
+ return $this->renderPipeline()->renderField($field, $this->variable);
+ }
+
+ /**
+ * 从内容节点提取字段数组.
+ * @param array> $nodes
+ * @return array>
+ */
+ private function extractFieldsFromNodes(array $nodes): array
+ {
+ $fields = [];
+ foreach ($nodes as $node) {
+ if (!is_array($node)) {
+ continue;
+ }
+ if (($node['type'] ?? '') === 'field' && is_array($node['field'] ?? null)) {
+ $field = $node['field'];
+ if (is_array($node['attrs'] ?? null) && count($node['attrs']) > 0) {
+ $field['container_attrs'] = $node['attrs'];
+ }
+ if (is_array($node['modules'] ?? null) && count($node['modules']) > 0) {
+ $field['container_modules'] = $node['modules'];
+ }
+ if (is_array($node['parts'] ?? null) && count($node['parts']) > 0) {
+ $field['parts'] = $node['parts'];
+ }
+ $fields[] = $this->normalizeField($field);
+ continue;
+ }
+ if (is_array($node['children'] ?? null)) {
+ $fields = array_merge($fields, $this->extractFieldsFromNodes($node['children']));
+ }
+ }
+ return $fields;
+ }
+
+ /**
+ * @param array> $nodes
+ * @return array>
+ */
+ private function extractButtonsFromNodes(array $nodes): array
+ {
+ $buttons = [];
+ foreach ($nodes as $node) {
+ if (!is_array($node)) {
+ continue;
+ }
+ if (($node['type'] ?? '') === 'button' && is_array($node['button'] ?? null)) {
+ $buttons[] = $node['button'];
+ continue;
+ }
+ if (is_array($node['children'] ?? null)) {
+ $buttons = array_merge($buttons, $this->extractButtonsFromNodes($node['children']));
+ }
+ }
+ return $buttons;
+ }
+
+ /**
+ * 生成页面表单模板
+ */
+ private function _buildFormPage(): string
+ {
+ return $this->buildFormShell();
+ }
+
+ /**
+ * 生成弹层表单模板
+ */
+ private function _buildFormModal(): string
+ {
+ return $this->buildFormShell();
+ }
+
+ /**
+ * 渲染表单主体结构.
+ */
+ private function buildFormShell(): string
+ {
+ $content = $this->currentContentNodes();
+ $fields = $this->layout instanceof FormLayout ? [] : $this->fields;
+ $buttons = $this->layout instanceof FormLayout ? [] : $this->buttons;
+ $this->renderState = $this->createRenderState($this->toArray());
+ try {
+ return $this->renderPipeline()->renderShell(
+ $this->buildFormAttrs(),
+ $this->buildBodyAttrs(),
+ $content,
+ $fields,
+ $this->headerButtons,
+ $buttons,
+ $this->renderState,
+ $this->scripts
+ );
+ } finally {
+ $this->renderState = null;
+ }
+ }
+
+ /**
+ * 渲染内容节点.
+ * @param array> $nodes
+ */
+ private function renderContentNodes(array $nodes): string
+ {
+ return $this->renderPipeline()->renderContentNodes($nodes, $this->currentRenderState());
+ }
+
+ /**
+ * @param mixed $value
+ * @return mixed
+ */
+ private function normalizeSchemaValue($value)
+ {
+ if (is_array($value)) {
+ if (array_is_list($value)) {
+ $result = [];
+ foreach ($value as $key => $item) {
+ $result[$key] = $this->normalizeSchemaValue($item);
+ }
+ return $result;
+ }
+
+ $result = [];
+ foreach ($value as $key => $item) {
+ if ($key === 'attrs' && is_array($item)) {
+ $result[$key] = BuilderLang::attrs($item);
+ continue;
+ }
+ if (in_array(strval($key), ['title', 'label', 'legend', 'placeholder', 'remark', 'subtitle', 'confirm', 'html'], true) && is_string($item)) {
+ $result[$key] = BuilderLang::text($item);
+ continue;
+ }
+ $result[$key] = $this->normalizeSchemaValue($item);
+ }
+ return $result;
+ }
+
+ return $value;
+ }
+
+ /**
+ * @return array>
+ */
+ private function currentContentNodes(): array
+ {
+ return $this->layout instanceof FormLayout ? $this->layout->exportChildren() : $this->contentNodes;
+ }
+
+ /**
+ * 获取提交地址.
+ */
+ private function resolveAction(): string
+ {
+ if (isset($this->action) && $this->action !== '') {
+ return $this->action;
+ }
+ try {
+ return url()->build();
+ } catch (\Throwable) {
+ return '';
+ }
+ }
+
+ /**
+ * 构建表单根节点属性.
+ */
+ private function buildFormAttrs(bool $resolveAction = true): array
+ {
+ $class = $this->mode === 'page' ? 'layui-form' : 'layui-form layui-card';
+ return BuilderAttributes::make([
+ 'action' => $resolveAction ? $this->resolveAction() : ($this->action ?? ''),
+ 'method' => 'post',
+ 'data-auto' => 'true',
+ 'data-builder-scope' => 'form',
+ 'data-builder-mode' => $this->mode,
+ 'data-builder-preset' => $this->preset,
+ ])->merge($this->formAttrs)
+ ->modules($this->formModules)
+ ->class($class)
+ ->all();
+ }
+
+ /**
+ * 构建表单主体属性.
+ */
+ private function buildBodyAttrs(): array
+ {
+ $class = $this->mode === 'page' ? 'pa40' : 'layui-card-body pl40 pr40 pt20 pb20';
+ return BuilderAttributes::make($this->bodyAttrs)
+ ->class($class)
+ ->all();
+ }
+
+ /**
+ * 构建取消动作属性.
+ * @return array
+ */
+ private function buildCancelAttrs(): array
+ {
+ if ($this->mode === 'page') {
+ return ['data-target-backup' => null];
+ }
+
+ return ['data-close' => null];
+ }
+
+ /**
+ * @param array $schema
+ */
+ private function createRenderState(array $schema): FormRenderState
+ {
+ $attrsRenderer = new BuilderAttributesRenderer();
+ return new FormRenderState(
+ $schema,
+ new FormNodeRendererFactory(),
+ new FormNodeRenderContext(
+ $this->variable,
+ fn (array $nodes): string => $this->renderContentNodes($nodes),
+ [$attrsRenderer, 'render'],
+ fn (array $field): string => $this->renderField($field)
+ )
+ );
+ }
+
+ private function currentRenderState(): FormRenderState
+ {
+ return $this->renderState ?? $this->createRenderState($this->toArray());
+ }
+
+ private function renderPipeline(): FormRenderPipeline
+ {
+ return new FormRenderPipeline();
+ }
+
+ /**
+ * 解析 Builder 运行时模板变量,避免依赖外层视图二次编译。
+ * @param array $vars
+ */
+ private function renderRuntimeTemplate(string $html, array $vars): string
+ {
+ $html = $this->renderForeachBlocks($html, $vars);
+ $html = $this->renderConditionBlocks($html, $vars);
+ $html = $this->renderJoinedValueExpressions($html, $vars);
+ $html = $this->renderVariableExpressions($html, $vars);
+ return $this->renderPlainExpressions($html, $vars);
+ }
+
+ /**
+ * @param array $vars
+ */
+ private function renderForeachBlocks(string $html, array $vars): string
+ {
+ $patterns = [
+ '/\{foreach\s+\$(\w+)\s+as\s+\$(\w+)=\>\$(\w+)\}(.*?)\{\/foreach\}/s',
+ '/(.*?)/s',
+ ];
+
+ foreach ($patterns as $pattern) {
+ while (preg_match($pattern, $html)) {
+ $html = preg_replace_callback($pattern, function (array $match) use ($vars): string {
+ $items = $this->resolveTemplateValue($vars, $match[1]);
+ if (!is_iterable($items)) {
+ return '';
+ }
+
+ $result = '';
+ foreach ($items as $key => $value) {
+ $local = $vars;
+ $local[$match[2]] = $key;
+ $local[$match[3]] = $value;
+ $result .= $this->renderRuntimeTemplate($match[4], $local);
+ }
+ return $result;
+ }, $html) ?? $html;
+ }
+ }
+
+ return $html;
+ }
+
+ /**
+ * @param array $vars
+ */
+ private function renderConditionBlocks(string $html, array $vars): string
+ {
+ $patterns = [
+ '/\{notempty\s+name="([^"]+)"\}(.*?)\{\/notempty\}/s' => fn (array $m) => $this->isNotEmptyValue($this->resolveTemplateValue($vars, $m[1])) ? $this->renderRuntimeTemplate($m[2], $vars) : '',
+ '/\{if\s+(.+?)\}(.*?)(?:\{else\}(.*?))?\{\/if\}/s' => function (array $m) use ($vars): string {
+ return $this->evaluateTemplateCondition($m[1], $vars)
+ ? $this->renderRuntimeTemplate($m[2], $vars)
+ : $this->renderRuntimeTemplate($m[3] ?? '', $vars);
+ },
+ '/(.*?)(?:(.*?))?/s' => function (array $m) use ($vars): string {
+ return $this->evaluateTemplateCondition($m[1], $vars)
+ ? $this->renderRuntimeTemplate($m[2], $vars)
+ : $this->renderRuntimeTemplate($m[3] ?? '', $vars);
+ },
+ ];
+
+ foreach ($patterns as $pattern => $renderer) {
+ while (preg_match($pattern, $html)) {
+ $html = preg_replace_callback($pattern, $renderer, $html) ?? $html;
+ }
+ }
+
+ return $html;
+ }
+
+ /**
+ * @param array $vars
+ */
+ private function renderJoinedValueExpressions(string $html, array $vars): string
+ {
+ $pattern = '/\{:\$(\w+)\.([\w.]+)\s*\?\?\s*null\s*\?\s*\(is_array\(\$\1\.\2\)\s*\?\s*join\(\'([^\']*)\',\s*\$\1\.\2\)\s*:\s*\$\1\.\2\)\s*:\s*\'\'\}/';
+ return preg_replace_callback($pattern, function (array $match) use ($vars): string {
+ $value = $this->resolveTemplateValue($vars, $match[1] . '.' . $match[2]);
+ if ($value === null) {
+ return '';
+ }
+ if (is_array($value)) {
+ return implode(stripslashes($match[3]), array_map('strval', $value));
+ }
+ return strval($value);
+ }, $html) ?? $html;
+ }
+
+ /**
+ * @param array $vars
+ */
+ private function renderVariableExpressions(string $html, array $vars): string
+ {
+ $patterns = [
+ '/\{(\$?[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)\|default=(["\'])(.*?)\2\}/' => fn (array $match): string => $this->replaceVariableExpression($match, $vars, stripcslashes($match[3])),
+ '/\{(\$?[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)\|default=([^}\'"]+)\}/' => fn (array $match): string => $this->replaceVariableExpression($match, $vars, trim($match[2])),
+ ];
+
+ foreach ($patterns as $pattern => $renderer) {
+ $html = preg_replace_callback($pattern, $renderer, $html) ?? $html;
+ }
+
+ return $html;
+ }
+
+ /**
+ * @param array $vars
+ */
+ private function renderPlainExpressions(string $html, array $vars): string
+ {
+ $pattern = '/\{(\$?[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)\}/';
+ return preg_replace_callback($pattern, function (array $match) use ($vars): string {
+ return $this->stringifyTemplateValue($this->resolveTemplateValue($vars, $match[1]));
+ }, $html) ?? $html;
+ }
+
+ /**
+ * @param array $vars
+ */
+ private function resolveTemplateValue(array $vars, string $path): mixed
+ {
+ $segments = array_values(array_filter(explode('.', ltrim($path, '$')), static fn (string $item): bool => $item !== ''));
+ if (count($segments) < 1) {
+ return null;
+ }
+
+ $value = $vars[$segments[0]] ?? null;
+ foreach (array_slice($segments, 1) as $segment) {
+ if (is_array($value) && array_key_exists($segment, $value)) {
+ $value = $value[$segment];
+ } elseif (is_object($value) && isset($value->{$segment})) {
+ $value = $value->{$segment};
+ } else {
+ return null;
+ }
+ }
+
+ return $value;
+ }
+
+ /**
+ * @param array $vars
+ */
+ private function evaluateTemplateCondition(string $condition, array $vars): bool
+ {
+ foreach (preg_split('/\s+and\s+/', trim($condition)) ?: [] as $segment) {
+ $segment = trim($segment);
+ if ($segment === '') {
+ continue;
+ }
+
+ if (preg_match('/^isset\((.+)\)$/', $segment, $match)) {
+ if ($this->resolveTemplateOperand($vars, $match[1]) === null) {
+ return false;
+ }
+ continue;
+ }
+
+ if (preg_match('/^is_array\((.+)\)$/', $segment, $match)) {
+ if (!is_array($this->resolveTemplateOperand($vars, $match[1]))) {
+ return false;
+ }
+ continue;
+ }
+
+ if (preg_match('/^in_array\((.+?),\s*(.+)\)$/', $segment, $match)) {
+ $needle = $this->resolveTemplateOperand($vars, $match[1]);
+ $haystack = $this->resolveTemplateOperand($vars, $match[2]);
+ if (!is_array($haystack) || !in_array($needle, $haystack, false)) {
+ return false;
+ }
+ continue;
+ }
+
+ if (preg_match('/^strval\((.+)\)\s*(?:eq|==)\s*strval\((.+)\)$/', $segment, $match)) {
+ if (strval($this->resolveTemplateOperand($vars, $match[1])) !== strval($this->resolveTemplateOperand($vars, $match[2]))) {
+ return false;
+ }
+ continue;
+ }
+
+ if (preg_match('/^strval\((.+)\)\s*(?:eq|==)\s*(.+)$/', $segment, $match)) {
+ if (strval($this->resolveTemplateOperand($vars, $match[1])) !== strval($this->resolveTemplateOperand($vars, $match[2]))) {
+ return false;
+ }
+ continue;
+ }
+
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * @param array $vars
+ */
+ private function resolveTemplateOperand(array $vars, string $operand): mixed
+ {
+ $operand = trim($operand);
+ if ($operand === 'null') {
+ return null;
+ }
+ if (preg_match('/^[\'"](.*)[\'"]$/s', $operand, $match)) {
+ return stripcslashes($match[1]);
+ }
+ return $this->resolveTemplateValue($vars, $operand);
+ }
+
+ private function isNotEmptyValue(mixed $value): bool
+ {
+ if (is_array($value)) {
+ return count($value) > 0;
+ }
+ return !($value === null || $value === '' || $value === false);
+ }
+
+ private function stringifyTemplateValue(mixed $value): string
+ {
+ if (is_array($value)) {
+ return implode(',', array_map('strval', $value));
+ }
+ if ($value === null) {
+ return '';
+ }
+ return strval($value);
+ }
+
+ /**
+ * @param array $match
+ * @param array $vars
+ */
+ private function replaceVariableExpression(array $match, array $vars, string $default): string
+ {
+ $value = $this->resolveTemplateValue($vars, $match[1]);
+ return $this->stringifyTemplateValue($value === null || $value === '' ? $default : $value);
+ }
+}
diff --git a/plugin/think-library/src/builder/form/FormButton.php b/plugin/think-library/src/builder/form/FormButton.php
new file mode 100644
index 000000000..579a3969d
--- /dev/null
+++ b/plugin/think-library/src/builder/form/FormButton.php
@@ -0,0 +1,33 @@
+ $button
+ */
+ public function __construct(FormBuilder $builder, private array $button, private string $buttonHtml)
+ {
+ parent::__construct($builder, 'button', '');
+ }
+
+ /**
+ * 导出节点数组.
+ * @return array
+ */
+ public function export(): array
+ {
+ return [
+ 'type' => 'button',
+ 'button' => $this->button,
+ 'html' => $this->buttonHtml,
+ ];
+ }
+}
diff --git a/plugin/think-library/src/builder/form/FormChoiceField.php b/plugin/think-library/src/builder/form/FormChoiceField.php
new file mode 100644
index 000000000..b060c91af
--- /dev/null
+++ b/plugin/think-library/src/builder/form/FormChoiceField.php
@@ -0,0 +1,17 @@
+optionsItem()->source($name)->end();
+ }
+}
diff --git a/plugin/think-library/src/builder/form/FormComponents.php b/plugin/think-library/src/builder/form/FormComponents.php
new file mode 100644
index 000000000..5f4b480ac
--- /dev/null
+++ b/plugin/think-library/src/builder/form/FormComponents.php
@@ -0,0 +1,52 @@
+text($text);
+ }
+
+ public static function readonlyField(): ReadonlyFieldComponent
+ {
+ return ReadonlyFieldComponent::make();
+ }
+
+ public static function pickerField(): PickerFieldComponent
+ {
+ return PickerFieldComponent::make();
+ }
+
+ /**
+ * @param array> $themes
+ */
+ public static function themePalette(array $themes = [], string $current = ''): ThemePaletteComponent
+ {
+ return ThemePaletteComponent::make()->themes($themes)->current($current);
+ }
+}
diff --git a/plugin/think-library/src/builder/form/FormField.php b/plugin/think-library/src/builder/form/FormField.php
new file mode 100644
index 000000000..4183d8614
--- /dev/null
+++ b/plugin/think-library/src/builder/form/FormField.php
@@ -0,0 +1,472 @@
+
+ */
+ private array $parts = [];
+
+ /**
+ * @param array $field
+ */
+ public function __construct(FormBuilder $builder, private FormNode $parent, protected array $field)
+ {
+ parent::__construct($builder, 'field', '');
+ }
+
+ public function name(string $name): static
+ {
+ $name = trim($name);
+ if ($name !== '') {
+ $this->field['name'] = $name;
+ }
+ return $this;
+ }
+
+ public function title(string $title): static
+ {
+ $this->field['title'] = trim($title);
+ return $this;
+ }
+
+ public function subtitle(string $subtitle): static
+ {
+ $this->field['subtitle'] = $subtitle;
+ return $this;
+ }
+
+ public function remark(string $remark): static
+ {
+ $this->field['remark'] = $remark;
+ return $this;
+ }
+
+ public function variable(string $name): static
+ {
+ $this->field['vname'] = trim($name);
+ return $this;
+ }
+
+ public function source(string $name): static
+ {
+ return $this->optionsItem()->source($name)->end();
+ }
+
+ public function search(bool $enabled = true): static
+ {
+ return $enabled ? $this->setFieldAttr('lay-search', null) : $this->unsetFieldAttr('lay-search');
+ }
+
+ public function options(array $options): static
+ {
+ return $this->optionsItem()->options($options)->end();
+ }
+
+ public function option(string|int $value, string $label): static
+ {
+ return $this->optionsItem()->option($value, $label)->end();
+ }
+
+ public function optionsItem(): FormFieldOptions
+ {
+ $options = is_array($this->field['options'] ?? null) ? $this->field['options'] : [];
+ $source = trim(strval($this->field['vname'] ?? ''));
+ return (new FormFieldOptions($this, $options, $source))
+ ->attach(fn(array $state): array => $this->replaceOptionsState($state));
+ }
+
+ public function required(bool $required = true, string $message = ''): static
+ {
+ $this->field['required'] = $required;
+ if ($message !== '') {
+ $this->setFieldAttr('required-error', $message);
+ } elseif (!$required) {
+ $this->unsetFieldAttr('required-error');
+ }
+ return $this;
+ }
+
+ public function pattern(?string $pattern, string $message = ''): static
+ {
+ $this->field['pattern'] = $pattern === null || trim($pattern) === '' ? null : trim($pattern);
+ if ($message !== '') {
+ $this->setFieldAttr('pattern-error', $message);
+ } elseif ($this->field['pattern'] === null) {
+ $this->unsetFieldAttr('pattern-error');
+ }
+ return $this;
+ }
+
+ public function rule(string $rule, string $message): static
+ {
+ $rule = trim($rule);
+ if ($rule !== '') {
+ $this->field['rules'] = is_array($this->field['rules'] ?? null) ? $this->field['rules'] : [];
+ $this->field['rules'][$rule] = $message;
+ }
+ return $this;
+ }
+
+ public function rules(array $rules): static
+ {
+ foreach ($rules as $rule => $message) {
+ if (is_string($rule)) {
+ $this->rule($rule, strval($message));
+ }
+ }
+ return $this;
+ }
+
+ public function placeholder(string $placeholder): static
+ {
+ return $this->setFieldAttr('placeholder', $placeholder);
+ }
+
+ public function maxlength(int $length): static
+ {
+ return $this->setFieldAttr('maxlength', $length);
+ }
+
+ public function inputRightIcon(string $iconClass, array $attrs = [], string|array $inputClass = 'pr40'): static
+ {
+ $iconClass = trim($iconClass);
+ if ($iconClass === '') {
+ return $this;
+ }
+
+ $renderer = new BuilderAttributesRenderer();
+ $attrs['onmousedown'] = $this->mergeEventHandler(strval($attrs['onmousedown'] ?? ''), 'event.preventDefault();event.stopPropagation();');
+ $attrs['ontouchstart'] = $this->mergeEventHandler(strval($attrs['ontouchstart'] ?? ''), 'event.preventDefault();event.stopPropagation();');
+ $attrs = BuilderAttributes::make($attrs)
+ ->class(trim("input-right-icon layui-icon {$iconClass}"))
+ ->all();
+
+ $this->body()->class('relative');
+ $this->input()->class($inputClass)->html(sprintf('', $renderer->render($attrs)));
+ return $this;
+ }
+
+ public function defaultValue(mixed $value): static
+ {
+ $this->field['default'] = $value;
+ return $this;
+ }
+
+ public function readonly(bool $readonly = true): static
+ {
+ return $readonly ? $this->setFieldAttr('readonly', null) : $this->unsetFieldAttr('readonly');
+ }
+
+ public function disabled(bool $disabled = true): static
+ {
+ return $disabled ? $this->setFieldAttr('disabled', null) : $this->unsetFieldAttr('disabled');
+ }
+
+ public function types(string $types): static
+ {
+ $this->uploadConfig()->types($types);
+ return $this;
+ }
+
+ public function previewOnly(): static
+ {
+ $this->uploadConfig()->previewOnly();
+ return $this;
+ }
+
+ public function inputTrigger(): static
+ {
+ $this->uploadConfig()->inputTrigger();
+ return $this;
+ }
+
+ public function uploadConfig(): FormUploadConfig
+ {
+ $config = is_array($this->field['upload'] ?? null) ? $this->field['upload'] : [];
+ return (new FormUploadConfig($this, $config))
+ ->attach(fn(array $state): array => $this->replaceUploadConfig($state));
+ }
+
+ public function label(?callable $callback = null): FormFieldPart
+ {
+ return $this->part('label', $callback);
+ }
+
+ public function input(?callable $callback = null): FormFieldPart
+ {
+ return $this->part('input', $callback);
+ }
+
+ public function control(?callable $callback = null): FormFieldPart
+ {
+ return $this->part('input', $callback);
+ }
+
+ public function body(?callable $callback = null): FormFieldPart
+ {
+ return $this->part('body', $callback);
+ }
+
+ public function remarkNode(?callable $callback = null): FormFieldPart
+ {
+ return $this->part('remark', $callback);
+ }
+
+ public function field(array $field): self
+ {
+ return $this->builder->addFieldToNode($this->parent, $field);
+ }
+
+ public function text(string $name, string $title, string $subtitle = '', bool $required = false, string $remark = '', ?string $pattern = null, array $attrs = []): self
+ {
+ return $this->builder->addFieldToNode($this->parent, [
+ 'type' => $attrs['type'] ?? 'text',
+ 'name' => $name,
+ 'title' => $title,
+ 'subtitle' => $subtitle,
+ 'required' => $required,
+ 'remark' => $remark,
+ 'pattern' => $pattern,
+ 'attrs' => $attrs,
+ ]);
+ }
+
+ public function textarea(string $name, string $title, string $subtitle = '', bool $required = false, string $remark = '', array $attrs = []): self
+ {
+ return $this->builder->addFieldToNode($this->parent, [
+ 'type' => 'textarea',
+ 'name' => $name,
+ 'title' => $title,
+ 'subtitle' => $subtitle,
+ 'required' => $required,
+ 'remark' => $remark,
+ 'attrs' => $attrs,
+ ]);
+ }
+
+ public function password(string $name, string $title, string $subtitle = '', bool $required = false, string $remark = '', ?string $pattern = null, array $attrs = []): self
+ {
+ $attrs['type'] = 'password';
+ return $this->text($name, $title, $subtitle, $required, $remark, $pattern, $attrs);
+ }
+
+ public function select(string $name, string $title, string $subtitle = '', bool $required = false, string $remark = '', array $options = [], string $variable = '', array $attrs = []): self
+ {
+ return $this->builder->addFieldToNode($this->parent, [
+ 'type' => 'select',
+ 'name' => $name,
+ 'title' => $title,
+ 'subtitle' => $subtitle,
+ 'required' => $required,
+ 'remark' => $remark,
+ 'options' => $options,
+ 'vname' => $variable,
+ 'attrs' => $attrs,
+ ]);
+ }
+
+ public function checkbox(string $name, string $title, string $subtitle, string $variable, bool $required = false, array $attrs = []): self
+ {
+ return $this->builder->addFieldToNode($this->parent, [
+ 'type' => 'checkbox',
+ 'name' => $name,
+ 'title' => $title,
+ 'subtitle' => $subtitle,
+ 'required' => $required,
+ 'attrs' => $attrs,
+ 'vname' => $variable,
+ ]);
+ }
+
+ public function radio(string $name, string $title, string $subtitle, string $variable, bool $required = false, array $attrs = []): self
+ {
+ return $this->builder->addFieldToNode($this->parent, [
+ 'type' => 'radio',
+ 'name' => $name,
+ 'title' => $title,
+ 'subtitle' => $subtitle,
+ 'required' => $required,
+ 'attrs' => $attrs,
+ 'vname' => $variable,
+ ]);
+ }
+
+ public function image(string $name, string $title, string $subtitle = '', bool $required = false, array $attrs = []): self
+ {
+ return $this->builder->addFieldToNode($this->parent, [
+ 'type' => 'image',
+ 'name' => $name,
+ 'title' => $title,
+ 'subtitle' => $subtitle,
+ 'required' => $required,
+ 'attrs' => $attrs,
+ ]);
+ }
+
+ public function video(string $name, string $title, string $subtitle = '', bool $required = false, array $attrs = []): self
+ {
+ return $this->builder->addFieldToNode($this->parent, [
+ 'type' => 'video',
+ 'name' => $name,
+ 'title' => $title,
+ 'subtitle' => $subtitle,
+ 'required' => $required,
+ 'attrs' => $attrs,
+ ]);
+ }
+
+ public function images(string $name, string $title, string $subtitle = '', bool $required = false, array $attrs = []): self
+ {
+ return $this->builder->addFieldToNode($this->parent, [
+ 'type' => 'images',
+ 'name' => $name,
+ 'title' => $title,
+ 'subtitle' => $subtitle,
+ 'required' => $required,
+ 'attrs' => $attrs,
+ ]);
+ }
+
+ /**
+ * 导出节点数组.
+ * @return array
+ */
+ public function export(): array
+ {
+ $parts = [];
+ foreach ($this->parts as $name => $part) {
+ if ($part->configured()) {
+ $parts[$name] = $part->export();
+ }
+ }
+
+ return [
+ 'type' => 'field',
+ 'attrs' => $this->buildAttrs(),
+ 'modules' => $this->modules,
+ 'parts' => $parts,
+ 'field' => $this->field,
+ ];
+ }
+
+ private function part(string $name, ?callable $callback = null): FormFieldPart
+ {
+ $name = trim($name);
+ if ($name === '') {
+ throw new \InvalidArgumentException('FormField 子节点名称不能为空');
+ }
+ $part = $this->parts[$name] ?? new FormFieldPart($this, $name);
+ $this->parts[$name] = $part;
+ if (is_callable($callback)) {
+ $callback($part);
+ }
+ return $part;
+ }
+
+ protected function setFieldAttr(string $name, mixed $value = null): static
+ {
+ $name = trim($name);
+ if ($name !== '') {
+ $attrs = is_array($this->field['attrs'] ?? null) ? $this->field['attrs'] : [];
+ $attrs[$name] = $value;
+ $this->field['attrs'] = $attrs;
+ }
+ return $this;
+ }
+
+ protected function unsetFieldAttr(string $name): static
+ {
+ $name = trim($name);
+ if ($name !== '' && is_array($this->field['attrs'] ?? null) && array_key_exists($name, $this->field['attrs'])) {
+ unset($this->field['attrs'][$name]);
+ }
+ return $this;
+ }
+
+ /**
+ * @param array $state
+ * @return array
+ */
+ private function replaceOptionsState(array $state): array
+ {
+ $this->field['options'] = is_array($state['options'] ?? null) ? $state['options'] : [];
+ $this->field['vname'] = trim(strval($state['vname'] ?? ''));
+ return [
+ 'options' => is_array($this->field['options'] ?? null) ? $this->field['options'] : [],
+ 'vname' => trim(strval($this->field['vname'] ?? '')),
+ ];
+ }
+
+ /**
+ * @param array $config
+ * @return array
+ */
+ private function replaceUploadConfig(array $config): array
+ {
+ $this->field['upload'] = $this->normalizeUploadConfig($config);
+ return $this->field['upload'];
+ }
+
+ /**
+ * @param array $config
+ * @return array
+ */
+ private function normalizeUploadConfig(array $config): array
+ {
+ $config = array_merge([
+ 'types' => '',
+ 'display' => '',
+ 'trigger' => [],
+ 'runtime' => [],
+ ], $config);
+
+ $config['types'] = trim(strval($config['types']));
+ $config['display'] = strtolower(trim(strval($config['display'])));
+ if (!in_array($config['display'], ['', 'input', 'preview'], true)) {
+ $config['display'] = '';
+ }
+ $config['trigger'] = is_array($config['trigger']) ? $config['trigger'] : [];
+ $config['runtime'] = is_array($config['runtime']) ? $config['runtime'] : [];
+ $config['trigger']['attrs'] = is_array($config['trigger']['attrs'] ?? null) ? $config['trigger']['attrs'] : [];
+ if (isset($config['trigger']['class'])) {
+ $config['trigger']['class'] = trim(strval($config['trigger']['class']));
+ }
+ if (isset($config['trigger']['icon'])) {
+ $config['trigger']['icon'] = trim(strval($config['trigger']['icon']));
+ }
+ if (isset($config['runtime']['method'])) {
+ $config['runtime']['method'] = trim(strval($config['runtime']['method']));
+ }
+ if (isset($config['runtime']['selector'])) {
+ $config['runtime']['selector'] = trim(strval($config['runtime']['selector']));
+ }
+
+ return $config;
+ }
+
+ private function mergeEventHandler(string $origin, string $append): string
+ {
+ $origin = trim($origin);
+ $append = trim($append);
+ if ($origin === '') {
+ return $append;
+ }
+ if ($append === '') {
+ return $origin;
+ }
+ return rtrim($append, ';') . ';' . ltrim($origin, ';');
+ }
+}
diff --git a/plugin/think-library/src/builder/form/FormFieldOptions.php b/plugin/think-library/src/builder/form/FormFieldOptions.php
new file mode 100644
index 000000000..75fe1f3d4
--- /dev/null
+++ b/plugin/think-library/src/builder/form/FormFieldOptions.php
@@ -0,0 +1,22 @@
+ $options
+ */
+ public function __construct(FormField $owner, array $options = [], string $source = '')
+ {
+ parent::__construct('vname', $options, $source, $owner);
+ }
+}
diff --git a/plugin/think-library/src/builder/form/FormFieldPart.php b/plugin/think-library/src/builder/form/FormFieldPart.php
new file mode 100644
index 000000000..89a143fc1
--- /dev/null
+++ b/plugin/think-library/src/builder/form/FormFieldPart.php
@@ -0,0 +1,185 @@
+
+ */
+ private array $attrs = [];
+
+ /**
+ * 模块配置.
+ * @var array>
+ */
+ private array $modules = [];
+
+ /**
+ * 覆盖内容.
+ */
+ private string $content = '';
+
+ public function __construct(private FormField $owner, private string $name)
+ {
+ }
+
+ public function attr(string $name, mixed $value = null): static
+ {
+ $name = trim($name);
+ if ($name !== '') {
+ $this->attrs[$name] = $value;
+ }
+ return $this;
+ }
+
+ public function attrs(array $attrs): static
+ {
+ $this->attrs = BuilderAttributes::make($this->attrs)->merge($attrs)->all();
+ return $this;
+ }
+
+ public function class(string|array $class): static
+ {
+ $this->attrs = BuilderAttributes::make($this->attrs)->class($class)->all();
+ return $this;
+ }
+
+ public function data(string $name, mixed $value = null): static
+ {
+ $name = trim($name);
+ if ($name !== '') {
+ $this->attr('data-' . ltrim($name, '-'), $value);
+ }
+ return $this;
+ }
+
+ public function id(string $id): static
+ {
+ return $this->attr('id', $id);
+ }
+
+ public function attrsItem(): BuilderAttributeBag
+ {
+ return $this->attachAttributes($this->createAttributes());
+ }
+
+ public function module(string $name, array $config = []): static
+ {
+ $this->attachModule($this->createModule($name, $config));
+ return $this;
+ }
+
+ public function moduleItem(string $name, array $config = []): BuilderModule
+ {
+ return $this->attachModule($this->createModule($name, $config));
+ }
+
+ public function text(string $content): static
+ {
+ $this->content = $content;
+ return $this;
+ }
+
+ public function html(string $content): static
+ {
+ $this->content = $content;
+ return $this;
+ }
+
+ public function end(): FormField
+ {
+ return $this->owner;
+ }
+
+ public function configured(): bool
+ {
+ return count($this->attrs) > 0 || count($this->modules) > 0 || $this->content !== '';
+ }
+
+ /**
+ * 导出节点数组.
+ * @return array
+ */
+ public function export(): array
+ {
+ return [
+ 'name' => $this->name,
+ 'attrs' => BuilderAttributes::make($this->attrs)->all(),
+ 'modules' => $this->modules,
+ 'content' => $this->content,
+ ];
+ }
+
+ protected function createModule(string $name, array $config = []): BuilderModule
+ {
+ return new BuilderModule($name, $config, $this);
+ }
+
+ protected function createAttributes(): BuilderAttributeBag
+ {
+ return new BuilderAttributeBag($this, $this->attrs);
+ }
+
+ protected function attachAttributes(BuilderAttributeBag $attributes): BuilderAttributeBag
+ {
+ return $attributes->attach(fn(array $state): array => $this->replaceAttributes($state));
+ }
+
+ /**
+ * @param array $state
+ * @return array
+ */
+ protected function replaceAttributes(array $state): array
+ {
+ $this->attrs = is_array($state['attrs'] ?? null) ? BuilderAttributes::make($state['attrs'])->all() : [];
+ return ['attrs' => $this->attrs];
+ }
+
+ protected function attachModule(BuilderModule $module): BuilderModule
+ {
+ $normalized = $this->normalizeModule($module->export());
+ if ($normalized['name'] === '') {
+ return $module;
+ }
+ $index = count($this->modules);
+ $this->modules[$index] = $normalized;
+ return $module->attach($index, $normalized, fn(int $index, array $module): array => $this->replaceModule($index, $module));
+ }
+
+ /**
+ * @param array $module
+ * @return array
+ */
+ protected function replaceModule(int $index, array $module): array
+ {
+ $normalized = $this->normalizeModule($module);
+ if ($normalized['name'] !== '') {
+ $this->modules[$index] = $normalized;
+ }
+ return $this->modules[$index] ?? $normalized;
+ }
+
+ /**
+ * @param array $module
+ * @return array
+ */
+ protected function normalizeModule(array $module): array
+ {
+ return [
+ 'name' => trim(strval($module['name'] ?? '')),
+ 'config' => is_array($module['config'] ?? null) ? $module['config'] : [],
+ ];
+ }
+}
diff --git a/plugin/think-library/src/builder/form/FormFields.php b/plugin/think-library/src/builder/form/FormFields.php
new file mode 100644
index 000000000..722d8962d
--- /dev/null
+++ b/plugin/think-library/src/builder/form/FormFields.php
@@ -0,0 +1,131 @@
+builder->addFieldToNode($this->parent, $field);
+ }
+
+ public function text(string $name, string $title, string $subtitle = '', bool $required = false, string $remark = '', ?string $pattern = null, array $attrs = []): FormField
+ {
+ return $this->builder->addFieldToNode($this->parent, [
+ 'type' => $attrs['type'] ?? 'text',
+ 'name' => $name,
+ 'title' => $title,
+ 'subtitle' => $subtitle,
+ 'required' => $required,
+ 'remark' => $remark,
+ 'pattern' => $pattern,
+ 'attrs' => $attrs,
+ ]);
+ }
+
+ public function textarea(string $name, string $title, string $subtitle = '', bool $required = false, string $remark = '', array $attrs = []): FormField
+ {
+ return $this->builder->addFieldToNode($this->parent, [
+ 'type' => 'textarea',
+ 'name' => $name,
+ 'title' => $title,
+ 'subtitle' => $subtitle,
+ 'required' => $required,
+ 'remark' => $remark,
+ 'attrs' => $attrs,
+ ]);
+ }
+
+ public function password(string $name, string $title, string $subtitle = '', bool $required = false, string $remark = '', ?string $pattern = null, array $attrs = []): FormField
+ {
+ $attrs['type'] = 'password';
+ return $this->text($name, $title, $subtitle, $required, $remark, $pattern, $attrs);
+ }
+
+ public function select(string $name, string $title, string $subtitle = '', bool $required = false, string $remark = '', array $options = [], string $variable = '', array $attrs = []): FormField
+ {
+ return $this->builder->addFieldToNode($this->parent, [
+ 'type' => 'select',
+ 'name' => $name,
+ 'title' => $title,
+ 'subtitle' => $subtitle,
+ 'required' => $required,
+ 'remark' => $remark,
+ 'options' => $options,
+ 'vname' => $variable,
+ 'attrs' => $attrs,
+ ]);
+ }
+
+ public function checkbox(string $name, string $title, string $subtitle, string $variable, bool $required = false, array $attrs = []): FormField
+ {
+ return $this->builder->addFieldToNode($this->parent, [
+ 'type' => 'checkbox',
+ 'name' => $name,
+ 'title' => $title,
+ 'subtitle' => $subtitle,
+ 'required' => $required,
+ 'attrs' => $attrs,
+ 'vname' => $variable,
+ ]);
+ }
+
+ public function radio(string $name, string $title, string $subtitle, string $variable, bool $required = false, array $attrs = []): FormField
+ {
+ return $this->builder->addFieldToNode($this->parent, [
+ 'type' => 'radio',
+ 'name' => $name,
+ 'title' => $title,
+ 'subtitle' => $subtitle,
+ 'required' => $required,
+ 'attrs' => $attrs,
+ 'vname' => $variable,
+ ]);
+ }
+
+ public function image(string $name, string $title, string $subtitle = '', bool $required = false, array $attrs = []): FormField
+ {
+ return $this->builder->addFieldToNode($this->parent, [
+ 'type' => 'image',
+ 'name' => $name,
+ 'title' => $title,
+ 'subtitle' => $subtitle,
+ 'required' => $required,
+ 'attrs' => $attrs,
+ ]);
+ }
+
+ public function video(string $name, string $title, string $subtitle = '', bool $required = false, array $attrs = []): FormField
+ {
+ return $this->builder->addFieldToNode($this->parent, [
+ 'type' => 'video',
+ 'name' => $name,
+ 'title' => $title,
+ 'subtitle' => $subtitle,
+ 'required' => $required,
+ 'attrs' => $attrs,
+ ]);
+ }
+
+ public function images(string $name, string $title, string $subtitle = '', bool $required = false, array $attrs = []): FormField
+ {
+ return $this->builder->addFieldToNode($this->parent, [
+ 'type' => 'images',
+ 'name' => $name,
+ 'title' => $title,
+ 'subtitle' => $subtitle,
+ 'required' => $required,
+ 'attrs' => $attrs,
+ ]);
+ }
+}
diff --git a/plugin/think-library/src/builder/form/FormLayout.php b/plugin/think-library/src/builder/form/FormLayout.php
new file mode 100644
index 000000000..bd324954f
--- /dev/null
+++ b/plugin/think-library/src/builder/form/FormLayout.php
@@ -0,0 +1,174 @@
+builder->setAction($url);
+ return $this;
+ }
+
+ public function title(string $title): self
+ {
+ $this->builder->setTitle($title);
+ return $this;
+ }
+
+ public function headerButton(string $name, string $type = 'button', string $confirm = '', array $attrs = [], string $class = ''): self
+ {
+ $this->builder->addHeaderButton($name, $type, $confirm, $attrs, $class);
+ return $this;
+ }
+
+ public function headerHtml(string $html, array $schema = []): self
+ {
+ $this->builder->addHeaderButtonHtml($html, $schema);
+ return $this;
+ }
+
+ public function variable(string $name): self
+ {
+ $this->builder->setVariable($name);
+ return $this;
+ }
+
+ public function attrs(array $attrs): static
+ {
+ $this->builder->setFormAttrs($attrs);
+ return $this;
+ }
+
+ public function attr(string $name, mixed $value = null): static
+ {
+ $this->builder->setFormAttr($name, $value);
+ return $this;
+ }
+
+ public function removeAttr(string $name): static
+ {
+ $this->builder->removeFormAttr($name);
+ return $this;
+ }
+
+ public function attrsItem(): BuilderAttributeBag
+ {
+ return $this->builder->attachFormAttributes($this->builder->createFormAttributes());
+ }
+
+ public function bodyAttrs(array $attrs): static
+ {
+ $this->builder->setBodyAttrs($attrs);
+ return $this;
+ }
+
+ public function bodyAttr(string $name, mixed $value = null): static
+ {
+ $this->builder->setBodyAttr($name, $value);
+ return $this;
+ }
+
+ public function bodyAttrsItem(): BuilderAttributeBag
+ {
+ return $this->builder->attachBodyAttributes($this->builder->createBodyAttributes());
+ }
+
+ public function class(string|array $class): static
+ {
+ $this->builder->addFormClass($class);
+ return $this;
+ }
+
+ public function removeClass(string|array $class): static
+ {
+ $this->builder->removeFormClass($class);
+ return $this;
+ }
+
+ public function bodyClass(string|array $class): static
+ {
+ $this->builder->addBodyClass($class);
+ return $this;
+ }
+
+ public function data(string $name, mixed $value = null): static
+ {
+ $this->builder->setFormData($name, $value);
+ return $this;
+ }
+
+ public function removeData(string $name): static
+ {
+ $this->builder->removeFormData($name);
+ return $this;
+ }
+
+ public function bodyData(string $name, mixed $value = null): static
+ {
+ $this->builder->setBodyData($name, $value);
+ return $this;
+ }
+
+ public function module(string $name, array $config = []): static
+ {
+ $this->builder->attachFormModule($this->builder->createFormModule($name, $config));
+ return $this;
+ }
+
+ public function moduleItem(string $name, array $config = []): BuilderModule
+ {
+ return $this->builder->attachFormModule($this->builder->createFormModule($name, $config));
+ }
+
+ public function script(string $script): self
+ {
+ $this->builder->addScript($script);
+ return $this;
+ }
+
+ public function rules(array $rules): self
+ {
+ $this->builder->addValidateRules($rules);
+ return $this;
+ }
+
+ public function rule(string $name, string $rule, string $message): self
+ {
+ $this->builder->addValidateRule($name, $rule, $message);
+ return $this;
+ }
+
+ /**
+ * @param callable(FormFields): void $callback
+ */
+ public function fields(callable $callback): static
+ {
+ parent::fields($callback);
+ return $this;
+ }
+
+ /**
+ * @param callable(FormActions): void $callback
+ */
+ public function actions(callable $callback): static
+ {
+ parent::actions($callback);
+ return $this;
+ }
+}
diff --git a/plugin/think-library/src/builder/form/FormNode.php b/plugin/think-library/src/builder/form/FormNode.php
new file mode 100644
index 000000000..03b06491a
--- /dev/null
+++ b/plugin/think-library/src/builder/form/FormNode.php
@@ -0,0 +1,201 @@
+builder, $type, $tag);
+ }
+
+ public function html(string $html): static
+ {
+ $child = $this->createNodeInstance('html');
+ $child->html = $html;
+ $this->appendChild($child);
+ return $this;
+ }
+
+ public function textNode(string $text): static
+ {
+ return $this->html(BuilderAttributes::escape($text));
+ }
+
+ /**
+ * @param (callable(FormNode): void)|null $callback
+ */
+ public function node(string $tag = 'div', ?callable $callback = null): self
+ {
+ $child = $this->createNodeInstance('element', trim($tag) ?: 'div');
+ $this->appendChild($child);
+ if (is_callable($callback)) {
+ $callback($child);
+ }
+ return $child;
+ }
+
+ /**
+ * @param (callable(FormNode): void)|null $callback
+ */
+ public function prepend(string $tag = 'div', ?callable $callback = null): self
+ {
+ $child = $this->createNodeInstance('element', trim($tag) ?: 'div');
+ $this->prependNode($child);
+ if (is_callable($callback)) {
+ $callback($child);
+ }
+ return $child;
+ }
+
+ /**
+ * @param (callable(FormNode): void)|null $callback
+ */
+ public function before(string $tag = 'div', ?callable $callback = null): self
+ {
+ $child = $this->createNodeInstance('element', trim($tag) ?: 'div');
+ $this->beforeNode($child);
+ if (is_callable($callback)) {
+ $callback($child);
+ }
+ return $child;
+ }
+
+ /**
+ * @param (callable(FormNode): void)|null $callback
+ */
+ public function after(string $tag = 'div', ?callable $callback = null): self
+ {
+ $child = $this->createNodeInstance('element', trim($tag) ?: 'div');
+ $this->afterNode($child);
+ if (is_callable($callback)) {
+ $callback($child);
+ }
+ return $child;
+ }
+
+ /**
+ * @param null|callable(FormNode): void $callback
+ */
+ public function component(FormComponentInterface $component, ?callable $callback = null): self
+ {
+ $node = $component->mount($this);
+ if (is_callable($callback)) {
+ $callback($node);
+ }
+ return $node;
+ }
+
+ public function div(?callable $callback = null): self
+ {
+ return $this->node('div', $callback);
+ }
+
+ public function section(?callable $callback = null): self
+ {
+ return $this->node('section', $callback);
+ }
+
+ public function article(?callable $callback = null): self
+ {
+ return $this->node('article', $callback);
+ }
+
+ public function header(?callable $callback = null): self
+ {
+ return $this->node('header', $callback);
+ }
+
+ public function footer(?callable $callback = null): self
+ {
+ return $this->node('footer', $callback);
+ }
+
+ public function fieldset(?callable $callback = null): self
+ {
+ return $this->node('fieldset', $callback);
+ }
+
+ public function fieldsNode(): FormFields
+ {
+ return new FormFields($this->builder, $this);
+ }
+
+ /**
+ * @param callable(FormFields): void $callback
+ */
+ public function fields(callable $callback): static
+ {
+ $callback($this->fieldsNode());
+ return $this;
+ }
+
+ public function actionsNode(): FormActions
+ {
+ return new FormActions($this->builder, $this->actionBar());
+ }
+
+ /**
+ * @param (callable(FormActions): void)|null $callback
+ */
+ public function actionBar(?callable $callback = null): FormActionBar
+ {
+ $node = $this->actionBar ?? new FormActionBar($this->builder);
+ if ($this->actionBar === null) {
+ $this->appendChild($node);
+ $this->actionBar = $node;
+ }
+ if (is_callable($callback)) {
+ $callback(new FormActions($this->builder, $node));
+ }
+ return $node;
+ }
+
+ /**
+ * @param callable(FormActions): void $callback
+ */
+ public function actions(callable $callback): static
+ {
+ $callback($this->actionsNode());
+ return $this;
+ }
+
+ public function append(FormNode $node): FormNode
+ {
+ return $this->appendChild($node);
+ }
+
+ protected function onChildDetached(object $child): void
+ {
+ if ($child === $this->actionBar) {
+ $this->actionBar = null;
+ }
+ }
+
+ /**
+ * 导出节点数组.
+ * @return array
+ */
+ public function export(): array
+ {
+ if ($this->type === 'html') {
+ return $this->exportHtmlNode();
+ }
+ return $this->exportElementNode();
+ }
+}
diff --git a/plugin/think-library/src/builder/form/FormSelectField.php b/plugin/think-library/src/builder/form/FormSelectField.php
new file mode 100644
index 000000000..79b42fba2
--- /dev/null
+++ b/plugin/think-library/src/builder/form/FormSelectField.php
@@ -0,0 +1,22 @@
+optionsItem()->source($name)->end();
+ }
+
+ public function search(bool $enabled = true): static
+ {
+ return $enabled ? $this->setFieldAttr('lay-search', null) : $this->unsetFieldAttr('lay-search');
+ }
+}
diff --git a/plugin/think-library/src/builder/form/FormTextField.php b/plugin/think-library/src/builder/form/FormTextField.php
new file mode 100644
index 000000000..2e91287cf
--- /dev/null
+++ b/plugin/think-library/src/builder/form/FormTextField.php
@@ -0,0 +1,60 @@
+setFieldAttr('maxlength', $length);
+ }
+
+ public function minlength(int $length): static
+ {
+ return $this->setFieldAttr('minlength', $length);
+ }
+
+ /**
+ * @param array $attrs
+ */
+ public function inputRightIcon(string $iconClass, array $attrs = [], string|array $inputClass = 'pr40'): static
+ {
+ $iconClass = trim($iconClass);
+ if ($iconClass === '') {
+ return $this;
+ }
+
+ $renderer = new BuilderAttributesRenderer();
+ $attrs['onmousedown'] = $this->mergeEventHandler(strval($attrs['onmousedown'] ?? ''), 'event.preventDefault();event.stopPropagation();');
+ $attrs['ontouchstart'] = $this->mergeEventHandler(strval($attrs['ontouchstart'] ?? ''), 'event.preventDefault();event.stopPropagation();');
+ $attrs = BuilderAttributes::make($attrs)
+ ->class(trim("input-right-icon layui-icon {$iconClass}"))
+ ->all();
+
+ $this->body()->class('relative');
+ $this->input()->class($inputClass)->html(sprintf('', $renderer->render($attrs)));
+ return $this;
+ }
+
+ private function mergeEventHandler(string $origin, string $append): string
+ {
+ $origin = trim($origin);
+ $append = trim($append);
+ if ($origin === '') {
+ return $append;
+ }
+ if ($append === '') {
+ return $origin;
+ }
+ return rtrim($append, ';') . ';' . ltrim($origin, ';');
+ }
+}
diff --git a/plugin/think-library/src/builder/form/FormUploadConfig.php b/plugin/think-library/src/builder/form/FormUploadConfig.php
new file mode 100644
index 000000000..ba25b8ea2
--- /dev/null
+++ b/plugin/think-library/src/builder/form/FormUploadConfig.php
@@ -0,0 +1,238 @@
+): array
+ */
+ private $syncHandler = null;
+
+ /**
+ * @param array $config
+ */
+ public function __construct(private FormField $owner, private array $config = [])
+ {
+ }
+
+ /**
+ * @param callable(array): array $syncHandler
+ */
+ public function attach(callable $syncHandler): self
+ {
+ $this->syncHandler = $syncHandler;
+ return $this;
+ }
+
+ public function types(string $types): self
+ {
+ $types = trim($types);
+ if ($types !== '') {
+ $this->config['types'] = $types;
+ $this->sync();
+ }
+ return $this;
+ }
+
+ public function display(string $mode): self
+ {
+ $mode = strtolower(trim($mode));
+ if (in_array($mode, ['input', 'preview'], true)) {
+ $this->config['display'] = $mode;
+ $this->sync();
+ }
+ return $this;
+ }
+
+ public function inputTrigger(): self
+ {
+ return $this->display('input');
+ }
+
+ public function previewOnly(): self
+ {
+ return $this->display('preview');
+ }
+
+ public function option(string $name, mixed $value): self
+ {
+ $name = trim($name);
+ if ($name !== '') {
+ $this->config[$name] = $value;
+ $this->sync();
+ }
+ return $this;
+ }
+
+ /**
+ * @param array $options
+ */
+ public function options(array $options): self
+ {
+ foreach ($options as $name => $value) {
+ if (is_string($name) && trim($name) !== '') {
+ $this->config[trim($name)] = $value;
+ }
+ }
+ return $this->sync();
+ }
+
+ /**
+ * @param array $trigger
+ */
+ public function trigger(array $trigger): self
+ {
+ $this->config['trigger'] = $this->mergeAssoc($this->resolveTrigger(), $trigger);
+ return $this->sync();
+ }
+
+ public function triggerClass(string|array $class): self
+ {
+ $trigger = $this->resolveTrigger();
+ $trigger['class'] = BuilderAttributes::mergeClassNames(strval($trigger['class'] ?? ''), $class);
+ $this->config['trigger'] = $trigger;
+ return $this->sync();
+ }
+
+ public function triggerIcon(string $icon): self
+ {
+ $icon = trim($icon);
+ if ($icon !== '') {
+ $trigger = $this->resolveTrigger();
+ $trigger['icon'] = $icon;
+ $this->config['trigger'] = $trigger;
+ $this->sync();
+ }
+ return $this;
+ }
+
+ public function triggerAttr(string $name, mixed $value = null): self
+ {
+ $name = trim($name);
+ if ($name !== '') {
+ $trigger = $this->resolveTrigger();
+ $attrs = is_array($trigger['attrs'] ?? null) ? $trigger['attrs'] : [];
+ $attrs[$name] = $value;
+ $trigger['attrs'] = $attrs;
+ $this->config['trigger'] = $trigger;
+ $this->sync();
+ }
+ return $this;
+ }
+
+ /**
+ * @param array $attrs
+ */
+ public function triggerAttrs(array $attrs): self
+ {
+ $trigger = $this->resolveTrigger();
+ $trigger['attrs'] = $this->mergeAssoc(is_array($trigger['attrs'] ?? null) ? $trigger['attrs'] : [], $attrs);
+ $this->config['trigger'] = $trigger;
+ return $this->sync();
+ }
+
+ public function triggerFile(string|bool $file): self
+ {
+ $trigger = $this->resolveTrigger();
+ $trigger['file'] = $file;
+ $this->config['trigger'] = $trigger;
+ return $this->sync();
+ }
+
+ /**
+ * @param array $runtime
+ */
+ public function runtime(array $runtime): self
+ {
+ $this->config['runtime'] = $this->mergeAssoc($this->resolveRuntime(), $runtime);
+ return $this->sync();
+ }
+
+ public function runtimeMethod(string $method): self
+ {
+ $method = trim($method);
+ if ($method !== '') {
+ $runtime = $this->resolveRuntime();
+ $runtime['method'] = $method;
+ $this->config['runtime'] = $runtime;
+ $this->sync();
+ }
+ return $this;
+ }
+
+ public function runtimeSelector(string $selector): self
+ {
+ $selector = trim($selector);
+ if ($selector !== '') {
+ $runtime = $this->resolveRuntime();
+ $runtime['selector'] = $selector;
+ $this->config['runtime'] = $runtime;
+ $this->sync();
+ }
+ return $this;
+ }
+
+ /**
+ * @return array
+ */
+ public function export(): array
+ {
+ return $this->config;
+ }
+
+ public function end(): FormField
+ {
+ return $this->owner;
+ }
+
+ private function sync(): self
+ {
+ if (is_callable($this->syncHandler)) {
+ $state = ($this->syncHandler)($this->config);
+ $this->config = is_array($state) ? $state : $this->config;
+ }
+ return $this;
+ }
+
+ /**
+ * @return array
+ */
+ private function resolveTrigger(): array
+ {
+ return is_array($this->config['trigger'] ?? null) ? $this->config['trigger'] : [];
+ }
+
+ /**
+ * @return array
+ */
+ private function resolveRuntime(): array
+ {
+ return is_array($this->config['runtime'] ?? null) ? $this->config['runtime'] : [];
+ }
+
+ /**
+ * @param array $origin
+ * @param array $append
+ * @return array
+ */
+ private function mergeAssoc(array $origin, array $append): array
+ {
+ foreach ($append as $key => $value) {
+ if (is_string($key) && isset($origin[$key]) && is_array($origin[$key]) && is_array($value) && !array_is_list($origin[$key]) && !array_is_list($value)) {
+ $origin[$key] = $this->mergeAssoc($origin[$key], $value);
+ } elseif (is_string($key)) {
+ $origin[$key] = $value;
+ }
+ }
+ return $origin;
+ }
+}
diff --git a/plugin/think-library/src/builder/form/FormUploadField.php b/plugin/think-library/src/builder/form/FormUploadField.php
new file mode 100644
index 000000000..10527499e
--- /dev/null
+++ b/plugin/think-library/src/builder/form/FormUploadField.php
@@ -0,0 +1,84 @@
+uploadConfig()->types($types);
+ return $this;
+ }
+
+ public function inputTrigger(): static
+ {
+ $this->uploadConfig()->inputTrigger();
+ return $this;
+ }
+
+ public function previewOnly(): static
+ {
+ $this->uploadConfig()->previewOnly();
+ return $this;
+ }
+
+ public function uploadConfig(): FormUploadConfig
+ {
+ $config = is_array($this->field['upload'] ?? null) ? $this->field['upload'] : [];
+ return (new FormUploadConfig($this, $config))
+ ->attach(fn(array $config): array => $this->replaceUploadConfig($config));
+ }
+
+ /**
+ * @param array $config
+ * @return array
+ */
+ private function replaceUploadConfig(array $config): array
+ {
+ $this->field['upload'] = $this->normalizeUploadConfig($config);
+ return $this->field['upload'];
+ }
+
+ /**
+ * @param array $config
+ * @return array
+ */
+ private function normalizeUploadConfig(array $config): array
+ {
+ $config = array_merge([
+ 'types' => '',
+ 'display' => '',
+ 'trigger' => [],
+ 'runtime' => [],
+ ], $config);
+
+ $config['types'] = trim(strval($config['types']));
+ $config['display'] = strtolower(trim(strval($config['display'])));
+ if (!in_array($config['display'], ['', 'input', 'preview'], true)) {
+ $config['display'] = '';
+ }
+ $config['trigger'] = is_array($config['trigger']) ? $config['trigger'] : [];
+ $config['runtime'] = is_array($config['runtime']) ? $config['runtime'] : [];
+ $config['trigger']['attrs'] = is_array($config['trigger']['attrs'] ?? null) ? $config['trigger']['attrs'] : [];
+ if (isset($config['trigger']['class'])) {
+ $config['trigger']['class'] = trim(strval($config['trigger']['class']));
+ }
+ if (isset($config['trigger']['icon'])) {
+ $config['trigger']['icon'] = trim(strval($config['trigger']['icon']));
+ }
+ if (isset($config['runtime']['method'])) {
+ $config['runtime']['method'] = trim(strval($config['runtime']['method']));
+ }
+ if (isset($config['runtime']['selector'])) {
+ $config['runtime']['selector'] = trim(strval($config['runtime']['selector']));
+ }
+
+ return $config;
+ }
+}
diff --git a/plugin/think-library/src/builder/form/component/AbstractFormComponent.php b/plugin/think-library/src/builder/form/component/AbstractFormComponent.php
new file mode 100644
index 000000000..7797416e4
--- /dev/null
+++ b/plugin/think-library/src/builder/form/component/AbstractFormComponent.php
@@ -0,0 +1,47 @@
+ $config
+ * @param array $extra
+ * @return array
+ */
+ protected function mergeConfig(array $config, array $extra): array
+ {
+ return array_merge($config, $extra);
+ }
+
+ /**
+ * @param array $config
+ */
+ protected function appendClass(array &$config, string $key, string|array $class): void
+ {
+ $config[$key] = BuilderAttributes::mergeClassNames(strval($config[$key] ?? ''), $class);
+ }
+
+ protected function escape(string $content): string
+ {
+ return htmlentities(BuilderLang::text($content), ENT_QUOTES, 'UTF-8');
+ }
+}
diff --git a/plugin/think-library/src/builder/form/component/FormComponentInterface.php b/plugin/think-library/src/builder/form/component/FormComponentInterface.php
new file mode 100644
index 000000000..7ff0feb50
--- /dev/null
+++ b/plugin/think-library/src/builder/form/component/FormComponentInterface.php
@@ -0,0 +1,16 @@
+
+ */
+ private array $config = [];
+
+ /**
+ * @param array $config
+ */
+ public function config(array $config): static
+ {
+ $this->config = $this->mergeConfig($this->config, $config);
+ return $this;
+ }
+
+ public function title(string $title): static
+ {
+ $this->config['title'] = $title;
+ return $this;
+ }
+
+ public function description(string $description): static
+ {
+ $this->config['description'] = $description;
+ return $this;
+ }
+
+ public function class(string|array $class): static
+ {
+ $this->appendClass($this->config, 'class', $class);
+ return $this;
+ }
+
+ public function mount(FormNode $parent): FormNode
+ {
+ $node = $parent->section()->class(trim(strval($this->config['class'] ?? 'layui-card mb15')));
+ $body = $node->div()->class(trim(strval($this->config['body_class'] ?? 'layui-card-body')));
+ $eyebrow = trim(strval($this->config['eyebrow'] ?? ''));
+ if ($eyebrow !== '') {
+ $body->div()->class(trim(strval($this->config['eyebrow_class'] ?? 'color-desc fs12')))->html($this->escape($eyebrow));
+ }
+ $title = trim(strval($this->config['title'] ?? ''));
+ if ($title !== '') {
+ $body->node('h2')->class(trim(strval($this->config['title_class'] ?? 'mt10 mb10')))->html($this->escape($title));
+ }
+ $description = trim(strval($this->config['description'] ?? ''));
+ if ($description !== '') {
+ $body->div()->class(trim(strval($this->config['description_class'] ?? 'color-desc lh24')))->html($this->escape($description));
+ }
+ return $node;
+ }
+}
diff --git a/plugin/think-library/src/builder/form/component/NoteComponent.php b/plugin/think-library/src/builder/form/component/NoteComponent.php
new file mode 100644
index 000000000..df3b6747e
--- /dev/null
+++ b/plugin/think-library/src/builder/form/component/NoteComponent.php
@@ -0,0 +1,34 @@
+text = $text;
+ return $this;
+ }
+
+ public function class(string $class): static
+ {
+ $this->class = trim($class) ?: $this->class;
+ return $this;
+ }
+
+ public function mount(FormNode $parent): FormNode
+ {
+ return $parent->div()->class($this->class)->html($this->escape($this->text));
+ }
+}
diff --git a/plugin/think-library/src/builder/form/component/PickerFieldComponent.php b/plugin/think-library/src/builder/form/component/PickerFieldComponent.php
new file mode 100644
index 000000000..41d1881b7
--- /dev/null
+++ b/plugin/think-library/src/builder/form/component/PickerFieldComponent.php
@@ -0,0 +1,77 @@
+
+ */
+ private array $config = [];
+
+ /**
+ * @param array $config
+ */
+ public function config(array $config): static
+ {
+ $this->config = $this->mergeConfig($this->config, $config);
+ return $this;
+ }
+
+ public function mount(FormNode $parent): FormNode
+ {
+ $node = $parent->node('label')->class(trim(strval($this->config['class'] ?? 'relative block')));
+ foreach ((array)($this->config['attrs'] ?? []) as $name => $value) {
+ $node->attr(strval($name), $value);
+ }
+ $label = $node->node('span')->class(trim(strval($this->config['label_class'] ?? 'help-label label-required-prev')));
+ $title = trim(strval($this->config['title'] ?? ''));
+ if ($title !== '') {
+ $label->node('b')->html($this->escape($title));
+ }
+ $subtitle = trim(strval($this->config['subtitle'] ?? ''));
+ if ($subtitle !== '') {
+ $label->html($this->escape($title === '' ? $subtitle : " {$subtitle}"));
+ }
+
+ $hiddenName = trim(strval($this->config['hidden_name'] ?? ''));
+ if ($hiddenName !== '') {
+ $node->node('input')->attrs([
+ 'type' => 'hidden',
+ 'name' => $hiddenName,
+ 'value' => strval($this->config['hidden_value'] ?? ''),
+ ]);
+ }
+
+ $input = $node->node('input')->attrs([
+ 'readonly' => null,
+ 'class' => trim(strval($this->config['input_class'] ?? 'layui-input')),
+ 'value' => strval($this->config['value'] ?? ''),
+ 'title' => strval($this->config['value'] ?? ''),
+ ]);
+ $inputName = trim(strval($this->config['name'] ?? ''));
+ if ($inputName !== '') {
+ $input->attr('name', $inputName);
+ }
+ foreach ((array)($this->config['input_attrs'] ?? []) as $name => $value) {
+ $input->attr(strval($name), $value);
+ }
+ $icon = $node->node('span')->class(trim(strval($this->config['icon_class'] ?? 'input-right-icon layui-icon layui-icon-theme')));
+ foreach ((array)($this->config['icon_attrs'] ?? []) as $name => $value) {
+ $icon->attr(strval($name), $value);
+ }
+ $help = trim(strval($this->config['help'] ?? ''));
+ if ($help !== '') {
+ $node->div()->class(trim(strval($this->config['help_class'] ?? 'help-block color-desc')))->html($this->escape($help));
+ }
+ return $node;
+ }
+}
diff --git a/plugin/think-library/src/builder/form/component/ReadonlyFieldComponent.php b/plugin/think-library/src/builder/form/component/ReadonlyFieldComponent.php
new file mode 100644
index 000000000..f2a6c21b6
--- /dev/null
+++ b/plugin/think-library/src/builder/form/component/ReadonlyFieldComponent.php
@@ -0,0 +1,65 @@
+
+ */
+ private array $config = [];
+
+ /**
+ * @param array $config
+ */
+ public function config(array $config): static
+ {
+ $this->config = $this->mergeConfig($this->config, $config);
+ return $this;
+ }
+
+ public function mount(FormNode $parent): FormNode
+ {
+ $node = $parent->node('label')->class(trim(strval($this->config['class'] ?? 'relative block')));
+ $label = $node->node('span')->class(trim(strval($this->config['label_class'] ?? 'help-label label-required-prev')));
+ $title = trim(strval($this->config['title'] ?? ''));
+ if ($title !== '') {
+ $label->node('b')->html($this->escape($title));
+ }
+ $subtitle = trim(strval($this->config['subtitle'] ?? ''));
+ if ($subtitle !== '') {
+ $label->html($this->escape($title === '' ? $subtitle : " {$subtitle}"));
+ }
+ $wrap = $node->node('label')->class(trim(strval($this->config['wrap_class'] ?? 'relative block')));
+ $inputAttrs = [
+ 'readonly' => null,
+ 'class' => trim(strval($this->config['input_class'] ?? 'layui-input layui-bg-gray')),
+ 'value' => strval($this->config['value'] ?? ''),
+ ];
+ $inputName = trim(strval($this->config['name'] ?? ''));
+ if ($inputName !== '') {
+ $inputAttrs['name'] = $inputName;
+ }
+ $input = $wrap->node('input')->attrs($inputAttrs);
+ foreach ((array)($this->config['input_attrs'] ?? []) as $name => $value) {
+ $input->attr(strval($name), $value);
+ }
+ $copy = trim(strval($this->config['copy'] ?? ''));
+ if ($copy !== '') {
+ $wrap->node('a')->class(trim(strval($this->config['copy_class'] ?? 'layui-icon layui-icon-release input-right-icon')))->attr('data-copy', $copy);
+ }
+ $help = trim(strval($this->config['help'] ?? ''));
+ if ($help !== '') {
+ $node->div()->class(trim(strval($this->config['help_class'] ?? 'help-block color-desc')))->html($this->escape($help));
+ }
+ return $node;
+ }
+}
diff --git a/plugin/think-library/src/builder/form/component/SectionComponent.php b/plugin/think-library/src/builder/form/component/SectionComponent.php
new file mode 100644
index 000000000..b97c7b275
--- /dev/null
+++ b/plugin/think-library/src/builder/form/component/SectionComponent.php
@@ -0,0 +1,79 @@
+
+ */
+ private array $config = [];
+
+ /**
+ * @var null|callable(FormNode): void
+ */
+ private $bodyCallback = null;
+
+ /**
+ * @param array $config
+ */
+ public function config(array $config): static
+ {
+ $this->config = $this->mergeConfig($this->config, $config);
+ return $this;
+ }
+
+ public function title(string $title): static
+ {
+ $this->config['title'] = $title;
+ return $this;
+ }
+
+ public function description(string $description): static
+ {
+ $this->config['description'] = $description;
+ return $this;
+ }
+
+ public function class(string|array $class): static
+ {
+ $this->appendClass($this->config, 'class', $class);
+ return $this;
+ }
+
+ /**
+ * @param callable(FormNode): void $callback
+ */
+ public function body(callable $callback): static
+ {
+ $this->bodyCallback = $callback;
+ return $this;
+ }
+
+ public function mount(FormNode $parent): FormNode
+ {
+ $node = $parent->section()->class(trim(strval($this->config['class'] ?? 'mb20')));
+ $head = $node->div()->class(trim(strval($this->config['head_class'] ?? 'mb15')));
+ $title = trim(strval($this->config['title'] ?? ''));
+ if ($title !== '') {
+ $head->node('h3')->class(trim(strval($this->config['title_class'] ?? 'mb5')))->html($this->escape($title));
+ }
+ $description = trim(strval($this->config['description'] ?? ''));
+ if ($description !== '') {
+ $head->node('p')->class(trim(strval($this->config['description_class'] ?? 'color-desc lh24')))->html($this->escape($description));
+ }
+ $body = $node->div()->class(trim(strval($this->config['body_class'] ?? '')));
+ if (is_callable($this->bodyCallback)) {
+ ($this->bodyCallback)($body);
+ }
+ return $node;
+ }
+}
diff --git a/plugin/think-library/src/builder/form/component/ThemePaletteComponent.php b/plugin/think-library/src/builder/form/component/ThemePaletteComponent.php
new file mode 100644
index 000000000..3be75b95b
--- /dev/null
+++ b/plugin/think-library/src/builder/form/component/ThemePaletteComponent.php
@@ -0,0 +1,138 @@
+>
+ */
+ private array $themes = [];
+
+ private string $current = '';
+
+ /**
+ * @var array
+ */
+ private array $config = [];
+
+ /**
+ * @param array> $themes
+ */
+ public function themes(array $themes): static
+ {
+ $this->themes = $themes;
+ return $this;
+ }
+
+ public function current(string $current): static
+ {
+ $this->current = $current;
+ return $this;
+ }
+
+ /**
+ * @param array $config
+ */
+ public function config(array $config): static
+ {
+ $this->config = $this->mergeConfig($this->config, $config);
+ return $this;
+ }
+
+ public function mount(FormNode $parent): FormNode
+ {
+ $node = $parent->div()->class(trim(strval($this->config['class'] ?? 'layui-form-item mb5 label-required-prev')));
+ $label = $node->div()->class(trim(strval($this->config['label_class'] ?? 'help-label')));
+ $title = trim(strval($this->config['title'] ?? ''));
+ if ($title !== '') {
+ $label->node('b')->html($this->escape($title));
+ }
+ $subtitle = trim(strval($this->config['subtitle'] ?? ''));
+ if ($subtitle !== '') {
+ $label->node('span')->class(trim(strval($this->config['subtitle_class'] ?? 'color-desc')))->html($this->escape($subtitle));
+ }
+ $description = trim(strval($this->config['description'] ?? ''));
+ if ($description !== '') {
+ $node->div()->class(trim(strval($this->config['description_class'] ?? 'help-block')))->html($this->escape($description));
+ }
+
+ $palette = $node->div()->class(trim(strval($this->config['palette_class'] ?? 'theme-palette')));
+ $inputName = trim(strval($this->config['input_name'] ?? 'site_theme'));
+ foreach ($this->themes as $key => $theme) {
+ $labelText = trim(strval($theme['label'] ?? $key));
+ $card = $palette->node('label')->class(trim('theme-palette-card' . ($this->current === $key ? ' active' : '')));
+ $card->data('theme-card', $key)
+ ->data('theme-label', $labelText)
+ ->attr('title', trim($labelText . ' / ' . $key, ' /'));
+
+ $input = $card->node('input')->attrs([
+ 'name' => $inputName,
+ 'type' => 'radio',
+ 'value' => strval($key),
+ 'lay-ignore' => 'true',
+ 'class' => 'theme-palette-input',
+ ]);
+ if ($this->current === $key) {
+ $input->attr('checked', null);
+ }
+
+ $preview = $card->node('span')->class(trim('theme-palette-preview ' . strval($theme['layout'] ?? 'default')));
+ $preview->attr('style', $this->themePreviewStyle($theme));
+ foreach ([
+ 'theme-palette-preview-header',
+ 'theme-palette-preview-side',
+ 'theme-palette-preview-side-alt',
+ 'theme-palette-preview-card theme-palette-preview-hero',
+ 'theme-palette-preview-card theme-palette-preview-panel',
+ 'theme-palette-preview-card theme-palette-preview-panel-right',
+ 'theme-palette-preview-line theme-palette-preview-tone',
+ 'theme-palette-preview-line theme-palette-preview-copy-1',
+ 'theme-palette-preview-line theme-palette-preview-copy-2',
+ 'theme-palette-preview-line theme-palette-preview-copy-3',
+ 'theme-palette-preview-line theme-palette-preview-copy-4',
+ ] as $class) {
+ $preview->node('span')->class($class);
+ }
+
+ $meta = $card->node('span')->class('theme-palette-meta');
+ $meta->node('span')->class('theme-palette-title')->html($this->escape($labelText));
+ $meta->node('span')->class('theme-palette-layout')->html($this->escape(strval($theme['layout_label'] ?? '')));
+ $card->node('span')->class('theme-palette-check layui-icon layui-icon-ok');
+ }
+
+ $help = trim(strval($this->config['help'] ?? ''));
+ if ($help !== '') {
+ $node->node('p')->class(trim(strval($this->config['help_class'] ?? 'help-block')))->html($this->escape($help));
+ }
+ return $node;
+ }
+
+ /**
+ * @param array $theme
+ */
+ private function themePreviewStyle(array $theme): string
+ {
+ $styles = [];
+ foreach ([
+ 'theme-accent' => strval($theme['primary'] ?? ''),
+ 'theme-header' => strval($theme['header'] ?? ''),
+ 'theme-side' => strval($theme['side'] ?? ''),
+ 'theme-surface' => strval($theme['surface'] ?? ''),
+ 'theme-body' => strval($theme['body'] ?? ''),
+ ] as $name => $value) {
+ if ($value !== '') {
+ $styles[] = '--' . $name . ':' . $value;
+ }
+ }
+ return join(';', $styles);
+ }
+}
diff --git a/plugin/think-library/src/builder/form/module/FormModules.php b/plugin/think-library/src/builder/form/module/FormModules.php
new file mode 100644
index 000000000..e3f147f28
--- /dev/null
+++ b/plugin/think-library/src/builder/form/module/FormModules.php
@@ -0,0 +1,87 @@
+ $config
+ */
+ public static function intro(FormNode $parent, array $config = []): FormNode
+ {
+ return FormComponents::intro()->config($config)->mount($parent);
+ }
+
+ /**
+ * @param array $config
+ * @param callable(FormNode): void $callback
+ */
+ public static function section(FormNode $parent, array $config, callable $callback): FormNode
+ {
+ return FormComponents::section()->config($config)->body($callback)->mount($parent);
+ }
+
+ public static function note(FormNode $parent, string $text, string $class = 'help-block color-desc'): FormNode
+ {
+ return FormComponents::note($text)->class($class)->mount($parent);
+ }
+
+ /**
+ * @param array $config
+ */
+ public static function readonlyField(FormNode $parent, array $config = []): FormNode
+ {
+ return FormComponents::readonlyField()->config($config)->mount($parent);
+ }
+
+ /**
+ * @param array $config
+ */
+ public static function pickerField(FormNode $parent, array $config = []): FormNode
+ {
+ return FormComponents::pickerField()->config($config)->mount($parent);
+ }
+
+ /**
+ * @param array> $themes
+ * @param array $config
+ */
+ public static function themePalette(FormNode $parent, array $themes, string $current = '', array $config = []): FormNode
+ {
+ return FormComponents::themePalette($themes, $current)->config($config)->mount($parent);
+ }
+
+ /**
+ * @param array $theme
+ */
+ private static function themePreviewStyle(array $theme): string
+ {
+ $styles = [];
+ foreach ([
+ 'theme-accent' => strval($theme['primary'] ?? ''),
+ 'theme-header' => strval($theme['header'] ?? ''),
+ 'theme-side' => strval($theme['side'] ?? ''),
+ 'theme-surface' => strval($theme['surface'] ?? ''),
+ 'theme-body' => strval($theme['body'] ?? ''),
+ ] as $name => $value) {
+ if ($value !== '') {
+ $styles[] = '--' . $name . ':' . $value;
+ }
+ }
+ return join(';', $styles);
+ }
+
+ private static function escape(string $content): string
+ {
+ return htmlentities($content, ENT_QUOTES, 'UTF-8');
+ }
+}
diff --git a/plugin/think-library/src/builder/form/render/AbstractFormFieldRenderer.php b/plugin/think-library/src/builder/form/render/AbstractFormFieldRenderer.php
new file mode 100644
index 000000000..29d7a4e99
--- /dev/null
+++ b/plugin/think-library/src/builder/form/render/AbstractFormFieldRenderer.php
@@ -0,0 +1,36 @@
+openContainer($containerTag, $containerClass);
+ $html .= "\n\t\t\t" . $context->renderLabel();
+ $html .= "\n\t\t\t" . $context->renderBody($control, $bodyClass, $alwaysWrapBody);
+ if ($remark = $context->renderRemark()) {
+ $html .= "\n\t\t\t" . $remark;
+ }
+ return "{$html}\n\t\t{$containerTag}>";
+ }
+
+ protected function appendInlineScript(string $html, string $script): string
+ {
+ return $html . (new InlineScriptRenderer())->render([$script]);
+ }
+}
diff --git a/plugin/think-library/src/builder/form/render/ChoiceFieldRenderer.php b/plugin/think-library/src/builder/form/render/ChoiceFieldRenderer.php
new file mode 100644
index 000000000..aae091784
--- /dev/null
+++ b/plugin/think-library/src/builder/form/render/ChoiceFieldRenderer.php
@@ -0,0 +1,34 @@
+field();
+ if (strval($field['vname'] ?? '') === '' && count((array)($field['options'] ?? [])) < 1) {
+ throw new \InvalidArgumentException('FormBuilder 复选或单选字段需要提供 vname 或 options');
+ }
+
+ $type = $context->type();
+ $attrs = $context->resolveInputAttrs();
+ $attrs['type'] = $type;
+ $attrs['lay-ignore'] = null;
+ $attrs['name'] = $field['name'] . ($type === 'checkbox' ? '[]' : '');
+ return $this->renderFieldShell(
+ $context,
+ $context->renderChoiceOptions($attrs, $type) . "\n\t\t\t",
+ 'div',
+ 'layui-form-item',
+ 'layui-textarea help-checks layui-bg-gray',
+ true
+ );
+ }
+}
diff --git a/plugin/think-library/src/builder/form/render/FormActionBarRenderer.php b/plugin/think-library/src/builder/form/render/FormActionBarRenderer.php
new file mode 100644
index 000000000..714b28164
--- /dev/null
+++ b/plugin/think-library/src/builder/form/render/FormActionBarRenderer.php
@@ -0,0 +1,30 @@
+ $node
+ */
+ public function render(array $node, FormNodeRenderContext $context): string
+ {
+ $attrs = is_array($node['attrs'] ?? null) ? $node['attrs'] : [];
+ $children = is_array($node['children'] ?? null) ? $node['children'] : [];
+
+ $html = '';
+ if ($identity = $context->renderIdentityField()) {
+ $html .= "\n\t\t" . $identity;
+ }
+ $html .= "\n\t\t" . $this->wrapElement('div', $attrs, "\n\t\t\t" . $context->renderChildren($children) . "\n\t\t", $context);
+ return $html;
+ }
+}
diff --git a/plugin/think-library/src/builder/form/render/FormButtonNodeRenderer.php b/plugin/think-library/src/builder/form/render/FormButtonNodeRenderer.php
new file mode 100644
index 000000000..1fcce7db0
--- /dev/null
+++ b/plugin/think-library/src/builder/form/render/FormButtonNodeRenderer.php
@@ -0,0 +1,19 @@
+renderHtml($node);
+ }
+}
diff --git a/plugin/think-library/src/builder/form/render/FormElementNodeRenderer.php b/plugin/think-library/src/builder/form/render/FormElementNodeRenderer.php
new file mode 100644
index 000000000..c6c1db5b9
--- /dev/null
+++ b/plugin/think-library/src/builder/form/render/FormElementNodeRenderer.php
@@ -0,0 +1,19 @@
+renderElement($node, $context);
+ }
+}
diff --git a/plugin/think-library/src/builder/form/render/FormFieldNodeRenderer.php b/plugin/think-library/src/builder/form/render/FormFieldNodeRenderer.php
new file mode 100644
index 000000000..763e60149
--- /dev/null
+++ b/plugin/think-library/src/builder/form/render/FormFieldNodeRenderer.php
@@ -0,0 +1,26 @@
+invoke([$context, 'renderField'], $field);
+ }
+}
diff --git a/plugin/think-library/src/builder/form/render/FormFieldRenderContext.php b/plugin/think-library/src/builder/form/render/FormFieldRenderContext.php
new file mode 100644
index 000000000..f52b069d0
--- /dev/null
+++ b/plugin/think-library/src/builder/form/render/FormFieldRenderContext.php
@@ -0,0 +1,230 @@
+ $field
+ */
+ public function __construct(private array $field, private string $variable)
+ {
+ parent::__construct([new BuilderAttributesRenderer(), 'render']);
+ }
+
+ /**
+ * 获取字段配置.
+ * @return array
+ */
+ public function field(): array
+ {
+ return $this->field;
+ }
+
+ public function type(): string
+ {
+ return strval($this->field['type'] ?? 'text');
+ }
+
+ public function valueExpression(?string $name = null): string
+ {
+ return sprintf('{%s.%s|default=%s}', $this->variable, $this->valuePath($name), $this->templateLiteral($this->defaultValue()));
+ }
+
+ public function joinedValueExpression(string $separator = '|', ?string $name = null): string
+ {
+ $path = $this->valuePath($name);
+ $separator = addslashes($separator);
+ return sprintf('{:%s.%s ?? null ? (is_array(%s.%s) ? join(\'%s\', %s.%s) : %s.%s) : \'\'}', $this->variable, $path, $this->variable, $path, $separator, $this->variable, $path, $this->variable, $path);
+ }
+
+ public function valuePath(?string $name = null): string
+ {
+ $name = $name === null ? strval($this->field['name'] ?? '') : $name;
+ $name = preg_replace('/\[\]$/', '', $name) ?? $name;
+ $name = str_replace(['][', '[', ']'], ['.', '.', ''], $name);
+ $name = preg_replace('/\.{2,}/', '.', $name) ?? $name;
+ return trim($name, '.');
+ }
+
+ public function variable(): string
+ {
+ return $this->variable;
+ }
+
+ public function defaultValue(): mixed
+ {
+ if (array_key_exists('default', $this->field)) {
+ return $this->field['default'];
+ }
+ return $this->type() === 'checkbox' ? [] : '';
+ }
+
+ public function scalarDefaultLiteral(): string
+ {
+ return $this->templateLiteral($this->defaultValue());
+ }
+
+ /**
+ * @return array
+ */
+ public function arrayDefaultValues(): array
+ {
+ $value = $this->defaultValue();
+ if (is_array($value)) {
+ return array_values(array_map('strval', $value));
+ }
+ $value = trim(strval($value));
+ return $value === '' ? [] : array_values(array_map('strval', str2arr($value)));
+ }
+
+ public function arrayDefaultLiteral(): string
+ {
+ $items = array_map(static fn(string $item): string => var_export($item, true), $this->arrayDefaultValues());
+ return '[' . join(', ', $items) . ']';
+ }
+
+ public function openContainer(string $tag, string $class): string
+ {
+ $attrs = $this->buildFieldContainerAttrs($class);
+ return sprintf('<%s %s>', $tag, ltrim($this->attrs($attrs)));
+ }
+
+ public function renderLabel(): string
+ {
+ $part = $this->resolveFieldPart('label');
+ $attrs = BuilderAttributes::make($this->buildPartAttrs($part))
+ ->class('help-label' . (!empty($this->field['required']) ? ' label-required-prev' : ''))
+ ->all();
+ $content = $part['content'] !== '' ? $part['content'] : sprintf('%s%s', $this->field['title'], $this->field['subtitle']);
+ return sprintf('%s', $this->attrs($attrs), $content);
+ }
+
+ public function renderBody(string $content, string $class = '', bool $alwaysWrap = false): string
+ {
+ $part = $this->resolveFieldPart('body');
+ if (!$alwaysWrap && $part['content'] === '' && count($part['attrs']) < 1 && count($part['modules']) < 1 && $class === '') {
+ return $content;
+ }
+ if ($part['content'] !== '') {
+ return $part['content'];
+ }
+ $attrs = BuilderAttributes::make($this->buildPartAttrs($part))->class($class)->all();
+ return sprintf('%s
', $this->attrs($attrs), $content);
+ }
+
+ public function renderRemark(): string
+ {
+ $part = $this->resolveFieldPart('remark');
+ $content = $part['content'] !== '' ? $part['content'] : strval($this->field['remark'] ?? '');
+ if ($content === '') {
+ return '';
+ }
+ $attrs = BuilderAttributes::make($this->buildPartAttrs($part))->class('help-block')->all();
+ return sprintf('%s', $this->attrs($attrs), $content);
+ }
+
+ public function renderInputContent(): string
+ {
+ return strval($this->resolveFieldPart('input')['content'] ?? '');
+ }
+
+ /**
+ * @return array
+ */
+ public function resolveInputAttrs(string $class = ''): array
+ {
+ $attrs = is_array($this->field['attrs'] ?? null) ? $this->field['attrs'] : [];
+ return BuilderAttributes::make($attrs)
+ ->merge($this->buildPartAttrs($this->resolveFieldPart('input')))
+ ->class($class)
+ ->all();
+ }
+
+ public function renderSelectOptions(): string
+ {
+ return $this->optionRenderer()->renderSelectOptions($this->field, $this);
+ }
+
+ /**
+ * @param array $attrs
+ */
+ public function renderChoiceOptions(array $attrs, ?string $type = null): string
+ {
+ $type = $type ?: $this->type();
+ return $this->optionRenderer()->renderChoiceOptions($this->field, $this, $type, $attrs);
+ }
+
+ /**
+ * @return array
+ */
+ private function buildFieldContainerAttrs(string $class): array
+ {
+ $modules = is_array($this->field['container_modules'] ?? null) ? $this->field['container_modules'] : [];
+ return BuilderAttributes::make(is_array($this->field['container_attrs'] ?? null) ? $this->field['container_attrs'] : [])
+ ->class($class)
+ ->default('data-field-name', $this->field['name'])
+ ->modules($modules)
+ ->all();
+ }
+
+ /**
+ * @return array
+ */
+ private function resolveFieldPart(string $name): array
+ {
+ $parts = is_array($this->field['parts'] ?? null) ? $this->field['parts'] : [];
+ $part = is_array($parts[$name] ?? null) ? $parts[$name] : [];
+ $part['attrs'] = is_array($part['attrs'] ?? null) ? $part['attrs'] : [];
+ $part['modules'] = is_array($part['modules'] ?? null) ? $part['modules'] : [];
+ $part['content'] = isset($part['content']) ? strval($part['content']) : '';
+ return $part;
+ }
+
+ /**
+ * @param array $part
+ * @param array $attrs
+ * @return array
+ */
+ private function buildPartAttrs(array $part, array $attrs = []): array
+ {
+ $modules = is_array($part['modules'] ?? null) ? $part['modules'] : [];
+ return BuilderAttributes::make($attrs)
+ ->merge(is_array($part['attrs'] ?? null) ? $part['attrs'] : [])
+ ->modules($modules)
+ ->all();
+ }
+
+ private function optionRenderer(): FormOptionRenderer
+ {
+ return $this->optionRenderer ??= new FormOptionRenderer();
+ }
+
+ private function templateLiteral(mixed $value): string
+ {
+ if (is_bool($value)) {
+ return $value ? '\'1\'' : '\'0\'';
+ }
+ if (is_int($value) || is_float($value)) {
+ return var_export($value, true);
+ }
+ if (is_array($value)) {
+ return '\'\'';
+ }
+
+ return '\'' . addslashes(strval($value)) . '\'';
+ }
+}
diff --git a/plugin/think-library/src/builder/form/render/FormFieldRendererFactory.php b/plugin/think-library/src/builder/form/render/FormFieldRendererFactory.php
new file mode 100644
index 000000000..a27e14f05
--- /dev/null
+++ b/plugin/think-library/src/builder/form/render/FormFieldRendererFactory.php
@@ -0,0 +1,25 @@
+ $field
+ */
+ public function create(array $field): FormFieldRendererInterface
+ {
+ return match ($field['type'] ?? 'text') {
+ 'select' => new SelectFieldRenderer(),
+ 'checkbox', 'radio' => new ChoiceFieldRenderer(),
+ 'image', 'video', 'images' => new UploadFieldRenderer(),
+ default => new TextFieldRenderer(),
+ };
+ }
+}
diff --git a/plugin/think-library/src/builder/form/render/FormFieldRendererInterface.php b/plugin/think-library/src/builder/form/render/FormFieldRendererInterface.php
new file mode 100644
index 000000000..742af5782
--- /dev/null
+++ b/plugin/think-library/src/builder/form/render/FormFieldRendererInterface.php
@@ -0,0 +1,14 @@
+renderHtml($node);
+ }
+}
diff --git a/plugin/think-library/src/builder/form/render/FormNodeRenderContext.php b/plugin/think-library/src/builder/form/render/FormNodeRenderContext.php
new file mode 100644
index 000000000..5ee0049ad
--- /dev/null
+++ b/plugin/think-library/src/builder/form/render/FormNodeRenderContext.php
@@ -0,0 +1,57 @@
+>): string $contentRenderer
+ * @param callable(array): string $attrsRenderer
+ * @param callable(array): string $fieldRenderer
+ */
+ public function __construct(
+ private string $variable,
+ callable $contentRenderer,
+ callable $attrsRenderer,
+ private $fieldRenderer,
+ ) {
+ parent::__construct($contentRenderer, $attrsRenderer);
+ }
+
+ public function variable(): string
+ {
+ return $this->variable;
+ }
+
+ public function variableName(): string
+ {
+ return ltrim($this->variable, '$');
+ }
+
+ /**
+ * @param array $field
+ */
+ public function renderField(array $field): string
+ {
+ return ($this->fieldRenderer)($field);
+ }
+
+ public function renderIdentityField(): string
+ {
+ if ($this->identityRendered) {
+ return '';
+ }
+ $this->identityRendered = true;
+ return sprintf('{notempty name="%s.id"}{/notempty}', $this->variableName(), $this->variable);
+ }
+}
diff --git a/plugin/think-library/src/builder/form/render/FormNodeRendererFactory.php b/plugin/think-library/src/builder/form/render/FormNodeRendererFactory.php
new file mode 100644
index 000000000..c30055600
--- /dev/null
+++ b/plugin/think-library/src/builder/form/render/FormNodeRendererFactory.php
@@ -0,0 +1,39 @@
+ $node
+ */
+ public function create(array $node): FormNodeRendererInterface
+ {
+ $class = $this->resolveRendererClass($node);
+ return new $class();
+ }
+
+ protected function rendererMap(): array
+ {
+ return [
+ 'html' => FormHtmlNodeRenderer::class,
+ 'field' => FormFieldNodeRenderer::class,
+ 'button' => FormButtonNodeRenderer::class,
+ 'actions' => FormActionBarRenderer::class,
+ 'element' => FormElementNodeRenderer::class,
+ ];
+ }
+
+ protected function fallbackRendererClass(): string
+ {
+ return FormHtmlNodeRenderer::class;
+ }
+}
diff --git a/plugin/think-library/src/builder/form/render/FormNodeRendererInterface.php b/plugin/think-library/src/builder/form/render/FormNodeRendererInterface.php
new file mode 100644
index 000000000..01a73a5f7
--- /dev/null
+++ b/plugin/think-library/src/builder/form/render/FormNodeRendererInterface.php
@@ -0,0 +1,17 @@
+ $node
+ */
+ public function render(array $node, FormNodeRenderContext $context): string;
+}
diff --git a/plugin/think-library/src/builder/form/render/FormOptionRenderer.php b/plugin/think-library/src/builder/form/render/FormOptionRenderer.php
new file mode 100644
index 000000000..b8857176c
--- /dev/null
+++ b/plugin/think-library/src/builder/form/render/FormOptionRenderer.php
@@ -0,0 +1,181 @@
+ $field
+ */
+ public function renderSelectOptions(array $field, FormFieldRenderContext $context): string
+ {
+ $variable = $context->variable();
+ $valuePath = $context->valuePath();
+ $default = $context->scalarDefaultLiteral();
+ if (strval($field['vname'] ?? '') !== '') {
+ $html = sprintf('{foreach $%s as $k=>$v}', $field['vname']);
+ $html .= sprintf(
+ '{if (isset(%s.%s) and strval(%s.%s) eq strval($k)) or (!isset(%s.%s) and strval($k) eq strval(%s))}{else}{/if}',
+ $variable,
+ $valuePath,
+ $variable,
+ $valuePath,
+ $variable,
+ $valuePath,
+ $default
+ );
+ $html .= '{/foreach}';
+ return $html;
+ }
+
+ $html = '';
+ foreach ((array)($field['options'] ?? []) as $value => $label) {
+ $value = $context->escape((string)$value);
+ $label = $context->escape((string)$label);
+ $html .= sprintf(
+ '{if (isset(%s.%s) and strval(%s.%s) eq \'%s\') or (!isset(%s.%s) and strval(%s) eq \'%s\')}{else}{/if}',
+ $variable,
+ $valuePath,
+ $variable,
+ $valuePath,
+ addslashes($value),
+ $variable,
+ $valuePath,
+ $default,
+ $value,
+ $value,
+ $label,
+ $value,
+ $label
+ );
+ }
+ return $html;
+ }
+
+ /**
+ * @param array $field
+ * @param array $attrs
+ */
+ public function renderChoiceOptions(array $field, FormFieldRenderContext $context, string $type, array $attrs): string
+ {
+ if (strval($field['vname'] ?? '') !== '') {
+ return $this->renderDynamicChoiceOptions($field, $context, $type, $attrs);
+ }
+
+ $html = '';
+ foreach ((array)$field['options'] as $value => $label) {
+ $html .= "\n\t\t\t\t" . sprintf('';
+ }
+ return $html;
+ }
+
+ /**
+ * @param array $field
+ * @param array $attrs
+ */
+ private function renderDynamicChoiceOptions(array $field, FormFieldRenderContext $context, string $type, array $attrs): string
+ {
+ $html = "\n\t\t\t\t" . sprintf('', $field['vname']);
+ $html .= "\n\t\t\t\t" . sprintf('';
+ $html .= "\n\t\t\t\t" . '';
+ return $html;
+ }
+
+ /**
+ */
+ private function renderChoiceCondition(FormFieldRenderContext $context, string $type): string
+ {
+ $valuePath = $context->valuePath();
+ $variable = $context->variable();
+ if ($type === 'checkbox') {
+ $defaults = $context->arrayDefaultLiteral();
+ return sprintf(
+ '',
+ $variable,
+ $valuePath,
+ $variable,
+ $valuePath,
+ $variable,
+ $valuePath,
+ $variable,
+ $valuePath,
+ $defaults
+ );
+ }
+ return sprintf(
+ '',
+ $variable,
+ $valuePath,
+ $variable,
+ $valuePath,
+ $variable,
+ $valuePath,
+ $context->scalarDefaultLiteral()
+ );
+ }
+
+ private function renderStaticChoiceCondition(FormFieldRenderContext $context, string $value, string $type): string
+ {
+ $valuePath = $context->valuePath();
+ $variable = $context->variable();
+ $value = addslashes($value);
+ if ($type === 'checkbox') {
+ $defaults = $context->arrayDefaultLiteral();
+ return sprintf(
+ '',
+ $variable,
+ $valuePath,
+ $variable,
+ $valuePath,
+ $value,
+ $variable,
+ $valuePath,
+ $variable,
+ $valuePath,
+ $value,
+ $defaults
+ );
+ }
+ return sprintf(
+ '',
+ $variable,
+ $valuePath,
+ $value,
+ $variable,
+ $valuePath,
+ $variable,
+ $valuePath,
+ $value,
+ $context->scalarDefaultLiteral()
+ );
+ }
+
+}
diff --git a/plugin/think-library/src/builder/form/render/FormRenderPipeline.php b/plugin/think-library/src/builder/form/render/FormRenderPipeline.php
new file mode 100644
index 000000000..fc8b934a8
--- /dev/null
+++ b/plugin/think-library/src/builder/form/render/FormRenderPipeline.php
@@ -0,0 +1,61 @@
+ $attrs
+ * @param array> $content
+ * @param array $fields
+ * @param array $headerButtons
+ * @param array $buttons
+ * @param array $scripts
+ */
+ public function renderShell(
+ array $attrs,
+ array $bodyAttrs,
+ array $content,
+ array $fields,
+ array $headerButtons,
+ array $buttons,
+ FormRenderState $state,
+ array $scripts
+ ): string {
+ return (new FormShellRenderer())->render(
+ $attrs,
+ $bodyAttrs,
+ $content,
+ $fields,
+ $headerButtons,
+ $buttons,
+ $state->schema(),
+ $scripts,
+ $state->nodeRenderContext()
+ );
+ }
+
+ /**
+ * @param array $field
+ */
+ public function renderField(array $field, string $variable): string
+ {
+ return (new FormFieldRendererFactory())->create($field)->render(new FormFieldRenderContext($field, $variable));
+ }
+
+ /**
+ * @param array> $nodes
+ */
+ public function renderContentNodes(array $nodes, FormRenderState $state): string
+ {
+ return $this->renderNodeContent($nodes, $state, "\n\t\t");
+ }
+}
diff --git a/plugin/think-library/src/builder/form/render/FormRenderState.php b/plugin/think-library/src/builder/form/render/FormRenderState.php
new file mode 100644
index 000000000..7bf8b12bb
--- /dev/null
+++ b/plugin/think-library/src/builder/form/render/FormRenderState.php
@@ -0,0 +1,40 @@
+ $schema
+ */
+ public function __construct(
+ array $schema,
+ FormNodeRendererFactory $nodeRendererFactory,
+ FormNodeRenderContext $nodeRenderContext,
+ ) {
+ parent::__construct($schema, $nodeRendererFactory, $nodeRenderContext);
+ $this->formNodeRendererFactory = $nodeRendererFactory;
+ $this->formNodeRenderContext = $nodeRenderContext;
+ }
+
+ public function nodeRendererFactory(): FormNodeRendererFactory
+ {
+ return $this->formNodeRendererFactory;
+ }
+
+ public function nodeRenderContext(): FormNodeRenderContext
+ {
+ return $this->formNodeRenderContext;
+ }
+}
diff --git a/plugin/think-library/src/builder/form/render/FormShellRenderer.php b/plugin/think-library/src/builder/form/render/FormShellRenderer.php
new file mode 100644
index 000000000..cbe7162ad
--- /dev/null
+++ b/plugin/think-library/src/builder/form/render/FormShellRenderer.php
@@ -0,0 +1,119 @@
+ $attrs
+ * @param array> $content
+ * @param array $fields
+ * @param array $headerButtons
+ * @param array $buttons
+ */
+ public function render(
+ array $attrs,
+ array $bodyAttrs,
+ array $content,
+ array $fields,
+ array $headerButtons,
+ array $buttons,
+ array $schema,
+ array $scripts,
+ FormNodeRenderContext $context
+ ): string {
+ $isPage = strval($schema['mode'] ?? '') === 'page';
+ if ($isPage) {
+ return $this->renderPageShell($attrs, $bodyAttrs, $content, $fields, $headerButtons, $buttons, $schema, $scripts, $context);
+ }
+
+ $html = sprintf('" . (new InlineScriptRenderer())->render($scripts);
+ }
+
+ private function renderPageShell(
+ array $attrs,
+ array $bodyAttrs,
+ array $content,
+ array $fields,
+ array $headerButtons,
+ array $buttons,
+ array $schema,
+ array $scripts,
+ FormNodeRenderContext $context
+ ): string {
+ $header = (new PageHeaderRenderer())->render(strval($schema['title'] ?? ''), $headerButtons);
+ $form = sprintf('';
+ $form .= (new InlineScriptRenderer())->render($scripts);
+
+ $html = sprintf(
+ '',
+ htmlentities(strval($schema['preset'] ?? 'page-form'), ENT_QUOTES, 'UTF-8')
+ );
+ if ($header !== '') {
+ $html .= "\n\t" . $header;
+ $html .= "\n\t" . '
';
+ }
+ $html .= "\n\t" . '
';
+ $html .= "\n\t\t" . '
';
+ $html .= "\n\t\t\t" . '
';
+ $html .= "\n\t\t\t\t" . $form;
+ $html .= "\n\t\t\t" . '
';
+ $html .= "\n\t\t" . '
';
+ $html .= "\n\t" . '
';
+ return $html . "\n
";
+ }
+}
diff --git a/plugin/think-library/src/builder/form/render/FormUploadRuntimeRenderer.php b/plugin/think-library/src/builder/form/render/FormUploadRuntimeRenderer.php
new file mode 100644
index 000000000..32c4621a0
--- /dev/null
+++ b/plugin/think-library/src/builder/form/render/FormUploadRuntimeRenderer.php
@@ -0,0 +1,104 @@
+ $field
+ */
+ public function resolveDisplayMode(array $field, string $type): string
+ {
+ if ($type !== 'image') {
+ return 'input';
+ }
+ $mode = strtolower(trim(strval($field['upload']['display'] ?? '')));
+ return in_array($mode, ['input', 'preview'], true) ? $mode : 'input';
+ }
+
+ /**
+ * @param array $field
+ */
+ public function resolveUploadTypes(array $field, string $type): string
+ {
+ return strval($field['upload']['types'] ?? ($type === 'image' ? 'gif,png,jpg,jpeg' : 'mp4'));
+ }
+
+ public function renderTrigger(FormFieldRenderContext $context, string $fieldName, string $type, string $uploadTypes): string
+ {
+ $field = $context->field();
+ $trigger = is_array($field['upload']['trigger'] ?? null) ? $field['upload']['trigger'] : [];
+ $attrs = is_array($trigger['attrs'] ?? null) ? $trigger['attrs'] : [];
+ $icon = trim(strval($trigger['icon'] ?? 'layui-icon-upload'));
+ $class = BuilderAttributes::mergeClassNames("layui-icon {$icon} input-right-icon", $trigger['class'] ?? '');
+ $attrs = BuilderAttributes::make($attrs)
+ ->merge([
+ 'data-field' => $fieldName,
+ 'data-type' => $uploadTypes,
+ ])
+ ->class($class)
+ ->all();
+
+ if (array_key_exists('file', $trigger)) {
+ $attrs = $this->applyFileAttr($attrs, $trigger['file']);
+ } elseif ($type === 'image') {
+ $attrs['data-file'] = 'image';
+ } else {
+ $attrs['data-file'] = null;
+ }
+
+ return sprintf('', $context->attrs($attrs));
+ }
+
+ /**
+ * @param array $field
+ */
+ public function renderInitScript(array $field, string $type): string
+ {
+ $runtime = is_array($field['upload']['runtime'] ?? null) ? $field['upload']['runtime'] : [];
+ $display = $this->resolveDisplayMode($field, $type);
+ $name = strval($field['name'] ?? '');
+ $selector = trim(strval($runtime['selector'] ?? sprintf('input[name="%s"]', $name)));
+ $method = trim(strval($runtime['method'] ?? $this->runtimeMethod($type, $display)));
+ if ($selector === '' || $method === '') {
+ return '';
+ }
+ return sprintf('$(%s).%s()', json_encode($selector, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '""', $method);
+ }
+
+ private function runtimeMethod(string $type, string $display): string
+ {
+ return match ($type) {
+ 'image' => $display === 'preview' ? 'uploadOneImage' : '',
+ 'video' => 'uploadOneVideo',
+ 'images' => 'uploadMultipleImage',
+ default => throw new \InvalidArgumentException("FormBuilder 上传字段类型无效: {$type}"),
+ };
+ }
+
+ /**
+ * @param array $attrs
+ * @return array
+ */
+ private function applyFileAttr(array $attrs, mixed $file): array
+ {
+ if ($file === false) {
+ unset($attrs['data-file']);
+ return $attrs;
+ }
+ if ($file === true || $file === null || $file === '') {
+ $attrs['data-file'] = null;
+ return $attrs;
+ }
+ $attrs['data-file'] = strval($file);
+ return $attrs;
+ }
+}
diff --git a/plugin/think-library/src/builder/form/render/SelectFieldRenderer.php b/plugin/think-library/src/builder/form/render/SelectFieldRenderer.php
new file mode 100644
index 000000000..021529b92
--- /dev/null
+++ b/plugin/think-library/src/builder/form/render/SelectFieldRenderer.php
@@ -0,0 +1,27 @@
+field();
+ $attrs = $context->resolveInputAttrs('layui-select');
+ $attrs['name'] = $field['name'];
+
+ $control = sprintf('';
+ return $this->renderFieldShell($context, $control);
+ }
+}
diff --git a/plugin/think-library/src/builder/form/render/TextFieldRenderer.php b/plugin/think-library/src/builder/form/render/TextFieldRenderer.php
new file mode 100644
index 000000000..9a201f9ab
--- /dev/null
+++ b/plugin/think-library/src/builder/form/render/TextFieldRenderer.php
@@ -0,0 +1,39 @@
+field();
+ if ($context->type() === 'textarea') {
+ $attrs = $context->resolveInputAttrs('layui-textarea');
+ $attrs['placeholder'] = $attrs['placeholder'] ?? BuilderLang::format('请输入%s', [strval($field['title'])]);
+ return $this->renderFieldShell(
+ $context,
+ sprintf('', $field['name'], $context->attrs($attrs), $context->valueExpression())
+ );
+ }
+
+ $attrs = $context->resolveInputAttrs('layui-input');
+ if ($context->type() !== 'text' && !isset($attrs['type'])) {
+ $attrs['type'] = $context->type();
+ }
+ $attrs['placeholder'] = $attrs['placeholder'] ?? BuilderLang::format('请输入%s', [strval($field['title'])]);
+ $control = sprintf('', $field['name'], $context->attrs($attrs), $context->valueExpression());
+ $addon = $context->renderInputContent();
+ if ($addon !== '') {
+ $control .= $addon;
+ }
+ return $this->renderFieldShell($context, $control, 'label', 'layui-form-item block relative', $addon !== '' ? 'relative' : '', $addon !== '');
+ }
+}
diff --git a/plugin/think-library/src/builder/form/render/UploadFieldRenderer.php b/plugin/think-library/src/builder/form/render/UploadFieldRenderer.php
new file mode 100644
index 000000000..3322bf190
--- /dev/null
+++ b/plugin/think-library/src/builder/form/render/UploadFieldRenderer.php
@@ -0,0 +1,77 @@
+type() === 'images' ? $this->renderMultiple($context) : $this->renderSingle($context);
+ }
+
+ private function renderSingle(FormFieldRenderContext $context): string
+ {
+ $field = $context->field();
+ $type = $context->type();
+ $display = $this->runtimeRenderer()->resolveDisplayMode($field, $type);
+ $attrs = $context->resolveInputAttrs($display === 'preview' ? '' : 'layui-input layui-bg-gray');
+ $attrs['type'] = $display === 'preview' ? 'hidden' : 'text';
+ if ($display === 'preview') {
+ $attrs['data-upload-display'] = 'preview';
+ } else {
+ $attrs['placeholder'] = $attrs['placeholder'] ?? BuilderLang::format('请上传%s', [strval($field['title'])]);
+ }
+
+ $uploadTypes = $this->runtimeRenderer()->resolveUploadTypes($field, $type);
+ $body = "\n\t\t\t\t" . sprintf('', $field['name'], $context->attrs($attrs), $context->valueExpression());
+ if ($display === 'input') {
+ $body .= "\n\t\t\t\t" . $this->runtimeRenderer()->renderTrigger($context, $field['name'], $type, $uploadTypes);
+ }
+
+ $html = $this->renderFieldShell(
+ $context,
+ $body . "\n\t\t\t",
+ 'div',
+ 'layui-form-item',
+ 'relative block label-required-null',
+ true
+ );
+ return $this->appendInlineScript($html, $this->runtimeRenderer()->renderInitScript($field, $type));
+ }
+
+ private function renderMultiple(FormFieldRenderContext $context): string
+ {
+ $field = $context->field();
+ $attrs = $context->resolveInputAttrs();
+ $attrs['type'] = 'hidden';
+ $attrs['placeholder'] = $attrs['placeholder'] ?? BuilderLang::format('请上传%s ( 多图 )', [strval($field['title'])]);
+
+ $body = "\n\t\t\t\t" . sprintf('', $field['name'], $context->attrs($attrs), $context->joinedValueExpression()) . "\n\t\t\t";
+ return $this->appendInlineScript(
+ $this->renderFieldShell(
+ $context,
+ $body,
+ 'div',
+ 'layui-form-item',
+ 'layui-textarea help-images layui-bg-gray',
+ true
+ ),
+ $this->runtimeRenderer()->renderInitScript($field, 'images')
+ );
+ }
+
+ private function runtimeRenderer(): FormUploadRuntimeRenderer
+ {
+ return $this->runtimeRenderer ??= new FormUploadRuntimeRenderer();
+ }
+}
diff --git a/plugin/think-library/src/builder/page/PageAction.php b/plugin/think-library/src/builder/page/PageAction.php
new file mode 100644
index 000000000..02edb0f20
--- /dev/null
+++ b/plugin/think-library/src/builder/page/PageAction.php
@@ -0,0 +1,202 @@
+ $action
+ */
+ public function __construct(private PageBuilder $builder, private string $scope, private array $action = [])
+ {
+ }
+
+ public function attach(int $index, ?int $version = null): self
+ {
+ $this->index = $index;
+ $this->version = $version;
+ $this->syncHandler = null;
+ return $this;
+ }
+
+ public function attachSync(callable $syncHandler): self
+ {
+ $this->index = null;
+ $this->version = null;
+ $this->syncHandler = $syncHandler;
+ return $this;
+ }
+
+ public function type(string $type): self
+ {
+ $this->action['type'] = trim($type);
+ return $this->sync();
+ }
+
+ public function label(string $label): self
+ {
+ $this->action['label'] = $label;
+ return $this->sync();
+ }
+
+ public function url(string $url): self
+ {
+ $this->action['url'] = $url;
+ return $this->sync();
+ }
+
+ public function title(string $title): self
+ {
+ $this->action['title'] = $title;
+ return $this->sync();
+ }
+
+ public function tag(string $tag): self
+ {
+ $this->action['tag'] = trim($tag);
+ return $this->sync();
+ }
+
+ public function value(string $value): self
+ {
+ $this->action['value'] = $value;
+ return $this->sync();
+ }
+
+ public function confirm(string $confirm): self
+ {
+ $this->action['confirm'] = $confirm;
+ return $this->sync();
+ }
+
+ public function rule(string $rule): self
+ {
+ $this->action['rule'] = $rule;
+ return $this->sync();
+ }
+
+ public function auth(?string $auth): self
+ {
+ $this->action['auth'] = $auth;
+ return $this->sync();
+ }
+
+ public function html(string $html): self
+ {
+ $this->action['type'] = 'html';
+ $this->action['html'] = $html;
+ return $this->sync();
+ }
+
+ public function attrs(array $attrs): self
+ {
+ $merged = is_array($this->action['attrs'] ?? null) ? $this->action['attrs'] : [];
+ foreach ($attrs as $name => $value) {
+ if (is_string($name) && trim($name) !== '') {
+ $merged[trim($name)] = $value;
+ }
+ }
+ $this->action['attrs'] = $merged;
+ return $this->sync();
+ }
+
+ public function attr(string $name, mixed $value = null): self
+ {
+ $name = trim($name);
+ if ($name === '') {
+ return $this;
+ }
+ $attrs = is_array($this->action['attrs'] ?? null) ? $this->action['attrs'] : [];
+ $attrs[$name] = $value;
+ $this->action['attrs'] = $attrs;
+ return $this->sync();
+ }
+
+ public function attrsItem(): BuilderAttributeBag
+ {
+ $attrs = is_array($this->action['attrs'] ?? null) ? $this->action['attrs'] : [];
+ $class = trim(strval($this->action['class'] ?? ''));
+ return (new BuilderAttributeBag($this, $attrs, true, $class))
+ ->attach(fn(array $state): array => $this->replaceAttributeState($state));
+ }
+
+ public function class(string|array $class): self
+ {
+ $this->action['class'] = BuilderAttributes::mergeClassNames(strval($this->action['class'] ?? ''), $class);
+ return $this->sync();
+ }
+
+ /**
+ * @return array
+ */
+ public function export(): array
+ {
+ return $this->action;
+ }
+
+ private function sync(): self
+ {
+ if (is_callable($this->syncHandler)) {
+ $this->action = ($this->syncHandler)($this->action);
+ return $this;
+ }
+
+ if (!$this->canSync()) {
+ return $this;
+ }
+
+ if ($this->scope === 'row') {
+ $this->builder->replaceRowAction($this->index, $this->action);
+ } else {
+ $this->builder->replaceButtonAction($this->index, $this->action);
+ }
+
+ return $this;
+ }
+
+ private function canSync(): bool
+ {
+ if ($this->index === null) {
+ return false;
+ }
+ return $this->scope !== 'row' || $this->builder->canSyncTableAttachment($this->version);
+ }
+
+ /**
+ * @param array $state
+ * @return array
+ */
+ private function replaceAttributeState(array $state): array
+ {
+ $this->action['attrs'] = is_array($state['attrs'] ?? null) ? BuilderAttributes::make($state['attrs'])->all() : [];
+ $this->action['class'] = trim(strval($state['class'] ?? $this->action['class'] ?? ''));
+
+ if (is_callable($this->syncHandler)) {
+ $this->action = ($this->syncHandler)($this->action);
+ } elseif ($this->canSync()) {
+ if ($this->scope === 'row') {
+ $this->action = $this->builder->replaceRowAction($this->index, $this->action);
+ } else {
+ $this->action = $this->builder->replaceButtonAction($this->index, $this->action);
+ }
+ }
+
+ return [
+ 'attrs' => is_array($this->action['attrs'] ?? null) ? $this->action['attrs'] : [],
+ 'class' => trim(strval($this->action['class'] ?? '')),
+ ];
+ }
+}
diff --git a/plugin/think-library/src/builder/page/PageActionNormalizer.php b/plugin/think-library/src/builder/page/PageActionNormalizer.php
new file mode 100644
index 000000000..60099ff70
--- /dev/null
+++ b/plugin/think-library/src/builder/page/PageActionNormalizer.php
@@ -0,0 +1,170 @@
+renderer = $renderer ?? new BuilderActionRenderer();
+ }
+
+ /**
+ * @param array $action
+ * @return array
+ */
+ public function button(array $action): array
+ {
+ $action = $this->normalizeBase($action);
+ if ($action['type'] === 'html') {
+ return $action;
+ }
+
+ $attrs = match ($action['type']) {
+ 'modal' => $this->buildAttrs($action['attrs'], 'layui-btn layui-btn-sm layui-btn-primary', [
+ 'data-modal' => $action['url'],
+ ], [
+ 'data-title' => $action['title'],
+ ]),
+ 'button' => $this->buildAttrs($action['attrs'], 'layui-btn layui-btn-sm layui-btn-primary'),
+ 'open' => $this->buildAttrs($action['attrs'], 'layui-btn layui-btn-sm layui-btn-primary', [
+ 'data-open' => $action['url'],
+ ]),
+ 'load' => $this->buildAttrs($action['attrs'], 'layui-btn layui-btn-sm layui-btn-primary', [
+ 'data-load' => $action['url'],
+ 'data-table-id' => strval($action['attrs']['data-table-id'] ?? $this->tableId),
+ ]),
+ 'action' => $this->buildAttrs($action['attrs'], 'layui-btn layui-btn-sm layui-btn-primary', [
+ 'data-action' => $action['url'],
+ ], [
+ 'data-value' => $action['value'],
+ 'data-confirm' => $action['confirm'],
+ ]),
+ 'batch-action' => $this->buildAttrs($action['attrs'], 'layui-btn layui-btn-sm layui-btn-primary', [
+ 'data-table-id' => $this->tableId,
+ 'data-action' => $action['url'],
+ 'data-rule' => $action['rule'],
+ ], [
+ 'data-confirm' => $action['confirm'],
+ ]),
+ default => throw new \InvalidArgumentException("PageBuilder 按钮类型无效: {$action['type']}"),
+ };
+
+ return array_merge($action, [
+ 'attrs' => $attrs,
+ 'html' => $this->renderer->render(strval($action['label']), $attrs, strval($action['tag'] ?? 'a')),
+ ]);
+ }
+
+ /**
+ * @param array $action
+ * @return array
+ */
+ public function row(array $action): array
+ {
+ $action = $this->normalizeBase($action);
+ if ($action['type'] === 'html') {
+ return $action;
+ }
+
+ $attrs = match ($action['type']) {
+ 'modal' => $this->buildAttrs($action['attrs'], 'layui-btn layui-btn-sm', [
+ 'data-modal' => $action['url'],
+ ], [
+ 'data-title' => $action['title'],
+ ]),
+ 'button' => $this->buildAttrs($action['attrs'], 'layui-btn layui-btn-sm'),
+ 'open' => $this->buildAttrs($action['attrs'], 'layui-btn layui-btn-sm', [
+ 'data-open' => $action['url'],
+ ], [
+ 'data-title' => $action['title'],
+ ]),
+ 'action' => $this->buildAttrs($action['attrs'], 'layui-btn layui-btn-sm layui-btn-danger', [
+ 'data-action' => $action['url'],
+ 'data-value' => $action['value'] === '' ? 'id#{{d.id}}' : $action['value'],
+ ], [
+ 'data-confirm' => $action['confirm'],
+ ]),
+ default => throw new \InvalidArgumentException("PageBuilder 行操作类型无效: {$action['type']}"),
+ };
+
+ return array_merge($action, [
+ 'attrs' => $attrs,
+ 'html' => $this->renderer->render(strval($action['label']), $attrs, strval($action['tag'] ?? 'a')),
+ ]);
+ }
+
+ /**
+ * @param array $action
+ * @return array
+ */
+ private function normalizeBase(array $action): array
+ {
+ $action = array_merge([
+ 'type' => '',
+ 'label' => '',
+ 'url' => '',
+ 'title' => '',
+ 'value' => '',
+ 'confirm' => '',
+ 'rule' => '',
+ 'auth' => null,
+ 'html' => '',
+ 'attrs' => [],
+ 'class' => '',
+ 'tag' => 'a',
+ ], $action);
+
+ $auth = $action['auth'];
+ $action['type'] = trim(strval($action['type']));
+ $action['label'] = BuilderLang::text(strval($action['label']));
+ $action['url'] = strval($action['url']);
+ $action['title'] = BuilderLang::text(strval($action['title']));
+ $action['value'] = strval($action['value']);
+ $action['confirm'] = BuilderLang::text(strval($action['confirm']));
+ $action['rule'] = strval($action['rule']);
+ $action['html'] = strval($action['html']);
+ $action['attrs'] = BuilderLang::attrs(is_array($action['attrs']) ? $action['attrs'] : []);
+ $action['auth'] = $auth === null ? null : (trim(strval($auth)) ?: null);
+ $action['tag'] = trim(strval($action['tag'])) ?: 'a';
+
+ $class = trim(strval($action['class']));
+ if ($class !== '') {
+ $action['attrs']['class'] = BuilderAttributes::mergeClassNames(strval($action['attrs']['class'] ?? ''), $class);
+ }
+
+ return $action;
+ }
+
+ /**
+ * @param array $attrs
+ * @param array $fixed
+ * @param array $optional
+ * @return array
+ */
+ private function buildAttrs(array $attrs, string $class, array $fixed = [], array $optional = []): array
+ {
+ $attrs = BuilderAttributes::make($attrs)->class($class)->all();
+ foreach ($fixed as $name => $value) {
+ $attrs[$name] = $value;
+ }
+ foreach ($optional as $name => $value) {
+ if ($value !== '') {
+ $attrs[$name] = $value;
+ }
+ }
+ return BuilderLang::attrs($attrs);
+ }
+}
diff --git a/plugin/think-library/src/builder/page/PageBlocks.php b/plugin/think-library/src/builder/page/PageBlocks.php
new file mode 100644
index 000000000..1203d52aa
--- /dev/null
+++ b/plugin/think-library/src/builder/page/PageBlocks.php
@@ -0,0 +1,99 @@
+section();
+ if ($class !== null && $class !== '') {
+ $node->class($class);
+ }
+ if (is_callable($callback)) {
+ $callback($node);
+ }
+ return $node;
+ }
+
+ public static function card(PageNode $parent, ?string $title = null, ?callable $callback = null, string $class = 'layui-card'): PageNode
+ {
+ $card = $parent->div()->class($class);
+ if ($title !== null && $title !== '') {
+ $card->div()->class('layui-card-header notselect')->html(sprintf(
+ '%s',
+ self::escape($title)
+ ));
+ }
+ $body = $card->div()->class('layui-card-body');
+ if (is_callable($callback)) {
+ $callback($body);
+ }
+ return $card;
+ }
+
+ public static function grid(PageNode $parent, string $class, callable $callback): PageNode
+ {
+ $node = $parent->div()->class($class);
+ $callback($node);
+ return $node;
+ }
+
+ public static function column(PageNode $parent, string $class, callable $callback): PageNode
+ {
+ $node = $parent->div()->class($class);
+ $callback($node);
+ return $node;
+ }
+
+ public static function title(PageNode $parent, string $title, string $tag = 'h3', string $class = ''): PageNode
+ {
+ $node = $parent->node($tag);
+ if ($class !== '') {
+ $node->class($class);
+ }
+ $node->html(self::escape($title));
+ return $node;
+ }
+
+ public static function text(PageNode $parent, string $text, string $class = ''): PageNode
+ {
+ $node = $parent->div();
+ if ($class !== '') {
+ $node->class($class);
+ }
+ $node->html(self::escape($text));
+ return $node;
+ }
+
+ public static function stat(PageNode $parent, string $label, string $value, string $class = ''): PageNode
+ {
+ $node = $parent->div()->class(trim($class) === '' ? 'ta-stat-item' : $class);
+ $node->html(sprintf('%s%s', self::escape($label), self::escape($value)));
+ return $node;
+ }
+
+ public static function kv(PageNode $parent, string $label, string $value, string $class = ''): PageNode
+ {
+ $node = $parent->div()->class(trim($class) === '' ? 'ta-kv-item' : $class);
+ $node->html(sprintf(
+ '%s%s',
+ self::escape($label),
+ self::escape($value)
+ ));
+ return $node;
+ }
+
+ private static function escape(string $content): string
+ {
+ return htmlentities(BuilderLang::text($content), ENT_QUOTES, 'UTF-8');
+ }
+}
diff --git a/plugin/think-library/src/builder/page/PageBuilder.php b/plugin/think-library/src/builder/page/PageBuilder.php
new file mode 100644
index 000000000..d11df7394
--- /dev/null
+++ b/plugin/think-library/src/builder/page/PageBuilder.php
@@ -0,0 +1,2041 @@
+
+ */
+ private array $tableTemplateKeys = [];
+
+ /**
+ * 表格生成的启动脚本索引.
+ * @var array
+ */
+ private array $tableBootScriptIndexes = [];
+
+ /**
+ * 表格生成的脚本索引.
+ * @var array
+ */
+ private array $tableScriptIndexes = [];
+
+ /**
+ * PageBuilder 构造函数.
+ *
+ * @param Controller $class 当前控制器实例
+ */
+ public function __construct(Controller $class)
+ {
+ $this->class = $class;
+ $this->tableOptions = $this->normalizeTableOptions([]);
+ }
+
+ /**
+ * 创建列表页生成器.
+ */
+ public static function make(): self
+ {
+ return Library::$sapp->invokeClass(static::class);
+ }
+
+ /**
+ * 创建普通 DOM 页面.
+ */
+ public static function domPage(): self
+ {
+ return self::make()->preset('dom-page');
+ }
+
+ /**
+ * 创建整页表格页面.
+ */
+ public static function tablePage(): self
+ {
+ return self::make()->preset('table-page');
+ }
+
+ /**
+ * 创建弹层列表页面.
+ */
+ public static function dialogList(): self
+ {
+ return self::make()->preset('dialog-list');
+ }
+
+ /**
+ * 创建原始 JS 片段包装对象
+ *
+ * @param string $script JavaScript 代码
+ * @return array 返回包装数组
+ */
+ public static function js(string $script): array
+ {
+ return ['__raw__' => $script];
+ }
+
+ /**
+ * 定义页面结构.
+ * @param callable(PageLayout): void $callback
+ * @return $this
+ */
+ public function define(callable $callback): self
+ {
+ $layout = new PageLayout($this);
+ $this->layout = $layout;
+ $callback($layout);
+ return $this;
+ }
+
+ /**
+ * 完成页面构建.
+ * @return $this
+ */
+ public function build(): self
+ {
+ return $this;
+ }
+
+ public function preset(string $preset): self
+ {
+ $preset = trim($preset);
+ if ($preset !== '') {
+ $this->preset = $preset;
+ }
+ return $this;
+ }
+
+ public function getPreset(): string
+ {
+ return $this->preset;
+ }
+
+ /**
+ * 设置页面标题.
+ * @return $this
+ */
+ public function setTitle(string $title): self
+ {
+ $this->title = $title;
+ return $this;
+ }
+
+ /**
+ * 设置内容样式类.
+ * @return $this
+ */
+ public function setContentClass(string $class): self
+ {
+ $this->contentClass = trim($class);
+ return $this;
+ }
+
+ /**
+ * 设置搜索表单属性.
+ * @return $this
+ */
+ public function setSearchAttrs(array $attrs): self
+ {
+ $this->searchAttrs = BuilderAttributes::make($this->searchAttrs)->merge($attrs)->all();
+ return $this;
+ }
+
+ public function syncSearchNode(PageSearch $node, array $attrs): self
+ {
+ if ($this->searchNode === $node) {
+ $this->searchNodeAttrs = BuilderAttributes::make($attrs)->all();
+ }
+ return $this;
+ }
+
+ public function activateSearchNode(PageSearch $node): self
+ {
+ ++$this->searchVersion;
+ $this->searchNode = $node;
+ $this->searchFields = [];
+ $this->searchNodeAttrs = [];
+ return $this;
+ }
+
+ public function deactivateSearchNode(PageSearch $node): self
+ {
+ if ($this->searchNode === $node) {
+ ++$this->searchVersion;
+ $this->searchNode = null;
+ $this->searchFields = [];
+ $this->searchNodeAttrs = [];
+ }
+ return $this;
+ }
+
+ public function isActiveSearchNode(PageSearch $node): bool
+ {
+ return $this->searchNode === $node;
+ }
+
+ public function currentSearchVersion(): int
+ {
+ return $this->searchVersion;
+ }
+
+ public function nextSearchFormId(): string
+ {
+ ++$this->searchNodeSeed;
+ return 'PageSearchForm' . $this->searchNodeSeed;
+ }
+
+ public function canSyncSearchAttachment(?int $version): bool
+ {
+ return $version === null || ($this->searchNode instanceof PageSearch && $this->searchVersion === $version);
+ }
+
+ /**
+ * 设置搜索图例.
+ * @return $this
+ */
+ public function setSearchLegend(string $legend): self
+ {
+ $this->searchLegend = $legend;
+ return $this;
+ }
+
+ /**
+ * 切换搜索图例显示.
+ * @return $this
+ */
+ public function withSearchLegend(bool $show = true): self
+ {
+ $this->searchLegendEnabled = $show;
+ return $this;
+ }
+
+ /**
+ * 设置表格.
+ * @return $this
+ */
+ public function setTable(string $id = 'PageDataTable', ?string $url = null, array $attrs = []): self
+ {
+ $this->tableId = $id;
+ $this->tableUrl = $url;
+ $this->tableAttrs = BuilderAttributes::make($attrs)->all();
+ return $this;
+ }
+
+ public function activateTableNode(PageTable $node, string $id = 'PageDataTable', ?string $url = null, array $attrs = []): self
+ {
+ ++$this->tableVersion;
+ if ($this->tableNode !== $node) {
+ $this->resetTableState();
+ }
+ $this->tableNode = $node;
+ return $this->setTable($id, $url, $attrs);
+ }
+
+ public function deactivateTableNode(PageTable $node): self
+ {
+ if ($this->tableNode === $node) {
+ ++$this->tableVersion;
+ $this->tableNode = null;
+ $this->resetTableState();
+ }
+ return $this;
+ }
+
+ public function isActiveTableNode(PageTable $node): bool
+ {
+ return $this->tableNode === $node;
+ }
+
+ public function currentTableVersion(): int
+ {
+ return $this->tableVersion;
+ }
+
+ public function canSyncTableAttachment(?int $version): bool
+ {
+ return $version === null || ($this->tableNode instanceof PageTable && $this->tableVersion === $version);
+ }
+
+ public function encodeJsValue(mixed $value): string
+ {
+ return $this->encodeJs($value);
+ }
+
+ /**
+ * 设置表格参数.
+ * @return $this
+ */
+ public function setTableOptions(array $options): self
+ {
+ $this->tableOptions = $this->mergeAssoc($this->tableOptions, $options);
+ return $this;
+ }
+
+ public function addTableBootScript(PageTable $node, string $script): self
+ {
+ if (!$this->isActiveTableNode($node)) {
+ return $this;
+ }
+ $script = trim($script);
+ if ($script === '') {
+ return $this;
+ }
+ $this->bootScripts[] = $script;
+ end($this->bootScripts);
+ $index = key($this->bootScripts);
+ if (is_int($index)) {
+ $this->tableBootScriptIndexes[] = $index;
+ }
+ return $this;
+ }
+
+ public function addTableScript(PageTable $node, string $script): self
+ {
+ if (!$this->isActiveTableNode($node)) {
+ return $this;
+ }
+ $script = trim($script);
+ if ($script === '') {
+ return $this;
+ }
+ $this->scripts[] = $script;
+ end($this->scripts);
+ $index = key($this->scripts);
+ if (is_int($index)) {
+ $this->tableScriptIndexes[] = $index;
+ }
+ return $this;
+ }
+
+ public function addTableTemplate(PageTable $node, string $id, string $html): self
+ {
+ if (!$this->isActiveTableNode($node)) {
+ return $this;
+ }
+ $id = trim($id);
+ if ($id !== '') {
+ $this->templates[$id] = $html;
+ $this->trackTableTemplateKeys([$id], []);
+ }
+ return $this;
+ }
+
+ /**
+ * @return array
+ */
+ public function getTableOptions(): array
+ {
+ return $this->tableOptions;
+ }
+
+ /**
+ * @param array $options
+ */
+ public function createTableOptions(array $options = []): PageTableOptions
+ {
+ return new PageTableOptions($this, $this->mergeAssoc($this->tableOptions, $options));
+ }
+
+ public function attachTableOptions(PageTableOptions $options): PageTableOptions
+ {
+ return $options->attach($this->replaceTableOptions($options->export()), $this->currentTableVersion());
+ }
+
+ /**
+ * @param array $options
+ * @return array
+ */
+ public function replaceTableOptions(array $options): array
+ {
+ $this->tableOptions = $this->normalizeTableOptions($options);
+ return $this->tableOptions;
+ }
+
+ /**
+ * 设置工具条模板 ID.
+ * @return $this
+ */
+ public function setToolbarId(string $toolbarId): self
+ {
+ $this->toolbarId = trim($toolbarId) ?: 'toolbar';
+ return $this;
+ }
+
+ /**
+ * 添加弹窗按钮.
+ */
+ public function addModalButton(string $label, string $url, string $title = '', array $attrs = [], ?string $auth = null): self
+ {
+ return $this->appendButtonAction($this->actionNormalizer()->button([
+ 'type' => 'modal',
+ 'label' => $label,
+ 'title' => $title,
+ 'url' => $url,
+ 'auth' => $auth,
+ 'attrs' => $attrs,
+ ]));
+ }
+
+ /**
+ * 添加跳转按钮.
+ */
+ public function addOpenButton(string $label, string $url, array $attrs = [], ?string $auth = null): self
+ {
+ return $this->appendButtonAction($this->actionNormalizer()->button([
+ 'type' => 'open',
+ 'label' => $label,
+ 'url' => $url,
+ 'auth' => $auth,
+ 'attrs' => $attrs,
+ ]));
+ }
+
+ /**
+ * 添加加载按钮.
+ */
+ public function addLoadButton(string $label, string $url, array $attrs = [], ?string $auth = null): self
+ {
+ return $this->appendButtonAction($this->actionNormalizer()->button([
+ 'type' => 'load',
+ 'label' => $label,
+ 'url' => $url,
+ 'auth' => $auth,
+ 'attrs' => $attrs,
+ ]));
+ }
+
+ /**
+ * 添加通用动作按钮.
+ */
+ public function addActionButton(string $label, string $url, string $value = '', string $confirm = '', array $attrs = [], ?string $auth = null): self
+ {
+ return $this->appendButtonAction($this->actionNormalizer()->button([
+ 'type' => 'action',
+ 'label' => $label,
+ 'url' => $url,
+ 'value' => $value,
+ 'confirm' => $confirm,
+ 'auth' => $auth,
+ 'attrs' => $attrs,
+ ]));
+ }
+
+ /**
+ * 添加头部按钮 HTML.
+ * @return $this
+ */
+ public function addButtonHtml(string $html, array $schema = []): self
+ {
+ $this->buttons[] = $html;
+ $this->buttonItems[] = array_merge(['html' => $html], $schema);
+ return $this;
+ }
+
+ /**
+ * 添加结构化按钮.
+ */
+ public function addButton(array $button): self
+ {
+ return $this->appendButtonAction($this->actionNormalizer()->button($button));
+ }
+
+ /**
+ * @param array $action
+ */
+ public function createButtonAction(array $action = []): PageAction
+ {
+ return new PageAction($this, 'button', $action);
+ }
+
+ public function attachButtonAction(PageAction $action): PageAction
+ {
+ $index = count($this->buttonItems);
+ $this->storeButtonAction($action->export(), $index);
+ return $action->attach($index);
+ }
+
+ /**
+ * @param array $action
+ * @return array
+ */
+ public function replaceButtonAction(int $index, array $action): array
+ {
+ return $this->storeButtonAction($action, $index);
+ }
+
+ /**
+ * 添加批量操作按钮.
+ */
+ public function addBatchActionButton(string $label, string $url, string $rule, string $confirm = '', array $attrs = [], ?string $auth = null): self
+ {
+ return $this->appendButtonAction($this->actionNormalizer()->button([
+ 'type' => 'batch-action',
+ 'label' => $label,
+ 'url' => $url,
+ 'rule' => $rule,
+ 'confirm' => $confirm,
+ 'auth' => $auth,
+ 'attrs' => $attrs,
+ ]));
+ }
+
+ /**
+ * 添加搜索输入框.
+ * @return $this
+ */
+ public function addSearchInput(string $name, string $label, string $placeholder = '', array $attrs = []): self
+ {
+ return $this->addSearchField($this->searchFieldNormalizer()->input($name, $label, $placeholder, $attrs));
+ }
+
+ /**
+ * 添加搜索字段.
+ * @return $this
+ */
+ public function addSearchField(array $field): self
+ {
+ $this->searchFields[] = $this->searchFieldNormalizer()->field($field);
+ return $this;
+ }
+
+ /**
+ * 创建搜索字段对象.
+ * @param array $field
+ */
+ public function createSearchField(array $field): PageSearchField
+ {
+ return new PageSearchField($this, $this->searchFieldNormalizer()->field($field));
+ }
+
+ public function attachSearchField(PageSearchField $field): PageSearchField
+ {
+ $index = count($this->searchFields);
+ $normalized = $this->searchFieldNormalizer()->field($field->export());
+ $this->searchFields[$index] = $normalized;
+ return $field->attach($index, $normalized, $this->currentSearchVersion());
+ }
+
+ /**
+ * @param array $field
+ * @return array
+ */
+ public function replaceSearchField(int $index, array $field): array
+ {
+ $normalized = $this->searchFieldNormalizer()->field($field);
+ $this->searchFields[$index] = $normalized;
+ return $normalized;
+ }
+
+ /**
+ * 添加搜索下拉框.
+ * @return $this
+ */
+ public function addSearchSelect(string $name, string $label, array $options = [], array $attrs = [], string $source = ''): self
+ {
+ return $this->addSearchField($this->searchFieldNormalizer()->select($name, $label, $options, $attrs, $source));
+ }
+
+ /**
+ * 添加搜索日期范围.
+ * @return $this
+ */
+ public function addSearchDateRange(string $name, string $label, string $placeholder = '', array $attrs = []): self
+ {
+ return $this->addSearchField($this->searchFieldNormalizer()->dateRange($name, $label, $placeholder, $attrs));
+ }
+
+ /**
+ * 添加搜索隐藏字段.
+ * @return $this
+ */
+ public function addSearchHidden(string $name, string $value = ''): self
+ {
+ return $this->addSearchField($this->searchFieldNormalizer()->hidden($name, $value));
+ }
+
+ /**
+ * 添加搜索按钮.
+ * @return $this
+ */
+ public function addSearchSubmitButton(string $label = '搜 索', array $attrs = []): self
+ {
+ return $this->addSearchField($this->searchFieldNormalizer()->submit($label, $attrs));
+ }
+
+ /**
+ * 添加勾选列.
+ * @return $this
+ */
+ public function addCheckboxColumn(array $options = []): self
+ {
+ return $this->addColumn(array_merge(['checkbox' => true, 'fixed' => true], $options));
+ }
+
+ /**
+ * 添加排序输入列.
+ * @return $this
+ */
+ public function addSortInputColumn(string $actionUrl = '{:sysuri()}', array $options = []): self
+ {
+ $this->attachSortInputColumn($this->createSortInputColumn($actionUrl, $options));
+ return $this;
+ }
+
+ /**
+ * 添加表格列.
+ * @return $this
+ */
+ public function addColumn(array $column): self
+ {
+ $this->columns[] = $column;
+ return $this;
+ }
+
+ /**
+ * @param array $column
+ */
+ public function createColumn(array $column = []): PageColumn
+ {
+ return new PageColumn($this, $column);
+ }
+
+ public function createSortInputColumn(string $actionUrl = '{:sysuri()}', array $options = []): PageSortInputColumn
+ {
+ return new PageSortInputColumn($this, $actionUrl, $options);
+ }
+
+ public function attachSortInputColumn(PageSortInputColumn $column): PageSortInputColumn
+ {
+ return $column->attachResult($this->storeSortInputColumn($column->getActionUrl(), $column->export()));
+ }
+
+ /**
+ * @param array $options
+ * @param array{templateKeys?: array, scriptIndexes?: array} $meta
+ * @return array
+ */
+ public function replaceSortInputColumn(int $index, string $actionUrl, array $options, array $meta = []): array
+ {
+ return $this->storeSortInputColumn($actionUrl, $options, $index, $meta);
+ }
+
+ public function attachColumn(PageColumn $column): PageColumn
+ {
+ $index = count($this->columns);
+ $this->columns[$index] = $column->export();
+ return $column->attach($index, $this->columns[$index], $this->currentTableVersion());
+ }
+
+ /**
+ * @param array $column
+ * @return array
+ */
+ public function replaceColumn(int $index, array $column): array
+ {
+ $this->columns[$index] = $column;
+ return $column;
+ }
+
+ /**
+ * 添加工具条列.
+ * @return $this
+ */
+ public function addToolbarColumn(string $title = '操作面板', array $options = []): self
+ {
+ $this->attachToolbarColumn($this->createToolbarColumn($title, $options));
+ return $this;
+ }
+
+ /**
+ * 添加状态开关列.
+ * @return $this
+ */
+ public function addStatusSwitchColumn(string $actionUrl, array $options = []): self
+ {
+ $this->attachStatusSwitchColumn($this->createStatusSwitchColumn($actionUrl, $options));
+ return $this;
+ }
+
+ public function createToolbarColumn(string $title = '操作面板', array $options = []): PageToolbarColumn
+ {
+ return new PageToolbarColumn($this, $title, $options);
+ }
+
+ public function attachToolbarColumn(PageToolbarColumn $column): PageToolbarColumn
+ {
+ return $column->attachResult($this->storeToolbarColumn($column->export()));
+ }
+
+ /**
+ * @param array $options
+ * @param array{templateKeys?: array, scriptIndexes?: array} $meta
+ * @return array
+ */
+ public function replaceToolbarColumn(int $index, array $options, array $meta = []): array
+ {
+ return $this->storeToolbarColumn($options, $index, $meta);
+ }
+
+ public function createStatusSwitchColumn(string $actionUrl, array $options = []): PageStatusSwitchColumn
+ {
+ return new PageStatusSwitchColumn($this, $actionUrl, $options);
+ }
+
+ public function attachStatusSwitchColumn(PageStatusSwitchColumn $column): PageStatusSwitchColumn
+ {
+ return $column->attachResult($this->storeStatusSwitchColumn($column->getActionUrl(), $column->export()));
+ }
+
+ /**
+ * @param array $options
+ * @param array{templateKeys?: array, scriptIndexes?: array} $meta
+ * @return array
+ */
+ public function replaceStatusSwitchColumn(int $index, string $actionUrl, array $options, array $meta = []): array
+ {
+ return $this->storeStatusSwitchColumn($actionUrl, $options, $index, $meta);
+ }
+
+ /**
+ * 添加行弹窗按钮.
+ */
+ public function addRowModalAction(string $label, string $url, string $title = '', array $attrs = [], ?string $auth = null): self
+ {
+ return $this->appendRowAction($this->actionNormalizer()->row([
+ 'type' => 'modal',
+ 'label' => $label,
+ 'title' => $title,
+ 'url' => $url,
+ 'auth' => $auth,
+ 'attrs' => $attrs,
+ ]));
+ }
+
+ /**
+ * 添加行跳转按钮.
+ */
+ public function addRowOpenAction(string $label, string $url, string $title = '', array $attrs = [], ?string $auth = null): self
+ {
+ return $this->appendRowAction($this->actionNormalizer()->row([
+ 'type' => 'open',
+ 'label' => $label,
+ 'title' => $title,
+ 'url' => $url,
+ 'auth' => $auth,
+ 'attrs' => $attrs,
+ ]));
+ }
+
+ /**
+ * 添加行工具按钮 HTML.
+ * @return $this
+ */
+ public function addRowActionHtml(string $html): self
+ {
+ $this->rowActions[] = $html;
+ return $this;
+ }
+
+ /**
+ * 添加结构化行操作.
+ */
+ public function addRowAction(array $action): self
+ {
+ return $this->appendRowAction($this->actionNormalizer()->row($action));
+ }
+
+ /**
+ * @param array $action
+ */
+ public function createRowAction(array $action = []): PageAction
+ {
+ return new PageAction($this, 'row', $action);
+ }
+
+ public function attachRowAction(PageAction $action): PageAction
+ {
+ $index = count($this->rowActions);
+ $this->storeRowAction($action->export(), $index);
+ return $action->attach($index, $this->currentTableVersion());
+ }
+
+ /**
+ * @param array $action
+ * @return array
+ */
+ public function replaceRowAction(int $index, array $action): array
+ {
+ return $this->storeRowAction($action, $index);
+ }
+
+ /**
+ * 添加行操作按钮.
+ */
+ public function addRowActionButton(string $label, string $url, string $value = 'id#{{d.id}}', string $confirm = '', array $attrs = [], ?string $auth = null): self
+ {
+ return $this->appendRowAction($this->actionNormalizer()->row([
+ 'type' => 'action',
+ 'label' => $label,
+ 'url' => $url,
+ 'value' => $value,
+ 'confirm' => $confirm,
+ 'auth' => $auth,
+ 'attrs' => $attrs,
+ ]));
+ }
+
+ /**
+ * 添加模板片段.
+ * @return $this
+ */
+ public function addTemplate(string $id, string $html): self
+ {
+ $this->templates[$id] = $html;
+ return $this;
+ }
+
+ /**
+ * 添加初始化前脚本.
+ * @return $this
+ */
+ public function addBootScript(string $script): self
+ {
+ $this->bootScripts[] = trim($script);
+ return $this;
+ }
+
+ /**
+ * 添加初始化后脚本.
+ * @return $this
+ */
+ public function addInitScript(string $script): self
+ {
+ $this->initScripts[] = trim($script);
+ return $this;
+ }
+
+ /**
+ * 添加附加脚本.
+ * @return $this
+ */
+ public function addScript(string $script): self
+ {
+ $this->scripts[] = trim($script);
+ return $this;
+ }
+
+ /**
+ * 输出页面内容.
+ * @return mixed
+ */
+ public function fetch(array $vars = [])
+ {
+ throw new HttpResponseException($this->renderResponse($vars));
+ }
+
+ /**
+ * 渲染页面 HTML.
+ * @param array $vars
+ */
+ public function renderHtml(array $vars = []): string
+ {
+ return $this->renderResponse($vars)->getContent();
+ }
+
+ /**
+ * 获取页面配置.
+ */
+ public function toArray(): array
+ {
+ $content = $this->schemaContentNodes();
+ return [
+ 'preset' => $this->preset,
+ 'title' => BuilderLang::text($this->title),
+ 'buttons' => $this->normalizeSchemaValue($this->resolveButtonItems($content)),
+ 'content' => $this->normalizeSchemaValue($content),
+ 'templates' => array_keys($this->collectTemplateMap($content, false)),
+ ];
+ }
+
+ /**
+ * 合并数组配置.
+ */
+ private function mergeAssoc(array $origin, array $append): array
+ {
+ foreach ($append as $key => $value) {
+ if (isset($origin[$key]) && is_array($origin[$key]) && is_array($value) && !array_is_list($origin[$key]) && !array_is_list($value)) {
+ $origin[$key] = $this->mergeAssoc($origin[$key], $value);
+ } else {
+ $origin[$key] = $value;
+ }
+ }
+ return $origin;
+ }
+
+ /**
+ * @param array $vars
+ */
+ private function renderResponse(array $vars = [])
+ {
+ $vars['title'] = $vars['title'] ?? BuilderLang::text($this->title);
+ $vars['pageBuilder'] = $vars['pageBuilder'] ?? $this;
+ $vars['pageSchema'] = $vars['pageSchema'] ?? $this->toArray();
+ $vars['staticRoot'] = strval($vars['staticRoot'] ?? AppService::uri('static'));
+ foreach (get_object_vars($this->class) as $k => $v) {
+ $vars[$k] = $v;
+ }
+ $this->renderVars = $vars;
+ return display($this->render(), $vars);
+ }
+
+ /**
+ * Schema 值标准化.
+ * @param mixed $value
+ */
+ private function normalizeSchemaValue($value)
+ {
+ if (is_array($value) && isset($value['__raw__'])) {
+ return ['type' => 'js', 'code' => $value['__raw__']];
+ }
+ if (is_array($value)) {
+ if (array_is_list($value)) {
+ $result = [];
+ foreach ($value as $key => $item) {
+ $result[$key] = $this->normalizeSchemaValue($item);
+ }
+ return $result;
+ }
+
+ $result = [];
+ foreach ($value as $key => $item) {
+ if ($key === 'attrs' && is_array($item)) {
+ $result[$key] = BuilderLang::attrs($item);
+ continue;
+ }
+ if (in_array(strval($key), ['title', 'label', 'legend', 'placeholder', 'remark', 'subtitle', 'confirm', 'html'], true) && is_string($item)) {
+ $result[$key] = BuilderLang::text($item);
+ continue;
+ }
+ $result[$key] = $this->normalizeSchemaValue($item);
+ }
+ return $result;
+ }
+ return $value;
+ }
+
+ /**
+ * 构建表格参数.
+ */
+ private function buildTableOptions(): array
+ {
+ $options = $this->tableOptions;
+ if (count($this->columns) > 0) {
+ $options['cols'] = [$this->columns];
+ }
+ return $options;
+ }
+
+ /**
+ * @param array $options
+ * @return array
+ */
+ private function normalizeTableOptions(array $options): array
+ {
+ return $this->mergeAssoc(['even' => true, 'height' => 'full'], $options);
+ }
+
+ /**
+ * 追加头部动作.
+ * @param array $action
+ */
+ private function appendButtonAction(array $action): self
+ {
+ $this->storeButtonAction($action);
+ return $this;
+ }
+
+ /**
+ * 追加行动作.
+ * @param array $action
+ */
+ private function appendRowAction(array $action): self
+ {
+ $this->storeRowAction($action);
+ return $this;
+ }
+
+ /**
+ * @param array $action
+ * @return array
+ */
+ private function storeButtonAction(array $action, ?int $index = null): array
+ {
+ $action = $this->actionNormalizer()->button($action);
+ $html = strval($action['html'] ?? '');
+ $schema = array_merge($action, ['html' => $html]);
+
+ if ($index === null) {
+ $this->buttons[] = $html;
+ $this->buttonItems[] = $schema;
+ } else {
+ $this->buttons[$index] = $html;
+ $this->buttonItems[$index] = $schema;
+ }
+
+ return $schema;
+ }
+
+ /**
+ * @param array $action
+ * @return array
+ */
+ private function storeRowAction(array $action, ?int $index = null): array
+ {
+ $action = $this->actionNormalizer()->row($action);
+ $html = strval($action['html'] ?? '');
+ $schema = array_merge($action, ['html' => $html]);
+
+ if ($index === null) {
+ $this->rowActions[] = $html;
+ } else {
+ $this->rowActions[$index] = $html;
+ }
+
+ return $schema;
+ }
+
+ private function actionNormalizer(): PageActionNormalizer
+ {
+ return new PageActionNormalizer($this->tableId);
+ }
+
+ private function columnNormalizer(): PageColumnNormalizer
+ {
+ return new PageColumnNormalizer($this->tableId, $this->toolbarId, fn($value): string => $this->encodeJs($value));
+ }
+
+ private function searchFieldNormalizer(): PageSearchFieldNormalizer
+ {
+ return new PageSearchFieldNormalizer();
+ }
+
+ /**
+ * @param array $preset
+ */
+ private function appendColumnPreset(array $preset): self
+ {
+ $this->storeColumnPreset($preset);
+ return $this;
+ }
+
+ /**
+ * @param array $options
+ * @param array{templateKeys?: array, scriptIndexes?: array} $meta
+ * @return array
+ */
+ private function storeSortInputColumn(string $actionUrl, array $options, ?int $index = null, array $meta = []): array
+ {
+ return $this->storeColumnPreset($this->columnNormalizer()->sortInput($actionUrl, $options), $index, $meta);
+ }
+
+ /**
+ * @param array $options
+ * @param array{templateKeys?: array, scriptIndexes?: array} $meta
+ * @return array
+ */
+ private function storeToolbarColumn(array $options, ?int $index = null, array $meta = []): array
+ {
+ $title = strval($options['title'] ?? '操作面板');
+ return $this->storeColumnPreset($this->columnNormalizer()->toolbar($title, $options), $index, $meta);
+ }
+
+ /**
+ * @param array $options
+ * @param array{templateKeys?: array, scriptIndexes?: array} $meta
+ * @return array
+ */
+ private function storeStatusSwitchColumn(string $actionUrl, array $options, ?int $index = null, array $meta = []): array
+ {
+ return $this->storeColumnPreset($this->columnNormalizer()->statusSwitch($actionUrl, $options), $index, $meta);
+ }
+
+ /**
+ * @param array $preset
+ * @param array{templateKeys?: array, scriptIndexes?: array} $meta
+ * @return array
+ */
+ private function storeColumnPreset(array $preset, ?int $index = null, array $meta = []): array
+ {
+ $columnIndex = $index ?? count($this->columns);
+ if (is_array($preset['column'] ?? null)) {
+ $this->columns[$columnIndex] = $preset['column'];
+ }
+ $templateKeys = $this->replacePresetTemplates((array)($preset['templates'] ?? []), (array)($meta['templateKeys'] ?? []));
+ $scriptIndexes = $this->replacePresetScripts((array)($preset['scripts'] ?? []), (array)($meta['scriptIndexes'] ?? []));
+ $this->trackTableTemplateKeys($templateKeys, (array)($meta['templateKeys'] ?? []));
+ $this->trackTableScriptIndexes($scriptIndexes, (array)($meta['scriptIndexes'] ?? []));
+ return [
+ 'index' => $columnIndex,
+ 'version' => $this->currentTableVersion(),
+ 'column' => $this->columns[$columnIndex] ?? [],
+ 'templateKeys' => $templateKeys,
+ 'scriptIndexes' => $scriptIndexes,
+ ];
+ }
+
+ /**
+ * @param array