20260909224013

This commit is contained in:
oneao committed 2026-09-09 22:40:13 +08:00
1 parent d8acf69533
commit 19267be58d
4 files changed
+1372 -538

No files matched your search

+831
View File
@@ -0,0 +1,831 @@
<!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>