Files
workspace/code/fms/demo2.html
T
2026-09-09 22:40:13 +08:00

832 lines
37 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FMS 权限分配 · 交互原型</title>
<style>
:root{
--bg:#f1f5f9; --card:#fff; --bd:#e2e8f0; --bd2:#cbd5e1;
--tx:#0f172a; --tx2:#64748b; --tx3:#94a3b8;
--pri:#2563eb; --pri-bg:#eff6ff; --pri-bd:#bfdbfe;
--danger:#dc2626; --danger-bg:#fef2f2; --danger-bd:#fecaca;
--warn:#d97706; --warn-bg:#fffbeb; --warn-bd:#fde68a;
--ok:#16a34a; --ok-bg:#f0fdf4;
}
*{box-sizing:border-box;margin:0;padding:0}
body{
font-family:system-ui,-apple-system,"Segoe UI","Microsoft YaHei",sans-serif;
background:var(--bg); color:var(--tx); font-size:13px; line-height:1.5;
height:100vh; display:flex; flex-direction:column; overflow:hidden;
}
button{font-family:inherit;font-size:inherit;cursor:pointer;border:none;background:none;color:inherit}
select,input{font-family:inherit;font-size:inherit;color:inherit}
/* ---------- header ---------- */
header{
background:var(--card); border-bottom:1px solid var(--bd);
padding:10px 18px; display:flex; align-items:center; gap:16px; flex-wrap:wrap;
box-shadow:0 1px 2px rgba(15,23,42,.04); z-index:10;
}
.logo{font-size:15px;font-weight:650;letter-spacing:-.2px}
.logo span{color:var(--tx3);font-weight:400;margin-left:6px;font-size:12px}
.hgap{flex:1}
.hctl{display:flex;align-items:center;gap:6px}
.hctl label{color:var(--tx2);font-size:12px}
select,input[type=text]{
border:1px solid var(--bd2);border-radius:6px;padding:5px 8px;background:#fff;outline:none;
}
input[type=text]{width:190px}
select:focus,input:focus{border-color:var(--pri)}
.btn{
border:1px solid var(--bd2);border-radius:6px;padding:5px 11px;background:#fff;
font-size:12px;transition:.12s;
}
.btn:hover{border-color:var(--pri);color:var(--pri)}
.btn.pri{background:var(--pri);border-color:var(--pri);color:#fff}
.btn.pri:hover{background:#1d4ed8;color:#fff}
.btn.sm{padding:2px 8px;font-size:11px;border-radius:5px}
.seg{display:flex;border:1px solid var(--bd2);border-radius:6px;overflow:hidden}
.seg button{padding:5px 12px;font-size:12px;background:#fff;border-right:1px solid var(--bd2)}
.seg button:last-child{border-right:none}
.seg button.on{background:var(--pri);color:#fff}
/* ---------- layout ---------- */
main{flex:1;display:flex;gap:14px;padding:14px;overflow:hidden}
.panel{background:var(--card);border:1px solid var(--bd);border-radius:10px;display:flex;flex-direction:column;overflow:hidden}
.panel-h{
padding:9px 14px;border-bottom:1px solid var(--bd);font-weight:600;font-size:13px;
display:flex;align-items:center;gap:8px;background:#fafcff;
}
.panel-h .sub{font-weight:400;color:var(--tx3);font-size:11px}
.panel-b{flex:1;overflow:auto;padding:12px 14px}
#left{flex:1;min-width:0}
#right{width:352px;flex:none}
/* ---------- guide ---------- */
.guide{
display:flex;align-items:center;gap:10px;padding:8px 12px;margin-bottom:10px;
background:var(--pri-bg);border:1px solid var(--pri-bd);border-radius:8px;font-size:12px;
}
.guide .g-txt{flex:1;color:#1e40af}
.guide button{color:var(--pri);font-weight:600;font-size:12px;text-decoration:underline}
/* ---------- tree ---------- */
.folder{margin-bottom:4px}
.folder-h{display:flex;align-items:center;gap:7px;padding:5px 4px;border-radius:6px}
.folder-h:hover{background:#f8fafc}
.folder-n{font-weight:600;font-size:13px}
.folder-c{color:var(--tx3);font-size:11px}
.folder-acts{margin-left:auto;display:flex;gap:5px;opacity:0;transition:.15s}
.folder-h:hover .folder-acts{opacity:1}
.menu{padding-left:16px}
.menu-h{display:flex;align-items:center;gap:7px;padding:5px 4px;border-radius:6px}
.menu-h:hover{background:#f8fafc}
.menu-n{font-size:13px}
.menu-n.off{color:var(--tx3)}
.mtag{
font-size:10px;color:var(--tx2);background:#f1f5f9;border:1px solid var(--bd);
border-radius:4px;padding:0 5px;line-height:15px;
}
.cb{
width:15px;height:15px;border:1.5px solid var(--bd2);border-radius:4px;flex:none;
display:inline-flex;align-items:center;justify-content:center;background:#fff;transition:.12s;
}
.cb.on{background:var(--pri);border-color:var(--pri)}
.cb.on::after{content:"";width:4px;height:7px;border:solid #fff;border-width:0 2px 2px 0;transform:rotate(42deg) translate(-1px,-1px)}
.cb.lock{background:#e2e8f0;border-color:var(--bd2)}
.cb.lock::after{content:"";width:4px;height:7px;border:solid #64748b;border-width:0 2px 2px 0;transform:rotate(42deg) translate(-1px,-1px)}
.row{display:flex;align-items:center;gap:7px;cursor:pointer;user-select:none}
/* ---------- module node ---------- */
.mod{margin:3px 0 3px 32px;border:1px solid var(--bd);border-radius:8px;background:#fff;overflow:hidden}
.mod.summary{background:#fcfdff}
.mod-h{display:flex;align-items:center;gap:7px;padding:6px 10px;cursor:pointer}
.mod-h:hover{background:#f8fafc}
.arrow{color:var(--tx3);font-size:10px;width:9px;flex:none}
.mod-n{font-size:12px;font-weight:600}
.mod-sum{color:var(--tx2);font-size:11px;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.pill{
font-size:10px;border-radius:4px;padding:1px 6px;line-height:15px;flex:none;
background:#f1f5f9;color:var(--tx2);border:1px solid var(--bd);
}
.pill.share{background:var(--pri-bg);color:#1e40af;border-color:var(--pri-bd)}
.pill.info{background:#f8fafc;color:var(--tx2)}
.mod-b{border-top:1px solid var(--bd);padding:9px 11px 10px;background:#fcfdff}
.grp{margin-bottom:8px}
.grp-t{font-size:10px;color:var(--tx3);font-weight:600;letter-spacing:.4px;margin-bottom:5px}
.acts{display:flex;flex-wrap:wrap;gap:5px}
.act{
display:inline-flex;align-items:center;gap:5px;border:1px solid var(--bd2);border-radius:6px;
padding:3px 8px;font-size:12px;background:#fff;cursor:pointer;transition:.12s;
}
.act:hover{border-color:var(--pri)}
.act.on{background:var(--pri-bg);border-color:var(--pri-bd);color:#1e40af}
.act.on.limited{background:#f1f5f9;border-color:var(--bd);color:var(--tx2)}
.act.risk.on{background:var(--danger-bg);border-color:var(--danger-bd);color:#b91c1c}
.act.auto{cursor:default;background:#f1f5f9;border-color:var(--bd);color:var(--tx2)}
.act .x{font-size:10px;color:var(--tx3)}
.act.limited .x{color:var(--warn)}
.field{margin-top:9px;padding-top:9px;border-top:1px dashed var(--bd)}
.field-t{font-size:11px;color:var(--tx2);margin-bottom:5px;display:flex;align-items:center;gap:6px}
.lv{display:inline-flex;border:1px solid var(--bd2);border-radius:6px;overflow:hidden}
.lv button{padding:2px 9px;font-size:11px;background:#fff;border-right:1px solid var(--bd2)}
.lv button:last-child{border-right:none}
.lv button.on{background:var(--pri);color:#fff}
.chips{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px}
.chip{font-size:10px;background:#f1f5f9;border:1px solid var(--bd);border-radius:4px;padding:1px 6px;color:var(--tx2)}
.chip.hide{background:var(--danger-bg);border-color:var(--danger-bd);color:#b91c1c;text-decoration:line-through}
.chip.ro{background:var(--warn-bg);border-color:var(--warn-bd);color:#b45309}
.scope-row{display:flex;align-items:center;gap:8px;margin-top:9px;padding-top:9px;border-top:1px dashed var(--bd)}
.count{font-size:11px;color:var(--tx2)}
.count b{color:var(--pri);font-size:13px}
.count .tot{color:var(--tx3)}
.note{margin-top:8px;font-size:11px;color:var(--tx2);display:flex;gap:5px;align-items:flex-start}
.note .i{
flex:none;width:13px;height:13px;border-radius:50%;background:var(--pri-bg);color:var(--pri);
font-size:9px;font-weight:700;display:flex;align-items:center;justify-content:center;margin-top:1px;
border:1px solid var(--pri-bd);
}
/* ---------- copy / diff ---------- */
.diff{
margin-bottom:10px;border:1px solid var(--warn-bd);background:var(--warn-bg);
border-radius:8px;padding:10px 12px;font-size:12px;
}
.diff-h{font-weight:600;color:#92400e;margin-bottom:6px;display:flex;align-items:center;gap:8px}
.diff-item{display:flex;align-items:center;gap:8px;padding:3px 0;color:#78350f}
.diff-item .t{flex:1}
.diff-item .s{font-size:11px;color:var(--tx2)}
/* ---------- preview ---------- */
.pv-sec{margin-bottom:14px}
.pv-t{font-size:11px;color:var(--tx3);font-weight:600;letter-spacing:.4px;margin-bottom:6px}
.pv-menus{display:flex;flex-wrap:wrap;gap:5px}
.pv-menu{font-size:11px;background:var(--ok-bg);border:1px solid #bbf7d0;color:#15803d;border-radius:5px;padding:2px 7px}
.pv-empty{font-size:11px;color:var(--tx3);font-style:italic}
.mock{border:1px solid var(--bd);border-radius:8px;overflow:hidden;background:#fff}
.mock-bar{
display:flex;gap:5px;padding:7px 9px;border-bottom:1px solid var(--bd);background:#fafcff;flex-wrap:wrap;
}
.mock-btn{
font-size:11px;border:1px solid var(--bd2);border-radius:5px;padding:2px 9px;background:#fff;color:var(--tx);
}
.mock-btn.danger{border-color:var(--danger-bd);color:var(--danger);background:var(--danger-bg)}
.mock-tbl{width:100%;border-collapse:collapse}
.mock-tbl th{
font-size:10px;font-weight:600;color:var(--tx2);text-align:left;padding:5px 8px;
border-bottom:1px solid var(--bd);border-right:1px solid #f1f5f9;background:#f8fafc;white-space:nowrap;
}
.mock-tbl th.ro{color:var(--warn)}
.mock-tbl td{font-size:10px;padding:5px 8px;border-bottom:1px solid #f1f5f9;color:var(--tx3)}
.mock-foot{padding:5px 9px;font-size:10px;color:var(--tx2);background:#f8fafc;border-top:1px solid var(--bd)}
/* ---------- risk ---------- */
.risk{
display:flex;gap:7px;padding:7px 10px;border-radius:7px;font-size:11px;margin-bottom:6px;align-items:flex-start;
}
.risk.high{background:var(--danger-bg);border:1px solid var(--danger-bd);color:#991b1b}
.risk.mid{background:var(--warn-bg);border:1px solid var(--warn-bd);color:#92400e}
.risk .b{
flex:none;width:14px;height:14px;border-radius:50%;font-size:9px;font-weight:700;
display:flex;align-items:center;justify-content:center;margin-top:1px;
}
.risk.high .b{background:var(--danger);color:#fff}
.risk.mid .b{background:var(--warn);color:#fff}
.risk-ok{font-size:11px;color:var(--tx3);padding:4px 0}
/* ---------- misc ---------- */
.empty{padding:30px 10px;text-align:center;color:var(--tx3);font-size:12px}
.empty b{display:block;color:var(--tx2);font-size:13px;margin-bottom:4px}
.toast{
position:fixed;left:50%;bottom:26px;transform:translateX(-50%);
background:#0f172a;color:#fff;padding:9px 18px;border-radius:8px;font-size:12px;
box-shadow:0 6px 20px rgba(0,0,0,.2);z-index:99;
}
.design{
margin-top:14px;border-top:1px solid var(--bd);padding-top:10px;font-size:11px;color:var(--tx2);
}
.design summary{cursor:pointer;font-weight:600;color:var(--tx);font-size:12px}
.design ol{margin:7px 0 0 16px}
.design li{margin-bottom:4px}
.design code{background:#f1f5f9;padding:0 4px;border-radius:3px;font-size:10px}
</style>
</head>
<body>
<header>
<div class="logo">FMS 权限分配<span>交互原型 v2</span></div>
<div class="hgap"></div>
<div class="hctl">
<label>配置对象</label>
<select id="userSel"></select>
</div>
<div class="hctl">
<label>从他人复制</label>
<select id="copySel"></select>
</div>
<div class="hctl">
<input type="text" id="search" placeholder="搜索菜单或权限,如 审核">
</div>
<div class="seg" id="viewSeg">
<button data-view="menu" class="on">按菜单</button>
<button data-view="module">按模块</button>
</div>
<button class="btn pri" id="saveBtn">保存</button>
</header>
<main>
<section class="panel" id="left">
<div class="panel-h">
权限配置树
<span class="sub">菜单 → 模块 → { 操作 · 敏感信息 · 数据范围 }</span>
</div>
<div class="panel-b" id="treeBox"></div>
</section>
<aside class="panel" id="right">
<div class="panel-h">实时预览 <span class="sub" id="pvWho"></span></div>
<div class="panel-b" id="pvBox"></div>
</aside>
</main>
<script>
/* ==================== 静态元数据(IT 配置) ==================== */
const MENUS = [
{ id:'base', name:'基础资料', folder:true, children:[
{ id:'customer_list', name:'客户管理', modules:['customer'] },
]},
{ id:'fee', name:'费用管理', folder:true, children:[
{ id:'fee_list', name:'费用列表', modules:['cw_fee'] },
{ id:'fee_edit', name:'费用编辑', modules:['cw_fee'] },
]},
{ id:'sea', name:'海运操作', folder:true, children:[
{ id:'sea_detail', name:'海运详细页', modules:['sea_main','container_main','cw_fee'] },
]},
{ id:'rpt', name:'财务报表', folder:true, children:[
{ id:'ar_report', name:'应收报表', modules:['cw_fee'] },
]},
];
const MODULES = {
cw_fee: {
name:'费用模块',
actions:[
{id:'create', name:'新增', group:'data', risk:0},
{id:'update', name:'修改', group:'data', risk:0},
{id:'delete', name:'删除', group:'data', risk:1},
{id:'export', name:'导出', group:'output', risk:0},
{id:'audit', name:'审核', group:'business', risk:0},
{id:'unaudit',name:'反审核', group:'business', risk:1},
],
total:50000, scope:{own:120, department:860, department_tree:2400, all:50000, custom:320},
},
sea_main: {
name:'海运主表模块',
actions:[
{id:'create', name:'新增', group:'data', risk:0},
{id:'update', name:'修改', group:'data', risk:0},
{id:'delete', name:'删除', group:'data', risk:1},
],
total:12000, scope:{own:40, department:300, department_tree:900, all:12000, custom:150},
},
container_main: {
name:'箱量模块',
actions:[
{id:'create', name:'新增', group:'data', risk:0},
{id:'update', name:'修改', group:'data', risk:0},
{id:'delete', name:'删除', group:'data', risk:1},
],
total:30000, scope:{own:60, department:500, department_tree:1500, all:30000, custom:210},
},
customer: {
name:'客户资料模块',
actions:[
{id:'create', name:'新增', group:'data', risk:0},
{id:'update', name:'修改', group:'data', risk:0},
{id:'delete', name:'删除', group:'data', risk:1},
{id:'export', name:'导出', group:'output', risk:0},
],
total:8000, scope:{own:15, department:200, department_tree:600, all:8000, custom:90},
},
};
// 字段敏感性分级:IT 在模块管理里一次性打标
const FIELDS = {
cw_fee:[
{n:'费用单号',s:0},{n:'业务日期',s:0},{n:'客户',s:0},{n:'费用项目',s:0},
{n:'金额',s:1},{n:'成本价',s:2},{n:'内部备注',s:2},{n:'利润',s:3},
],
sea_main:[{n:'提单号',s:0},{n:'船名',s:0},{n:'航线',s:0},{n:'海运费',s:1},{n:'合约价',s:2}],
container_main:[{n:'箱号',s:0},{n:'箱型',s:0},{n:'箱量',s:1},{n:'箱成本',s:2}],
customer:[{n:'客户编码',s:0},{n:'客户名称',s:0},{n:'联系人',s:1},{n:'账期',s:2},{n:'信用额度',s:3}],
};
// 页面能力上限:IT 在 s_menu_module 配置,客户不可改
const PAGE_ALLOW = {
'fee_list:cw_fee' :{create:0,update:0,delete:0,export:1,audit:0,unaudit:0},
'fee_edit:cw_fee' :{create:1,update:1,delete:0,export:0,audit:1,unaudit:1},
'sea_detail:cw_fee' :{create:1,update:1,delete:0,export:1,audit:0,unaudit:0},
'sea_detail:sea_main' :{create:1,update:1,delete:0},
'sea_detail:container_main':{create:1,update:1,delete:1},
'ar_report:cw_fee' :{create:0,update:0,delete:0,export:1,audit:0,unaudit:0},
'customer_list:customer' :{create:1,update:1,delete:1,export:1},
};
const SCOPES = [
{id:'own', name:'本人'},
{id:'department', name:'本部门'},
{id:'department_tree', name:'本部门及下级'},
{id:'custom', name:'指定范围'},
{id:'all', name:'全部数据'},
];
const LEVELS = [
{v:0,name:'公开'},{v:1,name:'内部'},{v:2,name:'敏感'},{v:3,name:'机密'},
];
const GROUPS = {data:'数据操作', output:'输出', business:'业务动作'};
/* ==================== 用户权限数据 ==================== */
function blankPerm(){
const m={};
for(const k in MODULES){ m[k]={actions:{}, sensitivity:1, scope:'department'}; }
return {menus:{}, modules:m};
}
const USERS = {
wangwu:{name:'王五(新员工)', perm:blankPerm()},
zhangsan:{name:'张三(费用会计)', perm:{
menus:{fee_list:1, fee_edit:1},
modules:{
cw_fee:{actions:{create:1,update:1,export:1,audit:1}, sensitivity:1, scope:'own'},
sea_main:{actions:{}, sensitivity:1, scope:'own'},
container_main:{actions:{}, sensitivity:1, scope:'own'},
customer:{actions:{}, sensitivity:1, scope:'own'},
}}},
lisi:{name:'李四(财务经理)', perm:{
menus:{fee_list:1, fee_edit:1, sea_detail:1, ar_report:1, customer_list:1},
modules:{
cw_fee:{actions:{create:1,update:1,delete:1,export:1,audit:1,unaudit:1}, sensitivity:3, scope:'all'},
sea_main:{actions:{create:1,update:1,delete:1}, sensitivity:2, scope:'department_tree'},
container_main:{actions:{create:1,update:1,delete:1}, sensitivity:2, scope:'all'},
customer:{actions:{create:1,update:1,delete:1,export:1}, sensitivity:3, scope:'all'},
}}},
};
/* ==================== 状态 ==================== */
const state = {
userId:'wangwu',
view:'menu',
expanded:null, // 'menuId:moduleId' —— 同时只展开一个
previewPage:'fee_list',
search:'',
copyFrom:null,
toast:'',
};
const P = () => USERS[state.userId].perm;
/* ==================== 计算 ==================== */
function flatMenus(){
const out=[];
MENUS.forEach(f=>f.children.forEach(m=>out.push({...m, folder:f.name})));
return out;
}
function menuById(id){ return flatMenus().find(m=>m.id===id); }
function pagesOfModule(mid){ return flatMenus().filter(m=>m.modules.includes(mid)); }
function activePagesOfModule(mid){ return pagesOfModule(mid).filter(m=>P().menus[m.id]); }
// 「查看」跟随菜单自动开启:只要任一已勾选菜单用到该模块
function autoRead(mid){ return activePagesOfModule(mid).length>0; }
function userHas(mid,aid){ return aid==='read' ? autoRead(mid) : !!P().modules[mid].actions[aid]; }
function pageAllows(menuId,mid,aid){
const a = PAGE_ALLOW[menuId+':'+mid];
if(!a) return true;
return a[aid]!==0;
}
function effective(menuId,mid,aid){
return userHas(mid,aid) && pageAllows(menuId,mid,aid);
}
function visibleFields(mid){
const lv = P().modules[mid].sensitivity;
return FIELDS[mid].map(f=>({...f, hidden:f.s>lv, readonly:f.s===lv && f.s>0}));
}
function scopeCount(mid){
const t = P().modules[mid].scope;
return MODULES[mid].scope[t] ?? 0;
}
/* ==================== 渲染 ==================== */
const esc = s => String(s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
const cb = (on,cls='') => `<span class="cb ${on?'on':''} ${cls}"></span>`;
function render(){
document.getElementById('userSel').innerHTML = Object.keys(USERS)
.map(k=>`<option value="${k}" ${k===state.userId?'selected':''}>${USERS[k].name}</option>`).join('');
document.getElementById('copySel').innerHTML =
`<option value="">选择来源用户…</option>` +
Object.keys(USERS).filter(k=>k!==state.userId)
.map(k=>`<option value="${k}" ${k===state.copyFrom?'selected':''}>${USERS[k].name}</option>`).join('');
document.getElementById('pvWho').textContent = '· ' + USERS[state.userId].name;
const box = document.getElementById('treeBox');
box.innerHTML = renderDiff() + renderGuide() + (state.view==='menu' ? renderMenuTree() : renderModuleView());
document.getElementById('pvBox').innerHTML = renderPreview() + renderRisk() + designNotes();
document.querySelectorAll('#viewSeg button').forEach(b=>
b.classList.toggle('on', b.dataset.view===state.view));
}
/* ---- 引导条 ---- */
function renderGuide(){
const ms = flatMenus().filter(m=>P().menus[m.id]);
if(!ms.length) return '';
const mids = [...new Set(ms.flatMap(m=>m.modules))];
const uncfg = mids.filter(mid=>{
const acts = Object.keys(P().modules[mid].actions).filter(k=>P().modules[mid].actions[k]);
return !autoRead(mid) || (acts.length===0 && P().modules[mid].sensitivity===1 && P().modules[mid].scope==='department');
});
return `<div class="guide">
<span class="g-txt">已选 <b>${ms.length}</b> 个页面,涉及 <b>${mids.length}</b> 个模块${
uncfg.length?`;其中 ${uncfg.length} 个模块尚未调整(当前为只读)`:''}</span>
<button data-act="expandFirst">展开配置操作 →</button>
</div>`;
}
/* ---- 复制 diff ---- */
function diffItems(){
if(!state.copyFrom) return null;
const src = USERS[state.copyFrom].perm, cur = P(), out=[];
flatMenus().forEach(m=>{
const a=!!src.menus[m.id], b=!!cur.menus[m.id];
if(a!==b) out.push({k:'menu', id:m.id, label:m.name, val:a, kind:'menu'});
});
Object.keys(MODULES).forEach(mid=>{
const sa=src.modules[mid], ca=cur.modules[mid];
MODULES[mid].actions.forEach(a=>{
const x=!!sa.actions[a.id], y=!!ca.actions[a.id];
if(x!==y) out.push({k:mid+':'+a.id, id:mid, aid:a.id, label:`${MODULES[mid].name} · ${a.name}`, val:x, kind:'act'});
});
if(sa.sensitivity!==ca.sensitivity)
out.push({k:mid+':lv', id:mid, label:`${MODULES[mid].name} · 敏感等级`, val:sa.sensitivity, kind:'lv'});
if(sa.scope!==ca.scope)
out.push({k:mid+':sc', id:mid, label:`${MODULES[mid].name} · 数据范围`, val:sa.scope, kind:'sc'});
});
return out;
}
function applyDiff(it){
const src = USERS[state.copyFrom].perm, cur = P();
if(it.kind==='menu'){ it.val ? cur.menus[it.id]=1 : delete cur.menus[it.id]; }
else if(it.kind==='act'){ it.val ? cur.modules[it.id].actions[it.aid]=1 : delete cur.modules[it.id].actions[it.aid]; }
else if(it.kind==='lv'){ cur.modules[it.id].sensitivity = it.val; }
else if(it.kind==='sc'){ cur.modules[it.id].scope = it.val; }
}
function renderDiff(){
const items = diffItems();
if(!items) return '';
if(!items.length) return `<div class="diff"><div class="diff-h">与 ${USERS[state.copyFrom].name} 无差异</div>
<button class="btn sm" data-act="closeCopy" style="margin-top:4px">关闭</button></div>`;
const show = v => {
if(typeof v==='boolean') return v?'开启':'关闭';
return String(v);
};
return `<div class="diff">
<div class="diff-h">与 ${USERS[state.copyFrom].name} 的差异 ${items.length} 项
<button class="btn sm" data-act="applyAll">全部应用</button>
<button class="btn sm" data-act="closeCopy">关闭</button>
</div>
${items.slice(0,14).map(it=>`<div class="diff-item">
<span class="t">${esc(it.label)}</span>
<span class="s">→ ${esc(show(it.val))}</span>
<button class="btn sm" data-act="applyOne" data-k="${esc(it.k)}">应用</button>
</div>`).join('')}
${items.length>14?`<div class="diff-item"><span class="t">… 另有 ${items.length-14} 项</span></div>`:''}
</div>`;
}
/* ---- 菜单视图 ---- */
function renderMenuTree(){
const kw = state.search.trim();
let html='';
MENUS.forEach(f=>{
const kids = f.children.filter(m=>matchMenu(m,kw));
if(kw && !kids.length) return;
const on = kids.filter(m=>P().menus[m.id]).length;
html += `<div class="folder">
<div class="folder-h">
<span class="folder-n">${esc(f.name)}</span>
<span class="folder-c">${on}/${kids.length}</span>
<span class="folder-acts">
<button class="btn sm" data-act="folderAll" data-f="${f.id}" data-v="1">全选</button>
<button class="btn sm" data-act="folderAll" data-f="${f.id}" data-v="0">清空</button>
</span>
</div>
<div class="menu">${kids.map(renderMenu).join('')}</div>
</div>`;
});
if(!html) html = `<div class="empty"><b>没有匹配的菜单</b>试试「审核」「删除」「费用」</div>`;
return html;
}
function matchMenu(m,kw){
if(!kw) return true;
if(m.name.includes(kw)) return true;
return m.modules.some(mid=>MODULES[mid].actions.some(a=>a.name.includes(kw)));
}
function renderMenu(m){
const on = !!P().menus[m.id];
const mods = m.modules.map(mid=>{
const key = m.id+':'+mid;
const open = state.expanded===key;
return open ? renderModuleOpen(m,mid) : renderModuleSummary(m,mid);
}).join('');
return `<div>
<div class="menu-h">
<div class="row" data-act="toggleMenu" data-id="${m.id}">
${cb(on)}<span class="menu-n ${on?'':'off'}">${esc(m.name)}</span>
</div>
${on?`<span class="mtag">${m.modules.length} 个模块</span>`:''}
</div>
${on?mods:''}
</div>`;
}
function renderModuleSummary(m,mid){
const M = MODULES[mid];
const acts = M.actions.filter(a=>effective(m.id,mid,a.id)).map(a=>a.name);
const list = (autoRead(mid)?['查看']:[]).concat(acts);
const n = activePagesOfModule(mid).length;
const shared = pagesOfModule(mid).length>1;
return `<div class="mod summary">
<div class="mod-h" data-act="expand" data-k="${m.id}:${mid}">
<span class="arrow">▸</span>
<span class="mod-n">${esc(M.name)}</span>
<span class="mod-sum">${list.length?list.join(' · '):'仅只读(未配置)'}</span>
${shared?`<span class="pill share">同一模块</span>`:''}
<span class="pill info">生效于 ${n} 页</span>
</div>
</div>`;
}
function renderModuleOpen(m,mid){
const M = MODULES[mid], pm = P().modules[mid];
const groups = {};
const head = `<div class="act auto">${cb(autoRead(mid),'lock')} 查看<span class="x">跟随菜单</span></div>`;
M.actions.forEach(a=>{
const on = userHas(mid,a.id);
const limited = on && !pageAllows(m.id,mid,a.id);
const cls = ['act', on?'on':'', limited?'limited':'', a.risk?'risk':''].join(' ');
(groups[a.group] ||= []).push(
`<div class="${cls}" data-act="toggleAct" data-m="${mid}" data-a="${a.id}">
${cb(on)}${esc(a.name)}${limited?`<span class="x">本页不提供</span>`:''}
</div>`);
});
let body='';
['data','output','business'].forEach(g=>{
if(!groups[g]) return;
body += `<div class="grp"><div class="grp-t">${GROUPS[g]}</div>
<div class="acts">${g==='data'?head:''}${groups[g].join('')}</div></div>`;
});
// 敏感信息
const vf = visibleFields(mid);
const hid = vf.filter(f=>f.hidden).map(f=>f.n);
const ro = vf.filter(f=>f.readonly).map(f=>f.n);
body += `<div class="field">
<div class="field-t">敏感信息可见等级
<span class="lv">${LEVELS.map(l=>`<button class="${pm.sensitivity===l.v?'on':''}"
data-act="setLv" data-m="${mid}" data-v="${l.v}">${l.name}</button>`).join('')}</span>
</div>
<div class="chips">
${vf.filter(f=>!f.hidden&&!f.readonly).map(f=>`<span class="chip">${esc(f.n)}</span>`).join('')}
${ro.map(n=>`<span class="chip ro">${esc(n)} 只读</span>`).join('')}
${hid.map(n=>`<span class="chip hide">${esc(n)}</span>`).join('')}
</div>
</div>`;
// 数据范围
const cnt = scopeCount(mid);
body += `<div class="scope-row">
<span class="field-t" style="margin:0">数据范围</span>
<select data-act="setScope" data-m="${mid}">
${SCOPES.map(s=>`<option value="${s.id}" ${pm.scope===s.id?'selected':''}>${s.name}</option>`).join('')}
</select>
<span class="count">可见 <b>${cnt.toLocaleString()}</b> <span class="tot">/ ${M.total.toLocaleString()} 条</span></span>
</div>`;
const pages = activePagesOfModule(mid).map(p=>p.name);
body += `<div class="note"><span class="i">i</span><span>以上配置属于「${esc(M.name)}」,同时生效于:${
pages.length?pages.join('、'):'(当前无已勾选页面)'}。在其他页面的同一模块下修改,此处同步变化。</span></div>`;
return `<div class="mod">
<div class="mod-h" data-act="collapse">
<span class="arrow">▾</span>
<span class="mod-n">${esc(M.name)}</span>
<span class="pill share">同一模块</span>
<span class="pill info">生效于 ${pages.length} 页</span>
</div>
<div class="mod-b">${body}</div>
</div>`;
}
/* ---- 模块视图(审查) ---- */
function renderModuleView(){
let html='';
Object.keys(MODULES).forEach(mid=>{
const M=MODULES[mid], pm=P().modules[mid];
const pages = activePagesOfModule(mid);
const acts = M.actions.filter(a=>userHas(mid,a.id)).map(a=>a.name);
const cnt = scopeCount(mid);
html += `<div class="mod" style="margin-left:0;margin-bottom:8px">
<div class="mod-h">
<span class="arrow">▾</span>
<span class="mod-n">${esc(M.name)}</span>
<span class="mod-sum">${(autoRead(mid)?['查看']:[]).concat(acts).join(' · ')||'只读'}</span>
<span class="pill info">影响 ${pages.length} 页</span>
</div>
<div class="mod-b">
<div class="acts" style="margin-bottom:8px">
<div class="act auto">${cb(autoRead(mid),'lock')} 查看<span class="x">跟随菜单</span></div>
${M.actions.map(a=>{
const on=userHas(mid,a.id);
return `<div class="act ${on?'on':''} ${a.risk?'risk':''}" data-act="toggleAct" data-m="${mid}" data-a="${a.id}">${cb(on)}${esc(a.name)}</div>`;
}).join('')}
</div>
<div class="scope-row" style="border-top:none;padding-top:0;margin-top:0">
<span class="field-t" style="margin:0">敏感等级</span>
<span class="lv">${LEVELS.map(l=>`<button class="${pm.sensitivity===l.v?'on':''}"
data-act="setLv" data-m="${mid}" data-v="${l.v}">${l.name}</button>`).join('')}</span>
<span class="field-t" style="margin:0 0 0 8px">范围</span>
<select data-act="setScope" data-m="${mid}">
${SCOPES.map(s=>`<option value="${s.id}" ${pm.scope===s.id?'selected':''}>${s.name}</option>`).join('')}
</select>
<span class="count">可见 <b>${cnt.toLocaleString()}</b> <span class="tot">/ ${M.total.toLocaleString()} 条</span></span>
</div>
<div class="note"><span class="i">i</span><span>用于:${
pages.length?pages.map(p=>p.name).join('、'):'(无已勾选页面,配置暂不生效)'}</span></div>
</div>
</div>`;
});
return html;
}
/* ---- 预览 ---- */
function renderPreview(){
const menus = flatMenus().filter(m=>P().menus[m.id]);
const page = menuById(state.previewPage) || menus[0];
let html = `<div class="pv-sec">
<div class="pv-t">可见菜单</div>
<div class="pv-menus">${
menus.length ? menus.map(m=>`<span class="pv-menu">${esc(m.name)}</span>`).join('')
: '<span class="pv-empty">暂未勾选任何菜单</span>'}
</div></div>`;
if(!page){ return html + `<div class="empty"><b>勾选左侧菜单后即可预览</b></div>`; }
if(!P().menus[page.id]) return html + `<div class="empty"><b>${esc(page.name)} 未授权</b>该用户看不到这个页面</div>`;
const mid = page.modules[0];
const acts = ['read', ...MODULES[mid].actions.map(a=>a.id)].filter(a=>effective(page.id,mid,a.id));
const btns = acts.filter(a=>a!=='read').map(a=>{
const d = MODULES[mid].actions.find(x=>x.id===a);
return `<span class="mock-btn ${d&&d.risk?'danger':''}">${d?d.name:a}</span>`;
});
const vf = visibleFields(mid);
const cols = vf.filter(f=>!f.hidden);
return html + `<div class="pv-sec">
<div class="pv-t">页面预览
<select id="pvPage" style="margin-left:4px">${
menus.map(m=>`<option value="${m.id}" ${m.id===page.id?'selected':''}>${esc(m.name)}</option>`).join('')}
</select>
</div>
<div class="mock">
<div class="mock-bar">
<span class="mock-btn">查询</span>
${btns.length?btns.join(''):'<span class="pv-empty">无可用操作</span>'}
</div>
<table class="mock-tbl">
<tr>${cols.map(c=>`<th class="${c.readonly?'ro':''}">${esc(c.n)}${c.readonly?' <span style="font-weight:400">只读</span>':''}</th>`).join('')}</tr>
<tr>${cols.map(()=>`<td>…</td>`).join('')}</tr>
<tr>${cols.map(()=>`<td>…</td>`).join('')}</tr>
</table>
<div class="mock-foot">可见 ${scopeCount(mid).toLocaleString()} 条 · 已隐藏 ${
vf.filter(f=>f.hidden).length} 个字段 · 依据模块「${esc(MODULES[mid].name)}」</div>
</div></div>`;
}
/* ---- 风险 ---- */
function renderRisk(){
const out=[];
Object.keys(MODULES).forEach(mid=>{
const pm=P().modules[mid], M=MODULES[mid];
if(!autoRead(mid)) return;
const has = id=>!!pm.actions[id];
if(has('delete')) out.push(['high', `${M.name}:拥有删除权限,可删除数据`]);
if(has('create') && has('audit')) out.push(['mid', `${M.name}:同时拥有录入与审核,不符合职责分离`]);
if(has('unaudit')) out.push(['mid', `${M.name}:拥有反审核权限`]);
if(pm.scope==='all') out.push(['mid', `${M.name}:数据范围为全部,可见 ${M.total.toLocaleString()} 条`]);
if(pm.sensitivity>=3) out.push(['mid', `${M.name}:可见全部机密字段(如 ${FIELDS[mid].filter(f=>f.s>=3).map(f=>f.n).join('、')||'敏感字段'})`]);
});
if(!out.length) return `<div class="pv-sec"><div class="pv-t">风险提示</div>
<div class="risk-ok">当前配置无高风险项</div></div>`;
return `<div class="pv-sec"><div class="pv-t">风险提示</div>
${out.map(([lv,t])=>`<div class="risk ${lv}"><span class="b">!</span><span>${esc(t)}</span></div>`).join('')}</div>`;
}
/* ---- 设计说明 ---- */
function designNotes(){
return `<details class="design">
<summary>这个原型在解决什么</summary>
<ol>
<li><b>一棵树完成全部配置</b>:菜单 → 模块 → {操作 / 敏感信息 / 数据范围},不分成多个区域。</li>
<li><b>模块是共享节点</b>:同一模块在多个菜单下出现,是同一份数据。在任意处修改,其他处摘要同步变化 —— 用行为揭示「操作是模块级」这一事实,不靠文字警告。</li>
<li><b>常驻「生效于 N 页」</b>:消除「我只在当前页面勾了」的误解。</li>
<li><b>查看跟随菜单</b>:避免「进得去却看不到数据」这一高频配置坑,置灰不可取消。</li>
<li><b>页面能力上限与用户权限分离</b>:客户配「用户有什么」,IT 配「页面最多允许什么」(<code>s_menu_module.b_allow_*</code>),最终取交集,界面上体现为「本页不提供」。</li>
<li><b>可见 N / M 条</b>:把抽象的数据范围变成可感知的数字,配完立刻知道多了还是少了。</li>
<li><b>字段敏感性分级</b>:把 <code>用户×模块×字段</code> 的笛卡尔积降维成「字段打标 + 用户一个等级」,新增敏感字段时自动受控,不会默认放开。</li>
<li><b>从他人复制 + diff</b>:顺应客户「照着李四抄」的真实行为,且差异可见、可逐项应用。</li>
</ol>
</details>`;
}
/* ==================== 事件 ==================== */
function toast(msg){
state.toast = msg;
let el = document.getElementById('toast');
if(!el){ el=document.createElement('div'); el.id='toast'; el.className='toast'; document.body.appendChild(el); }
el.textContent = msg;
clearTimeout(el._t);
el._t = setTimeout(()=>{ el.remove(); }, 1800);
}
document.getElementById('userSel').addEventListener('change', e=>{
state.userId = e.target.value; state.expanded=null; state.copyFrom=null;
const first = flatMenus().find(m=>P().menus[m.id]);
if(first) state.previewPage = first.id;
render();
});
document.getElementById('copySel').addEventListener('change', e=>{
state.copyFrom = e.target.value || null; render();
});
document.getElementById('search').addEventListener('input', e=>{
state.search = e.target.value; render();
});
document.getElementById('viewSeg').addEventListener('click', e=>{
const b = e.target.closest('button'); if(!b) return;
state.view = b.dataset.view; render();
});
document.getElementById('saveBtn').addEventListener('click', ()=>{
toast('已保存 ' + USERS[state.userId].name + ' 的权限配置');
});
document.getElementById('treeBox').addEventListener('click', e=>{
const el = e.target.closest('[data-act]'); if(!el) return;
const act = el.dataset.act;
if(act==='toggleMenu'){
const id = el.dataset.id;
P().menus[id] ? delete P().menus[id] : P().menus[id]=1;
if(P().menus[id]) state.previewPage = id;
render();
}
else if(act==='folderAll'){
const f = MENUS.find(x=>x.id===el.dataset.f);
f.children.forEach(m=>{ el.dataset.v==='1' ? P().menus[m.id]=1 : delete P().menus[m.id]; });
render();
}
else if(act==='expand'){ state.expanded = el.dataset.k; render(); }
else if(act==='collapse'){ state.expanded = null; render(); }
else if(act==='expandFirst'){
const m = flatMenus().find(x=>P().menus[x.id]);
if(m) state.expanded = m.id + ':' + m.modules[0];
render();
}
else if(act==='toggleAct'){
const {m:mid, a:aid} = el.dataset;
const acts = P().modules[mid].actions;
acts[aid] ? delete acts[aid] : acts[aid]=1;
render();
}
else if(act==='setLv'){ P().modules[el.dataset.m].sensitivity = +el.dataset.v; render(); }
else if(act==='applyAll'){
diffItems().forEach(applyDiff); render(); toast('已应用全部差异');
}
else if(act==='applyOne'){
const it = diffItems().find(x=>x.k===el.dataset.k);
if(it){ applyDiff(it); render(); }
}
else if(act==='closeCopy'){
state.copyFrom=null; document.getElementById('copySel').value=''; render();
}
});
document.getElementById('treeBox').addEventListener('change', e=>{
const el = e.target.closest('[data-act="setScope"]'); if(!el) return;
P().modules[el.dataset.m].scope = el.value; render();
});
document.getElementById('pvBox').addEventListener('change', e=>{
if(e.target.id==='pvPage'){ state.previewPage = e.target.value; render(); }
});
render();
</script>
</body>
</html>