feat(ui): finalize shared shells and loading states

This commit is contained in:
caoxiaozhu
2026-05-29 13:17:39 +08:00
parent 64cc76c970
commit e080105f9f
52 changed files with 1559 additions and 861 deletions

7
web/package-lock.json generated
View File

@@ -17,7 +17,6 @@
"element-plus": "^2.14.0", "element-plus": "^2.14.0",
"markdown-it": "^14.1.1", "markdown-it": "^14.1.1",
"pg": "^8.13.1", "pg": "^8.13.1",
"primeicons": "^7.0.0",
"vite": "^5.4.19", "vite": "^5.4.19",
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-chartjs": "^5.3.3", "vue-chartjs": "^5.3.3",
@@ -2754,12 +2753,6 @@
"node": ">=0.10.0" "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": { "node_modules/punycode.js": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmmirror.com/punycode.js/-/punycode.js-2.3.1.tgz", "resolved": "https://registry.npmmirror.com/punycode.js/-/punycode.js-2.3.1.tgz",

View File

@@ -19,7 +19,6 @@
"element-plus": "^2.14.0", "element-plus": "^2.14.0",
"markdown-it": "^14.1.1", "markdown-it": "^14.1.1",
"pg": "^8.13.1", "pg": "^8.13.1",
"primeicons": "^7.0.0",
"vite": "^5.4.19", "vite": "^5.4.19",
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-chartjs": "^5.3.3", "vue-chartjs": "^5.3.3",

View File

@@ -69,68 +69,6 @@
pointer-events: none; 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 { .login-entry-veil-enter-active {
transition: opacity 180ms var(--ease); transition: opacity 180ms var(--ease);
} }
@@ -231,7 +169,6 @@
.main.policies-main, .main.policies-main,
.main.audit-main, .main.audit-main,
.main.digital-employees-main, .main.digital-employees-main,
.main.logs-main,
.main.employees-main, .main.employees-main,
.main.settings-main { .main.settings-main {
height: var(--desktop-stage-height, 100dvh); height: var(--desktop-stage-height, 100dvh);
@@ -250,7 +187,6 @@
.workarea.policies-workarea, .workarea.policies-workarea,
.workarea.audit-workarea, .workarea.audit-workarea,
.workarea.digital-employees-workarea, .workarea.digital-employees-workarea,
.workarea.logs-workarea,
.workarea.employees-workarea, .workarea.employees-workarea,
.workarea.settings-workarea { .workarea.settings-workarea {
min-height: 0; min-height: 0;
@@ -267,28 +203,6 @@
background: #fff; 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 { @keyframes loginEntrySidebarIn {
from { from {
opacity: 0; opacity: 0;
@@ -401,8 +315,6 @@
transition-duration: 120ms, 120ms !important; transition-duration: 120ms, 120ms !important;
} }
.login-entry-card,
.login-entry-progress::after,
.app.login-entry-active .app-sidebar, .app.login-entry-active .app-sidebar,
.app.login-entry-active > .main { .app.login-entry-active > .main {
animation: none !important; animation: none !important;

View File

@@ -13,6 +13,12 @@
margin-top: 14px; margin-top: 14px;
border-bottom: 1px solid #dbe4ee; border-bottom: 1px solid #dbe4ee;
overflow-x: auto; overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
}
.enterprise-list-page .status-tabs::-webkit-scrollbar {
display: none;
} }
.enterprise-list-page .status-tabs button { .enterprise-list-page .status-tabs button {
@@ -65,6 +71,7 @@
} }
.enterprise-list-page .filter-set { .enterprise-list-page .filter-set {
flex: 1 1 auto;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
@@ -74,7 +81,14 @@
.enterprise-list-page .list-search { .enterprise-list-page .list-search {
position: relative; 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 { .enterprise-list-page .list-search .mdi {
@@ -202,13 +216,32 @@
color: #64748b; 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 { .enterprise-list-page .table-wrap {
min-height: 400px; min-height: 400px;
margin-top: 10px; margin-top: 10px;
overflow: auto; overflow: auto;
border: 1px solid #edf2f7; border: 1px solid #edf2f7;
border-radius: 4px; border-radius: 10px;
background: linear-gradient(180deg, #fcfeff 0%, var(--theme-primary-light-9) 100%); background: linear-gradient(180deg, #fcfefd 0%, #f4f8f6 100%);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
@@ -263,7 +296,8 @@
font-weight: 750; font-weight: 750;
} }
.enterprise-list-page table { .enterprise-list-page table,
.enterprise-list-page .table-wrap > table {
width: 100%; width: 100%;
min-width: 1080px; min-width: 1080px;
align-self: flex-start; align-self: flex-start;
@@ -272,7 +306,9 @@
} }
.enterprise-list-page th, .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; padding: 13px 12px;
border-bottom: 1px solid #edf2f7; border-bottom: 1px solid #edf2f7;
color: #24324a; color: #24324a;
@@ -285,7 +321,8 @@
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.enterprise-list-page th { .enterprise-list-page th,
.enterprise-list-page .table-wrap th {
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 1; z-index: 1;
@@ -295,16 +332,20 @@
font-weight: 800; font-weight: 800;
} }
.enterprise-list-page tbody tr { .enterprise-list-page tbody tr,
.enterprise-list-page .table-wrap tbody tr {
cursor: pointer; cursor: pointer;
} }
.enterprise-list-page tbody tr:hover, .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)); 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; border-bottom: 0;
} }
@@ -313,11 +354,119 @@
font-weight: 800; 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 { .enterprise-pagination {
flex: 0 0 auto; flex: 0 0 auto;
} }
.enterprise-list-page .list-foot { .enterprise-list-page .list-foot,
.enterprise-list-page.enterprise-list-page .list-foot {
display: grid; display: grid;
grid-template-columns: 1fr auto 1fr; grid-template-columns: 1fr auto 1fr;
align-items: center; align-items: center;
@@ -325,27 +474,30 @@
margin-top: 12px; margin-top: 12px;
} }
.enterprise-list-page .page-summary { .enterprise-list-page .page-summary,
.enterprise-list-page.enterprise-list-page .page-summary {
color: #64748b; color: #64748b;
font-size: 14px; font-size: 14px;
font-weight: 650; font-weight: 650;
} }
.enterprise-list-page .pager { .enterprise-list-page .pager,
.enterprise-list-page.enterprise-list-page .pager {
display: inline-flex; display: inline-flex;
justify-content: center; justify-content: center;
gap: 6px; gap: 6px;
padding: 4px; padding: 4px;
border: 1px solid #e2e8f0; border: 1px solid #e2e8f0;
border-radius: 4px; border-radius: 12px;
background: #f8fafc; background: #f8fafc;
} }
.enterprise-list-page .pager button { .enterprise-list-page .pager button,
.enterprise-list-page.enterprise-list-page .pager button {
width: 32px; width: 32px;
height: 32px; height: 32px;
border: 0; border: 0;
border-radius: 4px; border-radius: 9px;
background: transparent; background: transparent;
color: #334155; color: #334155;
font-size: 14px; font-size: 14px;
@@ -353,19 +505,22 @@
transition: background 160ms ease, color 160ms ease, box-shadow 160ms ease; 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; background: #fff;
color: var(--theme-primary-active); color: var(--theme-primary-active);
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.08); box-shadow: 0 1px 4px rgba(15, 23, 42, 0.08);
} }
.enterprise-list-page .pager button.active { .enterprise-list-page .pager button.active,
background: var(--theme-primary); .enterprise-list-page.enterprise-list-page .pager button.active {
background: var(--theme-primary-active);
color: #fff; color: #fff;
box-shadow: 0 8px 16px var(--theme-primary-shadow); 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; cursor: not-allowed;
opacity: 0.45; opacity: 0.45;
box-shadow: none; box-shadow: none;
@@ -394,6 +549,86 @@
overflow: auto; 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 { .enterprise-detail-card .card-head {
align-items: flex-start; align-items: flex-start;
} }

View File

@@ -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; } *, *::before, *::after { animation-duration: 1ms !important; transition-duration: 1ms !important; scroll-behavior: auto !important; }
} }
.table-loading__spinner { .table-state:has(.table-loading.screen-floating),
width: 38px; .table-state:has(.table-loading-anchor),
height: 38px; .table-loading-row:has(.table-loading.screen-floating),
display: inline-grid; .table-loading-row:has(.table-loading-anchor) {
place-items: center; min-height: 0 !important;
border: 3px solid #e2e8f0; padding: 0 !important;
border-top-color: var(--primary); background: transparent !important;
border-radius: 50%;
animation: table-spinner-rotate .8s linear infinite !important;
} }
.table-loading.sky .table-loading__spinner { tr:has(> .table-loading-row .table-loading.screen-floating),
border-top-color: var(--primary); 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.detail .table-loading__spinner { .table-loading-row:has(.table-loading.screen-floating),
width: 34px; .table-loading-row:has(.table-loading-anchor) {
height: 34px; height: 0 !important;
} border: 0 !important;
line-height: 0 !important;
.table-loading.banner .table-loading__spinner { background: transparent !important;
width: 18px;
height: 18px;
border-width: 2px;
}
.table-loading__spinner i {
display: none;
}
@keyframes table-spinner-rotate {
to {
transform: rotate(360deg);
}
} }
/* Global Scrollbar Styles */ /* Global Scrollbar Styles */

View File

@@ -5,49 +5,81 @@
.digital-employees-list { .digital-employees-list {
height: 100%; height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
padding: 16px 18px;
overflow: hidden;
} }
.digital-employees-list > .status-tabs { .digital-employees-list > .status-tabs {
flex: 0 0 auto; 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 { .digital-employees-list > .status-tabs::-webkit-scrollbar {
min-height: 0; 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 { .digital-employees-table {
min-width: 1060px; min-width: 1060px;
table-layout: fixed;
} }
.digital-employees-table .col-skill { width: 27%; } /* Default first column left alignment */
.digital-employees-table .col-schedule { width: 16%; } .digital-employees-table th:first-child,
.digital-employees-table .col-mode { width: 12%; } .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-skill-type { width: 11%; }
.digital-employees-table .col-status { width: 11%; } .digital-employees-table .col-owner { width: 11%; }
.digital-employees-table .col-enabled { width: 11%; } .digital-employees-table .col-schedule { width: 16%; }
.digital-employees-table .col-updated { width: 12%; } .digital-employees-table .col-mode { width: 10%; }
.digital-employees-table .col-status { width: 10%; }
.digital-employees-table td { .digital-employees-table .col-enabled { width: 10%; }
white-space: nowrap; .digital-employees-table .col-updated { width: 10%; }
overflow: hidden;
text-overflow: ellipsis;
}
.digital-employees-table tbody tr {
cursor: pointer;
}
.digital-refresh-action i { .digital-refresh-action i {
font-size: 16px; font-size: 16px;
} }
.skill-type-pill {
border-color: #dbeafe;
background: #eff6ff;
color: #1d4ed8;
}
.digital-employee-detail { .digital-employee-detail {
height: 100%; height: 100%;
} }

View File

@@ -54,6 +54,26 @@
color: var(--theme-primary); 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 { .system-logs-list .document-filter-menu {
position: absolute; position: absolute;
top: calc(100% + 8px); top: calc(100% + 8px);

View File

@@ -423,14 +423,6 @@ th {
text-align: center; text-align: center;
} }
.table-loading-row {
padding: 0;
}
.table-loading-row > .table-loading {
min-height: 220px;
}
.list-foot { .list-foot {
display: grid; display: grid;
grid-template-columns: 1fr auto 1fr; grid-template-columns: 1fr auto 1fr;

View File

@@ -206,6 +206,16 @@
padding: 20px 24px; 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 { .model-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -680,6 +690,19 @@
margin-bottom: 20px; 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 { .card-title-with-icon {
display: flex; display: flex;

View File

@@ -591,7 +591,7 @@
background: rgba(6, 78, 59, 0.34); background: rgba(6, 78, 59, 0.34);
} }
.setup-startup-spinner .pi { .setup-startup-spinner .mdi {
font-size: 22px; font-size: 22px;
} }
@@ -626,7 +626,7 @@
background: rgba(15, 23, 42, 0.24); background: rgba(15, 23, 42, 0.24);
} }
.setup-startup-step .pi { .setup-startup-step .mdi {
margin-top: 2px; margin-top: 2px;
color: color-mix(in srgb, var(--theme-primary-soft) 46%, transparent); color: color-mix(in srgb, var(--theme-primary-soft) 46%, transparent);
} }
@@ -648,7 +648,7 @@
border-color: rgba(59, 130, 246, 0.34); border-color: rgba(59, 130, 246, 0.34);
} }
.setup-startup-step.is-running .pi { .setup-startup-step.is-running .mdi {
color: #93c5fd; color: #93c5fd;
} }
@@ -656,7 +656,7 @@
border-color: rgba(var(--theme-primary-rgb), 0.32); 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); color: var(--theme-primary-light-5);
} }
@@ -664,7 +664,7 @@
border-color: rgba(248, 113, 113, 0.36); border-color: rgba(248, 113, 113, 0.36);
} }
.setup-startup-step.is-error .pi { .setup-startup-step.is-error .mdi {
color: #f87171; color: #f87171;
} }

View File

@@ -6,13 +6,21 @@
:error="errorMessage" :error="errorMessage"
:empty="!visibleEmployees.length" :empty="!visibleEmployees.length"
:empty-state="emptyState" :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-title="数字员工资产同步中"
loading-message="正在加载数字员工资产" loading-message="正在加载数字员工资产"
loading-icon="mdi mdi-view-list-outline" loading-icon="mdi mdi-view-list-outline"
hint="归集后台自动执行的数字员工技能,可查看技能内容、执行计划、启动状态和最近版本。" @update:current-page="currentPage = $event"
> >
<template #filters> <template #filters>
<label class="search-filter"> <label class="list-search">
<i class="mdi mdi-magnify"></i> <i class="mdi mdi-magnify"></i>
<input <input
:value="keyword" :value="keyword"
@@ -127,51 +135,21 @@
@click="emit('open-employee-detail', employee)" @click="emit('open-employee-detail', employee)"
> >
<td> <td>
<div class="skill-name-cell"> <strong class="doc-id">{{ employee.name }}</strong>
<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>
</td> </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>{{ 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>{{ employee.executionMode }}</td>
<td> <td>
<span :class="['status-pill', employee.statusTone]">{{ employee.status }}</span> <span :class="['status-tag', employee.statusTone]">{{ employee.status }}</span>
</td> </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> <td>{{ employee.updatedAt || '-' }}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</template> </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> </EnterpriseListPage>
</template> </template>
@@ -228,6 +206,9 @@ const pageNumbers = computed(() => {
const start = Math.max(1, Math.min(currentPage.value - 3, total - 6)) const start = Math.max(1, Math.min(currentPage.value - 3, total - 6))
return Array.from({ length: 7 }, (_, index) => start + index) return Array.from({ length: 7 }, (_, index) => start + index)
}) })
const paginationSummary = computed(() =>
`共 ${props.visibleEmployees.length} 条,目前第 ${currentPage.value} / ${totalPages.value} 页`
)
const emptyState = { const emptyState = {
eyebrow: '数字员工', eyebrow: '数字员工',
title: '暂无匹配的数字员工', title: '暂无匹配的数字员工',
@@ -261,7 +242,7 @@ function selectFilter(type, value) {
} }
</script> </script>
<style scoped src="../../assets/styles/views/audit-view.css"></style> <style scoped src="../../assets/styles/views/digital-employees-view.css"></style>
<style scoped> <style scoped>
.digital-employee-list-panel { .digital-employee-list-panel {
@@ -269,62 +250,8 @@ function selectFilter(type, value) {
min-height: 0; min-height: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 0;
overflow: hidden; 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> </style>

View File

@@ -2,104 +2,114 @@
<section class="digital-employee-list-panel digital-work-records"> <section class="digital-employee-list-panel digital-work-records">
<Transition name="skill-view" mode="out-in"> <Transition name="skill-view" mode="out-in">
<!-- 列表视图 --> <!-- 列表视图 -->
<div v-if="!selectedRunDetail" key="list" class="digital-work-records-list-stage"> <EnterpriseListPage
<div class="list-toolbar"> v-if="!selectedRunDetail"
<div class="filter-set"> key="list"
<label class="search-filter"> variant="digital-employee-list-panel digital-work-records-list-stage"
<i class="mdi mdi-magnify"></i> :panel="false"
<input :loading="loading && !runs.length"
v-model="listKeyword" :error="errorMessage"
type="search" :empty="!visibleRuns.length"
placeholder="搜索摘要、Run ID..." :empty-state="workRecordsEmptyState"
/> :show-pagination="!loading && !errorMessage && visibleRuns.length > 0"
</label> :current-page="currentPage"
:page-size="pageSize"
<AuditPickerFilter :pages="pageNumbers"
id="module" :show-page-size="false"
title="选择工作模块" :summary="paginationSummary"
close-label="关闭选择" :total="filteredRuns.length"
:active-filter-popover="activeFilterPopover" :total-pages="totalPages"
:label="activeModule === '全部' ? '工作模块' : activeModule" loading-title="工作记录同步中"
:options="modulePickerOptions" loading-message="正在读取数字员工近期执行记录"
:selected-value="activeModule" loading-icon="mdi mdi-clipboard-text-clock-outline"
@toggle="toggleFilterPopover" error-title="工作记录加载失败"
@close="closeFilterPopover" @update:current-page="currentPage = $event"
@select="selectModule" >
<template #filters>
<label class="list-search">
<i class="mdi mdi-magnify"></i>
<input
v-model="listKeyword"
type="search"
placeholder="搜索摘要Run ID..."
/> />
</label>
<AuditPickerFilter <AuditPickerFilter
id="status" id="module"
title="选择执行状态" title="选择工作模块"
close-label="关闭选择" close-label="关闭选择"
:active-filter-popover="activeFilterPopover" :active-filter-popover="activeFilterPopover"
:label="activeStatus === '全部' ? '执行状态' : activeStatus" :label="activeModule === '全部' ? '工作模块' : activeModule"
:options="statusPickerOptions" :options="modulePickerOptions"
:selected-value="activeStatus" :selected-value="activeModule"
@toggle="toggleFilterPopover" @toggle="toggleFilterPopover"
@close="closeFilterPopover" @close="closeFilterPopover"
@select="selectStatus" @select="selectModule"
/>
</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"
/> />
<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> <colgroup>
<col class="col-time"> <col class="col-time">
<col class="col-module"> <col class="col-module">
@@ -132,12 +142,9 @@
<td>{{ resolveWorkRecordModuleLabel(run) }}</td> <td>{{ resolveWorkRecordModuleLabel(run) }}</td>
<td>{{ resolveWorkRecordSourceLabel(run.source) }}</td> <td>{{ resolveWorkRecordSourceLabel(run.source) }}</td>
<td> <td>
<div class="work-record-status-stack"> <span class="status-tag" :class="resolveWorkRecordStatusTone(run)">
<span class="status-pill" :class="resolveWorkRecordStatusTone(run)"> {{ resolveWorkRecordStatusLabel(run) }}
{{ resolveWorkRecordStatusLabel(run) }} </span>
</span>
<span>{{ resolveWorkRecordStatusNote(run) }}</span>
</div>
</td> </td>
<td class="work-record-summary-cell"> <td class="work-record-summary-cell">
<strong>{{ resolveWorkRecordTitle(run) }}</strong> <strong>{{ resolveWorkRecordTitle(run) }}</strong>
@@ -148,30 +155,8 @@
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </template>
</EnterpriseListPage>
<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>
<!-- 详情视图 (全屏样式,参考 AuditJsonRiskRuleDetail) --> <!-- 详情视图 (全屏样式,参考 AuditJsonRiskRuleDetail) -->
<div v-else key="detail" class="json-risk-editor-shell panel work-records-detail-stage"> <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 { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import AuditPickerFilter from './AuditPickerFilter.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 TableLoadingState from '../shared/TableLoadingState.vue'
import { fetchAgentRunDetail, fetchAgentRuns } from '../../services/agentAssets.js' import { fetchAgentRunDetail, fetchAgentRuns } from '../../services/agentAssets.js'
import { useToast } from '../../composables/useToast.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 { import {
formatWorkRecordDateTime, formatWorkRecordDateTime,
formatWorkRecordSummary, formatWorkRecordSummary,
@@ -330,6 +319,7 @@ const detailLoading = ref(false)
const detailError = ref('') const detailError = ref('')
const selectedRunId = ref('') const selectedRunId = ref('')
const selectedRunDetail = ref(null) const selectedRunDetail = ref(null)
const refreshInterval = ref(DEFAULT_REFRESH_INTERVAL_MS)
watch(detailOpen, (newVal) => { watch(detailOpen, (newVal) => {
emit('detail-open-change', newVal) emit('detail-open-change', newVal)
@@ -392,6 +382,14 @@ const listKeyword = ref('')
const activeModule = ref('全部') const activeModule = ref('全部')
const activeStatus = ref('全部') const activeStatus = ref('全部')
const activeFilterPopover = ref('') const activeFilterPopover = ref('')
const workRecordsEmptyState = {
eyebrow: '工作记录',
title: '暂无匹配的工作记录',
description: '当前没有符合搜索条件的数字员工工作记录。',
icon: 'mdi mdi-clipboard-text-clock-outline',
tone: 'theme',
artLabel: 'RECORDS'
}
const modulePickerOptions = computed(() => { const modulePickerOptions = computed(() => {
const set = new Set(runs.value.map((run) => resolveWorkRecordModuleLabel(run))) 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 })) ...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 activeFilterTokens = computed(() => {
const tokens = [] const tokens = []
@@ -449,6 +452,9 @@ const pageNumbers = computed(() => {
const start = Math.max(1, Math.min(currentPage.value - 3, total - 6)) const start = Math.max(1, Math.min(currentPage.value - 3, total - 6))
return Array.from({ length: 7 }, (_, index) => start + index) return Array.from({ length: 7 }, (_, index) => start + index)
}) })
const paginationSummary = computed(() =>
`共 ${filteredRuns.value.length} 条,目前第 ${currentPage.value} / ${totalPages.value} 页`
)
watch( watch(
() => [listKeyword.value, activeModule.value, activeStatus.value], () => [listKeyword.value, activeModule.value, activeStatus.value],
@@ -486,6 +492,12 @@ function selectStatus(val) {
closeFilterPopover() closeFilterPopover()
} }
function changeRefreshInterval(value) {
refreshInterval.value = Number(value) || DEFAULT_REFRESH_INTERVAL_MS
closeFilterPopover()
startPolling()
}
function resetFilters() { function resetFilters() {
listKeyword.value = '' listKeyword.value = ''
activeModule.value = '全部' activeModule.value = '全部'
@@ -564,7 +576,7 @@ function startPolling() {
stopPolling() stopPolling()
pollTimer = window.setInterval(() => { pollTimer = window.setInterval(() => {
loadWorkRecords(false) loadWorkRecords(false)
}, AGENT_RUN_POLL_INTERVAL_MS) }, refreshInterval.value)
} }
function stopPolling() { 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.css"></style>
<style scoped src="../../assets/styles/views/audit-view-part2.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> <style scoped>
.digital-employee-list-panel { .digital-work-records {
height: 100%;
}
.digital-employee-list-panel,
.digital-work-records-list-stage {
flex: 1 1 0; flex: 1 1 0;
min-height: 0; min-height: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 0;
overflow: hidden; 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; flex: 1 1 0;
min-height: 0; min-height: 0;
} }
.digital-employee-list-panel .digital-employee-pagination { .digital-work-records :deep(.toolbar-actions .picker-filter),
flex: 0 0 auto; .digital-work-records :deep(.toolbar-actions .picker-trigger) {
display: grid; min-width: 148px;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 16px;
} }
.digital-employee-list-panel .digital-employee-pagination .page-summary { .digital-refresh-now {
justify-self: start; width: 40px;
min-width: 40px;
padding: 0;
} }
.digital-employee-list-panel .pager { .digital-refresh-now .mdi {
display: inline-flex; font-size: 18px;
justify-content: center;
gap: 6px;
padding: 4px;
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #f8fafc;
} }
.digital-employee-list-panel .pager button { .work-record-detail-body.inline-detail {
width: 32px; padding: 0;
height: 32px; overflow: visible;
border: 0;
border-radius: 9px;
background: transparent; 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> </style>

View File

@@ -150,6 +150,7 @@ const {
startDocumentInboxPolling, startDocumentInboxPolling,
stopDocumentInboxPolling stopDocumentInboxPolling
} = useDocumentCenterInbox() } = useDocumentCenterInbox()
let inboxInitialRefreshTimer = null
const sidebarMeta = { const sidebarMeta = {
overview: { label: '分析看板' }, overview: { label: '分析看板' },
@@ -159,7 +160,6 @@ const sidebarMeta = {
policies: { label: '知识管理' }, policies: { label: '知识管理' },
audit: { label: '规则中心' }, audit: { label: '规则中心' },
digitalEmployees: { label: '数字员工' }, digitalEmployees: { label: '数字员工' },
logs: { label: '系统日志' },
employees: { label: '员工管理' }, employees: { label: '员工管理' },
settings: { 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(() => { onMounted(() => {
void refreshDocumentInbox() scheduleInboxInitialRefresh()
startDocumentInboxPolling() startDocumentInboxPolling()
}) })
@@ -271,7 +290,18 @@ watch(
} }
) )
watch(
() => props.activeView,
(activeView, previousView) => {
if (activeView === 'documents' && previousView !== 'documents') {
clearInboxInitialRefreshTimer()
void refreshDocumentInbox({ force: true })
}
}
)
onBeforeUnmount(() => { onBeforeUnmount(() => {
clearInboxInitialRefreshTimer()
stopDocumentInboxPolling() stopDocumentInboxPolling()
closeCollapsedUserMenuNow() closeCollapsedUserMenuNow()
}) })

View File

@@ -149,16 +149,6 @@
</div> </div>
</template> </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"> <template v-else-if="showDigitalEmployeeWorkRecordKpis">
<div class="kpi-chips"> <div class="kpi-chips">
<div <div
@@ -229,10 +219,6 @@ const props = defineProps({
type: Object, type: Object,
default: () => null default: () => null
}, },
logsSummary: {
type: Object,
default: () => null
},
requestSummary: { requestSummary: {
type: Object, type: Object,
default: () => null default: () => null
@@ -253,10 +239,6 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: false default: false
}, },
logDetailMode: {
type: Boolean,
default: false
},
detailAlerts: { detailAlerts: {
type: Array, type: Array,
default: () => [] default: () => []
@@ -286,7 +268,6 @@ const isWorkbench = computed(() => props.activeView === 'workbench')
const isRequestDetail = computed(() => ['requests', 'documents', 'audit', 'digitalEmployees'].includes(props.activeView) && props.detailMode) const isRequestDetail = computed(() => ['requests', 'documents', 'audit', 'digitalEmployees'].includes(props.activeView) && props.detailMode)
const isDocuments = computed(() => props.activeView === 'documents' && !props.detailMode) const isDocuments = computed(() => props.activeView === 'documents' && !props.detailMode)
const isRequests = computed(() => props.activeView === 'requests') const isRequests = computed(() => props.activeView === 'requests')
const isLogs = computed(() => props.activeView === 'logs' && !props.logDetailMode)
const isDigitalEmployees = computed(() => props.activeView === 'digitalEmployees') const isDigitalEmployees = computed(() => props.activeView === 'digitalEmployees')
const isApproval = computed(() => props.activeView === 'approval') const isApproval = computed(() => props.activeView === 'approval')
const isPolicies = computed(() => props.activeView === 'policies') 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 showDigitalEmployeeWorkRecordKpis = computed(() => {
const summary = props.digitalEmployeeSummary ?? {} const summary = props.digitalEmployeeSummary ?? {}
return isDigitalEmployees.value && summary.section === 'workRecords' return isDigitalEmployees.value && summary.section === 'workRecords'

View 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>

View File

@@ -1,25 +1,30 @@
<template> <template>
<div <span v-if="floating" class="table-loading-anchor" aria-hidden="true"></span>
class="table-loading" <Teleport to="body" :disabled="!floating">
:class="[variant, tone]" <div
role="status" class="table-loading"
:aria-label="ariaLabel" :class="[variant, tone, { 'screen-floating': floating, 'modal-floating': floating && blocking }]"
aria-live="polite" role="status"
> :aria-label="ariaLabel"
<span class="table-loading__spinner" aria-hidden="true"> aria-live="polite"
<i :class="icon"></i> >
</span> <FloatingLightBandWindow
:icon="icon"
<div v-if="hasCopy" class="table-loading__copy"> :message="message"
<strong v-if="title">{{ title }}</strong> :motion="motion"
<p v-if="message">{{ message }}</p> :title="title"
:tone="tone"
:variant="variant"
/>
</div> </div>
</div> </Teleport>
</template> </template>
<script setup> <script setup>
import { computed } from 'vue' import { computed } from 'vue'
import FloatingLightBandWindow from './FloatingLightBandWindow.vue'
const props = defineProps({ const props = defineProps({
variant: { variant: {
type: String, type: String,
@@ -34,50 +39,75 @@ const props = defineProps({
title: { type: String, default: '' }, title: { type: String, default: '' },
message: { type: String, default: '' }, message: { type: String, default: '' },
icon: { type: String, default: 'mdi mdi-loading' }, 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 }, showSkeleton: { type: Boolean, default: true },
skeletonRows: { type: Number, default: 5 } skeletonRows: { type: Number, default: 5 }
}) })
const hasCopy = computed(() => Boolean(props.title || props.message))
const ariaLabel = computed(() => [props.title, props.message].filter(Boolean).join(', ') || 'Loading') const ariaLabel = computed(() => [props.title, props.message].filter(Boolean).join(', ') || 'Loading')
</script> </script>
<style scoped> <style scoped>
.table-loading { .table-loading {
--accent: var(--theme-primary);
--accent-deep: var(--theme-primary-active);
width: 100%; width: 100%;
color: #64748b; color: #64748b;
} }
.table-loading.theme, .table-loading-anchor {
.table-loading.sky { display: block;
--accent: var(--theme-primary); width: 0;
--accent-deep: var(--theme-primary-active); height: 0;
overflow: hidden;
} }
.table-loading.success { .table-loading.screen-floating {
--accent: var(--success); position: fixed;
--accent-deep: var(--success-hover); 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 { .table-loading.panel {
min-height: 220px; min-height: 220px;
display: grid; display: grid;
place-items: center; place-items: center;
gap: 12px;
padding: 28px 24px; padding: 28px 24px;
text-align: center; }
.table-loading.panel.screen-floating {
min-height: 100dvh;
padding: 24px;
} }
.table-loading.detail { .table-loading.detail {
min-height: 180px; min-height: 180px;
display: flex; display: grid;
align-items: center; align-items: center;
gap: 14px; justify-items: center;
padding: 22px 24px; padding: 22px 24px;
text-align: left;
} }
.table-loading.overlay, .table-loading.overlay,
@@ -97,86 +127,9 @@ const ariaLabel = computed(() => [props.title, props.message].filter(Boolean).jo
} }
.table-loading.banner { .table-loading.banner {
display: inline-flex; display: block;
align-items: center;
gap: 8px;
min-height: 0; min-height: 0;
padding: 0; padding: 0;
color: #255b7d; 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> </style>

View File

@@ -45,6 +45,7 @@ export function useAppShell() {
filteredRequests, filteredRequests,
approveRequest, approveRequest,
rejectRequest, rejectRequest,
ensureLoaded: ensureRequestsLoaded,
reload: reloadRequests reload: reloadRequests
} = useRequests() } = useRequests()
const { currentUser } = useSystemState() const { currentUser } = useSystemState()
@@ -83,23 +84,19 @@ export function useAppShell() {
}) })
const detailMode = computed(() => route.name === 'app-document-detail') 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 detailAlerts = computed(() => (detailMode.value ? buildDetailAlerts(selectedRequest.value) : []))
const documentsListActive = computed(() => activeView.value === 'documents' && !detailMode.value) const requestsNeeded = computed(() => ['documents', 'workbench'].includes(activeView.value))
const workbenchActive = computed(() => activeView.value === 'workbench')
watch(documentsListActive, (isActive, wasActive) => { watch(
if (isActive && !wasActive) { requestsNeeded,
void reloadRequests() (isNeeded) => {
} if (isNeeded) {
}) void ensureRequestsLoaded()
}
watch(workbenchActive, (isActive, wasActive) => { },
if (isActive && !wasActive) { { immediate: true }
void reloadRequests() )
}
})
const workbenchSummary = computed(() => const workbenchSummary = computed(() =>
buildWorkbenchSummary(requests.value, currentUser.value) buildWorkbenchSummary(requests.value, currentUser.value)
@@ -118,13 +115,6 @@ export function useAppShell() {
} }
} }
if (logDetailMode.value) {
return {
title: '日志详情',
desc: '查看单条日志的解析结果、上下文信息与原始记录。'
}
}
return currentView.value return currentView.value
}) })
@@ -353,7 +343,6 @@ export function useAppShell() {
currentView, currentView,
customRange, customRange,
detailMode, detailMode,
logDetailMode,
filteredRequests, filteredRequests,
filters, filters,
handleApprove, handleApprove,

View File

@@ -18,7 +18,10 @@ const SOURCE_PRIORITY = {
const documentRows = ref([]) const documentRows = ref([])
const viewedDocumentKeys = ref(readViewedDocumentKeys()) const viewedDocumentKeys = ref(readViewedDocumentKeys())
const loading = ref(false) const loading = ref(false)
const INBOX_CACHE_TTL_MS = 30000
let refreshTimer = null let refreshTimer = null
let refreshPromise = null
let lastRefreshAt = 0
let viewedKeysListenerAttached = false let viewedKeysListenerAttached = false
function normalizeClaimText(...values) { function normalizeClaimText(...values) {
@@ -125,10 +128,22 @@ export function useDocumentCenterInbox() {
const unreadCount = computed(() => countNewDocuments(documentRows.value, viewedDocumentKeys.value)) const unreadCount = computed(() => countNewDocuments(documentRows.value, viewedDocumentKeys.value))
const hasUnread = computed(() => unreadCount.value > 0) 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 loading.value = true
try { refreshPromise = (async () => {
const [ownedResult, approvalResult, archiveResult] = await Promise.allSettled([ const [ownedResult, approvalResult, archiveResult] = await Promise.allSettled([
readClaimList(fetchExpenseClaims), readClaimList(fetchExpenseClaims),
readClaimList(fetchApprovalExpenseClaims), readClaimList(fetchApprovalExpenseClaims),
@@ -140,13 +155,21 @@ export function useDocumentCenterInbox() {
approvalClaims: approvalResult.status === 'fulfilled' ? approvalResult.value : [], approvalClaims: approvalResult.status === 'fulfilled' ? approvalResult.value : [],
archivedClaims: archiveResult.status === 'fulfilled' ? archiveResult.value : [] archivedClaims: archiveResult.status === 'fulfilled' ? archiveResult.value : []
}) })
lastRefreshAt = Date.now()
refreshViewedDocumentKeys() refreshViewedDocumentKeys()
return documentRows.value
})()
try {
return await refreshPromise
} finally { } finally {
loading.value = false loading.value = false
refreshPromise = null
} }
} }
function startDocumentInboxPolling(intervalMs = 45000) { function startDocumentInboxPolling(intervalMs = 120000) {
stopDocumentInboxPolling() stopDocumentInboxPolling()
if (typeof window === 'undefined') { if (typeof window === 'undefined') {

View 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
}

View File

@@ -12,7 +12,6 @@ export const appViews = [
'digitalEmployees', 'digitalEmployees',
'employees', 'employees',
'policies', 'policies',
'logs',
'settings' 'settings'
] ]
@@ -81,14 +80,6 @@ export const navItems = [
title: '制度与知识库', title: '制度与知识库',
desc: '统一管理制度文档、检索入口与知识资产。' desc: '统一管理制度文档、检索入口与知识资产。'
}, },
{
id: 'logs',
label: '系统日志',
navHint: '查看系统运行日志',
icon: icons.logs,
title: '系统日志',
desc: '集中查看系统运行日志、结构化事件和请求追踪信息。'
},
{ {
id: 'settings', id: 'settings',
label: '系统设置', label: '系统设置',
@@ -107,18 +98,21 @@ const viewRouteNames = {
policies: 'app-policies', policies: 'app-policies',
audit: 'app-audit', audit: 'app-audit',
digitalEmployees: 'app-digitalEmployees', digitalEmployees: 'app-digitalEmployees',
logs: 'app-logs',
employees: 'app-employees', employees: 'app-employees',
settings: 'app-settings' settings: 'app-settings'
} }
const legacyViewRouteNames = {
logs: 'app-settings'
}
const routeNameViews = Object.fromEntries( const routeNameViews = Object.fromEntries(
Object.entries(viewRouteNames).map(([view, routeName]) => [routeName, view]) Object.entries(viewRouteNames).map(([view, routeName]) => [routeName, view])
) )
routeNameViews['app-request-detail'] = 'documents' routeNameViews['app-request-detail'] = 'documents'
routeNameViews['app-document-detail'] = 'documents' routeNameViews['app-document-detail'] = 'documents'
routeNameViews['app-log-detail'] = 'logs' routeNameViews['app-log-detail'] = 'settings'
export function resolveAppViewFromRoute(route) { export function resolveAppViewFromRoute(route) {
const routeName = String(route?.name || '').trim() const routeName = String(route?.name || '').trim()
@@ -131,7 +125,7 @@ export function resolveAppViewFromRoute(route) {
} }
export function resolveTargetRouteName(view) { export function resolveTargetRouteName(view) {
return viewRouteNames[view] || viewRouteNames.overview return viewRouteNames[view] || legacyViewRouteNames[view] || viewRouteNames.overview
} }
export function useNavigation() { export function useNavigation() {

View File

@@ -1024,6 +1024,7 @@ function resolveRangeMatch(activeRange, item) {
export function useRequests() { export function useRequests() {
const requests = ref([]) const requests = ref([])
const loading = ref(false) const loading = ref(false)
const loaded = ref(false)
const error = ref('') const error = ref('')
const search = ref('') const search = ref('')
const filters = reactive({ entity: '全部主体', category: '全部类型', risk: '全部状态' }) const filters = reactive({ entity: '全部主体', category: '全部类型', risk: '全部状态' })
@@ -1060,6 +1061,7 @@ export function useRequests() {
try { try {
const payload = await fetchExpenseClaims() const payload = await fetchExpenseClaims()
requests.value = Array.isArray(payload) ? payload.map((item) => mapExpenseClaimToRequest(item)) : [] requests.value = Array.isArray(payload) ? payload.map((item) => mapExpenseClaimToRequest(item)) : []
loaded.value = true
} catch (nextError) { } catch (nextError) {
requests.value = [] requests.value = []
error.value = nextError instanceof Error ? nextError.message : '个人报销列表加载失败。' error.value = nextError instanceof Error ? nextError.message : '个人报销列表加载失败。'
@@ -1076,11 +1078,14 @@ export function useRequests() {
return `${request.id} 未执行本地状态变更,列表当前只展示后端真实数据。` return `${request.id} 未执行本地状态变更,列表当前只展示后端真实数据。`
} }
void reload() function ensureLoaded() {
return loaded.value ? Promise.resolve() : reload()
}
return { return {
requests, requests,
loading, loading,
loaded,
error, error,
search, search,
filters, filters,
@@ -1089,6 +1094,7 @@ export function useRequests() {
filteredRequests, filteredRequests,
approveRequest, approveRequest,
rejectRequest, rejectRequest,
ensureLoaded,
reload reload
} }
} }

View File

@@ -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 { useSystemState } from './useSystemState.js'
import { useThemeSkin } from './useThemeSkin.js' import { useThemeSkin } from './useThemeSkin.js'
@@ -26,7 +27,20 @@ import {
readStoredSettings readStoredSettings
} from '../utils/settingsModelHelper.js' } 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() { export function useSettings() {
const route = useRoute()
const router = useRouter()
const { toast } = useToast() const { toast } = useToast()
const { companyProfile, currentUser, updateCompanyProfilePreview } = useSystemState() const { companyProfile, currentUser, updateCompanyProfilePreview } = useSystemState()
const { const {
@@ -38,7 +52,7 @@ export function useSettings() {
const buildResolvedDefaults = () => buildDefaultState(companyProfile.value, currentUser.value) const buildResolvedDefaults = () => buildDefaultState(companyProfile.value, currentUser.value)
const pageState = ref(mergeState(buildResolvedDefaults(), readStoredSettings())) const pageState = ref(mergeState(buildResolvedDefaults(), readStoredSettings()))
const activeSection = ref('profile') const activeSection = ref(resolveInitialSectionId(route))
const sessionRetentionPickerOpen = ref(false) const sessionRetentionPickerOpen = ref(false)
const sessionRetentionPickerRef = ref(null) const sessionRetentionPickerRef = ref(null)
const logoInputRef = ref(null) const logoInputRef = ref(null)
@@ -55,6 +69,7 @@ export function useSettings() {
const sectionStatus = computed(() => computeSectionStatus(pageState.value)) const sectionStatus = computed(() => computeSectionStatus(pageState.value))
const completedSectionCount = computed(() => Object.values(sectionStatus.value).filter(Boolean).length) const completedSectionCount = computed(() => Object.values(sectionStatus.value).filter(Boolean).length)
const systemLogDetailMode = computed(() => route.name === 'app-log-detail')
const activeSectionConfig = computed( const activeSectionConfig = computed(
() => sections.find((section) => section.id === activeSection.value) || sections[0] () => 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 sessionRetentionPickerOpen.value = false
activeSection.value = sectionId activeSection.value = nextSectionId
if (!options.skipRouteSync) {
syncActiveSectionRoute(nextSectionId)
}
} }
function toggleBoolean(formKey, field) { function toggleBoolean(formKey, field) {
@@ -447,6 +490,10 @@ export function useSettings() {
return return
} }
if (activeSection.value === 'systemLogs') {
return
}
if (activeSection.value === 'rendering') { if (activeSection.value === 'rendering') {
await saveRenderingSection() await saveRenderingSection()
return return
@@ -462,6 +509,16 @@ export function useSettings() {
loadSettingsSnapshot() loadSettingsSnapshot()
}) })
watch(
() => [route.name, route.query.section],
() => {
const nextSectionId = resolveInitialSectionId(route)
if (activeSection.value !== nextSectionId) {
activateSection(nextSectionId, { skipRouteSync: true })
}
}
)
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (typeof document !== 'undefined') { if (typeof document !== 'undefined') {
document.removeEventListener('pointerdown', handleDocumentPointerDown) document.removeEventListener('pointerdown', handleDocumentPointerDown)
@@ -512,6 +569,7 @@ export function useSettings() {
saveActiveSection, saveActiveSection,
sectionStatus, sectionStatus,
sections, sections,
systemLogDetailMode,
selectThemeSkin, selectThemeSkin,
selectSessionRetentionDays, selectSessionRetentionDays,
themeSkinOptions, themeSkinOptions,

View File

@@ -259,10 +259,10 @@ export function useSetupView(props, emit) {
}) })
const testButtonIcon = computed(() => { const testButtonIcon = computed(() => {
if ((activeSection.value === 'runtime' && props.runtimeTesting) || (activeSection.value === 'database' && props.databaseTesting)) { if ((activeSection.value === 'runtime' && props.runtimeTesting) || (activeSection.value === 'database' && props.databaseTesting)) {
return 'pi pi-spin pi-spinner' return 'mdi mdi-loading mdi-spin'
} }
return activeSection.value === 'runtime' ? 'pi pi-server' : 'pi pi-database' return activeSection.value === 'runtime' ? 'mdi mdi-server' : 'mdi mdi-database'
}) })
const canRuntimeTest = computed(() => Boolean(runtimeInputsReady.value && !props.submitting && !props.runtimeTesting && !props.databaseTesting)) const canRuntimeTest = computed(() => Boolean(runtimeInputsReady.value && !props.submitting && !props.runtimeTesting && !props.databaseTesting))

View File

@@ -3,7 +3,6 @@ import { MotionPlugin } from '@vueuse/motion'
import ElementPlus from 'element-plus' import ElementPlus from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn' import zhCn from 'element-plus/es/locale/lang/zh-cn'
import 'element-plus/dist/index.css' import 'element-plus/dist/index.css'
import 'primeicons/primeicons.css'
import App from './App.vue' import App from './App.vue'
import router from './router/index.js' import router from './router/index.js'
import { installThemeSkin } from './composables/useThemeSkin.js' import { installThemeSkin } from './composables/useThemeSkin.js'

View File

@@ -4,10 +4,11 @@ import { checkBackendHealth } from '../composables/useBackendHealth.js'
import { appViews } from '../composables/useNavigation.js' import { appViews } from '../composables/useNavigation.js'
import { useSystemState } from '../composables/useSystemState.js' import { useSystemState } from '../composables/useSystemState.js'
import { canAccessAppView } from '../utils/accessControl.js' import { canAccessAppView } from '../utils/accessControl.js'
import AppShellRouteView from '../views/AppShellRouteView.vue'
import BackendUnavailableRouteView from '../views/BackendUnavailableRouteView.vue' const AppShellRouteView = () => import('../views/AppShellRouteView.vue')
import LoginRouteView from '../views/LoginRouteView.vue' const BackendUnavailableRouteView = () => import('../views/BackendUnavailableRouteView.vue')
import SetupRouteView from '../views/SetupRouteView.vue' const LoginRouteView = () => import('../views/LoginRouteView.vue')
const SetupRouteView = () => import('../views/SetupRouteView.vue')
const appChildRoutes = appViews const appChildRoutes = appViews
.filter((view) => view !== 'documents') .filter((view) => view !== 'documents')
@@ -92,11 +93,24 @@ const router = createRouter({
}, },
{ {
path: '/app/logs/:logKind/:logId', 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', name: 'app-log-detail',
component: AppShellRouteView, component: AppShellRouteView,
meta: { meta: {
requiresAuth: true, requiresAuth: true,
appView: 'logs' appView: 'settings'
} }
}, },
...appChildRoutes.map((route) => ({ ...appChildRoutes.map((route) => ({

View File

@@ -6,7 +6,6 @@ export const DEFAULT_APP_VIEW_ORDER = [
'overview', 'overview',
'policies', 'policies',
'digitalEmployees', 'digitalEmployees',
'logs',
'employees', 'employees',
'settings' 'settings'
] ]
@@ -17,7 +16,6 @@ const VIEW_ROLE_RULES = {
budget: ['budget_monitor', 'executive'], budget: ['budget_monitor', 'executive'],
audit: ['finance'], audit: ['finance'],
digitalEmployees: ['finance'], digitalEmployees: ['finance'],
logs: ['manager'],
employees: ['manager'], employees: ['manager'],
settings: ['manager'] settings: ['manager']
} }

View 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'
}

View File

@@ -70,11 +70,19 @@ export const SECTION_DEFINITIONS = [
{ {
id: 'logs', id: 'logs',
label: '日志策略', label: '日志策略',
title: '日志与审计策略', title: '日志策略',
desc: '日志级别、留存与脱敏', desc: '日志级别、留存与路径',
longDesc: '定义系统日志级别、留存周期和审计策略,保证问题排查和合规审计可追溯。', longDesc: '定义系统日志级别、留存周期和写入路径,保证问题排查过程可追溯。',
actionLabel: '保存日志策略' actionLabel: '保存日志策略'
}, },
{
id: 'systemLogs',
label: '系统日志',
title: '系统日志',
desc: '运行事件、请求追踪与异常排查',
longDesc: '查看系统运行日志、结构化事件和请求追踪信息,作为系统设置下的排障与审计子项。',
actionLabel: ''
},
{ {
id: 'mail', id: 'mail',
label: '邮箱设置', label: '邮箱设置',
@@ -465,6 +473,7 @@ export function computeSectionStatus(state) {
Number(state.logForm.retentionDays) > 0 && Number(state.logForm.retentionDays) > 0 &&
normalizeValue(state.logForm.logPath) normalizeValue(state.logForm.logPath)
), ),
systemLogs: true,
mail: Boolean( mail: Boolean(
normalizeValue(state.mailForm.smtpHost) && normalizeValue(state.mailForm.smtpHost) &&
Number(state.mailForm.port) > 0 && Number(state.mailForm.port) > 0 &&

View File

@@ -10,16 +10,13 @@
<div class="mobile-overlay" aria-hidden="true" @click="mobileSidebarOpen = false"></div> <div class="mobile-overlay" aria-hidden="true" @click="mobileSidebarOpen = false"></div>
<Transition name="login-entry-veil"> <Transition name="login-entry-veil">
<div v-if="loginEntryAnimating" class="login-entry-veil" aria-live="polite" aria-label="登录成功,正在进入工作台"> <div v-if="loginEntryAnimating" class="login-entry-veil" aria-live="polite" aria-label="登录成功,正在进入工作台">
<div class="login-entry-card"> <FloatingLightBandWindow
<span class="login-entry-mark" aria-hidden="true"> icon="mdi mdi-shield-check-outline"
<i class="mdi mdi-shield-check-outline"></i> message="正在进入工作台"
</span> motion="entry"
<div class="login-entry-copy"> title="登录成功"
<strong>登录成功</strong> variant="entry"
<span>正在进入工作台</span> />
</div>
<span class="login-entry-progress" aria-hidden="true"></span>
</div>
</div> </div>
</Transition> </Transition>
<div class="app-sidebar"> <div class="app-sidebar">
@@ -49,7 +46,6 @@
'audit-detail-main': activeView === 'audit' && auditDetailOpen, 'audit-detail-main': activeView === 'audit' && auditDetailOpen,
'digital-employees-detail-main': activeView === 'digitalEmployees' && digitalEmployeeDetailOpen, 'digital-employees-detail-main': activeView === 'digitalEmployees' && digitalEmployeeDetailOpen,
'digital-employees-main': activeView === 'digitalEmployees', 'digital-employees-main': activeView === 'digitalEmployees',
'logs-main': activeView === 'logs',
'employees-main': activeView === 'employees', 'employees-main': activeView === 'employees',
'settings-main': activeView === 'settings' 'settings-main': activeView === 'settings'
}" }"
@@ -63,13 +59,11 @@
:active-range="activeRange" :active-range="activeRange"
:employee-summary="employeeSummary" :employee-summary="employeeSummary"
:knowledge-summary="knowledgeSummary" :knowledge-summary="knowledgeSummary"
:logs-summary="logsSummary"
:request-summary="requestSummary" :request-summary="requestSummary"
:document-summary="documentSummary" :document-summary="documentSummary"
:digital-employee-summary="digitalEmployeeSummary" :digital-employee-summary="digitalEmployeeSummary"
:company-name="ENTERPRISE_DISPLAY_NAME" :company-name="ENTERPRISE_DISPLAY_NAME"
:detail-mode="resolvedDetailMode" :detail-mode="resolvedDetailMode"
:log-detail-mode="logDetailMode"
:detail-alerts="resolvedDetailAlerts" :detail-alerts="resolvedDetailAlerts"
:detail-kpis="resolvedDetailKpis" :detail-kpis="resolvedDetailKpis"
:custom-range="customRange" :custom-range="customRange"
@@ -81,7 +75,7 @@
/> />
<FilterBar <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'" :compact="activeView === 'overview'"
:filters="filters" :filters="filters"
:ranges="ranges" :ranges="ranges"
@@ -98,7 +92,6 @@
'policies-workarea': activeView === 'policies', 'policies-workarea': activeView === 'policies',
'audit-workarea': activeView === 'audit', 'audit-workarea': activeView === 'audit',
'digital-employees-workarea': activeView === 'digitalEmployees', 'digital-employees-workarea': activeView === 'digitalEmployees',
'logs-workarea': activeView === 'logs',
'employees-workarea': activeView === 'employees', 'employees-workarea': activeView === 'employees',
'settings-workarea': activeView === 'settings' 'settings-workarea': activeView === 'settings'
}" }"
@@ -157,8 +150,6 @@
@detail-open-change="digitalEmployeeDetailOpen = $event" @detail-open-change="digitalEmployeeDetailOpen = $event"
@detail-topbar-change="detailTopBarPayload = $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" /> <EmployeeManagementView v-else-if="activeView === 'employees'" @overview-change="employeeSummary = $event" />
<SettingsView v-else /> <SettingsView v-else />
</section> </section>
@@ -181,11 +172,13 @@
</template> </template>
<script setup> <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 SidebarRail from '../components/layout/SidebarRail.vue'
import TopBar from '../components/layout/TopBar.vue' import TopBar from '../components/layout/TopBar.vue'
import FilterBar from '../components/layout/FilterBar.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 { useAppShell } from '../composables/useAppShell.js'
import { useSystemState } from '../composables/useSystemState.js' import { useSystemState } from '../composables/useSystemState.js'
@@ -197,18 +190,30 @@ const PersonalWorkbenchView = defineAsyncComponent(() => import('./PersonalWorkb
const TravelReimbursementCreateView = defineAsyncComponent(() => import('./TravelReimbursementCreateView.vue')) const TravelReimbursementCreateView = defineAsyncComponent(() => import('./TravelReimbursementCreateView.vue'))
const TravelRequestDetailView = defineAsyncComponent(() => import('./TravelRequestDetailView.vue')) const TravelRequestDetailView = defineAsyncComponent(() => import('./TravelRequestDetailView.vue'))
const DocumentsCenterView = defineAsyncComponent(() => import('./DocumentsCenterView.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 PoliciesView = defineAsyncComponent(() => import('./PoliciesView.vue'))
const AuditView = defineAsyncComponent(() => import('./AuditView.vue')) const AuditView = defineAsyncComponent(() => import('./AuditView.vue'))
const DigitalEmployeesView = defineAsyncComponent(() => import('./DigitalEmployeesView.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 EmployeeManagementView = defineAsyncComponent(() => import('./EmployeeManagementView.vue'))
const SettingsView = defineAsyncComponent(() => import('./SettingsView.vue')) const SettingsView = defineAsyncComponent(() => import('./SettingsView.vue'))
const employeeSummary = ref(null) const employeeSummary = ref(null)
const knowledgeSummary = ref(null) const knowledgeSummary = ref(null)
const logsSummary = ref(null)
const documentSummary = ref(null) const documentSummary = ref(null)
const digitalEmployeeSummary = ref(null) const digitalEmployeeSummary = ref(null)
const detailTopBarPayload = ref(null) const detailTopBarPayload = ref(null)
@@ -254,7 +259,6 @@ const {
customRange, customRange,
detailAlerts, detailAlerts,
detailMode, detailMode,
logDetailMode,
filteredRequests, filteredRequests,
filters, filters,
handleApprove, handleApprove,

View File

@@ -1,5 +1,13 @@
<template> <template>
<section class="budget-center-page"> <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="预算概览"> <section class="budget-summary-grid" aria-label="预算概览">
<article <article

View File

@@ -67,7 +67,7 @@
:class="{ active: activeSection === 'skills' }" :class="{ active: activeSection === 'skills' }"
@click="activeSection = 'skills'" @click="activeSection = 'skills'"
> >
数字员工 员工能力
</button> </button>
<button <button
type="button" type="button"

View File

@@ -140,6 +140,7 @@
title="单据数据同步中" title="单据数据同步中"
message="正在汇总当前报销、审批待办与归档单据" message="正在汇总当前报销、审批待办与归档单据"
icon="mdi mdi-file-document-multiple-outline" icon="mdi mdi-file-document-multiple-outline"
floating
/> />
</div> </div>
@@ -246,6 +247,7 @@ import { computed, onMounted, ref, watch } from 'vue'
import EnterpriseSelect from '../components/shared/EnterpriseSelect.vue' import EnterpriseSelect from '../components/shared/EnterpriseSelect.vue'
import TableEmptyState from '../components/shared/TableEmptyState.vue' import TableEmptyState from '../components/shared/TableEmptyState.vue'
import TableLoadingState from '../components/shared/TableLoadingState.vue' import TableLoadingState from '../components/shared/TableLoadingState.vue'
import { useMinimumVisibleState } from '../composables/useMinimumVisibleState.js'
import { mapExpenseClaimToRequest } from '../composables/useRequests.js' import { mapExpenseClaimToRequest } from '../composables/useRequests.js'
import { fetchApprovalExpenseClaims, fetchArchivedExpenseClaims } from '../services/reimbursements.js' import { fetchApprovalExpenseClaims, fetchArchivedExpenseClaims } from '../services/reimbursements.js'
import { countNewDocuments, isNewDocument, markDocumentViewed, readDocumentScope, readViewedDocumentKeys, writeDocumentScope } from '../utils/documentCenterNewState.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_REVIEW = '审核单'
const DOCUMENT_SCOPE_ARCHIVE = '归档' const DOCUMENT_SCOPE_ARCHIVE = '归档'
const scopeTabs = [DOCUMENT_SCOPE_ALL, DOCUMENT_SCOPE_APPLICATION, DOCUMENT_SCOPE_REIMBURSEMENT, DOCUMENT_SCOPE_REVIEW, 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 statusTabs = ['全部', '草稿', '待提交', '审批中', '待补充', '待付款', '已完成']
const FILTER_CONFIG_BY_SCOPE = { const FILTER_CONFIG_BY_SCOPE = {
[DOCUMENT_SCOPE_ALL]: { [DOCUMENT_SCOPE_ALL]: {
@@ -465,7 +468,11 @@ const visibleRows = computed(() => {
return filteredRows.value.slice(start, start + pageSize.value) 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 showError = computed(() => Boolean(props.error) && !visibleRows.value.length)
const errorMessage = computed(() => props.error || supportingError.value || '单据中心加载失败。') const errorMessage = computed(() => props.error || supportingError.value || '单据中心加载失败。')
const showEmpty = computed(() => !showLoading.value && !showError.value && visibleRows.value.length === 0) const showEmpty = computed(() => !showLoading.value && !showError.value && visibleRows.value.length === 0)

View File

@@ -567,6 +567,7 @@
title="员工数据同步中" title="员工数据同步中"
message="正在加载员工档案与角色权限" message="正在加载员工档案与角色权限"
icon="mdi mdi-account-group-outline" icon="mdi mdi-account-group-outline"
floating
/> />
</div> </div>

View File

@@ -184,7 +184,7 @@
<footer class="detail-actions"> <footer class="detail-actions">
<button class="back-action" type="button" @click="backToLogs"> <button class="back-action" type="button" @click="backToLogs">
<i class="mdi mdi-arrow-left"></i> <i class="mdi mdi-arrow-left"></i>
<span>返回日志列表</span> <span>返回系统日志</span>
</button> </button>
</footer> </footer>
</section> </section>
@@ -436,7 +436,7 @@ async function loadDetail(options = {}) {
} }
function backToLogs() { function backToLogs() {
router.push({ name: 'app-logs' }) router.push({ name: 'app-settings', query: { section: 'systemLogs' } })
} }
watch( watch(

View File

@@ -100,14 +100,44 @@
<i class="mdi mdi-filter-remove-outline"></i> <i class="mdi mdi-filter-remove-outline"></i>
<span>清空筛选</span> <span>清空筛选</span>
</button> </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 <button
type="button" type="button"
class="create-request-btn" class="create-request-btn icon-refresh-action"
:disabled="systemLogLoading" :disabled="systemLogLoading"
aria-label="立即刷新系统日志"
@click="loadSystemLogs(true)" @click="loadSystemLogs(true)"
> >
<i class="mdi mdi-refresh"></i> <i :class="systemLogLoading ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-refresh'"></i>
<span>{{ systemLogLoading ? '刷新中...' : '刷新日志' }}</span>
</button> </button>
</template> </template>

View File

@@ -65,6 +65,14 @@
</div> </div>
<div class="doc-table-wrap"> <div class="doc-table-wrap">
<TableLoadingState
v-if="loading && !visibleDocuments.length"
title="知识库文件同步中"
message="正在加载当前文件夹的知识库文件"
icon="mdi mdi-folder-table-outline"
floating
/>
<table class="knowledge-document-table"> <table class="knowledge-document-table">
<thead> <thead>
<tr> <tr>
@@ -129,16 +137,7 @@
</div> </div>
</td> </td>
</tr> </tr>
<tr v-if="loading && !visibleDocuments.length"> <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">
<td colspan="8" class="empty-row"> <td colspan="8" class="empty-row">
当前文件夹暂无文件 当前文件夹暂无文件
</td> </td>

View File

@@ -36,14 +36,14 @@
</div> </div>
<div class="settings-toolbar-actions"> <div class="settings-toolbar-actions">
<button class="save-button" type="button" @click="saveActiveSection"> <button v-if="activeSectionConfig.actionLabel" class="save-button" type="button" @click="saveActiveSection">
<i class="mdi mdi-content-save-outline"></i> <i class="mdi mdi-content-save-outline"></i>
<span>{{ activeSectionConfig.actionLabel }}</span> <span>{{ activeSectionConfig.actionLabel }}</span>
</button> </button>
</div> </div>
</header> </header>
<div class="settings-content"> <div class="settings-content" :class="{ 'settings-content-fill': activeSection === 'systemLogs' }">
<template v-if="activeSection === 'profile'"> <template v-if="activeSection === 'profile'">
<section class="settings-card"> <section class="settings-card">
<div class="card-head"> <div class="card-head">
@@ -380,7 +380,7 @@
</template> </template>
<template v-else-if="activeSection === 'logs'"> <template v-else-if="activeSection === 'logs'">
<section class="settings-card"> <section class="settings-card log-policy-card">
<div class="card-head"> <div class="card-head">
<div class="card-title-with-icon"> <div class="card-title-with-icon">
<div class="model-icon-box slate"> <div class="model-icon-box slate">
@@ -388,7 +388,7 @@
</div> </div>
<div> <div>
<h4>日志级别与留存</h4> <h4>日志级别与留存</h4>
<p>定义系统记录粒度归档周期和告警接收人方便后续审计与排障</p> <p>定义系统记录粒度归档周期写入路径和告警接收人方便后续排障追踪</p>
</div> </div>
</div> </div>
</div> </div>
@@ -432,46 +432,11 @@
</label> </label>
</div> </div>
</section> </section>
</template>
<section class="settings-card"> <template v-else-if="activeSection === 'systemLogs'">
<div class="card-head"> <LogDetailView v-if="systemLogDetailMode" class="settings-log-detail-view" />
<div class="card-title-with-icon"> <LogsView v-else class="settings-logs-view" />
<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>
<template v-else-if="activeSection === 'mail'"> <template v-else-if="activeSection === 'mail'">

View File

@@ -30,7 +30,7 @@
<strong>{{ section.title }}</strong> <strong>{{ section.title }}</strong>
<small>{{ section.desc }}</small> <small>{{ section.desc }}</small>
</span> </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> </button>
</nav> </nav>
@@ -42,11 +42,11 @@
<div v-if="canSubmit" class="setup-complete"> <div v-if="canSubmit" class="setup-complete">
<p>所有必要步骤已通过检测可以写入配置并进入登录界面</p> <p>所有必要步骤已通过检测可以写入配置并进入登录界面</p>
<button class="primary-btn setup-complete-btn" type="button" :disabled="submitting" @click="submitForm"> <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> <span>{{ submitting ? '写入配置中...' : '完成初始化并进入登录' }}</span>
</button> </button>
<p v-if="progressMessage" class="setup-complete-progress"> <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> <span>{{ progressMessage }}</span>
</p> </p>
</div> </div>
@@ -240,7 +240,7 @@
<span>{{ progressMessage || '正在准备后端服务...' }}</span> <span>{{ progressMessage || '正在准备后端服务...' }}</span>
</div> </div>
<div class="setup-startup-spinner" aria-hidden="true"> <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> <strong v-else>{{ startupCountdownSeconds }}</strong>
</div> </div>
</header> </header>
@@ -358,18 +358,18 @@ const {
function startupStepIcon(status) { function startupStepIcon(status) {
if (status === 'success') { if (status === 'success') {
return 'pi pi-check-circle' return 'mdi mdi-check-circle'
} }
if (status === 'error') { if (status === 'error') {
return 'pi pi-times-circle' return 'mdi mdi-close-circle'
} }
if (status === 'running') { if (status === 'running') {
return 'pi pi-spin pi-spinner' return 'mdi mdi-loading mdi-spin'
} }
return 'pi pi-circle' return 'mdi mdi-circle-outline'
} }
</script> </script>

View File

@@ -177,12 +177,6 @@ export default {
() => () =>
normalizeText(selectedSkill.value?.ruleDocument?.file_name) || '未上传规则表' normalizeText(selectedSkill.value?.ruleDocument?.file_name) || '未上传规则表'
) )
const selectedSpreadsheetModeLabel = computed(() => {
if (selectedSkill.value?.isPreviewMock) {
return canEditSpreadsheetInline.value ? '可编辑' : '只读'
}
return canEditSpreadsheetInline.value ? '在线可编辑' : '只读'
})
const { const {
versionSwitchTarget, versionSwitchTarget,
versionTimelineOpen, versionTimelineOpen,
@@ -438,11 +432,7 @@ export default {
const auditDetailTopBar = computed(() => const auditDetailTopBar = computed(() =>
buildAuditDetailTopBar({ buildAuditDetailTopBar({
skill: selectedSkill.value, skill: selectedSkill.value,
usesJsonRiskRule: selectedSkillUsesJsonRisk.value, usesJsonRiskRule: selectedSkillUsesJsonRisk.value
usesSpreadsheetRule: selectedSkillUsesSpreadsheet.value,
spreadsheetModeLabel: selectedSpreadsheetModeLabel.value,
spreadsheetFileName: selectedSpreadsheetFileName.value,
canEditSpreadsheetInline: canEditSpreadsheetInline.value
}) })
) )
@@ -711,7 +701,6 @@ export default {
selectedSkillUsesSpreadsheet, selectedSkillUsesSpreadsheet,
selectedSkillUsesJsonRisk, selectedSkillUsesJsonRisk,
selectedSpreadsheetFileName, selectedSpreadsheetFileName,
selectedSpreadsheetModeLabel,
selectedVersionTimelineItems, selectedVersionTimelineItems,
selectedSpreadsheetChangeRecords, selectedSpreadsheetChangeRecords,
detailBusy, detailBusy,

View File

@@ -3,6 +3,7 @@ import { ElButton, ElInput, ElPagination, ElTable, ElTableColumn } from 'element
import BudgetTrendChart from '../../components/charts/BudgetTrendChart.vue' import BudgetTrendChart from '../../components/charts/BudgetTrendChart.vue'
import EnterpriseSelect from '../../components/shared/EnterpriseSelect.vue' import EnterpriseSelect from '../../components/shared/EnterpriseSelect.vue'
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
import { fetchBudgetSummary } from '../../services/budgets.js' import { fetchBudgetSummary } from '../../services/budgets.js'
import { fetchEmployeeMeta } from '../../services/employees.js' import { fetchEmployeeMeta } from '../../services/employees.js'
import { import {
@@ -217,6 +218,7 @@ export default {
components: { components: {
BudgetTrendChart, BudgetTrendChart,
EnterpriseSelect, EnterpriseSelect,
TableLoadingState,
ElButton, ElButton,
ElInput, ElInput,
ElPagination, ElPagination,
@@ -238,7 +240,7 @@ export default {
const budgetTableKeyword = ref('') const budgetTableKeyword = ref('')
const budgetRows = ref([]) const budgetRows = ref([])
const budgetSummary = ref(null) const budgetSummary = ref(null)
const budgetLoading = ref(false) const budgetLoading = ref(true)
const budgetError = ref('') const budgetError = ref('')
const canEditBudget = computed(() => canEditBudgetCenter(props.currentUser)) const canEditBudget = computed(() => canEditBudgetCenter(props.currentUser))
const canSwitchDepartments = computed(() => canSwitchBudgetDepartments(props.currentUser)) const canSwitchDepartments = computed(() => canSwitchBudgetDepartments(props.currentUser))
@@ -424,6 +426,7 @@ export default {
} }
async function loadDepartments() { async function loadDepartments() {
budgetLoading.value = true
try { try {
const payload = await fetchEmployeeMeta() const payload = await fetchEmployeeMeta()
const options = Array.isArray(payload?.organizationOptions) ? payload.organizationOptions : [] const options = Array.isArray(payload?.organizationOptions) ? payload.organizationOptions : []

View File

@@ -5,8 +5,12 @@ import EnterpriseListPage from '../../components/shared/EnterpriseListPage.vue'
import { useSystemState } from '../../composables/useSystemState.js' import { useSystemState } from '../../composables/useSystemState.js'
import { useToast } from '../../composables/useToast.js' import { useToast } from '../../composables/useToast.js'
import { fetchSystemLogEntries } from '../../services/systemLogs.js' import { fetchSystemLogEntries } from '../../services/systemLogs.js'
import { AGENT_RUN_POLL_INTERVAL_MS } from '../../utils/agentRunMonitor.js'
import { isManagerUser } from '../../utils/accessControl.js' import { isManagerUser } from '../../utils/accessControl.js'
import {
DEFAULT_REFRESH_INTERVAL_MS,
REFRESH_INTERVAL_OPTIONS,
formatRefreshInterval
} from '../../utils/refreshIntervalOptions.js'
function formatDateTime(value) { function formatDateTime(value) {
if (!value) { if (!value) {
@@ -79,6 +83,8 @@ export default {
const pageSize = ref(10) const pageSize = ref(10)
const pageSizes = [10, 20, 50] const pageSizes = [10, 20, 50]
const pageSizeOptions = pageSizes.map((size) => ({ label: `${size} 条/页`, value: size })) const pageSizeOptions = pageSizes.map((size) => ({ label: `${size} 条/页`, value: size }))
const refreshInterval = ref(DEFAULT_REFRESH_INTERVAL_MS)
const refreshIntervalOptions = REFRESH_INTERVAL_OPTIONS
let pollTimer = 0 let pollTimer = 0
const isAdmin = computed(() => isManagerUser(currentUser.value)) const isAdmin = computed(() => isManagerUser(currentUser.value))
@@ -102,6 +108,7 @@ export default {
const systemEventTypeFilterLabel = computed(() => const systemEventTypeFilterLabel = computed(() =>
systemEventTypeFilterOptions.value.find((item) => item.value === systemEventTypeFilter.value)?.label || '全部类型' systemEventTypeFilterOptions.value.find((item) => item.value === systemEventTypeFilter.value)?.label || '全部类型'
) )
const refreshIntervalLabel = computed(() => formatRefreshInterval(refreshInterval.value))
const hasActiveFilters = computed(() => const hasActiveFilters = computed(() =>
Boolean(systemSearchKeyword.value.trim() || systemLevelFilter.value || systemEventTypeFilter.value) Boolean(systemSearchKeyword.value.trim() || systemLevelFilter.value || systemEventTypeFilter.value)
) )
@@ -175,6 +182,12 @@ export default {
openFilterKey.value = '' openFilterKey.value = ''
} }
function changeRefreshInterval(value) {
refreshInterval.value = Number(value) || DEFAULT_REFRESH_INTERVAL_MS
openFilterKey.value = ''
startPolling()
}
function resetFilters() { function resetFilters() {
systemSearchKeyword.value = '' systemSearchKeyword.value = ''
systemLevelFilter.value = '' systemLevelFilter.value = ''
@@ -211,7 +224,7 @@ export default {
stopPolling() stopPolling()
pollTimer = window.setInterval(() => { pollTimer = window.setInterval(() => {
loadSystemLogs(false) loadSystemLogs(false)
}, AGENT_RUN_POLL_INTERVAL_MS) }, refreshInterval.value)
} }
function stopPolling() { function stopPolling() {
@@ -253,6 +266,7 @@ export default {
return { return {
changePageSize, changePageSize,
changeRefreshInterval,
currentPage, currentPage,
filteredSystemLogEntries, filteredSystemLogEntries,
formatDateTime, formatDateTime,
@@ -263,6 +277,9 @@ export default {
openFilterKey, openFilterKey,
pageSize, pageSize,
pageSizeOptions, pageSizeOptions,
refreshInterval,
refreshIntervalLabel,
refreshIntervalOptions,
resetFilters, resetFilters,
resolveSystemLevelTone, resolveSystemLevelTone,
resolveSystemOutcomeTone, resolveSystemOutcomeTone,

View File

@@ -1,5 +1,7 @@
import HermesEmployeeSettingsPanel from '../HermesEmployeeSettingsPanel.vue' import HermesEmployeeSettingsPanel from '../HermesEmployeeSettingsPanel.vue'
import LlmSettingsPanel from '../LlmSettingsPanel.vue' import LlmSettingsPanel from '../LlmSettingsPanel.vue'
import LogDetailView from '../LogDetailView.vue'
import LogsView from '../LogsView.vue'
import MailSettingsPanel from '../MailSettingsPanel.vue' import MailSettingsPanel from '../MailSettingsPanel.vue'
import EnterpriseSelect from '../../components/shared/EnterpriseSelect.vue' import EnterpriseSelect from '../../components/shared/EnterpriseSelect.vue'
import { useSettings } from '../../composables/useSettings.js' import { useSettings } from '../../composables/useSettings.js'
@@ -10,6 +12,8 @@ export default {
HermesEmployeeSettingsPanel, HermesEmployeeSettingsPanel,
EnterpriseSelect, EnterpriseSelect,
LlmSettingsPanel, LlmSettingsPanel,
LogDetailView,
LogsView,
MailSettingsPanel MailSettingsPanel
}, },
setup() { setup() {

View File

@@ -10,11 +10,7 @@ function resolveRiskScoreCardColor(level) {
export function buildAuditDetailTopBar({ export function buildAuditDetailTopBar({
skill, skill,
usesJsonRiskRule = false, usesJsonRiskRule = false
usesSpreadsheetRule = false,
spreadsheetModeLabel = '',
spreadsheetFileName = '',
canEditSpreadsheetInline = false
} = {}) { } = {}) {
if (!skill) return null if (!skill) return null
@@ -39,15 +35,6 @@ export function buildAuditDetailTopBar({
: 'up', : 'up',
color: resolveRiskScoreCardColor(scoreLevel) color: resolveRiskScoreCardColor(scoreLevel)
}) })
} else if (usesSpreadsheetRule) {
kpis.push({
label: '编辑模式',
value: spreadsheetModeLabel,
unit: '',
meta: spreadsheetFileName,
trend: canEditSpreadsheetInline ? 'up' : 'down',
color: canEditSpreadsheetInline ? 'var(--success)' : '#64748b'
})
} }
return { return {

View File

@@ -7,7 +7,10 @@ import {
VERSION_STATE_META VERSION_STATE_META
} from './auditViewMetadata.js' } from './auditViewMetadata.js'
import { import {
formatRiskRuleAge,
resolveRiskRuleFlow,
resolveRiskRuleScore, resolveRiskRuleScore,
resolveRiskRuleScoreDetail,
resolveRiskRuleScoreLabel, resolveRiskRuleScoreLabel,
resolveRiskRuleScoreLevel, resolveRiskRuleScoreLevel,
resolveRiskRuleSeverity, resolveRiskRuleSeverity,
@@ -71,6 +74,7 @@ import {
applyRiskRuleJsonState, applyRiskRuleJsonState,
resolveRiskRuleBusinessStage, resolveRiskRuleBusinessStage,
resolveRiskRuleEnabled, resolveRiskRuleEnabled,
resolveLastOperationLabel,
resolveRiskRuleOnlineMeta resolveRiskRuleOnlineMeta
} from './auditViewRiskRuleState.js' } from './auditViewRiskRuleState.js'
import { import {

View File

@@ -20,6 +20,7 @@ import {
} from './auditViewDataUtils.js' } from './auditViewDataUtils.js'
import { formatDateTime } from './auditViewFormatters.js' import { formatDateTime } from './auditViewFormatters.js'
import { import {
buildRiskListSubtitle,
resolveRiskRuleCategory, resolveRiskRuleCategory,
resolveRiskRuleDescription, resolveRiskRuleDescription,
resolveRiskRuleSourceRef resolveRiskRuleSourceRef
@@ -83,7 +84,7 @@ export function resolveRiskRuleOnlineMeta(statusValue) {
return { label: '待上线', tone: 'draft', online: false } return { label: '待上线', tone: 'draft', online: false }
} }
function resolveLastOperationLabel(source, fallback = {}) { export function resolveLastOperationLabel(source, fallback = {}) {
const configJson = readConfigJson(source) const configJson = readConfigJson(source)
const operation = isPlainObject(configJson.last_operation) ? configJson.last_operation : {} const operation = isPlainObject(configJson.last_operation) ? configJson.last_operation : {}
const action = normalizeText(operation.action) || normalizeText(fallback.action) || 'create' const action = normalizeText(operation.action) || normalizeText(fallback.action) || 'create'
@@ -129,9 +130,12 @@ export function applyRiskRuleJsonState(target, payload, apiPayload) {
let publishedAt = target.publishedAt || '-' let publishedAt = target.publishedAt || '-'
if (apiPayload?.recent_versions) { if (apiPayload?.recent_versions) {
const history = buildHistory(apiPayload.recent_versions, { ...target, config_json: payload }) const publishedVersionObj = apiPayload.recent_versions.find((item) =>
const publishedVersionObj = history.find((item) => item.isPublished || item.lifecycleState === 'published') item?.is_current || item?.version === apiPayload?.published_version
publishedAt = publishedVersionObj ? publishedVersionObj.time : (apiPayload?.latest_review?.reviewed_at ? formatDateTime(apiPayload.latest_review.reviewed_at) : '-') )
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) { } else if (apiPayload?.latest_review?.reviewed_at) {
publishedAt = formatDateTime(apiPayload.latest_review.reviewed_at) publishedAt = formatDateTime(apiPayload.latest_review.reviewed_at)
} }

View File

@@ -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, 'requests'), false)
assert.equal(canAccessAppView(adminUser, 'approval'), false) assert.equal(canAccessAppView(adminUser, 'approval'), false)
assert.equal(canAccessAppView(adminUser, 'archive'), false) assert.equal(canAccessAppView(adminUser, 'archive'), false)
assert.equal(canAccessAppView(adminUser, 'logs'), false)
assert.equal(canAccessAppView(adminUser, 'documents'), true) assert.equal(canAccessAppView(adminUser, 'documents'), true)
}) })

View 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()
})

View File

@@ -8,9 +8,9 @@ import {
} from '../src/composables/useNavigation.js' } from '../src/composables/useNavigation.js'
function testDerivesViewFromRouteName() { 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-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() { function testFallsBackToValidMeta() {
@@ -19,7 +19,7 @@ function testFallsBackToValidMeta() {
} }
function testResolvesMainRouteNames() { function testResolvesMainRouteNames() {
assert.equal(resolveTargetRouteName('logs'), 'app-logs') assert.equal(resolveTargetRouteName('logs'), 'app-settings')
assert.equal(resolveTargetRouteName('policies'), 'app-policies') assert.equal(resolveTargetRouteName('policies'), 'app-policies')
assert.equal(resolveTargetRouteName('requests'), 'app-overview') assert.equal(resolveTargetRouteName('requests'), 'app-overview')
assert.equal(resolveTargetRouteName('approval'), 'app-overview') assert.equal(resolveTargetRouteName('approval'), 'app-overview')
@@ -31,7 +31,8 @@ function testLegacyCentersAreRemovedFromNavigation() {
assert.equal(appViews.includes('requests'), false) assert.equal(appViews.includes('requests'), false)
assert.equal(appViews.includes('approval'), false) assert.equal(appViews.includes('approval'), false)
assert.equal(appViews.includes('archive'), 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() { function run() {

View 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/)
})

View 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' \} \}\)/)
})

View File

@@ -1053,17 +1053,31 @@ export default defineConfig({
if (!id.includes('node_modules')) { if (!id.includes('node_modules')) {
return undefined 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' return 'vendor-element-plus'
} }
if (id.includes('echarts') || id.includes('zrender')) { if (normalizedId.includes('echarts') || normalizedId.includes('zrender')) {
return 'vendor-echarts' return 'vendor-echarts'
} }
if (id.includes('@vueuse')) { if (normalizedId.includes('@antv/g6')) {
return 'vendor-vueuse' return 'vendor-g6'
} }
if (id.includes('primeicons') || id.includes('primevue')) { if (normalizedId.includes('chart.js') || normalizedId.includes('vue-chartjs')) {
return 'vendor-prime' return 'vendor-chartjs'
}
if (normalizedId.includes('markdown-it')) {
return 'vendor-markdown'
}
if (normalizedId.includes('@vueuse')) {
return 'vendor-vueuse'
} }
return 'vendor' return 'vendor'
} }