diff --git a/web/src/composables/workbenchAiMode/useWorkbenchAiDocumentQueryFlow.js b/web/src/composables/workbenchAiMode/useWorkbenchAiDocumentQueryFlow.js index 6203c83..4e36730 100644 --- a/web/src/composables/workbenchAiMode/useWorkbenchAiDocumentQueryFlow.js +++ b/web/src/composables/workbenchAiMode/useWorkbenchAiDocumentQueryFlow.js @@ -2,6 +2,7 @@ import { nextTick } from 'vue' import { buildAiDocumentQueryConditionSummary, + buildAiDocumentQueryThinkingEvents, buildAiDocumentQueryMessage, filterAiDocumentQueryRecords, mergeAiDocumentQueryPayloads, @@ -100,33 +101,20 @@ export function useWorkbenchAiDocumentQueryFlow({ await nextTick() } - async function handleAiDocumentQueryIntent(prompt, pendingMessage) { + async function handleAiDocumentQueryIntent(prompt, pendingMessage, options = {}) { const intent = resolveAiDocumentQueryIntent(prompt) if (!intent) { return false } const conditionSummary = buildAiDocumentQueryConditionSummary(intent) - let thinkingEvents = [ - { - eventId: 'document-query-parse', - title: '解析自然语言筛选条件', - content: `正在从您的问题里提取查询来源、单据类型、时间、状态、费用类型、关键词和金额条件。当前识别:${conditionSummary}。`, - status: 'running' - }, - { - eventId: 'document-query-fetch', - title: '查询业务单据接口', - content: resolveAiDocumentQueryFetchPendingText(intent), - status: 'pending' - }, - { - eventId: 'document-query-filter', - title: '组合筛选单据', - content: '等待接口返回后,再按已识别条件做二次筛选。', - status: 'pending' - } - ] + let thinkingEvents = buildAiDocumentQueryThinkingEvents(intent, { + commandFrame: options.commandFrame + }).map((event) => ( + event.eventId === 'document-query-fetch' + ? { ...event, content: resolveAiDocumentQueryFetchPendingText(intent) } + : event + )) await updateAiDocumentQueryThinking(pendingMessage, thinkingEvents) await waitForAiDocumentQueryStep() @@ -163,7 +151,9 @@ export function useWorkbenchAiDocumentQueryFlow({ await updateAiDocumentQueryThinking(pendingMessage, thinkingEvents) await waitForAiDocumentQueryStep() - const finalMessageText = buildAiDocumentQueryMessage(intent, payload) + const finalMessageText = buildAiDocumentQueryMessage(intent, payload, { + commandFrame: options.commandFrame + }) thinkingEvents = completeAiDocumentQueryEvent( thinkingEvents, 'document-query-filter', diff --git a/web/src/utils/aiConversationHtmlRenderer.js b/web/src/utils/aiConversationHtmlRenderer.js index 339caf3..af5ca7e 100644 --- a/web/src/utils/aiConversationHtmlRenderer.js +++ b/web/src/utils/aiConversationHtmlRenderer.js @@ -9,6 +9,7 @@ import { const APPLICATION_DETAIL_HREF_PREFIX = '#ai-open-application-detail:' const DELETED_APPLICATION_DETAIL_HREF_PREFIX = '#ai-deleted-application-detail:' +const DELETED_DOCUMENT_DETAIL_HREF_PREFIX = '#ai-deleted-document-detail:' function escapeHtml(value = '') { return String(value) @@ -39,6 +40,10 @@ function isDocumentDetailHref(href = '') { return String(href || '').trim().startsWith(DOCUMENT_DETAIL_HREF_PREFIX) } +function isDeletedDocumentDetailHref(href = '') { + return String(href || '').trim().startsWith(DELETED_DOCUMENT_DETAIL_HREF_PREFIX) +} + function sanitizeImageSrc(src = '') { const value = String(src || '').trim() if (/^(https?:\/\/|blob:|\/)/i.test(value)) { @@ -63,6 +68,17 @@ function renderLinkHtml(label = '', href = '') { '' ].join('') } + if (isDeletedDocumentDetailHref(href)) { + return [ + '', + label, + '' + ].join('') + } if (isApplicationDetailHref(href)) { return [ `\d{1,3})天前/) + if (daysAgo?.groups?.days) { + const days = Math.max(0, Number(daysAgo.groups.days)) + const date = new Date(today.getTime()) + date.setUTCDate(date.getUTCDate() - days) + const value = formatDate(date) + return { start: value, end: value, label: `${days}天前` } + } + if (/本月|这个月|当月/.test(text)) { return buildMonthRange(today.getUTCFullYear(), today.getUTCMonth() + 1) } @@ -129,6 +146,11 @@ function resolveExpenseTypeFilter(prompt) { return EXPENSE_TYPE_FILTERS.find((item) => item.pattern.test(text)) || null } +function resolveRiskFilter(prompt) { + const text = compactText(prompt) + return RISK_FILTERS.find((item) => item.pattern.test(text)) || null +} + function normalizeAmountText(value = '') { const matched = compactText(value).replace(/,/g, '').match(/-?\d+(?:\.\d+)?/) if (!matched) { @@ -191,13 +213,13 @@ function resolveKeywordFilter(prompt) { function resolveSource(prompt) { const text = compactText(prompt) - if (/审核单|审批单|待审|待审核|待审批|我审批|我审核/.test(text)) { + if (/审核单|审批单|待办|待审|待审核|待审批|我审批|我审核|我要审批|我要审核|去审批|去审核|处理审批|处理审核/.test(text)) { return { source: 'approval', sourceLabel: '待我审核的单据' } } - if (/我名下|我发起|我提交|我创建|我的申请|我的报销/.test(text)) { + if (/我名下|我发起|我提交|我创建|我的/.test(text)) { return { source: 'mine', sourceLabel: '我的单据' @@ -211,7 +233,13 @@ function resolveSource(prompt) { export function resolveAiDocumentQueryIntent(prompt, options = {}) { const text = compactText(prompt) - if (!text || !/(单据|单子|申请单|报销单|审核单|审批单|待审|待审批|待审核)/.test(text)) { + if (!text || !/(单据|单子|申请单|报销单|审核单|审批单|待办|待审|待审批|待审核|我要审核|我要审批|去审核|去审批|处理审核|处理审批|草稿)/.test(text)) { + return null + } + if ( + /(标准|制度|规则|政策|口径|怎么|如何|能不能|可以吗)/.test(text) && + !/(单据|单子|申请单|报销单|审核单|审批单|待办|待审|待审批|待审核)/.test(text) + ) { return null } if (/(发起|创建|新增|填写|生成|申请出差|我要报销|提交).*(单据|申请单|报销单)?/.test(text)) { @@ -223,6 +251,7 @@ export function resolveAiDocumentQueryIntent(prompt, options = {}) { const expenseTypeFilter = resolveExpenseTypeFilter(text) const keywordFilter = resolveKeywordFilter(prompt) const amountFilter = resolveAmountFilter(text) + const riskFilter = resolveRiskFilter(text) return { ...source, documentType, @@ -235,6 +264,7 @@ export function resolveAiDocumentQueryIntent(prompt, options = {}) { statusFilter, expenseTypeFilter, keywordFilter, - amountFilter + amountFilter, + riskFilter } } diff --git a/web/src/utils/aiDocumentQueryModel.js b/web/src/utils/aiDocumentQueryModel.js index 94741ff..f812ae4 100644 --- a/web/src/utils/aiDocumentQueryModel.js +++ b/web/src/utils/aiDocumentQueryModel.js @@ -2,6 +2,7 @@ import { extractExpenseClaimItems } from '../services/reimbursements.js' import { buildAiDocumentDetailHref } from './aiDocumentDetailReference.js' import { isApplicationDocumentNo } from './documentClassification.js' import { compactText, normalizeDateText, normalizeText, parseDate } from './aiDocumentQueryText.js' +import { filterActionableRiskFlags, isRiskSummaryWithRisk, normalizeRiskFlagTone } from './riskFlags.js' export { resolveAiDocumentQueryIntent } from './aiDocumentQueryIntent.js' @@ -36,6 +37,26 @@ const TYPE_LABELS = { other: '其他费用' } +const COMMAND_ACTION_LABELS = { + delete: '删除', + approve: '审核通过', + reject: '驳回/退回' +} + +const COMMAND_ACTION_VERBS = { + delete: '删除', + approve: '审核', + reject: '驳回或退回' +} + +const COMMAND_OBJECT_LABELS = { + draft: '草稿', + application: '申请单', + reimbursement: '报销单', + approval_task: '待审任务', + document: '单据' +} + const MONEY_FORMATTER = new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY', @@ -155,6 +176,54 @@ function resolveRecordQuerySource(claim = {}, intent = {}) { ) } +function resolveRiskFlags(claim = {}) { + if (Array.isArray(claim.risk_flags_json)) { + return claim.risk_flags_json + } + if (Array.isArray(claim.riskFlags)) { + return claim.riskFlags + } + if (Array.isArray(claim.review_flags)) { + return claim.review_flags + } + if (Array.isArray(claim.reviewFlags)) { + return claim.reviewFlags + } + return [] +} + +function resolveRiskSummary(claim = {}) { + return normalizeText( + claim.risk_summary || + claim.riskSummary || + claim.risk || + claim.risk_label || + claim.riskLabel + ) +} + +function resolveRecordRiskMeta(claim = {}) { + const flags = filterActionableRiskFlags(resolveRiskFlags(claim)) + const summary = resolveRiskSummary(claim) + if (!flags.length && !isRiskSummaryWithRisk(summary)) { + return { hasRisk: false, riskTone: 'none', riskLabel: '无风险' } + } + const tones = flags.map((flag) => normalizeRiskFlagTone(flag)) + const riskTone = tones.includes('high') + ? 'high' + : tones.includes('medium') + ? 'medium' + : tones.includes('low') + ? 'low' + : 'medium' + const riskLabel = riskTone === 'high' + ? '高风险' + : riskTone === 'medium' + ? '中风险' + : '低风险' + return { hasRisk: true, riskTone, riskLabel } +} + function isApprovalTaskClaim(claim = {}, intent = {}) { return resolveRecordQuerySource(claim, intent) === 'approval' } @@ -381,6 +450,7 @@ function normalizeRecord(claim = {}, intent = {}) { const rawStatusLabel = resolveStatusLabel(claim) const querySource = resolveRecordQuerySource(claim, intent) const isApprovalTask = isApprovalTaskClaim(claim, intent) + const riskMeta = resolveRecordRiskMeta(claim) const statusLabel = isApprovalTask && isPendingApprovalStatus(statusKey, rawStatusLabel) ? '待审批' : rawStatusLabel @@ -402,6 +472,9 @@ function normalizeRecord(claim = {}, intent = {}) { statusKey, statusLabel, statusTone: resolveStatusTone(statusLabel), + hasRisk: riskMeta.hasRisk, + riskTone: riskMeta.riskTone, + riskLabel: riskMeta.riskLabel, querySource, isApprovalTask, reason, @@ -417,7 +490,8 @@ function normalizeRecord(claim = {}, intent = {}) { departmentLabel, locationLabel, typeLabel, - statusLabel + statusLabel, + riskMeta.riskLabel ].join(' ')) } } @@ -459,6 +533,19 @@ function matchesAmountFilter(record = {}, amountFilter = null) { return true } +function matchesRiskFilter(record = {}, riskFilter = null) { + if (!riskFilter?.level) { + return true + } + if (riskFilter.level === 'none') { + return !record.hasRisk + } + if (riskFilter.level === 'has') { + return record.hasRisk + } + return record.hasRisk && record.riskTone === riskFilter.level +} + export function filterAiDocumentQueryRecords(claimsPayload, intent = {}) { const rows = extractExpenseClaimItems(claimsPayload) .map((claim) => normalizeRecord(claim, intent)) @@ -472,6 +559,7 @@ export function filterAiDocumentQueryRecords(claimsPayload, intent = {}) { .filter((record) => matchesExpenseTypeFilter(record, intent?.expenseTypeFilter)) .filter((record) => matchesKeywordFilter(record, intent?.keywordFilter)) .filter((record) => matchesAmountFilter(record, intent?.amountFilter)) + .filter((record) => matchesRiskFilter(record, intent?.riskFilter)) .sort((left, right) => toTimestamp(right.dateKey) - toTimestamp(left.dateKey)) return rows @@ -492,7 +580,40 @@ function buildDocumentCardFieldHtml(label = '', value = '', options = {}) { ].join('') } -function buildDocumentCardHtml(record = {}) { +function resolveCommandDetailActionLabel(commandFrame = null) { + if (!commandFrame || commandFrame.safetyLevel !== 'confirm_required') { + return '查看详情' + } + if (commandFrame.action === 'delete') { + return '进入详情确认删除' + } + if (commandFrame.action === 'approve') { + return '进入详情确认审核' + } + if (commandFrame.action === 'reject') { + return '进入详情确认驳回' + } + return '进入详情确认' +} + +function buildCommandConfirmationGuidanceHtml(commandFrame = null) { + if (!commandFrame || commandFrame.safetyLevel !== 'confirm_required') { + return '' + } + const actionVerb = COMMAND_ACTION_VERBS[commandFrame.action] || '操作' + const ctaLabel = resolveCommandDetailActionLabel(commandFrame) + return [ + '
', + '需要先确认目标单据', + '
', + `系统不会直接${escapeHtml(actionVerb)}相关单据。`, + `如果您希望继续${escapeHtml(actionVerb)}单据,请点击下方候选单据里的快捷按钮“${escapeHtml(ctaLabel)}”,进入单据详情核对后再操作。`, + '
', + '
' + ].join('') +} + +function buildDocumentCardHtml(record = {}, options = {}) { const href = buildAiDocumentDetailHref(record) const typeClass = record.documentType === 'application' ? 'application' : 'reimbursement' const approvalTaskClass = record.isApprovalTask ? ' ai-document-card--approval-task' : '' @@ -503,12 +624,14 @@ function buildDocumentCardHtml(record = {}) { const ownerText = [record.ownerLabel, record.departmentLabel] .filter((item) => item && item !== '未显示') .join(' · ') || '未显示' + const detailActionLabel = resolveCommandDetailActionLabel(options.commandFrame) const summaryHtml = [ buildDocumentCardFieldHtml('日期', record.time || '待补充'), buildDocumentCardFieldHtml(amountLabel, record.amountLabel, { valueClass: 'ai-document-card__amount' }) ].join('') const detailsHtml = [ buildDocumentCardFieldHtml('地点', record.locationLabel || '待补充'), + buildDocumentCardFieldHtml('风险', record.riskLabel || '无风险'), buildDocumentCardFieldHtml('单据编号', record.documentNo || '未编号单据', { valueClass: 'ai-document-card__number' }), buildDocumentCardFieldHtml('事由', record.reason || '待补充'), buildDocumentCardFieldHtml('申请人', ownerText), @@ -516,7 +639,7 @@ function buildDocumentCardHtml(record = {}) { '
', '操作', href - ? `查看详情` + ? `${escapeHtml(detailActionLabel)}` : '暂无详情', '
' ].join(''), @@ -556,11 +679,13 @@ function buildDocumentQuerySummaryHtml(scopeText = '', totalCount = 0, visibleCo function buildDocumentCardsHtml(records = [], options = {}) { const querySummaryHtml = options.querySummaryHtml || '' + const commandGuidanceHtml = buildCommandConfirmationGuidanceHtml(options.commandFrame) return [ '', + commandGuidanceHtml, querySummaryHtml, '
', - ...records.map((record) => buildDocumentCardHtml(record)), + ...records.map((record) => buildDocumentCardHtml(record, options)), '
', '' ].join('\n') @@ -574,7 +699,8 @@ function buildQueryScopeText(intent = {}) { intent.statusFilter?.label ? `状态:${intent.statusFilter.label}` : '', intent.expenseTypeFilter?.label ? `费用类型:${intent.expenseTypeFilter.label}` : '', intent.keywordFilter?.label ? `关键词:${intent.keywordFilter.label}` : '', - intent.amountFilter?.label ? `金额:${intent.amountFilter.label}` : '' + intent.amountFilter?.label ? `金额:${intent.amountFilter.label}` : '', + intent.riskFilter?.label ? `风险:${intent.riskFilter.label}` : '' ].filter(Boolean).join(' / ') } @@ -586,12 +712,52 @@ export function buildAiDocumentQueryConditionSummary(intent = {}) { intent.statusFilter?.label ? `状态:${intent.statusFilter.label}` : '', intent.expenseTypeFilter?.label ? `费用类型:${intent.expenseTypeFilter.label}` : '', intent.keywordFilter?.label ? `关键词:${intent.keywordFilter.label}` : '', - intent.amountFilter?.label ? `金额:${intent.amountFilter.label}` : '' + intent.amountFilter?.label ? `金额:${intent.amountFilter.label}` : '', + intent.riskFilter?.label ? `风险:${intent.riskFilter.label}` : '' ].filter(Boolean) return conditions.join(';') } -export function buildAiDocumentQueryMessage(intent = {}, claimsPayload = []) { +function buildAiDocumentCommandPolicyEvent(commandFrame = null) { + if (!commandFrame || commandFrame.safetyLevel !== 'confirm_required') { + return null + } + const actionLabel = COMMAND_ACTION_LABELS[commandFrame.action] || '高风险操作' + const objectLabel = COMMAND_OBJECT_LABELS[commandFrame.objectType] || '单据' + return { + eventId: 'document-command-policy', + title: '识别高风险操作意图', + content: `识别到用户想执行“${actionLabel}”动作,对象是“${objectLabel}”。这类动作不会直接执行,我会先筛选候选单据,再让用户进入详情页确认。`, + status: 'running' + } +} + +export function buildAiDocumentQueryThinkingEvents(intent = {}, options = {}) { + const conditionSummary = buildAiDocumentQueryConditionSummary(intent) + return [ + buildAiDocumentCommandPolicyEvent(options.commandFrame), + { + eventId: 'document-query-parse', + title: '解析自然语言筛选条件', + content: `正在从您的问题里提取查询来源、单据类型、时间、状态、费用类型、风险、关键词和金额条件。当前识别:${conditionSummary}。`, + status: 'running' + }, + { + eventId: 'document-query-fetch', + title: '查询业务单据接口', + content: '等待调用业务单据接口。', + status: 'pending' + }, + { + eventId: 'document-query-filter', + title: '组合筛选单据', + content: '等待接口返回后,再按已识别条件做二次筛选。', + status: 'pending' + } + ].filter(Boolean) +} + +export function buildAiDocumentQueryMessage(intent = {}, claimsPayload = [], options = {}) { const records = filterAiDocumentQueryRecords(claimsPayload, intent) const visibleRecords = records.slice(0, DOCUMENT_QUERY_LIMIT) const scopeText = buildQueryScopeText(intent) @@ -610,7 +776,8 @@ export function buildAiDocumentQueryMessage(intent = {}, claimsPayload = []) { '### 已查询到相关单据', '', buildDocumentCardsHtml(visibleRecords, { - querySummaryHtml: buildDocumentQuerySummaryHtml(scopeText, records.length, visibleRecords.length) + querySummaryHtml: buildDocumentQuerySummaryHtml(scopeText, records.length, visibleRecords.length), + commandFrame: options.commandFrame }) ] diff --git a/web/src/utils/aiWorkbenchConversationStore.js b/web/src/utils/aiWorkbenchConversationStore.js index 77dd23d..83570da 100644 --- a/web/src/utils/aiWorkbenchConversationStore.js +++ b/web/src/utils/aiWorkbenchConversationStore.js @@ -3,7 +3,10 @@ const MAX_CONVERSATION_HISTORY = 30 const MAX_STORED_MESSAGES = 80 const APPLICATION_DETAIL_HREF_PREFIX = '#ai-open-application-detail:' const DELETED_APPLICATION_DETAIL_HREF_PREFIX = '#ai-deleted-application-detail:' +const DOCUMENT_DETAIL_HREF_PREFIX = '#ai-open-document-detail:' +const DELETED_DOCUMENT_DETAIL_HREF_PREFIX = '#ai-deleted-document-detail:' const APPLICATION_DETAIL_MARKDOWN_LINK_RE = /\[([^\]]+)\]\((#ai-open-application-detail:[^)]+)\)/g +const DOCUMENT_DETAIL_MARKDOWN_LINK_RE = /\[([^\]]+)\]\((#ai-open-document-detail:[^)]+)\)/g function safeString(value) { return String(value || '').trim() @@ -25,12 +28,12 @@ function collectDeletedDraftIdentifiers(payload = {}) { ].map((item) => normalizeIdentifier(item)).filter(Boolean)) } -function decodeApplicationDetailHref(href = '') { +function decodeDetailHref(href = '', prefix = '') { const value = safeString(href) - if (!value.startsWith(APPLICATION_DETAIL_HREF_PREFIX)) { + if (!value.startsWith(prefix)) { return [] } - const encodedReference = value.slice(APPLICATION_DETAIL_HREF_PREFIX.length) + const encodedReference = value.slice(prefix.length) if (!encodedReference) { return [] } @@ -54,10 +57,22 @@ function decodeApplicationDetailHref(href = '') { return [...identifiers] } +function decodeApplicationDetailHref(href = '') { + return decodeDetailHref(href, APPLICATION_DETAIL_HREF_PREFIX) +} + +function decodeDocumentDetailHref(href = '') { + return decodeDetailHref(href, DOCUMENT_DETAIL_HREF_PREFIX) +} + function applicationDetailHrefMatchesDeletedDraft(href = '', identifiers = new Set()) { return decodeApplicationDetailHref(href).some((item) => identifiers.has(item)) } +function documentDetailHrefMatchesDeletedDocument(href = '', identifiers = new Set()) { + return decodeDocumentDetailHref(href).some((item) => identifiers.has(item)) +} + function buildDeletedApplicationDetailHref(href = '') { const value = safeString(href) if (!value.startsWith(APPLICATION_DETAIL_HREF_PREFIX)) { @@ -66,6 +81,14 @@ function buildDeletedApplicationDetailHref(href = '') { return `${DELETED_APPLICATION_DETAIL_HREF_PREFIX}${value.slice(APPLICATION_DETAIL_HREF_PREFIX.length)}` } +function buildDeletedDocumentDetailHref(href = '') { + const value = safeString(href) + if (!value.startsWith(DOCUMENT_DETAIL_HREF_PREFIX)) { + return '' + } + return `${DELETED_DOCUMENT_DETAIL_HREF_PREFIX}${value.slice(DOCUMENT_DETAIL_HREF_PREFIX.length)}` +} + function markApplicationDetailLinksDeleted(content = '', identifiers = new Set()) { let changed = false const nextContent = String(content || '').replace(APPLICATION_DETAIL_MARKDOWN_LINK_RE, (match, _label, href) => { @@ -82,6 +105,33 @@ function markApplicationDetailLinksDeleted(content = '', identifiers = new Set() return { content: nextContent, changed } } +function markDocumentDetailLinksDeleted(content = '', identifiers = new Set()) { + let changed = false + const afterDocumentLinks = String(content || '').replace(DOCUMENT_DETAIL_MARKDOWN_LINK_RE, (match, _label, href) => { + if (!documentDetailHrefMatchesDeletedDocument(href, identifiers)) { + return match + } + const deletedHref = buildDeletedDocumentDetailHref(href) + if (!deletedHref) { + return '单据已删除' + } + changed = true + return `[单据已删除](${deletedHref})` + }) + const nextContent = afterDocumentLinks.replace(APPLICATION_DETAIL_MARKDOWN_LINK_RE, (match, _label, href) => { + if (!applicationDetailHrefMatchesDeletedDraft(href, identifiers)) { + return match + } + const deletedHref = buildDeletedApplicationDetailHref(href) + if (!deletedHref) { + return '单据已删除' + } + changed = true + return `[单据已删除](${deletedHref})` + }) + return { content: nextContent, changed } +} + function actionMatchesDeletedDraft(action = {}, identifiers = new Set()) { const payload = action?.payload && typeof action.payload === 'object' ? action.payload : {} return [ @@ -98,7 +148,8 @@ function actionMatchesDeletedDraft(action = {}, identifiers = new Set()) { function markSuggestedActionsDeleted(actions = [], identifiers = new Set()) { let changed = false const nextActions = (Array.isArray(actions) ? actions : []).map((action) => { - if (String(action?.action_type || '').trim() !== 'open_application_detail') { + const actionType = String(action?.action_type || '').trim() + if (!['open_application_detail', 'open_document_detail'].includes(actionType)) { return action } if (!actionMatchesDeletedDraft(action, identifiers)) { @@ -111,7 +162,9 @@ function markSuggestedActionsDeleted(actions = [], identifiers = new Set()) { description: '草稿单据已经删除,请重新再次申请。', icon: 'mdi mdi-trash-can-outline', disabled: true, - action_type: 'deleted_application_detail' + action_type: actionType === 'open_document_detail' + ? 'deleted_document_detail' + : 'deleted_application_detail' } }) return { actions: nextActions, changed } @@ -132,6 +185,21 @@ function buildDraftDeletedMessage(payload = {}) { } } +function buildDocumentDeletedMessage(payload = {}) { + const claimNo = safeString(payload.claimNo || payload.claim_no || payload.documentNo || payload.document_no) + return { + id: `document-deleted-${safeString(payload.claimId || payload.claim_id || payload.id || claimNo) || Date.now()}`, + role: 'assistant', + content: [ + `单据${claimNo ? ` ${claimNo}` : ''} 已经删除或不可访问。`, + '该历史入口已失效,请返回单据列表重新查询。' + ].join('\n\n'), + feedback: '', + stewardPlan: null, + suggestedActions: [] + } +} + function conversationHasDeletionNotice(messages = [], identifiers = new Set()) { return messages.some((message) => { const content = safeString(message?.content) @@ -139,6 +207,13 @@ function conversationHasDeletionNotice(messages = [], identifiers = new Set()) { }) } +function conversationHasDocumentDeletionNotice(messages = [], identifiers = new Set()) { + return messages.some((message) => { + const content = safeString(message?.content) + return content.includes('已经删除或不可访问') && [...identifiers].some((item) => content.includes(item)) + }) +} + function resolveUserStorageKey(user = {}) { const identity = safeString(user.username || user.email || user.name || 'anonymous') return `${STORAGE_KEY_PREFIX}:${identity || 'anonymous'}` @@ -329,3 +404,46 @@ export function markAiWorkbenchConversationDraftDeleted(user = {}, payload = {}) writeStoredList(user, nextList) return loadAiWorkbenchConversationHistory(user) } + +export function markAiWorkbenchConversationDocumentDeleted(user = {}, payload = {}) { + const identifiers = collectDeletedDraftIdentifiers(payload) + if (!identifiers.size) { + return loadAiWorkbenchConversationHistory(user) + } + + const nextList = readStoredList(user).map((conversation) => { + const normalized = normalizeConversation(conversation) + let conversationChanged = false + const messages = normalized.messages.map((message) => { + const contentResult = markDocumentDetailLinksDeleted(message.content, identifiers) + const actionsResult = markSuggestedActionsDeleted(message.suggestedActions, identifiers) + if (!contentResult.changed && !actionsResult.changed) { + return message + } + conversationChanged = true + return { + ...message, + content: contentResult.content, + suggestedActions: actionsResult.actions + } + }) + + if (!conversationChanged) { + return normalized + } + + if (!conversationHasDocumentDeletionNotice(messages, identifiers)) { + messages.push(buildDocumentDeletedMessage(payload)) + } + + return { + ...normalized, + desc: '单据已经删除或不可访问。', + messages, + updatedAt: Date.now() + } + }) + + writeStoredList(user, nextList) + return loadAiWorkbenchConversationHistory(user) +} diff --git a/web/tests/ai-conversation-html-renderer.test.mjs b/web/tests/ai-conversation-html-renderer.test.mjs index 24ae67c..b21d844 100644 --- a/web/tests/ai-conversation-html-renderer.test.mjs +++ b/web/tests/ai-conversation-html-renderer.test.mjs @@ -115,6 +115,15 @@ test('AI conversation renderer renders document detail action links as buttons', assert.doesNotMatch(rendered, /target="_blank"[\s\S]{0,120}#ai-open-document-detail/) }) +test('AI conversation renderer renders deleted document detail actions as disabled buttons', () => { + const rendered = renderAiConversationHtml('[单据已删除](#ai-deleted-document-detail:claim-deleted-1)') + + assert.match(rendered, /class="ai-html-action-link ai-html-action-link-document is-disabled"/) + assert.match(rendered, /aria-disabled="true"/) + assert.match(rendered, /data-ai-action="deleted-document-detail"/) + assert.doesNotMatch(rendered, /href="#ai-deleted-document-detail/) +}) + test('AI conversation renderer renders images as html and rejects unsafe image sources', () => { const rendered = renderAiConversationHtml([ '### 图片材料', diff --git a/web/tests/ai-document-query-model.test.mjs b/web/tests/ai-document-query-model.test.mjs index 88dccd0..2e791bc 100644 --- a/web/tests/ai-document-query-model.test.mjs +++ b/web/tests/ai-document-query-model.test.mjs @@ -9,6 +9,8 @@ import { resolveAiDocumentQueryIntent } from '../src/utils/aiDocumentQueryModel.js' import { renderAiConversationHtml } from '../src/utils/aiConversationHtmlRenderer.js' +import { resolveWorkbenchIntentActionRoute } from '../src/composables/workbenchAiMode/workbenchIntentActionPolicy.js' +import { resolveWorkbenchIntentFrame } from '../src/composables/workbenchAiMode/workbenchIntentFrameModel.js' const today = '2026-06-20' @@ -68,6 +70,21 @@ test('AI document query intent detects approval document questions', () => { assert.equal(intent?.sourceLabel, '待我审核的单据') }) +test('AI document query intent detects short approval workbench commands', () => { + const reviewIntent = resolveAiDocumentQueryIntent('我要审核', { today }) + const todoIntent = resolveAiDocumentQueryIntent('待办审批', { today }) + + assert.equal(reviewIntent?.source, 'approval') + assert.equal(reviewIntent?.documentType, 'all') + assert.equal(reviewIntent?.sourceLabel, '待我审核的单据') + assert.equal(todoIntent?.source, 'approval') + assert.equal(todoIntent?.sourceLabel, '待我审核的单据') +}) + +test('AI document query intent keeps approval policy questions out of document query', () => { + assert.equal(resolveAiDocumentQueryIntent('审批规则怎么走', { today }), null) +}) + test('AI document query keeps explicit own-document scope separate from accessible documents', () => { const intent = resolveAiDocumentQueryIntent('我名下有哪些单据?', { today }) @@ -136,6 +153,66 @@ test('AI document query combines natural-language filters', () => { assert.match(buildAiDocumentQueryConditionSummary(intent), /金额:不少于1000元/) }) +test('AI document query filters draft candidates by relative day', () => { + const intent = resolveAiDocumentQueryIntent('我的 3天前 草稿单据', { today: '2026-06-24' }) + const records = filterAiDocumentQueryRecords([ + { + id: 'draft-3-days-ago', + claim_no: 'AP-20260621001', + document_type_code: 'application', + status: 'draft', + reason: '三天前保存的申请草稿', + created_at: '2026-06-21T09:00:00Z' + }, + { + id: 'draft-yesterday', + claim_no: 'AP-20260623001', + document_type_code: 'application', + status: 'draft', + reason: '昨天保存的申请草稿', + created_at: '2026-06-23T09:00:00Z' + } + ], intent) + + assert.equal(intent?.source, 'mine') + assert.equal(intent?.statusFilter?.label, '草稿') + assert.equal(intent?.timeRange?.start, '2026-06-21') + assert.equal(intent?.timeRange?.end, '2026-06-21') + assert.deepEqual(records.map((record) => record.documentNo), ['AP-20260621001']) +}) + +test('AI document query filters no-risk approval application candidates', () => { + const intent = resolveAiDocumentQueryIntent('待我审核 无风险 申请单', { today }) + const payload = [{ + id: 'approval-no-risk', + claim_no: 'AP-NORISK-001', + document_type_code: 'application', + expense_type: 'travel_application', + status: 'submitted', + reason: '合规差旅申请', + created_at: '2026-06-20T09:00:00Z', + risk_flags_json: [], + risk_summary: '无' + }, { + id: 'approval-high-risk', + claim_no: 'AP-RISK-001', + document_type_code: 'application', + expense_type: 'travel_application', + status: 'submitted', + reason: '住宿超标申请', + created_at: '2026-06-20T10:00:00Z', + risk_flags_json: [{ severity: 'high', summary: '住宿超标' }], + risk_summary: '住宿超标' + }] + const records = filterAiDocumentQueryRecords({ items: payload, querySource: 'approval' }, intent) + + assert.equal(intent?.source, 'approval') + assert.equal(intent?.documentType, 'application') + assert.equal(intent?.riskFilter?.level, 'none') + assert.match(buildAiDocumentQueryConditionSummary(intent), /风险:无风险/) + assert.deepEqual(records.map((record) => record.documentNo), ['AP-NORISK-001']) +}) + test('AI document query excludes undated rows when a time condition is present', () => { const intent = resolveAiDocumentQueryIntent('我2月有哪些单据?', { today }) const records = filterAiDocumentQueryRecords([ @@ -246,6 +323,28 @@ test('AI document query html cards render as trusted card markup', () => { assert.doesNotMatch(rendered, /
/) }) +test('AI document query keeps high-risk command guidance in trusted rendered markup', () => { + const frame = resolveWorkbenchIntentFrame('删除申请单草稿', { today: '2026-06-24' }) + const route = resolveWorkbenchIntentActionRoute(frame) + const intent = resolveAiDocumentQueryIntent(route.queryPrompt, { today: '2026-06-24' }) + const rendered = renderAiConversationHtml(buildAiDocumentQueryMessage(intent, [{ + id: 'draft-application-1', + claim_no: 'AP-DRAFT-001', + document_type_code: 'application', + expense_type: 'travel_application', + status: 'draft', + reason: '测试申请草稿', + created_at: '2026-06-24T09:00:00Z' + }], { commandFrame: frame })) + + assert.match(rendered, /

已查询到相关单据<\/h3>/) + assert.match(rendered, /
/) + assert.match(rendered, /系统不会直接删除相关单据/) + assert.match(rendered, /进入单据详情核对后再操作/) + assert.match(rendered, /
/) + assert.match(rendered, /进入详情确认删除/) +}) + test('AI document query trusted html rejects unsafe card markup', () => { const rendered = renderAiConversationHtml([ '### 查询结果', diff --git a/web/tests/assistant-session-draft-delete.test.mjs b/web/tests/assistant-session-draft-delete.test.mjs index c6b5672..6293aaf 100644 --- a/web/tests/assistant-session-draft-delete.test.mjs +++ b/web/tests/assistant-session-draft-delete.test.mjs @@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url' import { markAiWorkbenchConversationDraftDeleted, + markAiWorkbenchConversationDocumentDeleted, loadAiWorkbenchConversationHistory, saveAiWorkbenchConversation } from '../src/utils/aiWorkbenchConversationStore.js' @@ -120,6 +121,54 @@ test('deleting an application draft marks AI workbench detail links as unavailab assert.equal(loadAiWorkbenchConversationHistory(user)[0].messages.length, 2) }) +test('deleted document detail links are disabled for reopened AI conversations', () => { + installWindowStub() + const user = { username: 'zhangsan@example.com' } + + saveAiWorkbenchConversation(user, { + id: 'conversation-document-delete-candidate', + title: '删除申请单草稿', + messages: [ + { + id: 'assistant-document-candidates', + role: 'assistant', + content: [ + '### 已查询到相关单据', + '', + '[进入详情确认删除](#ai-open-document-detail:claim_id%3Dclaim-draft-2%26claim_no%3DAP-DRAFT-002)' + ].join('\n') + } + ] + }) + + const nextHistory = markAiWorkbenchConversationDocumentDeleted(user, { + claimId: 'claim-draft-2', + claimNo: 'AP-DRAFT-002' + }) + const conversation = nextHistory.find((item) => item.id === 'conversation-document-delete-candidate') + + assert.ok(conversation) + assert.match(conversation.messages[0].content, /#ai-deleted-document-detail:/) + assert.doesNotMatch(conversation.messages[0].content, /#ai-open-document-detail:/) + assert.match(conversation.messages[0].content, /\[单据已删除\]/) + assert.match(conversation.messages.at(-1).content, /单据 AP-DRAFT-002 已经删除或不可访问/) + assert.match(conversation.messages.at(-1).content, /该历史入口已失效,请返回单据列表重新查询/) + assert.equal(loadAiWorkbenchConversationHistory(user)[0].messages.length, 2) +}) + +test('AI workbench validates document detail links before opening stale history entries', () => { + const aiMode = readFileSync( + fileURLToPath(new URL('../src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js', import.meta.url)), + 'utf8' + ) + + assert.match(aiMode, /fetchExpenseClaimDetail/) + assert.match(aiMode, /markAiWorkbenchConversationDocumentDeleted/) + assert.match(aiMode, /async function handleAiAnswerMarkdownClick/) + assert.match(aiMode, /await ensureAiDocumentDetailStillAvailable/) + assert.match(aiMode, /已将这条历史入口标记为不可查看/) +}) + 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)),