feat(web): AI 工作台会话与文档卡片渲染增强

- aiConversationHtmlRenderer 识别单据记录类表格并渲染为卡片列表,新增删除申请单详情的禁用占位链接
- aiWorkbenchConversationStore 增加草稿删除后会话链接失效处理,避免点击已删除单据跳转
- aiApplicationPreviewActions 调整提交/草稿调用路径,PersonalWorkbenchAiMode 接入新的会话存储与渲染
- ConfirmDialog/TravelRequestDeleteDialog/useAppShell/AppShellRouteView 配套适配,同步更新相关前端测试
This commit is contained in:
caoxiaozhu
2026-06-20 21:44:16 +08:00
parent 81e990ab72
commit 0cda750ff0
19 changed files with 734 additions and 92 deletions

View File

@@ -21,6 +21,7 @@ const BUSINESS_FIELD_LABELS = new Set([
])
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 TRUSTED_HTML_BLOCK_RE = /<!--\s*ai-trusted-html:start\s*-->\s*([\s\S]*?)\s*<!--\s*ai-trusted-html:end\s*-->/g
const TRUSTED_HTML_PLACEHOLDER_PREFIX = 'AI_TRUSTED_HTML_BLOCK_'
@@ -62,6 +63,10 @@ function isApplicationDetailHref(href = '') {
return String(href || '').trim().startsWith(APPLICATION_DETAIL_HREF_PREFIX)
}
function isDeletedApplicationDetailHref(href = '') {
return String(href || '').trim().startsWith(DELETED_APPLICATION_DETAIL_HREF_PREFIX)
}
function isDocumentDetailHref(href = '') {
return String(href || '').trim().startsWith(DOCUMENT_DETAIL_HREF_PREFIX)
}
@@ -79,6 +84,17 @@ function sanitizeImageSrc(src = '') {
function renderLinkHtml(label = '', href = '') {
const sanitizedHref = sanitizeHref(href)
if (isDeletedApplicationDetailHref(href)) {
return [
'<span',
' class="ai-html-action-link ai-html-action-link-application is-disabled"',
' data-ai-action="deleted-application-detail"',
' aria-disabled="true"',
'>',
label,
'</span>'
].join('')
}
if (isApplicationDetailHref(href)) {
return [
`<a href="${sanitizedHref}"`,
@@ -482,6 +498,80 @@ function renderOrderedList(items = []) {
].join('')
}
function normalizeTableHeaderCell(value = '') {
return String(value || '').replace(/\s+/g, '').trim()
}
function findTableColumnIndex(normalizedHeader = [], labels = []) {
return labels
.map((label) => normalizedHeader.indexOf(label))
.find((index) => index >= 0) ?? -1
}
function resolveTableCell(row = [], normalizedHeader = [], labels = []) {
const columnIndex = findTableColumnIndex(normalizedHeader, labels)
return columnIndex >= 0 ? String(row[columnIndex] || '').trim() : ''
}
function hasMeaningfulTableValue(value = '') {
const text = String(value || '').trim()
return Boolean(text && text !== '-')
}
function isDocumentRecordTable(normalizedHeader = []) {
return (
normalizedHeader.includes('单据编号') &&
normalizedHeader.includes('操作') &&
normalizedHeader.some((label) => ['单据类型', '申请时间', '单据状态', '状态', '当前节点', '事由'].includes(label))
)
}
function renderRecordMeta(label = '', value = '') {
if (!hasMeaningfulTableValue(value)) {
return ''
}
return [
'<span class="ai-html-record-meta-item">',
`<small>${escapeHtml(label)}</small>`,
`<b>${renderInlineHtml(value)}</b>`,
'</span>'
].join('')
}
function renderDocumentRecordList(header = [], bodyRows = []) {
const normalizedHeader = header.map((cell) => normalizeTableHeaderCell(cell))
const items = bodyRows.map((row) => {
const documentType = resolveTableCell(row, normalizedHeader, ['单据类型'])
const documentNo = resolveTableCell(row, normalizedHeader, ['单据编号'])
const applyTime = resolveTableCell(row, normalizedHeader, ['申请时间'])
const status = resolveTableCell(row, normalizedHeader, ['单据状态', '状态'])
const stage = resolveTableCell(row, normalizedHeader, ['当前节点'])
const reason = resolveTableCell(row, normalizedHeader, ['事由'])
const action = resolveTableCell(row, normalizedHeader, ['操作'])
return [
'<article class="ai-html-record-item" role="listitem">',
'<div class="ai-html-record-main">',
hasMeaningfulTableValue(documentType) ? `<span class="ai-html-record-kicker">${renderInlineHtml(documentType)}</span>` : '',
hasMeaningfulTableValue(documentNo) ? `<strong class="ai-html-record-id">${renderInlineHtml(documentNo)}</strong>` : '',
hasMeaningfulTableValue(reason) ? `<p class="ai-html-record-reason">${renderInlineHtml(reason)}</p>` : '',
'</div>',
'<div class="ai-html-record-meta">',
renderRecordMeta('申请时间', applyTime),
renderRecordMeta('状态', status),
renderRecordMeta('当前节点', stage),
'</div>',
hasMeaningfulTableValue(action) ? `<div class="ai-html-record-action">${renderInlineHtml(action)}</div>` : '',
'</article>'
].join('')
}).filter(Boolean)
return [
'<div class="ai-html-record-list" role="list">',
...items,
'</div>'
].join('')
}
function renderTable(lines = []) {
const rows = lines.map((line) => parseTableRow(line)).filter((row) => row.length)
if (rows.length < 2) {
@@ -489,6 +579,10 @@ function renderTable(lines = []) {
}
const header = rows[0]
const bodyRows = rows.slice(2)
const normalizedHeader = header.map((cell) => normalizeTableHeaderCell(cell))
if (isDocumentRecordTable(normalizedHeader)) {
return renderDocumentRecordList(header, bodyRows)
}
return [
'<div class="ai-html-table-wrap">',

View File

@@ -1,11 +1,144 @@
const STORAGE_KEY_PREFIX = 'x-financial:workbench-ai-conversations'
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 APPLICATION_DETAIL_MARKDOWN_LINK_RE = /\[([^\]]+)\]\((#ai-open-application-detail:[^)]+)\)/g
function safeString(value) {
return String(value || '').trim()
}
function normalizeIdentifier(value) {
return safeString(value)
}
function collectDeletedDraftIdentifiers(payload = {}) {
return new Set([
payload.claimId,
payload.claim_id,
payload.id,
payload.claimNo,
payload.claim_no,
payload.documentNo,
payload.document_no
].map((item) => normalizeIdentifier(item)).filter(Boolean))
}
function decodeApplicationDetailHref(href = '') {
const value = safeString(href)
if (!value.startsWith(APPLICATION_DETAIL_HREF_PREFIX)) {
return []
}
const encodedReference = value.slice(APPLICATION_DETAIL_HREF_PREFIX.length)
if (!encodedReference) {
return []
}
let reference = ''
try {
reference = decodeURIComponent(encodedReference).trim()
} catch {
reference = encodedReference.trim()
}
const identifiers = new Set([reference, encodedReference].map((item) => normalizeIdentifier(item)).filter(Boolean))
const params = new URLSearchParams(reference)
const detailParamKeys = ['claim_id', 'claim_no', 'document_no']
detailParamKeys.forEach((key) => {
const paramValue = normalizeIdentifier(params.get(key))
if (paramValue) {
identifiers.add(paramValue)
}
})
return [...identifiers]
}
function applicationDetailHrefMatchesDeletedDraft(href = '', identifiers = new Set()) {
return decodeApplicationDetailHref(href).some((item) => identifiers.has(item))
}
function buildDeletedApplicationDetailHref(href = '') {
const value = safeString(href)
if (!value.startsWith(APPLICATION_DETAIL_HREF_PREFIX)) {
return ''
}
return `${DELETED_APPLICATION_DETAIL_HREF_PREFIX}${value.slice(APPLICATION_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) => {
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 [
payload.claim_id,
payload.claimId,
payload.id,
payload.claim_no,
payload.claimNo,
payload.document_no,
payload.documentNo
].map((item) => normalizeIdentifier(item)).some((item) => item && identifiers.has(item))
}
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') {
return action
}
if (!actionMatchesDeletedDraft(action, identifiers)) {
return action
}
changed = true
return {
...action,
label: '草稿已删除',
description: '草稿单据已经删除,请重新再次申请。',
icon: 'mdi mdi-trash-can-outline',
disabled: true,
action_type: 'deleted_application_detail'
}
})
return { actions: nextActions, changed }
}
function buildDraftDeletedMessage(payload = {}) {
const claimNo = safeString(payload.claimNo || payload.claim_no || payload.documentNo || payload.document_no)
return {
id: `draft-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)
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'}`
@@ -153,3 +286,46 @@ export function deleteAiWorkbenchConversation(user = {}, conversationId = '') {
writeStoredList(user, nextList)
return loadAiWorkbenchConversationHistory(user)
}
export function markAiWorkbenchConversationDraftDeleted(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 = markApplicationDetailLinksDeleted(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 (!conversationHasDeletionNotice(messages, identifiers)) {
messages.push(buildDraftDeletedMessage(payload))
}
return {
...normalized,
desc: '草稿单据已经删除,请重新再次申请。',
messages,
updatedAt: Date.now()
}
})
writeStoredList(user, nextList)
return loadAiWorkbenchConversationHistory(user)
}