refactor: consolidate finance workflow modules
This commit is contained in:
@@ -148,10 +148,10 @@
|
||||
.ai-quick-btn {
|
||||
min-height: 48px;
|
||||
display: grid;
|
||||
grid-template-columns: 28px minmax(0, 1fr);
|
||||
grid-template-columns: 32px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 4px;
|
||||
gap: 12px;
|
||||
padding: 7px 10px;
|
||||
color: #111827;
|
||||
font-size: 14px;
|
||||
font-weight: 780;
|
||||
@@ -160,13 +160,21 @@
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.ai-quick-btn i {
|
||||
width: 28px;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
.ai-quick-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
color: #536277;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.ai-sidebar-tabler-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: block;
|
||||
overflow: visible;
|
||||
flex: none;
|
||||
stroke-width: 1.85;
|
||||
}
|
||||
|
||||
.ai-quick-btn.primary {
|
||||
@@ -175,17 +183,10 @@
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.ai-quick-btn.active {
|
||||
color: #173d78;
|
||||
background: rgba(45, 114, 217, 0.055);
|
||||
border-color: rgba(45, 114, 217, 0.12);
|
||||
}
|
||||
|
||||
.ai-quick-btn.primary i {
|
||||
.ai-quick-btn.primary .ai-quick-icon {
|
||||
color: var(--ai-rail-amber);
|
||||
}
|
||||
|
||||
.ai-nav-btn:hover,
|
||||
.ai-recent-item:hover,
|
||||
.ai-user-action:hover {
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
@@ -194,7 +195,10 @@
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.ai-quick-btn:hover {
|
||||
.ai-quick-btn:hover,
|
||||
.ai-quick-btn.active,
|
||||
.ai-nav-btn:hover,
|
||||
.ai-nav-btn.active {
|
||||
color: #0f172a;
|
||||
background: rgba(15, 23, 42, 0.035);
|
||||
border-color: transparent;
|
||||
@@ -202,11 +206,14 @@
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.ai-quick-btn:hover i {
|
||||
.ai-quick-btn:hover .ai-quick-icon,
|
||||
.ai-quick-btn.active .ai-quick-icon,
|
||||
.ai-nav-btn:hover .ai-nav-icon,
|
||||
.ai-nav-btn.active .ai-nav-icon {
|
||||
color: var(--ai-rail-accent);
|
||||
}
|
||||
|
||||
.ai-quick-btn.primary:hover i {
|
||||
.ai-quick-btn.primary:hover .ai-quick-icon {
|
||||
color: var(--ai-rail-amber);
|
||||
}
|
||||
|
||||
@@ -215,10 +222,10 @@
|
||||
min-height: 48px;
|
||||
height: 48px;
|
||||
display: grid;
|
||||
grid-template-columns: 28px minmax(0, 1fr) 28px;
|
||||
grid-template-columns: 32px minmax(0, 1fr) 28px;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0 6px 0 4px;
|
||||
gap: 8px;
|
||||
padding: 0 6px 0 10px;
|
||||
border: 1px solid rgba(45, 114, 217, 0.14);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
@@ -308,41 +315,6 @@
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.ai-nav-btn::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
width: 3px;
|
||||
height: 22px;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
transition:
|
||||
background 180ms var(--ease),
|
||||
opacity 180ms var(--ease);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.ai-nav-btn.active {
|
||||
border-color: rgba(45, 114, 217, 0.13);
|
||||
background:
|
||||
linear-gradient(90deg, rgba(45, 114, 217, 0.095), rgba(255, 255, 255, 0.74)),
|
||||
var(--ai-rail-panel);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.78),
|
||||
0 8px 18px rgba(45, 114, 217, 0.045);
|
||||
color: #173d78;
|
||||
}
|
||||
|
||||
.ai-nav-btn.active::before {
|
||||
background: linear-gradient(180deg, var(--ai-rail-accent), var(--ai-rail-green));
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.ai-nav-btn:not(.active):hover::before {
|
||||
background: rgba(45, 114, 217, 0.36);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.ai-nav-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
@@ -357,17 +329,9 @@
|
||||
box-shadow 180ms var(--ease);
|
||||
}
|
||||
|
||||
.ai-nav-btn.active .ai-nav-icon {
|
||||
background:
|
||||
linear-gradient(145deg, rgba(45, 114, 217, 0.12), rgba(255, 255, 255, 0.72)),
|
||||
rgba(255, 255, 255, 0.52);
|
||||
color: var(--ai-rail-accent);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.82);
|
||||
}
|
||||
|
||||
.ai-nav-icon i {
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
.ai-nav-icon .ai-sidebar-tabler-icon {
|
||||
width: 19px;
|
||||
height: 19px;
|
||||
}
|
||||
|
||||
.ai-nav-copy {
|
||||
@@ -386,10 +350,6 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ai-nav-btn.active .ai-nav-copy strong {
|
||||
font-weight: 820;
|
||||
}
|
||||
|
||||
.ai-recent-desc {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
@@ -621,8 +581,7 @@
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.ai-rail.rail-collapsed .ai-nav-list::before,
|
||||
.ai-rail.rail-collapsed .ai-nav-btn::before {
|
||||
.ai-rail.rail-collapsed .ai-nav-list::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -636,12 +595,6 @@
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.ai-rail.rail-collapsed .ai-nav-btn.active {
|
||||
grid-column: auto;
|
||||
min-height: 44px;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.ai-rail.rail-collapsed .ai-quick-btn span,
|
||||
.ai-rail.rail-collapsed .ai-conversation-search,
|
||||
.ai-rail.rail-collapsed .ai-brand-copy,
|
||||
@@ -653,7 +606,7 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ai-rail.rail-collapsed .ai-quick-btn i,
|
||||
.ai-rail.rail-collapsed .ai-quick-icon,
|
||||
.ai-rail.rail-collapsed .ai-brand-logo,
|
||||
.ai-rail.rail-collapsed .ai-nav-icon,
|
||||
.ai-rail.rail-collapsed .ai-user-avatar {
|
||||
|
||||
@@ -475,42 +475,30 @@
|
||||
top: calc(100% + 12px);
|
||||
right: 0;
|
||||
z-index: 60;
|
||||
width: 380px;
|
||||
width: 428px;
|
||||
max-width: calc(100vw - 24px);
|
||||
max-height: min(520px, calc(100vh - 96px));
|
||||
max-height: min(560px, calc(100vh - 68px));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
border-radius: 12px;
|
||||
border: 1px solid #dbe4ef;
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
box-shadow:
|
||||
0 16px 36px rgba(0, 0, 0, 0.08),
|
||||
0 4px 12px rgba(0, 0, 0, 0.03),
|
||||
0 0 1px rgba(0, 0, 0, 0.1);
|
||||
0 18px 42px rgba(15, 23, 42, 0.13),
|
||||
0 4px 12px rgba(15, 23, 42, 0.06);
|
||||
overscroll-behavior-y: contain;
|
||||
}
|
||||
|
||||
.notification-popover::before {
|
||||
content: "";
|
||||
display: block;
|
||||
height: 3px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--theme-primary-active) 0%,
|
||||
var(--theme-primary-light-3, #7eb3d4) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.notification-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 14px 10px;
|
||||
border-bottom: 1px solid #edf2f7;
|
||||
background: #fafbfd;
|
||||
padding: 14px 16px 12px;
|
||||
border-bottom: 1px solid #e6edf6;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.notification-head-brand {
|
||||
@@ -522,14 +510,14 @@
|
||||
}
|
||||
|
||||
.notification-head-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
flex: 0 0 auto;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid var(--theme-primary-light-6);
|
||||
border: 1px solid #dbeafe;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
background: #f8fbff;
|
||||
color: var(--theme-primary-active);
|
||||
font-size: 17px;
|
||||
}
|
||||
@@ -561,22 +549,25 @@
|
||||
}
|
||||
|
||||
.notification-clear-btn {
|
||||
height: 28px;
|
||||
height: 30px;
|
||||
padding: 0 10px;
|
||||
border: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--theme-primary-active);
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
font-weight: 750;
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
background 160ms var(--ease),
|
||||
border-color 160ms var(--ease),
|
||||
color 160ms var(--ease);
|
||||
}
|
||||
|
||||
.notification-clear-btn:hover:not(:disabled) {
|
||||
background: var(--theme-primary-light-9);
|
||||
border-color: #bfdbfe;
|
||||
background: #eff6ff;
|
||||
color: var(--theme-primary-active);
|
||||
}
|
||||
|
||||
.notification-clear-btn:disabled {
|
||||
@@ -585,8 +576,8 @@
|
||||
}
|
||||
|
||||
.notification-close-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
@@ -608,15 +599,15 @@
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
padding: 0 14px;
|
||||
border-bottom: 1px solid #edf2f7;
|
||||
background: #fff;
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid #e6edf6;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.notification-tabs button {
|
||||
position: relative;
|
||||
flex: 1 1 0;
|
||||
height: 38px;
|
||||
height: 40px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -666,10 +657,10 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
max-height: min(336px, calc(100vh - 226px));
|
||||
max-height: min(420px, calc(100vh - 166px));
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
padding: 4px 0 12px;
|
||||
padding: 10px 0 14px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #cbd5e1 #f8fafc;
|
||||
overscroll-behavior-y: contain;
|
||||
@@ -691,179 +682,151 @@
|
||||
|
||||
.notification-row {
|
||||
display: grid;
|
||||
grid-template-columns: 34px minmax(0, 1fr) 16px;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-height: 68px;
|
||||
padding: 12px 16px;
|
||||
grid-template-columns: 52px minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: 16px;
|
||||
min-height: 104px;
|
||||
padding: 20px 22px;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: #ffffff;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition:
|
||||
background 180ms var(--ease),
|
||||
border-color 180ms var(--ease);
|
||||
transform 180ms var(--ease);
|
||||
}
|
||||
|
||||
.notification-row + .notification-row {
|
||||
border-top: 1px solid #f1f5f9;
|
||||
border-top: 1px solid #f4f6fb;
|
||||
}
|
||||
|
||||
.notification-row.unread {
|
||||
background: #f8fafc;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.notification-row.unread::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 16px;
|
||||
bottom: 16px;
|
||||
width: 3px;
|
||||
border-radius: 0 4px 4px 0;
|
||||
background: var(--theme-primary-active);
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.notification-row:hover {
|
||||
background: #f1f5f9;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.notification-row.unread:hover {
|
||||
background: #f1f5f9;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.notification-type-icon {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
.notification-avatar {
|
||||
position: relative;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid rgba(0,0,0,0.04);
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
color: var(--theme-primary-active);
|
||||
font-size: 16px;
|
||||
box-shadow: 0 1.5px 4px rgba(0,0,0,0.03);
|
||||
border: 1px solid #dbeafe;
|
||||
border-radius: 999px;
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.notification-type-icon.danger {
|
||||
.notification-avatar.danger {
|
||||
border-color: #fecaca;
|
||||
background: #fff5f5;
|
||||
color: #dc2626;
|
||||
background: #fff1f2;
|
||||
color: #be123c;
|
||||
}
|
||||
|
||||
.notification-type-icon.warning {
|
||||
.notification-avatar.warning {
|
||||
border-color: #fde68a;
|
||||
background: #fffbeb;
|
||||
color: #d97706;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.notification-type-icon.success {
|
||||
.notification-avatar.success {
|
||||
border-color: #bbf7d0;
|
||||
background: #f0fdf4;
|
||||
color: #16a34a;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.notification-type-icon.info {
|
||||
.notification-avatar.info {
|
||||
border-color: #bfdbfe;
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.notification-copy {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding-top: 1px;
|
||||
padding-bottom: 1px;
|
||||
.notification-avatar-label {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.notification-title-line {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.notification-copy strong {
|
||||
min-width: 0;
|
||||
color: #0f172a;
|
||||
font-size: 13.5px;
|
||||
font-weight: 750;
|
||||
line-height: 1.3;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.notification-title-line b {
|
||||
flex: 0 0 auto;
|
||||
.notification-avatar-badge {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
right: -2px;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 5px;
|
||||
border-radius: 4px;
|
||||
background: #dc2626;
|
||||
color: #fff;
|
||||
padding: 0 4px;
|
||||
border: 2px solid #ffffff;
|
||||
border-radius: 999px;
|
||||
background: #ef4444;
|
||||
color: #ffffff;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.notification-copy small {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
.notification-row-content {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.notification-row-top {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.notification-row-title {
|
||||
min-width: 0;
|
||||
color: #111827;
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
line-height: 1.25;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.notification-row.unread .notification-row-title {
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.notification-preview {
|
||||
max-width: 100%;
|
||||
color: #8a94a6;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.55;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.notification-meta {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.notification-meta em,
|
||||
.notification-meta time {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: #94a3b8;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.notification-meta em {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.notification-meta time {
|
||||
.notification-time {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.notification-row-arrow {
|
||||
color: #cbd5e1;
|
||||
font-size: 18px;
|
||||
transition: color 160ms var(--ease), transform 160ms var(--ease);
|
||||
}
|
||||
|
||||
.notification-row:hover .notification-row-arrow {
|
||||
color: var(--theme-primary-active);
|
||||
transform: translateX(2px);
|
||||
color: #b4bcc8;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.notification-empty {
|
||||
@@ -1573,7 +1536,24 @@
|
||||
}
|
||||
|
||||
.notification-row {
|
||||
padding: 9px 12px;
|
||||
grid-template-columns: 44px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
min-height: 90px;
|
||||
padding: 16px 14px;
|
||||
}
|
||||
|
||||
.notification-avatar {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.notification-row-top {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.notification-time {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.company-switcher {
|
||||
@@ -1641,7 +1621,7 @@
|
||||
}
|
||||
|
||||
.notification-head-icon,
|
||||
.notification-type-icon {
|
||||
.notification-avatar {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
@@ -1653,10 +1633,26 @@
|
||||
.notification-row {
|
||||
grid-template-columns: 30px minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
min-height: 82px;
|
||||
padding: 13px 10px;
|
||||
}
|
||||
|
||||
.notification-row-arrow {
|
||||
display: none;
|
||||
.notification-avatar {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.notification-avatar-badge {
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.notification-row-title {
|
||||
font-size: 13.5px;
|
||||
}
|
||||
|
||||
.notification-preview {
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.topbar.detail-mode {
|
||||
|
||||
@@ -28,183 +28,11 @@
|
||||
<p>您可以直接向我提问,或选择下方推荐主题,快速完成费用相关事务</p>
|
||||
</div>
|
||||
|
||||
<form class="workbench-ai-composer" @submit.prevent="submitAiModePrompt">
|
||||
<div class="workbench-ai-composer-field">
|
||||
<div v-if="workbenchDateTagLabel" class="workbench-ai-date-chip">
|
||||
<i class="mdi mdi-calendar-check-outline"></i>
|
||||
<span>{{ workbenchDateTagLabel }}</span>
|
||||
<button type="button" aria-label="移除日期" :disabled="isAiModeInputLocked" @click="removeWorkbenchDateTag">
|
||||
<i class="mdi mdi-close"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
ref="assistantInputRef"
|
||||
v-model="assistantDraft"
|
||||
maxlength="1000"
|
||||
rows="3"
|
||||
:placeholder="isAiModeInputLocked ? '费用测算中,请稍等...' : '今天我能帮您做点什么?'"
|
||||
:disabled="isAiModeInputLocked"
|
||||
@keydown.enter.exact.prevent="submitAiModePrompt"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="workbench-ai-composer-toolbar">
|
||||
<div class="workbench-ai-tool-buttons">
|
||||
<div class="workbench-date-anchor workbench-ai-date-anchor">
|
||||
<button
|
||||
type="button"
|
||||
class="workbench-ai-icon-btn"
|
||||
:class="{ active: workbenchDatePickerOpen || workbenchDateTagLabel }"
|
||||
title="选择日期"
|
||||
aria-label="选择日期"
|
||||
:aria-expanded="workbenchDatePickerOpen"
|
||||
:disabled="isAiModeInputLocked"
|
||||
@click.stop="toggleWorkbenchDatePicker"
|
||||
>
|
||||
<i class="mdi mdi-calendar-range"></i>
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="workbenchDatePickerOpen"
|
||||
class="workbench-ai-date-popover"
|
||||
role="dialog"
|
||||
aria-label="选择业务日期"
|
||||
@click.stop
|
||||
>
|
||||
<div class="workbench-ai-date-tabs" role="tablist" aria-label="日期模式">
|
||||
<button
|
||||
type="button"
|
||||
:class="{ active: workbenchDateMode === 'single' }"
|
||||
:disabled="isAiModeInputLocked"
|
||||
@click="setWorkbenchDateMode('single')"
|
||||
>
|
||||
单日
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="{ active: workbenchDateMode === 'range' }"
|
||||
:disabled="isAiModeInputLocked"
|
||||
@click="setWorkbenchDateMode('range')"
|
||||
>
|
||||
范围
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label v-if="workbenchDateMode === 'single'" class="workbench-ai-date-field">
|
||||
<span>业务日期</span>
|
||||
<input
|
||||
v-model="workbenchSingleDate"
|
||||
type="date"
|
||||
:disabled="isAiModeInputLocked"
|
||||
@change="handleWorkbenchDateInputChange('single')"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div v-else class="workbench-ai-date-range">
|
||||
<label class="workbench-ai-date-field">
|
||||
<span>开始日期</span>
|
||||
<input
|
||||
v-model="workbenchRangeStartDate"
|
||||
type="date"
|
||||
:disabled="isAiModeInputLocked"
|
||||
@change="handleWorkbenchDateInputChange('range-start')"
|
||||
/>
|
||||
</label>
|
||||
<label class="workbench-ai-date-field">
|
||||
<span>结束日期</span>
|
||||
<input
|
||||
v-model="workbenchRangeEndDate"
|
||||
type="date"
|
||||
:disabled="isAiModeInputLocked"
|
||||
@change="handleWorkbenchDateInputChange('range-end')"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="workbench-ai-date-actions">
|
||||
<button type="button" class="ghost" :disabled="isAiModeInputLocked" @click="clearWorkbenchDateSelection">清除</button>
|
||||
<button
|
||||
type="button"
|
||||
class="primary"
|
||||
:disabled="!workbenchCanApplyDateSelection || isAiModeInputLocked"
|
||||
@click="applyWorkbenchDateSelection"
|
||||
>
|
||||
完成
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="workbench-ai-icon-btn"
|
||||
title="上传附件"
|
||||
aria-label="上传附件"
|
||||
:disabled="isAiModeInputLocked"
|
||||
@click="triggerAiModeFileUpload"
|
||||
>
|
||||
<i class="mdi mdi-paperclip"></i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="workbench-ai-icon-btn"
|
||||
title="语音输入"
|
||||
aria-label="语音输入"
|
||||
:disabled="isAiModeInputLocked"
|
||||
@click="handleVoiceInput"
|
||||
>
|
||||
<i class="mdi mdi-microphone-outline"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="workbench-ai-composer-right">
|
||||
<div class="workbench-ai-model-selector" :title="modelSelectorTitle">
|
||||
<span>{{ displayModelName }}</span>
|
||||
<i class="mdi mdi-chevron-down"></i>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
class="workbench-ai-send-btn"
|
||||
:disabled="!canSubmitAiModePrompt || sending || isAiModeInputLocked"
|
||||
aria-label="发送给小财管家"
|
||||
>
|
||||
<i class="mdi mdi-arrow-up"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div v-if="selectedFileCards.length" class="workbench-ai-file-strip" aria-label="已选择附件">
|
||||
<article v-for="file in selectedFileCards" :key="file.key" class="workbench-ai-file-card">
|
||||
<span class="workbench-ai-file-card__icon" :class="`type-${file.tone}`" aria-hidden="true">
|
||||
<i :class="file.icon"></i>
|
||||
</span>
|
||||
<span class="workbench-ai-file-card__body">
|
||||
<strong :title="file.name">{{ file.name }}</strong>
|
||||
<small>{{ file.typeLabel }}</small>
|
||||
<small
|
||||
v-if="file.ocrState?.label"
|
||||
class="workbench-ai-file-card__ocr"
|
||||
:class="`is-${file.ocrState.status || 'idle'}`"
|
||||
:title="file.ocrState.title || file.ocrState.label"
|
||||
>
|
||||
<i v-if="file.ocrState.status === 'recognizing'" class="mdi mdi-loading"></i>
|
||||
<i v-else-if="file.ocrState.status === 'recognized'" class="mdi mdi-text-recognition"></i>
|
||||
<i v-else-if="file.ocrState.status === 'failed'" class="mdi mdi-alert-circle-outline"></i>
|
||||
<span>{{ file.ocrState.label }}</span>
|
||||
</small>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="workbench-ai-file-card__remove"
|
||||
:disabled="isAiModeInputLocked"
|
||||
:aria-label="`移除附件 ${file.name}`"
|
||||
@click="removeAiModeFile(file.key)"
|
||||
>
|
||||
<i class="mdi mdi-close"></i>
|
||||
</button>
|
||||
</article>
|
||||
</div>
|
||||
<WorkbenchAiComposer
|
||||
:runtime="workbenchAiRuntime"
|
||||
placeholder="今天我能帮您做点什么?"
|
||||
/>
|
||||
<WorkbenchAiFileStrip :runtime="workbenchAiRuntime" />
|
||||
|
||||
<div class="workbench-ai-quick-start-section">
|
||||
<h3 class="workbench-ai-quick-start-title">快速开始</h3>
|
||||
@@ -565,183 +393,12 @@
|
||||
</div>
|
||||
|
||||
<div class="workbench-ai-conversation-bottom">
|
||||
<div v-if="selectedFileCards.length" class="workbench-ai-file-strip inline" aria-label="已选择附件">
|
||||
<article v-for="file in selectedFileCards" :key="file.key" class="workbench-ai-file-card">
|
||||
<span class="workbench-ai-file-card__icon" :class="`type-${file.tone}`" aria-hidden="true">
|
||||
<i :class="file.icon"></i>
|
||||
</span>
|
||||
<span class="workbench-ai-file-card__body">
|
||||
<strong :title="file.name">{{ file.name }}</strong>
|
||||
<small>{{ file.typeLabel }}</small>
|
||||
<small
|
||||
v-if="file.ocrState?.label"
|
||||
class="workbench-ai-file-card__ocr"
|
||||
:class="`is-${file.ocrState.status || 'idle'}`"
|
||||
:title="file.ocrState.title || file.ocrState.label"
|
||||
>
|
||||
<i v-if="file.ocrState.status === 'recognizing'" class="mdi mdi-loading"></i>
|
||||
<i v-else-if="file.ocrState.status === 'recognized'" class="mdi mdi-text-recognition"></i>
|
||||
<i v-else-if="file.ocrState.status === 'failed'" class="mdi mdi-alert-circle-outline"></i>
|
||||
<span>{{ file.ocrState.label }}</span>
|
||||
</small>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="workbench-ai-file-card__remove"
|
||||
:disabled="isAiModeInputLocked"
|
||||
:aria-label="`移除附件 ${file.name}`"
|
||||
@click="removeAiModeFile(file.key)"
|
||||
>
|
||||
<i class="mdi mdi-close"></i>
|
||||
</button>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<form class="workbench-ai-composer workbench-ai-composer--inline" @submit.prevent="submitAiModePrompt">
|
||||
<div class="workbench-ai-composer-field">
|
||||
<div v-if="workbenchDateTagLabel" class="workbench-ai-date-chip">
|
||||
<i class="mdi mdi-calendar-check-outline"></i>
|
||||
<span>{{ workbenchDateTagLabel }}</span>
|
||||
<button type="button" aria-label="移除日期" :disabled="isAiModeInputLocked" @click="removeWorkbenchDateTag">
|
||||
<i class="mdi mdi-close"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
ref="assistantInputRef"
|
||||
v-model="assistantDraft"
|
||||
maxlength="1000"
|
||||
rows="3"
|
||||
:placeholder="isAiModeInputLocked ? '费用测算中,请稍等...' : '继续和小财管家对话...'"
|
||||
:disabled="isAiModeInputLocked"
|
||||
@keydown.enter.exact.prevent="submitAiModePrompt"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="workbench-ai-composer-toolbar">
|
||||
<div class="workbench-ai-tool-buttons">
|
||||
<div class="workbench-date-anchor workbench-ai-date-anchor">
|
||||
<button
|
||||
type="button"
|
||||
class="workbench-ai-icon-btn"
|
||||
:class="{ active: workbenchDatePickerOpen || workbenchDateTagLabel }"
|
||||
title="选择日期"
|
||||
aria-label="选择日期"
|
||||
:aria-expanded="workbenchDatePickerOpen"
|
||||
:disabled="isAiModeInputLocked"
|
||||
@click.stop="toggleWorkbenchDatePicker"
|
||||
>
|
||||
<i class="mdi mdi-calendar-range"></i>
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="workbenchDatePickerOpen"
|
||||
class="workbench-ai-date-popover"
|
||||
role="dialog"
|
||||
aria-label="选择业务日期"
|
||||
@click.stop
|
||||
>
|
||||
<div class="workbench-ai-date-tabs" role="tablist" aria-label="日期模式">
|
||||
<button
|
||||
type="button"
|
||||
:class="{ active: workbenchDateMode === 'single' }"
|
||||
:disabled="isAiModeInputLocked"
|
||||
@click="setWorkbenchDateMode('single')"
|
||||
>
|
||||
单日
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="{ active: workbenchDateMode === 'range' }"
|
||||
:disabled="isAiModeInputLocked"
|
||||
@click="setWorkbenchDateMode('range')"
|
||||
>
|
||||
范围
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label v-if="workbenchDateMode === 'single'" class="workbench-ai-date-field">
|
||||
<span>业务日期</span>
|
||||
<input
|
||||
v-model="workbenchSingleDate"
|
||||
type="date"
|
||||
:disabled="isAiModeInputLocked"
|
||||
@change="handleWorkbenchDateInputChange('single')"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div v-else class="workbench-ai-date-range">
|
||||
<label class="workbench-ai-date-field">
|
||||
<span>开始日期</span>
|
||||
<input
|
||||
v-model="workbenchRangeStartDate"
|
||||
type="date"
|
||||
:disabled="isAiModeInputLocked"
|
||||
@change="handleWorkbenchDateInputChange('range-start')"
|
||||
/>
|
||||
</label>
|
||||
<label class="workbench-ai-date-field">
|
||||
<span>结束日期</span>
|
||||
<input
|
||||
v-model="workbenchRangeEndDate"
|
||||
type="date"
|
||||
:disabled="isAiModeInputLocked"
|
||||
@change="handleWorkbenchDateInputChange('range-end')"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="workbench-ai-date-actions">
|
||||
<button type="button" class="ghost" :disabled="isAiModeInputLocked" @click="clearWorkbenchDateSelection">清除</button>
|
||||
<button
|
||||
type="button"
|
||||
class="primary"
|
||||
:disabled="!workbenchCanApplyDateSelection || isAiModeInputLocked"
|
||||
@click="applyWorkbenchDateSelection"
|
||||
>
|
||||
完成
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="workbench-ai-icon-btn"
|
||||
title="上传附件"
|
||||
aria-label="上传附件"
|
||||
:disabled="isAiModeInputLocked"
|
||||
@click="triggerAiModeFileUpload"
|
||||
>
|
||||
<i class="mdi mdi-paperclip"></i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="workbench-ai-icon-btn"
|
||||
title="语音输入"
|
||||
aria-label="语音输入"
|
||||
:disabled="isAiModeInputLocked"
|
||||
@click="handleVoiceInput"
|
||||
>
|
||||
<i class="mdi mdi-microphone-outline"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="workbench-ai-composer-right">
|
||||
<div class="workbench-ai-model-selector" :title="modelSelectorTitle">
|
||||
<span>{{ displayModelName }}</span>
|
||||
<i class="mdi mdi-chevron-down"></i>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
class="workbench-ai-send-btn"
|
||||
:disabled="!canSubmitAiModePrompt || sending || isAiModeInputLocked"
|
||||
aria-label="发送给小财管家"
|
||||
>
|
||||
<i class="mdi mdi-arrow-up"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<WorkbenchAiFileStrip inline :runtime="workbenchAiRuntime" />
|
||||
<WorkbenchAiComposer
|
||||
inline
|
||||
:runtime="workbenchAiRuntime"
|
||||
placeholder="继续和小财管家对话..."
|
||||
/>
|
||||
|
||||
<p class="workbench-ai-disclaimer">小财管家可能会出错,重要费用事项请核对单据、制度与审批结果。</p>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<template src="./PersonalWorkbenchAiMode.template.html"></template>
|
||||
|
||||
<script setup>
|
||||
import { proxyRefs } from 'vue'
|
||||
import orbIcon from '../../assets/workbench-ai-mode-orb-icon.gif'
|
||||
import WorkbenchAiComposer from './workbench-ai/WorkbenchAiComposer.vue'
|
||||
import WorkbenchAiFileStrip from './workbench-ai/WorkbenchAiFileStrip.vue'
|
||||
import { usePersonalWorkbenchAiMode } from '../../composables/workbenchAiMode/usePersonalWorkbenchAiMode.js'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -9,9 +12,12 @@ const props = defineProps({
|
||||
})
|
||||
const emit = defineEmits(['conversation-change', 'conversation-history-change', 'open-document', 'request-updated'])
|
||||
|
||||
const aiModeRuntime = usePersonalWorkbenchAiMode(props, emit)
|
||||
const workbenchAiRuntime = proxyRefs(aiModeRuntime)
|
||||
|
||||
const {
|
||||
activeConversationTitle, aiModeActionItems, applicationPreviewEditor, applicationSubmitConfirmOpen, assistantDraft, assistantInputRef, canShowInlineSuggestedActions, canSubmitAiModePrompt, cancelDeleteConversation, cancelInlineApplicationSubmitConfirm, clearWorkbenchDateSelection, commitInlineApplicationPreviewEditor, confirmDeleteConversation, confirmInlineApplicationSubmit, conversationMessages, conversationScrollRef, conversationStarted, deleteDialogOpen, displayModelName, displayUserName, fileInputRef, handleAiAnswerMarkdownClick, handleAiModeFilesChange, handleInlineApplicationPreviewEditorKeydown, handleInlineConversationScroll, handleInlineSuggestedAction, handleVoiceInput, hasInlineAttachmentOcrDetails, hasInlineThinking, isAiModeInputLocked, isApplicationPreviewEditing, isApplicationPreviewEstimatePending, isInlineAttachmentOcrExpanded, isInlineSuggestedActionDisabled, isInlineThinkingExpanded, markInlineMessageFeedback, modelSelectorTitle, openApplicationPreviewEditor, quoteInlineMessage, regenerateLastReply, removeAiModeFile, removeWorkbenchDateTag, renderInlineConversationHtml, requestDeleteCurrentConversation, resolveApplicationPreviewEditorOptions, resolveInlineApplicationPreviewEditorControl, resolveInlineApplicationPreviewEditorDateMax, resolveInlineApplicationPreviewEditorDateMin, resolveInlineApplicationPreviewMissingFields, resolveInlineApplicationPreviewRows, resolveInlineAttachmentOcrDocuments, resolveInlineAttachmentOcrFileCount, resolveInlineThinkingEvents, runAiModeAction, scrollInlineConversationToTop, selectedFileCards, sending, setWorkbenchDateMode, submitAiModePrompt, toggleInlineAttachmentOcrDetails, toggleInlineThinking, toggleWorkbenchDatePicker, triggerAiModeFileUpload, workbenchCanApplyDateSelection, workbenchDateMode, workbenchDatePickerOpen, workbenchDateTagLabel, workbenchRangeEndDate, workbenchRangeStartDate, workbenchSingleDate, applyWorkbenchDateSelection, buildInlineApplicationPreviewFooterText, copyInlineMessage, formatMessageTime, handleWorkbenchDateInputChange
|
||||
} = usePersonalWorkbenchAiMode(props, emit)
|
||||
} = aiModeRuntime
|
||||
</script>
|
||||
|
||||
<style scoped src="../../assets/styles/components/personal-workbench-ai-mode.css"></style>
|
||||
|
||||
@@ -12,6 +12,17 @@
|
||||
<span class="workbench-ai-file-card__body">
|
||||
<strong :title="file.name">{{ file.name }}</strong>
|
||||
<small>{{ file.typeLabel }}</small>
|
||||
<small
|
||||
v-if="file.ocrState?.label"
|
||||
class="workbench-ai-file-card__ocr"
|
||||
:class="`is-${file.ocrState.status || 'idle'}`"
|
||||
:title="file.ocrState.title || file.ocrState.label"
|
||||
>
|
||||
<i v-if="file.ocrState.status === 'recognizing'" class="mdi mdi-loading"></i>
|
||||
<i v-else-if="file.ocrState.status === 'recognized'" class="mdi mdi-text-recognition"></i>
|
||||
<i v-else-if="file.ocrState.status === 'failed'" class="mdi mdi-alert-circle-outline"></i>
|
||||
<span>{{ file.ocrState.label }}</span>
|
||||
</small>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -43,7 +43,23 @@
|
||||
:class="{ primary: action.primary }"
|
||||
@click="handleQuickAction(action.event)"
|
||||
>
|
||||
<i :class="action.icon" aria-hidden="true"></i>
|
||||
<span class="ai-quick-icon" aria-hidden="true">
|
||||
<svg
|
||||
class="ai-sidebar-tabler-icon"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path
|
||||
v-for="(path, pathIndex) in action.iconPaths"
|
||||
:key="`${action.event}-${pathIndex}`"
|
||||
:d="path"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<span>{{ action.label }}</span>
|
||||
</button>
|
||||
</template>
|
||||
@@ -60,10 +76,26 @@
|
||||
class="ai-nav-btn"
|
||||
:class="{ active: activeView === item.id }"
|
||||
:aria-current="activeView === item.id ? 'page' : undefined"
|
||||
@mouseenter="emit('prefetch-view', item.id)"
|
||||
@focus="emit('prefetch-view', item.id)"
|
||||
@click="emit('navigate', item.id)"
|
||||
>
|
||||
<span class="ai-nav-icon" aria-hidden="true">
|
||||
<i :class="item.aiIcon"></i>
|
||||
<svg
|
||||
class="ai-sidebar-tabler-icon"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path
|
||||
v-for="(path, pathIndex) in item.aiIconPaths"
|
||||
:key="`${item.id}-${pathIndex}`"
|
||||
:d="path"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="ai-nav-copy">
|
||||
<strong>{{ item.displayLabel }}</strong>
|
||||
@@ -155,7 +187,7 @@ const props = defineProps({
|
||||
conversationHistory: { type: Array, default: () => [] }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['navigate', 'new-chat', 'open-recent', 'rename-conversation', 'logout'])
|
||||
const emit = defineEmits(['navigate', 'new-chat', 'open-recent', 'rename-conversation', 'logout', 'prefetch-view'])
|
||||
const conversationSearchOpen = ref(false)
|
||||
const conversationSearchQuery = ref('')
|
||||
const conversationSearchInputRef = ref(null)
|
||||
@@ -164,16 +196,78 @@ const editingConversationTitle = ref('')
|
||||
const editingTitleInputRef = ref(null)
|
||||
let recentClickTimer = null
|
||||
|
||||
const tablerIconPaths = {
|
||||
plus: [
|
||||
'M12 5l0 14',
|
||||
'M5 12l14 0'
|
||||
],
|
||||
search: [
|
||||
'M3 10a7 7 0 1 0 14 0a7 7 0 1 0 -14 0',
|
||||
'M21 21l-6 -6'
|
||||
],
|
||||
fileText: [
|
||||
'M14 3v4a1 1 0 0 0 1 1h4',
|
||||
'M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2',
|
||||
'M9 9l1 0',
|
||||
'M9 13l6 0',
|
||||
'M9 17l6 0'
|
||||
],
|
||||
folder: [
|
||||
'M5 4h4l3 3h7a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-11a2 2 0 0 1 2 -2'
|
||||
],
|
||||
book2: [
|
||||
'M19 4v16h-12a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h12',
|
||||
'M19 16h-12a2 2 0 0 0 -2 2',
|
||||
'M9 8h6'
|
||||
],
|
||||
chartLine: [
|
||||
'M4 19l16 0',
|
||||
'M4 15l4 -6l4 2l4 -5l4 4'
|
||||
],
|
||||
chartDonut: [
|
||||
'M10 3.2a9 9 0 1 0 10.8 10.8a1 1 0 0 0 -1 -1h-6.8a2 2 0 0 1 -2 -2v-6.8a1 1 0 0 0 -1 -1',
|
||||
'M15 3.5a9 9 0 0 1 5.5 5.5h-4.5a1 1 0 0 1 -1 -1v-4.5'
|
||||
],
|
||||
shieldCheck: [
|
||||
'M11.46 20.846a12 12 0 0 1 -7.46 -10.846v-4l8 -3l8 3v4c0 1.122 -.154 2.203 -.441 3.226',
|
||||
'M15 19l2 2l4 -4'
|
||||
],
|
||||
robot: [
|
||||
'M7 7h10a2 2 0 0 1 2 2v7a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-7a2 2 0 0 1 2 -2',
|
||||
'M9 11l.01 0',
|
||||
'M15 11l.01 0',
|
||||
'M9 15h6',
|
||||
'M12 7v-4'
|
||||
],
|
||||
users: [
|
||||
'M9 7m-4 0a4 4 0 1 0 8 0a4 4 0 1 0 -8 0',
|
||||
'M3 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2',
|
||||
'M16 3.13a4 4 0 0 1 0 7.75',
|
||||
'M21 21v-2a4 4 0 0 0 -3 -3.85'
|
||||
],
|
||||
sliders: [
|
||||
'M4 6h16',
|
||||
'M4 12h10',
|
||||
'M4 18h14',
|
||||
'M8 6v.01',
|
||||
'M14 12v.01',
|
||||
'M18 18v.01'
|
||||
],
|
||||
circle: [
|
||||
'M12 12m-8 0a8 8 0 1 0 16 0a8 8 0 1 0 -16 0'
|
||||
]
|
||||
}
|
||||
|
||||
const quickActions = [
|
||||
{
|
||||
label: '新建对话',
|
||||
icon: 'mdi mdi-plus',
|
||||
iconPaths: tablerIconPaths.plus,
|
||||
event: 'new-chat',
|
||||
primary: true
|
||||
},
|
||||
{
|
||||
label: '查询对话',
|
||||
icon: 'mdi mdi-magnify',
|
||||
iconPaths: tablerIconPaths.search,
|
||||
event: 'search'
|
||||
}
|
||||
]
|
||||
@@ -181,15 +275,15 @@ const quickActions = [
|
||||
const displayBrandName = computed(() => String(props.brandName || '易财费控').trim() || '易财费控')
|
||||
|
||||
const sidebarMeta = {
|
||||
overview: { label: '分析看板', icon: 'mdi mdi-chart-line-variant' },
|
||||
documents: { label: '单据中心', icon: 'mdi mdi-file-document-outline' },
|
||||
receiptFolder: { label: '票据夹', icon: 'mdi mdi-receipt-text-outline' },
|
||||
budget: { label: '预算管理', icon: 'mdi mdi-chart-donut' },
|
||||
policies: { label: '财务政策', icon: 'mdi mdi-book-open-page-variant-outline' },
|
||||
audit: { label: '规则管理', icon: 'mdi mdi-shield-check-outline' },
|
||||
digitalEmployees: { label: '数字员工', icon: 'mdi mdi-robot-outline' },
|
||||
employees: { label: '员工管理', icon: 'mdi mdi-account-group-outline' },
|
||||
settings: { label: '系统设置', icon: 'mdi mdi-tune-vertical' }
|
||||
overview: { label: '分析看板', iconPaths: tablerIconPaths.chartLine },
|
||||
documents: { label: '单据中心', iconPaths: tablerIconPaths.fileText },
|
||||
receiptFolder: { label: '票据夹', iconPaths: tablerIconPaths.folder },
|
||||
budget: { label: '预算管理', iconPaths: tablerIconPaths.chartDonut },
|
||||
policies: { label: '财务政策', iconPaths: tablerIconPaths.book2 },
|
||||
audit: { label: '规则管理', iconPaths: tablerIconPaths.shieldCheck },
|
||||
digitalEmployees: { label: '数字员工', iconPaths: tablerIconPaths.robot },
|
||||
employees: { label: '员工管理', iconPaths: tablerIconPaths.users },
|
||||
settings: { label: '系统设置', iconPaths: tablerIconPaths.sliders }
|
||||
}
|
||||
|
||||
const aiBusinessViewIds = computed(() => new Set(resolveAiSidebarBusinessViewIds(props.currentUser)))
|
||||
@@ -200,7 +294,7 @@ const businessNavItems = computed(() =>
|
||||
.map((item) => ({
|
||||
...item,
|
||||
displayLabel: sidebarMeta[item.id]?.label ?? item.label,
|
||||
aiIcon: sidebarMeta[item.id]?.icon ?? 'mdi mdi-circle-outline'
|
||||
aiIconPaths: sidebarMeta[item.id]?.iconPaths ?? tablerIconPaths.circle
|
||||
}))
|
||||
)
|
||||
|
||||
|
||||
@@ -47,6 +47,8 @@
|
||||
class="nav-btn"
|
||||
:class="{ active: activeView === item.id }"
|
||||
type="button"
|
||||
@mouseenter="emit('prefetch-view', item.id)"
|
||||
@focus="emit('prefetch-view', item.id)"
|
||||
@click="emit('navigate', item.id)"
|
||||
>
|
||||
<span class="nav-icon" v-html="item.icon"></span>
|
||||
@@ -100,7 +102,7 @@ const props = defineProps({
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['navigate', 'openChat', 'logout', 'toggle-collapse'])
|
||||
const emit = defineEmits(['navigate', 'openChat', 'logout', 'toggle-collapse', 'prefetch-view'])
|
||||
|
||||
const sidebarMeta = {
|
||||
overview: { label: '分析看板' },
|
||||
|
||||
@@ -153,10 +153,10 @@
|
||||
<button
|
||||
class="notification-clear-btn"
|
||||
type="button"
|
||||
:disabled="notificationItems.length === 0"
|
||||
@click="clearAllNotifications"
|
||||
:disabled="notificationBulkActionDisabled"
|
||||
@click="handleNotificationBulkAction"
|
||||
>
|
||||
清空通知
|
||||
{{ notificationBulkActionLabel }}
|
||||
</button>
|
||||
<button
|
||||
class="notification-close-btn"
|
||||
@@ -201,24 +201,16 @@
|
||||
:class="{ unread: item.unread }"
|
||||
@click="openNotification(item)"
|
||||
>
|
||||
<span class="notification-type-icon" :class="item.tone">
|
||||
<i :class="resolveNotificationIcon(item)"></i>
|
||||
<span class="notification-avatar" :class="item.tone" aria-hidden="true">
|
||||
<span class="notification-avatar-label">{{ item.avatarLabel }}</span>
|
||||
<span v-if="item.badge" class="notification-avatar-badge">{{ item.badge }}</span>
|
||||
</span>
|
||||
<span class="notification-row-main">
|
||||
<span class="notification-row-head">
|
||||
<span class="notification-title-line">
|
||||
<strong class="notification-row-title">{{ item.title }}</strong>
|
||||
<b v-if="item.badge">{{ item.badge }}</b>
|
||||
</span>
|
||||
<span class="notification-row-action" aria-hidden="true">
|
||||
<i class="mdi mdi-chevron-right"></i>
|
||||
</span>
|
||||
</span>
|
||||
<small class="notification-context">{{ item.description }}</small>
|
||||
<span class="notification-row-foot">
|
||||
<span class="notification-category-pill">{{ item.category || '系统通知' }}</span>
|
||||
<span class="notification-row-content">
|
||||
<span class="notification-row-top">
|
||||
<strong class="notification-row-title">{{ item.title }}</strong>
|
||||
<time class="notification-time">{{ item.timeLabel || item.time }}</time>
|
||||
</span>
|
||||
<small class="notification-preview">{{ item.description }}</small>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -512,7 +504,8 @@ const {
|
||||
isNotificationHidden,
|
||||
isNotificationRead,
|
||||
loadNotificationStates,
|
||||
markNotificationStateRead
|
||||
markNotificationStateRead,
|
||||
markNotificationStatesRead
|
||||
} = useTopBarNotificationStates()
|
||||
const notificationTab = ref('unread')
|
||||
|
||||
@@ -565,6 +558,36 @@ function resolveDocumentNotificationDescription(row) {
|
||||
].filter(Boolean).join(' · ') || '单据中心有新的单据状态'
|
||||
}
|
||||
|
||||
function resolveNotificationAvatarLabel(item) {
|
||||
const raw = String(
|
||||
item?.avatarLabel
|
||||
|| item?.initiatorName
|
||||
|| item?.applicantName
|
||||
|| item?.employeeName
|
||||
|| item?.category
|
||||
|| item?.title
|
||||
|| '通'
|
||||
).trim()
|
||||
|
||||
if (!raw) {
|
||||
return '通'
|
||||
}
|
||||
|
||||
return raw.replace(/\s+/g, '').slice(0, 1).toUpperCase()
|
||||
}
|
||||
|
||||
function resolveDocumentNotificationAvatarLabel(row) {
|
||||
return resolveNotificationAvatarLabel({
|
||||
avatarLabel:
|
||||
row?.initiatorName
|
||||
|| row?.applicantName
|
||||
|| row?.employeeName
|
||||
|| row?.sourceLabel
|
||||
|| row?.documentTypeLabel
|
||||
|| row?.title
|
||||
})
|
||||
}
|
||||
|
||||
function resolveWorkbenchNotificationId(item, index) {
|
||||
return normalizeNotificationId(`workbench:${item?.id || [item?.title, item?.description, item?.time, index].join('|')}`)
|
||||
}
|
||||
@@ -580,15 +603,15 @@ const documentNotificationItems = computed(() =>
|
||||
|
||||
return {
|
||||
id,
|
||||
kind: 'document',
|
||||
title: `${row.documentTypeLabel || '单据'} ${row.documentNo || row.claimId || '待生成'}`,
|
||||
description: resolveDocumentNotificationDescription(row),
|
||||
time: row.updatedAt || row.createdAt,
|
||||
timeLabel: formatNotificationTimeLabel(row.updatedAt || row.createdAt),
|
||||
category: row.sourceLabel || '单据中心',
|
||||
tone: resolveDocumentNotificationTone({ ...row, isUnread: unread }),
|
||||
unread,
|
||||
icon: row.source === 'approval' ? 'mdi mdi-clipboard-text-clock-outline' : 'mdi mdi-file-document-outline',
|
||||
kind: 'document',
|
||||
title: `${row.documentTypeLabel || '单据'} ${row.documentNo || row.claimId || '待生成'}`,
|
||||
description: resolveDocumentNotificationDescription(row),
|
||||
avatarLabel: resolveDocumentNotificationAvatarLabel(row),
|
||||
time: row.updatedAt || row.createdAt,
|
||||
timeLabel: formatNotificationTimeLabel(row.updatedAt || row.createdAt),
|
||||
category: row.sourceLabel || '单据中心',
|
||||
tone: resolveDocumentNotificationTone({ ...row, isUnread: unread }),
|
||||
unread,
|
||||
badge: unread ? '新' : '',
|
||||
target: {
|
||||
type: 'document',
|
||||
@@ -615,10 +638,10 @@ const workbenchNotificationItems = computed(() => (
|
||||
id,
|
||||
kind: 'workbench',
|
||||
category: item.category || '个人工作台',
|
||||
avatarLabel: resolveNotificationAvatarLabel(item),
|
||||
time: notificationTime,
|
||||
timeLabel: formatNotificationTimeLabel(item.time || item.updatedAt || item.due),
|
||||
unread: Boolean(item.unread) && !readNotificationIds.value.has(id),
|
||||
icon: item.icon || resolveNotificationIcon(item)
|
||||
unread: Boolean(item.unread) && !readNotificationIds.value.has(id)
|
||||
}
|
||||
}).filter(Boolean)
|
||||
: []
|
||||
@@ -631,6 +654,14 @@ const readNotifications = computed(() => notificationItems.value.filter((item) =
|
||||
const activeNotifications = computed(() => (
|
||||
notificationTab.value === 'unread' ? unreadNotifications.value : readNotifications.value
|
||||
))
|
||||
const notificationBulkActionLabel = computed(() => (
|
||||
notificationTab.value === 'unread' ? '全部已读' : '删除已读'
|
||||
))
|
||||
const notificationBulkActionDisabled = computed(() => (
|
||||
notificationTab.value === 'unread'
|
||||
? unreadNotifications.value.length === 0
|
||||
: readNotifications.value.length === 0
|
||||
))
|
||||
const topbarNotificationCount = computed(() => {
|
||||
const count = unreadNotifications.value.length
|
||||
return count > 0 ? Math.min(count, 99) : 0
|
||||
@@ -659,26 +690,6 @@ function scheduleDocumentInboxInitialRefresh() {
|
||||
}, props.activeView === 'workbench' ? 1200 : 6000)
|
||||
}
|
||||
|
||||
function resolveNotificationIcon(item) {
|
||||
if (item?.icon) {
|
||||
return item.icon
|
||||
}
|
||||
|
||||
if (item?.tone === 'danger') {
|
||||
return 'mdi mdi-alert-circle-outline'
|
||||
}
|
||||
|
||||
if (item?.tone === 'warning') {
|
||||
return 'mdi mdi-alert-outline'
|
||||
}
|
||||
|
||||
if (item?.tone === 'success') {
|
||||
return 'mdi mdi-check-circle-outline'
|
||||
}
|
||||
|
||||
return 'mdi mdi-bell-outline'
|
||||
}
|
||||
|
||||
function markNotificationRead(item) {
|
||||
if (!item?.id || !item.unread) {
|
||||
return
|
||||
@@ -691,8 +702,8 @@ function markNotificationRead(item) {
|
||||
void markNotificationStateRead(item)
|
||||
}
|
||||
|
||||
function clearAllNotifications() {
|
||||
const currentItems = notificationItems.value
|
||||
function markUnreadNotificationsRead() {
|
||||
const currentItems = unreadNotifications.value
|
||||
if (!currentItems.length) {
|
||||
return
|
||||
}
|
||||
@@ -705,8 +716,29 @@ function clearAllNotifications() {
|
||||
markDocumentInboxRowsRead(documentRows)
|
||||
}
|
||||
|
||||
void markNotificationStatesRead(currentItems)
|
||||
}
|
||||
|
||||
function deleteReadNotifications() {
|
||||
const currentItems = readNotifications.value
|
||||
if (!currentItems.length) {
|
||||
return
|
||||
}
|
||||
|
||||
void hideNotificationStates(currentItems)
|
||||
notificationTab.value = 'unread'
|
||||
}
|
||||
|
||||
function handleNotificationBulkAction() {
|
||||
if (notificationBulkActionDisabled.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (notificationTab.value === 'unread') {
|
||||
markUnreadNotificationsRead()
|
||||
return
|
||||
}
|
||||
|
||||
deleteReadNotifications()
|
||||
}
|
||||
|
||||
function openNotification(item) {
|
||||
|
||||
74
web/src/components/shared/AppModalLoadingState.vue
Normal file
74
web/src/components/shared/AppModalLoadingState.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="app-modal-loading-backdrop" role="status" aria-live="polite">
|
||||
<section class="app-modal-loading-panel" aria-label="正在打开智能工作台">
|
||||
<span class="app-modal-loading-spinner" aria-hidden="true"></span>
|
||||
<div>
|
||||
<strong>正在打开智能工作台</strong>
|
||||
<p>基础页面已就绪,正在载入助手模块。</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-modal-loading-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 3000;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: rgba(15, 23, 42, 0.36);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.app-modal-loading-panel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
width: min(420px, 100%);
|
||||
padding: 20px 22px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.68);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.18);
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.app-modal-loading-panel strong {
|
||||
display: block;
|
||||
font-size: 15px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.app-modal-loading-panel p {
|
||||
margin: 4px 0 0;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.app-modal-loading-spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex: 0 0 auto;
|
||||
border: 2px solid rgba(59, 130, 246, 0.18);
|
||||
border-top-color: #2563eb;
|
||||
border-radius: 999px;
|
||||
animation: app-modal-loading-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes app-modal-loading-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.app-modal-loading-spinner {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
104
web/src/components/shared/AppViewLoadingState.vue
Normal file
104
web/src/components/shared/AppViewLoadingState.vue
Normal file
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<section class="app-view-loading-state" aria-live="polite">
|
||||
<div class="app-view-loading-copy">
|
||||
<span class="app-view-loading-spinner" aria-hidden="true"></span>
|
||||
<div>
|
||||
<strong>正在加载页面内容</strong>
|
||||
<p>页面框架已就绪,正在载入当前模块的数据和控件。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="app-view-loading-skeleton" aria-hidden="true">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-view-loading-state {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
width: min(760px, 100%);
|
||||
margin: 24px auto;
|
||||
padding: 24px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.26);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
box-shadow: 0 14px 36px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.app-view-loading-copy {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.app-view-loading-copy strong {
|
||||
display: block;
|
||||
font-size: 15px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.app-view-loading-copy p {
|
||||
margin: 4px 0 0;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.app-view-loading-spinner {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
flex: 0 0 auto;
|
||||
border: 2px solid rgba(59, 130, 246, 0.18);
|
||||
border-top-color: #2563eb;
|
||||
border-radius: 999px;
|
||||
animation: app-view-loading-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.app-view-loading-skeleton {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.app-view-loading-skeleton span {
|
||||
display: block;
|
||||
height: 12px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, #e5e7eb 0%, #f8fafc 48%, #e5e7eb 100%);
|
||||
background-size: 200% 100%;
|
||||
animation: app-view-loading-shimmer 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.app-view-loading-skeleton span:nth-child(2) {
|
||||
width: 86%;
|
||||
}
|
||||
|
||||
.app-view-loading-skeleton span:nth-child(3) {
|
||||
width: 68%;
|
||||
}
|
||||
|
||||
@keyframes app-view-loading-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes app-view-loading-shimmer {
|
||||
0% {
|
||||
background-position: 100% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -100% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.app-view-loading-spinner,
|
||||
.app-view-loading-skeleton span {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,11 @@
|
||||
import { isApplicationDocumentNo } from '../../utils/documentClassification.js'
|
||||
import { filterActionableRiskFlags, normalizeRiskFlagTone } from '../../utils/riskFlags.js'
|
||||
import {
|
||||
DOCUMENT_TYPE_APPLICATION,
|
||||
DOCUMENT_TYPE_EXPENSE_APPLICATION,
|
||||
DOCUMENT_TYPE_REIMBURSEMENT,
|
||||
resolveDocumentTypeLabel
|
||||
} from '../../constants/documentProtocol.js'
|
||||
|
||||
const EXPENSE_TYPE_LABELS = {
|
||||
travel: '差旅费',
|
||||
@@ -49,8 +55,6 @@ const DOCUMENT_BACKED_EXPENSE_TYPES = new Set([
|
||||
'hotel_ticket',
|
||||
'ride_ticket'
|
||||
])
|
||||
const DOCUMENT_TYPE_APPLICATION = 'application'
|
||||
const DOCUMENT_TYPE_REIMBURSEMENT = 'reimbursement'
|
||||
const RELATED_APPLICATION_STEP_LABEL = '关联单据'
|
||||
const APPLICATION_LINK_STATUS_STEP_LABEL = '关联单据状态'
|
||||
const APPLICATION_ARCHIVE_STAGE_LABEL = '申请归档'
|
||||
@@ -179,14 +183,14 @@ function resolveDocumentTypeMeta(claim, typeCode) {
|
||||
const normalizedType = String(typeCode || '').trim()
|
||||
const isApplication =
|
||||
explicitType === DOCUMENT_TYPE_APPLICATION
|
||||
|| explicitType === 'expense_application'
|
||||
|| explicitType === DOCUMENT_TYPE_EXPENSE_APPLICATION
|
||||
|| isApplicationDocumentNo(claimNo)
|
||||
|| normalizedType === 'application'
|
||||
|| normalizedType.endsWith('_application')
|
||||
|
||||
return isApplication
|
||||
? { documentTypeCode: DOCUMENT_TYPE_APPLICATION, documentTypeLabel: '申请单' }
|
||||
: { documentTypeCode: DOCUMENT_TYPE_REIMBURSEMENT, documentTypeLabel: '报销单' }
|
||||
? { documentTypeCode: DOCUMENT_TYPE_APPLICATION, documentTypeLabel: resolveDocumentTypeLabel(DOCUMENT_TYPE_APPLICATION) }
|
||||
: { documentTypeCode: DOCUMENT_TYPE_REIMBURSEMENT, documentTypeLabel: resolveDocumentTypeLabel(DOCUMENT_TYPE_REIMBURSEMENT) }
|
||||
}
|
||||
|
||||
function normalizeExpenseType(typeCode) {
|
||||
|
||||
@@ -139,7 +139,14 @@ export function useTopBarNotificationStates() {
|
||||
}
|
||||
|
||||
function markNotificationStateRead(item) {
|
||||
return syncNotificationPatches([buildPatch(item, { read: true })])
|
||||
return markNotificationStatesRead([item])
|
||||
}
|
||||
|
||||
function markNotificationStatesRead(items) {
|
||||
const patches = (Array.isArray(items) ? items : [])
|
||||
.map((item) => buildPatch(item, { read: true, hidden: false }))
|
||||
.filter(Boolean)
|
||||
return syncNotificationPatches(patches)
|
||||
}
|
||||
|
||||
function hideNotificationStates(items) {
|
||||
@@ -158,6 +165,7 @@ export function useTopBarNotificationStates() {
|
||||
isNotificationHidden,
|
||||
isNotificationRead,
|
||||
loadNotificationStates,
|
||||
markNotificationStateRead
|
||||
markNotificationStateRead,
|
||||
markNotificationStatesRead
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,8 +24,7 @@ import {
|
||||
} from './workbenchAiComposerModel.js'
|
||||
import {
|
||||
createWorkbenchAiMessageRuntime,
|
||||
formatMessageTime,
|
||||
normalizeInlineAttachmentOcrDetails
|
||||
formatMessageTime
|
||||
} from './workbenchAiMessageModel.js'
|
||||
import { useWorkbenchAiActionRouter } from './useWorkbenchAiActionRouter.js'
|
||||
import { useWorkbenchAiAttachmentAssociationFlow } from './useWorkbenchAiAttachmentAssociationFlow.js'
|
||||
@@ -34,8 +33,10 @@ import { useWorkbenchAiComposerFiles } from './useWorkbenchAiComposerFiles.js'
|
||||
import { useWorkbenchAiDocumentQueryFlow } from './useWorkbenchAiDocumentQueryFlow.js'
|
||||
import { useWorkbenchAiExpenseFlow } from './useWorkbenchAiExpenseFlow.js'
|
||||
import { useWorkbenchAiMessageActions } from './useWorkbenchAiMessageActions.js'
|
||||
import { useWorkbenchAiMessageExpansion } from './useWorkbenchAiMessageExpansion.js'
|
||||
import { useWorkbenchAiSessionCommands } from './useWorkbenchAiSessionCommands.js'
|
||||
import { useWorkbenchAiStewardFlow } from './useWorkbenchAiStewardFlow.js'
|
||||
import { isReimbursementCreationIntent } from './workbenchAiApplicationGateModel.js'
|
||||
|
||||
const AI_SEARCH_CONVERSATION_ID = 'ai-search'
|
||||
const INLINE_ANSWER_STREAM_CHUNK_SIZE = 6
|
||||
@@ -174,6 +175,23 @@ export function usePersonalWorkbenchAiMode(props, emit) {
|
||||
...card,
|
||||
ocrState: attachmentFlow.resolveAiModeReceiptRecognitionState(selectedFiles.value[index])
|
||||
})))
|
||||
const {
|
||||
hasInlineAttachmentOcrDetails,
|
||||
hasInlineThinking,
|
||||
isInlineAttachmentOcrExpanded,
|
||||
isInlineThinkingExpanded,
|
||||
resolveInlineAttachmentOcrDocuments,
|
||||
resolveInlineAttachmentOcrFileCount,
|
||||
resolveInlineThinkingEvents,
|
||||
toggleInlineAttachmentOcrDetails,
|
||||
toggleInlineThinking
|
||||
} = useWorkbenchAiMessageExpansion({
|
||||
attachmentOcrExpandedMessageIds,
|
||||
inlineConversationAutoScrollPinned,
|
||||
scrollInlineConversationToBottom,
|
||||
thinkingCollapsedMessageIds,
|
||||
thinkingExpandedMessageIds
|
||||
})
|
||||
|
||||
const applicationFlow = useWorkbenchAiApplicationPreviewFlow({
|
||||
activateInlineConversation,
|
||||
@@ -324,6 +342,10 @@ export function usePersonalWorkbenchAiMode(props, emit) {
|
||||
})
|
||||
}
|
||||
|
||||
function setAssistantInputRef(element) {
|
||||
assistantInputRef.value = element
|
||||
}
|
||||
|
||||
function isInlineConversationNearBottom() {
|
||||
const el = conversationScrollRef.value
|
||||
if (!el) {
|
||||
@@ -499,78 +521,6 @@ export function usePersonalWorkbenchAiMode(props, emit) {
|
||||
return renderAiConversationHtml(content)
|
||||
}
|
||||
|
||||
function resolveInlineThinkingEvents(message) {
|
||||
return Array.isArray(message?.stewardPlan?.thinkingEvents) ? message.stewardPlan.thinkingEvents : []
|
||||
}
|
||||
|
||||
function hasInlineThinking(message) {
|
||||
return resolveInlineThinkingEvents(message).length > 0
|
||||
}
|
||||
|
||||
function isInlineThinkingExpanded(message) {
|
||||
if (!message?.id) {
|
||||
return Boolean(message?.pending)
|
||||
}
|
||||
if (thinkingCollapsedMessageIds.value.has(message.id)) {
|
||||
return false
|
||||
}
|
||||
return Boolean(message.pending || thinkingExpandedMessageIds.value.has(message.id))
|
||||
}
|
||||
|
||||
function toggleInlineThinking(message) {
|
||||
if (!message?.id) {
|
||||
return
|
||||
}
|
||||
const nextExpandedIds = new Set(thinkingExpandedMessageIds.value)
|
||||
const nextCollapsedIds = new Set(thinkingCollapsedMessageIds.value)
|
||||
if (isInlineThinkingExpanded(message)) {
|
||||
nextExpandedIds.delete(message.id)
|
||||
nextCollapsedIds.add(message.id)
|
||||
} else {
|
||||
nextCollapsedIds.delete(message.id)
|
||||
nextExpandedIds.add(message.id)
|
||||
}
|
||||
thinkingExpandedMessageIds.value = nextExpandedIds
|
||||
thinkingCollapsedMessageIds.value = nextCollapsedIds
|
||||
}
|
||||
|
||||
function hasInlineAttachmentOcrDetails(message = {}) {
|
||||
const details = normalizeInlineAttachmentOcrDetails(message?.attachmentOcrDetails || null)
|
||||
return Boolean(details?.documents?.length || details?.fileNames?.length)
|
||||
}
|
||||
|
||||
function resolveInlineAttachmentOcrDocuments(message = {}) {
|
||||
return normalizeInlineAttachmentOcrDetails(message?.attachmentOcrDetails || null)?.documents || []
|
||||
}
|
||||
|
||||
function resolveInlineAttachmentOcrFileCount(message = {}) {
|
||||
const details = normalizeInlineAttachmentOcrDetails(message?.attachmentOcrDetails || null)
|
||||
return Math.max(details?.documents?.length || 0, details?.fileNames?.length || 0)
|
||||
}
|
||||
|
||||
function isInlineAttachmentOcrExpanded(message = {}) {
|
||||
return Boolean(message?.id && attachmentOcrExpandedMessageIds.value.has(message.id))
|
||||
}
|
||||
|
||||
function toggleInlineAttachmentOcrDetails(message = {}, forceExpanded = null) {
|
||||
if (!message?.id || !hasInlineAttachmentOcrDetails(message)) {
|
||||
return
|
||||
}
|
||||
const nextExpandedIds = new Set(attachmentOcrExpandedMessageIds.value)
|
||||
const shouldExpand = forceExpanded === null
|
||||
? !nextExpandedIds.has(message.id)
|
||||
: Boolean(forceExpanded)
|
||||
if (shouldExpand) {
|
||||
nextExpandedIds.add(message.id)
|
||||
} else {
|
||||
nextExpandedIds.delete(message.id)
|
||||
}
|
||||
attachmentOcrExpandedMessageIds.value = nextExpandedIds
|
||||
nextTick(() => {
|
||||
scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
|
||||
})
|
||||
}
|
||||
|
||||
function buildInlinePromptText(rawPrompt, files = []) {
|
||||
const prompt = buildWorkbenchPromptText(rawPrompt)
|
||||
if (prompt) {
|
||||
@@ -579,20 +529,6 @@ export function usePersonalWorkbenchAiMode(props, emit) {
|
||||
return files.length ? '请帮我处理已上传的附件。' : ''
|
||||
}
|
||||
|
||||
function isReimbursementCreationIntent(prompt = '') {
|
||||
const compact = String(prompt || '').replace(/\s+/g, '')
|
||||
if (!compact || !/报销|报账/.test(compact)) {
|
||||
return false
|
||||
}
|
||||
if (/标准|制度|规则|政策|口径|怎么|如何|能不能|可以吗|查一下|查询|状态|进度|多少钱|多少/.test(compact)) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
/^(我要|我想|我需要|帮我|请帮我|需要|发起|新建|创建|提交|办理|走).{0,16}(报销|报账)/.test(compact) ||
|
||||
/^(报销|报账)(一下|一笔|单|流程)?$/.test(compact)
|
||||
)
|
||||
}
|
||||
|
||||
function handleAiAnswerMarkdownClick(event) {
|
||||
const target = event?.target
|
||||
const link = target?.closest?.('a[href^="#ai-open-document-detail:"], a[href^="#ai-open-application-detail:"]')
|
||||
@@ -800,6 +736,7 @@ export function usePersonalWorkbenchAiMode(props, emit) {
|
||||
scrollInlineConversationToTop,
|
||||
selectedFileCards,
|
||||
sending,
|
||||
setAssistantInputRef,
|
||||
setWorkbenchDateMode,
|
||||
submitAiModePrompt,
|
||||
toggleInlineAttachmentOcrDetails,
|
||||
|
||||
@@ -24,10 +24,15 @@ import {
|
||||
buildInlineApplicationSubmitPrecheckPayload,
|
||||
buildInlineApplicationSubmitThinkingEvents,
|
||||
completeInlineThinkingEvents,
|
||||
extractInlineApplicationDraftPayload,
|
||||
resolveInlineApplicationPreviewActionFromText
|
||||
extractInlineApplicationDraftPayload
|
||||
} from './workbenchAiApplicationPreviewModel.js'
|
||||
import { AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION } from './workbenchAiMessageModel.js'
|
||||
import {
|
||||
isOrphanInlineApplicationPreviewMessage,
|
||||
resolveInlineApplicationPreviewTextAction,
|
||||
resolveLatestApplicationPreviewMessage,
|
||||
resolveLatestOrphanApplicationPreviewMessage
|
||||
} from './workbenchAiApplicationGateModel.js'
|
||||
|
||||
function isApplicationPreviewEstimatePendingPreview(applicationPreview = {}) {
|
||||
const fields = normalizeApplicationPreview(applicationPreview).fields || {}
|
||||
@@ -197,23 +202,12 @@ export function useWorkbenchAiApplicationPreviewFlow({
|
||||
].join('\n\n')
|
||||
}
|
||||
|
||||
function resolveLatestApplicationPreviewMessage() {
|
||||
return [...conversationMessages.value]
|
||||
.reverse()
|
||||
.find((message) => message.role === 'assistant' && message.applicationPreview)
|
||||
function resolveLatestInlineApplicationPreviewMessage() {
|
||||
return resolveLatestApplicationPreviewMessage(conversationMessages.value)
|
||||
}
|
||||
|
||||
function isOrphanInlineApplicationPreviewMessage(message = {}) {
|
||||
if (message?.applicationPreview || message?.role !== 'assistant') {
|
||||
return false
|
||||
}
|
||||
return /下方表格|申请核对表|点击对应行即可直接编辑/.test(String(message.content || message.text || ''))
|
||||
}
|
||||
|
||||
function resolveLatestOrphanApplicationPreviewMessage() {
|
||||
return [...conversationMessages.value]
|
||||
.reverse()
|
||||
.find((message) => isOrphanInlineApplicationPreviewMessage(message))
|
||||
function resolveLatestOrphanInlineApplicationPreviewMessage() {
|
||||
return resolveLatestOrphanApplicationPreviewMessage(conversationMessages.value)
|
||||
}
|
||||
|
||||
function requestInlineApplicationSubmitConfirmation(targetMessage, options = {}) {
|
||||
@@ -310,7 +304,7 @@ export function useWorkbenchAiApplicationPreviewFlow({
|
||||
}
|
||||
|
||||
async function executeInlineApplicationPreviewAction(actionType, sourceMessage = null, options = {}) {
|
||||
const targetMessage = sourceMessage?.applicationPreview ? sourceMessage : resolveLatestApplicationPreviewMessage()
|
||||
const targetMessage = sourceMessage?.applicationPreview ? sourceMessage : resolveLatestInlineApplicationPreviewMessage()
|
||||
if (!targetMessage?.applicationPreview) {
|
||||
toast('当前没有可提交的申请表。')
|
||||
return false
|
||||
@@ -446,12 +440,12 @@ export function useWorkbenchAiApplicationPreviewFlow({
|
||||
toast('请等待费用测算完成后再继续操作。')
|
||||
return true
|
||||
}
|
||||
const actionType = resolveInlineApplicationPreviewActionFromText(prompt)
|
||||
const actionType = resolveInlineApplicationPreviewTextAction(prompt)
|
||||
if (!actionType) {
|
||||
return false
|
||||
}
|
||||
if (!resolveLatestApplicationPreviewMessage()) {
|
||||
const orphanPreviewMessage = resolveLatestOrphanApplicationPreviewMessage()
|
||||
if (!resolveLatestInlineApplicationPreviewMessage()) {
|
||||
const orphanPreviewMessage = resolveLatestOrphanInlineApplicationPreviewMessage()
|
||||
if (!orphanPreviewMessage) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import { normalizeInlineAttachmentOcrDetails } from './workbenchAiMessageModel.js'
|
||||
|
||||
export function useWorkbenchAiMessageExpansion({
|
||||
attachmentOcrExpandedMessageIds,
|
||||
inlineConversationAutoScrollPinned,
|
||||
scrollInlineConversationToBottom,
|
||||
thinkingCollapsedMessageIds,
|
||||
thinkingExpandedMessageIds
|
||||
}) {
|
||||
function resolveInlineThinkingEvents(message) {
|
||||
return Array.isArray(message?.stewardPlan?.thinkingEvents) ? message.stewardPlan.thinkingEvents : []
|
||||
}
|
||||
|
||||
function hasInlineThinking(message) {
|
||||
return resolveInlineThinkingEvents(message).length > 0
|
||||
}
|
||||
|
||||
function isInlineThinkingExpanded(message) {
|
||||
if (!message?.id) {
|
||||
return Boolean(message?.pending)
|
||||
}
|
||||
if (thinkingCollapsedMessageIds.value.has(message.id)) {
|
||||
return false
|
||||
}
|
||||
return Boolean(message.pending || thinkingExpandedMessageIds.value.has(message.id))
|
||||
}
|
||||
|
||||
function toggleInlineThinking(message) {
|
||||
if (!message?.id) {
|
||||
return
|
||||
}
|
||||
const nextExpandedIds = new Set(thinkingExpandedMessageIds.value)
|
||||
const nextCollapsedIds = new Set(thinkingCollapsedMessageIds.value)
|
||||
if (isInlineThinkingExpanded(message)) {
|
||||
nextExpandedIds.delete(message.id)
|
||||
nextCollapsedIds.add(message.id)
|
||||
} else {
|
||||
nextCollapsedIds.delete(message.id)
|
||||
nextExpandedIds.add(message.id)
|
||||
}
|
||||
thinkingExpandedMessageIds.value = nextExpandedIds
|
||||
thinkingCollapsedMessageIds.value = nextCollapsedIds
|
||||
}
|
||||
|
||||
function hasInlineAttachmentOcrDetails(message = {}) {
|
||||
const details = normalizeInlineAttachmentOcrDetails(message?.attachmentOcrDetails || null)
|
||||
return Boolean(details?.documents?.length || details?.fileNames?.length)
|
||||
}
|
||||
|
||||
function resolveInlineAttachmentOcrDocuments(message = {}) {
|
||||
return normalizeInlineAttachmentOcrDetails(message?.attachmentOcrDetails || null)?.documents || []
|
||||
}
|
||||
|
||||
function resolveInlineAttachmentOcrFileCount(message = {}) {
|
||||
const details = normalizeInlineAttachmentOcrDetails(message?.attachmentOcrDetails || null)
|
||||
return Math.max(details?.documents?.length || 0, details?.fileNames?.length || 0)
|
||||
}
|
||||
|
||||
function isInlineAttachmentOcrExpanded(message = {}) {
|
||||
return Boolean(message?.id && attachmentOcrExpandedMessageIds.value.has(message.id))
|
||||
}
|
||||
|
||||
function toggleInlineAttachmentOcrDetails(message = {}, forceExpanded = null) {
|
||||
if (!message?.id || !hasInlineAttachmentOcrDetails(message)) {
|
||||
return
|
||||
}
|
||||
const nextExpandedIds = new Set(attachmentOcrExpandedMessageIds.value)
|
||||
const shouldExpand = forceExpanded === null
|
||||
? !nextExpandedIds.has(message.id)
|
||||
: Boolean(forceExpanded)
|
||||
if (shouldExpand) {
|
||||
nextExpandedIds.add(message.id)
|
||||
} else {
|
||||
nextExpandedIds.delete(message.id)
|
||||
}
|
||||
attachmentOcrExpandedMessageIds.value = nextExpandedIds
|
||||
nextTick(() => {
|
||||
scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
hasInlineAttachmentOcrDetails,
|
||||
hasInlineThinking,
|
||||
isInlineAttachmentOcrExpanded,
|
||||
isInlineThinkingExpanded,
|
||||
resolveInlineAttachmentOcrDocuments,
|
||||
resolveInlineAttachmentOcrFileCount,
|
||||
resolveInlineThinkingEvents,
|
||||
toggleInlineAttachmentOcrDetails,
|
||||
toggleInlineThinking
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
AI_APPLICATION_ACTION_SAVE_DRAFT,
|
||||
AI_APPLICATION_ACTION_SUBMIT
|
||||
} from '../../services/aiApplicationPreviewActions.js'
|
||||
|
||||
export function isReimbursementCreationIntent(prompt = '') {
|
||||
const compact = String(prompt || '').replace(/\s+/g, '')
|
||||
if (!compact || !/报销|报账/.test(compact)) {
|
||||
return false
|
||||
}
|
||||
if (/标准|制度|规则|政策|口径|怎么|如何|能不能|可以吗|查一下|查询|状态|进度|多少钱|多少/.test(compact)) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
/^(我要|我想|我需要|帮我|请帮我|需要|发起|新建|创建|提交|办理|走).{0,16}(报销|报账)/.test(compact) ||
|
||||
/^(报销|报账)(一下|一笔|单|流程)?$/.test(compact)
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveInlineApplicationPreviewTextAction(text = '') {
|
||||
const normalized = String(text || '').replace(/\s+/g, '').trim()
|
||||
if (!normalized) {
|
||||
return ''
|
||||
}
|
||||
if (/^(保存草稿|保存|存草稿|先保存)$/.test(normalized)) {
|
||||
return AI_APPLICATION_ACTION_SAVE_DRAFT
|
||||
}
|
||||
if (/^(提交|提交申请|确认提交|提交审批|直接提交)$/.test(normalized)) {
|
||||
return AI_APPLICATION_ACTION_SUBMIT
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export function resolveLatestApplicationPreviewMessage(messages = []) {
|
||||
return [...(Array.isArray(messages) ? messages : [])]
|
||||
.reverse()
|
||||
.find((message) => message?.role === 'assistant' && message.applicationPreview) || null
|
||||
}
|
||||
|
||||
export function isOrphanInlineApplicationPreviewMessage(message = {}) {
|
||||
if (message?.applicationPreview || message?.role !== 'assistant') {
|
||||
return false
|
||||
}
|
||||
return /下方表格|申请核对表|点击对应行即可直接编辑/.test(String(message.content || message.text || ''))
|
||||
}
|
||||
|
||||
export function resolveLatestOrphanApplicationPreviewMessage(messages = []) {
|
||||
return [...(Array.isArray(messages) ? messages : [])]
|
||||
.reverse()
|
||||
.find((message) => isOrphanInlineApplicationPreviewMessage(message)) || null
|
||||
}
|
||||
@@ -13,19 +13,8 @@ import {
|
||||
AI_APPLICATION_ACTION_SAVE_DRAFT,
|
||||
AI_APPLICATION_ACTION_SUBMIT
|
||||
} from '../../services/aiApplicationPreviewActions.js'
|
||||
|
||||
const INLINE_APPLICATION_STATUS_LABELS = {
|
||||
draft: '草稿',
|
||||
submitted: '审批中',
|
||||
pending: '待处理',
|
||||
approved: '已审批',
|
||||
completed: '已完成',
|
||||
archived: '已归档',
|
||||
returned: '已退回',
|
||||
rejected: '已驳回',
|
||||
pending_payment: '待付款',
|
||||
paid: '已付款'
|
||||
}
|
||||
import { INLINE_APPLICATION_STATUS_LABELS } from '../../constants/documentProtocol.js'
|
||||
import { resolveInlineApplicationPreviewTextAction } from './workbenchAiApplicationGateModel.js'
|
||||
|
||||
function normalizeInlineApplicationResultTableCell(value, fallback = '-') {
|
||||
const text = String(value || '')
|
||||
@@ -201,17 +190,7 @@ export function buildInlineApplicationDetailAction(draftPayload = {}) {
|
||||
}
|
||||
|
||||
export function resolveInlineApplicationPreviewActionFromText(text = '') {
|
||||
const normalized = String(text || '').replace(/\s+/g, '').trim()
|
||||
if (!normalized) {
|
||||
return ''
|
||||
}
|
||||
if (/^(保存草稿|保存|存草稿|先保存)$/.test(normalized)) {
|
||||
return AI_APPLICATION_ACTION_SAVE_DRAFT
|
||||
}
|
||||
if (/^(提交|提交申请|确认提交|提交审批|直接提交)$/.test(normalized)) {
|
||||
return AI_APPLICATION_ACTION_SUBMIT
|
||||
}
|
||||
return ''
|
||||
return resolveInlineApplicationPreviewTextAction(text)
|
||||
}
|
||||
|
||||
export function normalizeInlineApplicationTypeLabel(expenseTypeLabel, fallback = '费用申请') {
|
||||
|
||||
28
web/src/constants/documentProtocol.js
Normal file
28
web/src/constants/documentProtocol.js
Normal file
@@ -0,0 +1,28 @@
|
||||
export const DOCUMENT_TYPE_ALL = 'all'
|
||||
export const DOCUMENT_TYPE_APPLICATION = 'application'
|
||||
export const DOCUMENT_TYPE_REIMBURSEMENT = 'reimbursement'
|
||||
export const DOCUMENT_TYPE_EXPENSE_APPLICATION = 'expense_application'
|
||||
|
||||
export const DOCUMENT_TYPE_LABELS = {
|
||||
[DOCUMENT_TYPE_APPLICATION]: '申请单',
|
||||
[DOCUMENT_TYPE_EXPENSE_APPLICATION]: '申请单',
|
||||
[DOCUMENT_TYPE_REIMBURSEMENT]: '报销单'
|
||||
}
|
||||
|
||||
export const INLINE_APPLICATION_STATUS_LABELS = {
|
||||
draft: '草稿',
|
||||
submitted: '审批中',
|
||||
pending: '待处理',
|
||||
approved: '已审批',
|
||||
completed: '已完成',
|
||||
archived: '已归档',
|
||||
returned: '已退回',
|
||||
rejected: '已驳回',
|
||||
pending_payment: '待付款',
|
||||
paid: '已付款'
|
||||
}
|
||||
|
||||
export function resolveDocumentTypeLabel(typeCode, fallback = '报销单') {
|
||||
const normalized = String(typeCode || '').trim().toLowerCase()
|
||||
return DOCUMENT_TYPE_LABELS[normalized] || fallback
|
||||
}
|
||||
@@ -1,49 +1,14 @@
|
||||
import { renderLegacyAttachmentAssociationHtml } from './aiConversationLegacyAttachmentRenderer.js'
|
||||
import { parseTableRow, renderTable } from './aiConversationTableRenderer.js'
|
||||
|
||||
const ALLOWED_COLON_HEADING_TITLES = new Set([
|
||||
'基础信息识别结果',
|
||||
'报销测算参考',
|
||||
'补充信息'
|
||||
])
|
||||
|
||||
const BUSINESS_FIELD_LABELS = new Set([
|
||||
'时间',
|
||||
'地点',
|
||||
'事由',
|
||||
'金额',
|
||||
'费用类型',
|
||||
'报销类型',
|
||||
'商户',
|
||||
'商户/开票方',
|
||||
'客户',
|
||||
'客户/项目对象',
|
||||
'附件',
|
||||
'附件/凭证',
|
||||
'出行方式'
|
||||
])
|
||||
import {
|
||||
DOCUMENT_DETAIL_HREF_PREFIX,
|
||||
extractTrustedHtmlBlocks,
|
||||
normalizeConversationText,
|
||||
restoreTrustedHtmlBlocks
|
||||
} from './conversationTrustedHtml.js'
|
||||
|
||||
const APPLICATION_DETAIL_HREF_PREFIX = '#ai-open-application-detail:'
|
||||
const DELETED_APPLICATION_DETAIL_HREF_PREFIX = '#ai-deleted-application-detail:'
|
||||
const DOCUMENT_DETAIL_HREF_PREFIX = '#ai-open-document-detail:'
|
||||
const TRUSTED_HTML_BLOCK_RE = /<!--\s*ai-trusted-html:start\s*-->\s*([\s\S]*?)\s*<!--\s*ai-trusted-html:end\s*-->/g
|
||||
const TRUSTED_HTML_PLACEHOLDER_PREFIX = 'AI_TRUSTED_HTML_BLOCK_'
|
||||
const TRUSTED_HTML_ALLOWED_TAGS = new Set([
|
||||
'section',
|
||||
'article',
|
||||
'header',
|
||||
'footer',
|
||||
'div',
|
||||
'span',
|
||||
'strong',
|
||||
'a'
|
||||
])
|
||||
const TRUSTED_HTML_ALLOWED_ATTRS = new Set([
|
||||
'aria-label',
|
||||
'class',
|
||||
'data-ai-action',
|
||||
'href'
|
||||
])
|
||||
|
||||
function escapeHtml(value = '') {
|
||||
return String(value)
|
||||
@@ -146,150 +111,6 @@ function renderInlineHtml(value = '') {
|
||||
return html
|
||||
}
|
||||
|
||||
function splitColonHeadingLine(line) {
|
||||
const rawLine = String(line || '')
|
||||
const trimmed = rawLine.trim()
|
||||
if (!trimmed || trimmed.startsWith('|') || /^#{1,6}\s/.test(trimmed)) {
|
||||
return [rawLine]
|
||||
}
|
||||
|
||||
const chineseColonIndex = trimmed.indexOf(':')
|
||||
const asciiColonIndex = trimmed.indexOf(':')
|
||||
const colonIndexes = [chineseColonIndex, asciiColonIndex].filter((index) => index > 0)
|
||||
if (!colonIndexes.length) {
|
||||
return [rawLine]
|
||||
}
|
||||
|
||||
const colonIndex = Math.min(...colonIndexes)
|
||||
const title = trimmed.slice(0, colonIndex)
|
||||
const body = trimmed.slice(colonIndex + 1).trim()
|
||||
if (!ALLOWED_COLON_HEADING_TITLES.has(title)) {
|
||||
return [rawLine]
|
||||
}
|
||||
return body ? [`### ${title}`, '', body] : [`### ${title}`]
|
||||
}
|
||||
|
||||
function normalizeBusinessFieldLine(line) {
|
||||
const rawLine = String(line || '')
|
||||
const trimmed = rawLine.trim()
|
||||
if (
|
||||
!trimmed ||
|
||||
trimmed.startsWith('|') ||
|
||||
/^[-*+]\s/.test(trimmed) ||
|
||||
/^#{1,6}\s/.test(trimmed)
|
||||
) {
|
||||
return rawLine
|
||||
}
|
||||
|
||||
const match = trimmed.match(/^([^::\n]{1,16})[::]\s*(.+)$/u)
|
||||
if (!match) {
|
||||
return rawLine
|
||||
}
|
||||
const label = match[1].trim()
|
||||
const value = match[2].trim()
|
||||
if (!BUSINESS_FIELD_LABELS.has(label) || !value) {
|
||||
return rawLine
|
||||
}
|
||||
return `- **${label}**:${value}`
|
||||
}
|
||||
|
||||
function normalizeConversationText(text = '') {
|
||||
const lines = String(text || '').replace(/\r\n?/g, '\n').split('\n')
|
||||
const normalizedLines = []
|
||||
let inFence = false
|
||||
|
||||
lines.forEach((line) => {
|
||||
if (/^\s*(```|~~~)/.test(line)) {
|
||||
inFence = !inFence
|
||||
normalizedLines.push(line)
|
||||
return
|
||||
}
|
||||
if (inFence) {
|
||||
normalizedLines.push(line)
|
||||
return
|
||||
}
|
||||
|
||||
const nextLines = splitColonHeadingLine(line)
|
||||
if (nextLines[0]?.startsWith('### ') && normalizedLines.length) {
|
||||
const previousLine = normalizedLines[normalizedLines.length - 1]
|
||||
if (String(previousLine || '').trim()) {
|
||||
normalizedLines.push('')
|
||||
}
|
||||
}
|
||||
normalizedLines.push(...nextLines.map((nextLine) => normalizeBusinessFieldLine(nextLine)))
|
||||
})
|
||||
|
||||
return normalizedLines.join('\n').replace(/\n{3,}/g, '\n\n').trim()
|
||||
}
|
||||
|
||||
function hasOnlyTrustedHtmlTags(html = '') {
|
||||
const tagPattern = /<\/?([a-z][\w-]*)([^>]*)>/gi
|
||||
let match = tagPattern.exec(html)
|
||||
while (match) {
|
||||
const tagName = String(match[1] || '').toLowerCase()
|
||||
if (!TRUSTED_HTML_ALLOWED_TAGS.has(tagName)) {
|
||||
return false
|
||||
}
|
||||
const attrText = String(match[2] || '')
|
||||
const attrPattern = /\s([:@\w-]+)\s*=/g
|
||||
let attrMatch = attrPattern.exec(attrText)
|
||||
while (attrMatch) {
|
||||
const attrName = String(attrMatch[1] || '').toLowerCase()
|
||||
if (!TRUSTED_HTML_ALLOWED_ATTRS.has(attrName)) {
|
||||
return false
|
||||
}
|
||||
attrMatch = attrPattern.exec(attrText)
|
||||
}
|
||||
match = tagPattern.exec(html)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function sanitizeTrustedHtmlBlock(html = '') {
|
||||
const value = String(html || '').trim()
|
||||
if (!value || !value.includes('class="ai-document-card-list"')) {
|
||||
return ''
|
||||
}
|
||||
if (/<(?:script|style|iframe|object|embed|link|meta|form|input|button|textarea|select)\b/i.test(value)) {
|
||||
return ''
|
||||
}
|
||||
if (/\son[a-z]+\s*=/i.test(value) || /javascript\s*:/i.test(value)) {
|
||||
return ''
|
||||
}
|
||||
if (!hasOnlyTrustedHtmlTags(value)) {
|
||||
return ''
|
||||
}
|
||||
const hrefs = [...value.matchAll(/\shref="([^"]*)"/gi)].map((match) => String(match[1] || '').trim())
|
||||
if (hrefs.some((href) => !href.startsWith(DOCUMENT_DETAIL_HREF_PREFIX))) {
|
||||
return ''
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function extractTrustedHtmlBlocks(text = '') {
|
||||
const trustedHtmlBlocks = []
|
||||
const content = String(text || '').replace(TRUSTED_HTML_BLOCK_RE, (_match, html) => {
|
||||
const sanitizedHtml = sanitizeTrustedHtmlBlock(html)
|
||||
if (!sanitizedHtml) {
|
||||
return ''
|
||||
}
|
||||
const placeholder = `${TRUSTED_HTML_PLACEHOLDER_PREFIX}${trustedHtmlBlocks.length}`
|
||||
trustedHtmlBlocks.push(sanitizedHtml)
|
||||
return `\n\n${placeholder}\n\n`
|
||||
})
|
||||
return { content, trustedHtmlBlocks }
|
||||
}
|
||||
|
||||
function restoreTrustedHtmlBlocks(html = '', trustedHtmlBlocks = []) {
|
||||
return trustedHtmlBlocks.reduce((nextHtml, block, index) => {
|
||||
const placeholder = `${TRUSTED_HTML_PLACEHOLDER_PREFIX}${index}`
|
||||
const paragraphPattern = new RegExp(`<p class="ai-html-paragraph">${placeholder}</p>`, 'g')
|
||||
return nextHtml
|
||||
.replace(paragraphPattern, block)
|
||||
.replaceAll(placeholder, block)
|
||||
}, html)
|
||||
}
|
||||
|
||||
function isFenceLine(line = '') {
|
||||
return /^\s*(```|~~~)/.test(String(line || ''))
|
||||
}
|
||||
@@ -501,7 +322,7 @@ export function renderAiConversationHtml(content = '') {
|
||||
}
|
||||
|
||||
const extracted = extractTrustedHtmlBlocks(content)
|
||||
const normalized = normalizeConversationText(extracted.content)
|
||||
const normalized = normalizeConversationText(extracted.content, { trim: true })
|
||||
if (!normalized) {
|
||||
return ''
|
||||
}
|
||||
@@ -628,6 +449,7 @@ export function renderAiConversationHtml(content = '') {
|
||||
|
||||
return restoreTrustedHtmlBlocks(
|
||||
`<div class="ai-html-flow">${blocks.filter(Boolean).join('')}</div>`,
|
||||
extracted.trustedHtmlBlocks
|
||||
extracted.trustedHtmlBlocks,
|
||||
{ paragraphClass: 'ai-html-paragraph' }
|
||||
)
|
||||
}
|
||||
|
||||
191
web/src/utils/conversationTrustedHtml.js
Normal file
191
web/src/utils/conversationTrustedHtml.js
Normal file
@@ -0,0 +1,191 @@
|
||||
export const DOCUMENT_DETAIL_HREF_PREFIX = '#ai-open-document-detail:'
|
||||
|
||||
const ALLOWED_COLON_HEADING_TITLES = new Set([
|
||||
'基础信息识别结果',
|
||||
'报销测算参考',
|
||||
'补充信息'
|
||||
])
|
||||
|
||||
const BUSINESS_FIELD_LABELS = new Set([
|
||||
'时间',
|
||||
'地点',
|
||||
'事由',
|
||||
'金额',
|
||||
'费用类型',
|
||||
'报销类型',
|
||||
'商户',
|
||||
'商户/开票方',
|
||||
'客户',
|
||||
'客户/项目对象',
|
||||
'附件',
|
||||
'附件/凭证',
|
||||
'出行方式'
|
||||
])
|
||||
|
||||
const TRUSTED_HTML_BLOCK_RE = /<!--\s*ai-trusted-html:start\s*-->\s*([\s\S]*?)\s*<!--\s*ai-trusted-html:end\s*-->/g
|
||||
const TRUSTED_HTML_PLACEHOLDER_PREFIX = 'AI_TRUSTED_HTML_BLOCK_'
|
||||
const TRUSTED_HTML_ALLOWED_TAGS = new Set([
|
||||
'section',
|
||||
'article',
|
||||
'header',
|
||||
'footer',
|
||||
'div',
|
||||
'span',
|
||||
'strong',
|
||||
'a'
|
||||
])
|
||||
const TRUSTED_HTML_ALLOWED_ATTRS = new Set([
|
||||
'aria-label',
|
||||
'class',
|
||||
'data-ai-action',
|
||||
'href'
|
||||
])
|
||||
|
||||
function splitColonHeadingLine(line) {
|
||||
const rawLine = String(line || '')
|
||||
const trimmed = rawLine.trim()
|
||||
if (!trimmed || trimmed.startsWith('|') || /^#{1,6}\s/.test(trimmed)) {
|
||||
return [rawLine]
|
||||
}
|
||||
|
||||
const chineseColonIndex = trimmed.indexOf(':')
|
||||
const asciiColonIndex = trimmed.indexOf(':')
|
||||
const colonIndexes = [chineseColonIndex, asciiColonIndex].filter((index) => index > 0)
|
||||
if (!colonIndexes.length) {
|
||||
return [rawLine]
|
||||
}
|
||||
|
||||
const colonIndex = Math.min(...colonIndexes)
|
||||
const title = trimmed.slice(0, colonIndex)
|
||||
const body = trimmed.slice(colonIndex + 1).trim()
|
||||
if (!ALLOWED_COLON_HEADING_TITLES.has(title)) {
|
||||
return [rawLine]
|
||||
}
|
||||
return body ? [`### ${title}`, '', body] : [`### ${title}`]
|
||||
}
|
||||
|
||||
function normalizeBusinessFieldLine(line) {
|
||||
const rawLine = String(line || '')
|
||||
const trimmed = rawLine.trim()
|
||||
if (
|
||||
!trimmed ||
|
||||
trimmed.startsWith('|') ||
|
||||
/^[-*+]\s/.test(trimmed) ||
|
||||
/^#{1,6}\s/.test(trimmed)
|
||||
) {
|
||||
return rawLine
|
||||
}
|
||||
|
||||
const match = trimmed.match(/^([^::\n]{1,16})[::]\s*(.+)$/u)
|
||||
if (!match) {
|
||||
return rawLine
|
||||
}
|
||||
const label = match[1].trim()
|
||||
const value = match[2].trim()
|
||||
if (!BUSINESS_FIELD_LABELS.has(label) || !value) {
|
||||
return rawLine
|
||||
}
|
||||
return `- **${label}**:${value}`
|
||||
}
|
||||
|
||||
function hasOnlyTrustedHtmlTags(html = '') {
|
||||
const tagPattern = /<\/?([a-z][\w-]*)([^>]*)>/gi
|
||||
let match = tagPattern.exec(html)
|
||||
while (match) {
|
||||
const tagName = String(match[1] || '').toLowerCase()
|
||||
if (!TRUSTED_HTML_ALLOWED_TAGS.has(tagName)) {
|
||||
return false
|
||||
}
|
||||
const attrText = String(match[2] || '')
|
||||
const attrPattern = /\s([:@\w-]+)\s*=/g
|
||||
let attrMatch = attrPattern.exec(attrText)
|
||||
while (attrMatch) {
|
||||
const attrName = String(attrMatch[1] || '').toLowerCase()
|
||||
if (!TRUSTED_HTML_ALLOWED_ATTRS.has(attrName)) {
|
||||
return false
|
||||
}
|
||||
attrMatch = attrPattern.exec(attrText)
|
||||
}
|
||||
match = tagPattern.exec(html)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function sanitizeTrustedHtmlBlock(html = '') {
|
||||
const value = String(html || '').trim()
|
||||
if (!value || !value.includes('class="ai-document-card-list"')) {
|
||||
return ''
|
||||
}
|
||||
if (/<(?:script|style|iframe|object|embed|link|meta|form|input|button|textarea|select)\b/i.test(value)) {
|
||||
return ''
|
||||
}
|
||||
if (/\son[a-z]+\s*=/i.test(value) || /javascript\s*:/i.test(value)) {
|
||||
return ''
|
||||
}
|
||||
if (!hasOnlyTrustedHtmlTags(value)) {
|
||||
return ''
|
||||
}
|
||||
const hrefs = [...value.matchAll(/\shref="([^"]*)"/gi)].map((match) => String(match[1] || '').trim())
|
||||
if (hrefs.some((href) => !href.startsWith(DOCUMENT_DETAIL_HREF_PREFIX))) {
|
||||
return ''
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export function normalizeConversationText(text = '', options = {}) {
|
||||
const shouldTrim = Boolean(options.trim)
|
||||
const lines = String(text || '').replace(/\r\n?/g, '\n').split('\n')
|
||||
const normalizedLines = []
|
||||
let inFence = false
|
||||
|
||||
lines.forEach((line) => {
|
||||
if (/^\s*(```|~~~)/.test(line)) {
|
||||
inFence = !inFence
|
||||
normalizedLines.push(line)
|
||||
return
|
||||
}
|
||||
if (inFence) {
|
||||
normalizedLines.push(line)
|
||||
return
|
||||
}
|
||||
|
||||
const nextLines = splitColonHeadingLine(line)
|
||||
if (nextLines[0]?.startsWith('### ') && normalizedLines.length) {
|
||||
const previousLine = normalizedLines[normalizedLines.length - 1]
|
||||
if (String(previousLine || '').trim()) {
|
||||
normalizedLines.push('')
|
||||
}
|
||||
}
|
||||
normalizedLines.push(...nextLines.map((nextLine) => normalizeBusinessFieldLine(nextLine)))
|
||||
})
|
||||
|
||||
const normalized = normalizedLines.join('\n').replace(/\n{3,}/g, '\n\n')
|
||||
return shouldTrim ? normalized.trim() : normalized
|
||||
}
|
||||
|
||||
export function extractTrustedHtmlBlocks(text = '') {
|
||||
const trustedHtmlBlocks = []
|
||||
const content = String(text || '').replace(TRUSTED_HTML_BLOCK_RE, (_match, html) => {
|
||||
const sanitizedHtml = sanitizeTrustedHtmlBlock(html)
|
||||
if (!sanitizedHtml) {
|
||||
return ''
|
||||
}
|
||||
const placeholder = `${TRUSTED_HTML_PLACEHOLDER_PREFIX}${trustedHtmlBlocks.length}`
|
||||
trustedHtmlBlocks.push(sanitizedHtml)
|
||||
return `\n\n${placeholder}\n\n`
|
||||
})
|
||||
return { content, trustedHtmlBlocks }
|
||||
}
|
||||
|
||||
export function restoreTrustedHtmlBlocks(html = '', trustedHtmlBlocks = [], options = {}) {
|
||||
const paragraphClass = String(options.paragraphClass || '').trim()
|
||||
return trustedHtmlBlocks.reduce((nextHtml, block, index) => {
|
||||
const placeholder = `${TRUSTED_HTML_PLACEHOLDER_PREFIX}${index}`
|
||||
const paragraphPattern = paragraphClass
|
||||
? new RegExp(`<p class="${paragraphClass}">${placeholder}</p>\\n?`, 'g')
|
||||
: new RegExp(`<p>${placeholder}</p>\\n?`, 'g')
|
||||
return nextHtml
|
||||
.replace(paragraphPattern, block)
|
||||
.replaceAll(placeholder, block)
|
||||
}, html)
|
||||
}
|
||||
@@ -2,6 +2,12 @@ import { countClaimRisks, resolveArchiveRiskTone } from './archiveCenterListFilt
|
||||
import { isNewDocument } from './documentCenterNewState.js'
|
||||
import { isArchivedDocumentRow } from './documentCenterRows.js'
|
||||
import { sortDocumentRowsByLatestTime } from './documentCenterSort.js'
|
||||
import {
|
||||
DOCUMENT_TYPE_ALL,
|
||||
DOCUMENT_TYPE_APPLICATION,
|
||||
DOCUMENT_TYPE_REIMBURSEMENT,
|
||||
resolveDocumentTypeLabel
|
||||
} from '../constants/documentProtocol.js'
|
||||
import {
|
||||
extractDateText,
|
||||
formatDocumentListTime,
|
||||
@@ -10,9 +16,11 @@ import {
|
||||
} from './documentCenterTime.js'
|
||||
import { normalizeRequestForUi } from './requestViewModel.js'
|
||||
|
||||
export const DOCUMENT_TYPE_ALL = 'all'
|
||||
export const DOCUMENT_TYPE_APPLICATION = 'application'
|
||||
export const DOCUMENT_TYPE_REIMBURSEMENT = 'reimbursement'
|
||||
export {
|
||||
DOCUMENT_TYPE_ALL,
|
||||
DOCUMENT_TYPE_APPLICATION,
|
||||
DOCUMENT_TYPE_REIMBURSEMENT
|
||||
}
|
||||
export const SCENE_ALL = 'all'
|
||||
export const DOCUMENT_SCOPE_ALL = '全部'
|
||||
export const DOCUMENT_SCOPE_APPLICATION = '申请单'
|
||||
@@ -129,7 +137,7 @@ export function buildDocumentRow(request, options = {}) {
|
||||
const documentTypeCode = normalized.documentTypeCode || DOCUMENT_TYPE_REIMBURSEMENT
|
||||
const documentTypeLabel =
|
||||
normalized.documentTypeLabel
|
||||
|| (documentTypeCode === DOCUMENT_TYPE_APPLICATION ? '申请单' : '报销单')
|
||||
|| resolveDocumentTypeLabel(documentTypeCode)
|
||||
const initiatorName = String(
|
||||
normalized.person
|
||||
|| normalized.employeeName
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import {
|
||||
DOCUMENT_DETAIL_HREF_PREFIX,
|
||||
extractTrustedHtmlBlocks,
|
||||
normalizeConversationText,
|
||||
restoreTrustedHtmlBlocks
|
||||
} from './conversationTrustedHtml.js'
|
||||
|
||||
const markdown = new MarkdownIt({
|
||||
html: false,
|
||||
@@ -25,25 +31,6 @@ const ACTION_LINK_CLASS_BY_HREF = {
|
||||
'#review-quick-edit': 'markdown-action-link-edit',
|
||||
'#review-risk-panel': 'markdown-action-link-risk'
|
||||
}
|
||||
const DOCUMENT_DETAIL_HREF_PREFIX = '#ai-open-document-detail:'
|
||||
const TRUSTED_HTML_BLOCK_RE = /<!--\s*ai-trusted-html:start\s*-->\s*([\s\S]*?)\s*<!--\s*ai-trusted-html:end\s*-->/g
|
||||
const TRUSTED_HTML_PLACEHOLDER_PREFIX = 'AI_TRUSTED_HTML_BLOCK_'
|
||||
const TRUSTED_HTML_ALLOWED_TAGS = new Set([
|
||||
'section',
|
||||
'article',
|
||||
'header',
|
||||
'footer',
|
||||
'div',
|
||||
'span',
|
||||
'strong',
|
||||
'a'
|
||||
])
|
||||
const TRUSTED_HTML_ALLOWED_ATTRS = new Set([
|
||||
'aria-label',
|
||||
'class',
|
||||
'data-ai-action',
|
||||
'href'
|
||||
])
|
||||
|
||||
function escapeHtml(text) {
|
||||
return String(text || '')
|
||||
@@ -136,176 +123,8 @@ markdown.renderer.rules.table_close = (tokens, idx, options, env, self) => (
|
||||
`${defaultTableClose ? defaultTableClose(tokens, idx, options, env, self) : '</table>'}</div>`
|
||||
)
|
||||
|
||||
const ALLOWED_COLON_HEADING_TITLES = new Set([
|
||||
'基础信息识别结果',
|
||||
'报销测算参考',
|
||||
'补充信息'
|
||||
])
|
||||
|
||||
const BUSINESS_FIELD_LABELS = new Set([
|
||||
'时间',
|
||||
'地点',
|
||||
'事由',
|
||||
'金额',
|
||||
'费用类型',
|
||||
'报销类型',
|
||||
'商户',
|
||||
'商户/开票方',
|
||||
'客户',
|
||||
'客户/项目对象',
|
||||
'附件',
|
||||
'附件/凭证',
|
||||
'出行方式'
|
||||
])
|
||||
|
||||
function splitColonHeadingLine(line) {
|
||||
const rawLine = String(line || '')
|
||||
const trimmed = rawLine.trim()
|
||||
if (!trimmed || trimmed.startsWith('|') || /^#{1,6}\s/.test(trimmed)) {
|
||||
return [rawLine]
|
||||
}
|
||||
|
||||
const chineseColonIndex = trimmed.indexOf(':')
|
||||
const asciiColonIndex = trimmed.indexOf(':')
|
||||
const colonIndexes = [chineseColonIndex, asciiColonIndex].filter((index) => index > 0)
|
||||
if (!colonIndexes.length) {
|
||||
return [rawLine]
|
||||
}
|
||||
|
||||
const colonIndex = Math.min(...colonIndexes)
|
||||
const title = trimmed.slice(0, colonIndex + 1)
|
||||
const titleText = title.slice(0, -1)
|
||||
const body = trimmed.slice(colonIndex + 1).trim()
|
||||
if (!ALLOWED_COLON_HEADING_TITLES.has(titleText)) {
|
||||
return [rawLine]
|
||||
}
|
||||
|
||||
return body ? [`### ${titleText}`, '', body] : [`### ${titleText}`]
|
||||
}
|
||||
|
||||
function normalizeBusinessFieldLine(line) {
|
||||
const rawLine = String(line || '')
|
||||
const trimmed = rawLine.trim()
|
||||
if (
|
||||
!trimmed ||
|
||||
trimmed.startsWith('|') ||
|
||||
/^[-*+]\s/.test(trimmed) ||
|
||||
/^#{1,6}\s/.test(trimmed)
|
||||
) {
|
||||
return rawLine
|
||||
}
|
||||
|
||||
const match = trimmed.match(/^([^::\n]{1,16})[::]\s*(.+)$/u)
|
||||
if (!match) {
|
||||
return rawLine
|
||||
}
|
||||
const label = match[1].trim()
|
||||
const value = match[2].trim()
|
||||
if (!BUSINESS_FIELD_LABELS.has(label) || !value) {
|
||||
return rawLine
|
||||
}
|
||||
return `- **${label}**:${value}`
|
||||
}
|
||||
|
||||
function normalizeColonHeadings(text) {
|
||||
const lines = String(text || '').replace(/\r\n?/g, '\n').split('\n')
|
||||
const normalizedLines = []
|
||||
let inFence = false
|
||||
|
||||
lines.forEach((line) => {
|
||||
if (/^\s*(```|~~~)/.test(line)) {
|
||||
inFence = !inFence
|
||||
normalizedLines.push(line)
|
||||
return
|
||||
}
|
||||
if (inFence) {
|
||||
normalizedLines.push(line)
|
||||
return
|
||||
}
|
||||
|
||||
const nextLines = splitColonHeadingLine(line)
|
||||
if (nextLines[0]?.startsWith('### ') && normalizedLines.length) {
|
||||
const previousLine = normalizedLines[normalizedLines.length - 1]
|
||||
if (String(previousLine || '').trim()) {
|
||||
normalizedLines.push('')
|
||||
}
|
||||
}
|
||||
normalizedLines.push(...nextLines.map((nextLine) => normalizeBusinessFieldLine(nextLine)))
|
||||
})
|
||||
|
||||
return normalizedLines.join('\n').replace(/\n{3,}/g, '\n\n')
|
||||
}
|
||||
|
||||
function hasOnlyTrustedHtmlTags(html = '') {
|
||||
const tagPattern = /<\/?([a-z][\w-]*)([^>]*)>/gi
|
||||
let match = tagPattern.exec(html)
|
||||
while (match) {
|
||||
const tagName = String(match[1] || '').toLowerCase()
|
||||
if (!TRUSTED_HTML_ALLOWED_TAGS.has(tagName)) {
|
||||
return false
|
||||
}
|
||||
const attrText = String(match[2] || '')
|
||||
const attrPattern = /\s([:@\w-]+)\s*=/g
|
||||
let attrMatch = attrPattern.exec(attrText)
|
||||
while (attrMatch) {
|
||||
const attrName = String(attrMatch[1] || '').toLowerCase()
|
||||
if (!TRUSTED_HTML_ALLOWED_ATTRS.has(attrName)) {
|
||||
return false
|
||||
}
|
||||
attrMatch = attrPattern.exec(attrText)
|
||||
}
|
||||
match = tagPattern.exec(html)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function sanitizeTrustedHtmlBlock(html = '') {
|
||||
const value = String(html || '').trim()
|
||||
if (!value || !value.includes('class="ai-document-card-list"')) {
|
||||
return ''
|
||||
}
|
||||
if (/<(?:script|style|iframe|object|embed|link|meta|form|input|button|textarea|select)\b/i.test(value)) {
|
||||
return ''
|
||||
}
|
||||
if (/\son[a-z]+\s*=/i.test(value) || /javascript\s*:/i.test(value)) {
|
||||
return ''
|
||||
}
|
||||
if (!hasOnlyTrustedHtmlTags(value)) {
|
||||
return ''
|
||||
}
|
||||
const hrefs = [...value.matchAll(/\shref="([^"]*)"/gi)].map((match) => String(match[1] || '').trim())
|
||||
if (hrefs.some((href) => !href.startsWith(DOCUMENT_DETAIL_HREF_PREFIX))) {
|
||||
return ''
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function extractTrustedHtmlBlocks(text = '') {
|
||||
const trustedHtmlBlocks = []
|
||||
const content = String(text || '').replace(TRUSTED_HTML_BLOCK_RE, (_match, html) => {
|
||||
const sanitizedHtml = sanitizeTrustedHtmlBlock(html)
|
||||
if (!sanitizedHtml) {
|
||||
return ''
|
||||
}
|
||||
const placeholder = `${TRUSTED_HTML_PLACEHOLDER_PREFIX}${trustedHtmlBlocks.length}`
|
||||
trustedHtmlBlocks.push(sanitizedHtml)
|
||||
return `\n\n${placeholder}\n\n`
|
||||
})
|
||||
return { content, trustedHtmlBlocks }
|
||||
}
|
||||
|
||||
function restoreTrustedHtmlBlocks(html = '', trustedHtmlBlocks = []) {
|
||||
return trustedHtmlBlocks.reduce((nextHtml, block, index) => {
|
||||
const placeholder = `${TRUSTED_HTML_PLACEHOLDER_PREFIX}${index}`
|
||||
const paragraphPattern = new RegExp(`<p>${placeholder}</p>\\n?`, 'g')
|
||||
return nextHtml
|
||||
.replace(paragraphPattern, block)
|
||||
.replaceAll(placeholder, block)
|
||||
}, html)
|
||||
}
|
||||
|
||||
export function renderMarkdown(text = '') {
|
||||
const { content, trustedHtmlBlocks } = extractTrustedHtmlBlocks(text)
|
||||
const normalized = normalizeColonHeadings(content).trim()
|
||||
const normalized = normalizeConversationText(content).trim()
|
||||
return normalized ? restoreTrustedHtmlBlocks(markdown.render(normalized), trustedHtmlBlocks) : ''
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
@new-chat="openAiSidebarNewChat"
|
||||
@open-recent="openAiSidebarRecent"
|
||||
@rename-conversation="handleAiConversationRename"
|
||||
@prefetch-view="prefetchAppView"
|
||||
@logout="handleLogout"
|
||||
/>
|
||||
<SidebarRail
|
||||
@@ -49,6 +50,7 @@
|
||||
@logout="handleLogout"
|
||||
@toggle-collapse="toggleSidebarCollapsed"
|
||||
@navigate="handleNavigateWithMobileClose"
|
||||
@prefetch-view="prefetchAppView"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
@@ -243,24 +245,18 @@ import AiSidebarRail from '../components/layout/AiSidebarRail.vue'
|
||||
import SidebarRail from '../components/layout/SidebarRail.vue'
|
||||
import TopBar from '../components/layout/TopBar.vue'
|
||||
import FilterBar from '../components/layout/FilterBar.vue'
|
||||
import AuditView from './AuditView.vue'
|
||||
import BudgetCenterView from './BudgetCenterView.vue'
|
||||
import DigitalEmployeesView from './DigitalEmployeesView.vue'
|
||||
import DocumentsCenterView from './DocumentsCenterView.vue'
|
||||
import EmployeeManagementView from './EmployeeManagementView.vue'
|
||||
import OverviewView from './OverviewView.vue'
|
||||
import PersonalWorkbenchView from './PersonalWorkbenchView.vue'
|
||||
import PoliciesView from './PoliciesView.vue'
|
||||
import ReceiptFolderView from './ReceiptFolderView.vue'
|
||||
import SettingsView from './SettingsView.vue'
|
||||
import TravelReimbursementCreateView from './TravelReimbursementCreateView.vue'
|
||||
import TravelRequestDetailView from './TravelRequestDetailView.vue'
|
||||
|
||||
import { useAppShell } from '../composables/useAppShell.js'
|
||||
import { useSystemState } from '../composables/useSystemState.js'
|
||||
import { filterNavItemsByAccess, isPlatformAdminUser } from '../utils/accessControl.js'
|
||||
import { isBusinessDocumentReference } from '../utils/aiDocumentDetailReference.js'
|
||||
import { loadAiWorkbenchConversationHistory, saveAiWorkbenchConversation } from '../utils/aiWorkbenchConversationStore.js'
|
||||
import {
|
||||
defineAsyncModalView,
|
||||
defineAsyncRouteView,
|
||||
preloadAppView,
|
||||
scheduleRelatedAppViewPreload
|
||||
} from './scripts/appShellAsyncViews.js'
|
||||
|
||||
const employeeSummary = ref(null)
|
||||
const knowledgeSummary = ref(null)
|
||||
@@ -300,6 +296,18 @@ const aiSidebarCommandSeq = ref(0)
|
||||
const aiSidebarCommand = ref({ seq: 0, type: '', payload: null })
|
||||
const aiActiveConversationId = ref('')
|
||||
const aiConversationHistory = ref([])
|
||||
const AuditView = defineAsyncRouteView('audit')
|
||||
const BudgetCenterView = defineAsyncRouteView('budget')
|
||||
const DigitalEmployeesView = defineAsyncRouteView('digitalEmployees')
|
||||
const DocumentsCenterView = defineAsyncRouteView('documents')
|
||||
const EmployeeManagementView = defineAsyncRouteView('employees')
|
||||
const OverviewView = defineAsyncRouteView('overview')
|
||||
const PersonalWorkbenchView = defineAsyncRouteView('workbench')
|
||||
const PoliciesView = defineAsyncRouteView('policies')
|
||||
const ReceiptFolderView = defineAsyncRouteView('receiptFolder')
|
||||
const SettingsView = defineAsyncRouteView('settings')
|
||||
const TravelReimbursementCreateView = defineAsyncModalView('travelCreate')
|
||||
const TravelRequestDetailView = defineAsyncRouteView('travelDetail')
|
||||
|
||||
function toggleSidebarCollapsed() {
|
||||
sidebarCollapsed.value = !sidebarCollapsed.value
|
||||
@@ -310,6 +318,10 @@ function handleNavigateWithMobileClose(viewId) {
|
||||
mobileSidebarOpen.value = false
|
||||
}
|
||||
|
||||
function prefetchAppView(viewId) {
|
||||
void preloadAppView(viewId).catch(() => {})
|
||||
}
|
||||
|
||||
function toggleWorkbenchMode() {
|
||||
const nextMode = workbenchMode.value === 'ai' ? 'traditional' : 'ai'
|
||||
if (nextMode === 'ai') {
|
||||
@@ -580,4 +592,12 @@ watch(
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => activeView.value,
|
||||
(view) => {
|
||||
scheduleRelatedAppViewPreload(view)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
86
web/src/views/scripts/appShellAsyncViews.js
Normal file
86
web/src/views/scripts/appShellAsyncViews.js
Normal file
@@ -0,0 +1,86 @@
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
|
||||
import AppModalLoadingState from '../../components/shared/AppModalLoadingState.vue'
|
||||
import AppViewLoadingState from '../../components/shared/AppViewLoadingState.vue'
|
||||
|
||||
const appViewLoaders = {
|
||||
audit: () => import('../AuditView.vue'),
|
||||
budget: () => import('../BudgetCenterView.vue'),
|
||||
digitalEmployees: () => import('../DigitalEmployeesView.vue'),
|
||||
documents: () => import('../DocumentsCenterView.vue'),
|
||||
employees: () => import('../EmployeeManagementView.vue'),
|
||||
overview: () => import('../OverviewView.vue'),
|
||||
policies: () => import('../PoliciesView.vue'),
|
||||
receiptFolder: () => import('../ReceiptFolderView.vue'),
|
||||
settings: () => import('../SettingsView.vue'),
|
||||
travelCreate: () => import('../TravelReimbursementCreateView.vue'),
|
||||
travelDetail: () => import('../TravelRequestDetailView.vue'),
|
||||
workbench: () => import('../PersonalWorkbenchView.vue')
|
||||
}
|
||||
|
||||
const appViewPreloadCache = new Map()
|
||||
|
||||
const relatedPreloadViews = {
|
||||
audit: ['documents', 'policies'],
|
||||
budget: ['documents', 'overview'],
|
||||
digitalEmployees: ['overview', 'settings'],
|
||||
documents: ['travelDetail', 'workbench'],
|
||||
employees: ['settings', 'overview'],
|
||||
overview: ['workbench', 'documents'],
|
||||
policies: ['audit', 'documents'],
|
||||
receiptFolder: ['documents', 'workbench'],
|
||||
settings: ['digitalEmployees', 'employees'],
|
||||
workbench: ['documents', 'receiptFolder']
|
||||
}
|
||||
|
||||
export function preloadAppView(viewId) {
|
||||
const normalizedViewId = String(viewId || '').trim()
|
||||
const loader = appViewLoaders[normalizedViewId]
|
||||
if (!loader) {
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
if (!appViewPreloadCache.has(normalizedViewId)) {
|
||||
appViewPreloadCache.set(
|
||||
normalizedViewId,
|
||||
loader().catch((error) => {
|
||||
appViewPreloadCache.delete(normalizedViewId)
|
||||
throw error
|
||||
})
|
||||
)
|
||||
}
|
||||
return appViewPreloadCache.get(normalizedViewId)
|
||||
}
|
||||
|
||||
export function defineAsyncRouteView(viewId, options = {}) {
|
||||
return defineAsyncComponent({
|
||||
loader: () => preloadAppView(viewId),
|
||||
loadingComponent: options.loadingComponent || AppViewLoadingState,
|
||||
delay: options.delay ?? 160,
|
||||
timeout: options.timeout ?? 30000,
|
||||
suspensible: false
|
||||
})
|
||||
}
|
||||
|
||||
export function defineAsyncModalView(viewId) {
|
||||
return defineAsyncRouteView(viewId, {
|
||||
loadingComponent: AppModalLoadingState,
|
||||
delay: 80
|
||||
})
|
||||
}
|
||||
|
||||
export function scheduleRelatedAppViewPreload(viewId) {
|
||||
const views = relatedPreloadViews[String(viewId || '').trim()] || []
|
||||
if (!views.length || typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
const runPreload = () => {
|
||||
for (const view of views.slice(0, 2)) {
|
||||
void preloadAppView(view).catch(() => {})
|
||||
}
|
||||
}
|
||||
if (typeof window.requestIdleCallback === 'function') {
|
||||
window.requestIdleCallback(runPreload, { timeout: 1600 })
|
||||
return
|
||||
}
|
||||
window.setTimeout(runPreload, 360)
|
||||
}
|
||||
@@ -1,319 +1,14 @@
|
||||
import {
|
||||
DATE_INPUT_FORMAT,
|
||||
buildReviewAttachmentStatus,
|
||||
cloneReviewEditFields,
|
||||
createEmptyInlineReviewState,
|
||||
formatAmountDisplay,
|
||||
formatReviewSceneDisplayValue,
|
||||
normalizeReviewRiskLevel,
|
||||
shouldShowReviewFactCard,
|
||||
buildBusinessTimeContextFromReviewValues as buildBusinessTimeContextFromReviewValuesModel,
|
||||
buildReviewFormContextFromPayload as buildReviewFormContextFromPayloadModel,
|
||||
isTravelReviewPayload as isTravelReviewPayloadModel,
|
||||
resolveReviewTravelTransportType as resolveReviewTravelTransportTypeModel
|
||||
} from './travelReimbursementReviewModel.js'
|
||||
|
||||
const REVIEW_PANEL_SCOPE_OVERVIEW = 'overview'
|
||||
const REVIEW_PANEL_SCOPE_DOCUMENTS = 'documents'
|
||||
const REVIEW_PANEL_SCOPE_RISK = 'risk'
|
||||
const DEPRECATED_REVIEW_RISK_TITLE_KEYWORDS = ['历史报销画像', '用户画像', '制度注意事项', '制度注意']
|
||||
const REVIEW_PENDING_SUMMARY_PATTERN = /(^|\n)\s*(?:当前还有|我这边看到还有|下方还有|这笔报销还有|目前还有|还有|这次识别结果里还有|我还需要你确认|当前信息还差|本次报销还有)\s+[^\n]*(?:信息待补充|风险提醒|细节还需要进一步确认)[^\n]*(?:草稿)[^\n]*。\s*/g
|
||||
|
||||
const REVIEW_RISK_LEVEL_META = {
|
||||
high: {
|
||||
label: '高风险',
|
||||
icon: 'mdi mdi-alert-octagon-outline',
|
||||
suggestion: '建议先处理该项,再提交审批;如果属于合理业务场景,请补充说明或附件。'
|
||||
},
|
||||
medium: {
|
||||
label: '中风险',
|
||||
icon: 'mdi mdi-alert-circle-outline',
|
||||
suggestion: '提交前建议核对业务真实性、附件完整性和规则口径。'
|
||||
},
|
||||
info: {
|
||||
label: '提示',
|
||||
icon: 'mdi mdi-information-outline',
|
||||
suggestion: '该项主要用于辅助判断,可结合当前单据情况简单核对。'
|
||||
},
|
||||
low: {
|
||||
label: '低风险',
|
||||
icon: 'mdi mdi-information-outline',
|
||||
suggestion: '该项主要用于辅助判断,可结合当前单据情况简单核对。'
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeReviewPanelScope(scope) {
|
||||
const normalized = String(scope || '').trim()
|
||||
return [REVIEW_PANEL_SCOPE_OVERVIEW, REVIEW_PANEL_SCOPE_DOCUMENTS, REVIEW_PANEL_SCOPE_RISK].includes(normalized)
|
||||
? normalized
|
||||
: ''
|
||||
}
|
||||
|
||||
export function canExposeReviewPanelScope(scope) {
|
||||
return Boolean(normalizeReviewPanelScope(scope))
|
||||
}
|
||||
|
||||
export function buildBusinessTimeContextFromReviewValues(values = {}) {
|
||||
return buildBusinessTimeContextFromReviewValuesModel(values)
|
||||
}
|
||||
|
||||
export function buildReviewFormContextFromPayload(reviewPayload, inlineState = null) {
|
||||
return buildReviewFormContextFromPayloadModel(reviewPayload, inlineState)
|
||||
}
|
||||
|
||||
export function buildReviewCorrectionMessage(fields) {
|
||||
const lines = ['请按以下核对后的报销信息更新当前识别结果:']
|
||||
for (const item of cloneReviewEditFields(fields)) {
|
||||
if (!item.label || (!item.value && !item.required)) {
|
||||
continue
|
||||
}
|
||||
lines.push(`${item.label}:${String(item.value || '').trim() || '待补充'}`)
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
export function isTravelReviewPayload(reviewPayload, inlineState = createEmptyInlineReviewState()) {
|
||||
return isTravelReviewPayloadModel(reviewPayload, inlineState)
|
||||
}
|
||||
|
||||
export function resolveReviewTravelTransportType(reviewPayload, fallbackText = '') {
|
||||
return resolveReviewTravelTransportTypeModel(reviewPayload, fallbackText)
|
||||
}
|
||||
|
||||
export function resolveReviewRiskBriefs(reviewPayload) {
|
||||
if (!Array.isArray(reviewPayload?.risk_briefs)) return []
|
||||
return reviewPayload.risk_briefs.filter((item) => {
|
||||
const title = String(item?.title || '').trim()
|
||||
return !DEPRECATED_REVIEW_RISK_TITLE_KEYWORDS.some((keyword) => title.includes(keyword))
|
||||
})
|
||||
}
|
||||
|
||||
export function buildReviewFactCards(reviewPayload, inlineState = createEmptyInlineReviewState()) {
|
||||
const pendingAttachmentCount = Math.max(0, Number(inlineState.pending_attachment_count || 0))
|
||||
const totalAttachmentCount = Math.max(0, Number(inlineState.attachment_count || 0))
|
||||
const existingAttachmentCount = Math.max(0, totalAttachmentCount - pendingAttachmentCount)
|
||||
const attachmentStatus =
|
||||
pendingAttachmentCount > 0
|
||||
? existingAttachmentCount > 0
|
||||
? `已上传 ${existingAttachmentCount} 份,待新增 ${pendingAttachmentCount} 份`
|
||||
: `待保存 ${pendingAttachmentCount} 份`
|
||||
: totalAttachmentCount > 0
|
||||
? `已上传 ${totalAttachmentCount} 份`
|
||||
: buildReviewAttachmentStatus(reviewPayload)
|
||||
if (isTravelReviewPayload(reviewPayload, inlineState)) {
|
||||
return [
|
||||
{
|
||||
key: 'occurred_date',
|
||||
label: '发生时间',
|
||||
value: String(inlineState.occurred_date || '').trim() || '待补充',
|
||||
icon: 'mdi mdi-calendar-month-outline',
|
||||
editor: 'date',
|
||||
modelKey: 'occurred_date',
|
||||
placeholder: `例如 ${DATE_INPUT_FORMAT}`
|
||||
},
|
||||
{
|
||||
key: 'amount',
|
||||
label: '金额',
|
||||
value: formatAmountDisplay(inlineState.amount) || '待补充',
|
||||
icon: 'mdi mdi-cash',
|
||||
editor: 'amount',
|
||||
modelKey: 'amount',
|
||||
placeholder: '例如 200.00'
|
||||
},
|
||||
{
|
||||
key: 'transport_type',
|
||||
label: '交通类型',
|
||||
value: String(inlineState.transport_type || '').trim() || '待确认',
|
||||
icon: 'mdi mdi-train-car',
|
||||
editor: 'text',
|
||||
modelKey: 'transport_type',
|
||||
placeholder: '例如 火车/高铁、飞机'
|
||||
},
|
||||
{
|
||||
key: 'hotel_name',
|
||||
label: '酒店名称',
|
||||
value: String(inlineState.merchant_name || '').trim() || '待补充',
|
||||
icon: 'mdi mdi-bed-outline',
|
||||
editor: 'text',
|
||||
modelKey: 'merchant_name',
|
||||
placeholder: '请输入酒店名称'
|
||||
},
|
||||
{
|
||||
key: 'travel_purpose',
|
||||
label: '出差事宜',
|
||||
value: String(inlineState.reason_value || '').trim() || '待补充',
|
||||
icon: 'mdi mdi-briefcase-edit-outline',
|
||||
editor: 'textarea',
|
||||
modelKey: 'reason_value',
|
||||
placeholder: '请填写本次出差的具体工作内容或业务意图',
|
||||
wide: true
|
||||
}
|
||||
]
|
||||
}
|
||||
const cards = [
|
||||
{
|
||||
key: 'occurred_date',
|
||||
label: '发生时间',
|
||||
value: String(inlineState.occurred_date || '').trim() || '待补充',
|
||||
icon: 'mdi mdi-calendar-month-outline',
|
||||
editor: 'date',
|
||||
modelKey: 'occurred_date',
|
||||
placeholder: `例如 ${DATE_INPUT_FORMAT}`
|
||||
},
|
||||
{
|
||||
key: 'amount',
|
||||
label: '金额',
|
||||
value: formatAmountDisplay(inlineState.amount) || '待补充',
|
||||
icon: 'mdi mdi-cash',
|
||||
editor: 'amount',
|
||||
modelKey: 'amount',
|
||||
placeholder: '例如 200.00'
|
||||
},
|
||||
{
|
||||
key: 'scene',
|
||||
label: '场景 / 事由',
|
||||
value: formatReviewSceneDisplayValue(inlineState),
|
||||
icon: 'mdi mdi-silverware-fork-knife',
|
||||
editor: 'select',
|
||||
modelKey: 'scene_label',
|
||||
placeholder: '请选择场景'
|
||||
},
|
||||
{
|
||||
key: 'attachments',
|
||||
label: '票据状态',
|
||||
value: attachmentStatus,
|
||||
icon: 'mdi mdi-file-document-outline',
|
||||
editor: 'upload',
|
||||
modelKey: 'attachment_names',
|
||||
placeholder: ''
|
||||
}
|
||||
]
|
||||
|
||||
if (shouldShowReviewFactCard(reviewPayload, 'customer_name', inlineState.customer_name)) {
|
||||
cards.splice(cards.length - 1, 0, {
|
||||
key: 'customer_name',
|
||||
label: '关联客户',
|
||||
value: String(inlineState.customer_name || '').trim() || '待补充',
|
||||
icon: 'mdi mdi-domain',
|
||||
editor: 'text',
|
||||
modelKey: 'customer_name',
|
||||
placeholder: '请输入客户名称'
|
||||
})
|
||||
}
|
||||
|
||||
if (shouldShowReviewFactCard(reviewPayload, 'location', inlineState.location)) {
|
||||
cards.splice(cards.length - 1, 0, {
|
||||
key: 'location',
|
||||
label: '业务地点',
|
||||
value: String(inlineState.location || '').trim() || '待补充',
|
||||
icon: 'mdi mdi-map-marker-outline',
|
||||
editor: 'text',
|
||||
modelKey: 'location',
|
||||
placeholder: '请输入业务地点'
|
||||
})
|
||||
}
|
||||
|
||||
if (shouldShowReviewFactCard(reviewPayload, 'merchant_name', inlineState.merchant_name)) {
|
||||
cards.splice(cards.length - 1, 0, {
|
||||
key: 'merchant_name',
|
||||
label: '酒店/商户',
|
||||
value: String(inlineState.merchant_name || '').trim() || '待补充',
|
||||
icon: 'mdi mdi-storefront-outline',
|
||||
editor: 'text',
|
||||
modelKey: 'merchant_name',
|
||||
placeholder: '请输入酒店或商户名称'
|
||||
})
|
||||
}
|
||||
|
||||
if (shouldShowReviewFactCard(reviewPayload, 'participants', inlineState.participants)) {
|
||||
cards.splice(cards.length - 1, 0, {
|
||||
key: 'participants',
|
||||
label: '同行人员',
|
||||
value: String(inlineState.participants || '').trim() || '待补充',
|
||||
icon: 'mdi mdi-account-group-outline',
|
||||
editor: 'text',
|
||||
modelKey: 'participants',
|
||||
placeholder: '例如 客户 2 人,我方 1 人'
|
||||
})
|
||||
}
|
||||
|
||||
return cards
|
||||
}
|
||||
|
||||
function normalizeReviewRiskTitle(title, fallbackTitle) {
|
||||
const normalized = String(title || '').trim()
|
||||
const fallback = String(fallbackTitle || '风险提示').trim() || '风险提示'
|
||||
if (!normalized) return fallback
|
||||
const cleaned = normalized
|
||||
.replace(/AI\s*预审\s*(暂未通过|未通过|不通过)?/g, '风险提示')
|
||||
.replace(/(高风险|中风险|低风险)/g, '')
|
||||
.replace(/^[::\-—\s]+|[::\-—\s]+$/g, '')
|
||||
.trim()
|
||||
return cleaned || fallback
|
||||
}
|
||||
|
||||
export function buildReviewRiskItems(reviewPayload) {
|
||||
return resolveReviewRiskBriefs(reviewPayload)
|
||||
.map((brief, index) => {
|
||||
const title = String(brief?.title || '').trim()
|
||||
const content = String(brief?.content || '').trim()
|
||||
const detail = String(brief?.detail || '').trim()
|
||||
const suggestion = String(brief?.suggestion || '').trim()
|
||||
const level = normalizeReviewRiskLevel(brief?.level)
|
||||
const meta = REVIEW_RISK_LEVEL_META[level] || REVIEW_RISK_LEVEL_META.low
|
||||
const fallbackTitle = content ? `风险提示 ${index + 1}` : '风险提示'
|
||||
const normalizedTitle = normalizeReviewRiskTitle(title, fallbackTitle)
|
||||
const summary = content || normalizedTitle
|
||||
|
||||
if (!normalizedTitle && !summary) return null
|
||||
|
||||
return {
|
||||
key: `${level}-${normalizedTitle}-${index}`,
|
||||
title: normalizedTitle,
|
||||
summary,
|
||||
detail: detail || content || '当前风险项没有返回更长解释,建议结合票据、报销事由和规则要求进行复核。',
|
||||
level,
|
||||
levelLabel: meta.label,
|
||||
icon: meta.icon,
|
||||
sourceLabel: meta.label,
|
||||
suggestion: suggestion || meta.suggestion
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
export function buildReviewRiskConversationText(item, detailTarget = {}) {
|
||||
const title = String(item?.title || '风险提示').trim()
|
||||
const summary = String(item?.summary || '').trim()
|
||||
const detail = String(item?.detail || '').trim()
|
||||
const suggestion = String(item?.suggestion || '').trim()
|
||||
const isInfo = String(item?.level || '').trim() === 'info'
|
||||
const detailHref = String(detailTarget?.href || '').trim()
|
||||
const detailLabel = String(detailTarget?.label || '').trim() || '进入该单据详情重新填写'
|
||||
const lines = [`${title}`]
|
||||
|
||||
if (summary) {
|
||||
lines.push('', `${isInfo ? '提示内容' : '风险点'}:${summary}`)
|
||||
}
|
||||
if (detail && detail !== summary) {
|
||||
lines.push('', `规则依据:${detail}`)
|
||||
}
|
||||
if (suggestion) {
|
||||
lines.push('', `${isInfo ? '处理建议' : '修改建议'}:${suggestion}`)
|
||||
}
|
||||
if (detailHref) {
|
||||
lines.push('', `[${detailLabel}](${detailHref})`)
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
export function buildReviewMainMessageText(message) {
|
||||
const text = String(message?.text || '')
|
||||
if (!message?.reviewPayload) {
|
||||
return text
|
||||
}
|
||||
return text
|
||||
.replace(REVIEW_PENDING_SUMMARY_PATTERN, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
}
|
||||
export {
|
||||
buildBusinessTimeContextFromReviewValues,
|
||||
buildReviewCorrectionMessage,
|
||||
buildReviewFactCards,
|
||||
buildReviewFormContextFromPayload,
|
||||
buildReviewMainMessageText,
|
||||
buildReviewRiskConversationText,
|
||||
buildReviewRiskItems,
|
||||
canExposeReviewPanelScope,
|
||||
isTravelReviewPayload,
|
||||
normalizeReviewPanelScope,
|
||||
resolveReviewRiskBriefs,
|
||||
resolveReviewTravelTransportType
|
||||
} from './travelReimbursementReviewPanelModel.js'
|
||||
|
||||
@@ -97,7 +97,7 @@ test('AI sidebar has quick actions, business navigation and conversation history
|
||||
assert.match(aiSidebar, /class="ai-nav-list"/)
|
||||
assert.match(aiSidebar, /v-for="item in businessNavItems"/)
|
||||
assert.match(aiSidebar, /ai-nav-copy/)
|
||||
assert.match(aiSidebar, /item\.aiIcon/)
|
||||
assert.match(aiSidebar, /item\.aiIconPaths/)
|
||||
assert.match(aiSidebar, /aria-current/)
|
||||
assert.doesNotMatch(aiSidebar, /displayHint/)
|
||||
assert.doesNotMatch(aiSidebar, /个人工作台/)
|
||||
@@ -136,7 +136,7 @@ test('AI sidebar has quick actions, business navigation and conversation history
|
||||
assert.match(aiSidebar, /displayUser\.subtitle/)
|
||||
assert.match(aiSidebar, /aria-label="用户操作"/)
|
||||
assert.match(aiSidebar, /emit\('logout'\)/)
|
||||
assert.match(aiSidebar, /defineEmits\(\['navigate', 'new-chat', 'open-recent', 'rename-conversation', 'logout'\]\)/)
|
||||
assert.match(aiSidebar, /defineEmits\(\['navigate', 'new-chat', 'open-recent', 'rename-conversation', 'logout', 'prefetch-view'\]\)/)
|
||||
assert.doesNotMatch(aiSidebar, /search-chat/)
|
||||
assert.doesNotMatch(aiSidebar, /打开系统设置/)
|
||||
assert.doesNotMatch(aiSidebar, /mdi-chevron-up/)
|
||||
@@ -154,10 +154,21 @@ test('AI sidebar visual treatment keeps a restrained three-layer workspace', ()
|
||||
assert.match(aiSidebarStyles, /\.ai-rail-brand\s*\{[\s\S]*min-height:\s*74px;[\s\S]*grid-template-columns:\s*42px minmax\(0,\s*1fr\);/)
|
||||
assert.match(aiSidebarStyles, /\.ai-brand-logo\s*\{[\s\S]*width:\s*42px;[\s\S]*height:\s*42px;/)
|
||||
assert.match(aiSidebarStyles, /\.ai-brand-logo svg\s*\{[\s\S]*width:\s*26px;[\s\S]*height:\s*26px;/)
|
||||
assert.match(aiSidebar, /icon:\s*'mdi mdi-plus'/)
|
||||
assert.match(aiSidebar, /const tablerIconPaths = \{/)
|
||||
assert.match(aiSidebar, /plus:\s*\[/)
|
||||
assert.match(aiSidebar, /search:\s*\[/)
|
||||
assert.match(aiSidebar, /fileText:\s*\[/)
|
||||
assert.match(aiSidebar, /book2:\s*\[/)
|
||||
assert.match(aiSidebar, /iconPaths:\s*tablerIconPaths\.plus/)
|
||||
assert.match(aiSidebar, /aiIconPaths:\s*sidebarMeta\[item\.id\]\?\.iconPaths/)
|
||||
assert.doesNotMatch(aiSidebar, /icon:\s*'mdi mdi-plus'/)
|
||||
assert.doesNotMatch(aiSidebar, /mdi mdi-file-document-outline/)
|
||||
assert.match(aiSidebarStyles, /\.ai-sidebar-tabler-icon\s*\{[\s\S]*stroke-width:\s*1\.85;/)
|
||||
assert.match(aiSidebarStyles, /\.ai-rail-quick\s*\{[\s\S]*gap:\s*6px;[\s\S]*padding:\s*8px 18px 12px;/)
|
||||
assert.match(quickButtonBlock, /min-height:\s*48px;/)
|
||||
assert.match(quickButtonBlock, /grid-template-columns:\s*28px minmax\(0,\s*1fr\);/)
|
||||
assert.match(quickButtonBlock, /grid-template-columns:\s*32px minmax\(0,\s*1fr\);/)
|
||||
assert.match(quickButtonBlock, /gap:\s*12px;/)
|
||||
assert.match(quickButtonBlock, /padding:\s*7px 10px;/)
|
||||
assert.match(quickButtonBlock, /background:\s*transparent;/)
|
||||
assert.match(quickButtonBlock, /border-color:\s*transparent;/)
|
||||
assert.match(quickButtonBlock, /box-shadow:\s*none;/)
|
||||
@@ -170,7 +181,12 @@ test('AI sidebar visual treatment keeps a restrained three-layer workspace', ()
|
||||
assert.doesNotMatch(navListBlock, /box-shadow:/)
|
||||
assert.match(aiSidebarStyles, /\.ai-nav-btn\s*\{[\s\S]*min-height:\s*48px;/)
|
||||
assert.match(aiSidebarStyles, /\.ai-nav-btn\s*\{[\s\S]*grid-template-columns:\s*32px minmax\(0,\s*1fr\);/)
|
||||
assert.match(aiSidebarStyles, /\.ai-nav-btn\.active\s*\{[\s\S]*background:[\s\S]*linear-gradient\(90deg,\s*rgba\(45,\s*114,\s*217,\s*0\.095\)/)
|
||||
assert.match(aiSidebarStyles, /\.ai-quick-btn:hover,\s*\.ai-quick-btn\.active,\s*\.ai-nav-btn:hover,\s*\.ai-nav-btn\.active\s*\{[\s\S]*background:\s*rgba\(15,\s*23,\s*42,\s*0\.035\);/)
|
||||
assert.doesNotMatch(aiSidebarStyles, /\.ai-nav-btn::before/)
|
||||
assert.doesNotMatch(aiSidebarStyles, /\.ai-nav-btn\.active::before/)
|
||||
assert.doesNotMatch(aiSidebarStyles, /\.ai-nav-btn\.active \.ai-nav-copy/)
|
||||
assert.doesNotMatch(aiSidebarStyles, /\.ai-rail\.rail-collapsed \.ai-nav-btn\.active/)
|
||||
assert.doesNotMatch(aiSidebarStyles, /linear-gradient\(90deg,\s*rgba\(45,\s*114,\s*217,\s*0\.095\)/)
|
||||
assert.match(aiSidebarStyles, /\.ai-rail\.rail-collapsed \.ai-nav-list\s*\{[\s\S]*grid-template-columns:\s*1fr;/)
|
||||
assert.match(aiSidebarStyles, /\.ai-rail-recents\s*\{[\s\S]*grid-template-rows:\s*auto minmax\(0,\s*1fr\);/)
|
||||
assert.match(aiSidebarStyles, /\.ai-recents-list\s*\{[\s\S]*overflow-y:\s*auto;[\s\S]*scrollbar-width:\s*none;/)
|
||||
|
||||
@@ -8,23 +8,65 @@ const shell = readFileSync(
|
||||
'utf8'
|
||||
)
|
||||
const router = readFileSync(fileURLToPath(new URL('../src/router/index.js', import.meta.url)), 'utf8')
|
||||
const sidebarRail = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/layout/SidebarRail.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const aiSidebarRail = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/layout/AiSidebarRail.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
test('app shell main route views are eagerly imported', () => {
|
||||
assert.doesNotMatch(shell, /defineAsyncRouteView/)
|
||||
assert.doesNotMatch(shell, /defineAsyncComponent/)
|
||||
assert.doesNotMatch(shell, /loadingComponent:/)
|
||||
assert.doesNotMatch(shell, /\u9875\u9762\u5207\u6362\u4e2d/)
|
||||
assert.doesNotMatch(shell, /floating:\s*true/)
|
||||
assert.doesNotMatch(shell, /blocking:\s*true/)
|
||||
assert.match(shell, /import AuditView from '\.\/AuditView\.vue'/)
|
||||
assert.match(shell, /import DigitalEmployeesView from '\.\/DigitalEmployeesView\.vue'/)
|
||||
assert.match(shell, /import EmployeeManagementView from '\.\/EmployeeManagementView\.vue'/)
|
||||
assert.match(shell, /import DocumentsCenterView from '\.\/DocumentsCenterView\.vue'/)
|
||||
assert.match(shell, /import BudgetCenterView from '\.\/BudgetCenterView\.vue'/)
|
||||
test('app shell lazily loads heavy business views with an in-workarea loading state', () => {
|
||||
assert.match(shell, /defineAsyncRouteView\('audit'\)/)
|
||||
assert.match(shell, /defineAsyncRouteView\('documents'\)/)
|
||||
assert.match(shell, /defineAsyncRouteView\('workbench'\)/)
|
||||
assert.match(shell, /defineAsyncModalView\('travelCreate'\)/)
|
||||
assert.match(shell, /function prefetchAppView\(viewId\)/)
|
||||
assert.match(shell, /@prefetch-view="prefetchAppView"/)
|
||||
assert.doesNotMatch(shell, /import AuditView from '\.\/AuditView\.vue'/)
|
||||
assert.doesNotMatch(shell, /import DigitalEmployeesView from '\.\/DigitalEmployeesView\.vue'/)
|
||||
assert.doesNotMatch(shell, /import EmployeeManagementView from '\.\/EmployeeManagementView\.vue'/)
|
||||
assert.doesNotMatch(shell, /import DocumentsCenterView from '\.\/DocumentsCenterView\.vue'/)
|
||||
assert.doesNotMatch(shell, /import BudgetCenterView from '\.\/BudgetCenterView\.vue'/)
|
||||
})
|
||||
|
||||
test('top-level app routes are eagerly imported', () => {
|
||||
assert.doesNotMatch(router, /\(\)\s*=>\s*import\(/)
|
||||
test('app view preloading is triggered from both standard and AI sidebars', () => {
|
||||
assert.match(sidebarRail, /'prefetch-view'/)
|
||||
assert.match(sidebarRail, /@mouseenter="emit\('prefetch-view', item\.id\)"/)
|
||||
assert.match(sidebarRail, /@focus="emit\('prefetch-view', item\.id\)"/)
|
||||
assert.match(aiSidebarRail, /'prefetch-view'/)
|
||||
assert.match(aiSidebarRail, /@mouseenter="emit\('prefetch-view', item\.id\)"/)
|
||||
assert.match(aiSidebarRail, /@focus="emit\('prefetch-view', item\.id\)"/)
|
||||
})
|
||||
|
||||
test('async app view loader keeps transitions nonblocking and visible', () => {
|
||||
const asyncViews = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/appShellAsyncViews.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const loadingState = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/shared/AppViewLoadingState.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const modalLoadingState = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/shared/AppModalLoadingState.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
assert.match(asyncViews, /defineAsyncComponent/)
|
||||
assert.match(asyncViews, /loadingComponent:\s*options\.loadingComponent \|\| AppViewLoadingState/)
|
||||
assert.match(asyncViews, /loadingComponent:\s*AppModalLoadingState/)
|
||||
assert.match(asyncViews, /suspensible:\s*false/)
|
||||
assert.match(asyncViews, /requestIdleCallback/)
|
||||
assert.match(loadingState, /正在加载页面内容/)
|
||||
assert.match(loadingState, /app-view-loading-skeleton/)
|
||||
assert.match(modalLoadingState, /Teleport to="body"/)
|
||||
assert.match(modalLoadingState, /正在打开智能工作台/)
|
||||
})
|
||||
|
||||
test('top-level shell routes stay eager so the layout does not blank during navigation', () => {
|
||||
assert.doesNotMatch(router, /component:\s*\(\)\s*=>\s*import\(\s*'\.\.\/views\/AppShellRouteView\.vue'/)
|
||||
assert.match(router, /import AppShellRouteView from '\.\.\/views\/AppShellRouteView\.vue'/)
|
||||
assert.match(router, /import LoginRouteView from '\.\.\/views\/LoginRouteView\.vue'/)
|
||||
assert.match(router, /import SetupRouteView from '\.\.\/views\/SetupRouteView\.vue'/)
|
||||
|
||||
61
web/tests/conversation-trusted-html.test.mjs
Normal file
61
web/tests/conversation-trusted-html.test.mjs
Normal file
@@ -0,0 +1,61 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
extractTrustedHtmlBlocks,
|
||||
normalizeConversationText,
|
||||
restoreTrustedHtmlBlocks
|
||||
} from '../src/utils/conversationTrustedHtml.js'
|
||||
|
||||
test('conversation trusted html helper preserves valid document cards', () => {
|
||||
const trustedBlock = [
|
||||
'<!-- ai-trusted-html:start -->',
|
||||
'<section class="ai-document-card-list" aria-label="单据结果">',
|
||||
'<article class="ai-document-card" aria-label="单据详情">',
|
||||
'<strong>差旅申请</strong>',
|
||||
'<a class="ai-html-action-link" data-ai-action="open-document-detail" href="#ai-open-document-detail:CL-1">查看</a>',
|
||||
'</article>',
|
||||
'</section>',
|
||||
'<!-- ai-trusted-html:end -->'
|
||||
].join('')
|
||||
|
||||
const extracted = extractTrustedHtmlBlocks(`结果如下:\n\n${trustedBlock}`)
|
||||
assert.equal(extracted.trustedHtmlBlocks.length, 1)
|
||||
assert.match(extracted.content, /AI_TRUSTED_HTML_BLOCK_0/)
|
||||
|
||||
const restored = restoreTrustedHtmlBlocks(
|
||||
'<p>AI_TRUSTED_HTML_BLOCK_0</p>\n',
|
||||
extracted.trustedHtmlBlocks
|
||||
)
|
||||
assert.match(restored, /class="ai-document-card-list"/)
|
||||
assert.match(restored, /href="#ai-open-document-detail:CL-1"/)
|
||||
assert.doesNotMatch(restored, /AI_TRUSTED_HTML_BLOCK_0/)
|
||||
})
|
||||
|
||||
test('conversation trusted html helper rejects unsafe trusted blocks', () => {
|
||||
const extracted = extractTrustedHtmlBlocks([
|
||||
'<!-- ai-trusted-html:start -->',
|
||||
'<section class="ai-document-card-list">',
|
||||
'<a href="javascript:alert(1)" onclick="alert(1)">危险</a>',
|
||||
'</section>',
|
||||
'<!-- ai-trusted-html:end -->'
|
||||
].join(''))
|
||||
|
||||
assert.equal(extracted.trustedHtmlBlocks.length, 0)
|
||||
assert.equal(extracted.content.trim(), '')
|
||||
})
|
||||
|
||||
test('conversation trusted html helper normalizes business copy outside fences', () => {
|
||||
const normalized = normalizeConversationText([
|
||||
'基础信息识别结果:请核对',
|
||||
'时间:2026-02-20',
|
||||
'',
|
||||
'```',
|
||||
'金额:不要改代码块',
|
||||
'```'
|
||||
].join('\n'), { trim: true })
|
||||
|
||||
assert.match(normalized, /### 基础信息识别结果/)
|
||||
assert.match(normalized, /- \*\*时间\*\*:2026-02-20/)
|
||||
assert.match(normalized, /```\n金额:不要改代码块\n```/)
|
||||
})
|
||||
39
web/tests/document-protocol-constants.test.mjs
Normal file
39
web/tests/document-protocol-constants.test.mjs
Normal file
@@ -0,0 +1,39 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
DOCUMENT_TYPE_APPLICATION,
|
||||
DOCUMENT_TYPE_LABELS,
|
||||
DOCUMENT_TYPE_REIMBURSEMENT,
|
||||
INLINE_APPLICATION_STATUS_LABELS,
|
||||
resolveDocumentTypeLabel
|
||||
} from '../src/constants/documentProtocol.js'
|
||||
import { readSourceFile, readSourceSurface } from './helpers/sourceSurface.mjs'
|
||||
|
||||
test('document protocol constants centralize document types and labels', () => {
|
||||
assert.equal(DOCUMENT_TYPE_APPLICATION, 'application')
|
||||
assert.equal(DOCUMENT_TYPE_REIMBURSEMENT, 'reimbursement')
|
||||
assert.equal(DOCUMENT_TYPE_LABELS[DOCUMENT_TYPE_APPLICATION], '申请单')
|
||||
assert.equal(DOCUMENT_TYPE_LABELS[DOCUMENT_TYPE_REIMBURSEMENT], '报销单')
|
||||
assert.equal(resolveDocumentTypeLabel('application'), '申请单')
|
||||
assert.equal(resolveDocumentTypeLabel('expense_application'), '申请单')
|
||||
assert.equal(resolveDocumentTypeLabel('unknown', '单据'), '单据')
|
||||
})
|
||||
|
||||
test('inline application status labels live in the shared document protocol', () => {
|
||||
assert.equal(INLINE_APPLICATION_STATUS_LABELS.draft, '草稿')
|
||||
assert.equal(INLINE_APPLICATION_STATUS_LABELS.submitted, '审批中')
|
||||
assert.equal(INLINE_APPLICATION_STATUS_LABELS.pending_payment, '待付款')
|
||||
})
|
||||
|
||||
test('source surface helper loads one or more source files for source assertions', () => {
|
||||
const model = readSourceFile('constants/documentProtocol.js')
|
||||
assert.match(model, /DOCUMENT_TYPE_APPLICATION/)
|
||||
|
||||
const combined = readSourceSurface([
|
||||
'constants/documentProtocol.js',
|
||||
'composables/workbenchAiMode/workbenchAiApplicationPreviewModel.js'
|
||||
])
|
||||
assert.match(combined, /INLINE_APPLICATION_STATUS_LABELS/)
|
||||
assert.match(combined, /normalizeInlineApplicationStatusLabel/)
|
||||
})
|
||||
@@ -1,38 +1,19 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const documentsCenterView = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/DocumentsCenterView.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const documentsCenterViewModel = readFileSync(
|
||||
fileURLToPath(new URL('../src/utils/documentCenterViewModel.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const documentsCenterLogic = `${documentsCenterView}\n${documentsCenterViewModel}`
|
||||
import { readSourceFile, readSourceSurface } from './helpers/sourceSurface.mjs'
|
||||
|
||||
const documentsCenterStyles = readFileSync(
|
||||
fileURLToPath(new URL('../src/assets/styles/views/documents-center-view.css', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const documentListSharedStyles = readFileSync(
|
||||
fileURLToPath(new URL('../src/assets/styles/components/document-list-shared.css', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const tableLoadingState = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/shared/TableLoadingState.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const reimbursementService = readFileSync(
|
||||
fileURLToPath(new URL('../src/services/reimbursements.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const requestsComposable = readFileSync(
|
||||
fileURLToPath(new URL('../src/composables/useRequests.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const documentsCenterView = readSourceFile('views/DocumentsCenterView.vue')
|
||||
const documentsCenterViewModel = readSourceFile('utils/documentCenterViewModel.js')
|
||||
const documentsCenterLogic = readSourceSurface([
|
||||
'views/DocumentsCenterView.vue',
|
||||
'utils/documentCenterViewModel.js'
|
||||
])
|
||||
const documentsCenterStyles = readSourceFile('assets/styles/views/documents-center-view.css')
|
||||
const documentListSharedStyles = readSourceFile('assets/styles/components/document-list-shared.css')
|
||||
const tableLoadingState = readSourceFile('components/shared/TableLoadingState.vue')
|
||||
const reimbursementService = readSourceFile('services/reimbursements.js')
|
||||
const requestsComposable = readSourceFile('composables/useRequests.js')
|
||||
|
||||
test('documents center keeps only the top scope tabs and renders risk level as a dropdown filter', () => {
|
||||
assert.match(documentsCenterView, /<nav class="status-tabs document-scope-tabs"/)
|
||||
@@ -145,7 +126,7 @@ test('documents center preserves application document type from mapped requests'
|
||||
)
|
||||
assert.match(
|
||||
documentsCenterLogic,
|
||||
/documentTypeCode === DOCUMENT_TYPE_APPLICATION \? '申请单' : '报销单'/
|
||||
/resolveDocumentTypeLabel\(documentTypeCode\)/
|
||||
)
|
||||
assert.doesNotMatch(
|
||||
documentsCenterLogic,
|
||||
|
||||
13
web/tests/helpers/sourceSurface.mjs
Normal file
13
web/tests/helpers/sourceSurface.mjs
Normal file
@@ -0,0 +1,13 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
export function readSourceFile(relativePath) {
|
||||
return readFileSync(
|
||||
fileURLToPath(new URL(`../../src/${relativePath}`, import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
}
|
||||
|
||||
export function readSourceSurface(relativePaths = []) {
|
||||
return relativePaths.map((relativePath) => readSourceFile(relativePath)).join('\n')
|
||||
}
|
||||
@@ -82,37 +82,64 @@ test('topbar bell owns document center unread notifications', () => {
|
||||
assert.match(topbar, /startDocumentInboxPolling\(\)/)
|
||||
assert.match(topbar, /stopDocumentInboxPolling\(\)/)
|
||||
assert.match(topbar, /class="notification-clear-btn"/)
|
||||
assert.match(topbar, /function clearAllNotifications\(\)/)
|
||||
assert.match(topbar, /notificationBulkActionLabel/)
|
||||
assert.match(topbar, /notificationBulkActionDisabled/)
|
||||
assert.match(topbar, /function handleNotificationBulkAction\(\)/)
|
||||
assert.match(topbar, /function markUnreadNotificationsRead\(\)/)
|
||||
assert.match(topbar, /function deleteReadNotifications\(\)/)
|
||||
assert.match(topbar, /function markNotificationRead\(item\)/)
|
||||
assert.match(topbar, /class="notification-type-icon" :class="item\.tone"/)
|
||||
assert.match(topbar, /class="notification-avatar" :class="item\.tone"/)
|
||||
assert.match(topbarStyles, /\.notification-head-copy\s*\{[\s\S]*display:\s*grid;/)
|
||||
assert.match(topbarStyles, /\.notification-head-actions\s*\{[\s\S]*display:\s*inline-flex;/)
|
||||
assert.match(topbarStyles, /\.notification-popover\s*\{[\s\S]*max-height:\s*min\(520px,\s*calc\(100vh - 96px\)\);/)
|
||||
assert.match(topbarStyles, /\.notification-list\s*\{[\s\S]*max-height:\s*min\(336px,\s*calc\(100vh - 226px\)\);[\s\S]*overflow-y:\s*auto;/)
|
||||
assert.match(topbarStyles, /\.notification-copy small\s*\{[\s\S]*-webkit-line-clamp:\s*2;/)
|
||||
assert.match(topbarStyles, /\.notification-popover\s*\{[\s\S]*max-height:\s*min\(560px,\s*calc\(100vh - 68px\)\);/)
|
||||
assert.match(topbarStyles, /\.notification-list\s*\{[\s\S]*max-height:\s*min\(420px,\s*calc\(100vh - 166px\)\);[\s\S]*overflow-y:\s*auto;/)
|
||||
assert.match(topbarStyles, /\.notification-preview\s*\{[\s\S]*-webkit-line-clamp:\s*2;/)
|
||||
assert.match(topbarStyles, /@media \(max-width: 640px\)[\s\S]*\.notification-popover\s*\{[\s\S]*position:\s*fixed;/)
|
||||
assert.match(topbarStyles, /\.notification-tabs button em\s*\{[\s\S]*border-radius:\s*4px;/)
|
||||
assert.match(topbarStyles, /\.notification-row\s*\{[\s\S]*grid-template-columns:\s*34px minmax\(0,\s*1fr\) 16px;/)
|
||||
assert.match(topbarStyles, /\.notification-row\s*\{[\s\S]*grid-template-columns:\s*52px minmax\(0,\s*1fr\);/)
|
||||
assert.doesNotMatch(topbarStyles, /\.notification-dot/)
|
||||
})
|
||||
|
||||
test('topbar notification popover uses inbox-style rows with formatted time labels', () => {
|
||||
assert.match(topbar, /class="notification-row-main"/)
|
||||
assert.match(topbar, /class="notification-row-head"/)
|
||||
test('topbar notification bulk action label follows active tab semantics', () => {
|
||||
assert.match(topbar, />\s*\{\{ notificationBulkActionLabel \}\}\s*<\/button>/)
|
||||
assert.match(topbar, /:disabled="notificationBulkActionDisabled"/)
|
||||
assert.match(topbar, /@click="handleNotificationBulkAction"/)
|
||||
assert.match(topbar, /const notificationBulkActionLabel = computed\(\(\) => \(\s*notificationTab\.value === 'unread' \? '全部已读' : '删除已读'\s*\)\)/)
|
||||
assert.match(topbar, /const notificationBulkActionDisabled = computed\(\(\) => \(\s*notificationTab\.value === 'unread'\s*\? unreadNotifications\.value\.length === 0\s*: readNotifications\.value\.length === 0\s*\)\)/)
|
||||
assert.match(topbar, /function handleNotificationBulkAction\(\) \{[\s\S]*if \(notificationTab\.value === 'unread'\) \{[\s\S]*markUnreadNotificationsRead\(\)[\s\S]*return[\s\S]*deleteReadNotifications\(\)[\s\S]*\}/)
|
||||
assert.match(topbar, /function markUnreadNotificationsRead\(\) \{[\s\S]*const currentItems = unreadNotifications\.value[\s\S]*markDocumentInboxRowsRead\(documentRows\)[\s\S]*markNotificationStatesRead\(currentItems\)/)
|
||||
assert.match(topbar, /function deleteReadNotifications\(\) \{[\s\S]*const currentItems = readNotifications\.value[\s\S]*hideNotificationStates\(currentItems\)/)
|
||||
assert.doesNotMatch(topbar, />\s*清空通知\s*<\/button>/)
|
||||
})
|
||||
|
||||
test('topbar notification popover uses reference-style avatar message rows', () => {
|
||||
assert.match(topbar, /class="notification-avatar" :class="item\.tone"/)
|
||||
assert.match(topbar, /class="notification-avatar-label"/)
|
||||
assert.match(topbar, /class="notification-avatar-badge"/)
|
||||
assert.match(topbar, /class="notification-row-content"/)
|
||||
assert.match(topbar, /class="notification-row-top"/)
|
||||
assert.match(topbar, /class="notification-row-title"/)
|
||||
assert.match(topbar, /class="notification-context"/)
|
||||
assert.match(topbar, /class="notification-row-foot"/)
|
||||
assert.match(topbar, /class="notification-category-pill"/)
|
||||
assert.match(topbar, /class="notification-preview"/)
|
||||
assert.match(topbar, /class="notification-time"/)
|
||||
assert.match(topbar, /class="notification-row-action"/)
|
||||
assert.match(topbar, /avatarLabel: resolveDocumentNotificationAvatarLabel\(row\)/)
|
||||
assert.match(topbar, /avatarLabel: resolveNotificationAvatarLabel\(item\)/)
|
||||
assert.match(topbar, /timeLabel:\s*formatNotificationTimeLabel\(row\.updatedAt \|\| row\.createdAt\)/)
|
||||
assert.match(topbar, /timeLabel:\s*formatNotificationTimeLabel\(item\.time \|\| item\.updatedAt \|\| item\.due\)/)
|
||||
assert.doesNotMatch(topbar, /class="notification-category-pill"/)
|
||||
assert.doesNotMatch(topbar, /class="notification-row-action"/)
|
||||
assert.doesNotMatch(topbar, /<time>\{\{ item\.time \}\}<\/time>/)
|
||||
assert.match(topbarStyles, /\.notification-row\s*\{[\s\S]*grid-template-columns:\s*36px minmax\(0,\s*1fr\);/)
|
||||
assert.match(topbarStyles, /\.notification-context\s*\{[\s\S]*-webkit-line-clamp:\s*2;/)
|
||||
assert.match(topbarStyles, /\.notification-row-foot\s*\{[\s\S]*justify-content:\s*space-between;/)
|
||||
assert.match(topbarStyles, /\.notification-row\s*\{[\s\S]*grid-template-columns:\s*52px minmax\(0,\s*1fr\);/)
|
||||
assert.match(topbarStyles, /\.notification-avatar\s*\{[\s\S]*border-radius:\s*999px;/)
|
||||
assert.match(topbarStyles, /\.notification-preview\s*\{[\s\S]*-webkit-line-clamp:\s*2;/)
|
||||
assert.match(topbarStyles, /\.notification-row-top\s*\{[\s\S]*justify-content:\s*space-between;/)
|
||||
assert.match(topbarStyles, /\.notification-time\s*\{[\s\S]*font-variant-numeric:\s*tabular-nums;/)
|
||||
assert.match(topbarStyles, /\.notification-row-action\s*\{[\s\S]*width:\s*28px;[\s\S]*height:\s*28px;/)
|
||||
assert.doesNotMatch(topbarStyles, /\.notification-category-pill/)
|
||||
assert.doesNotMatch(topbarStyles, /\.notification-row-action/)
|
||||
})
|
||||
|
||||
test('topbar notification popover does not render a top accent line', () => {
|
||||
assert.doesNotMatch(topbarStyles, /\.notification-popover::before/)
|
||||
assert.doesNotMatch(topbarStyles, /height:\s*2px;[\s\S]*background:\s*var\(--theme-primary-active\)/)
|
||||
})
|
||||
|
||||
test('topbar notification state is persisted through backend API with local fallback', () => {
|
||||
@@ -124,9 +151,20 @@ test('topbar notification state is persisted through backend API with local fall
|
||||
assert.match(topbarNotificationStates, /NOTIFICATION_HIDDEN_STORAGE_KEY/)
|
||||
assert.match(topbarNotificationStates, /applyRemoteStates/)
|
||||
assert.match(topbarNotificationStates, /markNotificationStateRead/)
|
||||
assert.match(topbarNotificationStates, /markNotificationStatesRead/)
|
||||
assert.match(topbarNotificationStates, /hideNotificationStates/)
|
||||
})
|
||||
|
||||
test('topbar notification bulk actions are wired to backend state API', () => {
|
||||
assert.match(topbar, /markNotificationStatesRead/)
|
||||
assert.match(topbar, /function markUnreadNotificationsRead\(\) \{[\s\S]*const currentItems = unreadNotifications\.value[\s\S]*markDocumentInboxRowsRead\(documentRows\)[\s\S]*markNotificationStatesRead\(currentItems\)/)
|
||||
assert.doesNotMatch(topbar, /function markUnreadNotificationsRead\(\) \{[\s\S]*currentItems\.forEach\(\(item\) => \{[\s\S]*markNotificationStateRead\(item\)/)
|
||||
assert.match(topbar, /function deleteReadNotifications\(\) \{[\s\S]*const currentItems = readNotifications\.value[\s\S]*hideNotificationStates\(currentItems\)/)
|
||||
assert.match(topbarNotificationStates, /function markNotificationStatesRead\(items\) \{[\s\S]*buildPatch\(item, \{ read: true, hidden: false \}\)[\s\S]*return syncNotificationPatches\(patches\)/)
|
||||
assert.match(topbarNotificationStates, /function hideNotificationStates\(items\) \{[\s\S]*buildPatch\(item, \{ read: true, hidden: true \}\)[\s\S]*return syncNotificationPatches\(patches\)/)
|
||||
assert.match(notificationStatesService, /apiRequest\('\/notification-states',\s*\{[\s\S]*method:\s*'POST'[\s\S]*body:\s*JSON\.stringify\(\{ states: batch \}\)/)
|
||||
})
|
||||
|
||||
test('document inbox reuses document center viewed-key state', () => {
|
||||
assert.match(documentInbox, /DOCUMENT_VIEWED_KEYS_CHANGE_EVENT/)
|
||||
assert.match(documentInbox, /fetchNotificationStates/)
|
||||
|
||||
@@ -40,6 +40,10 @@ const reviewPanelModelScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/travelReimbursementReviewPanelModel.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const createReviewModelScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/travelReimbursementCreateReviewModel.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const messageItemTemplate = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/travel/TravelReimbursementMessageItem.vue', import.meta.url)),
|
||||
'utf8'
|
||||
@@ -130,6 +134,13 @@ test('review drawer tools expose the default review tab before conditional docum
|
||||
)
|
||||
})
|
||||
|
||||
test('create review model remains a thin compatibility layer over review panel model', () => {
|
||||
assert.match(createReviewModelScript, /export \{[\s\S]*buildReviewFactCards[\s\S]*buildReviewRiskItems[\s\S]*\} from '\.\/travelReimbursementReviewPanelModel\.js'/)
|
||||
assert.doesNotMatch(createReviewModelScript, /function buildReviewFactCards/)
|
||||
assert.doesNotMatch(createReviewModelScript, /function buildReviewRiskItems/)
|
||||
assert.doesNotMatch(createReviewModelScript, /const REVIEW_RISK_LEVEL_META/)
|
||||
})
|
||||
|
||||
test('review drawer tool buttons switch modes instead of toggling the active mode closed', () => {
|
||||
assert.match(createViewScriptSurface, /const isReviewOverviewDrawer = computed\(\(\) => reviewDrawerMode\.value === REVIEW_DRAWER_MODE_REVIEW\)/)
|
||||
assert.match(createViewScriptSurface, /function switchReviewDrawerMode\(mode\) \{[\s\S]*if \(reviewDrawerMode\.value === mode\) \{[\s\S]*return[\s\S]*\}/)
|
||||
|
||||
52
web/tests/workbench-ai-application-gate-model.test.mjs
Normal file
52
web/tests/workbench-ai-application-gate-model.test.mjs
Normal file
@@ -0,0 +1,52 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
isOrphanInlineApplicationPreviewMessage,
|
||||
isReimbursementCreationIntent,
|
||||
resolveInlineApplicationPreviewTextAction,
|
||||
resolveLatestApplicationPreviewMessage,
|
||||
resolveLatestOrphanApplicationPreviewMessage
|
||||
} from '../src/composables/workbenchAiMode/workbenchAiApplicationGateModel.js'
|
||||
import {
|
||||
AI_APPLICATION_ACTION_SAVE_DRAFT,
|
||||
AI_APPLICATION_ACTION_SUBMIT
|
||||
} from '../src/services/aiApplicationPreviewActions.js'
|
||||
|
||||
test('workbench application gate detects reimbursement creation without catching policy questions', () => {
|
||||
assert.equal(isReimbursementCreationIntent('我要报销'), true)
|
||||
assert.equal(isReimbursementCreationIntent('帮我新建一笔报账'), true)
|
||||
assert.equal(isReimbursementCreationIntent('报销一下'), true)
|
||||
|
||||
assert.equal(isReimbursementCreationIntent('报销制度是什么'), false)
|
||||
assert.equal(isReimbursementCreationIntent('帮我查询报销进度'), false)
|
||||
assert.equal(isReimbursementCreationIntent('这张票能不能报销'), false)
|
||||
})
|
||||
|
||||
test('workbench application gate resolves save and submit text actions consistently', () => {
|
||||
assert.equal(resolveInlineApplicationPreviewTextAction('保存草稿'), AI_APPLICATION_ACTION_SAVE_DRAFT)
|
||||
assert.equal(resolveInlineApplicationPreviewTextAction(' 先保存 '), AI_APPLICATION_ACTION_SAVE_DRAFT)
|
||||
assert.equal(resolveInlineApplicationPreviewTextAction('确认提交'), AI_APPLICATION_ACTION_SUBMIT)
|
||||
assert.equal(resolveInlineApplicationPreviewTextAction('直接提交'), AI_APPLICATION_ACTION_SUBMIT)
|
||||
assert.equal(resolveInlineApplicationPreviewTextAction('继续修改'), '')
|
||||
})
|
||||
|
||||
test('workbench application gate resolves latest live or orphan preview message', () => {
|
||||
const messages = [
|
||||
{ id: 'user-1', role: 'user', content: '2月去上海出差' },
|
||||
{ id: 'assistant-orphan', role: 'assistant', content: '这是申请核对表,下方表格点击对应行即可直接编辑。' },
|
||||
{ id: 'assistant-other', role: 'assistant', content: '普通回复' }
|
||||
]
|
||||
|
||||
assert.equal(isOrphanInlineApplicationPreviewMessage(messages[1]), true)
|
||||
assert.equal(resolveLatestApplicationPreviewMessage(messages), null)
|
||||
assert.equal(resolveLatestOrphanApplicationPreviewMessage(messages)?.id, 'assistant-orphan')
|
||||
|
||||
messages.push({
|
||||
id: 'assistant-preview',
|
||||
role: 'assistant',
|
||||
content: '申请核对表',
|
||||
applicationPreview: { fields: { location: '上海' } }
|
||||
})
|
||||
assert.equal(resolveLatestApplicationPreviewMessage(messages)?.id, 'assistant-preview')
|
||||
})
|
||||
46
web/tests/workbench-ai-composer-components.test.mjs
Normal file
46
web/tests/workbench-ai-composer-components.test.mjs
Normal file
@@ -0,0 +1,46 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
function readSource(path) {
|
||||
return readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
|
||||
}
|
||||
|
||||
const aiModeComponent = readSource('../src/components/business/PersonalWorkbenchAiMode.vue')
|
||||
const aiModeTemplate = readSource('../src/components/business/PersonalWorkbenchAiMode.template.html')
|
||||
const composerComponent = readSource('../src/components/business/workbench-ai/WorkbenchAiComposer.vue')
|
||||
const fileStripComponent = readSource('../src/components/business/workbench-ai/WorkbenchAiFileStrip.vue')
|
||||
const aiModeRuntime = readSource('../src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js')
|
||||
|
||||
function countOccurrences(source, pattern) {
|
||||
return source.match(pattern)?.length || 0
|
||||
}
|
||||
|
||||
test('personal workbench AI mode reuses shared composer and file strip components', () => {
|
||||
assert.match(aiModeComponent, /import \{ proxyRefs \} from 'vue'/)
|
||||
assert.match(aiModeComponent, /import WorkbenchAiComposer from '\.\/workbench-ai\/WorkbenchAiComposer\.vue'/)
|
||||
assert.match(aiModeComponent, /import WorkbenchAiFileStrip from '\.\/workbench-ai\/WorkbenchAiFileStrip\.vue'/)
|
||||
assert.match(aiModeComponent, /const aiModeRuntime = usePersonalWorkbenchAiMode\(props, emit\)/)
|
||||
assert.match(aiModeComponent, /const workbenchAiRuntime = proxyRefs\(aiModeRuntime\)/)
|
||||
|
||||
assert.equal(countOccurrences(aiModeTemplate, /<WorkbenchAiComposer\b/g), 2)
|
||||
assert.equal(countOccurrences(aiModeTemplate, /<WorkbenchAiFileStrip\b/g), 2)
|
||||
assert.doesNotMatch(aiModeTemplate, /<form class="workbench-ai-composer"/)
|
||||
assert.doesNotMatch(aiModeTemplate, /<article v-for="file in selectedFileCards"/)
|
||||
})
|
||||
|
||||
test('shared workbench composer keeps the parent input focus ref writable', () => {
|
||||
assert.match(composerComponent, /:ref="runtime\.setAssistantInputRef"/)
|
||||
assert.match(aiModeRuntime, /function setAssistantInputRef\(element\)/)
|
||||
assert.match(aiModeRuntime, /assistantInputRef\.value = element/)
|
||||
assert.match(aiModeRuntime, /setAssistantInputRef,/)
|
||||
})
|
||||
|
||||
test('shared workbench file strip preserves OCR status badges', () => {
|
||||
assert.match(fileStripComponent, /file\.ocrState\?\.label/)
|
||||
assert.match(fileStripComponent, /class="workbench-ai-file-card__ocr"/)
|
||||
assert.match(fileStripComponent, /file\.ocrState\.status === 'recognizing'/)
|
||||
assert.match(fileStripComponent, /mdi mdi-text-recognition/)
|
||||
assert.match(fileStripComponent, /:title="file\.ocrState\.title \|\| file\.ocrState\.label"/)
|
||||
})
|
||||
@@ -156,13 +156,15 @@ const appShell = readSource('../src/views/AppShellRouteView.vue')
|
||||
const workbenchView = readSource('../src/views/PersonalWorkbenchView.vue')
|
||||
const aiMode = readSource('../src/components/business/PersonalWorkbenchAiMode.vue')
|
||||
const aiModeTemplate = readSource('../src/components/business/PersonalWorkbenchAiMode.template.html')
|
||||
const aiModeComposer = readSource('../src/components/business/workbench-ai/WorkbenchAiComposer.vue')
|
||||
const aiModeFileStrip = readSource('../src/components/business/workbench-ai/WorkbenchAiFileStrip.vue')
|
||||
const aiModeRuntimeDir = fileURLToPath(new URL('../src/composables/workbenchAiMode/', import.meta.url))
|
||||
const aiModeRuntime = readdirSync(aiModeRuntimeDir)
|
||||
.filter((file) => file.endsWith('.js'))
|
||||
.sort()
|
||||
.map((file) => readFileSync(new URL(`../src/composables/workbenchAiMode/${file}`, import.meta.url), 'utf8'))
|
||||
.join('\n')
|
||||
const aiModeSurface = `${aiMode}\n${aiModeTemplate}\n${aiModeRuntime}`
|
||||
const aiModeSurface = `${aiMode}\n${aiModeTemplate}\n${aiModeComposer}\n${aiModeFileStrip}\n${aiModeRuntime}`
|
||||
const aiModeStyles = readSource('../src/assets/styles/components/personal-workbench-ai-mode.css')
|
||||
const workbenchViewStyles = readSource('../src/assets/styles/views/personal-workbench-view.css')
|
||||
const appStyles = readSource('../src/assets/styles/app.css')
|
||||
@@ -228,7 +230,7 @@ test('AI mode screen follows the approved reference structure', () => {
|
||||
assert.match(aiModeSurface, /费用测算中,请稍等/)
|
||||
assert.match(aiModeSurface, /rows="3"/)
|
||||
assert.match(aiModeSurface, /workbench-ai-composer-toolbar/)
|
||||
assert.match(aiModeSurface, /<article v-for="file in selectedFileCards"[\s\S]*class="workbench-ai-file-card"/)
|
||||
assert.match(aiModeSurface, /<article v-for="file in runtime\.selectedFileCards"[\s\S]*class="workbench-ai-file-card"/)
|
||||
assert.match(aiModeSurface, /class="workbench-ai-file-card__ocr"/)
|
||||
assert.match(aiModeSurface, /file\.ocrState\?\.label/)
|
||||
assert.match(aiModeSurface, /mdi mdi-text-recognition/)
|
||||
@@ -301,8 +303,9 @@ test('AI mode screen follows the approved reference structure', () => {
|
||||
assert.match(aiModeSurface, /解释制度/)
|
||||
assert.match(aiModeSurface, /催办审批/)
|
||||
assert.match(aiModeSurface, /<Transition name="workbench-ai-panel-swap" mode="out-in" appear>/)
|
||||
assert.match(aiModeSurface, /@submit\.prevent="submitAiModePrompt"/)
|
||||
assert.equal((aiModeSurface.match(/type="submit"[\s\S]{0,160}class="workbench-ai-send-btn"/g) || []).length, 2)
|
||||
assert.match(aiModeSurface, /@submit\.prevent="runtime\.submitAiModePrompt"/)
|
||||
assert.equal((aiModeSurface.match(/<WorkbenchAiComposer\b/g) || []).length, 2)
|
||||
assert.equal((aiModeSurface.match(/type="submit"[\s\S]{0,160}class="workbench-ai-send-btn"/g) || []).length, 1)
|
||||
assert.match(aiModeSurface, /class="workbench-ai-conversation"/)
|
||||
assert.match(aiModeSurface, /class="workbench-ai-thread"[\s\S]*@scroll\.passive="handleInlineConversationScroll"/)
|
||||
assert.match(aiModeSurface, /workbench-ai-answer-card/)
|
||||
@@ -393,6 +396,14 @@ test('AI mode screen follows the approved reference structure', () => {
|
||||
aiModeSurface,
|
||||
/suggestedActions:\s*buildInlineApplicationPreviewSuggestedActions\([\s\S]*targetMessage\.applicationPreview/
|
||||
)
|
||||
assert.match(aiModeSurface, /function isOrphanInlineApplicationPreviewMessage\(message = \{\}\)/)
|
||||
assert.match(aiModeSurface, /function resolveLatestOrphanApplicationPreviewMessage\(messages = \[\]\)/)
|
||||
assert.match(aiModeSurface, /function resolveLatestOrphanInlineApplicationPreviewMessage\(\)/)
|
||||
assert.match(aiModeSurface, /当前申请核对表状态不完整,我先重新生成可编辑表格。/)
|
||||
assert.match(
|
||||
aiModeSurface,
|
||||
/const previewSourceText = resolveLatestInlineUserPrompt\(\)[\s\S]*pushInlineApplicationActionUserMessage\(prompt\)[\s\S]*startAiApplicationPreview\('travel', '差旅费', previewSourceText/
|
||||
)
|
||||
assert.doesNotMatch(aiModeSurface, /\*\*申请单号:\*\*/)
|
||||
assert.doesNotMatch(aiModeSurface, /createInlineMessage\('assistant', buildStewardPlanMessageText\(plan\)/)
|
||||
assert.doesNotMatch(aiModeSurface, /runOrchestrator\(/)
|
||||
|
||||
@@ -314,7 +314,10 @@ test('linked application selection can create reimbursement draft from associati
|
||||
})
|
||||
|
||||
test('personal workbench routes reimbursement creation intent to association gate before steward', () => {
|
||||
assert.match(personalWorkbenchAiMode, /function isReimbursementCreationIntent\(prompt = ''\)/)
|
||||
assert.match(
|
||||
personalWorkbenchAiMode,
|
||||
/import \{ isReimbursementCreationIntent \} from '\.\/workbenchAiApplicationGateModel\.js'/
|
||||
)
|
||||
const startConversationIndex = personalWorkbenchAiMode.indexOf('function startInlineConversation')
|
||||
const gateIndex = personalWorkbenchAiMode.indexOf('expenseFlow.startAiReimbursementAssociationGate(cleanPrompt', startConversationIndex)
|
||||
const stewardIndex = personalWorkbenchAiMode.indexOf('stewardFlow.requestInlineAssistantReply(cleanPrompt', startConversationIndex)
|
||||
|
||||
Reference in New Issue
Block a user