feat(web): 文档查询意图补充风险过滤与 X 天前范围

- aiDocumentQueryIntent 新增风险等级过滤(无/高/中/低/有风险)与 N 天前日期范围解析
- aiDocumentQueryModel/aiConversationHtmlRenderer 渲染适配风险过滤标签
- useWorkbenchAiDocumentQueryFlow/aiWorkbenchConversationStore 会话流转适配命令意图
- 更新 ai-document-query-model/ai-conversation-html-renderer/assistant-session-draft-delete 测试
This commit is contained in:
caoxiaozhu
2026-06-24 22:59:05 +08:00
parent 3eb78d343a
commit e5b03c6601
8 changed files with 517 additions and 39 deletions

View File

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

View File

@@ -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 = '') {
'</span>'
].join('')
}
if (isDeletedDocumentDetailHref(href)) {
return [
'<span',
' class="ai-html-action-link ai-html-action-link-document is-disabled"',
' data-ai-action="deleted-document-detail"',
' aria-disabled="true"',
'>',
label,
'</span>'
].join('')
}
if (isApplicationDetailHref(href)) {
return [
`<a href="${sanitizedHref}"`,

View File

@@ -23,6 +23,14 @@ const EXPENSE_TYPE_FILTERS = [
{ label: '软件服务费', codes: ['software'], pattern: /软件|服务费|订阅/ }
]
const RISK_FILTERS = [
{ label: '无风险', level: 'none', pattern: /无风险|没有风险|暂无风险|无异常|合规/ },
{ label: '高风险', level: 'high', pattern: /高风险/ },
{ label: '中风险', level: 'medium', pattern: /中风险/ },
{ label: '低风险', level: 'low', pattern: /低风险/ },
{ label: '有风险', level: 'has', pattern: /风险|异常|超标/ }
]
function resolveToday(options = {}) {
return parseDate(options.today) || new Date()
}
@@ -82,6 +90,15 @@ function resolveTimeRange(prompt, options = {}) {
return { start: value, end: value, label: '昨天' }
}
const daysAgo = text.match(/(?<days>\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
}
}

View File

@@ -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 [
'<section class="ai-document-command-guidance" aria-label="高风险操作提示">',
'<strong class="ai-document-command-guidance__title">需要先确认目标单据</strong>',
'<div class="ai-document-command-guidance__body">',
`系统不会直接${escapeHtml(actionVerb)}相关单据。`,
`如果您希望继续${escapeHtml(actionVerb)}单据,请点击下方候选单据里的快捷按钮“${escapeHtml(ctaLabel)}”,进入单据详情核对后再操作。`,
'</div>',
'</section>'
].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 = {}) {
'<div class="ai-document-card__field ai-document-card__field--action">',
'<span class="ai-document-card__label">操作</span>',
href
? `<a class="ai-html-action-link ai-html-action-link-document ai-document-card__action" data-ai-action="open-document-detail" href="${escapeHtml(href)}">查看详情</a>`
? `<a class="ai-html-action-link ai-html-action-link-document ai-document-card__action" data-ai-action="open-document-detail" href="${escapeHtml(href)}">${escapeHtml(detailActionLabel)}</a>`
: '<span class="ai-document-card__value">暂无详情</span>',
'</div>'
].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 [
'<!-- ai-trusted-html:start -->',
commandGuidanceHtml,
querySummaryHtml,
'<section class="ai-document-card-list" aria-label="单据查询结果">',
...records.map((record) => buildDocumentCardHtml(record)),
...records.map((record) => buildDocumentCardHtml(record, options)),
'</section>',
'<!-- ai-trusted-html:end -->'
].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
})
]

View File

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

View File

@@ -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([
'### 图片材料',

View File

@@ -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, /<blockquote>/)
})
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 class="ai-html-title">已查询到相关单据<\/h3>/)
assert.match(rendered, /<section class="ai-document-command-guidance" aria-label="高风险操作提示">/)
assert.match(rendered, /系统不会直接删除相关单据/)
assert.match(rendered, /进入单据详情核对后再操作/)
assert.match(rendered, /<section class="ai-document-card-list" aria-label="单据查询结果">/)
assert.match(rendered, /进入详情确认删除/)
})
test('AI document query trusted html rejects unsafe card markup', () => {
const rendered = renderAiConversationHtml([
'### 查询结果',

View File

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