feat: 增强规则资产管理与审计页面运行时调试
后端新增规则资产版本管理和规则文件 CRUD 接口,优化风险 规则生成模板执行和员工数据模型字段,知识库 RAG 增强本 地回退和文档提取能力,清理旧风险规则文件统一由生成引擎 管理,前端审计页面增加运行时调试面板和规则资产编辑交互, 补充单元测试覆盖。
This commit is contained in:
@@ -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,
|
||||
|
||||
330
web/src/assets/styles/components/expense-application-dialog.css
Normal file
330
web/src/assets/styles/components/expense-application-dialog.css
Normal 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;
|
||||
}
|
||||
}
|
||||
741
web/src/assets/styles/components/risk-rule-test-dialog.css
Normal file
741
web/src/assets/styles/components/risk-rule-test-dialog.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
720
web/src/assets/styles/views/documents-center-view.css
Normal file
720
web/src/assets/styles/views/documents-center-view.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -84,6 +84,7 @@ const {
|
||||
const sidebarMeta = {
|
||||
overview: { label: '财务总览' },
|
||||
workbench: { label: '个人工作台' },
|
||||
documents: { label: '单据中心' },
|
||||
requests: { label: '报销中心' },
|
||||
approval: { label: '审批中心' },
|
||||
archive: { label: '归档中心' },
|
||||
|
||||
@@ -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)
|
||||
|
||||
185
web/src/components/shared/ExpenseApplicationDialog.vue
Normal file
185
web/src/components/shared/ExpenseApplicationDialog.vue
Normal 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>
|
||||
@@ -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;
|
||||
|
||||
792
web/src/components/shared/RiskRuleTestDialog.vue
Normal file
792
web/src/components/shared/RiskRuleTestDialog.vue
Normal 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>
|
||||
|
||||
28
web/src/components/shared/riskRuleTestDialogUtils.js
Normal file
28
web/src/components/shared/riskRuleTestDialogUtils.js
Normal 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())
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 })}`
|
||||
|
||||
@@ -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'],
|
||||
|
||||
76
web/src/utils/documentCenterTime.js
Normal file
76
web/src/utils/documentCenterTime.js
Normal 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) || '待计算'
|
||||
}
|
||||
158
web/src/utils/expenseApplicationOntology.js
Normal file
158
web/src/utils/expenseApplicationOntology.js
Normal 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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ export const SECTION_DEFINITIONS = [
|
||||
id: 'hermes',
|
||||
label: '数字员工设置',
|
||||
title: '数字员工设置',
|
||||
desc: 'Hermes 自动任务',
|
||||
desc: '自动任务',
|
||||
longDesc: '选择需要自动执行的任务,并设置每天的执行时间。无需了解 Cron 或复杂调度规则。',
|
||||
actionLabel: '保存数字员工设置'
|
||||
},
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
788
web/src/views/DocumentsCenterView.vue
Normal file
788
web/src/views/DocumentsCenterView.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user