',
+ '',
+ hasMeaningfulTableValue(documentType) ? `
${renderInlineHtml(documentType)}` : '',
+ hasMeaningfulTableValue(documentNo) ? `
${renderInlineHtml(documentNo)}` : '',
+ hasMeaningfulTableValue(reason) ? `
${renderInlineHtml(reason)}
` : '',
+ '
',
+ '',
+ renderRecordMeta('申请时间', applyTime),
+ renderRecordMeta('状态', status),
+ renderRecordMeta('当前节点', stage),
+ '
',
+ hasMeaningfulTableValue(action) ? `${renderInlineHtml(action)}
` : '',
+ ''
+ ].join('')
+ }).filter(Boolean)
+
+ return [
+ '',
diff --git a/web/src/utils/aiWorkbenchConversationStore.js b/web/src/utils/aiWorkbenchConversationStore.js
index 753559b..77dd23d 100644
--- a/web/src/utils/aiWorkbenchConversationStore.js
+++ b/web/src/utils/aiWorkbenchConversationStore.js
@@ -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)
+}
diff --git a/web/src/views/AppShellRouteView.vue b/web/src/views/AppShellRouteView.vue
index f3b55a5..53bed1b 100644
--- a/web/src/views/AppShellRouteView.vue
+++ b/web/src/views/AppShellRouteView.vue
@@ -154,7 +154,7 @@
@back-to-requests="closeRequestDetail"
@open-assistant="openSmartEntry"
@request-updated="handleRequestUpdated"
- @request-deleted="handleRequestDeleted"
+ @request-deleted="handleDetailRequestDeleted"
/>
canManageExpenseClaims(currentUser.value))
const isArchivedRequest = computed(() => isArchivedRequestView(request.value))
- const canDeleteRequest = computed(() => isPlatformAdminUser(currentUser.value))
+ const isApplicantDeletableRequest = computed(() => {
+ if (!isCurrentApplicant.value) {
+ return false
+ }
+ const status = String(request.value.status || request.value.approvalKey || '').trim().toLowerCase()
+ return ['draft', 'supplement', 'returned'].includes(status)
+ })
+ const canDeleteRequest = computed(() => {
+ if (isPlatformAdminUser(currentUser.value)) {
+ return true
+ }
+ return isApplicantDeletableRequest.value
+ })
const isDirectManagerApprovalStage = computed(() => {
const node = String(request.value.node || request.value.approvalStage || '').trim()
return node === '直属领导审批'
@@ -926,11 +938,12 @@ export default {
}
return isDraftRequest.value ? '删除草稿' : '删除单据'
})
- const deleteDialogTitle = computed(() => `确认${deleteActionLabel.value} ${request.value.id} 吗?`)
+ const deleteDialogTarget = computed(() => request.value.documentNo || request.value.id || '当前单据')
+ const deleteDialogTitle = computed(() => `确认${deleteActionLabel.value}吗?`)
const deleteDialogDescription = computed(() =>
isDraftRequest.value
- ? '删除后该草稿及其当前费用明细将不可恢复,请确认本次操作。'
- : `删除后该${isApplicationDocument.value ? '申请单' : '报销单'}及费用明细将不可恢复,请确认本次操作。`
+ ? `${deleteDialogTarget.value} 删除后,该草稿及其当前费用明细将不可恢复。`
+ : `${deleteDialogTarget.value} 删除后,该${isApplicationDocument.value ? '申请单' : '报销单'}及费用明细将不可恢复。`
)
const actionBusy = computed(() =>
Boolean(savingExpenseId.value)
@@ -2514,8 +2527,8 @@ export default {
isArchivedRequest.value
? '已归档单据不能删除,只有高级管理员可以执行删除。'
: isApplicationDocument.value
- ? '当前申请单已进入审批流程,只有退回后申请人本人或系统管理员可以删除。'
- : '当前单据已进入流程,只有高级财务人员可以删除。'
+ ? '当前申请单已进入审批流程,只有草稿、待补充或退回待提交阶段的申请人本人或系统管理员可以删除。'
+ : '当前单据已进入流程,只有草稿、待补充或退回待提交阶段的申请人本人或系统管理员可以删除。'
)
return
}
@@ -2542,7 +2555,11 @@ export default {
const payload = await deleteExpenseClaim(request.value.claimId)
deleteDialogOpen.value = false
toast(payload?.message || `${request.value.id} ${isApplicationDocument.value ? '申请单' : '报销单'}已删除。`)
- emit('request-deleted', { claimId: request.value.claimId })
+ emit('request-deleted', {
+ claimId: request.value.claimId,
+ claimNo: request.value.claimNo || request.value.documentNo || request.value.id,
+ documentNo: request.value.documentNo || request.value.id
+ })
} catch (error) {
toast(error?.message || '删除单据失败,请稍后重试。')
} finally {
diff --git a/web/tests/ai-application-preview-actions.test.mjs b/web/tests/ai-application-preview-actions.test.mjs
index 29f5461..4094729 100644
--- a/web/tests/ai-application-preview-actions.test.mjs
+++ b/web/tests/ai-application-preview-actions.test.mjs
@@ -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')
}
diff --git a/web/tests/ai-conversation-html-renderer.test.mjs b/web/tests/ai-conversation-html-renderer.test.mjs
index 5a1cadd..4d66720 100644
--- a/web/tests/ai-conversation-html-renderer.test.mjs
+++ b/web/tests/ai-conversation-html-renderer.test.mjs
@@ -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, //)
+ assert.match(rendered, /
/)
+ assert.match(rendered, /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, //)
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, //)
+ assert.match(rendered, /申请时间/)
+ assert.match(rendered, /2026-02-20 至 2026-02-23/)
+ assert.match(rendered, /辅助国网仿生产服务器部署/)
+ assert.match(rendered, /
/)
+ assert.doesNotMatch(rendered, /
/)
+})
+
test('AI conversation renderer renders document detail action links as buttons', () => {
const rendered = renderAiConversationHtml('[查看单据](#ai-open-document-detail:CL-20260221001)')
diff --git a/web/tests/assistant-session-draft-delete.test.mjs b/web/tests/assistant-session-draft-delete.test.mjs
index ae0ac30..400edd6 100644
--- a/web/tests/assistant-session-draft-delete.test.mjs
+++ b/web/tests/assistant-session-draft-delete.test.mjs
@@ -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)),
diff --git a/web/tests/travel-request-detail-risk-advice.test.mjs b/web/tests/travel-request-detail-risk-advice.test.mjs
index 79b6332..9cd9ae1 100644
--- a/web/tests/travel-request-detail-risk-advice.test.mjs
+++ b/web/tests/travel-request-detail-risk-advice.test.mjs
@@ -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/)
diff --git a/web/tests/travel-request-detail-submit-confirm.test.mjs b/web/tests/travel-request-detail-submit-confirm.test.mjs
index aa3ca24..86c8492 100644
--- a/web/tests/travel-request-detail-submit-confirm.test.mjs
+++ b/web/tests/travel-request-detail-submit-confirm.test.mjs
@@ -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, /当前申请单已进入审批流程,只有草稿、待补充或退回待提交阶段的申请人本人或系统管理员可以删除。/)
})
diff --git a/web/tests/workbench-ai-mode-expense-scene-action.test.mjs b/web/tests/workbench-ai-mode-expense-scene-action.test.mjs
index e984a04..6eee94e 100644
--- a/web/tests/workbench-ai-mode-expense-scene-action.test.mjs
+++ b/web/tests/workbench-ai-mode-expense-scene-action.test.mjs
@@ -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)
diff --git a/web/tests/workbench-ai-mode-switch.test.mjs b/web/tests/workbench-ai-mode-switch.test.mjs
index 0ca5370..f99cb1c 100644
--- a/web/tests/workbench-ai-mode-switch.test.mjs
+++ b/web/tests/workbench-ai-mode-switch.test.mjs
@@ -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'/)
diff --git a/web/tests/workbench-detail-return.test.mjs b/web/tests/workbench-detail-return.test.mjs
index 1d46220..5391f08 100644
--- a/web/tests/workbench-detail-return.test.mjs
+++ b/web/tests/workbench-detail-return.test.mjs
@@ -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]*正在加载完整单据详情/