2351 lines
103 KiB
HTML
2351 lines
103 KiB
HTML
<!doctype html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>FMS 权限分配原型</title>
|
||
<style>
|
||
:root {
|
||
--bg: #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>
|