init
This commit is contained in:
@@ -105,6 +105,7 @@ return [
|
||||
&& str_contains($step['file'], 'support') === false
|
||||
&& str_contains($step['file'], 'monolog') === false
|
||||
&& str_contains($step['file'], 'Logger') === false
|
||||
&& str_contains($step['file'], 'vendor') === false
|
||||
) {
|
||||
// 获取文件路径(简化为项目内相对路径)
|
||||
$file = $step['file'];
|
||||
|
||||
@@ -0,0 +1,598 @@
|
||||
<?php
|
||||
|
||||
namespace plugin\admin\app\common;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use InvalidArgumentException;
|
||||
use plugin\admin\app\model\OpmMwPermissionRule;
|
||||
use support\Db;
|
||||
|
||||
/**
|
||||
* 数据权限规则服务类
|
||||
*
|
||||
* 作用:
|
||||
* 将数据库里配置的数据权限规则,动态应用到 Eloquent 查询构造器上。
|
||||
*
|
||||
* 规则配置示例:
|
||||
* - table: opm_mw_info_data // 规则对应的主表
|
||||
* - field: organ_id // 需要限制的字段
|
||||
* - field_table: opm_mw_info_data // 字段属于哪个表,可选
|
||||
* - admin_attr: hospitals // 当前管理员属性名
|
||||
* - admin_attr_map: opm_mw_department.id...opm_mw_department.name
|
||||
* - action: in
|
||||
* - status: 1
|
||||
* - sort: 1
|
||||
*
|
||||
* 说明:
|
||||
* 1. 默认规则字段会拼到主表别名上;
|
||||
* 2. 如果 field_table 配置了,就优先使用对应表别名;
|
||||
* 3. admin_attr_map 当前按“同表映射”使用,即:
|
||||
* 从 sourceField 查出 targetField 的值。
|
||||
*/
|
||||
class DataPermissionService
|
||||
{
|
||||
/**
|
||||
* 当前登录管理员信息
|
||||
*
|
||||
* 例如:
|
||||
* [
|
||||
* 'id' => 1,
|
||||
* 'name' => 'admin',
|
||||
* 'hospitals' => '1,2,3'
|
||||
* ]
|
||||
*/
|
||||
protected array $admin;
|
||||
|
||||
/**
|
||||
* 构造函数
|
||||
*
|
||||
* @param array $admin 当前管理员信息
|
||||
*/
|
||||
public function __construct(array $admin)
|
||||
{
|
||||
$this->admin = $admin;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将权限规则应用到查询构造器
|
||||
*
|
||||
* 逻辑:
|
||||
* 1. 从查询中解析主表和别名;
|
||||
* 2. 读取该表对应的权限规则;
|
||||
* 3. 收集查询中所有表别名;
|
||||
* 4. 逐条应用权限规则;
|
||||
*
|
||||
* @param Builder|string $query 查询对象
|
||||
* @return Builder
|
||||
*/
|
||||
public function apply(Builder|string $query): Builder
|
||||
{
|
||||
// 兼容传入 Builder 的情况;这里统一取底层 Query Builder
|
||||
$baseQuery = $query->getQuery();
|
||||
|
||||
// 解析主表名和主表别名,例如:table as t => [table, t]
|
||||
[$fromTable, $fromAlias] = $this->parseTableAlias((string)($baseQuery->from ?? ''));
|
||||
|
||||
// 如果无法识别主表,直接返回,不处理
|
||||
if ($fromTable === '') {
|
||||
return $query;
|
||||
}
|
||||
|
||||
// 加载该主表对应的启用规则
|
||||
$rules = $this->loadRules($fromTable);
|
||||
|
||||
// 没有规则则不做任何限制
|
||||
if (empty($rules)) {
|
||||
return $query;
|
||||
}
|
||||
|
||||
// 获取查询里所有表的别名映射,后续用于拼字段
|
||||
$tableAliases = $this->getAllTableAliases($baseQuery);
|
||||
|
||||
// 逐条应用规则
|
||||
foreach ($rules as $rule) {
|
||||
$query = $this->applySingleRule($query, $rule, $fromAlias, $tableAliases);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载指定表的启用权限规则
|
||||
*
|
||||
* 只读取:
|
||||
* - table = $tableName
|
||||
* - status = 1
|
||||
* 并按照 sort 升序排列
|
||||
*
|
||||
* @param string $tableName 表名
|
||||
* @return array
|
||||
*/
|
||||
protected function loadRules(string $tableName): array
|
||||
{
|
||||
$this->validateIdentifierPath($tableName, '表名');
|
||||
|
||||
return OpmMwPermissionRule::query()
|
||||
->where('table', $tableName)
|
||||
->where('status', 1)
|
||||
->orderBy('sort', 'asc')
|
||||
->get()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用单条权限规则
|
||||
*
|
||||
* 核心流程:
|
||||
* 1. 读取 rule 配置;
|
||||
* 2. 取出管理员对应属性;
|
||||
* 3. 如为 * 则表示拥有全部权限,直接跳过;
|
||||
* 4. 根据 admin_attr_map 解析映射值;
|
||||
* 5. 得到完整字段名;
|
||||
* 6. 根据 action 生成 where 条件。
|
||||
*
|
||||
* @param Builder $query 查询对象
|
||||
* @param array $rule 单条规则
|
||||
* @param string $mainAlias 主表别名
|
||||
* @param array $tableAliases 查询内所有表别名映射
|
||||
* @return Builder
|
||||
*/
|
||||
protected function applySingleRule(
|
||||
Builder $query,
|
||||
array $rule,
|
||||
string $mainAlias,
|
||||
array $tableAliases
|
||||
): Builder
|
||||
{
|
||||
$field = trim((string)($rule['field'] ?? ''));
|
||||
$adminAttr = trim((string)($rule['admin_attr'] ?? ''));
|
||||
$adminAttrMap = trim((string)($rule['admin_attr_map'] ?? ''));
|
||||
$action = strtolower(trim((string)($rule['action'] ?? 'in')));
|
||||
$fieldTable = trim((string)($rule['field_table'] ?? ''));
|
||||
|
||||
// 规则字段或管理员属性为空,视为无效规则,直接跳过
|
||||
if ($field === '' || $adminAttr === '') {
|
||||
return $query;
|
||||
}
|
||||
|
||||
// 校验字段写法是否合法
|
||||
$this->validateFieldPath($field, '规则字段');
|
||||
|
||||
// 如果配置了字段所属表,也要校验
|
||||
if ($fieldTable !== '') {
|
||||
$this->validateIdentifierPath($fieldTable, '字段所属表');
|
||||
}
|
||||
|
||||
// 取管理员对应属性值
|
||||
$rawValue = $this->admin[$adminAttr] ?? '';
|
||||
|
||||
// * 表示不受限制
|
||||
if ($rawValue === '*') {
|
||||
return $query;
|
||||
}
|
||||
|
||||
// 解析管理员属性值,必要时通过映射转换成目标值
|
||||
$resolvedValues = $this->resolveAdminAttrValue($rawValue, $adminAttrMap);
|
||||
|
||||
// 如果最终解析不到任何可用值,则直接返回空结果
|
||||
if (empty($resolvedValues) && !in_array('0', $resolvedValues, true)) {
|
||||
return $query->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
// 获取规则字段的完整字段名,如 dept.id / t.organ_id
|
||||
$fullField = $this->getFullFieldName($field, $mainAlias, $tableAliases, $fieldTable);
|
||||
|
||||
// in / not in 这类操作符需要数组值
|
||||
if ($this->isArrayOperator($action)) {
|
||||
return $this->buildCondition($query, $fullField, $action, $resolvedValues);
|
||||
}
|
||||
|
||||
// 单值操作符只能对应一个结果,否则说明配置有问题
|
||||
if (count($resolvedValues) !== 1) {
|
||||
throw new InvalidArgumentException(
|
||||
"规则配置错误:操作符 {$action} 只能对应单个值,但当前解析出 " . count($resolvedValues) . " 个值"
|
||||
);
|
||||
}
|
||||
|
||||
return $this->buildCondition($query, $fullField, $action, $resolvedValues[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析管理员属性值
|
||||
*
|
||||
* 支持:
|
||||
* - 字符串:1,2,3 / 1,2,3 / 1 2 3
|
||||
* - 数组:['1', '2']
|
||||
*
|
||||
* admin_attr_map 格式:
|
||||
* - source_table.source_field...target_table.target_field
|
||||
*
|
||||
* 示例:
|
||||
* - opm_mw_department.id...opm_mw_department.name
|
||||
*
|
||||
* 说明:
|
||||
* - 当前实现按照“同表映射”使用 sourceField -> targetField
|
||||
* - sourceTable 主要用于配置语义和校验
|
||||
*
|
||||
* @param mixed $rawValue 管理员原始属性值
|
||||
* @param string $mapStr 映射配置字符串
|
||||
* @return array
|
||||
*/
|
||||
protected function resolveAdminAttrValue($rawValue, string $mapStr): array
|
||||
{
|
||||
// 先把原始值拆成列表
|
||||
$values = $this->parseListValues($rawValue);
|
||||
|
||||
// 没有任何值,直接返回空数组
|
||||
if (empty($values)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 没有映射配置,则直接返回原始值列表
|
||||
if ($mapStr === '') {
|
||||
return $values;
|
||||
}
|
||||
|
||||
// 映射格式必须包含 ...
|
||||
if (!str_contains($mapStr, '...')) {
|
||||
throw new InvalidArgumentException("无效的 admin_attr_map 格式:{$mapStr}");
|
||||
}
|
||||
|
||||
[$sourcePart, $targetPart] = explode('...', $mapStr, 2);
|
||||
|
||||
$sourcePart = trim($sourcePart);
|
||||
$targetPart = trim($targetPart);
|
||||
|
||||
if ($sourcePart === '' || $targetPart === '') {
|
||||
throw new InvalidArgumentException("无效的 admin_attr_map 格式:{$mapStr}");
|
||||
}
|
||||
|
||||
if (!str_contains($sourcePart, '.') || !str_contains($targetPart, '.')) {
|
||||
throw new InvalidArgumentException("admin_attr_map 必须包含表名与字段名:{$mapStr}");
|
||||
}
|
||||
|
||||
[$sourceTable, $sourceField] = explode('.', $sourcePart, 2);
|
||||
[$targetTable, $targetField] = explode('.', $targetPart, 2);
|
||||
|
||||
$sourceTable = trim($sourceTable);
|
||||
$sourceField = trim($sourceField);
|
||||
$targetTable = trim($targetTable);
|
||||
$targetField = trim($targetField);
|
||||
|
||||
// 校验表名和字段名合法性,防止非法 SQL 标识符
|
||||
$this->validateIdentifierPath($sourceTable, '映射源表');
|
||||
$this->validateIdentifierPath($sourceField, '映射源字段');
|
||||
$this->validateIdentifierPath($targetTable, '映射目标表');
|
||||
$this->validateIdentifierPath($targetField, '映射目标字段');
|
||||
|
||||
if ($sourceTable !== $targetTable) {
|
||||
// 当前版本先按同表映射处理,避免复杂 join 映射带来歧义
|
||||
// 如果后面要扩展跨表映射,可以在这里补 join / 子查询 方案
|
||||
// admin_attr_map 本身是将 admin.sourceField 进行了一个跨表
|
||||
// 如果 admin_attr_map 内部再次跨表逻辑会更加混乱
|
||||
}
|
||||
|
||||
// 通过 sourceField 找到对应 targetField 的值
|
||||
$results = Db::table($targetTable)
|
||||
->whereIn($sourceField, $values)
|
||||
->pluck($targetField)
|
||||
->toArray();
|
||||
|
||||
// 统一转成字符串,去除空值
|
||||
$results = array_map(static fn($v) => trim((string)$v), $results);
|
||||
$results = array_filter($results, static fn($v) => $v !== '');
|
||||
|
||||
// 去重并重新排序
|
||||
return array_values(array_unique($results));
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 WHERE 条件
|
||||
*
|
||||
* 支持两类:
|
||||
* 1. 数组型条件:in / not in
|
||||
* 2. 单值条件:= / > / < / like / is null 等
|
||||
*
|
||||
* @param Builder $query 查询对象
|
||||
* @param string $field 完整字段名
|
||||
* @param string $action 操作符
|
||||
* @param mixed $value 条件值
|
||||
* @return Builder
|
||||
*/
|
||||
protected function buildCondition(Builder $query, string $field, string $action, $value): Builder
|
||||
{
|
||||
// 数组条件
|
||||
if (is_array($value)) {
|
||||
return match ($action) {
|
||||
'in' => $query->whereIn($field, $value),
|
||||
'not in', '<>in' => $query->whereNotIn($field, $value),
|
||||
default => throw new InvalidArgumentException("不支持的数组操作符:{$action}"),
|
||||
};
|
||||
}
|
||||
|
||||
// 单值条件
|
||||
return match ($action) {
|
||||
'=', 'eq' => $query->where($field, '=', $value),
|
||||
'>', 'gt' => $query->where($field, '>', $value),
|
||||
'<', 'lt' => $query->where($field, '<', $value),
|
||||
'>=', 'gte' => $query->where($field, '>=', $value),
|
||||
'<=', 'lte' => $query->where($field, '<=', $value),
|
||||
'<>', '!=', 'ne' => $query->where($field, '<>', $value),
|
||||
'like' => $query->where($field, 'like', $this->normalizeLikeValue((string)$value)),
|
||||
'not like' => $query->where($field, 'not like', $this->normalizeLikeValue((string)$value)),
|
||||
'is null' => $query->whereNull($field),
|
||||
'is not null' => $query->whereNotNull($field),
|
||||
default => throw new InvalidArgumentException("不支持的操作符:{$action}"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取完整字段名
|
||||
*
|
||||
* 优先级:
|
||||
* 1. 字段已带表名:直接返回
|
||||
* 2. 配置了 field_table:使用对应表别名
|
||||
* 3. 默认使用主表别名
|
||||
*
|
||||
* @param string $field 字段名
|
||||
* @param string $mainAlias 主表别名
|
||||
* @param array $tableAliases 查询中的表别名映射
|
||||
* @param string $fieldTable 字段所属表
|
||||
* @return string
|
||||
*/
|
||||
protected function getFullFieldName(string $field, string $mainAlias, array $tableAliases, string $fieldTable = ''): string
|
||||
{
|
||||
// 如果 field 已经是 table.field 格式,直接返回
|
||||
if (str_contains($field, '.')) {
|
||||
$this->validateFieldPath($field, '规则字段');
|
||||
return $field;
|
||||
}
|
||||
|
||||
// 默认使用主表别名
|
||||
$alias = $mainAlias;
|
||||
|
||||
// 如果规则里指定了字段所属表,则按该表找别名
|
||||
if ($fieldTable !== '') {
|
||||
$alias = $this->resolveTableAlias($fieldTable, $tableAliases, $mainAlias);
|
||||
}
|
||||
|
||||
if ($alias === '') {
|
||||
$alias = $mainAlias;
|
||||
}
|
||||
|
||||
// 校验别名是否合法
|
||||
$this->validateIdentifierPath($alias, '表别名');
|
||||
|
||||
return "{$alias}.{$field}";
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取查询中所有表的别名映射
|
||||
*
|
||||
* 返回格式:
|
||||
* [
|
||||
* 'opm_mw_info_data' => 'data',
|
||||
* 'data' => 'data',
|
||||
* 'opm_mw_department' => 'dept',
|
||||
* 'dept' => 'dept',
|
||||
* ]
|
||||
*
|
||||
* @param mixed $baseQuery 基础查询对象
|
||||
* @return array
|
||||
*/
|
||||
protected function getAllTableAliases($baseQuery): array
|
||||
{
|
||||
$aliases = [];
|
||||
|
||||
// 主表别名
|
||||
[$fromTable, $fromAlias] = $this->parseTableAlias((string)($baseQuery->from ?? ''));
|
||||
if ($fromTable !== '') {
|
||||
$aliases[$fromTable] = $fromAlias;
|
||||
$aliases[$fromAlias] = $fromAlias;
|
||||
}
|
||||
|
||||
// 关联表别名
|
||||
foreach (($baseQuery->joins ?? []) as $join) {
|
||||
[$joinTable, $joinAlias] = $this->parseTableAlias((string)($join->table ?? ''));
|
||||
if ($joinTable !== '') {
|
||||
$aliases[$joinTable] = $joinAlias;
|
||||
$aliases[$joinAlias] = $joinAlias;
|
||||
}
|
||||
}
|
||||
|
||||
return $aliases;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析表名和别名
|
||||
*
|
||||
* 支持:
|
||||
* - table
|
||||
* - table as t
|
||||
* - table t
|
||||
*
|
||||
* @param string $table 表表达式
|
||||
* @return array [tableName, alias]
|
||||
*/
|
||||
protected function parseTableAlias(string $table): array
|
||||
{
|
||||
// 去掉反引号并去首尾空格
|
||||
$table = trim(str_replace('`', '', $table));
|
||||
|
||||
if ($table === '') {
|
||||
return ['', ''];
|
||||
}
|
||||
|
||||
// 子查询或复杂表达式不处理
|
||||
if (str_starts_with($table, '(')) {
|
||||
return ['', ''];
|
||||
}
|
||||
|
||||
// 处理 as 写法
|
||||
if (stripos($table, ' as ') !== false) {
|
||||
[$name, $alias] = preg_split('/\s+as\s+/i', $table, 2);
|
||||
$name = trim((string)$name);
|
||||
$alias = trim((string)$alias);
|
||||
|
||||
return [$name, $alias !== '' ? $alias : $name];
|
||||
}
|
||||
|
||||
// 处理 table alias 写法
|
||||
$parts = preg_split('/\s+/', $table) ?: [];
|
||||
if (count($parts) >= 2) {
|
||||
return [trim($parts[0]), trim($parts[1])];
|
||||
}
|
||||
|
||||
// 没有别名时,默认表名即别名
|
||||
return [$table, $table];
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析列表值
|
||||
*
|
||||
* 支持:
|
||||
* - 数组
|
||||
* - 字符串:1,2,3 / 1,2,3 / 1 2 3
|
||||
*
|
||||
* 返回值统一为字符串数组,避免把非数字内容强制转 int 后出错
|
||||
*
|
||||
* @param mixed $raw 原始值
|
||||
* @return array
|
||||
*/
|
||||
protected function parseListValues($raw): array
|
||||
{
|
||||
if (is_array($raw)) {
|
||||
$values = [];
|
||||
array_walk_recursive($raw, static function ($item) use (&$values) {
|
||||
$values[] = trim((string)$item);
|
||||
});
|
||||
} else {
|
||||
$raw = trim((string)$raw);
|
||||
|
||||
// 空值或 * 视为无值
|
||||
if ($raw === '' || $raw === '*') {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 支持英文逗号、中文逗号、空格分隔
|
||||
$values = preg_split('/[,\s,]+/u', $raw) ?: [];
|
||||
$values = array_map(static fn($v) => trim((string)$v), $values);
|
||||
}
|
||||
|
||||
// 清理空项
|
||||
$values = array_filter($values, static fn($v) => $v !== '');
|
||||
|
||||
// 去重并重建索引
|
||||
return array_values(array_unique($values));
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为数组型操作符
|
||||
*
|
||||
* @param string $action 操作符
|
||||
* @return bool
|
||||
*/
|
||||
protected function isArrayOperator(string $action): bool
|
||||
{
|
||||
return in_array($action, ['in', 'not in', '<>in'], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* like 值自动补 %
|
||||
*
|
||||
* 规则:
|
||||
* - 如果用户已经写了 %,则保持原样
|
||||
* - 如果没有写 %,则默认转为 %xxx%
|
||||
*
|
||||
* @param string $value 原始值
|
||||
* @return string
|
||||
*/
|
||||
protected function normalizeLikeValue(string $value): string
|
||||
{
|
||||
if ($value === '') {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (!str_contains($value, '%')) {
|
||||
return '%' . $value . '%';
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析表名或别名到实际别名
|
||||
*
|
||||
* @param string $tableOrAlias 表名或别名
|
||||
* @param array $tableAliases 别名映射
|
||||
* @param string $mainAlias 主表别名
|
||||
* @return string
|
||||
*/
|
||||
protected function resolveTableAlias(string $tableOrAlias, array $tableAliases, string $mainAlias): string
|
||||
{
|
||||
$tableOrAlias = trim($tableOrAlias);
|
||||
|
||||
if ($tableOrAlias === '') {
|
||||
return $mainAlias;
|
||||
}
|
||||
|
||||
if (isset($tableAliases[$tableOrAlias])) {
|
||||
return $tableAliases[$tableOrAlias];
|
||||
}
|
||||
|
||||
return $tableOrAlias;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验字段路径
|
||||
*
|
||||
* 支持:
|
||||
* - field
|
||||
* - table.field
|
||||
*
|
||||
* @param string $field 字段路径
|
||||
* @param string $label 错误提示标签
|
||||
* @return void
|
||||
*/
|
||||
protected function validateFieldPath(string $field, string $label = '字段'): void
|
||||
{
|
||||
$field = trim($field);
|
||||
|
||||
if ($field === '') {
|
||||
throw new InvalidArgumentException("{$label}不能为空");
|
||||
}
|
||||
|
||||
$parts = explode('.', $field);
|
||||
foreach ($parts as $part) {
|
||||
$this->validateIdentifierPath($part, $label);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验单个标识符
|
||||
*
|
||||
* 仅允许:
|
||||
* - 字母
|
||||
* - 数字
|
||||
* - 下划线
|
||||
*
|
||||
* 且不能以数字开头。
|
||||
*
|
||||
* @param string $value 标识符
|
||||
* @param string $label 错误提示标签
|
||||
* @return void
|
||||
*/
|
||||
protected function validateIdentifierPath(string $value, string $label = '标识符'): void
|
||||
{
|
||||
$value = trim($value);
|
||||
|
||||
if ($value === '') {
|
||||
throw new InvalidArgumentException("{$label}不能为空");
|
||||
}
|
||||
|
||||
// 仅允许字母、数字、下划线,且首字符不能为数字
|
||||
if (!preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $value)) {
|
||||
throw new InvalidArgumentException("{$label}不合法:{$value}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,69 +4,136 @@ namespace plugin\admin\app\controller;
|
||||
|
||||
use plugin\admin\app\model\OpmMwDepartment;
|
||||
use plugin\admin\app\model\OpmMwHospital;
|
||||
use app\utils\Logger;
|
||||
use plugin\admin\app\model\OpmMwInfoDatum; // 引入新模型
|
||||
use support\Request;
|
||||
use support\Response;
|
||||
use Throwable;
|
||||
|
||||
class TestController extends Crud
|
||||
{
|
||||
|
||||
|
||||
/**
|
||||
* 浏览
|
||||
* @return Response
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function index(): Response
|
||||
{
|
||||
return raw_view('test/index');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 获取医院科室树形结构
|
||||
* @return Response
|
||||
* 树结构(已应用规则引擎)
|
||||
*/
|
||||
public function tree(): Response
|
||||
{
|
||||
// 1. 查询所有医院
|
||||
$hospitals = OpmMwHospital::where('is_true', 1) // 只查有效医院
|
||||
->withDataPermission()
|
||||
// 医院查询:自动应用规则引擎
|
||||
$hospitals = OpmMwHospital::where('is_true', 1)
|
||||
->withDataPermission() // 规则引擎
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
// 2. 组装树结构
|
||||
$treeData = [];
|
||||
$tree = [];
|
||||
|
||||
foreach ($hospitals as $hospital) {
|
||||
// 医院节点
|
||||
$hospitalNode = [
|
||||
$node = [
|
||||
'id' => $hospital->id,
|
||||
'title' => $hospital->organ_name, // 医院名称
|
||||
'title' => $hospital->organ_name,
|
||||
'type' => 'hospital',
|
||||
'hospital_id' => $hospital->id,
|
||||
'children' => []
|
||||
];
|
||||
|
||||
// 3. 查询该医院下的所有科室
|
||||
$departments = OpmMwDepartment::where('organ_id', $hospital->id)
|
||||
->withDataPermission()
|
||||
// 科室查询:自动应用规则引擎
|
||||
$depts = OpmMwDepartment::where('organ_id', $hospital->id)
|
||||
->withDataPermission() // 规则引擎
|
||||
->orderBy('sort_id')
|
||||
->get();
|
||||
|
||||
// 4. 组装科室节点
|
||||
foreach ($departments as $dept) {
|
||||
$hospitalNode['children'][] = [
|
||||
foreach ($depts as $dept) {
|
||||
$node['children'][] = [
|
||||
'id' => $dept->id,
|
||||
'title' => $dept->dept_name // 科室名称
|
||||
'title' => $dept->dept_name,
|
||||
'type' => 'dept',
|
||||
'hospital_id' => $hospital->id
|
||||
];
|
||||
}
|
||||
|
||||
$treeData[] = $hospitalNode;
|
||||
$tree[] = $node;
|
||||
}
|
||||
|
||||
// 5. 返回 Layui 树需要的格式
|
||||
return json([
|
||||
'code' => 0,
|
||||
'msg' => 'success',
|
||||
'data' => $treeData
|
||||
'data' => $tree
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 表格数据(已应用规则引擎)
|
||||
*/
|
||||
public function data(Request $request): Response
|
||||
{
|
||||
$type = $request->get('type');
|
||||
$id = $request->get('id');
|
||||
$hospitalId = $request->get('hospital_id');
|
||||
|
||||
// 改用模型查询 + 规则引擎
|
||||
$query = OpmMwInfoDatum::withDataPermission(); // 规则引擎
|
||||
|
||||
// 业务过滤:医院
|
||||
if ($hospitalId) {
|
||||
$hospital = OpmMwHospital::find($hospitalId);
|
||||
if ($hospital) {
|
||||
$query->where('organ_name', $hospital->organ_name);
|
||||
}
|
||||
}
|
||||
|
||||
// 业务过滤:科室
|
||||
if ($type === 'dept' && $id) {
|
||||
$dept = OpmMwDepartment::find($id);
|
||||
if ($dept) {
|
||||
$query->where('dept_name', $dept->dept_name);
|
||||
}
|
||||
}
|
||||
|
||||
$list = $query->orderBy('id', 'desc')->paginate(20);
|
||||
|
||||
return json([
|
||||
'code' => 0,
|
||||
'count' => $list->total(),
|
||||
'data' => $list->items()
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计卡片(已应用规则引擎)
|
||||
*/
|
||||
public function summary(Request $request): Response
|
||||
{
|
||||
$type = $request->get('type');
|
||||
$id = $request->get('id');
|
||||
$hospitalId = $request->get('hospital_id');
|
||||
|
||||
// 改用模型查询 + 规则引擎
|
||||
$query = OpmMwInfoDatum::withDataPermission(); // 规则引擎
|
||||
|
||||
// 业务过滤:医院
|
||||
if ($hospitalId) {
|
||||
$hospital = OpmMwHospital::find($hospitalId);
|
||||
if ($hospital) {
|
||||
$query->where('organ_name', $hospital->organ_name);
|
||||
}
|
||||
}
|
||||
|
||||
// 业务过滤:科室
|
||||
if ($type === 'dept' && $id) {
|
||||
$dept = OpmMwDepartment::find($id);
|
||||
if ($dept) {
|
||||
$query->where('dept_name', $dept->dept_name);
|
||||
}
|
||||
}
|
||||
|
||||
$data = $query
|
||||
->selectRaw("waste_type as type, SUM(CAST(weight AS DECIMAL(10,2))) as total")
|
||||
->groupBy('waste_type')
|
||||
->get();
|
||||
|
||||
return json([
|
||||
'code' => 0,
|
||||
'data' => $data
|
||||
]);
|
||||
}
|
||||
}
|
||||
+53
-177
@@ -5,10 +5,10 @@ namespace plugin\admin\app\model;
|
||||
use app\utils\Logger;
|
||||
use DateTimeInterface;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use plugin\admin\app\common\DataPermissionService;
|
||||
use support\Db;
|
||||
use support\Model;
|
||||
|
||||
|
||||
/**
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|static withDataPermission()
|
||||
*/
|
||||
@@ -20,194 +20,70 @@ class Base extends Model
|
||||
protected $connection = 'plugin.admin.mysql';
|
||||
|
||||
/**
|
||||
* 格式化日期
|
||||
*
|
||||
* @param DateTimeInterface $date
|
||||
* @return string
|
||||
* --------------------------
|
||||
* 【核心配置】权限规则配置
|
||||
* 新增规则只需在这里加一项,无需改下面的逻辑
|
||||
* --------------------------
|
||||
*/
|
||||
protected function serializeDate(DateTimeInterface $date)
|
||||
protected function getPermissionRules(): array
|
||||
{
|
||||
return [
|
||||
// 规则1:医院权限
|
||||
'hospital' => [
|
||||
'table' => 'opm_mw_hospital', // 表名
|
||||
'admin_attr' => 'hospitals', // 用户属性里的键($admin['hospitals'])
|
||||
'permission_field'=> 'id', // 表中用于权限过滤的字段
|
||||
'related_field' => null, // 关联上级权限的字段(如科室关联医院的organ_id)
|
||||
'related_rule' => null, // 关联的上级规则key(对应上面的'hospital')
|
||||
],
|
||||
// 规则2:科室权限
|
||||
'department' => [
|
||||
'table' => 'opm_mw_department',
|
||||
'admin_attr' => 'departments',
|
||||
'permission_field'=> 'id',
|
||||
'related_field' => 'organ_id', // 科室通过organ_id关联医院
|
||||
'related_rule' => 'hospital', // 关联上级规则:医院
|
||||
],
|
||||
// 规则3:数据权限
|
||||
// 这个需要绑定 医院的.id
|
||||
// 这个需要绑定 科室的.id
|
||||
'data' => [
|
||||
'table' => 'opm_mw_info_data',
|
||||
'admin_attr' => 'data',
|
||||
'permission_field'=> 'id',
|
||||
'related_field' => null,
|
||||
'related_rule' => null,
|
||||
],
|
||||
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期
|
||||
*/
|
||||
protected function serializeDate(DateTimeInterface $date): string
|
||||
{
|
||||
return $date->format('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
|
||||
public function scopeWithDataPermission(Builder $query): Builder
|
||||
{
|
||||
$admin = runCatching(fn() => admin(), "无法获取登录状态")->getOrDefault([]);
|
||||
|
||||
$hospitalRaw = trim((string)($admin['hospitals'] ?? ''));
|
||||
$departmentRaw = trim((string)($admin['departments'] ?? ''));
|
||||
|
||||
if ($hospitalRaw === '*' && $departmentRaw === '*') {
|
||||
return $query;
|
||||
}
|
||||
|
||||
$hospitalIds = $this->parseVisibleIds($hospitalRaw);
|
||||
$departmentIds = $this->parseVisibleIds($departmentRaw);
|
||||
|
||||
if (empty($hospitalIds) && empty($departmentIds)) {
|
||||
return $query->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
$baseQuery = $query->getQuery();
|
||||
|
||||
// 主表
|
||||
[$fromTable, $fromAlias] = $this->parseTableAlias((string)($baseQuery->from ?? ''));
|
||||
|
||||
// 识别 join 里的医院/科室表别名
|
||||
$hospitalAlias = $this->findTableAlias($baseQuery, 'opm_mw_hospital');
|
||||
$departmentAlias = $this->findTableAlias($baseQuery, 'opm_mw_department');
|
||||
|
||||
Logger::debug('scopeWithDataPermission fromTable:{} fromAlias:{} hospitalAlias:{} departmentAlias:{} hospitalIds:{} departmentIds:{}', [
|
||||
$fromTable,
|
||||
$fromAlias,
|
||||
$hospitalAlias,
|
||||
$departmentAlias,
|
||||
$hospitalIds,
|
||||
$departmentIds,
|
||||
]);
|
||||
|
||||
// 1) 主表就是医院表:只加医院权限
|
||||
if ($fromTable === 'opm_mw_hospital' && $hospitalAlias) {
|
||||
if (empty($hospitalIds)) {
|
||||
return $query->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
return $query->whereIn("{$hospitalAlias}.id", $hospitalIds);
|
||||
}
|
||||
|
||||
// 2) 主表就是科室表:只加科室权限
|
||||
if ($fromTable === 'opm_mw_department' && $departmentAlias) {
|
||||
return $query->where(function (Builder $q) use ($departmentAlias, $hospitalIds, $departmentIds) {
|
||||
$has = false;
|
||||
|
||||
// 科室权限:department.id
|
||||
if (!empty($departmentIds)) {
|
||||
$q->whereIn("{$departmentAlias}.id", $departmentIds);
|
||||
$has = true;
|
||||
}
|
||||
|
||||
// 医院权限:department.organ_id
|
||||
if (!empty($hospitalIds)) {
|
||||
if ($has) {
|
||||
$q->whereIn("{$departmentAlias}.organ_id", $hospitalIds);
|
||||
} else {
|
||||
$q->orWhereIn("{$departmentAlias}.organ_id", $hospitalIds);
|
||||
}
|
||||
$has = true;
|
||||
}
|
||||
|
||||
if (!$has) {
|
||||
$q->whereRaw('1 = 0');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 3) 主表不是这两个,但 join 里有它们:按别名补条件
|
||||
return $query->where(function (Builder $q) use (
|
||||
$hospitalAlias,
|
||||
$departmentAlias,
|
||||
$hospitalIds,
|
||||
$departmentIds
|
||||
) {
|
||||
$hasAny = false;
|
||||
|
||||
if ($hospitalAlias && !empty($hospitalIds)) {
|
||||
$q->whereIn("{$hospitalAlias}.id", $hospitalIds);
|
||||
$hasAny = true;
|
||||
}
|
||||
|
||||
if ($departmentAlias) {
|
||||
$q->where(function (Builder $dq) use ($departmentAlias, $hospitalIds, $departmentIds) {
|
||||
$has = false;
|
||||
|
||||
if (!empty($departmentIds)) {
|
||||
$dq->whereIn("{$departmentAlias}.id", $departmentIds);
|
||||
$has = true;
|
||||
}
|
||||
|
||||
if (!empty($hospitalIds)) {
|
||||
if ($has) {
|
||||
$dq->orWhereIn("{$departmentAlias}.organ_id", $hospitalIds);
|
||||
} else {
|
||||
$dq->whereIn("{$departmentAlias}.organ_id", $hospitalIds);
|
||||
}
|
||||
$has = true;
|
||||
}
|
||||
|
||||
if (!$has) {
|
||||
$dq->whereRaw('1 = 0');
|
||||
}
|
||||
});
|
||||
|
||||
$hasAny = true;
|
||||
}
|
||||
|
||||
if (!$hasAny) {
|
||||
$q->whereRaw('1 = 0');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 "table as t" / "table t"
|
||||
*/
|
||||
protected function parseTableAlias(string $table): array
|
||||
{
|
||||
$table = trim(str_replace('`', '', $table));
|
||||
|
||||
if ($table === '') {
|
||||
return ['', ''];
|
||||
}
|
||||
|
||||
if (stripos($table, ' as ') !== false) {
|
||||
[$name, $alias] = preg_split('/\s+as\s+/i', $table, 2);
|
||||
return [trim($name), trim($alias)];
|
||||
}
|
||||
|
||||
$parts = preg_split('/\s+/', $table);
|
||||
if (count($parts) >= 2) {
|
||||
return [trim($parts[0]), trim($parts[1])];
|
||||
}
|
||||
|
||||
return [$table, $table];
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 from / joins 中找指定表的别名
|
||||
*/
|
||||
protected function findTableAlias($baseQuery, string $tableName): ?string
|
||||
{
|
||||
[$fromTable, $fromAlias] = $this->parseTableAlias((string)($baseQuery->from ?? ''));
|
||||
if ($fromTable === $tableName) {
|
||||
return $fromAlias;
|
||||
}
|
||||
|
||||
foreach (($baseQuery->joins ?? []) as $join) {
|
||||
[$joinTable, $joinAlias] = $this->parseTableAlias((string)($join->table ?? ''));
|
||||
if ($joinTable === $tableName) {
|
||||
return $joinAlias;
|
||||
// 超管判断(可选,也可以在规则里配置*)
|
||||
$isSuper = true;
|
||||
foreach (['hospitals', 'departments'] as $attr) {
|
||||
if (($admin[$attr] ?? '') !== '*') {
|
||||
$isSuper = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($isSuper) return $query;
|
||||
|
||||
return null;
|
||||
// 使用服务类应用权限
|
||||
$service = new DataPermissionService($admin);
|
||||
return $service->apply($query);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 "1,2,3" / "1,2,3" / " 1 , 2 " 转成数组
|
||||
*/
|
||||
protected function parseVisibleIds(string $raw): array
|
||||
{
|
||||
$raw = trim($raw);
|
||||
|
||||
if ($raw === '' || $raw === '*') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$parts = preg_split('/[,\s,]+/u', $raw) ?: [];
|
||||
$parts = array_filter($parts, static fn($v) => $v !== '');
|
||||
|
||||
return array_values(array_unique(array_map('intval', $parts)));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -1,60 +1,165 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="zh-cn">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>医院科室树形结构</title>
|
||||
<!-- 引入你提供的资源 -->
|
||||
<title>医院科室统计</title>
|
||||
|
||||
<link rel="stylesheet" href="/app/admin/component/pear/css/pear.css" />
|
||||
<link rel="stylesheet" href="/app/admin/admin/css/reset.css" />
|
||||
|
||||
<style>
|
||||
html, body {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.container {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.left-tree {
|
||||
height: calc(100vh - 20px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.right-content {
|
||||
height: calc(100vh - 20px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.table-box {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- 树形结构容器 -->
|
||||
|
||||
<div class="layui-row container" style="padding:10px;">
|
||||
|
||||
<!-- 左侧树 -->
|
||||
<div class="layui-col-md3 left-tree">
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header">医院科室</div>
|
||||
<div class="layui-card-body">
|
||||
<div id="hospitalDeptTree" style="padding: 10px;"></div>
|
||||
<div id="hospitalDeptTree"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/app/admin/component/layui/layui.js?v=2.8.12"></script>
|
||||
<!-- 右侧 -->
|
||||
<div class="layui-col-md9 right-content">
|
||||
|
||||
<!-- 卡片 -->
|
||||
<div class="layui-row" id="summaryCards"></div>
|
||||
|
||||
<!-- 表格 -->
|
||||
<div class="table-box">
|
||||
<table id="dataTable"></table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script src="/app/admin/component/layui/layui.js"></script>
|
||||
<script src="/app/admin/component/pear/pear.js"></script>
|
||||
<script src="/app/admin/admin/js/permission.js"></script>
|
||||
<script src="/app/admin/admin/js/common.js"></script>
|
||||
|
||||
<script>
|
||||
layui.use(['tree', 'layer', 'jquery'], function(){
|
||||
layui.use(['tree', 'table', 'jquery'], function(){
|
||||
var tree = layui.tree;
|
||||
var layer = layui.layer;
|
||||
var table = layui.table;
|
||||
var $ = layui.jquery;
|
||||
|
||||
// 渲染医院科室树形结构
|
||||
function renderHospitalTree() {
|
||||
// 请求后端接口获取树数据
|
||||
let currentType = 'hospital';
|
||||
let currentId = null;
|
||||
let currentHospitalId = null;
|
||||
|
||||
function initTree(){
|
||||
$.get("/app/admin/test/tree", function(res){
|
||||
if(res.code === 0){
|
||||
|
||||
tree.render({
|
||||
elem: '#hospitalDeptTree', // 容器ID
|
||||
data: res.data, // 树数据
|
||||
showCheckbox: false, // 不显示复选框
|
||||
onlyIconControl: true, // 仅允许图标展开/折叠
|
||||
elem: '#hospitalDeptTree',
|
||||
data: res.data,
|
||||
onlyIconControl: true,
|
||||
|
||||
click: function(obj){
|
||||
// 点击节点事件
|
||||
layer.msg("你选择了:" + obj.data.title);
|
||||
console.log("节点数据:", obj.data);
|
||||
currentType = obj.data.type;
|
||||
currentId = obj.data.id;
|
||||
currentHospitalId = obj.data.hospital_id;
|
||||
|
||||
reloadAll();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
layer.msg(res.msg || "加载树形结构失败");
|
||||
|
||||
if(res.data.length){
|
||||
currentType = 'hospital';
|
||||
currentId = res.data[0].id;
|
||||
currentHospitalId = res.data[0].id;
|
||||
reloadAll();
|
||||
}
|
||||
}).fail(function(){
|
||||
layer.msg("接口请求失败,请检查后端服务");
|
||||
});
|
||||
}
|
||||
|
||||
// 页面加载完成渲染树
|
||||
$(function(){
|
||||
renderHospitalTree();
|
||||
function initTable(){
|
||||
table.render({
|
||||
elem: '#dataTable',
|
||||
id: 'dataTable',
|
||||
url: '/app/admin/test/data',
|
||||
page: true,
|
||||
height: 'full-150',
|
||||
cols: [[
|
||||
{field:'organ_name', title:'医院'},
|
||||
{field:'dept_name', title:'科室'},
|
||||
{field:'waste_type', title:'垃圾类型'},
|
||||
{field:'weight', title:'重量'},
|
||||
{field:'recl_time', title:'时间'}
|
||||
]]
|
||||
});
|
||||
}
|
||||
|
||||
function getParams(){
|
||||
return {
|
||||
type: currentType,
|
||||
id: currentId,
|
||||
hospital_id: currentHospitalId
|
||||
};
|
||||
}
|
||||
|
||||
function reloadAll(){
|
||||
table.reload('dataTable', {
|
||||
where: getParams(),
|
||||
page: {curr:1}
|
||||
});
|
||||
|
||||
loadSummary();
|
||||
}
|
||||
|
||||
function loadSummary(){
|
||||
$.get('/app/admin/test/summary', getParams(), function(res){
|
||||
|
||||
let html = '';
|
||||
|
||||
res.data.forEach(item=>{
|
||||
html += `
|
||||
<div class="layui-col-md3">
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header">${item.type || '未知'}</div>
|
||||
<div class="layui-card-body">${item.total || 0} kg</div>
|
||||
</div>
|
||||
</div>`;
|
||||
});
|
||||
|
||||
$('#summaryCards').html(html);
|
||||
});
|
||||
}
|
||||
|
||||
initTree();
|
||||
initTable();
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user