新增 Debug EML 上传调试页面
This commit is contained in:
5
client/pnpm-workspace.yaml
Normal file
5
client/pnpm-workspace.yaml
Normal file
@@ -0,0 +1,5 @@
|
||||
packages:
|
||||
- .
|
||||
|
||||
allowBuilds:
|
||||
vue-demi: true
|
||||
@@ -173,6 +173,58 @@ export default {
|
||||
relatedOrders: 'Related orders',
|
||||
relatedTasks: 'Related tasks',
|
||||
},
|
||||
debugEml: {
|
||||
title: 'Debug EML upload',
|
||||
subtitle: 'Upload one .eml email and review SourceMessage and SuperAgent debug results',
|
||||
uploadConfig: 'Upload configuration',
|
||||
hotelId: 'Hotel ID',
|
||||
debugUploadKey: 'Debug upload key',
|
||||
runLabel: 'Run label',
|
||||
emlFile: '.eml file',
|
||||
submit: 'Submit debug run',
|
||||
submitting: 'Waiting for SuperAgent',
|
||||
executionStatus: 'Execution status',
|
||||
status: 'Status',
|
||||
debugRunId: 'Debug run ID',
|
||||
backendStatus: 'Backend status',
|
||||
backendMessage: 'Backend message',
|
||||
waitingSuperAgent: 'Parsing the email and waiting for SuperAgent',
|
||||
sourceTrace: 'SourceMessage trace',
|
||||
sourceMessageId: 'SourceMessage ID',
|
||||
sourceProvider: 'Source provider',
|
||||
externalMessageId: 'External message ID',
|
||||
externalConversationId: 'External conversation ID',
|
||||
mailPreview: 'Email preview',
|
||||
noSafeHtml: 'No safe HTML body is available',
|
||||
uploadedMedia: 'Uploaded media',
|
||||
unnamedMedia: 'Unnamed media',
|
||||
rawHtml: 'Raw processed HTML',
|
||||
superAgentResult: 'SuperAgent result',
|
||||
superAgentSessionId: 'SuperAgent session ID',
|
||||
superAgentRunId: 'SuperAgent run ID',
|
||||
parsedJson: 'Parsed JSON',
|
||||
rawAnswer: 'Raw answer',
|
||||
warnings: 'Warnings',
|
||||
debugPayload: 'Debug payload',
|
||||
statuses: {
|
||||
idle: 'Idle',
|
||||
uploading: 'Running',
|
||||
succeeded: 'Succeeded',
|
||||
failed: 'Failed',
|
||||
},
|
||||
errors: {
|
||||
REQUEST_FIELD_REQUIRED: 'Required fields are missing. Check hotel ID, file, or request parameters',
|
||||
EML_FILE_REQUIRED: 'Choose an .eml email file',
|
||||
EML_FILE_TOO_LARGE: 'The email file exceeds the backend limit',
|
||||
INVALID_FILE_TYPE: 'Only .eml email files are supported',
|
||||
DEBUG_UPLOAD_KEY_INVALID: 'The debug upload key is missing or invalid',
|
||||
EML_PARSE_FAILED: 'The email file could not be parsed',
|
||||
OSS_UPLOAD_FAILED: 'OSS upload failed. Contact backend support',
|
||||
SUPERAGENT_OPEN_API_FAILED: 'SuperAgent call failed. SourceMessage may already be captured',
|
||||
DEBUG_EML_RUN_FAILED: 'Debug EML run failed internally',
|
||||
UNKNOWN: 'Debug EML upload failed',
|
||||
},
|
||||
},
|
||||
status: {
|
||||
TEMPORARY: 'Temporary',
|
||||
ACTIVE: 'Active',
|
||||
|
||||
@@ -173,6 +173,58 @@ export default {
|
||||
relatedOrders: 'ออเดอร์ที่เกี่ยวข้อง',
|
||||
relatedTasks: 'งานที่เกี่ยวข้อง',
|
||||
},
|
||||
debugEml: {
|
||||
title: 'อัปโหลด Debug EML',
|
||||
subtitle: 'อัปโหลดอีเมล .eml หนึ่งฉบับและดูผล Debug ของ SourceMessage กับ SuperAgent',
|
||||
uploadConfig: 'ค่าการอัปโหลด',
|
||||
hotelId: 'Hotel ID',
|
||||
debugUploadKey: 'คีย์อัปโหลด Debug',
|
||||
runLabel: 'ป้ายกำกับรัน',
|
||||
emlFile: 'ไฟล์ .eml',
|
||||
submit: 'ส่ง Debug',
|
||||
submitting: 'รอ SuperAgent',
|
||||
executionStatus: 'สถานะการทำงาน',
|
||||
status: 'สถานะ',
|
||||
debugRunId: 'Debug run ID',
|
||||
backendStatus: 'สถานะจากระบบหลังบ้าน',
|
||||
backendMessage: 'ข้อความจากระบบหลังบ้าน',
|
||||
waitingSuperAgent: 'กำลังแยกอีเมลและรอ SuperAgent ตอบกลับ',
|
||||
sourceTrace: 'การติดตาม SourceMessage',
|
||||
sourceMessageId: 'SourceMessage ID',
|
||||
sourceProvider: 'Source provider',
|
||||
externalMessageId: 'External message ID',
|
||||
externalConversationId: 'External conversation ID',
|
||||
mailPreview: 'ตัวอย่างอีเมล',
|
||||
noSafeHtml: 'ยังไม่มี HTML ที่ปลอดภัยสำหรับแสดงผล',
|
||||
uploadedMedia: 'สื่อที่อัปโหลด',
|
||||
unnamedMedia: 'สื่อไม่มีชื่อ',
|
||||
rawHtml: 'HTML หลังประมวลผลดิบ',
|
||||
superAgentResult: 'ผลจาก SuperAgent',
|
||||
superAgentSessionId: 'SuperAgent session ID',
|
||||
superAgentRunId: 'SuperAgent run ID',
|
||||
parsedJson: 'JSON ที่แยกแล้ว',
|
||||
rawAnswer: 'คำตอบดิบ',
|
||||
warnings: 'คำเตือน',
|
||||
debugPayload: 'Debug payload',
|
||||
statuses: {
|
||||
idle: 'รออัปโหลด',
|
||||
uploading: 'กำลังทำงาน',
|
||||
succeeded: 'สำเร็จ',
|
||||
failed: 'ล้มเหลว',
|
||||
},
|
||||
errors: {
|
||||
REQUEST_FIELD_REQUIRED: 'ขาดข้อมูลจำเป็น โปรดตรวจ Hotel ID ไฟล์ หรือพารามิเตอร์คำขอ',
|
||||
EML_FILE_REQUIRED: 'โปรดเลือกไฟล์อีเมล .eml',
|
||||
EML_FILE_TOO_LARGE: 'ไฟล์อีเมลเกินขนาดที่หลังบ้านกำหนด',
|
||||
INVALID_FILE_TYPE: 'รองรับเฉพาะไฟล์อีเมล .eml',
|
||||
DEBUG_UPLOAD_KEY_INVALID: 'คีย์อัปโหลด Debug ขาดหายหรือไม่ถูกต้อง',
|
||||
EML_PARSE_FAILED: 'ไม่สามารถแยกไฟล์อีเมลได้',
|
||||
OSS_UPLOAD_FAILED: 'อัปโหลด OSS ล้มเหลว โปรดให้หลังบ้านตรวจสอบ',
|
||||
SUPERAGENT_OPEN_API_FAILED: 'เรียก SuperAgent ล้มเหลว SourceMessage อาจถูกบันทึกแล้ว',
|
||||
DEBUG_EML_RUN_FAILED: 'กระบวนการ Debug EML ล้มเหลวภายใน',
|
||||
UNKNOWN: 'อัปโหลด Debug EML ล้มเหลว',
|
||||
},
|
||||
},
|
||||
status: {
|
||||
TEMPORARY: 'ชั่วคราว',
|
||||
ACTIVE: 'ใช้งานอยู่',
|
||||
|
||||
@@ -173,6 +173,58 @@ export default {
|
||||
relatedOrders: '关联订单',
|
||||
relatedTasks: '关联任务',
|
||||
},
|
||||
debugEml: {
|
||||
title: 'Debug EML 调试上传',
|
||||
subtitle: '上传单封 .eml 邮件并查看 SourceMessage 与 SuperAgent 调试结果',
|
||||
uploadConfig: '上传配置',
|
||||
hotelId: '酒店 ID',
|
||||
debugUploadKey: 'Debug 上传口令',
|
||||
runLabel: '运行标签',
|
||||
emlFile: '.eml 文件',
|
||||
submit: '提交调试',
|
||||
submitting: '等待 SuperAgent 返回',
|
||||
executionStatus: '执行状态',
|
||||
status: '状态',
|
||||
debugRunId: 'Debug 运行 ID',
|
||||
backendStatus: '后端状态',
|
||||
backendMessage: '后端消息',
|
||||
waitingSuperAgent: '正在解析邮件并等待 SuperAgent 返回',
|
||||
sourceTrace: 'SourceMessage 追溯',
|
||||
sourceMessageId: 'SourceMessage ID',
|
||||
sourceProvider: '来源 Provider',
|
||||
externalMessageId: '外部消息 ID',
|
||||
externalConversationId: '外部会话 ID',
|
||||
mailPreview: '邮件内容预览',
|
||||
noSafeHtml: '当前没有可安全渲染的 HTML 正文',
|
||||
uploadedMedia: '上传媒体',
|
||||
unnamedMedia: '未命名媒体',
|
||||
rawHtml: '原始处理 HTML',
|
||||
superAgentResult: 'SuperAgent 结果',
|
||||
superAgentSessionId: 'SuperAgent Session ID',
|
||||
superAgentRunId: 'SuperAgent Run ID',
|
||||
parsedJson: '解析 JSON',
|
||||
rawAnswer: '原始回答',
|
||||
warnings: '警告',
|
||||
debugPayload: '调试 Payload',
|
||||
statuses: {
|
||||
idle: '待上传',
|
||||
uploading: '执行中',
|
||||
succeeded: '成功',
|
||||
failed: '失败',
|
||||
},
|
||||
errors: {
|
||||
REQUEST_FIELD_REQUIRED: '缺少必填字段,请检查酒店 ID、文件或请求参数',
|
||||
EML_FILE_REQUIRED: '请选择 .eml 邮件文件',
|
||||
EML_FILE_TOO_LARGE: '邮件文件超过后端限制',
|
||||
INVALID_FILE_TYPE: '只支持 .eml 邮件文件',
|
||||
DEBUG_UPLOAD_KEY_INVALID: '调试上传口令缺失或错误',
|
||||
EML_PARSE_FAILED: '邮件文件无法解析',
|
||||
OSS_UPLOAD_FAILED: 'OSS 上传失败,请联系后端排查',
|
||||
SUPERAGENT_OPEN_API_FAILED: 'SuperAgent 调用失败,SourceMessage 可能已经写入',
|
||||
DEBUG_EML_RUN_FAILED: 'Debug EML 链路内部失败',
|
||||
UNKNOWN: 'Debug EML 调试失败',
|
||||
},
|
||||
},
|
||||
status: {
|
||||
TEMPORARY: '临时',
|
||||
ACTIVE: '有效',
|
||||
|
||||
@@ -51,5 +51,13 @@ export const router = createRouter({
|
||||
titleKey: 'nav.sourceMessageConversation',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/debug/eml-superagent',
|
||||
name: 'debug-eml-superagent',
|
||||
component: () => import('@/views/debug/DebugEmlSuperAgentRunView.vue'),
|
||||
meta: {
|
||||
titleKey: 'debugEml.title',
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
126
client/src/services/debugEmlService.ts
Normal file
126
client/src/services/debugEmlService.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import type {
|
||||
DebugEmlErrorCode,
|
||||
DebugEmlErrorResponse,
|
||||
DebugEmlSuperAgentRunResult,
|
||||
DebugEmlUploadInput,
|
||||
} from '@/types/debugEml'
|
||||
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? ''
|
||||
const debugEmlEndpoint = '/api/system/debug/eml-superagent-runs'
|
||||
|
||||
export class DebugEmlUploadError extends Error {
|
||||
readonly status: number
|
||||
readonly errorCode: DebugEmlErrorCode
|
||||
readonly details: unknown
|
||||
|
||||
constructor(message: string, status: number, errorCode: DebugEmlErrorCode, details: unknown) {
|
||||
super(message)
|
||||
this.name = 'DebugEmlUploadError'
|
||||
this.status = status
|
||||
this.errorCode = errorCode
|
||||
this.details = details
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadDebugEmlSuperAgentRun(
|
||||
input: DebugEmlUploadInput,
|
||||
): Promise<DebugEmlSuperAgentRunResult> {
|
||||
const form = new FormData()
|
||||
form.append('file', input.file)
|
||||
form.append('hotel_id', input.hotelId.trim())
|
||||
const runLabel = input.runLabel?.trim()
|
||||
if (runLabel) {
|
||||
form.append('run_label', runLabel)
|
||||
}
|
||||
|
||||
const response = await fetch(`${apiBaseUrl}${debugEmlEndpoint}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-TH-Hotel-Debug-Upload-Key': input.debugUploadKey.trim(),
|
||||
},
|
||||
body: form,
|
||||
})
|
||||
|
||||
const payload = await readResponsePayload(response)
|
||||
if (!response.ok) {
|
||||
const errorPayload = isDebugEmlErrorResponse(payload) ? payload : {}
|
||||
throw new DebugEmlUploadError(
|
||||
errorPayload.message || `Request failed with status ${response.status}`,
|
||||
response.status,
|
||||
errorPayload.error_code || 'DEBUG_EML_RUN_FAILED',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
return normalizeDebugEmlResult(payload)
|
||||
}
|
||||
|
||||
async function readResponsePayload(response: Response): Promise<unknown> {
|
||||
const contentType = response.headers.get('content-type') ?? ''
|
||||
if (!contentType.includes('application/json')) {
|
||||
return response.text()
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
function normalizeDebugEmlResult(payload: unknown): DebugEmlSuperAgentRunResult {
|
||||
const result = isRecord(payload) ? payload : {}
|
||||
return {
|
||||
debug_run_id: stringValue(result.debug_run_id),
|
||||
source_message_id: nullableStringValue(result.source_message_id),
|
||||
source_provider: nullableStringValue(result.source_provider),
|
||||
external_message_id: nullableStringValue(result.external_message_id),
|
||||
external_conversation_id: nullableStringValue(result.external_conversation_id),
|
||||
original_eml_oss_url: nullableStringValue(result.original_eml_oss_url),
|
||||
original_eml_sha256: nullableStringValue(result.original_eml_sha256),
|
||||
uploaded_media: Array.isArray(result.uploaded_media)
|
||||
? result.uploaded_media.map(normalizeUploadedMedia)
|
||||
: [],
|
||||
html_body_with_oss_urls: nullableStringValue(result.html_body_with_oss_urls),
|
||||
html_body_sanitized: nullableStringValue(result.html_body_sanitized),
|
||||
html_sanitize_required:
|
||||
typeof result.html_sanitize_required === 'boolean' ? result.html_sanitize_required : null,
|
||||
html_render_mode: nullableStringValue(result.html_render_mode),
|
||||
agentbus_like_payload: isRecord(result.agentbus_like_payload) ? result.agentbus_like_payload : null,
|
||||
superagent_session_id: nullableStringValue(result.superagent_session_id),
|
||||
superagent_run_id: nullableStringValue(result.superagent_run_id),
|
||||
superagent_raw_answer: nullableStringValue(result.superagent_raw_answer),
|
||||
superagent_parsed_json: result.superagent_parsed_json ?? null,
|
||||
warnings: Array.isArray(result.warnings) ? result.warnings.filter(isString) : [],
|
||||
status: nullableStringValue(result.status),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeUploadedMedia(value: unknown): DebugEmlSuperAgentRunResult['uploaded_media'][number] {
|
||||
const media = isRecord(value) ? value : {}
|
||||
return {
|
||||
media_type: nullableStringValue(media.media_type),
|
||||
file_name: nullableStringValue(media.file_name),
|
||||
content_type: nullableStringValue(media.content_type),
|
||||
size_bytes: typeof media.size_bytes === 'number' ? media.size_bytes : null,
|
||||
external_url: nullableStringValue(media.external_url),
|
||||
external_media_id: nullableStringValue(media.external_media_id),
|
||||
object_key: nullableStringValue(media.object_key),
|
||||
}
|
||||
}
|
||||
|
||||
function isDebugEmlErrorResponse(value: unknown): value is DebugEmlErrorResponse {
|
||||
return isRecord(value) && (typeof value.error_code === 'string' || typeof value.message === 'string')
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function isString(value: unknown): value is string {
|
||||
return typeof value === 'string'
|
||||
}
|
||||
|
||||
function nullableStringValue(value: unknown): string | null {
|
||||
return typeof value === 'string' ? value : null
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string {
|
||||
return typeof value === 'string' ? value : ''
|
||||
}
|
||||
102
client/src/tests/debugEmlService.spec.ts
Normal file
102
client/src/tests/debugEmlService.spec.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { DebugEmlUploadError, uploadDebugEmlSuperAgentRun } from '@/services/debugEmlService'
|
||||
|
||||
const jsonHeaders = {
|
||||
headers: {
|
||||
get: (name: string) => (name.toLowerCase() === 'content-type' ? 'application/json' : null),
|
||||
},
|
||||
}
|
||||
|
||||
function mockJsonResponse(payload: unknown, status = 201): Response {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => payload,
|
||||
text: async () => JSON.stringify(payload),
|
||||
...jsonHeaders,
|
||||
} as Response
|
||||
}
|
||||
|
||||
describe('debugEmlService', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('uploads an eml file with FormData and debug key header', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
mockJsonResponse({
|
||||
debug_run_id: '90001',
|
||||
source_message_id: '30001',
|
||||
source_provider: 'DEBUG_EML_UPLOAD',
|
||||
external_message_id: 'debug-eml-run-90001-abcdef123456',
|
||||
external_conversation_id: 'debug-thread-90001',
|
||||
original_eml_oss_url: 'https://oss.example/raw.eml',
|
||||
original_eml_sha256: 'sha256',
|
||||
uploaded_media: [],
|
||||
html_body_with_oss_urls: '<strong>raw</strong>',
|
||||
html_body_sanitized: '<strong>safe</strong>',
|
||||
html_sanitize_required: true,
|
||||
html_render_mode: 'SANITIZED_HTML',
|
||||
agentbus_like_payload: { schema_version: 'debug-eml-upload-v1' },
|
||||
superagent_session_id: 'session-1',
|
||||
superagent_run_id: 'run-1',
|
||||
superagent_raw_answer: '{"ok":true}',
|
||||
superagent_parsed_json: { ok: true },
|
||||
warnings: [],
|
||||
status: 'SUPERAGENT_SUCCEEDED',
|
||||
}),
|
||||
)
|
||||
const file = new File(['From: guest@example.test'], 'booking.eml', { type: 'message/rfc822' })
|
||||
|
||||
const result = await uploadDebugEmlSuperAgentRun({
|
||||
hotelId: 'HOTEL-TEST',
|
||||
debugUploadKey: ' manual-debug-key ',
|
||||
runLabel: 'frontend-smoke',
|
||||
file,
|
||||
})
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
const [url, init] = fetchMock.mock.calls[0]!
|
||||
expect(url).toBe('/api/system/debug/eml-superagent-runs')
|
||||
expect(init).toMatchObject({
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-TH-Hotel-Debug-Upload-Key': 'manual-debug-key',
|
||||
},
|
||||
})
|
||||
expect(init?.headers).not.toHaveProperty('Content-Type')
|
||||
expect(init?.body).toBeInstanceOf(FormData)
|
||||
const formData = init?.body as FormData
|
||||
expect(formData.get('hotel_id')).toBe('HOTEL-TEST')
|
||||
expect(formData.get('run_label')).toBe('frontend-smoke')
|
||||
expect(formData.get('file')).toBe(file)
|
||||
expect(result.debug_run_id).toBe('90001')
|
||||
})
|
||||
|
||||
it('throws a typed safe error for backend error_code responses', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
mockJsonResponse(
|
||||
{
|
||||
error_code: 'DEBUG_UPLOAD_KEY_INVALID',
|
||||
message: 'Debug 上传访问口令缺失或错误。',
|
||||
},
|
||||
401,
|
||||
),
|
||||
)
|
||||
const file = new File(['From: guest@example.test'], 'booking.eml', { type: 'message/rfc822' })
|
||||
|
||||
await expect(
|
||||
uploadDebugEmlSuperAgentRun({
|
||||
hotelId: 'HOTEL-TEST',
|
||||
debugUploadKey: 'wrong-key',
|
||||
file,
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
status: 401,
|
||||
errorCode: 'DEBUG_UPLOAD_KEY_INVALID',
|
||||
message: 'Debug 上传访问口令缺失或错误。',
|
||||
} satisfies Partial<DebugEmlUploadError>)
|
||||
})
|
||||
})
|
||||
150
client/src/tests/debugEmlView.spec.ts
Normal file
150
client/src/tests/debugEmlView.spec.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import zhCN from '@/i18n/locales/zh-CN'
|
||||
import { DebugEmlUploadError } from '@/services/debugEmlService'
|
||||
import DebugEmlSuperAgentRunView from '@/views/debug/DebugEmlSuperAgentRunView.vue'
|
||||
|
||||
vi.mock('@/services/debugEmlService', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/services/debugEmlService')>()
|
||||
return {
|
||||
...actual,
|
||||
uploadDebugEmlSuperAgentRun: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
const service = await import('@/services/debugEmlService')
|
||||
|
||||
function mountView() {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'zh-CN',
|
||||
messages: {
|
||||
'zh-CN': zhCN,
|
||||
},
|
||||
})
|
||||
|
||||
return mount(DebugEmlSuperAgentRunView, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function createResult() {
|
||||
return {
|
||||
debug_run_id: '90001',
|
||||
source_message_id: '30001',
|
||||
source_provider: 'DEBUG_EML_UPLOAD',
|
||||
external_message_id: 'debug-eml-run-90001-abcdef123456',
|
||||
external_conversation_id: 'debug-thread-90001',
|
||||
original_eml_oss_url: 'https://oss.example/raw.eml',
|
||||
original_eml_sha256: 'sha256',
|
||||
uploaded_media: [
|
||||
{
|
||||
media_type: 'ORIGINAL_EMAIL',
|
||||
file_name: 'booking.eml',
|
||||
content_type: 'message/rfc822',
|
||||
size_bytes: 1200,
|
||||
external_url: 'https://oss.example/raw.eml',
|
||||
external_media_id: 'raw',
|
||||
object_key: 'debug/eml/raw/booking.eml',
|
||||
},
|
||||
],
|
||||
html_body_with_oss_urls: '<script>alert("raw")</script><strong onclick="bad()">原始 HTML</strong>',
|
||||
html_body_sanitized: '<strong>安全正文</strong>',
|
||||
html_sanitize_required: true,
|
||||
html_render_mode: 'SANITIZED_HTML',
|
||||
agentbus_like_payload: {
|
||||
schema_version: 'debug-eml-upload-v1',
|
||||
source: {
|
||||
original_message_id: '<original@example.test>',
|
||||
},
|
||||
},
|
||||
superagent_session_id: 'session-1',
|
||||
superagent_run_id: 'run-1',
|
||||
superagent_raw_answer: '{"ok":true}',
|
||||
superagent_parsed_json: { ok: true },
|
||||
warnings: ['SuperAgent 返回非 JSON 时会展示原始回答'],
|
||||
status: 'SUPERAGENT_SUCCEEDED',
|
||||
}
|
||||
}
|
||||
|
||||
async function chooseFile(wrapper: ReturnType<typeof mountView>) {
|
||||
const file = new File(['From: guest@example.test'], 'booking.eml', { type: 'message/rfc822' })
|
||||
const input = wrapper.find('input[type="file"]')
|
||||
Object.defineProperty(input.element, 'files', {
|
||||
value: [file],
|
||||
configurable: true,
|
||||
})
|
||||
await input.trigger('change')
|
||||
return file
|
||||
}
|
||||
|
||||
describe('DebugEmlSuperAgentRunView', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(service.uploadDebugEmlSuperAgentRun).mockReset()
|
||||
})
|
||||
|
||||
it('submits the selected eml file and renders sanitized debug result', async () => {
|
||||
vi.mocked(service.uploadDebugEmlSuperAgentRun).mockResolvedValue(createResult())
|
||||
const wrapper = mountView()
|
||||
|
||||
await wrapper.find('input[name="hotel_id"]').setValue('HOTEL-TEST')
|
||||
await wrapper.find('input[name="debug_upload_key"]').setValue('manual-debug-key')
|
||||
await wrapper.find('input[name="run_label"]').setValue('frontend-smoke')
|
||||
const file = await chooseFile(wrapper)
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(service.uploadDebugEmlSuperAgentRun).toHaveBeenCalledWith({
|
||||
hotelId: 'HOTEL-TEST',
|
||||
debugUploadKey: 'manual-debug-key',
|
||||
runLabel: 'frontend-smoke',
|
||||
file,
|
||||
})
|
||||
expect(wrapper.text()).toContain('90001')
|
||||
expect(wrapper.text()).toContain('30001')
|
||||
expect(wrapper.text()).toContain('DEBUG_EML_UPLOAD')
|
||||
expect(wrapper.find('.debug-eml-html-preview').html()).toContain('<strong>安全正文</strong>')
|
||||
expect(wrapper.find('.debug-eml-html-preview').html()).not.toContain('<script>')
|
||||
expect(wrapper.find('.debug-eml-html-preview').html()).not.toContain('onclick')
|
||||
expect(wrapper.text()).toContain('booking.eml')
|
||||
expect(wrapper.text()).toContain('"schema_version": "debug-eml-upload-v1"')
|
||||
expect(wrapper.text()).toContain('"ok": true')
|
||||
})
|
||||
|
||||
it('does not render raw html with oss urls by default', async () => {
|
||||
vi.mocked(service.uploadDebugEmlSuperAgentRun).mockResolvedValue(createResult())
|
||||
const wrapper = mountView()
|
||||
|
||||
await wrapper.find('input[name="hotel_id"]').setValue('HOTEL-TEST')
|
||||
await wrapper.find('input[name="debug_upload_key"]').setValue('manual-debug-key')
|
||||
await chooseFile(wrapper)
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.debug-eml-html-preview').html()).not.toContain('原始 HTML')
|
||||
expect(wrapper.find('details.debug-eml-raw-html').text()).toContain('原始 HTML')
|
||||
})
|
||||
|
||||
it('shows mapped error_code messages without exposing the debug key', async () => {
|
||||
vi.mocked(service.uploadDebugEmlSuperAgentRun).mockRejectedValue(
|
||||
new DebugEmlUploadError('Debug 上传访问口令缺失或错误。', 401, 'DEBUG_UPLOAD_KEY_INVALID', {
|
||||
error_code: 'DEBUG_UPLOAD_KEY_INVALID',
|
||||
}),
|
||||
)
|
||||
const wrapper = mountView()
|
||||
|
||||
await wrapper.find('input[name="hotel_id"]').setValue('HOTEL-TEST')
|
||||
await wrapper.find('input[name="debug_upload_key"]').setValue('manual-debug-key')
|
||||
await chooseFile(wrapper)
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('DEBUG_UPLOAD_KEY_INVALID')
|
||||
expect(wrapper.text()).toContain('调试上传口令缺失或错误')
|
||||
expect(wrapper.text()).not.toContain('manual-debug-key')
|
||||
})
|
||||
})
|
||||
@@ -17,6 +17,26 @@ describe('reservation i18n locales', () => {
|
||||
expect(thTH.nav.taskQueue).toBe('คิวงาน')
|
||||
expect(thTH.taskList.viewConversation).toBe('ดูเธรดอีเมล')
|
||||
})
|
||||
|
||||
it('covers Debug EML backend error codes in all locales', () => {
|
||||
const expectedErrorCodes = [
|
||||
'REQUEST_FIELD_REQUIRED',
|
||||
'EML_FILE_REQUIRED',
|
||||
'EML_FILE_TOO_LARGE',
|
||||
'INVALID_FILE_TYPE',
|
||||
'DEBUG_UPLOAD_KEY_INVALID',
|
||||
'EML_PARSE_FAILED',
|
||||
'OSS_UPLOAD_FAILED',
|
||||
'SUPERAGENT_OPEN_API_FAILED',
|
||||
'DEBUG_EML_RUN_FAILED',
|
||||
] satisfies Array<keyof typeof zhCN.debugEml.errors>
|
||||
|
||||
for (const locale of [zhCN, enUS, thTH]) {
|
||||
for (const errorCode of expectedErrorCodes) {
|
||||
expect(locale.debugEml.errors[errorCode]).toBeTruthy()
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function collectKeyPaths(value: unknown, prefix = ''): string[] {
|
||||
|
||||
@@ -12,4 +12,8 @@ describe('reservation router', () => {
|
||||
'reservation-source-message-conversation',
|
||||
)
|
||||
})
|
||||
|
||||
it('exposes the hidden Debug EML route without adding it to the business menu', () => {
|
||||
expect(router.resolve('/debug/eml-superagent').name).toBe('debug-eml-superagent')
|
||||
})
|
||||
})
|
||||
|
||||
55
client/src/types/debugEml.ts
Normal file
55
client/src/types/debugEml.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
export type DebugEmlErrorCode =
|
||||
| 'REQUEST_FIELD_REQUIRED'
|
||||
| 'EML_FILE_REQUIRED'
|
||||
| 'EML_FILE_TOO_LARGE'
|
||||
| 'INVALID_FILE_TYPE'
|
||||
| 'DEBUG_UPLOAD_KEY_INVALID'
|
||||
| 'EML_PARSE_FAILED'
|
||||
| 'OSS_UPLOAD_FAILED'
|
||||
| 'SUPERAGENT_OPEN_API_FAILED'
|
||||
| 'DEBUG_EML_RUN_FAILED'
|
||||
| string
|
||||
|
||||
export interface DebugEmlUploadInput {
|
||||
hotelId: string
|
||||
debugUploadKey: string
|
||||
file: File
|
||||
runLabel?: string
|
||||
}
|
||||
|
||||
export interface DebugEmlSuperAgentRunResult {
|
||||
debug_run_id: string
|
||||
source_message_id: string | null
|
||||
source_provider: string | null
|
||||
external_message_id: string | null
|
||||
external_conversation_id: string | null
|
||||
original_eml_oss_url: string | null
|
||||
original_eml_sha256: string | null
|
||||
uploaded_media: DebugEmlUploadedMediaResult[]
|
||||
html_body_with_oss_urls: string | null
|
||||
html_body_sanitized: string | null
|
||||
html_sanitize_required: boolean | null
|
||||
html_render_mode: 'SANITIZED_HTML' | 'TEXT_ONLY' | string | null
|
||||
agentbus_like_payload: Record<string, unknown> | null
|
||||
superagent_session_id: string | null
|
||||
superagent_run_id: string | null
|
||||
superagent_raw_answer: string | null
|
||||
superagent_parsed_json: unknown | null
|
||||
warnings: string[]
|
||||
status: string | null
|
||||
}
|
||||
|
||||
export interface DebugEmlUploadedMediaResult {
|
||||
media_type: string | null
|
||||
file_name: string | null
|
||||
content_type: string | null
|
||||
size_bytes: number | null
|
||||
external_url: string | null
|
||||
external_media_id: string | null
|
||||
object_key: string | null
|
||||
}
|
||||
|
||||
export interface DebugEmlErrorResponse {
|
||||
error_code?: DebugEmlErrorCode
|
||||
message?: string
|
||||
}
|
||||
634
client/src/views/debug/DebugEmlSuperAgentRunView.vue
Normal file
634
client/src/views/debug/DebugEmlSuperAgentRunView.vue
Normal 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>
|
||||
Reference in New Issue
Block a user