3 Commits

Author SHA1 Message Date
caoxiaozhu
e080105f9f feat(ui): finalize shared shells and loading states 2026-05-29 13:17:39 +08:00
caoxiaozhu
64cc76c970 refactor(audit): reuse list shells and split models 2026-05-29 10:13:49 +08:00
caoxiaozhu
99e90798d2 refactor(audit): split list detail flows 2026-05-29 09:44:03 +08:00
78 changed files with 5616 additions and 4392 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);
@@ -241,10 +178,6 @@
.main.settings-main { .main.settings-main {
grid-template-rows: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr);
} }
.main.audit-detail-main,
.main.digital-employees-detail-main {
grid-template-rows: minmax(0, 1fr);
}
.workarea { min-height: 0; overflow: auto; padding: 24px; } .workarea { min-height: 0; overflow: auto; padding: 24px; }
.workarea.requests-workarea, .workarea.requests-workarea,
.workarea.documents-workarea, .workarea.documents-workarea,
@@ -254,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;
@@ -271,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;
@@ -405,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

@@ -243,6 +243,22 @@
gap: 10px; gap: 10px;
} }
.detail-topbar-actions {
display: flex;
align-items: flex-start;
justify-content: flex-end;
gap: 10px;
flex-wrap: wrap;
}
.detail-kpi-chips {
justify-content: flex-end;
}
.detail-kpi-chip {
min-width: 142px;
}
.detail-alert-strip { .detail-alert-strip {
display: flex; display: flex;
align-items: center; align-items: center;

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

@@ -1337,213 +1337,6 @@
overflow: hidden; overflow: hidden;
} }
.asset-detail-topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 0 10px;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
}
.asset-detail-topbar.panel {
padding: 14px 0 10px;
border: 0;
background: transparent;
box-shadow: none;
}
.asset-detail-topbar-main {
flex: 1 1 auto;
min-width: 0;
}
.asset-detail-topbar-main h2 {
margin: 0;
color: #0f172a;
font-size: 18px;
font-weight: 850;
line-height: 1.25;
}
.asset-detail-topbar-main p {
flex-basis: 100%;
margin: 0;
max-width: 860px;
color: #64748b;
font-size: 13px;
line-height: 1.5;
}
.asset-detail-topbar-meta {
flex: 0 0 auto;
justify-content: flex-end;
}
.asset-detail-topbar .hero-review-meta {
flex-basis: 100%;
margin-top: 2px;
}
.asset-detail-topbar .review-note-block {
flex-basis: 100%;
margin-top: 4px;
}
.asset-detail-topbar .hero-stats {
display: flex;
align-items: center;
flex-wrap: wrap;
}
.asset-detail-topbar .hero-stat {
min-height: 30px;
padding: 0 10px;
border-radius: 999px;
background: #f8fafc;
}
.asset-detail-topbar .hero-stat span {
display: none;
}
.asset-detail-topbar .hero-stat strong {
font-size: 12px;
font-weight: 800;
}
.json-risk-editor-head {
align-items: center;
padding-top: 4px;
padding-bottom: 8px;
}
.json-risk-score-ring {
--score-ring: #f97316;
--score-ring-bg: #fff7ed;
flex: 0 0 auto;
width: 82px;
height: 82px;
border-radius: 999px;
display: grid;
place-items: center;
align-content: center;
gap: 1px;
border: 2px solid var(--score-ring);
background: var(--score-ring-bg);
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.08);
}
.json-risk-score-ring strong {
color: #0f172a;
font-size: 22px;
font-weight: 900;
line-height: 1;
}
.json-risk-score-ring span,
.json-risk-score-ring em {
color: #64748b;
font-size: 11px;
font-style: normal;
font-weight: 700;
line-height: 1.1;
}
.json-risk-score-ring em {
color: var(--score-ring);
}
.json-risk-score-ring.low {
--score-ring: #2563eb;
--score-ring-bg: #eff6ff;
}
.json-risk-score-ring.medium {
--score-ring: #f97316;
--score-ring-bg: #fff7ed;
}
.json-risk-score-ring.high {
--score-ring: #dc2626;
--score-ring-bg: #fef2f2;
}
.json-risk-score-ring.critical {
--score-ring: #991b1b;
--score-ring-bg: #fff1f2;
}
.json-risk-editor-title {
min-width: 0;
display: flex;
align-items: center;
gap: 12px;
}
.json-risk-head-copy {
min-width: 0;
display: grid;
gap: 6px;
}
.json-risk-head-title-row {
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
}
.json-risk-editor-title h2 {
color: #0f172a;
font-size: 18px;
font-weight: 850;
line-height: 1.25;
}
.json-risk-editor-title p {
margin-top: 2px;
max-width: 760px;
color: #64748b;
font-size: 12px;
line-height: 1.4;
}
.json-risk-head-subtitle {
display: -webkit-box;
margin: 0;
max-width: 760px;
overflow: hidden;
color: #64748b;
font-size: 13px;
line-height: 1.55;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.json-risk-head-meta {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.json-risk-head-meta span {
min-height: 24px;
display: inline-flex;
align-items: center;
padding: 0 8px;
border-radius: 999px;
background: #f8fafc;
color: #475569;
font-size: 12px;
font-weight: 750;
border: 1px solid #e2e8f0;
}
.skill-name-cell .skill-list-subtitle { .skill-name-cell .skill-list-subtitle {
display: -webkit-box; display: -webkit-box;
overflow: hidden; overflow: hidden;
@@ -1554,40 +1347,6 @@
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
} }
.json-risk-editor-actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.json-risk-mode-pill {
min-height: 28px;
display: inline-flex;
align-items: center;
padding: 0 10px;
border-radius: 999px;
background: #fff1f2;
color: #be123c;
font-size: 12px;
font-weight: 800;
}
.json-risk-mode-pill.high {
background: #fef2f2;
color: #dc2626;
}
.json-risk-mode-pill.medium {
background: #fff7ed;
color: #ea580c;
}
.json-risk-mode-pill.low {
background: var(--success-soft);
color: var(--success-hover);
}
.json-risk-editor-body { .json-risk-editor-body {
flex: 1 1 auto; flex: 1 1 auto;
min-height: 0; min-height: 0;
@@ -1853,15 +1612,3 @@
grid-column: span 1; grid-column: span 1;
} }
} }
@media (max-width: 860px) {
.json-risk-editor-head {
flex-direction: column;
align-items: stretch;
}
.json-risk-editor-actions {
justify-content: flex-start;
}
}

View File

@@ -609,7 +609,8 @@ tbody tr.is-disabled:hover {
min-width: 0; min-width: 0;
} }
.skill-badge { .skill-badge,
.skill-detail :deep(.skill-badge) {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
min-height: 24px; min-height: 24px;
@@ -620,11 +621,16 @@ tbody tr.is-disabled:hover {
font-weight: 800; font-weight: 800;
} }
.skill-badge.primary { background: var(--theme-gradient-primary); } .skill-badge.primary,
.skill-badge.rose { background: linear-gradient(135deg, #f43f5e, #e11d48); } .skill-detail :deep(.skill-badge.primary) { background: var(--theme-gradient-primary); }
.skill-badge.violet { background: linear-gradient(135deg, #8b5cf6, #7c3aed); } .skill-badge.rose,
.skill-badge.blue { background: linear-gradient(135deg, #3b82f6, #2563eb); } .skill-detail :deep(.skill-badge.rose) { background: linear-gradient(135deg, #f43f5e, #e11d48); }
.skill-badge.amber { background: linear-gradient(135deg, #f59e0b, #ea580c); } .skill-badge.violet,
.skill-detail :deep(.skill-badge.violet) { background: linear-gradient(135deg, #8b5cf6, #7c3aed); }
.skill-badge.blue,
.skill-detail :deep(.skill-badge.blue) { background: linear-gradient(135deg, #3b82f6, #2563eb); }
.skill-badge.amber,
.skill-detail :deep(.skill-badge.amber) { background: linear-gradient(135deg, #f59e0b, #ea580c); }
.hero-title h2 { .hero-title h2 {
margin-top: 10px; margin-top: 10px;
@@ -868,41 +874,6 @@ tbody tr.is-disabled:hover {
padding: 10px; padding: 10px;
} }
.spreadsheet-editor-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.spreadsheet-editor-title {
min-width: 0;
display: flex;
align-items: flex-start;
gap: 12px;
}
.spreadsheet-editor-title h2 {
color: #0f172a;
font-size: 18px;
font-weight: 850;
}
.spreadsheet-editor-title p {
margin-top: 2px;
max-width: 760px;
color: #64748b;
font-size: 12px;
line-height: 1.4;
}
.spreadsheet-editor-actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.spreadsheet-editor-meta { .spreadsheet-editor-meta {
display: flex; display: flex;
gap: 8px; gap: 8px;

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

@@ -1,19 +1,21 @@
<template> <template>
<article class="skill-list panel"> <EnterpriseListPage
<nav class="status-tabs" aria-label="能力类型"> variant="skill-list"
<button :tabs="shellTabs"
v-for="tab in tabs" :active-tab="activeType"
:key="tab.id" tabs-label="能力类型"
type="button" :loading="loading"
:class="{ active: activeType === tab.id }" :error="errorMessage"
@click="emit('update:activeType', tab.id)" :empty="!visibleSkills.length"
:empty-state="auditEmptyState"
:loading-title="`${activeTabLabel}资产同步中`"
:loading-message="`正在加载${activeTabLabel}资产`"
loading-icon="mdi mdi-view-list-outline"
:hint="hintText"
@update:active-tab="emit('update:activeType', $event)"
@empty-action="emit('empty-action')"
> >
{{ tab.label }} <template #filters>
</button>
</nav>
<div class="list-toolbar">
<div class="filter-set">
<label class="search-filter"> <label class="search-filter">
<i class="mdi mdi-magnify"></i> <i class="mdi mdi-magnify"></i>
<input <input
@@ -120,9 +122,9 @@
@close="emit('close-filter-popover')" @close="emit('close-filter-popover')"
@select="selectFilter('status', $event)" @select="selectFilter('status', $event)"
/> />
</div> </template>
<div class="toolbar-actions"> <template #actions>
<button <button
v-if="activeFilterTokens.length" v-if="activeFilterTokens.length"
class="ghost-filter-btn" class="ghost-filter-btn"
@@ -142,50 +144,23 @@
<i class="mdi mdi-plus"></i> <i class="mdi mdi-plus"></i>
<span>{{ createButtonLabel }}</span> <span>{{ createButtonLabel }}</span>
</button> </button>
</div> </template>
</div>
<p class="hint"><i class="mdi mdi-information-outline"></i> {{ hintText }}</p>
<template #meta>
<div v-if="activeFilterTokens.length" class="active-filter-strip"> <div v-if="activeFilterTokens.length" class="active-filter-strip">
<span v-for="token in activeFilterTokens" :key="token" class="active-filter-chip"> <span v-for="token in activeFilterTokens" :key="token" class="active-filter-chip">
{{ token }} {{ token }}
</span> </span>
</div> </div>
</template>
<div <template #error>
class="table-wrap"
:class="{ 'is-empty': !loading && !errorMessage && !visibleSkills.length }"
>
<div v-if="loading" class="table-state">
<TableLoadingState
variant="panel"
:title="`${activeTabLabel}资产同步中`"
:message="`正在加载${activeTabLabel}资产`"
icon="mdi mdi-view-list-outline"
/>
</div>
<div v-else-if="errorMessage" class="table-state error">
<i class="mdi mdi-alert-circle-outline"></i> <i class="mdi mdi-alert-circle-outline"></i>
<p>{{ errorMessage }}</p> <p>{{ errorMessage }}</p>
</div> </template>
<TableEmptyState <template #table>
v-else-if="!visibleSkills.length" <table>
:eyebrow="auditEmptyState.eyebrow"
:title="auditEmptyState.title"
:description="auditEmptyState.desc"
:icon="auditEmptyState.icon"
:action-label="auditEmptyState.actionLabel"
:action-icon="auditEmptyState.actionIcon"
:tone="auditEmptyState.tone"
:art-label="auditEmptyState.artLabel"
:tips="auditEmptyState.tips"
@action="emit('empty-action')"
/>
<table v-else>
<thead> <thead>
<tr> <tr>
<th>{{ tableColumns.name }}</th> <th>{{ tableColumns.name }}</th>
@@ -245,24 +220,27 @@
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </template>
<template #footer>
<footer v-if="!loading && !errorMessage && visibleSkills.length" class="list-foot"> <footer v-if="!loading && !errorMessage && visibleSkills.length" class="list-foot">
<span class="page-summary">当前展示 {{ visibleSkills.length }} 条资产</span> <span class="page-summary">当前展示 {{ visibleSkills.length }} 条资产</span>
</footer> </footer>
</article> </template>
</EnterpriseListPage>
</template> </template>
<script setup> <script setup>
import { computed } 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'
defineOptions({ defineOptions({
name: 'AuditAssetList' name: 'AuditAssetList'
}) })
defineProps({ const props = defineProps({
tabs: { type: Array, default: () => [] }, tabs: { type: Array, default: () => [] },
activeType: { type: String, default: '' }, activeType: { type: String, default: '' },
activeTabLabel: { type: String, default: '' }, activeTabLabel: { type: String, default: '' },
@@ -325,6 +303,13 @@ const emit = defineEmits([
'open-asset-detail' 'open-asset-detail'
]) ])
const shellTabs = computed(() =>
props.tabs.map((tab) => ({
value: tab.id,
label: tab.label
}))
)
function selectFilter(type, value) { function selectFilter(type, value) {
emit('select-filter', type, value) emit('select-filter', type, value)
} }

View File

@@ -1,23 +1,5 @@
<template> <template>
<section class="json-risk-editor-shell panel digital-worker-detail-shell"> <section class="json-risk-editor-shell panel digital-worker-detail-shell">
<header class="json-risk-editor-head asset-detail-topbar list-toolbar">
<div class="json-risk-editor-title asset-detail-topbar-main filter-set">
<div class="json-risk-head-copy">
<div class="json-risk-head-title-row">
<h2>{{ selectedSkill.name }}</h2>
</div>
<p class="json-risk-head-subtitle">
{{ selectedSkill.summary || '后台自动执行的数字员工技能。' }}
</p>
<div class="json-risk-head-meta">
<span>技能编号{{ selectedSkill.code || '-' }}</span>
<span>执行计划{{ digitalEmployee.scheduleLabel || selectedSkill.scope || '-' }}</span>
<span>最近更新{{ selectedSkill.updatedAt || '-' }}</span>
</div>
</div>
</div>
</header>
<div class="json-risk-editor-body"> <div class="json-risk-editor-body">
<section class="json-risk-main-stage"> <section class="json-risk-main-stage">
<article class="detail-card panel json-risk-summary-card"> <article class="detail-card panel json-risk-summary-card">

View File

@@ -1,31 +1,5 @@
<template> <template>
<section class="json-risk-editor-shell panel"> <section class="json-risk-editor-shell panel">
<header class="json-risk-editor-head asset-detail-topbar list-toolbar">
<div class="json-risk-editor-title asset-detail-topbar-main filter-set">
<div class="json-risk-head-copy">
<div class="json-risk-head-title-row">
<h2>{{ selectedSkill.name }}</h2>
</div>
<p class="json-risk-head-subtitle">
{{ selectedSkill.riskRuleSubtitle || '平台通用风险规则' }}
</p>
<div class="json-risk-head-meta">
<span v-if="selectedSkill.riskCategory">适用场景{{ selectedSkill.riskCategory }}</span>
<span>业务域{{ selectedSkill.category || '-' }}</span>
<span>最近更新{{ selectedSkill.updatedAt || '-' }}</span>
</div>
</div>
</div>
<div
class="json-risk-score-ring"
:class="selectedSkill.riskRuleScoreLevel || selectedSkill.riskRuleSeverity"
>
<strong>{{ selectedSkill.riskRuleScore ?? '--' }}</strong>
<span>风险分</span>
<em>{{ selectedSkill.riskRuleScoreLabel || selectedSkill.riskRuleSeverityLabel }}</em>
</div>
</header>
<div <div
v-if="selectedSkill.riskRuleGenerationFailed" v-if="selectedSkill.riskRuleGenerationFailed"
class="json-risk-generation-failure" class="json-risk-generation-failure"

View File

@@ -1,21 +1,5 @@
<template> <template>
<section class="spreadsheet-editor-shell panel"> <section class="spreadsheet-editor-shell panel">
<header class="spreadsheet-editor-head asset-detail-topbar list-toolbar">
<div class="spreadsheet-editor-title asset-detail-topbar-main filter-set">
<div class="skill-badge" :class="selectedSkill.badgeTone">{{ selectedSkill.typeLabel }}</div>
<div>
<h2>{{ selectedSkill.name }}</h2>
<p>{{ selectedSkill.summary || '当前资产尚未补充说明。' }}</p>
</div>
</div>
<div class="spreadsheet-editor-actions asset-detail-topbar-meta toolbar-actions">
<span class="spreadsheet-mode-pill">
{{ selectedSpreadsheetModeLabel }}
</span>
</div>
</header>
<input <input
ref="fileInput" ref="fileInput"
class="spreadsheet-upload-input" class="spreadsheet-upload-input"
@@ -126,7 +110,6 @@ defineOptions({
defineProps({ defineProps({
selectedSkill: { type: Object, required: true }, selectedSkill: { type: Object, required: true },
selectedSpreadsheetModeLabel: { type: String, default: '' },
selectedSpreadsheetFileName: { type: String, default: '' }, selectedSpreadsheetFileName: { type: String, default: '' },
selectedSpreadsheetChangeRecords: { type: Array, default: () => [] }, selectedSpreadsheetChangeRecords: { type: Array, default: () => [] },
spreadsheetOnlyOfficeHostId: { type: String, required: true }, spreadsheetOnlyOfficeHostId: { type: String, required: true },

View File

@@ -1,8 +1,26 @@
<template> <template>
<section class="digital-employee-list-panel"> <EnterpriseListPage
<div class="list-toolbar"> variant="digital-employee-list-panel"
<div class="filter-set"> :panel="false"
<label class="search-filter"> :loading="loading"
:error="errorMessage"
:empty="!visibleEmployees.length"
:empty-state="emptyState"
:show-pagination="!loading && !errorMessage && visibleEmployees.length > 0"
:current-page="currentPage"
:page-size="pageSize"
:pages="pageNumbers"
:show-page-size="false"
:summary="paginationSummary"
:total="visibleEmployees.length"
:total-pages="totalPages"
loading-title="数字员工资产同步中"
loading-message="正在加载数字员工资产"
loading-icon="mdi mdi-view-list-outline"
@update:current-page="currentPage = $event"
>
<template #filters>
<label class="list-search">
<i class="mdi mdi-magnify"></i> <i class="mdi mdi-magnify"></i>
<input <input
:value="keyword" :value="keyword"
@@ -50,9 +68,9 @@
@close="emit('close-filter-popover')" @close="emit('close-filter-popover')"
@select="selectFilter('executionMode', $event)" @select="selectFilter('executionMode', $event)"
/> />
</div> </template>
<div class="toolbar-actions"> <template #actions>
<button <button
v-if="keyword || activeFilterTokens.length" v-if="keyword || activeFilterTokens.length"
class="ghost-filter-btn" class="ghost-filter-btn"
@@ -71,50 +89,23 @@
<i class="mdi mdi-refresh"></i> <i class="mdi mdi-refresh"></i>
<span>{{ loading ? '刷新中...' : '刷新' }}</span> <span>{{ loading ? '刷新中...' : '刷新' }}</span>
</button> </button>
</div> </template>
</div>
<p class="hint">
<i class="mdi mdi-information-outline"></i>
归集后台自动执行的数字员工技能可查看技能内容执行计划启动状态和最近版本
</p>
<template #meta>
<div v-if="activeFilterTokens.length" class="active-filter-strip"> <div v-if="activeFilterTokens.length" class="active-filter-strip">
<span v-for="token in activeFilterTokens" :key="token" class="active-filter-chip"> <span v-for="token in activeFilterTokens" :key="token" class="active-filter-chip">
{{ token }} {{ token }}
</span> </span>
</div> </div>
</template>
<div <template #error>
class="table-wrap digital-table-wrap"
:class="{ 'is-empty': !loading && !errorMessage && !visibleEmployees.length }"
>
<div v-if="loading" class="table-state">
<TableLoadingState
variant="panel"
title="数字员工资产同步中"
message="正在加载数字员工资产"
icon="mdi mdi-view-list-outline"
/>
</div>
<div v-else-if="errorMessage" class="table-state error">
<i class="mdi mdi-alert-circle-outline"></i> <i class="mdi mdi-alert-circle-outline"></i>
<p>{{ errorMessage }}</p> <p>{{ errorMessage }}</p>
</div> </template>
<TableEmptyState <template #table>
v-else-if="!visibleEmployees.length" <table class="digital-employees-table">
eyebrow="数字员工"
title="暂无匹配的数字员工"
description="当前没有符合搜索条件的后台执行技能。"
icon="mdi mdi-account-cog-outline"
tone="theme"
art-label="STAFF"
:tips="['数字员工已从规则中心拆出为独立入口', '运行与定时操作统一进入详情后处理']"
/>
<table v-else class="digital-employees-table">
<colgroup> <colgroup>
<col class="col-skill"> <col class="col-skill">
<col class="col-skill-type"> <col class="col-skill-type">
@@ -144,58 +135,29 @@
@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>
</div> </template>
</EnterpriseListPage>
<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>
</section>
</template> </template>
<script setup> <script setup>
import { computed, ref, watch } from 'vue' import { computed, 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'
defineOptions({ defineOptions({
name: 'DigitalEmployeeListPanel' name: 'DigitalEmployeeListPanel'
@@ -244,6 +206,18 @@ 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 = {
eyebrow: '数字员工',
title: '暂无匹配的数字员工',
description: '当前没有符合搜索条件的后台执行技能。',
icon: 'mdi mdi-account-cog-outline',
tone: 'theme',
artLabel: 'STAFF',
tips: ['数字员工已从规则中心拆出为独立入口', '运行与定时操作统一进入详情后处理']
}
watch( watch(
() => [props.keyword, props.selectedStatus, props.selectedEnabledState, props.selectedExecutionMode], () => [props.keyword, props.selectedStatus, props.selectedEnabledState, props.selectedExecutionMode],
@@ -268,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 {
@@ -276,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 .digital-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: 12px;
background: #f8fafc;
}
.digital-employee-list-panel .pager button {
width: 32px;
height: 32px;
border: 0;
border-radius: 9px;
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;
}
</style> </style>

View File

@@ -2,10 +2,31 @@
<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"
:panel="false"
:loading="loading && !runs.length"
:error="errorMessage"
:empty="!visibleRuns.length"
:empty-state="workRecordsEmptyState"
:show-pagination="!loading && !errorMessage && visibleRuns.length > 0"
:current-page="currentPage"
:page-size="pageSize"
:pages="pageNumbers"
:show-page-size="false"
:summary="paginationSummary"
:total="filteredRuns.length"
:total-pages="totalPages"
loading-title="工作记录同步中"
loading-message="正在读取数字员工近期执行记录"
loading-icon="mdi mdi-clipboard-text-clock-outline"
error-title="工作记录加载失败"
@update:current-page="currentPage = $event"
>
<template #filters>
<label class="list-search">
<i class="mdi mdi-magnify"></i> <i class="mdi mdi-magnify"></i>
<input <input
v-model="listKeyword" v-model="listKeyword"
@@ -39,9 +60,9 @@
@close="closeFilterPopover" @close="closeFilterPopover"
@select="selectStatus" @select="selectStatus"
/> />
</div> </template>
<div class="toolbar-actions"> <template #actions>
<button <button
v-if="listKeyword || activeFilterTokens.length" v-if="listKeyword || activeFilterTokens.length"
class="ghost-filter-btn" class="ghost-filter-btn"
@@ -51,55 +72,44 @@
<i class="mdi mdi-filter-remove-outline"></i> <i class="mdi mdi-filter-remove-outline"></i>
<span>清空筛选</span> <span>清空筛选</span>
</button> </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 <button
class="create-btn digital-refresh-action" class="create-btn digital-refresh-action digital-refresh-now"
type="button" type="button"
:disabled="loading" :disabled="loading"
aria-label="立即刷新工作记录"
@click="loadWorkRecords(true)" @click="loadWorkRecords(true)"
> >
<i class="mdi mdi-refresh"></i> <i :class="loading ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-refresh'"></i>
<span>{{ loading ? '刷新中...' : '刷新' }}</span>
</button> </button>
</div> </template>
</div>
<p class="hint">
<i class="mdi mdi-information-outline"></i>
查看数字员工近期执行记录状态和结果摘要
</p>
<template #meta>
<div v-if="activeFilterTokens.length" class="active-filter-strip"> <div v-if="activeFilterTokens.length" class="active-filter-strip">
<span v-for="token in activeFilterTokens" :key="token" class="active-filter-chip"> <span v-for="token in activeFilterTokens" :key="token" class="active-filter-chip">
{{ token }} {{ token }}
</span> </span>
</div> </div>
</template>
<div class="table-wrap digital-table-wrap" :class="{ 'is-empty': !loading && !errorMessage && !visibleRuns.length }"> <template #error>
<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> <i class="mdi mdi-alert-circle-outline"></i>
<p>{{ errorMessage }}</p> <p>{{ errorMessage }}</p>
</div> </template>
<TableEmptyState <template #table>
v-else-if="!visibleRuns.length" <table class="digital-work-records-table">
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">
<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,59 +155,11 @@
</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">
<header class="json-risk-editor-head asset-detail-topbar list-toolbar">
<div class="json-risk-editor-title asset-detail-topbar-main filter-set">
<div class="json-risk-head-copy">
<div class="json-risk-head-title-row">
<h2>{{ resolveWorkRecordTitle(selectedRunDetail) }}</h2>
</div>
<p class="json-risk-head-subtitle">
执行工作流{{ resolveWorkRecordModuleLabel(selectedRunDetail) }}
</p>
<div class="json-risk-head-meta">
<span>Run ID{{ selectedRunDetail.run_id }}</span>
<span>触发来源{{ resolveWorkRecordSourceLabel(selectedRunDetail.source) }}</span>
<span>开始时间{{ formatWorkRecordDateTime(selectedRunDetail.started_at) }}</span>
</div>
</div>
</div>
<div
class="json-risk-score-ring"
:class="selectedRunDetail.status"
>
<strong style="font-size: 16px; font-weight: 900;">{{ resolveWorkRecordStatusLabel(selectedRunDetail) }}</strong>
<span>运行状态</span>
<em>{{ resolveWorkRecordStatusNote(selectedRunDetail) || '执行完毕' }}</em>
</div>
</header>
<div v-if="detailLoading" class="work-record-detail-state panel" style="min-height: 200px; display: grid; place-items: center; border: 0;"> <div v-if="detailLoading" class="work-record-detail-state panel" style="min-height: 200px; display: grid; place-items: center; border: 0;">
<TableLoadingState <TableLoadingState
variant="panel" variant="panel"
@@ -324,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,
@@ -345,7 +308,7 @@ defineOptions({
name: 'DigitalEmployeeWorkRecords' name: 'DigitalEmployeeWorkRecords'
}) })
const emit = defineEmits(['summary-change', 'detail-open-change']) const emit = defineEmits(['summary-change', 'detail-open-change', 'detail-topbar-change'])
const { toast } = useToast() const { toast } = useToast()
const runs = ref([]) const runs = ref([])
@@ -356,20 +319,77 @@ 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)
}, { immediate: true }) }, { immediate: true })
let pollTimer = 0 let pollTimer = 0
const totalCount = computed(() => runs.value.length) const workRecordSummary = computed(() =>
const successCount = computed(() => runs.value.filter((run) => run.status === 'succeeded').length) runs.value.reduce(
const failedCount = computed(() => runs.value.filter((run) => run.status === 'failed').length) (summary, run) => {
summary.total += 1
if (run.status === 'succeeded') {
summary.succeeded += 1
} else if (run.status === 'failed') {
summary.failed += 1
}
return summary
},
{ total: 0, succeeded: 0, failed: 0 }
)
)
const workRecordDetailTopBar = computed(() => {
const detail = selectedRunDetail.value
if (!detail) return null
const status = String(detail.status || '').toLowerCase()
const statusColor =
status === 'failed'
? '#ef4444'
: status === 'succeeded'
? 'var(--success)'
: '#3b82f6'
return {
view: {
title: resolveWorkRecordTitle(detail),
desc: `执行工作流:${resolveWorkRecordModuleLabel(detail)}`
},
kpis: [
{
label: '运行状态',
value: resolveWorkRecordStatusLabel(detail),
unit: '',
meta: resolveWorkRecordStatusNote(detail) || '执行完毕',
trend: status === 'failed' ? 'down' : 'up',
color: statusColor
}
]
}
})
watch(
workRecordDetailTopBar,
(value) => {
emit('detail-topbar-change', value)
},
{ immediate: true, deep: true }
)
const listKeyword = ref('') 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)))
@@ -386,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 = []
@@ -427,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],
@@ -464,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 = '全部'
@@ -479,9 +513,9 @@ async function loadWorkRecords(showToast = false) {
const payload = await fetchAgentRuns({ agent: 'hermes', limit: 100 }) const payload = await fetchAgentRuns({ agent: 'hermes', limit: 100 })
runs.value = Array.isArray(payload) ? payload : [] runs.value = Array.isArray(payload) ? payload : []
emit('summary-change', { emit('summary-change', {
total: totalCount.value, total: workRecordSummary.value.total,
succeeded: successCount.value, succeeded: workRecordSummary.value.succeeded,
failed: failedCount.value failed: workRecordSummary.value.failed
}) })
} catch (error) { } catch (error) {
errorMessage.value = error?.message || '工作记录加载失败,请稍后重试。' errorMessage.value = error?.message || '工作记录加载失败,请稍后重试。'
@@ -542,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() {
@@ -564,101 +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;
}
/* 风险环的成功、失败、执行中状态配色 */
.json-risk-score-ring.succeeded {
--score-ring: #16a34a;
--score-ring-bg: #f0fdf4;
}
.json-risk-score-ring.failed {
--score-ring: #dc2626;
--score-ring-bg: #fef2f2;
}
.json-risk-score-ring.running {
--score-ring: #2563eb;
--score-ring-bg: #eff6ff;
}
</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(() =>
})) }))
) )
onMounted(() => { 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() void refreshDocumentInbox()
}, props.activeView === 'documents' ? 1200 : 6000)
}
onMounted(() => {
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

@@ -84,7 +84,20 @@
</template> </template>
<template v-else-if="isRequestDetail"> <template v-else-if="isRequestDetail">
<div class="detail-alert-strip"> <div class="detail-topbar-actions">
<div v-if="detailKpis.length" class="kpi-chips detail-kpi-chips">
<div
v-for="kpi in detailKpis"
:key="kpi.label"
class="kpi-chip detail-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>
<div v-if="detailAlerts.length" class="detail-alert-strip">
<span <span
v-for="alert in detailAlerts" v-for="alert in detailAlerts"
:key="alert.label" :key="alert.label"
@@ -95,6 +108,7 @@
<span>{{ alert.label }}</span> <span>{{ alert.label }}</span>
</span> </span>
</div> </div>
</div>
</template> </template>
<template v-else-if="isWorkbench"> <template v-else-if="isWorkbench">
@@ -135,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
@@ -215,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
@@ -239,14 +239,14 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: false default: false
}, },
logDetailMode: {
type: Boolean,
default: false
},
detailAlerts: { detailAlerts: {
type: Array, type: Array,
default: () => [] default: () => []
}, },
detailKpis: {
type: Array,
default: () => []
},
customRange: { customRange: {
type: Object, type: Object,
default: () => ({ start: '2024-07-06', end: '2024-07-12' }) default: () => ({ start: '2024-07-06', end: '2024-07-12' })
@@ -265,10 +265,9 @@ const emit = defineEmits([
const isChat = computed(() => props.activeView === 'chat') const isChat = computed(() => props.activeView === 'chat')
const isOverview = computed(() => props.activeView === 'overview') const isOverview = computed(() => props.activeView === 'overview')
const isWorkbench = computed(() => props.activeView === 'workbench') const isWorkbench = computed(() => props.activeView === 'workbench')
const isRequestDetail = computed(() => ['requests', 'documents'].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')
@@ -310,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

@@ -1,17 +1,7 @@
<template> <template>
<article class="enterprise-detail-page" :class="variant"> <article class="enterprise-detail-page" :class="variant">
<div class="detail-scroll"> <div class="detail-scroll">
<TableLoadingState <section v-if="error" class="detail-inline-state panel error">
v-if="loading"
class="detail-loading-state panel"
variant="panel"
:title="loadingTitle"
:message="loadingMessage"
:icon="loadingIcon"
:show-skeleton="false"
/>
<section v-else-if="error" class="detail-inline-state panel error">
<slot name="error" :error="error"> <slot name="error" :error="error">
<i class="mdi mdi-alert-circle-outline"></i> <i class="mdi mdi-alert-circle-outline"></i>
<div> <div>
@@ -21,6 +11,16 @@
</slot> </slot>
</section> </section>
<TableLoadingState
v-else-if="loading"
class="detail-loading-state panel"
variant="panel"
:title="loadingTitle"
:message="loadingMessage"
:icon="loadingIcon"
:show-skeleton="false"
/>
<template v-else> <template v-else>
<section v-if="$slots.hero" class="detail-hero panel"> <section v-if="$slots.hero" class="detail-hero panel">
<slot name="hero"></slot> <slot name="hero"></slot>
@@ -40,7 +40,7 @@
</template> </template>
</div> </div>
<footer v-if="backLabel || $slots.actions" class="detail-actions"> <footer v-if="backLabel || $slots.actions" class="detail-actions" :class="actionsClass">
<button v-if="backLabel" class="back-action" type="button" @click="emit('back')"> <button v-if="backLabel" class="back-action" type="button" @click="emit('back')">
<i class="mdi mdi-arrow-left"></i> <i class="mdi mdi-arrow-left"></i>
<span>{{ backLabel }}</span> <span>{{ backLabel }}</span>
@@ -57,6 +57,7 @@
import TableLoadingState from './TableLoadingState.vue' import TableLoadingState from './TableLoadingState.vue'
defineProps({ defineProps({
actionsClass: { type: [String, Array, Object], default: '' },
backLabel: { type: String, default: '' }, backLabel: { type: String, default: '' },
error: { type: String, default: '' }, error: { type: String, default: '' },
errorTitle: { type: String, default: '详情加载失败' }, errorTitle: { type: String, default: '详情加载失败' },
@@ -64,7 +65,7 @@ defineProps({
loadingIcon: { type: String, default: 'mdi mdi-file-document-outline' }, loadingIcon: { type: String, default: 'mdi mdi-file-document-outline' },
loadingMessage: { type: String, default: '' }, loadingMessage: { type: String, default: '' },
loadingTitle: { type: String, default: '正在加载详情' }, loadingTitle: { type: String, default: '正在加载详情' },
variant: { type: String, default: '' } variant: { type: [String, Array, Object], default: '' }
}) })
const emit = defineEmits(['back']) const emit = defineEmits(['back'])

View File

@@ -1,5 +1,5 @@
<template> <template>
<article class="enterprise-list-page panel" :class="variant"> <article class="enterprise-list-page" :class="[variant, { panel }]">
<slot name="before"></slot> <slot name="before"></slot>
<nav v-if="hasTabs" class="status-tabs" :aria-label="tabsLabel"> <nav v-if="hasTabs" class="status-tabs" :aria-label="tabsLabel">
@@ -51,12 +51,15 @@
</slot> </slot>
</p> </p>
<slot name="meta"></slot>
<div class="table-wrap" :class="{ 'is-empty': empty, 'has-error': Boolean(error) }"> <div class="table-wrap" :class="{ 'is-empty': empty, 'has-error': Boolean(error) }">
<div v-if="loading" class="table-state"> <div v-if="loading" class="table-state">
<TableLoadingState <TableLoadingState
:title="loadingTitle" :title="loadingTitle"
:message="loadingMessage" :message="loadingMessage"
:icon="loadingIcon" :icon="loadingIcon"
floating
/> />
</div> </div>
@@ -135,6 +138,7 @@ const props = defineProps({
type: Array, type: Array,
default: () => [] default: () => []
}, },
panel: { type: Boolean, default: true },
pages: { pages: {
type: Array, type: Array,
default: () => [] default: () => []

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>
<span v-if="floating" class="table-loading-anchor" aria-hidden="true"></span>
<Teleport to="body" :disabled="!floating">
<div <div
class="table-loading" class="table-loading"
:class="[variant, tone]" :class="[variant, tone, { 'screen-floating': floating, 'modal-floating': floating && blocking }]"
role="status" role="status"
:aria-label="ariaLabel" :aria-label="ariaLabel"
aria-live="polite" aria-live="polite"
> >
<span class="table-loading__spinner" aria-hidden="true"> <FloatingLightBandWindow
<i :class="icon"></i> :icon="icon"
</span> :message="message"
:motion="motion"
<div v-if="hasCopy" class="table-loading__copy"> :title="title"
<strong v-if="title">{{ title }}</strong> :tone="tone"
<p v-if="message">{{ message }}</p> :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()
} }
}) },
{ immediate: true }
watch(workbenchActive, (isActive, wasActive) => { )
if (isActive && !wasActive) {
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,28 +46,26 @@
'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'
}" }"
> >
<TopBar <TopBar
v-if="activeView !== 'settings' && !(activeView === 'audit' && auditDetailOpen) && !(activeView === 'digitalEmployees' && digitalEmployeeDetailOpen)" v-if="activeView !== 'settings'"
:current-view="topBarView" :current-view="resolvedTopBarView"
:search="search" :search="search"
:active-view="activeView" :active-view="activeView"
:ranges="ranges" :ranges="ranges"
: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="detailMode" :detail-mode="resolvedDetailMode"
:log-detail-mode="logDetailMode" :detail-alerts="resolvedDetailAlerts"
:detail-alerts="detailAlerts" :detail-kpis="resolvedDetailKpis"
:custom-range="customRange" :custom-range="customRange"
@update:search="search = $event" @update:search="search = $event"
@update:active-range="activeRange = $event" @update:active-range="activeRange = $event"
@@ -80,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"
@@ -97,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'
}" }"
@@ -145,14 +139,17 @@
@open-assistant="openSmartEntry" @open-assistant="openSmartEntry"
/> />
<PoliciesView v-else-if="activeView === 'policies'" @summary-change="knowledgeSummary = $event" /> <PoliciesView v-else-if="activeView === 'policies'" @summary-change="knowledgeSummary = $event" />
<AuditView v-else-if="activeView === 'audit'" @detail-open-change="auditDetailOpen = $event" /> <AuditView
v-else-if="activeView === 'audit'"
@detail-open-change="auditDetailOpen = $event"
@detail-topbar-change="detailTopBarPayload = $event"
/>
<DigitalEmployeesView <DigitalEmployeesView
v-else-if="activeView === 'digitalEmployees'" v-else-if="activeView === 'digitalEmployees'"
@summary-change="digitalEmployeeSummary = $event" @summary-change="digitalEmployeeSummary = $event"
@detail-open-change="digitalEmployeeDetailOpen = $event" @detail-open-change="digitalEmployeeDetailOpen = $event"
@detail-topbar-change="detailTopBarPayload = $event"
/> />
<LogDetailView v-else-if="activeView === 'logs' && logDetailMode" />
<LogsView v-else-if="activeView === 'logs'" @summary-change="logsSummary = $event" />
<EmployeeManagementView v-else-if="activeView === 'employees'" @overview-change="employeeSummary = $event" /> <EmployeeManagementView v-else-if="activeView === 'employees'" @overview-change="employeeSummary = $event" />
<SettingsView v-else /> <SettingsView v-else />
</section> </section>
@@ -175,35 +172,51 @@
</template> </template>
<script setup> <script setup>
import { computed, 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 OverviewView from './OverviewView.vue' import FloatingLightBandWindow from '../components/shared/FloatingLightBandWindow.vue'
import PersonalWorkbenchView from './PersonalWorkbenchView.vue' import TableLoadingState from '../components/shared/TableLoadingState.vue'
import TravelReimbursementCreateView from './TravelReimbursementCreateView.vue'
import TravelRequestDetailView from './TravelRequestDetailView.vue'
import DocumentsCenterView from './DocumentsCenterView.vue'
import BudgetCenterView from './BudgetCenterView.vue'
import PoliciesView from './PoliciesView.vue'
import AuditView from './AuditView.vue'
import DigitalEmployeesView from './DigitalEmployeesView.vue'
import LogsView from './LogsView.vue'
import LogDetailView from './LogDetailView.vue'
import EmployeeManagementView from './EmployeeManagementView.vue'
import SettingsView from './SettingsView.vue'
import { useAppShell } from '../composables/useAppShell.js' import { useAppShell } from '../composables/useAppShell.js'
import { useSystemState } from '../composables/useSystemState.js' import { useSystemState } from '../composables/useSystemState.js'
import { filterNavItemsByAccess } from '../utils/accessControl.js' import { filterNavItemsByAccess } from '../utils/accessControl.js'
import { consumeLoginEntryTransition } from '../utils/loginEntryTransition.js' import { consumeLoginEntryTransition } from '../utils/loginEntryTransition.js'
const OverviewView = defineAsyncComponent(() => import('./OverviewView.vue'))
const PersonalWorkbenchView = defineAsyncComponent(() => import('./PersonalWorkbenchView.vue'))
const TravelReimbursementCreateView = defineAsyncComponent(() => import('./TravelReimbursementCreateView.vue'))
const TravelRequestDetailView = defineAsyncComponent(() => import('./TravelRequestDetailView.vue'))
const DocumentsCenterView = defineAsyncComponent(() => import('./DocumentsCenterView.vue'))
const BudgetCenterRouteLoading = {
name: 'BudgetCenterRouteLoading',
render: () =>
h(TableLoadingState, {
title: '预算数据同步中',
message: '正在加载预算中心模块与预算数据',
icon: 'mdi mdi-chart-donut',
floating: true,
blocking: true
})
}
const BudgetCenterView = defineAsyncComponent({
loader: () => import('./BudgetCenterView.vue'),
loadingComponent: BudgetCenterRouteLoading,
delay: 0
})
const PoliciesView = defineAsyncComponent(() => import('./PoliciesView.vue'))
const AuditView = defineAsyncComponent(() => import('./AuditView.vue'))
const DigitalEmployeesView = defineAsyncComponent(() => import('./DigitalEmployeesView.vue'))
const EmployeeManagementView = defineAsyncComponent(() => import('./EmployeeManagementView.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 auditDetailOpen = ref(false) const auditDetailOpen = ref(false)
const digitalEmployeeDetailOpen = ref(false) const digitalEmployeeDetailOpen = ref(false)
const loginEntryAnimating = ref(false) const loginEntryAnimating = ref(false)
@@ -246,7 +259,6 @@ const {
customRange, customRange,
detailAlerts, detailAlerts,
detailMode, detailMode,
logDetailMode,
filteredRequests, filteredRequests,
filters, filters,
handleApprove, handleApprove,
@@ -282,6 +294,37 @@ const { companyProfile, currentUser, logout } = useSystemState()
const PRODUCT_DISPLAY_NAME = '易财费控' const PRODUCT_DISPLAY_NAME = '易财费控'
const ENTERPRISE_DISPLAY_NAME = '远光软件股份有限公司' const ENTERPRISE_DISPLAY_NAME = '远光软件股份有限公司'
const filteredNavItems = computed(() => filterNavItemsByAccess(navItems, currentUser.value)) const filteredNavItems = computed(() => filterNavItemsByAccess(navItems, currentUser.value))
const DETAIL_TOPBAR_FALLBACKS = {
audit: {
title: '规则中心详情',
desc: '查看规则配置、版本审核、测试结果与上线状态。'
},
digitalEmployees: {
title: '数字员工详情',
desc: '查看数字员工配置、执行计划、运行记录与源文件。'
}
}
const customDetailTopBarActive = computed(() => (
(activeView.value === 'audit' && auditDetailOpen.value) ||
(activeView.value === 'digitalEmployees' && digitalEmployeeDetailOpen.value)
))
const resolvedTopBarView = computed(() => (
customDetailTopBarActive.value
? detailTopBarPayload.value?.view || DETAIL_TOPBAR_FALLBACKS[activeView.value] || topBarView.value
: topBarView.value
))
const resolvedDetailMode = computed(() => (
detailMode.value ||
customDetailTopBarActive.value
))
const resolvedDetailAlerts = computed(() => (
customDetailTopBarActive.value
? detailTopBarPayload.value?.alerts || []
: detailAlerts.value
))
const resolvedDetailKpis = computed(() => (
customDetailTopBarActive.value ? detailTopBarPayload.value?.kpis || [] : []
))
function handleLogout() { function handleLogout() {
logout('manual') logout('manual')

View File

@@ -11,67 +11,6 @@
}" }"
> >
<div class="detail-scroll"> <div class="detail-scroll">
<section
v-if="!selectedSkill.usesSpreadsheetRule && !selectedSkill.usesJsonRiskRule"
class="detail-hero panel asset-detail-topbar list-toolbar"
>
<div class="hero-title asset-detail-topbar-main filter-set">
<div class="skill-badge" :class="selectedSkill.badgeTone">{{ selectedSkill.typeLabel }}</div>
<h2>{{ selectedSkill.name }}</h2>
<p>{{ selectedSkill.summary || '当前资产尚未补充说明。' }}</p>
<div class="hero-review-meta">
<span>
<i class="mdi mdi-code-tags"></i>
{{ selectedSkill.code }}
</span>
<span>
<i class="mdi mdi-account-outline"></i>
负责人{{ selectedSkill.owner }}
</span>
<span>
<i class="mdi mdi-account-check-outline"></i>
审核人{{ selectedSkill.reviewer }}
</span>
<b :class="['status-pill', selectedSkill.statusTone]">{{ selectedSkill.status }}</b>
<b
v-if="selectedSkillIsRule"
:class="['status-pill', selectedSkill.reviewStatusTone]"
>
{{ selectedSkill.reviewStatusLabel }}
</b>
</div>
<div v-if="selectedSkillIsRule" class="review-note-block">
<strong>上线约束</strong>
<p>{{ activateBlockedReason || '当前规则版本审核通过后可正式上线。' }}</p>
<span v-if="showReviewNote">
审核时间{{ selectedSkill.reviewTimeLabel }}
<template v-if="selectedSkill.reviewNote"> · 审核意见{{ selectedSkill.reviewNote }}</template>
</span>
</div>
</div>
<div class="hero-stats asset-detail-topbar-meta toolbar-actions">
<div class="hero-stat">
<span>资产编码</span>
<strong>{{ selectedSkill.code }}</strong>
</div>
<div class="hero-stat">
<span>业务域</span>
<strong>{{ selectedSkill.category }}</strong>
</div>
<div class="hero-stat">
<span>{{ selectedSkillIsRule ? '当前展示版本' : '当前版本' }}</span>
<strong>{{ selectedSkill.displayVersion || selectedSkill.version }}</strong>
</div>
<div class="hero-stat">
<span>最近更新</span>
<strong>{{ selectedSkill.updatedAt }}</strong>
</div>
</div>
</section>
<section v-if="detailError" class="detail-inline-state panel error"> <section v-if="detailError" class="detail-inline-state panel error">
<i class="mdi mdi-alert-circle-outline"></i> <i class="mdi mdi-alert-circle-outline"></i>
<div> <div>
@@ -94,7 +33,6 @@
v-else-if="selectedSkill.usesSpreadsheetRule" v-else-if="selectedSkill.usesSpreadsheetRule"
ref="spreadsheetUploadInput" ref="spreadsheetUploadInput"
:selected-skill="selectedSkill" :selected-skill="selectedSkill"
:selected-spreadsheet-mode-label="selectedSpreadsheetModeLabel"
:selected-spreadsheet-file-name="selectedSpreadsheetFileName" :selected-spreadsheet-file-name="selectedSpreadsheetFileName"
:selected-spreadsheet-change-records="selectedSpreadsheetChangeRecords" :selected-spreadsheet-change-records="selectedSpreadsheetChangeRecords"
:spreadsheet-only-office-host-id="spreadsheetOnlyOfficeHostId" :spreadsheet-only-office-host-id="spreadsheetOnlyOfficeHostId"

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

@@ -1,47 +1,29 @@
<template> <template>
<section class="digital-employees-view skill-center"> <section class="digital-employees-view skill-center">
<Transition name="skill-view" mode="out-in"> <Transition name="skill-view" mode="out-in">
<article <EnterpriseDetailPage
v-if="selectedEmployee" v-if="selectedEmployee"
key="detail" key="detail"
class="skill-detail digital-employee-detail json-risk-skill-detail" variant="skill-detail digital-employee-detail json-risk-skill-detail"
actions-class="digital-employee-detail-actions"
:error="detailError"
error-title="数字员工详情加载失败"
:loading="detailLoading && selectedEmployee.loading"
loading-title="正在加载数字员工详情"
loading-message="列表数据已就绪正在补充 Skills 源文件和执行配置"
loading-icon="mdi mdi-account-cog-outline"
back-label="返回数字员工列表"
@back="closeEmployeeDetail"
> >
<div class="detail-scroll">
<section v-if="detailError" class="detail-inline-state panel error">
<i class="mdi mdi-alert-circle-outline"></i>
<div>
<strong>数字员工详情加载失败</strong>
<p>{{ detailError }}</p>
</div>
</section>
<TableLoadingState
v-else-if="detailLoading && selectedEmployee.loading"
class="detail-loading-state panel"
variant="panel"
title="正在加载数字员工详情"
message="列表数据已就绪,正在补充 Skills 源文件和执行配置"
icon="mdi mdi-account-cog-outline"
:show-skeleton="false"
/>
<AuditDigitalEmployeeDetail <AuditDigitalEmployeeDetail
v-else
:selected-skill="selectedEmployee" :selected-skill="selectedEmployee"
:can-edit="canEditDigitalEmployeeSource" :can-edit="canEditDigitalEmployeeSource"
:detail-busy="detailBusy" :detail-busy="detailBusy"
:action-state="actionState" :action-state="actionState"
@save-source="saveDigitalEmployeeSource" @save-source="saveDigitalEmployeeSource"
/> />
</div>
<footer class="detail-actions digital-employee-detail-actions"> <template #actions>
<button class="back-action" type="button" @click="closeEmployeeDetail">
<i class="mdi mdi-arrow-left"></i>
<span>返回数字员工列表</span>
</button>
<div class="detail-action-group">
<button <button
class="minor-action enable-action" class="minor-action enable-action"
:class="{ 'is-on': selectedEmployee.statusValue === 'active' }" :class="{ 'is-on': selectedEmployee.statusValue === 'active' }"
@@ -70,9 +52,8 @@
<i class="mdi mdi-play-circle-outline"></i> <i class="mdi mdi-play-circle-outline"></i>
<span>{{ actionBusy(selectedEmployee.id, 'run-digital-now') ? '运行中...' : '立即运行' }}</span> <span>{{ actionBusy(selectedEmployee.id, 'run-digital-now') ? '运行中...' : '立即运行' }}</span>
</button> </button>
</div> </template>
</footer> </EnterpriseDetailPage>
</article>
<article <article
v-else v-else
@@ -86,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"
@@ -98,168 +79,29 @@
</nav> </nav>
<template v-if="activeSection === 'skills'"> <template v-if="activeSection === 'skills'">
<div class="list-toolbar"> <DigitalEmployeeListPanel
<div class="filter-set"> v-model:keyword="keyword"
<label class="search-filter">
<i class="mdi mdi-magnify"></i>
<input v-model="keyword" type="search" placeholder="搜索技能名称、编号、执行计划或维护人" />
</label>
<AuditPickerFilter
id="status"
title="选择资产状态"
close-label="关闭资产状态选择"
:active-filter-popover="activeFilterPopover" :active-filter-popover="activeFilterPopover"
:label="selectedStatusLabel" :selected-status="selectedStatus"
:options="statusOptions" :selected-status-label="selectedStatusLabel"
:selected-value="selectedStatus" :status-options="statusOptions"
@toggle="toggleFilterPopover" :selected-enabled-state="selectedEnabledState"
@close="closeFilterPopover" :selected-enabled-label="selectedEnabledLabel"
@select="selectFilter('status', $event)" :enabled-state-options="enabledStateOptions"
:selected-execution-mode="selectedExecutionMode"
:selected-execution-mode-label="selectedExecutionModeLabel"
:execution-mode-options="executionModeOptions"
:active-filter-tokens="activeFilterTokens"
:loading="loading"
:error-message="errorMessage"
:visible-employees="visibleEmployees"
@toggle-filter-popover="toggleFilterPopover"
@close-filter-popover="closeFilterPopover"
@select-filter="selectFilter"
@reset-filters="resetFilters"
@load-employees="loadEmployees"
@open-employee-detail="openEmployeeDetail"
/> />
<AuditPickerFilter
id="enabled"
title="选择启动状态"
close-label="关闭启动状态选择"
:active-filter-popover="activeFilterPopover"
:label="selectedEnabledLabel"
:options="enabledStateOptions"
:selected-value="selectedEnabledState"
@toggle="toggleFilterPopover"
@close="closeFilterPopover"
@select="selectFilter('enabled', $event)"
/>
<AuditPickerFilter
id="executionMode"
title="选择执行方式"
close-label="关闭执行方式选择"
:active-filter-popover="activeFilterPopover"
:label="selectedExecutionModeLabel"
:options="executionModeOptions"
:selected-value="selectedExecutionMode"
@toggle="toggleFilterPopover"
@close="closeFilterPopover"
@select="selectFilter('executionMode', $event)"
/>
</div>
<div class="toolbar-actions">
<button
v-if="keyword || 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="loadEmployees"
>
<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 && !visibleEmployees.length }"
>
<div v-if="loading" class="table-state">
<TableLoadingState
variant="panel"
title="数字员工同步中"
message="正在读取后台自动执行技能列表"
icon="mdi mdi-account-cog-outline"
/>
</div>
<div v-else-if="errorMessage" class="table-state error">
<i class="mdi mdi-alert-circle-outline"></i>
<strong>数字员工加载失败</strong>
<p>{{ errorMessage }}</p>
</div>
<TableEmptyState
v-else-if="!visibleEmployees.length"
eyebrow="数字员工"
title="暂无匹配的数字员工"
description="当前没有符合搜索条件的后台执行技能。"
icon="mdi mdi-account-cog-outline"
tone="theme"
art-label="STAFF"
:tips="['数字员工已从规则中心拆出为独立入口', '运行与定时操作统一进入详情后处理']"
/>
<table v-else class="digital-employees-table">
<colgroup>
<col class="col-skill">
<col class="col-schedule">
<col class="col-mode">
<col class="col-skill-type">
<col class="col-status">
<col class="col-enabled">
<col class="col-updated">
</colgroup>
<thead>
<tr>
<th>技能名称</th>
<th>执行计划</th>
<th>触发方式</th>
<th>技能类型</th>
<th>资产状态</th>
<th>启动状态</th>
<th>最近更新</th>
</tr>
</thead>
<tbody>
<tr
v-for="employee in visibleEmployees"
:key="employee.id"
@click="openEmployeeDetail(employee)"
>
<td>
<div class="skill-name-cell">
<span class="skill-avatar" :class="employee.badgeTone">{{ employee.short }}</span>
<div>
<strong>{{ employee.name }}</strong>
<span class="skill-list-subtitle">{{ employee.code }}</span>
</div>
</div>
</td>
<td><span class="scope-pill">{{ employee.scope }}</span></td>
<td>{{ employee.executionMode }}</td>
<td><span class="scope-pill skill-type-pill">{{ employee.skillCategory }}</span></td>
<td>
<span :class="['status-pill', employee.statusTone]">{{ employee.status }}</span>
</td>
<td><span :class="['status-pill', employee.enabledTone]">{{ employee.enabledLabel }}</span></td>
<td>{{ employee.updatedAt || '-' }}</td>
</tr>
</tbody>
</table>
</div>
<footer v-if="!loading && !errorMessage && visibleEmployees.length" class="list-foot">
<span class="page-summary">当前展示 {{ visibleEmployees.length }} 条数字员工</span>
</footer>
</template> </template>
<DigitalEmployeeWorkRecords <DigitalEmployeeWorkRecords
@@ -267,6 +109,7 @@
class="digital-work-records-section" class="digital-work-records-section"
@summary-change="emit('summary-change', $event)" @summary-change="emit('summary-change', $event)"
@detail-open-change="workRecordDetailOpen = $event" @detail-open-change="workRecordDetailOpen = $event"
@detail-topbar-change="workRecordDetailTopBar = $event"
/> />
</article> </article>
</Transition> </Transition>
@@ -289,11 +132,10 @@
import { computed, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import AuditDigitalEmployeeDetail from '../components/audit/AuditDigitalEmployeeDetail.vue' import AuditDigitalEmployeeDetail from '../components/audit/AuditDigitalEmployeeDetail.vue'
import AuditPickerFilter from '../components/audit/AuditPickerFilter.vue' import DigitalEmployeeListPanel from '../components/audit/DigitalEmployeeListPanel.vue'
import DigitalEmployeeScheduleDialog from '../components/audit/DigitalEmployeeScheduleDialog.vue' import DigitalEmployeeScheduleDialog from '../components/audit/DigitalEmployeeScheduleDialog.vue'
import DigitalEmployeeWorkRecords from '../components/audit/DigitalEmployeeWorkRecords.vue' import DigitalEmployeeWorkRecords from '../components/audit/DigitalEmployeeWorkRecords.vue'
import TableEmptyState from '../components/shared/TableEmptyState.vue' import EnterpriseDetailPage from '../components/shared/EnterpriseDetailPage.vue'
import TableLoadingState from '../components/shared/TableLoadingState.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 { import {
@@ -307,8 +149,6 @@ import { runOrchestrator } from '../services/orchestrator.js'
import { isPlatformAdminUser } from '../utils/accessControl.js' import { isPlatformAdminUser } from '../utils/accessControl.js'
import { import {
DIGITAL_EMPLOYEE_SKILL_CATEGORY_OPTIONS, DIGITAL_EMPLOYEE_SKILL_CATEGORY_OPTIONS,
buildDigitalEmployeeDetailMeta,
buildDigitalEmployeeListMeta,
formatDigitalEmployeeCron, formatDigitalEmployeeCron,
isDigitalEmployeeAsset isDigitalEmployeeAsset
} from './scripts/auditViewDigitalEmployeeModel.js' } from './scripts/auditViewDigitalEmployeeModel.js'
@@ -319,28 +159,55 @@ import {
resolveDigitalEmployeeScheduleValue resolveDigitalEmployeeScheduleValue
} from './scripts/digitalEmployeeScheduleModel.js' } from './scripts/digitalEmployeeScheduleModel.js'
import { incrementVersion } from './scripts/auditViewRuntimeModel.js' import { incrementVersion } from './scripts/auditViewRuntimeModel.js'
import {
DIGITAL_EMPLOYEE_EXECUTION_MODE_OPTIONS,
buildDigitalEmployeeDetailTopBar,
buildEmployeeDetail,
buildEmployeeListItem,
buildEmployeePlaceholder,
filterDigitalEmployees,
sortEmployees
} from './scripts/digitalEmployeesViewModel.js'
import { import {
ENABLED_STATE_OPTIONS, ENABLED_STATE_OPTIONS,
formatDateTime,
normalizeText, normalizeText,
resolveStatusMeta,
STATUS_OPTIONS STATUS_OPTIONS
} from './scripts/auditViewModel.js' } from './scripts/auditViewModel.js'
const { currentUser } = useSystemState() const { currentUser } = useSystemState()
const { toast } = useToast() const { toast } = useToast()
const emit = defineEmits(['summary-change', 'detail-open-change']) const emit = defineEmits(['summary-change', 'detail-open-change', 'detail-topbar-change'])
const employees = ref([]) const employees = ref([])
const selectedEmployee = ref(null) const selectedEmployee = ref(null)
const selectedEmployeeId = ref('') const selectedEmployeeId = ref('')
const activeSection = ref('skills') const activeSection = ref('skills')
const workRecordDetailOpen = ref(false) const workRecordDetailOpen = ref(false)
const workRecordDetailTopBar = ref(null)
const isDetailOpen = computed(() => Boolean(selectedEmployee.value) || (activeSection.value === 'workRecords' && workRecordDetailOpen.value)) const isDetailOpen = computed(() => Boolean(selectedEmployee.value) || (activeSection.value === 'workRecords' && workRecordDetailOpen.value))
const digitalEmployeeDetailTopBar = computed(() => {
const employee = selectedEmployee.value
if (employee) {
return buildDigitalEmployeeDetailTopBar(employee)
}
if (activeSection.value === 'workRecords' && workRecordDetailOpen.value) {
return workRecordDetailTopBar.value
}
return null
})
watch(isDetailOpen, (newVal) => { watch(isDetailOpen, (newVal) => {
emit('detail-open-change', newVal) emit('detail-open-change', newVal)
}, { immediate: true }) }, { immediate: true })
watch(
digitalEmployeeDetailTopBar,
(value) => {
emit('detail-topbar-change', value)
},
{ immediate: true, deep: true }
)
const keyword = ref('') const keyword = ref('')
const selectedStatus = ref('') const selectedStatus = ref('')
const selectedEnabledState = ref('') const selectedEnabledState = ref('')
@@ -365,11 +232,7 @@ const scheduleEditorBusy = computed(() => actionState.value === 'save-digital-sc
const statusOptions = STATUS_OPTIONS const statusOptions = STATUS_OPTIONS
const enabledStateOptions = ENABLED_STATE_OPTIONS const enabledStateOptions = ENABLED_STATE_OPTIONS
const executionModeOptions = [ const executionModeOptions = DIGITAL_EMPLOYEE_EXECUTION_MODE_OPTIONS
{ value: '', label: '全部执行方式' },
{ value: 'timed', label: '定时执行' },
{ value: 'manual', label: '手动触发' }
]
const selectedStatusLabel = computed(() => const selectedStatusLabel = computed(() =>
statusOptions.find((item) => item.value === selectedStatus.value)?.label || '全部状态' statusOptions.find((item) => item.value === selectedStatus.value)?.label || '全部状态'
@@ -403,32 +266,11 @@ const schedulePreviewLabel = computed(() => {
}) })
const visibleEmployees = computed(() => { const visibleEmployees = computed(() => {
const searchText = normalizeText(keyword.value).toLowerCase() return filterDigitalEmployees(employees.value, {
return employees.value.filter((item) => { keyword: keyword.value,
const matchesKeyword = searchText selectedEnabledState: selectedEnabledState.value,
? [ selectedExecutionMode: selectedExecutionMode.value,
item.name, selectedStatus: selectedStatus.value
item.code,
item.summary,
item.owner,
item.scope,
item.executionMode,
item.skillCategory,
item.status,
item.enabledLabel
]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(searchText))
: true
const matchesStatus = selectedStatus.value ? item.statusValue === selectedStatus.value : true
const matchesEnabled = selectedEnabledState.value
? (selectedEnabledState.value === 'enabled') === Boolean(item.isEnabledValue)
: true
const matchesExecutionMode = selectedExecutionMode.value
? item.executionModeValue === selectedExecutionMode.value
: true
return matchesKeyword && matchesStatus && matchesEnabled && matchesExecutionMode
}) })
}) })
@@ -466,91 +308,6 @@ function resolveActor() {
return normalizeText(user.name) || normalizeText(user.username) || 'system' return normalizeText(user.name) || normalizeText(user.username) || 'system'
} }
function buildEmployeeListItem(asset) {
const meta = buildDigitalEmployeeListMeta(asset)
const statusMeta = resolveStatusMeta(asset.status)
const displayName = meta.name || '数字员工技能'
return {
id: asset.id,
rawCode: asset.code,
short: displayName.slice(0, 2),
badgeTone: 'blue',
name: displayName,
code: meta.code,
summary: meta.summary,
owner: meta.owner,
scope: meta.scope,
executionMode: meta.executionMode,
executionModeValue: meta.executionMode === '定时执行' ? 'timed' : 'manual',
skillCategory: meta.skillCategory,
version: asset.working_version || asset.current_version || '-',
currentVersion: asset.current_version || '-',
status: statusMeta.label,
statusValue: asset.status,
statusTone: statusMeta.tone,
enabledLabel: meta.enabledLabel,
enabledTone: meta.enabledTone,
isEnabledValue: meta.enabled,
configJson: asset.config_json || {},
updatedAt: formatDateTime(asset.updated_at),
updatedAtRaw: asset.updated_at || '',
digitalEmployee: meta
}
}
function buildEmployeePlaceholder(employee) {
return {
...employee,
type: 'digitalEmployees',
typeLabel: '数字员工',
currentVersion: employee.currentVersion || employee.version || '-',
workingVersion: employee.version || '-',
markdownContent: '',
loading: true
}
}
function buildEmployeeDetail(asset) {
const meta = buildDigitalEmployeeDetailMeta({
...asset,
updated_at: formatDateTime(asset.updated_at)
})
const statusMeta = resolveStatusMeta(asset.status)
return {
id: asset.id,
type: 'digitalEmployees',
typeLabel: '数字员工',
rawCode: asset.code,
short: meta.name.slice(0, 2),
name: meta.name,
code: meta.code,
summary: meta.description,
owner: meta.owner,
reviewer: meta.reviewer,
category: meta.category,
scope: meta.scope,
version: asset.working_version || asset.current_version || '-',
currentVersion: asset.current_version || '-',
workingVersion: asset.working_version || asset.current_version || '-',
status: statusMeta.label,
statusValue: asset.status,
statusTone: statusMeta.tone,
configJson: asset.config_json || {},
updatedAt: formatDateTime(asset.updated_at),
markdownContent: meta.sourceMarkdown,
digitalEmployee: meta,
loading: false
}
}
function sortEmployees(items) {
return [...items].sort((left, right) =>
String(right.updatedAtRaw || '').localeCompare(String(left.updatedAtRaw || ''))
)
}
async function loadEmployees() { async function loadEmployees() {
loading.value = true loading.value = true
errorMessage.value = '' errorMessage.value = ''

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

@@ -15,7 +15,7 @@
</article> </article>
<template v-else-if="isHermes && hermesRun"> <template v-else-if="isHermes && hermesRun">
<article v-if="!isKnowledgeIngestRunDetail" class="detail-hero panel"> <article class="detail-hero panel">
<div class="hero-copy"> <div class="hero-copy">
<div class="hero-tags"> <div class="hero-tags">
<span class="level-pill" :class="resolveLevelTone(resolveRunLevel(hermesRun))"> <span class="level-pill" :class="resolveLevelTone(resolveRunLevel(hermesRun))">
@@ -43,12 +43,7 @@
{{ hermesRunAlert.message }} {{ hermesRunAlert.message }}
</article> </article>
<KnowledgeIngestRunPanel <div class="detail-grid">
v-if="isKnowledgeIngestRunDetail"
:run="hermesRun"
/>
<div v-if="!isKnowledgeIngestRunDetail" class="detail-grid">
<article class="panel detail-card wide"> <article class="panel detail-card wide">
<div class="card-head"> <div class="card-head">
<h3>基本信息</h3> <h3>基本信息</h3>
@@ -68,7 +63,7 @@
</div> </div>
</article> </article>
<article v-if="!isKnowledgeIngestRunDetail" class="panel detail-card"> <article class="panel detail-card">
<div class="card-head"> <div class="card-head">
<h3>处理链路</h3> <h3>处理链路</h3>
<p>按工具调用顺序查看执行链</p> <p>按工具调用顺序查看执行链</p>
@@ -97,7 +92,7 @@
</div> </div>
</article> </article>
<article v-if="selectedToolCall && !isKnowledgeIngestRunDetail" class="panel detail-card"> <article v-if="selectedToolCall" class="panel detail-card">
<div class="card-head"> <div class="card-head">
<h3>当前 ToolCall</h3> <h3>当前 ToolCall</h3>
<p>查看当前工具调用的请求与返回</p> <p>查看当前工具调用的请求与返回</p>
@@ -189,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>
@@ -199,7 +194,6 @@
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import KnowledgeIngestRunPanel from '../components/logs/KnowledgeIngestRunPanel.vue'
import { fetchAgentRunDetail } from '../services/agentAssets.js' import { fetchAgentRunDetail } from '../services/agentAssets.js'
import { fetchSystemLogEntry } from '../services/systemLogs.js' import { fetchSystemLogEntry } from '../services/systemLogs.js'
import { import {
@@ -210,7 +204,6 @@ import {
resolveAgentRunHeartbeat, resolveAgentRunHeartbeat,
resolveAgentRunStatus resolveAgentRunStatus
} from '../utils/agentRunMonitor.js' } from '../utils/agentRunMonitor.js'
import { isKnowledgeIngestRun } from '../utils/knowledgeIngestLogModel.js'
const SOURCE_LABELS = { const SOURCE_LABELS = {
schedule: '定时任务', schedule: '定时任务',
@@ -230,7 +223,6 @@ let pollTimer = 0
const isHermes = computed(() => route.params.logKind === 'hermes') const isHermes = computed(() => route.params.logKind === 'hermes')
const isSystem = computed(() => route.params.logKind === 'system') const isSystem = computed(() => route.params.logKind === 'system')
const isKnowledgeIngestRunDetail = computed(() => isKnowledgeIngestRun(hermesRun.value))
const selectedToolCall = computed(() => const selectedToolCall = computed(() =>
(hermesRun.value?.tool_calls || []).find((item) => item.id === selectedToolCallId.value) || null (hermesRun.value?.tool_calls || []).find((item) => item.id === selectedToolCallId.value) || null
) )
@@ -444,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>

File diff suppressed because it is too large Load Diff

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

@@ -0,0 +1,17 @@
export function normalizeText(value) {
return String(value || '').trim()
}
export function isPlainObject(value) {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}
export function readConfigJson(value) {
if (isPlainObject(value?.configJson)) {
return value.configJson
}
if (isPlainObject(value?.config_json)) {
return value.config_json
}
return {}
}

View File

@@ -0,0 +1,44 @@
import { normalizeText } from './auditViewModel.js'
function resolveRiskScoreCardColor(level) {
const normalized = normalizeText(level).toLowerCase()
if (['critical', 'high'].includes(normalized)) return '#ef4444'
if (['medium', 'warning'].includes(normalized)) return '#f59e0b'
if (['low', 'success'].includes(normalized)) return 'var(--success)'
return 'var(--theme-primary)'
}
export function buildAuditDetailTopBar({
skill,
usesJsonRiskRule = false
} = {}) {
if (!skill) return null
const title = normalizeText(skill.name) || '规则中心详情'
const desc =
normalizeText(skill.riskRuleSubtitle) ||
normalizeText(skill.summary) ||
normalizeText(skill.configDesc) ||
'查看规则配置、版本审核、测试结果与上线状态。'
const kpis = []
if (usesJsonRiskRule) {
const scoreLevel = skill.riskRuleScoreLevel || skill.riskRuleSeverity
const score = skill.riskRuleScore ?? '--'
kpis.push({
label: '风险分',
value: String(score),
unit: score === '--' ? '' : '分',
meta: normalizeText(skill.riskRuleScoreLabel || skill.riskRuleSeverityLabel) || '待评估',
trend: ['critical', 'high', 'medium', 'warning'].includes(normalizeText(scoreLevel).toLowerCase())
? 'down'
: 'up',
color: resolveRiskScoreCardColor(scoreLevel)
})
}
return {
view: { title, desc },
kpis
}
}

View File

@@ -0,0 +1,128 @@
import {
DOMAIN_LABELS,
REVIEW_META,
SCENARIO_LABELS,
STATUS_META
} from './auditViewMetadata.js'
import { normalizeText } from './auditViewDataUtils.js'
export function makeShort(value) {
const text = normalizeText(value).replace(/\s+/g, '')
if (!text) {
return 'AG'
}
return text.slice(0, 2).toUpperCase()
}
export function formatDateTime(value) {
if (!value) {
return '未记录'
}
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return String(value)
}
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false
})
.format(date)
.replace(/\//g, '-')
}
export function resolveDomainLabel(value) {
return DOMAIN_LABELS[value] || normalizeText(value) || '未分类'
}
export function resolveStatusMeta(value) {
return STATUS_META[value] || { label: normalizeText(value) || '未知状态', tone: 'draft' }
}
export function resolveReviewMeta(value) {
return REVIEW_META[value] || { label: '暂无审核', tone: 'draft' }
}
export function resolveTimelineEventMeta(value) {
return {
created: { label: '创建工作稿', icon: 'mdi mdi-file-document-edit-outline', tone: 'draft' },
submitted: { label: '提交审核', icon: 'mdi mdi-send-outline', tone: 'warning' },
approved: { label: '审核通过', icon: 'mdi mdi-check-decagram-outline', tone: 'success' },
rejected: { label: '审核驳回', icon: 'mdi mdi-close-octagon-outline', tone: 'danger' },
published: { label: '正式上线', icon: 'mdi mdi-rocket-launch-outline', tone: 'success' },
restored: { label: '恢复生成工作稿', icon: 'mdi mdi-history', tone: 'info' }
}[normalizeText(value)] || { label: normalizeText(value) || '版本事件', icon: 'mdi mdi-circle-medium', tone: 'draft' }
}
export function resolveDiffChangeMeta(value) {
return {
added: { label: '新增', tone: 'success' },
removed: { label: '删除', tone: 'danger' },
modified: { label: '修改', tone: 'warning' }
}[normalizeText(value)] || { label: normalizeText(value) || '变化', tone: 'draft' }
}
export function formatScenarioList(items) {
if (!Array.isArray(items) || !items.length) {
return '未配置场景'
}
return items
.map((item) => SCENARIO_LABELS[item] || item)
.filter(Boolean)
.join(' / ')
}
export function resolveTypeKey(assetType) {
if (assetType === 'rule') {
return 'rules'
}
if (assetType === 'mcp') {
return 'mcp'
}
return ''
}
export function formatSeverity(value) {
const severity = normalizeText(value).toLowerCase()
if (severity === 'high') {
return '高风险'
}
if (severity === 'medium') {
return '中风险'
}
if (severity === 'low') {
return '低风险'
}
return '未配置'
}
export function formatInputSummary(items) {
if (!Array.isArray(items) || !items.length) {
return '无输入'
}
return `${items.length} 项输入`
}
export function formatOutputSummary(items) {
if (!Array.isArray(items) || !items.length) {
return '无输出'
}
return `${items.length} 项输出`
}
export function formatSpreadsheetChangeSummary(summary) {
const normalized = normalizeText(summary)
return (
normalized
.replace(/^(ONLYOFFICE\s*)?在线编辑[:]\s*/i, '')
.replace(/^ONLYOFFICE\s*在线编辑保存[。.]?\s*/i, '')
.replace(/^保存表格[:]\s*/i, '')
.trim() || '表格内容已保存。'
)
}

View File

@@ -0,0 +1,235 @@
import { computed } from 'vue'
import {
ENABLED_STATE_OPTIONS,
ONLINE_STATE_OPTIONS,
RISK_SCENARIO_OPTIONS,
STATUS_OPTIONS
} from './auditViewMetadata.js'
import { RISK_RULE_LEVEL_OPTIONS } from './auditViewRiskRuleModel.js'
import {
normalizeText,
resolveDomainLabel,
resolveStatusMeta
} from './auditViewModel.js'
import { filterAuditAssets } from './auditViewRuntimeModel.js'
function buildOptions(items, valueGetter, labelGetter, defaultLabel) {
const values = []
const seen = new Set()
items.forEach((item) => {
const value = normalizeText(valueGetter(item))
if (!value || seen.has(value)) {
return
}
seen.add(value)
values.push(value)
})
return [
{ value: '', label: defaultLabel },
...values.map((value) => ({
value,
label: labelGetter(value)
}))
]
}
export function useAuditListFilters({
activeType,
activeTabLabel,
currentAssets,
keyword,
selectedDomain,
selectedOwner,
selectedRiskLevel,
selectedStatus,
selectedRiskScenario,
selectedOnlineState,
selectedEnabledState
}) {
const showRiskScenarioFilter = computed(() =>
['financialRules', 'riskRules'].includes(activeType.value)
)
const showOwnerFilter = computed(() => activeType.value !== 'riskRules')
const showRiskLevelFilter = computed(() => activeType.value === 'riskRules')
const showStatusFilter = computed(() => true)
const showOnlineFilter = computed(() => false)
const showEnabledFilter = computed(() => false)
const domainOptions = computed(() =>
buildOptions(
currentAssets.value,
(item) => item.domainValue,
(value) => resolveDomainLabel(value),
'全部业务域'
)
)
const ownerOptions = computed(() =>
buildOptions(
currentAssets.value,
(item) => item.owner,
(value) => value,
'全部负责人'
)
)
const riskLevelOptions = computed(() => [
{ value: '', label: '全部风险等级' },
...RISK_RULE_LEVEL_OPTIONS
])
const selectedDomainLabel = computed(
() => domainOptions.value.find((item) => item.value === selectedDomain.value)?.label || '业务域'
)
const selectedOwnerLabel = computed(
() =>
ownerOptions.value.find((item) => item.value === selectedOwner.value)?.label ||
'负责人'
)
const selectedRiskLevelLabel = computed(
() =>
riskLevelOptions.value.find((item) => item.value === selectedRiskLevel.value)?.label ||
'风险等级'
)
const selectedStatusLabel = computed(
() => STATUS_OPTIONS.find((item) => item.value === selectedStatus.value)?.label || '状态'
)
const selectedRiskScenarioLabel = computed(
() =>
RISK_SCENARIO_OPTIONS.find((item) => item.value === selectedRiskScenario.value)?.label ||
'使用场景'
)
const selectedOnlineStateLabel = computed(
() =>
ONLINE_STATE_OPTIONS.find((item) => item.value === selectedOnlineState.value)?.label ||
'是否上线'
)
const selectedEnabledStateLabel = computed(
() =>
ENABLED_STATE_OPTIONS.find((item) => item.value === selectedEnabledState.value)?.label ||
'是否启用'
)
const activeFilterTokens = computed(() => {
const tokens = []
if (selectedDomain.value) {
tokens.push(`业务域:${resolveDomainLabel(selectedDomain.value)}`)
}
if (showRiskScenarioFilter.value && selectedRiskScenario.value) {
tokens.push(`使用场景:${selectedRiskScenario.value}`)
}
if (showStatusFilter.value && selectedStatus.value) {
tokens.push(`状态:${resolveStatusMeta(selectedStatus.value).label}`)
}
if (showOnlineFilter.value && selectedOnlineState.value) {
tokens.push(`是否上线:${selectedOnlineStateLabel.value}`)
}
if (showEnabledFilter.value && selectedEnabledState.value) {
tokens.push(`是否启用:${selectedEnabledStateLabel.value}`)
}
if (showOwnerFilter.value && selectedOwner.value) {
tokens.push(`负责人:${selectedOwner.value}`)
}
if (showRiskLevelFilter.value && selectedRiskLevel.value) {
tokens.push(`风险等级:${selectedRiskLevelLabel.value}`)
}
if (keyword.value.trim()) {
tokens.push(`搜索:${keyword.value.trim()}`)
}
return tokens
})
const visibleSkills = computed(() =>
filterAuditAssets(currentAssets.value, {
keyword: keyword.value,
selectedDomain: selectedDomain.value,
selectedOwner: selectedOwner.value,
selectedRiskLevel: selectedRiskLevel.value,
selectedStatus: selectedStatus.value,
selectedRiskScenario: selectedRiskScenario.value,
selectedOnlineState: selectedOnlineState.value,
selectedEnabledState: selectedEnabledState.value,
showStatusFilter: showStatusFilter.value,
showRiskScenarioFilter: showRiskScenarioFilter.value,
showOnlineFilter: showOnlineFilter.value,
showEnabledFilter: showEnabledFilter.value
})
)
const auditEmptyState = computed(() => {
const hasFilters = activeFilterTokens.value.length > 0
const supportedFilters = [
'业务域',
...(showOwnerFilter.value ? ['负责人'] : []),
...(showRiskLevelFilter.value ? ['风险等级'] : []),
...(showRiskScenarioFilter.value ? ['使用场景'] : []),
...(showStatusFilter.value ? ['状态'] : []),
...(showOnlineFilter.value ? ['是否上线'] : []),
...(showEnabledFilter.value ? ['是否启用'] : []),
'关键词'
]
if (!currentAssets.value.length) {
return {
eyebrow: `${activeTabLabel.value}资产`,
title: `${activeTabLabel.value}列表暂时还是空的`,
desc: `当前环境里还没有可展示的${activeTabLabel.value}资产。完成接入或同步后,会统一展示在这里。`,
icon: 'mdi mdi-database-search-outline',
actionLabel: '',
actionIcon: '',
tone: 'amber',
artLabel: 'ASSET',
tips: [
'切换页签可查看其他资产类型',
`支持按${supportedFilters.slice(0, -1).join('、')}和关键词做过滤`
]
}
}
return {
eyebrow: '筛选结果为空',
title: `没有找到匹配的${activeTabLabel.value}`,
desc: hasFilters
? `试试清空${supportedFilters.join('、')}筛选,再重新查看。`
: `当前列表中还没有满足展示条件的${activeTabLabel.value}资产。`,
icon: hasFilters ? 'mdi mdi-tune-variant' : 'mdi mdi-view-grid-outline',
actionLabel: hasFilters ? '清空筛选' : '',
actionIcon: hasFilters ? 'mdi mdi-filter-remove-outline' : '',
tone: hasFilters ? 'primary' : 'slate',
artLabel: hasFilters ? 'FILTER' : 'QUEUE',
tips: hasFilters
? [
`${supportedFilters.join('、')}会叠加过滤`,
showRiskScenarioFilter.value
? '可以换个规则名称或场景分类继续搜索'
: '可以换个编码、名称或负责人关键词继续搜索'
]
: ['列表展示来自真实资产 API', '切换资产类型后会自动重新拉取数据']
}
})
return {
activeFilterTokens,
auditEmptyState,
domainOptions,
ownerOptions,
riskLevelOptions,
selectedDomainLabel,
selectedEnabledStateLabel,
selectedOnlineStateLabel,
selectedOwnerLabel,
selectedRiskLevelLabel,
selectedRiskScenarioLabel,
selectedStatusLabel,
showEnabledFilter,
showOnlineFilter,
showOwnerFilter,
showRiskLevelFilter,
showRiskScenarioFilter,
showStatusFilter,
visibleSkills
}
}

View File

@@ -0,0 +1,205 @@
import {
resolveRiskRuleScoreLabel,
resolveRiskRuleScoreLevel,
resolveRiskRuleSeverity,
resolveRiskRuleSeverityLabel
} from './auditViewRiskRuleModel.js'
import { normalizeText } from './auditViewDataUtils.js'
import {
formatDateTime,
formatScenarioList,
formatSeverity,
makeShort,
resolveDomainLabel,
resolveStatusMeta,
resolveTypeKey
} from './auditViewFormatters.js'
import {
buildRiskListSubtitle,
isJsonRiskRuleSource,
isSpreadsheetRuleSource,
readRuleDocumentMeta,
resolveRuleScenarioCategory,
resolveRuleScenarioList,
resolveTabId,
resolveTabMeta
} from './auditViewRuleClassifier.js'
import {
resolveRiskRuleBusinessStage,
resolveRiskRuleEnabled,
resolveRiskRuleOnlineMeta
} from './auditViewRiskRuleState.js'
export function findLatestMcpCall(runs, assetCode) {
const expectedToolName = normalizeText(assetCode).replace(/^mcp\./, '')
for (const run of runs) {
for (const toolCall of run.tool_calls || []) {
const toolName = normalizeText(toolCall.tool_name)
if (
toolName === expectedToolName ||
toolName.endsWith(expectedToolName) ||
expectedToolName.endsWith(toolName)
) {
return {
run,
toolCall
}
}
}
}
return null
}
export function buildRowRuntime(asset, typeKey) {
if (typeKey === 'rules') {
return formatSeverity(asset.config_json?.severity)
}
if (typeKey === 'mcp') {
return normalizeText(asset.config_json?.endpoint) || '未配置地址'
}
return ''
}
export function buildRowMetric(asset, typeKey) {
if (typeKey === 'rules') {
return normalizeText(asset.modified_by) || '未记录'
}
if (typeKey === 'mcp') {
return asset.config_json?.timeout_ms ? `${asset.config_json.timeout_ms} ms` : '未配置超时'
}
return ''
}
export function buildListItem(asset) {
const typeKey = resolveTypeKey(asset.asset_type)
const tabId = resolveTabId(asset, typeKey)
if (!tabId) {
return null
}
const tabMeta = resolveTabMeta(tabId, typeKey)
const statusMeta = resolveStatusMeta(asset.status)
const workingVersion = asset.working_version || asset.current_version || '-'
const changeCount =
typeof asset.change_count === 'number'
? asset.change_count
: Array.isArray(asset.recent_versions)
? Math.max(asset.recent_versions.length - 1, 0)
: 0
const modifiedBy =
normalizeText(asset.modified_by) ||
normalizeText(
Array.isArray(asset.recent_versions)
? asset.recent_versions.find((item) => item.version === workingVersion)?.created_by
: ''
)
const isRiskRule = tabId === 'riskRules'
const usesSpreadsheetRule = typeKey === 'rules' && isSpreadsheetRuleSource(asset)
const usesJsonRiskRule = typeKey === 'rules' && isJsonRiskRuleSource(asset)
const ruleDocument = readRuleDocumentMeta(asset)
const ruleScenarioCategory = typeKey === 'rules' ? resolveRuleScenarioCategory(asset, tabId) : ''
const listSubtitle = isRiskRule
? buildRiskListSubtitle(asset.description)
: normalizeText(asset.description)
const onlineMeta = resolveRiskRuleOnlineMeta(asset.status)
const isOnlineValue = onlineMeta.online
const isEnabledValue = usesJsonRiskRule ? resolveRiskRuleEnabled(asset) : true
const reviewer = normalizeText(asset.reviewer) || '待分配'
const creator =
normalizeText(asset.owner) ||
normalizeText(asset.config_json?.generation_request?.actor) ||
modifiedBy ||
'未知'
const publisher = isRiskRule ? creator : ''
const riskRuleCreatedAt = formatDateTime(asset.created_at || asset.updated_at)
const businessStage = usesJsonRiskRule
? resolveRiskRuleBusinessStage(asset)
: { value: '', label: '' }
const ruleScenarioList = typeKey === 'rules' ? resolveRuleScenarioList(asset, tabId) : []
const riskScoreLevel = usesJsonRiskRule
? resolveRiskRuleScoreLevel(asset.config_json, asset.config_json)
: ''
const riskLevelValue = usesJsonRiskRule
? riskScoreLevel || resolveRiskRuleSeverity(asset.config_json)
: ''
const riskLevelLabel = usesJsonRiskRule
? riskScoreLevel
? resolveRiskRuleScoreLabel(asset.config_json, asset.config_json) || resolveRiskRuleSeverityLabel(asset.config_json)
: resolveRiskRuleSeverityLabel(asset.config_json)
: ''
const displayName = asset.name
const displayCode = asset.code
const displaySummary = listSubtitle
const displayOwner = isRiskRule ? creator : asset.owner
const displayReviewer = reviewer
const displayCategory = resolveDomainLabel(asset.domain)
const displayScope = typeKey === 'rules' ? ruleScenarioCategory || '通用' : formatScenarioList(asset.scenario_json)
const displayEnabledValue = isEnabledValue
const displayEnabledLabel = isEnabledValue ? '是' : '否'
const displayEnabledTone = isEnabledValue ? 'success' : 'disabled'
const searchText = [
displayName,
displayCode,
displaySummary,
displayOwner,
displayScope,
riskLevelLabel
]
.map((value) => normalizeText(value).toLowerCase())
.filter(Boolean)
.join(' ')
return {
id: asset.id,
tabId,
type: typeKey,
isPreviewMock: Boolean(asset.isPreviewMock),
usesSpreadsheetRule,
usesJsonRiskRule,
ruleDocument,
typeLabel: tabMeta.typeLabel,
short: makeShort(displayName),
name: displayName,
code: displayCode,
rawCode: asset.code,
summary: displaySummary,
listSubtitle: displaySummary,
category: displayCategory,
owner: displayOwner,
reviewer: displayReviewer,
scope: displayScope,
riskCategory: ruleScenarioCategory,
scenarioList: ruleScenarioList,
businessStageValue: businessStage.value,
businessStageLabel: businessStage.label,
riskLevelValue,
riskLevelLabel,
riskLevelTone: riskLevelValue,
model: buildRowRuntime(asset, typeKey),
version: workingVersion,
versionDisplay: typeKey === 'rules' ? `${changeCount}` : workingVersion,
publishedVersion: asset.published_version || '-',
workingVersion,
status: statusMeta.label,
statusValue: asset.status,
statusTone: statusMeta.tone,
hitRate: isRiskRule ? publisher : buildRowMetric({ ...asset, modified_by: modifiedBy }, typeKey),
creator,
publisher,
publishedAt: isOnlineValue ? formatDateTime(asset.published_at || asset.updated_at) : '-',
isOnlineValue,
isOnlineLabel: onlineMeta.label,
isOnlineTone: onlineMeta.tone,
isEnabledValue: displayEnabledValue,
isEnabledLabel: displayEnabledLabel,
isEnabledTone: displayEnabledTone,
modifiedBy,
changeCount,
updatedAt: isRiskRule ? riskRuleCreatedAt : formatDateTime(asset.updated_at),
badgeTone: tabMeta.badgeTone,
domainValue: asset.domain,
searchText
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,197 @@
import {
buildRiskRuleFieldSummary,
formatRiskRuleAge,
resolveRiskRuleBusinessDescription,
resolveRiskRuleCreatedAt,
resolveRiskRuleFields,
resolveRiskRuleFlow,
resolveRiskRuleFlowDiagramSvg,
resolveRiskRuleScore,
resolveRiskRuleScoreDetail,
resolveRiskRuleScoreLabel,
resolveRiskRuleScoreLevel,
resolveRiskRuleSeverity,
resolveRiskRuleSeverityLabel
} from './auditViewRiskRuleModel.js'
import {
isPlainObject,
normalizeText,
readConfigJson
} from './auditViewDataUtils.js'
import { formatDateTime } from './auditViewFormatters.js'
import {
buildRiskListSubtitle,
resolveRiskRuleCategory,
resolveRiskRuleDescription,
resolveRiskRuleSourceRef
} from './auditViewRuleClassifier.js'
export function resolveRiskRuleEnabled(source, rulePayload = null) {
const configJson = readConfigJson(source)
if (isPlainObject(rulePayload) && rulePayload.enabled === false) {
return false
}
if (source?.enabled === false || configJson.enabled === false) {
return false
}
return true
}
const LAST_OPERATION_LABELS = {
generate: '开始生成',
create: '创建',
test: '测试',
online: '上线',
offline: '下线',
delete: '删除',
update: '更新'
}
const RISK_RULE_BUSINESS_STAGE_LABELS = {
expense_application: '费用申请',
reimbursement: '费用报销'
}
export function resolveRiskRuleBusinessStage(source, rulePayload = null) {
const configJson = readConfigJson(source)
const metadata = rulePayload && typeof rulePayload === 'object' ? rulePayload.metadata || {} : {}
const stage =
normalizeText(configJson.business_stage) ||
normalizeText(metadata.business_stage) ||
normalizeText(rulePayload?.business_stage)
const label =
normalizeText(configJson.business_stage_label) ||
normalizeText(metadata.business_stage_label) ||
RISK_RULE_BUSINESS_STAGE_LABELS[stage]
return {
value: stage || 'reimbursement',
label: label || '费用报销'
}
}
export function resolveRiskRuleOnlineMeta(statusValue) {
if (statusValue === 'active') {
return { label: '已上线', tone: 'success', online: true }
}
if (statusValue === 'disabled') {
return { label: '已下线', tone: 'disabled', online: false }
}
if (statusValue === 'generating') {
return { label: '生成中', tone: 'info', online: false }
}
if (statusValue === 'failed') {
return { label: '生成失败', tone: 'danger', online: false }
}
return { label: '待上线', tone: 'draft', online: false }
}
export function resolveLastOperationLabel(source, fallback = {}) {
const configJson = readConfigJson(source)
const operation = isPlainObject(configJson.last_operation) ? configJson.last_operation : {}
const action = normalizeText(operation.action) || normalizeText(fallback.action) || 'create'
const actor = normalizeText(operation.actor) || normalizeText(fallback.actor) || '系统'
const at = normalizeText(operation.at) || normalizeText(fallback.at)
const actionLabel = LAST_OPERATION_LABELS[action] || action
const timeLabel = formatDateTime(at)
return timeLabel && timeLabel !== '-' ? `${actionLabel}${actor} · ${timeLabel}` : `${actionLabel}${actor}`
}
export function applyRiskRuleJsonState(target, payload, apiPayload) {
const rulePayload = isPlainObject(payload) ? payload : {}
const metadata = rulePayload.metadata && typeof rulePayload.metadata === 'object'
? rulePayload.metadata
: {}
const apiConfig = apiPayload?.config_json && typeof apiPayload.config_json === 'object'
? apiPayload.config_json
: {}
const fullDescription =
resolveRiskRuleDescription(rulePayload) ||
normalizeText(apiPayload?.description) ||
normalizeText(target.riskRuleDescription)
const riskCategory =
normalizeText(metadata.expense_category_label) ||
normalizeText(apiConfig.expense_category_label) ||
normalizeText(rulePayload.risk_category) ||
resolveRiskRuleCategory({ ...target, risk_category: rulePayload.risk_category, config_json: rulePayload })
const businessStage = resolveRiskRuleBusinessStage(target, rulePayload)
const riskRuleFields = resolveRiskRuleFields(rulePayload)
const riskRuleCreatedAt = resolveRiskRuleCreatedAt(rulePayload, target.createdAt || target.updatedAt)
const riskRuleScoreLevel = resolveRiskRuleScoreLevel(rulePayload, apiConfig)
const statusValue = apiPayload?.status || target.statusValue || 'draft'
const onlineMeta = resolveRiskRuleOnlineMeta(statusValue)
const isEnabledValue = resolveRiskRuleEnabled(target, rulePayload)
const publisher =
target.creator ||
normalizeText(apiPayload?.owner) ||
normalizeText(metadata.created_by) ||
normalizeText(apiPayload?.recent_versions?.[0]?.created_by) ||
'未知'
let publishedAt = target.publishedAt || '-'
if (apiPayload?.recent_versions) {
const publishedVersionObj = apiPayload.recent_versions.find((item) =>
item?.is_current || item?.version === apiPayload?.published_version
)
publishedAt = publishedVersionObj?.created_at
? formatDateTime(publishedVersionObj.created_at)
: (apiPayload?.latest_review?.reviewed_at ? formatDateTime(apiPayload.latest_review.reviewed_at) : '-')
} else if (apiPayload?.latest_review?.reviewed_at) {
publishedAt = formatDateTime(apiPayload.latest_review.reviewed_at)
}
return {
...target,
riskRuleDescription: fullDescription,
riskRuleBusinessDescription: resolveRiskRuleBusinessDescription(rulePayload, fullDescription),
riskRuleSubtitle: buildRiskListSubtitle(fullDescription, 48),
riskCategory,
businessStageValue: businessStage.value,
businessStageLabel: businessStage.label,
scope: riskCategory,
riskRuleSourceRef: resolveRiskRuleSourceRef(rulePayload),
riskRuleSeverity: riskRuleScoreLevel || resolveRiskRuleSeverity(rulePayload),
riskRuleSeverityLabel: riskRuleScoreLevel
? resolveRiskRuleScoreLabel(rulePayload, apiConfig)
: resolveRiskRuleSeverityLabel(rulePayload),
riskRuleScore: resolveRiskRuleScore(rulePayload, apiConfig),
riskRuleScoreLabel: resolveRiskRuleScoreLabel(rulePayload, apiConfig),
riskRuleScoreLevel: riskRuleScoreLevel || resolveRiskRuleSeverity(rulePayload),
riskRuleScoreDetail: resolveRiskRuleScoreDetail(rulePayload, apiConfig),
riskRuleCreatedAt: formatDateTime(riskRuleCreatedAt),
riskRuleAgeLabel: formatRiskRuleAge(riskRuleCreatedAt),
riskRuleFields,
riskRuleFieldSummary: buildRiskRuleFieldSummary(riskRuleFields),
riskRuleFlow: resolveRiskRuleFlow(rulePayload, riskRuleFields),
riskRuleFlowDiagramSvg: resolveRiskRuleFlowDiagramSvg({
...rulePayload,
flow_diagram_svg: normalizeText(apiPayload?.flow_diagram_svg) || rulePayload?.flow_diagram_svg
}),
riskRuleRequiresAttachment: Boolean(
rulePayload.requires_attachment ||
metadata.requires_attachment ||
apiConfig.requires_attachment ||
target.configJson?.requires_attachment
),
riskRuleSummary: {
name: apiPayload?.name || target.name,
evaluator: apiPayload?.evaluator || rulePayload.evaluator || '',
ontologySignal: apiPayload?.ontology_signal || rulePayload.ontology_signal || '',
inputs: apiPayload?.inputs || rulePayload.inputs || {},
outcomes: apiPayload?.outcomes || rulePayload.outcomes || {}
},
riskRuleJsonText: JSON.stringify(rulePayload, null, 2),
isOnlineValue: onlineMeta.online,
isOnlineLabel: onlineMeta.label,
isOnlineTone: onlineMeta.tone,
isEnabledValue,
isEnabledLabel: isEnabledValue ? '是' : '否',
isEnabledTone: isEnabledValue ? 'success' : 'disabled',
lastOperationLabel: resolveLastOperationLabel(target, {
actor: publisher,
at: riskRuleCreatedAt
}),
publisher,
publishedAt
}
}

View File

@@ -0,0 +1,328 @@
import {
JSON_RISK_DETAIL_MODE,
LEGACY_RISK_SCENARIO_KEYS,
RISK_SCENARIO_VALUES,
RULE_TAB_TAG_ALIASES,
SPREADSHEET_DETAIL_MODE,
TAB_META,
TYPE_META
} from './auditViewMetadata.js'
import {
isPlainObject,
normalizeText,
readConfigJson
} from './auditViewDataUtils.js'
import { formatScenarioList } from './auditViewFormatters.js'
const EXPENSE_TYPE_SCENARIO_LABELS = {
travel: '差旅费',
hotel: '住宿费',
transport: '交通费',
meal: '业务招待费',
meeting: '会务费',
marketing: '市场推广费',
office: '办公用品费',
training: '培训费',
software: '软件服务费',
communication: '通信费',
welfare: '福利费'
}
export function readRuleDocumentMeta(value) {
const configJson = readConfigJson(value)
return isPlainObject(configJson.rule_document) ? configJson.rule_document : null
}
export function isSpreadsheetRuleSource(value) {
const configJson = readConfigJson(value)
return normalizeText(configJson.detail_mode || configJson.rule_detail_mode).toLowerCase() === SPREADSHEET_DETAIL_MODE
}
export function isJsonRiskRuleSource(value) {
const configJson = readConfigJson(value)
return normalizeText(configJson.detail_mode || configJson.rule_detail_mode).toLowerCase() === JSON_RISK_DETAIL_MODE
}
export function normalizeRuleTagValue(value) {
return normalizeText(value).toLowerCase().replace(/[\s_-]+/g, '')
}
export function collectRuleTagValues(source) {
const configJson = readConfigJson(source)
const rawValues = [
configJson.tag,
configJson.rule_tag,
...(Array.isArray(configJson.tags) ? configJson.tags : []),
...(Array.isArray(configJson.rule_tags) ? configJson.rule_tags : [])
]
return rawValues.map((item) => normalizeText(item)).filter(Boolean)
}
export function resolveRuleTabId(source) {
const code = normalizeText(source?.code || '').toLowerCase()
if (code.startsWith('risk.')) {
return 'riskRules'
}
if (isJsonRiskRuleSource(source)) {
return 'riskRules'
}
const normalizedTags = collectRuleTagValues(source).map((item) => normalizeRuleTagValue(item))
if (normalizedTags.some((item) => RULE_TAB_TAG_ALIASES.riskRules.has(item))) {
return 'riskRules'
}
if (normalizedTags.some((item) => RULE_TAB_TAG_ALIASES.financialRules.has(item))) {
return 'financialRules'
}
return ''
}
export function resolveTabId(source, typeKey) {
if (typeKey === 'rules') {
return resolveRuleTabId(source)
}
return typeKey
}
export function resolveTabMeta(tabId, typeKey) {
if (TAB_META[tabId]) {
return TAB_META[tabId]
}
if (typeKey === 'rules') {
return {
...TYPE_META.rules,
typeKey: 'rules',
badgeTone: 'primary'
}
}
return TAB_META[typeKey]
}
export function resolveRiskRuleDescription(payload) {
if (!isPlainObject(payload)) {
return ''
}
return normalizeText(payload.description)
}
export function resolveRiskRuleSourceRef(payload) {
if (!isPlainObject(payload)) {
return ''
}
const metadata = isPlainObject(payload.metadata) ? payload.metadata : {}
return normalizeText(metadata.source_ref)
}
export function inferRiskCategoryFromCode(code) {
const normalized = normalizeText(code).toLowerCase()
if (normalized.startsWith('risk.travel.')) {
return '差旅'
}
if (normalized.startsWith('risk.invoice.')) {
return '发票'
}
if (normalized.includes('entertainment') || normalized.includes('meal_localized')) {
return '餐饮招待'
}
if (normalized.includes('consecutive_transport')) {
return '交通出行'
}
if (normalized.startsWith('risk.expense.')) {
return '费用科目'
}
return '通用'
}
export function normalizeRiskScenarioCategory(value) {
const normalized = normalizeText(value)
const alias = normalized === '通讯费' ? '通信费' : normalized
return RISK_SCENARIO_VALUES.has(alias) ? alias : ''
}
export function normalizeExpenseTypeScenarioLabels(value) {
const values = Array.isArray(value) ? value : normalizeText(value) ? [value] : []
const labels = []
const seen = new Set()
values.forEach((item) => {
const key = normalizeText(item).toLowerCase()
const label = EXPENSE_TYPE_SCENARIO_LABELS[key] || normalizeRiskScenarioCategory(item)
if (!label || seen.has(label)) {
return
}
seen.add(label)
labels.push(label)
})
return labels
}
export function readRiskRuleExpenseTypes(source) {
const configJson = readConfigJson(source)
const metadata = isPlainObject(configJson.metadata) ? configJson.metadata : {}
const appliesTo = isPlainObject(configJson.applies_to) ? configJson.applies_to : {}
const values = []
;[
configJson.expense_types,
metadata.expense_types,
appliesTo.expense_types,
source?.expense_types
].forEach((item) => {
if (Array.isArray(item)) {
values.push(...item)
} else if (normalizeText(item)) {
values.push(item)
}
})
return values
}
export function readScenarioItems(source) {
if (Array.isArray(source?.scenario_json)) {
return source.scenario_json
}
if (Array.isArray(source?.scenarioList)) {
return source.scenarioList
}
return []
}
export function resolveRiskRuleCategory(source) {
const configJson = readConfigJson(source)
const expenseScenarioLabels = normalizeExpenseTypeScenarioLabels(readRiskRuleExpenseTypes(source))
if (expenseScenarioLabels.length) {
return formatScenarioList(expenseScenarioLabels)
}
const expenseCategoryLabel =
normalizeText(configJson.expense_category_label) ||
normalizeText(configJson.metadata?.expense_category_label) ||
normalizeText(source?.expense_category_label)
if (expenseCategoryLabel) {
return expenseCategoryLabel
}
const explicit = normalizeRiskScenarioCategory(configJson.risk_category)
if (explicit) {
return explicit
}
const payloadCategory = normalizeRiskScenarioCategory(source?.risk_category)
if (payloadCategory) {
return payloadCategory
}
const scenarioItems = readScenarioItems(source)
const businessScenario = scenarioItems
.map((item) => normalizeText(item))
.find((item) => item && !LEGACY_RISK_SCENARIO_KEYS.has(item) && RISK_SCENARIO_VALUES.has(item))
if (businessScenario) {
return businessScenario
}
return inferRiskCategoryFromCode(source?.code)
}
export function inferFinancialRuleCategory(source) {
const configJson = readConfigJson(source)
const explicit =
normalizeRiskScenarioCategory(configJson.scenario_category) ||
normalizeRiskScenarioCategory(configJson.ai_review_category) ||
normalizeRiskScenarioCategory(configJson.risk_category) ||
normalizeRiskScenarioCategory(source?.scenario_category) ||
normalizeRiskScenarioCategory(source?.risk_category)
if (explicit) {
return explicit
}
const scenarioCategory = readScenarioItems(source)
.map((item) => normalizeRiskScenarioCategory(item))
.find(Boolean)
if (scenarioCategory) {
return scenarioCategory
}
const configRuntimeRule = isPlainObject(configJson.runtime_rule) ? configJson.runtime_rule : {}
const haystack = [
source?.code,
source?.name,
source?.description,
configJson.runtime_kind,
configRuntimeRule.kind,
configRuntimeRule.scenario,
configRuntimeRule.template_key,
...readScenarioItems(source)
]
.map((item) => normalizeText(item).toLowerCase())
.filter(Boolean)
.join(' ')
if (!haystack) {
return '通用'
}
if (/(travel|trip|差旅|出差|住宿|酒店)/i.test(haystack)) {
return '差旅'
}
if (/(invoice|receipt|attachment|票据|发票|单据|附件)/i.test(haystack)) {
return '发票'
}
if (/(meal|dining|entertainment|餐饮|招待|餐费|用餐)/i.test(haystack)) {
return '餐饮招待'
}
if (/(transport|traffic|taxi|交通|出行|打车|机票|火车|高铁|地铁|公交)/i.test(haystack)) {
return '交通出行'
}
if (/(office|material|suppl|办公|物料|耗材)/i.test(haystack)) {
return '办公物料'
}
if (/(communication|telecom|phone|通信|通讯|手机)/i.test(haystack)) {
return '通信费'
}
if (/(welfare|福利)/i.test(haystack)) {
return '福利费'
}
if (/(expense_standard|费用科目|费用标准|补贴|科目)/i.test(haystack)) {
return '费用科目'
}
return '通用'
}
export function resolveRuleScenarioCategory(source, tabId = '') {
const scenarioList = resolveRuleScenarioList(source, tabId)
if (scenarioList.length) {
return formatScenarioList(scenarioList)
}
return ''
}
export function resolveRuleScenarioList(source, tabId = '') {
const resolvedTabId = tabId || resolveRuleTabId(source)
if (resolvedTabId === 'riskRules' || isJsonRiskRuleSource(source)) {
const expenseScenarioLabels = normalizeExpenseTypeScenarioLabels(readRiskRuleExpenseTypes(source))
if (expenseScenarioLabels.length) {
return expenseScenarioLabels
}
const riskCategory = resolveRiskRuleCategory(source)
return riskCategory ? [riskCategory] : []
}
if (resolvedTabId === 'financialRules') {
const financialCategory = inferFinancialRuleCategory(source)
return financialCategory ? [financialCategory] : []
}
return []
}
export function buildRiskListSubtitle(text, maxLength = 42) {
const normalized = normalizeText(text)
if (!normalized) {
return '平台内置风险规则'
}
const firstSentence = normalized.split(/[。;;\n]/)[0] || normalized
if (firstSentence.length <= maxLength) {
return firstSentence
}
return `${firstSentence.slice(0, maxLength)}`
}

View File

@@ -0,0 +1,117 @@
import {
EXPENSE_RULE_BLOCK_PATTERN,
RULE_SPREADSHEET_BLOCK_PATTERN,
RULE_TEMPLATE_LABELS
} from './auditViewMetadata.js'
import {
isPlainObject,
normalizeText,
readConfigJson
} from './auditViewDataUtils.js'
export function cloneJsonObject(value) {
if (!isPlainObject(value)) {
return null
}
try {
return JSON.parse(JSON.stringify(value))
} catch {
return { ...value }
}
}
export function resolveRuleTemplateLabel(value) {
const templateKey = normalizeText(value)
return RULE_TEMPLATE_LABELS[templateKey] || templateKey || '未指定模板'
}
export function extractRuntimeRuleFromMarkdown(markdown) {
const match = String(markdown || '').match(EXPENSE_RULE_BLOCK_PATTERN)
if (!match) {
return null
}
try {
const payload = JSON.parse(match[1])
return isPlainObject(payload) ? payload : null
} catch {
return null
}
}
export function extractSpreadsheetMetaFromMarkdown(markdown) {
const match = String(markdown || '').match(RULE_SPREADSHEET_BLOCK_PATTERN)
if (!match) {
return null
}
try {
const payload = JSON.parse(match[1])
return isPlainObject(payload) ? payload : null
} catch {
return null
}
}
export function stripRuntimeRuleBlock(markdown) {
return String(markdown || '')
.replace(EXPENSE_RULE_BLOCK_PATTERN, '')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
export function stringifyRuntimeRule(runtimeRule) {
return JSON.stringify(isPlainObject(runtimeRule) ? runtimeRule : {}, null, 2)
}
export function parseRuntimeRuleText(runtimeRuleText) {
const text = normalizeText(runtimeRuleText)
if (!text) {
return null
}
try {
const payload = JSON.parse(text)
return isPlainObject(payload) ? payload : null
} catch {
return null
}
}
export function buildDefaultRuntimeRule(source) {
const configJson = readConfigJson(source)
const scenarioItems = Array.isArray(source?.scenario_json)
? source.scenario_json
: Array.isArray(source?.scenarioList)
? source.scenarioList
: []
const configRuntimeRule = cloneJsonObject(configJson.runtime_rule)
return {
kind: normalizeText(configRuntimeRule?.kind || configJson.runtime_kind) || 'policy_rule_draft',
version:
typeof configRuntimeRule?.version === 'number' && Number.isFinite(configRuntimeRule.version)
? configRuntimeRule.version
: 1,
template_key:
normalizeText(configRuntimeRule?.template_key || configJson.rule_template_key) || 'general_policy_v1',
rule_name: normalizeText(configRuntimeRule?.rule_name || source?.name) || '未命名规则',
scenario: normalizeText(configRuntimeRule?.scenario || scenarioItems[0]) || 'expense',
review_required:
typeof configRuntimeRule?.review_required === 'boolean' ? configRuntimeRule.review_required : true
}
}
export function resolveRuntimeRuleForVersion(source, rawMarkdown, runtimeRuleFallback = null) {
return (
cloneJsonObject(extractRuntimeRuleFromMarkdown(rawMarkdown)) ||
cloneJsonObject(runtimeRuleFallback) ||
buildDefaultRuntimeRule(source)
)
}
export function buildMarkdownVersionContent(markdownContent, runtimeRule) {
const body = stripRuntimeRuleBlock(markdownContent)
const runtimeBlock = ['```expense-rule', stringifyRuntimeRule(runtimeRule), '```'].join('\n')
return body ? `${body}\n\n${runtimeBlock}` : runtimeBlock
}

View File

@@ -84,50 +84,53 @@ export function buildSpreadsheetChangeRecordKey(records = []) {
export function filterAuditAssets(assets = [], filters = {}) { export function filterAuditAssets(assets = [], filters = {}) {
const normalizedKeyword = normalizeText(filters.keyword).toLowerCase() const normalizedKeyword = normalizeText(filters.keyword).toLowerCase()
const hasKeyword = Boolean(normalizedKeyword)
const hasDomain = Boolean(filters.selectedDomain)
const hasOwner = Boolean(filters.selectedOwner)
const hasRiskLevel = Boolean(filters.selectedRiskLevel)
const hasStatus = Boolean(filters.showStatusFilter && filters.selectedStatus)
const hasRiskScenario = Boolean(filters.showRiskScenarioFilter && filters.selectedRiskScenario)
const hasOnline = Boolean(filters.showOnlineFilter && filters.selectedOnlineState)
const hasEnabled = Boolean(filters.showEnabledFilter && filters.selectedEnabledState)
return assets.filter((item) => { return assets.filter((item) => {
const matchesKeyword = normalizedKeyword if (hasKeyword) {
? [item.name, item.code, item.summary, item.owner, item.scope, item.riskLevelLabel] const searchText = item.searchText || [item.name, item.code, item.summary, item.owner, item.scope, item.riskLevelLabel]
.map((value) => normalizeText(value).toLowerCase())
.filter(Boolean) .filter(Boolean)
.some((value) => String(value).toLowerCase().includes(normalizedKeyword)) .join(' ')
: true if (!searchText.includes(normalizedKeyword)) {
const matchesDomain = filters.selectedDomain ? item.domainValue === filters.selectedDomain : true return false
const matchesOwner = filters.selectedOwner ? item.owner === filters.selectedOwner : true }
const matchesRiskLevel = filters.selectedRiskLevel }
? item.riskLevelValue === filters.selectedRiskLevel if (hasDomain && item.domainValue !== filters.selectedDomain) {
: true return false
const matchesStatus = filters.showStatusFilter }
? filters.selectedStatus if (hasOwner && item.owner !== filters.selectedOwner) {
? item.statusValue === filters.selectedStatus return false
: true }
: true if (hasRiskLevel && item.riskLevelValue !== filters.selectedRiskLevel) {
const matchesRiskScenario = filters.showRiskScenarioFilter return false
? filters.selectedRiskScenario }
? Array.isArray(item.scenarioList) && item.scenarioList.length if (hasStatus && item.statusValue !== filters.selectedStatus) {
return false
}
if (hasRiskScenario) {
const hasScenarioList = Array.isArray(item.scenarioList) && item.scenarioList.length
const matched = hasScenarioList
? item.scenarioList.includes(filters.selectedRiskScenario) ? item.scenarioList.includes(filters.selectedRiskScenario)
: item.riskCategory === filters.selectedRiskScenario : item.riskCategory === filters.selectedRiskScenario
: true if (!matched) {
: true return false
const matchesOnline = filters.showOnlineFilter }
? filters.selectedOnlineState }
? (filters.selectedOnlineState === 'online') === Boolean(item.isOnlineValue) if (hasOnline && (filters.selectedOnlineState === 'online') !== Boolean(item.isOnlineValue)) {
: true return false
: true }
const matchesEnabled = filters.showEnabledFilter if (hasEnabled && (filters.selectedEnabledState === 'enabled') !== Boolean(item.isEnabledValue)) {
? filters.selectedEnabledState return false
? (filters.selectedEnabledState === 'enabled') === Boolean(item.isEnabledValue) }
: true
: true
return ( return true
matchesKeyword &&
matchesDomain &&
matchesOwner &&
matchesRiskLevel &&
matchesStatus &&
matchesRiskScenario &&
matchesOnline &&
matchesEnabled
)
}) })
} }

View File

@@ -0,0 +1,180 @@
import {
buildDigitalEmployeeDetailMeta,
buildDigitalEmployeeListMeta
} from './auditViewDigitalEmployeeModel.js'
import {
formatDateTime,
normalizeText,
resolveStatusMeta
} from './auditViewModel.js'
export const DIGITAL_EMPLOYEE_EXECUTION_MODE_OPTIONS = [
{ value: '', label: '全部执行方式' },
{ value: 'timed', label: '定时执行' },
{ value: 'manual', label: '手动触发' }
]
export function resolveDigitalEmployeeStatusColor(statusValue) {
const normalized = normalizeText(statusValue).toLowerCase()
if (normalized === 'active') return 'var(--success)'
if (['failed', 'error'].includes(normalized)) return '#ef4444'
if (['disabled', 'inactive'].includes(normalized)) return '#f59e0b'
return 'var(--theme-primary)'
}
export function buildDigitalEmployeeDetailTopBar(employee) {
if (!employee) return null
const meta = employee.digitalEmployee || {}
const statusValue = normalizeText(employee.statusValue).toLowerCase()
return {
view: {
title: normalizeText(employee.name) || '数字员工详情',
desc:
normalizeText(meta.description) ||
normalizeText(employee.summary) ||
'查看数字员工配置、执行计划、运行记录与源文件。'
},
kpis: [
{
label: '运行状态',
value: normalizeText(employee.status) || (statusValue === 'active' ? '运行中' : '未运行'),
unit: '',
meta:
normalizeText(meta.scheduleLabel) ||
normalizeText(employee.scope) ||
normalizeText(employee.executionMode) ||
'待配置执行计划',
trend: statusValue === 'active' ? 'up' : 'down',
color: resolveDigitalEmployeeStatusColor(statusValue)
}
]
}
}
export function buildEmployeeListItem(asset) {
const meta = buildDigitalEmployeeListMeta(asset)
const statusMeta = resolveStatusMeta(asset.status)
const displayName = meta.name || '数字员工技能'
const executionModeValue = meta.executionMode === '定时执行' ? 'timed' : 'manual'
const searchText = [
displayName,
meta.code,
meta.summary,
meta.owner,
meta.scope,
meta.executionMode,
meta.skillCategory,
statusMeta.label,
meta.enabledLabel
]
.map((value) => normalizeText(value).toLowerCase())
.filter(Boolean)
.join(' ')
return {
id: asset.id,
rawCode: asset.code,
short: displayName.slice(0, 2),
badgeTone: 'blue',
name: displayName,
code: meta.code,
summary: meta.summary,
owner: meta.owner,
scope: meta.scope,
executionMode: meta.executionMode,
executionModeValue,
skillCategory: meta.skillCategory,
version: asset.working_version || asset.current_version || '-',
currentVersion: asset.current_version || '-',
status: statusMeta.label,
statusValue: asset.status,
statusTone: statusMeta.tone,
enabledLabel: meta.enabledLabel,
enabledTone: meta.enabledTone,
isEnabledValue: meta.enabled,
configJson: asset.config_json || {},
updatedAt: formatDateTime(asset.updated_at),
updatedAtRaw: asset.updated_at || '',
digitalEmployee: meta,
searchText
}
}
export function buildEmployeePlaceholder(employee) {
return {
...employee,
type: 'digitalEmployees',
typeLabel: '数字员工',
currentVersion: employee.currentVersion || employee.version || '-',
workingVersion: employee.version || '-',
markdownContent: '',
loading: true
}
}
export function buildEmployeeDetail(asset) {
const meta = buildDigitalEmployeeDetailMeta({
...asset,
updated_at: formatDateTime(asset.updated_at)
})
const statusMeta = resolveStatusMeta(asset.status)
return {
id: asset.id,
type: 'digitalEmployees',
typeLabel: '数字员工',
rawCode: asset.code,
short: meta.name.slice(0, 2),
name: meta.name,
code: meta.code,
summary: meta.description,
owner: meta.owner,
reviewer: meta.reviewer,
category: meta.category,
scope: meta.scope,
version: asset.working_version || asset.current_version || '-',
currentVersion: asset.current_version || '-',
workingVersion: asset.working_version || asset.current_version || '-',
status: statusMeta.label,
statusValue: asset.status,
statusTone: statusMeta.tone,
configJson: asset.config_json || {},
updatedAt: formatDateTime(asset.updated_at),
markdownContent: meta.sourceMarkdown,
digitalEmployee: meta,
loading: false
}
}
export function sortEmployees(items) {
return [...items].sort((left, right) =>
String(right.updatedAtRaw || '').localeCompare(String(left.updatedAtRaw || ''))
)
}
export function filterDigitalEmployees(items = [], filters = {}) {
const searchText = normalizeText(filters.keyword).toLowerCase()
const hasKeyword = Boolean(searchText)
const hasStatus = Boolean(filters.selectedStatus)
const hasEnabled = Boolean(filters.selectedEnabledState)
const hasExecutionMode = Boolean(filters.selectedExecutionMode)
return items.filter((item) => {
if (hasKeyword && !normalizeText(item.searchText).includes(searchText)) {
return false
}
if (hasStatus && item.statusValue !== filters.selectedStatus) {
return false
}
if (hasEnabled && (filters.selectedEnabledState === 'enabled') !== Boolean(item.isEnabledValue)) {
return false
}
if (hasExecutionMode && item.executionModeValue !== filters.selectedExecutionMode) {
return false
}
return true
})
}

View File

@@ -0,0 +1,214 @@
import { computed, ref } from 'vue'
import {
fetchAgentAssetDetail,
fetchAgentAssets,
fetchAgentRuns
} from '../../services/agentAssets.js'
import {
buildDetailViewModel,
buildListItem
} from './auditViewModel.js'
export function useAuditAssetData({
activeType,
activeMeta,
selectedSkill,
loadVersionTimeline,
loadSpreadsheetChangeRecords,
loadRiskRuleJson,
toast
}) {
const loading = ref(false)
const errorMessage = ref('')
const detailLoading = ref(false)
const detailError = ref('')
const runLoading = ref(false)
const runs = ref([])
const assetBuckets = ref({
financialRules: [],
riskRules: [],
mcp: []
})
const currentAssets = computed(() => assetBuckets.value[activeType.value] || [])
async function loadRuns(options = {}) {
if (runLoading.value && !options.force) {
return
}
runLoading.value = true
try {
const payload = await fetchAgentRuns({ limit: 50 })
runs.value = Array.isArray(payload) ? payload : []
} finally {
runLoading.value = false
}
}
async function loadAssets(options = {}) {
const shouldShowLoading = !options.silent && !options.background
if (shouldShowLoading) {
loading.value = true
}
if (!options.silent) {
errorMessage.value = ''
}
try {
const payload = await fetchAgentAssets({ assetType: activeMeta.value.assetType })
const items = Array.isArray(payload) ? payload.map(buildListItem).filter(Boolean) : []
if (activeMeta.value.assetType === 'rule') {
const nextBuckets = {
financialRules: [],
riskRules: []
}
items.forEach((item) => {
if (item?.tabId === 'financialRules' || item?.tabId === 'riskRules') {
nextBuckets[item.tabId].push(item)
}
})
assetBuckets.value = {
...assetBuckets.value,
...nextBuckets
}
} else {
assetBuckets.value = {
...assetBuckets.value,
[activeType.value]: items
}
}
} catch (error) {
if (options.silent || options.background) {
return
}
if (activeMeta.value.assetType === 'rule') {
assetBuckets.value = {
...assetBuckets.value,
financialRules:
activeType.value === 'financialRules' ? [] : assetBuckets.value.financialRules,
riskRules: []
}
} else {
assetBuckets.value = {
...assetBuckets.value,
[activeType.value]: []
}
}
errorMessage.value = error?.message || '资产数据加载失败,请稍后重试。'
toast(errorMessage.value)
} finally {
if (shouldShowLoading) {
loading.value = false
}
}
}
async function refreshCurrentAssets() {
await loadAssets({ force: true, silent: true, background: true })
}
async function loadSelectedAssetDetail(assetId) {
detailLoading.value = true
detailError.value = ''
try {
if (!runs.value.length) {
await loadRuns()
}
const detail = await fetchAgentAssetDetail(assetId)
selectedSkill.value = buildDetailViewModel(detail, runs.value)
if (selectedSkill.value?.type !== 'rules') {
return
}
if (!selectedSkill.value.usesSpreadsheetRule && !selectedSkill.value.usesJsonRiskRule) {
loadVersionTimeline(assetId, { silent: true }).catch(() => {})
}
if (selectedSkill.value.usesSpreadsheetRule) {
loadSpreadsheetChangeRecords(assetId).catch(() => {})
}
if (!selectedSkill.value.usesJsonRiskRule) {
return
}
if (selectedSkill.value.riskRuleGenerationFailed || selectedSkill.value.riskRuleGenerationBusy) {
return
}
try {
await loadRiskRuleJson(assetId)
} catch (jsonError) {
console.warn('Failed to load risk rule JSON:', jsonError)
const jsonMessage =
jsonError?.message || '风险规则 JSON 文件缺失或无法读取,请同步规则库后重试。'
toast(jsonMessage)
selectedSkill.value = {
...selectedSkill.value,
riskRuleJsonText: '{}',
riskRuleDescription:
selectedSkill.value.riskRuleDescription ||
'规则 JSON 尚未就绪,请联系管理员执行平台风险规则同步。'
}
}
} catch (error) {
detailError.value = error?.message || '资产详情加载失败,请稍后重试。'
toast(detailError.value)
} finally {
detailLoading.value = false
}
}
function mergeSelectedRuleLifecycle(detail) {
if (!selectedSkill.value || !detail) {
return
}
const next = buildDetailViewModel(detail, runs.value)
selectedSkill.value = {
...selectedSkill.value,
status: next.status,
statusValue: next.statusValue,
statusTone: next.statusTone,
publishedVersion: next.publishedVersion,
workingVersion: next.workingVersion,
currentVersion: next.currentVersion,
displayVersion: next.displayVersion,
reviewer: next.reviewer,
publisher: next.publisher,
publishedAt: next.publishedAt,
isOnlineValue: next.isOnlineValue,
isOnlineLabel: next.isOnlineLabel,
isOnlineTone: next.isOnlineTone,
isEnabledValue: next.isEnabledValue,
isEnabledLabel: next.isEnabledLabel,
isEnabledTone: next.isEnabledTone,
latestTestSummary: next.latestTestSummary,
lastOperationLabel: next.lastOperationLabel,
lastOperationTone: next.lastOperationTone,
publishMeta: next.publishMeta,
publishState: next.publishState,
updatedAt: next.updatedAt,
configJson: next.configJson
}
}
return {
loading,
errorMessage,
detailLoading,
detailError,
runLoading,
runs,
assetBuckets,
currentAssets,
loadRuns,
loadAssets,
refreshCurrentAssets,
loadSelectedAssetDetail,
mergeSelectedRuleLifecycle
}
}

View File

@@ -0,0 +1,226 @@
import { ref } from 'vue'
import {
deleteAgentAsset,
fetchAgentAssetDetail,
publishRiskRuleAsset,
returnRiskRuleAsset,
setRiskRuleAssetEnabled
} from '../../services/agentAssets.js'
import { normalizeText } from './auditViewModel.js'
export function useAuditRiskRuleActions({
selectedSkill,
detailBusy,
actionState,
canOpenRiskRuleTest,
canDeleteRiskRule,
canReturnRiskRule,
canPublishRiskRule,
canToggleRiskRuleEnabled,
riskRuleTestPassed,
refreshCurrentAssets,
loadSelectedAssetDetail,
mergeSelectedRuleLifecycle,
closeDetail,
resolveActor,
toast
}) {
const riskRuleTestOpen = ref(false)
const riskRuleDeleteOpen = ref(false)
const riskRuleReturnOpen = ref(false)
const riskRulePublishOpen = ref(false)
const riskRuleReturnNote = ref('')
function resetRiskRuleActionDialogs() {
riskRuleTestOpen.value = false
riskRuleDeleteOpen.value = false
riskRuleReturnOpen.value = false
riskRulePublishOpen.value = false
riskRuleReturnNote.value = ''
}
function openRiskRuleTestDialog() {
if (detailBusy.value) {
return
}
if (!canOpenRiskRuleTest.value) {
if (!selectedSkill.value?.id) {
toast('规则详情还没有加载完成,请稍后再测试。')
}
return
}
riskRuleTestOpen.value = true
}
function closeRiskRuleTestDialog() {
riskRuleTestOpen.value = false
}
async function handleRiskRuleReportSaved(summary) {
if (selectedSkill.value) {
selectedSkill.value.latestTestSummary = summary
}
await refreshCurrentAssets()
if (selectedSkill.value?.id) {
const detail = await fetchAgentAssetDetail(selectedSkill.value.id)
mergeSelectedRuleLifecycle(detail)
}
}
function openDeleteRiskRuleDialog() {
if (!canDeleteRiskRule.value) {
return
}
riskRuleDeleteOpen.value = true
}
function closeDeleteRiskRuleDialog() {
if (detailBusy.value) {
return
}
riskRuleDeleteOpen.value = false
}
async function deleteSelectedRiskRule() {
if (!selectedSkill.value || !canDeleteRiskRule.value || detailBusy.value) {
return
}
actionState.value = 'delete-risk-rule'
try {
await deleteAgentAsset(selectedSkill.value.id, { actor: resolveActor() })
riskRuleDeleteOpen.value = false
const deletedName = selectedSkill.value.name
closeDetail()
await refreshCurrentAssets()
toast(`风险规则“${deletedName}”已删除。`)
} catch (error) {
toast(error?.message || '风险规则删除失败,请稍后重试。')
} finally {
actionState.value = ''
}
}
function openReturnRiskRuleDialog() {
if (!canReturnRiskRule.value) {
return
}
riskRuleReturnNote.value = ''
riskRuleReturnOpen.value = true
}
function closeReturnRiskRuleDialog() {
if (detailBusy.value) {
return
}
riskRuleReturnOpen.value = false
}
async function returnSelectedRiskRule() {
if (!selectedSkill.value || !canReturnRiskRule.value || detailBusy.value) {
return
}
const note = normalizeText(riskRuleReturnNote.value)
if (!note) {
toast('请填写回退原因。')
return
}
actionState.value = 'return-risk-rule'
try {
await returnRiskRuleAsset(selectedSkill.value.id, { note }, { actor: resolveActor() })
riskRuleReturnOpen.value = false
await refreshCurrentAssets()
await loadSelectedAssetDetail(selectedSkill.value.id)
toast('风险规则已回退到草稿。')
} catch (error) {
toast(error?.message || '风险规则回退失败,请稍后重试。')
} finally {
actionState.value = ''
}
}
function openPublishRiskRuleDialog() {
if (!canPublishRiskRule.value) {
if (!riskRuleTestPassed.value) {
toast('请先确认测试报告通过,再发布上线。')
}
return
}
riskRulePublishOpen.value = true
}
function closePublishRiskRuleDialog() {
if (detailBusy.value) {
return
}
riskRulePublishOpen.value = false
}
async function publishSelectedRiskRule() {
if (!selectedSkill.value || !canPublishRiskRule.value || detailBusy.value) {
return
}
actionState.value = 'publish-risk-rule'
try {
await publishRiskRuleAsset(selectedSkill.value.id, { actor: resolveActor() })
riskRulePublishOpen.value = false
await refreshCurrentAssets()
await loadSelectedAssetDetail(selectedSkill.value.id)
toast('风险规则已发布上线。')
} catch (error) {
toast(error?.message || '风险规则发布失败,请稍后重试。')
} finally {
actionState.value = ''
}
}
async function toggleSelectedRiskRuleEnabled() {
if (!selectedSkill.value || !canToggleRiskRuleEnabled.value || detailBusy.value) {
return
}
const assetId = selectedSkill.value.id
const nextEnabled = !selectedSkill.value.isOnlineValue
actionState.value = 'toggle-risk-rule-enabled'
try {
const detail = await setRiskRuleAssetEnabled(assetId, nextEnabled, { actor: resolveActor() })
mergeSelectedRuleLifecycle(detail)
await refreshCurrentAssets()
toast(
nextEnabled
? '风险规则已上线。'
: '风险规则已下线,不会进入业务扫描。'
)
} catch (error) {
toast(error?.message || '风险规则上线状态更新失败,请稍后重试。')
} finally {
actionState.value = ''
}
}
return {
riskRuleTestOpen,
riskRuleDeleteOpen,
riskRuleReturnOpen,
riskRulePublishOpen,
riskRuleReturnNote,
resetRiskRuleActionDialogs,
openRiskRuleTestDialog,
closeRiskRuleTestDialog,
handleRiskRuleReportSaved,
openDeleteRiskRuleDialog,
closeDeleteRiskRuleDialog,
deleteSelectedRiskRule,
openReturnRiskRuleDialog,
closeReturnRiskRuleDialog,
returnSelectedRiskRule,
openPublishRiskRuleDialog,
closePublishRiskRuleDialog,
publishSelectedRiskRule,
toggleSelectedRiskRuleEnabled
}
}

View File

@@ -0,0 +1,133 @@
import { computed, ref } from 'vue'
import { generateRiskRuleAsset } from '../../services/agentAssets.js'
import { normalizeText } from './auditViewModel.js'
import {
createDefaultRiskRuleForm
} from './auditViewRiskRuleModel.js'
export function useAuditRiskRuleCreateFlow({
activeType,
isRuleManager,
detailBusy,
actionState,
assetBuckets,
refreshCurrentAssets,
resolveActor,
toast
}) {
const riskRuleCreateOpen = ref(false)
const riskRuleCreateForm = ref(createDefaultRiskRuleForm())
const riskRuleGenerationPollTimers = new Map()
const riskRuleCreateBusy = computed(() => actionState.value === 'generate-risk-rule')
const canCreateRiskRule = computed(
() => activeType.value === 'riskRules' && isRuleManager.value && !detailBusy.value
)
function openRiskRuleCreateDialog() {
if (activeType.value !== 'riskRules') {
return
}
riskRuleCreateForm.value = createDefaultRiskRuleForm()
riskRuleCreateOpen.value = true
}
function closeRiskRuleCreateDialog() {
if (riskRuleCreateBusy.value) {
return
}
riskRuleCreateOpen.value = false
}
async function submitRiskRuleCreate() {
if (!canCreateRiskRule.value || riskRuleCreateBusy.value) {
return
}
const naturalLanguage = String(riskRuleCreateForm.value.natural_language || '').trim()
const ruleTitle = String(riskRuleCreateForm.value.rule_title || '').trim()
if (ruleTitle.length < 2) {
toast('请输入至少 2 个字的规则标题。')
return
}
if (naturalLanguage.length < 8) {
toast('请至少输入 8 个字的风险规则描述。')
return
}
actionState.value = 'generate-risk-rule'
try {
const detail = await generateRiskRuleAsset(
{
business_domain: 'expense',
business_stage: riskRuleCreateForm.value.business_stage,
expense_category: riskRuleCreateForm.value.expense_category,
rule_title: ruleTitle,
requires_attachment: Boolean(riskRuleCreateForm.value.requires_attachment),
natural_language: naturalLanguage
},
{ actor: resolveActor() }
)
riskRuleCreateOpen.value = false
await refreshCurrentAssets()
scheduleRiskRuleGenerationPoll(detail.id)
toast('风险规则已进入后台生成,列表会先显示生成中。')
} catch (error) {
toast(error?.message || '风险规则生成失败,请稍后重试。')
} finally {
actionState.value = ''
}
}
function stopRiskRuleGenerationPoll(assetId) {
const timer = riskRuleGenerationPollTimers.get(assetId)
if (timer) {
window.clearTimeout(timer)
riskRuleGenerationPollTimers.delete(assetId)
}
}
function scheduleRiskRuleGenerationPoll(assetId, attempt = 0) {
const normalizedAssetId = normalizeText(assetId)
if (!normalizedAssetId) {
return
}
stopRiskRuleGenerationPoll(normalizedAssetId)
const timer = window.setTimeout(async () => {
try {
await refreshCurrentAssets()
const latest = (assetBuckets.value.riskRules || []).find((item) => item.id === normalizedAssetId)
if (!latest || latest.statusValue !== 'generating' || attempt >= 59) {
riskRuleGenerationPollTimers.delete(normalizedAssetId)
return
}
scheduleRiskRuleGenerationPoll(normalizedAssetId, attempt + 1)
} catch {
if (attempt < 59) {
scheduleRiskRuleGenerationPoll(normalizedAssetId, attempt + 1)
} else {
riskRuleGenerationPollTimers.delete(normalizedAssetId)
}
}
}, attempt === 0 ? 1200 : 3000)
riskRuleGenerationPollTimers.set(normalizedAssetId, timer)
}
function stopAllRiskRuleGenerationPolls() {
riskRuleGenerationPollTimers.forEach((timer) => window.clearTimeout(timer))
riskRuleGenerationPollTimers.clear()
}
return {
canCreateRiskRule,
riskRuleCreateOpen,
riskRuleCreateForm,
riskRuleCreateBusy,
openRiskRuleCreateDialog,
closeRiskRuleCreateDialog,
submitRiskRuleCreate,
stopAllRiskRuleGenerationPolls
}
}

View File

@@ -0,0 +1,101 @@
import {
fetchAgentAssetRuleJson,
saveAgentAssetRuleJson
} from '../../services/agentAssets.js'
import {
applyRiskRuleJsonState,
resolveRiskRuleDescription
} from './auditViewModel.js'
function readJsonPayload(payload) {
return payload?.payload && typeof payload.payload === 'object' ? payload.payload : payload
}
function downloadTextFile({ content, fileName, type }) {
const blob = new Blob([content], { type })
const objectUrl = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = objectUrl
link.download = fileName
link.click()
URL.revokeObjectURL(objectUrl)
}
export function useAuditRiskRuleJsonEditor({
selectedSkill,
canEditMarkdown,
actionState,
toast
}) {
async function loadRiskRuleJson(assetId) {
if (!assetId || !selectedSkill.value?.usesJsonRiskRule) {
return
}
const payload = await fetchAgentAssetRuleJson(assetId)
selectedSkill.value = applyRiskRuleJsonState(
selectedSkill.value,
readJsonPayload(payload),
payload
)
}
async function saveRiskRuleJson() {
if (!selectedSkill.value?.id || !canEditMarkdown.value) {
return
}
actionState.value = 'save-risk-json'
try {
const parsed = JSON.parse(String(selectedSkill.value.riskRuleJsonText || '{}'))
const saved = await saveAgentAssetRuleJson(selectedSkill.value.id, { payload: parsed })
selectedSkill.value = applyRiskRuleJsonState(
selectedSkill.value,
readJsonPayload(saved),
saved
)
toast('风险规则 JSON 已保存。')
} catch (error) {
toast(error?.message || '风险规则 JSON 保存失败。')
} finally {
actionState.value = ''
}
}
function formatRiskRuleJson() {
if (!selectedSkill.value?.usesJsonRiskRule) {
return
}
try {
const parsed = JSON.parse(String(selectedSkill.value.riskRuleJsonText || '{}'))
selectedSkill.value = applyRiskRuleJsonState(selectedSkill.value, parsed, {
name: selectedSkill.value.name,
description: resolveRiskRuleDescription(parsed)
})
} catch (error) {
toast(error?.message || 'JSON 格式无效,无法格式化。')
}
}
function downloadRiskRuleJson() {
if (!selectedSkill.value?.usesJsonRiskRule) {
return
}
downloadTextFile({
content: String(selectedSkill.value.riskRuleJsonText || '{}'),
fileName:
selectedSkill.value.ruleDocument?.file_name ||
`${selectedSkill.value.code || 'risk-rule'}.json`,
type: 'application/json;charset=utf-8'
})
}
return {
loadRiskRuleJson,
saveRiskRuleJson,
formatRiskRuleJson,
downloadRiskRuleJson
}
}

View File

@@ -0,0 +1,187 @@
import { computed, ref } from 'vue'
import { fetchEmployees } from '../../services/employees.js'
import { createAgentAssetReview } from '../../services/agentAssets.js'
import {
buildReviewNote
} from './auditViewRuntimeModel.js'
import {
normalizeText,
resolveReviewMeta
} from './auditViewModel.js'
export function useAuditRuleReviewFlow({
selectedSkill,
selectedSkillIsRule,
selectedSkillUsesJsonRisk,
canEditSelected,
canManageSelected,
isDisplayingWorkingVersion,
canOpenRiskRuleReviewSubmit,
riskRuleTestPassed,
detailBusy,
actionState,
refreshCurrentAssets,
loadSelectedAssetDetail,
resolveActor,
toast
}) {
const reviewSubmitOpen = ref(false)
const reviewSubmitVersion = ref('')
const reviewSubmitReviewer = ref('')
const reviewSubmitReviewerLoading = ref(false)
const reviewSubmitReviewerOptions = ref([])
const canSubmitReview = computed(
() =>
!selectedSkillUsesJsonRisk.value &&
canEditSelected.value &&
selectedSkillIsRule.value &&
isDisplayingWorkingVersion.value
)
const hasReviewSubmitReviewers = computed(() => reviewSubmitReviewerOptions.value.length > 0)
const canReviewSelected = computed(
() => canManageSelected.value && selectedSkillIsRule.value && isDisplayingWorkingVersion.value
)
async function reviewSelectedRule(reviewStatus) {
if (!selectedSkill.value || !selectedSkillIsRule.value || detailBusy.value) {
return
}
if (reviewStatus === 'pending' && !canSubmitReview.value) {
return
}
if (reviewStatus !== 'pending' && !canReviewSelected.value) {
return
}
actionState.value = `review-${reviewStatus}`
try {
await createAgentAssetReview(
selectedSkill.value.id,
{
version: selectedSkill.value.workingVersion,
reviewer: resolveActor(),
review_status: reviewStatus,
review_note: buildReviewNote(reviewStatus)
},
{ actor: resolveActor() }
)
await refreshCurrentAssets()
await loadSelectedAssetDetail(selectedSkill.value.id)
toast(`当前规则版本已标记为${resolveReviewMeta(reviewStatus).label}`)
} catch (error) {
toast(error?.message || '规则审核提交失败,请稍后重试。')
} finally {
actionState.value = ''
}
}
async function loadReviewSubmitReviewers() {
reviewSubmitReviewerLoading.value = true
try {
const employees = await fetchEmployees()
reviewSubmitReviewerOptions.value = (Array.isArray(employees) ? employees : [])
.filter(
(item) =>
item.status === '在职' &&
Array.isArray(item.roleCodes) &&
item.roleCodes.includes('manager')
)
.map((item) => ({
value: item.name,
label: `${item.name} · ${item.position || '高级管理员'}`
}))
} catch (error) {
reviewSubmitReviewerOptions.value = []
toast(error?.message || '审核人列表加载失败,请稍后重试。')
} finally {
reviewSubmitReviewerLoading.value = false
}
}
async function openSubmitReviewDialog() {
if (
selectedSkillUsesJsonRisk.value &&
!canOpenRiskRuleReviewSubmit.value
) {
return
}
if (!selectedSkill.value || !canSubmitReview.value || detailBusy.value) {
return
}
reviewSubmitVersion.value = selectedSkill.value.workingVersion || selectedSkill.value.displayVersion || ''
reviewSubmitReviewer.value = selectedSkill.value.reviewer || ''
reviewSubmitOpen.value = true
await loadReviewSubmitReviewers()
if (!reviewSubmitReviewerOptions.value.some((item) => item.value === reviewSubmitReviewer.value)) {
reviewSubmitReviewer.value = reviewSubmitReviewerOptions.value[0]?.value || ''
}
}
function closeSubmitReviewDialog() {
if (detailBusy.value) {
return
}
reviewSubmitOpen.value = false
}
async function submitSelectedRuleForReview() {
if (!selectedSkill.value || !canSubmitReview.value || detailBusy.value) {
return
}
if (selectedSkillUsesJsonRisk.value && !riskRuleTestPassed.value) {
toast('当前规则版本尚未确认测试通过,不能提交审核。')
return
}
const version = normalizeText(reviewSubmitVersion.value)
const reviewer = normalizeText(reviewSubmitReviewer.value)
if (!version) {
toast('请输入送审版本号。')
return
}
if (!reviewer) {
toast('请选择审核人。')
return
}
actionState.value = 'review-pending'
try {
await createAgentAssetReview(
selectedSkill.value.id,
{
version,
reviewer,
review_status: 'pending',
review_note: buildReviewNote('pending')
},
{ actor: resolveActor() }
)
reviewSubmitOpen.value = false
await refreshCurrentAssets()
await loadSelectedAssetDetail(selectedSkill.value.id)
toast(`规则版本 ${version} 已提交给 ${reviewer} 审核。`)
} catch (error) {
toast(error?.message || '规则审核提交失败,请稍后重试。')
} finally {
actionState.value = ''
}
}
return {
reviewSubmitOpen,
reviewSubmitVersion,
reviewSubmitReviewer,
reviewSubmitReviewerLoading,
reviewSubmitReviewerOptions,
canSubmitReview,
hasReviewSubmitReviewers,
canReviewSelected,
reviewSelectedRule,
openSubmitReviewDialog,
closeSubmitReviewDialog,
submitSelectedRuleForReview
}
}

View File

@@ -0,0 +1,151 @@
import {
activateAgentAsset,
createAgentAssetVersion,
restoreAgentAssetVersion,
updateAgentAsset
} from '../../services/agentAssets.js'
import {
buildRuleConfigPayload,
incrementVersion
} from './auditViewRuntimeModel.js'
import {
buildMarkdownVersionContent,
normalizeText,
parseRuntimeRuleText
} from './auditViewModel.js'
export function useAuditRuleVersionActions({
selectedSkill,
selectedSkillIsRule,
canEditMarkdown,
canManageSelected,
actionState,
detailBusy,
refreshCurrentAssets,
loadSelectedAssetDetail,
resolveActor,
toast
}) {
async function persistRuleRuntimeConfig(asset, runtimeRule) {
await updateAgentAsset(
asset.id,
{
config_json: buildRuleConfigPayload(asset, runtimeRule)
},
{ actor: resolveActor() }
)
}
async function saveRuleVersion({ action, changeNote, successLabel }) {
if (
!selectedSkill.value ||
!selectedSkillIsRule.value ||
selectedSkill.value.usesSpreadsheetRule ||
!canEditMarkdown.value ||
detailBusy.value
) {
return
}
if (!normalizeText(selectedSkill.value.markdownContent)) {
toast('规则 Markdown 内容不能为空。')
return
}
const runtimeRule = parseRuntimeRuleText(selectedSkill.value.runtimeRuleText)
if (!runtimeRule) {
toast('运行时 JSON 必须是合法的对象。')
return
}
const nextVersion = incrementVersion(selectedSkill.value.currentVersion)
actionState.value = action
try {
await createAgentAssetVersion(
selectedSkill.value.id,
{
version: nextVersion,
content: buildMarkdownVersionContent(selectedSkill.value.markdownContent, runtimeRule),
content_type: 'markdown',
change_note: changeNote,
created_by: resolveActor()
},
{ actor: resolveActor() }
)
await persistRuleRuntimeConfig(selectedSkill.value, runtimeRule)
await refreshCurrentAssets()
await loadSelectedAssetDetail(selectedSkill.value.id)
toast(`${successLabel} ${nextVersion}`)
} catch (error) {
toast(error?.message || `${successLabel}失败,请稍后重试。`)
} finally {
actionState.value = ''
}
}
async function saveRuleMarkdown() {
await saveRuleVersion({
action: 'save-markdown',
changeNote: '通过规则中心保存 Markdown 规则内容,并同步运行时 JSON。',
successLabel: '规则 Markdown 已保存为'
})
}
async function saveRuleRuntimeJson() {
await saveRuleVersion({
action: 'save-runtime-json',
changeNote: '通过规则中心保存运行时 JSON 配置。',
successLabel: '规则 JSON 已保存为'
})
}
async function activateSelectedRule() {
if (!selectedSkill.value || !selectedSkillIsRule.value || !canManageSelected.value || detailBusy.value) {
return
}
actionState.value = 'activate'
try {
await activateAgentAsset(selectedSkill.value.id, { actor: resolveActor() })
await refreshCurrentAssets()
await loadSelectedAssetDetail(selectedSkill.value.id)
toast('规则已正式上线。')
} catch (error) {
toast(error?.message || '规则上线失败,请稍后重试。')
} finally {
actionState.value = ''
}
}
async function restoreSelectedVersion(version) {
if (
!selectedSkill.value ||
!selectedSkillIsRule.value ||
!canManageSelected.value ||
detailBusy.value ||
!version
) {
return
}
actionState.value = `restore-${version}`
try {
await restoreAgentAssetVersion(selectedSkill.value.id, version, { actor: resolveActor() })
await refreshCurrentAssets()
await loadSelectedAssetDetail(selectedSkill.value.id)
toast(`已基于 ${version} 生成新的工作版本。`)
} catch (error) {
toast(error?.message || '历史版本恢复失败,请稍后重试。')
} finally {
actionState.value = ''
}
}
return {
saveRuleMarkdown,
saveRuleRuntimeJson,
activateSelectedRule,
restoreSelectedVersion
}
}

View File

@@ -0,0 +1,423 @@
import { computed, nextTick, ref } from 'vue'
import {
fetchAgentAssetSpreadsheetBlob,
fetchAgentAssetSpreadsheetChangeRecords,
fetchAgentAssetSpreadsheetOnlyOfficeConfig,
importAgentAssetSpreadsheetContent
} from '../../services/agentAssets.js'
import { loadOnlyOfficeApi } from '../../services/onlyoffice.js'
import { buildOnlyOfficeEditorConfig } from './onlyOfficePreviewConfig.js'
import { buildSpreadsheetChangeRecordKey } from './auditViewRuntimeModel.js'
import {
formatDateTime,
formatSpreadsheetChangeSummary,
normalizeText,
resolveDiffChangeMeta
} from './auditViewModel.js'
export function useAuditSpreadsheetEditor({
selectedSkill,
selectedSkillUsesSpreadsheet,
canEditSpreadsheetInline,
canUploadSpreadsheet,
canDownloadSpreadsheet,
selectedSpreadsheetFileName,
actionState,
refreshCurrentAssets,
loadSelectedAssetDetail,
resolveActor,
toast
}) {
const spreadsheetUploadInput = ref(null)
const spreadsheetOnlyOfficeLoading = ref(false)
const spreadsheetOnlyOfficeError = ref('')
const spreadsheetOnlyOfficeEditor = ref(null)
const spreadsheetOnlyOfficeReady = ref(false)
const spreadsheetOnlyOfficeHostId = ref('audit-rule-onlyoffice')
const spreadsheetChangeRecordsByAsset = ref({})
const spreadsheetChangeDetailOpen = ref(false)
const selectedSpreadsheetChangeRecord = ref(null)
let spreadsheetOnlyOfficeMountSeq = 0
let spreadsheetOnlyOfficeLoadTimer = null
let spreadsheetOnlyOfficeHadLocalEdits = false
let spreadsheetOnlyOfficeSyncSeq = 0
let spreadsheetOnlyOfficeChangePollTimer = null
const selectedSpreadsheetChangeRecords = computed(() => {
if (!selectedSkillUsesSpreadsheet.value || !selectedSkill.value?.id) {
return []
}
return (spreadsheetChangeRecordsByAsset.value[selectedSkill.value.id] || [])
.filter((item) => item?.changed_at)
.map((item) => {
const sheetNames = [
...(Array.isArray(item.sheet_changes)
? item.sheet_changes.map((change) => normalizeText(change.sheet_name))
: []),
...(Array.isArray(item.cell_changes)
? item.cell_changes.map((change) => normalizeText(change.sheet_name))
: [])
].filter(Boolean)
const changedSheetNames = [...new Set(sheetNames)]
const previewChanges = Array.isArray(item.cell_changes) ? item.cell_changes.slice(0, 3) : []
return {
...item,
time: formatDateTime(item.changed_at),
summary: formatSpreadsheetChangeSummary(item.summary),
changeCountLabel: item.changed_cell_count
? `${item.changed_cell_count} 处改动`
: `${item.changed_sheet_count || changedSheetNames.length || 0} 个工作表`,
changedSheetNames,
sheetPreview: changedSheetNames.slice(0, 4),
remainingSheetCount: Math.max(changedSheetNames.length - 4, 0),
previewChanges,
remainingChangeCount: Math.max((item.changed_cell_count || 0) - previewChanges.length, 0)
}
})
})
const selectedSpreadsheetChangeSheetRows = computed(() =>
Array.isArray(selectedSpreadsheetChangeRecord.value?.sheet_changes)
? selectedSpreadsheetChangeRecord.value.sheet_changes.map((item) => ({
...item,
meta: resolveDiffChangeMeta(item.change_type)
}))
: []
)
const selectedSpreadsheetChangeCellRows = computed(() =>
Array.isArray(selectedSpreadsheetChangeRecord.value?.cell_changes)
? selectedSpreadsheetChangeRecord.value.cell_changes.map((item) => ({
...item,
meta: resolveDiffChangeMeta(item.change_type)
}))
: []
)
function stopSpreadsheetOnlyOfficeChangeSync() {
if (spreadsheetOnlyOfficeChangePollTimer) {
window.clearTimeout(spreadsheetOnlyOfficeChangePollTimer)
spreadsheetOnlyOfficeChangePollTimer = null
}
}
function destroySpreadsheetOnlyOfficeEditor() {
if (spreadsheetOnlyOfficeLoadTimer) {
window.clearTimeout(spreadsheetOnlyOfficeLoadTimer)
spreadsheetOnlyOfficeLoadTimer = null
}
stopSpreadsheetOnlyOfficeChangeSync()
spreadsheetOnlyOfficeHadLocalEdits = false
spreadsheetOnlyOfficeSyncSeq += 1
if (spreadsheetOnlyOfficeEditor.value?.destroyEditor) {
spreadsheetOnlyOfficeEditor.value.destroyEditor()
}
spreadsheetOnlyOfficeEditor.value = null
spreadsheetOnlyOfficeReady.value = false
}
function getLatestSpreadsheetChangeKey(assetId) {
return buildSpreadsheetChangeRecordKey(spreadsheetChangeRecordsByAsset.value[assetId] || [])
}
async function loadSpreadsheetChangeRecords(assetId) {
if (!assetId) {
return
}
const payload = await fetchAgentAssetSpreadsheetChangeRecords(assetId, 30)
spreadsheetChangeRecordsByAsset.value = {
...spreadsheetChangeRecordsByAsset.value,
[assetId]: Array.isArray(payload) ? payload : []
}
}
async function refreshSpreadsheetChangeRecordsAfterSave(assetId, previousLatestKey = '', attempt = 0) {
const normalizedAssetId = normalizeText(assetId)
if (!normalizedAssetId || selectedSkill.value?.id !== normalizedAssetId) {
return false
}
await loadSpreadsheetChangeRecords(normalizedAssetId)
const nextLatestKey = getLatestSpreadsheetChangeKey(normalizedAssetId)
if (nextLatestKey && nextLatestKey !== previousLatestKey) {
return true
}
if (attempt >= 9) {
return false
}
await new Promise((resolve) => window.setTimeout(resolve, 800))
return refreshSpreadsheetChangeRecordsAfterSave(normalizedAssetId, previousLatestKey, attempt + 1)
}
function scheduleSpreadsheetOnlyOfficeChangeSync(assetId, attempt = 0) {
const normalizedAssetId = normalizeText(assetId)
if (!normalizedAssetId) {
return
}
const syncSeq = ++spreadsheetOnlyOfficeSyncSeq
stopSpreadsheetOnlyOfficeChangeSync()
const previousLatestChangeKey = getLatestSpreadsheetChangeKey(normalizedAssetId)
const runSync = async () => {
if (syncSeq !== spreadsheetOnlyOfficeSyncSeq || selectedSkill.value?.id !== normalizedAssetId) {
return
}
try {
const changeRecordRefreshed = await refreshSpreadsheetChangeRecordsAfterSave(
normalizedAssetId,
previousLatestChangeKey
)
if (changeRecordRefreshed) {
await refreshCurrentAssets()
stopSpreadsheetOnlyOfficeChangeSync()
return
}
} catch {
// 临时轮询失败不打断编辑器,继续在窗口期内重试。
}
if (syncSeq !== spreadsheetOnlyOfficeSyncSeq || selectedSkill.value?.id !== normalizedAssetId) {
return
}
if (attempt >= 29) {
return
}
spreadsheetOnlyOfficeChangePollTimer = window.setTimeout(() => {
scheduleSpreadsheetOnlyOfficeChangeSync(normalizedAssetId, attempt + 1)
}, 2000)
}
spreadsheetOnlyOfficeChangePollTimer = window.setTimeout(() => {
runSync().catch(() => {})
}, attempt === 0 ? 800 : 2000)
}
function isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId) {
return (
mountSeq !== spreadsheetOnlyOfficeMountSeq ||
!selectedSkillUsesSpreadsheet.value ||
selectedSkill.value?.id !== assetId ||
selectedSkill.value?.loading
)
}
async function mountSpreadsheetOnlyOfficeEditor(retryAttempt = 0) {
if (!selectedSkillUsesSpreadsheet.value || !selectedSkill.value?.id || selectedSkill.value?.loading) {
destroySpreadsheetOnlyOfficeEditor()
return
}
const mountSeq = ++spreadsheetOnlyOfficeMountSeq
const assetId = selectedSkill.value.id
const editable = canEditSpreadsheetInline.value
spreadsheetOnlyOfficeLoading.value = true
spreadsheetOnlyOfficeError.value = ''
spreadsheetOnlyOfficeReady.value = false
destroySpreadsheetOnlyOfficeEditor()
try {
const payload = await fetchAgentAssetSpreadsheetOnlyOfficeConfig(assetId)
if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId)) {
return
}
await loadOnlyOfficeApi(payload.documentServerUrl)
if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId)) {
return
}
if (!window.DocsAPI?.DocEditor) {
throw new Error('表格编辑器未正确加载。')
}
// ONLYOFFICE 会改写宿主节点;每次挂载使用新 id 避免复用脏容器。
spreadsheetOnlyOfficeHostId.value = `audit-rule-onlyoffice-${assetId}-${mountSeq}`
await nextTick()
if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId)) {
return
}
const config = buildOnlyOfficeEditorConfig(payload.config, {
viewportHeight: window.innerHeight,
editable,
fillContainer: true
})
const upstreamEvents = config.events || {}
spreadsheetOnlyOfficeLoadTimer = window.setTimeout(() => {
if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId)) {
return
}
if (retryAttempt < 1) {
destroySpreadsheetOnlyOfficeEditor()
spreadsheetOnlyOfficeLoading.value = true
window.setTimeout(() => {
mountSpreadsheetOnlyOfficeEditor(retryAttempt + 1).catch(() => {})
}, 600)
return
}
spreadsheetOnlyOfficeError.value = '表格加载超时,请退出详情后重试。'
spreadsheetOnlyOfficeLoading.value = false
destroySpreadsheetOnlyOfficeEditor()
}, 15000)
config.events = {
...upstreamEvents,
onAppReady(event) {
if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId)) {
return
}
if (spreadsheetOnlyOfficeLoadTimer) {
window.clearTimeout(spreadsheetOnlyOfficeLoadTimer)
spreadsheetOnlyOfficeLoadTimer = null
}
spreadsheetOnlyOfficeReady.value = true
spreadsheetOnlyOfficeLoading.value = false
upstreamEvents.onAppReady?.(event)
},
onError(event) {
if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId)) {
return
}
if (spreadsheetOnlyOfficeLoadTimer) {
window.clearTimeout(spreadsheetOnlyOfficeLoadTimer)
spreadsheetOnlyOfficeLoadTimer = null
}
const errorCode = event?.data?.errorCode
const errorDescription = event?.data?.errorDescription
spreadsheetOnlyOfficeError.value = errorDescription
? `表格加载失败:${errorDescription}`
: `表格加载失败${errorCode ? `(错误码 ${errorCode}` : '。'}`
spreadsheetOnlyOfficeLoading.value = false
upstreamEvents.onError?.(event)
},
onDocumentStateChange(event) {
const hasChanges = Boolean(event?.data)
if (hasChanges) {
spreadsheetOnlyOfficeHadLocalEdits = true
if (!spreadsheetOnlyOfficeChangePollTimer) {
scheduleSpreadsheetOnlyOfficeChangeSync(assetId)
}
} else if (
spreadsheetOnlyOfficeHadLocalEdits &&
editable &&
!isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId)
) {
spreadsheetOnlyOfficeHadLocalEdits = false
scheduleSpreadsheetOnlyOfficeChangeSync(assetId)
}
upstreamEvents.onDocumentStateChange?.(event)
}
}
spreadsheetOnlyOfficeEditor.value = new window.DocsAPI.DocEditor(
spreadsheetOnlyOfficeHostId.value,
config
)
if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId)) {
destroySpreadsheetOnlyOfficeEditor()
}
} catch (error) {
if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId)) {
return
}
spreadsheetOnlyOfficeError.value = error?.message || '规则表加载失败,请稍后重试。'
spreadsheetOnlyOfficeLoading.value = false
toast(spreadsheetOnlyOfficeError.value)
}
}
function triggerSpreadsheetUpload() {
if (!canUploadSpreadsheet.value) {
return
}
spreadsheetUploadInput.value?.click?.()
}
async function downloadSpreadsheetFile() {
if (!canDownloadSpreadsheet.value || !selectedSkill.value?.id) {
return
}
actionState.value = 'download-spreadsheet'
try {
const blob = await fetchAgentAssetSpreadsheetBlob(
selectedSkill.value.id,
'attachment'
)
const objectUrl = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = objectUrl
anchor.download = selectedSpreadsheetFileName.value || '规则表.xlsx'
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
URL.revokeObjectURL(objectUrl)
} catch (error) {
toast(error?.message || '规则表下载失败,请稍后重试。')
} finally {
actionState.value = ''
}
}
async function uploadSpreadsheetFile(file) {
if (!file || !selectedSkill.value?.id || !canUploadSpreadsheet.value) {
return
}
actionState.value = 'upload-spreadsheet'
try {
await importAgentAssetSpreadsheetContent(selectedSkill.value.id, file, {
actor: resolveActor()
})
await refreshCurrentAssets()
await loadSelectedAssetDetail(selectedSkill.value.id)
await loadSpreadsheetChangeRecords(selectedSkill.value.id)
toast(`已导入 ${file.name} 的表格内容,右侧会记录本次修改。`)
} catch (error) {
toast(error?.message || '规则表内容导入失败,请稍后重试。')
} finally {
actionState.value = ''
spreadsheetUploadInput.value?.reset?.()
}
}
async function handleSpreadsheetFileInput(event) {
await uploadSpreadsheetFile(event?.target?.files?.[0] || null)
}
function openSpreadsheetChangeDetail(item) {
if (!item?.changed_at) {
return
}
selectedSpreadsheetChangeRecord.value = item
spreadsheetChangeDetailOpen.value = true
}
function closeSpreadsheetChangeDetail() {
spreadsheetChangeDetailOpen.value = false
}
return {
spreadsheetUploadInput,
spreadsheetOnlyOfficeLoading,
spreadsheetOnlyOfficeError,
spreadsheetOnlyOfficeReady,
spreadsheetOnlyOfficeHostId,
spreadsheetChangeDetailOpen,
selectedSpreadsheetChangeRecord,
selectedSpreadsheetChangeRecords,
selectedSpreadsheetChangeSheetRows,
selectedSpreadsheetChangeCellRows,
destroySpreadsheetOnlyOfficeEditor,
mountSpreadsheetOnlyOfficeEditor,
triggerSpreadsheetUpload,
downloadSpreadsheetFile,
uploadSpreadsheetFile,
handleSpreadsheetFileInput,
loadSpreadsheetChangeRecords,
openSpreadsheetChangeDetail,
closeSpreadsheetChangeDetail
}
}

View File

@@ -0,0 +1,169 @@
import { computed, ref } from 'vue'
import { fetchAgentAssetVersionTimeline } from '../../services/agentAssets.js'
import {
buildDefaultRuntimeRule,
formatDateTime,
normalizeText,
resolveRuleTemplateLabel,
resolveTimelineEventMeta,
stringifyRuntimeRule
} from './auditViewModel.js'
const VERSION_TIMELINE_CACHE_TTL = 60 * 1000
function normalizeAssetId(assetId) {
return normalizeText(assetId)
}
function readVersionTimelineCache(timelineCache, assetId) {
const key = normalizeAssetId(assetId)
if (!key) {
return null
}
const cached = timelineCache.get(key)
if (!cached) {
return null
}
const isExpired = Date.now() - cached.timestamp > VERSION_TIMELINE_CACHE_TTL
return isExpired ? null : cached.items
}
function writeVersionTimelineCache(timelineCache, assetId, items) {
const key = normalizeAssetId(assetId)
if (!key) {
return
}
timelineCache.set(key, {
items,
timestamp: Date.now()
})
}
function applyVersionPayloadToRulePreview(skill, version) {
if (!skill || !version) {
return
}
const selectedVersion = version.version
skill.displayVersion = selectedVersion
skill.displayVersionChangeNote = version.note || '无版本说明'
if (skill.usesSpreadsheetRule) {
return
}
if (typeof version.markdownContent === 'string') {
skill.markdownContent = version.markdownContent
}
const runtimeRule = version.runtimeRule || buildDefaultRuntimeRule(skill)
skill.runtimeRuleText = stringifyRuntimeRule(runtimeRule)
skill.runtimeKind = normalizeText(runtimeRule.kind) || skill.runtimeKind || 'policy_rule_draft'
skill.ruleTemplateKey = normalizeText(runtimeRule.template_key) || skill.ruleTemplateKey
skill.ruleTemplateLabel = resolveRuleTemplateLabel(skill.ruleTemplateKey)
}
export function useAuditVersionTimeline({ selectedSkill, toast }) {
const versionSwitchTarget = ref(null)
const versionTimelineOpen = ref(false)
const versionTimelineLoading = ref(false)
const versionTimelineError = ref('')
const versionTimelineItems = ref([])
const versionTimelineCache = new Map()
const selectedVersionTimelineItems = computed(() =>
versionTimelineItems.value.map((item) => ({
...item,
meta: resolveTimelineEventMeta(item.event_type),
timeLabel: formatDateTime(item.event_time)
}))
)
async function loadVersionTimeline(assetId = selectedSkill.value?.id, options = {}) {
if (!assetId) {
return
}
const cachedItems = options.force ? null : readVersionTimelineCache(versionTimelineCache, assetId)
if (cachedItems) {
versionTimelineItems.value = cachedItems
return
}
versionTimelineLoading.value = true
versionTimelineError.value = ''
try {
const payload = await fetchAgentAssetVersionTimeline(assetId)
const nextItems = Array.isArray(payload) ? payload : []
versionTimelineItems.value = nextItems
writeVersionTimelineCache(versionTimelineCache, assetId, nextItems)
} catch (error) {
versionTimelineError.value = error?.message || '操作记录加载失败,请稍后重试。'
if (!options.silent) {
toast(versionTimelineError.value)
}
versionTimelineItems.value = []
writeVersionTimelineCache(versionTimelineCache, assetId, [])
} finally {
versionTimelineLoading.value = false
}
}
async function openVersionTimeline() {
if (!selectedSkill.value?.id) {
return
}
versionTimelineOpen.value = true
await loadVersionTimeline(selectedSkill.value.id)
}
function closeVersionTimeline() {
versionTimelineOpen.value = false
}
function clearVersionTimelineState() {
versionTimelineOpen.value = false
versionTimelineItems.value = []
versionTimelineError.value = ''
versionSwitchTarget.value = null
}
function openVersionSwitch(version) {
if (!selectedSkill.value || version.version === selectedSkill.value.displayVersion) {
return
}
versionSwitchTarget.value = version
}
function cancelVersionSwitch() {
versionSwitchTarget.value = null
}
function confirmVersionSwitch() {
if (!selectedSkill.value || !versionSwitchTarget.value) {
return
}
applyVersionPayloadToRulePreview(selectedSkill.value, versionSwitchTarget.value)
versionSwitchTarget.value = null
}
return {
versionSwitchTarget,
versionTimelineOpen,
versionTimelineLoading,
versionTimelineError,
selectedVersionTimelineItems,
loadVersionTimeline,
openVersionTimeline,
closeVersionTimeline,
clearVersionTimelineState,
openVersionSwitch,
cancelVersionSwitch,
confirmVersionSwitch
}
}

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

@@ -1046,5 +1046,43 @@ export default defineConfig({
} }
} }
}, },
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (!id.includes('node_modules')) {
return undefined
}
const normalizedId = id.replace(/\\/g, '/')
if (
normalizedId.includes('/node_modules/vue/') ||
normalizedId.includes('/node_modules/@vue/') ||
normalizedId.includes('/node_modules/vue-router/')
) {
return 'vendor-vue'
}
if (normalizedId.includes('element-plus') || normalizedId.includes('@element-plus')) {
return 'vendor-element-plus'
}
if (normalizedId.includes('echarts') || normalizedId.includes('zrender')) {
return 'vendor-echarts'
}
if (normalizedId.includes('@antv/g6')) {
return 'vendor-g6'
}
if (normalizedId.includes('chart.js') || normalizedId.includes('vue-chartjs')) {
return 'vendor-chartjs'
}
if (normalizedId.includes('markdown-it')) {
return 'vendor-markdown'
}
if (normalizedId.includes('@vueuse')) {
return 'vendor-vueuse'
}
return 'vendor'
}
}
}
},
plugins: [vue(), localSetupPlugin()] plugins: [vue(), localSetupPlugin()]
}) })