refactor: consolidate finance workflow modules

This commit is contained in:
caoxiaozhu
2026-06-23 11:21:18 +08:00
parent 1f40ce3df3
commit 73966b3a7b
52 changed files with 3468 additions and 2865 deletions

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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>

View File

@@ -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"

View File

@@ -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
}))
)

View File

@@ -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: '分析看板' },

View File

@@ -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) {

View 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>

View 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>

View File

@@ -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) {

View File

@@ -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
}
}

View File

@@ -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,

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -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 = '费用申请') {

View 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
}

View File

@@ -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' }
)
}

View 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)
}

View File

@@ -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

View File

@@ -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) : ''
}

View File

@@ -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>

View 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)
}

View File

@@ -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'

View File

@@ -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;/)

View File

@@ -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'/)

View 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```/)
})

View 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/)
})

View File

@@ -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,

View 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')
}

View File

@@ -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/)

View File

@@ -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]*\}/)

View 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')
})

View 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"/)
})

View File

@@ -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\(/)

View File

@@ -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)