权限管理与测试

This commit is contained in:
zimoyin
2026-04-03 16:21:28 +08:00
parent 673c83109f
commit 76e9f24aa7
11 changed files with 2855 additions and 20 deletions
@@ -0,0 +1,910 @@
<!DOCTYPE html>
<html lang="zh-cn">
<head>
<meta charset="utf-8">
<title>数据权限规则管理</title>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<link rel="stylesheet" href="/app/admin/component/pear/css/pear.css" />
<link rel="stylesheet" href="/app/admin/admin/css/reset.css" />
<style>
:root {
--bg: #f5f7fb;
--card: rgba(255, 255, 255, .92);
--line: #e5e7eb;
--text: #111827;
--muted: #6b7280;
--primary: #3b82f6;
--primary-weak: #eff6ff;
--success: #16a34a;
--danger: #dc2626;
--warning: #d97706;
--radius: 18px;
}
body.pear-container {
background: radial-gradient(circle at top left, #eef4ff 0%, #f7f9fc 42%, #f5f7fb 100%);
color: var(--text);
}
.page-wrap {
padding: 16px;
}
.hero {
background: linear-gradient(135deg, rgba(59,130,246,.12), rgba(99,102,241,.08));
border: 1px solid rgba(59,130,246,.15);
border-radius: 20px;
padding: 18px 20px;
margin-bottom: 16px;
box-shadow: 0 8px 30px rgba(15, 23, 42, .05);
}
.hero-title {
font-size: 20px;
font-weight: 700;
margin: 0 0 6px;
}
.hero-sub {
color: var(--muted);
line-height: 1.7;
margin: 0;
}
.hero-meta {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-top: 14px;
}
.meta-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
border-radius: 999px;
background: rgba(255,255,255,.8);
border: 1px solid rgba(148,163,184,.25);
font-size: 13px;
color: #334155;
}
.layui-card {
border-radius: var(--radius);
overflow: hidden;
margin-bottom: 16px;
border: 1px solid rgba(229,231,235,.9);
box-shadow: 0 10px 30px rgba(15, 23, 42, .04);
}
.layui-card-header {
font-weight: 700;
color: #0f172a;
border-bottom: 1px solid rgba(229,231,235,.85);
background: rgba(255,255,255,.8);
}
.section-title {
font-size: 15px;
font-weight: 700;
margin: 0 0 10px;
color: #0f172a;
}
.field-explain {
background: linear-gradient(180deg, rgba(239,246,255,.9), rgba(255,255,255,.95));
border: 1px solid rgba(191,219,254,.8);
border-radius: 16px;
padding: 16px;
}
.field-explain-item {
display: flex;
gap: 10px;
align-items: flex-start;
margin-bottom: 12px;
line-height: 1.7;
}
.field-explain-item:last-child {
margin-bottom: 0;
}
.field-key {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 999px;
background: var(--primary-weak);
color: #1d4ed8;
font-weight: 700;
font-size: 12px;
min-width: 76px;
justify-content: center;
}
.field-text {
color: #334155;
font-size: 14px;
}
.hint-box {
margin-top: 12px;
padding: 14px 16px;
border-radius: 14px;
background: linear-gradient(180deg, #f8fbff, #ffffff);
border: 1px dashed #c7d2fe;
color: #334155;
}
.hint-code {
display: inline-block;
padding: 2px 8px;
border-radius: 8px;
background: #eef2ff;
color: #4338ca;
font-size: 12px;
font-weight: 700;
}
.map-flow {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
margin-top: 12px;
padding: 14px;
border-radius: 14px;
background: #f0fdf4;
border: 1px solid #bbf7d0;
}
.flow-step {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: #ffffff;
border: 1px solid #dcfce7;
border-radius: 12px;
font-size: 13px;
color: #14532d;
}
.flow-arrow {
color: #16a34a;
font-weight: 700;
}
.toolbar-wrap {
padding: 14px 14px 0;
}
.search-row {
display: grid;
grid-template-columns: 220px 220px 160px auto auto auto;
gap: 10px;
align-items: center;
}
@media (max-width: 1200px) {
.search-row {
grid-template-columns: 1fr 1fr;
}
}
.search-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.btn-soft {
border-radius: 12px !important;
}
.layui-table-view {
border: none !important;
margin: 0;
}
.layui-table-tool {
border-top: 1px solid rgba(229,231,235,.8);
border-bottom: 1px solid rgba(229,231,235,.8);
background: linear-gradient(180deg, #ffffff, #fafbff);
}
.layui-table-header {
background: #f8fafc;
}
.layui-table thead tr th {
color: #334155;
font-weight: 700;
background: #f8fafc;
}
.layui-table tbody tr:hover {
background: #f8fbff;
}
.table-tag {
display: inline-flex;
align-items: center;
padding: 3px 9px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
line-height: 1;
white-space: nowrap;
}
.tag-blue { background: #eff6ff; color: #1d4ed8; }
.tag-green { background: #ecfdf5; color: #047857; }
.tag-orange { background: #fff7ed; color: #c2410c; }
.tag-gray { background: #f3f4f6; color: #4b5563; }
.tag-red { background: #fef2f2; color: #b91c1c; }
.ellipsis {
display: inline-block;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: middle;
}
.sql-preview-wrap {
height: 100%;
display: flex;
flex-direction: column;
gap: 12px;
}
.sql-preview-head {
display: grid;
grid-template-columns: 1.3fr auto auto;
gap: 10px;
align-items: center;
}
.sql-box-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
flex: 1;
min-height: 0;
}
@media (max-width: 1100px) {
.sql-box-grid {
grid-template-columns: 1fr;
}
}
.sql-box {
display: flex;
flex-direction: column;
min-height: 0;
border-radius: 16px;
overflow: hidden;
border: 1px solid #e5e7eb;
background: #fff;
}
.sql-box-hd {
padding: 10px 12px;
background: #f8fafc;
border-bottom: 1px solid #e5e7eb;
font-weight: 700;
color: #0f172a;
}
.sql-box-bd {
flex: 1;
min-height: 0;
padding: 12px;
overflow: auto;
background: #0f172a;
color: #d1d5db;
}
.sql-pre {
margin: 0;
font-family: Consolas, Menlo, Monaco, monospace;
font-size: 13px;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-all;
}
.preview-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.preview-meta .meta-pill {
background: #fff;
}
.small-help {
color: var(--muted);
font-size: 12px;
line-height: 1.7;
}
.rule-note {
margin-top: 10px;
padding: 12px 14px;
border-radius: 14px;
background: #fffbeb;
border: 1px solid #fde68a;
color: #92400e;
line-height: 1.7;
}
</style>
</head>
<body class="pear-container">
<div class="page-wrap">
<div class="hero">
<h1 class="hero-title">数据权限规则管理</h1>
<p class="hero-sub">
这里负责定义“谁能看哪张表、哪些数据”。<br>
一条规则可以理解成:当前登录人的某个属性,去匹配目标表的某个字段,从而自动限制可见范围。
</p>
<div class="hero-meta">
<span class="meta-pill">规则作用:自动加 WHERE 条件</span>
<span class="meta-pill">支持:in / not in / = / like / null 判断</span>
<span class="meta-pill">支持:跨表映射 / join 预览</span>
<span class="meta-pill">支持:SQL 预览</span>
</div>
</div>
<div class="layui-card">
<div class="layui-card-header">规则列表</div>
<div class="toolbar-wrap">
<form class="layui-form" lay-filter="search-form">
<div class="search-row">
<div class="layui-input-wrap">
<input type="text" name="table" placeholder="按表名搜索,如 opm_mw_info_data" class="layui-input">
</div>
<div class="layui-input-wrap">
<input type="text" name="admin_attr" placeholder="按Admin属性搜索,如 hospitals" class="layui-input">
</div>
<div class="layui-input-wrap">
<select name="status">
<option value="">全部状态</option>
<option value="1">启用</option>
<option value="0">禁用</option>
</select>
</div>
<div class="search-actions">
<button type="button" class="pear-btn pear-btn-primary btn-soft" lay-submit lay-filter="search-btn">
<i class="layui-icon layui-icon-search"></i> 搜索
</button>
<button type="reset" class="pear-btn btn-soft">
<i class="layui-icon layui-icon-refresh-1"></i> 重置
</button>
</div>
<button type="button" class="pear-btn pear-btn-warning btn-soft" id="btn-preview-global">
<i class="layui-icon layui-icon-code-circle"></i> SQL预览
</button>
<button type="button" class="pear-btn pear-btn-primary btn-soft" id="btn-add">
<i class="layui-icon layui-icon-add-1"></i> 新增规则
</button>
</div>
</form>
</div>
<div class="layui-card-body" style="padding-top: 0;">
<table id="data-table" lay-filter="data-table"></table>
</div>
</div>
<div class="layui-card">
<div class="layui-card-header">通俗解释</div>
<div class="layui-card-body">
<div class="field-explain">
<div class="field-explain-item">
<span class="field-key">table</span>
<span class="field-text">目标表。你要给哪张表加限制,比如 <strong>opm_mw_info_data</strong></span>
</div>
<div class="field-explain-item">
<span class="field-key">field</span>
<span class="field-text">目标字段。就是拿哪一列来做过滤,比如 <strong>organ_id</strong></span>
</div>
<div class="field-explain-item">
<span class="field-key">admin_attr</span>
<span class="field-text">Admin属性。当前登录人身上保存权限值的字段,比如 <strong>hospitals</strong><strong>departments</strong></span>
</div>
<div class="field-explain-item">
<span class="field-key">admin_attr_map</span>
<span class="field-text">映射规则。适合“用户拿到的是 ID,但最终要拿另一个字段去匹配”的场景。</span>
</div>
<div class="field-explain-item">
<span class="field-key">action</span>
<span class="field-text">比较方式。比如 <strong>in</strong><strong>=</strong><strong>like</strong><strong>is null</strong></span>
</div>
</div>
<div class="hint-box">
<div class="section-title">一句话理解 admin_attr_map</div>
<div>
它就是一条“翻译路线”:
<span class="hint-code">先从Admin属性拿值</span>
<span class="hint-code">去某张表找对应记录</span>
<span class="hint-code">取出你真正想匹配的字段</span>
<span class="hint-code">再去限制目标表</span>
</div>
<div class="map-flow">
<span class="flow-step">1. 取 admin_attr</span>
<span class="flow-arrow"></span>
<span class="flow-step">2. 查映射表 / join 关联</span>
<span class="flow-arrow"></span>
<span class="flow-step">3. 取目标字段</span>
<span class="flow-arrow"></span>
<span class="flow-step">4. 自动拼 WHERE 条件</span>
</div>
<div class="rule-note">
格式:<strong>源表.源字段...目标表.目标字段</strong><br>
</div>
</div>
</div>
</div>
<div class="layui-card">
<div class="layui-card-header">配置示例</div>
<div class="layui-card-body">
<table class="layui-table" lay-skin="line">
<thead>
<tr>
<th style="width: 20%">场景</th>
<th>table</th>
<th>field</th>
<th>admin_attr</th>
<th>admin_attr_map</th>
<th>action</th>
</tr>
</thead>
<tbody>
<tr>
<td>只看自己医院的数据</td>
<td><span class="table-tag tag-blue">opm_mw_info_data</span></td>
<td><span class="table-tag tag-green">organ_id</span></td>
<td><span class="table-tag tag-orange">hospitals</span></td>
<td><span class="table-tag tag-gray">-</span></td>
<td><span class="table-tag tag-blue">in</span></td>
</tr>
<tr>
<td>科室 ID 转科室名称过滤</td>
<td><span class="table-tag tag-blue">opm_mw_info_data</span></td>
<td><span class="table-tag tag-green">dept_name</span></td>
<td><span class="table-tag tag-orange">departments</span></td>
<td><span class="table-tag tag-gray">opm_mw_department.id...opm_mw_department.name</span></td>
<td><span class="table-tag tag-blue">in</span></td>
</tr>
<tr>
<td>跨表 join 取医院名称</td>
<td><span class="table-tag tag-blue">opm_mw_info_data</span></td>
<td><span class="table-tag tag-green">hospital_name</span></td>
<td><span class="table-tag tag-orange">hospitals</span></td>
<td><span class="table-tag tag-gray">opm_mw_department.id:organ_id...opm_mw_hospital.name:id</span></td>
<td><span class="table-tag tag-blue">in</span></td>
</tr>
<tr>
<td>医院表自身权限</td>
<td><span class="table-tag tag-blue">opm_mw_hospital</span></td>
<td><span class="table-tag tag-green">id</span></td>
<td><span class="table-tag tag-orange">hospitals</span></td>
<td><span class="table-tag tag-gray">-</span></td>
<td><span class="table-tag tag-blue">in</span></td>
</tr>
</tbody>
</table>
</div>
</div>
<script type="text/html" id="table-toolbar">
<button class="pear-btn pear-btn-warning pear-btn-md" lay-event="sqlPreview">
<i class="layui-icon layui-icon-code-circle"></i> SQL
</button>
<button class="pear-btn pear-btn-danger pear-btn-md" lay-event="batchRemove">
<i class="layui-icon layui-icon-delete"></i>
</button>
</script>
<script type="text/html" id="table-bar">
<button class="pear-btn pear-btn-xs pear-btn-warning" lay-event="preview">预览</button>
<button class="pear-btn pear-btn-xs" lay-event="edit">编辑</button>
<button class="pear-btn pear-btn-xs pear-btn-danger" lay-event="remove">删除</button>
</script>
<script type="text/html" id="sql-preview-tpl">
<div class="sql-preview-wrap">
<div class="sql-preview-head">
<div class="layui-input-wrap">
<select name="preview_table" lay-search>
<option value="">请选择要预览的表</option>
</select>
</div>
<button type="button" class="pear-btn pear-btn-primary" id="btn-run-preview">
<i class="layui-icon layui-icon-play"></i>
</button>
<button type="button" class="pear-btn" id="btn-copy-preview">
<i class="layui-icon layui-icon-template-1"></i> SQL
</button>
</div>
<div class="preview-meta" id="preview-meta"></div>
<div class="sql-box-grid">
<div class="sql-box">
<div class="sql-box-hd">原始 SQL</div>
<div class="sql-box-bd"><pre class="sql-pre" id="preview-original">请选择表后点击运行预览</pre></div>
</div>
<div class="sql-box">
<div class="sql-box-hd">应用权限后的 SQL</div>
<div class="sql-box-bd"><pre class="sql-pre" id="preview-permission">请选择表后点击运行预览</pre></div>
</div>
</div>
<div class="small-help">
说明这里会用当前登录人的权限信息模拟一次真实查询方便你检查规则是否写对
</div>
</div>
</script>
</div>
<script src="/app/admin/component/layui/layui.js?v=2.8.12"></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(["table", "form", "common", "popup", "util", "jquery", "layer"], function() {
let table = layui.table;
let form = layui.form;
let $ = layui.$;
let common = layui.common;
let layer = layui.layer;
const PRIMARY_KEY = "id";
const SELECT_API = "/app/admin/opm-mw-permission-rule/select";
const UPDATE_API = "/app/admin/opm-mw-permission-rule/update";
const DELETE_API = "/app/admin/opm-mw-permission-rule/delete";
const INSERT_URL = "/app/admin/opm-mw-permission-rule/insert";
const UPDATE_URL = "/app/admin/opm-mw-permission-rule/update";
const PREVIEW_API = "/app/admin/opm-mw-permission-rule/preview-sql";
const TABLES_API = "/app/admin/opm-mw-permission-rule/get-tables";
let tableIns = null;
let previewSqlCache = "";
function escapeHtml(str) {
return String(str ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function tag(text, cls) {
return '<span class="table-tag ' + cls + '">' + escapeHtml(text) + '</span>';
}
function renderStatus(status) {
return status == 1 ? tag("启用", "tag-green") : tag("禁用", "tag-red");
}
function renderAction(action) {
const map = {
"in": "tag-blue",
"not in": "tag-orange",
"=": "tag-green",
"like": "tag-orange",
"is null": "tag-gray",
"is not null": "tag-gray"
};
return tag(action || "-", map[String(action || "").toLowerCase()] || "tag-gray");
}
function loadPreviewTables(selectedTable) {
$.ajax({
url: TABLES_API,
dataType: "json",
type: "get",
success: function(res) {
if (res.code !== 0) return;
const tables = res.data || [];
const select = $('select[name="preview_table"]');
select.empty();
select.append('<option value="">请选择要预览的表</option>');
tables.forEach(function(t) {
let selected = (t === selectedTable) ? 'selected' : '';
select.append('<option value="' + escapeHtml(t) + '" ' + selected + '>' + escapeHtml(t) + '</option>');
});
form.render("select");
}
});
}
function openSqlPreview(defaultTable) {
const content = $("#sql-preview-tpl").html();
previewSqlCache = "";
layer.open({
type: 1,
title: "SQL 预览",
shade: 0.18,
maxmin: true,
area: [common.isModile() ? "100%" : "1100px", common.isModile() ? "100%" : "82%"],
content: content,
success: function(layero) {
layero.addClass("card-soft");
loadPreviewTables(defaultTable || "");
const runPreview = function() {
const tableName = $(layero).find('select[name="preview_table"]').val();
if (!tableName) {
layui.popup.warning("请选择要预览的表");
return;
}
const loading = layer.load(1);
$.ajax({
url: PREVIEW_API,
dataType: "json",
type: "get",
data: { table: tableName },
success: function(res) {
layer.close(loading);
if (res.code !== 0) {
layui.popup.failure(res.msg || "预览失败");
return;
}
const data = res.data || {};
const original = data.original || "";
const permission = data.permission || "";
previewSqlCache = original + "\n\n/* ===== 权限 SQL ===== */\n\n" + permission;
$(layero).find("#preview-original").text(original || "未返回原始 SQL");
$(layero).find("#preview-permission").text(permission || "未返回权限 SQL");
const metaHtml = [
tag("表:" + (data.table || tableName), "tag-blue"),
tag("原始 SQL:已生成", "tag-gray"),
tag("权限 SQL:已生成", "tag-green")
].join(" ");
$(layero).find("#preview-meta").html(metaHtml);
const adminAttr = data.admin_attr || {};
if (adminAttr && Object.keys(adminAttr).length) {
let attrText = Object.keys(adminAttr).map(function(k) {
return '<span class="meta-pill"><strong>' + escapeHtml(k) + '</strong>' + escapeHtml(adminAttr[k]) + '</span>';
}).join("");
$(layero).find("#preview-meta").append(attrText);
}
},
error: function() {
layer.close(loading);
layui.popup.failure("预览请求失败");
}
});
};
$(layero).on("click", "#btn-run-preview", runPreview);
$(layero).on("click", "#btn-copy-preview", function() {
if (!previewSqlCache) {
layui.popup.warning("请先运行预览");
return;
}
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(previewSqlCache).then(function() {
layui.popup.success("已复制 SQL");
}).catch(function() {
layui.popup.warning("复制失败,请手动复制");
});
} else {
layui.popup.warning("当前浏览器不支持自动复制");
}
});
}
});
}
let cols = [
{ type: "checkbox", align: "center", fixed: "left" },
{ title: "ID", field: "id", width: 76, align: "center", fixed: "left" },
{
title: "表名",
field: "table",
minWidth: 180,
templet: d => tag(d.table || "-", "tag-blue")
},
{
title: "字段名",
field: "field",
minWidth: 140,
templet: d => tag(d.field || "-", "tag-green")
},
{
title: "Admin属性",
field: "admin_attr",
minWidth: 140,
templet: d => tag(d.admin_attr || "-", "tag-orange")
},
{
title: "属性映射",
field: "admin_attr_map",
minWidth: 260,
templet: function(d) {
if (!d.admin_attr_map) return '<span style="color:#9ca3af">-</span>';
return '<span class="ellipsis" title="' + escapeHtml(d.admin_attr_map) + '">' + escapeHtml(d.admin_attr_map) + '</span>';
}
},
{
title: "运算符",
field: "action",
width: 110,
align: "center",
templet: d => renderAction(d.action)
},
{ title: "排序", field: "sort", width: 80, align: "center" },
{
title: "状态",
field: "status",
width: 90,
align: "center",
templet: d => renderStatus(d.status)
},
{
title: "备注",
field: "remark",
minWidth: 180,
templet: function(d) {
return d.remark ? '<span class="ellipsis" title="' + escapeHtml(d.remark) + '">' + escapeHtml(d.remark) + '</span>' : '<span style="color:#9ca3af">-</span>';
}
},
{
title: "操作",
toolbar: "#table-bar",
align: "center",
fixed: "right",
width: 240
}
];
function reloadTable() {
const formData = form.val("search-form") || {};
tableIns.reload({
where: {
table: formData.table || "",
admin_attr: formData.admin_attr || "",
status: formData.status || ""
},
page: { curr: 1 },
scrollPos: "fixed"
});
}
form.render();
tableIns = table.render({
elem: "#data-table",
url: SELECT_API,
page: true,
cols: [cols],
skin: "line",
size: "lg",
toolbar: "#table-toolbar",
defaultToolbar: ["refresh", "filter", "exports"],
height: "full",
text: {
none: "暂无规则,点击右上角“新增规则”开始配置"
}
});
table.on("tool(data-table)", function(obj) {
if (obj.event === "remove") remove(obj);
else if (obj.event === "edit") edit(obj);
else if (obj.event === "preview") openSqlPreview(obj.data.table);
});
table.on("toolbar(data-table)", function(obj) {
if (obj.event === "batchRemove") batchRemove(obj);
else if (obj.event === "sqlPreview") openSqlPreview("");
});
form.on("submit(search-btn)", function() {
reloadTable();
return false;
});
$("#btn-add").on("click", function() {
add();
});
$("#btn-preview-global").on("click", function() {
openSqlPreview("");
});
let add = function() {
layer.open({
type: 2,
title: "新增规则",
shade: 0.12,
maxmin: true,
area: [common.isModile() ? "100%" : "820px", common.isModile() ? "100%" : "640px"],
content: INSERT_URL
});
};
let edit = function(obj) {
layer.open({
type: 2,
title: "修改规则",
shade: 0.12,
maxmin: true,
area: [common.isModile() ? "100%" : "820px", common.isModile() ? "100%" : "640px"],
content: UPDATE_URL + "?" + PRIMARY_KEY + "=" + obj.data[PRIMARY_KEY]
});
};
let remove = function(obj) {
doRemove(obj.data[PRIMARY_KEY]);
};
let batchRemove = function(obj) {
let checkIds = common.checkField(obj, PRIMARY_KEY);
if (!checkIds) {
layui.popup.warning("未选中数据");
return;
}
doRemove(checkIds.split(","));
};
let doRemove = function(ids) {
let data = {};
data[PRIMARY_KEY] = ids;
layer.confirm("确定删除选中的规则吗?", { icon: 3, title: "提示" }, function(index) {
layer.close(index);
let loading = layer.load(1);
$.ajax({
url: DELETE_API,
data: data,
dataType: "json",
type: "post",
success: function(res) {
layer.close(loading);
if (res.code) return layui.popup.failure(res.msg);
layui.popup.success("操作成功", reloadTable);
},
error: function() {
layer.close(loading);
layui.popup.failure("删除失败");
}
});
});
};
window.refreshTable = function() {
reloadTable();
};
});
</script>
</body>
</html>