5 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
caoxiaozhu
064eeb614f refactor(ui): introduce shared list detail shells 2026-05-28 22:49:58 +08:00
caoxiaozhu
b383244a29 chore: backup workspace before list detail shell refactor 2026-05-28 22:33:53 +08:00
98 changed files with 10290 additions and 5496 deletions

2672
server/uv.lock generated Normal file

File diff suppressed because it is too large Load Diff

7
web/package-lock.json generated
View File

@@ -17,7 +17,6 @@
"element-plus": "^2.14.0",
"markdown-it": "^14.1.1",
"pg": "^8.13.1",
"primeicons": "^7.0.0",
"vite": "^5.4.19",
"vue": "^3.5.13",
"vue-chartjs": "^5.3.3",
@@ -2754,12 +2753,6 @@
"node": ">=0.10.0"
}
},
"node_modules/primeicons": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/primeicons/-/primeicons-7.0.0.tgz",
"integrity": "sha512-jK3Et9UzwzTsd6tzl2RmwrVY/b8raJ3QZLzoDACj+oTJ0oX7L9Hy+XnVwgo4QVKlKpnP/Ur13SXV/pVh4LzaDw==",
"license": "MIT"
},
"node_modules/punycode.js": {
"version": "2.3.1",
"resolved": "https://registry.npmmirror.com/punycode.js/-/punycode.js-2.3.1.tgz",

View File

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

View File

@@ -69,68 +69,6 @@
pointer-events: none;
}
.login-entry-card {
width: min(360px, calc(100% - 48px));
display: grid;
grid-template-columns: 42px minmax(0, 1fr);
gap: 12px 14px;
align-items: center;
padding: 22px 24px 20px;
border: 1px solid rgba(148, 163, 184, 0.26);
border-radius: 4px;
background: #fff;
box-shadow: 0 20px 46px rgba(15, 23, 42, 0.14);
animation: loginEntryCardIn 360ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.login-entry-mark {
width: 42px;
height: 42px;
display: inline-grid;
place-items: center;
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.2);
border-radius: 4px;
background: var(--theme-primary-soft);
color: var(--theme-primary-active);
font-size: 22px;
}
.login-entry-copy {
min-width: 0;
display: grid;
gap: 4px;
}
.login-entry-copy strong {
color: var(--ink);
font-size: 16px;
line-height: 1.35;
font-weight: 750;
}
.login-entry-copy span {
color: var(--muted);
font-size: 13px;
line-height: 1.45;
}
.login-entry-progress {
grid-column: 1 / -1;
height: 3px;
overflow: hidden;
background: #edf2f7;
}
.login-entry-progress::after {
content: '';
display: block;
width: 100%;
height: 100%;
background: var(--theme-primary);
transform-origin: left center;
animation: loginEntryProgress 840ms cubic-bezier(0.2, 0, 0, 1) both;
}
.login-entry-veil-enter-active {
transition: opacity 180ms var(--ease);
}
@@ -231,7 +169,6 @@
.main.policies-main,
.main.audit-main,
.main.digital-employees-main,
.main.logs-main,
.main.employees-main,
.main.settings-main {
height: var(--desktop-stage-height, 100dvh);
@@ -241,9 +178,6 @@
.main.settings-main {
grid-template-rows: minmax(0, 1fr);
}
.main.audit-detail-main {
grid-template-rows: minmax(0, 1fr);
}
.workarea { min-height: 0; overflow: auto; padding: 24px; }
.workarea.requests-workarea,
.workarea.documents-workarea,
@@ -253,7 +187,6 @@
.workarea.policies-workarea,
.workarea.audit-workarea,
.workarea.digital-employees-workarea,
.workarea.logs-workarea,
.workarea.employees-workarea,
.workarea.settings-workarea {
min-height: 0;
@@ -270,28 +203,6 @@
background: #fff;
}
@keyframes loginEntryCardIn {
from {
opacity: 0;
transform: scale3d(0.92, 0.92, 1);
}
to {
opacity: 1;
transform: scale3d(1, 1, 1);
}
}
@keyframes loginEntryProgress {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
@keyframes loginEntrySidebarIn {
from {
opacity: 0;
@@ -404,8 +315,6 @@
transition-duration: 120ms, 120ms !important;
}
.login-entry-card,
.login-entry-progress::after,
.app.login-entry-active .app-sidebar,
.app.login-entry-active > .main {
animation: none !important;

View File

@@ -1,69 +1,31 @@
.digital-work-records {
height: 100%;
min-height: 0;
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
gap: 12px;
display: flex;
flex-direction: column;
gap: 0;
}
.work-records-head {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(420px, 0.8fr);
.digital-work-records .work-records-head {
display: flex;
align-items: start;
justify-content: space-between;
gap: 16px;
}
.work-records-head h3 {
.digital-work-records .work-records-head h3 {
margin: 0;
color: #0f172a;
font-size: 16px;
}
.work-records-head p {
.digital-work-records .work-records-head p {
margin: 6px 0 0;
color: #64748b;
font-size: 13px;
}
.work-records-kpis {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
justify-self: end;
width: min(100%, 480px);
}
.work-record-kpi {
min-height: 58px;
padding: 10px 12px;
border: 1px solid #dfe7ef;
border-radius: 4px;
background: #fff;
}
.work-record-kpi span {
display: block;
color: #64748b;
font-size: 12px;
font-weight: 700;
}
.work-record-kpi strong {
display: block;
margin-top: 6px;
color: #0f172a;
font-size: 22px;
line-height: 1;
}
.work-record-kpi.success strong {
color: var(--success-active);
}
.work-record-kpi.danger strong {
color: #dc2626;
}
.work-records-toolbar {
.digital-work-records .work-records-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
@@ -72,12 +34,129 @@
font-size: 13px;
}
.work-records-toolbar button {
min-height: 34px;
.digital-work-records .work-records-table-wrap.is-empty {
display: grid;
align-items: center;
justify-content: center;
}
.digital-work-records .work-records-table-wrap {
min-height: 0;
}
.digital-work-records .filter-set {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.digital-work-records .list-search {
position: relative;
width: 280px;
}
.digital-work-records .list-search .mdi {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #64748b;
font-size: 15px;
}
.digital-work-records .list-search input {
width: 100%;
height: 38px;
padding: 0 12px 0 36px;
border: 1px solid #d7e0ea;
border-radius: 4px;
background: #fff;
color: #0f172a;
font-size: 13px;
transition: border-color 160ms ease, box-shadow 160ms ease;
}
.digital-work-records .list-search input::placeholder {
color: #8da0b4;
}
.digital-work-records .list-search input:focus {
border-color: var(--theme-primary);
box-shadow: 0 0 0 3px rgba(58, 124, 165, 0.14);
outline: none;
}
.digital-work-records .filter-btn {
min-height: 38px;
min-width: 120px;
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 9px;
padding: 0 14px;
border: 1px solid #d7e0ea;
border-radius: 4px;
background: #fff;
color: #334155;
font-size: 14px;
font-weight: 750;
white-space: nowrap;
cursor: pointer;
}
.digital-work-records .filter-btn:hover {
border-color: rgba(58, 124, 165, .32);
color: var(--theme-primary-active);
}
.digital-work-records .work-records-filter {
position: relative;
}
.digital-work-records .work-records-filter-menu {
position: absolute;
top: calc(100% + 8px);
left: 0;
min-width: 150px;
max-height: 280px;
padding: 6px;
z-index: 40;
border: 1px solid #d7e0ea;
border-radius: 4px;
background: #fff;
box-shadow: 0 16px 32px rgba(15, 23, 42, 0.12);
overflow-y: auto;
}
.digital-work-records .work-records-filter-menu button {
display: block;
width: 100%;
min-height: 36px;
padding: 0 12px;
border: 0;
border-radius: 4px;
background: transparent;
color: #334155;
font-size: 13px;
font-weight: 650;
text-align: left;
white-space: nowrap;
cursor: pointer;
}
.digital-work-records .work-records-filter-menu button:hover,
.digital-work-records .work-records-filter-menu button.active {
background: rgba(58, 124, 165, 0.1);
color: var(--theme-primary-active);
}
.digital-work-records .refresh-btn {
min-height: 38px;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0 12px;
padding: 0 14px;
border: 1px solid #d8e1eb;
border-radius: 4px;
background: #fff;
@@ -87,45 +166,44 @@
cursor: pointer;
}
.work-records-toolbar button:disabled {
.digital-work-records .refresh-btn:disabled {
cursor: not-allowed;
opacity: 0.7;
}
.work-records-table-wrap {
min-height: 400px;
overflow: auto;
border: 1px solid #edf2f7;
border-radius: 10px;
background: linear-gradient(180deg, #fcfefd 0%, #f4f8f6 100%);
}
.work-records-table-wrap.is-empty {
display: grid;
.digital-work-records .list-foot {
display: flex;
align-items: center;
justify-content: center;
justify-content: flex-end;
padding-top: 12px;
}
.digital-work-records-table {
.digital-work-records .page-summary {
color: #64748b;
font-size: 12px;
font-weight: 700;
}
.digital-work-records .digital-work-records-table {
width: 100%;
min-width: 1180px;
table-layout: fixed;
border-collapse: collapse;
}
.digital-work-records-table .col-time { width: 14%; }
.digital-work-records .digital-work-records-table .col-time { width: 14%; }
.digital-work-records-table .col-module { width: 12%; }
.digital-work-records .digital-work-records-table .col-module { width: 12%; }
.digital-work-records-table .col-source { width: 10%; }
.digital-work-records .digital-work-records-table .col-source { width: 10%; }
.digital-work-records-table .col-status { width: 17%; }
.digital-work-records .digital-work-records-table .col-status { width: 17%; }
.digital-work-records-table .col-summary { width: 31%; }
.digital-work-records .digital-work-records-table .col-summary { width: 31%; }
.digital-work-records-table .col-trace { width: 16%; }
.digital-work-records .digital-work-records-table .col-trace { width: 16%; }
.digital-work-records-table thead th {
.digital-work-records .digital-work-records-table thead th {
position: sticky;
top: 0;
z-index: 1;
@@ -140,7 +218,7 @@
white-space: nowrap;
}
.digital-work-records-table tbody td {
.digital-work-records .digital-work-records-table tbody td {
padding: 13px 12px;
border-bottom: 1px solid #edf2f7;
color: #24324a;
@@ -150,56 +228,73 @@
vertical-align: middle;
}
.digital-work-records-table tbody tr {
.digital-work-records .digital-work-records-table tbody tr {
cursor: pointer;
outline: none;
}
.digital-work-records-table tbody tr:hover,
.digital-work-records-table tbody tr:focus-visible {
.digital-work-records .digital-work-records-table tbody tr:hover,
.digital-work-records .digital-work-records-table tbody tr:focus-visible {
background: linear-gradient(90deg, rgba(58, 124, 165, .08), rgba(58, 124, 165, .03));
}
.digital-work-records-table tbody tr:focus-visible {
.digital-work-records .digital-work-records-table tbody tr:focus-visible {
box-shadow: inset 0 0 0 2px rgba(58, 124, 165, .28);
}
.digital-work-records-table tbody tr:last-child td {
.digital-work-records .digital-work-records-table tbody tr:last-child td {
border-bottom: 0;
}
.work-record-status-stack {
.digital-work-records .work-records-table {
min-width: 1180px;
table-layout: fixed;
}
.digital-work-records .work-records-table .col-time { width: 14%; }
.digital-work-records .work-records-table .col-module { width: 13%; }
.digital-work-records .work-records-table .col-source { width: 10%; }
.digital-work-records .work-records-table .col-status { width: 16%; }
.digital-work-records .work-records-table .col-summary { width: 31%; }
.digital-work-records .work-records-table .col-trace { width: 16%; }
.digital-work-records .work-record-status-stack {
display: grid;
gap: 5px;
justify-items: center;
}
.work-record-status-stack > span:last-child {
.digital-work-records .work-record-status-stack > span:last-child {
color: #64748b;
font-size: 12px;
line-height: 1.5;
}
.work-record-summary-cell {
.digital-work-records .work-record-summary-cell {
text-align: left !important;
}
.work-record-summary-cell strong,
.work-record-summary-cell span,
.work-record-summary-cell em {
.digital-work-records .work-record-summary-cell strong,
.digital-work-records .work-record-summary-cell span,
.digital-work-records .work-record-summary-cell em {
display: block;
overflow: hidden;
text-overflow: ellipsis;
}
.work-record-summary-cell strong {
.digital-work-records .work-record-summary-cell strong {
color: #0f172a;
font-size: 13px;
font-weight: 800;
white-space: nowrap;
}
.work-record-summary-cell span {
.digital-work-records .work-record-summary-cell span {
margin-top: 4px;
color: #64748b;
font-size: 13px;
@@ -207,7 +302,7 @@
white-space: nowrap;
}
.work-record-summary-cell em {
.digital-work-records .work-record-summary-cell em {
margin-top: 6px;
color: #94a3b8;
font-size: 12px;
@@ -215,12 +310,12 @@
white-space: nowrap;
}
.work-record-trace-cell {
.digital-work-records .work-record-trace-cell {
color: #2563eb !important;
word-break: break-all;
}
.status-pill {
.digital-work-records .status-pill {
min-height: 24px;
display: inline-flex;
align-items: center;
@@ -233,38 +328,38 @@
white-space: nowrap;
}
.status-pill.success {
.digital-work-records .status-pill.success {
border-color: var(--success-line);
background: var(--success-soft);
color: var(--success-active);
}
.status-pill.warning {
.digital-work-records .status-pill.warning {
border-color: #fed7aa;
background: #fff7ed;
color: #f97316;
}
.status-pill.danger {
.digital-work-records .status-pill.danger {
border-color: #fecaca;
background: #fef2f2;
color: #dc2626;
}
.status-pill.info {
.digital-work-records .status-pill.info {
border-color: #bfdbfe;
background: #eff6ff;
color: #2563eb;
}
.status-pill.muted {
.digital-work-records .status-pill.muted {
border-color: #cbd5e1;
background: #f8fafc;
color: #475569;
}
.table-state,
.work-records-empty {
.digital-work-records .table-state,
.digital-work-records .work-records-empty {
width: 100%;
min-height: 260px;
display: grid;
@@ -276,83 +371,73 @@
text-align: center;
}
.table-state.error {
.digital-work-records .table-state.error {
background: linear-gradient(180deg, #fffdfd 0%, #fff6f6 100%);
}
.table-state.error .mdi {
.digital-work-records .table-state.error .mdi {
color: #ef4444;
font-size: 28px;
}
.table-state.error strong {
.digital-work-records .table-state.error strong {
color: #0f172a;
font-size: 15px;
}
.table-state.error p {
.digital-work-records .table-state.error p {
margin: 0;
}
.work-record-detail-mask {
position: fixed;
inset: 0;
z-index: 2800;
display: flex;
justify-content: flex-end;
background: rgba(15, 23, 42, .28);
.digital-work-records.is-detail {
display: grid;
grid-template-rows: minmax(0, 1fr);
gap: 12px;
}
.work-record-detail-panel {
width: min(720px, calc(100vw - 32px));
.digital-work-records .work-record-detail-page {
height: 100%;
min-height: 0;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
border-left: 1px solid #dfe7ef;
background: #fff;
box-shadow: -18px 0 42px rgba(15, 23, 42, .18);
gap: 12px;
}
.work-record-detail-head {
min-height: 76px;
.digital-work-records .work-record-detail-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
padding: 16px 18px;
border-bottom: 1px solid #edf2f7;
min-height: 44px;
padding: 0 2px;
}
.work-record-detail-head span {
color: #64748b;
font-size: 12px;
font-weight: 800;
}
.work-record-detail-head h3 {
margin: 5px 0 0;
color: #0f172a;
font-size: 17px;
line-height: 1.35;
}
.work-record-detail-head button {
width: 34px;
height: 34px;
display: grid;
flex: 0 0 auto;
place-items: center;
.digital-work-records .work-record-detail-toolbar .back-action,
.digital-work-records .work-record-detail-toolbar .refresh-btn {
height: 36px;
display: inline-flex;
align-items: center;
gap: 7px;
padding: 0 12px;
border: 1px solid #d8e1eb;
border-radius: 4px;
background: #fff;
color: #64748b;
color: #0f172a;
font-size: 13px;
font-weight: 750;
}
.work-record-detail-head button:hover {
.digital-work-records .work-record-detail-toolbar button:hover:not(:disabled) {
border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), .34);
background: #f5fbff;
color: var(--theme-primary-active);
}
.work-record-detail-body {
.digital-work-records .work-record-detail-shell {
min-height: 0;
}
.digital-work-records .work-record-detail-body {
min-height: 0;
display: grid;
align-content: start;
@@ -362,18 +447,24 @@
background: #f8fafc;
}
.work-record-detail-section,
.work-record-detail-state {
.digital-work-records .work-record-detail-body.inline-detail {
padding: 0;
overflow: visible;
background: transparent;
}
.digital-work-records .work-record-detail-section,
.digital-work-records .work-record-detail-state {
border: 1px solid #e5edf5;
border-radius: 6px;
background: #fff;
}
.work-record-detail-section {
.digital-work-records .work-record-detail-section {
padding: 14px;
}
.work-record-detail-state {
.digital-work-records .work-record-detail-state {
min-height: 100%;
display: grid;
place-items: center;
@@ -383,20 +474,20 @@
text-align: center;
}
.work-record-detail-state.error .mdi {
.digital-work-records .work-record-detail-state.error .mdi {
color: #dc2626;
font-size: 30px;
}
.work-record-detail-state.error strong {
.digital-work-records .work-record-detail-state.error strong {
color: #0f172a;
}
.work-record-detail-state.error p {
.digital-work-records .work-record-detail-state.error p {
margin: 0;
}
.work-record-detail-state.error button {
.digital-work-records .work-record-detail-state.error button {
height: 34px;
padding: 0 12px;
border: 1px solid #fecaca;
@@ -406,7 +497,7 @@
font-weight: 750;
}
.work-record-section-head {
.digital-work-records .work-record-section-head {
display: flex;
align-items: center;
justify-content: space-between;
@@ -414,25 +505,25 @@
margin-bottom: 12px;
}
.work-record-section-head h4 {
.digital-work-records .work-record-section-head h4 {
margin: 0;
color: #0f172a;
font-size: 15px;
}
.work-record-section-head > span:not(.status-pill) {
.digital-work-records .work-record-section-head > span:not(.status-pill) {
color: #94a3b8;
font-size: 12px;
font-weight: 750;
}
.work-record-info-grid {
.digital-work-records .work-record-info-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.work-record-info-grid div {
.digital-work-records .work-record-info-grid div {
min-width: 0;
padding: 10px;
border: 1px solid #edf2f7;
@@ -440,14 +531,14 @@
background: #f8fafc;
}
.work-record-info-grid span {
.digital-work-records .work-record-info-grid span {
display: block;
color: #64748b;
font-size: 12px;
font-weight: 750;
}
.work-record-info-grid strong {
.digital-work-records .work-record-info-grid strong {
display: block;
margin-top: 5px;
overflow-wrap: anywhere;
@@ -455,16 +546,16 @@
font-size: 13px;
}
.work-record-result-text,
.work-record-error-text,
.work-record-inline-empty {
.digital-work-records .work-record-result-text,
.digital-work-records .work-record-error-text,
.digital-work-records .work-record-inline-empty {
margin: 0;
color: #475569;
font-size: 13px;
line-height: 1.65;
}
.work-record-error-text {
.digital-work-records .work-record-error-text {
margin-top: 10px;
padding: 10px 12px;
border: 1px solid #fecaca;
@@ -473,12 +564,12 @@
color: #b91c1c;
}
.work-record-tool-list {
.digital-work-records .work-record-tool-list {
display: grid;
gap: 8px;
}
.work-record-tool-list article {
.digital-work-records .work-record-tool-list article {
display: flex;
align-items: center;
justify-content: space-between;
@@ -489,17 +580,17 @@
background: #f8fafc;
}
.work-record-tool-list strong {
.digital-work-records .work-record-tool-list strong {
color: #0f172a;
font-size: 13px;
}
.work-record-tool-list span {
.digital-work-records .work-record-tool-list span {
color: #64748b;
font-size: 12px;
}
.work-record-code-block {
.digital-work-records .work-record-code-block {
max-height: 320px;
margin: 0;
padding: 12px;
@@ -512,37 +603,13 @@
line-height: 1.55;
}
.work-record-detail-enter-active,
.work-record-detail-leave-active {
transition: opacity 180ms ease;
}
.work-record-detail-enter-active .work-record-detail-panel,
.work-record-detail-leave-active .work-record-detail-panel {
transition: transform 220ms ease;
}
.work-record-detail-enter-from,
.work-record-detail-leave-to {
opacity: 0;
}
.work-record-detail-enter-from .work-record-detail-panel,
.work-record-detail-leave-to .work-record-detail-panel {
transform: translateX(24px);
}
@media (max-width: 980px) {
.work-records-head {
.digital-work-records .work-records-head {
display: grid;
grid-template-columns: 1fr;
}
.work-records-kpis {
justify-self: stretch;
width: 100%;
}
.work-record-info-grid {
.digital-work-records .work-record-info-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,673 @@
.enterprise-list-page {
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
padding: 16px 18px;
}
.enterprise-list-page .status-tabs {
flex: 0 0 auto;
display: flex;
gap: 28px;
margin-top: 14px;
border-bottom: 1px solid #dbe4ee;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
}
.enterprise-list-page .status-tabs::-webkit-scrollbar {
display: none;
}
.enterprise-list-page .status-tabs button {
position: relative;
min-height: 36px;
border: 0;
background: transparent;
color: #64748b;
font-size: 14px;
font-weight: 750;
white-space: nowrap;
}
.enterprise-list-page .status-tabs button.active {
color: var(--theme-primary-active);
}
.enterprise-list-page .status-tabs button.active::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: -1px;
height: 3px;
border-radius: 2px 2px 0 0;
background: var(--theme-primary);
}
.enterprise-list-page .list-toolbar {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-top: 14px;
}
.enterprise-list-page .table-wrap {
flex: 1 1 auto;
min-height: 0;
}
.enterprise-list-page .toolbar-actions,
.enterprise-list-page .document-actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-left: auto;
}
.enterprise-list-page .filter-set {
flex: 1 1 auto;
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
min-width: 0;
}
.enterprise-list-page .list-search {
position: relative;
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 {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #64748b;
font-size: 15px;
}
.enterprise-list-page .list-search input {
width: 100%;
height: 38px;
padding: 0 12px 0 36px;
border: 1px solid #d7e0ea;
border-radius: 4px;
background: #fff;
color: #0f172a;
font-size: 13px;
transition: border-color 160ms ease, box-shadow 160ms ease;
}
.enterprise-list-page .list-search input::placeholder {
color: #8da0b4;
}
.enterprise-list-page .list-search input:focus {
border-color: var(--theme-primary);
box-shadow: 0 0 0 3px var(--theme-focus-ring);
outline: none;
}
.enterprise-list-page .filter-btn {
min-width: 104px;
min-height: 38px;
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 9px;
padding: 0 14px;
border: 1px solid #d7e0ea;
border-radius: 4px;
background: #fff;
color: #334155;
font-size: 14px;
font-weight: 750;
white-space: nowrap;
}
.enterprise-list-page .filter-btn:hover,
.enterprise-list-page .document-filter.open .filter-btn {
border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.32);
color: var(--theme-primary-active);
}
.enterprise-list-page .create-request-btn,
.enterprise-list-page .create-btn,
.enterprise-list-page .export-btn,
.enterprise-list-page .template-btn {
min-height: 40px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 0 18px;
border: 0;
border-radius: 4px;
background: var(--theme-gradient-primary);
color: #fff;
font-size: 14px;
font-weight: 800;
white-space: nowrap;
box-shadow: 0 10px 24px var(--theme-primary-shadow);
transition: transform 160ms ease, box-shadow 160ms ease, filter 160ms ease;
}
.enterprise-list-page .create-request-btn.secondary,
.enterprise-list-page .ghost-filter-btn,
.enterprise-list-page .template-btn,
.enterprise-list-page .export-btn {
border: 1px solid #d7e0ea;
background: #fff;
color: #334155;
box-shadow: none;
}
.enterprise-list-page .create-request-btn:hover:not(:disabled),
.enterprise-list-page .create-btn:hover:not(:disabled),
.enterprise-list-page .export-btn:hover:not(:disabled),
.enterprise-list-page .template-btn:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 14px 28px var(--theme-primary-shadow);
filter: saturate(1.02);
}
.enterprise-list-page .create-request-btn.secondary:hover:not(:disabled),
.enterprise-list-page .ghost-filter-btn:hover:not(:disabled),
.enterprise-list-page .template-btn:hover:not(:disabled),
.enterprise-list-page .export-btn:hover:not(:disabled) {
border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.32);
color: var(--theme-primary-active);
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.08);
}
.enterprise-list-page .create-request-btn:disabled,
.enterprise-list-page .create-btn:disabled,
.enterprise-list-page .export-btn:disabled,
.enterprise-list-page .template-btn:disabled {
cursor: not-allowed;
opacity: 0.72;
transform: none;
}
.enterprise-list-page .hint {
display: inline-flex;
align-items: center;
gap: 7px;
margin-top: 10px;
color: #64748b;
font-size: 13px;
}
.enterprise-list-page .hint .mdi {
color: #64748b;
}
.enterprise-list-page .active-filter-strip {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.enterprise-list-page .active-filter-chip {
min-height: 28px;
display: inline-flex;
align-items: center;
padding: 0 10px;
border-radius: 4px;
background: var(--theme-primary-soft);
color: var(--theme-primary-active);
font-size: 12px;
font-weight: 800;
}
.enterprise-list-page .table-wrap {
min-height: 400px;
margin-top: 10px;
overflow: auto;
border: 1px solid #edf2f7;
border-radius: 10px;
background: linear-gradient(180deg, #fcfefd 0%, #f4f8f6 100%);
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: flex-start;
}
.enterprise-list-page .table-wrap.is-empty {
align-items: center;
justify-content: center;
}
.enterprise-list-page .table-state {
width: 100%;
min-height: 220px;
align-self: center;
}
.enterprise-list-page .table-state.error {
display: grid;
place-items: center;
gap: 10px;
padding: 32px;
text-align: center;
}
.enterprise-list-page .table-state.error > .mdi {
color: var(--danger);
font-size: 30px;
}
.enterprise-list-page .table-state.error strong {
color: var(--ink);
font-size: 15px;
}
.enterprise-list-page .table-state.error p {
max-width: 520px;
color: var(--muted);
font-size: 13px;
line-height: 1.6;
}
.enterprise-list-page .state-action,
.enterprise-list-page .retry-btn {
min-height: 34px;
padding: 0 12px;
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.22);
border-radius: 4px;
background: var(--theme-primary-soft);
color: var(--theme-primary-active);
font-size: 12px;
font-weight: 750;
}
.enterprise-list-page table,
.enterprise-list-page .table-wrap > table {
width: 100%;
min-width: 1080px;
align-self: flex-start;
border-collapse: collapse;
table-layout: fixed;
}
.enterprise-list-page th,
.enterprise-list-page td,
.enterprise-list-page .table-wrap th,
.enterprise-list-page .table-wrap td {
padding: 13px 12px;
border-bottom: 1px solid #edf2f7;
color: #24324a;
font-size: 14px;
line-height: 1.35;
text-align: center;
vertical-align: middle;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.enterprise-list-page th,
.enterprise-list-page .table-wrap th {
position: sticky;
top: 0;
z-index: 1;
background: #f7fafc;
color: #64748b;
font-size: 13px;
font-weight: 800;
}
.enterprise-list-page tbody tr,
.enterprise-list-page .table-wrap tbody tr {
cursor: pointer;
}
.enterprise-list-page tbody tr:hover,
.enterprise-list-page tbody tr.spotlight,
.enterprise-list-page .table-wrap tbody tr:hover,
.enterprise-list-page .table-wrap tbody tr.spotlight {
background: linear-gradient(90deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08), rgba(var(--theme-primary-rgb, 58, 124, 165), 0.03));
}
.enterprise-list-page tbody tr:last-child td,
.enterprise-list-page .table-wrap tbody tr:last-child td {
border-bottom: 0;
}
.enterprise-list-page .doc-id {
color: var(--theme-primary-active);
font-weight: 800;
}
.enterprise-list-page .doc-kind-tag,
.enterprise-list-page .type-tag,
.enterprise-list-page .status-tag {
display: inline-flex;
align-items: center;
justify-content: center;
white-space: nowrap;
}
.enterprise-list-page .doc-kind-tag {
min-height: 26px;
padding: 0 10px;
border-radius: 7px;
font-size: 12px;
font-weight: 800;
}
.enterprise-list-page .doc-kind-tag.reimbursement {
background: var(--theme-primary-light-9);
color: var(--theme-primary-active);
}
.enterprise-list-page .doc-kind-tag.application {
background: #eff6ff;
color: #2563eb;
}
.enterprise-list-page .type-tag {
min-height: 26px;
padding: 0 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 800;
}
.enterprise-list-page .type-tag.travel,
.enterprise-list-page .type-tag.hotel,
.enterprise-list-page .type-tag.transport {
background: var(--theme-primary-light-9);
color: var(--theme-primary-active);
}
.enterprise-list-page .type-tag.entertainment,
.enterprise-list-page .type-tag.meal {
background: #fff7ed;
color: #ea580c;
}
.enterprise-list-page .type-tag.office {
background: #eff6ff;
color: #2563eb;
}
.enterprise-list-page .type-tag.meeting,
.enterprise-list-page .type-tag.training {
background: #eef2ff;
color: #4f46e5;
}
.enterprise-list-page .type-tag.other {
background: #f8fafc;
color: #475569;
}
.enterprise-list-page .status-tag {
min-height: 24px;
padding: 0 9px;
border: 1px solid transparent;
border-radius: 6px;
font-size: 12px;
font-weight: 750;
}
.enterprise-list-page .status-tag.info {
border-color: #bfdbfe;
background: #eff6ff;
color: #2563eb;
}
.enterprise-list-page .status-tag.success,
.enterprise-list-page .status-tag.archived {
border-color: var(--success-line);
background: var(--success-soft);
color: var(--success-active);
}
.enterprise-list-page .status-tag.warning,
.enterprise-list-page .status-tag.draft {
border-color: #fed7aa;
background: #fff7ed;
color: #f97316;
}
.enterprise-list-page .status-tag.danger {
border-color: #fecaca;
background: #fef2f2;
color: #dc2626;
}
.enterprise-list-page .status-tag.neutral,
.enterprise-list-page .status-tag.muted,
.enterprise-list-page .status-tag.disabled {
border-color: #cbd5e1;
background: #f8fafc;
color: #475569;
}
.enterprise-pagination {
flex: 0 0 auto;
}
.enterprise-list-page .list-foot,
.enterprise-list-page.enterprise-list-page .list-foot {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 16px;
margin-top: 12px;
}
.enterprise-list-page .page-summary,
.enterprise-list-page.enterprise-list-page .page-summary {
color: #64748b;
font-size: 14px;
font-weight: 650;
}
.enterprise-list-page .pager,
.enterprise-list-page.enterprise-list-page .pager {
display: inline-flex;
justify-content: center;
gap: 6px;
padding: 4px;
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #f8fafc;
}
.enterprise-list-page .pager button,
.enterprise-list-page.enterprise-list-page .pager button {
width: 32px;
height: 32px;
border: 0;
border-radius: 9px;
background: transparent;
color: #334155;
font-size: 14px;
font-weight: 800;
transition: background 160ms ease, color 160ms ease, box-shadow 160ms ease;
}
.enterprise-list-page .pager button:hover:not(.active),
.enterprise-list-page.enterprise-list-page .pager button:hover:not(.active) {
background: #fff;
color: var(--theme-primary-active);
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.08);
}
.enterprise-list-page .pager button.active,
.enterprise-list-page.enterprise-list-page .pager button.active {
background: var(--theme-primary-active);
color: #fff;
box-shadow: 0 8px 16px var(--theme-primary-shadow);
}
.enterprise-list-page .pager button:disabled,
.enterprise-list-page.enterprise-list-page .pager button:disabled {
cursor: not-allowed;
opacity: 0.45;
box-shadow: none;
}
.enterprise-list-page .page-ellipsis,
.enterprise-list-page .pager span {
color: #64748b;
font-weight: 800;
}
.enterprise-list-page .page-size-select {
width: 112px;
justify-self: end;
}
.enterprise-detail-page {
min-height: 0;
height: 100%;
display: grid;
grid-template-rows: minmax(0, 1fr) auto;
}
.enterprise-detail-page .detail-scroll {
min-height: 0;
overflow: auto;
}
.enterprise-detail-page .detail-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 0 0;
border-top: 1px solid #e5eaf0;
}
.enterprise-detail-page .detail-action-group {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.enterprise-detail-page .back-action,
.enterprise-detail-page .minor-action,
.enterprise-detail-page .major-action {
height: 38px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 0 14px;
border-radius: 4px;
font-size: 13px;
font-weight: 760;
transition: transform 160ms ease, border-color 160ms ease, color 160ms ease, box-shadow 160ms ease;
}
.enterprise-detail-page .back-action,
.enterprise-detail-page .minor-action {
border: 1px solid #d7e0ea;
background: #fff;
color: #334155;
}
.enterprise-detail-page .minor-action.success-action {
border-color: var(--success-line);
background: var(--success-soft);
color: var(--success-hover);
}
.enterprise-detail-page .minor-action.enable-action {
border-color: rgba(100, 116, 139, 0.26);
color: #64748b;
}
.enterprise-detail-page .minor-action.enable-action.is-on {
border-color: rgba(var(--success-rgb), 0.26);
color: var(--success-hover);
}
.enterprise-detail-page .minor-action.danger-action {
border-color: rgba(220, 38, 38, 0.2);
color: #dc2626;
}
.enterprise-detail-page .major-action {
border: 1px solid var(--theme-primary);
background: var(--theme-primary);
color: #fff;
box-shadow: 0 4px 12px var(--theme-primary-shadow);
}
.enterprise-detail-page .back-action:hover,
.enterprise-detail-page .minor-action:hover,
.enterprise-detail-page .major-action:hover {
transform: translateY(-1px);
}
.enterprise-detail-page .back-action:disabled,
.enterprise-detail-page .minor-action:disabled,
.enterprise-detail-page .major-action:disabled {
opacity: 0.52;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.enterprise-detail-card .card-head {
align-items: flex-start;
}
@media (max-width: 760px) {
.enterprise-list-page {
padding: 16px;
}
.enterprise-list-page .status-tabs {
gap: 18px;
}
.enterprise-list-page .toolbar-actions,
.enterprise-list-page .document-actions {
width: 100%;
margin-left: 0;
}
.enterprise-list-page .filter-set,
.enterprise-list-page .list-search,
.enterprise-list-page .filter-btn,
.enterprise-list-page .create-request-btn,
.enterprise-list-page .create-btn,
.enterprise-list-page .export-btn,
.enterprise-list-page .template-btn,
.enterprise-list-page .page-size-select {
width: 100%;
}
.enterprise-list-page .list-toolbar,
.enterprise-list-page .list-foot {
display: grid;
grid-template-columns: 1fr;
justify-items: stretch;
}
.enterprise-list-page .pager,
.enterprise-list-page .page-size-select {
justify-self: stretch;
}
}

View File

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

View File

@@ -11,9 +11,6 @@
.approval-list {
min-height: 0;
display: grid;
grid-template-rows: auto auto auto minmax(0, 1fr) auto;
padding: 16px 18px;
overflow: hidden;
}

View File

@@ -1337,213 +1337,6 @@
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 {
display: -webkit-box;
overflow: hidden;
@@ -1554,40 +1347,6 @@
-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 {
flex: 1 1 auto;
min-height: 0;
@@ -1853,15 +1612,3 @@
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;
}
.skill-badge {
.skill-badge,
.skill-detail :deep(.skill-badge) {
display: inline-flex;
align-items: center;
min-height: 24px;
@@ -620,11 +621,16 @@ tbody tr.is-disabled:hover {
font-weight: 800;
}
.skill-badge.primary { background: var(--theme-gradient-primary); }
.skill-badge.rose { background: linear-gradient(135deg, #f43f5e, #e11d48); }
.skill-badge.violet { background: linear-gradient(135deg, #8b5cf6, #7c3aed); }
.skill-badge.blue { background: linear-gradient(135deg, #3b82f6, #2563eb); }
.skill-badge.amber { background: linear-gradient(135deg, #f59e0b, #ea580c); }
.skill-badge.primary,
.skill-detail :deep(.skill-badge.primary) { background: var(--theme-gradient-primary); }
.skill-badge.rose,
.skill-detail :deep(.skill-badge.rose) { background: linear-gradient(135deg, #f43f5e, #e11d48); }
.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 {
margin-top: 10px;
@@ -868,41 +874,6 @@ tbody tr.is-disabled:hover {
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 {
display: flex;
gap: 8px;

View File

@@ -5,49 +5,81 @@
.digital-employees-list {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
padding: 16px 18px;
overflow: hidden;
}
.digital-employees-list > .status-tabs {
flex: 0 0 auto;
display: flex;
gap: 28px;
margin-top: 14px;
padding-bottom: 0;
border-bottom: 1px solid #dbe4ee;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
}
.digital-employees-list .table-wrap {
min-height: 0;
.digital-employees-list > .status-tabs::-webkit-scrollbar {
display: none;
}
.digital-employees-list > .status-tabs button {
position: relative;
min-height: 36px;
display: inline-flex;
align-items: center;
gap: 7px;
border: 0;
background: transparent;
color: #64748b;
font-size: 14px;
font-weight: 750;
white-space: nowrap;
}
.digital-employees-list > .status-tabs button.active {
color: var(--theme-primary-active);
}
.digital-employees-list > .status-tabs button.active::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: -1px;
height: 3px;
border-radius: 999px 999px 0 0;
background: var(--theme-primary);
}
.digital-employees-table {
min-width: 1060px;
table-layout: fixed;
}
.digital-employees-table .col-skill { width: 27%; }
.digital-employees-table .col-schedule { width: 16%; }
.digital-employees-table .col-mode { width: 12%; }
/* Default first column left alignment */
.digital-employees-table th:first-child,
.digital-employees-table td:first-child {
text-align: left;
}
.digital-employees-table .col-skill { width: 22%; }
.digital-employees-table .col-skill-type { width: 11%; }
.digital-employees-table .col-status { width: 11%; }
.digital-employees-table .col-enabled { width: 11%; }
.digital-employees-table .col-updated { width: 12%; }
.digital-employees-table td {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.digital-employees-table tbody tr {
cursor: pointer;
}
.digital-employees-table .col-owner { width: 11%; }
.digital-employees-table .col-schedule { width: 16%; }
.digital-employees-table .col-mode { width: 10%; }
.digital-employees-table .col-status { width: 10%; }
.digital-employees-table .col-enabled { width: 10%; }
.digital-employees-table .col-updated { width: 10%; }
.digital-refresh-action i {
font-size: 16px;
}
.skill-type-pill {
border-color: #dbeafe;
background: #eff6ff;
color: #1d4ed8;
}
.digital-employee-detail {
height: 100%;
}
@@ -78,6 +110,7 @@
}
.digital-work-records-section {
flex: 1 1 auto;
min-height: 0;
}

View File

@@ -1,16 +1,16 @@
.logs-view {
display: grid;
grid-template-rows: minmax(0, 1fr);
height: 100%;
min-height: 0;
display: grid;
grid-template-rows: minmax(0, 1fr);
background: transparent;
}
.logs-empty {
min-height: 0;
display: grid;
align-content: center;
justify-items: center;
min-height: 0;
padding: 28px 20px;
text-align: center;
}
@@ -29,9 +29,6 @@
.system-logs-list.panel {
min-height: 0;
display: grid;
grid-template-rows: auto auto minmax(0, 1fr) auto;
padding: 16px 18px;
overflow: hidden;
border: 0;
border-radius: var(--radius);
@@ -39,84 +36,14 @@
box-shadow: 0 1px 3px rgba(0, 0, 0, .10), 0 1px 2px rgba(0, 0, 0, .06);
}
.system-logs-list .document-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.system-logs-list .filter-set,
.system-logs-list .document-actions {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.system-logs-list .list-search {
position: relative;
width: 280px;
}
.system-logs-list .list-search .mdi {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #64748b;
font-size: 15px;
}
.system-logs-list .list-search input {
width: 100%;
height: 38px;
padding: 0 12px 0 36px;
border: 1px solid #d7e0ea;
border-radius: 4px;
background: #fff;
color: #0f172a;
font-size: 13px;
transition: border-color 160ms ease, box-shadow 160ms ease;
}
.system-logs-list .list-search input::placeholder {
color: #8da0b4;
}
.system-logs-list .list-search input:focus {
border-color: var(--theme-primary);
box-shadow: 0 0 0 3px rgba(58, 124, 165, 0.14);
outline: none;
}
.system-logs-list .document-filter {
position: relative;
}
.system-logs-list .filter-btn {
min-width: 120px;
min-height: 38px;
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 9px;
padding: 0 14px;
border: 1px solid #d7e0ea;
border-radius: 4px;
background: #fff;
color: #334155;
font-size: 14px;
font-weight: 750;
white-space: nowrap;
}
.system-logs-list .filter-btn:hover,
.system-logs-list .document-filter.open .filter-btn {
border-color: rgba(58, 124, 165, .32);
color: var(--theme-primary-active);
}
.system-logs-list .status-dropdown-filter,
.system-logs-list .status-filter-trigger,
.system-logs-list .status-filter-menu {
@@ -127,6 +54,26 @@
color: var(--theme-primary);
}
.system-logs-list .refresh-interval-filter,
.system-logs-list .refresh-interval-trigger,
.system-logs-list .refresh-interval-menu {
min-width: 148px;
}
.system-logs-list .refresh-interval-trigger > .mdi:first-child {
color: var(--theme-primary);
}
.system-logs-list .icon-refresh-action {
width: 40px;
min-width: 40px;
padding: 0;
}
.system-logs-list .icon-refresh-action .mdi {
font-size: 18px;
}
.system-logs-list .document-filter-menu {
position: absolute;
top: calc(100% + 8px);
@@ -143,9 +90,9 @@
}
.system-logs-list .document-filter-menu button {
display: block;
width: 100%;
min-height: 36px;
display: block;
padding: 0 12px;
border: 0;
border-radius: 4px;
@@ -159,148 +106,22 @@
.system-logs-list .document-filter-menu button:hover,
.system-logs-list .document-filter-menu button.active {
background: rgba(58, 124, 165, 0.1);
background: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.1);
color: var(--theme-primary-active);
}
.system-logs-list .create-request-btn {
min-height: 40px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 0 18px;
border: 0;
border-radius: 4px;
background: linear-gradient(135deg, var(--theme-primary), var(--theme-primary-active));
color: #fff;
font-size: 14px;
font-weight: 800;
white-space: nowrap;
box-shadow: 0 10px 24px var(--theme-primary-shadow);
transition: transform 160ms ease, box-shadow 160ms ease, filter 160ms ease;
}
.system-logs-list .create-request-btn.secondary {
border: 1px solid #d7e0ea;
background: #fff;
color: #334155;
box-shadow: none;
}
.system-logs-list .create-request-btn:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 14px 28px var(--theme-primary-shadow);
filter: saturate(1.02);
}
.system-logs-list .create-request-btn.secondary:hover:not(:disabled) {
border-color: rgba(58, 124, 165, .32);
color: var(--theme-primary-active);
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.08);
}
.system-logs-list .create-request-btn:disabled {
cursor: not-allowed;
opacity: .72;
transform: none;
}
.system-logs-list .hint {
display: inline-flex;
align-items: center;
gap: 7px;
margin: 10px 0 0;
color: #64748b;
font-size: 13px;
}
.system-logs-list .table-wrap {
min-height: 400px;
margin-top: 10px;
display: flex;
flex-direction: column;
overflow: auto;
border: 1px solid #edf2f7;
border-radius: 10px;
background: linear-gradient(180deg, #fcfefd 0%, #f4f8f6 100%);
}
.system-logs-list .table-wrap.is-empty {
align-items: center;
justify-content: center;
}
.system-logs-list .table-state {
width: 100%;
min-height: 260px;
display: grid;
place-items: center;
gap: 10px;
padding: 28px 20px;
text-align: center;
color: #64748b;
background: linear-gradient(180deg, #fcfffd 0%, #f5f9f7 100%);
}
.system-logs-list .system-log-table {
width: 100%;
min-width: 1260px;
align-self: flex-start;
border-collapse: collapse;
table-layout: fixed;
}
.system-logs-list .col-time { width: 13%; }
.system-logs-list .col-level { width: 8%; }
.system-logs-list .col-event { width: 13%; }
.system-logs-list .col-module { width: 15%; }
.system-logs-list .col-outcome { width: 8%; }
.system-logs-list .col-summary { width: 27%; }
.system-logs-list .col-request { width: 16%; }
.system-logs-list th,
.system-logs-list td {
padding: 13px 12px;
border-bottom: 1px solid #edf2f7;
color: #24324a;
font-size: 14px;
line-height: 1.35;
text-align: center;
vertical-align: middle;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.system-logs-list th {
position: sticky;
top: 0;
z-index: 1;
background: #f7fafc;
color: #64748b;
font-size: 13px;
font-weight: 800;
}
.system-logs-list tbody tr {
cursor: pointer;
}
.system-logs-list tbody tr:hover {
background: linear-gradient(90deg, rgba(58, 124, 165, .08), rgba(58, 124, 165, .03));
}
.system-logs-list tbody tr:last-child td {
border-bottom: 0;
}
.system-logs-list .summary-cell {
text-align: left;
white-space: normal;
@@ -342,7 +163,7 @@
justify-content: center;
padding: 0 9px;
border: 1px solid transparent;
border-radius: 6px;
border-radius: 4px;
font-size: 12px;
font-weight: 750;
white-space: nowrap;
@@ -389,111 +210,12 @@
text-align: center;
}
.system-logs-list .list-foot {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 16px;
margin-top: 12px;
}
.system-logs-list .page-summary {
color: #64748b;
font-size: 14px;
font-weight: 650;
}
.system-logs-list .pager {
display: inline-flex;
justify-content: center;
gap: 6px;
padding: 4px;
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #f8fafc;
}
.system-logs-list .pager button,
.system-logs-list .page-ellipsis {
width: 32px;
height: 32px;
display: inline-grid;
place-items: center;
}
.system-logs-list .pager button {
border: 0;
border-radius: 9px;
background: transparent;
color: #334155;
font-size: 14px;
font-weight: 800;
}
.system-logs-list .page-ellipsis {
color: #94a3b8;
font-size: 13px;
font-weight: 800;
}
.system-logs-list .pager button:hover:not(.active):not(:disabled) {
background: #fff;
color: var(--theme-primary-active);
box-shadow: 0 1px 4px rgba(15, 23, 42, .08);
}
.system-logs-list .pager button.active {
background: var(--theme-primary-active);
color: #fff;
box-shadow: 0 8px 16px var(--theme-primary-shadow);
}
.system-logs-list .pager button:disabled {
color: #cbd5e1;
cursor: not-allowed;
opacity: 1;
}
.system-logs-list .page-size-select {
width: 118px;
justify-self: end;
}
@media (max-width: 1200px) {
.system-logs-list .document-toolbar {
align-items: stretch;
flex-direction: column;
}
.system-logs-list .document-actions {
justify-content: flex-start;
}
.system-logs-list .list-foot {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) {
.logs-view {
padding: 0;
}
.system-logs-list.panel {
padding: 16px;
}
.system-logs-list .filter-set,
.system-logs-list .document-actions,
.system-logs-list .list-search,
.system-logs-list .filter-btn,
.system-logs-list .page-size-select {
width: 100%;
}
.system-logs-list .document-filter-menu {
width: 100%;
}
}

View File

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

View File

@@ -4,146 +4,15 @@
display: grid;
grid-template-rows: minmax(0, 1fr);
gap: 14px;
animation: fadeUp 220ms var(--ease) both;
overflow: hidden;
animation: fadeUp 220ms var(--ease) both;
}
.travel-list {
min-height: 0;
display: grid;
grid-template-rows: auto auto auto minmax(0, 1fr) auto;
padding: 16px 18px;
overflow: hidden;
}
.list-search {
position: relative;
width: 220px;
}
.list-search .mdi {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #64748b;
font-size: 15px;
}
.list-search input {
width: 100%;
height: 38px;
padding: 0 12px 0 36px;
border: 1px solid #d7e0ea;
border-radius: 8px;
background: #fff;
color: #0f172a;
font-size: 13px;
transition: border-color 160ms ease, box-shadow 160ms ease;
}
.list-search input::placeholder {
color: #8da0b4;
}
.list-search input:focus {
border-color: var(--theme-primary);
box-shadow: 0 0 0 3px var(--theme-focus-ring);
outline: none;
}
.status-tabs {
display: flex;
gap: 28px;
margin-top: 14px;
border-bottom: 1px solid #dbe4ee;
}
.status-tabs button {
position: relative;
min-height: 36px;
border: 0;
background: transparent;
color: #64748b;
font-size: 14px;
font-weight: 750;
}
.status-tabs button.active {
color: var(--theme-primary-active);
}
.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);
}
.list-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-top: 14px;
}
.filter-set {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.create-request-btn {
min-height: 40px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 0 18px;
border: 0;
border-radius: 10px;
background: var(--theme-gradient-primary);
color: #fff;
font-size: 14px;
font-weight: 800;
white-space: nowrap;
box-shadow: 0 10px 24px var(--theme-primary-shadow);
transition: transform 160ms ease, box-shadow 160ms ease, filter 160ms ease;
}
.create-request-btn:hover {
transform: translateY(-1px);
box-shadow: 0 14px 28px rgba(var(--theme-primary-rgb), 0.24);
filter: saturate(1.02);
}
.filter-btn {
min-height: 38px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 9px;
padding: 0 14px;
border-radius: 8px;
font-size: 14px;
font-weight: 750;
white-space: nowrap;
border: 1px solid #d7e0ea;
background: #fff;
color: #334155;
}
.filter-btn {
min-width: 120px;
justify-content: space-between;
}
.date-range-filter {
position: relative;
}
@@ -153,25 +22,25 @@
}
.date-range-label {
max-width: 110px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 110px;
}
.date-range-popover {
position: absolute;
top: calc(100% + 8px);
left: 0;
width: 320px;
z-index: 40;
width: 320px;
display: grid;
gap: 14px;
padding: 16px;
border: 1px solid #d7e0ea;
border-radius: 12px;
border-radius: 4px;
background: #fff;
box-shadow: 0 18px 42px rgba(15, 23, 42, .16);
box-shadow: 0 18px 42px rgba(15, 23, 42, 0.16);
}
.date-range-popover header,
@@ -193,7 +62,7 @@
display: grid;
place-items: center;
border: 0;
border-radius: 8px;
border-radius: 4px;
background: transparent;
color: #64748b;
}
@@ -225,7 +94,7 @@
height: 38px;
padding: 0 9px;
border: 1px solid #d7e0ea;
border-radius: 8px;
border-radius: 4px;
color: #0f172a;
font-size: 13px;
}
@@ -240,7 +109,7 @@
.apply-btn {
height: 36px;
padding: 0 14px;
border-radius: 8px;
border-radius: 4px;
font-size: 13px;
font-weight: 750;
}
@@ -262,158 +131,20 @@
background: #cbd5e1;
}
.filter-btn:hover {
border-color: rgba(var(--theme-primary-rgb), .32);
color: var(--theme-primary-active);
}
.hint {
display: inline-flex;
align-items: center;
gap: 7px;
margin-top: 10px;
color: #64748b;
font-size: 13px;
}
.hint .mdi {
color: #64748b;
}
.table-wrap {
min-height: 400px;
margin-top: 10px;
overflow-x: auto;
overflow-y: auto;
border: 1px solid #edf2f7;
border-radius: 10px;
background: linear-gradient(180deg, #fcfefd 0%, var(--theme-primary-light-9) 100%);
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: flex-start;
}
.table-wrap.is-empty {
align-items: center;
justify-content: center;
}
/* 让表格在flex容器中正常显示 */
.table-wrap table {
width: 100%;
align-self: flex-start;
}
.table-state {
width: 100%;
min-height: 260px;
display: grid;
place-items: center;
gap: 10px;
padding: 28px 20px;
text-align: center;
color: #64748b;
background: linear-gradient(180deg, #fcfffd 0%, var(--theme-primary-light-9) 100%);
align-self: center;
}
.table-state .mdi {
font-size: 28px;
color: var(--theme-primary);
}
.table-state strong {
color: #0f172a;
font-size: 15px;
}
.table-state p {
max-width: 420px;
margin: 0;
font-size: 13px;
line-height: 1.6;
}
.table-state.error {
background: linear-gradient(180deg, #fffdfd 0%, #fff6f6 100%);
}
.table-state.error .mdi {
color: #ef4444;
}
.retry-btn {
height: 36px;
padding: 0 14px;
border: 1px solid #f1c5c5;
border-radius: 8px;
background: #fff;
color: #b91c1c;
font-size: 13px;
font-weight: 750;
}
table {
width: 100%;
min-width: 1080px;
border-collapse: collapse;
table-layout: fixed;
}
colgroup col {
width: 10%;
}
th,
td {
padding: 13px 12px;
border-bottom: 1px solid #edf2f7;
color: #24324a;
font-size: 14px;
line-height: 1.35;
text-align: center;
vertical-align: middle;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
th {
position: sticky;
top: 0;
z-index: 1;
background: #f7fafc;
color: #64748b;
font-size: 13px;
font-weight: 800;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
tbody tr {
cursor: pointer;
}
tbody tr:hover {
background: linear-gradient(90deg, rgba(var(--theme-primary-rgb), .08), rgba(var(--theme-primary-rgb), .03));
}
tbody tr:last-child td {
border-bottom: 0;
}
.doc-id {
color: var(--theme-primary-active);
font-weight: 800;
}
.col-id { width: 13%; }
.col-type { width: 12%; }
.col-title { width: 19%; }
.col-occurred,
.col-apply { width: 13%; }
.col-amount { width: 11%; }
.col-node { width: 11%; }
.col-approval { width: 8%; }
.type-tag {
min-height: 26px;
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 26px;
padding: 0 10px;
border-radius: 999px;
font-size: 12px;
@@ -421,7 +152,9 @@ tbody tr:last-child td {
white-space: nowrap;
}
.type-tag.travel {
.type-tag.travel,
.type-tag.hotel,
.type-tag.transport {
background: var(--theme-primary-light-9);
color: var(--theme-primary-active);
}
@@ -431,12 +164,6 @@ tbody tr:last-child td {
color: #ea580c;
}
.type-tag.hotel,
.type-tag.transport {
background: var(--theme-primary-light-9);
color: var(--theme-primary-active);
}
.type-tag.meal {
background: #fef3c7;
color: #b45309;
@@ -464,7 +191,7 @@ tbody tr:last-child td {
align-items: center;
padding: 0 9px;
border: 1px solid transparent;
border-radius: 6px;
border-radius: 4px;
font-size: 12px;
font-weight: 750;
white-space: nowrap;
@@ -506,96 +233,13 @@ tbody tr:last-child td {
color: #475569;
}
.list-foot {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 16px;
margin-top: 12px;
}
.page-summary {
color: #64748b;
font-size: 14px;
font-weight: 650;
}
.pager {
display: inline-flex;
justify-content: center;
gap: 6px;
padding: 4px;
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #f8fafc;
}
.pager button {
width: 32px;
height: 32px;
border: 0;
border-radius: 9px;
background: transparent;
color: #334155;
font-size: 14px;
font-weight: 800;
transition: background 160ms ease, color 160ms ease, box-shadow 160ms ease;
}
.pager button:hover:not(.active) {
background: #fff;
color: var(--theme-primary-active);
box-shadow: 0 1px 4px rgba(15, 23, 42, .08);
}
.pager button.active {
background: var(--theme-primary);
color: #fff;
box-shadow: 0 8px 16px var(--theme-primary-shadow);
}
.page-nav {
color: #64748b;
}
.page-size-select {
width: 112px;
justify-self: end;
}
@media (max-width: 1200px) {
.list-toolbar,
.list-foot {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) {
.travel-list {
padding: 16px;
}
.status-tabs {
gap: 18px;
overflow-x: auto;
}
.filter-btn,
.page-size-select {
.date-range-filter,
.date-range-trigger {
width: 100%;
}
.filter-set {
width: 100%;
}
.list-foot {
display: grid;
justify-items: stretch;
}
.pager,
.page-size-select {
justify-self: stretch;
.date-range-popover {
width: min(320px, calc(100vw - 48px));
}
}

View File

@@ -206,6 +206,16 @@
padding: 20px 24px;
}
.settings-content-fill {
overflow: hidden;
align-content: stretch;
}
.settings-content-fill .settings-logs-view,
.settings-content-fill .settings-log-detail-view {
min-height: 0;
}
.model-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -680,6 +690,19 @@
margin-bottom: 20px;
}
.log-policy-card .card-head {
padding-top: 12px;
padding-bottom: 12px;
}
.log-policy-card > *:not(.card-head) {
margin-top: 18px;
}
.log-policy-card > *:not(.card-head):last-child {
margin-bottom: 20px;
}
/* 大语言模型配置卡片图标与标题排版 */
.card-title-with-icon {
display: flex;

View File

@@ -591,9 +591,9 @@
background: rgba(6, 78, 59, 0.34);
}
.setup-startup-spinner .pi {
font-size: 22px;
}
.setup-startup-spinner .mdi {
font-size: 22px;
}
.setup-startup-spinner strong {
color: #ffffff;
@@ -626,10 +626,10 @@
background: rgba(15, 23, 42, 0.24);
}
.setup-startup-step .pi {
margin-top: 2px;
.setup-startup-step .mdi {
margin-top: 2px;
color: color-mix(in srgb, var(--theme-primary-soft) 46%, transparent);
}
}
.setup-startup-step strong {
color: #f8fffb;
@@ -648,25 +648,25 @@
border-color: rgba(59, 130, 246, 0.34);
}
.setup-startup-step.is-running .pi {
color: #93c5fd;
}
.setup-startup-step.is-running .mdi {
color: #93c5fd;
}
.setup-startup-step.is-success {
border-color: rgba(var(--theme-primary-rgb), 0.32);
}
.setup-startup-step.is-success .pi {
.setup-startup-step.is-success .mdi {
color: var(--theme-primary-light-5);
}
}
.setup-startup-step.is-error {
border-color: rgba(248, 113, 113, 0.36);
}
.setup-startup-step.is-error .pi {
color: #f87171;
}
.setup-startup-step.is-error .mdi {
color: #f87171;
}
.setup-startup-console {
min-height: 0;

View File

@@ -1,191 +1,166 @@
<template>
<article class="skill-list panel">
<nav class="status-tabs" aria-label="能力类型">
<button
v-for="tab in tabs"
:key="tab.id"
type="button"
:class="{ active: activeType === tab.id }"
@click="emit('update:activeType', tab.id)"
>
{{ tab.label }}
</button>
</nav>
<div class="list-toolbar">
<div class="filter-set">
<label class="search-filter">
<i class="mdi mdi-magnify"></i>
<input
:value="keyword"
type="search"
:placeholder="searchPlaceholder"
@input="emit('update:keyword', $event.target.value)"
/>
</label>
<AuditPickerFilter
id="domain"
title="选择业务域"
close-label="关闭业务域选择"
:active-filter-popover="activeFilterPopover"
:label="selectedDomainLabel"
:options="domainOptions"
:selected-value="selectedDomain"
@toggle="emit('toggle-filter-popover', $event)"
@close="emit('close-filter-popover')"
@select="selectFilter('domain', $event)"
<EnterpriseListPage
variant="skill-list"
:tabs="shellTabs"
:active-tab="activeType"
tabs-label="能力类型"
:loading="loading"
:error="errorMessage"
: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')"
>
<template #filters>
<label class="search-filter">
<i class="mdi mdi-magnify"></i>
<input
:value="keyword"
type="search"
:placeholder="searchPlaceholder"
@input="emit('update:keyword', $event.target.value)"
/>
</label>
<AuditPickerFilter
v-if="showOwnerFilter"
id="owner"
title="选择负责人"
close-label="关闭负责人选择"
:active-filter-popover="activeFilterPopover"
:label="selectedOwnerLabel"
:options="ownerOptions"
:selected-value="selectedOwner"
@toggle="emit('toggle-filter-popover', $event)"
@close="emit('close-filter-popover')"
@select="selectFilter('owner', $event)"
/>
<AuditPickerFilter
v-if="showRiskLevelFilter"
id="riskLevel"
title="选择风险等级"
close-label="关闭风险等级选择"
:active-filter-popover="activeFilterPopover"
:label="selectedRiskLevelLabel"
:options="riskLevelOptions"
:selected-value="selectedRiskLevel"
@toggle="emit('toggle-filter-popover', $event)"
@close="emit('close-filter-popover')"
@select="selectFilter('riskLevel', $event)"
/>
<AuditPickerFilter
v-if="showRiskScenarioFilter"
id="riskScenario"
title="选择使用场景"
close-label="关闭使用场景选择"
:active-filter-popover="activeFilterPopover"
:label="selectedRiskScenarioLabel"
:options="riskScenarioOptions"
:selected-value="selectedRiskScenario"
@toggle="emit('toggle-filter-popover', $event)"
@close="emit('close-filter-popover')"
@select="selectFilter('riskScenario', $event)"
/>
<AuditPickerFilter
v-if="showOnlineFilter"
id="online"
title="选择上线状态"
close-label="关闭上线状态选择"
:active-filter-popover="activeFilterPopover"
:label="selectedOnlineStateLabel"
:options="onlineStateOptions"
:selected-value="selectedOnlineState"
@toggle="emit('toggle-filter-popover', $event)"
@close="emit('close-filter-popover')"
@select="selectFilter('online', $event)"
/>
<AuditPickerFilter
v-if="showEnabledFilter"
id="enabled"
title="选择启用状态"
close-label="关闭启用状态选择"
:active-filter-popover="activeFilterPopover"
:label="selectedEnabledStateLabel"
:options="enabledStateOptions"
:selected-value="selectedEnabledState"
@toggle="emit('toggle-filter-popover', $event)"
@close="emit('close-filter-popover')"
@select="selectFilter('enabled', $event)"
/>
<AuditPickerFilter
v-if="showStatusFilter"
id="status"
title="选择状态"
close-label="关闭状态选择"
:active-filter-popover="activeFilterPopover"
:label="selectedStatusLabel"
:options="statusOptions"
:selected-value="selectedStatus"
@toggle="emit('toggle-filter-popover', $event)"
@close="emit('close-filter-popover')"
@select="selectFilter('status', $event)"
/>
</div>
<div class="toolbar-actions">
<button
v-if="activeFilterTokens.length"
class="ghost-filter-btn"
type="button"
@click="emit('reset-filters')"
>
<i class="mdi mdi-filter-remove-outline"></i>
<span>清空筛选</span>
</button>
<button
class="create-btn"
type="button"
:disabled="!canCreateRiskRule"
@click="emit('create-risk-rule')"
>
<i class="mdi mdi-plus"></i>
<span>{{ createButtonLabel }}</span>
</button>
</div>
</div>
<p class="hint"><i class="mdi mdi-information-outline"></i> {{ hintText }}</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"
: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>
<p>{{ errorMessage }}</p>
</div>
<TableEmptyState
v-else-if="!visibleSkills.length"
: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')"
<AuditPickerFilter
id="domain"
title="选择业务域"
close-label="关闭业务域选择"
:active-filter-popover="activeFilterPopover"
:label="selectedDomainLabel"
:options="domainOptions"
:selected-value="selectedDomain"
@toggle="emit('toggle-filter-popover', $event)"
@close="emit('close-filter-popover')"
@select="selectFilter('domain', $event)"
/>
<table v-else>
<AuditPickerFilter
v-if="showOwnerFilter"
id="owner"
title="选择负责人"
close-label="关闭负责人选择"
:active-filter-popover="activeFilterPopover"
:label="selectedOwnerLabel"
:options="ownerOptions"
:selected-value="selectedOwner"
@toggle="emit('toggle-filter-popover', $event)"
@close="emit('close-filter-popover')"
@select="selectFilter('owner', $event)"
/>
<AuditPickerFilter
v-if="showRiskLevelFilter"
id="riskLevel"
title="选择风险等级"
close-label="关闭风险等级选择"
:active-filter-popover="activeFilterPopover"
:label="selectedRiskLevelLabel"
:options="riskLevelOptions"
:selected-value="selectedRiskLevel"
@toggle="emit('toggle-filter-popover', $event)"
@close="emit('close-filter-popover')"
@select="selectFilter('riskLevel', $event)"
/>
<AuditPickerFilter
v-if="showRiskScenarioFilter"
id="riskScenario"
title="选择使用场景"
close-label="关闭使用场景选择"
:active-filter-popover="activeFilterPopover"
:label="selectedRiskScenarioLabel"
:options="riskScenarioOptions"
:selected-value="selectedRiskScenario"
@toggle="emit('toggle-filter-popover', $event)"
@close="emit('close-filter-popover')"
@select="selectFilter('riskScenario', $event)"
/>
<AuditPickerFilter
v-if="showOnlineFilter"
id="online"
title="选择上线状态"
close-label="关闭上线状态选择"
:active-filter-popover="activeFilterPopover"
:label="selectedOnlineStateLabel"
:options="onlineStateOptions"
:selected-value="selectedOnlineState"
@toggle="emit('toggle-filter-popover', $event)"
@close="emit('close-filter-popover')"
@select="selectFilter('online', $event)"
/>
<AuditPickerFilter
v-if="showEnabledFilter"
id="enabled"
title="选择启用状态"
close-label="关闭启用状态选择"
:active-filter-popover="activeFilterPopover"
:label="selectedEnabledStateLabel"
:options="enabledStateOptions"
:selected-value="selectedEnabledState"
@toggle="emit('toggle-filter-popover', $event)"
@close="emit('close-filter-popover')"
@select="selectFilter('enabled', $event)"
/>
<AuditPickerFilter
v-if="showStatusFilter"
id="status"
title="选择状态"
close-label="关闭状态选择"
:active-filter-popover="activeFilterPopover"
:label="selectedStatusLabel"
:options="statusOptions"
:selected-value="selectedStatus"
@toggle="emit('toggle-filter-popover', $event)"
@close="emit('close-filter-popover')"
@select="selectFilter('status', $event)"
/>
</template>
<template #actions>
<button
v-if="activeFilterTokens.length"
class="ghost-filter-btn"
type="button"
@click="emit('reset-filters')"
>
<i class="mdi mdi-filter-remove-outline"></i>
<span>清空筛选</span>
</button>
<button
class="create-btn"
type="button"
:disabled="!canCreateRiskRule"
@click="emit('create-risk-rule')"
>
<i class="mdi mdi-plus"></i>
<span>{{ createButtonLabel }}</span>
</button>
</template>
<template #meta>
<div v-if="activeFilterTokens.length" class="active-filter-strip">
<span v-for="token in activeFilterTokens" :key="token" class="active-filter-chip">
{{ token }}
</span>
</div>
</template>
<template #error>
<i class="mdi mdi-alert-circle-outline"></i>
<p>{{ errorMessage }}</p>
</template>
<template #table>
<table>
<thead>
<tr>
<th>{{ tableColumns.name }}</th>
@@ -245,24 +220,27 @@
</tr>
</tbody>
</table>
</div>
</template>
<footer v-if="!loading && !errorMessage && visibleSkills.length" class="list-foot">
<span class="page-summary">当前展示 {{ visibleSkills.length }} 条资产</span>
</footer>
</article>
<template #footer>
<footer v-if="!loading && !errorMessage && visibleSkills.length" class="list-foot">
<span class="page-summary">当前展示 {{ visibleSkills.length }} 条资产</span>
</footer>
</template>
</EnterpriseListPage>
</template>
<script setup>
import { computed } from 'vue'
import AuditPickerFilter from './AuditPickerFilter.vue'
import TableEmptyState from '../shared/TableEmptyState.vue'
import TableLoadingState from '../shared/TableLoadingState.vue'
import EnterpriseListPage from '../shared/EnterpriseListPage.vue'
defineOptions({
name: 'AuditAssetList'
})
defineProps({
const props = defineProps({
tabs: { type: Array, default: () => [] },
activeType: { type: String, default: '' },
activeTabLabel: { type: String, default: '' },
@@ -325,6 +303,13 @@ const emit = defineEmits([
'open-asset-detail'
])
const shellTabs = computed(() =>
props.tabs.map((tab) => ({
value: tab.id,
label: tab.label
}))
)
function selectFilter(type, value) {
emit('select-filter', type, value)
}

View File

@@ -1,23 +1,5 @@
<template>
<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">
<section class="json-risk-main-stage">
<article class="detail-card panel json-risk-summary-card">
@@ -51,7 +33,7 @@
</p>
</article>
<article class="detail-card panel json-risk-flow-card digital-worker-source-card">
<article class="detail-card panel json-risk-summary-card digital-worker-source-card">
<div class="card-head">
<div>
<h3>Skills Markdown 源文件</h3>

View File

@@ -1,31 +1,5 @@
<template>
<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
v-if="selectedSkill.riskRuleGenerationFailed"
class="json-risk-generation-failure"

View File

@@ -1,21 +1,5 @@
<template>
<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
ref="fileInput"
class="spreadsheet-upload-input"
@@ -126,7 +110,6 @@ defineOptions({
defineProps({
selectedSkill: { type: Object, required: true },
selectedSpreadsheetModeLabel: { type: String, default: '' },
selectedSpreadsheetFileName: { type: String, default: '' },
selectedSpreadsheetChangeRecords: { type: Array, default: () => [] },
spreadsheetOnlyOfficeHostId: { type: String, required: true },

View File

@@ -0,0 +1,257 @@
<template>
<EnterpriseListPage
variant="digital-employee-list-panel"
:panel="false"
: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>
<input
:value="keyword"
type="search"
placeholder="搜索数字员工技能编号执行计划或维护人"
@input="emit('update:keyword', $event.target.value)"
/>
</label>
<AuditPickerFilter
id="status"
title="选择资产状态"
close-label="关闭资产状态选择"
:active-filter-popover="activeFilterPopover"
:label="selectedStatusLabel"
:options="statusOptions"
:selected-value="selectedStatus"
@toggle="emit('toggle-filter-popover', $event)"
@close="emit('close-filter-popover')"
@select="selectFilter('status', $event)"
/>
<AuditPickerFilter
id="enabled"
title="选择启动状态"
close-label="关闭启动状态选择"
:active-filter-popover="activeFilterPopover"
:label="selectedEnabledLabel"
:options="enabledStateOptions"
:selected-value="selectedEnabledState"
@toggle="emit('toggle-filter-popover', $event)"
@close="emit('close-filter-popover')"
@select="selectFilter('enabled', $event)"
/>
<AuditPickerFilter
id="executionMode"
title="选择执行方式"
close-label="关闭执行方式选择"
:active-filter-popover="activeFilterPopover"
:label="selectedExecutionModeLabel"
:options="executionModeOptions"
:selected-value="selectedExecutionMode"
@toggle="emit('toggle-filter-popover', $event)"
@close="emit('close-filter-popover')"
@select="selectFilter('executionMode', $event)"
/>
</template>
<template #actions>
<button
v-if="keyword || activeFilterTokens.length"
class="ghost-filter-btn"
type="button"
@click="emit('reset-filters')"
>
<i class="mdi mdi-filter-remove-outline"></i>
<span>清空筛选</span>
</button>
<button
class="create-btn digital-refresh-action"
type="button"
:disabled="loading"
@click="emit('load-employees')"
>
<i class="mdi mdi-refresh"></i>
<span>{{ loading ? '刷新中...' : '刷新' }}</span>
</button>
</template>
<template #meta>
<div v-if="activeFilterTokens.length" class="active-filter-strip">
<span v-for="token in activeFilterTokens" :key="token" class="active-filter-chip">
{{ token }}
</span>
</div>
</template>
<template #error>
<i class="mdi mdi-alert-circle-outline"></i>
<p>{{ errorMessage }}</p>
</template>
<template #table>
<table class="digital-employees-table">
<colgroup>
<col class="col-skill">
<col class="col-skill-type">
<col class="col-owner">
<col class="col-schedule">
<col class="col-mode">
<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>
<th>最近更新</th>
</tr>
</thead>
<tbody>
<tr
v-for="employee in pagedEmployees"
:key="employee.id"
@click="emit('open-employee-detail', employee)"
>
<td>
<strong class="doc-id">{{ employee.name }}</strong>
</td>
<td><span class="doc-kind-tag application">{{ employee.skillCategory }}</span></td>
<td>{{ employee.owner }}</td>
<td><span class="type-tag other">{{ employee.scope }}</span></td>
<td>{{ employee.executionMode }}</td>
<td>
<span :class="['status-tag', employee.statusTone]">{{ employee.status }}</span>
</td>
<td><span :class="['status-tag', employee.enabledTone]">{{ employee.enabledLabel }}</span></td>
<td>{{ employee.updatedAt || '-' }}</td>
</tr>
</tbody>
</table>
</template>
</EnterpriseListPage>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import AuditPickerFilter from './AuditPickerFilter.vue'
import EnterpriseListPage from '../shared/EnterpriseListPage.vue'
defineOptions({
name: 'DigitalEmployeeListPanel'
})
const props = defineProps({
keyword: { type: String, default: '' },
activeFilterPopover: { type: String, default: '' },
selectedStatus: { type: String, default: '' },
selectedStatusLabel: { type: String, default: '' },
statusOptions: { type: Array, default: () => [] },
selectedEnabledState: { type: String, default: '' },
selectedEnabledLabel: { type: String, default: '' },
enabledStateOptions: { type: Array, default: () => [] },
selectedExecutionMode: { type: String, default: '' },
selectedExecutionModeLabel: { type: String, default: '' },
executionModeOptions: { type: Array, default: () => [] },
activeFilterTokens: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
errorMessage: { type: String, default: '' },
visibleEmployees: { type: Array, default: () => [] }
})
const emit = defineEmits([
'update:keyword',
'toggle-filter-popover',
'close-filter-popover',
'select-filter',
'reset-filters',
'load-employees',
'open-employee-detail'
])
const currentPage = ref(1)
const pageSize = 20
const totalPages = computed(() => Math.max(1, Math.ceil(props.visibleEmployees.length / pageSize)))
const pagedEmployees = computed(() => {
const start = (currentPage.value - 1) * pageSize
return props.visibleEmployees.slice(start, start + pageSize)
})
const pageNumbers = computed(() => {
const total = totalPages.value
if (total <= 7) {
return Array.from({ length: total }, (_, index) => index + 1)
}
const start = Math.max(1, Math.min(currentPage.value - 3, total - 6))
return Array.from({ length: 7 }, (_, index) => start + index)
})
const paginationSummary = computed(() =>
`共 ${props.visibleEmployees.length} 条,目前第 ${currentPage.value} / ${totalPages.value} 页`
)
const emptyState = {
eyebrow: '数字员工',
title: '暂无匹配的数字员工',
description: '当前没有符合搜索条件的后台执行技能。',
icon: 'mdi mdi-account-cog-outline',
tone: 'theme',
artLabel: 'STAFF',
tips: ['数字员工已从规则中心拆出为独立入口', '运行与定时操作统一进入详情后处理']
}
watch(
() => [props.keyword, props.selectedStatus, props.selectedEnabledState, props.selectedExecutionMode],
() => {
currentPage.value = 1
}
)
watch(
() => props.visibleEmployees.length,
() => {
currentPage.value = Math.min(currentPage.value, totalPages.value)
if (currentPage.value < 1) {
currentPage.value = 1
}
},
{ immediate: true }
)
function selectFilter(type, value) {
emit('select-filter', type, value)
}
</script>
<style scoped src="../../assets/styles/views/digital-employees-view.css"></style>
<style scoped>
.digital-employee-list-panel {
flex: 1 1 0;
min-height: 0;
display: flex;
flex-direction: column;
padding: 0;
overflow: hidden;
}
</style>

View File

@@ -1,210 +1,297 @@
<template>
<section class="digital-work-records">
<header class="work-records-head">
<div>
<h3>工作记录</h3>
<p>查看数字员工近期执行记录状态和结果摘要</p>
</div>
<section class="digital-employee-list-panel digital-work-records">
<Transition name="skill-view" mode="out-in">
<!-- 列表视图 -->
<EnterpriseListPage
v-if="!selectedRunDetail"
key="list"
variant="digital-employee-list-panel digital-work-records-list-stage"
:panel="false"
:loading="loading && !runs.length"
:error="errorMessage"
:empty="!visibleRuns.length"
:empty-state="workRecordsEmptyState"
:show-pagination="!loading && !errorMessage && visibleRuns.length > 0"
:current-page="currentPage"
:page-size="pageSize"
:pages="pageNumbers"
:show-page-size="false"
:summary="paginationSummary"
:total="filteredRuns.length"
:total-pages="totalPages"
loading-title="工作记录同步中"
loading-message="正在读取数字员工近期执行记录"
loading-icon="mdi mdi-clipboard-text-clock-outline"
error-title="工作记录加载失败"
@update:current-page="currentPage = $event"
>
<template #filters>
<label class="list-search">
<i class="mdi mdi-magnify"></i>
<input
v-model="listKeyword"
type="search"
placeholder="搜索摘要Run ID..."
/>
</label>
<div class="work-records-kpis" aria-label="工作记录统计">
<article class="work-record-kpi">
<span>日志总数</span>
<strong>{{ totalCount }}</strong>
</article>
<article class="work-record-kpi success">
<span>成功数量</span>
<strong>{{ successCount }}</strong>
</article>
<article class="work-record-kpi danger">
<span>失败数量</span>
<strong>{{ failedCount }}</strong>
</article>
</div>
</header>
<AuditPickerFilter
id="module"
title="选择工作模块"
close-label="关闭选择"
:active-filter-popover="activeFilterPopover"
:label="activeModule === '全部' ? '工作模块' : activeModule"
:options="modulePickerOptions"
:selected-value="activeModule"
@toggle="toggleFilterPopover"
@close="closeFilterPopover"
@select="selectModule"
/>
<div class="work-records-toolbar">
<span>{{ loading ? '正在同步工作记录' : `当前展示 ${visibleRuns.length} 条记录` }}</span>
<button type="button" :disabled="loading" @click="loadWorkRecords(true)">
<i class="mdi mdi-refresh"></i>
<span>{{ loading ? '刷新中...' : '刷新' }}</span>
</button>
</div>
<AuditPickerFilter
id="status"
title="选择执行状态"
close-label="关闭选择"
:active-filter-popover="activeFilterPopover"
:label="activeStatus === '全部' ? '执行状态' : activeStatus"
:options="statusPickerOptions"
:selected-value="activeStatus"
@toggle="toggleFilterPopover"
@close="closeFilterPopover"
@select="selectStatus"
/>
</template>
<div class="table-wrap work-records-table-wrap" :class="{ 'is-empty': !loading && !runs.length }">
<div v-if="loading && !runs.length" class="table-state">
<TableLoadingState
variant="panel"
title="工作记录同步中"
message="正在读取数字员工近期执行记录"
icon="mdi mdi-clipboard-text-clock-outline"
/>
</div>
<div v-else-if="errorMessage" class="table-state error">
<i class="mdi mdi-alert-circle-outline"></i>
<strong>工作记录加载失败</strong>
<p>{{ errorMessage }}</p>
</div>
<table v-else-if="runs.length" class="digital-work-records-table">
<colgroup>
<col class="col-time">
<col class="col-module">
<col class="col-source">
<col class="col-status">
<col class="col-summary">
<col class="col-trace">
</colgroup>
<thead>
<tr>
<th>执行时间</th>
<th>工作模块</th>
<th>触发来源</th>
<th>状态</th>
<th>摘要</th>
<th>Run ID</th>
</tr>
</thead>
<tbody>
<tr
v-for="run in visibleRuns"
:key="run.run_id"
class="work-record-row"
tabindex="0"
role="button"
@click="openWorkRecordDetail(run)"
@keydown.enter.prevent="openWorkRecordDetail(run)"
<template #actions>
<button
v-if="listKeyword || activeFilterTokens.length"
class="ghost-filter-btn"
type="button"
@click="resetFilters"
>
<td>{{ formatWorkRecordDateTime(run.started_at) }}</td>
<td>{{ resolveWorkRecordModuleLabel(run) }}</td>
<td>{{ resolveWorkRecordSourceLabel(run.source) }}</td>
<td>
<div class="work-record-status-stack">
<span class="status-pill" :class="resolveWorkRecordStatusTone(run)">
{{ resolveWorkRecordStatusLabel(run) }}
</span>
<span>{{ resolveWorkRecordStatusNote(run) }}</span>
</div>
</td>
<td class="work-record-summary-cell">
<strong>{{ resolveWorkRecordTitle(run) }}</strong>
<span>{{ formatWorkRecordSummary(run.result_summary) }}</span>
<em>{{ resolveWorkRecordSummaryMeta(run) }}</em>
</td>
<td class="work-record-trace-cell">{{ run.run_id }}</td>
</tr>
</tbody>
</table>
<i class="mdi mdi-filter-remove-outline"></i>
<span>清空筛选</span>
</button>
<AuditPickerFilter
id="refreshInterval"
title="选择刷新时间"
close-label="关闭刷新时间选择"
:active-filter-popover="activeFilterPopover"
:label="`刷新时间 ${refreshIntervalLabel}`"
:options="refreshIntervalPickerOptions"
:selected-value="refreshInterval"
@toggle="toggleFilterPopover"
@close="closeFilterPopover"
@select="changeRefreshInterval"
/>
<button
class="create-btn digital-refresh-action digital-refresh-now"
type="button"
:disabled="loading"
aria-label="立即刷新工作记录"
@click="loadWorkRecords(true)"
>
<i :class="loading ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-refresh'"></i>
</button>
</template>
<div v-else class="work-records-empty">
当前还没有数字员工工作记录
</div>
</div>
<template #meta>
<div v-if="activeFilterTokens.length" class="active-filter-strip">
<span v-for="token in activeFilterTokens" :key="token" class="active-filter-chip">
{{ token }}
</span>
</div>
</template>
<Teleport to="body">
<Transition name="work-record-detail">
<div
v-if="detailOpen"
class="work-record-detail-mask"
role="dialog"
aria-modal="true"
aria-label="工作记录详情"
@click.self="closeWorkRecordDetail"
>
<aside class="work-record-detail-panel">
<header class="work-record-detail-head">
<div>
<span>工作记录详情</span>
<h3>{{ selectedRunDetail ? resolveWorkRecordTitle(selectedRunDetail) : '工作记录' }}</h3>
</div>
<button type="button" aria-label="关闭工作记录详情" @click="closeWorkRecordDetail">
<i class="mdi mdi-close"></i>
</button>
</header>
<template #error>
<i class="mdi mdi-alert-circle-outline"></i>
<p>{{ errorMessage }}</p>
</template>
<div v-if="detailLoading" class="work-record-detail-state">
<TableLoadingState
variant="panel"
title="详情加载中"
message="正在读取该次工作记录的完整执行信息"
icon="mdi mdi-clipboard-text-search-outline"
/>
</div>
<div v-else-if="detailError" class="work-record-detail-state error">
<i class="mdi mdi-alert-circle-outline"></i>
<strong>工作记录详情加载失败</strong>
<p>{{ detailError }}</p>
<button type="button" @click="reloadSelectedDetail">重新加载</button>
</div>
<div v-else-if="selectedRunDetail" class="work-record-detail-body">
<section class="work-record-detail-section">
<div class="work-record-section-head">
<h4>基本信息</h4>
<span class="status-pill" :class="resolveWorkRecordStatusTone(selectedRunDetail)">
{{ resolveWorkRecordStatusLabel(selectedRunDetail) }}
<template #table>
<table class="digital-work-records-table">
<colgroup>
<col class="col-time">
<col class="col-module">
<col class="col-source">
<col class="col-status">
<col class="col-summary">
<col class="col-trace">
</colgroup>
<thead>
<tr>
<th>执行时间</th>
<th>工作模块</th>
<th>触发来源</th>
<th>状态</th>
<th>摘要</th>
<th>Run ID</th>
</tr>
</thead>
<tbody>
<tr
v-for="run in visibleRuns"
:key="run.run_id"
class="work-record-row"
tabindex="0"
role="button"
@click="openWorkRecordDetail(run)"
@keydown.enter.prevent="openWorkRecordDetail(run)"
>
<td>{{ formatWorkRecordDateTime(run.started_at) }}</td>
<td>{{ resolveWorkRecordModuleLabel(run) }}</td>
<td>{{ resolveWorkRecordSourceLabel(run.source) }}</td>
<td>
<span class="status-tag" :class="resolveWorkRecordStatusTone(run)">
{{ resolveWorkRecordStatusLabel(run) }}
</span>
</div>
<div class="work-record-info-grid">
<div><span>Run ID</span><strong>{{ selectedRunDetail.run_id }}</strong></div>
<div><span>工作模块</span><strong>{{ resolveWorkRecordModuleLabel(selectedRunDetail) }}</strong></div>
<div><span>触发来源</span><strong>{{ resolveWorkRecordSourceLabel(selectedRunDetail.source) }}</strong></div>
<div><span>开始时间</span><strong>{{ formatWorkRecordDateTime(selectedRunDetail.started_at) }}</strong></div>
<div><span>结束时间</span><strong>{{ formatWorkRecordDateTime(selectedRunDetail.finished_at) }}</strong></div>
<div><span>状态说明</span><strong>{{ resolveWorkRecordStatusNote(selectedRunDetail) }}</strong></div>
</div>
</section>
</td>
<td class="work-record-summary-cell">
<strong>{{ resolveWorkRecordTitle(run) }}</strong>
<span>{{ formatWorkRecordSummary(run.result_summary) }}</span>
<em>{{ resolveWorkRecordSummaryMeta(run) }}</em>
</td>
<td class="work-record-trace-cell">{{ run.run_id }}</td>
</tr>
</tbody>
</table>
</template>
</EnterpriseListPage>
<section class="work-record-detail-section">
<div class="work-record-section-head">
<h4>执行摘要</h4>
<span>{{ resolveWorkRecordSummaryMeta(selectedRunDetail) }}</span>
</div>
<p class="work-record-result-text">
{{ selectedRunDetail.result_summary || '暂无执行摘要。' }}
</p>
<p v-if="selectedRunDetail.error_message" class="work-record-error-text">
{{ selectedRunDetail.error_message }}
</p>
</section>
<section class="work-record-detail-section">
<div class="work-record-section-head">
<h4>工具调用</h4>
<span>{{ (selectedRunDetail.tool_calls || []).length }} </span>
</div>
<div v-if="(selectedRunDetail.tool_calls || []).length" class="work-record-tool-list">
<article v-for="toolCall in selectedRunDetail.tool_calls" :key="toolCall.id">
<strong>{{ toolCall.tool_name }}</strong>
<span>{{ toolCall.tool_type || 'tool' }} · {{ toolCall.status || 'unknown' }}</span>
</article>
</div>
<div v-else class="work-record-inline-empty">当前暂无工具调用明细</div>
</section>
<section class="work-record-detail-section">
<div class="work-record-section-head">
<h4>执行上下文</h4>
<span>JSON</span>
</div>
<pre class="work-record-code-block">{{ formatJson(selectedRunDetail.route_json) }}</pre>
</section>
</div>
</aside>
<!-- 详情视图 (全屏样式,参考 AuditJsonRiskRuleDetail) -->
<div v-else key="detail" class="json-risk-editor-shell panel work-records-detail-stage">
<div v-if="detailLoading" class="work-record-detail-state panel" style="min-height: 200px; display: grid; place-items: center; border: 0;">
<TableLoadingState
variant="panel"
title="详情加载中"
message="正在读取该次工作记录的完整执行信息"
icon="mdi mdi-clipboard-text-search-outline"
/>
</div>
</Transition>
</Teleport>
<div v-else-if="detailError" class="work-record-detail-state error panel" style="min-height: 200px; display: grid; place-items: center; text-align: center; border: 0; color: #dc2626;">
<i class="mdi mdi-alert-circle-outline" style="font-size: 32px; margin-bottom: 8px;"></i>
<strong>工作记录详情加载失败</strong>
<p>{{ detailError }}</p>
<button class="minor-action" type="button" @click="reloadSelectedDetail" style="margin-top: 12px;">重新加载</button>
</div>
<div v-else class="json-risk-editor-body work-record-detail-shell">
<section class="json-risk-main-stage work-record-detail-body inline-detail">
<!-- 卡片1基本信息 -->
<article class="detail-card panel json-risk-summary-card">
<div class="card-head">
<div>
<h3>基本信息</h3>
<p>此次运行的执行周期、触发来源、标识信息与最终状态。</p>
</div>
</div>
<div class="json-risk-meta-grid">
<div class="json-risk-meta-item">
<span class="json-risk-meta-label">Run ID</span>
<span class="json-risk-meta-value">{{ selectedRunDetail.run_id }}</span>
</div>
<div class="json-risk-meta-item">
<span class="json-risk-meta-label">工作模块</span>
<span class="json-risk-meta-value">{{ resolveWorkRecordModuleLabel(selectedRunDetail) }}</span>
</div>
<div class="json-risk-meta-item">
<span class="json-risk-meta-label">触发来源</span>
<span class="json-risk-meta-value">{{ resolveWorkRecordSourceLabel(selectedRunDetail.source) }}</span>
</div>
<div class="json-risk-meta-item">
<span class="json-risk-meta-label">开始时间</span>
<span class="json-risk-meta-value">{{ formatWorkRecordDateTime(selectedRunDetail.started_at) }}</span>
</div>
<div class="json-risk-meta-item">
<span class="json-risk-meta-label">结束时间</span>
<span class="json-risk-meta-value">{{ formatWorkRecordDateTime(selectedRunDetail.finished_at) }}</span>
</div>
<div class="json-risk-meta-item">
<span class="json-risk-meta-label">状态说明</span>
<span class="json-risk-meta-value">{{ resolveWorkRecordStatusNote(selectedRunDetail) || '-' }}</span>
</div>
</div>
</article>
<!-- 卡片2执行摘要 -->
<article class="detail-card panel json-risk-description-card">
<div class="card-head">
<div>
<h3>执行摘要</h3>
<p>本次数字员工工作流的执行内容与结果摘要。</p>
</div>
<span class="edit-badge">{{ resolveWorkRecordSummaryMeta(selectedRunDetail) }}</span>
</div>
<p class="json-risk-description-text" style="padding: 0 12px 12px; margin: 0;">{{ selectedRunDetail.result_summary || '暂无执行摘要。' }}</p>
<p v-if="selectedRunDetail.error_message" class="work-record-error-text" style="margin: 0 12px 12px; padding: 10px 12px; border: 1px solid #fecaca; border-radius: 4px; background: #fef2f2; color: #b91c1c;">
{{ selectedRunDetail.error_message }}
</p>
</article>
<!-- 卡片3工具调用 -->
<article class="detail-card panel">
<div class="card-head">
<div>
<h3>工具调用</h3>
<p>此任务在执行期间调用的外部系统/工具细节与执行状态。</p>
</div>
<span class="edit-badge">{{ (selectedRunDetail.tool_calls || []).length }} 次调用</span>
</div>
<div v-if="(selectedRunDetail.tool_calls || []).length" class="work-record-tool-list" style="padding: 0 12px 12px; display: grid; gap: 8px;">
<article v-for="toolCall in selectedRunDetail.tool_calls" :key="toolCall.id" style="display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; border: 1px solid #edf2f7; border-radius: 4px; background: #f8fafc;">
<strong style="color: #0f172a; font-size: 13px;">{{ toolCall.tool_name }}</strong>
<span style="color: #64748b; font-size: 12px;">{{ toolCall.tool_type || 'tool' }} · {{ toolCall.status || 'unknown' }}</span>
</article>
</div>
<div v-else class="work-record-inline-empty" style="padding: 0 12px 12px; color: #94a3b8; font-size: 13px;">当前暂无工具调用明细。</div>
</article>
<!-- 卡片4执行上下文 -->
<article class="detail-card panel">
<div class="card-head">
<div>
<h3>执行上下文</h3>
<p>后台调度的运行时配置与状态信息JSON 格式)。</p>
</div>
</div>
<div style="padding: 0 12px 12px;">
<pre class="work-record-code-block" style="max-height: 320px; margin: 0; padding: 12px; overflow: auto; border: 1px solid #e2e8f0; border-radius: 4px; background: #0f172a; color: #e2e8f0; font-size: 12px; line-height: 1.55;">{{ formatJson(selectedRunDetail.route_json) }}</pre>
</div>
</article>
</section>
</div>
<footer class="detail-actions">
<button class="back-action" type="button" @click="closeWorkRecordDetail">
<i class="mdi mdi-arrow-left"></i>
<span>返回工作记录列表</span>
</button>
<div class="detail-action-group">
<button class="minor-action" type="button" :disabled="detailLoading" @click="reloadSelectedDetail">
<i class="mdi mdi-refresh"></i>
<span>{{ detailLoading ? '刷新中...' : '刷新详情' }}</span>
</button>
</div>
</footer>
</div>
</Transition>
</section>
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import AuditPickerFilter from './AuditPickerFilter.vue'
import EnterpriseListPage from '../shared/EnterpriseListPage.vue'
import TableLoadingState from '../shared/TableLoadingState.vue'
import { fetchAgentRunDetail, fetchAgentRuns } from '../../services/agentAssets.js'
import { useToast } from '../../composables/useToast.js'
import { AGENT_RUN_POLL_INTERVAL_MS } from '../../utils/agentRunMonitor.js'
import {
DEFAULT_REFRESH_INTERVAL_MS,
REFRESH_INTERVAL_OPTIONS,
formatRefreshInterval
} from '../../utils/refreshIntervalOptions.js'
import {
formatWorkRecordDateTime,
formatWorkRecordSummary,
@@ -221,7 +308,7 @@ defineOptions({
name: 'DigitalEmployeeWorkRecords'
})
const emit = defineEmits(['summary-change'])
const emit = defineEmits(['summary-change', 'detail-open-change', 'detail-topbar-change'])
const { toast } = useToast()
const runs = ref([])
@@ -232,12 +319,191 @@ const detailLoading = ref(false)
const detailError = ref('')
const selectedRunId = ref('')
const selectedRunDetail = ref(null)
const refreshInterval = ref(DEFAULT_REFRESH_INTERVAL_MS)
watch(detailOpen, (newVal) => {
emit('detail-open-change', newVal)
}, { immediate: true })
let pollTimer = 0
const totalCount = computed(() => runs.value.length)
const successCount = computed(() => runs.value.filter((run) => run.status === 'succeeded').length)
const failedCount = computed(() => runs.value.filter((run) => run.status === 'failed').length)
const visibleRuns = computed(() => runs.value.slice(0, 100))
const workRecordSummary = computed(() =>
runs.value.reduce(
(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 activeModule = ref('全部')
const activeStatus = ref('全部')
const activeFilterPopover = ref('')
const workRecordsEmptyState = {
eyebrow: '工作记录',
title: '暂无匹配的工作记录',
description: '当前没有符合搜索条件的数字员工工作记录。',
icon: 'mdi mdi-clipboard-text-clock-outline',
tone: 'theme',
artLabel: 'RECORDS'
}
const modulePickerOptions = computed(() => {
const set = new Set(runs.value.map((run) => resolveWorkRecordModuleLabel(run)))
return [
{ label: '全部工作模块', value: '全部' },
...Array.from(set).map(m => ({ label: m, value: m }))
]
})
const statusPickerOptions = computed(() => {
const set = new Set(runs.value.map((run) => resolveWorkRecordStatusLabel(run)))
return [
{ label: '全部执行状态', value: '全部' },
...Array.from(set).map(s => ({ label: s, value: s }))
]
})
const refreshIntervalPickerOptions = REFRESH_INTERVAL_OPTIONS.map((option) => ({
label: `每 ${option.label}`,
value: option.value
}))
const refreshIntervalLabel = computed(() => formatRefreshInterval(refreshInterval.value))
const activeFilterTokens = computed(() => {
const tokens = []
if (activeModule.value !== '全部') {
tokens.push(activeModule.value)
}
if (activeStatus.value !== '全部') {
tokens.push(activeStatus.value)
}
return tokens
})
const filteredRuns = computed(() => {
const keyword = listKeyword.value.trim().toLowerCase()
return runs.value.filter((run) => {
const moduleLabel = resolveWorkRecordModuleLabel(run)
const statusLabel = resolveWorkRecordStatusLabel(run)
const matchesKeyword = !keyword || [run.run_id, moduleLabel, statusLabel, run.result_summary].filter(Boolean).join(' ').toLowerCase().includes(keyword)
const matchesModule = activeModule.value === '全部' || moduleLabel === activeModule.value
const matchesStatus = activeStatus.value === '全部' || statusLabel === activeStatus.value
return matchesKeyword && matchesModule && matchesStatus
})
})
const currentPage = ref(1)
const pageSize = 20
const totalPages = computed(() => Math.max(1, Math.ceil(filteredRuns.value.length / pageSize)))
const visibleRuns = computed(() => {
const start = (currentPage.value - 1) * pageSize
return filteredRuns.value.slice(start, start + pageSize)
})
const pageNumbers = computed(() => {
const total = totalPages.value
if (total <= 7) {
return Array.from({ length: total }, (_, index) => index + 1)
}
const start = Math.max(1, Math.min(currentPage.value - 3, total - 6))
return Array.from({ length: 7 }, (_, index) => start + index)
})
const paginationSummary = computed(() =>
`共 ${filteredRuns.value.length} 条,目前第 ${currentPage.value} / ${totalPages.value} 页`
)
watch(
() => [listKeyword.value, activeModule.value, activeStatus.value],
() => {
currentPage.value = 1
}
)
watch(
() => filteredRuns.value.length,
() => {
currentPage.value = Math.min(currentPage.value, totalPages.value)
if (currentPage.value < 1) {
currentPage.value = 1
}
},
{ immediate: true }
)
function toggleFilterPopover(id) {
activeFilterPopover.value = activeFilterPopover.value === id ? '' : id
}
function closeFilterPopover() {
activeFilterPopover.value = ''
}
function selectModule(val) {
activeModule.value = val
closeFilterPopover()
}
function selectStatus(val) {
activeStatus.value = val
closeFilterPopover()
}
function changeRefreshInterval(value) {
refreshInterval.value = Number(value) || DEFAULT_REFRESH_INTERVAL_MS
closeFilterPopover()
startPolling()
}
function resetFilters() {
listKeyword.value = ''
activeModule.value = '全部'
activeStatus.value = '全部'
closeFilterPopover()
}
async function loadWorkRecords(showToast = false) {
loading.value = true
@@ -247,9 +513,9 @@ async function loadWorkRecords(showToast = false) {
const payload = await fetchAgentRuns({ agent: 'hermes', limit: 100 })
runs.value = Array.isArray(payload) ? payload : []
emit('summary-change', {
total: totalCount.value,
succeeded: successCount.value,
failed: failedCount.value
total: workRecordSummary.value.total,
succeeded: workRecordSummary.value.succeeded,
failed: workRecordSummary.value.failed
})
} catch (error) {
errorMessage.value = error?.message || '工作记录加载失败,请稍后重试。'
@@ -302,6 +568,7 @@ function reloadSelectedDetail() {
function closeWorkRecordDetail() {
detailOpen.value = false
selectedRunDetail.value = null
detailError.value = ''
}
@@ -309,7 +576,7 @@ function startPolling() {
stopPolling()
pollTimer = window.setInterval(() => {
loadWorkRecords(false)
}, AGENT_RUN_POLL_INTERVAL_MS)
}, refreshInterval.value)
}
function stopPolling() {
@@ -329,4 +596,106 @@ onBeforeUnmount(() => {
})
</script>
<style scoped src="../../assets/styles/components/digital-employee-work-records.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>
.digital-work-records {
height: 100%;
}
.digital-employee-list-panel,
.digital-work-records-list-stage {
flex: 1 1 0;
min-height: 0;
display: flex;
flex-direction: column;
padding: 0;
overflow: hidden;
}
.digital-work-records-table {
min-width: 1180px;
table-layout: fixed;
}
.digital-work-records-table .col-time { width: 14%; }
.digital-work-records-table .col-module { width: 13%; }
.digital-work-records-table .col-source { width: 10%; }
.digital-work-records-table .col-status { width: 16%; }
.digital-work-records-table .col-summary { width: 31%; }
.digital-work-records-table .col-trace { width: 16%; }
.work-record-row {
outline: none;
}
.work-record-row:focus-visible {
box-shadow: inset 0 0 0 2px rgba(58, 124, 165, 0.28);
}
.work-record-summary-cell {
text-align: left !important;
}
.work-record-summary-cell strong,
.work-record-summary-cell span,
.work-record-summary-cell em {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.work-record-summary-cell strong {
color: #0f172a;
font-size: 13px;
font-weight: 800;
}
.work-record-summary-cell span {
margin-top: 4px;
color: #64748b;
font-size: 13px;
line-height: 1.5;
}
.work-record-summary-cell em {
margin-top: 6px;
color: #94a3b8;
font-size: 12px;
font-style: normal;
}
.work-record-trace-cell {
color: #2563eb !important;
}
.work-records-detail-stage,
.work-record-detail-shell {
flex: 1 1 0;
min-height: 0;
}
.digital-work-records :deep(.toolbar-actions .picker-filter),
.digital-work-records :deep(.toolbar-actions .picker-trigger) {
min-width: 148px;
}
.digital-refresh-now {
width: 40px;
min-width: 40px;
padding: 0;
}
.digital-refresh-now .mdi {
font-size: 18px;
}
.work-record-detail-body.inline-detail {
padding: 0;
overflow: visible;
background: transparent;
}
</style>

View File

@@ -23,7 +23,6 @@ import { GridComponent, TooltipComponent } from 'echarts/components'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { useAnimationProgress } from '../../composables/useAnimationProgress.js'
import { useEcharts } from '../../composables/useEcharts.js'
import { resolveCssColor, useThemeColors } from '../../composables/useThemeColors.js'
@@ -34,7 +33,6 @@ const props = defineProps({
})
const chartElement = shallowRef(null)
const progress = useAnimationProgress([() => props.items], 980)
const themeColors = useThemeColors()
const resolvedItems = computed(() => {
const fallback = themeColors.value.chartPrimary
@@ -54,18 +52,14 @@ const ariaLabel = computed(() =>
const chartMaxValue = computed(() => Math.max(...resolvedItems.value.map((item) => item.value), 1))
const chartAxisMax = computed(() => Math.ceil((chartMaxValue.value * 1.1) / 10000) * 10000)
const animatedItems = computed(() =>
resolvedItems.value.map((item) => ({
...item,
animatedValue: progress.value >= 0.999
? item.value
: Number((item.value * progress.value).toFixed(0))
}))
)
const chartOptions = computed(() => ({
backgroundColor: 'transparent',
animation: false,
animation: true,
animationDuration: 1200,
animationDurationUpdate: 1200,
animationEasing: 'linear',
animationEasingUpdate: 'linear',
grid: {
top: 8,
right: 62,
@@ -116,9 +110,9 @@ const chartOptions = computed(() => ({
series: [
{
type: 'bar',
data: animatedItems.value.map((item) => ({
data: resolvedItems.value.map((item) => ({
name: item.name || item.shortName,
value: item.animatedValue,
value: item.value,
itemStyle: { color: item.resolvedColor }
})),
barWidth: 14,
@@ -133,6 +127,7 @@ const chartOptions = computed(() => ({
label: {
show: true,
position: 'right',
valueAnimation: true,
color: '#64748b',
fontSize: 11,
fontWeight: 800,

View File

@@ -24,7 +24,6 @@ import { TooltipComponent } from 'echarts/components'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { useAnimationProgress } from '../../composables/useAnimationProgress.js'
import { useEcharts } from '../../composables/useEcharts.js'
import { resolveCssColor, useThemeColors } from '../../composables/useThemeColors.js'
@@ -37,7 +36,6 @@ const props = defineProps({
})
const chartElement = shallowRef(null)
const progress = useAnimationProgress([() => props.items], 980)
const themeColors = useThemeColors()
const resolvedItems = computed(() => {
const fallback = themeColors.value.chartPrimary
@@ -55,7 +53,11 @@ const ariaLabel = computed(() =>
const chartOptions = computed(() => ({
backgroundColor: 'transparent',
animation: false,
animation: true,
animationDuration: 1200,
animationDurationUpdate: 1200,
animationEasing: 'linear',
animationEasingUpdate: 'linear',
tooltip: {
trigger: 'item',
confine: true,
@@ -78,7 +80,8 @@ const chartOptions = computed(() => ({
radius: ['60%', '88%'],
center: ['50%', '50%'],
startAngle: 90,
endAngle: 90 - Math.max(progress.value, 0.0001) * 360,
animationType: 'expansion',
animationTypeUpdate: 'expansion',
avoidLabelOverlap: true,
padAngle: 1.5,
minAngle: 2,

View File

@@ -2,10 +2,6 @@
<div class="gauge-chart">
<div class="gauge-body">
<div ref="chartElement" class="gauge-canvas" role="img" :aria-label="ariaLabel"></div>
<div class="gauge-center">
<strong>{{ animatedRatio }}%</strong>
<span>已执行</span>
</div>
</div>
<div class="gauge-summary">
<div>
@@ -30,7 +26,6 @@ import { GaugeChart as EChartsGaugeChart } from 'echarts/charts'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { useAnimationProgress } from '../../composables/useAnimationProgress.js'
import { useEcharts } from '../../composables/useEcharts.js'
import { useThemeColors } from '../../composables/useThemeColors.js'
@@ -44,15 +39,8 @@ const props = defineProps({
})
const chartElement = shallowRef(null)
const progress = useAnimationProgress([() => props.ratio], 980)
const themeColors = useThemeColors()
const normalizedRatio = computed(() => Math.max(0, Math.min(100, Math.round(Number(props.ratio) || 0))))
const animatedRatio = computed(() => {
if (progress.value >= 0.999) {
return normalizedRatio.value
}
return Math.round(normalizedRatio.value * progress.value)
})
const ariaLabel = computed(() => `预算执行率${normalizedRatio.value}%,预算总额${props.total},已执行${props.used},剩余${props.left}`)
const chartOptions = computed(() => {
@@ -60,7 +48,11 @@ const chartOptions = computed(() => {
return {
backgroundColor: 'transparent',
animation: false,
animation: true,
animationDuration: 1200,
animationDurationUpdate: 1200,
animationEasing: 'linear',
animationEasingUpdate: 'linear',
series: [
{
type: 'gauge',
@@ -90,8 +82,23 @@ const chartOptions = computed(() => {
splitLine: { show: false },
axisLabel: { show: false },
anchor: { show: false },
detail: { show: false },
data: [{ value: animatedRatio.value }]
detail: {
show: true,
valueAnimation: true,
offsetCenter: [0, '22%'],
formatter: '{value}%',
color: primary,
fontSize: 24,
fontWeight: 850
},
title: {
show: true,
offsetCenter: [0, '46%'],
color: '#64748b',
fontSize: 11,
fontWeight: 700
},
data: [{ value: normalizedRatio.value, name: '已执行' }]
}
]
}
@@ -120,31 +127,6 @@ useEcharts(chartElement, chartOptions)
height: 100%;
}
.gauge-center {
position: absolute;
bottom: 4px;
left: 50%;
transform: translateX(-50%);
text-align: center;
pointer-events: none;
animation: gaugeCenterIn 620ms ease both;
animation-delay: 360ms;
}
.gauge-center strong {
color: var(--chart-primary);
font-size: 22px;
font-weight: 700;
line-height: 1;
}
.gauge-center span {
display: block;
margin-top: 4px;
color: #64748b;
font-size: 11px;
}
.gauge-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -168,17 +150,6 @@ useEcharts(chartElement, chartOptions)
font-weight: 500;
}
@keyframes gaugeCenterIn {
from {
opacity: 0;
transform: translateX(-50%) translateY(8px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
@keyframes gaugeSummaryIn {
from {
opacity: 0;
@@ -191,7 +162,6 @@ useEcharts(chartElement, chartOptions)
}
@media (prefers-reduced-motion: reduce) {
.gauge-center,
.gauge-summary {
animation: none;
}

View File

@@ -16,7 +16,6 @@ import { GridComponent, TooltipComponent } from 'echarts/components'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { useAnimationProgress } from '../../composables/useAnimationProgress.js'
import { useEcharts } from '../../composables/useEcharts.js'
import { useThemeColors } from '../../composables/useThemeColors.js'
@@ -30,12 +29,6 @@ const props = defineProps({
})
const chartElement = shallowRef(null)
const progress = useAnimationProgress([
() => props.labels,
() => props.applications,
() => props.approved,
() => props.avgHours
], 1100)
const themeColors = useThemeColors()
const chartColors = computed(() => ({
primary: themeColors.value.chartPrimary,
@@ -49,18 +42,13 @@ const ariaLabel = computed(() =>
)).join('')
)
const scaleSeries = (series, decimals = 0) =>
series.map((value) => {
const number = Number(value || 0)
if (progress.value >= 0.999) {
return number
}
return Number((number * progress.value).toFixed(decimals))
})
const chartOptions = computed(() => ({
backgroundColor: 'transparent',
animation: false,
animation: true,
animationDuration: 1200,
animationDurationUpdate: 1200,
animationEasing: 'linear',
animationEasingUpdate: 'linear',
grid: {
top: 18,
right: 38,
@@ -125,7 +113,7 @@ const chartOptions = computed(() => ({
{
name: '申请量(单)',
type: 'bar',
data: scaleSeries(props.applications),
data: props.applications,
barWidth: 12,
barGap: '28%',
itemStyle: {
@@ -136,7 +124,7 @@ const chartOptions = computed(() => ({
{
name: '审批完成量(单)',
type: 'bar',
data: scaleSeries(props.approved),
data: props.approved,
barWidth: 12,
itemStyle: {
color: chartColors.value.blue,
@@ -147,7 +135,7 @@ const chartOptions = computed(() => ({
name: '平均审批时长(小时)',
type: 'line',
yAxisIndex: 1,
data: scaleSeries(props.avgHours, 1),
data: props.avgHours,
smooth: true,
symbol: 'circle',
symbolSize: 7,

View File

@@ -150,6 +150,7 @@ const {
startDocumentInboxPolling,
stopDocumentInboxPolling
} = useDocumentCenterInbox()
let inboxInitialRefreshTimer = null
const sidebarMeta = {
overview: { label: '分析看板' },
@@ -159,7 +160,6 @@ const sidebarMeta = {
policies: { label: '知识管理' },
audit: { label: '规则中心' },
digitalEmployees: { label: '数字员工' },
logs: { label: '系统日志' },
employees: { label: '员工管理' },
settings: { label: '系统设置' }
}
@@ -173,8 +173,27 @@ const decoratedNavItems = computed(() =>
}))
)
function clearInboxInitialRefreshTimer() {
if (inboxInitialRefreshTimer && typeof window !== 'undefined') {
window.clearTimeout(inboxInitialRefreshTimer)
inboxInitialRefreshTimer = null
}
}
function scheduleInboxInitialRefresh() {
if (typeof window === 'undefined') {
return
}
clearInboxInitialRefreshTimer()
inboxInitialRefreshTimer = window.setTimeout(() => {
inboxInitialRefreshTimer = null
void refreshDocumentInbox()
}, props.activeView === 'documents' ? 1200 : 6000)
}
onMounted(() => {
void refreshDocumentInbox()
scheduleInboxInitialRefresh()
startDocumentInboxPolling()
})
@@ -271,7 +290,18 @@ watch(
}
)
watch(
() => props.activeView,
(activeView, previousView) => {
if (activeView === 'documents' && previousView !== 'documents') {
clearInboxInitialRefreshTimer()
void refreshDocumentInbox({ force: true })
}
}
)
onBeforeUnmount(() => {
clearInboxInitialRefreshTimer()
stopDocumentInboxPolling()
closeCollapsedUserMenuNow()
})

View File

@@ -81,21 +81,35 @@
</div>
</div>
</div>
</template>
<template v-else-if="isRequestDetail">
<div class="detail-alert-strip">
<span
v-for="alert in detailAlerts"
:key="alert.label"
class="detail-alert-pill"
:class="alert.tone"
>
<i :class="alert.icon || 'mdi mdi-alert-circle-outline'"></i>
<span>{{ alert.label }}</span>
</span>
</div>
</template>
</template>
<template v-else-if="isRequestDetail">
<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
v-for="alert in detailAlerts"
:key="alert.label"
class="detail-alert-pill"
:class="alert.tone"
>
<i :class="alert.icon || 'mdi mdi-alert-circle-outline'"></i>
<span>{{ alert.label }}</span>
</span>
</div>
</div>
</template>
<template v-else-if="isWorkbench">
<div class="topbar-toolset" aria-label="工作台快捷工具">
@@ -135,19 +149,24 @@
</div>
</template>
<template v-else-if="isLogs">
<div class="kpi-chips">
<div v-for="kpi in logsKpis" :key="kpi.label" class="kpi-chip" :style="{ '--chip-color': kpi.color }">
<span class="chip-value">{{ kpi.value }}<small>{{ kpi.unit }}</small></span>
<span class="chip-label">{{ kpi.label }}</span>
<span class="chip-delta" :class="kpi.trend">{{ kpi.meta }}</span>
</div>
</div>
</template>
<template v-else-if="isApproval">
<div class="kpi-chips">
<div v-for="kpi in approvalKpis" :key="kpi.label" class="kpi-chip" :style="{ '--chip-color': kpi.color }">
<template v-else-if="showDigitalEmployeeWorkRecordKpis">
<div class="kpi-chips">
<div
v-for="kpi in digitalEmployeeWorkRecordKpis"
:key="kpi.label"
class="kpi-chip"
:style="{ '--chip-color': kpi.color }"
>
<span class="chip-value">{{ kpi.value }}<small></small></span>
<span class="chip-label">{{ kpi.label }}</span>
<span class="chip-delta" :class="kpi.trend">{{ kpi.delta }} <i :class="kpi.arrow"></i></span>
</div>
</div>
</template>
<template v-else-if="isApproval">
<div class="kpi-chips">
<div v-for="kpi in approvalKpis" :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>
@@ -200,18 +219,18 @@ const props = defineProps({
type: Object,
default: () => null
},
logsSummary: {
type: Object,
default: () => null
},
requestSummary: {
type: Object,
default: () => null
},
documentSummary: {
requestSummary: {
type: Object,
default: () => null
},
documentSummary: {
type: Object,
default: () => null
},
digitalEmployeeSummary: {
type: Object,
default: () => null
},
companyName: {
type: String,
default: ''
@@ -220,14 +239,14 @@ const props = defineProps({
type: Boolean,
default: false
},
logDetailMode: {
type: Boolean,
default: false
},
detailAlerts: {
type: Array,
default: () => []
},
detailAlerts: {
type: Array,
default: () => []
},
detailKpis: {
type: Array,
default: () => []
},
customRange: {
type: Object,
default: () => ({ start: '2024-07-06', end: '2024-07-12' })
@@ -246,11 +265,11 @@ const emit = defineEmits([
const isChat = computed(() => props.activeView === 'chat')
const isOverview = computed(() => props.activeView === 'overview')
const isWorkbench = computed(() => props.activeView === 'workbench')
const isRequestDetail = computed(() => ['requests', 'documents'].includes(props.activeView) && props.detailMode)
const isDocuments = computed(() => props.activeView === 'documents' && !props.detailMode)
const isRequests = computed(() => props.activeView === 'requests')
const isLogs = computed(() => props.activeView === 'logs' && !props.logDetailMode)
const isApproval = computed(() => props.activeView === 'approval')
const isRequestDetail = computed(() => ['requests', 'documents', 'audit', 'digitalEmployees'].includes(props.activeView) && props.detailMode)
const isDocuments = computed(() => props.activeView === 'documents' && !props.detailMode)
const isRequests = computed(() => props.activeView === 'requests')
const isDigitalEmployees = computed(() => props.activeView === 'digitalEmployees')
const isApproval = computed(() => props.activeView === 'approval')
const isPolicies = computed(() => props.activeView === 'policies')
const isEmployees = computed(() => props.activeView === 'employees')
const displayCompanyName = computed(() => String(props.companyName || '远光软件股份有限公司').trim() || '远光软件股份有限公司')
@@ -290,22 +309,46 @@ const documentKpis = computed(() => {
]
})
const logsKpis = computed(() => {
const summary = props.logsSummary ?? {}
const showDigitalEmployeeWorkRecordKpis = computed(() => {
const summary = props.digitalEmployeeSummary ?? {}
return isDigitalEmployees.value && summary.section === 'workRecords'
})
const digitalEmployeeWorkRecordKpis = computed(() => {
const summary = props.digitalEmployeeSummary ?? {}
const total = Number(summary.total ?? 0)
const errors = Number(summary.errors ?? 0)
const warnings = Number(summary.warnings ?? 0)
const info = Number(summary.info ?? 0)
const succeeded = Number(summary.succeeded ?? 0)
const failed = Number(summary.failed ?? 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)' }
{
label: '日志总数',
value: total,
delta: '当前',
trend: 'up',
arrow: 'mdi mdi-minus',
color: 'var(--theme-primary)'
},
{
label: '成功数量',
value: succeeded,
delta: total ? `占比 ${Math.round((succeeded / total) * 100)}%` : '等待数据',
trend: 'up',
arrow: succeeded > 0 ? 'mdi mdi-arrow-up' : 'mdi mdi-minus',
color: 'var(--success)'
},
{
label: '失败数量',
value: failed,
delta: failed > 0 ? '需要关注' : '暂无失败',
trend: failed > 0 ? 'down' : 'up',
arrow: failed > 0 ? 'mdi mdi-arrow-down' : 'mdi mdi-minus',
color: '#ef4444'
}
]
})
const chatKpis = [
const chatKpis = [
{ label: '今日已问数', value: 86, unit: '次', meta: '较昨日 +18', trend: 'up', color: 'var(--theme-primary)' },
{ label: '已解决问题', value: 72, unit: '条', meta: '解决率 83.7%', trend: 'up', color: '#3b82f6' },
{ label: '知识命中率', value: '92.3', unit: '%', meta: '较昨日 +2.6%', trend: 'up', color: '#8b5cf6' },

View File

@@ -0,0 +1,22 @@
<template>
<article class="detail-card panel enterprise-detail-card" :class="{ wide }">
<div v-if="title || description || $slots.actions" class="card-head">
<div>
<h3 v-if="title">{{ title }}</h3>
<p v-if="description">{{ description }}</p>
</div>
<slot name="actions"></slot>
</div>
<slot></slot>
</article>
</template>
<script setup>
defineProps({
description: { type: String, default: '' },
title: { type: String, default: '' },
wide: { type: Boolean, default: false }
})
</script>

View File

@@ -0,0 +1,72 @@
<template>
<article class="enterprise-detail-page" :class="variant">
<div class="detail-scroll">
<section v-if="error" class="detail-inline-state panel error">
<slot name="error" :error="error">
<i class="mdi mdi-alert-circle-outline"></i>
<div>
<strong>{{ errorTitle }}</strong>
<p>{{ error }}</p>
</div>
</slot>
</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>
<section v-if="$slots.hero" class="detail-hero panel">
<slot name="hero"></slot>
</section>
<slot></slot>
<div v-if="$slots.main || $slots.side" class="detail-grid">
<section v-if="$slots.main" class="detail-main">
<slot name="main"></slot>
</section>
<aside v-if="$slots.side" class="detail-side">
<slot name="side"></slot>
</aside>
</div>
</template>
</div>
<footer v-if="backLabel || $slots.actions" class="detail-actions" :class="actionsClass">
<button v-if="backLabel" class="back-action" type="button" @click="emit('back')">
<i class="mdi mdi-arrow-left"></i>
<span>{{ backLabel }}</span>
</button>
<div v-if="$slots.actions" class="detail-action-group">
<slot name="actions"></slot>
</div>
</footer>
</article>
</template>
<script setup>
import TableLoadingState from './TableLoadingState.vue'
defineProps({
actionsClass: { type: [String, Array, Object], default: '' },
backLabel: { type: String, default: '' },
error: { type: String, default: '' },
errorTitle: { type: String, default: '详情加载失败' },
loading: { type: Boolean, default: false },
loadingIcon: { type: String, default: 'mdi mdi-file-document-outline' },
loadingMessage: { type: String, default: '' },
loadingTitle: { type: String, default: '正在加载详情' },
variant: { type: [String, Array, Object], default: '' }
})
const emit = defineEmits(['back'])
</script>

View File

@@ -0,0 +1,201 @@
<template>
<article class="enterprise-list-page" :class="[variant, { panel }]">
<slot name="before"></slot>
<nav v-if="hasTabs" class="status-tabs" :aria-label="tabsLabel">
<slot
name="tabs"
:tabs="normalizedTabs"
:active-tab="activeTab"
:select-tab="selectTab"
>
<button
v-for="tab in normalizedTabs"
:key="tab.value"
type="button"
:class="{ active: activeTab === tab.value }"
@click="selectTab(tab.value)"
>
<span>{{ tab.label }}</span>
<small v-if="tab.count !== null">{{ tab.count }}</small>
</button>
</slot>
</nav>
<div v-if="hasToolbar" class="list-toolbar">
<slot name="toolbar">
<div v-if="hasFilterRegion" class="filter-set">
<slot name="filters">
<label v-if="searchable" class="list-search">
<i class="mdi mdi-magnify"></i>
<input
:value="keyword"
type="search"
:placeholder="searchPlaceholder"
@input="emit('update:keyword', $event.target.value)"
/>
</label>
</slot>
</div>
<div v-if="$slots.actions" class="toolbar-actions">
<slot name="actions"></slot>
</div>
</slot>
</div>
<p v-if="hasHint" class="hint">
<slot name="hint">
<i v-if="hintIcon" :class="hintIcon"></i>
<span>{{ hint }}</span>
</slot>
</p>
<slot name="meta"></slot>
<div class="table-wrap" :class="{ 'is-empty': empty, 'has-error': Boolean(error) }">
<div v-if="loading" class="table-state">
<TableLoadingState
:title="loadingTitle"
:message="loadingMessage"
:icon="loadingIcon"
floating
/>
</div>
<div v-else-if="error" class="table-state error">
<slot name="error" :error="error">
<i class="mdi mdi-alert-circle-outline"></i>
<strong>{{ errorTitle }}</strong>
<p>{{ error }}</p>
<button v-if="retryLabel" class="state-action retry-btn" type="button" @click="emit('retry')">
{{ retryLabel }}
</button>
</slot>
</div>
<slot v-else-if="empty" name="empty">
<TableEmptyState
v-if="emptyState"
:eyebrow="emptyState.eyebrow"
:title="emptyState.title"
:description="emptyState.desc || emptyState.description"
:icon="emptyState.icon"
:action-label="emptyState.actionLabel"
:action-icon="emptyState.actionIcon"
:tone="emptyState.tone"
:art-label="emptyState.artLabel"
:tips="emptyState.tips || []"
@action="emit('empty-action')"
/>
</slot>
<slot v-else name="table"></slot>
</div>
<slot name="footer">
<EnterprisePagination
v-if="showPagination"
:current-page="currentPage"
:page-size="pageSize"
:page-size-options="pageSizeOptions"
:pages="pages"
:show-page-size="showPageSize"
:summary="summary"
:total="total"
:total-pages="totalPages"
@update:current-page="emit('update:currentPage', $event)"
@update:page-size="emit('update:pageSize', $event)"
@page-size-change="emit('page-size-change', $event)"
/>
</slot>
</article>
</template>
<script setup>
import { computed, useSlots } from 'vue'
import EnterprisePagination from './EnterprisePagination.vue'
import TableEmptyState from './TableEmptyState.vue'
import TableLoadingState from './TableLoadingState.vue'
const props = defineProps({
activeTab: { type: [String, Number], default: '' },
currentPage: { type: Number, default: 1 },
empty: { type: Boolean, default: false },
emptyState: { type: Object, default: null },
error: { type: String, default: '' },
errorTitle: { type: String, default: '列表加载失败' },
hint: { type: String, default: '' },
hintIcon: { type: String, default: 'mdi mdi-information-outline' },
keyword: { type: String, default: '' },
loading: { type: Boolean, default: false },
loadingIcon: { type: String, default: 'mdi mdi-file-document-outline' },
loadingMessage: { type: String, default: '' },
loadingTitle: { type: String, default: '数据同步中' },
pageSize: { type: Number, default: 10 },
pageSizeOptions: {
type: Array,
default: () => []
},
panel: { type: Boolean, default: true },
pages: {
type: Array,
default: () => []
},
retryLabel: { type: String, default: '重新加载' },
searchable: { type: Boolean, default: false },
searchPlaceholder: { type: String, default: '搜索' },
showPageSize: { type: Boolean, default: true },
showPagination: { type: Boolean, default: false },
summary: { type: String, default: '' },
tabs: {
type: Array,
default: () => []
},
tabsLabel: { type: String, default: '列表视图' },
total: { type: Number, default: 0 },
totalPages: { type: Number, default: 1 },
variant: { type: String, default: '' }
})
const emit = defineEmits([
'empty-action',
'page-size-change',
'retry',
'update:activeTab',
'update:currentPage',
'update:keyword',
'update:pageSize'
])
const slots = useSlots()
const normalizedTabs = computed(() =>
props.tabs.map((tab) => {
if (tab && typeof tab === 'object') {
const value = tab.value ?? tab.label ?? ''
return {
value,
label: String(tab.label ?? value),
count: tab.count ?? null
}
}
return {
value: tab,
label: String(tab),
count: null
}
})
)
const hasTabs = computed(() => normalizedTabs.value.length > 0 || Boolean(slots.tabs))
const hasToolbar = computed(() => props.searchable || Boolean(slots.filters || slots.actions || slots.toolbar))
const hasFilterRegion = computed(() => props.searchable || Boolean(slots.filters))
const hasHint = computed(() => Boolean(props.hint || slots.hint))
function selectTab(tab) {
emit('update:activeTab', tab)
}
</script>

View File

@@ -0,0 +1,107 @@
<template>
<footer class="list-foot enterprise-pagination">
<span class="page-summary">{{ summaryText }}</span>
<div class="pager" aria-label="分页">
<button
class="page-nav"
type="button"
:disabled="currentPage <= 1"
aria-label="上一页"
@click="setPage(currentPage - 1)"
>
<i class="mdi mdi-chevron-left"></i>
</button>
<template v-for="item in pageItems" :key="String(item)">
<span v-if="item === 'ellipsis'" class="page-ellipsis" aria-hidden="true">...</span>
<button
v-else
class="page-number"
:class="{ active: currentPage === item }"
type="button"
:aria-current="currentPage === item ? 'page' : undefined"
@click="setPage(item)"
>
{{ item }}
</button>
</template>
<button
class="page-nav"
type="button"
:disabled="currentPage >= totalPages"
aria-label="下一页"
@click="setPage(currentPage + 1)"
>
<i class="mdi mdi-chevron-right"></i>
</button>
</div>
<EnterpriseSelect
v-if="showPageSize"
class="page-size-select"
:model-value="pageSize"
:options="pageSizeOptions"
size="small"
@change="setPageSize"
/>
</footer>
</template>
<script setup>
import { computed } from 'vue'
import EnterpriseSelect from './EnterpriseSelect.vue'
const props = defineProps({
currentPage: { type: Number, default: 1 },
pageSize: { type: Number, default: 10 },
pageSizeOptions: {
type: Array,
default: () => []
},
pages: {
type: Array,
default: () => []
},
showPageSize: { type: Boolean, default: true },
summary: { type: String, default: '' },
total: { type: Number, default: 0 },
totalPages: { type: Number, default: 1 }
})
const emit = defineEmits(['update:currentPage', 'update:pageSize', 'page-size-change'])
const pageItems = computed(() => {
if (props.pages.length) {
return props.pages
}
return Array.from({ length: props.totalPages }, (_, index) => index + 1)
})
const summaryText = computed(() => {
if (props.summary) {
return props.summary
}
return `${props.total} 条,当前第 ${props.currentPage}`
})
function setPage(page) {
if (page === 'ellipsis') {
return
}
const nextPage = Math.min(Math.max(Number(page) || 1, 1), props.totalPages)
if (nextPage !== props.currentPage) {
emit('update:currentPage', nextPage)
}
}
function setPageSize(size) {
emit('update:pageSize', size)
emit('page-size-change', size)
}
</script>

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>
<div
class="table-loading"
:class="[variant, tone]"
role="status"
:aria-label="ariaLabel"
aria-live="polite"
>
<span class="table-loading__spinner" aria-hidden="true">
<i :class="icon"></i>
</span>
<div v-if="hasCopy" class="table-loading__copy">
<strong v-if="title">{{ title }}</strong>
<p v-if="message">{{ message }}</p>
<span v-if="floating" class="table-loading-anchor" aria-hidden="true"></span>
<Teleport to="body" :disabled="!floating">
<div
class="table-loading"
:class="[variant, tone, { 'screen-floating': floating, 'modal-floating': floating && blocking }]"
role="status"
:aria-label="ariaLabel"
aria-live="polite"
>
<FloatingLightBandWindow
:icon="icon"
:message="message"
:motion="motion"
:title="title"
:tone="tone"
:variant="variant"
/>
</div>
</div>
</Teleport>
</template>
<script setup>
import { computed } from 'vue'
import FloatingLightBandWindow from './FloatingLightBandWindow.vue'
const props = defineProps({
variant: {
type: String,
@@ -34,50 +39,75 @@ const props = defineProps({
title: { type: String, default: '' },
message: { type: String, default: '' },
icon: { type: String, default: 'mdi mdi-loading' },
motion: {
type: String,
default: 'loop',
validator: (value) => ['loop', 'entry'].includes(value)
},
floating: { type: Boolean, default: false },
blocking: { type: Boolean, default: false },
showSkeleton: { type: Boolean, default: true },
skeletonRows: { type: Number, default: 5 }
})
const hasCopy = computed(() => Boolean(props.title || props.message))
const ariaLabel = computed(() => [props.title, props.message].filter(Boolean).join(', ') || 'Loading')
</script>
<style scoped>
.table-loading {
--accent: var(--theme-primary);
--accent-deep: var(--theme-primary-active);
width: 100%;
color: #64748b;
}
.table-loading.theme,
.table-loading.sky {
--accent: var(--theme-primary);
--accent-deep: var(--theme-primary-active);
.table-loading-anchor {
display: block;
width: 0;
height: 0;
overflow: hidden;
}
.table-loading.success {
--accent: var(--success);
--accent-deep: var(--success-hover);
.table-loading.screen-floating {
position: fixed;
inset: 0;
z-index: 430;
width: 100vw;
height: 100dvh;
min-height: 100dvh;
display: grid;
place-items: center;
padding: 24px;
background: rgba(248, 250, 252, 0.08);
backdrop-filter: blur(0.5px);
-webkit-backdrop-filter: blur(0.5px);
pointer-events: none;
}
.table-loading.screen-floating.modal-floating {
z-index: 560;
background: rgba(15, 23, 42, 0.18);
backdrop-filter: blur(2px);
-webkit-backdrop-filter: blur(2px);
pointer-events: auto;
}
.table-loading.panel {
min-height: 220px;
display: grid;
place-items: center;
gap: 12px;
padding: 28px 24px;
text-align: center;
}
.table-loading.panel.screen-floating {
min-height: 100dvh;
padding: 24px;
}
.table-loading.detail {
min-height: 180px;
display: flex;
display: grid;
align-items: center;
gap: 14px;
justify-items: center;
padding: 22px 24px;
text-align: left;
}
.table-loading.overlay,
@@ -97,86 +127,9 @@ const ariaLabel = computed(() => [props.title, props.message].filter(Boolean).jo
}
.table-loading.banner {
display: inline-flex;
align-items: center;
gap: 8px;
display: block;
min-height: 0;
padding: 0;
color: #255b7d;
}
.table-loading__spinner {
width: 38px;
height: 38px;
display: inline-grid;
place-items: center;
border: 3px solid #e2e8f0;
border-top-color: var(--accent);
border-radius: 50%;
color: var(--accent-deep);
animation: table-spinner-rotate 0.8s linear infinite !important;
}
.table-loading.detail .table-loading__spinner {
width: 34px;
height: 34px;
}
.table-loading.banner .table-loading__spinner {
width: 18px;
height: 18px;
border-width: 2px;
}
.table-loading__spinner i {
display: none;
}
.table-loading__copy {
display: grid;
gap: 6px;
min-width: 0;
}
.table-loading.panel .table-loading__copy,
.table-loading.overlay .table-loading__copy,
.table-loading.drawer .table-loading__copy {
max-width: 360px;
}
.table-loading.detail .table-loading__copy {
flex: 1;
}
.table-loading.banner .table-loading__copy {
display: inline;
}
.table-loading__copy strong {
color: #0f172a;
font-size: 14px;
font-weight: 850;
line-height: 1.4;
}
.table-loading__copy p {
margin: 0;
font-size: 13px;
line-height: 1.65;
}
.table-loading.banner .table-loading__copy strong {
display: none;
}
.table-loading.banner .table-loading__copy p {
font-size: 12px;
font-weight: 700;
}
@keyframes table-spinner-rotate {
to {
transform: rotate(360deg);
}
}
</style>

View File

@@ -42,11 +42,12 @@ export function useAppShell() {
filters,
ranges,
activeRange,
filteredRequests,
approveRequest,
rejectRequest,
reload: reloadRequests
} = useRequests()
filteredRequests,
approveRequest,
rejectRequest,
ensureLoaded: ensureRequestsLoaded,
reload: reloadRequests
} = useRequests()
const { currentUser } = useSystemState()
const { toast } = useToast()
@@ -80,26 +81,22 @@ export function useAppShell() {
}
return null
})
})
const detailMode = computed(() => route.name === 'app-document-detail')
const logDetailMode = computed(() => route.name === 'app-log-detail')
const detailAlerts = computed(() => (detailMode.value ? buildDetailAlerts(selectedRequest.value) : []))
const documentsListActive = computed(() => activeView.value === 'documents' && !detailMode.value)
const workbenchActive = computed(() => activeView.value === 'workbench')
const requestsNeeded = computed(() => ['documents', 'workbench'].includes(activeView.value))
watch(documentsListActive, (isActive, wasActive) => {
if (isActive && !wasActive) {
void reloadRequests()
}
})
watch(workbenchActive, (isActive, wasActive) => {
if (isActive && !wasActive) {
void reloadRequests()
}
})
watch(
requestsNeeded,
(isNeeded) => {
if (isNeeded) {
void ensureRequestsLoaded()
}
},
{ immediate: true }
)
const workbenchSummary = computed(() =>
buildWorkbenchSummary(requests.value, currentUser.value)
@@ -118,15 +115,8 @@ export function useAppShell() {
}
}
if (logDetailMode.value) {
return {
title: '日志详情',
desc: '查看单条日志的解析结果、上下文信息与原始记录。'
}
}
return currentView.value
})
return currentView.value
})
const requestSummary = computed(() =>
filteredRequests.value.reduce(
@@ -351,10 +341,9 @@ export function useAppShell() {
closeRequestDetail,
closeSmartEntry,
currentView,
customRange,
detailMode,
logDetailMode,
filteredRequests,
customRange,
detailMode,
filteredRequests,
filters,
handleApprove,
handleDraftSaved,

View File

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

View File

@@ -5,6 +5,12 @@ export function useEcharts(chartElement, chartOptions) {
let chartInstance = null
let resizeObserver = null
let renderFrame = 0
let initialFrame = 0
let initialTimer = 0
let initialRendered = false
let initialPending = false
let latestOption = null
const initialAnimationDelay = 180
function renderChart() {
if (!chartElement.value) {
@@ -14,7 +20,37 @@ export function useEcharts(chartElement, chartOptions) {
chartInstance = init(chartElement.value, null, { renderer: 'canvas' })
chartInstance.resize()
}
chartInstance.setOption(chartOptions.value, true)
if (!initialRendered) {
initialRendered = true
initialPending = true
latestOption = chartOptions.value
if (typeof window !== 'undefined') {
initialTimer = window.setTimeout(() => {
initialTimer = 0
initialFrame = window.requestAnimationFrame(() => {
initialFrame = 0
initialPending = false
chartInstance?.setOption(latestOption || chartOptions.value, {
notMerge: true,
lazyUpdate: false
})
latestOption = null
})
}, initialAnimationDelay)
return
}
}
if (initialPending) {
latestOption = chartOptions.value
return
}
chartInstance.setOption(chartOptions.value, {
notMerge: false,
lazyUpdate: false
})
}
function handleResize() {
@@ -63,6 +99,14 @@ export function useEcharts(chartElement, chartOptions) {
window.cancelAnimationFrame(renderFrame)
renderFrame = 0
}
if (initialFrame && typeof window !== 'undefined') {
window.cancelAnimationFrame(initialFrame)
initialFrame = 0
}
if (initialTimer && typeof window !== 'undefined') {
window.clearTimeout(initialTimer)
initialTimer = 0
}
if (chartInstance) {
chartInstance.dispose()
chartInstance = null

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

View File

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

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

View File

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

View File

@@ -3,13 +3,13 @@ import { MotionPlugin } from '@vueuse/motion'
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import 'element-plus/dist/index.css'
import 'primeicons/primeicons.css'
import App from './App.vue'
import router from './router/index.js'
import { installThemeSkin } from './composables/useThemeSkin.js'
import { installSessionNavigation } from './composables/useSystemState.js'
import './assets/styles/element-plus-theme.css'
import './assets/styles/detail-page-corners.css'
import './assets/styles/components/enterprise-page-shell.css'
const app = createApp(App)

View File

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

View File

@@ -3,24 +3,22 @@ export const DEFAULT_APP_VIEW_ORDER = [
'documents',
'budget',
'audit',
'overview',
'policies',
'digitalEmployees',
'logs',
'employees',
'settings'
]
'overview',
'policies',
'digitalEmployees',
'employees',
'settings'
]
const ALWAYS_VISIBLE_VIEWS = new Set(['workbench', 'documents', 'policies'])
const VIEW_ROLE_RULES = {
overview: ['finance', 'executive'],
budget: ['budget_monitor', 'executive'],
audit: ['finance'],
digitalEmployees: ['finance'],
logs: ['manager'],
employees: ['manager'],
settings: ['manager']
}
budget: ['budget_monitor', 'executive'],
audit: ['finance'],
digitalEmployees: ['finance'],
employees: ['manager'],
settings: ['manager']
}
const CLAIM_MANAGER_ROLE_CODES = new Set(['executive'])
const CLAIM_RETURN_ROLE_CODES = new Set(['finance', 'executive', 'manager', 'approver', 'budget_monitor'])
const CLAIM_LEADER_APPROVAL_ROLE_CODES = new Set(['manager', 'approver'])

View File

@@ -1,4 +1,9 @@
const KNOWLEDGE_JOB_TYPES = new Set(['knowledge_index_sync', 'llm_wiki_sync'])
const KNOWLEDGE_JOB_TYPES = new Set([
'knowledge_index_sync',
'llm_wiki_sync',
'llm_wiki_rule_formation',
'finance_policy_knowledge_organize'
])
const STATUS_LABELS = {
running: '运行中',

View File

@@ -1,4 +1,9 @@
const KNOWLEDGE_INGEST_JOB_TYPES = new Set(['knowledge_index_sync', 'llm_wiki_sync'])
const KNOWLEDGE_INGEST_JOB_TYPES = new Set([
'knowledge_index_sync',
'llm_wiki_sync',
'llm_wiki_rule_formation',
'finance_policy_knowledge_organize'
])
const STATUS_META = {
queued: { label: '等待处理', tone: 'muted' },

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

View File

@@ -10,16 +10,13 @@
<div class="mobile-overlay" aria-hidden="true" @click="mobileSidebarOpen = false"></div>
<Transition name="login-entry-veil">
<div v-if="loginEntryAnimating" class="login-entry-veil" aria-live="polite" aria-label="登录成功,正在进入工作台">
<div class="login-entry-card">
<span class="login-entry-mark" aria-hidden="true">
<i class="mdi mdi-shield-check-outline"></i>
</span>
<div class="login-entry-copy">
<strong>登录成功</strong>
<span>正在进入工作台</span>
</div>
<span class="login-entry-progress" aria-hidden="true"></span>
</div>
<FloatingLightBandWindow
icon="mdi mdi-shield-check-outline"
message="正在进入工作台"
motion="entry"
title="登录成功"
variant="entry"
/>
</div>
</Transition>
<div class="app-sidebar">
@@ -47,28 +44,28 @@
'policies-main': activeView === 'policies',
'audit-main': activeView === 'audit',
'audit-detail-main': activeView === 'audit' && auditDetailOpen,
'digital-employees-detail-main': activeView === 'digitalEmployees' && digitalEmployeeDetailOpen,
'digital-employees-main': activeView === 'digitalEmployees',
'logs-main': activeView === 'logs',
'employees-main': activeView === 'employees',
'settings-main': activeView === 'settings'
}"
>
<TopBar
v-if="activeView !== 'settings' && !(activeView === 'audit' && auditDetailOpen)"
:current-view="topBarView"
v-if="activeView !== 'settings'"
:current-view="resolvedTopBarView"
:search="search"
:active-view="activeView"
:ranges="ranges"
:active-range="activeRange"
:employee-summary="employeeSummary"
:knowledge-summary="knowledgeSummary"
:logs-summary="logsSummary"
:request-summary="requestSummary"
:document-summary="documentSummary"
:digital-employee-summary="digitalEmployeeSummary"
:company-name="ENTERPRISE_DISPLAY_NAME"
:detail-mode="detailMode"
:log-detail-mode="logDetailMode"
:detail-alerts="detailAlerts"
:detail-mode="resolvedDetailMode"
:detail-alerts="resolvedDetailAlerts"
:detail-kpis="resolvedDetailKpis"
:custom-range="customRange"
@update:search="search = $event"
@update:active-range="activeRange = $event"
@@ -78,7 +75,7 @@
/>
<FilterBar
v-if="activeView !== 'overview' && activeView !== 'workbench' && activeView !== 'documents' && activeView !== 'budget' && activeView !== 'policies' && activeView !== 'audit' && activeView !== 'digitalEmployees' && activeView !== 'logs' && activeView !== 'employees' && activeView !== 'settings'"
v-if="activeView !== 'overview' && activeView !== 'workbench' && activeView !== 'documents' && activeView !== 'budget' && activeView !== 'policies' && activeView !== 'audit' && activeView !== 'digitalEmployees' && activeView !== 'employees' && activeView !== 'settings'"
:compact="activeView === 'overview'"
:filters="filters"
:ranges="ranges"
@@ -95,7 +92,6 @@
'policies-workarea': activeView === 'policies',
'audit-workarea': activeView === 'audit',
'digital-employees-workarea': activeView === 'digitalEmployees',
'logs-workarea': activeView === 'logs',
'employees-workarea': activeView === 'employees',
'settings-workarea': activeView === 'settings'
}"
@@ -143,10 +139,17 @@
@open-assistant="openSmartEntry"
/>
<PoliciesView v-else-if="activeView === 'policies'" @summary-change="knowledgeSummary = $event" />
<AuditView v-else-if="activeView === 'audit'" @detail-open-change="auditDetailOpen = $event" />
<DigitalEmployeesView v-else-if="activeView === 'digitalEmployees'" />
<LogDetailView v-else-if="activeView === 'logs' && logDetailMode" />
<LogsView v-else-if="activeView === 'logs'" @summary-change="logsSummary = $event" />
<AuditView
v-else-if="activeView === 'audit'"
@detail-open-change="auditDetailOpen = $event"
@detail-topbar-change="detailTopBarPayload = $event"
/>
<DigitalEmployeesView
v-else-if="activeView === 'digitalEmployees'"
@summary-change="digitalEmployeeSummary = $event"
@detail-open-change="digitalEmployeeDetailOpen = $event"
@detail-topbar-change="detailTopBarPayload = $event"
/>
<EmployeeManagementView v-else-if="activeView === 'employees'" @overview-change="employeeSummary = $event" />
<SettingsView v-else />
</section>
@@ -169,35 +172,53 @@
</template>
<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 TopBar from '../components/layout/TopBar.vue'
import FilterBar from '../components/layout/FilterBar.vue'
import OverviewView from './OverviewView.vue'
import PersonalWorkbenchView from './PersonalWorkbenchView.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 FloatingLightBandWindow from '../components/shared/FloatingLightBandWindow.vue'
import TableLoadingState from '../components/shared/TableLoadingState.vue'
import { useAppShell } from '../composables/useAppShell.js'
import { useSystemState } from '../composables/useSystemState.js'
import { filterNavItemsByAccess } from '../utils/accessControl.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 knowledgeSummary = ref(null)
const logsSummary = ref(null)
const documentSummary = ref(null)
const digitalEmployeeSummary = ref(null)
const detailTopBarPayload = ref(null)
const auditDetailOpen = ref(false)
const digitalEmployeeDetailOpen = ref(false)
const loginEntryAnimating = ref(false)
const sidebarCollapsed = ref(false)
const mobileSidebarOpen = ref(false)
@@ -238,7 +259,6 @@ const {
customRange,
detailAlerts,
detailMode,
logDetailMode,
filteredRequests,
filters,
handleApprove,
@@ -274,6 +294,37 @@ const { companyProfile, currentUser, logout } = useSystemState()
const PRODUCT_DISPLAY_NAME = '易财费控'
const ENTERPRISE_DISPLAY_NAME = '远光软件股份有限公司'
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() {
logout('manual')

View File

@@ -10,66 +10,43 @@
@request-deleted="handleDetailDeleted"
/>
<article v-else class="approval-list panel">
<nav class="status-tabs" aria-label="审批状态">
<button
v-for="tab in tabs"
:key="tab"
type="button"
:class="{ active: activeTab === tab }"
@click="activeTab = tab"
>
{{ tab }}
<EnterpriseListPage
v-else
v-model:active-tab="activeTab"
class="approval-list"
:tabs="tabs"
tabs-label="审批状态"
:loading="loading"
:error="error"
:empty="showEmpty"
:empty-state="approvalEmptyState"
loading-title="审批待办同步中"
loading-message="正在加载当前可见的待审批报销单据"
loading-icon="mdi mdi-clipboard-check-outline"
error-title="审批列表加载失败"
:show-pagination="false"
@retry="reload"
@empty-action="handleEmptyAction"
>
<template #filters>
<div class="list-search">
<i class="mdi mdi-magnify"></i>
<input v-model="listKeyword" type="search" placeholder="搜索单号、申请人、部门、报销类型..." />
</div>
<button v-for="filter in filters" :key="filter" type="button" class="filter-btn">
<span>{{ filter }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
</nav>
</template>
<div class="list-toolbar">
<div class="filter-set">
<div class="list-search">
<i class="mdi mdi-magnify"></i>
<input v-model="listKeyword" type="search" placeholder="搜索单号、申请人、部门、报销类型..." />
</div>
<template #hint>
<i class="mdi mdi-information-outline"></i>
点击单据行查看审批详情
</template>
<button v-for="filter in filters" :key="filter" type="button" class="filter-btn">
<span>{{ filter }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
</div>
</div>
<p class="hint"><i class="mdi mdi-information-outline"></i> 点击单据行查看审批详情</p>
<div class="table-wrap" :class="{ 'is-empty': showEmpty }">
<div v-if="loading" class="table-state">
<TableLoadingState
title="审批待办同步中"
message="正在加载当前可见的待审报销单据"
icon="mdi mdi-clipboard-check-outline"
/>
</div>
<div v-else-if="error" class="table-state error">
<i class="mdi mdi-alert-circle-outline"></i>
<strong>审批列表加载失败</strong>
<p>{{ error }}</p>
<button class="state-action" type="button" @click="reload">重新加载</button>
</div>
<TableEmptyState
v-else-if="showEmpty"
:eyebrow="approvalEmptyState.eyebrow"
:title="approvalEmptyState.title"
:description="approvalEmptyState.desc"
:icon="approvalEmptyState.icon"
:action-label="approvalEmptyState.actionLabel"
:action-icon="approvalEmptyState.actionIcon"
:tone="approvalEmptyState.tone"
:art-label="approvalEmptyState.artLabel"
:tips="approvalEmptyState.tips"
@action="handleEmptyAction"
/>
<table v-else>
<template #table>
<table>
<colgroup>
<col><col><col><col><col><col><col><col><col><col><col>
</colgroup>
@@ -113,8 +90,8 @@
</tr>
</tbody>
</table>
</div>
</article>
</template>
</EnterpriseListPage>
</section>
</template>

View File

@@ -9,91 +9,68 @@
@request-deleted="reload"
/>
<article v-else class="approval-list panel">
<nav class="status-tabs" aria-label="归档分类">
<button
v-for="tab in tabs"
:key="tab"
type="button"
:class="{ active: activeTab === tab }"
@click="activeTab = tab"
<EnterpriseListPage
v-else
v-model:active-tab="activeTab"
class="approval-list"
:tabs="tabs"
tabs-label="归档分类"
:loading="loading"
:error="error"
:empty="showEmpty"
:empty-state="archiveEmptyState"
loading-title="归档数据同步中"
loading-message="正在加载公司已归档的报销单据"
loading-icon="mdi mdi-archive-check-outline"
error-title="归档列表加载失败"
:show-pagination="false"
@retry="reload"
@empty-action="handleEmptyAction"
>
<template #filters>
<div class="list-search">
<i class="mdi mdi-magnify"></i>
<input v-model="listKeyword" type="search" placeholder="搜索单号、申请人、部门、归档类型..." />
</div>
<el-dropdown
v-for="menu in filterMenus"
:key="menu.key"
class="archive-filter-control"
trigger="click"
placement="bottom-start"
popper-class="archive-filter-menu"
@command="selectFilterValue(menu.key, $event)"
>
{{ tab }}
</button>
</nav>
<button class="filter-btn archive-filter-trigger" type="button">
<span>{{ menu.label }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="option in menu.options"
:key="`${menu.key}-${option.value}`"
:command="option.value"
:class="{ 'is-active': menu.activeValue === option.value }"
class="archive-filter-option"
:aria-current="menu.activeValue === option.value ? 'true' : undefined"
>
<i v-if="menu.activeValue === option.value" class="mdi mdi-check"></i>
<span>{{ option.label }}</span>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
<div class="list-toolbar">
<div class="filter-set">
<div class="list-search">
<i class="mdi mdi-magnify"></i>
<input v-model="listKeyword" type="search" placeholder="搜索单号、申请人、部门、归档类型..." />
</div>
<template #hint>
<i class="mdi mdi-information-outline"></i>
归档中心保存公司已付款或已归档的报销数据点击单据行查看详情
</template>
<el-dropdown
v-for="menu in filterMenus"
:key="menu.key"
class="archive-filter-control"
trigger="click"
placement="bottom-start"
popper-class="archive-filter-menu"
@command="selectFilterValue(menu.key, $event)"
>
<button class="filter-btn archive-filter-trigger" type="button">
<span>{{ menu.label }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="option in menu.options"
:key="`${menu.key}-${option.value}`"
:command="option.value"
:class="{ 'is-active': menu.activeValue === option.value }"
class="archive-filter-option"
:aria-current="menu.activeValue === option.value ? 'true' : undefined"
>
<i v-if="menu.activeValue === option.value" class="mdi mdi-check"></i>
<span>{{ option.label }}</span>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
<p class="hint"><i class="mdi mdi-information-outline"></i> 归档中心保存公司已付款或已归档的报销数据点击单据行查看详情</p>
<div class="table-wrap" :class="{ 'is-empty': showEmpty }">
<div v-if="loading" class="table-state">
<TableLoadingState
title="归档数据同步中"
message="正在加载公司已归档的报销单据"
icon="mdi mdi-archive-check-outline"
/>
</div>
<div v-else-if="error" class="table-state error">
<i class="mdi mdi-alert-circle-outline"></i>
<strong>归档列表加载失败</strong>
<p>{{ error }}</p>
<button class="state-action" type="button" @click="reload">重新加载</button>
</div>
<TableEmptyState
v-else-if="showEmpty"
:eyebrow="archiveEmptyState.eyebrow"
:title="archiveEmptyState.title"
:description="archiveEmptyState.desc"
:icon="archiveEmptyState.icon"
:action-label="archiveEmptyState.actionLabel"
:action-icon="archiveEmptyState.actionIcon"
:tone="archiveEmptyState.tone"
:art-label="archiveEmptyState.artLabel"
:tips="archiveEmptyState.tips"
@action="handleEmptyAction"
/>
<table v-else>
<template #table>
<table>
<colgroup>
<col><col><col><col><col><col><col><col><col>
</colgroup>
@@ -129,8 +106,8 @@
</tr>
</tbody>
</table>
</div>
</article>
</template>
</EnterpriseListPage>
</section>
</template>

View File

@@ -11,67 +11,6 @@
}"
>
<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">
<i class="mdi mdi-alert-circle-outline"></i>
<div>
@@ -94,7 +33,6 @@
v-else-if="selectedSkill.usesSpreadsheetRule"
ref="spreadsheetUploadInput"
:selected-skill="selectedSkill"
:selected-spreadsheet-mode-label="selectedSpreadsheetModeLabel"
:selected-spreadsheet-file-name="selectedSpreadsheetFileName"
:selected-spreadsheet-change-records="selectedSpreadsheetChangeRecords"
:spreadsheet-only-office-host-id="spreadsheetOnlyOfficeHostId"

View File

@@ -1,5 +1,13 @@
<template>
<section class="budget-center-page">
<TableLoadingState
v-if="budgetLoading"
title="预算数据同步中"
message="正在加载预算额度、使用情况与预警明细"
icon="mdi mdi-chart-donut"
floating
blocking
/>
<section class="budget-summary-grid" aria-label="预算概览">
<article

View File

@@ -1,47 +1,29 @@
<template>
<section class="digital-employees-view skill-center">
<Transition name="skill-view" mode="out-in">
<article
<EnterpriseDetailPage
v-if="selectedEmployee"
key="detail"
class="skill-detail digital-employee-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>
<AuditDigitalEmployeeDetail
:selected-skill="selectedEmployee"
:can-edit="canEditDigitalEmployeeSource"
:detail-busy="detailBusy"
:action-state="actionState"
@save-source="saveDigitalEmployeeSource"
/>
<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
v-else
:selected-skill="selectedEmployee"
:can-edit="canEditDigitalEmployeeSource"
:detail-busy="detailBusy"
:action-state="actionState"
@save-source="saveDigitalEmployeeSource"
/>
</div>
<footer class="detail-actions digital-employee-detail-actions">
<button class="back-action" type="button" @click="closeEmployeeDetail">
<i class="mdi mdi-arrow-left"></i>
<span>返回数字员工列表</span>
</button>
<div class="detail-action-group">
<template #actions>
<button
class="minor-action enable-action"
:class="{ 'is-on': selectedEmployee.statusValue === 'active' }"
@@ -70,18 +52,22 @@
<i class="mdi mdi-play-circle-outline"></i>
<span>{{ actionBusy(selectedEmployee.id, 'run-digital-now') ? '运行中...' : '立即运行' }}</span>
</button>
</div>
</footer>
</article>
</template>
</EnterpriseDetailPage>
<article v-else key="list" class="skill-list panel digital-employees-list">
<nav class="status-tabs" aria-label="数字员工页签">
<article
v-else
key="list"
class="skill-list digital-employees-list"
:class="{ 'panel': !workRecordDetailOpen }"
>
<nav v-if="!workRecordDetailOpen" class="status-tabs" aria-label="数字员工页签">
<button
type="button"
:class="{ active: activeSection === 'skills' }"
@click="activeSection = 'skills'"
>
数字员工
员工能力
</button>
<button
type="button"
@@ -93,173 +79,37 @@
</nav>
<template v-if="activeSection === 'skills'">
<div class="list-toolbar">
<div class="filter-set">
<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"
:label="selectedStatusLabel"
:options="statusOptions"
:selected-value="selectedStatus"
@toggle="toggleFilterPopover"
@close="closeFilterPopover"
@select="selectFilter('status', $event)"
/>
<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="['数字员工已从规则中心拆出为独立入口', '运行与定时操作统一进入详情后处理']"
<DigitalEmployeeListPanel
v-model:keyword="keyword"
:active-filter-popover="activeFilterPopover"
:selected-status="selectedStatus"
:selected-status-label="selectedStatusLabel"
:status-options="statusOptions"
:selected-enabled-state="selectedEnabledState"
:selected-enabled-label="selectedEnabledLabel"
: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"
/>
<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>
<DigitalEmployeeWorkRecords
v-else
class="digital-work-records-section"
@summary-change="emit('summary-change', $event)"
@detail-open-change="workRecordDetailOpen = $event"
@detail-topbar-change="workRecordDetailTopBar = $event"
/>
</article>
</Transition>
@@ -279,14 +129,13 @@
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, ref, watch } from '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 DigitalEmployeeWorkRecords from '../components/audit/DigitalEmployeeWorkRecords.vue'
import TableEmptyState from '../components/shared/TableEmptyState.vue'
import TableLoadingState from '../components/shared/TableLoadingState.vue'
import EnterpriseDetailPage from '../components/shared/EnterpriseDetailPage.vue'
import { useSystemState } from '../composables/useSystemState.js'
import { useToast } from '../composables/useToast.js'
import {
@@ -300,8 +149,6 @@ import { runOrchestrator } from '../services/orchestrator.js'
import { isPlatformAdminUser } from '../utils/accessControl.js'
import {
DIGITAL_EMPLOYEE_SKILL_CATEGORY_OPTIONS,
buildDigitalEmployeeDetailMeta,
buildDigitalEmployeeListMeta,
formatDigitalEmployeeCron,
isDigitalEmployeeAsset
} from './scripts/auditViewDigitalEmployeeModel.js'
@@ -312,21 +159,55 @@ import {
resolveDigitalEmployeeScheduleValue
} from './scripts/digitalEmployeeScheduleModel.js'
import { incrementVersion } from './scripts/auditViewRuntimeModel.js'
import {
DIGITAL_EMPLOYEE_EXECUTION_MODE_OPTIONS,
buildDigitalEmployeeDetailTopBar,
buildEmployeeDetail,
buildEmployeeListItem,
buildEmployeePlaceholder,
filterDigitalEmployees,
sortEmployees
} from './scripts/digitalEmployeesViewModel.js'
import {
ENABLED_STATE_OPTIONS,
formatDateTime,
normalizeText,
resolveStatusMeta,
STATUS_OPTIONS
} from './scripts/auditViewModel.js'
const { currentUser } = useSystemState()
const { toast } = useToast()
const emit = defineEmits(['summary-change', 'detail-open-change', 'detail-topbar-change'])
const employees = ref([])
const selectedEmployee = ref(null)
const selectedEmployeeId = ref('')
const activeSection = ref('skills')
const workRecordDetailOpen = ref(false)
const workRecordDetailTopBar = ref(null)
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) => {
emit('detail-open-change', newVal)
}, { immediate: true })
watch(
digitalEmployeeDetailTopBar,
(value) => {
emit('detail-topbar-change', value)
},
{ immediate: true, deep: true }
)
const keyword = ref('')
const selectedStatus = ref('')
const selectedEnabledState = ref('')
@@ -351,11 +232,7 @@ const scheduleEditorBusy = computed(() => actionState.value === 'save-digital-sc
const statusOptions = STATUS_OPTIONS
const enabledStateOptions = ENABLED_STATE_OPTIONS
const executionModeOptions = [
{ value: '', label: '全部执行方式' },
{ value: 'timed', label: '定时执行' },
{ value: 'manual', label: '手动触发' }
]
const executionModeOptions = DIGITAL_EMPLOYEE_EXECUTION_MODE_OPTIONS
const selectedStatusLabel = computed(() =>
statusOptions.find((item) => item.value === selectedStatus.value)?.label || '全部状态'
@@ -389,32 +266,11 @@ const schedulePreviewLabel = computed(() => {
})
const visibleEmployees = computed(() => {
const searchText = normalizeText(keyword.value).toLowerCase()
return employees.value.filter((item) => {
const matchesKeyword = searchText
? [
item.name,
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
return filterDigitalEmployees(employees.value, {
keyword: keyword.value,
selectedEnabledState: selectedEnabledState.value,
selectedExecutionMode: selectedExecutionMode.value,
selectedStatus: selectedStatus.value
})
})
@@ -452,91 +308,6 @@ function resolveActor() {
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() {
loading.value = true
errorMessage.value = ''

View File

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

View File

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

View File

@@ -15,7 +15,7 @@
</article>
<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-tags">
<span class="level-pill" :class="resolveLevelTone(resolveRunLevel(hermesRun))">
@@ -43,12 +43,7 @@
{{ hermesRunAlert.message }}
</article>
<KnowledgeIngestRunPanel
v-if="isKnowledgeIngestRunDetail"
:run="hermesRun"
/>
<div v-if="!isKnowledgeIngestRunDetail" class="detail-grid">
<div class="detail-grid">
<article class="panel detail-card wide">
<div class="card-head">
<h3>基本信息</h3>
@@ -68,7 +63,7 @@
</div>
</article>
<article v-if="!isKnowledgeIngestRunDetail" class="panel detail-card">
<article class="panel detail-card">
<div class="card-head">
<h3>处理链路</h3>
<p>按工具调用顺序查看执行链</p>
@@ -97,7 +92,7 @@
</div>
</article>
<article v-if="selectedToolCall && !isKnowledgeIngestRunDetail" class="panel detail-card">
<article v-if="selectedToolCall" class="panel detail-card">
<div class="card-head">
<h3>当前 ToolCall</h3>
<p>查看当前工具调用的请求与返回</p>
@@ -187,10 +182,10 @@
</div>
<footer class="detail-actions">
<button class="back-action" type="button" @click="backToLogs">
<i class="mdi mdi-arrow-left"></i>
<span>返回日志列表</span>
</button>
<button class="back-action" type="button" @click="backToLogs">
<i class="mdi mdi-arrow-left"></i>
<span>返回系统日志</span>
</button>
</footer>
</section>
</template>
@@ -199,7 +194,6 @@
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import KnowledgeIngestRunPanel from '../components/logs/KnowledgeIngestRunPanel.vue'
import { fetchAgentRunDetail } from '../services/agentAssets.js'
import { fetchSystemLogEntry } from '../services/systemLogs.js'
import {
@@ -210,7 +204,6 @@ import {
resolveAgentRunHeartbeat,
resolveAgentRunStatus
} from '../utils/agentRunMonitor.js'
import { isKnowledgeIngestRun } from '../utils/knowledgeIngestLogModel.js'
const SOURCE_LABELS = {
schedule: '定时任务',
@@ -230,7 +223,6 @@ let pollTimer = 0
const isHermes = computed(() => route.params.logKind === 'hermes')
const isSystem = computed(() => route.params.logKind === 'system')
const isKnowledgeIngestRunDetail = computed(() => isKnowledgeIngestRun(hermesRun.value))
const selectedToolCall = computed(() =>
(hermesRun.value?.tool_calls || []).find((item) => item.id === selectedToolCallId.value) || null
)
@@ -443,9 +435,9 @@ async function loadDetail(options = {}) {
}
}
function backToLogs() {
router.push({ name: 'app-logs' })
}
function backToLogs() {
router.push({ name: 'app-settings', query: { section: 'systemLogs' } })
}
watch(
() => [route.params.logKind, route.params.logId],

View File

@@ -6,112 +6,154 @@
</div>
<template v-else>
<article class="system-logs-list panel">
<div class="document-toolbar">
<div class="filter-set">
<div class="list-search">
<i class="mdi mdi-magnify"></i>
<input
v-model.trim="systemSearchKeyword"
type="search"
placeholder="搜索摘要、模块、请求路径或 Request ID"
/>
</div>
<div class="document-filter status-dropdown-filter" :class="{ open: openFilterKey === 'level' }">
<button
class="filter-btn status-filter-trigger"
type="button"
:aria-expanded="openFilterKey === 'level'"
@click="toggleFilter('level')"
>
<i class="mdi mdi-filter-variant"></i>
<span>{{ systemLevelFilterLabel }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
<div
v-if="openFilterKey === 'level'"
class="document-filter-menu status-filter-menu"
role="listbox"
aria-label="日志级别"
>
<button
v-for="option in systemLevelFilterOptions"
:key="option.value"
type="button"
role="option"
:aria-selected="systemLevelFilter === option.value"
:class="{ active: systemLevelFilter === option.value }"
@click="selectLevelFilter(option.value)"
>
{{ option.label }}
</button>
</div>
</div>
<div class="document-filter" :class="{ open: openFilterKey === 'eventType' }">
<button
class="filter-btn"
type="button"
:aria-expanded="openFilterKey === 'eventType'"
@click="toggleFilter('eventType')"
>
<span>{{ systemEventTypeFilterLabel }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
<div
v-if="openFilterKey === 'eventType'"
class="document-filter-menu"
role="listbox"
aria-label="事件类型"
>
<button
v-for="option in systemEventTypeFilterOptions"
:key="option.value"
type="button"
role="option"
:aria-selected="systemEventTypeFilter === option.value"
:class="{ active: systemEventTypeFilter === option.value }"
@click="selectEventTypeFilter(option.value)"
>
{{ option.label }}
</button>
</div>
</div>
</div>
<div class="document-actions">
<button v-if="hasActiveFilters" class="create-request-btn secondary" type="button" @click="resetFilters">
<i class="mdi mdi-filter-remove-outline"></i>
<span>清空筛选</span>
</button>
<button
type="button"
class="create-request-btn"
:disabled="systemLogLoading"
@click="loadSystemLogs(true)"
>
<i class="mdi mdi-refresh"></i>
<span>{{ systemLogLoading ? '刷新中...' : '刷新日志' }}</span>
</button>
</div>
</div>
<p class="hint">
<i class="mdi mdi-information-outline"></i>
点击任意行可查看系统日志详情
</p>
<div class="table-wrap" :class="{ 'is-empty': !systemLogLoading && !filteredSystemLogEntries.length }">
<div v-if="systemLogLoading && !visibleSystemLogEntries.length" class="table-state">
<TableLoadingState
title="系统日志同步中"
message="正在加载系统运行日志记录"
icon="mdi mdi-text-box-search-outline"
<EnterpriseListPage
v-model:current-page="currentPage"
v-model:page-size="pageSize"
class="system-logs-list"
:loading="systemLogLoading && !visibleSystemLogEntries.length"
:empty="!systemLogLoading && !visibleSystemLogEntries.length"
:total="totalCount"
:total-pages="totalPages"
:pages="visiblePageItems"
:page-size-options="pageSizeOptions"
:summary="`共 ${totalCount} 条系统日志,当前第 ${currentPage} 页`"
:show-pagination="!systemLogLoading && filteredSystemLogEntries.length > 0"
loading-title="系统日志同步中"
loading-message="正在加载系统运行日志记录"
loading-icon="mdi mdi-text-box-search-outline"
@page-size-change="changePageSize"
>
<template #filters>
<div class="list-search">
<i class="mdi mdi-magnify"></i>
<input
v-model.trim="systemSearchKeyword"
type="search"
placeholder="搜索摘要模块请求路径或 Request ID"
/>
</div>
<table v-else-if="visibleSystemLogEntries.length" class="system-log-table">
<div class="document-filter status-dropdown-filter" :class="{ open: openFilterKey === 'level' }">
<button
class="filter-btn status-filter-trigger"
type="button"
:aria-expanded="openFilterKey === 'level'"
@click="toggleFilter('level')"
>
<i class="mdi mdi-filter-variant"></i>
<span>{{ systemLevelFilterLabel }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
<div
v-if="openFilterKey === 'level'"
class="document-filter-menu status-filter-menu"
role="listbox"
aria-label="日志级别"
>
<button
v-for="option in systemLevelFilterOptions"
:key="option.value"
type="button"
role="option"
:aria-selected="systemLevelFilter === option.value"
:class="{ active: systemLevelFilter === option.value }"
@click="selectLevelFilter(option.value)"
>
{{ option.label }}
</button>
</div>
</div>
<div class="document-filter" :class="{ open: openFilterKey === 'eventType' }">
<button
class="filter-btn"
type="button"
:aria-expanded="openFilterKey === 'eventType'"
@click="toggleFilter('eventType')"
>
<span>{{ systemEventTypeFilterLabel }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
<div
v-if="openFilterKey === 'eventType'"
class="document-filter-menu"
role="listbox"
aria-label="事件类型"
>
<button
v-for="option in systemEventTypeFilterOptions"
:key="option.value"
type="button"
role="option"
:aria-selected="systemEventTypeFilter === option.value"
:class="{ active: systemEventTypeFilter === option.value }"
@click="selectEventTypeFilter(option.value)"
>
{{ option.label }}
</button>
</div>
</div>
</template>
<template #actions>
<button v-if="hasActiveFilters" class="create-request-btn secondary" type="button" @click="resetFilters">
<i class="mdi mdi-filter-remove-outline"></i>
<span>清空筛选</span>
</button>
<div class="document-filter refresh-interval-filter" :class="{ open: openFilterKey === 'refreshInterval' }">
<button
class="filter-btn refresh-interval-trigger"
type="button"
:aria-expanded="openFilterKey === 'refreshInterval'"
@click="toggleFilter('refreshInterval')"
>
<i class="mdi mdi-clock-outline"></i>
<span>刷新时间 {{ refreshIntervalLabel }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
<div
v-if="openFilterKey === 'refreshInterval'"
class="document-filter-menu refresh-interval-menu"
role="listbox"
aria-label="刷新时间"
>
<button
v-for="option in refreshIntervalOptions"
:key="option.value"
type="button"
role="option"
:aria-selected="refreshInterval === option.value"
:class="{ active: refreshInterval === option.value }"
@click="changeRefreshInterval(option.value)"
>
每 {{ option.label }}
</button>
</div>
</div>
<button
type="button"
class="create-request-btn icon-refresh-action"
:disabled="systemLogLoading"
aria-label="立即刷新系统日志"
@click="loadSystemLogs(true)"
>
<i :class="systemLogLoading ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-refresh'"></i>
</button>
</template>
<template #hint>
<i class="mdi mdi-information-outline"></i>
点击任意行可查看系统日志详情
</template>
<template #empty>
<div class="inline-empty">
当前筛选条件下没有系统日志记录。
</div>
</template>
<template #table>
<table class="system-log-table">
<colgroup>
<col class="col-time">
<col class="col-level">
@@ -155,48 +197,12 @@
<strong>{{ entry.summary || entry.message }}</strong>
<span>{{ formatSummary(entry.message) }}</span>
</td>
<td class="trace-cell">{{ entry.request_id || '—' }}</td>
<td class="trace-cell">{{ entry.request_id || '-' }}</td>
</tr>
</tbody>
</table>
<div v-else class="inline-empty">
当前筛选条件下没有系统日志记录
</div>
</div>
<footer v-if="!systemLogLoading && filteredSystemLogEntries.length" class="list-foot">
<span class="page-summary"> {{ totalCount }} 条系统日志目前第 {{ currentPage }} </span>
<div class="pager" aria-label="分页">
<button class="page-nav" type="button" :disabled="currentPage === 1" aria-label="上一页" @click="currentPage--">
<i class="mdi mdi-chevron-left"></i>
</button>
<template v-for="page in visiblePageItems" :key="page === 'ellipsis' ? 'ellipsis' : page">
<span v-if="page === 'ellipsis'" class="page-ellipsis" aria-hidden="true">...</span>
<button
v-else
class="page-number"
:class="{ active: currentPage === page }"
type="button"
:aria-current="currentPage === page ? 'page' : undefined"
@click="currentPage = page"
>
{{ page }}
</button>
</template>
<button class="page-nav" type="button" :disabled="currentPage === totalPages" aria-label="下一页" @click="currentPage++">
<i class="mdi mdi-chevron-right"></i>
</button>
</div>
<EnterpriseSelect
v-model="pageSize"
class="page-size-select"
:options="pageSizeOptions"
size="small"
@change="changePageSize"
/>
</footer>
</article>
</template>
</EnterpriseListPage>
</template>
</section>
</template>

View File

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

View File

@@ -1,96 +1,81 @@
<template>
<section class="travel-page">
<article class="travel-list panel">
<nav class="status-tabs" aria-label="个人报销状态">
<button
v-for="tab in tabs"
:key="tab"
type="button"
:class="{ active: activeTab === tab }"
@click="activeTab = tab"
>
{{ tab }}
</button>
</nav>
<div class="list-toolbar">
<div class="filter-set">
<div class="list-search">
<i class="mdi mdi-magnify"></i>
<input v-model="listKeyword" type="search" placeholder="搜索单号、事由、报销类型..." />
</div>
<div class="date-range-filter" :class="{ open: datePopover }">
<button class="filter-btn date-range-trigger" type="button" @click="datePopover = !datePopover">
<span class="date-range-label">{{ dateRangeLabel }}</span>
<i class="mdi mdi-calendar"></i>
</button>
<div v-if="datePopover" class="date-range-popover" role="dialog" aria-label="选择时间段">
<header>
<strong>选择时间段</strong>
<button type="button" aria-label="关闭" @click="datePopover = false"><i class="mdi mdi-close"></i></button>
</header>
<div class="date-range-fields">
<label>
<span>开始日期</span>
<input v-model="rangeStart" type="date" />
</label>
<label>
<span>结束日期</span>
<input v-model="rangeEnd" type="date" />
</label>
</div>
<footer>
<button class="ghost-btn" type="button" @click="datePopover = false">取消</button>
<button class="apply-btn" type="button" :disabled="!rangeStart || !rangeEnd" @click="applyDateRange">应用</button>
</footer>
</div>
</div>
<button v-for="filter in filters" :key="filter" type="button" class="filter-btn">
<span>{{ filter }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
<EnterpriseListPage
v-model:active-tab="activeTab"
v-model:current-page="currentPage"
v-model:page-size="pageSize"
class="travel-list"
:tabs="tabs"
tabs-label="个人报销状态"
:loading="loading"
:error="error"
:empty="showEmpty"
:empty-state="emptyState"
:total="totalCount"
:total-pages="totalPages"
:page-size-options="pageSizeOptions"
:show-pagination="showTable"
loading-title="真实报销数据同步中"
loading-message="正在加载后端返回的个人报销单据"
loading-icon="mdi mdi-file-document-outline"
error-title="报销列表加载失败"
@retry="emit('reload')"
@empty-action="handleEmptyAction"
@page-size-change="changePageSize"
>
<template #filters>
<div class="list-search">
<i class="mdi mdi-magnify"></i>
<input v-model="listKeyword" type="search" placeholder="搜索单号、事由、报销类型..." />
</div>
<div class="date-range-filter" :class="{ open: datePopover }">
<button class="filter-btn date-range-trigger" type="button" @click="datePopover = !datePopover">
<span class="date-range-label">{{ dateRangeLabel }}</span>
<i class="mdi mdi-calendar"></i>
</button>
<div v-if="datePopover" class="date-range-popover" role="dialog" aria-label="选择时间段">
<header>
<strong>选择时间段</strong>
<button type="button" aria-label="关闭" @click="datePopover = false"><i class="mdi mdi-close"></i></button>
</header>
<div class="date-range-fields">
<label>
<span>开始日期</span>
<input v-model="rangeStart" type="date" />
</label>
<label>
<span>结束日期</span>
<input v-model="rangeEnd" type="date" />
</label>
</div>
<footer>
<button class="ghost-btn" type="button" @click="datePopover = false">取消</button>
<button class="apply-btn" type="button" :disabled="!rangeStart || !rangeEnd" @click="applyDateRange">应用</button>
</footer>
</div>
</div>
<button v-for="filter in filters" :key="filter" type="button" class="filter-btn">
<span>{{ filter }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
</template>
<template #actions>
<button class="create-request-btn" type="button" @click="emit('create-request')">
<i class="mdi mdi-plus-circle-outline"></i>
<span>发起报销</span>
</button>
</div>
</template>
<p class="hint"><i class="mdi mdi-information-outline"></i> 点击任意行可查看单据详情</p>
<template #hint>
<i class="mdi mdi-information-outline"></i>
点击任意行可查看单据详情
</template>
<div class="table-wrap" :class="{ 'is-empty': showEmpty }">
<div v-if="loading" class="table-state">
<TableLoadingState
title="真实报销数据同步中"
message="正在加载后端返回的个人报销单据"
icon="mdi mdi-file-document-outline"
/>
</div>
<div v-else-if="error" class="table-state error">
<i class="mdi mdi-alert-circle-outline"></i>
<strong>报销列表加载失败</strong>
<p>{{ error }}</p>
<button class="retry-btn" type="button" @click="emit('reload')">重新加载</button>
</div>
<TableEmptyState
v-else-if="showEmpty"
:eyebrow="emptyState.eyebrow"
:title="emptyState.title"
:description="emptyState.desc"
:icon="emptyState.icon"
:action-label="emptyState.actionLabel"
:action-icon="emptyState.actionIcon"
:tone="emptyState.tone"
:art-label="emptyState.artLabel"
:tips="emptyState.tips"
@action="handleEmptyAction"
/>
<table v-else>
<template #table>
<table>
<colgroup>
<col class="col-id">
<col class="col-type">
@@ -126,24 +111,8 @@
</tr>
</tbody>
</table>
</div>
<footer v-if="showTable" class="list-foot">
<span class="page-summary"> {{ totalCount }} 目前第 {{ currentPage }} </span>
<div class="pager" aria-label="分页">
<button class="page-nav" type="button" :disabled="currentPage === 1" aria-label="上一页" @click="currentPage--"><i class="mdi mdi-chevron-left"></i></button>
<button v-for="p in totalPages" :key="p" class="page-number" :class="{ active: currentPage === p }" type="button" :aria-current="currentPage === p ? 'page' : undefined" @click="currentPage = p">{{ p }}</button>
<button class="page-nav" type="button" :disabled="currentPage === totalPages" aria-label="下一页" @click="currentPage++"><i class="mdi mdi-chevron-right"></i></button>
</div>
<EnterpriseSelect
v-model="pageSize"
class="page-size-select"
:options="pageSizeOptions"
size="small"
@change="changePageSize"
/>
</footer>
</article>
</template>
</EnterpriseListPage>
</section>
</template>

View File

@@ -35,15 +35,15 @@
<p>{{ activeSectionConfig.longDesc }}</p>
</div>
<div class="settings-toolbar-actions">
<button class="save-button" type="button" @click="saveActiveSection">
<i class="mdi mdi-content-save-outline"></i>
<span>{{ activeSectionConfig.actionLabel }}</span>
</button>
<div class="settings-toolbar-actions">
<button v-if="activeSectionConfig.actionLabel" class="save-button" type="button" @click="saveActiveSection">
<i class="mdi mdi-content-save-outline"></i>
<span>{{ activeSectionConfig.actionLabel }}</span>
</button>
</div>
</header>
<div class="settings-content">
<div class="settings-content" :class="{ 'settings-content-fill': activeSection === 'systemLogs' }">
<template v-if="activeSection === 'profile'">
<section class="settings-card">
<div class="card-head">
@@ -379,8 +379,8 @@
</section>
</template>
<template v-else-if="activeSection === 'logs'">
<section class="settings-card">
<template v-else-if="activeSection === 'logs'">
<section class="settings-card log-policy-card">
<div class="card-head">
<div class="card-title-with-icon">
<div class="model-icon-box slate">
@@ -388,7 +388,7 @@
</div>
<div>
<h4>日志级别与留存</h4>
<p>定义系统记录粒度归档周期和告警接收人方便后续审计与排障</p>
<p>定义系统记录粒度归档周期写入路径和告警接收人方便后续排障追踪</p>
</div>
</div>
</div>
@@ -429,53 +429,18 @@
<label class="field field-full">
<span>告警邮箱</span>
<input v-model="pageState.logForm.alertEmail" type="email" placeholder="用于接收日志异常提醒" />
</label>
</div>
</section>
<section class="settings-card">
<div class="card-head">
<div class="card-title-with-icon">
<div class="model-icon-box slate">
<i class="mdi mdi-eye-check-outline"></i>
</div>
<div>
<h4>审计策略</h4>
<p>决定是否记录关键操作登录行为以及是否对敏感字段进行脱敏处理</p>
</div>
</div>
</div>
<div class="switch-group">
<button class="switch-row" type="button" @click="toggleBoolean('logForm', 'operationAudit')">
<span class="switch-copy">
<strong>记录关键操作日志</strong>
<small>保存配置修改审批动作和账户管理等重要事件</small>
</span>
<span class="switch-btn" :class="{ active: pageState.logForm.operationAudit }"><i></i></span>
</button>
<button class="switch-row" type="button" @click="toggleBoolean('logForm', 'loginAudit')">
<span class="switch-copy">
<strong>记录登录审计</strong>
<small>追踪登录来源登录结果和异常登录行为</small>
</span>
<span class="switch-btn" :class="{ active: pageState.logForm.loginAudit }"><i></i></span>
</button>
<button class="switch-row" type="button" @click="toggleBoolean('logForm', 'maskSensitive')">
<span class="switch-copy">
<strong>敏感字段脱敏</strong>
<small>日志写入时自动隐藏密码密钥与认证令牌</small>
</span>
<span class="switch-btn" :class="{ active: pageState.logForm.maskSensitive }"><i></i></span>
</button>
</div>
</section>
</template>
<template v-else-if="activeSection === 'mail'">
<MailSettingsPanel :mail-form="pageState.mailForm" />
</label>
</div>
</section>
</template>
<template v-else-if="activeSection === 'systemLogs'">
<LogDetailView v-if="systemLogDetailMode" class="settings-log-detail-view" />
<LogsView v-else class="settings-logs-view" />
</template>
<template v-else-if="activeSection === 'mail'">
<MailSettingsPanel :mail-form="pageState.mailForm" />
</template>
</div>
</div>

View File

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

View File

@@ -1,7 +1,6 @@
import { computed, ref, watch } from 'vue'
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
import TableEmptyState from '../../components/shared/TableEmptyState.vue'
import EnterpriseListPage from '../../components/shared/EnterpriseListPage.vue'
import { useApprovalInbox } from '../../composables/useApprovalInbox.js'
import { useSystemState } from '../../composables/useSystemState.js'
import { fetchApprovalExpenseClaims } from '../../services/reimbursements.js'
@@ -115,9 +114,8 @@ function buildApprovalRow(request) {
export default {
name: 'ApprovalCenterView',
components: {
TravelRequestDetailView,
TableLoadingState,
TableEmptyState
EnterpriseListPage,
TravelRequestDetailView
},
setup() {
const { currentUser } = useSystemState()

View File

@@ -1,7 +1,6 @@
import { computed, ref } from 'vue'
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
import TableEmptyState from '../../components/shared/TableEmptyState.vue'
import EnterpriseListPage from '../../components/shared/EnterpriseListPage.vue'
import { mapExpenseClaimToRequest } from '../../composables/useRequests.js'
import { fetchArchivedExpenseClaims } from '../../services/reimbursements.js'
import {
@@ -92,9 +91,8 @@ function resolveFilterLabel(options, activeValue, fallbackLabel) {
export default {
name: 'ArchiveCenterView',
components: {
TravelRequestDetailView,
TableLoadingState,
TableEmptyState
EnterpriseListPage,
TravelRequestDetailView
},
setup() {
const activeTab = ref(ARCHIVE_TAB_ALL)

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

View File

@@ -1,13 +1,16 @@
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import EnterpriseSelect from '../../components/shared/EnterpriseSelect.vue'
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
import EnterpriseListPage from '../../components/shared/EnterpriseListPage.vue'
import { useSystemState } from '../../composables/useSystemState.js'
import { useToast } from '../../composables/useToast.js'
import { fetchSystemLogEntries } from '../../services/systemLogs.js'
import { AGENT_RUN_POLL_INTERVAL_MS } from '../../utils/agentRunMonitor.js'
import { isManagerUser } from '../../utils/accessControl.js'
import {
DEFAULT_REFRESH_INTERVAL_MS,
REFRESH_INTERVAL_OPTIONS,
formatRefreshInterval
} from '../../utils/refreshIntervalOptions.js'
function formatDateTime(value) {
if (!value) {
@@ -62,8 +65,7 @@ function resolveSystemOutcomeTone(outcome) {
export default {
name: 'LogsView',
components: {
EnterpriseSelect,
TableLoadingState
EnterpriseListPage
},
emits: ['summary-change'],
setup(_, { emit }) {
@@ -81,6 +83,8 @@ export default {
const pageSize = ref(10)
const pageSizes = [10, 20, 50]
const pageSizeOptions = pageSizes.map((size) => ({ label: `${size} 条/页`, value: size }))
const refreshInterval = ref(DEFAULT_REFRESH_INTERVAL_MS)
const refreshIntervalOptions = REFRESH_INTERVAL_OPTIONS
let pollTimer = 0
const isAdmin = computed(() => isManagerUser(currentUser.value))
@@ -104,6 +108,7 @@ export default {
const systemEventTypeFilterLabel = computed(() =>
systemEventTypeFilterOptions.value.find((item) => item.value === systemEventTypeFilter.value)?.label || '全部类型'
)
const refreshIntervalLabel = computed(() => formatRefreshInterval(refreshInterval.value))
const hasActiveFilters = computed(() =>
Boolean(systemSearchKeyword.value.trim() || systemLevelFilter.value || systemEventTypeFilter.value)
)
@@ -177,6 +182,12 @@ export default {
openFilterKey.value = ''
}
function changeRefreshInterval(value) {
refreshInterval.value = Number(value) || DEFAULT_REFRESH_INTERVAL_MS
openFilterKey.value = ''
startPolling()
}
function resetFilters() {
systemSearchKeyword.value = ''
systemLevelFilter.value = ''
@@ -213,7 +224,7 @@ export default {
stopPolling()
pollTimer = window.setInterval(() => {
loadSystemLogs(false)
}, AGENT_RUN_POLL_INTERVAL_MS)
}, refreshInterval.value)
}
function stopPolling() {
@@ -255,6 +266,7 @@ export default {
return {
changePageSize,
changeRefreshInterval,
currentPage,
filteredSystemLogEntries,
formatDateTime,
@@ -265,6 +277,9 @@ export default {
openFilterKey,
pageSize,
pageSizeOptions,
refreshInterval,
refreshIntervalLabel,
refreshIntervalOptions,
resetFilters,
resolveSystemLevelTone,
resolveSystemOutcomeTone,

View File

@@ -1,8 +1,6 @@
import { computed, ref, watch } from 'vue'
import EnterpriseSelect from '../../components/shared/EnterpriseSelect.vue'
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
import TableEmptyState from '../../components/shared/TableEmptyState.vue'
import EnterpriseListPage from '../../components/shared/EnterpriseListPage.vue'
import { normalizeRequestForUi } from '../../utils/requestViewModel.js'
function extractRowDate(value) {
@@ -13,9 +11,7 @@ function extractRowDate(value) {
export default {
name: 'RequestsView',
components: {
EnterpriseSelect,
TableLoadingState,
TableEmptyState
EnterpriseListPage
},
props: {
filteredRequests: { type: Array, required: true },

View File

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

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

View File

@@ -14,6 +14,7 @@ const SOURCE_LABELS = {
const KNOWLEDGE_JOB_TYPES = new Set([
'knowledge_index_sync',
'llm_wiki_sync',
'llm_wiki_rule_formation',
'finance_policy_knowledge_organize'
])

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, 'approval'), false)
assert.equal(canAccessAppView(adminUser, 'archive'), false)
assert.equal(canAccessAppView(adminUser, 'logs'), false)
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'
function testDerivesViewFromRouteName() {
assert.equal(resolveAppViewFromRoute({ name: 'app-log-detail', meta: {} }), 'logs')
assert.equal(resolveAppViewFromRoute({ name: 'app-log-detail', meta: {} }), 'settings')
assert.equal(resolveAppViewFromRoute({ name: 'app-request-detail', meta: {} }), 'documents')
assert.equal(resolveAppViewFromRoute({ name: 'app-policies', meta: { appView: 'logs' } }), 'policies')
assert.equal(resolveAppViewFromRoute({ name: 'app-policies', meta: { appView: 'settings' } }), 'policies')
}
function testFallsBackToValidMeta() {
@@ -19,7 +19,7 @@ function testFallsBackToValidMeta() {
}
function testResolvesMainRouteNames() {
assert.equal(resolveTargetRouteName('logs'), 'app-logs')
assert.equal(resolveTargetRouteName('logs'), 'app-settings')
assert.equal(resolveTargetRouteName('policies'), 'app-policies')
assert.equal(resolveTargetRouteName('requests'), 'app-overview')
assert.equal(resolveTargetRouteName('approval'), 'app-overview')
@@ -31,7 +31,8 @@ function testLegacyCentersAreRemovedFromNavigation() {
assert.equal(appViews.includes('requests'), false)
assert.equal(appViews.includes('approval'), false)
assert.equal(appViews.includes('archive'), false)
assert.equal(navItems.some((item) => ['requests', 'approval', 'archive'].includes(item.id)), false)
assert.equal(appViews.includes('logs'), false)
assert.equal(navItems.some((item) => ['requests', 'approval', 'archive', 'logs'].includes(item.id)), false)
}
function run() {

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

@@ -1043,8 +1043,46 @@ export default defineConfig({
'/api': {
target: `http://127.0.0.1:${process.env.SERVER_PORT || 8000}`,
changeOrigin: true
}
}
},
plugins: [vue(), localSetupPlugin()]
})
}
}
},
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()]
})