// +---------------------------------------------------------------------- declare(strict_types=1); namespace crmeb\services\schema; use crmeb\exceptions\AdminException; /** * 页面配置校验器 * * 对插件返回的页面配置做结构校验与规范化: * - 页面类型必须在封闭集合内(非法直接抛异常) * - 表格列类型/搜索字段类型/行操作模式超出白名单时降级(text/input,不中断渲染) * - 缺失关键结构(columns)时抛异常,帮助插件开发者尽早发现配置错误 */ class SchemaConfigValidator { /** * 支持的页面类型 * @var array */ public const PAGE_TYPES = ['list', 'form', 'stat', 'detail']; /** * 支持的表格列类型(未知类型降级为 text) * @var array */ public const COLUMN_TYPES = ['text', 'number', 'amount', 'datetime', 'date', 'tag', 'image', 'switch', 'link', 'ellipsis']; /** * 支持的搜索字段类型(未知类型降级为 input) * @var array */ public const SEARCH_TYPES = ['input', 'number', 'select', 'date', 'daterange']; /** * 支持的行操作模式(未知模式直接剔除) * @var array */ public const ACTION_MODES = ['drawer_form', 'drawer_detail', 'confirm_action', 'direct_action', 'navigate']; /** * 支持的批量操作模式(批量场景仅允许确认与直接执行) * @var array */ public const BATCH_ACTION_MODES = ['confirm_action', 'direct_action']; /** * 支持的统计卡片值类型(未知类型降级为 text) * @var array */ public const CARD_TYPES = ['text', 'number', 'amount']; /** * 支持的行操作显示条件运算符(visible 条件声明,未知运算符整条剔除) * @var array */ public const VISIBLE_OPS = ['eq', 'neq', 'gt', 'lt', 'gte', 'lte', 'empty', 'not_empty']; /** * 校验并规范化页面配置 * * @param array $config 插件返回的原始配置 * @return array 规范化后的配置 * @throws AdminException 页面类型非法或列表页缺少列定义 */ public static function validate(array $config): array { $type = (string)($config['type'] ?? ''); if (!in_array($type, self::PAGE_TYPES, true)) { throw new AdminException('页面配置 type 非法,仅支持: ' . implode('/', self::PAGE_TYPES)); } if ($type === 'list') { $config = self::validateList($config); } elseif ($type === 'detail') { $config = self::validateDetail($config); } return $config; } /** * 校验详情页配置(单例只读内容页:提示条 + 内容字段 + 页面级动作) * * @param array $config 原始配置 * @return array 规范化后的配置 */ protected static function validateDetail(array $config): array { // tip:顶部信息提示条文案(可选,非字符串时丢弃) if (isset($config['tip']) && !is_string($config['tip'])) { $config['tip'] = ''; } // actions:页面级动作(模式白名单与行操作同规则) $config['actions'] = self::normalizeActions($config['actions'] ?? []); return $config; } /** * 动作数组规范化(模式白名单校验,非法项剔除) * * @param mixed $actions 动作声明列表 * @return array */ protected static function normalizeActions($actions): array { $normalized = []; if (!empty($actions) && is_array($actions)) { foreach ($actions as $action) { if (!is_array($action) || empty($action['action']) || !isset($action['label'])) { continue; } $mode = (string)($action['mode'] ?? 'direct_action'); if (!in_array($mode, self::ACTION_MODES, true)) { continue; } $action['mode'] = $mode; // 行级显示条件:[{field, value, op}] 全部满足才显示(结构非法整条剔除) if (isset($action['visible'])) { $action['visible'] = self::normalizeVisible($action['visible']); if (empty($action['visible'])) { unset($action['visible']); } } $normalized[] = $action; } } return $normalized; } /** * 行操作显示条件规范化 * * @param mixed $visible 条件声明列表 * @return array */ protected static function normalizeVisible($visible): array { $normalized = []; if (!empty($visible) && is_array($visible)) { foreach ($visible as $condition) { if (!is_array($condition) || !isset($condition['field'])) { continue; } $op = (string)($condition['op'] ?? 'eq'); if (!in_array($op, self::VISIBLE_OPS, true)) { continue; } $condition['op'] = $op; $normalized[] = $condition; } } return $normalized; } /** * 校验列表页配置 * * @param array $config 原始配置 * @return array 规范化后的配置 * @throws AdminException 缺少列定义 */ protected static function validateList(array $config): array { if (empty($config['columns']) || !is_array($config['columns'])) { throw new AdminException('列表页配置缺少 columns 列定义'); } // 列类型白名单校验,未知类型降级为 text;switch 列未声明动作时规范化为通用 status 动作 foreach ($config['columns'] as $index => $column) { if (!is_array($column) || empty($column['prop']) || !isset($column['label'])) { throw new AdminException('列表页 columns[' . $index . '] 缺少 prop 或 label'); } $columnType = (string)($column['type'] ?? 'text'); $config['columns'][$index]['type'] = in_array($columnType, self::COLUMN_TYPES, true) ? $columnType : 'text'; if ($columnType === 'switch' && empty($config['columns'][$index]['action'])) { $config['columns'][$index]['action'] = 'status'; } } // 搜索字段类型白名单校验,未知类型降级为 input if (!empty($config['search']) && is_array($config['search'])) { foreach ($config['search'] as $index => $field) { if (!is_array($field) || empty($field['field']) || !isset($field['label'])) { throw new AdminException('列表页 search[' . $index . '] 缺少 field 或 label'); } $fieldType = (string)($field['type'] ?? 'input'); $config['search'][$index]['type'] = in_array($fieldType, self::SEARCH_TYPES, true) ? $fieldType : 'input'; } } else { $config['search'] = []; } // 行操作模式白名单校验,未知模式剔除(与页面级动作同规则) $config['row_actions'] = self::normalizeActions($config['row_actions'] ?? []); // 批量操作:结构走行操作规则,模式仅允许 confirm_action/direct_action(其余剔除) $config['batch_actions'] = self::normalizeBatchActions($config['batch_actions'] ?? []); // 统计卡片行:field+label 必填(缺失剔除),值类型白名单降级 $config['cards'] = self::normalizeCards($config['cards'] ?? []); // 状态分类头:field 与 options 必填(结构不完整时整体剔除),with_count 规范化为布尔 $config['tabs'] = self::normalizeTabs($config['tabs'] ?? []); return $config; } /** * 统计卡片数组规范化 * * 数据由 list() 响应的 summary 键按 field 下发;缺 field/label 的项剔除, * type 超出白名单时降级为 text。 * * @param mixed $cards 卡片声明列表 * @return array */ protected static function normalizeCards($cards): array { $normalized = []; if (!empty($cards) && is_array($cards)) { foreach ($cards as $card) { if (!is_array($card) || empty($card['field']) || !isset($card['label'])) { continue; } $cardType = (string)($card['type'] ?? 'text'); $card['type'] = in_array($cardType, self::CARD_TYPES, true) ? $cardType : 'text'; $normalized[] = $card; } } return $normalized; } /** * 状态分类头规范化 * * 选中项的 value 以 where[field] 提交;with_count 为 true 时计数由 list() 响应 * 的 tabs_count 键下发(键为选项 value 的字符串形式)。 * * @param mixed $tabs 分类头声明 * @return array */ protected static function normalizeTabs($tabs): array { if (!empty($tabs) && is_array($tabs) && !empty($tabs['field']) && !empty($tabs['options']) && is_array($tabs['options'])) { $tabs['with_count'] = !empty($tabs['with_count']); return $tabs; } return []; } /** * 批量操作数组规范化(结构同行操作,模式限定在批量白名单内) * * @param mixed $batchActions 批量操作声明列表 * @return array */ protected static function normalizeBatchActions($batchActions): array { return array_values(array_filter( self::normalizeActions($batchActions), fn(array $action): bool => in_array($action['mode'], self::BATCH_ACTION_MODES, true) )); } }