feat: 新增归档中心页面并完善知识库与报销查询能力
新增前端归档中心视图及相关工具函数,扩充知识库文档分类和 提取器支持多种格式,增强编排器报销查询的多维度检索,优 化本体规则和用户代理审核消息,前端完善报销创建和审批详 情交互细节,补充单元测试覆盖。
This commit is contained in:
@@ -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,
|
||||
|
||||
54
web/src/assets/styles/views/archive-center-view.css
Normal file
54
web/src/assets/styles/views/archive-center-view.css
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -453,10 +453,6 @@
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.review-upload-decision-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.primary-dialog-btn,
|
||||
.secondary-dialog-btn,
|
||||
.danger-dialog-btn {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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%);
|
||||
|
||||
@@ -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: '日志管理' },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"/>'),
|
||||
|
||||
@@ -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())}`)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
216
web/src/utils/archiveCenterListFilters.js
Normal file
216
web/src/utils/archiveCenterListFilters.js
Normal 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()
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
114
web/src/utils/detailAlerts.js
Normal file
114
web/src/utils/detailAlerts.js
Normal 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)
|
||||
}
|
||||
14
web/src/utils/expenseClaimArchive.js
Normal file
14
web/src/utils/expenseClaimArchive.js
Normal 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'
|
||||
}
|
||||
@@ -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
107
web/src/utils/riskFlags.js
Normal 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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
141
web/src/views/ArchiveCenterView.vue
Normal file
141
web/src/views/ArchiveCenterView.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
|
||||
313
web/src/views/scripts/ArchiveCenterView.js
Normal file
313
web/src/views/scripts/ArchiveCenterView.js
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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('。')
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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: []
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
100
web/tests/app-shell-detail-alerts.test.mjs
Normal file
100
web/tests/app-shell-detail-alerts.test.mjs
Normal 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, ['待提交', '缺少票据', '待补信息'])
|
||||
})
|
||||
102
web/tests/archive-center-list-filters.test.mjs
Normal file
102
web/tests/archive-center-list-filters.test.mjs
Normal 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)
|
||||
})
|
||||
130
web/tests/assistant-session-draft-delete.test.mjs
Normal file
130
web/tests/assistant-session-draft-delete.test.mjs
Normal 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/)
|
||||
})
|
||||
@@ -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, /> \*\*附件 1:train-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>附件 1:2月20 武汉-上海\.pdf<\/strong>/)
|
||||
assert.match(rendered, /<strong>附件 2:2月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}\\)`))
|
||||
})
|
||||
|
||||
34
web/tests/expense-claim-archive.test.mjs
Normal file
34
web/tests/expense-claim-archive.test.mjs
Normal 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/)
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
|
||||
@@ -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/)
|
||||
|
||||
40
web/tests/travel-reimbursement-composer-tools.test.mjs
Normal file
40
web/tests/travel-reimbursement-composer-tools.test.mjs
Normal 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\)/)
|
||||
})
|
||||
@@ -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\)/)
|
||||
})
|
||||
|
||||
@@ -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/)
|
||||
|
||||
Reference in New Issue
Block a user