20260908170359

This commit is contained in:
oneao committed 2026-09-08 17:03:59 +08:00
1 parent 2f5eac079e
commit 8734b43a2a
14 files changed
+1956 -1063

No files matched your search

+847 -29
View File
@@ -2,38 +2,856 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>ERP 查询构建器 Demo</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>FMS 权限中心 · 一屏授权</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = { theme: { extend: { fontFamily: { sans: ['Inter', 'Segoe UI', 'Microsoft YaHei', 'sans-serif'] } } } }
</script>
<style>
:root{--border:#e5e7eb;--border-strong:#cfd6e2;--text:#20242b;--muted:#7b8491;--primary:#1677ff;--primary-soft:#edf5ff;--bg:#f4f6f8;--danger:#e5484d;--shadow:0 16px 44px rgba(30,41,59,.18)}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:14px -apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei",sans-serif}button,input,select{font:inherit}button{cursor:pointer}
.page{padding:24px;max-width:1500px;margin:auto}.header{height:42px;display:flex;align-items:center;justify-content:space-between;margin-bottom:16px}.title{font-size:20px;font-weight:650}.subtitle{color:var(--muted);font-size:12px;margin-top:4px}
.btn{height:34px;padding:0 13px;background:#fff;border:1px solid var(--border-strong);border-radius:6px;color:#303640;display:inline-flex;align-items:center;gap:6px;white-space:nowrap}.btn:hover{background:#f8fafc;border-color:#aeb9c8}.btn.primary{background:var(--primary);border-color:var(--primary);color:#fff}.btn.primary:hover{background:#0f69e8}.btn.link-btn{border:0;background:transparent;color:var(--primary);padding:0 4px}.btn.danger{color:var(--danger)}
.search-panel,.table-panel{background:#fff;border:1px solid var(--border);border-radius:8px}.search-panel{padding:14px 16px;margin-bottom:14px}.search-line{display:flex;gap:8px;align-items:center}.search{position:relative;flex:1;max-width:680px}.search input{width:100%;height:36px;border:1px solid var(--border-strong);border-radius:6px;padding:0 12px 0 35px;outline:none}.search input:focus{border-color:var(--primary);box-shadow:0 0 0 2px #1677ff16}.search .icon{position:absolute;left:12px;top:7px;color:#8c95a2;font-size:18px}.badge{padding:2px 7px;border-radius:10px;background:#dcecff;color:var(--primary);font-size:12px}.query-summary{display:flex;align-items:center;gap:9px;margin-top:10px;min-height:28px}.summary-label{font-size:12px;color:var(--muted);white-space:nowrap}.conditions{display:flex;flex-wrap:wrap;gap:7px;min-width:0}.tag{height:28px;display:flex;align-items:center;gap:5px;border:1px solid #cfe0f8;background:#f7faff;border-radius:5px;padding:0 8px;color:#394454;white-space:nowrap}.tag .label{color:#6f7b89}.tag .x{border:0;background:none;color:#8993a0;padding:0 2px;font-size:16px;line-height:1}.table-head{height:48px;padding:0 16px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #eef0f3}.table-title{font-weight:600}.table-actions{display:flex;align-items:center;gap:10px}.muted{color:var(--muted);font-size:12px}table{width:100%;border-collapse:collapse}th,td{height:48px;padding:0 15px;text-align:left;border-bottom:1px solid #f0f1f3;white-space:nowrap}th{background:#fafbfc;color:#626a76;font-weight:500}tr:hover td{background:#fafcff}.th-label{display:inline-flex;align-items:center;gap:5px}.th-filter{border:0;background:transparent;color:#9aa3ae;padding:3px 2px;font-size:13px}.th-filter:hover{color:var(--primary)}.status{padding:3px 8px;border-radius:4px;background:#fff6df;color:#ad7200;font-size:12px}.link{color:var(--primary);margin-right:14px;cursor:pointer}
.overlay{display:none;position:fixed;inset:0;background:rgba(16,24,40,.32);z-index:20}.overlay.show{display:block}.drawer{position:absolute;right:0;top:0;width:min(820px,100%);height:100%;background:#fff;box-shadow:var(--shadow);display:flex;flex-direction:column}.drawer-head{height:64px;padding:0 22px;border-bottom:1px solid #edf0f3;display:flex;align-items:center;justify-content:space-between}.drawer-title{font-size:16px;font-weight:650}.drawer-title small{display:block;color:var(--muted);font-size:12px;font-weight:400;margin-top:4px}.close{border:0;background:none;color:#89919d;font-size:24px;line-height:1}.drawer-body{padding:18px 22px;overflow:auto;flex:1}.logic-bar{display:flex;align-items:center;gap:8px;padding:10px 12px;background:#f7f9fc;border:1px solid #e8edf3;border-radius:7px;margin-bottom:14px}.logic-bar label{color:#667180;font-size:12px}.logic-bar select{height:30px;border:1px solid var(--border-strong);background:#fff;border-radius:5px;padding:0 28px 0 9px;outline:none}.logic-hint{margin-left:auto;color:var(--muted);font-size:12px}
.builder{display:flex;flex-direction:column;gap:9px}.condition-row{display:grid;grid-template-columns:minmax(185px,1.15fr) 130px minmax(150px,1fr) 28px;gap:8px;align-items:center}.condition-row select,.condition-row input,.field-picker{height:36px;border:1px solid var(--border-strong);border-radius:6px;background:#fff;padding:0 10px;outline:none;min-width:0}.condition-row select:focus,.condition-row input:focus,.field-picker:focus{border-color:var(--primary);box-shadow:0 0 0 2px #1677ff12}.field-picker{width:100%;font-weight:500;color:#303946;text-align:left;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;position:relative;padding-right:28px}.field-picker:after{content:'⌄';position:absolute;right:10px;color:#8792a0}.value-wrap{min-width:0}.value-input{width:100%}.row-values{display:flex;align-items:center;gap:7px}.row-values input{flex:1}.remove-row{height:30px;width:28px;border:0;background:transparent;color:#9aa3ae;font-size:19px;border-radius:4px}.remove-row:hover{background:#fff0f0;color:var(--danger)}.condition-group{border:1px solid #d8e5f5;border-left:3px solid #78aaf1;border-radius:7px;background:#fbfdff;padding:11px 12px}.group-head{display:flex;align-items:center;gap:8px;margin-bottom:9px;color:#607084;font-size:12px}.group-head strong{font-weight:600;color:#40536b}.group-head select{height:28px;border:1px solid var(--border-strong);border-radius:5px;background:#fff;padding:0 24px 0 8px}.group-head .remove-group{margin-left:auto;border:0;background:transparent;color:#8e98a5}.group-rows{display:flex;flex-direction:column;gap:8px}.group-add{margin-top:9px;border:0;background:transparent;color:var(--primary);padding:2px 0}.add-bar{display:flex;align-items:center;gap:16px;margin-top:14px;padding-top:14px;border-top:1px dashed #e0e5eb}.add-bar .btn{height:32px}.field-guide{margin-top:18px;padding:13px 14px;background:#fafbfc;border:1px solid #edf0f3;border-radius:7px}.field-guide-title{font-size:12px;color:#667180;margin-bottom:9px}.quick-fields{display:flex;flex-wrap:wrap;gap:7px}.quick-field{height:28px;border:1px solid #d8e0ea;background:#fff;border-radius:5px;color:#435164;padding:0 9px;font-size:12px}.quick-field:hover{border-color:var(--primary);color:var(--primary);background:var(--primary-soft)}.drawer-foot{padding:13px 22px;border-top:1px solid #edf0f3;text-align:right}.drawer-foot button{margin-left:8px}
.field-pop{display:none;position:fixed;z-index:40;width:300px;max-height:min(520px,calc(100vh - 24px));background:#fff;border:1px solid var(--border);border-radius:8px;box-shadow:var(--shadow);padding:10px}.field-pop.show{display:block}.field-search{width:100%;height:32px;border:1px solid var(--border-strong);border-radius:5px;padding:0 9px;outline:none;margin-bottom:8px}.field-options{max-height:min(455px,calc(100vh - 100px));overflow:auto}.field-section{font-size:11px;color:#8b95a3;padding:7px 7px 4px}.field-option{display:flex;align-items:center;gap:7px;width:100%;height:31px;border:0;background:#fff;text-align:left;padding:0 7px;border-radius:4px;color:#3c4755}.field-option:hover{background:#f3f7fc;color:var(--primary)}.field-option .path{color:#98a2ae;font-size:11px;margin-left:auto}.toast{position:fixed;left:50%;top:25px;transform:translateX(-50%);background:#20242b;color:#fff;padding:9px 16px;border-radius:6px;display:none;z-index:50}.toast.show{display:block}
@media(max-width:760px){.page{padding:12px}.search-line{flex-wrap:wrap}.search{max-width:none;flex-basis:100%}.drawer{width:100%}.condition-row{grid-template-columns:1fr 1fr}.condition-row .value-wrap{grid-column:1 / span 2}.condition-row .remove-row{grid-column:2;justify-self:end;margin-top:-38px}.logic-hint{display:none}.query-summary{align-items:flex-start}.summary-label{padding-top:6px}}
[v-cloak]{display:none}
::-webkit-scrollbar{width:9px;height:9px}
::-webkit-scrollbar-thumb{background:#cbd5e1;border-radius:6px}
::-webkit-scrollbar-thumb:hover{background:#94a3b8}
.mono{font-family:Consolas,Menlo,"Courier New",monospace}
</style>
</head>
<body>
<div class="page">
<div class="header"><div><div class="title">费用列表</div><div class="subtitle">费用单与账单、发票、收付款状态统一查询</div></div><button class="btn primary">+ 新增</button></div>
<div class="search-panel"><div class="search-line"><div class="search"><span class="icon">⌕</span><input id="keyword" placeholder="搜索费用单号、客户、摘要…"></div><button class="btn" id="filterBtn">高级查询 <span class="badge" id="filterBadge">4</span></button><button class="btn primary" id="searchBtn">查询</button><button class="btn" id="resetBtn">重置</button></div><div class="query-summary"><span class="summary-label">当前查询</span><div class="conditions" id="conditions"></div><button class="btn link-btn" id="editQuery">编辑</button></div></div>
<div class="table-panel"><div class="table-head"><span class="table-title">费用数据</span><div class="table-actions"><span class="muted" id="resultText">已应用 4 个条件 · 共 18 条</span><button class="btn">保存视图</button></div></div><table><thead><tr><th width="45">☐</th><th><span class="th-label">费用单号 <button class="th-filter" data-field="expense.no" title="按此列添加条件">⌄</button></span></th><th><span class="th-label">客户 <button class="th-filter" data-field="expense.customer" title="按此列添加条件">⌄</button></span></th><th><span class="th-label">费用类型 <button class="th-filter" data-field="expense.type" title="按此列添加条件">⌄</button></span></th><th><span class="th-label">金额 <button class="th-filter" data-field="expense.amount" title="按此列添加条件">⌄</button></span></th><th><span class="th-label">状态 <button class="th-filter" data-field="expense.status" title="按此列添加条件">⌄</button></span></th><th><span class="th-label">创建时间 <button class="th-filter" data-field="expense.createdAt" title="按此列添加条件">⌄</button></span></th><th>操作</th></tr></thead><tbody><tr><td>□</td><td>FY20260906001</td><td>上海东方物流有限公司</td><td>海运费</td><td>¥12,800.00</td><td><span class="status">待审核</span></td><td>2026-09-06</td><td><span class="link">审核</span><span class="link">详情</span></td></tr><tr><td>□</td><td>FY20260906002</td><td>深圳华南供应链</td><td>港杂费</td><td>¥8,620.00</td><td><span class="status">待审核</span></td><td>2026-09-05</td><td><span class="link">审核</span><span class="link">详情</span></td></tr><tr><td>□</td><td>FY20260906003</td><td>宁波海联国际</td><td>拖车费</td><td>¥4,200.00</td><td><span class="status">已审核</span></td><td>2026-09-05</td><td><span class="link">详情</span></td></tr><tr><td>□</td><td>FY20260906004</td><td>上海东方物流有限公司</td><td>报关费</td><td>¥16,300.00</td><td><span class="status">待审核</span></td><td>2026-09-04</td><td><span class="link">审核</span><span class="link">详情</span></td></tr></tbody></table></div>
<body class="bg-slate-100 font-sans text-[13px] text-slate-700 antialiased">
<div id="app" v-cloak>
<!-- 顶栏 -->
<header class="sticky top-0 z-30 border-b border-slate-200 bg-white/95 backdrop-blur">
<div class="flex h-14 items-center gap-4 px-5">
<div class="flex items-center gap-2.5">
<span class="grid h-8 w-8 place-items-center rounded-lg bg-teal-600 text-sm font-bold text-white">F</span>
<div class="leading-tight">
<div class="text-sm font-semibold text-slate-800">FMS 权限中心</div>
<div class="text-[11px] text-slate-400">一客户一库 · 权限点自动生成</div>
</div>
</div>
<div class="ml-3 flex rounded-lg bg-slate-100 p-0.5">
<button v-for="m in TABS" :key="m.k" @click="state.tab = m.k"
class="rounded-md px-3 py-1.5 text-[13px] transition"
:class="state.tab === m.k ? 'bg-white font-medium text-teal-700 shadow-sm' : 'text-slate-500 hover:text-slate-700'">
{{ m.n }}
</button>
</div>
<div class="ml-auto flex items-center gap-2 border-l border-slate-200 pl-4">
<span class="grid h-8 w-8 place-items-center rounded-full bg-teal-50 text-[12px] font-bold text-teal-700">IT</span>
<div class="leading-tight">
<div class="text-[13px] font-medium text-slate-700">系统管理员</div>
<div class="text-[11px] text-slate-400">客户库:DEMO-FWD</div>
</div>
</div>
</div>
</header>
<!-- ============ 权限分配 ============ -->
<div v-if="state.tab === 'grant'" class="mx-auto max-w-[1560px] space-y-4 px-5 py-5">
<!-- 授权对象(用户条) -->
<div class="rounded-xl border border-slate-200 bg-white p-3.5">
<div class="flex flex-wrap items-center gap-2">
<span class="mr-1 text-[12px] text-slate-400">授权对象</span>
<button v-for="u in USERS" :key="u.id" @click="state.userId = u.id"
class="flex items-center gap-1.5 rounded-lg border py-1 pl-1.5 pr-2.5 transition"
:class="state.userId === u.id ? 'border-teal-300 bg-teal-50 text-teal-700' : 'border-slate-200 bg-white text-slate-600 hover:bg-slate-50'">
<input type="checkbox" class="h-3 w-3 accent-teal-600" :checked="state.picked.indexOf(u.id) > -1" @click.stop @change="togglePick(u.id)">
<span class="grid h-6 w-6 place-items-center rounded-full text-[11px] font-bold"
:class="state.userId === u.id ? 'bg-teal-600 text-white' : 'bg-slate-100 text-slate-500'">{{ u.name.charAt(0) }}</span>
<span class="text-[13px]">{{ u.name }}</span>
<span class="text-[11px] text-slate-400">{{ u.title }}</span>
<span v-if="restrictCount(u.id)" class="rounded bg-amber-50 px-1 text-[10px] text-amber-700">{{ restrictCount(u.id) }}</span>
</button>
<div class="ml-auto flex items-center gap-2">
<select v-model="rolePick" class="h-8 rounded-lg border border-slate-200 bg-white px-2.5 text-[12px] outline-none focus:border-teal-500">
<option value="">应用角色模板…</option>
<option v-for="r in ROLES" :key="r.id" :value="r.id">{{ r.name }}</option>
</select>
<button class="h-8 rounded-lg bg-teal-600 px-3 text-[12px] font-medium text-white hover:bg-teal-500" @click="applyRole">应用到当前用户</button>
</div>
</div>
</div>
<div class="grid gap-5 xl:grid-cols-[230px_minmax(0,1fr)_330px]">
<!-- 左:模块树 -->
<aside class="xl:sticky xl:top-20 xl:self-start">
<div class="rounded-xl border border-slate-200 bg-white">
<div class="border-b border-slate-100 px-4 py-3">
<div class="text-[13px] font-semibold text-slate-800">模块树</div>
<div class="mt-0.5 text-[11px] text-slate-400">权限以模块为核心</div>
</div>
<div class="p-1.5">
<button class="mb-1 flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-[13px] transition"
:class="state.selected === '__all' ? 'bg-teal-50 text-teal-700' : 'text-slate-600 hover:bg-slate-50'"
@click="state.selected = '__all'">
<span class="text-[10px] text-slate-400">▦</span>全部模块(概览)
</button>
<div v-for="node in treeNodes" :key="node.id">
<div class="mb-0.5 flex items-center rounded-lg py-2 pr-3 transition"
:class="state.selected === node.id ? 'bg-teal-50 text-teal-700' : 'hover:bg-slate-50'"
:style="{ paddingLeft: (8 + node.level * 16) + 'px' }">
<button v-if="node.hasChild" class="w-4 text-[9px] text-slate-400 hover:text-slate-700" @click="toggleExpand(node.id)">{{ isExpand(node.id) ? '▼' : '▶' }}</button>
<span v-else class="w-4"></span>
<button class="flex min-w-0 flex-1 items-center gap-1.5 text-left" @click="state.selected = node.id">
<span class="truncate text-[13px]">{{ node.name }}</span>
<span v-if="node.isPage" class="rounded bg-indigo-50 px-1 text-[9px] text-indigo-600">page</span>
</button>
<span v-if="moduleTight(node.id)" class="rounded bg-amber-50 px-1 text-[9px] text-amber-700">{{ moduleTight(node.id) }}</span>
</div>
</div>
</div>
</div>
</aside>
<!-- 中:权限面板 -->
<div class="min-w-0 space-y-4">
<!-- 全部模块概览 -->
<div v-if="state.selected === '__all'" class="overflow-hidden rounded-xl border border-slate-200 bg-white">
<div class="border-b border-slate-100 px-5 py-3.5">
<h2 class="text-[14px] font-semibold text-slate-800">全部模块概览</h2>
<p class="mt-0.5 text-[11px] text-slate-400">{{ curUser.name }} · 点模块名进入详细配置</p>
</div>
<table class="w-full">
<thead>
<tr class="border-b border-slate-100 bg-slate-50/70 text-[11px] text-slate-500">
<th class="px-5 py-2.5 text-left font-medium">模块</th>
<th class="w-14 px-3 py-2.5 text-center font-medium">入口</th>
<th class="px-3 py-2.5 text-left font-medium">已授权动作</th>
<th class="w-[130px] px-3 py-2.5 text-left font-medium">数据范围</th>
</tr>
</thead>
<tbody>
<tr v-for="m in flatModules" :key="m.id" class="cursor-pointer border-b border-slate-50 last:border-0 hover:bg-slate-50/60" @click="state.selected = m.id">
<td class="px-5 py-2.5">
<div class="text-slate-700">{{ m.name }}</div>
<div class="mono text-[11px] text-slate-400">{{ m.id }}</div>
</td>
<td class="px-3 py-2.5 text-center" @click.stop>
<sw :on="hasEntry(m.id)" @toggle="toggleEntry(m.id)"></sw>
</td>
<td class="px-3 py-2.5">
<span v-if="!moduleActions(m.id).length" class="text-[11px] text-slate-300">未授权(默认拒绝)</span>
<span v-else class="flex flex-wrap gap-1">
<span v-for="a in moduleActions(m.id)" :key="a" class="rounded bg-teal-50 px-1.5 py-0.5 text-[10px] text-teal-700">{{ a }}</span>
</span>
</td>
<td class="px-3 py-2.5">
<span class="text-[12px]" :class="scopeOf(m.id) === 'all' ? 'text-slate-300' : 'text-teal-700'">{{ scopeName(scopeOf(m.id)) }}</span>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 单模块配置 -->
<div v-else class="rounded-xl border border-slate-200 bg-white">
<div class="flex items-center gap-3 border-b border-slate-100 px-5 py-3.5">
<div>
<h2 class="text-[14px] font-semibold text-slate-800">{{ selName }}</h2>
<p class="mono mt-0.5 text-[11px] text-slate-400">{{ state.selected }}</p>
</div>
<span v-if="selIsPage" class="rounded bg-indigo-50 px-2 py-0.5 text-[11px] text-indigo-600">page 节点</span>
<span class="ml-auto text-[11px] text-slate-400">{{ curUser.name }} · 全部默认拒绝,勾选即授权</span>
</div>
<!-- 入口 -->
<div class="flex items-center gap-3 border-b border-slate-100 px-5 py-3">
<div class="w-16 shrink-0 text-[13px] text-slate-600">入口</div>
<sw :on="hasEntry(state.selected)" @toggle="toggleEntry(state.selected)"></sw>
<span class="text-[12px] text-slate-500">{{ hasEntry(state.selected) ? '已开通' : '未开通' }}</span>
<span class="mono text-[11px] text-slate-400">{{ entryCode }}</span>
</div>
<!-- 动作 -->
<div v-if="!selIsPage" class="flex items-start gap-3 border-b border-slate-100 px-5 py-3">
<div class="w-16 shrink-0 pt-1 text-[13px] text-slate-600">动作</div>
<div class="flex flex-1 flex-wrap gap-1.5">
<button v-for="a in selActions" :key="a.k"
class="rounded-md border px-2.5 py-1 text-[12px] transition"
:class="hasAction(state.selected, a.k) ? 'border-teal-200 bg-teal-50 text-teal-700' : 'border-slate-200 bg-white text-slate-400 hover:bg-slate-50'"
@click="toggleAction(state.selected, a.k)">{{ a.n }}</button>
</div>
</div>
<div v-else class="border-b border-slate-100 px-5 py-3 text-[11px] text-slate-400">页面节点只配入口,具体操作在下属模块上配置</div>
<!-- 数据范围 -->
<div v-if="!selIsPage" class="flex items-center gap-3 border-b border-slate-100 px-5 py-3">
<div class="w-16 shrink-0 text-[13px] text-slate-600">数据范围</div>
<select :value="scopeOf(state.selected)" @change="setScope(state.selected, $event.target.value)"
class="h-8 w-56 rounded-md border border-slate-200 bg-white px-2 text-[12px] outline-none focus:border-teal-500">
<option v-for="s in SCOPES" :key="s.v" :value="s.v">{{ s.n }}</option>
</select>
<span class="text-[11px] text-slate-400">未配置视为全部数据</span>
</div>
<!-- 敏感字段 -->
<div v-if="!selIsPage" class="flex items-start gap-3 px-5 py-3">
<div class="w-16 shrink-0 pt-1 text-[13px] text-slate-600">敏感字段</div>
<div class="flex-1">
<div v-if="!selFields.length" class="text-[11px] text-slate-400">该模块没有标记敏感字段,无需配置字段权限</div>
<div v-else class="space-y-1.5">
<div v-for="f in selFields" :key="f.n" class="flex items-center gap-3 rounded-md bg-slate-50 px-3 py-1.5">
<span class="w-36 shrink-0 text-slate-700">{{ f.l }}<span class="mono ml-1 text-[10px] text-slate-400">{{ f.n }}</span></span>
<select :value="fieldMode(state.selected, f.n)" @change="setField(state.selected, f.n, $event.target.value)"
class="h-7 w-32 rounded-md border border-slate-200 bg-white px-1.5 text-[12px] outline-none focus:border-teal-500">
<option value="default">跟随默认(可编辑)</option>
<option value="hidden">hidden · 隐藏</option>
<option value="view">view · 只读</option>
</select>
<label class="flex items-center gap-1 text-[11px] text-slate-500">
<input type="checkbox" class="h-3.5 w-3.5 accent-teal-600" :disabled="fieldMode(state.selected, f.n) === 'hidden'" :checked="fieldQuery(state.selected, f.n)" @change="setFieldFlag(state.selected, f.n, 'query', $event.target.checked)">可查询
</label>
<label class="flex items-center gap-1 text-[11px] text-slate-500">
<input type="checkbox" class="h-3.5 w-3.5 accent-teal-600" :disabled="fieldMode(state.selected, f.n) === 'hidden'" :checked="fieldExport(state.selected, f.n)" @change="setFieldFlag(state.selected, f.n, 'export', $event.target.checked)">可导出
</label>
<span v-if="fieldMode(state.selected, f.n) !== 'default'" class="ml-auto rounded bg-amber-50 px-1.5 py-0.5 text-[10px] text-amber-700">已收紧</span>
<span v-else class="ml-auto text-[10px] text-slate-300">默认</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 右:生效结果 -->
<aside class="space-y-4 xl:sticky xl:top-20 xl:self-start">
<div class="rounded-xl border border-slate-200 bg-white">
<div class="border-b border-slate-100 px-4 py-3">
<div class="text-[13px] font-semibold text-slate-800">当前生效结果</div>
<div class="mt-0.5 text-[11px] text-slate-400">{{ curUser.name }} · 全模块汇总</div>
</div>
<div class="divide-y divide-slate-100 px-4">
<div class="flex items-center gap-3 py-2.5">
<div class="min-w-0 flex-1">
<div class="text-slate-700">可进入的入口</div>
<div class="text-[11px] text-slate-400">菜单 / 页面,默认拒绝</div>
</div>
<span class="rounded bg-emerald-50 px-1.5 py-0.5 text-[10px] text-emerald-600">{{ entryCount }} 个</span>
</div>
<div class="flex items-center gap-3 py-2.5">
<div class="min-w-0 flex-1">
<div class="text-slate-700">已授权动作</div>
<div class="text-[11px] text-slate-400">默认拒绝,勾选才生效</div>
</div>
<span class="rounded bg-teal-50 px-1.5 py-0.5 text-[10px] text-teal-700">{{ actionCount }} 个</span>
</div>
<div class="flex items-center gap-3 py-2.5">
<div class="min-w-0 flex-1">
<div class="text-slate-700">收紧的字段</div>
<div class="text-[11px] text-slate-400">只显示敏感字段的例外</div>
</div>
<span class="rounded px-1.5 py-0.5 text-[10px]" :class="fieldCount ? 'bg-amber-50 text-amber-700' : 'bg-emerald-50 text-emerald-600'">
{{ fieldCount ? fieldCount + ' 项' : '跟随默认' }}
</span>
</div>
</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white">
<div class="border-b border-slate-100 px-4 py-3">
<div class="text-[13px] font-semibold text-slate-800">后端最终 SQL</div>
<div class="mt-0.5 text-[11px] text-slate-400">字段裁剪与行条件由后端落实</div>
</div>
<div class="mono whitespace-pre-wrap break-all rounded-b-xl bg-slate-900 px-4 py-3 text-[11px] leading-relaxed text-slate-300" v-html="sqlHtml"></div>
</div>
<div class="rounded-xl border border-slate-200 bg-white">
<div class="border-b border-slate-100 px-4 py-3">
<div class="text-[13px] font-semibold text-slate-800">将写入的表记录</div>
<div class="mt-0.5 text-[11px] text-slate-400">只存例外,没配的不落库</div>
</div>
<div class="max-h-72 space-y-2 overflow-auto p-3">
<div v-for="(r, i) in previewRows" :key="i" class="rounded-lg bg-slate-50 p-2.5">
<div class="mono text-[10px] text-teal-700">{{ r.table }}</div>
<div class="mono mt-1 break-all text-[11px] text-slate-500">{{ r.text }}</div>
</div>
<div v-if="!previewRows.length" class="px-1 py-6 text-center text-[12px] text-slate-400">当前用户没有任何授权记录</div>
</div>
</div>
</aside>
</div>
</div>
<!-- ============ IT 资源配置 ============ -->
<div v-else class="mx-auto max-w-[1400px] space-y-5 px-5 py-5">
<div class="rounded-xl bg-gradient-to-r from-slate-800 to-slate-700 p-5 text-white">
<div class="text-[11px] font-semibold tracking-wider text-teal-300">IT ADMINISTRATION</div>
<h1 class="mt-1 text-lg font-semibold">资源配置(客户不可见)</h1>
<p class="mt-1 text-[12px] text-slate-300">IT 定义「能分配什么」。客户只做分配,不碰元数据,也不能新建业务动作(动作需要后端代码)。</p>
<div class="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-4">
<div class="rounded-lg bg-white/10 px-3 py-2.5"><div class="text-[11px] text-slate-300">模块 / 页面</div><div class="mt-0.5 text-xl font-semibold">{{ nodeCount }}</div></div>
<div class="rounded-lg bg-white/10 px-3 py-2.5"><div class="text-[11px] text-slate-300">业务动作</div><div class="mt-0.5 text-xl font-semibold">{{ actionDefCount }}</div></div>
<div class="rounded-lg bg-white/10 px-3 py-2.5"><div class="text-[11px] text-slate-300">敏感字段</div><div class="mt-0.5 text-xl font-semibold">{{ sensitiveCount }}</div></div>
<div class="rounded-lg bg-white/10 px-3 py-2.5"><div class="text-[11px] text-slate-300">权限点(自动生成)</div><div class="mt-0.5 text-xl font-semibold">{{ powerCodes.length }}</div></div>
</div>
</div>
<div class="grid gap-5 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<!-- 模块与敏感字段 -->
<section class="rounded-xl border border-slate-200 bg-white">
<div class="border-b border-slate-100 px-5 py-3.5">
<h2 class="text-[14px] font-semibold text-slate-800">模块与敏感字段标记</h2>
<p class="mt-0.5 text-[11px] text-slate-400">只有标为敏感的字段才进入客户授权界面,其余字段不占配置量</p>
</div>
<div class="max-h-[420px] overflow-auto">
<table class="w-full">
<thead class="sticky top-0 bg-slate-50/95 backdrop-blur">
<tr class="border-b border-slate-100 text-[11px] text-slate-500">
<th class="px-5 py-2.5 text-left font-medium">模块</th>
<th class="px-3 py-2.5 text-left font-medium">字段</th>
<th class="w-20 px-3 py-2.5 text-center font-medium">敏感</th>
</tr>
</thead>
<tbody>
<template v-for="m in flatModules" :key="m.id">
<tr v-for="(f, i) in m.fields" :key="f.n" class="border-b border-slate-50 hover:bg-slate-50/60">
<td class="px-5 py-1.5 text-slate-700">{{ i === 0 ? m.name : '' }}</td>
<td class="px-3 py-1.5">
<span class="text-slate-700">{{ f.l }}</span>
<span class="mono ml-1.5 text-[10px] text-slate-400">{{ f.n }}</span>
</td>
<td class="px-3 py-1.5 text-center">
<sw :on="!!f.s" @toggle="f.s = f.s ? 0 : 1"></sw>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</section>
<!-- 权限点 -->
<section class="rounded-xl border border-slate-200 bg-white">
<div class="flex flex-wrap items-center gap-3 border-b border-slate-100 px-5 py-3.5">
<div>
<h2 class="text-[14px] font-semibold text-slate-800">权限点清单</h2>
<p class="mt-0.5 text-[11px] text-slate-400">随元数据自动生成,只增不删、不覆盖已有配置</p>
</div>
<div class="ml-auto flex items-center gap-2">
<button v-for="g in POWER_GROUPS" :key="g.k" @click="powerGroup = g.k"
class="rounded-md px-2.5 py-1 text-[12px] transition"
:class="powerGroup === g.k ? 'bg-slate-800 text-white' : 'text-slate-500 hover:bg-slate-100'">{{ g.n }}</button>
</div>
</div>
<div class="max-h-[420px] overflow-auto">
<table class="w-full">
<thead class="sticky top-0 bg-slate-50/95 backdrop-blur">
<tr class="border-b border-slate-100 text-[11px] text-slate-500">
<th class="px-5 py-2.5 text-left font-medium">权限点编码</th>
<th class="px-3 py-2.5 text-left font-medium">名称</th>
<th class="w-24 px-3 py-2.5 text-left font-medium">默认</th>
</tr>
</thead>
<tbody>
<tr v-for="p in filteredPowers" :key="p.code" class="border-b border-slate-50 hover:bg-slate-50/60">
<td class="mono px-5 py-1.5 text-[11px] text-slate-600">{{ p.code }}</td>
<td class="px-3 py-1.5 text-slate-700">{{ p.name }}</td>
<td class="px-3 py-1.5 text-[11px] text-rose-600">拒绝</td>
</tr>
</tbody>
</table>
</div>
<div class="border-t border-slate-100 bg-slate-50/60 px-5 py-3 text-[11px] leading-relaxed text-slate-500">
业务动作(如 <code class="mono text-slate-700">module.cw_fee.audit</code>)由开发注册代码后生成,客户界面只能勾选、不能新建。
</div>
</section>
</div>
</div>
<!-- 批量条 + 变更条 + toast -->
<!-- 批量 + 变更 -->
<div v-if="state.picked.length || diffList.length" class="fixed bottom-5 left-1/2 z-40 flex -translate-x-1/2 flex-col items-center gap-2">
<div v-if="state.picked.length" class="flex items-center gap-3 rounded-full bg-slate-800 py-2 pl-4 pr-2 text-[13px] text-white shadow-2xl">
<span>已选 <b class="text-teal-300">{{ state.picked.length }}</b> 人</span>
<select v-model="batchRole" class="h-7 rounded-full border-0 bg-slate-700 px-2.5 text-[12px] text-white outline-none">
<option value="">选择角色模板…</option>
<option v-for="r in ROLES" :key="r.id" :value="r.id">{{ r.name }}</option>
</select>
<button class="rounded-full bg-teal-500 px-3.5 py-1 font-medium hover:bg-teal-400" @click="applyRoleBatch">批量应用</button>
<button class="rounded-full px-3 py-1 text-slate-300 hover:text-white" @click="state.picked = []">取消</button>
</div>
<div v-if="diffList.length" class="flex items-center gap-3 rounded-full bg-slate-900 py-2 pl-4 pr-2 text-[13px] text-white shadow-2xl ring-1 ring-slate-700">
<span>有 <b class="text-teal-300">{{ diffList.length }}</b> 项未保存变更</span>
<button class="rounded-full px-3 py-1 text-slate-300 hover:text-white" @click="showDiff = true">查看变更</button>
<button class="rounded-full px-3 py-1 text-slate-300 hover:text-white" @click="discard">放弃</button>
<button class="rounded-full bg-teal-500 px-4 py-1.5 font-medium hover:bg-teal-400" @click="save">保存</button>
</div>
</div>
<!-- 变更预览 -->
<div v-if="showDiff" class="fixed inset-0 z-50 grid place-items-center bg-slate-900/45 p-6" @click.self="showDiff = false">
<div class="max-h-[80vh] w-full max-w-3xl overflow-hidden rounded-2xl bg-white shadow-2xl">
<div class="flex items-center gap-3 border-b border-slate-100 px-5 py-4">
<div>
<div class="text-[15px] font-semibold text-slate-800">保存前确认</div>
<div class="mt-0.5 text-[12px] text-slate-400">共 {{ diffList.length }} 项变更,保存后立即生效</div>
</div>
<button class="ml-auto text-slate-400 hover:text-slate-600" @click="showDiff = false">&#10005;</button>
</div>
<div class="max-h-[52vh] overflow-auto">
<table class="w-full">
<thead class="sticky top-0 bg-slate-50/95 backdrop-blur">
<tr class="border-b border-slate-100 text-[11px] text-slate-500">
<th class="px-5 py-2.5 text-left font-medium">用户</th>
<th class="px-3 py-2.5 text-left font-medium">类别</th>
<th class="px-3 py-2.5 text-left font-medium">对象</th>
<th class="px-3 py-2.5 text-left font-medium">变更前</th>
<th class="px-3 py-2.5 text-left font-medium">变更后</th>
</tr>
</thead>
<tbody>
<tr v-for="(d, i) in diffList" :key="i" class="border-b border-slate-50">
<td class="px-5 py-2 text-slate-700">{{ d.user }}</td>
<td class="px-3 py-2 text-slate-500">{{ d.type }}</td>
<td class="mono px-3 py-2 text-[11px] text-slate-600">{{ d.target }}</td>
<td class="px-3 py-2 text-[12px] text-slate-400">{{ d.from }}</td>
<td class="px-3 py-2 text-[12px] text-teal-700">{{ d.to }}</td>
</tr>
</tbody>
</table>
</div>
<div class="flex items-center gap-2 border-t border-slate-100 px-5 py-3.5">
<button class="ml-auto rounded-lg border border-slate-200 px-4 py-2 text-[13px] text-slate-600 hover:bg-slate-50" @click="showDiff = false">继续编辑</button>
<button class="rounded-lg bg-teal-600 px-4 py-2 text-[13px] font-medium text-white hover:bg-teal-500" @click="save">确认保存</button>
</div>
</div>
</div>
<div class="pointer-events-none fixed left-1/2 top-20 z-[60] -translate-x-1/2 rounded-lg bg-slate-900 px-4 py-2 text-[13px] text-white shadow-xl transition-all"
:class="toastMsg ? 'translate-y-0 opacity-100' : '-translate-y-2 opacity-0'">{{ toastMsg }}</div>
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<script>
const { createApp, reactive, ref, computed } = Vue;
const deep = o => JSON.parse(JSON.stringify(o));
const TABS = [{ k: 'grant', n: '权限分配' }, { k: 'it', n: 'IT 资源配置' }];
const POWER_GROUPS = [{ k: 'all', n: '全部' }, { k: 'entry', n: '入口' }, { k: 'action', n: '动作' }];
const BASE_OPS = [{ k: 'read', n: '查看' }, { k: 'create', n: '新增' }, { k: 'update', n: '修改' }, { k: 'delete', n: '删除' }, { k: 'export', n: '导出' }];
const SCOPES = [
{ v: 'all', n: '全部数据' }, { v: 'own', n: '仅本人' }, { v: 'department', n: '本部门' },
{ v: 'department_tree', n: '本部门及下属' }, { v: 'custom', n: '自定义(选组织节点)' }
];
// 模块树:page 节点只配入口,module 节点配操作;fields 只列敏感字段
const TREE = [
{
id: 'sea_detail', name: '海运详细页', isPage: true, hasEntryPower: true, children: [
{ id: 'sea_main', name: '海运主表', actions: [{ k: 'audit', n: '审核' }, { k: 'close', n: '关闭' }],
fields: [{ n: 'salesman_id', l: '业务员', s: 0 }] },
{ id: 'container_main', name: '装箱主表', actions: [],
fields: [{ n: 'gross_weight', l: '毛重', s: 0 }] }
]
},
{
id: 'cw_fee', name: '费用管理', actions: [
{ k: 'audit', n: '审核' }, { k: 'unaudit', n: '反审核' }, { k: 'settle', n: '核销' }, { k: 'cancel_settle', n: '取消核销' }
],
fields: [
{ n: 'amount', l: '金额', s: 1 }, { n: 'internal_cost', l: '内部成本', s: 1 },
{ n: 'gross_profit', l: '毛利', s: 1 }, { n: 'total_amount', l: '价税合计', s: 0 }
]
},
{
id: 'invoice_main', name: '发票管理', actions: [{ k: 'void', n: '作废' }],
fields: [{ n: 'amount', l: '金额', s: 1 }, { n: 'tax_amount', l: '税额', s: 1 }]
},
{
id: 'customer', name: '客户档案', actions: [],
fields: [{ n: 'credit_limit', l: '信用额度', s: 1 }, { n: 'balance', l: '应收余额', s: 1 }, { n: 'cust_name', l: '客户名称', s: 0 }]
}
];
const USERS = [
{ id: 'zhangsan', name: '张三', title: '费用会计', dept: '财务部' },
{ id: 'lisi', name: '李四', title: '财务经理', dept: '财务部' },
{ id: 'wangwu', name: '王五', title: '海运操作', dept: '操作部' },
{ id: 'zhaoliu', name: '赵六', title: '客服专员', dept: '客服部' },
{ id: 'sunqi', name: '孙七', title: '销售代表', dept: '销售部' },
{ id: 'zhouba', name: '周八', title: '销售经理', dept: '销售部' }
];
// 权限就是权限:不区分基础操作与业务动作,全部默认拒绝,勾选即授权
function acts(mid, list) {
return list.map(function (k) { return 'module.' + mid + '.' + k });
}
const ROLES = [
{
id: 'fee_clerk', name: '费用会计', grants: {
entry: ['cw_fee'],
actions: acts('cw_fee', ['read', 'create', 'update', 'export', 'audit']),
field: { cw_fee: { amount: { mode: 'view', query: 1, export: 0 }, internal_cost: { mode: 'hidden', query: 0, export: 0 }, gross_profit: { mode: 'hidden', query: 0, export: 0 } } },
scope: { cw_fee: 'department' }
}
},
{
id: 'fee_mgr', name: '财务经理', grants: {
entry: ['cw_fee', 'invoice_main'],
actions: acts('cw_fee', ['read', 'create', 'update', 'delete', 'export', 'audit', 'unaudit', 'settle', 'cancel_settle'])
.concat(acts('invoice_main', ['read', 'create', 'update', 'delete', 'export', 'void'])),
field: { cw_fee: { internal_cost: { mode: 'view', query: 1, export: 0 } } },
scope: { cw_fee: 'department_tree', invoice_main: 'department_tree' }
}
},
{
id: 'operator', name: '海运操作', grants: {
entry: ['sea_detail', 'sea_main', 'container_main'],
actions: acts('sea_main', ['read', 'create', 'update', 'export', 'audit', 'close'])
.concat(acts('container_main', ['read', 'create', 'update', 'delete', 'export'])),
field: {}, scope: { sea_main: 'own', container_main: 'department' }
}
},
{
id: 'readonly', name: '只读查询', grants: {
entry: ['cw_fee', 'customer'],
actions: acts('cw_fee', ['read', 'export']).concat(acts('customer', ['read', 'export'])),
field: { cw_fee: { internal_cost: { mode: 'hidden', query: 0, export: 0 }, gross_profit: { mode: 'hidden', query: 0, export: 0 } } },
scope: { cw_fee: 'department', customer: 'department' }
}
}
];
// 初始授权:周八为空,演示「默认拒绝 = 什么都没有」
const INIT = {
zhangsan: {
entry: ['cw_fee'],
actions: acts('cw_fee', ['read', 'create', 'update', 'export', 'audit']),
field: { cw_fee: { amount: { mode: 'view', query: 1, export: 0 }, internal_cost: { mode: 'hidden', query: 0, export: 0 }, gross_profit: { mode: 'hidden', query: 0, export: 0 } } },
scope: { cw_fee: 'department' }
},
lisi: {
entry: ['cw_fee', 'invoice_main'],
actions: acts('cw_fee', ['read', 'create', 'update', 'delete', 'export', 'audit', 'unaudit', 'settle'])
.concat(acts('invoice_main', ['read', 'create', 'update', 'export', 'void'])),
field: { cw_fee: { internal_cost: { mode: 'view', query: 1, export: 0 } } },
scope: { cw_fee: 'department_tree', invoice_main: 'department_tree' }
},
wangwu: {
entry: ['sea_detail', 'sea_main', 'container_main'],
actions: acts('sea_main', ['read', 'create', 'update', 'export', 'audit'])
.concat(acts('container_main', ['read', 'create', 'update', 'export'])),
field: {}, scope: { sea_main: 'own', container_main: 'department' }
},
zhaoliu: { entry: ['customer'], actions: acts('customer', ['read', 'update']), field: {}, scope: {} },
sunqi: { entry: ['customer'], actions: acts('customer', ['read']), field: {}, scope: { customer: 'own' } },
zhouba: { entry: [], actions: [], field: {}, scope: {} }
};
const Sw = {
props: { on: Boolean },
template: '<button type="button" @click="$emit(\'toggle\')" class="relative inline-flex h-4 w-8 shrink-0 items-center rounded-full transition" :class="on ? \'bg-teal-600\' : \'bg-slate-300\'"><span class="inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition" :class="on ? \'translate-x-4\' : \'translate-x-0.5\'"></span></button>'
};
const emptyGrants = () => ({ entry: [], actions: [], field: {}, scope: {} });
const scopeName = v => (SCOPES.filter(s => s.v === v)[0] || SCOPES[0]).n;
function allActions(m) {
return BASE_OPS.map(function (op) { return { k: op.k, n: op.n } }).concat(m.actions || []);
}
function moduleFields(id) {
for (let i = 0; i < TREE.length; i++) {
const n = TREE[i];
if (n.id === id) return n.fields || [];
if (n.children) for (let j = 0; j < n.children.length; j++) if (n.children[j].id === id) return n.children[j].fields || [];
}
return [];
}
const app = createApp({
components: { sw: Sw },
setup: function () {
const state = reactive({
tab: 'grant', userId: 'zhangsan', selected: 'cw_fee', picked: [], expand: { sea_detail: true, cw_fee: true },
grants: {}, saved: {}, roleMap: {}
});
USERS.forEach(function (u) {
state.grants[u.id] = Object.assign(emptyGrants(), deep(INIT[u.id] || {}));
});
state.saved = deep(state.grants);
const userQ = ref(''), rolePick = ref(''), batchRole = ref(''), powerGroup = ref('all');
const showDiff = ref(false), toastMsg = ref('');
let timer = null;
function toast(m) { toastMsg.value = m; clearTimeout(timer); timer = setTimeout(function () { toastMsg.value = '' }, 2200) }
const curUser = computed(function () { return USERS.filter(function (u) { return u.id === state.userId })[0] || USERS[0] });
const g = function () { return state.grants[state.userId] };
/* ---------- 用户列表 ---------- */
const filteredUsers = computed(function () {
const q = userQ.value.trim().toLowerCase();
if (!q) return USERS;
return USERS.filter(function (u) { return (u.id + u.name + u.title + u.dept).toLowerCase().indexOf(q) > -1 });
});
function togglePick(id) { const i = state.picked.indexOf(id); if (i > -1) state.picked.splice(i, 1); else state.picked.push(id) }
function restrictCount(uid) {
const u = state.grants[uid];
let n = u.actions.length + Object.keys(u.scope).length;
Object.keys(u.field).forEach(function (mid) { n += Object.keys(u.field[mid]).length });
return n;
}
function roleName(id) { const r = ROLES.filter(function (x) { return x.id === id })[0]; return r ? r.name : '' }
/* ---------- 模块树 ---------- */
function isExpand(id) { return !!state.expand[id] }
function toggleExpand(id) { state.expand[id] = !state.expand[id] }
const flatModules = computed(function () {
const out = [];
TREE.forEach(function (n) {
if (n.isPage) n.children.forEach(function (c) { out.push(c) });
else out.push(n);
});
return out;
});
const treeNodes = computed(function () {
const out = [];
TREE.forEach(function (n) {
out.push({ id: n.id, name: n.name, isPage: !!n.isPage, level: 0, hasChild: !!(n.children && n.children.length) });
if (isExpand(n.id) && n.children) {
n.children.forEach(function (c) { out.push({ id: c.id, name: c.name, isPage: false, level: 1, hasChild: false }) });
}
});
return out;
});
function nodeOf(id) {
for (let i = 0; i < TREE.length; i++) {
const n = TREE[i];
if (n.id === id) return n;
if (n.children) for (let j = 0; j < n.children.length; j++) if (n.children[j].id === id) return n.children[j];
}
return null;
}
const selName = computed(function () { const n = nodeOf(state.selected); return n ? n.name : '' });
const selIsPage = computed(function () { const n = nodeOf(state.selected); return !!(n && n.isPage) });
const selActions = computed(function () { const n = nodeOf(state.selected); return n && !n.isPage ? allActions(n) : [] });
const selFields = computed(function () { const n = nodeOf(state.selected); return n && n.fields ? n.fields.filter(function (f) { return f.s }) : [] });
const entryCode = computed(function () { return (selIsPage.value ? 'page.' : 'menu.') + state.selected + '.access' });
function moduleActions(mid) {
const out = [];
allActions(nodeOf(mid) || { actions: [] }).forEach(function (a) { if (hasAction(mid, a.k)) out.push(a.n) });
return out;
}
function moduleTight(mid) {
let n = moduleActions(mid).length;
if (g().scope[mid]) n++;
const fm = g().field[mid] || {};
Object.keys(fm).forEach(function (f) { if (fm[f].mode) n++ });
return n;
}
/* ---------- 入口 ---------- */
function hasEntry(id) { return g().entry.indexOf(id) > -1 }
function toggleEntry(id) {
const a = g().entry, i = a.indexOf(id);
if (i > -1) { a.splice(i, 1); toast('已移除入口:' + id) } else { a.push(id); toast('已开通入口:' + id) }
}
/* ---------- 动作(不区分基础与业务,统一默认拒绝,勾选即授权) ---------- */
function hasAction(mid, k) { return g().actions.indexOf('module.' + mid + '.' + k) > -1 }
function toggleAction(mid, k) {
const code = 'module.' + mid + '.' + k, a = g().actions, i = a.indexOf(code);
if (i > -1) { a.splice(i, 1); toast('已取消授权:' + code) } else { a.push(code); toast('已授权:' + code) }
}
/* ---------- 数据范围 ---------- */
function scopeOf(mid) { const v = g().scope[mid]; return v || 'all' }
function setScope(mid, v) {
if (v === 'all') delete g().scope[mid]; else g().scope[mid] = v;
toast('数据范围已更新:' + scopeName(v));
}
/* ---------- 字段(只处理敏感字段) ---------- */
function policyOf(mid, fn) {
const mm = g().field[mid] || {};
return mm[fn] || null;
}
function fieldMode(mid, fn) { const p = policyOf(mid, fn); return p && p.mode ? p.mode : 'default' }
function fieldQuery(mid, fn) { const p = policyOf(mid, fn); return p ? !!p.query : true }
function fieldExport(mid, fn) { const p = policyOf(mid, fn); return p ? !!p.export : true }
function setField(mid, fn, mode) {
g().field[mid] = g().field[mid] || {};
if (mode === 'default') { delete g().field[mid][fn]; toast('已恢复模块默认'); return }
const r = g().field[mid][fn] || { mode: mode, query: true, export: true };
r.mode = mode;
if (mode === 'hidden') { r.query = false; r.export = false }
g().field[mid][fn] = r;
toast('字段策略已更新');
}
function setFieldFlag(mid, fn, key, on) {
g().field[mid] = g().field[mid] || {};
const r = g().field[mid][fn] || { mode: 'view', query: true, export: true };
if (!r.mode) r.mode = 'view';
r[key] = on;
g().field[mid][fn] = r;
toast('字段能力已更新');
}
/* ---------- 统计 ---------- */
const entryCount = computed(function () { return g().entry.length });
const actionCount = computed(function () { return g().actions.length });
const fieldCount = computed(function () {
let n = 0;
Object.keys(g().field).forEach(function (mid) { Object.keys(g().field[mid]).forEach(function (f) { if (g().field[mid][f].mode) n++ }) });
return n;
});
/* ---------- SQL 预览 ---------- */
const sqlHtml = computed(function () {
const sc = g().scope, fm = g().field;
const mid = state.selected !== '__all' ? state.selected : (Object.keys(sc)[0] || Object.keys(fm)[0] || 'cw_fee');
const fields = moduleFields(mid);
const hidden = fields.filter(function (f) { return fieldMode(mid, f.n) === 'hidden' }).map(function (f) { return f.n });
const cols = fields.filter(function (f) { return hidden.indexOf(f.n) < 0 }).map(function (f) { return f.n });
const v = scopeOf(mid);
let cond = '';
if (v === 'own') cond = 'inputuser_id = @current_user';
else if (v === 'department') cond = 'input_dept_id = @current_dept';
else if (v === 'department_tree') cond = 'input_dept_id in (@current_dept_tree)';
else if (v === 'custom') cond = 'input_dept_id in (@selected_orgs)';
let s = '<span class="text-teal-300">select</span> ' + (cols.length ? cols.join(', ') : '*') +
'\n<span class="text-teal-300">from</span> ' + mid +
'\n<span class="text-teal-300">where</span> 1 = 1';
if (cond) s += '\n <span class="text-teal-300">and</span> ' + cond;
if (hidden.length) s += '\n\n<span class="text-slate-500">-- 已裁剪字段:' + hidden.join(', ') + '</span>';
return s;
});
/* ---------- 写入记录 ---------- */
const previewRows = computed(function () {
const uid = state.userId, u = g(), out = [];
u.entry.forEach(function (id) {
const node = TREE.filter(function (n) { return n.id === id })[0];
const code = (node && node.isPage) ? 'page.' + id + '.access' : 'menu.' + id + '.access';
out.push({ table: 's_user_power', text: uid + ' · ' + code + ' · b_canuse = 1' });
});
u.actions.forEach(function (c) { out.push({ table: 's_user_power', text: uid + ' · ' + c + ' · b_canuse = 1' }) });
Object.keys(u.field).forEach(function (mid) {
Object.keys(u.field[mid]).forEach(function (f) {
const v = u.field[mid][f];
if (!v.mode) return;
out.push({ table: 's_user_field_permission', text: uid + ' / ' + mid + ' / ' + f + ' · mode = ' + v.mode + ' · query = ' + (v.query ? 1 : 0) + ' · export = ' + (v.export ? 1 : 0) });
});
});
Object.keys(u.scope).forEach(function (mid) {
out.push({ table: 's_user_data_scope', text: uid + ' / ' + mid + ' / * / default / ' + u.scope[mid] });
});
return out;
});
/* ---------- 变更 ---------- */
function norm(src) {
const m = {};
Object.keys(src).forEach(function (uid) {
const u = src[uid];
(u.entry || []).forEach(function (id) { m[uid + '|入口|' + id] = '已开通' });
(u.actions || []).forEach(function (c) { m[uid + '|动作|' + c] = '已授权' });
Object.keys(u.field || {}).forEach(function (mid) {
Object.keys(u.field[mid]).forEach(function (f) {
const v = u.field[mid][f];
m[uid + '|字段|' + mid + '.' + f] = v.mode + ' / 查' + (v.query ? 1 : 0) + ' / 导' + (v.export ? 1 : 0);
});
});
Object.keys(u.scope || {}).forEach(function (mid) { m[uid + '|数据范围|' + mid] = scopeName(u.scope[mid]) });
});
return m;
}
const diffList = computed(function () {
const a = norm(state.saved), b = norm(state.grants), out = [];
const keys = Object.keys(a).concat(Object.keys(b).filter(function (k) { return !(k in a) }));
keys.forEach(function (k) {
if (a[k] === b[k]) return;
const seg = k.split('|');
const u = USERS.filter(function (x) { return x.id === seg[0] })[0];
out.push({ user: u ? u.name : seg[0], type: seg[1], target: seg[2], from: a[k] || '(默认)', to: b[k] || '(默认)' });
});
return out;
});
function save() {
const n = diffList.value.length;
state.saved = deep(state.grants);
showDiff.value = false;
toast('授权已保存,共 ' + n + ' 项变更');
}
function discard() { state.grants = deep(state.saved); toast('已放弃未保存的变更') }
/* ---------- 角色 ---------- */
function applyRoleTo(uid) {
const r = ROLES.filter(function (x) { return x.id === rolePick.value })[0];
if (!r) { toast('请先选择角色模板'); return }
state.grants[uid] = Object.assign(emptyGrants(), deep(r.grants));
state.roleMap[uid] = r.id;
}
function applyRole() { applyRoleTo(state.userId); toast('已应用角色模板到 ' + curUser.value.name) }
function applyRoleBatch() {
const r = ROLES.filter(function (x) { return x.id === batchRole.value })[0];
if (!r) { toast('请先选择角色模板'); return }
state.picked.forEach(function (uid) {
state.grants[uid] = Object.assign(emptyGrants(), deep(r.grants));
state.roleMap[uid] = r.id;
});
toast('已批量应用到 ' + state.picked.length + ' 人');
state.picked = [];
batchRole.value = '';
}
/* ---------- IT 资源配置 ---------- */
const powerCodes = computed(function () {
const out = [];
TREE.forEach(function (n) {
out.push({ code: (n.isPage ? 'page.' : 'menu.') + n.id + '.access', name: n.name + (n.isPage ? '页面' : '菜单'), group: 'entry' });
const mods = n.isPage ? n.children : [n];
mods.forEach(function (m) {
if (n.isPage) out.push({ code: 'menu.' + m.id + '.access', name: m.name + '菜单', group: 'entry' });
allActions(m).forEach(function (a) { out.push({ code: 'module.' + m.id + '.' + a.k, name: m.name + a.n, group: 'action' }) });
});
});
return out;
});
const filteredPowers = computed(function () {
if (powerGroup.value === 'all') return powerCodes.value;
return powerCodes.value.filter(function (p) { return p.group === powerGroup.value });
});
const nodeCount = computed(function () {
let n = 0;
TREE.forEach(function (x) { n += 1; if (x.children) n += x.children.length });
return n;
});
const actionDefCount = computed(function () {
let n = 0;
flatModules.value.forEach(function (m) { n += (m.actions || []).length });
return n;
});
const sensitiveCount = computed(function () {
let n = 0;
flatModules.value.forEach(function (m) { (m.fields || []).forEach(function (f) { if (f.s) n++ }) });
return n;
});
return {
TABS: TABS, POWER_GROUPS: POWER_GROUPS, BASE_OPS: BASE_OPS, SCOPES: SCOPES, ROLES: ROLES, USERS: USERS,
state: state, userQ: userQ, rolePick: rolePick, batchRole: batchRole, powerGroup: powerGroup,
showDiff: showDiff, toastMsg: toastMsg, curUser: curUser, filteredUsers: filteredUsers,
togglePick: togglePick, restrictCount: restrictCount, roleName: roleName,
isExpand: isExpand, toggleExpand: toggleExpand, treeNodes: treeNodes, flatModules: flatModules,
selName: selName, selIsPage: selIsPage, selActions: selActions, selFields: selFields, entryCode: entryCode,
moduleActions: moduleActions, moduleTight: moduleTight, scopeName: scopeName,
hasEntry: hasEntry, toggleEntry: toggleEntry,
hasAction: hasAction, toggleAction: toggleAction, scopeOf: scopeOf, setScope: setScope,
fieldMode: fieldMode, fieldQuery: fieldQuery, fieldExport: fieldExport, setField: setField, setFieldFlag: setFieldFlag,
entryCount: entryCount, actionCount: actionCount, fieldCount: fieldCount,
sqlHtml: sqlHtml, previewRows: previewRows, diffList: diffList, save: save, discard: discard,
applyRole: applyRole, applyRoleBatch: applyRoleBatch,
powerCodes: powerCodes, filteredPowers: filteredPowers,
nodeCount: nodeCount, actionDefCount: actionDefCount, sensitiveCount: sensitiveCount
};
}
});
app.mount('#app');
</script>
</body>
</html>
</div>
<div class="overlay" id="overlay"><div class="drawer"><div class="drawer-head"><div class="drawer-title">高级查询<small>当前列表列是快捷字段,也可以查询未展示的关联业务字段</small></div><button class="close" id="closeBtn">×</button></div><div class="drawer-body"><div class="logic-bar"><label>满足</label><select id="rootLogic"><option>全部条件(AND)</option><option>任一条件(OR)</option></select><span class="logic-hint">普通列条件与关联单据条件会组合成同一个查询</span></div><div class="builder" id="builder"></div><div class="add-bar"><button class="btn" id="addCondition">+ 添加条件</button><button class="btn" id="addGroup">+ 添加条件组</button></div><div class="field-guide"><div class="field-guide-title">快捷字段 · 点击即可添加</div><div class="quick-fields"><button class="quick-field" data-quick="expense.status">当前费用单 / 状态</button><button class="quick-field" data-quick="expense.amount">当前费用单 / 金额</button><button class="quick-field" data-quick="invoice.status">关联发票 / 发票状态</button><button class="quick-field" data-quick="payment.unpaidAmount">关联收付款 / 未付款金额</button></div></div></div><div class="drawer-foot"><button class="btn" id="drawerReset">重置条件</button><button class="btn" id="cancelBtn">取消</button><button class="btn primary" id="applyBtn">应用查询</button></div></div></div>
<div class="field-pop" id="fieldPop"><input class="field-search" id="fieldSearch" placeholder="搜索字段,例如:发票"><div class="field-options" id="fieldOptions"></div></div><div class="toast" id="toast"></div>
<script>
(function(){
var catalog=[{group:'当前费用单',items:[['expense.no','费用单号'],['expense.customer','客户'],['expense.type','费用类型'],['expense.amount','金额'],['expense.status','状态'],['expense.createdAt','创建时间'],['expense.creator','创建人']]},{group:'关联账单',items:[['bill.exists','账单是否存在'],['bill.status','账单状态'],['bill.amount','账单金额'],['bill.createdAt','账单日期']]},{group:'关联发票',items:[['invoice.exists','发票是否存在'],['invoice.status','发票状态'],['invoice.no','发票号码'],['invoice.amount','发票金额'],['invoice.createdAt','开票日期']]},{group:'关联收付款',items:[['payment.exists','收付款是否存在'],['payment.status','收付款状态'],['payment.unpaidAmount','未付款金额'],['payment.paidAt','付款日期']]}];var fieldMap={};catalog.forEach(function(g){g.items.forEach(function(i){fieldMap[i[0]]={label:i[1],group:g.group};});});var operators={text:[['contains','包含'],['eq','等于'],['ne','不等于'],['isNull','为空']],enum:[['eq','等于'],['ne','不等于'],['in','属于']],number:[['gt','大于'],['ge','大于等于'],['lt','小于'],['le','小于等于'],['between','介于']],date:[['eq','等于'],['ge','不早于'],['le','不晚于'],['between','介于']],exists:[['eq','是']]};var types={no:'text',customer:'text',type:'enum',amount:'number',status:'enum',createdAt:'date',creator:'text',exists:'exists',unpaidAmount:'number',paidAt:'date'};var applied=[{field:'expense.status',op:'eq',value:'待审核'},{field:'expense.amount',op:'gt',value:'10000'},{field:'invoice.status',op:'eq',value:'未开具'},{field:'payment.unpaidAmount',op:'gt',value:'0'}];var draft=[];var overlay=document.getElementById('overlay'),builder=document.getElementById('builder'),conditions=document.getElementById('conditions'),badge=document.getElementById('filterBadge'),resultText=document.getElementById('resultText'),fieldPop=document.getElementById('fieldPop'),fieldOptions=document.getElementById('fieldOptions'),fieldSearch=document.getElementById('fieldSearch'),toast=document.getElementById('toast');
function typeFor(field){return types[field.split('.').pop()]||'text'}function valueOptions(field){var k=field.split('.').pop();if(k==='status')return ['待审核','已审核','已驳回','已生成','未开具','已开具','未付款','部分付款','已付款'];if(k==='type')return ['海运费','港杂费','拖车费','报关费'];return []}function inputHtml(row){var type=typeFor(row.field),opts=valueOptions(row.field);if(type==='enum'||type==='exists')return '<select class="value-input"><option value="">请选择</option>'+opts.map(function(o){return '<option>'+o+'</option>'}).join('')+'</select>';if(type==='date')return '<input class="value-input" type="date">';if(row.op==='between')return '<div class="row-values"><input class="value-input" placeholder="最小值"><span>至</span><input class="value-input" placeholder="最大值"></div>';return '<input class="value-input" placeholder="输入值">'}
function renderBuilder(){builder.innerHTML='';draft.forEach(function(item,index){if(item.kind==='group'){builder.appendChild(makeGroup(item,index))}else builder.appendChild(makeRow(item,index,false))})}function makeFieldButton(row,index,inGroup){var b=document.createElement('button');b.type='button';b.className='field-picker';b.textContent=fieldMap[row.field].group+' / '+fieldMap[row.field].label;b.onclick=function(){openFieldPop(b,row,index,inGroup)};return b}function makeRow(row,index,inGroup){var wrap=document.createElement('div');wrap.className='condition-row';var fs=makeFieldButton(row,index,inGroup);var os=document.createElement('select');os.innerHTML=operators[typeFor(row.field)].map(function(o){return '<option value="'+o[0]+'">'+o[1]+'('+o[0]+')</option>'}).join('');os.value=row.op;os.onchange=function(){row.op=os.value;renderBuilder()};var vw=document.createElement('div');vw.className='value-wrap';vw.innerHTML=inputHtml(row);var inputs=vw.querySelectorAll('.value-input');inputs.forEach(function(inp,i){if(row.value&&inputs.length===1)inp.value=row.value;if(Array.isArray(row.value))inp.value=row.value[i]||'';inp.oninput=function(){row.value=inputs.length===1?inp.value:Array.from(inputs).map(function(x){return x.value})}});var rm=document.createElement('button');rm.className='remove-row';rm.textContent='×';rm.title='删除条件';rm.onclick=function(){if(inGroup)draft.forEach(function(x){if(x.kind==='group'){var n=x.children.indexOf(row);if(n>-1)x.children.splice(n,1)}});else draft.splice(index,1);renderBuilder()};wrap.appendChild(fs);wrap.appendChild(os);wrap.appendChild(vw);wrap.appendChild(rm);return wrap}
function makeGroup(group,index){var box=document.createElement('div');box.className='condition-group';var head=document.createElement('div');head.className='group-head';head.innerHTML='<strong>条件组</strong><span>满足</span>';var logic=document.createElement('select');logic.innerHTML='<option value="and">全部条件(AND)</option><option value="or">任一条件(OR)</option>';logic.value=group.logic||'and';logic.onchange=function(){group.logic=logic.value};head.appendChild(logic);var remove=document.createElement('button');remove.className='remove-group';remove.textContent='删除条件组';remove.onclick=function(){draft.splice(index,1);renderBuilder()};head.appendChild(remove);box.appendChild(head);var rows=document.createElement('div');rows.className='group-rows';group.children.forEach(function(row){rows.appendChild(makeRow(row,index,true))});box.appendChild(rows);var add=document.createElement('button');add.className='group-add';add.textContent='+ 在组内添加条件';add.onclick=function(){group.children.push({field:'invoice.status',op:'eq',value:''});renderBuilder()};box.appendChild(add);return box}
function openFieldPop(anchor,target){var r=anchor.getBoundingClientRect();fieldPop.style.left=Math.min(r.left,window.innerWidth-320)+'px';fieldPop.style.top=Math.min(r.bottom+5,window.innerHeight-390)+'px';fieldPop.classList.add('show');fieldPop._target=target;fieldSearch.value='';renderFieldOptions('');fieldSearch.focus()}function renderFieldOptions(query){var q=query.toLowerCase();fieldOptions.innerHTML='';catalog.forEach(function(g){var items=g.items.filter(function(i){return !q||i[1].toLowerCase().includes(q)||g.group.toLowerCase().includes(q)});if(!items.length)return;var s=document.createElement('div');s.className='field-section';s.textContent=g.group;fieldOptions.appendChild(s);items.forEach(function(i){var b=document.createElement('button');b.className='field-option';b.innerHTML=i[1]+'<span class="path">'+g.group.replace('当前费用单','当前')+'</span>';b.onclick=function(){var target=fieldPop._target;target.field=i[0];target.op=operators[typeFor(i[0])][0][0];target.value='';fieldPop.classList.remove('show');renderBuilder()};fieldOptions.appendChild(b)})})}
function cloneRows(rows){return rows.map(function(r){if(r.kind==='group')return {kind:'group',logic:r.logic,children:cloneRows(r.children)};return {field:r.field,op:r.op,value:Array.isArray(r.value)?r.value.slice():r.value}})}function operatorLabel(op){return {eq:'=',ne:'≠',gt:'>',ge:'≥',lt:'<',le:'≤',contains:'包含',between:'介于',isNull:'为空'}[op]||op}function displayValue(r){if(r.op==='isNull')return '';if(Array.isArray(r.value))return (r.value[0]||'不限')+' 至 '+(r.value[1]||'不限');return r.value||'未设置'}function countConditions(rows){return rows.reduce(function(n,r){return n+(r.kind==='group'?countConditions(r.children):1)},0)}function renderSummary(){conditions.innerHTML='';applied.forEach(function(r){var tag=document.createElement('div');tag.className='tag';if(r.kind==='group'){tag.innerHTML='<span class="label">条件组</span><span>'+countConditions(r.children)+' 个条件 · '+String(r.logic||'and').toUpperCase()+'</span>'}else{tag.innerHTML='<span class="label">'+fieldMap[r.field].label+'</span><span>'+operatorLabel(r.op)+' '+displayValue(r)+'</span>'}var x=document.createElement('button');x.className='x';x.textContent='×';x.onclick=function(){applied.splice(applied.indexOf(r),1);renderSummary()};tag.appendChild(x);conditions.appendChild(tag)});var count=countConditions(applied);badge.textContent=count;badge.style.display=count?'inline-block':'none';resultText.textContent='已应用 '+count+' 个条件 · 共 '+(count?18:128)+' 条'}function validRow(r){return r.op==='isNull'||(r.value!==''&&!(Array.isArray(r.value)&&!r.value[0]&&!r.value[1]))}function normalizeRows(rows){var out=[];rows.forEach(function(r){if(r.kind==='group'){var children=normalizeRows(r.children);if(children.length)out.push({kind:'group',logic:r.logic||'and',children:children})}else if(validRow(r))out.push(r)});return cloneRows(out)}function showDrawer(){draft=cloneRows(applied.length?applied:[{field:'expense.status',op:'eq',value:''}]);renderBuilder();overlay.classList.add('show')}function hideDrawer(){overlay.classList.remove('show');fieldPop.classList.remove('show')}
document.getElementById('filterBtn').onclick=showDrawer;document.getElementById('editQuery').onclick=showDrawer;document.getElementById('closeBtn').onclick=hideDrawer;document.getElementById('cancelBtn').onclick=hideDrawer;overlay.onclick=function(e){if(e.target===overlay)hideDrawer()};document.getElementById('addCondition').onclick=function(){draft.push({field:'expense.status',op:'eq',value:''});renderBuilder()};document.getElementById('addGroup').onclick=function(){draft.push({kind:'group',logic:'and',children:[{field:'invoice.status',op:'eq',value:''}]});renderBuilder()};document.querySelectorAll('.quick-field').forEach(function(b){b.onclick=function(){draft.push({field:b.dataset.quick,op:operators[typeFor(b.dataset.quick)][0][0],value:''});renderBuilder()}});document.querySelectorAll('.th-filter').forEach(function(b){b.onclick=function(){draft=cloneRows(applied);draft.push({field:b.dataset.field,op:operators[typeFor(b.dataset.field)][0][0],value:''});renderBuilder();overlay.classList.add('show')}});document.getElementById('drawerReset').onclick=function(){draft=[];renderBuilder()};document.getElementById('applyBtn').onclick=function(){applied=normalizeRows(draft);renderSummary();hideDrawer();showToast('查询条件已应用')};document.getElementById('resetBtn').onclick=function(){document.getElementById('keyword').value='';applied=[];renderSummary();showToast('查询条件已重置')};document.getElementById('searchBtn').onclick=function(){var k=document.getElementById('keyword').value.trim();if(k){resultText.textContent='搜索“'+k+'” · 已应用 '+countConditions(applied)+' 个条件';showToast('正在查询:'+k)}else showToast('请输入关键词,或使用高级查询')};document.getElementById('keyword').onkeydown=function(e){if(e.key==='Enter')document.getElementById('searchBtn').click()};fieldSearch.oninput=function(){renderFieldOptions(fieldSearch.value)};document.addEventListener('click',function(e){if(fieldPop.classList.contains('show')&&!fieldPop.contains(e.target)&&!e.target.classList.contains('field-picker'))fieldPop.classList.remove('show')});function showToast(t){toast.textContent=t;toast.classList.add('show');clearTimeout(window.__t);window.__t=setTimeout(function(){toast.classList.remove('show')},1500)}renderSummary();
})();
</script>
</body>
</html>