feat(web): AI 工作台会话与文档卡片渲染增强
- aiConversationHtmlRenderer 识别单据记录类表格并渲染为卡片列表,新增删除申请单详情的禁用占位链接 - aiWorkbenchConversationStore 增加草稿删除后会话链接失效处理,避免点击已删除单据跳转 - aiApplicationPreviewActions 调整提交/草稿调用路径,PersonalWorkbenchAiMode 接入新的会话存储与渲染 - ConfirmDialog/TravelRequestDeleteDialog/useAppShell/AppShellRouteView 配套适配,同步更新相关前端测试
This commit is contained in:
@@ -56,15 +56,27 @@ async function testSubmitActionUsesFastPreviewEndpoint() {
|
||||
assert.equal(body.context_json.application_preview.fields.transportMode, '火车')
|
||||
}
|
||||
|
||||
async function testSaveDraftActionKeepsOrchestratorPath() {
|
||||
async function testSaveDraftActionUsesFastPreviewEndpoint() {
|
||||
let capturedUrl = ''
|
||||
let capturedOptions = null
|
||||
|
||||
global.fetch = async (url) => {
|
||||
global.fetch = async (url, options) => {
|
||||
capturedUrl = String(url)
|
||||
capturedOptions = options
|
||||
return {
|
||||
ok: true,
|
||||
async json() {
|
||||
return { status: 'succeeded', result: {} }
|
||||
return {
|
||||
status: 'succeeded',
|
||||
result: {
|
||||
draft_payload: {
|
||||
claim_id: 'claim-fast-draft',
|
||||
claim_no: 'AP-20260620-DRAFT',
|
||||
status: 'draft',
|
||||
approval_stage: '待提交'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -75,12 +87,17 @@ async function testSaveDraftActionKeepsOrchestratorPath() {
|
||||
currentUser: { username: 'zhangsan@example.com', name: '张三' }
|
||||
})
|
||||
|
||||
assert.equal(capturedUrl, '/api/v1/orchestrator/run')
|
||||
assert.equal(capturedUrl, '/api/v1/reimbursements/application-preview-action')
|
||||
assert.equal(capturedOptions.method, 'POST')
|
||||
const body = JSON.parse(capturedOptions.body)
|
||||
assert.equal(body.context_json.application_action, 'save_draft')
|
||||
assert.equal(body.context_json.application_save_mode, true)
|
||||
assert.equal(body.context_json.application_stage, 'expense_application')
|
||||
}
|
||||
|
||||
async function run() {
|
||||
await testSubmitActionUsesFastPreviewEndpoint()
|
||||
await testSaveDraftActionKeepsOrchestratorPath()
|
||||
await testSaveDraftActionUsesFastPreviewEndpoint()
|
||||
console.log('ai-application-preview-actions tests passed')
|
||||
}
|
||||
|
||||
|
||||
@@ -47,17 +47,49 @@ test('AI conversation renderer supports tables and escapes unsafe HTML', () => {
|
||||
|
||||
test('AI conversation renderer renders application detail action links as buttons', () => {
|
||||
const rendered = renderAiConversationHtml([
|
||||
'| 单据编号 | 操作 |',
|
||||
'| --- | --- |',
|
||||
'| AP-OVERLAP | [查看](#ai-open-application-detail:AP-OVERLAP) |'
|
||||
'| 单据类型 | 单据编号 | 单据状态 | 当前节点 | 操作 |',
|
||||
'| --- | --- | --- | --- | --- |',
|
||||
'| 出差申请 | AP-OVERLAP | 草稿 | 待提交 | [查看](#ai-open-application-detail:AP-OVERLAP) |'
|
||||
].join('\n'))
|
||||
|
||||
assert.match(rendered, /<div class="ai-html-record-list" role="list">/)
|
||||
assert.match(rendered, /<article class="ai-html-record-item" role="listitem">/)
|
||||
assert.match(rendered, /<strong class="ai-html-record-id">AP-OVERLAP<\/strong>/)
|
||||
assert.match(rendered, /class="ai-html-action-link ai-html-action-link-application"/)
|
||||
assert.match(rendered, /data-ai-action="open-application-detail"/)
|
||||
assert.match(rendered, /href="#ai-open-application-detail:AP-OVERLAP"/)
|
||||
assert.doesNotMatch(rendered, /<table>/)
|
||||
assert.doesNotMatch(rendered, /target="_blank"[\s\S]{0,120}#ai-open-application-detail/)
|
||||
})
|
||||
|
||||
test('AI conversation renderer renders deleted application detail actions as disabled buttons', () => {
|
||||
const rendered = renderAiConversationHtml([
|
||||
'| 单据类型 | 单据编号 | 单据状态 | 当前节点 | 操作 |',
|
||||
'| --- | --- | --- | --- | --- |',
|
||||
'| 出差申请 | AP-20260620-DRAFT | 已删除 | 已删除 | [草稿已删除](#ai-deleted-application-detail:claim-draft-1) |'
|
||||
].join('\n'))
|
||||
|
||||
assert.match(rendered, /class="ai-html-action-link ai-html-action-link-application is-disabled"/)
|
||||
assert.match(rendered, /aria-disabled="true"/)
|
||||
assert.match(rendered, /data-ai-action="deleted-application-detail"/)
|
||||
assert.doesNotMatch(rendered, /href="#ai-deleted-application-detail/)
|
||||
})
|
||||
|
||||
test('AI conversation renderer turns application conflict tables into record lists', () => {
|
||||
const rendered = renderAiConversationHtml([
|
||||
'| 单据编号 | 申请时间 | 状态 | 事由 | 操作 |',
|
||||
'| --- | --- | --- | --- | --- |',
|
||||
'| AP-20260620063557-4JU2MWEF | 2026-02-20 至 2026-02-23 | 审批中 | 辅助国网仿生产服务器部署 | [查看](#ai-open-application-detail:AP-20260620063557-4JU2MWEF) |'
|
||||
].join('\n'))
|
||||
|
||||
assert.match(rendered, /<div class="ai-html-record-list" role="list">/)
|
||||
assert.match(rendered, /申请时间/)
|
||||
assert.match(rendered, /2026-02-20 至 2026-02-23/)
|
||||
assert.match(rendered, /辅助国网仿生产服务器部署/)
|
||||
assert.match(rendered, /<div class="ai-html-record-action">/)
|
||||
assert.doesNotMatch(rendered, /<table>/)
|
||||
})
|
||||
|
||||
test('AI conversation renderer renders document detail action links as buttons', () => {
|
||||
const rendered = renderAiConversationHtml('[查看单据](#ai-open-document-detail:CL-20260221001)')
|
||||
|
||||
|
||||
@@ -3,6 +3,11 @@ import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import {
|
||||
markAiWorkbenchConversationDraftDeleted,
|
||||
loadAiWorkbenchConversationHistory,
|
||||
saveAiWorkbenchConversation
|
||||
} from '../src/utils/aiWorkbenchConversationStore.js'
|
||||
import {
|
||||
clearAssistantSessionSnapshotForDraftClaim,
|
||||
readAssistantSessionSnapshot,
|
||||
@@ -79,6 +84,42 @@ test('claim delete flow invalidates the matching financial assistant session', (
|
||||
assert.match(createViewScript, /toast\('该草稿单据已删除,相关财务助手会话已清空。'\)/)
|
||||
})
|
||||
|
||||
test('deleting an application draft marks AI workbench detail links as unavailable', () => {
|
||||
installWindowStub()
|
||||
const user = { username: 'zhangsan@example.com' }
|
||||
|
||||
saveAiWorkbenchConversation(user, {
|
||||
id: 'conversation-application-draft',
|
||||
title: '申请草稿',
|
||||
messages: [
|
||||
{
|
||||
id: 'assistant-draft-saved',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
'### 申请草稿已保存',
|
||||
'',
|
||||
'| 单据类型 | 单据编号 | 单据状态 | 当前节点 | 操作 |',
|
||||
'| --- | --- | --- | --- | --- |',
|
||||
'| 出差申请 | AP-20260620-DRAFT | 草稿 | 待提交 | [查看](#ai-open-application-detail:claim_id%3Dclaim-draft-1%26claim_no%3DAP-20260620-DRAFT) |'
|
||||
].join('\n')
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const nextHistory = markAiWorkbenchConversationDraftDeleted(user, {
|
||||
claimId: 'claim-draft-1',
|
||||
claimNo: 'AP-20260620-DRAFT'
|
||||
})
|
||||
const conversation = nextHistory.find((item) => item.id === 'conversation-application-draft')
|
||||
|
||||
assert.ok(conversation)
|
||||
assert.match(conversation.messages[0].content, /#ai-deleted-application-detail:/)
|
||||
assert.doesNotMatch(conversation.messages[0].content, /#ai-open-application-detail:/)
|
||||
assert.match(conversation.messages.at(-1).content, /用户已经删除了草稿单据 AP-20260620-DRAFT/)
|
||||
assert.match(conversation.messages.at(-1).content, /草稿单据已经删除,请重新再次申请。/)
|
||||
assert.equal(loadAiWorkbenchConversationHistory(user)[0].messages.length, 2)
|
||||
})
|
||||
|
||||
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)),
|
||||
|
||||
@@ -322,11 +322,12 @@ test('stage risk advice card focuses on document risks without profile or budget
|
||||
assert.match(stageRiskAdviceCard, /stripEmbeddedExplanationText/)
|
||||
assert.match(stageRiskAdviceCard, /if \(summary\) \{[\s\S]*return \[`已补充异常说明:\$\{summary\}`\]/)
|
||||
assert.match(stageRiskAdviceCard, /employee-risk-tone-pill/)
|
||||
assert.match(stageRiskAdviceStyles, /\.employee-risk-decision-panel \{[\s\S]*grid-template-columns: minmax\(0, 1\.15fr\) minmax\(220px, \.85fr\);/)
|
||||
assert.match(stageRiskAdviceStyles, /\.employee-risk-review-summary \{[\s\S]*display: flex;[\s\S]*flex-wrap: wrap;/)
|
||||
assert.match(stageRiskAdviceStyles, /\.employee-risk-review-item \{[\s\S]*flex: 1 1 180px;/)
|
||||
assert.match(stageRiskAdviceStyles, /\.employee-risk-decision-panel \{[\s\S]*grid-template-columns: minmax\(0, 1fr\) minmax\(320px, \.72fr\);/)
|
||||
assert.match(stageRiskAdviceStyles, /\.employee-risk-review-summary \{[\s\S]*display: grid;[\s\S]*grid-template-columns: repeat\(3, minmax\(0, 1fr\)\);/)
|
||||
assert.match(stageRiskAdviceStyles, /\.employee-risk-review-item \{[\s\S]*min-height: 66px;/)
|
||||
assert.match(stageRiskAdviceStyles, /\.employee-risk-profile-list \{[\s\S]*grid-template-columns: 1fr;/)
|
||||
assert.match(stageRiskAdviceStyles, /\.employee-risk-evidence-row summary \{[\s\S]*cursor: pointer;/)
|
||||
assert.match(stageRiskAdviceStyles, /\.employee-risk-evidence-title \{[\s\S]*grid-template-columns: minmax\(0, 1fr\) minmax\(72px, auto\) 48px;/)
|
||||
assert.match(stageRiskAdviceStyles, /\.employee-risk-evidence-title::after \{[\s\S]*content: '展开';/)
|
||||
assert.match(stageRiskAdviceStyles, /\.employee-risk-evidence-row li \{[\s\S]*white-space: normal;/)
|
||||
assert.doesNotMatch(stageRiskAdviceStyles, /grid-row: span 2/)
|
||||
|
||||
@@ -19,6 +19,10 @@ const confirmDialogComponent = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/shared/ConfirmDialog.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const deleteDialogComponent = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/travel/TravelRequestDeleteDialog.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
function extractFunction(source, name) {
|
||||
let signatureIndex = source.indexOf(`function ${name}(`)
|
||||
@@ -138,6 +142,17 @@ test('submit confirm dialog is constrained for laptop viewport height', () => {
|
||||
assert.match(confirmDialogComponent, /@media \(max-width: 720px\) \{[\s\S]*max-height: calc\(100dvh - 28px\)/)
|
||||
})
|
||||
|
||||
test('delete request dialog uses a compact destructive confirmation layout', () => {
|
||||
assert.match(deleteDialogComponent, /size="destructive"/)
|
||||
assert.match(deleteDialogComponent, /actions-align="end"/)
|
||||
assert.match(detailViewScript, /const deleteDialogTarget = computed\(\(\) => request\.value\.documentNo \|\| request\.value\.id \|\| '当前单据'\)/)
|
||||
assert.match(detailViewScript, /const deleteDialogTitle = computed\(\(\) => `确认\$\{deleteActionLabel\.value\}吗?`\)/)
|
||||
assert.doesNotMatch(detailViewScript, /const deleteDialogTitle = computed\(\(\) => `确认\$\{deleteActionLabel\.value\} \$\{request\.value\.id\} 吗?`\)/)
|
||||
assert.match(confirmDialogComponent, /\.shared-confirm-card--destructive \{[\s\S]*width: min\(420px, calc\(100vw - 40px\)\);/)
|
||||
assert.match(confirmDialogComponent, /\.shared-confirm-card--destructive h4 \{[\s\S]*font-size: 19px;/)
|
||||
assert.match(confirmDialogComponent, /\.shared-confirm-card--destructive \.shared-confirm-btn \{[\s\S]*min-width: 112px;[\s\S]*min-height: 38px;/)
|
||||
})
|
||||
|
||||
test('detail header and fallback progress use reimbursement wording', () => {
|
||||
assert.match(detailViewScript, /label:\s*'单据申请日期'/)
|
||||
assert.match(detailExpenseModelScript, /label:\s*'关联单据'/)
|
||||
@@ -145,15 +160,24 @@ test('detail header and fallback progress use reimbursement wording', () => {
|
||||
assert.doesNotMatch(detailViewScript, /label:\s*'保存草稿'/)
|
||||
})
|
||||
|
||||
test('detail delete action is gated by admin-only permission', () => {
|
||||
assert.match(detailViewScript, /const canDeleteRequest = computed\(\(\) => isPlatformAdminUser\(currentUser\.value\)\)/)
|
||||
test('detail delete action allows admins or the applicant while the request is editable', () => {
|
||||
assert.match(detailViewScript, /const canDeleteRequest = computed\(\(\) => \{/)
|
||||
assert.match(detailViewScript, /if \(isPlatformAdminUser\(currentUser\.value\)\) \{[\s\S]*return true/)
|
||||
assert.match(detailViewScript, /return isApplicantDeletableRequest\.value/)
|
||||
assert.match(detailViewScript, /const isApplicantDeletableRequest = computed\(\(\) => \{/)
|
||||
assert.match(detailViewScript, /isCurrentApplicant\.value/)
|
||||
assert.match(detailViewScript, /\['draft', 'supplement', 'returned'\]\.includes\(status\)/)
|
||||
assert.match(detailViewTemplate, /v-else-if="canReturnRequest \|\| canApproveRequest \|\| canPayRequest \|\| canDeleteRequest"/)
|
||||
assert.doesNotMatch(detailViewTemplate, /v-if="canManageCurrentClaim"/)
|
||||
})
|
||||
|
||||
test('detail delete action does not allow applicant or claim manager fallback', () => {
|
||||
assert.doesNotMatch(detailViewScript, /const canDeleteRequest = computed\(\(\) => \{[\s\S]*isCurrentApplicant[\s\S]*\}\)/)
|
||||
assert.doesNotMatch(detailViewScript, /const canDeleteRequest = computed\(\(\) => \{[\s\S]*canManageCurrentClaim[\s\S]*\}\)/)
|
||||
test('detail delete action does not allow in-progress applicant or claim manager fallback', () => {
|
||||
const canDeleteStart = detailViewScript.indexOf('const canDeleteRequest = computed')
|
||||
const canDeleteEnd = detailViewScript.indexOf('\n const isDirectManagerApprovalStage', canDeleteStart)
|
||||
assert.ok(canDeleteStart >= 0)
|
||||
assert.ok(canDeleteEnd > canDeleteStart)
|
||||
const canDeleteBlock = detailViewScript.slice(canDeleteStart, canDeleteEnd)
|
||||
assert.doesNotMatch(canDeleteBlock, /canManageCurrentClaim/)
|
||||
assert.match(detailViewScript, /if \(isApplicationDocument\.value\) {\s*return '删除申请'\s*}/)
|
||||
assert.match(detailViewScript, /当前申请单已进入审批流程,只有退回后申请人本人或系统管理员可以删除。/)
|
||||
assert.match(detailViewScript, /当前申请单已进入审批流程,只有草稿、待补充或退回待提交阶段的申请人本人或系统管理员可以删除。/)
|
||||
})
|
||||
|
||||
@@ -181,6 +181,9 @@ test('AI mode formats saved application draft as a detail table without continui
|
||||
assert.match(aiMode, /function buildInlineApplicationResultTable\(draftPayload = \{\}, options = \{\}\)/)
|
||||
assert.match(aiMode, /\| 单据类型 \| 单据编号 \| 单据状态 \| 当前节点 \| 操作 \|/)
|
||||
assert.match(aiMode, /\[查看\]\(\$\{href\}\)/)
|
||||
assert.match(aiMode, /buildInlineApplicationActionDetailHref\(info\)/)
|
||||
assert.match(aiMode, /params\.set\('claim_id', claimId\)/)
|
||||
assert.match(aiMode, /params\.set\('claim_no', claimNo\)/)
|
||||
|
||||
const resultStart = aiMode.indexOf('function buildInlineApplicationPreviewActionResultText')
|
||||
const resultEnd = aiMode.indexOf('\nfunction buildInlineApplicationDetailAction', resultStart)
|
||||
|
||||
@@ -267,6 +267,18 @@ test('AI mode screen follows the approved reference structure', () => {
|
||||
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card__meta\)/)
|
||||
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-html-action-link\)/)
|
||||
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-html-table-wrap\)/)
|
||||
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-html-record-list\)/)
|
||||
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-html-record-item\)/)
|
||||
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-html-record-meta\)/)
|
||||
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-html-record-action \.ai-html-action-link\)/)
|
||||
assert.match(
|
||||
aiModeStyles,
|
||||
/\.workbench-ai-answer-markdown :deep\(\.ai-html-record-item\)\s*\{[\s\S]*grid-template-columns:\s*minmax\(220px,\s*1\.15fr\)\s*minmax\(260px,\s*0\.85fr\)\s*auto;/
|
||||
)
|
||||
assert.match(
|
||||
aiModeStyles,
|
||||
/\.workbench-ai-answer-markdown :deep\(\.ai-html-record-action \.ai-html-action-link\)[\s\S]*background:\s*#2563eb;/
|
||||
)
|
||||
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-html-image-frame\)/)
|
||||
assert.match(aiMode, /import \{ fetchSettings \} from '\.\.\/\.\.\/services\/settings\.js'/)
|
||||
assert.match(aiMode, /import \{ fetchStewardPlan, fetchStewardPlanStream \} from '\.\.\/\.\.\/services\/steward\.js'/)
|
||||
|
||||
@@ -37,6 +37,10 @@ test('workbench document detail keeps workbench as the return target', () => {
|
||||
|
||||
test('AI detail links wait for full document detail instead of rendering a half snapshot', () => {
|
||||
assert.match(aiMode, /detailLookupOnly:\s*true/)
|
||||
assert.match(aiMode, /params\.get\('claim_id'\)/)
|
||||
assert.match(aiMode, /params\.get\('claim_no'\)/)
|
||||
assert.match(aiMode, /claimId:\s*claimId \|\| reference/)
|
||||
assert.match(aiMode, /claimNo:\s*claimNo \|\| reference/)
|
||||
assert.match(
|
||||
appShell,
|
||||
/v-else-if="activeView === 'documents' && detailMode && !selectedRequest"[\s\S]*正在加载完整单据详情/
|
||||
|
||||
Reference in New Issue
Block a user