新增 Debug EML 上传调试页面

This commit is contained in:
andy
2026-07-09 16:47:39 +08:00
parent 0727dfb8d0
commit 7100612442
14 changed files with 1269 additions and 0 deletions

View File

@@ -0,0 +1,634 @@
<template>
<div class="th-page debug-eml-view">
<header class="page-heading">
<div>
<h1>{{ t('debugEml.title') }}</h1>
<p>{{ t('debugEml.subtitle') }}</p>
</div>
</header>
<form
class="th-section debug-eml-form"
@submit.prevent="submitUpload"
>
<div class="th-section-header">
<h2 class="th-section-title">
{{ t('debugEml.uploadConfig') }}
</h2>
</div>
<div class="debug-eml-grid">
<label>
<span>{{ t('debugEml.hotelId') }}</span>
<input
v-model="hotelId"
name="hotel_id"
type="text"
:disabled="busy"
autocomplete="off"
>
</label>
<label>
<span>{{ t('debugEml.debugUploadKey') }}</span>
<input
v-model="debugUploadKey"
name="debug_upload_key"
type="password"
:disabled="busy"
autocomplete="off"
>
</label>
<label>
<span>{{ t('debugEml.runLabel') }}</span>
<input
v-model="runLabel"
name="run_label"
type="text"
:disabled="busy"
autocomplete="off"
>
</label>
<label>
<span>{{ t('debugEml.emlFile') }}</span>
<input
type="file"
accept=".eml,message/rfc822"
:disabled="busy"
@change="onFileChange"
>
</label>
</div>
<div class="debug-eml-actions">
<button
type="submit"
class="primary-button"
:disabled="submitDisabled"
>
{{ busy ? t('debugEml.submitting') : t('debugEml.submit') }}
</button>
<span
v-if="selectedFile"
class="th-muted"
>
{{ selectedFile.name }}
</span>
</div>
</form>
<section class="th-section debug-eml-status">
<div class="th-section-header">
<h2 class="th-section-title">
{{ t('debugEml.executionStatus') }}
</h2>
</div>
<dl class="debug-eml-kv">
<div>
<dt>{{ t('debugEml.status') }}</dt>
<dd>{{ statusLabel }}</dd>
</div>
<div>
<dt>{{ t('debugEml.debugRunId') }}</dt>
<dd class="th-code">
{{ result?.debug_run_id ?? '-' }}
</dd>
</div>
<div>
<dt>{{ t('debugEml.backendStatus') }}</dt>
<dd class="th-code">
{{ result?.status ?? '-' }}
</dd>
</div>
</dl>
<p
v-if="busy"
class="debug-eml-callout"
>
{{ t('debugEml.waitingSuperAgent') }}
</p>
<div
v-if="errorCode"
class="empty-state empty-state--error"
>
<strong>{{ errorCode }}</strong>
<span>{{ errorDisplayMessage }}</span>
<small v-if="errorMessage">
{{ t('debugEml.backendMessage') }}: {{ errorMessage }}
</small>
</div>
</section>
<template v-if="result">
<section class="th-section">
<div class="th-section-header">
<h2 class="th-section-title">
{{ t('debugEml.sourceTrace') }}
</h2>
</div>
<dl class="debug-eml-kv debug-eml-kv--grid">
<div>
<dt>{{ t('debugEml.sourceMessageId') }}</dt>
<dd class="th-code">
{{ result.source_message_id ?? '-' }}
</dd>
</div>
<div>
<dt>{{ t('debugEml.sourceProvider') }}</dt>
<dd>
{{ result.source_provider ?? '-' }}
</dd>
</div>
<div>
<dt>{{ t('debugEml.externalMessageId') }}</dt>
<dd class="th-code">
{{ result.external_message_id ?? '-' }}
</dd>
</div>
<div>
<dt>{{ t('debugEml.externalConversationId') }}</dt>
<dd class="th-code">
{{ result.external_conversation_id ?? '-' }}
</dd>
</div>
</dl>
</section>
<section class="th-section debug-eml-preview-section">
<div class="th-section-header">
<h2 class="th-section-title">
{{ t('debugEml.mailPreview') }}
</h2>
</div>
<!-- eslint-disable vue/no-v-html -->
<div
v-if="renderableHtmlBody"
class="debug-eml-html-preview"
v-html="renderableHtmlBody"
/>
<!-- eslint-enable vue/no-v-html -->
<div
v-else
class="empty-state"
>
{{ t('debugEml.noSafeHtml') }}
</div>
<div class="debug-eml-media-list">
<h3>{{ t('debugEml.uploadedMedia') }}</h3>
<ul v-if="result.uploaded_media.length">
<li
v-for="(media, index) in result.uploaded_media"
:key="media.external_media_id ?? media.object_key ?? index"
>
<span>{{ media.media_type ?? '-' }}</span>
<a
v-if="safeExternalUrl(media.external_url)"
:href="safeExternalUrl(media.external_url)"
target="_blank"
rel="noopener noreferrer"
>
{{ media.file_name ?? media.external_media_id ?? t('debugEml.unnamedMedia') }}
</a>
<strong v-else>
{{ media.file_name ?? media.external_media_id ?? t('debugEml.unnamedMedia') }}
</strong>
<small>
{{ media.content_type ?? '-' }} / {{ formatMediaSize(media.size_bytes) }}
</small>
</li>
</ul>
<p
v-else
class="th-muted"
>
{{ t('common.noData') }}
</p>
</div>
<details class="debug-eml-raw-html">
<summary>{{ t('debugEml.rawHtml') }}</summary>
<pre>{{ result.html_body_with_oss_urls ?? '-' }}</pre>
</details>
</section>
<section class="th-section debug-eml-result-section">
<div class="th-section-header">
<h2 class="th-section-title">
{{ t('debugEml.superAgentResult') }}
</h2>
</div>
<dl class="debug-eml-kv debug-eml-kv--grid">
<div>
<dt>{{ t('debugEml.superAgentSessionId') }}</dt>
<dd class="th-code">
{{ result.superagent_session_id ?? '-' }}
</dd>
</div>
<div>
<dt>{{ t('debugEml.superAgentRunId') }}</dt>
<dd class="th-code">
{{ result.superagent_run_id ?? '-' }}
</dd>
</div>
</dl>
<div class="debug-eml-two-column">
<section>
<h3>{{ t('debugEml.parsedJson') }}</h3>
<pre>{{ formatJson(result.superagent_parsed_json) }}</pre>
</section>
<section>
<h3>{{ t('debugEml.rawAnswer') }}</h3>
<pre>{{ result.superagent_raw_answer ?? '-' }}</pre>
</section>
</div>
<div
v-if="result.warnings.length"
class="debug-eml-warning-list"
>
<h3>{{ t('debugEml.warnings') }}</h3>
<ul>
<li
v-for="warning in result.warnings"
:key="warning"
>
{{ warning }}
</li>
</ul>
</div>
</section>
<section class="th-section">
<div class="th-section-header">
<h2 class="th-section-title">
{{ t('debugEml.debugPayload') }}
</h2>
</div>
<pre>{{ formatJson(result.agentbus_like_payload) }}</pre>
</section>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { reservationHotelId } from '@/config/reservationConfig'
import { DebugEmlUploadError, uploadDebugEmlSuperAgentRun } from '@/services/debugEmlService'
import type { DebugEmlErrorCode, DebugEmlSuperAgentRunResult } from '@/types/debugEml'
type DebugEmlStatus = 'idle' | 'uploading' | 'succeeded' | 'failed'
const { t } = useI18n()
const hotelId = ref(reservationHotelId)
const debugUploadKey = ref('')
const runLabel = ref('')
const selectedFile = ref<File | null>(null)
const status = ref<DebugEmlStatus>('idle')
const result = ref<DebugEmlSuperAgentRunResult | null>(null)
const errorCode = ref<DebugEmlErrorCode | ''>('')
const errorMessage = ref('')
const busy = computed(() => status.value === 'uploading')
const submitDisabled = computed(
() => busy.value || !hotelId.value.trim() || !debugUploadKey.value.trim() || !selectedFile.value,
)
const statusLabel = computed(() => t(`debugEml.statuses.${status.value}`))
const errorDisplayMessage = computed(() => {
if (!errorCode.value) {
return ''
}
const errorKey = `debugEml.errors.${errorCode.value}`
const mappedMessage = t(errorKey)
return mappedMessage === errorKey ? errorMessage.value || t('debugEml.errors.UNKNOWN') : mappedMessage
})
const renderableHtmlBody = computed(() => {
if (!result.value) {
return ''
}
if (result.value.html_render_mode && result.value.html_render_mode !== 'SANITIZED_HTML') {
return ''
}
return result.value.html_body_sanitized ?? ''
})
function onFileChange(event: Event): void {
const input = event.target as HTMLInputElement
selectedFile.value = input.files?.[0] ?? null
}
async function submitUpload(): Promise<void> {
if (submitDisabled.value || !selectedFile.value) {
return
}
status.value = 'uploading'
errorCode.value = ''
errorMessage.value = ''
result.value = null
try {
result.value = await uploadDebugEmlSuperAgentRun({
hotelId: hotelId.value,
debugUploadKey: debugUploadKey.value.trim(),
runLabel: runLabel.value,
file: selectedFile.value,
})
status.value = 'succeeded'
} catch (error) {
status.value = 'failed'
if (error instanceof DebugEmlUploadError) {
errorCode.value = error.errorCode
errorMessage.value = error.message
return
}
errorCode.value = 'DEBUG_EML_RUN_FAILED'
errorMessage.value = error instanceof Error ? error.message : String(error)
}
}
function formatJson(value: unknown): string {
if (value === null || value === undefined) {
return '-'
}
return JSON.stringify(value, null, 2)
}
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>
.debug-eml-view {
max-width: 1320px;
}
.page-heading h1,
.page-heading p {
margin: 0;
}
.page-heading h1 {
color: var(--th-color-slate-900);
font-size: 28px;
}
.page-heading p {
margin-top: 8px;
color: var(--th-color-slate-500);
font-size: 14px;
}
.debug-eml-form,
.debug-eml-status,
.debug-eml-preview-section,
.debug-eml-result-section {
display: grid;
gap: 16px;
}
.debug-eml-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.debug-eml-grid label {
display: grid;
gap: 7px;
color: var(--th-color-slate-500);
font-size: 12px;
font-weight: 800;
}
.debug-eml-grid input {
min-height: 38px;
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: 0 12px;
}
.debug-eml-actions {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.primary-button {
min-height: 38px;
border: 0;
border-radius: var(--th-radius-sm);
background: var(--th-color-blue-600);
color: var(--th-color-white);
padding: 0 16px;
font-weight: 800;
cursor: pointer;
}
.primary-button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.empty-state {
min-height: 120px;
display: grid;
place-items: center;
gap: 8px;
color: var(--th-color-slate-500);
font-size: 13px;
padding: 24px;
text-align: center;
}
.empty-state strong {
color: var(--th-color-slate-900);
}
.empty-state--error {
color: var(--th-color-danger);
}
.debug-eml-kv {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin: 0;
}
.debug-eml-kv--grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.debug-eml-kv div {
min-width: 0;
border: 1px solid var(--th-color-slate-200);
border-radius: var(--th-radius-sm);
padding: 12px;
background: var(--th-color-white);
}
.debug-eml-kv dt {
color: var(--th-color-slate-500);
font-size: 12px;
font-weight: 700;
}
.debug-eml-kv dd {
margin: 5px 0 0;
color: var(--th-color-slate-900);
font-size: 13px;
font-weight: 800;
overflow-wrap: anywhere;
}
.debug-eml-callout {
margin: 0;
border-radius: var(--th-radius-sm);
background: rgba(37, 99, 235, 0.08);
color: var(--th-color-blue-600);
padding: 12px;
font-size: 13px;
font-weight: 800;
}
.debug-eml-html-preview {
max-height: 520px;
overflow: auto;
border: 1px solid var(--th-color-slate-200);
border-radius: var(--th-radius-sm);
background: var(--th-color-white);
padding: 16px;
}
.debug-eml-media-list {
display: grid;
gap: 10px;
}
.debug-eml-media-list h3,
.debug-eml-two-column h3,
.debug-eml-warning-list h3 {
margin: 0;
color: var(--th-color-slate-900);
font-size: 14px;
}
.debug-eml-media-list ul,
.debug-eml-warning-list ul {
display: grid;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
.debug-eml-media-list li {
display: grid;
grid-template-columns: 140px minmax(0, 1fr) 220px;
gap: 12px;
align-items: center;
border: 1px solid var(--th-color-slate-200);
border-radius: var(--th-radius-sm);
padding: 10px 12px;
}
.debug-eml-media-list a,
.debug-eml-media-list strong {
overflow-wrap: anywhere;
}
.debug-eml-media-list span,
.debug-eml-media-list small {
color: var(--th-color-slate-500);
font-size: 12px;
font-weight: 700;
}
.debug-eml-raw-html {
border: 1px solid var(--th-color-slate-200);
border-radius: var(--th-radius-sm);
padding: 12px;
}
.debug-eml-raw-html summary {
cursor: pointer;
color: var(--th-color-slate-700);
font-size: 13px;
font-weight: 800;
}
pre {
max-height: 420px;
overflow: auto;
margin: 0;
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);
padding: 12px;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.debug-eml-two-column {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.debug-eml-two-column section {
display: grid;
gap: 10px;
}
.debug-eml-warning-list {
display: grid;
gap: 10px;
}
.debug-eml-warning-list li {
border-radius: var(--th-radius-sm);
background: rgba(245, 158, 11, 0.12);
color: #92400e;
padding: 10px 12px;
font-size: 13px;
font-weight: 700;
}
@media (max-width: 920px) {
.debug-eml-grid,
.debug-eml-kv,
.debug-eml-kv--grid,
.debug-eml-two-column,
.debug-eml-media-list li {
grid-template-columns: 1fr;
}
}
</style>