feat: 增强规则资产管理与审计页面运行时调试

后端新增规则资产版本管理和规则文件 CRUD 接口,优化风险
规则生成模板执行和员工数据模型字段,知识库 RAG 增强本
地回退和文档提取能力,清理旧风险规则文件统一由生成引擎
管理,前端审计页面增加运行时调试面板和规则资产编辑交互,
补充单元测试覆盖。
This commit is contained in:
caoxiaozhu
2026-05-24 21:44:17 +08:00
parent 575f093c74
commit 50b1c3f9a9
113 changed files with 13896 additions and 5044 deletions

View File

@@ -94,6 +94,7 @@
grid-template-rows: auto minmax(0, 1fr);
overflow: hidden;
}
.main.documents-main,
.main.requests-main,
.main.approval-main,
.main.archive-main,
@@ -114,6 +115,7 @@
}
.workarea { min-height: 0; overflow: auto; padding: 24px; }
.workarea.requests-workarea,
.workarea.documents-workarea,
.workarea.approval-workarea,
.workarea.archive-workarea,
.workarea.policies-workarea,

View File

@@ -0,0 +1,330 @@
.expense-application-mask {
position: fixed;
inset: 0;
z-index: 120;
display: flex;
align-items: center;
justify-content: center;
padding: 28px;
background: rgba(15, 23, 42, 0.26);
backdrop-filter: blur(10px);
}
.expense-application-dialog {
width: min(1040px, calc(100vw - 56px));
max-height: calc(100vh - 56px);
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid #dbe5ee;
border-radius: 8px;
background: #fff;
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.18);
}
.application-dialog-header,
.application-dialog-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 22px;
border-bottom: 1px solid #e8eef4;
}
.application-dialog-footer {
justify-content: flex-end;
gap: 12px;
border-top: 1px solid #e8eef4;
border-bottom: 0;
}
.application-dialog-eyebrow {
display: block;
margin-bottom: 4px;
color: #059669;
font-size: 12px;
font-weight: 800;
}
.application-dialog-header h2 {
margin: 0;
color: #102033;
font-size: 20px;
font-weight: 850;
}
.dialog-icon-btn {
width: 36px;
height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid #dbe5ee;
border-radius: 8px;
background: #fff;
color: #4b5f73;
cursor: pointer;
}
.application-dialog-body {
display: grid;
grid-template-columns: minmax(320px, 0.92fr) minmax(420px, 1.08fr);
gap: 18px;
min-height: 0;
padding: 20px 22px;
overflow: auto;
background: #f7fafc;
}
.application-input-panel,
.application-ontology-panel {
border: 1px solid #e1e9f0;
border-radius: 8px;
background: #fff;
padding: 18px;
}
.application-field-label {
display: block;
margin-bottom: 10px;
color: #425466;
font-size: 13px;
font-weight: 800;
}
.application-input-panel textarea {
width: 100%;
min-height: 132px;
resize: vertical;
border: 1px solid #cfd9e3;
border-radius: 8px;
padding: 12px;
color: #182536;
font: inherit;
line-height: 1.55;
outline: none;
}
.application-input-panel textarea:focus {
border-color: #10b981;
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.12);
}
.application-example-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 14px 0;
}
.application-example-chip {
border: 1px solid #dbe5ee;
border-radius: 8px;
background: #f8fbfd;
color: #395066;
padding: 7px 10px;
font-size: 12px;
cursor: pointer;
}
.primary-parse-btn,
.confirm-btn,
.secondary-btn {
min-height: 38px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
border-radius: 8px;
padding: 0 16px;
font-weight: 850;
cursor: pointer;
}
.primary-parse-btn,
.confirm-btn {
border: 0;
background: #059669;
color: #fff;
}
.primary-parse-btn {
width: 100%;
}
.secondary-btn {
border: 1px solid #dbe5ee;
background: #fff;
color: #395066;
}
.primary-parse-btn:disabled,
.confirm-btn:disabled {
background: #b7c5d1;
cursor: not-allowed;
}
.application-error {
margin: 12px 0 0;
color: #dc2626;
font-size: 13px;
}
.ontology-panel-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
color: #425466;
font-size: 13px;
font-weight: 850;
}
.ontology-panel-head strong {
color: #059669;
}
.ontology-empty-state {
min-height: 300px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: #6b7c8f;
text-align: center;
}
.ontology-empty-state i {
color: #94a3b8;
font-size: 34px;
}
.ontology-empty-state p {
max-width: 360px;
margin: 0;
line-height: 1.6;
}
.ontology-chip-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 14px;
}
.ontology-chip {
border-radius: 8px;
background: #eefbf5;
color: #047857;
padding: 6px 10px;
font-size: 12px;
font-weight: 800;
}
.application-field-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.application-field-card {
min-height: 76px;
border: 1px solid #e4ebf2;
border-radius: 8px;
padding: 12px;
background: #fbfdff;
}
.application-field-card span,
.application-reason-block span,
.application-missing-block span {
display: block;
margin-bottom: 7px;
color: #6b7c8f;
font-size: 12px;
font-weight: 800;
}
.application-field-card strong {
color: #182536;
font-size: 15px;
}
.application-policy-strip,
.application-reason-block,
.application-missing-block {
margin-top: 12px;
border: 1px solid #dbe5ee;
border-radius: 8px;
padding: 12px;
background: #fff;
}
.application-policy-strip {
display: flex;
gap: 12px;
align-items: flex-start;
}
.application-policy-strip i {
margin-top: 2px;
color: #059669;
font-size: 20px;
}
.application-policy-strip.required {
border-color: #fed7aa;
background: #fff7ed;
}
.application-policy-strip.required i {
color: #ea580c;
}
.application-policy-strip strong {
color: #182536;
}
.application-policy-strip p,
.application-reason-block p {
margin: 5px 0 0;
color: #4b5f73;
line-height: 1.55;
}
.application-missing-block div {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.application-missing-block em {
border-radius: 8px;
background: #fff7ed;
color: #c2410c;
padding: 5px 8px;
font-style: normal;
font-size: 12px;
font-weight: 800;
}
@media (max-width: 860px) {
.expense-application-mask {
align-items: stretch;
padding: 14px;
}
.expense-application-dialog {
width: 100%;
max-height: calc(100vh - 28px);
}
.application-dialog-body {
grid-template-columns: 1fr;
}
.application-field-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,741 @@
.risk-sim-backdrop {
position: fixed;
inset: 0;
z-index: 1200;
display: flex;
align-items: center;
justify-content: center;
padding: 18px;
background: rgba(15, 23, 42, 0.44);
backdrop-filter: blur(12px);
}
.risk-sim-modal {
width: min(1180px, 100%);
height: min(820px, calc(100vh - 36px));
display: grid;
grid-template-rows: auto auto minmax(0, 1fr) auto;
overflow: hidden;
border: 1px solid rgba(203, 213, 225, 0.88);
border-radius: 18px;
background: #f8fafc;
box-shadow: 0 26px 70px rgba(15, 23, 42, 0.26);
}
.risk-sim-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
padding: 18px 20px 14px;
border-bottom: 1px solid #e2e8f0;
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
}
.risk-sim-title span,
.risk-sim-context-panel span,
.risk-sim-result-head span,
.risk-sim-evidence span,
.risk-sim-file-strip > span {
color: #64748b;
font-size: 12px;
font-weight: 850;
}
.risk-sim-title h3 {
margin: 4px 0 5px;
color: #0f172a;
font-size: 19px;
font-weight: 900;
line-height: 1.25;
}
.risk-sim-title p {
margin: 0;
color: #475569;
font-size: 13px;
line-height: 1.55;
}
.risk-sim-icon-btn,
.risk-sim-tool-btn,
.risk-sim-send-btn {
display: grid;
place-items: center;
border: 1px solid #dbe5ef;
background: #fff;
color: #475569;
cursor: pointer;
}
.risk-sim-icon-btn {
width: 38px;
height: 38px;
border-radius: 12px;
}
.risk-sim-meta {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
padding: 10px 20px;
border-bottom: 1px solid #e5edf5;
background: #fff;
}
.risk-sim-meta span {
min-height: 28px;
display: inline-flex;
align-items: center;
padding: 0 10px;
border: 1px solid #e2e8f0;
border-radius: 999px;
background: #f8fafc;
color: #475569;
font-size: 12px;
font-weight: 800;
}
.risk-sim-meta .tone-low {
border-color: #bfdbfe;
background: #eff6ff;
color: #1d4ed8;
}
.risk-sim-meta .tone-medium {
border-color: #fed7aa;
background: #fff7ed;
color: #c2410c;
}
.risk-sim-meta .tone-high {
border-color: #fecaca;
background: #fef2f2;
color: #b91c1c;
}
.risk-sim-main {
min-height: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) 300px;
gap: 14px;
padding: 14px;
}
.risk-sim-dialog-panel,
.risk-sim-context-panel {
min-height: 0;
border: 1px solid #dfe8f1;
border-radius: 14px;
background: #fff;
}
.risk-sim-dialog-panel {
display: grid;
grid-template-rows: minmax(0, 1fr) auto auto;
overflow: hidden;
}
.risk-sim-message-list {
min-height: 0;
display: grid;
align-content: start;
gap: 14px;
overflow-y: auto;
padding: 18px;
background: #f8fafc;
}
.risk-sim-message-row {
display: grid;
grid-template-columns: 34px minmax(0, 1fr);
gap: 10px;
align-items: start;
}
.risk-sim-message-row.user {
grid-template-columns: minmax(0, 1fr) 34px;
}
.risk-sim-message-row.user .risk-sim-avatar {
grid-column: 2;
grid-row: 1;
}
.risk-sim-message-row.user .risk-sim-bubble {
grid-column: 1;
justify-self: end;
background: #0f766e;
color: #fff;
}
.risk-sim-message-row.user .risk-sim-bubble header,
.risk-sim-message-row.user .risk-sim-bubble p {
color: #fff;
}
.risk-sim-avatar {
width: 34px;
height: 34px;
display: grid;
place-items: center;
border: 1px solid #dbe5ef;
border-radius: 12px;
background: #fff;
color: #0f766e;
}
.risk-sim-message-row.user .risk-sim-avatar {
color: #2563eb;
}
.risk-sim-bubble {
max-width: min(100%, 760px);
padding: 11px 13px;
border: 1px solid #e2e8f0;
border-radius: 14px;
background: #fff;
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.05);
}
.risk-sim-bubble header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
color: #64748b;
font-size: 11px;
}
.risk-sim-bubble header strong {
color: #0f172a;
font-size: 12px;
font-weight: 850;
}
.risk-sim-bubble p {
margin: 0;
color: #1e293b;
font-size: 13px;
line-height: 1.65;
white-space: pre-wrap;
}
.risk-sim-thinking {
display: inline-flex;
align-items: center;
gap: 8px;
}
.risk-sim-message-files,
.risk-sim-file-strip div {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.risk-sim-message-files {
margin-top: 9px;
}
.risk-sim-message-files span,
.risk-sim-file-chip {
min-width: 0;
max-width: 260px;
display: inline-flex;
align-items: center;
gap: 5px;
min-height: 28px;
padding: 0 9px;
border: 1px solid rgba(191, 219, 254, 0.8);
border-radius: 999px;
background: #eff6ff;
color: #1d4ed8;
font-size: 12px;
font-weight: 750;
}
.risk-sim-message-files span {
color: #dbeafe;
border-color: rgba(219, 234, 254, 0.38);
background: rgba(255, 255, 255, 0.12);
}
.risk-sim-result-card {
margin-top: 12px;
overflow: hidden;
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #fff;
}
.risk-sim-result-card.high {
border-color: #fecaca;
}
.risk-sim-result-card.medium {
border-color: #fed7aa;
}
.risk-sim-result-card.low {
border-color: #bfdbfe;
}
.risk-sim-result-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 11px 12px;
border-bottom: 1px solid #edf2f7;
}
.risk-sim-result-head strong {
display: block;
margin-top: 3px;
color: #0f172a;
font-size: 14px;
font-weight: 900;
}
.risk-sim-result-head b {
padding: 5px 9px;
border-radius: 999px;
background: #f1f5f9;
color: #475569;
font-size: 12px;
}
.risk-sim-result-card.high .risk-sim-result-head b {
background: #fef2f2;
color: #b91c1c;
}
.risk-sim-result-card.medium .risk-sim-result-head b {
background: #fff7ed;
color: #c2410c;
}
.risk-sim-result-card.low .risk-sim-result-head b {
background: #eff6ff;
color: #1d4ed8;
}
.risk-sim-result-message {
padding: 10px 12px 0;
}
.risk-sim-blocking-message {
padding: 10px 12px 0;
color: #92400e;
}
.risk-sim-field-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
padding: 12px;
}
.risk-sim-field-grid div {
min-width: 0;
padding: 9px 10px;
border: 1px solid #edf2f7;
border-radius: 10px;
background: #f8fafc;
}
.risk-sim-field-grid span {
display: block;
color: #64748b;
font-size: 11px;
font-weight: 800;
}
.risk-sim-field-grid strong {
display: block;
margin-top: 4px;
overflow: hidden;
color: #0f172a;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.risk-sim-evidence {
display: grid;
gap: 6px;
padding: 0 12px 12px;
}
.risk-sim-missing-fields {
display: flex;
gap: 7px;
flex-wrap: wrap;
padding: 0 12px 12px;
}
.risk-sim-missing-fields span {
flex: 0 0 100%;
color: #92400e;
font-size: 12px;
font-weight: 850;
}
.risk-sim-missing-fields b {
min-height: 26px;
display: inline-flex;
align-items: center;
padding: 0 8px;
border: 1px solid #fed7aa;
border-radius: 999px;
background: #fff7ed;
color: #c2410c;
font-size: 11px;
font-weight: 850;
}
.risk-sim-evidence ul {
display: grid;
gap: 5px;
margin: 0;
padding-left: 18px;
color: #334155;
font-size: 12px;
line-height: 1.55;
}
.risk-sim-file-strip {
display: grid;
gap: 8px;
padding: 10px 14px;
border-top: 1px solid #e2e8f0;
background: #fff;
}
.risk-sim-file-chip {
cursor: pointer;
}
.risk-sim-file-chip span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.risk-sim-file-chip em {
color: inherit;
font-size: 11px;
font-style: normal;
opacity: 0.78;
white-space: nowrap;
}
.risk-sim-file-chip.pending {
border-color: #dbe5ef;
background: #f8fafc;
color: #475569;
}
.risk-sim-file-chip.recognizing {
border-color: #dbeafe;
background: #eff6ff;
color: #2563eb;
}
.risk-sim-file-chip.recognized {
border-color: #bbf7d0;
background: #ecfdf5;
color: #047857;
}
.risk-sim-file-chip.failed {
border-color: #fed7aa;
background: #fff7ed;
color: #c2410c;
}
.risk-sim-composer {
display: grid;
grid-template-columns: 42px minmax(0, 1fr) 42px;
gap: 10px;
align-items: center;
padding: 12px 14px 14px;
border-top: 1px solid #e2e8f0;
background: #fff;
}
.risk-sim-composer.text-only {
grid-template-columns: minmax(0, 1fr) 42px;
}
.risk-sim-file-input {
display: none;
}
.risk-sim-tool-btn,
.risk-sim-send-btn {
width: 42px;
height: 42px;
border-radius: 999px;
}
.risk-sim-send-btn {
border-color: #0f766e;
background: #0f766e;
color: #fff;
}
.risk-sim-tool-btn:disabled,
.risk-sim-send-btn:disabled,
.risk-sim-primary-btn:disabled,
.risk-sim-secondary-btn:disabled {
opacity: 0.48;
cursor: not-allowed;
}
.risk-sim-composer-shell {
min-height: 42px;
display: flex;
align-items: center;
border: 1px solid #cbd5e1;
border-radius: 999px;
background: #fff;
}
.risk-sim-composer-shell:focus-within {
border-color: rgba(15, 118, 110, 0.58);
box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.1);
}
.risk-sim-composer-shell textarea {
width: 100%;
min-height: 34px;
max-height: 110px;
resize: none;
border: 0;
padding: 8px 14px;
background: transparent;
color: #0f172a;
font-size: 13px;
line-height: 1.45;
}
.risk-sim-composer-shell textarea:focus {
outline: none;
}
.risk-sim-context-panel {
display: grid;
align-content: start;
gap: 12px;
padding: 14px;
}
.risk-sim-context-panel section {
display: grid;
gap: 7px;
padding: 12px;
border: 1px solid #edf2f7;
border-radius: 12px;
background: #f8fafc;
}
.risk-sim-step-list,
.risk-sim-recognition-list {
display: grid;
gap: 9px;
}
.risk-sim-step {
display: grid;
grid-template-columns: 28px minmax(0, 1fr);
gap: 9px;
align-items: flex-start;
padding: 9px;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #fff;
}
.risk-sim-step i {
width: 28px;
height: 28px;
display: grid;
place-items: center;
border-radius: 10px;
background: #f1f5f9;
color: #64748b;
}
.risk-sim-step.done i {
background: #ecfdf5;
color: #047857;
}
.risk-sim-step.running i {
background: #eff6ff;
color: #2563eb;
}
.risk-sim-step.warning i {
background: #fff7ed;
color: #c2410c;
}
.risk-sim-step strong {
color: #0f172a;
font-size: 12px;
font-weight: 900;
}
.risk-sim-step p,
.risk-sim-recognition-list p,
.risk-sim-recognition-list small {
margin: 2px 0 0;
color: #64748b;
font-size: 11px;
line-height: 1.45;
}
.risk-sim-recognition-list article {
min-width: 0;
padding: 9px;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #fff;
}
.risk-sim-recognition-list article strong {
display: block;
color: #0f172a;
font-size: 12px;
font-weight: 900;
}
.risk-sim-recognition-list small {
display: block;
}
.risk-sim-error-text {
color: #c2410c;
}
.risk-sim-context-panel strong {
color: #0f172a;
font-size: 14px;
font-weight: 900;
}
.risk-sim-context-panel p,
.risk-sim-field-list em {
margin: 0;
color: #475569;
font-size: 12px;
line-height: 1.55;
}
.risk-sim-field-list {
display: flex;
gap: 7px;
flex-wrap: wrap;
}
.risk-sim-field-list b {
max-width: 100%;
min-height: 26px;
display: inline-flex;
align-items: center;
padding: 0 8px;
overflow: hidden;
border: 1px solid #dbe5ef;
border-radius: 999px;
background: #fff;
color: #334155;
font-size: 11px;
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
}
.risk-sim-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border-top: 1px solid #dfe8f1;
background: #fff;
}
.risk-sim-foot > span {
color: #64748b;
font-size: 12px;
font-weight: 750;
}
.risk-sim-foot div {
display: flex;
align-items: center;
gap: 10px;
}
.risk-sim-primary-btn,
.risk-sim-secondary-btn {
min-height: 36px;
display: inline-flex;
align-items: center;
gap: 7px;
padding: 0 13px;
border-radius: 10px;
font-size: 13px;
font-weight: 850;
}
.risk-sim-primary-btn {
border: 1px solid #0f766e;
background: #0f766e;
color: #fff;
}
.risk-sim-secondary-btn {
border: 1px solid #dbe5ef;
background: #f8fafc;
color: #334155;
}
.risk-sim-dialog-enter-active,
.risk-sim-dialog-leave-active {
transition: opacity 0.18s ease;
}
.risk-sim-dialog-enter-from,
.risk-sim-dialog-leave-to {
opacity: 0;
}
@media (max-width: 920px) {
.risk-sim-main {
grid-template-columns: 1fr;
}
.risk-sim-context-panel {
display: none;
}
.risk-sim-field-grid {
grid-template-columns: 1fr;
}
.risk-sim-foot {
align-items: stretch;
flex-direction: column;
}
.risk-sim-foot div {
justify-content: flex-end;
}
}

View File

@@ -155,6 +155,53 @@
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.12);
}
.review-submit-test-state,
.risk-rule-action-confirm,
.risk-rule-action-note {
display: grid;
gap: 6px;
padding: 10px 12px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
}
.review-submit-test-state span,
.risk-rule-action-confirm span,
.risk-rule-action-note span {
color: #64748b;
font-size: 12px;
font-weight: 800;
}
.review-submit-test-state strong,
.risk-rule-action-confirm strong {
color: #b45309;
font-size: 13px;
font-weight: 850;
}
.review-submit-test-state strong.passed {
color: #047857;
}
.review-submit-test-state p {
margin: 0;
color: #64748b;
font-size: 12px;
line-height: 1.5;
}
.risk-rule-action-note textarea {
width: 100%;
padding: 10px 12px;
border: 1px solid #cbd5e1;
border-radius: 8px;
resize: vertical;
color: #0f172a;
font-size: 13px;
}
.review-submit-hint {
margin: 0;
padding: 10px 12px;
@@ -847,6 +894,41 @@
grid-column: 1 / -1;
}
.risk-rule-create-toggle {
display: flex !important;
align-items: flex-start;
gap: 10px;
padding: 12px;
border: 1px solid #dbe5ef;
border-radius: 10px;
background: #f8fafc;
}
.risk-rule-create-toggle input {
width: 16px;
height: 16px;
margin-top: 3px;
accent-color: #0f766e;
}
.risk-rule-create-toggle span {
display: grid;
gap: 3px;
min-width: 0;
}
.risk-rule-create-toggle strong {
color: #0f172a;
font-size: 13px;
font-weight: 850;
}
.risk-rule-create-toggle small {
color: #64748b;
font-size: 12px;
line-height: 1.5;
}
.risk-rule-create-form label span {
color: #475569;
font-size: 12px;
@@ -949,6 +1031,20 @@
color: #059669;
}
.minor-action.enable-action {
border-color: rgba(100, 116, 139, 0.26);
color: #64748b;
}
.minor-action.enable-action.is-on {
border-color: rgba(5, 150, 105, 0.26);
color: #059669;
}
.minor-action.enable-action i {
font-size: 18px;
}
.minor-action.danger-action {
border-color: rgba(220, 38, 38, 0.2);
color: #dc2626;
@@ -1009,6 +1105,16 @@
color: #fff;
}
.mini-btn.danger {
border-color: rgba(220, 38, 38, 0.24);
color: #dc2626;
}
.mini-btn.warning {
border-color: rgba(245, 158, 11, 0.28);
color: #b45309;
}
@media (max-width: 1320px) {
.detail-hero {
grid-template-columns: 1fr;
@@ -1325,16 +1431,18 @@
padding-bottom: 2px;
}
.json-risk-summary-card,
.json-risk-flow-card,
.json-risk-description-card {
background: #ffffff;
border: 1px solid #e2e8f0;
}
.json-risk-flow-card {
min-width: 0;
overflow: hidden;
}
.json-risk-description-card {
border-color: #fecdd3;
background: linear-gradient(180deg, #fffafb 0%, #ffffff 100%);
}
.json-risk-description-text {
margin: 0;
padding: 0 4px 8px;
@@ -1348,36 +1456,109 @@
.json-risk-description-source {
margin: 0;
padding: 8px 12px 4px;
border-top: 1px solid #ffe4e6;
border-top: 1px solid #e2e8f0;
color: #94a3b8;
font-size: 12px;
line-height: 1.5;
}
.json-risk-summary-grid {
.json-risk-meta-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
column-gap: 36px;
row-gap: 0;
}
.json-risk-summary-grid span {
min-height: 34px;
.json-risk-meta-item {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 12px;
border-radius: 10px;
background: #f8fafc;
color: #475569;
font-size: 12px;
align-items: center;
padding: 10px 0;
border-bottom: 1px solid #f1f5f9;
font-size: 13px;
min-height: 40px;
}
.json-risk-summary-grid strong {
.json-risk-meta-item.full-width {
grid-column: span 2;
}
.json-risk-meta-label {
width: 84px;
flex-shrink: 0;
color: #64748b;
font-weight: 500;
}
.json-risk-meta-value {
color: #0f172a;
font-weight: 600;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
min-width: 0;
}
.meta-value-hint {
color: #64748b;
font-size: 11.5px;
font-weight: 400;
margin-left: 2px;
}
.json-risk-meta-badge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.04em;
line-height: 1.2;
}
.json-risk-meta-badge.high {
background: #fef2f2;
color: #dc2626;
}
.json-risk-meta-badge.medium {
background: #fff7ed;
color: #ea580c;
}
.json-risk-meta-badge.low {
background: #ecfdf5;
color: #059669;
}
.json-risk-meta-badge.test-passed {
background: #ecfdf5;
color: #047857;
}
.json-risk-meta-badge.test-pending {
background: #fff7ed;
color: #b45309;
}
.meta-status-indicator {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12.5px;
font-weight: 600;
}
.meta-status-indicator .indicator-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #94a3b8;
}
.meta-status-indicator.is-active .indicator-dot {
background: #10b981;
box-shadow: 0 0 4px rgba(16, 185, 129, 0.4);
}
.json-risk-editor-toolbar {
@@ -1403,9 +1584,12 @@
@media (max-width: 760px) {
.risk-rule-create-form,
.json-risk-summary-grid {
.json-risk-meta-grid {
grid-template-columns: 1fr;
}
.json-risk-meta-item.full-width {
grid-column: span 1;
}
}
@media (max-width: 860px) {
@@ -1417,4 +1601,5 @@
.json-risk-editor-actions {
justify-content: flex-start;
}
}

View File

@@ -423,11 +423,11 @@
font-weight: 700;
}
table {
width: 100%;
min-width: 1120px;
border-collapse: collapse;
}
table {
width: 100%;
min-width: 1260px;
border-collapse: collapse;
}
th,
td {

View File

@@ -0,0 +1,720 @@
.documents-page {
height: 100%;
min-height: 0;
display: grid;
grid-template-rows: minmax(0, 1fr);
animation: fadeUp 220ms var(--ease) both;
overflow: hidden;
}
.documents-list {
min-height: 0;
display: grid;
grid-template-rows: auto auto minmax(0, 1fr) auto;
padding: 16px 18px;
overflow: hidden;
}
.status-tabs {
display: flex;
gap: 28px;
margin-top: 14px;
border-bottom: 1px solid #dbe4ee;
}
.status-tabs button {
position: relative;
min-height: 36px;
display: inline-flex;
align-items: center;
gap: 7px;
border: 0;
background: transparent;
color: #64748b;
font-size: 14px;
font-weight: 750;
}
.status-tabs button.active {
color: #059669;
}
.status-tabs button.active::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: -1px;
height: 3px;
border-radius: 999px 999px 0 0;
background: #10b981;
}
.scope-tab-badge {
min-width: 18px;
height: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 5px;
border-radius: 999px;
background: #ef4444;
color: #fff;
font-size: 11px;
font-weight: 850;
line-height: 1;
box-shadow: 0 6px 14px rgba(239, 68, 68, 0.22);
}
.document-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-top: 14px;
}
.filter-set,
.document-actions {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.list-search {
position: relative;
width: 280px;
}
.list-search .mdi {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #64748b;
font-size: 15px;
}
.list-search input {
width: 100%;
height: 38px;
padding: 0 12px 0 36px;
border: 1px solid #d7e0ea;
border-radius: 8px;
background: #fff;
color: #0f172a;
font-size: 13px;
transition: border-color 160ms ease, box-shadow 160ms ease;
}
.list-search input::placeholder {
color: #8da0b4;
}
.list-search input:focus {
border-color: #10b981;
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.14);
outline: none;
}
.filter-btn,
.page-size {
min-height: 38px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 9px;
padding: 0 14px;
border: 1px solid #d7e0ea;
border-radius: 8px;
background: #fff;
color: #334155;
font-size: 14px;
font-weight: 750;
white-space: nowrap;
}
.filter-btn {
min-width: 120px;
justify-content: space-between;
}
.filter-btn:hover,
.page-size:hover {
border-color: rgba(16, 185, 129, .32);
color: #0f9f78;
}
.document-filter,
.date-range-filter {
position: relative;
}
.document-filter-menu,
.date-range-popover,
.page-size-dropdown {
position: absolute;
z-index: 40;
border: 1px solid #d7e0ea;
border-radius: 10px;
background: #fff;
box-shadow: 0 16px 32px rgba(15, 23, 42, 0.12);
overflow: hidden;
}
.document-filter-menu {
top: calc(100% + 8px);
left: 0;
min-width: 150px;
max-height: 280px;
padding: 6px;
overflow-y: auto;
}
.document-filter-menu button,
.page-size-dropdown button {
display: block;
width: 100%;
min-height: 36px;
padding: 0 12px;
border: 0;
border-radius: 8px;
background: transparent;
color: #334155;
font-size: 13px;
font-weight: 650;
text-align: left;
white-space: nowrap;
}
.document-filter-menu button:hover,
.document-filter-menu button.active {
background: rgba(16, 185, 129, 0.1);
color: #047857;
}
.date-range-trigger {
min-width: 150px;
}
.date-range-label {
max-width: 104px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.date-range-popover {
top: calc(100% + 8px);
left: 0;
width: 320px;
display: grid;
gap: 14px;
padding: 16px;
}
.date-range-popover header,
.date-range-popover footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.date-range-popover header strong {
color: #0f172a;
font-size: 15px;
}
.date-range-popover header button {
width: 30px;
height: 30px;
display: grid;
place-items: center;
border: 0;
border-radius: 8px;
background: transparent;
color: #64748b;
}
.date-range-fields {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.date-range-fields label {
display: grid;
gap: 6px;
}
.date-range-fields span {
color: #64748b;
font-size: 12px;
font-weight: 700;
}
.date-range-fields input {
width: 100%;
height: 38px;
padding: 0 9px;
border: 1px solid #d7e0ea;
border-radius: 8px;
color: #0f172a;
font-size: 13px;
}
.ghost-btn,
.apply-btn {
height: 36px;
padding: 0 14px;
border-radius: 8px;
font-size: 13px;
font-weight: 750;
}
.ghost-btn {
border: 1px solid #d7e0ea;
background: #fff;
color: #334155;
}
.apply-btn {
border: 0;
background: #10b981;
color: #fff;
}
.apply-btn:disabled {
cursor: not-allowed;
background: #cbd5e1;
}
.create-request-btn {
min-height: 40px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 0 18px;
border: 0;
border-radius: 10px;
background: linear-gradient(135deg, #10b981, #059669);
color: #fff;
font-size: 14px;
font-weight: 800;
white-space: nowrap;
box-shadow: 0 10px 24px rgba(5, 150, 105, 0.2);
transition: transform 160ms ease, box-shadow 160ms ease, filter 160ms ease;
}
.create-request-btn.secondary {
border: 1px solid #d7e0ea;
background: #fff;
color: #334155;
box-shadow: none;
}
.create-request-btn:hover {
transform: translateY(-1px);
box-shadow: 0 14px 28px rgba(5, 150, 105, 0.24);
filter: saturate(1.02);
}
.create-request-btn.secondary:hover {
border-color: rgba(16, 185, 129, .32);
color: #047857;
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.08);
}
.document-status-filter {
display: inline-flex;
align-items: center;
justify-content: flex-start;
gap: 10px;
min-height: 38px;
}
.status-dropdown-filter {
min-width: 154px;
}
.status-filter-trigger {
min-width: 154px;
}
.status-filter-trigger > .mdi:first-child {
color: #10b981;
}
.status-filter-menu {
min-width: 154px;
}
.table-wrap {
min-height: 400px;
margin-top: 10px;
overflow-x: auto;
overflow-y: auto;
border: 1px solid #edf2f7;
border-radius: 10px;
background: linear-gradient(180deg, #fcfefd 0%, #f4f8f6 100%);
display: flex;
flex-direction: column;
}
.table-wrap.is-empty {
align-items: center;
justify-content: center;
}
.table-wrap table {
width: 100%;
align-self: flex-start;
}
.table-state {
width: 100%;
min-height: 260px;
display: grid;
place-items: center;
gap: 10px;
padding: 28px 20px;
text-align: center;
color: #64748b;
background: linear-gradient(180deg, #fcfffd 0%, #f5f9f7 100%);
}
.table-state .mdi {
font-size: 28px;
color: #10b981;
}
.table-state strong {
color: #0f172a;
font-size: 15px;
}
.table-state p {
max-width: 420px;
margin: 0;
font-size: 13px;
line-height: 1.6;
}
.table-state.error {
background: linear-gradient(180deg, #fffdfd 0%, #fff6f6 100%);
}
.table-state.error .mdi {
color: #ef4444;
}
.retry-btn {
height: 36px;
padding: 0 14px;
border: 1px solid #f1c5c5;
border-radius: 8px;
background: #fff;
color: #b91c1c;
font-size: 13px;
font-weight: 750;
}
table {
width: 100%;
min-width: 1320px;
border-collapse: collapse;
table-layout: fixed;
}
.col-id { width: 11%; }
.col-created { width: 10%; }
.col-stay { width: 9%; }
.col-doc-type { width: 9%; }
.col-scene { width: 10%; }
.col-title { width: 18%; }
.col-amount { width: 9%; }
.col-node { width: 12%; }
.col-status { width: 8%; }
.col-updated { width: 9%; }
th,
td {
padding: 13px 12px;
border-bottom: 1px solid #edf2f7;
color: #24324a;
font-size: 14px;
line-height: 1.35;
text-align: center;
vertical-align: middle;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
th {
position: sticky;
top: 0;
z-index: 1;
background: #f7fafc;
color: #64748b;
font-size: 13px;
font-weight: 800;
}
tbody tr {
cursor: pointer;
}
tbody tr:hover {
background: linear-gradient(90deg, rgba(16, 185, 129, .08), rgba(16, 185, 129, .03));
}
tbody tr:last-child td {
border-bottom: 0;
}
.doc-id {
color: #059669;
font-weight: 800;
}
.doc-kind-tag,
.type-tag,
.status-tag {
display: inline-flex;
align-items: center;
justify-content: center;
white-space: nowrap;
}
.doc-kind-tag {
min-height: 26px;
padding: 0 10px;
border-radius: 7px;
font-size: 12px;
font-weight: 800;
}
.doc-kind-tag.reimbursement {
background: #ecfdf5;
color: #059669;
}
.doc-kind-tag.application {
background: #eff6ff;
color: #2563eb;
}
.type-tag {
min-height: 26px;
padding: 0 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 800;
}
.type-tag.travel,
.type-tag.hotel,
.type-tag.transport {
background: #ecfdf5;
color: #047857;
}
.type-tag.entertainment,
.type-tag.meal {
background: #fff7ed;
color: #ea580c;
}
.type-tag.office {
background: #eff6ff;
color: #2563eb;
}
.type-tag.meeting,
.type-tag.training {
background: #eef2ff;
color: #4f46e5;
}
.type-tag.other {
background: #f8fafc;
color: #475569;
}
.status-tag {
min-height: 24px;
padding: 0 9px;
border: 1px solid transparent;
border-radius: 6px;
font-size: 12px;
font-weight: 750;
}
.status-tag.info {
border-color: #bfdbfe;
background: #eff6ff;
color: #2563eb;
}
.status-tag.success,
.status-tag.archived {
border-color: #bbf7d0;
background: #ecfdf5;
color: #059669;
}
.status-tag.warning,
.status-tag.draft {
border-color: #fed7aa;
background: #fff7ed;
color: #f97316;
}
.status-tag.danger {
border-color: #fecaca;
background: #fef2f2;
color: #dc2626;
}
.status-tag.neutral {
border-color: #cbd5e1;
background: #f8fafc;
color: #475569;
}
.list-foot {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 16px;
margin-top: 12px;
}
.page-summary {
color: #64748b;
font-size: 14px;
font-weight: 650;
}
.pager {
display: inline-flex;
justify-content: center;
gap: 6px;
padding: 4px;
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #f8fafc;
}
.pager button {
width: 32px;
height: 32px;
border: 0;
border-radius: 9px;
background: transparent;
color: #334155;
font-size: 14px;
font-weight: 800;
}
.pager button:hover:not(.active) {
background: #fff;
color: #059669;
box-shadow: 0 1px 4px rgba(15, 23, 42, .08);
}
.pager button.active {
background: #059669;
color: #fff;
box-shadow: 0 8px 16px rgba(5, 150, 105, .20);
}
.pager button:disabled {
color: #cbd5e1;
cursor: not-allowed;
}
.page-size-wrap {
position: relative;
justify-self: end;
}
.page-size {
min-width: 112px;
border-radius: 10px;
background: #fff;
box-shadow: 0 1px 2px rgba(15, 23, 42, .04);
}
.page-size-dropdown {
right: 0;
bottom: calc(100% + 6px);
display: grid;
}
.page-size-dropdown button {
border-radius: 0;
text-align: center;
padding: 0 20px;
}
.page-size-dropdown button:hover {
background: #f0fdf4;
color: #059669;
}
.page-size-dropdown button.active {
background: #059669;
color: #fff;
}
@media (max-width: 1200px) {
.document-toolbar,
.list-foot {
grid-template-columns: 1fr;
}
.document-toolbar {
align-items: stretch;
flex-direction: column;
}
.document-actions {
justify-content: flex-start;
}
}
@media (max-width: 760px) {
.documents-list {
padding: 16px;
}
.status-tabs {
gap: 18px;
overflow-x: auto;
}
.filter-set,
.document-actions,
.document-status-filter,
.list-search,
.filter-btn,
.page-size {
width: 100%;
}
.document-status-filter {
align-items: stretch;
flex-direction: column;
gap: 6px;
}
.list-foot {
display: grid;
justify-items: stretch;
}
}

View File

@@ -237,7 +237,7 @@
color: #475569;
font-size: 12px;
font-weight: 800;
text-align: left;
text-align: center;
white-space: nowrap;
}
@@ -260,11 +260,13 @@
border-bottom: 1px solid #eef3f8;
color: #0f172a;
font-size: 13px;
vertical-align: top;
text-align: center;
vertical-align: middle;
}
.summary-cell {
min-width: 220px;
text-align: left;
}
.summary-cell strong,
@@ -301,6 +303,7 @@
display: grid;
gap: 5px;
align-content: start;
justify-items: center;
}
.status-note {
@@ -632,52 +635,60 @@
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 56px;
padding: 4px 9px;
border-radius: 999px;
padding: 3px 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 800;
font-weight: 600;
white-space: nowrap;
border: 1px solid transparent;
}
.status-pill.success {
background: rgba(22, 163, 74, 0.12);
border-color: rgba(22, 163, 74, 0.2);
color: #166534;
}
.status-pill.warning {
background: rgba(245, 158, 11, 0.14);
border-color: rgba(245, 158, 11, 0.25);
color: #b45309;
}
.status-pill.danger {
background: rgba(239, 68, 68, 0.14);
border-color: rgba(239, 68, 68, 0.25);
color: #b91c1c;
}
.status-pill.muted {
background: rgba(148, 163, 184, 0.14);
border-color: rgba(148, 163, 184, 0.25);
color: #475569;
}
.status-pill.info,
.level-pill.info {
background: rgba(37, 99, 235, 0.12);
border-color: rgba(37, 99, 235, 0.25);
color: #1d4ed8;
}
.level-pill.warning {
background: rgba(245, 158, 11, 0.14);
border-color: rgba(245, 158, 11, 0.25);
color: #b45309;
}
.level-pill.danger {
background: rgba(239, 68, 68, 0.14);
border-color: rgba(239, 68, 68, 0.25);
color: #b91c1c;
}
.level-pill.muted {
background: rgba(148, 163, 184, 0.14);
border-color: rgba(148, 163, 184, 0.25);
color: #475569;
}

View File

@@ -84,6 +84,7 @@ const {
const sidebarMeta = {
overview: { label: '财务总览' },
workbench: { label: '个人工作台' },
documents: { label: '单据中心' },
requests: { label: '报销中心' },
approval: { label: '审批中心' },
archive: { label: '归档中心' },

View File

@@ -109,6 +109,16 @@
</div>
</template>
<template v-else-if="isDocuments">
<div class="kpi-chips">
<div v-for="kpi in documentKpis" :key="kpi.label" class="kpi-chip" :style="{ '--chip-color': kpi.color }">
<span class="chip-value">{{ kpi.value }}<small></small></span>
<span class="chip-label">{{ kpi.label }}</span>
<span class="chip-delta" :class="kpi.trend">{{ kpi.delta }} <i :class="kpi.arrow"></i></span>
</div>
</div>
</template>
<template v-else-if="isRequests">
<div class="kpi-chips">
<div v-for="kpi in requestKpis" :key="kpi.label" class="kpi-chip" :style="{ '--chip-color': kpi.color }">
@@ -192,6 +202,10 @@ const props = defineProps({
type: Object,
default: () => null
},
documentSummary: {
type: Object,
default: () => null
},
workbenchSummary: {
type: Object,
default: () => null
@@ -226,7 +240,8 @@ const emit = defineEmits([
const isChat = computed(() => props.activeView === 'chat')
const isOverview = computed(() => props.activeView === 'overview')
const isWorkbench = computed(() => props.activeView === 'workbench')
const isRequestDetail = computed(() => props.activeView === 'requests' && props.detailMode)
const isRequestDetail = computed(() => ['requests', 'documents'].includes(props.activeView) && props.detailMode)
const isDocuments = computed(() => props.activeView === 'documents' && !props.detailMode)
const isRequests = computed(() => props.activeView === 'requests')
const isLogs = computed(() => props.activeView === 'logs' && !props.logDetailMode)
const isApproval = computed(() => props.activeView === 'approval')
@@ -291,6 +306,21 @@ const requestKpis = computed(() => {
]
})
const documentKpis = computed(() => {
const summary = props.documentSummary ?? {}
const total = Number(summary.total ?? 0)
const toSubmit = Number(summary.toSubmit ?? 0)
const toProcess = Number(summary.toProcess ?? 0)
const archived = Number(summary.archived ?? 0)
return [
{ label: '全部单据', value: total, delta: '当前', trend: 'up', arrow: 'mdi mdi-minus', color: '#10b981' },
{ label: '待提交', value: toSubmit, delta: '草稿待办', trend: toSubmit > 0 ? 'down' : 'up', arrow: toSubmit > 0 ? 'mdi mdi-arrow-down' : 'mdi mdi-minus', color: '#f59e0b' },
{ label: '待我处理', value: toProcess, delta: '审批待办', trend: toProcess > 0 ? 'down' : 'up', arrow: toProcess > 0 ? 'mdi mdi-arrow-down' : 'mdi mdi-minus', color: '#3b82f6' },
{ label: '已归档', value: archived, delta: '归档入账', trend: 'up', arrow: 'mdi mdi-arrow-up', color: '#10b981' }
]
})
const logsKpis = computed(() => {
const summary = props.logsSummary ?? {}
const total = Number(summary.total ?? 0)

View File

@@ -0,0 +1,185 @@
<template>
<div class="expense-application-mask" @click.self="emit('close')">
<section class="expense-application-dialog" role="dialog" aria-modal="true" aria-labelledby="expense-application-title">
<header class="application-dialog-header">
<div>
<span class="application-dialog-eyebrow">费用申请</span>
<h2 id="expense-application-title">发起申请</h2>
</div>
<button class="dialog-icon-btn" type="button" aria-label="关闭" @click="emit('close')">
<i class="mdi mdi-close"></i>
</button>
</header>
<div class="application-dialog-body">
<section class="application-input-panel">
<label class="application-field-label" for="application-intent-input">申请意图</label>
<textarea
id="application-intent-input"
v-model="draft"
rows="5"
placeholder="例如申请下周去北京做客户现场验收差旅预算18000元"
></textarea>
<div class="application-example-row">
<button
v-for="example in APPLICATION_EXAMPLES"
:key="example"
class="application-example-chip"
type="button"
@click="applyExample(example)"
>
{{ example }}
</button>
</div>
<button class="primary-parse-btn" type="button" :disabled="parsing || !draft.trim()" @click="parseApplication">
<i :class="parsing ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-brain'"></i>
<span>{{ parsing ? '识别中' : '识别申请字段' }}</span>
</button>
<p v-if="error" class="application-error">{{ error }}</p>
</section>
<section class="application-ontology-panel">
<div class="ontology-panel-head">
<span>本体识别结果</span>
<strong>{{ confidenceLabel }}</strong>
</div>
<div v-if="!fields" class="ontology-empty-state">
<i class="mdi mdi-file-search-outline"></i>
<p>输入申请事项后系统会调用本体解析费用场景金额时间地点和附件要求</p>
</div>
<template v-else>
<div class="ontology-chip-row">
<span class="ontology-chip">scenario: {{ ontology?.scenario || '-' }}</span>
<span class="ontology-chip">intent: {{ ontology?.intent || '-' }}</span>
<span class="ontology-chip">{{ fields.documentTypeLabel }}</span>
</div>
<div class="application-field-grid">
<div class="application-field-card">
<span>费用场景</span>
<strong>{{ fields.expenseTypeLabel }}</strong>
</div>
<div class="application-field-card">
<span>申请金额</span>
<strong>{{ fields.amountDisplay }}</strong>
</div>
<div class="application-field-card">
<span>业务时间</span>
<strong>{{ fields.timeRange }}</strong>
</div>
<div class="application-field-card">
<span>业务地点</span>
<strong>{{ fields.location }}</strong>
</div>
<div class="application-field-card">
<span>申请人</span>
<strong>{{ fields.applicant }}</strong>
</div>
<div class="application-field-card">
<span>所属部门</span>
<strong>{{ fields.department }}</strong>
</div>
</div>
<div class="application-policy-strip" :class="fields.attachmentPolicy.level">
<i class="mdi mdi-paperclip-check"></i>
<div>
<strong>附件要求{{ fields.attachmentPolicy.label }}</strong>
<p>{{ fields.attachmentPolicy.description }}</p>
</div>
</div>
<div class="application-reason-block">
<span>申请事由</span>
<p>{{ fields.reason }}</p>
</div>
<div v-if="fields.missingSlots.length" class="application-missing-block">
<span>待补充字段</span>
<div>
<em v-for="slot in fields.missingSlots" :key="slot.key">{{ slot.label }}</em>
</div>
</div>
</template>
</section>
</div>
<footer class="application-dialog-footer">
<button class="secondary-btn" type="button" @click="emit('close')">取消</button>
<button class="confirm-btn" type="button" :disabled="!fields" @click="confirmApplication">
<i class="mdi mdi-check-circle-outline"></i>
<span>确认本体字段</span>
</button>
</footer>
</section>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { useSystemState } from '../../composables/useSystemState.js'
import { fetchOntologyParse } from '../../services/ontology.js'
import {
APPLICATION_EXAMPLES,
buildApplicationFieldsFromOntology,
buildExpenseApplicationOntologyContext
} from '../../utils/expenseApplicationOntology.js'
const emit = defineEmits(['close', 'confirmed'])
const { currentUser } = useSystemState()
const draft = ref(APPLICATION_EXAMPLES[0])
const parsing = ref(false)
const error = ref('')
const ontology = ref(null)
const fields = ref(null)
const confidenceLabel = computed(() => {
if (!ontology.value) return '待识别'
return `${Math.round(Number(ontology.value.confidence || 0) * 100)}%`
})
function applyExample(example) {
draft.value = example
}
async function parseApplication() {
const query = draft.value.trim()
if (!query) return
parsing.value = true
error.value = ''
try {
const payload = await fetchOntologyParse({
query,
user_id: currentUser.value?.username || currentUser.value?.name || 'anonymous',
context_json: buildExpenseApplicationOntologyContext(currentUser.value || {})
})
ontology.value = payload
fields.value = buildApplicationFieldsFromOntology(payload, query, currentUser.value || {})
} catch (err) {
ontology.value = null
fields.value = null
error.value = err?.message || '申请字段识别失败,请稍后重试。'
} finally {
parsing.value = false
}
}
function confirmApplication() {
if (!fields.value) return
emit('confirmed', {
ontology: ontology.value,
fields: fields.value,
sourceText: draft.value.trim()
})
}
</script>
<style scoped src="../../assets/styles/components/expense-application-dialog.css"></style>

View File

@@ -46,18 +46,115 @@
/>
<div
v-else
class="risk-rule-flow-svg"
role="img"
aria-label="风险规则流程说明"
v-html="displaySvg"
></div>
class="risk-rule-flow-svg-viewport"
@mousedown="onDragStart"
@touchstart="onTouchStart"
@dblclick="resetZoom"
>
<div
class="risk-rule-flow-svg-canvas"
:style="transformStyle"
v-html="displaySvg"
></div>
<div class="diagram-zoom-controls" @mousedown.stop @touchstart.stop>
<button class="zoom-btn" @click="zoomIn" title="放大">
<i class="mdi mdi-plus"></i>
</button>
<button class="zoom-btn" @click="zoomOut" title="缩小">
<i class="mdi mdi-minus"></i>
</button>
<button class="zoom-btn" @click="resetZoom" title="重置">
<i class="mdi mdi-arrow-expand-all"></i>
</button>
</div>
</div>
</div>
</div>
</section>
</template>
<script setup>
import { computed } from 'vue'
import { computed, ref, onUnmounted } from 'vue'
const scale = ref(1)
const translateX = ref(0)
const translateY = ref(0)
const isDragging = ref(false)
const dragStart = { x: 0, y: 0 }
const transformStyle = computed(() => ({
transform: `translate(${translateX.value}px, ${translateY.value}px) scale(${scale.value})`,
transformOrigin: 'center center',
transition: isDragging.value ? 'none' : 'transform 0.15s ease-out'
}))
function onDragStart(e) {
if (e.button !== 0) return
isDragging.value = true
dragStart.x = e.clientX - translateX.value
dragStart.y = e.clientY - translateY.value
window.addEventListener('mousemove', onDragging)
window.addEventListener('mouseup', onDragEnd)
}
function onDragging(e) {
if (!isDragging.value) return
translateX.value = e.clientX - dragStart.x
translateY.value = e.clientY - dragStart.y
}
function onDragEnd() {
isDragging.value = false
window.removeEventListener('mousemove', onDragging)
window.removeEventListener('mouseup', onDragEnd)
}
function onTouchStart(e) {
if (e.touches.length !== 1) return
isDragging.value = true
const touch = e.touches[0]
dragStart.x = touch.clientX - translateX.value
dragStart.y = touch.clientY - translateY.value
window.addEventListener('touchmove', onTouchMove, { passive: false })
window.addEventListener('touchend', onTouchEnd)
}
function onTouchMove(e) {
if (!isDragging.value || e.touches.length !== 1) return
e.preventDefault()
const touch = e.touches[0]
translateX.value = touch.clientX - dragStart.x
translateY.value = touch.clientY - dragStart.y
}
function onTouchEnd() {
isDragging.value = false
window.removeEventListener('touchmove', onTouchMove)
window.removeEventListener('touchend', onTouchEnd)
}
function zoomIn() {
scale.value = Math.min(scale.value + 0.15, 3)
}
function zoomOut() {
scale.value = Math.max(scale.value - 0.15, 0.4)
}
function resetZoom() {
scale.value = 1
translateX.value = 0
translateY.value = 0
}
onUnmounted(() => {
window.removeEventListener('mousemove', onDragging)
window.removeEventListener('mouseup', onDragEnd)
window.removeEventListener('touchmove', onTouchMove)
window.removeEventListener('touchend', onTouchEnd)
})
const props = defineProps({
svg: { type: String, default: '' },
@@ -68,8 +165,7 @@ const props = defineProps({
severityLabel: { type: String, default: '中风险' }
})
const FONT =
"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica Neue, 'PingFang SC', 'Microsoft YaHei', 'Microsoft JhengHei', 'SimHei', sans-serif"
const FONT = "Helvetica, Arial, sans-serif"
const TEXT = '#0d0d0d'
const MUTED = '#6e6e80'
const NEUTRAL_LINE = '#cbd5e1'
@@ -95,6 +191,16 @@ const PALETTES = {
}
}
const DRAWIO_PALETTES = {
neutral: { fill: '#ffffff', stroke: '#e2e8f0' },
blue: { fill: '#ffffff', stroke: '#e2e8f0' },
yellow: { fill: '#ffffff', stroke: '#e2e8f0' },
green: { fill: '#ffffff', stroke: '#e2e8f0' },
low: { fill: '#eff6ff', stroke: '#bfdbfe' },
medium: { fill: '#fff7ed', stroke: '#fed7aa' },
high: { fill: '#fef2f2', stroke: '#fecaca' }
}
function normalizeText(value, fallback = '') {
return String(value || fallback || '').trim()
}
@@ -156,15 +262,12 @@ function textLines(lines, x, y, anchor = 'middle', color = MUTED, fontSize = 13)
.join('')
}
function node(title, body, x, y, width, height, currentPalette = null) {
const border = currentPalette?.border || NEUTRAL_BORDER
const stripe = currentPalette?.accent || NEUTRAL_LINE
const surface = currentPalette?.surface || '#ffffff'
return `<g>
<rect x="${x}" y="${y}" width="${width}" height="${height}" rx="7" ry="7" fill="${surface}" stroke="${border}" stroke-width="1.2"/>
<rect x="${x}" y="${y}" width="3.5" height="${height}" rx="1.75" ry="1.75" fill="${stripe}"/>
<text x="${x + width / 2}" y="${y + 24}" text-anchor="middle" fill="${TEXT}" font-family="${FONT}" font-size="13" font-weight="600">${escapeSvg(title)}</text>
${textLines(wrapText(body, width <= 126 ? 10 : 11, 1), x + width / 2, y + 43, 'middle', MUTED, 11)}
function node(title, body, x, y, width, height, type = 'blue') {
const palette = DRAWIO_PALETTES[type] || DRAWIO_PALETTES.blue
return `<g class="drawio-node">
<rect x="${x}" y="${y}" width="${width}" height="${height}" rx="3" ry="3" fill="${palette.fill}" stroke="${palette.stroke}" stroke-width="1.2" filter="url(#shadow)"/>
<text x="${x + width / 2}" y="${y + 24}" text-anchor="middle" fill="#0f172a" font-family="${FONT}" font-size="13" font-weight="600">${escapeSvg(title)}</text>
${textLines(wrapText(body, width <= 126 ? 10 : 11, 1), x + width / 2, y + 43, 'middle', '#475569', 11)}
</g>`
}
@@ -172,18 +275,19 @@ function diamond(title, body, x, y, width, height) {
const cx = x + width / 2
const cy = y + height / 2
const points = `${cx},${y} ${x + width},${cy} ${cx},${y + height} ${x},${cy}`
return `<g>
<polygon points="${points}" fill="#ffffff" stroke="${NEUTRAL_BORDER}" stroke-width="1.25"/>
<text x="${cx}" y="${cy - 10}" text-anchor="middle" fill="${TEXT}" font-family="${FONT}" font-size="12.5" font-weight="600">${escapeSvg(title)}</text>
${textLines(wrapText(body, 8, 2), cx, cy + 11, 'middle', MUTED, 10.2)}
const palette = DRAWIO_PALETTES.yellow
return `<g class="drawio-node">
<polygon points="${points}" fill="${palette.fill}" stroke="${palette.stroke}" stroke-width="1.2" filter="url(#shadow)"/>
<text x="${cx}" y="${cy - 8}" text-anchor="middle" fill="#0f172a" font-family="${FONT}" font-size="12.5" font-weight="600">${escapeSvg(title)}</text>
${textLines(wrapText(body, 8, 2), cx, cy + 12, 'middle', '#475569', 10.2)}
</g>`
}
function note(body) {
return `<g>
<rect x="214" y="218" width="290" height="36" rx="7" ry="7" fill="#ffffff" stroke="${NEUTRAL_BORDER}" stroke-width="1" stroke-dasharray="4,3"/>
<text x="226" y="240" fill="${MUTED}" font-family="${FONT}" font-size="10" font-weight="500">BASIS</text>
${textLines(wrapText(body, 22, 1), 268, 240, 'start', TEXT, 10.2)}
return `<g class="drawio-node note">
<rect x="214" y="218" width="290" height="36" rx="3" ry="3" fill="#ffffff" stroke="#cbd5e1" stroke-width="1.2" stroke-dasharray="3,3" filter="url(#shadow)"/>
<text x="226" y="240" fill="#64748b" font-family="${FONT}" font-size="10.5" font-weight="700">BASIS</text>
${textLines(wrapText(body, 22, 1), 272, 240, 'start', '#334155', 10.5)}
</g>`
}
@@ -262,29 +366,47 @@ const displaySvg = computed(() => {
}
const flow = flowModel.value
const currentPalette = palette.value
const severity = props.severity
return `<svg xmlns="http://www.w3.org/2000/svg" width="760" height="280" viewBox="0 0 760 280" data-risk-flow-style="review-node-only" role="img" aria-label="风险规则流程说明">
<defs>
<marker id="arrow-neutral" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="${NEUTRAL_LINE}"/>
<pattern id="grid" width="16" height="16" patternUnits="userSpaceOnUse">
<path d="M 16 0 L 0 0 0 16" fill="none" stroke="#e8ecef" stroke-width="0.75"/>
</pattern>
<marker id="arrow" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
<path d="M 0 0 L 8 3 L 0 6 Z" fill="#666666"/>
</marker>
<filter id="shadow" x="-10%" y="-10%" width="120%" height="120%">
<feDropShadow dx="1" dy="2" stdDeviation="1.5" flood-color="#000000" flood-opacity="0.08" />
</filter>
</defs>
<rect width="760" height="280" fill="#ffffff"/>
<rect x="18" y="18" width="724" height="244" rx="8" ry="8" fill="none" stroke="${NEUTRAL_BORDER}" stroke-width="1" stroke-dasharray="4,3"/>
<text x="34" y="43" fill="${MUTED}" font-family="${FONT}" font-size="11" font-weight="500">RULE FLOW</text>
${node('业务输入', flow.start, 48, 118, 124, 60)}
${node('字段取数', '读取字段证据', 214, 118, 132, 60)}
<rect width="760" height="280" fill="url(#grid)"/>
<rect x="0.5" y="0.5" width="759.5" height="279.5" rx="6" fill="none" stroke="#cbd5e1" stroke-width="1"/>
<text x="34" y="43" fill="#94a3b8" font-family="${FONT}" font-size="10.5" font-weight="700" letter-spacing="0.05em">RULE FLOW CANVAS</text>
${node('业务输入', flow.start, 48, 118, 124, 60, 'neutral')}
${node('字段取数', '读取字段证据', 214, 118, 132, 60, 'blue')}
${diamond('判断依据', flow.decision, 392, 92, 112, 112)}
${node('继续流转', flow.pass, 562, 74, 126, 60)}
${node('进入复核', flow.fail, 562, 190, 126, 62, currentPalette)}
${node('继续流转', flow.pass, 562, 74, 126, 60, 'green')}
${node('进入复核', flow.fail, 562, 190, 126, 62, severity)}
${note(flow.basis)}
<line x1="172" y1="148" x2="214" y2="148" stroke="${NEUTRAL_LINE}" stroke-width="1.45" marker-end="url(#arrow-neutral)"/>
<line x1="346" y1="148" x2="392" y2="148" stroke="${NEUTRAL_LINE}" stroke-width="1.45" marker-end="url(#arrow-neutral)"/>
<path d="M 504 127 L 532 127 L 532 104 L 562 104" fill="none" stroke="${NEUTRAL_LINE}" stroke-width="1.35" marker-end="url(#arrow-neutral)"/>
<text x="534" y="119" text-anchor="middle" fill="${MUTED}" font-family="${FONT}" font-size="10.5" font-weight="400">否</text>
<path d="M 504 169 L 532 169 L 532 221 L 562 221" fill="none" stroke="${NEUTRAL_LINE}" stroke-width="1.8" marker-end="url(#arrow-neutral)"/>
<text x="534" y="195" text-anchor="middle" fill="${MUTED}" font-family="${FONT}" font-size="10.5" font-weight="600">是</text>
<line x1="172" y1="148" x2="214" y2="148" stroke="#666666" stroke-width="1.5" marker-end="url(#arrow)"/>
<line x1="346" y1="148" x2="392" y2="148" stroke="#666666" stroke-width="1.5" marker-end="url(#arrow)"/>
<path d="M 504 127 L 532 127 L 532 104 L 562 104" fill="none" stroke="#666666" stroke-width="1.5" marker-end="url(#arrow)"/>
<g>
<rect x="521" y="108" width="22" height="15" fill="#ffffff" stroke="#cbd5e1" stroke-width="1" rx="2"/>
<text x="532" y="120" text-anchor="middle" fill="#475569" font-family="${FONT}" font-size="10" font-weight="bold">否</text>
</g>
<path d="M 504 169 L 532 169 L 532 221 L 562 221" fill="none" stroke="#666666" stroke-width="1.5" marker-end="url(#arrow)"/>
<g>
<rect x="521" y="187" width="22" height="15" fill="#ffffff" stroke="#cbd5e1" stroke-width="1" rx="2"/>
<text x="532" y="199" text-anchor="middle" fill="#475569" font-family="${FONT}" font-size="10" font-weight="bold">是</text>
</g>
</svg>`
})
</script>
@@ -294,10 +416,9 @@ const displaySvg = computed(() => {
width: 100%;
min-height: 0;
overflow: hidden;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #ffffff;
padding: 14px;
border: none;
background: transparent;
padding: 0;
cursor: default;
}
@@ -442,30 +563,84 @@ const displaySvg = computed(() => {
justify-content: flex-start;
}
.risk-rule-flow-image,
.risk-rule-flow-svg {
.risk-rule-flow-svg-viewport {
position: relative;
width: 100%;
height: 280px;
overflow: hidden;
border-radius: 6px;
background: #ffffff;
border: 1px solid #e2e8f0;
display: flex;
align-items: center;
justify-content: center;
user-select: none;
cursor: grab;
}
.risk-rule-flow-svg-viewport:active {
cursor: grabbing;
}
.risk-rule-flow-svg-canvas {
width: 760px;
height: 280px;
flex-shrink: 0;
}
.risk-rule-flow-svg-canvas :deep(svg) {
width: 100%;
height: 100%;
display: block;
}
.diagram-zoom-controls {
position: absolute;
bottom: 12px;
right: 12px;
display: flex;
flex-direction: column;
gap: 6px;
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(8px);
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
z-index: 10;
}
.zoom-btn {
width: 28px;
height: 28px;
display: grid;
place-items: center;
border: none;
border-radius: 6px;
background: transparent;
color: #475569;
cursor: pointer;
transition: all 0.2s ease;
}
.zoom-btn:hover {
background: #f1f5f9;
color: #0f172a;
}
.zoom-btn i {
font-size: 16px;
}
.risk-rule-flow-image {
width: min(760px, 100%);
display: block;
pointer-events: none;
user-select: none;
}
.risk-rule-flow-image {
height: auto;
object-fit: contain;
}
.risk-rule-flow-svg :deep(svg) {
width: 100%;
height: auto;
display: block;
}
.risk-rule-flow-svg :deep(*) {
pointer-events: none !important;
user-select: none;
}
@media (max-width: 980px) {
.risk-rule-flow-content {
grid-template-columns: 1fr;

View File

@@ -0,0 +1,792 @@
<template>
<Transition name="risk-sim-dialog">
<div v-if="open" class="risk-sim-backdrop" @click.self="handleClose">
<section class="risk-sim-modal" role="dialog" aria-modal="true" aria-label="风险规则仿真测试">
<header class="risk-sim-head">
<div class="risk-sim-title">
<span>独立仿真测试</span>
<h3>{{ rule?.name || '风险规则' }}</h3>
<p>临时对话只做单据识别和风险规则执行不创建报销单不写入主工作台会话</p>
</div>
<button type="button" class="risk-sim-icon-btn" aria-label="关闭" @click="handleClose">
<i class="mdi mdi-close"></i>
</button>
</header>
<div class="risk-sim-meta">
<span>版本{{ testVersion }}</span>
<span :class="severityTone">{{ rule?.riskRuleSeverityLabel || '中风险' }}</span>
<span>{{ requiresAttachment ? '测试需附件' : '文字测试' }}</span>
<span>{{ latestSummary?.test_passed ? '测试结论已保存' : '测试结论未保存' }}</span>
<span>Session{{ sessionShortId }}</span>
</div>
<div class="risk-sim-main">
<section class="risk-sim-dialog-panel">
<div ref="messageListRef" class="risk-sim-message-list" aria-live="polite">
<article
v-for="message in messages"
:key="message.id"
class="risk-sim-message-row"
:class="message.role"
>
<span class="risk-sim-avatar" aria-hidden="true">
<i :class="message.role === 'assistant' ? 'mdi mdi-shield-search-outline' : 'mdi mdi-account-outline'"></i>
</span>
<div class="risk-sim-bubble">
<header>
<strong>{{ message.role === 'assistant' ? '风险仿真助手' : '我' }}</strong>
<time>{{ message.time }}</time>
</header>
<p v-if="message.text">{{ message.text }}</p>
<div v-if="message.attachments?.length" class="risk-sim-message-files">
<span v-for="file in message.attachments" :key="`${message.id}-${file.id}`">
<i class="mdi mdi-file-document-outline"></i>
{{ file.name }}
</span>
</div>
<div v-if="message.result" class="risk-sim-result-card" :class="message.result.severity">
<div class="risk-sim-result-head">
<div>
<span>{{ message.result.ready === false ? '流程状态' : '识别结果' }}</span>
<strong>
{{ message.result.ready === false ? '待补充后再判断' : (message.result.hit ? '命中风险' : '未命中风险') }}
</strong>
</div>
<b>{{ message.result.severity_label }}</b>
</div>
<p v-if="message.result.blocking_reason" class="risk-sim-blocking-message">
{{ message.result.blocking_reason }}
</p>
<p v-if="message.result.message" class="risk-sim-result-message">
{{ message.result.message }}
</p>
<div class="risk-sim-field-grid">
<div v-for="item in buildResultFields(message.result)" :key="item.key">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</div>
</div>
<div v-if="buildEvidenceItems(message.result).length" class="risk-sim-evidence">
<span>判断依据</span>
<ul>
<li v-for="item in buildEvidenceItems(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">
{{ formatFieldLabel(field) }}
</b>
</div>
</div>
</div>
</article>
<article v-if="busyAction === 'simulate' || recognitionBusy" class="risk-sim-message-row assistant">
<span class="risk-sim-avatar" aria-hidden="true">
<i class="mdi mdi-shield-search-outline"></i>
</span>
<div class="risk-sim-bubble">
<header>
<strong>风险仿真助手</strong>
<time>{{ currentTime }}</time>
</header>
<p class="risk-sim-thinking">
<i class="mdi mdi-loading mdi-spin"></i>
{{ recognitionBusy ? '正在识别临时单据...' : '正在调用规则执行器识别风险...' }}
</p>
</div>
</article>
</div>
<div v-if="requiresAttachment && uploadedFiles.length" class="risk-sim-file-strip">
<span>本轮临时附件</span>
<div>
<button
v-for="file in uploadedFiles"
:key="file.id"
type="button"
class="risk-sim-file-chip"
:class="file.status"
:title="`${file.name} · ${formatFileSize(file.size)}`"
@click="removeFile(file.id)"
>
<i class="mdi mdi-file-document-outline"></i>
<span>{{ file.name }}</span>
<em>{{ resolveFileStatusLabel(file) }}</em>
<i class="mdi mdi-close"></i>
</button>
</div>
</div>
<footer class="risk-sim-composer" :class="{ 'text-only': !requiresAttachment }">
<input
v-if="requiresAttachment"
ref="fileInputRef"
class="risk-sim-file-input"
type="file"
multiple
@change="handleFileChange"
/>
<button
v-if="requiresAttachment"
type="button"
class="risk-sim-tool-btn"
:disabled="busy"
aria-label="上传临时单据"
@click="triggerFilePick"
>
<i class="mdi mdi-paperclip"></i>
</button>
<div class="risk-sim-composer-shell">
<textarea
ref="composerRef"
v-model="draft"
:disabled="busy"
rows="1"
:placeholder="composerPlaceholder"
@keydown.enter.exact.prevent="sendMessage"
></textarea>
</div>
<button
type="button"
class="risk-sim-send-btn"
:disabled="busy || !canSend"
:title="sendBlockedReason"
aria-label="执行风险识别"
@click="sendMessage"
>
<i :class="busyAction === 'simulate' ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-send'"></i>
</button>
</footer>
</section>
<aside class="risk-sim-context-panel">
<section>
<span>仿真流程</span>
<div class="risk-sim-step-list">
<div
v-for="step in simulationSteps"
:key="step.key"
class="risk-sim-step"
:class="step.status"
>
<i :class="step.icon"></i>
<div>
<strong>{{ step.label }}</strong>
<p>{{ step.description }}</p>
</div>
</div>
</div>
</section>
<section v-if="recognizedDocuments.length || recognitionError">
<span>单据识别</span>
<div class="risk-sim-recognition-list">
<article v-for="item in recognizedDocuments" :key="item.filename">
<strong>{{ item.document_type_label || item.scene_label || '已识别单据' }}</strong>
<p>{{ item.filename }}</p>
<small>{{ buildDocumentBrief(item) }}</small>
</article>
<p v-if="recognitionError" class="risk-sim-error-text">{{ recognitionError }}</p>
</div>
</section>
<section>
<span>规则边界</span>
<strong>{{ requiresAttachment ? '附件和文字合并判断' : '仅使用文字事实判断' }}</strong>
<p>{{ boundaryDescription }}</p>
</section>
<section>
<span>使用字段</span>
<div class="risk-sim-field-list">
<b v-for="field in displayFields" :key="field.key">{{ field.label }}</b>
<em v-if="!displayFields.length">当前规则未声明字段</em>
</div>
</section>
<section>
<span>关闭后清理</span>
<p>聊天记录临时附件识别结果会从弹窗内存中清空</p>
</section>
</aside>
</div>
<footer class="risk-sim-foot">
<span>{{ lastSimulationHint }}</span>
<div>
<button type="button" class="risk-sim-secondary-btn" :disabled="busy" @click="resetConversation">
<i class="mdi mdi-refresh"></i>
<span>清空本轮</span>
</button>
<button
type="button"
class="risk-sim-primary-btn"
:disabled="busy || !activeSimulationResult?.ready"
@click="saveSimulationConclusion"
>
<i :class="busyAction === 'report' ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-check-decagram-outline'"></i>
<span>{{ busyAction === 'report' ? '保存中' : '确认测试通过' }}</span>
</button>
</div>
</footer>
</section>
</div>
</Transition>
</template>
<script setup>
import { computed, nextTick, ref, watch } from 'vue'
import {
confirmRiskRuleTestReport,
runRiskRuleSampleTest,
simulateRiskRuleTest
} from '../../services/agentAssets.js'
import { recognizeOcrFiles } from '../../services/ocr.js'
import { useToast } from '../../composables/useToast.js'
import {
createId,
formatFileSize,
formatTestError,
formatTime
} from './riskRuleTestDialogUtils.js'
const props = defineProps({
open: {
type: Boolean,
default: false
},
rule: {
type: Object,
default: null
}
})
const emit = defineEmits(['close', 'report-saved'])
const { toast } = useToast()
const messageListRef = ref(null)
const fileInputRef = ref(null)
const composerRef = ref(null)
const sessionId = ref('')
const draft = ref('')
const messages = ref([])
const uploadedFiles = ref([])
const recognizedDocuments = ref([])
const recognitionError = ref('')
const activeSimulationResult = ref(null)
const latestSummary = ref(null)
const busyAction = ref('')
const requiresAttachment = computed(() => Boolean(props.rule?.riskRuleRequiresAttachment))
const recognitionBusy = computed(() => busyAction.value === 'recognize')
const busy = computed(() => Boolean(busyAction.value))
const hasPendingFiles = computed(() => uploadedFiles.value.some((file) => file.status === 'recognizing'))
const hasRecognizedFiles = computed(() => uploadedFiles.value.some((file) => file.status === 'recognized'))
const hasFailedOnlyFiles = computed(() => uploadedFiles.value.length > 0 && uploadedFiles.value.every((file) => file.status === 'failed'))
const canSend = computed(() => {
if (hasPendingFiles.value) return false
const hasText = Boolean(draft.value.trim())
if (requiresAttachment.value) {
return hasText && uploadedFiles.value.length > 0
}
return hasText
})
const fields = computed(() => (Array.isArray(props.rule?.riskRuleFields) ? props.rule.riskRuleFields : []))
const displayFields = computed(() => fields.value.map((field) => ({
key: field.key,
label: formatFieldLabel(field)
})))
const composerPlaceholder = computed(() => requiresAttachment.value
? '填写测试意图并上传附件,例如:请检查这张酒店发票是否与行程城市一致'
: '描述测试事实例如酒店发票城市上海申报目的地北京金额580元')
const boundaryDescription = computed(() => requiresAttachment.value
? '附件选择后不会立即识别,点击发送时才会和输入内容一起进入仿真;不会创建报销草稿或写入风险标记。'
: '这条规则不需要上传附件,测试窗口只根据输入文字执行规则;不会创建报销草稿或影响审批流。')
const testVersion = computed(() => props.rule?.workingVersion || props.rule?.displayVersion || props.rule?.version || '-')
const sessionShortId = computed(() => sessionId.value ? sessionId.value.slice(-8).toUpperCase() : '-')
const currentTime = computed(() => formatTime())
const severityTone = computed(() => `tone-${props.rule?.riskRuleSeverity || 'medium'}`)
const sendBlockedReason = computed(() => {
if (hasPendingFiles.value) return '单据识别中,请稍后执行风险识别。'
if (requiresAttachment.value && !uploadedFiles.value.length) return '这条规则要求上传测试附件。'
if (requiresAttachment.value && !draft.value.trim()) return '请填写测试意图或关键事实,附件会和文字一起判断。'
if (!draft.value.trim()) return '请先描述测试单据或测试事实。'
return ''
})
const simulationSteps = computed(() => [
{
key: 'recognize',
label: '1. 单据识别',
description: buildRecognitionStepDescription(),
status: resolveRecognitionStepStatus(),
icon: recognitionBusy.value ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-text-recognition'
},
{
key: 'fields',
label: '2. 字段确认',
description: buildFieldStepDescription(),
status: resolveFieldStepStatus(),
icon: 'mdi mdi-format-list-checks'
},
{
key: 'execute',
label: '3. 规则执行',
description: activeSimulationResult.value?.ready
? '已使用规则执行器完成判断。'
: '字段齐备后才会执行规则。',
status: activeSimulationResult.value?.ready ? 'done' : 'pending',
icon: 'mdi mdi-shield-check-outline'
}
])
const lastSimulationHint = computed(() => {
if (!activeSimulationResult.value) {
return '本窗口是独立临时会话,关闭后会清空聊天记录和上传文件。'
}
if (activeSimulationResult.value.ready === false) {
return `最近一次仿真:${activeSimulationResult.value.summary || '待补充字段'}`
}
return activeSimulationResult.value.hit
? `最近一次仿真:命中${activeSimulationResult.value.severity_label}`
: '最近一次仿真:未命中风险'
})
watch(
() => props.open,
(open) => {
if (open) {
initializeSession()
} else {
destroySession()
}
},
{ immediate: true }
)
function initializeSession() {
sessionId.value = createId()
latestSummary.value = props.rule?.latestTestSummary || null
draft.value = ''
uploadedFiles.value = []
recognizedDocuments.value = []
recognitionError.value = ''
activeSimulationResult.value = null
busyAction.value = ''
messages.value = [buildMessage('assistant', buildWelcomeMessage())]
clearFileInput()
nextTick(() => {
scrollMessagesToBottom()
composerRef.value?.focus()
})
}
function destroySession() {
draft.value = ''
messages.value = []
uploadedFiles.value = []
recognizedDocuments.value = []
recognitionError.value = ''
activeSimulationResult.value = null
busyAction.value = ''
sessionId.value = ''
clearFileInput()
}
function resetConversation() {
initializeSession()
}
function handleClose() {
destroySession()
emit('close')
}
function triggerFilePick() {
if (!requiresAttachment.value) return
fileInputRef.value?.click()
}
function handleFileChange(event) {
if (!requiresAttachment.value) {
clearFileInput()
return
}
const input = event.target
const incoming = Array.from(input?.files || [])
if (!incoming.length) return
const nextFiles = incoming.map((file) => ({
id: createId(),
name: file.name,
size: file.size,
contentType: file.type || '',
status: 'pending',
statusText: '待发送',
ocrDocument: null,
error: '',
file
}))
uploadedFiles.value = [...uploadedFiles.value, ...nextFiles].slice(0, 12)
clearFileInput()
}
function removeFile(fileId) {
uploadedFiles.value = uploadedFiles.value.filter((file) => file.id !== fileId)
}
async function sendMessage() {
if (!props.rule?.id || !canSend.value || busy.value) return
const activeSessionId = sessionId.value
const text = draft.value.trim()
const runFiles = requiresAttachment.value ? uploadedFiles.value.slice() : []
messages.value.push(buildMessage('user', text, { attachments: runFiles }))
draft.value = ''
await nextTick()
scrollMessagesToBottom()
try {
let attachments = []
if (requiresAttachment.value) {
const filesForRecognition = runFiles.filter((file) => file.status !== 'recognized')
if (filesForRecognition.length) {
busyAction.value = 'recognize'
await recognizeTemporaryFiles(filesForRecognition, activeSessionId)
if (!isActiveSession(activeSessionId)) return
}
attachments = runFiles.map(toAttachmentPayload)
}
busyAction.value = 'simulate'
const result = await simulateRiskRuleTest(props.rule.id, {
version: testVersion.value,
message: text,
attachments
})
if (!isActiveSession(activeSessionId)) return
activeSimulationResult.value = result
messages.value.push(buildMessage('assistant', result.summary, { result }))
} catch (error) {
if (!isActiveSession(activeSessionId)) return
messages.value.push(buildMessage('assistant', formatTestError(error, '仿真识别失败,请稍后重试。')))
} finally {
if (isActiveSession(activeSessionId)) {
busyAction.value = ''
await nextTick()
scrollMessagesToBottom()
composerRef.value?.focus()
}
}
}
async function saveSimulationConclusion() {
if (!props.rule?.id || !activeSimulationResult.value?.ready || busy.value) return
const activeSessionId = sessionId.value
busyAction.value = 'report'
try {
const sample = await runRiskRuleSampleTest(props.rule.id, {
version: testVersion.value,
cases: []
})
if (!sample?.passed) {
messages.value.push(buildMessage('assistant', '系统样例复核未通过,暂不能保存测试通过结论。请调整规则后重新仿真。'))
return
}
const report = await confirmRiskRuleTestReport(props.rule.id, {
version: testVersion.value,
confirm_passed: true,
note: '通过独立对话仿真后确认测试通过;聊天记录和临时附件不保存。'
})
if (!isActiveSession(activeSessionId)) return
latestSummary.value = {
...(latestSummary.value || {}),
sample,
report,
test_passed: true
}
emit('report-saved', latestSummary.value)
messages.value.push(buildMessage('assistant', '测试通过结论已保存。这个动作只更新规则生命周期状态,不保存本轮聊天记录或上传文件。'))
} catch (error) {
if (!isActiveSession(activeSessionId)) return
messages.value.push(buildMessage('assistant', formatTestError(error, '测试结论保存失败,请稍后重试。')))
} finally {
if (isActiveSession(activeSessionId)) {
busyAction.value = ''
await nextTick()
scrollMessagesToBottom()
}
}
}
async function recognizeTemporaryFiles(files, activeSessionId) {
if (!files.length) return
recognitionError.value = ''
files.forEach((file) => {
const target = uploadedFiles.value.find((item) => item.id === file.id)
if (!target) return
target.status = 'recognizing'
target.statusText = '识别中'
target.error = ''
})
try {
const payload = await recognizeOcrFiles(files.map((file) => file.file), {
timeoutMs: 90000,
timeoutMessage: '单据 OCR 识别超时,请补充关键字段后再执行规则。'
})
if (!isActiveSession(activeSessionId)) return
const documents = normalizeOcrDocuments(payload)
recognizedDocuments.value = mergeRecognizedDocuments(recognizedDocuments.value, documents)
files.forEach((file, index) => {
const document = documents[index] || null
const target = uploadedFiles.value.find((item) => item.id === file.id)
if (!target) return
if (document && documentHasMeaningfulText(document)) {
target.status = 'recognized'
target.statusText = '已识别'
target.ocrDocument = document
target.error = ''
} else {
target.status = 'failed'
target.statusText = '识别不足'
target.ocrDocument = document
target.error = '未提取到足够文本'
}
})
const recognizedCount = files.filter((file) => {
const target = uploadedFiles.value.find((item) => item.id === file.id)
return target?.status === 'recognized'
}).length
messages.value.push(buildMessage(
'assistant',
recognizedCount
? `已完成 ${recognizedCount} 份临时单据识别。请核对右侧识别字段,字段不足时可以直接在输入框补充。`
: '上传文件没有提取到足够字段,暂不能直接执行规则。请在输入框补充票据城市、金额、发票号等关键信息。'
))
} catch (error) {
if (!isActiveSession(activeSessionId)) return
recognitionError.value = formatTestError(error, '单据识别失败,请补充关键字段后再执行规则。')
files.forEach((file) => {
const target = uploadedFiles.value.find((item) => item.id === file.id)
if (!target) return
target.status = 'failed'
target.statusText = '识别失败'
target.error = recognitionError.value
})
messages.value.push(buildMessage('assistant', recognitionError.value))
} finally {
if (isActiveSession(activeSessionId)) {
await nextTick()
scrollMessagesToBottom()
}
}
}
function buildMessage(role, text, extra = {}) {
return {
id: createId(),
role,
text,
time: formatTime(),
...extra
}
}
function buildResultFields(result) {
const values = result?.field_values && typeof result.field_values === 'object'
? result.field_values
: {}
return Object.entries(values).slice(0, 8).map(([key, value]) => ({
key,
label: formatFieldLabel(fields.value.find((field) => field.key === key) || { key }),
value: Array.isArray(value) ? value.join('、') : String(value ?? '-')
}))
}
function buildEvidenceItems(result) {
const evidence = result?.evidence && typeof result.evidence === 'object'
? result.evidence
: {}
const items = []
if (Array.isArray(evidence.failed_conditions)) {
evidence.failed_conditions.slice(0, 3).forEach((condition) => {
const left = Array.isArray(condition.left_values) ? condition.left_values.join('、') : '-'
const right = Array.isArray(condition.right_values) ? condition.right_values.join('、') : '-'
items.push(`${formatFieldName(condition.left)}${left}${formatFieldName(condition.right)}${right}`)
})
}
if (Array.isArray(evidence.missing_fields)) {
evidence.missing_fields.slice(0, 5).forEach((field) => {
items.push(`${formatFieldName(field)} 缺失`)
})
}
if (Array.isArray(evidence.keyword_hits)) {
items.push(`命中关键词:${evidence.keyword_hits.join('、')}`)
}
if (evidence.condition_summary) {
items.push(String(evidence.condition_summary))
}
return [...new Set(items)].slice(0, 5)
}
function formatFieldLabel(field) {
const key = String(field?.key || '').trim()
const label = String(field?.display || field?.label || '').trim()
if (!key) return label || '-'
if (!label || label === key) return key
return label.includes(`[${key}]`) ? label : `${label}[${key}]`
}
function formatFieldName(key) {
return formatFieldLabel(fields.value.find((field) => field.key === key) || { key })
}
function toAttachmentPayload(file) {
const document = file.ocrDocument || {}
return {
id: file.id,
name: file.name,
size: file.size,
content_type: file.contentType,
note: file.error || '',
recognition_status: file.status,
ocr_text: document.text || '',
summary: document.summary || '',
document_type: document.document_type || '',
document_type_label: document.document_type_label || '',
scene_code: document.scene_code || '',
scene_label: document.scene_label || '',
avg_score: document.avg_score || 0,
document_fields: Array.isArray(document.document_fields) ? document.document_fields : []
}
}
function normalizeOcrDocuments(payload) {
const documents = Array.isArray(payload?.documents) ? payload.documents : []
return documents.map((item) => ({
filename: String(item?.filename || '').trim(),
summary: String(item?.summary || '').trim(),
text: String(item?.text || '').trim(),
avg_score: Number(item?.avg_score || 0),
document_type: String(item?.document_type || 'other').trim() || 'other',
document_type_label: String(item?.document_type_label || '').trim(),
scene_code: String(item?.scene_code || 'other').trim() || 'other',
scene_label: String(item?.scene_label || '').trim(),
document_fields: Array.isArray(item?.document_fields)
? item.document_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)
: [],
warnings: Array.isArray(item?.warnings) ? item.warnings : []
}))
}
function mergeRecognizedDocuments(current, incoming) {
const next = [...current]
incoming.forEach((document) => {
const index = next.findIndex((item) => item.filename === document.filename)
if (index >= 0) {
next.splice(index, 1, document)
} else {
next.push(document)
}
})
return next
}
function documentHasMeaningfulText(document) {
return Boolean(
String(document?.text || document?.summary || '').trim() ||
(Array.isArray(document?.document_fields) && document.document_fields.length)
)
}
function buildDocumentBrief(document) {
const fields = Array.isArray(document?.document_fields) ? document.document_fields : []
if (fields.length) {
return fields.slice(0, 4).map((field) => `${field.label}${field.value}`).join('')
}
return String(document?.summary || document?.text || '未提取到结构化字段').slice(0, 120)
}
function resolveFileStatusLabel(file) {
return file.statusText || {
pending: '待发送',
recognizing: '识别中',
recognized: '已识别',
failed: '识别失败'
}[file.status] || '待识别'
}
function buildRecognitionStepDescription() {
if (!requiresAttachment.value) return '当前规则不需要附件,直接根据文字测试事实抽取字段。'
if (recognitionBusy.value) return '正在读取临时附件并提取 OCR 字段。'
if (hasRecognizedFiles.value) return `已识别 ${recognizedDocuments.value.length} 份临时单据。`
if (hasFailedOnlyFiles.value) return '识别不足,请在对话中补充字段。'
if (uploadedFiles.value.length) return '附件已加入本轮,点击发送后会和文字一起识别。'
return '需要上传测试附件,并填写测试意图后执行。'
}
function resolveRecognitionStepStatus() {
if (!requiresAttachment.value) return 'done'
if (recognitionBusy.value) return 'running'
if (hasRecognizedFiles.value) return 'done'
if (hasFailedOnlyFiles.value || recognitionError.value) return 'warning'
return 'pending'
}
function buildFieldStepDescription() {
if (activeSimulationResult.value?.recognized_fields?.length) {
return `已确认 ${activeSimulationResult.value.recognized_fields.length} 个字段。`
}
if (draft.value.trim()) return '将使用你输入的文字抽取测试字段。'
return '识别完成或补充字段后进入确认。'
}
function resolveFieldStepStatus() {
if (activeSimulationResult.value?.ready) return 'done'
if (activeSimulationResult.value?.missing_fields?.length) return 'warning'
if (hasRecognizedFiles.value || draft.value.trim()) return 'running'
return 'pending'
}
function buildWelcomeMessage() {
if (requiresAttachment.value) {
return '这条规则要求测试附件。请先上传临时票据并填写测试意图,点击发送后我会统一识别附件和文字,再交给规则执行器判断。'
}
return '这条规则不需要上传附件。你可以直接输入测试事实,我只会执行风险识别,不创建单据、不写入主工作台会话。'
}
function clearFileInput() {
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
}
function scrollMessagesToBottom() {
const target = messageListRef.value
if (target) {
target.scrollTop = target.scrollHeight
}
}
function isActiveSession(activeSessionId) {
return props.open && activeSessionId && activeSessionId === sessionId.value
}
</script>
<style src="../../assets/styles/components/risk-rule-test-dialog.css"></style>

View File

@@ -0,0 +1,28 @@
export function createId() {
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
return crypto.randomUUID()
}
return `${Date.now()}-${Math.random().toString(16).slice(2)}`
}
export function formatFileSize(size) {
const value = Number(size || 0)
if (value < 1024) return `${value}B`
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)}KB`
return `${(value / 1024 / 1024).toFixed(1)}MB`
}
export function formatTestError(error, fallback) {
const message = String(error?.message || '').trim()
if (/not\s*found/i.test(message)) {
return '测试接口暂未加载或规则详情已失效,请刷新规则详情后再试。'
}
return message || fallback
}
export function formatTime() {
return new Intl.DateTimeFormat('zh-CN', {
hour: '2-digit',
minute: '2-digit'
}).format(new Date())
}

View File

@@ -6,29 +6,30 @@ import { useNavigation, navItems } from './useNavigation.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'
const SESSION_TYPE_EXPENSE = 'expense'
export function useAppShell() {
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'
const SESSION_TYPE_EXPENSE = 'expense'
export function useAppShell() {
const route = useRoute()
const router = useRouter()
const smartEntryOpen = ref(false)
const smartEntryContext = ref({
prompt: '',
source: 'requests',
request: null,
files: [],
conversation: null,
scope: null
})
const smartEntryContext = ref({
prompt: '',
source: 'requests',
request: null,
files: [],
conversation: null,
scope: null
})
const smartEntrySessionId = ref(0)
const smartEntryInvalidatedDraftClaimId = ref('')
const selectedRequestSnapshot = ref(null)
const { activeView, currentView, setView } = useNavigation()
const {
@@ -60,14 +61,32 @@ export function useAppShell() {
const rawRequest = requests.value.find(
(item) => String(item.claimId || '').trim() === requestId || String(item.id || '').trim() === requestId
)
return normalizeRequestForUi(rawRequest)
const normalizedRequest = normalizeRequestForUi(rawRequest)
if (normalizedRequest) {
return normalizedRequest
}
const snapshot = normalizeRequestForUi(selectedRequestSnapshot.value)
if (
snapshot
&& (
String(snapshot.claimId || '').trim() === requestId
|| String(snapshot.id || '').trim() === requestId
|| String(snapshot.documentNo || '').trim() === requestId
)
) {
return snapshot
}
return null
})
const detailMode = computed(() => route.name === 'app-request-detail')
const detailMode = computed(() => ['app-request-detail', 'app-document-detail'].includes(route.name))
const logDetailMode = computed(() => route.name === 'app-log-detail')
const detailAlerts = computed(() => (detailMode.value ? buildDetailAlerts(selectedRequest.value) : []))
const requestsListActive = computed(() => activeView.value === 'requests' && !detailMode.value)
const documentsListActive = computed(() => activeView.value === 'documents' && !detailMode.value)
const workbenchActive = computed(() => activeView.value === 'workbench')
watch(requestsListActive, (isActive, wasActive) => {
@@ -76,6 +95,12 @@ export function useAppShell() {
}
})
watch(documentsListActive, (isActive, wasActive) => {
if (isActive && !wasActive) {
void reloadRequests()
}
})
watch(workbenchActive, (isActive, wasActive) => {
if (isActive && !wasActive) {
void reloadRequests()
@@ -145,56 +170,56 @@ export function useAppShell() {
setView(view)
}
function openTravelCreate() {
smartEntryOpen.value = true
smartEntryContext.value = {
prompt: '',
source: 'topbar',
request: null,
files: [],
conversation: null,
scope: null
}
smartEntrySessionId.value += 1
}
function openTravelCreate() {
smartEntryOpen.value = true
smartEntryContext.value = {
prompt: '',
source: 'topbar',
request: null,
files: [],
conversation: null,
scope: null
}
smartEntrySessionId.value += 1
}
function resolveCurrentUserId() {
const user = currentUser.value || {}
return String(user.username || user.name || 'anonymous').trim() || 'anonymous'
}
function resolveSmartEntryClaimScope(payload = {}) {
const request = payload.request && typeof payload.request === 'object' ? payload.request : null
const payloadScope = payload.scope && typeof payload.scope === 'object' ? payload.scope : null
const claimId = String(
payloadScope?.claimId ||
payloadScope?.claim_id ||
request?.claimId ||
request?.claim_id ||
''
).trim()
if (!claimId) {
return null
}
return { type: 'claim', claimId }
}
function isDetailClaimScopedPayload(payload = {}) {
return String(payload.source || '').trim() === 'detail' && Boolean(resolveSmartEntryClaimScope(payload))
}
async function resolveSmartEntryConversation(payload = {}) {
if (payload.conversation) {
return payload.conversation
}
if (isDetailClaimScopedPayload(payload)) {
return null
}
if (!payload.restoreLatestConversation) {
return null
}
function resolveCurrentUserId() {
const user = currentUser.value || {}
return String(user.username || user.name || 'anonymous').trim() || 'anonymous'
}
function resolveSmartEntryClaimScope(payload = {}) {
const request = payload.request && typeof payload.request === 'object' ? payload.request : null
const payloadScope = payload.scope && typeof payload.scope === 'object' ? payload.scope : null
const claimId = String(
payloadScope?.claimId ||
payloadScope?.claim_id ||
request?.claimId ||
request?.claim_id ||
''
).trim()
if (!claimId) {
return null
}
return { type: 'claim', claimId }
}
function isDetailClaimScopedPayload(payload = {}) {
return String(payload.source || '').trim() === 'detail' && Boolean(resolveSmartEntryClaimScope(payload))
}
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, {
@@ -208,19 +233,19 @@ export function useAppShell() {
}
}
async function openSmartEntry(payload = {}) {
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
}
async function openSmartEntry(payload = {}) {
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
}
@@ -237,21 +262,23 @@ export function useAppShell() {
smartEntryOpen.value = false
void refreshApprovalInbox()
toast(`${claimNo || '该'}单据已完成 AI预审${approvalStage ? `,当前节点:${approvalStage}` : ',并已提交审批'}`)
router.push({ name: 'app-requests' })
router.push({ name: activeView.value === 'documents' ? 'app-documents' : 'app-requests' })
return
}
toast(`${claimNo || '该'}单据已保存为草稿,可继续上传票据或补充信息。`)
}
function openRequestDetail(request) {
selectedRequestSnapshot.value = request || null
const routeName = activeView.value === 'documents' ? 'app-document-detail' : 'app-request-detail'
router.push({
name: 'app-request-detail',
name: routeName,
params: { requestId: request.claimId || request.id }
})
}
function closeRequestDetail() {
router.push({ name: 'app-requests' })
router.push({ name: activeView.value === 'documents' ? 'app-documents' : 'app-requests' })
}
async function handleRequestUpdated() {
@@ -268,7 +295,8 @@ export function useAppShell() {
await reloadRequests()
void refreshApprovalInbox()
router.push({ name: 'app-requests' })
selectedRequestSnapshot.value = null
router.push({ name: activeView.value === 'documents' ? 'app-documents' : 'app-requests' })
}
return {

View File

@@ -3,7 +3,7 @@ import { useRoute, useRouter } from 'vue-router'
import { icons } from '../data/icons.js'
export const appViews = ['overview', 'workbench', 'requests', 'approval', 'archive', 'policies', 'audit', 'employees', 'logs', 'settings']
export const appViews = ['overview', 'workbench', 'documents', 'requests', 'approval', 'archive', 'policies', 'audit', 'employees', 'logs', 'settings']
export const navItems = [
{
@@ -22,6 +22,14 @@ export const navItems = [
title: '个人工作台',
desc: '聚焦当前待办、快捷操作与助手入口。'
},
{
id: 'documents',
label: '单据中心',
navHint: '统一查看申请、报销、审批与归档',
icon: icons.file,
title: '单据中心',
desc: '统一查看申请、报销、审批与归档。'
},
{
id: 'requests',
label: '报销中心',
@@ -91,6 +99,7 @@ export const navItems = [
const viewRouteNames = {
overview: 'app-overview',
workbench: 'app-workbench',
documents: 'app-documents',
requests: 'app-requests',
approval: 'app-approval',
archive: 'app-archive',
@@ -106,6 +115,7 @@ const routeNameViews = Object.fromEntries(
)
routeNameViews['app-request-detail'] = 'requests'
routeNameViews['app-document-detail'] = 'documents'
routeNameViews['app-log-detail'] = 'logs'
export function resolveAppViewFromRoute(route) {

View File

@@ -114,6 +114,7 @@ export function useSettings() {
adminForm: { ...pageState.value.adminForm },
sessionForm: { ...pageState.value.sessionForm },
llmForm: buildLlmPayload(pageState.value.llmForm),
hermesForm: { ...pageState.value.hermesForm },
renderForm: buildRenderPayload(pageState.value.renderForm),
logForm: { ...pageState.value.logForm },
mailForm: { ...pageState.value.mailForm }

View File

@@ -10,7 +10,7 @@ import LoginRouteView from '../views/LoginRouteView.vue'
import SetupRouteView from '../views/SetupRouteView.vue'
const appChildRoutes = appViews
.filter((view) => view !== 'requests')
.filter((view) => view !== 'requests' && view !== 'documents')
.map((view) => ({
path: view,
name: `app-${view}`,
@@ -51,6 +51,24 @@ const router = createRouter({
path: '/app',
redirect: { name: 'app-overview' }
},
{
path: '/app/documents',
name: 'app-documents',
component: AppShellRouteView,
meta: {
requiresAuth: true,
appView: 'documents'
}
},
{
path: '/app/documents/:requestId',
name: 'app-document-detail',
component: AppShellRouteView,
meta: {
requiresAuth: true,
appView: 'documents'
}
},
{
path: '/app/requests',
name: 'app-requests',

View File

@@ -154,6 +154,72 @@ export function generateRiskRuleAsset(payload, options = {}) {
})
}
export function fetchRiskRuleLatestTest(assetId) {
return apiRequest(`/agent-assets/${assetId}/risk-rule-tests/latest`)
}
export function runRiskRuleSampleTest(assetId, payload, options = {}) {
return apiRequest(`/agent-assets/${assetId}/risk-rule-tests/sample`, {
method: 'POST',
body: JSON.stringify(payload || {}),
headers: buildWriteHeaders(options)
})
}
export function simulateRiskRuleTest(assetId, payload, options = {}) {
return apiRequest(`/agent-assets/${assetId}/risk-rule-tests/simulate`, {
method: 'POST',
body: JSON.stringify(payload || {}),
headers: buildWriteHeaders(options)
})
}
export function runRiskRuleScenarioTest(assetId, payload, options = {}) {
return apiRequest(`/agent-assets/${assetId}/risk-rule-tests/scenario`, {
method: 'POST',
body: JSON.stringify(payload || {}),
headers: buildWriteHeaders(options)
})
}
export function confirmRiskRuleTestReport(assetId, payload, options = {}) {
return apiRequest(`/agent-assets/${assetId}/risk-rule-tests/report`, {
method: 'POST',
body: JSON.stringify(payload || {}),
headers: buildWriteHeaders(options)
})
}
export function deleteAgentAsset(assetId, options = {}) {
return apiRequest(`/agent-assets/${assetId}`, {
method: 'DELETE',
headers: buildWriteHeaders(options)
})
}
export function returnRiskRuleAsset(assetId, payload, options = {}) {
return apiRequest(`/agent-assets/${assetId}/return`, {
method: 'POST',
body: JSON.stringify(payload || {}),
headers: buildWriteHeaders(options)
})
}
export function publishRiskRuleAsset(assetId, options = {}) {
return apiRequest(`/agent-assets/${assetId}/publish`, {
method: 'POST',
headers: buildWriteHeaders(options)
})
}
export function setRiskRuleAssetEnabled(assetId, enabled, options = {}) {
return apiRequest(`/agent-assets/${assetId}/risk-rule-enabled`, {
method: 'POST',
body: JSON.stringify({ enabled: Boolean(enabled) }),
headers: buildWriteHeaders(options)
})
}
export function fetchAgentAssetSpreadsheetChangeRecords(assetId, limit = 30) {
return apiRequest(
`/agent-assets/${assetId}/spreadsheet/change-records${buildQuery({ limit })}`

View File

@@ -1,6 +1,7 @@
export const DEFAULT_APP_VIEW_ORDER = [
'overview',
'workbench',
'documents',
'requests',
'approval',
'archive',
@@ -11,7 +12,7 @@ export const DEFAULT_APP_VIEW_ORDER = [
'settings'
]
const ALWAYS_VISIBLE_VIEWS = new Set(['workbench', 'requests', 'policies'])
const ALWAYS_VISIBLE_VIEWS = new Set(['workbench', 'documents', 'requests', 'policies'])
const VIEW_ROLE_RULES = {
overview: ['finance', 'executive'],
approval: ['approver', 'finance', 'executive'],

View File

@@ -0,0 +1,76 @@
function toDate(value) {
if (!value) {
return null
}
const nextDate = new Date(value)
return Number.isNaN(nextDate.getTime()) ? null : nextDate
}
export function extractDateText(value) {
const matched = String(value || '').match(/\d{4}-\d{2}-\d{2}/)
return matched ? matched[0] : ''
}
export function formatDocumentListTime(value) {
const raw = String(value || '').trim()
if (!raw || raw === '待补充') {
return '待补充'
}
const date = toDate(raw)
if (date) {
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hour = String(date.getHours()).padStart(2, '0')
const minute = String(date.getMinutes()).padStart(2, '0')
return `${month}-${day} ${hour}:${minute}`
}
return raw.replace(/^\d{4}-/, '').slice(0, 11)
}
export function resolveDocumentSortTime(value) {
const date = toDate(value)
return date ? date.getTime() : 0
}
export function formatDocumentDurationSince(value, now = Date.now()) {
const startAt = toDate(value)
if (!startAt) {
return ''
}
const diffMs = Math.max(0, Number(now) - startAt.getTime())
const totalMinutes = Math.floor(diffMs / (60 * 1000))
if (totalMinutes < 1) {
return '刚刚'
}
const days = Math.floor(totalMinutes / (24 * 60))
const hours = Math.floor((totalMinutes % (24 * 60)) / 60)
const minutes = totalMinutes % 60
if (days > 0) {
return hours > 0 ? `${days}${hours}小时` : `${days}`
}
if (hours > 0) {
return minutes > 0 ? `${hours}小时${minutes}分钟` : `${hours}小时`
}
return `${minutes}分钟`
}
export function resolveDocumentStayTimeDisplay(row, now = Date.now()) {
const currentStep = Array.isArray(row?.progressSteps)
? row.progressSteps.find((step) => step?.current)
: null
const stepTime = String(currentStep?.time || '').trim()
if (stepTime.startsWith('停留')) {
return stepTime.replace(/^停留\s*/, '') || '待计算'
}
const startedAt = row?.updatedAt || row?.submittedAt || row?.createdAt || row?.applyTime
return formatDocumentDurationSince(startedAt, now) || '待计算'
}

View File

@@ -0,0 +1,158 @@
const EXPENSE_TYPE_LABELS = {
travel: '差旅费',
hotel: '住宿费',
transport: '交通费',
meal: '业务招待费',
entertainment: '业务招待费',
meeting: '会务费',
office: '办公用品费',
training: '培训费',
communication: '通讯费',
welfare: '福利费',
other: '其他费用'
}
const SLOT_LABELS = {
expense_type: '费用场景',
amount: '申请金额',
time_range: '业务时间',
reason: '申请事由',
attachments: '附件说明',
customer_name: '客户名称',
participants: '参与人员'
}
const PRE_APPROVAL_TYPES = new Set(['travel', 'meeting', 'office', 'training'])
const ATTACHMENT_REQUIRED_TYPES = new Set(['meeting', 'training'])
export const APPLICATION_EXAMPLES = [
'申请下周去北京做客户现场验收差旅预算18000元',
'申请上海产品发布会会务费32000元需要场地和物料',
'申请部门集中采购办公用品4800元用于新员工入职'
]
export function buildExpenseApplicationOntologyContext(currentUser = {}) {
return {
document_type: 'expense_application',
application_stage: 'pre_approval',
conversation_scenario: 'expense',
entry_source: 'documents_application',
role_codes: Array.isArray(currentUser.roleCodes) ? currentUser.roleCodes : [],
is_admin: Boolean(currentUser.isAdmin),
name: currentUser.name || '',
role: currentUser.role || '',
department: currentUser.department || currentUser.departmentName || '',
department_name: currentUser.department || currentUser.departmentName || '',
position: currentUser.position || '',
grade: currentUser.grade || '',
employee_no: currentUser.employeeNo || currentUser.employee_no || ''
}
}
export function resolveEntity(ontology, type) {
const entities = Array.isArray(ontology?.entities) ? ontology.entities : []
return entities.find((item) => item?.type === type) || null
}
export function resolveConstraint(ontology, field) {
const constraints = Array.isArray(ontology?.constraints) ? ontology.constraints : []
return constraints.find((item) => item?.field === field) || null
}
export function resolveExpenseTypeCode(ontology) {
const entity = resolveEntity(ontology, 'expense_type')
return String(entity?.normalized_value || entity?.value || 'other').trim() || 'other'
}
export function resolveExpenseTypeLabel(code) {
return EXPENSE_TYPE_LABELS[String(code || '').trim()] || EXPENSE_TYPE_LABELS.other
}
export function resolveApplicationAmount(ontology) {
const amountEntity = resolveEntity(ontology, 'amount')
const amountConstraint = resolveConstraint(ontology, 'amount')
const rawValue = amountEntity?.normalized_value || amountEntity?.value || amountConstraint?.value || ''
const numericValue = Number(String(rawValue).replace(/[^\d.]/g, ''))
return {
raw: String(rawValue || '').trim(),
value: Number.isFinite(numericValue) ? numericValue : 0
}
}
export function resolveTimeRangeText(ontology) {
const range = ontology?.time_range || {}
if (range.start_date && range.end_date) {
return range.start_date === range.end_date
? range.start_date
: `${range.start_date}${range.end_date}`
}
return String(range.raw || '').trim()
}
export function resolveAttachmentPolicy(expenseTypeCode, amount = 0) {
const code = String(expenseTypeCode || '').trim()
if (ATTACHMENT_REQUIRED_TYPES.has(code)) {
return {
level: 'required',
label: '必须提交',
description: code === 'meeting'
? '需补充会议通知、议程、参会范围或预算说明。'
: '需补充培训通知、课程说明、报价或审批依据。'
}
}
if (code === 'office' && amount >= 5000) {
return {
level: 'required',
label: '必须提交',
description: '办公采购金额较高,需补充采购清单、报价或预算说明。'
}
}
if (code === 'travel') {
return {
level: 'optional',
label: '说明可选',
description: '可先提交出差目的、时间和预算;行程或邀请材料可作为补充说明。'
}
}
return {
level: 'none',
label: '无需附件',
description: '当前申请事项可先不提交附件,后续报销阶段再按票据要求补充。'
}
}
export function buildApplicationFieldsFromOntology(ontology, prompt, currentUser = {}) {
const expenseTypeCode = resolveExpenseTypeCode(ontology)
const amount = resolveApplicationAmount(ontology)
const locationEntity = resolveEntity(ontology, 'location')
const documentTypeEntity = resolveEntity(ontology, 'document_type')
const workflowStageEntity = resolveEntity(ontology, 'workflow_stage')
const attachmentPolicy = resolveAttachmentPolicy(expenseTypeCode, amount.value)
return {
documentType: documentTypeEntity?.normalized_value || 'expense_application',
documentTypeLabel: documentTypeEntity?.value || '费用申请',
workflowStage: workflowStageEntity?.normalized_value || 'pre_approval',
workflowStageLabel: workflowStageEntity?.value || '前置申请',
expenseTypeCode,
expenseTypeLabel: resolveExpenseTypeLabel(expenseTypeCode),
amount: amount.value,
amountDisplay: amount.value ? `¥${amount.value.toLocaleString('zh-CN')}` : '待补充',
timeRange: resolveTimeRangeText(ontology) || '待补充',
location: locationEntity?.normalized_value || locationEntity?.value || '待补充',
reason: String(prompt || '').trim() || '待补充',
applicant: currentUser.name || currentUser.username || '当前用户',
department: currentUser.department || currentUser.departmentName || '待补充',
preApprovalRequired: PRE_APPROVAL_TYPES.has(expenseTypeCode),
attachmentPolicy,
missingSlots: normalizeMissingSlots(ontology?.missing_slots || [])
}
}
export function normalizeMissingSlots(slots = []) {
const normalized = Array.isArray(slots) ? slots : []
return normalized.map((item) => ({
key: String(item || '').trim(),
label: SLOT_LABELS[String(item || '').trim()] || String(item || '').trim()
})).filter((item) => item.key)
}

View File

@@ -1,69 +1,26 @@
/** 数字员工设置:面向管理员的简明任务列表(频率固定,仅可调执行时间) */
export const HERMES_SIMPLE_TASKS = [
{
id: 'knowledgeAggregation',
label: '知识库同步',
hint: '同步制度文档与知识索引',
frequency: 'daily',
frequencyLabel: '每天'
},
{
id: 'ruleReviewDigest',
label: '规则待审提醒',
hint: '汇总待审规则并推送管理员',
frequency: 'daily',
frequencyLabel: '每天'
},
{
id: 'riskSummary',
id: 'global_risk_scan',
label: '风险每日巡检',
hint: '扫描报销、付款等风险信号',
frequency: 'daily',
frequencyLabel: '每天'
},
{
id: 'archiveDigest',
label: '归档周报',
hint: '汇总已归档报销单',
id: 'weekly_expense_report',
label: '费控洞察周报',
hint: '聚合生成财务总结简报',
frequency: 'weekly',
frequencyLabel: '每周一',
weekday: 1
},
{
id: 'dailyStats',
label: '日报统计',
hint: '生成昨日报销与审批数据',
frequency: 'daily',
frequencyLabel: '每天'
},
{
id: 'monthlyStats',
label: '月报统计',
hint: '每月 1 号生成上月汇总',
frequency: 'monthly',
frequencyLabel: '每月 1 日',
monthDay: 1
},
{
id: 'yearlyStats',
label: '年报统计',
hint: '每年 1 月 1 号生成上年汇总',
frequency: 'yearly',
frequencyLabel: '每年 1 月 1 日',
month: 1,
monthDay: 1
}
]
function buildDefaultSchedules() {
const defaults = {
knowledgeAggregation: { enabled: true, frequency: 'daily', time: '00:00', weekday: 1, monthDay: 1, month: 1 },
ruleReviewDigest: { enabled: true, frequency: 'daily', time: '18:00', weekday: 5, monthDay: 1, month: 1 },
riskSummary: { enabled: true, frequency: 'daily', time: '09:00', weekday: 1, monthDay: 1, month: 1 },
archiveDigest: { enabled: false, frequency: 'weekly', time: '10:30', weekday: 1, monthDay: 1, month: 1 },
dailyStats: { enabled: true, frequency: 'daily', time: '08:30', weekday: 1, monthDay: 1, month: 1 },
monthlyStats: { enabled: true, frequency: 'monthly', time: '09:00', weekday: 1, monthDay: 1, month: 1 },
yearlyStats: { enabled: false, frequency: 'yearly', time: '10:00', weekday: 1, monthDay: 1, month: 1 }
global_risk_scan: { enabled: true, frequency: 'daily', time: '09:00', weekday: 1, monthDay: 1, month: 1 },
weekly_expense_report: { enabled: false, frequency: 'weekly', time: '10:30', weekday: 1, monthDay: 1, month: 1 }
}
for (const task of HERMES_SIMPLE_TASKS) {
@@ -91,13 +48,8 @@ export function buildDefaultHermesEmployeeForm() {
masterEnabled: true,
notifyOnFailure: true,
capabilities: {
knowledgeAggregation: true,
ruleReviewDigest: true,
riskSummary: true,
archiveDigest: false,
dailyStats: true,
monthlyStats: true,
yearlyStats: false
global_risk_scan: true,
weekly_expense_report: false
},
schedules: buildDefaultSchedules()
}

View File

@@ -39,7 +39,7 @@ export const SECTION_DEFINITIONS = [
id: 'hermes',
label: '数字员工设置',
title: '数字员工设置',
desc: 'Hermes 自动任务',
desc: '自动任务',
longDesc: '选择需要自动执行的任务,并设置每天的执行时间。无需了解 Cron 或复杂调度规则。',
actionLabel: '保存数字员工设置'
},

View File

@@ -16,6 +16,7 @@
:class="{
'overview-main': activeView === 'overview',
'workbench-main': activeView === 'workbench',
'documents-main': activeView === 'documents',
'requests-main': activeView === 'requests',
'approval-main': activeView === 'approval',
'archive-main': activeView === 'archive',
@@ -38,6 +39,7 @@
:knowledge-summary="knowledgeSummary"
:logs-summary="logsSummary"
:request-summary="requestSummary"
:document-summary="documentSummary"
:workbench-summary="workbenchSummary"
:detail-mode="detailMode"
:log-detail-mode="logDetailMode"
@@ -47,11 +49,11 @@
@update:active-range="activeRange = $event"
@update:custom-range="customRange = $event"
@batch-approve="toast('已批量通过 23 条审批任务')"
@new-application="openTravelCreate"
@new-application="openExpenseApplicationDialog"
/>
<FilterBar
v-if="activeView !== 'overview' && activeView !== 'workbench' && activeView !== 'requests' && activeView !== 'approval' && activeView !== 'archive' && activeView !== 'policies' && activeView !== 'audit' && activeView !== 'logs' && activeView !== 'employees' && activeView !== 'settings'"
v-if="activeView !== 'overview' && activeView !== 'workbench' && activeView !== 'documents' && activeView !== 'requests' && activeView !== 'approval' && activeView !== 'archive' && activeView !== 'policies' && activeView !== 'audit' && activeView !== 'logs' && activeView !== 'employees' && activeView !== 'settings'"
:compact="activeView === 'overview'"
:filters="filters"
:ranges="ranges"
@@ -63,6 +65,7 @@
class="workarea"
:class="{
'requests-workarea': activeView === 'requests',
'documents-workarea': activeView === 'documents',
'approval-workarea': activeView === 'approval',
'archive-workarea': activeView === 'archive',
'policies-workarea': activeView === 'policies',
@@ -86,7 +89,7 @@
/>
<TravelRequestDetailView
v-else-if="activeView === 'requests' && detailMode && selectedRequest"
v-else-if="['requests', 'documents'].includes(activeView) && detailMode && selectedRequest"
:request="selectedRequest"
@back-to-requests="closeRequestDetail"
@open-assistant="openSmartEntry"
@@ -94,6 +97,19 @@
@request-deleted="handleRequestDeleted"
/>
<DocumentsCenterView
v-else-if="activeView === 'documents'"
:filtered-requests="filteredRequests"
:has-data="requests.length > 0"
:loading="requestsLoading"
:error="requestsError"
@open-document="openRequestDetail"
@create-request="openTravelCreate"
@create-application="openExpenseApplicationDialog"
@reload="reloadRequests"
@summary-change="documentSummary = $event"
/>
<RequestsView
v-else-if="activeView === 'requests'"
:filtered-requests="filteredRequests"
@@ -130,6 +146,12 @@
@close="closeSmartEntry"
@draft-saved="handleDraftSaved"
/>
<ExpenseApplicationDialog
v-if="expenseApplicationDialogOpen"
@close="closeExpenseApplicationDialog"
@confirmed="handleExpenseApplicationConfirmed"
/>
</div>
</template>
@@ -139,10 +161,12 @@ import { computed, 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 ExpenseApplicationDialog from '../components/shared/ExpenseApplicationDialog.vue'
import OverviewView from './OverviewView.vue'
import PersonalWorkbenchView from './PersonalWorkbenchView.vue'
import TravelReimbursementCreateView from './TravelReimbursementCreateView.vue'
import TravelRequestDetailView from './TravelRequestDetailView.vue'
import DocumentsCenterView from './DocumentsCenterView.vue'
import RequestsView from './RequestsView.vue'
import ApprovalCenterView from './ApprovalCenterView.vue'
import ArchiveCenterView from './ArchiveCenterView.vue'
@@ -160,7 +184,9 @@ import { filterNavItemsByAccess } from '../utils/accessControl.js'
const employeeSummary = ref(null)
const knowledgeSummary = ref(null)
const logsSummary = ref(null)
const documentSummary = ref(null)
const auditDetailOpen = ref(false)
const expenseApplicationDialogOpen = ref(false)
const {
activeRange,
@@ -203,6 +229,19 @@ const {
const { companyProfile, currentUser, logout } = useSystemState()
const filteredNavItems = computed(() => filterNavItemsByAccess(navItems, currentUser.value))
function openExpenseApplicationDialog() {
expenseApplicationDialogOpen.value = true
}
function closeExpenseApplicationDialog() {
expenseApplicationDialogOpen.value = false
}
function handleExpenseApplicationConfirmed() {
expenseApplicationDialogOpen.value = false
toast('费用申请字段已接入本体识别,后续会按申请审批流落单。')
}
function handleLogout() {
logout('manual')
}

View File

@@ -215,7 +215,6 @@
>
<header class="json-risk-editor-head asset-detail-topbar list-toolbar">
<div class="json-risk-editor-title asset-detail-topbar-main filter-set">
<div class="skill-badge" :class="selectedSkill.badgeTone">{{ selectedSkill.typeLabel }}</div>
<div class="json-risk-head-copy">
<div class="json-risk-head-title-row">
<h2>{{ selectedSkill.name }}</h2>
@@ -230,11 +229,6 @@
</div>
</div>
</div>
<div class="json-risk-editor-actions asset-detail-topbar-meta toolbar-actions">
<span class="json-risk-mode-pill" :class="selectedSkill.riskRuleSeverity">
{{ selectedSkill.riskRuleSeverityLabel }}
</span>
</div>
</header>
<div class="json-risk-editor-body">
@@ -243,19 +237,74 @@
<div class="card-head">
<div>
<h3>基本信息</h3>
<p>这条规则的业务域风险等级创建时间和使用字段</p>
<p>这条规则的业务域风险等级创建时间上线状态和审核历史</p>
</div>
</div>
<div class="json-risk-summary-grid">
<span><strong>业务域</strong>{{ selectedSkill.category || '-' }}</span>
<span><strong>风险等级</strong>{{ selectedSkill.riskRuleSeverityLabel || '-' }}</span>
<span><strong>适用场景</strong>{{ selectedSkill.riskCategory || selectedSkill.scope || '-' }}</span>
<span><strong>创建时间</strong>{{ selectedSkill.riskRuleCreatedAt || selectedSkill.updatedAt }}</span>
<span><strong>已创建</strong>{{ selectedSkill.riskRuleAgeLabel || '-' }}</span>
<span>
<strong>使用字段</strong>
{{ selectedSkill.riskRuleFieldSummary || '-' }}
</span>
<div class="json-risk-meta-grid">
<div class="json-risk-meta-item">
<span class="json-risk-meta-label">业务域</span>
<span class="json-risk-meta-value">{{ selectedSkill.category || '-' }}</span>
</div>
<div class="json-risk-meta-item">
<span class="json-risk-meta-label">适用场景</span>
<span class="json-risk-meta-value">{{ selectedSkill.riskCategory || selectedSkill.scope || '-' }}</span>
</div>
<div class="json-risk-meta-item">
<span class="json-risk-meta-label">风险等级</span>
<span class="json-risk-meta-value">
<span class="json-risk-meta-badge" :class="selectedSkill.riskRuleSeverity">
{{ selectedSkill.riskRuleSeverityLabel || '-' }}
</span>
</span>
</div>
<div class="json-risk-meta-item">
<span class="json-risk-meta-label">是否上线</span>
<span class="json-risk-meta-value">
<span class="meta-status-indicator" :class="{ 'is-active': selectedSkill.isOnlineLabel === '是' }">
<span class="indicator-dot"></span>
{{ selectedSkill.isOnlineLabel || '否' }}
</span>
</span>
</div>
<div class="json-risk-meta-item">
<span class="json-risk-meta-label">是否启用</span>
<span class="json-risk-meta-value">
<span class="json-risk-meta-badge" :class="selectedSkill.isEnabledTone">
{{ selectedSkill.isEnabledLabel || '-' }}
</span>
</span>
</div>
<div class="json-risk-meta-item">
<span class="json-risk-meta-label">测试状态</span>
<span class="json-risk-meta-value">
<span class="json-risk-meta-badge" :class="riskRuleTestPassed ? 'test-passed' : 'test-pending'">
{{ riskRuleTestPassed ? '已确认通过' : '待测试确认' }}
</span>
</span>
</div>
<div class="json-risk-meta-item">
<span class="json-risk-meta-label">发布人</span>
<span class="json-risk-meta-value">{{ selectedSkill.publisher || '-' }}</span>
</div>
<div class="json-risk-meta-item">
<span class="json-risk-meta-label">审核人</span>
<span class="json-risk-meta-value">{{ selectedSkill.reviewer || '-' }}</span>
</div>
<div class="json-risk-meta-item">
<span class="json-risk-meta-label">创建时间</span>
<span class="json-risk-meta-value">
{{ selectedSkill.riskRuleCreatedAt || selectedSkill.updatedAt }}
<span class="meta-value-hint" v-if="selectedSkill.riskRuleAgeLabel">({{ selectedSkill.riskRuleAgeLabel }})</span>
</span>
</div>
<div class="json-risk-meta-item">
<span class="json-risk-meta-label">上线时间</span>
<span class="json-risk-meta-value">{{ selectedSkill.publishedAt || '-' }}</span>
</div>
<div class="json-risk-meta-item full-width">
<span class="json-risk-meta-label">使用字段</span>
<span class="json-risk-meta-value">{{ selectedSkill.riskRuleFieldSummary || '-' }}</span>
</div>
</div>
</article>
@@ -618,25 +667,88 @@
<button class="back-action" type="button" @click="closeDetail">
<i class="mdi mdi-arrow-left"></i>
<span>返回能力列表</span>
</button>
<div v-if="selectedSkillIsRule" class="detail-action-group">
<button
v-if="selectedSkill.usesSpreadsheetRule"
class="minor-action"
type="button"
:disabled="!canDownloadSpreadsheet"
@click="downloadSpreadsheetFile"
>
</button>
<div v-if="selectedSkillIsRule" class="detail-action-group">
<template v-if="selectedSkillUsesJsonRisk">
<button
class="minor-action"
type="button"
:disabled="!canOpenRiskRuleTest"
@click="openRiskRuleTestDialog"
>
<i class="mdi mdi-flask-outline"></i>
<span>测试规则</span>
</button>
<button
v-if="canToggleRiskRuleEnabled"
class="minor-action enable-action"
:class="{ 'is-on': selectedSkill.isEnabledValue }"
type="button"
:disabled="detailBusy"
@click="toggleSelectedRiskRuleEnabled"
>
<i :class="selectedSkill.isEnabledValue ? 'mdi mdi-toggle-switch' : 'mdi mdi-toggle-switch-off-outline'"></i>
<span>{{ selectedSkill.isEnabledValue ? '已启用' : '已停用' }}</span>
</button>
<button
v-if="selectedSkillUsesJsonRisk && canEditSelected"
class="minor-action danger-action"
type="button"
:disabled="!canDeleteRiskRule"
@click="openDeleteRiskRuleDialog"
:title="canDeleteRiskRule ? '删除未发布规则' : '已发布过的规则不能删除'"
>
<i class="mdi mdi-delete-outline"></i>
<span>删除规则</span>
</button>
<button
v-if="canEditSelected && !riskRuleInReview"
class="major-action"
type="button"
:disabled="!canSubmitRiskRuleReview"
@click="openSubmitReviewDialog"
>
<i class="mdi mdi-send-outline"></i>
<span>提交审核</span>
</button>
<button
v-if="canManageSelected && riskRuleInReview"
class="minor-action"
type="button"
:disabled="!canReturnRiskRule"
@click="openReturnRiskRuleDialog"
>
<i class="mdi mdi-keyboard-return"></i>
<span>回退规则</span>
</button>
<button
v-if="canManageSelected && riskRuleInReview"
class="major-action"
type="button"
:disabled="!canPublishRiskRule"
@click="openPublishRiskRuleDialog"
>
<i class="mdi mdi-rocket-launch-outline"></i>
<span>发布上线</span>
</button>
</template>
<button
v-else-if="selectedSkill.usesSpreadsheetRule"
class="minor-action"
type="button"
:disabled="!canDownloadSpreadsheet"
@click="downloadSpreadsheetFile"
>
<i class="mdi mdi-file-download-outline"></i>
<span>{{ actionState === 'download-spreadsheet' ? '下载中...' : '下载表格' }}</span>
</button>
<button
v-if="selectedSkill.usesSpreadsheetRule"
class="minor-action"
type="button"
:disabled="!canUploadSpreadsheet"
@click="triggerSpreadsheetUpload"
</button>
<button
v-if="selectedSkill.usesSpreadsheetRule"
class="minor-action"
type="button"
:disabled="!canUploadSpreadsheet"
@click="triggerSpreadsheetUpload"
>
<i class="mdi mdi-file-upload-outline"></i>
<span>{{ actionState === 'upload-spreadsheet' ? '导入中...' : '上传表格' }}</span>
@@ -732,18 +844,22 @@
<span class="picker-label">{{ selectedOwnerLabel }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
<div
v-if="activeFilterPopover === 'owner'"
class="picker-popover"
role="dialog"
aria-label="选择负责人"
>
<header>
<strong>选择负责人</strong>
<button type="button" aria-label="关闭负责人选择" @click="closeFilterPopover">
<i class="mdi mdi-close"></i>
</button>
</header>
<div
v-if="activeFilterPopover === 'owner'"
class="picker-popover"
role="dialog"
:aria-label="activeType === 'riskRules' ? '选择审核人' : '选择负责人'"
>
<header>
<strong>{{ activeType === 'riskRules' ? '选择审核人' : '选择负责人' }}</strong>
<button
type="button"
:aria-label="activeType === 'riskRules' ? '关闭审核人选择' : '关闭负责人选择'"
@click="closeFilterPopover"
>
<i class="mdi mdi-close"></i>
</button>
</header>
<div class="picker-option-list">
<button
v-for="option in ownerOptions"
@@ -800,11 +916,95 @@
</button>
</div>
</div>
</div>
<div
v-if="showStatusFilter"
class="picker-filter"
</div>
<div
v-if="showOnlineFilter"
class="picker-filter"
:class="{ open: activeFilterPopover === 'online' }"
>
<button
class="picker-trigger"
type="button"
:aria-expanded="activeFilterPopover === 'online'"
aria-haspopup="dialog"
@click="toggleFilterPopover('online')"
>
<span class="picker-label">{{ selectedOnlineStateLabel }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
<div
v-if="activeFilterPopover === 'online'"
class="picker-popover"
role="dialog"
aria-label="选择上线状态"
>
<header>
<strong>选择上线状态</strong>
<button type="button" aria-label="关闭上线状态选择" @click="closeFilterPopover">
<i class="mdi mdi-close"></i>
</button>
</header>
<div class="picker-option-list">
<button
v-for="option in onlineStateOptions"
:key="option.value || 'all-online-state'"
type="button"
class="picker-option"
:class="{ active: selectedOnlineState === option.value }"
@click="selectFilter('online', option.value)"
>
{{ option.label }}
</button>
</div>
</div>
</div>
<div
v-if="showEnabledFilter"
class="picker-filter"
:class="{ open: activeFilterPopover === 'enabled' }"
>
<button
class="picker-trigger"
type="button"
:aria-expanded="activeFilterPopover === 'enabled'"
aria-haspopup="dialog"
@click="toggleFilterPopover('enabled')"
>
<span class="picker-label">{{ selectedEnabledStateLabel }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
<div
v-if="activeFilterPopover === 'enabled'"
class="picker-popover"
role="dialog"
aria-label="选择启用状态"
>
<header>
<strong>选择启用状态</strong>
<button type="button" aria-label="关闭启用状态选择" @click="closeFilterPopover">
<i class="mdi mdi-close"></i>
</button>
</header>
<div class="picker-option-list">
<button
v-for="option in enabledStateOptions"
:key="option.value || 'all-enabled-state'"
type="button"
class="picker-option"
:class="{ active: selectedEnabledState === option.value }"
@click="selectFilter('enabled', option.value)"
>
{{ option.label }}
</button>
</div>
</div>
</div>
<div
v-if="showStatusFilter"
class="picker-filter"
:class="{ open: activeFilterPopover === 'status' }"
>
@@ -909,10 +1109,12 @@
<th>{{ tableColumns.owner }}</th>
<th>{{ tableColumns.scope }}</th>
<th v-if="showRuntimeColumn">{{ tableColumns.runtime }}</th>
<th v-if="showVersionColumn">{{ tableColumns.version }}</th>
<th v-if="showStatusColumn">状态</th>
<th v-if="showMetricColumn">{{ tableColumns.metric }}</th>
<th>最近更新</th>
<th v-if="showVersionColumn">{{ tableColumns.version }}</th>
<th v-if="showStatusColumn">状态</th>
<th v-if="showMetricColumn">{{ tableColumns.metric }}</th>
<th v-if="showOnlineColumn">是否上线</th>
<th v-if="showEnabledColumn">是否启用</th>
<th>{{ tableColumns.updatedAt || '最近更新' }}</th>
</tr>
</thead>
<tbody>
@@ -934,10 +1136,16 @@
<td>{{ skill.owner }}</td>
<td><span class="scope-pill">{{ skill.scope }}</span></td>
<td v-if="showRuntimeColumn">{{ skill.model }}</td>
<td v-if="showVersionColumn">{{ skill.versionDisplay || skill.version }}</td>
<td v-if="showStatusColumn"><span class="status-pill" :class="skill.statusTone">{{ skill.status }}</span></td>
<td v-if="showMetricColumn">{{ skill.hitRate }}</td>
<td>{{ skill.updatedAt }}</td>
<td v-if="showVersionColumn">{{ skill.versionDisplay || skill.version }}</td>
<td v-if="showStatusColumn"><span class="status-pill" :class="skill.statusTone">{{ skill.status }}</span></td>
<td v-if="showMetricColumn">{{ skill.hitRate }}</td>
<td v-if="showOnlineColumn">
<span class="status-pill" :class="skill.isOnlineTone">{{ skill.isOnlineLabel }}</span>
</td>
<td v-if="showEnabledColumn">
<span class="status-pill" :class="skill.isEnabledTone">{{ skill.isEnabledLabel }}</span>
</td>
<td>{{ skill.updatedAt }}</td>
</tr>
</tbody>
</table>
@@ -990,6 +1198,32 @@
</option>
</select>
</label>
<label v-if="riskRuleCreateForm.business_domain === 'expense'" class="span-2">
<span>费用领域</span>
<select
v-model="riskRuleCreateForm.expense_category"
:disabled="riskRuleCreateBusy"
>
<option
v-for="option in riskRuleExpenseCategoryOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
</label>
<label class="risk-rule-create-toggle span-2">
<input
v-model="riskRuleCreateForm.requires_attachment"
type="checkbox"
:disabled="riskRuleCreateBusy"
/>
<span>
<strong>测试时需要上传附件</strong>
<small>适用于依赖发票行程单合同等单据 OCR 字段的规则不勾选则测试窗口不显示附件上传</small>
</span>
</label>
<label class="span-2">
<span>自然语言规则</span>
<textarea
@@ -1001,6 +1235,81 @@
</div>
</ConfirmDialog>
<RiskRuleTestDialog
:open="riskRuleTestOpen"
:rule="selectedSkill"
@close="closeRiskRuleTestDialog"
@report-saved="handleRiskRuleReportSaved"
/>
<ConfirmDialog
:open="riskRuleDeleteOpen"
badge="删除规则"
badge-tone="danger"
title="删除未发布风险规则"
description="该操作会删除规则草稿、版本记录和关联 JSON 文件。只有从未发布过的规则允许删除。"
cancel-text="取消"
confirm-text="确认删除"
busy-text="删除中..."
confirm-tone="danger"
confirm-icon="mdi mdi-delete-outline"
:busy="actionState === 'delete-risk-rule'"
@close="closeDeleteRiskRuleDialog"
@confirm="deleteSelectedRiskRule"
>
<div class="risk-rule-action-confirm">
<span>规则名称</span>
<strong>{{ selectedSkill?.name }}</strong>
</div>
</ConfirmDialog>
<ConfirmDialog
:open="riskRuleReturnOpen"
badge="回退规则"
badge-tone="warning"
title="回退风险规则"
description="回退后规则会回到草稿状态,编写人需要根据原因重新调整并测试。"
cancel-text="取消"
confirm-text="确认回退"
busy-text="回退中..."
confirm-tone="warning"
confirm-icon="mdi mdi-keyboard-return"
:busy="actionState === 'return-risk-rule'"
@close="closeReturnRiskRuleDialog"
@confirm="returnSelectedRiskRule"
>
<label class="risk-rule-action-note">
<span>回退原因</span>
<textarea
v-model="riskRuleReturnNote"
rows="4"
:disabled="actionState === 'return-risk-rule'"
placeholder="请说明需要编写人调整的规则问题"
></textarea>
</label>
</ConfirmDialog>
<ConfirmDialog
:open="riskRulePublishOpen"
badge="发布上线"
badge-tone="info"
title="发布风险规则"
description="发布后该规则会进入真实业务风险扫描,只加载正式上线规则。"
cancel-text="取消"
confirm-text="确认发布"
busy-text="发布中..."
confirm-tone="primary"
confirm-icon="mdi mdi-rocket-launch-outline"
:busy="actionState === 'publish-risk-rule'"
@close="closePublishRiskRuleDialog"
@confirm="publishSelectedRiskRule"
>
<div class="risk-rule-action-confirm">
<span>测试状态</span>
<strong>{{ riskRuleTestPassed ? '已确认通过' : '未确认通过' }}</strong>
</div>
</ConfirmDialog>
<ConfirmDialog
:open="Boolean(versionSwitchTarget)"
badge="切换版本"
@@ -1076,11 +1385,18 @@
</option>
</select>
</label>
<p v-if="!reviewSubmitReviewerLoading && !hasReviewSubmitReviewers" class="review-submit-hint">
当前没有可选的高级管理员请先在员工管理中配置具备管理员角色的员工
</p>
</div>
</ConfirmDialog>
<p v-if="!reviewSubmitReviewerLoading && !hasReviewSubmitReviewers" class="review-submit-hint">
当前没有可选的高级管理员请先在员工管理中配置具备管理员角色的员工
</p>
<div v-if="selectedSkillUsesJsonRisk" class="review-submit-test-state">
<span>测试确认</span>
<strong :class="{ passed: riskRuleTestPassed }">
{{ riskRuleTestPassed ? '当前版本已通过测试确认' : '当前版本尚未确认测试通过' }}
</strong>
<p>只有保存测试报告的风险规则才能提交给高级管理人员审核</p>
</div>
</div>
</ConfirmDialog>
<Transition name="drawer-fade">
<div v-if="versionTimelineOpen" class="rule-drawer-backdrop" @click.self="closeVersionTimeline">

View File

@@ -0,0 +1,788 @@
<template>
<section class="documents-page">
<article class="documents-list panel">
<nav class="status-tabs document-scope-tabs" aria-label="单据工作视角">
<button
v-for="tab in scopeTabItems"
:key="tab.value"
type="button"
:class="{ active: activeScopeTab === tab.value }"
@click="activeScopeTab = tab.value"
>
<span>{{ tab.label }}</span>
<span v-if="tab.badgeCount > 0" class="scope-tab-badge" aria-label="新增单据数">
{{ tab.badgeCount > 99 ? '99+' : tab.badgeCount }}
</span>
</button>
</nav>
<div class="document-toolbar">
<div class="filter-set">
<div class="list-search">
<i class="mdi mdi-magnify"></i>
<input v-model="listKeyword" type="search" :placeholder="activeFilterConfig.searchPlaceholder" />
</div>
<div class="document-status-filter" :aria-label="activeFilterConfig.statusTitle">
<div class="document-filter status-dropdown-filter" :class="{ open: openFilterKey === 'status' }">
<button
class="filter-btn status-filter-trigger"
type="button"
:aria-expanded="openFilterKey === 'status'"
@click="toggleFilter('status')"
>
<i class="mdi mdi-filter-variant"></i>
<span>{{ statusFilterLabel }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
<div
v-if="openFilterKey === 'status'"
class="document-filter-menu status-filter-menu"
role="listbox"
aria-label="单据状态"
>
<button
v-for="option in statusFilterOptions"
:key="option.value"
type="button"
role="option"
:aria-selected="activeStatusTab === option.value"
:class="{ active: activeStatusTab === option.value }"
@click="selectStatusTab(option.value)"
>
{{ option.label }}
</button>
</div>
</div>
</div>
<div v-if="showDocumentTypeFilter" class="document-filter">
<button class="filter-btn" type="button" @click="toggleFilter('documentType')">
<span>{{ documentTypeFilterLabel }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
<div v-if="openFilterKey === 'documentType'" class="document-filter-menu">
<button
v-for="option in documentTypeOptions"
:key="option.value"
type="button"
:class="{ active: activeDocumentType === option.value }"
@click="selectDocumentType(option.value)"
>
{{ option.label }}
</button>
</div>
</div>
<div class="document-filter">
<button class="filter-btn" type="button" @click="toggleFilter('scene')">
<span>{{ sceneFilterLabel }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
<div v-if="openFilterKey === 'scene'" class="document-filter-menu">
<button
v-for="option in sceneFilterOptions"
:key="option.value"
type="button"
:class="{ active: activeScene === option.value }"
@click="selectScene(option.value)"
>
{{ option.label }}
</button>
</div>
</div>
<div class="date-range-filter" :class="{ open: datePopover }">
<button class="filter-btn date-range-trigger" type="button" @click="datePopover = !datePopover">
<span class="date-range-label">{{ dateRangeLabel }}</span>
<i class="mdi mdi-calendar"></i>
</button>
<div v-if="datePopover" class="date-range-popover" role="dialog" aria-label="选择时间段">
<header>
<strong>选择时间段</strong>
<button type="button" aria-label="关闭" @click="datePopover = false">
<i class="mdi mdi-close"></i>
</button>
</header>
<div class="date-range-fields">
<label>
<span>开始日期</span>
<input v-model="rangeStart" type="date" />
</label>
<label>
<span>结束日期</span>
<input v-model="rangeEnd" type="date" />
</label>
</div>
<footer>
<button class="ghost-btn" type="button" @click="clearDateRange">清空</button>
<button class="apply-btn" type="button" :disabled="!rangeStart || !rangeEnd" @click="applyDateRange">应用</button>
</footer>
</div>
</div>
</div>
<div v-if="[DOCUMENT_SCOPE_APPLICATION, DOCUMENT_SCOPE_REIMBURSEMENT].includes(activeScopeTab)" class="document-actions">
<button v-if="activeScopeTab === DOCUMENT_SCOPE_APPLICATION" class="create-request-btn" type="button" @click="emit('create-application')">
<i class="mdi mdi-file-plus-outline"></i>
<span>发起申请</span>
</button>
<button v-if="activeScopeTab === DOCUMENT_SCOPE_REIMBURSEMENT" class="create-request-btn" type="button" @click="emit('create-request')">
<i class="mdi mdi-plus-circle-outline"></i>
<span>发起报销</span>
</button>
</div>
</div>
<div class="table-wrap" :class="{ 'is-empty': showEmpty }">
<div v-if="showLoading" class="table-state">
<TableLoadingState
title="单据数据同步中"
message="正在汇总当前报销、审批待办与归档单据"
icon="mdi mdi-file-document-multiple-outline"
/>
</div>
<div v-else-if="showError" class="table-state error">
<i class="mdi mdi-alert-circle-outline"></i>
<strong>单据中心加载失败</strong>
<p>{{ errorMessage }}</p>
<button class="retry-btn" type="button" @click="reloadAll">重新加载</button>
</div>
<TableEmptyState
v-else-if="showEmpty"
:eyebrow="emptyState.eyebrow"
:title="emptyState.title"
:description="emptyState.desc"
:icon="emptyState.icon"
:action-label="emptyState.actionLabel"
:action-icon="emptyState.actionIcon"
:tone="emptyState.tone"
:art-label="emptyState.artLabel"
:tips="emptyState.tips"
@action="handleEmptyAction"
/>
<table v-else>
<colgroup>
<col class="col-id">
<col class="col-created">
<col v-if="showStayTimeColumn" class="col-stay">
<col class="col-doc-type">
<col class="col-scene">
<col class="col-title">
<col class="col-amount">
<col class="col-node">
<col class="col-status">
<col class="col-updated">
</colgroup>
<thead>
<tr>
<th>单号</th>
<th>创建时间</th>
<th v-if="showStayTimeColumn">停留时间</th>
<th>单据类型</th>
<th>费用场景</th>
<th>事项</th>
<th>金额</th>
<th>当前环节</th>
<th>状态</th>
<th>更新时间</th>
</tr>
</thead>
<tbody>
<tr v-for="row in visibleRows" :key="row.documentKey" @click="openDocument(row)">
<td><strong class="doc-id">{{ row.documentNo }}</strong></td>
<td>{{ row.createdAtDisplay }}</td>
<td v-if="showStayTimeColumn">{{ row.stayTimeDisplay }}</td>
<td><span class="doc-kind-tag" :class="row.documentTypeCode">{{ row.documentTypeLabel }}</span></td>
<td><span class="type-tag" :class="row.typeTone">{{ row.typeLabel }}</span></td>
<td>{{ row.reason }}</td>
<td>{{ row.amountDisplay }}</td>
<td>{{ row.node }}</td>
<td><span class="status-tag" :class="row.statusTone">{{ row.statusLabel }}</span></td>
<td>{{ row.updatedAtDisplay }}</td>
</tr>
</tbody>
</table>
</div>
<footer v-if="showTable" class="list-foot">
<span class="page-summary"> {{ filteredRows.length }} 目前第 {{ currentPage }} </span>
<div class="pager" aria-label="分页">
<button class="page-nav" type="button" :disabled="currentPage === 1" aria-label="上一页" @click="currentPage--">
<i class="mdi mdi-chevron-left"></i>
</button>
<button
v-for="page in totalPages"
:key="page"
class="page-number"
:class="{ active: currentPage === page }"
type="button"
:aria-current="currentPage === page ? 'page' : undefined"
@click="currentPage = page"
>
{{ page }}
</button>
<button class="page-nav" type="button" :disabled="currentPage === totalPages" aria-label="下一页" @click="currentPage++">
<i class="mdi mdi-chevron-right"></i>
</button>
</div>
<div class="page-size-wrap">
<button class="page-size" type="button" @click="pageSizeOpen = !pageSizeOpen">
{{ pageSize }} / <i class="mdi mdi-chevron-down"></i>
</button>
<div v-if="pageSizeOpen" class="page-size-dropdown" role="listbox">
<button
v-for="size in pageSizes"
:key="size"
type="button"
role="option"
:aria-selected="pageSize === size"
:class="{ active: pageSize === size }"
@click="changePageSize(size)"
>
{{ size }} /
</button>
</div>
</div>
</footer>
</article>
</section>
</template>
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import TableEmptyState from '../components/shared/TableEmptyState.vue'
import TableLoadingState from '../components/shared/TableLoadingState.vue'
import { mapExpenseClaimToRequest } from '../composables/useRequests.js'
import { fetchApprovalExpenseClaims, fetchArchivedExpenseClaims } from '../services/reimbursements.js'
import {
extractDateText,
formatDocumentListTime,
resolveDocumentSortTime,
resolveDocumentStayTimeDisplay
} from '../utils/documentCenterTime.js'
import { normalizeRequestForUi } from '../utils/requestViewModel.js'
const DOCUMENT_TYPE_ALL = 'all'
const DOCUMENT_TYPE_APPLICATION = 'application'
const DOCUMENT_TYPE_REIMBURSEMENT = 'reimbursement'
const SCENE_ALL = 'all'
const DOCUMENT_SCOPE_APPLICATION = '申请单'
const DOCUMENT_SCOPE_REIMBURSEMENT = '报销单'
const DOCUMENT_SCOPE_REVIEW = '审核单'
const DOCUMENT_SCOPE_ARCHIVE = '归档'
const scopeTabs = [
DOCUMENT_SCOPE_APPLICATION,
DOCUMENT_SCOPE_REIMBURSEMENT,
DOCUMENT_SCOPE_REVIEW,
DOCUMENT_SCOPE_ARCHIVE
]
const statusTabs = ['全部', '草稿', '待提交', '审批中', '待补充', '已完成']
const FILTER_CONFIG_BY_SCOPE = {
[DOCUMENT_SCOPE_APPLICATION]: {
searchPlaceholder: '搜索申请单号、申请事项、申请场景...',
sceneFallbackLabel: '申请场景',
dateLabel: '申请时间',
statusTitle: '申请状态',
statusTabs: ['全部', '草稿', '审批中', '已完成'],
showDocumentType: false
},
[DOCUMENT_SCOPE_REIMBURSEMENT]: {
searchPlaceholder: '搜索报销单号、报销事由、费用场景...',
sceneFallbackLabel: '费用场景',
dateLabel: '报销时间',
statusTitle: '报销状态',
statusTabs,
showDocumentType: false
},
[DOCUMENT_SCOPE_REVIEW]: {
searchPlaceholder: '搜索审核单号、事项、当前环节...',
sceneFallbackLabel: '审核场景',
dateLabel: '审核时间',
statusTitle: '审核状态',
statusTabs: ['全部', '审批中', '待补充', '已完成'],
showDocumentType: false
},
[DOCUMENT_SCOPE_ARCHIVE]: {
searchPlaceholder: '搜索归档单号、事项、费用场景...',
sceneFallbackLabel: '归档场景',
dateLabel: '归档时间',
statusTitle: '归档状态',
statusTabs: ['全部', '已完成'],
showDocumentType: false
}
}
const pageSizes = [10, 20, 50]
const documentTypeOptions = [
{ value: DOCUMENT_TYPE_ALL, label: '单据类型' },
{ value: DOCUMENT_TYPE_APPLICATION, label: '申请单' },
{ value: DOCUMENT_TYPE_REIMBURSEMENT, label: '报销单' }
]
const props = defineProps({
filteredRequests: { type: Array, required: true },
hasData: { type: Boolean, default: false },
loading: { type: Boolean, default: false },
error: { type: String, default: '' }
})
const emit = defineEmits([
'open-document',
'create-request',
'create-application',
'reload',
'summary-change'
])
const activeScopeTab = ref(DOCUMENT_SCOPE_APPLICATION)
const activeStatusTab = ref('全部')
const activeDocumentType = ref(DOCUMENT_TYPE_ALL)
const activeScene = ref(SCENE_ALL)
const openFilterKey = ref('')
const listKeyword = ref('')
const datePopover = ref(false)
const rangeStart = ref('')
const rangeEnd = ref('')
const appliedStart = ref('')
const appliedEnd = ref('')
const currentPage = ref(1)
const pageSize = ref(20)
const pageSizeOpen = ref(false)
const archiveRows = ref([])
const approvalRows = ref([])
const supportingLoading = ref(false)
const supportingError = ref('')
const activeFilterConfig = computed(() =>
FILTER_CONFIG_BY_SCOPE[activeScopeTab.value] || FILTER_CONFIG_BY_SCOPE[DOCUMENT_SCOPE_APPLICATION]
)
const showDocumentTypeFilter = computed(() => Boolean(activeFilterConfig.value.showDocumentType))
const documentTypeFilterLabel = computed(() =>
documentTypeOptions.find((item) => item.value === activeDocumentType.value)?.label || '单据类型'
)
const statusFilterOptions = computed(() =>
activeFilterConfig.value.statusTabs.map((tab) => ({
value: tab,
label: tab === '全部' ? '全部状态' : tab
}))
)
const dateRangeLabel = computed(() => {
if (appliedStart.value && appliedEnd.value) {
return `${appliedStart.value} ~ ${appliedEnd.value}`
}
return activeFilterConfig.value.dateLabel
})
const ownedRows = computed(() =>
props.filteredRequests
.map((item) => buildDocumentRow(item, { source: 'owned' }))
.filter(Boolean)
)
const allSummaryRows = computed(() => mergeDocumentRows([...ownedRows.value, ...approvalRows.value, ...archiveRows.value]))
const scopeNewCountMap = computed(() => ({
[DOCUMENT_SCOPE_APPLICATION]: allSummaryRows.value.filter((row) => row.documentTypeCode === DOCUMENT_TYPE_APPLICATION).length,
[DOCUMENT_SCOPE_REIMBURSEMENT]: ownedRows.value.filter((row) => row.documentTypeCode === DOCUMENT_TYPE_REIMBURSEMENT).length,
[DOCUMENT_SCOPE_REVIEW]: approvalRows.value.length,
[DOCUMENT_SCOPE_ARCHIVE]: archiveRows.value.length
}))
const scopeTabItems = computed(() =>
scopeTabs.map((tab) => ({
value: tab,
label: tab,
badgeCount: scopeNewCountMap.value[tab] || 0
}))
)
const activeScopeRows = computed(() => {
if (activeScopeTab.value === DOCUMENT_SCOPE_APPLICATION) {
return allSummaryRows.value.filter((row) => row.documentTypeCode === DOCUMENT_TYPE_APPLICATION)
}
if (activeScopeTab.value === DOCUMENT_SCOPE_REIMBURSEMENT) {
return ownedRows.value.filter((row) => row.documentTypeCode === DOCUMENT_TYPE_REIMBURSEMENT)
}
if (activeScopeTab.value === DOCUMENT_SCOPE_REVIEW) {
return approvalRows.value
}
if (activeScopeTab.value === DOCUMENT_SCOPE_ARCHIVE) {
return archiveRows.value
}
return allSummaryRows.value.filter((row) => row.documentTypeCode === DOCUMENT_TYPE_APPLICATION)
})
const sceneFilterOptions = computed(() => {
const sceneMap = new Map([[SCENE_ALL, activeFilterConfig.value.sceneFallbackLabel]])
activeScopeRows.value.forEach((row) => {
if (row.typeCode && row.typeLabel) {
sceneMap.set(row.typeCode, row.typeLabel)
}
})
return Array.from(sceneMap, ([value, label]) => ({ value, label }))
})
const sceneFilterLabel = computed(() =>
sceneFilterOptions.value.find((item) => item.value === activeScene.value)?.label || activeFilterConfig.value.sceneFallbackLabel
)
const statusFilterLabel = computed(() =>
statusFilterOptions.value.find((item) => item.value === activeStatusTab.value)?.label || '全部状态'
)
const filteredRows = computed(() => {
const keyword = listKeyword.value.trim().toLowerCase()
return activeScopeRows.value.filter((row) => {
const matchesKeyword = !keyword || [
row.documentNo,
row.documentTypeLabel,
row.typeLabel,
row.reason,
row.node,
row.statusLabel
].filter(Boolean).join('').toLowerCase().includes(keyword)
const matchesDocumentType =
!showDocumentTypeFilter.value
|| activeDocumentType.value === DOCUMENT_TYPE_ALL
|| row.documentTypeCode === activeDocumentType.value
const matchesScene = activeScene.value === SCENE_ALL || row.typeCode === activeScene.value
const matchesStatus = matchesStatusTab(row, activeStatusTab.value)
const matchesDateRange = matchesAppliedDateRange(row)
return matchesKeyword && matchesDocumentType && matchesScene && matchesStatus && matchesDateRange
})
})
const totalPages = computed(() => Math.max(1, Math.ceil(filteredRows.value.length / pageSize.value)))
const visibleRows = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return filteredRows.value.slice(start, start + pageSize.value)
})
const showLoading = computed(() => (props.loading || supportingLoading.value) && !visibleRows.value.length)
const showError = computed(() => Boolean(props.error) && !visibleRows.value.length)
const errorMessage = computed(() => props.error || supportingError.value || '单据中心加载失败。')
const showEmpty = computed(() => !showLoading.value && !showError.value && visibleRows.value.length === 0)
const showTable = computed(() => !showLoading.value && !showError.value && visibleRows.value.length > 0)
const showStayTimeColumn = computed(() =>
[DOCUMENT_SCOPE_APPLICATION, DOCUMENT_SCOPE_REVIEW].includes(activeScopeTab.value)
)
const documentSummary = computed(() => {
const rows = allSummaryRows.value
return {
total: rows.length,
toSubmit: rows.filter((row) => ['draft', 'pending_submit'].includes(row.statusGroup)).length,
toProcess: approvalRows.value.length,
archived: archiveRows.value.length
}
})
const emptyState = computed(() => {
const filtered = hasActiveFilters()
if (
activeScopeTab.value === DOCUMENT_SCOPE_APPLICATION
|| activeDocumentType.value === DOCUMENT_TYPE_APPLICATION
) {
return {
eyebrow: '申请单',
title: '当前还没有申请单数据',
desc: '费用申请功能接入后,差旅、会务、办公采购等前置申请会统一汇总到这里。',
icon: 'mdi mdi-file-sign-outline',
actionLabel: '发起申请',
actionIcon: 'mdi mdi-file-plus-outline',
tone: 'sky',
artLabel: 'APPLY',
tips: ['旧报销中心仍保留', '申请批准后可继续发起报销']
}
}
return {
eyebrow: filtered ? '筛选结果为空' : '单据中心',
title: filtered ? '没有符合当前条件的单据' : `${activeScopeTab.value}”里暂时没有单据`,
desc: filtered
? '可以清空当前分类下的筛选条件后再看看。'
: '当前视角暂无可展示单据,可以切换其他视角或发起一笔报销。',
icon: filtered ? 'mdi mdi-magnify-scan' : 'mdi mdi-file-document-multiple-outline',
actionLabel: filtered ? '清空筛选' : '发起报销',
actionIcon: filtered ? 'mdi mdi-filter-remove-outline' : 'mdi mdi-plus-circle-outline',
tone: filtered ? 'sky' : 'emerald',
artLabel: filtered ? 'FILTER' : 'DOCS',
tips: ['单据中心已接入当前报销单据', '归档视角会同步归档中心数据']
}
})
function buildDocumentRow(request, options = {}) {
const normalized = normalizeRequestForUi(request)
if (!normalized) {
return null
}
const archived = Boolean(options.archived)
const statusGroup = resolveStatusGroup(normalized, archived)
const statusLabel = archived ? '已归档' : resolveStatusLabel(normalized, statusGroup)
const documentNo = normalized.documentNo || normalized.id || normalized.claimId || '待生成'
const claimId = normalized.claimId || normalized.id || documentNo
const createdAtSource = normalized.createdAt || normalized.submittedAt || normalized.applyTime || normalized.updatedAt
const updatedAtSource = normalized.updatedAt || normalized.submittedAt || normalized.createdAt || normalized.applyTime
return {
...normalized,
rawRequest: request,
documentKey: `${options.source || 'owned'}:${claimId || documentNo}`,
documentTypeCode: DOCUMENT_TYPE_REIMBURSEMENT,
documentTypeLabel: '报销单',
claimId,
documentNo,
node: archived ? '财务归档' : (normalized.node || normalized.workflowNode || '待提交'),
statusGroup,
statusLabel,
statusTone: archived ? 'archived' : resolveStatusTone(normalized, statusGroup),
source: options.source || 'owned',
archived,
createdAtDisplay: formatDocumentListTime(createdAtSource),
stayTimeDisplay: resolveDocumentStayTimeDisplay(normalized),
updatedAtDisplay: formatDocumentListTime(updatedAtSource),
sortTime: resolveDocumentSortTime(updatedAtSource)
}
}
function resolveStatusGroup(row, archived) {
if (archived) return 'completed'
if (row.approvalKey === 'draft') return 'draft'
if (row.approvalKey === 'supplement' && row.status === 'returned') return 'pending_submit'
if (row.approvalKey === 'supplement') return 'supplement'
if (row.approvalKey === 'in_progress') return 'in_progress'
if (row.approvalKey === 'completed') return 'completed'
return 'other'
}
function resolveStatusLabel(row, statusGroup) {
if (statusGroup === 'pending_submit') return '待提交'
return row.approval || row.approvalStatus || '处理中'
}
function resolveStatusTone(row, statusGroup) {
if (statusGroup === 'pending_submit') return 'warning'
return row.approvalTone || 'neutral'
}
function matchesStatusTab(row, tab) {
if (tab === '全部') return true
if (tab === '草稿') return row.statusGroup === 'draft'
if (tab === '待提交') return row.statusGroup === 'pending_submit'
if (tab === '审批中') return row.statusGroup === 'in_progress'
if (tab === '待补充') return row.statusGroup === 'supplement'
if (tab === '已完成') return row.statusGroup === 'completed'
return true
}
function matchesAppliedDateRange(row) {
if (!appliedStart.value || !appliedEnd.value) {
return true
}
const date = extractDateText(row.updatedAt || row.submittedAt || row.createdAt || row.applyTime)
return Boolean(date) && date >= appliedStart.value && date <= appliedEnd.value
}
function mergeDocumentRows(rows) {
const rowMap = new Map()
rows.filter(Boolean).forEach((row) => {
const key = row.claimId || row.documentNo || row.documentKey
const current = rowMap.get(key)
if (!current || resolveSourcePriority(row) >= resolveSourcePriority(current)) {
rowMap.set(key, row)
}
})
return Array.from(rowMap.values()).sort((left, right) => right.sortTime - left.sortTime)
}
function resolveSourcePriority(row) {
if (row.archived) return 3
if (row.source === 'approval') return 2
return 1
}
function hasActiveFilters() {
return Boolean(
listKeyword.value.trim()
|| activeStatusTab.value !== '全部'
|| (showDocumentTypeFilter.value && activeDocumentType.value !== DOCUMENT_TYPE_ALL)
|| activeScene.value !== SCENE_ALL
|| appliedStart.value
|| appliedEnd.value
)
}
function toggleFilter(key) {
openFilterKey.value = openFilterKey.value === key ? '' : key
}
function selectDocumentType(value) {
activeDocumentType.value = value
openFilterKey.value = ''
}
function selectScene(value) {
activeScene.value = value
openFilterKey.value = ''
}
function selectStatusTab(value) {
activeStatusTab.value = value
openFilterKey.value = ''
}
function applyDateRange() {
if (!rangeStart.value || !rangeEnd.value) {
return
}
appliedStart.value = rangeStart.value
appliedEnd.value = rangeEnd.value
datePopover.value = false
}
function clearDateRange() {
rangeStart.value = ''
rangeEnd.value = ''
appliedStart.value = ''
appliedEnd.value = ''
datePopover.value = false
}
function resetFilters() {
activeStatusTab.value = '全部'
activeDocumentType.value = DOCUMENT_TYPE_ALL
activeScene.value = SCENE_ALL
listKeyword.value = ''
clearDateRange()
openFilterKey.value = ''
currentPage.value = 1
}
function handleEmptyAction() {
if (activeDocumentType.value === DOCUMENT_TYPE_APPLICATION) {
emit('create-application')
return
}
if (hasActiveFilters()) {
resetFilters()
return
}
emit('create-request')
}
function changePageSize(size) {
pageSize.value = size
pageSizeOpen.value = false
currentPage.value = 1
}
function openDocument(row) {
emit('open-document', row.rawRequest || row)
}
async function loadSupportingRows() {
supportingLoading.value = true
supportingError.value = ''
const [approvalResult, archiveResult] = await Promise.allSettled([
fetchApprovalExpenseClaims(),
fetchArchivedExpenseClaims()
])
if (approvalResult.status === 'fulfilled') {
approvalRows.value = Array.isArray(approvalResult.value)
? approvalResult.value
.map((item) => mapExpenseClaimToRequest(item))
.map((item) => buildDocumentRow(item, { source: 'approval' }))
.filter(Boolean)
: []
} else {
approvalRows.value = []
}
if (archiveResult.status === 'fulfilled') {
archiveRows.value = Array.isArray(archiveResult.value)
? archiveResult.value
.map((item) => mapExpenseClaimToRequest(item))
.map((item) => buildDocumentRow(item, { source: 'archive', archived: true }))
.filter(Boolean)
: []
} else {
archiveRows.value = []
supportingError.value = archiveResult.reason instanceof Error
? archiveResult.reason.message
: '归档数据加载失败。'
}
supportingLoading.value = false
}
function reloadAll() {
emit('reload')
void loadSupportingRows()
}
watch(
[activeScopeTab, activeStatusTab, activeDocumentType, activeScene, listKeyword, appliedStart, appliedEnd],
() => {
currentPage.value = 1
pageSizeOpen.value = false
}
)
watch(activeFilterConfig, () => {
openFilterKey.value = ''
datePopover.value = false
pageSizeOpen.value = false
if (!showDocumentTypeFilter.value) {
activeDocumentType.value = DOCUMENT_TYPE_ALL
}
if (!statusFilterOptions.value.some((item) => item.value === activeStatusTab.value)) {
activeStatusTab.value = '全部'
}
})
watch(sceneFilterOptions, (options) => {
if (!options.some((item) => item.value === activeScene.value)) {
activeScene.value = SCENE_ALL
}
})
watch(documentSummary, (summary) => {
emit('summary-change', summary)
}, { immediate: true })
onMounted(() => {
void loadSupportingRows()
})
</script>
<style scoped src="../assets/styles/views/documents-center-view.css"></style>

View File

@@ -9,7 +9,7 @@
<article class="panel logs-console" :class="{ 'without-toolbar': activeTab === 'hermes' }">
<div class="console-tabs" role="tablist" aria-label="日志类型切换">
<button type="button" :class="{ active: activeTab === 'hermes' }" @click="activeTab = 'hermes'">
Hermes 运行日志
数字员工日志
</button>
<button type="button" :class="{ active: activeTab === 'system' }" @click="activeTab = 'system'">
系统日志
@@ -88,12 +88,11 @@
<thead>
<tr>
<th>时间</th>
<th>来源</th>
<th>模块</th>
<th>级别</th>
<th>状态</th>
<th>摘要</th>
<th>Trace ID</th>
<th>状态</th>
</tr>
</thead>
<tbody>
@@ -103,19 +102,12 @@
@click="selectRun(run.run_id)"
>
<td>{{ formatDateTime(run.started_at) }}</td>
<td>Hermes</td>
<td>{{ resolveRunModuleLabel(run) }}</td>
<td>
<span class="level-pill" :class="resolveLevelTone(resolveRunLevel(run))">
{{ resolveRunLevel(run) }}
</span>
</td>
<td class="summary-cell">
<strong>{{ resolveRunTitle(run) }}</strong>
<span>{{ formatSummary(run.result_summary) }}</span>
<em class="summary-meta">{{ resolveRunSummaryMeta(run) }}</em>
</td>
<td class="trace-cell">{{ run.run_id }}</td>
<td>
<div class="status-stack">
<span class="status-pill" :class="resolveStatusTone(run)">
@@ -124,6 +116,12 @@
<span class="status-note">{{ resolveRunStatusNote(run) }}</span>
</div>
</td>
<td class="summary-cell">
<strong>{{ resolveRunTitle(run) }}</strong>
<span>{{ formatSummary(run.result_summary) }}</span>
<em class="summary-meta">{{ resolveRunSummaryMeta(run) }}</em>
</td>
<td class="trace-cell">{{ run.run_id }}</td>
</tr>
</tbody>
</table>
@@ -151,9 +149,9 @@
<th>级别</th>
<th>事件类型</th>
<th>模块</th>
<th>结果</th>
<th>摘要</th>
<th>Request ID</th>
<th>结果</th>
</tr>
</thead>
<tbody>
@@ -170,16 +168,16 @@
</td>
<td>{{ entry.event_type }}</td>
<td>{{ entry.logger || '未标记' }}</td>
<td class="summary-cell">
<strong>{{ entry.summary || entry.message }}</strong>
<span>{{ formatSummary(entry.message) }}</span>
</td>
<td class="trace-cell">{{ entry.request_id || '—' }}</td>
<td>
<span class="status-pill" :class="resolveSystemOutcomeTone(entry.outcome)">
{{ entry.outcome }}
</span>
</td>
<td class="summary-cell">
<strong>{{ entry.summary || entry.message }}</strong>
<span>{{ formatSummary(entry.message) }}</span>
</td>
<td class="trace-cell">{{ entry.request_id || '—' }}</td>
</tr>
</tbody>
</table>

View File

@@ -3,6 +3,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
import { fetchEmployees } from '../../services/employees.js'
import RiskRuleFlowDiagram from '../../components/shared/RiskRuleFlowDiagram.vue'
import RiskRuleTestDialog from '../../components/shared/RiskRuleTestDialog.vue'
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
import TableEmptyState from '../../components/shared/TableEmptyState.vue'
import { useSystemState } from '../../composables/useSystemState.js'
@@ -11,6 +12,7 @@ import {
activateAgentAsset,
createAgentAssetReview,
createAgentAssetVersion,
deleteAgentAsset,
fetchAgentAssetDetail,
fetchAgentAssets,
fetchAgentAssetSpreadsheetBlob,
@@ -20,9 +22,12 @@ import {
fetchAgentAssetVersionTimeline,
fetchAgentRuns,
generateRiskRuleAsset,
publishRiskRuleAsset,
returnRiskRuleAsset,
saveAgentAssetRuleJson,
importAgentAssetSpreadsheetContent,
restoreAgentAssetVersion,
setRiskRuleAssetEnabled,
updateAgentAsset
} from '../../services/agentAssets.js'
import { loadOnlyOfficeApi } from '../../services/onlyoffice.js'
@@ -39,6 +44,8 @@ import {
import {
TAB_META,
STATUS_OPTIONS,
ENABLED_STATE_OPTIONS,
ONLINE_STATE_OPTIONS,
RISK_SCENARIO_OPTIONS,
normalizeText,
readConfigJson,
@@ -63,6 +70,7 @@ import {
import {
createDefaultRiskRuleForm,
RISK_RULE_CREATE_DOMAIN_OPTIONS,
RISK_RULE_EXPENSE_CATEGORY_OPTIONS,
RISK_RULE_LEVEL_OPTIONS
} from './auditViewRiskRuleModel.js'
@@ -71,6 +79,7 @@ export default {
components: {
ConfirmDialog,
RiskRuleFlowDiagram,
RiskRuleTestDialog,
TableLoadingState,
TableEmptyState
},
@@ -93,6 +102,8 @@ export default {
const selectedOwner = ref('')
const selectedStatus = ref('')
const selectedRiskScenario = ref('')
const selectedOnlineState = ref('')
const selectedEnabledState = ref('')
const loading = ref(false)
const errorMessage = ref('')
const detailLoading = ref(false)
@@ -105,6 +116,11 @@ export default {
const reviewSubmitReviewerOptions = ref([])
const riskRuleCreateOpen = ref(false)
const riskRuleCreateForm = ref(createDefaultRiskRuleForm())
const riskRuleTestOpen = ref(false)
const riskRuleDeleteOpen = ref(false)
const riskRuleReturnOpen = ref(false)
const riskRulePublishOpen = ref(false)
const riskRuleReturnNote = ref('')
const runLoading = ref(false)
const runs = ref([])
const spreadsheetUploadInput = ref(null)
@@ -146,6 +162,8 @@ export default {
const showMetricColumn = computed(() => activeMeta.value.showMetricColumn !== false)
const showVersionColumn = computed(() => activeMeta.value.showVersionColumn !== false)
const showStatusColumn = computed(() => activeMeta.value.showStatusColumn !== false)
const showOnlineColumn = computed(() => activeType.value === 'riskRules')
const showEnabledColumn = computed(() => activeType.value === 'riskRules')
const selectedSkillIsRule = computed(() => selectedSkill.value?.type === 'rules')
const selectedSkillUsesSpreadsheet = computed(
() => selectedSkillIsRule.value && Boolean(selectedSkill.value?.usesSpreadsheetRule)
@@ -165,6 +183,46 @@ export default {
const canCreateRiskRule = computed(
() => activeType.value === 'riskRules' && (isAdmin.value || isFinance.value) && !detailBusy.value
)
const latestRiskRuleTestSummary = computed(() => selectedSkill.value?.latestTestSummary || null)
const riskRuleTestPassed = computed(() => Boolean(latestRiskRuleTestSummary.value?.test_passed))
const riskRuleInReview = computed(
() => selectedSkillUsesJsonRisk.value && selectedSkill.value?.statusValue === 'review'
)
const canOpenRiskRuleTest = computed(
() =>
selectedSkillUsesJsonRisk.value &&
canEditSelected.value &&
Boolean(selectedSkill.value?.id) &&
!detailBusy.value
)
const canDeleteRiskRule = computed(
() =>
selectedSkillUsesJsonRisk.value &&
canEditSelected.value &&
Boolean(selectedSkill.value?.id) &&
!normalizeText(selectedSkill.value?.publishedVersion).replace('-', '') &&
!detailBusy.value
)
const canSubmitRiskRuleReview = computed(
() =>
selectedSkillUsesJsonRisk.value &&
canSubmitReview.value &&
!riskRuleInReview.value &&
riskRuleTestPassed.value
)
const canReturnRiskRule = computed(
() => selectedSkillUsesJsonRisk.value && canManageSelected.value && riskRuleInReview.value
)
const canPublishRiskRule = computed(
() =>
selectedSkillUsesJsonRisk.value &&
canManageSelected.value &&
riskRuleInReview.value &&
riskRuleTestPassed.value
)
const canToggleRiskRuleEnabled = computed(
() => selectedSkillUsesJsonRisk.value && canManageSelected.value && !detailBusy.value
)
const riskRuleCreateBusy = computed(() => actionState.value === 'generate-risk-rule')
const canEditMarkdown = computed(() => canEditSelected.value && selectedSkillIsRule.value)
const isDisplayingWorkingVersion = computed(
@@ -276,7 +334,7 @@ export default {
const ownerOptions = computed(() => {
const uniqueOwners = [...new Set(currentAssets.value.map((item) => item.owner).filter(Boolean))]
return [
{ value: '', label: '全部负责人' },
{ value: '', label: activeType.value === 'riskRules' ? '全部审核人' : '全部负责人' },
...uniqueOwners.map((value) => ({
value,
label: value
@@ -287,7 +345,9 @@ export default {
() => domainOptions.value.find((item) => item.value === selectedDomain.value)?.label || '业务域'
)
const selectedOwnerLabel = computed(
() => ownerOptions.value.find((item) => item.value === selectedOwner.value)?.label || '负责人'
() =>
ownerOptions.value.find((item) => item.value === selectedOwner.value)?.label ||
(activeType.value === 'riskRules' ? '审核人' : '负责人')
)
const selectedStatusLabel = computed(
() => STATUS_OPTIONS.find((item) => item.value === selectedStatus.value)?.label || '状态'
@@ -296,11 +356,23 @@ export default {
['financialRules', 'riskRules'].includes(activeType.value)
)
const showStatusFilter = computed(() => activeType.value !== 'riskRules')
const showOnlineFilter = computed(() => activeType.value === 'riskRules')
const showEnabledFilter = computed(() => activeType.value === 'riskRules')
const selectedRiskScenarioLabel = computed(
() =>
RISK_SCENARIO_OPTIONS.find((item) => item.value === selectedRiskScenario.value)?.label ||
'使用场景'
)
const selectedOnlineStateLabel = computed(
() =>
ONLINE_STATE_OPTIONS.find((item) => item.value === selectedOnlineState.value)?.label ||
'是否上线'
)
const selectedEnabledStateLabel = computed(
() =>
ENABLED_STATE_OPTIONS.find((item) => item.value === selectedEnabledState.value)?.label ||
'是否启用'
)
const activeFilterTokens = computed(() => {
const tokens = []
@@ -313,8 +385,14 @@ export default {
if (showStatusFilter.value && selectedStatus.value) {
tokens.push(`状态:${resolveStatusMeta(selectedStatus.value).label}`)
}
if (showOnlineFilter.value && selectedOnlineState.value) {
tokens.push(`是否上线:${selectedOnlineStateLabel.value}`)
}
if (showEnabledFilter.value && selectedEnabledState.value) {
tokens.push(`是否启用:${selectedEnabledStateLabel.value}`)
}
if (selectedOwner.value) {
tokens.push(`负责人${selectedOwner.value}`)
tokens.push(`${activeType.value === 'riskRules' ? '审核人' : '负责人'}${selectedOwner.value}`)
}
if (keyword.value.trim()) {
tokens.push(`搜索:${keyword.value.trim()}`)
@@ -326,9 +404,11 @@ export default {
const hasFilters = activeFilterTokens.value.length > 0
const supportedFilters = [
'业务域',
'负责人',
activeType.value === 'riskRules' ? '审核人' : '负责人',
...(showRiskScenarioFilter.value ? ['使用场景'] : []),
...(showStatusFilter.value ? ['状态'] : []),
...(showOnlineFilter.value ? ['是否上线'] : []),
...(showEnabledFilter.value ? ['是否启用'] : []),
'关键词'
]
@@ -409,8 +489,12 @@ export default {
selectedOwner: selectedOwner.value,
selectedStatus: selectedStatus.value,
selectedRiskScenario: selectedRiskScenario.value,
selectedOnlineState: selectedOnlineState.value,
selectedEnabledState: selectedEnabledState.value,
showStatusFilter: showStatusFilter.value,
showRiskScenarioFilter: showRiskScenarioFilter.value
showRiskScenarioFilter: showRiskScenarioFilter.value,
showOnlineFilter: showOnlineFilter.value,
showEnabledFilter: showEnabledFilter.value
})
)
@@ -455,6 +539,8 @@ export default {
selectedOwner.value = ''
selectedStatus.value = ''
selectedRiskScenario.value = ''
selectedOnlineState.value = ''
selectedEnabledState.value = ''
activeFilterPopover.value = ''
}
@@ -488,6 +574,12 @@ export default {
if (name === 'riskScenario') {
selectedRiskScenario.value = value
}
if (name === 'online') {
selectedOnlineState.value = value
}
if (name === 'enabled') {
selectedEnabledState.value = value
}
closeFilterPopover()
}
@@ -536,7 +628,11 @@ export default {
const detail = await generateRiskRuleAsset(
{
business_domain: riskRuleCreateForm.value.business_domain,
expense_category: riskRuleCreateForm.value.business_domain === 'expense'
? riskRuleCreateForm.value.expense_category
: null,
risk_level: riskRuleCreateForm.value.risk_level,
requires_attachment: Boolean(riskRuleCreateForm.value.requires_attachment),
natural_language: naturalLanguage
},
{ actor: resolveActor() }
@@ -1105,6 +1201,11 @@ export default {
versionSwitchTarget.value = null
versionTimelineOpen.value = false
versionTimelineItems.value = []
riskRuleTestOpen.value = false
riskRuleDeleteOpen.value = false
riskRuleReturnOpen.value = false
riskRulePublishOpen.value = false
riskRuleReturnNote.value = ''
}
function openVersionSwitch(version) {
@@ -1299,6 +1400,10 @@ export default {
if (!selectedSkill.value || !canSubmitReview.value || detailBusy.value) {
return
}
if (selectedSkillUsesJsonRisk.value && !riskRuleTestPassed.value) {
toast('请先在“测试规则”中保存测试通过报告,再提交审核。')
return
}
reviewSubmitVersion.value = selectedSkill.value.workingVersion || selectedSkill.value.displayVersion || ''
reviewSubmitReviewer.value = selectedSkill.value.reviewer || ''
reviewSubmitOpen.value = true
@@ -1323,6 +1428,10 @@ export default {
if (!selectedSkill.value || !canSubmitReview.value || detailBusy.value) {
return
}
if (selectedSkillUsesJsonRisk.value && !riskRuleTestPassed.value) {
toast('当前规则版本尚未确认测试通过,不能提交审核。')
return
}
const version = normalizeText(reviewSubmitVersion.value)
const reviewer = normalizeText(reviewSubmitReviewer.value)
if (!version) {
@@ -1357,6 +1466,155 @@ export default {
}
}
function openRiskRuleTestDialog() {
if (!canOpenRiskRuleTest.value) {
if (!selectedSkill.value?.id) {
toast('规则详情还没有加载完成,请稍后再测试。')
}
return
}
riskRuleTestOpen.value = true
}
function closeRiskRuleTestDialog() {
riskRuleTestOpen.value = false
}
async function handleRiskRuleReportSaved(summary) {
if (selectedSkill.value) {
selectedSkill.value.latestTestSummary = summary
}
await refreshCurrentAssets()
if (selectedSkill.value?.id) {
await loadSelectedAssetDetail(selectedSkill.value.id)
}
}
function openDeleteRiskRuleDialog() {
if (!canDeleteRiskRule.value) {
return
}
riskRuleDeleteOpen.value = true
}
function closeDeleteRiskRuleDialog() {
if (detailBusy.value) {
return
}
riskRuleDeleteOpen.value = false
}
async function deleteSelectedRiskRule() {
if (!selectedSkill.value || !canDeleteRiskRule.value || detailBusy.value) {
return
}
actionState.value = 'delete-risk-rule'
try {
await deleteAgentAsset(selectedSkill.value.id, { actor: resolveActor() })
riskRuleDeleteOpen.value = false
const deletedName = selectedSkill.value.name
closeDetail()
await refreshCurrentAssets()
toast(`风险规则“${deletedName}”已删除。`)
} catch (error) {
toast(error?.message || '风险规则删除失败,请稍后重试。')
} finally {
actionState.value = ''
}
}
function openReturnRiskRuleDialog() {
if (!canReturnRiskRule.value) {
return
}
riskRuleReturnNote.value = ''
riskRuleReturnOpen.value = true
}
function closeReturnRiskRuleDialog() {
if (detailBusy.value) {
return
}
riskRuleReturnOpen.value = false
}
async function returnSelectedRiskRule() {
if (!selectedSkill.value || !canReturnRiskRule.value || detailBusy.value) {
return
}
const note = normalizeText(riskRuleReturnNote.value)
if (!note) {
toast('请填写回退原因。')
return
}
actionState.value = 'return-risk-rule'
try {
await returnRiskRuleAsset(selectedSkill.value.id, { note }, { actor: resolveActor() })
riskRuleReturnOpen.value = false
await refreshCurrentAssets()
await loadSelectedAssetDetail(selectedSkill.value.id)
toast('风险规则已回退到草稿。')
} catch (error) {
toast(error?.message || '风险规则回退失败,请稍后重试。')
} finally {
actionState.value = ''
}
}
function openPublishRiskRuleDialog() {
if (!canPublishRiskRule.value) {
if (!riskRuleTestPassed.value) {
toast('请先确认测试报告通过,再发布上线。')
}
return
}
riskRulePublishOpen.value = true
}
function closePublishRiskRuleDialog() {
if (detailBusy.value) {
return
}
riskRulePublishOpen.value = false
}
async function publishSelectedRiskRule() {
if (!selectedSkill.value || !canPublishRiskRule.value || detailBusy.value) {
return
}
actionState.value = 'publish-risk-rule'
try {
await publishRiskRuleAsset(selectedSkill.value.id, { actor: resolveActor() })
riskRulePublishOpen.value = false
await refreshCurrentAssets()
await loadSelectedAssetDetail(selectedSkill.value.id)
toast('风险规则已发布上线。')
} catch (error) {
toast(error?.message || '风险规则发布失败,请稍后重试。')
} finally {
actionState.value = ''
}
}
async function toggleSelectedRiskRuleEnabled() {
if (!selectedSkill.value || !canToggleRiskRuleEnabled.value || detailBusy.value) {
return
}
const assetId = selectedSkill.value.id
const nextEnabled = !selectedSkill.value.isEnabledValue
actionState.value = 'toggle-risk-rule-enabled'
try {
await setRiskRuleAssetEnabled(assetId, nextEnabled, { actor: resolveActor() })
await refreshCurrentAssets()
await loadSelectedAssetDetail(assetId)
toast(nextEnabled ? '风险规则已启用。' : '风险规则已停用,不会进入业务扫描。')
} catch (error) {
toast(error?.message || '风险规则启用状态更新失败,请稍后重试。')
} finally {
actionState.value = ''
}
}
async function activateSelectedRule() {
if (!selectedSkill.value || !selectedSkillIsRule.value || !canManageSelected.value || detailBusy.value) {
return
@@ -1458,6 +1716,8 @@ export default {
showVersionColumn,
showMetricColumn,
showStatusColumn,
showOnlineColumn,
showEnabledColumn,
visibleSkills,
auditEmptyState,
loading,
@@ -1468,21 +1728,37 @@ export default {
selectedOwner,
selectedStatus,
selectedRiskScenario,
selectedOnlineState,
selectedEnabledState,
selectedDomainLabel,
selectedOwnerLabel,
selectedStatusLabel,
selectedRiskScenarioLabel,
selectedOnlineStateLabel,
selectedEnabledStateLabel,
showRiskScenarioFilter,
showStatusFilter,
showOnlineFilter,
showEnabledFilter,
domainOptions,
ownerOptions,
statusOptions: STATUS_OPTIONS,
riskScenarioOptions: RISK_SCENARIO_OPTIONS,
onlineStateOptions: ONLINE_STATE_OPTIONS,
enabledStateOptions: ENABLED_STATE_OPTIONS,
activeFilterPopover,
activeFilterTokens,
canManageSelected,
canEditSelected,
canCreateRiskRule,
canOpenRiskRuleTest,
canDeleteRiskRule,
canSubmitRiskRuleReview,
canReturnRiskRule,
canPublishRiskRule,
canToggleRiskRuleEnabled,
riskRuleTestPassed,
riskRuleInReview,
canSubmitReview,
hasReviewSubmitReviewers,
canReviewSelected,
@@ -1509,7 +1785,13 @@ export default {
riskRuleCreateOpen,
riskRuleCreateForm,
riskRuleCreateBusy,
riskRuleTestOpen,
riskRuleDeleteOpen,
riskRuleReturnOpen,
riskRulePublishOpen,
riskRuleReturnNote,
riskRuleCreateDomainOptions: RISK_RULE_CREATE_DOMAIN_OPTIONS,
riskRuleExpenseCategoryOptions: RISK_RULE_EXPENSE_CATEGORY_OPTIONS,
riskRuleLevelOptions: RISK_RULE_LEVEL_OPTIONS,
showReviewNote,
spreadsheetUploadInput,
@@ -1549,6 +1831,19 @@ export default {
openSubmitReviewDialog,
closeSubmitReviewDialog,
submitSelectedRuleForReview,
openRiskRuleTestDialog,
closeRiskRuleTestDialog,
handleRiskRuleReportSaved,
openDeleteRiskRuleDialog,
closeDeleteRiskRuleDialog,
deleteSelectedRiskRule,
openReturnRiskRuleDialog,
closeReturnRiskRuleDialog,
returnSelectedRiskRule,
openPublishRiskRuleDialog,
closePublishRiskRuleDialog,
publishSelectedRiskRule,
toggleSelectedRiskRuleEnabled,
activateSelectedRule,
restoreSelectedVersion,
openVersionTimeline,

View File

@@ -7,6 +7,13 @@ export const RULE_TABLE_COLUMNS = {
metric: '修改人'
}
export const RISK_RULE_TABLE_COLUMNS = {
...RULE_TABLE_COLUMNS,
owner: '审核人',
metric: '发布者',
updatedAt: '发布时间'
}
export const TYPE_META = {
rules: {
assetType: 'rule',
@@ -89,8 +96,8 @@ export const TAB_META = {
typeLabel: '风险规则',
createButtonLabel: '新建风险规则',
hintText: '仅展示平台风险规则;适用场景按差旅、发票、餐饮招待等分类,可用「使用场景」筛选。',
searchPlaceholder: '搜索风险规则名称、编码或负责人',
tableColumns: RULE_TABLE_COLUMNS,
searchPlaceholder: '搜索风险规则名称、编码或审核人',
tableColumns: RISK_RULE_TABLE_COLUMNS,
showRuntimeColumn: false,
showVersionColumn: false,
showStatusColumn: false,
@@ -249,6 +256,18 @@ export const STATUS_OPTIONS = [
{ value: 'disabled', label: '已停用' }
]
export const ONLINE_STATE_OPTIONS = [
{ value: '', label: '全部上线状态' },
{ value: 'online', label: '已上线' },
{ value: 'offline', label: '未上线' }
]
export const ENABLED_STATE_OPTIONS = [
{ value: '', label: '全部启用状态' },
{ value: 'enabled', label: '已启用' },
{ value: 'disabled', label: '已停用' }
]
export const EXPENSE_RULE_BLOCK_PATTERN = /```expense-rule\s*([\s\S]*?)\s*```/i
export const RULE_SPREADSHEET_BLOCK_PATTERN = /```rule-spreadsheet\s*([\s\S]*?)\s*```/i

View File

@@ -34,6 +34,7 @@ import {
export {
DETAIL_TITLES,
DOMAIN_LABELS,
ENABLED_STATE_OPTIONS,
EXPENSE_RULE_BLOCK_PATTERN,
JSON_RISK_DETAIL_MODE,
LEGACY_RISK_SCENARIO_KEYS,
@@ -43,6 +44,7 @@ export {
REVIEW_META,
RISK_SCENARIO_OPTIONS,
RISK_SCENARIO_VALUES,
RISK_RULE_TABLE_COLUMNS,
RULE_SPREADSHEET_BLOCK_PATTERN,
RULE_TABLE_COLUMNS,
RULE_TAB_TAG_ALIASES,
@@ -51,6 +53,7 @@ export {
SPREADSHEET_DETAIL_MODE,
STATUS_META,
STATUS_OPTIONS,
ONLINE_STATE_OPTIONS,
TAB_META,
TYPE_META,
VERSION_STATE_META
@@ -189,6 +192,17 @@ export function readConfigJson(value) {
return {}
}
export function resolveRiskRuleEnabled(source, rulePayload = null) {
const configJson = readConfigJson(source)
if (isPlainObject(rulePayload) && rulePayload.enabled === false) {
return false
}
if (source?.enabled === false || configJson.enabled === false) {
return false
}
return true
}
export function readRuleDocumentMeta(value) {
const configJson = readConfigJson(value)
return isPlainObject(configJson.rule_document) ? configJson.rule_document : null
@@ -417,6 +431,12 @@ export function buildRiskListSubtitle(text, maxLength = 42) {
export function applyRiskRuleJsonState(target, payload, apiPayload) {
const rulePayload = isPlainObject(payload) ? payload : {}
const metadata = rulePayload.metadata && typeof rulePayload.metadata === 'object'
? rulePayload.metadata
: {}
const apiConfig = apiPayload?.config_json && typeof apiPayload.config_json === 'object'
? apiPayload.config_json
: {}
const fullDescription =
resolveRiskRuleDescription(rulePayload) ||
normalizeText(apiPayload?.description) ||
@@ -427,6 +447,21 @@ export function applyRiskRuleJsonState(target, payload, apiPayload) {
const riskRuleFields = resolveRiskRuleFields(rulePayload)
const riskRuleCreatedAt = resolveRiskRuleCreatedAt(rulePayload, target.createdAt || target.updatedAt)
const statusValue = apiPayload?.status || target.statusValue || 'draft'
const isOnlineLabel = statusValue === 'active' ? '是' : '否'
const isEnabledValue = resolveRiskRuleEnabled(target, rulePayload)
const publisher = apiPayload?.created_by || target.publisher || (apiPayload?.recent_versions && apiPayload.recent_versions[0]?.created_by) || '系统管理员'
let publishedAt = target.publishedAt || '-'
if (apiPayload?.recent_versions) {
const history = buildHistory(apiPayload.recent_versions, { ...target, config_json: payload })
const publishedVersionObj = history.find((item) => item.isPublished || item.lifecycleState === 'published')
publishedAt = publishedVersionObj ? publishedVersionObj.time : (apiPayload?.latest_review?.reviewed_at ? formatDateTime(apiPayload.latest_review.reviewed_at) : '-')
} else if (apiPayload?.latest_review?.reviewed_at) {
publishedAt = formatDateTime(apiPayload.latest_review.reviewed_at)
}
return {
...target,
riskRuleDescription: fullDescription,
@@ -444,6 +479,12 @@ export function applyRiskRuleJsonState(target, payload, apiPayload) {
riskRuleFlow: resolveRiskRuleFlow(rulePayload, riskRuleFields),
riskRuleFlowDiagramSvg:
normalizeText(apiPayload?.flow_diagram_svg) || resolveRiskRuleFlowDiagramSvg(rulePayload),
riskRuleRequiresAttachment: Boolean(
rulePayload.requires_attachment ||
metadata.requires_attachment ||
apiConfig.requires_attachment ||
target.configJson?.requires_attachment
),
riskRuleSummary: {
name: apiPayload?.name || target.name,
evaluator: apiPayload?.evaluator || rulePayload.evaluator || '',
@@ -451,7 +492,13 @@ export function applyRiskRuleJsonState(target, payload, apiPayload) {
inputs: apiPayload?.inputs || rulePayload.inputs || {},
outcomes: apiPayload?.outcomes || rulePayload.outcomes || {}
},
riskRuleJsonText: JSON.stringify(rulePayload, null, 2)
riskRuleJsonText: JSON.stringify(rulePayload, null, 2),
isOnlineLabel,
isEnabledValue,
isEnabledLabel: isEnabledValue ? '是' : '否',
isEnabledTone: isEnabledValue ? 'success' : 'disabled',
publisher,
publishedAt
}
}
@@ -810,6 +857,15 @@ export function buildListItem(asset) {
const listSubtitle = isRiskRule
? buildRiskListSubtitle(asset.description)
: normalizeText(asset.description)
const isOnlineValue = asset.status === 'active'
const isEnabledValue = usesJsonRiskRule ? resolveRiskRuleEnabled(asset) : true
const reviewer = normalizeText(asset.reviewer) || '待分配'
const publisher = isRiskRule
? isOnlineValue
? normalizeText(asset.published_by) || reviewer || modifiedBy || '系统管理员'
: '-'
: ''
const publishedAt = isRiskRule && isOnlineValue ? formatDateTime(asset.published_at || asset.updated_at) : '-'
return {
id: asset.id,
@@ -826,8 +882,8 @@ export function buildListItem(asset) {
summary: listSubtitle,
listSubtitle,
category: resolveDomainLabel(asset.domain),
owner: asset.owner,
reviewer: asset.reviewer || '待分配',
owner: isRiskRule ? reviewer : asset.owner,
reviewer,
scope: typeKey === 'rules' ? ruleScenarioCategory || '通用' : formatScenarioList(asset.scenario_json),
riskCategory: ruleScenarioCategory,
model: buildRowRuntime(asset, typeKey),
@@ -838,10 +894,18 @@ export function buildListItem(asset) {
status: statusMeta.label,
statusValue: asset.status,
statusTone: statusMeta.tone,
hitRate: buildRowMetric({ ...asset, modified_by: modifiedBy }, typeKey),
hitRate: isRiskRule ? publisher : buildRowMetric({ ...asset, modified_by: modifiedBy }, typeKey),
publisher,
publishedAt,
isOnlineValue,
isOnlineLabel: isOnlineValue ? '是' : '否',
isOnlineTone: isOnlineValue ? 'success' : 'disabled',
isEnabledValue,
isEnabledLabel: isEnabledValue ? '是' : '否',
isEnabledTone: isEnabledValue ? 'success' : 'disabled',
modifiedBy,
changeCount,
updatedAt: formatDateTime(asset.updated_at),
updatedAt: isRiskRule ? publishedAt : formatDateTime(asset.updated_at),
badgeTone: tabMeta.badgeTone,
domainValue: asset.domain
}
@@ -1218,6 +1282,7 @@ export function buildDetailViewModel(detail, runs) {
const ruleTemplateLabel = normalizeText(configJson.rule_template_label) || resolveRuleTemplateLabel(ruleTemplateKey)
const runtimeKind = normalizeText(configJson.runtime_kind || previewRuntimeRule.kind) || 'policy_rule_draft'
const ruleScenarioCategory = typeKey === 'rules' ? resolveRuleScenarioCategory(detail, tabId) : ''
const isEnabledValue = usesJsonRiskRule ? resolveRiskRuleEnabled(detail) : true
return {
id: detail.id,
@@ -1258,10 +1323,28 @@ export function buildDetailViewModel(detail, runs) {
riskRuleSeverityLabel: '中风险',
riskRuleCreatedAt: formatDateTime(detail.created_at),
riskRuleAgeLabel: formatRiskRuleAge(detail.created_at),
isOnlineLabel: detail.status === 'active' ? '是' : '否',
isEnabledValue,
isEnabledLabel: isEnabledValue ? '是' : '否',
isEnabledTone: isEnabledValue ? 'success' : 'disabled',
publisher:
detail.status === 'active'
? normalizeText(detail.published_by) ||
detail.latest_review?.reviewer ||
detail.reviewer ||
(detail.recent_versions && detail.recent_versions[0]?.created_by) ||
'系统管理员'
: '-',
publishedAt:
history.find((item) => item.isPublished || item.lifecycleState === 'published')?.time ||
(detail.published_at ? formatDateTime(detail.published_at) : '') ||
(detail.latest_review?.reviewed_at ? formatDateTime(detail.latest_review.reviewed_at) : '-'),
riskRuleFields: [],
riskRuleFieldSummary: '未识别字段',
riskRuleFlow: resolveRiskRuleFlow({}, []),
riskRuleFlowDiagramSvg: normalizeText(configJson.flow_diagram_svg),
riskRuleRequiresAttachment: Boolean(configJson.requires_attachment),
latestTestSummary: detail.latest_test_summary || detail.latestTestSummary || null,
riskCategory: typeKey === 'rules' ? ruleScenarioCategory : '',
ruleDocument,
scenarioList: typeKey === 'rules' && ruleScenarioCategory

View File

@@ -4,6 +4,18 @@ export const RISK_RULE_CREATE_DOMAIN_OPTIONS = [
{ value: 'ap', label: '应付' }
]
export const RISK_RULE_EXPENSE_CATEGORY_OPTIONS = [
{ value: 'travel', label: '差旅费' },
{ value: 'hotel', label: '住宿费' },
{ value: 'transport', label: '交通费' },
{ value: 'meal', label: '业务招待费' },
{ value: 'meeting', label: '会务费' },
{ value: 'office', label: '办公用品费' },
{ value: 'training', label: '培训费' },
{ value: 'communication', label: '通讯费' },
{ value: 'welfare', label: '福利费' }
]
export const RISK_RULE_LEVEL_OPTIONS = [
{ value: 'medium', label: '中风险' },
{ value: 'high', label: '高风险' },
@@ -19,7 +31,9 @@ const RISK_LEVEL_LABELS = {
export function createDefaultRiskRuleForm() {
return {
business_domain: 'expense',
expense_category: 'travel',
risk_level: 'medium',
requires_attachment: false,
natural_language: ''
}
}

View File

@@ -103,7 +103,25 @@ export function filterAuditAssets(assets = [], filters = {}) {
? item.riskCategory === filters.selectedRiskScenario
: true
: true
const matchesOnline = filters.showOnlineFilter
? filters.selectedOnlineState
? (filters.selectedOnlineState === 'online') === Boolean(item.isOnlineValue)
: true
: true
const matchesEnabled = filters.showEnabledFilter
? filters.selectedEnabledState
? (filters.selectedEnabledState === 'enabled') === Boolean(item.isEnabledValue)
: true
: true
return matchesKeyword && matchesDomain && matchesOwner && matchesStatus && matchesRiskScenario
return (
matchesKeyword &&
matchesDomain &&
matchesOwner &&
matchesStatus &&
matchesRiskScenario &&
matchesOnline &&
matchesEnabled
)
})
}

View File

@@ -0,0 +1,4 @@
{
"status": "failed",
"failedTests": []
}

View File

@@ -0,0 +1,33 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
formatDocumentDurationSince,
resolveDocumentStayTimeDisplay
} from '../src/utils/documentCenterTime.js'
test('document center stay time uses current workflow step stay label first', () => {
const label = resolveDocumentStayTimeDisplay({
progressSteps: [
{ label: '创建单据', current: false, time: '已完成' },
{ label: '直属领导审批', current: true, time: '停留 2天3小时' }
],
updatedAt: '2026-05-20T00:00:00.000Z'
})
assert.equal(label, '2天3小时')
})
test('document center stay time falls back to elapsed time from row timestamp', () => {
assert.equal(
formatDocumentDurationSince('2026-05-20T00:00:00.000Z', Date.parse('2026-05-21T01:30:00.000Z')),
'1天1小时'
)
assert.equal(
resolveDocumentStayTimeDisplay(
{ updatedAt: '2026-05-20T00:00:00.000Z' },
Date.parse('2026-05-20T01:05:00.000Z')
),
'1小时5分钟'
)
})

View File

@@ -0,0 +1,163 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
const documentsCenterView = readFileSync(
fileURLToPath(new URL('../src/views/DocumentsCenterView.vue', import.meta.url)),
'utf8'
)
const documentsCenterStyles = readFileSync(
fileURLToPath(new URL('../src/assets/styles/views/documents-center-view.css', import.meta.url)),
'utf8'
)
test('documents center keeps only the top scope tabs and renders status as a dropdown filter', () => {
assert.match(documentsCenterView, /<nav class="status-tabs document-scope-tabs"/)
assert.doesNotMatch(documentsCenterView, /<nav class="status-tabs document-state-tabs"/)
assert.match(documentsCenterView, /class="document-status-filter"[\s\S]*class="document-filter status-dropdown-filter"/)
assert.match(
documentsCenterView,
/<div class="filter-set">[\s\S]*<div class="list-search">[\s\S]*<div class="document-status-filter"[\s\S]*<div v-if="showDocumentTypeFilter" class="document-filter">/
)
assert.match(documentsCenterView, /v-for="option in statusFilterOptions"/)
assert.match(documentsCenterView, /@click="selectStatusTab\(option\.value\)"/)
})
test('documents center top tabs start from application and show document category labels', () => {
assert.doesNotMatch(documentsCenterView, /const DOCUMENT_SCOPE_ALL = '全部'/)
assert.match(documentsCenterView, /const DOCUMENT_SCOPE_APPLICATION = '申请单'/)
assert.match(documentsCenterView, /const DOCUMENT_SCOPE_REIMBURSEMENT = '报销单'/)
assert.match(documentsCenterView, /const DOCUMENT_SCOPE_REVIEW = '审核单'/)
assert.match(documentsCenterView, /const DOCUMENT_SCOPE_ARCHIVE = '归档'/)
assert.match(documentsCenterView, /const activeScopeTab = ref\(DOCUMENT_SCOPE_APPLICATION\)/)
assert.match(
documentsCenterView,
/const scopeTabs = \[[\s\S]*DOCUMENT_SCOPE_APPLICATION[\s\S]*DOCUMENT_SCOPE_REIMBURSEMENT[\s\S]*DOCUMENT_SCOPE_REVIEW[\s\S]*DOCUMENT_SCOPE_ARCHIVE[\s\S]*\]/
)
assert.doesNotMatch(documentsCenterView, /DOCUMENT_SCOPE_ALL/)
})
test('documents center category tabs map to the intended row sources', () => {
assert.match(
documentsCenterView,
/activeScopeTab\.value === DOCUMENT_SCOPE_APPLICATION[\s\S]*row\.documentTypeCode === DOCUMENT_TYPE_APPLICATION/
)
assert.match(
documentsCenterView,
/activeScopeTab\.value === DOCUMENT_SCOPE_REIMBURSEMENT[\s\S]*ownedRows\.value\.filter/
)
assert.match(
documentsCenterView,
/activeScopeTab\.value === DOCUMENT_SCOPE_REVIEW[\s\S]*return approvalRows\.value/
)
assert.match(
documentsCenterView,
/activeScopeTab\.value === DOCUMENT_SCOPE_ARCHIVE[\s\S]*return archiveRows\.value/
)
assert.match(documentsCenterView, /return allSummaryRows\.value\.filter\(\(row\) => row\.documentTypeCode === DOCUMENT_TYPE_APPLICATION\)/)
})
test('documents center list shows created time and conditional stay time columns', () => {
assert.match(documentsCenterView, /import \{[\s\S]*formatDocumentListTime[\s\S]*resolveDocumentStayTimeDisplay[\s\S]*\} from '..\/utils\/documentCenterTime\.js'/)
assert.match(documentsCenterView, /<col class="col-created">/)
assert.match(documentsCenterView, /<col v-if="showStayTimeColumn" class="col-stay">/)
assert.match(documentsCenterView, /<th>单号<\/th>[\s\S]*<th>创建时间<\/th>[\s\S]*<th v-if="showStayTimeColumn">停留时间<\/th>/)
assert.match(documentsCenterView, /<td>\{\{ row\.createdAtDisplay \}\}<\/td>/)
assert.match(documentsCenterView, /<td v-if="showStayTimeColumn">\{\{ row\.stayTimeDisplay \}\}<\/td>/)
assert.match(
documentsCenterView,
/const showStayTimeColumn = computed\(\(\) =>[\s\S]*DOCUMENT_SCOPE_APPLICATION[\s\S]*DOCUMENT_SCOPE_REVIEW/
)
assert.match(documentsCenterView, /createdAtDisplay: formatDocumentListTime\(createdAtSource\)/)
assert.match(documentsCenterView, /stayTimeDisplay: resolveDocumentStayTimeDisplay\(normalized\)/)
})
test('documents center action buttons are scoped to application and reimbursement tabs', () => {
assert.match(
documentsCenterView,
/v-if="\[DOCUMENT_SCOPE_APPLICATION, DOCUMENT_SCOPE_REIMBURSEMENT\]\.includes\(activeScopeTab\)"[\s\S]*class="document-actions"/
)
assert.match(
documentsCenterView,
/v-if="activeScopeTab === DOCUMENT_SCOPE_APPLICATION"[\s\S]*@click="emit\('create-application'\)"[\s\S]*发起申请/
)
assert.match(
documentsCenterView,
/v-if="activeScopeTab === DOCUMENT_SCOPE_REIMBURSEMENT"[\s\S]*@click="emit\('create-request'\)"[\s\S]*发起报销/
)
assert.doesNotMatch(documentsCenterView, /create-request-btn secondary/)
})
test('documents center category tabs render bubble counts for new documents', () => {
assert.match(documentsCenterView, /v-for="tab in scopeTabItems"/)
assert.match(documentsCenterView, /<span v-if="tab\.badgeCount > 0" class="scope-tab-badge"/)
assert.match(documentsCenterView, /tab\.badgeCount > 99 \? '99\+' : tab\.badgeCount/)
assert.match(documentsCenterView, /const scopeNewCountMap = computed\(\(\) => \(\{/)
assert.match(
documentsCenterView,
/\[DOCUMENT_SCOPE_REIMBURSEMENT\]: ownedRows\.value\.filter\(\(row\) => row\.documentTypeCode === DOCUMENT_TYPE_REIMBURSEMENT\)\.length/
)
assert.match(documentsCenterView, /\[DOCUMENT_SCOPE_REVIEW\]: approvalRows\.value\.length/)
assert.match(documentsCenterView, /\[DOCUMENT_SCOPE_ARCHIVE\]: archiveRows\.value\.length/)
assert.match(
documentsCenterView,
/const scopeTabItems = computed\(\(\) =>[\s\S]*badgeCount: scopeNewCountMap\.value\[tab\] \|\| 0/
)
})
test('documents center switches filter conditions by category tab', () => {
assert.match(documentsCenterView, /const FILTER_CONFIG_BY_SCOPE = \{/)
assert.doesNotMatch(documentsCenterView, /\[DOCUMENT_SCOPE_ALL\]: \{/)
assert.match(
documentsCenterView,
/\[DOCUMENT_SCOPE_APPLICATION\]: \{[\s\S]*sceneFallbackLabel: '申请场景'[\s\S]*statusTitle: '申请状态'[\s\S]*showDocumentType: false/
)
assert.match(
documentsCenterView,
/\[DOCUMENT_SCOPE_REIMBURSEMENT\]: \{[\s\S]*statusTitle: '报销状态'[\s\S]*showDocumentType: false/
)
assert.match(
documentsCenterView,
/\[DOCUMENT_SCOPE_REVIEW\]: \{[\s\S]*sceneFallbackLabel: '审核场景'[\s\S]*statusTitle: '审核状态'[\s\S]*statusTabs: \['全部', '审批中', '待补充', '已完成'\]/
)
assert.match(
documentsCenterView,
/\[DOCUMENT_SCOPE_ARCHIVE\]: \{[\s\S]*dateLabel: '归档时间'[\s\S]*statusTitle: '归档状态'[\s\S]*statusTabs: \['全部', '已完成'\]/
)
assert.match(documentsCenterView, /v-if="showDocumentTypeFilter" class="document-filter"/)
assert.match(documentsCenterView, /:placeholder="activeFilterConfig\.searchPlaceholder"/)
assert.match(documentsCenterView, /class="document-status-filter" :aria-label="activeFilterConfig\.statusTitle"/)
assert.doesNotMatch(documentsCenterView, /class="status-filter-label"/)
assert.match(
documentsCenterView,
/watch\(activeFilterConfig, \(\) => \{[\s\S]*openFilterKey\.value = ''[\s\S]*datePopover\.value = false[\s\S]*pageSizeOpen\.value = false/
)
})
test('documents center status dropdown derives labels and closes after selection', () => {
assert.match(documentsCenterView, /const statusFilterOptions = computed\(\(\) =>/)
assert.match(documentsCenterView, /activeFilterConfig\.value\.statusTabs\.map/)
assert.match(documentsCenterView, /label: tab === '全部' \? '全部状态' : tab/)
assert.match(documentsCenterView, /const statusFilterLabel = computed\(\(\) =>/)
assert.match(
documentsCenterView,
/function selectStatusTab\(value\) \{[\s\S]*activeStatusTab\.value = value[\s\S]*openFilterKey\.value = ''[\s\S]*\}/
)
})
test('documents center status dropdown uses compact filter styling', () => {
assert.match(documentsCenterStyles, /\.documents-list\s*\{[\s\S]*grid-template-rows:\s*auto auto minmax\(0,\s*1fr\) auto;/)
assert.match(documentsCenterStyles, /\.status-tabs button\s*\{[\s\S]*display:\s*inline-flex;/)
assert.match(documentsCenterStyles, /\.scope-tab-badge\s*\{[\s\S]*border-radius:\s*999px;/)
assert.match(documentsCenterStyles, /min-width:\s*1320px;/)
assert.match(documentsCenterStyles, /\.col-created\s*\{\s*width:\s*10%;\s*\}/)
assert.match(documentsCenterStyles, /\.col-stay\s*\{\s*width:\s*9%;\s*\}/)
assert.match(documentsCenterStyles, /\.document-status-filter\s*\{[\s\S]*display:\s*inline-flex;/)
assert.match(documentsCenterStyles, /\.document-status-filter\s*\{[\s\S]*min-height:\s*38px;/)
assert.match(documentsCenterStyles, /\.status-filter-trigger\s*\{[\s\S]*min-width:\s*154px;/)
assert.match(documentsCenterStyles, /\.status-filter-menu\s*\{[\s\S]*min-width:\s*154px;/)
assert.doesNotMatch(documentsCenterStyles, /\.document-state-tabs\s*\{/)
assert.doesNotMatch(documentsCenterStyles, /\.document-status-filter\s*\{[^}]*margin-top:/)
})