feat(ui): finalize shared shells and loading states
This commit is contained in:
7
web/package-lock.json
generated
7
web/package-lock.json
generated
@@ -17,7 +17,6 @@
|
||||
"element-plus": "^2.14.0",
|
||||
"markdown-it": "^14.1.1",
|
||||
"pg": "^8.13.1",
|
||||
"primeicons": "^7.0.0",
|
||||
"vite": "^5.4.19",
|
||||
"vue": "^3.5.13",
|
||||
"vue-chartjs": "^5.3.3",
|
||||
@@ -2754,12 +2753,6 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/primeicons": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/primeicons/-/primeicons-7.0.0.tgz",
|
||||
"integrity": "sha512-jK3Et9UzwzTsd6tzl2RmwrVY/b8raJ3QZLzoDACj+oTJ0oX7L9Hy+XnVwgo4QVKlKpnP/Ur13SXV/pVh4LzaDw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/punycode.js": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmmirror.com/punycode.js/-/punycode.js-2.3.1.tgz",
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
"element-plus": "^2.14.0",
|
||||
"markdown-it": "^14.1.1",
|
||||
"pg": "^8.13.1",
|
||||
"primeicons": "^7.0.0",
|
||||
"vite": "^5.4.19",
|
||||
"vue": "^3.5.13",
|
||||
"vue-chartjs": "^5.3.3",
|
||||
|
||||
@@ -69,68 +69,6 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.login-entry-card {
|
||||
width: min(360px, calc(100% - 48px));
|
||||
display: grid;
|
||||
grid-template-columns: 42px minmax(0, 1fr);
|
||||
gap: 12px 14px;
|
||||
align-items: center;
|
||||
padding: 22px 24px 20px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.26);
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
box-shadow: 0 20px 46px rgba(15, 23, 42, 0.14);
|
||||
animation: loginEntryCardIn 360ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
.login-entry-mark {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.2);
|
||||
border-radius: 4px;
|
||||
background: var(--theme-primary-soft);
|
||||
color: var(--theme-primary-active);
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.login-entry-copy {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.login-entry-copy strong {
|
||||
color: var(--ink);
|
||||
font-size: 16px;
|
||||
line-height: 1.35;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.login-entry-copy span {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.login-entry-progress {
|
||||
grid-column: 1 / -1;
|
||||
height: 3px;
|
||||
overflow: hidden;
|
||||
background: #edf2f7;
|
||||
}
|
||||
|
||||
.login-entry-progress::after {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--theme-primary);
|
||||
transform-origin: left center;
|
||||
animation: loginEntryProgress 840ms cubic-bezier(0.2, 0, 0, 1) both;
|
||||
}
|
||||
|
||||
.login-entry-veil-enter-active {
|
||||
transition: opacity 180ms var(--ease);
|
||||
}
|
||||
@@ -231,7 +169,6 @@
|
||||
.main.policies-main,
|
||||
.main.audit-main,
|
||||
.main.digital-employees-main,
|
||||
.main.logs-main,
|
||||
.main.employees-main,
|
||||
.main.settings-main {
|
||||
height: var(--desktop-stage-height, 100dvh);
|
||||
@@ -250,7 +187,6 @@
|
||||
.workarea.policies-workarea,
|
||||
.workarea.audit-workarea,
|
||||
.workarea.digital-employees-workarea,
|
||||
.workarea.logs-workarea,
|
||||
.workarea.employees-workarea,
|
||||
.workarea.settings-workarea {
|
||||
min-height: 0;
|
||||
@@ -267,28 +203,6 @@
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
@keyframes loginEntryCardIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale3d(0.92, 0.92, 1);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale3d(1, 1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes loginEntryProgress {
|
||||
from {
|
||||
transform: scaleX(0);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes loginEntrySidebarIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
@@ -401,8 +315,6 @@
|
||||
transition-duration: 120ms, 120ms !important;
|
||||
}
|
||||
|
||||
.login-entry-card,
|
||||
.login-entry-progress::after,
|
||||
.app.login-entry-active .app-sidebar,
|
||||
.app.login-entry-active > .main {
|
||||
animation: none !important;
|
||||
|
||||
@@ -13,6 +13,12 @@
|
||||
margin-top: 14px;
|
||||
border-bottom: 1px solid #dbe4ee;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.enterprise-list-page .status-tabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.enterprise-list-page .status-tabs button {
|
||||
@@ -65,6 +71,7 @@
|
||||
}
|
||||
|
||||
.enterprise-list-page .filter-set {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
@@ -74,7 +81,14 @@
|
||||
|
||||
.enterprise-list-page .list-search {
|
||||
position: relative;
|
||||
width: min(280px, 100%);
|
||||
flex: 0 1 280px;
|
||||
width: 280px;
|
||||
min-width: 220px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.enterprise-list-page .picker-filter {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.enterprise-list-page .list-search .mdi {
|
||||
@@ -202,13 +216,32 @@
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.enterprise-list-page .active-filter-strip {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.enterprise-list-page .active-filter-chip {
|
||||
min-height: 28px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 10px;
|
||||
border-radius: 4px;
|
||||
background: var(--theme-primary-soft);
|
||||
color: var(--theme-primary-active);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.enterprise-list-page .table-wrap {
|
||||
min-height: 400px;
|
||||
margin-top: 10px;
|
||||
overflow: auto;
|
||||
border: 1px solid #edf2f7;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(180deg, #fcfeff 0%, var(--theme-primary-light-9) 100%);
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(180deg, #fcfefd 0%, #f4f8f6 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
@@ -263,7 +296,8 @@
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.enterprise-list-page table {
|
||||
.enterprise-list-page table,
|
||||
.enterprise-list-page .table-wrap > table {
|
||||
width: 100%;
|
||||
min-width: 1080px;
|
||||
align-self: flex-start;
|
||||
@@ -272,7 +306,9 @@
|
||||
}
|
||||
|
||||
.enterprise-list-page th,
|
||||
.enterprise-list-page td {
|
||||
.enterprise-list-page td,
|
||||
.enterprise-list-page .table-wrap th,
|
||||
.enterprise-list-page .table-wrap td {
|
||||
padding: 13px 12px;
|
||||
border-bottom: 1px solid #edf2f7;
|
||||
color: #24324a;
|
||||
@@ -285,7 +321,8 @@
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.enterprise-list-page th {
|
||||
.enterprise-list-page th,
|
||||
.enterprise-list-page .table-wrap th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
@@ -295,16 +332,20 @@
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.enterprise-list-page tbody tr {
|
||||
.enterprise-list-page tbody tr,
|
||||
.enterprise-list-page .table-wrap tbody tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.enterprise-list-page tbody tr:hover,
|
||||
.enterprise-list-page tbody tr.spotlight {
|
||||
.enterprise-list-page tbody tr.spotlight,
|
||||
.enterprise-list-page .table-wrap tbody tr:hover,
|
||||
.enterprise-list-page .table-wrap tbody tr.spotlight {
|
||||
background: linear-gradient(90deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08), rgba(var(--theme-primary-rgb, 58, 124, 165), 0.03));
|
||||
}
|
||||
|
||||
.enterprise-list-page tbody tr:last-child td {
|
||||
.enterprise-list-page tbody tr:last-child td,
|
||||
.enterprise-list-page .table-wrap tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
@@ -313,11 +354,119 @@
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.enterprise-list-page .doc-kind-tag,
|
||||
.enterprise-list-page .type-tag,
|
||||
.enterprise-list-page .status-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.enterprise-list-page .doc-kind-tag {
|
||||
min-height: 26px;
|
||||
padding: 0 10px;
|
||||
border-radius: 7px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.enterprise-list-page .doc-kind-tag.reimbursement {
|
||||
background: var(--theme-primary-light-9);
|
||||
color: var(--theme-primary-active);
|
||||
}
|
||||
|
||||
.enterprise-list-page .doc-kind-tag.application {
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.enterprise-list-page .type-tag {
|
||||
min-height: 26px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.enterprise-list-page .type-tag.travel,
|
||||
.enterprise-list-page .type-tag.hotel,
|
||||
.enterprise-list-page .type-tag.transport {
|
||||
background: var(--theme-primary-light-9);
|
||||
color: var(--theme-primary-active);
|
||||
}
|
||||
|
||||
.enterprise-list-page .type-tag.entertainment,
|
||||
.enterprise-list-page .type-tag.meal {
|
||||
background: #fff7ed;
|
||||
color: #ea580c;
|
||||
}
|
||||
|
||||
.enterprise-list-page .type-tag.office {
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.enterprise-list-page .type-tag.meeting,
|
||||
.enterprise-list-page .type-tag.training {
|
||||
background: #eef2ff;
|
||||
color: #4f46e5;
|
||||
}
|
||||
|
||||
.enterprise-list-page .type-tag.other {
|
||||
background: #f8fafc;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.enterprise-list-page .status-tag {
|
||||
min-height: 24px;
|
||||
padding: 0 9px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.enterprise-list-page .status-tag.info {
|
||||
border-color: #bfdbfe;
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.enterprise-list-page .status-tag.success,
|
||||
.enterprise-list-page .status-tag.archived {
|
||||
border-color: var(--success-line);
|
||||
background: var(--success-soft);
|
||||
color: var(--success-active);
|
||||
}
|
||||
|
||||
.enterprise-list-page .status-tag.warning,
|
||||
.enterprise-list-page .status-tag.draft {
|
||||
border-color: #fed7aa;
|
||||
background: #fff7ed;
|
||||
color: #f97316;
|
||||
}
|
||||
|
||||
.enterprise-list-page .status-tag.danger {
|
||||
border-color: #fecaca;
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.enterprise-list-page .status-tag.neutral,
|
||||
.enterprise-list-page .status-tag.muted,
|
||||
.enterprise-list-page .status-tag.disabled {
|
||||
border-color: #cbd5e1;
|
||||
background: #f8fafc;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.enterprise-pagination {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.enterprise-list-page .list-foot {
|
||||
.enterprise-list-page .list-foot,
|
||||
.enterprise-list-page.enterprise-list-page .list-foot {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
@@ -325,27 +474,30 @@
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.enterprise-list-page .page-summary {
|
||||
.enterprise-list-page .page-summary,
|
||||
.enterprise-list-page.enterprise-list-page .page-summary {
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.enterprise-list-page .pager {
|
||||
.enterprise-list-page .pager,
|
||||
.enterprise-list-page.enterprise-list-page .pager {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 4px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
border-radius: 12px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.enterprise-list-page .pager button {
|
||||
.enterprise-list-page .pager button,
|
||||
.enterprise-list-page.enterprise-list-page .pager button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
border-radius: 9px;
|
||||
background: transparent;
|
||||
color: #334155;
|
||||
font-size: 14px;
|
||||
@@ -353,19 +505,22 @@
|
||||
transition: background 160ms ease, color 160ms ease, box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.enterprise-list-page .pager button:hover:not(.active) {
|
||||
.enterprise-list-page .pager button:hover:not(.active),
|
||||
.enterprise-list-page.enterprise-list-page .pager button:hover:not(.active) {
|
||||
background: #fff;
|
||||
color: var(--theme-primary-active);
|
||||
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.enterprise-list-page .pager button.active {
|
||||
background: var(--theme-primary);
|
||||
.enterprise-list-page .pager button.active,
|
||||
.enterprise-list-page.enterprise-list-page .pager button.active {
|
||||
background: var(--theme-primary-active);
|
||||
color: #fff;
|
||||
box-shadow: 0 8px 16px var(--theme-primary-shadow);
|
||||
}
|
||||
|
||||
.enterprise-list-page .pager button:disabled {
|
||||
.enterprise-list-page .pager button:disabled,
|
||||
.enterprise-list-page.enterprise-list-page .pager button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
box-shadow: none;
|
||||
@@ -394,6 +549,86 @@
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.enterprise-detail-page .detail-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 0 0;
|
||||
border-top: 1px solid #e5eaf0;
|
||||
}
|
||||
|
||||
.enterprise-detail-page .detail-action-group {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.enterprise-detail-page .back-action,
|
||||
.enterprise-detail-page .minor-action,
|
||||
.enterprise-detail-page .major-action {
|
||||
height: 38px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 0 14px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 760;
|
||||
transition: transform 160ms ease, border-color 160ms ease, color 160ms ease, box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.enterprise-detail-page .back-action,
|
||||
.enterprise-detail-page .minor-action {
|
||||
border: 1px solid #d7e0ea;
|
||||
background: #fff;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.enterprise-detail-page .minor-action.success-action {
|
||||
border-color: var(--success-line);
|
||||
background: var(--success-soft);
|
||||
color: var(--success-hover);
|
||||
}
|
||||
|
||||
.enterprise-detail-page .minor-action.enable-action {
|
||||
border-color: rgba(100, 116, 139, 0.26);
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.enterprise-detail-page .minor-action.enable-action.is-on {
|
||||
border-color: rgba(var(--success-rgb), 0.26);
|
||||
color: var(--success-hover);
|
||||
}
|
||||
|
||||
.enterprise-detail-page .minor-action.danger-action {
|
||||
border-color: rgba(220, 38, 38, 0.2);
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.enterprise-detail-page .major-action {
|
||||
border: 1px solid var(--theme-primary);
|
||||
background: var(--theme-primary);
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 12px var(--theme-primary-shadow);
|
||||
}
|
||||
|
||||
.enterprise-detail-page .back-action:hover,
|
||||
.enterprise-detail-page .minor-action:hover,
|
||||
.enterprise-detail-page .major-action:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.enterprise-detail-page .back-action:disabled,
|
||||
.enterprise-detail-page .minor-action:disabled,
|
||||
.enterprise-detail-page .major-action:disabled {
|
||||
opacity: 0.52;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.enterprise-detail-card .card-head {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
@@ -147,40 +147,25 @@ h1 { margin-top: 4px; color: var(--ink); font-size: 24px; line-height: 1.25; fon
|
||||
*, *::before, *::after { animation-duration: 1ms !important; transition-duration: 1ms !important; scroll-behavior: auto !important; }
|
||||
}
|
||||
|
||||
.table-loading__spinner {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
border: 3px solid #e2e8f0;
|
||||
border-top-color: var(--primary);
|
||||
border-radius: 50%;
|
||||
animation: table-spinner-rotate .8s linear infinite !important;
|
||||
.table-state:has(.table-loading.screen-floating),
|
||||
.table-state:has(.table-loading-anchor),
|
||||
.table-loading-row:has(.table-loading.screen-floating),
|
||||
.table-loading-row:has(.table-loading-anchor) {
|
||||
min-height: 0 !important;
|
||||
padding: 0 !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.table-loading.sky .table-loading__spinner {
|
||||
border-top-color: var(--primary);
|
||||
}
|
||||
|
||||
.table-loading.detail .table-loading__spinner {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.table-loading.banner .table-loading__spinner {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.table-loading__spinner i {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@keyframes table-spinner-rotate {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
tr:has(> .table-loading-row .table-loading.screen-floating),
|
||||
tr:has(> .table-loading-row .table-loading-anchor),
|
||||
tr:has(> .table-loading-row .table-loading.screen-floating) > td,
|
||||
tr:has(> .table-loading-row .table-loading-anchor) > td,
|
||||
.table-loading-row:has(.table-loading.screen-floating),
|
||||
.table-loading-row:has(.table-loading-anchor) {
|
||||
height: 0 !important;
|
||||
border: 0 !important;
|
||||
line-height: 0 !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
/* Global Scrollbar Styles */
|
||||
|
||||
@@ -5,49 +5,81 @@
|
||||
|
||||
.digital-employees-list {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 16px 18px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.digital-employees-list > .status-tabs {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
gap: 28px;
|
||||
margin-top: 14px;
|
||||
padding-bottom: 0;
|
||||
border-bottom: 1px solid #dbe4ee;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.digital-employees-list .table-wrap {
|
||||
min-height: 0;
|
||||
.digital-employees-list > .status-tabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.digital-employees-list > .status-tabs button {
|
||||
position: relative;
|
||||
min-height: 36px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
font-weight: 750;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.digital-employees-list > .status-tabs button.active {
|
||||
color: var(--theme-primary-active);
|
||||
}
|
||||
|
||||
.digital-employees-list > .status-tabs button.active::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: -1px;
|
||||
height: 3px;
|
||||
border-radius: 999px 999px 0 0;
|
||||
background: var(--theme-primary);
|
||||
}
|
||||
|
||||
.digital-employees-table {
|
||||
min-width: 1060px;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.digital-employees-table .col-skill { width: 27%; }
|
||||
.digital-employees-table .col-schedule { width: 16%; }
|
||||
.digital-employees-table .col-mode { width: 12%; }
|
||||
/* Default first column left alignment */
|
||||
.digital-employees-table th:first-child,
|
||||
.digital-employees-table td:first-child {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.digital-employees-table .col-skill { width: 22%; }
|
||||
.digital-employees-table .col-skill-type { width: 11%; }
|
||||
.digital-employees-table .col-status { width: 11%; }
|
||||
.digital-employees-table .col-enabled { width: 11%; }
|
||||
.digital-employees-table .col-updated { width: 12%; }
|
||||
|
||||
.digital-employees-table td {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.digital-employees-table tbody tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
.digital-employees-table .col-owner { width: 11%; }
|
||||
.digital-employees-table .col-schedule { width: 16%; }
|
||||
.digital-employees-table .col-mode { width: 10%; }
|
||||
.digital-employees-table .col-status { width: 10%; }
|
||||
.digital-employees-table .col-enabled { width: 10%; }
|
||||
.digital-employees-table .col-updated { width: 10%; }
|
||||
|
||||
.digital-refresh-action i {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.skill-type-pill {
|
||||
border-color: #dbeafe;
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.digital-employee-detail {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@@ -54,6 +54,26 @@
|
||||
color: var(--theme-primary);
|
||||
}
|
||||
|
||||
.system-logs-list .refresh-interval-filter,
|
||||
.system-logs-list .refresh-interval-trigger,
|
||||
.system-logs-list .refresh-interval-menu {
|
||||
min-width: 148px;
|
||||
}
|
||||
|
||||
.system-logs-list .refresh-interval-trigger > .mdi:first-child {
|
||||
color: var(--theme-primary);
|
||||
}
|
||||
|
||||
.system-logs-list .icon-refresh-action {
|
||||
width: 40px;
|
||||
min-width: 40px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.system-logs-list .icon-refresh-action .mdi {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.system-logs-list .document-filter-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
|
||||
@@ -423,14 +423,6 @@ th {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.table-loading-row {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.table-loading-row > .table-loading {
|
||||
min-height: 220px;
|
||||
}
|
||||
|
||||
.list-foot {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
|
||||
@@ -206,6 +206,16 @@
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
.settings-content-fill {
|
||||
overflow: hidden;
|
||||
align-content: stretch;
|
||||
}
|
||||
|
||||
.settings-content-fill .settings-logs-view,
|
||||
.settings-content-fill .settings-log-detail-view {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.model-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@@ -680,6 +690,19 @@
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.log-policy-card .card-head {
|
||||
padding-top: 12px;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.log-policy-card > *:not(.card-head) {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.log-policy-card > *:not(.card-head):last-child {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* 大语言模型配置卡片图标与标题排版 */
|
||||
.card-title-with-icon {
|
||||
display: flex;
|
||||
|
||||
@@ -591,9 +591,9 @@
|
||||
background: rgba(6, 78, 59, 0.34);
|
||||
}
|
||||
|
||||
.setup-startup-spinner .pi {
|
||||
font-size: 22px;
|
||||
}
|
||||
.setup-startup-spinner .mdi {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.setup-startup-spinner strong {
|
||||
color: #ffffff;
|
||||
@@ -626,10 +626,10 @@
|
||||
background: rgba(15, 23, 42, 0.24);
|
||||
}
|
||||
|
||||
.setup-startup-step .pi {
|
||||
margin-top: 2px;
|
||||
.setup-startup-step .mdi {
|
||||
margin-top: 2px;
|
||||
color: color-mix(in srgb, var(--theme-primary-soft) 46%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.setup-startup-step strong {
|
||||
color: #f8fffb;
|
||||
@@ -648,25 +648,25 @@
|
||||
border-color: rgba(59, 130, 246, 0.34);
|
||||
}
|
||||
|
||||
.setup-startup-step.is-running .pi {
|
||||
color: #93c5fd;
|
||||
}
|
||||
.setup-startup-step.is-running .mdi {
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
.setup-startup-step.is-success {
|
||||
border-color: rgba(var(--theme-primary-rgb), 0.32);
|
||||
}
|
||||
|
||||
.setup-startup-step.is-success .pi {
|
||||
.setup-startup-step.is-success .mdi {
|
||||
color: var(--theme-primary-light-5);
|
||||
}
|
||||
}
|
||||
|
||||
.setup-startup-step.is-error {
|
||||
border-color: rgba(248, 113, 113, 0.36);
|
||||
}
|
||||
|
||||
.setup-startup-step.is-error .pi {
|
||||
color: #f87171;
|
||||
}
|
||||
.setup-startup-step.is-error .mdi {
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.setup-startup-console {
|
||||
min-height: 0;
|
||||
|
||||
@@ -6,13 +6,21 @@
|
||||
:error="errorMessage"
|
||||
:empty="!visibleEmployees.length"
|
||||
:empty-state="emptyState"
|
||||
:show-pagination="!loading && !errorMessage && visibleEmployees.length > 0"
|
||||
:current-page="currentPage"
|
||||
:page-size="pageSize"
|
||||
:pages="pageNumbers"
|
||||
:show-page-size="false"
|
||||
:summary="paginationSummary"
|
||||
:total="visibleEmployees.length"
|
||||
:total-pages="totalPages"
|
||||
loading-title="数字员工资产同步中"
|
||||
loading-message="正在加载数字员工资产"
|
||||
loading-icon="mdi mdi-view-list-outline"
|
||||
hint="归集后台自动执行的数字员工技能,可查看技能内容、执行计划、启动状态和最近版本。"
|
||||
@update:current-page="currentPage = $event"
|
||||
>
|
||||
<template #filters>
|
||||
<label class="search-filter">
|
||||
<label class="list-search">
|
||||
<i class="mdi mdi-magnify"></i>
|
||||
<input
|
||||
:value="keyword"
|
||||
@@ -127,51 +135,21 @@
|
||||
@click="emit('open-employee-detail', employee)"
|
||||
>
|
||||
<td>
|
||||
<div class="skill-name-cell">
|
||||
<span class="skill-avatar" :class="employee.badgeTone">{{ employee.short }}</span>
|
||||
<div>
|
||||
<strong>{{ employee.name }}</strong>
|
||||
<span class="skill-list-subtitle">{{ employee.summary || employee.code }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<strong class="doc-id">{{ employee.name }}</strong>
|
||||
</td>
|
||||
<td><span class="scope-pill skill-type-pill">{{ employee.skillCategory }}</span></td>
|
||||
<td><span class="doc-kind-tag application">{{ employee.skillCategory }}</span></td>
|
||||
<td>{{ employee.owner }}</td>
|
||||
<td><span class="scope-pill">{{ employee.scope }}</span></td>
|
||||
<td><span class="type-tag other">{{ employee.scope }}</span></td>
|
||||
<td>{{ employee.executionMode }}</td>
|
||||
<td>
|
||||
<span :class="['status-pill', employee.statusTone]">{{ employee.status }}</span>
|
||||
<span :class="['status-tag', employee.statusTone]">{{ employee.status }}</span>
|
||||
</td>
|
||||
<td><span :class="['status-pill', employee.enabledTone]">{{ employee.enabledLabel }}</span></td>
|
||||
<td><span :class="['status-tag', employee.enabledTone]">{{ employee.enabledLabel }}</span></td>
|
||||
<td>{{ employee.updatedAt || '-' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<footer v-if="!loading && !errorMessage && visibleEmployees.length" class="list-foot digital-employee-pagination">
|
||||
<span class="page-summary">共 {{ visibleEmployees.length }} 条,目前第 {{ currentPage }} / {{ totalPages }} 页</span>
|
||||
<div class="pager" aria-label="员工技能分页">
|
||||
<button class="page-nav" type="button" :disabled="currentPage === 1" @click="currentPage--">
|
||||
<i class="mdi mdi-chevron-left"></i>
|
||||
</button>
|
||||
<button
|
||||
v-for="page in pageNumbers"
|
||||
:key="page"
|
||||
class="page-number"
|
||||
:class="{ active: currentPage === page }"
|
||||
type="button"
|
||||
@click="currentPage = page"
|
||||
>
|
||||
{{ page }}
|
||||
</button>
|
||||
<button class="page-nav" type="button" :disabled="currentPage === totalPages" @click="currentPage++">
|
||||
<i class="mdi mdi-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
</EnterpriseListPage>
|
||||
</template>
|
||||
|
||||
@@ -228,6 +206,9 @@ const pageNumbers = computed(() => {
|
||||
const start = Math.max(1, Math.min(currentPage.value - 3, total - 6))
|
||||
return Array.from({ length: 7 }, (_, index) => start + index)
|
||||
})
|
||||
const paginationSummary = computed(() =>
|
||||
`共 ${props.visibleEmployees.length} 条,目前第 ${currentPage.value} / ${totalPages.value} 页`
|
||||
)
|
||||
const emptyState = {
|
||||
eyebrow: '数字员工',
|
||||
title: '暂无匹配的数字员工',
|
||||
@@ -261,7 +242,7 @@ function selectFilter(type, value) {
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped src="../../assets/styles/views/audit-view.css"></style>
|
||||
<style scoped src="../../assets/styles/views/digital-employees-view.css"></style>
|
||||
|
||||
<style scoped>
|
||||
.digital-employee-list-panel {
|
||||
@@ -269,62 +250,8 @@ function selectFilter(type, value) {
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.digital-employee-list-panel :deep(.table-wrap) {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.digital-employee-list-panel .digital-employee-pagination {
|
||||
flex: 0 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.digital-employee-list-panel .digital-employee-pagination .page-summary {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.digital-employee-list-panel .pager {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 4px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.digital-employee-list-panel .pager button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: #334155;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.digital-employee-list-panel .pager button:hover:not(.active):not(:disabled) {
|
||||
background: #fff;
|
||||
color: var(--theme-primary-active);
|
||||
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.digital-employee-list-panel .pager button.active {
|
||||
background: var(--theme-primary-active);
|
||||
color: #fff;
|
||||
box-shadow: 0 8px 16px var(--theme-primary-shadow);
|
||||
}
|
||||
|
||||
.digital-employee-list-panel .pager button:disabled {
|
||||
color: #cbd5e1;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,104 +2,114 @@
|
||||
<section class="digital-employee-list-panel digital-work-records">
|
||||
<Transition name="skill-view" mode="out-in">
|
||||
<!-- 列表视图 -->
|
||||
<div v-if="!selectedRunDetail" key="list" class="digital-work-records-list-stage">
|
||||
<div class="list-toolbar">
|
||||
<div class="filter-set">
|
||||
<label class="search-filter">
|
||||
<i class="mdi mdi-magnify"></i>
|
||||
<input
|
||||
v-model="listKeyword"
|
||||
type="search"
|
||||
placeholder="搜索摘要、Run ID..."
|
||||
/>
|
||||
</label>
|
||||
|
||||
<AuditPickerFilter
|
||||
id="module"
|
||||
title="选择工作模块"
|
||||
close-label="关闭选择"
|
||||
:active-filter-popover="activeFilterPopover"
|
||||
:label="activeModule === '全部' ? '工作模块' : activeModule"
|
||||
:options="modulePickerOptions"
|
||||
:selected-value="activeModule"
|
||||
@toggle="toggleFilterPopover"
|
||||
@close="closeFilterPopover"
|
||||
@select="selectModule"
|
||||
<EnterpriseListPage
|
||||
v-if="!selectedRunDetail"
|
||||
key="list"
|
||||
variant="digital-employee-list-panel digital-work-records-list-stage"
|
||||
:panel="false"
|
||||
:loading="loading && !runs.length"
|
||||
:error="errorMessage"
|
||||
:empty="!visibleRuns.length"
|
||||
:empty-state="workRecordsEmptyState"
|
||||
:show-pagination="!loading && !errorMessage && visibleRuns.length > 0"
|
||||
:current-page="currentPage"
|
||||
:page-size="pageSize"
|
||||
:pages="pageNumbers"
|
||||
:show-page-size="false"
|
||||
:summary="paginationSummary"
|
||||
:total="filteredRuns.length"
|
||||
:total-pages="totalPages"
|
||||
loading-title="工作记录同步中"
|
||||
loading-message="正在读取数字员工近期执行记录"
|
||||
loading-icon="mdi mdi-clipboard-text-clock-outline"
|
||||
error-title="工作记录加载失败"
|
||||
@update:current-page="currentPage = $event"
|
||||
>
|
||||
<template #filters>
|
||||
<label class="list-search">
|
||||
<i class="mdi mdi-magnify"></i>
|
||||
<input
|
||||
v-model="listKeyword"
|
||||
type="search"
|
||||
placeholder="搜索摘要、Run ID..."
|
||||
/>
|
||||
</label>
|
||||
|
||||
<AuditPickerFilter
|
||||
id="status"
|
||||
title="选择执行状态"
|
||||
close-label="关闭选择"
|
||||
:active-filter-popover="activeFilterPopover"
|
||||
:label="activeStatus === '全部' ? '执行状态' : activeStatus"
|
||||
:options="statusPickerOptions"
|
||||
:selected-value="activeStatus"
|
||||
@toggle="toggleFilterPopover"
|
||||
@close="closeFilterPopover"
|
||||
@select="selectStatus"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-actions">
|
||||
<button
|
||||
v-if="listKeyword || activeFilterTokens.length"
|
||||
class="ghost-filter-btn"
|
||||
type="button"
|
||||
@click="resetFilters"
|
||||
>
|
||||
<i class="mdi mdi-filter-remove-outline"></i>
|
||||
<span>清空筛选</span>
|
||||
</button>
|
||||
<button
|
||||
class="create-btn digital-refresh-action"
|
||||
type="button"
|
||||
:disabled="loading"
|
||||
@click="loadWorkRecords(true)"
|
||||
>
|
||||
<i class="mdi mdi-refresh"></i>
|
||||
<span>{{ loading ? '刷新中...' : '刷新' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="hint">
|
||||
<i class="mdi mdi-information-outline"></i>
|
||||
查看数字员工近期执行记录、状态和结果摘要。
|
||||
</p>
|
||||
|
||||
<div v-if="activeFilterTokens.length" class="active-filter-strip">
|
||||
<span v-for="token in activeFilterTokens" :key="token" class="active-filter-chip">
|
||||
{{ token }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap digital-table-wrap" :class="{ 'is-empty': !loading && !errorMessage && !visibleRuns.length }">
|
||||
<div v-if="loading && !runs.length" class="table-state">
|
||||
<TableLoadingState
|
||||
variant="panel"
|
||||
title="工作记录同步中"
|
||||
message="正在读取数字员工近期执行记录"
|
||||
icon="mdi mdi-clipboard-text-clock-outline"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else-if="errorMessage" class="table-state error">
|
||||
<i class="mdi mdi-alert-circle-outline"></i>
|
||||
<p>{{ errorMessage }}</p>
|
||||
</div>
|
||||
|
||||
<TableEmptyState
|
||||
v-else-if="!visibleRuns.length"
|
||||
eyebrow="工作记录"
|
||||
title="暂无匹配的工作记录"
|
||||
description="当前没有符合搜索条件的数字员工工作记录。"
|
||||
icon="mdi mdi-clipboard-text-clock-outline"
|
||||
tone="theme"
|
||||
art-label="RECORDS"
|
||||
<AuditPickerFilter
|
||||
id="module"
|
||||
title="选择工作模块"
|
||||
close-label="关闭选择"
|
||||
:active-filter-popover="activeFilterPopover"
|
||||
:label="activeModule === '全部' ? '工作模块' : activeModule"
|
||||
:options="modulePickerOptions"
|
||||
:selected-value="activeModule"
|
||||
@toggle="toggleFilterPopover"
|
||||
@close="closeFilterPopover"
|
||||
@select="selectModule"
|
||||
/>
|
||||
|
||||
<table v-else class="digital-employees-table digital-work-records-table">
|
||||
<AuditPickerFilter
|
||||
id="status"
|
||||
title="选择执行状态"
|
||||
close-label="关闭选择"
|
||||
:active-filter-popover="activeFilterPopover"
|
||||
:label="activeStatus === '全部' ? '执行状态' : activeStatus"
|
||||
:options="statusPickerOptions"
|
||||
:selected-value="activeStatus"
|
||||
@toggle="toggleFilterPopover"
|
||||
@close="closeFilterPopover"
|
||||
@select="selectStatus"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<button
|
||||
v-if="listKeyword || activeFilterTokens.length"
|
||||
class="ghost-filter-btn"
|
||||
type="button"
|
||||
@click="resetFilters"
|
||||
>
|
||||
<i class="mdi mdi-filter-remove-outline"></i>
|
||||
<span>清空筛选</span>
|
||||
</button>
|
||||
<AuditPickerFilter
|
||||
id="refreshInterval"
|
||||
title="选择刷新时间"
|
||||
close-label="关闭刷新时间选择"
|
||||
:active-filter-popover="activeFilterPopover"
|
||||
:label="`刷新时间 ${refreshIntervalLabel}`"
|
||||
:options="refreshIntervalPickerOptions"
|
||||
:selected-value="refreshInterval"
|
||||
@toggle="toggleFilterPopover"
|
||||
@close="closeFilterPopover"
|
||||
@select="changeRefreshInterval"
|
||||
/>
|
||||
<button
|
||||
class="create-btn digital-refresh-action digital-refresh-now"
|
||||
type="button"
|
||||
:disabled="loading"
|
||||
aria-label="立即刷新工作记录"
|
||||
@click="loadWorkRecords(true)"
|
||||
>
|
||||
<i :class="loading ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-refresh'"></i>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template #meta>
|
||||
<div v-if="activeFilterTokens.length" class="active-filter-strip">
|
||||
<span v-for="token in activeFilterTokens" :key="token" class="active-filter-chip">
|
||||
{{ token }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #error>
|
||||
<i class="mdi mdi-alert-circle-outline"></i>
|
||||
<p>{{ errorMessage }}</p>
|
||||
</template>
|
||||
|
||||
<template #table>
|
||||
<table class="digital-work-records-table">
|
||||
<colgroup>
|
||||
<col class="col-time">
|
||||
<col class="col-module">
|
||||
@@ -132,12 +142,9 @@
|
||||
<td>{{ resolveWorkRecordModuleLabel(run) }}</td>
|
||||
<td>{{ resolveWorkRecordSourceLabel(run.source) }}</td>
|
||||
<td>
|
||||
<div class="work-record-status-stack">
|
||||
<span class="status-pill" :class="resolveWorkRecordStatusTone(run)">
|
||||
{{ resolveWorkRecordStatusLabel(run) }}
|
||||
</span>
|
||||
<span>{{ resolveWorkRecordStatusNote(run) }}</span>
|
||||
</div>
|
||||
<span class="status-tag" :class="resolveWorkRecordStatusTone(run)">
|
||||
{{ resolveWorkRecordStatusLabel(run) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="work-record-summary-cell">
|
||||
<strong>{{ resolveWorkRecordTitle(run) }}</strong>
|
||||
@@ -148,30 +155,8 @@
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<footer v-if="!loading && !errorMessage && visibleRuns.length" class="list-foot digital-employee-pagination">
|
||||
<span class="page-summary">共 {{ filteredRuns.length }} 条,目前第 {{ currentPage }} / {{ totalPages }} 页</span>
|
||||
<div class="pager" aria-label="工作记录分页">
|
||||
<button class="page-nav" type="button" :disabled="currentPage === 1" @click="currentPage--">
|
||||
<i class="mdi mdi-chevron-left"></i>
|
||||
</button>
|
||||
<button
|
||||
v-for="page in pageNumbers"
|
||||
:key="page"
|
||||
class="page-number"
|
||||
:class="{ active: currentPage === page }"
|
||||
type="button"
|
||||
@click="currentPage = page"
|
||||
>
|
||||
{{ page }}
|
||||
</button>
|
||||
<button class="page-nav" type="button" :disabled="currentPage === totalPages" @click="currentPage++">
|
||||
<i class="mdi mdi-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
</EnterpriseListPage>
|
||||
|
||||
<!-- 详情视图 (全屏样式,参考 AuditJsonRiskRuleDetail) -->
|
||||
<div v-else key="detail" class="json-risk-editor-shell panel work-records-detail-stage">
|
||||
@@ -298,11 +283,15 @@
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import AuditPickerFilter from './AuditPickerFilter.vue'
|
||||
import TableEmptyState from '../shared/TableEmptyState.vue'
|
||||
import EnterpriseListPage from '../shared/EnterpriseListPage.vue'
|
||||
import TableLoadingState from '../shared/TableLoadingState.vue'
|
||||
import { fetchAgentRunDetail, fetchAgentRuns } from '../../services/agentAssets.js'
|
||||
import { useToast } from '../../composables/useToast.js'
|
||||
import { AGENT_RUN_POLL_INTERVAL_MS } from '../../utils/agentRunMonitor.js'
|
||||
import {
|
||||
DEFAULT_REFRESH_INTERVAL_MS,
|
||||
REFRESH_INTERVAL_OPTIONS,
|
||||
formatRefreshInterval
|
||||
} from '../../utils/refreshIntervalOptions.js'
|
||||
import {
|
||||
formatWorkRecordDateTime,
|
||||
formatWorkRecordSummary,
|
||||
@@ -330,6 +319,7 @@ const detailLoading = ref(false)
|
||||
const detailError = ref('')
|
||||
const selectedRunId = ref('')
|
||||
const selectedRunDetail = ref(null)
|
||||
const refreshInterval = ref(DEFAULT_REFRESH_INTERVAL_MS)
|
||||
|
||||
watch(detailOpen, (newVal) => {
|
||||
emit('detail-open-change', newVal)
|
||||
@@ -392,6 +382,14 @@ const listKeyword = ref('')
|
||||
const activeModule = ref('全部')
|
||||
const activeStatus = ref('全部')
|
||||
const activeFilterPopover = ref('')
|
||||
const workRecordsEmptyState = {
|
||||
eyebrow: '工作记录',
|
||||
title: '暂无匹配的工作记录',
|
||||
description: '当前没有符合搜索条件的数字员工工作记录。',
|
||||
icon: 'mdi mdi-clipboard-text-clock-outline',
|
||||
tone: 'theme',
|
||||
artLabel: 'RECORDS'
|
||||
}
|
||||
|
||||
const modulePickerOptions = computed(() => {
|
||||
const set = new Set(runs.value.map((run) => resolveWorkRecordModuleLabel(run)))
|
||||
@@ -408,6 +406,11 @@ const statusPickerOptions = computed(() => {
|
||||
...Array.from(set).map(s => ({ label: s, value: s }))
|
||||
]
|
||||
})
|
||||
const refreshIntervalPickerOptions = REFRESH_INTERVAL_OPTIONS.map((option) => ({
|
||||
label: `每 ${option.label}`,
|
||||
value: option.value
|
||||
}))
|
||||
const refreshIntervalLabel = computed(() => formatRefreshInterval(refreshInterval.value))
|
||||
|
||||
const activeFilterTokens = computed(() => {
|
||||
const tokens = []
|
||||
@@ -449,6 +452,9 @@ const pageNumbers = computed(() => {
|
||||
const start = Math.max(1, Math.min(currentPage.value - 3, total - 6))
|
||||
return Array.from({ length: 7 }, (_, index) => start + index)
|
||||
})
|
||||
const paginationSummary = computed(() =>
|
||||
`共 ${filteredRuns.value.length} 条,目前第 ${currentPage.value} / ${totalPages.value} 页`
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [listKeyword.value, activeModule.value, activeStatus.value],
|
||||
@@ -486,6 +492,12 @@ function selectStatus(val) {
|
||||
closeFilterPopover()
|
||||
}
|
||||
|
||||
function changeRefreshInterval(value) {
|
||||
refreshInterval.value = Number(value) || DEFAULT_REFRESH_INTERVAL_MS
|
||||
closeFilterPopover()
|
||||
startPolling()
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
listKeyword.value = ''
|
||||
activeModule.value = '全部'
|
||||
@@ -564,7 +576,7 @@ function startPolling() {
|
||||
stopPolling()
|
||||
pollTimer = window.setInterval(() => {
|
||||
loadWorkRecords(false)
|
||||
}, AGENT_RUN_POLL_INTERVAL_MS)
|
||||
}, refreshInterval.value)
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
@@ -586,86 +598,104 @@ onBeforeUnmount(() => {
|
||||
|
||||
<style scoped src="../../assets/styles/views/audit-view.css"></style>
|
||||
<style scoped src="../../assets/styles/views/audit-view-part2.css"></style>
|
||||
<style scoped src="../../assets/styles/views/digital-employees-view.css"></style>
|
||||
<style scoped src="../../assets/styles/components/digital-employee-work-records.css"></style>
|
||||
|
||||
<style scoped>
|
||||
.digital-employee-list-panel {
|
||||
.digital-work-records {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.digital-employee-list-panel,
|
||||
.digital-work-records-list-stage {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.digital-employee-list-panel .digital-table-wrap {
|
||||
.digital-work-records-table {
|
||||
min-width: 1180px;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.digital-work-records-table .col-time { width: 14%; }
|
||||
.digital-work-records-table .col-module { width: 13%; }
|
||||
.digital-work-records-table .col-source { width: 10%; }
|
||||
.digital-work-records-table .col-status { width: 16%; }
|
||||
.digital-work-records-table .col-summary { width: 31%; }
|
||||
.digital-work-records-table .col-trace { width: 16%; }
|
||||
|
||||
.work-record-row {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.work-record-row:focus-visible {
|
||||
box-shadow: inset 0 0 0 2px rgba(58, 124, 165, 0.28);
|
||||
}
|
||||
|
||||
.work-record-summary-cell {
|
||||
text-align: left !important;
|
||||
}
|
||||
|
||||
.work-record-summary-cell strong,
|
||||
.work-record-summary-cell span,
|
||||
.work-record-summary-cell em {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.work-record-summary-cell strong {
|
||||
color: #0f172a;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.work-record-summary-cell span {
|
||||
margin-top: 4px;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.work-record-summary-cell em {
|
||||
margin-top: 6px;
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.work-record-trace-cell {
|
||||
color: #2563eb !important;
|
||||
}
|
||||
|
||||
.work-records-detail-stage,
|
||||
.work-record-detail-shell {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.digital-employee-list-panel .digital-employee-pagination {
|
||||
flex: 0 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
.digital-work-records :deep(.toolbar-actions .picker-filter),
|
||||
.digital-work-records :deep(.toolbar-actions .picker-trigger) {
|
||||
min-width: 148px;
|
||||
}
|
||||
|
||||
.digital-employee-list-panel .digital-employee-pagination .page-summary {
|
||||
justify-self: start;
|
||||
.digital-refresh-now {
|
||||
width: 40px;
|
||||
min-width: 40px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.digital-employee-list-panel .pager {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 4px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
background: #f8fafc;
|
||||
.digital-refresh-now .mdi {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.digital-employee-list-panel .pager button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 0;
|
||||
border-radius: 9px;
|
||||
.work-record-detail-body.inline-detail {
|
||||
padding: 0;
|
||||
overflow: visible;
|
||||
background: transparent;
|
||||
color: #334155;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.digital-employee-list-panel .pager button:hover:not(.active):not(:disabled) {
|
||||
background: #fff;
|
||||
color: var(--theme-primary-active);
|
||||
box-shadow: 0 1px 4px rgba(15, 23, 42, .08);
|
||||
}
|
||||
|
||||
.digital-employee-list-panel .pager button.active {
|
||||
background: var(--theme-primary-active);
|
||||
color: #fff;
|
||||
box-shadow: 0 8px 16px var(--theme-primary-shadow);
|
||||
}
|
||||
|
||||
.digital-employee-list-panel .pager button:disabled {
|
||||
color: #cbd5e1;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.digital-work-records-list-stage {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.work-records-detail-stage {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -150,6 +150,7 @@ const {
|
||||
startDocumentInboxPolling,
|
||||
stopDocumentInboxPolling
|
||||
} = useDocumentCenterInbox()
|
||||
let inboxInitialRefreshTimer = null
|
||||
|
||||
const sidebarMeta = {
|
||||
overview: { label: '分析看板' },
|
||||
@@ -159,7 +160,6 @@ const sidebarMeta = {
|
||||
policies: { label: '知识管理' },
|
||||
audit: { label: '规则中心' },
|
||||
digitalEmployees: { label: '数字员工' },
|
||||
logs: { label: '系统日志' },
|
||||
employees: { label: '员工管理' },
|
||||
settings: { label: '系统设置' }
|
||||
}
|
||||
@@ -173,8 +173,27 @@ const decoratedNavItems = computed(() =>
|
||||
}))
|
||||
)
|
||||
|
||||
function clearInboxInitialRefreshTimer() {
|
||||
if (inboxInitialRefreshTimer && typeof window !== 'undefined') {
|
||||
window.clearTimeout(inboxInitialRefreshTimer)
|
||||
inboxInitialRefreshTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleInboxInitialRefresh() {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
clearInboxInitialRefreshTimer()
|
||||
inboxInitialRefreshTimer = window.setTimeout(() => {
|
||||
inboxInitialRefreshTimer = null
|
||||
void refreshDocumentInbox()
|
||||
}, props.activeView === 'documents' ? 1200 : 6000)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void refreshDocumentInbox()
|
||||
scheduleInboxInitialRefresh()
|
||||
startDocumentInboxPolling()
|
||||
})
|
||||
|
||||
@@ -271,7 +290,18 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.activeView,
|
||||
(activeView, previousView) => {
|
||||
if (activeView === 'documents' && previousView !== 'documents') {
|
||||
clearInboxInitialRefreshTimer()
|
||||
void refreshDocumentInbox({ force: true })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearInboxInitialRefreshTimer()
|
||||
stopDocumentInboxPolling()
|
||||
closeCollapsedUserMenuNow()
|
||||
})
|
||||
|
||||
@@ -149,16 +149,6 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="isLogs">
|
||||
<div class="kpi-chips">
|
||||
<div v-for="kpi in logsKpis" :key="kpi.label" class="kpi-chip" :style="{ '--chip-color': kpi.color }">
|
||||
<span class="chip-value">{{ kpi.value }}<small>{{ kpi.unit }}</small></span>
|
||||
<span class="chip-label">{{ kpi.label }}</span>
|
||||
<span class="chip-delta" :class="kpi.trend">{{ kpi.meta }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="showDigitalEmployeeWorkRecordKpis">
|
||||
<div class="kpi-chips">
|
||||
<div
|
||||
@@ -229,11 +219,7 @@ const props = defineProps({
|
||||
type: Object,
|
||||
default: () => null
|
||||
},
|
||||
logsSummary: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
},
|
||||
requestSummary: {
|
||||
requestSummary: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
},
|
||||
@@ -253,10 +239,6 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
logDetailMode: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
detailAlerts: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
@@ -284,9 +266,8 @@ const isChat = computed(() => props.activeView === 'chat')
|
||||
const isOverview = computed(() => props.activeView === 'overview')
|
||||
const isWorkbench = computed(() => props.activeView === 'workbench')
|
||||
const isRequestDetail = computed(() => ['requests', 'documents', 'audit', 'digitalEmployees'].includes(props.activeView) && props.detailMode)
|
||||
const isDocuments = computed(() => props.activeView === 'documents' && !props.detailMode)
|
||||
const isRequests = computed(() => props.activeView === 'requests')
|
||||
const isLogs = computed(() => props.activeView === 'logs' && !props.logDetailMode)
|
||||
const isDocuments = computed(() => props.activeView === 'documents' && !props.detailMode)
|
||||
const isRequests = computed(() => props.activeView === 'requests')
|
||||
const isDigitalEmployees = computed(() => props.activeView === 'digitalEmployees')
|
||||
const isApproval = computed(() => props.activeView === 'approval')
|
||||
const isPolicies = computed(() => props.activeView === 'policies')
|
||||
@@ -328,21 +309,6 @@ const documentKpis = computed(() => {
|
||||
]
|
||||
})
|
||||
|
||||
const logsKpis = computed(() => {
|
||||
const summary = props.logsSummary ?? {}
|
||||
const total = Number(summary.total ?? 0)
|
||||
const errors = Number(summary.errors ?? 0)
|
||||
const warnings = Number(summary.warnings ?? 0)
|
||||
const info = Number(summary.info ?? 0)
|
||||
|
||||
return [
|
||||
{ label: '系统日志', value: total, unit: '条', meta: '当前', trend: 'up', color: 'var(--theme-primary)' },
|
||||
{ label: '错误数量', value: errors, unit: '条', meta: errors > 0 ? '需要关注' : '运行正常', trend: errors > 0 ? 'down' : 'up', color: '#ef4444' },
|
||||
{ label: '告警数量', value: warnings, unit: '条', meta: warnings > 0 ? '建议排查' : '暂无告警', trend: warnings > 0 ? 'down' : 'up', color: '#f59e0b' },
|
||||
{ label: '正常数量', value: info, unit: '条', meta: total ? `占比 ${Math.round((info / total) * 100)}%` : '等待数据', trend: 'up', color: 'var(--success)' }
|
||||
]
|
||||
})
|
||||
|
||||
const showDigitalEmployeeWorkRecordKpis = computed(() => {
|
||||
const summary = props.digitalEmployeeSummary ?? {}
|
||||
return isDigitalEmployees.value && summary.section === 'workRecords'
|
||||
|
||||
267
web/src/components/shared/FloatingLightBandWindow.vue
Normal file
267
web/src/components/shared/FloatingLightBandWindow.vue
Normal file
@@ -0,0 +1,267 @@
|
||||
<template>
|
||||
<div class="floating-light-band-window" :class="[variant, tone, motionClass]">
|
||||
<span class="floating-light-band-window__mark" aria-hidden="true">
|
||||
<i :class="icon"></i>
|
||||
</span>
|
||||
|
||||
<div v-if="hasCopy" class="floating-light-band-window__copy">
|
||||
<strong v-if="displayTitle">{{ displayTitle }}</strong>
|
||||
<span v-if="displayMessage">{{ displayMessage }}</span>
|
||||
</div>
|
||||
|
||||
<span class="floating-light-band-window__progress" aria-hidden="true"></span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
icon: { type: String, default: 'mdi mdi-loading' },
|
||||
message: { type: String, default: '' },
|
||||
motion: {
|
||||
type: String,
|
||||
default: 'loop',
|
||||
validator: (value) => ['loop', 'entry'].includes(value)
|
||||
},
|
||||
title: { type: String, default: '' },
|
||||
tone: {
|
||||
type: String,
|
||||
default: 'theme',
|
||||
validator: (value) => ['theme', 'sky', 'success'].includes(value)
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'panel',
|
||||
validator: (value) => ['entry', 'panel', 'detail', 'overlay', 'drawer', 'banner'].includes(value)
|
||||
}
|
||||
})
|
||||
|
||||
const displayTitle = computed(() => (props.variant === 'banner' && props.message ? '' : props.title))
|
||||
const displayMessage = computed(() => props.message || (props.variant === 'banner' ? props.title : ''))
|
||||
const hasCopy = computed(() => Boolean(displayTitle.value || displayMessage.value))
|
||||
const motionClass = computed(() => `motion-${props.motion}`)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.floating-light-band-window {
|
||||
--accent: var(--theme-primary);
|
||||
--accent-deep: var(--theme-primary-active);
|
||||
--accent-rgb: var(--theme-primary-rgb, 58, 124, 165);
|
||||
|
||||
width: min(380px, 100%);
|
||||
max-width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 42px minmax(0, 1fr);
|
||||
gap: 12px 14px;
|
||||
align-items: center;
|
||||
padding: 22px 24px 20px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.26);
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
box-shadow: 0 20px 46px rgba(15, 23, 42, 0.14);
|
||||
color: var(--muted);
|
||||
animation: floatingLightBandWindowIn 360ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
.floating-light-band-window.theme,
|
||||
.floating-light-band-window.sky {
|
||||
--accent: var(--theme-primary);
|
||||
--accent-deep: var(--theme-primary-active);
|
||||
--accent-rgb: var(--theme-primary-rgb, 58, 124, 165);
|
||||
}
|
||||
|
||||
.floating-light-band-window.success {
|
||||
--accent: var(--success);
|
||||
--accent-deep: var(--success-hover);
|
||||
--accent-rgb: var(--success-rgb, 47, 133, 90);
|
||||
}
|
||||
|
||||
.floating-light-band-window.entry {
|
||||
width: min(360px, calc(100% - 48px));
|
||||
}
|
||||
|
||||
.floating-light-band-window.detail,
|
||||
.floating-light-band-window.drawer {
|
||||
grid-template-columns: 38px minmax(0, 1fr);
|
||||
padding: 18px 20px 16px;
|
||||
box-shadow: 0 16px 34px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.floating-light-band-window.overlay {
|
||||
width: min(360px, 100%);
|
||||
}
|
||||
|
||||
.floating-light-band-window.banner {
|
||||
width: 100%;
|
||||
grid-template-columns: 28px minmax(0, 1fr);
|
||||
gap: 8px 10px;
|
||||
padding: 8px 10px 7px;
|
||||
border-color: rgba(var(--accent-rgb), 0.18);
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
box-shadow: none;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.floating-light-band-window__mark {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
border: 1px solid rgba(var(--accent-rgb), 0.2);
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--accent) 10%, white);
|
||||
color: var(--accent-deep);
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.floating-light-band-window.detail .floating-light-band-window__mark,
|
||||
.floating-light-band-window.drawer .floating-light-band-window__mark {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.floating-light-band-window.banner .floating-light-band-window__mark {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.floating-light-band-window__copy {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.floating-light-band-window__copy strong {
|
||||
color: var(--ink);
|
||||
font-size: 16px;
|
||||
line-height: 1.35;
|
||||
font-weight: 750;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.floating-light-band-window__copy span {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.floating-light-band-window.detail .floating-light-band-window__copy strong,
|
||||
.floating-light-band-window.drawer .floating-light-band-window__copy strong {
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.floating-light-band-window.banner .floating-light-band-window__copy {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.floating-light-band-window.banner .floating-light-band-window__copy strong,
|
||||
.floating-light-band-window.banner .floating-light-band-window__copy span {
|
||||
color: var(--accent-deep);
|
||||
font-size: 12px;
|
||||
font-weight: 760;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.floating-light-band-window__progress {
|
||||
position: relative;
|
||||
grid-column: 1 / -1;
|
||||
height: 3px;
|
||||
overflow: hidden;
|
||||
background:
|
||||
linear-gradient(
|
||||
90deg,
|
||||
rgba(var(--accent-rgb), 0.08),
|
||||
rgba(var(--accent-rgb), 0.16),
|
||||
rgba(var(--accent-rgb), 0.08)
|
||||
);
|
||||
}
|
||||
|
||||
.floating-light-band-window.banner .floating-light-band-window__progress {
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
.floating-light-band-window__progress::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: 46%;
|
||||
background:
|
||||
linear-gradient(
|
||||
90deg,
|
||||
rgba(var(--accent-rgb), 0),
|
||||
var(--accent),
|
||||
var(--accent-deep),
|
||||
color-mix(in srgb, var(--accent) 40%, white),
|
||||
rgba(var(--accent-rgb), 0)
|
||||
);
|
||||
box-shadow: 0 0 12px rgba(var(--accent-rgb), 0.36);
|
||||
transform: translateX(-105%);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.floating-light-band-window.motion-loop .floating-light-band-window__progress::before {
|
||||
animation: floatingLightBandSweep 980ms cubic-bezier(0.2, 0, 0, 1) infinite;
|
||||
}
|
||||
|
||||
.floating-light-band-window.motion-entry .floating-light-band-window__progress::before {
|
||||
width: 100%;
|
||||
background: var(--accent);
|
||||
transform-origin: left center;
|
||||
animation: floatingLightBandEntry 840ms cubic-bezier(0.2, 0, 0, 1) both;
|
||||
}
|
||||
|
||||
@keyframes floatingLightBandWindowIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale3d(0.92, 0.92, 1);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale3d(1, 1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes floatingLightBandSweep {
|
||||
from {
|
||||
transform: translateX(-105%);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateX(215%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes floatingLightBandEntry {
|
||||
from {
|
||||
transform: scaleX(0);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.floating-light-band-window {
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
.floating-light-band-window.motion-entry .floating-light-band-window__progress::before {
|
||||
width: 100%;
|
||||
background: var(--accent);
|
||||
animation: none !important;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.floating-light-band-window.motion-loop .floating-light-band-window__progress::before {
|
||||
animation: floatingLightBandSweep 1800ms linear infinite !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,25 +1,30 @@
|
||||
<template>
|
||||
<div
|
||||
class="table-loading"
|
||||
:class="[variant, tone]"
|
||||
role="status"
|
||||
:aria-label="ariaLabel"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span class="table-loading__spinner" aria-hidden="true">
|
||||
<i :class="icon"></i>
|
||||
</span>
|
||||
|
||||
<div v-if="hasCopy" class="table-loading__copy">
|
||||
<strong v-if="title">{{ title }}</strong>
|
||||
<p v-if="message">{{ message }}</p>
|
||||
<span v-if="floating" class="table-loading-anchor" aria-hidden="true"></span>
|
||||
<Teleport to="body" :disabled="!floating">
|
||||
<div
|
||||
class="table-loading"
|
||||
:class="[variant, tone, { 'screen-floating': floating, 'modal-floating': floating && blocking }]"
|
||||
role="status"
|
||||
:aria-label="ariaLabel"
|
||||
aria-live="polite"
|
||||
>
|
||||
<FloatingLightBandWindow
|
||||
:icon="icon"
|
||||
:message="message"
|
||||
:motion="motion"
|
||||
:title="title"
|
||||
:tone="tone"
|
||||
:variant="variant"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
import FloatingLightBandWindow from './FloatingLightBandWindow.vue'
|
||||
|
||||
const props = defineProps({
|
||||
variant: {
|
||||
type: String,
|
||||
@@ -34,50 +39,75 @@ const props = defineProps({
|
||||
title: { type: String, default: '' },
|
||||
message: { type: String, default: '' },
|
||||
icon: { type: String, default: 'mdi mdi-loading' },
|
||||
motion: {
|
||||
type: String,
|
||||
default: 'loop',
|
||||
validator: (value) => ['loop', 'entry'].includes(value)
|
||||
},
|
||||
floating: { type: Boolean, default: false },
|
||||
blocking: { type: Boolean, default: false },
|
||||
showSkeleton: { type: Boolean, default: true },
|
||||
skeletonRows: { type: Number, default: 5 }
|
||||
})
|
||||
|
||||
const hasCopy = computed(() => Boolean(props.title || props.message))
|
||||
|
||||
const ariaLabel = computed(() => [props.title, props.message].filter(Boolean).join(', ') || 'Loading')
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.table-loading {
|
||||
--accent: var(--theme-primary);
|
||||
--accent-deep: var(--theme-primary-active);
|
||||
width: 100%;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.table-loading.theme,
|
||||
.table-loading.sky {
|
||||
--accent: var(--theme-primary);
|
||||
--accent-deep: var(--theme-primary-active);
|
||||
.table-loading-anchor {
|
||||
display: block;
|
||||
width: 0;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table-loading.success {
|
||||
--accent: var(--success);
|
||||
--accent-deep: var(--success-hover);
|
||||
.table-loading.screen-floating {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 430;
|
||||
width: 100vw;
|
||||
height: 100dvh;
|
||||
min-height: 100dvh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: rgba(248, 250, 252, 0.08);
|
||||
backdrop-filter: blur(0.5px);
|
||||
-webkit-backdrop-filter: blur(0.5px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.table-loading.screen-floating.modal-floating {
|
||||
z-index: 560;
|
||||
background: rgba(15, 23, 42, 0.18);
|
||||
backdrop-filter: blur(2px);
|
||||
-webkit-backdrop-filter: blur(2px);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.table-loading.panel {
|
||||
min-height: 220px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 12px;
|
||||
padding: 28px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.table-loading.panel.screen-floating {
|
||||
min-height: 100dvh;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.table-loading.detail {
|
||||
min-height: 180px;
|
||||
display: flex;
|
||||
display: grid;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
justify-items: center;
|
||||
padding: 22px 24px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.table-loading.overlay,
|
||||
@@ -97,86 +127,9 @@ const ariaLabel = computed(() => [props.title, props.message].filter(Boolean).jo
|
||||
}
|
||||
|
||||
.table-loading.banner {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
display: block;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
color: #255b7d;
|
||||
}
|
||||
|
||||
.table-loading__spinner {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
border: 3px solid #e2e8f0;
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
color: var(--accent-deep);
|
||||
animation: table-spinner-rotate 0.8s linear infinite !important;
|
||||
}
|
||||
|
||||
.table-loading.detail .table-loading__spinner {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.table-loading.banner .table-loading__spinner {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.table-loading__spinner i {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.table-loading__copy {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.table-loading.panel .table-loading__copy,
|
||||
.table-loading.overlay .table-loading__copy,
|
||||
.table-loading.drawer .table-loading__copy {
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.table-loading.detail .table-loading__copy {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.table-loading.banner .table-loading__copy {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.table-loading__copy strong {
|
||||
color: #0f172a;
|
||||
font-size: 14px;
|
||||
font-weight: 850;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.table-loading__copy p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.table-loading.banner .table-loading__copy strong {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.table-loading.banner .table-loading__copy p {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@keyframes table-spinner-rotate {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -42,11 +42,12 @@ export function useAppShell() {
|
||||
filters,
|
||||
ranges,
|
||||
activeRange,
|
||||
filteredRequests,
|
||||
approveRequest,
|
||||
rejectRequest,
|
||||
reload: reloadRequests
|
||||
} = useRequests()
|
||||
filteredRequests,
|
||||
approveRequest,
|
||||
rejectRequest,
|
||||
ensureLoaded: ensureRequestsLoaded,
|
||||
reload: reloadRequests
|
||||
} = useRequests()
|
||||
const { currentUser } = useSystemState()
|
||||
const { toast } = useToast()
|
||||
|
||||
@@ -80,26 +81,22 @@ export function useAppShell() {
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
const detailMode = computed(() => route.name === 'app-document-detail')
|
||||
const logDetailMode = computed(() => route.name === 'app-log-detail')
|
||||
const detailAlerts = computed(() => (detailMode.value ? buildDetailAlerts(selectedRequest.value) : []))
|
||||
|
||||
const documentsListActive = computed(() => activeView.value === 'documents' && !detailMode.value)
|
||||
const workbenchActive = computed(() => activeView.value === 'workbench')
|
||||
const requestsNeeded = computed(() => ['documents', 'workbench'].includes(activeView.value))
|
||||
|
||||
watch(documentsListActive, (isActive, wasActive) => {
|
||||
if (isActive && !wasActive) {
|
||||
void reloadRequests()
|
||||
}
|
||||
})
|
||||
|
||||
watch(workbenchActive, (isActive, wasActive) => {
|
||||
if (isActive && !wasActive) {
|
||||
void reloadRequests()
|
||||
}
|
||||
})
|
||||
watch(
|
||||
requestsNeeded,
|
||||
(isNeeded) => {
|
||||
if (isNeeded) {
|
||||
void ensureRequestsLoaded()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const workbenchSummary = computed(() =>
|
||||
buildWorkbenchSummary(requests.value, currentUser.value)
|
||||
@@ -118,15 +115,8 @@ export function useAppShell() {
|
||||
}
|
||||
}
|
||||
|
||||
if (logDetailMode.value) {
|
||||
return {
|
||||
title: '日志详情',
|
||||
desc: '查看单条日志的解析结果、上下文信息与原始记录。'
|
||||
}
|
||||
}
|
||||
|
||||
return currentView.value
|
||||
})
|
||||
return currentView.value
|
||||
})
|
||||
|
||||
const requestSummary = computed(() =>
|
||||
filteredRequests.value.reduce(
|
||||
@@ -351,10 +341,9 @@ export function useAppShell() {
|
||||
closeRequestDetail,
|
||||
closeSmartEntry,
|
||||
currentView,
|
||||
customRange,
|
||||
detailMode,
|
||||
logDetailMode,
|
||||
filteredRequests,
|
||||
customRange,
|
||||
detailMode,
|
||||
filteredRequests,
|
||||
filters,
|
||||
handleApprove,
|
||||
handleDraftSaved,
|
||||
|
||||
@@ -18,7 +18,10 @@ const SOURCE_PRIORITY = {
|
||||
const documentRows = ref([])
|
||||
const viewedDocumentKeys = ref(readViewedDocumentKeys())
|
||||
const loading = ref(false)
|
||||
const INBOX_CACHE_TTL_MS = 30000
|
||||
let refreshTimer = null
|
||||
let refreshPromise = null
|
||||
let lastRefreshAt = 0
|
||||
let viewedKeysListenerAttached = false
|
||||
|
||||
function normalizeClaimText(...values) {
|
||||
@@ -125,10 +128,22 @@ export function useDocumentCenterInbox() {
|
||||
const unreadCount = computed(() => countNewDocuments(documentRows.value, viewedDocumentKeys.value))
|
||||
const hasUnread = computed(() => unreadCount.value > 0)
|
||||
|
||||
async function refreshDocumentInbox() {
|
||||
async function refreshDocumentInbox(options = {}) {
|
||||
const force = Boolean(options.force)
|
||||
const now = Date.now()
|
||||
|
||||
if (refreshPromise) {
|
||||
return refreshPromise
|
||||
}
|
||||
|
||||
if (!force && lastRefreshAt && now - lastRefreshAt < INBOX_CACHE_TTL_MS) {
|
||||
refreshViewedDocumentKeys()
|
||||
return documentRows.value
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
refreshPromise = (async () => {
|
||||
const [ownedResult, approvalResult, archiveResult] = await Promise.allSettled([
|
||||
readClaimList(fetchExpenseClaims),
|
||||
readClaimList(fetchApprovalExpenseClaims),
|
||||
@@ -140,13 +155,21 @@ export function useDocumentCenterInbox() {
|
||||
approvalClaims: approvalResult.status === 'fulfilled' ? approvalResult.value : [],
|
||||
archivedClaims: archiveResult.status === 'fulfilled' ? archiveResult.value : []
|
||||
})
|
||||
lastRefreshAt = Date.now()
|
||||
refreshViewedDocumentKeys()
|
||||
|
||||
return documentRows.value
|
||||
})()
|
||||
|
||||
try {
|
||||
return await refreshPromise
|
||||
} finally {
|
||||
loading.value = false
|
||||
refreshPromise = null
|
||||
}
|
||||
}
|
||||
|
||||
function startDocumentInboxPolling(intervalMs = 45000) {
|
||||
function startDocumentInboxPolling(intervalMs = 120000) {
|
||||
stopDocumentInboxPolling()
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
|
||||
78
web/src/composables/useMinimumVisibleState.js
Normal file
78
web/src/composables/useMinimumVisibleState.js
Normal file
@@ -0,0 +1,78 @@
|
||||
import { computed, getCurrentScope, onScopeDispose, ref, unref, watch } from 'vue'
|
||||
|
||||
export const DEFAULT_MIN_VISIBLE_MS = 650
|
||||
|
||||
function resolveBooleanSource(source) {
|
||||
return Boolean(typeof source === 'function' ? source() : unref(source))
|
||||
}
|
||||
|
||||
function resolveMinVisibleMs(value) {
|
||||
const nextValue = Number(value)
|
||||
return Number.isFinite(nextValue) && nextValue >= 0 ? nextValue : DEFAULT_MIN_VISIBLE_MS
|
||||
}
|
||||
|
||||
export function useMinimumVisibleState(source, options = {}) {
|
||||
const minVisibleMs = computed(() => resolveMinVisibleMs(unref(options.minVisibleMs)))
|
||||
const visible = ref(resolveBooleanSource(source))
|
||||
let visibleStartedAt = visible.value ? Date.now() : 0
|
||||
let hideTimer = null
|
||||
|
||||
function clearHideTimer() {
|
||||
if (!hideTimer) {
|
||||
return
|
||||
}
|
||||
|
||||
globalThis.clearTimeout(hideTimer)
|
||||
hideTimer = null
|
||||
}
|
||||
|
||||
function show() {
|
||||
clearHideTimer()
|
||||
|
||||
if (!visible.value) {
|
||||
visibleStartedAt = Date.now()
|
||||
visible.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function hide() {
|
||||
clearHideTimer()
|
||||
|
||||
if (!visible.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const remainingMs = Math.max(0, minVisibleMs.value - (Date.now() - visibleStartedAt))
|
||||
if (remainingMs <= 0) {
|
||||
visible.value = false
|
||||
return
|
||||
}
|
||||
|
||||
hideTimer = globalThis.setTimeout(() => {
|
||||
hideTimer = null
|
||||
visible.value = false
|
||||
}, remainingMs)
|
||||
}
|
||||
|
||||
const stopWatch = watch(
|
||||
() => resolveBooleanSource(source),
|
||||
(active) => {
|
||||
if (active) {
|
||||
show()
|
||||
return
|
||||
}
|
||||
|
||||
hide()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
if (getCurrentScope()) {
|
||||
onScopeDispose(() => {
|
||||
clearHideTimer()
|
||||
stopWatch()
|
||||
})
|
||||
}
|
||||
|
||||
return visible
|
||||
}
|
||||
@@ -12,7 +12,6 @@ export const appViews = [
|
||||
'digitalEmployees',
|
||||
'employees',
|
||||
'policies',
|
||||
'logs',
|
||||
'settings'
|
||||
]
|
||||
|
||||
@@ -81,14 +80,6 @@ export const navItems = [
|
||||
title: '制度与知识库',
|
||||
desc: '统一管理制度文档、检索入口与知识资产。'
|
||||
},
|
||||
{
|
||||
id: 'logs',
|
||||
label: '系统日志',
|
||||
navHint: '查看系统运行日志',
|
||||
icon: icons.logs,
|
||||
title: '系统日志',
|
||||
desc: '集中查看系统运行日志、结构化事件和请求追踪信息。'
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
label: '系统设置',
|
||||
@@ -107,18 +98,21 @@ const viewRouteNames = {
|
||||
policies: 'app-policies',
|
||||
audit: 'app-audit',
|
||||
digitalEmployees: 'app-digitalEmployees',
|
||||
logs: 'app-logs',
|
||||
employees: 'app-employees',
|
||||
settings: 'app-settings'
|
||||
}
|
||||
|
||||
const legacyViewRouteNames = {
|
||||
logs: 'app-settings'
|
||||
}
|
||||
|
||||
const routeNameViews = Object.fromEntries(
|
||||
Object.entries(viewRouteNames).map(([view, routeName]) => [routeName, view])
|
||||
)
|
||||
|
||||
routeNameViews['app-request-detail'] = 'documents'
|
||||
routeNameViews['app-document-detail'] = 'documents'
|
||||
routeNameViews['app-log-detail'] = 'logs'
|
||||
routeNameViews['app-log-detail'] = 'settings'
|
||||
|
||||
export function resolveAppViewFromRoute(route) {
|
||||
const routeName = String(route?.name || '').trim()
|
||||
@@ -131,7 +125,7 @@ export function resolveAppViewFromRoute(route) {
|
||||
}
|
||||
|
||||
export function resolveTargetRouteName(view) {
|
||||
return viewRouteNames[view] || viewRouteNames.overview
|
||||
return viewRouteNames[view] || legacyViewRouteNames[view] || viewRouteNames.overview
|
||||
}
|
||||
|
||||
export function useNavigation() {
|
||||
|
||||
@@ -1024,6 +1024,7 @@ function resolveRangeMatch(activeRange, item) {
|
||||
export function useRequests() {
|
||||
const requests = ref([])
|
||||
const loading = ref(false)
|
||||
const loaded = ref(false)
|
||||
const error = ref('')
|
||||
const search = ref('')
|
||||
const filters = reactive({ entity: '全部主体', category: '全部类型', risk: '全部状态' })
|
||||
@@ -1060,6 +1061,7 @@ export function useRequests() {
|
||||
try {
|
||||
const payload = await fetchExpenseClaims()
|
||||
requests.value = Array.isArray(payload) ? payload.map((item) => mapExpenseClaimToRequest(item)) : []
|
||||
loaded.value = true
|
||||
} catch (nextError) {
|
||||
requests.value = []
|
||||
error.value = nextError instanceof Error ? nextError.message : '个人报销列表加载失败。'
|
||||
@@ -1076,11 +1078,14 @@ export function useRequests() {
|
||||
return `${request.id} 未执行本地状态变更,列表当前只展示后端真实数据。`
|
||||
}
|
||||
|
||||
void reload()
|
||||
function ensureLoaded() {
|
||||
return loaded.value ? Promise.resolve() : reload()
|
||||
}
|
||||
|
||||
return {
|
||||
requests,
|
||||
loading,
|
||||
loaded,
|
||||
error,
|
||||
search,
|
||||
filters,
|
||||
@@ -1089,6 +1094,7 @@ export function useRequests() {
|
||||
filteredRequests,
|
||||
approveRequest,
|
||||
rejectRequest,
|
||||
ensureLoaded,
|
||||
reload
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { useSystemState } from './useSystemState.js'
|
||||
import { useThemeSkin } from './useThemeSkin.js'
|
||||
@@ -26,7 +27,20 @@ import {
|
||||
readStoredSettings
|
||||
} from '../utils/settingsModelHelper.js'
|
||||
|
||||
const sectionIds = new Set(SECTION_DEFINITIONS.map((section) => section.id))
|
||||
|
||||
function resolveSectionId(value) {
|
||||
const sectionId = String(value || '').trim()
|
||||
return sectionIds.has(sectionId) ? sectionId : 'profile'
|
||||
}
|
||||
|
||||
function resolveInitialSectionId(route) {
|
||||
return route.name === 'app-log-detail' ? 'systemLogs' : resolveSectionId(route.query.section)
|
||||
}
|
||||
|
||||
export function useSettings() {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
const { companyProfile, currentUser, updateCompanyProfilePreview } = useSystemState()
|
||||
const {
|
||||
@@ -38,7 +52,7 @@ export function useSettings() {
|
||||
|
||||
const buildResolvedDefaults = () => buildDefaultState(companyProfile.value, currentUser.value)
|
||||
const pageState = ref(mergeState(buildResolvedDefaults(), readStoredSettings()))
|
||||
const activeSection = ref('profile')
|
||||
const activeSection = ref(resolveInitialSectionId(route))
|
||||
const sessionRetentionPickerOpen = ref(false)
|
||||
const sessionRetentionPickerRef = ref(null)
|
||||
const logoInputRef = ref(null)
|
||||
@@ -55,6 +69,7 @@ export function useSettings() {
|
||||
|
||||
const sectionStatus = computed(() => computeSectionStatus(pageState.value))
|
||||
const completedSectionCount = computed(() => Object.values(sectionStatus.value).filter(Boolean).length)
|
||||
const systemLogDetailMode = computed(() => route.name === 'app-log-detail')
|
||||
const activeSectionConfig = computed(
|
||||
() => sections.find((section) => section.id === activeSection.value) || sections[0]
|
||||
)
|
||||
@@ -150,9 +165,37 @@ export function useSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
function activateSection(sectionId) {
|
||||
function syncActiveSectionRoute(sectionId) {
|
||||
if (route.name !== 'app-settings') {
|
||||
return
|
||||
}
|
||||
|
||||
const nextQuery = { ...route.query }
|
||||
if (sectionId === 'profile') {
|
||||
delete nextQuery.section
|
||||
} else {
|
||||
nextQuery.section = sectionId
|
||||
}
|
||||
|
||||
if (String(route.query.section || '') === String(nextQuery.section || '')) {
|
||||
return
|
||||
}
|
||||
|
||||
void router.replace({
|
||||
name: 'app-settings',
|
||||
query: nextQuery,
|
||||
hash: route.hash
|
||||
})
|
||||
}
|
||||
|
||||
function activateSection(sectionId, options = {}) {
|
||||
const nextSectionId = resolveSectionId(sectionId)
|
||||
sessionRetentionPickerOpen.value = false
|
||||
activeSection.value = sectionId
|
||||
activeSection.value = nextSectionId
|
||||
|
||||
if (!options.skipRouteSync) {
|
||||
syncActiveSectionRoute(nextSectionId)
|
||||
}
|
||||
}
|
||||
|
||||
function toggleBoolean(formKey, field) {
|
||||
@@ -447,6 +490,10 @@ export function useSettings() {
|
||||
return
|
||||
}
|
||||
|
||||
if (activeSection.value === 'systemLogs') {
|
||||
return
|
||||
}
|
||||
|
||||
if (activeSection.value === 'rendering') {
|
||||
await saveRenderingSection()
|
||||
return
|
||||
@@ -462,6 +509,16 @@ export function useSettings() {
|
||||
loadSettingsSnapshot()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => [route.name, route.query.section],
|
||||
() => {
|
||||
const nextSectionId = resolveInitialSectionId(route)
|
||||
if (activeSection.value !== nextSectionId) {
|
||||
activateSection(nextSectionId, { skipRouteSync: true })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.removeEventListener('pointerdown', handleDocumentPointerDown)
|
||||
@@ -512,6 +569,7 @@ export function useSettings() {
|
||||
saveActiveSection,
|
||||
sectionStatus,
|
||||
sections,
|
||||
systemLogDetailMode,
|
||||
selectThemeSkin,
|
||||
selectSessionRetentionDays,
|
||||
themeSkinOptions,
|
||||
|
||||
@@ -259,11 +259,11 @@ export function useSetupView(props, emit) {
|
||||
})
|
||||
const testButtonIcon = computed(() => {
|
||||
if ((activeSection.value === 'runtime' && props.runtimeTesting) || (activeSection.value === 'database' && props.databaseTesting)) {
|
||||
return 'pi pi-spin pi-spinner'
|
||||
}
|
||||
|
||||
return activeSection.value === 'runtime' ? 'pi pi-server' : 'pi pi-database'
|
||||
})
|
||||
return 'mdi mdi-loading mdi-spin'
|
||||
}
|
||||
|
||||
return activeSection.value === 'runtime' ? 'mdi mdi-server' : 'mdi mdi-database'
|
||||
})
|
||||
|
||||
const canRuntimeTest = computed(() => Boolean(runtimeInputsReady.value && !props.submitting && !props.runtimeTesting && !props.databaseTesting))
|
||||
const canDatabaseTest = computed(() => Boolean(databaseInputsReady.value && !props.submitting && !props.runtimeTesting && !props.databaseTesting))
|
||||
|
||||
@@ -3,7 +3,6 @@ import { MotionPlugin } from '@vueuse/motion'
|
||||
import ElementPlus from 'element-plus'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
import 'element-plus/dist/index.css'
|
||||
import 'primeicons/primeicons.css'
|
||||
import App from './App.vue'
|
||||
import router from './router/index.js'
|
||||
import { installThemeSkin } from './composables/useThemeSkin.js'
|
||||
|
||||
@@ -4,10 +4,11 @@ import { checkBackendHealth } from '../composables/useBackendHealth.js'
|
||||
import { appViews } from '../composables/useNavigation.js'
|
||||
import { useSystemState } from '../composables/useSystemState.js'
|
||||
import { canAccessAppView } from '../utils/accessControl.js'
|
||||
import AppShellRouteView from '../views/AppShellRouteView.vue'
|
||||
import BackendUnavailableRouteView from '../views/BackendUnavailableRouteView.vue'
|
||||
import LoginRouteView from '../views/LoginRouteView.vue'
|
||||
import SetupRouteView from '../views/SetupRouteView.vue'
|
||||
|
||||
const AppShellRouteView = () => import('../views/AppShellRouteView.vue')
|
||||
const BackendUnavailableRouteView = () => import('../views/BackendUnavailableRouteView.vue')
|
||||
const LoginRouteView = () => import('../views/LoginRouteView.vue')
|
||||
const SetupRouteView = () => import('../views/SetupRouteView.vue')
|
||||
|
||||
const appChildRoutes = appViews
|
||||
.filter((view) => view !== 'documents')
|
||||
@@ -92,11 +93,24 @@ const router = createRouter({
|
||||
},
|
||||
{
|
||||
path: '/app/logs/:logKind/:logId',
|
||||
redirect: (to) => ({
|
||||
name: 'app-log-detail',
|
||||
params: { logKind: to.params.logKind, logId: to.params.logId },
|
||||
query: to.query,
|
||||
hash: to.hash
|
||||
})
|
||||
},
|
||||
{
|
||||
path: '/app/logs',
|
||||
redirect: { name: 'app-settings', query: { section: 'systemLogs' } }
|
||||
},
|
||||
{
|
||||
path: '/app/settings/logs/:logKind/:logId',
|
||||
name: 'app-log-detail',
|
||||
component: AppShellRouteView,
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
appView: 'logs'
|
||||
appView: 'settings'
|
||||
}
|
||||
},
|
||||
...appChildRoutes.map((route) => ({
|
||||
|
||||
@@ -3,24 +3,22 @@ export const DEFAULT_APP_VIEW_ORDER = [
|
||||
'documents',
|
||||
'budget',
|
||||
'audit',
|
||||
'overview',
|
||||
'policies',
|
||||
'digitalEmployees',
|
||||
'logs',
|
||||
'employees',
|
||||
'settings'
|
||||
]
|
||||
'overview',
|
||||
'policies',
|
||||
'digitalEmployees',
|
||||
'employees',
|
||||
'settings'
|
||||
]
|
||||
|
||||
const ALWAYS_VISIBLE_VIEWS = new Set(['workbench', 'documents', 'policies'])
|
||||
const VIEW_ROLE_RULES = {
|
||||
overview: ['finance', 'executive'],
|
||||
budget: ['budget_monitor', 'executive'],
|
||||
audit: ['finance'],
|
||||
digitalEmployees: ['finance'],
|
||||
logs: ['manager'],
|
||||
employees: ['manager'],
|
||||
settings: ['manager']
|
||||
}
|
||||
budget: ['budget_monitor', 'executive'],
|
||||
audit: ['finance'],
|
||||
digitalEmployees: ['finance'],
|
||||
employees: ['manager'],
|
||||
settings: ['manager']
|
||||
}
|
||||
const CLAIM_MANAGER_ROLE_CODES = new Set(['executive'])
|
||||
const CLAIM_RETURN_ROLE_CODES = new Set(['finance', 'executive', 'manager', 'approver', 'budget_monitor'])
|
||||
const CLAIM_LEADER_APPROVAL_ROLE_CODES = new Set(['manager', 'approver'])
|
||||
|
||||
16
web/src/utils/refreshIntervalOptions.js
Normal file
16
web/src/utils/refreshIntervalOptions.js
Normal file
@@ -0,0 +1,16 @@
|
||||
export const REFRESH_INTERVAL_OPTIONS = [
|
||||
{ label: '1s', value: 1000 },
|
||||
{ label: '3s', value: 3000 },
|
||||
{ label: '5s', value: 5000 },
|
||||
{ label: '10s', value: 10000 },
|
||||
{ label: '30s', value: 30000 },
|
||||
{ label: '60s', value: 60000 },
|
||||
{ label: '180s', value: 180000 }
|
||||
]
|
||||
|
||||
export const DEFAULT_REFRESH_INTERVAL_MS = 60000
|
||||
|
||||
export function formatRefreshInterval(value) {
|
||||
const option = REFRESH_INTERVAL_OPTIONS.find((item) => item.value === Number(value))
|
||||
return option?.label || '60s'
|
||||
}
|
||||
@@ -70,11 +70,19 @@ export const SECTION_DEFINITIONS = [
|
||||
{
|
||||
id: 'logs',
|
||||
label: '日志策略',
|
||||
title: '日志与审计策略',
|
||||
desc: '日志级别、留存与脱敏',
|
||||
longDesc: '定义系统日志级别、留存周期和审计策略,保证问题排查和合规审计可追溯。',
|
||||
title: '日志策略',
|
||||
desc: '日志级别、留存与路径',
|
||||
longDesc: '定义系统日志级别、留存周期和写入路径,保证问题排查过程可追溯。',
|
||||
actionLabel: '保存日志策略'
|
||||
},
|
||||
{
|
||||
id: 'systemLogs',
|
||||
label: '系统日志',
|
||||
title: '系统日志',
|
||||
desc: '运行事件、请求追踪与异常排查',
|
||||
longDesc: '查看系统运行日志、结构化事件和请求追踪信息,作为系统设置下的排障与审计子项。',
|
||||
actionLabel: ''
|
||||
},
|
||||
{
|
||||
id: 'mail',
|
||||
label: '邮箱设置',
|
||||
@@ -465,6 +473,7 @@ export function computeSectionStatus(state) {
|
||||
Number(state.logForm.retentionDays) > 0 &&
|
||||
normalizeValue(state.logForm.logPath)
|
||||
),
|
||||
systemLogs: true,
|
||||
mail: Boolean(
|
||||
normalizeValue(state.mailForm.smtpHost) &&
|
||||
Number(state.mailForm.port) > 0 &&
|
||||
|
||||
@@ -10,16 +10,13 @@
|
||||
<div class="mobile-overlay" aria-hidden="true" @click="mobileSidebarOpen = false"></div>
|
||||
<Transition name="login-entry-veil">
|
||||
<div v-if="loginEntryAnimating" class="login-entry-veil" aria-live="polite" aria-label="登录成功,正在进入工作台">
|
||||
<div class="login-entry-card">
|
||||
<span class="login-entry-mark" aria-hidden="true">
|
||||
<i class="mdi mdi-shield-check-outline"></i>
|
||||
</span>
|
||||
<div class="login-entry-copy">
|
||||
<strong>登录成功</strong>
|
||||
<span>正在进入工作台</span>
|
||||
</div>
|
||||
<span class="login-entry-progress" aria-hidden="true"></span>
|
||||
</div>
|
||||
<FloatingLightBandWindow
|
||||
icon="mdi mdi-shield-check-outline"
|
||||
message="正在进入工作台"
|
||||
motion="entry"
|
||||
title="登录成功"
|
||||
variant="entry"
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
<div class="app-sidebar">
|
||||
@@ -49,7 +46,6 @@
|
||||
'audit-detail-main': activeView === 'audit' && auditDetailOpen,
|
||||
'digital-employees-detail-main': activeView === 'digitalEmployees' && digitalEmployeeDetailOpen,
|
||||
'digital-employees-main': activeView === 'digitalEmployees',
|
||||
'logs-main': activeView === 'logs',
|
||||
'employees-main': activeView === 'employees',
|
||||
'settings-main': activeView === 'settings'
|
||||
}"
|
||||
@@ -63,13 +59,11 @@
|
||||
:active-range="activeRange"
|
||||
:employee-summary="employeeSummary"
|
||||
:knowledge-summary="knowledgeSummary"
|
||||
:logs-summary="logsSummary"
|
||||
:request-summary="requestSummary"
|
||||
:document-summary="documentSummary"
|
||||
:digital-employee-summary="digitalEmployeeSummary"
|
||||
:company-name="ENTERPRISE_DISPLAY_NAME"
|
||||
:detail-mode="resolvedDetailMode"
|
||||
:log-detail-mode="logDetailMode"
|
||||
:detail-alerts="resolvedDetailAlerts"
|
||||
:detail-kpis="resolvedDetailKpis"
|
||||
:custom-range="customRange"
|
||||
@@ -81,7 +75,7 @@
|
||||
/>
|
||||
|
||||
<FilterBar
|
||||
v-if="activeView !== 'overview' && activeView !== 'workbench' && activeView !== 'documents' && activeView !== 'budget' && activeView !== 'policies' && activeView !== 'audit' && activeView !== 'digitalEmployees' && activeView !== 'logs' && activeView !== 'employees' && activeView !== 'settings'"
|
||||
v-if="activeView !== 'overview' && activeView !== 'workbench' && activeView !== 'documents' && activeView !== 'budget' && activeView !== 'policies' && activeView !== 'audit' && activeView !== 'digitalEmployees' && activeView !== 'employees' && activeView !== 'settings'"
|
||||
:compact="activeView === 'overview'"
|
||||
:filters="filters"
|
||||
:ranges="ranges"
|
||||
@@ -98,7 +92,6 @@
|
||||
'policies-workarea': activeView === 'policies',
|
||||
'audit-workarea': activeView === 'audit',
|
||||
'digital-employees-workarea': activeView === 'digitalEmployees',
|
||||
'logs-workarea': activeView === 'logs',
|
||||
'employees-workarea': activeView === 'employees',
|
||||
'settings-workarea': activeView === 'settings'
|
||||
}"
|
||||
@@ -157,8 +150,6 @@
|
||||
@detail-open-change="digitalEmployeeDetailOpen = $event"
|
||||
@detail-topbar-change="detailTopBarPayload = $event"
|
||||
/>
|
||||
<LogDetailView v-else-if="activeView === 'logs' && logDetailMode" />
|
||||
<LogsView v-else-if="activeView === 'logs'" @summary-change="logsSummary = $event" />
|
||||
<EmployeeManagementView v-else-if="activeView === 'employees'" @overview-change="employeeSummary = $event" />
|
||||
<SettingsView v-else />
|
||||
</section>
|
||||
@@ -181,11 +172,13 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, defineAsyncComponent, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { computed, defineAsyncComponent, h, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
import SidebarRail from '../components/layout/SidebarRail.vue'
|
||||
import TopBar from '../components/layout/TopBar.vue'
|
||||
import FilterBar from '../components/layout/FilterBar.vue'
|
||||
import FloatingLightBandWindow from '../components/shared/FloatingLightBandWindow.vue'
|
||||
import TableLoadingState from '../components/shared/TableLoadingState.vue'
|
||||
|
||||
import { useAppShell } from '../composables/useAppShell.js'
|
||||
import { useSystemState } from '../composables/useSystemState.js'
|
||||
@@ -197,18 +190,30 @@ const PersonalWorkbenchView = defineAsyncComponent(() => import('./PersonalWorkb
|
||||
const TravelReimbursementCreateView = defineAsyncComponent(() => import('./TravelReimbursementCreateView.vue'))
|
||||
const TravelRequestDetailView = defineAsyncComponent(() => import('./TravelRequestDetailView.vue'))
|
||||
const DocumentsCenterView = defineAsyncComponent(() => import('./DocumentsCenterView.vue'))
|
||||
const BudgetCenterView = defineAsyncComponent(() => import('./BudgetCenterView.vue'))
|
||||
const BudgetCenterRouteLoading = {
|
||||
name: 'BudgetCenterRouteLoading',
|
||||
render: () =>
|
||||
h(TableLoadingState, {
|
||||
title: '预算数据同步中',
|
||||
message: '正在加载预算中心模块与预算数据',
|
||||
icon: 'mdi mdi-chart-donut',
|
||||
floating: true,
|
||||
blocking: true
|
||||
})
|
||||
}
|
||||
const BudgetCenterView = defineAsyncComponent({
|
||||
loader: () => import('./BudgetCenterView.vue'),
|
||||
loadingComponent: BudgetCenterRouteLoading,
|
||||
delay: 0
|
||||
})
|
||||
const PoliciesView = defineAsyncComponent(() => import('./PoliciesView.vue'))
|
||||
const AuditView = defineAsyncComponent(() => import('./AuditView.vue'))
|
||||
const DigitalEmployeesView = defineAsyncComponent(() => import('./DigitalEmployeesView.vue'))
|
||||
const LogsView = defineAsyncComponent(() => import('./LogsView.vue'))
|
||||
const LogDetailView = defineAsyncComponent(() => import('./LogDetailView.vue'))
|
||||
const EmployeeManagementView = defineAsyncComponent(() => import('./EmployeeManagementView.vue'))
|
||||
const SettingsView = defineAsyncComponent(() => import('./SettingsView.vue'))
|
||||
|
||||
const employeeSummary = ref(null)
|
||||
const knowledgeSummary = ref(null)
|
||||
const logsSummary = ref(null)
|
||||
const documentSummary = ref(null)
|
||||
const digitalEmployeeSummary = ref(null)
|
||||
const detailTopBarPayload = ref(null)
|
||||
@@ -254,7 +259,6 @@ const {
|
||||
customRange,
|
||||
detailAlerts,
|
||||
detailMode,
|
||||
logDetailMode,
|
||||
filteredRequests,
|
||||
filters,
|
||||
handleApprove,
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
<template>
|
||||
<section class="budget-center-page">
|
||||
<TableLoadingState
|
||||
v-if="budgetLoading"
|
||||
title="预算数据同步中"
|
||||
message="正在加载预算额度、使用情况与预警明细"
|
||||
icon="mdi mdi-chart-donut"
|
||||
floating
|
||||
blocking
|
||||
/>
|
||||
|
||||
<section class="budget-summary-grid" aria-label="预算概览">
|
||||
<article
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
:class="{ active: activeSection === 'skills' }"
|
||||
@click="activeSection = 'skills'"
|
||||
>
|
||||
数字员工
|
||||
员工能力
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -140,6 +140,7 @@
|
||||
title="单据数据同步中"
|
||||
message="正在汇总当前报销、审批待办与归档单据"
|
||||
icon="mdi mdi-file-document-multiple-outline"
|
||||
floating
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -246,6 +247,7 @@ import { computed, onMounted, ref, watch } from 'vue'
|
||||
import EnterpriseSelect from '../components/shared/EnterpriseSelect.vue'
|
||||
import TableEmptyState from '../components/shared/TableEmptyState.vue'
|
||||
import TableLoadingState from '../components/shared/TableLoadingState.vue'
|
||||
import { useMinimumVisibleState } from '../composables/useMinimumVisibleState.js'
|
||||
import { mapExpenseClaimToRequest } from '../composables/useRequests.js'
|
||||
import { fetchApprovalExpenseClaims, fetchArchivedExpenseClaims } from '../services/reimbursements.js'
|
||||
import { countNewDocuments, isNewDocument, markDocumentViewed, readDocumentScope, readViewedDocumentKeys, writeDocumentScope } from '../utils/documentCenterNewState.js'
|
||||
@@ -262,6 +264,7 @@ const DOCUMENT_SCOPE_REIMBURSEMENT = '报销单'
|
||||
const DOCUMENT_SCOPE_REVIEW = '审核单'
|
||||
const DOCUMENT_SCOPE_ARCHIVE = '归档'
|
||||
const scopeTabs = [DOCUMENT_SCOPE_ALL, DOCUMENT_SCOPE_APPLICATION, DOCUMENT_SCOPE_REIMBURSEMENT, DOCUMENT_SCOPE_REVIEW, DOCUMENT_SCOPE_ARCHIVE]
|
||||
const DOCUMENT_LOADING_MIN_VISIBLE_MS = 720
|
||||
const statusTabs = ['全部', '草稿', '待提交', '审批中', '待补充', '待付款', '已完成']
|
||||
const FILTER_CONFIG_BY_SCOPE = {
|
||||
[DOCUMENT_SCOPE_ALL]: {
|
||||
@@ -465,7 +468,11 @@ const visibleRows = computed(() => {
|
||||
return filteredRows.value.slice(start, start + pageSize.value)
|
||||
})
|
||||
|
||||
const showLoading = computed(() => (props.loading || supportingLoading.value) && !visibleRows.value.length)
|
||||
const documentLoadingSource = computed(() => (props.loading || supportingLoading.value) && !visibleRows.value.length)
|
||||
const visibleDocumentLoading = useMinimumVisibleState(documentLoadingSource, {
|
||||
minVisibleMs: DOCUMENT_LOADING_MIN_VISIBLE_MS
|
||||
})
|
||||
const showLoading = computed(() => visibleDocumentLoading.value)
|
||||
const showError = computed(() => Boolean(props.error) && !visibleRows.value.length)
|
||||
const errorMessage = computed(() => props.error || supportingError.value || '单据中心加载失败。')
|
||||
const showEmpty = computed(() => !showLoading.value && !showError.value && visibleRows.value.length === 0)
|
||||
|
||||
@@ -567,6 +567,7 @@
|
||||
title="员工数据同步中"
|
||||
message="正在加载员工档案与角色权限"
|
||||
icon="mdi mdi-account-group-outline"
|
||||
floating
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -182,10 +182,10 @@
|
||||
</div>
|
||||
|
||||
<footer class="detail-actions">
|
||||
<button class="back-action" type="button" @click="backToLogs">
|
||||
<i class="mdi mdi-arrow-left"></i>
|
||||
<span>返回日志列表</span>
|
||||
</button>
|
||||
<button class="back-action" type="button" @click="backToLogs">
|
||||
<i class="mdi mdi-arrow-left"></i>
|
||||
<span>返回系统日志</span>
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</template>
|
||||
@@ -435,9 +435,9 @@ async function loadDetail(options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function backToLogs() {
|
||||
router.push({ name: 'app-logs' })
|
||||
}
|
||||
function backToLogs() {
|
||||
router.push({ name: 'app-settings', query: { section: 'systemLogs' } })
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [route.params.logKind, route.params.logId],
|
||||
|
||||
@@ -100,14 +100,44 @@
|
||||
<i class="mdi mdi-filter-remove-outline"></i>
|
||||
<span>清空筛选</span>
|
||||
</button>
|
||||
<div class="document-filter refresh-interval-filter" :class="{ open: openFilterKey === 'refreshInterval' }">
|
||||
<button
|
||||
class="filter-btn refresh-interval-trigger"
|
||||
type="button"
|
||||
:aria-expanded="openFilterKey === 'refreshInterval'"
|
||||
@click="toggleFilter('refreshInterval')"
|
||||
>
|
||||
<i class="mdi mdi-clock-outline"></i>
|
||||
<span>刷新时间 {{ refreshIntervalLabel }}</span>
|
||||
<i class="mdi mdi-chevron-down"></i>
|
||||
</button>
|
||||
<div
|
||||
v-if="openFilterKey === 'refreshInterval'"
|
||||
class="document-filter-menu refresh-interval-menu"
|
||||
role="listbox"
|
||||
aria-label="刷新时间"
|
||||
>
|
||||
<button
|
||||
v-for="option in refreshIntervalOptions"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
role="option"
|
||||
:aria-selected="refreshInterval === option.value"
|
||||
:class="{ active: refreshInterval === option.value }"
|
||||
@click="changeRefreshInterval(option.value)"
|
||||
>
|
||||
每 {{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="create-request-btn"
|
||||
class="create-request-btn icon-refresh-action"
|
||||
:disabled="systemLogLoading"
|
||||
aria-label="立即刷新系统日志"
|
||||
@click="loadSystemLogs(true)"
|
||||
>
|
||||
<i class="mdi mdi-refresh"></i>
|
||||
<span>{{ systemLogLoading ? '刷新中...' : '刷新日志' }}</span>
|
||||
<i :class="systemLogLoading ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-refresh'"></i>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -65,7 +65,15 @@
|
||||
</div>
|
||||
|
||||
<div class="doc-table-wrap">
|
||||
<table class="knowledge-document-table">
|
||||
<TableLoadingState
|
||||
v-if="loading && !visibleDocuments.length"
|
||||
title="知识库文件同步中"
|
||||
message="正在加载当前文件夹的知识库文件"
|
||||
icon="mdi mdi-folder-table-outline"
|
||||
floating
|
||||
/>
|
||||
|
||||
<table class="knowledge-document-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>文件名称</th>
|
||||
@@ -129,16 +137,7 @@
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="loading && !visibleDocuments.length">
|
||||
<td colspan="8" class="empty-row table-loading-row">
|
||||
<TableLoadingState
|
||||
title="知识库文件同步中"
|
||||
message="正在加载当前文件夹的知识库文件"
|
||||
icon="mdi mdi-folder-table-outline"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-else-if="!visibleDocuments.length">
|
||||
<tr v-if="!loading && !visibleDocuments.length">
|
||||
<td colspan="8" class="empty-row">
|
||||
当前文件夹暂无文件
|
||||
</td>
|
||||
|
||||
@@ -35,15 +35,15 @@
|
||||
<p>{{ activeSectionConfig.longDesc }}</p>
|
||||
</div>
|
||||
|
||||
<div class="settings-toolbar-actions">
|
||||
<button class="save-button" type="button" @click="saveActiveSection">
|
||||
<i class="mdi mdi-content-save-outline"></i>
|
||||
<span>{{ activeSectionConfig.actionLabel }}</span>
|
||||
</button>
|
||||
<div class="settings-toolbar-actions">
|
||||
<button v-if="activeSectionConfig.actionLabel" 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">
|
||||
<div class="settings-content" :class="{ 'settings-content-fill': activeSection === 'systemLogs' }">
|
||||
<template v-if="activeSection === 'profile'">
|
||||
<section class="settings-card">
|
||||
<div class="card-head">
|
||||
@@ -379,8 +379,8 @@
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<template v-else-if="activeSection === 'logs'">
|
||||
<section class="settings-card">
|
||||
<template v-else-if="activeSection === 'logs'">
|
||||
<section class="settings-card log-policy-card">
|
||||
<div class="card-head">
|
||||
<div class="card-title-with-icon">
|
||||
<div class="model-icon-box slate">
|
||||
@@ -388,7 +388,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<h4>日志级别与留存</h4>
|
||||
<p>定义系统记录粒度、归档周期和告警接收人,方便后续审计与排障。</p>
|
||||
<p>定义系统记录粒度、归档周期、写入路径和告警接收人,方便后续排障追踪。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -429,53 +429,18 @@
|
||||
<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 class="card-title-with-icon">
|
||||
<div class="model-icon-box slate">
|
||||
<i class="mdi mdi-eye-check-outline"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h4>审计策略</h4>
|
||||
<p>决定是否记录关键操作、登录行为以及是否对敏感字段进行脱敏处理。</p>
|
||||
</div>
|
||||
</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-btn" :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-btn" :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-btn" :class="{ active: pageState.logForm.maskSensitive }"><i></i></span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<template v-else-if="activeSection === 'mail'">
|
||||
<MailSettingsPanel :mail-form="pageState.mailForm" />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<template v-else-if="activeSection === 'systemLogs'">
|
||||
<LogDetailView v-if="systemLogDetailMode" class="settings-log-detail-view" />
|
||||
<LogsView v-else class="settings-logs-view" />
|
||||
</template>
|
||||
|
||||
<template v-else-if="activeSection === 'mail'">
|
||||
<MailSettingsPanel :mail-form="pageState.mailForm" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<strong>{{ section.title }}</strong>
|
||||
<small>{{ section.desc }}</small>
|
||||
</span>
|
||||
<i v-if="section.complete" class="pi pi-check setup-nav-check"></i>
|
||||
<i v-if="section.complete" class="mdi mdi-check setup-nav-check"></i>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
@@ -42,11 +42,11 @@
|
||||
<div v-if="canSubmit" class="setup-complete">
|
||||
<p>所有必要步骤已通过检测,可以写入配置并进入登录界面。</p>
|
||||
<button class="primary-btn setup-complete-btn" type="button" :disabled="submitting" @click="submitForm">
|
||||
<i :class="['pi', submitting ? 'pi-spin pi-spinner' : 'pi-check']"></i>
|
||||
<i :class="['mdi', submitting ? 'mdi-loading mdi-spin' : 'mdi-check']"></i>
|
||||
<span>{{ submitting ? '写入配置中...' : '完成初始化并进入登录' }}</span>
|
||||
</button>
|
||||
<p v-if="progressMessage" class="setup-complete-progress">
|
||||
<i class="pi pi-spin pi-spinner"></i>
|
||||
<i class="mdi mdi-loading mdi-spin"></i>
|
||||
<span>{{ progressMessage }}</span>
|
||||
</p>
|
||||
</div>
|
||||
@@ -240,7 +240,7 @@
|
||||
<span>{{ progressMessage || '正在准备后端服务...' }}</span>
|
||||
</div>
|
||||
<div class="setup-startup-spinner" aria-hidden="true">
|
||||
<i v-if="!startupCountdownSeconds" class="pi pi-spin pi-spinner"></i>
|
||||
<i v-if="!startupCountdownSeconds" class="mdi mdi-loading mdi-spin"></i>
|
||||
<strong v-else>{{ startupCountdownSeconds }}</strong>
|
||||
</div>
|
||||
</header>
|
||||
@@ -358,19 +358,19 @@ const {
|
||||
|
||||
function startupStepIcon(status) {
|
||||
if (status === 'success') {
|
||||
return 'pi pi-check-circle'
|
||||
return 'mdi mdi-check-circle'
|
||||
}
|
||||
|
||||
if (status === 'error') {
|
||||
return 'pi pi-times-circle'
|
||||
return 'mdi mdi-close-circle'
|
||||
}
|
||||
|
||||
if (status === 'running') {
|
||||
return 'pi pi-spin pi-spinner'
|
||||
return 'mdi mdi-loading mdi-spin'
|
||||
}
|
||||
|
||||
return 'pi pi-circle'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped src="../assets/styles/views/setup-view.css"></style>
|
||||
return 'mdi mdi-circle-outline'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped src="../assets/styles/views/setup-view.css"></style>
|
||||
|
||||
@@ -177,12 +177,6 @@ export default {
|
||||
() =>
|
||||
normalizeText(selectedSkill.value?.ruleDocument?.file_name) || '未上传规则表'
|
||||
)
|
||||
const selectedSpreadsheetModeLabel = computed(() => {
|
||||
if (selectedSkill.value?.isPreviewMock) {
|
||||
return canEditSpreadsheetInline.value ? '可编辑' : '只读'
|
||||
}
|
||||
return canEditSpreadsheetInline.value ? '在线可编辑' : '只读'
|
||||
})
|
||||
const {
|
||||
versionSwitchTarget,
|
||||
versionTimelineOpen,
|
||||
@@ -438,11 +432,7 @@ export default {
|
||||
const auditDetailTopBar = computed(() =>
|
||||
buildAuditDetailTopBar({
|
||||
skill: selectedSkill.value,
|
||||
usesJsonRiskRule: selectedSkillUsesJsonRisk.value,
|
||||
usesSpreadsheetRule: selectedSkillUsesSpreadsheet.value,
|
||||
spreadsheetModeLabel: selectedSpreadsheetModeLabel.value,
|
||||
spreadsheetFileName: selectedSpreadsheetFileName.value,
|
||||
canEditSpreadsheetInline: canEditSpreadsheetInline.value
|
||||
usesJsonRiskRule: selectedSkillUsesJsonRisk.value
|
||||
})
|
||||
)
|
||||
|
||||
@@ -711,7 +701,6 @@ export default {
|
||||
selectedSkillUsesSpreadsheet,
|
||||
selectedSkillUsesJsonRisk,
|
||||
selectedSpreadsheetFileName,
|
||||
selectedSpreadsheetModeLabel,
|
||||
selectedVersionTimelineItems,
|
||||
selectedSpreadsheetChangeRecords,
|
||||
detailBusy,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ElButton, ElInput, ElPagination, ElTable, ElTableColumn } from 'element
|
||||
|
||||
import BudgetTrendChart from '../../components/charts/BudgetTrendChart.vue'
|
||||
import EnterpriseSelect from '../../components/shared/EnterpriseSelect.vue'
|
||||
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
|
||||
import { fetchBudgetSummary } from '../../services/budgets.js'
|
||||
import { fetchEmployeeMeta } from '../../services/employees.js'
|
||||
import {
|
||||
@@ -217,6 +218,7 @@ export default {
|
||||
components: {
|
||||
BudgetTrendChart,
|
||||
EnterpriseSelect,
|
||||
TableLoadingState,
|
||||
ElButton,
|
||||
ElInput,
|
||||
ElPagination,
|
||||
@@ -238,7 +240,7 @@ export default {
|
||||
const budgetTableKeyword = ref('')
|
||||
const budgetRows = ref([])
|
||||
const budgetSummary = ref(null)
|
||||
const budgetLoading = ref(false)
|
||||
const budgetLoading = ref(true)
|
||||
const budgetError = ref('')
|
||||
const canEditBudget = computed(() => canEditBudgetCenter(props.currentUser))
|
||||
const canSwitchDepartments = computed(() => canSwitchBudgetDepartments(props.currentUser))
|
||||
@@ -424,6 +426,7 @@ export default {
|
||||
}
|
||||
|
||||
async function loadDepartments() {
|
||||
budgetLoading.value = true
|
||||
try {
|
||||
const payload = await fetchEmployeeMeta()
|
||||
const options = Array.isArray(payload?.organizationOptions) ? payload.organizationOptions : []
|
||||
|
||||
@@ -5,8 +5,12 @@ import EnterpriseListPage from '../../components/shared/EnterpriseListPage.vue'
|
||||
import { useSystemState } from '../../composables/useSystemState.js'
|
||||
import { useToast } from '../../composables/useToast.js'
|
||||
import { fetchSystemLogEntries } from '../../services/systemLogs.js'
|
||||
import { AGENT_RUN_POLL_INTERVAL_MS } from '../../utils/agentRunMonitor.js'
|
||||
import { isManagerUser } from '../../utils/accessControl.js'
|
||||
import {
|
||||
DEFAULT_REFRESH_INTERVAL_MS,
|
||||
REFRESH_INTERVAL_OPTIONS,
|
||||
formatRefreshInterval
|
||||
} from '../../utils/refreshIntervalOptions.js'
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) {
|
||||
@@ -79,6 +83,8 @@ export default {
|
||||
const pageSize = ref(10)
|
||||
const pageSizes = [10, 20, 50]
|
||||
const pageSizeOptions = pageSizes.map((size) => ({ label: `${size} 条/页`, value: size }))
|
||||
const refreshInterval = ref(DEFAULT_REFRESH_INTERVAL_MS)
|
||||
const refreshIntervalOptions = REFRESH_INTERVAL_OPTIONS
|
||||
let pollTimer = 0
|
||||
|
||||
const isAdmin = computed(() => isManagerUser(currentUser.value))
|
||||
@@ -102,6 +108,7 @@ export default {
|
||||
const systemEventTypeFilterLabel = computed(() =>
|
||||
systemEventTypeFilterOptions.value.find((item) => item.value === systemEventTypeFilter.value)?.label || '全部类型'
|
||||
)
|
||||
const refreshIntervalLabel = computed(() => formatRefreshInterval(refreshInterval.value))
|
||||
const hasActiveFilters = computed(() =>
|
||||
Boolean(systemSearchKeyword.value.trim() || systemLevelFilter.value || systemEventTypeFilter.value)
|
||||
)
|
||||
@@ -175,6 +182,12 @@ export default {
|
||||
openFilterKey.value = ''
|
||||
}
|
||||
|
||||
function changeRefreshInterval(value) {
|
||||
refreshInterval.value = Number(value) || DEFAULT_REFRESH_INTERVAL_MS
|
||||
openFilterKey.value = ''
|
||||
startPolling()
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
systemSearchKeyword.value = ''
|
||||
systemLevelFilter.value = ''
|
||||
@@ -211,7 +224,7 @@ export default {
|
||||
stopPolling()
|
||||
pollTimer = window.setInterval(() => {
|
||||
loadSystemLogs(false)
|
||||
}, AGENT_RUN_POLL_INTERVAL_MS)
|
||||
}, refreshInterval.value)
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
@@ -253,6 +266,7 @@ export default {
|
||||
|
||||
return {
|
||||
changePageSize,
|
||||
changeRefreshInterval,
|
||||
currentPage,
|
||||
filteredSystemLogEntries,
|
||||
formatDateTime,
|
||||
@@ -263,6 +277,9 @@ export default {
|
||||
openFilterKey,
|
||||
pageSize,
|
||||
pageSizeOptions,
|
||||
refreshInterval,
|
||||
refreshIntervalLabel,
|
||||
refreshIntervalOptions,
|
||||
resetFilters,
|
||||
resolveSystemLevelTone,
|
||||
resolveSystemOutcomeTone,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import HermesEmployeeSettingsPanel from '../HermesEmployeeSettingsPanel.vue'
|
||||
import LlmSettingsPanel from '../LlmSettingsPanel.vue'
|
||||
import LogDetailView from '../LogDetailView.vue'
|
||||
import LogsView from '../LogsView.vue'
|
||||
import MailSettingsPanel from '../MailSettingsPanel.vue'
|
||||
import EnterpriseSelect from '../../components/shared/EnterpriseSelect.vue'
|
||||
import { useSettings } from '../../composables/useSettings.js'
|
||||
@@ -10,6 +12,8 @@ export default {
|
||||
HermesEmployeeSettingsPanel,
|
||||
EnterpriseSelect,
|
||||
LlmSettingsPanel,
|
||||
LogDetailView,
|
||||
LogsView,
|
||||
MailSettingsPanel
|
||||
},
|
||||
setup() {
|
||||
|
||||
@@ -10,11 +10,7 @@ function resolveRiskScoreCardColor(level) {
|
||||
|
||||
export function buildAuditDetailTopBar({
|
||||
skill,
|
||||
usesJsonRiskRule = false,
|
||||
usesSpreadsheetRule = false,
|
||||
spreadsheetModeLabel = '',
|
||||
spreadsheetFileName = '',
|
||||
canEditSpreadsheetInline = false
|
||||
usesJsonRiskRule = false
|
||||
} = {}) {
|
||||
if (!skill) return null
|
||||
|
||||
@@ -39,15 +35,6 @@ export function buildAuditDetailTopBar({
|
||||
: 'up',
|
||||
color: resolveRiskScoreCardColor(scoreLevel)
|
||||
})
|
||||
} else if (usesSpreadsheetRule) {
|
||||
kpis.push({
|
||||
label: '编辑模式',
|
||||
value: spreadsheetModeLabel,
|
||||
unit: '',
|
||||
meta: spreadsheetFileName,
|
||||
trend: canEditSpreadsheetInline ? 'up' : 'down',
|
||||
color: canEditSpreadsheetInline ? 'var(--success)' : '#64748b'
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -7,7 +7,10 @@ import {
|
||||
VERSION_STATE_META
|
||||
} from './auditViewMetadata.js'
|
||||
import {
|
||||
formatRiskRuleAge,
|
||||
resolveRiskRuleFlow,
|
||||
resolveRiskRuleScore,
|
||||
resolveRiskRuleScoreDetail,
|
||||
resolveRiskRuleScoreLabel,
|
||||
resolveRiskRuleScoreLevel,
|
||||
resolveRiskRuleSeverity,
|
||||
@@ -71,6 +74,7 @@ import {
|
||||
applyRiskRuleJsonState,
|
||||
resolveRiskRuleBusinessStage,
|
||||
resolveRiskRuleEnabled,
|
||||
resolveLastOperationLabel,
|
||||
resolveRiskRuleOnlineMeta
|
||||
} from './auditViewRiskRuleState.js'
|
||||
import {
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from './auditViewDataUtils.js'
|
||||
import { formatDateTime } from './auditViewFormatters.js'
|
||||
import {
|
||||
buildRiskListSubtitle,
|
||||
resolveRiskRuleCategory,
|
||||
resolveRiskRuleDescription,
|
||||
resolveRiskRuleSourceRef
|
||||
@@ -83,7 +84,7 @@ export function resolveRiskRuleOnlineMeta(statusValue) {
|
||||
return { label: '待上线', tone: 'draft', online: false }
|
||||
}
|
||||
|
||||
function resolveLastOperationLabel(source, fallback = {}) {
|
||||
export function resolveLastOperationLabel(source, fallback = {}) {
|
||||
const configJson = readConfigJson(source)
|
||||
const operation = isPlainObject(configJson.last_operation) ? configJson.last_operation : {}
|
||||
const action = normalizeText(operation.action) || normalizeText(fallback.action) || 'create'
|
||||
@@ -129,9 +130,12 @@ export function applyRiskRuleJsonState(target, payload, apiPayload) {
|
||||
|
||||
let publishedAt = target.publishedAt || '-'
|
||||
if (apiPayload?.recent_versions) {
|
||||
const history = buildHistory(apiPayload.recent_versions, { ...target, config_json: payload })
|
||||
const publishedVersionObj = history.find((item) => item.isPublished || item.lifecycleState === 'published')
|
||||
publishedAt = publishedVersionObj ? publishedVersionObj.time : (apiPayload?.latest_review?.reviewed_at ? formatDateTime(apiPayload.latest_review.reviewed_at) : '-')
|
||||
const publishedVersionObj = apiPayload.recent_versions.find((item) =>
|
||||
item?.is_current || item?.version === apiPayload?.published_version
|
||||
)
|
||||
publishedAt = publishedVersionObj?.created_at
|
||||
? formatDateTime(publishedVersionObj.created_at)
|
||||
: (apiPayload?.latest_review?.reviewed_at ? formatDateTime(apiPayload.latest_review.reviewed_at) : '-')
|
||||
} else if (apiPayload?.latest_review?.reviewed_at) {
|
||||
publishedAt = formatDateTime(apiPayload.latest_review.reviewed_at)
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ test('legacy reimbursement approval and archive centers are no longer accessible
|
||||
assert.equal(canAccessAppView(adminUser, 'requests'), false)
|
||||
assert.equal(canAccessAppView(adminUser, 'approval'), false)
|
||||
assert.equal(canAccessAppView(adminUser, 'archive'), false)
|
||||
assert.equal(canAccessAppView(adminUser, 'logs'), false)
|
||||
assert.equal(canAccessAppView(adminUser, 'documents'), true)
|
||||
})
|
||||
|
||||
|
||||
56
web/tests/minimum-visible-state.test.mjs
Normal file
56
web/tests/minimum-visible-state.test.mjs
Normal file
@@ -0,0 +1,56 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { effectScope, nextTick, ref } from 'vue'
|
||||
|
||||
import { useMinimumVisibleState } from '../src/composables/useMinimumVisibleState.js'
|
||||
|
||||
function wait(ms) {
|
||||
return new Promise((resolve) => globalThis.setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
test('minimum visible state stays visible after a fast loading toggle', async () => {
|
||||
const scope = effectScope()
|
||||
const state = scope.run(() => {
|
||||
const loading = ref(false)
|
||||
const visible = useMinimumVisibleState(loading, { minVisibleMs: 35 })
|
||||
return { loading, visible }
|
||||
})
|
||||
|
||||
state.loading.value = true
|
||||
await nextTick()
|
||||
assert.equal(state.visible.value, true)
|
||||
|
||||
state.loading.value = false
|
||||
await nextTick()
|
||||
assert.equal(state.visible.value, true)
|
||||
|
||||
await wait(50)
|
||||
assert.equal(state.visible.value, false)
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
test('minimum visible state cancels a pending hide when loading restarts', async () => {
|
||||
const scope = effectScope()
|
||||
const state = scope.run(() => {
|
||||
const loading = ref(false)
|
||||
const visible = useMinimumVisibleState(loading, { minVisibleMs: 40 })
|
||||
return { loading, visible }
|
||||
})
|
||||
|
||||
state.loading.value = true
|
||||
await nextTick()
|
||||
state.loading.value = false
|
||||
await nextTick()
|
||||
await wait(10)
|
||||
|
||||
state.loading.value = true
|
||||
await nextTick()
|
||||
await wait(40)
|
||||
assert.equal(state.visible.value, true)
|
||||
|
||||
state.loading.value = false
|
||||
await nextTick()
|
||||
await wait(50)
|
||||
assert.equal(state.visible.value, false)
|
||||
scope.stop()
|
||||
})
|
||||
@@ -8,9 +8,9 @@ import {
|
||||
} from '../src/composables/useNavigation.js'
|
||||
|
||||
function testDerivesViewFromRouteName() {
|
||||
assert.equal(resolveAppViewFromRoute({ name: 'app-log-detail', meta: {} }), 'logs')
|
||||
assert.equal(resolveAppViewFromRoute({ name: 'app-log-detail', meta: {} }), 'settings')
|
||||
assert.equal(resolveAppViewFromRoute({ name: 'app-request-detail', meta: {} }), 'documents')
|
||||
assert.equal(resolveAppViewFromRoute({ name: 'app-policies', meta: { appView: 'logs' } }), 'policies')
|
||||
assert.equal(resolveAppViewFromRoute({ name: 'app-policies', meta: { appView: 'settings' } }), 'policies')
|
||||
}
|
||||
|
||||
function testFallsBackToValidMeta() {
|
||||
@@ -19,7 +19,7 @@ function testFallsBackToValidMeta() {
|
||||
}
|
||||
|
||||
function testResolvesMainRouteNames() {
|
||||
assert.equal(resolveTargetRouteName('logs'), 'app-logs')
|
||||
assert.equal(resolveTargetRouteName('logs'), 'app-settings')
|
||||
assert.equal(resolveTargetRouteName('policies'), 'app-policies')
|
||||
assert.equal(resolveTargetRouteName('requests'), 'app-overview')
|
||||
assert.equal(resolveTargetRouteName('approval'), 'app-overview')
|
||||
@@ -31,7 +31,8 @@ function testLegacyCentersAreRemovedFromNavigation() {
|
||||
assert.equal(appViews.includes('requests'), false)
|
||||
assert.equal(appViews.includes('approval'), false)
|
||||
assert.equal(appViews.includes('archive'), false)
|
||||
assert.equal(navItems.some((item) => ['requests', 'approval', 'archive'].includes(item.id)), false)
|
||||
assert.equal(appViews.includes('logs'), false)
|
||||
assert.equal(navItems.some((item) => ['requests', 'approval', 'archive', 'logs'].includes(item.id)), false)
|
||||
}
|
||||
|
||||
function run() {
|
||||
|
||||
34
web/tests/refresh-interval-controls.test.mjs
Normal file
34
web/tests/refresh-interval-controls.test.mjs
Normal file
@@ -0,0 +1,34 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
const refreshOptions = readFileSync(new URL('../src/utils/refreshIntervalOptions.js', import.meta.url), 'utf8')
|
||||
const logsView = readFileSync(new URL('../src/views/LogsView.vue', import.meta.url), 'utf8')
|
||||
const logsScript = readFileSync(new URL('../src/views/scripts/LogsView.js', import.meta.url), 'utf8')
|
||||
const workRecords = readFileSync(
|
||||
new URL('../src/components/audit/DigitalEmployeeWorkRecords.vue', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
test('shared refresh interval options default to 60 seconds', () => {
|
||||
assert.match(refreshOptions, /DEFAULT_REFRESH_INTERVAL_MS\s*=\s*60000/)
|
||||
for (const value of [1000, 3000, 5000, 10000, 30000, 60000, 180000]) {
|
||||
assert.match(refreshOptions, new RegExp(`value:\\s*${value}`))
|
||||
}
|
||||
})
|
||||
|
||||
test('system logs list exposes refresh interval control', () => {
|
||||
assert.match(logsScript, /refreshInterval\s*=\s*ref\(DEFAULT_REFRESH_INTERVAL_MS\)/)
|
||||
assert.match(logsScript, /window\.setInterval\([\s\S]*refreshInterval\.value/)
|
||||
assert.match(logsView, /刷新时间 \{\{ refreshIntervalLabel \}\}/)
|
||||
assert.match(logsView, /v-for="option in refreshIntervalOptions"/)
|
||||
assert.doesNotMatch(logsView, /刷新日志/)
|
||||
})
|
||||
|
||||
test('digital employee work records expose refresh interval control', () => {
|
||||
assert.match(workRecords, /refreshInterval\s*=\s*ref\(DEFAULT_REFRESH_INTERVAL_MS\)/)
|
||||
assert.match(workRecords, /refreshIntervalPickerOptions\s*=\s*REFRESH_INTERVAL_OPTIONS/)
|
||||
assert.match(workRecords, /window\.setInterval\([\s\S]*refreshInterval\.value/)
|
||||
assert.match(workRecords, /刷新时间 \$\{refreshIntervalLabel\}/)
|
||||
assert.doesNotMatch(workRecords, /AGENT_RUN_POLL_INTERVAL_MS/)
|
||||
})
|
||||
22
web/tests/settings-system-logs-section.test.mjs
Normal file
22
web/tests/settings-system-logs-section.test.mjs
Normal file
@@ -0,0 +1,22 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
const settingsModel = readFileSync(new URL('../src/utils/settingsModelHelper.js', import.meta.url), 'utf8')
|
||||
const settingsView = readFileSync(new URL('../src/views/SettingsView.vue', import.meta.url), 'utf8')
|
||||
const settingsScript = readFileSync(new URL('../src/views/scripts/SettingsView.js', import.meta.url), 'utf8')
|
||||
const router = readFileSync(new URL('../src/router/index.js', import.meta.url), 'utf8')
|
||||
const logDetailView = readFileSync(new URL('../src/views/LogDetailView.vue', import.meta.url), 'utf8')
|
||||
|
||||
test('system logs are nested under system settings instead of sidebar navigation', () => {
|
||||
assert.match(settingsModel, /id:\s*'systemLogs'[\s\S]*label:\s*'系统日志'/)
|
||||
assert.match(settingsView, /activeSection === 'systemLogs'/)
|
||||
assert.match(settingsView, /<LogsView v-else class="settings-logs-view" \/>/)
|
||||
assert.match(settingsScript, /import LogsView from '\.\.\/LogsView\.vue'/)
|
||||
})
|
||||
|
||||
test('log detail keeps the settings context and legacy logs URLs redirect', () => {
|
||||
assert.match(router, /path:\s*'\/app\/settings\/logs\/:logKind\/:logId'[\s\S]*appView:\s*'settings'/)
|
||||
assert.match(router, /path:\s*'\/app\/logs'[\s\S]*section:\s*'systemLogs'/)
|
||||
assert.match(logDetailView, /router\.push\(\{ name:\s*'app-settings', query:\s*\{ section:\s*'systemLogs' \} \}\)/)
|
||||
})
|
||||
@@ -1053,17 +1053,31 @@ export default defineConfig({
|
||||
if (!id.includes('node_modules')) {
|
||||
return undefined
|
||||
}
|
||||
if (id.includes('element-plus') || id.includes('@element-plus')) {
|
||||
const normalizedId = id.replace(/\\/g, '/')
|
||||
if (
|
||||
normalizedId.includes('/node_modules/vue/') ||
|
||||
normalizedId.includes('/node_modules/@vue/') ||
|
||||
normalizedId.includes('/node_modules/vue-router/')
|
||||
) {
|
||||
return 'vendor-vue'
|
||||
}
|
||||
if (normalizedId.includes('element-plus') || normalizedId.includes('@element-plus')) {
|
||||
return 'vendor-element-plus'
|
||||
}
|
||||
if (id.includes('echarts') || id.includes('zrender')) {
|
||||
if (normalizedId.includes('echarts') || normalizedId.includes('zrender')) {
|
||||
return 'vendor-echarts'
|
||||
}
|
||||
if (id.includes('@vueuse')) {
|
||||
return 'vendor-vueuse'
|
||||
if (normalizedId.includes('@antv/g6')) {
|
||||
return 'vendor-g6'
|
||||
}
|
||||
if (id.includes('primeicons') || id.includes('primevue')) {
|
||||
return 'vendor-prime'
|
||||
if (normalizedId.includes('chart.js') || normalizedId.includes('vue-chartjs')) {
|
||||
return 'vendor-chartjs'
|
||||
}
|
||||
if (normalizedId.includes('markdown-it')) {
|
||||
return 'vendor-markdown'
|
||||
}
|
||||
if (normalizedId.includes('@vueuse')) {
|
||||
return 'vendor-vueuse'
|
||||
}
|
||||
return 'vendor'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user