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

@@ -1321,6 +1321,8 @@
margin-top: 18px; margin-top: 18px;
border: 1px solid rgba(226, 232, 240, 0.9); border: 1px solid rgba(226, 232, 240, 0.9);
border-radius: 14px; border-radius: 14px;
background: #ffffff;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.04);
} }
.workbench-ai-answer-markdown :deep(table) { .workbench-ai-answer-markdown :deep(table) {
@@ -1342,6 +1344,123 @@
font-weight: 850; font-weight: 850;
} }
.workbench-ai-answer-markdown :deep(.ai-html-record-list) {
display: grid;
gap: 10px;
margin-top: 18px;
}
.workbench-ai-answer-markdown :deep(.ai-html-record-item) {
display: grid;
grid-template-columns: minmax(220px, 1.15fr) minmax(260px, 0.85fr) auto;
gap: 16px;
align-items: center;
padding: 15px 16px;
border: 1px solid rgba(203, 213, 225, 0.86);
border-left: 3px solid #60a5fa;
border-radius: 14px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.9));
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.045);
}
.workbench-ai-answer-markdown :deep(.ai-html-record-main) {
min-width: 0;
display: grid;
gap: 5px;
}
.workbench-ai-answer-markdown :deep(.ai-html-record-kicker) {
width: fit-content;
max-width: 100%;
padding: 2px 8px;
border-radius: 999px;
background: rgba(37, 99, 235, 0.08);
color: #1d4ed8;
font-size: 12px;
font-weight: 850;
line-height: 1.35;
}
.workbench-ai-answer-markdown :deep(.ai-html-record-id) {
color: #0f172a;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 15px;
font-weight: 860;
line-height: 1.45;
overflow-wrap: anywhere;
}
.workbench-ai-answer-markdown :deep(.ai-html-record-reason) {
color: #475569;
font-size: 14px;
font-weight: 660;
line-height: 1.55;
}
.workbench-ai-answer-markdown :deep(.ai-html-record-meta) {
min-width: 0;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(112px, 1fr));
gap: 10px;
}
.workbench-ai-answer-markdown :deep(.ai-html-record-meta-item) {
min-width: 0;
display: grid;
gap: 2px;
}
.workbench-ai-answer-markdown :deep(.ai-html-record-meta-item small) {
color: #94a3b8;
font-size: 12px;
font-weight: 760;
line-height: 1.3;
}
.workbench-ai-answer-markdown :deep(.ai-html-record-meta-item b) {
color: #334155;
font-size: 14px;
font-weight: 780;
line-height: 1.45;
overflow-wrap: anywhere;
}
.workbench-ai-answer-markdown :deep(.ai-html-record-action) {
justify-self: end;
display: inline-flex;
align-items: center;
}
.workbench-ai-answer-markdown :deep(.ai-html-record-action .ai-html-action-link) {
min-height: 32px;
padding: 0 15px;
border-radius: 10px;
background: #2563eb;
color: #ffffff;
box-shadow: none;
}
.workbench-ai-answer-markdown :deep(.ai-html-record-action .ai-html-action-link:hover) {
background: #1d4ed8;
color: #ffffff;
transform: translateY(-1px);
}
.workbench-ai-answer-markdown :deep(.ai-html-action-link.is-disabled) {
cursor: not-allowed;
pointer-events: none;
background: rgba(100, 116, 139, 0.14);
color: #64748b;
box-shadow: none;
}
.workbench-ai-answer-markdown :deep(.ai-html-record-action .ai-html-action-link.is-disabled:hover) {
background: rgba(100, 116, 139, 0.14);
color: #64748b;
transform: none;
}
.workbench-ai-answer-markdown :deep(.ai-html-image-frame) { .workbench-ai-answer-markdown :deep(.ai-html-image-frame) {
margin: 0; margin: 0;
overflow: hidden; overflow: hidden;
@@ -1418,6 +1537,15 @@
} }
@media (max-width: 720px) { @media (max-width: 720px) {
.workbench-ai-answer-markdown :deep(.ai-html-record-item) {
grid-template-columns: 1fr;
align-items: stretch;
}
.workbench-ai-answer-markdown :deep(.ai-html-record-action) {
justify-self: start;
}
.workbench-ai-answer-markdown :deep(.ai-document-card) { .workbench-ai-answer-markdown :deep(.ai-document-card) {
padding: 14px; padding: 14px;
} }

View File

@@ -1,7 +1,7 @@
.employee-risk-profile-card { .employee-risk-profile-card {
display: grid; display: grid;
gap: 12px; gap: 14px;
padding: 14px 16px; padding: 16px 18px;
} }
.employee-risk-head { .employee-risk-head {
@@ -74,17 +74,17 @@
.employee-risk-body { .employee-risk-body {
display: grid; display: grid;
gap: 12px; gap: 14px;
} }
.employee-risk-decision-panel { .employee-risk-decision-panel {
display: grid; display: grid;
grid-template-columns: minmax(0, 1.15fr) minmax(220px, .85fr); grid-template-columns: minmax(0, 1fr) minmax(320px, .72fr);
align-items: stretch; align-items: stretch;
gap: 12px; gap: 18px;
padding: 12px; padding: 16px 18px;
border: 1px solid #e5e7eb; border: 1px solid #e5e7eb;
border-radius: 2px; border-radius: 4px;
background: #ffffff; background: #ffffff;
} }
@@ -101,7 +101,9 @@
.employee-risk-decision-main { .employee-risk-decision-main {
min-width: 0; min-width: 0;
display: grid; display: grid;
gap: 4px; align-content: center;
gap: 8px;
padding: 4px 0;
} }
.employee-risk-decision-main > span, .employee-risk-decision-main > span,
@@ -117,7 +119,7 @@
.employee-risk-decision-main strong { .employee-risk-decision-main strong {
min-width: 0; min-width: 0;
color: #0f172a; color: #0f172a;
font-size: 15px; font-size: 16px;
font-weight: 900; font-weight: 900;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
@@ -143,10 +145,10 @@
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
justify-content: center; justify-content: center;
gap: 5px; gap: 7px;
padding: 10px 12px; padding: 13px 15px;
border: 1px solid #e5e7eb; border: 1px solid #e5e7eb;
border-radius: 2px; border-radius: 4px;
background: #fff; background: #fff;
} }
@@ -175,20 +177,21 @@
} }
.employee-risk-review-summary { .employee-risk-review-summary {
display: flex; display: grid;
flex-wrap: wrap; grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px; gap: 10px;
margin: 0; margin: 0;
} }
.employee-risk-review-item { .employee-risk-review-item {
min-width: 0; min-width: 0;
flex: 1 1 180px;
display: grid; display: grid;
gap: 4px; align-content: start;
padding: 9px 10px; gap: 7px;
min-height: 66px;
padding: 12px 14px;
border: 1px solid #e5e7eb; border: 1px solid #e5e7eb;
border-radius: 2px; border-radius: 4px;
background: #fff; background: #fff;
} }
@@ -232,10 +235,10 @@
.employee-risk-profile-section { .employee-risk-profile-section {
display: grid; display: grid;
gap: 8px; gap: 12px;
padding: 10px 12px; padding: 14px 16px;
border: 1px solid #e5e7eb; border: 1px solid #e5e7eb;
border-radius: 2px; border-radius: 4px;
background: #fff; background: #fff;
} }
@@ -264,7 +267,7 @@
.employee-risk-profile-list { .employee-risk-profile-list {
display: grid; display: grid;
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 8px; gap: 10px;
} }
.employee-risk-evidence-row { .employee-risk-evidence-row {
@@ -272,7 +275,7 @@
display: grid; display: grid;
gap: 0; gap: 0;
border: 1px solid #e2e8f0; border: 1px solid #e2e8f0;
border-radius: 2px; border-radius: 4px;
background: #f8fafc; background: #f8fafc;
overflow: hidden; overflow: hidden;
} }
@@ -301,14 +304,14 @@
} }
.employee-risk-evidence-title { .employee-risk-evidence-title {
min-height: 40px; min-height: 48px;
display: flex; display: grid;
grid-template-columns: minmax(0, 1fr) minmax(72px, auto) 48px;
align-items: center; align-items: center;
justify-content: space-between; column-gap: 14px;
gap: 8px; padding: 10px 14px;
padding: 8px 10px;
color: #0f172a; color: #0f172a;
font-size: 11px; font-size: 12px;
font-weight: 850; font-weight: 850;
} }
@@ -319,10 +322,11 @@
.employee-risk-evidence-title strong { .employee-risk-evidence-title strong {
height: 20px; height: 20px;
flex: 0 0 auto; min-width: 48px;
display: inline-grid; display: inline-grid;
place-items: center; place-items: center;
padding: 0 6px; justify-self: center;
padding: 0 7px;
border-radius: 4px; border-radius: 4px;
background: #eef2f7; background: #eef2f7;
color: #475569; color: #475569;
@@ -343,10 +347,11 @@
.employee-risk-evidence-title::after { .employee-risk-evidence-title::after {
content: '展开'; content: '展开';
flex: 0 0 auto; justify-self: end;
color: #94a3b8; color: #94a3b8;
font-size: 10px; font-size: 10px;
font-weight: 800; font-weight: 800;
text-align: right;
} }
.employee-risk-evidence-row[open] .employee-risk-evidence-title::after { .employee-risk-evidence-row[open] .employee-risk-evidence-title::after {
@@ -355,9 +360,9 @@
.employee-risk-evidence-row ul { .employee-risk-evidence-row ul {
display: grid; display: grid;
gap: 3px; gap: 6px;
margin: 0; margin: 0;
padding: 0 10px 10px 10px; padding: 0 14px 14px 14px;
list-style: none; list-style: none;
align-content: start; align-content: start;
border-top: 1px solid #e2e8f0; border-top: 1px solid #e2e8f0;
@@ -366,8 +371,8 @@
.employee-risk-evidence-row li { .employee-risk-evidence-row li {
min-width: 0; min-width: 0;
color: #475569; color: #475569;
font-size: 11px; font-size: 12px;
line-height: 1.45; line-height: 1.58;
overflow-wrap: anywhere; overflow-wrap: anywhere;
white-space: normal; white-space: normal;
} }
@@ -383,8 +388,8 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.employee-risk-review-item { .employee-risk-review-summary {
flex-basis: 100%; grid-template-columns: 1fr;
} }
.employee-risk-title-wrap, .employee-risk-title-wrap,

View File

@@ -1140,9 +1140,12 @@ function canShowInlineSuggestedActions(message = {}) {
function isInlineSuggestedActionDisabled(action = {}, message = {}) { function isInlineSuggestedActionDisabled(action = {}, message = {}) {
const actionType = String(action?.action_type || '').trim() const actionType = String(action?.action_type || '').trim()
return ( return (
Boolean(action?.disabled) ||
(
[AI_APPLICATION_ACTION_SAVE_DRAFT, AI_APPLICATION_ACTION_SUBMIT].includes(actionType) && [AI_APPLICATION_ACTION_SAVE_DRAFT, AI_APPLICATION_ACTION_SUBMIT].includes(actionType) &&
isApplicationPreviewEstimatePending(message) isApplicationPreviewEstimatePending(message)
) )
)
} }
function resolveInlineApplicationPreviewRows(message) { function resolveInlineApplicationPreviewRows(message) {
@@ -1267,8 +1270,21 @@ function normalizeInlineApplicationResultTableCell(value, fallback = '-') {
} }
function buildInlineApplicationActionDetailHref(reference = '') { function buildInlineApplicationActionDetailHref(reference = '') {
const value = String(reference || '').trim() const source = reference && typeof reference === 'object' ? reference : { reference }
return value ? `${AI_APPLICATION_DETAIL_HREF_PREFIX}${encodeURIComponent(value)}` : '' const claimId = String(source.claimId || source.claim_id || source.id || '').trim()
const claimNo = String(source.claimNo || source.claim_no || source.documentNo || source.document_no || '').trim()
const fallback = String(source.reference || '').trim()
if (claimId || claimNo) {
const params = new URLSearchParams()
if (claimId) {
params.set('claim_id', claimId)
}
if (claimNo) {
params.set('claim_no', claimNo)
}
return `${AI_APPLICATION_DETAIL_HREF_PREFIX}${encodeURIComponent(params.toString())}`
}
return fallback ? `${AI_APPLICATION_DETAIL_HREF_PREFIX}${encodeURIComponent(fallback)}` : ''
} }
function resolveInlineApplicationActionDocumentInfo(draftPayload = {}) { function resolveInlineApplicationActionDocumentInfo(draftPayload = {}) {
@@ -1293,7 +1309,7 @@ function resolveInlineApplicationActionDocumentInfo(draftPayload = {}) {
function buildInlineApplicationResultTable(draftPayload = {}, options = {}) { function buildInlineApplicationResultTable(draftPayload = {}, options = {}) {
const info = resolveInlineApplicationActionDocumentInfo(draftPayload) const info = resolveInlineApplicationActionDocumentInfo(draftPayload)
const reference = info.claimNo || info.claimId const reference = info.claimNo || info.claimId
const href = buildInlineApplicationActionDetailHref(reference) const href = buildInlineApplicationActionDetailHref(info)
const actionText = href ? `[查看](${href})` : '-' const actionText = href ? `[查看](${href})` : '-'
return [ return [
'| 单据类型 | 单据编号 | 单据状态 | 当前节点 | 操作 |', '| 单据类型 | 单据编号 | 单据状态 | 当前节点 | 操作 |',
@@ -1945,22 +1961,40 @@ function parseAiApplicationDetailHref(href = '') {
if (!encodedReference) { if (!encodedReference) {
return null return null
} }
let reference = ''
try { try {
const reference = decodeURIComponent(encodedReference).trim() reference = decodeURIComponent(encodedReference).trim()
return reference ? { reference } : null
} catch { } catch {
return { reference: encodedReference } reference = encodedReference.trim()
} }
if (!reference) {
return null
}
const params = new URLSearchParams(reference)
const claimId = String(params.get('claim_id') || '').trim()
const claimNo = String(params.get('claim_no') || '').trim()
if (claimId || claimNo) {
return {
reference: claimNo || claimId,
claimId,
claimNo
}
}
return { reference }
} }
function buildAiDocumentDetailRequest(detailReference = {}) { function buildAiDocumentDetailRequest(detailReference = {}) {
const reference = String(detailReference.reference || '').trim() const reference = String(detailReference.reference || '').trim()
const isApplication = /^APP?-/i.test(reference) const claimId = String(detailReference.claimId || detailReference.claim_id || '').trim()
const claimNo = String(detailReference.claimNo || detailReference.claim_no || '').trim()
const lookupReference = claimId || reference
const displayReference = claimNo || reference
const isApplication = /^APP?-/i.test(displayReference) || Boolean(claimId || claimNo)
return { return {
id: reference, id: lookupReference,
claimId: reference, claimId: claimId || reference,
claimNo: reference, claimNo: claimNo || reference,
documentNo: reference, documentNo: displayReference,
documentType: isApplication ? 'application' : 'reimbursement', documentType: isApplication ? 'application' : 'reimbursement',
documentTypeCode: isApplication ? 'application' : 'reimbursement', documentTypeCode: isApplication ? 'application' : 'reimbursement',
detailLookupOnly: true, detailLookupOnly: true,
@@ -2371,7 +2405,11 @@ function handleInlineSuggestedAction(action = {}, sourceMessage = null) {
if (actionType === 'open_application_detail') { if (actionType === 'open_application_detail') {
const claimNo = String(actionPayload.claim_no || actionPayload.claimNo || '').trim() const claimNo = String(actionPayload.claim_no || actionPayload.claimNo || '').trim()
const claimId = String(actionPayload.claim_id || actionPayload.claimId || '').trim() const claimId = String(actionPayload.claim_id || actionPayload.claimId || '').trim()
emit('open-document', buildAiDocumentDetailRequest({ reference: claimNo || claimId })) emit('open-document', buildAiDocumentDetailRequest({
reference: claimNo || claimId,
claimId,
claimNo
}))
return return
} }
if (actionPayload.steward_confirm_flow && actionPayload.flow_id === 'travel_reimbursement') { if (actionPayload.steward_confirm_flow && actionPayload.flow_id === 'travel_reimbursement') {

View File

@@ -321,6 +321,55 @@ function handleCancel() {
max-height: min(420px, calc(100dvh - 292px)); max-height: min(420px, calc(100dvh - 292px));
} }
.shared-confirm-card--destructive {
width: min(420px, calc(100vw - 40px));
gap: 12px;
padding: 20px 22px;
border-color: rgba(var(--danger-rgb), 0.16);
border-radius: 6px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.99), rgba(248, 250, 252, 0.97));
box-shadow:
0 18px 42px rgba(15, 23, 42, 0.16),
0 1px 0 rgba(255, 255, 255, 0.92) inset;
}
.shared-confirm-card--destructive .shared-confirm-badge {
min-height: 24px;
padding: 0 9px;
font-size: 11px;
font-weight: 850;
}
.shared-confirm-card--destructive h4 {
font-size: 19px;
line-height: 1.42;
font-weight: 850;
}
.shared-confirm-card--destructive p {
max-width: 34em;
color: #64748b;
font-size: 13px;
line-height: 1.65;
}
.shared-confirm-card--destructive .shared-confirm-actions {
gap: 8px;
padding-top: 2px;
}
.shared-confirm-card--destructive .shared-confirm-btn {
min-width: 112px;
min-height: 38px;
border-radius: 6px;
font-size: 13px;
}
.shared-confirm-card--destructive .shared-confirm-btn.confirm.danger {
box-shadow: 0 10px 20px rgba(var(--danger-rgb), 0.18);
}
.shared-confirm-card--compact h4 { .shared-confirm-card--compact h4 {
font-size: 15px; font-size: 15px;
line-height: 1.35; line-height: 1.35;

View File

@@ -5,6 +5,8 @@
badge-tone="danger" badge-tone="danger"
:title="title" :title="title"
:description="description" :description="description"
size="destructive"
actions-align="end"
cancel-text="取消" cancel-text="取消"
confirm-text="确认删除" confirm-text="确认删除"
busy-text="删除中..." busy-text="删除中..."

View File

@@ -8,6 +8,7 @@ import { useToast } from './useToast.js'
import { fetchAllApprovalExpenseClaims, fetchExpenseClaimDetail } from '../services/reimbursements.js' import { fetchAllApprovalExpenseClaims, fetchExpenseClaimDetail } from '../services/reimbursements.js'
import { fetchOntologyParse } from '../services/ontology.js' import { fetchOntologyParse } from '../services/ontology.js'
import { fetchLatestConversation } from '../services/orchestrator.js' import { fetchLatestConversation } from '../services/orchestrator.js'
import { markAiWorkbenchConversationDraftDeleted } from '../utils/aiWorkbenchConversationStore.js'
import { clearAssistantSessionSnapshotForDraftClaim } from '../utils/assistantSessionSnapshot.js' import { clearAssistantSessionSnapshotForDraftClaim } from '../utils/assistantSessionSnapshot.js'
import { import {
ASSISTANT_SCOPE_SESSION_STEWARD, ASSISTANT_SCOPE_SESSION_STEWARD,
@@ -607,6 +608,7 @@ export function useAppShell() {
const deletedClaimId = String(payload.claimId || payload.claim_id || '').trim() const deletedClaimId = String(payload.claimId || payload.claim_id || '').trim()
if (deletedClaimId) { if (deletedClaimId) {
clearAssistantSessionSnapshotForDraftClaim(resolveCurrentUserId(), deletedClaimId, SESSION_TYPE_EXPENSE) clearAssistantSessionSnapshotForDraftClaim(resolveCurrentUserId(), deletedClaimId, SESSION_TYPE_EXPENSE)
markAiWorkbenchConversationDraftDeleted(currentUser.value || {}, payload)
smartEntryInvalidatedDraftClaimId.value = deletedClaimId smartEntryInvalidatedDraftClaimId.value = deletedClaimId
} }

View File

@@ -1,5 +1,4 @@
import { apiRequest } from './api.js' import { apiRequest } from './api.js'
import { runOrchestrator } from './orchestrator.js'
import { import {
buildApplicationPreviewRows, buildApplicationPreviewRows,
buildApplicationPreviewSubmitText, buildApplicationPreviewSubmitText,
@@ -128,19 +127,12 @@ export function buildAiApplicationPreviewActionPayload({
export function runAiApplicationPreviewAction(params = {}, options = {}) { export function runAiApplicationPreviewAction(params = {}, options = {}) {
const payload = buildAiApplicationPreviewActionPayload(params) const payload = buildAiApplicationPreviewActionPayload(params)
if (params.actionType === AI_APPLICATION_ACTION_SUBMIT) { const isSubmit = params.actionType === AI_APPLICATION_ACTION_SUBMIT
return apiRequest('/reimbursements/application-preview-action', { return apiRequest('/reimbursements/application-preview-action', {
method: 'POST', method: 'POST',
body: JSON.stringify(payload), body: JSON.stringify(payload),
timeoutMs: 45000, timeoutMs: isSubmit ? 45000 : 30000,
timeoutMessage: '申请提交处理超时,请稍后重试。', timeoutMessage: isSubmit ? '申请提交处理超时,请稍后重试。' : '申请草稿保存超时,请稍后重试。',
...options
})
}
return runOrchestrator(payload, {
timeoutMs: 75000,
timeoutMessage: '申请草稿保存超时,请稍后重试。',
...options ...options
}) })
} }

View File

@@ -21,6 +21,7 @@ const BUSINESS_FIELD_LABELS = new Set([
]) ])
const APPLICATION_DETAIL_HREF_PREFIX = '#ai-open-application-detail:' 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 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_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_' 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) 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 = '') { function isDocumentDetailHref(href = '') {
return String(href || '').trim().startsWith(DOCUMENT_DETAIL_HREF_PREFIX) return String(href || '').trim().startsWith(DOCUMENT_DETAIL_HREF_PREFIX)
} }
@@ -79,6 +84,17 @@ function sanitizeImageSrc(src = '') {
function renderLinkHtml(label = '', href = '') { function renderLinkHtml(label = '', href = '') {
const sanitizedHref = sanitizeHref(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)) { if (isApplicationDetailHref(href)) {
return [ return [
`<a href="${sanitizedHref}"`, `<a href="${sanitizedHref}"`,
@@ -482,6 +498,80 @@ function renderOrderedList(items = []) {
].join('') ].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 = []) { function renderTable(lines = []) {
const rows = lines.map((line) => parseTableRow(line)).filter((row) => row.length) const rows = lines.map((line) => parseTableRow(line)).filter((row) => row.length)
if (rows.length < 2) { if (rows.length < 2) {
@@ -489,6 +579,10 @@ function renderTable(lines = []) {
} }
const header = rows[0] const header = rows[0]
const bodyRows = rows.slice(2) const bodyRows = rows.slice(2)
const normalizedHeader = header.map((cell) => normalizeTableHeaderCell(cell))
if (isDocumentRecordTable(normalizedHeader)) {
return renderDocumentRecordList(header, bodyRows)
}
return [ return [
'<div class="ai-html-table-wrap">', '<div class="ai-html-table-wrap">',

View File

@@ -1,11 +1,144 @@
const STORAGE_KEY_PREFIX = 'x-financial:workbench-ai-conversations' const STORAGE_KEY_PREFIX = 'x-financial:workbench-ai-conversations'
const MAX_CONVERSATION_HISTORY = 30 const MAX_CONVERSATION_HISTORY = 30
const MAX_STORED_MESSAGES = 80 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) { function safeString(value) {
return String(value || '').trim() 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 = {}) { function resolveUserStorageKey(user = {}) {
const identity = safeString(user.username || user.email || user.name || 'anonymous') const identity = safeString(user.username || user.email || user.name || 'anonymous')
return `${STORAGE_KEY_PREFIX}:${identity || 'anonymous'}` return `${STORAGE_KEY_PREFIX}:${identity || 'anonymous'}`
@@ -153,3 +286,46 @@ export function deleteAiWorkbenchConversation(user = {}, conversationId = '') {
writeStoredList(user, nextList) writeStoredList(user, nextList)
return loadAiWorkbenchConversationHistory(user) 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)
}

View File

@@ -154,7 +154,7 @@
@back-to-requests="closeRequestDetail" @back-to-requests="closeRequestDetail"
@open-assistant="openSmartEntry" @open-assistant="openSmartEntry"
@request-updated="handleRequestUpdated" @request-updated="handleRequestUpdated"
@request-deleted="handleRequestDeleted" @request-deleted="handleDetailRequestDeleted"
/> />
<section <section
@@ -460,6 +460,11 @@ function handleAiConversationHistoryChange(payload = []) {
aiConversationHistory.value = Array.isArray(payload) ? payload : [] aiConversationHistory.value = Array.isArray(payload) ? payload : []
} }
async function handleDetailRequestDeleted(payload = {}) {
await handleRequestDeleted(payload)
aiConversationHistory.value = loadAiWorkbenchConversationHistory(currentUser.value || {})
}
function handleAiConversationRename(payload = {}) { function handleAiConversationRename(payload = {}) {
const conversationId = String(payload.id || '').trim() const conversationId = String(payload.id || '').trim()
const title = String(payload.title || '').trim() const title = String(payload.title || '').trim()

View File

@@ -713,7 +713,19 @@ export default {
)) ))
const canManageCurrentClaim = computed(() => canManageExpenseClaims(currentUser.value)) const canManageCurrentClaim = computed(() => canManageExpenseClaims(currentUser.value))
const isArchivedRequest = computed(() => isArchivedRequestView(request.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 isDirectManagerApprovalStage = computed(() => {
const node = String(request.value.node || request.value.approvalStage || '').trim() const node = String(request.value.node || request.value.approvalStage || '').trim()
return node === '直属领导审批' return node === '直属领导审批'
@@ -926,11 +938,12 @@ export default {
} }
return isDraftRequest.value ? '删除草稿' : '删除单据' 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(() => const deleteDialogDescription = computed(() =>
isDraftRequest.value isDraftRequest.value
? '删除后该草稿及其当前费用明细将不可恢复,请确认本次操作。' ? `${deleteDialogTarget.value} 删除后该草稿及其当前费用明细将不可恢复`
: `删除后该${isApplicationDocument.value ? '申请单' : '报销单'}及费用明细将不可恢复,请确认本次操作` : `${deleteDialogTarget.value} 删除后${isApplicationDocument.value ? '申请单' : '报销单'}及费用明细将不可恢复。`
) )
const actionBusy = computed(() => const actionBusy = computed(() =>
Boolean(savingExpenseId.value) Boolean(savingExpenseId.value)
@@ -2514,8 +2527,8 @@ export default {
isArchivedRequest.value isArchivedRequest.value
? '已归档单据不能删除,只有高级管理员可以执行删除。' ? '已归档单据不能删除,只有高级管理员可以执行删除。'
: isApplicationDocument.value : isApplicationDocument.value
? '当前申请单已进入审批流程,只有退回后申请人本人或系统管理员可以删除。' ? '当前申请单已进入审批流程,只有草稿、待补充或退回待提交阶段的申请人本人或系统管理员可以删除。'
: '当前单据已进入流程,只有高级财务人员可以删除。' : '当前单据已进入流程,只有草稿、待补充或退回待提交阶段的申请人本人或系统管理员可以删除。'
) )
return return
} }
@@ -2542,7 +2555,11 @@ export default {
const payload = await deleteExpenseClaim(request.value.claimId) const payload = await deleteExpenseClaim(request.value.claimId)
deleteDialogOpen.value = false deleteDialogOpen.value = false
toast(payload?.message || `${request.value.id} ${isApplicationDocument.value ? '申请单' : '报销单'}已删除。`) 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) { } catch (error) {
toast(error?.message || '删除单据失败,请稍后重试。') toast(error?.message || '删除单据失败,请稍后重试。')
} finally { } finally {

View File

@@ -56,15 +56,27 @@ async function testSubmitActionUsesFastPreviewEndpoint() {
assert.equal(body.context_json.application_preview.fields.transportMode, '火车') assert.equal(body.context_json.application_preview.fields.transportMode, '火车')
} }
async function testSaveDraftActionKeepsOrchestratorPath() { async function testSaveDraftActionUsesFastPreviewEndpoint() {
let capturedUrl = '' let capturedUrl = ''
let capturedOptions = null
global.fetch = async (url) => { global.fetch = async (url, options) => {
capturedUrl = String(url) capturedUrl = String(url)
capturedOptions = options
return { return {
ok: true, ok: true,
async json() { 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: '张三' } 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() { async function run() {
await testSubmitActionUsesFastPreviewEndpoint() await testSubmitActionUsesFastPreviewEndpoint()
await testSaveDraftActionKeepsOrchestratorPath() await testSaveDraftActionUsesFastPreviewEndpoint()
console.log('ai-application-preview-actions tests passed') console.log('ai-application-preview-actions tests passed')
} }

View File

@@ -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', () => { test('AI conversation renderer renders application detail action links as buttons', () => {
const rendered = renderAiConversationHtml([ const rendered = renderAiConversationHtml([
'| 单据编号 | 操作 |', '| 单据类型 | 单据编号 | 单据状态 | 当前节点 | 操作 |',
'| --- | --- |', '| --- | --- | --- | --- | --- |',
'| AP-OVERLAP | [查看](#ai-open-application-detail:AP-OVERLAP) |' '| 出差申请 | AP-OVERLAP | 草稿 | 待提交 | [查看](#ai-open-application-detail:AP-OVERLAP) |'
].join('\n')) ].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, /class="ai-html-action-link ai-html-action-link-application"/)
assert.match(rendered, /data-ai-action="open-application-detail"/) assert.match(rendered, /data-ai-action="open-application-detail"/)
assert.match(rendered, /href="#ai-open-application-detail:AP-OVERLAP"/) 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/) 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', () => { test('AI conversation renderer renders document detail action links as buttons', () => {
const rendered = renderAiConversationHtml('[查看单据](#ai-open-document-detail:CL-20260221001)') const rendered = renderAiConversationHtml('[查看单据](#ai-open-document-detail:CL-20260221001)')

View File

@@ -3,6 +3,11 @@ import { readFileSync } from 'node:fs'
import test from 'node:test' import test from 'node:test'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import {
markAiWorkbenchConversationDraftDeleted,
loadAiWorkbenchConversationHistory,
saveAiWorkbenchConversation
} from '../src/utils/aiWorkbenchConversationStore.js'
import { import {
clearAssistantSessionSnapshotForDraftClaim, clearAssistantSessionSnapshotForDraftClaim,
readAssistantSessionSnapshot, readAssistantSessionSnapshot,
@@ -79,6 +84,42 @@ test('claim delete flow invalidates the matching financial assistant session', (
assert.match(createViewScript, /toast\('该草稿单据已删除,相关财务助手会话已清空。'\)/) 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', () => { test('saving a draft keeps the financial assistant open for continued work', () => {
const appShellScript = readFileSync( const appShellScript = readFileSync(
fileURLToPath(new URL('../src/composables/useAppShell.js', import.meta.url)), fileURLToPath(new URL('../src/composables/useAppShell.js', import.meta.url)),

View File

@@ -322,11 +322,12 @@ test('stage risk advice card focuses on document risks without profile or budget
assert.match(stageRiskAdviceCard, /stripEmbeddedExplanationText/) assert.match(stageRiskAdviceCard, /stripEmbeddedExplanationText/)
assert.match(stageRiskAdviceCard, /if \(summary\) \{[\s\S]*return \[`已补充异常说明:\$\{summary\}`\]/) assert.match(stageRiskAdviceCard, /if \(summary\) \{[\s\S]*return \[`已补充异常说明:\$\{summary\}`\]/)
assert.match(stageRiskAdviceCard, /employee-risk-tone-pill/) 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-decision-panel \{[\s\S]*grid-template-columns: minmax\(0, 1fr\) minmax\(320px, \.72fr\);/)
assert.match(stageRiskAdviceStyles, /\.employee-risk-review-summary \{[\s\S]*display: flex;[\s\S]*flex-wrap: wrap;/) 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]*flex: 1 1 180px;/) 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-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-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-title::after \{[\s\S]*content: '展开';/)
assert.match(stageRiskAdviceStyles, /\.employee-risk-evidence-row li \{[\s\S]*white-space: normal;/) assert.match(stageRiskAdviceStyles, /\.employee-risk-evidence-row li \{[\s\S]*white-space: normal;/)
assert.doesNotMatch(stageRiskAdviceStyles, /grid-row: span 2/) assert.doesNotMatch(stageRiskAdviceStyles, /grid-row: span 2/)

View File

@@ -19,6 +19,10 @@ const confirmDialogComponent = readFileSync(
fileURLToPath(new URL('../src/components/shared/ConfirmDialog.vue', import.meta.url)), fileURLToPath(new URL('../src/components/shared/ConfirmDialog.vue', import.meta.url)),
'utf8' 'utf8'
) )
const deleteDialogComponent = readFileSync(
fileURLToPath(new URL('../src/components/travel/TravelRequestDeleteDialog.vue', import.meta.url)),
'utf8'
)
function extractFunction(source, name) { function extractFunction(source, name) {
let signatureIndex = source.indexOf(`function ${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\)/) 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', () => { test('detail header and fallback progress use reimbursement wording', () => {
assert.match(detailViewScript, /label:\s*'单据申请日期'/) assert.match(detailViewScript, /label:\s*'单据申请日期'/)
assert.match(detailExpenseModelScript, /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*'保存草稿'/) assert.doesNotMatch(detailViewScript, /label:\s*'保存草稿'/)
}) })
test('detail delete action is gated by admin-only permission', () => { test('detail delete action allows admins or the applicant while the request is editable', () => {
assert.match(detailViewScript, /const canDeleteRequest = computed\(\(\) => isPlatformAdminUser\(currentUser\.value\)\)/) 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.match(detailViewTemplate, /v-else-if="canReturnRequest \|\| canApproveRequest \|\| canPayRequest \|\| canDeleteRequest"/)
assert.doesNotMatch(detailViewTemplate, /v-if="canManageCurrentClaim"/) assert.doesNotMatch(detailViewTemplate, /v-if="canManageCurrentClaim"/)
}) })
test('detail delete action does not allow applicant or claim manager fallback', () => { test('detail delete action does not allow in-progress applicant or claim manager fallback', () => {
assert.doesNotMatch(detailViewScript, /const canDeleteRequest = computed\(\(\) => \{[\s\S]*isCurrentApplicant[\s\S]*\}\)/) const canDeleteStart = detailViewScript.indexOf('const canDeleteRequest = computed')
assert.doesNotMatch(detailViewScript, /const canDeleteRequest = computed\(\(\) => \{[\s\S]*canManageCurrentClaim[\s\S]*\}\)/) 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, /if \(isApplicationDocument\.value\) {\s*return '删除申请'\s*}/)
assert.match(detailViewScript, /当前申请单已进入审批流程,只有退回后申请人本人或系统管理员可以删除。/) assert.match(detailViewScript, /当前申请单已进入审批流程,只有草稿、待补充或退回待提交阶段的申请人本人或系统管理员可以删除。/)
}) })

View File

@@ -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, /function buildInlineApplicationResultTable\(draftPayload = \{\}, options = \{\}\)/)
assert.match(aiMode, /\| 单据类型 \| 单据编号 \| 单据状态 \| 当前节点 \| 操作 \|/) assert.match(aiMode, /\| 单据类型 \| 单据编号 \| 单据状态 \| 当前节点 \| 操作 \|/)
assert.match(aiMode, /\[查看\]\(\$\{href\}\)/) 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 resultStart = aiMode.indexOf('function buildInlineApplicationPreviewActionResultText')
const resultEnd = aiMode.indexOf('\nfunction buildInlineApplicationDetailAction', resultStart) const resultEnd = aiMode.indexOf('\nfunction buildInlineApplicationDetailAction', resultStart)

View File

@@ -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-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-action-link\)/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-html-table-wrap\)/) 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(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-html-image-frame\)/)
assert.match(aiMode, /import \{ fetchSettings \} from '\.\.\/\.\.\/services\/settings\.js'/) assert.match(aiMode, /import \{ fetchSettings \} from '\.\.\/\.\.\/services\/settings\.js'/)
assert.match(aiMode, /import \{ fetchStewardPlan, fetchStewardPlanStream \} from '\.\.\/\.\.\/services\/steward\.js'/) assert.match(aiMode, /import \{ fetchStewardPlan, fetchStewardPlanStream \} from '\.\.\/\.\.\/services\/steward\.js'/)

View File

@@ -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', () => { test('AI detail links wait for full document detail instead of rendering a half snapshot', () => {
assert.match(aiMode, /detailLookupOnly:\s*true/) 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( assert.match(
appShell, appShell,
/v-else-if="activeView === 'documents' && detailMode && !selectedRequest"[\s\S]*正在加载完整单据详情/ /v-else-if="activeView === 'documents' && detailMode && !selectedRequest"[\s\S]*正在加载完整单据详情/