feat: 新增风险图谱算法与系统仪表盘及操作反馈体系
后端新增风险图谱算法模块、风险观察与反馈服务、规则 DSL 校验器和可解释性引擎,完善系统仪表盘和财务仪表盘统计, 优化 agent 运行和编排执行链路,清理旧开发文档,前端新增 系统趋势、负载热力图等多种仪表盘图表组件,完善操作反馈 对话框和工作台日期选择器,优化报销创建和审批详情交互, 补充单元测试覆盖。
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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%; }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -160,7 +160,7 @@
|
||||
</template>
|
||||
|
||||
<template #table>
|
||||
<table>
|
||||
<table class="audit-asset-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ tableColumns.name }}</th>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
404
web/src/components/audit/DigitalEmployeeRunProducts.vue
Normal file
404
web/src/components/audit/DigitalEmployeeRunProducts.vue
Normal 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>
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
143
web/src/components/charts/RiskDailyTrendChart.vue
Normal file
143
web/src/components/charts/RiskDailyTrendChart.vue
Normal 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>
|
||||
146
web/src/components/charts/SystemAccuracyCompareBar.vue
Normal file
146
web/src/components/charts/SystemAccuracyCompareBar.vue
Normal 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>
|
||||
135
web/src/components/charts/SystemAgentRatioBar.vue
Normal file
135
web/src/components/charts/SystemAgentRatioBar.vue
Normal 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>
|
||||
141
web/src/components/charts/SystemLoadHeatmap.vue
Normal file
141
web/src/components/charts/SystemLoadHeatmap.vue
Normal 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>
|
||||
197
web/src/components/charts/SystemLoginWaveChart.vue
Normal file
197
web/src/components/charts/SystemLoginWaveChart.vue
Normal 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>
|
||||
165
web/src/components/charts/SystemTokenDailyWaveChart.vue
Normal file
165
web/src/components/charts/SystemTokenDailyWaveChart.vue
Normal 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>
|
||||
113
web/src/components/charts/SystemTokenTreemap.vue
Normal file
113
web/src/components/charts/SystemTokenTreemap.vue
Normal 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>
|
||||
245
web/src/components/charts/SystemTrendChart.vue
Normal file
245
web/src/components/charts/SystemTrendChart.vue
Normal 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>
|
||||
199
web/src/components/charts/SystemUserTokenPie.vue
Normal file
199
web/src/components/charts/SystemUserTokenPie.vue
Normal 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>
|
||||
708
web/src/components/dashboard/RiskObservationDashboard.vue
Normal file
708
web/src/components/dashboard/RiskObservationDashboard.vue
Normal 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>
|
||||
@@ -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)
|
||||
|
||||
303
web/src/components/shared/OperationFeedbackDialog.vue
Normal file
303
web/src/components/shared/OperationFeedbackDialog.vue
Normal 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>
|
||||
474
web/src/components/shared/OperationFeedbackInlineCard.vue
Normal file
474
web/src/components/shared/OperationFeedbackInlineCard.vue
Normal 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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
381
web/src/components/travel/RiskObservationEvidenceCard.vue
Normal file
381
web/src/components/travel/RiskObservationEvidenceCard.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
161
web/src/composables/useOperationFeedback.js
Normal file
161
web/src/composables/useOperationFeedback.js
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
144
web/src/composables/useWorkbenchComposerDate.js
Normal file
144
web/src/composables/useWorkbenchComposerDate.js
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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)' }
|
||||
]
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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`)
|
||||
}
|
||||
|
||||
93
web/src/services/analytics.js
Normal file
93
web/src/services/analytics.js
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
27
web/src/services/operationFeedback.js
Normal file
27
web/src/services/operationFeedback.js
Normal 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)}`)
|
||||
}
|
||||
143
web/src/services/riskObservations.js
Normal file
143
web/src/services/riskObservations.js
Normal 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)
|
||||
}
|
||||
21
web/src/utils/assistantMessageMeta.js
Normal file
21
web/src/utils/assistantMessageMeta.js
Normal 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))
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
90
web/src/utils/authSessionMetrics.js
Normal file
90
web/src/utils/authSessionMetrics.js
Normal 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)
|
||||
})
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
export const HERMES_SIMPLE_TASKS = [
|
||||
{
|
||||
id: 'global_risk_scan',
|
||||
label: '风险每日巡检',
|
||||
hint: '扫描报销、付款等风险信号',
|
||||
label: '财务风险图谱巡检',
|
||||
hint: '扫描单据、票据、审批链和画像异常',
|
||||
frequency: 'daily',
|
||||
frequencyLabel: '每天'
|
||||
},
|
||||
|
||||
53
web/src/utils/personalWorkbenchAssistantEntry.js
Normal file
53
web/src/utils/personalWorkbenchAssistantEntry.js
Normal 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
|
||||
}
|
||||
}
|
||||
105
web/src/utils/workbenchAssistantIntent.js
Normal file
105
web/src/utils/workbenchAssistantIntent.js
Normal 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)
|
||||
}
|
||||
74
web/src/utils/workbenchComposerDate.js
Normal file
74
web/src/utils/workbenchComposerDate.js
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: '升级',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 || '未指定目录'}`
|
||||
}
|
||||
|
||||
224
web/src/views/scripts/receiptFolderDetailFields.js
Normal file
224
web/src/views/scripts/receiptFolderDetailFields.js
Normal 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, '')
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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 }
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
31
web/tests/app-shell-route-loading.test.mjs
Normal file
31
web/tests/app-shell-route-loading.test.mjs
Normal 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'/)
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
82
web/tests/digital-employee-work-record-products.test.mjs
Normal file
82
web/tests/digital-employee-work-record-products.test.mjs
Normal 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
Reference in New Issue
Block a user