Files
X-Financial/ai-reimbursement-mac-prototype.html
WIN-JHFT4D3SIVT\caoxiaozhu 7141e1d11a feat: refactor monolithic App.vue into modular Vue component architecture
- Extract 711-line App.vue into 15+ focused files across 5 directories
- Add data layer (icons, metrics, policies, auditTrail, requests)
- Add composables (useNavigation, useRequests, useChat, useToast)
- Add layout components (SidebarRail, TopBar, FilterBar)
- Add shared components (PanelHead, InfoRow, ToastNotification)
- Add business component (RequestTable) and 5 view components
- Extract global CSS to assets/styles/global.css
- Add start.sh with WSL/Windows cross-platform support
- Add .gitignore for node_modules, dist, and IDE dirs
2026-04-28 17:20:52 +08:00

2159 lines
69 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>AI 报销预审中台 - macOS Prototype</title>
<style>
:root {
--bg: #eef1f4;
--window: rgba(248, 249, 251, 0.92);
--sidebar: rgba(233, 237, 242, 0.78);
--surface: #ffffff;
--surface-2: #f7f8fa;
--line: #d8dde5;
--line-strong: #c1c8d2;
--text: #14171c;
--muted: #657083;
--soft: #8792a3;
--blue: #156df7;
--blue-soft: #e8f1ff;
--green: #11845b;
--green-soft: #e8f7ef;
--amber: #a55f00;
--amber-soft: #fff3dc;
--red: #c7352b;
--red-soft: #fff0ee;
--purple: #6b4fd8;
--purple-soft: #f0edff;
--shadow: 0 24px 80px rgba(31, 39, 51, 0.22);
--shadow-soft: 0 10px 30px rgba(31, 39, 51, 0.10);
--radius: 8px;
--font: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", "Segoe UI", sans-serif;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100dvh;
color: var(--text);
font-family: var(--font);
background:
linear-gradient(135deg, rgba(255,255,255,0.72), rgba(229,235,244,0.72)),
linear-gradient(90deg, #e8eef4, #f4f1ee 48%, #e9f0ed);
letter-spacing: 0;
}
button,
input,
textarea,
select {
font: inherit;
}
button {
cursor: pointer;
}
button:focus-visible,
input:focus-visible,
textarea:focus-visible,
select:focus-visible,
[tabindex]:focus-visible {
outline: 3px solid rgba(21, 109, 247, 0.25);
outline-offset: 2px;
}
.desktop {
min-height: 100dvh;
display: grid;
place-items: center;
padding: 28px;
}
.app-window {
width: min(1480px, 100%);
height: min(940px, calc(100dvh - 56px));
min-height: 720px;
display: grid;
grid-template-rows: 48px 1fr;
overflow: hidden;
border: 1px solid rgba(255,255,255,0.72);
border-radius: 18px;
background: var(--window);
box-shadow: var(--shadow);
backdrop-filter: blur(22px) saturate(1.12);
}
.titlebar {
display: grid;
grid-template-columns: 172px 1fr auto;
align-items: center;
gap: 18px;
padding: 0 16px;
border-bottom: 1px solid rgba(196, 204, 214, 0.78);
background: rgba(246, 248, 250, 0.78);
}
.traffic {
display: flex;
align-items: center;
gap: 8px;
}
.traffic span {
width: 12px;
height: 12px;
border-radius: 50%;
box-shadow: inset 0 0 0 1px rgba(0,0,0,0.10);
}
.traffic span:nth-child(1) { background: #ff5f57; }
.traffic span:nth-child(2) { background: #ffbd2e; }
.traffic span:nth-child(3) { background: #28c840; }
.toolbar {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
min-width: 0;
}
.search {
width: min(520px, 100%);
height: 32px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px;
color: var(--muted);
border: 1px solid rgba(192, 199, 210, 0.78);
border-radius: 7px;
background: rgba(255,255,255,0.7);
}
.search input {
width: 100%;
border: 0;
outline: 0;
background: transparent;
color: var(--text);
font-size: 13px;
}
.title-actions {
display: flex;
align-items: center;
gap: 10px;
}
.mac-chip {
display: inline-flex;
align-items: center;
min-height: 28px;
gap: 8px;
padding: 4px 10px;
border: 1px solid var(--line);
border-radius: 7px;
background: rgba(255,255,255,0.64);
color: #2c3340;
font-size: 12px;
white-space: nowrap;
}
.avatar {
width: 28px;
height: 28px;
display: grid;
place-items: center;
border-radius: 50%;
background: #20242b;
color: #fff;
font-size: 12px;
font-weight: 700;
}
.app-body {
min-height: 0;
display: grid;
grid-template-columns: 246px minmax(0, 1fr) 336px;
}
.sidebar {
min-height: 0;
display: flex;
flex-direction: column;
padding: 18px 12px;
border-right: 1px solid rgba(196, 204, 214, 0.72);
background: var(--sidebar);
}
.brand {
padding: 4px 8px 18px;
}
.brand h1 {
margin: 0 0 5px;
font-size: 17px;
line-height: 1.25;
letter-spacing: 0;
}
.brand p {
margin: 0;
color: var(--muted);
font-size: 12px;
line-height: 1.45;
}
.nav-section {
display: grid;
gap: 4px;
}
.nav-title {
padding: 16px 8px 6px;
color: var(--soft);
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
}
.nav-item {
width: 100%;
min-height: 40px;
display: grid;
grid-template-columns: 24px 1fr auto;
align-items: center;
gap: 9px;
padding: 8px 10px;
border: 0;
border-radius: 7px;
background: transparent;
color: #293241;
text-align: left;
}
.nav-item:hover {
background: rgba(255,255,255,0.58);
}
.nav-item.active {
background: rgba(255,255,255,0.86);
box-shadow: 0 1px 2px rgba(31, 39, 51, 0.08);
color: var(--blue);
}
.nav-item svg {
width: 18px;
height: 18px;
stroke-width: 2;
}
.nav-item .label {
min-width: 0;
font-size: 13px;
font-weight: 600;
}
.nav-badge {
min-width: 22px;
height: 20px;
display: grid;
place-items: center;
padding: 0 6px;
border-radius: 999px;
background: #edf0f5;
color: var(--muted);
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.sidebar-footer {
margin-top: auto;
display: grid;
gap: 10px;
padding: 12px 8px 0;
}
.mini-meter {
display: grid;
gap: 8px;
padding: 12px;
border: 1px solid rgba(205, 211, 220, 0.76);
border-radius: var(--radius);
background: rgba(255,255,255,0.54);
}
.mini-meter strong {
font-size: 12px;
}
.meter-row {
display: flex;
align-items: center;
justify-content: space-between;
color: var(--muted);
font-size: 12px;
}
.bar {
height: 7px;
overflow: hidden;
border-radius: 999px;
background: #dde3ec;
}
.bar span {
display: block;
width: var(--value, 35%);
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, var(--blue), #3c9a6d);
transition: width 260ms ease;
}
.main {
min-width: 0;
min-height: 0;
overflow: auto;
padding: 28px;
background:
linear-gradient(180deg, rgba(255,255,255,0.50), rgba(247,249,252,0.74)),
var(--bg);
}
.right-panel {
min-width: 0;
min-height: 0;
overflow: auto;
padding: 22px 18px;
border-left: 1px solid rgba(196, 204, 214, 0.72);
background: rgba(246, 248, 250, 0.78);
}
.view {
display: none;
animation: appear 190ms ease-out;
}
.view.active {
display: block;
}
@keyframes appear {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 1ms !important;
transition-duration: 1ms !important;
scroll-behavior: auto !important;
}
}
.page-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
margin-bottom: 22px;
}
.eyebrow {
display: inline-flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
color: var(--blue);
font-size: 12px;
font-weight: 700;
}
.page-head h2 {
margin: 0;
font-size: clamp(25px, 3vw, 34px);
line-height: 1.14;
letter-spacing: 0;
}
.page-head p {
max-width: 760px;
margin: 8px 0 0;
color: var(--muted);
font-size: 14px;
line-height: 1.65;
}
.headline-actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
justify-content: flex-end;
}
.btn {
min-height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 8px 13px;
border: 1px solid var(--line);
border-radius: 7px;
background: var(--surface);
color: #222936;
font-size: 13px;
font-weight: 700;
box-shadow: 0 1px 1px rgba(31, 39, 51, 0.04);
transition: transform 140ms ease, box-shadow 140ms ease, background 140ms ease;
}
.btn:hover {
background: #f9fafc;
box-shadow: var(--shadow-soft);
}
.btn:active {
transform: scale(0.98);
}
.btn.primary {
border-color: #075fdc;
background: linear-gradient(180deg, #2c86ff, #0f67ef);
color: #fff;
box-shadow: 0 10px 18px rgba(21, 109, 247, 0.24);
}
.btn.primary:hover {
background: linear-gradient(180deg, #3b90ff, #166df1);
}
.btn.ghost {
background: transparent;
box-shadow: none;
}
.btn.danger {
border-color: #f1beb8;
background: var(--red-soft);
color: var(--red);
}
.btn:disabled {
cursor: not-allowed;
opacity: 0.48;
box-shadow: none;
transform: none;
}
.btn svg,
.icon-btn svg,
.search svg {
width: 16px;
height: 16px;
stroke-width: 2;
flex: 0 0 auto;
}
.icon-btn {
width: 36px;
height: 36px;
display: inline-grid;
place-items: center;
border: 1px solid var(--line);
border-radius: 7px;
background: var(--surface);
color: #293241;
}
.grid-2 {
display: grid;
grid-template-columns: minmax(0, 1.08fr) minmax(320px, 0.92fr);
gap: 18px;
align-items: start;
}
.grid-3 {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
}
.card {
border: 1px solid var(--line);
border-radius: var(--radius);
background: rgba(255,255,255,0.88);
box-shadow: 0 1px 1px rgba(31, 39, 51, 0.03);
}
.card.pad {
padding: 18px;
}
.card-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.card-title h3 {
margin: 0;
font-size: 15px;
line-height: 1.3;
}
.card-title p {
margin: 3px 0 0;
color: var(--muted);
font-size: 12px;
line-height: 1.45;
}
.intent-box {
display: grid;
gap: 14px;
}
.intent-box textarea {
width: 100%;
min-height: 150px;
resize: vertical;
padding: 14px;
border: 1px solid var(--line-strong);
border-radius: 8px;
background: #fff;
color: var(--text);
line-height: 1.6;
}
.quick-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.quick {
min-height: 32px;
border: 1px solid #cbd4df;
border-radius: 999px;
padding: 6px 11px;
background: #fff;
color: #2d3748;
font-size: 12px;
font-weight: 700;
}
.quick:hover {
background: var(--blue-soft);
border-color: #a9c8ff;
color: var(--blue);
}
.stat {
display: grid;
gap: 8px;
padding: 16px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
}
.stat span {
color: var(--muted);
font-size: 12px;
font-weight: 700;
}
.stat strong {
font-size: 28px;
line-height: 1;
font-variant-numeric: tabular-nums;
}
.stat small {
color: var(--muted);
font-size: 12px;
line-height: 1.45;
}
.dropzone {
min-height: 238px;
display: grid;
place-items: center;
padding: 24px;
border: 1.5px dashed #aeb8c7;
border-radius: var(--radius);
background:
linear-gradient(180deg, rgba(255,255,255,0.92), rgba(246,248,251,0.92));
text-align: center;
transition: border-color 160ms ease, background 160ms ease, transform 160ms ease;
}
.dropzone.drag {
border-color: var(--blue);
background: var(--blue-soft);
transform: translateY(-1px);
}
.dropzone input {
display: none;
}
.drop-inner {
display: grid;
justify-items: center;
gap: 10px;
}
.big-icon {
width: 52px;
height: 52px;
display: grid;
place-items: center;
border-radius: 14px;
background: #ecf2fb;
color: var(--blue);
}
.big-icon svg {
width: 26px;
height: 26px;
}
.dropzone h3 {
margin: 0;
font-size: 17px;
}
.dropzone p {
max-width: 420px;
margin: 0;
color: var(--muted);
line-height: 1.55;
font-size: 13px;
}
.file-list,
.task-list,
.check-list,
.timeline,
.agent-list {
display: grid;
gap: 10px;
}
.file-row,
.task-row,
.check-row,
.audit-row,
.agent-row {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 12px;
padding: 12px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: #fff;
}
.file-thumb {
width: 48px;
height: 60px;
display: grid;
place-items: center;
border: 1px solid #ccd4df;
border-radius: 6px;
background:
linear-gradient(180deg, #ffffff, #f0f3f7);
color: var(--muted);
font-size: 10px;
font-weight: 800;
overflow: hidden;
}
.file-thumb.ticket {
background:
linear-gradient(90deg, transparent 12px, rgba(21,109,247,0.13) 12px, rgba(21,109,247,0.13) 13px, transparent 13px),
linear-gradient(180deg, #fff, #edf5ff);
}
.file-row strong,
.task-row strong,
.agent-row strong {
display: block;
font-size: 13px;
line-height: 1.35;
}
.file-row span,
.task-row span,
.agent-row span,
.audit-row span {
display: block;
margin-top: 3px;
color: var(--muted);
font-size: 12px;
line-height: 1.35;
}
.tag {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 24px;
padding: 3px 8px;
border: 1px solid transparent;
border-radius: 999px;
font-size: 12px;
font-weight: 800;
white-space: nowrap;
}
.tag.blue { background: var(--blue-soft); color: var(--blue); border-color: #c4dcff; }
.tag.green { background: var(--green-soft); color: var(--green); border-color: #bee8ce; }
.tag.amber { background: var(--amber-soft); color: var(--amber); border-color: #ffd992; }
.tag.red { background: var(--red-soft); color: var(--red); border-color: #ffc9c3; }
.tag.purple { background: var(--purple-soft); color: var(--purple); border-color: #d9d0ff; }
.tag.gray { background: #eef1f5; color: #546071; border-color: #d9dfe7; }
.progress-shell {
display: none;
gap: 12px;
margin-top: 16px;
padding: 14px;
border: 1px solid #c9d8f1;
border-radius: var(--radius);
background: #f4f8ff;
}
.progress-shell.active {
display: grid;
}
.progress-text {
display: flex;
justify-content: space-between;
gap: 12px;
color: #315078;
font-size: 13px;
font-weight: 700;
}
.table-wrap {
overflow: auto;
border: 1px solid var(--line);
border-radius: var(--radius);
background: #fff;
}
table {
width: 100%;
border-collapse: collapse;
min-width: 820px;
}
th,
td {
padding: 12px 12px;
border-bottom: 1px solid #edf0f4;
text-align: left;
vertical-align: middle;
font-size: 13px;
}
th {
position: sticky;
top: 0;
z-index: 1;
background: #f7f8fa;
color: #5f6b7d;
font-size: 12px;
font-weight: 800;
}
tr:last-child td {
border-bottom: 0;
}
td input {
width: 112px;
min-height: 32px;
padding: 5px 8px;
border: 1px solid transparent;
border-radius: 6px;
background: #f6f8fb;
color: #1b2029;
}
td input:focus {
border-color: #9ec0ff;
background: #fff;
}
.field-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.field {
display: grid;
gap: 7px;
}
.field label {
color: #5d6878;
font-size: 12px;
font-weight: 800;
}
.field input,
.field select,
.field textarea {
width: 100%;
min-height: 38px;
padding: 8px 10px;
border: 1px solid var(--line);
border-radius: 7px;
background: #fff;
color: var(--text);
}
.field textarea {
min-height: 118px;
resize: vertical;
line-height: 1.55;
}
.ai-note {
display: grid;
gap: 10px;
padding: 14px;
border: 1px solid #d9d0ff;
border-radius: var(--radius);
background: var(--purple-soft);
color: #3d317a;
font-size: 13px;
line-height: 1.6;
}
.receipt-strip {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.receipt {
min-height: 138px;
display: grid;
align-content: space-between;
padding: 12px;
border: 1px solid var(--line);
border-radius: var(--radius);
background:
linear-gradient(180deg, #fff, #f5f7fa);
}
.receipt .top-line {
height: 8px;
width: 58%;
border-radius: 999px;
background: #c7d2e1;
}
.receipt .lines {
display: grid;
gap: 7px;
}
.receipt .lines span {
height: 6px;
border-radius: 999px;
background: #e0e6ee;
}
.receipt .lines span:nth-child(2) { width: 76%; }
.receipt .lines span:nth-child(3) { width: 54%; }
.receipt strong {
color: #303846;
font-size: 12px;
}
.result-hero {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 18px;
align-items: center;
padding: 18px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: #fff;
}
.score-ring {
width: 92px;
height: 92px;
display: grid;
place-items: center;
border-radius: 50%;
background:
conic-gradient(var(--ring-color, #f0a429) var(--ring, 62%), #e9edf3 0);
color: #1e2530;
font-size: 23px;
font-weight: 900;
font-variant-numeric: tabular-nums;
}
.score-ring::before {
content: "";
position: absolute;
}
.score-inner {
width: 70px;
height: 70px;
display: grid;
place-items: center;
border-radius: 50%;
background: #fff;
}
.rule-card {
display: grid;
gap: 12px;
padding: 14px;
border: 1px solid var(--line);
border-left: 4px solid var(--severity, #f0a429);
border-radius: var(--radius);
background: #fff;
}
.rule-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.rule-head h3 {
margin: 0;
font-size: 15px;
line-height: 1.35;
}
.rule-card p {
margin: 0;
color: #556174;
font-size: 13px;
line-height: 1.6;
}
details {
color: #465366;
font-size: 13px;
line-height: 1.55;
}
summary {
cursor: pointer;
color: var(--blue);
font-weight: 800;
}
.supplement-item {
display: grid;
gap: 12px;
padding: 14px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: #fff;
}
.supplement-item header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.supplement-item h3 {
margin: 0;
font-size: 15px;
}
.supplement-item p {
margin: 5px 0 0;
color: var(--muted);
font-size: 13px;
line-height: 1.6;
}
.segmented {
display: inline-grid;
grid-auto-flow: column;
gap: 2px;
padding: 3px;
border: 1px solid var(--line);
border-radius: 8px;
background: #eef1f5;
}
.segmented button {
min-height: 30px;
border: 0;
border-radius: 6px;
padding: 5px 10px;
background: transparent;
color: var(--muted);
font-size: 12px;
font-weight: 800;
}
.segmented button.active {
background: #fff;
color: var(--blue);
box-shadow: 0 1px 2px rgba(31, 39, 51, 0.08);
}
.summary-line {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 12px 0;
border-bottom: 1px solid #edf0f4;
color: var(--muted);
font-size: 13px;
}
.summary-line:last-child {
border-bottom: 0;
}
.summary-line strong {
color: var(--text);
font-variant-numeric: tabular-nums;
}
.panel-card {
display: grid;
gap: 14px;
padding: 14px;
margin-bottom: 14px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: rgba(255,255,255,0.72);
}
.panel-card h3 {
margin: 0;
font-size: 14px;
}
.panel-card p {
margin: 0;
color: var(--muted);
font-size: 12px;
line-height: 1.55;
}
.agent-row {
grid-template-columns: 24px 1fr auto;
padding: 10px;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #b4bdca;
box-shadow: 0 0 0 4px rgba(180,189,202,0.14);
}
.status-dot.done { background: var(--green); box-shadow: 0 0 0 4px rgba(17,132,91,0.14); }
.status-dot.run { background: var(--blue); box-shadow: 0 0 0 4px rgba(21,109,247,0.14); }
.status-dot.warn { background: #d98b12; box-shadow: 0 0 0 4px rgba(217,139,18,0.14); }
.timeline {
position: relative;
}
.audit-row {
grid-template-columns: 92px 1fr auto;
}
.audit-row time {
color: var(--muted);
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.empty {
padding: 28px;
border: 1px dashed #c5ccd7;
border-radius: var(--radius);
background: rgba(255,255,255,0.56);
color: var(--muted);
text-align: center;
line-height: 1.6;
}
.toast {
position: fixed;
right: 28px;
bottom: 28px;
z-index: 5;
max-width: min(420px, calc(100vw - 56px));
display: none;
align-items: flex-start;
gap: 12px;
padding: 14px 16px;
border: 1px solid #bcd4ff;
border-radius: var(--radius);
background: rgba(255,255,255,0.95);
box-shadow: var(--shadow-soft);
color: #22314a;
backdrop-filter: blur(16px);
}
.toast.active {
display: flex;
animation: appear 160ms ease-out;
}
.toast strong {
display: block;
font-size: 13px;
margin-bottom: 2px;
}
.toast span {
display: block;
color: var(--muted);
font-size: 12px;
line-height: 1.45;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0,0,0,0);
white-space: nowrap;
border: 0;
}
@media (max-width: 1180px) {
.app-body {
grid-template-columns: 82px minmax(0, 1fr);
}
.right-panel {
display: none;
}
.brand p,
.nav-title,
.nav-item .label,
.nav-badge,
.sidebar-footer {
display: none;
}
.brand h1 {
font-size: 12px;
text-align: center;
}
.nav-item {
grid-template-columns: 1fr;
justify-items: center;
}
}
@media (max-width: 860px) {
.desktop {
padding: 0;
}
.app-window {
height: 100dvh;
min-height: 100dvh;
border-radius: 0;
}
.titlebar {
grid-template-columns: auto 1fr;
}
.title-actions {
display: none;
}
.app-body {
grid-template-columns: 1fr;
}
.sidebar {
display: none;
}
.main {
padding: 18px;
}
.grid-2,
.grid-3,
.field-grid,
.receipt-strip {
grid-template-columns: 1fr;
}
.page-head {
display: grid;
}
.headline-actions {
justify-content: flex-start;
}
.result-hero {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<div class="desktop">
<div class="app-window" role="application" aria-label="AI 报销预审中台原型">
<header class="titlebar">
<div class="traffic" aria-hidden="true"><span></span><span></span><span></span></div>
<div class="toolbar">
<label class="search" aria-label="搜索任务、票据、规则">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path d="m21 21-4.35-4.35"></path><circle cx="11" cy="11" r="7"></circle></svg>
<input type="search" placeholder="搜索任务、票据、规则或审计日志">
</label>
</div>
<div class="title-actions">
<span class="mac-chip">演示环境 · Mock OCR</span>
<span class="avatar" aria-label="当前用户:林"></span>
</div>
</header>
<div class="app-body">
<aside class="sidebar" aria-label="主导航">
<div class="brand">
<h1>X-Financial</h1>
<p>AI 报销预审中台 · 差旅 MVP</p>
</div>
<nav class="nav-section" id="nav"></nav>
<div class="sidebar-footer">
<div class="mini-meter">
<strong>本月报销健康度</strong>
<div class="meter-row"><span>规则通过率</span><b id="sidePassRate">86%</b></div>
<div class="bar" aria-hidden="true"><span id="sidePassBar" style="--value: 86%"></span></div>
</div>
<button class="btn ghost" id="resetDemo" type="button">重置演示数据</button>
</div>
</aside>
<main class="main" id="main">
<section class="view active" id="view-home" data-view="home">
<div class="page-head">
<div>
<span class="eyebrow">AI 操作层</span>
<h2>把一段报销意图变成可预审的差旅报销单</h2>
<p>面向员工的入口页:输入报销意图,系统创建任务并引导上传发票、火车票、机票行程单等材料。</p>
</div>
<div class="headline-actions">
<button class="btn" type="button" data-go="audit">查看审计</button>
<button class="btn primary" type="button" id="createTaskTop">新建报销</button>
</div>
</div>
<div class="grid-2">
<div class="card pad intent-box">
<div class="card-title">
<div>
<h3>报销意图</h3>
<p>自然语言输入会交给受理 Agent 提取出差地点、时间、费用类型和上下文。</p>
</div>
<span class="tag blue">IntakeAgent</span>
</div>
<textarea id="intentInput" aria-label="报销意图">我上周去上海拜访客户,报销高铁、住宿和市内交通,项目是 CRM 升级交付,成本中心是销售华东。</textarea>
<div class="quick-actions" aria-label="常用报销类型">
<button class="quick" type="button" data-intent="我去北京参加客户复盘,需要报销机票、酒店和餐饮,项目为北区金融客户拓展。">报差旅</button>
<button class="quick" type="button" data-intent="帮我看看这张增值税发票和酒店流水能不能报销,可能有住宿标准超额。">看发票能不能报</button>
<button class="quick" type="button" data-intent="补充上次杭州出差的出租车票和行程说明,希望重新预审。">补充材料</button>
</div>
<div class="headline-actions">
<button class="btn primary" type="button" id="createTask">创建任务并上传材料</button>
</div>
</div>
<div class="card pad">
<div class="card-title">
<div>
<h3>最近任务</h3>
<p>用于返回草稿、查看预审结果或审计轨迹。</p>
</div>
</div>
<div class="task-list">
<button class="task-row" type="button" data-go="precheck">
<span class="file-thumb ticket">TRIP</span>
<span><strong>上海客户拜访差旅</strong><span>需补件 · 酒店流水缺失 · ¥4,836.50</span></span>
<span class="tag amber">待处理</span>
</button>
<button class="task-row" type="button" data-go="confirm">
<span class="file-thumb">VAT</span>
<span><strong>北京售前支持</strong><span>预审通过 · 等待确认提交 · ¥2,418.00</span></span>
<span class="tag green">通过</span>
</button>
<button class="task-row" type="button" data-go="audit">
<span class="file-thumb">LOG</span>
<span><strong>广州渠道会议</strong><span>已同步到 expense_system · 8 条审计记录</span></span>
<span class="tag gray">归档</span>
</button>
</div>
</div>
</div>
<div class="grid-3" style="margin-top: 18px;">
<div class="stat"><span>今日自动识别</span><strong>48</strong><small>发票、火车票、行程单合计</small></div>
<div class="stat"><span>需人工补件</span><strong>7</strong><small>主要来自住宿流水和费用日期异常</small></div>
<div class="stat"><span>平均预审耗时</span><strong>42s</strong><small>Mock OCR 环境下的端到端耗时</small></div>
</div>
</section>
<section class="view" id="view-upload" data-view="upload">
<div class="page-head">
<div>
<span class="eyebrow">材料收集</span>
<h2>上传票据并启动 OCR / Agent 编排</h2>
<p>支持多文件拖拽,文件类型限定为 PDF、JPG、PNG。开始识别后进入 CREATED → MATERIAL_COLLECTING → PARSING → DRAFT_GENERATED。</p>
</div>
<div class="headline-actions">
<button class="btn" type="button" data-go="home">返回入口</button>
<button class="btn primary" type="button" id="runAgentBtn">开始识别</button>
</div>
</div>
<div class="grid-2">
<div class="card pad">
<div class="card-title">
<div>
<h3>票据上传</h3>
<p>拖入真实文件会显示文件名;不选择文件也可以使用内置样例继续演示。</p>
</div>
<span class="tag blue">PDF / JPG / PNG</span>
</div>
<label class="dropzone" id="dropzone">
<input id="fileInput" type="file" multiple accept=".pdf,.jpg,.jpeg,.png">
<span class="drop-inner">
<span class="big-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M12 16V4"></path><path d="m7 9 5-5 5 5"></path><path d="M20 16.5V19a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-2.5"></path></svg>
</span>
<span>
<h3>拖拽票据到这里</h3>
<p>建议上传增值税发票、火车票、酒店流水和行程单,用于验证附件完整性与费用类型匹配规则。</p>
</span>
<span class="btn" aria-hidden="true">选择文件</span>
</span>
</label>
<div class="progress-shell" id="progressShell">
<div class="progress-text"><span id="progressLabel">准备启动 Agent</span><span id="progressValue">0%</span></div>
<div class="bar"><span id="progressBar" style="--value: 0%"></span></div>
</div>
</div>
<div class="card pad">
<div class="card-title">
<div>
<h3>已上传文件</h3>
<p>OCR 结果会在草稿页以低置信度标注和可编辑字段展示。</p>
</div>
<select id="docType" aria-label="票据类型">
<option>自动识别类型</option>
<option>增值税发票</option>
<option>火车票</option>
<option>机票行程单</option>
<option>酒店流水</option>
</select>
</div>
<div class="file-list" id="fileList"></div>
</div>
</div>
</section>
<section class="view" id="view-draft" data-view="draft">
<div class="page-head">
<div>
<span class="eyebrow">影子报销账本</span>
<h2>OCR 已生成报销草稿,关键字段可人工修正</h2>
<p>草稿页对应 ShadowReimbursement + ReimbursementItem编辑后再执行规则引擎预审。</p>
</div>
<div class="headline-actions">
<button class="btn" type="button" data-go="upload">返回上传</button>
<button class="btn primary" type="button" id="runPrecheckBtn">执行预审</button>
</div>
</div>
<div class="card pad" style="margin-bottom: 18px;">
<div class="card-title">
<div>
<h3>报销人信息</h3>
<p>Agent 从意图和组织配置中补齐部门、成本中心、项目。</p>
</div>
<span class="tag purple">AI 自动生成</span>
</div>
<div class="field-grid">
<div class="field"><label for="employee">报销人</label><input id="employee" value="林一舟"></div>
<div class="field"><label for="department">部门</label><input id="department" value="销售华东"></div>
<div class="field"><label for="costCenter">成本中心</label><input id="costCenter" value="CC-SH-SALES"></div>
<div class="field"><label for="project">项目</label><input id="project" value="CRM 升级交付"></div>
</div>
</div>
<div class="grid-2">
<div class="card pad">
<div class="card-title">
<div>
<h3>费用明细</h3>
<p>金额可直接编辑,风险标签会在预审后刷新。</p>
</div>
<span class="tag gray" id="draftStatus">待预审</span>
</div>
<div class="table-wrap">
<table aria-label="费用明细表">
<thead>
<tr>
<th>费用类型</th>
<th>金额</th>
<th>税额</th>
<th>发生日期</th>
<th>城市</th>
<th>商户</th>
<th>风险</th>
</tr>
</thead>
<tbody id="expenseBody"></tbody>
</table>
</div>
</div>
<div class="card pad">
<div class="card-title">
<div>
<h3>AI 识别标注</h3>
<p>低置信度字段和附件缺口会在这里提示。</p>
</div>
</div>
<div class="ai-note">
<strong>ParseAgent 发现 2 个需要关注的点</strong>
<span>住宿金额高于上海 T2 标准 18%,且酒店流水附件未找到。出租车票日期与出差日期匹配,但商户字段置信度 0.71。</span>
</div>
<div class="receipt-strip" style="margin-top: 14px;">
<div class="receipt"><div class="top-line"></div><div class="lines"><span></span><span></span><span></span></div><strong>高铁票 · 可信</strong></div>
<div class="receipt"><div class="top-line"></div><div class="lines"><span></span><span></span><span></span></div><strong>酒店发票 · 需流水</strong></div>
<div class="receipt"><div class="top-line"></div><div class="lines"><span></span><span></span><span></span></div><strong>出租车票 · 低置信</strong></div>
</div>
</div>
</div>
</section>
<section class="view" id="view-precheck" data-view="precheck">
<div class="page-head">
<div>
<span class="eyebrow">规则预审</span>
<h2 id="precheckTitle">预审结果:需补件后再提交</h2>
<p>规则引擎执行 6 条 MVP 核心规则,并由 ExplainAgent 转化为面向用户的解释和修改建议。</p>
</div>
<div class="headline-actions">
<button class="btn" type="button" data-go="draft">回到草稿</button>
<button class="btn" type="button" data-go="supplement" id="supplementJump">一键补件</button>
<button class="btn primary" type="button" data-go="confirm" id="confirmJump">确认提交</button>
</div>
</div>
<div class="result-hero" style="margin-bottom: 18px;">
<div class="score-ring" id="scoreRing"><div class="score-inner" id="scoreText">62</div></div>
<div>
<h3 id="overallTitle" style="margin: 0 0 6px;">发现 2 个风险项和 1 个缺件项</h3>
<p id="overallDesc" style="margin: 0; color: var(--muted); line-height: 1.6;">当前可以继续补充酒店流水,并调整住宿金额或补充审批说明。补件后可重新预审。</p>
</div>
<span class="tag amber" id="overallTag">需补件</span>
</div>
<div class="grid-2">
<div class="card pad">
<div class="card-title">
<div>
<h3>命中规则</h3>
<p>每条规则展示问题、制度依据和建议动作。</p>
</div>
</div>
<div class="file-list" id="ruleList"></div>
</div>
<div class="card pad">
<div class="card-title">
<div>
<h3>通过项</h3>
<p>通过项用来建立用户信心,减少重复沟通。</p>
</div>
</div>
<div class="check-list" id="passList"></div>
</div>
</div>
</section>
<section class="view" id="view-supplement" data-view="supplement">
<div class="page-head">
<div>
<span class="eyebrow">补件交互</span>
<h2>按规则命中项补充材料和说明</h2>
<p>补件页支持补充附件、补充说明、修改字段三类动作,提交后回到预审结果页重新计算风险。</p>
</div>
<div class="headline-actions">
<button class="btn" type="button" data-go="precheck">返回预审</button>
<button class="btn primary" type="button" id="submitSupplement">提交补件并重新预审</button>
</div>
</div>
<div class="grid-2">
<div class="card pad">
<div class="card-title">
<div>
<h3>待补件清单</h3>
<p>由 ExplainAgent 根据缺件类规则自动创建。</p>
</div>
<span class="tag amber">2 项</span>
</div>
<div class="file-list">
<div class="supplement-item">
<header>
<div>
<h3>补充酒店流水</h3>
<p>住宿费必须提供酒店水单或入住明细,用于核对入住日期、房费和实际支付金额。</p>
</div>
<span class="tag amber">补充附件</span>
</header>
<button class="btn" type="button" id="mockAttach">添加酒店流水样例</button>
</div>
<div class="supplement-item">
<header>
<div>
<h3>说明住宿标准超额原因</h3>
<p>上海 T2 住宿标准为 ¥650/晚,当前票据折算 ¥768/晚。可说明客户指定酒店或会务协议价。</p>
</div>
<span class="tag purple">补充说明</span>
</header>
<div class="field">
<label for="supplementText">说明内容</label>
<textarea id="supplementText">客户临时要求在会场附近住宿,酒店为客户协议推荐,超额部分已由直属经理口头确认。</textarea>
</div>
</div>
</div>
</div>
<div class="card pad">
<div class="card-title">
<div>
<h3>补件预览</h3>
<p>提交后系统会自动触发 MATERIAL_COLLECTING → PRECHECKING。</p>
</div>
</div>
<div class="file-list" id="supplementPreview">
<div class="empty">尚未添加补件附件。可以直接提交说明,系统会用样例酒店流水完成演示。</div>
</div>
</div>
</div>
</section>
<section class="view" id="view-confirm" data-view="confirm">
<div class="page-head">
<div>
<span class="eyebrow">提交确认</span>
<h2>确认最终报销单并模拟同步后端系统</h2>
<p>预审通过后SyncAgent 将影子账本映射为标准报销单,并写入同步记录和审计日志。</p>
</div>
<div class="headline-actions">
<button class="btn" type="button" data-go="precheck">返回预审</button>
<button class="btn primary" type="button" id="submitFinal">确认提交</button>
</div>
</div>
<div class="grid-2">
<div class="card pad">
<div class="card-title">
<div>
<h3>最终摘要</h3>
<p>提交前不可编辑,若需调整请回到草稿。</p>
</div>
<span class="tag green" id="confirmState">预审通过</span>
</div>
<div class="summary-line"><span>报销人</span><strong>林一舟 · 销售华东</strong></div>
<div class="summary-line"><span>报销事由</span><strong>上海客户拜访差旅</strong></div>
<div class="summary-line"><span>费用总额</span><strong id="confirmTotal">¥0.00</strong></div>
<div class="summary-line"><span>附件数量</span><strong id="confirmDocs">4 份</strong></div>
<div class="summary-line"><span>同步目标</span><strong>expense_system</strong></div>
</div>
<div class="card pad">
<div class="card-title">
<div>
<h3>同步设置</h3>
<p>MVP 默认使用 mock adapter展示提交中、已同步、同步失败重试状态。</p>
</div>
</div>
<div class="segmented" aria-label="同步目标系统">
<button class="active" type="button">expense_system</button>
<button type="button">ERP</button>
<button type="button">仅归档</button>
</div>
<div class="progress-shell" id="syncShell">
<div class="progress-text"><span id="syncLabel">等待提交</span><span id="syncValue">0%</span></div>
<div class="bar"><span id="syncBar" style="--value: 0%"></span></div>
</div>
</div>
</div>
</section>
<section class="view" id="view-audit" data-view="audit">
<div class="page-head">
<div>
<span class="eyebrow">审计日志</span>
<h2>关键操作均写入可追溯时间线</h2>
<p>记录文件上传、OCR 识别、Agent 调用、规则命中、用户补件、用户确认和后端同步。</p>
</div>
<div class="headline-actions">
<div class="segmented" aria-label="日志筛选">
<button class="active" type="button">全部</button>
<button type="button">Agent</button>
<button type="button">规则</button>
<button type="button">同步</button>
</div>
</div>
</div>
<div class="card pad">
<div class="timeline" id="auditList"></div>
</div>
</section>
</main>
<aside class="right-panel" aria-label="AI 状态面板">
<div class="panel-card">
<h3>当前任务</h3>
<p id="taskSummary">上海客户拜访差旅 · TASK-20260424-018</p>
<div class="summary-line"><span>状态</span><strong id="taskState">CREATED</strong></div>
<div class="summary-line"><span>总金额</span><strong id="panelTotal">¥0.00</strong></div>
<div class="summary-line"><span>风险等级</span><strong id="panelRisk">未预审</strong></div>
</div>
<div class="panel-card">
<h3>Agent 编排</h3>
<div class="agent-list" id="agentList"></div>
</div>
<div class="panel-card">
<h3>核心规则</h3>
<div class="check-list">
<div class="check-row"><span class="status-dot done"></span><span><strong>必填字段校验</strong><span>事由、日期、城市、金额完整</span></span><span class="tag green">通过</span></div>
<div class="check-row"><span class="status-dot warn"></span><span><strong>附件完整性</strong><span>住宿费需要酒店流水</span></span><span class="tag amber">待补件</span></div>
<div class="check-row"><span class="status-dot warn"></span><span><strong>金额标准</strong><span>上海住宿超 T2 标准</span></span><span class="tag amber">说明</span></div>
</div>
</div>
</aside>
</div>
</div>
</div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script>
const icons = {
home: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="m3 11 9-8 9 8"></path><path d="M5 10v10h14V10"></path><path d="M9 20v-6h6v6"></path></svg>',
upload: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M12 16V4"></path><path d="m7 9 5-5 5 5"></path><path d="M20 16.5V19a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-2.5"></path></svg>',
draft: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M6 2h9l5 5v15H6z"></path><path d="M14 2v6h6"></path><path d="M9 13h6"></path><path d="M9 17h6"></path></svg>',
precheck: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M20 6 9 17l-5-5"></path></svg>',
supplement: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M12 5v14"></path><path d="M5 12h14"></path><path d="M4 4h16v16H4z"></path></svg>',
confirm: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M9 12l2 2 4-5"></path><circle cx="12" cy="12" r="9"></circle></svg>',
audit: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M4 4h16v16H4z"></path><path d="M8 8h8"></path><path d="M8 12h8"></path><path d="M8 16h5"></path></svg>'
};
const navItems = [
{ id: 'home', label: '报销入口', badge: '1', icon: icons.home },
{ id: 'upload', label: '票据上传', badge: '2', icon: icons.upload },
{ id: 'draft', label: '报销草稿', badge: '3', icon: icons.draft },
{ id: 'precheck', label: '预审结果', badge: '4', icon: icons.precheck },
{ id: 'supplement', label: '补件交互', badge: '5', icon: icons.supplement },
{ id: 'confirm', label: '提交确认', badge: '6', icon: icons.confirm },
{ id: 'audit', label: '审计日志', badge: 'log', icon: icons.audit }
];
const baseFiles = [
{ name: '上海-北京南高铁票.pdf', type: '火车票', size: '436 KB', confidence: '0.96' },
{ name: '上海酒店增值税发票.jpg', type: '增值税发票', size: '812 KB', confidence: '0.93' },
{ name: '市内交通出租车票.png', type: '出租车票', size: '288 KB', confidence: '0.71' }
];
const expenses = [
{ type: '城际交通', amount: 1126.5, tax: 0, date: '2026-04-16', city: '上海', vendor: '中国铁路', risk: 'low' },
{ type: '住宿费', amount: 2304, tax: 130.42, date: '2026-04-16', city: '上海', vendor: '衡山商务酒店', risk: 'medium' },
{ type: '市内交通', amount: 286, tax: 0, date: '2026-04-17', city: '上海', vendor: '出租车票据', risk: 'medium' },
{ type: '餐饮费', amount: 1120, tax: 63.4, date: '2026-04-17', city: '上海', vendor: '客户接待餐厅', risk: 'low' }
];
let state = {
view: 'home',
files: [...baseFiles],
supplemented: false,
submitted: false,
taskStatus: 'NEED_SUPPLEMENT',
risk: '需补件',
progress: 0,
audit: [
['09:12', '任务创建', '林一舟创建差旅报销任务,意图已进入受理 Agent。', '用户'],
['09:14', '文件上传', '上传高铁票、酒店发票、出租车票共 3 份。', '文件'],
['09:15', 'OCR 识别', 'Mock OCR 提取 4 条费用明细1 个字段低置信。', 'Agent'],
['09:16', '规则命中', '附件完整性和住宿金额标准需要处理。', '规则']
],
agents: [
{ name: 'IntakeAgent', desc: '解析报销意图与上下文', status: 'done' },
{ name: 'ParseAgent', desc: 'OCR 识别并生成草稿', status: 'done' },
{ name: 'RuleCheckAgent', desc: '执行 6 条预审规则', status: 'warn' },
{ name: 'ExplainAgent', desc: '生成解释和补件请求', status: 'warn' },
{ name: 'SyncAgent', desc: '等待用户确认提交', status: 'idle' }
]
};
const $ = (selector) => document.querySelector(selector);
const $$ = (selector) => [...document.querySelectorAll(selector)];
function money(value) {
return new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY' }).format(value);
}
function totalAmount() {
return expenses.reduce((sum, item) => sum + Number(item.amount || 0), 0);
}
function toast(title, body) {
const node = $('#toast');
node.innerHTML = '<div class="status-dot done"></div><div><strong>' + title + '</strong><span>' + body + '</span></div>';
node.classList.add('active');
clearTimeout(node.timer);
node.timer = setTimeout(() => node.classList.remove('active'), 3600);
}
function addAudit(action, detail, actor = '系统') {
const now = new Date();
const time = now.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
state.audit.unshift([time, action, detail, actor]);
renderAudit();
}
function renderNav() {
const nav = $('#nav');
nav.innerHTML = '<div class="nav-title">工作流</div>' + navItems.map(item => `
<button class="nav-item ${state.view === item.id ? 'active' : ''}" type="button" data-go="${item.id}" aria-label="${item.label}">
${item.icon}
<span class="label">${item.label}</span>
<span class="nav-badge">${item.badge}</span>
</button>
`).join('');
}
function go(view) {
state.view = view;
$$('.view').forEach(node => node.classList.toggle('active', node.dataset.view === view));
renderNav();
renderPanel();
if (view === 'precheck') renderPrecheck();
if (view === 'confirm') renderConfirm();
if (view === 'audit') renderAudit();
$('#main').scrollTo({ top: 0, behavior: 'smooth' });
}
function renderFiles() {
const list = $('#fileList');
if (!state.files.length) {
list.innerHTML = '<div class="empty">还没有上传文件。拖拽票据或点击上传区域选择文件。</div>';
return;
}
list.innerHTML = state.files.map((file, index) => `
<div class="file-row">
<span class="file-thumb ${file.type.includes('票') ? 'ticket' : ''}">${file.type.slice(0, 2)}</span>
<span><strong>${file.name}</strong><span>${file.type} · ${file.size} · OCR 置信度 ${file.confidence}</span></span>
<button class="icon-btn" type="button" aria-label="移除 ${file.name}" data-remove-file="${index}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M18 6 6 18"></path><path d="m6 6 12 12"></path></svg>
</button>
</div>
`).join('');
}
function riskTag(risk) {
if (risk === 'low') return '<span class="tag green">低</span>';
if (risk === 'medium') return '<span class="tag amber">中</span>';
if (risk === 'high') return '<span class="tag red">高</span>';
return '<span class="tag gray">待定</span>';
}
function renderExpenses() {
$('#expenseBody').innerHTML = expenses.map((item, index) => `
<tr>
<td>${item.type}</td>
<td><input aria-label="${item.type}金额" data-amount="${index}" value="${item.amount}"></td>
<td>${money(item.tax)}</td>
<td>${item.date}</td>
<td>${item.city}</td>
<td>${item.vendor}</td>
<td>${riskTag(state.supplemented ? 'low' : item.risk)}</td>
</tr>
`).join('');
renderPanel();
}
function renderPrecheck() {
const passed = state.supplemented;
$('#confirmJump').disabled = !passed;
$('#supplementJump').disabled = passed;
$('#precheckTitle').textContent = passed ? '预审结果:已通过,可确认提交' : '预审结果:需补件后再提交';
$('#overallTitle').textContent = passed ? '6 条核心规则全部通过' : '发现 2 个风险项和 1 个缺件项';
$('#overallDesc').textContent = passed ? '补充酒店流水和超标说明后,规则引擎已重新计算为可提交状态。' : '当前可以继续补充酒店流水,并调整住宿金额或补充审批说明。补件后可重新预审。';
$('#overallTag').className = 'tag ' + (passed ? 'green' : 'amber');
$('#overallTag').textContent = passed ? '通过' : '需补件';
$('#scoreText').textContent = passed ? '96' : '62';
$('#scoreRing').style.setProperty('--ring', passed ? '96%' : '62%');
$('#scoreRing').style.setProperty('--ring-color', passed ? '#11845b' : '#f0a429');
state.risk = passed ? '低风险' : '需补件';
state.taskStatus = passed ? 'PENDING_USER_CONFIRM' : 'NEED_SUPPLEMENT';
const rules = passed ? [
{ title: '住宿附件完整性已通过', code: 'ATTACHMENT_CHECK', level: 'green', line: '#11845b', desc: '已补充酒店流水,入住日期、房费和支付金额可核对。', suggestion: '可进入提交确认。' }
] : [
{ title: '住宿费缺少酒店流水', code: 'ATTACHMENT_CHECK', level: 'amber', line: '#d98b12', desc: '住宿费必须提供酒店水单或入住明细,当前仅识别到增值税发票。', suggestion: '补充酒店流水,或上传包含入住明细的订单截图。' },
{ title: '住宿金额超过城市标准', code: 'AMOUNT_LIMIT', level: 'amber', line: '#d98b12', desc: '上海 T2 住宿标准为 ¥650/晚,当前折算 ¥768/晚。', suggestion: '调整金额,或补充客户指定酒店、会务协议价等审批说明。' },
{ title: '出租车票商户字段置信度偏低', code: 'OCR_LOW_CONFIDENCE', level: 'purple', line: '#6b4fd8', desc: 'OCR 对商户字段置信度为 0.71,不影响提交但建议人工确认。', suggestion: '在草稿页检查商户字段,必要时手动修正。' }
];
$('#ruleList').innerHTML = rules.map(rule => `
<article class="rule-card" style="--severity:${rule.line}">
<div class="rule-head">
<div>
<h3>${rule.title}</h3>
<p>${rule.code}</p>
</div>
<span class="tag ${rule.level}">${rule.level === 'green' ? '通过' : '命中'}</span>
</div>
<p>${rule.desc}</p>
<details>
<summary>查看制度依据和建议</summary>
<p>制度依据:差旅报销制度 7.2,住宿费需要可核对入住明细;城市/职级标准按金额标准表执行。</p>
<p>修改建议:${rule.suggestion}</p>
</details>
</article>
`).join('');
const passItems = [
['必填字段校验', '报销人、部门、事由、金额、发生日期完整。'],
['重复发票检查', '未发现 invoice_code + invoice_number + amount 重复。'],
['费用日期合理性', '费用发生日期在出差期间内。'],
['费用类型匹配', '票据类型与费用类型匹配。']
];
$('#passList').innerHTML = passItems.map(item => `
<div class="check-row">
<span class="status-dot done"></span>
<span><strong>${item[0]}</strong><span>${item[1]}</span></span>
<span class="tag green">通过</span>
</div>
`).join('');
renderPanel();
}
function renderConfirm() {
$('#confirmTotal').textContent = money(totalAmount());
$('#confirmDocs').textContent = (state.files.length + (state.supplemented ? 1 : 0)) + ' 份';
$('#confirmState').textContent = state.submitted ? '已同步' : '预审通过';
$('#confirmState').className = 'tag ' + (state.submitted ? 'blue' : 'green');
renderPanel();
}
function renderAudit() {
$('#auditList').innerHTML = state.audit.map(row => `
<div class="audit-row">
<time>${row[0]}</time>
<span><strong>${row[1]}</strong><span>${row[2]}</span></span>
<span class="tag gray">${row[3]}</span>
</div>
`).join('');
}
function renderPanel() {
$('#taskState').textContent = state.taskStatus;
$('#panelTotal').textContent = money(totalAmount());
$('#panelRisk').textContent = state.risk;
$('#sidePassRate').textContent = state.supplemented ? '96%' : '86%';
$('#sidePassBar').style.setProperty('--value', state.supplemented ? '96%' : '86%');
$('#agentList').innerHTML = state.agents.map(agent => `
<div class="agent-row">
<span class="status-dot ${agent.status === 'done' ? 'done' : agent.status === 'run' ? 'run' : agent.status === 'warn' ? 'warn' : ''}"></span>
<span><strong>${agent.name}</strong><span>${agent.desc}</span></span>
<span class="tag ${agent.status === 'done' ? 'green' : agent.status === 'run' ? 'blue' : agent.status === 'warn' ? 'amber' : 'gray'}">${agent.status === 'done' ? '完成' : agent.status === 'run' ? '运行' : agent.status === 'warn' ? '待处理' : '等待'}</span>
</div>
`).join('');
}
function startRecognition() {
if (!state.files.length) {
state.files = [...baseFiles];
renderFiles();
}
const steps = [
['MATERIAL_COLLECTING', '校验文件类型和票据类型', 18],
['PARSING', '调用 Mock OCR 提取票据字段', 46],
['DRAFT_GENERATED', 'ParseAgent 汇总费用明细', 76],
['DRAFT_GENERATED', '写入影子报销账本', 100]
];
const shell = $('#progressShell');
shell.classList.add('active');
$('#runAgentBtn').disabled = true;
let index = 0;
const tick = () => {
const step = steps[index];
state.taskStatus = step[0];
$('#progressLabel').textContent = step[1];
$('#progressValue').textContent = step[2] + '%';
$('#progressBar').style.setProperty('--value', step[2] + '%');
state.agents = state.agents.map(agent => {
if (agent.name === 'ParseAgent') return { ...agent, status: step[2] < 100 ? 'run' : 'done' };
return agent;
});
renderPanel();
index += 1;
if (index < steps.length) {
setTimeout(tick, 560);
} else {
$('#runAgentBtn').disabled = false;
addAudit('Agent 调用', '受理 Agent 和解析 Agent 完成,生成影子报销草稿。', 'Agent');
toast('草稿已生成', 'OCR 识别完成,已进入报销草稿页。');
renderExpenses();
go('draft');
}
};
tick();
}
function runPrecheck() {
state.taskStatus = state.supplemented ? 'PENDING_USER_CONFIRM' : 'NEED_SUPPLEMENT';
state.agents = state.agents.map(agent => {
if (agent.name === 'RuleCheckAgent') return { ...agent, status: state.supplemented ? 'done' : 'warn' };
if (agent.name === 'ExplainAgent') return { ...agent, status: state.supplemented ? 'done' : 'warn' };
if (agent.name === 'SyncAgent') return { ...agent, status: 'idle' };
return agent;
});
$('#draftStatus').textContent = state.supplemented ? '预审通过' : '需补件';
$('#draftStatus').className = 'tag ' + (state.supplemented ? 'green' : 'amber');
addAudit('规则预审', state.supplemented ? '6 条核心规则通过,等待用户确认提交。' : '命中附件完整性和金额标准规则,进入补件流程。', '规则');
toast('预审已完成', state.supplemented ? '当前报销单可以提交。' : '发现待补件项,已生成解释和建议。');
renderExpenses();
go('precheck');
}
function submitSupplement() {
state.supplemented = true;
state.files.push({ name: '上海酒店流水-补件.pdf', type: '酒店流水', size: '524 KB', confidence: '0.98' });
state.taskStatus = 'PRECHECKING';
state.risk = '低风险';
state.agents = state.agents.map(agent => {
if (['RuleCheckAgent', 'ExplainAgent'].includes(agent.name)) return { ...agent, status: 'done' };
return agent;
});
addAudit('用户补件', '补充酒店流水和住宿超标说明,触发重新预审。', '用户');
toast('补件已提交', '规则引擎已重新计算,预审状态更新为通过。');
renderFiles();
renderExpenses();
go('precheck');
}
function submitFinal() {
const shell = $('#syncShell');
shell.classList.add('active');
$('#submitFinal').disabled = true;
const steps = [
['生成标准报销单', 28],
['调用 mock sync adapter', 62],
['写入同步记录', 88],
['已同步到 expense_system', 100]
];
let index = 0;
const tick = () => {
const step = steps[index];
state.taskStatus = step[1] === 100 ? 'SYNCED' : 'SUBMITTING';
$('#syncLabel').textContent = step[0];
$('#syncValue').textContent = step[1] + '%';
$('#syncBar').style.setProperty('--value', step[1] + '%');
state.agents = state.agents.map(agent => agent.name === 'SyncAgent' ? { ...agent, status: step[1] === 100 ? 'done' : 'run' } : agent);
renderPanel();
index += 1;
if (index < steps.length) {
setTimeout(tick, 520);
} else {
state.submitted = true;
$('#submitFinal').disabled = false;
addAudit('用户确认', '用户确认提交最终报销单。', '用户');
addAudit('后端同步', 'SyncAgent 已将报销单同步到 expense_system。', '系统');
toast('同步完成', '报销单已模拟同步到后端系统。');
renderConfirm();
go('audit');
}
};
tick();
}
function bindEvents() {
document.addEventListener('click', (event) => {
const goTarget = event.target.closest('[data-go]');
if (goTarget && !goTarget.disabled) go(goTarget.dataset.go);
const quick = event.target.closest('[data-intent]');
if (quick) $('#intentInput').value = quick.dataset.intent;
const remove = event.target.closest('[data-remove-file]');
if (remove) {
state.files.splice(Number(remove.dataset.removeFile), 1);
renderFiles();
addAudit('文件移除', '用户从上传列表移除 1 份材料。', '用户');
}
});
$('#createTask').addEventListener('click', () => {
state.taskStatus = 'CREATED';
addAudit('任务创建', '根据自然语言意图创建新的差旅报销任务。', '用户');
toast('任务已创建', '请继续上传票据材料。');
go('upload');
});
$('#createTaskTop').addEventListener('click', () => $('#createTask').click());
$('#runAgentBtn').addEventListener('click', startRecognition);
$('#runPrecheckBtn').addEventListener('click', runPrecheck);
$('#submitSupplement').addEventListener('click', submitSupplement);
$('#submitFinal').addEventListener('click', submitFinal);
$('#mockAttach').addEventListener('click', () => {
$('#supplementPreview').innerHTML = `
<div class="file-row">
<span class="file-thumb">HTL</span>
<span><strong>上海酒店流水-补件.pdf</strong><span>酒店流水 · 524 KB · OCR 置信度 0.98</span></span>
<span class="tag green">已添加</span>
</div>
`;
toast('附件已添加', '酒店流水已加入补件预览。');
});
$('#resetDemo').addEventListener('click', () => {
state.supplemented = false;
state.submitted = false;
state.files = [...baseFiles];
state.taskStatus = 'NEED_SUPPLEMENT';
state.risk = '需补件';
state.agents = [
{ name: 'IntakeAgent', desc: '解析报销意图与上下文', status: 'done' },
{ name: 'ParseAgent', desc: 'OCR 识别并生成草稿', status: 'done' },
{ name: 'RuleCheckAgent', desc: '执行 6 条预审规则', status: 'warn' },
{ name: 'ExplainAgent', desc: '生成解释和补件请求', status: 'warn' },
{ name: 'SyncAgent', desc: '等待用户确认提交', status: 'idle' }
];
renderAll();
toast('演示已重置', '数据已恢复为初始待补件状态。');
go('home');
});
$('#fileInput').addEventListener('change', (event) => {
const files = [...event.target.files].map(file => ({
name: file.name,
type: $('#docType').value === '自动识别类型' ? '待识别' : $('#docType').value,
size: Math.max(1, Math.round(file.size / 1024)) + ' KB',
confidence: '待 OCR'
}));
state.files.push(...files);
addAudit('文件上传', '用户上传 ' + files.length + ' 份材料。', '用户');
renderFiles();
});
const dropzone = $('#dropzone');
['dragenter', 'dragover'].forEach(name => {
dropzone.addEventListener(name, (event) => {
event.preventDefault();
dropzone.classList.add('drag');
});
});
['dragleave', 'drop'].forEach(name => {
dropzone.addEventListener(name, (event) => {
event.preventDefault();
dropzone.classList.remove('drag');
});
});
dropzone.addEventListener('drop', (event) => {
const files = [...event.dataTransfer.files].map(file => ({
name: file.name,
type: $('#docType').value === '自动识别类型' ? '待识别' : $('#docType').value,
size: Math.max(1, Math.round(file.size / 1024)) + ' KB',
confidence: '待 OCR'
}));
if (files.length) {
state.files.push(...files);
addAudit('文件上传', '用户拖拽上传 ' + files.length + ' 份材料。', '用户');
renderFiles();
}
});
document.addEventListener('input', (event) => {
const input = event.target.closest('[data-amount]');
if (!input) return;
expenses[Number(input.dataset.amount)].amount = Number(input.value || 0);
renderPanel();
});
}
function renderAll() {
renderNav();
renderFiles();
renderExpenses();
renderPrecheck();
renderConfirm();
renderAudit();
renderPanel();
}
bindEvents();
renderAll();
</script>
</body>
</html>