Files
workspace/code/fms/权限分配原型.html
T
2026-09-19 21:17:02 +08:00

2351 lines
103 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>FMS 权限分配原型</title>
<style>
:root {
--bg: #f4f7fb;
--surface: #ffffff;
--surface-muted: #f8fafc;
--surface-blue: #f0f6ff;
--line: #dfe7f1;
--line-strong: #c9d6e5;
--text: #162334;
--text-muted: #6d7c8f;
--text-soft: #8c9aac;
--blue: #1f67d6;
--blue-dark: #1451ad;
--blue-soft: #e8f1ff;
--green: #17835b;
--green-soft: #e9f8f1;
--amber: #a66608;
--amber-soft: #fff5df;
--red: #bd4d52;
--red-soft: #fff0f0;
--shadow: 0 12px 32px rgba(27, 52, 83, 0.08);
--radius: 12px;
--radius-sm: 8px;
--font: "Segoe UI", "Microsoft YaHei", "PingFang SC", Arial, sans-serif;
}
* { box-sizing: border-box; }
html, body { min-height: 100%; }
body {
margin: 0;
color: var(--text);
background: var(--bg);
font: 14px/1.5 var(--font);
}
button, input, select { font: inherit; }
button {
border: 0;
cursor: pointer;
}
button:focus-visible,
input:focus-visible,
select:focus-visible {
outline: 3px solid rgba(31, 103, 214, 0.22);
outline-offset: 2px;
}
.icon {
width: 17px;
height: 17px;
flex: 0 0 auto;
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 1.8;
}
.app-shell {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.topbar {
min-height: 68px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 28px;
background: var(--surface);
border-bottom: 1px solid var(--line);
}
.brand,
.brand-mark,
.top-actions,
.crumbs,
.user-summary,
.button-row,
.inline,
.tab-button,
.section-title,
.page-title,
.status-line,
.node-main,
.node-meta,
.user-row,
.mini-stat,
.module-heading,
.module-meta,
.summary-item,
.notice,
.dependency-row,
.modal-actions {
display: flex;
align-items: center;
}
.brand { gap: 12px; }
.brand-mark {
width: 34px;
height: 34px;
justify-content: center;
color: #fff;
background: var(--blue);
border-radius: 9px;
}
.brand-name {
font-weight: 700;
letter-spacing: 0.02em;
}
.brand-subtitle {
margin-left: 5px;
color: var(--text-soft);
font-size: 12px;
font-weight: 400;
}
.top-actions { gap: 12px; }
.save-status {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--text-soft);
font-size: 12px;
white-space: nowrap;
}
.save-status::before {
width: 7px;
height: 7px;
content: "";
background: var(--green);
border-radius: 50%;
}
.save-status.pending { color: var(--amber); }
.save-status.pending::before { background: #d79a26; }
.top-discard { display: none; }
.top-discard.visible { display: inline-flex; }
.top-link {
display: inline-flex;
align-items: center;
gap: 7px;
min-height: 40px;
padding: 0 11px;
color: var(--text-muted);
background: transparent;
border-radius: var(--radius-sm);
}
.top-link:hover { color: var(--text); background: var(--surface-muted); }
.save-button,
.primary-button {
min-height: 40px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 0 16px;
color: #fff;
background: var(--blue);
border-radius: var(--radius-sm);
box-shadow: 0 4px 12px rgba(31, 103, 214, 0.2);
transition: transform 160ms ease, background 160ms ease, box-shadow 160ms ease;
}
.save-button:hover,
.primary-button:hover {
background: var(--blue-dark);
box-shadow: 0 6px 16px rgba(31, 103, 214, 0.26);
transform: translateY(-1px);
}
.body-shell {
min-height: 0;
flex: 1;
display: flex;
}
.users-panel {
width: 252px;
flex: 0 0 252px;
padding: 22px 14px;
background: var(--surface);
border-right: 1px solid var(--line);
}
.panel-label {
margin: 0 12px 10px;
color: var(--text-soft);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.search-box {
height: 38px;
display: flex;
align-items: center;
gap: 8px;
margin: 0 2px 18px;
padding: 0 11px;
color: var(--text-soft);
background: var(--surface-muted);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
}
.search-box input {
width: 100%;
min-width: 0;
color: var(--text);
background: transparent;
border: 0;
outline: none;
}
.user-list { display: grid; gap: 5px; }
.user-row {
width: 100%;
gap: 10px;
padding: 11px 10px;
text-align: left;
color: var(--text-muted);
background: transparent;
border: 1px solid transparent;
border-radius: 10px;
transition: background 160ms ease, border-color 160ms ease, color 160ms ease;
}
.user-row:hover { background: var(--surface-muted); color: var(--text); }
.user-row.active {
color: var(--blue-dark);
background: var(--surface-blue);
border-color: #cfe0fb;
}
.avatar {
width: 34px;
height: 34px;
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
color: var(--blue-dark);
background: #dbeaff;
border-radius: 50%;
font-size: 12px;
font-weight: 700;
}
.avatar.green { color: #106a4c; background: #d9f3e7; }
.avatar.orange { color: #8c5c09; background: #ffedc6; }
.user-copy { min-width: 0; }
.user-name {
overflow: hidden;
color: inherit;
font-weight: 650;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-role {
overflow: hidden;
color: var(--text-soft);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-row.active .user-role { color: #6284b4; }
.workspace {
min-width: 0;
flex: 1;
padding: 26px 30px 34px;
overflow: auto;
}
.workspace-head {
max-width: 1540px;
margin: 0 auto;
}
.crumbs {
gap: 7px;
margin-bottom: 7px;
color: var(--text-soft);
font-size: 12px;
}
.page-title {
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
}
.page-title h1 {
margin: 0;
font-size: 24px;
letter-spacing: -0.02em;
}
.page-title p {
margin: 5px 0 0;
color: var(--text-muted);
}
.user-summary {
gap: 10px;
padding: 8px 10px 8px 8px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 10px;
}
.summary-copy { line-height: 1.25; }
.summary-copy strong { display: block; font-size: 13px; }
.summary-copy span { color: var(--text-soft); font-size: 11px; }
.notice {
gap: 10px;
min-height: 47px;
margin-bottom: 18px;
padding: 10px 13px;
color: #315a8c;
background: var(--surface-blue);
border: 1px solid #cfe0fb;
border-radius: var(--radius-sm);
}
.notice strong { color: #1d4f8c; }
.authorization-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
margin-bottom: 15px;
padding: 15px 17px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.toolbar-copy { min-width: 0; }
.toolbar-eyebrow {
display: block;
margin-bottom: 4px;
color: var(--text-soft);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.toolbar-title {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin: 0;
font-size: 16px;
}
.toolbar-copy p { margin: 3px 0 0; color: var(--text-muted); font-size: 12px; }
.toolbar-controls {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 8px;
}
.toolbar-field {
display: inline-flex;
align-items: center;
gap: 7px;
color: var(--text-muted);
font-size: 12px;
white-space: nowrap;
}
.role-select {
min-height: 35px;
min-width: 170px;
padding: 0 27px 0 9px;
color: var(--text);
background: var(--surface);
border: 1px solid var(--line-strong);
border-radius: 7px;
}
.domain-overview {
margin-bottom: 15px;
padding: 16px 17px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.domain-overview-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 11px;
}
.domain-overview-head h2 { margin: 0; font-size: 14px; }
.domain-overview-head p { margin: 3px 0 0; color: var(--text-muted); font-size: 12px; }
.domain-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 9px;
}
.domain-card {
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
padding: 11px;
color: var(--text-muted);
background: var(--surface-muted);
border: 1px solid var(--line);
border-radius: 9px;
text-align: left;
transition: background 150ms ease, border-color 150ms ease, color 150ms ease, transform 150ms ease;
}
.domain-card:hover { color: var(--text); border-color: #b8cee9; transform: translateY(-1px); }
.domain-card.active { color: var(--blue-dark); background: var(--surface-blue); border-color: #aac8ec; }
.domain-card-icon {
width: 31px;
height: 31px;
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
color: var(--blue);
background: var(--surface);
border: 1px solid var(--line);
border-radius: 8px;
}
.domain-card-copy { min-width: 0; flex: 1; }
.domain-card-name { display: block; font-weight: 650; }
.domain-card-desc { display: block; overflow: hidden; color: var(--text-soft); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.domain-card.active .domain-card-desc { color: #6284b4; }
.role-badge {
display: inline-flex;
align-items: center;
min-height: 21px;
padding: 0 6px;
color: var(--blue-dark);
background: var(--blue-soft);
border: 1px solid #cfe0fb;
border-radius: 5px;
font-size: 10px;
white-space: nowrap;
}
.preview-actions {
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--line);
}
.preview-actions .ghost-button { flex: 1; }
.effective-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
margin-bottom: 15px;
}
.effective-stat {
padding: 10px;
background: var(--surface-muted);
border: 1px solid var(--line);
border-radius: 8px;
}
.effective-stat strong { display: block; font-size: 18px; }
.effective-stat span { color: var(--text-soft); font-size: 11px; }
.effective-section + .effective-section { margin-top: 16px; }
.effective-section h3 { margin: 0 0 8px; font-size: 13px; }
.effective-list { display: grid; gap: 7px; }
.effective-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 9px 10px;
background: var(--surface-muted);
border: 1px solid var(--line);
border-radius: 7px;
}
.effective-row strong { display: block; font-size: 12px; }
.effective-row span { display: block; color: var(--text-soft); font-size: 11px; }
.effective-empty { padding: 12px; color: var(--text-soft); background: var(--surface-muted); border-radius: 8px; font-size: 12px; }
.change-list { display: grid; gap: 7px; margin-top: 12px; }
.change-item {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 8px 9px;
color: var(--text-muted);
background: var(--amber-soft);
border: 1px solid #f3dfac;
border-radius: 7px;
font-size: 11px;
}
.change-item .icon { width: 14px; height: 14px; flex: 0 0 auto; color: var(--amber); }
.effective-modal .modal-body { max-height: min(590px, calc(100vh - 180px)); overflow: auto; }
.simple-workspace { display: grid; gap: 15px; }
.simple-intro {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
padding: 20px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.simple-intro h2 { margin: 7px 0 4px; font-size: 19px; letter-spacing: -0.02em; }
.simple-intro p { margin: 0; color: var(--text-muted); }
.simple-grid {
display: grid;
grid-template-columns: minmax(360px, 1.05fr) minmax(350px, 0.95fr);
gap: 15px;
align-items: stretch;
}
.business-card,
.simple-preview {
min-width: 0;
padding: 19px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.business-head,
.simple-preview-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
}
.business-head h2,
.simple-preview-head h2 { margin: 0; font-size: 18px; }
.business-head p,
.simple-preview-head p { margin: 4px 0 0; color: var(--text-muted); font-size: 12px; }
.business-icon {
width: 42px;
height: 42px;
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
color: var(--blue);
background: var(--blue-soft);
border: 1px solid #cfe0fb;
border-radius: 10px;
}
.business-modules {
display: flex;
flex-wrap: wrap;
gap: 7px;
margin: 18px 0;
padding: 11px;
background: var(--surface-muted);
border: 1px dashed var(--line-strong);
border-radius: 8px;
}
.business-module {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 29px;
padding: 0 9px;
color: var(--text-muted);
background: var(--surface);
border: 1px solid var(--line);
border-radius: 6px;
font-size: 12px;
}
.business-module .icon { width: 14px; height: 14px; color: var(--blue); }
.preset-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 9px;
}
.preset-title h3 { margin: 0; font-size: 13px; }
.preset-title span { color: var(--text-soft); font-size: 11px; }
.preset-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; }
.preset-button {
min-height: 78px;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 4px;
padding: 10px;
color: var(--text-muted);
background: var(--surface);
border: 1px solid var(--line-strong);
border-radius: 8px;
text-align: left;
transition: background 150ms ease, border-color 150ms ease, color 150ms ease, transform 150ms ease;
}
.preset-button:hover { color: var(--blue-dark); background: var(--surface-blue); border-color: #aac8ec; transform: translateY(-1px); }
.preset-button.active { color: var(--blue-dark); background: var(--surface-blue); border-color: var(--blue); box-shadow: inset 0 0 0 1px rgba(31, 103, 214, 0.08); }
.preset-button strong { font-size: 13px; }
.preset-button span { color: var(--text-soft); font-size: 11px; line-height: 1.35; }
.preset-button.active span { color: #6284b4; }
.preview-list { display: grid; gap: 9px; margin-top: 17px; }
.preview-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 45px;
padding: 9px 10px;
background: var(--surface-muted);
border: 1px solid var(--line);
border-radius: 8px;
}
.preview-row-main { min-width: 0; display: flex; align-items: center; gap: 9px; }
.preview-row-main .icon { color: var(--blue); }
.preview-row-copy { min-width: 0; }
.preview-row-name { display: block; font-weight: 650; }
.preview-row-desc { display: block; color: var(--text-soft); font-size: 11px; }
.simple-note {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 12px 14px;
color: var(--text-muted);
background: var(--surface-muted);
border: 1px solid var(--line);
border-radius: 9px;
font-size: 12px;
}
.simple-note .icon { color: var(--blue); margin-top: 1px; }
.advanced-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.advanced-bar p { margin: 0; color: var(--text-muted); font-size: 12px; }
.tabs {
display: flex;
gap: 5px;
margin-bottom: 14px;
padding-bottom: 1px;
border-bottom: 1px solid var(--line);
}
.tab-button {
gap: 8px;
min-height: 43px;
padding: 0 15px;
color: var(--text-muted);
background: transparent;
border-bottom: 2px solid transparent;
}
.tab-button:hover { color: var(--text); }
.tab-button.active {
color: var(--blue);
border-color: var(--blue);
font-weight: 650;
}
.view-panel { display: none; }
.view-panel.active { display: block; }
.module-layout {
display: grid;
grid-template-columns: minmax(255px, 0.78fr) minmax(390px, 1.35fr) minmax(245px, 0.74fr);
gap: 15px;
align-items: start;
}
.card {
min-width: 0;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.card-header {
min-height: 61px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 15px 17px;
border-bottom: 1px solid var(--line);
}
.card-header h2,
.card-header h3 {
margin: 0;
font-size: 14px;
}
.card-header p {
margin: 3px 0 0;
color: var(--text-soft);
font-size: 12px;
}
.small-button {
min-height: 33px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 0 10px;
color: var(--blue-dark);
background: var(--blue-soft);
border: 1px solid #cfe0fb;
border-radius: 7px;
font-size: 12px;
white-space: nowrap;
}
.small-button:hover { background: #dceaff; }
.ghost-button {
min-height: 33px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 0 10px;
color: var(--text-muted);
background: var(--surface);
border: 1px solid var(--line-strong);
border-radius: 7px;
font-size: 12px;
white-space: nowrap;
}
.ghost-button:hover { color: var(--text); background: var(--surface-muted); }
.tree-body { padding: 12px 10px 15px; }
.tree-root {
padding: 8px 8px 9px;
color: var(--text);
background: #fbfcfe;
border: 1px solid var(--line);
border-radius: 9px;
}
.tree-root-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 7px;
padding: 2px 3px 8px;
border-bottom: 1px dashed var(--line);
}
.node-row {
width: 100%;
min-height: 45px;
justify-content: space-between;
gap: 9px;
padding: 6px 7px;
color: var(--text-muted);
background: transparent;
border: 1px solid transparent;
border-radius: 8px;
text-align: left;
transition: background 150ms ease, border-color 150ms ease, color 150ms ease;
}
.node-row:hover { color: var(--text); background: var(--surface-muted); }
.node-row.selected {
color: var(--blue-dark);
background: var(--surface-blue);
border-color: #cfe0fb;
}
.node-row.domain { color: var(--text); font-weight: 650; }
.node-row.domain:hover { background: #eef4fb; }
.node-main { min-width: 0; gap: 8px; }
.node-main .icon { color: var(--text-soft); }
.node-row.selected .node-main .icon { color: var(--blue); }
.node-copy { min-width: 0; }
.node-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.node-code {
overflow: hidden;
color: var(--text-soft);
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.node-meta { gap: 6px; }
.tree-indent { padding-left: 22px; }
.check {
width: 17px;
height: 17px;
flex: 0 0 auto;
accent-color: var(--blue);
cursor: pointer;
}
.tag {
display: inline-flex;
align-items: center;
min-height: 22px;
padding: 0 7px;
color: var(--text-muted);
background: var(--surface-muted);
border: 1px solid var(--line);
border-radius: 5px;
font-size: 11px;
white-space: nowrap;
}
.tag.blue { color: var(--blue-dark); background: var(--blue-soft); border-color: #cfe0fb; }
.tag.green { color: var(--green); background: var(--green-soft); border-color: #c8ebdc; }
.tag.amber { color: var(--amber); background: var(--amber-soft); border-color: #f3dfac; }
.tag.red { color: var(--red); background: var(--red-soft); border-color: #f4cece; }
.tree-footer {
display: flex;
gap: 8px;
margin-top: 13px;
padding-top: 13px;
border-top: 1px solid var(--line);
}
.tree-footer button { flex: 1; }
.detail-body { padding: 18px; }
.module-heading {
justify-content: space-between;
gap: 14px;
margin-bottom: 17px;
}
.module-heading-main { min-width: 0; }
.module-heading h2 {
margin: 0;
font-size: 20px;
letter-spacing: -0.02em;
}
.module-heading p {
margin: 4px 0 0;
color: var(--text-muted);
}
.module-icon {
width: 43px;
height: 43px;
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
color: var(--blue);
background: var(--blue-soft);
border: 1px solid #cfe0fb;
border-radius: 10px;
}
.section-title {
justify-content: space-between;
gap: 10px;
margin: 20px 0 9px;
}
.section-title h3 {
margin: 0;
font-size: 13px;
}
.section-title span {
color: var(--text-soft);
font-size: 11px;
}
.permission-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.permission-card {
min-height: 67px;
display: flex;
align-items: center;
gap: 9px;
padding: 10px;
color: var(--text-muted);
background: var(--surface-muted);
border: 1px solid var(--line);
border-radius: 8px;
cursor: pointer;
transition: background 150ms ease, border-color 150ms ease, color 150ms ease;
}
.permission-card:hover { border-color: #b8cee9; color: var(--text); }
.permission-card.enabled {
color: var(--blue-dark);
background: var(--surface-blue);
border-color: #bed5f6;
}
.permission-card .icon-box {
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
color: var(--text-soft);
background: var(--surface);
border-radius: 7px;
}
.permission-card.enabled .icon-box { color: var(--blue); }
.permission-copy { min-width: 0; }
.permission-name { display: block; font-weight: 650; }
.permission-desc { display: block; overflow: hidden; color: var(--text-soft); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.quick-actions {
display: flex;
flex-wrap: wrap;
gap: 7px;
}
.quick-action {
min-height: 34px;
padding: 0 11px;
color: var(--text-muted);
background: var(--surface);
border: 1px solid var(--line-strong);
border-radius: 7px;
font-size: 12px;
}
.quick-action:hover { color: var(--blue-dark); border-color: #a9c7ec; background: var(--surface-blue); }
.dependency-box {
padding: 12px;
background: #fbfcfe;
border: 1px solid var(--line);
border-radius: 9px;
}
.dependency-row {
justify-content: space-between;
gap: 12px;
min-height: 33px;
padding: 4px 0;
color: var(--text-muted);
}
.dependency-row + .dependency-row { border-top: 1px dashed var(--line); }
.dependency-row strong { color: var(--text); font-weight: 600; }
.helper-text {
display: flex;
gap: 7px;
margin-top: 17px;
padding: 10px 11px;
color: var(--text-muted);
background: var(--surface-muted);
border-radius: 8px;
font-size: 12px;
}
.helper-text .icon { color: var(--blue); }
.summary-body { padding: 15px; }
.summary-stat {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
margin-bottom: 16px;
}
.mini-stat {
min-height: 74px;
flex-direction: column;
align-items: flex-start;
justify-content: center;
padding: 12px;
background: var(--surface-muted);
border: 1px solid var(--line);
border-radius: 8px;
}
.mini-stat strong { font-size: 21px; line-height: 1.05; }
.mini-stat span { margin-top: 4px; color: var(--text-soft); font-size: 11px; }
.summary-list { display: grid; gap: 8px; }
.summary-item {
justify-content: space-between;
gap: 10px;
padding: 9px 0;
border-bottom: 1px solid var(--line);
}
.summary-item:last-child { border-bottom: 0; }
.summary-item-label { color: var(--text-muted); }
.summary-item-value { color: var(--text); font-weight: 650; text-align: right; }
.summary-note {
margin-top: 14px;
padding: 10px;
color: #315a8c;
background: var(--surface-blue);
border-radius: 8px;
font-size: 12px;
}
.menu-layout,
.scope-layout {
display: grid;
grid-template-columns: minmax(280px, 0.9fr) minmax(440px, 1.5fr);
gap: 15px;
align-items: start;
}
.menu-list,
.scope-list { padding: 10px; }
.menu-item {
width: 100%;
min-height: 55px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 9px;
padding: 8px 10px;
color: var(--text-muted);
background: transparent;
border: 1px solid transparent;
border-radius: 8px;
text-align: left;
}
.menu-item:hover { color: var(--text); background: var(--surface-muted); }
.menu-item.selected { color: var(--blue-dark); background: var(--surface-blue); border-color: #cfe0fb; }
.menu-item-main { min-width: 0; display: flex; align-items: center; gap: 9px; }
.menu-item-main .icon { color: var(--text-soft); }
.menu-item.selected .menu-item-main .icon { color: var(--blue); }
.menu-copy { min-width: 0; }
.menu-name { overflow: hidden; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; }
.menu-desc { overflow: hidden; color: var(--text-soft); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.menu-detail { padding: 20px; }
.page-detail-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
margin-bottom: 20px;
}
.page-detail-head h2 { margin: 0; font-size: 20px; }
.page-detail-head p { margin: 5px 0 0; color: var(--text-muted); }
.bound-module-list { display: grid; gap: 9px; }
.bound-module {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px;
background: var(--surface-muted);
border: 1px solid var(--line);
border-radius: 8px;
}
.bound-module-main { min-width: 0; display: flex; align-items: center; gap: 10px; }
.bound-module-main .icon { color: var(--blue); }
.bound-copy { min-width: 0; }
.bound-name { display: block; font-weight: 650; }
.bound-code { display: block; color: var(--text-soft); font-size: 11px; }
.scope-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
min-height: 60px;
padding: 10px;
border-bottom: 1px solid var(--line);
}
.scope-item:last-child { border-bottom: 0; }
.scope-item-main { min-width: 0; display: flex; align-items: center; gap: 10px; }
.scope-item-main .icon { color: var(--blue); }
.scope-item-name { display: block; font-weight: 650; }
.scope-item-code { display: block; color: var(--text-soft); font-size: 11px; }
.scope-control { display: flex; align-items: center; gap: 8px; }
.scope-select {
min-height: 34px;
padding: 0 28px 0 9px;
color: var(--text);
background: var(--surface);
border: 1px solid var(--line-strong);
border-radius: 7px;
}
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 20;
display: none;
align-items: center;
justify-content: center;
padding: 20px;
background: rgba(15, 28, 45, 0.42);
}
.modal-backdrop.open { display: flex; }
.modal {
width: min(580px, 100%);
max-height: min(680px, calc(100vh - 40px));
overflow: auto;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 14px;
box-shadow: 0 24px 70px rgba(15, 28, 45, 0.22);
}
.modal-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 20px 20px 14px;
border-bottom: 1px solid var(--line);
}
.modal-head h2 { margin: 0; font-size: 18px; }
.modal-head p { margin: 4px 0 0; color: var(--text-muted); font-size: 12px; }
.close-button {
width: 34px;
height: 34px;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
background: transparent;
border-radius: 7px;
}
.close-button:hover { color: var(--text); background: var(--surface-muted); }
.modal-body { padding: 16px 20px; }
.modal-page-list { display: grid; gap: 8px; }
.modal-page {
display: flex;
align-items: center;
gap: 10px;
min-height: 55px;
padding: 10px;
background: var(--surface-muted);
border: 1px solid var(--line);
border-radius: 8px;
}
.modal-page-copy { min-width: 0; }
.modal-page-copy strong { display: block; }
.modal-page-copy span { color: var(--text-soft); font-size: 11px; }
.modal-actions {
justify-content: flex-end;
gap: 8px;
padding: 14px 20px 20px;
}
.toast {
position: fixed;
right: 24px;
bottom: 24px;
z-index: 30;
min-width: 245px;
max-width: min(420px, calc(100vw - 48px));
display: flex;
align-items: center;
gap: 9px;
padding: 12px 14px;
color: #fff;
background: #18304d;
border-radius: 9px;
box-shadow: 0 10px 30px rgba(15, 28, 45, 0.22);
opacity: 0;
pointer-events: none;
transform: translateY(10px);
transition: opacity 180ms ease, transform 180ms ease;
}
.toast.show { opacity: 1; transform: translateY(0); }
@media (max-width: 1220px) {
.module-layout { grid-template-columns: minmax(255px, 0.8fr) minmax(390px, 1.4fr); }
.summary-card { grid-column: 1 / -1; }
.summary-body { display: grid; grid-template-columns: 260px 1fr; gap: 20px; align-items: start; }
.summary-stat { margin-bottom: 0; }
}
@media (max-width: 860px) {
.topbar { padding: 0 16px; }
.brand-subtitle, .top-link span { display: none; }
.users-panel { width: 210px; flex-basis: 210px; }
.workspace { padding: 20px 16px 28px; }
.page-title { align-items: flex-start; flex-direction: column; }
.menu-layout, .scope-layout { grid-template-columns: 1fr; }
.simple-grid { grid-template-columns: 1fr; }
.authorization-toolbar { align-items: flex-start; flex-direction: column; }
.toolbar-controls { justify-content: flex-start; }
}
@media (max-width: 680px) {
.body-shell { display: block; }
.users-panel { width: 100%; padding: 13px 16px; border-right: 0; border-bottom: 1px solid var(--line); }
.panel-label { margin-left: 0; }
.search-box { margin-left: 0; margin-right: 0; margin-bottom: 10px; }
.user-list { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.user-row { padding: 8px; }
.user-role { display: none; }
.module-layout { grid-template-columns: 1fr; }
.summary-body { display: block; }
.summary-stat { margin-bottom: 16px; }
.permission-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.preset-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.simple-intro { flex-direction: column; }
.top-actions .save-button { padding: 0 11px; }
.page-title h1 { font-size: 21px; }
.domain-grid { grid-template-columns: 1fr; }
.effective-summary { grid-template-columns: 1fr; }
.save-status { display: none; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; }
}
</style>
</head>
<body>
<svg aria-hidden="true" style="position:absolute;width:0;height:0;overflow:hidden">
<symbol id="i-grid" viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></symbol>
<symbol id="i-search" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="m20 20-4-4"/></symbol>
<symbol id="i-save" viewBox="0 0 24 24"><path d="M5 4h12l2 2v14H5z"/><path d="M8 4v6h8V4M8 20v-6h8v6"/></symbol>
<symbol id="i-info" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 10v6M12 7h.01"/></symbol>
<symbol id="i-users" viewBox="0 0 24 24"><path d="M16 20v-1.5a4 4 0 0 0-4-4H7a4 4 0 0 0-4 4V20"/><circle cx="9.5" cy="7" r="3"/><path d="M16 4.5a3 3 0 0 1 0 5.8M21 20v-1.5a4 4 0 0 0-3-3.87"/></symbol>
<symbol id="i-chevron-down" viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></symbol>
<symbol id="i-chevron-right" viewBox="0 0 24 24"><path d="m9 6 6 6-6 6"/></symbol>
<symbol id="i-layers" viewBox="0 0 24 24"><path d="m12 3 9 5-9 5-9-5z"/><path d="m3 12 9 5 9-5M3 16l9 5 9-5"/></symbol>
<symbol id="i-database" viewBox="0 0 24 24"><ellipse cx="12" cy="5" rx="8" ry="3"/><path d="M4 5v7c0 1.7 3.6 3 8 3s8-1.3 8-3V5M4 12v7c0 1.7 3.6 3 8 3s8-1.3 8-3v-7"/></symbol>
<symbol id="i-menu" viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h16"/></symbol>
<symbol id="i-key" viewBox="0 0 24 24"><circle cx="8" cy="15" r="4"/><path d="m11 12 8-8M16 7l2 2M14 9l2 2"/></symbol>
<symbol id="i-shield" viewBox="0 0 24 24"><path d="M12 3 20 6v5c0 5-3.2 8.3-8 10-4.8-1.7-8-5-8-10V6z"/><path d="m9 12 2 2 4-4"/></symbol>
<symbol id="i-eye" viewBox="0 0 24 24"><path d="M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z"/><circle cx="12" cy="12" r="2.5"/></symbol>
<symbol id="i-plus" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></symbol>
<symbol id="i-x" viewBox="0 0 24 24"><path d="m6 6 12 12M18 6 6 18"/></symbol>
<symbol id="i-arrow-right" viewBox="0 0 24 24"><path d="M5 12h14M13 6l6 6-6 6"/></symbol>
<symbol id="i-check" viewBox="0 0 24 24"><path d="m5 12 4 4L19 6"/></symbol>
<symbol id="i-settings" viewBox="0 0 24 24"><path d="M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8Z"/><path d="m4.9 4.9 1.4 1.4M17.7 17.7l1.4 1.4M4 12H2M22 12h-2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4M12 4V2M12 22v-2"/></symbol>
<symbol id="i-edit" viewBox="0 0 24 24"><path d="m4 16 10.5-10.5a2.1 2.1 0 0 1 3 3L7 19H4z"/><path d="m13 7 4 4"/></symbol>
<symbol id="i-trash" viewBox="0 0 24 24"><path d="M4 7h16M10 11v6M14 11v6M6 7l1 13h10l1-13M9 7V4h6v3"/></symbol>
<symbol id="i-download" viewBox="0 0 24 24"><path d="M12 3v12M7 10l5 5 5-5M5 20h14"/></symbol>
<symbol id="i-lock" viewBox="0 0 24 24"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></symbol>
<symbol id="i-chart" viewBox="0 0 24 24"><path d="M4 19V5M4 19h16"/><path d="m7 15 3-4 3 2 5-7"/></symbol>
</svg>
<div class="app-shell">
<header class="topbar">
<div class="brand">
<span class="brand-mark"><svg class="icon"><use href="#i-grid" /></svg></span>
<span class="brand-name">FMS<span class="brand-subtitle">管理控制台</span></span>
</div>
<div class="top-actions">
<button class="top-link" type="button" id="helpButton"><svg class="icon"><use href="#i-info" /></svg><span>授权说明</span></button>
<span class="save-status" id="saveStatus">已保存</span>
<button class="ghost-button top-discard" type="button" id="discardButton">撤销修改</button>
<button class="save-button" type="button" id="saveButton"><svg class="icon"><use href="#i-save" /></svg><span id="saveLabel">保存权限</span></button>
</div>
</header>
<div class="body-shell">
<aside class="users-panel" aria-label="授权用户">
<p class="panel-label">授权对象</p>
<label class="search-box" aria-label="搜索用户">
<svg class="icon"><use href="#i-search" /></svg>
<input id="userSearch" type="search" placeholder="搜索姓名或部门" />
</label>
<div class="user-list" id="userList"></div>
</aside>
<main class="workspace">
<div class="workspace-head">
<div class="crumbs"><span>系统设置</span><svg class="icon" style="width:13px;height:13px"><use href="#i-chevron-right" /></svg><span>权限管理</span></div>
<div class="page-title">
<div>
<h1>用户权限分配</h1>
<p>先给入口,再给模块能力;模块权限会在所有菜单和接口中保持一致。</p>
</div>
<div class="user-summary" id="userSummary"></div>
</div>
<div class="notice">
<svg class="icon"><use href="#i-shield" /></svg>
<span><strong>当前授权原则:</strong>菜单控制能否进入页面,模块控制能否使用数据;选择“海运”只做批量操作,不会通过数据关联自动扩大权限。</span>
</div>
<section class="authorization-toolbar" aria-label="授权来源">
<div class="toolbar-copy">
<span class="toolbar-eyebrow">授权来源</span>
<h2 class="toolbar-title"><span id="roleSourceName">海运·经办</span><span class="tag blue" id="roleSourceTag">角色方案</span></h2>
<p id="roleSourceDesc">角色方案会一次生成菜单入口和模块动作,之后仍可按用户单独微调。</p>
</div>
<div class="toolbar-controls">
<label class="toolbar-field" for="roleSelect">套用角色方案
<select class="role-select" id="roleSelect" aria-label="选择角色方案"></select>
</label>
<button class="small-button" type="button" id="applyRole"><svg class="icon"><use href="#i-check" /></svg>套用方案</button>
<button class="ghost-button" type="button" id="previewEffective"><svg class="icon"><use href="#i-eye" /></svg>预览最终权限</button>
</div>
</section>
<section class="simple-workspace" id="simpleWorkspace" aria-label="业务授权方案">
<div class="simple-intro">
<div><span class="tag blue">推荐授权方式</span><h2>按业务方案分配</h2><p id="simpleIntroDescription">管理员只需要选择业务域的工作方式,系统会自动准备菜单和模块权限。</p></div>
<button class="ghost-button" type="button" id="showAdvanced"><svg class="icon"><use href="#i-settings" /></svg>详细权限调整</button>
</div>
<section class="domain-overview" aria-label="业务域选择">
<div class="domain-overview-head"><div><h2>业务域</h2><p>先选择客户熟悉的业务域,再选择该业务域的工作方式。</p></div><span class="tag" id="domainModeHint">当前配置:海运</span></div>
<div class="domain-grid" id="domainCards"></div>
</section>
<div class="simple-grid">
<section class="business-card">
<div class="business-head"><div><h2 id="businessName">海运业务</h2><p id="businessDescription">包含主单、装箱信息和费用模块</p></div><span class="business-icon"><svg class="icon"><use href="#i-layers" /></svg></span></div>
<div class="business-modules" id="businessModules"></div>
<div class="preset-title"><h3>选择授权方案</h3><span>点击后可查看授权预览</span></div>
<div class="preset-grid" id="presetGrid"></div>
</section>
<section class="simple-preview">
<div class="simple-preview-head"><div><h2>授权预览</h2><p id="simplePreviewSubtitle">选择方案后,这里会显示实际授权内容</p></div><span class="tag amber">确认后一次保存</span></div>
<div class="preview-list" id="simplePreview"></div>
<div class="preview-actions"><button class="ghost-button" type="button" id="previewEffectiveInline"><svg class="icon"><use href="#i-eye" /></svg>查看最终生效权限</button></div>
</section>
</div>
<div class="simple-note"><svg class="icon"><use href="#i-info" /></svg><span>“只读 / 经办 / 主管”只是管理员的快捷入口。保存后仍按具体模块、动作、字段和数据范围校验,不会因为数据关联自动扩大权限。</span></div>
</section>
<div id="advancedWorkspace" hidden>
<div class="advanced-bar"><p>详细权限调整:适合需要单独调整主单、装箱或费用权限的管理员。</p><button class="ghost-button" type="button" id="showSimple"><svg class="icon"><use href="#i-arrow-right" /></svg>返回业务授权</button></div>
<nav class="tabs" aria-label="权限类型">
<button class="tab-button" type="button" data-view="menu"><svg class="icon"><use href="#i-menu" /></svg>菜单入口</button>
<button class="tab-button active" type="button" data-view="modules"><svg class="icon"><use href="#i-database" /></svg>模块能力</button>
<button class="tab-button" type="button" data-view="scope"><svg class="icon"><use href="#i-key" /></svg>字段与数据范围</button>
</nav>
<section class="view-panel active" id="panel-modules" aria-label="模块能力">
<div class="module-layout">
<section class="card tree-card">
<div class="card-header">
<div><h2>业务模块</h2><p>父节点仅做分组和批量选择</p></div>
<button class="small-button" type="button" id="openPageGrant"><svg class="icon"><use href="#i-arrow-right" /></svg>按页面授权</button>
</div>
<div class="tree-body" id="moduleTree"></div>
</section>
<section class="card detail-card">
<div class="detail-body" id="moduleDetail"></div>
</section>
<aside class="card summary-card">
<div class="card-header"><div><h2>授权概览</h2><p>当前用户的即时状态</p></div><svg class="icon" style="color:var(--blue)"><use href="#i-chart" /></svg></div>
<div class="summary-body" id="summaryBody"></div>
</aside>
</div>
</section>
<section class="view-panel" id="panel-menu" aria-label="菜单入口">
<div class="menu-layout">
<section class="card">
<div class="card-header"><div><h2>菜单入口</h2><p>只决定是否能进入页面</p></div></div>
<div class="menu-list" id="menuList"></div>
</section>
<section class="card">
<div class="menu-detail" id="menuDetail"></div>
</section>
</div>
</section>
<section class="view-panel" id="panel-scope" aria-label="字段与数据范围">
<div class="scope-layout">
<section class="card">
<div class="card-header"><div><h2>选择模块</h2><p>字段权限和数据范围按模块分别配置</p></div></div>
<div class="scope-list" id="scopeModuleList"></div>
</section>
<section class="card">
<div class="menu-detail" id="scopeDetail"></div>
</section>
</div>
</section>
</div>
</div>
</main>
</div>
</div>
<div class="modal-backdrop" id="pageGrantModal" role="presentation">
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
<div class="modal-head">
<div><h2 id="modalTitle">按页面补齐模块权限</h2><p>菜单绑定了哪些模块,管理员可以在这里一次性确认授权。</p></div>
<button class="close-button" type="button" id="closeModal" aria-label="关闭"><svg class="icon"><use href="#i-x" /></svg></button>
</div>
<div class="modal-body"><div class="modal-page-list" id="modalPageList"></div></div>
<div class="modal-actions"><button class="ghost-button" type="button" id="cancelModal">取消</button><button class="primary-button" type="button" id="confirmPageGrant"><svg class="icon"><use href="#i-check" /></svg>确认并授权</button></div>
</div>
</div>
<div class="modal-backdrop" id="effectiveModal" role="presentation">
<div class="modal effective-modal" role="dialog" aria-modal="true" aria-labelledby="effectiveModalTitle">
<div class="modal-head">
<div><h2 id="effectiveModalTitle">最终生效权限</h2><p id="effectiveModalSubtitle">按当前用户、业务域和角色方案汇总展示。</p></div>
<button class="close-button" type="button" id="closeEffective" aria-label="关闭"><svg class="icon"><use href="#i-x" /></svg></button>
</div>
<div class="modal-body" id="effectiveBody"></div>
<div class="modal-actions"><button class="ghost-button" type="button" id="cancelEffective">返回继续调整</button></div>
</div>
</div>
<div class="toast" id="toast" role="status" aria-live="polite"><svg class="icon"><use href="#i-check" /></svg><span id="toastText"></span></div>
<script>
const actionDefinitions = [
{ key: 'view', label: '查看', desc: '进入模块并查询', icon: 'eye' },
{ key: 'create', label: '新增', desc: '创建业务记录', icon: 'plus' },
{ key: 'update', label: '编辑', desc: '修改已有记录', icon: 'edit' },
{ key: 'delete', label: '删除', desc: '删除业务记录', icon: 'trash' },
{ key: 'export', label: '导出', desc: '导出可见数据', icon: 'download' },
{ key: 'audit', label: '审核', desc: '执行审核动作', icon: 'shield' },
];
const moduleDefinitions = {
order: {
name: '海运主单', code: 'sea_order', type: '业务主表', relation: '核心单据',
description: '海运业务的主记录,承载客户、航次和单据状态。',
fields: ['单号', '客户', '船名航次', '业务状态', '业务员'],
related: ['装箱信息', '费用'],
},
container: {
name: '装箱信息', code: 'sea_container', type: '明细模块', relation: '引用海运主单',
description: '记录主单下的箱号、尺寸和装载状态。',
fields: ['箱号', '箱型', '封号', '装载状态'],
related: ['海运主单'],
},
fee: {
name: '费用', code: 'sea_fee', type: '业务模块', relation: '引用海运主单',
description: '记录海运业务的应收、应付和结算状态。',
fields: ['费用类型', '金额', '币种', '结算状态'],
related: ['海运主单'],
},
};
const menuDefinitions = {
seaDesk: { name: '海运工作台', type: '工作台', desc: '主单与配套明细的一体化处理页面', modules: ['order', 'container', 'fee'] },
seaList: { name: '海运单列表', type: '列表页', desc: '查询和维护海运主单', modules: ['order'] },
feeCenter: { name: '费用中心', type: '业务页', desc: '查看费用和结算进度', modules: ['fee'] },
};
const userDefinitions = {
zhang: { name: '张三', dept: '海运业务部 · 业务员', initials: '张', tone: '', menu: { seaDesk: true, seaList: true, feeCenter: false } },
li: { name: '李四', dept: '财务部 · 主管', initials: '李', tone: 'green', menu: { seaDesk: true, seaList: true, feeCenter: true } },
wang: { name: '王五', dept: '运营部 · 只读用户', initials: '王', tone: 'orange', menu: { seaDesk: true, seaList: false, feeCenter: false } },
};
const basePermissions = () => ({ view: false, create: false, update: false, delete: false, export: false, audit: false });
const makePermissions = (values = {}) => ({ ...basePermissions(), ...values });
const userStates = {
zhang: {
menu: { seaDesk: true, seaList: true, feeCenter: false },
modules: {
order: makePermissions({ view: true, create: true, update: true, export: true }),
container: makePermissions({ view: true, update: true }),
fee: makePermissions(),
},
scopes: { order: 'dept', container: 'all', fee: 'none' },
fields: { order: ['单号', '客户', '船名航次', '业务状态'], container: ['箱号', '箱型', '封号'], fee: [] },
},
li: {
menu: { seaDesk: true, seaList: true, feeCenter: true },
modules: {
order: makePermissions({ view: true, create: true, update: true, delete: true, export: true, audit: true }),
container: makePermissions({ view: true, create: true, update: true, delete: true, export: true }),
fee: makePermissions({ view: true, create: true, update: true, delete: true, export: true, audit: true }),
},
scopes: { order: 'all', container: 'all', fee: 'dept' },
fields: { order: ['单号', '客户', '船名航次', '业务状态', '业务员'], container: ['箱号', '箱型', '封号', '装载状态'], fee: ['费用类型', '金额', '币种', '结算状态'] },
},
wang: {
menu: { seaDesk: true, seaList: false, feeCenter: false },
modules: {
order: makePermissions({ view: true, export: true }),
container: makePermissions({ view: true }),
fee: makePermissions(),
},
scopes: { order: 'self', container: 'self', fee: 'none' },
fields: { order: ['单号', '客户', '业务状态'], container: ['箱号', '装载状态'], fee: [] },
},
};
const state = {
userId: 'zhang',
activeDomain: 'sea',
activeView: 'modules',
selectedModule: 'order',
selectedMenu: 'seaDesk',
selectedScopeModule: 'order',
modalPages: { seaDesk: false, seaList: false, feeCenter: false },
simplePreset: 'custom',
selectedRoleId: 'seaOperator',
};
const simplePresets = {
none: { label: '未授权', desc: '不开放海运业务', menu: [], modules: {} },
readonly: {
label: '只读', desc: '查看和导出业务数据', menu: ['seaDesk', 'seaList'],
modules: {
order: makePermissions({ view: true, export: true }),
container: makePermissions({ view: true }),
fee: makePermissions({ view: true }),
},
},
operate: {
label: '经办', desc: '处理主单和装箱业务', menu: ['seaDesk', 'seaList'],
modules: {
order: makePermissions({ view: true, create: true, update: true, export: true }),
container: makePermissions({ view: true, update: true }),
fee: makePermissions(),
},
},
manager: {
label: '主管', desc: '完整业务和审核权限', menu: ['seaDesk', 'seaList', 'feeCenter'],
modules: {
order: makePermissions({ view: true, create: true, update: true, delete: true, export: true, audit: true }),
container: makePermissions({ view: true, create: true, update: true, delete: true, export: true }),
fee: makePermissions({ view: true, create: true, update: true, delete: true, export: true, audit: true }),
},
},
};
/*
* 业务域是用户理解权限的第一层。海运进入高级配置,其余业务域用同一套
* “角色方案 -> 菜单入口 + 模块动作”的交互,方便后续按实际业务继续扩展。
*/
const domainDefinitions = {
sea: {
name: '海运',
description: '主单、装箱信息和费用',
modules: moduleDefinitions,
menus: menuDefinitions,
advanced: true,
},
air: {
name: '空运',
description: '空运主单、运单和费用',
advanced: false,
modules: {
airOrder: { name: '空运主单', code: 'air_order', type: '业务主表', fields: ['主单号', '客户', '航班'], related: ['运单', '费用'] },
airShipment: { name: '空运运单', code: 'air_shipment', type: '明细模块', fields: ['运单号', '件数', '重量'], related: ['空运主单'] },
airFee: { name: '空运费用', code: 'air_fee', type: '业务模块', fields: ['费用类型', '金额', '币种'], related: ['空运主单'] },
},
menus: {
airDesk: { name: '空运工作台', type: '工作台', desc: '主单、运单和费用的一体化处理页面', modules: ['airOrder', 'airShipment', 'airFee'] },
airList: { name: '空运单列表', type: '列表页', desc: '查询和维护空运主单', modules: ['airOrder'] },
},
},
finance: {
name: '财务',
description: '应收、应付和结算报表',
advanced: false,
modules: {
receivable: { name: '应收管理', code: 'finance_receivable', type: '财务模块', fields: ['客户', '应收金额', '回款状态'], related: ['结算报表'] },
payable: { name: '应付管理', code: 'finance_payable', type: '财务模块', fields: ['供应商', '应付金额', '付款状态'], related: ['结算报表'] },
settlement: { name: '结算报表', code: 'finance_settlement', type: '报表模块', fields: ['业务域', '结算金额', '结算周期'], related: ['应收管理', '应付管理'] },
},
menus: {
financeDesk: { name: '财务工作台', type: '工作台', desc: '应收、应付和结算数据总览', modules: ['receivable', 'payable', 'settlement'] },
financeReport: { name: '结算报表', type: '报表页', desc: '按业务域查看结算报表', modules: ['settlement'] },
},
},
};
const domainPresets = {
sea: simplePresets,
air: {
none: { label: '未授权', desc: '不开放空运业务', menu: [], modules: {} },
readonly: {
label: '只读', desc: '查询空运业务数据', menu: ['airDesk', 'airList'],
modules: { airOrder: makePermissions({ view: true, export: true }), airShipment: makePermissions({ view: true }), airFee: makePermissions({ view: true }) },
},
operate: {
label: '经办', desc: '处理空运主单和运单', menu: ['airDesk', 'airList'],
modules: { airOrder: makePermissions({ view: true, create: true, update: true, export: true }), airShipment: makePermissions({ view: true, update: true }), airFee: makePermissions() },
},
manager: {
label: '主管', desc: '完整空运和费用权限', menu: ['airDesk', 'airList'],
modules: { airOrder: makePermissions({ view: true, create: true, update: true, delete: true, export: true, audit: true }), airShipment: makePermissions({ view: true, create: true, update: true, delete: true, export: true }), airFee: makePermissions({ view: true, create: true, update: true, delete: true, export: true, audit: true }) },
},
},
finance: {
none: { label: '未授权', desc: '不开放财务业务', menu: [], modules: {} },
readonly: {
label: '只读', desc: '查看财务数据和报表', menu: ['financeDesk', 'financeReport'],
modules: { receivable: makePermissions({ view: true, export: true }), payable: makePermissions({ view: true, export: true }), settlement: makePermissions({ view: true, export: true }) },
},
operate: {
label: '经办', desc: '处理应收应付数据', menu: ['financeDesk', 'financeReport'],
modules: { receivable: makePermissions({ view: true, create: true, update: true, export: true }), payable: makePermissions({ view: true, create: true, update: true, export: true }), settlement: makePermissions({ view: true, export: true }) },
},
manager: {
label: '主管', desc: '完整财务和审核权限', menu: ['financeDesk', 'financeReport'],
modules: { receivable: makePermissions({ view: true, create: true, update: true, delete: true, export: true, audit: true }), payable: makePermissions({ view: true, create: true, update: true, delete: true, export: true, audit: true }), settlement: makePermissions({ view: true, export: true, audit: true }) },
},
},
};
function buildDomainState(domainId, presetKey) {
const definition = domainDefinitions[domainId];
const preset = domainPresets[domainId][presetKey] || domainPresets[domainId].none;
return {
menu: Object.fromEntries(Object.keys(definition.menus).map((id) => [id, preset.menu.includes(id)])),
modules: Object.fromEntries(Object.keys(definition.modules).map((id) => [id, makePermissions(preset.modules[id] || {})])),
};
}
const domainStates = {
zhang: { air: buildDomainState('air', 'none'), finance: buildDomainState('finance', 'none') },
li: { air: buildDomainState('air', 'readonly'), finance: buildDomainState('finance', 'manager') },
wang: { air: buildDomainState('air', 'readonly'), finance: buildDomainState('finance', 'none') },
};
const roleDefinitions = {
seaOperator: { label: '海运·经办', domain: 'sea', preset: 'operate', desc: '主单新增编辑,装箱查看编辑,费用不可见。' },
seaManager: { label: '海运·主管', domain: 'sea', preset: 'manager', desc: '海运全模块操作、导出和审核。' },
seaViewer: { label: '海运·只读', domain: 'sea', preset: 'readonly', desc: '只查询海运主单、装箱和费用。' },
airOperator: { label: '空运·经办', domain: 'air', preset: 'operate', desc: '处理空运主单和运单,费用保持不可见。' },
financeViewer: { label: '财务·只读', domain: 'finance', preset: 'readonly', desc: '查看财务数据和结算报表。' },
custom: { label: '自定义权限', domain: 'custom', preset: 'custom', desc: '当前用户存在单独调整,按明细配置生效。' },
};
const roleAssignments = { zhang: 'seaOperator', li: 'seaManager', wang: 'seaViewer' };
const savedWorkspaces = Object.fromEntries(Object.keys(userDefinitions).map((id) => [id, {
sea: JSON.parse(JSON.stringify(userStates[id])),
domains: JSON.parse(JSON.stringify(domainStates[id])),
role: roleAssignments[id],
}]));
const $ = (selector) => document.querySelector(selector);
const all = (selector) => Array.from(document.querySelectorAll(selector));
const currentUser = () => userDefinitions[state.userId];
const currentData = () => userStates[state.userId];
const icon = (name, className = 'icon') => `<svg class="${className}"><use href="#i-${name}"></use></svg>`;
function clonePermissions(value) { return JSON.parse(JSON.stringify(value)); }
function showToast(message) {
$('#toastText').textContent = message;
$('#toast').classList.add('show');
window.clearTimeout(showToast.timer);
showToast.timer = window.setTimeout(() => $('#toast').classList.remove('show'), 2600);
}
function permissionsEqual(left, right) {
return Object.keys(basePermissions()).every((key) => Boolean(left?.[key]) === Boolean(right?.[key]));
}
function activeDomainDefinition() { return domainDefinitions[state.activeDomain]; }
function activeDomainData() {
return state.activeDomain === 'sea' ? currentData() : domainStates[state.userId][state.activeDomain];
}
function activeDomainPresets() { return domainPresets[state.activeDomain]; }
function activeModules() { return activeDomainDefinition().modules; }
function activeMenus() { return activeDomainDefinition().menus; }
function workspaceSnapshot(userId = state.userId) {
return JSON.stringify({ sea: userStates[userId], domains: domainStates[userId], role: roleAssignments[userId] });
}
function currentDiffs() {
const saved = savedWorkspaces[state.userId];
if (!saved) return [];
const diffs = [];
if (saved.role !== roleAssignments[state.userId]) diffs.push(`角色方案:${roleDefinitions[saved.role]?.label || '自定义'} → ${roleDefinitions[roleAssignments[state.userId]]?.label || '自定义'}`);
Object.entries(domainDefinitions).forEach(([domainId, definition]) => {
const before = domainId === 'sea' ? saved.sea : saved.domains[domainId];
const after = domainId === 'sea' ? userStates[state.userId] : domainStates[state.userId][domainId];
Object.keys(definition.menus).forEach((menuId) => {
if (Boolean(before?.menu?.[menuId]) !== Boolean(after?.menu?.[menuId])) diffs.push(`${definition.name}菜单:${definition.menus[menuId].name} ${after.menu[menuId] ? '已开放' : '已关闭'}`);
});
Object.entries(definition.modules).forEach(([moduleId, module]) => {
Object.keys(basePermissions()).forEach((action) => {
if (Boolean(before?.modules?.[moduleId]?.[action]) !== Boolean(after?.modules?.[moduleId]?.[action])) diffs.push(`${definition.name} / ${module.name}:${actionDefinitions.find((item) => item.key === action)?.label || action} ${after.modules[moduleId][action] ? '已授予' : '已取消'}`);
});
});
});
Object.keys(moduleDefinitions).forEach((moduleId) => {
const beforeFields = (saved.sea.fields?.[moduleId] || []).join('|');
const afterFields = (userStates[state.userId].fields?.[moduleId] || []).join('|');
if (beforeFields !== afterFields) diffs.push(`海运 / ${moduleDefinitions[moduleId].name}:字段可见性已调整`);
if (saved.sea.scopes?.[moduleId] !== userStates[state.userId].scopes?.[moduleId]) diffs.push(`海运 / ${moduleDefinitions[moduleId].name}:数据范围已调整`);
});
return diffs;
}
function hasUnsavedChanges() { return workspaceSnapshot() !== JSON.stringify(savedWorkspaces[state.userId]); }
function detectSimplePreset() {
const data = activeDomainData();
const presets = activeDomainPresets();
const menus = activeMenus();
const modules = activeModules();
for (const [presetKey, preset] of Object.entries(presets)) {
const menusMatch = Object.keys(menus).every((menuId) => Boolean(data.menu[menuId]) === preset.menu.includes(menuId));
const modulesMatch = Object.keys(modules).every((moduleId) => permissionsEqual(data.modules[moduleId], preset.modules[moduleId] || basePermissions()));
if (menusMatch && modulesMatch) return presetKey;
}
return 'custom';
}
function renderDomainCards() {
const html = Object.entries(domainDefinitions).map(([domainId, definition]) => {
const data = domainId === 'sea' ? currentData() : domainStates[state.userId][domainId];
const presetKey = (() => {
const previousDomain = state.activeDomain;
state.activeDomain = domainId;
const value = detectSimplePreset();
state.activeDomain = previousDomain;
return value;
})();
const granted = Object.values(data.modules).filter((item) => item.view).length;
const roleText = presetKey === 'custom' ? '自定义' : (domainPresets[domainId][presetKey]?.label || '未授权');
return `<button class="domain-card ${domainId === state.activeDomain ? 'active' : ''}" type="button" data-select-domain="${domainId}">
<span class="domain-card-icon">${icon(domainId === 'finance' ? 'chart' : 'layers')}</span>
<span class="domain-card-copy"><span class="domain-card-name">${definition.name}</span><span class="domain-card-desc">${definition.description} · ${granted}/${Object.keys(definition.modules).length} 个模块</span></span>
<span class="role-badge">${roleText}</span>
</button>`;
}).join('');
$('#domainCards').innerHTML = html;
$('#domainModeHint').textContent = `当前配置:${activeDomainDefinition().name}`;
}
function renderRoleToolbar() {
const currentRole = roleDefinitions[roleAssignments[state.userId]] || roleDefinitions.custom;
const roleEntries = Object.entries(roleDefinitions).filter(([id, role]) => id === 'custom' || role.domain === state.activeDomain);
const roleIds = roleEntries.map(([id]) => id);
if (!roleIds.includes(state.selectedRoleId)) state.selectedRoleId = roleIds[0] || 'custom';
const options = roleEntries.map(([id, role]) => `<option value="${id}" ${id === state.selectedRoleId ? 'selected' : ''}>${role.label}</option>`).join('');
$('#roleSelect').innerHTML = options;
const selectedRole = roleDefinitions[state.selectedRoleId] || currentRole;
const detected = detectSimplePreset();
const detectedRole = roleEntries.find(([, role]) => role.preset === detected)?.[1];
const isCustom = detected === 'custom';
const isNone = detected === 'none';
$('#roleSourceName').textContent = isCustom ? `${activeDomainDefinition().name} · 自定义权限` : (isNone ? `${activeDomainDefinition().name} · 未授权` : (detectedRole?.label || selectedRole.label));
$('#roleSourceTag').textContent = isCustom ? '单独调整' : '角色方案';
$('#roleSourceDesc').textContent = isCustom ? '当前用户存在单独调整,保存后只影响当前用户。' : (isNone ? `当前用户没有${activeDomainDefinition().name}权限,可直接套用右侧角色方案。` : (detectedRole?.desc || selectedRole.desc));
}
function renderSimpleWorkspace() {
const data = activeDomainData();
const definition = activeDomainDefinition();
const modules = activeModules();
const menus = activeMenus();
const presets = activeDomainPresets();
const detected = detectSimplePreset();
state.simplePreset = detected;
$('#businessName').textContent = `${definition.name}业务`;
$('#businessDescription').textContent = `包含${definition.description}`;
$('#simpleIntroDescription').textContent = `管理员只需要选择“${definition.name}”的工作方式,系统会自动准备菜单和模块权限。`;
$('#businessModules').innerHTML = Object.values(modules).map((module) => `<span class="business-module"><svg class="icon"><use href="#i-database" /></svg>${module.name}</span>`).join('');
$('#presetGrid').innerHTML = Object.entries(presets).map(([key, preset]) => `
<button class="preset-button ${key === detected ? 'active' : ''}" type="button" data-simple-preset="${key}"><strong>${preset.label}</strong><span>${preset.desc}</span></button>
`).join('');
const previewRows = detected === 'custom'
? [
`<div class="preview-row"><span class="preview-row-main">${icon('menu')}<span class="preview-row-copy"><span class="preview-row-name">当前配置</span><span class="preview-row-desc">已做过单独模块调整</span></span></span><span class="tag amber">自定义</span></div>`,
`<div class="preview-row"><span class="preview-row-main">${icon('menu')}<span class="preview-row-copy"><span class="preview-row-name">菜单入口</span><span class="preview-row-desc">${Object.entries(data.menu).filter(([, enabled]) => enabled).map(([id]) => menus[id].name).join('、') || '没有开放页面'}</span></span></span><span class="tag ${Object.values(data.menu).some(Boolean) ? 'green' : 'red'}">${Object.values(data.menu).some(Boolean) ? '已开放' : '未开放'}</span></div>`,
...Object.entries(modules).map(([id, module]) => {
const permissions = data.modules[id];
const actions = actionDefinitions.filter((action) => permissions[action.key]).map((action) => action.label).join('、') || '未授权';
return `<div class="preview-row"><span class="preview-row-main">${icon('database')}<span class="preview-row-copy"><span class="preview-row-name">${module.name}</span><span class="preview-row-desc">${actions}</span></span></span><span class="tag ${permissions.view ? 'green' : 'red'}">${permissions.view ? '可访问' : '未授权'}</span></div>`;
}),
]
: [
`<div class="preview-row"><span class="preview-row-main">${icon('menu')}<span class="preview-row-copy"><span class="preview-row-name">菜单入口</span><span class="preview-row-desc">${(presets[detected]?.menu || []).map((id) => menus[id].name).join('、') || `不开放${definition.name}页面`}</span></span></span><span class="tag ${detected === 'none' ? 'red' : 'green'}">${detected === 'none' ? '未开放' : '已开放'}</span></div>`,
...Object.entries(modules).map(([id, module]) => {
const permissions = presets[detected].modules[id] || basePermissions();
const actions = actionDefinitions.filter((action) => permissions[action.key]).map((action) => action.label).join('、') || '未授权';
return `<div class="preview-row"><span class="preview-row-main">${icon('database')}<span class="preview-row-copy"><span class="preview-row-name">${module.name}</span><span class="preview-row-desc">${actions}</span></span></span><span class="tag ${permissions.view ? 'green' : 'red'}">${permissions.view ? '可访问' : '未授权'}</span></div>`;
}),
];
$('#simplePreviewSubtitle').textContent = detected === 'custom' ? '当前配置包含单独调整,可进入详细权限调整' : '确认后会同时准备菜单入口和模块能力';
$('#simplePreview').innerHTML = previewRows.join('');
$('#showAdvanced').hidden = !definition.advanced;
$('#showAdvanced').title = definition.advanced ? '打开海运的菜单、模块、字段和数据范围调整' : '该业务域先使用角色方案分配';
}
function applySimplePreset(presetKey) {
const presets = activeDomainPresets();
const preset = presets[presetKey];
const data = activeDomainData();
Object.keys(activeMenus()).forEach((menuId) => { data.menu[menuId] = preset.menu.includes(menuId); });
Object.keys(activeModules()).forEach((moduleId) => { data.modules[moduleId] = makePermissions(preset.modules[moduleId] || {}); });
const matchingRole = Object.entries(roleDefinitions).find(([, role]) => role.domain === state.activeDomain && role.preset === presetKey);
roleAssignments[state.userId] = matchingRole ? matchingRole[0] : 'custom';
state.selectedRoleId = matchingRole ? matchingRole[0] : 'custom';
state.simplePreset = presetKey;
renderAll();
showToast(`已选择“${activeDomainDefinition().name}·${preset.label}”,确认保存即可生效`);
}
function renderUsers() {
const keyword = ($('#userSearch').value || '').trim().toLowerCase();
const html = Object.entries(userDefinitions)
.filter(([, user]) => !keyword || `${user.name}${user.dept}`.toLowerCase().includes(keyword))
.map(([id, user]) => `
<button class="user-row ${id === state.userId ? 'active' : ''}" type="button" data-user="${id}">
<span class="avatar ${user.tone}">${user.initials}</span>
<span class="user-copy"><span class="user-name">${user.name}</span><span class="user-role">${user.dept}</span></span>
</button>
`).join('');
$('#userList').innerHTML = html || '<div style="padding:14px;color:var(--text-soft);font-size:12px">没有匹配用户</div>';
const user = currentUser();
$('#userSummary').innerHTML = `<span class="avatar ${user.tone}">${user.initials}</span><span class="summary-copy"><strong>${user.name}</strong><span>${user.dept}</span></span>`;
}
function moduleAccess(id) { return currentData().modules[id].view; }
function renderModuleTree() {
const data = currentData();
const allGranted = Object.keys(moduleDefinitions).every((id) => moduleAccess(id));
const someGranted = Object.keys(moduleDefinitions).some((id) => moduleAccess(id));
$('#moduleTree').innerHTML = `
<div class="tree-root">
<div class="tree-root-title">
<span class="node-main"><input class="check" type="checkbox" id="domainCheck" ${allGranted ? 'checked' : ''} ${!allGranted && someGranted ? 'data-indeterminate="true"' : ''} /><span>${icon('layers')}<strong style="margin-left:6px">海运</strong></span></span>
<span class="tag">业务域</span>
</div>
<div class="tree-indent">
${Object.entries(moduleDefinitions).map(([id, module]) => `
<div class="node-row ${id === state.selectedModule ? 'selected' : ''}" data-select-module="${id}" role="button" tabindex="0">
<span class="node-main">
<input class="check module-check" type="checkbox" data-module-access="${id}" ${moduleAccess(id) ? 'checked' : ''} aria-label="${module.name}模块访问权限" />
${icon('database')}
<span class="node-copy"><span class="node-name">${module.name}</span><span class="node-code">${module.code}</span></span>
</span>
<span class="node-meta"><span class="tag ${moduleAccess(id) ? 'green' : 'red'}">${moduleAccess(id) ? '已授权' : '未授权'}</span></span>
</div>
`).join('')}
</div>
</div>
<div class="tree-footer"><button class="ghost-button" type="button" id="clearDomain">清空海运权限</button><button class="small-button" type="button" id="grantDomain">全选模块</button></div>
`;
const domainCheck = $('#domainCheck');
if (domainCheck && !allGranted && someGranted) domainCheck.indeterminate = true;
}
function permissionCard(action, enabled) {
return `<label class="permission-card ${enabled ? 'enabled' : ''}"><input class="check action-check" type="checkbox" data-action-key="${action.key}" ${enabled ? 'checked' : ''} /><span class="icon-box">${icon(action.icon)}</span><span class="permission-copy"><span class="permission-name">${action.label}</span><span class="permission-desc">${action.desc}</span></span></label>`;
}
function renderModuleDetail() {
const module = moduleDefinitions[state.selectedModule];
const permissions = currentData().modules[state.selectedModule];
const access = permissions.view;
const dependencyText = module.related.join('、');
$('#moduleDetail').innerHTML = `
<div class="module-heading">
<div class="module-heading-main"><div class="inline" style="gap:8px;margin-bottom:5px"><span class="tag blue">${module.type}</span><span class="tag">${module.relation}</span></div><h2>${module.name}</h2><p>${module.description}</p></div>
<span class="module-icon">${icon('database')}</span>
</div>
<div class="section-title"><h3>模块访问</h3><span>${access ? '已允许进入和查询' : '未授予访问权限'}</span></div>
<label class="permission-card ${access ? 'enabled' : ''}" style="min-height:58px"><input class="check action-check" type="checkbox" data-action-key="view" ${access ? 'checked' : ''} /><span class="icon-box">${icon('eye')}</span><span class="permission-copy"><span class="permission-name">查看模块</span><span class="permission-desc">拥有后才能进入页面和查询数据</span></span></label>
<div class="section-title"><h3>标准操作</h3><span>按模块单独生效</span></div>
<div class="permission-grid">${actionDefinitions.filter((action) => action.key !== 'view').map((action) => permissionCard(action, permissions[action.key])).join('')}</div>
<div class="section-title"><h3>快速方案</h3><span>快捷操作仍会生成明确权限</span></div>
<div class="quick-actions"><button class="quick-action" type="button" data-preset="readonly">只读</button><button class="quick-action" type="button" data-preset="operate">日常操作</button><button class="quick-action" type="button" data-preset="full">全部权限</button><button class="quick-action" type="button" data-preset="clear">清空</button></div>
<div class="section-title"><h3>关联上下文</h3><span>仅用于理解,不自动授权</span></div>
<div class="dependency-box"><div class="dependency-row"><span>被页面使用</span><strong>${Object.values(menuDefinitions).filter((menu) => menu.modules.includes(state.selectedModule)).length} 个菜单页面</strong></div><div class="dependency-row"><span>数据关系</span><strong>${dependencyText}</strong></div><div class="dependency-row"><span>无权限时</span><strong>${access ? '按模块权限展示' : '隐藏模块面板'}</strong></div></div>
<div class="helper-text">${icon('info')}<span>数据关系只帮助管理员理解主单、明细和费用之间的业务联系,不会把主单权限自动传给其他模块。</span></div>
`;
}
function renderSummary() {
const data = currentData();
const moduleCount = Object.values(data.modules).filter((item) => item.view).length;
const actionCount = Object.values(data.modules).reduce((total, item) => total + Object.values(item).filter(Boolean).length, 0);
const menuCount = Object.values(data.menu).filter(Boolean).length;
$('#summaryBody').innerHTML = `
<div class="summary-stat"><div class="mini-stat"><strong>${moduleCount}/3</strong><span>模块可访问</span></div><div class="mini-stat"><strong>${menuCount}/3</strong><span>菜单入口</span></div></div>
<div class="summary-list"><div class="summary-item"><span class="summary-item-label">当前模块</span><span class="summary-item-value">${moduleDefinitions[state.selectedModule].name}</span></div><div class="summary-item"><span class="summary-item-label">已授予操作</span><span class="summary-item-value">${actionCount} 项</span></div><div class="summary-item"><span class="summary-item-label">数据范围</span><span class="summary-item-value">按模块设置</span></div></div>
<div class="summary-note">保存后,后端仍按模块、动作、字段和数据范围逐层校验。</div>
`;
}
function renderMenu() {
const data = currentData();
$('#menuList').innerHTML = Object.entries(menuDefinitions).map(([id, menu]) => `
<button class="menu-item ${id === state.selectedMenu ? 'selected' : ''}" type="button" data-select-menu="${id}">
<span class="menu-item-main">${icon('menu')}<span class="menu-copy"><span class="menu-name">${menu.name}</span><span class="menu-desc">${menu.desc}</span></span></span>
<span class="node-meta"><span class="tag ${data.menu[id] ? 'green' : 'red'}">${data.menu[id] ? '已授权' : '未授权'}</span></span>
</button>
`).join('');
const menu = menuDefinitions[state.selectedMenu];
$('#menuDetail').innerHTML = `
<div class="page-detail-head"><div><div class="inline" style="gap:8px;margin-bottom:5px"><span class="tag blue">${menu.type}</span><span class="tag">${data.menu[state.selectedMenu] ? '入口已开放' : '入口未开放'}</span></div><h2>${menu.name}</h2><p>${menu.desc}</p></div><button class="primary-button" type="button" id="syncSelectedPage"><svg class="icon"><use href="#i-check" /></svg>补齐页面模块权限</button></div>
<div class="section-title"><h3>页面使用的模块</h3><span>${menu.modules.length} 个模块</span></div>
<div class="bound-module-list">${menu.modules.map((id, index) => { const module = moduleDefinitions[id]; return `<div class="bound-module"><span class="bound-module-main">${icon('database')}<span class="bound-copy"><span class="bound-name">${module.name} ${index === 0 ? '<span class="tag amber" style="margin-left:5px">主模块</span>' : ''}</span><span class="bound-code">${module.code}</span></span></span><span class="tag ${moduleAccess(id) ? 'green' : 'red'}">${moduleAccess(id) ? '模块已授权' : '需补授权'}</span></div>`; }).join('')}</div>
<div class="helper-text">${icon('info')}<span>打开菜单只代表可以进入页面;主单、装箱、费用仍然分别检查模块权限。</span></div>
`;
}
function renderScope() {
const data = currentData();
$('#scopeModuleList').innerHTML = Object.entries(moduleDefinitions).map(([id, module]) => `
<button class="menu-item ${id === state.selectedScopeModule ? 'selected' : ''}" type="button" data-select-scope="${id}">
<span class="menu-item-main">${icon('database')}<span class="menu-copy"><span class="menu-name">${module.name}</span><span class="menu-desc">${module.code}</span></span></span>
<span class="tag ${moduleAccess(id) ? 'green' : 'red'}">${moduleAccess(id) ? '可访问' : '未授权'}</span>
</button>
`).join('');
const module = moduleDefinitions[state.selectedScopeModule];
const selectedFields = data.fields[state.selectedScopeModule];
const scope = data.scopes[state.selectedScopeModule];
const scopeOptions = [{ value: 'all', label: '全部数据' }, { value: 'dept', label: '本部门数据' }, { value: 'self', label: '本人数据' }, { value: 'none', label: '未设置' }];
$('#scopeDetail').innerHTML = `
<div class="page-detail-head"><div><div class="inline" style="gap:8px;margin-bottom:5px"><span class="tag blue">字段权限</span><span class="tag">${moduleAccess(state.selectedScopeModule) ? '模块可访问' : '模块未授权'}</span></div><h2>${module.name}</h2><p>字段可见性和数据行范围独立于菜单入口。</p></div></div>
<div class="section-title"><h3>字段可见性</h3><span>${selectedFields.length}/${module.fields.length} 个字段可见</span></div>
<div class="permission-grid">${module.fields.map((field) => `<label class="permission-card ${selectedFields.includes(field) ? 'enabled' : ''}" style="min-height:58px"><input class="check field-check" type="checkbox" data-field-name="${field}" ${selectedFields.includes(field) ? 'checked' : ''} /><span class="permission-copy"><span class="permission-name">${field}</span><span class="permission-desc">允许查看该字段</span></span></label>`).join('')}</div>
<div class="section-title"><h3>数据范围</h3><span>按模块保存</span></div>
<div class="dependency-box"><div class="dependency-row"><span>当前范围</span><select class="scope-select" id="scopeSelect">${scopeOptions.map((option) => `<option value="${option.value}" ${scope === option.value ? 'selected' : ''}>${option.label}</option>`).join('')}</select></div><div class="dependency-row"><span>执行位置</span><strong>后端 SQL 权限过滤</strong></div></div>
<div class="helper-text">${icon('lock')}<span>没有模块访问权限时,字段和数据范围配置不会让用户重新获得模块访问权限。</span></div>
`;
}
function renderSaveStatus() {
const dirty = hasUnsavedChanges();
const diffs = currentDiffs();
$('#saveStatus').textContent = dirty ? `${diffs.length} 项待保存` : '已保存';
$('#saveStatus').classList.toggle('pending', dirty);
$('#discardButton').classList.toggle('visible', dirty);
$('#saveLabel').textContent = dirty ? `保存${diffs.length}项修改` : '保存权限';
}
function renderEffectiveModal() {
const definition = activeDomainDefinition();
const data = activeDomainData();
const menus = activeMenus();
const modules = activeModules();
const visibleMenus = Object.entries(data.menu).filter(([, enabled]) => enabled);
const visibleModules = Object.entries(data.modules).filter(([, permissions]) => permissions.view);
const actionCount = visibleModules.reduce((total, [, permissions]) => total + Object.values(permissions).filter(Boolean).length, 0);
const diffs = currentDiffs();
$('#effectiveModalTitle').textContent = `${currentUser().name} · ${definition.name}最终生效权限`;
$('#effectiveModalSubtitle').textContent = '这是后端最终会收到的入口、模块和动作授权预览。';
$('#effectiveBody').innerHTML = `
<div class="effective-summary">
<div class="effective-stat"><strong>${visibleMenus.length}</strong><span>菜单入口</span></div>
<div class="effective-stat"><strong>${visibleModules.length}</strong><span>可访问模块</span></div>
<div class="effective-stat"><strong>${actionCount}</strong><span>动作权限</span></div>
</div>
<section class="effective-section"><h3>菜单入口</h3><div class="effective-list">${visibleMenus.length ? visibleMenus.map(([id]) => `<div class="effective-row"><span><strong>${menus[id].name}</strong><span>${menus[id].desc}</span></span><span class="tag green">可进入</span></div>`).join('') : '<div class="effective-empty">当前业务域没有开放菜单入口。</div>'}</div></section>
<section class="effective-section"><h3>模块能力</h3><div class="effective-list">${visibleModules.length ? visibleModules.map(([id, permissions]) => `<div class="effective-row"><span><strong>${modules[id].name}</strong><span>${actionDefinitions.filter((action) => permissions[action.key]).map((action) => action.label).join('、')}</span></span><span class="tag green">已授权</span></div>`).join('') : '<div class="effective-empty">当前业务域没有可访问模块。</div>'}</div></section>
${state.activeDomain === 'sea' ? `<section class="effective-section"><h3>字段与数据范围</h3><div class="effective-list">${visibleModules.map(([id]) => `<div class="effective-row"><span><strong>${modules[id].name}</strong><span>${(data.fields[id] || []).length} 个字段 · ${({ all: '全部数据', dept: '本部门数据', self: '本人数据', none: '未设置' })[data.scopes[id]] || '未设置'}</span></span><span class="tag">后端过滤</span></div>`).join('') || '<div class="effective-empty">没有模块可配置字段和数据范围。</div>'}</div></section>` : ''}
<section class="effective-section"><h3>本次未保存变更</h3>${diffs.length ? `<div class="change-list">${diffs.slice(0, 8).map((item) => `<div class="change-item">${icon('edit')}<span>${item}</span></div>`).join('')}</div>${diffs.length > 8 ? `<div class="helper-text">还有 ${diffs.length - 8} 项变更未展开,请保存后生效。</div>` : ''}` : '<div class="effective-empty">没有未保存变更,当前展示的是已保存配置。</div>'}</section>
`;
}
function openEffectiveModal() {
renderEffectiveModal();
$('#effectiveModal').classList.add('open');
}
function closeEffectiveModal() { $('#effectiveModal').classList.remove('open'); }
function saveCurrentUser() {
savedWorkspaces[state.userId] = {
sea: clonePermissions(userStates[state.userId]),
domains: clonePermissions(domainStates[state.userId]),
role: roleAssignments[state.userId],
};
renderAll();
showToast(`已保存${currentUser().name}的权限配置`);
}
function discardCurrentUser() {
const saved = savedWorkspaces[state.userId];
if (!saved) return;
userStates[state.userId] = clonePermissions(saved.sea);
domainStates[state.userId] = clonePermissions(saved.domains);
roleAssignments[state.userId] = saved.role;
state.selectedRoleId = saved.role || 'custom';
renderAll();
showToast('已撤销本次未保存修改');
}
function renderAll() {
renderUsers();
renderRoleToolbar();
renderDomainCards();
renderSimpleWorkspace();
renderModuleTree();
renderModuleDetail();
renderSummary();
renderMenu();
renderScope();
all('.tab-button').forEach((button) => button.classList.toggle('active', button.dataset.view === state.activeView));
all('.view-panel').forEach((panel) => panel.classList.toggle('active', panel.id === `panel-${state.activeView}`));
renderSaveStatus();
}
function setModuleAccess(id, value) {
if (state.activeDomain === 'sea') roleAssignments[state.userId] = 'custom';
currentData().modules[id].view = value;
if (!value) {
['create', 'update', 'delete', 'export', 'audit'].forEach((key) => { currentData().modules[id][key] = false; });
}
}
function setDomainAccess(value) {
if (state.activeDomain === 'sea') roleAssignments[state.userId] = 'custom';
Object.keys(moduleDefinitions).forEach((id) => setModuleAccess(id, value));
}
function applyPreset(preset) {
roleAssignments[state.userId] = 'custom';
const permissions = currentData().modules[state.selectedModule];
if (preset === 'readonly') Object.assign(permissions, makePermissions({ view: true, export: true }));
if (preset === 'operate') Object.assign(permissions, makePermissions({ view: true, create: true, update: true, export: true }));
if (preset === 'full') Object.assign(permissions, makePermissions({ view: true, create: true, update: true, delete: true, export: true, audit: true }));
if (preset === 'clear') Object.assign(permissions, makePermissions());
renderAll();
showToast(`已将${moduleDefinitions[state.selectedModule].name}设置为${preset === 'readonly' ? '只读' : preset === 'operate' ? '日常操作' : preset === 'full' ? '全部权限' : '无权限'}`);
}
function openPageModal() {
state.modalPages = { seaDesk: false, seaList: false, feeCenter: false };
$('#modalPageList').innerHTML = Object.entries(menuDefinitions).map(([id, menu]) => `
<label class="modal-page"><input class="check modal-page-check" type="checkbox" data-modal-page="${id}" /><span class="modal-page-copy"><strong>${menu.name}</strong><span>${menu.modules.map((moduleId) => moduleDefinitions[moduleId].name).join('、')}</span></span></label>
`).join('');
$('#pageGrantModal').classList.add('open');
}
function closePageModal() { $('#pageGrantModal').classList.remove('open'); }
document.addEventListener('click', (event) => {
const userButton = event.target.closest('[data-user]');
if (userButton) {
state.userId = userButton.dataset.user;
state.activeDomain = 'sea';
state.selectedRoleId = roleAssignments[state.userId] || 'custom';
renderAll();
showToast(`已切换到${currentUser().name}的权限配置`);
return;
}
const domainButton = event.target.closest('[data-select-domain]');
if (domainButton) {
state.activeDomain = domainButton.dataset.selectDomain;
const assignedRole = roleDefinitions[roleAssignments[state.userId]];
state.selectedRoleId = assignedRole?.domain === state.activeDomain ? roleAssignments[state.userId] : (Object.entries(roleDefinitions).find(([, role]) => role.domain === state.activeDomain)?.[0] || 'custom');
$('#advancedWorkspace').hidden = true;
$('#simpleWorkspace').hidden = false;
renderAll();
return;
}
const moduleNode = event.target.closest('[data-select-module]');
if (moduleNode && !event.target.closest('input')) {
state.selectedModule = moduleNode.dataset.selectModule;
renderAll();
return;
}
const menuNode = event.target.closest('[data-select-menu]');
if (menuNode) {
state.selectedMenu = menuNode.dataset.selectMenu;
renderMenu();
return;
}
const scopeNode = event.target.closest('[data-select-scope]');
if (scopeNode) {
state.selectedScopeModule = scopeNode.dataset.selectScope;
renderScope();
return;
}
const tab = event.target.closest('[data-view]');
if (tab) {
state.activeView = tab.dataset.view;
renderAll();
return;
}
const simplePreset = event.target.closest('[data-simple-preset]');
if (simplePreset) {
applySimplePreset(simplePreset.dataset.simplePreset);
return;
}
const preset = event.target.closest('[data-preset]');
if (preset) {
applyPreset(preset.dataset.preset);
return;
}
if (event.target.closest('#showAdvanced')) {
if (!domainDefinitions[state.activeDomain].advanced) {
showToast('该业务域先使用角色方案分配,详细模块调整可按需接入');
return;
}
$('#simpleWorkspace').hidden = true;
$('#advancedWorkspace').hidden = false;
return;
}
if (event.target.closest('#showSimple')) {
$('#advancedWorkspace').hidden = true;
$('#simpleWorkspace').hidden = false;
return;
}
if (event.target.closest('#grantDomain')) {
setDomainAccess(true); renderAll(); showToast('已授权海运下的全部模块'); return;
}
if (event.target.closest('#clearDomain')) {
setDomainAccess(false); renderAll(); showToast('已清空海运模块权限'); return;
}
if (event.target.closest('#openPageGrant')) { openPageModal(); return; }
if (event.target.closest('#closeModal') || event.target.closest('#cancelModal')) { closePageModal(); return; }
if (event.target.closest('#syncSelectedPage')) {
const menu = menuDefinitions[state.selectedMenu];
menu.modules.forEach((id) => setModuleAccess(id, true));
currentData().menu[state.selectedMenu] = true;
roleAssignments[state.userId] = 'custom';
renderAll();
showToast(`已补齐${menu.name}的模块访问权限`);
return;
}
if (event.target.closest('#confirmPageGrant')) {
const selected = all('.modal-page-check:checked').map((input) => input.dataset.modalPage);
selected.forEach((menuId) => {
currentData().menu[menuId] = true;
menuDefinitions[menuId].modules.forEach((id) => setModuleAccess(id, true));
});
if (selected.length) roleAssignments[state.userId] = 'custom';
closePageModal();
renderAll();
showToast(selected.length ? `已按 ${selected.length} 个页面补齐模块权限` : '没有选择页面');
return;
}
if (event.target.closest('#previewEffective') || event.target.closest('#previewEffectiveInline')) { openEffectiveModal(); return; }
if (event.target.closest('#closeEffective') || event.target.closest('#cancelEffective')) { closeEffectiveModal(); return; }
if (event.target.closest('#saveButton')) { saveCurrentUser(); return; }
if (event.target.closest('#discardButton')) { discardCurrentUser(); return; }
if (event.target.closest('#applyRole')) {
const roleId = $('#roleSelect').value;
const role = roleDefinitions[roleId];
if (!role || roleId === 'custom') {
roleAssignments[state.userId] = 'custom';
state.selectedRoleId = 'custom';
renderAll();
showToast('已切换为自定义权限,请在详细权限中调整');
return;
}
state.activeDomain = role.domain;
state.selectedRoleId = roleId;
applySimplePreset(role.preset);
return;
}
if (event.target.closest('#helpButton')) { showToast('菜单管入口,模块管能力;父节点只做批量选择'); return; }
});
document.addEventListener('change', (event) => {
if (event.target.matches('#roleSelect')) {
state.selectedRoleId = event.target.value;
renderRoleToolbar();
return;
}
if (event.target.matches('[data-module-access]')) {
setModuleAccess(event.target.dataset.moduleAccess, event.target.checked);
renderAll();
return;
}
if (event.target.matches('#domainCheck')) {
setDomainAccess(event.target.checked); renderAll(); return;
}
if (event.target.matches('.action-check')) {
const actionKey = event.target.dataset.actionKey;
roleAssignments[state.userId] = 'custom';
currentData().modules[state.selectedModule][actionKey] = event.target.checked;
if (actionKey === 'view' && !event.target.checked) {
['create', 'update', 'delete', 'export', 'audit'].forEach((key) => { currentData().modules[state.selectedModule][key] = false; });
}
if (actionKey !== 'view' && event.target.checked) currentData().modules[state.selectedModule].view = true;
renderAll();
return;
}
if (event.target.matches('.field-check')) {
roleAssignments[state.userId] = 'custom';
const fields = currentData().fields[state.selectedScopeModule];
const field = event.target.dataset.fieldName;
if (event.target.checked && !fields.includes(field)) fields.push(field);
if (!event.target.checked) currentData().fields[state.selectedScopeModule] = fields.filter((item) => item !== field);
renderScope();
return;
}
if (event.target.matches('#scopeSelect')) {
roleAssignments[state.userId] = 'custom';
currentData().scopes[state.selectedScopeModule] = event.target.value;
showToast('数据范围已更新');
return;
}
if (event.target.matches('.modal-page-check')) {
state.modalPages[event.target.dataset.modalPage] = event.target.checked;
}
});
$('#userSearch').addEventListener('input', renderUsers);
$('#pageGrantModal').addEventListener('click', (event) => { if (event.target.id === 'pageGrantModal') closePageModal(); });
$('#effectiveModal').addEventListener('click', (event) => { if (event.target.id === 'effectiveModal') closeEffectiveModal(); });
renderAll();
</script>
</body>
</html>