适配来源消息只读任务展示

This commit is contained in:
andy
2026-07-10 19:38:13 +08:00
parent bbd4ea22df
commit 1efa819599
11 changed files with 324 additions and 14 deletions

View File

@@ -25,6 +25,7 @@
</span>
<div class="task-links">
<RouterLink
v-if="!isSourceMessageOnlyDetail"
class="task-link"
:to="`/reservation/orders/${detail.order_id}`"
>
@@ -74,6 +75,50 @@
</dl>
</section>
<section
v-if="isSourceMessageOnlyDetail"
class="source-only-result"
>
<header>
<h2>{{ t('task.sourceOnlyResult') }}</h2>
<p>{{ t('task.sourceOnlyReadonly') }}</p>
</header>
<dl v-if="sourceMessageOnlyResult">
<div>
<dt>{{ t('task.entryResultCode') }}</dt>
<dd class="th-code">
{{ sourceMessageOnlyResult.entry_result_code || '-' }}
</dd>
</div>
<div>
<dt>{{ t('task.entryResultMeaning') }}</dt>
<dd>{{ sourceMessageOnlyResult.entry_result_meaning || '-' }}</dd>
</div>
<div>
<dt>{{ t('task.entryResultDescription') }}</dt>
<dd>{{ sourceMessageOnlyResult.entry_result_description || '-' }}</dd>
</div>
<div>
<dt>{{ t('task.entryResultSourceMessageId') }}</dt>
<dd class="th-code">
{{ sourceMessageOnlyResult.entry_result_source_message_id || '-' }}
</dd>
</div>
<div class="source-only-result__raw">
<dt>{{ t('task.rawAnswer') }}</dt>
<dd>
<pre>{{ sourceMessageOnlyResult.raw_answer || '-' }}</pre>
</dd>
</div>
</dl>
<p
v-else
class="source-only-result__empty"
>
{{ t('task.noSourceOnlyResult') }}
</p>
</section>
<div
v-if="detail.availability.blocked"
class="task-alert"
@@ -105,7 +150,7 @@
<div class="task-detail-body">
<div class="task-card-main">
<ReservationManualReviewConversionPanel
v-if="isManualReviewTask"
v-if="isManualReviewTask && !isSourceMessageOnlyDetail"
:busy="mutationBusy !== null"
@submit="submitManualReviewConversion"
/>
@@ -152,6 +197,7 @@
<aside class="task-side-stack">
<ReservationOperaPanel
v-if="!isSourceMessageOnlyDetail"
:task-id="detail.task_id"
:operations="detail.opera_operations"
:executable="detail.availability.executable && canExecuteOpera"
@@ -198,7 +244,11 @@ import {
validateReservationFieldValues,
type ReservationFieldValidationErrors,
} from '@/utils/reservationFieldRules'
import { formatReservationMaybeStableReason, formatReservationTaskCard } from '@/utils/reservationDisplay'
import {
formatReservationMaybeStableReason,
formatReservationTaskCard,
isReservationSourceMessageOnlyTask,
} from '@/utils/reservationDisplay'
import { formatReservationDateTime } from '@/utils/reservationFormat'
import { useAuthStore } from '@/stores/authStore'
@@ -219,9 +269,17 @@ const validationErrors = ref<ReservationFieldValidationErrors>({})
const validationSummaryActive = ref(false)
const mutationBusy = ref<'save' | 'confirm' | 'manual-convert' | null>(null)
const canEditTask = computed(() => authStore.hasPermission('RESERVATION_TASK_EDIT'))
const canConfirmTask = computed(() => authStore.hasPermission('RESERVATION_TASK_CONFIRM'))
const canExecuteOpera = computed(() => authStore.hasPermission('RESERVATION_OPERA_SIM_EXECUTE'))
const isSourceMessageOnlyDetail = computed(() =>
isReservationSourceMessageOnlyTask(detail.value?.system_task_type, detail.value?.task_card_type),
)
const sourceMessageOnlyResult = computed(() => detail.value?.source_message_only_result ?? null)
const canEditTask = computed(() => authStore.hasPermission('RESERVATION_TASK_EDIT') && !isSourceMessageOnlyDetail.value)
const canConfirmTask = computed(
() => authStore.hasPermission('RESERVATION_TASK_CONFIRM') && !isSourceMessageOnlyDetail.value,
)
const canExecuteOpera = computed(
() => authStore.hasPermission('RESERVATION_OPERA_SIM_EXECUTE') && !isSourceMessageOnlyDetail.value,
)
const canViewAudit = computed(() => authStore.hasPermission('RESERVATION_AUDIT_READ'))
const canSave = computed(() => canEditTask.value && Boolean(detail.value?.availability.editable))
const canConfirm = computed(() => canConfirmTask.value && Boolean(detail.value?.availability.confirmable))
@@ -503,35 +561,81 @@ function replaceOperation(operation: ReservationOperaOperationResult): void {
padding: 16px;
}
.source-only-result {
display: grid;
gap: 12px;
border: 1px solid var(--th-color-blue-100);
border-radius: var(--th-radius-md);
background: var(--th-color-blue-100);
padding: 16px;
}
.task-evidence h2,
.task-evidence dl {
.task-evidence dl,
.source-only-result h2,
.source-only-result p,
.source-only-result dl {
margin: 0;
}
.task-evidence h2 {
.task-evidence h2,
.source-only-result h2 {
color: var(--th-color-slate-900);
font-size: 15px;
}
.source-only-result header {
display: grid;
gap: 6px;
}
.source-only-result header p,
.source-only-result__empty {
color: var(--th-color-slate-500);
font-size: 13px;
line-height: 1.6;
}
.task-evidence dl {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.source-only-result dl {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.task-evidence dt {
color: var(--th-color-slate-500);
font-size: 12px;
font-weight: 700;
}
.task-evidence dd {
.task-evidence dd,
.source-only-result dd {
margin: 5px 0 0;
color: var(--th-color-slate-900);
font-size: 13px;
overflow-wrap: anywhere;
}
.source-only-result__raw {
grid-column: 1 / -1;
}
.source-only-result pre {
max-height: 180px;
overflow: auto;
border: 1px solid var(--th-color-slate-200);
border-radius: var(--th-radius-sm);
background: var(--th-color-white);
padding: 10px;
white-space: pre-wrap;
}
.task-alert,
.task-notice {
display: flex;
@@ -617,7 +721,8 @@ function replaceOperation(operation: ReservationOperaOperationResult): void {
}
@media (max-width: 640px) {
.task-evidence dl {
.task-evidence dl,
.source-only-result dl {
grid-template-columns: 1fr;
}
}

View File

@@ -268,6 +268,9 @@ export default {
viewTask: 'View task',
viewOrder: 'View order',
viewConversation: 'View email conversation',
legacyMessageNotification: 'Message notification (legacy)',
sourceOnlyOrderLabel: 'Source message only',
hiddenTechnicalOrder: 'No visible order',
columns: {
task: 'Task',
order: 'Order',
@@ -312,6 +315,15 @@ export default {
sourceSender: 'Sender',
sourceReceivedAt: 'Received at',
sourceSummaryPending: 'Source email summary fields are pending backend support',
sourceOnlyResult: 'Source message entry result',
sourceOnlyReadonly: 'This task only shows the source message entry result. It does not enter order handling, field editing, or OPERA operations.',
sourceOnlyNoOpera: 'This task type does not involve OPERA operations.',
entryResultCode: 'Entry result code',
entryResultMeaning: 'Result meaning',
entryResultDescription: 'Result description',
entryResultSourceMessageId: 'Result source message ID',
rawAnswer: 'SuperAgent raw answer',
noSourceOnlyResult: 'No source message entry result is available.',
manualConversion: {
title: 'Manual conversion',
description: 'Convert a Fallback / manual review task into a processable reservation task.',
@@ -424,6 +436,8 @@ export default {
TRACE_NOTE: 'Trace / notes',
TA_RECORD: 'TA record',
MESSAGE_NOTIFICATION: 'Message notification',
INFORMATIONAL_MESSAGE: 'Message notification (legacy)',
SOURCE_MESSAGE_ONLY: 'Source message only',
MANUAL_REVIEW: 'Manual review',
FALLBACK_REVIEW: 'Manual review',
},
@@ -438,6 +452,8 @@ export default {
NEW_GROUP_BLOCK: 'New group block',
NEW_FIT_RESERVATION: 'New FIT reservation',
MANUAL_REVIEW: 'Manual review',
S000: 'Pure informational email',
S999: 'Business material package unavailable',
},
readonlyReason: {
PROCESSABLE: 'Processable',

View File

@@ -268,6 +268,9 @@ export default {
viewTask: 'ดูงาน',
viewOrder: 'ดูออเดอร์',
viewConversation: 'ดูเธรดอีเมล',
legacyMessageNotification: 'การแจ้งเตือนข้อความ (เดิม)',
sourceOnlyOrderLabel: 'ข้อความต้นทางแบบอ่านอย่างเดียว',
hiddenTechnicalOrder: 'ไม่มีออเดอร์ที่แสดงได้',
columns: {
task: 'งาน',
order: 'ออเดอร์',
@@ -312,6 +315,15 @@ export default {
sourceSender: 'ผู้ส่ง',
sourceReceivedAt: 'เวลารับ',
sourceSummaryPending: 'ข้อมูลสรุปอีเมลต้นทางยังรอหลังบ้านเพิ่มเติม',
sourceOnlyResult: 'ผลลัพธ์ทางเข้าของข้อความต้นทาง',
sourceOnlyReadonly: 'งานนี้ใช้แสดงผลลัพธ์ทางเข้าของข้อความต้นทางเท่านั้น ไม่เข้าสู่การจัดการออเดอร์ การแก้ไขข้อมูล หรือ OPERA',
sourceOnlyNoOpera: 'งานประเภทนี้ไม่เกี่ยวข้องกับ OPERA',
entryResultCode: 'รหัสผลลัพธ์ทางเข้า',
entryResultMeaning: 'ความหมายผลลัพธ์',
entryResultDescription: 'คำอธิบายผลลัพธ์',
entryResultSourceMessageId: 'รหัสข้อความต้นทางของผลลัพธ์',
rawAnswer: 'คำตอบดิบจาก SuperAgent',
noSourceOnlyResult: 'ยังไม่มีผลลัพธ์ทางเข้าของข้อความต้นทาง',
manualConversion: {
title: 'แปลงงานด้วยคน',
description: 'แปลงงาน Fallback / งานตรวจสอบด้วยคนให้เป็นงานการจองที่ประมวลผลได้',
@@ -424,6 +436,8 @@ export default {
TRACE_NOTE: 'หมายเหตุติดตาม (Trace)',
TA_RECORD: 'ข้อมูลบริษัททัวร์',
MESSAGE_NOTIFICATION: 'แจ้งเตือนข้อความ',
INFORMATIONAL_MESSAGE: 'แจ้งเตือนข้อความ (เดิม)',
SOURCE_MESSAGE_ONLY: 'ข้อความต้นทางแบบอ่านอย่างเดียว',
MANUAL_REVIEW: 'ตรวจสอบด้วยคน',
FALLBACK_REVIEW: 'ตรวจสอบด้วยคน',
},
@@ -438,6 +452,8 @@ export default {
NEW_GROUP_BLOCK: 'บล็อกกรุ๊ปใหม่',
NEW_FIT_RESERVATION: 'จองรายบุคคลใหม่',
MANUAL_REVIEW: 'ตรวจสอบด้วยคน',
S000: 'อีเมลข้อมูลทั่วไป',
S999: 'ไม่สามารถสร้างชุดข้อมูลธุรกิจได้',
},
readonlyReason: {
PROCESSABLE: 'ประมวลผลได้',

View File

@@ -268,6 +268,9 @@ export default {
viewTask: '查看任务',
viewOrder: '查看订单',
viewConversation: '查看邮件会话',
legacyMessageNotification: '消息通知(历史 Message Notification',
sourceOnlyOrderLabel: '来源消息只读',
hiddenTechnicalOrder: '不关联可见订单',
columns: {
task: '任务',
order: '订单',
@@ -312,6 +315,15 @@ export default {
sourceSender: '发件人',
sourceReceivedAt: '接收时间',
sourceSummaryPending: '来源邮件摘要字段待后端补充',
sourceOnlyResult: '来源消息入口结果',
sourceOnlyReadonly: '该任务仅用于展示来源消息入口结果,不进入订单处理、字段编辑或 OPERA 操作。',
sourceOnlyNoOpera: '该类任务不涉及 OPERA 操作。',
entryResultCode: '入口结果码',
entryResultMeaning: '结果含义',
entryResultDescription: '结果说明',
entryResultSourceMessageId: '结果来源消息编号',
rawAnswer: 'SuperAgent 原始回答',
noSourceOnlyResult: '暂无来源消息入口结果。',
manualConversion: {
title: '人工转换',
description: '将人工复核任务转换为可处理的预订任务。',
@@ -424,6 +436,8 @@ export default {
TRACE_NOTE: '追踪备注Trace',
TA_RECORD: '旅行社记录',
MESSAGE_NOTIFICATION: '消息通知',
INFORMATIONAL_MESSAGE: '消息通知(历史 Message Notification',
SOURCE_MESSAGE_ONLY: '来源消息只读',
MANUAL_REVIEW: '人工复核',
FALLBACK_REVIEW: '人工复核',
},
@@ -438,6 +452,8 @@ export default {
NEW_GROUP_BLOCK: '新团队预留',
NEW_FIT_RESERVATION: '新散客预订',
MANUAL_REVIEW: '人工复核',
S000: '纯信息类邮件',
S999: '无法形成业务素材包',
},
readonlyReason: {
PROCESSABLE: '可处理',

View File

@@ -66,6 +66,35 @@ function createTaskDetail(overrides: Partial<ReservationTaskDetailResult> = {}):
}
}
function createSourceMessageOnlyTaskDetail(
overrides: Partial<ReservationTaskDetailResult> = {},
): ReservationTaskDetailResult {
return createTaskDetail({
system_task_type: 'SOURCE_MESSAGE_ONLY',
task_card_type: 'SOURCE_MESSAGE_ONLY',
task_status: 'COMPLETED',
fields: [],
opera_operations: [],
availability: {
blocked: false,
read_only: true,
editable: false,
confirmable: false,
executable: false,
blocked_by_task_id: null,
blocked_reason: null,
},
source_message_only_result: {
entry_result_code: 'S000',
entry_result_meaning: 'PURE_INFORMATION',
entry_result_description: '纯信息类邮件',
entry_result_source_message_id: '30001',
raw_answer: 'S000,30001',
},
...overrides,
})
}
function createMutationResult(taskStatus = 'PENDING_CONFIRM'): ReservationTaskPayloadMutationResult {
return {
task_id: '10001',
@@ -428,6 +457,29 @@ describe('ReservationTaskDetailPanel', () => {
expect(wrapper.text()).not.toContain('接口待补')
})
it('renders source-message-only entry result without editable or OPERA actions', async () => {
vi.mocked(service.fetchReservationTaskDetail).mockResolvedValue(createSourceMessageOnlyTaskDetail())
const wrapper = await mountPanel()
expect(wrapper.text()).toContain('来源消息只读')
expect(wrapper.text()).toContain('来源消息入口结果')
expect(wrapper.text()).toContain('该任务仅用于展示来源消息入口结果')
expect(wrapper.text()).toContain('S000')
expect(wrapper.text()).toContain('PURE_INFORMATION')
expect(wrapper.text()).toContain('纯信息类邮件')
expect(wrapper.text()).toContain('S000,30001')
expect(wrapper.text()).toContain('查看邮件会话')
expect(wrapper.text()).not.toContain('查看订单')
expect(wrapper.text()).not.toContain('保存草稿')
expect(wrapper.text()).not.toContain('确认任务')
expect(wrapper.text()).not.toContain('人工转换')
expect(wrapper.text()).not.toContain('OPERA 模拟操作')
expect(wrapper.text()).not.toContain('确认任务后生成 OPERA 模拟操作')
expect(wrapper.find('button[title="执行"]').exists()).toBe(false)
expect(wrapper.find('button[title="重试"]').exists()).toBe(false)
})
it('converts a manual review task to a new booking without a target order', async () => {
vi.mocked(service.fetchReservationTaskDetail)
.mockResolvedValueOnce(

View File

@@ -93,6 +93,41 @@ function createTaskListResult(
}
}
function createSourceMessageOnlyTaskListResult(taskSubtype: 'S000' | 'S999' = 'S000') {
return {
items: [
{
task_id: taskSubtype === 'S000' ? '19990' : '19999',
order_id: taskSubtype === 'S000' ? '29990' : '29999',
hotel_id: 'HOTEL-TEST',
display_order_key: null,
temporary_order_no: null,
task_type: 'SOURCE_MESSAGE_ONLY',
task_subtype: taskSubtype,
task_status: 'COMPLETED',
card_name: 'SOURCE_MESSAGE_ONLY',
queue_sequence: null,
queue_participation: false,
can_process: false,
readonly_reason_code: 'QUEUE_EXCLUDED',
source_message_id: taskSubtype === 'S000' ? '39990' : '39999',
source_subject: taskSubtype === 'S000' ? 'FYI only' : 'Unclear email',
source_sender_summary: 'guest@example.test',
source_received_at: '2026-07-08T03:00:00Z',
external_conversation_id: 'thread-source-only',
conversation_message_count: 1,
created_at: '2026-07-08T03:00:00Z',
updated_at: '2026-07-08T03:10:00Z',
},
],
page: {
page_num: 1,
page_size: 20,
total: 1,
},
}
}
function createOrderDetailResult(displayName = 'GRP-001') {
return {
order: {
@@ -440,6 +475,46 @@ describe('reservation P0 views', () => {
expect(wrapper.text()).not.toContain('NEW_BOOKING / NEW_BOOKING')
})
it('filters source-message-only tasks with S000 and S999 subtypes', async () => {
vi.mocked(service.fetchReservationTaskList).mockResolvedValue(createSourceMessageOnlyTaskListResult('S000'))
const wrapper = await mountWithPlugins(ReservationTaskListView)
await vi.dynamicImportSettled()
expect(wrapper.find('select[name="task_type"]').text()).toContain('来源消息只读')
expect(wrapper.find('select[name="task_type"]').text()).toContain('历史 Message Notification')
expect(wrapper.find('select[name="task_subtype"]').text()).toContain('纯信息类邮件')
expect(wrapper.find('select[name="task_subtype"]').text()).toContain('无法形成业务素材包')
await wrapper.find('select[name="task_type"]').setValue('SOURCE_MESSAGE_ONLY')
await vi.dynamicImportSettled()
await wrapper.find('select[name="task_subtype"]').setValue('S999')
await vi.dynamicImportSettled()
expect(service.fetchReservationTaskList).toHaveBeenLastCalledWith(
expect.objectContaining({
task_type: 'SOURCE_MESSAGE_ONLY',
task_subtype: 'S999',
page_num: 1,
}),
)
})
it('renders source-message-only task rows without a visible order action', async () => {
vi.mocked(service.fetchReservationTaskList).mockResolvedValue(createSourceMessageOnlyTaskListResult('S000'))
const wrapper = await mountWithPlugins(ReservationTaskListView)
await vi.dynamicImportSettled()
expect(wrapper.text()).toContain('来源消息只读')
expect(wrapper.text()).toContain('纯信息类邮件')
expect(wrapper.text()).toContain('FYI only')
expect(wrapper.text()).toContain('查看任务')
expect(wrapper.text()).toContain('查看邮件会话')
expect(wrapper.text()).not.toContain('查看订单')
expect(wrapper.text()).not.toContain('29990')
})
it('filters and paginates task list with supported backend fields', async () => {
vi.mocked(service.fetchReservationTaskList)
.mockResolvedValueOnce(createTaskListResult('GRP-001', 'Booking Request', { page_num: 1, page_size: 20, total: 45 }))

View File

@@ -22,6 +22,7 @@ export type ReservationTaskCardType =
| 'TRACE_NOTE'
| 'TA_RECORD'
| 'MESSAGE_NOTIFICATION'
| 'SOURCE_MESSAGE_ONLY'
| 'MANUAL_REVIEW'
| 'FALLBACK_REVIEW'
@@ -311,6 +312,15 @@ export interface ReservationTaskDetailResult {
availability: ReservationTaskAvailabilityResult
fields: ReservationTaskFieldResult[]
opera_operations: ReservationOperaOperationResult[]
source_message_only_result?: ReservationSourceMessageOnlyResult | null
}
export interface ReservationSourceMessageOnlyResult {
entry_result_code: string | null
entry_result_meaning: string | null
entry_result_description: string | null
entry_result_source_message_id: string | null
raw_answer: string | null
}
export interface ReservationTaskPayloadMutationRequest {

View File

@@ -22,6 +22,10 @@ export function formatReservationTaskTypeSummary(
return `${typeLabel} / ${translateStableCode(t, 'taskSubtype', taskSubtype, 'common.unknownTaskSubtype')}`
}
export function isReservationSourceMessageOnlyTask(...codes: Array<string | null | undefined>): boolean {
return codes.some((code) => normalizeStableCode(code) === 'SOURCE_MESSAGE_ONLY')
}
export function formatReservationReadonlyReason(
t: ReservationTranslator,
reasonCode: string | null | undefined,

View File

@@ -43,7 +43,8 @@
<option value="UPDATE_BOOKING">{{ t('cardType.UPDATE_BOOKING') }}</option>
<option value="CANCEL_BOOKING">{{ t('cardType.CANCEL_BOOKING') }}</option>
<option value="MANUAL_REVIEW">{{ t('cardType.MANUAL_REVIEW') }}</option>
<option value="INFORMATIONAL_MESSAGE">{{ t('cardType.MESSAGE_NOTIFICATION') }}</option>
<option value="SOURCE_MESSAGE_ONLY">{{ t('cardType.SOURCE_MESSAGE_ONLY') }}</option>
<option value="INFORMATIONAL_MESSAGE">{{ t('taskList.legacyMessageNotification') }}</option>
</select>
</label>
<label>
@@ -64,6 +65,8 @@
<option value="NEW_GROUP_BLOCK">{{ t('taskSubtype.NEW_GROUP_BLOCK') }}</option>
<option value="NEW_FIT_RESERVATION">{{ t('taskSubtype.NEW_FIT_RESERVATION') }}</option>
<option value="MANUAL_REVIEW">{{ t('taskSubtype.MANUAL_REVIEW') }}</option>
<option value="S000">{{ t('taskSubtype.S000') }}</option>
<option value="S999">{{ t('taskSubtype.S999') }}</option>
</select>
</label>
<label>
@@ -168,8 +171,14 @@
<small>{{ formatReservationTaskTypeSummary(t, task.task_type, task.task_subtype) }}</small>
</td>
<td>
<strong>{{ task.display_order_key ?? task.temporary_order_no ?? task.order_id }}</strong>
<small class="th-code">{{ task.order_id }}</small>
<template v-if="isSourceMessageOnlyTask(task.task_type)">
<strong>{{ t('taskList.sourceOnlyOrderLabel') }}</strong>
<small>{{ t('taskList.hiddenTechnicalOrder') }}</small>
</template>
<template v-else>
<strong>{{ task.display_order_key ?? task.temporary_order_no ?? task.order_id }}</strong>
<small class="th-code">{{ task.order_id }}</small>
</template>
</td>
<td>{{ formatReservationTaskCard(t, task.task_type, task.card_name) }}</td>
<td><ReservationStatusBadge :status="task.task_status" /></td>
@@ -190,6 +199,7 @@
{{ t('taskList.viewTask') }}
</RouterLink>
<RouterLink
v-if="!isSourceMessageOnlyTask(task.task_type)"
class="row-action"
:to="`/reservation/orders/${task.order_id}`"
>
@@ -264,6 +274,7 @@ import {
formatReservationReadonlyReason,
formatReservationTaskCard,
formatReservationTaskTypeSummary,
isReservationSourceMessageOnlyTask,
} from '@/utils/reservationDisplay'
const { t } = useI18n()
@@ -357,6 +368,10 @@ function goToPage(page: number): void {
filters.page_num = Math.min(Math.max(page, 1), totalPages.value)
}
function isSourceMessageOnlyTask(taskType: string | null | undefined): boolean {
return isReservationSourceMessageOnlyTask(taskType)
}
function createPage(pageNum: number, pageSize: number, total: number): ReservationPageResult {
return {
page_num: pageNum,