|
|
@@ -0,0 +1,573 @@
|
|
|
+<template>
|
|
|
+ <div class="v2-chat-page">
|
|
|
+ <el-card shadow="never" class="mb-3">
|
|
|
+ <div class="head">
|
|
|
+ <div>
|
|
|
+ <div class="title">v2 语义交互测试</div>
|
|
|
+ <div class="sub">POST /api/v2/agent/chat/stream · SSE 事件流</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </el-card>
|
|
|
+
|
|
|
+ <div class="chat-layout">
|
|
|
+ <!-- 左侧:输入区 -->
|
|
|
+ <div class="left-panel">
|
|
|
+ <el-card shadow="never" class="mb-3">
|
|
|
+ <template #header>
|
|
|
+ <div class="flex items-center justify-between">
|
|
|
+ <span>请求参数</span>
|
|
|
+ <el-switch
|
|
|
+ v-model="dataSource"
|
|
|
+ active-text="后端"
|
|
|
+ inactive-text="Mock"
|
|
|
+ size="small"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ <el-form label-position="top" size="default">
|
|
|
+ <el-form-item label="设备 ID">
|
|
|
+ <el-input v-model="deviceId" placeholder="EMOON-KIOSK-001" />
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="会话 ID(可选,留空新建)">
|
|
|
+ <el-input v-model="conversationId" placeholder="conv_xxx" clearable />
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="消息文本">
|
|
|
+ <el-input
|
|
|
+ v-model="inputText"
|
|
|
+ type="textarea"
|
|
|
+ :rows="3"
|
|
|
+ placeholder="输入查询,如:挂明天呼吸科主任医师的号"
|
|
|
+ @keydown.ctrl.enter="handleSend"
|
|
|
+ />
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item>
|
|
|
+ <div class="flex gap-2">
|
|
|
+ <el-button type="primary" :loading="sending" @click="handleSend">
|
|
|
+ 发送 (Ctrl+Enter)
|
|
|
+ </el-button>
|
|
|
+ <el-button v-if="sending" type="danger" @click="handleAbort">
|
|
|
+ 中断
|
|
|
+ </el-button>
|
|
|
+ <el-button @click="handleClear">清空</el-button>
|
|
|
+ </div>
|
|
|
+ </el-form-item>
|
|
|
+ </el-form>
|
|
|
+ </el-card>
|
|
|
+
|
|
|
+ <!-- 交互模式 -->
|
|
|
+ <el-card shadow="never" class="mb-3">
|
|
|
+ <template #header>结构化交互(可选)</template>
|
|
|
+ <el-form label-position="top" size="default">
|
|
|
+ <el-form-item label="交互类型">
|
|
|
+ <el-select v-model="interaction.interactionType" clearable placeholder="留空则使用文本模式">
|
|
|
+ <el-option label="SELECT_CANDIDATE" value="SELECT_CANDIDATE" />
|
|
|
+ <el-option label="APPLY_FILTER" value="APPLY_FILTER" />
|
|
|
+ <el-option label="CHANGE_CONSTRAINT" value="CHANGE_CONSTRAINT" />
|
|
|
+ </el-select>
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="resultRef">
|
|
|
+ <el-input v-model="interaction.resultRef" placeholder="result_001" />
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="resultVersion">
|
|
|
+ <el-input-number v-model="interaction.resultVersion" :min="1" />
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="candidateId">
|
|
|
+ <el-input v-model="interaction.candidateId" placeholder="slot_001" />
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item>
|
|
|
+ <el-button :loading="sending" @click="handleSendInteraction">发送交互</el-button>
|
|
|
+ </el-form-item>
|
|
|
+ </el-form>
|
|
|
+ </el-card>
|
|
|
+
|
|
|
+ <!-- 断线恢复 -->
|
|
|
+ <el-card shadow="never">
|
|
|
+ <template #header>断线恢复</template>
|
|
|
+ <el-form label-position="top" size="default">
|
|
|
+ <el-form-item label="会话 ID">
|
|
|
+ <el-input v-model="recovery.conversationId" placeholder="conv_xxx" />
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="轮次 ID">
|
|
|
+ <el-input v-model="recovery.turnId" placeholder="turn_xxx" />
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item>
|
|
|
+ <el-button :loading="recovering" @click="handleRecover">查询</el-button>
|
|
|
+ </el-form-item>
|
|
|
+ </el-form>
|
|
|
+ <el-divider />
|
|
|
+ <div v-if="recovery.result" class="recovery-result">
|
|
|
+ <div><strong>conversationId:</strong> {{ recovery.result.conversationId }}</div>
|
|
|
+ <div><strong>turnId:</strong> {{ recovery.result.turnId }}</div>
|
|
|
+ <div><strong>recoveredAt:</strong> {{ recovery.result.recoveredAt }}</div>
|
|
|
+ <div><strong>answer:</strong> {{ recovery.result.lastSemanticResponse?.answer }}</div>
|
|
|
+ <div v-if="recovery.result.lastSemanticResponse?.task">
|
|
|
+ <strong>task:</strong>
|
|
|
+ {{ recovery.result.lastSemanticResponse.task.status }}
|
|
|
+ / {{ recovery.result.lastSemanticResponse.task.currentStage }}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div v-if="recovery.error" class="text-red-500 text-sm">{{ recovery.error }}</div>
|
|
|
+ </el-card>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 右侧:事件日志 -->
|
|
|
+ <div class="right-panel">
|
|
|
+ <!-- 错误降级 Banner -->
|
|
|
+ <el-alert
|
|
|
+ v-if="lastError"
|
|
|
+ :title="lastError.message"
|
|
|
+ :type="lastError.retryable ? 'warning' : 'error'"
|
|
|
+ :closable="true"
|
|
|
+ show-icon
|
|
|
+ class="mb-3"
|
|
|
+ @close="lastError = null"
|
|
|
+ >
|
|
|
+ <template v-if="lastError.code === 'QUERY_TIMEOUT'">
|
|
|
+ <span class="text-sm">查询超时,您可以重试或转传统挂号入口</span>
|
|
|
+ </template>
|
|
|
+ <template v-if="lastError.code === 'ENGINE_UNAVAILABLE' || lastError.code === 'TOOL_UNAVAILABLE'">
|
|
|
+ <span class="text-sm">AI 服务暂不可用,您可以使用传统挂号方式</span>
|
|
|
+ </template>
|
|
|
+ <template #default>
|
|
|
+ <div class="flex gap-2 mt-1">
|
|
|
+ <el-button v-if="lastError.retryable" size="small" type="warning" @click="handleRetry">
|
|
|
+ 重试
|
|
|
+ </el-button>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </el-alert>
|
|
|
+
|
|
|
+ <el-card shadow="never" class="mb-3">
|
|
|
+ <template #header>
|
|
|
+ <div class="flex items-center justify-between">
|
|
|
+ <span>SSE 事件流</span>
|
|
|
+ <span class="text-gray-400 text-xs">{{ events.length }} 条</span>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ <div class="event-log" ref="eventLogRef">
|
|
|
+ <div v-if="events.length === 0" class="text-gray-400 text-center py-10">
|
|
|
+ 等待请求…
|
|
|
+ </div>
|
|
|
+ <div
|
|
|
+ v-for="(evt, i) in events"
|
|
|
+ :key="i"
|
|
|
+ :class="['event-entry', evt.css]"
|
|
|
+ >
|
|
|
+ <span class="event-tag">{{ evt.name }}</span>
|
|
|
+ <pre class="event-data">{{ evt.raw }}</pre>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </el-card>
|
|
|
+
|
|
|
+ <el-card shadow="never" class="mb-3">
|
|
|
+ <template #header>完整回答</template>
|
|
|
+ <div class="answer-box">{{ answer || '(暂无)' }}</div>
|
|
|
+ </el-card>
|
|
|
+
|
|
|
+ <!-- 候选列表 -->
|
|
|
+ <CandidateList
|
|
|
+ :result-refs="candidateRefs"
|
|
|
+ :selected-id="activeCandidateId"
|
|
|
+ @select="handleCandidateSelect"
|
|
|
+ />
|
|
|
+
|
|
|
+ <!-- Command 状态面板(使用独立组件) -->
|
|
|
+ <CommandStatus
|
|
|
+ class="mt-3"
|
|
|
+ :command="activeCommand"
|
|
|
+ @update="onCommandUpdate"
|
|
|
+ />
|
|
|
+
|
|
|
+ <!-- 快捷测试 -->
|
|
|
+ <el-card shadow="never" class="mt-3" v-if="candidateRefs.length > 0">
|
|
|
+ <template #header>快捷交互测试</template>
|
|
|
+ <div class="flex gap-2 flex-wrap">
|
|
|
+ <el-button size="small" @click="quickSelect(2)">选第二个</el-button>
|
|
|
+ <el-button size="small" @click="quickSelect(1)">选第一个</el-button>
|
|
|
+ <el-button size="small" @click="quickSelect(3)">选第三个</el-button>
|
|
|
+ <el-button size="small" type="warning" @click="quickSendText('选第二个')">
|
|
|
+ 发送"选第二个"文本
|
|
|
+ </el-button>
|
|
|
+ </div>
|
|
|
+ <div class="text-xs text-gray-400 mt-2">
|
|
|
+ 「选第二个」和「点击第二张卡片」最终发送相同的 interaction 请求
|
|
|
+ </div>
|
|
|
+ </el-card>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup lang="ts">
|
|
|
+import { reactive, ref, nextTick, shallowRef } from 'vue';
|
|
|
+import { chatStream, getTurn } from '@/api/v2';
|
|
|
+import type {
|
|
|
+ AgentInteractionRequest,
|
|
|
+ SemanticResponse,
|
|
|
+ ResultReference,
|
|
|
+ Command,
|
|
|
+ CommandPreparedData,
|
|
|
+ CommandCompletedData,
|
|
|
+ TaskUpdatedData,
|
|
|
+ PresentationData,
|
|
|
+} from '@/api/v2/types';
|
|
|
+import CandidateList, { type CandidateDisplay } from './CandidateList.vue';
|
|
|
+import CommandStatus from './CommandStatus.vue';
|
|
|
+
|
|
|
+// ---- 请求参数 ----
|
|
|
+const dataSource = ref(true); // true=后端, false=Mock
|
|
|
+const deviceId = ref('EMOON-KIOSK-001');
|
|
|
+const conversationId = ref('');
|
|
|
+const inputText = ref('');
|
|
|
+const sending = ref(false);
|
|
|
+let abortController: AbortController | null = null;
|
|
|
+
|
|
|
+// ---- 交互参数 ----
|
|
|
+const interaction = reactive({
|
|
|
+ interactionType: '' as string,
|
|
|
+ resultRef: '',
|
|
|
+ resultVersion: 1,
|
|
|
+ candidateId: '',
|
|
|
+});
|
|
|
+
|
|
|
+// ---- 候选列表 ----
|
|
|
+const candidateRefs = ref<ResultReference[]>([]);
|
|
|
+const activeCandidateId = ref('');
|
|
|
+
|
|
|
+// ---- 断线恢复 ----
|
|
|
+const recovery = reactive({
|
|
|
+ conversationId: '',
|
|
|
+ turnId: '',
|
|
|
+ result: null as any,
|
|
|
+ error: '',
|
|
|
+});
|
|
|
+const recovering = ref(false);
|
|
|
+
|
|
|
+// ---- Command 状态(委托 CommandStatus 组件) ----
|
|
|
+const activeCommand = ref<Command | null>(null);
|
|
|
+
|
|
|
+function onCommandUpdate(cmd: Command) {
|
|
|
+ activeCommand.value = cmd;
|
|
|
+ addEvent('command_update', cmd);
|
|
|
+}
|
|
|
+
|
|
|
+// ---- 错误降级状态 ----
|
|
|
+const lastError = ref<{ message: string; code: string; retryable: boolean } | null>(null);
|
|
|
+const lastRequest = ref<AgentInteractionRequest | null>(null); // 用于重试
|
|
|
+
|
|
|
+// ---- 事件日志 ----
|
|
|
+interface LogEntry {
|
|
|
+ name: string;
|
|
|
+ raw: string;
|
|
|
+ css: string;
|
|
|
+}
|
|
|
+const events = shallowRef<LogEntry[]>([]);
|
|
|
+const answer = ref('');
|
|
|
+const eventLogRef = ref<HTMLElement>();
|
|
|
+
|
|
|
+const CSS_MAP: Record<string, string> = {
|
|
|
+ message_delta: 'evt-delta',
|
|
|
+ semantic_result: 'evt-result',
|
|
|
+ completed: 'evt-ok',
|
|
|
+ error: 'evt-err',
|
|
|
+};
|
|
|
+
|
|
|
+function addEvent(name: string, data: any) {
|
|
|
+ const raw = JSON.stringify(data, null, 2);
|
|
|
+ events.value = [...events.value, { name, raw, css: CSS_MAP[name] ?? '' }];
|
|
|
+ nextTick(() => {
|
|
|
+ if (eventLogRef.value) {
|
|
|
+ eventLogRef.value.scrollTop = eventLogRef.value.scrollHeight;
|
|
|
+ }
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+// ---- 发送文本 ----
|
|
|
+async function handleSend() {
|
|
|
+ if (!inputText.value.trim()) return;
|
|
|
+ await doSend({
|
|
|
+ deviceId: deviceId.value,
|
|
|
+ conversationId: conversationId.value || undefined,
|
|
|
+ message: { text: inputText.value.trim() },
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+// ---- 发送交互 ----
|
|
|
+async function handleSendInteraction() {
|
|
|
+ if (!interaction.interactionType) return;
|
|
|
+ await doSend({
|
|
|
+ deviceId: deviceId.value,
|
|
|
+ conversationId: conversationId.value || undefined,
|
|
|
+ interaction: {
|
|
|
+ interactionType: interaction.interactionType as any,
|
|
|
+ resultRef: interaction.resultRef,
|
|
|
+ resultVersion: interaction.resultVersion,
|
|
|
+ candidateId: interaction.candidateId,
|
|
|
+ clientActionId: 'act_' + Date.now(),
|
|
|
+ },
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+async function doSend(req: AgentInteractionRequest) {
|
|
|
+ events.value = [];
|
|
|
+ answer.value = '';
|
|
|
+ candidateRefs.value = [];
|
|
|
+ activeCandidateId.value = '';
|
|
|
+ lastError.value = null;
|
|
|
+ lastRequest.value = req;
|
|
|
+ sending.value = true;
|
|
|
+ abortController = new AbortController();
|
|
|
+
|
|
|
+ try {
|
|
|
+ await chatStream(
|
|
|
+ req,
|
|
|
+ {
|
|
|
+ onDelta(text, _turnId) {
|
|
|
+ answer.value += text;
|
|
|
+ addEvent('message_delta', { text, turnId: _turnId });
|
|
|
+ },
|
|
|
+ onSemanticResult(resp: SemanticResponse) {
|
|
|
+ addEvent('semantic_result', resp);
|
|
|
+ answer.value = resp.answer;
|
|
|
+ if (resp.task) {
|
|
|
+ addEvent('task_summary', resp.task);
|
|
|
+ }
|
|
|
+ // 捕获候选引用(驱动候选列表组件)
|
|
|
+ if (resp.resultRefs && resp.resultRefs.length > 0) {
|
|
|
+ candidateRefs.value = resp.resultRefs;
|
|
|
+ }
|
|
|
+ },
|
|
|
+ onCompleted(convId, turnId) {
|
|
|
+ addEvent('completed', { conversationId: convId, turnId });
|
|
|
+ if (convId) conversationId.value = convId;
|
|
|
+ recovery.conversationId = convId;
|
|
|
+ recovery.turnId = turnId;
|
|
|
+ },
|
|
|
+ onError(msg, code) {
|
|
|
+ addEvent('error', { message: msg, code });
|
|
|
+ // QUERY_TIMEOUT / ENGINE_UNAVAILABLE 等可重试错误
|
|
|
+ const retryableCodes = ['QUERY_TIMEOUT', 'ENGINE_UNAVAILABLE', 'TOOL_UNAVAILABLE', 'STREAM_ERROR'];
|
|
|
+ lastError.value = { message: msg, code, retryable: retryableCodes.includes(code) };
|
|
|
+ },
|
|
|
+ onPresentation(data: PresentationData) {
|
|
|
+ addEvent('presentation', data);
|
|
|
+ },
|
|
|
+ onTaskUpdated(data: TaskUpdatedData) {
|
|
|
+ addEvent('task_updated', data);
|
|
|
+ },
|
|
|
+ onCommandPrepared(data: CommandPreparedData) {
|
|
|
+ addEvent('command_prepared', data);
|
|
|
+ activeCommand.value = data.command;
|
|
|
+ },
|
|
|
+ onCommandCompleted(data: CommandCompletedData) {
|
|
|
+ addEvent('command_completed', data);
|
|
|
+ if (activeCommand.value && activeCommand.value.commandId === data.commandId) {
|
|
|
+ activeCommand.value = {
|
|
|
+ ...activeCommand.value,
|
|
|
+ status: data.status,
|
|
|
+ result: data.result,
|
|
|
+ error: data.error,
|
|
|
+ };
|
|
|
+ }
|
|
|
+ },
|
|
|
+ },
|
|
|
+ abortController.signal,
|
|
|
+ );
|
|
|
+ } catch (e: any) {
|
|
|
+ if (e?.name !== 'AbortError') {
|
|
|
+ addEvent('error', { message: e?.message ?? '请求失败', code: 'CLIENT_ERROR' });
|
|
|
+ }
|
|
|
+ } finally {
|
|
|
+ sending.value = false;
|
|
|
+ abortController = null;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function handleAbort() {
|
|
|
+ abortController?.abort();
|
|
|
+}
|
|
|
+
|
|
|
+function handleClear() {
|
|
|
+ events.value = [];
|
|
|
+ answer.value = '';
|
|
|
+ candidateRefs.value = [];
|
|
|
+ activeCandidateId.value = '';
|
|
|
+ lastError.value = null;
|
|
|
+ lastRequest.value = null;
|
|
|
+}
|
|
|
+
|
|
|
+function handleRetry() {
|
|
|
+ if (lastRequest.value) {
|
|
|
+ lastError.value = null;
|
|
|
+ doSend(lastRequest.value);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// ---- 候选点击 → 发送 SELECT_CANDIDATE ----
|
|
|
+function handleCandidateSelect(c: CandidateDisplay) {
|
|
|
+ activeCandidateId.value = c.id;
|
|
|
+
|
|
|
+ // 自动填充交互表单
|
|
|
+ interaction.interactionType = 'SELECT_CANDIDATE';
|
|
|
+ interaction.resultRef = c.resultRef;
|
|
|
+ interaction.resultVersion = c.resultVersion;
|
|
|
+ interaction.candidateId = c.id;
|
|
|
+
|
|
|
+ addEvent('click', { action: 'SELECT_CANDIDATE', candidate: c });
|
|
|
+
|
|
|
+ // 直接发送(等同于点击选择)
|
|
|
+ doSend({
|
|
|
+ deviceId: deviceId.value,
|
|
|
+ conversationId: conversationId.value || undefined,
|
|
|
+ interaction: {
|
|
|
+ interactionType: 'SELECT_CANDIDATE',
|
|
|
+ resultRef: c.resultRef,
|
|
|
+ resultVersion: c.resultVersion,
|
|
|
+ candidateId: c.id,
|
|
|
+ clientActionId: 'click_' + Date.now(),
|
|
|
+ },
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+// ---- 快捷测试:按序号选择(模拟"选第二个") ----
|
|
|
+function quickSelect(nth: number) {
|
|
|
+ const ref0 = candidateRefs.value[0];
|
|
|
+ if (!ref0) return;
|
|
|
+ // Mock ID 规则:slot_001 ~ slot_006
|
|
|
+ const cid = `slot_${String(nth).padStart(3, '0')}`;
|
|
|
+ addEvent('quick', { action: `选第${nth}个`, candidateId: cid });
|
|
|
+
|
|
|
+ doSend({
|
|
|
+ deviceId: deviceId.value,
|
|
|
+ conversationId: conversationId.value || undefined,
|
|
|
+ interaction: {
|
|
|
+ interactionType: 'SELECT_CANDIDATE',
|
|
|
+ resultRef: ref0.resultRef,
|
|
|
+ resultVersion: ref0.version,
|
|
|
+ candidateId: cid,
|
|
|
+ clientActionId: 'quick_' + Date.now(),
|
|
|
+ },
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+// ---- 快捷测试:发送"选第二个"文本 ----
|
|
|
+function quickSendText(text: string) {
|
|
|
+ inputText.value = text;
|
|
|
+ doSend({
|
|
|
+ deviceId: deviceId.value,
|
|
|
+ conversationId: conversationId.value || undefined,
|
|
|
+ message: { text },
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+// ---- 断线恢复 ----
|
|
|
+async function handleRecover() {
|
|
|
+ if (!recovery.conversationId || !recovery.turnId) return;
|
|
|
+ recovering.value = true;
|
|
|
+ recovery.error = '';
|
|
|
+ recovery.result = null;
|
|
|
+ try {
|
|
|
+ const res = await getTurn(recovery.conversationId, recovery.turnId);
|
|
|
+ recovery.result = (res as any).data ?? res;
|
|
|
+ } catch (e: any) {
|
|
|
+ recovery.error = e?.message ?? '查询失败';
|
|
|
+ } finally {
|
|
|
+ recovering.value = false;
|
|
|
+ }
|
|
|
+}
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.v2-chat-page {
|
|
|
+ padding: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.head .title {
|
|
|
+ font-size: 18px;
|
|
|
+ font-weight: 600;
|
|
|
+}
|
|
|
+.head .sub {
|
|
|
+ font-size: 13px;
|
|
|
+ color: #909399;
|
|
|
+ margin-top: 4px;
|
|
|
+}
|
|
|
+
|
|
|
+.chat-layout {
|
|
|
+ display: flex;
|
|
|
+ gap: 12px;
|
|
|
+ align-items: flex-start;
|
|
|
+}
|
|
|
+
|
|
|
+.left-panel {
|
|
|
+ width: 420px;
|
|
|
+ flex-shrink: 0;
|
|
|
+}
|
|
|
+
|
|
|
+.right-panel {
|
|
|
+ flex: 1;
|
|
|
+ min-width: 0;
|
|
|
+}
|
|
|
+
|
|
|
+.event-log {
|
|
|
+ max-height: 500px;
|
|
|
+ overflow-y: auto;
|
|
|
+ font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
|
|
+ font-size: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.event-entry {
|
|
|
+ padding: 6px 8px;
|
|
|
+ border-left: 3px solid #909399;
|
|
|
+ margin-bottom: 4px;
|
|
|
+ background: #fafafa;
|
|
|
+ border-radius: 2px;
|
|
|
+}
|
|
|
+
|
|
|
+.event-entry.evt-delta { border-left-color: #60a5fa; }
|
|
|
+.event-entry.evt-result { border-left-color: #4ade80; background: rgba(74,222,128,.05); }
|
|
|
+.event-entry.evt-ok { border-left-color: #a78bfa; }
|
|
|
+.event-entry.evt-err { border-left-color: #f87171; background: rgba(248,113,113,.08); }
|
|
|
+
|
|
|
+.event-tag {
|
|
|
+ display: inline-block;
|
|
|
+ font-weight: 600;
|
|
|
+ font-size: 11px;
|
|
|
+ color: #374151;
|
|
|
+ margin-bottom: 2px;
|
|
|
+}
|
|
|
+
|
|
|
+.event-data {
|
|
|
+ margin: 0;
|
|
|
+ white-space: pre-wrap;
|
|
|
+ word-break: break-all;
|
|
|
+ color: #6b7280;
|
|
|
+}
|
|
|
+
|
|
|
+.answer-box {
|
|
|
+ min-height: 80px;
|
|
|
+ white-space: pre-wrap;
|
|
|
+ color: #1f2937;
|
|
|
+ line-height: 1.7;
|
|
|
+}
|
|
|
+
|
|
|
+.recovery-result {
|
|
|
+ font-size: 13px;
|
|
|
+ line-height: 1.8;
|
|
|
+}
|
|
|
+
|
|
|
+.flex { display: flex; }
|
|
|
+.items-center { align-items: center; }
|
|
|
+.justify-between { justify-content: space-between; }
|
|
|
+.gap-2 { gap: 8px; }
|
|
|
+.flex-wrap { flex-wrap: wrap; }
|
|
|
+.text-red-500 { color: #ef4444; }
|
|
|
+.text-green-600 { color: #16a34a; }
|
|
|
+.text-gray-400 { color: #9ca3af; }
|
|
|
+.text-sm { font-size: 13px; }
|
|
|
+.text-xs { font-size: 12px; }
|
|
|
+.text-center { text-align: center; }
|
|
|
+.py-10 { padding-top: 40px; padding-bottom: 40px; }
|
|
|
+.mb-3 { margin-bottom: 12px; }
|
|
|
+.mt-2 { margin-top: 8px; }
|
|
|
+.mt-3 { margin-top: 12px; }
|
|
|
+</style>
|