20260908170359
This commit is contained in:
1 parent
2f5eac079e
commit
8734b43a2a
14 files changed
+1956
-1063
No files matched your search
@@ -0,0 +1,10 @@
|
||||
const fs = require('fs');
|
||||
const file = 'd:/workspace/code/fms/FMS权限设计.md';
|
||||
const content = fs.readFileSync(file, 'utf8');
|
||||
|
||||
const oldPoints = '- 第一列是**模块树**,缩进 + 展开 + 父子半选\n- `page` 节点只配入口,权限列置灰(不直接对应数据表)\n- 动作全在一列,全部默认拒绝,勾选即授权;业务动作与基础操作 chip 同样式\n- **字段是模块行的展开子行**,且只显示**敏感字段 + 字段组**,默认折叠\n- 数据范围是一列,跟着模块走("仅本人"与"禁止删除"是同频操作,不该分开放)';
|
||||
const newPoints = '- **顶部用户条**横排选人 + 角色模板 + 批量多选\n- **左:模块树**只负责导航选择,节点带配置数 badge;树顶有「全部模块」概览入口\n- **右:权限面板** 4 个分区(入口 / 动作 / 数据范围 / 敏感字段),只配置当前选中模块\n- 面板内的字段**只显示 IT 标记的敏感字段**\n- 数据范围是面板内一段,不是独立分区(与「增删改」同频)';
|
||||
|
||||
if (content.indexOf(oldPoints) < 0) { console.log('STILL NOT FOUND'); process.exit(1) }
|
||||
fs.writeFileSync(file, content.replace(oldPoints, newPoints), 'utf8');
|
||||
console.log('OK');
|
||||
@@ -0,0 +1,30 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN"><head>
|
||||
<meta charset="UTF-8">
|
||||
<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>
|
||||
[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>
|
||||
<style>*, ::before, ::after{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgb(59 130 246 / 0.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgb(59 130 246 / 0.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/* ! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com */*,::after,::before{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}::after,::before{--tw-content:''}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;font-family:Inter, Segoe UI, Microsoft YaHei, sans-serif;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.pointer-events-none{pointer-events:none}.fixed{position:fixed}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0px}.bottom-5{bottom:1.25rem}.left-1\/2{left:50%}.top-0{top:0px}.top-20{top:5rem}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.z-\[60\]{z-index:60}.mx-auto{margin-left:auto;margin-right:autLine truncated
|
||||
<body class="bg-slate-100 font-sans text-[13px] text-slate-700 antialiased">
|
||||
<div id="app" data-v-app=""><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 class="rounded-md px-3 py-1.5 text-[13px] transition bg-white font-medium text-teal-700 shadow-sm">权限分配</button><button class="rounded-md px-3 py-1.5 text-[13px] transition text-slate-500 hover:text-slate-700">IT 资源配置</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 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 class="flex items-center gap-1.5 rounded-lg border py-1 pl-1.5 pr-2.5 transition border-teal-300 bg-teal-50 text-teal-700"><input type="checkbox" class="h-3 w-3 accent-teal-600"><span class="grid h-6 w-6 place-items-center rounded-full text-[11px] font-bold bg-teal-600 text-white">张</span><span class="text-[13px]">张三</span><span class="text-[11px] text-slate-400">费用会计</span><span class="rounded bg-amber-50 px-1 text-[10px] text-amber-700">9</span></button><button class="flex items-center gap-1.5 rounded-lg border py-1 pl-1.5 pr-2.5 transition border-slate-200 bg-white text-slate-600 hover:bg-slate-50"><input type="checkbox" class="h-3 w-3 accent-teal-600"><span class="grid h-6 w-6 place-items-center rounded-full text-[11px] font-bold bg-slate-100 text-slate-500">李</span><span class="text-[13px]">李四</span><span class="text-[11px] text-slate-400">财务经理</span><span class="rounded bg-amber-50 px-1 text-[10px] text-amber-700">16</span></button><button class="flex items-center gap-1.5 rounded-lg border py-1 pl-1.5 pr-2.5 transition border-slate-200 bg-white text-slate-600 hover:bg-slate-50"><input type="checkbox" class="h-3 w-3 accent-teal-600"><span class="grid h-6 w-6 place-items-center rounded-full text-[11px] font-bold bg-slate-100 text-slate-500">王</span><span class="text-[13px]">王五</span><span class="text-[11px] text-slate-400">海运操作</span><span class="rounded bg-amber-50 px-1 text-[10px] text-amber-700">11</span></button><button class="flex items-center gap-1.5 rounded-lg border py-1 pl-1.5 pr-2.5 transition border-slate-200 bg-white text-slate-600 hover:bg-slate-50"><input type="checkbox" class="h-3 w-3 accent-teal-600"><span class="grid h-6 w-6 place-items-center rounded-full text-[11px] font-bold bg-slate-100 text-slate-500">赵</span><span class="text-[13px]">赵六</span><span class="text-[11px] text-slate-400">客服专员</span><span class="rounded bg-amber-50 px-1 text-[10px] text-amber-700">2</span></button><button class="flex items-center gap-1.5 rounded-lg border py-1 pl-1.5 pr-2.5 transition border-slate-200 bg-white text-slate-600 hover:bg-slate-50"><input type="checkbox" class="h-3 w-3 accent-teal-600"><span class="grid h-6 w-6 place-items-center rounded-full text-[11px] font-bold bg-slate-100 text-slate-500">孙</span><span class="text-[13px]">孙七</span><span class="text-[11px] text-slate-400">销售代表</span><span class="rounded bg-amber-50 px-1 text-[10px] text-amber-700">2</span></button><button class="flex items-center gap-1.5 rounded-lg border py-1 pl-1.5 pr-2.5 transition border-slate-200 bg-white text-slate-600 hover:bg-slate-50"><input type="checkbox" class="h-3 w-3 accent-teal-600"><span class="grid h-6 w-6 place-items-center rounded-full text-[11px] font-bold bg-slate-100 text-slate-500">周</span><span class="text-[13px]">周八</span><span class="text-[11px] text-slate-400">销售经理</span><!----></button><div class="ml-auto flex items-center gap-2"><select 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 value="fee_clerk">费用会计</option><option value="fee_mgr">财务经理</option><option value="operator">海运操作</option><option value="readonly">只读查询</option></select><button class="h-8 rounded-lg bg-teal-600 px-3 text-[12px] font-medium text-white hover:bg-teal-500">应用到当前用户</button></div></div></div><div class="grid gap-5 xl:grid-cols-[230px_minmax(0,1fr)_330px]"><aside class=Line truncated
|
||||
<span class="text-teal-300">from</span> cw_fee
|
||||
<span class="text-teal-300">where</span> 1 = 1
|
||||
<span class="text-teal-300">and</span> input_dept_id = @current_dept
|
||||
|
||||
<span class="text-slate-500">-- 已裁剪字段:internal_cost, gross_profit</span></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 class="rounded-lg bg-slate-50 p-2.5"><div class="mono text-[10px] text-teal-700">s_user_power</div><div class="mono mt-1 break-all text-[11px] text-slate-500">zhangsan · menu.cw_fee.access · b_canuse = 1</div></div><div class="rounded-lg bg-slate-50 p-2.5"><div class="mono text-[10px] text-teal-700">s_user_power</div><div class="mono mt-1 break-all text-[11px] text-slate-500">zhangsan · module.cw_fee.read · b_canuse = 1</div></div><div class="rounded-lg bg-slate-50 p-2.5"><div class="mono text-[10px] text-teal-700">s_user_power</div><div class="mono mt-1 break-all text-[11px] text-slate-500">zhangsan · module.cw_fee.create · b_canuse = 1</div></div><div class="rounded-lg bg-slate-50 p-2.5"><div class="mono text-[10px] text-teal-700">s_user_power</div><div class="mono mt-1 break-all text-[11px] text-slate-500">zhangsan · module.cw_fee.update · b_canuse = 1</div></div><div class="rounded-lg bg-slate-50 p-2.5"><div class="mono text-[10px] text-teal-700">s_user_power</div><div class="mono mt-1 break-all text-[11px] text-slate-500">zhangsan · module.cw_fee.export · b_canuse = 1</div></div><div class="rounded-lg bg-slate-50 p-2.5"><div class="mono text-[10px] text-teal-700">s_user_power</div><div class="mono mt-1 break-all text-[11px] text-slate-500">zhangsan · module.cw_fee.audit · b_canuse = 1</div></div><div class="rounded-lg bg-slate-50 p-2.5"><div class="mono text-[10px] text-teal-700">s_user_field_permission</div><div class="mono mt-1 break-all text-[11px] text-slate-500">zhangsan / cw_fee / amount · mode = view · query = 1 · export = 0</div></div><div class="rounded-lg bg-slate-50 p-2.5"><div class="mono text-[10px] text-teal-700">s_user_field_permission</div><div class="mono mt-1 break-all text-[11px] text-slate-500">zhangsan / cw_fee / internal_cost · mode = hidden · query = 0 · export = 0</div></div><div class="rounded-lg bg-slate-50 p-2.5"><div class="mono text-[10px] text-teal-700">s_user_field_permission</div><div class="mono mt-1 break-all text-[11px] text-slate-500">zhangsan / cw_fee / gross_profit · mode = hidden · query = 0 · export = 0</div></div><div class="rounded-lg bg-slate-50 p-2.5"><div class="mono text-[10px] text-teal-700">s_user_data_scope</div><div class="mono mt-1 break-all text-[11px] text-slate-500">zhangsan / cw_fee / * / default / department</div></div><!----></div></div></aside></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 -translate-y-2 opacity-0"></div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
</body></html>
|
||||
@@ -0,0 +1,109 @@
|
||||
<!-- 中:权限面板 -->
|
||||
<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>
|
||||
@@ -0,0 +1,14 @@
|
||||
const fs = require('fs');
|
||||
const file = 'd:/workspace/code/fms/demo.html';
|
||||
const panelFile = 'd:/workspace/code/fms/.tmp-panel.html';
|
||||
const lines = fs.readFileSync(file, 'utf8').split('\n');
|
||||
const panel = fs.readFileSync(panelFile, 'utf8').replace(/\n$/, '').split('\n');
|
||||
|
||||
// 替换第 108..211 行(1-based)为新的权限面板;保留第 212 行的 </div>
|
||||
const before = lines.slice(0, 107);
|
||||
const after = lines.slice(211);
|
||||
console.log('第108行原内容:', JSON.stringify(lines[107]));
|
||||
console.log('第211行原内容:', JSON.stringify(lines[210]));
|
||||
console.log('第212行原内容:', JSON.stringify(lines[211]));
|
||||
fs.writeFileSync(file, before.concat(panel).concat(after).join('\n'), 'utf8');
|
||||
console.log('done, total lines:', before.length + panel.length + after.length);
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 105 KiB |
@@ -0,0 +1,23 @@
|
||||
const { execFileSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const exe = 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe';
|
||||
const shot = process.argv[2] === 'shot';
|
||||
let out = '';
|
||||
try {
|
||||
out = execFileSync(exe, ['--headless=new', '--disable-gpu', '--no-sandbox', '--hide-scrollbars',
|
||||
'--window-size=1680,1400', '--virtual-time-budget=12000',
|
||||
shot ? '--screenshot=d:/workspace/code/fms/.tmp-shot.png' : '--dump-dom',
|
||||
'file:///d:/workspace/code/fms/demo.html'], { maxBuffer: 64 * 1024 * 1024 }).toString();
|
||||
} catch (e) { out = String(e.stdout || ''); console.log('ERR', String(e.stderr || '').slice(0, 400)) }
|
||||
if (shot) { console.log('shot ok'); process.exit(0) }
|
||||
fs.writeFileSync('d:/workspace/code/fms/.tmp-dom.html', out, 'utf8');
|
||||
console.log(JSON.stringify({
|
||||
len: out.length,
|
||||
vueWarn: out.indexOf('[Vue warn]') > -1,
|
||||
hasTreeAside: out.indexOf('模块树') > -1,
|
||||
hasPanel: out.indexOf('敏感字段') > -1,
|
||||
hasUserBar: out.indexOf('授权对象') > -1,
|
||||
hasOverview: out.indexOf('全部模块(概览)') > -1,
|
||||
hasOldTable: out.indexOf('min-w-[1000px]') > -1,
|
||||
hasError: out.indexOf('Uncaught') > -1
|
||||
}, null, 2));
|
||||
@@ -3,6 +3,16 @@
|
||||
> 目标结构基于 2026-09-04 只读拉取的 `FMS-NEW` 数据库整理。
|
||||
> 平台配置使用字符串业务键;未来业务数据表使用 `bigint` 雪花 ID。
|
||||
|
||||
## 0. SQL 驱动与动态配置
|
||||
|
||||
本系统是 SQL 驱动的内部 ERP。能通过 SQL 完成的查询、关联、过滤、排序、分页、聚合、权限过滤和数据范围过滤,优先在 SQL 层完成,不在业务代码中加载大量数据后再处理。
|
||||
|
||||
- 模块、字段、查询条件、排序、字段权限和数据范围等规则,优先从本文件定义的配置表读取,由通用查询逻辑动态生成 SQL;
|
||||
- 动态 SQL 的表名、字段名和操作符只能来自受控的模块元数据、字段定义和白名单,业务值使用参数传递;
|
||||
- 配置驱动的动态 SQL 不等于保存任意 SQL 字符串。不得把未审查的前端字符串或整段 SQL 直接保存后执行;
|
||||
- 固定 SQL 仅用于稳定且明确的特殊业务逻辑,且应保持通用,不为单个页面或单个用户复制一套查询接口;
|
||||
- 前端权限控制只负责界面展示,模块操作、字段访问和数据范围必须在后端查询或写入 SQL 中落实;用户个性化只能调整布局,不能放宽权限。
|
||||
|
||||
## 1. 用户表
|
||||
|
||||
```sql
|
||||
@@ -345,11 +355,13 @@ create index ix_s_user_data_scope_module
|
||||
|
||||
权限计算约定:
|
||||
|
||||
- 默认拒绝:用户没有有效授权记录时不具备该权限;
|
||||
- 用户存在有效 `s_user_power` 记录时获得该权限;停用的权限点或授权记录不生效;
|
||||
- 字段没有有效策略记录时为 `hidden`;`view` 表示可见只读,`edit` 表示可见可编辑;`s_field_view` / `s_field_edit` 等元数据只能收紧权限;
|
||||
- 入口权限默认拒绝:`menu/access`、`page/access`、`report/access` 和 `action/execute` 必须存在有效的 `s_user_power` 授权记录;停用的权限点或授权记录不生效;
|
||||
- 模块操作权限默认放行:`module/read|create|update|delete|export` 没有用户授权记录时视为允许;`s_power` 权限点或用户授权记录存在 `b_canuse = 0` 时视为明确禁用;
|
||||
- 字段没有有效用户策略记录时不增加限制,按 `s_field`、`s_field_view`、`s_field_edit`、`s_field_query` 等模块默认配置执行;有效的 `s_user_field_permission` 只能收紧权限;`hidden` 表示不可见,`view` 表示可见只读,`edit` 表示可见可编辑;
|
||||
- `hidden` 字段不可查询、不可导出;`b_allow_query` / `b_allow_export` 不能突破字段访问级别;
|
||||
- 模块数据操作权限与数据范围同时生效:先判断操作权限,再按 `s_user_data_scope` 限制数据行;
|
||||
- 模块数据操作权限与数据范围同时生效:先判断操作权限,再按 `s_user_data_scope` 限制数据行;数据范围没有配置时视为 `all`;
|
||||
- 数据范围优先使用当前操作的 `override`,没有 `override` 时使用 `operation = '*'` 的 `default`;同一层级的多条范围按 OR 合并;
|
||||
- `read`、`export`、`update`、`delete` 在 SQL 行条件中应用数据范围;`create` 在写入前校验新增数据是否符合数据范围;
|
||||
- 菜单/页面/报表访问权限只控制入口和访问,不能替代模块、字段或数据范围权限。
|
||||
|
||||
## 9. 多语言
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
# FMS 权限设计
|
||||
|
||||
> 本文是权限部分的**定稿设计**,以《FMS新系统核心表结构设计.md》的表结构为基础,替代《字段权限与用户个性化设计.md》中与本文冲突的表述(差异见第 8 节)。
|
||||
> 用户个性化(`s_user_field_pref`、`s_user_query_field_pref`)不在本文范围内,后续单独处理。
|
||||
|
||||
## 0. 设计定位
|
||||
|
||||
### 0.1 系统特征(决定设计取向的前提)
|
||||
|
||||
| 特征 | 现状 | 对权限设计的影响 |
|
||||
| --- | --- | --- |
|
||||
| 部署方式 | **一客户一数据库** | 权限点只需在本库内唯一,编码不带租户前缀;不存在跨租户数据串户风险 |
|
||||
| 架构 | 元数据驱动(`s_module` / `s_field` / `s_field_view` …) | 权限点必须**自动生成**,不能手工维护清单 |
|
||||
| 客户可定制 | 可加模块、加字段 | 权限点需要**增量同步**机制 |
|
||||
| 客户规模 | 中小货代,多数无专职 IT | 模型必须简单到客户管理员能自行配置 |
|
||||
| 用户规模 | 几十 ~ 几百 | 不需要 SAP 级别的授权对象模型 |
|
||||
|
||||
### 0.2 结论:对应用友 / 金蝶的三层模型
|
||||
|
||||
**认知模型对标用友 / 金蝶**(功能权限 / 数据权限 / 字段权限 + 角色),理由不是技术最优,而是**客户和实施顾问无需学习成本**,且规模匹配。
|
||||
|
||||
**不采用**:
|
||||
|
||||
- SAP 授权对象模型(为跨国集团设计,规模不匹配,交付成本高)
|
||||
- Salesforce 四层共享模型(Profile + Permission Set + OWD + Sharing,计算复杂)
|
||||
- 通用权限框架 / ABAC / 运行时策略引擎(明确不做)
|
||||
|
||||
**借鉴两点**:
|
||||
|
||||
- 权限点自动生成(Salesforce 思路,利用本系统元数据驱动的优势)
|
||||
- 数据范围支持选择多个组织节点(CargoWise 的 Branch 思路,覆盖"只看上海 + 深圳分公司")
|
||||
|
||||
---
|
||||
|
||||
## 1. 权限模型
|
||||
|
||||
### 1.1 四类权限
|
||||
|
||||
| 类别 | 回答的问题 | 载体 | 默认策略 |
|
||||
| --- | --- | --- | --- |
|
||||
| **入口** | 能不能进 | `menu` / `page` / `report` | 默认拒绝 |
|
||||
| **动作** | 能不能做 | `module.<id>.<action>` | 全部默认拒绝(权限就是权限) |
|
||||
| **字段** | 能看/改哪些列 | `s_user_field_permission` | 跟随模块默认,只收紧 |
|
||||
| **数据范围** | 能看哪些行 | `s_user_data_scope` | 未配置视为 `all` |
|
||||
|
||||
### 1.2 入口 ≠ 动作
|
||||
|
||||
- **入口**是一维的:能不能打开(菜单可见、页面可访问)。勾了"能进费用管理"**不代表**能改数据。
|
||||
- **动作**是多维的:对某个模块能做什么。
|
||||
|
||||
两者互不继承,符合《核心表结构》"菜单权限不自动授予模块权限"的约定。
|
||||
|
||||
### 1.3 权限就是权限
|
||||
|
||||
不区分「内置动作」与「业务动作」。查看 / 新增 / 修改 / 删除 / 导出、审核 / 核销 / 关闭 / 作废等都是同一种**动作权限**,只有「勾选」与「未勾选」两种状态。
|
||||
|
||||
| 状态 | 含义 |
|
||||
| --- | --- |
|
||||
| 勾选 | 有 `s_user_power` 授权记录(`b_canuse = 1`) |
|
||||
| 未勾选 | 无授权记录 = 拒绝 |
|
||||
|
||||
**全部动作统一默认拒绝**。简化点:
|
||||
|
||||
- 一种存储:只存「授权记录」,没有「禁止记录」
|
||||
- 一种默认策略:未勾选 = 拒绝
|
||||
- 一种行为:勾选即授权
|
||||
|
||||
> 客户不能新建动作权限点——业务动作需要对应的后端代码,客户建了也无效。客户界面只提供勾选。
|
||||
|
||||
### 1.4 角色(可选层)
|
||||
|
||||
采用**主角色 + 个人例外**两层,不做角色继承角色:
|
||||
|
||||
```
|
||||
用户 → 主角色(继承,只有一个)+ 个人例外(覆盖角色中的少数项)
|
||||
```
|
||||
|
||||
- 新员工入职 = 选一个角色
|
||||
- 个别人特殊 = 加几条例外
|
||||
- 改角色 = 该角色所有人跟着变(与 SAP / 用友行为一致)
|
||||
- 查询只有两层,不是无限递归
|
||||
|
||||
角色不是强制项:不引入角色也能用(直接给用户配),但引入后配置量大幅下降。产品内置「货代标准权限包」(操作员 / 客服 / 费用会计 / 财务经理 / 只读查询),实施时导入客户库后微调。
|
||||
|
||||
---
|
||||
|
||||
## 2. 权限点编码规范
|
||||
|
||||
| 类型 | 编码 | 示例 |
|
||||
| --- | --- | --- |
|
||||
| 菜单入口 | `menu.<module_id>.access` | `menu.cw_fee.access` |
|
||||
| 页面入口 | `page.<page_id>.access` | `page.sea_detail.access` |
|
||||
| 报表入口 | `report.<report_id>.access` | `report.cw_fee_summary.access` |
|
||||
| 内置动作 | `module.<module_id>.<read\|create\|update\|delete\|export>` | `module.cw_fee.delete` |
|
||||
| 业务动作 | `module.<module_id>.<action_id>` | `module.cw_fee.audit` |
|
||||
|
||||
规则:
|
||||
|
||||
- 全部小写,点分分隔;`<module_id>` 用 `s_module.b_id`
|
||||
- **一客户一库,编码在本库内唯一即可**,不加租户前缀
|
||||
- 权限点由系统生成,**不允许手工新增业务动作**
|
||||
- `s_power.b_code` 是稳定业务键;`s_power.b_id` 是内部主键,业务代码只依赖 `b_code`
|
||||
|
||||
### 与旧模型的差异
|
||||
|
||||
| 项 | 旧(`b_user_power` / `s_module_power`) | 本文 |
|
||||
| --- | --- | --- |
|
||||
| 权限点标识 | `${module_id}.${雪花ID}`,跨环境 ID 不同,配置无法迁移 | 语义化 `b_code`,可跨环境迁移 |
|
||||
| 动作编码 | `action.module.xx.yy.execute` | `module.xx.yy` |
|
||||
|
||||
> 现有 `stores/permissions.js` 依赖旧表(`b_user_power` / `s_module_power`),迁移到新模型后需同步改造(见第 6 节)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 权限点的自动生成与升级同步
|
||||
|
||||
### 3.1 生成规则
|
||||
|
||||
| 来源 | 生成内容 |
|
||||
| --- | --- |
|
||||
| `s_module` 模块 | `menu.<id>.access` + 5 个内置动作 |
|
||||
| `s_module` 中 `b_module_type = 'page'` 的节点 | `page.<id>.access`(仅"需要独立入口控制"的页面) |
|
||||
| 报表定义 | `report.<id>.access` |
|
||||
| 开发注册的业务动作 | `module.<id>.<action_id>` |
|
||||
|
||||
### 3.2 增量同步三原则
|
||||
|
||||
产品升级或客户新增模块后需要补权限点,同步逻辑必须幂等:
|
||||
|
||||
1. **只增不删** —— 不删除已有权限点(删了会丢客户配置)
|
||||
2. **不覆盖已有配置** —— 客户主动停用的 `b_canuse = 0` 不被重置
|
||||
3. **废弃置 `b_canuse = 0`** —— 不物理删除,保留历史可追溯
|
||||
|
||||
### 3.3 客户自定义字段的权限
|
||||
|
||||
客户新增字段(如"拖车费")默认跟随模块默认配置,即**全员可见可编辑**。
|
||||
|
||||
防泄漏建议(低优先级,可后置):
|
||||
|
||||
- 新增字段时,字段名命中 `cost / price / amount / profit / 成本 / 价` 等关键词 → 提示是否标记为敏感字段
|
||||
- 提供「敏感字段体检」:列出金额类型但未标记敏感的字段供确认
|
||||
|
||||
---
|
||||
|
||||
## 4. 权限计算顺序
|
||||
|
||||
一次页面访问或接口调用按顺序计算:
|
||||
|
||||
1. 用户账号与对应资源是否启用
|
||||
2. **入口**:菜单 / 页面 / 报表是否有有效授权(默认拒绝)
|
||||
3. **动作**:当前动作是否被允许
|
||||
- 内置动作:无记录即放行;`b_canuse = 0` 明确禁止
|
||||
- 业务动作:必须有有效授权记录
|
||||
4. **字段**:按模块字段元数据 + 用户字段策略裁剪(只收紧)
|
||||
5. **数据范围**:`override` → `default` → `all`,同层多条 OR 合并,生成 SQL 行条件
|
||||
6. 角色层:用户例外覆盖角色配置
|
||||
|
||||
> 页面显示与接口执行必须用同一套规则。前端可提前隐藏,但**最终以后端 SQL 为准**(《开发规范》第 10、23 条)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 后端落实
|
||||
|
||||
### 5.1 统一拦截点
|
||||
|
||||
通用数据接口是唯一的 SQL 出入口,权限在此单点落实:
|
||||
|
||||
| 接口 | 对应动作 | 需落实 |
|
||||
| --- | --- | --- |
|
||||
| `/data/loaddata` | read | 动作校验 + 数据范围 WHERE + 字段裁剪 |
|
||||
| `/data/page` | read | 同上 |
|
||||
| `/data/saveobjt` | create / update / delete | 动作校验 + 字段 edit 校验 + **目标行必须落在数据范围内** |
|
||||
| `/data/loaddatabysql` | — | ⚠️ 见 5.2 |
|
||||
| `/data/nextid` `/nextcode` `/describe` | — | 无需 |
|
||||
|
||||
流程:
|
||||
|
||||
```
|
||||
解析 table → 用 s_module.b_viewtable / b_savetable 反查 module_id
|
||||
→ 确定 action(loaddata/page → read;saveobjt 按 inserts/updates/deletes 分别定)
|
||||
→ ① 动作校验
|
||||
→ ② 数据范围拼入 WHERE
|
||||
→ ③ SELECT 列剔除 hidden 字段
|
||||
→ 执行
|
||||
```
|
||||
|
||||
权限结果按 `(user_id, module_id, action)` 缓存,避免每次查询都查权限表。
|
||||
|
||||
### 5.2 ⚠️ `/data/loaddatabysql` 必须收紧
|
||||
|
||||
该接口接受任意 SQL,**会完全绕过字段裁剪和数据范围**。不处理则第 4 节所有规则形同虚设。
|
||||
|
||||
处理方案(择一,建议第一种):
|
||||
|
||||
1. **仅限管理员 / 内部账号可用**(推荐)
|
||||
2. 改为只接受白名单视图名,不接受 SQL 片段
|
||||
3. 解析 SQL 后注入条件(复杂且易出漏洞,不推荐)
|
||||
|
||||
> 这与《开发规范》第 8 条「动态配置不等于任意 SQL」一致。
|
||||
|
||||
### 5.3 表名 → 模块 反查
|
||||
|
||||
`loaddata` / `page` 的参数只带表名,需经 `s_module.b_viewtable` / `b_savetable` 反查 `module_id`。
|
||||
|
||||
**待确认**:是否存在多个模块共用同一视图?若存在,表名 → 模块的映射不唯一,需要前端额外传 `moduleId` 参数。
|
||||
|
||||
---
|
||||
|
||||
## 6. 前端落实
|
||||
|
||||
### 6.1 `stores/permissions.js` 改造
|
||||
|
||||
现有骨架(并发加载 + 主体去重 + 管理员提权)保留,替换数据源与判断逻辑:
|
||||
|
||||
| 现状 | 改造后 |
|
||||
| --- | --- |
|
||||
| 拉 `s_module` / `b_user_module` / `b_user_power` / `s_module_power` | 拉 `s_module` / `s_power` / `s_user_power` / `s_user_field_permission` / `s_user_data_scope` |
|
||||
| `powerSet` 存 `${module_id}.${雪花ID}` | 存语义化 `b_code` |
|
||||
| `canPower(scopeCode, powerCode)`(零调用) | `can(code)` 直接传 `b_code` |
|
||||
|
||||
对外暴露:
|
||||
|
||||
```js
|
||||
can('module.cw_fee.delete') // 动作判断
|
||||
fieldPerm(moduleId, field) // → { mode, query, export }
|
||||
scope(moduleId, action) // 数据范围
|
||||
```
|
||||
|
||||
### 6.2 三个接线点
|
||||
|
||||
1. **按钮级**:提供 `v-permission` 指令 + `usePermission()`,替换掉零调用的 `canPower`
|
||||
2. **字段裁剪**:`FmsModuleTable.buildColumns()` 之后过滤 —— `hidden` 移除列、`view` 置只读、`export = 0` 剔除导出列
|
||||
3. **菜单过滤**:`stores/menus.js` 目前只按 `g3soft` 管理员裁剪,**未使用授权数据**,需改为按入口权限过滤
|
||||
|
||||
> 前端裁剪只是展示层,**不可信**。真正的过滤在后端 SQL。
|
||||
|
||||
---
|
||||
|
||||
## 7. 配置界面设计
|
||||
|
||||
### 7.1 核心原则
|
||||
|
||||
- **IT 定义"能分配什么",客户决定"分给谁"**。IT 不碰人,客户不碰元数据。
|
||||
- **一个页面配完**,不分区切换、不重复选模块。
|
||||
- 默认展示**已配置项**,而非全量清单(规则是"没配 = 放行",全量铺开只会增加噪音)。
|
||||
|
||||
### 7.2 树的主体是模块树,不是菜单树
|
||||
|
||||
`s_module` 本身是树(`b_parent_id` + `b_module_type`),`page` 类型节点天然承载"页面包含哪些模块":
|
||||
|
||||
```
|
||||
海运详细页(page 节点,只配入口)
|
||||
├── 海运主表(module)
|
||||
├── 装箱主表(module)
|
||||
└── 费用管理(module)
|
||||
```
|
||||
|
||||
菜单 `s_menu` 只是指向模块的指针(`s_menu.b_module_id`)。**不用菜单树做权限骨架**:
|
||||
|
||||
- 菜单不拥有字段 / 动作 / 数据范围,挂上去语义错误
|
||||
- 一个模块可能被多个菜单引用 → 同一模块重复出现、配置冲突
|
||||
- 与《核心表结构》"权限以业务模块为核心"矛盾
|
||||
|
||||
菜单入口权限 = 模块树上的「入口」列。只有"一个模块被多个菜单引用且需分别控制"时,才启用 `page.<id>.access` 独立入口。
|
||||
|
||||
### 7.3 主界面形态(一屏配完一个人)
|
||||
|
||||
```
|
||||
模块树 入口 │ 动作(默认拒绝,勾选即授权) │ 数据范围
|
||||
──────────────────────────────────────────────────────────────────────────────
|
||||
▾ 海运详细页[page] ☑ │ - - - - - │ - │ -
|
||||
▾ 海运主表 ☑ │ ☑ ☑ ☐ ☐ ☐ │ ☑审核 ☐关闭 │ 仅本人▾
|
||||
└ 字段(敏感) 金额=只读 · 内部成本=隐藏
|
||||
▾ 装箱主表 ☑ │ ☑ ☐ ☐ ☐ ☐ │ - │ 本部门▾
|
||||
▾ 费用管理 ☑ │ ☑ ☑ ☑ ☐ ☑ │ ☑审核 ☐反审核 │ 本部门▾
|
||||
└ 字段(敏感) 金额=只读 · 内部成本=隐藏 · 毛利=隐藏
|
||||
▾ 发票管理 ☑ │ ☑ ☑ ☑ ☐ ☑ │ ☑作废 │ 本部门▾
|
||||
客户档案 ☐ │ ☑ ☐ ☐ ☐ ☐ │ - │ 本部门▾
|
||||
```
|
||||
|
||||
要点:
|
||||
|
||||
- 第一列是**模块树**,缩进 + 展开 + 父子半选
|
||||
- `page` 节点只配入口,权限列置灰(不直接对应数据表)
|
||||
- 动作全在一列,全部默认拒绝,勾选即授权;业务动作与基础操作 chip 同样式
|
||||
- **字段是模块行的展开子行**,且只显示**敏感字段 + 字段组**,默认折叠
|
||||
- 数据范围是一列,跟着模块走("仅本人"与"禁止删除"是同频操作,不该分开放)
|
||||
|
||||
### 7.4 批量
|
||||
|
||||
- 左侧用户列表支持多选 → 底部出现「应用角色模板」「批量设置数据范围」「清空」
|
||||
- 表格支持整列操作(如"所有模块禁止删除")
|
||||
|
||||
### 7.5 IT 专属入口(客户不可见)
|
||||
|
||||
| 内容 | 频率 |
|
||||
| --- | --- |
|
||||
| 模块 / 字段元数据 + 敏感标记 | 低 |
|
||||
| 字段分组(`s_field_group`) | 低 |
|
||||
| 业务动作注册(需写代码) | 低 |
|
||||
| 权限点清单 + 重新生成 | 低 |
|
||||
| 标准权限包维护 | 低 |
|
||||
|
||||
### 7.6 权限诊断(必做)
|
||||
|
||||
- **以某用户身份预览**
|
||||
- **反查**:选中一个权限点 / 字段 → 列出谁有 → 批量调整
|
||||
- **查看生效 SQL**:排查"为什么这个人看不到这条数据"
|
||||
|
||||
---
|
||||
|
||||
## 8. 与既有文档的差异(决策变更)
|
||||
|
||||
| # | 原表述 | 本文 | 原因 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | 动作编码 `action.module.<module>.<action>.execute` | `module.<module>.<action>` | CRUD 与业务动作本质是同一概念,统一编码 |
|
||||
| 2 | 权限直接授予用户,**不引入角色继承** | 可选「主角色 + 个人例外」两层 | 无角色时配置量是 `人 × 模块 × 字段` 的笛卡尔积 |
|
||||
| 3 | 权限点类型含 `page` / `report`,与 `module` 并列 | 入口与动作分离;动作统一挂模块 | 入口是"能不能进",动作是"能不能做" |
|
||||
| 4 | 数据范围 `custom` 填单个值 | 支持选择多个组织节点 | "只看上海 + 深圳分公司" |
|
||||
| 5 | 未说明树的主语 | **模块树**是主体,菜单只是指针 | 权限以模块为核心 |
|
||||
| 6 | 未涉及 | 权限点自动生成 + 增量同步三原则 | 一客户一库 + 客户可定制 |
|
||||
| 7 | 动作分内置 / 业务两种,默认策略不同 | 不区分,统一为「动作」,**全部默认拒绝** | 简化模型:只存授权记录,没有禁止记录 |
|
||||
|
||||
> 建议:将第 1、2 条同步修订到《FMS新系统核心表结构设计.md》第 276 行及权限章节,避免两份文档冲突。
|
||||
|
||||
---
|
||||
|
||||
## 9. 实施顺序
|
||||
|
||||
| 阶段 | 内容 | 产出 |
|
||||
| --- | --- | --- |
|
||||
| P1 | 建 `s_power` / `s_user_power`(统一编码)+ 权限点自动生成 + `/data/*` 拦截只做动作校验 | 能控制"谁能进哪个模块、能做什么" |
|
||||
| P2 | 字段裁剪 + 数据范围 WHERE;**收紧 `/data/loaddatabysql`** | 后端强制完整 |
|
||||
| P3 | 前端 `permissions.js` 改造 + `v-permission` + 菜单过滤 + 表格字段裁剪 | 界面生效 |
|
||||
| P4 | 配置界面(模块树 + 一屏配置)+ 标准权限包 + 权限诊断 | 好用 |
|
||||
|
||||
P1 与 P2 是同一个拦截层的两批规则,不会返工。**不建议先做 P4**——模型未定,界面做了会返工。
|
||||
+847
-29
@@ -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">✕</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>
|
||||
@@ -110,14 +110,17 @@
|
||||
}
|
||||
|
||||
/* 加载态 —— spinner 在前,隐藏自带图标 */
|
||||
.button-loading {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.button-loading :deep(svg):not(.button-spinner) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 加载态虽因防重复提交而原生 disabled,但视觉是“进行中”而非“禁用”:
|
||||
保持原配色(不降透明度、悬停不变色),光标用纯加载样式 */
|
||||
.button-loading:disabled {
|
||||
opacity: 1;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
:deep(.button-spinner) {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
|
||||
@@ -243,15 +243,12 @@ const searchForm = reactive(makeEmptyForm())
|
||||
const queryPrefs = reactive(Object.fromEntries(SEARCH_FIELD_DEFS.map((field) => [field.key, { area: field.area || 'advanced', order: field.order || 999 }])))
|
||||
const QUICK_VISIBLE_LIMIT = 4
|
||||
const quickSearchExpanded = ref(false)
|
||||
const drawerOpen = ref(false)
|
||||
const drawerTab = ref('conditions')
|
||||
const queryApplied = ref(false)
|
||||
const activeQueryMode = ref('quick')
|
||||
const queryMode = ref('quick')
|
||||
const activeQueryMode = ref('quick') // 'quick' 搜索卡 / 'advanced' 左侧常驻高级面板
|
||||
const settingsOpen = ref(false) // 查询设置右侧抽屉
|
||||
const advancedLogic = ref('and')
|
||||
const advancedNodes = reactive([])
|
||||
const advancedGroups = reactive([])
|
||||
const advancedSnapshot = ref(null)
|
||||
const draggingFieldKey = ref('')
|
||||
const dragOverFieldKey = ref('')
|
||||
const allAdvancedNodes = computed(() => advancedNodes.concat(advancedGroups.flatMap((group) => group.nodes)))
|
||||
@@ -282,13 +279,8 @@ const extraQuickConditionCount = computed(() => extraQuickSearchFields.value.fil
|
||||
if (field.type === 'numrange') return searchForm[`${field.key}Min`] || searchForm[`${field.key}Max`]
|
||||
return searchForm[field.key] !== '' && searchForm[field.key] !== null && searchForm[field.key] !== undefined
|
||||
}).length)
|
||||
// 常用区已填条件总数:高级模式的说明条要用 —— 告诉用户这些值还在,只是不参与
|
||||
const quickConditionCount = computed(() => quickSearchFields.value.filter((field) => {
|
||||
if (field.type === 'numrange') return searchForm[`${field.key}Min`] || searchForm[`${field.key}Max`]
|
||||
return searchForm[field.key] !== '' && searchForm[field.key] !== null && searchForm[field.key] !== undefined
|
||||
}).length)
|
||||
// 高级模式下常用区强制收起:保留上下文(知道有哪几个常用条件),但不占版面
|
||||
const showAllQuickFields = computed(() => quickSearchExpanded.value && activeQueryMode.value === 'quick')
|
||||
// 高级模式下面板即状态呈现,不再需要「常用条件暂不参与」的说明
|
||||
const showAllQuickFields = computed(() => quickSearchExpanded.value)
|
||||
const advancedSearchFields = computed(() =>
|
||||
searchFields.value.filter((field) => field.queryable !== false && queryPrefs[field.key]?.area !== 'hidden'),
|
||||
)
|
||||
@@ -379,30 +371,11 @@ function removeSummaryItem(item) {
|
||||
}
|
||||
if (!querySummary.value.length) queryApplied.value = false
|
||||
}
|
||||
function openQueryDrawer(tab = 'conditions') {
|
||||
if (tab === 'conditions' && !drawerOpen.value) {
|
||||
advancedSnapshot.value = {
|
||||
logic: advancedLogic.value,
|
||||
nodes: JSON.parse(JSON.stringify(advancedNodes)),
|
||||
groups: JSON.parse(JSON.stringify(advancedGroups)),
|
||||
}
|
||||
}
|
||||
drawerTab.value = tab
|
||||
drawerOpen.value = true
|
||||
function openSettings() {
|
||||
settingsOpen.value = true
|
||||
}
|
||||
function closeQueryDrawer() {
|
||||
if (advancedSnapshot.value) {
|
||||
advancedLogic.value = advancedSnapshot.value.logic
|
||||
advancedNodes.splice(0, advancedNodes.length, ...advancedSnapshot.value.nodes)
|
||||
advancedGroups.splice(0, advancedGroups.length, ...advancedSnapshot.value.groups)
|
||||
advancedSnapshot.value = null
|
||||
queryMode.value = activeQueryMode.value
|
||||
}
|
||||
drawerOpen.value = false
|
||||
}
|
||||
function selectQueryMode(mode) {
|
||||
queryMode.value = mode
|
||||
if (mode === 'advanced') openQueryDrawer('conditions')
|
||||
function closeSettings() {
|
||||
settingsOpen.value = false
|
||||
}
|
||||
function setFieldArea(field, area) {
|
||||
if (field.queryable === false) return
|
||||
@@ -443,6 +416,11 @@ function resetQuickQuery() {
|
||||
Object.assign(searchForm, makeEmptyForm())
|
||||
if (activeQueryMode.value === 'quick') queryApplied.value = false
|
||||
}
|
||||
function resetAllQueries() {
|
||||
resetQuickQuery()
|
||||
resetAdvancedQuery()
|
||||
queryApplied.value = false
|
||||
}
|
||||
function startFieldDrag(field, event) {
|
||||
draggingFieldKey.value = field.key
|
||||
// Firefox 下不写 dataTransfer 不会触发 drop,同时声明「移动」语义拿到正确的光标
|
||||
@@ -476,28 +454,52 @@ function dropField(field, area) {
|
||||
}
|
||||
function onSearch() {
|
||||
activeQueryMode.value = 'quick'
|
||||
queryMode.value = 'quick'
|
||||
queryApplied.value = true
|
||||
}
|
||||
function applyAdvancedQuery() {
|
||||
// 进入高级模式:滑出左侧常驻面板(无遮罩),搜索卡隐藏、表格占满剩余宽高
|
||||
function enterAdvancedMode() {
|
||||
activeQueryMode.value = 'advanced'
|
||||
queryMode.value = 'advanced'
|
||||
queryApplied.value = true
|
||||
advancedSnapshot.value = null
|
||||
drawerOpen.value = false
|
||||
}
|
||||
function resetAllQueries() {
|
||||
resetQuickQuery()
|
||||
resetAdvancedQuery()
|
||||
queryApplied.value = false
|
||||
advancedSnapshot.value = null
|
||||
}
|
||||
// 回到简单模式:收起面板、恢复搜索卡
|
||||
function returnToQuickQuery() {
|
||||
if (drawerOpen.value && drawerTab.value === 'conditions') closeQueryDrawer()
|
||||
activeQueryMode.value = 'quick'
|
||||
queryMode.value = 'quick'
|
||||
queryApplied.value = false
|
||||
}
|
||||
// 面板内应用:编辑即暂存,应用只固化生效态
|
||||
function applyAdvancedQuery() {
|
||||
queryApplied.value = true
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 布局原型扩展(模拟验证用,正式页面删除):
|
||||
* pageLayout = 'flat' 纯列表 | 'tree' 树+表格。
|
||||
* 验证「左栏槽位」约定:树(快速模式)与高级面板(高级模式)轮班占同一列,
|
||||
* 表格宽度开销恒等于一列;树选中条件以 chip 呈现,两种模式都可见可清除。
|
||||
* ============================================================ */
|
||||
const pageLayout = ref('flat')
|
||||
const TREE_NODES = [
|
||||
{ id: 'all', label: '全部分类', children: [
|
||||
{ id: 'c1', label: '华东区', children: [
|
||||
{ id: 'c11', label: '上海' },
|
||||
{ id: 'c12', label: '杭州' },
|
||||
] },
|
||||
{ id: 'c2', label: '华南区', children: [
|
||||
{ id: 'c21', label: '广州' },
|
||||
{ id: 'c22', label: '深圳' },
|
||||
] },
|
||||
{ id: 'c3', label: '华北区' },
|
||||
] },
|
||||
]
|
||||
function findTreeLabel(nodes, id) {
|
||||
for (const node of nodes) {
|
||||
if (node.id === id) return node.label
|
||||
const childLabel = findTreeLabel(node.children || [], id)
|
||||
if (childLabel) return childLabel
|
||||
}
|
||||
return ''
|
||||
}
|
||||
// 树选中条件(页面固定条件):chips 条数据源之一,两种模式都渲染
|
||||
const selectedTreeNodeId = ref('')
|
||||
const selectedTreeNodeLabel = computed(() => findTreeLabel(TREE_NODES, selectedTreeNodeId.value) || '')
|
||||
|
||||
/* ---------- fms-table 大数据量 + 单行编辑 demo ---------- */
|
||||
const TYPE_OPTIONS = [
|
||||
@@ -614,7 +616,7 @@ const selectedText = computed(() =>
|
||||
? t('demo.statusbar.selected').replace('{n}', selectedRowKeys.value.length)
|
||||
: '',
|
||||
)
|
||||
const totalText = computed(() => t('demo.footer.total').replace('{n}', allRows.value.length))
|
||||
const totalText = computed(() => t('demo.footer.total').replace('{n}', filteredRows.value.length))
|
||||
|
||||
function onRefresh() {
|
||||
allRows.value = makeRows(DEFAULT_ROW_COUNT)
|
||||
@@ -625,25 +627,138 @@ function onMoreAction({ key }) {
|
||||
// 打印 / 密度 / 全屏:占位,后续接入
|
||||
void key
|
||||
}
|
||||
/* 树选中过滤表格行(模拟;正式页面由 SQL 条件承担) */
|
||||
const filteredRows = computed(() => {
|
||||
if (!selectedTreeNodeLabel.value || selectedTreeNodeLabel.value === '全部分类') return allRows.value
|
||||
return allRows.value.filter((row) => String(row.name).length % 2 === Number(selectedTreeNodeId.value.slice(-1)) % 2)
|
||||
})
|
||||
/* 左栏是否有内容占位:树(快速模式)或高级面板(高级模式) */
|
||||
const leftColumnOccupied = computed(() => pageLayout.value === 'tree' || activeQueryMode.value === 'advanced')
|
||||
/* 树扁平化渲染(模拟;正式页面用 ui/tree 组件) */
|
||||
function flattenTree(nodes, depth = 0) {
|
||||
const result = []
|
||||
for (const node of nodes) {
|
||||
result.push({ id: node.id, label: node.label, depth })
|
||||
if (node.children?.length) result.push(...flattenTree(node.children, depth + 1))
|
||||
}
|
||||
return result
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dashboard-page">
|
||||
<!-- 搜索区:常用条件来自模块默认配置,并可由用户个性化调整 -->
|
||||
<section class="table-card search-card">
|
||||
<!-- 左栏槽位有内容(树 / 高级面板)时页面横排,右侧主列纵排搜索卡+表格卡 -->
|
||||
<div class="dashboard-page" :class="{ 'dashboard-page--row': leftColumnOccupied }">
|
||||
<!-- ===== 原型布局切换(正式页面删除):flat 纯列表 / tree 树+表格 ===== -->
|
||||
<div class="layout-switch">
|
||||
<Button :type="pageLayout === 'flat' ? 'primary' : 'outline'" size="small" @click="pageLayout = 'flat'">纯列表</Button>
|
||||
<Button :type="pageLayout === 'tree' ? 'primary' : 'outline'" size="small" @click="pageLayout = 'tree'">树+表格</Button>
|
||||
</div>
|
||||
|
||||
<!-- ===== 左栏槽位:树(快速模式)与高级面板(高级模式)轮班占位 ===== -->
|
||||
<!-- 树+表格页面、快速模式:树占据左栏 -->
|
||||
<aside v-if="pageLayout === 'tree' && activeQueryMode === 'quick'" class="tree-panel">
|
||||
<div class="tree-panel__head">
|
||||
<strong>物料分类</strong>
|
||||
<button class="panel-icon-button" title="调整常用条件与高级条件的显示和顺序" @click="openSettings">
|
||||
<Settings2 :size="15" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="tree-panel__body">
|
||||
<button
|
||||
v-for="node in flattenTree(TREE_NODES)"
|
||||
:key="node.id"
|
||||
class="tree-node"
|
||||
:class="{ 'is-active': selectedTreeNodeId === node.id }"
|
||||
:style="{ paddingLeft: `${8 + node.depth * 16}px` }"
|
||||
@click="selectedTreeNodeId = selectedTreeNodeId === node.id ? '' : node.id"
|
||||
>
|
||||
{{ node.label }}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 高级模式:高级面板占据左栏(树隐藏,树条件以 chip 呈现在生效条件条上) -->
|
||||
<aside v-if="activeQueryMode === 'advanced'" class="advanced-panel">
|
||||
<div class="advanced-panel__head">
|
||||
<div>
|
||||
<strong>高级查询</strong>
|
||||
</div>
|
||||
<div class="advanced-panel__head-actions">
|
||||
<button class="panel-icon-button" title="调整常用条件与高级条件的显示和顺序" @click="openSettings">
|
||||
<Settings2 :size="15" />
|
||||
</button>
|
||||
<button class="panel-icon-button" title="改用简单查询" @click="returnToQuickQuery">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="advanced-panel__body">
|
||||
<div class="ast-root"><span>满足</span><select v-model="advancedLogic"><option value="and">全部条件</option><option value="or">任一条件</option></select></div>
|
||||
<div v-if="!advancedNodes.length && !advancedGroups.length" class="ast-empty">
|
||||
<p>暂无条件</p>
|
||||
<Button type="primary" @click="addAdvancedNode">+ 添加条件</Button>
|
||||
</div>
|
||||
<div v-for="node in advancedNodes" :key="node.id" class="ast-row">
|
||||
<div class="ast-row__top">
|
||||
<div class="ast-field-wrap">
|
||||
<select v-model="node.field" class="ast-field" @change="onNodeFieldChange(node)">
|
||||
<option v-for="field in advancedSearchFields" :key="field.key" :value="field.key">{{ field.label }}</option>
|
||||
</select>
|
||||
<span v-if="fieldByKey[node.field]?.relation" class="relation-badge">关联</span>
|
||||
</div>
|
||||
<button class="ast-remove" title="删除条件" @click="removeAdvancedNode(node.id)">×</button>
|
||||
</div>
|
||||
<div class="ast-row__bottom">
|
||||
<select v-model="node.operator" class="ast-op" @change="onNodeOperatorChange(node)">
|
||||
<option v-for="operator in getNodeOperators(node)" :key="operator.value" :value="operator.value">{{ operator.label }}</option>
|
||||
</select>
|
||||
<Input v-if="fieldByKey[node.field]?.type === 'input'" v-model="node.value" class="ast-value" placeholder="请输入值" />
|
||||
<RangePicker v-else-if="fieldByKey[node.field]?.type === 'daterange'" v-model="node.value" class="ast-value" />
|
||||
<div v-else-if="fieldByKey[node.field]?.type === 'numrange'" class="ast-range-value">
|
||||
<template v-if="node.operator === 'between'"><Input v-model="node.value[0]" placeholder="最小值" /><span>至</span><Input v-model="node.value[1]" placeholder="最大值" /></template>
|
||||
<Input v-else v-model="node.value" placeholder="请输入数值" />
|
||||
</div>
|
||||
<select v-else v-model="node.value" class="ast-value">
|
||||
<option value="">请选择</option>
|
||||
<option v-for="option in getFieldOptions(node.field)" :key="option.value" :value="option.value">{{ option.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div v-for="group in advancedGroups" :key="group.id" class="ast-group">
|
||||
<div class="ast-group__head"><span>条件组</span><select v-model="group.logic"><option value="and">全部满足</option><option value="or">任一满足</option></select><button type="button" @click="advancedGroups.splice(advancedGroups.indexOf(group), 1)">删除组</button></div>
|
||||
<div v-for="node in group.nodes" :key="node.id" class="ast-row ast-row--nested">
|
||||
<div class="ast-row__top">
|
||||
<div class="ast-field-wrap"><select v-model="node.field" class="ast-field" @change="onNodeFieldChange(node)"><option v-for="field in advancedSearchFields" :key="field.key" :value="field.key">{{ field.label }}</option></select><span v-if="fieldByKey[node.field]?.relation" class="relation-badge">关联</span></div>
|
||||
<button class="ast-remove" title="删除条件" @click="removeAdvancedNode(node.id)">×</button>
|
||||
</div>
|
||||
<div class="ast-row__bottom">
|
||||
<select v-model="node.operator" class="ast-op" @change="onNodeOperatorChange(node)"><option v-for="operator in getNodeOperators(node)" :key="operator.value" :value="operator.value">{{ operator.label }}</option></select>
|
||||
<Input v-if="fieldByKey[node.field]?.type === 'input'" v-model="node.value" class="ast-value" placeholder="请输入值" />
|
||||
<RangePicker v-else-if="fieldByKey[node.field]?.type === 'daterange'" v-model="node.value" class="ast-value" />
|
||||
<div v-else-if="fieldByKey[node.field]?.type === 'numrange'" class="ast-range-value"><template v-if="node.operator === 'between'"><Input v-model="node.value[0]" placeholder="最小值" /><span>至</span><Input v-model="node.value[1]" placeholder="最大值" /></template><Input v-else v-model="node.value" placeholder="请输入数值" /></div>
|
||||
<select v-else v-model="node.value" class="ast-value"><option value="">请选择</option><option v-for="option in getFieldOptions(node.field)" :key="option.value" :value="option.value">{{ option.label }}</option></select>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="ghost" @click="group.nodes.push(createAdvancedNode(advancedSearchFields[0]?.key || 'creator'))">+ 条件</Button>
|
||||
</div>
|
||||
<div class="ast-actions"><Button type="outline" @click="addAdvancedNode">+ 添加条件</Button><Button type="outline" @click="addAdvancedGroup">+ 添加条件组</Button><Button type="ghost" class="clear-conditions" @click="resetAdvancedQuery">清空条件</Button></div>
|
||||
</div>
|
||||
<div class="advanced-panel__foot">
|
||||
<span class="ast-foot-note">已配置 {{ advancedConditionCount }} 个条件</span>
|
||||
<Button type="outline" @click="returnToQuickQuery">取消</Button>
|
||||
<Button type="primary" @click="applyAdvancedQuery">应用查询</Button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 右侧主列:搜索卡 + 表格卡纵向堆叠;横排模式(树/高级面板占左栏)时占满剩余宽度 -->
|
||||
<div class="dashboard-main">
|
||||
|
||||
<!-- 搜索区:常用条件来自模块默认配置,并可由用户个性化调整(高级模式隐藏,表格占满剩余宽高) -->
|
||||
<section v-if="activeQueryMode === 'quick'" class="table-card search-card">
|
||||
<!-- 查询设置:卡片级配置,绝对定位在右上角 —— 不占流内高度,
|
||||
也不随字段增减、模式切换位移 -->
|
||||
<Button type="ghost" class="settings-button search-card__settings" title="调整常用条件与高级条件的显示和顺序" @click="openQueryDrawer('settings')">
|
||||
<Button type="ghost" class="settings-button search-card__settings" title="调整常用条件与高级条件的显示和顺序" @click="openSettings">
|
||||
<Settings2 :size="15" /> 查询设置
|
||||
</Button>
|
||||
<!-- 高级模式:常用条件「降权」而不是消失,也不加遮罩 ——
|
||||
遮罩在交互语义上是「阻塞/锁定」,而这里只是「不参与本次查询」。
|
||||
只在你真的填了常用条件时才占这一行:没填值时降权区是空的,不解释也不会困惑 -->
|
||||
<div v-if="activeQueryMode === 'advanced' && quickConditionCount" class="mode-notice">
|
||||
<span class="mode-notice__dot" />
|
||||
<span>顶部已填的 {{ quickConditionCount }} 个常用条件暂不参与本次高级查询,改用简单查询后重新生效。</span>
|
||||
</div>
|
||||
<div class="list-search" :class="{ 'list-search--muted': activeQueryMode === 'advanced' }">
|
||||
<div class="list-search">
|
||||
<div v-for="field in (showAllQuickFields ? quickSearchFields : quickSearchFields.slice(0, QUICK_VISIBLE_LIMIT))" :key="field.key" class="search-field">
|
||||
<label class="search-field__label">{{ field.label }}</label>
|
||||
<Input
|
||||
@@ -670,8 +785,7 @@ function onMoreAction({ key }) {
|
||||
<Input v-model="searchForm[`${field.key}Max`]" class="search-field__control--mini" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- 操作区只放「执行」类动作;模式切换统一在卡片头的分段控件上,
|
||||
不再让按钮在不同模式下变形 -->
|
||||
<!-- 操作区只放「执行」类动作;模式切换由「高级查询」入口承担 -->
|
||||
<div class="list-search__actions">
|
||||
<template v-if="activeQueryMode === 'quick'">
|
||||
<div class="action-group">
|
||||
@@ -684,32 +798,27 @@ function onMoreAction({ key }) {
|
||||
<span v-if="!quickSearchExpanded && extraQuickConditionCount" class="query-count">{{ extraQuickConditionCount }}</span>
|
||||
<ChevronDown :size="14" :class="{ 'is-up': quickSearchExpanded }" />
|
||||
</Button>
|
||||
<Button type="ghost" class="advanced-query-button advanced-query-button--ghost" @click="selectQueryMode('advanced')">
|
||||
<Button type="ghost" class="advanced-query-button advanced-query-button--ghost" @click="enterAdvancedMode">
|
||||
<SlidersHorizontal :size="14" />
|
||||
高级查询
|
||||
<span v-if="advancedConditionCount" class="query-count">{{ advancedConditionCount }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="action-group">
|
||||
<Button type="outline" class="advanced-query-button" @click="openQueryDrawer('conditions')">
|
||||
<SlidersHorizontal :size="14" /> 编辑高级查询
|
||||
<span v-if="advancedConditionCount" class="query-count">{{ advancedConditionCount }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="action-group">
|
||||
<Button type="ghost" @click="returnToQuickQuery">改用简单查询</Button>
|
||||
<Button type="ghost" @click="resetAllQueries">清除查询</Button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="queryApplied && querySummary.length" class="active-query-bar">
|
||||
<span class="active-query-label">{{ activeQueryMode === 'quick' ? '简单查询生效' : '高级查询生效' }}</span>
|
||||
<!-- 树条件 chip:高级模式下树面板被轮换隐藏,靠这枚 chip 保持可见、可清除 -->
|
||||
<span v-if="selectedTreeNodeLabel && selectedTreeNodeLabel !== '全部分类'" class="active-query-item" :title="`分类 ${selectedTreeNodeLabel}`"><span>分类 {{ selectedTreeNodeLabel }}</span><button type="button" aria-label="清除分类条件" @click="selectedTreeNodeId = ''">×</button></span>
|
||||
<span v-for="item in querySummary" :key="item.id" class="active-query-item" :title="item.text"><span>{{ item.text }}</span><button type="button" aria-label="移除条件" @click="removeSummaryItem(item)">×</button></span>
|
||||
<button class="clear-query" @click="resetAllQueries">清除全部</button>
|
||||
</div>
|
||||
<!-- 未应用查询时也保持条位(空态):树条件始终有处可看,不随模式切换跳动 -->
|
||||
<div v-else-if="selectedTreeNodeLabel && selectedTreeNodeLabel !== '全部分类'" class="active-query-bar">
|
||||
<span class="active-query-label">固定条件</span>
|
||||
<span class="active-query-item" :title="`分类 ${selectedTreeNodeLabel}`"><span>分类 {{ selectedTreeNodeLabel }}</span><button type="button" aria-label="清除分类条件" @click="selectedTreeNodeId = ''">×</button></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ②③④ 表格卡:工具条 + 表格 + 分页条,撑满剩余高度 -->
|
||||
@@ -769,7 +878,7 @@ function onMoreAction({ key }) {
|
||||
<div class="list-table">
|
||||
<FmsTable
|
||||
:columns="columns"
|
||||
:data-source="allRows"
|
||||
:data-source="filteredRows"
|
||||
:row-class-name="rowClassName"
|
||||
:row-selection="rowSelection"
|
||||
row-key="id"
|
||||
@@ -779,68 +888,25 @@ function onMoreAction({ key }) {
|
||||
<!-- 底部分页条:仅展示,不与表格数据联动(demo 阶段表格恒为 1w 行全量) -->
|
||||
<div class="list-footer">
|
||||
<span class="footer-total">{{ totalText }}</span>
|
||||
<Pagination :total="allRows.length" show-size-changer />
|
||||
<Pagination :total="filteredRows.length" show-size-changer />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 查询 Drawer:高级条件与查询设置使用独立入口,共用 Drawer 外壳 -->
|
||||
<div v-if="drawerOpen" class="query-drawer-mask" :class="{ 'query-drawer-mask--light': drawerTab === 'settings' }" @click.self="closeQueryDrawer">
|
||||
<aside class="query-drawer" :class="{ 'query-drawer--narrow': drawerTab === 'settings' }">
|
||||
<!-- 查询设置:右侧抽屉(浅遮罩),两个模式共用 -->
|
||||
<div v-if="settingsOpen" class="query-drawer-mask--light" @click.self="closeSettings">
|
||||
<aside class="query-drawer query-drawer--narrow">
|
||||
<div class="query-drawer__head">
|
||||
<div>
|
||||
<strong>{{ drawerTab === 'conditions' ? '高级查询' : '查询设置' }}</strong>
|
||||
<span class="query-drawer__sub">{{ drawerTab === 'conditions' ? '设置本次查询需要满足的条件,将替代常用条件' : '调整常用条件的显示和顺序,改动即时生效' }}</span>
|
||||
<strong>查询设置</strong>
|
||||
<span class="query-drawer__sub">调整常用条件的显示和顺序,改动即时生效</span>
|
||||
</div>
|
||||
<button class="query-drawer__close" aria-label="关闭" @click="closeQueryDrawer">×</button>
|
||||
<button class="query-drawer__close" aria-label="关闭" @click="closeSettings">×</button>
|
||||
</div>
|
||||
<div v-if="drawerTab === 'conditions'" class="query-drawer__body">
|
||||
<div class="drawer-section-title">当前条件</div>
|
||||
<div class="ast-root"><span>满足</span><select v-model="advancedLogic"><option value="and">全部条件</option><option value="or">任一条件</option></select></div>
|
||||
<div v-if="!advancedNodes.length && !advancedGroups.length" class="ast-empty">
|
||||
<p>暂无条件</p>
|
||||
<Button type="primary" @click="addAdvancedNode">+ 添加条件</Button>
|
||||
</div>
|
||||
<div v-for="node in advancedNodes" :key="node.id" class="ast-row">
|
||||
<div class="ast-field-wrap">
|
||||
<select v-model="node.field" class="ast-field" @change="onNodeFieldChange(node)">
|
||||
<option v-for="field in advancedSearchFields" :key="field.key" :value="field.key">{{ field.label }}</option>
|
||||
</select>
|
||||
<span v-if="fieldByKey[node.field]?.relation" class="relation-badge">关联</span>
|
||||
</div>
|
||||
<select v-model="node.operator" class="ast-op" @change="onNodeOperatorChange(node)">
|
||||
<option v-for="operator in getNodeOperators(node)" :key="operator.value" :value="operator.value">{{ operator.label }}</option>
|
||||
</select>
|
||||
<Input v-if="fieldByKey[node.field]?.type === 'input'" v-model="node.value" class="ast-value" placeholder="请输入值" />
|
||||
<RangePicker v-else-if="fieldByKey[node.field]?.type === 'daterange'" v-model="node.value" class="ast-value" />
|
||||
<div v-else-if="fieldByKey[node.field]?.type === 'numrange'" class="ast-range-value">
|
||||
<template v-if="node.operator === 'between'"><Input v-model="node.value[0]" placeholder="最小值" /><span>至</span><Input v-model="node.value[1]" placeholder="最大值" /></template>
|
||||
<Input v-else v-model="node.value" placeholder="请输入数值" />
|
||||
</div>
|
||||
<select v-else v-model="node.value" class="ast-value">
|
||||
<option value="">请选择</option>
|
||||
<option v-for="option in getFieldOptions(node.field)" :key="option.value" :value="option.value">{{ option.label }}</option>
|
||||
</select>
|
||||
<button class="ast-remove" title="删除条件" @click="removeAdvancedNode(node.id)">×</button>
|
||||
</div>
|
||||
<div v-for="group in advancedGroups" :key="group.id" class="ast-group">
|
||||
<div class="ast-group__head"><span>条件组</span><select v-model="group.logic"><option value="and">全部满足</option><option value="or">任一满足</option></select><button type="button" @click="advancedGroups.splice(advancedGroups.indexOf(group), 1)">删除组</button></div>
|
||||
<div v-for="node in group.nodes" :key="node.id" class="ast-row ast-row--nested">
|
||||
<div class="ast-field-wrap"><select v-model="node.field" class="ast-field" @change="onNodeFieldChange(node)"><option v-for="field in advancedSearchFields" :key="field.key" :value="field.key">{{ field.label }}</option></select><span v-if="fieldByKey[node.field]?.relation" class="relation-badge">关联</span></div>
|
||||
<select v-model="node.operator" class="ast-op" @change="onNodeOperatorChange(node)"><option v-for="operator in getNodeOperators(node)" :key="operator.value" :value="operator.value">{{ operator.label }}</option></select>
|
||||
<Input v-if="fieldByKey[node.field]?.type === 'input'" v-model="node.value" class="ast-value" placeholder="请输入值" />
|
||||
<RangePicker v-else-if="fieldByKey[node.field]?.type === 'daterange'" v-model="node.value" class="ast-value" />
|
||||
<div v-else-if="fieldByKey[node.field]?.type === 'numrange'" class="ast-range-value"><template v-if="node.operator === 'between'"><Input v-model="node.value[0]" placeholder="最小值" /><span>至</span><Input v-model="node.value[1]" placeholder="最大值" /></template><Input v-else v-model="node.value" placeholder="请输入数值" /></div>
|
||||
<select v-else v-model="node.value" class="ast-value"><option value="">请选择</option><option v-for="option in getFieldOptions(node.field)" :key="option.value" :value="option.value">{{ option.label }}</option></select>
|
||||
<button class="ast-remove" title="删除条件" @click="removeAdvancedNode(node.id)">×</button>
|
||||
</div>
|
||||
<Button type="ghost" @click="group.nodes.push(createAdvancedNode(advancedSearchFields[0]?.key || 'creator'))">+ 条件</Button>
|
||||
</div>
|
||||
<div class="ast-hint"><LockKeyhole :size="14" /> 无权限字段不会出现在字段选择器中</div>
|
||||
<div class="drawer-actions"><Button type="outline" @click="addAdvancedNode">+ 添加条件</Button><Button type="outline" @click="addAdvancedGroup">+ 添加条件组</Button><Button type="ghost" class="clear-conditions" @click="resetAdvancedQuery">清空条件</Button></div>
|
||||
</div>
|
||||
<div v-else class="query-drawer__body settings-body">
|
||||
<div class="drawer-section-title">我的查询布局 <span>只影响当前用户</span></div>
|
||||
<div class="query-drawer__body settings-body">
|
||||
<div class="settings-section-title">我的查询布局 <span>只影响当前用户</span></div>
|
||||
<div class="settings-note">拖动调整常用条件顺序,也可以把字段移到高级条件或隐藏。</div>
|
||||
<div class="setting-group"><div class="setting-group__head"><strong>页面常用条件</strong><span>直接显示在列表顶部</span></div><div v-for="field in searchFields.filter((item) => queryPrefs[item.key].area === 'quick').toSorted((a, b) => queryPrefs[a.key].order - queryPrefs[b.key].order)" :key="field.key" class="setting-row" :class="rowDragClass(field)" draggable="true" @dragstart="startFieldDrag(field, $event)" @dragover.prevent="dragOverFieldKey = field.key" @dragleave="dragOverFieldKey = ''" @drop="dropField(field, 'quick')" @dragend="endFieldDrag"><GripVertical :size="14" class="drag-handle" /><span class="setting-label">{{ field.label }}</span><button class="setting-first" @click="setFirstQuick(field)">设为第一个</button><button class="setting-more" @click="setFieldArea(field,'advanced')">移到高级</button></div></div>
|
||||
<div class="setting-group"><div class="setting-group__head"><strong>仅高级条件</strong><span>不占页面空间</span></div><div v-for="field in searchFields.filter((item) => queryPrefs[item.key].area === 'advanced').toSorted((a, b) => queryPrefs[a.key].order - queryPrefs[b.key].order)" :key="field.key" class="setting-row" :class="rowDragClass(field)" draggable="true" @dragstart="startFieldDrag(field, $event)" @dragover.prevent="dragOverFieldKey = field.key" @dragleave="dragOverFieldKey = ''" @drop="dropField(field, 'advanced')" @dragend="endFieldDrag"><GripVertical :size="14" class="drag-handle" /><span class="setting-label">{{ field.label }}</span><button class="setting-first" @click="setFieldArea(field,'quick')">移到常用</button><button class="setting-more" @click="setFieldArea(field,'hidden')">隐藏</button></div></div>
|
||||
@@ -848,15 +914,8 @@ function onMoreAction({ key }) {
|
||||
<div class="setting-group setting-group--muted"><div class="setting-group__head"><strong>不可用字段</strong><span>字段权限限制</span></div><div v-for="field in searchFields.filter((item) => item.queryable === false)" :key="field.key" class="setting-row is-disabled"><LockKeyhole :size="14" /><span class="setting-label">{{ field.label }}</span><span class="setting-disabled">无查询权限</span></div></div>
|
||||
</div>
|
||||
<div class="query-drawer__foot">
|
||||
<span v-if="drawerTab === 'conditions'" class="ast-foot-note">已配置 {{ advancedConditionCount }} 个条件</span>
|
||||
<template v-if="drawerTab === 'conditions'">
|
||||
<Button type="outline" @click="closeQueryDrawer">取消</Button>
|
||||
<Button type="primary" @click="applyAdvancedQuery">应用查询</Button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Button type="ghost" @click="resetQueryPrefs">恢复默认</Button>
|
||||
<Button type="primary" @click="drawerOpen = false">完成设置</Button>
|
||||
</template>
|
||||
<Button type="ghost" @click="resetQueryPrefs">恢复默认</Button>
|
||||
<Button type="primary" @click="closeSettings">完成设置</Button>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
@@ -879,6 +938,171 @@ function onMoreAction({ key }) {
|
||||
color: var(--fms-text);
|
||||
}
|
||||
|
||||
/* ---- 查询设置抽屉的节标题 ---- */
|
||||
.settings-section-title { display: flex; align-items: baseline; gap: 8px; margin-bottom: 10px; color: var(--fms-text); font-size: 13px; font-weight: 600; }
|
||||
.settings-section-title span { color: var(--fms-text-secondary); font-size: 12px; font-weight: 400; }
|
||||
|
||||
/* 高级模式骨架调整:改为横排(左栏 + 右主列)、取消固定 min-width(表格自己横向滚) */
|
||||
.dashboard-page--row {
|
||||
flex-direction: row;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 横排时搜索卡允许收缩(树占掉一列后,搜索卡定宽 4 列会把表格挤没),
|
||||
容器查询随之降列数;纯列表(纵排)模式不受影响 */
|
||||
.dashboard-page--row .search-card {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 右侧主列:搜索卡 + 表格卡纵向堆叠,占满左栏以外的全部宽度 */
|
||||
.dashboard-main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* ---- 左侧常驻高级面板:无遮罩,全高,表格保持可交互 ---- */
|
||||
.advanced-panel {
|
||||
display: flex;
|
||||
flex: none;
|
||||
flex-direction: column;
|
||||
width: 360px;
|
||||
min-height: 0;
|
||||
border-radius: 6px;
|
||||
background: var(--fms-card);
|
||||
box-shadow: 0 2px 6px rgb(31 35 41 / 4%);
|
||||
}
|
||||
|
||||
/* ---- 原型布局切换(正式页面删除):右下角浮动小工具条 ---- */
|
||||
.layout-switch {
|
||||
position: fixed;
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
z-index: 60;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 6px;
|
||||
border-radius: 8px;
|
||||
background: var(--fms-card);
|
||||
box-shadow: 0 4px 16px rgb(31 35 41 / 18%);
|
||||
}
|
||||
|
||||
/* ---- 树面板(模拟树+表格页):与高级面板同宽同位,轮班占左栏 ---- */
|
||||
.tree-panel {
|
||||
display: flex;
|
||||
flex: none;
|
||||
flex-direction: column;
|
||||
width: 360px;
|
||||
min-height: 0;
|
||||
border-radius: 6px;
|
||||
background: var(--fms-card);
|
||||
box-shadow: 0 2px 6px rgb(31 35 41 / 4%);
|
||||
}
|
||||
|
||||
.tree-panel__head {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 14px 10px;
|
||||
}
|
||||
|
||||
.tree-panel__head strong {
|
||||
font-size: 15px;
|
||||
color: var(--fms-text);
|
||||
}
|
||||
|
||||
.tree-panel__body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 0 8px 10px;
|
||||
}
|
||||
|
||||
.tree-node {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 7px 10px;
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
color: var(--fms-text);
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tree-node:hover {
|
||||
background: var(--fms-background, #f5f6f8);
|
||||
}
|
||||
|
||||
.tree-node.is-active {
|
||||
background: color-mix(in srgb, var(--fms-primary) 10%, transparent);
|
||||
color: var(--fms-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.advanced-panel__head {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 14px 14px 10px;
|
||||
}
|
||||
|
||||
.advanced-panel__head strong {
|
||||
display: block;
|
||||
font-size: 15px;
|
||||
color: var(--fms-text);
|
||||
}
|
||||
|
||||
.advanced-panel__head-actions {
|
||||
display: flex;
|
||||
flex: none;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.panel-icon-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
color: var(--fms-text-secondary);
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.panel-icon-button:hover {
|
||||
background: var(--fms-background, #f5f6f8);
|
||||
color: var(--fms-text);
|
||||
}
|
||||
|
||||
.advanced-panel__body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 4px 14px 10px;
|
||||
}
|
||||
|
||||
.advanced-panel__foot {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 14px;
|
||||
border-top: 1px solid var(--fms-border-soft, #f0f1f3);
|
||||
}
|
||||
|
||||
/* 白卡公共样式。表格卡:撑满剩余高度、内部滚动;
|
||||
留白统一由卡的 padding + gap 承担,各段之间不用 border 分隔 */
|
||||
.table-card {
|
||||
@@ -908,16 +1132,6 @@ function onMoreAction({ key }) {
|
||||
/* 右上角固定位:绝对定位不占流内高度(查询区本身就不宽裕),
|
||||
右缘与内容区(padding 16)对齐 */
|
||||
.search-card__settings { position: absolute; top: 10px; right: 16px; z-index: 1; }
|
||||
/* 高级模式:常用条件降权(低透明度 + 去饱和 + 禁用交互)而不是消失、也不是遮罩。
|
||||
遮罩的语义是「阻塞/锁定」,这里只是「不参与本次查询」。
|
||||
只降权 .search-field,操作区按钮保持可用 */
|
||||
.list-search--muted .search-field {
|
||||
opacity: 0.45;
|
||||
filter: saturate(0.5);
|
||||
pointer-events: none;
|
||||
}
|
||||
/* 说明条压到最薄:它只在有已填常用条件时才出现,不该再抢版面 */
|
||||
.search-card .mode-notice { margin: 0; padding: 6px 10px; }
|
||||
.advanced-query-button { gap: 5px; }
|
||||
/* ghost(常用模式下的入口)用次要色,不与「查询/重置」抢视觉;
|
||||
outline(高级模式下的主操作)保持主色 */
|
||||
@@ -1168,10 +1382,7 @@ function onMoreAction({ key }) {
|
||||
.active-query-item button { width: 16px; height: 16px; padding: 0; border: 0; background: transparent; color: #7c8da6; font-size: 15px; line-height: 14px; cursor: pointer; }
|
||||
.active-query-item button:hover { color: var(--fms-danger); }
|
||||
.clear-query { flex: none; border: 0; background: transparent; color: var(--fms-primary); font-size: 12px; }
|
||||
.query-drawer-mask { position: fixed; z-index: 50; inset: 0; background: rgb(31 35 41 / 32%); }
|
||||
/* 设置面板:遮罩压到最浅,让左侧搜索区保持可见 ——
|
||||
调顺序、移分组时能直接看到常用区的实时变化,不用关掉才知道结果 */
|
||||
.query-drawer-mask--light { background: rgb(31 35 41 / 8%); }
|
||||
.query-drawer-mask--light { position: fixed; z-index: 50; inset: 0; background: rgb(31 35 41 / 8%); }
|
||||
.query-drawer {
|
||||
display: flex;
|
||||
width: min(760px, 100%);
|
||||
@@ -1189,35 +1400,36 @@ function onMoreAction({ key }) {
|
||||
.query-drawer__close { width: 30px; height: 30px; border: 0; border-radius: 5px; background: transparent; color: var(--fms-text-secondary); font-size: 22px; }
|
||||
.query-drawer__close:hover { background: var(--fms-background, #f5f6f8); }
|
||||
.query-drawer__body { flex: 1; min-height: 0; overflow: auto; padding: 18px 20px; }
|
||||
.mode-notice { display: flex; align-items: flex-start; gap: 8px; margin-bottom: 16px; padding: 9px 10px; border: 1px solid #d8e5f7; border-radius: 5px; background: #f7faff; color: #526176; font-size: 12px; line-height: 1.5; }
|
||||
.mode-notice__dot { flex: none; width: 7px; height: 7px; margin-top: 5px; border-radius: 50%; background: var(--fms-primary); }
|
||||
.drawer-section-title { display: flex; align-items: baseline; gap: 8px; margin-bottom: 10px; color: var(--fms-text); font-size: 13px; font-weight: 600; }
|
||||
.drawer-section-title span { color: var(--fms-text-secondary); font-size: 12px; font-weight: 400; }
|
||||
/* ---- 高级面板:条件行窄版两行堆叠(第一行字段+删除,第二行操作符+值) ---- */
|
||||
.ast-root { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; color: var(--fms-text-secondary); font-size: 12px; }
|
||||
.ast-root select, .ast-row select { height: 34px; border: 1px solid var(--fms-border); border-radius: 4px; background: var(--fms-card); color: var(--fms-text); padding: 0 9px; outline: none; }
|
||||
.ast-empty { display: flex; flex-direction: column; align-items: center; gap: 10px; margin: 8px 0 12px; padding: 22px 12px; border: 1px dashed var(--fms-border); border-radius: 5px; color: var(--fms-text-secondary); font-size: 12px; text-align: center; }
|
||||
.ast-empty p { margin: 0; }
|
||||
.ast-row { display: grid; grid-template-columns: minmax(160px, 1.25fr) 150px minmax(130px, 1fr) 30px; gap: 8px; align-items: center; margin-bottom: 8px; }
|
||||
.ast-row { display: flex; flex-direction: column; gap: 6px; margin-bottom: 8px; padding: 8px; border: 1px solid var(--fms-border-soft, #f0f1f3); border-radius: 6px; background: var(--fms-background, #fafbfc); }
|
||||
.ast-row__top { display: flex; align-items: center; gap: 6px; min-width: 0; }
|
||||
.ast-row__top .ast-field-wrap { flex: 1; min-width: 0; }
|
||||
.ast-row__bottom { display: flex; align-items: center; gap: 6px; min-width: 0; }
|
||||
.ast-row__bottom .ast-op { flex: none; width: 96px; }
|
||||
.ast-row__bottom .ast-value { flex: 1; min-width: 0; }
|
||||
/* 条件组:左侧粗竖线表达嵌套层级,让「这一组是一个整体」一眼可辨 */
|
||||
.ast-group { margin: 12px 0; padding: 10px; border: 1px solid #dfe6ef; border-left: 3px solid #b9d3f5; border-radius: 6px; background: #fbfcfe; }
|
||||
.ast-group__head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; color: var(--fms-text-secondary); font-size: 12px; }
|
||||
.ast-group__head select { height: 30px; }
|
||||
.ast-group__head button { margin-left: auto; border: 0; background: transparent; color: var(--fms-danger); font-size: 12px; cursor: pointer; }
|
||||
.ast-row--nested { margin-left: 8px; }
|
||||
.ast-row--nested { margin-left: 4px; }
|
||||
.ast-field-wrap { position: relative; display: flex; min-width: 0; align-items: center; gap: 5px; }
|
||||
.ast-field-wrap .ast-field { min-width: 0; flex: 1; }
|
||||
.relation-badge { flex: none; padding: 2px 5px; border-radius: 3px; background: #f3edff; color: #7048b8; font-size: 10px; }
|
||||
.ast-value { width: 100%; min-width: 0; }
|
||||
.ast-range-value { display: flex; min-width: 0; align-items: center; gap: 5px; }
|
||||
.ast-range-value :deep(input) { min-width: 0; flex: 1; }
|
||||
.ast-range-value :deep(input) { width: 100%; }
|
||||
.ast-remove { width: 28px; height: 28px; border: 0; border-radius: 4px; background: transparent; color: var(--fms-text-secondary); font-size: 18px; }
|
||||
.ast-range-value :deep(input) { min-width: 0; flex: 1; width: 100%; }
|
||||
.ast-remove { flex: none; width: 28px; height: 28px; border: 0; border-radius: 4px; background: transparent; color: var(--fms-text-secondary); font-size: 18px; cursor: pointer; }
|
||||
.ast-remove:hover { background: #fff1f0; color: var(--fms-danger); }
|
||||
.ast-hint { display: flex; align-items: center; gap: 6px; margin: 12px 0; color: var(--fms-text-secondary); font-size: 12px; }
|
||||
.drawer-actions { display: flex; gap: 8px; padding-top: 12px; border-top: 1px dashed var(--fms-border); }
|
||||
/* 清空条件:从 footer 挪到条件列表底部并靠右,避免和「取消/应用」两个放弃类动作挤在一起误点 */
|
||||
.ast-actions { display: flex; gap: 8px; padding-top: 12px; border-top: 1px dashed var(--fms-border); }
|
||||
/* 清空条件:靠右,避免和「添加」类动作挤在一起误点 */
|
||||
.clear-conditions { margin-left: auto; color: var(--fms-text-secondary) !important; }
|
||||
.clear-conditions:hover { color: var(--fms-danger) !important; }
|
||||
.ast-foot-note { flex: 1; color: var(--fms-text-secondary); font-size: 12px; }
|
||||
.query-drawer__foot { display: flex; flex: none; align-items: center; justify-content: flex-end; gap: 8px; padding: 12px 20px; border-top: 1px solid var(--fms-border-soft, #f0f1f3); }
|
||||
.ast-foot-note { flex: 1; color: var(--fms-text-secondary); font-size: 12px; }
|
||||
.settings-note { margin-bottom: 14px; padding: 9px 10px; border: 1px solid #e8edf3; border-radius: 5px; background: #f7f9fc; color: var(--fms-text-secondary); font-size: 12px; }
|
||||
@@ -1232,5 +1444,5 @@ function onMoreAction({ key }) {
|
||||
否则用户拖的时候不知道会插到哪 */
|
||||
.setting-row.is-dragging { opacity: 0.45; background: #f2f7ff; }
|
||||
.setting-row.is-drop-target { box-shadow: inset 0 2px 0 0 var(--fms-primary, #1677ff); }.setting-label { flex: 1; min-width: 0; color: var(--fms-text); }.setting-row button { border: 0; background: transparent; color: var(--fms-primary); font-size: 12px; }.setting-row button:hover { text-decoration: underline; }.setting-more { color: var(--fms-text-secondary) !important; }.setting-row.is-disabled { color: var(--fms-disabled-text); }.setting-row.is-disabled .setting-label { color: var(--fms-disabled-text); }.setting-disabled { color: var(--fms-disabled-text); font-size: 12px; }
|
||||
@media (max-width: 760px) { .query-drawer__body { padding-inline: 12px; }.query-drawer__head, .query-drawer__foot { padding-inline: 12px; }.ast-row { grid-template-columns: 1fr 1fr; }.ast-row .ast-value { grid-column: 1 / span 2; }.ast-remove { justify-self: end; margin-top: -38px; } .active-query-bar { flex-wrap: wrap; } }
|
||||
@media (max-width: 760px) { .query-drawer__body { padding-inline: 12px; }.query-drawer__head, .query-drawer__foot { padding-inline: 12px; }.active-query-bar { flex-wrap: wrap; } }
|
||||
</style>
|
||||
@@ -161,7 +161,6 @@ function onFailed() {
|
||||
id="orgId"
|
||||
v-model="formModel.orgId"
|
||||
placeholder="请输入机构码"
|
||||
:disabled="submitting"
|
||||
>
|
||||
<template #prefix><Building2 /></template>
|
||||
</Input>
|
||||
@@ -173,7 +172,6 @@ function onFailed() {
|
||||
v-model="formModel.account"
|
||||
autocomplete="username"
|
||||
placeholder="请输入账号"
|
||||
:disabled="submitting"
|
||||
>
|
||||
<template #prefix><User /></template>
|
||||
</Input>
|
||||
@@ -186,18 +184,13 @@ function onFailed() {
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
placeholder="请输入密码"
|
||||
:disabled="submitting"
|
||||
>
|
||||
<template #prefix><KeyRound /></template>
|
||||
</Input>
|
||||
</FormItem>
|
||||
|
||||
<FormItem name="captcha">
|
||||
<SliderCaptcha
|
||||
ref="captchaRef"
|
||||
v-model="formModel.captcha"
|
||||
:disabled="submitting"
|
||||
/>
|
||||
<SliderCaptcha ref="captchaRef" v-model="formModel.captcha" />
|
||||
</FormItem>
|
||||
|
||||
<Button type="primary" block :loading="submitting" html-type="submit" class="login-submit">
|
||||
|
||||
+170
-850
File diff suppressed because it is too large.
Load diff
@@ -12,6 +12,18 @@
|
||||
|
||||
5. **当前系统按内部 ERP 处理** — 默认使用场景是已登录的内部用户和受控网络。当前需求阶段不额外引入复杂的安全防护层、SQL 注入专项改造或前后端重复校验;除非用户明确提出,不因安全假设新增接口、中间层或配置系统。查询条件可以信任前端传入,后端重点完成查询、分页和数据格式转换。
|
||||
|
||||
6. **SQL 驱动优先** — 本系统是 SQL 驱动的内部 ERP。能通过 SQL 完成的查询、关联、过滤、排序、分页、聚合、权限过滤和数据范围过滤,优先在 SQL 层完成,不在业务代码中加载大量数据后再处理。
|
||||
|
||||
7. **优先使用配置驱动的动态 SQL** — 模块、字段、查询条件、排序、字段权限和数据范围等存在于表结构中的规则,应由通用查询逻辑读取配置并动态生成 SQL。动态部分只允许来自受控的模块元数据、字段定义、操作符白名单和权限配置;业务值使用参数传递,不把任意前端字符串直接拼接为 SQL。
|
||||
|
||||
8. **动态配置不等于任意 SQL** — 配置可以决定查询哪些表、字段和条件,但必须经过现有元数据和业务规则校验。不得为了追求灵活,把整段未审查的 SQL 保存到配置表并直接执行;确需使用原生 SQL 时,应限制在明确的模块或服务边界内。
|
||||
|
||||
9. **固定 SQL 只用于稳定且明确的特殊逻辑** — 当 SQL 由固定业务规则组成,或动态生成会明显降低可读性、性能和可维护性时,可以使用固定 SQL。固定 SQL 应保持通用,不为单个页面或单个用户复制一套接口和查询。
|
||||
|
||||
10. **权限过滤以后端 SQL 为准** — 前端可以根据权限隐藏菜单、按钮和字段,但最终的模块操作权限、字段可见/可查询/可导出权限以及数据范围,必须在后端查询或写入 SQL 中落实。用户个性化配置只能调整布局,不能放宽权限。
|
||||
|
||||
SQL 驱动的实现应优先复用通用查询、保存和权限计算逻辑。模块之间的差异通过 `s_module`、`s_field`、`s_field_view`、`s_field_edit`、`s_field_query`、权限表和数据范围表配置;新增模块或字段时,优先增加配置数据,不复制一套模块专用 SQL。动态 SQL 只动态替换受控的标识符和条件结构,参数值仍单独传递。
|
||||
|
||||
## 前端代码风格
|
||||
|
||||
1. **少用语法糖** — 优先使用直白、易读的写法,避免为了炫技而使用语法糖,可读性优先。
|
||||
|
||||
Reference in new issue
Block a user