feat(workbench): keep progress detail return context

This commit is contained in:
caoxiaozhu
2026-06-03 15:14:44 +08:00
parent 20cb60e247
commit 31052d0b98
11 changed files with 231 additions and 10 deletions

View File

@@ -96,7 +96,7 @@
}
.progress-row {
grid-template-columns: minmax(72px, 0.42fr) minmax(126px, 0.86fr) minmax(270px, 1.32fr) minmax(86px, auto);
grid-template-columns: minmax(72px, 0.4fr) minmax(122px, 0.72fr) minmax(78px, 0.42fr) minmax(238px, 1.18fr) minmax(86px, auto);
gap: 12px;
}
}
@@ -328,6 +328,7 @@
grid-template-columns: minmax(70px, auto) 1fr minmax(74px, auto);
grid-template-areas:
"time identity result"
"type type type"
"steps steps steps";
gap: 8px;
padding: 10px 0;
@@ -356,6 +357,11 @@
font-size: 11px;
}
.progress-type {
grid-area: type;
width: 100%;
}
.progress-result {
grid-area: result;
width: 100%;

View File

@@ -525,6 +525,38 @@
white-space: nowrap;
}
.progress-type {
min-width: 0;
display: grid;
justify-items: start;
gap: 3px;
}
.progress-type small {
color: var(--workbench-muted);
font-size: 10px;
font-weight: 750;
line-height: 1.2;
}
.progress-type strong {
max-width: 100%;
overflow: hidden;
display: inline-flex;
align-items: center;
min-height: 22px;
padding: 0 8px;
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.18);
border-radius: 4px;
background: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08);
color: var(--workbench-primary-active);
font-size: 11.5px;
font-weight: 850;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.progress-status {
display: inline-flex;
align-items: center;
@@ -555,7 +587,7 @@
.progress-row {
position: relative;
display: grid;
grid-template-columns: minmax(78px, 0.44fr) minmax(138px, 0.86fr) minmax(300px, 1.46fr) minmax(92px, auto);
grid-template-columns: minmax(78px, 0.42fr) minmax(132px, 0.74fr) minmax(84px, 0.42fr) minmax(260px, 1.28fr) minmax(92px, auto);
align-items: center;
gap: 12px;
width: 100%;
@@ -605,6 +637,7 @@
.progress-time,
.progress-identity,
.progress-type,
.progress-result {
min-width: 0;
display: grid;

View File

@@ -215,6 +215,11 @@
<small>{{ item.title }}</small>
</span>
<span class="progress-type" :title="item.expenseTypeLabel || '其他费用'">
<small>费用类型</small>
<strong>{{ item.expenseTypeLabel || '其他费用' }}</strong>
</span>
<span class="progress-steps" aria-hidden="true">
<span
v-for="step in item.steps"
@@ -665,6 +670,8 @@ function openWorkbenchTarget(item) {
const target = item?.target || {}
if (target.type === 'document' && (target.id || target.claimNo)) {
emit('open-document', {
source: 'workbench',
returnTo: 'workbench',
claimId: target.id,
id: target.id || target.claimNo,
claimNo: target.claimNo

View File

@@ -95,6 +95,10 @@ export function useAppShell() {
})
const detailMode = computed(() => route.name === 'app-document-detail')
const detailReturnTarget = computed(() => String(route.query.returnTo || '').trim())
const detailBackLabel = computed(() => (
detailReturnTarget.value === 'workbench' ? '返回首页' : '返回单据中心'
))
const detailAlerts = computed(() => (
detailMode.value
? buildDetailAlerts(selectedRequest.value, { currentUser: currentUser.value })
@@ -398,16 +402,38 @@ export function useAppShell() {
)
}
function openRequestDetail(request) {
function buildDocumentDetailQuery(options = {}) {
const nextQuery = { ...route.query }
const returnTo = String(options.returnTo || '').trim()
if (returnTo === 'workbench') {
nextQuery.returnTo = 'workbench'
} else {
delete nextQuery.returnTo
}
return nextQuery
}
function buildDocumentReturnQuery() {
const { returnTo, ...nextQuery } = route.query
return nextQuery
}
function openRequestDetail(request, options = {}) {
selectedRequestSnapshot.value = request || null
router.push({
name: 'app-document-detail',
params: { requestId: request.claimId || request.id }
params: { requestId: request.claimId || request.id },
query: buildDocumentDetailQuery(options)
})
}
function closeRequestDetail() {
router.push({ name: 'app-documents' })
if (detailReturnTarget.value === 'workbench') {
router.push({ name: 'app-workbench' })
return
}
router.push({ name: 'app-documents', query: buildDocumentReturnQuery() })
}
async function handleRequestUpdated() {
@@ -423,7 +449,7 @@ export function useAppShell() {
await reloadRequests()
selectedRequestSnapshot.value = null
router.push({ name: 'app-documents' })
router.push({ name: 'app-documents', query: buildDocumentReturnQuery() })
}
return {
@@ -465,6 +491,7 @@ export function useAppShell() {
smartEntryRevealToken,
smartEntrySessionId,
detailAlerts,
detailBackLabel,
toast,
topBarView
}

View File

@@ -247,6 +247,7 @@ function buildProgressItems(ownedRequests) {
id: requestId,
requestId,
title,
expenseTypeLabel: resolveExpenseCategory(request),
amount: formatCurrency(request?.amount),
status: normalizeText(request?.approvalStatus || currentStep?.label) || '处理中',
statusTone: resolveProgressStatusTone(normalizeText(request?.approvalKey)),

View File

@@ -132,7 +132,7 @@
<TravelRequestDetailView
v-else-if="activeView === 'documents' && detailMode && selectedRequest"
:request="selectedRequest"
back-label="返回单据中心"
:back-label="detailBackLabel"
@back-to-requests="closeRequestDetail"
@open-assistant="openSmartEntry"
@request-updated="handleRequestUpdated"
@@ -279,6 +279,7 @@ const {
closeSmartEntry,
customRange,
detailAlerts,
detailBackLabel,
detailMode,
documentCenterRefreshToken,
filteredRequests,
@@ -370,7 +371,14 @@ function openWorkbenchDocument(payload = {}) {
|| String(item.id || '').trim() === requestId
|| String(item.claimNo || '').trim() === requestId
))
openRequestDetail(request || payload)
const returnTo = (
String(payload.returnTo || '').trim() === 'workbench'
|| String(payload.source || '').trim() === 'workbench'
|| activeView.value === 'workbench'
)
? 'workbench'
: ''
openRequestDetail(request || payload, { returnTo })
}
function handleLogout() {

View File

@@ -145,6 +145,11 @@ test('workbench submit shows intent recognition feedback before assistant opens'
test('workbench progress rows show update time first', () => {
assert.match(workbench, /class="progress-time"/)
assert.match(workbench, /class="progress-type"/)
assert.match(workbench, /费用类型/)
assert.match(workbench, /\{\{ item\.expenseTypeLabel \|\| '其他费用' \}\}/)
assert.match(workbench, /source:\s*'workbench'/)
assert.match(workbench, /returnTo:\s*'workbench'/)
assert.match(workbench, /has-long-duration-divider/)
assert.match(workbench, /hasLongDurationDivider/)
assert.match(workbench, /const LONG_DURATION_DAYS = 10/)
@@ -153,13 +158,15 @@ test('workbench progress rows show update time first', () => {
assert.match(workbench, /displayTime: formatProgressTime\(item\?\.updatedAt\)/)
assert.match(workbench, /function formatProgressTime\(value\)/)
assert.doesNotMatch(workbench, />全部进度/)
assert.match(workbenchStyles, /\.progress-row\s*\{[\s\S]*grid-template-columns:\s*minmax\(78px,\s*0\.44fr\)/)
assert.match(workbenchStyles, /\.progress-row\s*\{[\s\S]*grid-template-columns:\s*minmax\(78px,\s*0\.42fr\)[\s\S]*minmax\(84px,\s*0\.42fr\)/)
assert.match(workbenchStyles, /\.progress-type strong\s*\{[\s\S]*var\(--workbench-primary-active\)/)
assert.match(workbenchStyles, /\.progress-row\.has-long-duration-divider::before\s*\{[\s\S]*content:\s*"10日以上"/)
assert.match(workbenchStyles, /\.progress-row\.has-long-duration-divider::before\s*\{[\s\S]*left:\s*50%;[\s\S]*transform:\s*translateX\(-50%\);/)
assert.match(workbenchStyles, /\.progress-row\.has-long-duration-divider::before\s*\{[\s\S]*color:\s*var\(--theme-primary-active\);/)
assert.match(workbenchStyles, /\.progress-row\.has-long-duration-divider::after\s*\{[\s\S]*rgba\(var\(--theme-primary-rgb/)
assert.match(workbenchStyles, /\.progress-time\s*\{[\s\S]*color:\s*var\(--workbench-muted\);/)
assert.match(workbenchResponsiveStyles, /grid-template-areas:[\s\S]*"time identity result"[\s\S]*"steps steps steps"/)
assert.match(workbenchResponsiveStyles, /grid-template-areas:[\s\S]*"time identity result"[\s\S]*"type type type"[\s\S]*"steps steps steps"/)
assert.match(workbenchResponsiveStyles, /\.progress-type\s*\{[\s\S]*grid-area:\s*type;/)
})
test('workbench expense stats detail opens a local modal instead of the assistant', () => {

View File

@@ -0,0 +1,31 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
const appShell = readFileSync(
fileURLToPath(new URL('../src/views/AppShellRouteView.vue', import.meta.url)),
'utf8'
)
const appShellComposable = readFileSync(
fileURLToPath(new URL('../src/composables/useAppShell.js', import.meta.url)),
'utf8'
)
const workbench = readFileSync(
fileURLToPath(new URL('../src/components/business/PersonalWorkbench.vue', import.meta.url)),
'utf8'
)
test('workbench document detail keeps workbench as the return target', () => {
assert.match(workbench, /source:\s*'workbench'/)
assert.match(workbench, /returnTo:\s*'workbench'/)
assert.match(appShell, /:back-label="detailBackLabel"/)
assert.match(appShell, /String\(payload\.returnTo \|\| ''\)\.trim\(\) === 'workbench'/)
assert.match(appShell, /String\(payload\.source \|\| ''\)\.trim\(\) === 'workbench'/)
assert.match(appShell, /openRequestDetail\(request \|\| payload,\s*\{ returnTo \}\)/)
assert.match(appShellComposable, /const detailReturnTarget = computed/)
assert.match(appShellComposable, /detailReturnTarget\.value === 'workbench' \? '返回首页' : '返回单据中心'/)
assert.match(appShellComposable, /nextQuery\.returnTo = 'workbench'/)
assert.match(appShellComposable, /router\.push\(\{ name: 'app-workbench' \}\)/)
assert.match(appShellComposable, /router\.push\(\{ name: 'app-documents', query: buildDocumentReturnQuery\(\) \}\)/)
})

View File

@@ -75,6 +75,7 @@ test('workbench summary builds real user notifications and progress from request
summary.progressItems[0].steps.map((step) => step.label),
['创建单据', '待提交', '直属领导审批', '财务审批']
)
assert.equal(summary.progressItems[0].expenseTypeLabel, '差旅交通')
assert.equal(summary.notifications.length, 1)
assert.equal(summary.unreadNotificationCount, 1)
assert.equal(summary.expenseStatsDetail.distributionRows[0].label, '差旅交通')