feat(web): 统一平台管理员判定与 AI 工作台申请预览动作接入
- authUser 抽出 resolveAuthUserAdminFlag,统一 isAdmin 解析(含 superadmin、role_codes、中英文角色名),accessControl 复用同一逻辑 - 登录态、应用外壳路由、系统状态接入统一管理员判定,LoginView 与相关 composable 配套调整 - AI 工作台申请提交改为调用新的 /application-preview-action 接口,草稿保存仍走 orchestrator;预审模型补充重叠冲突提示与阻断判断 - 同步更新 accessControl/api-request/ai 预览动作等前端测试
This commit is contained in:
@@ -72,38 +72,6 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-entry-veil {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 380;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
background: rgba(248, 250, 252, 0.9);
|
|
||||||
backdrop-filter: blur(3px);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-entry-veil-enter-active {
|
|
||||||
transition: opacity 180ms var(--ease);
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-entry-veil-leave-active {
|
|
||||||
transition: opacity 260ms cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-entry-veil-enter-from,
|
|
||||||
.login-entry-veil-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app.login-entry-active .app-sidebar {
|
|
||||||
animation: loginEntrySidebarIn 520ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app.login-entry-active > .main {
|
|
||||||
animation: loginEntryMainIn 620ms 90ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
|
||||||
}
|
|
||||||
|
|
||||||
.boot-state {
|
.boot-state {
|
||||||
min-height: var(--desktop-stage-height, 100dvh);
|
min-height: var(--desktop-stage-height, 100dvh);
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -234,6 +202,28 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
.document-detail-loading {
|
||||||
|
min-height: 280px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
align-content: center;
|
||||||
|
gap: 14px;
|
||||||
|
text-align: center;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
.document-detail-loading i {
|
||||||
|
font-size: 30px;
|
||||||
|
color: var(--theme-primary-active);
|
||||||
|
}
|
||||||
|
.document-detail-loading strong {
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.document-detail-loading p {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
.workarea.settings-workarea {
|
.workarea.settings-workarea {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
@@ -299,10 +289,6 @@
|
|||||||
transform: translateX(0);
|
transform: translateX(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.app.login-entry-active .app-sidebar {
|
|
||||||
animation: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app > .main {
|
.app > .main {
|
||||||
flex: 1 1 100%;
|
flex: 1 1 100%;
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
@@ -374,14 +360,4 @@
|
|||||||
flex-basis 120ms ease-out !important;
|
flex-basis 120ms ease-out !important;
|
||||||
transition-duration: 120ms, 120ms !important;
|
transition-duration: 120ms, 120ms !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app.login-entry-active .app-sidebar,
|
|
||||||
.app.login-entry-active > .main {
|
|
||||||
animation: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-entry-veil-enter-active,
|
|
||||||
.login-entry-veil-leave-active {
|
|
||||||
transition: opacity 120ms ease-out !important;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1556,12 +1556,28 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.application-preview-row.is-disabled {
|
||||||
|
cursor: wait;
|
||||||
|
}
|
||||||
|
|
||||||
|
.application-preview-row.is-disabled .application-preview-label,
|
||||||
|
.application-preview-row.is-disabled .application-preview-value {
|
||||||
|
background: rgba(248, 250, 252, 0.84);
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
.application-preview-row.editable:hover,
|
.application-preview-row.editable:hover,
|
||||||
.application-preview-row.editable:hover .application-preview-label,
|
.application-preview-row.editable:hover .application-preview-label,
|
||||||
.application-preview-row.editable:hover .application-preview-value {
|
.application-preview-row.editable:hover .application-preview-value {
|
||||||
background: rgba(239, 246, 255, 0.58);
|
background: rgba(239, 246, 255, 0.58);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.application-preview-row.is-disabled:hover,
|
||||||
|
.application-preview-row.is-disabled:hover .application-preview-label,
|
||||||
|
.application-preview-row.is-disabled:hover .application-preview-value {
|
||||||
|
background: rgba(248, 250, 252, 0.84);
|
||||||
|
}
|
||||||
|
|
||||||
.application-preview-row.editable:focus-visible {
|
.application-preview-row.editable:focus-visible {
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
outline: 2px solid rgba(37, 99, 235, 0.42);
|
outline: 2px solid rgba(37, 99, 235, 0.42);
|
||||||
@@ -1655,6 +1671,12 @@
|
|||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.application-preview-edit-btn:disabled {
|
||||||
|
cursor: wait;
|
||||||
|
opacity: 0.46;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
.application-preview-footer {
|
.application-preview-footer {
|
||||||
color: #334155;
|
color: #334155;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
@@ -1748,11 +1770,17 @@
|
|||||||
transform 160ms ease;
|
transform 160ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workbench-ai-suggested-actions button:hover {
|
.workbench-ai-suggested-actions button:hover:not(:disabled) {
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
background: #eff6ff;
|
background: #eff6ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.workbench-ai-suggested-actions button:disabled {
|
||||||
|
cursor: wait;
|
||||||
|
opacity: 0.6;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
.workbench-ai-message-actions {
|
.workbench-ai-message-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1940,6 +1968,12 @@
|
|||||||
box-shadow: 0 12px 22px rgba(220, 38, 38, 0.2);
|
box-shadow: 0 12px 22px rgba(220, 38, 38, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.workbench-ai-confirm-actions .primary {
|
||||||
|
background: #2563eb;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 12px 22px rgba(37, 99, 235, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
.workbench-ai-confirm-fade-enter-active,
|
.workbench-ai-confirm-fade-enter-active,
|
||||||
.workbench-ai-confirm-fade-leave-active {
|
.workbench-ai-confirm-fade-leave-active {
|
||||||
transition: opacity 180ms ease;
|
transition: opacity 180ms ease;
|
||||||
|
|||||||
@@ -559,11 +559,72 @@
|
|||||||
|
|
||||||
.submit-btn:disabled,
|
.submit-btn:disabled,
|
||||||
.sso-btn:disabled {
|
.sso-btn:disabled {
|
||||||
opacity: .6;
|
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 登录中:SSO 按钮置灰,登录按钮保持主色并显示 spinner */
|
||||||
|
.sso-btn:disabled {
|
||||||
|
opacity: .6;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.submit-btn:disabled {
|
||||||
|
opacity: 1;
|
||||||
|
box-shadow: 0 16px 30px rgba(var(--theme-primary-rgb, 58, 124, 165), .20);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 登录中表单态:用户名 / 密码 / 租户 / 记住账号 / 忘记密码全部置灰禁用,
|
||||||
|
* 让视觉焦点集中在正在校验的登录按钮上
|
||||||
|
*/
|
||||||
|
.login-form.is-submitting .field input,
|
||||||
|
.login-form.is-submitting .field select {
|
||||||
|
background: #f1f5f9;
|
||||||
|
border-color: #e2e8f0;
|
||||||
|
color: #94a3b8;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form.is-submitting .field input::placeholder {
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form.is-submitting .field > .mdi,
|
||||||
|
.login-form.is-submitting .field-icon-btn,
|
||||||
|
.login-form.is-submitting .field-select-chevron {
|
||||||
|
color: #cbd5e1;
|
||||||
|
opacity: .5;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form.is-submitting .remember,
|
||||||
|
.login-form.is-submitting .link-btn {
|
||||||
|
opacity: .55;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 登录按钮内的旋转 loading */
|
||||||
|
.submit-btn__spinner {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 2.5px solid rgba(255, 255, 255, .4);
|
||||||
|
border-top-color: #fff;
|
||||||
|
border-radius: 999px;
|
||||||
|
animation: loginSubmitSpin 720ms linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes loginSubmitSpin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.submit-btn__spinner {
|
||||||
|
animation-duration: 1800ms;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.divider {
|
.divider {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -158,6 +158,10 @@ export function useAppShell() {
|
|||||||
].some((value) => String(value || '').trim() === normalizedId)
|
].some((value) => String(value || '').trim() === normalizedId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isDetailLookupOnlyPayload(payload = {}) {
|
||||||
|
return Boolean(payload?.detailLookupOnly || payload?.detail_lookup_only)
|
||||||
|
}
|
||||||
|
|
||||||
function resolveRequestDetailLookupId(requestOrId = selectedRequestSnapshot.value) {
|
function resolveRequestDetailLookupId(requestOrId = selectedRequestSnapshot.value) {
|
||||||
if (typeof requestOrId === 'string') {
|
if (typeof requestOrId === 'string') {
|
||||||
return requestOrId.trim()
|
return requestOrId.trim()
|
||||||
@@ -168,6 +172,8 @@ export function useAppShell() {
|
|||||||
|| requestOrId?.id
|
|| requestOrId?.id
|
||||||
|| requestOrId?.claimNo
|
|| requestOrId?.claimNo
|
||||||
|| requestOrId?.claim_no
|
|| requestOrId?.claim_no
|
||||||
|
|| requestOrId?.documentNo
|
||||||
|
|| requestOrId?.document_no
|
||||||
|| ''
|
|| ''
|
||||||
).trim()
|
).trim()
|
||||||
}
|
}
|
||||||
@@ -564,13 +570,18 @@ export function useAppShell() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openRequestDetail(request, options = {}) {
|
function openRequestDetail(request, options = {}) {
|
||||||
selectedRequestSnapshot.value = request || null
|
const requestId = resolveRequestDetailLookupId(request)
|
||||||
|
if (!requestId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const isDetailLookupOnlyRequest = isDetailLookupOnlyPayload(request)
|
||||||
|
selectedRequestSnapshot.value = isDetailLookupOnlyRequest ? null : request || null
|
||||||
router.push({
|
router.push({
|
||||||
name: 'app-document-detail',
|
name: 'app-document-detail',
|
||||||
params: { requestId: request.claimId || request.id },
|
params: { requestId },
|
||||||
query: buildDocumentDetailQuery(options)
|
query: buildDocumentDetailQuery(options)
|
||||||
})
|
})
|
||||||
void refreshSelectedRequestDetail(request)
|
void refreshSelectedRequestDetail(isDetailLookupOnlyRequest ? requestId : request)
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeRequestDetail() {
|
function closeRequestDetail() {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { resolveDefaultAuthorizedRoute } from '../utils/accessControl.js'
|
|||||||
import { useToast } from './useToast.js'
|
import { useToast } from './useToast.js'
|
||||||
import { fetchSettings } from '../services/settings.js'
|
import { fetchSettings } from '../services/settings.js'
|
||||||
import { setThemeSkin } from './useThemeSkin.js'
|
import { setThemeSkin } from './useThemeSkin.js'
|
||||||
import { normalizeAuthUserSnapshot } from '../utils/authUser.js'
|
import { normalizeAuthUserSnapshot, resolveAuthUserAdminFlag } from '../utils/authUser.js'
|
||||||
import {
|
import {
|
||||||
clearAuthSessionMetrics,
|
clearAuthSessionMetrics,
|
||||||
finalizeAuthSession,
|
finalizeAuthSession,
|
||||||
@@ -142,18 +142,7 @@ function buildLegacyAdminUser(username = '') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolvePlatformAdminFlag(payload, roleCodes = []) {
|
function resolvePlatformAdminFlag(payload, roleCodes = []) {
|
||||||
const username = String(payload?.username || payload?.account || '').trim().toLowerCase()
|
return resolveAuthUserAdminFlag(payload, roleCodes)
|
||||||
const role = String(payload?.role || '').trim().toLowerCase()
|
|
||||||
const normalizedRoleCodes = roleCodes.map((item) => String(item || '').trim().toLowerCase()).filter(Boolean)
|
|
||||||
|
|
||||||
return (
|
|
||||||
Boolean(payload?.isAdmin)
|
|
||||||
|| username === 'admin'
|
|
||||||
|| role === 'admin'
|
|
||||||
|| role === '管理员'
|
|
||||||
|| role === '系统管理员'
|
|
||||||
|| normalizedRoleCodes.includes('admin')
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeStoredAuthUser(payload = {}) {
|
function normalizeStoredAuthUser(payload = {}) {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { apiRequest } from './api.js'
|
||||||
import { runOrchestrator } from './orchestrator.js'
|
import { runOrchestrator } from './orchestrator.js'
|
||||||
import {
|
import {
|
||||||
buildApplicationPreviewRows,
|
buildApplicationPreviewRows,
|
||||||
@@ -126,11 +127,20 @@ export function buildAiApplicationPreviewActionPayload({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function runAiApplicationPreviewAction(params = {}, options = {}) {
|
export function runAiApplicationPreviewAction(params = {}, options = {}) {
|
||||||
return runOrchestrator(buildAiApplicationPreviewActionPayload(params), {
|
const payload = buildAiApplicationPreviewActionPayload(params)
|
||||||
timeoutMs: params.actionType === AI_APPLICATION_ACTION_SUBMIT ? 120000 : 75000,
|
if (params.actionType === AI_APPLICATION_ACTION_SUBMIT) {
|
||||||
timeoutMessage: params.actionType === AI_APPLICATION_ACTION_SUBMIT
|
return apiRequest('/reimbursements/application-preview-action', {
|
||||||
? '申请提交处理超时,请稍后重试。'
|
method: 'POST',
|
||||||
: '申请草稿保存超时,请稍后重试。',
|
body: JSON.stringify(payload),
|
||||||
|
timeoutMs: 45000,
|
||||||
|
timeoutMessage: '申请提交处理超时,请稍后重试。',
|
||||||
|
...options
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return runOrchestrator(payload, {
|
||||||
|
timeoutMs: 75000,
|
||||||
|
timeoutMessage: '申请草稿保存超时,请稍后重试。',
|
||||||
...options
|
...options
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { normalizeAuthUserSnapshot } from '../utils/authUser.js'
|
import { normalizeAuthUserSnapshot, resolveAuthUserAdminFlag } from '../utils/authUser.js'
|
||||||
|
|
||||||
const API_BASE_STORAGE_KEY = 'x-financial-api-base-url'
|
const API_BASE_STORAGE_KEY = 'x-financial-api-base-url'
|
||||||
const AUTH_USER_STORAGE_KEY = 'x-financial-auth-user'
|
const AUTH_USER_STORAGE_KEY = 'x-financial-auth-user'
|
||||||
@@ -49,7 +49,7 @@ function readCurrentUserHeaders() {
|
|||||||
const username = user.username
|
const username = user.username
|
||||||
const name = user.name || username
|
const name = user.name || username
|
||||||
const roleCodes = user.roleCodes
|
const roleCodes = user.roleCodes
|
||||||
const isAdmin = resolveStoredUserAdminFlag(payload, roleCodes)
|
const isAdmin = resolveAuthUserAdminFlag(payload, roleCodes)
|
||||||
const department = user.department || user.departmentName
|
const department = user.department || user.departmentName
|
||||||
const costCenter = user.costCenter
|
const costCenter = user.costCenter
|
||||||
const position = user.position
|
const position = user.position
|
||||||
@@ -112,21 +112,6 @@ function readCurrentUserHeaders() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveStoredUserAdminFlag(payload, roleCodes = []) {
|
|
||||||
const username = String(payload?.username || payload?.account || '').trim().toLowerCase()
|
|
||||||
const role = String(payload?.role || '').trim().toLowerCase()
|
|
||||||
const normalizedRoleCodes = roleCodes.map((item) => String(item || '').trim().toLowerCase()).filter(Boolean)
|
|
||||||
|
|
||||||
return (
|
|
||||||
Boolean(payload?.isAdmin)
|
|
||||||
|| username === 'admin'
|
|
||||||
|| role === 'admin'
|
|
||||||
|| role === '管理员'
|
|
||||||
|| role === '系统管理员'
|
|
||||||
|| normalizedRoleCodes.includes('admin')
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeApiBaseUrl(value) {
|
function normalizeApiBaseUrl(value) {
|
||||||
return String(value || '/api/v1').replace(/\/$/, '')
|
return String(value || '/api/v1').replace(/\/$/, '')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { resolveAuthUserAdminFlag } from './authUser.js'
|
||||||
|
|
||||||
export const DEFAULT_APP_VIEW_ORDER = [
|
export const DEFAULT_APP_VIEW_ORDER = [
|
||||||
'workbench',
|
'workbench',
|
||||||
'documents',
|
'documents',
|
||||||
@@ -81,18 +83,7 @@ function hasPlatformAdminIdentity(user) {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const username = String(user.username || user.account || '').trim().toLowerCase()
|
return resolveAuthUserAdminFlag(user, normalizedRoleCodes(user))
|
||||||
const role = String(user.role || '').trim().toLowerCase()
|
|
||||||
const roleCodes = normalizedRoleCodes(user)
|
|
||||||
|
|
||||||
return (
|
|
||||||
Boolean(user.isAdmin)
|
|
||||||
|| username === 'admin'
|
|
||||||
|| role === 'admin'
|
|
||||||
|| role === '管理员'
|
|
||||||
|| role === '系统管理员'
|
|
||||||
|| roleCodes.includes('admin')
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isManagerUser(user) {
|
export function isManagerUser(user) {
|
||||||
|
|||||||
@@ -159,6 +159,10 @@ function isBlockingPrecheck(precheck = {}) {
|
|||||||
return precheck?.overlap?.status === 'warning'
|
return precheck?.overlap?.status === 'warning'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isAiApplicationPrecheckBlocking(precheck = {}) {
|
||||||
|
return isBlockingPrecheck(precheck)
|
||||||
|
}
|
||||||
|
|
||||||
function buildOverlapMatchTable(matches = []) {
|
function buildOverlapMatchTable(matches = []) {
|
||||||
const rows = Array.isArray(matches) ? matches : []
|
const rows = Array.isArray(matches) ? matches : []
|
||||||
if (!rows.length) {
|
if (!rows.length) {
|
||||||
@@ -343,3 +347,32 @@ export function buildAiApplicationPrecheckMessage(preview = {}, precheck = {}) {
|
|||||||
|
|
||||||
return lines.join('\n')
|
return lines.join('\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildAiApplicationSubmitConflictMessage(preview = {}, precheck = {}) {
|
||||||
|
const matchTable = buildOverlapMatchTable(precheck?.overlap?.matches)
|
||||||
|
const normalized = normalizeApplicationPreview(preview)
|
||||||
|
const fields = normalized.fields || {}
|
||||||
|
const currentRange = resolveDateRange(fields.time, fields.days)
|
||||||
|
const currentRangeText = currentRange
|
||||||
|
? `${currentRange.startText} 至 ${currentRange.endText}`
|
||||||
|
: normalizeText(fields.time) || '待确认'
|
||||||
|
const lines = [
|
||||||
|
'### 发现相同日期已有申请单',
|
||||||
|
'',
|
||||||
|
'**我已完成提交前的单据重叠核查**,发现相同或重叠日期已有差旅申请单,当前不能继续提交。',
|
||||||
|
'',
|
||||||
|
`> **相同日期提醒**:${precheck?.overlap?.summary || '发现相同日期已有申请单,请先核对后再提交。'}`,
|
||||||
|
'',
|
||||||
|
`> **本次申请时间**:${currentRangeText}`,
|
||||||
|
]
|
||||||
|
if (matchTable) {
|
||||||
|
lines.push('', matchTable)
|
||||||
|
}
|
||||||
|
lines.push(
|
||||||
|
'',
|
||||||
|
'> **请先核对**:请先核对申请时间是否填写正确。若日期填错,请直接回复正确的出发时间和返回时间,我会重新查询;若日期无误,请先查看或处理已有申请单,避免重复申请。',
|
||||||
|
'',
|
||||||
|
'我会先暂停本次提交,不会生成新的审批流。'
|
||||||
|
)
|
||||||
|
return lines.join('\n')
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,10 +8,45 @@ function pickText(payload = {}, keys = [], fallback = '') {
|
|||||||
return String(fallback || '').trim()
|
return String(fallback || '').trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PLATFORM_ADMIN_IDENTITIES = new Set(['admin', 'superadmin'])
|
||||||
|
const PLATFORM_ADMIN_ROLES = new Set(['admin', 'superadmin', '管理员', '系统管理员'])
|
||||||
|
|
||||||
|
function isTruthyAdminFlag(value) {
|
||||||
|
if (value === true) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return value === 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['1', 'true', 'yes', 'on'].includes(String(value || '').trim().toLowerCase())
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeRoleCodes(payload = {}) {
|
function normalizeRoleCodes(payload = {}) {
|
||||||
return Array.isArray(payload.roleCodes)
|
const rawRoleCodes = Array.isArray(payload.roleCodes)
|
||||||
? payload.roleCodes.map((item) => String(item || '').trim()).filter(Boolean)
|
? payload.roleCodes
|
||||||
: []
|
: Array.isArray(payload.role_codes)
|
||||||
|
? payload.role_codes
|
||||||
|
: typeof payload.roleCodes === 'string'
|
||||||
|
? payload.roleCodes.split(',')
|
||||||
|
: []
|
||||||
|
|
||||||
|
return rawRoleCodes.map((item) => String(item || '').trim()).filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveAuthUserAdminFlag(payload = {}, roleCodes = []) {
|
||||||
|
const username = String(payload?.username || payload?.account || '').trim().toLowerCase()
|
||||||
|
const role = String(payload?.role || '').trim().toLowerCase()
|
||||||
|
const normalizedRoleCodes = roleCodes.map((item) => String(item || '').trim().toLowerCase()).filter(Boolean)
|
||||||
|
|
||||||
|
return (
|
||||||
|
isTruthyAdminFlag(payload?.isAdmin)
|
||||||
|
|| isTruthyAdminFlag(payload?.is_admin)
|
||||||
|
|| PLATFORM_ADMIN_IDENTITIES.has(username)
|
||||||
|
|| PLATFORM_ADMIN_ROLES.has(role)
|
||||||
|
|| normalizedRoleCodes.some((item) => PLATFORM_ADMIN_IDENTITIES.has(item))
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeAuthUserSnapshot(payload = {}, defaults = {}) {
|
export function normalizeAuthUserSnapshot(payload = {}, defaults = {}) {
|
||||||
@@ -47,6 +82,7 @@ export function normalizeAuthUserSnapshot(payload = {}, defaults = {}) {
|
|||||||
'leaderName',
|
'leaderName',
|
||||||
'leader_name'
|
'leader_name'
|
||||||
])
|
])
|
||||||
|
const roleCodes = normalizeRoleCodes(payload)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
username,
|
username,
|
||||||
@@ -62,9 +98,9 @@ export function normalizeAuthUserSnapshot(payload = {}, defaults = {}) {
|
|||||||
costCenter,
|
costCenter,
|
||||||
financeOwnerName,
|
financeOwnerName,
|
||||||
riskProfile: payload.riskProfile && typeof payload.riskProfile === 'object' ? payload.riskProfile : {},
|
riskProfile: payload.riskProfile && typeof payload.riskProfile === 'object' ? payload.riskProfile : {},
|
||||||
roleCodes: normalizeRoleCodes(payload),
|
roleCodes,
|
||||||
email: pickText(payload, ['email'], username),
|
email: pickText(payload, ['email'], username),
|
||||||
avatar: pickText(payload, ['avatar'], name.slice(0, 1).toUpperCase()),
|
avatar: pickText(payload, ['avatar'], name.slice(0, 1).toUpperCase()),
|
||||||
isAdmin: Boolean(payload.isAdmin)
|
isAdmin: resolveAuthUserAdminFlag(payload, roleCodes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,7 @@
|
|||||||
:class="{
|
:class="{
|
||||||
'sidebar-collapsed': sidebarCollapsed,
|
'sidebar-collapsed': sidebarCollapsed,
|
||||||
'workbench-ai-sidebar-active': isAiShellMode,
|
'workbench-ai-sidebar-active': isAiShellMode,
|
||||||
'mobile-sidebar-open': mobileSidebarOpen,
|
'mobile-sidebar-open': mobileSidebarOpen
|
||||||
'login-entry-active': loginEntryAnimating
|
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<div class="mobile-overlay" aria-hidden="true" @click="mobileSidebarOpen = false"></div>
|
<div class="mobile-overlay" aria-hidden="true" @click="mobileSidebarOpen = false"></div>
|
||||||
@@ -18,17 +17,6 @@
|
|||||||
>
|
>
|
||||||
<i class="mdi mdi-menu" aria-hidden="true"></i>
|
<i class="mdi mdi-menu" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
<Transition name="login-entry-veil">
|
|
||||||
<div v-if="loginEntryAnimating" class="login-entry-veil" aria-live="polite" aria-label="登录成功,正在进入工作台">
|
|
||||||
<FloatingLightBandWindow
|
|
||||||
icon="mdi mdi-shield-check-outline"
|
|
||||||
message="正在进入工作台"
|
|
||||||
motion="entry"
|
|
||||||
title="登录成功"
|
|
||||||
variant="entry"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
<div class="app-sidebar">
|
<div class="app-sidebar">
|
||||||
<Transition name="sidebar-mode-fade" mode="out-in">
|
<Transition name="sidebar-mode-fade" mode="out-in">
|
||||||
<AiSidebarRail
|
<AiSidebarRail
|
||||||
@@ -169,6 +157,18 @@
|
|||||||
@request-deleted="handleRequestDeleted"
|
@request-deleted="handleRequestDeleted"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<section
|
||||||
|
v-else-if="activeView === 'documents' && detailMode && !selectedRequest"
|
||||||
|
class="document-detail-loading panel"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<i class="mdi mdi-loading mdi-spin" aria-hidden="true"></i>
|
||||||
|
<div>
|
||||||
|
<strong>正在加载完整单据详情</strong>
|
||||||
|
<p>正在读取申请表、审批进度和详情字段,加载完成后再展示详情表格。</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<DocumentsCenterView
|
<DocumentsCenterView
|
||||||
v-else-if="activeView === 'documents'"
|
v-else-if="activeView === 'documents'"
|
||||||
:filtered-requests="requests"
|
:filtered-requests="requests"
|
||||||
@@ -236,13 +236,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { computed, nextTick, ref, watch } from 'vue'
|
||||||
|
|
||||||
import AiSidebarRail from '../components/layout/AiSidebarRail.vue'
|
import AiSidebarRail from '../components/layout/AiSidebarRail.vue'
|
||||||
import SidebarRail from '../components/layout/SidebarRail.vue'
|
import SidebarRail from '../components/layout/SidebarRail.vue'
|
||||||
import TopBar from '../components/layout/TopBar.vue'
|
import TopBar from '../components/layout/TopBar.vue'
|
||||||
import FilterBar from '../components/layout/FilterBar.vue'
|
import FilterBar from '../components/layout/FilterBar.vue'
|
||||||
import FloatingLightBandWindow from '../components/shared/FloatingLightBandWindow.vue'
|
|
||||||
import AuditView from './AuditView.vue'
|
import AuditView from './AuditView.vue'
|
||||||
import BudgetCenterView from './BudgetCenterView.vue'
|
import BudgetCenterView from './BudgetCenterView.vue'
|
||||||
import DigitalEmployeesView from './DigitalEmployeesView.vue'
|
import DigitalEmployeesView from './DigitalEmployeesView.vue'
|
||||||
@@ -258,9 +257,8 @@ import TravelRequestDetailView from './TravelRequestDetailView.vue'
|
|||||||
|
|
||||||
import { useAppShell } from '../composables/useAppShell.js'
|
import { useAppShell } from '../composables/useAppShell.js'
|
||||||
import { useSystemState } from '../composables/useSystemState.js'
|
import { useSystemState } from '../composables/useSystemState.js'
|
||||||
import { filterNavItemsByAccess } from '../utils/accessControl.js'
|
import { filterNavItemsByAccess, isPlatformAdminUser } from '../utils/accessControl.js'
|
||||||
import { loadAiWorkbenchConversationHistory, saveAiWorkbenchConversation } from '../utils/aiWorkbenchConversationStore.js'
|
import { loadAiWorkbenchConversationHistory, saveAiWorkbenchConversation } from '../utils/aiWorkbenchConversationStore.js'
|
||||||
import { consumeLoginEntryTransition } from '../utils/loginEntryTransition.js'
|
|
||||||
|
|
||||||
const employeeSummary = ref(null)
|
const employeeSummary = ref(null)
|
||||||
const knowledgeSummary = ref(null)
|
const knowledgeSummary = ref(null)
|
||||||
@@ -271,7 +269,6 @@ const auditDetailOpen = ref(false)
|
|||||||
const digitalEmployeeDetailOpen = ref(false)
|
const digitalEmployeeDetailOpen = ref(false)
|
||||||
const receiptFolderDetailOpen = ref(false)
|
const receiptFolderDetailOpen = ref(false)
|
||||||
const budgetDetailOpen = ref(false)
|
const budgetDetailOpen = ref(false)
|
||||||
const loginEntryAnimating = ref(false)
|
|
||||||
const sidebarCollapsed = ref(false)
|
const sidebarCollapsed = ref(false)
|
||||||
const sidebarCollapsedBeforeAiMode = ref(false)
|
const sidebarCollapsedBeforeAiMode = ref(false)
|
||||||
const mobileSidebarOpen = ref(false)
|
const mobileSidebarOpen = ref(false)
|
||||||
@@ -281,25 +278,6 @@ const aiSidebarCommandSeq = ref(0)
|
|||||||
const aiSidebarCommand = ref({ seq: 0, type: '', payload: null })
|
const aiSidebarCommand = ref({ seq: 0, type: '', payload: null })
|
||||||
const aiActiveConversationId = ref('')
|
const aiActiveConversationId = ref('')
|
||||||
const aiConversationHistory = ref([])
|
const aiConversationHistory = ref([])
|
||||||
let loginEntryTimer = null
|
|
||||||
|
|
||||||
function stopLoginEntryAnimation() {
|
|
||||||
if (loginEntryTimer) {
|
|
||||||
window.clearTimeout(loginEntryTimer)
|
|
||||||
loginEntryTimer = null
|
|
||||||
}
|
|
||||||
|
|
||||||
loginEntryAnimating.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
function playLoginEntryAnimation() {
|
|
||||||
if (!consumeLoginEntryTransition()) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
loginEntryAnimating.value = true
|
|
||||||
loginEntryTimer = window.setTimeout(stopLoginEntryAnimation, 920)
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleSidebarCollapsed() {
|
function toggleSidebarCollapsed() {
|
||||||
sidebarCollapsed.value = !sidebarCollapsed.value
|
sidebarCollapsed.value = !sidebarCollapsed.value
|
||||||
@@ -414,7 +392,7 @@ const resolvedDetailKpis = computed(() => (
|
|||||||
))
|
))
|
||||||
|
|
||||||
function openWorkbenchDocument(payload = {}) {
|
function openWorkbenchDocument(payload = {}) {
|
||||||
const requestId = String(payload.claimId || payload.id || payload.claimNo || '').trim()
|
const requestId = String(payload.claimId || payload.id || payload.claimNo || payload.documentNo || '').trim()
|
||||||
if (!requestId) {
|
if (!requestId) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -423,6 +401,7 @@ function openWorkbenchDocument(payload = {}) {
|
|||||||
String(item.claimId || '').trim() === requestId
|
String(item.claimId || '').trim() === requestId
|
||||||
|| String(item.id || '').trim() === requestId
|
|| String(item.id || '').trim() === requestId
|
||||||
|| String(item.claimNo || '').trim() === requestId
|
|| String(item.claimNo || '').trim() === requestId
|
||||||
|
|| String(item.documentNo || '').trim() === requestId
|
||||||
))
|
))
|
||||||
const returnTo = (
|
const returnTo = (
|
||||||
String(payload.returnTo || '').trim() === 'workbench'
|
String(payload.returnTo || '').trim() === 'workbench'
|
||||||
@@ -431,7 +410,15 @@ function openWorkbenchDocument(payload = {}) {
|
|||||||
)
|
)
|
||||||
? 'workbench'
|
? 'workbench'
|
||||||
: ''
|
: ''
|
||||||
openRequestDetail(request || payload, { returnTo })
|
const detailPayload = request || {
|
||||||
|
...payload,
|
||||||
|
id: payload.id || requestId,
|
||||||
|
claimId: payload.claimId || requestId,
|
||||||
|
claimNo: payload.claimNo || payload.documentNo || requestId,
|
||||||
|
documentNo: payload.documentNo || requestId,
|
||||||
|
detailLookupOnly: true
|
||||||
|
}
|
||||||
|
openRequestDetail(detailPayload, { returnTo })
|
||||||
}
|
}
|
||||||
|
|
||||||
function dispatchAiSidebarCommand(type, payload = null) {
|
function dispatchAiSidebarCommand(type, payload = null) {
|
||||||
@@ -509,12 +496,4 @@ watch(
|
|||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
playLoginEntryAnimation()
|
|
||||||
})
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
stopLoginEntryAnimation()
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -74,11 +74,22 @@
|
|||||||
<p>使用员工邮箱或管理员账号进入系统</p>
|
<p>使用员工邮箱或管理员账号进入系统</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<form class="login-form" @submit.prevent="emit('login', { username, password })">
|
<form
|
||||||
|
class="login-form"
|
||||||
|
:class="{ 'is-submitting': submitting }"
|
||||||
|
@submit.prevent="emit('login', { username, password })"
|
||||||
|
>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span class="sr-only">账号</span>
|
<span class="sr-only">账号</span>
|
||||||
<i class="mdi mdi-account-outline"></i>
|
<i class="mdi mdi-account-outline"></i>
|
||||||
<input v-model="username" type="text" placeholder="请输入员工邮箱 / 管理员账号" autocomplete="username" required />
|
<input
|
||||||
|
v-model="username"
|
||||||
|
type="text"
|
||||||
|
placeholder="请输入员工邮箱 / 管理员账号"
|
||||||
|
autocomplete="username"
|
||||||
|
:disabled="submitting"
|
||||||
|
required
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="field">
|
<label class="field">
|
||||||
@@ -89,11 +100,13 @@
|
|||||||
:type="showPassword ? 'text' : 'password'"
|
:type="showPassword ? 'text' : 'password'"
|
||||||
placeholder="请输入登录密码"
|
placeholder="请输入登录密码"
|
||||||
autocomplete="current-password"
|
autocomplete="current-password"
|
||||||
|
:disabled="submitting"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
class="field-icon-btn"
|
class="field-icon-btn"
|
||||||
type="button"
|
type="button"
|
||||||
|
:disabled="submitting"
|
||||||
:aria-label="showPassword ? '隐藏密码' : '显示密码'"
|
:aria-label="showPassword ? '隐藏密码' : '显示密码'"
|
||||||
@click="showPassword = !showPassword"
|
@click="showPassword = !showPassword"
|
||||||
>
|
>
|
||||||
@@ -104,7 +117,12 @@
|
|||||||
<label class="field">
|
<label class="field">
|
||||||
<span class="sr-only">企业或租户</span>
|
<span class="sr-only">企业或租户</span>
|
||||||
<i class="mdi mdi-office-building"></i>
|
<i class="mdi mdi-office-building"></i>
|
||||||
<select v-model="tenant" class="tenant-select" aria-label="请选择企业或租户">
|
<select
|
||||||
|
v-model="tenant"
|
||||||
|
class="tenant-select"
|
||||||
|
aria-label="请选择企业或租户"
|
||||||
|
:disabled="submitting"
|
||||||
|
>
|
||||||
<option value="远光软件股份有限公司">远光软件股份有限公司</option>
|
<option value="远光软件股份有限公司">远光软件股份有限公司</option>
|
||||||
</select>
|
</select>
|
||||||
<span class="field-select-chevron" aria-hidden="true">
|
<span class="field-select-chevron" aria-hidden="true">
|
||||||
@@ -114,16 +132,17 @@
|
|||||||
|
|
||||||
<div class="form-meta">
|
<div class="form-meta">
|
||||||
<label class="remember">
|
<label class="remember">
|
||||||
<input v-model="remember" type="checkbox" />
|
<input v-model="remember" type="checkbox" :disabled="submitting" />
|
||||||
<span>记住账号</span>
|
<span>记住账号</span>
|
||||||
</label>
|
</label>
|
||||||
<button type="button" class="link-btn" @click="emit('recover-password')">忘记密码?</button>
|
<button type="button" class="link-btn" :disabled="submitting" @click="emit('recover-password')">忘记密码?</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="errorMessage" class="login-error">{{ errorMessage }}</p>
|
<p v-if="errorMessage" class="login-error">{{ errorMessage }}</p>
|
||||||
|
|
||||||
<button class="submit-btn" type="submit" :disabled="submitting">
|
<button class="submit-btn" type="submit" :disabled="submitting">
|
||||||
{{ submitting ? '登录中...' : '登录' }}
|
<span v-if="submitting" class="submit-btn__spinner" aria-hidden="true"></span>
|
||||||
|
<span class="submit-btn__label">{{ submitting ? '登录中' : '登录' }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="divider"><span>或</span></div>
|
<div class="divider"><span>或</span></div>
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ test('archived claims can only be deleted by admin users', () => {
|
|||||||
assert.equal(canDeleteArchivedExpenseClaims({ roleCodes: ['executive'] }), false)
|
assert.equal(canDeleteArchivedExpenseClaims({ roleCodes: ['executive'] }), false)
|
||||||
assert.equal(canDeleteArchivedExpenseClaims({ roleCodes: ['finance'] }), false)
|
assert.equal(canDeleteArchivedExpenseClaims({ roleCodes: ['finance'] }), false)
|
||||||
assert.equal(canDeleteArchivedExpenseClaims({ isAdmin: true, roleCodes: ['manager'] }), true)
|
assert.equal(canDeleteArchivedExpenseClaims({ isAdmin: true, roleCodes: ['manager'] }), true)
|
||||||
|
assert.equal(canDeleteArchivedExpenseClaims({ username: 'superadmin', roleCodes: ['manager'] }), true)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('legacy reimbursement approval and archive centers are no longer accessible app views', () => {
|
test('legacy reimbursement approval and archive centers are no longer accessible app views', () => {
|
||||||
@@ -76,6 +77,7 @@ test('legacy reimbursement approval and archive centers are no longer accessible
|
|||||||
|
|
||||||
test('platform admin users do not enter the personal workbench', () => {
|
test('platform admin users do not enter the personal workbench', () => {
|
||||||
const adminUser = { username: 'admin', isAdmin: true, roleCodes: ['manager', 'finance'] }
|
const adminUser = { username: 'admin', isAdmin: true, roleCodes: ['manager', 'finance'] }
|
||||||
|
const legacyAdminUser = { username: 'superadmin', roleCodes: ['manager'] }
|
||||||
const employeeUser = { username: 'employee@example.com', roleCodes: [] }
|
const employeeUser = { username: 'employee@example.com', roleCodes: [] }
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ id: 'workbench', label: '个人工作台' },
|
{ id: 'workbench', label: '个人工作台' },
|
||||||
@@ -85,8 +87,10 @@ test('platform admin users do not enter the personal workbench', () => {
|
|||||||
]
|
]
|
||||||
|
|
||||||
assert.equal(canAccessAppView(adminUser, 'workbench'), false)
|
assert.equal(canAccessAppView(adminUser, 'workbench'), false)
|
||||||
|
assert.equal(canAccessAppView(legacyAdminUser, 'workbench'), false)
|
||||||
assert.equal(canAccessAppView(employeeUser, 'workbench'), true)
|
assert.equal(canAccessAppView(employeeUser, 'workbench'), true)
|
||||||
assert.equal(getAccessibleViewIds(adminUser).includes('workbench'), false)
|
assert.equal(getAccessibleViewIds(adminUser).includes('workbench'), false)
|
||||||
|
assert.deepEqual(resolveDefaultAuthorizedRoute(legacyAdminUser), { name: 'app-documents' })
|
||||||
assert.deepEqual(resolveDefaultAuthorizedRoute(adminUser), { name: 'app-documents' })
|
assert.deepEqual(resolveDefaultAuthorizedRoute(adminUser), { name: 'app-documents' })
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
filterNavItemsByAccess(navItems, adminUser).map((item) => item.id),
|
filterNavItemsByAccess(navItems, adminUser).map((item) => item.id),
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import test from 'node:test'
|
|||||||
import {
|
import {
|
||||||
buildAiApplicationPrecheck,
|
buildAiApplicationPrecheck,
|
||||||
buildAiApplicationPrecheckMessage,
|
buildAiApplicationPrecheckMessage,
|
||||||
buildAiApplicationPrecheckThinkingEvents
|
buildAiApplicationPrecheckThinkingEvents,
|
||||||
|
buildAiApplicationSubmitConflictMessage,
|
||||||
|
isAiApplicationPrecheckBlocking
|
||||||
} from '../src/utils/aiApplicationPrecheckModel.js'
|
} from '../src/utils/aiApplicationPrecheckModel.js'
|
||||||
|
|
||||||
const preview = {
|
const preview = {
|
||||||
@@ -71,6 +73,42 @@ test('application precheck blocks application generation when existing applicati
|
|||||||
assert.doesNotMatch(message, /出差申请表草稿已生成/)
|
assert.doesNotMatch(message, /出差申请表草稿已生成/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('application submit precheck blocks submit and keeps application detail action link', () => {
|
||||||
|
const precheck = buildAiApplicationPrecheck(preview, {
|
||||||
|
currentUser: { name: '曹笑竹', departmentName: '技术部' },
|
||||||
|
claimsPayload: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
claim_no: 'AP-OVERLAP',
|
||||||
|
document_type: 'expense_application',
|
||||||
|
expense_type: 'travel_application',
|
||||||
|
employee_name: '曹笑竹',
|
||||||
|
status: 'submitted',
|
||||||
|
risk_flags_json: [
|
||||||
|
{
|
||||||
|
source: 'application_detail',
|
||||||
|
application_detail: {
|
||||||
|
business_time: '2026-02-20 至 2026-02-23',
|
||||||
|
reason: '辅助国网仿生产服务器部署',
|
||||||
|
location: '上海'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(isAiApplicationPrecheckBlocking(precheck), true)
|
||||||
|
|
||||||
|
const message = buildAiApplicationSubmitConflictMessage(preview, precheck)
|
||||||
|
assert.match(message, /### 发现相同日期已有申请单/)
|
||||||
|
assert.match(message, /当前不能继续提交/)
|
||||||
|
assert.match(message, /请先核对申请时间是否填写正确/)
|
||||||
|
assert.match(message, /\[查看\]\(#ai-open-application-detail:AP-OVERLAP\)/)
|
||||||
|
assert.doesNotMatch(message, /生成新的出差申请表/)
|
||||||
|
})
|
||||||
|
|
||||||
test('application precheck emits thinking events for overlap, budget, and form generation', () => {
|
test('application precheck emits thinking events for overlap, budget, and form generation', () => {
|
||||||
const precheck = buildAiApplicationPrecheck(preview, {
|
const precheck = buildAiApplicationPrecheck(preview, {
|
||||||
currentUser: { name: '曹笑竹' },
|
currentUser: { name: '曹笑竹' },
|
||||||
|
|||||||
@@ -1,127 +1,90 @@
|
|||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import test from 'node:test'
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AI_APPLICATION_ACTION_SAVE_DRAFT,
|
AI_APPLICATION_ACTION_SAVE_DRAFT,
|
||||||
AI_APPLICATION_ACTION_SUBMIT,
|
AI_APPLICATION_ACTION_SUBMIT,
|
||||||
buildAiApplicationPreviewActionPayload
|
runAiApplicationPreviewAction
|
||||||
} from '../src/services/aiApplicationPreviewActions.js'
|
} from '../src/services/aiApplicationPreviewActions.js'
|
||||||
import {
|
|
||||||
applyApplicationPolicyEstimateResult,
|
|
||||||
buildApplicationPolicyEstimateRequest,
|
|
||||||
buildLocalApplicationPreview
|
|
||||||
} from '../src/utils/expenseApplicationPreview.js'
|
|
||||||
|
|
||||||
const applicationPreview = {
|
async function testSubmitActionUsesFastPreviewEndpoint() {
|
||||||
fields: {
|
let capturedUrl = ''
|
||||||
applicationType: '差旅费用申请',
|
let capturedOptions = null
|
||||||
applicant: '曹笑竹',
|
|
||||||
grade: 'P5',
|
|
||||||
department: '技术部',
|
|
||||||
position: '财务智能化产品经理',
|
|
||||||
managerName: '向万红',
|
|
||||||
time: '2026-02-20 至 2026-02-23',
|
|
||||||
location: '上海',
|
|
||||||
reason: '辅助国网仿生产服务器部署',
|
|
||||||
days: '4天',
|
|
||||||
transportMode: '火车',
|
|
||||||
lodgingDailyCap: '250元/天',
|
|
||||||
subsidyDailyCap: '100元/天',
|
|
||||||
transportPolicy: '按交通费用预估表暂估',
|
|
||||||
policyEstimate: '交通 720元 + 住宿 1,000元 + 补贴 400元 = 2,120元(4天)',
|
|
||||||
amount: '2,120元'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentUser = {
|
global.fetch = async (url, options) => {
|
||||||
username: 'caoxiaozhu@xf.com',
|
capturedUrl = String(url)
|
||||||
name: '曹笑竹',
|
capturedOptions = options
|
||||||
departmentName: '技术部',
|
return {
|
||||||
position: '财务智能化产品经理',
|
ok: true,
|
||||||
grade: 'P5',
|
async json() {
|
||||||
managerName: '向万红',
|
return {
|
||||||
roleCodes: ['employee']
|
status: 'succeeded',
|
||||||
}
|
result: {
|
||||||
|
draft_payload: {
|
||||||
test('save application preview payload uses save draft action without submit wording', () => {
|
claim_id: 'claim-fast-submit',
|
||||||
const payload = buildAiApplicationPreviewActionPayload({
|
claim_no: 'AP-20260620-FAST',
|
||||||
actionType: AI_APPLICATION_ACTION_SAVE_DRAFT,
|
status: 'submitted',
|
||||||
applicationPreview,
|
approval_stage: '直属领导审批'
|
||||||
currentUser,
|
}
|
||||||
conversationId: 'inline-1'
|
}
|
||||||
})
|
}
|
||||||
|
}
|
||||||
assert.equal(payload.user_id, 'caoxiaozhu@xf.com')
|
|
||||||
assert.equal(payload.conversation_id, 'inline-1')
|
|
||||||
assert.equal(payload.context_json.session_type, 'application')
|
|
||||||
assert.equal(payload.context_json.review_action, undefined)
|
|
||||||
assert.equal(payload.context_json.application_action, 'save_draft')
|
|
||||||
assert.equal(payload.context_json.application_preview.fields.transportMode, '火车')
|
|
||||||
assert.match(payload.message, /费用申请保存草稿/)
|
|
||||||
assert.match(payload.message, /保存草稿/)
|
|
||||||
assert.doesNotMatch(payload.message, /确认提交/)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('submit application preview payload keeps existing draft id for resubmission', () => {
|
|
||||||
const payload = buildAiApplicationPreviewActionPayload({
|
|
||||||
actionType: AI_APPLICATION_ACTION_SUBMIT,
|
|
||||||
applicationPreview,
|
|
||||||
currentUser,
|
|
||||||
conversationId: 'inline-1',
|
|
||||||
draftPayload: {
|
|
||||||
claim_id: 'draft-001',
|
|
||||||
claim_no: 'AP-202602200001'
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await runAiApplicationPreviewAction({
|
||||||
|
actionType: AI_APPLICATION_ACTION_SUBMIT,
|
||||||
|
applicationPreview: {
|
||||||
|
fields: {
|
||||||
|
applicationType: '差旅费用申请',
|
||||||
|
time: '2026-07-01 至 2026-07-03',
|
||||||
|
location: '北京',
|
||||||
|
reason: '项目实施',
|
||||||
|
days: '3天',
|
||||||
|
transportMode: '火车',
|
||||||
|
amount: '1000元'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
currentUser: { username: 'zhangsan@example.com', name: '张三' },
|
||||||
|
conversationId: 'conversation-fast-submit'
|
||||||
})
|
})
|
||||||
|
|
||||||
assert.equal(payload.context_json.review_action, undefined)
|
assert.equal(capturedUrl, '/api/v1/reimbursements/application-preview-action')
|
||||||
assert.equal(payload.context_json.application_edit_claim_id, 'draft-001')
|
assert.equal(capturedOptions.method, 'POST')
|
||||||
assert.equal(payload.context_json.draft_claim_id, 'draft-001')
|
const body = JSON.parse(capturedOptions.body)
|
||||||
assert.match(payload.message, /费用申请确认提交/)
|
assert.equal(body.context_json.session_type, 'application')
|
||||||
assert.match(payload.message, /确认提交/)
|
assert.equal(body.context_json.application_stage, 'expense_application')
|
||||||
})
|
assert.equal(body.context_json.application_preview.fields.transportMode, '火车')
|
||||||
|
}
|
||||||
|
|
||||||
test('travel application preview calculates base standards before transport mode is selected', () => {
|
async function testSaveDraftActionKeepsOrchestratorPath() {
|
||||||
const preview = buildLocalApplicationPreview(
|
let capturedUrl = ''
|
||||||
'2月20-23日去上海出差,辅助国网仿生产服务器部署',
|
|
||||||
{ name: '曹笑竹', grade: 'P5', location: '武汉' },
|
|
||||||
{ today: '2026-06-20' }
|
|
||||||
)
|
|
||||||
const request = buildApplicationPolicyEstimateRequest(preview, { grade: 'P5', location: '武汉' })
|
|
||||||
|
|
||||||
assert.equal(request.canCalculate, true)
|
global.fetch = async (url) => {
|
||||||
assert.deepEqual(request.payload, {
|
capturedUrl = String(url)
|
||||||
days: 4,
|
return {
|
||||||
location: '上海',
|
ok: true,
|
||||||
grade: 'P5',
|
async json() {
|
||||||
transport_mode: null,
|
return { status: 'succeeded', result: {} }
|
||||||
origin_location: '武汉',
|
}
|
||||||
travel_date: '2026-02-20'
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await runAiApplicationPreviewAction({
|
||||||
|
actionType: AI_APPLICATION_ACTION_SAVE_DRAFT,
|
||||||
|
applicationPreview: { fields: { reason: '项目实施' } },
|
||||||
|
currentUser: { username: 'zhangsan@example.com', name: '张三' }
|
||||||
})
|
})
|
||||||
|
|
||||||
const estimatedPreview = applyApplicationPolicyEstimateResult(preview, {
|
assert.equal(capturedUrl, '/api/v1/orchestrator/run')
|
||||||
days: 4,
|
}
|
||||||
location: '上海',
|
|
||||||
matched_city: '上海',
|
|
||||||
grade: 'P5',
|
|
||||||
hotel_rate: 450,
|
|
||||||
hotel_amount: 1800,
|
|
||||||
total_allowance_rate: 100,
|
|
||||||
allowance_amount: 400,
|
|
||||||
transport_mode: '火车',
|
|
||||||
transport_origin: '武汉',
|
|
||||||
transport_destination: '上海',
|
|
||||||
transport_estimated_amount: 720,
|
|
||||||
total_amount: 2200,
|
|
||||||
rule_name: '公司差旅费报销规则',
|
|
||||||
rule_version: 'v1.0.0'
|
|
||||||
}, { grade: 'P5', location: '武汉' })
|
|
||||||
|
|
||||||
assert.equal(estimatedPreview.fields.transportMode, '')
|
async function run() {
|
||||||
assert.equal(estimatedPreview.missingFields.includes('出行方式'), true)
|
await testSubmitActionUsesFastPreviewEndpoint()
|
||||||
assert.equal(estimatedPreview.fields.lodgingDailyCap, '450元/天')
|
await testSaveDraftActionKeepsOrchestratorPath()
|
||||||
assert.equal(estimatedPreview.fields.subsidyDailyCap, '100元/天')
|
console.log('ai-application-preview-actions tests passed')
|
||||||
assert.equal(estimatedPreview.fields.transportPolicy, '选择火车、飞机或轮船后自动预估交通费用')
|
}
|
||||||
assert.equal(estimatedPreview.fields.policyEstimate, '交通待补充 + 住宿 1,800元 + 补贴 400元 = 2,200元(4天,不含交通)')
|
|
||||||
assert.equal(estimatedPreview.fields.amount, '2,200元(不含交通)')
|
run().catch((error) => {
|
||||||
|
console.error(error)
|
||||||
|
process.exit(1)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -94,6 +94,46 @@ async function testInjectsAuthenticatedUserHeaders() {
|
|||||||
assert.equal(capturedOptions.headers['x-auth-is-admin'], 'true')
|
assert.equal(capturedOptions.headers['x-auth-is-admin'], 'true')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function testInjectsLegacyAdminHeaderFromSnakeCaseFlag() {
|
||||||
|
const sessionStorage = new Map([
|
||||||
|
[
|
||||||
|
'x-financial-auth-user',
|
||||||
|
JSON.stringify({
|
||||||
|
username: 'superadmin',
|
||||||
|
name: 'superadmin',
|
||||||
|
roleCodes: ['manager'],
|
||||||
|
is_admin: true
|
||||||
|
})
|
||||||
|
]
|
||||||
|
])
|
||||||
|
|
||||||
|
global.window = {
|
||||||
|
sessionStorage: {
|
||||||
|
getItem(key) {
|
||||||
|
return sessionStorage.get(key) ?? null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let capturedOptions = null
|
||||||
|
|
||||||
|
global.fetch = async (_url, options) => {
|
||||||
|
capturedOptions = options
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
async json() {
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await apiRequest('/reimbursements/claims/demo', { method: 'DELETE' })
|
||||||
|
|
||||||
|
assert.equal(capturedOptions.headers['x-auth-username'], 'superadmin')
|
||||||
|
assert.equal(capturedOptions.headers['x-auth-role-codes'], 'manager')
|
||||||
|
assert.equal(capturedOptions.headers['x-auth-is-admin'], 'true')
|
||||||
|
}
|
||||||
|
|
||||||
async function testFormatsValidationErrors() {
|
async function testFormatsValidationErrors() {
|
||||||
global.fetch = async () => ({
|
global.fetch = async () => ({
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -153,6 +193,7 @@ async function run() {
|
|||||||
await testUsesCustomContentTypeHeader()
|
await testUsesCustomContentTypeHeader()
|
||||||
await testSupportsBlobResponses()
|
await testSupportsBlobResponses()
|
||||||
await testInjectsAuthenticatedUserHeaders()
|
await testInjectsAuthenticatedUserHeaders()
|
||||||
|
await testInjectsLegacyAdminHeaderFromSnakeCaseFlag()
|
||||||
await testFormatsValidationErrors()
|
await testFormatsValidationErrors()
|
||||||
await testRejectsWithCustomTimeoutMessage()
|
await testRejectsWithCustomTimeoutMessage()
|
||||||
console.log('api-request tests passed')
|
console.log('api-request tests passed')
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ test('workbench progress refresh is silent to avoid homepage flashing', () => {
|
|||||||
test('document detail navigation preserves document center list query', () => {
|
test('document detail navigation preserves document center list query', () => {
|
||||||
assert.match(
|
assert.match(
|
||||||
appShellComposable,
|
appShellComposable,
|
||||||
/function openRequestDetail\(request, options = \{\}\) \{[\s\S]*name: 'app-document-detail'[\s\S]*params: \{ requestId: request\.claimId \|\| request\.id \},[\s\S]*query: buildDocumentDetailQuery\(options\)/
|
/function openRequestDetail\(request, options = \{\}\) \{[\s\S]*const requestId = resolveRequestDetailLookupId\(request\)[\s\S]*name: 'app-document-detail'[\s\S]*params: \{ requestId \},[\s\S]*query: buildDocumentDetailQuery\(options\)/
|
||||||
)
|
)
|
||||||
assert.match(
|
assert.match(
|
||||||
appShellComposable,
|
appShellComposable,
|
||||||
@@ -135,7 +135,8 @@ test('document detail refreshes claim detail instead of relying on stale list ca
|
|||||||
assert.match(appShellComposable, /import \{ mapExpenseClaimToRequest, useRequests \} from '\.\/useRequests\.js'/)
|
assert.match(appShellComposable, /import \{ mapExpenseClaimToRequest, useRequests \} from '\.\/useRequests\.js'/)
|
||||||
assert.match(appShellComposable, /const snapshot = normalizeRequestForUi\(selectedRequestSnapshot\.value\)[\s\S]*if \(isSameRequestIdentity\(snapshot, requestId\)\) \{[\s\S]*return snapshot/)
|
assert.match(appShellComposable, /const snapshot = normalizeRequestForUi\(selectedRequestSnapshot\.value\)[\s\S]*if \(isSameRequestIdentity\(snapshot, requestId\)\) \{[\s\S]*return snapshot/)
|
||||||
assert.match(appShellComposable, /async function refreshSelectedRequestDetail\(requestOrId = selectedRequestSnapshot\.value\) \{[\s\S]*fetchExpenseClaimDetail\(lookupId\)[\s\S]*mapExpenseClaimToRequest\(payload\)[\s\S]*upsertRequestSnapshot\(mappedRequest\)/)
|
assert.match(appShellComposable, /async function refreshSelectedRequestDetail\(requestOrId = selectedRequestSnapshot\.value\) \{[\s\S]*fetchExpenseClaimDetail\(lookupId\)[\s\S]*mapExpenseClaimToRequest\(payload\)[\s\S]*upsertRequestSnapshot\(mappedRequest\)/)
|
||||||
assert.match(appShellComposable, /function openRequestDetail\(request, options = \{\}\) \{[\s\S]*void refreshSelectedRequestDetail\(request\)/)
|
assert.match(appShellComposable, /function isDetailLookupOnlyPayload\(payload = \{\}\) \{[\s\S]*payload\?\.detailLookupOnly/)
|
||||||
|
assert.match(appShellComposable, /function openRequestDetail\(request, options = \{\}\) \{[\s\S]*selectedRequestSnapshot\.value = isDetailLookupOnlyRequest \? null : request \|\| null[\s\S]*void refreshSelectedRequestDetail\(isDetailLookupOnlyRequest \? requestId : request\)/)
|
||||||
assert.match(appShellComposable, /async function handleRequestUpdated\(payload = \{\}\) \{[\s\S]*await reloadWorkbenchRequests\(\)[\s\S]*await refreshSelectedRequestDetail\(claimId\)/)
|
assert.match(appShellComposable, /async function handleRequestUpdated\(payload = \{\}\) \{[\s\S]*await reloadWorkbenchRequests\(\)[\s\S]*await refreshSelectedRequestDetail\(claimId\)/)
|
||||||
assert.match(appShellComposable, /route\.name === 'app-document-detail'[\s\S]*void refreshSelectedRequestDetail\(String\(route\.params\.requestId \|\| ''\)\)/)
|
assert.match(appShellComposable, /route\.name === 'app-document-detail'[\s\S]*void refreshSelectedRequestDetail\(String\(route\.params\.requestId \|\| ''\)\)/)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ test('AI mode offers an inline application shortcut when no candidate applicatio
|
|||||||
assert.match(aiMode, /buildLocalApplicationPreviewMessage/)
|
assert.match(aiMode, /buildLocalApplicationPreviewMessage/)
|
||||||
assert.match(aiMode, /refreshApplicationPreviewEstimate/)
|
assert.match(aiMode, /refreshApplicationPreviewEstimate/)
|
||||||
assert.match(aiMode, /applicationPreview:\s*preview/)
|
assert.match(aiMode, /applicationPreview:\s*preview/)
|
||||||
|
assert.match(aiMode, /suggestedActions:\s*buildInlineApplicationPreviewSuggestedActions\(preview\)/)
|
||||||
assert.doesNotMatch(aiMode, /function startAiApplicationDraft/)
|
assert.doesNotMatch(aiMode, /function startAiApplicationDraft/)
|
||||||
assert.doesNotMatch(aiMode, /buildAiApplicationStepPrompt/)
|
assert.doesNotMatch(aiMode, /buildAiApplicationStepPrompt/)
|
||||||
})
|
})
|
||||||
@@ -94,12 +95,143 @@ test('AI mode handles document query prompts locally before steward planning', (
|
|||||||
assert.match(aiMode, /emit\('open-document', buildAiDocumentDetailRequest\(detailReference\)\)/)
|
assert.match(aiMode, /emit\('open-document', buildAiDocumentDetailRequest\(detailReference\)\)/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('AI mode continues required application gate decisions into table preview from steward plan', () => {
|
test('AI mode asks for manual confirmation before generating application preview table', () => {
|
||||||
assert.match(aiMode, /function continueAiRequiredApplicationGateFromPlan\(normalizedPlan, prompt = ''\)/)
|
assert.match(aiMode, /function buildAiRequiredApplicationGateSuggestedActions\(flow, prompt = ''\)/)
|
||||||
assert.match(aiMode, /flow\.flowId === 'travel_application'[\s\S]*void startAiApplicationPreview\('travel', '差旅费', prompt/)
|
assert.match(aiMode, /label:\s*'确认发起出差申请'/)
|
||||||
assert.match(aiMode, /flow\.flowId === 'travel_reimbursement'[\s\S]*startAiExpenseDraft\('travel', '差旅费', true/)
|
assert.match(aiMode, /action_type:\s*'ai_application_start_inline'/)
|
||||||
assert.match(aiMode, /continueAiRequiredApplicationGateFromPlan\(normalizedPlan, prompt\)/)
|
assert.match(aiMode, /carry_text:\s*prompt/)
|
||||||
|
assert.match(aiMode, /label:\s*'确认关联已有申请单'/)
|
||||||
|
assert.match(aiMode, /flow_id:\s*'travel_reimbursement'/)
|
||||||
|
assert.match(aiMode, /suggestedActions:\s*requiredApplicationContinuationFlow[\s\S]*buildAiRequiredApplicationGateSuggestedActions\(requiredApplicationContinuationFlow, prompt\)/)
|
||||||
|
assert.doesNotMatch(aiMode, /continueAiRequiredApplicationGateFromPlan\(normalizedPlan, prompt\)/)
|
||||||
|
assert.doesNotMatch(aiMode, /flow\.flowId === 'travel_application'[\s\S]*void startAiApplicationPreview\('travel', '差旅费', prompt\)/)
|
||||||
assert.match(aiMode, /class="workbench-ai-application-preview application-preview-shell"/)
|
assert.match(aiMode, /class="workbench-ai-application-preview application-preview-shell"/)
|
||||||
assert.match(aiMode, /resolveInlineApplicationPreviewRows\(message\)/)
|
assert.match(aiMode, /resolveInlineApplicationPreviewRows\(message\)/)
|
||||||
assert.match(aiMode, /commitInlineApplicationPreviewEditor\(message\)/)
|
assert.match(aiMode, /commitInlineApplicationPreviewEditor\(message\)/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('AI mode shows pending feedback before async application preview estimate refresh', () => {
|
||||||
|
const startPreviewFunction = aiMode.match(
|
||||||
|
/async function startAiApplicationPreview[\s\S]*?\n}\n\nfunction requestDeleteCurrentConversation/
|
||||||
|
)?.[0] || ''
|
||||||
|
|
||||||
|
assert.match(startPreviewFunction, /const pendingMessage = createInlineMessage\(\s*'assistant',\s*'正在生成申请核对表/)
|
||||||
|
assert.ok(
|
||||||
|
startPreviewFunction.indexOf('conversationMessages.value.push(pendingMessage)') <
|
||||||
|
startPreviewFunction.indexOf('await refreshApplicationPreviewEstimate(')
|
||||||
|
)
|
||||||
|
assert.match(startPreviewFunction, /pending:\s*true/)
|
||||||
|
assert.match(startPreviewFunction, /replaceInlineMessage\(\s*pendingMessage\.id/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('AI mode handles application preview save and submit through buttons or text commands', () => {
|
||||||
|
assert.match(aiMode, /AI_APPLICATION_ACTION_SAVE_DRAFT/)
|
||||||
|
assert.match(aiMode, /AI_APPLICATION_ACTION_SUBMIT/)
|
||||||
|
assert.match(aiMode, /runAiApplicationPreviewAction/)
|
||||||
|
assert.match(aiMode, /buildAiApplicationPrecheck/)
|
||||||
|
assert.match(aiMode, /buildAiApplicationSubmitConflictMessage/)
|
||||||
|
assert.match(aiMode, /isAiApplicationPrecheckBlocking/)
|
||||||
|
assert.match(aiMode, /applicationSubmitConfirmOpen/)
|
||||||
|
assert.match(aiMode, /确认直接提交申请/)
|
||||||
|
assert.match(aiMode, /function buildInlineApplicationPreviewSuggestedActions\(applicationPreview = \{\}, draftPayload = null\)/)
|
||||||
|
assert.match(aiMode, /label:\s*'直接提交'/)
|
||||||
|
assert.match(aiMode, /function resolveInlineApplicationPreviewActionFromText\(text = ''\)/)
|
||||||
|
assert.match(aiMode, /function executeInlineApplicationPreviewAction\(actionType, sourceMessage = null, options = \{\}\)/)
|
||||||
|
assert.match(aiMode, /function confirmInlineApplicationSubmit\(\)/)
|
||||||
|
assert.match(aiMode, /function cancelInlineApplicationSubmitConfirm\(\)/)
|
||||||
|
assert.match(aiMode, /function handleInlineApplicationPreviewTextAction\(prompt\)/)
|
||||||
|
assert.match(aiMode, /if \(handleInlineApplicationPreviewTextAction\(cleanPrompt\)\) \{[\s\S]*return[\s\S]*\}/)
|
||||||
|
assert.match(aiMode, /\[AI_APPLICATION_ACTION_SAVE_DRAFT, AI_APPLICATION_ACTION_SUBMIT\]\.includes\(actionType\)/)
|
||||||
|
assert.match(aiMode, /normalizedPreview\.readyToSubmit/)
|
||||||
|
assert.match(aiMode, /fetchExpenseClaims\(\{ page: 1, pageSize: 100 \}\)/)
|
||||||
|
assert.match(aiMode, /skipUserMessage/)
|
||||||
|
assert.match(aiMode, /暂不能提交申请/)
|
||||||
|
assert.match(aiMode, /#ai-open-application-detail:/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('AI mode waits for submit confirmation before adding submit action to the conversation', () => {
|
||||||
|
const executeStart = aiMode.indexOf('async function executeInlineApplicationPreviewAction')
|
||||||
|
const executeEnd = aiMode.indexOf('\nfunction handleInlineApplicationPreviewTextAction', executeStart)
|
||||||
|
const executeBlock = aiMode.slice(executeStart, executeEnd)
|
||||||
|
const confirmGateIndex = executeBlock.indexOf('if (isSubmit && !options.confirmed)')
|
||||||
|
const requestConfirmIndex = executeBlock.indexOf('requestInlineApplicationSubmitConfirmation', confirmGateIndex)
|
||||||
|
const confirmedActionPushIndex = executeBlock.indexOf('pushInlineApplicationActionUserMessage(userText)', requestConfirmIndex)
|
||||||
|
|
||||||
|
assert.ok(confirmGateIndex >= 0, '直接提交应先进入确认分支')
|
||||||
|
assert.ok(requestConfirmIndex > confirmGateIndex, '直接提交确认分支应先打开确认弹窗')
|
||||||
|
assert.ok(confirmedActionPushIndex > requestConfirmIndex, '确认弹窗打开前不应追加“直接提交”用户消息')
|
||||||
|
assert.match(
|
||||||
|
executeBlock,
|
||||||
|
/requestInlineApplicationSubmitConfirmation\(targetMessage,\s*\{\s*\.\.\.options,\s*userText\s*\}\)/
|
||||||
|
)
|
||||||
|
|
||||||
|
const confirmStart = aiMode.indexOf('function confirmInlineApplicationSubmit()')
|
||||||
|
const confirmEnd = aiMode.indexOf('\nasync function runInlineApplicationSubmitPrecheck', confirmStart)
|
||||||
|
const confirmBlock = aiMode.slice(confirmStart, confirmEnd)
|
||||||
|
assert.match(confirmBlock, /userText:\s*context\.userText \|\| '直接提交'/)
|
||||||
|
assert.match(confirmBlock, /skipUserMessage:\s*false/)
|
||||||
|
|
||||||
|
const cancelStart = aiMode.indexOf('function cancelInlineApplicationSubmitConfirm()')
|
||||||
|
const cancelEnd = aiMode.indexOf('\nfunction confirmInlineApplicationSubmit', cancelStart)
|
||||||
|
const cancelBlock = aiMode.slice(cancelStart, cancelEnd)
|
||||||
|
assert.doesNotMatch(cancelBlock, /pushInlineUserMessage|pushInlineApplicationActionUserMessage/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('AI mode formats saved application draft as a detail table without continuing submit flow', () => {
|
||||||
|
assert.match(aiMode, /function buildInlineApplicationResultTable\(draftPayload = \{\}, options = \{\}\)/)
|
||||||
|
assert.match(aiMode, /\| 单据类型 \| 单据编号 \| 单据状态 \| 当前节点 \| 操作 \|/)
|
||||||
|
assert.match(aiMode, /\[查看\]\(\$\{href\}\)/)
|
||||||
|
|
||||||
|
const resultStart = aiMode.indexOf('function buildInlineApplicationPreviewActionResultText')
|
||||||
|
const resultEnd = aiMode.indexOf('\nfunction buildInlineApplicationDetailAction', resultStart)
|
||||||
|
const resultBlock = aiMode.slice(resultStart, resultEnd)
|
||||||
|
const submitBranchIndex = resultBlock.indexOf('actionType === AI_APPLICATION_ACTION_SUBMIT')
|
||||||
|
const saveBranchIndex = resultBlock.indexOf("'### 申请草稿已保存'")
|
||||||
|
const saveBranch = resultBlock.slice(saveBranchIndex)
|
||||||
|
|
||||||
|
assert.ok(submitBranchIndex >= 0)
|
||||||
|
assert.ok(saveBranchIndex > submitBranchIndex, '保存草稿结果应走非提交分支')
|
||||||
|
assert.match(
|
||||||
|
saveBranch,
|
||||||
|
/buildInlineApplicationResultTable\(draftPayload,\s*\{[\s\S]*statusLabel:\s*'草稿'[\s\S]*stageLabel:\s*'待提交'/
|
||||||
|
)
|
||||||
|
assert.doesNotMatch(saveBranch, /进入审批流程/)
|
||||||
|
|
||||||
|
const executeStart = aiMode.indexOf('async function executeInlineApplicationPreviewAction')
|
||||||
|
const executeEnd = aiMode.indexOf('\nfunction handleInlineApplicationPreviewTextAction', executeStart)
|
||||||
|
const executeBlock = aiMode.slice(executeStart, executeEnd)
|
||||||
|
assert.match(executeBlock, /targetMessage\.suggestedActions = \[\]/)
|
||||||
|
assert.doesNotMatch(
|
||||||
|
executeBlock,
|
||||||
|
/targetMessage\.suggestedActions = isSubmit[\s\S]*buildInlineApplicationPreviewSuggestedActions\(targetMessage\.applicationPreview, draftPayload\)/
|
||||||
|
)
|
||||||
|
assert.match(executeBlock, /suggestedActions:\s*isSubmit\s*\?\s*buildInlineApplicationDetailAction\(draftPayload\)\s*:\s*\[\]/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('AI mode locks application preview actions while estimate refresh is pending', () => {
|
||||||
|
assert.match(aiMode, /function isApplicationPreviewEstimatePendingPreview\(applicationPreview = \{\}\)/)
|
||||||
|
assert.match(
|
||||||
|
aiMode,
|
||||||
|
/function buildInlineApplicationPreviewSuggestedActions\(applicationPreview = \{\}, draftPayload = null\) \{[\s\S]*if \(isApplicationPreviewEstimatePendingPreview\(applicationPreview\)\) \{[\s\S]*return \[\]/
|
||||||
|
)
|
||||||
|
assert.match(aiMode, /const isAiModeInputLocked = computed\(\(\) => applicationPreviewEstimatePending\.value\)/)
|
||||||
|
assert.match(aiMode, /:disabled="isAiModeInputLocked"/)
|
||||||
|
assert.match(aiMode, /v-if="canShowInlineSuggestedActions\(message\)"/)
|
||||||
|
assert.match(aiMode, /:disabled="isInlineSuggestedActionDisabled\(action, message\)"/)
|
||||||
|
assert.match(
|
||||||
|
aiMode,
|
||||||
|
/message\.suggestedActions = \[\][\s\S]*const committed = await commitApplicationPreviewEditor\(message\)/
|
||||||
|
)
|
||||||
|
assert.match(
|
||||||
|
aiMode,
|
||||||
|
/if \(applicationPreviewEstimatePending\.value\) \{[\s\S]*toast\('请等待费用测算完成后再继续操作。'\)[\s\S]*return true/
|
||||||
|
)
|
||||||
|
assert.match(
|
||||||
|
aiMode,
|
||||||
|
/row\.editable && !isApplicationPreviewEstimatePending\(message\) \? 0 : -1/
|
||||||
|
)
|
||||||
|
assert.match(
|
||||||
|
aiMode,
|
||||||
|
/费用测算正在同步,请稍等,完成后才能保存草稿或直接提交。/
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|||||||
@@ -211,7 +211,8 @@ test('AI mode screen follows the approved reference structure', () => {
|
|||||||
assert.match(aiMode, /<img[\s\S]*class="workbench-ai-orb__image"/)
|
assert.match(aiMode, /<img[\s\S]*class="workbench-ai-orb__image"/)
|
||||||
assert.match(aiMode, /小财管家/)
|
assert.match(aiMode, /小财管家/)
|
||||||
assert.match(aiMode, /我是您的小财管家/)
|
assert.match(aiMode, /我是您的小财管家/)
|
||||||
assert.match(aiMode, /placeholder="今天我能帮您做点什么?"/)
|
assert.match(aiMode, /今天我能帮您做点什么?/)
|
||||||
|
assert.match(aiMode, /费用测算中,请稍等/)
|
||||||
assert.match(aiMode, /rows="3"/)
|
assert.match(aiMode, /rows="3"/)
|
||||||
assert.match(aiMode, /workbench-ai-composer-toolbar/)
|
assert.match(aiMode, /workbench-ai-composer-toolbar/)
|
||||||
assert.match(aiMode, /Axiom Ultra 3\.1/)
|
assert.match(aiMode, /Axiom Ultra 3\.1/)
|
||||||
@@ -257,7 +258,7 @@ test('AI mode screen follows the approved reference structure', () => {
|
|||||||
assert.doesNotMatch(aiMode, /小财管家正在思考/)
|
assert.doesNotMatch(aiMode, /小财管家正在思考/)
|
||||||
assert.doesNotMatch(aiMode, /思考过程/)
|
assert.doesNotMatch(aiMode, /思考过程/)
|
||||||
assert.doesNotMatch(aiMode, /message\.pending \?/)
|
assert.doesNotMatch(aiMode, /message\.pending \?/)
|
||||||
assert.match(aiMode, /placeholder="继续和小财管家对话\.\.\."/)
|
assert.match(aiMode, /继续和小财管家对话\.\.\./)
|
||||||
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card\)/)
|
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card\)/)
|
||||||
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.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__body\)/)
|
||||||
@@ -293,6 +294,9 @@ test('AI mode screen follows the approved reference structure', () => {
|
|||||||
assert.match(aiMode, /const finalMessageText = requiredApplicationContinuationFlow[\s\S]*buildAiRequiredApplicationGateAutoMessage\(normalizedPlan, requiredApplicationContinuationFlow\)[\s\S]*buildStewardPlanMessageText\(plan\)/)
|
assert.match(aiMode, /const finalMessageText = requiredApplicationContinuationFlow[\s\S]*buildAiRequiredApplicationGateAutoMessage\(normalizedPlan, requiredApplicationContinuationFlow\)[\s\S]*buildStewardPlanMessageText\(plan\)/)
|
||||||
assert.match(aiMode, /const hasServerStreamedContent = Boolean\(String\(pendingMessage\.content \|\| ''\)\.trim\(\)\)/)
|
assert.match(aiMode, /const hasServerStreamedContent = Boolean\(String\(pendingMessage\.content \|\| ''\)\.trim\(\)\)/)
|
||||||
assert.match(aiMode, /if \(!hasServerStreamedContent\) \{[\s\S]*await streamInlineAssistantContent\(pendingMessage\.id, finalMessageText\)[\s\S]*\}/)
|
assert.match(aiMode, /if \(!hasServerStreamedContent\) \{[\s\S]*await streamInlineAssistantContent\(pendingMessage\.id, finalMessageText\)[\s\S]*\}/)
|
||||||
|
assert.match(aiMode, /if \(actionType === AI_APPLICATION_ACTION_SUBMIT\) \{[\s\S]*buildInlineApplicationResultTable\(draftPayload/)
|
||||||
|
assert.match(aiMode, /需要查看完整详情时,请点击列表最后一列的“查看”进入单据详情。/)
|
||||||
|
assert.doesNotMatch(aiMode, /\*\*申请单号:\*\*/)
|
||||||
assert.doesNotMatch(aiMode, /createInlineMessage\('assistant', buildStewardPlanMessageText\(plan\)/)
|
assert.doesNotMatch(aiMode, /createInlineMessage\('assistant', buildStewardPlanMessageText\(plan\)/)
|
||||||
assert.doesNotMatch(aiMode, /runOrchestrator\(/)
|
assert.doesNotMatch(aiMode, /runOrchestrator\(/)
|
||||||
assert.doesNotMatch(aiMode, /buildFallbackAnswer/)
|
assert.doesNotMatch(aiMode, /buildFallbackAnswer/)
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ const workbench = readFileSync(
|
|||||||
fileURLToPath(new URL('../src/components/business/PersonalWorkbench.vue', import.meta.url)),
|
fileURLToPath(new URL('../src/components/business/PersonalWorkbench.vue', import.meta.url)),
|
||||||
'utf8'
|
'utf8'
|
||||||
)
|
)
|
||||||
|
const aiMode = readFileSync(
|
||||||
|
fileURLToPath(new URL('../src/components/business/PersonalWorkbenchAiMode.vue', import.meta.url)),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
|
||||||
test('workbench document detail keeps workbench as the return target', () => {
|
test('workbench document detail keeps workbench as the return target', () => {
|
||||||
assert.match(workbench, /source:\s*'workbench'/)
|
assert.match(workbench, /source:\s*'workbench'/)
|
||||||
@@ -22,10 +26,31 @@ test('workbench document detail keeps workbench as the return target', () => {
|
|||||||
assert.match(appShell, /:back-label="detailBackLabel"/)
|
assert.match(appShell, /:back-label="detailBackLabel"/)
|
||||||
assert.match(appShell, /String\(payload\.returnTo \|\| ''\)\.trim\(\) === 'workbench'/)
|
assert.match(appShell, /String\(payload\.returnTo \|\| ''\)\.trim\(\) === 'workbench'/)
|
||||||
assert.match(appShell, /String\(payload\.source \|\| ''\)\.trim\(\) === 'workbench'/)
|
assert.match(appShell, /String\(payload\.source \|\| ''\)\.trim\(\) === 'workbench'/)
|
||||||
assert.match(appShell, /openRequestDetail\(request \|\| payload,\s*\{ returnTo \}\)/)
|
assert.match(appShell, /const detailPayload = request \|\| \{[\s\S]*detailLookupOnly:\s*true[\s\S]*\}/)
|
||||||
|
assert.match(appShell, /openRequestDetail\(detailPayload,\s*\{ returnTo \}\)/)
|
||||||
assert.match(appShellComposable, /const detailReturnTarget = computed/)
|
assert.match(appShellComposable, /const detailReturnTarget = computed/)
|
||||||
assert.match(appShellComposable, /detailReturnTarget\.value === 'workbench' \? '返回首页' : '返回单据中心'/)
|
assert.match(appShellComposable, /detailReturnTarget\.value === 'workbench' \? '返回首页' : '返回单据中心'/)
|
||||||
assert.match(appShellComposable, /nextQuery\.returnTo = 'workbench'/)
|
assert.match(appShellComposable, /nextQuery\.returnTo = 'workbench'/)
|
||||||
assert.match(appShellComposable, /router\.push\(\{ name: 'app-workbench' \}\)/)
|
assert.match(appShellComposable, /router\.push\(\{ name: 'app-workbench' \}\)/)
|
||||||
assert.match(appShellComposable, /router\.push\(\{ name: 'app-documents', query: buildDocumentReturnQuery\(\) \}\)/)
|
assert.match(appShellComposable, /router\.push\(\{ name: 'app-documents', query: buildDocumentReturnQuery\(\) \}\)/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('AI detail links wait for full document detail instead of rendering a half snapshot', () => {
|
||||||
|
assert.match(aiMode, /detailLookupOnly:\s*true/)
|
||||||
|
assert.match(
|
||||||
|
appShell,
|
||||||
|
/v-else-if="activeView === 'documents' && detailMode && !selectedRequest"[\s\S]*正在加载完整单据详情/
|
||||||
|
)
|
||||||
|
assert.match(
|
||||||
|
appShell,
|
||||||
|
/const detailPayload = request \|\| \{[\s\S]*detailLookupOnly:\s*true[\s\S]*\}/
|
||||||
|
)
|
||||||
|
assert.match(
|
||||||
|
appShellComposable,
|
||||||
|
/const isDetailLookupOnlyRequest = isDetailLookupOnlyPayload\(request\)[\s\S]*selectedRequestSnapshot\.value = isDetailLookupOnlyRequest \? null : request \|\| null/
|
||||||
|
)
|
||||||
|
assert.match(
|
||||||
|
appShellComposable,
|
||||||
|
/void refreshSelectedRequestDetail\(isDetailLookupOnlyRequest \? requestId : request\)/
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user