diff --git a/web/src/assets/styles/components/personal-workbench-ai-mode.css b/web/src/assets/styles/components/personal-workbench-ai-mode.css index 50cfd73..3142704 100644 --- a/web/src/assets/styles/components/personal-workbench-ai-mode.css +++ b/web/src/assets/styles/components/personal-workbench-ai-mode.css @@ -1094,30 +1094,84 @@ list-style: decimal; } +.workbench-ai-answer-markdown :deep(.ai-document-query-summary) { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px 12px; + margin-top: 0; + padding: 2px 0 0; + border: 0; + background: transparent; + color: #334155; +} + +.workbench-ai-answer-markdown :deep(.ai-document-query-summary__label) { + display: inline-flex; + align-items: center; + min-height: auto; + padding: 0; + border-radius: 0; + background: transparent; + color: #64748b; + font-size: 13px; + font-weight: 760; + line-height: 1.2; +} + +.workbench-ai-answer-markdown :deep(.ai-document-query-summary__scope) { + min-width: 0; + color: #0f172a; + font-size: 15px; + font-weight: 860; + line-height: 1.5; + overflow-wrap: anywhere; +} + +.workbench-ai-answer-markdown :deep(.ai-document-query-summary__count) { + display: inline-flex; + align-items: baseline; + gap: 4px; + color: #64748b; + font-size: 14px; + font-weight: 700; + line-height: 1.4; +} + +.workbench-ai-answer-markdown :deep(.ai-document-query-summary__count strong) { + color: #1d4ed8; + font-size: 14px; + font-weight: 900; +} + .workbench-ai-answer-markdown :deep(.ai-document-card-list) { display: grid; - gap: 12px; - margin-top: 18px; + gap: 16px; + margin-top: 14px; } .workbench-ai-answer-markdown :deep(.ai-document-card) { position: relative; display: grid; - gap: 12px; - padding: 16px 18px; - border: 1px solid rgba(226, 232, 240, 0.9); - border-left: 3px solid #cbd5e1; + gap: 0; + overflow: hidden; + padding: 0; + border: 1px solid rgba(203, 213, 225, 0.76); + border-left: 0; border-radius: 12px; - background: #ffffff; - box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04), 0 4px 12px rgba(15, 23, 42, 0.04); + background: rgba(255, 255, 255, 0.96); + box-shadow: + 0 1px 2px rgba(15, 23, 42, 0.035), + 0 10px 26px rgba(15, 23, 42, 0.045); color: #334155; animation: workbenchDocumentCardReveal 360ms cubic-bezier(0.2, 0.8, 0.2, 1) both; transition: border-color 180ms ease, box-shadow 180ms ease, transform 180ms ease; } .workbench-ai-answer-markdown :deep(.ai-document-card:hover) { - border-color: rgba(148, 163, 184, 0.7); - box-shadow: 0 2px 4px rgba(15, 23, 42, 0.05), 0 8px 20px rgba(15, 23, 42, 0.07); + border-color: rgba(148, 163, 184, 0.72); + background: #ffffff; + box-shadow: 0 8px 22px rgba(15, 23, 42, 0.065); transform: translateY(-1px); } @@ -1133,186 +1187,161 @@ animation-delay: 120ms; } -/* 状态语义色:左侧边条颜色随状态变化,一眼判断当前阶段 */ -.workbench-ai-answer-markdown :deep(.ai-document-card.is-pending) { - border-left-color: #2563eb; -} - -.workbench-ai-answer-markdown :deep(.ai-document-card.is-success) { - border-left-color: #16a34a; -} - -.workbench-ai-answer-markdown :deep(.ai-document-card.is-warning) { - border-left-color: #d97706; -} - -.workbench-ai-answer-markdown :deep(.ai-document-card.is-danger) { - border-left-color: #dc2626; -} - -/* 卡片头部:状态 + 类型(左) · 单据编号(右) */ +/* 状态语义色:头部浅底色和状态文字随单据状态变化 */ .workbench-ai-answer-markdown :deep(.ai-document-card__head) { display: flex; align-items: center; justify-content: space-between; - gap: 12px; + gap: 16px; min-width: 0; -} - -.workbench-ai-answer-markdown :deep(.ai-document-card__head-left) { - display: flex; - align-items: center; - gap: 8px; - min-width: 0; - flex-wrap: wrap; + padding: 13px 18px; + background: rgba(37, 99, 235, 0.11); } .workbench-ai-answer-markdown :deep(.ai-document-card__status) { display: inline-flex; align-items: center; - min-height: 22px; - padding: 0 9px; - border-radius: 6px; - background: rgba(148, 163, 184, 0.16); - color: #475569; - font-size: 12px; - font-weight: 700; - line-height: 1.2; + min-height: 24px; + padding: 0; + border-radius: 0; + background: transparent; + color: #1d4ed8; + font-size: 15px; + font-weight: 860; + line-height: 1.3; white-space: nowrap; } +.workbench-ai-answer-markdown :deep(.ai-document-card.is-success .ai-document-card__head) { + background: rgba(22, 163, 74, 0.1); +} + +.workbench-ai-answer-markdown :deep(.ai-document-card.is-warning .ai-document-card__head) { + background: rgba(217, 119, 6, 0.12); +} + +.workbench-ai-answer-markdown :deep(.ai-document-card.is-danger .ai-document-card__head) { + background: rgba(220, 38, 38, 0.1); +} + .workbench-ai-answer-markdown :deep(.ai-document-card.is-pending .ai-document-card__status) { - background: rgba(37, 99, 235, 0.1); color: #1d4ed8; } .workbench-ai-answer-markdown :deep(.ai-document-card.is-success .ai-document-card__status) { - background: rgba(22, 163, 74, 0.1); color: #15803d; } .workbench-ai-answer-markdown :deep(.ai-document-card.is-warning .ai-document-card__status) { - background: rgba(217, 119, 6, 0.1); color: #b45309; } .workbench-ai-answer-markdown :deep(.ai-document-card.is-danger .ai-document-card__status) { - background: rgba(220, 38, 38, 0.1); color: #b91c1c; } -.workbench-ai-answer-markdown :deep(.ai-document-card__type) { - color: #64748b; - font-size: 12px; - font-weight: 500; - line-height: 1.3; -} - -.workbench-ai-answer-markdown :deep(.ai-document-card__number) { - flex: 0 0 auto; - color: #94a3b8; - font-size: 12px; - font-weight: 500; - line-height: 1.3; - overflow-wrap: anywhere; -} - -/* 卡片主体:事由(主焦点) + 申请人/部门(次焦点) */ .workbench-ai-answer-markdown :deep(.ai-document-card__body) { display: grid; - gap: 6px; + gap: 14px; min-width: 0; + padding: 16px 18px 18px; } .workbench-ai-answer-markdown :deep(.ai-document-card__reason) { display: -webkit-box; - color: #0f172a; - font-size: 16px; - font-weight: 700; + min-width: 0; + color: #1e40af; + font-size: 15px; + font-weight: 760; line-height: 1.45; overflow: hidden; - -webkit-line-clamp: 2; + -webkit-line-clamp: 1; -webkit-box-orient: vertical; } -.workbench-ai-answer-markdown :deep(.ai-document-card__owner-line) { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 6px; - min-width: 0; +.workbench-ai-answer-markdown :deep(.ai-document-card.is-success .ai-document-card__reason) { + color: #166534; } -.workbench-ai-answer-markdown :deep(.ai-document-card__owner) { - color: #1e293b; - font-size: 13px; - font-weight: 600; - line-height: 1.3; +.workbench-ai-answer-markdown :deep(.ai-document-card.is-warning .ai-document-card__reason) { + color: #92400e; } -.workbench-ai-answer-markdown :deep(.ai-document-card__dept) { - color: #64748b; - font-size: 13px; - font-weight: 500; - line-height: 1.3; +.workbench-ai-answer-markdown :deep(.ai-document-card.is-danger .ai-document-card__reason) { + color: #991b1b; } -.workbench-ai-answer-markdown :deep(.ai-document-card__dot) { - color: #cbd5e1; - font-size: 12px; - font-weight: 700; -} - -/* 卡片底部:辅助元信息(左) · 金额(右) · 操作 */ -.workbench-ai-answer-markdown :deep(.ai-document-card__foot) { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding-top: 12px; - border-top: 1px solid rgba(226, 232, 240, 0.9); -} - -.workbench-ai-answer-markdown :deep(.ai-document-card__meta) { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 6px; - min-width: 0; - flex: 1 1 auto; -} - -.workbench-ai-answer-markdown :deep(.ai-document-card__meta-item) { - color: #64748b; - font-size: 12px; - font-weight: 500; - line-height: 1.3; -} - -.workbench-ai-answer-markdown :deep(.ai-document-card__amount-block) { +.workbench-ai-answer-markdown :deep(.ai-document-card__details) { display: grid; - justify-items: end; - gap: 1px; - flex: 0 0 auto; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px 28px; + padding-top: 2px; + border-top: 1px solid rgba(203, 213, 225, 0.76); } -.workbench-ai-answer-markdown :deep(.ai-document-card__amount-label) { - color: #94a3b8; - font-size: 11px; - font-weight: 500; - line-height: 1.2; +.workbench-ai-answer-markdown :deep(.ai-document-card__field) { + display: grid; + grid-template-columns: 86px minmax(0, 1fr); + align-items: center; + gap: 14px; + min-width: 0; +} + +.workbench-ai-answer-markdown :deep(.ai-document-card__field--action) { + grid-column: 1 / -1; +} + +.workbench-ai-answer-markdown :deep(.ai-document-card__label) { + color: #8a94a6; + font-size: 13px; + font-weight: 640; + line-height: 1.4; + white-space: nowrap; +} + +.workbench-ai-answer-markdown :deep(.ai-document-card__value) { + min-width: 0; + color: #334155; + font-size: 14px; + font-weight: 720; + line-height: 1.45; + overflow-wrap: anywhere; } .workbench-ai-answer-markdown :deep(.ai-document-card__amount) { color: #0f172a; - font-size: 17px; - font-weight: 700; + font-size: 18px; + font-weight: 900; line-height: 1.2; white-space: nowrap; } +.workbench-ai-answer-markdown :deep(.ai-document-card__number) { + color: #64748b; + font-size: 13px; + font-weight: 740; + letter-spacing: 0; +} + .workbench-ai-answer-markdown :deep(.ai-document-card__action) { - flex: 0 0 auto; + display: inline-flex; + align-items: center; + width: fit-content; + min-height: 26px; + padding: 0; + border-radius: 0; + background: transparent; + color: #1d4ed8; + font-size: 14px; + font-weight: 820; + box-shadow: none; + white-space: nowrap; +} + +.workbench-ai-answer-markdown :deep(.ai-document-card__action:hover) { + background: transparent; + color: #1e40af; + text-decoration: underline; } .workbench-ai-answer-markdown :deep(.markdown-table-wrap), @@ -1547,31 +1576,37 @@ } .workbench-ai-answer-markdown :deep(.ai-document-card) { - padding: 14px; + padding: 0; } .workbench-ai-answer-markdown :deep(.ai-document-card__head) { align-items: flex-start; + flex-direction: column; + gap: 8px; + padding: 12px 14px; + } + + .workbench-ai-answer-markdown :deep(.ai-document-card__body) { + padding: 14px; + } + + .workbench-ai-answer-markdown :deep(.ai-document-card__details) { + grid-template-columns: 1fr; + gap: 10px; + } + + .workbench-ai-answer-markdown :deep(.ai-document-card__field) { + grid-template-columns: 76px minmax(0, 1fr); + gap: 10px; + } + + .workbench-ai-answer-markdown :deep(.ai-document-card__field--action) { + grid-column: auto; } .workbench-ai-answer-markdown :deep(.ai-document-card__number) { - flex-basis: 100%; text-align: left; } - - .workbench-ai-answer-markdown :deep(.ai-document-card__foot) { - flex-wrap: wrap; - } - - .workbench-ai-answer-markdown :deep(.ai-document-card__amount-block) { - justify-items: start; - order: 2; - } - - .workbench-ai-answer-markdown :deep(.ai-document-card__action) { - order: 3; - margin-left: auto; - } } .workbench-ai-application-preview { diff --git a/web/src/composables/useAppShell.js b/web/src/composables/useAppShell.js index 87c2bcf..491c521 100644 --- a/web/src/composables/useAppShell.js +++ b/web/src/composables/useAppShell.js @@ -10,6 +10,7 @@ import { fetchOntologyParse } from '../services/ontology.js' import { fetchLatestConversation } from '../services/orchestrator.js' import { markAiWorkbenchConversationDraftDeleted } from '../utils/aiWorkbenchConversationStore.js' import { clearAssistantSessionSnapshotForDraftClaim } from '../utils/assistantSessionSnapshot.js' +import { isApplicationDocumentNo } from '../utils/documentClassification.js' import { ASSISTANT_SCOPE_SESSION_STEWARD, buildUnsupportedBusinessScopeConversation, @@ -428,8 +429,7 @@ export function useAppShell() { return ( documentType === 'application' || documentType === 'expense_application' - || normalizedClaimNo.startsWith('AP-') - || normalizedClaimNo.startsWith('APP-') + || isApplicationDocumentNo(normalizedClaimNo) ) } diff --git a/web/src/composables/useRequests.js b/web/src/composables/useRequests.js index 5cb033c..91cd956 100644 --- a/web/src/composables/useRequests.js +++ b/web/src/composables/useRequests.js @@ -1,6 +1,7 @@ import { computed, reactive, ref } from 'vue' import { fetchAllExpenseClaims } from '../services/reimbursements.js' +import { isApplicationDocumentNo } from '../utils/documentClassification.js' import { filterActionableRiskFlags, normalizeRiskFlagTone } from '../utils/riskFlags.js' const EXPENSE_TYPE_LABELS = { @@ -212,8 +213,7 @@ function resolveDocumentTypeMeta(claim, typeCode) { const isApplication = explicitType === DOCUMENT_TYPE_APPLICATION || explicitType === 'expense_application' - || claimNo.startsWith('AP-') - || claimNo.startsWith('APP-') + || isApplicationDocumentNo(claimNo) || normalizedType === 'application' || normalizedType.endsWith('_application') diff --git a/web/src/utils/aiDocumentQueryModel.js b/web/src/utils/aiDocumentQueryModel.js index 26e6c7e..9cb63d5 100644 --- a/web/src/utils/aiDocumentQueryModel.js +++ b/web/src/utils/aiDocumentQueryModel.js @@ -1,4 +1,5 @@ import { extractExpenseClaimItems } from '../services/reimbursements.js' +import { isApplicationDocumentNo } from './documentClassification.js' const DOCUMENT_QUERY_LIMIT = 8 @@ -336,8 +337,7 @@ function resolveDocumentTypeCode(claim = {}) { || explicitType === 'expense_application' || expenseType === 'application' || expenseType.endsWith('_application') - || documentNo.startsWith('AP-') - || documentNo.startsWith('APP-') + || isApplicationDocumentNo(documentNo) ) { return 'application' } @@ -679,49 +679,66 @@ function buildDocumentCardHtml(record = {}) { const typeClass = record.documentType === 'application' ? 'application' : 'reimbursement' const statusTone = record.statusTone || 'is-pending' const amountLabel = record.documentType === 'application' ? '预计金额' : '报销金额' - - // footer 左侧辅助元信息:业务地点(可选)+ 时间 - const metaParts = [] - if (record.locationLabel) { - metaParts.push(`${escapeHtml(record.locationLabel)}`) - } - metaParts.push(`${escapeHtml(record.time || '待补充')}`) - const metaHtml = `
${metaParts.join('·')}
` + const ownerText = [record.ownerLabel, record.departmentLabel] + .filter((item) => item && item !== '未显示') + .join(' · ') || '未显示' return [ `
`, '
', - '
', + `${escapeHtml(record.reason)}`, `${escapeHtml(record.statusLabel)}`, - `${escapeHtml(record.documentTypeLabel)} · ${escapeHtml(record.typeLabel)}`, - '
', - `${escapeHtml(record.documentNo || '未编号单据')}`, '
', '
', - `${escapeHtml(record.reason)}`, - '
', - `${escapeHtml(record.ownerLabel)}`, - '·', - `${escapeHtml(record.departmentLabel)}`, + '
', + '
', + '单据类型', + `${escapeHtml(record.documentTypeLabel)} · ${escapeHtml(record.typeLabel)}`, '
', - '
', - '
', - metaHtml, - '
', - `${escapeHtml(amountLabel)}`, + '
', + `${escapeHtml(amountLabel)}`, `${escapeHtml(record.amountLabel)}`, '
', + '
', + '申请人', + `${escapeHtml(ownerText)}`, + '
', + '
', + '单据编号', + `${escapeHtml(record.documentNo || '未编号单据')}`, + '
', + '
', + '操作', href ? `查看详情` - : '', - '
', + : '暂无详情', + '
', + '
', + '', '
' ].join('') } -function buildDocumentCardsHtml(records = []) { +function buildDocumentQuerySummaryHtml(scopeText = '', totalCount = 0, visibleCount = 0) { + return [ + '
', + '查询范围', + `${escapeHtml(scopeText || '相关单据')}`, + '', + `共 ${escapeHtml(String(totalCount))} 张`, + '', + '', + `展示最近 ${escapeHtml(String(visibleCount))} 张`, + '', + '
' + ].join('') +} + +function buildDocumentCardsHtml(records = [], options = {}) { + const querySummaryHtml = options.querySummaryHtml || '' return [ '', + querySummaryHtml, '
', ...records.map((record) => buildDocumentCardHtml(record)), '
', @@ -772,9 +789,9 @@ export function buildAiDocumentQueryMessage(intent = {}, claimsPayload = []) { const lines = [ '### 已查询到相关单据', '', - `**查询范围**:${scopeText || '相关单据'};共找到 **${records.length}** 张,先展示最近 **${visibleRecords.length}** 张。`, - '', - buildDocumentCardsHtml(visibleRecords) + buildDocumentCardsHtml(visibleRecords, { + querySummaryHtml: buildDocumentQuerySummaryHtml(scopeText, records.length, visibleRecords.length) + }) ] if (records.length > visibleRecords.length) { diff --git a/web/src/utils/detailAlerts.js b/web/src/utils/detailAlerts.js index 1b4be2a..ced2e9a 100644 --- a/web/src/utils/detailAlerts.js +++ b/web/src/utils/detailAlerts.js @@ -1,5 +1,6 @@ import { filterActionableRiskFlags, normalizeRiskFlagTone } from './riskFlags.js' import { canViewRiskForContext } from './riskVisibility.js' +import { isApplicationDocumentNo } from './documentClassification.js' const SYSTEM_GENERATED_EXPENSE_TYPES = new Set(['travel_allowance']) const LOCATION_REQUIRED_EXPENSE_TYPES = new Set(['travel', 'meeting', 'entertainment']) @@ -37,8 +38,7 @@ function isApplicationDocumentRequest(request) { return ( documentType === 'application' || documentType === 'expense_application' - || claimNo.startsWith('AP-') - || claimNo.startsWith('APP-') + || isApplicationDocumentNo(claimNo) || typeCode === 'application' || typeCode.endsWith('_application') ) diff --git a/web/src/utils/documentClassification.js b/web/src/utils/documentClassification.js index 48c96b2..62d7d4f 100644 --- a/web/src/utils/documentClassification.js +++ b/web/src/utils/documentClassification.js @@ -1,3 +1,14 @@ +const APPLICATION_DOCUMENT_NO_PATTERN = /^A[A-HJ-NP-Z2-9]{8}$/i + +export function isApplicationDocumentNo(value) { + const claimNo = String(value || '').trim().toUpperCase() + return ( + APPLICATION_DOCUMENT_NO_PATTERN.test(claimNo) + || claimNo.startsWith('AP-') + || claimNo.startsWith('APP-') + ) +} + export function isApplicationRequestLike(value) { const explicitType = String( value?.documentTypeCode @@ -14,8 +25,7 @@ export function isApplicationRequestLike(value) { return ( explicitType === 'application' || explicitType === 'expense_application' - || claimNo.startsWith('AP-') - || claimNo.startsWith('APP-') + || isApplicationDocumentNo(claimNo) || typeCode === 'application' || typeCode.endsWith('_application') ) diff --git a/web/src/utils/riskVisibility.js b/web/src/utils/riskVisibility.js index 7396cb4..980af72 100644 --- a/web/src/utils/riskVisibility.js +++ b/web/src/utils/riskVisibility.js @@ -6,6 +6,7 @@ import { isFinanceUser, isPlatformAdminUser } from './accessControl.js' +import { isApplicationDocumentNo } from './documentClassification.js' const APPLICATION_STAGE_ALIASES = new Set([ 'expense_application', @@ -159,8 +160,7 @@ export function isApplicationRiskStageRequest(request = {}) { return ( documentType === 'application' || documentType === 'expense_application' || - claimNo.startsWith('AP-') || - claimNo.startsWith('APP-') || + isApplicationDocumentNo(claimNo) || typeCode === 'application' || typeCode.endsWith('_application') ) diff --git a/web/src/views/AppShellRouteView.vue b/web/src/views/AppShellRouteView.vue index 53bed1b..50b82fa 100644 --- a/web/src/views/AppShellRouteView.vue +++ b/web/src/views/AppShellRouteView.vue @@ -273,7 +273,27 @@ const sidebarCollapsed = ref(false) const sidebarCollapsedBeforeAiMode = ref(false) const mobileSidebarOpen = ref(false) const overviewDashboard = ref('finance') -const workbenchMode = ref('traditional') +const { companyProfile, currentUser, logout } = useSystemState() + +function resolveDefaultWorkbenchMode(user) { + return isPlatformAdminUser(user) ? 'traditional' : 'ai' +} + +function resolveWorkbenchUserKey(user = {}) { + const roleCodes = Array.isArray(user?.roleCodes) ? user.roleCodes.join(',') : '' + return [ + user?.id, + user?.userId, + user?.username, + user?.account, + user?.name, + user?.role, + roleCodes, + user?.isAdmin ? 'admin' : 'user' + ].map((item) => String(item || '').trim()).join('|') +} + +const workbenchMode = ref(resolveDefaultWorkbenchMode(currentUser.value)) const aiSidebarCommandSeq = ref(0) const aiSidebarCommand = ref({ seq: 0, type: '', payload: null }) const aiActiveConversationId = ref('') @@ -343,7 +363,6 @@ const { topBarView } = useAppShell() -const { companyProfile, currentUser, logout } = useSystemState() const PRODUCT_DISPLAY_NAME = '易财费控' const ENTERPRISE_DISPLAY_NAME = '远光软件股份有限公司' const filteredNavItems = computed(() => filterNavItemsByAccess(navItems, currentUser.value)) @@ -496,7 +515,14 @@ function handleLogout() { watch( () => currentUser.value, - (user) => { + (user, previousUser) => { + if (resolveWorkbenchUserKey(user) !== resolveWorkbenchUserKey(previousUser)) { + const nextMode = resolveDefaultWorkbenchMode(user) + workbenchMode.value = nextMode + if (nextMode === 'ai') { + sidebarCollapsed.value = false + } + } aiConversationHistory.value = loadAiWorkbenchConversationHistory(user || {}) }, { immediate: true } diff --git a/web/src/views/scripts/travelReimbursementApplicationLinkModel.js b/web/src/views/scripts/travelReimbursementApplicationLinkModel.js index b82a564..3b49827 100644 --- a/web/src/views/scripts/travelReimbursementApplicationLinkModel.js +++ b/web/src/views/scripts/travelReimbursementApplicationLinkModel.js @@ -1,3 +1,5 @@ +import { isApplicationDocumentNo } from '../../utils/documentClassification.js' + const REQUIRED_APPLICATION_EXPENSE_TYPES = new Set(['travel', 'meal']) const APPLICATION_TYPE_ALIASES = { @@ -302,8 +304,7 @@ export function isExpenseApplicationClaim(claim) { return documentType === 'application' || documentType === 'expense_application' - || claimNo.startsWith('AP-') - || claimNo.startsWith('APP-') + || isApplicationDocumentNo(claimNo) || expenseType === 'application' || expenseType.endsWith('_application') } diff --git a/web/src/views/scripts/travelRequestDetailAdviceModel.js b/web/src/views/scripts/travelRequestDetailAdviceModel.js index 704e515..842e6b5 100644 --- a/web/src/views/scripts/travelRequestDetailAdviceModel.js +++ b/web/src/views/scripts/travelRequestDetailAdviceModel.js @@ -1,3 +1,5 @@ +import { isApplicationDocumentNo } from '../../utils/documentClassification.js' + function normalizeText(value) { return String(value || '').trim() } @@ -22,7 +24,7 @@ function isApplicationDocumentRequest(requestModel) { || requestModel?.document_type ).toLowerCase() const claimNo = normalizeText(requestModel?.claimNo || requestModel?.claim_no || requestModel?.documentNo).toUpperCase() - return documentType === 'application' || claimNo.startsWith('AP-') || claimNo.startsWith('APP-') + return documentType === 'application' || isApplicationDocumentNo(claimNo) } function isHotelExpenseItem(item) { diff --git a/web/src/views/scripts/travelRequestDetailBusinessStage.js b/web/src/views/scripts/travelRequestDetailBusinessStage.js index 2ff30ca..dfea502 100644 --- a/web/src/views/scripts/travelRequestDetailBusinessStage.js +++ b/web/src/views/scripts/travelRequestDetailBusinessStage.js @@ -1,3 +1,5 @@ +import { isApplicationDocumentNo } from '../../utils/documentClassification.js' + function normalizeText(value) { return String(value || '').trim() } @@ -112,7 +114,7 @@ export function resolveRequestBusinessStage(request = {}) { || request?.document_no || request?.id ).toUpperCase() - if (claimNo.startsWith('AP-') || claimNo.startsWith('APP-')) { + if (isApplicationDocumentNo(claimNo)) { return 'expense_application' } diff --git a/web/src/views/scripts/travelRequestDetailExpenseModel.js b/web/src/views/scripts/travelRequestDetailExpenseModel.js index 72431dd..ec90d11 100644 --- a/web/src/views/scripts/travelRequestDetailExpenseModel.js +++ b/web/src/views/scripts/travelRequestDetailExpenseModel.js @@ -1,3 +1,5 @@ +import { isApplicationDocumentNo } from '../../utils/documentClassification.js' + export const EXPENSE_TYPE_OPTIONS = [ { value: 'travel', label: '差旅费' }, { value: 'train_ticket', label: '火车票' }, @@ -83,8 +85,7 @@ export function isApplicationDocumentRequest(request) { return ( documentType === 'application' || documentType === 'expense_application' - || claimNo.startsWith('AP-') - || claimNo.startsWith('APP-') + || isApplicationDocumentNo(claimNo) || typeCode === 'application' || typeCode.endsWith('_application') ) diff --git a/web/src/views/scripts/useTravelReimbursementFlow.js b/web/src/views/scripts/useTravelReimbursementFlow.js index 235ea53..75031e2 100644 --- a/web/src/views/scripts/useTravelReimbursementFlow.js +++ b/web/src/views/scripts/useTravelReimbursementFlow.js @@ -718,7 +718,7 @@ export function useTravelReimbursementFlow({ function buildApplicationDuplicateDetail(payload) { const result = payload?.result && typeof payload.result === 'object' ? payload.result : {} const answer = String(result.answer || result.message || '').trim() - const claimNo = answer.match(/AP-\d{14}-[A-HJ-NP-Z2-9]{8}/)?.[0] || '' + const claimNo = answer.match(/A[A-HJ-NP-Z2-9]{8}|AP-\d{14}-[A-HJ-NP-Z2-9]{8}|APP-\d{8}-[A-Z0-9]{6}/)?.[0] || '' return claimNo ? `已拦截重复申请,已有申请单:${claimNo}` : '已拦截重复申请,未创建新申请单' diff --git a/web/src/views/scripts/useTravelReimbursementSubmitComposer.js b/web/src/views/scripts/useTravelReimbursementSubmitComposer.js index cf00bcd..fa6fbf3 100644 --- a/web/src/views/scripts/useTravelReimbursementSubmitComposer.js +++ b/web/src/views/scripts/useTravelReimbursementSubmitComposer.js @@ -5,6 +5,7 @@ import { collectReceiptFiles } from './travelReimbursementAttachmentModel.js' import { resolveAssistantScopeGuard } from '../../utils/assistantSessionScope.js' +import { isApplicationDocumentNo } from '../../utils/documentClassification.js' import { APPLICATION_TRANSPORT_MODE_OPTIONS, applyApplicationBusinessTimeContext, @@ -148,8 +149,7 @@ function isApplicationClaimRecord(claim) { expenseType === 'application' || expenseType === 'expense_application' || expenseType.endsWith('_application') || - claimNo.startsWith('AP-') || - claimNo.startsWith('APP-') || + isApplicationDocumentNo(claimNo) || Boolean(extractApplicationDetailFromClaim(claim)) ) } diff --git a/web/tests/ai-document-query-model.test.mjs b/web/tests/ai-document-query-model.test.mjs index a84a0c3..983c66d 100644 --- a/web/tests/ai-document-query-model.test.mjs +++ b/web/tests/ai-document-query-model.test.mjs @@ -127,24 +127,33 @@ test('AI document query message renders html document cards with detail actions' assert.match(message, /### 已查询到相关单据/) assert.match(message, //) + assert.match(message, /
/) + assert.match(message, /查询范围<\/span>/) + assert.match(message, //) + assert.match(message, //) assert.match(message, /
/) // 申请单 app-1 状态为 approved → is-success 语义类 assert.match(message, /
/) assert.match(message, /
/) assert.match(message, /已审批<\/span>/) assert.match(message, /辅助国网仿生产服务器部署<\/strong>/) - assert.match(message, /曹小筑<\/span>/) - assert.match(message, /交付部<\/span>/) - assert.match(message, /AP-20260220001<\/span>/) + assert.match(message, /
/) + assert.match(message, /单据类型<\/span>/) + assert.match(message, /申请单 · 差旅费用申请<\/strong>/) + assert.match(message, /申请人<\/span>/) + assert.match(message, /曹小筑 · 交付部<\/strong>/) + assert.match(message, /AP-20260220001<\/strong>/) assert.match(message, /¥3,000\.00<\/strong>/) - assert.match(message, /
/) - assert.match(message, /上海<\/span>/) + assert.match(message, /
/) + assert.doesNotMatch(message, /ai-document-card__meta/) + assert.doesNotMatch(message, /ai-document-card__meta-item/) assert.match(message, /href="#ai-open-document-detail:AP-20260220001"/) // 报销单 claim-1 状态为 submitted → is-pending 语义类 assert.match(message, /
/) assert.match(message, /href="#ai-open-document-detail:CL-20260221001"/) assert.doesNotMatch(message, /\| 单据编号 \|/) assert.doesNotMatch(message, /^> /m) + assert.doesNotMatch(message, /\*\*查询范围\*\*/) }) test('AI document query html cards render as trusted card markup', () => { @@ -152,13 +161,16 @@ test('AI document query html cards render as trusted card markup', () => { const rendered = renderAiConversationHtml(buildAiDocumentQueryMessage(intent, claims)) assert.match(rendered, /

已查询到相关单据<\/h3>/) + assert.match(rendered, /
/) assert.match(rendered, /
/) assert.match(rendered, /
/) assert.match(rendered, /class="ai-document-card__head"/) - assert.match(rendered, /class="ai-document-card__meta"/) - assert.match(rendered, /class="ai-document-card__meta-item"/) + assert.match(rendered, /class="ai-document-card__details"/) + assert.match(rendered, /class="ai-document-card__field"/) + assert.match(rendered, /class="ai-document-card__label"/) assert.match(rendered, /class="ai-html-action-link ai-html-action-link-document ai-document-card__action"/) assert.match(rendered, /href="#ai-open-document-detail:CL-20260221001"/) + assert.doesNotMatch(rendered, /ai-document-card__meta/) assert.doesNotMatch(rendered, /<section class="ai-document-card-list/) assert.doesNotMatch(rendered, /
/) }) diff --git a/web/tests/document-classification.test.mjs b/web/tests/document-classification.test.mjs new file mode 100644 index 0000000..67bb59b --- /dev/null +++ b/web/tests/document-classification.test.mjs @@ -0,0 +1,20 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + isApplicationDocumentNo, + isApplicationRequestLike +} from '../src/utils/documentClassification.js' + +test('application document number detection supports short and legacy formats', () => { + assert.equal(isApplicationDocumentNo('A7K3M9Q2P'), true) + assert.equal(isApplicationDocumentNo('AP-20260525103045-ABCDEFGH'), true) + assert.equal(isApplicationDocumentNo('APP-20260525-ABC123'), true) + assert.equal(isApplicationDocumentNo('R7K3M9Q2P'), false) + assert.equal(isApplicationDocumentNo('RE-20260525103045-HGFEDCBA'), false) +}) + +test('application request classification can rely on a short document number', () => { + assert.equal(isApplicationRequestLike({ claim_no: 'A7K3M9Q2P' }), true) + assert.equal(isApplicationRequestLike({ claim_no: 'R7K3M9Q2P' }), false) +}) diff --git a/web/tests/workbench-ai-mode-switch.test.mjs b/web/tests/workbench-ai-mode-switch.test.mjs index f99cb1c..14b5cfd 100644 --- a/web/tests/workbench-ai-mode-switch.test.mjs +++ b/web/tests/workbench-ai-mode-switch.test.mjs @@ -172,7 +172,10 @@ const orbIconPngAsset = fileURLToPath( const orbIconBuffer = readFileSync(orbIconAsset) test('app shell owns the workbench mode and wires it through topbar and content', () => { - assert.match(appShell, /const workbenchMode = ref\('traditional'\)/) + assert.match(appShell, /function resolveDefaultWorkbenchMode\(user\)\s*\{[\s\S]*isPlatformAdminUser\(user\)[\s\S]*'traditional'[\s\S]*'ai'/) + assert.match(appShell, /const workbenchMode = ref\(resolveDefaultWorkbenchMode\(currentUser\.value\)\)/) + assert.doesNotMatch(appShell, /const workbenchMode = ref\('traditional'\)/) + assert.match(appShell, /watch\(\s*\(\) => currentUser\.value,[\s\S]*resolveDefaultWorkbenchMode\(user\)/) assert.match(appShell, /function toggleWorkbenchMode\(\)/) assert.match(appShell, /const nextMode = workbenchMode\.value === 'ai' \? 'traditional' : 'ai'/) assert.match(appShell, /sidebarCollapsedBeforeAiMode\.value = sidebarCollapsed\.value/) @@ -260,11 +263,21 @@ test('AI mode screen follows the approved reference structure', () => { assert.doesNotMatch(aiMode, /message\.pending \?/) assert.match(aiMode, /继续和小财管家对话\.\.\./) assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card\)/) + assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-query-summary\)/) + assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-query-summary__scope\)/) + assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card-list\) \{[\s\S]*gap:\s*16px;/) + assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card__head\) \{[\s\S]*background: rgba\(37, 99, 235, 0\.11\);/) + assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card\.is-success \.ai-document-card__head\)/) assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card__head\)/) assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card__body\)/) - assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card__foot\)/) + assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card__details\)/) + assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card__field\)/) + assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card__label\)/) assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card__amount\)/) - assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card__meta\)/) + assert.match( + aiModeStyles, + /\.workbench-ai-answer-markdown :deep\(\.ai-document-card__details\) \{[\s\S]*grid-template-columns: repeat\(2, minmax\(0, 1fr\)\);/ + ) 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\)/)