feat: 新增风险图谱算法与系统仪表盘及操作反馈体系

后端新增风险图谱算法模块、风险观察与反馈服务、规则 DSL
校验器和可解释性引擎,完善系统仪表盘和财务仪表盘统计,
优化 agent 运行和编排执行链路,清理旧开发文档,前端新增
系统趋势、负载热力图等多种仪表盘图表组件,完善操作反馈
对话框和工作台日期选择器,优化报销创建和审批详情交互,
补充单元测试覆盖。
This commit is contained in:
caoxiaozhu
2026-05-30 15:46:51 +08:00
parent 4c59941ec6
commit 7989f3a159
314 changed files with 30073 additions and 20626 deletions

View File

@@ -0,0 +1,283 @@
.run-products-card {
overflow: hidden;
}
.run-product-state {
margin: 0 12px 12px;
min-height: 96px;
display: grid;
place-items: center;
gap: 8px;
color: #64748b;
font-size: 13px;
border: 1px dashed #d8e2ee;
border-radius: 4px;
background: #f8fafc;
}
.run-product-state .mdi {
color: #2563eb;
font-size: 24px;
}
.run-product-state.error {
color: #b91c1c;
border-color: #fecaca;
background: #fef2f2;
}
.run-product-state.error .mdi {
color: #dc2626;
}
.run-product-meta-grid {
padding: 0 12px 12px;
}
.run-product-section {
margin: 0 12px 12px;
padding-top: 12px;
border-top: 1px solid #edf2f7;
}
.run-product-section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.run-product-section-head h4 {
margin: 0;
color: #0f172a;
font-size: 14px;
font-weight: 800;
}
.run-product-section-head span {
flex: 0 0 auto;
color: #64748b;
font-size: 12px;
}
.run-product-observation-list {
display: grid;
gap: 8px;
}
.run-product-observation {
width: 100%;
padding: 10px 12px;
text-align: left;
border: 1px solid #e5edf6;
border-radius: 4px;
background: #fbfdff;
cursor: pointer;
transition: border-color 0.18s ease, box-shadow 0.18s ease, background 0.18s ease;
}
.run-product-observation:hover,
.run-product-observation:focus-visible,
.run-product-observation.is-expanded {
border-color: rgba(37, 99, 235, 0.32);
background: #ffffff;
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.08);
outline: none;
}
.run-product-observation-head {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
}
.run-product-observation-head strong {
min-width: 0;
overflow: hidden;
color: #0f172a;
font-size: 13px;
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
}
.run-product-observation-head b {
color: #111827;
font-size: 14px;
}
.run-product-observation p,
.run-product-copy {
margin: 8px 0 0;
color: #475569;
font-size: 13px;
line-height: 1.65;
}
.run-product-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 10px;
}
.run-product-tags span {
max-width: 100%;
padding: 4px 8px;
overflow: hidden;
color: #475569;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
border: 1px solid #e2e8f0;
border-radius: 4px;
background: #ffffff;
}
.run-product-observation-detail {
display: grid;
gap: 10px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #e5edf6;
}
.run-product-observation-detail section {
display: grid;
gap: 8px;
}
.run-product-observation-detail section > span {
color: #334155;
font-size: 12px;
font-weight: 800;
}
.run-product-score-list {
display: grid;
gap: 7px;
}
.run-product-score-list div {
display: grid;
grid-template-columns: 72px minmax(0, 1fr) 34px;
align-items: center;
gap: 8px;
}
.run-product-score-list em {
overflow: hidden;
color: #64748b;
font-size: 12px;
font-style: normal;
text-overflow: ellipsis;
white-space: nowrap;
}
.run-product-score-list i {
height: 6px;
overflow: hidden;
border-radius: 999px;
background: #e2e8f0;
}
.run-product-score-list i b {
display: block;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #2563eb, #22c55e);
}
.run-product-score-list strong {
color: #0f172a;
font-size: 12px;
text-align: right;
}
.run-product-evidence-list {
display: grid;
gap: 6px;
margin: 0;
padding: 0;
list-style: none;
}
.run-product-evidence-list li {
padding: 8px 10px;
border: 1px solid #edf2f7;
border-radius: 4px;
background: #f8fafc;
}
.run-product-evidence-list strong {
display: block;
color: #0f172a;
font-size: 12px;
}
.run-product-evidence-list p {
margin: 4px 0 0;
color: #64748b;
font-size: 12px;
line-height: 1.5;
}
.run-product-inline-empty {
margin: 0;
padding: 12px;
color: #94a3b8;
font-size: 13px;
border: 1px dashed #d8e2ee;
border-radius: 4px;
background: #f8fafc;
}
.risk-level-pill {
display: inline-flex;
align-items: center;
height: 24px;
padding: 0 8px;
color: #475569;
font-size: 12px;
font-weight: 800;
white-space: nowrap;
border: 1px solid #e2e8f0;
border-radius: 4px;
background: #f8fafc;
}
.risk-level-pill.critical,
.risk-level-pill.high {
color: #b91c1c;
border-color: #fecaca;
background: #fef2f2;
}
.risk-level-pill.medium {
color: #92400e;
border-color: #fed7aa;
background: #fff7ed;
}
.risk-level-pill.low {
color: #166534;
border-color: #bbf7d0;
background: #f0fdf4;
}
@media (max-width: 760px) {
.run-product-observation-head {
grid-template-columns: minmax(0, 1fr) auto;
}
.run-product-score-list div {
grid-template-columns: 64px minmax(0, 1fr) 30px;
}
.risk-level-pill {
grid-column: 1 / -1;
width: fit-content;
}
}

View File

@@ -0,0 +1,197 @@
.workbench-date-anchor {
display: inline-flex;
}
.composer-icon-button.active {
border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.42);
background: var(--workbench-primary-soft);
color: var(--workbench-primary-active);
box-shadow: 0 6px 14px rgba(var(--theme-primary-rgb, 58, 124, 165), 0.12);
}
.workbench-date-chip-row {
display: flex;
align-items: center;
min-height: 28px;
}
.workbench-date-chip {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 100%;
min-height: 26px;
padding: 0 8px;
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.3);
border-radius: 4px;
background: var(--workbench-primary-soft);
color: var(--workbench-primary-active);
font-size: 12px;
font-weight: 800;
line-height: 1;
}
.workbench-date-chip span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.workbench-date-chip button {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 18px;
height: 18px;
border-radius: 4px;
color: inherit;
}
.workbench-date-chip button:hover {
background: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.12);
}
.workbench-date-chip button:disabled {
opacity: 0.48;
cursor: not-allowed;
}
.composer-date-popover {
position: absolute;
top: calc(100% + 8px);
left: 18px;
z-index: 60;
width: min(320px, calc(100% - 36px));
max-width: calc(100vw - 32px);
display: grid;
gap: 12px;
padding: 14px;
border: 1px solid var(--workbench-line-strong);
border-radius: 4px;
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.14), 0 4px 12px rgba(15, 23, 42, 0.06);
}
.composer-date-mode-tabs {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
padding: 4px;
border-radius: 4px;
background: #f1f5f9;
}
.composer-date-mode-btn {
min-height: 34px;
border-radius: 4px;
color: var(--workbench-muted);
font-size: 12px;
font-weight: 800;
}
.composer-date-mode-btn.active {
background: #fff;
color: var(--workbench-ink);
box-shadow: 0 4px 10px rgba(148, 163, 184, 0.16);
}
.composer-date-fields {
display: grid;
gap: 8px;
}
.composer-date-fields-range {
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: end;
}
.composer-date-field {
display: grid;
gap: 6px;
min-width: 0;
}
.composer-date-field span {
color: var(--workbench-muted);
font-size: 11px;
font-weight: 800;
}
.composer-date-field input {
width: 100%;
min-height: 36px;
padding: 0 10px;
border: 1px solid var(--workbench-line-strong);
border-radius: 4px;
background: #fff;
color: var(--workbench-ink);
font-size: 12px;
font-weight: 700;
}
.composer-date-field input:focus {
border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.46);
box-shadow: 0 0 0 3px rgba(var(--theme-primary-rgb, 58, 124, 165), 0.1);
outline: none;
}
.composer-date-range-sep {
align-self: center;
color: #94a3b8;
font-size: 12px;
font-weight: 800;
}
.composer-date-hint {
margin: 0;
color: #dc2626;
font-size: 11px;
line-height: 1.5;
}
.composer-date-popover-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.composer-date-cancel-btn,
.composer-date-apply-btn {
min-height: 34px;
padding: 0 14px;
border-radius: 4px;
font-size: 12px;
font-weight: 800;
}
.composer-date-cancel-btn {
border: 1px solid var(--workbench-line-strong);
background: #fff;
color: var(--workbench-muted);
}
.composer-date-apply-btn {
background: var(--workbench-primary-active);
color: #fff;
}
.composer-date-apply-btn:disabled {
opacity: 0.48;
cursor: not-allowed;
}
@media (max-width: 480px) {
.composer-date-popover {
left: 12px;
width: calc(100% - 24px);
}
.composer-date-fields-range {
grid-template-columns: minmax(0, 1fr);
}
.composer-date-range-sep {
display: none;
}
}

View File

@@ -30,7 +30,6 @@
}
.composer-icon-button,
.composer-related-button,
.composer-send-button {
height: 32px;
}
@@ -287,7 +286,6 @@
}
.composer-icon-button,
.composer-related-button,
.composer-send-button {
height: 30px;
font-size: 13px;
@@ -297,11 +295,6 @@
width: 30px;
}
.composer-related-button {
padding: 0 10px;
gap: 4px;
}
.composer-send-button {
width: 46px;
}

View File

@@ -127,6 +127,8 @@
.assistant-file-input { display: none; }
.assistant-composer {
position: relative;
z-index: 5;
display: grid;
gap: 6px;
min-height: var(--composer-min-height);
@@ -160,6 +162,32 @@
.assistant-composer textarea:focus { outline: none; }
.assistant-composer textarea[readonly] {
color: color-mix(in srgb, var(--workbench-ink) 72%, #ffffff);
cursor: progress;
}
.assistant-intent-status {
display: inline-flex;
align-items: center;
width: fit-content;
max-width: 100%;
min-height: 28px;
gap: 8px;
padding: 0 10px;
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.22);
border-radius: 4px;
background: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08);
color: var(--workbench-primary-active);
font-size: 12px;
font-weight: 750;
line-height: 1.35;
}
.assistant-intent-status i {
font-size: 15px;
}
.composer-toolbar {
display: flex;
align-items: center;
@@ -167,7 +195,6 @@
}
.composer-icon-button,
.composer-related-button,
.composer-send-button {
display: inline-flex;
align-items: center;
@@ -185,16 +212,6 @@
font-size: 19px;
}
.composer-related-button {
gap: 8px;
padding: 0 16px;
border: 1px solid var(--workbench-line);
background: var(--workbench-surface);
color: var(--workbench-text);
font-size: 14px;
font-weight: 700;
}
.composer-count {
margin-left: auto;
color: color-mix(in srgb, var(--workbench-muted) 75%, #ffffff);
@@ -691,8 +708,7 @@
.todo-row:hover,
.progress-row:hover,
.quick-prompts button:hover,
.composer-icon-button:hover,
.composer-related-button:hover {
.composer-icon-button:hover {
border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.24);
color: var(--workbench-primary-active);
}

View File

@@ -0,0 +1,334 @@
.risk-observation-evidence-card {
display: grid;
gap: 14px;
}
.risk-evidence-refresh {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 30px;
padding: 0 10px;
border: 1px solid #dbe5ef;
border-radius: 4px;
background: #fff;
color: #334155;
font-size: 12px;
font-weight: 800;
cursor: pointer;
}
.risk-evidence-refresh:disabled {
cursor: not-allowed;
opacity: .72;
}
.risk-evidence-state {
min-height: 112px;
display: grid;
place-content: center;
justify-items: center;
gap: 8px;
color: #64748b;
font-size: 13px;
font-weight: 700;
}
.risk-evidence-state i {
color: #94a3b8;
font-size: 24px;
}
.risk-evidence-state.error {
color: #b91c1c;
}
.risk-evidence-current-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border: 1px solid #e6edf5;
border-radius: 4px;
background: #fff;
}
.risk-evidence-current-head div {
min-width: 0;
display: grid;
gap: 3px;
}
.risk-evidence-current-head span {
color: #64748b;
font-size: 12px;
font-weight: 850;
}
.risk-evidence-current-head strong {
overflow: hidden;
color: #0f172a;
font-size: 14px;
font-weight: 900;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.risk-evidence-current-head em {
flex: 0 0 auto;
color: #64748b;
font-size: 12px;
font-style: normal;
font-weight: 850;
}
.risk-evidence-detail-region {
display: grid;
gap: 12px;
}
.risk-evidence-summary {
display: grid;
grid-template-columns: 86px minmax(0, 1fr);
gap: 14px;
align-items: stretch;
padding: 14px;
border: 1px solid #edf2f7;
border-radius: 4px;
background: #f8fafc;
}
.risk-evidence-score {
display: grid;
place-content: center;
justify-items: center;
gap: 4px;
min-height: 82px;
border-radius: 4px;
background: #e2e8f0;
color: #475569;
}
.risk-evidence-score.critical,
.risk-evidence-score.high {
background: rgba(239, 68, 68, .1);
color: #b91c1c;
}
.risk-evidence-score.medium {
background: rgba(245, 158, 11, .12);
color: #b45309;
}
.risk-evidence-score strong {
font-size: 28px;
font-weight: 900;
line-height: 1;
font-variant-numeric: tabular-nums;
}
.risk-evidence-score span {
font-size: 12px;
font-weight: 900;
}
.risk-evidence-copy {
min-width: 0;
display: grid;
gap: 8px;
align-content: center;
}
.risk-evidence-copy h4 {
min-width: 0;
color: #0f172a;
font-size: 15px;
font-weight: 850;
line-height: 1.35;
}
.risk-evidence-copy p {
color: #475569;
font-size: 13px;
font-weight: 600;
line-height: 1.55;
}
.risk-evidence-meta {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.risk-evidence-meta span,
.risk-chip-list span {
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 0 8px;
border: 1px solid #dbe5ef;
border-radius: 4px;
background: #fff;
color: #475569;
font-size: 12px;
font-weight: 800;
}
.risk-evidence-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.risk-evidence-section {
min-width: 0;
display: grid;
gap: 10px;
padding: 12px;
border: 1px solid #edf2f7;
border-radius: 4px;
background: #fff;
}
.risk-evidence-section-title {
color: #334155;
font-size: 12px;
font-weight: 900;
}
.risk-score-list {
display: grid;
gap: 8px;
}
.risk-score-row {
display: grid;
grid-template-columns: 72px minmax(0, 1fr) 34px;
align-items: center;
gap: 8px;
}
.risk-score-row span {
color: #64748b;
font-size: 12px;
font-weight: 800;
}
.risk-score-row i {
height: 7px;
overflow: hidden;
border-radius: 999px;
background: #edf2f7;
}
.risk-score-row b {
display: block;
height: 100%;
border-radius: inherit;
background: var(--theme-primary);
}
.risk-score-row strong {
color: #0f172a;
font-size: 12px;
font-weight: 900;
text-align: right;
}
.risk-evidence-list {
display: grid;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
.risk-evidence-list li {
display: grid;
gap: 3px;
}
.risk-evidence-list strong {
color: #0f172a;
font-size: 12px;
font-weight: 850;
}
.risk-evidence-list span,
.risk-evidence-empty,
.risk-chip-list em {
color: #64748b;
font-size: 12px;
font-style: normal;
font-weight: 650;
line-height: 1.5;
}
.risk-chip-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.risk-observation-list {
display: grid;
gap: 8px;
}
.risk-observation-row {
display: grid;
grid-template-columns: 80px minmax(0, 1fr) 42px;
align-items: center;
gap: 10px;
min-height: 40px;
padding: 8px 10px;
border: 1px solid #edf2f7;
border-radius: 4px;
background: #fff;
text-align: left;
cursor: pointer;
}
.risk-observation-row.active,
.risk-observation-row:hover,
.risk-observation-row:focus-visible {
border-color: rgba(var(--theme-primary-rgb), .24);
background: #f8fafc;
}
.risk-observation-row:focus-visible {
outline: 2px solid rgba(var(--theme-primary-rgb), .28);
outline-offset: 2px;
}
.risk-observation-row span,
.risk-observation-row em {
color: #64748b;
font-size: 12px;
font-style: normal;
font-weight: 850;
}
.risk-observation-row strong {
min-width: 0;
overflow: hidden;
color: #0f172a;
font-size: 13px;
font-weight: 850;
text-overflow: ellipsis;
white-space: nowrap;
}
.risk-observation-row em {
color: #0f172a;
text-align: right;
}
@media (max-width: 960px) {
.risk-evidence-summary,
.risk-evidence-grid {
grid-template-columns: minmax(0, 1fr);
}
}

View File

@@ -124,6 +124,49 @@
position: relative;
}
.dashboard-switch-wrap {
width: 190px;
flex: 0 0 190px;
height: 44px;
display: flex;
align-items: center;
}
.dashboard-switch-select {
width: 100%;
}
.dashboard-switch-select :deep(.el-select__wrapper) {
height: 44px;
min-height: 44px;
border-radius: 4px;
background: #f8fbfd;
box-shadow:
0 0 0 2px rgba(var(--theme-primary-rgb, 58, 124, 165), .48) inset,
0 1px 2px rgba(15, 23, 42, .05);
}
.dashboard-switch-select :deep(.el-select__wrapper:hover) {
background: #f3f9fd;
box-shadow:
0 0 0 2px rgba(var(--theme-primary-rgb, 58, 124, 165), .72) inset,
0 0 0 3px rgba(var(--theme-primary-rgb, 58, 124, 165), .08);
}
.dashboard-switch-select :deep(.el-select__wrapper.is-focused) {
background: #ffffff;
box-shadow:
0 0 0 2px var(--theme-primary) inset,
0 0 0 3px rgba(var(--theme-primary-rgb, 58, 124, 165), .14);
}
.dashboard-switch-select :deep(.el-select__placeholder),
.dashboard-switch-select :deep(.el-select__selected-item) {
color: #1e293b;
font-size: 13px;
font-weight: 800;
}
.custom-range-btn {
height: 42px;
display: inline-flex;
@@ -507,6 +550,11 @@
justify-content: center;
}
.dashboard-switch-wrap {
width: 100%;
flex: 1 1 100%;
}
.calendar-popover {
right: auto;
left: 0;

View File

@@ -24,7 +24,7 @@
grid-template-rows: auto minmax(0, 1fr);
overflow: hidden;
border: 1px solid rgba(189, 201, 214, 0.74);
border-radius: 16px;
border-radius: 4px;
background: #ffffff;
box-shadow: 0 14px 32px rgba(148, 163, 184, 0.16);
opacity: 1;
@@ -63,7 +63,7 @@
gap: 12px;
padding: 16px;
border-bottom: 1px solid #e2e8f0;
background: linear-gradient(180deg, #f8fbff, #ffffff);
background: #ffffff;
}
.insight-head h3 {
@@ -96,7 +96,7 @@
display: inline-flex;
align-items: center;
padding: 0 10px;
border-radius: 999px;
border-radius: 4px;
background: #eff6ff;
color: #2563eb;
font-size: 11px;
@@ -111,7 +111,7 @@
gap: 10px;
padding: 12px;
border: 1px solid #e2e8f0;
border-radius: 12px;
border-radius: 4px;
background: #ffffff;
}
@@ -126,7 +126,6 @@
.note-block p,
.review-side-head p,
.review-side-risk-summary,
.flow-step-tool,
.flow-step-detail,
.flow-step-card time {
margin: 0;
@@ -166,7 +165,7 @@
display: grid;
gap: 6px;
padding: 10px;
border-radius: 10px;
border-radius: 4px;
background: #f8fbff;
}
@@ -187,7 +186,7 @@
display: inline-flex;
align-items: center;
padding: 0 8px;
border-radius: 999px;
border-radius: 4px;
background: #eff6ff;
color: #2563eb;
font-size: 11px;
@@ -202,7 +201,7 @@
.flow-step-card,
.review-document-preview-card {
border: 1px solid #e2e8f0;
border-radius: 10px;
border-radius: 4px;
background: #ffffff;
}
@@ -229,7 +228,7 @@
height: 30px;
display: grid;
place-items: center;
border-radius: 8px;
border-radius: 4px;
background: #eff6ff;
color: #2563eb;
font-size: 11px;
@@ -255,7 +254,7 @@
height: 30px;
display: grid;
place-items: center;
border-radius: 8px;
border-radius: 4px;
background: #eff6ff;
color: var(--theme-primary, #3a7ca5);
}
@@ -295,7 +294,7 @@
.review-document-nav-btn,
.review-side-save-pill {
border: 1px solid #cbd5e1;
border-radius: 8px;
border-radius: 4px;
background: #ffffff;
color: #334155;
}
@@ -383,7 +382,7 @@
display: grid;
gap: 7px;
padding: 10px 11px;
border-radius: 8px;
border-radius: 4px;
background: #f8fafc;
box-shadow: none;
transition: background 0.18s ease, border-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
@@ -410,7 +409,6 @@
color: #ffffff;
}
.flow-step-item.completed .flow-step-tool,
.flow-step-item.completed .flow-step-detail,
.flow-step-item.completed .flow-step-card time {
color: #cbd5e1;
@@ -447,7 +445,7 @@
gap: 8px;
padding: 18px;
border: 1px dashed #cbd5e1;
border-radius: 10px;
border-radius: 4px;
background: #f8fbff;
color: #64748b;
text-align: center;
@@ -459,7 +457,7 @@
width: 100%;
min-height: 34px;
border: 1px solid #cbd5e1;
border-radius: 8px;
border-radius: 4px;
background: #ffffff;
color: #0f172a;
font: inherit;

View File

@@ -0,0 +1,191 @@
.application-preview-date-chip {
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 0 8px;
border-radius: 999px;
background: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.1);
color: var(--theme-primary-active, #255b7d);
font-weight: 850;
}
.application-draft-preview {
width: min(100%, 620px);
max-width: 620px;
gap: 12px;
background: #ffffff;
}
.application-draft-preview .application-draft-head {
display: grid;
grid-template-columns: 36px minmax(0, 1fr) auto;
align-items: start;
justify-content: initial;
gap: 10px;
padding-bottom: 10px;
border-bottom: 1px solid #e6edf5;
}
.application-draft-icon {
width: 34px;
height: 34px;
display: inline-grid;
place-items: center;
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.22);
border-radius: 4px;
background: #f4f9fc;
color: var(--theme-primary-active, #255b7d);
font-size: 18px;
}
.application-draft-title {
min-width: 0;
display: grid;
gap: 3px;
}
.application-draft-title strong {
color: #102033;
font-size: 14px;
font-weight: 850;
line-height: 1.35;
}
.application-draft-title small {
color: #667085;
font-size: 12px;
line-height: 1.45;
}
.application-draft-status {
min-height: 22px;
display: inline-flex;
align-items: center;
padding: 0 8px;
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.24);
border-radius: 4px;
background: #f7fbff;
color: var(--theme-primary-active, #255b7d);
font-size: 11px;
font-weight: 850;
line-height: 1;
}
.application-draft-brief {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0;
border: 1px solid #d7e4f2;
border-radius: 4px;
overflow: hidden;
background: #ffffff;
}
.application-draft-brief-item {
display: grid;
grid-template-columns: 42px minmax(0, 1fr);
gap: 10px;
align-items: center;
min-height: 42px;
padding: 8px 12px;
border-top: 1px solid #edf2f7;
border-left: 1px solid #edf2f7;
font-size: 12px;
}
.application-draft-brief-item:nth-child(even) {
border-left: 0;
}
.application-draft-brief-item.is-primary {
grid-column: 1 / -1;
grid-template-columns: 42px minmax(0, 1fr);
min-height: 48px;
border-top: 0;
border-left: 0;
background: #f8fbff;
}
.application-draft-brief-item span {
color: #64748b;
font-weight: 760;
}
.application-draft-brief-item strong {
min-width: 0;
overflow-wrap: anywhere;
color: #0f172a;
font-weight: 850;
}
.application-draft-brief-item.is-primary strong {
color: #102033;
font-size: 13px;
}
.application-draft-footer {
display: block;
}
.application-draft-footer p {
margin: 0;
color: #64748b;
font-size: 12px;
line-height: 1.5;
}
.application-draft-detail-link {
display: inline;
margin: 0 1px;
padding: 0;
border: 0;
border-radius: 2px;
background: transparent;
color: var(--theme-primary-active, #255b7d);
font: inherit;
font-weight: 850;
line-height: inherit;
text-decoration: underline;
text-underline-offset: 3px;
cursor: pointer;
transition: color 0.18s ease, outline-color 0.18s ease;
}
.application-draft-detail-link:hover:not(:disabled) {
color: var(--theme-primary, #3a7ca5);
}
.application-draft-detail-link:focus-visible {
outline: 2px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.24);
outline-offset: 2px;
}
.application-draft-detail-link:disabled {
cursor: not-allowed;
opacity: 0.58;
}
@media (max-width: 640px) {
.application-draft-preview .application-draft-head {
grid-template-columns: 34px minmax(0, 1fr);
}
.application-draft-status {
grid-column: 2;
justify-self: start;
}
.application-draft-brief {
grid-template-columns: 1fr;
}
.application-draft-brief-item {
border-left: 0;
border-top: 1px solid #edf2f7;
}
.application-draft-brief-item.is-primary {
border-top: 0;
}
}

View File

@@ -1,3 +1,5 @@
@import "./travel-reimbursement-message-application.css";
.message-row {
display: grid;
grid-template-columns: 38px minmax(0, 1fr);
@@ -58,6 +60,13 @@
max-width: min(100%, 1080px);
}
.message-feedback-bubble {
grid-column: 2;
justify-self: start;
max-width: min(100%, 420px);
margin-top: -2px;
}
.message-bubble-review-risk-low,
.message-bubble-review-risk-medium,
.message-bubble-review-risk-high {
@@ -230,14 +239,6 @@
color: var(--theme-primary-active, #2f6d95);
}
.message-meta-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 10px;
}
.message-meta-chip,
.message-risk-chip,
.file-chip {
min-height: 26px;

View File

@@ -456,6 +456,11 @@ td:first-child {
text-align: left;
}
.audit-asset-table th:first-child,
.audit-asset-table td:first-child {
text-align: left;
}
tbody tr:hover {
background: #f8fbff;
}
@@ -474,6 +479,13 @@ tbody tr.is-disabled:hover {
grid-template-columns: 38px minmax(0, 1fr);
gap: 10px;
align-items: center;
justify-items: start;
text-align: left;
}
.skill-name-cell > div {
min-width: 0;
text-align: left;
}
.skill-avatar {

View File

@@ -71,8 +71,6 @@
.agent-answer-markdown :deep(th) { background: var(--theme-primary-light-9); color: #0f172a; font-weight: 800; }
.agent-answer-markdown :deep(td) { color: #334155; font-weight: 650; }
.agent-answer-markdown :deep(tbody tr:last-child td) { border-bottom: 0; }
.agent-meta-row { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; max-width: 760px; }
.agent-meta-chip { min-height: 26px; display: inline-flex; align-items: center; padding: 0 10px; border-radius: 999px; background: var(--theme-primary-light-9); color: var(--theme-primary-active); font-size: 12px; font-weight: 760; }
.agent-detail-block { max-width: 760px; margin-top: 10px; display: grid; gap: 8px; }
.agent-detail-block > strong { color: #0f172a; font-size: 12px; font-weight: 820; }
.agent-citation-disclosure { overflow: hidden; border: 1px solid #dce5ef; border-radius: 10px; background: #fff; }

View File

@@ -67,6 +67,41 @@
text-align: left;
}
.digital-skill-cell {
display: grid;
grid-template-columns: 38px minmax(0, 1fr);
gap: 10px;
align-items: center;
justify-items: start;
text-align: left;
}
.digital-skill-avatar {
width: 38px;
height: 38px;
display: grid;
place-items: center;
border-radius: 11px;
color: #fff;
font-size: 13px;
font-weight: 900;
}
.digital-skill-avatar.primary { background: var(--theme-gradient-primary); }
.digital-skill-avatar.rose { background: linear-gradient(135deg, #f43f5e, #e11d48); }
.digital-skill-avatar.violet { background: linear-gradient(135deg, #8b5cf6, #7c3aed); }
.digital-skill-avatar.blue {
background: linear-gradient(135deg, #3b82f6, #2563eb);
}
.digital-skill-avatar.amber { background: linear-gradient(135deg, #f59e0b, #ea580c); }
.digital-skill-cell .doc-id {
min-width: 0;
overflow: hidden;
text-align: left;
text-overflow: ellipsis;
}
.digital-employees-table .col-skill { width: 22%; }
.digital-employees-table .col-skill-type { width: 11%; }
.digital-employees-table .col-owner { width: 11%; }

View File

@@ -143,7 +143,9 @@
.donut-panel,
.bottleneck-panel,
.budget-panel {
.budget-panel,
.model-panel,
.feedback-panel {
grid-column: span 3;
}
@@ -183,8 +185,228 @@
text-align: center;
}
.card-subtitle {
margin: -8px 0 12px;
color: #64748b;
font-size: 12px;
font-weight: 550;
line-height: 1.55;
}
.system-observability-grid {
display: grid;
grid-template-columns: minmax(0, 1.18fr) minmax(320px, .9fr) minmax(320px, .9fr);
grid-template-areas:
"agent agent agent"
"token token token"
"accuracy accuracy side"
"tools tools side";
gap: 18px;
}
.system-agent-ratio-panel {
grid-area: agent;
}
.system-token-pie-panel {
grid-area: token;
}
.system-token-panel-grid {
display: grid;
grid-template-columns: minmax(0, 1.05fr) minmax(380px, .95fr);
gap: 18px;
align-items: stretch;
}
.system-accuracy-panel {
grid-area: accuracy;
}
.system-tool-detail-panel {
grid-area: tools;
}
.system-side-stack {
grid-area: side;
display: grid;
gap: 18px;
align-content: start;
}
.system-side-stack .dashboard-card {
grid-column: auto;
}
.system-side-card {
padding: 16px;
}
.system-side-card .card-head {
margin-bottom: 12px;
}
.system-side-card .card-subtitle {
margin: -6px 0 8px;
font-size: 11px;
}
.duration-summary {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.duration-summary strong {
display: block;
color: #0f172a;
font-size: 26px;
font-weight: 850;
line-height: 1;
letter-spacing: 0;
}
.duration-summary span {
display: block;
margin-top: 5px;
color: #64748b;
font-size: 11px;
font-weight: 650;
}
.duration-summary em {
padding: 2px 7px;
border-radius: 4px;
background: rgba(var(--success-rgb), 0.10);
color: var(--success);
font-size: 11px;
font-style: normal;
font-weight: 800;
white-space: nowrap;
}
.duration-meta {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
margin-bottom: 12px;
}
.duration-meta span {
padding: 7px 8px;
border: 1px solid #e2e8f0;
border-radius: 4px;
background: #fbfdff;
color: #475569;
font-size: 11px;
font-weight: 750;
}
.duration-bars {
display: grid;
gap: 9px;
}
.duration-bar-row {
display: grid;
grid-template-columns: 72px minmax(0, 1fr) 44px;
align-items: center;
gap: 8px;
}
.duration-bar-row span,
.duration-bar-row strong {
color: #475569;
font-size: 11px;
font-weight: 750;
white-space: nowrap;
}
.duration-bar-row strong {
color: #0f172a;
text-align: right;
font-variant-numeric: tabular-nums;
}
.duration-bar-row i {
height: 7px;
overflow: hidden;
border-radius: 4px;
background: #eef2f7;
}
.duration-bar-row b {
display: block;
height: 100%;
border-radius: inherit;
}
.system-tool-table {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px 14px;
}
.system-tool-row {
display: grid;
gap: 8px;
padding: 12px;
border: 1px solid #e2e8f0;
border-radius: 4px;
background: #fbfdff;
}
.system-tool-row-head,
.system-tool-row-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.system-tool-row-head strong {
min-width: 0;
color: #1e293b;
font-size: 13px;
font-weight: 750;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.system-tool-row-head span {
color: #0f172a;
font-size: 12px;
font-weight: 800;
white-space: nowrap;
}
.system-tool-meter {
height: 8px;
overflow: hidden;
border-radius: 4px;
background: #eef2f7;
}
.system-tool-meter i {
display: block;
height: 100%;
border-radius: inherit;
}
.system-tool-row-meta {
color: #64748b;
font-size: 11px;
flex-wrap: wrap;
justify-content: flex-start;
}
.bottleneck-panel,
.budget-panel {
.budget-panel,
.model-panel,
.feedback-panel {
display: flex;
flex-direction: column;
}
@@ -194,6 +416,67 @@
margin-top: auto;
}
.feedback-list {
flex: 1;
display: grid;
gap: 12px;
align-content: center;
}
.feedback-row {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 0;
border-bottom: 1px solid #f1f5f9;
}
.feedback-row:last-child {
border-bottom: 0;
}
.feedback-icon {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border-radius: 4px;
background: #eef2f7;
color: #64748b;
font-size: 16px;
flex: 0 0 auto;
}
.feedback-row.success .feedback-icon {
background: rgba(var(--success-rgb), .10);
color: var(--success);
}
.feedback-row.danger .feedback-icon {
background: rgba(239, 68, 68, .10);
color: #dc2626;
}
.feedback-row.info .feedback-icon {
background: rgba(var(--theme-primary-rgb, 58, 124, 165), .10);
color: var(--theme-primary-active);
}
.feedback-row strong {
display: block;
color: #1e293b;
font-size: 18px;
font-weight: 800;
line-height: 1;
}
.feedback-row span:not(.feedback-icon) {
display: block;
margin-top: 4px;
color: #64748b;
font-size: 12px;
}
.bottleneck-list {
flex: 1;
display: grid;
@@ -337,6 +620,15 @@
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.system-observability-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-areas:
"agent agent"
"token token"
"accuracy side"
"tools side";
}
.trend-panel,
.rank-panel {
grid-column: span 12;
@@ -344,7 +636,9 @@
.donut-panel,
.bottleneck-panel,
.budget-panel {
.budget-panel,
.model-panel,
.feedback-panel {
grid-column: span 6;
}
}
@@ -381,11 +675,31 @@
grid-template-columns: 1fr;
}
.system-observability-grid {
grid-template-columns: 1fr;
grid-template-areas:
"agent"
"token"
"accuracy"
"tools"
"side";
}
.system-tool-table {
grid-template-columns: 1fr;
}
.system-token-panel-grid {
grid-template-columns: 1fr;
}
.trend-panel,
.rank-panel,
.donut-panel,
.bottleneck-panel,
.budget-panel {
.budget-panel,
.model-panel,
.feedback-panel {
grid-column: span 1;
}

View File

@@ -11,26 +11,41 @@
.receipt-folder-detail {
min-height: 0;
overflow: hidden;
padding: 16px 18px;
}
.receipt-folder-list {
display: grid;
grid-template-rows: auto auto minmax(0, 1fr) auto;
padding: 16px 18px;
}
.receipt-detail-head,
.receipt-detail-foot,
.receipt-basic-panel header,
.receipt-preview-panel header,
.receipt-folder-list th:first-child,
.receipt-folder-list td:first-child {
text-align: left;
}
.receipt-folder-list td:first-child .doc-id {
display: block;
overflow: hidden;
text-overflow: ellipsis;
}
.col-file { width: 22%; }
.col-kind { width: 13%; }
.col-scene { width: 13%; }
.col-money { width: 10%; }
.col-date { width: 12%; }
.col-score { width: 10%; }
.col-status { width: 10%; }
.col-updated { width: 14%; }
.receipt-field-list-head {
display: flex;
align-items: center;
}
.receipt-form-grid input,
.receipt-form-grid textarea,
.receipt-field-row input {
.receipt-key-grid input,
.receipt-edit-field-row input {
width: 100%;
border: 1px solid #d7e0ea;
border-radius: 4px;
@@ -40,31 +55,21 @@
transition: border-color 160ms ease, box-shadow 160ms ease;
}
.receipt-form-grid input,
.receipt-field-row input {
.receipt-key-grid input,
.receipt-edit-field-row input {
height: 36px;
padding: 0 10px;
}
.receipt-form-grid textarea {
resize: vertical;
min-height: 78px;
padding: 9px 10px;
line-height: 1.55;
}
.receipt-form-grid input:focus,
.receipt-form-grid textarea:focus,
.receipt-field-row input:focus {
.receipt-key-grid input:focus,
.receipt-edit-field-row input:focus {
border-color: var(--theme-primary);
box-shadow: 0 0 0 3px rgba(58, 124, 165, 0.14);
outline: none;
}
.apply-btn,
.ghost-btn,
.danger-btn,
.back-btn {
.ghost-btn {
min-height: 36px;
display: inline-flex;
align-items: center;
@@ -76,8 +81,7 @@
white-space: nowrap;
}
.ghost-btn,
.back-btn {
.ghost-btn {
padding: 0 13px;
border: 1px solid #d7e0ea;
background: #fff;
@@ -91,68 +95,67 @@
color: #fff;
}
.danger-btn {
padding: 0 14px;
border: 1px solid #dc2626;
background: #dc2626;
color: #fff;
}
.apply-btn:disabled,
.danger-btn:disabled {
.ghost-btn:disabled {
opacity: .55;
cursor: not-allowed;
}
.receipt-folder-detail {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
grid-template-rows: minmax(0, 1fr) auto;
gap: 12px;
padding: 0;
}
.receipt-detail-head {
gap: 14px;
padding-bottom: 14px;
border-bottom: 1px solid #dbe4ee;
}
.receipt-detail-head h2 {
margin: 4px 0;
color: #0f172a;
font-size: 20px;
line-height: 1.25;
}
.receipt-detail-head p {
margin: 0;
color: #64748b;
font-size: 13px;
}
.assistant-badge {
display: inline-flex;
align-items: center;
min-height: 22px;
padding: 0 8px;
border-radius: 4px;
background: #eef6ff;
color: var(--theme-primary-active);
font-size: 12px;
font-weight: 850;
}
.detail-loading {
.receipt-folder-detail :deep(.detail-scroll) {
min-height: 0;
display: grid;
place-items: center;
align-content: start;
gap: 16px;
padding-right: 4px;
overflow: auto;
}
.receipt-detail-layout {
.receipt-folder-detail :deep(.detail-actions) {
margin-top: 10px;
padding-top: 10px;
}
.receipt-folder-detail :deep(.detail-grid) {
min-height: 0;
display: grid;
grid-template-columns: minmax(360px, 0.95fr) minmax(420px, 1.05fr);
gap: 16px;
padding: 16px 0;
overflow: hidden;
align-items: stretch;
overflow: visible;
}
.receipt-folder-detail :deep(.detail-main),
.receipt-folder-detail :deep(.detail-side) {
min-height: 0;
display: grid;
}
.receipt-folder-detail :deep(.enterprise-detail-card .card-head) {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.receipt-folder-detail :deep(.enterprise-detail-card .card-head h3) {
margin: 0;
color: #0f172a;
font-size: 15px;
font-weight: 850;
}
.receipt-folder-detail :deep(.enterprise-detail-card .card-head p) {
margin: 4px 0 0;
color: #64748b;
font-size: 12px;
}
.receipt-basic-panel,
@@ -165,67 +168,122 @@
}
.receipt-basic-panel {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
display: block;
padding: 14px;
overflow-y: auto;
}
.receipt-basic-panel header,
.receipt-preview-panel header,
.receipt-field-list-head {
justify-content: space-between;
gap: 12px;
}
.receipt-basic-panel header strong,
.receipt-preview-panel header strong,
.receipt-field-list-head strong {
color: #0f172a;
font-size: 15px;
}
.receipt-form-grid {
.receipt-field-list-head small {
color: #64748b;
font-size: 12px;
font-weight: 750;
}
.receipt-key-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin-top: 14px;
}
.receipt-form-grid label {
.receipt-key-field,
.receipt-edit-field-row label {
display: grid;
gap: 6px;
}
.receipt-form-grid label span {
.receipt-key-field span,
.receipt-edit-field-row label span {
color: #64748b;
font-size: 12px;
font-weight: 750;
}
.field-wide {
grid-column: 1 / -1;
.receipt-other-info {
margin-top: 18px;
}
.receipt-field-list {
margin-top: 18px;
.receipt-other-collapse {
border-top: 1px solid #e5edf5;
border-bottom: 0;
}
.receipt-other-collapse :deep(.el-collapse-item__header) {
min-height: 42px;
height: auto;
border-bottom: 1px solid #e5edf5;
background: #fff;
color: #0f172a;
}
.receipt-other-collapse :deep(.el-collapse-item__wrap) {
border-bottom: 0;
}
.receipt-other-collapse :deep(.el-collapse-item__content) {
padding: 12px 0 0;
}
.receipt-collapse-title {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding-right: 10px;
}
.receipt-collapse-title strong {
color: #0f172a;
font-size: 15px;
}
.receipt-collapse-title small {
color: #64748b;
font-size: 12px;
font-weight: 750;
}
.receipt-other-scroll {
max-height: 320px;
display: grid;
gap: 10px;
overflow-y: auto;
padding-right: 4px;
}
.receipt-field-row {
.receipt-edit-field-row {
display: grid;
grid-template-columns: minmax(100px, .6fr) minmax(160px, 1fr) 30px;
gap: 8px;
}
.receipt-field-row button {
display: grid;
place-items: center;
border: 1px solid #d7e0ea;
grid-template-columns: minmax(120px, .72fr) minmax(180px, 1.28fr);
gap: 10px;
padding: 10px;
border: 1px solid #e1e8f0;
border-radius: 4px;
background: #fff;
background: #f8fafc;
}
.receipt-field-empty {
min-height: 64px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
border: 1px dashed #d7e0ea;
border-radius: 4px;
background: #f8fafc;
color: #64748b;
font-size: 13px;
font-weight: 700;
}
.receipt-preview-panel {
@@ -233,20 +291,6 @@
grid-template-rows: auto minmax(0, 1fr);
}
.receipt-preview-panel header {
padding: 14px;
border-bottom: 1px solid #e5edf5;
}
.preview-source-btn {
border: 0;
background: transparent;
color: var(--theme-primary-active);
font-size: 13px;
font-weight: 750;
text-decoration: none;
}
.receipt-preview-box {
min-height: 0;
display: grid;
@@ -281,13 +325,6 @@
font-size: 34px;
}
.receipt-detail-foot {
justify-content: space-between;
gap: 12px;
padding-top: 14px;
border-top: 1px solid #dbe4ee;
}
.associate-step {
display: grid;
gap: 12px;
@@ -350,7 +387,7 @@
}
@media (max-width: 1120px) {
.receipt-detail-layout {
.receipt-folder-detail :deep(.detail-grid) {
grid-template-columns: 1fr;
overflow-y: auto;
}
@@ -361,12 +398,12 @@
}
@media (max-width: 760px) {
.receipt-folder-list,
.receipt-folder-detail {
.receipt-folder-list {
padding: 12px;
}
.receipt-form-grid {
.receipt-key-grid,
.receipt-edit-field-row {
grid-template-columns: 1fr;
}
}

View File

@@ -27,7 +27,7 @@
min-height: 36px;
padding: 0 10px;
border: 1px solid rgba(203, 213, 225, 0.92);
border-radius: 10px;
border-radius: 4px;
background: #fff;
color: #0f172a;
font-size: 12px;
@@ -53,14 +53,14 @@
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
padding: 4px;
border-radius: 12px;
border-radius: 4px;
background: rgba(241, 245, 249, 0.92);
}
.composer-date-mode-btn {
min-height: 34px;
border: 0;
border-radius: 10px;
border-radius: 4px;
background: transparent;
color: #64748b;
font-size: 12px;
@@ -101,7 +101,7 @@
min-height: 36px;
padding: 0 10px;
border: 1px solid rgba(203, 213, 225, 0.92);
border-radius: 10px;
border-radius: 4px;
background: #fff;
color: #0f172a;
font-size: 12px;
@@ -132,7 +132,7 @@
.composer-date-apply-btn {
min-height: 34px;
padding: 0 14px;
border-radius: 10px;
border-radius: 4px;
font-size: 12px;
font-weight: 800;
}
@@ -158,7 +158,7 @@
min-width: 0;
min-height: var(--composer-control-size, 44px);
border: 1px solid rgba(214, 225, 234, 0.95);
border-radius: 999px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.98);
box-shadow:
0 10px 22px rgba(226, 232, 240, 0.24),
@@ -181,7 +181,7 @@
max-width: min(100%, 320px);
min-height: 28px;
padding: 0 8px 0 10px;
border-radius: 999px;
border-radius: 4px;
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.28);
background: linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.14), rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08));
color: var(--theme-primary-active);
@@ -209,7 +209,7 @@
place-items: center;
padding: 0;
border: 0;
border-radius: 999px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.72);
color: #3b82f6;
flex: none;
@@ -224,8 +224,8 @@
gap: 10px;
padding: 14px;
border: 1px solid rgba(226, 232, 240, 0.9);
border-radius: 18px;
background: linear-gradient(180deg, rgba(248, 251, 255, 0.92) 0%, rgba(242, 247, 251, 0.78) 100%);
border-radius: 4px;
background: rgba(248, 251, 255, 0.92);
}
.composer-files-head {
@@ -299,7 +299,7 @@
place-items: center;
padding: 0;
border: 0;
border-radius: 999px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.82);
color: inherit;
flex: none;
@@ -323,7 +323,7 @@
justify-content: space-between;
gap: 10px;
padding: 10px 12px;
border-radius: 14px;
border-radius: 4px;
border: 1px solid rgba(219, 230, 240, 0.92);
background: rgba(255, 255, 255, 0.88);
}
@@ -350,7 +350,7 @@
display: grid;
place-items: center;
border: 0;
border-radius: 10px;
border-radius: 4px;
background: rgba(248, 250, 252, 0.92);
color: #64748b;
}
@@ -404,7 +404,7 @@
display: grid;
place-items: center;
border: 0;
border-radius: 999px;
border-radius: 4px;
flex: none;
}
@@ -501,7 +501,7 @@
display: inline-flex;
align-items: center;
padding: 0 10px;
border-radius: 999px;
border-radius: 4px;
background: var(--theme-primary-soft);
color: var(--theme-primary-active);
font-size: 11px;
@@ -538,7 +538,7 @@
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
border-radius: 4px;
border: 1px solid rgba(203, 213, 225, 0.92);
background: rgba(248, 250, 252, 0.96);
color: #94a3b8;
@@ -606,7 +606,7 @@
display: inline-flex;
align-items: center;
padding: 0 13px;
border-radius: 999px;
border-radius: 4px;
font-size: var(--wb-fs-chip);
font-weight: 800;
}
@@ -654,7 +654,7 @@
.confidence-card {
min-width: 92px;
padding: 10px 12px;
border-radius: 14px;
border-radius: 4px;
background: rgba(250, 252, 252, 0.9);
border: 1px solid rgba(202, 213, 223, 0.9);
box-shadow: 0 8px 18px rgba(203, 213, 225, 0.3);
@@ -698,7 +698,7 @@
display: grid;
gap: 10px;
padding: 14px;
border-radius: 18px;
border-radius: 4px;
border: 1px solid rgba(197, 209, 221, 0.88);
background: rgba(249, 251, 251, 0.88);
box-shadow: 0 10px 20px rgba(226, 232, 240, 0.3);
@@ -745,7 +745,7 @@
gap: 8px;
align-items: start;
padding: 12px;
border-radius: 14px;
border-radius: 4px;
border: 1px solid rgba(206, 216, 226, 0.88);
background: rgba(251, 252, 252, 0.82);
position: relative;
@@ -776,7 +776,7 @@
height: 32px;
display: grid;
place-items: center;
border-radius: 8px;
border-radius: 4px;
background: #f1f5f9;
border: 1px solid transparent;
color: #64748b;
@@ -821,7 +821,7 @@
min-height: 34px;
padding: 0 10px;
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.2);
border-radius: 10px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.96);
color: #0f172a;
font-size: 12px;
@@ -860,7 +860,7 @@
align-items: center;
justify-content: center;
padding: 0 10px;
border-radius: 999px;
border-radius: 4px;
border: 1px solid rgba(203, 213, 225, 0.92);
background: rgba(255, 255, 255, 0.96);
color: #475569;
@@ -889,7 +889,7 @@
display: inline-flex;
align-items: center;
padding: 0 6px;
border-radius: 999px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.92);
border: 1px solid rgba(226, 232, 240, 0.92);
color: #94a3b8;
@@ -953,7 +953,7 @@
gap: 8px;
padding: 12px;
min-height: 66px;
border-radius: 14px;
border-radius: 4px;
border: 1px solid rgba(226, 232, 240, 0.94);
background: rgba(255, 255, 255, 0.68);
cursor: pointer;
@@ -1005,7 +1005,7 @@
align-items: center;
justify-content: center;
padding: 0 12px;
border-radius: 999px;
border-radius: 4px;
border: 1px solid rgba(203, 213, 225, 0.92);
background: rgba(255, 255, 255, 0.94);
color: #475569;
@@ -1045,7 +1045,7 @@
min-height: 66px;
padding: 10px;
border: 1px solid rgba(226, 232, 240, 0.95);
border-radius: 14px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.76);
color: #334155;
text-align: left;
@@ -1063,7 +1063,7 @@
height: 30px;
display: grid;
place-items: center;
border-radius: 10px;
border-radius: 4px;
background: rgba(58, 124, 165, 0.12);
color: #2f6d95;
font-size: 16px;
@@ -1136,7 +1136,7 @@
gap: 8px;
padding: 14px;
border: 1px dashed rgba(203, 213, 225, 0.92);
border-radius: 16px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.52);
}
@@ -1145,7 +1145,7 @@
height: 36px;
display: grid;
place-items: center;
border-radius: 12px;
border-radius: 4px;
background: rgba(240, 244, 248, 0.96);
color: #94a3b8;
font-size: 18px;
@@ -1174,7 +1174,7 @@
gap: 6px;
padding: 0 14px;
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.22);
border-radius: 999px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.94);
color: var(--theme-primary-active);
font-size: 12px;
@@ -1208,7 +1208,7 @@
align-items: center;
gap: 8px;
padding: 4px;
border-radius: 999px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.92);
border: 1px solid rgba(226, 232, 240, 0.92);
white-space: nowrap;
@@ -1226,7 +1226,7 @@
display: grid;
place-items: center;
border: 0;
border-radius: 999px;
border-radius: 4px;
background: rgba(241, 245, 249, 0.96);
color: #334155;
}
@@ -1269,7 +1269,7 @@
display: inline-flex;
align-items: center;
padding: 0 10px;
border-radius: 999px;
border-radius: 4px;
background: var(--theme-primary-soft);
color: var(--theme-primary-active);
font-size: 11px;
@@ -1287,7 +1287,7 @@
display: inline-flex;
align-items: center;
padding: 0 10px;
border-radius: 999px;
border-radius: 4px;
background: rgba(248, 250, 252, 0.94);
border: 1px solid rgba(226, 232, 240, 0.92);
color: #475569;
@@ -1313,9 +1313,9 @@
.review-document-preview-card {
min-height: 168px;
overflow: hidden;
border-radius: 16px;
border-radius: 4px;
border: 1px solid rgba(226, 232, 240, 0.94);
background: linear-gradient(180deg, #f8fafc 0%, #eef2f7 100%);
background: #f8fafc;
}
.review-document-preview-card.clickable {
@@ -1392,7 +1392,7 @@
.review-document-edit-field textarea {
width: 100%;
border: 1px solid rgba(219, 230, 240, 0.96);
border-radius: 14px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.96);
color: #0f172a;
font-size: 13px;
@@ -1423,7 +1423,7 @@
gap: 8px;
align-items: start;
padding: 10px 12px;
border-radius: 14px;
border-radius: 4px;
background: rgba(255, 247, 237, 0.92);
border: 1px solid rgba(253, 186, 116, 0.6);
color: #c2410c;
@@ -1438,13 +1438,13 @@
.insight-card {
padding: 16px;
border: 1px solid #e7eef6;
border-radius: 20px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.95);
box-shadow: 0 14px 24px rgba(241, 245, 249, 0.86);
}
.insight-card.primary {
background: linear-gradient(180deg, #ffffff 0%, #f9fbff 100%);
background: #ffffff;
}
.card-head {
@@ -1474,7 +1474,7 @@
gap: 10px;
padding: 12px 14px;
border: 1px solid rgba(226, 232, 240, 0.92);
border-radius: 16px;
border-radius: 4px;
background: rgba(248, 250, 252, 0.86);
color: #1e293b;
text-align: left;
@@ -1505,7 +1505,7 @@
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
border-radius: 4px;
background: rgba(226, 232, 240, 0.9);
color: #0f172a;
font-size: 12px;

View File

@@ -17,7 +17,7 @@
display: inline-flex;
align-items: center;
padding: 0 10px;
border-radius: 999px;
border-radius: 4px;
font-size: 12px;
font-weight: 800;
white-space: nowrap;
@@ -50,7 +50,7 @@
.metric-item {
padding: 12px 14px;
border-radius: 16px;
border-radius: 4px;
background: #f8fafc;
}
@@ -129,7 +129,7 @@
gap: 12px;
align-items: center;
padding: 12px 14px;
border-radius: 16px;
border-radius: 4px;
background: #f8fafc;
}
@@ -190,7 +190,7 @@
}
.review-flow-panel .flow-step-card {
border-radius: 14px;
border-radius: 4px;
box-shadow: none;
}
@@ -315,7 +315,7 @@
display: grid;
gap: 10px;
padding: 12px 14px;
border-radius: 16px;
border-radius: 4px;
border: 1px solid rgba(226, 232, 240, 0.92);
background: rgba(255, 255, 255, 0.76);
}
@@ -338,7 +338,7 @@
display: inline-flex;
align-items: center;
padding: 0 8px;
border-radius: 999px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.92);
border: 1px solid #e2e8f0;
color: #475569;
@@ -377,7 +377,7 @@
gap: 10px;
align-items: center;
padding: 11px 12px;
border-radius: 14px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.88);
border: 1px solid rgba(226, 232, 240, 0.92);
}
@@ -404,7 +404,7 @@
height: 36px;
display: grid;
place-items: center;
border-radius: 10px;
border-radius: 4px;
background: var(--theme-primary-soft);
color: var(--theme-primary-active);
font-size: 16px;
@@ -457,7 +457,7 @@
align-items: center;
justify-content: center;
padding: 0 10px;
border-radius: 999px;
border-radius: 4px;
font-size: 10px;
font-weight: 800;
white-space: nowrap;
@@ -500,7 +500,7 @@
align-items: center;
justify-content: center;
padding: 0 14px;
border-radius: 12px;
border-radius: 4px;
border: 1px solid #dbe6f0;
background: rgba(255, 255, 255, 0.92);
color: #334155;
@@ -545,7 +545,7 @@
align-items: center;
justify-content: center;
padding: 0 16px;
border-radius: 999px;
border-radius: 4px;
font-size: 12px;
font-weight: 800;
}
@@ -605,7 +605,7 @@
display: grid;
gap: 8px;
padding: 14px 16px;
border-radius: 18px;
border-radius: 4px;
border: 1px solid #dbeafe;
background: linear-gradient(180deg, #f8fbff 0%, #f0f7ff 100%);
}
@@ -626,7 +626,7 @@
display: inline-flex;
align-items: center;
padding: 0 10px;
border-radius: 999px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.86);
color: #0f172a;
font-size: 12px;
@@ -638,7 +638,7 @@
display: grid;
gap: 10px;
padding: 14px 16px;
border-radius: 18px;
border-radius: 4px;
border: 1px solid #e2e8f0;
background: rgba(255, 255, 255, 0.88);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.66);
@@ -662,7 +662,7 @@
display: inline-flex;
align-items: center;
padding: 0 9px;
border-radius: 999px;
border-radius: 4px;
background: #fff;
color: #475569;
font-size: 11px;
@@ -693,7 +693,7 @@
display: inline-flex;
align-items: center;
padding: 0 12px;
border-radius: 999px;
border-radius: 4px;
background: #fff;
color: #0f172a;
font-size: 12px;
@@ -706,7 +706,7 @@
display: grid;
gap: 4px;
padding: 10px 12px;
border-radius: 14px;
border-radius: 4px;
border: 1px solid #e2e8f0;
background: #fff;
}
@@ -762,7 +762,7 @@
.review-claim-card,
.review-document-card {
border: 1px solid #e2e8f0;
border-radius: 16px;
border-radius: 4px;
background: #f8fbff;
}
@@ -829,7 +829,7 @@
.review-slot-meta-item {
padding: 9px 10px;
border-radius: 12px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.82);
border: 1px solid rgba(226, 232, 240, 0.9);
}
@@ -886,8 +886,8 @@
.document-preview {
min-height: 124px;
overflow: hidden;
border-radius: 14px;
background: linear-gradient(180deg, #f8fafc 0%, #eef2f7 100%);
border-radius: 4px;
background: #f8fafc;
border: 1px dashed #dbe3ec;
}
@@ -941,7 +941,7 @@
display: grid;
gap: 8px;
padding: 14px;
border-radius: 16px;
border-radius: 4px;
background: #f8fafc;
}
@@ -978,15 +978,15 @@
display: grid;
gap: 10px;
padding: 16px 18px;
border-radius: 22px;
border-radius: 4px;
border: 1px solid rgba(191, 219, 254, 0.9);
background: linear-gradient(180deg, #ffffff 0%, #f5fbff 100%);
background: #ffffff;
box-shadow: 0 16px 28px rgba(241, 245, 249, 0.9);
}
.recognition-bubble.secondary {
border-color: #e2e8f0;
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
background: #ffffff;
}
.recognition-bubble-label {
@@ -1028,8 +1028,8 @@
gap: 14px;
align-items: start;
padding: 16px;
border-radius: 22px;
background: linear-gradient(180deg, #ffffff 0%, #f7fafc 100%);
border-radius: 4px;
background: #ffffff;
border: 1px solid rgba(226, 232, 240, 0.95);
box-shadow: 0 16px 28px rgba(241, 245, 249, 0.92);
}
@@ -1065,10 +1065,8 @@
.review-confirm-modal {
width: min(720px, calc(100vw - 40px));
border-radius: 24px;
background:
radial-gradient(circle at top right, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08), transparent 28%),
linear-gradient(180deg, #fbfdff 0%, #f6f9fc 100%);
border-radius: 4px;
background: #fbfdff;
box-shadow:
0 24px 80px rgba(15, 23, 42, 0.22),
0 2px 12px rgba(15, 23, 42, 0.08);

View File

@@ -12,10 +12,8 @@
display: grid;
grid-template-rows: auto minmax(0, 1fr);
overflow: hidden;
border-radius: 24px;
background:
radial-gradient(circle at top right, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08), transparent 28%),
linear-gradient(180deg, #fbfdff 0%, #f6f9fc 100%);
border-radius: 4px;
background: #fbfdff;
box-shadow:
0 24px 80px rgba(15, 23, 42, 0.22),
0 2px 12px rgba(15, 23, 42, 0.08);
@@ -51,7 +49,7 @@
max-width: 100%;
max-height: calc(92vh - 170px);
display: block;
border-radius: 20px;
border-radius: 4px;
object-fit: contain;
box-shadow: 0 16px 34px rgba(148, 163, 184, 0.26);
}
@@ -60,7 +58,7 @@
width: 100%;
height: min(78vh, 820px);
border: 0;
border-radius: 18px;
border-radius: 4px;
background: #fff;
}
@@ -92,7 +90,7 @@
gap: 6px;
padding: 0 14px;
border: 1px solid #cbd5e1;
border-radius: 12px;
border-radius: 4px;
background: #ffffff;
color: #334155;
font-size: var(--wb-fs-chip);
@@ -131,7 +129,7 @@
.welcome-card {
padding: 14px;
border-radius: 18px;
border-radius: 4px;
background: #f8fafc;
}
@@ -329,7 +327,7 @@
.assistant-modal,
.assistant-modal-stage {
border-radius: 10px;
border-radius: 4px;
}
.assistant-header {
@@ -352,7 +350,7 @@
.close-btn {
width: 40px;
height: 40px;
border-radius: 14px;
border-radius: 4px;
font-size: 16px;
}

View File

@@ -24,7 +24,7 @@
display: flex;
flex-direction: column;
border: 0;
border-radius: 10px;
border-radius: 4px;
background: transparent;
box-shadow: none;
overflow: hidden;
@@ -142,7 +142,7 @@
background: transparent;
box-shadow: none;
border: 0;
border-radius: 10px;
border-radius: 4px;
backdrop-filter: none;
-webkit-backdrop-filter: none;
overflow: hidden;
@@ -179,9 +179,8 @@
display: grid;
grid-template-rows: auto minmax(0, 1fr);
transform: none;
border-radius: 10px;
background:
linear-gradient(180deg, #f8fbff 0%, #edf5ff 100%);
border-radius: 4px;
background: #f6f9fc;
box-shadow:
0 28px 72px rgba(15, 23, 42, 0.22),
0 10px 28px rgba(15, 23, 42, 0.09),
@@ -217,7 +216,7 @@
align-items: center;
justify-content: center;
padding: 0 14px;
border-radius: 999px;
border-radius: 4px;
background: linear-gradient(135deg, var(--theme-primary), var(--theme-primary-active));
color: #fff;
font-size: var(--wb-fs-badge);
@@ -290,7 +289,7 @@
place-items: center;
padding: 0;
border: 1px solid rgba(248, 113, 113, 0.28);
border-radius: 14px;
border-radius: 4px;
flex: none;
}
@@ -342,7 +341,7 @@
padding: 0;
flex: none;
border: 1px solid rgba(193, 204, 216, 0.92);
border-radius: 14px;
border-radius: 4px;
background: rgba(248, 251, 251, 0.94);
color: #475569;
font-size: 16px;
@@ -375,7 +374,7 @@
align-items: center;
justify-content: center;
padding: 0 10px;
border-radius: 999px;
border-radius: 4px;
background: #f1f5f9;
color: #64748b;
font-size: 11px;
@@ -405,7 +404,7 @@
place-items: center;
padding: 0;
border: 1px solid rgba(203, 213, 225, 0.86);
border-radius: 12px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.92);
color: #475569;
font-size: 16px;
@@ -493,7 +492,7 @@
gap: 7px;
padding: 13px 14px;
border: 1px solid #e5edf5;
border-radius: 12px;
border-radius: 4px;
background: #fff;
box-shadow: 0 8px 22px rgba(226, 232, 240, 0.34);
}
@@ -531,7 +530,7 @@
display: inline-flex;
align-items: center;
padding: 0 9px;
border-radius: 999px;
border-radius: 4px;
background: #f1f5f9;
color: #64748b;
font-size: 11px;
@@ -563,7 +562,6 @@
font-variant-numeric: tabular-nums;
}
.flow-step-tool,
.flow-step-detail,
.flow-step-error {
margin: 0;
@@ -632,7 +630,7 @@
min-width: 0;
min-height: 0;
border: 1px solid rgba(189, 201, 214, 0.74);
border-radius: 16px;
border-radius: 4px;
background: #ffffff;
box-shadow:
0 14px 32px rgba(148, 163, 184, 0.16),
@@ -646,8 +644,7 @@
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
overflow: hidden;
background:
linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
background: #ffffff;
transition:
transform 320ms cubic-bezier(0.22, 1, 0.36, 1),
box-shadow 320ms cubic-bezier(0.22, 1, 0.36, 1);
@@ -715,7 +712,7 @@
gap: 6px;
padding: 0 13px;
border: 1px solid rgba(219, 230, 240, 0.9);
border-radius: 8px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.95);
color: #334155;
font-size: 13px;
@@ -812,7 +809,7 @@
max-width: min(100%, 720px);
padding: 12px 14px;
border: 1px solid rgba(210, 220, 230, 0.94);
border-radius: 20px;
border-radius: 4px;
background: rgba(253, 254, 254, 0.94);
color: #24324a;
font-size: var(--wb-fs-bubble);
@@ -940,7 +937,7 @@
margin-top: 12px;
overflow: hidden;
border: 1px solid #d7e4f2;
border-radius: 8px;
border-radius: 4px;
background: #ffffff;
color: #334155;
font-size: var(--wb-fs-bubble);
@@ -1113,7 +1110,7 @@
.message-answer-markdown :deep(blockquote) {
padding: 8px 10px;
border-left: 3px solid #cbd5e1;
border-radius: 0 10px 10px 0;
border-radius: 0 4px 4px 0;
background: rgba(248, 250, 252, 0.84);
color: #475569;
}
@@ -1123,7 +1120,7 @@
padding: 12px 14px;
border: 1px solid #dbe4ee;
border-left: 4px solid #2563eb;
border-radius: 8px;
border-radius: 4px;
background: #f8fafc;
color: #334155;
}
@@ -1152,7 +1149,7 @@
.message-answer-markdown :deep(code) {
padding: 2px 6px;
border-radius: 6px;
border-radius: 4px;
background: #e2e8f0;
font-size: 12px;
}
@@ -1160,7 +1157,7 @@
.message-answer-markdown :deep(pre) {
overflow-x: auto;
padding: 12px;
border-radius: 14px;
border-radius: 4px;
background: #0f172a;
color: #e2e8f0;
}
@@ -1218,7 +1215,7 @@
margin: 8px 0 10px;
overflow-x: auto;
border: 1px solid #dbe4ee;
border-radius: 10px;
border-radius: 4px;
background: #fff;
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.05);
}
@@ -1262,13 +1259,6 @@
border-bottom: 0;
}
.message-meta-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.message-suggested-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -1276,7 +1266,7 @@
margin-top: 14px;
padding: 10px;
border: 1px solid rgba(203, 213, 225, 0.72);
border-radius: 14px;
border-radius: 4px;
background:
linear-gradient(180deg, rgba(248, 250, 252, 0.92), rgba(255, 255, 255, 0.98));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.78);
@@ -1291,7 +1281,7 @@
gap: 10px;
padding: 12px 11px;
border: 1px solid rgba(203, 213, 225, 0.8);
border-radius: 10px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.9);
color: #0f172a;
text-align: left;
@@ -1309,7 +1299,7 @@
height: 34px;
display: grid;
place-items: center;
border-radius: 10px;
border-radius: 4px;
background: #f1f5f9;
color: var(--theme-primary-active);
font-size: 18px;
@@ -1396,7 +1386,6 @@
opacity: 1;
}
.message-meta-chip,
.capability-chip,
.risk-chip,
.message-risk-chip,
@@ -1405,35 +1394,16 @@
display: inline-flex;
align-items: center;
padding: 0 10px;
border-radius: 999px;
border-radius: 4px;
font-size: var(--wb-fs-chip);
font-weight: 800;
}
.message-meta-chip,
.capability-chip {
background: #eef6ff;
color: #1d4ed8;
}
.message-meta-chip.high {
background: #fef2f2;
color: #dc2626;
border: 1px solid #fecaca;
}
.message-meta-chip.medium {
background: #fffbeb;
color: #b45309;
border: 1px solid #fde68a;
}
.message-meta-chip.low {
background: #eff6ff;
color: #1d4ed8;
border: 1px solid #bfdbfe;
}
.risk-chip,
.message-risk-chip {
background: #fff1f2;
@@ -1460,7 +1430,7 @@
.message-citation-disclosure {
overflow: hidden;
border: 1px solid #dbe4ee;
border-radius: 16px;
border-radius: 4px;
background: #fbfdff;
}
@@ -1531,7 +1501,7 @@
display: inline-flex;
align-items: center;
padding: 0 10px;
border-radius: 999px;
border-radius: 4px;
font-size: 11px;
font-weight: 800;
background: #eef2ff;
@@ -1571,7 +1541,7 @@
gap: 10px;
padding: 10px 12px;
border: 1px solid #dbe4ee;
border-radius: 14px;
border-radius: 4px;
background: #fbfdff;
cursor: pointer;
font: inherit;
@@ -1646,7 +1616,7 @@
display: inline-flex;
align-items: center;
padding: 0 8px;
border-radius: 999px;
border-radius: 4px;
font-size: 10px;
font-weight: 800;
background: #f1f5f9;
@@ -1698,7 +1668,7 @@
gap: 5px;
padding: 0 8px;
border: 1px solid #fecaca;
border-radius: 999px;
border-radius: 4px;
background: #fff7ed;
color: #9a3412;
font: inherit;
@@ -1763,7 +1733,7 @@
align-items: center;
justify-content: center;
border: 1px solid #dbe4ee;
border-radius: 999px;
border-radius: 4px;
background: #fff;
color: #475569;
transition: border-color 0.2s ease, color 0.2s ease, background 0.2s ease;
@@ -1808,7 +1778,7 @@
gap: 10px;
padding: 0 14px;
border: 1px dashed #dbe4ee;
border-radius: 16px;
border-radius: 4px;
color: #64748b;
font-size: 12px;
font-weight: 700;
@@ -1845,7 +1815,7 @@
.action-card {
padding: 12px 14px;
border: 1px solid #e2e8f0;
border-radius: 16px;
border-radius: 4px;
background: #f8fbff;
}
@@ -1887,8 +1857,8 @@
margin-top: 12px;
padding: 12px 14px;
border: 1px solid #dbe3ec;
border-radius: 16px;
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
border-radius: 4px;
background: #ffffff;
}
.draft-preview header {
@@ -1931,7 +1901,7 @@
gap: 6px;
padding: 0 10px;
border: 0;
border-radius: 999px;
border-radius: 4px;
background: #f1f5f9;
color: #475569;
font-size: 12px;
@@ -1990,7 +1960,7 @@
gap: 12px;
padding: 14px;
border: 1px solid rgba(203, 213, 225, 0.92);
border-radius: 16px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.98);
box-shadow:
0 18px 40px rgba(15, 23, 42, 0.16),
@@ -2007,7 +1977,7 @@
gap: 12px;
padding: 14px;
border: 1px solid rgba(203, 213, 225, 0.92);
border-radius: 16px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.98);
box-shadow:
0 18px 40px rgba(15, 23, 42, 0.16),

View File

@@ -653,6 +653,32 @@
font-weight: 850;
}
.related-application-facts {
margin-top: 0;
}
.related-application-empty {
display: grid;
gap: 6px;
padding: 13px 14px;
border: 1px solid #e2e8f0;
border-radius: 4px;
background: #f8fafc;
}
.related-application-empty strong {
color: #334155;
font-size: 13px;
font-weight: 850;
}
.related-application-empty p {
margin: 0;
color: #64748b;
font-size: 12px;
line-height: 1.6;
}
.detail-note-editor {
display: grid;
gap: 10px;

View File

@@ -160,7 +160,7 @@
</template>
<template #table>
<table>
<table class="audit-asset-table">
<thead>
<tr>
<th>{{ tableColumns.name }}</th>

View File

@@ -67,6 +67,67 @@
@report-saved="emit('report-saved', $event)"
/>
<ConfirmDialog
:open="riskRuleEditOpen"
badge="规则维护"
badge-tone="info"
:title="riskRuleEditMode === 'revision' ? '创建修订版本' : '编辑风险规则'"
:description="riskRuleEditMode === 'revision' ? '已上线规则不会被直接覆盖,系统会先创建一个新的修订草稿。' : '未上线规则可以直接调整标题、费用领域、附件要求和自然语言描述。'"
cancel-text="取消"
:confirm-text="riskRuleEditMode === 'revision' ? '创建修订' : '保存草稿'"
busy-text="保存中..."
confirm-tone="primary"
confirm-icon="mdi mdi-content-save-outline"
:busy="riskRuleEditBusy"
:close-on-mask="!riskRuleEditBusy"
@close="emit('close-risk-rule-edit')"
@confirm="emit('submit-risk-rule-edit')"
>
<div class="risk-rule-create-form">
<label>
<span>费用领域</span>
<EnterpriseSelect
v-model="riskRuleEditForm.expense_category"
:options="riskRuleExpenseCategoryOptions"
:disabled="riskRuleEditBusy"
/>
</label>
<label>
<span>是否上传附件</span>
<EnterpriseSelect
v-model="riskRuleEditForm.requires_attachment"
:options="riskRuleAttachmentOptions"
:disabled="riskRuleEditBusy"
/>
</label>
<label class="span-2">
<span>规则标题</span>
<input
v-model="riskRuleEditForm.rule_title"
:disabled="riskRuleEditBusy"
maxlength="80"
placeholder="例如:差旅目的地与票据城市一致性校验"
/>
</label>
<label class="span-2">
<span>自然语言规则</span>
<textarea
v-model="riskRuleEditForm.natural_language"
:disabled="riskRuleEditBusy"
placeholder="请用自然语言描述风险判断流程、字段、例外条件和处理动作。"
></textarea>
</label>
<label v-if="riskRuleEditMode === 'revision'" class="span-2">
<span>修订原因</span>
<textarea
v-model="riskRuleEditForm.change_reason"
:disabled="riskRuleEditBusy"
placeholder="请说明本次修订要解决的规则问题或业务变化。"
></textarea>
</label>
</div>
</ConfirmDialog>
<ConfirmDialog
:open="riskRuleDeleteOpen"
badge="删除规则"
@@ -239,6 +300,10 @@ const props = defineProps({
riskRuleExpenseCategoryOptions: { type: Array, default: () => [] },
riskRuleAttachmentOptions: { type: Array, default: () => [] },
riskRuleTestOpen: { type: Boolean, default: false },
riskRuleEditOpen: { type: Boolean, default: false },
riskRuleEditMode: { type: String, default: 'draft' },
riskRuleEditForm: { type: Object, default: () => ({}) },
riskRuleEditBusy: { type: Boolean, default: false },
riskRuleDeleteOpen: { type: Boolean, default: false },
riskRuleReturnOpen: { type: Boolean, default: false },
riskRulePublishOpen: { type: Boolean, default: false },
@@ -261,6 +326,8 @@ const emit = defineEmits([
'submit-risk-rule-create',
'close-risk-rule-test',
'report-saved',
'close-risk-rule-edit',
'submit-risk-rule-edit',
'close-delete-risk-rule',
'delete-selected-risk-rule',
'close-return-risk-rule',

View File

@@ -135,7 +135,10 @@
@click="emit('open-employee-detail', employee)"
>
<td>
<strong class="doc-id">{{ employee.name }}</strong>
<div class="digital-skill-cell">
<span class="digital-skill-avatar" :class="employee.badgeTone">{{ employee.short }}</span>
<strong class="doc-id">{{ employee.name }}</strong>
</div>
</td>
<td><span class="doc-kind-tag application">{{ employee.skillCategory }}</span></td>
<td>{{ employee.owner }}</td>

View File

@@ -0,0 +1,404 @@
<template>
<article v-if="visible" class="detail-card panel run-products-card">
<div class="card-head">
<div>
<h3>本次任务产物</h3>
<p>{{ productSubtitle }}</p>
</div>
<span class="edit-badge">{{ productBadge }}</span>
</div>
<div v-if="loading" class="run-product-state">
<i class="mdi mdi-loading mdi-spin"></i>
<span>正在读取本次运行产物</span>
</div>
<div v-else-if="errorMessage" class="run-product-state error">
<i class="mdi mdi-alert-circle-outline"></i>
<span>{{ errorMessage }}</span>
</div>
<template v-else>
<div v-if="metrics.length" class="json-risk-meta-grid run-product-meta-grid">
<div
v-for="item in metrics"
:key="item.label"
class="json-risk-meta-item"
>
<span class="json-risk-meta-label">{{ item.label }}</span>
<span class="json-risk-meta-value">{{ item.value }}</span>
</div>
</div>
<section v-if="productKind === 'risk_graph'" class="run-product-section">
<div class="run-product-section-head">
<h4>风险观察</h4>
<span>{{ observations.length }} </span>
</div>
<div v-if="observations.length" class="run-product-observation-list">
<article
v-for="item in observations"
:key="item.observationKey || item.id"
class="run-product-observation"
:class="{ 'is-expanded': isActiveObservation(item) }"
role="button"
tabindex="0"
@click="toggleObservation(item)"
@keydown.enter.prevent="toggleObservation(item)"
@keydown.space.prevent="toggleObservation(item)"
>
<div class="run-product-observation-head">
<span class="risk-level-pill" :class="item.riskLevel">
{{ formatRiskLevel(item.riskLevel) }}
</span>
<strong>{{ item.title || formatSignal(item.riskSignal) }}</strong>
<b>{{ item.riskScore }}</b>
</div>
<p>{{ item.description || '暂无风险描述。' }}</p>
<div class="run-product-tags">
<span>单据{{ item.claimNo || item.claimId || '-' }}</span>
<span>证据{{ (item.evidence || []).length }}</span>
<span>图谱关系{{ observationGraphCount(item) }}</span>
<span>{{ item.algorithmVersion || '未记录算法版本' }}</span>
</div>
<div
v-if="isActiveObservation(item)"
class="run-product-observation-detail"
>
<section v-if="scoreRows(item).length">
<span>贡献分</span>
<div class="run-product-score-list">
<div v-for="score in scoreRows(item)" :key="score.key">
<em>{{ score.label }}</em>
<i><b :style="{ width: score.width }"></b></i>
<strong>{{ score.value }}</strong>
</div>
</div>
</section>
<section v-if="evidenceRows(item).length">
<span>关键证据</span>
<ul class="run-product-evidence-list">
<li v-for="evidence in evidenceRows(item)" :key="evidence.key">
<strong>{{ evidence.title }}</strong>
<p>{{ evidence.detail }}</p>
</li>
</ul>
</section>
<section v-if="observationGraphItems(item).length">
<span>异常关系</span>
<div class="run-product-tags">
<span
v-for="graphItem in observationGraphItems(item)"
:key="graphItem"
>
{{ graphItem }}
</span>
</div>
</section>
<section v-if="observationDecisionItems(item).length">
<span>制度与建议</span>
<div class="run-product-tags">
<span
v-for="decisionItem in observationDecisionItems(item)"
:key="decisionItem"
>
{{ decisionItem }}
</span>
</div>
</section>
</div>
</article>
</div>
<p v-else class="run-product-inline-empty">本次运行没有生成新的风险观察</p>
</section>
<section v-else-if="productKind === 'employee_profile'" class="run-product-section">
<div class="run-product-section-head">
<h4>画像快照</h4>
<span>{{ summary.algorithm_version || '算法版本未记录' }}</span>
</div>
<p class="run-product-copy">
本次产物已写入员工行为画像快照用于后续风险图谱审批复核和员工行为基线分析
</p>
</section>
<section v-else-if="productKind === 'knowledge'" class="run-product-section">
<div class="run-product-section-head">
<h4>知识制度整理</h4>
<span>{{ summary.status || '已提交' }}</span>
</div>
<p class="run-product-copy">
{{ summary.summary || '知识制度整理任务已提交,后台会继续归纳文档并刷新知识索引。' }}
</p>
<div class="run-product-tags">
<span>目录{{ summary.folder || routeJson.folder || '全部知识库' }}</span>
<span>文档{{ documentCount }} </span>
<span v-if="summary.agent_run_id">子任务{{ summary.agent_run_id }}</span>
</div>
</section>
</template>
</article>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { fetchRunRiskObservations } from '../../services/riskObservations.js'
import {
extractWorkRecordToolSummary,
resolveWorkRecordProductKind,
resolveWorkRecordTaskLabel
} from '../../views/scripts/digitalEmployeeWorkRecordsModel.js'
const props = defineProps({
run: { type: Object, default: null }
})
const observations = ref([])
const activeObservationKey = ref('')
const loading = ref(false)
const errorMessage = ref('')
let loadSequence = 0
const routeJson = computed(() =>
props.run?.route_json && typeof props.run.route_json === 'object'
? props.run.route_json
: {}
)
const runId = computed(() => String(props.run?.run_id || '').trim())
const productKind = computed(() => resolveWorkRecordProductKind(props.run))
const taskLabel = computed(() => resolveWorkRecordTaskLabel(props.run) || '数字员工任务')
const summary = computed(() => extractWorkRecordToolSummary(props.run))
const visible = computed(() =>
Boolean(productKind.value || loading.value || errorMessage.value)
)
const productSubtitle = computed(() => {
if (productKind.value === 'risk_graph') {
return '展示本次巡检生成的风险观察、证据数量和图谱关系计数。'
}
if (productKind.value === 'employee_profile') {
return '展示本次画像巡检写入的员工画像快照摘要。'
}
if (productKind.value === 'knowledge') {
return '展示本次知识制度整理任务的入队结果与处理范围。'
}
return '展示本次数字员工任务产生的结构化结果。'
})
const productBadge = computed(() => {
if (productKind.value === 'risk_graph') {
return '风险观察'
}
if (productKind.value === 'employee_profile') {
return '画像快照'
}
if (productKind.value === 'knowledge') {
return '知识整理'
}
return taskLabel.value
})
const documentCount = computed(() =>
Array.isArray(summary.value.document_ids) ? summary.value.document_ids.length : 0
)
const metrics = computed(() => {
const payload = summary.value || {}
if (productKind.value === 'risk_graph') {
return [
buildMetric('扫描单据', payload.scanned_claim_count),
buildMetric('风险观察', payload.risk_observation_count ?? observations.value.length),
buildMetric('图谱节点', payload.graph_node_count),
buildMetric('图谱关系', payload.graph_edge_count)
]
}
if (productKind.value === 'employee_profile') {
return [
buildMetric('目标员工', payload.target_employee_count),
buildMetric('画像快照', payload.snapshot_count),
buildMetric('重点关注', payload.high_attention_employee_count),
buildMetric('窗口期', formatWindowDays(payload.window_days))
]
}
if (productKind.value === 'knowledge') {
return [
buildMetric('处理目录', payload.folder || routeJson.value.folder || '全部知识库'),
buildMetric('目标文档', documentCount.value),
buildMetric('复用任务', payload.reused ? '是' : '否'),
buildMetric('后台 Run ID', payload.agent_run_id || '-')
]
}
return []
})
watch(
() => [runId.value, productKind.value],
() => {
void loadProducts()
},
{ immediate: true }
)
async function loadProducts() {
const currentRunId = runId.value
const sequence = ++loadSequence
errorMessage.value = ''
if (productKind.value !== 'risk_graph') {
observations.value = []
loading.value = false
return
}
if (!currentRunId) {
observations.value = []
loading.value = false
return
}
loading.value = true
try {
const payload = await fetchRunRiskObservations(currentRunId)
if (sequence !== loadSequence) {
return
}
observations.value = payload
activeObservationKey.value = payload[0]?.observationKey || payload[0]?.id || ''
} catch (error) {
if (sequence === loadSequence) {
observations.value = []
activeObservationKey.value = ''
errorMessage.value = error?.message || '本次运行产物加载失败。'
}
} finally {
if (sequence === loadSequence) {
loading.value = false
}
}
}
function buildMetric(label, value) {
return {
label,
value: formatMetricValue(value)
}
}
function formatMetricValue(value) {
if (Array.isArray(value)) {
return value.length ? value.join(' / ') : '-'
}
if (value === null || value === undefined || value === '') {
return '-'
}
return String(value)
}
function formatWindowDays(value) {
const days = Array.isArray(value) ? value : []
return days.length ? days.map((item) => `${item}`).join(' / ') : '-'
}
function observationGraphCount(item) {
return (item.graphNodeKeys || []).length + (item.graphEdgeKeys || []).length
}
function observationKey(item) {
return String(item?.observationKey || item?.id || '').trim()
}
function isActiveObservation(item) {
return observationKey(item) === activeObservationKey.value
}
function toggleObservation(item) {
const key = observationKey(item)
activeObservationKey.value = activeObservationKey.value === key ? '' : key
}
function scoreRows(item) {
const scores = item?.contributionScores || {}
return Object.entries(scores).map(([key, value]) => {
const numericValue = Math.max(0, Math.min(Number(value || 0), 100))
return {
key,
label: formatScoreLabel(key),
value: Math.round(numericValue),
width: `${numericValue}%`
}
})
}
function evidenceRows(item) {
return (item?.evidence || []).slice(0, 4).map((evidence, index) => ({
key: `${evidence.code || evidence.title || index}`,
title: String(evidence.title || evidence.code || evidence.source || `证据 ${index + 1}`).trim(),
detail: String(evidence.detail || evidence.message || evidence.summary || '').trim() || '已记录证据。'
}))
}
function observationGraphItems(item) {
return [
...(item?.graphNodeKeys || []).slice(0, 5).map((value) => `节点:${formatChipValue(value)}`),
...(item?.graphEdgeKeys || []).slice(0, 5).map((value) => `关系:${formatChipValue(value)}`)
].filter(Boolean)
}
function observationDecisionItems(item) {
return [
...(item?.policyRefs || []).slice(0, 4).map((value) => `制度:${formatChipValue(value)}`),
...(item?.similarCaseClaimIds || []).slice(0, 4).map((value) => `相似案例:${formatChipValue(value)}`),
...formatRecordItems(item?.decisionTrace, '建议').slice(0, 4)
].filter(Boolean)
}
function formatScoreLabel(value) {
const labels = {
S_rule: '规则命中',
S_anomaly: '画像偏离',
S_graph: '图谱异常',
S_policy: '制度约束',
S_history: '历史反馈'
}
return labels[value] || value
}
function formatChipValue(value) {
if (typeof value === 'string') {
return value.trim()
}
if (value && typeof value === 'object') {
return String(value.key || value.edge_key || value.node_key || JSON.stringify(value)).trim()
}
return String(value || '').trim()
}
function formatRecordItems(value, prefix) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return []
}
return Object.entries(value)
.map(([key, item]) => `${prefix}${key}=${formatChipValue(item)}`)
.filter(Boolean)
}
function formatRiskLevel(value) {
const labels = {
critical: '重大风险',
high: '高风险',
medium: '中风险',
low: '低风险'
}
return labels[String(value || '').trim()] || '未知风险'
}
function formatSignal(value) {
const labels = {
duplicate_invoice: '重复发票',
split_billing: '拆分报销',
frequent_small_claims: '高频小额',
location_mismatch: '地点不一致',
amount_outlier: '金额异常',
preapproval_absent: '缺少事前申请'
}
const normalized = String(value || '').trim()
return labels[normalized] || normalized.replace(/_/g, ' ') || '未知风险'
}
</script>
<style scoped src="../../assets/styles/components/digital-employee-run-products.css"></style>

View File

@@ -229,6 +229,8 @@
</p>
</article>
<DigitalEmployeeRunProducts :run="selectedRunDetail" />
<!-- 卡片3工具调用 -->
<article class="detail-card panel">
<div class="card-head">
@@ -283,6 +285,7 @@
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import AuditPickerFilter from './AuditPickerFilter.vue'
import DigitalEmployeeRunProducts from './DigitalEmployeeRunProducts.vue'
import EnterpriseListPage from '../shared/EnterpriseListPage.vue'
import TableLoadingState from '../shared/TableLoadingState.vue'
import { fetchAgentRunDetail, fetchAgentRuns } from '../../services/agentAssets.js'
@@ -308,6 +311,9 @@ defineOptions({
name: 'DigitalEmployeeWorkRecords'
})
const props = defineProps({
focusRunId: { type: String, default: '' }
})
const emit = defineEmits(['summary-change', 'detail-open-change', 'detail-topbar-change'])
const { toast } = useToast()
@@ -559,6 +565,35 @@ function openWorkRecordDetail(run) {
void loadWorkRecordDetail(runId)
}
async function openWorkRecordById(runId) {
const normalizedRunId = String(runId || '').trim()
if (!normalizedRunId) {
return
}
selectedRunId.value = normalizedRunId
selectedRunDetail.value = runs.value.find((run) => run.run_id === normalizedRunId) || {
run_id: normalizedRunId,
source: 'schedule',
status: 'running',
route_json: {},
result_summary: '正在读取本次运行详情。'
}
detailOpen.value = true
if (!runs.value.some((run) => run.run_id === normalizedRunId)) {
await loadWorkRecords(false)
const refreshedRun = runs.value.find((run) => run.run_id === normalizedRunId)
if (refreshedRun && selectedRunId.value === normalizedRunId) {
selectedRunDetail.value = refreshedRun
}
}
if (selectedRunId.value === normalizedRunId) {
await loadWorkRecordDetail(normalizedRunId)
}
}
function reloadSelectedDetail() {
if (!selectedRunId.value) {
return
@@ -572,6 +607,16 @@ function closeWorkRecordDetail() {
detailError.value = ''
}
watch(
() => props.focusRunId,
(runId) => {
if (String(runId || '').trim()) {
void openWorkRecordById(runId)
}
},
{ immediate: true }
)
function startPolling() {
stopPolling()
pollTimer = window.setInterval(() => {

View File

@@ -61,28 +61,41 @@
</section>
<section class="profile-panel profile-radar-panel" aria-label="行为雷达图">
<div class="profile-section-title">
<div class="profile-section-title profile-radar-title">
<div>
<span>行为雷达</span>
<small>分数越高行为特征越明显</small>
<small>{{ currentRadarView.description }}</small>
</div>
<ElSelect
v-model="selectedRadarView"
class="profile-radar-view-select"
size="small"
aria-label="切换行为雷达视角"
>
<ElOption
v-for="option in radarViewOptions"
:key="option.value"
:label="option.shortLabel"
:value="option.value"
/>
</ElSelect>
</div>
<div v-if="radarDimensions.length" class="profile-radar-layout">
<div v-if="filteredRadarDimensions.length" class="profile-radar-layout">
<RadarChart
:key="radarRenderKey"
class="profile-radar-chart"
:items="radarDimensions"
label="用户画像评分"
:items="filteredRadarDimensions"
:label="`${currentRadarView.shortLabel}评分`"
/>
</div>
<p v-else class="profile-panel-empty profile-radar-empty">暂无可展示的雷达维度</p>
<div v-if="tags.length" class="profile-behavior-tags" aria-label="行为标签">
<div :class="['profile-behavior-tags', { 'is-empty': !filteredBehaviorTags.length }]" :aria-hidden="!filteredBehaviorTags.length" aria-label="行为标签">
<span class="profile-behavior-tags-title">行为标签</span>
<div class="profile-behavior-tag-list">
<div v-if="filteredBehaviorTags.length" class="profile-behavior-tag-list">
<span
v-for="tag in tags"
v-for="tag in filteredBehaviorTags"
:key="`behavior-${tag.code}`"
:class="[
'profile-behavior-tag',
@@ -137,10 +150,12 @@
import { computed, nextTick, ref, watch } from 'vue'
import { ElButton } from 'element-plus/es/components/button/index.mjs'
import { ElDialog } from 'element-plus/es/components/dialog/index.mjs'
import { ElOption, ElSelect } from 'element-plus/es/components/select/index.mjs'
import { ElTag } from 'element-plus/es/components/tag/index.mjs'
import ExpenseProfileTagPager from './ExpenseProfileTagPager.vue'
import RadarChart from '../charts/RadarChart.vue'
import { USER_PROFILE_RADAR_VIEW_OPTIONS, filterUserProfileRadarDimensions, filterUserProfileTagsByRadarView } from '../../utils/employeeProfileViewModel.js'
const props = defineProps({
visible: { type: Boolean, default: false },
@@ -148,6 +163,7 @@ const props = defineProps({
metrics: { type: Array, default: () => [] },
tags: { type: Array, default: () => [] },
radarDimensions: { type: Array, default: () => [] },
radarDefaultView: { type: String, default: 'financial_risk' },
operations: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
errorMessage: { type: String, default: '' },
@@ -156,6 +172,11 @@ const props = defineProps({
const emit = defineEmits(['close'])
const radarRenderKey = ref(0)
const selectedRadarView = ref(props.radarDefaultView)
const radarViewOptions = USER_PROFILE_RADAR_VIEW_OPTIONS
const currentRadarView = computed(() => radarViewOptions.find((option) => option.value === selectedRadarView.value) || radarViewOptions[0])
const filteredRadarDimensions = computed(() => filterUserProfileRadarDimensions(props.radarDimensions, selectedRadarView.value))
const filteredBehaviorTags = computed(() => filterUserProfileTagsByRadarView(props.tags, selectedRadarView.value))
function emitClose() {
emit('close')
@@ -225,6 +246,27 @@ watch(
})
}
)
watch(
() => props.radarDefaultView,
(value) => {
selectedRadarView.value = value || 'financial_risk'
},
{ immediate: true }
)
watch(
[filteredRadarDimensions, selectedRadarView],
async () => {
if (!props.visible) {
return
}
await nextTick()
scheduleRadarFrame(() => {
radarRenderKey.value += 1
})
}
)
</script>
<style scoped>
@@ -469,6 +511,21 @@ watch(
font-weight: 850;
}
.profile-radar-title { align-items: flex-start; }
.profile-radar-view-select {
width: 118px;
flex: 0 0 118px;
}
.profile-radar-view-select :deep(.el-select__wrapper) {
min-height: 28px;
border-radius: 4px;
box-shadow: 0 0 0 1px #cbd5e1 inset;
color: #334155;
font-size: 12px;
font-weight: 750;
}
.profile-operation-list {
display: grid;
gap: 8px;
@@ -536,9 +593,12 @@ watch(
display: grid;
gap: 8px;
padding-top: 10px;
min-height: 59px;
border-top: 1px solid #e8eef5;
}
.profile-behavior-tags.is-empty { visibility: hidden; }
.profile-behavior-tags-title {
color: #0f172a;
font-size: 12px;

View File

@@ -27,9 +27,35 @@
maxlength="1000"
rows="2"
placeholder="请输入费用申请、报销问题、预算查询或制度问答..."
:readonly="isComposerPending"
@keydown.enter.prevent="handleWorkbenchEnter"
/>
<div
v-if="composerPendingLabel"
class="assistant-intent-status"
role="status"
aria-live="polite"
>
<i class="mdi mdi-loading mdi-spin"></i>
<span>{{ composerPendingLabel }}</span>
</div>
<div v-if="workbenchDateTagLabel" class="workbench-date-chip-row">
<span class="workbench-date-chip">
<i class="mdi mdi-calendar-check"></i>
<span>{{ workbenchDateTagLabel }}</span>
<button
type="button"
aria-label="移除日期"
:disabled="Boolean(pendingAction)"
@click="removeWorkbenchDateTag"
>
<i class="mdi mdi-close"></i>
</button>
</span>
</div>
<div class="composer-toolbar">
<button
type="button"
@@ -42,16 +68,70 @@
<i class="mdi mdi-paperclip"></i>
</button>
<button
type="button"
class="composer-related-button"
:disabled="Boolean(pendingAction)"
@click="triggerFileUpload"
>
<i class="mdi mdi-source-branch"></i>
<span>关联单据</span>
<i class="mdi mdi-chevron-down"></i>
</button>
<div class="workbench-date-anchor">
<button
type="button"
class="composer-icon-button"
:class="{ active: workbenchDatePickerOpen }"
title="选择日期"
aria-label="选择日期"
:aria-expanded="workbenchDatePickerOpen"
:disabled="Boolean(pendingAction)"
@click.stop="toggleWorkbenchDatePicker"
>
<i class="mdi mdi-calendar-range"></i>
</button>
<div
v-if="workbenchDatePickerOpen"
class="composer-date-popover"
role="dialog"
aria-label="日期选择"
@click.stop
>
<div class="composer-date-mode-tabs">
<button
type="button"
class="composer-date-mode-btn"
:class="{ active: workbenchDateMode === 'single' }"
@click="setWorkbenchDateMode('single')"
>
当天
</button>
<button
type="button"
class="composer-date-mode-btn"
:class="{ active: workbenchDateMode === 'range' }"
@click="setWorkbenchDateMode('range')"
>
时间段
</button>
</div>
<div v-if="workbenchDateMode === 'single'" class="composer-date-fields">
<label class="composer-date-field">
<span>日期</span>
<input v-model="workbenchSingleDate" type="date" @change="handleWorkbenchDateInputChange('single')" />
</label>
</div>
<div v-else class="composer-date-fields composer-date-fields-range">
<label class="composer-date-field">
<span>开始</span>
<input v-model="workbenchRangeStartDate" type="date" @change="handleWorkbenchDateInputChange('range-start')" />
</label>
<span class="composer-date-range-sep"></span>
<label class="composer-date-field">
<span>结束</span>
<input v-model="workbenchRangeEndDate" type="date" :min="workbenchRangeStartDate" @change="handleWorkbenchDateInputChange('range-end')" />
</label>
</div>
<p v-if="workbenchDateMode === 'range' && !workbenchCanApplyDateSelection" class="composer-date-hint">
请确认结束日期不早于开始日期
</p>
</div>
</div>
<span class="composer-count">{{ assistantDraft.length }}/1000</span>
@@ -59,10 +139,10 @@
type="button"
class="composer-send-button"
:disabled="Boolean(pendingAction)"
:aria-label="pendingAction === 'expense' ? '处理中' : expenseActionLabel"
:aria-label="composerPendingLabel || expenseActionLabel"
@click="handleExpenseConversationAction"
>
<i :class="pendingAction === 'expense' ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-send'"></i>
<i :class="pendingAction ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-send'"></i>
</button>
</div>
</div>
@@ -98,7 +178,7 @@
type="button"
class="capability-card panel"
:class="`capability-card--${item.tone}`"
@click="openPromptAssistant(item.prompt)"
@click="openCapabilityAssistant(item)"
>
<span class="capability-icon"><i :class="item.icon"></i></span>
<span class="capability-copy">
@@ -263,6 +343,7 @@
:metrics="expenseProfileModalMetrics"
:tags="expenseProfileTags"
:radar-dimensions="expenseProfileRadarDimensions"
:radar-default-view="expenseProfileRadarDefaultView"
:operations="expenseProfileOperations"
:loading="employeeProfileLoading"
:error-message="employeeProfileError"
@@ -280,6 +361,7 @@ import WorkbenchListIcon from '../shared/WorkbenchListIcon.vue'
import homepageBackground from '../../assets/homepage_backgraound.png'
import { useSystemState } from '../../composables/useSystemState.js'
import { useToast } from '../../composables/useToast.js'
import { useWorkbenchComposerDate } from '../../composables/useWorkbenchComposerDate.js'
import {
assistantCapabilities,
buildExpenseStatItems,
@@ -295,15 +377,16 @@ import {
ASSISTANT_SESSION_SNAPSHOT_EVENT,
hasAssistantSessionSnapshot
} from '../../utils/assistantSessionSnapshot.js'
import { buildWorkbenchCapabilityAssistantPayload } from '../../utils/personalWorkbenchAssistantEntry.js'
import {
buildProfileOperationsFromAgentRuns,
buildUserProfileMetricCards,
buildUserProfileSummaryMetrics,
normalizeUserProfileRadarDimensions,
normalizeUserProfileTags,
resolveUserProfileDefaultRadarView,
resolveCurrentUserProfileError
} from '../../utils/employeeProfileViewModel.js'
const props = defineProps({
showHeader: { type: Boolean, default: true },
assistantModalOpen: { type: Boolean, default: false },
@@ -318,6 +401,27 @@ const assistantInputRef = ref(null)
const fileInputRef = ref(null)
const selectedFiles = ref([])
const pendingAction = ref('')
let pendingActionTimer = 0
const {
workbenchDatePickerOpen,
workbenchDateMode,
workbenchSingleDate,
workbenchRangeStartDate,
workbenchRangeEndDate,
workbenchDateTagLabel,
workbenchCanApplyDateSelection,
clearWorkbenchDateSelection,
toggleWorkbenchDatePicker,
closeWorkbenchDatePicker,
setWorkbenchDateMode,
handleWorkbenchDatePickerOutside,
handleWorkbenchDateInputChange,
removeWorkbenchDateTag,
buildWorkbenchPromptText
} = useWorkbenchComposerDate({
draft: assistantDraft,
focusInput: focusAssistantInput
})
const latestExpenseConversation = ref(null)
const hasLocalExpenseSnapshot = ref(false)
const expenseProfileModalOpen = ref(false)
@@ -342,6 +446,16 @@ const displayUserName = computed(() => {
return String(user.name || user.username || '同事').trim() || '同事'
})
const expenseActionLabel = computed(() => (hasExpenseConversation.value ? '继续报销' : '新建报销'))
const isComposerPending = computed(() => Boolean(pendingAction.value))
const composerPendingLabel = computed(() => {
if (pendingAction.value === 'intent') {
return '正在识别意图,准备进入对应助手...'
}
if (pendingAction.value === 'expense') {
return '正在恢复最近报销会话...'
}
return ''
})
const currentRoleCodes = computed(() => {
const user = currentUser.value || {}
const rawCodes = Array.isArray(user.roleCodes)
@@ -387,6 +501,7 @@ const expenseProfileModalMetrics = computed(() => {
})
const expenseProfileTags = computed(() => normalizeUserProfileTags(employeeProfile.value))
const expenseProfileRadarDimensions = computed(() => normalizeUserProfileRadarDimensions(employeeProfile.value))
const expenseProfileRadarDefaultView = computed(() => resolveUserProfileDefaultRadarView(employeeProfile.value))
const expenseProfileOperations = computed(() =>
buildProfileOperationsFromAgentRuns(employeeProfileRuns.value, currentUser.value)
)
@@ -446,7 +561,7 @@ function resolveCurrentUserId() {
function buildAssistantPayload() {
return {
prompt: assistantDraft.value.trim(),
prompt: buildWorkbenchPromptText(),
source: 'workbench',
files: Array.from(selectedFiles.value)
}
@@ -462,6 +577,34 @@ function clearSelectedFiles() {
function resetWorkbenchDraft() {
assistantDraft.value = ''
clearSelectedFiles()
clearWorkbenchDateSelection()
}
function clearPendingAction() {
pendingAction.value = ''
if (pendingActionTimer) {
window.clearTimeout(pendingActionTimer)
pendingActionTimer = 0
}
}
function startPendingAction(action) {
clearPendingAction()
pendingAction.value = action
pendingActionTimer = window.setTimeout(() => {
if (pendingAction.value !== action) {
return
}
clearPendingAction()
toast('进入助手耗时较长,请稍后重试。')
}, 16000)
}
function shouldShowIntentPending(payload = {}) {
return !props.assistantModalOpen
&& String(payload.prompt || '').trim()
&& String(payload.source || 'workbench').trim() === 'workbench'
&& !String(payload.sessionType || '').trim()
}
function emitAssistant(payload) {
@@ -492,12 +635,24 @@ function openPromptAssistant(prompt) {
return
}
emitAssistant({
prompt: String(prompt || '').trim(),
const payload = {
prompt: buildWorkbenchPromptText(prompt),
source: 'workbench',
files: Array.from(selectedFiles.value),
conversation: null
})
}
if (shouldShowIntentPending(payload)) {
startPendingAction('intent')
}
emitAssistant(payload)
}
function openCapabilityAssistant(item) {
if (pendingAction.value) {
return
}
emitAssistant(buildWorkbenchCapabilityAssistantPayload(item, buildAssistantPayload()))
}
async function loadCurrentEmployeeProfile() {
@@ -597,6 +752,9 @@ async function handleExpenseConversationAction() {
const shouldOpenImmediately = Boolean(nextPayload.prompt || nextPayload.files.length)
if (shouldOpenImmediately) {
if (shouldShowIntentPending(nextPayload)) {
startPendingAction('intent')
}
emitAssistant({
...nextPayload,
conversation: null
@@ -607,7 +765,7 @@ async function handleExpenseConversationAction() {
return
}
pendingAction.value = 'expense'
startPendingAction('expense')
try {
await clearKnowledgeHistoryBeforeExpense()
@@ -621,7 +779,7 @@ async function handleExpenseConversationAction() {
console.warn('Failed to open expense conversation:', error)
toast(error?.message || '打开报销会话失败,请稍后重试。')
} finally {
pendingAction.value = ''
clearPendingAction()
}
}
@@ -629,16 +787,22 @@ onMounted(() => {
refreshLocalExpenseSnapshot()
refreshLatestExpenseConversation()
loadCurrentEmployeeProfile()
document.addEventListener('click', handleWorkbenchDatePickerOutside)
window.addEventListener(ASSISTANT_SESSION_SNAPSHOT_EVENT, handleAssistantSessionSnapshotChange)
})
onBeforeUnmount(() => {
clearPendingAction()
document.removeEventListener('click', handleWorkbenchDatePickerOutside)
window.removeEventListener(ASSISTANT_SESSION_SNAPSHOT_EVENT, handleAssistantSessionSnapshotChange)
})
watch(
() => props.assistantModalOpen,
(open, previous) => {
if (open) {
clearPendingAction()
}
if (previous && !open) {
refreshLatestExpenseConversation()
}
@@ -653,5 +817,6 @@ watch(currentUserProfileKey, (nextKey, previousKey) => {
</script>
<style scoped src="../../assets/styles/components/personal-workbench.css"></style>
<style scoped src="../../assets/styles/components/personal-workbench-composer-date.css"></style>
<style scoped src="../../assets/styles/components/personal-workbench-insights.css"></style>
<style scoped src="../../assets/styles/components/personal-workbench-responsive.css"></style>

View File

@@ -29,7 +29,10 @@ import { resolveCssColor, useThemeColors } from '../../composables/useThemeColor
use([GridComponent, TooltipComponent, EChartsBarChart, CanvasRenderer])
const props = defineProps({
items: { type: Array, required: true }
items: { type: Array, required: true },
valuePrefix: { type: String, default: '¥' },
valueSuffix: { type: String, default: '' },
compact: { type: Boolean, default: true }
})
const chartElement = shallowRef(null)
@@ -51,7 +54,13 @@ 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 axisStep = computed(() => {
if (chartMaxValue.value <= 100) return 10
if (chartMaxValue.value <= 1000) return 100
if (chartMaxValue.value <= 10000) return 1000
return 10000
})
const chartAxisMax = computed(() => Math.ceil((chartMaxValue.value * 1.1) / axisStep.value) * axisStep.value)
const chartOptions = computed(() => ({
backgroundColor: 'transparent',
@@ -115,7 +124,7 @@ const chartOptions = computed(() => ({
value: item.value,
itemStyle: { color: item.resolvedColor }
})),
barWidth: 14,
barWidth: 18,
showBackground: true,
backgroundStyle: {
color: 'rgba(226, 232, 240, 0.42)',
@@ -155,9 +164,11 @@ const medalFill = (idx) => {
const formatValue = (value) => {
const number = Number(value || 0)
if (number >= 1_000_000) return `¥${(number / 1_000_000).toFixed(1)}M`
if (number >= 1_000) return `¥${(number / 1_000).toFixed(1)}K`
return `¥${number}`
const prefix = props.valuePrefix
const suffix = props.valueSuffix
if (props.compact && number >= 1_000_000) return `${prefix}${(number / 1_000_000).toFixed(1)}M${suffix}`
if (props.compact && number >= 1_000) return `${prefix}${(number / 1_000).toFixed(1)}K${suffix}`
return `${prefix}${number}${suffix}`
}
</script>
@@ -180,7 +191,7 @@ const formatValue = (value) => {
display: flex;
align-items: center;
gap: 6px;
height: 34px;
height: 38px;
white-space: nowrap;
}

View File

@@ -2,6 +2,10 @@
<div class="gauge-chart">
<div class="gauge-body">
<div ref="chartElement" class="gauge-canvas" role="img" :aria-label="ariaLabel"></div>
<div class="gauge-center-value" aria-hidden="true">
<strong>{{ normalizedRatio }}%</strong>
<span>已执行</span>
</div>
</div>
<div class="gauge-summary">
<div>
@@ -82,22 +86,8 @@ const chartOptions = computed(() => {
splitLine: { show: false },
axisLabel: { show: false },
anchor: { show: false },
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
},
detail: { show: false },
title: { show: false },
data: [{ value: normalizedRatio.value, name: '已执行' }]
}
]
@@ -127,6 +117,36 @@ useEcharts(chartElement, chartOptions)
height: 100%;
}
.gauge-center-value {
position: absolute;
left: 0;
right: 0;
top: 60%;
display: grid;
justify-items: center;
gap: 4px;
min-width: 0;
transform: translateY(-50%);
text-align: center;
pointer-events: none;
}
.gauge-center-value strong {
color: var(--theme-primary);
font-size: 24px;
font-weight: 850;
line-height: 1;
font-variant-numeric: tabular-nums;
letter-spacing: 0;
}
.gauge-center-value span {
color: #64748b;
font-size: 11px;
font-weight: 700;
line-height: 1;
}
.gauge-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));

View File

@@ -0,0 +1,143 @@
<template>
<div ref="chartElement" class="risk-daily-trend-chart" role="img" :aria-label="ariaLabel"></div>
</template>
<script setup>
import { computed, shallowRef } from 'vue'
import { BarChart as EChartsBarChart, LineChart as EChartsLineChart } from 'echarts/charts'
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { useEcharts } from '../../composables/useEcharts.js'
import { useThemeColors } from '../../composables/useThemeColors.js'
use([
GridComponent,
LegendComponent,
TooltipComponent,
EChartsBarChart,
EChartsLineChart,
CanvasRenderer
])
const props = defineProps({
rows: { type: Array, default: () => [] }
})
const chartElement = shallowRef(null)
const themeColors = useThemeColors()
const labels = computed(() => props.rows.map((item) => item.date))
const totals = computed(() => props.rows.map((item) => Number(item.total || 0)))
const highValues = computed(() => props.rows.map((item) => Number(item.highOrAbove || 0)))
const maxValue = computed(() => Math.max(...totals.value, ...highValues.value, 1))
const axisMax = computed(() => Math.max(5, Math.ceil(maxValue.value * 1.2)))
const ariaLabel = computed(() =>
props.rows.map((item) => (
`${item.date}风险观察${item.total || 0}项,高风险${item.highOrAbove || 0}`
)).join('')
)
const chartOptions = computed(() => ({
backgroundColor: 'transparent',
animation: true,
animationDuration: 900,
animationDurationUpdate: 700,
grid: {
top: 34,
right: 16,
bottom: 24,
left: 28,
containLabel: true
},
legend: {
top: 0,
right: 4,
itemWidth: 8,
itemHeight: 8,
textStyle: {
color: '#64748b',
fontSize: 11,
fontWeight: 700
}
},
tooltip: {
trigger: 'axis',
confine: true,
appendToBody: true,
backgroundColor: 'rgba(255,255,255,.98)',
borderColor: 'rgba(148,163,184,.24)',
borderWidth: 1,
padding: [9, 10],
textStyle: {
color: '#334155',
fontSize: 12,
fontWeight: 700
},
extraCssText: 'border-radius:4px;box-shadow:0 12px 28px rgba(15,23,42,.12);'
},
xAxis: {
type: 'category',
data: labels.value,
boundaryGap: true,
axisTick: { show: false },
axisLine: { lineStyle: { color: 'rgba(148,163,184,.26)' } },
axisLabel: {
color: '#64748b',
fontSize: 11,
fontWeight: 700
}
},
yAxis: {
type: 'value',
min: 0,
max: axisMax.value,
splitNumber: 4,
axisLabel: {
color: '#64748b',
fontSize: 11,
fontWeight: 700
},
splitLine: { lineStyle: { color: 'rgba(226,232,240,.74)' } }
},
series: [
{
name: '风险观察',
type: 'bar',
data: totals.value,
barWidth: 14,
itemStyle: {
color: themeColors.value.chartPrimary,
borderRadius: [4, 4, 0, 0]
}
},
{
name: '高风险',
type: 'line',
data: highValues.value,
smooth: true,
symbol: 'circle',
symbolSize: 7,
lineStyle: {
width: 2.5,
color: '#ef4444'
},
itemStyle: {
color: '#ffffff',
borderColor: '#ef4444',
borderWidth: 2.5
}
}
]
}))
useEcharts(chartElement, chartOptions)
</script>
<style scoped>
.risk-daily-trend-chart {
width: 100%;
height: 250px;
}
</style>

View File

@@ -0,0 +1,146 @@
<template>
<div class="system-accuracy-compare-bar">
<div ref="chartElement" class="chart-body" role="img" :aria-label="ariaLabel"></div>
</div>
</template>
<script setup>
import { computed, shallowRef } from 'vue'
import { BarChart as EChartsBarChart } from 'echarts/charts'
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { useEcharts } from '../../composables/useEcharts.js'
import { useThemeColors } from '../../composables/useThemeColors.js'
use([GridComponent, LegendComponent, TooltipComponent, EChartsBarChart, CanvasRenderer])
const props = defineProps({
categories: { type: Array, required: true },
correct: { type: Array, required: true },
wrong: { type: Array, required: true }
})
const chartElement = shallowRef(null)
const themeColors = useThemeColors()
const ariaLabel = computed(() =>
props.categories.map((name, index) => (
`${name}正确${props.correct[index] || 0}次,错误${props.wrong[index] || 0}`
)).join('')
)
const chartOptions = computed(() => ({
backgroundColor: 'transparent',
animation: true,
animationDuration: 900,
animationEasing: 'cubicOut',
legend: {
top: 0,
left: 0,
itemWidth: 9,
itemHeight: 9,
itemGap: 18,
textStyle: {
color: '#475569',
fontSize: 12,
fontWeight: 700
}
},
grid: {
top: 38,
right: 42,
bottom: 18,
left: 88,
containLabel: false
},
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
confine: true,
appendToBody: true,
backgroundColor: 'rgba(255, 255, 255, 0.98)',
borderColor: 'rgba(148, 163, 184, 0.24)',
borderWidth: 1,
padding: [9, 10],
textStyle: {
color: '#334155',
fontSize: 12,
fontWeight: 700
},
extraCssText: 'border-radius:4px;box-shadow:0 12px 28px rgba(15,23,42,.12);',
valueFormatter: (value) => `${value}`
},
xAxis: {
type: 'value',
min: 0,
axisLabel: {
color: '#64748b',
fontSize: 11,
fontWeight: 700
},
splitLine: { lineStyle: { color: 'rgba(226, 232, 240, 0.74)' } }
},
yAxis: {
type: 'category',
data: props.categories,
inverse: true,
axisTick: { show: false },
axisLine: { show: false },
axisLabel: {
color: '#475569',
fontSize: 12,
fontWeight: 750
}
},
series: [
{
name: '正确',
type: 'bar',
data: props.correct,
barWidth: 18,
itemStyle: {
color: themeColors.value.success,
borderRadius: [0, 4, 4, 0]
},
label: {
show: true,
position: 'right',
color: '#475569',
fontSize: 11,
fontWeight: 800
}
},
{
name: '错误',
type: 'bar',
data: props.wrong,
barWidth: 18,
itemStyle: {
color: themeColors.value.danger,
borderRadius: [0, 4, 4, 0]
},
label: {
show: true,
position: 'right',
color: '#ef4444',
fontSize: 11,
fontWeight: 800
}
}
]
}))
useEcharts(chartElement, chartOptions)
</script>
<style scoped>
.system-accuracy-compare-bar {
height: 292px;
}
.chart-body {
width: 100%;
height: 100%;
}
</style>

View File

@@ -0,0 +1,135 @@
<template>
<div class="system-agent-ratio-bar">
<div ref="chartElement" class="chart-body" role="img" :aria-label="ariaLabel"></div>
</div>
</template>
<script setup>
import { computed, shallowRef } from 'vue'
import { BarChart as EChartsBarChart } from 'echarts/charts'
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { useEcharts } from '../../composables/useEcharts.js'
import { resolveCssColor, useThemeColors } from '../../composables/useThemeColors.js'
use([GridComponent, LegendComponent, TooltipComponent, EChartsBarChart, CanvasRenderer])
const props = defineProps({
labels: { type: Array, required: true },
agents: { type: Array, required: true },
series: { type: Object, required: true }
})
const chartElement = shallowRef(null)
const themeColors = useThemeColors()
const resolvedAgents = computed(() =>
props.agents.map((agent) => ({
...agent,
resolvedColor: resolveCssColor(agent.color, themeColors.value.chartPrimary)
}))
)
const ariaLabel = computed(() =>
props.labels.map((label, dayIndex) => {
const parts = resolvedAgents.value.map((agent) => (
`${agent.name}${props.series[agent.key]?.[dayIndex] || 0}%`
))
return `${label}${parts.join('')}`
}).join('')
)
const chartOptions = computed(() => ({
backgroundColor: 'transparent',
animation: true,
animationDuration: 900,
animationEasing: 'cubicOut',
legend: {
top: 0,
left: 0,
itemWidth: 8,
itemHeight: 8,
itemGap: 14,
textStyle: {
color: '#475569',
fontSize: 12,
fontWeight: 700
}
},
grid: {
top: 38,
right: 16,
bottom: 24,
left: 34,
containLabel: true
},
tooltip: {
trigger: 'axis',
confine: true,
appendToBody: true,
backgroundColor: 'rgba(255, 255, 255, 0.98)',
borderColor: 'rgba(148, 163, 184, 0.24)',
borderWidth: 1,
padding: [9, 10],
textStyle: {
color: '#334155',
fontSize: 12,
fontWeight: 700
},
extraCssText: 'border-radius:4px;box-shadow:0 12px 28px rgba(15,23,42,.12);',
valueFormatter: (value) => `${value}%`
},
xAxis: {
type: 'category',
data: props.labels,
axisTick: { show: false },
axisLine: { lineStyle: { color: 'rgba(148, 163, 184, 0.28)' } },
axisLabel: {
color: '#64748b',
fontSize: 11,
fontWeight: 700
}
},
yAxis: {
type: 'value',
min: 0,
max: 100,
splitNumber: 5,
axisLabel: {
color: '#64748b',
fontSize: 11,
fontWeight: 700,
formatter: '{value}%'
},
splitLine: { lineStyle: { color: 'rgba(226, 232, 240, 0.75)' } }
},
series: resolvedAgents.value.map((agent, index) => ({
name: agent.name,
type: 'bar',
stack: 'agentRatio',
data: props.series[agent.key] || [],
barWidth: 34,
emphasis: { focus: 'series' },
itemStyle: {
color: agent.resolvedColor,
borderColor: '#ffffff',
borderWidth: index === resolvedAgents.value.length - 1 ? 0 : 1,
borderRadius: index === resolvedAgents.value.length - 1 ? [4, 4, 0, 0] : 0
}
}))
}))
useEcharts(chartElement, chartOptions)
</script>
<style scoped>
.system-agent-ratio-bar {
height: 292px;
}
.chart-body {
width: 100%;
height: 100%;
}
</style>

View File

@@ -0,0 +1,141 @@
<template>
<div class="system-load-heatmap">
<div ref="chartElement" class="chart-body" role="img" :aria-label="ariaLabel"></div>
</div>
</template>
<script setup>
import { computed, shallowRef } from 'vue'
import { HeatmapChart } from 'echarts/charts'
import { GridComponent, TooltipComponent, VisualMapComponent } from 'echarts/components'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { useEcharts } from '../../composables/useEcharts.js'
import { useThemeColors } from '../../composables/useThemeColors.js'
use([GridComponent, TooltipComponent, VisualMapComponent, HeatmapChart, CanvasRenderer])
const props = defineProps({
hours: { type: Array, required: true },
tools: { type: Array, required: true },
values: { type: Array, required: true }
})
const chartElement = shallowRef(null)
const themeColors = useThemeColors()
const maxValue = computed(() => Math.max(...props.values.map((item) => Number(item[2] || 0)), 1))
const ariaLabel = computed(() =>
props.values.map(([hourIndex, toolIndex, value]) => (
`${props.hours[hourIndex] || ''}${props.tools[toolIndex] || ''}${value || 0}`
)).join('')
)
const chartOptions = computed(() => ({
backgroundColor: 'transparent',
animation: true,
animationDuration: 760,
animationEasing: 'cubicOut',
grid: {
top: 20,
right: 18,
bottom: 18,
left: 78,
containLabel: false
},
tooltip: {
position: 'top',
confine: true,
appendToBody: true,
backgroundColor: 'rgba(255, 255, 255, 0.98)',
borderColor: 'rgba(148, 163, 184, 0.24)',
borderWidth: 1,
padding: [9, 10],
textStyle: {
color: '#334155',
fontSize: 12,
fontWeight: 700
},
extraCssText: 'border-radius:4px;box-shadow:0 12px 28px rgba(15,23,42,.12);',
formatter: (params) => {
const [hourIndex, toolIndex, value] = params.value || []
return `${params.marker}${props.tools[toolIndex] || ''}<br/>${props.hours[hourIndex] || ''}: ${value || 0}`
}
},
visualMap: {
show: false,
min: 0,
max: maxValue.value,
inRange: {
color: [
'#eef6fb',
'#d7e9f3',
themeColors.value.chartPrimary,
themeColors.value.primaryActive
]
}
},
xAxis: {
type: 'category',
data: props.hours,
splitArea: { show: true },
axisTick: { show: false },
axisLine: { lineStyle: { color: 'rgba(148, 163, 184, 0.28)' } },
axisLabel: {
color: '#64748b',
fontSize: 11,
fontWeight: 700
}
},
yAxis: {
type: 'category',
data: props.tools,
splitArea: { show: true },
axisTick: { show: false },
axisLine: { show: false },
axisLabel: {
color: '#475569',
fontSize: 12,
fontWeight: 750
}
},
series: [
{
name: '调用热力',
type: 'heatmap',
data: props.values,
label: {
show: true,
color: '#ffffff',
fontSize: 10,
fontWeight: 800,
formatter: ({ value }) => value?.[2] || 0
},
itemStyle: {
borderColor: '#ffffff',
borderWidth: 2,
borderRadius: 4
},
emphasis: {
itemStyle: {
borderColor: themeColors.value.primaryActive,
borderWidth: 2
}
}
}
]
}))
useEcharts(chartElement, chartOptions)
</script>
<style scoped>
.system-load-heatmap {
height: 320px;
}
.chart-body {
width: 100%;
height: 100%;
}
</style>

View File

@@ -0,0 +1,197 @@
<template>
<div class="system-login-wave-chart" :class="{ 'is-compact': compact }">
<div ref="chartElement" class="chart-body" role="img" :aria-label="ariaLabel"></div>
</div>
</template>
<script setup>
import { computed, shallowRef } from 'vue'
import { LineChart as EChartsLineChart } from 'echarts/charts'
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { useEcharts } from '../../composables/useEcharts.js'
import { useThemeColors } from '../../composables/useThemeColors.js'
use([GridComponent, LegendComponent, TooltipComponent, EChartsLineChart, CanvasRenderer])
const props = defineProps({
labels: { type: Array, required: true },
loginUsers: { type: Array, required: true },
interactions: { type: Array, required: true },
compact: { type: Boolean, default: false }
})
const chartElement = shallowRef(null)
const themeColors = useThemeColors()
const chartColors = computed(() => ({
primary: themeColors.value.chartPrimary,
blue: themeColors.value.chartBlue
}))
const ariaLabel = computed(() =>
props.labels.map((label, index) => (
`${label}登录${props.loginUsers[index] || 0}人,互动${props.interactions[index] || 0}`
)).join('')
)
const chartOptions = computed(() => ({
backgroundColor: 'transparent',
animation: true,
animationDuration: 950,
animationEasing: 'cubicOut',
legend: {
top: 0,
left: 0,
itemWidth: 18,
itemHeight: 8,
itemGap: 16,
textStyle: {
color: '#475569',
fontSize: 12,
fontWeight: 700
}
},
grid: {
top: 38,
right: 36,
bottom: 24,
left: 32,
containLabel: true
},
tooltip: {
trigger: 'axis',
confine: true,
appendToBody: true,
backgroundColor: 'rgba(255, 255, 255, 0.98)',
borderColor: 'rgba(148, 163, 184, 0.24)',
borderWidth: 1,
padding: [9, 10],
textStyle: {
color: '#334155',
fontSize: 12,
fontWeight: 700
},
extraCssText: 'border-radius:4px;box-shadow:0 12px 28px rgba(15,23,42,.12);'
},
xAxis: {
type: 'category',
data: props.labels,
boundaryGap: false,
axisTick: { show: false },
axisLine: { lineStyle: { color: 'rgba(148, 163, 184, 0.28)' } },
axisLabel: {
color: '#64748b',
fontSize: 11,
fontWeight: 700
}
},
yAxis: [
{
type: 'value',
name: '登录',
min: 0,
axisLabel: {
color: '#64748b',
fontSize: 11,
fontWeight: 700
},
nameTextStyle: { color: '#94a3b8', fontSize: 11, fontWeight: 700 },
splitLine: { lineStyle: { color: 'rgba(226, 232, 240, 0.72)' } }
},
{
type: 'value',
name: '互动',
min: 0,
axisLabel: {
color: '#94a3b8',
fontSize: 11,
fontWeight: 700
},
nameTextStyle: { color: '#94a3b8', fontSize: 11, fontWeight: 700 },
splitLine: { show: false }
}
],
series: [
{
name: '登录人数',
type: 'line',
smooth: 0.42,
symbol: 'circle',
symbolSize: 7,
data: props.loginUsers,
lineStyle: {
width: 3,
color: chartColors.value.primary
},
itemStyle: {
color: '#ffffff',
borderColor: chartColors.value.primary,
borderWidth: 2.5
},
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: toRgba(chartColors.value.primary, 0.18) },
{ offset: 1, color: toRgba(chartColors.value.primary, 0.02) }
]
}
}
},
{
name: '互动次数',
type: 'line',
yAxisIndex: 1,
smooth: 0.5,
symbol: 'emptyCircle',
symbolSize: 6,
data: props.interactions,
lineStyle: {
width: 2.5,
color: chartColors.value.blue,
type: 'dashed'
},
itemStyle: {
color: '#ffffff',
borderColor: chartColors.value.blue,
borderWidth: 2
}
}
]
}))
useEcharts(chartElement, chartOptions)
function toRgba(color, alpha) {
const normalized = String(color || '').trim()
const hex = normalized.replace('#', '')
if (/^[\da-f]{6}$/i.test(hex)) {
const r = parseInt(hex.slice(0, 2), 16)
const g = parseInt(hex.slice(2, 4), 16)
const b = parseInt(hex.slice(4, 6), 16)
return `rgba(${r}, ${g}, ${b}, ${alpha})`
}
return `rgba(58, 124, 165, ${alpha})`
}
</script>
<style scoped>
.system-login-wave-chart {
height: 292px;
}
.system-login-wave-chart.is-compact {
height: 188px;
}
.chart-body {
width: 100%;
height: 100%;
}
</style>

View File

@@ -0,0 +1,165 @@
<template>
<div class="system-token-daily-wave-chart">
<div ref="chartElement" class="chart-body" role="img" :aria-label="ariaLabel"></div>
</div>
</template>
<script setup>
import { computed, shallowRef } from 'vue'
import { BarChart as EChartsBarChart, LineChart as EChartsLineChart } from 'echarts/charts'
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { useEcharts } from '../../composables/useEcharts.js'
import { useThemeColors } from '../../composables/useThemeColors.js'
use([GridComponent, LegendComponent, TooltipComponent, EChartsBarChart, EChartsLineChart, CanvasRenderer])
const props = defineProps({
labels: { type: Array, required: true },
inputTokens: { type: Array, required: true },
outputTokens: { type: Array, required: true },
totalTokens: { type: Array, required: true }
})
const chartElement = shallowRef(null)
const themeColors = useThemeColors()
const chartColors = computed(() => ({
amber: themeColors.value.chartAmber,
purple: themeColors.value.chartPurple,
danger: themeColors.value.chartDanger
}))
const ariaLabel = computed(() =>
props.labels.map((label, index) => (
`${label}输入${formatTokens(props.inputTokens[index])},输出${formatTokens(props.outputTokens[index])},合计${formatTokens(props.totalTokens[index])}`
)).join('')
)
const chartOptions = computed(() => ({
backgroundColor: 'transparent',
animation: true,
animationDuration: 920,
animationEasing: 'cubicOut',
legend: {
top: 0,
left: 0,
itemWidth: 9,
itemHeight: 9,
itemGap: 16,
textStyle: {
color: '#475569',
fontSize: 12,
fontWeight: 700
}
},
grid: {
top: 38,
right: 22,
bottom: 24,
left: 34,
containLabel: true
},
tooltip: {
trigger: 'axis',
confine: true,
appendToBody: true,
backgroundColor: 'rgba(255, 255, 255, 0.98)',
borderColor: 'rgba(148, 163, 184, 0.24)',
borderWidth: 1,
padding: [9, 10],
textStyle: {
color: '#334155',
fontSize: 12,
fontWeight: 700
},
extraCssText: 'border-radius:4px;box-shadow:0 12px 28px rgba(15,23,42,.12);',
valueFormatter: (value) => `${formatTokens(value)} tokens`
},
xAxis: {
type: 'category',
data: props.labels,
axisTick: { show: false },
axisLine: { lineStyle: { color: 'rgba(148, 163, 184, 0.28)' } },
axisLabel: {
color: '#64748b',
fontSize: 11,
fontWeight: 700
}
},
yAxis: {
type: 'value',
min: 0,
axisLabel: {
color: '#64748b',
fontSize: 11,
fontWeight: 700,
formatter: (value) => `${Math.round(value / 1000)}K`
},
splitLine: { lineStyle: { color: 'rgba(226, 232, 240, 0.74)' } }
},
series: [
{
name: '输入 Tokens',
type: 'bar',
stack: 'tokens',
data: props.inputTokens,
barWidth: 24,
itemStyle: {
color: chartColors.value.amber,
borderRadius: [0, 0, 3, 3]
}
},
{
name: '输出 Tokens',
type: 'bar',
stack: 'tokens',
data: props.outputTokens,
barWidth: 24,
itemStyle: {
color: chartColors.value.purple,
borderRadius: [3, 3, 0, 0]
}
},
{
name: '合计波动',
type: 'line',
data: props.totalTokens,
smooth: 0.45,
symbol: 'circle',
symbolSize: 7,
lineStyle: {
width: 2.8,
color: chartColors.value.danger
},
itemStyle: {
color: '#ffffff',
borderColor: chartColors.value.danger,
borderWidth: 2.4
},
emphasis: { focus: 'series' }
}
]
}))
useEcharts(chartElement, chartOptions)
function formatTokens(value) {
const number = Number(value || 0)
if (number >= 1_000_000) return `${(number / 1_000_000).toFixed(1)}M`
if (number >= 1_000) return `${(number / 1_000).toFixed(1)}K`
return `${Math.round(number)}`
}
</script>
<style scoped>
.system-token-daily-wave-chart {
height: 292px;
}
.chart-body {
width: 100%;
height: 100%;
}
</style>

View File

@@ -0,0 +1,113 @@
<template>
<div class="system-token-treemap">
<div ref="chartElement" class="chart-body" role="img" :aria-label="ariaLabel"></div>
</div>
</template>
<script setup>
import { computed, shallowRef } from 'vue'
import { TreemapChart } from 'echarts/charts'
import { TooltipComponent } from 'echarts/components'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { useEcharts } from '../../composables/useEcharts.js'
import { resolveCssColor, useThemeColors } from '../../composables/useThemeColors.js'
use([TooltipComponent, TreemapChart, CanvasRenderer])
const props = defineProps({
items: { type: Array, required: true }
})
const chartElement = shallowRef(null)
const themeColors = useThemeColors()
const normalizedItems = computed(() =>
props.items.map((item) => ({
...item,
value: Number(item.tokens || item.value || 0),
resolvedColor: resolveCssColor(item.color, themeColors.value.chartPrimary)
}))
)
const ariaLabel = computed(() =>
normalizedItems.value.map((item) => `${item.name}${formatTokens(item.value)},占比${item.share}%`).join('')
)
const chartOptions = computed(() => ({
backgroundColor: 'transparent',
animation: true,
animationDuration: 760,
animationEasing: 'cubicOut',
tooltip: {
trigger: 'item',
confine: true,
appendToBody: true,
backgroundColor: 'rgba(255, 255, 255, 0.98)',
borderColor: 'rgba(148, 163, 184, 0.24)',
borderWidth: 1,
padding: [9, 10],
textStyle: {
color: '#334155',
fontSize: 12,
fontWeight: 700
},
extraCssText: 'border-radius:4px;box-shadow:0 12px 28px rgba(15,23,42,.12);',
formatter: (params) => `${params.marker}${params.name}<br/>${formatTokens(params.value)} · ${params.data?.share || 0}%`
},
series: [
{
type: 'treemap',
roam: false,
nodeClick: false,
breadcrumb: { show: false },
top: 4,
right: 4,
bottom: 4,
left: 4,
visibleMin: 300,
leafDepth: 1,
label: {
show: true,
color: '#ffffff',
fontSize: 12,
fontWeight: 800,
lineHeight: 16,
formatter: (params) => `${params.name}\n${formatTokens(params.value)}`
},
itemStyle: {
borderColor: '#ffffff',
borderWidth: 3,
borderRadius: 4,
gapWidth: 3
},
upperLabel: { show: false },
data: normalizedItems.value.map((item) => ({
name: item.name,
value: item.value,
share: item.share,
itemStyle: { color: item.resolvedColor }
}))
}
]
}))
useEcharts(chartElement, chartOptions)
function formatTokens(value) {
const number = Number(value || 0)
if (number >= 1_000_000) return `${(number / 1_000_000).toFixed(1)}M tokens`
if (number >= 1_000) return `${(number / 1_000).toFixed(1)}K tokens`
return `${Math.round(number)} tokens`
}
</script>
<style scoped>
.system-token-treemap {
height: 268px;
}
.chart-body {
width: 100%;
height: 100%;
}
</style>

View File

@@ -0,0 +1,245 @@
<template>
<div class="system-trend-chart">
<div class="chart-legend">
<span><i :style="{ background: chartColors.primary }"></i>工具调用</span>
<span><i :style="{ background: chartColors.blue }"></i>Token 消耗K</span>
<span><i :style="{ background: chartColors.purple }"></i>在线人数</span>
<span><i :style="{ background: chartColors.amber }"></i>平均在线时长分钟</span>
</div>
<div ref="chartElement" class="chart-body" role="img" :aria-label="ariaLabel"></div>
</div>
</template>
<script setup>
import { computed, shallowRef } from 'vue'
import { BarChart as EChartsBarChart, LineChart as EChartsLineChart } from 'echarts/charts'
import { GridComponent, TooltipComponent } from 'echarts/components'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { useEcharts } from '../../composables/useEcharts.js'
import { useThemeColors } from '../../composables/useThemeColors.js'
use([GridComponent, TooltipComponent, EChartsBarChart, EChartsLineChart, CanvasRenderer])
const props = defineProps({
labels: { type: Array, required: true },
toolCalls: { type: Array, required: true },
tokens: { type: Array, required: true },
onlineUsers: { type: Array, required: true },
onlineMinutes: { type: Array, required: true }
})
const chartElement = shallowRef(null)
const themeColors = useThemeColors()
const chartColors = computed(() => ({
primary: themeColors.value.chartPrimary,
blue: themeColors.value.chartBlue,
purple: themeColors.value.chartPurple,
amber: themeColors.value.chartAmber
}))
const tokenInK = computed(() => props.tokens.map((value) => Math.round(Number(value || 0) / 100) / 10))
const ariaLabel = computed(() =>
props.labels.map((label, index) => (
`${label}工具调用${props.toolCalls[index] || 0}Token消耗${tokenInK.value[index] || 0}K在线${props.onlineUsers[index] || 0}人,平均在线${props.onlineMinutes[index] || 0}分钟`
)).join('')
)
const chartOptions = computed(() => ({
backgroundColor: 'transparent',
animation: true,
animationDuration: 1200,
animationDurationUpdate: 1200,
animationEasing: 'linear',
animationEasingUpdate: 'linear',
grid: {
top: 18,
right: 42,
bottom: 22,
left: 38,
containLabel: true
},
tooltip: {
trigger: 'axis',
confine: true,
appendToBody: true,
backgroundColor: 'rgba(255, 255, 255, 0.98)',
borderColor: 'rgba(148, 163, 184, 0.24)',
borderWidth: 1,
padding: [9, 10],
textStyle: {
color: '#334155',
fontSize: 12,
fontWeight: 700
},
extraCssText: 'border-radius:4px;box-shadow:0 12px 28px rgba(15,23,42,.12);'
},
xAxis: {
type: 'category',
data: props.labels,
boundaryGap: true,
axisTick: { show: false },
axisLine: { lineStyle: { color: 'rgba(148, 163, 184, 0.28)' } },
axisLabel: {
color: '#64748b',
fontSize: 11,
fontWeight: 700
}
},
yAxis: [
{
type: 'value',
min: 0,
max: 360,
splitNumber: 6,
axisLabel: {
color: '#64748b',
fontSize: 11,
fontWeight: 700
},
splitLine: { lineStyle: { color: 'rgba(226, 232, 240, 0.75)' } }
},
{
type: 'value',
min: 0,
max: 90,
splitNumber: 6,
axisLabel: {
color: '#64748b',
fontSize: 11,
fontWeight: 700
},
splitLine: { show: false }
}
],
series: [
{
name: '工具调用(次)',
type: 'bar',
data: props.toolCalls,
barWidth: 14,
itemStyle: {
color: chartColors.value.primary,
borderRadius: [4, 4, 0, 0]
}
},
{
name: 'Token 消耗K',
type: 'line',
yAxisIndex: 1,
data: tokenInK.value,
smooth: true,
symbol: 'circle',
symbolSize: 7,
lineStyle: {
width: 2.5,
color: chartColors.value.blue
},
itemStyle: {
color: '#ffffff',
borderColor: chartColors.value.blue,
borderWidth: 2.5
},
areaStyle: {
color: buildAreaColor(chartColors.value.blue, 0.11)
}
},
{
name: '在线人数',
type: 'line',
data: props.onlineUsers,
smooth: true,
symbol: 'circle',
symbolSize: 7,
lineStyle: {
width: 2.5,
color: chartColors.value.purple
},
itemStyle: {
color: '#ffffff',
borderColor: chartColors.value.purple,
borderWidth: 2.5
}
},
{
name: '平均在线时长(分钟)',
type: 'line',
yAxisIndex: 1,
data: props.onlineMinutes,
smooth: true,
symbol: 'circle',
symbolSize: 7,
lineStyle: {
width: 2.5,
color: chartColors.value.amber
},
itemStyle: {
color: '#ffffff',
borderColor: chartColors.value.amber,
borderWidth: 2.5
}
}
]
}))
useEcharts(chartElement, chartOptions)
function buildAreaColor(color, alpha) {
return {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: toRgba(color, alpha) },
{ offset: 1, color: toRgba(color, 0.02) }
]
}
}
function toRgba(color, alpha) {
const normalized = String(color || '').trim()
const hex = normalized.replace('#', '')
if (/^[\da-f]{6}$/i.test(hex)) {
const r = parseInt(hex.slice(0, 2), 16)
const g = parseInt(hex.slice(2, 4), 16)
const b = parseInt(hex.slice(4, 6), 16)
return `rgba(${r}, ${g}, ${b}, ${alpha})`
}
return `rgba(58, 124, 165, ${alpha})`
}
</script>
<style scoped>
.system-trend-chart {
height: 280px;
display: flex;
flex-direction: column;
}
.chart-legend {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px 16px;
color: #475569;
font-size: 12px;
margin-bottom: 12px;
}
.chart-legend i {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 2px;
margin-right: 4px;
vertical-align: middle;
}
.chart-body {
flex: 1;
min-height: 0;
}
</style>

View File

@@ -0,0 +1,199 @@
<template>
<div class="system-user-token-pie">
<div ref="chartElement" class="chart-body" role="img" :aria-label="ariaLabel"></div>
<div class="token-user-list">
<div v-for="item in resolvedItems" :key="item.name" class="token-user-row">
<i :style="{ background: item.resolvedColor }"></i>
<div>
<strong>{{ item.name }}</strong>
<span>{{ item.role }}</span>
</div>
<em>{{ formatTokens(item.tokens) }}</em>
</div>
</div>
</div>
</template>
<script setup>
import { computed, shallowRef } from 'vue'
import { PieChart } from 'echarts/charts'
import { TooltipComponent } from 'echarts/components'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { useEcharts } from '../../composables/useEcharts.js'
import { resolveCssColor, useThemeColors } from '../../composables/useThemeColors.js'
use([TooltipComponent, PieChart, CanvasRenderer])
const props = defineProps({
items: { type: Array, required: true }
})
const chartElement = shallowRef(null)
const themeColors = useThemeColors()
const totalTokens = computed(() =>
props.items.reduce((sum, item) => sum + Number(item.tokens || 0), 0)
)
const resolvedItems = computed(() =>
props.items.map((item) => ({
...item,
tokens: Number(item.tokens || 0),
resolvedColor: resolveCssColor(item.color, themeColors.value.chartPrimary)
}))
)
const ariaLabel = computed(() =>
resolvedItems.value.map((item) => (
`${item.name}${formatTokens(item.tokens)},占比${getShare(item.tokens)}%`
)).join('')
)
const chartOptions = computed(() => ({
backgroundColor: 'transparent',
animation: true,
animationDuration: 900,
animationEasing: 'cubicOut',
tooltip: {
trigger: 'item',
confine: true,
appendToBody: true,
backgroundColor: 'rgba(255, 255, 255, 0.98)',
borderColor: 'rgba(148, 163, 184, 0.24)',
borderWidth: 1,
padding: [9, 10],
textStyle: {
color: '#334155',
fontSize: 12,
fontWeight: 700
},
extraCssText: 'border-radius:4px;box-shadow:0 12px 28px rgba(15,23,42,.12);',
formatter: (params) => `${params.marker}${params.name}<br/>${formatTokens(params.value)} · ${params.percent}%`
},
series: [
{
type: 'pie',
radius: [0, '82%'],
center: ['47%', '50%'],
roseType: 'radius',
minAngle: 8,
avoidLabelOverlap: true,
label: {
show: true,
color: '#475569',
fontSize: 11,
fontWeight: 800,
formatter: '{b}\n{d}%'
},
labelLine: {
length: 10,
length2: 6,
lineStyle: { color: 'rgba(148, 163, 184, 0.55)' }
},
itemStyle: {
borderColor: '#ffffff',
borderWidth: 3,
borderRadius: 4
},
emphasis: {
scale: true,
scaleSize: 4
},
data: resolvedItems.value.map((item) => ({
name: item.name,
value: item.tokens,
itemStyle: { color: item.resolvedColor }
}))
}
]
}))
useEcharts(chartElement, chartOptions)
function getShare(value) {
if (!totalTokens.value) return 0
return Math.round((Number(value || 0) / totalTokens.value) * 1000) / 10
}
function formatTokens(value) {
const number = Number(value || 0)
if (number >= 1_000_000) return `${(number / 1_000_000).toFixed(1)}M`
if (number >= 1_000) return `${(number / 1_000).toFixed(1)}K`
return `${Math.round(number)}`
}
</script>
<style scoped>
.system-user-token-pie {
min-height: 292px;
display: grid;
grid-template-columns: minmax(0, 1fr) 188px;
gap: 10px;
}
.chart-body {
width: 100%;
min-width: 0;
height: 292px;
}
.token-user-list {
display: grid;
gap: 8px;
align-content: center;
}
.token-user-row {
display: grid;
grid-template-columns: 8px minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
padding: 8px 0;
border-bottom: 1px solid #f1f5f9;
}
.token-user-row:last-child {
border-bottom: 0;
}
.token-user-row i {
width: 8px;
height: 22px;
border-radius: 2px;
}
.token-user-row strong,
.token-user-row span {
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.token-user-row strong {
color: #1e293b;
font-size: 12px;
font-weight: 800;
}
.token-user-row span {
margin-top: 2px;
color: #64748b;
font-size: 11px;
}
.token-user-row em {
color: #0f172a;
font-size: 12px;
font-style: normal;
font-weight: 850;
font-variant-numeric: tabular-nums;
}
@media (max-width: 860px) {
.system-user-token-pie {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,708 @@
<template>
<section class="risk-observation-dashboard">
<article class="panel dashboard-card risk-trend-panel">
<div class="card-head">
<h3>风险观察趋势 <i class="mdi mdi-information-outline"></i></h3>
<div class="risk-window-controls">
<span class="risk-window-label"> {{ dashboard.windowDays }} </span>
<EnterpriseSelect
class="risk-window-select"
:model-value="activeWindowDays"
:options="windowOptions"
size="small"
aria-label="风险看板时间窗口"
@update:model-value="emit('update:windowDays', $event)"
/>
</div>
</div>
<div v-if="loading" class="risk-dashboard-state">
<i class="mdi mdi-loading mdi-spin"></i>
<span>正在加载风险看板数据</span>
</div>
<div v-else-if="error" class="risk-dashboard-state error">
<i class="mdi mdi-alert-circle-outline"></i>
<span>{{ errorMessage }}</span>
</div>
<RiskDailyTrendChart v-else :rows="dailyRows" />
</article>
<article class="panel dashboard-card risk-level-panel">
<div class="card-head">
<h3>风险等级分布 <i class="mdi mdi-information-outline"></i></h3>
</div>
<DonutChart
:items="levelLegend"
:center-value="String(dashboard.totalObservations)"
center-label="风险观察"
/>
</article>
<article class="panel dashboard-card risk-source-panel">
<div class="card-head">
<h3>来源分布 <i class="mdi mdi-information-outline"></i></h3>
</div>
<DonutChart
:items="sourceLegend"
:center-value="String(dashboard.totalObservations)"
center-label="归集来源"
/>
</article>
<article class="panel dashboard-card risk-dimension-panel">
<div class="card-head">
<h3>业务维度分布 <i class="mdi mdi-information-outline"></i></h3>
</div>
<div class="risk-dimension-grid">
<section
v-for="group in dimensionGroups"
:key="group.label"
class="risk-dimension-group"
>
<span class="risk-dimension-title">{{ group.label }}</span>
<div v-if="group.rows.length" class="risk-dimension-list">
<div v-for="row in group.rows" :key="row.name" class="risk-dimension-row">
<span>{{ row.name }}</span>
<i><b :style="{ width: row.width }"></b></i>
<strong>{{ row.count }}</strong>
</div>
</div>
<p v-else class="risk-dashboard-inline-empty">暂无数据</p>
</section>
</div>
</article>
<article class="panel dashboard-card risk-signal-panel">
<div class="card-head">
<h3>风险信号排行 <i class="mdi mdi-information-outline"></i></h3>
</div>
<BarChart
v-if="signalRanking.length"
:items="signalRanking"
value-prefix=""
value-suffix=""
/>
<div v-else class="risk-dashboard-empty">
<i class="mdi mdi-shield-check-outline"></i>
<span>当前周期暂无风险信号</span>
</div>
</article>
<article class="panel dashboard-card risk-ranking-panel">
<div class="card-head">
<h3>异常排行 <i class="mdi mdi-information-outline"></i></h3>
</div>
<div class="risk-ranking-grid">
<section
v-for="group in rankingGroups"
:key="group.label"
class="risk-ranking-group"
>
<span class="risk-ranking-title">{{ group.label }}</span>
<ol v-if="group.rows.length" class="risk-ranking-list">
<li v-for="(row, index) in group.rows" :key="`${group.label}-${row.name}`">
<em>{{ index + 1 }}</em>
<div>
<strong>{{ row.name }}</strong>
<small>{{ row.amountLabel }}</small>
</div>
<span>{{ row.count }}</span>
</li>
</ol>
<p v-else class="risk-dashboard-inline-empty">暂无数据</p>
</section>
</div>
</article>
<article class="panel dashboard-card risk-effect-panel">
<div class="card-head">
<h3>算法闭环效果 <i class="mdi mdi-information-outline"></i></h3>
</div>
<div class="risk-effect-grid">
<div v-for="item in effectItems" :key="item.label" class="risk-effect-item">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</div>
</div>
</article>
<article class="panel dashboard-card risk-recent-panel">
<div class="card-head">
<h3>近期高风险观察 <i class="mdi mdi-information-outline"></i></h3>
</div>
<div v-if="recentHighObservations.length" class="risk-recent-list">
<button
v-for="item in recentHighObservations"
:key="item.observationKey || item.id"
class="risk-recent-row"
type="button"
:disabled="!item.claimId"
@click="openClaim(item)"
>
<span class="risk-recent-level" :class="item.riskLevel">
{{ formatRiskLevel(item.riskLevel) }}
</span>
<span class="risk-recent-main">
<strong>{{ item.title || formatSignal(item.riskSignal) }}</strong>
<small>{{ item.claimNo || item.claimId || '未关联单据' }}</small>
</span>
<span class="risk-recent-score">{{ item.riskScore }}</span>
</button>
</div>
<div v-else class="risk-dashboard-empty">
<i class="mdi mdi-check-circle-outline"></i>
<span>暂无高风险观察</span>
</div>
</article>
</section>
</template>
<script setup>
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import BarChart from '../charts/BarChart.vue'
import DonutChart from '../charts/DonutChart.vue'
import RiskDailyTrendChart from '../charts/RiskDailyTrendChart.vue'
import EnterpriseSelect from '../shared/EnterpriseSelect.vue'
const props = defineProps({
dashboard: { type: Object, required: true },
loading: { type: Boolean, default: false },
error: { type: Object, default: () => null },
levelLegend: { type: Array, default: () => [] },
sourceLegend: { type: Array, default: () => [] },
signalRanking: { type: Array, default: () => [] },
dailyRows: { type: Array, default: () => [] },
windowOptions: { type: Array, default: () => [] },
activeWindowDays: { type: Number, default: 30 }
})
const emit = defineEmits(['update:windowDays'])
const router = useRouter()
const errorMessage = computed(() => props.error?.message || '风险看板数据加载失败')
const recentHighObservations = computed(() => props.dashboard.recentHighObservations || [])
const dimensionGroups = computed(() => [
buildDimensionGroup('部门', props.dashboard.departmentDistribution, 'department'),
buildDimensionGroup('费用类型', props.dashboard.expenseTypeDistribution, 'expense_type'),
buildDimensionGroup('风险类型', props.dashboard.riskTypeDistribution, 'risk_type'),
buildDimensionGroup('供应商', props.dashboard.supplierDistribution, 'supplier'),
buildDimensionGroup('员工职级', props.dashboard.employeeGradeDistribution, 'grade')
])
const rankingGroups = computed(() => [
buildRankingGroup('部门', props.dashboard.topDepartments, 'department'),
buildRankingGroup('员工', props.dashboard.topEmployees, 'employee'),
buildRankingGroup('供应商', props.dashboard.topSuppliers, 'supplier'),
buildRankingGroup('规则', props.dashboard.topRules, 'rule'),
buildRankingGroup('费用类型', props.dashboard.topExpenseTypes, 'expense_type')
])
const effectItems = computed(() => {
const sourceDistribution = props.dashboard.sourceDistribution || {}
const total = Number(props.dashboard.totalObservations || 0)
const pending = Number(props.dashboard.pendingCount || 0)
const processedRate = total > 0 ? Math.max(0, (total - pending) / total) : 0
return [
{ label: '规则命中', value: sourceDistribution.rule_center || 0 },
{ label: '图谱异常', value: sourceDistribution.financial_risk_graph || 0 },
{ label: '确认率', value: formatPercent(props.dashboard.confirmationRate) },
{ label: '误报率', value: formatPercent(props.dashboard.falsePositiveRate) },
{ label: '候选规则', value: props.dashboard.candidateRuleCount || 0 },
{ label: '完成率', value: formatPercent(processedRate) }
]
})
function buildDimensionGroup(label, distribution = {}, kind = '') {
const rows = Object.entries(distribution || {})
.map(([name, count]) => ({
name: formatDimensionName(name, kind),
count: Number(count || 0)
}))
.filter((item) => item.count > 0)
.sort((a, b) => b.count - a.count)
.slice(0, 4)
const max = Math.max(...rows.map((item) => item.count), 1)
return {
label,
rows: rows.map((item) => ({
...item,
width: `${Math.max((item.count / max) * 100, 10)}%`
}))
}
}
function buildRankingGroup(label, rows = [], kind = '') {
return {
label,
rows: (Array.isArray(rows) ? rows : [])
.map((item) => ({
name: formatDimensionName(item.name, kind),
count: Number(item.count || 0),
amount: Number(item.amount || 0),
amountLabel: formatAmount(item.amount)
}))
.filter((item) => item.count > 0)
.slice(0, 5)
}
}
function formatAmount(value) {
const amount = Number(value || 0)
if (amount >= 1_000_000) return `¥${(amount / 1_000_000).toFixed(1)}M`
if (amount >= 1_000) return `¥${(amount / 1_000).toFixed(1)}K`
return `¥${Math.round(amount)}`
}
function formatPercent(value) {
return `${Math.round(Number(value || 0) * 100)}%`
}
function formatRiskLevel(value) {
const labels = {
critical: '重大',
high: '高',
medium: '中',
low: '低'
}
return labels[String(value || '').trim()] || '未知'
}
function formatDimensionName(value, kind = '') {
const text = String(value || '').trim()
if (!text || text === 'unknown') {
const unknownLabels = {
department: '未归集部门',
expense_type: '未归集费用',
risk_type: '未知风险类型',
supplier: '未归集供应商',
grade: '未归集职级',
employee: '未归集员工',
rule: '未关联规则'
}
return unknownLabels[kind] || '未知'
}
if (kind === 'risk_type' || kind === 'expense_type' || kind === 'rule') {
return formatSignal(text)
}
return text.replace(/_/g, ' ')
}
function formatSignal(value) {
const text = String(value || '').trim()
const labels = {
duplicate_invoice: '重复发票',
split_billing: '拆分报销',
frequent_small_claims: '高频小额',
location_mismatch: '地点不一致',
amount_outlier: '金额异常',
preapproval_absent: '缺少事前申请'
}
return labels[text] || text.replace(/_/g, ' ') || '未知风险'
}
function openClaim(item) {
if (!item.claimId) {
return
}
router.push({
name: 'app-document-detail',
params: { requestId: item.claimId }
})
}
</script>
<style scoped>
.risk-observation-dashboard {
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
gap: 18px;
min-width: 0;
}
.dashboard-card {
min-width: 0;
padding: 18px;
border: 1px solid #edf2f7;
background: #fff;
}
.card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.card-head h3 {
min-width: 0;
color: #1e293b;
font-size: 15px;
font-weight: 700;
line-height: 1.35;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-head .mdi {
color: #94a3b8;
font-size: 12px;
}
.risk-window-label {
flex: 0 0 auto;
color: #64748b;
font-size: 12px;
font-weight: 700;
}
.risk-window-controls {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 8px;
}
.risk-window-select {
width: 108px;
}
.risk-trend-panel,
.risk-signal-panel,
.risk-dimension-panel,
.risk-ranking-panel {
grid-column: span 6;
}
.risk-level-panel,
.risk-source-panel,
.risk-effect-panel,
.risk-recent-panel {
grid-column: span 3;
}
.risk-dashboard-state,
.risk-dashboard-empty {
min-height: 220px;
display: grid;
place-content: center;
justify-items: center;
gap: 8px;
color: #64748b;
font-size: 13px;
font-weight: 700;
text-align: center;
}
.risk-dashboard-state i,
.risk-dashboard-empty i {
color: #94a3b8;
font-size: 24px;
}
.risk-dashboard-state.error {
color: #b91c1c;
}
.risk-dashboard-inline-empty {
margin: 0;
color: #94a3b8;
font-size: 12px;
font-weight: 700;
}
.risk-dimension-grid,
.risk-ranking-grid {
display: grid;
gap: 10px;
}
.risk-dimension-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.risk-ranking-grid {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
.risk-dimension-group,
.risk-ranking-group {
min-width: 0;
display: grid;
align-content: start;
gap: 8px;
padding: 12px;
border: 1px solid #edf2f7;
border-radius: 4px;
background: #f8fafc;
}
.risk-dimension-title,
.risk-ranking-title {
color: #334155;
font-size: 12px;
font-weight: 900;
}
.risk-dimension-list {
display: grid;
gap: 8px;
}
.risk-dimension-row {
display: grid;
grid-template-columns: 72px minmax(0, 1fr) 42px;
align-items: center;
gap: 8px;
}
.risk-dimension-row span,
.risk-dimension-row strong {
min-width: 0;
overflow: hidden;
color: #64748b;
font-size: 12px;
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
}
.risk-dimension-row strong {
color: #0f172a;
text-align: right;
}
.risk-dimension-row i {
height: 7px;
overflow: hidden;
border-radius: 999px;
background: #e2e8f0;
}
.risk-dimension-row b {
display: block;
height: 100%;
border-radius: inherit;
background: var(--theme-primary);
}
.risk-ranking-list {
display: grid;
gap: 7px;
margin: 0;
padding: 0;
list-style: none;
}
.risk-ranking-list li {
min-width: 0;
display: grid;
grid-template-columns: 20px minmax(0, 1fr) 38px;
align-items: center;
gap: 8px;
}
.risk-ranking-list em {
display: grid;
place-items: center;
width: 20px;
height: 20px;
border-radius: 999px;
background: #e2e8f0;
color: #475569;
font-size: 11px;
font-style: normal;
font-weight: 900;
}
.risk-ranking-list div {
min-width: 0;
display: grid;
gap: 2px;
}
.risk-ranking-list strong,
.risk-ranking-list small,
.risk-ranking-list span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.risk-ranking-list strong {
color: #0f172a;
font-size: 12px;
font-weight: 850;
}
.risk-ranking-list small {
color: #94a3b8;
font-size: 11px;
font-weight: 700;
}
.risk-ranking-list span {
color: #64748b;
font-size: 12px;
font-weight: 850;
text-align: right;
}
.risk-effect-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.risk-effect-item {
min-height: 84px;
display: grid;
align-content: center;
gap: 6px;
padding: 12px;
border: 1px solid #edf2f7;
border-radius: 4px;
background: #f8fafc;
}
.risk-effect-item span {
color: #64748b;
font-size: 12px;
font-weight: 700;
}
.risk-effect-item strong {
color: #0f172a;
font-size: 22px;
font-weight: 900;
font-variant-numeric: tabular-nums;
}
.risk-recent-list {
display: grid;
gap: 8px;
}
.risk-recent-row {
width: 100%;
min-width: 0;
display: grid;
grid-template-columns: 46px minmax(0, 1fr) 42px;
align-items: center;
gap: 10px;
padding: 10px;
border: 1px solid #edf2f7;
border-radius: 4px;
background: #fff;
text-align: left;
cursor: pointer;
transition: border-color 180ms ease, background 180ms ease;
}
.risk-recent-row:hover:not(:disabled) {
border-color: rgba(var(--theme-primary-rgb), .26);
background: #f8fafc;
}
.risk-recent-row:disabled {
cursor: default;
}
.risk-recent-level {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 24px;
border-radius: 4px;
background: #e2e8f0;
color: #475569;
font-size: 12px;
font-weight: 900;
}
.risk-recent-level.critical,
.risk-recent-level.high {
background: rgba(239, 68, 68, .1);
color: #b91c1c;
}
.risk-recent-level.medium {
background: rgba(245, 158, 11, .12);
color: #b45309;
}
.risk-recent-main {
min-width: 0;
display: grid;
gap: 3px;
}
.risk-recent-main strong,
.risk-recent-main small {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.risk-recent-main strong {
color: #0f172a;
font-size: 13px;
font-weight: 800;
}
.risk-recent-main small {
color: #64748b;
font-size: 11px;
font-weight: 700;
}
.risk-recent-score {
color: #0f172a;
font-size: 16px;
font-weight: 900;
text-align: right;
font-variant-numeric: tabular-nums;
}
@media (max-width: 1280px) {
.risk-trend-panel,
.risk-signal-panel,
.risk-dimension-panel,
.risk-ranking-panel {
grid-column: span 12;
}
.risk-level-panel,
.risk-source-panel,
.risk-effect-panel,
.risk-recent-panel {
grid-column: span 6;
}
}
@media (max-width: 760px) {
.risk-observation-dashboard {
grid-template-columns: minmax(0, 1fr);
}
.risk-trend-panel,
.risk-signal-panel,
.risk-dimension-panel,
.risk-ranking-panel,
.risk-level-panel,
.risk-source-panel,
.risk-effect-panel,
.risk-recent-panel {
grid-column: 1;
}
.risk-dimension-grid,
.risk-ranking-grid {
grid-template-columns: minmax(0, 1fr);
}
}
</style>

View File

@@ -1,7 +1,7 @@
<template>
<header class="topbar" :class="{ 'chat-mode': isChat }">
<div class="title-group">
<div class="eyebrow">{{ isChat ? 'Smart Finance Q&A' : 'Smart Expense Operations' }}</div>
<div class="eyebrow">{{ eyebrowLabel }}</div>
<h1>{{ currentView.title }}</h1>
<p>{{ currentView.desc }}</p>
</div>
@@ -40,10 +40,10 @@
</div>
</div>
<div class="custom-range-wrap">
<button
class="custom-range-btn"
type="button"
<div class="custom-range-wrap">
<button
class="custom-range-btn"
type="button"
:class="{ active: isCustomRange }"
:aria-expanded="calendarOpen"
aria-haspopup="dialog"
@@ -77,10 +77,20 @@
<button class="apply-btn" type="button" :disabled="!canApplyCustomRange" @click="applyCustomRange">
应用
</button>
</footer>
</div>
</div>
</div>
</footer>
</div>
</div>
<div class="dashboard-switch-wrap">
<EnterpriseSelect
v-model="overviewDashboardValue"
class="dashboard-switch-select"
:options="overviewDashboardOptions"
aria-label="选择看板类型"
size="default"
/>
</div>
</div>
</template>
<template v-else-if="isRequestDetail">
@@ -202,8 +212,10 @@
</header>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
<script setup>
import { computed, ref, watch } from 'vue'
import EnterpriseSelect from '../shared/EnterpriseSelect.vue'
const props = defineProps({
currentView: { type: Object, required: true },
@@ -247,31 +259,39 @@ const props = defineProps({
type: Array,
default: () => []
},
customRange: {
type: Object,
default: () => ({ start: '2024-07-06', end: '2024-07-12' })
}
})
const emit = defineEmits([
'update:search',
'update:activeRange',
'update:customRange',
'batchApprove',
'openChat',
'newApplication'
])
const isChat = computed(() => props.activeView === 'chat')
const isOverview = computed(() => props.activeView === 'overview')
const isWorkbench = computed(() => props.activeView === 'workbench')
const isRequestDetail = computed(() => ['requests', 'documents', 'audit', 'digitalEmployees'].includes(props.activeView) && props.detailMode)
customRange: {
type: Object,
default: () => ({ start: '2024-07-06', end: '2024-07-12' })
},
overviewDashboard: {
type: String,
default: 'finance'
}
})
const emit = defineEmits([
'update:search',
'update:activeRange',
'update:customRange',
'update:overviewDashboard',
'batchApprove',
'openChat',
'newApplication'
])
const isChat = computed(() => props.activeView === 'chat')
const isOverview = computed(() => props.activeView === 'overview')
const isWorkbench = computed(() => props.activeView === 'workbench')
const isRequestDetail = computed(() => ['requests', 'documents', 'audit', 'digitalEmployees', 'receiptFolder'].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 isPolicies = computed(() => props.activeView === 'policies')
const isEmployees = computed(() => props.activeView === 'employees')
const eyebrowLabel = computed(() => (
String(props.currentView?.eyebrow || '').trim()
|| (isChat.value ? 'Smart Finance Q&A' : 'Smart Expense Operations')
))
const displayCompanyName = computed(() => String(props.companyName || '远光软件股份有限公司').trim() || '远光软件股份有限公司')
const topbarNotificationCount = computed(() => {
const summary = props.documentSummary ?? {}
@@ -421,11 +441,20 @@ const employeeKpis = computed(() => {
}
]
})
const calendarOpen = ref(false)
const draftStart = ref(props.customRange.start)
const draftEnd = ref(props.customRange.end)
const rangeOptions = computed(() =>
const calendarOpen = ref(false)
const draftStart = ref(props.customRange.start)
const draftEnd = ref(props.customRange.end)
const overviewDashboardOptions = [
{ label: '财务看板', value: 'finance' },
{ label: '风险看板', value: 'risk' },
{ label: '系统看板', value: 'system' }
]
const overviewDashboardValue = computed({
get: () => props.overviewDashboard,
set: (value) => emit('update:overviewDashboard', value)
})
const rangeOptions = computed(() =>
props.ranges.map((range, index) => ({
value: range,
label: String(range)

View File

@@ -0,0 +1,303 @@
<template>
<ElDialog
:model-value="open"
append-to-body
width="420px"
:show-close="false"
:close-on-click-modal="!busy"
:close-on-press-escape="!busy"
class="operation-feedback-dialog"
modal-class="operation-feedback-overlay"
@update:model-value="handleModelUpdate"
@closed="resetForm"
>
<section class="operation-feedback">
<header class="operation-feedback-head">
<span class="operation-feedback-icon">
<i class="mdi mdi-message-star-outline"></i>
</span>
<div>
<h3>评价本轮处理</h3>
<p>请给本轮智能体处理结果打分</p>
</div>
</header>
<div class="operation-feedback-stars" role="radiogroup" aria-label="本轮处理评分">
<button
v-for="score in scores"
:key="score"
type="button"
class="operation-feedback-star"
:class="{ active: score <= rating }"
:disabled="busy"
:aria-checked="rating === score ? 'true' : 'false'"
role="radio"
@click="rating = score"
@mouseenter="hoverRating = score"
@mouseleave="hoverRating = 0"
>
<i :class="score <= displayRating ? 'mdi mdi-star' : 'mdi mdi-star-outline'"></i>
</button>
</div>
<Transition name="feedback-reason-slide">
<label v-if="showReasonInput" class="operation-feedback-reason">
<span>不满意的原因</span>
<textarea
v-model="reason"
maxlength="500"
rows="3"
:disabled="busy"
placeholder="例如:意图识别不准、信息提取遗漏、流程引导不清晰..."
></textarea>
<small>{{ reason.length }}/500</small>
</label>
</Transition>
<p v-if="errorMessage" class="operation-feedback-error">{{ errorMessage }}</p>
<footer class="operation-feedback-actions">
<button type="button" class="operation-feedback-secondary" :disabled="busy" @click="emit('close')">
稍后评价
</button>
<button
type="button"
class="operation-feedback-primary"
:disabled="busy || !rating"
@click="submit"
>
<i v-if="busy" class="mdi mdi-loading mdi-spin"></i>
<span>{{ busy ? '提交中...' : '提交评价' }}</span>
</button>
</footer>
</section>
</ElDialog>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { ElDialog } from 'element-plus/es/components/dialog/index.mjs'
const props = defineProps({
open: { type: Boolean, default: false },
busy: { type: Boolean, default: false },
errorMessage: { type: String, default: '' }
})
const emit = defineEmits(['close', 'submit'])
const scores = [1, 2, 3, 4, 5]
const rating = ref(0)
const hoverRating = ref(0)
const reason = ref('')
const displayRating = computed(() => hoverRating.value || rating.value)
const showReasonInput = computed(() => rating.value > 0 && rating.value <= 3)
function resetForm() {
rating.value = 0
hoverRating.value = 0
reason.value = ''
}
function handleModelUpdate(value) {
if (!value) {
emit('close')
}
}
function submit() {
emit('submit', {
rating: rating.value,
reason: reason.value
})
}
watch(
() => props.open,
(open) => {
if (open) {
resetForm()
}
}
)
</script>
<style scoped>
:deep(.operation-feedback-dialog) {
border-radius: 8px;
}
:deep(.operation-feedback-dialog .el-dialog__header) {
display: none;
}
:deep(.operation-feedback-dialog .el-dialog__body) {
padding: 20px;
}
.operation-feedback {
display: flex;
flex-direction: column;
gap: 18px;
padding: 2px 2px 0;
}
.operation-feedback-head {
display: flex;
gap: 12px;
align-items: flex-start;
}
.operation-feedback-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
border-radius: 6px;
background: #eef4ff;
color: #1d4ed8;
font-size: 20px;
}
.operation-feedback h3 {
margin: 0;
color: #172033;
font-size: 17px;
font-weight: 700;
}
.operation-feedback p {
margin: 5px 0 0;
color: #667085;
font-size: 13px;
line-height: 1.6;
}
.operation-feedback-stars {
display: flex;
justify-content: center;
gap: 8px;
padding: 6px 0 2px;
}
.operation-feedback-star {
display: inline-flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
border: 1px solid #d6deea;
border-radius: 6px;
background: #fff;
color: #9aa8bb;
cursor: pointer;
font-size: 24px;
transition: border-color 180ms ease, color 180ms ease, background 180ms ease;
}
.operation-feedback-star:hover,
.operation-feedback-star.active {
border-color: #f59e0b;
background: #fff7ed;
color: #f59e0b;
}
.operation-feedback-star:disabled {
cursor: not-allowed;
opacity: 0.7;
}
.operation-feedback-reason {
display: flex;
flex-direction: column;
gap: 8px;
}
.operation-feedback-reason span {
color: #344054;
font-size: 13px;
font-weight: 600;
}
.operation-feedback-reason textarea {
width: 100%;
box-sizing: border-box;
resize: vertical;
min-height: 82px;
border: 1px solid #d0d5dd;
border-radius: 6px;
padding: 10px 12px;
color: #172033;
font: inherit;
line-height: 1.5;
outline: none;
transition: border-color 180ms ease, box-shadow 180ms ease;
}
.operation-feedback-reason textarea:focus {
border-color: #2563eb;
box-shadow: 0 0 0 3px rgb(37 99 235 / 12%);
}
.operation-feedback-reason small {
align-self: flex-end;
color: #98a2b3;
font-size: 12px;
}
.operation-feedback-error {
margin: 0;
color: #b42318;
font-size: 13px;
}
.operation-feedback-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.operation-feedback-secondary,
.operation-feedback-primary {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-width: 88px;
height: 34px;
border-radius: 6px;
border: 1px solid #d0d5dd;
padding: 0 14px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.operation-feedback-secondary {
background: #fff;
color: #344054;
}
.operation-feedback-primary {
border-color: #2563eb;
background: #2563eb;
color: #fff;
}
.operation-feedback-secondary:disabled,
.operation-feedback-primary:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.feedback-reason-slide-enter-active,
.feedback-reason-slide-leave-active {
transition: opacity 180ms ease, transform 180ms ease;
}
.feedback-reason-slide-enter-from,
.feedback-reason-slide-leave-to {
opacity: 0;
transform: translateY(-6px);
}
</style>

View File

@@ -0,0 +1,474 @@
<template>
<section
ref="feedbackRootRef"
class="operation-feedback-inline"
:class="{ 'is-submitted': submitted }"
:aria-labelledby="feedbackTitleId"
>
<header class="operation-feedback-inline-head">
<strong :id="feedbackTitleId">这次处理符合预期吗</strong>
<button
v-if="!submitted"
type="button"
class="operation-feedback-link"
:disabled="busy"
@click="emit('dismiss')"
>
稍后
</button>
</header>
<div
class="operation-feedback-stars"
role="radiogroup"
:aria-labelledby="feedbackTitleId"
:aria-describedby="feedbackDescriptionId"
@mouseleave="hoverRating = 0"
>
<button
v-for="option in ratingOptions"
:key="option.value"
type="button"
class="operation-feedback-star"
:class="{ active: option.value === effectiveRating, preview: option.value <= displayRating }"
:disabled="busy || submitted"
:aria-checked="effectiveRating === option.value ? 'true' : 'false'"
:aria-label="`${option.value}星,${option.label}`"
:data-score="option.value"
role="radio"
:tabindex="resolveRatingTabIndex(option.value)"
@click="selectRating(option.value)"
@mouseenter="hoverRating = option.value"
@focus="hoverRating = option.value"
@blur="hoverRating = 0"
@keydown="handleRatingKeydown($event, option.value)"
>
<i
aria-hidden="true"
:class="option.value <= displayRating ? 'mdi mdi-star' : 'mdi mdi-star-outline'"
></i>
</button>
</div>
<p :id="feedbackDescriptionId" class="operation-feedback-status" aria-live="polite">
{{ selectedFeedbackText }}
</p>
<p v-if="submitted" class="operation-feedback-thanks" aria-live="polite">
<i class="mdi mdi-check-circle-outline" aria-hidden="true"></i>
<span>感谢您的反馈谢谢</span>
</p>
<Transition name="feedback-reason-slide">
<div v-if="showReasonInput" class="operation-feedback-low-rating">
<label class="operation-feedback-reason">
<span>哪里不符合预期</span>
<textarea
v-model="reason"
maxlength="500"
rows="2"
:disabled="busy"
placeholder="例如:意图识别不准、信息提取遗漏..."
></textarea>
<small>{{ reason.length }}/500</small>
</label>
<button
type="button"
class="operation-feedback-primary"
:disabled="busy || !rating"
@click="submit"
>
<i v-if="busy" class="mdi mdi-loading mdi-spin"></i>
<span>{{ busy ? '提交中...' : '提交' }}</span>
</button>
</div>
</Transition>
<p v-if="errorMessage" class="operation-feedback-error">{{ errorMessage }}</p>
</section>
</template>
<script setup>
import { computed, nextTick, ref, watch } from 'vue'
const props = defineProps({
busy: { type: Boolean, default: false },
errorMessage: { type: String, default: '' },
resetKey: { type: String, default: '' },
submitted: { type: Boolean, default: false },
submittedRating: { type: Number, default: 0 }
})
const emit = defineEmits(['dismiss', 'submit'])
const feedbackIdSuffix = Math.random().toString(36).slice(2, 10)
const feedbackTitleId = `operation-feedback-title-${feedbackIdSuffix}`
const feedbackDescriptionId = `operation-feedback-note-${feedbackIdSuffix}`
const ratingOptions = [
{ value: 1, label: '很差' },
{ value: 2, label: '不满意' },
{ value: 3, label: '一般' },
{ value: 4, label: '满意' },
{ value: 5, label: '很好' }
]
const rating = ref(0)
const hoverRating = ref(0)
const reason = ref('')
const feedbackRootRef = ref(null)
const normalizedSubmittedRating = computed(() => {
const score = Number(props.submittedRating || 0)
return Number.isInteger(score) && score >= 1 && score <= 5 ? score : 0
})
const effectiveRating = computed(() => (props.submitted ? normalizedSubmittedRating.value || rating.value : rating.value))
const displayRating = computed(() => (props.submitted ? effectiveRating.value : hoverRating.value || rating.value))
const selectedOption = computed(() => ratingOptions.find((option) => option.value === effectiveRating.value) || null)
const selectedFeedbackText = computed(() => (
props.submitted
? '感谢您的反馈。谢谢'
: selectedOption.value
? rating.value <= 3
? `已选择 ${selectedOption.value.value} 星,可补充原因后提交。`
: `已选择 ${selectedOption.value.value} 星,按 Enter 确认。`
: '请选择一个评分。'
))
const showReasonInput = computed(() => !props.submitted && rating.value > 0 && rating.value <= 3)
function focusRatingButton(score) {
nextTick(() => {
feedbackRootRef.value
?.querySelector(`[data-score="${score}"]`)
?.focus()
})
}
function selectRating(score, options = {}) {
if (props.busy || props.submitted) {
return
}
const shouldSubmitHighRating = options.submitHighRating !== false
rating.value = score
if (score > 3 && shouldSubmitHighRating) {
reason.value = ''
emit('submit', {
rating: score,
reason: ''
})
}
}
function resolveRatingTabIndex(score) {
if (props.submitted) {
return -1
}
return rating.value
? rating.value === score ? 0 : -1
: score === 1 ? 0 : -1
}
function handleRatingKeydown(event, score) {
const key = event.key
const currentIndex = ratingOptions.findIndex((option) => option.value === score)
if (currentIndex < 0) {
return
}
const lastIndex = ratingOptions.length - 1
let nextIndex = currentIndex
if (['ArrowRight', 'ArrowDown'].includes(key)) {
nextIndex = currentIndex === lastIndex ? 0 : currentIndex + 1
} else if (['ArrowLeft', 'ArrowUp'].includes(key)) {
nextIndex = currentIndex === 0 ? lastIndex : currentIndex - 1
} else if (key === 'Home') {
nextIndex = 0
} else if (key === 'End') {
nextIndex = lastIndex
} else {
return
}
event.preventDefault()
const nextScore = ratingOptions[nextIndex].value
selectRating(nextScore, { submitHighRating: false })
focusRatingButton(nextScore)
}
function resetForm() {
rating.value = 0
hoverRating.value = 0
reason.value = ''
}
function submit() {
if (props.submitted) {
return
}
emit('submit', {
rating: rating.value,
reason: reason.value
})
}
watch(
() => props.resetKey,
() => resetForm()
)
watch(
() => props.submittedRating,
(nextRating) => {
const score = Number(nextRating || 0)
if (props.submitted && Number.isInteger(score) && score >= 1 && score <= 5) {
rating.value = score
hoverRating.value = 0
}
}
)
</script>
<style scoped>
.operation-feedback-inline {
width: fit-content;
max-width: min(100%, 360px);
display: inline-grid;
grid-template-columns: minmax(0, auto);
gap: 8px;
padding: 9px 11px;
border: 1px solid #d8e2ee;
border-radius: 14px;
background: #ffffff;
color: #24324a;
box-shadow: 0 8px 18px rgb(148 163 184 / 12%);
}
.operation-feedback-inline.is-submitted {
border-color: #d0d5dd;
background: #f8fafc;
color: #475467;
box-shadow: none;
}
.operation-feedback-inline-head {
display: flex;
justify-content: space-between;
gap: 10px;
align-items: center;
}
.operation-feedback-inline strong {
color: #172033;
font-size: 12px;
font-weight: 760;
line-height: 1.35;
}
.operation-feedback-link {
flex: 0 0 auto;
height: 22px;
padding: 0 6px;
border: 1px solid transparent;
border-radius: 4px;
background: transparent;
color: #475467;
font-size: 11px;
font-weight: 700;
cursor: pointer;
transition: border-color 0.18s ease, color 0.18s ease, background 0.18s ease;
}
.operation-feedback-link:hover,
.operation-feedback-link:focus-visible {
border-color: #c8d5e6;
background: #f5f8fc;
color: #1d4ed8;
outline: none;
}
.operation-feedback-stars {
display: flex;
align-items: center;
gap: 3px;
}
.operation-feedback-star {
width: 24px;
height: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border: 1px solid transparent;
border-radius: 4px;
background: transparent;
color: #98a2b3;
cursor: pointer;
font-size: 16px;
line-height: 1;
transition: border-color 0.16s ease, color 0.16s ease, background 0.16s ease;
}
.operation-feedback-star:hover,
.operation-feedback-star:focus-visible {
border-color: #d69a2d;
background: #fffaf0;
color: #b7791f;
outline: none;
}
.operation-feedback-star.preview {
color: #b7791f;
}
.operation-feedback-star.active {
border-color: #c78315;
background: #fff7e6;
color: #8a4f00;
}
.operation-feedback-star:disabled {
cursor: not-allowed;
opacity: 0.7;
}
.operation-feedback-inline.is-submitted .operation-feedback-star.preview {
color: #667085;
}
.operation-feedback-inline.is-submitted .operation-feedback-star.active {
border-color: #d0d5dd;
background: #eef2f6;
color: #475467;
}
.operation-feedback-status {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
.operation-feedback-low-rating {
display: grid;
gap: 7px;
min-width: min(320px, calc(100vw - 96px));
}
.operation-feedback-reason {
display: grid;
gap: 6px;
}
.operation-feedback-reason span {
color: #344054;
font-size: 11px;
font-weight: 760;
}
.operation-feedback-reason textarea {
width: 100%;
box-sizing: border-box;
resize: vertical;
min-height: 54px;
padding: 7px 9px;
border: 1px solid #d0d5dd;
border-radius: 4px;
background: #ffffff;
color: #172033;
font: inherit;
font-size: 12px;
line-height: 1.5;
outline: none;
transition: border-color 0.18s ease, box-shadow 0.18s ease;
}
.operation-feedback-reason textarea:focus {
border-color: #2563eb;
box-shadow: 0 0 0 3px rgb(37 99 235 / 12%);
}
.operation-feedback-reason small {
justify-self: end;
color: #98a2b3;
font-size: 11px;
}
.operation-feedback-error {
margin: 0;
color: #b42318;
font-size: 12px;
}
.operation-feedback-thanks {
display: inline-flex;
align-items: center;
gap: 5px;
margin: 0;
color: #475467;
font-size: 12px;
font-weight: 760;
line-height: 1.45;
}
.operation-feedback-thanks i {
color: #667085;
font-size: 14px;
}
.operation-feedback-primary {
justify-self: end;
min-width: 58px;
height: 26px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 0 10px;
border: 1px solid #2563eb;
border-radius: 4px;
font-size: 11px;
font-weight: 760;
cursor: pointer;
transition: border-color 0.18s ease, background 0.18s ease, opacity 0.18s ease;
}
.operation-feedback-primary {
background: #2563eb;
color: #ffffff;
}
.operation-feedback-primary:hover:not(:disabled),
.operation-feedback-primary:focus-visible {
border-color: #1d4ed8;
background: #1d4ed8;
outline: none;
}
.operation-feedback-link:disabled,
.operation-feedback-primary:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.feedback-reason-slide-enter-active,
.feedback-reason-slide-leave-active {
transition: opacity 0.18s ease, transform 0.18s ease;
}
.feedback-reason-slide-enter-from,
.feedback-reason-slide-leave-to {
opacity: 0;
transform: translateY(-6px);
}
@media (max-width: 540px) {
.operation-feedback-inline {
max-width: 100%;
}
.operation-feedback-low-rating {
min-width: min(280px, calc(100vw - 80px));
}
}
</style>

View File

@@ -121,6 +121,13 @@
</ul>
</div>
<div v-if="buildTraceItems(message.result).length" class="risk-sim-evidence">
<span>执行路径</span>
<ul>
<li v-for="item in buildTraceItems(message.result)" :key="item">{{ item }}</li>
</ul>
</div>
<div v-if="message.result.missing_fields?.length" class="risk-sim-missing-fields">
<span>待补充字段</span>
<b v-for="field in message.result.missing_fields" :key="field.key">
@@ -306,6 +313,7 @@ import {
buildEvidenceItems as buildEvidenceItemsModel,
buildRecognizedFieldRows as buildRecognizedFieldRowsModel,
buildResultFields as buildResultFieldsModel,
buildTraceItems as buildTraceItemsModel,
formatDocumentMeta,
formatFieldLabel,
resolveFileStatusLabel,
@@ -662,6 +670,10 @@ function buildEvidenceItems(result) {
return buildEvidenceItemsModel(result, fields.value)
}
function buildTraceItems(result) {
return buildTraceItemsModel(result, fields.value)
}
function toAttachmentPayload(file) {
const document = file.ocrDocument || {}
return {

View File

@@ -58,6 +58,18 @@ export function buildEvidenceItems(result, fields = []) {
return [...new Set(items)].slice(0, 5)
}
export function buildTraceItems(result, fields = []) {
const steps = Array.isArray(result?.trace?.steps) ? result.trace.steps : []
return steps.slice(0, 6).map((step, index) => {
const title = String(step?.title || step?.node_id || `判断 ${index + 1}`).trim()
const status = step?.result ? '成立' : '不成立'
const inputs = step?.inputs && typeof step.inputs === 'object'
? Object.entries(step.inputs).slice(0, 3).map(([key, value]) => `${formatFieldName(key, fields)}=${formatDebugValue(value)}`).join('')
: ''
return inputs ? `${title}${status}${inputs}` : `${title}${status}`
})
}
export function buildDocumentBrief(document) {
const fields = Array.isArray(document?.document_fields) ? document.document_fields : []
if (fields.length) {

View File

@@ -0,0 +1,381 @@
<template>
<article v-if="visible" class="detail-card panel risk-observation-evidence-card">
<div class="detail-card-head">
<div>
<h3 class="detail-card-title-with-icon">
<i class="mdi mdi-shield-search"></i>
<span>风险证据链</span>
</h3>
<p>展示当前单据进入统一风险观察池后的评分证据图谱关系和反馈状态</p>
</div>
<button
class="risk-evidence-refresh"
type="button"
:disabled="loading"
@click="loadObservations"
>
<i :class="loading ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-refresh'"></i>
<span>刷新</span>
</button>
</div>
<div v-if="loading" class="risk-evidence-state">
<i class="mdi mdi-loading mdi-spin"></i>
<span>正在加载风险证据链</span>
</div>
<div v-else-if="errorMessage" class="risk-evidence-state error">
<i class="mdi mdi-alert-circle-outline"></i>
<span>{{ errorMessage }}</span>
</div>
<template v-else-if="mainObservation">
<div class="risk-evidence-current-head">
<div>
<span>当前风险观察详情</span>
<strong>{{ mainObservation.title || formatSignal(mainObservation.riskSignal) }}</strong>
</div>
<em>{{ activeObservationPosition }}</em>
</div>
<div
:id="detailRegionId"
class="risk-evidence-detail-region"
role="region"
aria-live="polite"
>
<div class="risk-evidence-summary">
<div class="risk-evidence-score" :class="mainObservation.riskLevel">
<strong>{{ mainObservation.riskScore }}</strong>
<span>{{ formatRiskLevel(mainObservation.riskLevel) }}</span>
</div>
<div class="risk-evidence-copy">
<h4>{{ mainObservation.title || formatSignal(mainObservation.riskSignal) }}</h4>
<p>{{ mainObservation.description || '暂无风险描述。' }}</p>
<div class="risk-evidence-meta">
<span>{{ formatSource(mainObservation.source) }}</span>
<span>{{ mainObservation.algorithmVersion || '未记录算法版本' }}</span>
<span>{{ formatFeedbackStatus(mainObservation.feedbackStatus) }}</span>
</div>
</div>
</div>
<div class="risk-evidence-grid">
<section class="risk-evidence-section">
<span class="risk-evidence-section-title">贡献分</span>
<div class="risk-score-list">
<div v-for="item in scoreItems" :key="item.key" class="risk-score-row">
<span>{{ item.label }}</span>
<i><b :style="{ width: item.width }"></b></i>
<strong>{{ item.value }}</strong>
</div>
</div>
</section>
<section class="risk-evidence-section">
<span class="risk-evidence-section-title">证据</span>
<ul v-if="evidenceRows.length" class="risk-evidence-list">
<li v-for="item in evidenceRows" :key="item.key">
<strong>{{ item.title }}</strong>
<span>{{ item.detail }}</span>
</li>
</ul>
<p v-else class="risk-evidence-empty">暂无证据明细</p>
</section>
<section class="risk-evidence-section">
<span class="risk-evidence-section-title">图谱关系</span>
<div class="risk-chip-list">
<span v-for="item in graphItems" :key="item">{{ item }}</span>
<em v-if="!graphItems.length">暂无图谱关系</em>
</div>
</section>
<section class="risk-evidence-section">
<span class="risk-evidence-section-title">基线与建议</span>
<div class="risk-chip-list">
<span v-for="item in baselineAndDecisionItems" :key="item">{{ item }}</span>
<em v-if="!baselineAndDecisionItems.length">暂无基线或建议动作</em>
</div>
</section>
<section class="risk-evidence-section">
<span class="risk-evidence-section-title">制度与相似案例</span>
<div class="risk-chip-list">
<span v-for="item in policyAndSimilarItems" :key="item">{{ item }}</span>
<em v-if="!policyAndSimilarItems.length">暂无制度引用或相似案例</em>
</div>
</section>
<section class="risk-evidence-section">
<span class="risk-evidence-section-title">反馈历史</span>
<ul v-if="feedbackRows.length" class="risk-evidence-list">
<li v-for="item in feedbackRows" :key="item.key">
<strong>{{ item.title }}</strong>
<span>{{ item.detail }}</span>
</li>
</ul>
<p v-else class="risk-evidence-empty">暂无人工反馈</p>
</section>
</div>
</div>
<div v-if="observations.length > 1" class="risk-observation-list">
<button
v-for="(item, index) in observations"
:key="observationIdentity(item, index)"
type="button"
class="risk-observation-row"
:class="{ active: isActiveObservation(item, index) }"
:aria-pressed="isActiveObservation(item, index)"
:aria-controls="detailRegionId"
@click="selectObservation(item, index)"
>
<span>{{ formatRiskLevel(item.riskLevel) }}</span>
<strong>{{ item.title || formatSignal(item.riskSignal) }}</strong>
<em>{{ item.riskScore }}</em>
</button>
</div>
</template>
</article>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { fetchClaimRiskObservations } from '../../services/riskObservations.js'
const props = defineProps({
claimId: { type: String, default: '' }
})
const observations = ref([])
const loading = ref(false)
const errorMessage = ref('')
const activeObservationKey = ref('')
const detailRegionId = 'risk-observation-active-detail'
let loadSequence = 0
const visible = computed(() =>
loading.value || Boolean(errorMessage.value) || observations.value.length > 0
)
const mainObservation = computed(() => {
if (!observations.value.length) {
return null
}
return (
observations.value.find((item, index) => isActiveObservation(item, index))
|| observations.value[0]
)
})
const activeObservationPosition = computed(() => {
if (observations.value.length <= 1) {
return '单条观察'
}
const activeIndex = observations.value.findIndex((item, index) =>
isActiveObservation(item, index)
)
return activeIndex >= 0 ? `${activeIndex + 1} / ${observations.value.length}` : '未选择'
})
const scoreItems = computed(() => {
const scores = mainObservation.value?.contributionScores || {}
return Object.entries(scores).map(([key, value]) => {
const numericValue = Math.max(0, Math.min(Number(value || 0), 100))
return {
key,
label: formatScoreLabel(key),
value: Math.round(numericValue),
width: `${numericValue}%`
}
})
})
const evidenceRows = computed(() =>
(mainObservation.value?.evidence || []).slice(0, 6).map((item, index) => ({
key: `${item.code || item.title || index}`,
title: String(item.title || item.code || item.source || `证据 ${index + 1}`).trim(),
detail: String(item.detail || item.message || item.summary || '').trim() || '已记录证据。'
}))
)
const graphItems = computed(() => [
...(mainObservation.value?.graphNodeKeys || []).slice(0, 4),
...(mainObservation.value?.graphEdgeKeys || []).slice(0, 4)
].map(formatChipValue).filter(Boolean))
const baselineAndDecisionItems = computed(() => [
...formatRecordItems(mainObservation.value?.baseline, '基线'),
...formatRecordItems(mainObservation.value?.decisionTrace, '建议')
])
const policyAndSimilarItems = computed(() => [
...(mainObservation.value?.policyRefs || [])
.map(formatChipValue)
.filter(Boolean)
.map((item) => `制度:${item}`),
...(mainObservation.value?.similarCaseClaimIds || [])
.map(formatChipValue)
.filter(Boolean)
.map((item) => `相似案例:${item}`)
])
const feedbackRows = computed(() =>
(mainObservation.value?.feedbackItems || []).slice(0, 5).map((item, index) => {
const feedbackType = item.feedbackType || item.feedback_type || item.type
const actor = String(item.actor || item.createdBy || item.created_by || '未记录人员').trim()
const createdAt = String(item.createdAt || item.created_at || '').trim()
const comment = String(item.comment || item.remark || item.message || '').trim()
return {
key: `${item.id || createdAt || index}`,
title: `${formatFeedbackType(feedbackType)} · ${actor}`,
detail: [comment || '已记录反馈。', createdAt].filter(Boolean).join(' · ')
}
})
)
watch(
() => props.claimId,
() => {
void loadObservations()
},
{ immediate: true }
)
async function loadObservations() {
const claimId = String(props.claimId || '').trim()
const sequence = ++loadSequence
errorMessage.value = ''
if (!claimId) {
observations.value = []
activeObservationKey.value = ''
loading.value = false
return
}
loading.value = true
try {
const payload = await fetchClaimRiskObservations(claimId)
if (sequence !== loadSequence) {
return
}
observations.value = payload
activeObservationKey.value = observationIdentity(payload[0], 0)
} catch (error) {
if (sequence === loadSequence) {
observations.value = []
activeObservationKey.value = ''
errorMessage.value = error?.message || '风险证据链加载失败。'
}
} finally {
if (sequence === loadSequence) {
loading.value = false
}
}
}
function observationIdentity(item, index = -1) {
const explicitKey = String(item?.observationKey || item?.id || '').trim()
if (explicitKey) {
return explicitKey
}
return [
item?.claimId,
item?.riskSignal,
item?.createdAt,
index >= 0 ? index : ''
].map((value) => String(value || '').trim()).filter(Boolean).join(':')
}
function isActiveObservation(item, index = -1) {
return observationIdentity(item, index) === activeObservationKey.value
}
function selectObservation(item, index = -1) {
const key = observationIdentity(item, index)
if (key) {
activeObservationKey.value = key
}
}
function formatScoreLabel(value) {
const labels = {
S_rule: '规则命中',
S_anomaly: '画像偏离',
S_graph: '图谱异常',
S_policy: '制度约束',
S_history: '历史反馈'
}
return labels[value] || value
}
function formatChipValue(value) {
if (typeof value === 'string') {
return value.trim()
}
if (value && typeof value === 'object') {
return String(value.key || value.edge_key || value.node_key || JSON.stringify(value)).trim()
}
return String(value || '').trim()
}
function formatRecordItems(value, prefix) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return []
}
return Object.entries(value)
.slice(0, 6)
.map(([key, item]) => `${prefix}${key}=${formatChipValue(item)}`)
.filter(Boolean)
}
function formatRiskLevel(value) {
const labels = {
critical: '重大风险',
high: '高风险',
medium: '中风险',
low: '低风险'
}
return labels[String(value || '').trim()] || '未知风险'
}
function formatSignal(value) {
const text = String(value || '').trim()
const labels = {
duplicate_invoice: '重复发票',
split_billing: '拆分报销',
frequent_small_claims: '高频小额',
location_mismatch: '地点不一致',
amount_outlier: '金额异常',
preapproval_absent: '缺少事前申请'
}
return labels[text] || text.replace(/_/g, ' ') || '未知风险'
}
function formatSource(value) {
const labels = {
financial_risk_graph: '风险图谱',
rule_center: '规则中心'
}
return labels[String(value || '').trim()] || '风险观察池'
}
function formatFeedbackType(value) {
const labels = {
confirm: '确认风险',
confirmed: '确认风险',
false_positive: '标记误报',
ignore: '忽略',
ignored: '忽略',
resolve: '已处理',
resolved: '已处理',
comment: '备注'
}
return labels[String(value || '').trim()] || '人工反馈'
}
function formatFeedbackStatus(value) {
const labels = {
confirmed: '已确认',
false_positive: '已标记误报',
ignored: '已忽略',
resolved: '已处理',
unreviewed: '未复核'
}
return labels[String(value || '').trim()] || '未复核'
}
</script>
<style scoped src="../../assets/styles/components/risk-observation-evidence-card.css"></style>

View File

@@ -170,7 +170,6 @@
<time>{{ ui.formatFlowStepDuration(step) }}</time>
</div>
</header>
<p class="flow-step-tool">工具{{ step.tool }}</p>
<p class="flow-step-detail">{{ ui.resolveFlowStepDetail(step) }}</p>
<p v-if="step.error" class="flow-step-error">{{ step.error }}</p>
</div>
@@ -379,7 +378,6 @@
<time>{{ ui.formatFlowStepDuration(step) }}</time>
</div>
</header>
<p class="flow-step-tool">工具{{ step.tool }}</p>
<p class="flow-step-detail">{{ ui.resolveFlowStepDetail(step) }}</p>
<p v-if="step.error" class="flow-step-error">{{ step.error }}</p>
</div>

View File

@@ -62,14 +62,14 @@
role="row"
:tabindex="row.editable && !ui.submitting && !ui.reviewActionBusy && !ui.sessionSwitchBusy ? 0 : -1"
:aria-label="row.editable ? `编辑${row.label}` : row.label"
@click="row.editable && !ui.isApplicationPreviewEditing(message, row.key) && !ui.submitting && !ui.reviewActionBusy && !ui.sessionSwitchBusy && ui.openApplicationPreviewEditor(message, row.key, row.value)"
@click.stop="row.editable && !ui.isApplicationPreviewEditing(message, row.key) && !ui.submitting && !ui.reviewActionBusy && !ui.sessionSwitchBusy && ui.openApplicationPreviewEditor(message, row.key, row.value)"
@keydown.enter.prevent="row.editable && !ui.isApplicationPreviewEditing(message, row.key) && !ui.submitting && !ui.reviewActionBusy && !ui.sessionSwitchBusy && ui.openApplicationPreviewEditor(message, row.key, row.value)"
@keydown.space.prevent="row.editable && !ui.isApplicationPreviewEditing(message, row.key) && !ui.submitting && !ui.reviewActionBusy && !ui.sessionSwitchBusy && ui.openApplicationPreviewEditor(message, row.key, row.value)"
>
<span class="application-preview-label" role="cell">{{ row.label }}</span>
<span class="application-preview-value" role="cell">
<input
v-if="ui.isApplicationPreviewEditing(message, row.key) && ui.resolveApplicationPreviewEditorControl(row.key) !== 'select'"
v-if="ui.isApplicationPreviewEditing(message, row.key) && ui.resolveApplicationPreviewEditorControl(row.key) === 'text'"
v-model="ui.applicationPreviewEditor.draftValue"
class="application-preview-input"
type="text"
@@ -79,7 +79,7 @@
@blur="ui.commitApplicationPreviewEditor(message)"
/>
<EnterpriseSelect
v-else-if="ui.isApplicationPreviewEditing(message, row.key)"
v-else-if="ui.isApplicationPreviewEditing(message, row.key) && ui.resolveApplicationPreviewEditorControl(row.key) === 'select'"
v-model="ui.applicationPreviewEditor.draftValue"
class="application-preview-select"
:options="ui.resolveApplicationPreviewEditorOptions(row.key)"
@@ -92,7 +92,10 @@
@blur="ui.commitApplicationPreviewEditor(message)"
/>
<template v-else>
<span class="application-preview-text">{{ row.value }}</span>
<span
class="application-preview-text"
:class="{ 'application-preview-date-chip': row.key === 'time' && !row.missing }"
>{{ row.value }}</span>
<button
v-if="row.editable"
type="button"
@@ -156,17 +159,6 @@
</div>
</div>
<div v-if="message.role === 'assistant' && !message.reviewPayload && !message.queryPayload && message.meta?.length" class="message-meta-row">
<span
v-for="item in message.meta"
:key="item"
class="message-meta-chip"
:class="message.metaTone"
>
{{ item }}
</span>
</div>
<div
v-if="message.role === 'assistant' && !message.reviewPayload && !message.queryPayload && message.suggestedActions?.length"
class="message-suggested-actions"
@@ -413,12 +405,52 @@
</div>
</div>
<div v-if="message.role === 'assistant' && !message.reviewPayload && message.draftPayload" class="draft-preview">
<header>
<strong>{{ message.draftPayload.title }}</strong>
<span>待人工确认</span>
</header>
<pre>{{ message.draftPayload.body }}</pre>
<div
v-if="message.role === 'assistant' && !message.reviewPayload && message.draftPayload"
class="draft-preview"
:class="{ 'application-draft-preview': ui.isApplicationDraftPayload(message.draftPayload) }"
>
<template v-if="ui.isApplicationDraftPayload(message.draftPayload)">
<header class="application-draft-head">
<span class="application-draft-icon" aria-hidden="true">
<i class="mdi mdi-file-document-check-outline"></i>
</span>
<span class="application-draft-title">
<strong>申请单据已生成</strong>
<small>已为本次业务生成申请单请按需查看完整详情</small>
</span>
<span class="application-draft-status">{{ ui.resolveApplicationDraftStatusLabel(message.draftPayload) }}</span>
</header>
<div class="application-draft-brief" role="group" aria-label="申请单据简要信息">
<div
v-for="item in ui.buildApplicationDraftSummaryItems(message.draftPayload)"
:key="`${message.id}-application-draft-${item.label}`"
class="application-draft-brief-item"
:class="{ 'is-primary': item.label === '单号' }"
>
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</div>
</div>
<footer class="application-draft-footer">
<p>
完整审批链附件和明细可在单据详情中
<button
type="button"
class="application-draft-detail-link"
:disabled="ui.submitting || ui.reviewActionBusy || ui.sessionSwitchBusy"
@click="ui.openApplicationDraftDetail(message)"
>查看</button>
</p>
</footer>
</template>
<template v-else>
<header>
<strong>{{ message.draftPayload.title }}</strong>
<span>待人工确认</span>
</header>
<pre>{{ message.draftPayload.body }}</pre>
</template>
</div>
<div v-if="message.attachments?.length" class="message-files">
@@ -428,18 +460,35 @@
</span>
</div>
</div>
<div
v-if="ui.isOperationFeedbackVisible(message)"
class="message-feedback-bubble"
>
<OperationFeedbackInlineCard
:busy="Boolean(message.operationFeedback?.submitting)"
:error-message="message.operationFeedback?.error || ''"
:submitted="Boolean(message.operationFeedback?.submitted)"
:submitted-rating="Number(message.operationFeedback?.rating || 0)"
:reset-key="`${message.id}-${message.operationFeedback?.context?.runId || message.operationFeedback?.context?.run_id || ''}`"
@dismiss="ui.dismissOperationFeedbackForMessage(message)"
@submit="ui.submitOperationFeedbackForMessage(message, $event)"
/>
</div>
</article>
</template>
<script>
import BudgetAssistantReport from './BudgetAssistantReport.vue'
import EnterpriseSelect from '../shared/EnterpriseSelect.vue'
import OperationFeedbackInlineCard from '../shared/OperationFeedbackInlineCard.vue'
export default {
name: 'TravelReimbursementMessageItem',
components: {
BudgetAssistantReport,
EnterpriseSelect
EnterpriseSelect,
OperationFeedbackInlineCard
},
props: {
message: {

View File

@@ -1,15 +1,21 @@
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useNavigation, navItems } from './useNavigation.js'
import { useRequests } from './useRequests.js'
import { useRequests } from './useRequests.js'
import { useSystemState } from './useSystemState.js'
import { useToast } from './useToast.js'
import { fetchLatestConversation } from '../services/orchestrator.js'
import { clearAssistantSessionSnapshotForDraftClaim } from '../utils/assistantSessionSnapshot.js'
import { buildDetailAlerts } from '../utils/detailAlerts.js'
import { normalizeRequestForUi } from '../utils/requestViewModel.js'
import { buildWorkbenchSummary } from '../utils/workbenchSummary.js'
import { useToast } from './useToast.js'
import { fetchOntologyParse } from '../services/ontology.js'
import { fetchLatestConversation } from '../services/orchestrator.js'
import { clearAssistantSessionSnapshotForDraftClaim } from '../utils/assistantSessionSnapshot.js'
import { buildDetailAlerts } from '../utils/detailAlerts.js'
import { normalizeRequestForUi } from '../utils/requestViewModel.js'
import {
buildWorkbenchIntentOntologyContext,
resolveWorkbenchSessionTypeFallback,
resolveWorkbenchSessionTypeFromOntology
} from '../utils/workbenchAssistantIntent.js'
import { buildWorkbenchSummary } from '../utils/workbenchSummary.js'
const SESSION_TYPE_EXPENSE = 'expense'
const SMART_ENTRY_SOURCE_APPLICATION = 'application'
@@ -24,10 +30,11 @@ export function useAppShell() {
prompt: '',
source: 'documents',
request: null,
files: [],
conversation: null,
scope: null
})
files: [],
conversation: null,
scope: null,
sessionType: ''
})
const smartEntrySessionId = ref(0)
const smartEntryRevealToken = ref(0)
const smartEntryInvalidatedDraftClaimId = ref('')
@@ -48,10 +55,10 @@ export function useAppShell() {
ensureLoaded: ensureRequestsLoaded,
reload: reloadRequests
} = useRequests()
const { currentUser } = useSystemState()
const { toast } = useToast()
const customRange = ref({ start: '2024-07-06', end: '2024-07-12' })
const { currentUser } = useSystemState()
const { toast } = useToast()
const customRange = ref({ start: '2024-07-06', end: '2024-07-12' })
const selectedRequest = computed(() => {
const requestId = String(route.params.requestId || '')
@@ -171,7 +178,8 @@ export function useAppShell() {
request: null,
files: [],
conversation: null,
scope: null
scope: null,
sessionType: ''
}
smartEntrySessionId.value += 1
}
@@ -209,6 +217,52 @@ export function useAppShell() {
return String(payload.source || '').trim() === 'detail' && Boolean(resolveSmartEntryClaimScope(payload))
}
async function resolveSmartEntrySessionType(payload = {}) {
const explicitSessionType = String(payload.sessionType || '').trim()
if (explicitSessionType) {
return explicitSessionType
}
const source = String(payload.source || 'workbench').trim()
if (source !== 'workbench') {
return ''
}
const prompt = String(payload.prompt || '').trim()
const files = Array.isArray(payload.files) ? payload.files : []
const fallbackSessionType = resolveWorkbenchSessionTypeFallback(prompt, {
attachmentCount: files.length
})
if (!prompt) {
return fallbackSessionType
}
try {
const ontology = await fetchOntologyParse(
{
query: prompt,
user_id: resolveCurrentUserId(),
context_json: {
...buildWorkbenchIntentOntologyContext({
currentUser: currentUser.value,
files
}),
user_input_text: prompt,
fallback_session_type: fallbackSessionType
}
},
{
timeoutMs: 12000,
timeoutMessage: '意图识别超时,已使用本地规则进入助手。'
}
)
return resolveWorkbenchSessionTypeFromOntology(ontology, prompt, fallbackSessionType)
} catch (error) {
console.warn('Workbench model intent routing failed, fallback to local routing:', error)
return fallbackSessionType
}
}
function isApplicationDocumentPayload(payload = {}, claimNo = '') {
const documentType = String(
payload.documentType
@@ -227,32 +281,32 @@ export function useAppShell() {
|| normalizedClaimNo.startsWith('APP-')
)
}
async function resolveSmartEntryConversation(payload = {}) {
if (payload.conversation) {
return payload.conversation
}
if (isDetailClaimScopedPayload(payload)) {
return null
}
if (!payload.restoreLatestConversation) {
return null
}
try {
const latestPayload = await fetchLatestConversation(resolveCurrentUserId(), SESSION_TYPE_EXPENSE, {
preferRecoverable: true
})
return latestPayload?.found ? latestPayload.conversation || null : null
} catch (error) {
console.warn('Failed to restore latest expense conversation for smart entry:', error)
toast(error?.message || '恢复最近报销会话失败,请稍后重试。')
return null
}
}
async function resolveSmartEntryConversation(payload = {}) {
if (payload.conversation) {
return payload.conversation
}
if (isDetailClaimScopedPayload(payload)) {
return null
}
if (!payload.restoreLatestConversation) {
return null
}
try {
const latestPayload = await fetchLatestConversation(resolveCurrentUserId(), SESSION_TYPE_EXPENSE, {
preferRecoverable: true
})
return latestPayload?.found ? latestPayload.conversation || null : null
} catch (error) {
console.warn('Failed to restore latest expense conversation for smart entry:', error)
toast(error?.message || '恢复最近报销会话失败,请稍后重试。')
return null
}
}
async function openSmartEntry(payload = {}) {
const shouldReplaceOpenEntry = Boolean(
payload?.source === 'budget'
@@ -265,38 +319,42 @@ export function useAppShell() {
smartEntryRevealToken.value += 1
return
}
const conversation = await resolveSmartEntryConversation(payload)
const scope = resolveSmartEntryClaimScope(payload)
smartEntryOpen.value = true
smartEntryContext.value = {
prompt: payload.prompt ?? '',
source: payload.source ?? 'workbench',
request: payload.request ?? selectedRequest.value,
files: Array.isArray(payload.files) ? payload.files : [],
conversation,
scope
}
smartEntrySessionId.value += 1
}
function closeSmartEntry() {
smartEntryOpen.value = false
}
async function handleDraftSaved(payload = {}) {
const [conversation, sessionType] = await Promise.all([
resolveSmartEntryConversation(payload),
resolveSmartEntrySessionType(payload)
])
const scope = resolveSmartEntryClaimScope(payload)
smartEntryOpen.value = true
smartEntryContext.value = {
prompt: payload.prompt ?? '',
source: payload.source ?? 'workbench',
request: payload.request ?? selectedRequest.value,
files: Array.isArray(payload.files) ? payload.files : [],
conversation,
scope,
sessionType
}
smartEntrySessionId.value += 1
}
function closeSmartEntry() {
smartEntryOpen.value = false
}
async function handleDraftSaved(payload = {}) {
const claimNo = String(payload.claimNo || payload.claim_no || '').trim()
const status = String(payload.status || payload.claimStatus || '').trim()
const approvalStage = String(payload.approvalStage || payload.approval_stage || '').trim()
const isApplicationDocument = isApplicationDocumentPayload(payload, claimNo)
await reloadRequests()
if (status === 'submitted') {
if (isApplicationDocument) {
toast(`${claimNo || '该'}申请单已提交${approvalStage ? `,当前节点:${approvalStage}` : ',等待直属领导审批'}`)
return
}
smartEntryOpen.value = false
toast(
isApplicationDocument
? `${claimNo || '该'}申请单已提交${approvalStage ? `,当前节点:${approvalStage}` : ',等待直属领导审批'}`
: `${claimNo || '该'}单据已完成 AI预审${approvalStage ? `,当前节点:${approvalStage}` : ',并已提交审批'}`
)
toast(`${claimNo || '该'}单据已完成 AI预审${approvalStage ? `,当前节点:${approvalStage}` : ',并已提交审批'}`)
router.push({ name: 'app-documents' })
return
}
@@ -306,7 +364,7 @@ export function useAppShell() {
: `${claimNo || '该'}单据已保存为草稿,可继续上传票据或补充信息。`
)
}
function openRequestDetail(request) {
selectedRequestSnapshot.value = request || null
router.push({
@@ -322,57 +380,57 @@ export function useAppShell() {
async function handleRequestUpdated() {
await reloadRequests()
}
async function handleRequestDeleted(payload = {}) {
const deletedClaimId = String(payload.claimId || payload.claim_id || '').trim()
if (deletedClaimId) {
clearAssistantSessionSnapshotForDraftClaim(resolveCurrentUserId(), deletedClaimId, SESSION_TYPE_EXPENSE)
smartEntryInvalidatedDraftClaimId.value = deletedClaimId
}
async function handleRequestDeleted(payload = {}) {
const deletedClaimId = String(payload.claimId || payload.claim_id || '').trim()
if (deletedClaimId) {
clearAssistantSessionSnapshotForDraftClaim(resolveCurrentUserId(), deletedClaimId, SESSION_TYPE_EXPENSE)
smartEntryInvalidatedDraftClaimId.value = deletedClaimId
}
await reloadRequests()
selectedRequestSnapshot.value = null
router.push({ name: 'app-documents' })
}
return {
activeRange,
activeView,
closeRequestDetail,
closeSmartEntry,
currentView,
return {
activeRange,
activeView,
closeRequestDetail,
closeSmartEntry,
currentView,
customRange,
detailMode,
filteredRequests,
filters,
handleApprove,
handleDraftSaved,
handleNavigate,
handleReject,
handleRequestDeleted,
filters,
handleApprove,
handleDraftSaved,
handleNavigate,
handleReject,
handleRequestDeleted,
handleRequestUpdated,
navItems,
openExpenseApplicationCreate,
openRequestDetail,
openSmartEntry,
openTravelCreate,
ranges,
requestSummary,
workbenchSummary,
requestsError,
requestsLoading,
reloadRequests,
requests,
search,
selectedRequest,
setView,
smartEntryContext,
openTravelCreate,
ranges,
requestSummary,
workbenchSummary,
requestsError,
requestsLoading,
reloadRequests,
requests,
search,
selectedRequest,
setView,
smartEntryContext,
smartEntryInvalidatedDraftClaimId,
smartEntryOpen,
smartEntryRevealToken,
smartEntrySessionId,
detailAlerts,
toast,
topBarView
}
}
detailAlerts,
toast,
topBarView
}
}

View File

@@ -2,6 +2,7 @@ import { nextTick, ref } from 'vue'
import { useSystemState } from './useSystemState.js'
import { runOrchestrator } from '../services/orchestrator.js'
import { filterVisibleMessageMeta } from '../utils/assistantMessageMeta.js'
const initialMessages = [
{
@@ -73,18 +74,6 @@ export function useChat(activeView) {
function buildOrchestratorMeta(payload) {
const items = []
if (payload?.selected_agent) {
items.push(`Agent: ${payload.selected_agent}`)
}
if (payload?.permission_level) {
items.push(`权限: ${payload.permission_level}`)
}
if (payload?.trace_summary?.tool_count) {
items.push(`工具: ${payload.trace_summary.tool_count}`)
}
if (payload?.trace_summary?.degraded) {
items.push('已降级')
}
@@ -93,11 +82,7 @@ export function useChat(activeView) {
items.push('待确认')
}
if (payload?.run_id) {
items.push(`Run: ${payload.run_id}`)
}
return items
return filterVisibleMessageMeta(items)
}
function buildAssistantMessage(payload, fallbackText) {

View File

@@ -0,0 +1,161 @@
import { ref } from 'vue'
import { createOperationFeedback } from '../services/operationFeedback.js'
const LOW_RATING_MAX = 3
function pickText(...values) {
for (const value of values) {
const normalized = String(value || '').trim()
if (normalized) {
return normalized
}
}
return ''
}
function resolveUserId(currentUser = {}) {
return pickText(
currentUser.username,
currentUser.email,
currentUser.name,
'anonymous'
)
}
export function normalizeOperationFeedbackContext(context = {}, currentUser = {}) {
const traceSummary = context.trace_summary || context.traceSummary || null
const result = context.result && typeof context.result === 'object' ? context.result : {}
const resultSummary = pickText(
context.result_summary,
context.resultSummary,
result.answer,
result.message
)
return {
runId: pickText(context.run_id, context.runId),
conversationId: pickText(context.conversation_id, context.conversationId),
userId: pickText(context.user_id, context.userId, resolveUserId(currentUser)),
agent: pickText(context.selected_agent, context.selectedAgent, context.agent),
source: pickText(context.source, 'user_message'),
sessionType: pickText(context.session_type, context.sessionType),
operationType: pickText(context.operation_type, context.operationType, 'assistant_round'),
operationStatus: pickText(context.operation_status, context.operationStatus, context.status),
routeReason: pickText(context.route_reason, context.routeReason),
entrySource: pickText(context.entry_source, context.entrySource),
traceSummary,
resultSummary: resultSummary.slice(0, 500)
}
}
export function buildOperationFeedbackPayload(context = {}, feedback = {}, currentUser = {}) {
const normalizedContext = normalizeOperationFeedbackContext(context, currentUser)
const rating = Number(feedback.rating || 0)
const reason = String(feedback.reason || '').trim()
return {
run_id: normalizedContext.runId || null,
conversation_id: normalizedContext.conversationId || null,
user_id: normalizedContext.userId || null,
agent: normalizedContext.agent || null,
source: normalizedContext.source || null,
session_type: normalizedContext.sessionType || null,
operation_type: normalizedContext.operationType || 'assistant_round',
operation_status: normalizedContext.operationStatus || null,
rating,
reason: reason || null,
context_json: {
entry_source: normalizedContext.entrySource,
route_reason: normalizedContext.routeReason,
trace_summary: normalizedContext.traceSummary,
result_summary: normalizedContext.resultSummary,
low_rating: rating > 0 && rating <= LOW_RATING_MAX
}
}
}
export function useOperationFeedback({ currentUser, toast } = {}) {
const operationFeedbackDialog = ref({
open: false,
submitting: false,
error: '',
context: null
})
const promptedRunIds = new Set()
function openOperationFeedback(context = {}) {
const normalized = normalizeOperationFeedbackContext(context, currentUser?.value || {})
if (!normalized.runId || promptedRunIds.has(normalized.runId)) {
return
}
if (normalized.operationStatus && normalized.operationStatus !== 'succeeded') {
return
}
promptedRunIds.add(normalized.runId)
globalThis.setTimeout(() => {
operationFeedbackDialog.value = {
open: true,
submitting: false,
error: '',
context: normalized
}
}, 320)
}
function closeOperationFeedback() {
if (operationFeedbackDialog.value.submitting) {
return
}
operationFeedbackDialog.value = {
...operationFeedbackDialog.value,
open: false,
error: ''
}
}
async function submitOperationFeedbackRating(feedback = {}) {
const rating = Number(feedback.rating || 0)
if (!Number.isInteger(rating) || rating < 1 || rating > 5) {
operationFeedbackDialog.value = {
...operationFeedbackDialog.value,
error: '请选择 1 到 5 星评分。'
}
return
}
const context = operationFeedbackDialog.value.context || {}
operationFeedbackDialog.value = {
...operationFeedbackDialog.value,
submitting: true,
error: ''
}
try {
await createOperationFeedback(
buildOperationFeedbackPayload(context, feedback, currentUser?.value || {})
)
operationFeedbackDialog.value = {
open: false,
submitting: false,
error: '',
context: null
}
toast?.('评价已记录,后续会纳入智能体质量统计。')
} catch (error) {
operationFeedbackDialog.value = {
...operationFeedbackDialog.value,
submitting: false,
error: error?.message || '评价提交失败,请稍后重试。'
}
}
}
return {
operationFeedbackDialog,
openOperationFeedback,
closeOperationFeedback,
submitOperationFeedbackRating
}
}

View File

@@ -1,19 +1,53 @@
import { computed, ref } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { fetchFinanceDashboard, fetchSystemDashboard } from '../services/analytics.js'
import { fetchRiskObservationDashboard } from '../services/riskObservations.js'
import {
metricBlueprints,
systemMetricBlueprints,
trendRanges,
trendSeries,
spendByCategory,
exceptionMix,
spendByCategory as fallbackSpendByCategory,
exceptionMix as fallbackExceptionMix,
departmentRangeOptions,
bottlenecks,
budgetSummary
bottlenecks as fallbackBottlenecks,
budgetSummary as fallbackBudgetSummary,
systemDashboardTotals as fallbackSystemDashboardTotals,
systemAgentDailyRatio as fallbackSystemAgentDailyRatio,
systemLoginWave as fallbackSystemLoginWave,
systemTokenDailyWave as fallbackSystemTokenDailyWave,
systemUsageDurationSummary as fallbackSystemUsageDurationSummary,
systemUserTokenUsage as fallbackSystemUserTokenUsage,
systemAccuracyComparison as fallbackSystemAccuracyComparison,
systemTrendSeries,
systemToolCallMix,
systemExecutionMix,
systemToolRankings,
systemModelUsage,
systemFeedbackSummary as fallbackSystemFeedbackSummary,
systemLoadHeatmap,
systemToolDetailRows as fallbackSystemToolDetailRows
} from '../data/metrics.js'
export function useOverviewView() {
export function useOverviewView(options = {}) {
const activeTrendRange = ref(trendRanges[0])
const activeDepartmentRange = ref(departmentRangeOptions[0])
const riskWindowOptions = [
{ label: '近 7 天', value: 7 },
{ label: '近 30 天', value: 30 },
{ label: '近 90 天', value: 90 }
]
const activeRiskWindowDays = ref(30)
const financeDashboardPayload = ref(null)
const financeDashboardLoading = ref(false)
const financeDashboardError = ref(null)
const systemDashboardPayload = ref(null)
const systemDashboardLoading = ref(false)
const systemDashboardError = ref(null)
const riskDashboardPayload = ref(null)
const riskDashboardLoading = ref(false)
const riskDashboardError = ref(null)
const demoTotals = {
pendingCount: 128,
@@ -40,6 +74,15 @@ export function useOverviewView() {
const formatCurrency = (value) => formatCompact(value)
const formatNumberCompact = (value) => {
const number = Number(value || 0)
if (number >= 1_000_000) return `${(number / 1_000_000).toFixed(1)}M`
if (number >= 1_000) return `${(number / 1_000).toFixed(1)}K`
return `${Math.round(number)}`
}
const formatPercent = (value) => `${Math.round(Number(value || 0) * 100)}%`
const formatMetricValue = (metric, value) => {
if (metric.key === 'pendingAmount') return formatCurrency(Math.round(value))
if (metric.key === 'avgSla') return `${value.toFixed(1)} ${metric.unit}`
@@ -48,34 +91,382 @@ export function useOverviewView() {
return `${Math.round(value)}`
}
const formatSystemMetricValue = (metric, value) => {
const numericValue = Number(value || 0)
if (metric.key === 'modelTokens') return formatNumberCompact(numericValue)
if (metric.key === 'avgOnlineMinutes') return `${numericValue.toFixed(1)} ${metric.unit}`
if (metric.key === 'executionSuccessRate') return `${numericValue.toFixed(1)}${metric.unit}`
if (metric.key === 'positiveFeedback') {
const negativeFeedback = Math.round(Number(systemDashboardTotals.value.negativeFeedback || 0))
return `${Math.round(numericValue)} / ${negativeFeedback}`
}
if (metric.unit) return `${formatNumberCompact(numericValue)} ${metric.unit}`
return formatNumberCompact(numericValue)
}
const getFinanceRangeParams = () => {
const activeRange = String(options.activeRange || '近10日')
const customRange = options.customRange || {}
const isCustomRange = activeRange === 'custom'
return {
rangeKey: activeRange,
startDate: isCustomRange ? customRange.start : '',
endDate: isCustomRange ? customRange.end : '',
trendRange: activeTrendRange.value,
departmentRange: activeDepartmentRange.value
}
}
const loadFinanceDashboard = async () => {
financeDashboardLoading.value = true
financeDashboardError.value = null
try {
financeDashboardPayload.value = await fetchFinanceDashboard(getFinanceRangeParams())
} catch (error) {
financeDashboardPayload.value = null
financeDashboardError.value = error
} finally {
financeDashboardLoading.value = false
}
}
const loadSystemDashboard = async () => {
systemDashboardLoading.value = true
systemDashboardError.value = null
try {
systemDashboardPayload.value = await fetchSystemDashboard({ days: 7 })
} catch (error) {
systemDashboardPayload.value = null
systemDashboardError.value = error
} finally {
systemDashboardLoading.value = false
}
}
const loadRiskDashboard = async () => {
riskDashboardLoading.value = true
riskDashboardError.value = null
try {
riskDashboardPayload.value = await fetchRiskObservationDashboard({
windowDays: activeRiskWindowDays.value,
limit: 500
})
} catch (error) {
riskDashboardPayload.value = null
riskDashboardError.value = error
} finally {
riskDashboardLoading.value = false
}
}
const setRiskWindowDays = (value) => {
const days = Number(value || 30)
const matched = riskWindowOptions.some((item) => Number(item.value) === days)
activeRiskWindowDays.value = matched ? days : 30
}
onMounted(() => {
void loadFinanceDashboard()
void loadSystemDashboard()
void loadRiskDashboard()
})
watch(
() => [
options.activeRange,
options.customRange?.start,
options.customRange?.end,
activeTrendRange.value,
activeDepartmentRange.value
],
() => {
void loadFinanceDashboard()
}
)
watch(activeRiskWindowDays, () => {
void loadRiskDashboard()
})
const systemDashboardTotals = computed(() => (
systemDashboardPayload.value?.totals || fallbackSystemDashboardTotals
))
const systemAgentDailyRatio = computed(() => (
systemDashboardPayload.value?.agentDailyRatio || fallbackSystemAgentDailyRatio
))
const systemLoginWave = computed(() => (
systemDashboardPayload.value?.loginWave || fallbackSystemLoginWave
))
const systemTokenDailyWave = computed(() => (
systemDashboardPayload.value?.tokenDailyWave || fallbackSystemTokenDailyWave
))
const systemUsageDurationSummary = computed(() => (
systemDashboardPayload.value?.usageDurationSummary || fallbackSystemUsageDurationSummary
))
const systemUserTokenUsage = computed(() => (
systemDashboardPayload.value?.userTokenUsage || fallbackSystemUserTokenUsage
))
const systemAccuracyComparison = computed(() => (
systemDashboardPayload.value?.accuracyComparison || fallbackSystemAccuracyComparison
))
const systemFeedbackSummary = computed(() => (
systemDashboardPayload.value?.feedbackSummary || fallbackSystemFeedbackSummary
))
const systemToolDetailRows = computed(() => (
systemDashboardPayload.value?.toolDetailRows || fallbackSystemToolDetailRows
))
const riskDashboard = computed(() => (
riskDashboardPayload.value || {
windowDays: activeRiskWindowDays.value,
totalObservations: 0,
pendingCount: 0,
highOrAboveCount: 0,
confirmedCount: 0,
falsePositiveCount: 0,
totalAmount: 0,
averageScore: 0,
confirmationRate: 0,
falsePositiveRate: 0,
candidateRuleCount: 0,
levelDistribution: {},
statusDistribution: {},
signalDistribution: {},
sourceDistribution: {},
automationDistribution: {},
departmentDistribution: {},
expenseTypeDistribution: {},
riskTypeDistribution: {},
supplierDistribution: {},
employeeGradeDistribution: {},
dailyTrend: [],
topRiskSignals: [],
topDepartments: [],
topEmployees: [],
topSuppliers: [],
topExpenseTypes: [],
topRules: [],
recentHighObservations: []
}
))
const financeDashboardTotals = computed(() => (
financeDashboardPayload.value?.totals || demoTotals
))
const financeMetricMeta = computed(() => (
financeDashboardPayload.value?.metricMeta || {}
))
const financeTrend = computed(() => (
financeDashboardPayload.value?.trend || trendSeries[activeTrendRange.value]
))
const financeSpendByCategory = computed(() => (
financeDashboardPayload.value?.spendByCategory || fallbackSpendByCategory
))
const financeExceptionMix = computed(() => (
financeDashboardPayload.value?.exceptionMix || fallbackExceptionMix
))
const financeDepartmentRanking = computed(() => (
financeDashboardPayload.value?.departmentRanking || demoDepartments
))
const financeBottlenecks = computed(() => (
financeDashboardPayload.value?.bottlenecks || fallbackBottlenecks
))
const financeBudgetSummary = computed(() => (
financeDashboardPayload.value?.budgetSummary || fallbackBudgetSummary
))
const resolveSystemMetricMeta = (metric) => {
const totals = systemDashboardTotals.value
const realDashboardLoaded = Boolean(systemDashboardPayload.value)
if (!realDashboardLoaded) {
return {
changeText: metric.change,
delta: metric.delta,
trend: metric.trend
}
}
if (metric.key === 'toolCalls' || metric.key === 'modelTokens') {
const changeValue = Number(totals[`${metric.key}Change`] || 0)
return {
changeText: `${changeValue >= 0 ? '+' : ''}${changeValue.toFixed(1)}%`,
delta: '较上一周期',
trend: changeValue < 0 ? 'down' : 'up'
}
}
if (metric.key === 'executionSuccessRate') {
const errorRate = Math.max(0, 100 - Number(totals.executionSuccessRate || 0))
return {
changeText: '实时',
delta: `错误率 ${errorRate.toFixed(1)}%`,
trend: 'up'
}
}
if (metric.key === 'positiveFeedback') {
return {
changeText: '实时',
delta: `差评 ${Math.round(Number(totals.negativeFeedback || 0))}`,
trend: 'up'
}
}
return {
changeText: '实时',
delta: metric.key === 'onlineUsers' ? '活跃会话' : '按最近会话统计',
trend: metric.trend
}
}
const resolveFinanceMetricMeta = (metric) => {
const meta = financeMetricMeta.value[metric.key]
if (!financeDashboardPayload.value || !meta) {
return {
changeText: metric.change,
delta: metric.delta,
trend: metric.trend
}
}
return {
changeText: meta.changeText || metric.change,
delta: meta.delta || metric.delta,
trend: meta.trend || metric.trend
}
}
const kpiMetrics = computed(() => metricBlueprints.map((metric, index) => {
const rawValue = demoTotals[metric.key]
const rawValue = Number(financeDashboardTotals.value[metric.key] || 0)
const displayValue = formatMetricValue(metric, rawValue)
const metricMeta = resolveFinanceMetricMeta(metric)
return {
...metric,
...metricMeta,
displayValue,
changeText: metric.change,
delay: index * 55
}
}))
const activeTrend = computed(() => trendSeries[activeTrendRange.value])
const spendTotal = computed(() => spendByCategory.reduce((sum, item) => sum + item.value, 0))
const riskTotal = computed(() => exceptionMix.reduce((sum, item) => sum + item.value, 0))
const systemKpiMetrics = computed(() => systemMetricBlueprints.map((metric, index) => {
const rawValue = systemDashboardTotals.value[metric.key]
const displayValue = formatSystemMetricValue(metric, rawValue)
const metricMeta = resolveSystemMetricMeta(metric)
const spendLegend = computed(() => spendByCategory.map((item) => ({
return {
...metric,
...metricMeta,
displayValue,
delay: index * 55
}
}))
const riskKpiMetrics = computed(() => {
const data = riskDashboard.value
const rows = [
{
label: '新增风险数',
value: formatNumberCompact(data.totalObservations),
changeText: `${data.windowDays}`,
delta: '统一观察池',
trend: 'up',
icon: 'mdi mdi-shield-search',
accent: 'var(--theme-primary)'
},
{
label: '高风险待处置',
value: formatNumberCompact(data.highOrAboveCount),
changeText: data.highOrAboveCount > 0 ? '需关注' : '稳定',
delta: '高/重大风险',
trend: data.highOrAboveCount > 0 ? 'down' : 'up',
icon: 'mdi mdi-alert-octagon-outline',
accent: '#ef4444'
},
{
label: '涉及金额',
value: formatCurrency(Number(data.totalAmount || 0)),
changeText: '归集',
delta: '关联单据金额',
trend: 'up',
icon: 'mdi mdi-cash-multiple',
accent: '#0f766e'
},
{
label: '已确认风险',
value: formatNumberCompact(data.confirmedCount),
changeText: formatPercent(data.confirmationRate),
delta: '人工确认',
trend: 'up',
icon: 'mdi mdi-check-decagram-outline',
accent: 'var(--success)'
},
{
label: '误报数量',
value: formatNumberCompact(data.falsePositiveCount),
changeText: formatPercent(data.falsePositiveRate),
delta: '反馈校准',
trend: data.falsePositiveRate > 0.2 ? 'down' : 'up',
icon: 'mdi mdi-tune-variant',
accent: '#8b5cf6'
},
{
label: '待复核',
value: formatNumberCompact(data.pendingCount),
changeText: '待处理',
delta: '人工闭环',
trend: data.pendingCount > 0 ? 'down' : 'up',
icon: 'mdi mdi-account-clock-outline',
accent: '#f59e0b'
}
]
return rows.map((item, index) => ({
...item,
displayValue: item.value,
delay: index * 55
}))
})
const activeTrend = computed(() => financeTrend.value)
const spendTotal = computed(() => financeSpendByCategory.value.reduce((sum, item) => sum + Number(item.value || 0), 0))
const riskTotal = computed(() => financeExceptionMix.value.reduce((sum, item) => sum + Number(item.value || 0), 0))
const spendCenterValue = computed(() => formatCurrency(Math.round(spendTotal.value)))
const spendLegend = computed(() => financeSpendByCategory.value.map((item) => ({
...item,
display: `${Math.round((item.value / spendTotal.value) * 100)}%`
display: spendTotal.value ? `${Math.round((Number(item.value || 0) / spendTotal.value) * 100)}%` : '0%'
})))
const riskLegend = computed(() => exceptionMix.map((item) => ({
const riskLegend = computed(() => financeExceptionMix.value.map((item) => ({
...item,
display: `${item.value}`
})))
const systemToolTotal = computed(() =>
systemToolCallMix.reduce((sum, item) => sum + item.value, 0)
)
const systemExecutionTotal = computed(() =>
systemExecutionMix.reduce((sum, item) => sum + item.value, 0)
)
const systemToolCallLegend = computed(() => systemToolCallMix.map((item) => ({
...item,
display: `${Math.round((item.value / systemToolTotal.value) * 100)}%`
})))
const systemExecutionLegend = computed(() => systemExecutionMix.map((item) => ({
...item,
display: `${Math.round((item.value / systemExecutionTotal.value) * 1000) / 10}%`
})))
const rankedDepartments = computed(() => {
const rows = demoDepartments
const rows = financeDepartmentRanking.value.map((item) => ({
...item,
amount: Number(item.amount || item.value || 0)
}))
const max = Math.max(...rows.map((item) => item.amount), 1)
return rows.slice(0, 5).map((item, index) => ({
@@ -88,25 +479,215 @@ export function useOverviewView() {
}))
})
const systemToolRankingItems = computed(() => systemToolRankings.map((item, index) => ({
...item,
rank: index + 1,
shortName: item.name,
amount: item.value
})))
const systemModelUsageRows = computed(() => systemModelUsage.map((item) => ({
...item,
tokenLabel: `${formatNumberCompact(item.tokens)} tokens`,
width: `${Math.max(Math.min(item.share, 100), 8)}%`
})))
const systemExecutionRows = computed(() => systemExecutionMix.map((item) => ({
...item,
display: `${Math.round((item.value / systemExecutionTotal.value) * 1000) / 10}%`,
width: `${Math.max((item.value / systemExecutionTotal.value) * 100, 1)}%`
})))
const systemToolDetailItems = computed(() => {
const maxCalls = Math.max(...systemToolDetailRows.value.map((item) => item.calls), 1)
return systemToolDetailRows.value.map((item) => ({
...item,
callLabel: `${formatNumberCompact(item.calls)}`,
tokenLabel: `${formatNumberCompact(item.tokens)} tokens`,
width: `${Math.max((item.calls / maxCalls) * 100, 10)}%`
}))
})
const systemUsageDurationRows = computed(() => {
const rows = systemUsageDurationSummary.value.rows || []
const maxValue = Math.max(...rows.map((item) => item.value), 1)
return rows.map((item) => ({
...item,
width: `${Math.max((item.value / maxValue) * 100, 12)}%`
}))
})
const riskLevelLegend = computed(() => buildRiskDistributionLegend(
riskDashboard.value.levelDistribution,
{
critical: '重大风险',
high: '高风险',
medium: '中风险',
low: '低风险'
},
{
critical: '#b91c1c',
high: '#ef4444',
medium: '#f59e0b',
low: '#3b82f6'
}
))
const riskSourceLegend = computed(() => buildRiskDistributionLegend(
riskDashboard.value.sourceDistribution,
{
financial_risk_graph: '风险图谱',
rule_center: '规则中心',
unknown: '未知来源'
},
{
financial_risk_graph: 'var(--theme-primary)',
rule_center: '#0f766e',
unknown: '#94a3b8'
}
))
const riskSignalRanking = computed(() => {
const rows = Array.isArray(riskDashboard.value.topRiskSignals)
? riskDashboard.value.topRiskSignals
: []
const fallbackRows = Object.entries(riskDashboard.value.signalDistribution || {})
.map(([name, count]) => ({ name, count }))
return (rows.length ? rows : fallbackRows)
.slice(0, 6)
.map((item, index) => ({
name: formatRiskSignalName(item.name),
shortName: formatRiskSignalName(item.name),
value: Number(item.count || 0),
color: [
'#ef4444',
'#f59e0b',
'var(--theme-primary)',
'#3b82f6',
'#8b5cf6',
'#0f766e'
][index] || '#64748b'
}))
})
const riskDailyTrendRows = computed(() => {
const rows = Array.isArray(riskDashboard.value.dailyTrend) ? riskDashboard.value.dailyTrend : []
const normalizedRows = rows.slice(-7).map((item) => ({
date: String(item.date || '').trim() || '-',
total: Number(item.total || 0),
highOrAbove: Number(item.high_or_above ?? item.highOrAbove ?? 0)
}))
const maxValue = Math.max(...normalizedRows.map((item) => item.total), 1)
return normalizedRows.map((item) => ({
...item,
width: `${Math.max((item.total / maxValue) * 100, 4)}%`,
highWidth: `${Math.max((item.highOrAbove / maxValue) * 100, item.highOrAbove ? 4 : 0)}%`
}))
})
function buildRiskDistributionLegend(distribution, labels, colors) {
const entries = Object.entries(distribution || {})
.filter(([, value]) => Number(value || 0) > 0)
if (!entries.length) {
return [
{
name: '暂无数据',
value: 1,
display: '0项',
color: '#cbd5e1'
}
]
}
return entries.map(([key, value]) => ({
name: labels[key] || formatRiskSignalName(key),
value: Number(value || 0),
display: `${Number(value || 0)}`,
color: colors[key] || 'var(--theme-primary)'
}))
}
function formatRiskSignalName(value) {
const text = String(value || '').trim()
const labels = {
duplicate_invoice: '重复发票',
split_billing: '拆分报销',
frequent_small_claims: '高频小额',
location_mismatch: '地点不一致',
amount_outlier: '金额异常',
preapproval_absent: '缺少事前申请'
}
return labels[text] || text.replace(/_/g, ' ') || '未知风险'
}
const bottlenecks = financeBottlenecks
const budgetSummary = financeBudgetSummary
const spendByCategory = financeSpendByCategory
const exceptionMix = financeExceptionMix
return {
activeDepartmentRange,
activeRiskWindowDays,
activeTrend,
activeTrendRange,
bottlenecks,
budgetSummary,
departmentRangeOptions,
exceptionMix,
financeDashboardError,
financeDashboardLoading,
formatCompact,
formatCurrency,
formatMetricValue,
formatNumberCompact,
formatSystemMetricValue,
kpiMetrics,
metricBlueprints,
rankedDepartments,
riskDashboard,
riskDashboardError,
riskDashboardLoading,
riskDailyTrendRows,
riskLegend,
riskKpiMetrics,
riskLevelLegend,
riskSignalRanking,
riskSourceLegend,
riskTotal,
riskWindowOptions,
setRiskWindowDays,
spendByCategory,
spendCenterValue,
spendLegend,
spendTotal,
systemDashboardTotals,
systemDashboardError,
systemDashboardLoading,
systemAgentDailyRatio,
systemLoginWave,
systemTokenDailyWave,
systemUsageDurationRows,
systemUsageDurationSummary,
systemUserTokenUsage,
systemAccuracyComparison,
systemExecutionLegend,
systemExecutionMix,
systemExecutionTotal,
systemFeedbackSummary,
systemKpiMetrics,
systemLoadHeatmap,
systemExecutionRows,
systemMetricBlueprints,
systemModelUsageRows,
systemToolDetailItems,
systemToolCallLegend,
systemToolCallMix,
systemToolRankingItems,
systemToolRankings,
systemToolTotal,
systemTrendSeries,
trendRanges,
trendSeries
}

View File

@@ -44,15 +44,18 @@ const ROUTE_DESCRIPTION_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket'
const HOTEL_DESCRIPTION_EXPENSE_TYPES = new Set(['hotel_ticket'])
const DOCUMENT_TYPE_APPLICATION = 'application'
const DOCUMENT_TYPE_REIMBURSEMENT = 'reimbursement'
const RELATED_APPLICATION_STEP_LABEL = '关联单据'
const ARCHIVED_STEP_LABEL = '已归档'
const REIMBURSEMENT_PROGRESS_LABELS = [
'创建单据',
RELATED_APPLICATION_STEP_LABEL,
'待提交',
'AI预审',
'直属领导审批',
'财务审批',
'待付款',
'已付款'
'已付款',
ARCHIVED_STEP_LABEL
]
const APPLICATION_PROGRESS_LABELS = [
@@ -366,7 +369,7 @@ function resolveProgressCurrentIndex(approvalMeta, workflowNode) {
const normalizedNode = String(workflowNode || '').trim()
if (approvalMeta.key === 'completed') {
return 6
return 7
}
if (approvalMeta.key === 'pending_payment') {
@@ -380,7 +383,7 @@ function resolveProgressCurrentIndex(approvalMeta, workflowNode) {
return 5
}
if (normalizedNode.includes('归档') || normalizedNode.includes('入账')) {
return 6
return 7
}
if (normalizedNode.includes('财务')) {
return 4
@@ -589,6 +592,116 @@ function findLatestPaymentEvent(claim) {
)
}
function findApplicationHandoffEvent(claim) {
const handoffEvents = getRiskFlags(claim).filter((flag) => (
flag
&& typeof flag === 'object'
&& normalizeText(flag.source) === 'application_handoff'
&& normalizeText(flag.application_claim_no || flag.applicationClaimNo)
))
return getLatestEvent(handoffEvents) || handoffEvents[handoffEvents.length - 1] || null
}
function normalizeApplicationHandoffDetail(flag = {}) {
const detail = flag?.application_detail || flag?.applicationDetail || {}
return detail && typeof detail === 'object' ? detail : {}
}
function resolveRelatedApplicationAmountLabel(flag, detail, claim) {
const explicitLabel = normalizeText(
flag?.application_amount_label
|| flag?.applicationAmountLabel
|| detail?.application_amount_label
|| detail?.applicationAmountLabel
)
if (explicitLabel) return explicitLabel
const rawAmount = normalizeText(
flag?.application_amount
|| flag?.applicationAmount
|| flag?.application_budget_amount
|| flag?.applicationBudgetAmount
|| detail?.application_amount
|| detail?.applicationAmount
|| detail?.amount
|| claim?.amount
)
const amountValue = parseNumber(rawAmount)
return amountValue > 0 ? formatAmount(amountValue) : rawAmount
}
function resolveRelatedApplicationInfo(claim, typeLabel = '') {
const handoff = findApplicationHandoffEvent(claim)
if (!handoff) {
return null
}
const detail = normalizeApplicationHandoffDetail(handoff)
const claimNo = normalizeText(handoff.application_claim_no || handoff.applicationClaimNo)
const applicationType = normalizeText(
detail.application_type
|| detail.applicationType
|| handoff.application_type
|| handoff.applicationType
|| typeLabel
)
const location = normalizeText(
detail.application_location
|| detail.applicationLocation
|| detail.location
|| handoff.application_location
|| handoff.applicationLocation
|| claim?.location
)
const reason = normalizeText(
detail.application_reason
|| detail.applicationReason
|| detail.reason
|| handoff.application_reason
|| handoff.applicationReason
|| claim?.reason
)
const content = normalizeText(
detail.application_content
|| detail.applicationContent
|| handoff.application_content
|| handoff.applicationContent
) || [applicationType, location].filter(Boolean).join(' / ')
const rawTime = normalizeText(
detail.application_time
|| detail.applicationTime
|| detail.time
|| handoff.application_time
|| handoff.applicationTime
|| claim?.occurred_at
)
return {
id: normalizeText(handoff.application_claim_id || handoff.applicationClaimId),
claimNo,
content,
reason,
days: normalizeText(
detail.application_days
|| detail.applicationDays
|| detail.days
|| handoff.application_days
|| handoff.applicationDays
),
location,
time: formatDate(rawTime) || rawTime,
amountLabel: resolveRelatedApplicationAmountLabel(handoff, detail, claim),
statusLabel: normalizeText(handoff.application_status_label || handoff.applicationStatusLabel),
transportMode: normalizeText(
detail.application_transport_mode
|| detail.applicationTransportMode
|| detail.transport_mode
|| handoff.application_transport_mode
|| handoff.applicationTransportMode
)
}
}
function findLatestApplicationReturnEvent(claim) {
return getLatestEvent(
getRiskFlags(claim).filter((flag) => {
@@ -608,6 +721,28 @@ function findLatestApplicationReturnEvent(claim) {
)
}
function findMergedApplicationBudgetApprovalEvent(claim) {
return getLatestEvent(
getRiskFlags(claim).filter((flag) => {
if (!flag || typeof flag !== 'object') {
return false
}
const source = normalizeText(flag.source)
const eventType = normalizeText(flag.event_type || flag.eventType)
const previousStage = normalizeText(flag.previous_approval_stage || flag.previousApprovalStage)
const nextStage = normalizeText(flag.next_approval_stage || flag.nextApprovalStage)
const mergedFlag = Boolean(flag.budget_approval_merged || flag.budgetApprovalMerged)
return (
source === 'manual_approval'
&& eventType === 'expense_application_approval'
&& previousStage.includes('直属领导')
&& nextStage.includes('审批完成')
&& mergedFlag
)
})
)
}
function buildProgressStepMeta(time, detail = '', title = '') {
return {
time,
@@ -620,6 +755,15 @@ function buildCompletedStepMeta(claim, label) {
const stepLabel = normalizeText(label)
const employeeName = normalizeText(claim?.employee_name) || '申请人'
if (stepLabel === RELATED_APPLICATION_STEP_LABEL) {
const relatedApplication = resolveRelatedApplicationInfo(claim)
const createdAt = formatDateTime(claim?.created_at)
if (relatedApplication?.claimNo) {
return buildProgressStepMeta(`已关联 ${relatedApplication.claimNo}`, createdAt)
}
return buildProgressStepMeta('待核对关联单据', createdAt)
}
if (stepLabel === '创建单据' || stepLabel === '创建申请') {
const createdAt = formatDateTime(claim?.created_at)
return buildProgressStepMeta(stepLabel === '创建申请' ? `${employeeName}发起申请` : `${employeeName}创建`, createdAt)
@@ -694,6 +838,11 @@ function buildCompletedStepMeta(claim, label) {
return buildProgressStepMeta('归档入账', archivedAt)
}
if (stepLabel === ARCHIVED_STEP_LABEL) {
const archivedAt = formatDateTime(claim?.updated_at)
return buildProgressStepMeta(ARCHIVED_STEP_LABEL, archivedAt)
}
if (stepLabel === '审批完成') {
const completedAt = formatDateTime(claim?.updated_at)
return buildProgressStepMeta('审批完成', completedAt)
@@ -704,7 +853,7 @@ function buildCompletedStepMeta(claim, label) {
function resolveCurrentStepStartedAt(claim, label) {
const stepLabel = normalizeText(label)
if (stepLabel === '创建单据' || stepLabel === '创建申请') {
if (stepLabel === RELATED_APPLICATION_STEP_LABEL || stepLabel === '创建单据' || stepLabel === '创建申请') {
return claim?.created_at
}
if (stepLabel === '待提交') {
@@ -733,7 +882,7 @@ function resolveCurrentStepStartedAt(claim, label) {
const paymentEvent = findLatestPaymentEvent(claim)
return paymentEvent?.created_at || paymentEvent?.createdAt || claim?.updated_at || claim?.submitted_at
}
if (stepLabel === '归档入账' || stepLabel === '审批完成') {
if (stepLabel === '归档入账' || stepLabel === ARCHIVED_STEP_LABEL || stepLabel === '审批完成') {
return claim?.updated_at || claim?.submitted_at
}
return ''
@@ -746,17 +895,26 @@ function buildProgressSteps(approvalMeta, workflowNode, claim = {}, options = {}
&& Boolean(findLatestApplicationReturnEvent(claim))
&& approvalMeta.key === 'supplement'
)
const hasMergedApplicationBudgetApproval = (
documentTypeCode === DOCUMENT_TYPE_APPLICATION
&& Boolean(findMergedApplicationBudgetApprovalEvent(claim))
)
const progressLabels =
documentTypeCode === DOCUMENT_TYPE_APPLICATION
? hasApplicationReturnStep
? ['创建申请', '直属领导审批', '退回', '待提交']
: APPLICATION_PROGRESS_LABELS
: hasMergedApplicationBudgetApproval
? ['创建申请', '直属领导审批', '审批完成']
: APPLICATION_PROGRESS_LABELS
: REIMBURSEMENT_PROGRESS_LABELS
const currentIndex =
documentTypeCode === DOCUMENT_TYPE_APPLICATION
? hasApplicationReturnStep
? 3
: resolveApplicationProgressCurrentIndex(approvalMeta, workflowNode)
: Math.min(
resolveApplicationProgressCurrentIndex(approvalMeta, workflowNode),
Math.max(0, progressLabels.length - 1)
)
: resolveProgressCurrentIndex(approvalMeta, workflowNode)
const currentTime =
approvalMeta.key === 'completed'
@@ -902,6 +1060,7 @@ export function mapExpenseClaimToRequest(claim) {
const riskSummary = buildRiskSummary(claim?.risk_flags_json)
const expenseItems = buildExpenseItems(claim, riskSummary)
const applyDateTime = claim?.submitted_at || claim?.created_at
const relatedApplication = isApplicationDocument ? null : resolveRelatedApplicationInfo(claim, typeLabel)
return {
id: String(claim?.claim_no || claim?.id || '').trim(),
@@ -958,6 +1117,7 @@ export function mapExpenseClaimToRequest(claim) {
: `${expenseItems.length} 条费用明细,待补充票据`)
: '暂无费用明细',
note: String(claim?.reason || '').trim(),
relatedApplication,
progressSteps: buildProgressSteps(approvalMeta, workflowNode, claim, {
documentTypeCode: documentTypeMeta.documentTypeCode
}),

View File

@@ -8,18 +8,24 @@ import {
testBootstrapDatabase,
testBootstrapRuntime
} from '../services/bootstrap.js'
import { login as loginByAccount } from '../services/auth.js'
import { login as loginByAccount } from '../services/auth.js'
import { setRuntimeApiBaseUrl } from '../services/api.js'
import { checkBackendHealth } from './useBackendHealth.js'
import { resolveDefaultAuthorizedRoute } from '../utils/accessControl.js'
import { useToast } from './useToast.js'
import { fetchSettings } from '../services/settings.js'
import { setThemeSkin } from './useThemeSkin.js'
import { fetchSettings } from '../services/settings.js'
import { setThemeSkin } from './useThemeSkin.js'
import {
clearAuthSessionMetrics,
finalizeAuthSession,
incrementAuthActivityCount,
persistAuthSessionMetrics
} from '../utils/authSessionMetrics.js'
const AUTH_STORAGE_KEY = 'x-financial-authenticated'
const AUTH_USERNAME_KEY = 'x-financial-auth-username'
const AUTH_USER_KEY = 'x-financial-auth-user'
const AUTH_LAST_ACTIVITY_KEY = 'x-financial-auth-last-activity'
const AUTH_USERNAME_KEY = 'x-financial-auth-username'
const AUTH_USER_KEY = 'x-financial-auth-user'
const AUTH_LAST_ACTIVITY_KEY = 'x-financial-auth-last-activity'
const DEFAULT_USER_NAME = '系统管理员'
const DEFAULT_USER_ROLE = '管理员'
const SESSION_ACTIVITY_EVENTS = ['pointerdown', 'keydown', 'scroll', 'touchstart', 'visibilitychange']
@@ -193,13 +199,13 @@ function readStoredUser() {
return legacyUsername ? buildLegacyAdminUser(legacyUsername) : buildAnonymousUser()
}
function readLastActivityAt() {
if (typeof window === 'undefined') {
return 0
}
return Number(window.sessionStorage.getItem(AUTH_LAST_ACTIVITY_KEY) || 0)
}
function readLastActivityAt() {
if (typeof window === 'undefined') {
return 0
}
return Number(window.sessionStorage.getItem(AUTH_LAST_ACTIVITY_KEY) || 0)
}
function isSessionExpired(now = Date.now()) {
if (!readAuthState()) {
@@ -215,24 +221,26 @@ function isSessionExpired(now = Date.now()) {
return now - lastActivityAt > authIdleTimeoutMs
}
function persistAuthState(value, user = null) {
if (typeof window === 'undefined') {
return
}
function persistAuthState(value, user = null, sessionId = '') {
if (typeof window === 'undefined') {
return
}
if (value) {
window.sessionStorage.setItem(AUTH_STORAGE_KEY, 'true')
const normalizedUser = user || buildAnonymousUser()
window.sessionStorage.setItem(AUTH_USERNAME_KEY, String(normalizedUser.username || '').trim())
window.sessionStorage.setItem(AUTH_USER_KEY, JSON.stringify(normalizedUser))
return
}
const normalizedUser = user || buildAnonymousUser()
window.sessionStorage.setItem(AUTH_USERNAME_KEY, String(normalizedUser.username || '').trim())
window.sessionStorage.setItem(AUTH_USER_KEY, JSON.stringify(normalizedUser))
persistAuthSessionMetrics(sessionId)
return
}
window.sessionStorage.removeItem(AUTH_STORAGE_KEY)
window.sessionStorage.removeItem(AUTH_USERNAME_KEY)
window.sessionStorage.removeItem(AUTH_USER_KEY)
window.sessionStorage.removeItem(AUTH_LAST_ACTIVITY_KEY)
}
window.sessionStorage.removeItem(AUTH_USERNAME_KEY)
window.sessionStorage.removeItem(AUTH_USER_KEY)
window.sessionStorage.removeItem(AUTH_LAST_ACTIVITY_KEY)
clearAuthSessionMetrics()
}
function clearSessionTimeout() {
if (typeof window === 'undefined' || !sessionTimeoutHandle) {
@@ -294,19 +302,27 @@ function touchAuthActivity(force = false) {
scheduleSessionTimeout()
return
}
window.sessionStorage.setItem(AUTH_LAST_ACTIVITY_KEY, String(now))
incrementAuthActivityCount()
lastActivityWriteAt = now
scheduleSessionTimeout()
}
window.sessionStorage.setItem(AUTH_LAST_ACTIVITY_KEY, String(now))
lastActivityWriteAt = now
scheduleSessionTimeout()
}
function handleSessionActivity(event) {
if (typeof document !== 'undefined' && event?.type === 'visibilitychange' && document.visibilityState !== 'visible') {
return
}
touchAuthActivity()
}
function handleSessionActivity(event) {
if (typeof document !== 'undefined' && event?.type === 'visibilitychange' && document.visibilityState !== 'visible') {
return
}
touchAuthActivity()
}
function handleSessionUnload(event) {
if (event?.type === 'pagehide' && event.persisted) {
return
}
finalizeAuthSession('pagehide', { unload: true })
}
function installSessionMonitoring() {
if (sessionMonitoringInstalled || typeof window === 'undefined') {
@@ -314,10 +330,12 @@ function installSessionMonitoring() {
}
sessionMonitoringInstalled = true
SESSION_ACTIVITY_EVENTS.forEach((eventName) => {
window.addEventListener(eventName, handleSessionActivity, { passive: true })
})
}
SESSION_ACTIVITY_EVENTS.forEach((eventName) => {
window.addEventListener(eventName, handleSessionActivity, { passive: true })
})
window.addEventListener('pagehide', handleSessionUnload, { passive: true })
window.addEventListener('beforeunload', handleSessionUnload, { passive: true })
}
function syncAuthSession(options = {}) {
const shouldNotify = Boolean(options.notify)
@@ -641,11 +659,11 @@ async function handleLogin(credentials) {
...responseUser,
roleCodes: responseRoleCodes,
isAdmin: resolvePlatformAdminFlag(responseUser, responseRoleCodes)
}
loggedIn.value = true
persistAuthState(true, user)
currentUser.value = user
touchAuthActivity(true)
}
loggedIn.value = true
persistAuthState(true, user, response?.sessionId || '')
currentUser.value = user
touchAuthActivity(true)
return true
} catch (error) {
logout('invalid', { redirect: false })
@@ -658,12 +676,13 @@ async function handleLogin(credentials) {
}
function logout(reason = 'manual', options = {}) {
const notify = options.notify ?? reason === 'timeout'
const redirect = options.redirect ?? reason !== 'invalid'
loggedIn.value = false
persistAuthState(false)
currentUser.value = buildAnonymousUser()
const notify = options.notify ?? reason === 'timeout'
const redirect = options.redirect ?? reason !== 'invalid'
finalizeAuthSession(reason)
loggedIn.value = false
persistAuthState(false)
currentUser.value = buildAnonymousUser()
clearSessionTimeout()
if (notify) {

View File

@@ -0,0 +1,144 @@
import { computed, ref } from 'vue'
import {
buildWorkbenchDateLabel,
canApplyWorkbenchDateSelection,
getTodayDateValue,
mergeWorkbenchDateLabelIntoDraft,
stripWorkbenchDateLabelFromDraft
} from '../utils/workbenchComposerDate.js'
export function useWorkbenchComposerDate({ draft, focusInput } = {}) {
const workbenchDatePickerOpen = ref(false)
const workbenchDateMode = ref('single')
const workbenchSingleDate = ref(getTodayDateValue())
const workbenchRangeStartDate = ref(getTodayDateValue())
const workbenchRangeEndDate = ref(getTodayDateValue())
const workbenchDateTagLabel = ref('')
const workbenchCanApplyDateSelection = computed(() =>
canApplyWorkbenchDateSelection({
mode: workbenchDateMode.value,
singleDate: workbenchSingleDate.value,
rangeStartDate: workbenchRangeStartDate.value,
rangeEndDate: workbenchRangeEndDate.value
})
)
function clearWorkbenchDateSelection() {
const today = getTodayDateValue()
workbenchDatePickerOpen.value = false
workbenchDateMode.value = 'single'
workbenchSingleDate.value = today
workbenchRangeStartDate.value = today
workbenchRangeEndDate.value = today
workbenchDateTagLabel.value = ''
}
function ensureWorkbenchDateDefaults() {
const today = getTodayDateValue()
workbenchSingleDate.value ||= today
workbenchRangeStartDate.value ||= workbenchSingleDate.value || today
workbenchRangeEndDate.value ||= workbenchRangeStartDate.value || today
}
function toggleWorkbenchDatePicker() {
if (workbenchDatePickerOpen.value) {
workbenchDatePickerOpen.value = false
return
}
ensureWorkbenchDateDefaults()
workbenchDatePickerOpen.value = true
}
function closeWorkbenchDatePicker() {
workbenchDatePickerOpen.value = false
}
function setWorkbenchDateMode(mode) {
const today = getTodayDateValue()
const nextMode = mode === 'range' ? 'range' : 'single'
if (nextMode === 'range') {
const baseDate = workbenchSingleDate.value || today
workbenchRangeStartDate.value ||= baseDate
workbenchRangeEndDate.value ||= workbenchRangeStartDate.value
} else {
workbenchSingleDate.value ||= workbenchRangeStartDate.value || today
}
workbenchDateMode.value = nextMode
}
function handleWorkbenchDatePickerOutside(event) {
if (!workbenchDatePickerOpen.value) {
return
}
if (event.target instanceof Element && event.target.closest('.workbench-date-anchor')) {
return
}
workbenchDatePickerOpen.value = false
}
function applyWorkbenchDateSelection() {
const label = buildWorkbenchDateLabel({
mode: workbenchDateMode.value,
singleDate: workbenchSingleDate.value,
rangeStartDate: workbenchRangeStartDate.value,
rangeEndDate: workbenchRangeEndDate.value
})
if (!label) {
return
}
workbenchDateTagLabel.value = label
draft.value = stripWorkbenchDateLabelFromDraft(draft.value)
workbenchDatePickerOpen.value = false
focusInput?.()
}
function handleWorkbenchDateInputChange(part = 'single') {
if (workbenchDateMode.value !== 'range' || part === 'single') {
applyWorkbenchDateSelection()
return
}
if (part === 'range-start') {
if (!workbenchRangeEndDate.value || workbenchRangeEndDate.value < workbenchRangeStartDate.value) {
workbenchRangeEndDate.value = workbenchRangeStartDate.value
}
return
}
applyWorkbenchDateSelection()
}
function removeWorkbenchDateTag() {
workbenchDateTagLabel.value = ''
focusInput?.()
}
function buildWorkbenchPromptText(rawText = draft.value) {
return mergeWorkbenchDateLabelIntoDraft(rawText, workbenchDateTagLabel.value)
}
return {
workbenchDatePickerOpen,
workbenchDateMode,
workbenchSingleDate,
workbenchRangeStartDate,
workbenchRangeEndDate,
workbenchDateTagLabel,
workbenchCanApplyDateSelection,
clearWorkbenchDateSelection,
toggleWorkbenchDatePicker,
closeWorkbenchDatePicker,
setWorkbenchDateMode,
handleWorkbenchDatePickerOutside,
applyWorkbenchDateSelection,
handleWorkbenchDateInputChange,
removeWorkbenchDateTag,
buildWorkbenchPromptText
}
}

View File

@@ -60,6 +60,68 @@ export const metricBlueprints = [
}
]
export const systemMetricBlueprints = [
{
key: 'toolCalls',
label: '工具调用次数',
unit: '次',
accent: 'var(--theme-primary)',
icon: 'mdi mdi-tools',
trend: 'up',
change: '18.6%',
delta: '较昨日 +286 次'
},
{
key: 'modelTokens',
label: '大模型 Token',
unit: 'tokens',
accent: 'var(--chart-blue)',
icon: 'mdi mdi-chip',
trend: 'up',
change: '11.2%',
delta: '较昨日 +43.2K'
},
{
key: 'onlineUsers',
label: '当前在线人数',
unit: '人',
accent: 'var(--chart-purple)',
icon: 'mdi mdi-account-multiple-outline',
trend: 'up',
change: '9.4%',
delta: '峰值 168 人'
},
{
key: 'avgOnlineMinutes',
label: '平均在线时长',
unit: '分钟',
accent: 'var(--chart-amber)',
icon: 'mdi mdi-timer-outline',
trend: 'down',
change: '4.8%',
delta: '较昨日 -1.9 分钟'
},
{
key: 'executionSuccessRate',
label: '执行成功率',
unit: '%',
accent: 'var(--success)',
icon: 'mdi mdi-check-decagram-outline',
trend: 'up',
change: '2.1%',
delta: '错误率 3.6%'
},
{
key: 'positiveFeedback',
label: '好评 / 差评',
accent: 'var(--danger)',
icon: 'mdi mdi-thumb-up-outline',
trend: 'up',
change: '5.7%',
delta: '差评 31 次'
}
]
export const trendRanges = ['近12天', '近7天', '近30天']
export const trendSeries = {
@@ -132,3 +194,141 @@ export const budgetSummary = {
used: '¥2,128,000',
left: '¥672,000'
}
export const systemDashboardTotals = {
toolCalls: 1842,
modelTokens: 428600,
onlineUsers: 126,
avgOnlineMinutes: 38.6,
executionSuccessRate: 96.4,
positiveFeedback: 215,
negativeFeedback: 31
}
export const systemTrendSeries = {
labels: ['09:00', '10:00', '11:00', '12:00', '13:00', '14:00', '15:00', '16:00'],
toolCalls: [148, 196, 252, 184, 216, 318, 296, 232],
tokens: [36200, 48600, 64200, 41800, 52300, 73600, 68900, 43000],
onlineUsers: [76, 94, 118, 86, 104, 143, 137, 126],
onlineMinutes: [31.2, 35.4, 39.8, 34.1, 36.6, 42.5, 41.7, 38.6]
}
export const systemAgentDailyRatio = {
labels: ['05-23', '05-24', '05-25', '05-26', '05-27', '05-28', '05-29'],
agents: [
{ key: 'preAudit', name: '报销预审', color: 'var(--theme-primary)' },
{ key: 'policyQa', name: '政策问答', color: 'var(--chart-blue)' },
{ key: 'invoiceOcr', name: '票据识别', color: 'var(--chart-amber)' },
{ key: 'ruleAudit', name: '规则审核', color: 'var(--chart-purple)' },
{ key: 'employeeLookup', name: '员工查询', color: 'var(--success)' },
{ key: 'diagnosis', name: '异常诊断', color: 'var(--danger)' }
],
series: {
preAudit: [30, 32, 35, 31, 34, 37, 33],
policyQa: [24, 22, 21, 25, 22, 20, 23],
invoiceOcr: [18, 19, 17, 18, 16, 15, 17],
ruleAudit: [16, 15, 15, 14, 16, 15, 15],
employeeLookup: [8, 8, 8, 8, 8, 7, 8],
diagnosis: [4, 4, 4, 4, 4, 6, 4]
}
}
export const systemLoginWave = {
labels: ['08:00', '09:00', '10:00', '11:00', '12:00', '13:00', '14:00', '15:00', '16:00', '17:00', '18:00', '19:00', '20:00'],
loginUsers: [18, 56, 94, 121, 84, 73, 116, 158, 146, 126, 92, 58, 34],
interactions: [36, 118, 214, 286, 174, 156, 268, 348, 326, 278, 188, 102, 54]
}
export const systemTokenDailyWave = {
labels: ['05-23', '05-24', '05-25', '05-26', '05-27', '05-28', '05-29'],
inputTokens: [38200, 41600, 48600, 46200, 53800, 58400, 52600],
outputTokens: [18600, 22400, 25800, 23600, 28600, 31800, 29400],
totalTokens: [56800, 64000, 74400, 69800, 82400, 90200, 82000]
}
export const systemUsageDurationSummary = {
average: '38.6 分钟',
median: '34.2 分钟',
peak: '58.4 分钟',
trend: '+6.8%',
rows: [
{ label: '0-10 分钟', value: 28, color: 'var(--chart-blue)' },
{ label: '10-30 分钟', value: 64, color: 'var(--theme-primary)' },
{ label: '30-60 分钟', value: 82, color: 'var(--chart-purple)' },
{ label: '60 分钟以上', value: 31, color: 'var(--chart-amber)' }
]
}
export const systemUserTokenUsage = [
{ name: '陈雨晴', role: '财务经理', tokens: 96800, color: 'var(--theme-primary)' },
{ name: '顾成宇', role: '研发负责人', tokens: 82500, color: 'var(--chart-blue)' },
{ name: '沈佳宁', role: '销售运营', tokens: 68400, color: 'var(--chart-amber)' },
{ name: '赵明轩', role: '行政专员', tokens: 53800, color: 'var(--chart-purple)' },
{ name: '李思远', role: '部门主管', tokens: 42600, color: 'var(--success)' },
{ name: '王若彤', role: '费用审核', tokens: 31500, color: 'var(--danger)' }
]
export const systemAccuracyComparison = {
categories: ['报销预审', '政策问答', '票据识别', '规则审核', '员工查询', '异常诊断'],
correct: [516, 410, 313, 275, 172, 102],
wrong: [12, 6, 15, 11, 2, 8]
}
export const systemLoadHeatmap = {
hours: ['09:00', '10:00', '11:00', '12:00', '13:00', '14:00', '15:00', '16:00'],
tools: ['报销预审', '政策问答', '票据识别', '规则审核', '员工查询', '异常诊断'],
values: [
[0, 0, 42], [1, 0, 58], [2, 0, 74], [3, 0, 52], [4, 0, 64], [5, 0, 88], [6, 0, 82], [7, 0, 68],
[0, 1, 36], [1, 1, 44], [2, 1, 61], [3, 1, 40], [4, 1, 46], [5, 1, 69], [6, 1, 64], [7, 1, 56],
[0, 2, 28], [1, 2, 39], [2, 2, 52], [3, 2, 33], [4, 2, 41], [5, 2, 67], [6, 2, 58], [7, 2, 46],
[0, 3, 24], [1, 3, 32], [2, 3, 45], [3, 3, 30], [4, 3, 36], [5, 3, 55], [6, 3, 52], [7, 3, 38],
[0, 4, 12], [1, 4, 20], [2, 4, 26], [3, 4, 18], [4, 4, 22], [5, 4, 34], [6, 4, 29], [7, 4, 24],
[0, 5, 6], [1, 5, 12], [2, 5, 18], [3, 5, 11], [4, 5, 13], [5, 5, 22], [6, 5, 19], [7, 5, 15]
]
}
export const systemToolCallMix = [
{ name: '报销预审', value: 528, color: 'var(--theme-primary)' },
{ name: '政策问答', value: 416, color: 'var(--chart-blue)' },
{ name: '票据识别', value: 328, color: 'var(--chart-amber)' },
{ name: '规则审核', value: 286, color: 'var(--chart-purple)' },
{ name: '员工查询', value: 174, color: 'var(--success)' },
{ name: '异常诊断', value: 110, color: 'var(--danger)' }
]
export const systemExecutionMix = [
{ name: '成功', value: 1776, color: 'var(--success)' },
{ name: '业务拦截', value: 38, color: 'var(--chart-amber)' },
{ name: '超时', value: 16, color: 'var(--warning)' },
{ name: '错误', value: 12, color: 'var(--danger)' }
]
export const systemToolRankings = [
{ name: '费用规则匹配', value: 486, color: 'var(--theme-primary)' },
{ name: '发票结构化解析', value: 392, color: 'var(--chart-blue)' },
{ name: '差旅标准查询', value: 318, color: 'var(--chart-amber)' },
{ name: '审批流推荐', value: 241, color: 'var(--chart-purple)' },
{ name: '员工组织查询', value: 176, color: 'var(--success)' }
]
export const systemModelUsage = [
{ name: '智能填单与预审', tokens: 146800, share: 34, color: 'var(--theme-primary)' },
{ name: '政策知识问答', tokens: 118400, share: 28, color: 'var(--chart-blue)' },
{ name: '票据 OCR 解释', tokens: 86300, share: 20, color: 'var(--chart-amber)' },
{ name: '异常原因归纳', tokens: 77100, share: 18, color: 'var(--chart-purple)' }
]
export const systemFeedbackSummary = [
{ label: '好评次数', value: 215, tone: 'success', icon: 'mdi mdi-thumb-up-outline' },
{ label: '差评次数', value: 31, tone: 'danger', icon: 'mdi mdi-thumb-down-outline' },
{ label: '反馈率', value: '13.4%', tone: 'info', icon: 'mdi mdi-message-processing-outline' }
]
export const systemToolDetailRows = [
{ name: '报销预审', calls: 528, successRate: 97.8, avgLatency: '1.8s', tokens: 146800, color: 'var(--theme-primary)' },
{ name: '政策问答', calls: 416, successRate: 98.6, avgLatency: '1.2s', tokens: 118400, color: 'var(--chart-blue)' },
{ name: '票据识别', calls: 328, successRate: 95.4, avgLatency: '2.6s', tokens: 86300, color: 'var(--chart-amber)' },
{ name: '规则审核', calls: 286, successRate: 96.1, avgLatency: '1.5s', tokens: 77100, color: 'var(--chart-purple)' },
{ name: '员工查询', calls: 174, successRate: 99.2, avgLatency: '0.8s', tokens: 18200, color: 'var(--success)' },
{ name: '异常诊断', calls: 110, successRate: 92.7, avgLatency: '2.9s', tokens: 31800, color: 'var(--danger)' }
]

View File

@@ -4,11 +4,10 @@ import { checkBackendHealth } from '../composables/useBackendHealth.js'
import { appViews } from '../composables/useNavigation.js'
import { useSystemState } from '../composables/useSystemState.js'
import { canAccessAppView } from '../utils/accessControl.js'
const AppShellRouteView = () => import('../views/AppShellRouteView.vue')
const BackendUnavailableRouteView = () => import('../views/BackendUnavailableRouteView.vue')
const LoginRouteView = () => import('../views/LoginRouteView.vue')
const SetupRouteView = () => import('../views/SetupRouteView.vue')
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 appChildRoutes = appViews
.filter((view) => view !== 'documents')
@@ -124,6 +123,19 @@ const router = createRouter({
]
})
function isAuthenticatedAppNavigation(to, from) {
return Boolean(to.meta.requiresAuth && from.meta.requiresAuth && from.name !== 'backend-unavailable')
}
function scheduleBackgroundBackendHealthCheck() {
void checkBackendHealth({ allowStaleOnTimeout: true }).then((ok) => {
const currentRoute = router.currentRoute.value
if (!ok && currentRoute.meta.requiresAuth && currentRoute.name !== 'backend-unavailable') {
void router.replace({ name: 'backend-unavailable' })
}
})
}
router.beforeEach((to, from) => {
if (to.name === 'app-documents' && from.name !== 'app-document-detail') {
if (typeof window !== 'undefined' && window.localStorage) {
@@ -147,6 +159,15 @@ router.beforeEach((to, from) => {
}
if (authActive && to.meta.requiresAuth) {
if (typeof to.meta.appView === 'string' && !canAccessAppView(currentUser.value, to.meta.appView)) {
return resolveEntryRoute()
}
if (isAuthenticatedAppNavigation(to, from)) {
scheduleBackgroundBackendHealthCheck()
return true
}
return checkBackendHealth({ allowStaleOnTimeout: true }).then((ok) => {
if (!ok && to.name !== 'backend-unavailable') {
return { name: 'backend-unavailable' }
@@ -156,10 +177,6 @@ router.beforeEach((to, from) => {
return resolveEntryRoute()
}
if (ok && typeof to.meta.appView === 'string' && !canAccessAppView(currentUser.value, to.meta.appView)) {
return resolveEntryRoute()
}
return true
})
}

View File

@@ -165,6 +165,22 @@ export function generateRiskRuleAsset(payload, options = {}) {
})
}
export function updateRiskRuleDraft(assetId, payload, options = {}) {
return apiRequest(`/agent-assets/${assetId}/risk-rules/draft`, {
method: 'PATCH',
body: JSON.stringify(payload || {}),
headers: buildWriteHeaders(options)
})
}
export function createRiskRuleRevision(assetId, payload, options = {}) {
return apiRequest(`/agent-assets/${assetId}/risk-rules/revisions`, {
method: 'POST',
body: JSON.stringify(payload || {}),
headers: buildWriteHeaders(options)
})
}
export function fetchRiskRuleLatestTest(assetId) {
return apiRequest(`/agent-assets/${assetId}/risk-rule-tests/latest`)
}

View File

@@ -0,0 +1,93 @@
import { apiRequest } from './api.js'
const SYSTEM_DASHBOARD_FALLBACK = {
totals: null,
agentDailyRatio: null,
loginWave: null,
tokenDailyWave: null,
userTokenUsage: null,
accuracyComparison: null,
usageDurationSummary: null,
feedbackSummary: null,
toolDetailRows: null,
hasRealData: false
}
const FINANCE_DASHBOARD_FALLBACK = {
totals: null,
metricMeta: null,
trend: null,
spendByCategory: null,
exceptionMix: null,
departmentRanking: null,
bottlenecks: null,
budgetSummary: null,
hasRealData: false
}
function normalizeSystemDashboardPayload(payload = {}) {
return {
...SYSTEM_DASHBOARD_FALLBACK,
windowDays: Number(payload.window_days || payload.windowDays || 7),
generatedAt: payload.generated_at || payload.generatedAt || '',
hasRealData: Boolean(payload.has_real_data ?? payload.hasRealData),
totals: payload.totals || null,
agentDailyRatio: payload.agent_daily_ratio || payload.agentDailyRatio || null,
loginWave: payload.login_wave || payload.loginWave || null,
tokenDailyWave: payload.token_daily_wave || payload.tokenDailyWave || null,
userTokenUsage: payload.user_token_usage || payload.userTokenUsage || null,
accuracyComparison: payload.accuracy_comparison || payload.accuracyComparison || null,
usageDurationSummary: payload.usage_duration_summary || payload.usageDurationSummary || null,
feedbackSummary: payload.feedback_summary || payload.feedbackSummary || null,
toolDetailRows: payload.tool_detail_rows || payload.toolDetailRows || null
}
}
function normalizeFinanceDashboardPayload(payload = {}) {
return {
...FINANCE_DASHBOARD_FALLBACK,
rangeKey: payload.range_key || payload.rangeKey || '近10日',
startDate: payload.start_date || payload.startDate || '',
endDate: payload.end_date || payload.endDate || '',
generatedAt: payload.generated_at || payload.generatedAt || '',
hasRealData: Boolean(payload.has_real_data ?? payload.hasRealData),
totals: payload.totals || null,
metricMeta: payload.metric_meta || payload.metricMeta || null,
trend: payload.trend || null,
spendByCategory: payload.spend_by_category || payload.spendByCategory || null,
exceptionMix: payload.exception_mix || payload.exceptionMix || null,
departmentRanking: payload.department_ranking || payload.departmentRanking || null,
bottlenecks: payload.bottlenecks || null,
budgetSummary: payload.budget_summary || payload.budgetSummary || null
}
}
export async function fetchSystemDashboard(options = {}) {
const days = Number(options.days || 7)
const search = new URLSearchParams()
search.set('days', String(Math.max(1, Math.min(days, 30))))
const payload = await apiRequest(`/analytics/system-dashboard?${search.toString()}`, {
timeoutMs: Number(options.timeoutMs || 3500),
timeoutMessage: '系统看板真实数据加载超时,已保留本地展示数据。'
})
return normalizeSystemDashboardPayload(payload)
}
export async function fetchFinanceDashboard(options = {}) {
const search = new URLSearchParams()
search.set('range_key', String(options.rangeKey || options.range || '近10日'))
search.set('trend_range', String(options.trendRange || '近12天'))
search.set('department_range', String(options.departmentRange || '本月'))
if (options.startDate) search.set('start_date', String(options.startDate))
if (options.endDate) search.set('end_date', String(options.endDate))
const payload = await apiRequest(`/analytics/finance-dashboard?${search.toString()}`, {
timeoutMs: Number(options.timeoutMs || 3500),
timeoutMessage: '财务看板真实数据加载超时,已保留本地展示数据。'
})
return normalizeFinanceDashboardPayload(payload)
}

View File

@@ -1,4 +1,4 @@
import { apiRequest } from './api.js'
import { apiRequest, getRuntimeApiBaseUrl } from './api.js'
export function login(payload) {
return apiRequest('/auth/login', {
@@ -6,3 +6,34 @@ export function login(payload) {
body: JSON.stringify(payload)
})
}
export function finishSession(sessionId, payload) {
return apiRequest(`/auth/sessions/${encodeURIComponent(sessionId)}/finish`, {
method: 'POST',
body: JSON.stringify(payload),
keepalive: true,
timeoutMs: 3000
})
}
export function finishSessionOnUnload(sessionId, payload) {
const normalizedSessionId = String(sessionId || '').trim()
if (!normalizedSessionId || typeof window === 'undefined') {
return false
}
const url = `${getRuntimeApiBaseUrl()}/auth/sessions/${encodeURIComponent(normalizedSessionId)}/finish`
const body = JSON.stringify(payload || {})
if (typeof window.navigator?.sendBeacon === 'function') {
return window.navigator.sendBeacon(url, new Blob([body], { type: 'application/json' }))
}
window.fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
keepalive: true
}).catch(() => {})
return true
}

View File

@@ -0,0 +1,27 @@
import { apiRequest } from './api.js'
function buildQuery(params = {}) {
const search = new URLSearchParams()
if (params.agent) {
search.set('agent', String(params.agent).trim())
}
if (params.sessionType || params.session_type) {
search.set('session_type', String(params.sessionType || params.session_type).trim())
}
if (params.limit) {
search.set('limit', String(params.limit))
}
const query = search.toString()
return query ? `?${query}` : ''
}
export function createOperationFeedback(payload) {
return apiRequest('/agent-feedback', {
method: 'POST',
body: JSON.stringify(payload || {})
})
}
export function fetchOperationFeedbackSummary(params = {}) {
return apiRequest(`/agent-feedback/summary${buildQuery(params)}`)
}

View File

@@ -0,0 +1,143 @@
import { apiRequest } from './api.js'
function toNumber(value, fallback = 0) {
const number = Number(value)
return Number.isFinite(number) ? number : fallback
}
function toArray(value) {
return Array.isArray(value) ? value : []
}
function toObject(value) {
return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
}
export function normalizeRiskObservation(item = {}) {
return {
id: String(item.id || '').trim(),
observationKey: String(item.observation_key || item.observationKey || '').trim(),
claimId: String(item.claim_id || item.claimId || '').trim(),
claimNo: String(item.claim_no || item.claimNo || '').trim(),
riskType: String(item.risk_type || item.riskType || '').trim(),
riskSignal: String(item.risk_signal || item.riskSignal || '').trim(),
title: String(item.title || '').trim(),
description: String(item.description || '').trim(),
riskScore: toNumber(item.risk_score ?? item.riskScore),
riskLevel: String(item.risk_level || item.riskLevel || '').trim(),
confidenceScore: toNumber(item.confidence_score ?? item.confidenceScore),
controlStage: String(item.control_stage || item.controlStage || '').trim(),
controlMode: String(item.control_mode || item.controlMode || '').trim(),
automationMode: String(item.automation_mode || item.automationMode || '').trim(),
source: String(item.source || '').trim(),
algorithmVersion: String(item.algorithm_version || item.algorithmVersion || '').trim(),
status: String(item.status || '').trim(),
feedbackStatus: String(item.feedback_status || item.feedbackStatus || '').trim(),
contributionScores: toObject(item.contribution_scores || item.contributionScores),
baseline: toObject(item.baseline),
evidence: toArray(item.evidence),
graphNodeKeys: toArray(item.graph_node_keys || item.graphNodeKeys),
graphEdgeKeys: toArray(item.graph_edge_keys || item.graphEdgeKeys),
policyRefs: toArray(item.policy_refs || item.policyRefs),
similarCaseClaimIds: toArray(item.similar_case_claim_ids || item.similarCaseClaimIds),
ontology: toObject(item.ontology_json || item.ontologyJson),
decisionTrace: toObject(item.decision_trace || item.decisionTrace),
feedbackItems: toArray(item.feedback_items || item.feedbackItems),
createdAt: String(item.created_at || item.createdAt || '').trim(),
updatedAt: String(item.updated_at || item.updatedAt || '').trim()
}
}
export function normalizeRiskObservationDashboard(payload = {}) {
return {
windowDays: toNumber(payload.window_days ?? payload.windowDays, 30),
totalObservations: toNumber(payload.total_observations ?? payload.totalObservations),
pendingCount: toNumber(payload.pending_count ?? payload.pendingCount),
highOrAboveCount: toNumber(payload.high_or_above_count ?? payload.highOrAboveCount),
confirmedCount: toNumber(payload.confirmed_count ?? payload.confirmedCount),
falsePositiveCount: toNumber(payload.false_positive_count ?? payload.falsePositiveCount),
totalAmount: toNumber(payload.total_amount ?? payload.totalAmount),
averageScore: toNumber(payload.average_score ?? payload.averageScore),
confirmationRate: toNumber(payload.confirmation_rate ?? payload.confirmationRate),
falsePositiveRate: toNumber(payload.false_positive_rate ?? payload.falsePositiveRate),
candidateRuleCount: toNumber(payload.candidate_rule_count ?? payload.candidateRuleCount),
levelDistribution: toObject(payload.level_distribution || payload.levelDistribution),
statusDistribution: toObject(payload.status_distribution || payload.statusDistribution),
signalDistribution: toObject(payload.signal_distribution || payload.signalDistribution),
sourceDistribution: toObject(payload.source_distribution || payload.sourceDistribution),
automationDistribution: toObject(
payload.automation_distribution || payload.automationDistribution
),
departmentDistribution: toObject(
payload.department_distribution || payload.departmentDistribution
),
expenseTypeDistribution: toObject(
payload.expense_type_distribution || payload.expenseTypeDistribution
),
riskTypeDistribution: toObject(payload.risk_type_distribution || payload.riskTypeDistribution),
supplierDistribution: toObject(payload.supplier_distribution || payload.supplierDistribution),
employeeGradeDistribution: toObject(
payload.employee_grade_distribution || payload.employeeGradeDistribution
),
dailyTrend: toArray(payload.daily_trend || payload.dailyTrend),
topRiskSignals: toArray(payload.top_risk_signals || payload.topRiskSignals),
topDepartments: toArray(payload.top_departments || payload.topDepartments),
topEmployees: toArray(payload.top_employees || payload.topEmployees),
topSuppliers: toArray(payload.top_suppliers || payload.topSuppliers),
topExpenseTypes: toArray(payload.top_expense_types || payload.topExpenseTypes),
topRules: toArray(payload.top_rules || payload.topRules),
recentHighObservations: toArray(
payload.recent_high_observations || payload.recentHighObservations
).map(normalizeRiskObservation)
}
}
export async function fetchRiskObservationDashboard(options = {}) {
const windowDays = Math.max(1, Math.min(toNumber(options.windowDays || 30), 365))
const limit = Math.max(1, Math.min(toNumber(options.limit || 500), 2000))
const search = new URLSearchParams()
search.set('window_days', String(windowDays))
search.set('limit', String(limit))
const payload = await apiRequest(`/risk-observations/dashboard?${search.toString()}`, {
timeoutMs: toNumber(options.timeoutMs || 3500),
timeoutMessage: '风险看板数据加载超时,请稍后重试。'
})
return normalizeRiskObservationDashboard(payload)
}
export async function fetchClaimRiskObservations(claimId, options = {}) {
const normalizedClaimId = String(claimId || '').trim()
if (!normalizedClaimId) {
return []
}
const payload = await apiRequest(
`/risk-observations/claim/${encodeURIComponent(normalizedClaimId)}`,
{
timeoutMs: toNumber(options.timeoutMs || 3500),
timeoutMessage: '风险证据链加载超时,请稍后重试。'
}
)
return toArray(payload).map(normalizeRiskObservation)
}
export async function fetchRunRiskObservations(runId, options = {}) {
const normalizedRunId = String(runId || '').trim()
if (!normalizedRunId) {
return []
}
const search = new URLSearchParams()
search.set('run_id', normalizedRunId)
search.set('limit', String(Math.max(1, Math.min(toNumber(options.limit || 100), 200))))
const payload = await apiRequest(`/risk-observations?${search.toString()}`, {
timeoutMs: toNumber(options.timeoutMs || 3500),
timeoutMessage: '本次运行风险观察加载超时,请稍后重试。'
})
return toArray(payload.items || payload).map(normalizeRiskObservation)
}

View File

@@ -0,0 +1,21 @@
const INTERNAL_MESSAGE_META_PATTERNS = [
/^Agent\s*[:]/i,
/^权限\s*[:]/,
/^Run\s*[:]/i,
/^工具\s*[:]/,
/^能力\s*[:]/,
/^Capability\s*[:]/i,
/(?:^|[_\s-])user[_\s-]?agent(?:$|[_\s-])/i,
/\bdraft[_\s-]?write\b/i
]
export function isInternalMessageMeta(item) {
const normalized = String(item || '').trim()
return !normalized || INTERNAL_MESSAGE_META_PATTERNS.some((pattern) => pattern.test(normalized))
}
export function filterVisibleMessageMeta(meta = []) {
return (Array.isArray(meta) ? meta : [])
.map((item) => String(item || '').trim())
.filter((item) => item && !isInternalMessageMeta(item))
}

View File

@@ -31,7 +31,17 @@ const SESSION_SCOPE_CONFIG = {
const SESSION_SCOPE_TYPES = Object.keys(SESSION_SCOPE_CONFIG)
const APPLICATION_PATTERN =
/费用申请|发起申请|申请单|事前申请|事前审批|前置审批|出差申请|采购申请|用款申请|预算申请|申请材料|材料清单|先申请|立项申请/
/费用申请|发起申请|申请单|事前申请|事前审批|前置审批|出差申请|申请出差|差旅申请|申请差旅|采购申请|用款申请|预算申请|申请材料|材料清单|先申请|立项申请/
const APPLICATION_PLANNING_PATTERN =
/计划|安排|准备|需要|打算|预计|申请|发起|提交|提出|先走|先办|要去|将要|下周|下月|明天|后天|近期|月底|去|到|赴|前往|参加/
const APPLICATION_BUSINESS_PATTERN =
/出差|差旅|客户现场|现场|客户|项目|部署|实施|支撑|支持|协助|拜访|调研|培训|会议|会务|驻场|上线|验收|采购|购置|用款|立项/
const APPLICATION_FUTURE_OR_DURATION_PATTERN =
/明天|后天|下周|下月|近期|月底|预计|计划|安排|准备|将要|[0-9]+天|[一二两三四五六七八九十]+天/
const APPLICATION_ROUTE_PATTERN =
/(?:去|到|赴|前往)[^,。;;?!\n]{0,24}(?:出差|差旅|客户|现场|项目|部署|实施|支撑|支持|协助|拜访|调研|培训|会议|驻场|上线|验收)|(?:出差|差旅)[^,。;;?!\n]{0,24}(?:[0-9]+天|[一二两三四五六七八九十]+天|客户|现场|项目|部署|实施|支撑|支持|协助|拜访|调研|培训|会议|驻场|上线|验收)/
const COMPLETED_EXPENSE_PATTERN =
/已经|已|昨天|前天|上周|上月|去年|花了|花销|消费|垫付|支付|付了|买了|采购了|招待了|发生了/
const EXPENSE_PATTERN =
/报销|报销单|票据|发票|火车票|高铁票|机票|飞机票|的士票|出租车|网约车|酒店票|住宿票|住宿单据|保存草稿|草稿|费用明细|归集|上传.*票|关联单据|继续下一步/
const APPROVAL_PATTERN =
@@ -52,6 +62,34 @@ function normalizeText(rawText) {
.toLowerCase()
}
export function hasReimbursementIntentSignal(rawText) {
return EXPENSE_PATTERN.test(normalizeText(rawText))
}
export function hasExpenseApplicationIntentSignal(rawText) {
const text = normalizeText(rawText)
if (!text) {
return false
}
if (APPLICATION_PATTERN.test(text)) {
return true
}
if (hasReimbursementIntentSignal(text) || COMPLETED_EXPENSE_PATTERN.test(text)) {
return false
}
if (KNOWLEDGE_PATTERN.test(text) && !EXPENSE_OPERATION_PATTERN.test(text)) {
return false
}
const hasBusinessSignal = APPLICATION_BUSINESS_PATTERN.test(text)
const planningScore = APPLICATION_PLANNING_PATTERN.test(text) ? 1 : 0
const timingScore = APPLICATION_FUTURE_OR_DURATION_PATTERN.test(text) ? 1 : 0
const routeScore = APPLICATION_ROUTE_PATTERN.test(text) ? 2 : 0
return hasBusinessSignal && planningScore + timingScore + routeScore >= 2
}
function resolveScopeConfig(sessionType) {
return SESSION_SCOPE_CONFIG[normalizeSessionType(sessionType)] || SESSION_SCOPE_CONFIG[ASSISTANT_SCOPE_SESSION_EXPENSE]
}
@@ -62,7 +100,7 @@ export function inferAssistantScopeTarget(rawText, options = {}) {
return ''
}
const applicationMatched = APPLICATION_PATTERN.test(text)
const applicationMatched = hasExpenseApplicationIntentSignal(text)
const expenseMatched = EXPENSE_PATTERN.test(text)
const approvalMatched = APPROVAL_PATTERN.test(text)
const knowledgeMatched = KNOWLEDGE_PATTERN.test(text)

View File

@@ -0,0 +1,90 @@
import {
finishSession,
finishSessionOnUnload
} from '../services/auth.js'
const AUTH_SESSION_ID_KEY = 'x-financial-auth-session-id'
const AUTH_LAST_ACTIVITY_KEY = 'x-financial-auth-last-activity'
const AUTH_ACTIVITY_COUNT_KEY = 'x-financial-auth-activity-count'
function readStoredSessionId() {
if (typeof window === 'undefined') {
return ''
}
return String(window.sessionStorage.getItem(AUTH_SESSION_ID_KEY) || '').trim()
}
function readLastActivityAt() {
if (typeof window === 'undefined') {
return 0
}
return Number(window.sessionStorage.getItem(AUTH_LAST_ACTIVITY_KEY) || 0)
}
function readActivityEventCount() {
if (typeof window === 'undefined') {
return 0
}
const value = Number(window.sessionStorage.getItem(AUTH_ACTIVITY_COUNT_KEY) || 0)
return Number.isFinite(value) && value > 0 ? Math.round(value) : 0
}
function buildSessionFinishPayload(reason) {
const lastActivityAt = readLastActivityAt()
const pagePath = typeof window === 'undefined'
? ''
: `${window.location.pathname}${window.location.search}`
return {
reason,
lastActivityAt: lastActivityAt ? new Date(lastActivityAt).toISOString() : null,
activityEventCount: readActivityEventCount(),
pagePath
}
}
export function persistAuthSessionMetrics(sessionId) {
if (typeof window === 'undefined') {
return
}
window.sessionStorage.setItem(AUTH_SESSION_ID_KEY, String(sessionId || '').trim())
window.sessionStorage.setItem(AUTH_ACTIVITY_COUNT_KEY, '0')
}
export function clearAuthSessionMetrics() {
if (typeof window === 'undefined') {
return
}
window.sessionStorage.removeItem(AUTH_SESSION_ID_KEY)
window.sessionStorage.removeItem(AUTH_ACTIVITY_COUNT_KEY)
}
export function incrementAuthActivityCount() {
if (typeof window === 'undefined') {
return
}
window.sessionStorage.setItem(AUTH_ACTIVITY_COUNT_KEY, String(readActivityEventCount() + 1))
}
export function finalizeAuthSession(reason, options = {}) {
const sessionId = readStoredSessionId()
if (!sessionId) {
return
}
const payload = buildSessionFinishPayload(reason)
if (options.unload) {
finishSessionOnUnload(sessionId, payload)
return
}
finishSession(sessionId, payload).catch((error) => {
console.warn('Failed to finish auth session:', error)
})
}

View File

@@ -46,6 +46,41 @@ const RADAR_COLORS = [
'#db2777'
]
const FINANCIAL_RADAR_CODES = [
'expense_intensity',
'application_rhythm',
'travel_entertainment',
'material_completeness',
'process_pressure'
]
const GOVERNANCE_RADAR_CODES = [
'ai_collaboration',
'approval_efficiency',
'approval_control'
]
export const USER_PROFILE_RADAR_VIEW_OPTIONS = [
{
value: 'financial_risk',
label: '财务风险视角',
shortLabel: '财务风险',
description: '费用、材料和流程相关维度'
},
{
value: 'collaboration_governance',
label: '协作治理视角',
shortLabel: '协作治理',
description: 'AI 协作和审批治理维度'
},
{
value: 'all_behavior',
label: '全部行为视角',
shortLabel: '全部行为',
description: '展示全部可用画像维度'
}
]
const TAG_ACCENT_COUNT = 8
const SOURCE_LABELS = {
@@ -89,7 +124,7 @@ const JOB_TYPE_LABELS = {
llm_wiki_sync: '知识库归纳同步',
employee_behavior_profile_scan: '用户画像测算',
workbench_on_demand: '工作台画像测算',
global_risk_scan: '全局风险巡检',
global_risk_scan: '财务风险图谱巡检',
weekly_expense_report: '周费用报告'
}
@@ -98,9 +133,7 @@ export function buildUserProfileMetricCards(profile, runs = [], currentUser = {}
const aiMetrics = metricsOf(index.ai_usage)
const userRuns = filterRunsByCurrentUser(runs, currentUser)
const windowedUserRuns = filterRunsByProfileWindow(userRuns, profile)
const durationMs = hasProfileDurationMetric(aiMetrics)
? resolveNumber(aiMetrics.ai_run_duration_ms)
: sumRunDurationMs(windowedUserRuns)
const durationMs = resolveUsageDurationMs(aiMetrics, windowedUserRuns)
const durationDisplay = formatDurationMetric(durationMs)
const commonAgent = resolveCommonAgent(windowedUserRuns)
const tokenCount = resolveNumber(aiMetrics.exact_token_count) || resolveNumber(aiMetrics.estimated_token_count)
@@ -113,7 +146,7 @@ export function buildUserProfileMetricCards(profile, runs = [], currentUser = {}
label: '使用时长',
value: durationDisplay.value,
unit: durationDisplay.unit,
hint: `${resolveWindowDays(profile)}天智能体运行累计`,
hint: resolveUsageDurationHint(aiMetrics, profile),
icon: 'mdi mdi-timer-sand',
tone: 'primary'
},
@@ -157,10 +190,12 @@ export function normalizeUserProfileTags(profile, limit = 8) {
code: normalizeText(tag.code || tag.label),
label: normalizeText(tag.label),
displayLabel: normalizeText(tag.display_label || tag.displayLabel || tag.label),
category: normalizeCode(tag.category),
tone: resolveTagTone(tag),
score: clampScore(tag.score),
reason: normalizeText(tag.reason) || '画像算法已识别该行为特征。',
confidence: resolveNumber(tag.confidence)
confidence: resolveNumber(tag.confidence),
radarDimensions: normalizeRadarDimensions(tag)
}))
.filter((tag) => tag.code && tag.displayLabel)
.sort((left, right) => right.score - left.score)
@@ -194,6 +229,55 @@ export function normalizeUserProfileRadarDimensions(profile) {
)
}
export function filterUserProfileRadarDimensions(dimensions, viewKey) {
const items = Array.isArray(dimensions) ? dimensions : []
const codes = resolveRadarViewCodes(viewKey)
if (!codes.length) {
return items
}
const filtered = items.filter((item) => codes.includes(normalizeCode(item?.code)))
return filtered.length ? filtered : items
}
export function filterUserProfileTagsByRadarView(tags, viewKey) {
const items = Array.isArray(tags) ? tags : []
const codes = resolveRadarViewCodes(viewKey)
if (!codes.length) {
return items
}
return items.filter((tag) => {
const dimensions = Array.isArray(tag?.radarDimensions) ? tag.radarDimensions : []
if (dimensions.some((code) => codes.includes(normalizeCode(code)))) {
return true
}
return resolveFallbackTagRadarCodes(tag).some((code) => codes.includes(code))
})
}
export function resolveUserProfileDefaultRadarView(profile) {
const profileTypes = new Set(
(Array.isArray(profile?.profiles) ? profile.profiles : [])
.map((item) => normalizeCode(item?.profile_type))
.filter(Boolean)
)
if (profileTypes.has('expense') || profileTypes.has('process_quality')) {
return 'financial_risk'
}
if (profileTypes.has('ai_usage') || profileTypes.has('approval')) {
return 'collaboration_governance'
}
const dimensions = normalizeUserProfileRadarDimensions(profile)
const financialScore = sumRadarScores(dimensions, FINANCIAL_RADAR_CODES)
const governanceScore = sumRadarScores(dimensions, GOVERNANCE_RADAR_CODES)
if (financialScore > 0 || governanceScore > 0) {
return financialScore >= governanceScore ? 'financial_risk' : 'collaboration_governance'
}
return 'all_behavior'
}
export function buildProfileOperationsFromAgentRuns(runs, currentUser, limit = 5) {
const identities = resolveCurrentUserIdentities(currentUser)
return (Array.isArray(runs) ? runs : [])
@@ -227,8 +311,69 @@ function metricsOf(profile) {
return profile?.metrics && typeof profile.metrics === 'object' ? profile.metrics : {}
}
function resolveRadarViewCodes(viewKey) {
if (viewKey === 'financial_risk') {
return FINANCIAL_RADAR_CODES
}
if (viewKey === 'collaboration_governance') {
return GOVERNANCE_RADAR_CODES
}
return []
}
function resolveFallbackTagRadarCodes(tag) {
const category = normalizeCode(tag?.category)
if (['expense', 'travel', 'entertainment', 'process'].includes(category)) {
return FINANCIAL_RADAR_CODES
}
if (['ai', 'approval'].includes(category)) {
return GOVERNANCE_RADAR_CODES
}
return []
}
function normalizeRadarDimensions(tag) {
const dimensions = Array.isArray(tag?.radar_dimensions)
? tag.radar_dimensions
: Array.isArray(tag?.radarDimensions)
? tag.radarDimensions
: []
return dimensions.map((item) => normalizeCode(item)).filter(Boolean)
}
function sumRadarScores(dimensions, codes) {
const codeSet = new Set(codes)
return (Array.isArray(dimensions) ? dimensions : [])
.filter((item) => codeSet.has(normalizeCode(item?.code)))
.reduce((total, item) => total + clampScore(item?.score), 0)
}
function hasProfileDurationMetric(metrics) {
return Object.prototype.hasOwnProperty.call(metrics || {}, 'ai_run_duration_ms')
return (
Object.prototype.hasOwnProperty.call(metrics || {}, 'usage_duration_ms')
|| Object.prototype.hasOwnProperty.call(metrics || {}, 'online_duration_ms')
|| Object.prototype.hasOwnProperty.call(metrics || {}, 'ai_run_duration_ms')
)
}
function resolveUsageDurationMs(metrics, fallbackRuns) {
if (!hasProfileDurationMetric(metrics)) {
return sumRunDurationMs(fallbackRuns)
}
return resolveNumber(metrics.usage_duration_ms)
|| resolveNumber(metrics.online_duration_ms)
|| resolveNumber(metrics.ai_run_duration_ms)
}
function resolveUsageDurationHint(metrics, profile) {
const days = resolveWindowDays(profile)
if (normalizeCode(metrics?.usage_duration_mode) === 'online_session') {
return `${days}天在线会话累计`
}
if (normalizeCode(metrics?.usage_duration_mode) === 'agent_run_fallback') {
return `${days}天智能体运行累计`
}
return `${days}天使用行为累计`
}
function filterRunsByCurrentUser(runs, currentUser) {

View File

@@ -104,3 +104,54 @@ export function buildApplicationDetailFactItems(request = {}) {
return rows.filter((row) => isProvided(row.value))
}
export function buildRelatedApplicationFactItems(request = {}) {
const related = request.relatedApplication || {}
const rows = [
{
key: 'claim_no',
label: '关联单据单号',
value: related.claimNo,
highlight: true
},
{
key: 'content',
label: '申请内容',
value: related.content
},
{
key: 'days',
label: '申请天数',
value: related.days
},
{
key: 'reason',
label: '申请事由',
value: related.reason
},
{
key: 'location',
label: '申请地点',
value: related.location
},
{
key: 'time',
label: '申请时间',
value: related.time
},
{
key: 'amount',
label: '预计金额',
value: related.amountLabel,
highlight: true,
emphasis: true
},
{
key: 'transport_mode',
label: '出行方式',
value: related.transportMode
}
]
return rows.filter((row) => isProvided(row.value))
}

View File

@@ -186,8 +186,10 @@ export function expandApplicationTimeWithDays(timeText, days = 0) {
if (!startDate) return normalizedTime
const endDate = new Date(startDate.getTime())
endDate.setUTCDate(endDate.getUTCDate() + dayCount)
return `${formatApplicationDate(startDate)}${formatApplicationDate(endDate)}`
endDate.setUTCDate(endDate.getUTCDate() + Math.max(dayCount - 1, 0))
const startText = formatApplicationDate(startDate)
const endText = formatApplicationDate(endDate)
return startText === endText ? startText : `${startText}${endText}`
}
function normalizeApplicationTimeCandidate(value) {

View File

@@ -1,4 +1,5 @@
import { buildApplicationFieldsFromOntology } from './expenseApplicationOntology.js'
import { getTodayDateValue } from './workbenchComposerDate.js'
const APPLICATION_SESSION_TYPE = 'application'
const APPLICATION_CREATE_PATTERN = /申请|事前|前置|预算|出差|差旅|采购|会务|会议|培训|办公用品|交通|住宿/
@@ -54,11 +55,11 @@ function formatIsoDate(date) {
}
function buildEndDateFromDays(startText, daysText = '') {
const days = Number(String(daysText || '').replace(/[^\d]/g, ''))
const days = parseApplicationDaysValue(daysText)
const start = parseIsoDate(startText)
if (!days || !start) return ''
const end = new Date(start.getTime())
end.setUTCDate(end.getUTCDate() + days)
end.setUTCDate(end.getUTCDate() + Math.max(days - 1, 0))
return formatIsoDate(end)
}
@@ -69,7 +70,16 @@ function resolveDaysFromDateRange(rangeText) {
const end = parseIsoDate(match[2])
if (!start || !end) return ''
const diffDays = Math.round((end.getTime() - start.getTime()) / 86400000)
return diffDays > 0 ? `${diffDays}` : '1天'
return diffDays >= 0 ? `${diffDays + 1}` : ''
}
function resolvePreviewToday(options = {}) {
const explicitToday = String(options.today || options.currentDate || '').trim()
if (parseIsoDate(explicitToday)) return normalizeDateText(explicitToday)
if (options.now instanceof Date && !Number.isNaN(options.now.getTime())) {
return getTodayDateValue(options.now)
}
return getTodayDateValue()
}
function resolveApplicationType(text) {
@@ -106,10 +116,35 @@ function resolveCurrentUserGrade(currentUser = {}) {
function parseApplicationDaysValue(value) {
const match = String(value || '').match(/\d+/)
const days = match ? Number(match[0]) : 0
const days = match ? Number(match[0]) : parseChineseNumber(value)
return Number.isFinite(days) && days > 0 ? Math.max(1, Math.floor(days)) : 0
}
function parseChineseNumber(value) {
const digits = {
: 1,
: 2,
: 2,
: 3,
: 4,
: 5,
: 6,
: 7,
: 8,
: 9
}
const text = String(value || '').match(/[一二两三四五六七八九十]{1,3}/)?.[0] || ''
if (!text) return 0
if (text === '十') return 10
if (text.includes('十')) {
const [left, right] = text.split('十')
const tens = left ? digits[left] || 0 : 1
const ones = right ? digits[right] || 0 : 0
return tens * 10 + ones
}
return digits[text] || 0
}
function parseMoneyNumber(value) {
const normalized = String(value ?? '').replace(/[^\d.-]/g, '')
const amount = Number(normalized)
@@ -161,7 +196,7 @@ function resolveApplicationDays(text) {
return value ? `${value}` : ''
}
function resolveApplicationTime(text, daysText = '') {
function resolveApplicationTime(text, daysText = '', options = {}) {
const range = text.match(
/(20\d{2}[-/.]\d{1,2}[-/.]\d{1,2})\s*(?:至|到|~|—||--)\s*(20\d{2}[-/.]\d{1,2}[-/.]\d{1,2})/u
)
@@ -176,7 +211,18 @@ function resolveApplicationTime(text, daysText = '') {
if (!single) return ''
const normalized = normalizeDateText(single)
const endDate = buildEndDateFromDays(normalized, daysText)
return endDate ? `${normalized}${endDate}` : normalized
return endDate && endDate !== normalized ? `${normalized}${endDate}` : normalized
}
function resolveApplicationTimeWithDefault(text, daysText = '', options = {}) {
const resolvedTime = resolveApplicationTime(text, daysText)
if (resolvedTime || !parseApplicationDaysValue(daysText)) {
return resolvedTime
}
const startDate = resolvePreviewToday(options)
const endDate = buildEndDateFromDays(startDate, daysText)
return endDate && endDate !== startDate ? `${startDate}${endDate}` : startDate
}
function resolveApplicationLocation(text) {
@@ -449,10 +495,10 @@ export function buildApplicationPreviewSubmitText(preview = {}) {
].join('\n')
}
export function buildLocalApplicationPreview(rawText, currentUser = {}) {
export function buildLocalApplicationPreview(rawText, currentUser = {}, options = {}) {
const sourceText = String(rawText || '').trim()
const explicitDays = resolveApplicationDays(sourceText)
const time = resolveApplicationTime(sourceText, explicitDays)
const time = resolveApplicationTimeWithDefault(sourceText, explicitDays, options)
const days = explicitDays || resolveDaysFromDateRange(time)
const location = resolveApplicationLocation(sourceText)
const fields = {

View File

@@ -2,8 +2,8 @@
export const HERMES_SIMPLE_TASKS = [
{
id: 'global_risk_scan',
label: '风险每日巡检',
hint: '扫描报销、付款等风险信号',
label: '财务风险图谱巡检',
hint: '扫描单据、票据、审批链和画像异常',
frequency: 'daily',
frequencyLabel: '每天'
},

View File

@@ -0,0 +1,53 @@
const SESSION_TYPE_APPLICATION = 'application'
const SESSION_TYPE_APPROVAL = 'approval'
const SESSION_TYPE_BUDGET = 'budget'
const SESSION_TYPE_EXPENSE = 'expense'
const SESSION_TYPE_KNOWLEDGE = 'knowledge'
const DEFAULT_WORKBENCH_ENTRY = {
source: 'workbench',
sessionType: SESSION_TYPE_EXPENSE
}
const CAPABILITY_ASSISTANT_ENTRIES = {
'expense-application': {
source: 'application',
sessionType: SESSION_TYPE_APPLICATION
},
'quick-reimbursement': DEFAULT_WORKBENCH_ENTRY,
'budget-planning': {
source: 'budget',
sessionType: SESSION_TYPE_BUDGET
},
'quick-approval': {
source: 'workbench',
sessionType: SESSION_TYPE_APPROVAL
},
'finance-analysis': {
source: 'budget',
sessionType: SESSION_TYPE_BUDGET
},
'company-policy': {
source: 'workbench',
sessionType: SESSION_TYPE_KNOWLEDGE
}
}
export function resolveWorkbenchCapabilityAssistantEntry(item = {}) {
const key = String(item?.key || '').trim()
const entry = CAPABILITY_ASSISTANT_ENTRIES[key] || DEFAULT_WORKBENCH_ENTRY
return { ...entry }
}
export function buildWorkbenchCapabilityAssistantPayload(item = {}, basePayload = {}) {
const entry = resolveWorkbenchCapabilityAssistantEntry(item)
return {
...basePayload,
...entry,
prompt: String(basePayload.prompt || '').trim(),
files: Array.isArray(basePayload.files) ? basePayload.files : [],
conversation: null
}
}

View File

@@ -0,0 +1,105 @@
import {
ASSISTANT_SCOPE_SESSION_APPLICATION,
ASSISTANT_SCOPE_SESSION_EXPENSE,
ASSISTANT_SCOPE_SESSION_KNOWLEDGE,
hasExpenseApplicationIntentSignal,
hasReimbursementIntentSignal,
inferAssistantScopeTarget
} from './assistantSessionScope.js'
const ASSISTANT_SCOPE_SESSION_BUDGET = 'budget'
function normalizeText(rawText) {
return String(rawText || '')
.replace(/\s+/g, '')
.toLowerCase()
}
function hasEntity(ontology, type, normalizedValue = '') {
const expectedType = String(type || '').trim()
const expectedValue = String(normalizedValue || '').trim()
return Array.isArray(ontology?.entities) && ontology.entities.some((item) => {
if (String(item?.type || '').trim() !== expectedType) {
return false
}
if (!expectedValue) {
return true
}
return String(item?.normalized_value || item?.value || '').trim() === expectedValue
})
}
function hasTravelExpenseType(ontology) {
return hasEntity(ontology, 'expense_type', 'travel')
}
function hasApplicationDocumentEntity(ontology) {
return hasEntity(ontology, 'document_type', 'expense_application')
|| hasEntity(ontology, 'workflow_stage', 'pre_approval')
}
export function buildWorkbenchIntentOntologyContext({ currentUser = {}, files = [] } = {}) {
return {
entry_source: 'workbench',
session_type: '',
attachment_count: Array.isArray(files) ? files.length : 0,
role_codes: Array.isArray(currentUser.roleCodes) ? currentUser.roleCodes : [],
name: currentUser.name || currentUser.username || '',
username: currentUser.username || '',
department: currentUser.department || currentUser.departmentName || '',
grade: currentUser.grade || ''
}
}
export function resolveWorkbenchSessionTypeFromOntology(ontology, rawText, fallbackSessionType = '') {
const text = normalizeText(rawText)
const fallback = String(fallbackSessionType || '').trim()
const scenario = String(ontology?.scenario || '').trim()
const intent = String(ontology?.intent || '').trim()
const reimbursementSignal = hasReimbursementIntentSignal(text)
const applicationSignal = hasExpenseApplicationIntentSignal(text)
if (!text) {
return fallback
}
if (hasApplicationDocumentEntity(ontology)) {
return ASSISTANT_SCOPE_SESSION_APPLICATION
}
if (
!reimbursementSignal
&& (
applicationSignal
|| (
scenario === 'expense'
&& intent === 'draft'
&& hasTravelExpenseType(ontology)
&& applicationSignal
)
|| (scenario === 'budget' && hasTravelExpenseType(ontology) && applicationSignal)
)
) {
return ASSISTANT_SCOPE_SESSION_APPLICATION
}
if (reimbursementSignal && scenario === 'expense' && intent === 'draft') {
return ASSISTANT_SCOPE_SESSION_EXPENSE
}
if (scenario === 'knowledge') {
return ASSISTANT_SCOPE_SESSION_KNOWLEDGE
}
if (scenario === 'budget') {
return ASSISTANT_SCOPE_SESSION_BUDGET
}
if (scenario === 'expense' && intent === 'draft') {
return ASSISTANT_SCOPE_SESSION_EXPENSE
}
return fallback || inferAssistantScopeTarget(rawText)
}
export function resolveWorkbenchSessionTypeFallback(rawText, options = {}) {
return inferAssistantScopeTarget(rawText, options)
}

View File

@@ -0,0 +1,74 @@
function normalizeDateValue(value) {
return String(value || '').trim()
}
const ISO_DATE_PATTERN = '20\\d{2}-\\d{1,2}-\\d{1,2}'
const DATE_RANGE_PATTERN = `${ISO_DATE_PATTERN}(?:\\s*\\u81f3\\s*${ISO_DATE_PATTERN})?`
const LABELED_DATE_PREFIX_RE = new RegExp(
`^(?:(?:\\u4e1a\\u52a1)?\\u53d1\\u751f\\u65f6\\u95f4|\\u65e5\\u671f|\\u65f6\\u95f4)\\s*[:\\uFF1A]\\s*${DATE_RANGE_PATTERN}[\\uFF0C,\\u3002\\s]*`,
'u'
)
const RAW_DATE_PREFIX_RE = new RegExp(`^${DATE_RANGE_PATTERN}[\\uFF0C,\\u3002\\s]*`, 'u')
export function getTodayDateValue(date = new Date()) {
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
return ''
}
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
export function canApplyWorkbenchDateSelection({
mode = 'single',
singleDate = '',
rangeStartDate = '',
rangeEndDate = ''
} = {}) {
if (mode === 'range') {
const startDate = normalizeDateValue(rangeStartDate)
const endDate = normalizeDateValue(rangeEndDate)
return Boolean(startDate && endDate && startDate <= endDate)
}
return Boolean(normalizeDateValue(singleDate))
}
export function buildWorkbenchDateLabel({
mode = 'single',
singleDate = '',
rangeStartDate = '',
rangeEndDate = ''
} = {}) {
if (!canApplyWorkbenchDateSelection({ mode, singleDate, rangeStartDate, rangeEndDate })) {
return ''
}
if (mode !== 'range') {
return normalizeDateValue(singleDate)
}
const startDate = normalizeDateValue(rangeStartDate)
const endDate = normalizeDateValue(rangeEndDate)
return startDate === endDate ? startDate : `${startDate} \u81f3 ${endDate}`
}
export function stripWorkbenchDateLabelFromDraft(rawDraft) {
return String(rawDraft || '')
.replace(LABELED_DATE_PREFIX_RE, '')
.replace(RAW_DATE_PREFIX_RE, '')
.trim()
}
export function mergeWorkbenchDateLabelIntoDraft(rawDraft, dateLabel) {
const label = String(dateLabel || '').trim()
if (!label) {
return String(rawDraft || '').trim()
}
const draft = stripWorkbenchDateLabelFromDraft(rawDraft)
return draft ? `${label}\uFF0C${draft}` : label
}

View File

@@ -68,9 +68,11 @@
:detail-alerts="resolvedDetailAlerts"
:detail-kpis="resolvedDetailKpis"
:custom-range="customRange"
:overview-dashboard="overviewDashboard"
@update:search="search = $event"
@update:active-range="activeRange = $event"
@update:custom-range="customRange = $event"
@update:overview-dashboard="overviewDashboard = $event"
@batch-approve="toast('已批量通过 23 条审批任务')"
@new-application="openExpenseApplicationCreate"
/>
@@ -101,6 +103,9 @@
<OverviewView
v-if="activeView === 'overview'"
:filtered-requests="filteredRequests"
:dashboard="overviewDashboard"
:active-range="activeRange"
:custom-range="customRange"
@approve="handleApprove"
@reject="handleReject"
/>
@@ -138,6 +143,8 @@
<ReceiptFolderView
v-else-if="activeView === 'receiptFolder'"
@open-assistant="openSmartEntry"
@detail-open-change="receiptFolderDetailOpen = $event"
@detail-topbar-change="detailTopBarPayload = $event"
/>
<BudgetCenterView
@@ -168,58 +175,43 @@
:initial-prompt="smartEntryContext.prompt"
:initial-files="smartEntryContext.files"
:initial-conversation="smartEntryContext.conversation"
:initial-session-type="smartEntryContext.sessionType"
:entry-source="smartEntryContext.source"
:request-context="smartEntryContext.request"
:invalidated-draft-claim-id="smartEntryInvalidatedDraftClaimId"
:reopen-token="smartEntryRevealToken"
@close="closeSmartEntry"
@draft-saved="handleDraftSaved"
@request-updated="handleRequestUpdated"
/>
</div>
</template>
<script setup>
import { computed, defineAsyncComponent, h, onBeforeUnmount, onMounted, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import SidebarRail from '../components/layout/SidebarRail.vue'
import TopBar from '../components/layout/TopBar.vue'
import FilterBar from '../components/layout/FilterBar.vue'
import FloatingLightBandWindow from '../components/shared/FloatingLightBandWindow.vue'
import TableLoadingState from '../components/shared/TableLoadingState.vue'
import AuditView from './AuditView.vue'
import BudgetCenterView from './BudgetCenterView.vue'
import DigitalEmployeesView from './DigitalEmployeesView.vue'
import DocumentsCenterView from './DocumentsCenterView.vue'
import EmployeeManagementView from './EmployeeManagementView.vue'
import OverviewView from './OverviewView.vue'
import PersonalWorkbenchView from './PersonalWorkbenchView.vue'
import PoliciesView from './PoliciesView.vue'
import ReceiptFolderView from './ReceiptFolderView.vue'
import SettingsView from './SettingsView.vue'
import TravelReimbursementCreateView from './TravelReimbursementCreateView.vue'
import TravelRequestDetailView from './TravelRequestDetailView.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 ReceiptFolderView = defineAsyncComponent(() => import('./ReceiptFolderView.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 documentSummary = ref(null)
@@ -227,9 +219,11 @@ const digitalEmployeeSummary = ref(null)
const detailTopBarPayload = ref(null)
const auditDetailOpen = ref(false)
const digitalEmployeeDetailOpen = ref(false)
const receiptFolderDetailOpen = ref(false)
const loginEntryAnimating = ref(false)
const sidebarCollapsed = ref(false)
const mobileSidebarOpen = ref(false)
const overviewDashboard = ref('finance')
let loginEntryTimer = null
function stopLoginEntryAnimation() {
@@ -310,11 +304,16 @@ const DETAIL_TOPBAR_FALLBACKS = {
digitalEmployees: {
title: '数字员工详情',
desc: '查看数字员工配置、执行计划、运行记录与源文件。'
},
receiptFolder: {
title: '票据详情',
desc: '查看票据源文件、OCR 识别信息与关联状态。'
}
}
const customDetailTopBarActive = computed(() => (
(activeView.value === 'audit' && auditDetailOpen.value) ||
(activeView.value === 'digitalEmployees' && digitalEmployeeDetailOpen.value)
(activeView.value === 'digitalEmployees' && digitalEmployeeDetailOpen.value) ||
(activeView.value === 'receiptFolder' && receiptFolderDetailOpen.value)
))
const resolvedTopBarView = computed(() => (
customDetailTopBarActive.value

View File

@@ -384,6 +384,26 @@
<i class="mdi mdi-flask-outline"></i>
<span>测试规则</span>
</button>
<button
v-if="canEditRiskRuleDraft"
class="minor-action"
type="button"
:disabled="detailBusy"
@click="openRiskRuleEditDialog('draft')"
>
<i class="mdi mdi-square-edit-outline"></i>
<span>编辑规则</span>
</button>
<button
v-if="canCreateRiskRuleRevision"
class="minor-action"
type="button"
:disabled="detailBusy"
@click="openRiskRuleEditDialog('revision')"
>
<i class="mdi mdi-source-branch-plus"></i>
<span>创建修订版本</span>
</button>
<button
v-if="canDeleteRiskRule"
class="minor-action danger-action"
@@ -511,6 +531,10 @@
:risk-rule-delete-open="riskRuleDeleteOpen"
:risk-rule-return-open="riskRuleReturnOpen"
:risk-rule-publish-open="riskRulePublishOpen"
:risk-rule-edit-open="riskRuleEditOpen"
:risk-rule-edit-mode="riskRuleEditMode"
:risk-rule-edit-form="riskRuleEditForm"
:risk-rule-edit-busy="actionState === 'save-risk-rule-edit'"
:risk-rule-test-passed="riskRuleTestPassed"
:review-submit-open="reviewSubmitOpen"
:review-submit-reviewer-loading="reviewSubmitReviewerLoading"
@@ -521,6 +545,8 @@
@submit-risk-rule-create="submitRiskRuleCreate"
@close-risk-rule-test="closeRiskRuleTestDialog"
@report-saved="handleRiskRuleReportSaved"
@close-risk-rule-edit="closeRiskRuleEditDialog"
@submit-risk-rule-edit="submitRiskRuleEdit"
@close-delete-risk-rule="closeDeleteRiskRuleDialog"
@delete-selected-risk-rule="deleteSelectedRiskRule"
@close-return-risk-rule="closeReturnRiskRuleDialog"

View File

@@ -114,10 +114,7 @@
class="agent-answer-content agent-answer-markdown"
v-html="renderMarkdown(message.text)"
></div>
<div v-if="message.role !== 'user' && message.meta?.length" class="agent-meta-row">
<span v-for="item in message.meta" :key="item" class="agent-meta-chip">{{ item }}</span>
</div>
<div v-if="message.role !== 'user' && message.riskFlags?.length" class="agent-detail-block">
<div v-if="message.role !== 'user' && message.riskFlags?.length" class="agent-detail-block">
<strong>风险标签</strong>
<div class="agent-detail-chip-row">
<span v-for="item in message.riskFlags" :key="item" class="agent-risk-chip">{{ item }}</span>

View File

@@ -67,7 +67,7 @@
:class="{ active: activeSection === 'skills' }"
@click="activeSection = 'skills'"
>
员工能
员工
</button>
<button
type="button"
@@ -107,6 +107,7 @@
<DigitalEmployeeWorkRecords
v-else
class="digital-work-records-section"
:focus-run-id="workRecordFocusRunId"
@summary-change="emit('summary-change', $event)"
@detail-open-change="workRecordDetailOpen = $event"
@detail-topbar-change="workRecordDetailTopBar = $event"
@@ -129,7 +130,7 @@
</template>
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import AuditDigitalEmployeeDetail from '../components/audit/AuditDigitalEmployeeDetail.vue'
import DigitalEmployeeListPanel from '../components/audit/DigitalEmployeeListPanel.vue'
@@ -184,6 +185,7 @@ const selectedEmployeeId = ref('')
const activeSection = ref('skills')
const workRecordDetailOpen = ref(false)
const workRecordDetailTopBar = ref(null)
const workRecordFocusRunId = ref('')
const isDetailOpen = computed(() => Boolean(selectedEmployee.value) || (activeSection.value === 'workRecords' && workRecordDetailOpen.value))
const digitalEmployeeDetailTopBar = computed(() => {
const employee = selectedEmployee.value
@@ -511,7 +513,17 @@ async function runDigitalEmployeeNow(employee) {
entry: 'digital_employees'
}
})
toast(`已发起立即运行Run ID${result?.run_id || '-'}`)
const runId = String(result?.run_id || '').trim()
if (runId) {
closeEmployeeDetail()
activeSection.value = 'workRecords'
workRecordFocusRunId.value = ''
await nextTick()
workRecordFocusRunId.value = runId
toast(`已发起立即运行已打开本次工作记录。Run ID${runId}`)
} else {
toast('已发起立即运行,请在工作记录中查看结果。')
}
} catch (error) {
toast(error?.message || '立即运行失败,请稍后重试。')
} finally {

View File

@@ -1,8 +1,8 @@
<template>
<section class="dashboard">
<section class="dashboard" :class="`dashboard-${activeDashboard}`">
<div class="kpi-grid">
<article
v-for="metric in kpiMetrics"
v-for="metric in activeKpiMetrics"
:key="metric.label"
class="kpi-card panel"
:style="{ '--accent': metric.accent, '--delay': `${metric.delay}ms` }"
@@ -22,122 +22,297 @@
</article>
</div>
<div class="content-grid top-grid">
<article class="panel dashboard-card trend-panel">
<div class="card-head">
<h3>报销申请与审批趋势 <i class="mdi mdi-information-outline"></i></h3>
<EnterpriseSelect
v-model="activeTrendRange"
class="card-select"
:options="trendRanges"
aria-label="趋势时间范围"
size="small"
<template v-if="activeDashboard === 'finance'">
<div class="content-grid top-grid">
<article class="panel dashboard-card trend-panel">
<div class="card-head">
<h3>报销申请与审批趋势 <i class="mdi mdi-information-outline"></i></h3>
<EnterpriseSelect
v-model="activeTrendRange"
class="card-select"
:options="trendRanges"
aria-label="趋势时间范围"
size="small"
/>
</div>
<TrendChart
:labels="activeTrend.labels"
:applications="activeTrend.applications"
:approved="activeTrend.approved"
:avg-hours="activeTrend.avgHours"
/>
</div>
</article>
<TrendChart
:labels="activeTrend.labels"
:applications="activeTrend.applications"
:approved="activeTrend.approved"
:avg-hours="activeTrend.avgHours"
/>
</article>
<article class="panel dashboard-card donut-panel">
<div class="card-head">
<h3>费用结构 <i class="mdi mdi-information-outline"></i></h3>
</div>
<DonutChart :items="spendLegend" :center-value="spendCenterValue" center-label="费用总额" />
<p class="panel-note">* 百分比按当前时间范围内的费用金额计算</p>
</article>
<article class="panel dashboard-card donut-panel">
<div class="card-head">
<h3>费用结构 <i class="mdi mdi-information-outline"></i></h3>
</div>
<DonutChart :items="spendLegend" center-value="¥361.6K" center-label="待处理金额" />
<p class="panel-note">* 百分比为占待处理金额比例</p>
</article>
<article class="panel dashboard-card donut-panel">
<div class="card-head">
<h3>风险异常分布 <i class="mdi mdi-information-outline"></i></h3>
</div>
<DonutChart :items="riskLegend" :center-value="`${riskTotal}`" center-label="异常预警单" />
<p class="panel-note">* 30 天数据</p>
</article>
</div>
<article class="panel dashboard-card donut-panel">
<div class="card-head">
<h3>风险异常分布 <i class="mdi mdi-information-outline"></i></h3>
</div>
<DonutChart :items="riskLegend" :center-value="`${riskTotal}`" center-label="异常预警单" />
<p class="panel-note">* 30 天数据</p>
</article>
</div>
<div class="content-grid bottom-grid">
<article class="panel dashboard-card rank-panel">
<div class="card-head">
<h3>部门报销排行待处理金额<i class="mdi mdi-information-outline"></i></h3>
<EnterpriseSelect
v-model="activeDepartmentRange"
class="card-select"
:options="departmentRangeOptions"
aria-label="部门排行时间范围"
size="small"
/>
</div>
<div class="content-grid bottom-grid">
<article class="panel dashboard-card rank-panel">
<div class="card-head">
<h3>部门报销排行待处理金额<i class="mdi mdi-information-outline"></i></h3>
<EnterpriseSelect
v-model="activeDepartmentRange"
class="card-select"
:options="departmentRangeOptions"
aria-label="部门排行时间范围"
size="small"
/>
</div>
<BarChart :items="rankedDepartments" />
</article>
<BarChart :items="rankedDepartments" />
</article>
<article class="panel dashboard-card bottleneck-panel">
<div class="card-head">
<h3>审批瓶颈平均处理时长 <i class="mdi mdi-information-outline"></i></h3>
</div>
<article class="panel dashboard-card bottleneck-panel">
<div class="card-head">
<h3>审批瓶颈平均处理时长 <i class="mdi mdi-information-outline"></i></h3>
</div>
<div class="bottleneck-list">
<div
v-for="(item, index) in bottlenecks"
:key="item.name"
class="bottleneck-row"
:style="{ '--delay': `${index * 70}ms` }"
>
<div class="reviewer">
<div class="reviewer-avatar">{{ item.avatar }}</div>
<div>
<strong>{{ item.name }}</strong>
<span>{{ item.role }}</span>
<div class="bottleneck-list">
<div
v-for="(item, index) in bottlenecks"
:key="item.name"
class="bottleneck-row"
:style="{ '--delay': `${index * 70}ms` }"
>
<div class="reviewer">
<div class="reviewer-avatar">{{ item.avatar }}</div>
<div>
<strong>{{ item.name }}</strong>
<span>{{ item.role }}</span>
</div>
</div>
<div class="reviewer-stats">
<strong>{{ item.duration }}</strong>
<span class="status-tag" :class="item.tone">{{ item.status }}</span>
</div>
</div>
<div class="reviewer-stats">
<strong>{{ item.duration }}</strong>
<span class="status-tag" :class="item.tone">{{ item.status }}</span>
</div>
<button type="button" class="text-link">查看全部 <i class="mdi mdi-chevron-right"></i></button>
</article>
<article class="panel dashboard-card budget-panel">
<div class="card-head">
<h3>预算执行率本月<i class="mdi mdi-information-outline"></i></h3>
</div>
<GaugeChart
:ratio="budgetSummary.ratio"
:total="budgetSummary.total"
:used="budgetSummary.used"
:left="budgetSummary.left"
/>
<button type="button" class="text-link">查看详情 <i class="mdi mdi-chevron-right"></i></button>
</article>
</div>
</template>
<RiskObservationDashboard
v-else-if="activeDashboard === 'risk'"
:dashboard="riskDashboard"
:loading="riskDashboardLoading"
:error="riskDashboardError"
:level-legend="riskLevelLegend"
:source-legend="riskSourceLegend"
:signal-ranking="riskSignalRanking"
:daily-rows="riskDailyTrendRows"
:window-options="riskWindowOptions"
:active-window-days="activeRiskWindowDays"
@update:window-days="setRiskWindowDays"
/>
<template v-else>
<div class="system-observability-grid">
<article class="panel dashboard-card system-agent-ratio-panel">
<div class="card-head">
<h3>智能体调用占比 <i class="mdi mdi-information-outline"></i></h3>
</div>
<p class="card-subtitle">按天查看几个核心智能体的调用构成比例变化比单纯总量更容易定位偏移</p>
<SystemAgentRatioBar
:labels="systemAgentDailyRatio.labels"
:agents="systemAgentDailyRatio.agents"
:series="systemAgentDailyRatio.series"
/>
</article>
<article class="panel dashboard-card system-token-pie-panel">
<div class="card-head">
<h3>用户 Token 消耗占比 <i class="mdi mdi-information-outline"></i></h3>
</div>
<p class="card-subtitle">左侧看每日 Token 消耗波动右侧看高消耗用户便于排查重复问答或异常长上下文</p>
<div class="system-token-panel-grid">
<SystemTokenDailyWaveChart
:labels="systemTokenDailyWave.labels"
:input-tokens="systemTokenDailyWave.inputTokens"
:output-tokens="systemTokenDailyWave.outputTokens"
:total-tokens="systemTokenDailyWave.totalTokens"
/>
<SystemUserTokenPie :items="systemUserTokenUsage" />
</div>
</article>
<article class="panel dashboard-card system-accuracy-panel">
<div class="card-head">
<h3>正确 / 错误对比 <i class="mdi mdi-information-outline"></i></h3>
</div>
<p class="card-subtitle">按智能体对比正确与错误次数错误柱越靠前越需要优先追踪日志</p>
<SystemAccuracyCompareBar
:categories="systemAccuracyComparison.categories"
:correct="systemAccuracyComparison.correct"
:wrong="systemAccuracyComparison.wrong"
/>
</article>
<article class="panel dashboard-card system-tool-detail-panel">
<div class="card-head">
<h3>工具调用明细 <i class="mdi mdi-information-outline"></i></h3>
</div>
<div class="system-tool-table">
<div
v-for="item in systemToolDetailItems"
:key="item.name"
class="system-tool-row"
>
<div class="system-tool-row-head">
<strong>{{ item.name }}</strong>
<span>{{ item.callLabel }}</span>
</div>
<div class="system-tool-meter" aria-hidden="true">
<i :style="{ width: item.width, background: item.color }"></i>
</div>
<div class="system-tool-row-meta">
<span>成功率 {{ item.successRate }}%</span>
<span>平均 {{ item.avgLatency }}</span>
<span>{{ item.tokenLabel }}</span>
</div>
</div>
</div>
</div>
</article>
<button type="button" class="text-link">查看全部 <i class="mdi mdi-chevron-right"></i></button>
</article>
<aside class="system-side-stack">
<article class="panel dashboard-card system-side-card system-login-wave-panel">
<div class="card-head">
<h3>用户在线波动 <i class="mdi mdi-information-outline"></i></h3>
</div>
<p class="card-subtitle">登录人数与互动次数的时段波动</p>
<article class="panel dashboard-card budget-panel">
<div class="card-head">
<h3>预算执行率本月<i class="mdi mdi-information-outline"></i></h3>
</div>
<SystemLoginWaveChart
compact
:labels="systemLoginWave.labels"
:login-users="systemLoginWave.loginUsers"
:interactions="systemLoginWave.interactions"
/>
</article>
<GaugeChart
:ratio="budgetSummary.ratio"
:total="budgetSummary.total"
:used="budgetSummary.used"
:left="budgetSummary.left"
/>
<article class="panel dashboard-card system-side-card system-duration-panel">
<div class="card-head">
<h3>用户使用时长 <i class="mdi mdi-information-outline"></i></h3>
</div>
<button type="button" class="text-link">查看详情 <i class="mdi mdi-chevron-right"></i></button>
</article>
</div>
<div class="duration-summary">
<div>
<strong>{{ systemUsageDurationSummary.average }}</strong>
<span>平均使用时长</span>
</div>
<em>{{ systemUsageDurationSummary.trend }}</em>
</div>
<div class="duration-meta">
<span>中位数 {{ systemUsageDurationSummary.median }}</span>
<span>峰值 {{ systemUsageDurationSummary.peak }}</span>
</div>
<div class="duration-bars">
<div
v-for="item in systemUsageDurationRows"
:key="item.label"
class="duration-bar-row"
>
<span>{{ item.label }}</span>
<i><b :style="{ width: item.width, background: item.color }"></b></i>
<strong>{{ item.value }} </strong>
</div>
</div>
</article>
<article class="panel dashboard-card feedback-panel system-feedback-panel">
<div class="card-head">
<h3>用户反馈概览 <i class="mdi mdi-information-outline"></i></h3>
</div>
<div class="feedback-list">
<div
v-for="item in systemFeedbackSummary"
:key="item.label"
class="feedback-row"
:class="item.tone"
>
<span class="feedback-icon"><i :class="item.icon"></i></span>
<div>
<strong>{{ item.value }}</strong>
<span>{{ item.label }}</span>
</div>
</div>
</div>
<p class="panel-note">* 反馈用于衡量智能体回答和工具执行体验</p>
</article>
</aside>
</div>
</template>
</section>
</template>
<script setup>
import { computed } from 'vue'
import TrendChart from '../components/charts/TrendChart.vue'
import DonutChart from '../components/charts/DonutChart.vue'
import BarChart from '../components/charts/BarChart.vue'
import GaugeChart from '../components/charts/GaugeChart.vue'
import SystemAccuracyCompareBar from '../components/charts/SystemAccuracyCompareBar.vue'
import SystemAgentRatioBar from '../components/charts/SystemAgentRatioBar.vue'
import SystemLoginWaveChart from '../components/charts/SystemLoginWaveChart.vue'
import SystemTokenDailyWaveChart from '../components/charts/SystemTokenDailyWaveChart.vue'
import SystemUserTokenPie from '../components/charts/SystemUserTokenPie.vue'
import RiskObservationDashboard from '../components/dashboard/RiskObservationDashboard.vue'
import EnterpriseSelect from '../components/shared/EnterpriseSelect.vue'
import { useOverviewView } from '../composables/useOverviewView.js'
defineProps({
filteredRequests: { type: Array, required: true }
const props = defineProps({
filteredRequests: { type: Array, required: true },
dashboard: { type: String, default: 'finance' },
activeRange: { type: String, default: '近10日' },
customRange: {
type: Object,
default: () => ({ start: '', end: '' })
}
})
const {
activeDepartmentRange,
activeRiskWindowDays,
activeTrend,
activeTrendRange,
bottlenecks,
@@ -145,11 +320,45 @@ const {
departmentRangeOptions,
kpiMetrics,
rankedDepartments,
riskDashboard,
riskDashboardError,
riskDashboardLoading,
riskDailyTrendRows,
riskLegend,
riskKpiMetrics,
riskLevelLegend,
riskSignalRanking,
riskSourceLegend,
riskTotal,
riskWindowOptions,
setRiskWindowDays,
spendCenterValue,
spendLegend,
systemAccuracyComparison,
systemAgentDailyRatio,
systemFeedbackSummary,
systemKpiMetrics,
systemLoginWave,
systemTokenDailyWave,
systemToolDetailItems,
systemUsageDurationRows,
systemUsageDurationSummary,
systemUserTokenUsage,
trendRanges
} = useOverviewView()
} = useOverviewView(props)
const activeDashboard = computed(() => {
if (props.dashboard === 'system') return 'system'
if (props.dashboard === 'risk') return 'risk'
return 'finance'
})
const activeKpiMetrics = computed(() => (
activeDashboard.value === 'system'
? systemKpiMetrics.value
: activeDashboard.value === 'risk'
? riskKpiMetrics.value
: kpiMetrics.value
))
</script>
<style scoped src="../assets/styles/views/overview-view.css"></style>

View File

@@ -75,7 +75,7 @@
<col class="col-money">
<col class="col-date">
<col class="col-score">
<col class="col-status">
<col v-if="showStatusColumn" class="col-status">
<col class="col-updated">
</colgroup>
<thead>
@@ -86,7 +86,7 @@
<th>金额</th>
<th>票据日期</th>
<th>置信度</th>
<th>关联状态</th>
<th v-if="showStatusColumn">关联状态</th>
<th>上传时间</th>
</tr>
</thead>
@@ -101,7 +101,7 @@
<td>{{ row.amount || '待补充' }}</td>
<td>{{ row.document_date || '待补充' }}</td>
<td>{{ formatScore(row.avg_score) }}</td>
<td>
<td v-if="showStatusColumn">
<span class="status-tag" :class="row.status === 'linked' ? 'completed' : 'warning'">
{{ row.status_label }}
</span>
@@ -136,109 +136,95 @@
</footer>
</article>
<article v-else class="receipt-folder-detail panel">
<header class="receipt-detail-head">
<button class="back-btn" type="button" @click="backToList">
<i class="mdi mdi-arrow-left"></i>
<span>返回票据夹</span>
</button>
<div>
<span class="assistant-badge">票据详情</span>
<h2>{{ detailForm.file_name }}</h2>
<p>{{ selectedReceipt?.summary || '核对并修正票据基础信息,后续关联报销单时会带入当前票据。' }}</p>
</div>
</header>
<div v-if="detailLoading" class="detail-loading">
<TableLoadingState title="票据详情加载中" message="正在读取票据源文件与 OCR 元数据" icon="mdi mdi-receipt-text-outline" floating />
</div>
<div v-else class="receipt-detail-layout">
<section class="receipt-basic-panel">
<header>
<strong>基本票据信息</strong>
<button class="apply-btn" type="button" :disabled="savingDetail" @click="saveDetail">
<EnterpriseDetailPage
v-else
variant="receipt-folder-detail"
back-label="返回票据夹"
:loading="detailLoading"
loading-title="票据详情加载中"
loading-message="正在读取票据源文件与 OCR 元数据"
loading-icon="mdi mdi-receipt-text-outline"
@back="backToList"
>
<template #main>
<EnterpriseDetailCard class="receipt-basic-panel" title="票据关键字段">
<template #actions>
<button class="major-action" type="button" :disabled="savingDetail" @click="saveDetail">
<i class="mdi mdi-content-save-outline"></i>
<span>{{ savingDetail ? '保存中' : '保存修改' }}</span>
</button>
</header>
</template>
<div class="receipt-form-grid">
<label>
<span>票据类型</span>
<input v-model="detailForm.document_type_label" type="text" />
</label>
<label>
<span>费用场景</span>
<input v-model="detailForm.scene_label" type="text" />
</label>
<label>
<span>金额</span>
<input v-model="detailForm.amount" type="text" placeholder="待补充" />
</label>
<label>
<span>票据日期</span>
<input v-model="detailForm.document_date" type="text" placeholder="YYYY-MM-DD" />
</label>
<label>
<span>商户</span>
<input v-model="detailForm.merchant_name" type="text" placeholder="待补充" />
</label>
<label>
<span>OCR 置信度</span>
<input :value="formatScore(selectedReceipt?.avg_score)" type="text" disabled />
</label>
<label class="field-wide">
<span>摘要</span>
<textarea v-model="detailForm.summary" rows="3" />
<div class="receipt-key-grid">
<label v-for="field in keyReceiptFields" :key="field.id" class="receipt-key-field">
<span>{{ field.label }}</span>
<input
:value="field.value"
type="text"
:placeholder="field.placeholder"
@input="updateReceiptField(field, $event.target.value)"
/>
</label>
</div>
<div class="receipt-field-list">
<div class="receipt-field-list-head">
<strong>识别字段</strong>
<button class="ghost-btn" type="button" @click="addField">
<i class="mdi mdi-plus"></i>
<span>新增字段</span>
</button>
</div>
<div v-for="(field, index) in detailForm.fields" :key="`${field.key}-${index}`" class="receipt-field-row">
<input v-model="field.label" type="text" placeholder="字段名" />
<input v-model="field.value" type="text" placeholder="字段值" />
<button type="button" aria-label="删除字段" @click="removeField(index)">
<i class="mdi mdi-close"></i>
</button>
</div>
</div>
</section>
<div class="receipt-other-info">
<ElCollapse v-model="expandedFieldPanels" class="receipt-other-collapse">
<ElCollapseItem name="other">
<template #title>
<div class="receipt-collapse-title">
<strong>其他信息</strong>
<small>{{ editableOtherFields.length }} </small>
</div>
</template>
<section class="receipt-preview-panel">
<header>
<strong>原始文件</strong>
<button v-if="selectedReceipt?.source_url" class="preview-source-btn" type="button" @click="openSourceFile">
打开源文件
</button>
</header>
<div v-if="editableOtherFields.length" class="receipt-other-scroll">
<div
v-for="(field, index) in editableOtherFields"
:key="`${field.key || field.label}-${index}`"
class="receipt-edit-field-row"
>
<label>
<span>字段名</span>
<input v-model="field.label" type="text" placeholder="字段名" />
</label>
<label>
<span>字段值</span>
<input v-model="field.value" type="text" placeholder="字段值" @input="syncEditableFieldsToTopLevel" />
</label>
</div>
</div>
<div v-else class="receipt-field-empty">
<i class="mdi mdi-information-outline"></i>
<span>暂无其他可编辑信息</span>
</div>
</ElCollapseItem>
</ElCollapse>
</div>
</EnterpriseDetailCard>
</template>
<template #side>
<EnterpriseDetailCard class="receipt-preview-panel" title="原始文件">
<div class="receipt-preview-box">
<img v-if="previewKind === 'image' && previewObjectUrl" :src="previewObjectUrl" alt="票据预览" />
<iframe v-else-if="previewKind === 'pdf' && previewObjectUrl" :src="previewObjectUrl" title="票据 PDF 预览"></iframe>
<div v-else class="preview-empty">
<i class="mdi mdi-file-eye-outline"></i>
<strong>当前文件暂不支持内嵌预览</strong>
<p>可以点击右上角打开源文件查看</p>
<p>请确认源文件是否支持预览或重新上传清晰图片/PDF</p>
</div>
</div>
</section>
</div>
</EnterpriseDetailCard>
</template>
<footer class="receipt-detail-foot">
<button class="ghost-btn" type="button" @click="backToList">返回列表</button>
<button class="danger-btn" type="button" :disabled="deleting" @click="deleteCurrentReceipt">
<template #actions>
<button class="minor-action danger-action" type="button" :disabled="deleting" @click="deleteCurrentReceipt">
<i class="mdi mdi-delete-outline"></i>
<span>{{ deleting ? '删除中' : '删除票据' }}</span>
</button>
</footer>
</article>
</template>
</EnterpriseDetailPage>
<ElDialog
v-model="associateDialogOpen"
@@ -301,9 +287,12 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { ElCheckbox, ElCheckboxGroup } from 'element-plus/es/components/checkbox/index.mjs'
import { ElCollapse, ElCollapseItem } from 'element-plus/es/components/collapse/index.mjs'
import { ElDialog } from 'element-plus/es/components/dialog/index.mjs'
import EnterpriseSelect from '../components/shared/EnterpriseSelect.vue'
import EnterpriseDetailCard from '../components/shared/EnterpriseDetailCard.vue'
import EnterpriseDetailPage from '../components/shared/EnterpriseDetailPage.vue'
import TableEmptyState from '../components/shared/TableEmptyState.vue'
import TableLoadingState from '../components/shared/TableLoadingState.vue'
import { fetchExpenseClaims } from '../services/reimbursements.js'
@@ -315,12 +304,13 @@ import {
fetchReceiptFolderItems,
updateReceiptFolderItem
} from '../services/receiptFolder.js'
import { createReceiptDetailFieldModel } from './scripts/receiptFolderDetailFields.js'
const NEW_CLAIM_VALUE = '__new_claim__'
const pageSizeOptions = [10, 20, 50].map((size) => ({ label: `${size} 条/页`, value: size }))
const emit = defineEmits(['open-assistant'])
const emit = defineEmits(['open-assistant', 'detail-open-change', 'detail-topbar-change'])
const activeStatus = ref('unlinked')
const activeStatus = ref('all')
const keyword = ref('')
const receipts = ref([])
const loading = ref(false)
@@ -338,6 +328,7 @@ const selectedReceiptIds = ref([])
const targetDraftId = ref(NEW_CLAIM_VALUE)
const draftClaims = ref([])
const associateBusy = ref(false)
const expandedFieldPanels = ref([])
const detailForm = reactive({
file_name: '',
@@ -356,12 +347,16 @@ const detailMode = computed(() => Boolean(selectedReceipt.value))
const unlinkedReceipts = computed(() => receipts.value.filter((item) => item.status !== 'linked'))
const linkedReceipts = computed(() => receipts.value.filter((item) => item.status === 'linked'))
const receiptTabs = computed(() => [
{ value: 'all', label: '全部', count: receipts.value.length },
{ value: 'unlinked', label: '未关联票据', count: unlinkedReceipts.value.length },
{ value: 'linked', label: '已关联票据', count: linkedReceipts.value.length }
])
const activeRows = computed(() => (
activeStatus.value === 'linked' ? linkedReceipts.value : unlinkedReceipts.value
))
const activeRows = computed(() => {
if (activeStatus.value === 'linked') return linkedReceipts.value
if (activeStatus.value === 'unlinked') return unlinkedReceipts.value
return receipts.value
})
const showStatusColumn = computed(() => activeStatus.value !== 'linked')
const filteredRows = computed(() => {
const normalized = keyword.value.trim().toLowerCase()
if (!normalized) return activeRows.value
@@ -382,15 +377,67 @@ const visibleRows = computed(() => {
})
const showEmpty = computed(() => !loading.value && !error.value && visibleRows.value.length === 0)
const showTable = computed(() => !loading.value && !error.value && visibleRows.value.length > 0)
const emptyTitle = computed(() => keyword.value.trim() ? '没有符合条件的票据' : `${activeStatus.value === 'linked' ? '已关联票据' : '未关联票据'}为空`)
const emptyDesc = computed(() => activeStatus.value === 'linked'
? '关联到报销单的票据会显示在这里,方便后续回溯。'
: '上传并完成 OCR 的票据会先进入这里,稍后可以再关联到报销草稿。'
)
const emptyTips = computed(() => activeStatus.value === 'linked'
? ['可从报销明细或票据夹关联流程形成已关联票据', '点击票据可查看原始文件与识别字段']
: ['票据不会因为未立即建单而丢失', '可多选票据后一次性带入报销对话']
)
const activeStatusLabel = computed(() => {
if (activeStatus.value === 'linked') return '已关联票据'
if (activeStatus.value === 'unlinked') return '关联票据'
return '全部票据'
})
const emptyTitle = computed(() => keyword.value.trim() ? '没有符合条件的票据' : `${activeStatusLabel.value}为空`)
const emptyDesc = computed(() => {
if (activeStatus.value === 'linked') return '已关联到报销单的票据会显示在这里,方便后续回溯。'
if (activeStatus.value === 'unlinked') return '上传并完成 OCR 的票据会先进入这里,稍后可以再关联到报销草稿。'
return '上传并完成 OCR 的票据会统一进入票据夹,可按关联状态继续筛选。'
})
const emptyTips = computed(() => {
if (activeStatus.value === 'linked') return ['可从报销明细或票据夹关联流程形成已关联票据', '点击票据可查看原始文件与识别字段']
if (activeStatus.value === 'unlinked') return ['票据不会因为未立即建单而丢失', '可多选票据后一次性带入报销对话']
return ['全部视图同时展示未关联和已关联票据', '可切换标签快速定位待处理票据']
})
const receiptDetailSubtitle = computed(() => {
const receipt = selectedReceipt.value || {}
const documentType = String(detailForm.document_type_label || receipt.document_type_label || '').trim()
const scene = String(detailForm.scene_label || receipt.scene_label || '').trim()
const merchant = String(detailForm.merchant_name || receipt.merchant_name || '').trim()
const sceneLabel = scene && /^[(].*[)]$/.test(scene) ? scene : (scene ? `${scene}` : '')
const parts = [documentType, sceneLabel, merchant].filter(Boolean)
return parts.length
? parts.join('')
: (selectedReceipt.value?.summary || '核对并修正票据基础信息,后续关联报销单时会带入当前票据。')
})
const receiptDetailTitle = computed(() => (
String(selectedReceipt.value?.file_name || detailForm.file_name || '').trim() || '票据详情'
))
const isTrainTicket = computed(() => {
const type = String(detailForm.document_type || selectedReceipt.value?.document_type || '').trim().toLowerCase()
const label = [
detailForm.document_type_label,
selectedReceipt.value?.document_type_label,
detailForm.scene_label,
selectedReceipt.value?.scene_label
].filter(Boolean).join('')
return type === 'train_ticket' || /火车|高铁|动车|铁路|电子客票/.test(label)
})
const {
buildDetailPayload,
editableOtherFields,
ensureEditableReceiptFields,
keyReceiptFields,
syncEditableFieldsToTopLevel,
updateReceiptField
} = createReceiptDetailFieldModel({ detailForm, isTrainTicket })
const receiptDetailTopBarPayload = computed(() => (
detailMode.value
? {
view: {
eyebrow: '票据详情',
title: receiptDetailTitle.value,
desc: receiptDetailSubtitle.value
},
alerts: [],
kpis: []
}
: null
))
const previewKind = computed(() => selectedReceipt.value?.preview_kind || '')
const canProceedAssociate = computed(() => (
associateStep.value === 1
@@ -406,6 +453,14 @@ watch([activeStatus, keyword, pageSize], () => {
currentPage.value = 1
})
watch(detailMode, (value) => {
emit('detail-open-change', value)
}, { immediate: true })
watch(receiptDetailTopBarPayload, (payload) => {
emit('detail-topbar-change', payload)
}, { immediate: true })
onMounted(() => {
void reloadReceipts()
})
@@ -460,6 +515,9 @@ function fillDetailForm(detail) {
detailForm.fields = Array.isArray(detail.fields)
? detail.fields.map((field) => ({ ...field }))
: []
expandedFieldPanels.value = []
ensureEditableReceiptFields()
syncEditableFieldsToTopLevel()
}
async function loadPreview(detail) {
@@ -479,42 +537,16 @@ function revokePreviewUrl() {
}
}
async function openSourceFile() {
if (!selectedReceipt.value?.source_url) return
const blob = await fetchReceiptFolderAsset(selectedReceipt.value.source_url)
const objectUrl = URL.createObjectURL(blob)
window.open(objectUrl, '_blank', 'noopener,noreferrer')
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 30000)
}
function backToList() {
selectedReceipt.value = null
revokePreviewUrl()
}
function addField() {
detailForm.fields.push({ key: '', label: '', value: '' })
}
function removeField(index) {
detailForm.fields.splice(index, 1)
}
async function saveDetail() {
if (!selectedReceipt.value?.id || savingDetail.value) return
savingDetail.value = true
try {
const updated = await updateReceiptFolderItem(selectedReceipt.value.id, {
document_type: detailForm.document_type,
document_type_label: detailForm.document_type_label,
scene_code: detailForm.scene_code,
scene_label: detailForm.scene_label,
summary: detailForm.summary,
amount: detailForm.amount,
document_date: detailForm.document_date,
merchant_name: detailForm.merchant_name,
fields: detailForm.fields
})
const updated = await updateReceiptFolderItem(selectedReceipt.value.id, buildDetailPayload())
selectedReceipt.value = updated
fillDetailForm(updated)
await reloadReceipts()

View File

@@ -201,7 +201,7 @@
class="tool-btn composer-side-btn"
:class="{ active: composerDatePickerOpen }"
:disabled="submitting || reviewActionBusy || sessionSwitchBusy"
aria-label="选择业务发生时间"
aria-label="选择日期"
:aria-expanded="composerDatePickerOpen"
@click.stop="toggleComposerDatePicker"
>
@@ -211,7 +211,7 @@
v-if="composerDatePickerOpen"
class="composer-date-popover"
role="dialog"
aria-label="业务发生时间"
aria-label="日期选择"
@click.stop
>
<div class="composer-date-mode-tabs">
@@ -235,13 +235,13 @@
<div v-if="composerDateMode === 'single'" class="composer-date-fields">
<label class="composer-date-field">
<span>日期</span>
<input v-model="composerSingleDate" type="date" @change="handleComposerDateInputChange" />
<input v-model="composerSingleDate" type="date" @change="handleComposerDateInputChange('single')" />
</label>
</div>
<div v-else class="composer-date-fields composer-date-fields-range">
<label class="composer-date-field">
<span>开始</span>
<input v-model="composerRangeStartDate" type="date" @change="handleComposerDateInputChange" />
<input v-model="composerRangeStartDate" type="date" @change="handleComposerDateInputChange('range-start')" />
</label>
<span class="composer-date-range-sep"></span>
<label class="composer-date-field">
@@ -250,26 +250,13 @@
v-model="composerRangeEndDate"
type="date"
:min="composerRangeStartDate"
@change="handleComposerDateInputChange"
@change="handleComposerDateInputChange('range-end')"
/>
</label>
</div>
<p v-if="composerDateMode === 'range' && !composerCanApplyDateSelection" class="composer-date-hint">
请确认结束日期不早于开始日期
</p>
<div class="composer-date-popover-actions">
<button type="button" class="composer-date-cancel-btn" @click="closeComposerDatePicker">
取消
</button>
<button
type="button"
class="composer-date-apply-btn"
:disabled="!composerCanApplyDateSelection"
@click="applyComposerDateSelection"
>
插入标签
</button>
</div>
</div>
</div>
<div v-if="canShowTravelCalculator" class="travel-calculator-anchor">
@@ -353,7 +340,7 @@
type="button"
class="composer-biz-time-tag-remove"
:disabled="submitting || reviewActionBusy || sessionSwitchBusy"
aria-label="移除业务发生时间"
aria-label="移除日期"
@click="removeComposerBusinessTimeTag(tag.id)"
>
<i class="mdi mdi-close"></i>

View File

@@ -88,42 +88,24 @@
<article v-if="!isApplicationDocument" class="detail-card panel">
<div class="detail-card-head">
<div>
<h3>附加说明</h3>
<p>用于说明本次出差或办事目的例如去哪里拜访谁处理什么事项</p>
<h3>关联单据信息</h3>
<p>展示本次报销关联的前置申请便于核对申请内容天数事由和预计金额</p>
</div>
</div>
<div v-if="canEditDetailNote" class="detail-note-editor">
<textarea
v-model="detailNoteEditorView"
maxlength="500"
placeholder="例如:去北京客户现场出差,拜访 XX 客户并处理项目验收事项"
aria-label="附加说明"
></textarea>
<div class="detail-note-editor-meta">
<span>仅草稿待提交状态可编辑提交后将作为明确说明展示</span>
<div class="detail-note-actions">
<button
v-if="detailNoteDirty"
class="inline-action"
type="button"
:disabled="savingDetailNote"
@click="resetDetailNote"
>
恢复
</button>
<button
class="inline-action primary"
type="button"
:disabled="!detailNoteDirty || savingDetailNote"
@click="saveDetailNote"
>
{{ savingDetailNote ? '保存中' : '保存说明' }}
</button>
</div>
<div v-if="relatedApplicationFactItems.length" class="application-detail-facts related-application-facts">
<div
v-for="item in relatedApplicationFactItems"
:key="item.key"
class="application-detail-fact related-application-fact"
:class="{ highlight: item.highlight, emphasis: item.emphasis }"
>
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</div>
</div>
<div v-else class="detail-note readonly">
<p>{{ detailNote }}</p>
<div v-else class="related-application-empty">
<strong>暂未识别到关联申请单</strong>
<p>差旅报销应先关联已审批的申请单请核对本单据是否由申请单生成或已在智能录入中完成关联</p>
</div>
</article>
<article class="detail-card panel">
@@ -475,6 +457,10 @@
</section>
</div>
</article>
<RiskObservationEvidenceCard
v-if="request.claimId"
:claim-id="request.claimId"
/>
<EmployeeProfileRiskCard
v-if="showEmployeeRiskProfile"
:profile="employeeRiskProfile"

View File

@@ -152,6 +152,23 @@ export default {
const canToggleRiskRuleEnabled = computed(
() => selectedSkillUsesJsonRisk.value && canManageSelected.value
)
const canEditRiskRuleDraft = computed(
() =>
selectedSkillUsesJsonRisk.value &&
(canEditSelected.value || canManageSelected.value) &&
!detailBusy.value &&
!riskRuleGenerationBusy.value &&
!normalizeText(selectedSkill.value?.publishedVersion).replace('-', '')
)
const canCreateRiskRuleRevision = computed(
() =>
selectedSkillUsesJsonRisk.value &&
(canEditSelected.value || canManageSelected.value) &&
!detailBusy.value &&
!riskRuleGenerationBusy.value &&
!riskRuleGenerationFailed.value &&
Boolean(normalizeText(selectedSkill.value?.publishedVersion).replace('-', ''))
)
const canEditMarkdown = computed(() => selectedSkillIsRule.value && canEditSelected.value)
const isDisplayingWorkingVersion = computed(
() => selectedSkill.value?.displayVersion === selectedSkill.value?.workingVersion
@@ -330,10 +347,16 @@ export default {
riskRuleReturnOpen,
riskRulePublishOpen,
riskRuleReturnNote,
riskRuleEditOpen,
riskRuleEditMode,
riskRuleEditForm,
resetRiskRuleActionDialogs,
openRiskRuleTestDialog,
closeRiskRuleTestDialog,
handleRiskRuleReportSaved,
openRiskRuleEditDialog,
closeRiskRuleEditDialog,
submitRiskRuleEdit,
openDeleteRiskRuleDialog,
closeDeleteRiskRuleDialog,
deleteSelectedRiskRule,
@@ -353,6 +376,8 @@ export default {
canReturnRiskRule,
canPublishRiskRule,
canToggleRiskRuleEnabled,
canEditRiskRuleDraft,
canCreateRiskRuleRevision,
riskRuleTestPassed,
refreshCurrentAssets,
loadSelectedAssetDetail,
@@ -719,6 +744,9 @@ export default {
riskRuleReturnOpen,
riskRulePublishOpen,
riskRuleReturnNote,
riskRuleEditOpen,
riskRuleEditMode,
riskRuleEditForm,
riskRuleBusinessStageOptions: RISK_RULE_BUSINESS_STAGE_OPTIONS,
riskRuleExpenseCategoryOptions: RISK_RULE_EXPENSE_CATEGORY_OPTIONS,
showReviewNote,
@@ -762,6 +790,9 @@ export default {
openRiskRuleTestDialog,
closeRiskRuleTestDialog,
handleRiskRuleReportSaved,
openRiskRuleEditDialog,
closeRiskRuleEditDialog,
submitRiskRuleEdit,
openDeleteRiskRuleDialog,
closeDeleteRiskRuleDialog,
deleteSelectedRiskRule,

View File

@@ -16,8 +16,13 @@ import { useTravelReimbursementSubmitComposer } from './useTravelReimbursementSu
import { useTravelReimbursementReviewActions } from './useTravelReimbursementReviewActions.js'
import { useTravelReimbursementGuidedFlow } from './useTravelReimbursementGuidedFlow.js'
import { useApplicationPreviewEditor } from './useApplicationPreviewEditor.js'
import {
buildOperationFeedbackPayload,
normalizeOperationFeedbackContext
} from '../../composables/useOperationFeedback.js'
import { recognizeOcrFiles } from '../../services/ocr.js'
import { fetchAgentRunDetail } from '../../services/agentAssets.js'
import { createOperationFeedback } from '../../services/operationFeedback.js'
import { deleteConversation, runOrchestrator } from '../../services/orchestrator.js'
import { renderMarkdown } from '../../utils/markdown.js'
import { clearAssistantSessionSnapshot } from '../../utils/assistantSessionSnapshot.js'
@@ -46,6 +51,7 @@ import {
} from '../../utils/expenseApplicationPreview.js'
import {
calculateTravelReimbursement,
createExpenseClaimItem,
fetchExpenseClaims,
fetchExpenseClaimAttachmentAsset,
fetchExpenseClaimDetail,
@@ -526,6 +532,10 @@ export default {
type: Object,
default: null
},
initialSessionType: {
type: String,
default: ''
},
entrySource: {
type: String,
default: 'requests'
@@ -543,7 +553,7 @@ export default {
default: 0
}
},
emits: ['close', 'draft-saved'],
emits: ['close', 'draft-saved', 'request-updated'],
setup(props, { emit }) {
const router = useRouter()
const { currentUser } = useSystemState()
@@ -605,14 +615,42 @@ export default {
resolveApplicationPreviewEditorControl,
resolveApplicationPreviewEditorOptions,
isApplicationPreviewEditing,
isApplicationPreviewDateEditorOpen,
openApplicationPreviewEditor,
commitApplicationPreviewEditor,
commitApplicationPreviewDateEditor,
cancelApplicationPreviewEditor,
setApplicationPreviewDateMode,
canApplyApplicationPreviewDateSelection,
handleApplicationPreviewEditorKeydown
} = useApplicationPreviewEditor({
persistSessionState,
toast
})
function applyLinkedApplicationPreviewDateSelection(selection) {
const editor = applicationPreviewEditor.value
if (editor.fieldKey !== 'time' || !editor.messageId) {
return false
}
const targetMessage = messages.value.find((item) =>
String(item.id || '') === String(editor.messageId || '')
)
if (!targetMessage?.applicationPreview) {
return false
}
applicationPreviewEditor.value = {
...editor,
dateMode: selection.mode === 'range' ? 'range' : 'single',
singleDate: selection.startDate,
rangeStartDate: selection.startDate,
rangeEndDate: selection.endDate || selection.startDate
}
return commitApplicationPreviewDateEditor(targetMessage)
}
const isKnowledgeSession = computed(() => activeSessionType.value === SESSION_TYPE_KNOWLEDGE)
const isApplicationSession = computed(() => activeSessionType.value === SESSION_TYPE_APPLICATION)
const activeAssistantMode = computed(() => resolveAssistantSessionMode(activeSessionType.value))
@@ -884,8 +922,34 @@ export default {
buildReviewSlotMap,
isValidIsoDateString,
buildLocallySyncedReviewPayload,
formatDateInputValue
formatDateInputValue,
onComposerDateSelection: applyLinkedApplicationPreviewDateSelection
})
function syncComposerDateFromApplicationEditor() {
const editor = applicationPreviewEditor.value
const today = formatDateInputValue()
composerDateMode.value = editor.dateMode === 'range' ? 'range' : 'single'
composerSingleDate.value = editor.singleDate || today
composerRangeStartDate.value = editor.rangeStartDate || composerSingleDate.value || today
composerRangeEndDate.value = editor.rangeEndDate || composerRangeStartDate.value || today
composerDatePickerOpen.value = true
travelCalculatorOpen.value = false
}
function openApplicationPreviewEditorFromUi(message, fieldKey, value) {
openApplicationPreviewEditor(message, fieldKey, value)
if (fieldKey === 'time' && isApplicationPreviewEditing(message, 'time')) {
syncComposerDateFromApplicationEditor()
}
}
watch(composerDatePickerOpen, (open, previousOpen) => {
if (!open && previousOpen && applicationPreviewEditor.value.fieldKey === 'time') {
cancelApplicationPreviewEditor()
}
})
const canShowTravelCalculator = computed(() => activeSessionType.value === SESSION_TYPE_EXPENSE)
const {
fileInputMode,
@@ -918,6 +982,7 @@ export default {
reviewActionBusy,
toast,
fileInputRef,
createExpenseClaimItem,
fetchExpenseClaimDetail,
fetchExpenseClaimItemAttachmentMeta,
fetchExpenseClaimAttachmentAsset,
@@ -939,6 +1004,32 @@ export default {
composerFilesExpanded,
guidedFlowState
}
const promptedOperationFeedbackRunIds = new Set()
function emitOperationCompleted(payload = {}, extras = {}) {
const runId = String(payload?.run_id || payload?.runId || '').trim()
const operationStatus = String(payload?.status || '').trim()
if (!runId || promptedOperationFeedbackRunIds.has(runId) || operationStatus !== 'succeeded') {
return null
}
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
promptedOperationFeedbackRunIds.add(runId)
return normalizeOperationFeedbackContext({
run_id: runId,
conversation_id: String(payload?.conversation_id || payload?.conversationId || conversationId.value || '').trim(),
user_id: resolveCurrentUserId(),
selected_agent: String(payload?.selected_agent || payload?.selectedAgent || '').trim(),
source: 'user_message',
session_type: activeSessionType.value,
operation_type: String(extras.operationType || 'assistant_round').trim(),
operation_status: operationStatus,
status: operationStatus,
route_reason: String(payload?.route_reason || payload?.routeReason || '').trim(),
entry_source: props.entrySource,
trace_summary: payload?.trace_summary || payload?.traceSummary || null,
result_summary: String(result.answer || result.message || '').trim()
}, currentUser.value || {})
}
const {
confirmPendingAttachmentAssociationInternal,
submitComposerInternal
@@ -1016,6 +1107,8 @@ export default {
startSemanticFlowPreview,
submitting,
syncComposerFilesToDraft,
emitOperationCompleted,
emitRequestUpdated: (payload) => emit('request-updated', payload),
toast
})
const canSubmit = computed(
@@ -1757,6 +1850,121 @@ export default {
return buildApplicationPreviewFooterMessage(message.applicationPreview)
}
function isApplicationDraftPayload(draftPayload) {
return String(draftPayload?.draft_type || '').trim() === 'expense_application'
}
function resolveDraftPayloadBodyField(draftPayload, label) {
const body = String(draftPayload?.body || '')
const pattern = new RegExp(`^${label}(.+)$`, 'm')
return String(body.match(pattern)?.[1] || '').trim()
}
function resolveApplicationDraftStatusLabel(draftPayload) {
const status = String(draftPayload?.status || '').trim()
if (status === 'submitted') return '审批中'
return status || '已生成'
}
function buildApplicationDraftSummaryItems(draftPayload) {
if (!isApplicationDraftPayload(draftPayload)) {
return []
}
return [
{ label: '单号', value: String(draftPayload?.claim_no || '').trim() || '待生成' },
{ label: '类型', value: String(draftPayload?.title || '').trim() || '费用申请' },
{ label: '节点', value: String(draftPayload?.approval_stage || '').trim() || '直属领导审批' },
{ label: '时间', value: resolveDraftPayloadBodyField(draftPayload, '发生时间') },
{ label: '费用', value: resolveDraftPayloadBodyField(draftPayload, '用户预估费用') }
].filter((item) => String(item.value || '').trim() && item.value !== '待补充')
}
function updateMessageOperationFeedback(message, patch = {}) {
if (!message?.id) {
return
}
messages.value = messages.value.map((item) => (
item.id === message.id
? {
...item,
operationFeedback: {
...(item.operationFeedback || {}),
...patch
}
}
: item
))
}
function isOperationFeedbackVisible(message) {
const feedback = message?.operationFeedback || null
return Boolean(
feedback?.context
&& !feedback.dismissed
)
}
function dismissOperationFeedbackForMessage(message) {
updateMessageOperationFeedback(message, {
dismissed: true,
error: ''
})
persistSessionState()
}
async function submitOperationFeedbackForMessage(message, feedback = {}) {
const rating = Number(feedback.rating || 0)
if (!Number.isInteger(rating) || rating < 1 || rating > 5) {
updateMessageOperationFeedback(message, { error: '请选择 1 到 5 星评分。' })
return
}
const context = message?.operationFeedback?.context || null
if (!context) {
return
}
updateMessageOperationFeedback(message, {
submitting: true,
rating,
reason: String(feedback.reason || '').trim(),
error: ''
})
try {
await createOperationFeedback(
buildOperationFeedbackPayload(context, feedback, currentUser.value || {})
)
updateMessageOperationFeedback(message, {
submitting: false,
submitted: true,
dismissed: false,
rating,
reason: String(feedback.reason || '').trim(),
error: ''
})
persistSessionState()
} catch (error) {
updateMessageOperationFeedback(message, {
submitting: false,
error: error?.message || '评价提交失败,请稍后重试。'
})
}
}
async function openApplicationDraftDetail(message) {
const draftPayload = message?.draftPayload || {}
const claimId = String(draftPayload.claim_id || draftPayload.claimId || '').trim()
if (!claimId) {
toast('暂未获取到申请单据 ID稍后可在单据中心查看。')
return
}
await router.push({
name: 'app-document-detail',
params: { requestId: claimId }
})
emit('close')
}
function resolveApplicationPreviewMissingFields(message) {
if (!message?.applicationPreview) {
return []
@@ -1818,6 +2026,7 @@ export default {
pendingText: '正在提交费用申请...',
systemGenerated: true,
skipScopeGuard: true,
feedbackOperationType: 'submit_application',
extraContext: {
application_preview: applicationPreview,
user_input_text: applicationSubmitText
@@ -2181,10 +2390,21 @@ export default {
resolveApplicationPreviewEditorOptions,
resolveApplicationPreviewMissingFields,
isApplicationPreviewEditing,
openApplicationPreviewEditor,
isApplicationPreviewDateEditorOpen,
openApplicationPreviewEditor: openApplicationPreviewEditorFromUi,
commitApplicationPreviewEditor,
commitApplicationPreviewDateEditor,
setApplicationPreviewDateMode,
canApplyApplicationPreviewDateSelection,
handleApplicationPreviewEditorKeydown,
buildApplicationPreviewFooterText,
isApplicationDraftPayload,
resolveApplicationDraftStatusLabel,
buildApplicationDraftSummaryItems,
openApplicationDraftDetail,
isOperationFeedbackVisible,
dismissOperationFeedbackForMessage,
submitOperationFeedbackForMessage,
runWelcomeQuickAction: runShortcut,
handleSuggestedAction,
isSuggestedActionSelected,
@@ -2297,7 +2517,7 @@ export default {
renderMarkdown, buildExpenseQueryWindowLabel, buildExpenseQueryHint, getExpenseQueryActivePage, getExpenseQueryTotalPages, getExpenseQueryVisibleRecords, resolveDocumentPreview, triggerFileUpload, applyComposerDateSelection, handleFilesChange, handleComposerInput, handleComposerEnter, runShortcut, runWelcomeQuickAction: runShortcut, handleSuggestedAction, isSuggestedActionSelected, askHotKnowledgeQuestion, resolveKnowledgeRankLabel, resolveKnowledgeRankTone,
refreshFlowRunDetail, formatFlowStepDuration, resolveFlowStepStatusLabel, resolveFlowStepDetail, toggleInsightPanel, openTravelCalculator, toggleTravelCalculator, closeTravelCalculator, submitTravelCalculator, switchToReviewOverviewDrawer, toggleReviewDocumentDrawer, toggleReviewRiskDrawer, toggleReviewFlowDrawer, toggleAttachedFilesExpanded, removeAttachedFile, clearAttachedFiles,
requestCloseWorkbench, emitCloseAfterLeave, handleAssistantModalAfterEnter, openExpenseQueryRecord, handleExpenseQueryRecordClick, setExpenseQueryPage, shiftExpenseQueryPage, openDeleteSessionDialog, closeDeleteSessionDialog, confirmDeleteCurrentSession, openInlineReviewEditor, closeInlineReviewEditor, commitInlineReviewEditor, clearInlineReviewFieldError, selectInlineScene, selectReviewCategory, selectReviewOtherCategory,
queryDraftByClaimNo, appendReviewRiskBriefToConversation, appendExpenseQueryRiskToConversation, goReviewDocument, openActiveReviewDocumentPreview, closeDocumentPreview, saveInlineReviewChanges, submitComposer, handleAssistantMarkdownClick, handleReviewAction, handleSaveDraftDirectly, resolveApplicationPreviewRows, resolveApplicationPreviewEditorControl, resolveApplicationPreviewEditorOptions, isApplicationPreviewEditing, openApplicationPreviewEditor, commitApplicationPreviewEditor, cancelApplicationPreviewEditor, handleApplicationPreviewEditorKeydown, buildApplicationPreviewFooterText, closeApplicationSubmitConfirm, confirmApplicationSubmit, closeReviewNextStepConfirm, confirmReviewNextStepSubmit, isDraftSavedReviewMessage, canUseInlineSaveDraft, handleInlineSaveDraft
queryDraftByClaimNo, appendReviewRiskBriefToConversation, appendExpenseQueryRiskToConversation, goReviewDocument, openActiveReviewDocumentPreview, closeDocumentPreview, saveInlineReviewChanges, submitComposer, handleAssistantMarkdownClick, handleReviewAction, handleSaveDraftDirectly, resolveApplicationPreviewRows, resolveApplicationPreviewEditorControl, resolveApplicationPreviewEditorOptions, isApplicationPreviewEditing, isApplicationPreviewDateEditorOpen, openApplicationPreviewEditor: openApplicationPreviewEditorFromUi, commitApplicationPreviewEditor, commitApplicationPreviewDateEditor, cancelApplicationPreviewEditor, setApplicationPreviewDateMode, canApplyApplicationPreviewDateSelection, handleApplicationPreviewEditorKeydown, buildApplicationPreviewFooterText, isApplicationDraftPayload, resolveApplicationDraftStatusLabel, buildApplicationDraftSummaryItems, openApplicationDraftDetail, isOperationFeedbackVisible, dismissOperationFeedbackForMessage, submitOperationFeedbackForMessage, closeApplicationSubmitConfirm, confirmApplicationSubmit, closeReviewNextStepConfirm, confirmReviewNextStepSubmit, isDraftSavedReviewMessage, canUseInlineSaveDraft, handleInlineSaveDraft
}
}
}

View File

@@ -8,6 +8,7 @@ import TravelRequestApprovalDialog from '../../components/travel/TravelRequestAp
import TravelRequestBudgetAnalysis from '../../components/travel/TravelRequestBudgetAnalysis.vue'
import TravelRequestDeleteDialog from '../../components/travel/TravelRequestDeleteDialog.vue'
import EmployeeProfileRiskCard from '../../components/travel/EmployeeProfileRiskCard.vue'
import RiskObservationEvidenceCard from '../../components/travel/RiskObservationEvidenceCard.vue'
import TravelRequestReturnDialog from '../../components/travel/TravelRequestReturnDialog.vue'
import {
approveExpenseClaim,
@@ -39,7 +40,10 @@ import {
buildLeaderApprovalInfo,
resolveGeneratedDraftClaimNo
} from '../../utils/applicationApproval.js'
import { buildApplicationDetailFactItems } from '../../utils/expenseApplicationDetail.js'
import {
buildApplicationDetailFactItems,
buildRelatedApplicationFactItems
} from '../../utils/expenseApplicationDetail.js'
import { isArchivedRequestView, normalizeRequestForUi } from '../../utils/requestViewModel.js'
import {
buildAiAdviceViewModel,
@@ -374,6 +378,7 @@ export default {
ConfirmDialog,
EnterpriseSelect,
EmployeeProfileRiskCard,
RiskObservationEvidenceCard,
TravelRequestApprovalDialog,
TravelRequestBudgetAnalysis,
TravelRequestDeleteDialog,
@@ -793,6 +798,7 @@ export default {
return formatCurrency(total)
})
const applicationDetailFactItems = computed(() => buildApplicationDetailFactItems(request.value))
const relatedApplicationFactItems = computed(() => buildRelatedApplicationFactItems(request.value))
const uploadedExpenseCount = computed(() => expenseItems.value.filter((item) => item.attachments.length).length)
const expenseTableColumnCount = computed(
@@ -1920,7 +1926,7 @@ export default {
attachmentPreviewUrl, approveBusy, approveConfirmDialogOpen, approvalConfirmBadge,
approvalConfirmDescription, approvalOpinionHint,
approvalOpinionPlaceholder, approvalOpinionTitle, approveActionLabel, approveBusyLabel,
applicationDetailFactItems,
applicationDetailFactItems, relatedApplicationFactItems,
approveBusyText, approveConfirmText, approveConfirmTitle, canDeleteRequest, canManageCurrentClaim,
canNavigateAttachmentPreview,
canOpenAiEntry, canApproveRequest, canPayRequest, canReturnRequest, canSubmit, canPreviewAttachment,

View File

@@ -3,7 +3,8 @@ export const DIGITAL_EMPLOYEE_SKILL_CATEGORY_OPTIONS = ['积累', '升级', '整
const TASK_TYPE_LABELS = {
daily_risk_scan: '每日风险巡检',
global_risk_scan: '全局风险巡检',
global_risk_scan: '财务风险图谱巡检',
employee_behavior_profile_scan: '员工行为画像巡检',
weekly_ar_summary: '周度应收账龄汇总',
weekly_expense_report: '周度费用洞察',
rule_review_digest: '规则待审摘要',
@@ -16,6 +17,7 @@ const TASK_TYPE_LABELS = {
const TASK_TYPE_SKILL_CATEGORIES = {
daily_risk_scan: '评估',
global_risk_scan: '评估',
employee_behavior_profile_scan: '评估',
weekly_ar_summary: '整理',
weekly_expense_report: '整理',
rule_review_digest: '升级',

View File

@@ -247,28 +247,39 @@ export function resolveRiskRuleConditionSummary(payload) {
export function resolveRiskRuleFlow(payload, fields) {
const metadata = payload && typeof payload === 'object' ? payload.metadata || {} : {}
const flowModel = resolveFlowModel(payload)
const flow = metadata && typeof metadata.flow === 'object' ? metadata.flow : {}
const fieldSummary = buildRiskRuleFieldSummary(fields)
const conditionSummary = resolveRiskRuleConditionSummary(payload)
const severityLabel = resolveRiskRuleSeverityLabel(payload)
const isCityRouteRule = isCityRouteConsistencyPayload(payload)
const modelNodes = Array.isArray(flowModel?.nodes) ? flowModel.nodes : []
const startNode = modelNodes.find((node) => node?.type === 'start')
const evidenceNode = modelNodes.find((node) => node?.type === 'evidence')
const riskNode = modelNodes.find((node) => node?.type === 'risk')
const passNode = modelNodes.find((node) => node?.type === 'pass')
return {
start: normalizeRiskRuleText(flow.start) || '业务单据提交',
evidence: isCityRouteRule
start: normalizeRiskRuleText(startNode?.description) || normalizeRiskRuleText(flow.start) || '业务单据提交',
evidence: normalizeRiskRuleText(evidenceNode?.description) || (isCityRouteRule
? CITY_ROUTE_FLOW_EVIDENCE
: normalizeRiskRuleText(flow.evidence) || `读取 ${fieldSummary}`,
: normalizeRiskRuleText(flow.evidence) || `读取 ${fieldSummary}`),
decision: isCityRouteRule
? CITY_ROUTE_FLOW_DECISION
: normalizeRiskRuleText(flow.decision) || conditionSummary,
basis: conditionSummary,
...resolveRiskRuleFlowDetails(payload, fields),
pass: normalizeRiskRuleText(flow.pass) || '未命中风险,继续流转',
fail: normalizeRiskRuleText(flow.fail) || `命中${severityLabel},进入人工复核`
...resolveRiskRuleFlowDetails(payload, fields, flowModel),
flowModel,
pass: normalizeRiskRuleText(passNode?.description) || normalizeRiskRuleText(flow.pass) || '未命中风险,继续流转',
fail: normalizeRiskRuleText(riskNode?.description) || normalizeRiskRuleText(flow.fail) || `命中${severityLabel},进入人工复核`
}
}
function resolveRiskRuleFlowDetails(payload, fields) {
function resolveRiskRuleFlowDetails(payload, fields, flowModel = null) {
const modelDetails = resolveFlowModelDetails(flowModel, fields)
if (modelDetails) {
return modelDetails
}
const params = payload && typeof payload === 'object' && payload.params && typeof payload.params === 'object'
? payload.params
: {}
@@ -283,6 +294,44 @@ function resolveRiskRuleFlowDetails(payload, fields) {
}
}
function resolveFlowModel(payload) {
if (!payload || typeof payload !== 'object') {
return null
}
const metadata = payload.metadata && typeof payload.metadata === 'object' ? payload.metadata : {}
const flowModel = payload.flow_model && typeof payload.flow_model === 'object'
? payload.flow_model
: metadata.flow_model
return flowModel && typeof flowModel === 'object' ? flowModel : null
}
function resolveFlowModelDetails(flowModel, fields) {
const nodes = Array.isArray(flowModel?.nodes) ? flowModel.nodes : []
if (!nodes.length) {
return null
}
const labelByKey = buildLabelByKey(fields)
const evidenceNodes = nodes.filter((node) => node?.type === 'evidence')
const decisionNodes = nodes.filter((node) => node?.type === 'decision')
const facts = evidenceNodes.flatMap((node) => {
const keys = readStringList(node?.fields)
const fieldText = keys.slice(0, 4).map((key) => `${labelByKey[key] || key}[${key}]`)
return fieldText.length
? fieldText
: [normalizeRiskRuleText(node?.description)]
}).filter(Boolean)
const conditions = decisionNodes.map((node, index) => {
const title = normalizeRiskRuleText(node?.title || `判断 ${index + 1}`)
const description = normalizeRiskRuleText(node?.description)
return description ? `${title}${description}` : title
}).filter(Boolean)
return {
facts: facts.length ? facts : buildFieldFactLines(fields),
conditions,
hitLogic: conditions.join(' AND ')
}
}
function buildFactLines(facts, fields) {
const labelByKey = buildLabelByKey(fields)
const rows = facts

View File

@@ -18,6 +18,47 @@ const KNOWLEDGE_JOB_TYPES = new Set([
'finance_policy_knowledge_organize'
])
const TASK_TYPE_LABELS = {
global_risk_scan: '财务风险图谱巡检',
employee_behavior_profile_scan: '员工行为画像巡检',
finance_policy_knowledge_organize: '知识制度整理',
knowledge_index_sync: '知识制度整理',
llm_wiki_sync: '知识制度整理',
llm_wiki_rule_formation: '知识制度整理'
}
const TASK_CODE_TO_TYPE = {
'task.hermes.global_risk_scan': 'global_risk_scan',
'task.hermes.employee_behavior_profile_scan': 'employee_behavior_profile_scan',
'task.hermes.finance_policy_knowledge_organize': 'finance_policy_knowledge_organize'
}
function toObject(value) {
return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
}
function normalizeTaskType(value) {
const normalized = String(value || '').trim()
if (!normalized) {
return ''
}
return TASK_CODE_TO_TYPE[normalized] || normalized
}
function resolveTaskTypeFromToolName(value) {
const name = String(value || '').trim()
if (name.includes('financial_risk_graph')) {
return 'global_risk_scan'
}
if (name.includes('employee_behavior_profile')) {
return 'employee_behavior_profile_scan'
}
if (name.includes('finance_policy_knowledge')) {
return 'finance_policy_knowledge_organize'
}
return ''
}
export function formatWorkRecordDateTime(value) {
if (!value) {
return '未结束'
@@ -46,8 +87,89 @@ export function resolveWorkRecordSourceLabel(source) {
return SOURCE_LABELS[source] || source || '未标记'
}
export function resolveWorkRecordTaskType(run) {
const routeJson = toObject(run?.route_json)
const routeCandidates = [
routeJson.job_type,
routeJson.task_type,
routeJson.report_type,
routeJson.task_code
].map(normalizeTaskType)
for (const candidate of routeCandidates) {
if (candidate) {
return candidate
}
}
for (const toolCall of run?.tool_calls || []) {
const requestJson = toObject(toolCall?.request_json)
const responseJson = toObject(toolCall?.response_json)
const candidates = [
requestJson.task_type,
requestJson.job_type,
responseJson.report_type,
responseJson.task_type,
responseJson.job_type,
resolveTaskTypeFromToolName(toolCall?.tool_name)
].map(normalizeTaskType)
const matched = candidates.find(Boolean)
if (matched) {
return matched
}
}
return ''
}
export function resolveWorkRecordTaskLabel(run) {
const taskType = resolveWorkRecordTaskType(run)
return TASK_TYPE_LABELS[taskType] || ''
}
export function resolveWorkRecordProductKind(run) {
const taskType = resolveWorkRecordTaskType(run)
if (taskType === 'global_risk_scan') {
return 'risk_graph'
}
if (taskType === 'employee_behavior_profile_scan') {
return 'employee_profile'
}
if (KNOWLEDGE_JOB_TYPES.has(taskType)) {
return 'knowledge'
}
return ''
}
export function extractWorkRecordToolSummary(run) {
const taskType = resolveWorkRecordTaskType(run)
const toolCalls = Array.isArray(run?.tool_calls) ? run.tool_calls : []
const matchedCall = toolCalls.find((toolCall) => {
const requestJson = toObject(toolCall?.request_json)
const responseJson = toObject(toolCall?.response_json)
const candidates = [
requestJson.task_type,
requestJson.job_type,
responseJson.report_type,
responseJson.task_type,
responseJson.job_type,
resolveTaskTypeFromToolName(toolCall?.tool_name)
].map(normalizeTaskType)
return candidates.includes(taskType)
}) || toolCalls[0]
const responseJson = toObject(matchedCall?.response_json)
const nestedSummary = toObject(responseJson.summary)
return Object.keys(nestedSummary).length ? nestedSummary : responseJson
}
export function resolveWorkRecordModuleLabel(run) {
const routeJson = run?.route_json || {}
const taskLabel = resolveWorkRecordTaskLabel(run)
if (taskLabel) {
return taskLabel
}
if (KNOWLEDGE_JOB_TYPES.has(routeJson.job_type)) {
return '知识制度整理'
}
@@ -62,6 +184,11 @@ export function resolveWorkRecordModuleLabel(run) {
export function resolveWorkRecordTitle(run) {
const routeJson = run?.route_json || {}
const taskLabel = resolveWorkRecordTaskLabel(run)
if (taskLabel) {
const suffix = String(routeJson.task_name || routeJson.folder || '本次运行').trim()
return suffix && suffix !== taskLabel ? `${taskLabel} · ${suffix}` : taskLabel
}
if (KNOWLEDGE_JOB_TYPES.has(routeJson.job_type)) {
return `知识制度整理 · ${routeJson.folder || '未指定目录'}`
}

View File

@@ -0,0 +1,224 @@
import { computed } from 'vue'
const TRAIN_KEY_FIELD_DEFINITIONS = [
{
id: 'invoice_number',
label: '发票号码',
placeholder: '待识别',
keys: ['invoice_number', 'ticket_number'],
labels: ['发票号码', '票据号码', '票号']
},
{
id: 'invoice_date',
label: '开票日期',
placeholder: 'YYYY-MM-DD',
keys: ['invoice_date', 'issue_date'],
labels: ['开票日期', '发票日期']
},
{
id: 'fare',
label: '票价',
placeholder: '待识别',
keys: ['fare', 'amount'],
labels: ['票价', '金额']
},
{
id: 'passenger_name',
label: '姓名',
placeholder: '待识别',
keys: ['passenger_name'],
labels: ['乘车人', '旅客姓名', '姓名']
}
]
const DEFAULT_KEY_FIELD_DEFINITIONS = [
{
id: 'invoice_number',
label: '发票号码',
placeholder: '待识别',
keys: ['invoice_number', 'ticket_number'],
labels: ['发票号码', '票据号码', '票号']
},
{
id: 'invoice_date',
label: '开票日期',
placeholder: 'YYYY-MM-DD',
keys: ['invoice_date', 'issue_date'],
labels: ['开票日期', '发票日期']
},
{
id: 'amount',
label: '金额',
placeholder: '待识别',
keys: ['amount', 'fare'],
labels: ['金额', '价税合计', '合计金额', '票价']
},
{
id: 'merchant_name',
label: '商户',
placeholder: '待识别',
keys: ['merchant_name'],
labels: ['商户', '销售方', '开票方']
}
]
const RECEIPT_META_FIELD_DEFINITIONS = [
{
id: 'document_type_label',
label: '票据类型',
placeholder: '待识别',
keys: ['document_type_label'],
labels: ['票据类型', '识别类型']
},
{
id: 'scene_label',
label: '费用场景',
placeholder: '待识别',
keys: ['scene_label'],
labels: ['费用场景', '场景']
},
{
id: 'merchant_name',
label: '商户',
placeholder: '待识别',
keys: ['merchant_name'],
labels: ['商户', '销售方', '开票方']
}
]
export function createReceiptDetailFieldModel({ detailForm, isTrainTicket }) {
const activeKeyFieldDefinitions = computed(() => (
isTrainTicket.value ? TRAIN_KEY_FIELD_DEFINITIONS : DEFAULT_KEY_FIELD_DEFINITIONS
))
const keyReceiptFields = computed(() => (
activeKeyFieldDefinitions.value.map((definition) => ({
...definition,
value: getReceiptFieldValue(definition)
}))
))
const keyReceiptFieldTokens = computed(() => {
const tokens = new Set()
activeKeyFieldDefinitions.value.forEach((definition) => {
for (const token of [definition.id, ...(definition.keys || []), ...(definition.labels || [])]) {
const normalized = normalizeReceiptFieldToken(token)
if (normalized) tokens.add(normalized)
}
})
return tokens
})
const editableOtherFields = computed(() => (
detailForm.fields.filter((field) => {
const key = normalizeReceiptFieldToken(field?.key)
const label = normalizeReceiptFieldToken(field?.label)
return !keyReceiptFieldTokens.value.has(key) && !keyReceiptFieldTokens.value.has(label)
})
))
function findReceiptFieldForDefinition(definition) {
const keys = (definition.keys || []).map(normalizeReceiptFieldToken).filter(Boolean)
const labels = (definition.labels || []).map(normalizeReceiptFieldToken).filter(Boolean)
return detailForm.fields.find((field) => keys.includes(normalizeReceiptFieldToken(field?.key)))
|| detailForm.fields.find((field) => labels.includes(normalizeReceiptFieldToken(field?.label)))
|| null
}
function getReceiptFieldFallback(definition) {
if (definition.id === 'invoice_date') return detailForm.document_date
if (definition.id === 'fare' || definition.id === 'amount') return detailForm.amount
if (definition.id === 'merchant_name') return detailForm.merchant_name
if (definition.id === 'document_type_label') return detailForm.document_type_label
if (definition.id === 'scene_label') return detailForm.scene_label
return ''
}
function getReceiptFieldValue(definition) {
const field = findReceiptFieldForDefinition(definition)
return String(field?.value || getReceiptFieldFallback(definition) || '')
}
function ensureReceiptField(definition) {
const field = findReceiptFieldForDefinition(definition)
if (field) {
field.key = field.key || definition.keys?.[0] || definition.id
field.label = field.label || definition.label
return field
}
const created = {
key: definition.keys?.[0] || definition.id,
label: definition.label,
value: getReceiptFieldFallback(definition)
}
detailForm.fields.push(created)
return created
}
function ensureEditableReceiptFields() {
for (const definition of [...activeKeyFieldDefinitions.value, ...RECEIPT_META_FIELD_DEFINITIONS]) {
const field = ensureReceiptField(definition)
const fallback = getReceiptFieldFallback(definition)
if (!String(field.value || '').trim() && fallback) {
field.value = fallback
}
}
}
function updateReceiptField(definition, value) {
const field = ensureReceiptField(definition)
field.value = value
syncEditableFieldsToTopLevel()
}
function readFieldValue(definition) {
return String(findReceiptFieldForDefinition(definition)?.value || '').trim()
}
function syncEditableFieldsToTopLevel() {
const invoiceDate = readFieldValue(TRAIN_KEY_FIELD_DEFINITIONS[1]) || readFieldValue(DEFAULT_KEY_FIELD_DEFINITIONS[1])
const amount = readFieldValue(TRAIN_KEY_FIELD_DEFINITIONS[2]) || readFieldValue(DEFAULT_KEY_FIELD_DEFINITIONS[2])
const merchant = readFieldValue(RECEIPT_META_FIELD_DEFINITIONS[2]) || readFieldValue(DEFAULT_KEY_FIELD_DEFINITIONS[3])
const documentTypeLabel = readFieldValue(RECEIPT_META_FIELD_DEFINITIONS[0])
const sceneLabel = readFieldValue(RECEIPT_META_FIELD_DEFINITIONS[1])
if (invoiceDate) detailForm.document_date = invoiceDate
if (amount) detailForm.amount = amount
if (merchant) detailForm.merchant_name = merchant
if (documentTypeLabel) detailForm.document_type_label = documentTypeLabel
if (sceneLabel) detailForm.scene_label = sceneLabel
}
function buildDetailPayload() {
syncEditableFieldsToTopLevel()
return {
document_type: detailForm.document_type,
document_type_label: detailForm.document_type_label,
scene_code: detailForm.scene_code,
scene_label: detailForm.scene_label,
summary: detailForm.summary,
amount: detailForm.amount,
document_date: detailForm.document_date,
merchant_name: detailForm.merchant_name,
fields: detailForm.fields
.map((field) => ({
key: String(field?.key || '').trim(),
label: String(field?.label || '').trim(),
value: String(field?.value || '').trim()
}))
.filter((field) => field.key || field.label || field.value)
}
}
return {
buildDetailPayload,
editableOtherFields,
ensureEditableReceiptFields,
keyReceiptFields,
syncEditableFieldsToTopLevel,
updateReceiptField
}
}
function normalizeReceiptFieldToken(value) {
return String(value || '').trim().toLowerCase().replace(/\s+/g, '')
}

View File

@@ -1,4 +1,5 @@
import { buildSuggestedActionKey } from '../../utils/suggestedActionKey.js'
import { filterVisibleMessageMeta } from '../../utils/assistantMessageMeta.js'
import { normalizeExpenseQueryPayload } from './travelReimbursementExpenseQueryModel.js'
import { buildAgentInsight, buildReviewFilePreviewsFromReviewPayload } from './travelReimbursementAttachmentModel.js'
import { resolveExpenseTypeLabel } from './travelReimbursementReviewModel.js'
@@ -108,6 +109,8 @@ export const SOURCE_LABELS = {
requests: '来自报销列表'
}
export { filterVisibleMessageMeta } from '../../utils/assistantMessageMeta.js'
export const SCENARIO_LABELS = {
expense: '报销',
accounts_receivable: '应收',
@@ -157,6 +160,12 @@ export const FLOW_STEP_FALLBACKS = {
runningText: '正在把已确认信息保存为草稿...',
completedText: '草稿已保存'
},
'application-submit-success': {
title: '申请单提交成功',
tool: 'ApplicationSubmit',
runningText: '正在提交费用申请...',
completedText: '申请单提交成功'
},
'attachment-association': {
title: '票据关联草稿',
tool: 'database.expense_claims.save_or_submit',
@@ -286,7 +295,7 @@ export function nowTime() {
export function createMessage(role, text, attachments = [], extras = {}) {
messageSeed += 1
return {
const message = {
id: `msg-${messageSeed}`,
role,
text,
@@ -308,8 +317,11 @@ export function createMessage(role, text, attachments = [], extras = {}) {
pendingAttachmentAssociation: null,
applicationPreview: null,
budgetReport: null,
operationFeedback: null,
...extras
}
message.meta = filterVisibleMessageMeta(message.meta)
return message
}
export function buildExpenseIntentConfirmationMessage(rawText) {
@@ -471,18 +483,6 @@ export function resolveStatusTone(status) {
export function buildMessageMeta(payload, fileNames = []) {
const items = []
if (payload?.selected_agent) {
items.push(`Agent: ${payload.selected_agent}`)
}
if (payload?.permission_level) {
items.push(`权限: ${payload.permission_level}`)
}
if (payload?.trace_summary?.tool_count) {
items.push(`工具: ${payload.trace_summary.tool_count}`)
}
if (payload?.trace_summary?.degraded) {
items.push('已降级')
}
@@ -491,15 +491,11 @@ export function buildMessageMeta(payload, fileNames = []) {
items.push('待确认')
}
if (payload?.run_id) {
items.push(`Run: ${payload.run_id}`)
}
if (fileNames.length) {
items.push(`附件: ${fileNames.length}`)
}
return items
return filterVisibleMessageMeta(items)
}
export function buildStoredMessageMeta(messageJson, attachmentNames = []) {
@@ -515,7 +511,7 @@ export function buildStoredMessageMeta(messageJson, attachmentNames = []) {
if (attachmentNames.length) {
items.push(`附件: ${attachmentNames.length}`)
}
return items
return filterVisibleMessageMeta(items)
}
export function buildWelcomeUserContext(user = {}) {
@@ -870,7 +866,7 @@ export function serializeSessionMessages(messages) {
text: message.text,
attachments: Array.isArray(message.attachments) ? message.attachments.filter(Boolean) : [],
time: message.time,
meta: Array.isArray(message.meta) ? message.meta.filter(Boolean) : [],
meta: filterVisibleMessageMeta(message.meta),
metaTone: message.metaTone || '',
citations: Array.isArray(message.citations) ? message.citations : [],
suggestedActions: Array.isArray(message.suggestedActions) ? message.suggestedActions : [],
@@ -883,10 +879,11 @@ export function serializeSessionMessages(messages) {
draftPayload: message.draftPayload || null,
reviewPayload: message.reviewPayload || null,
riskFlags: Array.isArray(message.riskFlags) ? message.riskFlags : [],
pendingAttachmentAssociation: message.pendingAttachmentAssociation || null,
applicationPreview: message.applicationPreview || null,
budgetReport: message.budgetReport || null,
assistantName: message.assistantName || '',
pendingAttachmentAssociation: message.pendingAttachmentAssociation || null,
applicationPreview: message.applicationPreview || null,
budgetReport: message.budgetReport || null,
operationFeedback: message.operationFeedback || null,
assistantName: message.assistantName || '',
isWelcome: Boolean(message.isWelcome),
welcomeQuickActions: Array.isArray(message.welcomeQuickActions) ? message.welcomeQuickActions : []
}))
@@ -908,6 +905,7 @@ export function hasMeaningfulSessionMessages(messages) {
|| message.draftPayload
|| message.applicationPreview
|| message.budgetReport
|| message.operationFeedback
|| message.pendingAttachmentAssociation
|| (Array.isArray(message.riskFlags) && message.riskFlags.length)
)

View File

@@ -164,6 +164,7 @@ export function buildFallbackProgressSteps(requestModel = {}) {
const pendingPayment = approvalKey === 'pending_payment' || /待付款/.test(node)
const paid = /已付款/.test(node)
const completed = approvalKey === 'completed' || paid || /审批完成|申请完成|已完成/.test(node)
const hasRelatedApplication = Boolean(requestModel?.relatedApplication?.claimNo)
if (isApplicationDocumentRequest(requestModel)) {
const inLeaderApproval = approvalKey === 'in_progress' || /直属领导|领导审批|审批中/.test(node)
@@ -197,13 +198,14 @@ export function buildFallbackProgressSteps(requestModel = {}) {
}
return [
{ index: 1, label: '创建单据', time: '已完成', done: true, active: true },
{ index: 2, label: '待提交', time: '进行中', active: true, current: true },
{ index: 1, label: '关联单据', time: hasRelatedApplication ? '已关联' : '待核对', done: hasRelatedApplication, active: true, current: !hasRelatedApplication },
{ index: 2, label: '待提交', time: hasRelatedApplication ? '进行中' : '待处理', active: hasRelatedApplication, current: hasRelatedApplication },
{ index: 3, label: 'AI预审', time: '待处理' },
{ index: 4, label: '直属领导审批', time: '待处理' },
{ index: 5, label: '财务审批', time: '待处理' },
{ index: 6, label: '待付款', time: pendingPayment ? '进行中' : completed ? '已完成' : '待处理', done: completed, active: pendingPayment || completed, current: pendingPayment },
{ index: 7, label: '已付款', time: paid || completed ? '已完成' : '待处理', done: paid || completed, active: paid || completed, current: false }
{ index: 7, label: '已付款', time: paid || completed ? '已完成' : '待处理', done: paid || completed, active: paid || completed, current: false },
{ index: 8, label: '已归档', time: paid || completed ? '已完成' : '待处理', done: paid || completed, active: paid || completed, current: false }
]
}

View File

@@ -6,20 +6,48 @@ import {
buildLocalApplicationPreviewMessage,
normalizeApplicationPreview
} from '../../utils/expenseApplicationPreview.js'
import {
buildWorkbenchDateLabel,
canApplyWorkbenchDateSelection,
getTodayDateValue
} from '../../utils/workbenchComposerDate.js'
export function useApplicationPreviewEditor({ persistSessionState, toast } = {}) {
const applicationPreviewEditor = ref({
function parseEditorDateValue(value) {
const text = String(value || '').trim()
const matches = [...text.matchAll(/20\d{2}-\d{1,2}-\d{1,2}/g)].map((item) => item[0])
const startDate = matches[0] || getTodayDateValue()
const endDate = matches[1] || startDate
return {
dateMode: matches.length > 1 && startDate !== endDate ? 'range' : 'single',
singleDate: startDate,
rangeStartDate: startDate,
rangeEndDate: endDate
}
}
function buildEmptyEditor() {
return {
messageId: '',
fieldKey: '',
draftValue: ''
})
draftValue: '',
dateMode: 'single',
singleDate: getTodayDateValue(),
rangeStartDate: getTodayDateValue(),
rangeEndDate: getTodayDateValue()
}
}
export function useApplicationPreviewEditor({ persistSessionState, toast } = {}) {
const applicationPreviewEditor = ref(buildEmptyEditor())
function resolveApplicationPreviewRows(message) {
return buildApplicationPreviewRows(message?.applicationPreview || {})
}
function resolveApplicationPreviewEditorControl(fieldKey) {
return fieldKey === 'transportMode' ? 'select' : 'text'
if (fieldKey === 'transportMode') return 'select'
if (fieldKey === 'time') return 'date'
return 'text'
}
function resolveApplicationPreviewEditorOptions(fieldKey) {
@@ -39,21 +67,47 @@ export function useApplicationPreviewEditor({ persistSessionState, toast } = {})
.find((row) => row.key === fieldKey)
if (targetRow && targetRow.editable === false) return
const normalizedValue = String(value || '').trim() === '待补充' ? '' : String(value || '')
const dateState = fieldKey === 'time' ? parseEditorDateValue(normalizedValue) : {}
applicationPreviewEditor.value = {
messageId: String(message.id || ''),
fieldKey,
draftValue: fieldKey === 'transportMode' && !APPLICATION_TRANSPORT_MODE_OPTIONS.includes(normalizedValue)
? ''
: normalizedValue
: normalizedValue,
...dateState
}
}
function cancelApplicationPreviewEditor() {
applicationPreviewEditor.value = {
messageId: '',
fieldKey: '',
draftValue: ''
}
applicationPreviewEditor.value = buildEmptyEditor()
}
function isApplicationPreviewDateEditorOpen(message) {
return isApplicationPreviewEditing(message, 'time')
}
function setApplicationPreviewDateMode(mode) {
applicationPreviewEditor.value.dateMode = mode === 'range' ? 'range' : 'single'
}
function canApplyApplicationPreviewDateSelection() {
const editor = applicationPreviewEditor.value
return canApplyWorkbenchDateSelection({
mode: editor.dateMode,
singleDate: editor.singleDate,
rangeStartDate: editor.rangeStartDate,
rangeEndDate: editor.rangeEndDate
})
}
function buildApplicationPreviewDateDraftValue() {
const editor = applicationPreviewEditor.value
return buildWorkbenchDateLabel({
mode: editor.dateMode,
singleDate: editor.singleDate,
rangeStartDate: editor.rangeStartDate,
rangeEndDate: editor.rangeEndDate
})
}
function commitApplicationPreviewEditor(message) {
@@ -63,7 +117,13 @@ export function useApplicationPreviewEditor({ persistSessionState, toast } = {})
return false
}
const nextValue = String(editor.draftValue || '').trim()
const nextValue = editor.fieldKey === 'time'
? buildApplicationPreviewDateDraftValue()
: String(editor.draftValue || '').trim()
if (editor.fieldKey === 'time' && !nextValue) {
toast?.('请先选择有效日期。')
return false
}
const nextPreview = normalizeApplicationPreview({
...message.applicationPreview,
fields: {
@@ -79,6 +139,14 @@ export function useApplicationPreviewEditor({ persistSessionState, toast } = {})
return true
}
function commitApplicationPreviewDateEditor(message) {
if (!canApplyApplicationPreviewDateSelection()) {
toast?.('请确认结束日期不早于开始日期。')
return false
}
return commitApplicationPreviewEditor(message)
}
function handleApplicationPreviewEditorKeydown(event, message) {
if (event.key === 'Enter') {
event.preventDefault()
@@ -97,9 +165,13 @@ export function useApplicationPreviewEditor({ persistSessionState, toast } = {})
resolveApplicationPreviewEditorControl,
resolveApplicationPreviewEditorOptions,
isApplicationPreviewEditing,
isApplicationPreviewDateEditorOpen,
openApplicationPreviewEditor,
commitApplicationPreviewEditor,
commitApplicationPreviewDateEditor,
cancelApplicationPreviewEditor,
setApplicationPreviewDateMode,
canApplyApplicationPreviewDateSelection,
handleApplicationPreviewEditorKeydown
}
}

View File

@@ -1,14 +1,18 @@
import { ref } from 'vue'
import {
createRiskRuleRevision,
deleteAgentAsset,
fetchAgentAssetDetail,
publishRiskRuleAsset,
returnRiskRuleAsset,
setRiskRuleAssetEnabled
setRiskRuleAssetEnabled,
updateRiskRuleDraft
} from '../../services/agentAssets.js'
import { normalizeText } from './auditViewModel.js'
const DEFAULT_EXPENSE_CATEGORY = 'travel'
export function useAuditRiskRuleActions({
selectedSkill,
detailBusy,
@@ -18,6 +22,8 @@ export function useAuditRiskRuleActions({
canReturnRiskRule,
canPublishRiskRule,
canToggleRiskRuleEnabled,
canEditRiskRuleDraft,
canCreateRiskRuleRevision,
riskRuleTestPassed,
refreshCurrentAssets,
loadSelectedAssetDetail,
@@ -31,6 +37,9 @@ export function useAuditRiskRuleActions({
const riskRuleReturnOpen = ref(false)
const riskRulePublishOpen = ref(false)
const riskRuleReturnNote = ref('')
const riskRuleEditOpen = ref(false)
const riskRuleEditMode = ref('draft')
const riskRuleEditForm = ref(createRiskRuleEditForm())
function resetRiskRuleActionDialogs() {
riskRuleTestOpen.value = false
@@ -38,6 +47,9 @@ export function useAuditRiskRuleActions({
riskRuleReturnOpen.value = false
riskRulePublishOpen.value = false
riskRuleReturnNote.value = ''
riskRuleEditOpen.value = false
riskRuleEditMode.value = 'draft'
riskRuleEditForm.value = createRiskRuleEditForm()
}
function openRiskRuleTestDialog() {
@@ -68,6 +80,68 @@ export function useAuditRiskRuleActions({
}
}
function openRiskRuleEditDialog(mode = 'draft') {
const normalizedMode = mode === 'revision' ? 'revision' : 'draft'
if (normalizedMode === 'revision' && !canCreateRiskRuleRevision.value) {
return
}
if (normalizedMode === 'draft' && !canEditRiskRuleDraft.value) {
return
}
riskRuleEditMode.value = normalizedMode
riskRuleEditForm.value = createRiskRuleEditForm(selectedSkill.value, normalizedMode)
riskRuleEditOpen.value = true
}
function closeRiskRuleEditDialog() {
if (actionState.value === 'save-risk-rule-edit') {
return
}
riskRuleEditOpen.value = false
}
async function submitRiskRuleEdit() {
const isRevision = riskRuleEditMode.value === 'revision'
if (!selectedSkill.value || detailBusy.value) {
return
}
if (isRevision && !canCreateRiskRuleRevision.value) {
return
}
if (!isRevision && !canEditRiskRuleDraft.value) {
return
}
const payload = normalizeRiskRuleEditPayload(riskRuleEditForm.value, isRevision)
if (payload.rule_title.length < 2) {
toast('请输入至少 2 个字的规则标题。')
return
}
if (payload.natural_language.length < 8) {
toast('请至少输入 8 个字的风险规则描述。')
return
}
if (isRevision && !payload.change_reason) {
toast('请填写修订原因。')
return
}
actionState.value = 'save-risk-rule-edit'
try {
const detail = isRevision
? await createRiskRuleRevision(selectedSkill.value.id, payload, { actor: resolveActor() })
: await updateRiskRuleDraft(selectedSkill.value.id, payload, { actor: resolveActor() })
riskRuleEditOpen.value = false
mergeSelectedRuleLifecycle(detail)
await refreshCurrentAssets()
toast(isRevision ? '已创建风险规则修订草稿。' : '风险规则草稿已更新。')
} catch (error) {
toast(error?.message || (isRevision ? '创建修订版本失败,请稍后重试。' : '编辑规则草稿失败,请稍后重试。'))
} finally {
actionState.value = ''
}
}
function openDeleteRiskRuleDialog() {
if (!canDeleteRiskRule.value) {
return
@@ -208,10 +282,16 @@ export function useAuditRiskRuleActions({
riskRuleReturnOpen,
riskRulePublishOpen,
riskRuleReturnNote,
riskRuleEditOpen,
riskRuleEditMode,
riskRuleEditForm,
resetRiskRuleActionDialogs,
openRiskRuleTestDialog,
closeRiskRuleTestDialog,
handleRiskRuleReportSaved,
openRiskRuleEditDialog,
closeRiskRuleEditDialog,
submitRiskRuleEdit,
openDeleteRiskRuleDialog,
closeDeleteRiskRuleDialog,
deleteSelectedRiskRule,
@@ -224,3 +304,27 @@ export function useAuditRiskRuleActions({
toggleSelectedRiskRuleEnabled
}
}
function createRiskRuleEditForm(rule = null, mode = 'draft') {
const config = rule?.configJson || {}
return {
rule_title: normalizeText(rule?.name),
expense_category: normalizeText(config.expense_category) || DEFAULT_EXPENSE_CATEGORY,
requires_attachment: Boolean(rule?.riskRuleRequiresAttachment || config.requires_attachment),
natural_language: normalizeText(rule?.summary || rule?.riskRuleSubtitle),
change_reason: mode === 'revision' ? '' : undefined
}
}
function normalizeRiskRuleEditPayload(form, includeReason) {
const payload = {
rule_title: normalizeText(form?.rule_title),
expense_category: normalizeText(form?.expense_category) || DEFAULT_EXPENSE_CATEGORY,
requires_attachment: Boolean(form?.requires_attachment),
natural_language: normalizeText(form?.natural_language)
}
if (includeReason) {
payload.change_reason = normalizeText(form?.change_reason)
}
return payload
}

View File

@@ -26,6 +26,7 @@ export function useTravelReimbursementAttachments({
reviewActionBusy,
toast,
fileInputRef,
createExpenseClaimItem,
fetchExpenseClaimDetail,
fetchExpenseClaimItemAttachmentMeta,
fetchExpenseClaimAttachmentAsset,
@@ -149,7 +150,7 @@ export function useTravelReimbursementAttachments({
async function syncComposerFilesToDraft(claimId, files) {
const normalizedClaimId = String(claimId || '').trim()
if (!normalizedClaimId || !Array.isArray(files) || !files.length || isKnowledgeSession.value) {
return
return { uploadedCount: 0, skippedCount: Array.isArray(files) ? files.length : 0 }
}
const claim = await fetchExpenseClaimDetail(normalizedClaimId)
@@ -157,16 +158,30 @@ export function useTravelReimbursementAttachments({
const exactMatchBuckets = new Map()
const normalizedMatchBuckets = new Map()
const placeholderQueue = []
const emptyAttachmentQueue = []
const usedItemIds = new Set()
let uploadedCount = 0
for (const item of items) {
const itemId = String(item?.id || '').trim()
const invoiceId = String(item?.invoiceId || item?.invoice_id || '').trim()
if (!itemId) continue
if (invoiceId && !invoiceId.includes('/')) {
const itemType = String(item?.itemType || item?.item_type || '').trim()
const isSystemGenerated = Boolean(
item?.isSystemGenerated ||
item?.is_system_generated ||
itemType === 'travel_allowance'
)
if (!invoiceId && !isSystemGenerated) {
emptyAttachmentQueue.push(item)
continue
}
if (!invoiceId || invoiceId.includes('/')) {
continue
}
if (invoiceId) {
placeholderQueue.push(item)
}
if (!invoiceId) continue
const bucket = exactMatchBuckets.get(invoiceId) || []
bucket.push(item)
exactMatchBuckets.set(invoiceId, bucket)
@@ -185,17 +200,41 @@ export function useTravelReimbursementAttachments({
const normalizedBucket = normalizedMatchBuckets.get(normalizeAttachmentMatchName(file.name)) || []
const nextNormalizedMatch = normalizedBucket.find((item) => !usedItemIds.has(String(item?.id || '').trim()))
const fallbackMatch = placeholderQueue.find((item) => !usedItemIds.has(String(item?.id || '').trim()))
const targetItem = nextExactMatch || nextNormalizedMatch || fallbackMatch
const targetItemId = String(targetItem?.id || '').trim()
const emptyFallbackMatch = emptyAttachmentQueue.find((item) => !usedItemIds.has(String(item?.id || '').trim()))
let targetItem = nextExactMatch || nextNormalizedMatch || fallbackMatch || emptyFallbackMatch
let targetItemId = String(targetItem?.id || '').trim()
if (!targetItemId && typeof createExpenseClaimItem === 'function') {
const updatedClaim = await createExpenseClaimItem(normalizedClaimId, {})
const createdItems = Array.isArray(updatedClaim?.items) ? updatedClaim.items : []
targetItem = createdItems.find((item) => {
const itemId = String(item?.id || '').trim()
const invoiceId = String(item?.invoiceId || item?.invoice_id || '').trim()
const itemType = String(item?.itemType || item?.item_type || '').trim()
return (
itemId &&
!usedItemIds.has(itemId) &&
!invoiceId &&
itemType !== 'travel_allowance' &&
!item?.isSystemGenerated &&
!item?.is_system_generated
)
}) || null
targetItemId = String(targetItem?.id || '').trim()
}
if (!targetItemId) {
continue
}
usedItemIds.add(targetItemId)
await uploadExpenseClaimItemAttachment(normalizedClaimId, targetItemId, file)
uploadedCount += 1
}
await restorePersistedDraftAttachmentPreviews(normalizedClaimId, { force: true })
return {
uploadedCount,
skippedCount: Math.max(0, files.length - uploadedCount)
}
}
function triggerFileUpload(mode = 'composer') {

View File

@@ -44,6 +44,9 @@ const CHINESE_DAY_NUMBERS = {
: 10
}
const COMPOSER_DATE_RANGE_PREFIX_RE = /^20\d{2}-\d{1,2}-\d{1,2}(?:\s*至\s*20\d{2}-\d{1,2}-\d{1,2})?[,。\s]*/u
const COMPOSER_LABELED_TIME_PREFIX_RE = /^(?:业务)?发生时间[:]\s*[^,。\n]+(?:至\s*[^,。\n]+)?[,。\s]*/u
function normalizeComposerText(value) {
return String(value || '').trim().replace(/\s+/g, ' ')
}
@@ -85,7 +88,8 @@ function calculateBusinessDays(businessTimeContext) {
function stripBusinessTimePrefix(text) {
return normalizeComposerText(text)
.replace(/^(?:业务)?发生时间[:]\s*[^,。\n]+(?:至\s*[^,。\n]+)?[,。\s]*/u, '')
.replace(COMPOSER_LABELED_TIME_PREFIX_RE, '')
.replace(COMPOSER_DATE_RANGE_PREFIX_RE, '')
.trim()
}
@@ -183,7 +187,8 @@ export function useTravelReimbursementComposerTools({
buildReviewSlotMap,
isValidIsoDateString,
buildLocallySyncedReviewPayload,
formatDateInputValue
formatDateInputValue,
onComposerDateSelection
}) {
const composerDatePickerOpen = ref(false)
const composerDateMode = ref('single')
@@ -217,23 +222,19 @@ export function useTravelReimbursementComposerTools({
)
function buildComposerBusinessTimeLabel() {
if (composerDateMode.value === 'single') {
return `发生时间:${composerSingleDate.value}`
return composerSingleDate.value
}
if (composerRangeStartDate.value === composerRangeEndDate.value) {
return `发生时间:${composerRangeStartDate.value}`
return composerRangeStartDate.value
}
return `发生时间:${composerRangeStartDate.value}${composerRangeEndDate.value}`
return `${composerRangeStartDate.value}${composerRangeEndDate.value}`
}
function hasComposerBusinessTimeSelection() {
return composerBusinessTimeTags.value.length > 0 || composerBusinessTimeDraftTouched.value
}
function buildComposerBusinessTimeContext() {
if (!hasComposerBusinessTimeSelection()) {
return null
}
function buildComposerBusinessTimeContextFromSelection() {
const mode = composerDateMode.value === 'range' ? 'range' : 'single'
const startDate = String(mode === 'range' ? composerRangeStartDate.value : composerSingleDate.value).trim()
const endDate = String(mode === 'range' ? composerRangeEndDate.value : startDate).trim()
@@ -255,6 +256,28 @@ export function useTravelReimbursementComposerTools({
}
}
function buildComposerBusinessTimeContext() {
if (!hasComposerBusinessTimeSelection()) {
return null
}
return buildComposerBusinessTimeContextFromSelection()
}
function buildComposerBusinessTimeSelection() {
const context = buildComposerBusinessTimeContextFromSelection()
if (!context) {
return null
}
return {
label: buildComposerBusinessTimeLabel(),
context,
mode: context.mode,
startDate: context.start_date,
endDate: context.end_date
}
}
function mergeBusinessTimeIntoExtraContext(extraContext, businessTimeContext) {
if (!businessTimeContext) {
return extraContext
@@ -345,9 +368,60 @@ export function useTravelReimbursementComposerTools({
composerDateMode.value = mode === 'range' ? 'range' : 'single'
}
function handleComposerDateInputChange() {
composerBusinessTimeDraftTouched.value = true
syncComposerBusinessTimeToReviewCard(buildComposerBusinessTimeContext())
async function commitComposerDateSelection({ closePicker = true, focusComposer = true } = {}) {
if (!composerCanApplyDateSelection.value) {
return false
}
const selection = buildComposerBusinessTimeSelection()
if (!selection) {
return false
}
const handled = onComposerDateSelection?.(selection) === true
if (handled) {
composerBusinessTimeDraftTouched.value = false
composerBusinessTimeTags.value = []
} else {
composerBusinessTimeDraftTouched.value = true
composerBusinessTimeTags.value = [
{
id: `biz-time-${Date.now()}`,
label: selection.label
}
]
syncComposerBusinessTimeToReviewCard(selection.context)
}
if (closePicker) {
composerDatePickerOpen.value = false
}
await nextTick()
adjustComposerTextareaHeight()
if (focusComposer) {
composerTextareaRef.value?.focus()
}
return true
}
function handleComposerDateInputChange(part = 'single') {
if (composerDateMode.value !== 'range' || part === 'single') {
void commitComposerDateSelection()
return
}
if (part === 'range-start') {
if (!composerRangeEndDate.value || composerRangeEndDate.value < composerRangeStartDate.value) {
composerRangeEndDate.value = composerRangeStartDate.value
}
if (!onComposerDateSelection) {
composerBusinessTimeDraftTouched.value = true
syncComposerBusinessTimeToReviewCard(buildComposerBusinessTimeContextFromSelection())
}
return
}
void commitComposerDateSelection()
}
function removeComposerBusinessTimeTag(tagId) {
@@ -376,22 +450,7 @@ export function useTravelReimbursementComposerTools({
}
async function applyComposerDateSelection() {
if (!composerCanApplyDateSelection.value) {
return
}
composerBusinessTimeDraftTouched.value = true
composerBusinessTimeTags.value = [
{
id: `biz-time-${Date.now()}`,
label: buildComposerBusinessTimeLabel()
}
]
syncComposerBusinessTimeToReviewCard(buildComposerBusinessTimeContext())
composerDatePickerOpen.value = false
await nextTick()
adjustComposerTextareaHeight()
composerTextareaRef.value?.focus()
await commitComposerDateSelection()
}
function resolveTravelCalculatorInitialDays() {
@@ -547,6 +606,7 @@ export function useTravelReimbursementComposerTools({
travelCalculatorCanSubmit,
buildComposerBusinessTimeLabel,
hasComposerBusinessTimeSelection,
buildComposerBusinessTimeSelection,
buildComposerBusinessTimeContext,
mergeBusinessTimeIntoExtraContext,
syncComposerBusinessTimeToReviewCard,

View File

@@ -1,8 +1,11 @@
import { computed, ref } from 'vue'
function formatFlowDuration(ms) {
if (ms === null || ms === undefined || ms === '') {
return '--'
}
const numericValue = Number(ms)
if (!Number.isFinite(numericValue) || numericValue < 0) {
if (!Number.isFinite(numericValue) || numericValue <= 0) {
return '--'
}
if (numericValue < 1000) {
@@ -15,18 +18,122 @@ function formatFlowDuration(ms) {
}
function parseFlowTimestamp(value) {
const timestamp = new Date(value || '').getTime()
if (value === null || value === undefined || value === '') {
return 0
}
if (typeof value === 'number' && Number.isFinite(value)) {
return value > 0 && value < 10000000000 ? Math.round(value * 1000) : Math.round(value)
}
const timestamp = new Date(value).getTime()
return Number.isFinite(timestamp) ? timestamp : 0
}
const FLOW_DURATION_MS_FIELDS = [
'duration_ms',
'elapsed_ms',
'latency_ms',
'total_duration_ms',
'execution_time_ms'
]
const FLOW_DURATION_SECOND_FIELDS = [
'duration_seconds',
'elapsed_seconds',
'latency_seconds',
'execution_time_seconds'
]
const FLOW_DURATION_AUTO_FIELDS = ['duration', 'elapsed', 'latency', 'execution_time']
const FLOW_STARTED_AT_FIELDS = ['started_at', 'start_time', 'created_at', 'queued_at']
const FLOW_FINISHED_AT_FIELDS = ['finished_at', 'completed_at', 'ended_at', 'end_time', 'updated_at']
function normalizeDurationValue(value, unit = 'ms') {
if (value === null || value === undefined || value === '') {
return null
}
let numericValue = Number(value)
let normalizedUnit = unit
if (typeof value === 'string') {
const text = value.trim()
const match = text.match(/^(\d+(?:\.\d+)?)\s*(ms|毫秒|s|秒)?$/i)
if (match) {
numericValue = Number(match[1])
if (match[2]) {
normalizedUnit = ['s', '秒'].includes(match[2].toLowerCase()) ? 'seconds' : 'ms'
}
}
}
if (!Number.isFinite(numericValue) || numericValue <= 0) {
return null
}
if (normalizedUnit === 'seconds') {
return Math.round(numericValue * 1000)
}
if (normalizedUnit === 'auto') {
return Math.round(numericValue <= 300 ? numericValue * 1000 : numericValue)
}
return Math.round(numericValue)
}
function readFirstDurationField(source, fields, unit) {
if (!source || typeof source !== 'object') {
return null
}
for (const field of fields) {
if (!Object.prototype.hasOwnProperty.call(source, field)) {
continue
}
const durationMs = normalizeDurationValue(source[field], unit)
if (durationMs) {
return durationMs
}
}
return null
}
function resolveDurationFromFields(source) {
return (
readFirstDurationField(source, FLOW_DURATION_MS_FIELDS, 'ms')
|| readFirstDurationField(source, FLOW_DURATION_SECOND_FIELDS, 'seconds')
|| readFirstDurationField(source, FLOW_DURATION_AUTO_FIELDS, 'auto')
)
}
function readFirstTimestampField(source, fields) {
if (!source || typeof source !== 'object') {
return 0
}
for (const field of fields) {
const timestamp = parseFlowTimestamp(source[field])
if (timestamp) {
return timestamp
}
}
return 0
}
function resolveStartedTimestamp(source) {
return readFirstTimestampField(source, FLOW_STARTED_AT_FIELDS)
}
function resolveFinishedTimestamp(source) {
return readFirstTimestampField(source, FLOW_FINISHED_AT_FIELDS)
}
function resolveTimeRangeDurationMs(source) {
const startedAt = resolveStartedTimestamp(source)
const finishedAt = resolveFinishedTimestamp(source)
return finishedAt > startedAt ? finishedAt - startedAt : null
}
function resolveSemanticPhaseDurations(run) {
const runStart = parseFlowTimestamp(run?.started_at)
const runStart = resolveStartedTimestamp(run)
const toolCalls = Array.isArray(run?.tool_calls) ? run.tool_calls : []
const firstToolStartedAt = toolCalls
.map((item) => parseFlowTimestamp(item?.created_at))
.map((item) => resolveStartedTimestamp(item))
.filter((value) => value > 0)
.sort((left, right) => left - right)[0] || 0
const runFinishedAt = parseFlowTimestamp(run?.finished_at)
const runFinishedAt = resolveFinishedTimestamp(run)
const semanticFinishedAt = firstToolStartedAt || runFinishedAt
if (!runStart || !semanticFinishedAt || semanticFinishedAt <= runStart) {
@@ -43,18 +150,24 @@ function resolveSemanticPhaseDurations(run) {
}
function resolveToolCallDurationMs(toolCall, index, toolCalls, run) {
const explicitDuration = Number(toolCall?.duration_ms)
if (Number.isFinite(explicitDuration) && explicitDuration > 0) {
const response = toolCall?.response_json && typeof toolCall.response_json === 'object'
? toolCall.response_json
: {}
const explicitDuration = resolveDurationFromFields(toolCall)
|| resolveTimeRangeDurationMs(toolCall)
|| resolveDurationFromFields(response)
|| resolveTimeRangeDurationMs(response)
if (explicitDuration) {
return explicitDuration
}
const startedAt = parseFlowTimestamp(toolCall?.created_at)
const startedAt = resolveStartedTimestamp(toolCall)
if (!startedAt) {
return null
}
const nextStartedAt = parseFlowTimestamp(toolCalls[index + 1]?.created_at)
const runFinishedAt = parseFlowTimestamp(run?.finished_at)
const nextStartedAt = resolveStartedTimestamp(toolCalls[index + 1])
const runFinishedAt = resolveFinishedTimestamp(run)
const finishedAt = nextStartedAt > startedAt ? nextStartedAt : (runFinishedAt > startedAt ? runFinishedAt : 0)
if (!finishedAt || finishedAt <= startedAt) {
@@ -64,6 +177,19 @@ function resolveToolCallDurationMs(toolCall, index, toolCalls, run) {
return finishedAt - startedAt
}
function summarizeVisibleToolText(value) {
const text = String(value || '')
.replace(/\|[^\n]*\|/g, '')
.replace(/\*\*/g, '')
.split('\n')
.map((line) => line.trim())
.find(Boolean) || ''
if (!text) {
return ''
}
return text.length > 80 ? `${text.slice(0, 80)}...` : text
}
export function useTravelReimbursementFlow({
activeSessionType,
reviewDrawerMode,
@@ -238,7 +364,8 @@ export function useTravelReimbursementFlow({
startedAt: normalizedPatch.startedAt || 0,
finishedAt: normalizedPatch.finishedAt || 0,
error: normalizedPatch.error || '',
deferredCompletion: Boolean(normalizedPatch.deferredCompletion)
deferredCompletion: Boolean(normalizedPatch.deferredCompletion),
syntheticTiming: Boolean(normalizedPatch.syntheticTiming)
}
}
@@ -276,7 +403,8 @@ export function useTravelReimbursementFlow({
startedAt,
finishedAt: 0,
durationMs: null,
error: ''
error: '',
syntheticTiming: Boolean(normalizedPatch.syntheticTiming)
})
}
@@ -286,16 +414,22 @@ export function useTravelReimbursementFlow({
const currentStep = flowSteps.value.find((step) => step.key === key)
const explicitDuration = Number(durationMs)
const hasExplicitDuration = Number.isFinite(explicitDuration) && explicitDuration >= 0
const startedAt = currentStep?.startedAt || (hasExplicitDuration ? Math.max(0, now - explicitDuration) : now)
const startedAt = currentStep?.startedAt || (hasExplicitDuration ? Math.max(0, now - explicitDuration) : 0)
const measuredDuration = hasExplicitDuration
? explicitDuration
: startedAt && !currentStep?.syntheticTiming
? Math.max(0, now - startedAt)
: null
upsertFlowStep(key, {
...patch,
status: FLOW_STEP_STATUS_COMPLETED,
detail: detail || definition?.completedText || '',
startedAt,
finishedAt: now,
durationMs: hasExplicitDuration ? explicitDuration : Math.max(0, now - startedAt),
durationMs: measuredDuration,
error: '',
deferredCompletion: false
deferredCompletion: false,
syntheticTiming: false
})
if (
flowSteps.value.length
@@ -323,15 +457,21 @@ export function useTravelReimbursementFlow({
}
function completePendingFlowStep(key, detail = '', durationMs = null, patch = {}) {
const patchObject = patch && typeof patch === 'object' ? { ...patch } : {}
const refreshCompleted = Boolean(patchObject.refreshCompleted)
delete patchObject.refreshCompleted
const currentStep = flowSteps.value.find((step) => step.key === key)
if (currentStep?.status === FLOW_STEP_STATUS_COMPLETED) {
return
}
const normalizedDuration = Number(durationMs)
const hasMeasuredDuration = Number.isFinite(normalizedDuration) && normalizedDuration > 0
if (currentStep?.status === FLOW_STEP_STATUS_COMPLETED) {
if (refreshCompleted && hasMeasuredDuration) {
completeFlowStep(key, detail, normalizedDuration, patchObject)
}
return
}
if (!currentStep || currentStep.status === FLOW_STEP_STATUS_PENDING) {
const revealOrder = flowSteps.value.length
startFlowStep(key, { ...patch, deferredCompletion: true })
startFlowStep(key, { ...patchObject, deferredCompletion: true, syntheticTiming: !hasMeasuredDuration })
const completionTimer = window.setTimeout(() => {
completeFlowStep(
key,
@@ -343,7 +483,7 @@ export function useTravelReimbursementFlow({
flowSimulationTimers.push(completionTimer)
return
}
completeFlowStep(key, detail, hasMeasuredDuration ? normalizedDuration : null, patch)
completeFlowStep(key, detail, hasMeasuredDuration ? normalizedDuration : null, patchObject)
}
function failCurrentFlowStep(error) {
@@ -527,7 +667,51 @@ export function useTravelReimbursementFlow({
})
}
function isApplicationSessionActive() {
return String(activeSessionType?.value || '').trim() === 'application'
}
function isSubmittedApplicationPayload(payload) {
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
const draftPayload = result.draft_payload && typeof result.draft_payload === 'object'
? result.draft_payload
: payload?.draft_payload && typeof payload.draft_payload === 'object'
? payload.draft_payload
: null
return Boolean(
draftPayload
&& String(draftPayload.draft_type || '').trim() === 'expense_application'
&& String(draftPayload.status || '').trim() === 'submitted'
)
}
function buildApplicationSubmitSuccessDetail(payload) {
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
const draftPayload = result.draft_payload && typeof result.draft_payload === 'object'
? result.draft_payload
: {}
const claimNo = String(draftPayload.claim_no || '').trim()
const approvalStage = String(draftPayload.approval_stage || '').trim() || '直属领导审批'
return claimNo
? `申请单 ${claimNo} 已提交成功,当前节点:${approvalStage}`
: `申请单提交成功,当前节点:${approvalStage}`
}
function shouldHideToolCall(toolCall) {
const toolType = String(toolCall?.tool_type || '').toLowerCase()
const toolName = String(toolCall?.tool_name || '').toLowerCase()
return (
toolName.includes('semantic_ontology')
|| toolName.includes('ontology.')
|| toolType.includes('semantic_ontology')
|| toolType.includes('ontology')
)
}
function resolveToolCallFlowMeta(toolCall, index) {
if (shouldHideToolCall(toolCall)) {
return null
}
const toolType = String(toolCall?.tool_type || '').toLowerCase()
const toolName = String(toolCall?.tool_name || '').toLowerCase()
const response = toolCall?.response_json && typeof toolCall.response_json === 'object'
@@ -535,17 +719,31 @@ export function useTravelReimbursementFlow({
: {}
const responseMessage = String(response.message || '').trim()
const key = `tool-${toolCall?.id || `${index}-${toolType}-${toolName}`}`
if (
isApplicationSessionActive()
&& (
String(response.status || '').trim() === 'submitted'
|| String(response?.draft_payload?.status || '').trim() === 'submitted'
)
) {
return { key: 'application-submit-success', title: '申请单提交成功', tool: 'ApplicationSubmit' }
}
if (toolType.includes('rule')) {
return { key, title: '规则引擎校验', tool: toolCall?.tool_name || 'RuleEngine' }
}
if (toolType.includes('mcp')) {
return { key, title: toolName.includes('standard') ? '差旅补助标准查询' : 'MCP 服务调用', tool: toolCall?.tool_name || 'MCPService' }
return toolName.includes('standard')
? { key, title: '差旅补助标准查询', tool: 'TravelStandard' }
: null
}
if (toolName.includes('knowledge')) {
return { key, title: '知识库检索', tool: toolCall?.tool_name || 'KnowledgeSearch' }
}
if (toolName.includes('application_review_preview')) {
return { key: 'application-review-preview', title: '申请信息核对', tool: 'ApplicationReview' }
}
if (toolName.includes('expense_review_preview') || response.preview_only) {
return { key: 'expense-review-preview', title: '报销信息核对', tool: toolCall?.tool_name || 'user_agent.expense_review_preview' }
return { key: 'expense-review-preview', title: '报销信息核对', tool: 'ExpenseReview' }
}
if (toolName.includes('expense_claim') || toolName.includes('save_or_submit')) {
if (
@@ -564,39 +762,45 @@ export function useTravelReimbursementFlow({
}
return { key: 'expense-claim-draft', title: '保存报销草稿', tool: toolCall?.tool_name || 'ExpenseClaimService' }
}
if (toolType.includes('database')) {
return { key, title: '数据查询/字段处理', tool: toolCall?.tool_name || 'DatabaseTool' }
}
if (toolType.includes('llm') || toolName.includes('user_agent')) {
return { key, title: '智能体生成', tool: toolCall?.tool_name || 'UserAgent' }
}
return { key, title: '智能体工具调用', tool: toolCall?.tool_name || toolCall?.tool_type || 'AgentTool' }
return null
}
function summarizeFlowToolCall(toolCall) {
const response = toolCall?.response_json && typeof toolCall.response_json === 'object'
? toolCall.response_json
: {}
const toolName = String(toolCall?.tool_name || '').toLowerCase()
if (toolName.includes('application_review_preview')) {
return '已整理申请核对信息'
}
if (toolName.includes('expense_review_preview') || response.preview_only) {
return '已整理报销核对信息'
}
if (String(response.status || '').trim() === 'submitted') {
return `已完成财务规则、风险规则和审批路径校验,提交至${response.approval_stage || '审批环节'}`
return isApplicationSessionActive()
? '申请单提交成功'
: `已完成财务规则、风险规则和审批路径校验,提交至${response.approval_stage || '审批环节'}`
}
if (response.submission_blocked) {
return String(response.message || '').trim() || 'AI预审发现待补充项暂未提交审批'
return summarizeVisibleToolText(response.message) || 'AI预审发现待补充项暂未提交审批'
}
return (
String(response.message || response.summary || response.result_summary || '').trim()
summarizeVisibleToolText(response.message || response.summary || response.result_summary)
|| String(toolCall?.tool_name || '').trim()
|| '工具调用完成'
)
}
function mergeFlowRunDetail(run) {
const runStartedAt = resolveStartedTimestamp(run)
const runFinishedAt = resolveFinishedTimestamp(run)
if (runStartedAt) {
flowStartedAt.value = runStartedAt
}
const toolCalls = Array.isArray(run?.tool_calls) ? run.tool_calls : []
if (run?.semantic_parse && flowSteps.value.some((step) => step.key === 'intent')) {
clearFlowSimulationTimers()
const semanticDurations = resolveSemanticPhaseDurations(run)
const intentStep = flowSteps.value.find((step) => step.key === 'intent')
const extractionStep = flowSteps.value.find((step) => step.key === 'extraction')
completePendingFlowStep(
'intent',
summarizeSemanticIntentDetail(run.semantic_parse, {
@@ -605,17 +809,26 @@ export function useTravelReimbursementFlow({
expenseTypeLabels: EXPENSE_TYPE_LABELS,
fallbackText: FLOW_STEP_FALLBACKS.intent.completedText
}),
intentStep?.startedAt ? null : semanticDurations.intentMs
semanticDurations.intentMs,
{ refreshCompleted: true }
)
completePendingFlowStep(
'extraction',
summarizeSemanticParseDetail(run.semantic_parse, run?.ontology_json || {}),
extractionStep?.startedAt ? null : semanticDurations.extractionMs
semanticDurations.extractionMs,
{ refreshCompleted: true }
)
}
const hasApplicationSubmitSuccess = flowSteps.value.some((step) => step.key === 'application-submit-success')
toolCalls.forEach((toolCall, index) => {
const meta = resolveToolCallFlowMeta(toolCall, index)
if (!meta) {
return
}
if (hasApplicationSubmitSuccess && isApplicationSessionActive() && meta.key !== 'application-submit-success') {
return
}
const failed = String(toolCall?.status || '').toLowerCase() === 'failed'
if (failed) {
failFlowStep(meta.key, toolCall?.error_message || summarizeFlowToolCall(toolCall), toolCall?.error_message || '', meta)
@@ -625,7 +838,7 @@ export function useTravelReimbursementFlow({
meta.key,
summarizeFlowToolCall(toolCall),
toolDurationMs,
meta
{ ...meta, refreshCompleted: true }
)
}
})
@@ -634,6 +847,13 @@ export function useTravelReimbursementFlow({
failCurrentFlowStep({ message: run?.error_message || '智能体调用失败' })
return
}
if (
runFinishedAt
&& flowSteps.value.length
&& flowSteps.value.every((step) => [FLOW_STEP_STATUS_COMPLETED, FLOW_STEP_STATUS_FAILED].includes(step.status))
) {
flowFinishedAt.value = runFinishedAt
}
}
function completeFlowResult(payload, run = null) {
@@ -651,11 +871,20 @@ export function useTravelReimbursementFlow({
: resolveFlowStepDetail({ ...step, status: FLOW_STEP_STATUS_COMPLETED })
completeFlowStep(step.key, detail)
})
if (isSubmittedApplicationPayload(payload)) {
completePendingFlowStep(
'application-submit-success',
buildApplicationSubmitSuccessDetail(payload),
null,
{ title: '申请单提交成功', tool: 'ApplicationSubmit' }
)
}
const runFinishedAt = resolveFinishedTimestamp(run)
flowFinishedAt.value = flowSteps.value.some(
(step) => ![FLOW_STEP_STATUS_COMPLETED, FLOW_STEP_STATUS_FAILED].includes(step.status)
)
? 0
: Date.now()
: runFinishedAt || Date.now()
}
async function refreshFlowRunDetail() {

View File

@@ -65,6 +65,10 @@ export function useTravelReimbursementSessionState({
}
function resolveDefaultSessionTypeFromEntry() {
const initialSessionType = String(props.initialSessionType || '').trim()
if (initialSessionType) {
return initialSessionType
}
if (props.entrySource === 'budget') {
return SESSION_TYPE_BUDGET
}

View File

@@ -57,6 +57,8 @@ export function useTravelReimbursementSubmitComposer(ctx) {
currentInsight,
currentUser,
draftClaimId,
emitOperationCompleted,
emitRequestUpdated,
extractReviewAttachmentNames,
failCurrentFlowStep,
fetchExpenseClaims,
@@ -101,6 +103,36 @@ export function useTravelReimbursementSubmitComposer(ctx) {
const pendingAttachmentAssociations = new Map()
function isSubmittedApplicationDraftPayload(draftPayload) {
return (
String(draftPayload?.draft_type || '').trim() === 'expense_application'
&& String(draftPayload?.status || '').trim() === 'submitted'
)
}
function buildOperationFeedbackState(context) {
if (!context) {
return null
}
return {
context,
submitting: false,
submitted: false,
dismissed: false,
rating: 0,
reason: '',
error: ''
}
}
function resolveAssistantResultText(payload, fallbackAnswer) {
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
if (isSubmittedApplicationDraftPayload(result.draft_payload)) {
return ''
}
return result.answer || result.message || fallbackAnswer
}
function createPendingAttachmentAssociationId() {
return `attachment-association-${Date.now()}-${Math.random().toString(16).slice(2)}`
}
@@ -411,6 +443,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
? initialExtraContext
: mergeBusinessTimeIntoExtraContext(initialExtraContext, selectedBusinessTimeContext)
const reviewAction = String(extraContext.review_action || '').trim()
const feedbackOperationType = String(options.feedbackOperationType || '').trim()
const attachmentAssociationConfirmed = Boolean(
options.associationConfirmed ||
extraContext.attachment_association_confirmed ||
@@ -966,7 +999,12 @@ export function useTravelReimbursementSubmitComposer(ctx) {
const fallbackAnswer = reviewActionResult === 'link_to_existing_draft'
? (resultClaimNo ? `已将本次上传的票据关联到草稿 ${resultClaimNo}` : '已将本次上传的票据关联到现有草稿。')
: '智能体已完成处理。'
const assistantMessage = createMessage('assistant', payload?.result?.answer || payload?.result?.message || fallbackAnswer, [], {
const operationFeedbackContext = String(payload?.status || '').trim() === 'succeeded'
? emitOperationCompleted?.(payload, {
operationType: feedbackOperationType || reviewActionResult || (files.length ? 'attachment_review' : 'assistant_round')
})
: null
const assistantMessage = createMessage('assistant', resolveAssistantResultText(payload, fallbackAnswer), [], {
meta: buildMessageMeta(payload, effectiveFileNames),
citations: Array.isArray(payload?.result?.citations) ? payload.result.citations : [],
suggestedActions: Array.isArray(payload?.result?.suggested_actions)
@@ -981,7 +1019,8 @@ export function useTravelReimbursementSubmitComposer(ctx) {
fileCount: files.length,
rawText
}),
riskFlags: Array.isArray(payload?.result?.risk_flags) ? payload.result.risk_flags : []
riskFlags: Array.isArray(payload?.result?.risk_flags) ? payload.result.risk_flags : [],
operationFeedback: buildOperationFeedbackState(operationFeedbackContext)
})
replaceMessage(pendingMessage.id, assistantMessage)
const nextInsight = buildAgentInsight(
@@ -996,12 +1035,17 @@ export function useTravelReimbursementSubmitComposer(ctx) {
completeFlowResult(payload, flowRunDetail)
persistSessionState()
nextTick(scrollToBottom)
const resolvedDraftClaimId = String(payload?.result?.draft_payload?.claim_id || draftClaimId.value || '').trim()
if (!isKnowledgeSession.value && resolvedDraftClaimId && files.length) {
void syncComposerFilesToDraft(resolvedDraftClaimId, files)
.then(() => {
.then((syncResult) => {
persistSessionState()
if (detailScopedUpload && Number(syncResult?.uploadedCount || 0) > 0) {
emitRequestUpdated?.({
claimId: resolvedDraftClaimId,
source: 'detail-smart-entry-attachment-sync'
})
}
})
.catch((error) => {
console.warn('Failed to persist composer attachments to draft claim:', error)

View File

@@ -3,12 +3,20 @@ import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import {
buildOperationFeedbackPayload,
normalizeOperationFeedbackContext
} from '../src/composables/useOperationFeedback.js'
import {
SESSION_TYPE_APPLICATION,
SESSION_TYPE_EXPENSE,
SESSION_TYPE_KNOWLEDGE,
buildMessageMeta,
buildWelcomeInsight,
buildWelcomeMessage
buildWelcomeMessage,
createMessage,
filterVisibleMessageMeta
} from '../src/views/scripts/travelReimbursementConversationModel.js'
const appShellRouteView = readFileSync(
@@ -23,10 +31,26 @@ const assistantScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)),
'utf8'
)
const assistantSubmitComposerScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.js', import.meta.url)),
'utf8'
)
const assistantTemplate = readFileSync(
fileURLToPath(new URL('../src/views/TravelReimbursementCreateView.vue', import.meta.url)),
'utf8'
)
const messageItemTemplate = readFileSync(
fileURLToPath(new URL('../src/components/travel/TravelReimbursementMessageItem.vue', import.meta.url)),
'utf8'
)
const chatViewTemplate = readFileSync(
fileURLToPath(new URL('../src/views/ChatView.vue', import.meta.url)),
'utf8'
)
const operationFeedbackInlineTemplate = readFileSync(
fileURLToPath(new URL('../src/components/shared/OperationFeedbackInlineCard.vue', import.meta.url)),
'utf8'
)
test('application and reimbursement entries open the same financial assistant modal', () => {
assert.match(appShellRouteView, /<TravelReimbursementCreateView[\s\S]*:entry-source="smartEntryContext\.source"/)
@@ -45,7 +69,8 @@ test('application entry keeps its own assistant source without creating a separa
})
test('financial assistant toolbar renders four isolated assistant sessions', () => {
assert.match(assistantScript, /ASSISTANT_SESSION_MODE_OPTIONS\.map/)
assert.match(assistantScript, /filterAssistantSessionModes\(ASSISTANT_SESSION_MODE_OPTIONS, currentUser\.value\)/)
assert.match(assistantScript, /visibleModes\.map/)
assert.match(assistantScript, /targetSessionType:\s*mode\.key/)
assert.match(assistantScript, /active:\s*mode\.key === activeSessionType\.value/)
assert.match(assistantTemplate, /:class="\{ active: shortcut\.active \}"/)
@@ -79,3 +104,88 @@ test('financial assistant welcome copy differentiates application intent from re
assert.equal(applicationInsight.metricValue, '申请助手')
assert.equal(applicationInsight.title, '申请助手')
})
test('assistant message meta hides internal routing and permission chips', () => {
const meta = buildMessageMeta(
{
selected_agent: 'user_agent',
permission_level: 'draft_write',
run_id: 'run-001',
trace_summary: {
tool_count: 3,
degraded: true
},
requires_confirmation: true
},
['invoice.pdf']
)
assert.deepEqual(meta, ['已降级', '待确认', '附件: 1'])
assert.deepEqual(
filterVisibleMessageMeta(['Agent: user_agent', '权限: draft_write', 'Run: run-001', '工具: 3', '等待确认']),
['等待确认']
)
assert.deepEqual(
createMessage('assistant', '测试', [], { meta: ['Agent: user_agent', '权限: draft_write', '处理中'] }).meta,
['处理中']
)
assert.doesNotMatch(messageItemTemplate, /message-meta-row|message-meta-chip/)
assert.doesNotMatch(chatViewTemplate, /agent-meta-row|agent-meta-chip/)
})
test('assistant operation feedback is inline and persists run context', () => {
assert.doesNotMatch(appShellRouteView, /<OperationFeedbackDialog/)
assert.doesNotMatch(appShellRouteView, /@operation-completed="handleOperationCompleted"/)
assert.doesNotMatch(appShellComposable, /useOperationFeedback/)
assert.match(messageItemTemplate, /<OperationFeedbackInlineCard/)
assert.match(messageItemTemplate, /class="message-feedback-bubble"/)
assert.match(messageItemTemplate, /:submitted="Boolean\(message\.operationFeedback\?\.submitted\)"/)
assert.match(messageItemTemplate, /:submitted-rating="Number\(message\.operationFeedback\?\.rating \|\| 0\)"/)
assert.match(assistantScript, /emits:\s*\['close', 'draft-saved', 'request-updated'\]/)
assert.match(appShellRouteView, /@request-updated="handleRequestUpdated"/)
assert.match(assistantScript, /function submitOperationFeedbackForMessage/)
assert.match(assistantScript, /createOperationFeedback/)
assert.match(assistantScript, /normalizeOperationFeedbackContext/)
assert.match(assistantScript, /&& !feedback\.dismissed/)
assert.doesNotMatch(assistantScript, /&& !feedback\.submitted/)
assert.match(assistantScript, /submitted:\s*true/)
assert.match(assistantScript, /dismissed:\s*false/)
assert.doesNotMatch(assistantScript, /emit\('operation-completed'/)
assert.match(assistantSubmitComposerScript, /emitOperationCompleted\?\.\(payload/)
assert.match(assistantSubmitComposerScript, /operationFeedback:\s*buildOperationFeedbackState/)
assert.match(assistantSubmitComposerScript, /rating:\s*0/)
assert.match(operationFeedbackInlineTemplate, /v-for="option in ratingOptions"/)
assert.match(operationFeedbackInlineTemplate, /is-submitted/)
assert.match(operationFeedbackInlineTemplate, /submittedRating/)
assert.match(operationFeedbackInlineTemplate, /感谢您的反馈。谢谢/)
assert.match(operationFeedbackInlineTemplate, /busy \|\| submitted/)
assert.match(operationFeedbackInlineTemplate, /role="radiogroup"/)
assert.match(operationFeedbackInlineTemplate, /handleRatingKeydown/)
assert.match(operationFeedbackInlineTemplate, /operation-feedback-stars/)
assert.match(operationFeedbackInlineTemplate, /score > 3/)
assert.match(operationFeedbackInlineTemplate, /v-if="showReasonInput"/)
assert.match(operationFeedbackInlineTemplate, /稍后/)
const context = normalizeOperationFeedbackContext(
{
run_id: 'run-001',
conversation_id: 'conv-001',
selected_agent: 'user_agent',
session_type: 'application',
operation_status: 'succeeded',
route_reason: 'model_route',
result: { answer: '处理完成' }
},
{ username: 'wenjing.li' }
)
const payload = buildOperationFeedbackPayload(context, { rating: 2, reason: '识别错了' })
assert.equal(context.runId, 'run-001')
assert.equal(context.userId, 'wenjing.li')
assert.equal(payload.run_id, 'run-001')
assert.equal(payload.conversation_id, 'conv-001')
assert.equal(payload.agent, 'user_agent')
assert.equal(payload.rating, 2)
assert.equal(payload.reason, '识别错了')
assert.equal(payload.context_json.low_rating, true)
})

View File

@@ -0,0 +1,31 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
const shell = readFileSync(
fileURLToPath(new URL('../src/views/AppShellRouteView.vue', import.meta.url)),
'utf8'
)
const router = readFileSync(fileURLToPath(new URL('../src/router/index.js', import.meta.url)), 'utf8')
test('app shell main route views are eagerly imported', () => {
assert.doesNotMatch(shell, /defineAsyncRouteView/)
assert.doesNotMatch(shell, /defineAsyncComponent/)
assert.doesNotMatch(shell, /loadingComponent:/)
assert.doesNotMatch(shell, /\u9875\u9762\u5207\u6362\u4e2d/)
assert.doesNotMatch(shell, /floating:\s*true/)
assert.doesNotMatch(shell, /blocking:\s*true/)
assert.match(shell, /import AuditView from '\.\/AuditView\.vue'/)
assert.match(shell, /import DigitalEmployeesView from '\.\/DigitalEmployeesView\.vue'/)
assert.match(shell, /import EmployeeManagementView from '\.\/EmployeeManagementView\.vue'/)
assert.match(shell, /import DocumentsCenterView from '\.\/DocumentsCenterView\.vue'/)
assert.match(shell, /import BudgetCenterView from '\.\/BudgetCenterView\.vue'/)
})
test('top-level app routes are eagerly imported', () => {
assert.doesNotMatch(router, /\(\)\s*=>\s*import\(/)
assert.match(router, /import AppShellRouteView from '\.\.\/views\/AppShellRouteView\.vue'/)
assert.match(router, /import LoginRouteView from '\.\.\/views\/LoginRouteView\.vue'/)
assert.match(router, /import SetupRouteView from '\.\.\/views\/SetupRouteView\.vue'/)
})

View File

@@ -89,10 +89,15 @@ test('saving a draft keeps the financial assistant open for continued work', ()
)?.[0]
assert.ok(handleDraftSavedBlock)
assert.match(handleDraftSavedBlock, /if \(status === 'submitted'\) \{[\s\S]*smartEntryOpen\.value = false/)
assert.match(handleDraftSavedBlock, /if \(status === 'submitted'\) \{[\s\S]*router\.push\(\{ name: 'app-documents' \}\)/)
assert.match(handleDraftSavedBlock, /if \(status === 'submitted'\) \{[\s\S]*if \(isApplicationDocument\) \{[\s\S]*return/)
assert.match(handleDraftSavedBlock, /smartEntryOpen\.value = false[\s\S]*router\.push\(\{ name: 'app-documents' \}\)/)
assert.match(handleDraftSavedBlock, /return[\s\S]*单据已保存为草稿,可继续上传票据或补充信息。/)
const applicationSubmittedIndex = handleDraftSavedBlock.indexOf('if (isApplicationDocument)')
const applicationSubmittedReturnIndex = handleDraftSavedBlock.indexOf('return', applicationSubmittedIndex)
assert.equal(handleDraftSavedBlock.indexOf('smartEntryOpen.value = false', applicationSubmittedIndex) > applicationSubmittedReturnIndex, true)
assert.equal(handleDraftSavedBlock.indexOf("router.push({ name: 'app-documents' })", applicationSubmittedIndex) > applicationSubmittedReturnIndex, true)
const draftSuccessIndex = handleDraftSavedBlock.indexOf('单据已保存为草稿,可继续上传票据或补充信息。')
assert.equal(handleDraftSavedBlock.indexOf('smartEntryOpen.value = false', draftSuccessIndex), -1)
assert.equal(handleDraftSavedBlock.indexOf("router.push({ name: 'app-documents' })", draftSuccessIndex), -1)

View File

@@ -14,6 +14,14 @@ test('app route guard allows stale healthy state when health check times out', (
assert.match(routerScript, /checkBackendHealth\(\{\s*allowStaleOnTimeout:\s*true\s*\}\)/)
})
test('authenticated in-app navigation does not wait for backend health check', () => {
assert.match(routerScript, /function isAuthenticatedAppNavigation\(to, from\)/)
assert.match(
routerScript,
/if \(isAuthenticatedAppNavigation\(to, from\)\) \{[\s\S]*scheduleBackgroundBackendHealthCheck\(\)[\s\S]*return true[\s\S]*\}/
)
})
test('backend health timeout does not block app rendering when stale fallback is allowed', async () => {
const originalFetch = global.fetch

View File

@@ -0,0 +1,82 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import {
extractWorkRecordToolSummary,
resolveWorkRecordModuleLabel,
resolveWorkRecordProductKind,
resolveWorkRecordTaskType,
resolveWorkRecordTitle
} from '../src/views/scripts/digitalEmployeeWorkRecordsModel.js'
const runProductsComponent = readFileSync(
fileURLToPath(new URL('../src/components/audit/DigitalEmployeeRunProducts.vue', import.meta.url)),
'utf8'
)
const digitalEmployeeDetailComponent = readFileSync(
fileURLToPath(new URL('../src/components/audit/AuditDigitalEmployeeDetail.vue', import.meta.url)),
'utf8'
)
test('digital employee risk graph run resolves structured product metadata', () => {
const run = {
route_json: {
task_code: 'task.hermes.global_risk_scan',
task_name: '财务风险图谱巡检'
},
tool_calls: [
{
tool_name: 'digital_employee.financial_risk_graph.scan',
request_json: { task_type: 'global_risk_scan' },
response_json: {
scanned_claim_count: 3,
risk_observation_count: 2,
graph_node_count: 7,
graph_edge_count: 6
}
}
]
}
assert.equal(resolveWorkRecordTaskType(run), 'global_risk_scan')
assert.equal(resolveWorkRecordProductKind(run), 'risk_graph')
assert.equal(resolveWorkRecordModuleLabel(run), '财务风险图谱巡检')
assert.equal(resolveWorkRecordTitle(run), '财务风险图谱巡检')
assert.equal(extractWorkRecordToolSummary(run).risk_observation_count, 2)
})
test('digital employee profile run resolves from tool request when route is sparse', () => {
const run = {
route_json: { selected_agent: 'hermes' },
tool_calls: [
{
tool_name: 'digital_employee.employee_behavior_profile.scan',
request_json: { task_type: 'employee_behavior_profile_scan' },
response_json: {
target_employee_count: 4,
snapshot_count: 16,
high_attention_employee_count: 1
}
}
]
}
assert.equal(resolveWorkRecordTaskType(run), 'employee_behavior_profile_scan')
assert.equal(resolveWorkRecordProductKind(run), 'employee_profile')
assert.equal(resolveWorkRecordModuleLabel(run), '员工行为画像巡检')
assert.equal(extractWorkRecordToolSummary(run).snapshot_count, 16)
})
test('digital employee work record product supports scoped observation expansion', () => {
assert.match(runProductsComponent, /activeObservationKey/)
assert.match(runProductsComponent, /toggleObservation/)
assert.match(runProductsComponent, /异常关系/)
assert.doesNotMatch(runProductsComponent, /KnowledgeIngestGraphView/)
})
test('digital employee skill detail does not render knowledge graph component', () => {
assert.doesNotMatch(digitalEmployeeDetailComponent, /KnowledgeIngestGraphView/)
assert.doesNotMatch(digitalEmployeeDetailComponent, /LightRAG 知识图谱/)
})

Some files were not shown because too many files have changed in this diff Show More