feat: add settings page with navigation and access control updates
This commit is contained in:
@@ -82,11 +82,15 @@
|
|||||||
.main.approval-main,
|
.main.approval-main,
|
||||||
.main.policies-main,
|
.main.policies-main,
|
||||||
.main.audit-main,
|
.main.audit-main,
|
||||||
.main.employees-main {
|
.main.employees-main,
|
||||||
|
.main.settings-main {
|
||||||
height: 100dvh;
|
height: 100dvh;
|
||||||
grid-template-rows: auto minmax(0, 1fr);
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
.main.settings-main {
|
||||||
|
grid-template-rows: minmax(0, 1fr);
|
||||||
|
}
|
||||||
.workarea { overflow: auto; padding: 24px; }
|
.workarea { overflow: auto; padding: 24px; }
|
||||||
.workarea.chat-workarea {
|
.workarea.chat-workarea {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -96,11 +100,16 @@
|
|||||||
.workarea.approval-workarea,
|
.workarea.approval-workarea,
|
||||||
.workarea.policies-workarea,
|
.workarea.policies-workarea,
|
||||||
.workarea.audit-workarea,
|
.workarea.audit-workarea,
|
||||||
.workarea.employees-workarea {
|
.workarea.employees-workarea,
|
||||||
|
.workarea.settings-workarea {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding: 20px 24px;
|
padding: 20px 24px;
|
||||||
}
|
}
|
||||||
|
.workarea.settings-workarea {
|
||||||
|
padding: 0;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 1180px) {
|
@media (max-width: 1180px) {
|
||||||
.app { grid-template-columns: 220px minmax(0, 1fr); }
|
.app { grid-template-columns: 220px minmax(0, 1fr); }
|
||||||
|
|||||||
664
web/src/assets/styles/views/settings-view.css
Normal file
664
web/src/assets/styles/views/settings-view.css
Normal file
@@ -0,0 +1,664 @@
|
|||||||
|
.settings-page {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
animation: fadeUp 220ms var(--ease) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-shell {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 248px minmax(0, 1fr);
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 24px;
|
||||||
|
background: linear-gradient(180deg, #ffffff 0%, #fbfefd 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 22px 16px 18px;
|
||||||
|
border-right: 1px solid #e7edf3;
|
||||||
|
background: linear-gradient(180deg, #fcfffd 0%, #f5fbf8 58%, #ffffff 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav-head {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 4px 10px 18px;
|
||||||
|
border-bottom: 1px solid #eef3f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-kicker {
|
||||||
|
color: #10b981;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav-head h2 {
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 860;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav-head p {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav-list {
|
||||||
|
min-height: 0;
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: 8px;
|
||||||
|
overflow: auto;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav-item {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 74px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px 14px 14px 16px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: transparent;
|
||||||
|
color: #334155;
|
||||||
|
text-align: left;
|
||||||
|
transition:
|
||||||
|
background 180ms var(--ease),
|
||||||
|
border-color 180ms var(--ease),
|
||||||
|
box-shadow 180ms var(--ease),
|
||||||
|
color 180ms var(--ease),
|
||||||
|
transform 180ms var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav-item:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
border-color: rgba(16, 185, 129, 0.14);
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav-item.active {
|
||||||
|
border-color: rgba(16, 185, 129, 0.16);
|
||||||
|
background: linear-gradient(135deg, rgba(16, 185, 129, 0.14), rgba(16, 185, 129, 0.04));
|
||||||
|
box-shadow: inset 3px 0 0 #10b981;
|
||||||
|
color: #047857;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item-copy {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item-copy strong {
|
||||||
|
color: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 820;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item-copy small {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item-state {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav-item.complete .nav-item-state {
|
||||||
|
background: #ecfdf5;
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav-foot {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 16px 12px 2px;
|
||||||
|
border-top: 1px solid #eef3f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav-foot span {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav-foot strong {
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 820;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-body {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(248, 250, 252, 0.96) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-toolbar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 2;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 24px 28px 20px;
|
||||||
|
border-bottom: 1px solid #eef2f7;
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-toolbar-copy {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-breadcrumb {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #eef8f2;
|
||||||
|
color: #047857;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-toolbar-copy h3 {
|
||||||
|
margin-top: 14px;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 860;
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-toolbar-copy p {
|
||||||
|
margin-top: 10px;
|
||||||
|
max-width: 760px;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-toolbar-actions {
|
||||||
|
display: grid;
|
||||||
|
justify-items: end;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-status {
|
||||||
|
min-height: 36px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 13px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #fff7ed;
|
||||||
|
color: #c2410c;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-status.complete {
|
||||||
|
background: #ecfdf5;
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-button {
|
||||||
|
min-height: 42px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 18px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: linear-gradient(135deg, #13b87b, #0a9d68);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 820;
|
||||||
|
box-shadow: 0 12px 26px rgba(5, 150, 105, 0.2);
|
||||||
|
transition:
|
||||||
|
transform 180ms var(--ease),
|
||||||
|
box-shadow 180ms var(--ease),
|
||||||
|
filter 180ms var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-button:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 16px 30px rgba(5, 150, 105, 0.22);
|
||||||
|
filter: saturate(1.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-content {
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 24px 28px 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-card {
|
||||||
|
padding: 22px 22px 24px;
|
||||||
|
border: 1px solid #e8eef3;
|
||||||
|
border-radius: 22px;
|
||||||
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 251, 255, 0.94));
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 14px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-head h4 {
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 840;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-head p {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 18px 20px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-grid {
|
||||||
|
grid-template-columns: 96px repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-grid {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-wide {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-full {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field span {
|
||||||
|
color: #334155;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field em {
|
||||||
|
margin-right: 4px;
|
||||||
|
color: #ef4444;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input,
|
||||||
|
.field select {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border: 1px solid #d7e0ea;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: #fff;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.45;
|
||||||
|
transition:
|
||||||
|
border-color 180ms var(--ease),
|
||||||
|
box-shadow 180ms var(--ease),
|
||||||
|
background 180ms var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input::placeholder {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input:focus,
|
||||||
|
.field select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: rgba(16, 185, 129, 0.55);
|
||||||
|
box-shadow: 0 0 0 4px rgba(16, 185, 129, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-field {
|
||||||
|
align-self: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-tile {
|
||||||
|
width: 96px;
|
||||||
|
height: 96px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px dashed #cbd5e1;
|
||||||
|
border-radius: 22px;
|
||||||
|
background:
|
||||||
|
linear-gradient(45deg, #f8fafc 25%, transparent 25%, transparent 75%, #f8fafc 75%, #f8fafc),
|
||||||
|
linear-gradient(45deg, #f8fafc 25%, transparent 25%, transparent 75%, #f8fafc 75%, #f8fafc);
|
||||||
|
background-position: 0 0, 9px 9px;
|
||||||
|
background-size: 18px 18px;
|
||||||
|
color: #10b981;
|
||||||
|
font-size: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-card {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 78px minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 22px;
|
||||||
|
border: 1px solid rgba(16, 185, 129, 0.14);
|
||||||
|
border-radius: 24px;
|
||||||
|
background: linear-gradient(135deg, rgba(16, 185, 129, 0.08), rgba(59, 130, 246, 0.05));
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-icon {
|
||||||
|
width: 78px;
|
||||||
|
height: 78px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 22px;
|
||||||
|
background: linear-gradient(135deg, #10b981, #0f766e);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 34px;
|
||||||
|
box-shadow: 0 14px 28px rgba(16, 185, 129, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-copy strong {
|
||||||
|
display: block;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 840;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-copy p {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: #334155;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-copy small {
|
||||||
|
display: block;
|
||||||
|
margin-top: 8px;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-badge {
|
||||||
|
min-height: 30px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #ecfdf5;
|
||||||
|
color: #059669;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 820;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-chip {
|
||||||
|
min-width: 78px;
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border: 1px solid #d7e0ea;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #fff;
|
||||||
|
color: #475569;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 820;
|
||||||
|
transition:
|
||||||
|
border-color 160ms ease,
|
||||||
|
background 160ms ease,
|
||||||
|
box-shadow 160ms ease,
|
||||||
|
color 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-chip.active {
|
||||||
|
border-color: #10b981;
|
||||||
|
background: #10b981;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 8px 18px rgba(16, 185, 129, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.range-shell {
|
||||||
|
min-height: 44px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border: 1px solid #d7e0ea;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.range-shell input[type='range'] {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
accent-color: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.range-shell strong {
|
||||||
|
min-width: 28px;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-group {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-row {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 15px 16px;
|
||||||
|
border: 1px solid #e5eaf0;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: #fbfdff;
|
||||||
|
text-align: left;
|
||||||
|
transition:
|
||||||
|
border-color 180ms var(--ease),
|
||||||
|
background 180ms var(--ease),
|
||||||
|
transform 180ms var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-row:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
border-color: rgba(16, 185, 129, 0.18);
|
||||||
|
background: #f7fffb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-copy {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-copy strong {
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-copy small {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch {
|
||||||
|
position: relative;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 48px;
|
||||||
|
height: 28px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 3px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #dbe4ee;
|
||||||
|
transition: background 180ms var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch i {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 2px 6px rgba(15, 23, 42, 0.14);
|
||||||
|
transition: transform 180ms var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch.active {
|
||||||
|
background: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch.active i {
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1260px) {
|
||||||
|
.settings-shell {
|
||||||
|
grid-template-columns: 226px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-toolbar {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-toolbar-actions {
|
||||||
|
justify-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-button,
|
||||||
|
.section-status {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.settings-shell {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav {
|
||||||
|
grid-template-rows: auto auto auto;
|
||||||
|
border-right: 0;
|
||||||
|
border-bottom: 1px solid #e7edf3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav-list {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav-item {
|
||||||
|
min-width: 208px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-toolbar,
|
||||||
|
.settings-content {
|
||||||
|
padding-inline: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid,
|
||||||
|
.profile-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-wide,
|
||||||
|
.field-full {
|
||||||
|
grid-column: span 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-field {
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-card {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
justify-items: start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.settings-toolbar {
|
||||||
|
padding: 18px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-toolbar-copy h3 {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-content {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-card {
|
||||||
|
padding: 18px 16px;
|
||||||
|
border-radius: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav {
|
||||||
|
padding: 18px 12px 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
<path d="M9 7.6c-3 3.5-4 7.3-2.9 11.2 1.2 4.2 4.6 7 10.1 8.5-2 1.8-4.6 2.6-7.6 2.3C5.1 26.7 3.5 23.1 3.7 19 4 14.4 5.7 10.6 9 7.6Z" />
|
<path d="M9 7.6c-3 3.5-4 7.3-2.9 11.2 1.2 4.2 4.6 7 10.1 8.5-2 1.8-4.6 2.6-7.6 2.3C5.1 26.7 3.5 23.1 3.7 19 4 14.4 5.7 10.6 9 7.6Z" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<strong class="brand-name">星海科技</strong>
|
<strong class="brand-name">{{ displayCompanyName }}</strong>
|
||||||
<button class="brand-toggle" type="button" aria-label="打开 AI 助手" @click="emit('openChat')">
|
<button class="brand-toggle" type="button" aria-label="打开 AI 助手" @click="emit('openChat')">
|
||||||
<i class="mdi mdi-chevron-double-left"></i>
|
<i class="mdi mdi-chevron-double-left"></i>
|
||||||
</button>
|
</button>
|
||||||
@@ -54,11 +54,15 @@ import { computed } from 'vue'
|
|||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
navItems: { type: Array, required: true },
|
navItems: { type: Array, required: true },
|
||||||
activeView: { type: String, required: true },
|
activeView: { type: String, required: true },
|
||||||
|
companyName: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
currentUser: {
|
currentUser: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => ({
|
default: () => ({
|
||||||
name: '系统管理员',
|
name: '系统管理员',
|
||||||
role: '财务管理员',
|
role: '管理员',
|
||||||
avatar: '管'
|
avatar: '管'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -73,8 +77,9 @@ const sidebarMeta = {
|
|||||||
approval: { label: '审批中心', badge: '12' },
|
approval: { label: '审批中心', badge: '12' },
|
||||||
chat: { label: 'AI 助手' },
|
chat: { label: 'AI 助手' },
|
||||||
policies: { label: '知识管理' },
|
policies: { label: '知识管理' },
|
||||||
audit: { label: '技能中心' },
|
audit: { label: '审计追踪' },
|
||||||
employees: { label: '员工管理' }
|
employees: { label: '员工管理' },
|
||||||
|
settings: { label: '系统设置' }
|
||||||
}
|
}
|
||||||
|
|
||||||
const decoratedNavItems = computed(() =>
|
const decoratedNavItems = computed(() =>
|
||||||
@@ -87,9 +92,11 @@ const decoratedNavItems = computed(() =>
|
|||||||
|
|
||||||
const displayUser = computed(() => ({
|
const displayUser = computed(() => ({
|
||||||
name: props.currentUser?.name || '系统管理员',
|
name: props.currentUser?.name || '系统管理员',
|
||||||
role: props.currentUser?.role || '财务管理员',
|
role: props.currentUser?.role || '管理员',
|
||||||
avatar: props.currentUser?.avatar || '管'
|
avatar: props.currentUser?.avatar || '管'
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
const displayCompanyName = computed(() => props.companyName || 'X-Financial')
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -334,7 +341,7 @@ const displayUser = computed(() => ({
|
|||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: rgba(255, 255, 255, 0.98);
|
background: rgba(255, 255, 255, 0.98);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 16px 32px rgba(15, 23, 42, 0.10),
|
0 16px 32px rgba(15, 23, 42, 0.1),
|
||||||
0 2px 8px rgba(15, 23, 42, 0.04);
|
0 2px 8px rgba(15, 23, 42, 0.04);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(8px);
|
transform: translateY(8px);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useRoute, useRouter } from 'vue-router'
|
|||||||
|
|
||||||
import { icons } from '../data/icons.js'
|
import { icons } from '../data/icons.js'
|
||||||
|
|
||||||
export const appViews = ['overview', 'workbench', 'requests', 'approval', 'chat', 'policies', 'audit', 'employees']
|
export const appViews = ['overview', 'workbench', 'requests', 'approval', 'chat', 'policies', 'audit', 'employees', 'settings']
|
||||||
|
|
||||||
export const navItems = [
|
export const navItems = [
|
||||||
{
|
{
|
||||||
@@ -69,6 +69,14 @@ export const navItems = [
|
|||||||
icon: icons.users,
|
icon: icons.users,
|
||||||
title: '员工与组织管理',
|
title: '员工与组织管理',
|
||||||
desc: '维护员工账号、组织结构与角色权限。'
|
desc: '维护员工账号、组织结构与角色权限。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'settings',
|
||||||
|
label: '系统设置',
|
||||||
|
navHint: '维护企业品牌、管理员安全与系统配置',
|
||||||
|
icon: icons.settings,
|
||||||
|
title: '系统设置中心',
|
||||||
|
desc: '集中配置公司名称、管理员账号安全、模型接入、日志策略与邮箱通知。'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -80,7 +88,8 @@ const viewRouteNames = {
|
|||||||
chat: 'app-chat',
|
chat: 'app-chat',
|
||||||
policies: 'app-policies',
|
policies: 'app-policies',
|
||||||
audit: 'app-audit',
|
audit: 'app-audit',
|
||||||
employees: 'app-employees'
|
employees: 'app-employees',
|
||||||
|
settings: 'app-settings'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useNavigation() {
|
export function useNavigation() {
|
||||||
|
|||||||
@@ -319,6 +319,20 @@ const companyProfile = computed(() => ({
|
|||||||
adminEmail: bootstrapState.value.company?.admin_email || ''
|
adminEmail: bootstrapState.value.company?.admin_email || ''
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
function updateCompanyProfilePreview(payload = {}) {
|
||||||
|
const currentCompany = bootstrapState.value.company || {}
|
||||||
|
|
||||||
|
bootstrapState.value = {
|
||||||
|
...bootstrapState.value,
|
||||||
|
company: {
|
||||||
|
...currentCompany,
|
||||||
|
...(payload.name !== undefined ? { name: payload.name } : {}),
|
||||||
|
...(payload.code !== undefined ? { code: payload.code } : {}),
|
||||||
|
...(payload.adminEmail !== undefined ? { admin_email: payload.adminEmail } : {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const isInitialized = computed(() => Boolean(bootstrapState.value.initialized))
|
const isInitialized = computed(() => Boolean(bootstrapState.value.initialized))
|
||||||
|
|
||||||
function applyBootstrapState(state) {
|
function applyBootstrapState(state) {
|
||||||
@@ -527,6 +541,7 @@ export function useSystemState() {
|
|||||||
runtimeTesting,
|
runtimeTesting,
|
||||||
setupError,
|
setupError,
|
||||||
setupSubmitting,
|
setupSubmitting,
|
||||||
syncAuthSession
|
syncAuthSession,
|
||||||
|
updateCompanyProfilePreview
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export const icons = {
|
|||||||
skill: iconPath('<path d="M12 3 9.5 8.5 3 11l6.5 2.5L12 19l2.5-5.5L21 11l-6.5-2.5z"/><path d="M19 19l.9 2 .9-2 2-.9-2-.9-.9-2-.9 2-2 .9z"/><path d="M5 5l.6 1.4L7 7l-1.4.6L5 9l-.6-1.4L3 7l1.4-.6z"/>'),
|
skill: iconPath('<path d="M12 3 9.5 8.5 3 11l6.5 2.5L12 19l2.5-5.5L21 11l-6.5-2.5z"/><path d="M19 19l.9 2 .9-2 2-.9-2-.9-.9-2-.9 2-2 .9z"/><path d="M5 5l.6 1.4L7 7l-1.4.6L5 9l-.6-1.4L3 7l1.4-.6z"/>'),
|
||||||
users: iconPath('<path d="M16 21v-2a4 4 0 0 0-4-4H7a4 4 0 0 0-4 4v2"/><circle cx="9.5" cy="7" r="4"/><path d="M20 8v6"/><path d="M23 11h-6"/>'),
|
users: iconPath('<path d="M16 21v-2a4 4 0 0 0-4-4H7a4 4 0 0 0-4 4v2"/><circle cx="9.5" cy="7" r="4"/><path d="M20 8v6"/><path d="M23 11h-6"/>'),
|
||||||
audit: iconPath('<path d="M12 8v4l3 3"/><path d="M3.05 11a9 9 0 1 1 .5 4"/><path d="M3 4v7h7"/>'),
|
audit: iconPath('<path d="M12 8v4l3 3"/><path d="M3.05 11a9 9 0 1 1 .5 4"/><path d="M3 4v7h7"/>'),
|
||||||
|
settings: iconPath('<path d="M12 3v3"/><path d="M12 18v3"/><path d="M3 12h3"/><path d="M18 12h3"/><path d="m5.64 5.64 2.12 2.12"/><path d="m16.24 16.24 2.12 2.12"/><path d="m5.64 18.36 2.12-2.12"/><path d="m16.24 7.76 2.12-2.12"/><circle cx="12" cy="12" r="3.5"/>'),
|
||||||
search: iconPath('<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>'),
|
search: iconPath('<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>'),
|
||||||
check: iconPath('<path d="M20 6 9 17l-5-5"/>'),
|
check: iconPath('<path d="M20 6 9 17l-5-5"/>'),
|
||||||
message: iconPath('<path d="M21 15a4 4 0 0 1-4 4H7l-4 4V7a4 4 0 0 1 4-4h10a4 4 0 0 1 4 4z"/>'),
|
message: iconPath('<path d="M21 15a4 4 0 0 1-4 4H7l-4 4V7a4 4 0 0 1 4-4h10a4 4 0 0 1 4 4z"/>'),
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ export const DEFAULT_APP_VIEW_ORDER = [
|
|||||||
'chat',
|
'chat',
|
||||||
'policies',
|
'policies',
|
||||||
'audit',
|
'audit',
|
||||||
'employees'
|
'employees',
|
||||||
|
'settings'
|
||||||
]
|
]
|
||||||
|
|
||||||
const ALWAYS_VISIBLE_VIEWS = new Set(['workbench', 'requests', 'chat'])
|
const ALWAYS_VISIBLE_VIEWS = new Set(['workbench', 'requests', 'chat'])
|
||||||
@@ -15,7 +16,8 @@ const VIEW_ROLE_RULES = {
|
|||||||
approval: ['approver'],
|
approval: ['approver'],
|
||||||
policies: ['manager'],
|
policies: ['manager'],
|
||||||
audit: ['auditor'],
|
audit: ['auditor'],
|
||||||
employees: ['manager']
|
employees: ['manager'],
|
||||||
|
settings: ['manager']
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizedRoleCodes(user) {
|
function normalizedRoleCodes(user) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
<SidebarRail
|
<SidebarRail
|
||||||
:nav-items="filteredNavItems"
|
:nav-items="filteredNavItems"
|
||||||
:active-view="activeView"
|
:active-view="activeView"
|
||||||
|
:company-name="companyProfile.name"
|
||||||
:current-user="currentUser"
|
:current-user="currentUser"
|
||||||
@navigate="handleNavigate"
|
@navigate="handleNavigate"
|
||||||
@open-chat="handleOpenChat"
|
@open-chat="handleOpenChat"
|
||||||
@@ -19,10 +20,12 @@
|
|||||||
'approval-main': activeView === 'approval',
|
'approval-main': activeView === 'approval',
|
||||||
'policies-main': activeView === 'policies',
|
'policies-main': activeView === 'policies',
|
||||||
'audit-main': activeView === 'audit',
|
'audit-main': activeView === 'audit',
|
||||||
'employees-main': activeView === 'employees'
|
'employees-main': activeView === 'employees',
|
||||||
|
'settings-main': activeView === 'settings'
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<TopBar
|
<TopBar
|
||||||
|
v-if="activeView !== 'settings'"
|
||||||
:current-view="topBarView"
|
:current-view="topBarView"
|
||||||
:search="search"
|
:search="search"
|
||||||
:active-view="activeView"
|
:active-view="activeView"
|
||||||
@@ -39,7 +42,7 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<FilterBar
|
<FilterBar
|
||||||
v-if="activeView !== 'chat' && activeView !== 'overview' && activeView !== 'workbench' && activeView !== 'requests' && activeView !== 'approval' && activeView !== 'policies' && activeView !== 'audit' && activeView !== 'employees'"
|
v-if="activeView !== 'chat' && activeView !== 'overview' && activeView !== 'workbench' && activeView !== 'requests' && activeView !== 'approval' && activeView !== 'policies' && activeView !== 'audit' && activeView !== 'employees' && activeView !== 'settings'"
|
||||||
:compact="activeView === 'overview'"
|
:compact="activeView === 'overview'"
|
||||||
:filters="filters"
|
:filters="filters"
|
||||||
:ranges="ranges"
|
:ranges="ranges"
|
||||||
@@ -55,7 +58,8 @@
|
|||||||
'approval-workarea': activeView === 'approval',
|
'approval-workarea': activeView === 'approval',
|
||||||
'policies-workarea': activeView === 'policies',
|
'policies-workarea': activeView === 'policies',
|
||||||
'audit-workarea': activeView === 'audit',
|
'audit-workarea': activeView === 'audit',
|
||||||
'employees-workarea': activeView === 'employees'
|
'employees-workarea': activeView === 'employees',
|
||||||
|
'settings-workarea': activeView === 'settings'
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<OverviewView
|
<OverviewView
|
||||||
@@ -108,7 +112,8 @@
|
|||||||
<ApprovalCenterView v-else-if="activeView === 'approval'" />
|
<ApprovalCenterView v-else-if="activeView === 'approval'" />
|
||||||
<PoliciesView v-else-if="activeView === 'policies'" />
|
<PoliciesView v-else-if="activeView === 'policies'" />
|
||||||
<AuditView v-else-if="activeView === 'audit'" />
|
<AuditView v-else-if="activeView === 'audit'" />
|
||||||
<EmployeeManagementView v-else @overview-change="employeeSummary = $event" />
|
<EmployeeManagementView v-else-if="activeView === 'employees'" @overview-change="employeeSummary = $event" />
|
||||||
|
<SettingsView v-else />
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@@ -139,6 +144,7 @@ import ApprovalCenterView from './ApprovalCenterView.vue'
|
|||||||
import PoliciesView from './PoliciesView.vue'
|
import PoliciesView from './PoliciesView.vue'
|
||||||
import AuditView from './AuditView.vue'
|
import AuditView from './AuditView.vue'
|
||||||
import EmployeeManagementView from './EmployeeManagementView.vue'
|
import EmployeeManagementView from './EmployeeManagementView.vue'
|
||||||
|
import SettingsView from './SettingsView.vue'
|
||||||
|
|
||||||
import { useAppShell } from '../composables/useAppShell.js'
|
import { useAppShell } from '../composables/useAppShell.js'
|
||||||
import { useSystemState } from '../composables/useSystemState.js'
|
import { useSystemState } from '../composables/useSystemState.js'
|
||||||
@@ -183,7 +189,7 @@ const {
|
|||||||
uploadedFiles
|
uploadedFiles
|
||||||
} = useAppShell()
|
} = useAppShell()
|
||||||
|
|
||||||
const { currentUser, logout } = useSystemState()
|
const { companyProfile, currentUser, logout } = useSystemState()
|
||||||
const filteredNavItems = computed(() => filterNavItemsByAccess(navItems, currentUser.value))
|
const filteredNavItems = computed(() => filterNavItemsByAccess(navItems, currentUser.value))
|
||||||
|
|
||||||
function handleLogout() {
|
function handleLogout() {
|
||||||
|
|||||||
510
web/src/views/SettingsView.vue
Normal file
510
web/src/views/SettingsView.vue
Normal file
@@ -0,0 +1,510 @@
|
|||||||
|
<template>
|
||||||
|
<section class="settings-page">
|
||||||
|
<div class="settings-shell panel">
|
||||||
|
<aside class="settings-nav" aria-label="系统设置分类">
|
||||||
|
<div class="settings-nav-head">
|
||||||
|
<span class="nav-kicker">Settings</span>
|
||||||
|
<h2>系统设置</h2>
|
||||||
|
<p>已完成 {{ completedSectionCount }} / {{ sections.length }} 项配置,敏感字段不会保存在浏览器草稿中。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="settings-nav-list">
|
||||||
|
<button
|
||||||
|
v-for="section in sections"
|
||||||
|
:key="section.id"
|
||||||
|
class="settings-nav-item"
|
||||||
|
:class="{
|
||||||
|
active: activeSection === section.id,
|
||||||
|
complete: sectionStatus[section.id]
|
||||||
|
}"
|
||||||
|
type="button"
|
||||||
|
@click="activateSection(section.id)"
|
||||||
|
>
|
||||||
|
<span class="nav-item-copy">
|
||||||
|
<strong>{{ section.label }}</strong>
|
||||||
|
<small>{{ section.desc }}</small>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="nav-item-state">
|
||||||
|
<i :class="sectionStatus[section.id] ? 'mdi mdi-check' : 'mdi mdi-chevron-right'"></i>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="settings-nav-foot">
|
||||||
|
<span>当前环境</span>
|
||||||
|
<strong>{{ pageState.companyForm.environment }}</strong>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="settings-body">
|
||||||
|
<header class="settings-toolbar">
|
||||||
|
<div class="settings-toolbar-copy">
|
||||||
|
<span class="settings-breadcrumb">首页 / 系统设置 / {{ activeSectionConfig.label }}</span>
|
||||||
|
<h3>{{ activeSectionConfig.title }}</h3>
|
||||||
|
<p>{{ activeSectionConfig.longDesc }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-toolbar-actions">
|
||||||
|
<span class="section-status" :class="{ complete: sectionStatus[activeSection] }">
|
||||||
|
<i :class="sectionStatus[activeSection] ? 'mdi mdi-check-decagram' : 'mdi mdi-progress-clock'"></i>
|
||||||
|
<span>{{ sectionStatus[activeSection] ? '当前项已就绪' : '当前项待补全' }}</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button class="save-button" type="button" @click="saveActiveSection">
|
||||||
|
<i class="mdi mdi-content-save-outline"></i>
|
||||||
|
<span>{{ activeSectionConfig.actionLabel }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="settings-content">
|
||||||
|
<template v-if="activeSection === 'profile'">
|
||||||
|
<section class="settings-card">
|
||||||
|
<div class="card-head">
|
||||||
|
<div>
|
||||||
|
<h4>系统基本信息</h4>
|
||||||
|
<p>统一维护企业名称、显示名称和版权信息,保存后左侧品牌名称会立即同步预览。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-grid profile-grid">
|
||||||
|
<label class="field logo-field">
|
||||||
|
<span><em>*</em> 系统图标</span>
|
||||||
|
<div class="logo-tile" aria-hidden="true">
|
||||||
|
<i class="mdi mdi-domain"></i>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span><em>*</em> 企业名称</span>
|
||||||
|
<input v-model="pageState.companyForm.companyName" type="text" placeholder="请输入企业法定名称" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>企业编码</span>
|
||||||
|
<input v-model="pageState.companyForm.companyCode" type="text" placeholder="例如 XF-001" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field field-wide">
|
||||||
|
<span><em>*</em> 系统显示名称</span>
|
||||||
|
<input v-model="pageState.companyForm.displayName" type="text" placeholder="请输入系统对内展示名称" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>备案号</span>
|
||||||
|
<input v-model="pageState.companyForm.recordNumber" type="text" placeholder="请输入备案号" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>运行环境</span>
|
||||||
|
<select v-model="pageState.companyForm.environment">
|
||||||
|
<option value="生产环境">生产环境</option>
|
||||||
|
<option value="预发布环境">预发布环境</option>
|
||||||
|
<option value="测试环境">测试环境</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field field-full">
|
||||||
|
<span><em>*</em> 版权信息</span>
|
||||||
|
<input
|
||||||
|
v-model="pageState.companyForm.copyright"
|
||||||
|
type="text"
|
||||||
|
placeholder="例如 Copyright © 2024-2026 X-Financial. All Rights Reserved."
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="settings-card">
|
||||||
|
<div class="card-head">
|
||||||
|
<div>
|
||||||
|
<h4>品牌预览</h4>
|
||||||
|
<p>用于确认侧边栏品牌、页脚版权和系统入口名称的实际展示效果。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="preview-card">
|
||||||
|
<div class="preview-icon">
|
||||||
|
<i class="mdi mdi-domain"></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="preview-copy">
|
||||||
|
<strong>{{ pageState.companyForm.displayName || '系统显示名称' }}</strong>
|
||||||
|
<p>{{ pageState.companyForm.companyName || '企业法定名称' }}</p>
|
||||||
|
<small>{{ pageState.companyForm.copyright || '版权信息将显示在这里' }}</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="preview-badge">{{ pageState.companyForm.environment }}</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="activeSection === 'admin'">
|
||||||
|
<section class="settings-card">
|
||||||
|
<div class="card-head">
|
||||||
|
<div>
|
||||||
|
<h4>管理员账号</h4>
|
||||||
|
<p>维护最高权限管理员的登录账户、密码和安全通知邮箱。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-grid">
|
||||||
|
<label class="field">
|
||||||
|
<span><em>*</em> 管理员账号</span>
|
||||||
|
<input v-model="pageState.adminForm.adminAccount" type="text" placeholder="请输入管理员账号" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span><em>*</em> 管理员邮箱</span>
|
||||||
|
<input v-model="pageState.adminForm.adminEmail" type="email" placeholder="请输入管理员邮箱" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>新密码</span>
|
||||||
|
<input
|
||||||
|
v-model="pageState.adminForm.newPassword"
|
||||||
|
type="password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
placeholder="至少 5 位"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>确认密码</span>
|
||||||
|
<input
|
||||||
|
v-model="pageState.adminForm.confirmPassword"
|
||||||
|
type="password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
placeholder="再次输入管理员密码"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="settings-card">
|
||||||
|
<div class="card-head">
|
||||||
|
<div>
|
||||||
|
<h4>登录安全策略</h4>
|
||||||
|
<p>控制会话超时、登录提醒和管理员高风险操作的基础安全策略。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-grid compact-grid">
|
||||||
|
<label class="field">
|
||||||
|
<span><em>*</em> 会话超时(分钟)</span>
|
||||||
|
<input v-model.number="pageState.adminForm.sessionTimeout" type="number" min="5" max="240" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>安全通知邮箱</span>
|
||||||
|
<input v-model="pageState.adminForm.noticeEmail" type="email" placeholder="用于接收安全提醒" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="switch-group">
|
||||||
|
<button class="switch-row" type="button" @click="toggleBoolean('adminForm', 'mfaEnabled')">
|
||||||
|
<span class="switch-copy">
|
||||||
|
<strong>开启双因素验证</strong>
|
||||||
|
<small>要求管理员使用附加验证步骤登录后台。</small>
|
||||||
|
</span>
|
||||||
|
<span class="switch" :class="{ active: pageState.adminForm.mfaEnabled }"><i></i></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="switch-row" type="button" @click="toggleBoolean('adminForm', 'strongPassword')">
|
||||||
|
<span class="switch-copy">
|
||||||
|
<strong>启用强密码策略</strong>
|
||||||
|
<small>管理员密码修改时需要满足强度要求。</small>
|
||||||
|
</span>
|
||||||
|
<span class="switch" :class="{ active: pageState.adminForm.strongPassword }"><i></i></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="switch-row" type="button" @click="toggleBoolean('adminForm', 'loginAlertEnabled')">
|
||||||
|
<span class="switch-copy">
|
||||||
|
<strong>异常登录提醒</strong>
|
||||||
|
<small>检测到高风险登录时,向安全通知邮箱发送告警。</small>
|
||||||
|
</span>
|
||||||
|
<span class="switch" :class="{ active: pageState.adminForm.loginAlertEnabled }"><i></i></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="activeSection === 'llm'">
|
||||||
|
<section class="settings-card">
|
||||||
|
<div class="card-head">
|
||||||
|
<div>
|
||||||
|
<h4>模型接入</h4>
|
||||||
|
<p>配置大语言模型的供应商、模型名称和接入地址,用于 AI 助手与识别流程。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-grid">
|
||||||
|
<label class="field">
|
||||||
|
<span><em>*</em> 供应商</span>
|
||||||
|
<select v-model="pageState.llmForm.provider">
|
||||||
|
<option value="OpenAI Compatible">OpenAI Compatible</option>
|
||||||
|
<option value="Azure OpenAI">Azure OpenAI</option>
|
||||||
|
<option value="Ollama">Ollama</option>
|
||||||
|
<option value="自定义网关">自定义网关</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span><em>*</em> 模型名称</span>
|
||||||
|
<input v-model="pageState.llmForm.model" type="text" placeholder="请输入主模型名称" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field field-full">
|
||||||
|
<span><em>*</em> 接口地址</span>
|
||||||
|
<input v-model="pageState.llmForm.endpoint" type="text" placeholder="请输入兼容接口地址" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>Embedding 模型</span>
|
||||||
|
<input v-model="pageState.llmForm.embeddingModel" type="text" placeholder="请输入向量模型名称" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>API Key</span>
|
||||||
|
<input v-model="pageState.llmForm.apiKey" type="password" autocomplete="off" placeholder="保存后不会保留在草稿中" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="settings-card">
|
||||||
|
<div class="card-head">
|
||||||
|
<div>
|
||||||
|
<h4>推理与知识策略</h4>
|
||||||
|
<p>控制响应质量、输出长度以及知识库、引用回溯等增强能力。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-grid compact-grid">
|
||||||
|
<label class="field">
|
||||||
|
<span>推理模式</span>
|
||||||
|
<select v-model="pageState.llmForm.reasoningMode">
|
||||||
|
<option value="balanced">平衡</option>
|
||||||
|
<option value="quality">优先质量</option>
|
||||||
|
<option value="latency">优先速度</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>最大 Token</span>
|
||||||
|
<input v-model.number="pageState.llmForm.maxTokens" type="number" min="512" step="256" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field field-full">
|
||||||
|
<span>Temperature</span>
|
||||||
|
<div class="range-shell">
|
||||||
|
<input v-model.number="pageState.llmForm.temperature" type="range" min="0" max="1" step="0.1" />
|
||||||
|
<strong>{{ pageState.llmForm.temperature.toFixed(1) }}</strong>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="switch-group">
|
||||||
|
<button class="switch-row" type="button" @click="toggleBoolean('llmForm', 'knowledgeEnabled')">
|
||||||
|
<span class="switch-copy">
|
||||||
|
<strong>启用知识库检索</strong>
|
||||||
|
<small>允许模型在回答时结合制度知识库和业务文档。</small>
|
||||||
|
</span>
|
||||||
|
<span class="switch" :class="{ active: pageState.llmForm.knowledgeEnabled }"><i></i></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="switch-row" type="button" @click="toggleBoolean('llmForm', 'citationEnabled')">
|
||||||
|
<span class="switch-copy">
|
||||||
|
<strong>输出引用来源</strong>
|
||||||
|
<small>在 AI 助手回答中附带依据与来源提示。</small>
|
||||||
|
</span>
|
||||||
|
<span class="switch" :class="{ active: pageState.llmForm.citationEnabled }"><i></i></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="activeSection === 'logs'">
|
||||||
|
<section class="settings-card">
|
||||||
|
<div class="card-head">
|
||||||
|
<div>
|
||||||
|
<h4>日志级别与留存</h4>
|
||||||
|
<p>定义系统记录粒度、归档周期和告警接收人,方便后续审计与排障。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chip-row">
|
||||||
|
<button
|
||||||
|
v-for="level in logLevels"
|
||||||
|
:key="level"
|
||||||
|
class="level-chip"
|
||||||
|
:class="{ active: pageState.logForm.level === level }"
|
||||||
|
type="button"
|
||||||
|
@click="pageState.logForm.level = level"
|
||||||
|
>
|
||||||
|
{{ level }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-grid">
|
||||||
|
<label class="field">
|
||||||
|
<span><em>*</em> 留存天数</span>
|
||||||
|
<input v-model.number="pageState.logForm.retentionDays" type="number" min="7" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>归档周期</span>
|
||||||
|
<select v-model="pageState.logForm.archiveCycle">
|
||||||
|
<option value="daily">按天归档</option>
|
||||||
|
<option value="weekly">按周归档</option>
|
||||||
|
<option value="monthly">按月归档</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field field-full">
|
||||||
|
<span><em>*</em> 日志路径</span>
|
||||||
|
<input v-model="pageState.logForm.logPath" type="text" placeholder="例如 server/logs/app.log" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field field-full">
|
||||||
|
<span>告警邮箱</span>
|
||||||
|
<input v-model="pageState.logForm.alertEmail" type="email" placeholder="用于接收日志异常提醒" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="settings-card">
|
||||||
|
<div class="card-head">
|
||||||
|
<div>
|
||||||
|
<h4>审计策略</h4>
|
||||||
|
<p>决定是否记录关键操作、登录行为以及是否对敏感字段进行脱敏处理。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="switch-group">
|
||||||
|
<button class="switch-row" type="button" @click="toggleBoolean('logForm', 'operationAudit')">
|
||||||
|
<span class="switch-copy">
|
||||||
|
<strong>记录关键操作日志</strong>
|
||||||
|
<small>保存配置修改、审批动作和账户管理等重要事件。</small>
|
||||||
|
</span>
|
||||||
|
<span class="switch" :class="{ active: pageState.logForm.operationAudit }"><i></i></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="switch-row" type="button" @click="toggleBoolean('logForm', 'loginAudit')">
|
||||||
|
<span class="switch-copy">
|
||||||
|
<strong>记录登录审计</strong>
|
||||||
|
<small>追踪登录来源、登录结果和异常登录行为。</small>
|
||||||
|
</span>
|
||||||
|
<span class="switch" :class="{ active: pageState.logForm.loginAudit }"><i></i></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="switch-row" type="button" @click="toggleBoolean('logForm', 'maskSensitive')">
|
||||||
|
<span class="switch-copy">
|
||||||
|
<strong>敏感字段脱敏</strong>
|
||||||
|
<small>日志写入时自动隐藏密码、密钥与认证令牌。</small>
|
||||||
|
</span>
|
||||||
|
<span class="switch" :class="{ active: pageState.logForm.maskSensitive }"><i></i></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<section class="settings-card">
|
||||||
|
<div class="card-head">
|
||||||
|
<div>
|
||||||
|
<h4>SMTP 基础配置</h4>
|
||||||
|
<p>维护系统发信地址、认证账号和加密方式,用于审批提醒与系统通知。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-grid">
|
||||||
|
<label class="field">
|
||||||
|
<span><em>*</em> SMTP Host</span>
|
||||||
|
<input v-model="pageState.mailForm.smtpHost" type="text" placeholder="请输入 SMTP Host" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span><em>*</em> 端口</span>
|
||||||
|
<input v-model.number="pageState.mailForm.port" type="number" min="1" max="65535" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>加密方式</span>
|
||||||
|
<select v-model="pageState.mailForm.encryption">
|
||||||
|
<option value="SSL/TLS">SSL/TLS</option>
|
||||||
|
<option value="STARTTLS">STARTTLS</option>
|
||||||
|
<option value="None">无</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>发件人名称</span>
|
||||||
|
<input v-model="pageState.mailForm.senderName" type="text" placeholder="请输入发件人名称" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span><em>*</em> 发件人邮箱</span>
|
||||||
|
<input v-model="pageState.mailForm.senderAddress" type="email" placeholder="请输入发件人邮箱" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span><em>*</em> 登录账号</span>
|
||||||
|
<input v-model="pageState.mailForm.username" type="text" placeholder="请输入 SMTP 登录账号" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field field-full">
|
||||||
|
<span>SMTP 密码</span>
|
||||||
|
<input v-model="pageState.mailForm.password" type="password" autocomplete="off" placeholder="保存后不会保留在草稿中" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="settings-card">
|
||||||
|
<div class="card-head">
|
||||||
|
<div>
|
||||||
|
<h4>通知策略</h4>
|
||||||
|
<p>控制是否启用邮件通知、日报摘要以及默认接收邮箱。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="switch-group">
|
||||||
|
<button class="switch-row" type="button" @click="toggleBoolean('mailForm', 'alertEnabled')">
|
||||||
|
<span class="switch-copy">
|
||||||
|
<strong>启用系统通知</strong>
|
||||||
|
<small>审批、异常告警和系统事件可通过邮件触达用户。</small>
|
||||||
|
</span>
|
||||||
|
<span class="switch" :class="{ active: pageState.mailForm.alertEnabled }"><i></i></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="switch-row" type="button" @click="toggleBoolean('mailForm', 'digestEnabled')">
|
||||||
|
<span class="switch-copy">
|
||||||
|
<strong>启用日报摘要</strong>
|
||||||
|
<small>按固定时间发送系统运行与待办摘要。</small>
|
||||||
|
</span>
|
||||||
|
<span class="switch" :class="{ active: pageState.mailForm.digestEnabled }"><i></i></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-grid compact-grid">
|
||||||
|
<label class="field">
|
||||||
|
<span>摘要发送时间</span>
|
||||||
|
<input v-model="pageState.mailForm.digestTime" type="time" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>默认接收邮箱</span>
|
||||||
|
<input v-model="pageState.mailForm.defaultReceiver" type="email" placeholder="请输入默认接收邮箱" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script src="./scripts/SettingsView.js"></script>
|
||||||
|
|
||||||
|
<style scoped src="../assets/styles/views/settings-view.css"></style>
|
||||||
395
web/src/views/scripts/SettingsView.js
Normal file
395
web/src/views/scripts/SettingsView.js
Normal file
@@ -0,0 +1,395 @@
|
|||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
import { useSystemState } from '../../composables/useSystemState.js'
|
||||||
|
import { useToast } from '../../composables/useToast.js'
|
||||||
|
|
||||||
|
const SETTINGS_STORAGE_KEY = 'x-financial-settings-draft'
|
||||||
|
const CURRENT_YEAR = new Date().getFullYear()
|
||||||
|
|
||||||
|
const SECTION_DEFINITIONS = [
|
||||||
|
{
|
||||||
|
id: 'profile',
|
||||||
|
label: '企业信息',
|
||||||
|
title: '系统基本信息',
|
||||||
|
desc: '公司名称、品牌与版权',
|
||||||
|
longDesc: '统一维护企业名称、系统显示名和版权信息,保存后会直接同步到当前界面的品牌预览。',
|
||||||
|
actionLabel: '保存企业信息'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'admin',
|
||||||
|
label: '管理员安全',
|
||||||
|
title: '管理员账号与安全策略',
|
||||||
|
desc: '账号、密码与登录安全',
|
||||||
|
longDesc: '管理最高权限管理员的账号、密码和登录安全策略,密码类字段仅用于本次填写,不会进入浏览器草稿。',
|
||||||
|
actionLabel: '保存安全设置'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'llm',
|
||||||
|
label: '大语言模型',
|
||||||
|
title: '模型接入配置',
|
||||||
|
desc: '供应商、模型与推理策略',
|
||||||
|
longDesc: '配置 AI 助手与识别流程依赖的大模型接入信息,并维护推理模式、知识检索和输出行为。',
|
||||||
|
actionLabel: '保存模型配置'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'logs',
|
||||||
|
label: '日志策略',
|
||||||
|
title: '日志与审计策略',
|
||||||
|
desc: '日志级别、留存与脱敏',
|
||||||
|
longDesc: '定义系统日志级别、留存周期和审计策略,保证后续排障、追溯和安全审计有完整依据。',
|
||||||
|
actionLabel: '保存日志策略'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mail',
|
||||||
|
label: '邮箱设置',
|
||||||
|
title: '邮箱通知配置',
|
||||||
|
desc: 'SMTP 与通知投递策略',
|
||||||
|
longDesc: '维护系统邮件发送配置和通知投递策略,审批、预警和摘要邮件都会依赖这里的设置。',
|
||||||
|
actionLabel: '保存邮箱配置'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const LOG_LEVELS = ['DEBUG', 'INFO', 'WARN', 'ERROR']
|
||||||
|
|
||||||
|
function normalizeValue(value) {
|
||||||
|
return String(value ?? '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDefaultState(companyProfile, currentUser) {
|
||||||
|
const companyName = normalizeValue(companyProfile?.name) || 'X-Financial'
|
||||||
|
const companyCode = normalizeValue(companyProfile?.code) || 'XF-001'
|
||||||
|
const adminEmail =
|
||||||
|
normalizeValue(companyProfile?.adminEmail) ||
|
||||||
|
normalizeValue(currentUser?.email) ||
|
||||||
|
'admin@example.com'
|
||||||
|
const adminAccount = normalizeValue(currentUser?.username) || 'superadmin'
|
||||||
|
|
||||||
|
return {
|
||||||
|
companyForm: {
|
||||||
|
companyName,
|
||||||
|
displayName: companyName,
|
||||||
|
companyCode,
|
||||||
|
recordNumber: '',
|
||||||
|
environment: '生产环境',
|
||||||
|
copyright: `Copyright © 2024-${CURRENT_YEAR} ${companyName}. All Rights Reserved.`
|
||||||
|
},
|
||||||
|
adminForm: {
|
||||||
|
adminAccount,
|
||||||
|
adminEmail,
|
||||||
|
newPassword: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
sessionTimeout: Number(import.meta.env.VITE_AUTH_IDLE_TIMEOUT_MINUTES || 30),
|
||||||
|
noticeEmail: adminEmail,
|
||||||
|
mfaEnabled: true,
|
||||||
|
strongPassword: true,
|
||||||
|
loginAlertEnabled: true
|
||||||
|
},
|
||||||
|
llmForm: {
|
||||||
|
provider: 'OpenAI Compatible',
|
||||||
|
model: 'gpt-4.1-mini',
|
||||||
|
endpoint: 'https://api.openai.com/v1',
|
||||||
|
embeddingModel: 'text-embedding-3-large',
|
||||||
|
apiKey: '',
|
||||||
|
reasoningMode: 'balanced',
|
||||||
|
maxTokens: 4096,
|
||||||
|
temperature: 0.2,
|
||||||
|
knowledgeEnabled: true,
|
||||||
|
citationEnabled: true
|
||||||
|
},
|
||||||
|
logForm: {
|
||||||
|
level: 'INFO',
|
||||||
|
retentionDays: 180,
|
||||||
|
archiveCycle: 'weekly',
|
||||||
|
logPath: 'server/logs/app.log',
|
||||||
|
alertEmail: adminEmail,
|
||||||
|
operationAudit: true,
|
||||||
|
loginAudit: true,
|
||||||
|
maskSensitive: true
|
||||||
|
},
|
||||||
|
mailForm: {
|
||||||
|
smtpHost: 'smtp.exmail.qq.com',
|
||||||
|
port: 465,
|
||||||
|
encryption: 'SSL/TLS',
|
||||||
|
senderName: companyName,
|
||||||
|
senderAddress: adminEmail,
|
||||||
|
username: adminEmail,
|
||||||
|
password: '',
|
||||||
|
alertEnabled: true,
|
||||||
|
digestEnabled: false,
|
||||||
|
digestTime: '09:00',
|
||||||
|
defaultReceiver: adminEmail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStoredSettings() {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = window.sessionStorage.getItem(SETTINGS_STORAGE_KEY)
|
||||||
|
if (!raw) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeStoredState(defaults, stored) {
|
||||||
|
return {
|
||||||
|
companyForm: { ...defaults.companyForm, ...(stored?.companyForm || {}) },
|
||||||
|
adminForm: { ...defaults.adminForm, ...(stored?.adminForm || {}) },
|
||||||
|
llmForm: { ...defaults.llmForm, ...(stored?.llmForm || {}) },
|
||||||
|
logForm: { ...defaults.logForm, ...(stored?.logForm || {}) },
|
||||||
|
mailForm: { ...defaults.mailForm, ...(stored?.mailForm || {}) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeForStorage(state) {
|
||||||
|
return {
|
||||||
|
companyForm: { ...state.companyForm },
|
||||||
|
adminForm: {
|
||||||
|
...state.adminForm,
|
||||||
|
newPassword: '',
|
||||||
|
confirmPassword: ''
|
||||||
|
},
|
||||||
|
llmForm: {
|
||||||
|
...state.llmForm,
|
||||||
|
apiKey: ''
|
||||||
|
},
|
||||||
|
logForm: { ...state.logForm },
|
||||||
|
mailForm: {
|
||||||
|
...state.mailForm,
|
||||||
|
password: ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistSettings(state) {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
window.sessionStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(sanitizeForStorage(state)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeSectionStatus(state) {
|
||||||
|
return {
|
||||||
|
profile: Boolean(
|
||||||
|
normalizeValue(state.companyForm.companyName) &&
|
||||||
|
normalizeValue(state.companyForm.displayName) &&
|
||||||
|
normalizeValue(state.companyForm.copyright)
|
||||||
|
),
|
||||||
|
admin: Boolean(
|
||||||
|
normalizeValue(state.adminForm.adminAccount) &&
|
||||||
|
normalizeValue(state.adminForm.adminEmail) &&
|
||||||
|
Number(state.adminForm.sessionTimeout) >= 5
|
||||||
|
),
|
||||||
|
llm: Boolean(
|
||||||
|
normalizeValue(state.llmForm.provider) &&
|
||||||
|
normalizeValue(state.llmForm.model) &&
|
||||||
|
normalizeValue(state.llmForm.endpoint)
|
||||||
|
),
|
||||||
|
logs: Boolean(
|
||||||
|
normalizeValue(state.logForm.level) &&
|
||||||
|
Number(state.logForm.retentionDays) > 0 &&
|
||||||
|
normalizeValue(state.logForm.logPath)
|
||||||
|
),
|
||||||
|
mail: Boolean(
|
||||||
|
normalizeValue(state.mailForm.smtpHost) &&
|
||||||
|
Number(state.mailForm.port) > 0 &&
|
||||||
|
normalizeValue(state.mailForm.senderAddress) &&
|
||||||
|
normalizeValue(state.mailForm.username)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'SettingsView',
|
||||||
|
setup() {
|
||||||
|
const { toast } = useToast()
|
||||||
|
const { companyProfile, currentUser, updateCompanyProfilePreview } = useSystemState()
|
||||||
|
|
||||||
|
const defaults = buildDefaultState(companyProfile.value, currentUser.value)
|
||||||
|
const pageState = ref(mergeStoredState(defaults, readStoredSettings()))
|
||||||
|
const activeSection = ref('profile')
|
||||||
|
|
||||||
|
const sections = SECTION_DEFINITIONS
|
||||||
|
const logLevels = LOG_LEVELS
|
||||||
|
|
||||||
|
const sectionStatus = computed(() => computeSectionStatus(pageState.value))
|
||||||
|
const completedSectionCount = computed(() => Object.values(sectionStatus.value).filter(Boolean).length)
|
||||||
|
const activeSectionConfig = computed(
|
||||||
|
() => sections.find((section) => section.id === activeSection.value) || sections[0]
|
||||||
|
)
|
||||||
|
|
||||||
|
function activateSection(sectionId) {
|
||||||
|
activeSection.value = sectionId
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleBoolean(formKey, field) {
|
||||||
|
pageState.value[formKey][field] = !pageState.value[formKey][field]
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveProfileSection() {
|
||||||
|
const companyForm = pageState.value.companyForm
|
||||||
|
|
||||||
|
if (!normalizeValue(companyForm.companyName)) {
|
||||||
|
toast('请输入企业名称。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!normalizeValue(companyForm.displayName)) {
|
||||||
|
toast('请输入系统显示名称。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!normalizeValue(companyForm.copyright)) {
|
||||||
|
toast('请输入版权信息。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCompanyProfilePreview({
|
||||||
|
name: normalizeValue(companyForm.displayName),
|
||||||
|
code: normalizeValue(companyForm.companyCode)
|
||||||
|
})
|
||||||
|
|
||||||
|
pageState.value.mailForm.senderName = normalizeValue(companyForm.displayName)
|
||||||
|
persistSettings(pageState.value)
|
||||||
|
toast('企业信息已保存并应用到当前界面预览。')
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveAdminSection() {
|
||||||
|
const adminForm = pageState.value.adminForm
|
||||||
|
|
||||||
|
if (!normalizeValue(adminForm.adminAccount)) {
|
||||||
|
toast('请输入管理员账号。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!normalizeValue(adminForm.adminEmail)) {
|
||||||
|
toast('请输入管理员邮箱。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Number(adminForm.sessionTimeout) < 5) {
|
||||||
|
toast('会话超时时间不能少于 5 分钟。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (adminForm.newPassword) {
|
||||||
|
if (adminForm.newPassword.length < 5) {
|
||||||
|
toast('管理员密码至少需要 5 位。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (adminForm.newPassword !== adminForm.confirmPassword) {
|
||||||
|
toast('两次输入的管理员密码不一致。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCompanyProfilePreview({
|
||||||
|
adminEmail: normalizeValue(adminForm.adminEmail)
|
||||||
|
})
|
||||||
|
|
||||||
|
persistSettings(pageState.value)
|
||||||
|
adminForm.newPassword = ''
|
||||||
|
adminForm.confirmPassword = ''
|
||||||
|
toast('管理员安全设置已保存。')
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveLlmSection() {
|
||||||
|
const llmForm = pageState.value.llmForm
|
||||||
|
|
||||||
|
if (
|
||||||
|
!normalizeValue(llmForm.provider) ||
|
||||||
|
!normalizeValue(llmForm.model) ||
|
||||||
|
!normalizeValue(llmForm.endpoint)
|
||||||
|
) {
|
||||||
|
toast('请完整填写模型供应商、模型名称和接口地址。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
persistSettings(pageState.value)
|
||||||
|
llmForm.apiKey = ''
|
||||||
|
toast('模型配置已保存。')
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveLogsSection() {
|
||||||
|
const logForm = pageState.value.logForm
|
||||||
|
|
||||||
|
if (!normalizeValue(logForm.level) || Number(logForm.retentionDays) <= 0) {
|
||||||
|
toast('请填写有效的日志级别和留存天数。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!normalizeValue(logForm.logPath)) {
|
||||||
|
toast('请输入日志路径。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
persistSettings(pageState.value)
|
||||||
|
toast('日志策略已保存。')
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveMailSection() {
|
||||||
|
const mailForm = pageState.value.mailForm
|
||||||
|
|
||||||
|
if (!normalizeValue(mailForm.smtpHost) || Number(mailForm.port) <= 0) {
|
||||||
|
toast('请填写有效的 SMTP Host 和端口。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!normalizeValue(mailForm.senderAddress) || !normalizeValue(mailForm.username)) {
|
||||||
|
toast('请填写发件人邮箱和 SMTP 登录账号。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
persistSettings(pageState.value)
|
||||||
|
mailForm.password = ''
|
||||||
|
toast('邮箱配置已保存。')
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveActiveSection() {
|
||||||
|
if (activeSection.value === 'profile') {
|
||||||
|
saveProfileSection()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeSection.value === 'admin') {
|
||||||
|
saveAdminSection()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeSection.value === 'llm') {
|
||||||
|
saveLlmSection()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeSection.value === 'logs') {
|
||||||
|
saveLogsSection()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
saveMailSection()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeSection,
|
||||||
|
activeSectionConfig,
|
||||||
|
activateSection,
|
||||||
|
completedSectionCount,
|
||||||
|
logLevels,
|
||||||
|
pageState,
|
||||||
|
saveActiveSection,
|
||||||
|
sectionStatus,
|
||||||
|
sections,
|
||||||
|
toggleBoolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user