832 lines
37 KiB
HTML
832 lines
37 KiB
HTML
<!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=>({'&':'&','<':'<','>':'>','"':'"'}[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>
|