feat(web): AI 工作台会话与文档卡片渲染增强
- aiConversationHtmlRenderer 识别单据记录类表格并渲染为卡片列表,新增删除申请单详情的禁用占位链接 - aiWorkbenchConversationStore 增加草稿删除后会话链接失效处理,避免点击已删除单据跳转 - aiApplicationPreviewActions 调整提交/草稿调用路径,PersonalWorkbenchAiMode 接入新的会话存储与渲染 - ConfirmDialog/TravelRequestDeleteDialog/useAppShell/AppShellRouteView 配套适配,同步更新相关前端测试
This commit is contained in:
@@ -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">',
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user