接入前端真实接口并标记邮件HTML清洗
This commit is contained in:
@@ -70,7 +70,42 @@
|
||||
|
||||
<section class="th-section source-gap">
|
||||
<h2>{{ t('task.evidence') }}</h2>
|
||||
<p>{{ t('order.sourceSummaryPending') }}</p>
|
||||
<template v-if="activeTask">
|
||||
<dl>
|
||||
<div>
|
||||
<dt>{{ t('task.sourceSubject') }}</dt>
|
||||
<dd>{{ activeTask.source_subject ?? '-' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('task.sourceSender') }}</dt>
|
||||
<dd>{{ activeTask.source_sender_summary ?? '-' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('task.sourceReceivedAt') }}</dt>
|
||||
<dd>{{ formatReservationDateTime(activeTask.source_received_at) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('order.conversationMessageCount') }}</dt>
|
||||
<dd>{{ activeTask.conversation_message_count ?? '-' }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<RouterLink
|
||||
v-if="activeTask.source_message_id"
|
||||
class="source-link"
|
||||
:to="`/reservation/source-messages/${activeTask.source_message_id}/conversation`"
|
||||
>
|
||||
{{ t('task.viewConversation') }}
|
||||
</RouterLink>
|
||||
<span
|
||||
v-else
|
||||
class="source-link source-link--disabled"
|
||||
>
|
||||
{{ t('common.endpointMissing') }}
|
||||
</span>
|
||||
</template>
|
||||
<p v-else>
|
||||
{{ t('common.noData') }}
|
||||
</p>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
@@ -91,7 +126,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ReservationStatusBadge from '@/components/reservation/ReservationStatusBadge.vue'
|
||||
@@ -109,6 +144,7 @@ const detail = ref<ReservationOrderDetailResult | null>(null)
|
||||
const activeTaskId = ref('')
|
||||
|
||||
const orderId = computed(() => String(route.params.orderId ?? ''))
|
||||
const activeTask = computed(() => detail.value?.tasks.find((task) => task.task_id === activeTaskId.value) ?? null)
|
||||
|
||||
watch(
|
||||
orderId,
|
||||
@@ -231,7 +267,8 @@ async function loadOrder(nextOrderId: string): Promise<void> {
|
||||
}
|
||||
|
||||
.source-gap h2,
|
||||
.source-gap p {
|
||||
.source-gap p,
|
||||
.source-gap dl {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -246,6 +283,36 @@ async function loadOrder(nextOrderId: string): Promise<void> {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.source-gap dl {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.source-gap dt {
|
||||
color: var(--th-color-slate-500);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.source-gap dd {
|
||||
margin: 4px 0 0;
|
||||
color: var(--th-color-slate-900);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.source-link {
|
||||
color: var(--th-color-blue-600);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.source-link--disabled {
|
||||
color: var(--th-color-slate-500);
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.order-summary dl {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
|
||||
@@ -12,6 +12,13 @@
|
||||
<h2 class="th-section-title">
|
||||
{{ t('workbench.filters') }}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="text-button"
|
||||
@click="resetFilters"
|
||||
>
|
||||
{{ t('workbench.reset') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="filter-grid">
|
||||
<label>
|
||||
@@ -20,11 +27,15 @@
|
||||
v-model="filters.keyword"
|
||||
type="search"
|
||||
placeholder="GRP-001 / CNF123456"
|
||||
@input="resetToFirstPage"
|
||||
>
|
||||
</label>
|
||||
<label>
|
||||
<span>{{ t('workbench.orderStatus') }}</span>
|
||||
<select v-model="filters.order_status">
|
||||
<select
|
||||
v-model="filters.order_status"
|
||||
@change="resetToFirstPage"
|
||||
>
|
||||
<option value="">All</option>
|
||||
<option value="TEMPORARY">{{ t('status.TEMPORARY') }}</option>
|
||||
<option value="ACTIVE">{{ t('status.ACTIVE') }}</option>
|
||||
@@ -41,25 +52,13 @@
|
||||
{{ t('orderList.tableTitle') }}
|
||||
</h2>
|
||||
<p class="th-muted">
|
||||
{{ pendingMessage || t('common.noData') }}
|
||||
{{ t('workbench.totalPrefix') }} {{ total }} {{ t('orderList.totalSuffix') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="pendingMessage"
|
||||
class="empty-state"
|
||||
>
|
||||
<i
|
||||
class="pi pi-info-circle"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<strong>{{ t('common.endpointPending') }}</strong>
|
||||
<span>{{ pendingMessage }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="loading"
|
||||
v-if="loading"
|
||||
class="empty-state"
|
||||
>
|
||||
{{ t('common.loading') }}
|
||||
@@ -78,22 +77,118 @@
|
||||
>
|
||||
{{ t('common.noData') }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="table-wrap"
|
||||
>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t('orderList.columns.order') }}</th>
|
||||
<th>{{ t('orderList.columns.status') }}</th>
|
||||
<th>{{ t('orderList.columns.openTasks') }}</th>
|
||||
<th>{{ t('orderList.columns.createdAt') }}</th>
|
||||
<th>{{ t('workbench.columns.updatedAt') }}</th>
|
||||
<th>{{ t('workbench.columns.operation') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="order in orders"
|
||||
:key="order.order_id"
|
||||
>
|
||||
<td>
|
||||
<strong>{{ formatOrderKey(order) }}</strong>
|
||||
<small class="th-code">{{ order.order_id }}</small>
|
||||
</td>
|
||||
<td><ReservationStatusBadge :status="order.order_status" /></td>
|
||||
<td>{{ order.open_task_count ?? 0 }}</td>
|
||||
<td>{{ formatReservationDateTime(order.created_at) }}</td>
|
||||
<td>{{ formatReservationDateTime(order.updated_at) }}</td>
|
||||
<td>
|
||||
<div class="row-actions">
|
||||
<RouterLink
|
||||
class="row-action"
|
||||
:to="`/reservation/orders/${order.order_id}`"
|
||||
>
|
||||
{{ t('orderList.viewDetail') }}
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
v-if="order.next_processable_task_id"
|
||||
class="row-action"
|
||||
:to="`/reservation/tasks/${order.next_processable_task_id}`"
|
||||
>
|
||||
{{ t('orderList.continueTask') }}
|
||||
</RouterLink>
|
||||
<span
|
||||
v-else
|
||||
class="row-action row-action--muted"
|
||||
>
|
||||
{{ t('orderList.noOpenTask') }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<footer class="pagination-bar">
|
||||
<label class="page-size-control">
|
||||
<span>{{ t('common.pageSize') }}</span>
|
||||
<select
|
||||
v-model.number="filters.page_size"
|
||||
@change="resetToFirstPage"
|
||||
>
|
||||
<option :value="10">10</option>
|
||||
<option :value="20">20</option>
|
||||
<option :value="50">50</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="pagination-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="secondary-button"
|
||||
:disabled="currentPage <= 1 || loading"
|
||||
@click="goToPage(currentPage - 1)"
|
||||
>
|
||||
{{ t('common.previousPage') }}
|
||||
</button>
|
||||
<span>{{ t('common.pageStatus', { current: currentPage, total: totalPages }) }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="secondary-button"
|
||||
:disabled="currentPage >= totalPages || loading"
|
||||
@click="goToPage(currentPage + 1)"
|
||||
>
|
||||
{{ t('common.nextPage') }}
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { EndpointPendingError, fetchReservationOrders } from '@/services/reservationService'
|
||||
import type { ReservationOrderListFilters, ReservationOrderListItem } from '@/types/reservation'
|
||||
import ReservationStatusBadge from '@/components/reservation/ReservationStatusBadge.vue'
|
||||
import { fetchReservationOrders } from '@/services/reservationService'
|
||||
import type { ReservationOrderListFilters, ReservationOrderListItem, ReservationPageResult } from '@/types/reservation'
|
||||
import { formatReservationDateTime } from '@/utils/reservationFormat'
|
||||
|
||||
const { t } = useI18n()
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const pendingMessage = ref('')
|
||||
const orders = ref<ReservationOrderListItem[]>([])
|
||||
const page = ref(createPage(1, 20, 0))
|
||||
const total = computed(() => page.value.total)
|
||||
const currentPage = computed(() => page.value.page_num)
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(page.value.total / Math.max(page.value.page_size, 1))))
|
||||
let orderListRequestSequence = 0
|
||||
const filters = reactive<ReservationOrderListFilters>({
|
||||
keyword: '',
|
||||
order_status: '',
|
||||
@@ -105,24 +200,68 @@ onMounted(() => {
|
||||
void loadOrders()
|
||||
})
|
||||
|
||||
watch(filters, () => {
|
||||
void loadOrders()
|
||||
})
|
||||
|
||||
async function loadOrders(): Promise<void> {
|
||||
const requestSequence = ++orderListRequestSequence
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
pendingMessage.value = ''
|
||||
try {
|
||||
const result = await fetchReservationOrders({ ...filters })
|
||||
orders.value = result.items
|
||||
} catch (error) {
|
||||
orders.value = []
|
||||
if (error instanceof EndpointPendingError) {
|
||||
pendingMessage.value = t('orderList.endpointPending')
|
||||
if (requestSequence !== orderListRequestSequence) {
|
||||
return
|
||||
}
|
||||
orders.value = result.items
|
||||
page.value = result.page
|
||||
} catch (error) {
|
||||
if (requestSequence !== orderListRequestSequence) {
|
||||
return
|
||||
}
|
||||
orders.value = []
|
||||
page.value = createPage(filters.page_num ?? 1, filters.page_size ?? 20, 0)
|
||||
errorMessage.value = error instanceof Error ? error.message : String(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (requestSequence === orderListRequestSequence) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resetFilters(): void {
|
||||
filters.keyword = ''
|
||||
filters.order_status = ''
|
||||
filters.page_num = 1
|
||||
filters.page_size = 20
|
||||
}
|
||||
|
||||
function resetToFirstPage(): void {
|
||||
filters.page_num = 1
|
||||
}
|
||||
|
||||
function goToPage(page: number): void {
|
||||
filters.page_num = Math.min(Math.max(page, 1), totalPages.value)
|
||||
}
|
||||
|
||||
function createPage(pageNum: number, pageSize: number, total: number): ReservationPageResult {
|
||||
return {
|
||||
page_num: pageNum,
|
||||
page_size: pageSize,
|
||||
total,
|
||||
}
|
||||
}
|
||||
|
||||
function formatOrderKey(order: ReservationOrderListItem): string {
|
||||
return (
|
||||
order.display_order_key ??
|
||||
order.group_code ??
|
||||
order.confirmation_number ??
|
||||
order.temporary_order_no ??
|
||||
order.display_name ??
|
||||
order.order_id
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -179,6 +318,22 @@ async function loadOrders(): Promise<void> {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.text-button,
|
||||
.row-action {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--th-color-blue-600);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.row-action--muted {
|
||||
color: var(--th-color-slate-500);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
min-height: 220px;
|
||||
display: grid;
|
||||
@@ -198,9 +353,114 @@ async function loadOrders(): Promise<void> {
|
||||
color: var(--th-color-danger);
|
||||
}
|
||||
|
||||
.table-section {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
padding: 14px 20px 20px;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
min-width: 920px;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
border-bottom: 1px solid var(--th-color-slate-200);
|
||||
padding: 14px 10px;
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
th {
|
||||
color: var(--th-color-slate-500);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
td {
|
||||
color: var(--th-color-slate-900);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
td strong,
|
||||
td small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
td small {
|
||||
margin-top: 4px;
|
||||
color: var(--th-color-slate-500);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.pagination-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
border-top: 1px solid var(--th-color-slate-200);
|
||||
padding: 14px 20px 18px;
|
||||
}
|
||||
|
||||
.page-size-control,
|
||||
.pagination-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.page-size-control span,
|
||||
.pagination-actions span {
|
||||
color: var(--th-color-slate-500);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.page-size-control select {
|
||||
min-height: 34px;
|
||||
border: 1px solid var(--th-color-slate-200);
|
||||
border-radius: var(--th-radius-sm);
|
||||
background: var(--th-color-white);
|
||||
color: var(--th-color-slate-900);
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.secondary-button {
|
||||
min-height: 34px;
|
||||
border: 1px solid var(--th-color-slate-200);
|
||||
border-radius: var(--th-radius-sm);
|
||||
background: var(--th-color-white);
|
||||
color: var(--th-color-slate-700);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
.secondary-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.filter-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.pagination-bar {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -9,18 +9,7 @@
|
||||
|
||||
<section class="th-section conversation-section">
|
||||
<div
|
||||
v-if="pendingMessage"
|
||||
class="empty-state"
|
||||
>
|
||||
<i
|
||||
class="pi pi-envelope"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<strong>{{ t('common.endpointPending') }}</strong>
|
||||
<span>{{ pendingMessage }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="loading"
|
||||
v-if="loading"
|
||||
class="empty-state"
|
||||
>
|
||||
{{ t('common.loading') }}
|
||||
@@ -37,25 +26,143 @@
|
||||
>
|
||||
{{ t('common.noData') }}
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<header class="conversation-summary">
|
||||
<div>
|
||||
<p>{{ t('conversation.subject') }}</p>
|
||||
<h2>{{ conversation.conversation.subject ?? '-' }}</h2>
|
||||
</div>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>{{ t('conversation.messageCount') }}</dt>
|
||||
<dd>{{ conversation.conversation.message_count }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('conversation.channel') }}</dt>
|
||||
<dd>{{ conversation.conversation.channel }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('conversation.firstReceivedAt') }}</dt>
|
||||
<dd>{{ formatReservationDateTime(conversation.conversation.first_received_at) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('conversation.lastReceivedAt') }}</dt>
|
||||
<dd>{{ formatReservationDateTime(conversation.conversation.last_received_at) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</header>
|
||||
|
||||
<div class="message-stack">
|
||||
<article
|
||||
v-for="message in conversation.messages"
|
||||
:key="message.id"
|
||||
class="message-card"
|
||||
>
|
||||
<header class="message-card__header">
|
||||
<div>
|
||||
<p>{{ message.sender_summary ?? '-' }}</p>
|
||||
<h3>{{ message.subject ?? conversation.conversation.subject ?? '-' }}</h3>
|
||||
</div>
|
||||
<span>{{ formatReservationDateTime(message.received_at ?? message.source_sent_at) }}</span>
|
||||
</header>
|
||||
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<div
|
||||
v-if="message.html_body"
|
||||
class="message-body message-body--html"
|
||||
v-html="message.html_body"
|
||||
/>
|
||||
<!-- eslint-enable vue/no-v-html -->
|
||||
<pre
|
||||
v-else
|
||||
class="message-body"
|
||||
>{{ formatTextMessageBody(message) }}</pre>
|
||||
|
||||
<section
|
||||
v-if="message.attachments.length || message.inline_images.length"
|
||||
class="message-related"
|
||||
>
|
||||
<h4>{{ t('conversation.attachments') }}</h4>
|
||||
<ul>
|
||||
<li
|
||||
v-for="(media, mediaIndex) in combinedMedia(message)"
|
||||
:key="mediaKey(media, mediaIndex)"
|
||||
>
|
||||
<a
|
||||
v-if="safeExternalUrl(media.externalUrl)"
|
||||
:href="safeExternalUrl(media.externalUrl)"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{{ media.fileName ?? media.externalMediaId ?? t('conversation.unnamedAttachment') }}
|
||||
</a>
|
||||
<span v-else>
|
||||
{{ media.fileName ?? media.externalMediaId ?? t('conversation.unnamedAttachment') }}
|
||||
</span>
|
||||
<small>{{ formatMediaSize(media.sizeBytes) }}</small>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-if="message.related_orders.length || message.related_tasks.length"
|
||||
class="message-related message-related--grid"
|
||||
>
|
||||
<div>
|
||||
<h4>{{ t('conversation.relatedOrders') }}</h4>
|
||||
<ul>
|
||||
<li
|
||||
v-for="order in message.related_orders"
|
||||
:key="order.order_id"
|
||||
>
|
||||
<RouterLink :to="`/reservation/orders/${order.order_id}`">
|
||||
{{ order.display_order_key ?? order.order_id }}
|
||||
</RouterLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4>{{ t('conversation.relatedTasks') }}</h4>
|
||||
<ul>
|
||||
<li
|
||||
v-for="task in message.related_tasks"
|
||||
:key="task.task_id"
|
||||
>
|
||||
<RouterLink :to="`/reservation/tasks/${task.task_id}`">
|
||||
{{ task.card_name ?? task.task_type }}
|
||||
</RouterLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { EndpointPendingError, fetchSourceMessageConversation } from '@/services/reservationService'
|
||||
import type { SourceMessageConversationResult } from '@/types/reservation'
|
||||
import { fetchSourceMessageConversation } from '@/services/reservationService'
|
||||
import type {
|
||||
SourceMessageConversationItem,
|
||||
SourceMessageConversationResult,
|
||||
SourceMessageOriginalMedia,
|
||||
} from '@/types/reservation'
|
||||
import { formatReservationDateTime } from '@/utils/reservationFormat'
|
||||
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const pendingMessage = ref('')
|
||||
const conversation = ref<SourceMessageConversationResult | null>(null)
|
||||
const sourceMessageId = computed(() => String(route.params.sourceMessageId ?? ''))
|
||||
let conversationRequestSequence = 0
|
||||
|
||||
watch(
|
||||
sourceMessageId,
|
||||
@@ -66,22 +173,81 @@ watch(
|
||||
)
|
||||
|
||||
async function loadConversation(nextSourceMessageId: string): Promise<void> {
|
||||
const requestSequence = ++conversationRequestSequence
|
||||
if (!nextSourceMessageId) {
|
||||
conversation.value = null
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
pendingMessage.value = ''
|
||||
conversation.value = null
|
||||
try {
|
||||
conversation.value = await fetchSourceMessageConversation(nextSourceMessageId)
|
||||
const result = await fetchSourceMessageConversation(nextSourceMessageId)
|
||||
if (requestSequence !== conversationRequestSequence) {
|
||||
return
|
||||
}
|
||||
conversation.value = normalizeConversation(result)
|
||||
} catch (error) {
|
||||
if (error instanceof EndpointPendingError) {
|
||||
pendingMessage.value = t('conversation.endpointPending')
|
||||
if (requestSequence !== conversationRequestSequence) {
|
||||
return
|
||||
}
|
||||
errorMessage.value = error instanceof Error ? error.message : String(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (requestSequence === conversationRequestSequence) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeConversation(result: SourceMessageConversationResult): SourceMessageConversationResult {
|
||||
return {
|
||||
conversation: result.conversation,
|
||||
messages: result.messages.map((message) => ({
|
||||
...message,
|
||||
attachments: message.attachments ?? [],
|
||||
inline_images: message.inline_images ?? [],
|
||||
related_orders: message.related_orders ?? [],
|
||||
related_tasks: message.related_tasks ?? [],
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
function formatTextMessageBody(message: SourceMessageConversationItem): string {
|
||||
return message.text_body || t('conversation.emptyBody')
|
||||
}
|
||||
|
||||
function combinedMedia(message: SourceMessageConversationItem): SourceMessageOriginalMedia[] {
|
||||
return [...message.attachments, ...message.inline_images]
|
||||
}
|
||||
|
||||
function mediaKey(media: SourceMessageOriginalMedia, index: number): string {
|
||||
return `${media.externalMediaId ?? media.fileName ?? media.mediaType ?? 'media'}-${index}`
|
||||
}
|
||||
|
||||
function safeExternalUrl(value: string | null): string | undefined {
|
||||
if (!value) {
|
||||
return undefined
|
||||
}
|
||||
try {
|
||||
const url = new URL(value)
|
||||
return url.protocol === 'http:' || url.protocol === 'https:' ? value : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function formatMediaSize(sizeBytes: number | null): string {
|
||||
if (!sizeBytes) {
|
||||
return '-'
|
||||
}
|
||||
if (sizeBytes < 1024) {
|
||||
return `${sizeBytes} B`
|
||||
}
|
||||
if (sizeBytes < 1024 * 1024) {
|
||||
return `${Math.round(sizeBytes / 1024)} KB`
|
||||
}
|
||||
return `${(sizeBytes / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -106,7 +272,10 @@ async function loadConversation(nextSourceMessageId: string): Promise<void> {
|
||||
}
|
||||
|
||||
.conversation-section {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
min-height: 360px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
@@ -127,4 +296,170 @@ async function loadConversation(nextSourceMessageId: string): Promise<void> {
|
||||
.empty-state--error {
|
||||
color: var(--th-color-danger);
|
||||
}
|
||||
|
||||
.conversation-summary {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.4fr) minmax(0, 2fr);
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.conversation-summary p,
|
||||
.conversation-summary h2,
|
||||
.conversation-summary dl {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.conversation-summary p {
|
||||
color: var(--th-color-blue-600);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.conversation-summary h2 {
|
||||
margin-top: 5px;
|
||||
color: var(--th-color-slate-900);
|
||||
font-size: 22px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.conversation-summary dl {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.conversation-summary dt {
|
||||
color: var(--th-color-slate-500);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.conversation-summary dd {
|
||||
margin: 5px 0 0;
|
||||
color: var(--th-color-slate-900);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.message-stack {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.message-card {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
border: 1px solid var(--th-color-slate-200);
|
||||
border-radius: var(--th-radius-md);
|
||||
background: var(--th-color-white);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.message-card__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.message-card__header p,
|
||||
.message-card__header h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.message-card__header p,
|
||||
.message-card__header span {
|
||||
color: var(--th-color-slate-500);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.message-card__header h3 {
|
||||
margin-top: 5px;
|
||||
color: var(--th-color-slate-900);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.message-body {
|
||||
max-height: none;
|
||||
margin: 0;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
border: 1px solid var(--th-color-slate-200);
|
||||
border-radius: var(--th-radius-sm);
|
||||
background: var(--th-color-slate-50);
|
||||
color: var(--th-color-slate-900);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.message-body--html {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.message-body--html :deep(*) {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.message-body--html :deep(img) {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.message-related {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.message-related--grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.message-related h4,
|
||||
.message-related ul {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.message-related h4 {
|
||||
color: var(--th-color-slate-900);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.message-related ul {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.message-related li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
color: var(--th-color-slate-700);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.message-related a {
|
||||
color: var(--th-color-blue-600);
|
||||
font-weight: 800;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.message-related small {
|
||||
color: var(--th-color-slate-500);
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.conversation-summary,
|
||||
.message-related--grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.message-card__header {
|
||||
display: grid;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -28,11 +28,15 @@
|
||||
v-model="filters.keyword"
|
||||
type="search"
|
||||
placeholder="GRP-001 / Booking"
|
||||
@input="resetToFirstPage"
|
||||
>
|
||||
</label>
|
||||
<label>
|
||||
<span>{{ t('taskList.taskType') }}</span>
|
||||
<select v-model="filters.task_type">
|
||||
<select
|
||||
v-model="filters.task_type"
|
||||
@change="resetToFirstPage"
|
||||
>
|
||||
<option value="">All</option>
|
||||
<option value="NEW_BOOKING">{{ t('cardType.NEW_BOOKING') }}</option>
|
||||
<option value="UPDATE_BOOKING">{{ t('cardType.UPDATE_BOOKING') }}</option>
|
||||
@@ -43,7 +47,10 @@
|
||||
</label>
|
||||
<label>
|
||||
<span>{{ t('workbench.taskStatus') }}</span>
|
||||
<select v-model="filters.task_status">
|
||||
<select
|
||||
v-model="filters.task_status"
|
||||
@change="resetToFirstPage"
|
||||
>
|
||||
<option value="">All</option>
|
||||
<option value="PENDING_CONFIRM">{{ t('status.PENDING_CONFIRM') }}</option>
|
||||
<option value="READY">{{ t('status.READY') }}</option>
|
||||
@@ -51,6 +58,30 @@
|
||||
<option value="COMPLETED">{{ t('status.COMPLETED') }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>{{ t('workbench.orderStatus') }}</span>
|
||||
<select
|
||||
value=""
|
||||
disabled
|
||||
>
|
||||
<option value="">All</option>
|
||||
<option value="TEMPORARY">{{ t('status.TEMPORARY') }}</option>
|
||||
<option value="ACTIVE">{{ t('status.ACTIVE') }}</option>
|
||||
<option value="ENDED">{{ t('status.ENDED') }}</option>
|
||||
</select>
|
||||
<small class="filter-hint">{{ t('taskList.orderStatusFilterPending') }}</small>
|
||||
</label>
|
||||
<label>
|
||||
<span>{{ t('taskList.queueParticipation') }}</span>
|
||||
<select
|
||||
v-model="filters.queue_participation"
|
||||
@change="resetToFirstPage"
|
||||
>
|
||||
<option :value="undefined">{{ t('taskList.queueAll') }}</option>
|
||||
<option :value="true">{{ t('taskList.queueIncluded') }}</option>
|
||||
<option :value="false">{{ t('taskList.queueExcluded') }}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -61,7 +92,7 @@
|
||||
{{ t('taskList.tableTitle') }}
|
||||
</h2>
|
||||
<p class="th-muted">
|
||||
{{ t('workbench.totalPrefix') }} {{ tasks.length }} {{ t('taskList.totalSuffix') }}
|
||||
{{ t('workbench.totalPrefix') }} {{ total }} {{ t('taskList.totalSuffix') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -139,7 +170,7 @@
|
||||
{{ t('taskList.viewOrder') }}
|
||||
</RouterLink>
|
||||
<span
|
||||
v-if="!task.source_message_id || !task.external_conversation_id"
|
||||
v-if="!task.source_message_id"
|
||||
class="pending-link"
|
||||
>
|
||||
{{ t('common.endpointMissing') }}
|
||||
@@ -157,28 +188,66 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<footer class="pagination-bar">
|
||||
<label class="page-size-control">
|
||||
<span>{{ t('common.pageSize') }}</span>
|
||||
<select
|
||||
v-model.number="filters.page_size"
|
||||
@change="resetToFirstPage"
|
||||
>
|
||||
<option :value="10">10</option>
|
||||
<option :value="20">20</option>
|
||||
<option :value="50">50</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="pagination-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="secondary-button"
|
||||
:disabled="currentPage <= 1 || loading"
|
||||
@click="goToPage(currentPage - 1)"
|
||||
>
|
||||
{{ t('common.previousPage') }}
|
||||
</button>
|
||||
<span>{{ t('common.pageStatus', { current: currentPage, total: totalPages }) }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="secondary-button"
|
||||
:disabled="currentPage >= totalPages || loading"
|
||||
@click="goToPage(currentPage + 1)"
|
||||
>
|
||||
{{ t('common.nextPage') }}
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref, watch } from 'vue'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ReservationStatusBadge from '@/components/reservation/ReservationStatusBadge.vue'
|
||||
import { fetchReservationTaskList } from '@/services/reservationService'
|
||||
import type { ReservationTaskListFilters, ReservationTaskListItem } from '@/types/reservation'
|
||||
import type { ReservationPageResult, ReservationTaskListFilters, ReservationTaskListItem } from '@/types/reservation'
|
||||
|
||||
const { t } = useI18n()
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const tasks = ref<ReservationTaskListItem[]>([])
|
||||
const page = ref(createPage(1, 20, 0))
|
||||
const total = computed(() => page.value.total)
|
||||
const currentPage = computed(() => page.value.page_num)
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(page.value.total / Math.max(page.value.page_size, 1))))
|
||||
let taskListRequestSequence = 0
|
||||
const filters = reactive<ReservationTaskListFilters>({
|
||||
keyword: '',
|
||||
task_type: '',
|
||||
task_status: '',
|
||||
queue_participation: undefined,
|
||||
page_num: 1,
|
||||
page_size: 20,
|
||||
})
|
||||
@@ -201,11 +270,13 @@ async function loadTasks(): Promise<void> {
|
||||
return
|
||||
}
|
||||
tasks.value = result.items
|
||||
page.value = result.page
|
||||
} catch (error) {
|
||||
if (requestSequence !== taskListRequestSequence) {
|
||||
return
|
||||
}
|
||||
tasks.value = []
|
||||
page.value = createPage(filters.page_num ?? 1, filters.page_size ?? 20, 0)
|
||||
errorMessage.value = error instanceof Error ? error.message : String(error)
|
||||
} finally {
|
||||
if (requestSequence === taskListRequestSequence) {
|
||||
@@ -218,6 +289,25 @@ function resetFilters(): void {
|
||||
filters.keyword = ''
|
||||
filters.task_type = ''
|
||||
filters.task_status = ''
|
||||
filters.queue_participation = undefined
|
||||
filters.page_num = 1
|
||||
filters.page_size = 20
|
||||
}
|
||||
|
||||
function resetToFirstPage(): void {
|
||||
filters.page_num = 1
|
||||
}
|
||||
|
||||
function goToPage(page: number): void {
|
||||
filters.page_num = Math.min(Math.max(page, 1), totalPages.value)
|
||||
}
|
||||
|
||||
function createPage(pageNum: number, pageSize: number, total: number): ReservationPageResult {
|
||||
return {
|
||||
page_num: pageNum,
|
||||
page_size: pageSize,
|
||||
total,
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -248,7 +338,7 @@ function resetFilters(): void {
|
||||
|
||||
.filter-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
padding: 18px 20px 0;
|
||||
}
|
||||
@@ -275,6 +365,19 @@ function resetFilters(): void {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.filter-grid select:disabled {
|
||||
color: var(--th-color-slate-500);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.filter-hint {
|
||||
color: var(--th-color-warning);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.text-button,
|
||||
.row-action {
|
||||
border: 0;
|
||||
@@ -355,9 +458,63 @@ td small {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.pagination-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
border-top: 1px solid var(--th-color-slate-200);
|
||||
padding: 14px 20px 18px;
|
||||
}
|
||||
|
||||
.page-size-control,
|
||||
.pagination-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.page-size-control span,
|
||||
.pagination-actions span {
|
||||
color: var(--th-color-slate-500);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.page-size-control select {
|
||||
min-height: 34px;
|
||||
border: 1px solid var(--th-color-slate-200);
|
||||
border-radius: var(--th-radius-sm);
|
||||
background: var(--th-color-white);
|
||||
color: var(--th-color-slate-900);
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.secondary-button {
|
||||
min-height: 34px;
|
||||
border: 1px solid var(--th-color-slate-200);
|
||||
border-radius: var(--th-radius-sm);
|
||||
background: var(--th-color-white);
|
||||
color: var(--th-color-slate-700);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
.secondary-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.filter-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.pagination-bar {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user