feat: 新增归档中心页面并完善知识库与报销查询能力

新增前端归档中心视图及相关工具函数,扩充知识库文档分类和
提取器支持多种格式,增强编排器报销查询的多维度检索,优
化本体规则和用户代理审核消息,前端完善报销创建和审批详
情交互细节,补充单元测试覆盖。
This commit is contained in:
caoxiaozhu
2026-05-22 16:00:19 +08:00
parent 1f15699013
commit 88ff04bef8
120 changed files with 6236 additions and 643 deletions

View File

@@ -96,6 +96,7 @@
}
.main.requests-main,
.main.approval-main,
.main.archive-main,
.main.policies-main,
.main.audit-main,
.main.logs-main,
@@ -114,6 +115,7 @@
.workarea { min-height: 0; overflow: auto; padding: 24px; }
.workarea.requests-workarea,
.workarea.approval-workarea,
.workarea.archive-workarea,
.workarea.policies-workarea,
.workarea.audit-workarea,
.workarea.logs-workarea,

View File

@@ -0,0 +1,54 @@
.archive-page .status-tag.archived {
color: #0f766e;
background: rgba(16, 185, 129, 0.12);
border: 1px solid rgba(16, 185, 129, 0.22);
}
.archive-page .risk-tag.none {
background: #f1f5f9;
color: #64748b;
}
.archive-dropdown-filter {
position: relative;
}
.archive-dropdown-menu {
position: absolute;
top: calc(100% + 8px);
left: 0;
z-index: 12;
min-width: 148px;
max-height: 280px;
padding: 6px;
border: 1px solid #d7e0ea;
border-radius: 10px;
background: #fff;
box-shadow: 0 16px 32px rgba(15, 23, 42, 0.12);
overflow-y: auto;
}
.archive-dropdown-option {
display: block;
width: 100%;
min-height: 36px;
padding: 0 12px;
border: 0;
border-radius: 8px;
background: transparent;
color: #334155;
font-size: 13px;
font-weight: 600;
text-align: left;
cursor: pointer;
}
.archive-dropdown-option:hover,
.archive-dropdown-option.active {
background: rgba(16, 185, 129, 0.1);
color: #047857;
}
.archive-page .hint {
color: #475569;
}

View File

@@ -1089,23 +1089,3 @@
gap: 12px;
flex-wrap: wrap;
}
.review-upload-decision-modal {
display: grid;
gap: 18px;
}
.review-upload-decision-copy {
display: grid;
gap: 10px;
}
.review-upload-decision-actions {
justify-content: stretch;
}
.review-upload-decision-actions .primary-dialog-btn,
.review-upload-decision-actions .secondary-dialog-btn {
flex: 1 1 168px;
}

View File

@@ -453,10 +453,6 @@
justify-content: stretch;
}
.review-upload-decision-actions {
width: 100%;
}
.primary-dialog-btn,
.secondary-dialog-btn,
.danger-dialog-btn {

View File

@@ -740,6 +740,38 @@
color: #475569;
}
.message-answer-markdown :deep(.markdown-attachment-card) {
margin: 10px 0 12px;
padding: 12px 14px;
border: 1px solid #dbe4ee;
border-left: 4px solid #2563eb;
border-radius: 8px;
background: #f8fafc;
color: #334155;
}
.message-answer-markdown :deep(.markdown-attachment-card + .markdown-attachment-card) {
margin-top: 12px;
}
.message-answer-markdown :deep(.markdown-attachment-card p) {
margin: 0;
}
.message-answer-markdown :deep(.markdown-attachment-card p:first-child) {
color: #0f172a;
font-weight: 820;
}
.message-answer-markdown :deep(.markdown-attachment-card ul) {
margin-top: 8px;
padding-left: 18px;
}
.message-answer-markdown :deep(.markdown-attachment-card li + li) {
margin-top: 4px;
}
.message-answer-markdown :deep(code) {
padding: 2px 6px;
border-radius: 6px;
@@ -766,6 +798,22 @@
text-decoration: underline;
}
.message-answer-markdown :deep(.markdown-action-paragraph) {
margin-top: 34px;
color: #475569;
}
.message-answer-markdown :deep(.markdown-action-link) {
color: #2563eb;
font-weight: 850;
text-decoration-thickness: 1.5px;
text-underline-offset: 3px;
}
.message-answer-markdown :deep(.markdown-action-link:hover) {
color: #1d4ed8;
}
.message-answer-markdown :deep(.markdown-table-wrap) {
width: 100%;
max-width: 100%;
@@ -1237,6 +1285,71 @@
font-weight: 700;
}
.expense-query-risk-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 4px;
}
.expense-query-risk-chip {
max-width: 100%;
min-height: 24px;
display: inline-flex;
align-items: center;
gap: 5px;
padding: 0 8px;
border: 1px solid #fecaca;
border-radius: 999px;
background: #fff7ed;
color: #9a3412;
font: inherit;
font-size: 11px;
cursor: pointer;
}
.expense-query-risk-chip span,
.expense-query-risk-chip em {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.expense-query-risk-chip span {
max-width: 86px;
color: #7c2d12;
}
.expense-query-risk-chip strong {
flex-shrink: 0;
font-weight: 850;
}
.expense-query-risk-chip em {
max-width: 120px;
font-style: normal;
}
.expense-query-risk-chip.high {
border-color: #fecaca;
background: #fef2f2;
color: #b91c1c;
}
.expense-query-risk-chip.medium,
.expense-query-risk-chip.warning {
border-color: #fed7aa;
background: #fff7ed;
color: #c2410c;
}
.expense-query-risk-chip.low,
.expense-query-risk-chip.info {
border-color: #bfdbfe;
background: #eff6ff;
color: #1d4ed8;
}
.expense-query-pager {
display: flex;
align-items: center;
@@ -1513,4 +1626,3 @@
font-size: 13px;
font-weight: 900;
}

View File

@@ -606,54 +606,6 @@
gap: 8px;
}
.detail-note-tag-list,
.risk-card-tag-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.risk-note-tag {
display: inline-flex;
align-items: center;
min-height: 22px;
padding: 0 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 850;
line-height: 1;
}
.risk-note-tag.high {
background: #fef2f2;
color: #dc2626;
}
.risk-note-tag.medium {
background: #fff7ed;
color: #c2410c;
}
.risk-note-tag.low {
background: #eff6ff;
color: #2563eb;
}
.risk-note-tag.hotel {
background: #fdf2f8;
color: #be185d;
}
.risk-note-tag.traffic {
background: #ecfeff;
color: #0e7490;
}
.risk-note-tag.neutral {
background: #f1f5f9;
color: #475569;
}
.leader-approval-card {
border-color: rgba(5, 150, 105, .18);
background: linear-gradient(180deg, #ffffff 0%, #f7fdfb 100%);

View File

@@ -79,8 +79,9 @@ const {
const sidebarMeta = {
overview: { label: '财务总览' },
workbench: { label: '个人工作台' },
requests: { label: '个人报销' },
requests: { label: '报销中心' },
approval: { label: '审批中心' },
archive: { label: '归档中心' },
policies: { label: '知识管理' },
audit: { label: '任务规则中心' },
logs: { label: '日志管理' },

View File

@@ -6,92 +6,29 @@ import { useNavigation, navItems } from './useNavigation.js'
import { useRequests } from './useRequests.js'
import { useSystemState } from './useSystemState.js'
import { useToast } from './useToast.js'
import { fetchLatestConversation } from '../services/orchestrator.js'
import { normalizeRequestForUi } from '../utils/requestViewModel.js'
import { buildWorkbenchSummary } from '../utils/workbenchSummary.js'
const SESSION_TYPE_EXPENSE = 'expense'
function isPlaceholderValue(value) {
const text = String(value || '').trim()
if (!text) {
return true
}
return ['待补充', '暂无', '无', '未知', '处理中'].includes(text.replace(/\s+/g, ''))
}
function hasMissingAttachment(request) {
const expenseItems = Array.isArray(request?.expenseItems) ? request.expenseItems : []
if (expenseItems.length) {
return expenseItems.some((item) => !String(item?.invoiceId || item?.invoice_id || '').trim())
}
const attachmentSummary = String(request?.attachmentSummary || '').trim()
const secondaryStatusValue = String(request?.secondaryStatusValue || '').trim()
return /待|缺|未/.test(attachmentSummary) || /待|缺|未/.test(secondaryStatusValue)
}
function hasPendingInfo(request) {
if (!request) {
return false
}
if (request.approvalKey === 'draft' || request.approvalKey === 'supplement') {
return true
}
if (!Number.isFinite(Number(request.amountValue)) || Number(request.amountValue) <= 0) {
return true
}
return [
request.profileDepartment,
request.profilePosition,
request.profileGrade,
request.profileManager,
request.reason,
request.occurredDisplay
].some(isPlaceholderValue)
}
function resolveDetailAlertTone(request) {
if (request?.approvalKey === 'completed') return 'success'
if (request?.approvalKey === 'rejected') return 'danger'
return 'warning'
}
function buildDetailAlerts(request) {
if (!request) {
return []
}
const alerts = []
const nodeLabel = String(request.node || request.approval || '').trim()
if (nodeLabel) {
alerts.push({ label: nodeLabel, tone: resolveDetailAlertTone(request) })
}
if (hasMissingAttachment(request)) {
alerts.push({ label: '缺少票据', tone: 'warning' })
}
if (hasPendingInfo(request)) {
alerts.push({ label: '待补信息', tone: 'warning' })
}
return alerts.filter((item, index, list) => list.findIndex((entry) => entry.label === item.label) === index).slice(0, 3)
}
export function useAppShell() {
import { fetchLatestConversation } from '../services/orchestrator.js'
import { clearAssistantSessionSnapshotForDraftClaim } from '../utils/assistantSessionSnapshot.js'
import { buildDetailAlerts } from '../utils/detailAlerts.js'
import { normalizeRequestForUi } from '../utils/requestViewModel.js'
import { buildWorkbenchSummary } from '../utils/workbenchSummary.js'
const SESSION_TYPE_EXPENSE = 'expense'
export function useAppShell() {
const route = useRoute()
const router = useRouter()
const smartEntryOpen = ref(false)
const smartEntryContext = ref({ prompt: '', source: 'requests', request: null, files: [], conversation: null })
const smartEntryContext = ref({
prompt: '',
source: 'requests',
request: null,
files: [],
conversation: null,
scope: null
})
const smartEntrySessionId = ref(0)
const smartEntryInvalidatedDraftClaimId = ref('')
const { activeView, currentView, setView } = useNavigation()
const {
@@ -208,25 +145,56 @@ export function useAppShell() {
setView(view)
}
function openTravelCreate() {
smartEntryOpen.value = true
smartEntryContext.value = { prompt: '', source: 'topbar', request: null, files: [], conversation: null }
smartEntrySessionId.value += 1
}
function openTravelCreate() {
smartEntryOpen.value = true
smartEntryContext.value = {
prompt: '',
source: 'topbar',
request: null,
files: [],
conversation: null,
scope: null
}
smartEntrySessionId.value += 1
}
function resolveCurrentUserId() {
const user = currentUser.value || {}
return String(user.username || user.name || 'anonymous').trim() || 'anonymous'
}
async function resolveSmartEntryConversation(payload = {}) {
if (payload.conversation) {
return payload.conversation
}
if (!payload.restoreLatestConversation) {
return null
}
function resolveCurrentUserId() {
const user = currentUser.value || {}
return String(user.username || user.name || 'anonymous').trim() || 'anonymous'
}
function resolveSmartEntryClaimScope(payload = {}) {
const request = payload.request && typeof payload.request === 'object' ? payload.request : null
const payloadScope = payload.scope && typeof payload.scope === 'object' ? payload.scope : null
const claimId = String(
payloadScope?.claimId ||
payloadScope?.claim_id ||
request?.claimId ||
request?.claim_id ||
''
).trim()
if (!claimId) {
return null
}
return { type: 'claim', claimId }
}
function isDetailClaimScopedPayload(payload = {}) {
return String(payload.source || '').trim() === 'detail' && Boolean(resolveSmartEntryClaimScope(payload))
}
async function resolveSmartEntryConversation(payload = {}) {
if (payload.conversation) {
return payload.conversation
}
if (isDetailClaimScopedPayload(payload)) {
return null
}
if (!payload.restoreLatestConversation) {
return null
}
try {
const latestPayload = await fetchLatestConversation(resolveCurrentUserId(), SESSION_TYPE_EXPENSE, {
@@ -240,17 +208,19 @@ export function useAppShell() {
}
}
async function openSmartEntry(payload = {}) {
const conversation = await resolveSmartEntryConversation(payload)
smartEntryOpen.value = true
smartEntryContext.value = {
prompt: payload.prompt ?? '',
source: payload.source ?? 'workbench',
request: payload.request ?? selectedRequest.value,
files: Array.isArray(payload.files) ? payload.files : [],
conversation
}
async function openSmartEntry(payload = {}) {
const conversation = await resolveSmartEntryConversation(payload)
const scope = resolveSmartEntryClaimScope(payload)
smartEntryOpen.value = true
smartEntryContext.value = {
prompt: payload.prompt ?? '',
source: payload.source ?? 'workbench',
request: payload.request ?? selectedRequest.value,
files: Array.isArray(payload.files) ? payload.files : [],
conversation,
scope
}
smartEntrySessionId.value += 1
}
@@ -262,15 +232,15 @@ export function useAppShell() {
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()
smartEntryOpen.value = false
await reloadRequests()
void refreshApprovalInbox()
if (status === 'submitted') {
smartEntryOpen.value = false
void refreshApprovalInbox()
toast(`${claimNo || '该'}单据已完成 AI预审${approvalStage ? `,当前节点:${approvalStage}` : ',并已提交审批'}`)
} else {
toast(`${claimNo || '该'}单据已保存到草稿,请到报销页面查看。`)
router.push({ name: 'app-requests' })
return
}
router.push({ name: 'app-requests' })
toast(`${claimNo || '该'}单据已保存为草稿,可继续上传票据或补充信息。`)
}
function openRequestDetail(request) {
@@ -289,7 +259,13 @@ export function useAppShell() {
void refreshApprovalInbox()
}
async function handleRequestDeleted() {
async function handleRequestDeleted(payload = {}) {
const deletedClaimId = String(payload.claimId || payload.claim_id || '').trim()
if (deletedClaimId) {
clearAssistantSessionSnapshotForDraftClaim(resolveCurrentUserId(), deletedClaimId, SESSION_TYPE_EXPENSE)
smartEntryInvalidatedDraftClaimId.value = deletedClaimId
}
await reloadRequests()
void refreshApprovalInbox()
router.push({ name: 'app-requests' })
@@ -327,6 +303,7 @@ export function useAppShell() {
selectedRequest,
setView,
smartEntryContext,
smartEntryInvalidatedDraftClaimId,
smartEntryOpen,
smartEntrySessionId,
detailAlerts,

View File

@@ -3,7 +3,7 @@ import { useRoute, useRouter } from 'vue-router'
import { icons } from '../data/icons.js'
export const appViews = ['overview', 'workbench', 'requests', 'approval', 'policies', 'audit', 'logs', 'employees', 'settings']
export const appViews = ['overview', 'workbench', 'requests', 'approval', 'archive', 'policies', 'audit', 'logs', 'employees', 'settings']
export const navItems = [
{
@@ -24,10 +24,10 @@ export const navItems = [
},
{
id: 'requests',
label: '个人报销',
navHint: '查看和管理个人报销',
label: '报销中心',
navHint: '查看和管理报销单据',
icon: icons.list,
title: '个人报销',
title: '报销中心',
desc: '集中查看草稿、审批进度、票据状态与风险提示。'
},
{
@@ -38,6 +38,14 @@ export const navItems = [
title: '审批中心',
desc: '按优先级处理待审批事项,控制时效与风险。'
},
{
id: 'archive',
label: '归档中心',
navHint: '查阅公司已归档财务数据',
icon: icons.archive,
title: '归档中心',
desc: '集中保存公司已归档入账的报销单据,形成完整财务归档库。'
},
{
id: 'policies',
label: '制度知识',
@@ -85,6 +93,7 @@ const viewRouteNames = {
workbench: 'app-workbench',
requests: 'app-requests',
approval: 'app-approval',
archive: 'app-archive',
policies: 'app-policies',
audit: 'app-audit',
logs: 'app-logs',

View File

@@ -1,11 +1,14 @@
import { computed, reactive, ref } from 'vue'
import { fetchExpenseClaims } from '../services/reimbursements.js'
import { filterActionableRiskFlags } from '../utils/riskFlags.js'
const EXPENSE_TYPE_LABELS = {
travel: '差旅费',
train_ticket: '火车票',
flight_ticket: '机票',
ship_ticket: '轮船票',
ferry_ticket: '轮船票',
hotel_ticket: '住宿票',
ride_ticket: '乘车',
travel_allowance: '出差补贴',
@@ -31,6 +34,8 @@ const LOCATION_REQUIRED_EXPENSE_TYPES = new Set([
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 REIMBURSEMENT_PROGRESS_LABELS = [
'创建单据',
@@ -135,6 +140,17 @@ function resolveLocationDisplay(location, typeCode) {
return isLocationRequiredExpenseType(typeCode) ? '待补充' : '非必填'
}
function resolveExpenseDescriptionDetail(itemType, itemLocation) {
const normalizedType = normalizeExpenseType(itemType)
if (ROUTE_DESCRIPTION_EXPENSE_TYPES.has(normalizedType)) {
return '起始地-目的地'
}
if (HOTEL_DESCRIPTION_EXPENSE_TYPES.has(normalizedType)) {
return '目的地酒店'
}
return resolveLocationDisplay(itemLocation, normalizedType)
}
function resolveExpenseItemViewId(item, index, claim) {
return String(item?.id || `${claim?.id || 'claim'}-item-${index}`)
}
@@ -273,7 +289,7 @@ function buildRiskSummary(riskFlags) {
return '无'
}
const items = riskFlags.map((item) => stringifyRiskFlag(item)).filter(Boolean)
const items = filterActionableRiskFlags(riskFlags).map((item) => stringifyRiskFlag(item)).filter(Boolean)
return items.length ? items.join('') : '无'
}
@@ -602,7 +618,7 @@ function buildExpenseItems(claim, riskSummary) {
name: itemTypeLabel,
category: itemTypeLabel,
desc: itemReason || '待补充',
detail: resolveLocationDisplay(itemLocation, itemType),
detail: resolveExpenseDescriptionDetail(itemType, itemLocation),
amount: itemAmountDisplay,
status: isSystemGenerated ? '系统计算' : attachments.length ? '已识别' : '待补充',
tone: isSystemGenerated ? 'system' : attachments.length ? 'ok' : 'bad',
@@ -654,6 +670,7 @@ export function mapExpenseClaimToRequest(claim) {
applyTime: formatDateTime(applyDateTime) || '待补充',
submittedAt: applyDateTime || '',
createdAt: claim?.created_at || '',
updatedAt: claim?.updated_at || '',
amount: parseNumber(claim?.amount),
riskFlags: Array.isArray(claim?.risk_flags_json) ? claim.risk_flags_json : [],
invoiceCount,

View File

@@ -5,6 +5,7 @@ export const icons = {
workspace: iconPath('<path d="M4 20h16"/><path d="M6 20V8a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v12"/><path d="M9 10h6"/><path d="M9 14h6"/><path d="M12 3v3"/>'),
list: iconPath('<path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/>'),
approval: iconPath('<path d="M9 11l2 2 4-5"/><path d="M20 12v5a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h8"/><path d="M17 3h4v4"/>'),
archive: iconPath('<path d="M3 7h18v13a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><path d="M3 7l2-3h14l2 3"/><path d="M10 12h4"/>'),
file: iconPath('<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M8 13h8"/><path d="M8 17h5"/>'),
skill: iconPath('<path d="M12 3 9.5 8.5 3 11l6.5 2.5L12 19l2.5-5.5L21 11l-6.5-2.5z"/><path d="M19 19l.9 2 .9-2 2-.9-2-.9-.9-2-.9 2-2 .9z"/><path d="M5 5l.6 1.4L7 7l-1.4.6L5 9l-.6-1.4L3 7l1.4-.6z"/>'),
users: iconPath('<path d="M16 21v-2a4 4 0 0 0-4-4H7a4 4 0 0 0-4 4v2"/><circle cx="9.5" cy="7" r="4"/><path d="M20 8v6"/><path d="M23 11h-6"/>'),

View File

@@ -8,6 +8,10 @@ export function fetchApprovalExpenseClaims() {
return apiRequest('/reimbursements/claims/approvals')
}
export function fetchArchivedExpenseClaims() {
return apiRequest('/reimbursements/claims/archives')
}
export function fetchExpenseClaimDetail(claimId) {
return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}`)
}

View File

@@ -1,75 +1,77 @@
export const DEFAULT_APP_VIEW_ORDER = [
'overview',
'workbench',
'requests',
'approval',
'policies',
'audit',
'logs',
'employees',
'settings'
]
const ALWAYS_VISIBLE_VIEWS = new Set(['workbench', 'requests', 'policies'])
const VIEW_ROLE_RULES = {
overview: ['finance', 'executive'],
approval: ['approver', 'finance', 'executive'],
audit: ['auditor', 'finance'],
logs: ['manager'],
employees: ['manager'],
settings: ['manager']
}
const CLAIM_MANAGER_ROLE_CODES = new Set(['executive'])
const CLAIM_RETURN_ROLE_CODES = new Set(['finance', 'executive', 'manager', 'approver'])
const CLAIM_LEADER_APPROVAL_ROLE_CODES = new Set(['manager', 'approver'])
export const DEFAULT_APP_VIEW_ORDER = [
'overview',
'workbench',
'requests',
'approval',
'archive',
'policies',
'audit',
'logs',
'employees',
'settings'
]
const ALWAYS_VISIBLE_VIEWS = new Set(['workbench', 'requests', 'policies'])
const VIEW_ROLE_RULES = {
overview: ['finance', 'executive'],
approval: ['approver', 'finance', 'executive'],
archive: ['finance', 'executive', 'auditor'],
audit: ['auditor', 'finance'],
logs: ['manager'],
employees: ['manager'],
settings: ['manager']
}
const CLAIM_MANAGER_ROLE_CODES = new Set(['executive'])
const CLAIM_RETURN_ROLE_CODES = new Set(['finance', 'executive', 'manager', 'approver'])
const CLAIM_LEADER_APPROVAL_ROLE_CODES = new Set(['manager', 'approver'])
function normalizedRoleCodes(user) {
if (!user) {
return []
}
return Array.isArray(user.roleCodes)
? user.roleCodes.map((item) => String(item || '').trim().toLowerCase()).filter(Boolean)
: []
}
return Array.isArray(user.roleCodes)
? user.roleCodes.map((item) => String(item || '').trim().toLowerCase()).filter(Boolean)
: []
}
export function isManagerUser(user) {
return Boolean(user?.isAdmin) || normalizedRoleCodes(user).includes('manager')
}
export function isFinanceUser(user) {
return normalizedRoleCodes(user).includes('finance')
}
export function isExecutiveUser(user) {
return normalizedRoleCodes(user).includes('executive')
}
export function canManageExpenseClaims(user) {
if (Boolean(user?.isAdmin)) {
return true
}
return normalizedRoleCodes(user).some((roleCode) => CLAIM_MANAGER_ROLE_CODES.has(roleCode))
}
export function canReturnExpenseClaims(user) {
if (Boolean(user?.isAdmin)) {
return true
}
return normalizedRoleCodes(user).some((roleCode) => CLAIM_RETURN_ROLE_CODES.has(roleCode))
}
export function canApproveLeaderExpenseClaims(user) {
if (Boolean(user?.isAdmin)) {
return true
}
return normalizedRoleCodes(user).some((roleCode) => CLAIM_LEADER_APPROVAL_ROLE_CODES.has(roleCode))
}
export function canAccessAppView(user, viewId) {
export function isManagerUser(user) {
return Boolean(user?.isAdmin) || normalizedRoleCodes(user).includes('manager')
}
export function isFinanceUser(user) {
return normalizedRoleCodes(user).includes('finance')
}
export function isExecutiveUser(user) {
return normalizedRoleCodes(user).includes('executive')
}
export function canManageExpenseClaims(user) {
if (Boolean(user?.isAdmin)) {
return true
}
return normalizedRoleCodes(user).some((roleCode) => CLAIM_MANAGER_ROLE_CODES.has(roleCode))
}
export function canReturnExpenseClaims(user) {
if (Boolean(user?.isAdmin)) {
return true
}
return normalizedRoleCodes(user).some((roleCode) => CLAIM_RETURN_ROLE_CODES.has(roleCode))
}
export function canApproveLeaderExpenseClaims(user) {
if (Boolean(user?.isAdmin)) {
return true
}
return normalizedRoleCodes(user).some((roleCode) => CLAIM_LEADER_APPROVAL_ROLE_CODES.has(roleCode))
}
export function canAccessAppView(user, viewId) {
if (!viewId || !user) {
return false
}

View File

@@ -0,0 +1,216 @@
import {
isActionableRiskFlag,
isRiskSummaryWithRisk,
normalizeRiskFlagTone
} from './riskFlags.js'
export const ARCHIVE_FILTER_ALL = 'all'
export function countClaimRisks(riskFlags, riskSummary) {
let count = 0
for (const flag of Array.isArray(riskFlags) ? riskFlags : []) {
if (!isActionableRiskFlag(flag)) {
continue
}
if (!flag || typeof flag !== 'object') {
count += 1
continue
}
const points = Array.isArray(flag.points)
? flag.points.map((point) => String(point || '').trim()).filter(Boolean)
: []
if (points.length) {
count += points.length
continue
}
const message = String(
flag.message || flag.reason || flag.summary || flag.label || flag.description || flag.title || ''
).trim()
if (message) {
count += 1
}
}
if (!count && isRiskSummaryWithRisk(riskSummary)) {
return 1
}
return count
}
export function resolveArchiveRiskTone(riskFlags, riskSummary) {
let tone = 'low'
for (const flag of Array.isArray(riskFlags) ? riskFlags : []) {
if (!isActionableRiskFlag(flag)) {
continue
}
const flagTone = normalizeRiskFlagTone(flag)
if (flagTone === 'high') {
return 'high'
}
if (flagTone === 'medium') {
tone = 'medium'
}
}
if (tone === 'low' && isRiskSummaryWithRisk(riskSummary)) {
return 'medium'
}
return tone
}
export function formatArchiveRiskCountLabel(riskCount) {
const count = Math.max(0, Number(riskCount) || 0)
return `${count}`
}
export function extractArchiveMonth(...values) {
for (const value of values) {
const text = String(value || '').trim()
if (!text) {
continue
}
const parsedDate = new Date(text)
if (!Number.isNaN(parsedDate.getTime())) {
const year = parsedDate.getFullYear()
const month = String(parsedDate.getMonth() + 1).padStart(2, '0')
return `${year}-${month}`
}
const matched = text.match(/(\d{4})-(\d{2})/)
if (matched) {
return `${matched[1]}-${matched[2]}`
}
}
return ''
}
export function formatArchiveMonthLabel(monthKey) {
const normalized = String(monthKey || '').trim()
const matched = normalized.match(/^(\d{4})-(\d{2})$/)
if (!matched) {
return normalized || '未知月份'
}
return `${matched[1]}${matched[2]}`
}
export function buildTypeFilterOptions(rows) {
const typeMap = new Map()
for (const row of rows) {
const value = String(row?.typeCode || 'other').trim() || 'other'
if (!typeMap.has(value)) {
typeMap.set(value, String(row?.type || row?.typeLabel || value).trim() || value)
}
}
return [
{ value: ARCHIVE_FILTER_ALL, label: '全部类型' },
...Array.from(typeMap.entries())
.sort((left, right) => left[1].localeCompare(right[1], 'zh-CN'))
.map(([value, label]) => ({ value, label }))
]
}
export function buildDepartmentFilterOptions(rows) {
const departments = new Set()
for (const row of rows) {
const department = String(row?.department || row?.dept || '').trim()
if (department) {
departments.add(department)
}
}
return [
{ value: ARCHIVE_FILTER_ALL, label: '全部部门' },
...Array.from(departments)
.sort((left, right) => left.localeCompare(right, 'zh-CN'))
.map((value) => ({ value, label: value }))
]
}
export function buildArchiveMonthFilterOptions(rows) {
const months = new Set()
for (const row of rows) {
const month = String(row?.archiveMonth || '').trim()
if (month) {
months.add(month)
}
}
return [
{ value: ARCHIVE_FILTER_ALL, label: '全部月份' },
...Array.from(months)
.sort((left, right) => right.localeCompare(left))
.map((value) => ({ value, label: formatArchiveMonthLabel(value) }))
]
}
export function applyArchiveListFilters(rows, filters) {
let filteredRows = Array.isArray(rows) ? [...rows] : []
if (filters.tab && filters.tab !== '全部归档') {
filteredRows = filteredRows.filter((row) => row.archiveTab === filters.tab)
}
if (filters.risk === 'has') {
filteredRows = filteredRows.filter((row) => row.hasRisk)
} else if (filters.risk === 'none') {
filteredRows = filteredRows.filter((row) => !row.hasRisk)
} else if (filters.risk && filters.risk !== ARCHIVE_FILTER_ALL) {
filteredRows = filteredRows.filter((row) => row.hasRisk && row.riskTone === filters.risk)
}
if (filters.type && filters.type !== ARCHIVE_FILTER_ALL) {
filteredRows = filteredRows.filter((row) => String(row.typeCode || '').trim() === filters.type)
}
if (filters.department && filters.department !== ARCHIVE_FILTER_ALL) {
filteredRows = filteredRows.filter((row) => String(row.department || '').trim() === filters.department)
}
if (filters.archiveMonth && filters.archiveMonth !== ARCHIVE_FILTER_ALL) {
filteredRows = filteredRows.filter((row) => String(row.archiveMonth || '').trim() === filters.archiveMonth)
}
const keyword = String(filters.keyword || '').trim().toLowerCase()
if (keyword) {
filteredRows = filteredRows.filter((row) => (
String(row.id || '').toLowerCase().includes(keyword)
|| String(row.applicant || '').toLowerCase().includes(keyword)
|| String(row.department || '').toLowerCase().includes(keyword)
|| String(row.type || '').toLowerCase().includes(keyword)
|| String(row.amount || '').toLowerCase().includes(keyword)
|| String(row.risk || '').toLowerCase().includes(keyword)
|| String(row.riskCount ?? '').includes(keyword)
|| String(row.archiveMonthLabel || '').toLowerCase().includes(keyword)
))
}
return filteredRows
}
export function hasActiveArchiveListFilters(filters) {
return Boolean(
(filters.tab && filters.tab !== '全部归档')
|| (filters.risk && filters.risk !== ARCHIVE_FILTER_ALL)
|| (filters.type && filters.type !== ARCHIVE_FILTER_ALL)
|| (filters.department && filters.department !== ARCHIVE_FILTER_ALL)
|| (filters.archiveMonth && filters.archiveMonth !== ARCHIVE_FILTER_ALL)
|| String(filters.keyword || '').trim()
)
}

View File

@@ -22,12 +22,12 @@ function getStorage() {
return window.localStorage
}
function emitSnapshotChange(sessionType) {
function emitSnapshotChange(sessionType, detail = {}) {
if (typeof window === 'undefined') {
return
}
window.dispatchEvent(new CustomEvent(ASSISTANT_SESSION_SNAPSHOT_EVENT, {
detail: { sessionType: normalizeSessionType(sessionType) }
detail: { sessionType: normalizeSessionType(sessionType), ...detail }
}))
}
@@ -82,18 +82,39 @@ export function writeAssistantSessionSnapshot(userId, sessionType = 'expense', s
export function clearAssistantSessionSnapshot(userId, sessionType = 'expense') {
const storage = getStorage()
if (!storage) {
return
return false
}
const normalizedSessionType = normalizeSessionType(sessionType)
try {
storage.removeItem(resolveAssistantSessionSnapshotKey(userId, normalizedSessionType))
emitSnapshotChange(normalizedSessionType)
emitSnapshotChange(normalizedSessionType, { action: 'clear' })
return true
} catch (error) {
console.warn('Failed to clear assistant session snapshot:', error)
return false
}
}
export function hasAssistantSessionSnapshot(userId, sessionType = 'expense') {
return Boolean(readAssistantSessionSnapshot(userId, sessionType)?.state)
}
function resolveSnapshotDraftClaimId(snapshot) {
const state = snapshot?.state && typeof snapshot.state === 'object' ? snapshot.state : {}
return String(state.draftClaimId || state.draft_claim_id || '').trim()
}
export function clearAssistantSessionSnapshotForDraftClaim(userId, claimId, sessionType = 'expense') {
const normalizedClaimId = String(claimId || '').trim()
if (!normalizedClaimId) {
return false
}
const snapshot = readAssistantSessionSnapshot(userId, sessionType)
if (resolveSnapshotDraftClaimId(snapshot) !== normalizedClaimId) {
return false
}
return clearAssistantSessionSnapshot(userId, sessionType)
}

View File

@@ -0,0 +1,114 @@
const SYSTEM_GENERATED_EXPENSE_TYPES = new Set(['travel_allowance'])
const LOCATION_REQUIRED_EXPENSE_TYPES = new Set(['travel', 'meeting', 'entertainment'])
function isPlaceholderValue(value) {
const text = String(value || '').trim()
if (!text) {
return true
}
return ['待补充', '暂无', '无', '未知', '处理中'].includes(text.replace(/\s+/g, ''))
}
function normalizeExpenseType(value) {
return String(value || '').trim() || 'other'
}
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))
}
function hasPositiveAmount(value) {
const amount = Number(value)
return Number.isFinite(amount) && amount > 0
}
function getExpenseItems(request) {
return Array.isArray(request?.expenseItems) ? request.expenseItems : []
}
export function hasMissingAttachment(request) {
const expenseItems = getExpenseItems(request)
if (expenseItems.length) {
return expenseItems.some((item) => {
if (isSystemGeneratedExpenseItem(item)) {
return false
}
return !String(item?.invoiceId || item?.invoice_id || '').trim()
})
}
const attachmentSummary = String(request?.attachmentSummary || '').trim()
const secondaryStatusValue = String(request?.secondaryStatusValue || '').trim()
return /待|缺|未/.test(attachmentSummary) || /待|缺|未/.test(secondaryStatusValue)
}
export function hasPendingInfo(request) {
if (!request) {
return false
}
const expenseItems = getExpenseItems(request).filter((item) => !isSystemGeneratedExpenseItem(item))
const hasItemValue = (resolver) => expenseItems.some((item) => !isPlaceholderValue(resolver(item)))
const hasItemAmount = expenseItems.some((item) => hasPositiveAmount(item?.itemAmount || item?.item_amount))
const requestType = normalizeExpenseType(request.typeCode || request.expense_type)
const locationRequired = LOCATION_REQUIRED_EXPENSE_TYPES.has(requestType)
if (!hasPositiveAmount(request.amountValue) && !hasItemAmount) {
return true
}
if (isPlaceholderValue(request.typeLabel) && !hasItemValue((item) => item?.itemType || item?.item_type)) {
return true
}
if (isPlaceholderValue(request.reason) && !hasItemValue((item) => item?.itemReason || item?.item_reason || item?.desc)) {
return true
}
if (isPlaceholderValue(request.occurredDisplay) && !hasItemValue((item) => item?.itemDate || item?.item_date || item?.time)) {
return true
}
if (
locationRequired
&& isPlaceholderValue(request.location)
&& isPlaceholderValue(request.city)
&& !hasItemValue((item) => item?.itemLocation || item?.item_location)
) {
return true
}
return false
}
function resolveDetailAlertTone(request) {
if (request?.approvalKey === 'completed') return 'success'
if (request?.approvalKey === 'rejected') return 'danger'
return 'warning'
}
export function buildDetailAlerts(request) {
if (!request) {
return []
}
const alerts = []
const nodeLabel = String(request.node || request.approval || '').trim()
if (nodeLabel) {
alerts.push({ label: nodeLabel, tone: resolveDetailAlertTone(request) })
}
if (hasMissingAttachment(request)) {
alerts.push({ label: '缺少票据', tone: 'warning' })
}
if (hasPendingInfo(request)) {
alerts.push({ label: '待补信息', tone: 'warning' })
}
return alerts.filter((item, index, list) => list.findIndex((entry) => entry.label === item.label) === index).slice(0, 3)
}

View File

@@ -0,0 +1,14 @@
export function isArchivedExpenseClaim(claim) {
const stage = String(claim?.approval_stage || claim?.approvalStage || '').trim()
const status = String(claim?.status || '').trim().toLowerCase()
if (stage === '归档入账' || stage === 'completed' || stage.includes('归档')) {
return true
}
if (!['approved', 'completed', 'paid'].includes(status)) {
return false
}
return !stage || stage === '归档入账' || stage === 'completed'
}

View File

@@ -8,6 +8,76 @@ const markdown = new MarkdownIt({
const defaultTableOpen = markdown.renderer.rules.table_open
const defaultTableClose = markdown.renderer.rules.table_close
const defaultParagraphOpen = markdown.renderer.rules.paragraph_open
const defaultLinkOpen = markdown.renderer.rules.link_open
const defaultBlockquoteOpen = markdown.renderer.rules.blockquote_open
const ACTION_LINK_CLASS_BY_HREF = {
'#confirm-attachment-association': 'markdown-action-link-confirm'
}
function resolveActionLinkClass(href) {
const normalizedHref = String(href || '').trim()
return ACTION_LINK_CLASS_BY_HREF[normalizedHref] || ''
}
function inlineTokenHasActionLink(token) {
const children = Array.isArray(token?.children) ? token.children : []
return children.some((child) => (
child?.type === 'link_open' && resolveActionLinkClass(child.attrGet?.('href'))
))
}
function resolveInlineTokenPlainText(token) {
const children = Array.isArray(token?.children) ? token.children : []
const childText = children
.filter((child) => ['text', 'code_inline'].includes(String(child?.type || '')))
.map((child) => String(child?.content || ''))
.join('')
.trim()
return childText || String(token?.content || '').replace(/[*_`]+/g, '').trim()
}
function blockquoteHasAttachmentHeading(tokens, idx) {
for (let i = idx + 1; i < tokens.length; i += 1) {
const token = tokens[i]
if (token?.type === 'blockquote_close') {
return false
}
if (token?.type === 'inline') {
return /^附件\s*\d+\s*[:]/.test(resolveInlineTokenPlainText(token))
}
}
return false
}
markdown.renderer.rules.paragraph_open = (tokens, idx, options, env, self) => {
if (inlineTokenHasActionLink(tokens[idx + 1])) {
tokens[idx].attrJoin('class', 'markdown-action-paragraph')
}
return defaultParagraphOpen
? defaultParagraphOpen(tokens, idx, options, env, self)
: self.renderToken(tokens, idx, options)
}
markdown.renderer.rules.link_open = (tokens, idx, options, env, self) => {
const actionClass = resolveActionLinkClass(tokens[idx].attrGet('href'))
if (actionClass) {
tokens[idx].attrJoin('class', `markdown-action-link ${actionClass}`)
}
return defaultLinkOpen
? defaultLinkOpen(tokens, idx, options, env, self)
: self.renderToken(tokens, idx, options)
}
markdown.renderer.rules.blockquote_open = (tokens, idx, options, env, self) => {
if (blockquoteHasAttachmentHeading(tokens, idx)) {
tokens[idx].attrJoin('class', 'markdown-attachment-card')
}
return defaultBlockquoteOpen
? defaultBlockquoteOpen(tokens, idx, options, env, self)
: self.renderToken(tokens, idx, options)
}
markdown.renderer.rules.table_open = (tokens, idx, options, env, self) => (
`<div class="markdown-table-wrap">${defaultTableOpen ? defaultTableOpen(tokens, idx, options, env, self) : '<table>'}`

107
web/src/utils/riskFlags.js Normal file
View File

@@ -0,0 +1,107 @@
const NO_RISK_SUMMARY_VALUES = new Set(['无', '暂无异常', '无异常', '暂无风险'])
const NON_RISK_SOURCES = new Set([
'manual_approval',
'finance_approval',
'approval',
'approval_log',
'expense_claim_approval',
'expense_claim_finance_approval'
])
const NON_RISK_EVENTS = new Set([
'expense_claim_approval',
'expense_claim_finance_approval'
])
const NON_RISK_TONES = new Set(['info', 'pass', 'success', 'approved', 'ok', 'none'])
const RISK_SOURCES = new Set([
'attachment_analysis',
'submission_review',
'manual_return',
'platform_risk',
'policy_review',
'scene_policy_review'
])
function normalizeText(value) {
return String(value || '').trim()
}
function normalizeKey(value) {
return normalizeText(value).toLowerCase()
}
function isApprovalOnlyText(value) {
const text = normalizeText(value)
if (!text) {
return true
}
return (
/^(同意|通过|审批通过|审核通过|已同意|无意见)$/.test(text)
|| /已审批通过/.test(text)
|| /已完成财务审核/.test(text)
|| /进入归档入账/.test(text)
|| /流转至/.test(text)
)
}
export function normalizeRiskFlagTone(flag) {
if (!flag || typeof flag !== 'object') {
return normalizeText(flag) ? 'medium' : 'none'
}
const tone = normalizeKey(flag.severity || flag.tone || flag.level || flag.riskTone || flag.risk_tone)
if (['high', 'medium', 'low'].includes(tone)) {
return tone
}
if (NON_RISK_TONES.has(tone)) {
return 'none'
}
const source = normalizeKey(flag.source)
if (source === 'manual_return') {
return 'medium'
}
if (RISK_SOURCES.has(source)) {
return 'medium'
}
const riskText = normalizeText(flag.message || flag.reason || flag.summary || flag.label || flag.description || flag.title)
if (riskText && !isApprovalOnlyText(riskText)) {
return 'medium'
}
return 'none'
}
export function isActionableRiskFlag(flag) {
if (!flag || typeof flag !== 'object') {
const text = normalizeText(flag)
return Boolean(text && !isApprovalOnlyText(text))
}
const source = normalizeKey(flag.source)
const eventType = normalizeKey(flag.event_type || flag.eventType)
if (NON_RISK_SOURCES.has(source) || NON_RISK_EVENTS.has(eventType)) {
return false
}
const tone = normalizeRiskFlagTone(flag)
if (tone === 'high' || tone === 'medium' || tone === 'low') {
return true
}
return false
}
export function filterActionableRiskFlags(riskFlags) {
return (Array.isArray(riskFlags) ? riskFlags : []).filter((flag) => isActionableRiskFlag(flag))
}
export function isRiskSummaryWithRisk(riskSummary) {
const summary = normalizeText(riskSummary)
if (!summary || NO_RISK_SUMMARY_VALUES.has(summary) || isApprovalOnlyText(summary)) {
return false
}
return true
}

View File

@@ -17,6 +17,7 @@
'workbench-main': activeView === 'workbench',
'requests-main': activeView === 'requests',
'approval-main': activeView === 'approval',
'archive-main': activeView === 'archive',
'policies-main': activeView === 'policies',
'audit-main': activeView === 'audit',
'audit-detail-main': activeView === 'audit' && auditDetailOpen,
@@ -49,7 +50,7 @@
/>
<FilterBar
v-if="activeView !== 'overview' && activeView !== 'workbench' && activeView !== 'requests' && activeView !== 'approval' && activeView !== 'policies' && activeView !== 'audit' && activeView !== 'logs' && activeView !== 'employees' && activeView !== 'settings'"
v-if="activeView !== 'overview' && activeView !== 'workbench' && activeView !== 'requests' && activeView !== 'approval' && activeView !== 'archive' && activeView !== 'policies' && activeView !== 'audit' && activeView !== 'logs' && activeView !== 'employees' && activeView !== 'settings'"
:compact="activeView === 'overview'"
:filters="filters"
:ranges="ranges"
@@ -62,6 +63,7 @@
:class="{
'requests-workarea': activeView === 'requests',
'approval-workarea': activeView === 'approval',
'archive-workarea': activeView === 'archive',
'policies-workarea': activeView === 'policies',
'audit-workarea': activeView === 'audit',
'logs-workarea': activeView === 'logs',
@@ -105,6 +107,7 @@
/>
<ApprovalCenterView v-else-if="activeView === 'approval'" />
<ArchiveCenterView v-else-if="activeView === 'archive'" />
<PoliciesView v-else-if="activeView === 'policies'" @summary-change="knowledgeSummary = $event" />
<AuditView v-else-if="activeView === 'audit'" @detail-open-change="auditDetailOpen = $event" />
<LogDetailView v-else-if="activeView === 'logs' && logDetailMode" />
@@ -122,6 +125,7 @@
:initial-conversation="smartEntryContext.conversation"
:entry-source="smartEntryContext.source"
:request-context="smartEntryContext.request"
:invalidated-draft-claim-id="smartEntryInvalidatedDraftClaimId"
@close="closeSmartEntry"
@draft-saved="handleDraftSaved"
/>
@@ -140,6 +144,7 @@ import TravelReimbursementCreateView from './TravelReimbursementCreateView.vue'
import TravelRequestDetailView from './TravelRequestDetailView.vue'
import RequestsView from './RequestsView.vue'
import ApprovalCenterView from './ApprovalCenterView.vue'
import ArchiveCenterView from './ArchiveCenterView.vue'
import PoliciesView from './PoliciesView.vue'
import AuditView from './AuditView.vue'
import LogsView from './LogsView.vue'
@@ -187,6 +192,7 @@ const {
search,
selectedRequest,
smartEntryContext,
smartEntryInvalidatedDraftClaimId,
smartEntryOpen,
smartEntrySessionId,
toast,

View File

@@ -0,0 +1,141 @@
<template>
<section class="approval-page archive-page">
<TravelRequestDetailView
v-if="selectedRow"
:request="selectedRow"
back-label="返回归档列表"
@back-to-requests="closeSelectedDetail"
@request-updated="reload"
@request-deleted="reload"
/>
<article v-else class="approval-list panel">
<nav class="status-tabs" aria-label="归档分类">
<button
v-for="tab in tabs"
:key="tab"
type="button"
:class="{ active: activeTab === tab }"
@click="activeTab = tab"
>
{{ tab }}
</button>
</nav>
<div class="list-toolbar">
<div class="filter-set">
<div class="list-search">
<i class="mdi mdi-magnify"></i>
<input v-model="listKeyword" type="search" placeholder="搜索单号、申请人、部门、报销类型..." />
</div>
<div
v-for="dropdown in filterDropdowns"
:key="dropdown.key"
class="archive-dropdown-filter"
:class="{ open: openFilterKey === dropdown.key }"
>
<button class="filter-btn" type="button" @click="toggleFilterDropdown(dropdown.key)">
<span>{{ dropdown.label }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
<div
v-if="openFilterKey === dropdown.key"
class="archive-dropdown-menu"
role="menu"
:aria-label="`${dropdown.label}筛选`"
>
<button
v-for="option in dropdown.options"
:key="`${dropdown.key}-${option.value}`"
type="button"
class="archive-dropdown-option"
:class="{ active: dropdown.activeValue === option.value }"
role="menuitem"
@click="selectFilterValue(dropdown.key, option.value)"
>
{{ option.label }}
</button>
</div>
</div>
</div>
</div>
<p class="hint"><i class="mdi mdi-information-outline"></i> 归档中心保存公司已归档入账的报销数据点击单据行查看详情</p>
<div class="table-wrap" :class="{ 'is-empty': showEmpty }">
<div v-if="loading" class="table-state">
<TableLoadingState
title="归档数据同步中"
message="正在加载公司已归档的报销单据"
icon="mdi mdi-archive-check-outline"
/>
</div>
<div v-else-if="error" class="table-state error">
<i class="mdi mdi-alert-circle-outline"></i>
<strong>归档列表加载失败</strong>
<p>{{ error }}</p>
<button class="state-action" type="button" @click="reload">重新加载</button>
</div>
<TableEmptyState
v-else-if="showEmpty"
:eyebrow="archiveEmptyState.eyebrow"
:title="archiveEmptyState.title"
:description="archiveEmptyState.desc"
:icon="archiveEmptyState.icon"
:action-label="archiveEmptyState.actionLabel"
:action-icon="archiveEmptyState.actionIcon"
:tone="archiveEmptyState.tone"
:art-label="archiveEmptyState.artLabel"
:tips="archiveEmptyState.tips"
@action="handleEmptyAction"
/>
<table v-else>
<colgroup>
<col><col><col><col><col><col><col><col><col>
</colgroup>
<thead>
<tr>
<th>单号</th>
<th>申请人</th>
<th>申请部门</th>
<th>报销类型</th>
<th>金额</th>
<th>提交时间 <i class="mdi mdi-sort"></i></th>
<th>归档节点</th>
<th>风险</th>
<th>状态</th>
</tr>
</thead>
<tbody>
<tr v-for="row in visibleRows" :key="row.id" @click="selectedRow = row">
<td><strong class="doc-id">{{ row.id }}</strong></td>
<td>
<span class="person">
<span class="avatar">{{ row.avatar }}</span>
{{ row.applicant }}
</span>
</td>
<td>{{ row.department }}</td>
<td>{{ row.type }}</td>
<td>{{ row.amount }}</td>
<td>{{ row.time }}</td>
<td>{{ row.node }}</td>
<td><span class="risk-tag" :class="row.riskTone">{{ row.risk }}</span></td>
<td><span class="status-tag archived">{{ row.status }}</span></td>
</tr>
</tbody>
</table>
</div>
</article>
</section>
</template>
<script src="./scripts/ArchiveCenterView.js"></script>
<style scoped src="../assets/styles/views/approval-center-view.css"></style>
<style scoped src="../assets/styles/views/approval-center-view-part2.css"></style>
<style scoped src="../assets/styles/views/archive-center-view.css"></style>

View File

@@ -24,7 +24,7 @@
:class="{ active: activeFolder === folder.name }"
@click="activeFolder = folder.name"
>
<i :class="folder.icon"></i>
<i :class="resolveKnowledgeFolderIcon(folder, activeFolder)"></i>
<span>{{ folder.name }}</span>
<b>{{ folder.count }}</b>
</button>

View File

@@ -1,6 +1,6 @@
<template>
<Teleport to="body">
<Transition name="assistant-modal" @after-leave="emitCloseAfterLeave">
<Transition name="assistant-modal" @after-enter="handleAssistantModalAfterEnter" @after-leave="emitCloseAfterLeave">
<div v-if="workbenchVisible" class="assistant-overlay">
<section class="assistant-modal">
<div class="assistant-header-actions">
@@ -127,7 +127,7 @@
</div>
</div>
<div v-if="message.role === 'assistant' && !message.reviewPayload && message.meta?.length" class="message-meta-row">
<div v-if="message.role === 'assistant' && !message.reviewPayload && !message.queryPayload && message.meta?.length" class="message-meta-row">
<span
v-for="item in message.meta"
:key="item"
@@ -139,7 +139,7 @@
</div>
<div
v-if="message.role === 'assistant' && !message.reviewPayload && message.suggestedActions?.length"
v-if="message.role === 'assistant' && !message.reviewPayload && !message.queryPayload && message.suggestedActions?.length"
class="message-suggested-actions"
>
<button
@@ -173,7 +173,7 @@
</div>
<details
v-if="message.role === 'assistant' && !message.reviewPayload && message.citations?.length"
v-if="message.role === 'assistant' && !message.reviewPayload && !message.queryPayload && message.citations?.length"
class="message-detail-block message-citation-disclosure"
>
<summary>
@@ -197,7 +197,7 @@
class="message-detail-block expense-query-block"
>
<strong>
{{ message.queryPayload.title || (message.queryPayload.selectionMode === 'draft_association' ? '选择关联草稿' : (message.queryPayload.recentWindowApplied ? '近 10 日单据' : '单据明细')) }}
{{ message.queryPayload.title || (message.queryPayload.selectionMode === 'draft_association' ? '选择关联草稿' : '5 条筛选结果') }}
</strong>
<p v-if="buildExpenseQueryWindowLabel(message.queryPayload)" class="expense-query-window-label">
@@ -242,6 +242,20 @@
<span>{{ record.dateDisplay }}</span>
<span>{{ record.amountDisplay }}</span>
</div>
<div v-if="record.riskItems?.length" class="expense-query-risk-row">
<button
v-for="risk in record.riskItems"
:key="`${message.id}-${record.claimId}-${risk.key}`"
type="button"
class="expense-query-risk-chip"
:class="risk.level"
@click.stop="appendExpenseQueryRiskToConversation(record, risk)"
>
<span>{{ record.claimNo }}</span>
<strong>{{ risk.levelLabel }}</strong>
<em>{{ risk.title }}</em>
</button>
</div>
</div>
<i class="mdi mdi-chevron-right"></i>
</button>
@@ -289,15 +303,19 @@
<span>{{ message.queryPayload.emptyText || '当前没有可直接展开的近期待办单据。' }}</span>
</div>
<p v-if="buildExpenseQueryHint(message.queryPayload)" class="expense-query-hint">
{{ buildExpenseQueryHint(message.queryPayload) }}
<p
v-if="buildExpenseQueryHint(message.queryPayload)"
class="expense-query-hint message-answer-markdown"
v-html="renderMarkdown(buildExpenseQueryHint(message.queryPayload))"
@click="handleAssistantMarkdownClick($event, message)"
>
</p>
</div>
<div v-if="message.role === 'assistant' && message.reviewPayload" class="message-detail-block review-message-block">
<div class="review-plain-followup">
<template
v-for="followup in [buildReviewPlainFollowupCopy(message.reviewPayload)]"
v-for="followup in [buildReviewPlainFollowupForMessage(message)]"
:key="`${message.id}-review-followup`"
>
<h3
@@ -684,7 +702,7 @@
<div v-if="activeReviewPayload || isReviewFlowDrawer" class="review-insight-tools">
<button
v-if="activeReviewPayload"
v-if="activeReviewPayload && reviewOverviewDrawerAvailable"
type="button"
class="review-insight-switch-icon-btn"
:class="{
@@ -836,7 +854,7 @@
<template v-else-if="currentInsight.intent === 'agent' && currentInsight.agent">
<template v-if="activeReviewPayload">
<template v-if="!isReviewDocumentDrawer && !isReviewRiskDrawer && !isReviewFlowDrawer">
<template v-if="reviewOverviewDrawerAvailable && !isReviewDocumentDrawer && !isReviewRiskDrawer && !isReviewFlowDrawer">
<section class="review-side-card review-side-overview-card">
<div class="review-side-intent-row">
<i class="mdi mdi-account-outline"></i>
@@ -1221,7 +1239,7 @@
</button>
</template>
<section v-if="currentInsight.agent.citations?.length && !activeReviewPayload" class="insight-card">
<section v-if="currentInsight.agent.citations?.length && !currentInsight.agent.queryPayload && !activeReviewPayload" class="insight-card">
<div class="card-head">
<h4>制度依据</h4>
</div>
@@ -1284,30 +1302,6 @@
@confirm="confirmDeleteCurrentSession"
/>
<Transition name="assistant-modal">
<div v-if="uploadDecisionDialogOpen" class="assistant-overlay review-overlay">
<section class="review-confirm-modal review-upload-decision-modal">
<div class="review-upload-decision-copy">
<span class="assistant-badge">上传票据</span>
<h3>检测到你已有单据事件</h3>
<p>这次新上传的附件需要先确认处理方式你可以继续归集到上一笔单据也可以重新开启一张新单据</p>
</div>
<div class="review-confirm-actions review-upload-decision-actions">
<button type="button" class="primary-dialog-btn" :disabled="submitting || reviewActionBusy" @click="continueExistingUpload">
继续
</button>
<button type="button" class="secondary-dialog-btn" :disabled="submitting || reviewActionBusy" @click="createNewUploadDocument">
新单据
</button>
<button type="button" class="secondary-dialog-btn" :disabled="submitting || reviewActionBusy" @click="closeUploadDecisionDialog">
取消
</button>
</div>
</section>
</div>
</Transition>
<Transition name="assistant-modal">
<div v-if="documentPreviewDialog.open" class="assistant-overlay review-overlay">
<section class="review-preview-modal">

View File

@@ -97,20 +97,11 @@
</div>
<div v-if="canEditDetailNote" class="detail-note-editor">
<textarea
v-model="detailNoteEditor"
v-model="detailNoteEditorView"
maxlength="500"
placeholder="例如:去北京客户现场出差,拜访 XX 客户并处理项目验收事项"
aria-label="附加说明"
></textarea>
<div v-if="detailNoteTags.length" class="detail-note-tag-list" aria-label="附加说明风险标签">
<span
v-for="tag in detailNoteTags"
:key="tag"
:class="['risk-note-tag', resolveRiskTagClass(tag)]"
>
{{ tag }}
</span>
</div>
<div class="detail-note-editor-meta">
<span>仅草稿待提交状态可编辑提交后将作为明确说明展示</span>
<div class="detail-note-actions">
@@ -136,15 +127,6 @@
</div>
<div v-else class="detail-note readonly">
<p>{{ detailNote }}</p>
<div v-if="detailNoteTags.length" class="detail-note-tag-list" aria-label="附加说明风险标签">
<span
v-for="tag in detailNoteTags"
:key="tag"
:class="['risk-note-tag', resolveRiskTagClass(tag)]"
>
{{ tag }}
</span>
</div>
</div>
</article>
@@ -178,8 +160,8 @@
<table>
<thead>
<tr>
<th class="col-time">时间</th>
<th class="col-filled-at">填写时间</th>
<th class="col-time">发生时间</th>
<th class="col-type">费用项目</th>
<th class="col-desc">说明</th>
<th class="col-amount">金额</th>
@@ -190,6 +172,10 @@
<tbody>
<template v-for="item in expenseItems" :key="item.id">
<tr :class="{ 'system-generated-row': item.isSystemGenerated }">
<td class="expense-filled-at col-filled-at">
<strong>{{ item.filledAt }}</strong>
<span>条款填写时间</span>
</td>
<td :class="['expense-time col-time', { 'has-major-risk': isMajorExpenseRisk(item) }]">
<i
v-if="isMajorExpenseRisk(item)"
@@ -208,10 +194,6 @@
<span>{{ item.dayLabel }}</span>
</template>
</td>
<td class="expense-filled-at col-filled-at">
<strong>{{ item.filledAt }}</strong>
<span>条款填写时间</span>
</td>
<td class="expense-type col-type">
<template v-if="editingExpenseId === item.id">
<div class="cell-editor">
@@ -405,11 +387,11 @@
</div>
</article>
<article v-if="isEditableRequest" class="detail-card panel validation-card">
<article v-if="showAiAdvicePanel" class="detail-card panel validation-card">
<div class="validation-head">
<div>
<h3>AI建议</h3>
<p>按建议顺序补齐信息或处理风险后再发起审批</p>
<h3>{{ aiAdviceTitle }}</h3>
<p>{{ aiAdviceHint }}</p>
</div>
<span :class="['validation-pill', aiAdvice.tone]">{{ aiAdvice.badge }}</span>
</div>
@@ -434,15 +416,6 @@
<span>{{ card.label }}</span>
<strong>{{ card.title }}</strong>
</div>
<div v-if="card.tags?.length" class="risk-card-tag-list" aria-label="风险标签">
<span
v-for="tag in card.tags"
:key="tag"
:class="['risk-note-tag', resolveRiskTagClass(tag)]"
>
{{ tag }}
</span>
</div>
<p class="risk-advice-point">{{ card.risk }}</p>
<div class="risk-advice-meta">
<div>
@@ -733,15 +706,6 @@
<strong>{{ currentSubmitRiskWarning.title }}</strong>
</div>
<p>{{ currentSubmitRiskWarning.risk }}</p>
<div class="risk-card-tag-list" aria-label="风险标签">
<span
v-for="tag in currentSubmitRiskWarning.tags"
:key="tag"
:class="['risk-note-tag', resolveRiskTagClass(tag)]"
>
{{ tag }}
</span>
</div>
<textarea
v-model="riskOverrideReasons[currentSubmitRiskWarning.id]"
maxlength="160"

View File

@@ -6,6 +6,11 @@ import { useApprovalInbox } from '../../composables/useApprovalInbox.js'
import { useSystemState } from '../../composables/useSystemState.js'
import { fetchApprovalExpenseClaims } from '../../services/reimbursements.js'
import { listPendingApprovalRequests } from '../../utils/approvalInbox.js'
import {
filterActionableRiskFlags,
isRiskSummaryWithRisk,
normalizeRiskFlagTone
} from '../../utils/riskFlags.js'
import TravelRequestDetailView from '../TravelRequestDetailView.vue'
const DEFAULT_SLA_HOURS = 24
@@ -37,10 +42,9 @@ function formatCurrency(value) {
}
function resolveRiskTone(riskFlags, riskSummary) {
if (Array.isArray(riskFlags)) {
const severities = riskFlags
.map((item) => String(item?.severity || '').trim().toLowerCase())
.filter(Boolean)
const actionableFlags = filterActionableRiskFlags(riskFlags)
if (actionableFlags.length) {
const severities = actionableFlags.map((item) => normalizeRiskFlagTone(item)).filter(Boolean)
if (severities.includes('high')) {
return 'high'
@@ -53,7 +57,7 @@ function resolveRiskTone(riskFlags, riskSummary) {
}
}
if (String(riskSummary || '').trim() && String(riskSummary || '').trim() !== '无') {
if (isRiskSummaryWithRisk(riskSummary)) {
return 'medium'
}

View File

@@ -0,0 +1,313 @@
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
import TableEmptyState from '../../components/shared/TableEmptyState.vue'
import { mapExpenseClaimToRequest } from '../../composables/useRequests.js'
import { fetchArchivedExpenseClaims } from '../../services/reimbursements.js'
import {
ARCHIVE_FILTER_ALL,
applyArchiveListFilters,
buildArchiveMonthFilterOptions,
buildDepartmentFilterOptions,
buildTypeFilterOptions,
countClaimRisks,
extractArchiveMonth,
formatArchiveMonthLabel,
formatArchiveRiskCountLabel,
hasActiveArchiveListFilters,
resolveArchiveRiskTone
} from '../../utils/archiveCenterListFilters.js'
import { normalizeRequestForUi } from '../../utils/requestViewModel.js'
import TravelRequestDetailView from '../TravelRequestDetailView.vue'
const tabs = ['全部归档', '差旅报销', '招待报销', '其他费用']
const RISK_FILTER_OPTIONS = [
{ value: ARCHIVE_FILTER_ALL, label: '全部风险' },
{ value: 'has', label: '有风险' },
{ value: 'none', label: '无风险' },
{ value: 'high', label: '高风险' },
{ value: 'medium', label: '中风险' },
{ value: 'low', label: '低风险' }
]
function formatCurrency(value) {
const amount = Number(value)
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: 'CNY',
minimumFractionDigits: 0,
maximumFractionDigits: Number.isInteger(amount) ? 0 : 2
}).format(Number.isFinite(amount) ? amount : 0)
}
function resolveArchiveTypeTab(request) {
const expenseType = String(request?.typeCode || request?.expenseType || '').trim().toLowerCase()
if (expenseType === 'travel') {
return '差旅报销'
}
if (expenseType === 'entertainment') {
return '招待报销'
}
return '其他费用'
}
function buildArchiveRow(request) {
const normalized = normalizeRequestForUi(request)
const riskCount = countClaimRisks(normalized.riskFlags, normalized.riskSummary)
const riskTone = riskCount > 0 ? resolveArchiveRiskTone(normalized.riskFlags, normalized.riskSummary) : 'none'
const hasRisk = riskCount > 0
const archiveMonth = extractArchiveMonth(
normalized.updatedAt,
normalized.submittedAt,
normalized.createdAt,
normalized.occurredAt,
normalized.applyTime
)
return {
...normalized,
applicant: normalized.person,
avatar: String(normalized.person || '?').trim().slice(0, 1) || '?',
department: normalized.dept,
type: normalized.typeLabel,
amount: formatCurrency(normalized.amountValue),
time: normalized.applyTime,
archivedAt: normalized.updatedAt || normalized.applyTime,
archiveMonth,
archiveMonthLabel: formatArchiveMonthLabel(archiveMonth),
node: normalized.workflowNode || '归档入账',
hasRisk,
riskCount,
risk: formatArchiveRiskCountLabel(riskCount),
riskTone,
status: '已归档',
statusTone: 'archived',
archiveTab: resolveArchiveTypeTab(normalized)
}
}
function resolveFilterLabel(options, activeValue, fallbackLabel) {
return options.find((item) => item.value === activeValue)?.label || fallbackLabel
}
export default {
name: 'ArchiveCenterView',
components: {
TravelRequestDetailView,
TableLoadingState,
TableEmptyState
},
setup() {
const activeTab = ref('全部归档')
const activeRiskFilter = ref(ARCHIVE_FILTER_ALL)
const activeTypeFilter = ref(ARCHIVE_FILTER_ALL)
const activeDepartmentFilter = ref(ARCHIVE_FILTER_ALL)
const activeArchiveMonthFilter = ref(ARCHIVE_FILTER_ALL)
const openFilterKey = ref('')
const selectedClaimId = ref('')
const listKeyword = ref('')
const rows = ref([])
const loading = ref(false)
const error = ref('')
const typeFilterOptions = computed(() => buildTypeFilterOptions(rows.value))
const departmentFilterOptions = computed(() => buildDepartmentFilterOptions(rows.value))
const archiveMonthFilterOptions = computed(() => buildArchiveMonthFilterOptions(rows.value))
const riskFilterLabel = computed(() => resolveFilterLabel(RISK_FILTER_OPTIONS, activeRiskFilter.value, '全部风险'))
const typeFilterLabel = computed(() => resolveFilterLabel(typeFilterOptions.value, activeTypeFilter.value, '费用类型'))
const departmentFilterLabel = computed(() => resolveFilterLabel(departmentFilterOptions.value, activeDepartmentFilter.value, '所属部门'))
const archiveMonthFilterLabel = computed(() => resolveFilterLabel(archiveMonthFilterOptions.value, activeArchiveMonthFilter.value, '归档月份'))
const filterDropdowns = computed(() => [
{
key: 'risk',
label: riskFilterLabel.value,
options: RISK_FILTER_OPTIONS,
activeValue: activeRiskFilter.value
},
{
key: 'type',
label: typeFilterLabel.value,
options: typeFilterOptions.value,
activeValue: activeTypeFilter.value
},
{
key: 'department',
label: departmentFilterLabel.value,
options: departmentFilterOptions.value,
activeValue: activeDepartmentFilter.value
},
{
key: 'archiveMonth',
label: archiveMonthFilterLabel.value,
options: archiveMonthFilterOptions.value,
activeValue: activeArchiveMonthFilter.value
}
])
const selectedRow = computed({
get() {
return rows.value.find((row) => row.claimId === selectedClaimId.value) || null
},
set(value) {
selectedClaimId.value = value?.claimId || ''
}
})
const visibleRows = computed(() => applyArchiveListFilters(rows.value, {
tab: activeTab.value,
risk: activeRiskFilter.value,
type: activeTypeFilter.value,
department: activeDepartmentFilter.value,
archiveMonth: activeArchiveMonthFilter.value,
keyword: listKeyword.value
}))
const showEmpty = computed(() => !loading.value && !error.value && visibleRows.value.length === 0)
const archiveEmptyState = computed(() => {
if (!rows.value.length) {
return {
eyebrow: '归档中心',
title: '当前还没有已归档单据',
desc: '财务终审通过并进入「归档入账」节点的报销单会自动汇总到这里,形成公司级财务归档库。',
icon: 'mdi mdi-archive-check-outline',
actionLabel: null,
actionIcon: null,
tone: 'slate',
artLabel: 'ARCHIVE',
tips: ['仅展示已归档入账的单据', '申请人仍可在报销中心查看自己的归档记录']
}
}
const filtersActive = hasActiveArchiveListFilters({
tab: activeTab.value,
risk: activeRiskFilter.value,
type: activeTypeFilter.value,
department: activeDepartmentFilter.value,
archiveMonth: activeArchiveMonthFilter.value,
keyword: listKeyword.value
})
return {
eyebrow: filtersActive ? '筛选结果为空' : '归档中心',
title: filtersActive ? '没有符合当前筛选条件的归档单据' : `${activeTab.value}”里暂时没有归档单据`,
desc: filtersActive
? '可以调整风险、费用类型、部门或归档月份筛选,也可以修改搜索关键词后重试。'
: '可以切换到其他分类查看,或调整筛选条件后重新检索。',
icon: 'mdi mdi-archive-outline',
actionLabel: null,
actionIcon: null,
tone: 'sky',
artLabel: filtersActive ? 'FILTER' : 'ARCHIVE',
tips: ['归档中心保存全公司归档数据', '非申请人无法在报销中心查看他人归档单']
}
})
function resetListFilters() {
activeTab.value = '全部归档'
activeRiskFilter.value = ARCHIVE_FILTER_ALL
activeTypeFilter.value = ARCHIVE_FILTER_ALL
activeDepartmentFilter.value = ARCHIVE_FILTER_ALL
activeArchiveMonthFilter.value = ARCHIVE_FILTER_ALL
listKeyword.value = ''
openFilterKey.value = ''
}
function handleEmptyAction() {
if (!rows.value.length) {
void reload()
return
}
resetListFilters()
}
function toggleFilterDropdown(key) {
openFilterKey.value = openFilterKey.value === key ? '' : key
}
function selectFilterValue(key, value) {
if (key === 'risk') {
activeRiskFilter.value = value
} else if (key === 'type') {
activeTypeFilter.value = value
} else if (key === 'department') {
activeDepartmentFilter.value = value
} else if (key === 'archiveMonth') {
activeArchiveMonthFilter.value = value
}
openFilterKey.value = ''
}
function handleDocumentClick(event) {
const target = event.target
if (!(target instanceof Element)) {
return
}
if (!target.closest('.archive-dropdown-filter')) {
openFilterKey.value = ''
}
}
function closeSelectedDetail() {
selectedClaimId.value = ''
}
async function reload() {
loading.value = true
error.value = ''
try {
const payload = await fetchArchivedExpenseClaims()
const mappedRows = (Array.isArray(payload) ? payload : [])
.map((item) => mapExpenseClaimToRequest(item))
.filter(Boolean)
.map((item) => buildArchiveRow(item))
rows.value = mappedRows
if (!mappedRows.some((item) => item.claimId === selectedClaimId.value)) {
selectedClaimId.value = ''
}
} catch (nextError) {
rows.value = []
selectedClaimId.value = ''
error.value = nextError instanceof Error ? nextError.message : '归档中心加载失败。'
} finally {
loading.value = false
}
}
onMounted(() => {
document.addEventListener('click', handleDocumentClick)
})
onBeforeUnmount(() => {
document.removeEventListener('click', handleDocumentClick)
})
void reload()
return {
activeTab,
archiveEmptyState,
closeSelectedDetail,
error,
filterDropdowns,
handleEmptyAction,
listKeyword,
loading,
openFilterKey,
reload,
resetListFilters,
rows,
selectFilterValue,
selectedRow,
showEmpty,
tabs,
toggleFilterDropdown,
visibleRows
}
}
}

View File

@@ -27,7 +27,10 @@ import {
shouldRenderOnlyOfficePreview
} from './knowledgePreviewMode.js'
import { resolveKnowledgePreviewLayoutState } from './knowledgePreviewLayout.js'
import { resolveInitialKnowledgeFolder } from './knowledgeFolderSelection.js'
import {
resolveInitialKnowledgeFolder,
resolveKnowledgeFolderIcon
} from './knowledgeFolderSelection.js'
import { buildOnlyOfficePreviewConfig } from './onlyOfficePreviewConfig.js'
const KNOWLEDGE_POLL_INTERVAL_MS = 5000
@@ -663,11 +666,12 @@ export default {
previewLoading,
shouldRenderOnlyOffice,
shouldRenderOnlyOfficeHostNode,
selectDocument,
selectPreviewPage,
selectedDocument,
syncingFolder,
totalCount,
selectDocument,
selectPreviewPage,
selectedDocument,
resolveKnowledgeFolderIcon,
syncingFolder,
totalCount,
totalPages,
triggerUpload,
uploadHint,

View File

@@ -181,12 +181,26 @@ const REVIEW_DRAWER_MODE_REVIEW = 'review'
const REVIEW_DRAWER_MODE_DOCUMENTS = 'documents'
const REVIEW_DRAWER_MODE_RISK = 'risk'
const REVIEW_DRAWER_MODE_FLOW = 'flow'
const REVIEW_PANEL_SCOPE_OVERVIEW = 'overview'
const REVIEW_PANEL_SCOPE_DOCUMENTS = 'documents'
const REVIEW_PANEL_SCOPE_RISK = 'risk'
const FLOW_STEP_STATUS_PENDING = 'pending'
const FLOW_STEP_STATUS_RUNNING = 'running'
const FLOW_STEP_STATUS_COMPLETED = 'completed'
const FLOW_STEP_STATUS_FAILED = 'failed'
const DEPRECATED_REVIEW_RISK_TITLE_KEYWORDS = ['历史报销画像', '用户画像', '制度注意事项', '制度注意']
function normalizeReviewPanelScope(scope) {
const normalized = String(scope || '').trim()
return [REVIEW_PANEL_SCOPE_OVERVIEW, REVIEW_PANEL_SCOPE_DOCUMENTS, REVIEW_PANEL_SCOPE_RISK].includes(normalized)
? normalized
: ''
}
function canExposeReviewPanelScope(scope) {
return Boolean(normalizeReviewPanelScope(scope))
}
function buildBusinessTimeContextFromReviewValues(values = {}) {
return buildBusinessTimeContextFromReviewValuesModel(values)
}
@@ -413,11 +427,13 @@ function buildReviewRiskItems(reviewPayload) {
.filter(Boolean)
}
function buildReviewRiskConversationText(item) {
function buildReviewRiskConversationText(item, detailTarget = {}) {
const title = String(item?.title || '风险提示').trim()
const summary = String(item?.summary || '').trim()
const detail = String(item?.detail || '').trim()
const suggestion = String(item?.suggestion || '').trim()
const detailHref = String(detailTarget?.href || '').trim()
const detailLabel = String(detailTarget?.label || '').trim() || '进入该单据详情重新填写'
const lines = [`${title}`]
if (summary) {
@@ -429,6 +445,9 @@ function buildReviewRiskConversationText(item) {
if (suggestion) {
lines.push('', `修改建议:${suggestion}`)
}
if (detailHref) {
lines.push('', `[${detailLabel}](${detailHref})`)
}
return lines.join('\n')
}
@@ -470,6 +489,10 @@ export default {
requestContext: {
type: Object,
default: null
},
invalidatedDraftClaimId: {
type: String,
default: ''
}
},
emits: ['close', 'draft-saved'],
@@ -484,10 +507,10 @@ export default {
const composerDraft = ref('')
const submitting = ref(false)
const workbenchVisible = ref(false)
const closeAfterBusy = ref(false)
const linkedRequest = computed(() => sanitizeRequest(props.requestContext))
const hotKnowledgeQuestions = HOT_KNOWLEDGE_QUESTIONS
let sessionRuntimeRefs = {}
const uploadDecisionDialogOpen = ref(false)
const {
activeSessionType,
messages,
@@ -511,7 +534,6 @@ export default {
linkedRequest,
toast,
composerDraft,
uploadDecisionDialogOpen,
adjustComposerTextareaHeight,
scrollToBottom,
getSessionRuntimeRefs: () => sessionRuntimeRefs
@@ -568,8 +590,21 @@ export default {
FLOW_STEP_STATUS_COMPLETED,
FLOW_STEP_STATUS_FAILED
})
const hasScopedReviewPayload = computed(() => {
const agent = currentInsight.value.agent || null
if (agent?.reviewPayload && canExposeReviewPanelScope(agent.reviewPanelScope)) {
return true
}
if (currentInsight.value.intent === 'agent' && agent) {
return false
}
return messages.value.some((item) =>
item.role === 'assistant' && item.reviewPayload && canExposeReviewPanelScope(item.reviewPanelScope)
)
})
const hasQueryInsight = computed(() => Boolean(currentInsight.value.agent?.queryPayload))
const hasInsightPanelContent = computed(
() => isKnowledgeSession.value || currentInsight.value.intent !== 'welcome' || flowSteps.value.length > 0
() => isKnowledgeSession.value || hasScopedReviewPayload.value || hasQueryInsight.value || flowSteps.value.length > 0
)
const showInsightPanel = computed(() => hasInsightPanelContent.value && !insightPanelCollapsed.value)
const insightPanelToggleLabel = computed(() =>
@@ -604,11 +639,31 @@ export default {
() => Boolean(conversationId.value) || messages.value.some((item) => item.role === 'user')
)
const latestReviewMessage = computed(() =>
[...messages.value].reverse().find((item) => item.role === 'assistant' && item.reviewPayload) ?? null
)
const activeReviewPayload = computed(
() => currentInsight.value.agent?.reviewPayload || latestReviewMessage.value?.reviewPayload || null
[...messages.value].reverse().find((item) =>
item.role === 'assistant' && item.reviewPayload && canExposeReviewPanelScope(item.reviewPanelScope)
) ?? null
)
const activeReviewPanelScope = computed(() => {
const agent = currentInsight.value.agent || null
const agentScope = normalizeReviewPanelScope(agent?.reviewPanelScope)
if (agent?.reviewPayload && agentScope) {
return agentScope
}
if (currentInsight.value.intent === 'agent' && agent) {
return ''
}
return normalizeReviewPanelScope(latestReviewMessage.value?.reviewPanelScope)
})
const activeReviewPayload = computed(() => {
const agent = currentInsight.value.agent || null
if (agent?.reviewPayload && normalizeReviewPanelScope(agent.reviewPanelScope)) {
return agent.reviewPayload
}
if (currentInsight.value.intent === 'agent' && agent) {
return null
}
return latestReviewMessage.value?.reviewPayload || null
})
const reviewRiskBriefResolver = (payload) => resolveReviewRiskBriefs(payload)
const buildReviewRiskSummary = (payload) => buildReviewRiskSummaryModel(payload, reviewRiskBriefResolver)
const {
@@ -634,6 +689,7 @@ export default {
reviewRiskSummary,
reviewRiskItems,
reviewRiskEmpty,
reviewOverviewDrawerAvailable,
reviewDocumentDrawerAvailable,
reviewRiskDrawerAvailable,
reviewFlowDrawerAvailable,
@@ -671,6 +727,7 @@ export default {
closeDocumentPreview
} = useTravelReimbursementReviewDrawer({
activeReviewPayload,
activeReviewPanelScope,
reviewFilePreviews,
flowSteps,
submitting,
@@ -709,6 +766,7 @@ export default {
mergeBusinessTimeIntoExtraContext,
syncComposerBusinessTimeToReviewCard,
resolveComposerSubmitText,
resolveComposerDisplaySubmitText,
toggleComposerDatePicker,
closeComposerDatePicker,
setComposerDateMode,
@@ -853,6 +911,7 @@ export default {
refreshFlowRunDetail,
rememberFilePreviews,
replaceMessage,
resolveComposerDisplaySubmitText,
resetFlowRun,
resolveComposerSubmitText,
reviewInlineForm,
@@ -868,7 +927,6 @@ export default {
startSemanticFlowPreview,
submitting,
syncComposerFilesToDraft,
uploadDecisionDialogOpen,
toast
})
const canSubmit = computed(
@@ -906,8 +964,8 @@ export default {
}
])
watch(
() => activeReviewPayload.value,
(payload) => {
() => [activeReviewPayload.value, activeReviewPanelScope.value],
([payload]) => {
rememberFilePreviews(buildReviewFilePreviewsFromReviewPayload(payload))
// reviewDrawerMode.value = resolveReviewRiskBriefs(payload).length
// ? REVIEW_DRAWER_MODE_RISK
@@ -989,11 +1047,51 @@ export default {
{ immediate: true }
)
watch(
() => props.invalidatedDraftClaimId,
(claimId) => {
clearExpenseSessionForDeletedClaim(claimId)
},
{ immediate: true }
)
watch(
() => workbenchVisible.value,
(visible) => {
if (visible) {
scrollToBottom()
} else {
maybeFinalizeDeferredClose()
}
}
)
watch(
() => [submitting.value, reviewActionBusy.value, sessionSwitchBusy.value, workbenchVisible.value],
() => {
maybeFinalizeDeferredClose()
}
)
watch(
() => messages.value.length,
() => {
if (!workbenchVisible.value) {
return
}
scrollToBottom()
}
)
onMounted(() => {
document.addEventListener('click', handleComposerDatePickerOutside)
startFlowTick()
nextTick(() => {
workbenchVisible.value = true
nextTick(() => {
adjustComposerTextareaHeight()
scrollToBottom()
})
})
void clearKnowledgeSessionOnEntry()
currentInsight.value =
@@ -1008,11 +1106,6 @@ export default {
toast(`一次最多上传 ${MAX_ATTACHMENTS} 份附件,已保留前 ${MAX_ATTACHMENTS} 份。`)
}
submitComposer()
} else {
nextTick(() => {
adjustComposerTextareaHeight()
scrollToBottom()
})
}
})
@@ -1023,8 +1116,31 @@ export default {
})
function scrollToBottom() {
if (!messageListRef.value) return
messageListRef.value.scrollTop = messageListRef.value.scrollHeight
const scrollOnce = () => {
const list = messageListRef.value
if (!list) {
return false
}
list.scrollTop = list.scrollHeight
return true
}
nextTick(() => {
if (scrollOnce()) {
return
}
requestAnimationFrame(() => {
scrollOnce()
requestAnimationFrame(scrollOnce)
})
})
}
function handleAssistantModalAfterEnter() {
scrollToBottom()
requestAnimationFrame(() => {
scrollToBottom()
})
}
function resetCurrentSessionState() {
@@ -1034,6 +1150,31 @@ export default {
resetFlowRun({ startedAt: 0, openDrawer: false })
}
function clearExpenseSessionForDeletedClaim(claimId) {
const normalizedClaimId = String(claimId || '').trim()
if (!normalizedClaimId) {
return
}
const expenseSnapshot = sessionSnapshots.value[SESSION_TYPE_EXPENSE]
const snapshotMatchesDeletedClaim = String(expenseSnapshot?.draftClaimId || '').trim() === normalizedClaimId
const currentMatchesDeletedClaim =
activeSessionType.value === SESSION_TYPE_EXPENSE
&& String(resolveActiveClaimId() || '').trim() === normalizedClaimId
if (!snapshotMatchesDeletedClaim && !currentMatchesDeletedClaim) {
return
}
clearAssistantSessionSnapshot(resolveCurrentUserId(), SESSION_TYPE_EXPENSE)
if (currentMatchesDeletedClaim) {
resetCurrentSessionState()
toast('该草稿单据已删除,相关财务助手会话已清空。')
return
}
sessionSnapshots.value[SESSION_TYPE_EXPENSE] = buildEmptySessionState(SESSION_TYPE_EXPENSE)
}
function adjustComposerTextareaHeight() {
if (!composerTextareaRef.value) return
@@ -1071,31 +1212,6 @@ export default {
messages.value.splice(index, 1, nextMessage)
}
function closeUploadDecisionDialog() {
if (submitting.value || reviewActionBusy.value) return
uploadDecisionDialogOpen.value = false
}
async function continueExistingUpload() {
if (submitting.value || reviewActionBusy.value) return
uploadDecisionDialogOpen.value = false
composerUploadIntent.value = 'continue_existing'
await submitComposer({
uploadDisposition: 'continue_existing',
skipUploadDecisionPrompt: true
})
}
async function createNewUploadDocument() {
if (submitting.value || reviewActionBusy.value) return
uploadDecisionDialogOpen.value = false
composerUploadIntent.value = ''
await submitComposer({
uploadDisposition: 'new_document',
skipUploadDecisionPrompt: true
})
}
async function runShortcut(shortcut) {
if (shortcut?.action === 'switch_view' && shortcut?.targetSessionType) {
await switchSessionType(shortcut.targetSessionType)
@@ -1218,6 +1334,9 @@ export default {
}
function switchToReviewOverviewDrawer() {
if (!reviewOverviewDrawerAvailable.value) {
return
}
switchReviewDrawerMode(REVIEW_DRAWER_MODE_REVIEW)
}
@@ -1255,20 +1374,98 @@ export default {
function appendReviewRiskBriefToConversation(item) {
if (!item) return
messages.value.push(createMessage('assistant', buildReviewRiskConversationText(item), [], {
messages.value.push(createMessage('assistant', buildReviewRiskConversationText(item, resolveReviewRiskDetailTarget()), [], {
meta: [item.sourceLabel || item.levelLabel || '风险提示'],
metaTone: item.level || 'low'
}))
nextTick(scrollToBottom)
}
function appendExpenseQueryRiskToConversation(record, risk) {
if (!record || !risk) return
const claimId = String(record.claimId || '').trim()
const claimNo = String(record.claimNo || '该单据').trim()
const route = claimId
? router.resolve({
name: 'app-request-detail',
params: { requestId: claimId }
})
: null
messages.value.push(createMessage(
'assistant',
buildReviewRiskConversationText(
{
title: `${claimNo} ${risk.levelLabel || '风险提示'}${risk.title || '风险提示'}`,
summary: risk.summary,
detail: risk.detail,
suggestion: '请进入单据详情核对费用明细、票据附件和附加说明;如属于合理例外,请补充业务说明后再继续流程。',
sourceLabel: risk.levelLabel,
level: risk.level
},
route?.href
? {
href: route.href,
label: `进入 ${claimNo} 详情重新填写`
}
: {}
),
[],
{
meta: [`${claimNo} 风险详情`],
metaTone: risk.level || 'medium'
}
))
nextTick(scrollToBottom)
}
function resolveReviewRiskDetailTarget() {
const latestDraftMessage = [...messages.value].reverse().find((item) => item?.draftPayload)
const candidates = [
currentInsight.value.agent?.draftPayload,
latestReviewMessage.value?.draftPayload,
latestDraftMessage?.draftPayload,
linkedRequest.value
].filter(Boolean)
const claimTarget = candidates.find((item) => String(item?.claim_id || item?.claimId || item?.id || '').trim())
const claimId = String(claimTarget?.claim_id || claimTarget?.claimId || claimTarget?.id || draftClaimId.value || resolveActiveClaimId() || '').trim()
if (!claimId) {
return {}
}
const claimNoTarget = candidates.find((item) => String(item?.claim_no || item?.claimNo || item?.documentNo || '').trim())
const claimNo = String(claimNoTarget?.claim_no || claimNoTarget?.claimNo || claimNoTarget?.documentNo || '').trim()
const route = router.resolve({
name: 'app-request-detail',
params: { requestId: claimId }
})
return {
href: route.href,
label: claimNo ? `进入 ${claimNo} 详情重新填写` : '进入该单据详情重新填写'
}
}
function isWorkbenchBusy() {
return submitting.value || reviewActionBusy.value || sessionSwitchBusy.value
}
function maybeFinalizeDeferredClose() {
if (!closeAfterBusy.value || workbenchVisible.value || isWorkbenchBusy()) {
return
}
closeAfterBusy.value = false
emit('close')
}
function requestCloseWorkbench() {
persistSessionState()
closeAfterBusy.value = isWorkbenchBusy()
workbenchVisible.value = false
}
function emitCloseAfterLeave() {
if (closeAfterBusy.value && isWorkbenchBusy()) {
return
}
closeAfterBusy.value = false
emit('close')
}
@@ -1317,7 +1514,6 @@ export default {
pendingText: `已选择草稿 ${record.claimNo},正在识别并归集附件...`,
files,
uploadDisposition: 'continue_existing',
skipUploadDecisionPrompt: true,
extraContext: {
draft_claim_id: claimId,
selected_claim_id: claimId,
@@ -1469,6 +1665,12 @@ export default {
}
const href = String(anchor.getAttribute('href') || '').trim()
if (href.startsWith('/app/')) {
event.preventDefault()
router.push(href)
return
}
if (href !== ATTACHMENT_ASSOCIATION_CONFIRM_HREF) {
return
}
@@ -1492,8 +1694,25 @@ export default {
return handleSaveDraftDirectlyInternal(message, actionType)
}
function isDraftSavedReviewMessage(message) {
if (!message?.reviewPayload) {
return false
}
return Boolean(
String(message?.draftPayload?.claim_no || message?.draftPayload?.claim_id || '').trim()
|| String(draftClaimId.value || '').trim()
|| String(resolveActiveClaimId() || '').trim()
)
}
function buildReviewPlainFollowupForMessage(message) {
return buildReviewPlainFollowupCopy(message?.reviewPayload, {
savedDraft: isDraftSavedReviewMessage(message)
})
}
function canUseInlineSaveDraft(message) {
if (!message?.reviewPayload || message?.draftPayload?.claim_no) {
if (!message?.reviewPayload || isDraftSavedReviewMessage(message)) {
return false
}
return Boolean(resolveReviewSaveDraftAction(message.reviewPayload))
@@ -1515,16 +1734,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, activeReviewFilePreviews, reviewDrawerMode, isReviewOverviewDrawer, isReviewDocumentDrawer, isReviewRiskDrawer, isReviewFlowDrawer,
reviewDrawerTitle, reviewDocumentDrawerAvailable, reviewRiskDrawerAvailable, reviewFlowDrawerAvailable, reviewDocumentDrawerLabel, reviewDocumentDrawerIcon, reviewRiskDrawerLabel, reviewRiskDrawerIcon, reviewFlowDrawerLabel, reviewFlowDrawerIcon, activeReviewDocument, activeReviewDocumentIndex, activeReviewDocumentPreview, canPreviewActiveReviewDocument,
hasInsightPanelContent, showInsightPanel, insightPanelToggleLabel, 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, uploadDecisionDialogOpen,
workbenchVisible, reviewPanelConfidence, reviewRiskSummary, reviewRiskItems, reviewRiskEmpty, recognizedNarratives, reviewRecognitionNotes, reviewDocumentSummaries, reviewDocumentCount, reviewDocumentDirty, reviewHasUnsavedChanges,
travelCalculatorOpen, travelCalculatorBusy, travelCalculatorError, travelCalculatorResult, travelCalculatorForm, travelCalculatorCanSubmit, deleteSessionDialogOpen, reviewActionBusy, deleteSessionBusy, documentPreviewDialog, shortcuts,
resolveReviewMissingSlotCards, resolveReviewRiskBriefs, buildReviewHeadline, buildReviewSubline, buildReviewStateLabel, buildReviewStateTone, buildReviewPlainFollowupCopy, resolveReviewFooterActions, resolveReviewSaveDraftAction, buildReviewPrimaryButtonLabel, buildReviewMainMessageText,
resolveReviewMissingSlotCards, resolveReviewRiskBriefs, buildReviewHeadline, buildReviewSubline, buildReviewStateLabel, buildReviewStateTone, buildReviewPlainFollowupCopy, buildReviewPlainFollowupForMessage, 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, openExpenseQueryRecord, handleExpenseQueryRecordClick, setExpenseQueryPage, shiftExpenseQueryPage, openDeleteSessionDialog, closeDeleteSessionDialog, confirmDeleteCurrentSession, closeUploadDecisionDialog, continueExistingUpload, createNewUploadDocument, openInlineReviewEditor, closeInlineReviewEditor, commitInlineReviewEditor, clearInlineReviewFieldError, selectInlineScene, selectReviewCategory, selectReviewOtherCategory,
queryDraftByClaimNo, appendReviewRiskBriefToConversation, goReviewDocument, openActiveReviewDocumentPreview, closeDocumentPreview, saveInlineReviewChanges, submitComposer, handleAssistantMarkdownClick, handleReviewAction, handleSaveDraftDirectly, canUseInlineSaveDraft, handleInlineSaveDraft
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, isDraftSavedReviewMessage, canUseInlineSaveDraft, handleInlineSaveDraft
}
}
}

View File

@@ -29,10 +29,11 @@ import {
buildAiAdviceViewModel,
buildAttachmentInsightViewModel,
buildAttachmentRiskCards,
buildClaimSummaryRiskCards,
buildItemClaimRiskState,
extractRiskTagsFromText,
normalizeRiskTone,
resolveRiskTags,
resolveRiskTagTone
resolveRiskTags
} from './travelRequestDetailInsights.js'
import {
EXPENSE_TYPE_OPTIONS,
@@ -95,6 +96,26 @@ function normalizeDetailNoteDraftValue(value) {
return isPlaceholderValue(text) ? '' : text
}
function stripRiskTagsForDisplay(value) {
return String(value || '')
.split('\n')
.map((line) =>
line
.replace(/(?:^|\s)#[A-Za-z_]+(?=\s|$)/g, ' ')
.replace(/[ \t]{2,}/g, ' ')
.replace(/\s+第/g, ':第')
.trim()
)
.join('\n')
.trim()
}
function mergeVisibleNoteWithHiddenTags(visibleText, rawText) {
const cleanText = normalizeDetailNoteDraftValue(visibleText)
const tags = extractRiskTagsFromText(rawText).join(' ')
return [cleanText, tags].map((item) => String(item || '').trim()).filter(Boolean).join('\n')
}
function buildTravelTimeLabelMap(items, requestModel) {
const travelItems = items
.map((item, index) => {
@@ -612,13 +633,36 @@ export default {
() => 6 + (isEditableRequest.value ? 1 : 0)
)
const canEditDetailNote = computed(() => isDraftRequest.value)
const stripDetailNoteRiskTags = (value) =>
String(value || '')
.split('\n')
.map((line) =>
line
.replace(/(?:^|\s)#[A-Za-z_]+(?=\s|$)/g, ' ')
.replace(/[ \t]{2,}/g, ' ')
.replace(/:\s+第/g, ':第')
.trim()
)
.join('\n')
.trim()
const mergeDetailNoteVisibleTextWithTags = (visibleText, rawText) => {
const cleanText = normalizeDetailNoteDraftValue(visibleText)
const tags = extractRiskTagsFromText(rawText).join(' ')
return [cleanText, tags].map((item) => String(item || '').trim()).filter(Boolean).join('\n')
}
const detailNoteSource = computed(() => normalizeDetailNoteDraftValue(request.value.note))
const detailNote = computed(() => {
if (detailNoteSource.value) {
return detailNoteSource.value
return stripDetailNoteRiskTags(detailNoteSource.value)
}
return '暂无附加说明。请补充本次出差或办事事由,例如“去北京客户现场出差,拜访 XX 客户并处理项目验收事项”。'
})
const detailNoteEditorView = computed({
get: () => stripDetailNoteRiskTags(detailNoteEditor.value),
set: (value) => {
detailNoteEditor.value = mergeDetailNoteVisibleTextWithTags(value, detailNoteEditor.value)
}
})
const detailNoteDirty = computed(() => detailNoteEditor.value.trim() !== detailNoteSource.value)
const detailNoteTags = computed(() =>
extractRiskTagsFromText(canEditDetailNote.value ? detailNoteEditor.value : detailNoteSource.value)
@@ -689,6 +733,11 @@ export default {
return expenseAttachmentMeta[item.id] || null
}
function resolveClaimRiskFlags() {
const flags = request.value?.riskFlags || request.value?.risk_flags_json || []
return Array.isArray(flags) ? flags : []
}
function resolveAttachmentDisplayName(item) {
const metadata = resolveAttachmentMeta(item)
return String(metadata?.file_name || item.attachmentHint || '').trim()
@@ -790,10 +839,6 @@ export default {
}
function resolveExpenseRiskState(item) {
if (!item.invoiceId) {
return null
}
if (uploadingExpenseId.value === item.id) {
return {
label: 'AI识别中',
@@ -818,6 +863,15 @@ export default {
}
}
const claimRiskState = buildItemClaimRiskState(item, resolveClaimRiskFlags())
if (claimRiskState) {
return claimRiskState
}
if (!item.invoiceId) {
return null
}
return {
label: '已上传',
tone: 'low',
@@ -843,13 +897,20 @@ export default {
}
const aiAdvice = computed(() => {
const completionItems = draftBlockingIssues.value.map(mapIssueToAdvice).filter(Boolean)
const completionItems = isEditableRequest.value
? draftBlockingIssues.value.map(mapIssueToAdvice).filter(Boolean)
: []
const directRiskCards = buildAttachmentRiskCards({
expenseItems: expenseItems.value,
attachmentMetaByItemId: expenseAttachmentMeta,
claimRiskFlags: resolveClaimRiskFlags()
})
const hasActionableRiskCards = directRiskCards.some(
(card) => ['medium', 'high'].includes(normalizeRiskTone(card?.tone))
)
const riskCards = [
...buildAttachmentRiskCards({
expenseItems: expenseItems.value,
attachmentMetaByItemId: expenseAttachmentMeta,
claimRiskFlags: request.value.riskFlags || request.value.risk_flags_json || []
}),
...(hasActionableRiskCards ? [] : buildClaimSummaryRiskCards(request.value)),
...directRiskCards,
...buildOptionalTravelReceiptRiskCards(request.value, expenseItems.value)
]
@@ -859,6 +920,14 @@ export default {
})
})
const showAiAdvicePanel = computed(() => isEditableRequest.value || aiAdvice.value.riskCards.length > 0)
const aiAdviceTitle = computed(() => (isEditableRequest.value ? 'AI建议' : 'AI提示'))
const aiAdviceHint = computed(() => (
isEditableRequest.value
? '按建议顺序补齐信息或处理风险后,再发起审批。'
: '展示系统已识别的风险点,便于审批和后续整改。'
))
const submitRiskWarnings = computed(() =>
aiAdvice.value.riskCards
.filter((card) => normalizeRiskTone(card?.tone) === 'high')
@@ -904,10 +973,6 @@ export default {
}
}
function resolveRiskTagClass(tag) {
return resolveRiskTagTone(tag)
}
function openRiskOverrideDialog() {
const warnings = submitRiskWarnings.value
if (!warnings.length) {
@@ -1619,11 +1684,18 @@ export default {
return
}
const claimId = String(request.value?.claimId || '').trim()
emit('openAssistant', {
source: 'detail',
prompt: '',
request: request.value,
restoreLatestConversation: true
restoreLatestConversation: false,
scope: claimId
? {
type: 'claim',
claimId
}
: null
})
}
@@ -1632,7 +1704,7 @@ export default {
})
return {
emit, actionBusy, aiAdvice, attachmentPreviewError, attachmentPreviewIndexLabel,
emit, actionBusy, aiAdvice, aiAdviceHint, aiAdviceTitle, attachmentPreviewError, attachmentPreviewIndexLabel,
attachmentPreviewLoading, attachmentPreviewMediaType, attachmentPreviewName, attachmentPreviewOpen,
attachmentPreviewUrl, approveBusy, approveConfirmDialogOpen, approvalConfirmBadge,
approvalConfirmDescription, approvalNextStage, approvalOpinionHint, approvalOpinionPlaceholder,
@@ -1646,7 +1718,7 @@ export default {
currentSubmitRiskWarning,
canEditDetailNote, deleteActionLabel, deleteBusy, deleteDialogDescription, deleteDialogOpen,
deleteDialogTitle, deletingAttachmentId, deletingExpenseId, detailNote, detailNoteDirty,
detailNoteEditor, detailNoteTags, draftBlockingIssues, editingExpenseId, creatingExpense, expenseEditor,
detailNoteEditor, detailNoteEditorView, detailNoteTags, draftBlockingIssues, editingExpenseId, creatingExpense, expenseEditor,
expenseItems, expenseTableColumnCount, expenseTotal, expenseUploadInput,
expenseTypeOptions: EXPENSE_TYPE_OPTIONS,
goToNextSubmitRisk, goToPreviousSubmitRisk,
@@ -1655,12 +1727,12 @@ export default {
isMajorExpenseRisk,
openAiEntry, openAttachmentPreview, goToNextAttachmentPreview, goToPreviousAttachmentPreview,
profile, progressSteps, request, leaderOpinion, removeExpenseAttachment, removeExpenseItem,
resolveExpenseRiskIndicatorTitle, resolveRiskTagClass,
resolveExpenseRiskIndicatorTitle,
resetDetailNote, resolveAttachmentDisplayName, resolveAttachmentPreviewTitle, resolveAttachmentRecognition,
resolveExpenseReasonHelper, resolveExpenseReasonPlaceholder, resolveExpenseRiskState, resolveExpenseIssues,
returnBusy, returnDialogOpen, riskOverrideBusy, riskOverrideDialogOpen, riskOverrideIndexLabel,
riskOverrideReasons, saveDetailNote, savingDetailNote, savingExpenseId,
showLeaderApprovalPanel, showExpenseRisk, startExpenseEdit, submitBusy, submitConfirmDialogOpen,
showAiAdvicePanel, showLeaderApprovalPanel, showExpenseRisk, startExpenseEdit, submitBusy, submitConfirmDialogOpen,
submitRiskWarnings,
triggerExpenseUpload, uploadedExpenseCount, uploadingExpenseId, saveExpenseEdit
}

View File

@@ -8,3 +8,12 @@ export function resolveInitialKnowledgeFolder(folders, currentFolder = '') {
return normalizedFolders[0]?.name || ''
}
export function resolveKnowledgeFolderIcon(folder, activeFolder = '') {
const folderName = String(folder?.name || folder || '').trim()
const normalizedActiveFolder = String(activeFolder || '').trim()
return folderName && folderName === normalizedActiveFolder
? 'mdi mdi-folder-open'
: 'mdi mdi-folder'
}

View File

@@ -6,26 +6,26 @@ import {
import { normalizeExpenseQueryPayload } from './travelReimbursementExpenseQueryModel.js'
const SCENARIO_LABELS = {
expense: '??',
accounts_receivable: '??',
accounts_payable: '??',
knowledge: '??',
unknown: '??'
expense: '报销',
accounts_receivable: '应收',
accounts_payable: '应付',
knowledge: '知识',
unknown: '通用'
}
const INTENT_LABELS = {
query: '??',
explain: '??',
compare: '??',
risk_check: '????',
draft: '????',
operate: '????'
query: '查询',
explain: '解释',
compare: '对比',
risk_check: '风险检查',
draft: '信息核对',
operate: '动作请求'
}
function resolveStatusLabel(status) {
if (status === 'succeeded') return '???'
if (status === 'blocked') return '???'
return '??'
if (status === 'succeeded') return '已完成'
if (status === 'blocked') return '已阻断'
return '处理中'
}
function resolveStatusTone(status) {
@@ -123,6 +123,12 @@ function buildAssociationDocumentContentLines(document) {
return ['- 识别内容:暂未提取到结构化字段,请以票据原件为准。']
}
function buildAssociationDocumentCard(lines) {
return (Array.isArray(lines) ? lines : [])
.map((line) => String(line || '').trim() ? `> ${line}` : '>')
.join('\n')
}
export function buildAttachmentAssociationConfirmationMessage({
claimNo = '',
claimTitle = '',
@@ -144,13 +150,14 @@ export function buildAttachmentAssociationConfirmationMessage({
const filename = String(document?.filename || '').trim() || `附件 ${index + 1}`
const typeLabel = resolveAssociationDocumentTypeLabel(document)
const contentLines = buildAssociationDocumentContentLines(document)
return [
`附件 ${index + 1}${filename}`,
.map((line) => String(line || '').replace(/^-\s*/, ''))
return buildAssociationDocumentCard([
`**附件 ${index + 1}${filename}**`,
'',
`附件类型:${typeLabel}`,
'',
...contentLines
].join('\n')
])
})
return [
@@ -158,14 +165,17 @@ export function buildAttachmentAssociationConfirmationMessage({
'',
documentBlocks.join('\n\n'),
'',
'',
'请问是否确定将票据信息归集到单据:',
'',
targetLines.join('\n'),
'',
`如果 [确认](${ATTACHMENT_ASSOCIATION_CONFIRM_HREF}) 该信息,我将直接将票据进行归集。`
'',
`如果 **[确认](${ATTACHMENT_ASSOCIATION_CONFIRM_HREF})** 该信息,我将直接将票据进行归集。`
]
.filter((part) => String(part || '').trim())
.join('\n')
.replace(/\n{4,}/g, '\n\n\n')
.trim()
}
export function normalizeReviewDocumentFieldKey(label) {
@@ -235,6 +245,9 @@ export function buildOcrDocumentsFromReviewPayload(reviewPayload) {
document_type_label: resolveDocumentTypeLabel(item?.document_type),
scene_code: resolveExpenseTypeCode(item?.suggested_expense_type),
scene_label: String(item?.scene_label || '').trim(),
preview_kind: String(item?.preview_kind || '').trim(),
preview_data_url: String(item?.preview_data_url || '').trim(),
preview_url: String(item?.preview_url || '').trim(),
document_fields: fields,
warnings: Array.isArray(item?.warnings) ? item.warnings : []
}
@@ -373,12 +386,32 @@ export function mergeFilePreviews(existingPreviews, incomingPreviews) {
return result
}
function inferPreviewKindFromUrl(url) {
const normalized = String(url || '').trim().toLowerCase()
if (!normalized) return ''
if (normalized.startsWith('data:image/') || /\.(png|jpg|jpeg|webp|bmp)(?:[?#].*)?$/i.test(normalized)) {
return 'image'
}
if (normalized.startsWith('data:application/pdf') || /\.pdf(?:[?#].*)?$/i.test(normalized)) {
return 'pdf'
}
return ''
}
function resolveDocumentPreviewKind(item) {
const explicit = String(item?.preview_kind || '').trim()
if (explicit) {
return explicit
}
return inferPreviewKindFromUrl(String(item?.preview_url || item?.preview_data_url || '').trim())
}
export function buildOcrFilePreviews(payload) {
const documents = Array.isArray(payload?.documents) ? payload.documents : []
return documents
.map((item) => ({
filename: String(item?.filename || '').trim(),
kind: String(item?.preview_kind || '').trim(),
kind: resolveDocumentPreviewKind(item),
url: String(item?.preview_url || item?.preview_data_url || '').trim()
}))
.filter((item) => item.filename && item.kind === 'image' && item.url)
@@ -389,7 +422,7 @@ export function buildReviewFilePreviewsFromReviewPayload(reviewPayload) {
return documents
.map((item) => ({
filename: String(item?.filename || '').trim(),
kind: String(item?.preview_kind || '').trim(),
kind: resolveDocumentPreviewKind(item),
url: String(item?.preview_url || item?.preview_data_url || '').trim()
}))
.filter((item) => item.filename && item.kind === 'image' && item.url)

View File

@@ -166,6 +166,7 @@ export function createMessage(role, text, attachments = [], extras = {}) {
queryPayload: null,
draftPayload: null,
reviewPayload: null,
reviewPanelScope: '',
riskFlags: [],
pendingAttachmentAssociation: null,
...extras
@@ -299,6 +300,7 @@ export function sanitizeRequest(request) {
if (!request || typeof request !== 'object') return null
const normalized = {
claimId: String(request.claimId || request.claim_id || '').trim(),
id: String(request.id || '').trim(),
typeLabel: String(request.typeLabel || request.category || '').trim(),
reason: String(request.reason || request.title || '').trim(),

View File

@@ -4,6 +4,7 @@ import {
} from './travelReimbursementReviewModel.js'
export const EXPENSE_QUERY_PAGE_SIZE = 5
export const EXPENSE_CENTER_HREF = '/app/requests'
export const ASSOCIATABLE_CLAIM_STATUSES = new Set(['draft', 'supplement', 'returned'])
const EXPENSE_STATUS_LABELS = {
draft: '草稿',
@@ -14,6 +15,36 @@ const EXPENSE_STATUS_LABELS = {
approved: '已审核',
paid: '已入账'
}
const EXPENSE_RISK_LEVEL_LABELS = {
high: '高风险',
medium: '中风险',
warning: '中风险',
low: '低风险',
info: '低风险'
}
export function normalizeExpenseQueryRiskItem(item, index = 0) {
if (!item || typeof item !== 'object') {
return null
}
const rawLevel = String(item.level || item.severity || '').trim().toLowerCase()
const level = EXPENSE_RISK_LEVEL_LABELS[rawLevel] ? rawLevel : 'medium'
const summary = String(item.summary || item.message || item.content || '').trim()
const detail = String(item.detail || item.description || summary).trim()
if (!summary && !detail) {
return null
}
return {
key: String(item.key || `${level}-${index}`).trim() || `${level}-${index}`,
level,
levelLabel: String(item.level_label || item.levelLabel || EXPENSE_RISK_LEVEL_LABELS[level]).trim() || EXPENSE_RISK_LEVEL_LABELS[level],
title: String(item.title || item.label || EXPENSE_RISK_LEVEL_LABELS[level]).trim() || EXPENSE_RISK_LEVEL_LABELS[level],
summary: summary || detail,
detail: detail || summary
}
}
export function normalizeExpenseQueryStatusGroup(item) {
if (!item || typeof item !== 'object') {
@@ -57,6 +88,9 @@ export function normalizeExpenseQueryRecord(item) {
occurredAt,
reason,
location: String(item.location || '').trim(),
riskItems: (Array.isArray(item.risk_flags) ? item.risk_flags : [])
.map((riskItem, index) => normalizeExpenseQueryRiskItem(riskItem, index))
.filter(Boolean),
summary: reason || `${expenseTypeLabel}报销`,
dateDisplay: documentDate || occurredAt || '待补充日期'
}
@@ -164,6 +198,7 @@ export function normalizeExpenseQueryPayload(payload) {
const rawRecordCount = Number(payload.record_count || 0)
const rawPreviewCount = Number(payload.preview_count || records.length)
const rawPreviewLimit = Number(payload.preview_limit || EXPENSE_QUERY_PAGE_SIZE)
const rawOlderRecordCount = Number(payload.older_record_count || 0)
const totalAmount = Number(payload.total_amount || 0)
const rawWindowDays = Number(payload.window_days || 0)
@@ -187,6 +222,7 @@ export function normalizeExpenseQueryPayload(payload) {
windowEndDate: windowEndDate || '',
recordCount: Number.isFinite(rawRecordCount) ? Math.max(0, rawRecordCount) : 0,
previewCount: Number.isFinite(rawPreviewCount) ? Math.max(0, rawPreviewCount) : records.length,
previewLimit: Number.isFinite(rawPreviewLimit) ? Math.max(1, rawPreviewLimit) : EXPENSE_QUERY_PAGE_SIZE,
olderRecordCount: Number.isFinite(rawOlderRecordCount) ? Math.max(0, rawOlderRecordCount) : 0,
hasMoreInWindow: Boolean(payload.has_more_in_window || payload.has_more),
totalAmount: Number.isFinite(totalAmount) ? totalAmount : 0,
@@ -250,18 +286,13 @@ export function buildExpenseQueryHint(queryPayload) {
}
const parts = []
const windowText = buildExpenseQueryWindowLabel(queryPayload)
const previewLimit = Math.max(1, Number(queryPayload.previewLimit || EXPENSE_QUERY_PAGE_SIZE))
const totalCount = Math.max(0, Number(queryPayload.recordCount || 0))
if (Array.isArray(queryPayload.records) && queryPayload.records.length > EXPENSE_QUERY_PAGE_SIZE) {
parts.push(`当前共整理 ${queryPayload.records.length} 笔单据,可左右切换查看`)
}
if (queryPayload.hasMoreInWindow && queryPayload.previewCount < queryPayload.recordCount) {
parts.push(`${windowText}${queryPayload.recordCount} 笔,当前先整理最近 ${queryPayload.previewCount}`)
}
if (queryPayload.olderRecordCount > 0 && queryPayload.windowDays) {
parts.push(`另有 ${queryPayload.olderRecordCount} 笔超过 ${queryPayload.windowDays} 日的单据,请前往个人报销中心查看`)
if (totalCount > previewLimit) {
parts.push(`我只会筛选出最近的 ${previewLimit} 条记录;如果想查询全部的单据,请点击 [**这里**](${EXPENSE_CENTER_HREF}) 跳转到报销中心查看`)
} else if (totalCount > 0) {
parts.push(`当前已展示本次筛选命中的全部记录;如果想进入报销中心继续筛选,请点击 [**这里**](${EXPENSE_CENTER_HREF})。`)
}
return parts.join('。')

View File

@@ -30,6 +30,7 @@ export function buildReviewDocumentDrafts(reviewPayload) {
expenseTypeLabel: String(item.expenseTypeLabel || '').trim(),
preview_kind: String(item.preview_kind || '').trim(),
preview_data_url: String(item.preview_data_url || '').trim(),
preview_url: String(item.preview_url || '').trim(),
warnings: Array.isArray(item.warnings) ? [...item.warnings] : [],
fields: Array.isArray(item.fields)
? item.fields.map((field) => ({

View File

@@ -1260,6 +1260,19 @@ const REVIEW_PENDING_SUMMARY_TEMPLATES = [
({ issueSummary }) => `本次报销还有 ${issueSummary},请先检查下面的补充项;想先留存当前识别结果时可以点“草稿”。`
]
const REVIEW_SAVED_DRAFT_PENDING_SUMMARY_TEMPLATES = [
({ issueSummary }) => `当前还有 ${issueSummary}。草稿已保存,后续上传票据时请关联这张草稿,补齐后再继续提交审批。`,
({ issueSummary }) => `这张草稿仍有 ${issueSummary} 需要补充。您可以继续上传或关联票据,系统会归集到已保存草稿中。`,
({ issueSummary }) => `草稿已生成,当前还差 ${issueSummary}。请按下方提示补充字段或票据,完整后再进入下一步。`,
({ issueSummary }) => `草稿已经留存,下面还有 ${issueSummary} 待处理。新增附件请关联当前草稿,避免重复建单。`,
({ issueSummary }) => `当前草稿还有 ${issueSummary}。建议先补齐金额、票据等信息,再从草稿详情继续提交审批。`,
({ issueSummary }) => `已保留当前进度,这笔草稿还需要 ${issueSummary}。后续补充内容会作为该草稿的更新处理。`,
({ issueSummary }) => `这张单据已进入草稿状态,仍有 ${issueSummary}。请继续补充必要信息,补齐后再发起正式提交。`,
({ issueSummary }) => `草稿保存完成后,当前还剩 ${issueSummary}。上传附件时请选择关联这张草稿,系统会继续合并识别结果。`,
({ issueSummary }) => `当前草稿待完善:${issueSummary}。请先处理下方项目,确认完整后再继续下一步。`,
({ issueSummary }) => `这笔草稿还存在 ${issueSummary}。可以继续补充票据和字段,系统会围绕已保存草稿继续更新。`
]
function buildStableTemplateIndex(signature, total) {
const source = String(signature || '')
let hash = 0
@@ -1269,7 +1282,7 @@ function buildStableTemplateIndex(signature, total) {
return total ? hash % total : 0
}
function buildReviewPendingSummary(pendingCount, riskCount, signature = '') {
function buildReviewPendingSummary(pendingCount, riskCount, signature = '', options = {}) {
const issueParts = []
if (pendingCount) {
issueParts.push(`${pendingCount} 项信息待补充`)
@@ -1278,11 +1291,15 @@ function buildReviewPendingSummary(pendingCount, riskCount, signature = '') {
issueParts.push(`${riskCount} 条风险提醒`)
}
const issueSummary = issueParts.length ? issueParts.join('、') : '一些细节还需要进一步确认'
const templateIndex = buildStableTemplateIndex(signature || issueSummary, REVIEW_PENDING_SUMMARY_TEMPLATES.length)
return REVIEW_PENDING_SUMMARY_TEMPLATES[templateIndex]({ issueSummary })
const templates = options.savedDraft
? REVIEW_SAVED_DRAFT_PENDING_SUMMARY_TEMPLATES
: REVIEW_PENDING_SUMMARY_TEMPLATES
const templateIndex = buildStableTemplateIndex(signature || issueSummary, templates.length)
return templates[templateIndex]({ issueSummary })
}
export function buildReviewPlainFollowupCopy(reviewPayload) {
export function buildReviewPlainFollowupCopy(reviewPayload, options = {}) {
const savedDraft = Boolean(options?.savedDraft)
const todoItems = buildReviewTodoItems(reviewPayload)
const pendingCount = countReviewPendingItems(reviewPayload)
const riskBriefs = resolvePresentationRiskBriefs(reviewPayload)
@@ -1297,7 +1314,9 @@ export function buildReviewPlainFollowupCopy(reviewPayload) {
return {
lead: '补充信息:',
tone: 'danger',
summary: buildReviewPendingSummary(pendingCount || extraMissingCount, riskBriefs.length, summarySignature),
summary: buildReviewPendingSummary(pendingCount || extraMissingCount, riskBriefs.length, summarySignature, {
savedDraft
}),
items: todoItems.map((item) => buildReviewPlainFollowupItem(item, true)),
notes: []
}

View File

@@ -2,6 +2,8 @@ export const EXPENSE_TYPE_OPTIONS = [
{ value: 'travel', label: '差旅费' },
{ value: 'train_ticket', label: '火车票' },
{ value: 'flight_ticket', label: '机票' },
{ value: 'ship_ticket', label: '轮船票' },
{ value: 'ferry_ticket', label: '轮船票' },
{ value: 'hotel_ticket', label: '住宿票' },
{ value: 'ride_ticket', label: '乘车' },
{ value: 'entertainment', label: '业务招待费' },
@@ -116,6 +118,13 @@ export function resolveExpenseReasonHelper(itemType) {
return '业务报销说明'
}
export function resolveExpenseDescriptionDetail(itemType, itemLocation) {
if (isRouteDescriptionExpenseType(itemType) || isHotelDescriptionExpenseType(itemType)) {
return resolveExpenseReasonHelper(itemType)
}
return resolveLocationDisplay(itemLocation, itemType)
}
export function buildFallbackProgressSteps() {
return [
{ index: 1, label: '创建单据', time: '已完成', done: true, active: true },
@@ -345,7 +354,7 @@ export function buildExpenseItemViewModel(source, index, requestModel, travelTim
name: resolveExpenseTypeLabel(itemType),
category: resolveExpenseTypeLabel(itemType),
desc: itemReason || '待补充',
detail: resolveLocationDisplay(itemLocation, itemType),
detail: resolveExpenseDescriptionDetail(itemType, itemLocation),
amount: amountDisplay,
status: isSystemGenerated ? '系统计算' : attachments.length ? '已识别' : '待补充',
tone: isSystemGenerated ? 'system' : attachments.length ? 'ok' : 'bad',

View File

@@ -1,3 +1,9 @@
import {
isActionableRiskFlag,
isRiskSummaryWithRisk,
normalizeRiskFlagTone
} from '../../utils/riskFlags.js'
const DOCUMENT_TYPE_LABELS = {
flight_itinerary: '机票/航班行程单',
train_ticket: '火车/高铁票',
@@ -34,6 +40,62 @@ export function normalizeRiskTone(value) {
return normalizeTone(value)
}
function resolveFlagTone(flag) {
return normalizeRiskFlagTone(flag)
}
function isRiskTone(tone) {
return ['medium', 'high'].includes(normalizeText(tone).toLowerCase())
}
function normalizeId(value) {
return normalizeText(value)
}
function resolveItemRiskFlag(item, claimRiskFlags) {
const itemId = normalizeId(item?.id)
if (!itemId || !Array.isArray(claimRiskFlags)) {
return null
}
return claimRiskFlags.find((flag) => {
if (!flag || typeof flag !== 'object') {
return false
}
if (!isActionableRiskFlag(flag)) {
return false
}
const flagItemId = normalizeId(flag.item_id || flag.itemId)
const tone = resolveFlagTone(flag)
return flagItemId === itemId && isRiskTone(tone)
}) || null
}
export function buildItemClaimRiskState(item, claimRiskFlags = []) {
const flag = resolveItemRiskFlag(item, claimRiskFlags)
if (!flag) {
return null
}
const tone = resolveFlagTone(flag)
const label = normalizeText(flag.label) || (tone === 'high' ? '高风险' : '中风险')
const points = Array.isArray(flag.points)
? flag.points.map((point) => normalizeText(point)).filter(Boolean)
: []
const summary = normalizeText(flag.summary || flag.message || flag.reason)
return {
label,
tone,
headline: normalizeText(flag.headline || flag.title) || label,
summary,
points,
suggestion: normalizeText(flag.suggestion) || '如业务确需提交,请在附加说明中补充特殊情况原因后继续提交。'
}
}
export function resolveRiskTagTone(tag) {
const normalized = normalizeText(tag).toLowerCase()
if (normalized === '#high_risk') return 'high'
@@ -99,6 +161,68 @@ function normalizeRuleBasis(value) {
return text ? [text] : []
}
function resolveClaimRiskRuleBasis(flag = {}, { risk = '', summary = '', tone = 'medium' } = {}) {
const explicitBasis = normalizeRuleBasis(flag.rule_basis || flag.ruleBasis)
if (explicitBasis.length) {
return explicitBasis
}
const source = normalizeText(flag.source)
const label = normalizeText(flag.label || flag.title || flag.name)
const corpus = [risk, summary, label].map((item) => normalizeText(item)).join(' ')
const basis = []
if (/高风险|中风险/.test(corpus)) {
basis.push(`风险文本已明确标记为${tone === 'high' ? '高风险' : '中风险'}`)
}
if (source === 'attachment_analysis' || /附件|票据|OCR|识别|发票/.test(corpus)) {
basis.push('附件识别或票据核验未完全通过,系统将该项同步为单据风险。')
}
if (/直属领导|审批链|审批人缺失|审批人信息|补充分配|分配/.test(corpus)) {
basis.push('审批链校验未匹配到完整审批人信息,因此按中风险提醒。')
}
if (/金额|超标|阈值|住宿标准|报销标准|标准/.test(corpus)) {
basis.push('金额或标准核算命中制度阈值,需要补充说明或人工复核。')
}
if (/历史|近\s*\d+\s*天|重复|多次/.test(corpus)) {
basis.push('历史报销风险次数达到预警条件,系统提示审批人重点关注。')
}
if (/缺失|缺少|未识别|待补充|不一致|不匹配/.test(corpus)) {
basis.push('单据、附件或审批信息存在缺失、不一致或待补充项。')
}
if (summary) {
basis.push(`风险汇总:${summary}`)
}
return uniqueTexts(basis.length ? basis : [`系统预审根据“${label || '单据风险'}”将该项列为${tone === 'high' ? '高风险' : '中风险'}`])
}
function resolveClaimRiskSuggestion(flag = {}, { risk = '', summary = '' } = {}) {
const explicitSuggestion = normalizeText(flag.suggestion)
if (explicitSuggestion) {
return explicitSuggestion
}
const corpus = [risk, summary, flag.label, flag.title].map((item) => normalizeText(item)).join(' ')
if (/直属领导|审批链|审批人缺失|审批人信息|补充分配|分配/.test(corpus)) {
return '请先核对员工档案中的直属领导和审批链配置;如果信息无误,可由审批环节人工补充分配说明。'
}
if (/金额|超标|阈值|住宿标准|报销标准|标准/.test(corpus)) {
return '请核对金额、天数、地点和职级标准;如确需超标,请在附加说明中写清楚业务原因和佐证材料。'
}
if (/附件|票据|OCR|识别|发票/.test(corpus)) {
return '请打开对应费用明细的附件预览,核对票据类型、金额、日期和说明;识别有误时先修正明细或重新上传附件。'
}
if (/历史|近\s*\d+\s*天|重复|多次/.test(corpus)) {
return '请核对近期同类报销记录,必要时补充本次费用与历史单据不同的业务背景。'
}
if (/缺失|缺少|未识别|待补充|不一致|不匹配/.test(corpus)) {
return '请按风险点补齐缺失信息,并核对费用明细与附件内容是否一致。'
}
return '请先核对上方触发原因;如属于真实业务例外,在附加说明中写清楚原因和佐证后再继续流转。'
}
export function buildAttachmentInsightViewModel(metadata, item = {}) {
if (!metadata) {
return null
@@ -245,6 +369,7 @@ export function buildAttachmentRiskCards({
attachmentMetaByItemId = {},
claimRiskFlags = []
} = {}) {
const attachmentRiskItemIds = new Set()
const attachmentCards = expenseItems.flatMap((item, index) => {
if (!item?.invoiceId) {
return []
@@ -257,6 +382,10 @@ export function buildAttachmentRiskCards({
if (!analysis || !['medium', 'high'].includes(tone)) {
return []
}
const itemId = normalizeId(item.id)
if (itemId) {
attachmentRiskItemIds.add(itemId)
}
const points = Array.isArray(analysis.points) && analysis.points.length
? analysis.points
@@ -276,6 +405,10 @@ export function buildAttachmentRiskCards({
}
if (!flag || typeof flag !== 'object') {
if (!isActionableRiskFlag(flag)) {
return []
}
const risk = normalizeText(flag)
return risk
? [withRiskTags({
@@ -285,29 +418,41 @@ export function buildAttachmentRiskCards({
title: '单据风险提示',
risk,
summary: '',
ruleBasis: ['系统预审规则命中该风险提示。'],
suggestion: '请结合业务背景补充说明或调整单据后再提交。'
ruleBasis: resolveClaimRiskRuleBasis({}, { risk, tone: 'medium' }),
suggestion: resolveClaimRiskSuggestion({}, { risk })
})]
: []
}
const tone = normalizeTone(flag.severity)
if (!['medium', 'high'].includes(tone)) {
if (!isActionableRiskFlag(flag)) {
return []
}
const source = normalizeText(flag.source)
const flagItemId = normalizeId(flag.item_id || flag.itemId)
if (source === 'attachment_analysis' && flagItemId && attachmentRiskItemIds.has(flagItemId)) {
return []
}
const tone = resolveFlagTone(flag)
if (!isRiskTone(tone)) {
return []
}
const flagPoints = Array.isArray(flag.points)
? flag.points.map((point) => normalizeText(point)).filter(Boolean)
: []
const primaryRisk = normalizeText(flag.message || flag.reason || flag.summary)
const fallbackRisk = normalizeText(flag.description || flag.detail || flag.title || flag.label || flag.name)
const risks = flagPoints.length
? flagPoints
: [normalizeText(flag.message || flag.reason || flag.summary)].filter(Boolean)
const summary = normalizeText(flag.summary)
const ruleBasis = uniqueTexts([
...normalizeRuleBasis(flag.rule_basis || flag.ruleBasis),
summary ? `风险汇总:${summary}` : '',
'系统预审规则命中该风险提示。'
])
: [primaryRisk || fallbackRisk].filter(Boolean)
const summary = normalizeText(flag.summary || flag.message || flag.reason)
const ruleBasis = resolveClaimRiskRuleBasis(flag, {
risk: risks[0] || primaryRisk || fallbackRisk,
summary,
tone
})
return risks.map((risk, pointIndex) => withRiskTags({
id: `claim-risk-${index}-${pointIndex}`,
@@ -317,7 +462,7 @@ export function buildAttachmentRiskCards({
risk,
summary,
ruleBasis,
suggestion: normalizeText(flag.suggestion) || '请结合业务背景补充说明或调整单据后再提交。'
suggestion: resolveClaimRiskSuggestion(flag, { risk, summary })
}))
})
.filter(Boolean)
@@ -328,6 +473,52 @@ export function buildAttachmentRiskCards({
return [...attachmentCards, ...claimCards]
}
function isNoRiskSummary(value) {
return !isRiskSummaryWithRisk(value)
}
function resolveClaimSummaryTone(request) {
const explicitTone = normalizeText(request?.riskTone || request?.risk_tone || request?.severity)
if (explicitTone) {
return normalizeTone(explicitTone)
}
const summary = normalizeText(request?.riskSummary || request?.risk || request?.riskText)
if (/高风险|重大风险|严重|超标|违规/.test(summary)) {
return 'high'
}
if (/中风险|待关注|待复核|提醒|异常|风险/.test(summary)) {
return 'medium'
}
return 'medium'
}
export function buildClaimSummaryRiskCards(request = {}) {
const summary = normalizeText(request?.riskSummary || request?.risk || request?.riskText)
if (isNoRiskSummary(summary)) {
return []
}
const tone = resolveClaimSummaryTone(request)
if (!isRiskTone(tone)) {
return []
}
return [withRiskTags({
id: 'claim-risk-summary',
tone,
label: tone === 'high' ? '高风险' : '中风险',
title: '单据风险提示',
risk: summary,
summary,
ruleBasis: resolveClaimRiskRuleBasis(
{ source: 'risk_summary', message: summary, severity: tone },
{ risk: summary, summary, tone }
),
suggestion: resolveClaimRiskSuggestion({ source: 'risk_summary' }, { risk: summary, summary })
})]
}
export function buildAiAdviceViewModel({ completionItems = [], riskCards = [] } = {}) {
const normalizedCompletionItems = completionItems.map((item) => normalizeText(item)).filter(Boolean)
const normalizedRiskCards = riskCards.filter(Boolean)

View File

@@ -1,5 +1,171 @@
import { computed, nextTick, ref } from 'vue'
const COMMON_DESTINATION_PREFIXES = [
'上海',
'北京',
'广州',
'深圳',
'杭州',
'南京',
'苏州',
'成都',
'重庆',
'武汉',
'西安',
'天津',
'宁波',
'青岛',
'长沙',
'郑州',
'济南',
'合肥',
'福州',
'厦门',
'昆明',
'南昌',
'沈阳',
'大连',
'无锡',
'佛山',
'东莞'
]
const CHINESE_DAY_NUMBERS = {
: 1,
: 2,
: 2,
: 3,
: 4,
: 5,
: 6,
: 7,
: 8,
: 9,
: 10
}
function normalizeComposerText(value) {
return String(value || '').trim().replace(/\s+/g, ' ')
}
function parseDayCount(value) {
const text = String(value || '').trim()
const numericValue = Number.parseInt(text, 10)
if (Number.isFinite(numericValue) && numericValue > 0) {
return numericValue
}
if (text === '十') {
return 10
}
if (/^十[一二三四五六七八九]$/.test(text)) {
return 10 + (CHINESE_DAY_NUMBERS[text.slice(1)] || 0)
}
if (/^[一二两三四五六七八九]十$/.test(text)) {
return (CHINESE_DAY_NUMBERS[text.slice(0, 1)] || 1) * 10
}
if (/^[一二两三四五六七八九]十[一二三四五六七八九]$/.test(text)) {
return (CHINESE_DAY_NUMBERS[text.slice(0, 1)] || 1) * 10 + (CHINESE_DAY_NUMBERS[text.slice(2)] || 0)
}
return CHINESE_DAY_NUMBERS[text] || 0
}
function calculateBusinessDays(businessTimeContext) {
const startDate = String(businessTimeContext?.start_date || '').trim()
const endDate = String(businessTimeContext?.end_date || startDate).trim()
if (!startDate || !endDate || startDate > endDate) {
return 0
}
const startAt = Date.parse(`${startDate}T00:00:00Z`)
const endAt = Date.parse(`${endDate}T00:00:00Z`)
if (!Number.isFinite(startAt) || !Number.isFinite(endAt)) {
return 0
}
return Math.max(1, Math.round((endAt - startAt) / 86400000) + 1)
}
function stripBusinessTimePrefix(text) {
return normalizeComposerText(text)
.replace(/^(?:业务)?发生时间[:]\s*[^,。\n]+(?:至\s*[^,。\n]+)?[,。\s]*/u, '')
.trim()
}
function resolveDestinationFromText(text) {
const normalized = normalizeComposerText(text).replace(/\s+/g, '')
const targetMatch = normalized.match(/(?:去|到|赴|前往)([^,。;;]+)/u)
const targetText = String(targetMatch?.[1] || '').trim()
if (!targetText) {
return ''
}
const knownDestination = COMMON_DESTINATION_PREFIXES.find((item) => targetText.startsWith(item))
if (knownDestination) {
return knownDestination
}
const verbIndex = targetText.search(/支撑|支持|部署|实施|驻场|出差|拜访|处理|办理|参加|进行|协助|服务器|项目/u)
if (verbIndex > 0) {
return targetText.slice(0, verbIndex)
}
return targetText.slice(0, 12)
}
function resolveTripDaysFromText(text, businessTimeContext) {
const dayMatch = normalizeComposerText(text).match(/(?:出差|共|总计)?\s*([0-9]+|[一二两三四五六七八九十]{1,3})\s*天/u)
const explicitDays = parseDayCount(dayMatch?.[1])
return explicitDays || calculateBusinessDays(businessTimeContext)
}
function resolveReasonFromText(text, destination) {
let reason = normalizeComposerText(text)
.replace(/^(?:去|到|赴|前往)\s*/u, '')
.trim()
if (destination && reason.startsWith(destination)) {
reason = reason.slice(destination.length).trim()
}
return reason
.replace(/[,。\s]*(?:出差|共|总计)?\s*(?:[0-9]+|[一二两三四五六七八九十]{1,3})\s*天/u, '')
.replace(/[,。\s]*(?:申请|发起|办理)?(?:差旅费|差旅|费用)?报销(?:申请)?[。.!]?$/u, '')
.replace(/^[,。;;\s]+|[,。;;\s]+$/gu, '')
.trim()
}
export function buildStructuredComposerSubmitText(rawText, businessTimeContext = null) {
const normalizedText = normalizeComposerText(rawText)
const timeDisplay = String(
businessTimeContext?.business_time ||
businessTimeContext?.time_range ||
businessTimeContext?.display_value ||
''
).trim()
if (!timeDisplay || !normalizedText) {
return normalizedText
}
const bodyText = stripBusinessTimePrefix(normalizedText)
if (!bodyText) {
return `发生时间:${timeDisplay}`
}
const destination = resolveDestinationFromText(bodyText)
const reason = resolveReasonFromText(bodyText, destination)
const days = resolveTripDaysFromText(bodyText, businessTimeContext)
const lines = [`发生时间:${timeDisplay}`]
if (destination) {
lines.push(`地点:${destination}`)
}
if (reason) {
lines.push(`事由:${reason}`)
}
if (days > 0 && (days > 1 || /出差|差旅|至/.test(timeDisplay) || /出差|差旅/.test(bodyText))) {
lines.push(`天数:${days}`)
}
return lines.join('\n')
}
export function useTravelReimbursementComposerTools({
currentUser,
activeReviewPayload,
@@ -51,12 +217,12 @@ export function useTravelReimbursementComposerTools({
)
function buildComposerBusinessTimeLabel() {
if (composerDateMode.value === 'single') {
return `业务发生时间:${composerSingleDate.value}`
return `发生时间${composerSingleDate.value}`
}
if (composerRangeStartDate.value === composerRangeEndDate.value) {
return `业务发生时间:${composerRangeStartDate.value}`
return `发生时间${composerRangeStartDate.value}`
}
return `业务发生时间:${composerRangeStartDate.value}${composerRangeEndDate.value}`
return `发生时间${composerRangeStartDate.value}${composerRangeEndDate.value}`
}
function hasComposerBusinessTimeSelection() {
@@ -156,6 +322,14 @@ export function useTravelReimbursementComposerTools({
return `${tagPart}${draftPart}`
}
function resolveComposerDisplaySubmitText(rawText) {
const businessTimeContext = buildComposerBusinessTimeContext()
if (!businessTimeContext) {
return String(rawText || '').trim()
}
return buildStructuredComposerSubmitText(rawText, businessTimeContext)
}
function toggleComposerDatePicker() {
composerDatePickerOpen.value = !composerDatePickerOpen.value
if (composerDatePickerOpen.value) {
@@ -377,6 +551,7 @@ export function useTravelReimbursementComposerTools({
mergeBusinessTimeIntoExtraContext,
syncComposerBusinessTimeToReviewCard,
resolveComposerSubmitText,
resolveComposerDisplaySubmitText,
toggleComposerDatePicker,
closeComposerDatePicker,
setComposerDateMode,

View File

@@ -26,6 +26,7 @@ import {
export function useTravelReimbursementReviewDrawer({
activeReviewPayload,
activeReviewPanelScope,
reviewFilePreviews,
flowSteps,
submitting,
@@ -92,6 +93,11 @@ export function useTravelReimbursementReviewDrawer({
const reviewRiskItems = computed(() => buildReviewRiskItems(activeReviewPayload.value))
const reviewRiskEmpty = computed(() => !reviewRiskItems.value.length)
const reviewDocumentCount = computed(() => reviewDocumentDrafts.value.length)
const normalizedReviewPanelScope = computed(() => {
const scope = String(activeReviewPanelScope?.value || '').trim()
return ['overview', 'documents', 'risk'].includes(scope) ? scope : ''
})
const reviewOverviewDrawerAvailable = computed(() => normalizedReviewPanelScope.value === 'overview')
const reviewDocumentDrawerAvailable = computed(() => reviewDocumentCount.value > 0)
const reviewRiskDrawerAvailable = computed(() => !reviewRiskEmpty.value)
const reviewFlowDrawerAvailable = computed(() => flowSteps.value.length > 0)
@@ -135,22 +141,31 @@ export function useTravelReimbursementReviewDrawer({
: 'mdi mdi-timeline-clock-outline'
))
const activeReviewDocument = computed(() => reviewDocumentDrafts.value[activeReviewDocumentIndex.value] ?? null)
const activeReviewDocumentPreview = computed(() =>
activeReviewDocument.value
? (
resolveDocumentPreview(activeReviewFilePreviews.value, activeReviewDocument.value.filename)
|| (
activeReviewDocument.value.preview_kind === 'image' && activeReviewDocument.value.preview_data_url
? {
filename: activeReviewDocument.value.filename,
kind: activeReviewDocument.value.preview_kind,
url: activeReviewDocument.value.preview_data_url
}
: null
)
)
: null
)
const activeReviewDocumentPreview = computed(() => {
const document = activeReviewDocument.value
if (!document) return null
const matchedPreview = resolveDocumentPreview(activeReviewFilePreviews.value, document.filename)
if (matchedPreview?.url) {
return matchedPreview
}
const inlineUrl = String(document.preview_url || document.preview_data_url || '').trim()
if (!inlineUrl) {
return null
}
const explicitKind = String(document.preview_kind || '').trim()
const inferredKind = inlineUrl.startsWith('data:image/') ? 'image' : explicitKind
if (inferredKind !== 'image') {
return null
}
return {
filename: document.filename,
kind: 'image',
url: inlineUrl
}
})
const canPreviewActiveReviewDocument = computed(() => Boolean(activeReviewDocumentPreview.value?.url))
const reviewDocumentDirty = computed(() => {
const baseValue = JSON.stringify(reviewDocumentBaseDrafts.value.map(normalizeReviewDocumentComparableValue))
@@ -170,9 +185,22 @@ export function useTravelReimbursementReviewDrawer({
activeReviewDocumentIndex.value = nextDocumentDrafts.length
? Math.min(activeReviewDocumentIndex.value, nextDocumentDrafts.length - 1)
: 0
reviewDrawerMode.value = resolveReviewRiskBriefs(payload).length
? REVIEW_DRAWER_MODE_RISK
: REVIEW_DRAWER_MODE_REVIEW
const hasDocuments = nextDocumentDrafts.length > 0
const hasRisks = resolveReviewRiskBriefs(payload).length > 0
const scope = normalizedReviewPanelScope.value
if (scope === 'documents' && hasDocuments) {
reviewDrawerMode.value = REVIEW_DRAWER_MODE_DOCUMENTS
} else if (scope === 'risk' && hasRisks) {
reviewDrawerMode.value = REVIEW_DRAWER_MODE_RISK
} else if (scope === 'overview') {
reviewDrawerMode.value = REVIEW_DRAWER_MODE_REVIEW
} else if (hasDocuments) {
reviewDrawerMode.value = REVIEW_DRAWER_MODE_DOCUMENTS
} else if (hasRisks) {
reviewDrawerMode.value = REVIEW_DRAWER_MODE_RISK
} else {
reviewDrawerMode.value = REVIEW_DRAWER_MODE_REVIEW
}
reviewInlinePendingFiles.value = []
reviewInlineEditorKey.value = ''
reviewInlineErrors.value = {}
@@ -348,14 +376,27 @@ export function useTravelReimbursementReviewDrawer({
}
function enforceReviewDrawerAvailability() {
if (!reviewOverviewDrawerAvailable.value && reviewDrawerMode.value === REVIEW_DRAWER_MODE_REVIEW) {
if (reviewDocumentDrawerAvailable.value) {
reviewDrawerMode.value = REVIEW_DRAWER_MODE_DOCUMENTS
} else if (reviewRiskDrawerAvailable.value) {
reviewDrawerMode.value = REVIEW_DRAWER_MODE_RISK
}
}
if (!reviewDocumentDrawerAvailable.value && reviewDrawerMode.value === REVIEW_DRAWER_MODE_DOCUMENTS) {
reviewDrawerMode.value = REVIEW_DRAWER_MODE_REVIEW
reviewDrawerMode.value = reviewOverviewDrawerAvailable.value
? REVIEW_DRAWER_MODE_REVIEW
: REVIEW_DRAWER_MODE_RISK
}
if (!reviewRiskDrawerAvailable.value && reviewDrawerMode.value === REVIEW_DRAWER_MODE_RISK) {
reviewDrawerMode.value = REVIEW_DRAWER_MODE_REVIEW
reviewDrawerMode.value = reviewOverviewDrawerAvailable.value
? REVIEW_DRAWER_MODE_REVIEW
: REVIEW_DRAWER_MODE_DOCUMENTS
}
if (!reviewFlowDrawerAvailable.value && reviewDrawerMode.value === REVIEW_DRAWER_MODE_FLOW) {
reviewDrawerMode.value = REVIEW_DRAWER_MODE_REVIEW
reviewDrawerMode.value = reviewOverviewDrawerAvailable.value
? REVIEW_DRAWER_MODE_REVIEW
: (reviewDocumentDrawerAvailable.value ? REVIEW_DRAWER_MODE_DOCUMENTS : REVIEW_DRAWER_MODE_RISK)
}
}
@@ -390,6 +431,7 @@ export function useTravelReimbursementReviewDrawer({
reviewRecognitionNotes,
reviewDocumentSummaries,
reviewDocumentCount,
reviewOverviewDrawerAvailable,
isReviewDocumentDrawer,
isReviewRiskDrawer,
isReviewFlowDrawer,

View File

@@ -29,7 +29,6 @@ export function useTravelReimbursementSessionState({
linkedRequest,
toast,
composerDraft,
uploadDecisionDialogOpen,
adjustComposerTextareaHeight,
scrollToBottom,
getSessionRuntimeRefs = () => ({})
@@ -122,6 +121,7 @@ export function useTravelReimbursementSessionState({
}
const initialSessionType = resolveInitialSessionType(props.initialConversation)
const shouldPersistLocalSnapshot = props.entrySource !== 'detail'
const conversationInitialState = props.initialConversation
? buildConversationSessionState(props.initialConversation, initialSessionType)
: buildEmptySessionState(initialSessionType)
@@ -172,6 +172,10 @@ export function useTravelReimbursementSessionState({
}
function persistSessionState(sessionState = null) {
if (!shouldPersistLocalSnapshot) {
return
}
const state = sessionState || captureCurrentSessionState()
const persistedState = buildPersistableSessionState(state)
const meaningful = Boolean(
@@ -240,7 +244,6 @@ export function useTravelReimbursementSessionState({
}
composerUploadIntent.value = String(nextState.composerUploadIntent || '').trim()
insightPanelCollapsed.value = Boolean(nextState.insightPanelCollapsed)
uploadDecisionDialogOpen.value = false
nextTick(() => {
adjustComposerTextareaHeight()
scrollToBottom()

View File

@@ -61,6 +61,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
refreshFlowRunDetail,
rememberFilePreviews,
replaceMessage,
resolveComposerDisplaySubmitText,
resetFlowRun,
resolveComposerSubmitText,
reviewInlineForm,
@@ -76,7 +77,6 @@ export function useTravelReimbursementSubmitComposer(ctx) {
startSemanticFlowPreview,
submitting,
syncComposerFilesToDraft,
uploadDecisionDialogOpen,
toast
} = ctx
@@ -109,6 +109,33 @@ export function useTravelReimbursementSubmitComposer(ctx) {
)
}
function resolveReviewPanelScope({
reviewPayload = null,
reviewAction = '',
fileCount = 0,
rawText = ''
} = {}) {
if (!reviewPayload || typeof reviewPayload !== 'object') {
return ''
}
const normalizedAction = String(reviewAction || '').trim()
const documentCount = Array.isArray(reviewPayload.document_cards) ? reviewPayload.document_cards.length : 0
const riskCount = Array.isArray(reviewPayload.risk_briefs) ? reviewPayload.risk_briefs.length : 0
const asksRisk = /风险|隐患|超标|异常|重复|待整改|风险项|高风险|中风险|低风险/.test(String(rawText || ''))
if (fileCount > 0 && documentCount > 0) {
return 'documents'
}
if (riskCount > 0 && (asksRisk || ['next_step', 'submit', 'submit_claim'].includes(normalizedAction))) {
return 'risk'
}
if (!normalizedAction && fileCount === 0) {
return 'overview'
}
return ''
}
async function confirmPendingAttachmentAssociation(message) {
if (submitting.value || sessionSwitchBusy.value) return null
@@ -137,7 +164,6 @@ export function useTravelReimbursementSubmitComposer(ctx) {
userText: `确认归集到草稿 ${runtime.claimNo || '当前草稿'}`,
files: runtime.files,
uploadDisposition: 'continue_existing',
skipUploadDecisionPrompt: true,
skipDraftAssociationPrompt: true,
pendingText: runtime.claimNo
? `正在将票据归集到草稿 ${runtime.claimNo}...`
@@ -189,26 +215,57 @@ export function useTravelReimbursementSubmitComposer(ctx) {
return parts.join('\n')
}
function resolveDetailScopedClaimId() {
if (props.entrySource !== 'detail' || isKnowledgeSession.value) {
return ''
}
return String(
linkedRequest.value?.claimId ||
linkedRequest.value?.claim_id ||
''
).trim()
}
async function submitComposer(options = {}) {
if (submitting.value || sessionSwitchBusy.value) return null
const rawText = resolveComposerSubmitText(options.rawText).trim()
const systemGenerated = Boolean(options.systemGenerated)
const resolvedUploadDisposition =
String(options.uploadDisposition || '').trim() ||
(composerUploadIntent.value === 'continue_existing' ? 'continue_existing' : '')
const normalizedFiles = isKnowledgeSession.value ? [] : Array.from(options.files ?? attachedFiles.value)
const fileMergeResult = mergeFilesWithLimit([], normalizedFiles, MAX_ATTACHMENTS)
const files = fileMergeResult.files
const detailScopedClaimId = resolveDetailScopedClaimId()
const detailScopedUpload = Boolean(detailScopedClaimId && files.length)
if (detailScopedClaimId) {
draftClaimId.value = detailScopedClaimId
}
const resolvedUploadDisposition =
String(options.uploadDisposition || '').trim() ||
(composerUploadIntent.value === 'continue_existing' ? 'continue_existing' : '') ||
(detailScopedUpload ? 'continue_existing' : '')
if (fileMergeResult.overflowCount > 0) {
toast(`一次最多上传 ${MAX_ATTACHMENTS} 份附件,已保留前 ${MAX_ATTACHMENTS} 份。`)
}
if (!rawText && !files.length) return
const fileNames = files.map((file) => file.name)
const initialExtraContext = options.extraContext && typeof options.extraContext === 'object'
const optionExtraContext = options.extraContext && typeof options.extraContext === 'object'
? { ...options.extraContext }
: {}
const detailScopedClaimNo = String(
linkedRequest.value?.documentNo ||
linkedRequest.value?.id ||
''
).trim()
const initialExtraContext = detailScopedClaimId
? {
...optionExtraContext,
draft_claim_id: detailScopedClaimId,
selected_claim_id: detailScopedClaimId,
selected_claim_no: detailScopedClaimNo,
detail_scope_claim_id: detailScopedClaimId
}
: optionExtraContext
const selectedBusinessTimeContext = isKnowledgeSession.value ? null : buildComposerBusinessTimeContext()
const extraContext = isKnowledgeSession.value
? initialExtraContext
@@ -217,7 +274,8 @@ export function useTravelReimbursementSubmitComposer(ctx) {
const attachmentAssociationConfirmed = Boolean(
options.associationConfirmed ||
extraContext.attachment_association_confirmed ||
reviewAction === 'link_to_existing_draft'
reviewAction === 'link_to_existing_draft' ||
detailScopedUpload
)
const hasSelectedExpenseType = Boolean(
extraContext.expense_scene_selection ||
@@ -238,10 +296,9 @@ export function useTravelReimbursementSubmitComposer(ctx) {
hasSelectedExpenseType
})
const reviewAttachmentNames = extractReviewAttachmentNames(activeReviewPayload.value)
const hasExistingDocumentEvent =
Boolean(String(draftClaimId.value || '').trim()) || reviewAttachmentNames.length > 0
const userText =
String(options.userText || '').trim() ||
resolveComposerDisplaySubmitText(rawText) ||
rawText ||
(isKnowledgeSession.value
? `我上传了 ${fileNames.length} 份附件,请帮我回答相关财务问题。`
@@ -254,19 +311,6 @@ export function useTravelReimbursementSubmitComposer(ctx) {
if (
!isKnowledgeSession.value &&
files.length &&
hasExistingDocumentEvent &&
!resolvedUploadDisposition &&
!options.skipUploadDecisionPrompt &&
!reviewAction
) {
uploadDecisionDialogOpen.value = true
return null
}
if (
!isKnowledgeSession.value &&
files.length &&
!hasExistingDocumentEvent &&
!resolvedUploadDisposition &&
!options.skipDraftAssociationPrompt &&
!reviewAction
@@ -300,7 +344,22 @@ export function useTravelReimbursementSubmitComposer(ctx) {
}
} catch (error) {
console.warn('Failed to load draft claims before attachment recognition:', error)
toast(error?.message || '查询可关联草稿失败,已继续按新单据识别。')
resetFlowRun()
if (!options.skipUserMessage) {
messages.value.push(createMessage('user', userText, fileNames))
}
messages.value.push(createMessage(
'assistant',
'我暂时没能查询到可关联的草稿/待补单据,所以先不识别这批附件。请稍后重试,或从对应草稿进入后继续上传票据。',
[],
{
meta: ['单据查询失败']
}
))
nextTick(scrollToBottom)
persistSessionState()
toast(error?.message || '查询可关联草稿失败,请稍后重试。')
return null
}
}
@@ -602,14 +661,24 @@ export function useTravelReimbursementSubmitComposer(ctx) {
queryPayload: normalizeExpenseQueryPayload(payload?.result?.query_payload),
draftPayload: payload?.result?.draft_payload || null,
reviewPayload: payload?.result?.review_payload || null,
reviewPanelScope: resolveReviewPanelScope({
reviewPayload: payload?.result?.review_payload || null,
reviewAction: reviewActionResult,
fileCount: files.length,
rawText
}),
riskFlags: Array.isArray(payload?.result?.risk_flags) ? payload.result.risk_flags : []
})
replaceMessage(pendingMessage.id, assistantMessage)
currentInsight.value = buildAgentInsight(
const nextInsight = buildAgentInsight(
payload,
effectiveFileNames,
mergeFilePreviews(filePreviews, ocrFilePreviews)
)
if (nextInsight.agent) {
nextInsight.agent.reviewPanelScope = assistantMessage.reviewPanelScope
}
currentInsight.value = nextInsight
completeFlowResult(payload, flowRunDetail)
persistSessionState()
nextTick(scrollToBottom)

View File

@@ -0,0 +1,100 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
buildDetailAlerts,
hasMissingAttachment,
hasPendingInfo
} from '../src/utils/detailAlerts.js'
test('detail topbar ignores system allowance rows when checking missing tickets', () => {
const request = {
node: '直属领导审批',
approvalKey: 'in_progress',
typeCode: 'travel',
typeLabel: '差旅费',
reason: '上海项目出差',
location: '上海',
city: '上海',
occurredDisplay: '2026-05-13 至 2026-05-15',
amountValue: 1008,
profilePosition: '待补充',
profileGrade: '待补充',
profileManager: '待补充',
expenseItems: [
{
id: 'outbound-train',
itemType: 'train_ticket',
itemReason: '广州南-上海虹桥',
itemLocation: '上海',
itemDate: '2026-05-13',
itemAmount: 354,
invoiceId: 'outbound.png'
},
{
id: 'hotel',
itemType: 'hotel_ticket',
itemReason: '上海中心酒店',
itemLocation: '上海',
itemDate: '2026-05-14',
itemAmount: 354,
invoiceId: 'hotel.png'
},
{
id: 'allowance',
itemType: 'travel_allowance',
itemReason: '系统自动计算出差补贴',
itemLocation: '上海',
itemDate: '2026-05-15',
itemAmount: 300,
invoiceId: '',
isSystemGenerated: true
}
]
}
const alerts = buildDetailAlerts(request).map((item) => item.label)
assert.equal(hasMissingAttachment(request), false)
assert.equal(hasPendingInfo(request), false)
assert.deepEqual(alerts, ['直属领导审批'])
})
test('detail topbar still flags real manual rows without required ticket info', () => {
const request = {
node: '待提交',
approvalKey: 'draft',
typeCode: 'travel',
typeLabel: '差旅费',
reason: '待补充',
location: '待补充',
city: '待补充',
occurredDisplay: '待补充',
amountValue: 0,
expenseItems: [
{
id: 'manual-train',
itemType: 'train_ticket',
itemReason: '',
itemLocation: '',
itemDate: '',
itemAmount: 0,
invoiceId: ''
},
{
id: 'allowance',
itemType: 'travel_allowance',
itemReason: '系统自动计算出差补贴',
itemAmount: 300,
invoiceId: '',
isSystemGenerated: true
}
]
}
const alerts = buildDetailAlerts(request).map((item) => item.label)
assert.equal(hasMissingAttachment(request), true)
assert.equal(hasPendingInfo(request), true)
assert.deepEqual(alerts, ['待提交', '缺少票据', '待补信息'])
})

View File

@@ -0,0 +1,102 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
applyArchiveListFilters,
buildArchiveMonthFilterOptions,
buildDepartmentFilterOptions,
buildTypeFilterOptions,
countClaimRisks,
extractArchiveMonth,
formatArchiveMonthLabel,
formatArchiveRiskCountLabel,
hasActiveArchiveListFilters
} from '../src/utils/archiveCenterListFilters.js'
const sampleRows = [
{
id: 'EXP-001',
typeCode: 'travel',
type: '差旅费',
department: '研发部',
archiveMonth: '2026-05',
archiveMonthLabel: '2026年05月',
archiveTab: '差旅报销',
hasRisk: true,
riskTone: 'high',
risk: '2条',
riskCount: 2
},
{
id: 'EXP-002',
typeCode: 'entertainment',
type: '业务招待费',
department: '销售部',
archiveMonth: '2026-04',
archiveMonthLabel: '2026年04月',
archiveTab: '招待报销',
hasRisk: false,
riskTone: 'none',
risk: '0条',
riskCount: 0
}
]
test('countClaimRisks counts flag points and summary fallback', () => {
assert.equal(
countClaimRisks([
{ severity: 'high', points: ['酒店超标', '缺少水单'] },
{ severity: 'info', message: '提示信息' }
], '无'),
2
)
assert.equal(countClaimRisks([], '发票抬头不一致'), 1)
assert.equal(formatArchiveRiskCountLabel(3), '3条')
})
test('countClaimRisks ignores approval opinions and completed flow logs', () => {
assert.equal(
countClaimRisks([
{ source: 'manual_approval', severity: 'info', message: '同意' },
{ source: 'finance_approval', severity: 'info', message: '周晓彤 已完成财务审核,进入归档入账。' }
], '无'),
0
)
assert.equal(countClaimRisks([], '同意'), 0)
assert.equal(countClaimRisks([], '周晓彤 已完成财务审核,进入归档入账。'), 0)
})
test('extractArchiveMonth parses iso timestamps', () => {
assert.equal(extractArchiveMonth('2026-05-20T08:00:00.000Z'), '2026-05')
})
test('applyArchiveListFilters supports department and archive month', () => {
const filtered = applyArchiveListFilters(sampleRows, {
department: '销售部',
archiveMonth: '2026-04'
})
assert.equal(filtered.length, 1)
assert.equal(filtered[0].id, 'EXP-002')
})
test('build filter options are derived from loaded rows', () => {
const typeLabels = buildTypeFilterOptions(sampleRows).map((item) => item.label)
const departmentLabels = buildDepartmentFilterOptions(sampleRows).map((item) => item.label)
const monthOptions = buildArchiveMonthFilterOptions(sampleRows)
assert.equal(typeLabels[0], '全部类型')
assert.ok(typeLabels.includes('差旅费'))
assert.ok(typeLabels.includes('业务招待费'))
assert.equal(departmentLabels[0], '全部部门')
assert.ok(departmentLabels.includes('研发部'))
assert.ok(departmentLabels.includes('销售部'))
assert.equal(formatArchiveMonthLabel('2026-05'), '2026年05月')
assert.equal(monthOptions[0].label, '全部月份')
assert.ok(monthOptions.some((item) => item.value === '2026-05'))
})
test('hasActiveArchiveListFilters detects active criteria', () => {
assert.equal(hasActiveArchiveListFilters({ risk: 'high' }), true)
assert.equal(hasActiveArchiveListFilters({ risk: 'all', type: 'all' }), false)
})

View File

@@ -0,0 +1,130 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import {
clearAssistantSessionSnapshotForDraftClaim,
readAssistantSessionSnapshot,
writeAssistantSessionSnapshot
} from '../src/utils/assistantSessionSnapshot.js'
function installWindowStub() {
const store = new Map()
const events = []
globalThis.CustomEvent = class CustomEvent {
constructor(type, options = {}) {
this.type = type
this.detail = options.detail || {}
}
}
globalThis.window = {
localStorage: {
getItem(key) {
return store.has(key) ? store.get(key) : null
},
setItem(key, value) {
store.set(key, String(value))
},
removeItem(key) {
store.delete(key)
}
},
dispatchEvent(event) {
events.push(event)
return true
}
}
return { events }
}
test('assistant snapshot is cleared only when it belongs to the deleted draft claim', () => {
const { events } = installWindowStub()
writeAssistantSessionSnapshot('emp-1', 'expense', {
draftClaimId: 'claim-1',
messages: [{ role: 'assistant', text: '已保存草稿 EXP-001' }]
})
assert.equal(clearAssistantSessionSnapshotForDraftClaim('emp-1', 'claim-2', 'expense'), false)
assert.equal(readAssistantSessionSnapshot('emp-1', 'expense')?.state?.draftClaimId, 'claim-1')
assert.equal(clearAssistantSessionSnapshotForDraftClaim('emp-1', 'claim-1', 'expense'), true)
assert.equal(readAssistantSessionSnapshot('emp-1', 'expense'), null)
assert.equal(events.at(-1)?.detail?.action, 'clear')
})
test('claim delete flow invalidates the matching financial assistant session', () => {
const appShellScript = readFileSync(
fileURLToPath(new URL('../src/composables/useAppShell.js', import.meta.url)),
'utf8'
)
const appShellRouteView = readFileSync(
fileURLToPath(new URL('../src/views/AppShellRouteView.vue', import.meta.url)),
'utf8'
)
const createViewScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)),
'utf8'
)
assert.match(appShellScript, /clearAssistantSessionSnapshotForDraftClaim/)
assert.match(appShellScript, /async function handleRequestDeleted\(payload = \{\}\)/)
assert.match(appShellScript, /smartEntryInvalidatedDraftClaimId\.value = deletedClaimId/)
assert.match(appShellRouteView, /:invalidated-draft-claim-id="smartEntryInvalidatedDraftClaimId"/)
assert.match(createViewScript, /invalidatedDraftClaimId/)
assert.match(createViewScript, /function clearExpenseSessionForDeletedClaim\(claimId\)/)
assert.match(createViewScript, /toast\('该草稿单据已删除,相关财务助手会话已清空。'\)/)
})
test('saving a draft keeps the financial assistant open for continued work', () => {
const appShellScript = readFileSync(
fileURLToPath(new URL('../src/composables/useAppShell.js', import.meta.url)),
'utf8'
)
const handleDraftSavedBlock = appShellScript.match(
/async function handleDraftSaved\(payload = \{\}\) \{[\s\S]*?\r?\n \}\r?\n\r?\n function openRequestDetail/
)?.[0]
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, /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)
})
test('detail smart entry is scoped to the current claim instead of the latest conversation', () => {
const detailViewScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/TravelRequestDetailView.js', import.meta.url)),
'utf8'
)
const appShellScript = readFileSync(
fileURLToPath(new URL('../src/composables/useAppShell.js', import.meta.url)),
'utf8'
)
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'
)
assert.match(detailViewScript, /restoreLatestConversation:\s*false/)
assert.match(detailViewScript, /scope:\s*claimId[\s\S]*type:\s*'claim'[\s\S]*claimId/)
assert.match(appShellScript, /function isDetailClaimScopedPayload\(payload = \{\}\)/)
assert.match(appShellScript, /if \(isDetailClaimScopedPayload\(payload\)\) \{[\s\S]*return null[\s\S]*\}/)
assert.match(sessionStateScript, /const shouldPersistLocalSnapshot = props\.entrySource !== 'detail'/)
assert.match(sessionStateScript, /if \(!shouldPersistLocalSnapshot\) \{[\s\S]*return[\s\S]*\}/)
assert.match(submitComposerScript, /function resolveDetailScopedClaimId\(\)/)
assert.match(submitComposerScript, /const detailScopedUpload = Boolean\(detailScopedClaimId && files\.length\)/)
assert.match(submitComposerScript, /draft_claim_id: detailScopedClaimId/)
assert.match(submitComposerScript, /detail_scope_claim_id: detailScopedClaimId/)
assert.match(submitComposerScript, /detailScopedUpload/)
})

View File

@@ -1,10 +1,21 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import {
ATTACHMENT_ASSOCIATION_CONFIRM_HREF,
buildAttachmentAssociationConfirmationMessage
buildAttachmentAssociationConfirmationMessage,
buildOcrFilePreviews,
buildReviewFilePreviewsFromReviewPayload
} from '../src/views/scripts/travelReimbursementAttachmentModel.js'
import {
buildDraftAssociationQueryPayload,
buildExpenseQueryHint,
EXPENSE_CENTER_HREF,
normalizeExpenseQueryPayload
} from '../src/views/scripts/travelReimbursementExpenseQueryModel.js'
import { renderMarkdown } from '../src/utils/markdown.js'
test('attachment association prompt prints recognized receipt details before confirmation link', () => {
const message = buildAttachmentAssociationConfirmationMessage({
@@ -26,9 +37,165 @@ test('attachment association prompt prints recognized receipt details before con
})
assert.match(message, /已识别附件信息:/)
assert.match(message, /> \*\*附件 1train-ticket\.pdf\*\*/)
assert.match(message, /附件类型:差旅票据/)
assert.match(message, /行程:武汉-上海/)
assert.match(message, /票价354.00/)
assert.match(message, /草稿单号EXP-202605-001/)
assert.match(message, new RegExp(`\\[确认\\]\\(${ATTACHMENT_ASSOCIATION_CONFIRM_HREF}\\)`))
assert.match(message, new RegExp(`\\n\\n\\n如果 \\*\\*\\[确认\\]\\(${ATTACHMENT_ASSOCIATION_CONFIRM_HREF}\\)\\*\\* 该信息`))
const rendered = renderMarkdown(message)
assert.match(rendered, /<blockquote class="markdown-attachment-card">/)
const questionIndex = rendered.indexOf('请问是否确定将票据信息归集到单据')
const attachmentCardCloseIndex = rendered.indexOf('</blockquote>')
assert.ok(attachmentCardCloseIndex > -1 && questionIndex > attachmentCardCloseIndex)
const attachmentCardHtml = rendered.slice(
rendered.indexOf('<blockquote class="markdown-attachment-card">'),
attachmentCardCloseIndex
)
assert.doesNotMatch(attachmentCardHtml, /请问是否确定将票据信息归集到单据/)
assert.match(rendered, /<p class="markdown-action-paragraph">/)
assert.match(rendered, /<strong><a href="#confirm-attachment-association" class="markdown-action-link markdown-action-link-confirm">确认<\/a><\/strong>/)
})
test('multiple recognized attachments render as separated attachment cards', () => {
const message = buildAttachmentAssociationConfirmationMessage({
claimNo: 'EXP-202605-001',
ocrDocuments: [
{
filename: '2月20 武汉-上海.pdf',
document_type: 'train_ticket',
document_type_label: '火车/高铁票',
document_fields: [
{ key: 'amount', label: '金额', value: '354元' },
{ key: 'route', label: '行程', value: '武汉-上海' }
]
},
{
filename: '2月23 上海-武汉.pdf',
document_type: 'train_ticket',
document_type_label: '火车/高铁票',
document_fields: [
{ key: 'amount', label: '金额', value: '354元' },
{ key: 'route', label: '行程', value: '上海-武汉' }
]
}
]
})
const rendered = renderMarkdown(message)
assert.equal((rendered.match(/class="markdown-attachment-card"/g) || []).length, 2)
assert.match(rendered, /<strong>附件 12月20 武汉-上海\.pdf<\/strong>/)
assert.match(rendered, /<strong>附件 22月23 上海-武汉\.pdf<\/strong>/)
assert.match(rendered, /本次待归集附件2 份/)
})
test('attachment upload association uses conversation selection instead of legacy modal', () => {
const viewSource = readFileSync(
fileURLToPath(new URL('../src/views/TravelReimbursementCreateView.vue', import.meta.url)),
'utf8'
)
const submitComposerSource = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.js', import.meta.url)),
'utf8'
)
assert.doesNotMatch(viewSource, /检测到你已有单据事件|uploadDecisionDialogOpen|continueExistingUpload|createNewUploadDocument/)
assert.doesNotMatch(submitComposerSource, /uploadDecisionDialogOpen|hasExistingDocumentEvent|skipUploadDecisionPrompt/)
assert.doesNotMatch(submitComposerSource, /查询可关联草稿失败,已继续按新单据识别/)
assert.match(
submitComposerSource,
/const claims = await fetchExpenseClaims\(\)[\s\S]*const queryPayload = buildDraftAssociationQueryPayload\(claims\)[\s\S]*meta: \['等待选择关联单据'\][\s\S]*queryPayload/
)
assert.match(submitComposerSource, /meta: \['单据查询失败'\][\s\S]*return null/)
assert.match(
submitComposerSource,
/files\.length[\s\S]*!resolvedUploadDisposition[\s\S]*!options\.skipDraftAssociationPrompt[\s\S]*!reviewAction/
)
})
test('OCR preview builders keep hotel receipt image previews when preview kind is omitted', () => {
const dataUrl = 'data:image/png;base64,abc123'
const ocrPreviews = buildOcrFilePreviews({
documents: [
{
filename: 'hotel.png',
preview_data_url: dataUrl
}
]
})
const reviewPreviews = buildReviewFilePreviewsFromReviewPayload({
document_cards: [
{
filename: 'hotel.png',
preview_url: dataUrl
}
]
})
assert.deepEqual(ocrPreviews, [{ filename: 'hotel.png', kind: 'image', url: dataUrl }])
assert.deepEqual(reviewPreviews, [{ filename: 'hotel.png', kind: 'image', url: dataUrl }])
})
test('draft association query keeps a single candidate selectable in the conversation', () => {
const payload = buildDraftAssociationQueryPayload([
{
id: 'claim-1',
claim_no: 'EXP-202605-001',
status: 'draft',
expense_type: 'travel',
reason: '上海出差',
amount: 1280
}
])
assert.equal(payload.selectionMode, 'draft_association')
assert.equal(payload.title, '选择关联草稿')
assert.equal(payload.records.length, 1)
assert.equal(payload.records[0].claimId, 'claim-1')
})
test('expense query payload keeps structured risk items for claim-level risk drilldown', () => {
const payload = normalizeExpenseQueryPayload({
result_type: 'expense_claim_list',
records: [
{
claim_id: 'claim-risk',
claim_no: 'EXP-202605-009',
amount: 880,
risk_flags: [
{
key: 'hotel-limit',
level: 'high',
level_label: '高风险',
title: '酒店超标',
summary: '住宿金额超过城市标准',
detail: '上海 P5 住宿标准为 600 元,本次 880 元。'
}
]
}
]
})
assert.equal(payload.records[0].riskItems.length, 1)
assert.equal(payload.records[0].riskItems[0].levelLabel, '高风险')
assert.equal(payload.records[0].riskItems[0].summary, '住宿金额超过城市标准')
})
test('expense query hint guides users to the reimbursement center after the top five results', () => {
const payload = normalizeExpenseQueryPayload({
result_type: 'expense_claim_list',
title: '最近 5 条你的归档报销单',
scope_label: '你的归档报销单',
record_count: 8,
preview_count: 5,
preview_limit: 5,
records: [
{ claim_id: 'claim-1', claim_no: 'EXP-1', amount: 100 }
]
})
const hint = buildExpenseQueryHint(payload)
assert.match(hint, /最近的 5 条记录/)
assert.match(hint, new RegExp(`\\[\\*\\*这里\\*\\*\\]\\(${EXPENSE_CENTER_HREF}\\)`))
})

View File

@@ -0,0 +1,34 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import { isArchivedExpenseClaim } from '../src/utils/expenseClaimArchive.js'
test('isArchivedExpenseClaim recognizes finance archive stage', () => {
assert.equal(
isArchivedExpenseClaim({ status: 'approved', approval_stage: '归档入账' }),
true
)
})
test('isArchivedExpenseClaim ignores in-progress claims', () => {
assert.equal(
isArchivedExpenseClaim({ status: 'submitted', approval_stage: '财务审批' }),
false
)
})
test('archive center is wired into navigation and api client', () => {
const navigationScript = readFileSync(
fileURLToPath(new URL('../src/composables/useNavigation.js', import.meta.url)),
'utf8'
)
const reimbursementsService = readFileSync(
fileURLToPath(new URL('../src/services/reimbursements.js', import.meta.url)),
'utf8'
)
assert.match(navigationScript, /id:\s*'archive'/)
assert.match(reimbursementsService, /\/reimbursements\/claims\/archives/)
})

View File

@@ -1,6 +1,9 @@
import assert from 'node:assert/strict'
import { resolveInitialKnowledgeFolder } from '../src/views/scripts/knowledgeFolderSelection.js'
import {
resolveInitialKnowledgeFolder,
resolveKnowledgeFolderIcon
} from '../src/views/scripts/knowledgeFolderSelection.js'
function testFallsBackToFirstFolderWhenCurrentFolderDoesNotExist() {
const folders = [{ name: '财务知识库' }, { name: '制度政策' }, { name: '差旅规范' }]
@@ -18,10 +21,22 @@ function testReturnsEmptyStringWhenFoldersAreEmpty() {
assert.equal(resolveInitialKnowledgeFolder([], '差旅规范'), '')
}
function testUsesOpenIconForActiveFolderOnly() {
assert.equal(
resolveKnowledgeFolderIcon({ name: '差旅规范', icon: 'mdi mdi-folder' }, '差旅规范'),
'mdi mdi-folder-open'
)
assert.equal(
resolveKnowledgeFolderIcon({ name: '制度政策', icon: 'mdi mdi-folder-open' }, '差旅规范'),
'mdi mdi-folder'
)
}
function run() {
testFallsBackToFirstFolderWhenCurrentFolderDoesNotExist()
testKeepsCurrentFolderWhenItStillExists()
testReturnsEmptyStringWhenFoldersAreEmpty()
testUsesOpenIconForActiveFolderOnly()
console.log('knowledge folder selection tests passed')
}

View File

@@ -41,6 +41,7 @@ test('progress steps show approval operator time and current stay duration', ()
const aiStep = request.progressSteps.find((step) => step.label === 'AI预审')
const firstStep = request.progressSteps[0]
assert.equal(request.riskSummary, '无')
assert.equal(firstStep.label, '创建单据')
assert.equal(leaderStep.time, '李经理通过')
assert.match(leaderStep.detail, /2026-05-20/)
@@ -159,10 +160,57 @@ test('travel expense items describe departure return and lodging time below the
assert.equal(request.expenseItems.find((item) => item.id === 'outbound-train')?.dayLabel, '出发时间')
assert.equal(request.expenseItems.find((item) => item.id === 'return-train')?.dayLabel, '返回时间')
assert.equal(request.expenseItems.find((item) => item.id === 'hotel')?.dayLabel, '住宿时间')
assert.equal(request.expenseItems.find((item) => item.id === 'outbound-train')?.detail, '起始地-目的地')
assert.equal(request.expenseItems.find((item) => item.id === 'return-train')?.detail, '起始地-目的地')
assert.equal(request.expenseItems.find((item) => item.id === 'hotel')?.detail, '目的地酒店')
assert.equal(request.expenseItems.at(-1)?.id, 'allowance')
assert.equal(request.expenseItems.at(-1)?.dayLabel, '系统自动计算')
})
test('ticket description helper does not show the destination city as detail text', () => {
const request = mapExpenseClaimToRequest({
id: 'claim-ticket-detail-helper',
claim_no: 'EXP-202605-ROUTE',
employee_name: '张三',
department_name: '市场部',
expense_type: 'travel',
reason: '上海项目出差',
location: '上海',
amount: 520,
invoice_count: 2,
occurred_at: '2026-05-13T01:00:00.000Z',
created_at: '2026-05-13T01:30:00.000Z',
updated_at: '2026-05-13T03:30:00.000Z',
status: 'draft',
approval_stage: '待提交',
risk_flags_json: [],
items: [
{
id: 'flight',
item_type: 'flight_ticket',
item_reason: '广州白云-上海虹桥',
item_location: '上海',
item_date: '2026-05-13',
item_amount: 320,
invoice_id: 'flight.png'
},
{
id: 'ship',
item_type: 'ship_ticket',
item_reason: '上海港-舟山港',
item_location: '舟山',
item_date: '2026-05-14',
item_amount: 200,
invoice_id: 'ship.png'
}
]
})
assert.equal(request.expenseItems.find((item) => item.id === 'flight')?.detail, '起始地-目的地')
assert.equal(request.expenseItems.find((item) => item.id === 'ship')?.detail, '起始地-目的地')
assert.equal(request.expenseItems.find((item) => item.id === 'ship')?.name, '轮船票')
})
test('completed finance approval marks finance and archive progress steps', () => {
const request = mapExpenseClaimToRequest({
id: 'claim-finance-completed',
@@ -202,6 +250,7 @@ test('completed finance approval marks finance and archive progress steps', () =
const financeStep = request.progressSteps.find((step) => step.label === '财务审批')
const archiveStep = request.progressSteps.find((step) => step.label === '归档入账')
assert.equal(request.riskSummary, '无')
assert.equal(request.workflowNode, '归档入账')
assert.equal(financeStep.time, '财务复核通过')
assert.match(financeStep.detail, /2026-05-20/)

View File

@@ -0,0 +1,40 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import {
buildStructuredComposerSubmitText
} from '../src/views/scripts/useTravelReimbursementComposerTools.js'
const submitComposerScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.js', import.meta.url)),
'utf8'
)
test('composer formats date-picker expense text into readable structured fields', () => {
const formatted = buildStructuredComposerSubmitText(
'发生时间2026-05-20 至 2026-05-23去上海支撑上海国电的服务器部署出差3天',
{
mode: 'range',
start_date: '2026-05-20',
end_date: '2026-05-23',
business_time: '2026-05-20 至 2026-05-23'
}
)
assert.equal(
formatted,
[
'发生时间2026-05-20 至 2026-05-23',
'地点:上海',
'事由:支撑上海国电的服务器部署',
'天数3天'
].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

@@ -3,6 +3,8 @@ import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import { buildReviewPlainFollowupCopy } from '../src/views/scripts/travelReimbursementReviewModel.js'
const createViewTemplate = readFileSync(
fileURLToPath(new URL('../src/views/TravelReimbursementCreateView.vue', import.meta.url)),
'utf8'
@@ -19,6 +21,10 @@ const reviewActionsScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementReviewActions.js', import.meta.url)),
'utf8'
)
const reviewDrawerScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementReviewDrawer.js', import.meta.url)),
'utf8'
)
const submitComposerScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.js', import.meta.url)),
'utf8'
@@ -29,7 +35,7 @@ const attachmentsScript = readFileSync(
)
test('review drawer tools expose the default review tab before conditional document and risk tabs', () => {
assert.match(createViewTemplate, /title="报销识别核对"[\s\S]*@click="switchToReviewOverviewDrawer"/)
assert.match(createViewTemplate, /v-if="activeReviewPayload && reviewOverviewDrawerAvailable"[\s\S]*title="报销识别核对"[\s\S]*@click="switchToReviewOverviewDrawer"/)
assert.match(createViewTemplate, /v-if="activeReviewPayload && reviewDocumentDrawerAvailable"[\s\S]*title="单据识别"/)
assert.match(createViewTemplate, /v-if="activeReviewPayload && reviewRiskDrawerAvailable"[\s\S]*title="显示风险"/)
assert.match(createViewTemplate, /title="调用流程"/)
@@ -91,13 +97,22 @@ test('review risk drawer lists risk briefs without score and posts details into
createViewScript,
/function appendReviewRiskBriefToConversation\(item\) \{[\s\S]*messages\.value\.push\(createMessage\('assistant'/
)
assert.match(createViewScript, /function buildReviewRiskConversationText\(item, detailTarget = \{\}\)/)
assert.match(createViewScript, /function resolveReviewRiskDetailTarget\(\) \{[\s\S]*router\.resolve\(\{[\s\S]*name: 'app-request-detail'/)
assert.match(createViewScript, /进入 \$\{claimNo\} 详情重新填写/)
assert.match(createViewTemplate, /class="expense-query-risk-row"[\s\S]*appendExpenseQueryRiskToConversation\(record, risk\)/)
assert.match(createViewScript, /function appendExpenseQueryRiskToConversation\(record, risk\) \{[\s\S]*进入 \$\{claimNo\} 详情重新填写/)
})
test('review payload with risks opens risk drawer and travel overview uses travel-specific fields', () => {
assert.match(
createViewScript,
/reviewDrawerMode\.value = resolveReviewRiskBriefs\(payload\)\.length[\s\S]*\? REVIEW_DRAWER_MODE_RISK[\s\S]*: REVIEW_DRAWER_MODE_REVIEW/
)
test('review drawer default mode is scoped by the current action and travel overview uses travel-specific fields', () => {
assert.match(reviewDrawerScript, /activeReviewPanelScope/)
assert.match(reviewDrawerScript, /const reviewOverviewDrawerAvailable = computed\(\(\) => normalizedReviewPanelScope\.value === 'overview'\)/)
assert.match(reviewDrawerScript, /scope === 'documents' && hasDocuments[\s\S]*REVIEW_DRAWER_MODE_DOCUMENTS/)
assert.match(reviewDrawerScript, /scope === 'risk' && hasRisks[\s\S]*REVIEW_DRAWER_MODE_RISK/)
assert.match(reviewDrawerScript, /scope === 'overview'[\s\S]*REVIEW_DRAWER_MODE_REVIEW/)
assert.match(createViewScript, /function normalizeReviewPanelScope\(scope\)/)
assert.match(createViewScript, /canExposeReviewPanelScope\(item\.reviewPanelScope\)/)
assert.match(createViewScript, /currentInsight\.value\.intent === 'agent' && agent[\s\S]*return null/)
assert.match(createViewScript, /function isTravelReviewPayload\(reviewPayload/)
assert.match(createViewScript, /function resolveReviewTravelTransportType\(reviewPayload/)
assert.match(createViewScript, /label: '交通类型'[\s\S]*modelKey: 'transport_type'/)
@@ -107,6 +122,51 @@ test('review payload with risks opens risk drawer and travel overview uses trave
assert.match(createViewTemplate, /wide: item\.wide/)
})
test('submit composer scopes the side panel to intent overview, document upload, or triggered risk only', () => {
assert.match(submitComposerScript, /function resolveReviewPanelScope\(\{[\s\S]*reviewPayload = null/)
assert.match(submitComposerScript, /fileCount > 0 && documentCount > 0[\s\S]*return 'documents'/)
assert.match(submitComposerScript, /riskCount > 0 && \(asksRisk \|\| \['next_step', 'submit', 'submit_claim'\]\.includes\(normalizedAction\)\)[\s\S]*return 'risk'/)
assert.match(submitComposerScript, /!normalizedAction && fileCount === 0[\s\S]*return 'overview'/)
assert.match(submitComposerScript, /reviewPanelScope: resolveReviewPanelScope\(\{/)
assert.match(submitComposerScript, /nextInsight\.agent\.reviewPanelScope = assistantMessage\.reviewPanelScope/)
})
test('expense query answers keep one clear result structure with reimbursement center jump link', () => {
assert.match(createViewTemplate, /!message\.reviewPayload && !message\.queryPayload && message\.meta\?\.length/)
assert.match(createViewTemplate, /!message\.reviewPayload && !message\.queryPayload && message\.suggestedActions\?\.length/)
assert.match(createViewTemplate, /!message\.reviewPayload && !message\.queryPayload && message\.citations\?\.length/)
assert.match(createViewTemplate, /message\.queryPayload\.title \|\| \(message\.queryPayload\.selectionMode === 'draft_association' \? '选择关联草稿' : '最近 5 条筛选结果'\)/)
assert.match(createViewTemplate, /v-html="renderMarkdown\(buildExpenseQueryHint\(message\.queryPayload\)\)"/)
assert.match(createViewScript, /href\.startsWith\('\/app\/'\)[\s\S]*router\.push\(href\)/)
})
test('backend query response suppresses generic query actions and supports archived filter title', () => {
const responseScript = readFileSync(
fileURLToPath(new URL('../../server/src/app/services/user_agent_response.py', import.meta.url)),
'utf8'
)
const queryScript = readFileSync(
fileURLToPath(new URL('../../server/src/app/services/orchestrator_expense_query.py', import.meta.url)),
'utf8'
)
assert.match(responseScript, /if payload\.ontology\.intent in \{"query", "compare"\}:[\s\S]*return \[\]/)
assert.match(responseScript, /下面先列出最近 \{query_payload\.preview_count\} 条记录/)
assert.match(queryScript, /EXPENSE_QUERY_PREVIEW_LIMIT = 5/)
assert.match(queryScript, /"归档"[\s\S]*"archived"/)
assert.match(queryScript, /ExpenseClaim\.approval_stage\.ilike\("%归档%"\)/)
assert.match(queryScript, /"title": f"最近 \{len\(preview_claims\)\} 条\{scope_label\}"/)
})
test('closing the assistant while OCR is running defers unmount until the current flow finishes', () => {
assert.match(createViewScript, /const closeAfterBusy = ref\(false\)/)
assert.match(createViewScript, /function isWorkbenchBusy\(\) \{[\s\S]*submitting\.value \|\| reviewActionBusy\.value \|\| sessionSwitchBusy\.value/)
assert.match(createViewScript, /function maybeFinalizeDeferredClose\(\) \{[\s\S]*!closeAfterBusy\.value \|\| workbenchVisible\.value \|\| isWorkbenchBusy\(\)/)
assert.match(createViewScript, /function requestCloseWorkbench\(\) \{[\s\S]*closeAfterBusy\.value = isWorkbenchBusy\(\)[\s\S]*workbenchVisible\.value = false/)
assert.match(createViewScript, /function emitCloseAfterLeave\(\) \{[\s\S]*closeAfterBusy\.value && isWorkbenchBusy\(\)[\s\S]*return/)
assert.match(createViewScript, /\[submitting\.value, reviewActionBusy\.value, sessionSwitchBusy\.value, workbenchVisible\.value\][\s\S]*maybeFinalizeDeferredClose\(\)/)
})
test('composer exposes travel calculator and posts spreadsheet-backed result into conversation', () => {
assert.match(createViewTemplate, /class="tool-btn composer-side-btn travel-calculator-trigger"[\s\S]*差旅计算器/)
assert.match(createViewTemplate, /class="travel-calculator-popover"[\s\S]*v-model="travelCalculatorForm\.days"[\s\S]*v-model="travelCalculatorForm\.location"/)
@@ -188,3 +248,26 @@ test('review summary renders markdown and save draft relies on backend response
/messages\.value\.push\(\s*createMessage\('assistant', actionConfig\.successMessage/
)
})
test('saved draft review messages stop showing the save-draft prompt', () => {
const reviewPayload = {
slot_cards: [
{ key: 'amount', label: '金额', title: '金额', status: 'missing', required: true },
{ key: 'attachments', label: '票据状态', title: '票据状态', status: 'missing', required: true }
],
missing_slots: ['金额', '票据附件'],
risk_briefs: [],
confirmation_actions: [
{ label: '保存为草稿', action_type: 'save_draft' }
]
}
const followup = buildReviewPlainFollowupCopy(reviewPayload, { savedDraft: true })
assert.equal(followup.lead, '补充信息:')
assert.match(followup.summary, /草稿/)
assert.match(followup.summary, /关联|补充|提交/)
assert.doesNotMatch(followup.summary, /点击|点“草稿”|保存为草稿|临时保存|暂存/)
assert.match(createViewTemplate, /buildReviewPlainFollowupForMessage\(message\)/)
assert.match(createViewScript, /function isDraftSavedReviewMessage\(message\)/)
assert.match(createViewScript, /function canUseInlineSaveDraft\(message\)[\s\S]*isDraftSavedReviewMessage\(message\)/)
})

View File

@@ -7,11 +7,14 @@ import {
buildAiAdviceViewModel,
buildAttachmentInsightViewModel,
buildAttachmentRiskCards,
buildClaimSummaryRiskCards,
buildItemClaimRiskState,
extractRiskTagsFromText,
resolveRiskTags,
resolveRiskTagTone
} from '../src/views/scripts/travelRequestDetailInsights.js'
import {
buildExpenseItemViewModel,
buildDraftBlockingIssues
} from '../src/views/scripts/travelRequestDetailExpenseModel.js'
@@ -148,6 +151,124 @@ test('AI advice splits claim attachment risk flags into specific points', () =>
assert.ok(riskCards[0].ruleBasis.some((item) => item.includes('风险汇总')))
})
test('AI advice keeps visible risk flags when backend uses tone instead of severity', () => {
const riskCards = buildAttachmentRiskCards({
claimRiskFlags: [
{
source: 'submission_review',
tone: 'medium',
label: '中风险',
message: '直属领导缺失,当前单据需审批环节补充分配。'
}
]
})
assert.equal(riskCards.length, 1)
assert.equal(riskCards[0].tone, 'medium')
assert.equal(riskCards[0].risk, '直属领导缺失,当前单据需审批环节补充分配。')
assert.ok(riskCards[0].ruleBasis.some((item) => item.includes('审批链校验')))
assert.ok(riskCards[0].suggestion.includes('员工档案'))
})
test('AI advice falls back to claim risk summary instead of showing an empty risk area', () => {
const riskCards = buildClaimSummaryRiskCards({
riskSummary: 'AI预审发现 1 条中风险附件,已随单流转给审批人复核。'
})
assert.equal(riskCards.length, 1)
assert.equal(riskCards[0].tone, 'medium')
assert.equal(riskCards[0].label, '中风险')
assert.match(riskCards[0].risk, /中风险附件/)
assert.ok(riskCards[0].ruleBasis.some((item) => item.includes('风险汇总')))
assert.ok(riskCards[0].suggestion.includes('附件预览'))
})
test('AI advice ignores approval opinions and flow logs as risks', () => {
const riskCards = buildAttachmentRiskCards({
claimRiskFlags: [
{
source: 'manual_approval',
severity: 'info',
label: '领导审批通过',
message: '同意'
},
{
source: 'finance_approval',
severity: 'info',
label: '财务审核通过',
message: '周晓彤 已完成财务审核,进入归档入账。'
}
]
})
assert.deepEqual(riskCards, [])
assert.deepEqual(buildClaimSummaryRiskCards({ riskSummary: '同意' }), [])
assert.deepEqual(buildClaimSummaryRiskCards({ riskSummary: '周晓彤 已完成财务审核,进入归档入账。' }), [])
})
test('expense row risk state falls back to claim item risk flags', () => {
const state = buildItemClaimRiskState(
{
id: 'hotel-item',
name: '住宿费'
},
[
{
source: 'attachment_analysis',
item_id: 'hotel-item',
severity: 'high',
label: '高风险',
message: '费用明细第 2 条:住宿标准:当前酒店识别金额约 880.00 元/晚。',
summary: '当前住宿票据金额超过规则中心差旅住宿标准。',
points: ['住宿标准:当前酒店识别金额约 880.00 元/晚。']
}
]
)
assert.equal(state.tone, 'high')
assert.equal(state.label, '高风险')
assert.match(state.summary, /住宿票据金额超过/)
assert.deepEqual(state.points, ['住宿标准:当前酒店识别金额约 880.00 元/晚。'])
})
test('attachment risk cards do not duplicate claim fallback flags for the same item', () => {
const riskCards = buildAttachmentRiskCards({
expenseItems: [
{
id: 'hotel-item',
name: '住宿费',
invoiceId: 'hotel-risk.png'
}
],
attachmentMetaByItemId: {
'hotel-item': {
analysis: {
severity: 'high',
label: '高风险',
headline: 'AI提示住宿金额超出报销标准',
summary: '当前住宿票据金额超过规则中心差旅住宿标准。',
points: ['住宿标准:当前酒店识别金额约 880.00 元/晚。'],
suggestion: '请补充超标说明。'
}
}
},
claimRiskFlags: [
{
source: 'attachment_analysis',
item_id: 'hotel-item',
severity: 'high',
label: '高风险',
message: '费用明细第 1 条:住宿标准:当前酒店识别金额约 880.00 元/晚。',
summary: '当前住宿票据金额超过规则中心差旅住宿标准。',
points: ['住宿标准:当前酒店识别金额约 880.00 元/晚。']
}
]
})
assert.equal(riskCards.length, 1)
assert.equal(riskCards[0].risk, '住宿标准:当前酒店识别金额约 880.00 元/晚。')
})
test('AI advice view model exposes grouped completion and risk sections', () => {
const advice = buildAiAdviceViewModel({
completionItems: ['补充业务地点', '补充报销金额'],
@@ -207,6 +328,11 @@ test('AI advice view model omits empty sections', () => {
})
test('AI advice template renders grouped section titles with completion before risk', () => {
assert.match(detailViewTemplate, /v-if="showAiAdvicePanel" class="detail-card panel validation-card"/)
assert.match(detailViewTemplate, /<h3>\{\{ aiAdviceTitle \}\}<\/h3>/)
assert.match(detailViewTemplate, /<p>\{\{ aiAdviceHint \}\}<\/p>/)
assert.match(detailViewScript, /buildClaimSummaryRiskCards\(request\.value\)/)
assert.match(detailViewScript, /const showAiAdvicePanel = computed\(\(\) => isEditableRequest\.value \|\| aiAdvice\.value\.riskCards\.length > 0\)/)
assert.match(detailViewTemplate, /v-if="aiAdvice\.sections\.length" class="validation-sections"/)
assert.match(detailViewTemplate, /v-for="section in aiAdvice\.sections"/)
assert.match(detailViewTemplate, /validation-section--\$\{section\.kind\}/)
@@ -220,13 +346,15 @@ test('AI advice template renders grouped section titles with completion before r
test('AI advice risk section uses compact card styling hooks', () => {
assert.match(detailViewTemplate, /class="\['risk-advice-card', card\.tone\]"/)
assert.match(detailViewTemplate, /v-if="card\.tags\?\.length" class="risk-card-tag-list"/)
assert.doesNotMatch(detailViewTemplate, /card\.tags\?\.length/)
assert.doesNotMatch(detailViewTemplate, /risk-card-tag-list/)
assert.doesNotMatch(detailViewTemplate, /risk-note-tag/)
assert.match(detailViewScript, /tags: resolveRiskTags\(card\)/)
assert.match(detailViewStyle, /\.validation-card \{\s*border: 1px solid #e5e7eb;/)
assert.match(detailViewStyle, /\.validation-section--risk \.risk-advice-card \{\s*display: grid;\s*gap: 8px;\s*padding: 12px 12px 11px;/)
assert.match(detailViewStyle, /\.validation-section--risk \.risk-advice-card\.low/)
assert.match(detailViewStyle, /\.risk-advice-card\.low/)
assert.match(detailViewStyle, /\.risk-note-tag\.high/)
assert.match(detailViewStyle, /\.risk-note-tag\.hotel/)
assert.doesNotMatch(detailViewStyle, /\.risk-note-tag/)
assert.match(detailViewStyle, /\.validation-section--risk \.risk-advice-meta ul,\s*\.validation-section--risk \.risk-advice-meta p \{\s*margin: 0;/)
})
@@ -235,6 +363,7 @@ test('expense rows show a major-risk warning icon before time', () => {
assert.match(detailViewTemplate, /class="mdi mdi-alert expense-risk-indicator"/)
assert.match(detailViewStyle, /\.expense-risk-indicator \{/)
assert.match(detailViewScript, /function isMajorExpenseRisk\(item\)/)
assert.match(detailViewScript, /buildItemClaimRiskState\(item, resolveClaimRiskFlags\(\)\)/)
})
test('AI advice shows only the latest manual return while preserving return count context', () => {
@@ -296,10 +425,12 @@ test('additional note is shown above expense details as travel purpose text', ()
assert.match(detailViewTemplate, /用于说明本次出差或办事目的/)
assert.match(detailViewTemplate, /v-if="canEditDetailNote" class="detail-note-editor"/)
assert.match(detailViewTemplate, /v-else class="detail-note readonly"/)
assert.match(detailViewTemplate, /v-model="detailNoteEditor"/)
assert.match(detailViewTemplate, /v-model="detailNoteEditorView"/)
assert.match(detailViewTemplate, /提交后将作为明确说明展示/)
assert.match(detailViewScript, /const canEditDetailNote = computed\(\(\) => isDraftRequest\.value\)/)
assert.match(detailViewScript, /function normalizeDetailNoteDraftValue\(value\)/)
assert.match(detailViewScript, /function stripRiskTagsForDisplay\(value\)/)
assert.match(detailViewScript, /function mergeVisibleNoteWithHiddenTags\(visibleText, rawText\)/)
assert.match(detailViewScript, /const detailNoteSource = computed\(\(\) => normalizeDetailNoteDraftValue\(request\.value\.note\)\)/)
assert.match(detailViewScript, /updateExpenseClaim\(request\.value\.claimId/)
assert.match(detailViewScript, /emit\('request-updated', \{ claimId: request\.value\.claimId \}\)/)
@@ -337,8 +468,8 @@ test('travel item date caption distinguishes departure return and trip events',
})
test('expense detail table shows each item filled time from item creation time', () => {
assert.match(detailViewTemplate, /<th class="col-filled-at">填写时间<\/th>/)
assert.match(detailViewTemplate, /<td class="expense-filled-at col-filled-at">[\s\S]*\{\{ item\.filledAt \}\}/)
assert.match(detailViewTemplate, /<th class="col-filled-at">填写时间<\/th>[\s\S]*<th class="col-time">发生时间<\/th>/)
assert.match(detailViewTemplate, /<td class="expense-filled-at col-filled-at">[\s\S]*\{\{ item\.filledAt \}\}[\s\S]*<td :class="\['expense-time col-time'/)
assert.match(detailViewTemplate, /<span>条款填写时间<\/span>/)
assert.match(detailViewScript, /function formatExpenseFilledTime\(value\)/)
assert.match(detailViewScript, /source\?\.filledAt[\s\S]*source\?\.created_at/)
@@ -439,6 +570,35 @@ test('draft submit validation uses expense detail date and amount when claim sum
})
test('transport ticket descriptions use route format and invalid format becomes risk advice', () => {
const routeItem = buildExpenseItemViewModel(
{
id: 'route-item',
itemType: 'train_ticket',
itemReason: '广州南-上海虹桥',
itemLocation: '上海',
itemAmount: 354,
invoiceId: 'train-ticket.png'
},
0,
{ claimId: 'claim-route', detailVariant: 'travel' }
)
const shipItem = buildExpenseItemViewModel(
{
id: 'ship-item',
itemType: 'ship_ticket',
itemReason: '上海港-舟山港',
itemLocation: '舟山',
itemAmount: 120,
invoiceId: 'ship-ticket.png'
},
1,
{ claimId: 'claim-route', detailVariant: 'travel' }
)
assert.equal(routeItem.desc, '广州南-上海虹桥')
assert.equal(routeItem.detail, '起始地-目的地')
assert.equal(shipItem.name, '轮船票')
assert.equal(shipItem.detail, '起始地-目的地')
assert.match(detailViewScript, /const ROUTE_DESCRIPTION_EXPENSE_TYPES = new Set\(\['train_ticket', 'flight_ticket', 'ship_ticket', 'ferry_ticket', 'ride_ticket'\]\)/)
assert.match(detailViewScript, /const HOTEL_DESCRIPTION_EXPENSE_TYPES = new Set\(\['hotel_ticket'\]\)/)
assert.match(detailViewScript, /const ROUTE_DESCRIPTION_PATTERN = \/\^\[A-Za-z0-9\\u4e00-\\u9fa5/)