├── README.md ├── composer.json ├── copy ├── config │ └── plugin │ │ ├── amis.php │ │ └── app.php └── resource │ └── translations │ └── en │ └── amis-admin.php ├── docs ├── common_usage.md └── multi_app.md └── src ├── Amis.php ├── Amis ├── ActionButtons.php ├── Component.php ├── Crud.php ├── DetailAttribute.php ├── FormField.php ├── GridBatchActions.php ├── GridColumn.php ├── GridColumnActions.php ├── Page.php └── Traits │ ├── ActionButtonSupport.php │ └── ComponentCommonFn.php ├── Controller ├── AmisSourceController.php ├── RenderController.php └── Traits │ └── AmisSourceController │ ├── CreateTrait.php │ ├── CreateUpdateFormTrait.php │ ├── DeleteTrait.php │ ├── DetailTrait.php │ ├── RecoveryTrait.php │ └── UpdateTrait.php ├── Exceptions ├── ActionDisableException.php └── ValidationException.php ├── Helper ├── ArrayHelper.php ├── ConfigHelper.php ├── DTO │ └── PresetItem.php └── PresetsHelper.php ├── Install.php ├── Middleware └── AmisModuleChangeMiddleware.php ├── Repository ├── AbsRepository.php ├── EloquentRepository.php ├── HasPresetInterface.php ├── HasPresetTrait.php └── RepositoryInterface.php ├── Validator ├── LaravelValidator.php ├── NullValidator.php └── ValidatorInterface.php ├── helper.php └── view ├── _amis-basic.php ├── amis-app-history.js ├── amis-app.html ├── amis-app.php ├── amis-page.html └── amis-page.php /README.md: -------------------------------------------------------------------------------- 1 | # webman-tech/amis-admin 2 | 3 | [amis](https://github.com/baidu/amis) For webman quick use ~ 4 | 5 | ## 简介 6 | 7 | 借用 amis 的 json 配置化能力,提供给 webman 快速搭建管理后台的能力 8 | 9 | 只做最基础的增删改查封装,具体的业务都不实现 10 | 11 | 特性: 12 | 13 | - 无依赖:不依赖第三方组件,Laravel 系和 TP 系都能用(目前建议 laravel,tp 的实现未做) 14 | - 无侵入:不设定任何初始 sql,业务无关 15 | - 无前端:基本不需要考虑前端,熟悉 amis 和 php 即可 16 | - 高扩展:amis 的各种组件支持全局控制和页面级控制 17 | - 支持多应用模式:可以支持作用于类似 admin/agent/user 多后台形式 18 | 19 | 局限: 20 | 21 | - 功能简单:没有admin帐号体系,没有菜单管理,没有权限管理 22 | 23 | ## 安装 24 | 25 | ```bash 26 | composer require webman-tech/amis-admin 27 | ``` 28 | 29 | 要求 webman > 1.4 且关闭了 controller_reuse(原因:controller_reuse 导致成员变量会被缓存,AmisSourceController 需要使用到成员做单个请求的缓存) 30 | 31 | ## 使用 32 | 33 | 参考使用:[https://github.com/krissss/webman-basic](https://github.com/krissss/webman-basic) 34 | 35 | > 注意: Amis 实际上是前后端分离的框架,即数据接口是数据接口,页面配置(json)是页面配置, 因此不能用常规的 PHP 框架下的 admin 框架(如 laravel-admin 等)来思考 36 | 37 | ### AmisSourceController 38 | 39 | 是一个基础的 CRUD 资源控制器基类,负责控制页面结构,操作按钮权限等 40 | 41 | ### Repository 42 | 43 | AmisSourceController 中使用的 repository 的方法封装,负责提供对数据的增删改 44 | 45 | ### Component 46 | 47 | Amis 组件的封装,目前仅封装了常用的组件类型和属性, 但 amis 的所有组件都可以通过 `Component::make(['type' => 'xxx'])` 来配置 48 | 49 | 所有组件也都支持 `schema()` 方法来覆盖(支持嵌套覆盖)参数 50 | 51 | > 组件支持 Controller 级别和全局(config中)修改默认配置参数 52 | 53 | ## 注意点 54 | 55 | 1. 如果使用 LaravelValidator,校验 file 时需要安装依赖:`webman-tech/polyfill` 56 | 57 | ## 其他 58 | 59 | - [多应用支持](./docs/multi_app.md) 60 | - [常用配置](./docs/common_usage.md) 61 | 62 | ## 不使用 cdn 63 | 64 | 配合使用 [kriss/composer-assets-plugin](https://github.com/krissss/composer-assets-plugin) 65 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webman-tech/amis-admin", 3 | "type": "library", 4 | "license": "MIT", 5 | "description": "Webman plugin webman-tech/amis-admin", 6 | "autoload": { 7 | "psr-4": { 8 | "WebmanTech\\AmisAdmin\\": "src" 9 | }, 10 | "files": [ 11 | "src/helper.php" 12 | ] 13 | }, 14 | "config": { 15 | "sort-packages": true 16 | }, 17 | "require": { 18 | "php": "^8.2" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /copy/config/plugin/amis.php: -------------------------------------------------------------------------------- 1 | [ 19 | /** 20 | * html 上的 lang 属性 21 | */ 22 | 'lang' => fn() => locale(), 23 | /** 24 | * 静态资源,建议下载下来放到 public 目录下然后替换链接 25 | * @link https://aisuda.bce.baidu.com/amis/zh-CN/docs/start/getting-started#sdk 26 | */ 27 | 'css' => [ 28 | $amisAssetBaseUrl . 'sdk.css', 29 | $amisAssetBaseUrl . 'helper.css', 30 | $amisAssetBaseUrl . 'iconfont.css', 31 | ], 32 | 'js' => [ 33 | $amisAssetBaseUrl . 'sdk.js', 34 | 'https://unpkg.com/history@4.10.1/umd/history.js', // 使用 app 必须 35 | // 可以添加复杂的 script 脚本 36 | /*[ 37 | 'type' => 'script', 38 | 'content' => << '', 48 | /** 49 | * 语言 50 | * @link https://aisuda.bce.baidu.com/amis/zh-CN/docs/extend/i18n 51 | */ 52 | 'locale' => fn() => str_replace('_', '-', locale()), 53 | /** 54 | * debug 55 | * @link https://aisuda.bce.baidu.com/amis/zh-CN/docs/extend/debug 56 | */ 57 | 'debug' => false, 58 | ], 59 | /** 60 | * @see Amis::renderApp() 61 | */ 62 | 'app' => [ 63 | /** 64 | * @link https://aisuda.bce.baidu.com/amis/zh-CN/components/app 65 | */ 66 | 'amisJSON' => [ 67 | 'brandName' => config('app.name', 'App Admin'), 68 | 'logo' => '/favicon.ico', 69 | 'api' => route('admin.pages'), // 修改成获取菜单的路由 70 | ], 71 | 'title' => config('app.name'), 72 | ], 73 | /** 74 | * @see Amis::renderPage() 75 | */ 76 | 'page' => [ 77 | /** 78 | * @link https://aisuda.bce.baidu.com/amis/zh-CN/docs/start/getting-started 79 | */ 80 | 'amisJSON' => [], 81 | ], 82 | /** 83 | * 登录页面配置 84 | * @see RenderController::login() 85 | */ 86 | 'page_login' => function() { 87 | return [ 88 | //'background' => '#eee', // 可以使用图片, 'url(http://xxxx)' 89 | 'login_api' => route('admin.login'), 90 | 'success_redirect' => route('admin'), 91 | ]; 92 | }, 93 | /** 94 | * 用于全局替换组件的默认参数 95 | * @see Component::$config 96 | */ 97 | 'components' => [ 98 | // 例如: 将列表页的字段默认左显示 99 | /*\WebmanTech\AmisAdmin\Amis\GridColumn::class => [ 100 | 'schema' => [ 101 | 'align' => 'left', 102 | ], 103 | ],*/ 104 | ], 105 | /** 106 | * 默认的验证器 107 | * 返回一个 \WebmanTech\AmisAdmin\Validator\ValidatorInterface 108 | */ 109 | 'validator' => fn() => new \WebmanTech\AmisAdmin\Validator\NullValidator(), 110 | //'validator' => fn() => new \WebmanTech\AmisAdmin\Validator\LaravelValidator(\support\Container::get(\Illuminate\Contracts\Validation\Factory::class)), 111 | /** 112 | * 用于获取当前请求的路径,当部署到二级目录时有用 113 | */ 114 | 'request_path_getter' => null, 115 | ]; 116 | -------------------------------------------------------------------------------- /copy/config/plugin/app.php: -------------------------------------------------------------------------------- 1 | true, 4 | ]; -------------------------------------------------------------------------------- /copy/resource/translations/en/amis-admin.php: -------------------------------------------------------------------------------- 1 | 'Operate', 5 | '详情' => 'Detail', 6 | '取消' => 'Cancel', 7 | '新增' => 'Create', 8 | '修改' => 'Update', 9 | '删除' => 'Delete', 10 | '恢复' => 'Recovery', 11 | '确定要%operate%?' => 'Confirm to %operate%?', 12 | '登录' => 'Login', 13 | '登录成功' => 'Login success', 14 | '用户名' => 'Username', 15 | '密码' => 'Password', 16 | ]; -------------------------------------------------------------------------------- /docs/common_usage.md: -------------------------------------------------------------------------------- 1 | # 常用配置 2 | 3 |
4 | 如何修改 crud 的详情打开的 dialog 的 size 5 | 6 | 在对应的 controller 中添加以下配置 7 |
 8 | // 修改该参数,对于 新增、修改、明细 都使用 lg 的 dialog
 9 | protected ?array $defaultDialogConfig = [
10 |     'size' => 'lg',
11 | ];
12 | 
13 | // 单独控制
14 | protected function gridActionsConfig(): array
15 | {
16 |     return [
17 |         // 单独配置 detail
18 |         'schema_detail' => [
19 |             'dialog' => [
20 |                 'size' => 'lg',
21 |             ],
22 |         ],
23 |     ];
24 | }
25 | 
26 |
27 | 28 |
29 | 如何全局配置一个 amis 的组件 30 | 31 | 在 config 的 amis 中 components 中添加以下配置 32 |
33 | return [
34 |     // ... 其他配置
35 |     /*
36 |      * 用于全局替换组件的默认参数
37 |      * @see Component::$config
38 |      */
39 |     'components' => [
40 |         // 例如: 将列表页的字段默认左显示
41 |         /*\WebmanTech\AmisAdmin\Amis\GridColumn::class => [
42 |             'schema' => [
43 |                 'align' => 'left',
44 |             ],
45 |         ],*/
46 |         // typeXxx,xxx 未 amis 的组件 type,通过 schema 会全局注入到每个 type 组件
47 |         'typeImage' => [
48 |             'schema' => [
49 |                 'enlargeAble' => true,
50 |             ],
51 |         ],
52 |     ],
53 | ];
54 | 
55 |
-------------------------------------------------------------------------------- /docs/multi_app.md: -------------------------------------------------------------------------------- 1 | # 多应用支持 2 | 3 | 1. 复制一份 `config/plugin/webman-tech/amis-admin/amis.php` 到 `config/plugin/webman-tech/amis-admin/amis-user.php` 4 | 5 | 2. 继承 `AmisModuleChangeMiddleware` 实现一个无 `__construct` 的中间件(因为 webman 目前还不支持中间件注册使用 __construct),例如: 6 | 7 | ```php 8 | $msg ? 1 : 0, 25 | 'msg' => $msg, 26 | 'data' => $data ?: '{}', 27 | ], $extra); 28 | 29 | return json($data, JSON_UNESCAPED_UNICODE); 30 | } 31 | 32 | /** 33 | * 渲染 app 多页面结构 34 | * @param array $schema 35 | * @return string 36 | * @throws Throwable 37 | */ 38 | public function renderApp(array $schema = []) 39 | { 40 | $defaultData = [ 41 | 'view' => 'amis-app', 42 | 'view_path' => ConfigHelper::getViewPath(), 43 | 'assets' => $this->getAssets(), 44 | ]; 45 | $appData = (array)ConfigHelper::get('app', []); 46 | if (isset($appData['amisJSON']) && is_callable($appData['amisJSON'])) { 47 | $appData['amisJSON'] = call_user_func($appData['amisJSON']); 48 | } 49 | $schema['type'] = 'app'; 50 | $data = ArrayHelper::merge( 51 | $defaultData, 52 | $appData, 53 | [ 54 | 'amisJSON' => $schema, 55 | ], 56 | ); 57 | /** 58 | * Fix https://github.com/walkor/webman-framework/commit/a559a642058aa9d5fd9dea9d129dc31b615c56eb 59 | */ 60 | $app = $data['view_path']; 61 | unset($data['view_path']); 62 | 63 | return Raw::render($data['view'], $data, $app); 64 | } 65 | 66 | /** 67 | * 渲染单页面 68 | * @param string $title 69 | * @param array $schema 70 | * @return string 71 | * @throws Throwable 72 | */ 73 | public function renderPage(string $title, array $schema = []) 74 | { 75 | $defaultData = [ 76 | 'view' => 'amis-page', 77 | 'view_path' => ConfigHelper::getViewPath(), 78 | 'assets' => $this->getAssets(), 79 | ]; 80 | $pageData = (array)ConfigHelper::get('page', []); 81 | if (isset($pageData['amisJSON']) && is_callable($pageData['amisJSON'])) { 82 | $pageData['amisJSON'] = call_user_func($pageData['amisJSON']); 83 | } 84 | $schema['type'] = 'page'; 85 | $data = ArrayHelper::merge( 86 | $defaultData, 87 | $pageData, 88 | [ 89 | 'amisJSON' => $schema, 90 | 'title' => $title, 91 | ], 92 | ); 93 | /** 94 | * Fix https://github.com/walkor/webman-framework/commit/a559a642058aa9d5fd9dea9d129dc31b615c56eb 95 | */ 96 | $app = $data['view_path']; 97 | unset($data['view_path']); 98 | 99 | return Raw::render($data['view'], $data, $app); 100 | } 101 | 102 | /** 103 | * 获取请求接口的路劲 104 | * @param Request $request 105 | * @return string 106 | */ 107 | public function getRequestPath(Request $request): string 108 | { 109 | if ($requestPathGetter = ConfigHelper::get('request_path_getter')) { 110 | if (!is_callable($requestPathGetter)) { 111 | throw new \InvalidArgumentException('request_path_getter 必须是个 callable'); 112 | } 113 | return (string)$requestPathGetter($request); 114 | } 115 | 116 | return $request->path(); 117 | } 118 | 119 | private function getAssets(): array 120 | { 121 | $assets = (array)ConfigHelper::get('assets', []); 122 | 123 | $assets['js'] ??= []; 124 | if (is_callable($assets['js'])) { 125 | $assets['js'] = call_user_func($assets['js']); 126 | } 127 | $assets['js'] = array_map(function ($item) { 128 | if (is_string($item)) { 129 | $item = ['type' => 'js', 'content' => $item]; 130 | } 131 | if (!is_array($item) && !isset($item['type'], $item['content'])) { 132 | throw new \InvalidArgumentException('js 配置错误'); 133 | } 134 | return $item; 135 | }, $assets['js']); 136 | 137 | $assets['lang'] ??= 'zh'; 138 | if (is_callable($assets['lang'])) { 139 | $assets['lang'] = (string)call_user_func($assets['lang']); 140 | } 141 | 142 | $assets['locale'] ??= 'zh-CN'; 143 | if (is_callable($assets['locale'])) { 144 | $assets['locale'] = (string)call_user_func($assets['locale']); 145 | } 146 | 147 | return $assets; 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /src/Amis/ActionButtons.php: -------------------------------------------------------------------------------- 1 | schema[$index] = $schema; 19 | } 20 | 21 | /** 22 | * @inheritDoc 23 | */ 24 | public function toArray(): array 25 | { 26 | ksort($this->schema); 27 | $this->schema = array_filter(array_values($this->schema)); 28 | return parent::toArray(); 29 | } 30 | } -------------------------------------------------------------------------------- /src/Amis/Component.php: -------------------------------------------------------------------------------- 1 | [], 18 | ]; 19 | protected array $schema = [ 20 | 'type' => '', 21 | ]; 22 | 23 | public function __construct() 24 | { 25 | $componentConfig = ConfigHelper::get('components.' . static::class, []); 26 | if (is_callable($componentConfig)) { 27 | $componentConfig = call_user_func($componentConfig); 28 | } 29 | $this->config((array)$componentConfig); 30 | $this->schema($this->config['schema']); 31 | } 32 | 33 | /** 34 | * @param array|null $schema 35 | * @return static 36 | */ 37 | public static function make(?array $schema = null) 38 | { 39 | /** @var static $component */ 40 | /** @phpstan-ignore-next-line */ 41 | $component = clone Container::get(static::class); 42 | if ($schema) { 43 | $component->schema($schema); 44 | } 45 | return $component; 46 | } 47 | 48 | /** 49 | * @param array $schema 50 | * @param bool $overwrite 51 | * @return $this 52 | */ 53 | public function schema(array $schema, bool $overwrite = false) 54 | { 55 | if ($overwrite) { 56 | $this->schema = $schema; 57 | } else { 58 | $this->schema = $this->merge($this->schema, $schema); 59 | } 60 | return $this; 61 | } 62 | 63 | /** 64 | * @param array $config 65 | * @param bool $overwrite 66 | * @return $this 67 | */ 68 | public function config(array $config, bool $overwrite = false) 69 | { 70 | if ($overwrite) { 71 | $this->config = $config; 72 | } else { 73 | $this->config = $this->merge($this->config, $config); 74 | } 75 | return $this; 76 | } 77 | 78 | /** 79 | * @return array 80 | */ 81 | public function toArray(): array 82 | { 83 | return $this->deepToArray($this->schema); 84 | } 85 | 86 | /** 87 | * @param array $arr 88 | * @return array 89 | */ 90 | protected function deepToArray(array $arr): array 91 | { 92 | $newArr = []; 93 | if (isset($arr['type']) && $arr['type']) { 94 | $arr = $this->mergeGlobalConfigWithType($arr['type'], $arr); 95 | } 96 | foreach ($arr as $key => $item) { 97 | if (is_array($item)) { 98 | $item = $this->deepToArray($item); 99 | } elseif ($item instanceof Component) { 100 | $item = $item->toArray(); 101 | } 102 | $newArr[$key] = $item; 103 | } 104 | return $newArr; 105 | } 106 | 107 | /** 108 | * 获取 schema 中的值 109 | * @param string $schemaKey 110 | * @param mixed $default 111 | * @return array|mixed 112 | */ 113 | public function get(string $schemaKey, mixed $default = null) 114 | { 115 | return ArrayHelper::get($this->toArray(), $schemaKey, $default); 116 | } 117 | 118 | /** 119 | * 合并数组 120 | * @param array ...$arrays 121 | * @return array 122 | */ 123 | protected function merge(array ...$arrays): array 124 | { 125 | return ArrayHelper::merge(...$arrays); 126 | } 127 | 128 | /** 129 | * 合并全局的 配置 参数 130 | * @param string $type 131 | * @param array $schema 132 | * @return array 133 | */ 134 | protected function mergeGlobalConfigWithType(string $type, array $schema): array 135 | { 136 | $componentTypeName = 'type' . Str::studly($type); 137 | $componentConfig = (array)ConfigHelper::get('components.' . $componentTypeName, [], true); 138 | if ($componentConfig === [] && Str::contains($type, 'static-')) { 139 | // 支持兼容 typeStaticImage 到 typeImage 的配置 140 | $componentTypeName = str_replace('typeStatic', 'type', $componentTypeName); 141 | $componentConfig = (array)ConfigHelper::get('components.' . $componentTypeName, [], true); 142 | } 143 | if (($globalSchema = $componentConfig['schema'] ?? []) && is_array($globalSchema)) { 144 | if (isset($globalSchema['type'])) { 145 | $schema['type'] = $globalSchema['type']; // 允许做全局的 type 修改,这样可以做自定义组件 146 | } 147 | $schema = $this->merge($globalSchema, $schema); 148 | if ($schema['type'] !== $type) { 149 | return $this->mergeGlobalConfigWithType($schema['type'], $schema); 150 | } 151 | } 152 | return $schema; 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /src/Amis/Crud.php: -------------------------------------------------------------------------------- 1 | schema = [ 25 | 'type' => 'crud', 26 | 'syncLocation' => false, 27 | 'autoGenerateFilter' => true, 28 | 'alwaysShowPagination' => true, 29 | 'headerToolbar' => [ 30 | static::INDEX_RELOAD => 'reload', 31 | static::INDEX_BULK_ACTIONS => 'bulkActions', 32 | static::INDEX_COLUMNS_TOGGLE => [ 33 | 'type' => 'columns-toggler', 34 | 'align' => 'right', 35 | 'draggable' => true, 36 | 'icon' => 'fas fa-cog', 37 | ], 38 | ], 39 | 'footerToolbar' => [ 40 | static::INDEX_SWITCH_PER_PAGE => 'switch-per-page', 41 | static::INDEX_PAGINATION => 'pagination', 42 | ], 43 | 'columns' => [], 44 | ]; 45 | $this->config['schema_create'] = []; 46 | 47 | parent::__construct(); 48 | } 49 | 50 | /** 51 | * @return $this 52 | */ 53 | public function withColumns(array $columns) 54 | { 55 | $quickEditEnable = isset($this->schema['_columnQuickEditApi']) && is_array($this->schema['_columnQuickEditApi']); 56 | foreach ($columns as $index => &$column) { 57 | if ($column instanceof GridColumnActions) { 58 | // 去除操作栏为空 buttons 的 59 | if (count((array)$column->get('buttons', [])) <= 0) { 60 | unset($columns[$index]); 61 | continue; 62 | } 63 | } 64 | if ($column instanceof Component) { 65 | $column = $column->toArray(); 66 | } 67 | if ($quickEditEnable && isset($column['quickEdit'])) { 68 | if (isset($column['quickEdit']['saveImmediately']) && $column['quickEdit']['saveImmediately'] === true) { 69 | // 当设置 saveImmediately 更改为数组模式 70 | $column['quickEdit']['saveImmediately'] = []; 71 | } 72 | $apiDataSchema = []; 73 | if ($attributeName = $column['name'] ?? '') { 74 | $apiDataSchema = [ 75 | 'data' => [ 76 | $attributeName => '${' . $attributeName . '}', 77 | ], 78 | ]; 79 | } 80 | $quickEditSchema = [ 81 | 'saveImmediately' => [ 82 | 'api' => ArrayHelper::merge( 83 | $this->schema['_columnQuickEditApi'], 84 | $apiDataSchema, 85 | ), 86 | ], 87 | ]; 88 | // 处理 quickEdit 为 true 时的逻辑 89 | if ($column['quickEdit'] === true) { 90 | $column['quickEdit'] = $quickEditSchema; 91 | } elseif (is_array($column['quickEdit'])) { 92 | $column['quickEdit'] = ArrayHelper::merge($quickEditSchema, $column['quickEdit']); 93 | } 94 | } 95 | } 96 | unset($column); 97 | 98 | $this->schema['columns'] = $columns; 99 | return $this; 100 | } 101 | 102 | /** 103 | * 新增按钮 104 | * @param string $api 105 | * @param array $form 106 | * @param string $can 107 | * @return Crud 108 | */ 109 | public function withCreate(string $api, array $form, string $can = '1==1') 110 | { 111 | $label = $this->config['schema_create']['label'] ?? trans('新增', [], 'amis-admin'); 112 | return $this->withButtonDialog(static::INDEX_CREATE, $label, $form, $this->merge([ 113 | 'api' => $api, 114 | 'level' => 'primary', 115 | 'visibleOn' => $can, 116 | ], $this->config['schema_create'])); 117 | } 118 | 119 | /** 120 | * @param int $index 121 | * @param array|ActionButtons $schema 122 | * @return $this 123 | */ 124 | public function withHeaderToolbar(int $index, $schema) 125 | { 126 | $this->schema['headerToolbar'][$index] = $schema; 127 | return $this; 128 | } 129 | 130 | /** 131 | * @param int $index 132 | * @param array|ActionButtons $schema 133 | * @return $this 134 | */ 135 | public function withFooterToolbar(int $index, $schema) 136 | { 137 | $this->schema['footerToolbar'][$index] = $schema; 138 | return $this; 139 | } 140 | 141 | /** 142 | * @inheritDoc 143 | */ 144 | protected function setActionButton(int $index, array $schema): void 145 | { 146 | $this->schema['headerToolbar'][$index] = $schema; 147 | } 148 | 149 | /** 150 | * @inheritDoc 151 | */ 152 | public function toArray(): array 153 | { 154 | ksort($this->schema['headerToolbar']); 155 | $this->schema['headerToolbar'] = array_filter(array_values($this->schema['headerToolbar'])); 156 | ksort($this->schema['footerToolbar']); 157 | $this->schema['footerToolbar'] = array_filter(array_values($this->schema['footerToolbar'])); 158 | return parent::toArray(); 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /src/Amis/DetailAttribute.php: -------------------------------------------------------------------------------- 1 | 'static', 45 | 'name' => '', 46 | ]; 47 | 48 | protected array $defaultValue = [ 49 | 'hidden' => true, 50 | 'visible' => true, 51 | ]; 52 | 53 | /** 54 | * 复制 55 | * @param null|string $content 56 | * @return $this 57 | */ 58 | public function copyable(?string $content = null) 59 | { 60 | $this->schema['copyable'] = [ 61 | 'content' => $content ?? "\${$this->schema['name']}", 62 | ]; 63 | return $this; 64 | } 65 | 66 | /** 67 | * @return $this 68 | */ 69 | public function __call(string $name, array $arguments) 70 | { 71 | if (strlen($name) > 4 && str_starts_with($name, 'type')) { 72 | $this->schema['type'] = 'static-' . lcfirst(substr($name, 4)); 73 | $this->schema($arguments[0] ?? []); 74 | } else { 75 | $this->callToSetSchema($name, $arguments); 76 | } 77 | return $this; 78 | } 79 | 80 | public function toArray(): array 81 | { 82 | $this->solveType(); 83 | 84 | return parent::toArray(); 85 | } 86 | 87 | protected function solveType(): void 88 | { 89 | $type = $this->schema['type']; 90 | if ($type === 'static-mapping') { 91 | $this->solveMappingMap(); 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/Amis/FormField.php: -------------------------------------------------------------------------------- 1 | 'input-text', 95 | 'name' => '', 96 | 'clearable' => true, 97 | ]; 98 | 99 | protected array $defaultValue = [ 100 | 'disabled' => true, 101 | 'hidden' => true, 102 | 'visible' => true, 103 | 'required' => true, 104 | 'static' => true, 105 | 'validateOnChange' => true, 106 | ]; 107 | 108 | /** 109 | * @return $this 110 | */ 111 | public function __call(string $name, array $arguments) 112 | { 113 | if (strlen($name) > 4 && str_starts_with($name, 'type')) { 114 | /** @phpstan-ignore-next-line */ 115 | $this->schema['type'] = strtolower((string) preg_replace('/(?<=[a-z])([A-Z])/', '-$1', lcfirst(substr($name, 4)))); 116 | $this->schema($arguments[0] ?? []); 117 | } else { 118 | $this->callToSetSchema($name, $arguments); 119 | } 120 | return $this; 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/Amis/GridBatchActions.php: -------------------------------------------------------------------------------- 1 | 'text', 45 | 'name' => '', 46 | 'align' => 'center', 47 | ]; 48 | 49 | protected array $defaultValue = [ 50 | 'sortable' => true, 51 | 'searchable' => true, 52 | 'quickEdit' => true, 53 | 'copyable' => true, 54 | ]; 55 | 56 | /** 57 | * 截断 58 | * @param int $size 59 | * @param array|bool $popOver 60 | * @return GridColumn 61 | */ 62 | public function truncate(int $size = 20, $popOver = null) 63 | { 64 | $schema = [ 65 | 'type' => 'tpl', 66 | 'tpl' => "\${{$this->schema['name']}|truncate:{$size}}", 67 | ]; 68 | if ($popOver !== false) { 69 | $schema['popOverEnableOn'] = "this.{$this->schema['name']} && this.{$this->schema['name']}.length > {$size}"; 70 | if (is_array($popOver)) { 71 | $schema['popOver'] = $this->merge([ 72 | 'showIcon' => true, 73 | 'body' => [ 74 | 'type' => 'tpl', 75 | 'tpl' => "\${{$this->schema['name']}}", 76 | ], 77 | ], $popOver); 78 | } else { 79 | $schema['popOver'] = "\${{$this->schema['name']}}"; 80 | } 81 | } 82 | return $this->schema($schema); 83 | } 84 | 85 | public function toArray(): array 86 | { 87 | $this->solveType(); 88 | $this->solveSearchable(); 89 | $this->solveQuickEdit(); 90 | 91 | return parent::toArray(); 92 | } 93 | 94 | /** 95 | * @return $this 96 | */ 97 | public function __call(string $name, array $arguments) 98 | { 99 | if (strlen($name) > 4 && str_starts_with($name, 'type')) { 100 | $this->schema['type'] = lcfirst(substr($name, 4)); 101 | $this->schema($arguments[0] ?? []); 102 | } else { 103 | $this->callToSetSchema($name, $arguments); 104 | } 105 | return $this; 106 | } 107 | 108 | protected function solveType(): void 109 | { 110 | $type = $this->schema['type']; 111 | if ($type === 'mapping') { 112 | $this->solveMappingMap(); 113 | } 114 | } 115 | 116 | protected function solveSearchable(): void 117 | { 118 | $searchable = $this->schema['searchable'] ?? false; 119 | if (!$searchable) { 120 | return; 121 | } 122 | 123 | $autoSolve = !is_array($searchable); 124 | if ($autoSolve) { 125 | $searchable = ['type' => 'input-text']; 126 | } 127 | $searchable['name'] ??= $this->schema['name']; 128 | $searchable['clearable'] ??= true; 129 | 130 | if (!$autoSolve) { 131 | $this->schema['searchable'] = $searchable; 132 | return; 133 | } 134 | $type = $this->schema['type']; 135 | if ($type === 'mapping') { 136 | if (isset($this->schema['map'])) { 137 | $searchable = array_merge( 138 | $this->buildTypeSelectBySchemaMap($this->schema['map']), 139 | $searchable 140 | ); 141 | } 142 | } elseif ($type === 'date' || $type === 'datetime') { 143 | $searchable['type'] = 'input-datetime-range'; 144 | } 145 | 146 | $this->schema['searchable'] = $searchable; 147 | } 148 | 149 | protected function solveQuickEdit(): void 150 | { 151 | if (!isset($this->schema['quickEdit'])) { 152 | return; 153 | } 154 | $quickEdit = $this->schema['quickEdit']; 155 | if ($quickEdit === true) { 156 | $quickEdit = []; 157 | } 158 | 159 | $type = $this->schema['type']; 160 | if ($type === 'mapping') { 161 | if (isset($this->schema['map'])) { 162 | $quickEdit = array_merge( 163 | $this->buildTypeSelectBySchemaMap($this->schema['map']), 164 | $quickEdit 165 | ); 166 | } 167 | } 168 | 169 | $this->schema['quickEdit'] = $quickEdit; 170 | } 171 | 172 | private function buildTypeSelectBySchemaMap(array $map): array 173 | { 174 | return [ 175 | 'type' => 'select', 176 | 'options' => array_map( 177 | function (array $item) { 178 | if (isset($item['label'])) { 179 | $item['label'] = strip_tags((string) $item['label']); // 去除 html 结构,使得 map 带 html 格式时支持 180 | } 181 | return $item; 182 | }, 183 | $map, 184 | ), 185 | ]; 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /src/Amis/GridColumnActions.php: -------------------------------------------------------------------------------- 1 | config['schema_detail'] = []; 22 | $this->config['schema_update'] = []; 23 | $this->config['schema_delete'] = []; 24 | $this->config['schema_recovery'] = []; 25 | $this->config['schema'] = [ 26 | 'type' => 'operation', 27 | 'label' => trans('操作', [], 'amis-admin'), 28 | 'buttons' => [], 29 | ]; 30 | 31 | parent::__construct(); 32 | } 33 | 34 | /** 35 | * 详情 36 | * @param array $detailAttributes 37 | * @param string|null $initApi 38 | * @param string $can 39 | * @return $this 40 | */ 41 | public function withDetail(array $detailAttributes, ?string $initApi = null, string $can = '1==1') 42 | { 43 | $label = $this->config['schema_detail']['label'] ?? trans('详情', [], 'amis-admin'); 44 | return $this->withButtonDialog(static::INDEX_DETAIL, $label, $detailAttributes, $this->merge([ 45 | 'initApi' => $initApi, 46 | 'visibleOn' => $can, 47 | 'dialog' => [ 48 | 'closeOnOutside' => true, 49 | 'actions' => [ 50 | ['type' => 'button', 'label' => trans('取消', [], 'amis-admin'), 'actionType' => 'cancel'], 51 | ], 52 | ], 53 | ], $this->config['schema_detail'])); 54 | } 55 | 56 | /** 57 | * 修改 58 | * @param array $formFields 59 | * @param string $api 60 | * @param string|null $initApi 61 | * @param string $can 62 | * @return $this 63 | */ 64 | public function withUpdate(array $formFields, string $api, ?string $initApi = null, string $can = '1==1') 65 | { 66 | $label = $this->config['schema_update']['label'] ?? trans('修改', [], 'amis-admin'); 67 | return $this->withButtonDialog(static::INDEX_UPDATE, $label, $formFields, $this->merge([ 68 | 'initApi' => $initApi, 69 | 'api' => $api, 70 | 'level' => 'primary', 71 | 'visibleOn' => $can, 72 | ], $this->config['schema_update'])); 73 | } 74 | 75 | /** 76 | * 删除 77 | * @param string $api 78 | * @param string $can 79 | * @return $this 80 | */ 81 | public function withDelete(string $api, string $can = '1==1') 82 | { 83 | $label = $this->config['schema_delete']['label'] ?? trans('删除', [], 'amis-admin'); 84 | return $this->withButtonAjax(static::INDEX_DELETE, $label, $api, $this->merge([ 85 | 'level' => 'danger', 86 | 'confirmText' => trans('确定要%operate%?', ['%operate%' => $label], 'amis-admin'), 87 | 'visibleOn' => $can, 88 | ], $this->config['schema_delete'])); 89 | } 90 | 91 | /** 92 | * 恢复 93 | * @param string $api 94 | * @param string $can 95 | * @return $this 96 | */ 97 | public function withRecovery(string $api, string $can = '1==1') 98 | { 99 | $label = $this->config['schema_recovery']['label'] ?? trans('恢复', [], 'amis-admin'); 100 | return $this->withButtonAjax(static::INDEX_RECOVERY, $label, $api, $this->merge([ 101 | 'level' => 'warning', 102 | 'confirmText' => trans('确定要%operate%?', ['%operate%' => $label], 'amis-admin'), 103 | 'visibleOn' => $can, 104 | ], $this->config['schema_recovery'])); 105 | } 106 | 107 | /** 108 | * @inheritdoc 109 | */ 110 | protected function setActionButton(int $index, array $schema): void 111 | { 112 | $this->schema['buttons'][$index] = $schema; 113 | } 114 | 115 | /** 116 | * @inheritdoc 117 | */ 118 | public function toArray(): array 119 | { 120 | ksort($this->schema['buttons']); 121 | $this->schema['buttons'] = array_filter(array_values($this->schema['buttons'])); 122 | return parent::toArray(); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/Amis/Page.php: -------------------------------------------------------------------------------- 1 | 'page', 13 | 'body' => [], 14 | ]; 15 | 16 | /** 17 | * @param int $index 18 | * @param array|Component $schema 19 | * @return $this 20 | */ 21 | public function withBody(int $index, $schema) 22 | { 23 | $this->schema['body'][$index] = $schema; 24 | return $this; 25 | } 26 | 27 | public function toArray(): array 28 | { 29 | ksort($this->schema['body']); 30 | $this->schema['body'] = array_filter(array_values($this->schema['body'])); 31 | return parent::toArray(); 32 | } 33 | } -------------------------------------------------------------------------------- /src/Amis/Traits/ActionButtonSupport.php: -------------------------------------------------------------------------------- 1 | withButton($index, $label, $this->merge([ 22 | 'actionType' => 'ajax', 23 | 'api' => $api, 24 | ], $schema)); 25 | } 26 | 27 | /** 28 | * 下载请求 29 | * @param int $index 30 | * @param string $label 31 | * @param string $api 32 | * @param array $schema 33 | * @return $this 34 | */ 35 | public function withButtonDownload(int $index, string $label, string $api, array $schema = []) 36 | { 37 | return $this->withButton($index, $label, $this->merge([ 38 | 'actionType' => 'download', 39 | 'api' => $api, 40 | ], $schema)); 41 | } 42 | 43 | /** 44 | * 单页跳转 45 | * @param int $index 46 | * @param string $label 47 | * @param string $link 48 | * @param array $schema 49 | * @return $this 50 | */ 51 | public function withButtonLink(int $index, string $label, string $link, array $schema = []) 52 | { 53 | return $this->withButton($index, $label, $this->merge([ 54 | 'actionType' => 'link', 55 | 'link' => $link, 56 | ], $schema)); 57 | } 58 | 59 | /** 60 | * 直接跳转 61 | * @param int $index 62 | * @param string $label 63 | * @param string $link 64 | * @param bool $blank 65 | * @param array $schema 66 | * @return $this 67 | */ 68 | public function withButtonUrl(int $index, string $label, string $link, bool $blank = false, array $schema = []) 69 | { 70 | return $this->withButton($index, $label, $this->merge([ 71 | 'actionType' => 'url', 72 | 'url' => $link, 73 | 'blank' => $blank, 74 | ], $schema)); 75 | } 76 | 77 | /** 78 | * 弹框 79 | * @param int $index 80 | * @param string $label 81 | * @param string|array $body 82 | * @param array $schema 83 | * @return $this 84 | */ 85 | public function withButtonDialog(int $index, string $label, $body, array $schema = []) 86 | { 87 | if (isset($schema['api'])) { 88 | $schema['dialog']['body']['api'] = $schema['api']; 89 | unset($schema['api']); 90 | } 91 | if (isset($schema['initApi'])) { 92 | $schema['dialog']['body']['initApi'] = $schema['initApi']; 93 | unset($schema['initApi']); 94 | } 95 | 96 | return $this->withButton($index, $label, $this->merge([ 97 | 'actionType' => 'dialog', 98 | 'dialog' => [ 99 | 'title' => $label, 100 | 'body' => [ 101 | 'type' => 'form', 102 | 'body' => $body, 103 | ], 104 | ], 105 | ], $schema)); 106 | } 107 | 108 | /** 109 | * 抽屉 110 | * @param int $index 111 | * @param string $label 112 | * @param string|array $body 113 | * @param array $schema 114 | * @return $this 115 | */ 116 | public function withButtonDrawer(int $index, string $label, $body, array $schema = []) 117 | { 118 | if (isset($schema['api'])) { 119 | $schema['drawer']['body']['api'] = $schema['api']; 120 | unset($schema['api']); 121 | } 122 | if (isset($schema['initApi'])) { 123 | $schema['drawer']['body']['initApi'] = $schema['initApi']; 124 | unset($schema['initApi']); 125 | } 126 | 127 | return $this->withButton($index, $label, $this->merge([ 128 | 'actionType' => 'drawer', 129 | 'drawer' => [ 130 | 'title' => $label, 131 | 'body' => [ 132 | 'type' => 'form', 133 | 'body' => $body, 134 | ], 135 | ], 136 | ], $schema)); 137 | } 138 | 139 | /** 140 | * @param int $index 141 | * @param string $label 142 | * @param array $schema 143 | * @return $this 144 | */ 145 | public function withButton(int $index, string $label, array $schema = []) 146 | { 147 | $schema['type'] ??= 'button'; 148 | $schema['label'] ??= $label; 149 | $this->setActionButton($index, $schema); 150 | return $this; 151 | } 152 | 153 | /** 154 | * 分割线 155 | * @param int $index 156 | * @return $this 157 | */ 158 | public function withDivider(int $index) 159 | { 160 | $schema['type'] = 'divider'; 161 | $this->setActionButton($index, $schema); 162 | return $this; 163 | } 164 | 165 | /** 166 | * 设置 button 到 schema 中 167 | * @param int $index 168 | * @param array $schema 169 | * @return void 170 | */ 171 | abstract protected function setActionButton(int $index, array $schema): void; 172 | } 173 | -------------------------------------------------------------------------------- /src/Amis/Traits/ComponentCommonFn.php: -------------------------------------------------------------------------------- 1 | defaultValue[$name] ?? null; 15 | } 16 | if ($value === null) { 17 | unset($this->schema[$name]); 18 | } else { 19 | $this->schema[$name] = $value; 20 | } 21 | } 22 | 23 | /** 24 | * 处理 mapping 类型的 map 25 | */ 26 | private function solveMappingMap(): void 27 | { 28 | if (isset($this->schema['map']) && !is_array($this->schema['map'][0] ?? null)) { 29 | // 将 [$value => $label] 强制转为 [{label: xx, value: xxx}] 的形式,可以防止 map 被转为 array 的情况 30 | $this->schema['map'] = array_map( 31 | fn($label, $value) => [ 32 | 'label' => $label, 33 | 'value' => $value, 34 | ], 35 | array_values((array)$this->schema['map']), 36 | array_keys((array)$this->schema['map']) 37 | ); 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Controller/AmisSourceController.php: -------------------------------------------------------------------------------- 1 | repository === null) { 44 | $this->repository = $this->createRepository(); 45 | } 46 | return $this->repository; 47 | } 48 | 49 | /** 50 | * 创建 Repository 51 | * @return RepositoryInterface 52 | */ 53 | abstract protected function createRepository(): RepositoryInterface; 54 | 55 | /** 56 | * page 数据和列表数据 57 | * @param Request $request 58 | * @return Response 59 | */ 60 | public function index(Request $request): Response 61 | { 62 | if ($request->get('_ajax')) { 63 | $order = []; 64 | if ($orderBy = $request->get('orderBy')) { 65 | $order[$orderBy] = $request->get('orderDir', 'asc'); 66 | } 67 | return amis_response($this->repository()->pagination( 68 | (int)$request->get('page'), 69 | (int)$request->get('perPage'), 70 | array_filter((array)$request->get(), fn($item) => $item !== ''), // 仅过滤空字符串的,保留为 0 的情况 71 | $order 72 | )); 73 | } 74 | 75 | return amis_response( 76 | $this->amisPage($request) 77 | ->withBody(50, $this->amisCrud($request)) 78 | ->toArray() 79 | ); 80 | } 81 | 82 | /** 83 | * @param Request $request 84 | * @return Amis\Page 85 | */ 86 | protected function amisPage(Request $request): Amis\Page 87 | { 88 | return Amis\Page::make(); 89 | } 90 | 91 | /** 92 | * @param Request $request 93 | * @return Amis\Crud 94 | */ 95 | protected function amisCrud(Request $request): Amis\Crud 96 | { 97 | $routePrefix = amis()->getRequestPath($request); 98 | 99 | $crud = Amis\Crud::make() 100 | ->config($this->crudConfig()) 101 | ->schema([ 102 | 'primaryField' => $this->repository()->getPrimaryKey(), 103 | 'api' => "get:{$routePrefix}?_ajax=1", 104 | // 批量保存接口,目前不支持 105 | //'quickSaveApi' => "put:{$routePrefix}/all", 106 | // 单个快速编辑接口,需要 column 配置为 'quickEdit' => ['saveImmediately' => true] 107 | // 但是此接口形式会将所有字段都提交更新 108 | //'quickSaveItemApi' => "put:{$routePrefix}/\${{$this->repository()->getPrimaryKey()}}", 109 | // 自定义的参数,用于列快速编辑时的 api 情况,可以处理快速编辑仅编辑某个字段 110 | '_columnQuickEditApi' => [ 111 | 'method' => 'put', 112 | 'url' => "{$routePrefix}/\${{$this->repository()->getPrimaryKey()}}", 113 | ], 114 | 'bulkActions' => $this->gridBatchActions(), 115 | ]) 116 | ->withColumns(array_merge( 117 | $this->buildGridColumn($this->grid()), 118 | [$this->gridActions($routePrefix)], 119 | )); 120 | $this->addCreateAction($crud, $routePrefix); 121 | return $crud; 122 | } 123 | 124 | /** 125 | * 配置 Amis\Crud 的 config 126 | * @return array 127 | */ 128 | protected function crudConfig(): array 129 | { 130 | if ($this->defaultDialogConfig) { 131 | return [ 132 | 'schema_create' => [ 133 | 'dialog' => $this->defaultDialogConfig, 134 | ], 135 | ]; 136 | } 137 | 138 | return []; 139 | } 140 | 141 | /** 142 | * 列表的 columns 143 | * @return array 144 | */ 145 | protected function grid(): array 146 | { 147 | $repository = $this->repository(); 148 | if ($repository instanceof HasPresetInterface) { 149 | return $repository->getPresetsHelper()->withScene(AbsRepository::SCENE_LIST)->pickGrid(); 150 | } 151 | 152 | return [ 153 | Amis\GridColumn::make()->name($this->repository()->getPrimaryKey()), 154 | ]; 155 | } 156 | 157 | /** 158 | * @param array $gridColumns 159 | * @return array 160 | */ 161 | protected function buildGridColumn(array $gridColumns): array 162 | { 163 | foreach ($gridColumns as &$item) { 164 | if ($item instanceof Amis\GridColumnActions) { 165 | $item = $item->toArray(); 166 | continue; 167 | } 168 | 169 | if (is_string($item)) { 170 | $item = Amis\GridColumn::make()->name($item); 171 | } 172 | if (is_array($item)) { 173 | $item = Amis\GridColumn::make($item); 174 | } 175 | if ($item instanceof Component) { 176 | $item = $item->toArray(); 177 | } 178 | $item['label'] ??= $this->repository()->getLabel($item['name']); 179 | } 180 | unset($item); 181 | 182 | return $gridColumns; 183 | } 184 | 185 | /** 186 | * grid 操作栏 187 | * @param string $routePrefix 188 | * @return Amis\GridColumnActions 189 | */ 190 | protected function gridActions(string $routePrefix): Amis\GridColumnActions 191 | { 192 | $actions = Amis\GridColumnActions::make()->config($this->gridActionsConfig()); 193 | 194 | $this->addDetailAction($actions, $routePrefix); 195 | $this->addUpdateAction($actions, $routePrefix); 196 | $this->addDeleteAction($actions, $routePrefix); 197 | $this->addRecoveryAction($actions, $routePrefix); 198 | 199 | return $actions; 200 | } 201 | 202 | /** 203 | * 配置 GridColumnActions 的 config 204 | * @return array 205 | */ 206 | protected function gridActionsConfig(): array 207 | { 208 | if ($this->defaultDialogConfig) { 209 | return [ 210 | 'schema_detail' => [ 211 | 'dialog' => $this->defaultDialogConfig, 212 | ], 213 | 'schema_update' => [ 214 | 'dialog' => $this->defaultDialogConfig, 215 | ], 216 | ]; 217 | } 218 | 219 | return []; 220 | } 221 | 222 | /** 223 | * 批量操作 224 | * @return Amis\GridBatchActions 225 | */ 226 | protected function gridBatchActions(): Amis\GridBatchActions 227 | { 228 | return Amis\GridBatchActions::make(); 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /src/Controller/RenderController.php: -------------------------------------------------------------------------------- 1 | renderApp(); 14 | } 15 | 16 | public function login(): string 17 | { 18 | // 默认值,可以被配置参数替换 19 | $defaultData = [ 20 | // 以下为常用的替换参数 21 | 'background' => '#eee', // 可以使用图片, 'url(http://xxxx)' 22 | 'title' => config('app.name', trans('登录', [], 'amis-admin')), 23 | 'submit_text' => trans('登录', [], 'amis-admin'), 24 | 'success_msg' => trans('登录成功', [], 'amis-admin'), 25 | 'form_width' => 400, 26 | 'login_api' => '/admin/auth/login', 27 | 'form' => [ 28 | Amis\FormField::make()->name('username')->label(trans('用户名', [], 'amis-admin'))->required(), 29 | Amis\FormField::make()->name('password')->label(trans('密码', [], 'amis-admin'))->typeInputPassword()->required(), 30 | ], 31 | 'success_redirect' => '/admin', 32 | // 用于调整整个表单 33 | 'schema' => [], 34 | 'schema_overwrite' => false, 35 | ]; 36 | $data = ConfigHelper::get('page_login', []); 37 | if (is_callable($data)) { 38 | $data = call_user_func($data); 39 | } 40 | if (isset($data['form'])) { 41 | unset($defaultData['form']); 42 | } 43 | $data = ArrayHelper::merge($defaultData, $data); 44 | 45 | $schema = []; 46 | if (!$data['schema_overwrite']) { 47 | $schema = Amis\Page::make() 48 | ->schema([ 49 | 'cssVars' => [ 50 | '--Page-body-padding' => 0, 51 | ], 52 | ]) 53 | ->withBody(0, [ 54 | 'type' => 'flex', 55 | 'justify' => 'center', 56 | 'alignItems' => 'center', 57 | 'style' => [ 58 | 'height' => '100%', 59 | 'background' => $data['background'], 60 | ], 61 | 'items' => [ 62 | [ 63 | 'type' => 'container', 64 | 'style' => [ 65 | 'width' => $data['form_width'], 66 | ], 67 | 'body' => [ 68 | 'type' => 'form', 69 | 'title' => [ 70 | 'type' => 'tpl', 71 | 'tpl' => $data['title'], 72 | 'className' => 'text-lg flex justify-center' 73 | ], 74 | 'submitText' => $data['submit_text'], 75 | 'api' => $data['login_api'], 76 | 'body' => $data['form'], 77 | 'redirect' => $data['success_redirect'], 78 | 'messages' => [ 79 | 'saveSuccess' => $data['success_msg'], 80 | ] 81 | ], 82 | ] 83 | ], 84 | ]) 85 | ->toArray(); 86 | } 87 | $schema = ArrayHelper::merge($schema, $data['schema']); 88 | 89 | return amis()->renderPage($data['title'], $schema); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/Controller/Traits/AmisSourceController/CreateTrait.php: -------------------------------------------------------------------------------- 1 | authCreate()) { 22 | throw new ActionDisableException(); 23 | } 24 | $this->repository()->create(array_replace_recursive((array)$request->post(), (array)$request->file())); 25 | return amis_response(['result' => 'ok']); 26 | } 27 | 28 | /** 29 | * 【后端】判断新增是否可用 30 | * @return bool 31 | */ 32 | protected function authCreate(): bool 33 | { 34 | if ($this->onlyShow) { 35 | return false; 36 | } 37 | 38 | return true; 39 | } 40 | 41 | /** 42 | * 【前端】判断新增是否可见 43 | * amis 表达式,通过 this 获取当前 model, 如 this.id != 1 44 | * @return string 45 | */ 46 | protected function authCreateVisible(): string 47 | { 48 | if ($this->onlyShow) { 49 | return '1==0'; 50 | } 51 | 52 | return '1==1'; 53 | } 54 | 55 | /** 56 | * 添加新增按钮 57 | * @param Amis\Crud $crud 58 | * @param string $routePrefix 59 | * @return void 60 | */ 61 | protected function addCreateAction(Amis\Crud $crud, string $routePrefix): void 62 | { 63 | if ($this->authCreate()) { 64 | $crud->withCreate( 65 | 'post:' . $routePrefix, 66 | $this->buildFormFields($this->form($this->repository()::SCENE_CREATE)), 67 | $this->authCreateVisible() 68 | ); 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Controller/Traits/AmisSourceController/CreateUpdateFormTrait.php: -------------------------------------------------------------------------------- 1 | repository(); 18 | if ($repository instanceof HasPresetInterface) { 19 | return $repository->getPresetsHelper()->withScene($scene)->pickForm(); 20 | } 21 | 22 | return [ 23 | //Amis\FormField::make()->name('name'), 24 | ]; 25 | } 26 | 27 | /** 28 | * @param array $formFields 29 | * @return array 30 | */ 31 | protected function buildFormFields(array $formFields): array 32 | { 33 | foreach ($formFields as &$item) { 34 | if (is_string($item)) { 35 | $item = Amis\FormField::make()->name($item); 36 | } 37 | if (is_array($item)) { 38 | $item = Amis\FormField::make($item); 39 | } 40 | if ($item instanceof Amis\Component) { 41 | $item = $item->toArray(); 42 | } 43 | if ($value = $item['label'] ?? $this->repository()->getLabel($item['name'])) { 44 | $item['label'] = $value; 45 | } 46 | if ($value = $item['labelRemark'] ?? $this->repository()->getLabelRemark($item['name'])) { 47 | $item['labelRemark'] = $value; 48 | } 49 | if ($value = $item['description'] ?? $this->repository()->getDescription($item['name'])) { 50 | $item['description'] = $value; 51 | } 52 | } 53 | unset($item); 54 | return $formFields; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Controller/Traits/AmisSourceController/DeleteTrait.php: -------------------------------------------------------------------------------- 1 | authDestroy($id)) { 21 | throw new ActionDisableException(); 22 | } 23 | $this->repository()->destroy($id); 24 | return amis_response(['result' => 'ok']); 25 | } 26 | 27 | /** 28 | * 【后端】判断删除是否可用 29 | * @param string|int|null $id 30 | * @return bool 31 | */ 32 | protected function authDestroy($id = null): bool 33 | { 34 | if ($this->onlyShow) { 35 | return false; 36 | } 37 | 38 | return true; 39 | } 40 | 41 | /** 42 | * 【前端】判断删除是否可见 43 | * amis 表达式,通过 this 获取当前 model, 如 this.id != 1 44 | * @return string 45 | */ 46 | protected function authDestroyVisible(): string 47 | { 48 | if ($this->onlyShow) { 49 | return '1==0'; 50 | } 51 | 52 | return '!this.deleted_at'; 53 | } 54 | 55 | /** 56 | * 添加删除按钮到 action column 57 | * @param Amis\GridColumnActions $actions 58 | * @param string $routePrefix 59 | * @return void 60 | */ 61 | protected function addDeleteAction(Amis\GridColumnActions $actions, string $routePrefix): void 62 | { 63 | if ($this->authDestroy()) { 64 | $actions->withDelete( 65 | "delete:{$routePrefix}/\${{$this->repository()->getPrimaryKey()}}", 66 | $this->authDestroyVisible() 67 | ); 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Controller/Traits/AmisSourceController/DetailTrait.php: -------------------------------------------------------------------------------- 1 | authDetail($id)) { 23 | throw new ActionDisableException(); 24 | } 25 | return amis_response($this->repository()->detail($id)); 26 | } 27 | 28 | /** 29 | * 【后端】详情判断是否可用 30 | * @param string|int|null $id 31 | * @return bool 32 | */ 33 | protected function authDetail($id = null): bool 34 | { 35 | return true; 36 | } 37 | 38 | /** 39 | * 【前端】详情判断是否可见 40 | * amis 表达式,通过 this 获取当前 model, 如 this.id != 1 41 | * @return string 42 | */ 43 | protected function authDetailVisible(): string 44 | { 45 | return '1==1'; 46 | } 47 | 48 | /** 49 | * 添加详情按钮到 action column 50 | * @param Amis\GridColumnActions $actions 51 | * @param string $routePrefix 52 | * @return void 53 | */ 54 | protected function addDetailAction(Amis\GridColumnActions $actions, string $routePrefix): void 55 | { 56 | if ($this->authDetail()) { 57 | $actions->withDetail( 58 | $this->buildDetailAttributes($this->detail()), 59 | "get:{$routePrefix}/\${{$this->repository()->getPrimaryKey()}}", 60 | $this->authDetailVisible() 61 | ); 62 | } 63 | } 64 | 65 | /** 66 | * 明细的字段展示 67 | * @return array 68 | */ 69 | protected function detail(): array 70 | { 71 | $repository = $this->repository(); 72 | if ($repository instanceof HasPresetInterface) { 73 | return $repository->getPresetsHelper()->withScene(AbsRepository::SCENE_DETAIL)->pickDetail(); 74 | } 75 | 76 | return [ 77 | Amis\DetailAttribute::make()->name($this->repository()->getPrimaryKey()), 78 | ]; 79 | } 80 | 81 | /** 82 | * @param array $detailAttributes 83 | * @return array 84 | */ 85 | protected function buildDetailAttributes(array $detailAttributes): array 86 | { 87 | foreach ($detailAttributes as &$item) { 88 | if (is_string($item)) { 89 | $item = Amis\DetailAttribute::make()->name($item); 90 | } 91 | if (is_array($item)) { 92 | $item = Amis\DetailAttribute::make($item); 93 | } 94 | if ($item instanceof Amis\Component) { 95 | $item = $item->toArray(); 96 | } 97 | $item['label'] ??= $this->repository()->getLabel($item['name']); 98 | } 99 | unset($item); 100 | return $detailAttributes; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/Controller/Traits/AmisSourceController/RecoveryTrait.php: -------------------------------------------------------------------------------- 1 | authRecovery($id)) { 21 | throw new ActionDisableException(); 22 | } 23 | $this->repository()->recovery($id); 24 | return amis_response(['result' => 'ok']); 25 | } 26 | 27 | /** 28 | * 【后端】判断恢复是否可用 29 | * @param string|int|null $id 30 | * @return bool 31 | */ 32 | protected function authRecovery($id = null): bool 33 | { 34 | if ($this->onlyShow) { 35 | return false; 36 | } 37 | 38 | return true; 39 | } 40 | 41 | /** 42 | * 【前端】判断恢复是否可见 43 | * amis 表达式,通过 this 获取当前 model, 如 this.id != 1 44 | * @return string 45 | */ 46 | protected function authRecoveryVisible(): string 47 | { 48 | if ($this->onlyShow) { 49 | return '1==0'; 50 | } 51 | 52 | return 'this.deleted_at'; 53 | } 54 | 55 | /** 56 | * 添加恢复按钮到 action column 57 | * @param Amis\GridColumnActions $actions 58 | * @param string $routePrefix 59 | * @return void 60 | */ 61 | protected function addRecoveryAction(Amis\GridColumnActions $actions, string $routePrefix): void 62 | { 63 | if ($this->authRecovery()) { 64 | $actions->withRecovery( 65 | "put:{$routePrefix}/\${{$this->repository()->getPrimaryKey()}}/recovery", 66 | $this->authRecoveryVisible() 67 | ); 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Controller/Traits/AmisSourceController/UpdateTrait.php: -------------------------------------------------------------------------------- 1 | authUpdate($id)) { 23 | throw new ActionDisableException(); 24 | } 25 | $this->repository()->update((array)$request->post(), $id); 26 | return amis_response(['result' => 'ok']); 27 | } 28 | 29 | /** 30 | * 【后端】判断更新是否可用 31 | * @param string|int|null $id 32 | * @return bool 33 | */ 34 | protected function authUpdate($id = null): bool 35 | { 36 | if ($this->onlyShow) { 37 | return false; 38 | } 39 | 40 | return true; 41 | } 42 | 43 | /** 44 | * 【前端】判断更新是否可见 45 | * amis 表达式,通过 this 获取当前 model, 如 this.id != 1 46 | * @return string 47 | */ 48 | protected function authUpdateVisible(): string 49 | { 50 | if ($this->onlyShow) { 51 | return '1==0'; 52 | } 53 | 54 | return '1==1'; 55 | } 56 | 57 | /** 58 | * 添加更新按钮到 action column 59 | * @param Amis\GridColumnActions $actions 60 | * @param string $routePrefix 61 | * @return void 62 | */ 63 | protected function addUpdateAction(Amis\GridColumnActions $actions, string $routePrefix): void 64 | { 65 | if ($this->authUpdate()) { 66 | $actions->withUpdate( 67 | $this->buildFormFields($this->form($this->repository()::SCENE_UPDATE)), 68 | "put:{$routePrefix}/\${{$this->repository()->getPrimaryKey()}}", 69 | "get:{$routePrefix}/\${{$this->repository()->getPrimaryKey()}}", 70 | $this->authUpdateVisible() 71 | ); 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Exceptions/ActionDisableException.php: -------------------------------------------------------------------------------- 1 | $value) { 22 | if (is_int($key)) { 23 | if (array_key_exists($key, $result)) { 24 | if ($result[$key] !== $value) { 25 | /** @var mixed */ 26 | $result[] = $value; 27 | } 28 | } else { 29 | /** @var mixed */ 30 | $result[$key] = $value; 31 | } 32 | } elseif (isset($result[$key]) && is_array($value) && is_array($result[$key])) { 33 | $result[$key] = static::merge($result[$key], $value); 34 | } else { 35 | /** @var mixed */ 36 | $result[$key] = $value; 37 | } 38 | } 39 | } 40 | return $result; 41 | } 42 | 43 | /** 44 | * 获取数组中的值 45 | * @param array $array 46 | * @param string|int|null $key 47 | * @param mixed $default 48 | * @return mixed 49 | * @see \Illuminate\Support\Arr::get() 50 | */ 51 | public static function get(array $array, $key, $default = null) 52 | { 53 | if (is_null($key)) { 54 | return $array; 55 | } 56 | 57 | if (array_key_exists($key, $array)) { 58 | return $array[$key]; 59 | } 60 | 61 | if (!str_contains($key, '.')) { 62 | return $array[$key] ?? $default; 63 | } 64 | 65 | foreach (explode('.', $key) as $segment) { 66 | if (is_array($array) && array_key_exists($segment, $array)) { 67 | $array = $array[$segment]; 68 | } else { 69 | return $default; 70 | } 71 | } 72 | 73 | return $array; 74 | } 75 | } -------------------------------------------------------------------------------- /src/Helper/ConfigHelper.php: -------------------------------------------------------------------------------- 1 | {self::AMIS_MODULE} ?? 'amis'; 26 | $cacheKey = "{$module}.{$key}"; 27 | if (isset(self::$closureCache[$cacheKey])) { 28 | return self::$closureCache[$cacheKey]; 29 | } 30 | 31 | if (self::$isForTest) { 32 | $value = self::$testConfig[$key] ?? $default; 33 | } else { 34 | $value = config("plugin.webman-tech.amis-admin.{$module}.{$key}", $default); 35 | } 36 | 37 | if ($solveClosure && $value instanceof \Closure) { 38 | $value = $value(); 39 | self::$closureCache[$cacheKey] = $value; 40 | } 41 | 42 | return $value; 43 | } 44 | 45 | public static function reset(): void 46 | { 47 | self::$testConfig = []; 48 | self::$closureCache = []; 49 | } 50 | 51 | private static ?string $viewPath = null; 52 | 53 | public static function getViewPath(): string 54 | { 55 | if (self::$viewPath !== null) { 56 | return self::$viewPath; 57 | } 58 | 59 | // 相对 app 目录的路径 60 | $guessPaths = [ 61 | '../vendor/webman-tech/amis-admin/src', 62 | '../vendor/webman-tech/components-monorepo/packages/amis-admin/src', 63 | ]; 64 | foreach ($guessPaths as $guessPath) { 65 | if (is_dir(app_path() . '/' . $guessPath)) { 66 | return self::$viewPath = $guessPath; 67 | } 68 | } 69 | 70 | throw new \RuntimeException('找不到 amis-admin 模板路径'); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/Helper/DTO/PresetItem.php: -------------------------------------------------------------------------------- 1 | key = $key; 47 | return $this; 48 | } 49 | 50 | public function getKey(): string 51 | { 52 | return $this->key; 53 | } 54 | 55 | private string $scene = self::SCENE_DEFAULT; 56 | 57 | public function withScene(?string $scene = null): self 58 | { 59 | $this->scene = $scene ?? self::SCENE_DEFAULT; 60 | return $this; 61 | } 62 | 63 | public function getScene(): string 64 | { 65 | return $this->scene; 66 | } 67 | 68 | public function getLabel(): ?string 69 | { 70 | return $this->getOrSetCacheValue(__FUNCTION__, fn() => value($this->label)); 71 | } 72 | 73 | public function getLabelRemark(): ?string 74 | { 75 | return $this->getOrSetCacheValue(__FUNCTION__, fn() => value($this->labelRemark)); 76 | } 77 | 78 | public function getDescription(): ?string 79 | { 80 | return $this->getOrSetCacheValue(__FUNCTION__, fn() => value($this->description)); 81 | } 82 | 83 | public function getFilter(): ?Closure 84 | { 85 | return $this->getOrSetCacheValue(__FUNCTION__, function () { 86 | $value = value($this->filter); 87 | if ($value === null || $value === false) { 88 | return null; 89 | } 90 | 91 | if ($value === '=' || $value === true) { 92 | return fn($query, $value, $attribute) => $query->where($attribute, $value); 93 | } 94 | if ($value === 'like') { 95 | // TODO like 需要做转义 96 | return fn($query, $value, $attribute) => $query->where($attribute, 'like', '%' . $value . '%'); 97 | } 98 | if ($value === 'datetime-range') { 99 | return fn($query, $value, $attribute) => $query 100 | ->whereBetween($attribute, array_map( 101 | fn($timestamp) => date('Y-m-d H:i:s', (int)$timestamp), 102 | explode(',', (string)$value) 103 | )); 104 | } 105 | 106 | // TODO 扩展其他 filter,或者方式(比如 static 直接注入?) 107 | 108 | return $value; 109 | }); 110 | } 111 | 112 | /** 113 | * @return GridColumn[]|null 114 | */ 115 | public function getGrid(): null|array 116 | { 117 | $value = $this->getOrSetCacheValue(__FUNCTION__, function () { 118 | $value = value($this->grid, $this->getKey()); 119 | if ($value === false || $value === null) { 120 | return null; 121 | } 122 | if ($value === true) { 123 | $value = GridColumn::make() 124 | ->name($this->getKey()) 125 | ->label($this->getLabel()); 126 | // 自动添加选项 map 127 | if (($info = $this->getSelectOptions()) !== null) { 128 | $value->typeMapping(['map' => $info['map']]); 129 | } 130 | // 自动添加搜索 131 | if ($this->getFilter() !== null) { 132 | $value->searchable(); 133 | } 134 | } 135 | if (!is_array($value)) { 136 | $value = [$value]; 137 | } 138 | foreach ($value as $k => $v) { 139 | if (!$v instanceof GridColumn) { 140 | throw new \InvalidArgumentException('grid must be GridColumn instance or array with GridColumn instance'); 141 | } 142 | if ($this->gridExt instanceof Closure) { 143 | $v = call_user_func($this->gridExt, $v, $k); 144 | if (!$v instanceof GridColumn) { 145 | throw new \InvalidArgumentException('gridExt must be GridColumn instance'); 146 | } 147 | } 148 | $value[$k] = $v; 149 | } 150 | return $value; 151 | }); 152 | 153 | if (is_array($value)) { 154 | if ($this->gridExtDynamic instanceof Closure) { 155 | foreach ($value as $k => $v) { 156 | $v = call_user_func($this->gridExtDynamic, $v, $this->getScene(), $k); 157 | if (!$v instanceof GridColumn) { 158 | throw new \InvalidArgumentException('gridExtDynamic must be GridColumn instance'); 159 | } 160 | $value[$k] = $v; 161 | } 162 | } 163 | $value = array_values($value); 164 | } 165 | 166 | return $value; 167 | } 168 | 169 | /** 170 | * @return FormField[]|null 171 | */ 172 | public function getForm(): null|array 173 | { 174 | $value = $this->getOrSetCacheValue(__FUNCTION__, function () { 175 | $value = value($this->form, $this->getKey()); 176 | if ($value === false || $value === null) { 177 | return null; 178 | } 179 | if ($value === true) { 180 | $value = FormField::make() 181 | ->name($this->getKey()) 182 | ->label($this->getLabel()) 183 | ->labelRemark($this->getLabelRemark()) 184 | ->description($this->getDescription()); 185 | // 自动添加选项 186 | if (($info = $this->getSelectOptions()) !== null) { 187 | $value->typeSelect(['options' => $info['options']]); 188 | } 189 | // 没有动态 rule 时,在缓存中即自动添加 required 190 | if (!$this->hasRuleExtDynamic() && in_array(self::RULE_REQUIRED, $this->getRules() ?? [], true)) { 191 | $value->required(); 192 | } 193 | // 自动添加默认值 194 | if (($defaultValue = $this->getFormDefaultValue()) !== null) { 195 | $value->value($defaultValue); 196 | } 197 | } 198 | if (!is_array($value)) { 199 | $value = [$value]; 200 | } 201 | foreach ($value as $k => $v) { 202 | if (!$v instanceof FormField) { 203 | throw new \InvalidArgumentException('form must be FormField instance or array with FormField instance'); 204 | } 205 | if ($this->formExt instanceof Closure) { 206 | $v = call_user_func($this->formExt, $v, $k); 207 | if (!$v instanceof FormField) { 208 | throw new \InvalidArgumentException('formExt must be FormField instance'); 209 | } 210 | } 211 | $value[$k] = $v; 212 | } 213 | return $value; 214 | }); 215 | 216 | if (is_array($value)) { 217 | if ($this->formExtDynamic instanceof Closure) { 218 | foreach ($value as $k => $v) { 219 | $v = call_user_func($this->formExtDynamic, $v, $this->getScene(), $k); 220 | if (!$v instanceof FormField) { 221 | throw new \InvalidArgumentException('formExtDynamic must be FormField instance'); 222 | } 223 | $value[$k] = $v; 224 | } 225 | } 226 | 227 | // 有动态的 rule 规则时,再重新处理一遍 required 228 | if ($this->hasRuleExtDynamic() && $rules = $this->getRules() ?? []) { 229 | foreach ($value as $v) { 230 | $isRequired = in_array(self::RULE_REQUIRED, $rules, true); 231 | if ($isRequired) { 232 | $v->required(); 233 | } else { 234 | if ($v->get('required')) { 235 | $v->required(false); 236 | } 237 | } 238 | } 239 | } 240 | 241 | $value = array_values($value); 242 | } 243 | 244 | return $value; 245 | } 246 | 247 | /** 248 | * @return DetailAttribute[]|null 249 | */ 250 | public function getDetail(): null|array 251 | { 252 | $value = $this->getOrSetCacheValue(__FUNCTION__, function () { 253 | $value = value($this->detail, $this->getKey()); 254 | if ($value === false || $value === null) { 255 | return null; 256 | } 257 | if ($value === true) { 258 | $value = DetailAttribute::make() 259 | ->name($this->getKey()) 260 | ->label($this->getLabel()); 261 | // 自动添加选项 map 262 | if (($info = $this->getSelectOptions()) !== null) { 263 | $value->typeMapping(['map' => $info['map']]); 264 | } 265 | } 266 | if (!is_array($value)) { 267 | $value = [$value]; 268 | } 269 | foreach ($value as $k => $v) { 270 | if (!$v instanceof DetailAttribute) { 271 | throw new \InvalidArgumentException('form must be DetailAttribute instance or array with DetailAttribute instance'); 272 | } 273 | if ($this->detailExt instanceof Closure) { 274 | $v = call_user_func($this->detailExt, $v, $k); 275 | if (!$v instanceof DetailAttribute) { 276 | throw new \InvalidArgumentException('detailExt must be DetailAttribute instance'); 277 | } 278 | } 279 | $value[$k] = $v; 280 | } 281 | return $value; 282 | }); 283 | 284 | if (is_array($value)) { 285 | if ($this->detailExtDynamic instanceof Closure) { 286 | foreach ($value as $k => $v) { 287 | $v = call_user_func($this->detailExtDynamic, $v, $this->getScene(), $k); 288 | if (!$v instanceof DetailAttribute) { 289 | throw new \InvalidArgumentException('detailExtDynamic must be DetailAttribute instance'); 290 | } 291 | $value[$k] = $v; 292 | } 293 | } 294 | $value = array_values($value); 295 | } 296 | 297 | return $value; 298 | } 299 | 300 | private function hasRuleExtDynamic(): bool 301 | { 302 | return $this->ruleExtDynamic instanceof Closure; 303 | } 304 | 305 | public function getRules(): ?array 306 | { 307 | $value = $this->getOrSetCacheValue(__FUNCTION__, function () { 308 | $value = value($this->rule); 309 | if ($value === false || $value === null) { 310 | return null; 311 | } 312 | // 将字符串形式 rule 切割成数组 313 | if (is_string($value)) { 314 | $value = array_values(array_filter(explode('|', $value))); 315 | } 316 | return $value; 317 | }); 318 | 319 | if ($this->ruleExtDynamic instanceof Closure) { 320 | $value = call_user_func($this->ruleExtDynamic, $value, $this->getScene()); 321 | } 322 | // 将字符串形式 rule 切割成数组 323 | if (is_string($value)) { 324 | $value = array_values(array_filter(explode('|', $value))); 325 | } 326 | 327 | // 更新时默认添加 sometimes 规则 328 | if ($this->getScene() === AbsRepository::SCENE_UPDATE && is_array($value)) { 329 | // 为了保证 update 场景下,可能需要部分字段更新(quickEdit),此时给字段默认添加 sometimes 规则 330 | if (!in_array(self::RULE_SOMETIMES, $value, true)) { 331 | array_unshift($value, self::RULE_SOMETIMES); 332 | } 333 | } 334 | 335 | return $value; 336 | } 337 | 338 | public function getRuleMessages(): ?array 339 | { 340 | return $this->getOrSetCacheValue(__FUNCTION__, fn() => value($this->ruleMessages)); 341 | } 342 | 343 | public function getRuleCustomAttribute() 344 | { 345 | return $this->getOrSetCacheValue(__FUNCTION__, fn() => value($this->ruleCustomAttribute)); 346 | } 347 | 348 | /** 349 | * @return array{map: array, options: array, values: array}|null 350 | */ 351 | private function getSelectOptions(): ?array 352 | { 353 | return $this->getOrSetCacheValue(__FUNCTION__, function () { 354 | $value = value($this->selectOptions); 355 | if ($value === null) { 356 | return null; 357 | } 358 | if (!is_array($value)) { 359 | throw new \InvalidArgumentException('selectOptions must be an array or Closure return array'); 360 | } 361 | $data = []; 362 | /** @phpstan-ignore-next-line */ 363 | if (isset($value[0]['value'])) { 364 | // 二维数组的形式 365 | $data['options'] = $value; 366 | $data['map'] = array_column($value, 'label', 'value'); 367 | } else { 368 | // map 的形式 369 | $data['map'] = $value; 370 | foreach ($value as $val => $label) { 371 | $data['options'][] = [ 372 | 'value' => (string)$val, // 强制为 string,保证行为一致 373 | 'label' => strip_tags((string)$label), // 去除 html 374 | ]; 375 | } 376 | } 377 | $data['values'] = array_keys($data['map']); 378 | 379 | return array_merge([ 380 | 'map' => [], 381 | 'options' => [], 382 | 'values' => [], 383 | ], $data); 384 | }); 385 | } 386 | 387 | private function getFormDefaultValue(): ?string 388 | { 389 | return $this->getOrSetCacheValue(__FUNCTION__, fn() => value($this->formDefaultValue)); 390 | } 391 | 392 | private array $cache = []; 393 | 394 | private function getOrSetCacheValue(string $key, Closure $value): mixed 395 | { 396 | if (!array_key_exists($key, $this->cache)) { 397 | $this->cache[$key] = $value() ?? '__NULL__'; 398 | } 399 | return $this->cache[$key] === '__NULL__' ? null : $this->cache[$key]; 400 | } 401 | } 402 | -------------------------------------------------------------------------------- /src/Helper/PresetsHelper.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | protected array $presets = []; 17 | protected array $sceneKeys = []; 18 | 19 | /** 20 | * 添加预设 21 | * @param array $presets 22 | * @return $this 23 | */ 24 | public function withPresets(array $presets): static 25 | { 26 | foreach ($presets as $attribute => $define) { 27 | if (!$define instanceof PresetItem) { 28 | throw new \InvalidArgumentException('presets item must be instance of PresetItem'); 29 | } 30 | $this->presets[$attribute] = $define; 31 | } 32 | return $this; 33 | } 34 | 35 | /** 36 | * 设置场景对应的字段 37 | * @param array $data 38 | * @return $this 39 | */ 40 | public function withSceneKeys(array $data): static 41 | { 42 | foreach ($data as $scene => $keys) { 43 | $this->sceneKeys[$scene] = $keys; 44 | } 45 | return $this; 46 | } 47 | 48 | /** 49 | * 设置默认场景对应的字段 50 | * @param array $keys 51 | * @return $this 52 | */ 53 | public function withDefaultSceneKeys(array $keys): static 54 | { 55 | return $this->withSceneKeys([ 56 | PresetItem::SCENE_DEFAULT => $keys, 57 | ]); 58 | } 59 | 60 | /** 61 | * 设置 CRUD 场景对应的字段 62 | * @param array $keys 63 | * @return $this 64 | */ 65 | public function withCrudSceneKeys(array $keys): static 66 | { 67 | return $this->withSceneKeys([ 68 | AbsRepository::SCENE_LIST => $keys, 69 | AbsRepository::SCENE_CREATE => $keys, 70 | AbsRepository::SCENE_UPDATE => $keys, 71 | AbsRepository::SCENE_DETAIL => $keys, 72 | ]); 73 | } 74 | 75 | protected string $scene = PresetItem::SCENE_DEFAULT; 76 | 77 | /** 78 | * @return $this 79 | */ 80 | public function withScene(?string $scene = null): static 81 | { 82 | $this->scene = $scene ?? PresetItem::SCENE_DEFAULT; 83 | return $this; 84 | } 85 | 86 | /** 87 | * 获取字段对应的 label 88 | * @param array|null $keys 89 | * @return array 90 | */ 91 | public function pickLabel(?array $keys = null): array 92 | { 93 | $data = []; 94 | foreach ($this->getPresetItems($keys) as $key => $item) { 95 | $value = $item->withKey($key)->withScene($this->scene)->getLabel(); 96 | if ($value !== null) { 97 | $data[$key] = $value; 98 | } 99 | } 100 | return $data; 101 | } 102 | 103 | /** 104 | * 获取字段对应的 labelRemark 105 | * @param array|null $keys 106 | * @return array 107 | */ 108 | public function pickLabelRemark(?array $keys = null): array 109 | { 110 | $data = []; 111 | foreach ($this->getPresetItems($keys) as $key => $item) { 112 | $value = $item->withKey($key)->withScene($this->scene)->getLabelRemark(); 113 | if ($value !== null) { 114 | $data[$key] = $value; 115 | } 116 | } 117 | return $data; 118 | } 119 | 120 | /** 121 | * 获取字段对应的 description 122 | * @param array|null $keys 123 | * @return array 124 | */ 125 | public function pickDescription(?array $keys = null): array 126 | { 127 | $data = []; 128 | foreach ($this->getPresetItems($keys) as $key => $item) { 129 | $value = $item->withKey($key)->withScene($this->scene)->getDescription(); 130 | if ($value !== null) { 131 | $data[$key] = $value; 132 | } 133 | } 134 | return $data; 135 | } 136 | 137 | /** 138 | * 获取字段对应的 filter 139 | * @param array|null $keys 140 | * @return array 141 | */ 142 | public function pickFilter(?array $keys = null): array 143 | { 144 | $data = []; 145 | foreach ($this->getPresetItems($keys) as $key => $item) { 146 | $value = $item->withKey($key)->withScene($this->scene)->getFilter(); 147 | if ($value !== null) { 148 | $data[$key] = $value; 149 | } 150 | } 151 | return $data; 152 | } 153 | 154 | /** 155 | * 获取字段对应的 grid 156 | * @param array|null $keys 157 | * @return GridColumn[] 158 | */ 159 | public function pickGrid(?array $keys = null): array 160 | { 161 | $data = []; 162 | foreach ($this->getPresetItems($keys) as $key => $item) { 163 | $value = $item->withKey($key)->withScene($this->scene)->getGrid(); 164 | if ($value === null) { 165 | continue; 166 | } 167 | $data = array_merge($data, $value); 168 | } 169 | return $data; 170 | } 171 | 172 | /** 173 | * 获取字段对应的 form 174 | * @param array|null $keys 175 | * @return FormField[] 176 | */ 177 | public function pickForm(?array $keys = null): array 178 | { 179 | $data = []; 180 | foreach ($this->getPresetItems($keys) as $key => $item) { 181 | $value = $item->withKey($key)->withScene($this->scene)->getForm(); 182 | if ($value === null) { 183 | continue; 184 | } 185 | $data = array_merge($data, $value); 186 | } 187 | return $data; 188 | } 189 | 190 | /** 191 | * 获取字段对应的 rules 192 | * @param array|null $keys 193 | * @return array 194 | */ 195 | public function pickRules(?array $keys = null): array 196 | { 197 | $data = []; 198 | foreach ($this->getPresetItems($keys) as $key => $item) { 199 | $value = $item->withKey($key)->withScene($this->scene)->getRules(); 200 | if ($value !== null) { 201 | $data[$key] = $value; 202 | } 203 | } 204 | return $data; 205 | } 206 | 207 | /** 208 | * 获取字段对应的 ruleMessages 209 | * @param array|null $keys 210 | * @return array 211 | */ 212 | public function pickRuleMessages(?array $keys = null): array 213 | { 214 | $data = []; 215 | foreach ($this->getPresetItems($keys) as $key => $item) { 216 | $value = $item->withKey($key)->withScene($this->scene)->getRuleMessages(); 217 | if ($value !== null) { 218 | $data[$key] = $value; 219 | } 220 | } 221 | return $data; 222 | } 223 | 224 | /** 225 | * 获取字段对应的 ruleCustomAttributes 226 | * @param array|null $keys 227 | * @return array 228 | */ 229 | public function pickRuleCustomAttributes(?array $keys = null): array 230 | { 231 | $data = []; 232 | foreach ($this->getPresetItems($keys) as $key => $item) { 233 | $value = $item->withKey($key)->withScene($this->scene)->getRuleCustomAttribute(); 234 | if ($value !== null) { 235 | $data[$key] = $value; 236 | } 237 | } 238 | return $data; 239 | } 240 | 241 | /** 242 | * 获取字段对应的 detail 243 | * @param array|null $keys 244 | * @return DetailAttribute[] 245 | */ 246 | public function pickDetail(?array $keys = null): array 247 | { 248 | $data = []; 249 | foreach ($this->getPresetItems($keys) as $key => $item) { 250 | $value = $item->withKey($key)->withScene($this->scene)->getDetail(); 251 | if ($value === null) { 252 | continue; 253 | } 254 | $data = array_merge($data, $value); 255 | } 256 | return $data; 257 | } 258 | 259 | /** 260 | * 获取 presetItems 261 | * @param array|null $keys 262 | * @return array 263 | */ 264 | protected function getPresetItems(?array $keys = null): array 265 | { 266 | $keys = $this->getKeys($keys); 267 | 268 | $data = []; 269 | foreach ($keys as $key) { 270 | if (!isset($this->presets[$key])) { 271 | continue; 272 | } 273 | $data[$key] = $this->presets[$key]; 274 | } 275 | 276 | return $data; 277 | } 278 | 279 | /** 280 | * 获取场景对应的 keys 281 | * @param array|null $keys 282 | * @return array 283 | */ 284 | private function getKeys(?array $keys = null): array 285 | { 286 | if ($keys === null) { 287 | $keys = $this->sceneKeys[$this->scene] ?? array_keys($this->presets); 288 | } 289 | return $keys; 290 | } 291 | } 292 | -------------------------------------------------------------------------------- /src/Install.php: -------------------------------------------------------------------------------- 1 | 'config/plugin/webman-tech/amis-admin', 14 | '../copy/resource/translations/en/amis-admin.php' => 'resource/translations/en/amis-admin.php', 15 | ); 16 | 17 | /** 18 | * Install 19 | * @return void 20 | */ 21 | public static function install() 22 | { 23 | static::installByRelation(); 24 | } 25 | 26 | /** 27 | * Uninstall 28 | * @return void 29 | */ 30 | public static function uninstall() 31 | { 32 | self::uninstallByRelation(); 33 | } 34 | 35 | /** 36 | * installByRelation 37 | * @return void 38 | */ 39 | public static function installByRelation() 40 | { 41 | foreach (static::$pathRelation as $source => $dest) { 42 | if ($pos = strrpos($dest, '/')) { 43 | $parent_dir = base_path() . '/' . substr($dest, 0, $pos); 44 | if (!is_dir($parent_dir)) { 45 | mkdir($parent_dir, 0777, true); 46 | } 47 | } 48 | //symlink(__DIR__ . "/$source", base_path()."/$dest"); 49 | copy_dir(__DIR__ . "/$source", base_path() . "/$dest"); 50 | echo "Create $dest 51 | "; 52 | } 53 | } 54 | 55 | /** 56 | * uninstallByRelation 57 | * @return void 58 | */ 59 | public static function uninstallByRelation() 60 | { 61 | foreach (static::$pathRelation as $source => $dest) { 62 | $path = base_path() . "/$dest"; 63 | if (!is_dir($path) && !is_file($path)) { 64 | continue; 65 | } 66 | echo "Remove $dest 67 | "; 68 | if (is_file($path) || is_link($path)) { 69 | unlink($path); 70 | continue; 71 | } 72 | remove_dir($path); 73 | } 74 | } 75 | 76 | } 77 | -------------------------------------------------------------------------------- /src/Middleware/AmisModuleChangeMiddleware.php: -------------------------------------------------------------------------------- 1 | {ConfigHelper::AMIS_MODULE} = $this->moduleName; 23 | 24 | return $handler($request); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Repository/AbsRepository.php: -------------------------------------------------------------------------------- 1 | primaryKey ?? 'id'; 26 | } 27 | 28 | /** 29 | * @inheritdoc 30 | */ 31 | public function getLabel(string $attribute): string 32 | { 33 | return $this->attributeLabels()[$attribute] ?? $attribute; 34 | } 35 | 36 | /** 37 | * $attribute => $label 38 | * @return array 39 | */ 40 | protected function attributeLabels(): array 41 | { 42 | if ($this instanceof HasPresetInterface) { 43 | return $this->getPresetsHelper()->withScene()->pickLabel(); 44 | } 45 | 46 | return []; 47 | } 48 | 49 | /** 50 | * @inheritdoc 51 | */ 52 | public function getLabelRemark(string $attribute) 53 | { 54 | return $this->attributeLabelRemarks()[$attribute] ?? null; 55 | } 56 | 57 | /** 58 | * $attribute => $labelRemark 59 | * @return array 60 | */ 61 | protected function attributeLabelRemarks(): array 62 | { 63 | if ($this instanceof HasPresetInterface) { 64 | return $this->getPresetsHelper()->withScene()->pickLabelRemark(); 65 | } 66 | 67 | return []; 68 | } 69 | 70 | /** 71 | * @inheritdoc 72 | */ 73 | public function getDescription(string $attribute) 74 | { 75 | return $this->attributeDescriptions()[$attribute] ?? null; 76 | } 77 | 78 | /** 79 | * $attribute => $labelRemark 80 | * @return array 81 | */ 82 | protected function attributeDescriptions(): array 83 | { 84 | if ($this instanceof HasPresetInterface) { 85 | return $this->getPresetsHelper()->withScene()->pickDescription(); 86 | } 87 | 88 | return []; 89 | } 90 | 91 | /** 92 | * 获取需要隐藏的字段 93 | * @param string $scene 94 | * @return array 95 | */ 96 | protected function hiddenAttributes(string $scene): array 97 | { 98 | return []; 99 | } 100 | 101 | /** 102 | * 获取默认隐藏后需要展示的字段 103 | * @param string $scene 104 | * @return array 105 | */ 106 | protected function visibleAttributes(string $scene): array 107 | { 108 | return []; 109 | } 110 | 111 | /** 112 | * 获取需要追加的字段 113 | * @param string $scene 114 | * @return array 115 | */ 116 | protected function appendAttributes(string $scene): array 117 | { 118 | return []; 119 | } 120 | 121 | /** 122 | * @inheritDoc 123 | */ 124 | public function create(array $data): void 125 | { 126 | $data = $this->validate($data, static::SCENE_CREATE); 127 | $this->doCreate($data); 128 | } 129 | 130 | /** 131 | * @param array $data 132 | */ 133 | abstract protected function doCreate(array $data): void; 134 | 135 | /** 136 | * @inheritDoc 137 | */ 138 | public function update(array $data, $id): void 139 | { 140 | $data = $this->validate($data, static::SCENE_UPDATE); 141 | $this->doUpdate($data, $id); 142 | } 143 | 144 | /** 145 | * @param array $data 146 | * @param string|int $id 147 | */ 148 | abstract protected function doUpdate(array $data, $id): void; 149 | 150 | /** 151 | * 验证字段 152 | * @return array 验证过后的字段 153 | * @throws ValidationException 154 | */ 155 | public function validate(array $data, string $scene): array 156 | { 157 | return $this->validator()->validate( 158 | $data, 159 | $this->rules($scene), 160 | $this->ruleMessages($scene), 161 | $this->ruleCustomAttributes($scene) 162 | ); 163 | } 164 | 165 | /** 166 | * @param ValidatorInterface $validator 167 | * @return $this 168 | */ 169 | public function withValidator(ValidatorInterface $validator) 170 | { 171 | $this->validator = $validator; 172 | return $this; 173 | } 174 | 175 | /** 176 | * @return ValidatorInterface 177 | */ 178 | protected function validator(): ValidatorInterface 179 | { 180 | if ($this->validator) { 181 | return $this->validator; 182 | } 183 | if (($validator = ConfigHelper::get('validator')) && is_callable($validator)) { 184 | $this->validator = call_user_func($validator); 185 | if (!$this->validator instanceof ValidatorInterface) { 186 | throw new \InvalidArgumentException('validator must be instance of ' . ValidatorInterface::class); 187 | } 188 | } else { 189 | $this->validator = new NullValidator(); 190 | } 191 | 192 | return $this->validator; 193 | } 194 | 195 | /** 196 | * @param string $scene 197 | * @return array 198 | */ 199 | protected function rules(string $scene): array 200 | { 201 | if ($this instanceof HasPresetInterface) { 202 | return $this->getPresetsHelper()->withScene($scene)->pickRules(); 203 | } 204 | 205 | return []; 206 | } 207 | 208 | /** 209 | * @param string $scene 210 | * @return array 211 | */ 212 | protected function ruleMessages(string $scene): array 213 | { 214 | if ($this instanceof HasPresetInterface) { 215 | return $this->getPresetsHelper()->withScene($scene)->pickRuleMessages(); 216 | } 217 | 218 | return []; 219 | } 220 | 221 | /** 222 | * @param string $scene 223 | * @return array 224 | */ 225 | protected function ruleCustomAttributes(string $scene): array 226 | { 227 | if ($this instanceof HasPresetInterface) { 228 | return $this->getPresetsHelper()->withScene($scene)->pickRuleCustomAttributes(); 229 | } 230 | 231 | return $this->attributeLabels(); 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /src/Repository/EloquentRepository.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | protected string $modelClass; 20 | protected ?array $defaultOrder = null; 21 | 22 | /** 23 | * @param EloquentModel|class-string $model 24 | */ 25 | public function __construct($model) 26 | { 27 | $this->initModel($model); 28 | if ($this->defaultOrder === null) { 29 | $this->defaultOrder = [ 30 | // 默认按照主键倒序 31 | $this->model()->qualifyColumn($this->model()->getKeyName()) => 'desc', 32 | ]; 33 | } 34 | } 35 | 36 | /** 37 | * @inheritdoc 38 | */ 39 | public function getPrimaryKey(): string 40 | { 41 | return $this->primaryKey ?: $this->model()->getKeyName(); 42 | } 43 | 44 | /** 45 | * @param EloquentModel|class-string $model 46 | */ 47 | protected function initModel($model): void 48 | { 49 | if (is_string($model)) { 50 | $this->modelClass = $model; 51 | } elseif ($model instanceof EloquentModel) { 52 | $this->modelClass = $model::class; 53 | $this->model = $model; 54 | } else { 55 | throw new \InvalidArgumentException('model error'); 56 | } 57 | $this->primaryKey = $this->model()->getKeyName(); 58 | } 59 | 60 | /** 61 | * @return EloquentModel 62 | */ 63 | public function model(): EloquentModel 64 | { 65 | if (!$this->model) { 66 | $this->model = new $this->modelClass; 67 | } 68 | return $this->model; 69 | } 70 | 71 | /** 72 | * @return EloquentBuilder 73 | */ 74 | public function query(): EloquentBuilder 75 | { 76 | return $this->model()->newQuery(); 77 | } 78 | 79 | /** 80 | * @inheritDoc 81 | */ 82 | public function pagination(int $page = 1, int $perPage = 20, array $search = [], array $order = []): array 83 | { 84 | $query = $this->query(); 85 | $query = $this->buildSearch($query, $search); 86 | $query = $this->buildOrder($query, $order); 87 | $query = $this->extGridQuery($query); 88 | 89 | $paginator = $this->queryPagination($query, $perPage, $page); 90 | if ($paginator instanceof AbstractPaginator) { 91 | /** @var DBCollection $itemCollection */ 92 | $itemCollection = $paginator->getCollection(); 93 | } elseif ($paginator instanceof DBCollection) { 94 | $itemCollection = $paginator; 95 | } else { 96 | throw new \InvalidArgumentException('error $paginator type'); 97 | } 98 | $itemCollection 99 | ->makeHidden($this->hiddenAttributes(static::SCENE_LIST)) 100 | ->makeVisible($this->visibleAttributes(static::SCENE_LIST)) 101 | ->append($this->appendAttributes(static::SCENE_LIST)); 102 | 103 | return $this->solvePaginationResult($paginator); 104 | } 105 | 106 | /** 107 | * 构建搜索条件 108 | * @param EloquentBuilder $query 109 | * @param array $search 110 | * @return EloquentBuilder 111 | */ 112 | protected function buildSearch(EloquentBuilder $query, array $search): EloquentBuilder 113 | { 114 | $searchableAttributes = $this->searchableAttributes(); 115 | foreach ($search as $attribute => $value) { 116 | if (array_key_exists($attribute, $searchableAttributes) && $value !== '' && $value !== null) { 117 | $newQuery = call_user_func($searchableAttributes[$attribute], $query, $value, $attribute); 118 | if ($newQuery instanceof EloquentBuilder) { 119 | return $newQuery; 120 | } 121 | } 122 | } 123 | return $query; 124 | } 125 | 126 | /** 127 | * 搜索字段配置 128 | * @return array 129 | */ 130 | protected function searchableAttributes(): array 131 | { 132 | if ($this instanceof HasPresetInterface) { 133 | return $this->getPresetsHelper()->withScene(AbsRepository::SCENE_LIST)->pickFilter(); 134 | } 135 | 136 | // 表下的所有字段可搜索 137 | $columns = $this->model()->getConnection()->getSchemaBuilder()->getColumnListing($this->model()->getTable()); 138 | $result = []; 139 | foreach ($columns as $column) { 140 | $result[$column] = fn($query, $value, $attribute) => $query->where($attribute, $value); 141 | } 142 | return $result; 143 | } 144 | 145 | /** 146 | * 构建排序条件 147 | * @param EloquentBuilder $query 148 | * @param array $order 149 | * @return EloquentBuilder 150 | */ 151 | protected function buildOrder(EloquentBuilder $query, array $order): EloquentBuilder 152 | { 153 | if (!$order) { 154 | $order = $this->defaultOrder ?? []; 155 | } 156 | foreach ($order as $column => $direction) { 157 | $query->orderBy($column, $direction); 158 | } 159 | return $query; 160 | } 161 | 162 | /** 163 | * 扩展列表的 query 164 | * @param EloquentBuilder $query 165 | * @return EloquentBuilder 166 | */ 167 | protected function extGridQuery(EloquentBuilder $query): EloquentBuilder 168 | { 169 | return $query; 170 | } 171 | 172 | /** 173 | * 查询分页数据 174 | * @param EloquentBuilder $query 175 | * @param int $perPage 176 | * @param int $page 177 | * @return PaginatorInterface|DBCollection 返回分页或全量数据 178 | */ 179 | protected function queryPagination(EloquentBuilder $query, int $perPage, int $page) 180 | { 181 | return $query->paginate($perPage, ['*'], 'page', $page); 182 | } 183 | 184 | /** 185 | * 处理分页结果 186 | * @param PaginatorInterface|DBCollection $paginator 187 | * @return array 188 | */ 189 | protected function solvePaginationResult($paginator): array 190 | { 191 | if ($paginator instanceof LengthAwarePaginator) { 192 | return [ 193 | 'items' => $paginator->items(), 194 | 'total' => $paginator->total(), 195 | ]; 196 | } 197 | if ($paginator instanceof Paginator) { 198 | return [ 199 | 'items' => $paginator->items(), 200 | 'hasNext' => $paginator->hasMorePages(), 201 | ]; 202 | } 203 | if ($paginator instanceof DBCollection) { 204 | return [ 205 | 'items' => $paginator, 206 | ]; 207 | } 208 | throw new \InvalidArgumentException('$paginator type error'); 209 | } 210 | 211 | /** 212 | * @inheritDoc 213 | */ 214 | public function detail($id): array 215 | { 216 | $query = $this->query(); 217 | $query = $this->extDetailQuery($query); 218 | $collection = $query->findOrFail($id) 219 | ->makeHidden($this->hiddenAttributes(static::SCENE_DETAIL)) 220 | ->makeVisible($this->visibleAttributes(static::SCENE_DETAIL)) 221 | ->append($this->appendAttributes(static::SCENE_DETAIL)); 222 | 223 | return $this->solveDetailResult($collection); 224 | } 225 | 226 | /** 227 | * 扩展 detail 的 query 228 | * @param EloquentBuilder $query 229 | * @return EloquentBuilder 230 | */ 231 | protected function extDetailQuery(EloquentBuilder $query): EloquentBuilder 232 | { 233 | return $query; 234 | } 235 | 236 | /** 237 | * 处理明细结果 238 | * @param EloquentModel $query 239 | * @return array 240 | */ 241 | protected function solveDetailResult(EloquentModel $query): array 242 | { 243 | return $query->toArray(); 244 | } 245 | 246 | /** 247 | * @inheritDoc 248 | */ 249 | protected function doCreate(array $data): void 250 | { 251 | $model = $this->model(); 252 | foreach ($data as $key => $value) { 253 | $model->{$key} = $value; 254 | } 255 | $this->doSave($model); 256 | } 257 | 258 | /** 259 | * @inheritDoc 260 | */ 261 | protected function doUpdate(array $data, $id): void 262 | { 263 | $model = $this->query()->findOrFail($id); 264 | foreach ($data as $key => $value) { 265 | $model->{$key} = $value; 266 | } 267 | $this->doSave($model); 268 | } 269 | 270 | /** 271 | * @param EloquentModel $model 272 | */ 273 | protected function doSave(EloquentModel $model): void 274 | { 275 | $model->save(); 276 | } 277 | 278 | /** 279 | * @inheritDoc 280 | */ 281 | public function destroy($id): void 282 | { 283 | $model = $this->query()->findOrFail($id); 284 | $model->delete(); 285 | } 286 | 287 | /** 288 | * @inheritDoc 289 | */ 290 | public function recovery($id): void 291 | { 292 | /* @phpstan-ignore-next-line */ 293 | $this->query() 294 | ->withTrashed() 295 | ->whereKey($id) 296 | ->restore(); 297 | } 298 | } 299 | -------------------------------------------------------------------------------- /src/Repository/HasPresetInterface.php: -------------------------------------------------------------------------------- 1 | presetsHelper === null) { 19 | $this->presetsHelper = $this->createPresetsHelper(); 20 | } 21 | return $this->presetsHelper; 22 | } 23 | 24 | /** 25 | * 创建 PresetsHelper 26 | * @return PresetsHelper 27 | */ 28 | abstract protected function createPresetsHelper(): PresetsHelper; 29 | } 30 | -------------------------------------------------------------------------------- /src/Repository/RepositoryInterface.php: -------------------------------------------------------------------------------- 1 | factory->make($data, $rules, $messages, $customAttributes); 29 | if ($validator->fails()) { 30 | $errors = array_map(fn($messages) => $messages[0], $validator->errors()->toArray()); 31 | throw new ValidationException($errors); 32 | } 33 | return $validator->validated(); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Validator/NullValidator.php: -------------------------------------------------------------------------------- 1 | response($data, $msg, $extra); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/view/_amis-basic.php: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | 12 | 13 | <?= $title ?? 'App Admin' ?> 14 | 15 | 16 | 17 | 18 | 19 | 20 | 31 | 32 | 33 |
34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /src/view/amis-app-history.js: -------------------------------------------------------------------------------- 1 | const historyRouter = { 2 | match: null, 3 | history: null, 4 | init(routeMode = 'hash') { 5 | historyRouter.match = amisRequire('path-to-regexp').match; 6 | historyRouter.history = routeMode === 'history' ? History.createBrowserHistory() : History.createHashHistory() 7 | }, 8 | normalizeLink(to, location = historyRouter.history.location) { 9 | to = to || ''; 10 | 11 | if (to && to[0] === '#') { 12 | to = location.pathname + location.search + to; 13 | } else if (to && to[0] === '?') { 14 | to = location.pathname + to; 15 | } 16 | 17 | const idx = to.indexOf('?'); 18 | const idx2 = to.indexOf('#'); 19 | let pathname = ~idx 20 | ? to.substring(0, idx) 21 | : ~idx2 22 | ? to.substring(0, idx2) 23 | : to; 24 | let search = ~idx ? to.substring(idx, ~idx2 ? idx2 : undefined) : ''; 25 | let hash = ~idx2 ? to.substring(idx2) : location.hash; 26 | 27 | if (!pathname) { 28 | pathname = location.pathname; 29 | } else if (pathname[0] !== '/' && !/^https?\:\/\//.test(pathname)) { 30 | let relativeBase = location.pathname; 31 | const paths = relativeBase.split('/'); 32 | paths.pop(); 33 | let m; 34 | while ((m = /^\.\.?\//.exec(pathname))) { 35 | if (m[0] === '../') { 36 | paths.pop(); 37 | } 38 | pathname = pathname.substring(m[0].length); 39 | } 40 | pathname = paths.concat(pathname).join('/'); 41 | } 42 | 43 | return pathname + search + hash; 44 | }, 45 | isCurrentUrl(to, ctx) { 46 | if (!to) { 47 | return false; 48 | } 49 | const pathname = historyRouter.history.location.pathname; 50 | const link = historyRouter.normalizeLink(to, { 51 | ...window.location, 52 | pathname, 53 | hash: '' 54 | }); 55 | 56 | if (!~link.indexOf('http') && ~link.indexOf(':')) { 57 | let strict = ctx && ctx.strict; 58 | return match(link, { 59 | decode: decodeURIComponent, 60 | strict: typeof strict !== 'undefined' ? strict : true 61 | })(pathname); 62 | } 63 | 64 | return decodeURI(pathname) === link; 65 | }, 66 | updateLocation(location, replace) { 67 | location = historyRouter.normalizeLink(location); 68 | if (location === 'goBack') { 69 | return historyRouter.history.goBack(); 70 | } else if ( 71 | (!/^https?\:\/\//.test(location) && 72 | location === 73 | historyRouter.history.location.pathname + historyRouter.history.location.search) || 74 | location === historyRouter.history.location.href 75 | ) { 76 | // 目标地址和当前地址一样,不处理,免得重复刷新 77 | return; 78 | } else if (/^https?\:\/\//.test(location) || !historyRouter.history) { 79 | return (window.location.href = location); 80 | } 81 | 82 | historyRouter.history[replace ? 'replace' : 'push'](location); 83 | }, 84 | jumpTo(to, action) { 85 | if (to === 'goBack') { 86 | return historyRouter.history.goBack(); 87 | } 88 | 89 | to = historyRouter.normalizeLink(to); 90 | 91 | if (historyRouter.isCurrentUrl(to)) { 92 | return; 93 | } 94 | 95 | if (action && action.actionType === 'url') { 96 | action.blank === false 97 | ? (window.location.href = to) 98 | : window.open(to, '_blank'); 99 | return; 100 | } else if (action && action.blank) { 101 | window.open(to, '_blank'); 102 | return; 103 | } 104 | 105 | if (/^https?:\/\//.test(to)) { 106 | window.location.href = to; 107 | } else if ( 108 | (!/^https?\:\/\//.test(to) && 109 | to === historyRouter.history.pathname + historyRouter.history.location.search) || 110 | to === historyRouter.history.location.href 111 | ) { 112 | // do nothing 113 | } else { 114 | historyRouter.history.push(to); 115 | } 116 | } 117 | } 118 | 119 | historyRouter.init(routeMode) 120 | 121 | window.amisAppProps = Object.assign({ 122 | location: historyRouter.history.location, 123 | }, window.amisAppProps || {}); 124 | 125 | window.amisAppEnv = Object.assign({ 126 | updateLocation: historyRouter.updateLocation, 127 | jumpTo: historyRouter.jumpTo, 128 | isCurrentUrl: historyRouter.isCurrentUrl, 129 | }, window.amisAppEnv || {}); 130 | 131 | const tmpAmisAppLoaded = window.amisAppLoaded 132 | window.amisAppLoaded = (amisApp) => { 133 | tmpAmisAppLoaded && tmpAmisAppLoaded() 134 | historyRouter.history.listen(state => { 135 | amisApp.updateProps({ 136 | location: state.location || state 137 | }); 138 | }); 139 | } 140 | -------------------------------------------------------------------------------- /src/view/amis-app.html: -------------------------------------------------------------------------------- 1 |