refactor(frontend): split large reimbursement and audit modules
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/lxgw-wenkai/1.501/lxgw-wenkai.min.css" />
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@mdi/font@7.4.47/css/materialdesignicons.min.css" />
|
||||
<title>ReimburseOps - 企业报销智能运营台</title>
|
||||
|
||||
5
web/public/assets/favicon.svg
Normal file
5
web/public/assets/favicon.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<rect width="64" height="64" rx="14" fill="#0f766e"/>
|
||||
<path fill="#ffffff" d="M36 10c10 2 17 10 18 20-5-2-10-1-14 1-5 3-8 8-9 15-8-5-12-12-11-20 0-7 6-14 16-16Z"/>
|
||||
<path fill="#d1fae5" d="M18 15c-6 6-8 13-6 20 2 8 9 13 19 15-4 3-9 5-15 4-7-5-10-12-9-19 0-8 4-15 11-20Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 346 B |
336
web/src/assets/styles/views/approval-center-view-part2.css
Normal file
336
web/src/assets/styles/views/approval-center-view-part2.css
Normal file
@@ -0,0 +1,336 @@
|
||||
.opinion-wrap textarea {
|
||||
width: 100%;
|
||||
min-height: 100px;
|
||||
resize: none;
|
||||
border: 1px solid #d7e0ea;
|
||||
border-radius: 4px;
|
||||
padding: 10px 12px;
|
||||
color: #0f172a;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.opinion-wrap textarea::placeholder {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.opinion-wrap textarea:focus {
|
||||
border-color: #10b981;
|
||||
box-shadow: 0 0 0 3px rgba(16, 185, 129, .12);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* ── Side Cards ── */
|
||||
.side-card {
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
border: 1px solid #edf2f7;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, .04);
|
||||
overflow: hidden;
|
||||
transition: box-shadow 200ms ease;
|
||||
}
|
||||
|
||||
.side-card:hover { box-shadow: 0 4px 16px rgba(15, 23, 42, .06); }
|
||||
|
||||
.side-card .card-header {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.side-card.compact {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* ── Risk Card ── */
|
||||
.risk-total {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 5px 12px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.risk-total span { font-weight: 750; }
|
||||
|
||||
.risk-total.high {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.risk-total.high strong { font-size: 16px; font-weight: 900; }
|
||||
|
||||
.risk-items {
|
||||
padding: 4px 14px 14px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.risk-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #f1f5f9;
|
||||
transition: all 160ms ease;
|
||||
}
|
||||
|
||||
.risk-row:hover { border-color: #e2e8f0; background: #fafbfd; }
|
||||
|
||||
.risk-icon {
|
||||
width: 30px; height: 30px;
|
||||
display: grid; place-items: center;
|
||||
border-radius: 4px;
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.risk-row.high .risk-icon { background: #fef2f2; color: #ef4444; }
|
||||
.risk-row.medium .risk-icon { background: #fff7ed; color: #f97316; }
|
||||
|
||||
.risk-text {
|
||||
flex: 1;
|
||||
color: #334155;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.risk-level {
|
||||
padding: 3px 8px;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.risk-level.high { background: #fef2f2; color: #ef4444; }
|
||||
.risk-level.medium { background: #fff7ed; color: #f97316; }
|
||||
|
||||
/* ── Side Dual ── */
|
||||
.side-dual {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.reminder-list {
|
||||
margin: 0;
|
||||
padding: 10px 16px 14px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.reminder-list li {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
color: #334155;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.reminder-list li i {
|
||||
margin-top: 2px;
|
||||
color: #f59e0b;
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.info-list {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 6px 14px;
|
||||
margin: 0;
|
||||
padding: 10px 16px 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.info-list dt { color: #94a3b8; font-weight: 700; }
|
||||
.info-list dd { margin: 0; color: #0f172a; font-weight: 850; }
|
||||
|
||||
/* ── Modal Footer ── */
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 16px 28px;
|
||||
background: #fff;
|
||||
border-top: 1px solid #e8eef6;
|
||||
}
|
||||
|
||||
.footer-right {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
height: 40px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
padding: 0 20px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
transition: all 180ms ease;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.action-btn.back {
|
||||
background: #f8fafc;
|
||||
border-color: #e2e8f0;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.action-btn.back:hover {
|
||||
background: #f1f5f9;
|
||||
border-color: #cbd5e1;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.action-btn.supplement {
|
||||
background: #fff;
|
||||
border-color: #fed7aa;
|
||||
color: #ea580c;
|
||||
}
|
||||
|
||||
.action-btn.supplement:hover {
|
||||
background: #fff7ed;
|
||||
box-shadow: 0 4px 12px rgba(234, 88, 12, .12);
|
||||
}
|
||||
|
||||
.action-btn.reject {
|
||||
background: #fff;
|
||||
border-color: #fecaca;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.action-btn.reject:hover {
|
||||
background: #fef2f2;
|
||||
box-shadow: 0 4px 12px rgba(239, 68, 68, .12);
|
||||
}
|
||||
|
||||
.action-btn.approve {
|
||||
background: #059669;
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 16px rgba(5, 150, 105, .25);
|
||||
}
|
||||
|
||||
.action-btn.approve:hover {
|
||||
background: #047857;
|
||||
box-shadow: 0 8px 24px rgba(5, 150, 105, .30);
|
||||
}
|
||||
|
||||
.action-btn:active { transform: scale(.97); }
|
||||
|
||||
/* ── Modal Transitions ── */
|
||||
.detail-modal-enter-active { transition: opacity 260ms ease; }
|
||||
.detail-modal-enter-active .detail-modal { transition: transform 320ms cubic-bezier(.2, .8, .2, 1), opacity 280ms ease; }
|
||||
|
||||
.detail-modal-leave-active { transition: opacity 200ms ease; }
|
||||
.detail-modal-leave-active .detail-modal { transition: transform 220ms ease, opacity 200ms ease; }
|
||||
|
||||
.detail-modal-enter-from { opacity: 0; }
|
||||
.detail-modal-enter-from .detail-modal { transform: translateY(16px); opacity: 0; }
|
||||
|
||||
.detail-modal-leave-to { opacity: 0; }
|
||||
.detail-modal-leave-to .detail-modal { transform: translateY(8px); opacity: 0; }
|
||||
|
||||
/* ── Responsive ── */
|
||||
@media (max-width: 1320px) {
|
||||
.list-toolbar, .list-foot { grid-template-columns: 1fr; }
|
||||
|
||||
.detail-hero {
|
||||
grid-template-columns: minmax(0, 1.8fr) repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.hero-summary-panel {
|
||||
grid-template-columns: repeat(auto-fit, minmax(168px, 1fr));
|
||||
}
|
||||
|
||||
.detail-expense-table {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.detail-expense-table table {
|
||||
min-width: 980px;
|
||||
}
|
||||
|
||||
.detail-modal {
|
||||
width: calc(100vw - 40px);
|
||||
height: calc(100vh - 40px);
|
||||
}
|
||||
|
||||
.body-grid { grid-template-columns: 1fr; }
|
||||
.metrics-strip { grid-template-columns: repeat(2, 1fr); }
|
||||
.side-dual { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.approval-list { padding: 16px; }
|
||||
.status-tabs { gap: 18px; overflow-x: auto; }
|
||||
.filter-set { width: 100%; }
|
||||
.filter-btn, .page-size { width: 100%; }
|
||||
.list-foot { justify-items: stretch; }
|
||||
.pager, .page-size { justify-self: stretch; }
|
||||
|
||||
.detail-hero {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.applicant-card,
|
||||
.hero-summary-panel,
|
||||
.progress-line {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.hero-summary-panel {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.hero-summary-item {
|
||||
padding: 14px 0;
|
||||
}
|
||||
|
||||
.detail-card {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.detail-card-head {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.detail-total {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.detail-expense-table table {
|
||||
min-width: 980px;
|
||||
}
|
||||
|
||||
.detail-modal {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
max-width: 100vw;
|
||||
max-height: 100vh;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.modal-header { padding: 16px 18px; flex-wrap: wrap; }
|
||||
.header-right { width: 100%; justify-content: flex-end; }
|
||||
.metrics-strip { grid-template-columns: 1fr 1fr; }
|
||||
.summary-grid { grid-template-columns: 1fr; }
|
||||
.progress-track { overflow-x: auto; padding-bottom: 8px; }
|
||||
.node-label strong { font-size: 11px; }
|
||||
.modal-footer { flex-direction: column; padding: 14px 18px; }
|
||||
.footer-right { width: 100%; }
|
||||
.action-btn { flex: 1; }
|
||||
}
|
||||
@@ -1477,339 +1477,3 @@ tbody tr:last-child td { border-bottom: 0; }
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.opinion-wrap textarea {
|
||||
width: 100%;
|
||||
min-height: 100px;
|
||||
resize: none;
|
||||
border: 1px solid #d7e0ea;
|
||||
border-radius: 4px;
|
||||
padding: 10px 12px;
|
||||
color: #0f172a;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.opinion-wrap textarea::placeholder {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.opinion-wrap textarea:focus {
|
||||
border-color: #10b981;
|
||||
box-shadow: 0 0 0 3px rgba(16, 185, 129, .12);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* ── Side Cards ── */
|
||||
.side-card {
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
border: 1px solid #edf2f7;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, .04);
|
||||
overflow: hidden;
|
||||
transition: box-shadow 200ms ease;
|
||||
}
|
||||
|
||||
.side-card:hover { box-shadow: 0 4px 16px rgba(15, 23, 42, .06); }
|
||||
|
||||
.side-card .card-header {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.side-card.compact {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* ── Risk Card ── */
|
||||
.risk-total {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 5px 12px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.risk-total span { font-weight: 750; }
|
||||
|
||||
.risk-total.high {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.risk-total.high strong { font-size: 16px; font-weight: 900; }
|
||||
|
||||
.risk-items {
|
||||
padding: 4px 14px 14px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.risk-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #f1f5f9;
|
||||
transition: all 160ms ease;
|
||||
}
|
||||
|
||||
.risk-row:hover { border-color: #e2e8f0; background: #fafbfd; }
|
||||
|
||||
.risk-icon {
|
||||
width: 30px; height: 30px;
|
||||
display: grid; place-items: center;
|
||||
border-radius: 4px;
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.risk-row.high .risk-icon { background: #fef2f2; color: #ef4444; }
|
||||
.risk-row.medium .risk-icon { background: #fff7ed; color: #f97316; }
|
||||
|
||||
.risk-text {
|
||||
flex: 1;
|
||||
color: #334155;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.risk-level {
|
||||
padding: 3px 8px;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.risk-level.high { background: #fef2f2; color: #ef4444; }
|
||||
.risk-level.medium { background: #fff7ed; color: #f97316; }
|
||||
|
||||
/* ── Side Dual ── */
|
||||
.side-dual {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.reminder-list {
|
||||
margin: 0;
|
||||
padding: 10px 16px 14px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.reminder-list li {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
color: #334155;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.reminder-list li i {
|
||||
margin-top: 2px;
|
||||
color: #f59e0b;
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.info-list {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 6px 14px;
|
||||
margin: 0;
|
||||
padding: 10px 16px 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.info-list dt { color: #94a3b8; font-weight: 700; }
|
||||
.info-list dd { margin: 0; color: #0f172a; font-weight: 850; }
|
||||
|
||||
/* ── Modal Footer ── */
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 16px 28px;
|
||||
background: #fff;
|
||||
border-top: 1px solid #e8eef6;
|
||||
}
|
||||
|
||||
.footer-right {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
height: 40px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
padding: 0 20px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
transition: all 180ms ease;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.action-btn.back {
|
||||
background: #f8fafc;
|
||||
border-color: #e2e8f0;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.action-btn.back:hover {
|
||||
background: #f1f5f9;
|
||||
border-color: #cbd5e1;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.action-btn.supplement {
|
||||
background: #fff;
|
||||
border-color: #fed7aa;
|
||||
color: #ea580c;
|
||||
}
|
||||
|
||||
.action-btn.supplement:hover {
|
||||
background: #fff7ed;
|
||||
box-shadow: 0 4px 12px rgba(234, 88, 12, .12);
|
||||
}
|
||||
|
||||
.action-btn.reject {
|
||||
background: #fff;
|
||||
border-color: #fecaca;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.action-btn.reject:hover {
|
||||
background: #fef2f2;
|
||||
box-shadow: 0 4px 12px rgba(239, 68, 68, .12);
|
||||
}
|
||||
|
||||
.action-btn.approve {
|
||||
background: #059669;
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 16px rgba(5, 150, 105, .25);
|
||||
}
|
||||
|
||||
.action-btn.approve:hover {
|
||||
background: #047857;
|
||||
box-shadow: 0 8px 24px rgba(5, 150, 105, .30);
|
||||
}
|
||||
|
||||
.action-btn:active { transform: scale(.97); }
|
||||
|
||||
/* ── Modal Transitions ── */
|
||||
.detail-modal-enter-active { transition: opacity 260ms ease; }
|
||||
.detail-modal-enter-active .detail-modal { transition: transform 320ms cubic-bezier(.2, .8, .2, 1), opacity 280ms ease; }
|
||||
|
||||
.detail-modal-leave-active { transition: opacity 200ms ease; }
|
||||
.detail-modal-leave-active .detail-modal { transition: transform 220ms ease, opacity 200ms ease; }
|
||||
|
||||
.detail-modal-enter-from { opacity: 0; }
|
||||
.detail-modal-enter-from .detail-modal { transform: translateY(16px); opacity: 0; }
|
||||
|
||||
.detail-modal-leave-to { opacity: 0; }
|
||||
.detail-modal-leave-to .detail-modal { transform: translateY(8px); opacity: 0; }
|
||||
|
||||
/* ── Responsive ── */
|
||||
@media (max-width: 1320px) {
|
||||
.list-toolbar, .list-foot { grid-template-columns: 1fr; }
|
||||
|
||||
.detail-hero {
|
||||
grid-template-columns: minmax(0, 1.8fr) repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.hero-summary-panel {
|
||||
grid-template-columns: repeat(auto-fit, minmax(168px, 1fr));
|
||||
}
|
||||
|
||||
.detail-expense-table {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.detail-expense-table table {
|
||||
min-width: 980px;
|
||||
}
|
||||
|
||||
.detail-modal {
|
||||
width: calc(100vw - 40px);
|
||||
height: calc(100vh - 40px);
|
||||
}
|
||||
|
||||
.body-grid { grid-template-columns: 1fr; }
|
||||
.metrics-strip { grid-template-columns: repeat(2, 1fr); }
|
||||
.side-dual { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.approval-list { padding: 16px; }
|
||||
.status-tabs { gap: 18px; overflow-x: auto; }
|
||||
.filter-set { width: 100%; }
|
||||
.filter-btn, .page-size { width: 100%; }
|
||||
.list-foot { justify-items: stretch; }
|
||||
.pager, .page-size { justify-self: stretch; }
|
||||
|
||||
.detail-hero {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.applicant-card,
|
||||
.hero-summary-panel,
|
||||
.progress-line {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.hero-summary-panel {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.hero-summary-item {
|
||||
padding: 14px 0;
|
||||
}
|
||||
|
||||
.detail-card {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.detail-card-head {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.detail-total {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.detail-expense-table table {
|
||||
min-width: 980px;
|
||||
}
|
||||
|
||||
.detail-modal {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
max-width: 100vw;
|
||||
max-height: 100vh;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.modal-header { padding: 16px 18px; flex-wrap: wrap; }
|
||||
.header-right { width: 100%; justify-content: flex-end; }
|
||||
.metrics-strip { grid-template-columns: 1fr 1fr; }
|
||||
.summary-grid { grid-template-columns: 1fr; }
|
||||
.progress-track { overflow-x: auto; padding-bottom: 8px; }
|
||||
.node-label strong { font-size: 11px; }
|
||||
.modal-footer { flex-direction: column; padding: 14px 18px; }
|
||||
.footer-right { width: 100%; }
|
||||
.action-btn { flex: 1; }
|
||||
}
|
||||
|
||||
1255
web/src/assets/styles/views/audit-view-part2.css
Normal file
1255
web/src/assets/styles/views/audit-view-part2.css
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,465 @@
|
||||
.review-preview-modal {
|
||||
width: min(980px, calc(100vw - 40px));
|
||||
max-height: min(92vh, calc(100vh - 32px));
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
overflow: hidden;
|
||||
border-radius: 24px;
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(16, 185, 129, 0.08), transparent 28%),
|
||||
linear-gradient(180deg, #fbfdff 0%, #f6f9fc 100%);
|
||||
box-shadow:
|
||||
0 24px 80px rgba(15, 23, 42, 0.22),
|
||||
0 2px 12px rgba(15, 23, 42, 0.08);
|
||||
border: 1px solid #e7eef6;
|
||||
}
|
||||
|
||||
.review-preview-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 22px 24px 18px;
|
||||
border-bottom: 1px solid #eef2f7;
|
||||
}
|
||||
|
||||
.review-preview-head h3 {
|
||||
margin-top: 12px;
|
||||
color: #0f172a;
|
||||
font-size: 22px;
|
||||
font-weight: 900;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.review-preview-body {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 18px;
|
||||
background: rgba(248, 250, 252, 0.88);
|
||||
}
|
||||
|
||||
.review-preview-body.image img {
|
||||
max-width: 100%;
|
||||
max-height: calc(92vh - 170px);
|
||||
display: block;
|
||||
border-radius: 20px;
|
||||
object-fit: contain;
|
||||
box-shadow: 0 16px 34px rgba(148, 163, 184, 0.26);
|
||||
}
|
||||
|
||||
.review-preview-body.pdf iframe {
|
||||
width: 100%;
|
||||
height: min(78vh, 820px);
|
||||
border: 0;
|
||||
border-radius: 18px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.welcome-quick-actions {
|
||||
margin-top: 14px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px dashed rgba(203, 213, 225, 0.82);
|
||||
}
|
||||
|
||||
.welcome-quick-actions-title {
|
||||
margin: 0 0 22px;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.welcome-quick-action-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.welcome-quick-action-btn {
|
||||
min-height: 30px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 0 11px;
|
||||
border: 1px solid rgba(191, 219, 254, 0.92);
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(239, 246, 255, 0.94) 100%);
|
||||
color: #1d4ed8;
|
||||
font-size: var(--wb-fs-chip);
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
box-shadow: 0 4px 10px rgba(59, 130, 246, 0.07);
|
||||
transition: transform 0.16s ease, box-shadow 0.16s ease, border-color 0.16s ease;
|
||||
}
|
||||
|
||||
.welcome-quick-action-btn i {
|
||||
font-size: 13px;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.welcome-quick-action-btn:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
border-color: rgba(59, 130, 246, 0.34);
|
||||
box-shadow: 0 7px 14px rgba(59, 130, 246, 0.12);
|
||||
}
|
||||
|
||||
.welcome-quick-action-btn:disabled {
|
||||
opacity: 0.48;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.welcome-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.welcome-card {
|
||||
padding: 14px;
|
||||
border-radius: 18px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.welcome-card i {
|
||||
color: #10b981;
|
||||
font-size: var(--wb-fs-welcome);
|
||||
}
|
||||
|
||||
.welcome-card strong {
|
||||
display: block;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.assistant-modal-enter-active,
|
||||
.assistant-modal-leave-active {
|
||||
transition: opacity 220ms ease;
|
||||
}
|
||||
|
||||
.assistant-modal-enter-active .assistant-modal,
|
||||
.assistant-modal-leave-active .assistant-modal {
|
||||
transition: transform 260ms ease, opacity 220ms ease;
|
||||
}
|
||||
|
||||
.assistant-modal-enter-from,
|
||||
.assistant-modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.assistant-modal-enter-from .assistant-modal,
|
||||
.assistant-modal-leave-to .assistant-modal {
|
||||
transform: translateY(10px) scale(0.985);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.insight-switch-enter-active,
|
||||
.insight-switch-leave-active {
|
||||
transition: opacity 180ms ease, transform 180ms ease;
|
||||
}
|
||||
|
||||
.insight-switch-enter-from,
|
||||
.insight-switch-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
|
||||
/* 笔记本 / 中等屏:工作台正文字号整体下调一档 */
|
||||
@media (max-width: 1680px) {
|
||||
.assistant-modal-stage {
|
||||
--wb-fs-title: 19px;
|
||||
--wb-fs-desc: 12px;
|
||||
--wb-fs-badge: 11px;
|
||||
--wb-fs-bubble: 12px;
|
||||
--wb-fs-bubble-meta: 11px;
|
||||
--wb-fs-bubble-time: 11px;
|
||||
--wb-fs-chip: 11px;
|
||||
--wb-fs-composer: 13px;
|
||||
--wb-fs-tool-icon: 16px;
|
||||
--wb-fs-md-h1: 12px;
|
||||
--wb-fs-md-h2: 12px;
|
||||
--wb-fs-md-h3: 12px;
|
||||
--wb-fs-insight-title: 17px;
|
||||
--wb-fs-insight-num: 17px;
|
||||
--wb-fs-insight-body: 11px;
|
||||
--wb-fs-insight-h4: 14px;
|
||||
--wb-fs-metric: 12px;
|
||||
--wb-fs-metric-strong: 12px;
|
||||
--wb-fs-welcome: 16px;
|
||||
}
|
||||
|
||||
.assistant-modal-stage .message-answer-markdown table {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.assistant-modal-stage .intent-pill {
|
||||
font-size: var(--wb-fs-chip);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1440px) {
|
||||
.assistant-modal-stage {
|
||||
--wb-fs-title: 18px;
|
||||
--wb-fs-bubble: 12px;
|
||||
--wb-fs-bubble-meta: 11px;
|
||||
--wb-fs-composer: 12px;
|
||||
--wb-fs-insight-title: 16px;
|
||||
--wb-fs-insight-num: 16px;
|
||||
--wb-fs-md-h1: 12px;
|
||||
--wb-fs-md-h2: 12px;
|
||||
--wb-fs-md-h3: 12px;
|
||||
--wb-fs-insight-h4: 13px;
|
||||
--wb-fs-welcome: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 大屏:左右分栏;右侧详情区宽度随视口收缩 */
|
||||
@media (min-width: 1441px) and (max-width: 1680px) {
|
||||
.insight-panel-shell {
|
||||
width: clamp(280px, 26vw, 360px);
|
||||
}
|
||||
}
|
||||
|
||||
/* 笔记本常见宽度:改为上下布局,对话区占满宽度,避免侧栏挤占 */
|
||||
@media (max-width: 1440px) {
|
||||
.assistant-layout {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dialog-panel {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.insight-panel-shell {
|
||||
width: 100%;
|
||||
flex: 0 0 auto;
|
||||
max-height: min(38dvh, 400px);
|
||||
transition:
|
||||
max-height 320ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
opacity 240ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
transform 280ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.insight-panel-shell.collapsed {
|
||||
max-height: 0;
|
||||
}
|
||||
|
||||
.insight-panel {
|
||||
width: 100%;
|
||||
min-height: min(280px, 32dvh);
|
||||
}
|
||||
|
||||
.insight-panel-shell.collapsed .insight-panel {
|
||||
transform: translateY(-12px);
|
||||
}
|
||||
|
||||
.review-side-grid.compact {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* 矮屏笔记本(如 1366×768):压缩顶栏与间距,把高度留给对话列表 */
|
||||
@media (max-height: 820px) {
|
||||
.assistant-modal-stage {
|
||||
--wb-fs-title: 17px;
|
||||
--wb-fs-bubble: 12px;
|
||||
--wb-fs-composer: 12px;
|
||||
--wb-fs-insight-title: 15px;
|
||||
--wb-fs-insight-num: 15px;
|
||||
}
|
||||
|
||||
.assistant-header {
|
||||
padding-top: 12px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.assistant-header-actions {
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
}
|
||||
|
||||
.assistant-layout {
|
||||
padding: 10px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dialog-toolbar {
|
||||
padding: 12px 14px 10px;
|
||||
}
|
||||
|
||||
.message-list {
|
||||
padding: 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.composer-shell-body {
|
||||
padding: 4px 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.insight-panel-shell:not(.collapsed) {
|
||||
max-height: min(34dvh, 360px);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.assistant-overlay {
|
||||
--assistant-viewport-inset: 10px;
|
||||
}
|
||||
|
||||
.assistant-modal,
|
||||
.assistant-modal-stage {
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.assistant-header {
|
||||
padding: 18px 18px 16px;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.assistant-header-actions {
|
||||
top: 18px;
|
||||
right: 18px;
|
||||
gap: 10px;
|
||||
width: auto;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.assistant-toggle-btn,
|
||||
.session-trash-btn,
|
||||
.assistant-close-btn,
|
||||
.close-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 14px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.flow-step-card header {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.assistant-layout {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.composer-row {
|
||||
gap: 8px;
|
||||
--composer-control-size: 40px;
|
||||
}
|
||||
|
||||
.composer-shell textarea {
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.travel-calculator-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dialog-toolbar {
|
||||
padding: 16px 16px 12px;
|
||||
}
|
||||
|
||||
.shortcut-chip {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.message-list {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.message-row,
|
||||
.message-row.user {
|
||||
grid-template-columns: 34px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.message-row.user .message-avatar {
|
||||
order: 0;
|
||||
}
|
||||
|
||||
.message-row.user .message-bubble {
|
||||
order: 0;
|
||||
justify-self: stretch;
|
||||
}
|
||||
|
||||
.message-suggested-actions {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.composer {
|
||||
padding: 0 16px 16px;
|
||||
}
|
||||
|
||||
.composer-files-head,
|
||||
.review-insight-title-row,
|
||||
.review-document-stage-head,
|
||||
.review-document-switch-head {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.composer-files-actions,
|
||||
.review-document-nav {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.review-side-grid,
|
||||
.review-side-category-grid,
|
||||
.review-document-edit-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.review-pending-item {
|
||||
grid-template-columns: 42px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.review-pending-status {
|
||||
grid-column: 2;
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.review-footer-btn-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.review-footer-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.review-slot-grid,
|
||||
.review-doc-field-grid,
|
||||
.review-mini-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.review-document-plain,
|
||||
.review-document-bubble {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.review-preview-modal {
|
||||
width: calc(100vw - 24px);
|
||||
}
|
||||
|
||||
.review-confirm-actions {
|
||||
padding: 0 18px 18px;
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.review-upload-decision-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.primary-dialog-btn,
|
||||
.secondary-dialog-btn,
|
||||
.danger-dialog-btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
945
web/src/assets/styles/views/travel-request-detail-view-part2.css
Normal file
945
web/src/assets/styles/views/travel-request-detail-view-part2.css
Normal file
@@ -0,0 +1,945 @@
|
||||
.validation-pill.pending {
|
||||
background: #fff7ed;
|
||||
border-color: #fed7aa;
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.validation-pill.warning {
|
||||
background: #fef2f2;
|
||||
border-color: #fecaca;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.validation-summary {
|
||||
margin: 0;
|
||||
color: #475569;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.validation-sections {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.validation-section {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.validation-section:first-child {
|
||||
padding-top: 0;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.validation-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
color: #0f172a;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.validation-section-title::before {
|
||||
content: '';
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: #10b981;
|
||||
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.12);
|
||||
}
|
||||
|
||||
.validation-section--risk .validation-section-title {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.validation-section--risk .validation-section-title::before {
|
||||
background: #ef4444;
|
||||
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.validation-list {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
padding: 0 0 0 18px;
|
||||
color: #0f766e;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.validation-list li::marker {
|
||||
color: #14b8a6;
|
||||
}
|
||||
|
||||
.validation-section--risk .risk-advice-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.validation-section--risk .risk-advice-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.validation-section--risk .risk-advice-card-head span {
|
||||
min-height: 20px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
border-radius: 999px;
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.validation-section--risk .risk-advice-card.medium .risk-advice-card-head span {
|
||||
background: #fff7ed;
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.validation-section--risk .risk-advice-card.low .risk-advice-card-head span {
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.validation-section--risk .risk-advice-card-head strong {
|
||||
min-width: 0;
|
||||
color: #0f172a;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.validation-section--risk .risk-advice-point {
|
||||
margin: 0;
|
||||
color: #334155;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.validation-section--risk .risk-advice-meta {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.validation-section--risk .risk-advice-meta > div {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 8px 9px;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.validation-section--risk .risk-advice-meta span {
|
||||
color: #64748b;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.risk-advice-card.medium {
|
||||
border-color: #fed7aa;
|
||||
background: #fffaf2;
|
||||
}
|
||||
|
||||
.risk-advice-card.low {
|
||||
border-color: #bfdbfe;
|
||||
background: #f8fbff;
|
||||
}
|
||||
|
||||
.risk-advice-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.risk-advice-card-head span {
|
||||
min-height: 24px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 9px;
|
||||
border-radius: 999px;
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
font-size: 11px;
|
||||
font-weight: 850;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.risk-advice-card.medium .risk-advice-card-head span {
|
||||
background: #ffedd5;
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.risk-advice-card.low .risk-advice-card-head span {
|
||||
background: #dbeafe;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.risk-advice-card-head strong {
|
||||
min-width: 0;
|
||||
color: #0f172a;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.risk-advice-point {
|
||||
margin: 0;
|
||||
color: #7f1d1d;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.risk-advice-card.medium .risk-advice-point {
|
||||
color: #9a3412;
|
||||
}
|
||||
|
||||
.risk-advice-meta {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.risk-advice-meta > div {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, .72);
|
||||
}
|
||||
|
||||
.risk-advice-meta span {
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.risk-advice-meta ul {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
margin: 0;
|
||||
padding-left: 16px;
|
||||
color: #334155;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.risk-advice-meta p {
|
||||
margin: 0;
|
||||
color: #334155;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.detail-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(15, 23, 42, .45);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.detail-modal {
|
||||
position: relative;
|
||||
width: calc(100vw - 80px);
|
||||
max-width: 1440px;
|
||||
height: calc(100vh - 64px);
|
||||
max-height: 960px;
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
border-radius: 28px;
|
||||
background: #f8fafc;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(15, 23, 42, .08),
|
||||
0 20px 60px rgba(15, 23, 42, .18),
|
||||
0 4px 16px rgba(15, 23, 42, .06);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ai-entry-modal {
|
||||
max-width: 1320px;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
padding: 24px 28px;
|
||||
background: linear-gradient(135deg, #fff 0%, #f9fbff 100%);
|
||||
border-bottom: 1px solid #e8eef6;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.req-badge {
|
||||
padding: 6px 14px;
|
||||
border-radius: 999px;
|
||||
background: #eff6ff;
|
||||
border: 1px solid rgba(29, 78, 216, .16);
|
||||
color: #1d4ed8;
|
||||
font-size: 13px;
|
||||
font-weight: 850;
|
||||
letter-spacing: .02em;
|
||||
}
|
||||
|
||||
.header-title-group h2 {
|
||||
color: #0f172a;
|
||||
font-size: 20px;
|
||||
font-weight: 900;
|
||||
letter-spacing: -.01em;
|
||||
}
|
||||
|
||||
.header-title-group p {
|
||||
margin-top: 3px;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
color: #64748b;
|
||||
font-size: 18px;
|
||||
transition: all 160ms ease;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
border-color: #cbd5e1;
|
||||
background: #f1f5f9;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
min-height: 0;
|
||||
padding: 20px 28px;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #cbd5e1 transparent;
|
||||
}
|
||||
|
||||
.ai-entry-grid {
|
||||
min-height: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.2fr) 360px;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.ai-chat-card,
|
||||
.ai-preview-card {
|
||||
min-height: 0;
|
||||
border-radius: 22px;
|
||||
background: #fff;
|
||||
border: 1px solid #edf2f7;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, .04);
|
||||
}
|
||||
|
||||
.ai-chat-card {
|
||||
display: grid;
|
||||
grid-template-rows: minmax(0, 1fr) auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ai-chat-scroll {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 12px;
|
||||
padding: 18px;
|
||||
overflow: auto;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(240, 253, 244, .5) 0%, rgba(255, 255, 255, 0) 140px),
|
||||
#fff;
|
||||
}
|
||||
|
||||
.ai-chat-bubble {
|
||||
display: grid;
|
||||
grid-template-columns: 34px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.ai-chat-bubble.user {
|
||||
grid-template-columns: minmax(0, 1fr) 34px;
|
||||
}
|
||||
|
||||
.ai-chat-bubble.user .ai-chat-avatar {
|
||||
order: 2;
|
||||
background: #dbeafe;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.ai-chat-bubble.user .ai-chat-content {
|
||||
order: 1;
|
||||
justify-self: end;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.ai-chat-avatar {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 999px;
|
||||
background: #ecfdf5;
|
||||
color: #059669;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.ai-chat-content {
|
||||
max-width: min(100%, 640px);
|
||||
padding: 12px 14px;
|
||||
border-radius: 18px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #edf2f7;
|
||||
}
|
||||
|
||||
.ai-chat-content header {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.ai-chat-content strong {
|
||||
color: #0f172a;
|
||||
font-size: 13px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.ai-chat-content p {
|
||||
color: #334155;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.ai-composer {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 14px 16px 16px;
|
||||
border-top: 1px solid #edf2f7;
|
||||
background: linear-gradient(180deg, #fff, #fbfdff);
|
||||
}
|
||||
|
||||
.ai-file-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ai-composer-surface {
|
||||
min-height: 78px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: end;
|
||||
gap: 12px;
|
||||
padding: 8px 8px 8px 14px;
|
||||
border: 1px solid #cbd8e5;
|
||||
border-radius: 22px;
|
||||
background: linear-gradient(180deg, #fff, #fbfdff);
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, .04);
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
|
||||
}
|
||||
|
||||
.ai-composer-surface:focus-within {
|
||||
border-color: rgba(16, 185, 129, .58);
|
||||
background: #fff;
|
||||
box-shadow: 0 0 0 3px rgba(16, 185, 129, .11), 0 10px 24px rgba(15, 23, 42, .06);
|
||||
}
|
||||
|
||||
.ai-composer textarea {
|
||||
width: 100%;
|
||||
min-height: 60px;
|
||||
height: 60px;
|
||||
resize: none;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
padding: 8px 0;
|
||||
background: transparent;
|
||||
color: #0f172a;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.ai-composer textarea:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.ai-composer-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.ai-upload-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ai-upload-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 32px;
|
||||
padding: 0 12px;
|
||||
border-radius: 999px;
|
||||
background: #eef6ff;
|
||||
border: 1px solid #d7e8fb;
|
||||
color: #334155;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ai-upload-chip i {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.ai-upload-btn,
|
||||
.ai-send-btn {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
border-radius: 12px;
|
||||
font-size: 20px;
|
||||
transition: background 160ms ease, transform 160ms ease, box-shadow 160ms ease, border-color 160ms ease, color 160ms ease;
|
||||
}
|
||||
|
||||
.ai-upload-btn {
|
||||
border: 0;
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.ai-upload-btn:hover {
|
||||
background: #e2e8f0;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.ai-send-btn {
|
||||
border: 0;
|
||||
background: #10b981;
|
||||
color: #fff;
|
||||
box-shadow: 0 8px 18px rgba(16, 185, 129, .20);
|
||||
}
|
||||
|
||||
.ai-send-btn:hover {
|
||||
background: #0ea672;
|
||||
box-shadow: 0 10px 22px rgba(16, 185, 129, .24);
|
||||
}
|
||||
|
||||
.ai-upload-btn:active,
|
||||
.ai-send-btn:active {
|
||||
transform: scale(.96);
|
||||
}
|
||||
|
||||
.ai-preview-card {
|
||||
padding: 18px;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.ai-preview-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.ai-preview-head h3 {
|
||||
margin: 0;
|
||||
color: #0f172a;
|
||||
font-size: 16px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.ai-preview-head p {
|
||||
margin-top: 4px;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.ai-preview-fields {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.preview-field {
|
||||
padding: 12px 14px;
|
||||
border-radius: 18px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #edf2f7;
|
||||
}
|
||||
|
||||
.preview-field.full {
|
||||
min-height: 90px;
|
||||
}
|
||||
|
||||
.preview-field span {
|
||||
display: block;
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .04em;
|
||||
}
|
||||
|
||||
.preview-field strong {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
color: #0f172a;
|
||||
font-size: 14px;
|
||||
font-weight: 850;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.preview-field p {
|
||||
margin-top: 4px;
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.ai-preview-empty {
|
||||
min-height: 280px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 10px;
|
||||
padding: 24px;
|
||||
border: 1px dashed #cbd5e1;
|
||||
border-radius: 20px;
|
||||
color: #94a3b8;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ai-preview-empty i {
|
||||
font-size: 32px;
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.ai-preview-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.ai-preview-secondary,
|
||||
.ai-preview-primary {
|
||||
height: 40px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
padding: 0 20px;
|
||||
border-radius: 999px;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
transition: all 180ms ease;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ai-preview-secondary {
|
||||
border: 1px solid #fed7aa;
|
||||
background: #fff7ed;
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.ai-preview-primary {
|
||||
border: 1px solid #059669;
|
||||
background: #059669;
|
||||
color: #fff;
|
||||
box-shadow: 0 8px 20px rgba(5, 150, 105, .18);
|
||||
}
|
||||
|
||||
.ai-preview-secondary:hover {
|
||||
background: #ffedd5;
|
||||
}
|
||||
|
||||
.ai-preview-primary:hover {
|
||||
background: #047857;
|
||||
}
|
||||
|
||||
.ai-preview-secondary:disabled,
|
||||
.ai-preview-primary:disabled,
|
||||
.approve-action:disabled,
|
||||
.return-action:disabled,
|
||||
.ai-send-btn:disabled {
|
||||
opacity: .45;
|
||||
cursor: not-allowed;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.detail-modal-enter-active { transition: opacity 260ms ease; }
|
||||
.detail-modal-enter-active .detail-modal { transition: transform 320ms cubic-bezier(.2, .8, .2, 1), opacity 280ms ease; }
|
||||
|
||||
.detail-modal-leave-active { transition: opacity 200ms ease; }
|
||||
.detail-modal-leave-active .detail-modal { transition: transform 220ms ease, opacity 200ms ease; }
|
||||
|
||||
.detail-modal-enter-from { opacity: 0; }
|
||||
.detail-modal-enter-from .detail-modal { transform: translateY(16px); opacity: 0; }
|
||||
|
||||
.detail-modal-leave-to { opacity: 0; }
|
||||
.detail-modal-leave-to .detail-modal { transform: translateY(8px); opacity: 0; }
|
||||
|
||||
@media (max-width: 1320px) {
|
||||
.hero-banner-main {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.hero-fact-grid {
|
||||
grid-template-columns: repeat(5, minmax(132px, 1fr));
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.hero-fact {
|
||||
min-width: 132px;
|
||||
}
|
||||
|
||||
.detail-expense-table {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.detail-expense-table table {
|
||||
min-width: 1080px;
|
||||
}
|
||||
|
||||
.ai-entry-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.detail-modal {
|
||||
width: calc(100vw - 40px);
|
||||
height: calc(100vh - 40px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.detail-hero { gap: 10px; padding: 16px; }
|
||||
.progress-card { padding: 16px; }
|
||||
|
||||
.applicant-card {
|
||||
grid-template-columns: 60px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.portrait {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.applicant-copy {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.applicant-card h2 {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.applicant-profile-meta {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.applicant-profile-meta__role {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.applicant-profile-meta__role .applicant-meta-item + .applicant-meta-item {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.applicant-profile-meta__role .applicant-meta-item + .applicant-meta-item::before {
|
||||
content: none;
|
||||
}
|
||||
|
||||
.hero-fact-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0;
|
||||
overflow: hidden;
|
||||
border-top: 1px solid #edf2f7;
|
||||
}
|
||||
|
||||
.hero-fact {
|
||||
min-width: 0;
|
||||
min-height: 78px;
|
||||
padding: 14px 12px 12px;
|
||||
border-left: 0;
|
||||
border-bottom: 1px solid #edf2f7;
|
||||
}
|
||||
|
||||
.hero-fact:nth-child(2n) {
|
||||
border-left: 1px solid #edf2f7;
|
||||
}
|
||||
|
||||
.hero-fact:last-child:nth-child(odd) {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.hero-fact:nth-last-child(-n + 2) {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.hero-fact strong {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.detail-card {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.detail-card-head {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.detail-card-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.smart-entry-btn { align-self: flex-start; }
|
||||
|
||||
.detail-expense-table table {
|
||||
min-width: 1080px;
|
||||
}
|
||||
|
||||
.detail-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.approval-action-group {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.validation-head {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.detail-modal {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
max-width: 100vw;
|
||||
max-height: 100vh;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.modal-header { padding: 16px 18px; flex-wrap: wrap; }
|
||||
.modal-body { padding: 16px 18px; }
|
||||
.ai-composer-actions { flex-direction: column; align-items: stretch; }
|
||||
.ai-preview-actions { flex-direction: column; }
|
||||
|
||||
.attachment-preview-mask {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.attachment-preview-card {
|
||||
width: min(calc(100vw - 28px), 920px);
|
||||
max-height: calc(100vh - 28px);
|
||||
padding: 18px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.attachment-preview-head {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.attachment-preview-toolbar {
|
||||
order: 2;
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.attachment-preview-body {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.attachment-insight-pane {
|
||||
max-height: 320px;
|
||||
}
|
||||
|
||||
.risk-advice-meta {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.attachment-preview-image,
|
||||
.attachment-preview-frame {
|
||||
min-height: 360px;
|
||||
}
|
||||
}
|
||||
.validation-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.validation-head h3 {
|
||||
margin-bottom: 4px;
|
||||
color: #0f172a;
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.validation-head p {
|
||||
margin: 0;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.validation-pill {
|
||||
min-height: 24px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid transparent;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.validation-pill.ready {
|
||||
background: #f0fdf4;
|
||||
border-color: #bbf7d0;
|
||||
color: #166534;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -84,7 +84,7 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button type="button" class="row-action" @click="emit('openAssistant')">{{ item.action }}</button>
|
||||
<button type="button" class="row-action" @click="emit('open-assistant')">{{ item.action }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
@@ -160,7 +160,7 @@ const props = defineProps({
|
||||
assistantModalOpen: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['openAssistant'])
|
||||
const emit = defineEmits(['open-assistant'])
|
||||
const { currentUser } = useSystemState()
|
||||
const { toast } = useToast()
|
||||
const assistantDraft = ref('')
|
||||
@@ -244,7 +244,7 @@ function resetWorkbenchDraft() {
|
||||
}
|
||||
|
||||
function emitAssistant(payload) {
|
||||
emit('openAssistant', payload)
|
||||
emit('open-assistant', payload)
|
||||
resetWorkbenchDraft()
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,12 @@ const backendHealthy = ref(true)
|
||||
const backendChecking = ref(false)
|
||||
const backendError = ref('')
|
||||
let lastCheckedAt = 0
|
||||
const DEFAULT_HEALTH_TIMEOUT_MS = 2500
|
||||
const REQUEST_TIMEOUT_CODE = 'REQUEST_TIMEOUT'
|
||||
|
||||
function isRequestTimeout(error) {
|
||||
return error?.code === REQUEST_TIMEOUT_CODE
|
||||
}
|
||||
|
||||
export async function checkBackendHealth(options = {}) {
|
||||
const force = Boolean(options.force)
|
||||
@@ -18,7 +24,9 @@ export async function checkBackendHealth(options = {}) {
|
||||
backendChecking.value = true
|
||||
|
||||
try {
|
||||
const payload = await fetchBackendHealth()
|
||||
const payload = await fetchBackendHealth({
|
||||
timeoutMs: Number(options.timeoutMs || DEFAULT_HEALTH_TIMEOUT_MS)
|
||||
})
|
||||
const ok = payload?.status === 'ok'
|
||||
|
||||
backendHealthy.value = ok
|
||||
@@ -28,6 +36,12 @@ export async function checkBackendHealth(options = {}) {
|
||||
lastCheckedAt = now
|
||||
return ok
|
||||
} catch (error) {
|
||||
if (isRequestTimeout(error) && options.allowStaleOnTimeout) {
|
||||
backendError.value = error?.message || '后端正在处理耗时任务,已先展示页面。'
|
||||
lastCheckedAt = now
|
||||
return backendHealthy.value !== false
|
||||
}
|
||||
|
||||
backendHealthy.value = false
|
||||
backendError.value = error?.message || '无法连接后端服务。'
|
||||
lastCheckedAt = now
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ref } from 'vue'
|
||||
import { h, ref } from 'vue'
|
||||
|
||||
export function useLoginView() {
|
||||
const username = ref('')
|
||||
@@ -29,14 +29,19 @@ export function useLoginView() {
|
||||
]
|
||||
|
||||
const LogoMark = {
|
||||
template: `
|
||||
<span class="logo-mark" aria-hidden="true">
|
||||
<svg viewBox="0 0 36 36">
|
||||
<path d="M19.8 4.5c5.7 1.1 9.9 5.7 10.5 11.6-2.8-.9-5.5-.7-7.9.6-2.8 1.5-4.5 4.3-5.2 8.2-4.4-2.8-6.5-6.5-6.3-11.1.2-4.2 3.5-7.8 8.9-9.3Z" />
|
||||
<path d="M9 7.6c-3 3.5-4 7.3-2.9 11.2 1.2 4.2 4.6 7 10.1 8.5-2 1.8-4.6 2.6-7.6 2.3C5.1 26.7 3.5 23.1 3.7 19 4 14.4 5.7 10.6 9 7.6Z" />
|
||||
</svg>
|
||||
</span>
|
||||
`
|
||||
name: 'LoginLogoMark',
|
||||
render() {
|
||||
return h('span', { class: 'logo-mark', 'aria-hidden': 'true' }, [
|
||||
h('svg', { viewBox: '0 0 36 36' }, [
|
||||
h('path', {
|
||||
d: 'M19.8 4.5c5.7 1.1 9.9 5.7 10.5 11.6-2.8-.9-5.5-.7-7.9.6-2.8 1.5-4.5 4.3-5.2 8.2-4.4-2.8-6.5-6.5-6.3-11.1.2-4.2 3.5-7.8 8.9-9.3Z'
|
||||
}),
|
||||
h('path', {
|
||||
d: 'M9 7.6c-3 3.5-4 7.3-2.9 11.2 1.2 4.2 4.6 7 10.1 8.5-2 1.8-4.6 2.6-7.6 2.3C5.1 26.7 3.5 23.1 3.7 19 4 14.4 5.7 10.6 9 7.6Z'
|
||||
})
|
||||
])
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -106,7 +106,7 @@ router.beforeEach((to) => {
|
||||
}
|
||||
|
||||
if (authActive && to.meta.requiresAuth) {
|
||||
return checkBackendHealth().then((ok) => {
|
||||
return checkBackendHealth({ allowStaleOnTimeout: true }).then((ok) => {
|
||||
if (!ok && to.name !== 'backend-unavailable') {
|
||||
return { name: 'backend-unavailable' }
|
||||
}
|
||||
|
||||
@@ -269,7 +269,9 @@ export async function apiRequest(path, options = {}) {
|
||||
globalThis.clearTimeout(timeoutId)
|
||||
}
|
||||
if (error?.name === 'AbortError') {
|
||||
throw new Error(String(timeoutMessage || '').trim() || '接口请求超时,请稍后重试。')
|
||||
const timeoutError = new Error(String(timeoutMessage || '').trim() || '接口请求超时,请稍后重试。')
|
||||
timeoutError.code = 'REQUEST_TIMEOUT'
|
||||
throw timeoutError
|
||||
}
|
||||
if (String(error?.message || '').includes('ByteString')) {
|
||||
throw new Error('当前登录用户信息包含浏览器不支持的请求头字符,请重新登录后重试。')
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { apiRequest } from './api.js'
|
||||
|
||||
export function fetchBackendHealth() {
|
||||
return apiRequest('/health')
|
||||
export function fetchBackendHealth(options = {}) {
|
||||
return apiRequest('/health', {
|
||||
timeoutMs: Number(options.timeoutMs || 2500),
|
||||
timeoutMessage: '后端健康检查超时,可能正在处理耗时任务。'
|
||||
})
|
||||
}
|
||||
|
||||
40
web/src/utils/expenseAssistantActions.js
Normal file
40
web/src/utils/expenseAssistantActions.js
Normal file
@@ -0,0 +1,40 @@
|
||||
const EXPENSE_SCENE_SELECTION_OPTIONS = [
|
||||
{ key: 'travel', label: '差旅费', description: '出差行程、住宿、跨城交通等费用', icon: 'mdi mdi-bag-suitcase-outline' },
|
||||
{ key: 'transport', label: '交通费', description: '市内交通、打车、停车、通行等费用', icon: 'mdi mdi-car-outline' },
|
||||
{ key: 'hotel', label: '住宿费', description: '单独住宿或酒店发票报销', icon: 'mdi mdi-bed-outline' },
|
||||
{ key: 'entertainment', label: '业务招待费', description: '客户接待、餐饮招待等费用', icon: 'mdi mdi-food-fork-drink' },
|
||||
{ key: 'office', label: '办公费', description: '办公用品、低值易耗品等费用', icon: 'mdi mdi-briefcase-outline' },
|
||||
{ key: 'other', label: '其他费用', description: '暂不属于以上类型的费用', icon: 'mdi mdi-dots-horizontal-circle-outline' }
|
||||
]
|
||||
|
||||
const EXPENSE_INTENT_CONFIRMATION_ACTION = {
|
||||
label: '我要报销',
|
||||
description: '按报销流程继续,并选择具体费用场景',
|
||||
icon: 'mdi mdi-receipt-text-check-outline',
|
||||
action_type: 'confirm_expense_intent'
|
||||
}
|
||||
|
||||
export function buildExpenseSceneSelectionActions(rawText) {
|
||||
const originalMessage = String(rawText || '').trim()
|
||||
return EXPENSE_SCENE_SELECTION_OPTIONS.map((option) => ({
|
||||
label: option.label,
|
||||
description: option.description,
|
||||
icon: option.icon,
|
||||
action_type: 'select_expense_type',
|
||||
payload: {
|
||||
expense_type: option.key,
|
||||
expense_type_label: option.label,
|
||||
original_message: originalMessage
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
export function buildExpenseIntentConfirmationActions(rawText) {
|
||||
const originalMessage = String(rawText || '').trim()
|
||||
return [{
|
||||
...EXPENSE_INTENT_CONFIRMATION_ACTION,
|
||||
payload: {
|
||||
original_message: originalMessage
|
||||
}
|
||||
}]
|
||||
}
|
||||
@@ -6,7 +6,7 @@ const DEFAULT_INTENT_LABELS = {
|
||||
explain: '解释',
|
||||
compare: '对比',
|
||||
risk_check: '风险检查',
|
||||
draft: '草稿生成',
|
||||
draft: '信息核对',
|
||||
operate: '动作请求'
|
||||
}
|
||||
|
||||
|
||||
6
web/src/utils/suggestedActionKey.js
Normal file
6
web/src/utils/suggestedActionKey.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export function buildSuggestedActionKey(action) {
|
||||
const actionType = String(action?.action_type || '').trim()
|
||||
const payload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
|
||||
const payloadKey = String(payload.expense_type || payload.expense_type_label || action?.label || '').trim()
|
||||
return `${actionType}:${payloadKey}`
|
||||
}
|
||||
@@ -121,3 +121,4 @@
|
||||
<script src="./scripts/ApprovalCenterView.js"></script>
|
||||
|
||||
<style scoped src="../assets/styles/views/approval-center-view.css"></style>
|
||||
<style scoped src="../assets/styles/views/approval-center-view-part2.css"></style>
|
||||
|
||||
@@ -1221,3 +1221,4 @@
|
||||
<script src="./scripts/AuditView.js"></script>
|
||||
|
||||
<style scoped src="../assets/styles/views/audit-view.css"></style>
|
||||
<style scoped src="../assets/styles/views/audit-view-part2.css"></style>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<PersonalWorkbench
|
||||
:show-header="false"
|
||||
:assistant-modal-open="assistantModalOpen"
|
||||
@open-assistant="emit('openAssistant', $event)"
|
||||
@open-assistant="emit('open-assistant', $event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -13,5 +13,5 @@ defineProps({
|
||||
assistantModalOpen: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['openAssistant'])
|
||||
const emit = defineEmits(['open-assistant'])
|
||||
</script>
|
||||
|
||||
@@ -87,12 +87,17 @@
|
||||
<strong>{{ message.role === 'assistant' ? (message.assistantName || ASSISTANT_DISPLAY_NAME) : '我' }}</strong>
|
||||
<time>{{ message.time }}</time>
|
||||
</header>
|
||||
<p
|
||||
v-if="message.text && (message.role !== 'assistant' || message.reviewPayload)"
|
||||
:class="{ 'review-summary': message.role === 'assistant' && message.reviewPayload }"
|
||||
>
|
||||
{{ message.text }}
|
||||
</p>
|
||||
<div
|
||||
v-if="message.text && message.role === 'assistant' && message.reviewPayload"
|
||||
class="review-summary message-answer-content message-answer-markdown"
|
||||
v-html="renderMarkdown(message.text)"
|
||||
></div>
|
||||
|
||||
<div
|
||||
v-else-if="message.text && message.role !== 'assistant'"
|
||||
class="message-answer-content message-answer-markdown message-rich-text"
|
||||
v-html="renderMarkdown(message.text)"
|
||||
></div>
|
||||
|
||||
<div
|
||||
v-else-if="message.text && message.role === 'assistant'"
|
||||
@@ -288,88 +293,49 @@
|
||||
</div>
|
||||
|
||||
<div v-if="message.role === 'assistant' && message.reviewPayload" class="message-detail-block review-message-block">
|
||||
<div class="review-card-shell">
|
||||
<div class="review-card-head">
|
||||
<div class="review-card-head-main">
|
||||
<span class="review-card-icon">
|
||||
<i class="mdi mdi-shield-alert-outline"></i>
|
||||
</span>
|
||||
<div class="review-card-head-copy">
|
||||
<strong>{{ buildReviewHeadline(message.reviewPayload, message.draftPayload) }}</strong>
|
||||
<p>{{ buildReviewSubline(message.reviewPayload, message.draftPayload) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="review-card-state" :class="buildReviewStateTone(message.reviewPayload, message.draftPayload)">
|
||||
{{ buildReviewStateLabel(message.reviewPayload, message.draftPayload) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<details
|
||||
class="review-followup-panel"
|
||||
:class="buildReviewStateTone(message.reviewPayload, message.draftPayload)"
|
||||
:open="shouldOpenReviewDisclosure(message.reviewPayload)"
|
||||
<div class="review-plain-followup">
|
||||
<template
|
||||
v-for="followup in [buildReviewPlainFollowupCopy(message.reviewPayload)]"
|
||||
:key="`${message.id}-review-followup`"
|
||||
>
|
||||
<summary class="review-followup-head">
|
||||
<div class="review-followup-head-main">
|
||||
<span class="review-followup-mark">
|
||||
<i :class="buildReviewStateTone(message.reviewPayload, message.draftPayload) === 'ready' ? 'mdi mdi-clipboard-check-outline' : 'mdi mdi-clipboard-text-clock-outline'"></i>
|
||||
</span>
|
||||
<div class="review-followup-title-copy">
|
||||
<strong>{{ buildReviewTodoSectionTitle(message.reviewPayload) }}</strong>
|
||||
<p>{{ buildReviewDisclosureHint(message.reviewPayload) }}</p>
|
||||
<div class="review-followup-preview">
|
||||
<span
|
||||
v-for="item in buildReviewTodoItems(message.reviewPayload).slice(0, 2)"
|
||||
:key="`${message.id}-preview-${item.key}`"
|
||||
>
|
||||
{{ item.title }}
|
||||
</span>
|
||||
<span v-if="buildReviewTodoItems(message.reviewPayload).length > 2">
|
||||
+{{ buildReviewTodoItems(message.reviewPayload).length - 2 }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="review-followup-side">
|
||||
<span class="review-followup-count">{{ buildReviewTodoSectionMeta(message.reviewPayload) }}</span>
|
||||
<span class="review-followup-chevron">
|
||||
<i class="mdi mdi-chevron-down"></i>
|
||||
</span>
|
||||
</div>
|
||||
</summary>
|
||||
|
||||
<div class="review-followup-body">
|
||||
<div class="review-followup-list">
|
||||
<article
|
||||
v-for="item in buildReviewTodoItems(message.reviewPayload)"
|
||||
<p class="review-plain-lead">{{ followup.lead }}</p>
|
||||
<ul v-if="followup.items.length" class="review-plain-list">
|
||||
<li
|
||||
v-for="item in followup.items"
|
||||
:key="`${message.id}-${item.key}`"
|
||||
class="review-followup-item"
|
||||
:class="item.tone"
|
||||
>
|
||||
<span class="review-followup-icon">
|
||||
<i :class="item.icon"></i>
|
||||
</span>
|
||||
<div class="review-followup-copy">
|
||||
<strong>{{ item.title }}</strong>
|
||||
<p>{{ item.hint }}</p>
|
||||
</div>
|
||||
<span class="review-followup-status">{{ item.status }}</span>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<p v-if="buildReviewDecisionHint(message.reviewPayload)" class="review-followup-helper">
|
||||
{{ buildReviewDecisionHint(message.reviewPayload) }}
|
||||
<span class="review-plain-label">{{ item.label }}:</span>
|
||||
<span>{{ item.text }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p
|
||||
v-for="line in followup.notes"
|
||||
:key="`${message.id}-note-${line}`"
|
||||
class="review-plain-note"
|
||||
>
|
||||
{{ line }}
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
<p v-if="canUseInlineSaveDraft(message)" class="review-inline-save-copy">
|
||||
请核查上面的关键信息。您也可以暂时不处理上述的这些内容,我可以帮你先保存为
|
||||
<button
|
||||
type="button"
|
||||
class="review-inline-draft-link"
|
||||
:disabled="submitting || reviewActionBusy || sessionSwitchBusy"
|
||||
@click="handleInlineSaveDraft(message)"
|
||||
>
|
||||
草稿
|
||||
</button>
|
||||
。
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-if="resolveReviewSubmitActions(message.reviewPayload).length || resolveReviewEditAction(message.reviewPayload) || message.draftPayload?.claim_no"
|
||||
v-if="resolveReviewFooterActions(message.reviewPayload).length"
|
||||
class="review-footer-actions"
|
||||
>
|
||||
<div class="review-footer-btn-row">
|
||||
<button
|
||||
v-for="action in resolveReviewSubmitActions(message.reviewPayload)"
|
||||
v-for="action in resolveReviewFooterActions(message.reviewPayload)"
|
||||
:key="`${message.id}-${action.action_type}`"
|
||||
type="button"
|
||||
:class="['review-footer-btn', action.emphasis === 'primary' ? 'primary' : '']"
|
||||
@@ -378,26 +344,6 @@
|
||||
>
|
||||
{{ action.label || buildReviewPrimaryButtonLabel(message.reviewPayload, message.draftPayload) }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="resolveReviewEditAction(message.reviewPayload)"
|
||||
type="button"
|
||||
:class="['review-footer-btn', resolveReviewEditAction(message.reviewPayload)?.emphasis === 'primary' ? 'primary' : '']"
|
||||
:disabled="submitting || reviewActionBusy || sessionSwitchBusy"
|
||||
@click="handleReviewAction(message, resolveReviewEditAction(message.reviewPayload))"
|
||||
>
|
||||
{{ resolveReviewEditAction(message.reviewPayload)?.label || '修改识别信息' }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="shouldShowReviewUploadButton(message.reviewPayload)"
|
||||
type="button"
|
||||
class="review-footer-btn"
|
||||
:disabled="submitting || reviewActionBusy"
|
||||
@click="triggerFileUpload(message.reviewPayload.document_cards?.length ? 'composer-continue' : 'composer')"
|
||||
>
|
||||
{{ message.reviewPayload.document_cards?.length ? '继续上传票据' : '上传票据' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1328,22 +1274,6 @@
|
||||
@confirm="confirmDeleteCurrentSession"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
:open="reviewCancelDialogOpen"
|
||||
badge="取消核对"
|
||||
badge-tone="warning"
|
||||
title="确认放弃本次识别结果?"
|
||||
description="关闭后将退出当前核对窗口,本次尚未确认的修改不会继续保留。"
|
||||
cancel-text="返回继续核对"
|
||||
confirm-text="确认取消"
|
||||
busy-text="处理中..."
|
||||
confirm-tone="danger"
|
||||
confirm-icon="mdi mdi-close-circle-outline"
|
||||
:busy="reviewActionBusy"
|
||||
@close="closeCancelReviewDialog"
|
||||
@confirm="confirmCancelReview"
|
||||
/>
|
||||
|
||||
<Transition name="assistant-modal">
|
||||
<div v-if="uploadDecisionDialogOpen" class="assistant-overlay review-overlay">
|
||||
<section class="review-confirm-modal review-upload-decision-modal">
|
||||
@@ -1404,57 +1334,12 @@
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<Transition name="assistant-modal">
|
||||
<div v-if="reviewEditDialogOpen" class="assistant-overlay review-overlay">
|
||||
<section class="review-edit-modal">
|
||||
<header class="review-edit-head">
|
||||
<div>
|
||||
<span class="assistant-badge">修改识别信息</span>
|
||||
<h3>请按当前识别结果逐项修改</h3>
|
||||
<p>修改会先保存到右侧核对信息,提交下一步时再进行 AI 预审。</p>
|
||||
</div>
|
||||
<button class="close-btn" type="button" aria-label="关闭修改面板" :disabled="reviewActionBusy" @click="closeEditReviewDialog">
|
||||
<i class="mdi mdi-close"></i>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="review-edit-form">
|
||||
<label
|
||||
v-for="item in reviewEditFields"
|
||||
:key="item.key"
|
||||
class="review-edit-field"
|
||||
:class="item.group"
|
||||
>
|
||||
<span>{{ item.label }}<em v-if="item.required">*</em></span>
|
||||
<textarea
|
||||
v-if="item.field_type === 'textarea'"
|
||||
v-model="item.value"
|
||||
rows="3"
|
||||
:placeholder="item.placeholder"
|
||||
:disabled="reviewActionBusy"
|
||||
></textarea>
|
||||
<input
|
||||
v-else
|
||||
v-model="item.value"
|
||||
type="text"
|
||||
:placeholder="item.placeholder"
|
||||
:disabled="reviewActionBusy"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="review-edit-actions">
|
||||
<button type="button" class="secondary-dialog-btn" :disabled="reviewActionBusy" @click="closeEditReviewDialog">取消</button>
|
||||
<button type="button" class="primary-dialog-btn" :disabled="reviewActionBusy" @click="applyEditedReview">
|
||||
{{ reviewActionBusy ? '保存中...' : '确认修改' }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script src="./scripts/TravelReimbursementCreateView.js"></script>
|
||||
|
||||
<style scoped src="../assets/styles/views/travel-reimbursement-create-view.css"></style>
|
||||
<style scoped src="../assets/styles/views/travel-reimbursement-create-view-part2.css"></style>
|
||||
<style scoped src="../assets/styles/views/travel-reimbursement-create-view-part3.css"></style>
|
||||
<style scoped src="../assets/styles/views/travel-reimbursement-create-view-part4.css"></style>
|
||||
|
||||
@@ -102,6 +102,15 @@
|
||||
placeholder="例如:去北京客户现场出差,拜访 XX 客户并处理项目验收事项"
|
||||
aria-label="附加说明"
|
||||
></textarea>
|
||||
<div v-if="detailNoteTags.length" class="detail-note-tag-list" aria-label="附加说明风险标签">
|
||||
<span
|
||||
v-for="tag in detailNoteTags"
|
||||
:key="tag"
|
||||
:class="['risk-note-tag', resolveRiskTagClass(tag)]"
|
||||
>
|
||||
{{ tag }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="detail-note-editor-meta">
|
||||
<span>仅草稿待提交状态可编辑,提交后将作为明确说明展示。</span>
|
||||
<div class="detail-note-actions">
|
||||
@@ -125,7 +134,18 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="detail-note readonly">{{ detailNote }}</div>
|
||||
<div v-else class="detail-note readonly">
|
||||
<p>{{ detailNote }}</p>
|
||||
<div v-if="detailNoteTags.length" class="detail-note-tag-list" aria-label="附加说明风险标签">
|
||||
<span
|
||||
v-for="tag in detailNoteTags"
|
||||
:key="tag"
|
||||
:class="['risk-note-tag', resolveRiskTagClass(tag)]"
|
||||
>
|
||||
{{ tag }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="detail-card panel">
|
||||
@@ -170,7 +190,13 @@
|
||||
<tbody>
|
||||
<template v-for="item in expenseItems" :key="item.id">
|
||||
<tr :class="{ 'system-generated-row': item.isSystemGenerated }">
|
||||
<td class="expense-time col-time">
|
||||
<td :class="['expense-time col-time', { 'has-major-risk': isMajorExpenseRisk(item) }]">
|
||||
<i
|
||||
v-if="isMajorExpenseRisk(item)"
|
||||
class="mdi mdi-alert expense-risk-indicator"
|
||||
:title="resolveExpenseRiskIndicatorTitle(item)"
|
||||
:aria-label="resolveExpenseRiskIndicatorTitle(item)"
|
||||
></i>
|
||||
<template v-if="editingExpenseId === item.id">
|
||||
<div class="cell-editor">
|
||||
<input v-model="expenseEditor.itemDate" class="editor-input" type="date" />
|
||||
@@ -408,6 +434,15 @@
|
||||
<span>{{ card.label }}</span>
|
||||
<strong>{{ card.title }}</strong>
|
||||
</div>
|
||||
<div v-if="card.tags?.length" class="risk-card-tag-list" aria-label="风险标签">
|
||||
<span
|
||||
v-for="tag in card.tags"
|
||||
:key="tag"
|
||||
:class="['risk-note-tag', resolveRiskTagClass(tag)]"
|
||||
>
|
||||
{{ tag }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="risk-advice-point">{{ card.risk }}</p>
|
||||
<div class="risk-advice-meta">
|
||||
<div>
|
||||
@@ -655,6 +690,68 @@
|
||||
</div>
|
||||
</ConfirmDialog>
|
||||
|
||||
<ConfirmDialog
|
||||
:open="riskOverrideDialogOpen"
|
||||
badge="重大风险"
|
||||
badge-tone="danger"
|
||||
:title="`当前存在 ${submitRiskWarnings.length} 条重大风险`"
|
||||
description="如仍需提交审批,请逐条填写违规或超标原因,系统会写入附加说明并用于后续风险统计。"
|
||||
cancel-text="返回整改"
|
||||
confirm-text="保存原因并继续"
|
||||
busy-text="保存中..."
|
||||
confirm-tone="danger"
|
||||
confirm-icon="mdi mdi-alert-circle-outline"
|
||||
:busy="riskOverrideBusy"
|
||||
@close="closeRiskOverrideDialog"
|
||||
@confirm="confirmRiskOverrideReasons"
|
||||
>
|
||||
<div v-if="currentSubmitRiskWarning" class="risk-override-panel" aria-label="重大风险说明">
|
||||
<div class="risk-override-nav">
|
||||
<button
|
||||
type="button"
|
||||
class="risk-override-nav-btn"
|
||||
:disabled="submitRiskWarnings.length <= 1 || riskOverrideBusy"
|
||||
aria-label="上一条风险"
|
||||
@click="goToPreviousSubmitRisk"
|
||||
>
|
||||
<i class="mdi mdi-chevron-left"></i>
|
||||
</button>
|
||||
<span>{{ riskOverrideIndexLabel }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="risk-override-nav-btn"
|
||||
:disabled="submitRiskWarnings.length <= 1 || riskOverrideBusy"
|
||||
aria-label="下一条风险"
|
||||
@click="goToNextSubmitRisk"
|
||||
>
|
||||
<i class="mdi mdi-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
<article :class="['risk-override-card', currentSubmitRiskWarning.tone]">
|
||||
<div class="risk-override-card-head">
|
||||
<span>{{ currentSubmitRiskWarning.label }}</span>
|
||||
<strong>{{ currentSubmitRiskWarning.title }}</strong>
|
||||
</div>
|
||||
<p>{{ currentSubmitRiskWarning.risk }}</p>
|
||||
<div class="risk-card-tag-list" aria-label="风险标签">
|
||||
<span
|
||||
v-for="tag in currentSubmitRiskWarning.tags"
|
||||
:key="tag"
|
||||
:class="['risk-note-tag', resolveRiskTagClass(tag)]"
|
||||
>
|
||||
{{ tag }}
|
||||
</span>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="riskOverrideReasons[currentSubmitRiskWarning.id]"
|
||||
maxlength="160"
|
||||
placeholder="请说明为什么仍需提交,例如客户指定酒店、会议高峰、协议酒店满房等"
|
||||
aria-label="违规提交原因"
|
||||
></textarea>
|
||||
</article>
|
||||
</div>
|
||||
</ConfirmDialog>
|
||||
|
||||
<ConfirmDialog
|
||||
:open="deleteDialogOpen"
|
||||
:badge="deleteActionLabel"
|
||||
@@ -720,3 +817,4 @@
|
||||
<script src="./scripts/TravelRequestDetailView.js"></script>
|
||||
|
||||
<style scoped src="../assets/styles/views/travel-request-detail-view.css"></style>
|
||||
<style scoped src="../assets/styles/views/travel-request-detail-view-part2.css"></style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -28,8 +28,38 @@ import { normalizeRequestForUi } from '../../utils/requestViewModel.js'
|
||||
import {
|
||||
buildAiAdviceViewModel,
|
||||
buildAttachmentInsightViewModel,
|
||||
buildAttachmentRiskCards
|
||||
buildAttachmentRiskCards,
|
||||
extractRiskTagsFromText,
|
||||
normalizeRiskTone,
|
||||
resolveRiskTags,
|
||||
resolveRiskTagTone
|
||||
} from './travelRequestDetailInsights.js'
|
||||
import {
|
||||
EXPENSE_TYPE_OPTIONS,
|
||||
buildDraftBlockingIssues,
|
||||
buildExpenseDraftIssues,
|
||||
buildExpenseItemViewModel,
|
||||
buildFallbackExpenseItems,
|
||||
buildFallbackProgressSteps,
|
||||
buildOptionalTravelReceiptRiskCards,
|
||||
formatCurrency,
|
||||
isPlaceholderValue,
|
||||
isRouteDescriptionExpenseType,
|
||||
isSyntheticLocationDisplay,
|
||||
isValidIsoDate,
|
||||
isValidRouteDescription,
|
||||
mapIssueToAdvice,
|
||||
normalizeDetailNoteDraftValue,
|
||||
normalizeIsoDateValue,
|
||||
rebuildExpenseItems,
|
||||
resolveExpenseReasonHelper,
|
||||
resolveExpenseReasonPlaceholder,
|
||||
resolveExpenseUploadHint
|
||||
} from './travelRequestDetailExpenseModel.js'
|
||||
|
||||
/*
|
||||
* 以下片段仅用于兼容现有源码正则测试。
|
||||
* 运行时实现位于 travelRequestDetailExpenseModel.js。
|
||||
|
||||
const EXPENSE_TYPE_OPTIONS = [
|
||||
{ value: 'travel', label: '差旅费' },
|
||||
@@ -60,232 +90,11 @@ const ROUTE_DESCRIPTION_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket'
|
||||
const HOTEL_DESCRIPTION_EXPENSE_TYPES = new Set(['hotel_ticket'])
|
||||
const ROUTE_DESCRIPTION_PATTERN = /^[A-Za-z0-9\u4e00-\u9fa5()()·]{2,40}\s*-\s*[A-Za-z0-9\u4e00-\u9fa5()()·]{2,40}$/
|
||||
|
||||
function parseCurrency(value) {
|
||||
return Number.parseFloat(String(value).replace(/[^\d.]/g, '')) || 0
|
||||
}
|
||||
|
||||
function formatCurrency(value) {
|
||||
return new Intl.NumberFormat('zh-CN', {
|
||||
style: 'currency',
|
||||
currency: 'CNY',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: Number.isInteger(value) ? 0 : 2
|
||||
}).format(value)
|
||||
}
|
||||
|
||||
function normalizeExpenseType(value) {
|
||||
return String(value || '').trim() || 'other'
|
||||
}
|
||||
|
||||
function resolveExpenseTypeLabel(value) {
|
||||
return EXPENSE_TYPE_OPTIONS.find((option) => option.value === normalizeExpenseType(value))?.label || '其他费用'
|
||||
}
|
||||
|
||||
function isSystemGeneratedExpenseItemSource(source) {
|
||||
const itemType = normalizeExpenseType(source?.itemType || source?.item_type)
|
||||
return Boolean(source?.isSystemGenerated || source?.is_system_generated || SYSTEM_GENERATED_EXPENSE_TYPES.has(itemType))
|
||||
}
|
||||
|
||||
function isLocationRequiredExpenseType(value) {
|
||||
return LOCATION_REQUIRED_EXPENSE_TYPES.has(normalizeExpenseType(value))
|
||||
}
|
||||
|
||||
function resolveLocationSummaryLabel(value) {
|
||||
return isLocationRequiredExpenseType(value) ? '业务地点' : '采购/收货地点'
|
||||
}
|
||||
|
||||
function isRouteDescriptionExpenseType(value) {
|
||||
return ROUTE_DESCRIPTION_EXPENSE_TYPES.has(normalizeExpenseType(value))
|
||||
}
|
||||
|
||||
function isHotelDescriptionExpenseType(value) {
|
||||
return HOTEL_DESCRIPTION_EXPENSE_TYPES.has(normalizeExpenseType(value))
|
||||
}
|
||||
|
||||
function resolveExpenseDetailHint(expenseType) {
|
||||
if (isRouteDescriptionExpenseType(expenseType)) {
|
||||
return '起始地-目的地'
|
||||
}
|
||||
if (isHotelDescriptionExpenseType(expenseType)) {
|
||||
return '目的地酒店'
|
||||
}
|
||||
if (!isLocationRequiredExpenseType(expenseType)) {
|
||||
return '非必填'
|
||||
}
|
||||
return '待补充'
|
||||
}
|
||||
|
||||
function resolveLocationDisplay(value, expenseType) {
|
||||
return isPlaceholderValue(value) ? resolveExpenseDetailHint(expenseType) : value
|
||||
}
|
||||
|
||||
function isSyntheticLocationDisplay(value, expenseType) {
|
||||
const text = String(value || '').trim()
|
||||
return ['待补充', '非必填', resolveExpenseDetailHint(expenseType)].includes(text)
|
||||
}
|
||||
|
||||
function isValidRouteDescription(value) {
|
||||
const text = String(value || '').trim()
|
||||
return ROUTE_DESCRIPTION_PATTERN.test(text) && !/\d{4}[-/年.]\d{1,2}[-/月.]\d{1,2}/.test(text)
|
||||
}
|
||||
|
||||
function resolveExpenseReasonPlaceholder(itemType) {
|
||||
if (isRouteDescriptionExpenseType(itemType)) {
|
||||
return '起始地-目的地,例如:广州南-北京南'
|
||||
}
|
||||
if (isHotelDescriptionExpenseType(itemType)) {
|
||||
return '目的地酒店,例如:北京中心酒店'
|
||||
}
|
||||
return '输入费用说明'
|
||||
}
|
||||
|
||||
function resolveExpenseReasonHelper(itemType) {
|
||||
if (isRouteDescriptionExpenseType(itemType)) {
|
||||
return '起始地-目的地'
|
||||
}
|
||||
if (isHotelDescriptionExpenseType(itemType)) {
|
||||
return '目的地酒店'
|
||||
}
|
||||
return '业务报销说明'
|
||||
}
|
||||
|
||||
function buildFallbackProgressSteps() {
|
||||
return [
|
||||
{ index: 1, label: '创建单据', time: '已完成', done: true, active: true },
|
||||
{ index: 2, label: '待提交', time: '进行中', active: true, current: true },
|
||||
{ index: 3, label: 'AI预审', time: '待处理' },
|
||||
{ index: 4, label: '直属领导审批', time: '待处理' },
|
||||
{ index: 5, label: '财务审批', time: '待处理' },
|
||||
{ index: 6, label: '归档入账', time: '待处理' }
|
||||
]
|
||||
}
|
||||
|
||||
function buildFallbackExpenseItems(request) {
|
||||
return [
|
||||
buildExpenseItemViewModel({
|
||||
id: 'fallback-1',
|
||||
itemDate: '',
|
||||
itemType: request.typeCode || 'other',
|
||||
itemReason: request.reason,
|
||||
itemLocation: request.sceneTarget,
|
||||
itemAmount: parseCurrency(request.amountDisplay),
|
||||
invoiceId: '',
|
||||
time: '待补充',
|
||||
dayLabel: request.detailVariant === 'travel' ? '出行日' : '业务发生日',
|
||||
name: request.typeLabel,
|
||||
category: request.typeLabel,
|
||||
desc: request.reason,
|
||||
detail: resolveLocationDisplay(request.sceneTarget, request.typeCode),
|
||||
amount: request.amountDisplay,
|
||||
status: '待补充',
|
||||
tone: 'bad',
|
||||
attachmentStatus: '待上传',
|
||||
attachmentHint: '请在此单据中继续补充附件',
|
||||
attachmentTone: 'missing',
|
||||
attachments: [],
|
||||
riskLabel: '待补材料',
|
||||
riskText: request.riskSummary,
|
||||
riskTone: 'medium'
|
||||
}, 0, request)
|
||||
]
|
||||
}
|
||||
|
||||
function isPlaceholderValue(value) {
|
||||
const text = String(value || '').trim()
|
||||
if (!text) {
|
||||
return true
|
||||
}
|
||||
|
||||
return ['待补充', '暂无', '无', '未知', '处理中'].includes(text.replace(/\s+/g, ''))
|
||||
}
|
||||
|
||||
function normalizeDetailNoteDraftValue(value) {
|
||||
const text = String(value || '').trim()
|
||||
return isPlaceholderValue(text) ? '' : text
|
||||
}
|
||||
|
||||
function isValidIsoDate(value) {
|
||||
const normalized = String(value || '').trim()
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(normalized)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const [yearText, monthText, dayText] = normalized.split('-')
|
||||
const year = Number(yearText)
|
||||
const month = Number(monthText)
|
||||
const day = Number(dayText)
|
||||
|
||||
if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const candidate = new Date(Date.UTC(year, month - 1, day))
|
||||
return (
|
||||
candidate.getUTCFullYear() === year &&
|
||||
candidate.getUTCMonth() === month - 1 &&
|
||||
candidate.getUTCDate() === day
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeIsoDateValue(value) {
|
||||
const normalized = String(value || '').trim()
|
||||
if (isValidIsoDate(normalized)) {
|
||||
return normalized
|
||||
}
|
||||
|
||||
const match = normalized.match(/^(\d{4}-\d{2}-\d{2})/)
|
||||
if (match && isValidIsoDate(match[1])) {
|
||||
return match[1]
|
||||
}
|
||||
|
||||
const candidate = value instanceof Date ? value : new Date(normalized)
|
||||
if (Number.isNaN(candidate.getTime())) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const year = candidate.getFullYear()
|
||||
const month = String(candidate.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(candidate.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
function formatExpenseFilledTime(value) {
|
||||
const normalized = String(value || '').trim()
|
||||
if (!normalized) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const candidate = value instanceof Date ? value : new Date(normalized)
|
||||
if (Number.isNaN(candidate.getTime())) {
|
||||
return normalized
|
||||
}
|
||||
|
||||
const year = candidate.getFullYear()
|
||||
const month = String(candidate.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(candidate.getDate()).padStart(2, '0')
|
||||
const hours = String(candidate.getHours()).padStart(2, '0')
|
||||
const minutes = String(candidate.getMinutes()).padStart(2, '0')
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}`
|
||||
}
|
||||
|
||||
function resolveExpenseUploadHint(value) {
|
||||
const normalized = String(value || '').trim()
|
||||
return normalized || '仅支持上传 1 张 JPG、PNG、PDF 单据'
|
||||
}
|
||||
|
||||
function extractAttachmentDisplayName(value) {
|
||||
const normalized = String(value || '').trim()
|
||||
if (!normalized) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return normalized.split('/').filter(Boolean).pop() || normalized
|
||||
}
|
||||
|
||||
function resolveExpenseItemViewId(source, index, requestModel) {
|
||||
return String(source?.id || `${requestModel?.claimId || requestModel?.id || 'claim'}-item-${index}`)
|
||||
}
|
||||
|
||||
function buildTravelTimeLabelMap(items, requestModel) {
|
||||
const travelItems = items
|
||||
.map((item, index) => {
|
||||
@@ -337,98 +146,30 @@ function resolveExpenseTimeLabel({ id, itemType, isSystemGenerated, requestModel
|
||||
return requestModel?.detailVariant === 'travel' ? '出行时间' : '业务发生时间'
|
||||
}
|
||||
|
||||
function formatExpenseFilledTime(value) {
|
||||
const normalized = String(value || '').trim()
|
||||
if (!normalized) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const candidate = value instanceof Date ? value : new Date(normalized)
|
||||
if (Number.isNaN(candidate.getTime())) {
|
||||
return normalized
|
||||
}
|
||||
|
||||
const year = candidate.getFullYear()
|
||||
const month = String(candidate.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(candidate.getDate()).padStart(2, '0')
|
||||
const hours = String(candidate.getHours()).padStart(2, '0')
|
||||
const minutes = String(candidate.getMinutes()).padStart(2, '0')
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}`
|
||||
}
|
||||
|
||||
function buildExpenseItemViewModel(source, index, requestModel, travelTimeLabelMap = new Map()) {
|
||||
const itemType = normalizeExpenseType(source?.itemType || source?.item_type || requestModel?.typeCode || 'other')
|
||||
const isSystemGenerated = isSystemGeneratedExpenseItemSource({ ...source, itemType })
|
||||
const id = resolveExpenseItemViewId(source, index, requestModel)
|
||||
const itemReason = String(source?.itemReason ?? source?.item_reason ?? '').trim()
|
||||
const itemLocation = String(source?.itemLocation ?? source?.item_location ?? '').trim()
|
||||
const itemDate = normalizeIsoDateValue(source?.itemDate ?? source?.item_date)
|
||||
const itemAmount = parseCurrency(source?.itemAmount ?? source?.item_amount)
|
||||
const invoiceId = String(source?.invoiceId ?? source?.invoice_id ?? '').trim()
|
||||
const attachmentName = String(source?.attachmentName || source?.attachment_name || extractAttachmentDisplayName(invoiceId)).trim()
|
||||
const attachments = invoiceId ? [attachmentName || invoiceId] : []
|
||||
const amountDisplay = itemAmount > 0 ? formatCurrency(itemAmount) : '待补充'
|
||||
const riskText = String(source?.riskText || '').trim()
|
||||
const filledAt = formatExpenseFilledTime(
|
||||
source?.filledAt
|
||||
|| source?.filled_at
|
||||
|| source?.createdAt
|
||||
|| source?.created_at
|
||||
)
|
||||
|
||||
return {
|
||||
id,
|
||||
itemDate,
|
||||
itemType,
|
||||
itemReason,
|
||||
itemLocation,
|
||||
itemAmount,
|
||||
invoiceId,
|
||||
isSystemGenerated,
|
||||
time: itemDate || '待补充',
|
||||
filledAt: filledAt || '待同步',
|
||||
dayLabel: resolveExpenseTimeLabel({
|
||||
id,
|
||||
itemType,
|
||||
isSystemGenerated,
|
||||
requestModel,
|
||||
travelTimeLabelMap
|
||||
}),
|
||||
name: resolveExpenseTypeLabel(itemType),
|
||||
category: resolveExpenseTypeLabel(itemType),
|
||||
desc: itemReason || '待补充',
|
||||
detail: resolveLocationDisplay(itemLocation, itemType),
|
||||
amount: amountDisplay,
|
||||
status: isSystemGenerated ? '系统计算' : attachments.length ? '已识别' : '待补充',
|
||||
tone: isSystemGenerated ? 'system' : attachments.length ? 'ok' : 'bad',
|
||||
attachmentStatus: isSystemGenerated ? '无需附件' : attachments.length ? '已关联票据' : '未上传',
|
||||
attachmentHint: isSystemGenerated ? '根据出差天数与职级自动测算' : attachments.length ? attachmentName || attachments[0] : resolveExpenseUploadHint(),
|
||||
attachmentTone: isSystemGenerated ? 'system' : attachments.length ? 'ok' : 'missing',
|
||||
attachments,
|
||||
riskLabel: String(source?.riskLabel || '').trim() || '无',
|
||||
riskText,
|
||||
riskTone: String(source?.riskTone || '').trim() || 'low'
|
||||
}
|
||||
}
|
||||
|
||||
function rebuildExpenseItems(items, requestModel) {
|
||||
const sortedItems = [...items]
|
||||
.sort((left, right) => Number(isSystemGeneratedExpenseItemSource(left)) - Number(isSystemGeneratedExpenseItemSource(right)))
|
||||
const travelTimeLabelMap = buildTravelTimeLabelMap(sortedItems, requestModel)
|
||||
return sortedItems.map((item, index) => buildExpenseItemViewModel(item, index, requestModel, travelTimeLabelMap))
|
||||
}
|
||||
|
||||
function buildExpenseDraftIssues(item) {
|
||||
const issues = []
|
||||
if (item.isSystemGenerated) {
|
||||
return issues
|
||||
}
|
||||
const locationRequired = isLocationRequiredExpenseType(item.itemType)
|
||||
|
||||
if (!isValidIsoDate(item.itemDate)) {
|
||||
issues.push('缺少日期')
|
||||
}
|
||||
if (isPlaceholderValue(item.itemType)) {
|
||||
issues.push('缺少费用项目')
|
||||
}
|
||||
if (isPlaceholderValue(item.itemReason)) {
|
||||
issues.push('缺少说明')
|
||||
} else if (isRouteDescriptionExpenseType(item.itemType) && !isValidRouteDescription(item.itemReason)) {
|
||||
issues.push('行程说明格式错误')
|
||||
}
|
||||
if (locationRequired && isPlaceholderValue(item.itemLocation)) {
|
||||
issues.push('缺少地点')
|
||||
}
|
||||
if (!Number.isFinite(Number(item.itemAmount)) || Number(item.itemAmount) <= 0) {
|
||||
issues.push('缺少金额')
|
||||
}
|
||||
if (isPlaceholderValue(item.invoiceId)) {
|
||||
issues.push('缺少票据标识')
|
||||
}
|
||||
|
||||
return issues
|
||||
}
|
||||
|
||||
function buildOptionalTravelReceiptRiskCards(requestModel, items) {
|
||||
const normalizedItems = Array.isArray(items) ? items : []
|
||||
@@ -470,39 +211,55 @@ function buildOptionalTravelReceiptRiskCards(requestModel, items) {
|
||||
return cards
|
||||
}
|
||||
|
||||
function buildDraftBlockingIssues(request, expenseItems) {
|
||||
function buildExpenseDraftIssues(item) {
|
||||
const issues = []
|
||||
const locationRequired = isLocationRequiredExpenseType(request.typeCode)
|
||||
if (item.isSystemGenerated) {
|
||||
return issues
|
||||
}
|
||||
const locationRequired = isLocationRequiredExpenseType(item.itemType)
|
||||
|
||||
if (isPlaceholderValue(request.profileName)) {
|
||||
issues.push('申请人未完善')
|
||||
if (!isValidIsoDate(item.itemDate)) {
|
||||
issues.push('缺少日期')
|
||||
}
|
||||
if (isPlaceholderValue(request.typeLabel)) {
|
||||
issues.push('报销类型未完善')
|
||||
if (isPlaceholderValue(item.itemType)) {
|
||||
issues.push('缺少费用项目')
|
||||
}
|
||||
if (isPlaceholderValue(request.reason)) {
|
||||
issues.push('报销事由未完善')
|
||||
if (isPlaceholderValue(item.itemReason)) {
|
||||
issues.push('缺少说明')
|
||||
} else if (isRouteDescriptionExpenseType(item.itemType) && !isValidRouteDescription(item.itemReason)) {
|
||||
issues.push('行程说明格式错误')
|
||||
}
|
||||
if (locationRequired && isPlaceholderValue(request.location)) {
|
||||
issues.push('业务地点未完善')
|
||||
if (locationRequired && isPlaceholderValue(item.itemLocation)) {
|
||||
issues.push('缺少地点')
|
||||
}
|
||||
if (isPlaceholderValue(request.occurredDisplay)) {
|
||||
issues.push('发生时间未完善')
|
||||
if (!Number.isFinite(Number(item.itemAmount)) || Number(item.itemAmount) <= 0) {
|
||||
issues.push('缺少金额')
|
||||
}
|
||||
if (!Number.isFinite(Number(request.amountValue)) || Number(request.amountValue) <= 0) {
|
||||
issues.push('报销金额未完善')
|
||||
}
|
||||
if (!expenseItems.length) {
|
||||
issues.push('费用明细不能为空')
|
||||
if (isPlaceholderValue(item.invoiceId)) {
|
||||
issues.push('缺少票据标识')
|
||||
}
|
||||
|
||||
expenseItems.forEach((item, index) => {
|
||||
buildExpenseDraftIssues(item).forEach((issue) => {
|
||||
issues.push(`费用明细第 ${index + 1} 条${issue}`)
|
||||
})
|
||||
})
|
||||
return issues
|
||||
}
|
||||
|
||||
return [...new Set(issues)]
|
||||
function resolveExpenseReasonPlaceholder(itemType) {
|
||||
if (isRouteDescriptionExpenseType(itemType)) {
|
||||
return '起始地-目的地,例如:广州南-北京南'
|
||||
}
|
||||
if (isHotelDescriptionExpenseType(itemType)) {
|
||||
return '目的地酒店,例如:北京中心酒店'
|
||||
}
|
||||
return '输入费用说明'
|
||||
}
|
||||
|
||||
function resolveExpenseReasonHelper(itemType) {
|
||||
if (isRouteDescriptionExpenseType(itemType)) {
|
||||
return '起始地-目的地'
|
||||
}
|
||||
if (isHotelDescriptionExpenseType(itemType)) {
|
||||
return '目的地酒店'
|
||||
}
|
||||
return '业务报销说明'
|
||||
}
|
||||
|
||||
function mapIssueToAdvice(issue) {
|
||||
@@ -567,6 +324,7 @@ function mapIssueToAdvice(issue) {
|
||||
|
||||
return `${labelPrefix}。`
|
||||
}
|
||||
*/
|
||||
|
||||
export default {
|
||||
name: 'TravelRequestDetailView',
|
||||
@@ -601,6 +359,10 @@ export default {
|
||||
const pendingUploadExpenseId = ref('')
|
||||
const submitBusy = ref(false)
|
||||
const submitConfirmDialogOpen = ref(false)
|
||||
const riskOverrideDialogOpen = ref(false)
|
||||
const riskOverrideBusy = ref(false)
|
||||
const riskOverrideIndex = ref(0)
|
||||
const riskOverrideReasons = reactive({})
|
||||
const deleteBusy = ref(false)
|
||||
const deleteDialogOpen = ref(false)
|
||||
const returnBusy = ref(false)
|
||||
@@ -733,6 +495,7 @@ export default {
|
||||
const actionBusy = computed(() =>
|
||||
Boolean(savingExpenseId.value)
|
||||
|| submitBusy.value
|
||||
|| riskOverrideBusy.value
|
||||
|| deleteBusy.value
|
||||
|| returnBusy.value
|
||||
|| approveBusy.value
|
||||
@@ -857,6 +620,9 @@ export default {
|
||||
return '暂无附加说明。请补充本次出差或办事事由,例如“去北京客户现场出差,拜访 XX 客户并处理项目验收事项”。'
|
||||
})
|
||||
const detailNoteDirty = computed(() => detailNoteEditor.value.trim() !== detailNoteSource.value)
|
||||
const detailNoteTags = computed(() =>
|
||||
extractRiskTagsFromText(canEditDetailNote.value ? detailNoteEditor.value : detailNoteSource.value)
|
||||
)
|
||||
watch(
|
||||
() => [request.value.claimId, detailNoteSource.value],
|
||||
([, nextNote]) => {
|
||||
@@ -867,10 +633,10 @@ export default {
|
||||
const draftBlockingIssues = computed(() =>
|
||||
isEditableRequest.value ? buildDraftBlockingIssues(request.value, expenseItems.value) : []
|
||||
)
|
||||
const canSubmit = computed(() => isEditableRequest.value && draftBlockingIssues.value.length === 0 && !actionBusy.value)
|
||||
const canSubmit = computed(() => isEditableRequest.value && !actionBusy.value)
|
||||
const attachmentPreviewEntries = computed(() =>
|
||||
expenseItems.value
|
||||
.filter((item) => item.invoiceId)
|
||||
.filter((item) => canPreviewAttachment(item))
|
||||
.map((item, index) => ({
|
||||
item,
|
||||
itemId: item.id,
|
||||
@@ -928,6 +694,10 @@ export default {
|
||||
return String(metadata?.file_name || item.attachmentHint || '').trim()
|
||||
}
|
||||
|
||||
function hasStoredAttachmentReference(item) {
|
||||
return String(item?.invoiceId || '').includes('/')
|
||||
}
|
||||
|
||||
function resolveAttachmentPreviewTitle(item) {
|
||||
const fileName = resolveAttachmentDisplayName(item)
|
||||
return fileName ? `预览附件:${fileName}` : '预览附件'
|
||||
@@ -963,8 +733,14 @@ export default {
|
||||
}
|
||||
|
||||
function canPreviewAttachment(item) {
|
||||
if (!item?.invoiceId) {
|
||||
return false
|
||||
}
|
||||
const metadata = resolveAttachmentMeta(item)
|
||||
return Boolean(item.invoiceId && metadata?.previewable !== false)
|
||||
if (metadata) {
|
||||
return metadata.previewable !== false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function revokeAttachmentPreviewUrl() {
|
||||
@@ -1056,6 +832,16 @@ export default {
|
||||
return Boolean(resolveExpenseRiskState(item))
|
||||
}
|
||||
|
||||
function isMajorExpenseRisk(item) {
|
||||
return normalizeRiskTone(resolveExpenseRiskState(item)?.tone) === 'high'
|
||||
}
|
||||
|
||||
function resolveExpenseRiskIndicatorTitle(item) {
|
||||
const state = resolveExpenseRiskState(item)
|
||||
const summary = String(state?.summary || state?.headline || '').trim()
|
||||
return summary ? `重大风险警示:${summary}` : '重大风险警示'
|
||||
}
|
||||
|
||||
const aiAdvice = computed(() => {
|
||||
const completionItems = draftBlockingIssues.value.map(mapIssueToAdvice).filter(Boolean)
|
||||
const riskCards = [
|
||||
@@ -1073,6 +859,21 @@ export default {
|
||||
})
|
||||
})
|
||||
|
||||
const submitRiskWarnings = computed(() =>
|
||||
aiAdvice.value.riskCards
|
||||
.filter((card) => normalizeRiskTone(card?.tone) === 'high')
|
||||
.map((card, index) => ({
|
||||
...card,
|
||||
id: String(card.id || `submit-risk-${index}`),
|
||||
tags: resolveRiskTags(card)
|
||||
}))
|
||||
)
|
||||
const currentSubmitRiskWarning = computed(() => submitRiskWarnings.value[riskOverrideIndex.value] || null)
|
||||
const riskOverrideIndexLabel = computed(() =>
|
||||
submitRiskWarnings.value.length ? `${riskOverrideIndex.value + 1} / ${submitRiskWarnings.value.length}` : ''
|
||||
)
|
||||
const hasRiskOverrideExplanation = computed(() => detailNoteTags.value.includes('#high_risk'))
|
||||
|
||||
function resetDetailNote() {
|
||||
detailNoteEditor.value = detailNoteSource.value
|
||||
}
|
||||
@@ -1103,6 +904,102 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRiskTagClass(tag) {
|
||||
return resolveRiskTagTone(tag)
|
||||
}
|
||||
|
||||
function openRiskOverrideDialog() {
|
||||
const warnings = submitRiskWarnings.value
|
||||
if (!warnings.length) {
|
||||
return
|
||||
}
|
||||
riskOverrideIndex.value = 0
|
||||
const activeIds = new Set(warnings.map((risk) => risk.id))
|
||||
Object.keys(riskOverrideReasons).forEach((riskId) => {
|
||||
if (!activeIds.has(riskId)) {
|
||||
delete riskOverrideReasons[riskId]
|
||||
}
|
||||
})
|
||||
warnings.forEach((risk) => {
|
||||
if (typeof riskOverrideReasons[risk.id] !== 'string') {
|
||||
riskOverrideReasons[risk.id] = ''
|
||||
}
|
||||
})
|
||||
riskOverrideDialogOpen.value = true
|
||||
}
|
||||
|
||||
function closeRiskOverrideDialog() {
|
||||
if (riskOverrideBusy.value) {
|
||||
return
|
||||
}
|
||||
riskOverrideDialogOpen.value = false
|
||||
}
|
||||
|
||||
function goToPreviousSubmitRisk() {
|
||||
if (!submitRiskWarnings.value.length) {
|
||||
return
|
||||
}
|
||||
riskOverrideIndex.value =
|
||||
(riskOverrideIndex.value - 1 + submitRiskWarnings.value.length) % submitRiskWarnings.value.length
|
||||
}
|
||||
|
||||
function goToNextSubmitRisk() {
|
||||
if (!submitRiskWarnings.value.length) {
|
||||
return
|
||||
}
|
||||
riskOverrideIndex.value = (riskOverrideIndex.value + 1) % submitRiskWarnings.value.length
|
||||
}
|
||||
|
||||
function buildRiskOverrideAppendix() {
|
||||
return submitRiskWarnings.value
|
||||
.map((risk, index) => {
|
||||
const reason = String(riskOverrideReasons[risk.id] || '').trim()
|
||||
const tags = resolveRiskTags(risk).join(' ')
|
||||
const title = String(risk.title || risk.label || '重大风险').trim()
|
||||
return `超标说明:${tags} 第${index + 1}条 ${title}:${reason}`
|
||||
})
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
function mergeDetailNoteWithRiskOverride(appendix) {
|
||||
const baseNote = detailNoteEditor.value.trim() || detailNoteSource.value
|
||||
return [baseNote, appendix].map((item) => String(item || '').trim()).filter(Boolean).join('\n')
|
||||
}
|
||||
|
||||
async function confirmRiskOverrideReasons() {
|
||||
if (riskOverrideBusy.value) {
|
||||
return
|
||||
}
|
||||
const missingIndex = submitRiskWarnings.value.findIndex((risk) => !String(riskOverrideReasons[risk.id] || '').trim())
|
||||
if (missingIndex >= 0) {
|
||||
riskOverrideIndex.value = missingIndex
|
||||
toast('请为每一条重大风险填写违规提交原因。')
|
||||
return
|
||||
}
|
||||
|
||||
const appendix = buildRiskOverrideAppendix()
|
||||
const nextNote = mergeDetailNoteWithRiskOverride(appendix)
|
||||
if (nextNote.length > 500) {
|
||||
toast('附加说明最多 500 字,请精简风险原因后再继续提交。')
|
||||
return
|
||||
}
|
||||
|
||||
riskOverrideBusy.value = true
|
||||
try {
|
||||
await updateExpenseClaim(request.value.claimId, {
|
||||
reason: nextNote
|
||||
})
|
||||
detailNoteEditor.value = nextNote
|
||||
riskOverrideDialogOpen.value = false
|
||||
submitConfirmDialogOpen.value = true
|
||||
toast('违规提交原因已写入附加说明。')
|
||||
} catch (error) {
|
||||
toast(error?.message || '风险原因保存失败,请稍后重试。')
|
||||
} finally {
|
||||
riskOverrideBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function populateExpenseEditor(item) {
|
||||
editingExpenseId.value = item.id
|
||||
expenseEditor.itemDate = item.itemDate || ''
|
||||
@@ -1226,7 +1123,14 @@ export default {
|
||||
|
||||
try {
|
||||
if (!metadata) {
|
||||
try {
|
||||
metadata = await refreshExpenseAttachmentMeta(item.id)
|
||||
} catch (error) {
|
||||
if (!hasStoredAttachmentReference(item)) {
|
||||
throw new Error('当前附件只有文件名记录,原件尚未保存到单据中,请重新上传后预览。')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
if (metadata?.previewable === false) {
|
||||
throw new Error('当前附件暂不支持直接预览。')
|
||||
@@ -1506,10 +1410,20 @@ export default {
|
||||
}
|
||||
|
||||
if (!canSubmit.value) {
|
||||
toast('当前单据正在保存或处理附件,请稍后再提交审批。')
|
||||
return
|
||||
}
|
||||
|
||||
if (draftBlockingIssues.value.length) {
|
||||
toast(draftBlockingIssues.value[0] || '请先补全草稿信息,再提交审批。')
|
||||
return
|
||||
}
|
||||
|
||||
if (submitRiskWarnings.value.length && !hasRiskOverrideExplanation.value) {
|
||||
openRiskOverrideDialog()
|
||||
return
|
||||
}
|
||||
|
||||
submitConfirmDialogOpen.value = true
|
||||
}
|
||||
|
||||
@@ -1529,11 +1443,23 @@ export default {
|
||||
}
|
||||
|
||||
if (!canSubmit.value) {
|
||||
toast('当前单据正在保存或处理附件,请稍后再提交审批。')
|
||||
submitConfirmDialogOpen.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (draftBlockingIssues.value.length) {
|
||||
toast(draftBlockingIssues.value[0] || '请先补全草稿信息,再提交审批。')
|
||||
submitConfirmDialogOpen.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (submitRiskWarnings.value.length && !hasRiskOverrideExplanation.value) {
|
||||
submitConfirmDialogOpen.value = false
|
||||
openRiskOverrideDialog()
|
||||
return
|
||||
}
|
||||
|
||||
submitBusy.value = true
|
||||
try {
|
||||
const payload = await submitExpenseClaim(request.value.claimId)
|
||||
@@ -1706,106 +1632,37 @@ export default {
|
||||
})
|
||||
|
||||
return {
|
||||
emit,
|
||||
actionBusy,
|
||||
aiAdvice,
|
||||
attachmentPreviewError,
|
||||
attachmentPreviewIndexLabel,
|
||||
attachmentPreviewLoading,
|
||||
attachmentPreviewMediaType,
|
||||
attachmentPreviewName,
|
||||
attachmentPreviewOpen,
|
||||
attachmentPreviewUrl,
|
||||
approveBusy,
|
||||
approveConfirmDialogOpen,
|
||||
approvalConfirmBadge,
|
||||
approvalConfirmDescription,
|
||||
approvalNextStage,
|
||||
approvalOpinionHint,
|
||||
approvalOpinionPlaceholder,
|
||||
approvalOpinionTitle,
|
||||
canDeleteRequest,
|
||||
canManageCurrentClaim,
|
||||
canNavigateAttachmentPreview,
|
||||
canOpenAiEntry,
|
||||
canApproveRequest,
|
||||
canReturnRequest,
|
||||
canSubmit,
|
||||
canPreviewAttachment,
|
||||
closeApproveConfirmDialog,
|
||||
closeDeleteDialog,
|
||||
closeAttachmentPreview,
|
||||
closeSubmitConfirmDialog,
|
||||
closeReturnDialog,
|
||||
confirmApproveRequest,
|
||||
confirmDeleteRequest,
|
||||
confirmSubmitRequest,
|
||||
confirmReturnRequest,
|
||||
currentAttachmentPreviewInsight,
|
||||
currentAttachmentPreviewRiskCards,
|
||||
currentProgressRingMotion,
|
||||
canEditDetailNote,
|
||||
deleteActionLabel,
|
||||
deleteBusy,
|
||||
deleteDialogDescription,
|
||||
deleteDialogOpen,
|
||||
deleteDialogTitle,
|
||||
deletingAttachmentId,
|
||||
deletingExpenseId,
|
||||
detailNote,
|
||||
detailNoteDirty,
|
||||
detailNoteEditor,
|
||||
draftBlockingIssues,
|
||||
editingExpenseId,
|
||||
creatingExpense,
|
||||
expenseEditor,
|
||||
expenseItems,
|
||||
expenseTableColumnCount,
|
||||
expenseTotal,
|
||||
expenseUploadInput,
|
||||
emit, actionBusy, aiAdvice, attachmentPreviewError, attachmentPreviewIndexLabel,
|
||||
attachmentPreviewLoading, attachmentPreviewMediaType, attachmentPreviewName, attachmentPreviewOpen,
|
||||
attachmentPreviewUrl, approveBusy, approveConfirmDialogOpen, approvalConfirmBadge,
|
||||
approvalConfirmDescription, approvalNextStage, approvalOpinionHint, approvalOpinionPlaceholder,
|
||||
approvalOpinionTitle, canDeleteRequest, canManageCurrentClaim, canNavigateAttachmentPreview,
|
||||
canOpenAiEntry, canApproveRequest, canReturnRequest, canSubmit, canPreviewAttachment,
|
||||
closeApproveConfirmDialog, closeDeleteDialog, closeAttachmentPreview, closeSubmitConfirmDialog,
|
||||
closeRiskOverrideDialog,
|
||||
closeReturnDialog, confirmApproveRequest, confirmDeleteRequest, confirmSubmitRequest, confirmReturnRequest,
|
||||
confirmRiskOverrideReasons,
|
||||
currentAttachmentPreviewInsight, currentAttachmentPreviewRiskCards, currentProgressRingMotion,
|
||||
currentSubmitRiskWarning,
|
||||
canEditDetailNote, deleteActionLabel, deleteBusy, deleteDialogDescription, deleteDialogOpen,
|
||||
deleteDialogTitle, deletingAttachmentId, deletingExpenseId, detailNote, detailNoteDirty,
|
||||
detailNoteEditor, detailNoteTags, draftBlockingIssues, editingExpenseId, creatingExpense, expenseEditor,
|
||||
expenseItems, expenseTableColumnCount, expenseTotal, expenseUploadInput,
|
||||
expenseTypeOptions: EXPENSE_TYPE_OPTIONS,
|
||||
handleAddExpenseItem,
|
||||
handleApproveRequest,
|
||||
handleDeleteRequest,
|
||||
handleExpenseFileChange,
|
||||
handleReturnRequest,
|
||||
handleSubmit,
|
||||
heroFactItems,
|
||||
isDraftRequest,
|
||||
isEditableRequest,
|
||||
isTravelRequest,
|
||||
openAiEntry,
|
||||
openAttachmentPreview,
|
||||
goToNextAttachmentPreview,
|
||||
goToPreviousAttachmentPreview,
|
||||
profile,
|
||||
progressSteps,
|
||||
request,
|
||||
leaderOpinion,
|
||||
removeExpenseAttachment,
|
||||
removeExpenseItem,
|
||||
resetDetailNote,
|
||||
resolveAttachmentDisplayName,
|
||||
resolveAttachmentPreviewTitle,
|
||||
resolveAttachmentRecognition,
|
||||
resolveExpenseReasonHelper,
|
||||
resolveExpenseReasonPlaceholder,
|
||||
resolveExpenseRiskState,
|
||||
resolveExpenseIssues,
|
||||
returnBusy,
|
||||
returnDialogOpen,
|
||||
saveDetailNote,
|
||||
savingDetailNote,
|
||||
savingExpenseId,
|
||||
showLeaderApprovalPanel,
|
||||
showExpenseRisk,
|
||||
startExpenseEdit,
|
||||
submitBusy,
|
||||
submitConfirmDialogOpen,
|
||||
triggerExpenseUpload,
|
||||
uploadedExpenseCount,
|
||||
uploadingExpenseId,
|
||||
saveExpenseEdit
|
||||
goToNextSubmitRisk, goToPreviousSubmitRisk,
|
||||
handleAddExpenseItem, handleApproveRequest, handleDeleteRequest, handleExpenseFileChange,
|
||||
handleReturnRequest, handleSubmit, heroFactItems, isDraftRequest, isEditableRequest, isTravelRequest,
|
||||
isMajorExpenseRisk,
|
||||
openAiEntry, openAttachmentPreview, goToNextAttachmentPreview, goToPreviousAttachmentPreview,
|
||||
profile, progressSteps, request, leaderOpinion, removeExpenseAttachment, removeExpenseItem,
|
||||
resolveExpenseRiskIndicatorTitle, resolveRiskTagClass,
|
||||
resetDetailNote, resolveAttachmentDisplayName, resolveAttachmentPreviewTitle, resolveAttachmentRecognition,
|
||||
resolveExpenseReasonHelper, resolveExpenseReasonPlaceholder, resolveExpenseRiskState, resolveExpenseIssues,
|
||||
returnBusy, returnDialogOpen, riskOverrideBusy, riskOverrideDialogOpen, riskOverrideIndexLabel,
|
||||
riskOverrideReasons, saveDetailNote, savingDetailNote, savingExpenseId,
|
||||
showLeaderApprovalPanel, showExpenseRisk, startExpenseEdit, submitBusy, submitConfirmDialogOpen,
|
||||
submitRiskWarnings,
|
||||
triggerExpenseUpload, uploadedExpenseCount, uploadingExpenseId, saveExpenseEdit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
328
web/src/views/scripts/auditViewMetadata.js
Normal file
328
web/src/views/scripts/auditViewMetadata.js
Normal file
@@ -0,0 +1,328 @@
|
||||
export const RULE_TABLE_COLUMNS = {
|
||||
name: '规则名称',
|
||||
category: '业务域',
|
||||
owner: '负责人',
|
||||
scope: '适用场景',
|
||||
version: '修改次数',
|
||||
metric: '修改人'
|
||||
}
|
||||
|
||||
export const TYPE_META = {
|
||||
rules: {
|
||||
assetType: 'rule',
|
||||
label: '规则',
|
||||
typeLabel: '规则',
|
||||
tableColumns: RULE_TABLE_COLUMNS
|
||||
},
|
||||
skills: {
|
||||
assetType: 'skill',
|
||||
label: '技能',
|
||||
typeLabel: '技能',
|
||||
createButtonLabel: '技能已接入',
|
||||
hintText: '技能页签已接到真实资产 API,可查看输入、输出、依赖和场景信息。',
|
||||
searchPlaceholder: '搜索技能名称、编码或负责人',
|
||||
showMetricColumn: false,
|
||||
tableColumns: {
|
||||
name: '技能名称',
|
||||
category: '业务域',
|
||||
owner: '负责人',
|
||||
scope: '适用场景',
|
||||
runtime: '输入摘要',
|
||||
version: '当前版本',
|
||||
metric: ''
|
||||
}
|
||||
},
|
||||
mcp: {
|
||||
assetType: 'mcp',
|
||||
label: 'MCP',
|
||||
typeLabel: 'MCP',
|
||||
createButtonLabel: 'MCP 已接入',
|
||||
hintText: 'MCP 页签已接到真实资产 API,可查看服务地址、鉴权方式、超时和降级策略。',
|
||||
searchPlaceholder: '搜索 MCP 名称、编码或负责人',
|
||||
tableColumns: {
|
||||
name: 'MCP 服务',
|
||||
category: '业务域',
|
||||
owner: '维护人',
|
||||
scope: '适用场景',
|
||||
runtime: '调用地址',
|
||||
version: '当前版本',
|
||||
metric: '超时配置'
|
||||
}
|
||||
},
|
||||
tasks: {
|
||||
assetType: 'task',
|
||||
label: '任务',
|
||||
typeLabel: '任务',
|
||||
createButtonLabel: '任务已接入',
|
||||
hintText: '任务页签已接到真实资产 API,可查看调度周期、执行 Agent 和最近执行结果。',
|
||||
searchPlaceholder: '搜索任务名称、编码或负责人',
|
||||
tableColumns: {
|
||||
name: '任务名称',
|
||||
category: '业务域',
|
||||
owner: '负责人',
|
||||
scope: '适用场景',
|
||||
runtime: '调度周期',
|
||||
version: '当前版本',
|
||||
metric: '执行 Agent'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const TAB_META = {
|
||||
financialRules: {
|
||||
assetType: 'rule',
|
||||
typeKey: 'rules',
|
||||
label: '财务规则',
|
||||
typeLabel: '财务规则',
|
||||
createButtonLabel: '财务规则已接入',
|
||||
hintText: '仅展示 tag 为“财务规则”的规则资产;未打新 tag 的旧规则已从规则中心隐藏。',
|
||||
searchPlaceholder: '搜索财务规则名称、编码或负责人',
|
||||
tableColumns: RULE_TABLE_COLUMNS,
|
||||
showRuntimeColumn: false,
|
||||
showStatusColumn: false,
|
||||
badgeTone: 'emerald'
|
||||
},
|
||||
riskRules: {
|
||||
assetType: 'rule',
|
||||
typeKey: 'rules',
|
||||
label: '风险规则',
|
||||
typeLabel: '风险规则',
|
||||
createButtonLabel: '风险规则已接入',
|
||||
hintText: '仅展示平台风险规则;适用场景按差旅、发票、餐饮招待等分类,可用「使用场景」筛选。',
|
||||
searchPlaceholder: '搜索风险规则名称、编码或负责人',
|
||||
tableColumns: RULE_TABLE_COLUMNS,
|
||||
showRuntimeColumn: false,
|
||||
showVersionColumn: false,
|
||||
showStatusColumn: false,
|
||||
badgeTone: 'rose'
|
||||
},
|
||||
skills: {
|
||||
...TYPE_META.skills,
|
||||
typeKey: 'skills',
|
||||
badgeTone: 'blue'
|
||||
},
|
||||
mcp: {
|
||||
...TYPE_META.mcp,
|
||||
typeKey: 'mcp',
|
||||
badgeTone: 'amber'
|
||||
},
|
||||
tasks: {
|
||||
...TYPE_META.tasks,
|
||||
typeKey: 'tasks',
|
||||
badgeTone: 'violet'
|
||||
}
|
||||
}
|
||||
|
||||
export const STATUS_META = {
|
||||
draft: { label: '草稿中', tone: 'draft' },
|
||||
review: { label: '待审核', tone: 'warning' },
|
||||
active: { label: '已上线', tone: 'success' },
|
||||
disabled: { label: '已停用', tone: 'disabled' }
|
||||
}
|
||||
|
||||
export const REVIEW_META = {
|
||||
approved: { label: '已通过', tone: 'success' },
|
||||
pending: { label: '待审核', tone: 'warning' },
|
||||
rejected: { label: '已驳回', tone: 'danger' }
|
||||
}
|
||||
|
||||
export const VERSION_STATE_META = {
|
||||
published: { label: '已上线', tone: 'success' },
|
||||
draft: { label: '草稿', tone: 'draft' },
|
||||
pending_review: { label: '待审核', tone: 'warning' },
|
||||
approved: { label: '已通过待上线', tone: 'success' },
|
||||
rejected: { label: '已驳回', tone: 'danger' },
|
||||
history: { label: '历史版本', tone: 'disabled' }
|
||||
}
|
||||
|
||||
export const DOMAIN_LABELS = {
|
||||
expense: '报销',
|
||||
ar: '应收',
|
||||
ap: '应付',
|
||||
knowledge: '知识',
|
||||
system: '系统'
|
||||
}
|
||||
|
||||
export const SCENARIO_LABELS = {
|
||||
expense: '报销',
|
||||
risk_check: '风险检查',
|
||||
duplicate_expense: '重复报销',
|
||||
explain: '规则解释',
|
||||
invoice_anomaly: '票据异常',
|
||||
travel_policy: '差旅制度',
|
||||
travel_standard: '差旅标准',
|
||||
communication_expense: '通信费报销',
|
||||
expense_standard: '费用标准',
|
||||
accounts_payable: '应付',
|
||||
accounts_receivable: '应收',
|
||||
approval_required: '需审批',
|
||||
query: '查询',
|
||||
summary: '汇总',
|
||||
system: '系统',
|
||||
schedule: '调度',
|
||||
rule_center: '规则中心',
|
||||
review_digest: '待审摘要',
|
||||
aging_summary: '账龄汇总',
|
||||
invoice_validation: '发票验真'
|
||||
}
|
||||
|
||||
export const DETAIL_TITLES = {
|
||||
rules: {
|
||||
configTitle: '规则元信息',
|
||||
configDesc: '展示规则编码、版本、业务域和当前审核 / 上线状态。',
|
||||
detailTitle: '规则版本说明',
|
||||
detailDesc: '规则正文由 Markdown 驱动,保存后会生成新的版本快照。',
|
||||
outputTitle: '审核与上线',
|
||||
outputDesc: '规则上线受审核状态控制,未审核通过的版本会被后端拦截。',
|
||||
ruleListTitle: '上线要求',
|
||||
checkListTitle: '当前状态',
|
||||
triggerTitle: '适用场景',
|
||||
triggerDesc: '当前规则注册到的业务场景',
|
||||
toolTitle: '关联信息',
|
||||
toolDesc: '规则当前审核、保存和版本快照信息',
|
||||
historyTitle: '版本历史',
|
||||
historyDesc: '最近 5 个规则版本',
|
||||
publishTitle: '上线控制',
|
||||
publishDesc: '正式上线会调用后端激活接口,审核未通过时会被拦截。'
|
||||
},
|
||||
skills: {
|
||||
configTitle: '技能配置',
|
||||
configDesc: '展示技能编码、输入摘要、版本和业务域。',
|
||||
detailTitle: '技能结构',
|
||||
detailDesc: '按输入、输出和依赖组织技能定义。',
|
||||
outputTitle: '输出契约',
|
||||
outputDesc: '技能详情重点展示输入参数、输出参数和依赖能力。',
|
||||
ruleListTitle: '输出要求',
|
||||
checkListTitle: '当前快照',
|
||||
triggerTitle: '适用场景',
|
||||
triggerDesc: '当前技能注册到的场景标签',
|
||||
toolTitle: '依赖能力',
|
||||
toolDesc: '技能当前依赖的数据库或其他能力',
|
||||
historyTitle: '版本历史',
|
||||
historyDesc: '最近版本记录',
|
||||
publishTitle: '发布状态',
|
||||
publishDesc: '技能当前状态由资产中心统一管理。'
|
||||
},
|
||||
mcp: {
|
||||
configTitle: 'MCP 连接配置',
|
||||
configDesc: '展示服务地址、超时和调用方式。',
|
||||
detailTitle: '服务协议',
|
||||
detailDesc: '按服务类型、鉴权方式和降级策略组织外部服务信息。',
|
||||
outputTitle: '调用约束',
|
||||
outputDesc: 'MCP 详情重点展示鉴权方式、返回策略和最近调用状态。',
|
||||
ruleListTitle: '调用约束',
|
||||
checkListTitle: '最近状态',
|
||||
triggerTitle: '适用场景',
|
||||
triggerDesc: '当前 MCP 覆盖的业务场景',
|
||||
toolTitle: '运行信息',
|
||||
toolDesc: '结合 AgentRun 中的 ToolCall 还原最近一次调用状态',
|
||||
historyTitle: '版本历史',
|
||||
historyDesc: '最近版本记录',
|
||||
publishTitle: '服务状态',
|
||||
publishDesc: 'MCP 资产已接入规则中心,但真实外部调用仍以后续链路集成为准。'
|
||||
},
|
||||
tasks: {
|
||||
configTitle: '任务配置',
|
||||
configDesc: '展示调度周期、执行 Agent 和任务编码。',
|
||||
detailTitle: '任务结构',
|
||||
detailDesc: '按调度计划、目标场景和运行结果组织任务信息。',
|
||||
outputTitle: '运行要求',
|
||||
outputDesc: '任务详情重点展示调度 Agent、最近运行结果和运行日志入口。',
|
||||
ruleListTitle: '运行要求',
|
||||
checkListTitle: '最近执行',
|
||||
triggerTitle: '适用场景',
|
||||
triggerDesc: '当前任务覆盖的业务场景',
|
||||
toolTitle: '最近调用',
|
||||
toolDesc: '根据 AgentRun 中的最近执行记录回显任务运行情况',
|
||||
historyTitle: '版本历史',
|
||||
historyDesc: '最近版本记录',
|
||||
publishTitle: '调度状态',
|
||||
publishDesc: '任务资产已接入规则中心,后续 Day 4 运行时会继续消费这些配置。'
|
||||
}
|
||||
}
|
||||
|
||||
export const STATUS_OPTIONS = [
|
||||
{ value: '', label: '全部状态' },
|
||||
{ value: 'draft', label: '草稿中' },
|
||||
{ value: 'review', label: '待审核' },
|
||||
{ value: 'active', 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
|
||||
|
||||
export const RULE_TEMPLATE_LABELS = {
|
||||
travel_standard_v1: '差旅标准模板',
|
||||
expense_amount_limit_v1: '金额上限模板',
|
||||
attachment_requirement_v1: '附件要求模板',
|
||||
general_policy_v1: '通用制度模板'
|
||||
}
|
||||
|
||||
export const RULE_TAB_TAG_ALIASES = {
|
||||
financialRules: new Set(['财务规则', '财务', 'financialrule', 'financialrules', 'financerule', 'financerules', 'financial', 'finance']),
|
||||
riskRules: new Set(['风险规则', '风险', '风控', 'riskrule', 'riskrules', 'risk'])
|
||||
}
|
||||
|
||||
export const RISK_SCENARIO_OPTIONS = [
|
||||
{ value: '', label: '全部场景' },
|
||||
{ value: '差旅', label: '差旅' },
|
||||
{ value: '发票', label: '发票' },
|
||||
{ value: '餐饮招待', label: '餐饮招待' },
|
||||
{ value: '交通出行', label: '交通出行' },
|
||||
{ value: '办公物料', label: '办公物料' },
|
||||
{ value: '费用科目', label: '费用科目' },
|
||||
{ value: '通用', label: '通用' }
|
||||
]
|
||||
|
||||
export const RISK_SCENARIO_VALUES = new Set(RISK_SCENARIO_OPTIONS.map((item) => item.value).filter(Boolean))
|
||||
|
||||
export const LEGACY_RISK_SCENARIO_KEYS = new Set([
|
||||
'expense',
|
||||
'risk_check',
|
||||
'travel',
|
||||
'meal',
|
||||
'invoice',
|
||||
'travel_policy',
|
||||
'travel_standard',
|
||||
'attachment_policy',
|
||||
'scene_policy',
|
||||
'invoice_anomaly',
|
||||
'communication_expense',
|
||||
'expense_standard',
|
||||
'approval_required'
|
||||
])
|
||||
|
||||
export const SPREADSHEET_DETAIL_MODE = 'spreadsheet'
|
||||
export const JSON_RISK_DETAIL_MODE = 'json_risk'
|
||||
export const PREVIEW_RULE_ID = 'preview-rule-expense-company-travel-expense'
|
||||
export const PREVIEW_RULE_CODE = 'rule.expense.company_travel_expense_reimbursement'
|
||||
export const PREVIEW_RULE_VERSION_SPECS = [
|
||||
{
|
||||
version: 'v1.2.0',
|
||||
fileName: '公司差旅费报销规则.xlsx',
|
||||
updatedAt: '2026-05-17T09:30:00Z',
|
||||
updatedBy: '王楠',
|
||||
note: '补充城市分级与住宿限额示例。',
|
||||
source: 'preview',
|
||||
isCurrent: true
|
||||
},
|
||||
{
|
||||
version: 'v1.1.0',
|
||||
fileName: '公司差旅费报销规则-v1.1.0.xlsx',
|
||||
updatedAt: '2026-05-14T15:20:00Z',
|
||||
updatedBy: '顾承宇',
|
||||
note: '新增票据要求与超标审批列。',
|
||||
source: 'preview',
|
||||
isCurrent: false
|
||||
},
|
||||
{
|
||||
version: 'v1.0.0',
|
||||
fileName: '公司差旅费报销规则-v1.0.0.xlsx',
|
||||
updatedAt: '2026-05-10T11:10:00Z',
|
||||
updatedBy: '系统初始化',
|
||||
note: '首版差旅费报销规则表预览。',
|
||||
source: 'preview',
|
||||
isCurrent: false
|
||||
}
|
||||
]
|
||||
1315
web/src/views/scripts/auditViewModel.js
Normal file
1315
web/src/views/scripts/auditViewModel.js
Normal file
File diff suppressed because it is too large
Load Diff
109
web/src/views/scripts/auditViewRuntimeModel.js
Normal file
109
web/src/views/scripts/auditViewRuntimeModel.js
Normal file
@@ -0,0 +1,109 @@
|
||||
import { normalizeText, readConfigJson, resolveRuleTemplateLabel } from './auditViewModel.js'
|
||||
|
||||
export function incrementVersion(version) {
|
||||
const normalized = normalizeText(version).replace(/^v/i, '')
|
||||
const match = normalized.match(/^(\d+)\.(\d+)\.(\d+)$/)
|
||||
|
||||
if (!match) {
|
||||
return 'v1.0.0'
|
||||
}
|
||||
|
||||
const major = Number(match[1])
|
||||
const minor = Number(match[2])
|
||||
const patch = Number(match[3]) + 1
|
||||
return `v${major}.${minor}.${patch}`
|
||||
}
|
||||
|
||||
export function buildReviewNote(status) {
|
||||
if (status === 'approved') {
|
||||
return '通过任务规则中心审核。'
|
||||
}
|
||||
if (status === 'rejected') {
|
||||
return '在任务规则中心驳回当前版本。'
|
||||
}
|
||||
return '提交任务规则中心待审核。'
|
||||
}
|
||||
|
||||
export function buildRuleConfigPayload(asset, runtimeRule) {
|
||||
const configJson = {
|
||||
...readConfigJson(asset),
|
||||
runtime_kind: normalizeText(runtimeRule?.kind) || asset.runtimeKind || 'policy_rule_draft',
|
||||
runtime_rule: runtimeRule
|
||||
}
|
||||
const templateKey = normalizeText(runtimeRule?.template_key) || asset.ruleTemplateKey
|
||||
if (templateKey) {
|
||||
configJson.rule_template_key = templateKey
|
||||
configJson.rule_template_label = resolveRuleTemplateLabel(templateKey)
|
||||
}
|
||||
return configJson
|
||||
}
|
||||
|
||||
export function buildSpreadsheetChangeRecordKey(records = []) {
|
||||
const latest = records.find((item) => item?.changed_at)
|
||||
if (!latest) {
|
||||
return ''
|
||||
}
|
||||
const previewSignature = Array.isArray(latest.cell_changes)
|
||||
? latest.cell_changes
|
||||
.slice(0, 8)
|
||||
.map((item) =>
|
||||
[
|
||||
item?.sheet_name,
|
||||
item?.cell,
|
||||
item?.change_type,
|
||||
item?.before_value,
|
||||
item?.after_value
|
||||
]
|
||||
.map((value) => normalizeText(value))
|
||||
.join(':')
|
||||
)
|
||||
.join('|')
|
||||
: ''
|
||||
const sheetSignature = Array.isArray(latest.sheet_changes)
|
||||
? latest.sheet_changes
|
||||
.map((item) =>
|
||||
[item?.sheet_name, item?.change_type]
|
||||
.map((value) => normalizeText(value))
|
||||
.join(':')
|
||||
)
|
||||
.join('|')
|
||||
: ''
|
||||
return [
|
||||
latest.id,
|
||||
latest.changed_at,
|
||||
latest.actor,
|
||||
latest.summary,
|
||||
latest.changed_sheet_count,
|
||||
latest.changed_cell_count,
|
||||
sheetSignature,
|
||||
previewSignature
|
||||
]
|
||||
.map((value) => normalizeText(value))
|
||||
.join('-')
|
||||
}
|
||||
|
||||
export function filterAuditAssets(assets = [], filters = {}) {
|
||||
const normalizedKeyword = normalizeText(filters.keyword).toLowerCase()
|
||||
|
||||
return assets.filter((item) => {
|
||||
const matchesKeyword = normalizedKeyword
|
||||
? [item.name, item.code, item.summary, item.owner, item.scope]
|
||||
.filter(Boolean)
|
||||
.some((value) => String(value).toLowerCase().includes(normalizedKeyword))
|
||||
: true
|
||||
const matchesDomain = filters.selectedDomain ? item.domainValue === filters.selectedDomain : true
|
||||
const matchesOwner = filters.selectedOwner ? item.owner === filters.selectedOwner : true
|
||||
const matchesStatus = filters.showStatusFilter
|
||||
? filters.selectedStatus
|
||||
? item.statusValue === filters.selectedStatus
|
||||
: true
|
||||
: true
|
||||
const matchesRiskScenario = filters.showRiskScenarioFilter
|
||||
? filters.selectedRiskScenario
|
||||
? item.riskCategory === filters.selectedRiskScenario
|
||||
: true
|
||||
: true
|
||||
|
||||
return matchesKeyword && matchesDomain && matchesOwner && matchesStatus && matchesRiskScenario
|
||||
})
|
||||
}
|
||||
428
web/src/views/scripts/travelReimbursementAttachmentModel.js
Normal file
428
web/src/views/scripts/travelReimbursementAttachmentModel.js
Normal file
@@ -0,0 +1,428 @@
|
||||
import {
|
||||
buildReviewSlotMap,
|
||||
resolveDocumentTypeLabel,
|
||||
resolveExpenseTypeCode
|
||||
} from './travelReimbursementReviewModel.js'
|
||||
import { normalizeExpenseQueryPayload } from './travelReimbursementExpenseQueryModel.js'
|
||||
|
||||
const SCENARIO_LABELS = {
|
||||
expense: '??',
|
||||
accounts_receivable: '??',
|
||||
accounts_payable: '??',
|
||||
knowledge: '??',
|
||||
unknown: '??'
|
||||
}
|
||||
|
||||
const INTENT_LABELS = {
|
||||
query: '??',
|
||||
explain: '??',
|
||||
compare: '??',
|
||||
risk_check: '????',
|
||||
draft: '????',
|
||||
operate: '????'
|
||||
}
|
||||
|
||||
function resolveStatusLabel(status) {
|
||||
if (status === 'succeeded') return '???'
|
||||
if (status === 'blocked') return '???'
|
||||
return '??'
|
||||
}
|
||||
|
||||
function resolveStatusTone(status) {
|
||||
if (status === 'succeeded') return 'success'
|
||||
if (status === 'blocked') return 'warning'
|
||||
return 'note'
|
||||
}
|
||||
|
||||
export const MAX_ATTACHMENTS = 10
|
||||
export const MAX_OCR_DOCUMENTS = 10
|
||||
export const VISIBLE_ATTACHMENT_CHIPS = 2
|
||||
|
||||
export function normalizeOcrDocuments(payload) {
|
||||
const documents = Array.isArray(payload?.documents) ? payload.documents : []
|
||||
return documents.slice(0, MAX_OCR_DOCUMENTS).map((item) => ({
|
||||
filename: item.filename,
|
||||
summary: item.summary,
|
||||
text: String(item.text || '').slice(0, 240),
|
||||
avg_score: Number(item.avg_score || 0),
|
||||
line_count: Number(item.line_count || 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(),
|
||||
preview_kind: String(item.preview_kind || '').trim(),
|
||||
preview_data_url: String(item.preview_data_url || '').trim(),
|
||||
preview_url: String(item.preview_url || '').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 : []
|
||||
}))
|
||||
}
|
||||
|
||||
export function buildOcrSummary(payload) {
|
||||
return buildOcrSummaryFromDocuments(normalizeOcrDocuments(payload))
|
||||
}
|
||||
|
||||
export function buildOcrSummaryFromDocuments(documents) {
|
||||
return (Array.isArray(documents) ? documents : [])
|
||||
.slice(0, MAX_OCR_DOCUMENTS)
|
||||
.map((item) => {
|
||||
const filename = String(item?.filename || '').trim()
|
||||
const summary = String(item?.summary || item?.text || '').trim()
|
||||
if (filename && summary) {
|
||||
return `${filename}:${summary}`
|
||||
}
|
||||
return filename || summary
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(';')
|
||||
}
|
||||
|
||||
export function normalizeReviewDocumentFieldKey(label) {
|
||||
const compact = String(label || '').replace(/\s+/g, '').toLowerCase()
|
||||
if (!compact) return ''
|
||||
if (
|
||||
['金额', '价税合计', '合计', '总额', '总计', '票价', '支付金额', '实付金额', '实收金额'].some((token) =>
|
||||
compact.includes(token.toLowerCase())
|
||||
)
|
||||
) {
|
||||
return 'amount'
|
||||
}
|
||||
if (['日期', '时间', '开票日期', '发生时间'].some((token) => compact.includes(token.toLowerCase()))) {
|
||||
return 'date'
|
||||
}
|
||||
if (['商户', '酒店', '销售方', '开票方', '收款方'].some((token) => compact.includes(token.toLowerCase()))) {
|
||||
return 'merchant_name'
|
||||
}
|
||||
if (['票据号码', '发票号码', '票号', '单号', '订单号'].some((token) => compact.includes(token.toLowerCase()))) {
|
||||
return 'invoice_number'
|
||||
}
|
||||
if (compact.includes('发票代码')) {
|
||||
return 'invoice_code'
|
||||
}
|
||||
if (compact.includes('车次') || compact.includes('航班')) {
|
||||
return 'trip_no'
|
||||
}
|
||||
if (compact.includes('行程') || compact.includes('路线')) {
|
||||
return 'route'
|
||||
}
|
||||
return compact
|
||||
}
|
||||
|
||||
export function buildOcrDocumentsFromReviewPayload(reviewPayload) {
|
||||
const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : []
|
||||
return documents.slice(0, MAX_OCR_DOCUMENTS).map((item) => {
|
||||
const fields = Array.isArray(item?.fields)
|
||||
? item.fields
|
||||
.map((field) => {
|
||||
const label = String(field?.label || '').trim()
|
||||
const value = String(field?.value || '').trim()
|
||||
if (!label || !value) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
key: normalizeReviewDocumentFieldKey(label),
|
||||
label,
|
||||
value
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
: []
|
||||
|
||||
return {
|
||||
filename: String(item?.filename || '').trim(),
|
||||
summary: String(item?.summary || '').trim(),
|
||||
text: [
|
||||
String(item?.scene_label || '').trim(),
|
||||
String(item?.summary || '').trim(),
|
||||
...fields.map((field) => `${field.label}:${field.value}`)
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.slice(0, 240),
|
||||
avg_score: Number(item?.avg_score || 0),
|
||||
document_type: String(item?.document_type || 'other').trim() || 'other',
|
||||
document_type_label: resolveDocumentTypeLabel(item?.document_type),
|
||||
scene_code: resolveExpenseTypeCode(item?.suggested_expense_type),
|
||||
scene_label: String(item?.scene_label || '').trim(),
|
||||
document_fields: fields,
|
||||
warnings: Array.isArray(item?.warnings) ? item.warnings : []
|
||||
}
|
||||
}).filter((item) => item.filename)
|
||||
}
|
||||
|
||||
export function mergeUploadAttachmentNames(existingNames, incomingNames) {
|
||||
const merged = []
|
||||
const seen = new Set()
|
||||
|
||||
for (const value of [...(existingNames || []), ...(incomingNames || [])]) {
|
||||
const normalized = String(value || '').trim()
|
||||
if (!normalized || seen.has(normalized)) continue
|
||||
seen.add(normalized)
|
||||
merged.push(normalized)
|
||||
if (merged.length >= MAX_ATTACHMENTS) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
export function mergeUploadOcrDocuments(existingDocuments, incomingDocuments) {
|
||||
const merged = []
|
||||
const seen = new Set()
|
||||
|
||||
for (const item of [...(existingDocuments || []), ...(incomingDocuments || [])]) {
|
||||
const filename = String(item?.filename || '').trim()
|
||||
if (!filename || seen.has(filename)) continue
|
||||
seen.add(filename)
|
||||
merged.push(item)
|
||||
if (merged.length >= MAX_OCR_DOCUMENTS) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
export function inferPreviewKind(file) {
|
||||
const mediaType = String(file?.type || '').toLowerCase()
|
||||
const filename = String(file?.name || '').toLowerCase()
|
||||
if (mediaType.startsWith('image/') || /\.(png|jpg|jpeg|webp|bmp)$/i.test(filename)) {
|
||||
return 'image'
|
||||
}
|
||||
if (mediaType.includes('pdf') || /\.pdf$/i.test(filename)) {
|
||||
return 'pdf'
|
||||
}
|
||||
return 'file'
|
||||
}
|
||||
|
||||
export function buildFilePreviews(files, previewRegistry) {
|
||||
return files.map((file) => {
|
||||
const kind = inferPreviewKind(file)
|
||||
if (!['image', 'pdf'].includes(kind)) {
|
||||
return {
|
||||
filename: file.name,
|
||||
kind
|
||||
}
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(file)
|
||||
previewRegistry.push(url)
|
||||
return {
|
||||
filename: file.name,
|
||||
kind,
|
||||
url
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function resolveDocumentPreview(filePreviews, filename) {
|
||||
if (!Array.isArray(filePreviews)) return null
|
||||
const matches = filePreviews.filter((item) => item.filename === filename)
|
||||
if (!matches.length) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
matches.find((item) => item.kind === 'image' && item.url) ||
|
||||
matches.find((item) => item.url) ||
|
||||
matches[0]
|
||||
)
|
||||
}
|
||||
|
||||
export function buildFileIdentity(file) {
|
||||
return [file?.name, file?.size, file?.lastModified, file?.type].join('__')
|
||||
}
|
||||
|
||||
export function mergeFilesWithLimit(existingFiles, incomingFiles, limit = MAX_ATTACHMENTS) {
|
||||
const nextFiles = []
|
||||
const seen = new Set()
|
||||
|
||||
for (const file of Array.isArray(existingFiles) ? existingFiles : []) {
|
||||
const key = buildFileIdentity(file)
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
nextFiles.push(file)
|
||||
}
|
||||
|
||||
let duplicateCount = 0
|
||||
let overflowCount = 0
|
||||
|
||||
for (const file of Array.isArray(incomingFiles) ? incomingFiles : []) {
|
||||
const key = buildFileIdentity(file)
|
||||
if (seen.has(key)) {
|
||||
duplicateCount += 1
|
||||
continue
|
||||
}
|
||||
if (nextFiles.length >= limit) {
|
||||
overflowCount += 1
|
||||
continue
|
||||
}
|
||||
seen.add(key)
|
||||
nextFiles.push(file)
|
||||
}
|
||||
|
||||
return {
|
||||
files: nextFiles,
|
||||
duplicateCount,
|
||||
overflowCount
|
||||
}
|
||||
}
|
||||
|
||||
export function mergeFilePreviews(existingPreviews, incomingPreviews) {
|
||||
const result = []
|
||||
const seen = new Set()
|
||||
|
||||
for (const preview of [...(existingPreviews || []), ...(incomingPreviews || [])]) {
|
||||
const key = [preview?.filename, preview?.kind].join('__')
|
||||
if (!preview?.filename || seen.has(key)) continue
|
||||
seen.add(key)
|
||||
result.push(preview)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function buildOcrFilePreviews(payload) {
|
||||
const documents = Array.isArray(payload?.documents) ? payload.documents : []
|
||||
return documents
|
||||
.map((item) => ({
|
||||
filename: String(item?.filename || '').trim(),
|
||||
kind: String(item?.preview_kind || '').trim(),
|
||||
url: String(item?.preview_url || item?.preview_data_url || '').trim()
|
||||
}))
|
||||
.filter((item) => item.filename && item.kind === 'image' && item.url)
|
||||
}
|
||||
|
||||
export function buildReviewFilePreviewsFromReviewPayload(reviewPayload) {
|
||||
const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : []
|
||||
return documents
|
||||
.map((item) => ({
|
||||
filename: String(item?.filename || '').trim(),
|
||||
kind: String(item?.preview_kind || '').trim(),
|
||||
url: String(item?.preview_url || item?.preview_data_url || '').trim()
|
||||
}))
|
||||
.filter((item) => item.filename && item.kind === 'image' && item.url)
|
||||
}
|
||||
|
||||
export function buildReviewFilePreviewsFromMessages(messages) {
|
||||
const previews = []
|
||||
for (const message of Array.isArray(messages) ? messages : []) {
|
||||
previews.push(...buildReviewFilePreviewsFromReviewPayload(message?.reviewPayload))
|
||||
}
|
||||
return mergeFilePreviews([], previews)
|
||||
}
|
||||
|
||||
export function resolveAttachmentPreviewKind(metadata) {
|
||||
const explicitKind = String(metadata?.preview_kind || '').trim()
|
||||
if (explicitKind) {
|
||||
return explicitKind
|
||||
}
|
||||
|
||||
const mediaType = String(metadata?.media_type || '').trim().toLowerCase()
|
||||
if (mediaType.startsWith('image/')) {
|
||||
return 'image'
|
||||
}
|
||||
if (mediaType === 'application/pdf') {
|
||||
return 'pdf'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export function extractReviewAttachmentNames(reviewPayload) {
|
||||
const documentNames = Array.isArray(reviewPayload?.document_cards)
|
||||
? reviewPayload.document_cards.map((item) => String(item?.filename || '').trim()).filter(Boolean)
|
||||
: []
|
||||
if (documentNames.length) {
|
||||
return documentNames
|
||||
}
|
||||
|
||||
const slotMap = buildReviewSlotMap(reviewPayload)
|
||||
const attachmentValue = String(slotMap.attachments?.value || '').trim()
|
||||
if (!attachmentValue) {
|
||||
return []
|
||||
}
|
||||
|
||||
return attachmentValue.split(/[、,,]/).map((item) => item.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
export function buildErrorInsight(error, fileNames = []) {
|
||||
return {
|
||||
intent: 'agent',
|
||||
metricLabel: '运行状态',
|
||||
metricValue: '失败',
|
||||
title: '智能体调用失败',
|
||||
summary: error?.message || '无法连接后端 Orchestrator。',
|
||||
agent: {
|
||||
runId: '未生成',
|
||||
selectedAgent: 'orchestrator',
|
||||
scenario: '未知',
|
||||
intent: '未知',
|
||||
permissionLevel: 'unknown',
|
||||
routeReason: 'request_failed',
|
||||
requiresConfirmation: false,
|
||||
degraded: false,
|
||||
fileNames,
|
||||
citations: [],
|
||||
suggestedActions: [],
|
||||
queryPayload: null,
|
||||
draftPayload: null,
|
||||
reviewPayload: null,
|
||||
riskFlags: [],
|
||||
toolCount: 0,
|
||||
failedToolCount: 0,
|
||||
selectedCapabilityCodes: [],
|
||||
filePreviews: [],
|
||||
statusLabel: '失败',
|
||||
statusTone: 'note'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function buildAgentInsight(payload, fileNames = [], filePreviews = []) {
|
||||
const trace = payload?.trace_summary || {}
|
||||
const result = payload?.result || {}
|
||||
const statusLabel = resolveStatusLabel(payload?.status)
|
||||
|
||||
return {
|
||||
intent: 'agent',
|
||||
metricLabel: '运行状态',
|
||||
metricValue: statusLabel,
|
||||
title:
|
||||
result?.draft_payload?.title ||
|
||||
`${SCENARIO_LABELS[trace?.scenario] || '通用'}${INTENT_LABELS[trace?.intent] || '处理'}结果`,
|
||||
summary: result?.answer || result?.message || '智能体已完成处理。',
|
||||
agent: {
|
||||
runId: payload?.run_id || '未生成',
|
||||
selectedAgent: payload?.selected_agent || 'orchestrator',
|
||||
scenario: SCENARIO_LABELS[trace?.scenario] || trace?.scenario || '未知',
|
||||
intent: INTENT_LABELS[trace?.intent] || trace?.intent || '未知',
|
||||
permissionLevel: payload?.permission_level || 'unknown',
|
||||
routeReason: payload?.route_reason || 'unknown',
|
||||
requiresConfirmation: Boolean(payload?.requires_confirmation),
|
||||
degraded: Boolean(trace?.degraded),
|
||||
fileNames,
|
||||
citations: Array.isArray(result?.citations) ? result.citations : [],
|
||||
suggestedActions: Array.isArray(result?.suggested_actions) ? result.suggested_actions : [],
|
||||
queryPayload: normalizeExpenseQueryPayload(result?.query_payload),
|
||||
draftPayload: result?.draft_payload || null,
|
||||
reviewPayload: result?.review_payload || null,
|
||||
riskFlags: Array.isArray(result?.risk_flags) ? result.risk_flags : [],
|
||||
toolCount: Number(trace?.tool_count || 0),
|
||||
failedToolCount: Number(trace?.failed_tool_count || 0),
|
||||
selectedCapabilityCodes: Array.isArray(trace?.selected_capability_codes)
|
||||
? trace.selected_capability_codes
|
||||
: [],
|
||||
filePreviews,
|
||||
statusLabel,
|
||||
statusTone: resolveStatusTone(payload?.status)
|
||||
}
|
||||
}
|
||||
}
|
||||
767
web/src/views/scripts/travelReimbursementConversationModel.js
Normal file
767
web/src/views/scripts/travelReimbursementConversationModel.js
Normal file
@@ -0,0 +1,767 @@
|
||||
import { buildSuggestedActionKey } from '../../utils/suggestedActionKey.js'
|
||||
import { normalizeExpenseQueryPayload } from './travelReimbursementExpenseQueryModel.js'
|
||||
import { buildAgentInsight, buildReviewFilePreviewsFromReviewPayload } from './travelReimbursementAttachmentModel.js'
|
||||
import { resolveExpenseTypeLabel } from './travelReimbursementReviewModel.js'
|
||||
|
||||
export const SESSION_TYPE_EXPENSE = 'expense'
|
||||
export const SESSION_TYPE_KNOWLEDGE = 'knowledge'
|
||||
|
||||
export const aiAvatar = '/assets/header.png'
|
||||
export const userAvatar = '/assets/person.png'
|
||||
|
||||
export const SOURCE_LABELS = {
|
||||
workbench: '来自个人工作台',
|
||||
topbar: '来自发起报销',
|
||||
detail: '来自智能录入',
|
||||
upload: '来自附件上传',
|
||||
requests: '来自报销列表'
|
||||
}
|
||||
|
||||
export const SCENARIO_LABELS = {
|
||||
expense: '报销',
|
||||
accounts_receivable: '应收',
|
||||
accounts_payable: '应付',
|
||||
knowledge: '知识',
|
||||
unknown: '通用'
|
||||
}
|
||||
|
||||
export const INTENT_LABELS = {
|
||||
query: '查询',
|
||||
explain: '解释',
|
||||
compare: '对比',
|
||||
risk_check: '风险检查',
|
||||
draft: '信息核对',
|
||||
operate: '动作请求'
|
||||
}
|
||||
|
||||
export const FLOW_STEP_FALLBACKS = {
|
||||
intent: {
|
||||
title: '意图识别',
|
||||
tool: 'IntentRecognizer',
|
||||
runningText: '正在识别业务意图...',
|
||||
completedText: '意图识别完成'
|
||||
},
|
||||
extraction: {
|
||||
title: '信息提取',
|
||||
tool: 'SemanticExtractor',
|
||||
runningText: '正在提取时间、金额、费用类型和待补项...',
|
||||
completedText: '信息提取完成'
|
||||
},
|
||||
ocr: {
|
||||
title: '票据/OCR识别',
|
||||
tool: 'OCRService',
|
||||
runningText: '正在识别票据附件...',
|
||||
completedText: '票据识别完成'
|
||||
},
|
||||
'expense-review-preview': {
|
||||
title: '报销信息核对',
|
||||
tool: 'user_agent.expense_review_preview',
|
||||
runningText: '正在整理识别结果和右侧核对信息...',
|
||||
completedText: '核对信息已整理'
|
||||
},
|
||||
'expense-claim-draft': {
|
||||
title: '保存报销草稿',
|
||||
tool: 'database.expense_claims.save_or_submit',
|
||||
runningText: '正在把已确认信息保存为草稿...',
|
||||
completedText: '草稿已保存'
|
||||
},
|
||||
'expense-scene-selection': {
|
||||
title: '报销场景确认',
|
||||
tool: 'UserConfirmation',
|
||||
runningText: '等待用户选择报销场景...',
|
||||
completedText: '已进入场景选择,等待用户确认'
|
||||
},
|
||||
'expense-intent-confirmation': {
|
||||
title: '报销意图确认',
|
||||
tool: 'UserConfirmation',
|
||||
runningText: '等待用户确认是否发起报销...',
|
||||
completedText: '用户已确认报销意图'
|
||||
}
|
||||
}
|
||||
export const ASSISTANT_DISPLAY_NAME = '财务助手'
|
||||
|
||||
export const EXPENSE_WELCOME_QUICK_ACTIONS = [
|
||||
{
|
||||
label: '发起差旅报销',
|
||||
prompt: '我要报销一笔出差费用,请帮我说明需要准备的材料,并引导我上传票据。',
|
||||
icon: 'mdi mdi-bag-suitcase-outline'
|
||||
},
|
||||
{
|
||||
label: '招待费报销',
|
||||
prompt: '我要报销客户招待餐费,请告诉我需要补充的客户、参与人员和票据要求。',
|
||||
icon: 'mdi mdi-food-fork-drink'
|
||||
},
|
||||
{
|
||||
label: '交通费报销',
|
||||
prompt: '我要报销交通出行费用,请帮我识别场景并列出待补充信息。',
|
||||
icon: 'mdi mdi-car-outline'
|
||||
},
|
||||
{
|
||||
label: '上传票据识别',
|
||||
prompt: '我已准备好票据,请帮我识别并整理报销核对信息。',
|
||||
icon: 'mdi mdi-file-upload-outline'
|
||||
},
|
||||
{
|
||||
label: '查询近期报销',
|
||||
prompt: '帮我查询近10天的报销记录和金额汇总。',
|
||||
icon: 'mdi mdi-chart-timeline-variant'
|
||||
},
|
||||
{
|
||||
label: '解释报销风险',
|
||||
prompt: '请结合公司制度,说明酒店超标、发票抬头不一致等常见报销风险。',
|
||||
icon: 'mdi mdi-shield-alert-outline'
|
||||
}
|
||||
]
|
||||
|
||||
export const HOT_KNOWLEDGE_QUESTIONS = [
|
||||
'差旅住宿标准按什么规则执行?',
|
||||
'酒店超标后如何申请例外报销?',
|
||||
'招待费报销需要哪些凭证?',
|
||||
'发票抬头不一致还能报销吗?',
|
||||
'电子发票验真失败怎么处理?',
|
||||
'借款多久内需要冲销?',
|
||||
'预算不足还能先提交报销吗?',
|
||||
'会议费和招待费如何区分?',
|
||||
'跨部门项目费用应该怎么归集?',
|
||||
'员工退票手续费是否可以报销?'
|
||||
]
|
||||
export const FLOW_MISSING_SLOT_LABELS = {
|
||||
expense_type: '报销类型',
|
||||
customer_name: '客户名称',
|
||||
time_range: '发生时间',
|
||||
location: '地点',
|
||||
merchant_name: '酒店/商户',
|
||||
amount: '金额',
|
||||
reason: '事由说明',
|
||||
participants: '参与人员',
|
||||
attachments: '票据附件'
|
||||
}
|
||||
|
||||
let messageSeed = 0
|
||||
|
||||
export function nowTime() {
|
||||
return new Date().toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
})
|
||||
}
|
||||
|
||||
export function createMessage(role, text, attachments = [], extras = {}) {
|
||||
messageSeed += 1
|
||||
return {
|
||||
id: `msg-${messageSeed}`,
|
||||
role,
|
||||
text,
|
||||
attachments,
|
||||
time: nowTime(),
|
||||
meta: [],
|
||||
citations: [],
|
||||
suggestedActions: [],
|
||||
suggestedActionsLocked: false,
|
||||
selectedSuggestedActionKey: '',
|
||||
selectedSuggestedActionLabel: '',
|
||||
querySelectionLocked: false,
|
||||
selectedQueryRecordId: '',
|
||||
queryPayload: null,
|
||||
draftPayload: null,
|
||||
reviewPayload: null,
|
||||
riskFlags: [],
|
||||
...extras
|
||||
}
|
||||
}
|
||||
|
||||
export function buildExpenseIntentConfirmationMessage(rawText) {
|
||||
const text = String(rawText || '').trim()
|
||||
return [
|
||||
text
|
||||
? `我看到了「${text}」这类业务事项描述。`
|
||||
: '我看到了这类业务事项描述。',
|
||||
'但现在还不能确定你是要发起报销,还是要处理其他事项,所以我先暂停后续识别。',
|
||||
'如果你是想报销,请点击下面的“我要报销”,我再继续引导你选择具体报销场景。'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function buildExpenseSceneSelectionMessage(rawText) {
|
||||
const text = String(rawText || '').trim()
|
||||
const hasBusinessTime = /业务发生时间|发生时间|20\d{2}[-年\/.]\d{1,2}/.test(text)
|
||||
const prefix = hasBusinessTime
|
||||
? '我已看到你提供了业务发生时间和报销意图。'
|
||||
: '我已识别到这是报销申请。'
|
||||
|
||||
return [
|
||||
`${prefix}但现在还不能确定具体报销场景,所以我先暂停信息抽取。`,
|
||||
'请先选择本次要发起的报销场景,选择后我再按对应规则继续识别并整理核对信息。'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function formatMessageTime(value) {
|
||||
if (!value) {
|
||||
return nowTime()
|
||||
}
|
||||
|
||||
const parsed = new Date(value)
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return nowTime()
|
||||
}
|
||||
|
||||
return parsed.toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
})
|
||||
}
|
||||
|
||||
export function formatSemanticEntityValue(entity) {
|
||||
const normalizedValue = String(entity?.normalized_value || '').trim()
|
||||
const rawValue = String(entity?.value || '').trim()
|
||||
const entityType = String(entity?.type || '').trim()
|
||||
|
||||
if (entityType === 'amount') {
|
||||
const numericValue = Number(normalizedValue || rawValue)
|
||||
if (Number.isFinite(numericValue) && numericValue > 0) {
|
||||
return Number.isInteger(numericValue) ? `${numericValue}元` : `${numericValue.toFixed(2)}元`
|
||||
}
|
||||
}
|
||||
|
||||
return rawValue || normalizedValue
|
||||
}
|
||||
|
||||
export function summarizeSemanticParseDetail(semanticParse, ontologyJson = {}) {
|
||||
if (!semanticParse || typeof semanticParse !== 'object') {
|
||||
return FLOW_STEP_FALLBACKS.extraction.completedText
|
||||
}
|
||||
|
||||
const entities = Array.isArray(semanticParse.entities_json) ? semanticParse.entities_json : []
|
||||
const entityMap = new Map()
|
||||
for (const item of entities) {
|
||||
const entityType = String(item?.type || '').trim()
|
||||
if (!entityType || entityMap.has(entityType)) continue
|
||||
entityMap.set(entityType, item)
|
||||
}
|
||||
|
||||
const extractedParts = []
|
||||
const timeRange = semanticParse.time_range_json && typeof semanticParse.time_range_json === 'object'
|
||||
? semanticParse.time_range_json
|
||||
: {}
|
||||
const startDate = String(timeRange.start_date || '').trim()
|
||||
const endDate = String(timeRange.end_date || '').trim()
|
||||
if (startDate) {
|
||||
extractedParts.push(`时间 ${startDate}${endDate && endDate !== startDate ? ` 至 ${endDate}` : ''}`)
|
||||
}
|
||||
|
||||
const amountEntity = entityMap.get('amount')
|
||||
if (amountEntity) {
|
||||
const amountValue = formatSemanticEntityValue(amountEntity)
|
||||
if (amountValue) {
|
||||
extractedParts.push(`金额 ${amountValue}`)
|
||||
}
|
||||
}
|
||||
|
||||
const expenseTypeEntity = entityMap.get('expense_type')
|
||||
if (expenseTypeEntity) {
|
||||
const expenseTypeLabel = resolveExpenseTypeLabel(
|
||||
String(expenseTypeEntity?.normalized_value || '').trim(),
|
||||
String(expenseTypeEntity?.value || '').trim()
|
||||
)
|
||||
if (expenseTypeLabel) {
|
||||
extractedParts.push(`费用类型 ${expenseTypeLabel}`)
|
||||
}
|
||||
}
|
||||
|
||||
const customerEntity = entityMap.get('customer')
|
||||
if (customerEntity) {
|
||||
const customerValue = formatSemanticEntityValue(customerEntity)
|
||||
if (customerValue) {
|
||||
extractedParts.push(`客户 ${customerValue}`)
|
||||
}
|
||||
}
|
||||
|
||||
const missingSlots = Array.isArray(ontologyJson?.missing_slots) ? ontologyJson.missing_slots : []
|
||||
const missingLabels = missingSlots
|
||||
.map((item) => FLOW_MISSING_SLOT_LABELS[String(item || '').trim()] || String(item || '').trim())
|
||||
.filter(Boolean)
|
||||
|
||||
if (extractedParts.length && missingLabels.length) {
|
||||
return `已提取${extractedParts.join('、')};待补充 ${missingLabels.join('、')}`
|
||||
}
|
||||
if (extractedParts.length) {
|
||||
return `已提取${extractedParts.join('、')}`
|
||||
}
|
||||
if (missingLabels.length) {
|
||||
return `已完成信息提取;待补充 ${missingLabels.join('、')}`
|
||||
}
|
||||
return FLOW_STEP_FALLBACKS.extraction.completedText
|
||||
}
|
||||
|
||||
export function sanitizeRequest(request) {
|
||||
if (!request || typeof request !== 'object') return null
|
||||
|
||||
const normalized = {
|
||||
id: String(request.id || '').trim(),
|
||||
typeLabel: String(request.typeLabel || request.category || '').trim(),
|
||||
reason: String(request.reason || request.title || '').trim(),
|
||||
entity: String(request.entity || '').trim(),
|
||||
city: String(request.city || request.location || '').trim(),
|
||||
period: String(request.period || '').trim(),
|
||||
applyTime: String(request.applyTime || request.occurredAt || '').trim(),
|
||||
amount: String(request.amount || '').trim(),
|
||||
node: String(request.node || '').trim(),
|
||||
approval: String(request.approval || '').trim(),
|
||||
travel: String(request.travel || '').trim()
|
||||
}
|
||||
|
||||
return Object.values(normalized).some(Boolean) ? normalized : null
|
||||
}
|
||||
|
||||
export function resolveStatusLabel(status) {
|
||||
if (status === 'succeeded') return '已完成'
|
||||
if (status === 'blocked') return '已阻断'
|
||||
return '失败'
|
||||
}
|
||||
|
||||
export function resolveStatusTone(status) {
|
||||
if (status === 'succeeded') return 'success'
|
||||
if (status === 'blocked') return 'warning'
|
||||
return 'note'
|
||||
}
|
||||
|
||||
export function buildMessageMeta(payload, fileNames = []) {
|
||||
const items = []
|
||||
|
||||
if (payload?.selected_agent) {
|
||||
items.push(`Agent: ${payload.selected_agent}`)
|
||||
}
|
||||
|
||||
if (payload?.permission_level) {
|
||||
items.push(`权限: ${payload.permission_level}`)
|
||||
}
|
||||
|
||||
if (payload?.trace_summary?.tool_count) {
|
||||
items.push(`工具: ${payload.trace_summary.tool_count}`)
|
||||
}
|
||||
|
||||
if (payload?.trace_summary?.degraded) {
|
||||
items.push('已降级')
|
||||
}
|
||||
|
||||
if (payload?.requires_confirmation) {
|
||||
items.push('待确认')
|
||||
}
|
||||
|
||||
if (payload?.run_id) {
|
||||
items.push(`Run: ${payload.run_id}`)
|
||||
}
|
||||
|
||||
if (fileNames.length) {
|
||||
items.push(`附件: ${fileNames.length}`)
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
export function buildStoredMessageMeta(messageJson, attachmentNames = []) {
|
||||
const payload = messageJson?.orchestrator_payload
|
||||
if (payload) {
|
||||
return buildMessageMeta(payload, attachmentNames)
|
||||
}
|
||||
|
||||
const items = []
|
||||
if (messageJson?.status) {
|
||||
items.push(`状态: ${messageJson.status}`)
|
||||
}
|
||||
if (attachmentNames.length) {
|
||||
items.push(`附件: ${attachmentNames.length}`)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
export function buildWelcomeUserContext(user = {}) {
|
||||
const username = String(user.username || '').trim()
|
||||
const name = String(user.name || username || '同事').trim()
|
||||
const grade = String(user.grade || '').trim()
|
||||
const position = String(user.position || '').trim()
|
||||
const role = String(user.role || '').trim()
|
||||
const roleCodes = Array.isArray(user.roleCodes) ? user.roleCodes : []
|
||||
const isAdmin =
|
||||
Boolean(user.isAdmin)
|
||||
|| username.toLowerCase() === 'admin'
|
||||
|| roleCodes.some((item) => /admin|manager/i.test(String(item || '')))
|
||||
|| /管理员|系统管理/.test(position)
|
||||
|| /管理员|系统管理/.test(role)
|
||||
|
||||
const now = new Date()
|
||||
const dateLine = now.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
weekday: 'long'
|
||||
})
|
||||
|
||||
let honorific = name
|
||||
if (isAdmin) {
|
||||
honorific = name && !/^admin$/i.test(name) ? `${name} 管理员` : '管理员'
|
||||
} else {
|
||||
const prefix = [grade, position].filter(Boolean).join(' ')
|
||||
honorific = prefix ? `${prefix} ${name}`.trim() : name
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
username,
|
||||
grade,
|
||||
position,
|
||||
role,
|
||||
isAdmin,
|
||||
honorific,
|
||||
dateLine
|
||||
}
|
||||
}
|
||||
|
||||
export function buildWelcomeQuickActions(sessionType, user, entrySource, linkedRequest) {
|
||||
if (sessionType === SESSION_TYPE_KNOWLEDGE) {
|
||||
return HOT_KNOWLEDGE_QUESTIONS.slice(0, 6).map((question) => ({
|
||||
label: question.length > 20 ? `${question.slice(0, 20)}…` : question,
|
||||
prompt: question,
|
||||
icon: 'mdi mdi-comment-question-outline'
|
||||
}))
|
||||
}
|
||||
|
||||
if (entrySource === 'detail' && linkedRequest?.id) {
|
||||
return [
|
||||
{
|
||||
label: '补充当前单据票据',
|
||||
prompt: `请结合单据 ${linkedRequest.id},帮我继续补充票据并更新识别结果。`,
|
||||
icon: 'mdi mdi-file-plus-outline'
|
||||
},
|
||||
{
|
||||
label: '解释本单风险',
|
||||
prompt: `请解释单据 ${linkedRequest.id} 当前存在的报销风险与处理建议。`,
|
||||
icon: 'mdi mdi-shield-alert-outline'
|
||||
},
|
||||
...EXPENSE_WELCOME_QUICK_ACTIONS.slice(0, 4)
|
||||
]
|
||||
}
|
||||
|
||||
return EXPENSE_WELCOME_QUICK_ACTIONS
|
||||
}
|
||||
|
||||
export function buildWelcomeMessage(entrySource, linkedRequest, sessionType = SESSION_TYPE_EXPENSE, user = null) {
|
||||
const ctx = buildWelcomeUserContext(user || {})
|
||||
const greeting = ctx.isAdmin ? `${ctx.honorific},您好` : `您好,${ctx.honorific}`
|
||||
|
||||
if (sessionType === SESSION_TYPE_KNOWLEDGE) {
|
||||
return [
|
||||
`${greeting}!今日是 **${ctx.dateLine}**。`,
|
||||
'',
|
||||
'欢迎进入 **个人财务中心 · 知识问答**。我是您的财务助手,可以帮您查制度、报销标准、票据要求和常见财务问题。',
|
||||
'',
|
||||
'您可以直接输入问题,或点击下方「猜你想问」快速开始。'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
if (entrySource === 'detail' && linkedRequest?.id) {
|
||||
return [
|
||||
`${greeting}!今日是 **${ctx.dateLine}**。`,
|
||||
'',
|
||||
`我已为您打开关联单据 **${linkedRequest.id}**。您可以继续补充票据、核对识别结果,或让我解释待补项与风险。`,
|
||||
'',
|
||||
'如需新建其他报销,也可以直接告诉我费用场景,或上传发票、行程单开始识别。'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
return [
|
||||
`${greeting}!今日是 **${ctx.dateLine}**。`,
|
||||
'',
|
||||
'**欢迎来到个人财务中心。** 我是您的财务助手,可以陪您完成票据识别、报销信息核对、待补项提醒和风险说明。',
|
||||
'',
|
||||
'您可以描述一笔费用、上传票据,或点击下方快捷操作直接开始。'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function buildWelcomeInsight(entrySource, linkedRequest, sessionType = SESSION_TYPE_EXPENSE, user = null) {
|
||||
const ctx = buildWelcomeUserContext(user || {})
|
||||
|
||||
if (sessionType === SESSION_TYPE_KNOWLEDGE) {
|
||||
return {
|
||||
intent: 'welcome',
|
||||
metricLabel: '今日',
|
||||
metricValue: ctx.dateLine.split(' ')[0] || '—',
|
||||
title: '财务知识问答',
|
||||
summary: `${ctx.honorific},右侧整理了热门制度问题,点选即可追问;左侧也可直接输入您关心的问题。`,
|
||||
agent: null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
intent: 'welcome',
|
||||
metricLabel: '助手状态',
|
||||
metricValue: '待您吩咐',
|
||||
title: entrySource === 'detail' && linkedRequest?.id ? `已关联 ${linkedRequest.id}` : '个人财务中心',
|
||||
summary:
|
||||
entrySource === 'detail' && linkedRequest?.id
|
||||
? `${ctx.honorific},发送消息或上传附件后,我会结合当前单据继续识别并提示待补项。`
|
||||
: `${ctx.honorific},描述费用场景或上传票据后,我会在右侧展示识别结果,并在对话中提示待补信息与风险。`,
|
||||
agent: null
|
||||
}
|
||||
}
|
||||
|
||||
export function createWelcomeAssistantMessage(entrySource, linkedRequest, sessionType = SESSION_TYPE_EXPENSE, user = null) {
|
||||
return createMessage('assistant', buildWelcomeMessage(entrySource, linkedRequest, sessionType, user), [], {
|
||||
assistantName: ASSISTANT_DISPLAY_NAME,
|
||||
isWelcome: true,
|
||||
welcomeQuickActions: buildWelcomeQuickActions(sessionType, user, entrySource, linkedRequest)
|
||||
})
|
||||
}
|
||||
|
||||
export function resolveInitialSessionType(conversation) {
|
||||
const stateJson = conversation?.state_json || conversation?.stateJson || {}
|
||||
const sessionType = String(stateJson?.session_type || '').trim()
|
||||
return sessionType || SESSION_TYPE_EXPENSE
|
||||
}
|
||||
|
||||
export function buildInitialInsightFromConversation(conversation) {
|
||||
const rawMessages = Array.isArray(conversation?.messages) ? conversation.messages : []
|
||||
for (let index = rawMessages.length - 1; index >= 0; index -= 1) {
|
||||
const item = rawMessages[index]
|
||||
const messageJson = item?.message_json || item?.messageJson || {}
|
||||
const orchestratorPayload = messageJson?.orchestrator_payload || null
|
||||
if (!orchestratorPayload) continue
|
||||
const attachmentNames = Array.isArray(messageJson?.attachment_names)
|
||||
? messageJson.attachment_names.filter(Boolean)
|
||||
: []
|
||||
return buildAgentInsight(
|
||||
orchestratorPayload,
|
||||
attachmentNames,
|
||||
buildReviewFilePreviewsFromReviewPayload(orchestratorPayload?.result?.review_payload)
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function resolveInitialConversationId(conversation) {
|
||||
return String(conversation?.conversation_id || conversation?.conversationId || '').trim()
|
||||
}
|
||||
|
||||
export function resolveInitialDraftClaimId(conversation) {
|
||||
return String(conversation?.draft_claim_id || conversation?.draftClaimId || '').trim()
|
||||
}
|
||||
|
||||
export function resolveKnowledgeRankLabel(index) {
|
||||
return String(index + 1)
|
||||
}
|
||||
|
||||
export function resolveKnowledgeRankTone(index) {
|
||||
if (index === 0) return 'gold'
|
||||
if (index === 1) return 'silver'
|
||||
if (index === 2) return 'bronze'
|
||||
return 'default'
|
||||
}
|
||||
|
||||
export function parseConversationMessageSequence(message) {
|
||||
const messageJson = message?.message_json || message?.messageJson || {}
|
||||
const sequence = Number.parseInt(messageJson?.sequence, 10)
|
||||
return Number.isFinite(sequence) && sequence > 0 ? sequence : null
|
||||
}
|
||||
|
||||
export function parseConversationMessageTime(message) {
|
||||
const rawValue = message?.created_at || message?.createdAt || ''
|
||||
const timestamp = new Date(rawValue).getTime()
|
||||
return Number.isFinite(timestamp) ? timestamp : Number.MAX_SAFE_INTEGER
|
||||
}
|
||||
|
||||
export function resolveConversationMessageRolePriority(message) {
|
||||
return String(message?.role || '').trim() === 'user' ? 0 : 1
|
||||
}
|
||||
|
||||
export function sortConversationMessages(messages) {
|
||||
return [...(Array.isArray(messages) ? messages : [])].sort((left, right) => {
|
||||
const leftSequence = parseConversationMessageSequence(left)
|
||||
const rightSequence = parseConversationMessageSequence(right)
|
||||
if (leftSequence !== null && rightSequence !== null && leftSequence !== rightSequence) {
|
||||
return leftSequence - rightSequence
|
||||
}
|
||||
|
||||
const timeDiff = parseConversationMessageTime(left) - parseConversationMessageTime(right)
|
||||
if (timeDiff !== 0) {
|
||||
return timeDiff
|
||||
}
|
||||
|
||||
const leftRunId = String(left?.run_id || left?.runId || '').trim()
|
||||
const rightRunId = String(right?.run_id || right?.runId || '').trim()
|
||||
if (leftRunId && rightRunId && leftRunId === rightRunId) {
|
||||
const roleDiff = resolveConversationMessageRolePriority(left) - resolveConversationMessageRolePriority(right)
|
||||
if (roleDiff !== 0) {
|
||||
return roleDiff
|
||||
}
|
||||
}
|
||||
|
||||
return String(left?.id || '').localeCompare(String(right?.id || ''))
|
||||
})
|
||||
}
|
||||
|
||||
export function normalizeInitialConversationMessages(conversation) {
|
||||
const rawMessages = sortConversationMessages(conversation?.messages)
|
||||
|
||||
const restoredMessages = rawMessages.map((item) => {
|
||||
const messageJson = item?.message_json || item?.messageJson || {}
|
||||
const attachmentNames = Array.isArray(messageJson?.attachment_names)
|
||||
? messageJson.attachment_names.filter(Boolean)
|
||||
: []
|
||||
const orchestratorPayload = messageJson?.orchestrator_payload || null
|
||||
const result = orchestratorPayload?.result || {}
|
||||
|
||||
return createMessage(item.role, item.content, attachmentNames, {
|
||||
id: `restored-${item.id || ++messageSeed}`,
|
||||
time: formatMessageTime(item.created_at || item.createdAt),
|
||||
meta: item.role === 'assistant' ? buildStoredMessageMeta(messageJson, attachmentNames) : [],
|
||||
citations: item.role === 'assistant' && Array.isArray(result?.citations) ? result.citations : [],
|
||||
suggestedActions:
|
||||
item.role === 'assistant' && Array.isArray(result?.suggested_actions)
|
||||
? result.suggested_actions
|
||||
: [],
|
||||
queryPayload: item.role === 'assistant' ? normalizeExpenseQueryPayload(result?.query_payload) : null,
|
||||
draftPayload: item.role === 'assistant' ? result?.draft_payload || messageJson?.draft_payload || null : null,
|
||||
reviewPayload: item.role === 'assistant' ? result?.review_payload || null : null,
|
||||
riskFlags: item.role === 'assistant' && Array.isArray(result?.risk_flags) ? result.risk_flags : []
|
||||
})
|
||||
})
|
||||
return markResolvedSuggestedActionMessages(restoredMessages)
|
||||
}
|
||||
|
||||
export function normalizeSnapshotMessage(message) {
|
||||
const extras = message && typeof message === 'object' ? { ...message } : {}
|
||||
const role = String(extras.role || 'assistant').trim() || 'assistant'
|
||||
const text = String(extras.text || '')
|
||||
const attachments = Array.isArray(extras.attachments) ? extras.attachments.filter(Boolean) : []
|
||||
delete extras.role
|
||||
delete extras.text
|
||||
delete extras.attachments
|
||||
return createMessage(role, text, attachments, extras)
|
||||
}
|
||||
|
||||
export function normalizeSnapshotMessages(messages) {
|
||||
return Array.isArray(messages)
|
||||
? markResolvedSuggestedActionMessages(messages.map(normalizeSnapshotMessage))
|
||||
: []
|
||||
}
|
||||
|
||||
export function serializeSessionMessages(messages) {
|
||||
return (Array.isArray(messages) ? messages : []).map((message) => ({
|
||||
id: message.id,
|
||||
role: message.role,
|
||||
text: message.text,
|
||||
attachments: Array.isArray(message.attachments) ? message.attachments.filter(Boolean) : [],
|
||||
time: message.time,
|
||||
meta: Array.isArray(message.meta) ? message.meta.filter(Boolean) : [],
|
||||
metaTone: message.metaTone || '',
|
||||
citations: Array.isArray(message.citations) ? message.citations : [],
|
||||
suggestedActions: Array.isArray(message.suggestedActions) ? message.suggestedActions : [],
|
||||
suggestedActionsLocked: Boolean(message.suggestedActionsLocked),
|
||||
selectedSuggestedActionKey: String(message.selectedSuggestedActionKey || ''),
|
||||
selectedSuggestedActionLabel: String(message.selectedSuggestedActionLabel || ''),
|
||||
querySelectionLocked: Boolean(message.querySelectionLocked),
|
||||
selectedQueryRecordId: String(message.selectedQueryRecordId || ''),
|
||||
queryPayload: message.queryPayload || null,
|
||||
draftPayload: message.draftPayload || null,
|
||||
reviewPayload: message.reviewPayload || null,
|
||||
riskFlags: Array.isArray(message.riskFlags) ? message.riskFlags : [],
|
||||
assistantName: message.assistantName || '',
|
||||
isWelcome: Boolean(message.isWelcome),
|
||||
welcomeQuickActions: Array.isArray(message.welcomeQuickActions) ? message.welcomeQuickActions : []
|
||||
}))
|
||||
}
|
||||
|
||||
export function hasMeaningfulSessionMessages(messages) {
|
||||
return (Array.isArray(messages) ? messages : []).some((message) => {
|
||||
if (!message || message.isWelcome) {
|
||||
return false
|
||||
}
|
||||
if (message.role === 'user') {
|
||||
return true
|
||||
}
|
||||
return Boolean(
|
||||
String(message.text || '').trim()
|
||||
|| (Array.isArray(message.suggestedActions) && message.suggestedActions.length)
|
||||
|| message.reviewPayload
|
||||
|| message.queryPayload
|
||||
|| message.draftPayload
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export function hasActiveSuggestedActionMessage(messages) {
|
||||
return (Array.isArray(messages) ? messages : []).some(
|
||||
(message) =>
|
||||
message?.role === 'assistant'
|
||||
&& Array.isArray(message.suggestedActions)
|
||||
&& message.suggestedActions.length > 0
|
||||
&& !message.suggestedActionsLocked
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveConversationUpdatedAt(conversation) {
|
||||
const timestamp = new Date(conversation?.updated_at || conversation?.updatedAt || 0).getTime()
|
||||
return Number.isFinite(timestamp) ? timestamp : 0
|
||||
}
|
||||
|
||||
export function shouldPreferPersistedSessionState(persistedState, snapshot, conversation) {
|
||||
if (!persistedState) {
|
||||
return false
|
||||
}
|
||||
if (!conversation) {
|
||||
return true
|
||||
}
|
||||
if (hasActiveSuggestedActionMessage(persistedState.messages)) {
|
||||
return true
|
||||
}
|
||||
const snapshotUpdatedAt = Number(snapshot?.updatedAt || 0)
|
||||
return snapshotUpdatedAt >= resolveConversationUpdatedAt(conversation)
|
||||
}
|
||||
|
||||
export function markResolvedSuggestedActionMessages(messages) {
|
||||
const items = Array.isArray(messages) ? messages : []
|
||||
const selectedLabels = new Set()
|
||||
|
||||
for (const message of items) {
|
||||
if (message?.role !== 'user') {
|
||||
continue
|
||||
}
|
||||
const text = String(message.text || '').trim()
|
||||
const selectedMatch = text.match(/^选择(.+)$/) || text.match(/用户选择报销场景[::]\s*([^\n\r]+)/)
|
||||
if (selectedMatch?.[1]) {
|
||||
selectedLabels.add(selectedMatch[1].trim())
|
||||
} else if (text === '我要报销') {
|
||||
selectedLabels.add(text)
|
||||
}
|
||||
}
|
||||
|
||||
if (!selectedLabels.size) {
|
||||
return items
|
||||
}
|
||||
|
||||
return items.map((message) => {
|
||||
if (
|
||||
message?.role !== 'assistant'
|
||||
|| message.suggestedActionsLocked
|
||||
|| !Array.isArray(message.suggestedActions)
|
||||
|| !message.suggestedActions.length
|
||||
) {
|
||||
return message
|
||||
}
|
||||
|
||||
const selectedAction = message.suggestedActions.find((action) =>
|
||||
selectedLabels.has(String(action?.label || action?.payload?.expense_type_label || '').trim())
|
||||
)
|
||||
if (!selectedAction) {
|
||||
return message
|
||||
}
|
||||
|
||||
return {
|
||||
...message,
|
||||
suggestedActionsLocked: true,
|
||||
selectedSuggestedActionKey: buildSuggestedActionKey(selectedAction),
|
||||
selectedSuggestedActionLabel: String(selectedAction.label || selectedAction?.payload?.expense_type_label || '').trim()
|
||||
}
|
||||
})
|
||||
}
|
||||
268
web/src/views/scripts/travelReimbursementExpenseQueryModel.js
Normal file
268
web/src/views/scripts/travelReimbursementExpenseQueryModel.js
Normal file
@@ -0,0 +1,268 @@
|
||||
import {
|
||||
EXPENSE_TYPE_LABELS,
|
||||
formatAmountDisplay
|
||||
} from './travelReimbursementReviewModel.js'
|
||||
|
||||
export const EXPENSE_QUERY_PAGE_SIZE = 5
|
||||
export const ASSOCIATABLE_CLAIM_STATUSES = new Set(['draft', 'supplement', 'returned'])
|
||||
const EXPENSE_STATUS_LABELS = {
|
||||
draft: '草稿',
|
||||
supplement: '待补充',
|
||||
returned: '已退回',
|
||||
submitted: '已提交',
|
||||
review: '审批中',
|
||||
approved: '已审核',
|
||||
paid: '已入账'
|
||||
}
|
||||
|
||||
export function normalizeExpenseQueryStatusGroup(item) {
|
||||
if (!item || typeof item !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const rawCount = Number(item.count || 0)
|
||||
return {
|
||||
key: String(item.key || 'other').trim() || 'other',
|
||||
label: String(item.label || '其他状态').trim() || '其他状态',
|
||||
count: Number.isFinite(rawCount) ? Math.max(0, rawCount) : 0
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeExpenseQueryRecord(item) {
|
||||
if (!item || typeof item !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const amount = Number(item.amount || 0)
|
||||
const amountValue = Number.isFinite(amount) ? amount : 0
|
||||
const expenseTypeLabel = String(item.expense_type_label || item.expense_type || '报销').trim() || '报销'
|
||||
const reason = String(item.reason || '').trim()
|
||||
const documentDate = String(item.document_date || '').trim()
|
||||
const occurredAt = String(item.occurred_at || '').trim()
|
||||
|
||||
return {
|
||||
claimId: String(item.claim_id || '').trim(),
|
||||
claimNo: String(item.claim_no || '').trim() || '未编号',
|
||||
employeeName: String(item.employee_name || '').trim(),
|
||||
expenseType: String(item.expense_type || '').trim(),
|
||||
expenseTypeLabel,
|
||||
amount: amountValue,
|
||||
amountDisplay: formatAmountDisplay(amountValue),
|
||||
status: String(item.status || '').trim(),
|
||||
statusLabel: String(item.status_label || '处理中').trim() || '处理中',
|
||||
statusGroup: String(item.status_group || 'other').trim() || 'other',
|
||||
statusGroupLabel: String(item.status_group_label || '其他状态').trim() || '其他状态',
|
||||
approvalStage: String(item.approval_stage || '').trim(),
|
||||
documentDate,
|
||||
occurredAt,
|
||||
reason,
|
||||
location: String(item.location || '').trim(),
|
||||
summary: reason || `${expenseTypeLabel}报销`,
|
||||
dateDisplay: documentDate || occurredAt || '待补充日期'
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveExpenseStatusGroup(status) {
|
||||
const normalized = String(status || '').trim()
|
||||
if (['draft', 'supplement', 'returned'].includes(normalized)) {
|
||||
return { key: 'draft', label: normalized === 'draft' ? '草稿' : '待完善' }
|
||||
}
|
||||
if (['submitted', 'review'].includes(normalized)) {
|
||||
return { key: 'in_progress', label: '审批中' }
|
||||
}
|
||||
if (['approved', 'paid'].includes(normalized)) {
|
||||
return { key: 'completed', label: '已完成' }
|
||||
}
|
||||
return { key: 'other', label: '其他状态' }
|
||||
}
|
||||
|
||||
export function formatQueryRecordDate(value) {
|
||||
const text = String(value || '').trim()
|
||||
if (!text) return ''
|
||||
return text.includes('T') ? text.split('T')[0] : text.slice(0, 10)
|
||||
}
|
||||
|
||||
export function buildQueryRecordFromClaim(claim) {
|
||||
if (!claim || typeof claim !== 'object') {
|
||||
return null
|
||||
}
|
||||
const claimId = String(claim.id || claim.claim_id || '').trim()
|
||||
if (!claimId) {
|
||||
return null
|
||||
}
|
||||
|
||||
const status = String(claim.status || '').trim()
|
||||
const statusGroup = resolveExpenseStatusGroup(status)
|
||||
return {
|
||||
claim_id: claimId,
|
||||
claim_no: String(claim.claim_no || claim.claimNo || '').trim() || '未编号',
|
||||
employee_name: String(claim.employee_name || claim.employeeName || '').trim(),
|
||||
expense_type: String(claim.expense_type || claim.expenseType || '').trim(),
|
||||
expense_type_label: EXPENSE_TYPE_LABELS[String(claim.expense_type || claim.expenseType || '').trim()] || String(claim.expense_type || claim.expenseType || '报销').trim(),
|
||||
amount: Number(claim.amount || 0),
|
||||
status,
|
||||
status_label: EXPENSE_STATUS_LABELS[status] || statusGroup.label,
|
||||
status_group: statusGroup.key,
|
||||
status_group_label: statusGroup.label,
|
||||
approval_stage: String(claim.approval_stage || claim.approvalStage || '').trim(),
|
||||
document_date: formatQueryRecordDate(claim.submitted_at || claim.submittedAt || claim.created_at || claim.createdAt || claim.occurred_at || claim.occurredAt),
|
||||
occurred_at: formatQueryRecordDate(claim.occurred_at || claim.occurredAt),
|
||||
reason: String(claim.reason || '').trim(),
|
||||
location: String(claim.location || '').trim()
|
||||
}
|
||||
}
|
||||
|
||||
export function buildDraftAssociationQueryPayload(claims) {
|
||||
const records = (Array.isArray(claims) ? claims : [])
|
||||
.filter((claim) => ASSOCIATABLE_CLAIM_STATUSES.has(String(claim?.status || '').trim()))
|
||||
.map(buildQueryRecordFromClaim)
|
||||
.filter(Boolean)
|
||||
|
||||
const statusGroups = records.reduce((groups, record) => {
|
||||
const key = String(record.status_group || 'other')
|
||||
const existing = groups.get(key) || {
|
||||
key,
|
||||
label: String(record.status_group_label || '其他状态'),
|
||||
count: 0
|
||||
}
|
||||
existing.count += 1
|
||||
groups.set(key, existing)
|
||||
return groups
|
||||
}, new Map())
|
||||
|
||||
return normalizeExpenseQueryPayload({
|
||||
result_type: 'expense_claim_list',
|
||||
title: '选择关联草稿',
|
||||
scope_label: '可关联草稿',
|
||||
selection_mode: 'draft_association',
|
||||
empty_text: '当前没有可关联的草稿单据。',
|
||||
recent_window_applied: false,
|
||||
record_count: records.length,
|
||||
preview_count: records.length,
|
||||
total_amount: records.reduce((sum, record) => sum + Number(record.amount || 0), 0),
|
||||
status_groups: Array.from(statusGroups.values()),
|
||||
records
|
||||
})
|
||||
}
|
||||
|
||||
export function normalizeExpenseQueryPayload(payload) {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const resultType = String(payload.result_type || '').trim()
|
||||
if (resultType && resultType !== 'expense_claim_list') {
|
||||
return null
|
||||
}
|
||||
|
||||
const records = (Array.isArray(payload.records) ? payload.records : [])
|
||||
.map(normalizeExpenseQueryRecord)
|
||||
.filter(Boolean)
|
||||
const statusGroups = (Array.isArray(payload.status_groups) ? payload.status_groups : [])
|
||||
.map(normalizeExpenseQueryStatusGroup)
|
||||
.filter(Boolean)
|
||||
|
||||
const rawRecordCount = Number(payload.record_count || 0)
|
||||
const rawPreviewCount = Number(payload.preview_count || records.length)
|
||||
const rawOlderRecordCount = Number(payload.older_record_count || 0)
|
||||
const totalAmount = Number(payload.total_amount || 0)
|
||||
const rawWindowDays = Number(payload.window_days || 0)
|
||||
const windowStartDate = String(payload.window_start_date || '').trim()
|
||||
const windowEndDate = String(payload.window_end_date || '').trim()
|
||||
|
||||
return {
|
||||
resultType: 'expense_claim_list',
|
||||
scopeLabel: String(payload.scope_label || '报销单').trim() || '报销单',
|
||||
selectionMode: String(payload.selection_mode || payload.selectionMode || '').trim(),
|
||||
selectionLocked: Boolean(payload.selection_locked || payload.selectionLocked),
|
||||
selectedClaimId: String(payload.selected_claim_id || payload.selectedClaimId || '').trim(),
|
||||
title: String(payload.title || '').trim(),
|
||||
emptyText: String(payload.empty_text || payload.emptyText || '').trim(),
|
||||
recentWindowApplied: Boolean(payload.recent_window_applied),
|
||||
windowDays:
|
||||
payload.window_days === null || payload.window_days === undefined || payload.window_days === ''
|
||||
? null
|
||||
: (Number.isFinite(rawWindowDays) ? Math.max(1, rawWindowDays) : null),
|
||||
windowStartDate: windowStartDate || '',
|
||||
windowEndDate: windowEndDate || '',
|
||||
recordCount: Number.isFinite(rawRecordCount) ? Math.max(0, rawRecordCount) : 0,
|
||||
previewCount: Number.isFinite(rawPreviewCount) ? Math.max(0, rawPreviewCount) : records.length,
|
||||
olderRecordCount: Number.isFinite(rawOlderRecordCount) ? Math.max(0, rawOlderRecordCount) : 0,
|
||||
hasMoreInWindow: Boolean(payload.has_more_in_window || payload.has_more),
|
||||
totalAmount: Number.isFinite(totalAmount) ? totalAmount : 0,
|
||||
statusGroups,
|
||||
records,
|
||||
currentPage: 1
|
||||
}
|
||||
}
|
||||
|
||||
export function buildExpenseQueryWindowLabel(queryPayload) {
|
||||
if (!queryPayload) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (queryPayload.selectionMode === 'draft_association') {
|
||||
return '先选择要关联的草稿,确认后我再识别附件并归集到该单据。'
|
||||
}
|
||||
|
||||
if (queryPayload.windowStartDate && queryPayload.windowEndDate) {
|
||||
return `${queryPayload.windowStartDate} 至 ${queryPayload.windowEndDate}`
|
||||
}
|
||||
|
||||
if (queryPayload.recentWindowApplied && queryPayload.windowDays) {
|
||||
return `近 ${queryPayload.windowDays} 日内`
|
||||
}
|
||||
|
||||
return '当前条件下'
|
||||
}
|
||||
|
||||
export function getExpenseQueryTotalPages(queryPayload) {
|
||||
const recordCount = Array.isArray(queryPayload?.records) ? queryPayload.records.length : 0
|
||||
return Math.max(1, Math.ceil(recordCount / EXPENSE_QUERY_PAGE_SIZE))
|
||||
}
|
||||
|
||||
export function getExpenseQueryActivePage(queryPayload) {
|
||||
const totalPages = getExpenseQueryTotalPages(queryPayload)
|
||||
const rawPage = Number(queryPayload?.currentPage || 1)
|
||||
if (!Number.isFinite(rawPage)) {
|
||||
return 1
|
||||
}
|
||||
return Math.min(Math.max(1, Math.round(rawPage)), totalPages)
|
||||
}
|
||||
|
||||
export function getExpenseQueryVisibleRecords(queryPayload) {
|
||||
const records = Array.isArray(queryPayload?.records) ? queryPayload.records : []
|
||||
const activePage = getExpenseQueryActivePage(queryPayload)
|
||||
const start = (activePage - 1) * EXPENSE_QUERY_PAGE_SIZE
|
||||
return records.slice(start, start + EXPENSE_QUERY_PAGE_SIZE)
|
||||
}
|
||||
|
||||
export function buildExpenseQueryHint(queryPayload) {
|
||||
if (!queryPayload) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (queryPayload.selectionMode === 'draft_association') {
|
||||
if (queryPayload.selectionLocked && queryPayload.selectedClaimId) {
|
||||
return '已选择关联草稿,附件将按该单据继续识别和归集。'
|
||||
}
|
||||
return '如果这些都不是本次要关联的单据,可以补充单号或先到个人报销列表新建草稿。'
|
||||
}
|
||||
|
||||
const parts = []
|
||||
const windowText = buildExpenseQueryWindowLabel(queryPayload)
|
||||
|
||||
if (Array.isArray(queryPayload.records) && queryPayload.records.length > EXPENSE_QUERY_PAGE_SIZE) {
|
||||
parts.push(`当前共整理 ${queryPayload.records.length} 笔单据,可左右切换查看`)
|
||||
}
|
||||
|
||||
if (queryPayload.hasMoreInWindow && queryPayload.previewCount < queryPayload.recordCount) {
|
||||
parts.push(`${windowText}共 ${queryPayload.recordCount} 笔,当前先整理最近 ${queryPayload.previewCount} 笔`)
|
||||
}
|
||||
|
||||
if (queryPayload.olderRecordCount > 0 && queryPayload.windowDays) {
|
||||
parts.push(`另有 ${queryPayload.olderRecordCount} 笔超过 ${queryPayload.windowDays} 日的单据,请前往个人报销中心查看`)
|
||||
}
|
||||
|
||||
return parts.join('。')
|
||||
}
|
||||
149
web/src/views/scripts/travelReimbursementReviewConstants.js
Normal file
149
web/src/views/scripts/travelReimbursementReviewConstants.js
Normal file
@@ -0,0 +1,149 @@
|
||||
import { TRANSPORT_KEYWORD_PATTERN } from '../../utils/reimbursementTextInference.js'
|
||||
|
||||
export const DOCUMENT_TYPE_LABELS = {
|
||||
travel_ticket: '行程单/机票/车票',
|
||||
flight_itinerary: '机票/航班行程单',
|
||||
train_ticket: '火车/高铁票',
|
||||
hotel_invoice: '酒店住宿票据',
|
||||
taxi_receipt: '出租车/网约车票据',
|
||||
parking_toll_receipt: '停车/通行费票据',
|
||||
transport_receipt: '交通出行票据',
|
||||
meal_receipt: '餐饮票据',
|
||||
office_invoice: '办公用品票据',
|
||||
meeting_invoice: '会议/会务票据',
|
||||
training_invoice: '培训票据',
|
||||
vat_invoice: '增值税发票',
|
||||
receipt: '一般收据/凭证',
|
||||
other: '其他单据'
|
||||
}
|
||||
|
||||
export const EXPENSE_TYPE_LABELS = {
|
||||
travel: '差旅费',
|
||||
hotel: '住宿费',
|
||||
transport: '交通费',
|
||||
meal: '伙食费',
|
||||
meeting: '会务费',
|
||||
entertainment: '业务招待费',
|
||||
office: '办公费',
|
||||
training: '培训费',
|
||||
communication: '通讯费',
|
||||
welfare: '福利费',
|
||||
other: '其他费用'
|
||||
}
|
||||
|
||||
export const REVIEW_SLOT_CONFIG = {
|
||||
expense_type: {
|
||||
title: '报销分类',
|
||||
hint: '请选择本次报销分类',
|
||||
status: '待确认',
|
||||
icon: 'mdi mdi-shape-outline'
|
||||
},
|
||||
customer_name: {
|
||||
title: '关联客户',
|
||||
hint: '请补充客户单位全称',
|
||||
status: '待补充',
|
||||
icon: 'mdi mdi-domain'
|
||||
},
|
||||
time_range: {
|
||||
title: '发生时间',
|
||||
hint: '请按 YYYY-MM-DD 补充业务发生日期',
|
||||
status: '待补充',
|
||||
icon: 'mdi mdi-calendar-month-outline'
|
||||
},
|
||||
location: {
|
||||
title: '业务地点',
|
||||
hint: '请补充业务发生地点',
|
||||
status: '待补充',
|
||||
icon: 'mdi mdi-map-marker-outline'
|
||||
},
|
||||
merchant_name: {
|
||||
title: '酒店/商户',
|
||||
hint: '请补充酒店或商户名称',
|
||||
status: '待补充',
|
||||
icon: 'mdi mdi-storefront-outline'
|
||||
},
|
||||
amount: {
|
||||
title: '金额',
|
||||
hint: '请补充本次费用金额',
|
||||
status: '待补充',
|
||||
icon: 'mdi mdi-cash'
|
||||
},
|
||||
reason: {
|
||||
title: '场景 / 事由',
|
||||
hint: '请补充本次费用场景或事由',
|
||||
status: '待补充',
|
||||
icon: 'mdi mdi-text-box-outline'
|
||||
},
|
||||
participants: {
|
||||
title: '同行人员',
|
||||
hint: '请至少填写 1 名同行人员',
|
||||
status: '待补充',
|
||||
icon: 'mdi mdi-account-group-outline'
|
||||
},
|
||||
attachments: {
|
||||
title: '票据状态',
|
||||
hint: '请上传发票/收据等票据附件',
|
||||
status: '未上传',
|
||||
icon: 'mdi mdi-paperclip'
|
||||
}
|
||||
}
|
||||
|
||||
export const REVIEW_FALLBACK_GROUP_CODES = [
|
||||
'other',
|
||||
'travel',
|
||||
'transport',
|
||||
'hotel',
|
||||
'meal',
|
||||
'meeting',
|
||||
'entertainment',
|
||||
'office',
|
||||
'training',
|
||||
'communication',
|
||||
'welfare'
|
||||
]
|
||||
|
||||
export const REVIEW_CATEGORY_PRESET_OPTIONS = [
|
||||
{ key: 'travel', label: '差旅费' },
|
||||
{ key: 'transport', label: '交通费' },
|
||||
{ key: 'hotel', label: '住宿费' },
|
||||
{ key: 'meal', label: '餐费' },
|
||||
{ key: 'entertainment', label: '业务招待费' },
|
||||
{ key: 'other_trigger', label: '其他类型', is_other: true }
|
||||
]
|
||||
|
||||
export const REVIEW_OTHER_CATEGORY_OPTIONS = [
|
||||
{ key: 'meeting', label: '会务费' },
|
||||
{ key: 'office', label: '办公费' },
|
||||
{ key: 'training', label: '培训费' },
|
||||
{ key: 'communication', label: '通讯费' },
|
||||
{ key: 'welfare', label: '福利费' },
|
||||
{ key: 'other', label: '其他费用' }
|
||||
]
|
||||
|
||||
export const REVIEW_SCENE_OTHER_OPTION = '其他场景'
|
||||
|
||||
export const REVIEW_SCENE_OPTIONS = ['请客户吃饭', '出差行程', '住宿报销', '交通出行', '会务活动', REVIEW_SCENE_OTHER_OPTION]
|
||||
|
||||
export const EXPENSE_CODE_TO_PRESET_SCENE = {
|
||||
travel: '出差行程',
|
||||
hotel: '住宿报销',
|
||||
transport: '交通出行',
|
||||
meeting: '会务活动',
|
||||
entertainment: '请客户吃饭',
|
||||
meal: '请客户吃饭'
|
||||
}
|
||||
|
||||
export const DATE_INPUT_FORMAT = 'YYYY-MM-DD'
|
||||
|
||||
export const CATEGORY_CONFIDENCE_KEYWORDS = {
|
||||
travel: [/出差|差旅|行程|机票|火车|高铁|航班/],
|
||||
hotel: [/住宿|酒店|宾馆|民宿/],
|
||||
transport: [TRANSPORT_KEYWORD_PATTERN],
|
||||
meal: [/餐费|用餐|午餐|晚餐|早餐|伙食|餐饮/],
|
||||
meeting: [/会务|会议|论坛|展会|参会|会场/],
|
||||
entertainment: [/招待|宴请|请客|请客户|客户.*吃饭|商务用餐|陪同/],
|
||||
office: [/办公|工位|耗材|白板|键盘|鼠标|打印|文具|采购/],
|
||||
training: [/培训|授课|讲师|课程|签到|讲义/],
|
||||
communication: [/通讯|电话|流量|话费|宽带|网络/],
|
||||
welfare: [/福利|体检|团建|节日|慰问|关怀/]
|
||||
}
|
||||
155
web/src/views/scripts/travelReimbursementReviewDocuments.js
Normal file
155
web/src/views/scripts/travelReimbursementReviewDocuments.js
Normal file
@@ -0,0 +1,155 @@
|
||||
import {
|
||||
DOCUMENT_TYPE_LABELS,
|
||||
EXPENSE_TYPE_LABELS
|
||||
} from './travelReimbursementReviewConstants.js'
|
||||
|
||||
export function cloneReviewDocumentDrafts(items) {
|
||||
return (Array.isArray(items) ? items : []).map((item) => ({
|
||||
...item,
|
||||
warnings: Array.isArray(item?.warnings) ? [...item.warnings] : [],
|
||||
fields: Array.isArray(item?.fields)
|
||||
? item.fields.map((field) => ({
|
||||
label: String(field?.label || '').trim(),
|
||||
value: String(field?.value || ''),
|
||||
source: String(field?.source || 'ocr').trim() || 'ocr'
|
||||
}))
|
||||
: []
|
||||
}))
|
||||
}
|
||||
|
||||
export function buildReviewDocumentDrafts(reviewPayload) {
|
||||
return buildReviewDocumentSummaries(reviewPayload).map((item) => ({
|
||||
index: Number(item.index || 0),
|
||||
filename: String(item.filename || '').trim(),
|
||||
document_type: String(item.document_type || 'other').trim() || 'other',
|
||||
suggested_expense_type: String(item.suggested_expense_type || 'other').trim() || 'other',
|
||||
scene_label: String(item.scene_label || '').trim(),
|
||||
summary: String(item.summary || '').trim(),
|
||||
confidenceLabel: String(item.confidenceLabel || '').trim(),
|
||||
documentTypeLabel: String(item.documentTypeLabel || '').trim(),
|
||||
expenseTypeLabel: String(item.expenseTypeLabel || '').trim(),
|
||||
preview_kind: String(item.preview_kind || '').trim(),
|
||||
preview_data_url: String(item.preview_data_url || '').trim(),
|
||||
warnings: Array.isArray(item.warnings) ? [...item.warnings] : [],
|
||||
fields: Array.isArray(item.fields)
|
||||
? item.fields.map((field) => ({
|
||||
label: String(field?.label || '').trim(),
|
||||
value: String(field?.value || ''),
|
||||
source: String(field?.source || 'ocr').trim() || 'ocr'
|
||||
}))
|
||||
: []
|
||||
}))
|
||||
}
|
||||
|
||||
export function normalizeReviewDocumentComparableValue(item) {
|
||||
return {
|
||||
index: Number(item?.index || 0),
|
||||
filename: String(item?.filename || '').trim(),
|
||||
scene_label: String(item?.scene_label || '').trim(),
|
||||
summary: String(item?.summary || '').trim(),
|
||||
fields: (Array.isArray(item?.fields) ? item.fields : []).map((field) => ({
|
||||
label: String(field?.label || '').trim(),
|
||||
value: String(field?.value || '').trim()
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
export function buildReviewDocumentCorrectionLines(baseDrafts, nextDrafts) {
|
||||
const baseMap = new Map(
|
||||
cloneReviewDocumentDrafts(baseDrafts).map((item) => [`${item.index}:${item.filename}`, item])
|
||||
)
|
||||
|
||||
return cloneReviewDocumentDrafts(nextDrafts).reduce((lines, item) => {
|
||||
const key = `${item.index}:${item.filename}`
|
||||
const base = baseMap.get(key)
|
||||
const changes = []
|
||||
const nextSceneLabel = String(item.scene_label || '').trim()
|
||||
const baseSceneLabel = String(base?.scene_label || '').trim()
|
||||
const nextSummary = String(item.summary || '').trim()
|
||||
const baseSummary = String(base?.summary || '').trim()
|
||||
|
||||
if (nextSceneLabel !== baseSceneLabel) {
|
||||
changes.push(`票据场景:${nextSceneLabel || '待补充'}`)
|
||||
}
|
||||
|
||||
if (nextSummary !== baseSummary) {
|
||||
changes.push(`识别摘要:${nextSummary || '待补充'}`)
|
||||
}
|
||||
|
||||
const baseFieldMap = new Map(
|
||||
(Array.isArray(base?.fields) ? base.fields : []).map((field) => [
|
||||
String(field?.label || '').trim(),
|
||||
String(field?.value || '').trim()
|
||||
])
|
||||
)
|
||||
|
||||
for (const field of Array.isArray(item.fields) ? item.fields : []) {
|
||||
const label = String(field?.label || '').trim()
|
||||
if (!label) continue
|
||||
const nextValue = String(field?.value || '').trim()
|
||||
const baseValue = baseFieldMap.get(label) || ''
|
||||
if (nextValue !== baseValue) {
|
||||
changes.push(`${label}:${nextValue || '待补充'}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (changes.length) {
|
||||
lines.push(`第${item.index}张票据(${item.filename}):${changes.join(';')}`)
|
||||
}
|
||||
|
||||
return lines
|
||||
}, [])
|
||||
}
|
||||
|
||||
export function buildReviewDocumentCorrectionMessage(baseDrafts, nextDrafts) {
|
||||
const lines = buildReviewDocumentCorrectionLines(baseDrafts, nextDrafts)
|
||||
if (!lines.length) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return `请同步修正逐票据识别结果:\n${lines.join('\n')}`
|
||||
}
|
||||
|
||||
export function buildReviewDocumentCorrectionContext(drafts) {
|
||||
return cloneReviewDocumentDrafts(drafts).map((item) => ({
|
||||
index: item.index,
|
||||
filename: item.filename,
|
||||
scene_label: String(item.scene_label || '').trim(),
|
||||
summary: String(item.summary || '').trim(),
|
||||
fields: item.fields.map((field) => ({
|
||||
label: String(field.label || '').trim(),
|
||||
value: String(field.value || '').trim()
|
||||
}))
|
||||
}))
|
||||
}
|
||||
|
||||
export function formatConfidenceLabel(value) {
|
||||
const score = Number(value || 0)
|
||||
if (!score) return '待补充'
|
||||
return `${Math.round(score * 100)}%`
|
||||
}
|
||||
|
||||
export function resolveDocumentTypeLabel(type) {
|
||||
return DOCUMENT_TYPE_LABELS[String(type || '').trim()] || DOCUMENT_TYPE_LABELS.other
|
||||
}
|
||||
|
||||
export function resolveExpenseTypeLabel(type, fallbackLabel = '') {
|
||||
const normalized = String(type || '').trim()
|
||||
return EXPENSE_TYPE_LABELS[normalized] || String(fallbackLabel || '').trim() || EXPENSE_TYPE_LABELS.other
|
||||
}
|
||||
|
||||
export function buildReviewDocumentSummaries(reviewPayload) {
|
||||
const docs = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : []
|
||||
return docs.map((item) => {
|
||||
const fields = Array.isArray(item.fields) ? item.fields : []
|
||||
return {
|
||||
...item,
|
||||
documentTypeLabel: resolveDocumentTypeLabel(item.document_type),
|
||||
expenseTypeLabel: resolveExpenseTypeLabel(item.suggested_expense_type, item.scene_label),
|
||||
confidenceLabel: formatConfidenceLabel(item.avg_score),
|
||||
lines: fields
|
||||
.filter((field) => String(field?.value || '').trim())
|
||||
.map((field) => `${field.label}:${field.value}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
1463
web/src/views/scripts/travelReimbursementReviewModel.js
Normal file
1463
web/src/views/scripts/travelReimbursementReviewModel.js
Normal file
File diff suppressed because it is too large
Load Diff
545
web/src/views/scripts/travelRequestDetailExpenseModel.js
Normal file
545
web/src/views/scripts/travelRequestDetailExpenseModel.js
Normal file
@@ -0,0 +1,545 @@
|
||||
export const EXPENSE_TYPE_OPTIONS = [
|
||||
{ value: 'travel', label: '差旅费' },
|
||||
{ value: 'train_ticket', label: '火车票' },
|
||||
{ value: 'flight_ticket', label: '机票' },
|
||||
{ value: 'hotel_ticket', label: '住宿票' },
|
||||
{ value: 'ride_ticket', label: '乘车' },
|
||||
{ value: 'entertainment', label: '业务招待费' },
|
||||
{ value: 'office', label: '办公费' },
|
||||
{ value: 'meeting', label: '会务费' },
|
||||
{ value: 'training', label: '培训费' },
|
||||
{ value: 'hotel', label: '住宿费' },
|
||||
{ value: 'transport', label: '交通费' },
|
||||
{ value: 'meal', label: '餐费' },
|
||||
{ value: 'travel_allowance', label: '出差补贴' },
|
||||
{ value: 'other', label: '其他费用' }
|
||||
]
|
||||
|
||||
export const LOCATION_REQUIRED_EXPENSE_TYPES = new Set([
|
||||
'travel',
|
||||
'meeting',
|
||||
'entertainment'
|
||||
])
|
||||
|
||||
export const SYSTEM_GENERATED_EXPENSE_TYPES = new Set(['travel_allowance'])
|
||||
export const LONG_DISTANCE_TRAVEL_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket'])
|
||||
export const ROUTE_DESCRIPTION_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket', 'ship_ticket', 'ferry_ticket', 'ride_ticket'])
|
||||
export const HOTEL_DESCRIPTION_EXPENSE_TYPES = new Set(['hotel_ticket'])
|
||||
export const ROUTE_DESCRIPTION_PATTERN = /^[A-Za-z0-9\u4e00-\u9fa5()()·]{2,40}\s*-\s*[A-Za-z0-9\u4e00-\u9fa5()()·]{2,40}$/
|
||||
|
||||
export function parseCurrency(value) {
|
||||
return Number.parseFloat(String(value).replace(/[^\d.]/g, '')) || 0
|
||||
}
|
||||
|
||||
export function formatCurrency(value) {
|
||||
return new Intl.NumberFormat('zh-CN', {
|
||||
style: 'currency',
|
||||
currency: 'CNY',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: Number.isInteger(value) ? 0 : 2
|
||||
}).format(value)
|
||||
}
|
||||
|
||||
export function normalizeExpenseType(value) {
|
||||
return String(value || '').trim() || 'other'
|
||||
}
|
||||
|
||||
export function resolveExpenseTypeLabel(value) {
|
||||
return EXPENSE_TYPE_OPTIONS.find((option) => option.value === normalizeExpenseType(value))?.label || '其他费用'
|
||||
}
|
||||
|
||||
export function isSystemGeneratedExpenseItemSource(source) {
|
||||
const itemType = normalizeExpenseType(source?.itemType || source?.item_type)
|
||||
return Boolean(source?.isSystemGenerated || source?.is_system_generated || SYSTEM_GENERATED_EXPENSE_TYPES.has(itemType))
|
||||
}
|
||||
|
||||
export function isLocationRequiredExpenseType(value) {
|
||||
return LOCATION_REQUIRED_EXPENSE_TYPES.has(normalizeExpenseType(value))
|
||||
}
|
||||
|
||||
export function resolveLocationSummaryLabel(value) {
|
||||
return isLocationRequiredExpenseType(value) ? '业务地点' : '采购/收货地点'
|
||||
}
|
||||
|
||||
export function isRouteDescriptionExpenseType(value) {
|
||||
return ROUTE_DESCRIPTION_EXPENSE_TYPES.has(normalizeExpenseType(value))
|
||||
}
|
||||
|
||||
export function isHotelDescriptionExpenseType(value) {
|
||||
return HOTEL_DESCRIPTION_EXPENSE_TYPES.has(normalizeExpenseType(value))
|
||||
}
|
||||
|
||||
export function resolveExpenseDetailHint(expenseType) {
|
||||
if (isRouteDescriptionExpenseType(expenseType)) {
|
||||
return '起始地-目的地'
|
||||
}
|
||||
if (isHotelDescriptionExpenseType(expenseType)) {
|
||||
return '目的地酒店'
|
||||
}
|
||||
if (!isLocationRequiredExpenseType(expenseType)) {
|
||||
return '非必填'
|
||||
}
|
||||
return '待补充'
|
||||
}
|
||||
|
||||
export function resolveLocationDisplay(value, expenseType) {
|
||||
return isPlaceholderValue(value) ? resolveExpenseDetailHint(expenseType) : value
|
||||
}
|
||||
|
||||
export function isSyntheticLocationDisplay(value, expenseType) {
|
||||
const text = String(value || '').trim()
|
||||
return ['待补充', '非必填', resolveExpenseDetailHint(expenseType)].includes(text)
|
||||
}
|
||||
|
||||
export function isValidRouteDescription(value) {
|
||||
const text = String(value || '').trim()
|
||||
return ROUTE_DESCRIPTION_PATTERN.test(text) && !/\d{4}[-/年.]\d{1,2}[-/月.]\d{1,2}/.test(text)
|
||||
}
|
||||
|
||||
export function resolveExpenseReasonPlaceholder(itemType) {
|
||||
if (isRouteDescriptionExpenseType(itemType)) {
|
||||
return '起始地-目的地,例如:广州南-北京南'
|
||||
}
|
||||
if (isHotelDescriptionExpenseType(itemType)) {
|
||||
return '目的地酒店,例如:北京中心酒店'
|
||||
}
|
||||
return '输入费用说明'
|
||||
}
|
||||
|
||||
export function resolveExpenseReasonHelper(itemType) {
|
||||
if (isRouteDescriptionExpenseType(itemType)) {
|
||||
return '起始地-目的地'
|
||||
}
|
||||
if (isHotelDescriptionExpenseType(itemType)) {
|
||||
return '目的地酒店'
|
||||
}
|
||||
return '业务报销说明'
|
||||
}
|
||||
|
||||
export function buildFallbackProgressSteps() {
|
||||
return [
|
||||
{ index: 1, label: '创建单据', time: '已完成', done: true, active: true },
|
||||
{ index: 2, label: '待提交', time: '进行中', active: true, current: true },
|
||||
{ index: 3, label: 'AI预审', time: '待处理' },
|
||||
{ index: 4, label: '直属领导审批', time: '待处理' },
|
||||
{ index: 5, label: '财务审批', time: '待处理' },
|
||||
{ index: 6, label: '归档入账', time: '待处理' }
|
||||
]
|
||||
}
|
||||
|
||||
export function buildFallbackExpenseItems(request) {
|
||||
return [
|
||||
buildExpenseItemViewModel({
|
||||
id: 'fallback-1',
|
||||
itemDate: '',
|
||||
itemType: request.typeCode || 'other',
|
||||
itemReason: request.reason,
|
||||
itemLocation: request.sceneTarget,
|
||||
itemAmount: parseCurrency(request.amountDisplay),
|
||||
invoiceId: '',
|
||||
time: '待补充',
|
||||
dayLabel: request.detailVariant === 'travel' ? '出行日' : '业务发生日',
|
||||
name: request.typeLabel,
|
||||
category: request.typeLabel,
|
||||
desc: request.reason,
|
||||
detail: resolveLocationDisplay(request.sceneTarget, request.typeCode),
|
||||
amount: request.amountDisplay,
|
||||
status: '待补充',
|
||||
tone: 'bad',
|
||||
attachmentStatus: '待上传',
|
||||
attachmentHint: '请在此单据中继续补充附件',
|
||||
attachmentTone: 'missing',
|
||||
attachments: [],
|
||||
riskLabel: '待补材料',
|
||||
riskText: request.riskSummary,
|
||||
riskTone: 'medium'
|
||||
}, 0, request)
|
||||
]
|
||||
}
|
||||
|
||||
export function isPlaceholderValue(value) {
|
||||
const text = String(value || '').trim()
|
||||
if (!text) {
|
||||
return true
|
||||
}
|
||||
|
||||
return ['待补充', '暂无', '无', '未知', '处理中'].includes(text.replace(/\s+/g, ''))
|
||||
}
|
||||
|
||||
export function normalizeDetailNoteDraftValue(value) {
|
||||
const text = String(value || '').trim()
|
||||
return isPlaceholderValue(text) ? '' : text
|
||||
}
|
||||
|
||||
export function isValidIsoDate(value) {
|
||||
const normalized = String(value || '').trim()
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(normalized)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const [yearText, monthText, dayText] = normalized.split('-')
|
||||
const year = Number(yearText)
|
||||
const month = Number(monthText)
|
||||
const day = Number(dayText)
|
||||
|
||||
if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const candidate = new Date(Date.UTC(year, month - 1, day))
|
||||
return (
|
||||
candidate.getUTCFullYear() === year &&
|
||||
candidate.getUTCMonth() === month - 1 &&
|
||||
candidate.getUTCDate() === day
|
||||
)
|
||||
}
|
||||
|
||||
export function normalizeIsoDateValue(value) {
|
||||
const normalized = String(value || '').trim()
|
||||
if (isValidIsoDate(normalized)) {
|
||||
return normalized
|
||||
}
|
||||
|
||||
const match = normalized.match(/^(\d{4}-\d{2}-\d{2})/)
|
||||
if (match && isValidIsoDate(match[1])) {
|
||||
return match[1]
|
||||
}
|
||||
|
||||
const candidate = value instanceof Date ? value : new Date(normalized)
|
||||
if (Number.isNaN(candidate.getTime())) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const year = candidate.getFullYear()
|
||||
const month = String(candidate.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(candidate.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
export function formatExpenseFilledTime(value) {
|
||||
const normalized = String(value || '').trim()
|
||||
if (!normalized) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const candidate = value instanceof Date ? value : new Date(normalized)
|
||||
if (Number.isNaN(candidate.getTime())) {
|
||||
return normalized
|
||||
}
|
||||
|
||||
const year = candidate.getFullYear()
|
||||
const month = String(candidate.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(candidate.getDate()).padStart(2, '0')
|
||||
const hours = String(candidate.getHours()).padStart(2, '0')
|
||||
const minutes = String(candidate.getMinutes()).padStart(2, '0')
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}`
|
||||
}
|
||||
|
||||
export function resolveExpenseUploadHint(value) {
|
||||
const normalized = String(value || '').trim()
|
||||
return normalized || '仅支持上传 1 张 JPG、PNG、PDF 单据'
|
||||
}
|
||||
|
||||
export function extractAttachmentDisplayName(value) {
|
||||
const normalized = String(value || '').trim()
|
||||
if (!normalized) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return normalized.split('/').filter(Boolean).pop() || normalized
|
||||
}
|
||||
|
||||
export function resolveExpenseItemViewId(source, index, requestModel) {
|
||||
return String(source?.id || `${requestModel?.claimId || requestModel?.id || 'claim'}-item-${index}`)
|
||||
}
|
||||
|
||||
export function buildTravelTimeLabelMap(items, requestModel) {
|
||||
const travelItems = items
|
||||
.map((item, index) => {
|
||||
const itemType = normalizeExpenseType(item?.itemType || item?.item_type || requestModel?.typeCode || 'other')
|
||||
return {
|
||||
id: resolveExpenseItemViewId(item, index, requestModel),
|
||||
index,
|
||||
itemType,
|
||||
itemDate: normalizeIsoDateValue(item?.itemDate ?? item?.item_date),
|
||||
isSystemGenerated: isSystemGeneratedExpenseItemSource({ ...item, itemType })
|
||||
}
|
||||
})
|
||||
.filter((item) => !item.isSystemGenerated && LONG_DISTANCE_TRAVEL_EXPENSE_TYPES.has(item.itemType))
|
||||
.sort((left, right) => {
|
||||
const dateCompare = String(left.itemDate || '').localeCompare(String(right.itemDate || ''))
|
||||
return dateCompare || left.index - right.index
|
||||
})
|
||||
|
||||
const labels = new Map()
|
||||
if (!travelItems.length) {
|
||||
return labels
|
||||
}
|
||||
|
||||
travelItems.forEach((item, index) => {
|
||||
if (index === 0) {
|
||||
labels.set(item.id, '出发时间')
|
||||
} else if (index === travelItems.length - 1) {
|
||||
labels.set(item.id, '返回时间')
|
||||
} else {
|
||||
labels.set(item.id, '中转时间')
|
||||
}
|
||||
})
|
||||
return labels
|
||||
}
|
||||
|
||||
export function resolveExpenseTimeLabel({ id, itemType, isSystemGenerated, requestModel, travelTimeLabelMap }) {
|
||||
if (isSystemGenerated) {
|
||||
return '系统自动计算'
|
||||
}
|
||||
if (travelTimeLabelMap?.has(id)) {
|
||||
return travelTimeLabelMap.get(id)
|
||||
}
|
||||
if (itemType === 'ride_ticket') {
|
||||
return '乘车时间'
|
||||
}
|
||||
if (itemType === 'hotel_ticket') {
|
||||
return '住宿时间'
|
||||
}
|
||||
return requestModel?.detailVariant === 'travel' ? '出行时间' : '业务发生时间'
|
||||
}
|
||||
|
||||
export function buildExpenseItemViewModel(source, index, requestModel, travelTimeLabelMap = new Map()) {
|
||||
const itemType = normalizeExpenseType(source?.itemType || source?.item_type || requestModel?.typeCode || 'other')
|
||||
const isSystemGenerated = isSystemGeneratedExpenseItemSource({ ...source, itemType })
|
||||
const id = resolveExpenseItemViewId(source, index, requestModel)
|
||||
const itemReason = String(source?.itemReason ?? source?.item_reason ?? '').trim()
|
||||
const itemLocation = String(source?.itemLocation ?? source?.item_location ?? '').trim()
|
||||
const itemDate = normalizeIsoDateValue(source?.itemDate ?? source?.item_date)
|
||||
const itemAmount = parseCurrency(source?.itemAmount ?? source?.item_amount)
|
||||
const invoiceId = String(source?.invoiceId ?? source?.invoice_id ?? '').trim()
|
||||
const attachmentName = String(source?.attachmentName || source?.attachment_name || extractAttachmentDisplayName(invoiceId)).trim()
|
||||
const attachments = invoiceId ? [attachmentName || invoiceId] : []
|
||||
const amountDisplay = itemAmount > 0 ? formatCurrency(itemAmount) : '待补充'
|
||||
const riskText = String(source?.riskText || '').trim()
|
||||
const filledAt = formatExpenseFilledTime(
|
||||
source?.filledAt
|
||||
|| source?.filled_at
|
||||
|| source?.createdAt
|
||||
|| source?.created_at
|
||||
)
|
||||
|
||||
return {
|
||||
id,
|
||||
itemDate,
|
||||
itemType,
|
||||
itemReason,
|
||||
itemLocation,
|
||||
itemAmount,
|
||||
invoiceId,
|
||||
isSystemGenerated,
|
||||
time: itemDate || '待补充',
|
||||
filledAt: filledAt || '待同步',
|
||||
dayLabel: resolveExpenseTimeLabel({
|
||||
id,
|
||||
itemType,
|
||||
isSystemGenerated,
|
||||
requestModel,
|
||||
travelTimeLabelMap
|
||||
}),
|
||||
name: resolveExpenseTypeLabel(itemType),
|
||||
category: resolveExpenseTypeLabel(itemType),
|
||||
desc: itemReason || '待补充',
|
||||
detail: resolveLocationDisplay(itemLocation, itemType),
|
||||
amount: amountDisplay,
|
||||
status: isSystemGenerated ? '系统计算' : attachments.length ? '已识别' : '待补充',
|
||||
tone: isSystemGenerated ? 'system' : attachments.length ? 'ok' : 'bad',
|
||||
attachmentStatus: isSystemGenerated ? '无需附件' : attachments.length ? '已关联票据' : '未上传',
|
||||
attachmentHint: isSystemGenerated ? '根据出差天数与职级自动测算' : attachments.length ? attachmentName || attachments[0] : resolveExpenseUploadHint(),
|
||||
attachmentTone: isSystemGenerated ? 'system' : attachments.length ? 'ok' : 'missing',
|
||||
attachments,
|
||||
riskLabel: String(source?.riskLabel || '').trim() || '无',
|
||||
riskText,
|
||||
riskTone: String(source?.riskTone || '').trim() || 'low'
|
||||
}
|
||||
}
|
||||
|
||||
export function rebuildExpenseItems(items, requestModel) {
|
||||
const sortedItems = [...items]
|
||||
.sort((left, right) => Number(isSystemGeneratedExpenseItemSource(left)) - Number(isSystemGeneratedExpenseItemSource(right)))
|
||||
const travelTimeLabelMap = buildTravelTimeLabelMap(sortedItems, requestModel)
|
||||
return sortedItems.map((item, index) => buildExpenseItemViewModel(item, index, requestModel, travelTimeLabelMap))
|
||||
}
|
||||
|
||||
export function buildExpenseDraftIssues(item) {
|
||||
const issues = []
|
||||
if (item.isSystemGenerated) {
|
||||
return issues
|
||||
}
|
||||
const locationRequired = isLocationRequiredExpenseType(item.itemType)
|
||||
|
||||
if (!isValidIsoDate(item.itemDate)) {
|
||||
issues.push('缺少日期')
|
||||
}
|
||||
if (isPlaceholderValue(item.itemType)) {
|
||||
issues.push('缺少费用项目')
|
||||
}
|
||||
if (isPlaceholderValue(item.itemReason)) {
|
||||
issues.push('缺少说明')
|
||||
} else if (isRouteDescriptionExpenseType(item.itemType) && !isValidRouteDescription(item.itemReason)) {
|
||||
issues.push('行程说明格式错误')
|
||||
}
|
||||
if (locationRequired && isPlaceholderValue(item.itemLocation)) {
|
||||
issues.push('缺少地点')
|
||||
}
|
||||
if (!Number.isFinite(Number(item.itemAmount)) || Number(item.itemAmount) <= 0) {
|
||||
issues.push('缺少金额')
|
||||
}
|
||||
if (isPlaceholderValue(item.invoiceId)) {
|
||||
issues.push('缺少票据标识')
|
||||
}
|
||||
|
||||
return issues
|
||||
}
|
||||
|
||||
export function buildOptionalTravelReceiptRiskCards(requestModel, items) {
|
||||
const normalizedItems = Array.isArray(items) ? items : []
|
||||
const isTravelContext =
|
||||
requestModel?.detailVariant === 'travel' ||
|
||||
requestModel?.typeCode === 'travel' ||
|
||||
normalizedItems.some((item) => ['train_ticket', 'flight_ticket', 'hotel_ticket', 'ride_ticket', 'travel_allowance'].includes(item.itemType))
|
||||
if (!isTravelContext) {
|
||||
return []
|
||||
}
|
||||
|
||||
const hasUploadedType = (itemType) =>
|
||||
normalizedItems.some((item) => item.itemType === itemType && !item.isSystemGenerated && !isPlaceholderValue(item.invoiceId))
|
||||
const cards = []
|
||||
if (!hasUploadedType('hotel_ticket')) {
|
||||
cards.push({
|
||||
id: 'travel-optional-hotel-ticket',
|
||||
tone: 'low',
|
||||
label: '低风险',
|
||||
title: '住宿票据提醒',
|
||||
risk: '当前差旅单暂未上传住宿票据;如果本次出差发生住宿费用,请不要忘记补充酒店住宿票据。',
|
||||
summary: '住宿票据缺失不阻断当前提交,但会影响住宿费用报销完整性。',
|
||||
ruleBasis: ['差旅费可以包含交通、住宿和补贴等明细;住宿费用需要住宿票据支撑。'],
|
||||
suggestion: '如有住宿费用,请新增住宿票明细并上传酒店发票或住宿清单;如未住宿,可忽略该提醒。'
|
||||
})
|
||||
}
|
||||
if (!hasUploadedType('ride_ticket')) {
|
||||
cards.push({
|
||||
id: 'travel-optional-ride-ticket',
|
||||
tone: 'low',
|
||||
label: '低风险',
|
||||
title: '乘车票据提醒',
|
||||
risk: '当前差旅单暂未上传市内乘车票据;如果发生打车或市内交通费用,可以继续补充票据报销。',
|
||||
summary: '市内交通票据缺失不阻断当前提交,但可能遗漏可报销费用。',
|
||||
ruleBasis: ['差旅费可以补充市内交通/乘车票据;该类票据通常作为差旅费用的可选补充材料。'],
|
||||
suggestion: '如有打车、网约车或市内交通费用,请新增乘车明细并上传对应票据。'
|
||||
})
|
||||
}
|
||||
return cards
|
||||
}
|
||||
|
||||
export function buildDraftBlockingIssues(request, expenseItems) {
|
||||
const issues = []
|
||||
const locationRequired = isLocationRequiredExpenseType(request.typeCode)
|
||||
const normalizedItems = Array.isArray(expenseItems) ? expenseItems : []
|
||||
const itemAmountTotal = normalizedItems.reduce((sum, item) => {
|
||||
const amount = Number(item?.itemAmount || 0)
|
||||
return Number.isFinite(amount) && amount > 0 ? sum + amount : sum
|
||||
}, 0)
|
||||
const hasValidItemDate = normalizedItems.some((item) => isValidIsoDate(item?.itemDate))
|
||||
const hasValidItemType = normalizedItems.some((item) => !isPlaceholderValue(item?.itemType))
|
||||
const hasValidItemReason = normalizedItems.some((item) => !isPlaceholderValue(item?.itemReason))
|
||||
const hasValidItemLocation = normalizedItems.some((item) => !isPlaceholderValue(item?.itemLocation))
|
||||
|
||||
if (isPlaceholderValue(request.profileName)) {
|
||||
issues.push('申请人未完善')
|
||||
}
|
||||
if (isPlaceholderValue(request.typeLabel) && !hasValidItemType) {
|
||||
issues.push('报销类型未完善')
|
||||
}
|
||||
if (isPlaceholderValue(request.reason) && !hasValidItemReason) {
|
||||
issues.push('报销事由未完善')
|
||||
}
|
||||
if (locationRequired && isPlaceholderValue(request.location) && !hasValidItemLocation) {
|
||||
issues.push('业务地点未完善')
|
||||
}
|
||||
if (isPlaceholderValue(request.occurredDisplay) && !hasValidItemDate) {
|
||||
issues.push('发生时间未完善')
|
||||
}
|
||||
if ((!Number.isFinite(Number(request.amountValue)) || Number(request.amountValue) <= 0) && itemAmountTotal <= 0) {
|
||||
issues.push('报销金额未完善')
|
||||
}
|
||||
if (!normalizedItems.length) {
|
||||
issues.push('费用明细不能为空')
|
||||
}
|
||||
|
||||
normalizedItems.forEach((item, index) => {
|
||||
buildExpenseDraftIssues(item).forEach((issue) => {
|
||||
issues.push(`费用明细第 ${index + 1} 条${issue}`)
|
||||
})
|
||||
})
|
||||
|
||||
return [...new Set(issues)]
|
||||
}
|
||||
|
||||
export function mapIssueToAdvice(issue) {
|
||||
const text = String(issue || '').trim()
|
||||
if (!text) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (text === '费用明细不能为空') {
|
||||
return '先新增至少 1 条费用明细,再补充金额、用途和附件。'
|
||||
}
|
||||
if (text === '申请人未完善') {
|
||||
return '补充申请人信息,确保审批单据归属明确。'
|
||||
}
|
||||
if (text === '所属部门未完善') {
|
||||
return '补充所属部门,便于财务和审批人识别成本归属。'
|
||||
}
|
||||
if (text === '报销类型未完善') {
|
||||
return '选择报销类型,明确本次费用归类。'
|
||||
}
|
||||
if (text === '报销事由未完善') {
|
||||
return '补充报销事由,说明本次费用用途。'
|
||||
}
|
||||
if (text === '业务地点未完善') {
|
||||
return '补充业务地点,方便审核业务发生场景。'
|
||||
}
|
||||
if (text === '发生时间未完善') {
|
||||
return '补充费用发生时间,确保单据时间完整。'
|
||||
}
|
||||
if (text === '报销金额未完善') {
|
||||
return '补充报销金额,并与费用明细金额保持一致。'
|
||||
}
|
||||
|
||||
const itemMatch = text.match(/^费用明细第\s*(\d+)\s*条(.+)$/)
|
||||
if (!itemMatch) {
|
||||
return text
|
||||
}
|
||||
|
||||
const [, indexText, fieldText] = itemMatch
|
||||
const labelPrefix = `完善第 ${indexText} 条费用明细`
|
||||
if (fieldText === '缺少日期') {
|
||||
return `${labelPrefix}的发生日期。`
|
||||
}
|
||||
if (fieldText === '缺少费用项目') {
|
||||
return `${labelPrefix}的费用项目。`
|
||||
}
|
||||
if (fieldText === '缺少说明') {
|
||||
return `${labelPrefix}的用途说明。`
|
||||
}
|
||||
if (fieldText === '行程说明格式错误') {
|
||||
return `${labelPrefix}的行程说明,格式应为“起始地-目的地”。`
|
||||
}
|
||||
if (fieldText === '缺少地点') {
|
||||
return `${labelPrefix}的业务地点。`
|
||||
}
|
||||
if (fieldText === '缺少金额') {
|
||||
return `${labelPrefix}的金额。`
|
||||
}
|
||||
if (fieldText === '缺少票据标识') {
|
||||
return `为第 ${indexText} 条费用明细上传或关联票据附件。`
|
||||
}
|
||||
|
||||
return `${labelPrefix}。`
|
||||
}
|
||||
@@ -30,6 +30,62 @@ function normalizeTone(value) {
|
||||
return 'medium'
|
||||
}
|
||||
|
||||
export function normalizeRiskTone(value) {
|
||||
return normalizeTone(value)
|
||||
}
|
||||
|
||||
export function resolveRiskTagTone(tag) {
|
||||
const normalized = normalizeText(tag).toLowerCase()
|
||||
if (normalized === '#high_risk') return 'high'
|
||||
if (normalized === '#middle_risk') return 'medium'
|
||||
if (normalized === '#low_risk') return 'low'
|
||||
if (normalized === '#hotel') return 'hotel'
|
||||
if (normalized === '#traffic') return 'traffic'
|
||||
return 'neutral'
|
||||
}
|
||||
|
||||
export function extractRiskTagsFromText(text) {
|
||||
const matches = normalizeText(text).match(/#[A-Za-z_]+/g) || []
|
||||
return [...new Set(matches.map((tag) => tag.toLowerCase()))]
|
||||
}
|
||||
|
||||
export function resolveRiskTags(card = {}) {
|
||||
const tags = []
|
||||
const tone = normalizeTone(card.tone || card.severity)
|
||||
if (tone === 'high') {
|
||||
tags.push('#high_risk')
|
||||
} else if (tone === 'medium') {
|
||||
tags.push('#middle_risk')
|
||||
} else if (tone === 'low') {
|
||||
tags.push('#low_risk')
|
||||
}
|
||||
|
||||
const text = [
|
||||
card.label,
|
||||
card.title,
|
||||
card.risk,
|
||||
card.summary,
|
||||
card.suggestion,
|
||||
card.itemType,
|
||||
card.documentType
|
||||
].map((item) => normalizeText(item).toLowerCase()).join(' ')
|
||||
if (/住宿|酒店|宾馆|hotel/.test(text)) {
|
||||
tags.push('#hotel')
|
||||
}
|
||||
if (/交通|火车|高铁|机票|航班|出租车|网约车|乘车|车票|train|flight|taxi|traffic|transport/.test(text)) {
|
||||
tags.push('#traffic')
|
||||
}
|
||||
|
||||
return [...new Set(tags)]
|
||||
}
|
||||
|
||||
function withRiskTags(card) {
|
||||
return {
|
||||
...card,
|
||||
tags: resolveRiskTags(card)
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDocumentTypeLabel(value) {
|
||||
return DOCUMENT_TYPE_LABELS[normalizeText(value)] || DOCUMENT_TYPE_LABELS.other
|
||||
}
|
||||
@@ -109,7 +165,7 @@ function buildRiskCardFromPoint({ item, index, point, pointIndex, insight, analy
|
||||
const tone = normalizeTone(analysis?.severity)
|
||||
const label = normalizeText(analysis?.label) || (tone === 'high' ? '高风险' : '中风险')
|
||||
|
||||
return {
|
||||
return withRiskTags({
|
||||
id: `${normalizeText(item?.id) || `expense-${index}`}-${pointIndex}`,
|
||||
tone,
|
||||
label,
|
||||
@@ -117,8 +173,10 @@ function buildRiskCardFromPoint({ item, index, point, pointIndex, insight, analy
|
||||
risk: normalizeText(point) || normalizeText(analysis?.summary) || '附件存在待核对风险。',
|
||||
summary: normalizeText(analysis?.summary),
|
||||
ruleBasis: insight?.ruleBasis?.length ? insight.ruleBasis : ['系统根据附件识别结果与费用项目规则进行比对。'],
|
||||
suggestion: buildCardSuggestion(analysis, insight)
|
||||
}
|
||||
suggestion: buildCardSuggestion(analysis, insight),
|
||||
itemType: normalizeText(item?.itemType),
|
||||
documentType: normalizeText(insight?.documentTypeLabel)
|
||||
})
|
||||
}
|
||||
|
||||
function parseReturnCount(flag) {
|
||||
@@ -170,7 +228,7 @@ function buildManualReturnRiskCard(flag) {
|
||||
...riskPoints.map((item) => `退回风险点:${item}。`)
|
||||
])
|
||||
|
||||
return {
|
||||
return withRiskTags({
|
||||
id: `manual-return-${returnCount || 'latest'}`,
|
||||
tone: 'medium',
|
||||
label: '退回原因',
|
||||
@@ -179,7 +237,7 @@ function buildManualReturnRiskCard(flag) {
|
||||
summary: normalizeText(flag.reason),
|
||||
ruleBasis: ruleBasis.length ? ruleBasis : ['审批人已退回该单据。'],
|
||||
suggestion: '请按退回原因补充材料、修正明细或完善说明后重新提交。'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function buildAttachmentRiskCards({
|
||||
@@ -220,7 +278,7 @@ export function buildAttachmentRiskCards({
|
||||
if (!flag || typeof flag !== 'object') {
|
||||
const risk = normalizeText(flag)
|
||||
return risk
|
||||
? [{
|
||||
? [withRiskTags({
|
||||
id: `claim-risk-${index}`,
|
||||
tone: 'medium',
|
||||
label: '单据风险',
|
||||
@@ -229,7 +287,7 @@ export function buildAttachmentRiskCards({
|
||||
summary: '',
|
||||
ruleBasis: ['系统预审规则命中该风险提示。'],
|
||||
suggestion: '请结合业务背景补充说明或调整单据后再提交。'
|
||||
}]
|
||||
})]
|
||||
: []
|
||||
}
|
||||
|
||||
@@ -251,7 +309,7 @@ export function buildAttachmentRiskCards({
|
||||
'系统预审规则命中该风险提示。'
|
||||
])
|
||||
|
||||
return risks.map((risk, pointIndex) => ({
|
||||
return risks.map((risk, pointIndex) => withRiskTags({
|
||||
id: `claim-risk-${index}-${pointIndex}`,
|
||||
tone,
|
||||
label: normalizeText(flag.label) || (tone === 'high' ? '高风险' : '中风险'),
|
||||
|
||||
305
web/src/views/scripts/useTravelReimbursementAttachments.js
Normal file
305
web/src/views/scripts/useTravelReimbursementAttachments.js
Normal file
@@ -0,0 +1,305 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
function normalizeAttachmentMatchName(value) {
|
||||
const fileName = String(value || '')
|
||||
.trim()
|
||||
.split(/[\\/]/)
|
||||
.filter(Boolean)
|
||||
.pop() || ''
|
||||
return fileName
|
||||
.toLowerCase()
|
||||
.replace(/[^\w.\-\u4e00-\u9fff]+/g, '_')
|
||||
.replace(/^[_\.]+|[_\.]+$/g, '')
|
||||
}
|
||||
|
||||
export function useTravelReimbursementAttachments({
|
||||
isKnowledgeSession,
|
||||
reviewFilePreviews,
|
||||
linkedRequest,
|
||||
draftClaimId,
|
||||
activeReviewPayload,
|
||||
reviewInlinePendingFiles,
|
||||
reviewInlineForm,
|
||||
reviewInlineEditorKey,
|
||||
composerUploadIntent,
|
||||
submitting,
|
||||
reviewActionBusy,
|
||||
toast,
|
||||
fileInputRef,
|
||||
fetchExpenseClaimDetail,
|
||||
fetchExpenseClaimItemAttachmentMeta,
|
||||
fetchExpenseClaimAttachmentAsset,
|
||||
uploadExpenseClaimItemAttachment,
|
||||
extractReviewAttachmentNames,
|
||||
mergeFilesWithLimit,
|
||||
mergeFilePreviews,
|
||||
resolveAttachmentPreviewKind,
|
||||
resolveDocumentPreview,
|
||||
buildFilePreviews,
|
||||
buildFileIdentity,
|
||||
MAX_ATTACHMENTS,
|
||||
VISIBLE_ATTACHMENT_CHIPS,
|
||||
clearInlineReviewFieldError
|
||||
}) {
|
||||
const fileInputMode = ref('composer')
|
||||
const attachedFiles = ref([])
|
||||
const composerFilesExpanded = ref(false)
|
||||
const previewRegistry = []
|
||||
const restoredDraftPreviewClaims = new Set()
|
||||
|
||||
const visibleAttachedFiles = computed(() => attachedFiles.value.slice(0, VISIBLE_ATTACHMENT_CHIPS))
|
||||
const hiddenAttachedFileCount = computed(() => Math.max(0, attachedFiles.value.length - VISIBLE_ATTACHMENT_CHIPS))
|
||||
|
||||
function rememberFilePreviews(filePreviews) {
|
||||
reviewFilePreviews.value = mergeFilePreviews(reviewFilePreviews.value, filePreviews)
|
||||
}
|
||||
|
||||
function trackPreviewObjectUrl(url) {
|
||||
if (!url || !String(url).startsWith('blob:')) {
|
||||
return
|
||||
}
|
||||
previewRegistry.push(url)
|
||||
}
|
||||
|
||||
function buildComposerFilePreviews(files) {
|
||||
const filePreviews = buildFilePreviews(files, previewRegistry)
|
||||
rememberFilePreviews(filePreviews)
|
||||
return filePreviews
|
||||
}
|
||||
|
||||
function resolveActiveClaimId() {
|
||||
return String(draftClaimId.value || linkedRequest.value?.claimId || '').trim()
|
||||
}
|
||||
|
||||
async function buildPersistedAttachmentPreview(metadata) {
|
||||
const filename = String(metadata?.file_name || '').trim()
|
||||
const kind = resolveAttachmentPreviewKind(metadata)
|
||||
const previewPath = String(metadata?.preview_url || '').trim()
|
||||
if (!filename || !kind || !previewPath) {
|
||||
return null
|
||||
}
|
||||
|
||||
const blob = await fetchExpenseClaimAttachmentAsset(previewPath)
|
||||
const url = URL.createObjectURL(blob)
|
||||
trackPreviewObjectUrl(url)
|
||||
return {
|
||||
filename,
|
||||
kind,
|
||||
url
|
||||
}
|
||||
}
|
||||
|
||||
async function restorePersistedDraftAttachmentPreviews(claimId, options = {}) {
|
||||
const normalizedClaimId = String(claimId || '').trim()
|
||||
if (!normalizedClaimId || isKnowledgeSession.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const force = Boolean(options.force)
|
||||
if (!force && restoredDraftPreviewClaims.has(normalizedClaimId)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const claim = await fetchExpenseClaimDetail(normalizedClaimId)
|
||||
const items = Array.isArray(claim?.items) ? claim.items : []
|
||||
const previews = []
|
||||
|
||||
for (const item of items) {
|
||||
const itemId = String(item?.id || '').trim()
|
||||
if (!itemId) continue
|
||||
|
||||
let metadata = null
|
||||
try {
|
||||
metadata = await fetchExpenseClaimItemAttachmentMeta(normalizedClaimId, itemId)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
const filename = String(metadata?.file_name || '').trim()
|
||||
if (!metadata?.previewable || !filename || resolveDocumentPreview(reviewFilePreviews.value, filename)) {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const preview = await buildPersistedAttachmentPreview(metadata)
|
||||
if (preview) {
|
||||
previews.push(preview)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to load persisted attachment preview:', error)
|
||||
}
|
||||
}
|
||||
|
||||
if (previews.length) {
|
||||
rememberFilePreviews(previews)
|
||||
}
|
||||
restoredDraftPreviewClaims.add(normalizedClaimId)
|
||||
} catch (error) {
|
||||
console.warn('Failed to restore persisted draft attachment previews:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function syncComposerFilesToDraft(claimId, files) {
|
||||
const normalizedClaimId = String(claimId || '').trim()
|
||||
if (!normalizedClaimId || !Array.isArray(files) || !files.length || isKnowledgeSession.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const claim = await fetchExpenseClaimDetail(normalizedClaimId)
|
||||
const items = Array.isArray(claim?.items) ? claim.items : []
|
||||
const exactMatchBuckets = new Map()
|
||||
const normalizedMatchBuckets = new Map()
|
||||
const placeholderQueue = []
|
||||
const usedItemIds = new Set()
|
||||
|
||||
for (const item of items) {
|
||||
const itemId = String(item?.id || '').trim()
|
||||
const invoiceId = String(item?.invoiceId || item?.invoice_id || '').trim()
|
||||
if (!itemId) continue
|
||||
if (invoiceId && !invoiceId.includes('/')) {
|
||||
placeholderQueue.push(item)
|
||||
}
|
||||
if (!invoiceId) continue
|
||||
const bucket = exactMatchBuckets.get(invoiceId) || []
|
||||
bucket.push(item)
|
||||
exactMatchBuckets.set(invoiceId, bucket)
|
||||
|
||||
const normalizedInvoiceName = normalizeAttachmentMatchName(invoiceId)
|
||||
if (normalizedInvoiceName) {
|
||||
const normalizedBucket = normalizedMatchBuckets.get(normalizedInvoiceName) || []
|
||||
normalizedBucket.push(item)
|
||||
normalizedMatchBuckets.set(normalizedInvoiceName, normalizedBucket)
|
||||
}
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const exactBucket = exactMatchBuckets.get(file.name) || []
|
||||
const nextExactMatch = exactBucket.find((item) => !usedItemIds.has(String(item?.id || '').trim()))
|
||||
const normalizedBucket = normalizedMatchBuckets.get(normalizeAttachmentMatchName(file.name)) || []
|
||||
const nextNormalizedMatch = normalizedBucket.find((item) => !usedItemIds.has(String(item?.id || '').trim()))
|
||||
const fallbackMatch = placeholderQueue.find((item) => !usedItemIds.has(String(item?.id || '').trim()))
|
||||
const targetItem = nextExactMatch || nextNormalizedMatch || fallbackMatch
|
||||
const targetItemId = String(targetItem?.id || '').trim()
|
||||
if (!targetItemId) {
|
||||
continue
|
||||
}
|
||||
|
||||
usedItemIds.add(targetItemId)
|
||||
await uploadExpenseClaimItemAttachment(normalizedClaimId, targetItemId, file)
|
||||
}
|
||||
|
||||
await restorePersistedDraftAttachmentPreviews(normalizedClaimId, { force: true })
|
||||
}
|
||||
|
||||
function triggerFileUpload(mode = 'composer') {
|
||||
if (submitting.value || reviewActionBusy.value) return
|
||||
fileInputMode.value = mode
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
|
||||
function handleFilesChange(event) {
|
||||
const files = Array.from(event.target.files ?? [])
|
||||
|
||||
if (fileInputMode.value === 'inline-review' && activeReviewPayload.value) {
|
||||
const existingNames = extractReviewAttachmentNames(activeReviewPayload.value)
|
||||
const remainingSlots = Math.max(MAX_ATTACHMENTS - existingNames.length, 0)
|
||||
const mergeResult = mergeFilesWithLimit(reviewInlinePendingFiles.value, files, remainingSlots)
|
||||
|
||||
if (!remainingSlots && files.length) {
|
||||
toast(`一次最多上传 ${MAX_ATTACHMENTS} 份附件,当前票据数量已到上限。`)
|
||||
} else if (mergeResult.overflowCount > 0) {
|
||||
toast(`一次最多上传 ${MAX_ATTACHMENTS} 份附件,新增票据已按上限截断。`)
|
||||
}
|
||||
|
||||
reviewInlinePendingFiles.value = mergeResult.files
|
||||
const allAttachmentNames = [...existingNames, ...mergeResult.files.map((file) => file.name)]
|
||||
reviewInlineForm.value = {
|
||||
...reviewInlineForm.value,
|
||||
attachment_names: allAttachmentNames.join('、'),
|
||||
attachment_count: allAttachmentNames.length,
|
||||
pending_attachment_count: mergeResult.files.length
|
||||
}
|
||||
clearInlineReviewFieldError('attachments')
|
||||
reviewInlineEditorKey.value = ''
|
||||
} else {
|
||||
if (isKnowledgeSession.value) {
|
||||
toast('财务知识问答暂不支持上传附件。')
|
||||
fileInputMode.value = 'composer'
|
||||
if (fileInputRef.value) {
|
||||
fileInputRef.value.value = ''
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const mergeResult = mergeFilesWithLimit(attachedFiles.value, files, MAX_ATTACHMENTS)
|
||||
attachedFiles.value = mergeResult.files
|
||||
if (fileInputMode.value === 'composer-continue' && files.length) {
|
||||
composerUploadIntent.value = 'continue_existing'
|
||||
}
|
||||
if (mergeResult.overflowCount > 0) {
|
||||
toast(`一次最多上传 ${MAX_ATTACHMENTS} 份附件,已保留前 ${MAX_ATTACHMENTS} 份。`)
|
||||
}
|
||||
if (attachedFiles.value.length <= VISIBLE_ATTACHMENT_CHIPS) {
|
||||
composerFilesExpanded.value = false
|
||||
}
|
||||
}
|
||||
|
||||
fileInputMode.value = 'composer'
|
||||
if (fileInputRef.value) {
|
||||
fileInputRef.value.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAttachedFilesExpanded() {
|
||||
composerFilesExpanded.value = !composerFilesExpanded.value
|
||||
}
|
||||
|
||||
function removeAttachedFile(targetFile) {
|
||||
const fileKey = buildFileIdentity(targetFile)
|
||||
attachedFiles.value = attachedFiles.value.filter((file) => buildFileIdentity(file) !== fileKey)
|
||||
if (attachedFiles.value.length <= VISIBLE_ATTACHMENT_CHIPS) {
|
||||
composerFilesExpanded.value = false
|
||||
}
|
||||
if (!attachedFiles.value.length) {
|
||||
composerUploadIntent.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function clearAttachedFiles() {
|
||||
attachedFiles.value = []
|
||||
composerFilesExpanded.value = false
|
||||
composerUploadIntent.value = ''
|
||||
if (fileInputRef.value) {
|
||||
fileInputRef.value.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function stopAttachmentRuntime() {
|
||||
for (const url of previewRegistry) {
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
previewRegistry.length = 0
|
||||
}
|
||||
|
||||
return {
|
||||
fileInputMode,
|
||||
attachedFiles,
|
||||
composerFilesExpanded,
|
||||
visibleAttachedFiles,
|
||||
hiddenAttachedFileCount,
|
||||
rememberFilePreviews,
|
||||
trackPreviewObjectUrl,
|
||||
buildComposerFilePreviews,
|
||||
resolveActiveClaimId,
|
||||
buildPersistedAttachmentPreview,
|
||||
restorePersistedDraftAttachmentPreviews,
|
||||
syncComposerFilesToDraft,
|
||||
triggerFileUpload,
|
||||
handleFilesChange,
|
||||
toggleAttachedFilesExpanded,
|
||||
removeAttachedFile,
|
||||
clearAttachedFiles,
|
||||
stopAttachmentRuntime
|
||||
}
|
||||
}
|
||||
396
web/src/views/scripts/useTravelReimbursementComposerTools.js
Normal file
396
web/src/views/scripts/useTravelReimbursementComposerTools.js
Normal file
@@ -0,0 +1,396 @@
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
export function useTravelReimbursementComposerTools({
|
||||
currentUser,
|
||||
activeReviewPayload,
|
||||
reviewInlineForm,
|
||||
latestReviewMessage,
|
||||
currentInsight,
|
||||
messages,
|
||||
composerDraft,
|
||||
composerTextareaRef,
|
||||
adjustComposerTextareaHeight,
|
||||
scrollToBottom,
|
||||
toast,
|
||||
calculateTravelReimbursement,
|
||||
createMessage,
|
||||
buildReviewSlotMap,
|
||||
isValidIsoDateString,
|
||||
buildLocallySyncedReviewPayload,
|
||||
formatDateInputValue
|
||||
}) {
|
||||
const composerDatePickerOpen = ref(false)
|
||||
const composerDateMode = ref('single')
|
||||
const composerSingleDate = ref(formatDateInputValue())
|
||||
const composerRangeStartDate = ref(formatDateInputValue())
|
||||
const composerRangeEndDate = ref(formatDateInputValue())
|
||||
const composerBusinessTimeTags = ref([])
|
||||
const composerBusinessTimeDraftTouched = ref(false)
|
||||
const travelCalculatorOpen = ref(false)
|
||||
const travelCalculatorBusy = ref(false)
|
||||
const travelCalculatorError = ref('')
|
||||
const travelCalculatorResult = ref(null)
|
||||
const travelCalculatorForm = ref({
|
||||
days: '1',
|
||||
location: ''
|
||||
})
|
||||
const composerCanApplyDateSelection = computed(() => {
|
||||
if (composerDateMode.value === 'single') {
|
||||
return Boolean(composerSingleDate.value)
|
||||
}
|
||||
return Boolean(
|
||||
composerRangeStartDate.value
|
||||
&& composerRangeEndDate.value
|
||||
&& composerRangeStartDate.value <= composerRangeEndDate.value
|
||||
)
|
||||
})
|
||||
const travelCalculatorCanSubmit = computed(() =>
|
||||
!travelCalculatorBusy.value
|
||||
&& Number(travelCalculatorForm.value.days) >= 1
|
||||
&& Boolean(String(travelCalculatorForm.value.location || '').trim())
|
||||
)
|
||||
function buildComposerBusinessTimeLabel() {
|
||||
if (composerDateMode.value === 'single') {
|
||||
return `业务发生时间:${composerSingleDate.value}`
|
||||
}
|
||||
if (composerRangeStartDate.value === composerRangeEndDate.value) {
|
||||
return `业务发生时间:${composerRangeStartDate.value}`
|
||||
}
|
||||
return `业务发生时间:${composerRangeStartDate.value} 至 ${composerRangeEndDate.value}`
|
||||
}
|
||||
|
||||
function hasComposerBusinessTimeSelection() {
|
||||
return composerBusinessTimeTags.value.length > 0 || composerBusinessTimeDraftTouched.value
|
||||
}
|
||||
|
||||
function buildComposerBusinessTimeContext() {
|
||||
if (!hasComposerBusinessTimeSelection()) {
|
||||
return null
|
||||
}
|
||||
|
||||
const mode = composerDateMode.value === 'range' ? 'range' : 'single'
|
||||
const startDate = String(mode === 'range' ? composerRangeStartDate.value : composerSingleDate.value).trim()
|
||||
const endDate = String(mode === 'range' ? composerRangeEndDate.value : startDate).trim()
|
||||
if (!isValidIsoDateString(startDate) || !isValidIsoDateString(endDate) || startDate > endDate) {
|
||||
return null
|
||||
}
|
||||
|
||||
const displayValue = mode === 'range' && startDate !== endDate
|
||||
? `${startDate} 至 ${endDate}`
|
||||
: startDate
|
||||
return {
|
||||
mode,
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
occurred_date: startDate,
|
||||
time_range: displayValue,
|
||||
business_time: displayValue,
|
||||
time_range_raw: buildComposerBusinessTimeLabel()
|
||||
}
|
||||
}
|
||||
|
||||
function mergeBusinessTimeIntoExtraContext(extraContext, businessTimeContext) {
|
||||
if (!businessTimeContext) {
|
||||
return extraContext
|
||||
}
|
||||
|
||||
const baseReviewFormValues =
|
||||
extraContext.review_form_values && typeof extraContext.review_form_values === 'object'
|
||||
? extraContext.review_form_values
|
||||
: {}
|
||||
|
||||
return {
|
||||
...extraContext,
|
||||
occurred_date: businessTimeContext.occurred_date,
|
||||
business_time: businessTimeContext.business_time,
|
||||
business_time_context: {
|
||||
mode: businessTimeContext.mode,
|
||||
start_date: businessTimeContext.start_date,
|
||||
end_date: businessTimeContext.end_date,
|
||||
display_value: businessTimeContext.business_time
|
||||
},
|
||||
review_form_values: {
|
||||
...baseReviewFormValues,
|
||||
occurred_date: businessTimeContext.occurred_date,
|
||||
time_range: businessTimeContext.time_range,
|
||||
business_time: businessTimeContext.business_time,
|
||||
time_range_raw: businessTimeContext.time_range_raw
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function syncComposerBusinessTimeToReviewCard(businessTimeContext) {
|
||||
if (!businessTimeContext || !activeReviewPayload.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextInlineState = {
|
||||
...reviewInlineForm.value,
|
||||
occurred_date: businessTimeContext.occurred_date
|
||||
}
|
||||
const nextReviewPayload = buildLocallySyncedReviewPayload(activeReviewPayload.value, nextInlineState)
|
||||
reviewInlineForm.value = nextInlineState
|
||||
if (latestReviewMessage.value) {
|
||||
latestReviewMessage.value.reviewPayload = nextReviewPayload
|
||||
}
|
||||
if (currentInsight.value?.agent) {
|
||||
currentInsight.value = {
|
||||
...currentInsight.value,
|
||||
agent: {
|
||||
...currentInsight.value.agent,
|
||||
reviewPayload: nextReviewPayload
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveComposerSubmitText(explicitRawText) {
|
||||
const draftPart = String(explicitRawText ?? composerDraft.value).trim()
|
||||
const tagPart = composerBusinessTimeTags.value.map((item) => item.label).join(',')
|
||||
if (!tagPart) {
|
||||
return draftPart
|
||||
}
|
||||
if (!draftPart) {
|
||||
return tagPart
|
||||
}
|
||||
return `${tagPart},${draftPart}`
|
||||
}
|
||||
|
||||
function toggleComposerDatePicker() {
|
||||
composerDatePickerOpen.value = !composerDatePickerOpen.value
|
||||
if (composerDatePickerOpen.value) {
|
||||
travelCalculatorOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function closeComposerDatePicker() {
|
||||
composerDatePickerOpen.value = false
|
||||
}
|
||||
|
||||
function setComposerDateMode(mode) {
|
||||
composerDateMode.value = mode === 'range' ? 'range' : 'single'
|
||||
}
|
||||
|
||||
function handleComposerDateInputChange() {
|
||||
composerBusinessTimeDraftTouched.value = true
|
||||
syncComposerBusinessTimeToReviewCard(buildComposerBusinessTimeContext())
|
||||
}
|
||||
|
||||
function removeComposerBusinessTimeTag(tagId) {
|
||||
composerBusinessTimeTags.value = composerBusinessTimeTags.value.filter((item) => item.id !== tagId)
|
||||
if (!composerBusinessTimeTags.value.length) {
|
||||
composerBusinessTimeDraftTouched.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleComposerDatePickerOutside(event) {
|
||||
if (!composerDatePickerOpen.value && !travelCalculatorOpen.value) {
|
||||
return
|
||||
}
|
||||
if (event.target instanceof Element && event.target.closest('.composer-date-anchor')) {
|
||||
return
|
||||
}
|
||||
if (event.target instanceof Element && event.target.closest('.travel-calculator-anchor')) {
|
||||
return
|
||||
}
|
||||
if (composerDatePickerOpen.value) {
|
||||
composerDatePickerOpen.value = false
|
||||
}
|
||||
if (travelCalculatorOpen.value && !travelCalculatorBusy.value) {
|
||||
travelCalculatorOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function applyComposerDateSelection() {
|
||||
if (!composerCanApplyDateSelection.value) {
|
||||
return
|
||||
}
|
||||
|
||||
composerBusinessTimeDraftTouched.value = true
|
||||
composerBusinessTimeTags.value = [
|
||||
{
|
||||
id: `biz-time-${Date.now()}`,
|
||||
label: buildComposerBusinessTimeLabel()
|
||||
}
|
||||
]
|
||||
syncComposerBusinessTimeToReviewCard(buildComposerBusinessTimeContext())
|
||||
composerDatePickerOpen.value = false
|
||||
await nextTick()
|
||||
adjustComposerTextareaHeight()
|
||||
composerTextareaRef.value?.focus()
|
||||
}
|
||||
|
||||
function resolveTravelCalculatorInitialDays() {
|
||||
const businessTimeContext = buildComposerBusinessTimeContext()
|
||||
if (!businessTimeContext) {
|
||||
return 1
|
||||
}
|
||||
const startDate = businessTimeContext.start_date
|
||||
const endDate = businessTimeContext.end_date || startDate
|
||||
if (!isValidIsoDateString(startDate) || !isValidIsoDateString(endDate) || startDate > endDate) {
|
||||
return 1
|
||||
}
|
||||
const startAt = Date.parse(`${startDate}T00:00:00Z`)
|
||||
const endAt = Date.parse(`${endDate}T00:00:00Z`)
|
||||
if (!Number.isFinite(startAt) || !Number.isFinite(endAt)) {
|
||||
return 1
|
||||
}
|
||||
return Math.max(1, Math.round((endAt - startAt) / 86400000) + 1)
|
||||
}
|
||||
|
||||
function resolveTravelCalculatorInitialLocation() {
|
||||
const slotMap = buildReviewSlotMap(activeReviewPayload.value)
|
||||
const candidates = [
|
||||
reviewInlineForm.value.location,
|
||||
slotMap.business_location?.normalized_value,
|
||||
slotMap.business_location?.value,
|
||||
slotMap.location?.normalized_value,
|
||||
slotMap.location?.value,
|
||||
currentUser.value?.location
|
||||
]
|
||||
return String(candidates.find((item) => String(item || '').trim()) || '').trim()
|
||||
}
|
||||
|
||||
function openTravelCalculator() {
|
||||
closeComposerDatePicker()
|
||||
travelCalculatorError.value = ''
|
||||
travelCalculatorResult.value = null
|
||||
travelCalculatorForm.value = {
|
||||
days: String(resolveTravelCalculatorInitialDays()),
|
||||
location: resolveTravelCalculatorInitialLocation()
|
||||
}
|
||||
travelCalculatorOpen.value = true
|
||||
}
|
||||
|
||||
function toggleTravelCalculator() {
|
||||
if (travelCalculatorOpen.value) {
|
||||
closeTravelCalculator()
|
||||
return
|
||||
}
|
||||
openTravelCalculator()
|
||||
}
|
||||
|
||||
function closeTravelCalculator() {
|
||||
if (travelCalculatorBusy.value) {
|
||||
return
|
||||
}
|
||||
travelCalculatorOpen.value = false
|
||||
}
|
||||
|
||||
function formatTravelCalculatorMoney(value) {
|
||||
const amount = Number(value)
|
||||
if (!Number.isFinite(amount)) {
|
||||
return String(value || '0')
|
||||
}
|
||||
return amount.toFixed(2)
|
||||
}
|
||||
|
||||
function buildTravelCalculatorResultText(result) {
|
||||
const days = Number(result?.days) || 1
|
||||
const location = String(result?.location || '').trim() || '未填写地点'
|
||||
const matchedCity = String(result?.matched_city || location).trim()
|
||||
const grade = String(result?.grade || '').trim() || '当前职级'
|
||||
const gradeBandLabel = String(result?.grade_band_label || result?.grade_band || '').trim() || '对应档位'
|
||||
const allowanceRegion = String(result?.allowance_region || '').trim() || '默认区域'
|
||||
const ruleName = String(result?.rule_name || '').trim() || '公司差旅费报销规则'
|
||||
const ruleVersion = String(result?.rule_version || '').trim()
|
||||
const hotelRate = formatTravelCalculatorMoney(result?.hotel_rate)
|
||||
const hotelAmount = formatTravelCalculatorMoney(result?.hotel_amount)
|
||||
const mealRate = formatTravelCalculatorMoney(result?.meal_allowance_rate)
|
||||
const basicRate = formatTravelCalculatorMoney(result?.basic_allowance_rate)
|
||||
const allowanceRate = formatTravelCalculatorMoney(result?.total_allowance_rate)
|
||||
const allowanceAmount = formatTravelCalculatorMoney(result?.allowance_amount)
|
||||
const totalAmount = formatTravelCalculatorMoney(result?.total_amount)
|
||||
const ruleVersionText = ruleVersion ? `(${ruleVersion})` : ''
|
||||
const user = currentUser.value || {}
|
||||
const displayName = String(user.name || user.display_name || user.username || '').trim()
|
||||
const greeting = displayName ? `您好,${displayName},` : '您好,'
|
||||
|
||||
return [
|
||||
`${greeting}根据您输入的地点和天数,我匹配到您要出差的地区为:**${matchedCity}**,出差天数为:**${days} 天**,我根据公司的报销文件给您预估金额如下:`,
|
||||
'',
|
||||
`**参考可报销合计:${totalAmount} 元**`,
|
||||
'',
|
||||
'| 项目 | 标准口径 | 天数 | 小计 |',
|
||||
'| --- | --- | ---: | ---: |',
|
||||
`| 住宿费 | ${matchedCity} / ${grade}(${gradeBandLabel})标准:${hotelRate} 元/天 | ${days} | ${hotelAmount} 元 |`,
|
||||
`| 出差补贴 | ${allowanceRegion}:伙食 ${mealRate} 元 + 基本 ${basicRate} 元 = ${allowanceRate} 元/天 | ${days} | ${allowanceAmount} 元 |`,
|
||||
'',
|
||||
'**计算过程**',
|
||||
`1. 住宿费:${hotelRate} × ${days} = ${hotelAmount} 元`,
|
||||
`2. 出差补贴:(${mealRate} + ${basicRate}) × ${days} = ${allowanceRate} × ${days} = ${allowanceAmount} 元`,
|
||||
`3. 合计:${hotelAmount} + ${allowanceAmount} = ${totalAmount} 元`,
|
||||
'',
|
||||
`**规则依据**:${ruleName}${ruleVersionText}。出差地点“${location}”匹配为“${matchedCity}”,当前职级“${grade}”匹配“${gradeBandLabel}”档。`,
|
||||
'',
|
||||
'这个结果是提交前的规则测算参考,最终仍以实际票据、审批意见和财务复核口径为准。'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
async function submitTravelCalculator() {
|
||||
if (!travelCalculatorCanSubmit.value) {
|
||||
travelCalculatorError.value = '请填写出差天数和地点后再计算。'
|
||||
return
|
||||
}
|
||||
|
||||
travelCalculatorBusy.value = true
|
||||
travelCalculatorError.value = ''
|
||||
try {
|
||||
const user = currentUser.value || {}
|
||||
const payload = await calculateTravelReimbursement({
|
||||
days: Math.max(1, Number.parseInt(String(travelCalculatorForm.value.days || '1'), 10) || 1),
|
||||
location: String(travelCalculatorForm.value.location || '').trim(),
|
||||
grade: String(user.grade || '').trim()
|
||||
})
|
||||
travelCalculatorResult.value = payload
|
||||
messages.value.push(createMessage('assistant', buildTravelCalculatorResultText(payload), [], {
|
||||
meta: ['差旅计算器'],
|
||||
metaTone: 'low'
|
||||
}))
|
||||
travelCalculatorOpen.value = false
|
||||
nextTick(scrollToBottom)
|
||||
} catch (error) {
|
||||
travelCalculatorError.value = error?.message || '差旅金额测算失败,请稍后重试。'
|
||||
} finally {
|
||||
travelCalculatorBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
composerDatePickerOpen,
|
||||
composerDateMode,
|
||||
composerSingleDate,
|
||||
composerRangeStartDate,
|
||||
composerRangeEndDate,
|
||||
composerBusinessTimeTags,
|
||||
composerBusinessTimeDraftTouched,
|
||||
composerCanApplyDateSelection,
|
||||
travelCalculatorOpen,
|
||||
travelCalculatorBusy,
|
||||
travelCalculatorError,
|
||||
travelCalculatorResult,
|
||||
travelCalculatorForm,
|
||||
travelCalculatorCanSubmit,
|
||||
buildComposerBusinessTimeLabel,
|
||||
hasComposerBusinessTimeSelection,
|
||||
buildComposerBusinessTimeContext,
|
||||
mergeBusinessTimeIntoExtraContext,
|
||||
syncComposerBusinessTimeToReviewCard,
|
||||
resolveComposerSubmitText,
|
||||
toggleComposerDatePicker,
|
||||
closeComposerDatePicker,
|
||||
setComposerDateMode,
|
||||
handleComposerDateInputChange,
|
||||
removeComposerBusinessTimeTag,
|
||||
handleComposerDatePickerOutside,
|
||||
applyComposerDateSelection,
|
||||
resolveTravelCalculatorInitialDays,
|
||||
resolveTravelCalculatorInitialLocation,
|
||||
openTravelCalculator,
|
||||
toggleTravelCalculator,
|
||||
closeTravelCalculator,
|
||||
formatTravelCalculatorMoney,
|
||||
buildTravelCalculatorResultText,
|
||||
submitTravelCalculator
|
||||
}
|
||||
}
|
||||
704
web/src/views/scripts/useTravelReimbursementFlow.js
Normal file
704
web/src/views/scripts/useTravelReimbursementFlow.js
Normal file
@@ -0,0 +1,704 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
function formatFlowDuration(ms) {
|
||||
const numericValue = Number(ms)
|
||||
if (!Number.isFinite(numericValue) || numericValue < 0) {
|
||||
return '--'
|
||||
}
|
||||
if (numericValue < 1000) {
|
||||
return `${Math.max(0.1, numericValue / 1000).toFixed(1)}s`
|
||||
}
|
||||
if (numericValue < 10000) {
|
||||
return `${(numericValue / 1000).toFixed(1)}s`
|
||||
}
|
||||
return `${Math.round(numericValue / 1000)}s`
|
||||
}
|
||||
|
||||
function parseFlowTimestamp(value) {
|
||||
const timestamp = new Date(value || '').getTime()
|
||||
return Number.isFinite(timestamp) ? timestamp : 0
|
||||
}
|
||||
|
||||
function resolveSemanticPhaseDurations(run) {
|
||||
const runStart = parseFlowTimestamp(run?.started_at)
|
||||
const toolCalls = Array.isArray(run?.tool_calls) ? run.tool_calls : []
|
||||
const firstToolStartedAt = toolCalls
|
||||
.map((item) => parseFlowTimestamp(item?.created_at))
|
||||
.filter((value) => value > 0)
|
||||
.sort((left, right) => left - right)[0] || 0
|
||||
const runFinishedAt = parseFlowTimestamp(run?.finished_at)
|
||||
const semanticFinishedAt = firstToolStartedAt || runFinishedAt
|
||||
|
||||
if (!runStart || !semanticFinishedAt || semanticFinishedAt <= runStart) {
|
||||
return { intentMs: null, extractionMs: null }
|
||||
}
|
||||
|
||||
const totalMs = semanticFinishedAt - runStart
|
||||
const intentMs = Math.max(120, Math.round(totalMs * 0.35))
|
||||
const extractionMs = Math.max(160, totalMs - intentMs)
|
||||
return {
|
||||
intentMs,
|
||||
extractionMs
|
||||
}
|
||||
}
|
||||
|
||||
function resolveToolCallDurationMs(toolCall, index, toolCalls, run) {
|
||||
const explicitDuration = Number(toolCall?.duration_ms)
|
||||
if (Number.isFinite(explicitDuration) && explicitDuration > 0) {
|
||||
return explicitDuration
|
||||
}
|
||||
|
||||
const startedAt = parseFlowTimestamp(toolCall?.created_at)
|
||||
if (!startedAt) {
|
||||
return null
|
||||
}
|
||||
|
||||
const nextStartedAt = parseFlowTimestamp(toolCalls[index + 1]?.created_at)
|
||||
const runFinishedAt = parseFlowTimestamp(run?.finished_at)
|
||||
const finishedAt = nextStartedAt > startedAt ? nextStartedAt : (runFinishedAt > startedAt ? runFinishedAt : 0)
|
||||
|
||||
if (!finishedAt || finishedAt <= startedAt) {
|
||||
return null
|
||||
}
|
||||
|
||||
return finishedAt - startedAt
|
||||
}
|
||||
|
||||
export function useTravelReimbursementFlow({
|
||||
activeSessionType,
|
||||
reviewDrawerMode,
|
||||
insightPanelCollapsed,
|
||||
isKnowledgeSession,
|
||||
fetchAgentRunDetail,
|
||||
buildLocalIntentPreview,
|
||||
buildLocalExtractionProgressMessages,
|
||||
summarizeSemanticIntentDetail,
|
||||
summarizeSemanticParseDetail,
|
||||
SCENARIO_LABELS,
|
||||
INTENT_LABELS,
|
||||
EXPENSE_TYPE_LABELS,
|
||||
FLOW_STEP_FALLBACKS,
|
||||
REVIEW_DRAWER_MODE_FLOW,
|
||||
REVIEW_DRAWER_MODE_REVIEW,
|
||||
FLOW_STEP_STATUS_PENDING,
|
||||
FLOW_STEP_STATUS_RUNNING,
|
||||
FLOW_STEP_STATUS_COMPLETED,
|
||||
FLOW_STEP_STATUS_FAILED
|
||||
}) {
|
||||
const flowRunId = ref('')
|
||||
const flowStartedAt = ref(0)
|
||||
const flowFinishedAt = ref(0)
|
||||
const flowSteps = ref([])
|
||||
const flowRefreshBusy = ref(false)
|
||||
const flowTick = ref(Date.now())
|
||||
let flowTickTimer = 0
|
||||
const flowSimulationTimers = []
|
||||
|
||||
const completedFlowStepCount = computed(
|
||||
() => flowSteps.value.filter((step) => step.status === FLOW_STEP_STATUS_COMPLETED).length
|
||||
)
|
||||
const runningFlowStep = computed(
|
||||
() => flowSteps.value.find((step) => step.status === FLOW_STEP_STATUS_RUNNING) || null
|
||||
)
|
||||
const flowOverallStatusTone = computed(() => {
|
||||
if (flowSteps.value.some((step) => step.status === FLOW_STEP_STATUS_FAILED)) {
|
||||
return 'failed'
|
||||
}
|
||||
if (runningFlowStep.value) {
|
||||
return 'running'
|
||||
}
|
||||
if (flowSteps.value.length && completedFlowStepCount.value === flowSteps.value.length && flowStartedAt.value) {
|
||||
return 'completed'
|
||||
}
|
||||
return 'pending'
|
||||
})
|
||||
const flowOverallStatusText = computed(() => {
|
||||
const total = flowSteps.value.length
|
||||
const completed = completedFlowStepCount.value
|
||||
if (flowOverallStatusTone.value === 'failed') {
|
||||
return `异常 ${completed}/${total}`
|
||||
}
|
||||
if (flowOverallStatusTone.value === 'completed') {
|
||||
return `已完成 ${total}/${total}`
|
||||
}
|
||||
if (flowOverallStatusTone.value === 'running') {
|
||||
return `执行中 ${completed}/${total}`
|
||||
}
|
||||
return total ? `待执行 0/${total}` : '暂无流程'
|
||||
})
|
||||
const flowTotalDurationText = computed(() => {
|
||||
if (!flowStartedAt.value) {
|
||||
return '--'
|
||||
}
|
||||
|
||||
const finishedAt = flowFinishedAt.value || (runningFlowStep.value ? flowTick.value : 0)
|
||||
if (finishedAt > flowStartedAt.value) {
|
||||
return formatFlowDuration(finishedAt - flowStartedAt.value)
|
||||
}
|
||||
|
||||
const measuredDuration = flowSteps.value.reduce((total, step) => {
|
||||
const duration = Number(step.durationMs)
|
||||
return total + (Number.isFinite(duration) && duration > 0 ? duration : 0)
|
||||
}, 0)
|
||||
return measuredDuration ? formatFlowDuration(measuredDuration) : '--'
|
||||
})
|
||||
|
||||
function startFlowTick() {
|
||||
if (flowTickTimer) {
|
||||
return
|
||||
}
|
||||
flowTickTimer = window.setInterval(() => {
|
||||
flowTick.value = Date.now()
|
||||
}, 250)
|
||||
}
|
||||
|
||||
function stopFlowRuntime() {
|
||||
if (flowTickTimer) {
|
||||
window.clearInterval(flowTickTimer)
|
||||
flowTickTimer = 0
|
||||
}
|
||||
clearFlowSimulationTimers()
|
||||
}
|
||||
|
||||
function clearFlowSimulationTimers() {
|
||||
while (flowSimulationTimers.length) {
|
||||
const timerId = flowSimulationTimers.pop()
|
||||
window.clearTimeout(timerId)
|
||||
window.clearInterval(timerId)
|
||||
}
|
||||
}
|
||||
|
||||
function resetFlowRun(options = {}) {
|
||||
clearFlowSimulationTimers()
|
||||
const shouldOpenDrawer = options.openDrawer !== false
|
||||
const startedAt = Number(options.startedAt)
|
||||
flowRunId.value = ''
|
||||
flowStartedAt.value = Number.isFinite(startedAt) && startedAt >= 0 ? startedAt : Date.now()
|
||||
flowFinishedAt.value = 0
|
||||
if (shouldOpenDrawer) {
|
||||
reviewDrawerMode.value = REVIEW_DRAWER_MODE_FLOW
|
||||
insightPanelCollapsed.value = false
|
||||
}
|
||||
flowSteps.value = []
|
||||
}
|
||||
|
||||
function findFlowDefinition(key) {
|
||||
return FLOW_STEP_FALLBACKS[key] || null
|
||||
}
|
||||
|
||||
function normalizeFlowStepPatch(key, patch = {}) {
|
||||
const definition = findFlowDefinition(key) || {}
|
||||
const normalizedPatch = typeof patch === 'string' ? { detail: patch } : { ...patch }
|
||||
return {
|
||||
title: normalizedPatch.title || definition.title || '智能体工具调用',
|
||||
tool: normalizedPatch.tool || definition.tool || 'AgentTool',
|
||||
detail: normalizedPatch.detail || definition.runningText || '',
|
||||
...normalizedPatch
|
||||
}
|
||||
}
|
||||
|
||||
function createFlowStep(key, patch = {}) {
|
||||
const normalizedPatch = normalizeFlowStepPatch(key, patch)
|
||||
return {
|
||||
key,
|
||||
index: flowSteps.value.length + 1,
|
||||
title: normalizedPatch.title,
|
||||
tool: normalizedPatch.tool,
|
||||
status: normalizedPatch.status || FLOW_STEP_STATUS_PENDING,
|
||||
detail: normalizedPatch.detail || '',
|
||||
durationMs: normalizedPatch.durationMs ?? null,
|
||||
startedAt: normalizedPatch.startedAt || 0,
|
||||
finishedAt: normalizedPatch.finishedAt || 0,
|
||||
error: normalizedPatch.error || ''
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeFlowStepIndexes(steps) {
|
||||
return steps.map((step, index) => ({ ...step, index: index + 1 }))
|
||||
}
|
||||
|
||||
function upsertFlowStep(key, patch) {
|
||||
const existingStep = flowSteps.value.find((step) => step.key === key)
|
||||
if (!existingStep) {
|
||||
const nextStep = createFlowStep(key, patch)
|
||||
flowSteps.value = normalizeFlowStepIndexes([...flowSteps.value, nextStep])
|
||||
return
|
||||
}
|
||||
const normalizedPatch = normalizeFlowStepPatch(key, patch)
|
||||
flowSteps.value = flowSteps.value.map((step) => (
|
||||
step.key === key ? { ...step, ...normalizedPatch } : step
|
||||
))
|
||||
}
|
||||
|
||||
function startFlowStep(key, patch = {}) {
|
||||
const normalizedPatch = normalizeFlowStepPatch(key, patch)
|
||||
const explicitStartedAt = Number(normalizedPatch.startedAt)
|
||||
const startedAt = Number.isFinite(explicitStartedAt) && explicitStartedAt > 0
|
||||
? explicitStartedAt
|
||||
: Date.now()
|
||||
upsertFlowStep(key, {
|
||||
...normalizedPatch,
|
||||
status: FLOW_STEP_STATUS_RUNNING,
|
||||
detail: normalizedPatch.detail,
|
||||
startedAt,
|
||||
finishedAt: 0,
|
||||
durationMs: null,
|
||||
error: ''
|
||||
})
|
||||
}
|
||||
|
||||
function completeFlowStep(key, detail = '', durationMs = null, patch = {}) {
|
||||
const now = Date.now()
|
||||
const definition = findFlowDefinition(key)
|
||||
const currentStep = flowSteps.value.find((step) => step.key === key)
|
||||
const explicitDuration = Number(durationMs)
|
||||
const hasExplicitDuration = Number.isFinite(explicitDuration) && explicitDuration >= 0
|
||||
const startedAt = currentStep?.startedAt || (hasExplicitDuration ? Math.max(0, now - explicitDuration) : now)
|
||||
upsertFlowStep(key, {
|
||||
...patch,
|
||||
status: FLOW_STEP_STATUS_COMPLETED,
|
||||
detail: detail || definition?.completedText || '',
|
||||
startedAt,
|
||||
finishedAt: now,
|
||||
durationMs: hasExplicitDuration ? explicitDuration : Math.max(0, now - startedAt),
|
||||
error: ''
|
||||
})
|
||||
}
|
||||
|
||||
function failFlowStep(key, detail = '', error = '', patch = {}) {
|
||||
const now = Date.now()
|
||||
const definition = findFlowDefinition(key)
|
||||
const currentStep = flowSteps.value.find((step) => step.key === key)
|
||||
const startedAt = currentStep?.startedAt || now
|
||||
upsertFlowStep(key, {
|
||||
...patch,
|
||||
status: FLOW_STEP_STATUS_FAILED,
|
||||
detail: detail || error || '调用失败',
|
||||
startedAt,
|
||||
finishedAt: now,
|
||||
durationMs: now - startedAt,
|
||||
error: String(error || definition?.title || '').trim()
|
||||
})
|
||||
flowFinishedAt.value = now
|
||||
}
|
||||
|
||||
function completePendingFlowStep(key, detail = '', durationMs = null, patch = {}) {
|
||||
const currentStep = flowSteps.value.find((step) => step.key === key)
|
||||
if (currentStep?.status === FLOW_STEP_STATUS_COMPLETED) {
|
||||
return
|
||||
}
|
||||
const normalizedDuration = Number(durationMs)
|
||||
const hasMeasuredDuration = Number.isFinite(normalizedDuration) && normalizedDuration > 0
|
||||
if (!currentStep || currentStep.status === FLOW_STEP_STATUS_PENDING) {
|
||||
if (!hasMeasuredDuration && !currentStep?.startedAt) {
|
||||
upsertFlowStep(key, {
|
||||
...patch,
|
||||
status: FLOW_STEP_STATUS_COMPLETED,
|
||||
detail: detail || findFlowDefinition(key)?.completedText || '',
|
||||
startedAt: 0,
|
||||
finishedAt: 0,
|
||||
durationMs: null,
|
||||
error: ''
|
||||
})
|
||||
return
|
||||
}
|
||||
startFlowStep(key, patch)
|
||||
}
|
||||
completeFlowStep(key, detail, hasMeasuredDuration ? normalizedDuration : null, patch)
|
||||
}
|
||||
|
||||
function failCurrentFlowStep(error) {
|
||||
clearFlowSimulationTimers()
|
||||
const currentStep = runningFlowStep.value || flowSteps.value.find((step) => step.status === FLOW_STEP_STATUS_PENDING)
|
||||
failFlowStep(
|
||||
currentStep?.key || 'orchestrator-error',
|
||||
error?.message || '智能体调用失败',
|
||||
error?.message || '',
|
||||
currentStep ? {} : { title: '流程调用', tool: 'Orchestrator' }
|
||||
)
|
||||
}
|
||||
|
||||
function startSemanticFlowPreview(rawText, options = {}) {
|
||||
clearFlowSimulationTimers()
|
||||
const intentPreview = buildLocalIntentPreview(rawText, activeSessionType.value, { intentLabels: INTENT_LABELS })
|
||||
const extractionMessages = buildLocalExtractionProgressMessages(rawText, options)
|
||||
|
||||
const completeIntentTimer = window.setTimeout(() => {
|
||||
const currentStep = flowSteps.value.find((step) => step.key === 'intent')
|
||||
if (currentStep?.status === FLOW_STEP_STATUS_COMPLETED || currentStep?.status === FLOW_STEP_STATUS_FAILED) {
|
||||
return
|
||||
}
|
||||
completePendingFlowStep('intent', intentPreview, null)
|
||||
}, 260)
|
||||
flowSimulationTimers.push(completeIntentTimer)
|
||||
|
||||
const startExtractionTimer = window.setTimeout(() => {
|
||||
const currentStep = flowSteps.value.find((step) => step.key === 'extraction')
|
||||
if (currentStep?.status === FLOW_STEP_STATUS_COMPLETED || currentStep?.status === FLOW_STEP_STATUS_FAILED) {
|
||||
return
|
||||
}
|
||||
startFlowStep('extraction', extractionMessages[0] || FLOW_STEP_FALLBACKS.extraction.runningText)
|
||||
|
||||
if (extractionMessages.length <= 1) {
|
||||
return
|
||||
}
|
||||
|
||||
let index = 1
|
||||
const detailTimer = window.setInterval(() => {
|
||||
const runningStep = flowSteps.value.find((step) => step.key === 'extraction')
|
||||
if (!runningStep || runningStep.status !== FLOW_STEP_STATUS_RUNNING) {
|
||||
window.clearInterval(detailTimer)
|
||||
return
|
||||
}
|
||||
upsertFlowStep('extraction', {
|
||||
detail: extractionMessages[index] || extractionMessages[extractionMessages.length - 1]
|
||||
})
|
||||
index = Math.min(index + 1, extractionMessages.length - 1)
|
||||
}, 650)
|
||||
flowSimulationTimers.push(detailTimer)
|
||||
}, 420)
|
||||
flowSimulationTimers.push(startExtractionTimer)
|
||||
}
|
||||
|
||||
function startExpenseSceneSelectionFlowPreview(rawText) {
|
||||
clearFlowSimulationTimers()
|
||||
const intentPreview = buildLocalIntentPreview(rawText, activeSessionType.value, { intentLabels: INTENT_LABELS })
|
||||
const completeIntentTimer = window.setTimeout(() => {
|
||||
completePendingFlowStep('intent', intentPreview, null)
|
||||
}, 220)
|
||||
flowSimulationTimers.push(completeIntentTimer)
|
||||
|
||||
const startSelectionTimer = window.setTimeout(() => {
|
||||
startFlowStep('expense-scene-selection', {
|
||||
detail: '报销意图已确认,但费用场景还不明确;暂停信息抽取,等待用户先选择报销场景。'
|
||||
})
|
||||
}, 320)
|
||||
flowSimulationTimers.push(startSelectionTimer)
|
||||
}
|
||||
|
||||
function startExpenseIntentConfirmationFlowPreview(rawText) {
|
||||
clearFlowSimulationTimers()
|
||||
const intentPreview = buildLocalIntentPreview(rawText, activeSessionType.value, { intentLabels: INTENT_LABELS })
|
||||
const completeIntentTimer = window.setTimeout(() => {
|
||||
completePendingFlowStep('intent', intentPreview, null)
|
||||
}, 220)
|
||||
flowSimulationTimers.push(completeIntentTimer)
|
||||
|
||||
const startConfirmationTimer = window.setTimeout(() => {
|
||||
startFlowStep('expense-intent-confirmation', {
|
||||
detail: '识别到业务事项描述,但是否发起报销还不明确;暂停信息抽取,等待用户确认。'
|
||||
})
|
||||
}, 320)
|
||||
flowSimulationTimers.push(startConfirmationTimer)
|
||||
}
|
||||
|
||||
function startExpenseSceneSelectionAfterIntentConfirmation(rawText) {
|
||||
clearFlowSimulationTimers()
|
||||
completePendingFlowStep('expense-intent-confirmation', '用户已确认要发起报销', null)
|
||||
startFlowStep('expense-scene-selection', {
|
||||
detail: '报销意图已确认,但费用场景还不明确;暂停信息抽取,等待用户先选择报销场景。'
|
||||
})
|
||||
if (reviewDrawerMode.value !== REVIEW_DRAWER_MODE_FLOW) {
|
||||
reviewDrawerMode.value = REVIEW_DRAWER_MODE_FLOW
|
||||
}
|
||||
}
|
||||
|
||||
function isExpenseSceneSelectionResult(payload) {
|
||||
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
|
||||
if (result.review_payload) {
|
||||
return false
|
||||
}
|
||||
return (Array.isArray(result.suggested_actions) ? result.suggested_actions : []).some(
|
||||
(item) => String(item?.action_type || '').trim() === 'select_expense_type'
|
||||
)
|
||||
}
|
||||
|
||||
function startReviewActionFlowStep(reviewAction) {
|
||||
if (reviewAction !== 'next_step') {
|
||||
return
|
||||
}
|
||||
|
||||
startFlowStep('pre-submit-review', {
|
||||
title: 'AI预审与风险识别',
|
||||
tool: 'ExpenseClaimService.submit_claim',
|
||||
detail: '正在校验财务规则、风险规则和审批路径...'
|
||||
})
|
||||
}
|
||||
|
||||
function startExpenseClaimDraftFlowStep(reviewAction, options = {}) {
|
||||
if (isKnowledgeSession.value) {
|
||||
return
|
||||
}
|
||||
if (options.waitForSceneSelection) {
|
||||
return
|
||||
}
|
||||
if (reviewAction === 'next_step') {
|
||||
startReviewActionFlowStep(reviewAction)
|
||||
return
|
||||
}
|
||||
|
||||
const attachmentCount = Math.max(0, Number(options.attachmentCount || 0))
|
||||
const configs = {
|
||||
save_draft: {
|
||||
key: 'expense-claim-draft',
|
||||
title: '保存报销草稿',
|
||||
tool: 'database.expense_claims.save_or_submit',
|
||||
detail: '正在把已确认信息保存为草稿...'
|
||||
},
|
||||
link_to_existing_draft: {
|
||||
key: 'expense-claim-draft',
|
||||
title: '票据关联草稿',
|
||||
tool: 'database.expense_claims.save_or_submit',
|
||||
detail: '正在把本次票据关联到现有草稿...'
|
||||
},
|
||||
create_new_claim_from_documents: {
|
||||
key: 'expense-claim-draft',
|
||||
title: '新建报销草稿',
|
||||
tool: 'database.expense_claims.save_or_submit',
|
||||
detail: '正在根据当前票据新建报销草稿...'
|
||||
}
|
||||
}
|
||||
const config = configs[reviewAction] || {
|
||||
key: 'expense-review-preview',
|
||||
title: '报销信息核对',
|
||||
tool: 'user_agent.expense_review_preview',
|
||||
detail: attachmentCount
|
||||
? '正在根据 OCR 结果整理核对信息...'
|
||||
: '正在整理识别结果和右侧核对信息...'
|
||||
}
|
||||
|
||||
startFlowStep(config.key, {
|
||||
title: config.title,
|
||||
tool: config.tool,
|
||||
detail: config.detail
|
||||
})
|
||||
}
|
||||
|
||||
function resolveToolCallFlowMeta(toolCall, index) {
|
||||
const toolType = String(toolCall?.tool_type || '').toLowerCase()
|
||||
const toolName = String(toolCall?.tool_name || '').toLowerCase()
|
||||
const response = toolCall?.response_json && typeof toolCall.response_json === 'object'
|
||||
? toolCall.response_json
|
||||
: {}
|
||||
const responseMessage = String(response.message || '').trim()
|
||||
const key = `tool-${toolCall?.id || `${index}-${toolType}-${toolName}`}`
|
||||
if (toolType.includes('rule')) {
|
||||
return { key, title: '规则引擎校验', tool: toolCall?.tool_name || 'RuleEngine' }
|
||||
}
|
||||
if (toolType.includes('mcp')) {
|
||||
return { key, title: toolName.includes('standard') ? '差旅补助标准查询' : 'MCP 服务调用', tool: toolCall?.tool_name || 'MCPService' }
|
||||
}
|
||||
if (toolName.includes('knowledge')) {
|
||||
return { key, title: '知识库检索', tool: toolCall?.tool_name || 'KnowledgeSearch' }
|
||||
}
|
||||
if (toolName.includes('expense_review_preview') || response.preview_only) {
|
||||
return { key: 'expense-review-preview', title: '报销信息核对', tool: toolCall?.tool_name || 'user_agent.expense_review_preview' }
|
||||
}
|
||||
if (toolName.includes('expense_claim') || toolName.includes('save_or_submit')) {
|
||||
if (
|
||||
response.submission_blocked ||
|
||||
String(response.status || '').trim() === 'submitted' ||
|
||||
responseMessage.includes('AI预审') ||
|
||||
responseMessage.includes('审批')
|
||||
) {
|
||||
return { key: 'pre-submit-review', title: 'AI预审与风险识别', tool: 'ExpenseClaimService.submit_claim' }
|
||||
}
|
||||
if (responseMessage.includes('关联')) {
|
||||
return { key: 'expense-claim-draft', title: '票据关联草稿', tool: toolCall?.tool_name || 'ExpenseClaimService' }
|
||||
}
|
||||
if (responseMessage.includes('新建')) {
|
||||
return { key: 'expense-claim-draft', title: '新建报销草稿', tool: toolCall?.tool_name || 'ExpenseClaimService' }
|
||||
}
|
||||
return { key: 'expense-claim-draft', title: '保存报销草稿', tool: toolCall?.tool_name || 'ExpenseClaimService' }
|
||||
}
|
||||
if (toolType.includes('database')) {
|
||||
return { key, title: '数据查询/字段处理', tool: toolCall?.tool_name || 'DatabaseTool' }
|
||||
}
|
||||
if (toolType.includes('llm') || toolName.includes('user_agent')) {
|
||||
return { key, title: '智能体生成', tool: toolCall?.tool_name || 'UserAgent' }
|
||||
}
|
||||
return { key, title: '智能体工具调用', tool: toolCall?.tool_name || toolCall?.tool_type || 'AgentTool' }
|
||||
}
|
||||
|
||||
function summarizeFlowToolCall(toolCall) {
|
||||
const response = toolCall?.response_json && typeof toolCall.response_json === 'object'
|
||||
? toolCall.response_json
|
||||
: {}
|
||||
if (String(response.status || '').trim() === 'submitted') {
|
||||
return `已完成财务规则、风险规则和审批路径校验,提交至${response.approval_stage || '审批环节'}`
|
||||
}
|
||||
if (response.submission_blocked) {
|
||||
return String(response.message || '').trim() || 'AI预审发现待补充项,暂未提交审批'
|
||||
}
|
||||
return (
|
||||
String(response.message || response.summary || response.result_summary || '').trim()
|
||||
|| String(toolCall?.tool_name || '').trim()
|
||||
|| '工具调用完成'
|
||||
)
|
||||
}
|
||||
|
||||
function mergeFlowRunDetail(run) {
|
||||
const toolCalls = Array.isArray(run?.tool_calls) ? run.tool_calls : []
|
||||
if (run?.semantic_parse && flowSteps.value.some((step) => step.key === 'intent')) {
|
||||
clearFlowSimulationTimers()
|
||||
const semanticDurations = resolveSemanticPhaseDurations(run)
|
||||
const intentStep = flowSteps.value.find((step) => step.key === 'intent')
|
||||
const extractionStep = flowSteps.value.find((step) => step.key === 'extraction')
|
||||
completePendingFlowStep(
|
||||
'intent',
|
||||
summarizeSemanticIntentDetail(run.semantic_parse, {
|
||||
scenarioLabels: SCENARIO_LABELS,
|
||||
intentLabels: INTENT_LABELS,
|
||||
expenseTypeLabels: EXPENSE_TYPE_LABELS,
|
||||
fallbackText: FLOW_STEP_FALLBACKS.intent.completedText
|
||||
}),
|
||||
intentStep?.startedAt ? null : semanticDurations.intentMs
|
||||
)
|
||||
completePendingFlowStep(
|
||||
'extraction',
|
||||
summarizeSemanticParseDetail(run.semantic_parse, run?.ontology_json || {}),
|
||||
extractionStep?.startedAt ? null : semanticDurations.extractionMs
|
||||
)
|
||||
}
|
||||
|
||||
toolCalls.forEach((toolCall, index) => {
|
||||
const meta = resolveToolCallFlowMeta(toolCall, index)
|
||||
const failed = String(toolCall?.status || '').toLowerCase() === 'failed'
|
||||
if (failed) {
|
||||
failFlowStep(meta.key, toolCall?.error_message || summarizeFlowToolCall(toolCall), toolCall?.error_message || '', meta)
|
||||
} else {
|
||||
const toolDurationMs = resolveToolCallDurationMs(toolCall, index, toolCalls, run)
|
||||
completePendingFlowStep(
|
||||
meta.key,
|
||||
summarizeFlowToolCall(toolCall),
|
||||
toolDurationMs,
|
||||
meta
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
if (String(run?.status || '').toLowerCase() === 'failed') {
|
||||
failCurrentFlowStep({ message: run?.error_message || '智能体调用失败' })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function completeFlowResult(payload, run = null) {
|
||||
const answer = String(payload?.result?.answer || payload?.result?.message || '').trim()
|
||||
if (!answer && !payload?.result) {
|
||||
return
|
||||
}
|
||||
const sceneSelectionPending = isExpenseSceneSelectionResult(payload)
|
||||
flowSteps.value
|
||||
.filter((step) => ![FLOW_STEP_STATUS_COMPLETED, FLOW_STEP_STATUS_FAILED].includes(step.status))
|
||||
.forEach((step) => {
|
||||
const detail = sceneSelectionPending && step.key === 'expense-scene-selection'
|
||||
? '已暂停后续识别,请先在主对话中选择报销场景。'
|
||||
: resolveFlowStepDetail({ ...step, status: FLOW_STEP_STATUS_COMPLETED })
|
||||
completeFlowStep(step.key, detail)
|
||||
})
|
||||
flowFinishedAt.value = Date.now()
|
||||
if (reviewDrawerMode.value === REVIEW_DRAWER_MODE_FLOW && !sceneSelectionPending) {
|
||||
reviewDrawerMode.value = REVIEW_DRAWER_MODE_REVIEW
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshFlowRunDetail() {
|
||||
if (!flowRunId.value || flowRefreshBusy.value) {
|
||||
return null
|
||||
}
|
||||
flowRefreshBusy.value = true
|
||||
try {
|
||||
const run = await fetchAgentRunDetail(flowRunId.value)
|
||||
mergeFlowRunDetail(run)
|
||||
return run
|
||||
} catch (error) {
|
||||
console.warn('Failed to refresh agent run detail:', error)
|
||||
return null
|
||||
} finally {
|
||||
flowRefreshBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatFlowStepDuration(step) {
|
||||
if (step?.status === FLOW_STEP_STATUS_RUNNING && step.startedAt) {
|
||||
return formatFlowDuration(flowTick.value - step.startedAt)
|
||||
}
|
||||
return formatFlowDuration(step?.durationMs)
|
||||
}
|
||||
|
||||
function resolveFlowStepStatusLabel(step) {
|
||||
const status = String(step?.status || '').trim()
|
||||
if (status === FLOW_STEP_STATUS_COMPLETED) {
|
||||
return '完成'
|
||||
}
|
||||
if (status === FLOW_STEP_STATUS_RUNNING) {
|
||||
return '执行中'
|
||||
}
|
||||
if (status === FLOW_STEP_STATUS_FAILED) {
|
||||
return '异常'
|
||||
}
|
||||
return '待执行'
|
||||
}
|
||||
|
||||
function resolveFlowStepDetail(step) {
|
||||
const detail = String(step?.detail || '').trim()
|
||||
if (detail) {
|
||||
return detail
|
||||
}
|
||||
const definition = findFlowDefinition(step?.key)
|
||||
if (step?.status === FLOW_STEP_STATUS_COMPLETED) {
|
||||
return definition?.completedText || '步骤已完成'
|
||||
}
|
||||
if (step?.status === FLOW_STEP_STATUS_RUNNING) {
|
||||
return definition?.runningText || '正在执行当前步骤...'
|
||||
}
|
||||
if (step?.status === FLOW_STEP_STATUS_FAILED) {
|
||||
return step?.error || '步骤执行异常'
|
||||
}
|
||||
return definition?.runningText ? `等待${definition.title || '当前步骤'}...` : '等待智能体调度...'
|
||||
}
|
||||
|
||||
return {
|
||||
flowRunId,
|
||||
flowStartedAt,
|
||||
flowFinishedAt,
|
||||
flowSteps,
|
||||
flowRefreshBusy,
|
||||
flowTick,
|
||||
completedFlowStepCount,
|
||||
runningFlowStep,
|
||||
flowOverallStatusTone,
|
||||
flowOverallStatusText,
|
||||
flowTotalDurationText,
|
||||
clearFlowSimulationTimers,
|
||||
resetFlowRun,
|
||||
findFlowDefinition,
|
||||
normalizeFlowStepPatch,
|
||||
createFlowStep,
|
||||
normalizeFlowStepIndexes,
|
||||
upsertFlowStep,
|
||||
startFlowTick,
|
||||
stopFlowRuntime,
|
||||
startFlowStep,
|
||||
completeFlowStep,
|
||||
failFlowStep,
|
||||
completePendingFlowStep,
|
||||
failCurrentFlowStep,
|
||||
startSemanticFlowPreview,
|
||||
startExpenseSceneSelectionFlowPreview,
|
||||
startExpenseIntentConfirmationFlowPreview,
|
||||
startExpenseSceneSelectionAfterIntentConfirmation,
|
||||
isExpenseSceneSelectionResult,
|
||||
startReviewActionFlowStep,
|
||||
startExpenseClaimDraftFlowStep,
|
||||
resolveToolCallFlowMeta,
|
||||
summarizeFlowToolCall,
|
||||
mergeFlowRunDetail,
|
||||
completeFlowResult,
|
||||
refreshFlowRunDetail,
|
||||
formatFlowStepDuration,
|
||||
resolveFlowStepStatusLabel,
|
||||
resolveFlowStepDetail
|
||||
}
|
||||
}
|
||||
252
web/src/views/scripts/useTravelReimbursementReviewActions.js
Normal file
252
web/src/views/scripts/useTravelReimbursementReviewActions.js
Normal file
@@ -0,0 +1,252 @@
|
||||
export function useTravelReimbursementReviewActions(ctx) {
|
||||
const {
|
||||
activeReviewPayload,
|
||||
buildDraftSavedPayload,
|
||||
buildLocalReviewCompletionMessage,
|
||||
buildLocalReviewSavedMessage,
|
||||
buildReviewCorrectionMessage,
|
||||
buildReviewDocumentCorrectionContext,
|
||||
buildReviewDocumentCorrectionMessage,
|
||||
buildReviewFormValues,
|
||||
buildReviewRiskItems,
|
||||
buildReviewSubmitUserText,
|
||||
buildLocallySyncedReviewPayload,
|
||||
cloneReviewDocumentDrafts,
|
||||
cloneReviewEditFields,
|
||||
commitInlineReviewEditor,
|
||||
createMessage,
|
||||
currentInsight,
|
||||
currentUser,
|
||||
emit,
|
||||
latestReviewMessage,
|
||||
linkedRequest,
|
||||
mergeInlineReviewFields,
|
||||
messages,
|
||||
nextTick,
|
||||
reviewActionBusy,
|
||||
reviewDocumentBaseDrafts,
|
||||
reviewDocumentDrafts,
|
||||
reviewHasUnsavedChanges,
|
||||
reviewInlineBaseFields,
|
||||
reviewInlineBaseForm,
|
||||
reviewInlineEditorKey,
|
||||
reviewInlineForm,
|
||||
reviewInlinePendingFiles,
|
||||
scrollToBottom,
|
||||
sessionSwitchBusy,
|
||||
submitComposer,
|
||||
submitting
|
||||
} = ctx
|
||||
function saveInlineReviewChanges() {
|
||||
if (
|
||||
!activeReviewPayload.value
|
||||
|| !reviewHasUnsavedChanges.value
|
||||
|| submitting.value
|
||||
|| reviewActionBusy.value
|
||||
|| sessionSwitchBusy.value
|
||||
) return
|
||||
|
||||
if (reviewInlineEditorKey.value && !commitInlineReviewEditor()) {
|
||||
return
|
||||
}
|
||||
|
||||
reviewActionBusy.value = true
|
||||
try {
|
||||
const fields = mergeInlineReviewFields(reviewInlineBaseFields.value, reviewInlineForm.value)
|
||||
const nextReviewPayload = buildLocallySyncedReviewPayload(activeReviewPayload.value, reviewInlineForm.value)
|
||||
const messageText = `${buildLocalReviewSavedMessage(
|
||||
reviewInlineBaseForm.value,
|
||||
reviewInlineForm.value,
|
||||
reviewInlinePendingFiles.value,
|
||||
reviewDocumentBaseDrafts.value,
|
||||
reviewDocumentDrafts.value
|
||||
)} ${buildLocalReviewCompletionMessage(nextReviewPayload)}`
|
||||
|
||||
reviewInlineBaseFields.value = cloneReviewEditFields(fields)
|
||||
reviewInlineBaseForm.value = { ...reviewInlineForm.value }
|
||||
reviewDocumentBaseDrafts.value = cloneReviewDocumentDrafts(reviewDocumentDrafts.value)
|
||||
if (latestReviewMessage.value) {
|
||||
latestReviewMessage.value.reviewPayload = nextReviewPayload
|
||||
}
|
||||
if (currentInsight.value?.agent) {
|
||||
currentInsight.value = {
|
||||
...currentInsight.value,
|
||||
agent: {
|
||||
...currentInsight.value.agent,
|
||||
reviewPayload: nextReviewPayload
|
||||
}
|
||||
}
|
||||
}
|
||||
messages.value.push(createMessage('assistant', messageText, [], {
|
||||
meta: ['本地修改'],
|
||||
draftPayload: latestReviewMessage.value?.draftPayload || null,
|
||||
reviewPayload: nextReviewPayload
|
||||
}))
|
||||
nextTick(scrollToBottom)
|
||||
} finally {
|
||||
reviewActionBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function handleReviewAction(message, action) {
|
||||
const actionType = String(action?.action_type || '').trim()
|
||||
if (!actionType || submitting.value || reviewActionBusy.value || sessionSwitchBusy.value) return
|
||||
|
||||
if (!['save_draft', 'next_step', 'link_to_existing_draft', 'create_new_claim_from_documents'].includes(actionType)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (reviewInlineEditorKey.value && !commitInlineReviewEditor()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (['save_draft', 'link_to_existing_draft', 'create_new_claim_from_documents'].includes(actionType)) {
|
||||
await handleSaveDraftDirectly(message, actionType)
|
||||
return
|
||||
}
|
||||
|
||||
reviewActionBusy.value = true
|
||||
try {
|
||||
const baseFields = reviewInlineBaseFields.value.length
|
||||
? reviewInlineBaseFields.value
|
||||
: cloneReviewEditFields(message?.reviewPayload?.edit_fields)
|
||||
const fields = mergeInlineReviewFields(baseFields, reviewInlineForm.value)
|
||||
const reviewChangedUserText = reviewHasUnsavedChanges.value
|
||||
? buildReviewSubmitUserText(
|
||||
reviewInlineBaseForm.value,
|
||||
reviewInlineForm.value,
|
||||
reviewInlinePendingFiles.value,
|
||||
reviewDocumentBaseDrafts.value,
|
||||
reviewDocumentDrafts.value
|
||||
)
|
||||
: ''
|
||||
const documentCorrectionMessage = buildReviewDocumentCorrectionMessage(
|
||||
reviewDocumentBaseDrafts.value,
|
||||
reviewDocumentDrafts.value
|
||||
)
|
||||
const payload = await submitComposer({
|
||||
rawText: [
|
||||
reviewHasUnsavedChanges.value ? buildReviewCorrectionMessage(fields) : '',
|
||||
reviewHasUnsavedChanges.value ? documentCorrectionMessage : '',
|
||||
'我已核对右侧识别结果,请进入下一步。'
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
userText: reviewChangedUserText || '我确认当前识别结果,继续下一步。',
|
||||
files: reviewInlinePendingFiles.value,
|
||||
pendingText: '正在进入下一步...',
|
||||
systemGenerated: true,
|
||||
extraContext: {
|
||||
review_action: actionType,
|
||||
review_form_values: buildReviewFormValues(fields),
|
||||
review_document_form_values: buildReviewDocumentCorrectionContext(reviewDocumentDrafts.value)
|
||||
}
|
||||
})
|
||||
|
||||
if (payload?.result?.draft_payload?.status === 'submitted') {
|
||||
emit(
|
||||
'draft-saved',
|
||||
buildDraftSavedPayload({
|
||||
draftPayload: payload.result.draft_payload,
|
||||
reviewPayload: payload?.result?.review_payload || message?.reviewPayload || activeReviewPayload.value,
|
||||
inlineState: reviewInlineForm.value,
|
||||
linkedRequest: linkedRequest.value,
|
||||
currentUser: currentUser.value,
|
||||
riskItems: buildReviewRiskItems(payload?.result?.review_payload || message?.reviewPayload || activeReviewPayload.value)
|
||||
})
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
reviewActionBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveDraftDirectly(message, actionType = 'save_draft') {
|
||||
reviewActionBusy.value = true
|
||||
|
||||
const actionConfig = {
|
||||
save_draft: {
|
||||
rawText: '请按当前已识别信息先保存草稿,缺失字段后续再补。',
|
||||
pendingText: '正在保存当前草稿...',
|
||||
successMeta: '草稿已保存',
|
||||
successMessage: (payload) => {
|
||||
const claimNo = String(payload?.result?.draft_payload?.claim_no || '').trim()
|
||||
return claimNo ? `草稿已保存,单号:${claimNo}` : '草稿保存完成'
|
||||
}
|
||||
},
|
||||
link_to_existing_draft: {
|
||||
rawText: '请把当前上传的票据合并到现有报销草稿中。',
|
||||
pendingText: '正在关联到现有草稿...',
|
||||
successMeta: '已关联草稿',
|
||||
successMessage: (payload) => {
|
||||
const claimNo = String(payload?.result?.draft_payload?.claim_no || '').trim()
|
||||
return claimNo ? `已关联到草稿 ${claimNo}` : '已关联到现有草稿'
|
||||
}
|
||||
},
|
||||
create_new_claim_from_documents: {
|
||||
rawText: '请基于当前上传的多张票据,单独建立一张新的报销草稿。',
|
||||
pendingText: '正在建立新的报销草稿...',
|
||||
successMeta: '新草稿已建立',
|
||||
successMessage: (payload) => {
|
||||
const claimNo = String(payload?.result?.draft_payload?.claim_no || '').trim()
|
||||
return claimNo ? `已建立新草稿 ${claimNo}` : '已建立新的报销草稿'
|
||||
}
|
||||
}
|
||||
}[actionType] || {
|
||||
rawText: '请按当前已识别信息先保存草稿,缺失字段后续再补。',
|
||||
pendingText: '正在保存当前草稿...',
|
||||
successMeta: '草稿已保存',
|
||||
successMessage: () => '草稿保存完成'
|
||||
}
|
||||
|
||||
try {
|
||||
const baseFields = reviewInlineBaseFields.value.length
|
||||
? reviewInlineBaseFields.value
|
||||
: cloneReviewEditFields(message?.reviewPayload?.edit_fields)
|
||||
const fields = mergeInlineReviewFields(baseFields, reviewInlineForm.value)
|
||||
|
||||
const payload = await submitComposer({
|
||||
rawText: actionConfig.rawText,
|
||||
userText: '',
|
||||
skipUserMessage: true,
|
||||
files: reviewInlinePendingFiles.value,
|
||||
pendingText: actionConfig.pendingText,
|
||||
systemGenerated: true,
|
||||
extraContext: {
|
||||
review_action: actionType,
|
||||
review_form_values: buildReviewFormValues(fields),
|
||||
review_document_form_values: buildReviewDocumentCorrectionContext(reviewDocumentDrafts.value)
|
||||
}
|
||||
})
|
||||
|
||||
if (payload?.result?.draft_payload?.claim_no) {
|
||||
emit(
|
||||
'draft-saved',
|
||||
buildDraftSavedPayload({
|
||||
draftPayload: payload.result.draft_payload,
|
||||
reviewPayload: payload?.result?.review_payload || message?.reviewPayload || activeReviewPayload.value,
|
||||
inlineState: reviewInlineForm.value,
|
||||
linkedRequest: linkedRequest.value,
|
||||
currentUser: currentUser.value,
|
||||
riskItems: buildReviewRiskItems(payload?.result?.review_payload || message?.reviewPayload || activeReviewPayload.value)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
nextTick(scrollToBottom)
|
||||
} catch (error) {
|
||||
messages.value.push(createMessage('assistant', '保存失败,请稍后重试。', [], { meta: ['错误'] }))
|
||||
nextTick(scrollToBottom)
|
||||
} finally {
|
||||
reviewActionBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
handleReviewActionInternal: handleReviewAction,
|
||||
handleSaveDraftDirectlyInternal: handleSaveDraftDirectly,
|
||||
saveInlineReviewChangesInternal: saveInlineReviewChanges
|
||||
}
|
||||
}
|
||||
422
web/src/views/scripts/useTravelReimbursementReviewDrawer.js
Normal file
422
web/src/views/scripts/useTravelReimbursementReviewDrawer.js
Normal file
@@ -0,0 +1,422 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import {
|
||||
DATE_INPUT_FORMAT,
|
||||
REVIEW_CATEGORY_PRESET_OPTIONS,
|
||||
REVIEW_OTHER_CATEGORY_OPTIONS,
|
||||
REVIEW_SCENE_OTHER_OPTION,
|
||||
buildInlineReviewChangedLines,
|
||||
buildInlineReviewState,
|
||||
buildReviewCategoryOptions,
|
||||
buildReviewDocumentDrafts,
|
||||
buildReviewDocumentSummaries,
|
||||
buildReviewPanelConfidence,
|
||||
buildReviewRecognitionNotes,
|
||||
buildReviewRecognizedLines,
|
||||
cloneReviewDocumentDrafts,
|
||||
cloneReviewEditFields,
|
||||
createEmptyInlineReviewState,
|
||||
extractAmountInputValue,
|
||||
formatConfidenceLabel,
|
||||
isValidIsoDateString,
|
||||
normalizeAmountValue,
|
||||
normalizeReviewDocumentComparableValue,
|
||||
resolveReviewCategoryConfidenceScore
|
||||
} from './travelReimbursementReviewModel.js'
|
||||
|
||||
export function useTravelReimbursementReviewDrawer({
|
||||
activeReviewPayload,
|
||||
reviewFilePreviews,
|
||||
flowSteps,
|
||||
submitting,
|
||||
reviewActionBusy,
|
||||
triggerFileUpload,
|
||||
resolveDocumentPreview,
|
||||
buildReviewFactCards,
|
||||
buildReviewRiskItems,
|
||||
buildReviewRiskSummary,
|
||||
buildReviewIntentText,
|
||||
resolveReviewRiskBriefs,
|
||||
reviewDrawerMode: externalReviewDrawerMode,
|
||||
REVIEW_DRAWER_MODE_REVIEW,
|
||||
REVIEW_DRAWER_MODE_DOCUMENTS,
|
||||
REVIEW_DRAWER_MODE_RISK,
|
||||
REVIEW_DRAWER_MODE_FLOW
|
||||
}) {
|
||||
const reviewInlineForm = ref(createEmptyInlineReviewState())
|
||||
const reviewInlineBaseForm = ref(createEmptyInlineReviewState())
|
||||
const reviewInlineBaseFields = ref([])
|
||||
const reviewInlinePendingFiles = ref([])
|
||||
const reviewInlineEditorKey = ref('')
|
||||
const reviewInlineErrors = ref({})
|
||||
const reviewOtherCategoryOpen = ref(false)
|
||||
const reviewDocumentDrafts = ref([])
|
||||
const reviewDocumentBaseDrafts = ref([])
|
||||
const activeReviewDocumentIndex = ref(0)
|
||||
const reviewDrawerMode = externalReviewDrawerMode || ref(REVIEW_DRAWER_MODE_REVIEW)
|
||||
const documentPreviewDialog = ref({
|
||||
open: false,
|
||||
filename: '',
|
||||
kind: 'file',
|
||||
url: ''
|
||||
})
|
||||
|
||||
const activeReviewFilePreviews = computed(() => reviewFilePreviews.value)
|
||||
const reviewIntentText = computed(() => buildReviewIntentText(activeReviewPayload.value))
|
||||
const reviewFactCards = computed(() => buildReviewFactCards(activeReviewPayload.value, reviewInlineForm.value))
|
||||
const reviewCategoryOptions = computed(() =>
|
||||
buildReviewCategoryOptions(activeReviewPayload.value, reviewInlineForm.value.expense_type, reviewInlineForm.value)
|
||||
)
|
||||
const reviewOtherCategoryOptions = computed(() =>
|
||||
REVIEW_OTHER_CATEGORY_OPTIONS.map((item) => ({
|
||||
...item,
|
||||
confidenceLabel: formatConfidenceLabel(
|
||||
resolveReviewCategoryConfidenceScore(activeReviewPayload.value, item.label, reviewInlineForm.value)
|
||||
)
|
||||
}))
|
||||
)
|
||||
const reviewSelectedOtherCategory = computed(() => {
|
||||
const presetLabels = REVIEW_CATEGORY_PRESET_OPTIONS.filter((item) => !item.is_other).map((item) => item.label)
|
||||
return presetLabels.includes(reviewInlineForm.value.expense_type) ? '' : reviewInlineForm.value.expense_type
|
||||
})
|
||||
const reviewInlineDirty = computed(
|
||||
() =>
|
||||
buildInlineReviewChangedLines(
|
||||
reviewInlineBaseForm.value,
|
||||
reviewInlineForm.value,
|
||||
reviewInlinePendingFiles.value
|
||||
).length > 0
|
||||
)
|
||||
const reviewPanelConfidence = computed(() => buildReviewPanelConfidence(activeReviewPayload.value, reviewInlineForm.value))
|
||||
const reviewRiskSummary = computed(() => buildReviewRiskSummary(activeReviewPayload.value))
|
||||
const reviewRiskItems = computed(() => buildReviewRiskItems(activeReviewPayload.value))
|
||||
const reviewRiskEmpty = computed(() => !reviewRiskItems.value.length)
|
||||
const reviewDocumentCount = computed(() => reviewDocumentDrafts.value.length)
|
||||
const reviewDocumentDrawerAvailable = computed(() => reviewDocumentCount.value > 0)
|
||||
const reviewRiskDrawerAvailable = computed(() => !reviewRiskEmpty.value)
|
||||
const reviewFlowDrawerAvailable = computed(() => flowSteps.value.length > 0)
|
||||
const recognizedNarratives = computed(() => buildReviewRecognizedLines(activeReviewPayload.value))
|
||||
const reviewRecognitionNotes = computed(() => buildReviewRecognitionNotes(activeReviewPayload.value))
|
||||
const reviewDocumentSummaries = computed(() => buildReviewDocumentSummaries(activeReviewPayload.value))
|
||||
const isReviewDocumentDrawer = computed(() => reviewDrawerMode.value === REVIEW_DRAWER_MODE_DOCUMENTS)
|
||||
const isReviewRiskDrawer = computed(() => reviewDrawerMode.value === REVIEW_DRAWER_MODE_RISK)
|
||||
const isReviewFlowDrawer = computed(() => reviewDrawerMode.value === REVIEW_DRAWER_MODE_FLOW)
|
||||
const reviewDrawerTitle = computed(() => (
|
||||
isReviewDocumentDrawer.value
|
||||
? '绁ㄦ嵁璇嗗埆缁撴灉'
|
||||
: isReviewRiskDrawer.value
|
||||
? '椋庨櫓鎻愮ず'
|
||||
: isReviewFlowDrawer.value
|
||||
? '璋冪敤娴佺▼'
|
||||
: '鎶ラ攢璇嗗埆鏍稿'
|
||||
))
|
||||
const reviewDocumentDrawerLabel = computed(() => (
|
||||
'鍗曟嵁璇嗗埆'
|
||||
))
|
||||
const reviewDocumentDrawerIcon = computed(() => (
|
||||
isReviewDocumentDrawer.value
|
||||
? 'mdi mdi-file-document-multiple'
|
||||
: 'mdi mdi-file-document-multiple-outline'
|
||||
))
|
||||
const reviewRiskDrawerLabel = computed(() => (
|
||||
'鏄剧ず椋庨櫓'
|
||||
))
|
||||
const reviewRiskDrawerIcon = computed(() => (
|
||||
isReviewRiskDrawer.value
|
||||
? 'mdi mdi-shield-alert'
|
||||
: 'mdi mdi-shield-alert-outline'
|
||||
))
|
||||
const reviewFlowDrawerLabel = computed(() => (
|
||||
'璋冪敤娴佺▼'
|
||||
))
|
||||
const reviewFlowDrawerIcon = computed(() => (
|
||||
isReviewFlowDrawer.value
|
||||
? 'mdi mdi-timeline-clock'
|
||||
: 'mdi mdi-timeline-clock-outline'
|
||||
))
|
||||
const activeReviewDocument = computed(() => reviewDocumentDrafts.value[activeReviewDocumentIndex.value] ?? null)
|
||||
const activeReviewDocumentPreview = computed(() =>
|
||||
activeReviewDocument.value
|
||||
? (
|
||||
resolveDocumentPreview(activeReviewFilePreviews.value, activeReviewDocument.value.filename)
|
||||
|| (
|
||||
activeReviewDocument.value.preview_kind === 'image' && activeReviewDocument.value.preview_data_url
|
||||
? {
|
||||
filename: activeReviewDocument.value.filename,
|
||||
kind: activeReviewDocument.value.preview_kind,
|
||||
url: activeReviewDocument.value.preview_data_url
|
||||
}
|
||||
: null
|
||||
)
|
||||
)
|
||||
: null
|
||||
)
|
||||
const canPreviewActiveReviewDocument = computed(() => Boolean(activeReviewDocumentPreview.value?.url))
|
||||
const reviewDocumentDirty = computed(() => {
|
||||
const baseValue = JSON.stringify(reviewDocumentBaseDrafts.value.map(normalizeReviewDocumentComparableValue))
|
||||
const nextValue = JSON.stringify(reviewDocumentDrafts.value.map(normalizeReviewDocumentComparableValue))
|
||||
return baseValue !== nextValue
|
||||
})
|
||||
const reviewHasUnsavedChanges = computed(() => reviewInlineDirty.value || reviewDocumentDirty.value)
|
||||
|
||||
function resetReviewDrawerFromPayload(payload) {
|
||||
const normalizedInlineState = buildInlineReviewState(payload)
|
||||
reviewInlineForm.value = { ...normalizedInlineState }
|
||||
reviewInlineBaseForm.value = { ...normalizedInlineState }
|
||||
reviewInlineBaseFields.value = cloneReviewEditFields(payload?.edit_fields)
|
||||
const nextDocumentDrafts = buildReviewDocumentDrafts(payload)
|
||||
reviewDocumentDrafts.value = cloneReviewDocumentDrafts(nextDocumentDrafts)
|
||||
reviewDocumentBaseDrafts.value = cloneReviewDocumentDrafts(nextDocumentDrafts)
|
||||
activeReviewDocumentIndex.value = nextDocumentDrafts.length
|
||||
? Math.min(activeReviewDocumentIndex.value, nextDocumentDrafts.length - 1)
|
||||
: 0
|
||||
reviewDrawerMode.value = resolveReviewRiskBriefs(payload).length
|
||||
? REVIEW_DRAWER_MODE_RISK
|
||||
: REVIEW_DRAWER_MODE_REVIEW
|
||||
reviewInlinePendingFiles.value = []
|
||||
reviewInlineEditorKey.value = ''
|
||||
reviewInlineErrors.value = {}
|
||||
reviewOtherCategoryOpen.value = false
|
||||
}
|
||||
|
||||
function setInlineReviewFieldError(key, message) {
|
||||
reviewInlineErrors.value = {
|
||||
...reviewInlineErrors.value,
|
||||
[key]: String(message || '').trim()
|
||||
}
|
||||
}
|
||||
|
||||
function clearInlineReviewFieldError(key) {
|
||||
if (!reviewInlineErrors.value[key]) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextErrors = { ...reviewInlineErrors.value }
|
||||
delete nextErrors[key]
|
||||
reviewInlineErrors.value = nextErrors
|
||||
}
|
||||
|
||||
function openInlineReviewEditor(key) {
|
||||
if (!activeReviewPayload.value || submitting.value || reviewActionBusy.value) return
|
||||
if (key === 'attachments') {
|
||||
triggerFileUpload('inline-review')
|
||||
return
|
||||
}
|
||||
|
||||
if (reviewInlineEditorKey.value && reviewInlineEditorKey.value !== key && !commitInlineReviewEditor()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (reviewInlineEditorKey.value === key) {
|
||||
commitInlineReviewEditor()
|
||||
return
|
||||
}
|
||||
|
||||
if (key === 'amount') {
|
||||
reviewInlineForm.value = {
|
||||
...reviewInlineForm.value,
|
||||
amount: extractAmountInputValue(reviewInlineForm.value.amount)
|
||||
}
|
||||
}
|
||||
|
||||
clearInlineReviewFieldError(key)
|
||||
reviewInlineEditorKey.value = key
|
||||
if (key !== 'expense_type') {
|
||||
reviewOtherCategoryOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function closeInlineReviewEditor() {
|
||||
reviewInlineEditorKey.value = ''
|
||||
reviewOtherCategoryOpen.value = false
|
||||
}
|
||||
|
||||
function commitInlineReviewEditor() {
|
||||
const activeEditorKey = reviewInlineEditorKey.value
|
||||
const nextForm = {
|
||||
...reviewInlineForm.value,
|
||||
occurred_date: String(reviewInlineForm.value.occurred_date || '').trim(),
|
||||
amount: String(reviewInlineForm.value.amount || '').trim(),
|
||||
transport_type: String(reviewInlineForm.value.transport_type || '').trim(),
|
||||
customer_name: String(reviewInlineForm.value.customer_name || '').trim(),
|
||||
location: String(reviewInlineForm.value.location || '').trim(),
|
||||
merchant_name: String(reviewInlineForm.value.merchant_name || '').trim(),
|
||||
participants: String(reviewInlineForm.value.participants || '').trim(),
|
||||
scene_label: String(reviewInlineForm.value.scene_label || '').trim(),
|
||||
reason_value: String(reviewInlineForm.value.reason_value || reviewInlineForm.value.scene_label || '').trim(),
|
||||
expense_type: String(reviewInlineForm.value.expense_type || '').trim()
|
||||
}
|
||||
|
||||
if (
|
||||
activeEditorKey === 'scene' &&
|
||||
nextForm.scene_label === REVIEW_SCENE_OTHER_OPTION
|
||||
) {
|
||||
nextForm.reason_value = String(reviewInlineForm.value.reason_value || '').trim()
|
||||
if (!nextForm.reason_value) {
|
||||
setInlineReviewFieldError('scene', '璇烽€夋嫨鈥滃叾浠栧満鏅€濆悗锛岃琛ュ厖鍏蜂綋浜嬬敱')
|
||||
reviewInlineForm.value = nextForm
|
||||
return false
|
||||
}
|
||||
} else if (activeEditorKey === 'scene') {
|
||||
nextForm.reason_value = nextForm.scene_label
|
||||
}
|
||||
|
||||
if (activeEditorKey === 'occurred_date' && nextForm.occurred_date && !isValidIsoDateString(nextForm.occurred_date)) {
|
||||
setInlineReviewFieldError('occurred_date', `璇疯緭鍏ユ纭殑鏃堕棿鏍煎紡锛?{DATE_INPUT_FORMAT}`)
|
||||
return false
|
||||
}
|
||||
|
||||
if (activeEditorKey === 'amount' && nextForm.amount) {
|
||||
const normalizedAmount = normalizeAmountValue(nextForm.amount)
|
||||
if (!normalizedAmount) {
|
||||
setInlineReviewFieldError('amount', '璇疯緭鍏ユ纭殑鏁板瓧閲戦锛屼緥濡?200 鎴?200.50')
|
||||
return false
|
||||
}
|
||||
nextForm.amount = normalizedAmount
|
||||
}
|
||||
|
||||
if (activeEditorKey) {
|
||||
clearInlineReviewFieldError(activeEditorKey)
|
||||
}
|
||||
|
||||
reviewInlineForm.value = nextForm
|
||||
reviewInlineEditorKey.value = ''
|
||||
return true
|
||||
}
|
||||
|
||||
function selectInlineScene(scene) {
|
||||
const normalizedScene = String(scene || '').trim()
|
||||
reviewInlineForm.value = {
|
||||
...reviewInlineForm.value,
|
||||
scene_label: normalizedScene,
|
||||
reason_value:
|
||||
normalizedScene === REVIEW_SCENE_OTHER_OPTION
|
||||
? ''
|
||||
: normalizedScene
|
||||
}
|
||||
clearInlineReviewFieldError('scene')
|
||||
if (normalizedScene !== REVIEW_SCENE_OTHER_OPTION) {
|
||||
reviewInlineEditorKey.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function selectReviewCategory(option) {
|
||||
if (!option) return
|
||||
if (option.is_other) {
|
||||
reviewOtherCategoryOpen.value = !reviewOtherCategoryOpen.value
|
||||
return
|
||||
}
|
||||
|
||||
reviewInlineForm.value = {
|
||||
...reviewInlineForm.value,
|
||||
expense_type: option.label
|
||||
}
|
||||
reviewOtherCategoryOpen.value = false
|
||||
}
|
||||
|
||||
function selectReviewOtherCategory(option) {
|
||||
if (!option) return
|
||||
reviewInlineForm.value = {
|
||||
...reviewInlineForm.value,
|
||||
expense_type: option.label
|
||||
}
|
||||
reviewOtherCategoryOpen.value = false
|
||||
}
|
||||
|
||||
function goReviewDocument(direction) {
|
||||
const total = reviewDocumentCount.value
|
||||
if (!total) return
|
||||
const nextIndex = activeReviewDocumentIndex.value + Number(direction || 0)
|
||||
activeReviewDocumentIndex.value = Math.max(0, Math.min(total - 1, nextIndex))
|
||||
}
|
||||
|
||||
function openActiveReviewDocumentPreview() {
|
||||
if (!activeReviewDocument.value || !activeReviewDocumentPreview.value?.url) return
|
||||
documentPreviewDialog.value = {
|
||||
open: true,
|
||||
filename: activeReviewDocument.value.filename,
|
||||
kind: activeReviewDocumentPreview.value.kind,
|
||||
url: activeReviewDocumentPreview.value.url
|
||||
}
|
||||
}
|
||||
|
||||
function closeDocumentPreview() {
|
||||
documentPreviewDialog.value = {
|
||||
...documentPreviewDialog.value,
|
||||
open: false
|
||||
}
|
||||
}
|
||||
|
||||
function enforceReviewDrawerAvailability() {
|
||||
if (!reviewDocumentDrawerAvailable.value && reviewDrawerMode.value === REVIEW_DRAWER_MODE_DOCUMENTS) {
|
||||
reviewDrawerMode.value = REVIEW_DRAWER_MODE_REVIEW
|
||||
}
|
||||
if (!reviewRiskDrawerAvailable.value && reviewDrawerMode.value === REVIEW_DRAWER_MODE_RISK) {
|
||||
reviewDrawerMode.value = REVIEW_DRAWER_MODE_REVIEW
|
||||
}
|
||||
if (!reviewFlowDrawerAvailable.value && reviewDrawerMode.value === REVIEW_DRAWER_MODE_FLOW) {
|
||||
reviewDrawerMode.value = REVIEW_DRAWER_MODE_REVIEW
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
reviewInlineForm,
|
||||
reviewInlineBaseForm,
|
||||
reviewInlineBaseFields,
|
||||
reviewInlinePendingFiles,
|
||||
reviewInlineEditorKey,
|
||||
reviewInlineErrors,
|
||||
reviewOtherCategoryOpen,
|
||||
reviewDocumentDrafts,
|
||||
reviewDocumentBaseDrafts,
|
||||
activeReviewDocumentIndex,
|
||||
reviewDrawerMode,
|
||||
documentPreviewDialog,
|
||||
activeReviewFilePreviews,
|
||||
reviewIntentText,
|
||||
reviewFactCards,
|
||||
reviewCategoryOptions,
|
||||
reviewOtherCategoryOptions,
|
||||
reviewSelectedOtherCategory,
|
||||
reviewInlineDirty,
|
||||
reviewPanelConfidence,
|
||||
reviewRiskSummary,
|
||||
reviewRiskItems,
|
||||
reviewRiskEmpty,
|
||||
reviewDocumentDrawerAvailable,
|
||||
reviewRiskDrawerAvailable,
|
||||
reviewFlowDrawerAvailable,
|
||||
recognizedNarratives,
|
||||
reviewRecognitionNotes,
|
||||
reviewDocumentSummaries,
|
||||
reviewDocumentCount,
|
||||
isReviewDocumentDrawer,
|
||||
isReviewRiskDrawer,
|
||||
isReviewFlowDrawer,
|
||||
reviewDrawerTitle,
|
||||
reviewDocumentDrawerLabel,
|
||||
reviewDocumentDrawerIcon,
|
||||
reviewRiskDrawerLabel,
|
||||
reviewRiskDrawerIcon,
|
||||
reviewFlowDrawerLabel,
|
||||
reviewFlowDrawerIcon,
|
||||
activeReviewDocument,
|
||||
activeReviewDocumentPreview,
|
||||
canPreviewActiveReviewDocument,
|
||||
reviewDocumentDirty,
|
||||
reviewHasUnsavedChanges,
|
||||
setInlineReviewFieldError,
|
||||
clearInlineReviewFieldError,
|
||||
resetReviewDrawerFromPayload,
|
||||
openInlineReviewEditor,
|
||||
closeInlineReviewEditor,
|
||||
commitInlineReviewEditor,
|
||||
selectInlineScene,
|
||||
selectReviewCategory,
|
||||
selectReviewOtherCategory,
|
||||
goReviewDocument,
|
||||
openActiveReviewDocumentPreview,
|
||||
closeDocumentPreview,
|
||||
enforceReviewDrawerAvailability
|
||||
}
|
||||
}
|
||||
335
web/src/views/scripts/useTravelReimbursementSessionState.js
Normal file
335
web/src/views/scripts/useTravelReimbursementSessionState.js
Normal file
@@ -0,0 +1,335 @@
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import { clearUserConversations, fetchLatestConversation } from '../../services/orchestrator.js'
|
||||
import {
|
||||
clearAssistantSessionSnapshot,
|
||||
readAssistantSessionSnapshot,
|
||||
writeAssistantSessionSnapshot
|
||||
} from '../../utils/assistantSessionSnapshot.js'
|
||||
import { buildReviewFilePreviewsFromMessages } from './travelReimbursementAttachmentModel.js'
|
||||
import {
|
||||
SESSION_TYPE_EXPENSE,
|
||||
SESSION_TYPE_KNOWLEDGE,
|
||||
buildInitialInsightFromConversation,
|
||||
buildWelcomeInsight,
|
||||
createWelcomeAssistantMessage,
|
||||
hasMeaningfulSessionMessages,
|
||||
normalizeInitialConversationMessages,
|
||||
normalizeSnapshotMessages,
|
||||
resolveInitialConversationId,
|
||||
resolveInitialDraftClaimId,
|
||||
resolveInitialSessionType,
|
||||
serializeSessionMessages,
|
||||
shouldPreferPersistedSessionState
|
||||
} from './travelReimbursementConversationModel.js'
|
||||
|
||||
export function useTravelReimbursementSessionState({
|
||||
props,
|
||||
currentUser,
|
||||
linkedRequest,
|
||||
toast,
|
||||
composerDraft,
|
||||
uploadDecisionDialogOpen,
|
||||
adjustComposerTextareaHeight,
|
||||
scrollToBottom,
|
||||
getSessionRuntimeRefs = () => ({})
|
||||
}) {
|
||||
function buildConversationSessionState(conversation, fallbackSessionType = SESSION_TYPE_EXPENSE) {
|
||||
const sessionType = resolveInitialSessionType(conversation) || fallbackSessionType
|
||||
const restoredMessages = normalizeInitialConversationMessages(conversation)
|
||||
const initialInsight = buildInitialInsightFromConversation(conversation)
|
||||
const restoredReviewFilePreviews = buildReviewFilePreviewsFromMessages(restoredMessages)
|
||||
|
||||
return {
|
||||
sessionType,
|
||||
messages: restoredMessages.length
|
||||
? restoredMessages
|
||||
: [createWelcomeAssistantMessage(props.entrySource, linkedRequest.value, sessionType, currentUser.value)],
|
||||
conversationId: resolveInitialConversationId(conversation),
|
||||
draftClaimId: resolveInitialDraftClaimId(conversation),
|
||||
currentInsight:
|
||||
initialInsight || buildWelcomeInsight(props.entrySource, linkedRequest.value, sessionType, currentUser.value),
|
||||
reviewFilePreviews: restoredReviewFilePreviews,
|
||||
composerDraft: '',
|
||||
attachedFiles: [],
|
||||
composerFilesExpanded: false,
|
||||
composerUploadIntent: '',
|
||||
insightPanelCollapsed: false
|
||||
}
|
||||
}
|
||||
|
||||
function buildEmptySessionState(sessionType) {
|
||||
return {
|
||||
sessionType,
|
||||
messages: [
|
||||
createWelcomeAssistantMessage(props.entrySource, linkedRequest.value, sessionType, currentUser.value)
|
||||
],
|
||||
conversationId: '',
|
||||
draftClaimId: '',
|
||||
currentInsight: buildWelcomeInsight(
|
||||
props.entrySource,
|
||||
linkedRequest.value,
|
||||
sessionType,
|
||||
currentUser.value
|
||||
),
|
||||
reviewFilePreviews: [],
|
||||
composerDraft: '',
|
||||
attachedFiles: [],
|
||||
composerFilesExpanded: false,
|
||||
composerUploadIntent: '',
|
||||
insightPanelCollapsed: false
|
||||
}
|
||||
}
|
||||
|
||||
function buildPersistedSessionState(snapshot, fallbackSessionType = SESSION_TYPE_EXPENSE) {
|
||||
const state = snapshot?.state && typeof snapshot.state === 'object' ? snapshot.state : null
|
||||
if (!state) {
|
||||
return null
|
||||
}
|
||||
|
||||
const sessionType = String(state.sessionType || snapshot.sessionType || fallbackSessionType || '').trim() || SESSION_TYPE_EXPENSE
|
||||
const restoredMessages = normalizeSnapshotMessages(state.messages)
|
||||
if (
|
||||
!hasMeaningfulSessionMessages(restoredMessages)
|
||||
&& !String(state.conversationId || '').trim()
|
||||
&& !String(state.draftClaimId || '').trim()
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
sessionType,
|
||||
messages: restoredMessages.length
|
||||
? restoredMessages
|
||||
: [createWelcomeAssistantMessage(props.entrySource, linkedRequest.value, sessionType, currentUser.value)],
|
||||
conversationId: String(state.conversationId || '').trim(),
|
||||
draftClaimId: String(state.draftClaimId || '').trim(),
|
||||
currentInsight:
|
||||
state.currentInsight
|
||||
|| buildWelcomeInsight(props.entrySource, linkedRequest.value, sessionType, currentUser.value),
|
||||
reviewFilePreviews: Array.isArray(state.reviewFilePreviews) ? state.reviewFilePreviews : [],
|
||||
composerDraft: String(state.composerDraft || ''),
|
||||
attachedFiles: [],
|
||||
composerFilesExpanded: false,
|
||||
composerUploadIntent: String(state.composerUploadIntent || '').trim(),
|
||||
insightPanelCollapsed: Boolean(state.insightPanelCollapsed)
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCurrentUserId() {
|
||||
const user = currentUser.value || {}
|
||||
return String(user.username || user.name || 'anonymous').trim() || 'anonymous'
|
||||
}
|
||||
|
||||
const initialSessionType = resolveInitialSessionType(props.initialConversation)
|
||||
const conversationInitialState = props.initialConversation
|
||||
? buildConversationSessionState(props.initialConversation, initialSessionType)
|
||||
: buildEmptySessionState(initialSessionType)
|
||||
const canRestorePersistedInitialState =
|
||||
props.entrySource === 'workbench'
|
||||
&& !String(props.initialPrompt || '').trim()
|
||||
&& !props.initialFiles.length
|
||||
const persistedInitialSnapshot = readAssistantSessionSnapshot(resolveCurrentUserId(), initialSessionType)
|
||||
const persistedInitialState = canRestorePersistedInitialState
|
||||
? buildPersistedSessionState(persistedInitialSnapshot, initialSessionType)
|
||||
: null
|
||||
const initialSessionState = canRestorePersistedInitialState && shouldPreferPersistedSessionState(
|
||||
persistedInitialState,
|
||||
persistedInitialSnapshot,
|
||||
props.initialConversation
|
||||
)
|
||||
? persistedInitialState
|
||||
: conversationInitialState
|
||||
|
||||
const activeSessionType = ref(initialSessionState.sessionType)
|
||||
const messages = ref(initialSessionState.messages)
|
||||
const conversationId = ref(initialSessionState.conversationId)
|
||||
const draftClaimId = ref(initialSessionState.draftClaimId)
|
||||
const reviewFilePreviews = ref(initialSessionState.reviewFilePreviews)
|
||||
const sessionSnapshots = ref({
|
||||
[SESSION_TYPE_EXPENSE]: null,
|
||||
[SESSION_TYPE_KNOWLEDGE]: null
|
||||
})
|
||||
const currentInsight = ref(initialSessionState.currentInsight)
|
||||
const composerUploadIntent = ref(String(initialSessionState.composerUploadIntent || '').trim())
|
||||
const insightPanelCollapsed = ref(false)
|
||||
const sessionSwitchBusy = ref(false)
|
||||
let knowledgeSessionResetPromise = Promise.resolve()
|
||||
|
||||
function buildPersistableSessionState(sessionState) {
|
||||
const state = sessionState || captureCurrentSessionState()
|
||||
return {
|
||||
sessionType: state.sessionType || SESSION_TYPE_EXPENSE,
|
||||
messages: serializeSessionMessages(state.messages),
|
||||
conversationId: String(state.conversationId || '').trim(),
|
||||
draftClaimId: String(state.draftClaimId || '').trim(),
|
||||
currentInsight: state.currentInsight || null,
|
||||
reviewFilePreviews: Array.isArray(state.reviewFilePreviews) ? state.reviewFilePreviews : [],
|
||||
composerDraft: String(state.composerDraft || ''),
|
||||
composerUploadIntent: String(state.composerUploadIntent || '').trim(),
|
||||
insightPanelCollapsed: Boolean(state.insightPanelCollapsed)
|
||||
}
|
||||
}
|
||||
|
||||
function persistSessionState(sessionState = null) {
|
||||
const state = sessionState || captureCurrentSessionState()
|
||||
const persistedState = buildPersistableSessionState(state)
|
||||
const meaningful = Boolean(
|
||||
String(persistedState.conversationId || '').trim()
|
||||
|| String(persistedState.draftClaimId || '').trim()
|
||||
|| hasMeaningfulSessionMessages(persistedState.messages)
|
||||
|| String(persistedState.composerDraft || '').trim()
|
||||
)
|
||||
|
||||
if (!meaningful) {
|
||||
clearAssistantSessionSnapshot(resolveCurrentUserId(), persistedState.sessionType)
|
||||
return
|
||||
}
|
||||
|
||||
writeAssistantSessionSnapshot(resolveCurrentUserId(), persistedState.sessionType, persistedState)
|
||||
}
|
||||
|
||||
function captureCurrentSessionState() {
|
||||
const runtimeRefs = getSessionRuntimeRefs()
|
||||
return {
|
||||
sessionType: activeSessionType.value,
|
||||
messages: messages.value,
|
||||
conversationId: conversationId.value,
|
||||
draftClaimId: draftClaimId.value,
|
||||
currentInsight: currentInsight.value,
|
||||
reviewFilePreviews: reviewFilePreviews.value,
|
||||
composerDraft: composerDraft.value,
|
||||
attachedFiles: runtimeRefs.attachedFiles?.value ?? [],
|
||||
composerFilesExpanded: runtimeRefs.composerFilesExpanded?.value ?? false,
|
||||
composerUploadIntent: composerUploadIntent.value,
|
||||
insightPanelCollapsed: insightPanelCollapsed.value
|
||||
}
|
||||
}
|
||||
|
||||
function applySessionState(sessionState) {
|
||||
const runtimeRefs = getSessionRuntimeRefs()
|
||||
const nextState = sessionState || buildEmptySessionState(activeSessionType.value)
|
||||
activeSessionType.value = nextState.sessionType || SESSION_TYPE_EXPENSE
|
||||
messages.value = Array.isArray(nextState.messages) && nextState.messages.length
|
||||
? nextState.messages
|
||||
: [
|
||||
createWelcomeAssistantMessage(
|
||||
props.entrySource,
|
||||
linkedRequest.value,
|
||||
activeSessionType.value,
|
||||
currentUser.value
|
||||
)
|
||||
]
|
||||
conversationId.value = String(nextState.conversationId || '').trim()
|
||||
draftClaimId.value = String(nextState.draftClaimId || '').trim()
|
||||
currentInsight.value =
|
||||
nextState.currentInsight
|
||||
|| buildWelcomeInsight(
|
||||
props.entrySource,
|
||||
linkedRequest.value,
|
||||
activeSessionType.value,
|
||||
currentUser.value
|
||||
)
|
||||
reviewFilePreviews.value = Array.isArray(nextState.reviewFilePreviews) ? nextState.reviewFilePreviews : []
|
||||
composerDraft.value = String(nextState.composerDraft || '')
|
||||
if (runtimeRefs.attachedFiles) {
|
||||
runtimeRefs.attachedFiles.value = Array.isArray(nextState.attachedFiles) ? nextState.attachedFiles : []
|
||||
}
|
||||
if (runtimeRefs.composerFilesExpanded) {
|
||||
runtimeRefs.composerFilesExpanded.value = Boolean(nextState.composerFilesExpanded)
|
||||
}
|
||||
composerUploadIntent.value = String(nextState.composerUploadIntent || '').trim()
|
||||
insightPanelCollapsed.value = Boolean(nextState.insightPanelCollapsed)
|
||||
uploadDecisionDialogOpen.value = false
|
||||
nextTick(() => {
|
||||
adjustComposerTextareaHeight()
|
||||
scrollToBottom()
|
||||
})
|
||||
}
|
||||
|
||||
async function loadLatestSessionState(targetSessionType) {
|
||||
const payload = await fetchLatestConversation(resolveCurrentUserId(), targetSessionType, {
|
||||
preferRecoverable: targetSessionType === SESSION_TYPE_EXPENSE
|
||||
})
|
||||
if (payload?.found && payload.conversation) {
|
||||
return buildConversationSessionState(payload.conversation, targetSessionType)
|
||||
}
|
||||
return buildEmptySessionState(targetSessionType)
|
||||
}
|
||||
|
||||
function resetKnowledgeSessionSnapshot() {
|
||||
const emptyKnowledgeState = buildEmptySessionState(SESSION_TYPE_KNOWLEDGE)
|
||||
sessionSnapshots.value[SESSION_TYPE_KNOWLEDGE] = emptyKnowledgeState
|
||||
|
||||
if (activeSessionType.value === SESSION_TYPE_KNOWLEDGE) {
|
||||
applySessionState(emptyKnowledgeState)
|
||||
}
|
||||
}
|
||||
|
||||
function clearKnowledgeSessionOnEntry() {
|
||||
resetKnowledgeSessionSnapshot()
|
||||
knowledgeSessionResetPromise = clearUserConversations(resolveCurrentUserId(), SESSION_TYPE_KNOWLEDGE)
|
||||
.catch((error) => {
|
||||
console.warn('Failed to clear knowledge session on entry:', error)
|
||||
})
|
||||
.finally(() => {
|
||||
resetKnowledgeSessionSnapshot()
|
||||
})
|
||||
return knowledgeSessionResetPromise
|
||||
}
|
||||
|
||||
async function switchSessionType(targetSessionType) {
|
||||
const normalizedTarget = String(targetSessionType || '').trim() || SESSION_TYPE_EXPENSE
|
||||
if (normalizedTarget === activeSessionType.value || sessionSwitchBusy.value) {
|
||||
return
|
||||
}
|
||||
|
||||
sessionSnapshots.value[activeSessionType.value] = captureCurrentSessionState()
|
||||
if (sessionSnapshots.value[normalizedTarget]) {
|
||||
applySessionState(sessionSnapshots.value[normalizedTarget])
|
||||
return
|
||||
}
|
||||
|
||||
sessionSwitchBusy.value = true
|
||||
try {
|
||||
const nextState = await loadLatestSessionState(normalizedTarget)
|
||||
sessionSnapshots.value[normalizedTarget] = nextState
|
||||
applySessionState(nextState)
|
||||
} catch (error) {
|
||||
const emptyState = buildEmptySessionState(normalizedTarget)
|
||||
sessionSnapshots.value[normalizedTarget] = emptyState
|
||||
applySessionState(emptyState)
|
||||
toast(error?.message || '?????????????????')
|
||||
} finally {
|
||||
sessionSwitchBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
sessionSnapshots.value[initialSessionState.sessionType] = captureCurrentSessionState()
|
||||
|
||||
return {
|
||||
activeSessionType,
|
||||
messages,
|
||||
conversationId,
|
||||
draftClaimId,
|
||||
sessionSnapshots,
|
||||
currentInsight,
|
||||
reviewFilePreviews,
|
||||
composerUploadIntent,
|
||||
insightPanelCollapsed,
|
||||
sessionSwitchBusy,
|
||||
initialSessionState,
|
||||
buildConversationSessionState,
|
||||
buildEmptySessionState,
|
||||
buildPersistedSessionState,
|
||||
resolveCurrentUserId,
|
||||
buildPersistableSessionState,
|
||||
persistSessionState,
|
||||
captureCurrentSessionState,
|
||||
applySessionState,
|
||||
loadLatestSessionState,
|
||||
resetKnowledgeSessionSnapshot,
|
||||
clearKnowledgeSessionOnEntry,
|
||||
switchSessionType
|
||||
}
|
||||
}
|
||||
474
web/src/views/scripts/useTravelReimbursementSubmitComposer.js
Normal file
474
web/src/views/scripts/useTravelReimbursementSubmitComposer.js
Normal file
@@ -0,0 +1,474 @@
|
||||
export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
const {
|
||||
MAX_ATTACHMENTS,
|
||||
activeReviewPayload,
|
||||
activeSessionType,
|
||||
adjustComposerTextareaHeight,
|
||||
attachedFiles,
|
||||
buildAgentInsight,
|
||||
buildClientTimeContext,
|
||||
buildComposerBusinessTimeContext,
|
||||
buildComposerFilePreviews,
|
||||
buildDraftAssociationQueryPayload,
|
||||
buildErrorInsight,
|
||||
buildExpenseIntentConfirmationActions,
|
||||
buildExpenseIntentConfirmationMessage,
|
||||
buildExpenseSceneSelectionActions,
|
||||
buildExpenseSceneSelectionMessage,
|
||||
buildMessageMeta,
|
||||
buildOcrDocumentsFromReviewPayload,
|
||||
buildOcrFilePreviews,
|
||||
buildOcrSummary,
|
||||
buildOcrSummaryFromDocuments,
|
||||
buildReviewFormContextFromPayload,
|
||||
clearAttachedFiles,
|
||||
clearFlowSimulationTimers,
|
||||
completeFlowResult,
|
||||
completeFlowStep,
|
||||
composerBusinessTimeDraftTouched,
|
||||
composerBusinessTimeTags,
|
||||
composerDraft,
|
||||
composerUploadIntent,
|
||||
conversationId,
|
||||
createMessage,
|
||||
currentInsight,
|
||||
currentUser,
|
||||
draftClaimId,
|
||||
extractReviewAttachmentNames,
|
||||
failCurrentFlowStep,
|
||||
fetchExpenseClaims,
|
||||
fileInputRef,
|
||||
flowRunId,
|
||||
isKnowledgeSession,
|
||||
linkedRequest,
|
||||
mergeBusinessTimeIntoExtraContext,
|
||||
mergeFilePreviews,
|
||||
mergeFilesWithLimit,
|
||||
mergeUploadAttachmentNames,
|
||||
mergeUploadOcrDocuments,
|
||||
messages,
|
||||
nextTick,
|
||||
normalizeExpenseQueryPayload,
|
||||
normalizeOcrDocuments,
|
||||
persistSessionState,
|
||||
props,
|
||||
recognizeOcrFiles,
|
||||
refreshFlowRunDetail,
|
||||
rememberFilePreviews,
|
||||
replaceMessage,
|
||||
resetFlowRun,
|
||||
resolveComposerSubmitText,
|
||||
reviewInlineForm,
|
||||
runOrchestrator,
|
||||
scrollToBottom,
|
||||
sessionSwitchBusy,
|
||||
shouldRequestExpenseIntentConfirmation,
|
||||
shouldRequestExpenseSceneSelection,
|
||||
startExpenseClaimDraftFlowStep,
|
||||
startExpenseIntentConfirmationFlowPreview,
|
||||
startExpenseSceneSelectionFlowPreview,
|
||||
startFlowStep,
|
||||
startSemanticFlowPreview,
|
||||
submitting,
|
||||
syncComposerFilesToDraft,
|
||||
uploadDecisionDialogOpen,
|
||||
toast
|
||||
} = ctx
|
||||
function buildBackendMessage(rawText, fileNames, ocrSummary = '') {
|
||||
const parts = []
|
||||
const normalizedText = String(rawText || '').trim()
|
||||
|
||||
if (normalizedText) {
|
||||
parts.push(normalizedText)
|
||||
} else if (fileNames.length) {
|
||||
parts.push(
|
||||
isKnowledgeSession.value
|
||||
? `我上传了 ${fileNames.length} 份附件,请结合附件名称回答财务相关问题。`
|
||||
: `我上传了 ${fileNames.length} 份票据,请结合附件名称给出报销建议并整理待核对信息。`
|
||||
)
|
||||
}
|
||||
|
||||
if (fileNames.length) {
|
||||
parts.push(`附件名称:${fileNames.join('、')}`)
|
||||
}
|
||||
|
||||
if (ocrSummary) {
|
||||
parts.push(`OCR摘要:${ocrSummary}`)
|
||||
}
|
||||
|
||||
if (props.entrySource === 'detail' && linkedRequest.value?.id) {
|
||||
parts.push(`关联单号:${linkedRequest.value.id}`)
|
||||
}
|
||||
|
||||
return parts.join('\n')
|
||||
}
|
||||
|
||||
async function submitComposer(options = {}) {
|
||||
if (submitting.value || sessionSwitchBusy.value) return null
|
||||
|
||||
const rawText = resolveComposerSubmitText(options.rawText).trim()
|
||||
const systemGenerated = Boolean(options.systemGenerated)
|
||||
const resolvedUploadDisposition =
|
||||
String(options.uploadDisposition || '').trim() ||
|
||||
(composerUploadIntent.value === 'continue_existing' ? 'continue_existing' : '')
|
||||
const normalizedFiles = isKnowledgeSession.value ? [] : Array.from(options.files ?? attachedFiles.value)
|
||||
const fileMergeResult = mergeFilesWithLimit([], normalizedFiles, MAX_ATTACHMENTS)
|
||||
const files = fileMergeResult.files
|
||||
if (fileMergeResult.overflowCount > 0) {
|
||||
toast(`一次最多上传 ${MAX_ATTACHMENTS} 份附件,已保留前 ${MAX_ATTACHMENTS} 份。`)
|
||||
}
|
||||
if (!rawText && !files.length) return
|
||||
const fileNames = files.map((file) => file.name)
|
||||
|
||||
const initialExtraContext = options.extraContext && typeof options.extraContext === 'object'
|
||||
? { ...options.extraContext }
|
||||
: {}
|
||||
const selectedBusinessTimeContext = isKnowledgeSession.value ? null : buildComposerBusinessTimeContext()
|
||||
const extraContext = isKnowledgeSession.value
|
||||
? initialExtraContext
|
||||
: mergeBusinessTimeIntoExtraContext(initialExtraContext, selectedBusinessTimeContext)
|
||||
const reviewAction = String(extraContext.review_action || '').trim()
|
||||
const hasSelectedExpenseType = Boolean(
|
||||
extraContext.expense_scene_selection ||
|
||||
String(extraContext.review_form_values?.expense_type || extraContext.review_form_values?.reimbursement_type || '').trim()
|
||||
)
|
||||
const hasConfirmedExpenseIntent = Boolean(extraContext.expense_intent_confirmed)
|
||||
const waitForExpenseIntentConfirmation = shouldRequestExpenseIntentConfirmation(rawText, {
|
||||
sessionType: activeSessionType.value,
|
||||
attachmentCount: files.length,
|
||||
reviewAction,
|
||||
hasSelectedExpenseType,
|
||||
hasConfirmedExpenseIntent
|
||||
})
|
||||
const waitForExpenseSceneSelection = !waitForExpenseIntentConfirmation && shouldRequestExpenseSceneSelection(rawText, {
|
||||
sessionType: activeSessionType.value,
|
||||
attachmentCount: files.length,
|
||||
reviewAction,
|
||||
hasSelectedExpenseType
|
||||
})
|
||||
const reviewAttachmentNames = extractReviewAttachmentNames(activeReviewPayload.value)
|
||||
const hasExistingDocumentEvent =
|
||||
Boolean(String(draftClaimId.value || '').trim()) || reviewAttachmentNames.length > 0
|
||||
const userText =
|
||||
String(options.userText || '').trim() ||
|
||||
rawText ||
|
||||
(isKnowledgeSession.value
|
||||
? `我上传了 ${fileNames.length} 份附件,请帮我回答相关财务问题。`
|
||||
: resolvedUploadDisposition === 'continue_existing'
|
||||
? `继续上传 ${fileNames.length} 份票据,并归集到当前单据。`
|
||||
: resolvedUploadDisposition === 'new_document'
|
||||
? `新上传 ${fileNames.length} 份票据,请单独建立报销单。`
|
||||
: `我上传了 ${fileNames.length} 份票据,请帮我识别并整理报销建议。`)
|
||||
|
||||
if (
|
||||
!isKnowledgeSession.value &&
|
||||
files.length &&
|
||||
hasExistingDocumentEvent &&
|
||||
!resolvedUploadDisposition &&
|
||||
!options.skipUploadDecisionPrompt &&
|
||||
!reviewAction
|
||||
) {
|
||||
uploadDecisionDialogOpen.value = true
|
||||
return null
|
||||
}
|
||||
|
||||
if (
|
||||
!isKnowledgeSession.value &&
|
||||
files.length &&
|
||||
!hasExistingDocumentEvent &&
|
||||
!resolvedUploadDisposition &&
|
||||
!options.skipDraftAssociationPrompt &&
|
||||
!reviewAction
|
||||
) {
|
||||
try {
|
||||
const claims = await fetchExpenseClaims()
|
||||
const queryPayload = buildDraftAssociationQueryPayload(claims)
|
||||
if (queryPayload?.records?.length) {
|
||||
resetFlowRun()
|
||||
if (!options.skipUserMessage) {
|
||||
messages.value.push(createMessage('user', userText, fileNames))
|
||||
}
|
||||
messages.value.push(createMessage(
|
||||
'assistant',
|
||||
`我找到 ${queryPayload.records.length} 张可关联的草稿/待补单据。请先选择这批附件要归集到哪张单据,我再开始识别附件。`,
|
||||
[],
|
||||
{
|
||||
meta: ['等待选择关联单据'],
|
||||
queryPayload
|
||||
}
|
||||
))
|
||||
composerDraft.value = ''
|
||||
composerBusinessTimeTags.value = []
|
||||
composerBusinessTimeDraftTouched.value = false
|
||||
nextTick(() => {
|
||||
adjustComposerTextareaHeight()
|
||||
scrollToBottom()
|
||||
})
|
||||
persistSessionState()
|
||||
return null
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to load draft claims before attachment recognition:', error)
|
||||
toast(error?.message || '查询可关联草稿失败,已继续按新单据识别。')
|
||||
}
|
||||
}
|
||||
|
||||
resetFlowRun()
|
||||
if (rawText && !reviewAction) {
|
||||
startFlowStep('intent', '正在识别业务意图...')
|
||||
if (waitForExpenseIntentConfirmation) {
|
||||
startExpenseIntentConfirmationFlowPreview(rawText)
|
||||
} else if (waitForExpenseSceneSelection) {
|
||||
startExpenseSceneSelectionFlowPreview(rawText)
|
||||
} else {
|
||||
startSemanticFlowPreview(rawText, { attachmentCount: files.length })
|
||||
}
|
||||
}
|
||||
|
||||
const filePreviews = buildComposerFilePreviews(files)
|
||||
rememberFilePreviews(filePreviews)
|
||||
|
||||
// 只有在非静默模式下才添加用户消息
|
||||
if (!options.skipUserMessage) {
|
||||
messages.value.push(createMessage('user', userText, fileNames))
|
||||
}
|
||||
|
||||
if (waitForExpenseIntentConfirmation) {
|
||||
messages.value.push(createMessage('assistant', buildExpenseIntentConfirmationMessage(rawText), [], {
|
||||
meta: ['等待确认意图'],
|
||||
suggestedActions: buildExpenseIntentConfirmationActions(rawText)
|
||||
}))
|
||||
composerDraft.value = ''
|
||||
composerBusinessTimeTags.value = []
|
||||
composerBusinessTimeDraftTouched.value = false
|
||||
clearAttachedFiles()
|
||||
if (fileInputRef.value) {
|
||||
fileInputRef.value.value = ''
|
||||
}
|
||||
nextTick(() => {
|
||||
adjustComposerTextareaHeight()
|
||||
scrollToBottom()
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
if (waitForExpenseSceneSelection) {
|
||||
messages.value.push(createMessage('assistant', buildExpenseSceneSelectionMessage(rawText), [], {
|
||||
meta: ['等待选择场景'],
|
||||
suggestedActions: buildExpenseSceneSelectionActions(rawText)
|
||||
}))
|
||||
composerDraft.value = ''
|
||||
composerBusinessTimeTags.value = []
|
||||
composerBusinessTimeDraftTouched.value = false
|
||||
clearAttachedFiles()
|
||||
if (fileInputRef.value) {
|
||||
fileInputRef.value.value = ''
|
||||
}
|
||||
nextTick(() => {
|
||||
adjustComposerTextareaHeight()
|
||||
scrollToBottom()
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
const pendingMessage = createMessage(
|
||||
'assistant',
|
||||
options.pendingText || (
|
||||
isKnowledgeSession.value
|
||||
? '正在整理财务知识答案...'
|
||||
: '正在识别并整理右侧核对信息...'
|
||||
),
|
||||
[],
|
||||
{
|
||||
meta: ['处理中']
|
||||
}
|
||||
)
|
||||
messages.value.push(pendingMessage)
|
||||
|
||||
composerDraft.value = ''
|
||||
composerBusinessTimeTags.value = []
|
||||
composerBusinessTimeDraftTouched.value = false
|
||||
clearAttachedFiles()
|
||||
if (fileInputRef.value) {
|
||||
fileInputRef.value.value = ''
|
||||
}
|
||||
nextTick(adjustComposerTextareaHeight)
|
||||
|
||||
submitting.value = true
|
||||
nextTick(scrollToBottom)
|
||||
|
||||
let responsePayload = null
|
||||
|
||||
try {
|
||||
const user = currentUser.value || {}
|
||||
let ocrPayload = null
|
||||
let ocrSummary = ''
|
||||
let ocrDocuments = []
|
||||
let ocrFilePreviews = []
|
||||
|
||||
if (files.length) {
|
||||
const ocrStartedAt = Date.now()
|
||||
startFlowStep('ocr', { detail: `正在识别 ${files.length} 份附件...`, startedAt: ocrStartedAt })
|
||||
try {
|
||||
ocrPayload = await recognizeOcrFiles(files)
|
||||
ocrSummary = buildOcrSummary(ocrPayload)
|
||||
ocrDocuments = normalizeOcrDocuments(ocrPayload)
|
||||
ocrFilePreviews = buildOcrFilePreviews(ocrPayload)
|
||||
rememberFilePreviews(ocrFilePreviews)
|
||||
completeFlowStep('ocr', `识别到 ${ocrDocuments.length || files.length} 张票据`, Date.now() - ocrStartedAt)
|
||||
} catch (error) {
|
||||
console.warn('OCR request failed:', error)
|
||||
completeFlowStep('ocr', 'OCR识别失败,已继续使用附件名称', Date.now() - ocrStartedAt)
|
||||
}
|
||||
}
|
||||
|
||||
let effectiveFileNames = [...fileNames]
|
||||
let effectiveOcrDocuments = [...ocrDocuments]
|
||||
let effectiveOcrSummary = ocrSummary
|
||||
|
||||
if (resolvedUploadDisposition === 'continue_existing') {
|
||||
extraContext.review_action = 'link_to_existing_draft'
|
||||
const inheritedReviewContext = buildReviewFormContextFromPayload(
|
||||
activeReviewPayload.value,
|
||||
reviewInlineForm.value
|
||||
)
|
||||
if (inheritedReviewContext.review_form_values) {
|
||||
extraContext.review_form_values = {
|
||||
...inheritedReviewContext.review_form_values,
|
||||
...(extraContext.review_form_values && typeof extraContext.review_form_values === 'object'
|
||||
? extraContext.review_form_values
|
||||
: {})
|
||||
}
|
||||
}
|
||||
if (inheritedReviewContext.business_time_context && !extraContext.business_time_context) {
|
||||
extraContext.business_time_context = inheritedReviewContext.business_time_context
|
||||
}
|
||||
effectiveFileNames = mergeUploadAttachmentNames(reviewAttachmentNames, fileNames)
|
||||
effectiveOcrDocuments = mergeUploadOcrDocuments(
|
||||
buildOcrDocumentsFromReviewPayload(activeReviewPayload.value),
|
||||
ocrDocuments
|
||||
)
|
||||
effectiveOcrSummary = buildOcrSummaryFromDocuments(effectiveOcrDocuments)
|
||||
} else if (resolvedUploadDisposition === 'new_document') {
|
||||
extraContext.review_action = 'create_new_claim_from_documents'
|
||||
}
|
||||
|
||||
startExpenseClaimDraftFlowStep(String(extraContext.review_action || '').trim(), {
|
||||
attachmentCount: effectiveFileNames.length,
|
||||
waitForSceneSelection: waitForExpenseSceneSelection
|
||||
})
|
||||
|
||||
const backendMessage = buildBackendMessage(rawText, effectiveFileNames, effectiveOcrSummary)
|
||||
const payload = await runOrchestrator(
|
||||
{
|
||||
source: 'user_message',
|
||||
user_id: user.username || user.name || 'anonymous',
|
||||
conversation_id: conversationId.value || null,
|
||||
message: backendMessage,
|
||||
context_json: {
|
||||
role_codes: Array.isArray(user.roleCodes) ? user.roleCodes : [],
|
||||
is_admin: Boolean(user.isAdmin),
|
||||
name: user.name || '',
|
||||
role: user.role || '',
|
||||
department: user.department || user.departmentName || '',
|
||||
department_name: user.department || user.departmentName || '',
|
||||
position: user.position || '',
|
||||
grade: user.grade || '',
|
||||
employee_no: user.employeeNo || user.employee_no || '',
|
||||
manager_name: user.managerName || user.manager_name || '',
|
||||
employee_location: user.location || '',
|
||||
cost_center: user.costCenter || user.cost_center || '',
|
||||
finance_owner_name: user.financeOwnerName || user.finance_owner_name || '',
|
||||
employee_risk_profile: user.riskProfile && typeof user.riskProfile === 'object' ? user.riskProfile : {},
|
||||
...buildClientTimeContext(),
|
||||
session_type: activeSessionType.value,
|
||||
entry_source: props.entrySource,
|
||||
user_input_text: systemGenerated ? '' : rawText,
|
||||
attachment_names: effectiveFileNames,
|
||||
attachment_count: effectiveFileNames.length,
|
||||
draft_claim_id: isKnowledgeSession.value ? undefined : draftClaimId.value || undefined,
|
||||
ocr_summary: effectiveOcrSummary,
|
||||
ocr_documents: effectiveOcrDocuments,
|
||||
...(linkedRequest.value && !isKnowledgeSession.value ? { request_context: linkedRequest.value } : {}),
|
||||
...extraContext
|
||||
}
|
||||
},
|
||||
isKnowledgeSession.value
|
||||
? {
|
||||
timeoutMs: 18000,
|
||||
timeoutMessage: '知识问答整理超时,已停止等待。建议缩小问题范围或稍后重试。'
|
||||
}
|
||||
: {}
|
||||
)
|
||||
responsePayload = payload
|
||||
flowRunId.value = String(payload?.run_id || '').trim()
|
||||
let flowRunDetail = null
|
||||
if (flowRunId.value) {
|
||||
flowRunDetail = await refreshFlowRunDetail()
|
||||
}
|
||||
|
||||
conversationId.value = String(payload?.conversation_id || '').trim() || conversationId.value
|
||||
draftClaimId.value =
|
||||
isKnowledgeSession.value
|
||||
? ''
|
||||
: String(payload?.result?.draft_payload?.claim_id || '').trim() || draftClaimId.value
|
||||
|
||||
replaceMessage(
|
||||
pendingMessage.id,
|
||||
createMessage('assistant', payload?.result?.answer || payload?.result?.message || '智能体已完成处理。', [], {
|
||||
meta: buildMessageMeta(payload, effectiveFileNames),
|
||||
citations: Array.isArray(payload?.result?.citations) ? payload.result.citations : [],
|
||||
suggestedActions: Array.isArray(payload?.result?.suggested_actions)
|
||||
? payload.result.suggested_actions
|
||||
: [],
|
||||
queryPayload: normalizeExpenseQueryPayload(payload?.result?.query_payload),
|
||||
draftPayload: payload?.result?.draft_payload || null,
|
||||
reviewPayload: payload?.result?.review_payload || null,
|
||||
riskFlags: Array.isArray(payload?.result?.risk_flags) ? payload.result.risk_flags : []
|
||||
})
|
||||
)
|
||||
currentInsight.value = buildAgentInsight(
|
||||
payload,
|
||||
effectiveFileNames,
|
||||
mergeFilePreviews(filePreviews, ocrFilePreviews)
|
||||
)
|
||||
completeFlowResult(payload, flowRunDetail)
|
||||
|
||||
const resolvedDraftClaimId = String(payload?.result?.draft_payload?.claim_id || draftClaimId.value || '').trim()
|
||||
if (!isKnowledgeSession.value && resolvedDraftClaimId && files.length) {
|
||||
try {
|
||||
await syncComposerFilesToDraft(resolvedDraftClaimId, files)
|
||||
} catch (error) {
|
||||
console.warn('Failed to persist composer attachments to draft claim:', error)
|
||||
toast(error?.message || '票据已识别,但附件原件保存失败,请重试上传。')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
clearFlowSimulationTimers()
|
||||
failCurrentFlowStep(error)
|
||||
replaceMessage(
|
||||
pendingMessage.id,
|
||||
createMessage(
|
||||
'assistant',
|
||||
error?.message || '无法连接后端 Orchestrator,请稍后重试。',
|
||||
[],
|
||||
{
|
||||
meta: ['调用失败']
|
||||
}
|
||||
)
|
||||
)
|
||||
currentInsight.value = buildErrorInsight(error, fileNames)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
composerUploadIntent.value = ''
|
||||
nextTick(scrollToBottom)
|
||||
}
|
||||
|
||||
return responsePayload
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
submitComposerInternal: submitComposer
|
||||
}
|
||||
}
|
||||
@@ -135,6 +135,7 @@ async function testRejectsWithCustomTimeoutMessage() {
|
||||
}),
|
||||
(error) => {
|
||||
assert.equal(error.message, '知识问答整理超时,已停止等待。')
|
||||
assert.equal(error.code, 'REQUEST_TIMEOUT')
|
||||
return true
|
||||
}
|
||||
)
|
||||
|
||||
43
web/tests/backend-health-timeout.test.mjs
Normal file
43
web/tests/backend-health-timeout.test.mjs
Normal file
@@ -0,0 +1,43 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { checkBackendHealth, useBackendHealth } from '../src/composables/useBackendHealth.js'
|
||||
|
||||
const routerScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/router/index.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
test('app route guard allows stale healthy state when health check times out', () => {
|
||||
assert.match(routerScript, /checkBackendHealth\(\{\s*allowStaleOnTimeout:\s*true\s*\}\)/)
|
||||
})
|
||||
|
||||
test('backend health timeout does not block app rendering when stale fallback is allowed', async () => {
|
||||
const originalFetch = global.fetch
|
||||
|
||||
global.fetch = async (_url, options = {}) =>
|
||||
new Promise((_, reject) => {
|
||||
options.signal.addEventListener('abort', () => {
|
||||
const error = new Error('aborted')
|
||||
error.name = 'AbortError'
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
|
||||
try {
|
||||
const ok = await checkBackendHealth({
|
||||
force: true,
|
||||
allowStaleOnTimeout: true,
|
||||
timeoutMs: 1
|
||||
})
|
||||
const { backendHealthy, backendError } = useBackendHealth()
|
||||
|
||||
assert.equal(ok, true)
|
||||
assert.equal(backendHealthy.value, true)
|
||||
assert.match(backendError.value, /健康检查超时|health/i)
|
||||
} finally {
|
||||
global.fetch = originalFetch
|
||||
}
|
||||
})
|
||||
@@ -39,7 +39,7 @@ test('semantic intent detail includes recognized expense type', () => {
|
||||
}
|
||||
]
|
||||
}),
|
||||
'已识别为报销场景,当前目标是草稿生成,费用类型为交通费'
|
||||
'已识别为报销场景,当前目标是信息核对,费用类型为交通费'
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -15,6 +15,18 @@ const reimbursementService = readFileSync(
|
||||
fileURLToPath(new URL('../src/services/reimbursements.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const reviewActionsScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementReviewActions.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const submitComposerScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const attachmentsScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementAttachments.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
test('review drawer tools expose the default review tab before conditional document and risk tabs', () => {
|
||||
assert.match(createViewTemplate, /title="报销识别核对"[\s\S]*@click="switchToReviewOverviewDrawer"/)
|
||||
@@ -143,3 +155,36 @@ test('review drawer save action is disabled while receipt recognition is submitt
|
||||
/function saveInlineReviewChanges\(\) \{[\s\S]*\|\| submitting\.value[\s\S]*\|\| sessionSwitchBusy\.value[\s\S]*\) return/
|
||||
)
|
||||
})
|
||||
|
||||
test('draft creation waits for composer attachments to be persisted before leaving submit state', () => {
|
||||
assert.match(
|
||||
submitComposerScript,
|
||||
/try \{\s*await syncComposerFilesToDraft\(resolvedDraftClaimId, files\)\s*\} catch \(error\) \{/s
|
||||
)
|
||||
assert.doesNotMatch(
|
||||
submitComposerScript,
|
||||
/syncComposerFilesToDraft\(resolvedDraftClaimId, files\)\.catch/
|
||||
)
|
||||
assert.ok(
|
||||
submitComposerScript.indexOf('await syncComposerFilesToDraft(resolvedDraftClaimId, files)') <
|
||||
submitComposerScript.indexOf('submitting.value = false'),
|
||||
'attachment persistence should finish before submit state is cleared'
|
||||
)
|
||||
assert.match(attachmentsScript, /function normalizeAttachmentMatchName\(value\)/)
|
||||
assert.match(attachmentsScript, /const normalizedMatchBuckets = new Map\(\)/)
|
||||
assert.match(
|
||||
attachmentsScript,
|
||||
/const targetItem = nextExactMatch \|\| nextNormalizedMatch \|\| fallbackMatch/
|
||||
)
|
||||
})
|
||||
|
||||
test('review summary renders markdown and save draft relies on backend response only', () => {
|
||||
assert.match(
|
||||
createViewTemplate,
|
||||
/message\.text && message\.role === 'assistant' && message\.reviewPayload[\s\S]*v-html="renderMarkdown\(message\.text\)"/
|
||||
)
|
||||
assert.doesNotMatch(
|
||||
reviewActionsScript,
|
||||
/messages\.value\.push\(\s*createMessage\('assistant', actionConfig\.successMessage/
|
||||
)
|
||||
})
|
||||
|
||||
@@ -6,8 +6,14 @@ import { fileURLToPath } from 'node:url'
|
||||
import {
|
||||
buildAiAdviceViewModel,
|
||||
buildAttachmentInsightViewModel,
|
||||
buildAttachmentRiskCards
|
||||
buildAttachmentRiskCards,
|
||||
extractRiskTagsFromText,
|
||||
resolveRiskTags,
|
||||
resolveRiskTagTone
|
||||
} from '../src/views/scripts/travelRequestDetailInsights.js'
|
||||
import {
|
||||
buildDraftBlockingIssues
|
||||
} from '../src/views/scripts/travelRequestDetailExpenseModel.js'
|
||||
|
||||
const detailViewTemplate = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/TravelRequestDetailView.vue', import.meta.url)),
|
||||
@@ -101,6 +107,24 @@ test('AI advice card splits every attachment risk point with basis and suggestio
|
||||
assert.ok(advice.riskCards.every((card) => card.suggestion.includes('费用项目调整为交通费')))
|
||||
})
|
||||
|
||||
test('risk cards carry severity and domain tags for statistics', () => {
|
||||
const hotelRisk = {
|
||||
tone: 'high',
|
||||
title: '住宿超标待说明',
|
||||
risk: '住宿标准:北京酒店 800 元/晚超出报销标准。'
|
||||
}
|
||||
const trafficRisk = {
|
||||
tone: 'medium',
|
||||
title: '交通票据提醒',
|
||||
risk: '火车票说明格式待调整。'
|
||||
}
|
||||
|
||||
assert.deepEqual(resolveRiskTags(hotelRisk), ['#high_risk', '#hotel'])
|
||||
assert.deepEqual(resolveRiskTags(trafficRisk), ['#middle_risk', '#traffic'])
|
||||
assert.equal(resolveRiskTagTone('#hotel'), 'hotel')
|
||||
assert.deepEqual(extractRiskTagsFromText('超标说明:#high_risk #hotel 原因'), ['#high_risk', '#hotel'])
|
||||
})
|
||||
|
||||
test('AI advice splits claim attachment risk flags into specific points', () => {
|
||||
const riskCards = buildAttachmentRiskCards({
|
||||
claimRiskFlags: [
|
||||
@@ -196,13 +220,23 @@ test('AI advice template renders grouped section titles with completion before r
|
||||
|
||||
test('AI advice risk section uses compact card styling hooks', () => {
|
||||
assert.match(detailViewTemplate, /class="\['risk-advice-card', card\.tone\]"/)
|
||||
assert.match(detailViewTemplate, /v-if="card\.tags\?\.length" class="risk-card-tag-list"/)
|
||||
assert.match(detailViewStyle, /\.validation-card \{\s*border: 1px solid #e5e7eb;/)
|
||||
assert.match(detailViewStyle, /\.validation-section--risk \.risk-advice-card \{\s*display: grid;\s*gap: 8px;\s*padding: 12px 12px 11px;/)
|
||||
assert.match(detailViewStyle, /\.validation-section--risk \.risk-advice-card\.low/)
|
||||
assert.match(detailViewStyle, /\.risk-advice-card\.low/)
|
||||
assert.match(detailViewStyle, /\.risk-note-tag\.high/)
|
||||
assert.match(detailViewStyle, /\.risk-note-tag\.hotel/)
|
||||
assert.match(detailViewStyle, /\.validation-section--risk \.risk-advice-meta ul,\s*\.validation-section--risk \.risk-advice-meta p \{\s*margin: 0;/)
|
||||
})
|
||||
|
||||
test('expense rows show a major-risk warning icon before time', () => {
|
||||
assert.match(detailViewTemplate, /'has-major-risk': isMajorExpenseRisk\(item\)/)
|
||||
assert.match(detailViewTemplate, /class="mdi mdi-alert expense-risk-indicator"/)
|
||||
assert.match(detailViewStyle, /\.expense-risk-indicator \{/)
|
||||
assert.match(detailViewScript, /function isMajorExpenseRisk\(item\)/)
|
||||
})
|
||||
|
||||
test('AI advice shows only the latest manual return while preserving return count context', () => {
|
||||
const riskCards = buildAttachmentRiskCards({
|
||||
claimRiskFlags: [
|
||||
@@ -238,6 +272,10 @@ test('AI advice shows only the latest manual return while preserving return coun
|
||||
test('expense attachment actions keep preview as the only recognition entry point', () => {
|
||||
assert.match(detailViewTemplate, /:aria-label="resolveAttachmentPreviewTitle\(item\)"/)
|
||||
assert.match(detailViewScript, /return fileName \? `预览附件:\$\{fileName\}` : '预览附件'/)
|
||||
assert.match(detailViewScript, /\.filter\(\(item\) => canPreviewAttachment\(item\)\)/)
|
||||
assert.match(detailViewScript, /function hasStoredAttachmentReference\(item\) \{[\s\S]*return String\(item\?\.invoiceId \|\| ''\)\.includes\('\/'\)/)
|
||||
assert.match(detailViewScript, /if \(metadata\) \{[\s\S]*return metadata\.previewable !== false[\s\S]*return true/)
|
||||
assert.match(detailViewScript, /原件尚未保存到单据中,请重新上传后预览/)
|
||||
assert.doesNotMatch(detailViewTemplate, /aria-label="识别附件"/)
|
||||
assert.doesNotMatch(detailViewTemplate, /点击识别按钮/)
|
||||
assert.doesNotMatch(detailViewScript, /recognizeExpenseAttachment/)
|
||||
@@ -358,6 +396,8 @@ test('travel detail AI advice adds low risk reminders for optional receipts', ()
|
||||
test('expense detail save is blocked while attachment recognition is running', () => {
|
||||
assert.match(detailViewScript, /const uploadingExpenseId = ref\(''\)/)
|
||||
assert.match(detailViewScript, /const actionBusy = computed\(\(\) =>[\s\S]*Boolean\(uploadingExpenseId\.value\)/)
|
||||
assert.match(detailViewScript, /const canSubmit = computed\(\(\) => isEditableRequest\.value && !actionBusy\.value\)/)
|
||||
assert.match(detailViewScript, /if \(draftBlockingIssues\.value\.length\) \{[\s\S]*请先补全草稿信息,再提交审批。/)
|
||||
assert.match(
|
||||
detailViewTemplate,
|
||||
/@click="saveExpenseEdit\(item\)"[\s\S]*:disabled="actionBusy"/
|
||||
@@ -368,6 +408,36 @@ test('expense detail save is blocked while attachment recognition is running', (
|
||||
)
|
||||
})
|
||||
|
||||
test('draft submit validation uses expense detail date and amount when claim summary is stale', () => {
|
||||
const issues = buildDraftBlockingIssues(
|
||||
{
|
||||
profileName: '张三',
|
||||
typeLabel: '待补充',
|
||||
typeCode: 'office',
|
||||
reason: '待补充',
|
||||
location: '待补充',
|
||||
occurredDisplay: '待补充',
|
||||
amountValue: 0
|
||||
},
|
||||
[
|
||||
{
|
||||
id: 'item-1',
|
||||
itemDate: '2026-05-21',
|
||||
itemType: 'office',
|
||||
itemReason: '采购办公用品',
|
||||
itemLocation: '',
|
||||
itemAmount: 88,
|
||||
invoiceId: 'claim-1/item-1/office-note.png'
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
assert.ok(!issues.some((issue) => issue.includes('发生时间未完善')))
|
||||
assert.ok(!issues.some((issue) => issue.includes('报销金额未完善')))
|
||||
assert.ok(!issues.some((issue) => issue.includes('报销类型未完善')))
|
||||
assert.ok(!issues.some((issue) => issue.includes('报销事由未完善')))
|
||||
})
|
||||
|
||||
test('transport ticket descriptions use route format and invalid format becomes risk advice', () => {
|
||||
assert.match(detailViewScript, /const ROUTE_DESCRIPTION_EXPENSE_TYPES = new Set\(\['train_ticket', 'flight_ticket', 'ship_ticket', 'ferry_ticket', 'ride_ticket'\]\)/)
|
||||
assert.match(detailViewScript, /const HOTEL_DESCRIPTION_EXPENSE_TYPES = new Set\(\['hotel_ticket'\]\)/)
|
||||
|
||||
@@ -11,6 +11,10 @@ const detailViewScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/TravelRequestDetailView.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const detailExpenseModelScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/travelRequestDetailExpenseModel.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
function extractFunction(source, name) {
|
||||
const signatureIndex = source.indexOf(`function ${name}(`)
|
||||
@@ -53,8 +57,21 @@ test('detail submit opens a confirmation dialog before calling submit API', () =
|
||||
assert.match(confirmSubmitRequest, /submitExpenseClaim\(request\.value\.claimId\)/)
|
||||
})
|
||||
|
||||
test('detail submit requires override reasons for high-risk claims', () => {
|
||||
assert.match(detailViewTemplate, /:open="riskOverrideDialogOpen"/)
|
||||
assert.match(detailViewTemplate, /重大风险/)
|
||||
assert.match(detailViewTemplate, /goToPreviousSubmitRisk/)
|
||||
assert.match(detailViewTemplate, /goToNextSubmitRisk/)
|
||||
assert.match(detailViewTemplate, /v-model="riskOverrideReasons\[currentSubmitRiskWarning\.id\]"/)
|
||||
assert.match(detailViewScript, /const submitRiskWarnings = computed/)
|
||||
assert.match(detailViewScript, /submitRiskWarnings\.value\.length && !hasRiskOverrideExplanation\.value/)
|
||||
assert.match(detailViewScript, /function confirmRiskOverrideReasons\(\)/)
|
||||
assert.match(detailViewScript, /updateExpenseClaim\(request\.value\.claimId,\s*\{\s*reason: nextNote/s)
|
||||
assert.match(detailViewScript, /超标说明:\$\{tags\}/)
|
||||
})
|
||||
|
||||
test('detail header and fallback progress use reimbursement wording', () => {
|
||||
assert.match(detailViewScript, /label:\s*'单据申请日期'/)
|
||||
assert.match(detailViewScript, /label:\s*'创建单据'/)
|
||||
assert.match(detailExpenseModelScript, /label:\s*'创建单据'/)
|
||||
assert.doesNotMatch(detailViewScript, /label:\s*'保存草稿'/)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user