feat: 完善文档中心与报销申请交互及侧边栏重构

后端优化编排器报销查询和本体检测精度,增强报销单草稿保
存和附件回填逻辑,前端重构侧边栏组件支持折叠和图标导
航,完善文档中心状态筛选和详情提示,报销创建和审批详情
页优化会话管理和费用明细交互,新增助手应用服务和预设动
作工具函数,补充单元测试覆盖。
This commit is contained in:
caoxiaozhu
2026-05-25 13:35:39 +08:00
parent 50b1c3f9a9
commit d0e946cf47
59 changed files with 5117 additions and 416 deletions

View File

@@ -15,13 +15,43 @@
}
.app {
--sidebar-expanded-width: 220px;
--sidebar-collapsed-width: 64px;
--sidebar-motion: 320ms cubic-bezier(0.22, 1, 0.36, 1);
height: var(--desktop-stage-height, 100dvh);
min-height: var(--desktop-stage-height, 100dvh);
display: grid;
grid-template-columns: 220px minmax(0, 1fr);
display: flex;
align-items: stretch;
background: var(--bg);
}
.app-sidebar {
flex: 0 0 auto;
width: var(--sidebar-expanded-width);
min-width: 0;
overflow: hidden;
will-change: width;
transition: width var(--sidebar-motion);
}
.app.sidebar-collapsed .app-sidebar {
width: var(--sidebar-collapsed-width);
overflow: visible;
position: relative;
z-index: 200;
}
.app.sidebar-collapsed > .main {
position: relative;
z-index: 1;
}
.app > .main {
flex: 1 1 auto;
min-width: 0;
}
.boot-state {
min-height: var(--desktop-stage-height, 100dvh);
display: grid;
@@ -133,9 +163,28 @@
}
@media (max-width: 1180px) {
.app { grid-template-columns: 220px minmax(0, 1fr); }
.app-sidebar {
width: var(--sidebar-expanded-width);
}
.app.sidebar-collapsed .app-sidebar {
width: var(--sidebar-collapsed-width);
}
}
@media (max-width: 760px) {
.app { display: block; }
.app {
display: block;
}
.app-sidebar {
width: 100%;
transition: none;
}
.workarea { padding: 18px 16px 28px; }
}
@media (prefers-reduced-motion: reduce) {
.app-sidebar {
transition: none;
}
}

View File

@@ -481,6 +481,33 @@ tbody tr:last-child td {
font-weight: 800;
}
.new-document-badge {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
min-width: 30px;
height: 17px;
margin-right: 6px;
padding: 0 5px;
border: 1px solid #fecaca;
border-radius: 999px;
background: #fff5f5;
color: #dc2626;
font-size: 10px;
font-weight: 900;
line-height: 1;
letter-spacing: .2px;
}
.new-document-badge::before {
content: "";
width: 5px;
height: 5px;
border-radius: 999px;
background: #ef4444;
}
.doc-kind-tag,
.type-tag,
.status-tag {

View File

@@ -567,6 +567,17 @@
color: #059669;
}
.shortcut-chip.active {
border-color: rgba(5, 150, 105, 0.38);
background: rgba(16, 185, 129, 0.1);
color: #047857;
box-shadow: none;
}
.shortcut-chip.active i {
color: #047857;
}
.shortcut-chip:disabled {
opacity: 0.48;
cursor: not-allowed;

View File

@@ -14,8 +14,8 @@
</div>
<div class="assistant-copy">
<h3>{{ assistantGreetingName }}描述费用或上传票据AI 直接帮你判断怎么报</h3>
<p>自动识别报销类别核对附件完整性并生成可继续提交的报销草稿</p>
<h3>{{ assistantGreetingName }}描述您想做的事AI 直接帮您处理</h3>
<p>我会自动识别您的意图协助完成费用申请报销查询和制度问答等业务工作耐心把事情推进到可执行的下一步</p>
<div class="assistant-input">
<input

View File

@@ -1,5 +1,5 @@
<template>
<aside class="rail" aria-label="主导航">
<aside class="rail" :class="{ 'rail-collapsed': collapsed }" aria-label="主导航">
<div class="rail-brand">
<div class="brand-mark" aria-hidden="true">
<img v-if="companyLogo" :src="companyLogo" alt="System Logo" class="custom-logo" />
@@ -9,6 +9,16 @@
</svg>
</div>
<strong class="brand-name">{{ displayCompanyName }}</strong>
<button
class="rail-collapse-btn"
type="button"
:aria-label="collapsed ? '展开侧边栏' : '折叠侧边栏'"
:title="collapsed ? '展开侧边栏' : '折叠侧边栏'"
:aria-expanded="!collapsed"
@click="emit('toggle-collapse')"
>
<i :class="collapsed ? 'mdi mdi-chevron-right' : 'mdi mdi-chevron-left'"></i>
</button>
</div>
<nav class="rail-nav" aria-label="功能导航">
@@ -18,6 +28,7 @@
class="nav-btn"
:class="{ active: activeView === item.id }"
type="button"
:title="collapsed ? item.displayLabel : undefined"
@click="emit('navigate', item.id)"
>
<span class="nav-icon" v-html="item.icon"></span>
@@ -26,15 +37,38 @@
</button>
</nav>
<div class="rail-user">
<div class="user-menu" role="menu" aria-label="用户菜单">
<div
class="rail-user"
@mouseenter="openCollapsedUserMenu"
@mouseleave="closeCollapsedUserMenu"
@focusin="openCollapsedUserMenu"
@focusout="handleUserFocusOut"
>
<div v-if="!collapsed" class="user-menu" role="menu" aria-label="用户菜单">
<button class="user-menu-item" type="button" @click="emit('logout')">
<i class="mdi mdi-logout-variant"></i>
<span>退出系统</span>
</button>
</div>
<div class="user-summary" tabindex="0" aria-label="用户信息">
<Teleport to="body">
<div
v-if="collapsed && userMenuOpen"
class="rail-user-menu-floating"
:style="userMenuStyle"
role="menu"
aria-label="用户菜单"
@mouseenter="clearUserMenuCloseTimer"
@mouseleave="closeCollapsedUserMenu"
>
<button class="user-menu-item" type="button" @click="handleLogout">
<i class="mdi mdi-logout-variant"></i>
<span>退出系统</span>
</button>
</div>
</Teleport>
<div class="user-summary" tabindex="0" aria-label="用户信息" :title="collapsed ? displayUser.name : undefined">
<span class="user-avatar">{{ displayUser.avatar }}</span>
<span class="user-copy">
<strong>{{ displayUser.name }}</strong>
@@ -47,7 +81,7 @@
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted } from 'vue'
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useApprovalInbox } from '../../composables/useApprovalInbox.js'
@@ -69,10 +103,14 @@ const props = defineProps({
role: '管理员',
avatar: '管'
})
},
collapsed: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['navigate', 'openChat', 'logout'])
const emit = defineEmits(['navigate', 'openChat', 'logout', 'toggle-collapse'])
const {
badgeLabel: approvalBadgeLabel,
@@ -108,9 +146,6 @@ onMounted(() => {
startApprovalInboxPolling()
})
onBeforeUnmount(() => {
stopApprovalInboxPolling()
})
const displayUser = computed(() => ({
name: props.currentUser?.name || '系统管理员',
@@ -119,33 +154,133 @@ const displayUser = computed(() => ({
}))
const displayCompanyName = computed(() => props.companyName || 'X-Financial')
const userMenuOpen = ref(false)
let userMenuCloseTimer = null
const userMenuPosition = reactive({
top: 0,
left: 0
})
const userMenuStyle = computed(() => ({
top: `${userMenuPosition.top}px`,
left: `${userMenuPosition.left}px`
}))
function resolveUserMenuAnchor(element) {
return element?.querySelector?.('.user-summary') || element
}
function clearUserMenuCloseTimer() {
if (userMenuCloseTimer) {
clearTimeout(userMenuCloseTimer)
userMenuCloseTimer = null
}
}
function openCollapsedUserMenu(event) {
if (!props.collapsed) {
return
}
clearUserMenuCloseTimer()
const anchor = resolveUserMenuAnchor(event?.currentTarget)
if (!anchor?.getBoundingClientRect) {
return
}
const rect = anchor.getBoundingClientRect()
userMenuPosition.top = rect.top + rect.height / 2
userMenuPosition.left = rect.right + 12
userMenuOpen.value = true
}
function closeCollapsedUserMenu() {
clearUserMenuCloseTimer()
userMenuCloseTimer = setTimeout(() => {
userMenuOpen.value = false
userMenuCloseTimer = null
}, 120)
}
function closeCollapsedUserMenuNow() {
clearUserMenuCloseTimer()
userMenuOpen.value = false
}
function handleUserFocusOut(event) {
if (!props.collapsed) {
return
}
const container = event.currentTarget
const nextTarget = event.relatedTarget
if (nextTarget && container?.contains(nextTarget)) {
return
}
closeCollapsedUserMenuNow()
}
function handleLogout() {
closeCollapsedUserMenuNow()
emit('logout')
}
watch(
() => props.collapsed,
(isCollapsed) => {
if (!isCollapsed) {
closeCollapsedUserMenuNow()
}
}
)
onBeforeUnmount(() => {
stopApprovalInboxPolling()
closeCollapsedUserMenuNow()
})
</script>
<style scoped>
.rail {
--rail-motion-duration: 320ms;
--rail-motion-ease: cubic-bezier(0.22, 1, 0.36, 1);
--rail-fade-duration: 160ms;
position: sticky;
top: 0;
width: 100%;
height: var(--desktop-stage-height, 100dvh);
display: grid;
grid-template-rows: auto 1fr auto;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 251, 250, 0.96)),
#fff;
min-height: var(--desktop-stage-height, 100dvh);
min-width: 0;
display: flex;
flex-direction: column;
overflow: hidden;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 251, 250, 0.96)), #fff;
border-right: 1px solid #dbe4ee;
box-shadow: 1px 0 0 rgba(15, 23, 42, 0.02);
z-index: 20;
}
.rail-brand {
min-height: 86px;
position: relative;
min-height: 92px;
display: flex;
align-items: center;
justify-content: center;
justify-content: flex-start;
gap: 12px;
padding: 22px 20px 18px;
padding: 22px 16px 18px;
overflow: hidden;
transition:
padding var(--rail-motion-duration) var(--rail-motion-ease),
gap var(--rail-motion-duration) var(--rail-motion-ease);
}
.brand-mark {
flex: 0 0 auto;
width: 30px;
height: 30px;
display: grid;
@@ -153,6 +288,10 @@ const displayCompanyName = computed(() => props.companyName || 'X-Financial')
color: #07936f;
border-radius: 6px;
overflow: hidden;
transition:
width var(--rail-motion-duration) var(--rail-motion-ease),
height var(--rail-motion-duration) var(--rail-motion-ease),
margin var(--rail-motion-duration) var(--rail-motion-ease);
}
.custom-logo {
@@ -168,26 +307,74 @@ const displayCompanyName = computed(() => props.companyName || 'X-Financial')
}
.brand-name {
flex: 1 1 auto;
min-width: 0;
max-width: 124px;
color: #0f172a;
font-size: 16px;
font-weight: 800;
letter-spacing: 0;
white-space: nowrap;
overflow: hidden;
transition:
max-width var(--rail-motion-duration) var(--rail-motion-ease),
opacity var(--rail-fade-duration) var(--rail-motion-ease),
transform var(--rail-fade-duration) var(--rail-motion-ease);
}
.rail-collapse-btn {
position: absolute;
right: 16px;
top: 31px;
z-index: 2;
width: 30px;
height: 30px;
display: inline-grid;
place-items: center;
cursor: pointer;
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 9px;
background: rgba(255, 255, 255, 0.86);
color: #64748b;
box-shadow: 0 6px 14px rgba(15, 23, 42, 0.06);
transition:
top var(--rail-motion-duration) var(--rail-motion-ease),
right var(--rail-motion-duration) var(--rail-motion-ease),
transform var(--rail-motion-duration) var(--rail-motion-ease),
background 180ms var(--ease),
border-color 180ms var(--ease),
color 180ms var(--ease),
box-shadow 180ms var(--ease);
}
.rail-collapse-btn:hover {
border-color: rgba(16, 185, 129, 0.28);
background: #ecfdf5;
color: #059669;
}
.rail-collapse-btn .mdi {
font-size: 18px;
line-height: 1;
}
.rail-nav {
display: grid;
align-content: start;
gap: 14px;
display: flex;
flex-direction: column;
gap: 8px;
padding: 16px 20px;
overflow-y: auto;
overflow-x: hidden;
flex: 1;
transition:
padding var(--rail-motion-duration) var(--rail-motion-ease),
gap var(--rail-motion-duration) var(--rail-motion-ease);
}
.nav-btn {
width: 100%;
min-height: 48px;
display: grid;
grid-template-columns: 28px minmax(0, 1fr) auto;
position: relative;
display: flex;
align-items: center;
gap: 12px;
padding: 0 12px;
@@ -196,11 +383,13 @@ const displayCompanyName = computed(() => props.companyName || 'X-Financial')
background: transparent;
color: #64748b;
text-align: left;
overflow: hidden;
transition:
padding var(--rail-motion-duration) var(--rail-motion-ease),
gap var(--rail-motion-duration) var(--rail-motion-ease),
background 180ms var(--ease),
border-color 180ms var(--ease),
color 180ms var(--ease),
box-shadow 180ms var(--ease);
color 180ms var(--ease);
}
.nav-btn:hover {
@@ -215,12 +404,17 @@ const displayCompanyName = computed(() => props.companyName || 'X-Financial')
}
.nav-icon {
flex: 0 0 28px;
width: 28px;
height: 28px;
display: grid;
place-items: center;
border-radius: 7px;
color: currentColor;
transition:
flex-basis var(--rail-motion-duration) var(--rail-motion-ease),
width var(--rail-motion-duration) var(--rail-motion-ease),
height var(--rail-motion-duration) var(--rail-motion-ease);
}
.nav-btn :deep(svg) {
@@ -234,17 +428,23 @@ const displayCompanyName = computed(() => props.companyName || 'X-Financial')
}
.nav-label {
flex: 1;
min-width: 0;
max-width: 128px;
color: currentColor;
font-size: 14px;
font-weight: 700;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
opacity: 1;
transition:
max-width var(--rail-motion-duration) var(--rail-motion-ease),
opacity var(--rail-fade-duration) var(--rail-motion-ease),
transform var(--rail-fade-duration) var(--rail-motion-ease);
}
.nav-badge {
flex: 0 0 auto;
min-width: 34px;
height: 22px;
display: inline-flex;
@@ -256,7 +456,11 @@ const displayCompanyName = computed(() => props.companyName || 'X-Financial')
color: #fff;
font-size: 12px;
font-weight: 800;
line-height: 1;
transition:
min-width var(--rail-motion-duration) var(--rail-motion-ease),
max-width var(--rail-motion-duration) var(--rail-motion-ease),
padding var(--rail-motion-duration) var(--rail-motion-ease),
opacity var(--rail-fade-duration) var(--rail-motion-ease);
}
.rail-user {
@@ -266,28 +470,32 @@ const displayCompanyName = computed(() => props.companyName || 'X-Financial')
margin: 0;
padding: 16px 20px 18px;
border-top: 1px solid #edf2f7;
transition: padding var(--rail-motion-duration) var(--rail-motion-ease);
}
.user-summary {
position: relative;
min-width: 0;
min-height: 42px;
display: grid;
grid-template-columns: 38px minmax(0, 1fr) 18px;
display: flex;
align-items: center;
gap: 10px;
padding: 4px 0 0;
padding: 4px;
color: #64748b;
border-radius: 12px;
outline: none;
transition: background 180ms var(--ease);
cursor: pointer;
transition:
gap var(--rail-motion-duration) var(--rail-motion-ease),
padding var(--rail-motion-duration) var(--rail-motion-ease),
background 180ms var(--ease);
}
.rail-user:hover .user-summary,
.rail-user:focus-within .user-summary {
.rail-user:hover .user-summary {
background: rgba(255, 255, 255, 0.72);
}
.user-avatar {
flex: 0 0 36px;
width: 36px;
height: 36px;
display: grid;
@@ -299,19 +507,30 @@ const displayCompanyName = computed(() => props.companyName || 'X-Financial')
color: #fff;
font-size: 14px;
font-weight: 800;
transition:
flex-basis var(--rail-motion-duration) var(--rail-motion-ease),
width var(--rail-motion-duration) var(--rail-motion-ease),
height var(--rail-motion-duration) var(--rail-motion-ease);
}
.user-copy {
flex: 1;
min-width: 0;
display: grid;
max-width: 116px;
display: flex;
flex-direction: column;
gap: 2px;
opacity: 1;
transition:
max-width var(--rail-motion-duration) var(--rail-motion-ease),
opacity var(--rail-fade-duration) var(--rail-motion-ease),
transform var(--rail-fade-duration) var(--rail-motion-ease);
}
.user-copy strong {
color: #334155;
font-size: 14px;
font-weight: 750;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -320,24 +539,17 @@ const displayCompanyName = computed(() => props.companyName || 'X-Financial')
.user-copy span {
color: #64748b;
font-size: 12px;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user-summary .mdi {
justify-self: end;
color: #94a3b8;
font-size: 13px;
line-height: 1;
transition: transform 180ms var(--ease), color 180ms var(--ease);
}
.rail-user:hover .user-summary .mdi,
.rail-user:focus-within .user-summary .mdi {
color: #0f9f78;
transform: translateY(-1px);
flex: 0 0 18px;
font-size: 18px;
transition:
max-width var(--rail-motion-duration) var(--rail-motion-ease),
opacity var(--rail-fade-duration) var(--rail-motion-ease);
}
.user-menu {
@@ -349,34 +561,15 @@ const displayCompanyName = computed(() => props.companyName || 'X-Financial')
border: 1px solid rgba(226, 232, 240, 0.96);
border-radius: 12px;
background: rgba(255, 255, 255, 0.98);
box-shadow:
0 16px 32px rgba(15, 23, 42, 0.1),
0 2px 8px rgba(15, 23, 42, 0.04);
box-shadow: 0 16px 32px rgba(15, 23, 42, 0.1);
opacity: 0;
transform: translateY(8px);
pointer-events: none;
transition:
opacity 180ms var(--ease),
transform 180ms var(--ease),
box-shadow 180ms var(--ease);
transition: all 180ms var(--ease);
z-index: 4;
}
.user-menu::after {
content: "";
position: absolute;
right: 18px;
bottom: -6px;
width: 12px;
height: 12px;
border-right: 1px solid rgba(226, 232, 240, 0.96);
border-bottom: 1px solid rgba(226, 232, 240, 0.96);
background: rgba(255, 255, 255, 0.98);
transform: rotate(45deg);
}
.rail-user:hover .user-menu,
.rail-user:focus-within .user-menu {
.rail-user:hover .user-menu {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
@@ -385,9 +578,8 @@ const displayCompanyName = computed(() => props.companyName || 'X-Financial')
.user-menu-item {
width: 100%;
height: 38px;
display: inline-flex;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 8px;
padding: 0 12px;
border: 0;
@@ -396,18 +588,138 @@ const displayCompanyName = computed(() => props.companyName || 'X-Financial')
color: #dc2626;
font-size: 13px;
font-weight: 700;
text-align: left;
transition: background 180ms var(--ease), color 180ms var(--ease);
transition: all 180ms var(--ease);
}
.user-menu-item:hover {
background: #fff5f5;
color: #b91c1c;
/* ========================================= */
/* COLLAPSED STATE */
/* ========================================= */
.rail-collapsed .rail-brand {
flex-direction: column;
align-items: center;
justify-content: flex-start;
gap: 8px;
min-height: 0;
padding: 24px 8px 14px;
}
.user-menu-item .mdi {
font-size: 15px;
line-height: 1;
.rail-collapsed .brand-mark {
width: 36px;
height: 36px;
margin: 0;
}
.rail-collapsed .brand-mark svg {
width: 34px;
height: 34px;
}
.rail-collapsed .brand-name {
position: absolute;
width: 1px;
height: 1px;
max-width: 0;
margin: 0;
opacity: 0;
overflow: hidden;
clip: rect(0 0 0 0);
pointer-events: none;
}
.rail-collapsed .rail-collapse-btn {
position: static;
top: auto;
right: auto;
align-self: center;
width: 36px;
height: 32px;
transform: none;
}
.rail-collapsed .rail-nav {
gap: 10px;
padding: 12px 8px;
}
.rail-collapsed .nav-btn {
justify-content: center;
padding: 0;
gap: 0;
}
.rail-collapsed .nav-icon {
width: 32px;
height: 32px;
flex: 0 0 32px;
}
.rail-collapsed .nav-label {
max-width: 0;
opacity: 0;
transform: translateX(-6px);
}
.rail-collapsed .nav-badge {
max-width: 0;
min-width: 0;
padding: 0;
opacity: 0;
overflow: hidden;
}
.rail-collapsed {
overflow: visible;
}
.rail-collapsed .rail-user {
position: relative;
z-index: 6;
padding: 14px 8px;
overflow: visible;
}
.rail-collapsed .user-summary {
justify-content: center;
padding: 4px;
gap: 0;
}
.rail-user-menu-floating {
position: fixed;
z-index: 12000;
min-width: 132px;
padding: 8px;
border: 1px solid rgba(226, 232, 240, 0.96);
border-radius: 12px;
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 16px 32px rgba(15, 23, 42, 0.14);
transform: translateY(-50%);
animation: railUserMenuIn 180ms var(--rail-motion-ease) both;
}
.rail-user-menu-floating .user-menu-item {
width: 100%;
}
@keyframes railUserMenuIn {
from {
opacity: 0;
transform: translateY(-50%) translateX(-6px);
}
to {
opacity: 1;
transform: translateY(-50%) translateX(0);
}
}
.rail-collapsed .user-copy,
.rail-collapsed .user-summary .mdi {
max-width: 0;
opacity: 0;
overflow: hidden;
transform: translateX(-6px);
}
@media (max-width: 980px) {
@@ -417,30 +729,11 @@ const displayCompanyName = computed(() => props.companyName || 'X-Financial')
}
}
@media (max-width: 760px) {
.rail {
border-right: 0;
border-bottom: 1px solid #dbe4ee;
}
.rail-brand {
min-height: 68px;
padding: 16px;
}
.rail-nav {
display: flex;
gap: 10px;
padding: 8px 16px 16px;
overflow-x: auto;
}
.nav-btn {
min-width: 148px;
}
.rail-user {
display: none;
@media (prefers-reduced-motion: reduce) {
.rail *,
.rail *::before,
.rail *::after {
transition: none !important;
}
}
</style>

View File

@@ -12,7 +12,9 @@ import { buildDetailAlerts } from '../utils/detailAlerts.js'
import { normalizeRequestForUi } from '../utils/requestViewModel.js'
import { buildWorkbenchSummary } from '../utils/workbenchSummary.js'
const SESSION_TYPE_EXPENSE = 'expense'
const SESSION_TYPE_EXPENSE = 'expense'
const SMART_ENTRY_SOURCE_APPLICATION = 'application'
const SMART_ENTRY_SOURCE_REIMBURSEMENT = 'topbar'
export function useAppShell() {
const route = useRoute()
@@ -111,13 +113,18 @@ export function useAppShell() {
buildWorkbenchSummary(requests.value, currentUser.value)
)
const topBarView = computed(() => {
if (detailMode.value) {
return {
title: '报销单详情',
desc: '查看报销明细、票据材料、审批进度与风险提示。'
}
}
const topBarView = computed(() => {
if (detailMode.value) {
const request = selectedRequest.value || {}
const claimNo = request.claimNo || request.claim_no || request.documentNo || request.id || ''
const isApplicationDocument = isApplicationDocumentPayload(request, claimNo)
return {
title: isApplicationDocument ? '申请单详情' : '报销单详情',
desc: isApplicationDocument
? '查看申请信息、预计金额、审批进度与预算管理口径。'
: '查看报销明细、票据材料、审批进度与风险提示。'
}
}
if (logDetailMode.value) {
return {
@@ -170,18 +177,26 @@ export function useAppShell() {
setView(view)
}
function openTravelCreate() {
smartEntryOpen.value = true
smartEntryContext.value = {
prompt: '',
source: 'topbar',
request: null,
files: [],
conversation: null,
scope: null
}
smartEntrySessionId.value += 1
}
function openFinancialAssistantCreate(source) {
smartEntryOpen.value = true
smartEntryContext.value = {
prompt: '',
source,
request: null,
files: [],
conversation: null,
scope: null
}
smartEntrySessionId.value += 1
}
function openTravelCreate() {
openFinancialAssistantCreate(SMART_ENTRY_SOURCE_REIMBURSEMENT)
}
function openExpenseApplicationCreate() {
openFinancialAssistantCreate(SMART_ENTRY_SOURCE_APPLICATION)
}
function resolveCurrentUserId() {
const user = currentUser.value || {}
@@ -204,9 +219,27 @@ export function useAppShell() {
return { type: 'claim', claimId }
}
function isDetailClaimScopedPayload(payload = {}) {
return String(payload.source || '').trim() === 'detail' && Boolean(resolveSmartEntryClaimScope(payload))
}
function isDetailClaimScopedPayload(payload = {}) {
return String(payload.source || '').trim() === 'detail' && Boolean(resolveSmartEntryClaimScope(payload))
}
function isApplicationDocumentPayload(payload = {}, claimNo = '') {
const documentType = String(
payload.documentType
|| payload.document_type
|| payload.documentTypeCode
|| payload.document_type_code
|| payload.draftType
|| payload.draft_type
|| ''
).trim()
const normalizedClaimNo = String(claimNo || payload.claimNo || payload.claim_no || '').trim().toUpperCase()
return (
documentType === 'application'
|| documentType === 'expense_application'
|| normalizedClaimNo.startsWith('APP-')
)
}
async function resolveSmartEntryConversation(payload = {}) {
if (payload.conversation) {
@@ -254,19 +287,28 @@ export function useAppShell() {
}
async function handleDraftSaved(payload = {}) {
const claimNo = String(payload.claimNo || payload.claim_no || '').trim()
const status = String(payload.status || payload.claimStatus || '').trim()
const approvalStage = String(payload.approvalStage || payload.approval_stage || '').trim()
await reloadRequests()
if (status === 'submitted') {
smartEntryOpen.value = false
void refreshApprovalInbox()
toast(`${claimNo || '该'}单据已完成 AI预审${approvalStage ? `,当前节点:${approvalStage}` : ',并已提交审批'}`)
router.push({ name: activeView.value === 'documents' ? 'app-documents' : 'app-requests' })
return
}
toast(`${claimNo || '该'}单据已保存为草稿,可继续上传票据或补充信息`)
}
const claimNo = String(payload.claimNo || payload.claim_no || '').trim()
const status = String(payload.status || payload.claimStatus || '').trim()
const approvalStage = String(payload.approvalStage || payload.approval_stage || '').trim()
const isApplicationDocument = isApplicationDocumentPayload(payload, claimNo)
await reloadRequests()
if (status === 'submitted') {
smartEntryOpen.value = false
void refreshApprovalInbox()
toast(
isApplicationDocument
? `${claimNo || '该'}申请单已提交${approvalStage ? `,当前节点:${approvalStage}` : ',等待直属领导审批'}`
: `${claimNo || '该'}单据已完成 AI预审${approvalStage ? `,当前节点:${approvalStage}` : ',并已提交审批'}`
)
router.push({ name: activeView.value === 'documents' ? 'app-documents' : 'app-requests' })
return
}
toast(
isApplicationDocument
? `${claimNo || '该'}申请单已保存为草稿,可继续补充申请信息。`
: `${claimNo || '该'}单据已保存为草稿,可继续上传票据或补充信息。`
)
}
function openRequestDetail(request) {
selectedRequestSnapshot.value = request || null
@@ -315,10 +357,11 @@ export function useAppShell() {
handleNavigate,
handleReject,
handleRequestDeleted,
handleRequestUpdated,
navItems,
openRequestDetail,
openSmartEntry,
handleRequestUpdated,
navItems,
openExpenseApplicationCreate,
openRequestDetail,
openSmartEntry,
openTravelCreate,
ranges,
requestSummary,

View File

@@ -5,6 +5,10 @@ import { filterActionableRiskFlags } from '../utils/riskFlags.js'
const EXPENSE_TYPE_LABELS = {
travel: '差旅费',
travel_application: '差旅费用申请',
expense_application: '费用申请',
purchase_application: '采购费用申请',
meeting_application: '会务费用申请',
train_ticket: '火车票',
flight_ticket: '机票',
ship_ticket: '轮船票',
@@ -36,6 +40,8 @@ const SYSTEM_GENERATED_EXPENSE_TYPES = new Set(['travel_allowance'])
const LONG_DISTANCE_TRAVEL_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket'])
const ROUTE_DESCRIPTION_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket', 'ship_ticket', 'ferry_ticket', 'ride_ticket'])
const HOTEL_DESCRIPTION_EXPENSE_TYPES = new Set(['hotel_ticket'])
const DOCUMENT_TYPE_APPLICATION = 'application'
const DOCUMENT_TYPE_REIMBURSEMENT = 'reimbursement'
const REIMBURSEMENT_PROGRESS_LABELS = [
'创建单据',
@@ -46,6 +52,12 @@ const REIMBURSEMENT_PROGRESS_LABELS = [
'归档入账'
]
const APPLICATION_PROGRESS_LABELS = [
'创建申请',
'直属领导审批',
'审批完成'
]
function parseNumber(value) {
const nextValue = Number(value)
return Number.isFinite(nextValue) ? nextValue : 0
@@ -123,6 +135,28 @@ function resolveTypeLabel(typeCode) {
return EXPENSE_TYPE_LABELS[String(typeCode || '').trim()] || EXPENSE_TYPE_LABELS.other
}
function resolveDocumentTypeMeta(claim, typeCode) {
const explicitType = String(
claim?.document_type_code
|| claim?.documentTypeCode
|| claim?.document_type
|| claim?.documentType
|| ''
).trim()
const claimNo = String(claim?.claim_no || claim?.claimNo || '').trim().toUpperCase()
const normalizedType = String(typeCode || '').trim()
const isApplication =
explicitType === DOCUMENT_TYPE_APPLICATION
|| explicitType === 'expense_application'
|| claimNo.startsWith('APP-')
|| normalizedType === 'application'
|| normalizedType.endsWith('_application')
return isApplication
? { documentTypeCode: DOCUMENT_TYPE_APPLICATION, documentTypeLabel: '申请单' }
: { documentTypeCode: DOCUMENT_TYPE_REIMBURSEMENT, documentTypeLabel: '报销单' }
}
function normalizeExpenseType(typeCode) {
return String(typeCode || '').trim() || 'other'
}
@@ -237,7 +271,7 @@ function resolveApprovalMeta(status) {
return { key: 'in_progress', label: '审批中', tone: 'info' }
}
function resolveWorkflowNode(claim, approvalMeta) {
function resolveWorkflowNode(claim, approvalMeta, isApplicationDocument = false) {
if (String(claim?.status || '').trim().toLowerCase() === 'returned') {
return '待提交'
}
@@ -259,10 +293,10 @@ function resolveWorkflowNode(claim, approvalMeta) {
}
if (approvalMeta.key === 'completed') {
return '归档入账'
return isApplicationDocument ? '审批完成' : '归档入账'
}
return 'AI预审'
return isApplicationDocument ? '直属领导审批' : 'AI预审'
}
function stringifyRiskFlag(value) {
@@ -345,6 +379,31 @@ function resolveProgressCurrentIndex(approvalMeta, workflowNode) {
return 2
}
function resolveApplicationProgressCurrentIndex(approvalMeta, workflowNode) {
const normalizedNode = String(workflowNode || '').trim()
if (approvalMeta.key === 'completed') {
return 2
}
if (normalizedNode.includes('审批完成') || normalizedNode.includes('申请完成')) {
return 2
}
if (
normalizedNode.includes('直属领导')
|| normalizedNode.includes('领导审批')
|| normalizedNode.includes('部门负责人')
|| normalizedNode.includes('负责人审批')
) {
return 1
}
if (approvalMeta.key === 'draft' || approvalMeta.key === 'supplement' || approvalMeta.key === 'rejected') {
return 0
}
return 1
}
function normalizeText(value) {
return String(value || '').trim()
}
@@ -438,9 +497,9 @@ function buildCompletedStepMeta(claim, label) {
const stepLabel = normalizeText(label)
const employeeName = normalizeText(claim?.employee_name) || '申请人'
if (stepLabel === '创建单据') {
if (stepLabel === '创建单据' || stepLabel === '创建申请') {
const createdAt = formatDateTime(claim?.created_at)
return buildProgressStepMeta(`${employeeName}创建`, createdAt)
return buildProgressStepMeta(stepLabel === '创建申请' ? `${employeeName}发起申请` : `${employeeName}创建`, createdAt)
}
if (stepLabel === '待提交') {
@@ -477,12 +536,17 @@ function buildCompletedStepMeta(claim, label) {
return buildProgressStepMeta('归档入账', archivedAt)
}
if (stepLabel === '审批完成') {
const completedAt = formatDateTime(claim?.updated_at)
return buildProgressStepMeta('审批完成', completedAt)
}
return buildProgressStepMeta('已完成')
}
function resolveCurrentStepStartedAt(claim, label) {
const stepLabel = normalizeText(label)
if (stepLabel === '创建单据') {
if (stepLabel === '创建单据' || stepLabel === '创建申请') {
return claim?.created_at
}
if (stepLabel === '待提交') {
@@ -499,14 +563,22 @@ function resolveCurrentStepStartedAt(claim, label) {
const leaderApprovalEvent = findApprovalEventForStep(claim, '直属领导审批')
return leaderApprovalEvent?.created_at || leaderApprovalEvent?.createdAt || claim?.updated_at || claim?.submitted_at
}
if (stepLabel === '归档入账') {
if (stepLabel === '归档入账' || stepLabel === '审批完成') {
return claim?.updated_at || claim?.submitted_at
}
return ''
}
function buildProgressSteps(approvalMeta, workflowNode, claim = {}) {
const currentIndex = resolveProgressCurrentIndex(approvalMeta, workflowNode)
function buildProgressSteps(approvalMeta, workflowNode, claim = {}, options = {}) {
const documentTypeCode = String(options.documentTypeCode || '').trim()
const progressLabels =
documentTypeCode === DOCUMENT_TYPE_APPLICATION
? APPLICATION_PROGRESS_LABELS
: REIMBURSEMENT_PROGRESS_LABELS
const currentIndex =
documentTypeCode === DOCUMENT_TYPE_APPLICATION
? resolveApplicationProgressCurrentIndex(approvalMeta, workflowNode)
: resolveProgressCurrentIndex(approvalMeta, workflowNode)
const currentTime =
approvalMeta.key === 'completed'
? '已完成'
@@ -516,7 +588,7 @@ function buildProgressSteps(approvalMeta, workflowNode, claim = {}) {
? '已退回'
: '进行中'
return REIMBURSEMENT_PROGRESS_LABELS.map((label, index) => {
return progressLabels.map((label, index) => {
if (approvalMeta.key === 'completed') {
const stepMeta = buildCompletedStepMeta(claim, label)
return {
@@ -636,8 +708,10 @@ function buildExpenseItems(claim, riskSummary) {
export function mapExpenseClaimToRequest(claim) {
const typeCode = String(claim?.expense_type || '').trim() || 'other'
const typeLabel = resolveTypeLabel(typeCode)
const documentTypeMeta = resolveDocumentTypeMeta(claim, typeCode)
const isApplicationDocument = documentTypeMeta.documentTypeCode === DOCUMENT_TYPE_APPLICATION
const approvalMeta = resolveApprovalMeta(claim?.status)
const workflowNode = resolveWorkflowNode(claim, approvalMeta)
const workflowNode = resolveWorkflowNode(claim, approvalMeta, isApplicationDocument)
const invoiceCount = Math.max(0, parseNumber(claim?.invoice_count))
const riskSummary = buildRiskSummary(claim?.risk_flags_json)
const expenseItems = buildExpenseItems(claim, riskSummary)
@@ -659,8 +733,9 @@ export function mapExpenseClaimToRequest(claim) {
entity: '',
typeCode,
typeLabel,
detailVariant: typeCode === 'travel' ? 'travel' : 'general',
title: String(claim?.reason || '').trim() || `${typeLabel}报销`,
...documentTypeMeta,
detailVariant: typeCode === 'travel' || typeCode === 'travel_application' ? 'travel' : 'general',
title: String(claim?.reason || '').trim() || (isApplicationDocument ? typeLabel : `${typeLabel}报销`),
sceneLabel: typeLabel,
sceneTarget: String(claim?.location || '').trim() || '待补充',
location: String(claim?.location || '').trim() || '待补充',
@@ -678,18 +753,24 @@ export function mapExpenseClaimToRequest(claim) {
approvalKey: approvalMeta.key,
approvalStatus: approvalMeta.label,
approvalTone: approvalMeta.tone,
secondaryStatusLabel: typeCode === 'travel' ? '行程状态' : '票据状态',
secondaryStatusValue: invoiceCount > 0 ? `已关联 ${invoiceCount} 张票据` : '待上传票据',
secondaryStatusTone: invoiceCount > 0 ? 'success' : 'warning',
secondaryStatusLabel: isApplicationDocument ? '申请材料' : (typeCode === 'travel' ? '行程状态' : '票据状态'),
secondaryStatusValue: isApplicationDocument
? '已进入审批流程'
: (invoiceCount > 0 ? `已关联 ${invoiceCount} 张票据` : '待上传票据'),
secondaryStatusTone: isApplicationDocument ? 'success' : (invoiceCount > 0 ? 'success' : 'warning'),
riskSummary,
attachmentSummary: invoiceCount > 0 ? `${invoiceCount} 张票据` : '无',
expenseTableSummary: expenseItems.length
attachmentSummary: isApplicationDocument ? '申请单' : (invoiceCount > 0 ? `${invoiceCount} 张票据` : '无'),
expenseTableSummary: isApplicationDocument
? '预计金额已纳入预算管理口径'
: expenseItems.length
? (invoiceCount > 0
? `${expenseItems.length} 条费用明细,已关联 ${invoiceCount} 张票据`
: `${expenseItems.length} 条费用明细,待补充票据`)
: '暂无费用明细',
note: String(claim?.reason || '').trim(),
progressSteps: buildProgressSteps(approvalMeta, workflowNode, claim),
progressSteps: buildProgressSteps(approvalMeta, workflowNode, claim, {
documentTypeCode: documentTypeMeta.documentTypeCode
}),
expenseItems
}
}

View File

@@ -0,0 +1,173 @@
export const ASSISTANT_SCOPE_ACTION_SWITCH = 'switch_assistant_session'
export const ASSISTANT_SCOPE_SESSION_APPLICATION = 'application'
export const ASSISTANT_SCOPE_SESSION_EXPENSE = 'expense'
export const ASSISTANT_SCOPE_SESSION_APPROVAL = 'approval'
export const ASSISTANT_SCOPE_SESSION_KNOWLEDGE = 'knowledge'
const SESSION_SCOPE_CONFIG = {
[ASSISTANT_SCOPE_SESSION_APPLICATION]: {
label: '申请助手',
icon: 'mdi mdi-file-plus-outline',
scope: '费用申请、事前审批、申请材料清单、申请单状态查询'
},
[ASSISTANT_SCOPE_SESSION_EXPENSE]: {
label: '报销助手',
icon: 'mdi mdi-receipt-text-plus-outline',
scope: '发起报销、票据识别、草稿归集、报销单状态查询和报销信息核对'
},
[ASSISTANT_SCOPE_SESSION_APPROVAL]: {
label: '审核助手',
icon: 'mdi mdi-clipboard-check-outline',
scope: '待审单据查询、审批动作、风险解释和审核意见草稿'
},
[ASSISTANT_SCOPE_SESSION_KNOWLEDGE]: {
label: '财务知识助手',
icon: 'mdi mdi-book-open-page-variant-outline',
scope: '财务制度、报销标准、票据要求、流程规则和政策口径解释'
}
}
const SESSION_SCOPE_TYPES = Object.keys(SESSION_SCOPE_CONFIG)
const APPLICATION_PATTERN =
/费用申请|发起申请|申请单|事前申请|事前审批|前置审批|出差申请|采购申请|用款申请|预算申请|申请材料|材料清单|先申请|立项申请/
const EXPENSE_PATTERN =
/报销|报销单|票据|发票|火车票|高铁票|机票|飞机票|的士票|出租车|网约车|酒店票|住宿票|住宿单据|保存草稿|草稿|费用明细|归集|上传.*票|关联单据|继续下一步/
const APPROVAL_PATTERN =
/待我审核|待审|审核|审批|审核意见|审批意见|审批通过|审批驳回|驳回|退回|审核中心|审批中心|领导审批|财务审核|处理意见/
const KNOWLEDGE_PATTERN =
/制度|政策|标准|规则|规定|流程|口径|依据|上限|额度|补贴|住宿标准|差旅标准|报销标准|票据要求|可不可以|能不能|怎么规定|如何计算|怎么算/
const EXPENSE_OPERATION_PATTERN = /发起报销|报销单|票据|发票|火车票|高铁票|机票|的士票|草稿|归集|上传|关联单据|继续下一步/
const CURRENT_CLAIM_RISK_PATTERN = /这张|当前|本单|该单|单据|风险|超标|异常|重复|待补/
function normalizeSessionType(sessionType) {
const normalized = String(sessionType || '').trim()
return SESSION_SCOPE_TYPES.includes(normalized) ? normalized : ASSISTANT_SCOPE_SESSION_EXPENSE
}
function normalizeText(rawText) {
return String(rawText || '')
.replace(/\s+/g, '')
.toLowerCase()
}
function resolveScopeConfig(sessionType) {
return SESSION_SCOPE_CONFIG[normalizeSessionType(sessionType)] || SESSION_SCOPE_CONFIG[ASSISTANT_SCOPE_SESSION_EXPENSE]
}
export function inferAssistantScopeTarget(rawText, options = {}) {
const text = normalizeText(rawText)
if (!text) {
return ''
}
const applicationMatched = APPLICATION_PATTERN.test(text)
const expenseMatched = EXPENSE_PATTERN.test(text)
const approvalMatched = APPROVAL_PATTERN.test(text)
const knowledgeMatched = KNOWLEDGE_PATTERN.test(text)
if (approvalMatched && /(待我审核|待审|审核意见|审批意见|审批通过|审批驳回|驳回|退回|审核中心|审批中心|处理意见)/.test(text)) {
return ASSISTANT_SCOPE_SESSION_APPROVAL
}
if (knowledgeMatched && !options.hasActiveReviewPayload && !EXPENSE_OPERATION_PATTERN.test(text)) {
return ASSISTANT_SCOPE_SESSION_KNOWLEDGE
}
if (expenseMatched && !applicationMatched) {
return ASSISTANT_SCOPE_SESSION_EXPENSE
}
if (applicationMatched && !expenseMatched) {
return ASSISTANT_SCOPE_SESSION_APPLICATION
}
if (knowledgeMatched && !expenseMatched && !approvalMatched && !applicationMatched) {
return ASSISTANT_SCOPE_SESSION_KNOWLEDGE
}
if (knowledgeMatched && !options.hasActiveReviewPayload) {
return ASSISTANT_SCOPE_SESSION_KNOWLEDGE
}
if (approvalMatched) {
return ASSISTANT_SCOPE_SESSION_APPROVAL
}
if (expenseMatched) {
return ASSISTANT_SCOPE_SESSION_EXPENSE
}
if (applicationMatched) {
return ASSISTANT_SCOPE_SESSION_APPLICATION
}
return ''
}
function shouldAllowCurrentExpensePolicyQuestion(rawText, currentSessionType, targetSessionType, options = {}) {
if (
normalizeSessionType(currentSessionType) !== ASSISTANT_SCOPE_SESSION_EXPENSE ||
targetSessionType !== ASSISTANT_SCOPE_SESSION_KNOWLEDGE ||
!options.hasActiveReviewPayload
) {
return false
}
return CURRENT_CLAIM_RISK_PATTERN.test(normalizeText(rawText))
}
function buildScopeSwitchAction(targetSessionType, rawText, options = {}) {
const target = resolveScopeConfig(targetSessionType)
const carryText = String(rawText || '').trim()
return {
label: `切换到${target.label}`,
description: `带着这条内容进入${target.label}继续处理`,
icon: target.icon,
action_type: ASSISTANT_SCOPE_ACTION_SWITCH,
payload: {
session_type: normalizeSessionType(targetSessionType),
carry_text: carryText,
carry_files: Boolean(options.attachmentCount)
}
}
}
function buildScopeBoundaryText(currentSessionType, targetSessionType) {
const current = resolveScopeConfig(currentSessionType)
const target = resolveScopeConfig(targetSessionType)
return [
`我先暂停在「${current.label}」里继续处理这条消息。`,
'',
`当前助手的业务范围是:${current.scope}`,
'',
`您这条内容更适合交给「${target.label}」处理;它的业务范围是:${target.scope}`,
'',
`建议切换到「${target.label}」后继续,我会尽量把这条内容带过去,避免在错误的会话里把流程跑偏。`
].join('\n')
}
export function resolveAssistantScopeGuard(rawText, currentSessionType, options = {}) {
const normalizedCurrent = normalizeSessionType(currentSessionType)
const targetSessionType = inferAssistantScopeTarget(rawText, options)
if (!targetSessionType || targetSessionType === normalizedCurrent) {
return null
}
if (shouldAllowCurrentExpensePolicyQuestion(rawText, normalizedCurrent, targetSessionType, options)) {
return null
}
const target = resolveScopeConfig(targetSessionType)
return {
targetSessionType,
targetLabel: target.label,
text: buildScopeBoundaryText(normalizedCurrent, targetSessionType),
meta: [`建议切换至${target.label}`],
suggestedActions: [buildScopeSwitchAction(targetSessionType, rawText, options)]
}
}

View File

@@ -0,0 +1,46 @@
const APPLICATION_FIELD_PREFILLS = {
time: '申请时间段:',
time_range: '申请时间段:',
location: '地点:',
reason: '事由:',
days: '天数:',
transport_mode: '出行方式:',
amount: '预计总费用:'
}
export function resolveSuggestedActionPrefill(action = {}) {
const payload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
const explicitPrefill = String(
payload.prompt_prefill
|| payload.input_prefill
|| payload.prefill_text
|| ''
).trim()
if (explicitPrefill) {
return explicitPrefill
}
const actionType = String(action?.action_type || '').trim()
if (actionType !== 'prefill_composer') {
return ''
}
const applicationField = String(payload.application_field || '').trim()
return APPLICATION_FIELD_PREFILLS[applicationField] || ''
}
export function mergeComposerPrefill(currentDraft = '', prefill = '') {
const normalizedPrefill = String(prefill || '').trim()
if (!normalizedPrefill) {
return String(currentDraft || '')
}
const current = String(currentDraft || '')
if (!current.trim()) {
return normalizedPrefill
}
if (current.includes(normalizedPrefill)) {
return current
}
return `${current.trimEnd()}\n${normalizedPrefill}`
}

View File

@@ -14,6 +14,32 @@ function normalizeExpenseType(value) {
return String(value || '').trim() || 'other'
}
function isApplicationDocumentRequest(request) {
const documentType = String(
request?.documentTypeCode
|| request?.document_type_code
|| request?.documentType
|| request?.document_type
|| ''
).trim()
const claimNo = String(
request?.claimNo
|| request?.claim_no
|| request?.documentNo
|| request?.id
|| ''
).trim().toUpperCase()
const typeCode = normalizeExpenseType(request?.typeCode || request?.expense_type)
return (
documentType === 'application'
|| documentType === 'expense_application'
|| claimNo.startsWith('APP-')
|| typeCode === 'application'
|| typeCode.endsWith('_application')
)
}
function isSystemGeneratedExpenseItem(item) {
const itemType = normalizeExpenseType(item?.itemType || item?.item_type)
return Boolean(item?.isSystemGenerated || item?.is_system_generated || SYSTEM_GENERATED_EXPENSE_TYPES.has(itemType))
@@ -29,6 +55,10 @@ function getExpenseItems(request) {
}
export function hasMissingAttachment(request) {
if (isApplicationDocumentRequest(request)) {
return false
}
const expenseItems = getExpenseItems(request)
if (expenseItems.length) {

View File

@@ -0,0 +1,71 @@
const STORAGE_KEY = 'x-financial.documents.viewed'
const SCOPE_STORAGE_KEY = 'x-financial.documents.scope'
function getStorage() {
return typeof window === 'undefined' ? null : window.localStorage
}
export function resolveDocumentNewKey(row) {
const source = String(row?.source || 'document').trim()
const id = String(row?.claimId || row?.documentNo || row?.documentKey || row?.id || '').trim()
return id ? `${source}:${id}` : ''
}
export function readViewedDocumentKeys(storage = getStorage()) {
if (!storage) {
return new Set()
}
try {
const parsed = JSON.parse(storage.getItem(STORAGE_KEY) || '[]')
return new Set(Array.isArray(parsed) ? parsed.map((item) => String(item || '').trim()).filter(Boolean) : [])
} catch {
return new Set()
}
}
export function writeViewedDocumentKeys(keys, storage = getStorage()) {
if (!storage) {
return
}
storage.setItem(STORAGE_KEY, JSON.stringify(Array.from(keys).filter(Boolean)))
}
export function readDocumentScope(fallback, allowedScopes = [], storage = getStorage()) {
if (!storage) {
return fallback
}
const storedScope = String(storage.getItem(SCOPE_STORAGE_KEY) || '').trim()
return allowedScopes.includes(storedScope) ? storedScope : fallback
}
export function writeDocumentScope(scope, allowedScopes = [], storage = getStorage()) {
if (!storage || !allowedScopes.includes(scope)) {
return
}
storage.setItem(SCOPE_STORAGE_KEY, scope)
}
export function isNewDocument(row, viewedKeys) {
const key = resolveDocumentNewKey(row)
return Boolean(key) && !viewedKeys.has(key)
}
export function countNewDocuments(rows, viewedKeys) {
return rows.filter((row) => isNewDocument(row, viewedKeys)).length
}
export function markDocumentViewed(row, viewedKeys, storage = getStorage()) {
const key = resolveDocumentNewKey(row)
if (!key) {
return viewedKeys
}
const nextKeys = new Set(viewedKeys)
nextKeys.add(key)
writeViewedDocumentKeys(nextKeys, storage)
return nextKeys
}

View File

@@ -16,7 +16,10 @@ const SLOT_LABELS = {
expense_type: '费用场景',
amount: '申请金额',
time_range: '业务时间',
location: '业务地点',
reason: '申请事由',
days: '天数',
transport_mode: '出行方式',
attachments: '附件说明',
customer_name: '客户名称',
participants: '参与人员'
@@ -24,6 +27,33 @@ const SLOT_LABELS = {
const PRE_APPROVAL_TYPES = new Set(['travel', 'meeting', 'office', 'training'])
const ATTACHMENT_REQUIRED_TYPES = new Set(['meeting', 'training'])
const PLACEHOLDER_VALUES = new Set(['', '待补充', '暂无', '无', '未知'])
const PROMPT_FIELD_LABELS = [
'发生时间',
'业务发生时间',
'申请时间',
'时间',
'地点',
'业务地点',
'发生地点',
'事由',
'申请事由',
'出差事由',
'原因',
'用途',
'天数',
'出差天数',
'申请天数',
'出行方式',
'交通方式',
'交通工具',
'预计总费用',
'预计费用',
'预计金额',
'申请金额',
'预算',
'金额'
]
export const APPLICATION_EXAMPLES = [
'申请下周去北京做客户现场验收差旅预算18000元',
@@ -89,6 +119,112 @@ export function resolveTimeRangeText(ontology) {
return String(range.raw || '').trim()
}
function parseApplicationDate(value) {
const normalized = String(value || '')
.trim()
.replace(/日$/, '')
.replace(/年|月|\//g, '-')
.replace(/\./g, '-')
const match = normalized.match(/^(20\d{2})-(\d{1,2})-(\d{1,2})$/)
if (!match) return null
const [, year, month, day] = match
const date = new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)))
if (Number.isNaN(date.getTime())) return null
return date
}
function formatApplicationDate(date) {
return date.toISOString().slice(0, 10)
}
function parseChineseNumber(value) {
const digits = {
: 1,
: 2,
: 2,
: 3,
: 4,
: 5,
: 6,
: 7,
: 8,
: 9
}
const text = String(value || '').trim()
if (!text) return 0
if (text === '十') return 10
if (text.includes('十')) {
const [left, right] = text.split('十')
const tens = left ? digits[left] || 0 : 1
const ones = right ? digits[right] || 0 : 0
return tens * 10 + ones
}
return digits[text] || 0
}
export function resolvePromptDays(prompt) {
const labeled = resolvePromptField(prompt, ['天数', '出差天数', '申请天数'])
const source = labeled || String(prompt || '')
const match = source.match(/(?<days>\d+|[一二两三四五六七八九十]{1,3})\s*天/)
if (!match?.groups?.days) return 0
if (/^\d+$/.test(match.groups.days)) return Number(match.groups.days)
return parseChineseNumber(match.groups.days)
}
export function expandApplicationTimeWithDays(timeText, days = 0) {
const normalizedTime = String(timeText || '').trim()
const dayCount = Number(days || 0)
if (!normalizedTime || !dayCount) return normalizedTime
if (/\s*(至|到|~|--|—)\s*/.test(normalizedTime)) return normalizedTime
const match = normalizedTime.match(/20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?/)
const startDate = parseApplicationDate(match?.[0] || '')
if (!startDate) return normalizedTime
const endDate = new Date(startDate.getTime())
endDate.setUTCDate(endDate.getUTCDate() + dayCount)
return `${formatApplicationDate(startDate)}${formatApplicationDate(endDate)}`
}
export function resolveApplicationTimeRange(ontology, prompt) {
const range = ontology?.time_range || {}
const baseTime = resolveTimeRangeText(ontology)
|| resolvePromptField(prompt, ['发生时间', '业务发生时间', '申请时间', '时间'])
if (range.start_date && range.end_date && range.start_date !== range.end_date) {
return `${range.start_date}${range.end_date}`
}
return expandApplicationTimeWithDays(baseTime, resolvePromptDays(prompt)) || baseTime
}
function escapeRegExp(value) {
return String(value || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
export function resolvePromptField(prompt, labels = []) {
const text = String(prompt || '').trim()
if (!text) return ''
const labelSet = new Set(labels.map((item) => String(item || '').trim()).filter(Boolean))
for (const line of text.split(/\r?\n/)) {
const match = line.match(/^\s*([^:\s]+)\s*[:]\s*(.+?)\s*$/)
if (match && labelSet.has(match[1].trim())) {
return match[2].trim()
}
}
const labelPattern = labels.map(escapeRegExp).join('|')
const nextLabelPattern = PROMPT_FIELD_LABELS.map(escapeRegExp).join('|')
if (!labelPattern) return ''
const match = text.match(
new RegExp(`(?:${labelPattern})\\s*[:]\\s*([\\s\\S]*?)(?=\\s*(?:${nextLabelPattern})\\s*[:]|$)`)
)
return match ? match[1].trim().replace(/[,。;;]$/, '') : ''
}
export function resolveApplicationReason(prompt) {
const labeled = resolvePromptField(prompt, ['事由', '申请事由', '出差事由', '原因', '用途'])
return labeled || String(prompt || '').trim()
}
export function resolveAttachmentPolicy(expenseTypeCode, amount = 0) {
const code = String(expenseTypeCode || '').trim()
if (ATTACHMENT_REQUIRED_TYPES.has(code)) {
@@ -128,8 +264,15 @@ export function buildApplicationFieldsFromOntology(ontology, prompt, currentUser
const documentTypeEntity = resolveEntity(ontology, 'document_type')
const workflowStageEntity = resolveEntity(ontology, 'workflow_stage')
const attachmentPolicy = resolveAttachmentPolicy(expenseTypeCode, amount.value)
const timeRange = resolveApplicationTimeRange(ontology, prompt)
|| '待补充'
const location = locationEntity?.normalized_value
|| locationEntity?.value
|| resolvePromptField(prompt, ['地点', '业务地点', '发生地点'])
|| '待补充'
const reason = resolveApplicationReason(prompt) || '待补充'
return {
const fields = {
documentType: documentTypeEntity?.normalized_value || 'expense_application',
documentTypeLabel: documentTypeEntity?.value || '费用申请',
workflowStage: workflowStageEntity?.normalized_value || 'pre_approval',
@@ -138,21 +281,40 @@ export function buildApplicationFieldsFromOntology(ontology, prompt, currentUser
expenseTypeLabel: resolveExpenseTypeLabel(expenseTypeCode),
amount: amount.value,
amountDisplay: amount.value ? `¥${amount.value.toLocaleString('zh-CN')}` : '待补充',
timeRange: resolveTimeRangeText(ontology) || '待补充',
location: locationEntity?.normalized_value || locationEntity?.value || '待补充',
reason: String(prompt || '').trim() || '待补充',
timeRange,
location,
reason,
applicant: currentUser.name || currentUser.username || '当前用户',
department: currentUser.department || currentUser.departmentName || '待补充',
preApprovalRequired: PRE_APPROVAL_TYPES.has(expenseTypeCode),
attachmentPolicy,
missingSlots: normalizeMissingSlots(ontology?.missing_slots || [])
attachmentPolicy
}
return {
...fields,
missingSlots: normalizeMissingSlots(ontology?.missing_slots || [], fields)
}
}
export function normalizeMissingSlots(slots = []) {
function hasProvidedValue(value) {
const normalized = String(value || '').trim()
return !PLACEHOLDER_VALUES.has(normalized)
}
function isSlotAlreadyResolved(slot, fields = {}) {
const key = String(slot || '').trim()
if (key === 'reason') return hasProvidedValue(fields.reason)
if (key === 'time_range' || key === 'time') return hasProvidedValue(fields.timeRange)
if (key === 'location') return hasProvidedValue(fields.location)
if (key === 'amount') return Number(fields.amount || 0) > 0
if (key === 'transport_mode') return hasProvidedValue(fields.transportMode)
return false
}
export function normalizeMissingSlots(slots = [], fields = {}) {
const normalized = Array.isArray(slots) ? slots : []
return normalized.map((item) => ({
key: String(item || '').trim(),
label: SLOT_LABELS[String(item || '').trim()] || String(item || '').trim()
})).filter((item) => item.key)
})).filter((item) => item.key && !isSlotAlreadyResolved(item.key, fields))
}

View File

@@ -20,6 +20,7 @@ const RISK_TEXT_CLASS_BY_LABEL = {
const ACTION_LINK_CLASS_BY_HREF = {
'#confirm-attachment-association': 'markdown-action-link-confirm',
'#application-submit': 'markdown-action-link-confirm',
'#review-next-step': 'markdown-action-link-next',
'#review-quick-edit': 'markdown-action-link-edit',
'#review-risk-panel': 'markdown-action-link-risk'

View File

@@ -1,4 +1,6 @@
const DEFAULT_SESSION_TYPE_EXPENSE = 'expense'
const DEFAULT_SESSION_TYPE_APPLICATION = 'application'
const DEFAULT_SESSION_TYPE_APPROVAL = 'approval'
const DEFAULT_SESSION_TYPE_KNOWLEDGE = 'knowledge'
const DEFAULT_INTENT_LABELS = {
@@ -145,7 +147,8 @@ export function inferLocalFlowCandidates(rawText) {
}
export function shouldRequestExpenseSceneSelection(rawText, options = {}) {
if (options.sessionType === DEFAULT_SESSION_TYPE_KNOWLEDGE) {
const sessionType = options.sessionType || DEFAULT_SESSION_TYPE_EXPENSE
if (sessionType !== DEFAULT_SESSION_TYPE_EXPENSE) {
return false
}
if (Number(options.attachmentCount || 0) > 0) {
@@ -172,7 +175,8 @@ export function shouldRequestExpenseSceneSelection(rawText, options = {}) {
}
export function shouldRequestExpenseIntentConfirmation(rawText, options = {}) {
if (options.sessionType === DEFAULT_SESSION_TYPE_KNOWLEDGE) {
const sessionType = options.sessionType || DEFAULT_SESSION_TYPE_EXPENSE
if (sessionType !== DEFAULT_SESSION_TYPE_EXPENSE) {
return false
}
if (Number(options.attachmentCount || 0) > 0) {
@@ -203,6 +207,12 @@ export function buildLocalIntentPreview(rawText, sessionType = DEFAULT_SESSION_T
if (sessionType === DEFAULT_SESSION_TYPE_KNOWLEDGE) {
return '初步识别为财务知识问答,正在准备检索范围'
}
if (sessionType === DEFAULT_SESSION_TYPE_APPLICATION) {
return '初步识别为费用申请事项,准备进入申请信息识别'
}
if (sessionType === DEFAULT_SESSION_TYPE_APPROVAL) {
return '初步识别为审核处理事项,准备进入单据查询或风险核对'
}
if (shouldRequestExpenseIntentConfirmation(rawText, { ...options, sessionType })) {
return '识别到业务事项描述,但是否发起报销尚不明确,需要先由用户确认'

View File

@@ -5,6 +5,30 @@ const REQUEST_TYPE_META = {
tone: 'travel',
secondaryStatusLabel: '行程状态'
},
travel_application: {
label: '差旅费用申请',
detailVariant: 'travel',
tone: 'travel',
secondaryStatusLabel: '申请材料'
},
expense_application: {
label: '费用申请',
detailVariant: 'general',
tone: 'other',
secondaryStatusLabel: '申请材料'
},
purchase_application: {
label: '采购费用申请',
detailVariant: 'general',
tone: 'office',
secondaryStatusLabel: '申请材料'
},
meeting_application: {
label: '会务费用申请',
detailVariant: 'general',
tone: 'meeting',
secondaryStatusLabel: '申请材料'
},
train_ticket: {
label: '火车票',
detailVariant: 'travel',

View File

@@ -1,15 +1,19 @@
<template>
<div class="app">
<SidebarRail
:nav-items="filteredNavItems"
:active-view="activeView"
:company-name="companyProfile.name"
:company-logo="companyProfile.logo"
:current-user="currentUser"
@navigate="handleNavigate"
@open-chat="openSmartEntry"
@logout="handleLogout"
/>
<div class="app" :class="{ 'sidebar-collapsed': sidebarCollapsed }">
<div class="app-sidebar">
<SidebarRail
:nav-items="filteredNavItems"
:active-view="activeView"
:company-name="companyProfile.name"
:company-logo="companyProfile.logo"
:current-user="currentUser"
:collapsed="sidebarCollapsed"
@navigate="handleNavigate"
@open-chat="openSmartEntry"
@logout="handleLogout"
@toggle-collapse="toggleSidebarCollapsed"
/>
</div>
<main
class="main"
@@ -49,7 +53,7 @@
@update:active-range="activeRange = $event"
@update:custom-range="customRange = $event"
@batch-approve="toast('已批量通过 23 条审批任务')"
@new-application="openExpenseApplicationDialog"
@new-application="openExpenseApplicationCreate"
/>
<FilterBar
@@ -91,6 +95,7 @@
<TravelRequestDetailView
v-else-if="['requests', 'documents'].includes(activeView) && detailMode && selectedRequest"
:request="selectedRequest"
:back-label="activeView === 'documents' ? '返回单据中心' : '返回报销列表'"
@back-to-requests="closeRequestDetail"
@open-assistant="openSmartEntry"
@request-updated="handleRequestUpdated"
@@ -105,7 +110,7 @@
:error="requestsError"
@open-document="openRequestDetail"
@create-request="openTravelCreate"
@create-application="openExpenseApplicationDialog"
@create-application="openExpenseApplicationCreate"
@reload="reloadRequests"
@summary-change="documentSummary = $event"
/>
@@ -146,12 +151,6 @@
@close="closeSmartEntry"
@draft-saved="handleDraftSaved"
/>
<ExpenseApplicationDialog
v-if="expenseApplicationDialogOpen"
@close="closeExpenseApplicationDialog"
@confirmed="handleExpenseApplicationConfirmed"
/>
</div>
</template>
@@ -161,7 +160,6 @@ import { computed, ref } from 'vue'
import SidebarRail from '../components/layout/SidebarRail.vue'
import TopBar from '../components/layout/TopBar.vue'
import FilterBar from '../components/layout/FilterBar.vue'
import ExpenseApplicationDialog from '../components/shared/ExpenseApplicationDialog.vue'
import OverviewView from './OverviewView.vue'
import PersonalWorkbenchView from './PersonalWorkbenchView.vue'
import TravelReimbursementCreateView from './TravelReimbursementCreateView.vue'
@@ -186,7 +184,11 @@ const knowledgeSummary = ref(null)
const logsSummary = ref(null)
const documentSummary = ref(null)
const auditDetailOpen = ref(false)
const expenseApplicationDialogOpen = ref(false)
const sidebarCollapsed = ref(true)
function toggleSidebarCollapsed() {
sidebarCollapsed.value = !sidebarCollapsed.value
}
const {
activeRange,
@@ -206,6 +208,7 @@ const {
handleRequestDeleted,
handleRequestUpdated,
navItems,
openExpenseApplicationCreate,
openRequestDetail,
openSmartEntry,
openTravelCreate,
@@ -229,19 +232,6 @@ const {
const { companyProfile, currentUser, logout } = useSystemState()
const filteredNavItems = computed(() => filterNavItemsByAccess(navItems, currentUser.value))
function openExpenseApplicationDialog() {
expenseApplicationDialogOpen.value = true
}
function closeExpenseApplicationDialog() {
expenseApplicationDialogOpen.value = false
}
function handleExpenseApplicationConfirmed() {
expenseApplicationDialogOpen.value = false
toast('费用申请字段已接入本体识别,后续会按申请审批流落单。')
}
function handleLogout() {
logout('manual')
}

View File

@@ -193,7 +193,10 @@
</thead>
<tbody>
<tr v-for="row in visibleRows" :key="row.documentKey" @click="openDocument(row)">
<td><strong class="doc-id">{{ row.documentNo }}</strong></td>
<td>
<span v-if="row.isNewDocument" class="new-document-badge">NEW</span>
<strong class="doc-id">{{ row.documentNo }}</strong>
</td>
<td>{{ row.createdAtDisplay }}</td>
<td v-if="showStayTimeColumn">{{ row.stayTimeDisplay }}</td>
<td><span class="doc-kind-tag" :class="row.documentTypeCode">{{ row.documentTypeLabel }}</span></td>
@@ -259,31 +262,31 @@ import TableEmptyState from '../components/shared/TableEmptyState.vue'
import TableLoadingState from '../components/shared/TableLoadingState.vue'
import { mapExpenseClaimToRequest } from '../composables/useRequests.js'
import { fetchApprovalExpenseClaims, fetchArchivedExpenseClaims } from '../services/reimbursements.js'
import {
extractDateText,
formatDocumentListTime,
resolveDocumentSortTime,
resolveDocumentStayTimeDisplay
} from '../utils/documentCenterTime.js'
import { countNewDocuments, isNewDocument, markDocumentViewed, readDocumentScope, readViewedDocumentKeys, writeDocumentScope } from '../utils/documentCenterNewState.js'
import { extractDateText, formatDocumentListTime, resolveDocumentSortTime, resolveDocumentStayTimeDisplay } from '../utils/documentCenterTime.js'
import { normalizeRequestForUi } from '../utils/requestViewModel.js'
const DOCUMENT_TYPE_ALL = 'all'
const DOCUMENT_TYPE_APPLICATION = 'application'
const DOCUMENT_TYPE_REIMBURSEMENT = 'reimbursement'
const SCENE_ALL = 'all'
const DOCUMENT_SCOPE_ALL = '全部'
const DOCUMENT_SCOPE_APPLICATION = '申请单'
const DOCUMENT_SCOPE_REIMBURSEMENT = '报销单'
const DOCUMENT_SCOPE_REVIEW = '审核单'
const DOCUMENT_SCOPE_ARCHIVE = '归档'
const scopeTabs = [
DOCUMENT_SCOPE_APPLICATION,
DOCUMENT_SCOPE_REIMBURSEMENT,
DOCUMENT_SCOPE_REVIEW,
DOCUMENT_SCOPE_ARCHIVE
]
const scopeTabs = [DOCUMENT_SCOPE_ALL, DOCUMENT_SCOPE_APPLICATION, DOCUMENT_SCOPE_REIMBURSEMENT, DOCUMENT_SCOPE_REVIEW, DOCUMENT_SCOPE_ARCHIVE]
const statusTabs = ['全部', '草稿', '待提交', '审批中', '待补充', '已完成']
const FILTER_CONFIG_BY_SCOPE = {
[DOCUMENT_SCOPE_ALL]: {
searchPlaceholder: '搜索单号、事项、费用场景...',
sceneFallbackLabel: '单据场景',
dateLabel: '单据时间',
statusTitle: '单据状态',
statusTabs,
showDocumentType: true
},
[DOCUMENT_SCOPE_APPLICATION]: {
searchPlaceholder: '搜索申请单号、申请事项、申请场景...',
sceneFallbackLabel: '申请场景',
@@ -339,7 +342,7 @@ const emit = defineEmits([
'summary-change'
])
const activeScopeTab = ref(DOCUMENT_SCOPE_APPLICATION)
const activeScopeTab = ref(readDocumentScope(DOCUMENT_SCOPE_ALL, scopeTabs))
const activeStatusTab = ref('全部')
const activeDocumentType = ref(DOCUMENT_TYPE_ALL)
const activeScene = ref(SCENE_ALL)
@@ -357,6 +360,7 @@ const archiveRows = ref([])
const approvalRows = ref([])
const supportingLoading = ref(false)
const supportingError = ref('')
const viewedDocumentKeys = ref(readViewedDocumentKeys())
const activeFilterConfig = computed(() =>
FILTER_CONFIG_BY_SCOPE[activeScopeTab.value] || FILTER_CONFIG_BY_SCOPE[DOCUMENT_SCOPE_APPLICATION]
@@ -389,13 +393,14 @@ const ownedRows = computed(() =>
.filter(Boolean)
)
const allSummaryRows = computed(() => mergeDocumentRows([...ownedRows.value, ...approvalRows.value, ...archiveRows.value]))
const nonArchivedRows = computed(() => mergeDocumentRows([...ownedRows.value, ...approvalRows.value]))
const scopeNewCountMap = computed(() => ({
[DOCUMENT_SCOPE_APPLICATION]: allSummaryRows.value.filter((row) => row.documentTypeCode === DOCUMENT_TYPE_APPLICATION).length,
[DOCUMENT_SCOPE_REIMBURSEMENT]: ownedRows.value.filter((row) => row.documentTypeCode === DOCUMENT_TYPE_REIMBURSEMENT).length,
[DOCUMENT_SCOPE_REVIEW]: approvalRows.value.length,
[DOCUMENT_SCOPE_ARCHIVE]: archiveRows.value.length
[DOCUMENT_SCOPE_ALL]: countNewDocuments(nonArchivedRows.value, viewedDocumentKeys.value),
[DOCUMENT_SCOPE_APPLICATION]: countNewDocuments(nonArchivedRows.value.filter((row) => row.documentTypeCode === DOCUMENT_TYPE_APPLICATION), viewedDocumentKeys.value),
[DOCUMENT_SCOPE_REIMBURSEMENT]: countNewDocuments(ownedRows.value.filter((row) => row.documentTypeCode === DOCUMENT_TYPE_REIMBURSEMENT), viewedDocumentKeys.value),
[DOCUMENT_SCOPE_REVIEW]: countNewDocuments(approvalRows.value, viewedDocumentKeys.value),
[DOCUMENT_SCOPE_ARCHIVE]: countNewDocuments(archiveRows.value, viewedDocumentKeys.value)
}))
const scopeTabItems = computed(() =>
@@ -407,8 +412,10 @@ const scopeTabItems = computed(() =>
)
const activeScopeRows = computed(() => {
if (activeScopeTab.value === DOCUMENT_SCOPE_ALL) return nonArchivedRows.value
if (activeScopeTab.value === DOCUMENT_SCOPE_APPLICATION) {
return allSummaryRows.value.filter((row) => row.documentTypeCode === DOCUMENT_TYPE_APPLICATION)
return nonArchivedRows.value.filter((row) => row.documentTypeCode === DOCUMENT_TYPE_APPLICATION)
}
if (activeScopeTab.value === DOCUMENT_SCOPE_REIMBURSEMENT) {
@@ -423,7 +430,7 @@ const activeScopeRows = computed(() => {
return archiveRows.value
}
return allSummaryRows.value.filter((row) => row.documentTypeCode === DOCUMENT_TYPE_APPLICATION)
return nonArchivedRows.value
})
const sceneFilterOptions = computed(() => {
@@ -487,7 +494,7 @@ const showStayTimeColumn = computed(() =>
)
const documentSummary = computed(() => {
const rows = allSummaryRows.value
const rows = nonArchivedRows.value
return {
total: rows.length,
toSubmit: rows.filter((row) => ['draft', 'pending_submit'].includes(row.statusGroup)).length,
@@ -507,9 +514,9 @@ const emptyState = computed(() => {
title: '当前还没有申请单数据',
desc: '费用申请功能接入后,差旅、会务、办公采购等前置申请会统一汇总到这里。',
icon: 'mdi mdi-file-sign-outline',
actionLabel: '发起申请',
actionIcon: 'mdi mdi-file-plus-outline',
tone: 'sky',
actionLabel: '',
actionIcon: '',
tone: 'emerald',
artLabel: 'APPLY',
tips: ['旧报销中心仍保留', '申请批准后可继续发起报销']
}
@@ -522,9 +529,9 @@ const emptyState = computed(() => {
? '可以清空当前分类下的筛选条件后再看看。'
: '当前视角暂无可展示单据,可以切换其他视角或发起一笔报销。',
icon: filtered ? 'mdi mdi-magnify-scan' : 'mdi mdi-file-document-multiple-outline',
actionLabel: filtered ? '清空筛选' : '发起报销',
actionIcon: filtered ? 'mdi mdi-filter-remove-outline' : 'mdi mdi-plus-circle-outline',
tone: filtered ? 'sky' : 'emerald',
actionLabel: '',
actionIcon: '',
tone: 'emerald',
artLabel: filtered ? 'FILTER' : 'DOCS',
tips: ['单据中心已接入当前报销单据', '归档视角会同步归档中心数据']
}
@@ -543,13 +550,17 @@ function buildDocumentRow(request, options = {}) {
const claimId = normalized.claimId || normalized.id || documentNo
const createdAtSource = normalized.createdAt || normalized.submittedAt || normalized.applyTime || normalized.updatedAt
const updatedAtSource = normalized.updatedAt || normalized.submittedAt || normalized.createdAt || normalized.applyTime
const documentTypeCode = normalized.documentTypeCode || DOCUMENT_TYPE_REIMBURSEMENT
const documentTypeLabel =
normalized.documentTypeLabel
|| (documentTypeCode === DOCUMENT_TYPE_APPLICATION ? '申请单' : '报销单')
return {
...normalized,
rawRequest: request,
documentKey: `${options.source || 'owned'}:${claimId || documentNo}`,
documentTypeCode: DOCUMENT_TYPE_REIMBURSEMENT,
documentTypeLabel: '报销单',
documentTypeCode,
documentTypeLabel,
claimId,
documentNo,
node: archived ? '财务归档' : (normalized.node || normalized.workflowNode || '待提交'),
@@ -560,6 +571,7 @@ function buildDocumentRow(request, options = {}) {
archived,
createdAtDisplay: formatDocumentListTime(createdAtSource),
stayTimeDisplay: resolveDocumentStayTimeDisplay(normalized),
isNewDocument: isNewDocument({ ...normalized, source: options.source || 'owned', claimId, documentNo }, viewedDocumentKeys.value),
updatedAtDisplay: formatDocumentListTime(updatedAtSource),
sortTime: resolveDocumentSortTime(updatedAtSource)
}
@@ -703,6 +715,8 @@ function changePageSize(size) {
}
function openDocument(row) {
writeDocumentScope(activeScopeTab.value, scopeTabs)
viewedDocumentKeys.value = markDocumentViewed(row, viewedDocumentKeys.value)
emit('open-document', row.rawRequest || row)
}

View File

@@ -40,8 +40,8 @@
<header class="assistant-header">
<div class="assistant-header-main">
<div>
<h2>财务助手</h2>
<p>个人财务中心 · 报销识别票据核对与制度咨询右侧会随处理进度展示识别结果与风险提示</p>
<h2>{{ assistantHeaderTitle }}</h2>
<p>{{ assistantHeaderDescription }}</p>
</div>
</div>
</header>
@@ -60,7 +60,9 @@
:key="shortcut.label"
type="button"
class="shortcut-chip"
:disabled="submitting || reviewActionBusy || deleteSessionBusy || sessionSwitchBusy"
:class="{ active: shortcut.active }"
:aria-pressed="shortcut.active ? 'true' : 'false'"
:disabled="shortcut.active || submitting || reviewActionBusy || deleteSessionBusy || sessionSwitchBusy"
@click="runShortcut(shortcut)"
>
<i :class="shortcut.icon"></i>
@@ -1313,6 +1315,22 @@
@confirm="confirmDeleteCurrentSession"
/>
<ConfirmDialog
:open="applicationSubmitConfirmDialog.open"
badge="提交确认"
badge-tone="primary"
title="确认提交当前费用申请?"
description="提交后申请将进入领导审核流程,并同步纳入预算管理口径,请确认关键申请信息和预计费用已经核对无误。"
cancel-text="再检查一下"
confirm-text="确认提交"
busy-text="提交中..."
confirm-tone="primary"
confirm-icon="mdi mdi-send-check-outline"
:busy="reviewActionBusy"
@close="closeApplicationSubmitConfirm"
@confirm="confirmApplicationSubmit"
/>
<ConfirmDialog
:open="nextStepConfirmDialog.open"
badge="提交确认"

View File

@@ -55,7 +55,7 @@
<article class="progress-card panel">
<div class="progress-block">
<div class="progress-head">
<h3>{{ isTravelRequest ? '差旅进度' : '报销进度' }}</h3>
<h3>{{ isApplicationDocument ? '申请进度' : isTravelRequest ? '差旅进度' : '报销进度' }}</h3>
</div>
<div class="progress-line" :style="{ '--progress-columns': progressSteps.length }">
<div
@@ -133,12 +133,18 @@
<article class="detail-card panel">
<div class="detail-card-head">
<div>
<h3>费用明细</h3>
<h3>{{ isApplicationDocument ? '申请预算' : '费用明细' }}</h3>
<p>
{{ isTravelRequest ? '按出行时间逐笔核对票据与差旅规则。' : '按业务发生时间逐笔核对票据、用途说明与 AI 识别结果。' }}
{{
isApplicationDocument
? '展示本次费用申请的预计金额,提交后纳入预算管理口径。'
: isTravelRequest
? '按出行时间逐笔核对票据与差旅规则。'
: '按业务发生时间逐笔核对票据、用途说明与 AI 识别结果。'
}}
</p>
</div>
<div class="detail-card-actions">
<div v-if="!isApplicationDocument" class="detail-card-actions">
<button v-if="canOpenAiEntry" class="smart-entry-btn" type="button" @click="openAiEntry">
<i class="mdi mdi-robot-outline"></i>
<span>智能录入</span>
@@ -156,7 +162,13 @@
</div>
</div>
<div class="detail-expense-table">
<div v-if="isApplicationDocument" class="detail-note readonly">
<p>
预计总费用{{ request.amountDisplay }}该金额用于领导审批和预算管理无需补充任何报销票据
</p>
</div>
<div v-else class="detail-expense-table">
<table>
<thead>
<tr>
@@ -381,7 +393,7 @@
</tbody>
</table>
</div>
<div v-if="expenseItems.length" class="expense-total-under-table">
<div v-if="expenseItems.length && !isApplicationDocument" class="expense-total-under-table">
<span>金额合计</span>
<strong>{{ expenseTotal }}</strong>
</div>
@@ -476,7 +488,7 @@
@click="handleReturnRequest"
>
<i class="mdi mdi-undo"></i>
{{ returnBusy ? '退回中' : '退回单据' }}
{{ returnBusy ? '退回中' : isApplicationDocument ? '退回申请' : '退回单据' }}
</button>
<button
v-if="canApproveRequest"
@@ -496,10 +508,12 @@
@click="handleDeleteRequest"
>
<i class="mdi mdi-trash-can-outline"></i>
{{ deleteBusy ? '删除中' : '删除单据' }}
{{ deleteBusy ? '删除中' : isApplicationDocument ? '删除申请' : '删除单据' }}
</button>
</div>
<p v-else class="detail-action-hint">当前单据已进入流程详情页仅展示状态与费用明细</p>
<p v-else class="detail-action-hint">
{{ isApplicationDocument ? '当前申请单已进入流程,详情页仅展示状态与申请信息。' : '当前单据已进入流程,详情页仅展示状态与费用明细。' }}
</p>
</footer>
</div>
@@ -633,7 +647,7 @@
badge="提交确认"
badge-tone="warning"
:title="`确认提交 ${request.id} 吗?`"
description="请确认报销事由、金额、费用明细和附件材料均已核对无误。确认后系统将发起 AI 预审并进入审批流程。"
:description="isApplicationDocument ? '请确认申请事由、预计金额和申请信息均已核对无误。确认后将提交至直属领导审批。' : '请确认报销事由、金额、费用明细和附件材料均已核对无误。确认后系统将发起 AI 预审并进入审批流程。'"
cancel-text="返回核对"
confirm-text="确认提交"
busy-text="提交中..."
@@ -649,14 +663,14 @@
<strong>{{ request.documentNo || request.id }}</strong>
</div>
<div class="submit-confirm-row">
<span>报销类型</span>
<span>{{ isApplicationDocument ? '申请类型' : '报销类型' }}</span>
<strong>{{ request.typeLabel }}</strong>
</div>
<div class="submit-confirm-row">
<span>报销金额</span>
<span>{{ isApplicationDocument ? '预计金额' : '报销金额' }}</span>
<strong>{{ request.amountDisplay || expenseTotal }}</strong>
</div>
<div class="submit-confirm-row">
<div v-if="!isApplicationDocument" class="submit-confirm-row">
<span>费用明细</span>
<strong>{{ expenseItems.length }} / {{ uploadedExpenseCount }} 张单据</strong>
</div>

View File

@@ -29,6 +29,11 @@ import {
buildExpenseSceneSelectionActions
} from '../../utils/expenseAssistantActions.js'
import { buildSuggestedActionKey } from '../../utils/suggestedActionKey.js'
import { ASSISTANT_SCOPE_ACTION_SWITCH } from '../../utils/assistantSessionScope.js'
import {
mergeComposerPrefill,
resolveSuggestedActionPrefill
} from '../../utils/assistantSuggestedActionPrefill.js'
import {
calculateTravelReimbursement,
fetchExpenseClaims,
@@ -143,11 +148,14 @@ import {
resolveDocumentPreview
} from './travelReimbursementAttachmentModel.js'
import {
ASSISTANT_SESSION_MODE_OPTIONS,
ASSISTANT_DISPLAY_NAME,
FLOW_STEP_FALLBACKS,
HOT_KNOWLEDGE_QUESTIONS,
INTENT_LABELS,
SCENARIO_LABELS,
SESSION_TYPE_APPLICATION,
SESSION_TYPE_APPROVAL,
SESSION_TYPE_EXPENSE,
SESSION_TYPE_KNOWLEDGE,
aiAvatar,
@@ -156,6 +164,7 @@ import {
buildMessageMeta,
buildWelcomeInsight,
createMessage,
resolveAssistantSessionMode,
resolveKnowledgeRankLabel,
resolveKnowledgeRankTone,
sanitizeRequest,
@@ -195,6 +204,7 @@ const REVIEW_PANEL_SCOPE_OVERVIEW = 'overview'
const REVIEW_PANEL_SCOPE_DOCUMENTS = 'documents'
const REVIEW_PANEL_SCOPE_RISK = 'risk'
const REVIEW_NEXT_STEP_HREF = '#review-next-step'
const APPLICATION_SUBMIT_HREF = '#application-submit'
const REVIEW_RISK_PANEL_HREF_PREFIX = '#review-risk'
const REVIEW_QUICK_EDIT_HREF = '#review-quick-edit'
const FLOW_STEP_STATUS_PENDING = 'pending'
@@ -544,7 +554,6 @@ export default {
resolveCurrentUserId,
persistSessionState,
applySessionState,
clearKnowledgeSessionOnEntry,
switchSessionType
} = useTravelReimbursementSessionState({
props,
@@ -557,6 +566,10 @@ export default {
getSessionRuntimeRefs: () => sessionRuntimeRefs
})
const deleteSessionDialogOpen = ref(false)
const applicationSubmitConfirmDialog = ref({
open: false,
message: null
})
const nextStepConfirmDialog = ref({
open: false,
message: null,
@@ -566,6 +579,9 @@ export default {
const deleteSessionBusy = ref(false)
const reviewDrawerMode = ref(REVIEW_DRAWER_MODE_REVIEW)
const isKnowledgeSession = computed(() => activeSessionType.value === SESSION_TYPE_KNOWLEDGE)
const activeAssistantMode = computed(() => resolveAssistantSessionMode(activeSessionType.value))
const assistantHeaderTitle = computed(() => activeAssistantMode.value?.label || '财务助手')
const assistantHeaderDescription = computed(() => activeAssistantMode.value?.description || '个人财务中心')
const {
flowRunId,
flowSteps,
@@ -640,6 +656,12 @@ export default {
if (props.entrySource === 'detail' && linkedRequest.value?.id) {
return `例如:解释一下 ${linkedRequest.value.id} 的报销风险,或帮我生成处理意见草稿。`
}
if (activeSessionType.value === SESSION_TYPE_APPLICATION) {
return '例如:我想先申请一笔差旅费用,去上海支持项目部署。'
}
if (activeSessionType.value === SESSION_TYPE_APPROVAL) {
return '例如:查一下待我审核的单据,或帮我生成这张单据的审核意见。'
}
return '例如查一下近10日报销金额、解释酒店超标风险或根据附件整理报销核对信息。'
})
const currentIntentLabel = computed(() => {
@@ -652,12 +674,11 @@ export default {
agent: '知识回答'
}
: {
welcome: '财务助手',
welcome: activeAssistantMode.value?.label || '财务助手',
agent: '处理中'
}
return labels[currentInsight.value.intent] ?? 'AI 处理中'
})
let knowledgeSessionResetPromise = Promise.resolve()
const canDeleteCurrentSession = computed(
() => Boolean(conversationId.value) || messages.value.some((item) => item.role === 'user')
)
@@ -1008,14 +1029,15 @@ export default {
}
const isReviewOverviewDrawer = computed(() => reviewDrawerMode.value === REVIEW_DRAWER_MODE_REVIEW)
const shortcuts = computed(() => [
{
label: isKnowledgeSession.value ? '切换为个人工作台' : '切换为财务知识问答',
icon: isKnowledgeSession.value ? 'mdi mdi-briefcase-outline' : 'mdi mdi-book-open-page-variant-outline',
const shortcuts = computed(() =>
ASSISTANT_SESSION_MODE_OPTIONS.map((mode) => ({
label: mode.label,
icon: mode.icon,
action: 'switch_view',
targetSessionType: isKnowledgeSession.value ? SESSION_TYPE_EXPENSE : SESSION_TYPE_KNOWLEDGE
}
])
targetSessionType: mode.key,
active: mode.key === activeSessionType.value
}))
)
watch(
() => [activeReviewPayload.value, activeReviewPanelScope.value],
([payload]) => {
@@ -1147,7 +1169,6 @@ export default {
scrollToBottom()
})
})
void clearKnowledgeSessionOnEntry()
currentInsight.value =
currentInsight.value
|| buildWelcomeInsight(props.entrySource, linkedRequest.value, activeSessionType.value, currentUser.value)
@@ -1269,6 +1290,9 @@ export default {
async function runShortcut(shortcut) {
if (shortcut?.action === 'switch_view' && shortcut?.targetSessionType) {
if (shortcut.active) {
return
}
await switchSessionType(shortcut.targetSessionType)
return
}
@@ -1325,12 +1349,52 @@ export default {
persistSessionState()
}
function applySuggestedActionPrefill(action) {
const prefillText = resolveSuggestedActionPrefill(action)
if (!prefillText) {
return false
}
composerDraft.value = mergeComposerPrefill(composerDraft.value, prefillText)
nextTick(() => {
adjustComposerTextareaHeight()
composerTextareaRef.value?.focus()
})
persistSessionState()
return true
}
async function handleSuggestedAction(message, action) {
const actionType = String(action?.action_type || '').trim()
if (!actionType || submitting.value || reviewActionBusy.value || sessionSwitchBusy.value) return
if (message?.suggestedActionsLocked) return
if (applySuggestedActionPrefill(action)) return
if (await handleGuidedSuggestedAction(message, action)) return
if (actionType === ASSISTANT_SCOPE_ACTION_SWITCH) {
const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
const targetSessionType = String(actionPayload.session_type || '').trim()
if (!targetSessionType) return
const carryText = String(actionPayload.carry_text || '').trim()
const carryFiles = actionPayload.carry_files ? Array.from(attachedFiles.value || []) : []
if (!lockSuggestedActionMessage(message, action)) return
await switchSessionType(targetSessionType)
if (carryText) {
composerDraft.value = carryText
}
if (carryFiles.length) {
const fileMergeResult = mergeFilesWithLimit([], carryFiles, MAX_ATTACHMENTS)
attachedFiles.value = fileMergeResult.files
composerFilesExpanded.value = fileMergeResult.files.length > VISIBLE_ATTACHMENT_CHIPS
}
nextTick(() => {
adjustComposerTextareaHeight()
scrollToBottom()
})
persistSessionState()
return
}
if (actionType === 'confirm_expense_intent') {
const originalMessage = String(action?.payload?.original_message || message?.text || '').trim()
if (!originalMessage) return
@@ -1571,6 +1635,60 @@ export default {
}
}
function openApplicationSubmitConfirm(message) {
if (!message) {
return
}
applicationSubmitConfirmDialog.value = {
open: true,
message
}
}
function closeApplicationSubmitConfirm() {
if (reviewActionBusy.value) {
return
}
applicationSubmitConfirmDialog.value = {
open: false,
message: null
}
}
async function confirmApplicationSubmit() {
const message = applicationSubmitConfirmDialog.value.message
if (!message || submitting.value || reviewActionBusy.value) {
return
}
applicationSubmitConfirmDialog.value = {
open: false,
message: null
}
reviewActionBusy.value = true
try {
const payload = await submitComposer({
rawText: '确认提交',
userText: '确认提交',
pendingText: '正在提交费用申请...',
systemGenerated: true
})
const draftPayload = payload?.result?.draft_payload || {}
const claimNo = String(draftPayload.claim_no || '').trim()
const claimId = String(draftPayload.claim_id || '').trim()
if (String(payload?.status || '').trim() === 'succeeded' && (claimNo || claimId)) {
emit('draft-saved', {
claimId,
claimNo,
status: 'submitted',
approvalStage: String(draftPayload.approval_stage || '直属领导审批').trim(),
documentType: 'application'
})
}
} finally {
reviewActionBusy.value = false
}
}
function isWorkbenchBusy() {
return submitting.value || reviewActionBusy.value || sessionSwitchBusy.value
}
@@ -1796,6 +1914,12 @@ export default {
}
const href = String(anchor.getAttribute('href') || '').trim()
if (href === APPLICATION_SUBMIT_HREF) {
event.preventDefault()
openApplicationSubmitConfirm(message)
return
}
if (href === REVIEW_NEXT_STEP_HREF) {
event.preventDefault()
openReviewNextStepConfirm(message)
@@ -1890,16 +2014,16 @@ export default {
emit, ASSISTANT_DISPLAY_NAME, aiAvatar, userAvatar, fileInputRef, composerTextareaRef, messageListRef, composerDraft, composerDatePickerOpen, composerDateMode, composerSingleDate, composerRangeStartDate, composerRangeEndDate, composerBusinessTimeTags, composerCanApplyDateSelection,
toggleComposerDatePicker, closeComposerDatePicker, setComposerDateMode, handleComposerDateInputChange, removeComposerBusinessTimeTag, flowSteps, flowRunId, flowRefreshBusy, completedFlowStepCount, flowOverallStatusTone, flowOverallStatusText, flowTotalDurationText,
attachedFiles, composerFilesExpanded, visibleAttachedFiles, hiddenAttachedFileCount, submitting, sessionSwitchBusy, messages, currentInsight, linkedRequest, canSubmit, activeSessionType, isKnowledgeSession, hotKnowledgeQuestions,
hasInsightPanelContent, showInsightPanel, insightPanelToggleLabel, composerPlaceholder, currentIntentLabel, canDeleteCurrentSession, latestReviewMessage, activeReviewPayload, activeReviewPanelScope, activeReviewFilePreviews, reviewDrawerMode, isReviewOverviewDrawer, isReviewDocumentDrawer, isReviewRiskDrawer, isReviewFlowDrawer,
hasInsightPanelContent, showInsightPanel, insightPanelToggleLabel, assistantHeaderTitle, assistantHeaderDescription, composerPlaceholder, currentIntentLabel, canDeleteCurrentSession, latestReviewMessage, activeReviewPayload, activeReviewPanelScope, activeReviewFilePreviews, reviewDrawerMode, isReviewOverviewDrawer, isReviewDocumentDrawer, isReviewRiskDrawer, isReviewFlowDrawer,
reviewDrawerTitle, reviewOverviewDrawerAvailable, reviewDocumentDrawerAvailable, reviewRiskDrawerAvailable, reviewFlowDrawerAvailable, reviewDocumentDrawerLabel, reviewDocumentDrawerIcon, reviewRiskDrawerLabel, reviewRiskDrawerIcon, reviewFlowDrawerLabel, reviewFlowDrawerIcon, activeReviewDocument, activeReviewDocumentIndex, activeReviewDocumentPreview, canPreviewActiveReviewDocument,
reviewIntentText, reviewFactCards, reviewCategoryOptions, reviewOtherCategoryOptions, reviewSelectedOtherCategory, reviewInlineDirty, reviewInlineForm, reviewInlineEditorKey, reviewInlineErrors, reviewOtherCategoryOpen, reviewInlinePendingFiles, DATE_INPUT_FORMAT, REVIEW_SCENE_OTHER_OPTION, REVIEW_SCENE_OPTIONS, REVIEW_OTHER_CATEGORY_OPTIONS,
workbenchVisible, reviewPanelConfidence, reviewRiskSummary, reviewRiskItems, reviewRiskEmpty, recognizedNarratives, reviewRecognitionNotes, reviewDocumentSummaries, reviewDocumentCount, reviewDocumentDirty, reviewHasUnsavedChanges,
travelCalculatorOpen, travelCalculatorBusy, travelCalculatorError, travelCalculatorResult, travelCalculatorForm, travelCalculatorCanSubmit, deleteSessionDialogOpen, nextStepConfirmDialog, reviewActionBusy, deleteSessionBusy, documentPreviewDialog, shortcuts,
travelCalculatorOpen, travelCalculatorBusy, travelCalculatorError, travelCalculatorResult, travelCalculatorForm, travelCalculatorCanSubmit, deleteSessionDialogOpen, applicationSubmitConfirmDialog, nextStepConfirmDialog, reviewActionBusy, deleteSessionBusy, documentPreviewDialog, shortcuts,
resolveReviewMissingSlotCards, resolveReviewRiskBriefs, buildReviewHeadline, buildReviewSubline, buildReviewStateLabel, buildReviewStateTone, buildReviewPlainFollowupCopy, buildReviewPlainFollowupForMessage, buildReviewNextStepRichCopyForMessage, buildMessageBubbleClass, resolveReviewFooterActions, resolveReviewSaveDraftAction, buildReviewPrimaryButtonLabel, buildReviewMainMessageText,
renderMarkdown, buildExpenseQueryWindowLabel, buildExpenseQueryHint, getExpenseQueryActivePage, getExpenseQueryTotalPages, getExpenseQueryVisibleRecords, resolveDocumentPreview, triggerFileUpload, applyComposerDateSelection, handleFilesChange, handleComposerInput, handleComposerEnter, runShortcut, runWelcomeQuickAction: runShortcut, handleSuggestedAction, isSuggestedActionSelected, askHotKnowledgeQuestion, resolveKnowledgeRankLabel, resolveKnowledgeRankTone,
refreshFlowRunDetail, formatFlowStepDuration, resolveFlowStepStatusLabel, resolveFlowStepDetail, toggleInsightPanel, openTravelCalculator, toggleTravelCalculator, closeTravelCalculator, submitTravelCalculator, switchToReviewOverviewDrawer, toggleReviewDocumentDrawer, toggleReviewRiskDrawer, toggleReviewFlowDrawer, toggleAttachedFilesExpanded, removeAttachedFile, clearAttachedFiles,
requestCloseWorkbench, emitCloseAfterLeave, handleAssistantModalAfterEnter, openExpenseQueryRecord, handleExpenseQueryRecordClick, setExpenseQueryPage, shiftExpenseQueryPage, openDeleteSessionDialog, closeDeleteSessionDialog, confirmDeleteCurrentSession, openInlineReviewEditor, closeInlineReviewEditor, commitInlineReviewEditor, clearInlineReviewFieldError, selectInlineScene, selectReviewCategory, selectReviewOtherCategory,
queryDraftByClaimNo, appendReviewRiskBriefToConversation, appendExpenseQueryRiskToConversation, goReviewDocument, openActiveReviewDocumentPreview, closeDocumentPreview, saveInlineReviewChanges, submitComposer, handleAssistantMarkdownClick, handleReviewAction, handleSaveDraftDirectly, closeReviewNextStepConfirm, confirmReviewNextStepSubmit, isDraftSavedReviewMessage, canUseInlineSaveDraft, handleInlineSaveDraft
queryDraftByClaimNo, appendReviewRiskBriefToConversation, appendExpenseQueryRiskToConversation, goReviewDocument, openActiveReviewDocumentPreview, closeDocumentPreview, saveInlineReviewChanges, submitComposer, handleAssistantMarkdownClick, handleReviewAction, handleSaveDraftDirectly, closeApplicationSubmitConfirm, confirmApplicationSubmit, closeReviewNextStepConfirm, confirmReviewNextStepSubmit, isDraftSavedReviewMessage, canUseInlineSaveDraft, handleInlineSaveDraft
}
}
}

View File

@@ -45,6 +45,7 @@ import {
buildOptionalTravelReceiptRiskCards,
formatCurrency,
isPlaceholderValue,
isApplicationDocumentRequest,
isRouteDescriptionExpenseType,
isSyntheticLocationDisplay,
isValidIsoDate,
@@ -192,6 +193,10 @@ function buildExpenseItemViewModel(source, index, requestModel, travelTimeLabelM
attachmentStatus: isSystemGenerated ? '无需附件' : attachments.length ? '已关联票据' : '未上传',
function buildOptionalTravelReceiptRiskCards(requestModel, items) {
if (isApplicationDocumentRequest(requestModel)) {
return []
}
const normalizedItems = Array.isArray(items) ? items : []
const isTravelContext =
requestModel?.detailVariant === 'travel' ||
@@ -449,7 +454,8 @@ export default {
)
})
const isTravelRequest = computed(() => request.value.detailVariant === 'travel')
const isApplicationDocument = computed(() => isApplicationDocumentRequest(request.value))
const isTravelRequest = computed(() => request.value.detailVariant === 'travel' && !isApplicationDocument.value)
const isDraftRequest = computed(() => request.value.approvalKey === 'draft')
const isEditableRequest = computed(() => ['draft', 'supplement'].includes(request.value.approvalKey))
const canOpenAiEntry = computed(() => isEditableRequest.value)
@@ -478,39 +484,59 @@ export default {
&& canApproveLeaderExpenseClaims(currentUser.value)
)
|| (
isFinanceApprovalStage.value
!isApplicationDocument.value
&& isFinanceApprovalStage.value
&& isFinanceUser(currentUser.value)
)
)
)
const showLeaderApprovalPanel = computed(() => canApproveRequest.value)
const approvalOpinionTitle = computed(() => (isFinanceApprovalStage.value ? '财务意见' : '领导意见'))
const approvalOpinionPlaceholder = computed(() =>
isFinanceApprovalStage.value
? '请输入财务终审意见,可补充票据核验、金额一致性或入账关注点。'
: '请输入审批意见,可补充核实情况、费用合理性或后续财务关注点。'
)
const approvalOpinionHint = computed(() =>
isFinanceApprovalStage.value ? '审核通过后将进入归档入账。' : '审批通过后将流转至财务审批。'
)
const approvalOpinionPlaceholder = computed(() => {
if (isFinanceApprovalStage.value) {
return '请输入财务终审意见,可补充票据核验、金额一致性或入账关注点。'
}
if (isApplicationDocument.value) {
return '请输入审批意见,可补充业务必要性、预算合理性或执行要求。'
}
return '请输入审批意见,可补充核实情况、费用合理性或后续财务关注点。'
})
const approvalOpinionHint = computed(() => {
if (isFinanceApprovalStage.value) {
return '审核通过后将进入归档入账。'
}
return isApplicationDocument.value ? '审批通过后申请流程完成。' : '审批通过后将流转至财务审批。'
})
const approvalConfirmBadge = computed(() => (isFinanceApprovalStage.value ? '财务终审' : '领导审批'))
const approvalConfirmDescription = computed(() =>
isFinanceApprovalStage.value
? '确认后该报销单会完成财务终审并进入归档入账,请确认票据、金额与财务意见无误。'
: '确认后该报销单会从直属领导审批流转到财务审批,请确认申请信息与领导意见无误。'
)
const approvalNextStage = computed(() => (isFinanceApprovalStage.value ? '归档入账' : '财务审批'))
const approvalSuccessToast = computed(() =>
isFinanceApprovalStage.value
? `${request.value.id} 已完成财务终审,进入归档入账。`
const approvalConfirmDescription = computed(() => {
if (isFinanceApprovalStage.value) {
return '确认后该报销单会完成财务终审并进入归档入账,请确认票据、金额与财务意见无误。'
}
if (isApplicationDocument.value) {
return '确认后该申请单会完成直属领导审批,请确认申请信息与领导意见无误。'
}
return '确认后该报销单会从直属领导审批流转到财务审批,请确认申请信息与领导意见无误。'
})
const approvalNextStage = computed(() => {
if (isFinanceApprovalStage.value) {
return '归档入账'
}
return isApplicationDocument.value ? '审批完成' : '财务审批'
})
const approvalSuccessToast = computed(() => {
if (isFinanceApprovalStage.value) {
return `${request.value.id} 已完成财务终审,进入归档入账。`
}
return isApplicationDocument.value
? `${request.value.id} 申请已审批通过。`
: `${request.value.id} 已审批通过,流转至财务审批。`
)
})
const deleteActionLabel = computed(() => (isDraftRequest.value ? '删除草稿' : '删除单据'))
const deleteDialogTitle = computed(() => `确认${deleteActionLabel.value} ${request.value.id} 吗?`)
const deleteDialogDescription = computed(() =>
isDraftRequest.value
? '删除后该草稿及其当前费用明细将不可恢复,请确认本次操作。'
: '删除后该报销单及费用明细将不可恢复,请确认本次操作。'
: `删除后该${isApplicationDocument.value ? '申请单' : '报销单'}及费用明细将不可恢复,请确认本次操作。`
)
const actionBusy = computed(() =>
Boolean(savingExpenseId.value)
@@ -562,7 +588,7 @@ export default {
const heroFactItems = computed(() => [
{
key: 'document',
label: '报销单号',
label: isApplicationDocument.value ? '申请单号' : '报销单号',
value: request.value.documentNo || request.value.id,
icon: 'mdi mdi-camera-outline',
valueClass: ''
@@ -576,14 +602,14 @@ export default {
},
{
key: 'amount',
label: '报销金额',
label: isApplicationDocument.value ? '预计金额' : '报销金额',
value: request.value.amountDisplay,
icon: '',
valueClass: 'amount'
},
{
key: 'type',
label: isTravelRequest.value ? '差旅类型' : '报销类型',
label: isApplicationDocument.value ? '申请类型' : isTravelRequest.value ? '差旅类型' : '报销类型',
value: request.value.typeLabel,
icon: '',
valueClass: ''
@@ -600,7 +626,7 @@ export default {
const progressSteps = computed(() =>
Array.isArray(request.value.progressSteps) && request.value.progressSteps.length
? request.value.progressSteps
: buildFallbackProgressSteps()
: buildFallbackProgressSteps(request.value)
)
const currentProgressRingMotion = {
@@ -1530,7 +1556,11 @@ export default {
const claimStatus = String(payload?.status || '').trim().toLowerCase()
const approvalStage = String(payload?.approval_stage || payload?.approvalStage || '').trim()
if (claimStatus === 'submitted') {
toast(`${request.value.id} 已完成 AI预审${approvalStage ? `,当前节点:${approvalStage}` : ',并已提交审批'}`)
toast(
isApplicationDocument.value
? `${request.value.id} 申请单已提交${approvalStage ? `,当前节点:${approvalStage}` : ',等待直属领导审批'}`
: `${request.value.id} 已完成 AI预审${approvalStage ? `,当前节点:${approvalStage}` : ',并已提交审批'}`
)
} else if (claimStatus === 'supplement') {
toast(`${request.value.id} AI预审未通过已转待补充。`)
} else {
@@ -1577,7 +1607,7 @@ export default {
try {
const payload = await deleteExpenseClaim(request.value.claimId)
deleteDialogOpen.value = false
toast(payload?.message || `${request.value.id} 报销单已删除。`)
toast(payload?.message || `${request.value.id} ${isApplicationDocument.value ? '申请单' : '报销单'}已删除。`)
emit('request-deleted', { claimId: request.value.claimId })
} catch (error) {
toast(error?.message || '删除单据失败,请稍后重试。')
@@ -1722,7 +1752,7 @@ export default {
expenseTypeOptions: EXPENSE_TYPE_OPTIONS,
goToNextSubmitRisk, goToPreviousSubmitRisk,
handleAddExpenseItem, handleApproveRequest, handleDeleteRequest, handleExpenseFileChange,
handleReturnRequest, handleSubmit, heroFactItems, isDraftRequest, isEditableRequest, isTravelRequest,
handleReturnRequest, handleSubmit, heroFactItems, isApplicationDocument, isDraftRequest, isEditableRequest, isTravelRequest,
isMajorExpenseRisk,
openAiEntry, openAttachmentPreview, goToNextAttachmentPreview, goToPreviousAttachmentPreview,
profile, progressSteps, request, leaderOpinion, removeExpenseAttachment, removeExpenseItem,

View File

@@ -9,14 +9,65 @@ import {
} from './travelReimbursementGuidedFlowModel.js'
export const SESSION_TYPE_EXPENSE = 'expense'
export const SESSION_TYPE_APPLICATION = 'application'
export const SESSION_TYPE_APPROVAL = 'approval'
export const SESSION_TYPE_KNOWLEDGE = 'knowledge'
export const ASSISTANT_SESSION_TYPES = [
SESSION_TYPE_APPLICATION,
SESSION_TYPE_EXPENSE,
SESSION_TYPE_APPROVAL,
SESSION_TYPE_KNOWLEDGE
]
export const ASSISTANT_SESSION_MODE_OPTIONS = [
{
key: SESSION_TYPE_APPLICATION,
label: '申请助手',
icon: 'mdi mdi-file-plus-outline',
description: '只处理费用申请、事前审批、申请材料和申请状态'
},
{
key: SESSION_TYPE_EXPENSE,
label: '报销助手',
icon: 'mdi mdi-receipt-text-plus-outline',
description: '只处理报销发起、票据识别、草稿归集和报销状态'
},
{
key: SESSION_TYPE_APPROVAL,
label: '审核助手',
icon: 'mdi mdi-clipboard-check-outline',
description: '只处理待审单据、风险解释、审批动作和审核意见'
},
{
key: SESSION_TYPE_KNOWLEDGE,
label: '财务知识助手',
icon: 'mdi mdi-book-open-page-variant-outline',
description: '只处理财务制度、标准规则、票据要求和政策解释'
}
]
export function normalizeAssistantSessionType(sessionType, fallback = SESSION_TYPE_EXPENSE) {
const normalized = String(sessionType || '').trim()
if (ASSISTANT_SESSION_TYPES.includes(normalized)) {
return normalized
}
const fallbackType = String(fallback || '').trim()
return ASSISTANT_SESSION_TYPES.includes(fallbackType) ? fallbackType : SESSION_TYPE_EXPENSE
}
export function resolveAssistantSessionMode(sessionType) {
const normalized = normalizeAssistantSessionType(sessionType)
return ASSISTANT_SESSION_MODE_OPTIONS.find((item) => item.key === normalized) || ASSISTANT_SESSION_MODE_OPTIONS[1]
}
export const aiAvatar = '/assets/header.png'
export const userAvatar = '/assets/person.png'
export const SOURCE_LABELS = {
workbench: '来自个人工作台',
topbar: '来自发起报销',
application: '来自发起申请',
detail: '来自智能录入',
upload: '来自附件上传',
requests: '来自报销列表'
@@ -109,6 +160,42 @@ export const EXPENSE_WELCOME_QUICK_ACTIONS = [
}
]
export const APPLICATION_WELCOME_QUICK_ACTIONS = [
{
label: '快速发起申请',
prompt: '我想快速发起一笔费用申请,请先帮我判断申请类型并引导补充信息。',
icon: 'mdi mdi-file-plus-outline'
},
{
label: '查询申请状态',
prompt: '帮我查询我的费用申请单状态,筛选最近的 5 条记录。',
icon: 'mdi mdi-file-search-outline'
},
{
label: '申请材料清单',
prompt: '请告诉我发起费用申请通常需要准备哪些关键信息和附件。',
icon: 'mdi mdi-clipboard-text-search-outline'
}
]
export const APPROVAL_WELCOME_QUICK_ACTIONS = [
{
label: '待我审核',
prompt: '帮我查询当前待我审核的单据,筛选最近的 5 条记录。',
icon: 'mdi mdi-clipboard-list-outline'
},
{
label: '审核风险说明',
prompt: '帮我梳理待审核单据中需要重点关注的风险,并按高、中、低风险分类说明。',
icon: 'mdi mdi-alert-circle-outline'
},
{
label: '生成审核意见',
prompt: '请根据当前待审核单据的风险点,帮我生成一段专业、克制的审核意见草稿。',
icon: 'mdi mdi-text-box-edit-outline'
}
]
export const HOT_KNOWLEDGE_QUESTIONS = [
'差旅住宿标准按什么规则执行?',
'酒店超标后如何申请例外报销?',
@@ -418,7 +505,8 @@ export function buildWelcomeUserContext(user = {}) {
}
export function buildWelcomeQuickActions(sessionType, user, entrySource, linkedRequest) {
if (sessionType === SESSION_TYPE_KNOWLEDGE) {
const normalizedSessionType = normalizeAssistantSessionType(sessionType)
if (normalizedSessionType === SESSION_TYPE_KNOWLEDGE) {
return HOT_KNOWLEDGE_QUESTIONS.slice(0, 6).map((question) => ({
label: question.length > 20 ? `${question.slice(0, 20)}` : question,
prompt: question,
@@ -426,23 +514,58 @@ export function buildWelcomeQuickActions(sessionType, user, entrySource, linkedR
}))
}
if (normalizedSessionType === SESSION_TYPE_APPLICATION) {
return APPLICATION_WELCOME_QUICK_ACTIONS
}
if (normalizedSessionType === SESSION_TYPE_APPROVAL) {
return APPROVAL_WELCOME_QUICK_ACTIONS
}
return EXPENSE_WELCOME_QUICK_ACTIONS
}
export function buildWelcomeMessage(entrySource, linkedRequest, sessionType = SESSION_TYPE_EXPENSE, user = null) {
const normalizedSessionType = normalizeAssistantSessionType(sessionType)
const ctx = buildWelcomeUserContext(user || {})
const greeting = ctx.isAdmin ? `${ctx.honorific},您好` : `您好,${ctx.honorific}`
if (sessionType === SESSION_TYPE_KNOWLEDGE) {
if (normalizedSessionType === SESSION_TYPE_KNOWLEDGE) {
return [
`${greeting}!今日是 **${ctx.dateLine}**。`,
'',
'欢迎进入 **个人财务中心 · 知识问答**。我是您的财务助手,可以帮您查制度、报销标准、票据要求和常见财务问题。',
'**欢迎来到个人财务中心 · 财务知识助手。** 我可以帮您查制度、报销标准、票据要求和常见财务问题,并保持知识问答对话独立记录。',
'',
'业务范围:财务制度、标准规则、票据要求和政策口径解释。发起申请、报销处理或审核动作请切换到对应助手。',
'',
'您可以直接输入问题,或点击下方「猜你想问」快速开始。'
].join('\n')
}
if (normalizedSessionType === SESSION_TYPE_APPLICATION) {
return [
`${greeting}!今日是 **${ctx.dateLine}**。`,
'',
'**欢迎来到个人财务中心 · 申请助手。** 我会先判断您要处理的是费用申请、报销申请还是其他财务事项,再按对应流程引导补充信息。',
'',
'业务范围:费用申请、事前审批、申请材料清单和申请单状态。报销票据、审核处理和制度问答请切换到对应助手。',
'',
'您可以直接描述申请事项,或点击下方快捷操作开始发起申请。'
].join('\n')
}
if (normalizedSessionType === SESSION_TYPE_APPROVAL) {
return [
`${greeting}!今日是 **${ctx.dateLine}**。`,
'',
'**欢迎来到个人财务中心 · 审核助手。** 我可以帮您查询待审单据、解释风险点、整理审核意见,并保持审核对话独立记录。',
'',
'业务范围:待审单据查询、审批动作、风险解释和审核意见草稿。申请、报销和制度问答请切换到对应助手。',
'',
'您可以直接输入要审核或查询的内容,或点击下方快捷操作快速开始。'
].join('\n')
}
if (entrySource === 'detail' && linkedRequest?.id) {
return [
`${greeting}!今日是 **${ctx.dateLine}**。`,
@@ -456,16 +579,19 @@ export function buildWelcomeMessage(entrySource, linkedRequest, sessionType = SE
return [
`${greeting}!今日是 **${ctx.dateLine}**。`,
'',
'**欢迎来到个人财务中心。** 我是您的财务助手,可以陪您完成票据识别、报销信息核对、待补项提醒和风险说明。',
'**欢迎来到个人财务中心 · 报销助手。** 我可以陪您完成报销发起、票据识别、草稿归集、报销信息核对、待补项提醒和风险说明,并保持报销对话独立记录。',
'',
'业务范围:发起报销、票据识别、草稿归集、报销状态查询和报销信息核对。申请、审核和制度问答请切换到对应助手。',
'',
'您可以描述一笔费用、上传票据,或点击下方快捷操作直接开始。'
].join('\n')
}
export function buildWelcomeInsight(entrySource, linkedRequest, sessionType = SESSION_TYPE_EXPENSE, user = null) {
const normalizedSessionType = normalizeAssistantSessionType(sessionType)
const ctx = buildWelcomeUserContext(user || {})
if (sessionType === SESSION_TYPE_KNOWLEDGE) {
if (normalizedSessionType === SESSION_TYPE_KNOWLEDGE) {
return {
intent: 'welcome',
metricLabel: '今日',
@@ -476,11 +602,36 @@ export function buildWelcomeInsight(entrySource, linkedRequest, sessionType = SE
}
}
if (normalizedSessionType === SESSION_TYPE_APPLICATION) {
return {
intent: 'welcome',
metricLabel: '当前助手',
metricValue: '申请助手',
title: '申请助手',
summary: `${ctx.honorific},这里会单独保存费用申请相关对话,不会混入报销、审核或知识问答记录。`,
agent: null
}
}
if (normalizedSessionType === SESSION_TYPE_APPROVAL) {
return {
intent: 'welcome',
metricLabel: '当前助手',
metricValue: '审核助手',
title: '审核助手',
summary: `${ctx.honorific},这里会单独保存审核相关对话,适合查询待审单据、风险点和审核意见。`,
agent: null
}
}
return {
intent: 'welcome',
metricLabel: '助手状态',
metricValue: '待您吩咐',
title: entrySource === 'detail' && linkedRequest?.id ? `已关联 ${linkedRequest.id}` : '个人财务中心',
metricLabel: '当前助手',
metricValue: '报销助手',
title:
entrySource === 'detail' && linkedRequest?.id
? `已关联 ${linkedRequest.id}`
: '报销助手',
summary:
entrySource === 'detail' && linkedRequest?.id
? `${ctx.honorific},发送消息或上传附件后,我会结合当前单据继续识别并提示待补项。`
@@ -497,10 +648,10 @@ export function createWelcomeAssistantMessage(entrySource, linkedRequest, sessio
})
}
export function resolveInitialSessionType(conversation) {
export function resolveInitialSessionType(conversation, fallback = SESSION_TYPE_EXPENSE) {
const stateJson = conversation?.state_json || conversation?.stateJson || {}
const sessionType = String(stateJson?.session_type || '').trim()
return sessionType || SESSION_TYPE_EXPENSE
return normalizeAssistantSessionType(sessionType, fallback)
}
export function buildInitialInsightFromConversation(conversation) {

View File

@@ -49,6 +49,32 @@ export function normalizeExpenseType(value) {
return String(value || '').trim() || 'other'
}
export function isApplicationDocumentRequest(request) {
const documentType = String(
request?.documentTypeCode
|| request?.document_type_code
|| request?.documentType
|| request?.document_type
|| ''
).trim()
const claimNo = String(
request?.claimNo
|| request?.claim_no
|| request?.documentNo
|| request?.id
|| ''
).trim().toUpperCase()
const typeCode = normalizeExpenseType(request?.typeCode || request?.expense_type)
return (
documentType === 'application'
|| documentType === 'expense_application'
|| claimNo.startsWith('APP-')
|| typeCode === 'application'
|| typeCode.endsWith('_application')
)
}
export function resolveExpenseTypeLabel(value) {
const normalized = normalizeExpenseType(value)
return EXPENSE_TYPE_OPTIONS.find((option) => option.value === normalized)?.label
@@ -131,7 +157,41 @@ export function resolveExpenseDescriptionDetail(itemType, itemLocation) {
return resolveLocationDisplay(itemLocation, itemType)
}
export function buildFallbackProgressSteps() {
export function buildFallbackProgressSteps(requestModel = {}) {
if (isApplicationDocumentRequest(requestModel)) {
const node = String(requestModel?.node || requestModel?.workflowNode || requestModel?.approvalStage || '').trim()
const approvalKey = String(requestModel?.approvalKey || '').trim()
const completed = approvalKey === 'completed' || /审批完成|申请完成|已完成/.test(node)
const inLeaderApproval = approvalKey === 'in_progress' || /直属领导|领导审批|审批中/.test(node)
return [
{
index: 1,
label: '创建申请',
time: completed || inLeaderApproval ? '已完成' : '进行中',
done: completed || inLeaderApproval,
active: true,
current: !(completed || inLeaderApproval)
},
{
index: 2,
label: '直属领导审批',
time: completed ? '已完成' : inLeaderApproval ? '进行中' : '待处理',
done: completed,
active: completed || inLeaderApproval,
current: !completed && inLeaderApproval
},
{
index: 3,
label: '审批完成',
time: completed ? '已完成' : '待处理',
done: completed,
active: completed,
current: false
}
]
}
return [
{ index: 1, label: '创建单据', time: '已完成', done: true, active: true },
{ index: 2, label: '待提交', time: '进行中', active: true, current: true },
@@ -143,6 +203,10 @@ export function buildFallbackProgressSteps() {
}
export function buildFallbackExpenseItems(request) {
if (isApplicationDocumentRequest(request)) {
return []
}
return [
buildExpenseItemViewModel({
id: 'fallback-1',
@@ -413,6 +477,10 @@ export function buildExpenseDraftIssues(item) {
}
export function buildOptionalTravelReceiptRiskCards(requestModel, items) {
if (isApplicationDocumentRequest(requestModel)) {
return []
}
const normalizedItems = Array.isArray(items) ? items : []
const isTravelContext =
requestModel?.detailVariant === 'travel' ||

View File

@@ -91,7 +91,7 @@ function stripBusinessTimePrefix(text) {
function resolveDestinationFromText(text) {
const normalized = normalizeComposerText(text).replace(/\s+/g, '')
const targetMatch = normalized.match(/(?:去|到|赴|前往)([^,。;;]+)/u)
const targetMatch = normalized.match(/(?:出差|去|到|赴|前往)([^,。;;]+)/u)
const targetText = String(targetMatch?.[1] || '').trim()
if (!targetText) {
return ''
@@ -117,7 +117,7 @@ function resolveTripDaysFromText(text, businessTimeContext) {
function resolveReasonFromText(text, destination) {
let reason = normalizeComposerText(text)
.replace(/^(?:去|到|赴|前往)\s*/u, '')
.replace(/^(?:出差|去|到|赴|前往)\s*/u, '')
.trim()
if (destination && reason.startsWith(destination)) {

View File

@@ -459,7 +459,21 @@ export function useTravelReimbursementFlow({
detail: '正在根据当前票据新建报销草稿...'
}
}
const config = configs[reviewAction] || {
const defaultConfigBySessionType = {
application: {
key: 'application-review-preview',
title: '申请信息核对',
tool: 'user_agent.application_review_preview',
detail: '正在整理申请事项和待补充信息...'
},
approval: {
key: 'approval-review-preview',
title: '审核信息核对',
tool: 'user_agent.approval_review_preview',
detail: '正在整理待审核单据、风险点和审核建议...'
}
}
const config = configs[reviewAction] || defaultConfigBySessionType[String(activeSessionType.value || '').trim()] || {
key: 'expense-review-preview',
title: '报销信息核对',
tool: 'user_agent.expense_review_preview',

View File

@@ -1,6 +1,6 @@
import { nextTick, ref } from 'vue'
import { clearUserConversations, fetchLatestConversation } from '../../services/orchestrator.js'
import { fetchLatestConversation } from '../../services/orchestrator.js'
import {
clearAssistantSessionSnapshot,
readAssistantSessionSnapshot,
@@ -11,8 +11,9 @@ import {
filterPersistableFilePreviews
} from './travelReimbursementAttachmentModel.js'
import {
ASSISTANT_SESSION_TYPES,
SESSION_TYPE_APPLICATION,
SESSION_TYPE_EXPENSE,
SESSION_TYPE_KNOWLEDGE,
buildInitialInsightFromConversation,
buildWelcomeInsight,
buildWelcomeQuickActions,
@@ -20,6 +21,7 @@ import {
hasMeaningfulSessionMessages,
normalizeInitialConversationMessages,
normalizeSnapshotMessages,
normalizeAssistantSessionType,
resolveInitialConversationId,
resolveInitialDraftClaimId,
resolveInitialSessionType,
@@ -41,6 +43,10 @@ export function useTravelReimbursementSessionState({
scrollToBottom,
getSessionRuntimeRefs = () => ({})
}) {
function resolveDefaultSessionTypeFromEntry() {
return props.entrySource === 'application' ? SESSION_TYPE_APPLICATION : SESSION_TYPE_EXPENSE
}
function refreshWelcomeQuickActions(messages, sessionType) {
if (!Array.isArray(messages) || !messages.length) {
return []
@@ -58,8 +64,8 @@ export function useTravelReimbursementSessionState({
))
}
function buildConversationSessionState(conversation, fallbackSessionType = SESSION_TYPE_EXPENSE) {
const sessionType = resolveInitialSessionType(conversation) || fallbackSessionType
function buildConversationSessionState(conversation, fallbackSessionType = resolveDefaultSessionTypeFromEntry()) {
const sessionType = resolveInitialSessionType(conversation, fallbackSessionType)
const restoredMessages = refreshWelcomeQuickActions(normalizeInitialConversationMessages(conversation), sessionType)
const initialInsight = buildInitialInsightFromConversation(conversation)
const restoredReviewFilePreviews = buildReviewFilePreviewsFromMessages(restoredMessages)
@@ -84,17 +90,18 @@ export function useTravelReimbursementSessionState({
}
function buildEmptySessionState(sessionType) {
const normalizedSessionType = normalizeAssistantSessionType(sessionType, resolveDefaultSessionTypeFromEntry())
return {
sessionType,
sessionType: normalizedSessionType,
messages: [
createWelcomeAssistantMessage(props.entrySource, linkedRequest.value, sessionType, currentUser.value)
createWelcomeAssistantMessage(props.entrySource, linkedRequest.value, normalizedSessionType, currentUser.value)
],
conversationId: '',
draftClaimId: '',
currentInsight: buildWelcomeInsight(
props.entrySource,
linkedRequest.value,
sessionType,
normalizedSessionType,
currentUser.value
),
reviewFilePreviews: [],
@@ -107,13 +114,16 @@ export function useTravelReimbursementSessionState({
}
}
function buildPersistedSessionState(snapshot, fallbackSessionType = SESSION_TYPE_EXPENSE) {
function buildPersistedSessionState(snapshot, fallbackSessionType = resolveDefaultSessionTypeFromEntry()) {
const state = snapshot?.state && typeof snapshot.state === 'object' ? snapshot.state : null
if (!state) {
return null
}
const sessionType = String(state.sessionType || snapshot.sessionType || fallbackSessionType || '').trim() || SESSION_TYPE_EXPENSE
const sessionType = normalizeAssistantSessionType(
state.sessionType || snapshot.sessionType || fallbackSessionType,
fallbackSessionType
)
const restoredMessages = refreshWelcomeQuickActions(normalizeSnapshotMessages(state.messages), sessionType)
if (
!hasMeaningfulSessionMessages(restoredMessages)
@@ -148,13 +158,16 @@ export function useTravelReimbursementSessionState({
return String(user.username || user.name || 'anonymous').trim() || 'anonymous'
}
const initialSessionType = resolveInitialSessionType(props.initialConversation)
const defaultInitialSessionType = resolveDefaultSessionTypeFromEntry()
const initialSessionType = props.initialConversation
? resolveInitialSessionType(props.initialConversation, defaultInitialSessionType)
: defaultInitialSessionType
const shouldPersistLocalSnapshot = props.entrySource !== 'detail'
const conversationInitialState = props.initialConversation
? buildConversationSessionState(props.initialConversation, initialSessionType)
: buildEmptySessionState(initialSessionType)
const canRestorePersistedInitialState =
props.entrySource === 'workbench'
shouldPersistLocalSnapshot
&& !String(props.initialPrompt || '').trim()
&& !props.initialFiles.length
const persistedInitialSnapshot = readAssistantSessionSnapshot(resolveCurrentUserId(), initialSessionType)
@@ -174,21 +187,22 @@ export function useTravelReimbursementSessionState({
const conversationId = ref(initialSessionState.conversationId)
const draftClaimId = ref(initialSessionState.draftClaimId)
const reviewFilePreviews = ref(initialSessionState.reviewFilePreviews)
const sessionSnapshots = ref({
[SESSION_TYPE_EXPENSE]: null,
[SESSION_TYPE_KNOWLEDGE]: null
})
const sessionSnapshots = ref(
ASSISTANT_SESSION_TYPES.reduce((result, sessionType) => {
result[sessionType] = null
return result
}, {})
)
const currentInsight = ref(initialSessionState.currentInsight)
const composerUploadIntent = ref(String(initialSessionState.composerUploadIntent || '').trim())
const guidedFlowState = ref(normalizeGuidedFlowState(initialSessionState.guidedFlowState))
const insightPanelCollapsed = ref(false)
const sessionSwitchBusy = ref(false)
let knowledgeSessionResetPromise = Promise.resolve()
function buildPersistableSessionState(sessionState) {
const state = sessionState || captureCurrentSessionState()
return {
sessionType: state.sessionType || SESSION_TYPE_EXPENSE,
sessionType: normalizeAssistantSessionType(state.sessionType, resolveDefaultSessionTypeFromEntry()),
messages: serializeSessionMessages(state.messages),
conversationId: String(state.conversationId || '').trim(),
draftClaimId: String(state.draftClaimId || '').trim(),
@@ -244,7 +258,7 @@ export function useTravelReimbursementSessionState({
function applySessionState(sessionState) {
const runtimeRefs = getSessionRuntimeRefs()
const nextState = sessionState || buildEmptySessionState(activeSessionType.value)
activeSessionType.value = nextState.sessionType || SESSION_TYPE_EXPENSE
activeSessionType.value = normalizeAssistantSessionType(nextState.sessionType, resolveDefaultSessionTypeFromEntry())
messages.value = Array.isArray(nextState.messages) && nextState.messages.length
? nextState.messages
: [
@@ -287,38 +301,18 @@ export function useTravelReimbursementSessionState({
}
async function loadLatestSessionState(targetSessionType) {
const payload = await fetchLatestConversation(resolveCurrentUserId(), targetSessionType, {
preferRecoverable: targetSessionType === SESSION_TYPE_EXPENSE
const normalizedTarget = normalizeAssistantSessionType(targetSessionType, resolveDefaultSessionTypeFromEntry())
const payload = await fetchLatestConversation(resolveCurrentUserId(), normalizedTarget, {
preferRecoverable: normalizedTarget === SESSION_TYPE_EXPENSE
})
if (payload?.found && payload.conversation) {
return buildConversationSessionState(payload.conversation, targetSessionType)
return buildConversationSessionState(payload.conversation, normalizedTarget)
}
return buildEmptySessionState(targetSessionType)
}
function resetKnowledgeSessionSnapshot() {
const emptyKnowledgeState = buildEmptySessionState(SESSION_TYPE_KNOWLEDGE)
sessionSnapshots.value[SESSION_TYPE_KNOWLEDGE] = emptyKnowledgeState
if (activeSessionType.value === SESSION_TYPE_KNOWLEDGE) {
applySessionState(emptyKnowledgeState)
}
}
function clearKnowledgeSessionOnEntry() {
resetKnowledgeSessionSnapshot()
knowledgeSessionResetPromise = clearUserConversations(resolveCurrentUserId(), SESSION_TYPE_KNOWLEDGE)
.catch((error) => {
console.warn('Failed to clear knowledge session on entry:', error)
})
.finally(() => {
resetKnowledgeSessionSnapshot()
})
return knowledgeSessionResetPromise
return buildEmptySessionState(normalizedTarget)
}
async function switchSessionType(targetSessionType) {
const normalizedTarget = String(targetSessionType || '').trim() || SESSION_TYPE_EXPENSE
const normalizedTarget = normalizeAssistantSessionType(targetSessionType, resolveDefaultSessionTypeFromEntry())
if (normalizedTarget === activeSessionType.value || sessionSwitchBusy.value) {
return
}
@@ -338,7 +332,7 @@ export function useTravelReimbursementSessionState({
const emptyState = buildEmptySessionState(normalizedTarget)
sessionSnapshots.value[normalizedTarget] = emptyState
applySessionState(emptyState)
toast(error?.message || '?????????????????')
toast(error?.message || '切换助手失败,请稍后重试。')
} finally {
sessionSwitchBusy.value = false
}
@@ -368,8 +362,6 @@ export function useTravelReimbursementSessionState({
captureCurrentSessionState,
applySessionState,
loadLatestSessionState,
resetKnowledgeSessionSnapshot,
clearKnowledgeSessionOnEntry,
switchSessionType
}
}

View File

@@ -3,6 +3,7 @@ import {
buildAttachmentAssociationConfirmationMessage,
buildUnsavedDraftAttachmentConfirmationMessage
} from './travelReimbursementAttachmentModel.js'
import { resolveAssistantScopeGuard } from '../../utils/assistantSessionScope.js'
export function useTravelReimbursementSubmitComposer(ctx) {
const {
@@ -238,6 +239,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
function buildBackendMessage(rawText, fileNames, ocrSummary = '') {
const parts = []
const normalizedText = String(rawText || '').trim()
const sessionType = String(activeSessionType.value || '').trim()
if (normalizedText) {
parts.push(normalizedText)
@@ -245,7 +247,11 @@ export function useTravelReimbursementSubmitComposer(ctx) {
parts.push(
isKnowledgeSession.value
? `我上传了 ${fileNames.length} 份附件,请结合附件名称回答财务相关问题。`
: `我上传了 ${fileNames.length} 份票据,请结合附件名称给出报销建议并整理待核对信息。`
: sessionType === 'application'
? `我上传了 ${fileNames.length} 份附件,请结合附件名称整理费用申请建议和待核对信息。`
: sessionType === 'approval'
? `我上传了 ${fileNames.length} 份附件,请结合附件名称整理审核风险和处理建议。`
: `我上传了 ${fileNames.length} 份票据,请结合附件名称给出报销建议并整理待核对信息。`
)
}
@@ -358,6 +364,30 @@ export function useTravelReimbursementSubmitComposer(ctx) {
? `新上传 ${fileNames.length} 份票据,请单独建立报销单。`
: `我上传了 ${fileNames.length} 份票据,请帮我识别并整理报销建议。`)
const scopeGuard = resolveAssistantScopeGuard(rawText, activeSessionType.value, {
attachmentCount: files.length,
hasActiveReviewPayload: Boolean(activeReviewPayload.value),
reviewAction
})
if (scopeGuard && !systemGenerated && !reviewAction && !options.skipScopeGuard) {
if (!options.skipUserMessage) {
messages.value.push(createMessage('user', userText, fileNames))
}
messages.value.push(createMessage('assistant', scopeGuard.text, [], {
meta: scopeGuard.meta,
suggestedActions: scopeGuard.suggestedActions
}))
composerDraft.value = ''
composerBusinessTimeTags.value = []
composerBusinessTimeDraftTouched.value = false
nextTick(() => {
adjustComposerTextareaHeight()
scrollToBottom()
})
persistSessionState()
return null
}
const hasUnsavedReviewDraft = Boolean(
!isKnowledgeSession.value &&
files.length &&
@@ -521,7 +551,11 @@ export function useTravelReimbursementSubmitComposer(ctx) {
options.pendingText || (
isKnowledgeSession.value
? '正在整理财务知识答案...'
: '正在识别并整理右侧核对信息...'
: activeSessionType.value === 'application'
? '正在识别并整理申请核对信息...'
: activeSessionType.value === 'approval'
? '正在查询审核上下文并整理风险提示...'
: '正在识别并整理右侧核对信息...'
),
[],
{

View File

@@ -98,3 +98,29 @@ test('detail topbar still flags real manual rows without required ticket info',
assert.equal(hasPendingInfo(request), true)
assert.deepEqual(alerts, ['待提交', '缺少票据', '待补信息'])
})
test('application detail topbar does not ask for receipt attachments', () => {
const request = {
id: 'APP-20260525-ABC123',
claimNo: 'APP-20260525-ABC123',
documentTypeCode: 'application',
node: '直属领导审批',
approvalKey: 'in_progress',
typeCode: 'travel_application',
typeLabel: '差旅费用申请',
reason: '支撑国网服务器上线部署',
location: '上海',
city: '上海',
occurredDisplay: '2026-05-25 ~ 2026-05-28',
amountValue: 12000,
attachmentSummary: '申请单',
secondaryStatusValue: '已进入审批流程',
expenseItems: []
}
const alerts = buildDetailAlerts(request).map((item) => item.label)
assert.equal(hasMissingAttachment(request), false)
assert.equal(alerts.includes('缺少票据'), false)
assert.deepEqual(alerts, ['直属领导审批'])
})

View File

@@ -0,0 +1,71 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import {
SESSION_TYPE_APPLICATION,
SESSION_TYPE_EXPENSE,
SESSION_TYPE_KNOWLEDGE,
buildWelcomeInsight,
buildWelcomeMessage
} from '../src/views/scripts/travelReimbursementConversationModel.js'
const appShellRouteView = readFileSync(
fileURLToPath(new URL('../src/views/AppShellRouteView.vue', import.meta.url)),
'utf8'
)
const appShellComposable = readFileSync(
fileURLToPath(new URL('../src/composables/useAppShell.js', import.meta.url)),
'utf8'
)
const assistantScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)),
'utf8'
)
const assistantTemplate = readFileSync(
fileURLToPath(new URL('../src/views/TravelReimbursementCreateView.vue', import.meta.url)),
'utf8'
)
test('application and reimbursement entries open the same financial assistant modal', () => {
assert.match(appShellRouteView, /<TravelReimbursementCreateView[\s\S]*:entry-source="smartEntryContext\.source"/)
assert.match(appShellRouteView, /@create-request="openTravelCreate"/)
assert.match(appShellRouteView, /@create-application="openExpenseApplicationCreate"/)
assert.match(appShellRouteView, /@new-application="openExpenseApplicationCreate"/)
assert.doesNotMatch(appShellRouteView, /ExpenseApplicationDialog/)
})
test('application entry keeps its own assistant source without creating a separate dialog', () => {
assert.match(appShellComposable, /const SMART_ENTRY_SOURCE_APPLICATION = 'application'/)
assert.match(appShellComposable, /function openExpenseApplicationCreate\(\) \{[\s\S]*openFinancialAssistantCreate\(SMART_ENTRY_SOURCE_APPLICATION\)/)
assert.match(appShellComposable, /function openTravelCreate\(\) \{[\s\S]*openFinancialAssistantCreate\(SMART_ENTRY_SOURCE_REIMBURSEMENT\)/)
assert.match(appShellComposable, /openExpenseApplicationCreate,/)
assert.match(assistantScript, /activeSessionType\.value === SESSION_TYPE_APPLICATION[\s\S]*我想先申请一笔差旅费用/)
})
test('financial assistant toolbar renders four isolated assistant sessions', () => {
assert.match(assistantScript, /ASSISTANT_SESSION_MODE_OPTIONS\.map/)
assert.match(assistantScript, /targetSessionType:\s*mode\.key/)
assert.match(assistantScript, /active:\s*mode\.key === activeSessionType\.value/)
assert.match(assistantTemplate, /:class="\{ active: shortcut\.active \}"/)
assert.match(assistantTemplate, /:aria-pressed="shortcut\.active \? 'true' : 'false'"/)
assert.match(assistantTemplate, /:disabled="shortcut\.active \|\| submitting/)
})
test('financial assistant welcome copy differentiates application intent from reimbursement entry', () => {
const user = { name: '李文静', username: 'wenjing.li', grade: 'P5' }
const applicationWelcome = buildWelcomeMessage('application', null, SESSION_TYPE_APPLICATION, user)
const reimbursementWelcome = buildWelcomeMessage('topbar', null, SESSION_TYPE_EXPENSE, user)
const knowledgeWelcome = buildWelcomeMessage('topbar', null, SESSION_TYPE_KNOWLEDGE, user)
const applicationInsight = buildWelcomeInsight('application', null, SESSION_TYPE_APPLICATION, user)
assert.match(applicationWelcome, /申请助手/)
assert.match(applicationWelcome, /费用申请、报销申请还是其他财务事项/)
assert.match(reimbursementWelcome, /报销助手/)
assert.match(reimbursementWelcome, /报销发起、票据识别、草稿归集、报销信息核对/)
assert.match(knowledgeWelcome, /财务知识助手/)
assert.notEqual(applicationWelcome, reimbursementWelcome)
assert.equal(applicationInsight.metricValue, '申请助手')
assert.equal(applicationInsight.title, '申请助手')
})

View File

@@ -90,12 +90,12 @@ test('saving a draft keeps the financial assistant open for continued work', ()
assert.ok(handleDraftSavedBlock)
assert.match(handleDraftSavedBlock, /if \(status === 'submitted'\) \{[\s\S]*smartEntryOpen\.value = false/)
assert.match(handleDraftSavedBlock, /if \(status === 'submitted'\) \{[\s\S]*router\.push\(\{ name: 'app-requests' \}\)/)
assert.match(handleDraftSavedBlock, /if \(status === 'submitted'\) \{[\s\S]*router\.push\(\{ name: activeView\.value === 'documents' \? 'app-documents' : 'app-requests' \}\)/)
assert.match(handleDraftSavedBlock, /return[\s\S]*单据已保存为草稿,可继续上传票据或补充信息。/)
const draftSuccessIndex = handleDraftSavedBlock.indexOf('单据已保存为草稿,可继续上传票据或补充信息。')
assert.equal(handleDraftSavedBlock.indexOf('smartEntryOpen.value = false', draftSuccessIndex), -1)
assert.equal(handleDraftSavedBlock.indexOf("router.push({ name: 'app-requests' })", draftSuccessIndex), -1)
assert.equal(handleDraftSavedBlock.indexOf("router.push({ name: activeView.value === 'documents' ? 'app-documents' : 'app-requests' })", draftSuccessIndex), -1)
})
test('detail smart entry is scoped to the current claim instead of the latest conversation', () => {

View File

@@ -0,0 +1,43 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
mergeComposerPrefill,
resolveSuggestedActionPrefill
} from '../src/utils/assistantSuggestedActionPrefill.js'
test('suggested action prefill uses backend prompt payload', () => {
assert.equal(
resolveSuggestedActionPrefill({
action_type: 'prefill_composer',
payload: {
application_field: 'time',
prompt_prefill: '申请时间段:'
}
}),
'申请时间段:'
)
})
test('suggested action prefill falls back to application field templates', () => {
assert.equal(
resolveSuggestedActionPrefill({
action_type: 'prefill_composer',
payload: { application_field: 'amount' }
}),
'预计总费用:'
)
assert.equal(
resolveSuggestedActionPrefill({
action_type: 'ask_clarification',
payload: { application_field: 'amount' }
}),
''
)
})
test('composer prefill appends to existing draft without duplication', () => {
assert.equal(mergeComposerPrefill('', '事由:'), '事由:')
assert.equal(mergeComposerPrefill('地点:上海', '事由:'), '地点:上海\n事由')
assert.equal(mergeComposerPrefill('地点:上海\n事由', '事由:'), '地点:上海\n事由')
})

View File

@@ -0,0 +1,60 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
countNewDocuments,
isNewDocument,
markDocumentViewed,
readDocumentScope,
readViewedDocumentKeys,
resolveDocumentNewKey,
writeDocumentScope
} from '../src/utils/documentCenterNewState.js'
function createMemoryStorage(initial = {}) {
const store = new Map(Object.entries(initial))
return {
getItem(key) {
return store.has(key) ? store.get(key) : null
},
setItem(key, value) {
store.set(key, value)
}
}
}
test('document center new state resolves source scoped document keys', () => {
assert.equal(resolveDocumentNewKey({ source: 'archive', claimId: 'claim-1' }), 'archive:claim-1')
assert.equal(resolveDocumentNewKey({ source: 'approval', documentNo: 'EXP-1' }), 'approval:EXP-1')
})
test('document center new state counts unseen documents and persists viewed rows', () => {
const storage = createMemoryStorage()
const rows = [
{ source: 'archive', claimId: 'claim-1' },
{ source: 'archive', claimId: 'claim-2' }
]
let viewedKeys = readViewedDocumentKeys(storage)
assert.equal(countNewDocuments(rows, viewedKeys), 2)
assert.equal(isNewDocument(rows[0], viewedKeys), true)
viewedKeys = markDocumentViewed(rows[0], viewedKeys, storage)
assert.equal(countNewDocuments(rows, viewedKeys), 1)
assert.equal(isNewDocument(rows[0], viewedKeys), false)
assert.deepEqual([...readViewedDocumentKeys(storage)], ['archive:claim-1'])
})
test('document center scope state restores only allowed tabs', () => {
const storage = createMemoryStorage()
const scopes = ['全部', '申请单', '报销单', '审核单', '归档']
assert.equal(readDocumentScope('全部', scopes, storage), '全部')
writeDocumentScope('归档', scopes, storage)
assert.equal(readDocumentScope('全部', scopes, storage), '归档')
writeDocumentScope('不存在', scopes, storage)
assert.equal(readDocumentScope('全部', scopes, storage), '归档')
})

View File

@@ -25,24 +25,28 @@ test('documents center keeps only the top scope tabs and renders status as a dro
assert.match(documentsCenterView, /@click="selectStatusTab\(option\.value\)"/)
})
test('documents center top tabs start from application and show document category labels', () => {
assert.doesNotMatch(documentsCenterView, /const DOCUMENT_SCOPE_ALL = '全部'/)
test('documents center top tabs start from all and show document category labels', () => {
assert.match(documentsCenterView, /const DOCUMENT_SCOPE_ALL = '全部'/)
assert.match(documentsCenterView, /const DOCUMENT_SCOPE_APPLICATION = '申请单'/)
assert.match(documentsCenterView, /const DOCUMENT_SCOPE_REIMBURSEMENT = '报销单'/)
assert.match(documentsCenterView, /const DOCUMENT_SCOPE_REVIEW = '审核单'/)
assert.match(documentsCenterView, /const DOCUMENT_SCOPE_ARCHIVE = '归档'/)
assert.match(documentsCenterView, /const activeScopeTab = ref\(DOCUMENT_SCOPE_APPLICATION\)/)
assert.match(documentsCenterView, /const activeScopeTab = ref\(readDocumentScope\(DOCUMENT_SCOPE_ALL, scopeTabs\)\)/)
assert.match(
documentsCenterView,
/const scopeTabs = \[[\s\S]*DOCUMENT_SCOPE_APPLICATION[\s\S]*DOCUMENT_SCOPE_REIMBURSEMENT[\s\S]*DOCUMENT_SCOPE_REVIEW[\s\S]*DOCUMENT_SCOPE_ARCHIVE[\s\S]*\]/
/const scopeTabs = \[[\s\S]*DOCUMENT_SCOPE_ALL[\s\S]*DOCUMENT_SCOPE_APPLICATION[\s\S]*DOCUMENT_SCOPE_REIMBURSEMENT[\s\S]*DOCUMENT_SCOPE_REVIEW[\s\S]*DOCUMENT_SCOPE_ARCHIVE[\s\S]*\]/
)
assert.doesNotMatch(documentsCenterView, /DOCUMENT_SCOPE_ALL/)
})
test('documents center category tabs map to the intended row sources', () => {
assert.match(documentsCenterView, /const nonArchivedRows = computed\(\(\) => mergeDocumentRows\(\[\.\.\.ownedRows\.value, \.\.\.approvalRows\.value\]\)\)/)
assert.match(
documentsCenterView,
/activeScopeTab\.value === DOCUMENT_SCOPE_APPLICATION[\s\S]*row\.documentTypeCode === DOCUMENT_TYPE_APPLICATION/
/activeScopeTab\.value === DOCUMENT_SCOPE_ALL[\s\S]*return nonArchivedRows\.value/
)
assert.match(
documentsCenterView,
/activeScopeTab\.value === DOCUMENT_SCOPE_APPLICATION[\s\S]*nonArchivedRows\.value\.filter\(\(row\) => row\.documentTypeCode === DOCUMENT_TYPE_APPLICATION/
)
assert.match(
documentsCenterView,
@@ -56,7 +60,22 @@ test('documents center category tabs map to the intended row sources', () => {
documentsCenterView,
/activeScopeTab\.value === DOCUMENT_SCOPE_ARCHIVE[\s\S]*return archiveRows\.value/
)
assert.match(documentsCenterView, /return allSummaryRows\.value\.filter\(\(row\) => row\.documentTypeCode === DOCUMENT_TYPE_APPLICATION\)/)
assert.match(documentsCenterView, /return nonArchivedRows\.value/)
})
test('documents center preserves application document type from mapped requests', () => {
assert.match(
documentsCenterView,
/const documentTypeCode = normalized\.documentTypeCode \|\| DOCUMENT_TYPE_REIMBURSEMENT/
)
assert.match(
documentsCenterView,
/documentTypeCode === DOCUMENT_TYPE_APPLICATION \? '申请单' : '报销单'/
)
assert.doesNotMatch(
documentsCenterView,
/documentTypeCode:\s*DOCUMENT_TYPE_REIMBURSEMENT,[\s\S]*documentTypeLabel:\s*'报销单'/
)
})
test('documents center list shows created time and conditional stay time columns', () => {
@@ -91,25 +110,70 @@ test('documents center action buttons are scoped to application and reimbursemen
})
test('documents center category tabs render bubble counts for new documents', () => {
assert.match(documentsCenterView, /readViewedDocumentKeys/)
assert.match(documentsCenterView, /const viewedDocumentKeys = ref\(readViewedDocumentKeys\(\)\)/)
assert.match(documentsCenterView, /v-for="tab in scopeTabItems"/)
assert.match(documentsCenterView, /<span v-if="tab\.badgeCount > 0" class="scope-tab-badge"/)
assert.match(documentsCenterView, /tab\.badgeCount > 99 \? '99\+' : tab\.badgeCount/)
assert.match(documentsCenterView, /const scopeNewCountMap = computed\(\(\) => \(\{/)
assert.match(documentsCenterView, /\[DOCUMENT_SCOPE_ALL\]: countNewDocuments\(nonArchivedRows\.value, viewedDocumentKeys\.value\)/)
assert.match(
documentsCenterView,
/\[DOCUMENT_SCOPE_REIMBURSEMENT\]: ownedRows\.value\.filter\(\(row\) => row\.documentTypeCode === DOCUMENT_TYPE_REIMBURSEMENT\)\.length/
/\[DOCUMENT_SCOPE_APPLICATION\]: countNewDocuments\(nonArchivedRows\.value\.filter\(\(row\) => row\.documentTypeCode === DOCUMENT_TYPE_APPLICATION\), viewedDocumentKeys\.value\)/
)
assert.match(documentsCenterView, /\[DOCUMENT_SCOPE_REVIEW\]: approvalRows\.value\.length/)
assert.match(documentsCenterView, /\[DOCUMENT_SCOPE_ARCHIVE\]: archiveRows\.value\.length/)
assert.match(
documentsCenterView,
/\[DOCUMENT_SCOPE_REIMBURSEMENT\]: countNewDocuments\(ownedRows\.value\.filter\(\(row\) => row\.documentTypeCode === DOCUMENT_TYPE_REIMBURSEMENT\), viewedDocumentKeys\.value\)/
)
assert.match(documentsCenterView, /\[DOCUMENT_SCOPE_REVIEW\]: countNewDocuments\(approvalRows\.value, viewedDocumentKeys\.value\)/)
assert.match(documentsCenterView, /\[DOCUMENT_SCOPE_ARCHIVE\]: countNewDocuments\(archiveRows\.value, viewedDocumentKeys\.value\)/)
assert.match(
documentsCenterView,
/const scopeTabItems = computed\(\(\) =>[\s\S]*badgeCount: scopeNewCountMap\.value\[tab\] \|\| 0/
)
})
test('documents center rows show NEW marker until the row is opened', () => {
assert.match(documentsCenterView, /<span v-if="row\.isNewDocument" class="new-document-badge">NEW<\/span>/)
assert.match(documentsCenterView, /isNewDocument: isNewDocument\(/)
assert.match(
documentsCenterView,
/function openDocument\(row\) \{[\s\S]*writeDocumentScope\(activeScopeTab\.value, scopeTabs\)[\s\S]*viewedDocumentKeys\.value = markDocumentViewed\(row, viewedDocumentKeys\.value\)[\s\S]*emit\('open-document', row\.rawRequest \|\| row\)/
)
assert.match(documentsCenterStyles, /\.new-document-badge\s*\{[\s\S]*background:\s*#fff5f5;/)
assert.match(documentsCenterStyles, /\.new-document-badge\s*\{[\s\S]*border:\s*1px solid #fecaca;/)
assert.match(documentsCenterStyles, /\.new-document-badge::before\s*\{[\s\S]*background:\s*#ef4444;/)
assert.doesNotMatch(documentsCenterStyles, /newDocumentPulse/)
})
test('documents center empty states stay emerald across all scope tabs', () => {
const emptyStateBlock = documentsCenterView.match(/const emptyState = computed\(\(\) => \{[\s\S]*?\n\}\)/)?.[0] || ''
assert.match(emptyStateBlock, /eyebrow: '申请单'[\s\S]*tone: 'emerald'/)
assert.match(emptyStateBlock, /title: filtered \? '没有符合当前条件的单据'[\s\S]*tone: 'emerald'/)
assert.doesNotMatch(emptyStateBlock, /tone:\s*'sky'/)
assert.doesNotMatch(emptyStateBlock, /tone:\s*'slate'/)
assert.doesNotMatch(emptyStateBlock, /tone:\s*'amber'/)
})
test('documents center empty states do not render small action buttons', () => {
const emptyStateBlock = documentsCenterView.match(/const emptyState = computed\(\(\) => \{[\s\S]*?\n\}\)/)?.[0] || ''
assert.match(emptyStateBlock, /actionLabel:\s*''/)
assert.match(emptyStateBlock, /actionIcon:\s*''/)
assert.doesNotMatch(emptyStateBlock, /actionLabel:\s*filtered/)
assert.doesNotMatch(emptyStateBlock, /actionIcon:\s*filtered/)
assert.doesNotMatch(emptyStateBlock, /actionLabel:\s*'发起申请'/)
assert.doesNotMatch(emptyStateBlock, /actionLabel:\s*'发起报销'/)
assert.doesNotMatch(emptyStateBlock, /actionLabel:\s*'清空筛选'/)
})
test('documents center switches filter conditions by category tab', () => {
assert.match(documentsCenterView, /const FILTER_CONFIG_BY_SCOPE = \{/)
assert.doesNotMatch(documentsCenterView, /\[DOCUMENT_SCOPE_ALL\]: \{/)
assert.match(
documentsCenterView,
/\[DOCUMENT_SCOPE_ALL\]: \{[\s\S]*sceneFallbackLabel: '单据场景'[\s\S]*statusTitle: '单据状态'[\s\S]*showDocumentType: true/
)
assert.match(
documentsCenterView,
/\[DOCUMENT_SCOPE_APPLICATION\]: \{[\s\S]*sceneFallbackLabel: '申请场景'[\s\S]*statusTitle: '申请状态'[\s\S]*showDocumentType: false/

View File

@@ -0,0 +1,69 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
buildApplicationFieldsFromOntology,
expandApplicationTimeWithDays,
resolveApplicationReason,
resolveApplicationTimeRange,
resolvePromptField
} from '../src/utils/expenseApplicationOntology.js'
const structuredApplicationPrompt = [
'发生时间2026-05-25',
'地点:上海',
'事由:支撑国网服务器部署',
'天数3天'
].join('\n')
test('expense application fields use labeled reason and filter resolved missing slots', () => {
const fields = buildApplicationFieldsFromOntology(
{
scenario: 'expense',
intent: 'draft',
entities: [],
time_range: {},
missing_slots: ['time_range', 'location', 'reason', 'amount']
},
structuredApplicationPrompt,
{ name: '申请员工', departmentName: '交付部' }
)
assert.equal(fields.timeRange, '2026-05-25 至 2026-05-28')
assert.equal(fields.location, '上海')
assert.equal(fields.reason, '支撑国网服务器部署')
assert.deepEqual(
fields.missingSlots.map((item) => item.key),
['amount']
)
})
test('expense application prompt field parser supports multiline labels', () => {
assert.equal(resolvePromptField(structuredApplicationPrompt, ['事由']), '支撑国网服务器部署')
assert.equal(resolveApplicationReason(structuredApplicationPrompt), '支撑国网服务器部署')
})
test('expense application expands a single selected date with natural days', () => {
const prompt = [
'发生时间2026-05-25',
'去上海出差3天支撑国网服务器部署'
].join('\n')
assert.equal(expandApplicationTimeWithDays('2026-05-25', 3), '2026-05-25 至 2026-05-28')
assert.equal(resolveApplicationTimeRange({ time_range: {} }, prompt), '2026-05-25 至 2026-05-28')
})
test('expense application keeps explicit time range before applying days', () => {
assert.equal(
resolveApplicationTimeRange(
{
time_range: {
start_date: '2026-05-25',
end_date: '2026-05-27'
}
},
'去上海出差3天支撑国网服务器部署'
),
'2026-05-25 至 2026-05-27'
)
})

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'
import { renderMarkdown } from '../src/utils/markdown.js'
const createViewTemplate = readFileSync(
fileURLToPath(new URL('../src/views/TravelReimbursementCreateView.vue', import.meta.url)),
'utf8'
)
const createViewScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)),
'utf8'
)
test('expense application submit uses rich text link and confirm dialog', () => {
const copy = '请核对上述信息无误,确认无误后 [确认](#application-submit) 提交至审批流程。'
const rendered = renderMarkdown(copy)
assert.match(
rendered,
/<a href="#application-submit" class="markdown-action-link markdown-action-link-confirm">确认<\/a>/
)
assert.match(createViewTemplate, /:open="applicationSubmitConfirmDialog\.open"/)
assert.match(createViewTemplate, /title="确认提交当前费用申请?"/)
assert.match(createViewTemplate, /description="提交后申请将进入领导审核流程,并同步纳入预算管理口径/)
assert.match(createViewTemplate, /@confirm="confirmApplicationSubmit"/)
assert.match(createViewScript, /const APPLICATION_SUBMIT_HREF = '#application-submit'/)
assert.match(
createViewScript,
/href === APPLICATION_SUBMIT_HREF[\s\S]*openApplicationSubmitConfirm\(message\)/
)
assert.match(
createViewScript,
/async function confirmApplicationSubmit\(\)[\s\S]*rawText: '确认提交'[\s\S]*systemGenerated: true/
)
assert.match(
createViewScript,
/applicationSubmitConfirmDialog\.value = \{[\s\S]*open: false,[\s\S]*message: null[\s\S]*\}[\s\S]*const payload = await submitComposer/
)
assert.match(
createViewScript,
/emit\('draft-saved', \{[\s\S]*status: 'submitted'[\s\S]*documentType: 'application'/
)
})

View File

@@ -11,7 +11,8 @@ const workbench = readFileSync(
test('workbench assistant greets the current employee without the old helper tag', () => {
assert.doesNotMatch(workbench, /assistant-tag/)
assert.doesNotMatch(workbench, /AI 报销助手/)
assert.match(workbench, /嗨,\{\{ assistantGreetingName \}\},描述费用或上传票据AI 直接帮你判断怎么报/)
assert.match(workbench, /嗨,\{\{ assistantGreetingName \}\},描述您想做的事AI 直接帮您处理/)
assert.match(workbench, /我会自动识别您的意图,协助完成费用申请、报销、查询和制度问答等业务工作/)
assert.match(workbench, /const assistantGreetingName = computed/)
assert.match(workbench, /user\.name/)
})

View File

@@ -79,6 +79,15 @@ test('business activity without expense intent asks for reimbursement confirmati
assert.equal(shouldRequestExpenseSceneSelection(businessMessage), false)
})
test('non-reimbursement assistant sessions do not trigger reimbursement scene selection', () => {
const ambiguousMessage = '业务发生时间:2026-02-20 至 2026-02-23去上海支持上海电力部署项目申请报销'
assert.equal(shouldRequestExpenseSceneSelection(ambiguousMessage, { sessionType: 'application' }), false)
assert.equal(shouldRequestExpenseIntentConfirmation('去上海电力支撑项目部署', { sessionType: 'approval' }), false)
assert.match(buildLocalIntentPreview(ambiguousMessage, 'application'), /费用申请事项/)
assert.match(buildLocalIntentPreview('查一下待我审核的单据', 'approval'), /审核处理事项/)
})
test('explicit technical operation does not ask for reimbursement confirmation', () => {
const operationMessage = '去上海电力支撑项目部署,帮我整理服务器部署步骤'

View File

@@ -3,6 +3,83 @@ import test from 'node:test'
import { mapExpenseClaimToRequest } from '../src/composables/useRequests.js'
test('application claims are mapped as application documents', () => {
const request = mapExpenseClaimToRequest({
id: 'claim-application-1',
claim_no: 'APP-20260525-ABC123',
employee_name: '张三',
department_name: '交付部',
expense_type: 'travel_application',
reason: '支撑国网服务器上线部署',
location: '上海',
amount: 12000,
invoice_count: 0,
occurred_at: '2026-05-25T00:00:00.000Z',
submitted_at: '2026-05-25T02:00:00.000Z',
created_at: '2026-05-25T01:30:00.000Z',
updated_at: '2026-05-25T02:00:00.000Z',
status: 'submitted',
approval_stage: '直属领导审批',
risk_flags_json: [],
items: []
})
assert.equal(request.documentTypeCode, 'application')
assert.equal(request.documentTypeLabel, '申请单')
assert.equal(request.typeLabel, '差旅费用申请')
assert.equal(request.secondaryStatusLabel, '申请材料')
assert.equal(request.secondaryStatusValue, '已进入审批流程')
assert.equal(request.expenseTableSummary, '预计金额已纳入预算管理口径')
assert.deepEqual(
request.progressSteps.map((step) => step.label),
['创建申请', '直属领导审批', '审批完成']
)
assert.equal(request.progressSteps.some((step) => step.label === 'AI预审'), false)
assert.equal(request.progressSteps.some((step) => step.label === '财务审批'), false)
assert.equal(request.progressSteps.find((step) => step.label === '直属领导审批')?.current, true)
})
test('approved application claims complete after direct manager approval only', () => {
const request = mapExpenseClaimToRequest({
id: 'claim-application-approved',
claim_no: 'APP-20260525-DONE01',
employee_name: '张三',
department_name: '交付部',
manager_name: '李经理',
expense_type: 'travel_application',
reason: '支撑国网服务器上线部署',
location: '上海',
amount: 12000,
invoice_count: 0,
occurred_at: '2026-05-25T00:00:00.000Z',
submitted_at: '2026-05-25T02:00:00.000Z',
created_at: '2026-05-25T01:30:00.000Z',
updated_at: '2026-05-25T03:00:00.000Z',
status: 'approved',
approval_stage: '审批完成',
risk_flags_json: [
{
source: 'manual_approval',
event_type: 'expense_application_approval',
operator: '李经理',
previous_approval_stage: '直属领导审批',
next_approval_stage: '审批完成',
created_at: '2026-05-25T03:00:00.000Z'
}
],
items: []
})
assert.equal(request.documentTypeCode, 'application')
assert.equal(request.workflowNode, '审批完成')
assert.deepEqual(
request.progressSteps.map((step) => step.label),
['创建申请', '直属领导审批', '审批完成']
)
assert.equal(request.progressSteps.every((step) => step.done), true)
assert.equal(request.progressSteps.find((step) => step.label === '直属领导审批')?.time, '李经理通过')
})
test('progress steps show approval operator time and current stay duration', () => {
const originalNow = Date.now
Date.now = () => new Date('2026-05-20T05:00:00.000Z').getTime()

View File

@@ -0,0 +1,41 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
const appShell = readFileSync(
fileURLToPath(new URL('../src/views/AppShellRouteView.vue', import.meta.url)),
'utf8'
)
const sidebar = readFileSync(
fileURLToPath(new URL('../src/components/layout/SidebarRail.vue', import.meta.url)),
'utf8'
)
const appStyles = readFileSync(
fileURLToPath(new URL('../src/assets/styles/app.css', import.meta.url)),
'utf8'
)
test('sidebar supports smooth animated collapsed layout', () => {
assert.match(appShell, /sidebarCollapsed = ref\(true\)/)
assert.match(appShell, /:class="\{ 'sidebar-collapsed': sidebarCollapsed \}"/)
assert.match(appShell, /:collapsed="sidebarCollapsed"/)
assert.match(appShell, /class="app-sidebar"/)
assert.match(appShell, /@toggle-collapse="toggleSidebarCollapsed"/)
assert.match(appShell, /function toggleSidebarCollapsed\(\)/)
assert.match(appShell, /sidebarCollapsed\.value = !sidebarCollapsed\.value/)
assert.match(sidebar, /collapsed:\s*\{\s*type: Boolean/)
assert.match(sidebar, /'toggle-collapse'/)
assert.match(sidebar, /rail-collapsed/)
assert.match(sidebar, /折叠侧边栏/)
assert.match(sidebar, /展开侧边栏/)
assert.match(sidebar, /--rail-motion-duration: 320ms/)
assert.match(sidebar, /opacity var\(--rail-fade-duration\)/)
assert.match(appStyles, /--sidebar-collapsed-width: 64px/)
assert.match(appStyles, /\.app-sidebar\s*\{[^}]*transition:\s*width var\(--sidebar-motion\)/)
assert.match(appStyles, /\.app\.sidebar-collapsed\s+\.app-sidebar\s*\{\s*width:\s*var\(--sidebar-collapsed-width\)/)
})

View File

@@ -34,6 +34,28 @@ test('composer formats date-picker expense text into readable structured fields'
)
})
test('composer extracts destination and reason from compact travel text', () => {
const formatted = buildStructuredComposerSubmitText(
'出差上海,支撑国网服务器上线部署',
{
mode: 'single',
start_date: '2026-05-25',
end_date: '2026-05-25',
business_time: '2026-05-25'
}
)
assert.equal(
formatted,
[
'发生时间2026-05-25',
'地点:上海',
'事由:支撑国网服务器上线部署',
'天数1天'
].join('\n')
)
})
test('composer keeps backend raw text but displays structured user message', () => {
assert.match(submitComposerScript, /const rawText = resolveComposerSubmitText\(options\.rawText\)\.trim\(\)/)
assert.match(submitComposerScript, /resolveComposerDisplaySubmitText\(rawText\)/)

View File

@@ -4,7 +4,12 @@ import test from 'node:test'
import { fileURLToPath } from 'node:url'
import {
APPLICATION_WELCOME_QUICK_ACTIONS,
APPROVAL_WELCOME_QUICK_ACTIONS,
ASSISTANT_SESSION_MODE_OPTIONS,
EXPENSE_WELCOME_QUICK_ACTIONS,
SESSION_TYPE_APPLICATION,
SESSION_TYPE_APPROVAL,
SESSION_TYPE_EXPENSE,
SESSION_TYPE_KNOWLEDGE,
buildWelcomeQuickActions
@@ -40,6 +45,10 @@ import {
selectGuidedQueryMode,
shouldConfirmGuidedInterruption
} from '../src/views/scripts/travelReimbursementGuidedFlowModel.js'
import {
ASSISTANT_SCOPE_ACTION_SWITCH,
resolveAssistantScopeGuard
} from '../src/utils/assistantSessionScope.js'
const createViewScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)),
@@ -53,8 +62,16 @@ const sessionStateScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSessionState.js', import.meta.url)),
'utf8'
)
const submitComposerScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.js', import.meta.url)),
'utf8'
)
test('welcome quick actions are reduced to three guided local actions', () => {
test('assistant session modes expose independent quick actions', () => {
assert.deepEqual(
ASSISTANT_SESSION_MODE_OPTIONS.map((item) => item.label),
['申请助手', '报销助手', '审核助手', '财务知识助手']
)
assert.deepEqual(
EXPENSE_WELCOME_QUICK_ACTIONS.map((item) => item.label),
['快速发起报销', '查询单据状态', '差旅计算器']
@@ -69,6 +86,14 @@ test('welcome quick actions are reduced to three guided local actions', () => {
)
assert.ok(EXPENSE_WELCOME_QUICK_ACTIONS.every((item) => !item.prompt))
assert.equal(buildWelcomeQuickActions(SESSION_TYPE_EXPENSE).length, 3)
assert.deepEqual(
buildWelcomeQuickActions(SESSION_TYPE_APPLICATION).map((item) => item.label),
APPLICATION_WELCOME_QUICK_ACTIONS.map((item) => item.label)
)
assert.deepEqual(
buildWelcomeQuickActions(SESSION_TYPE_APPROVAL).map((item) => item.label),
APPROVAL_WELCOME_QUICK_ACTIONS.map((item) => item.label)
)
assert.notDeepEqual(
buildWelcomeQuickActions(SESSION_TYPE_KNOWLEDGE).map((item) => item.label),
EXPENSE_WELCOME_QUICK_ACTIONS.map((item) => item.label),
@@ -76,6 +101,34 @@ test('welcome quick actions are reduced to three guided local actions', () => {
)
})
test('assistant session scope guard keeps business boundaries isolated', () => {
const expenseInApplication = resolveAssistantScopeGuard('我想报销的士票', SESSION_TYPE_APPLICATION)
assert.equal(expenseInApplication.targetSessionType, SESSION_TYPE_EXPENSE)
assert.match(expenseInApplication.text, /申请助手/)
assert.match(expenseInApplication.text, /报销助手/)
assert.equal(expenseInApplication.suggestedActions[0].action_type, ASSISTANT_SCOPE_ACTION_SWITCH)
assert.equal(expenseInApplication.suggestedActions[0].payload.session_type, SESSION_TYPE_EXPENSE)
assert.equal(expenseInApplication.suggestedActions[0].payload.carry_text, '我想报销的士票')
assert.equal(resolveAssistantScopeGuard('我想发起一笔费用申请', SESSION_TYPE_APPLICATION), null)
assert.equal(
resolveAssistantScopeGuard('帮我查询待我审核的单据', SESSION_TYPE_EXPENSE).targetSessionType,
SESSION_TYPE_APPROVAL
)
assert.equal(
resolveAssistantScopeGuard('差旅住宿标准是多少', SESSION_TYPE_EXPENSE).targetSessionType,
SESSION_TYPE_KNOWLEDGE
)
assert.equal(
resolveAssistantScopeGuard('报销标准是多少', SESSION_TYPE_EXPENSE).targetSessionType,
SESSION_TYPE_KNOWLEDGE
)
assert.equal(
resolveAssistantScopeGuard('解释这张单据酒店超标风险', SESSION_TYPE_EXPENSE, { hasActiveReviewPayload: true }),
null
)
})
test('guided reimbursement asks type first and walks travel fields in order', () => {
const typeActions = buildGuidedExpenseTypeActions()
assert.deepEqual(
@@ -177,6 +230,9 @@ test('guided flow state is serializable and restored through session state', ()
assert.match(sessionStateScript, /guidedFlowState,\s*\n\s*insightPanelCollapsed/)
assert.match(sessionStateScript, /function refreshWelcomeQuickActions/)
assert.match(sessionStateScript, /buildWelcomeQuickActions\(/)
assert.match(sessionStateScript, /ASSISTANT_SESSION_TYPES\.reduce/)
assert.match(sessionStateScript, /props\.entrySource === 'application' \? SESSION_TYPE_APPLICATION : SESSION_TYPE_EXPENSE/)
assert.match(sessionStateScript, /const canRestorePersistedInitialState =[\s\S]*shouldPersistLocalSnapshot/)
})
test('guided flow is local until final confirmation or collected query handoff', () => {
@@ -184,6 +240,10 @@ test('guided flow is local until final confirmation or collected query handoff',
assert.doesNotMatch(guidedFlowScript, /startExpenseClaimDraftFlowStep/)
assert.doesNotMatch(guidedFlowScript, /review_action:\s*['"]save_draft['"]/)
assert.match(createViewScript, /if \(await handleGuidedComposerSubmit\(options\)\) \{[\s\S]*return null[\s\S]*\}[\s\S]*return submitComposerInternal\(options\)/)
assert.match(createViewScript, /ASSISTANT_SCOPE_ACTION_SWITCH/)
assert.match(createViewScript, /actionPayload\.carry_text/)
assert.match(submitComposerScript, /resolveAssistantScopeGuard/)
assert.match(submitComposerScript, /skipScopeGuard/)
assert.match(guidedFlowScript, /submitExistingComposer\(submitOptions\)/)
assert.match(guidedFlowScript, /submitExistingComposer\(\{[\s\S]*pendingText:\s*'正在查询单据状态\.\.\.'/)
})

View File

@@ -15,7 +15,9 @@ import {
} from '../src/views/scripts/travelRequestDetailInsights.js'
import {
buildExpenseItemViewModel,
buildDraftBlockingIssues
buildDraftBlockingIssues,
buildOptionalTravelReceiptRiskCards,
isApplicationDocumentRequest
} from '../src/views/scripts/travelRequestDetailExpenseModel.js'
const detailViewTemplate = readFileSync(
@@ -412,7 +414,7 @@ test('expense attachment actions keep preview as the only recognition entry poin
})
test('expense detail table shows the amount total below detail rows', () => {
assert.match(detailViewTemplate, /<div class="detail-expense-table">/)
assert.match(detailViewTemplate, /<div[^>]*class="detail-expense-table"/)
assert.match(detailViewTemplate, /当前还没有费用明细/)
assert.doesNotMatch(detailViewTemplate, /class="total-row"/)
assert.match(detailViewTemplate, /class="expense-total-under-table"[\s\S]*金额合计[\s\S]*\{\{ expenseTotal \}\}/)
@@ -421,7 +423,10 @@ test('expense detail table shows the amount total below detail rows', () => {
})
test('additional note is shown above expense details as travel purpose text', () => {
assert.ok(detailViewTemplate.indexOf('<h3>附加说明</h3>') < detailViewTemplate.indexOf('<h3>费用明细</h3>'))
assert.ok(
detailViewTemplate.indexOf('<h3>附加说明</h3>')
< detailViewTemplate.indexOf("isApplicationDocument ? '申请预算' : '费用明细'")
)
assert.match(detailViewTemplate, /用于说明本次出差或办事目的/)
assert.match(detailViewTemplate, /v-if="canEditDetailNote" class="detail-note-editor"/)
assert.match(detailViewTemplate, /v-else class="detail-note readonly"/)
@@ -514,6 +519,7 @@ test('expense detail edit keeps delete but removes cancel and allows draft place
test('travel detail AI advice adds low risk reminders for optional receipts', () => {
assert.match(detailViewScript, /function buildOptionalTravelReceiptRiskCards\(requestModel, items\)/)
assert.match(detailViewScript, /isApplicationDocumentRequest\(requestModel\)[\s\S]*return \[\]/)
assert.match(detailViewScript, /id: 'travel-optional-hotel-ticket'[\s\S]*tone: 'low'[\s\S]*住宿票据提醒/)
assert.match(detailViewScript, /不要忘记补充酒店住宿票据/)
assert.match(detailViewScript, /id: 'travel-optional-ride-ticket'[\s\S]*tone: 'low'[\s\S]*乘车票据提醒/)
@@ -539,6 +545,33 @@ test('expense detail save is blocked while attachment recognition is running', (
)
})
test('application detail uses application labels instead of reimbursement labels', () => {
assert.match(detailViewTemplate, /isApplicationDocument \? '申请进度'/)
assert.match(detailViewTemplate, /isApplicationDocument \? '申请预算' : '费用明细'/)
assert.match(detailViewTemplate, /无需补充任何报销票据/)
assert.match(detailViewTemplate, /isApplicationDocument \? '申请类型' : '报销类型'/)
assert.match(detailViewTemplate, /isApplicationDocument \? '预计金额' : '报销金额'/)
assert.match(detailViewTemplate, /isApplicationDocument \? '退回申请' : '退回单据'/)
assert.match(detailViewTemplate, /当前申请单已进入流程,详情页仅展示状态与申请信息。/)
})
test('application detail does not show optional travel receipt reminders', () => {
const request = {
documentTypeCode: 'application',
claimNo: 'APP-20260525-ABC123',
typeCode: 'travel_application',
detailVariant: 'travel'
}
assert.equal(isApplicationDocumentRequest(request), true)
assert.deepEqual(
buildOptionalTravelReceiptRiskCards(request, [
{ id: 'allowance', itemType: 'travel_allowance', isSystemGenerated: true, invoiceId: '' }
]),
[]
)
})
test('draft submit validation uses expense detail date and amount when claim summary is stale', () => {
const issues = buildDraftBlockingIssues(
{