feat: add mid-platform frontend pages and APIs [AC-IDMP-01~20]

- Add mid-platform API client (mid-platform.ts)
- Add mid-platform playground page for testing
- Add share page for shared session viewing
This commit is contained in:
MerCry 2026-03-05 18:14:10 +08:00
parent b4eb98e7c4
commit 9e77923d3a
3 changed files with 1025 additions and 0 deletions

View File

@ -0,0 +1,199 @@
import request from '@/utils/request'
export type MidMode = 'agent' | 'micro_flow' | 'fixed' | 'transfer'
export type SessionMode = 'BOT_ACTIVE' | 'HUMAN_ACTIVE'
export interface DialogueMessage {
role: 'user' | 'assistant' | 'human'
content: string
}
export interface InterruptedSegment {
segment_id: string
content: string
}
export interface DialogueRequest {
session_id: string
user_id?: string
user_message: string
history: DialogueMessage[]
interrupted_segments?: InterruptedSegment[]
feature_flags?: {
agent_enabled?: boolean
rollback_to_legacy?: boolean
}
}
export interface ToolCallTrace {
tool_name: string
tool_type?: 'internal' | 'mcp'
registry_version?: string
auth_applied?: boolean
duration_ms: number
status: 'ok' | 'timeout' | 'error' | 'rejected'
error_code?: string
args_digest?: string
result_digest?: string
}
export interface DialogueResponse {
segments: Array<{
segment_id: string
text: string
delay_after: number
}>
trace: {
mode: MidMode
intent?: string
request_id?: string
generation_id?: string
guardrail_triggered?: boolean
fallback_reason_code?: string
react_iterations?: number
timeout_profile?: {
per_tool_timeout_ms?: number
end_to_end_timeout_ms?: number
}
high_risk_policy_set?: string[]
tools_used?: string[]
tool_calls?: ToolCallTrace[]
metrics_snapshot?: {
task_completion_rate?: number
slot_completion_rate?: number
wrong_transfer_rate?: number
no_recall_rate?: number
avg_latency_ms?: number
}
}
}
export function respondDialogue(data: DialogueRequest): Promise<DialogueResponse> {
return request({
url: '/mid/dialogue/respond',
method: 'post',
data
})
}
export function switchSessionMode(sessionId: string, mode: SessionMode, reason?: string): Promise<{ session_id: string; mode: SessionMode }> {
return request({
url: `/mid/sessions/${sessionId}/mode`,
method: 'post',
data: {
mode,
reason
}
})
}
export function reportMessages(data: {
session_id: string
messages: Array<{
role: 'user' | 'assistant' | 'human' | 'system'
content: string
source: 'bot' | 'human' | 'channel'
timestamp: string
segment_id?: string
}>
}): Promise<void> {
return request({
url: '/mid/messages/report',
method: 'post',
data
})
}
export interface CreateShareRequest {
title?: string
description?: string
expires_in_days?: number
max_concurrent_users?: number
}
export interface ShareResponse {
share_token: string
share_url: string
expires_at: string
title?: string
description?: string
max_concurrent_users: number
}
export interface ShareListItem {
share_token: string
share_url: string
title?: string
description?: string
expires_at: string
is_active: boolean
max_concurrent_users: number
current_users: number
created_at: string
}
export interface ShareListResponse {
shares: ShareListItem[]
}
export interface SharedSessionInfo {
session_id: string
title?: string
description?: string
expires_at: string
max_concurrent_users: number
current_users: number
history: DialogueMessage[]
}
export function createShare(sessionId: string, data: CreateShareRequest = {}): Promise<ShareResponse> {
return request({
url: `/mid/sessions/${sessionId}/share`,
method: 'post',
data
})
}
export function getSharedSession(shareToken: string): Promise<SharedSessionInfo> {
return request({
url: `/mid/share/${shareToken}`,
method: 'get'
})
}
export function listShares(sessionId: string, includeExpired = false): Promise<ShareListResponse> {
return request({
url: `/mid/sessions/${sessionId}/shares`,
method: 'get',
params: { include_expired: includeExpired }
})
}
export function deleteShare(shareToken: string): Promise<{ success: boolean; message: string }> {
return request({
url: `/mid/share/${shareToken}`,
method: 'delete'
})
}
export function joinSharedSession(shareToken: string): Promise<SharedSessionInfo> {
return request({
url: `/mid/share/${shareToken}/join`,
method: 'post'
})
}
export function leaveSharedSession(shareToken: string): Promise<{ success: boolean; current_users: number }> {
return request({
url: `/mid/share/${shareToken}/leave`,
method: 'post'
})
}
export function sendSharedMessage(shareToken: string, userMessage: string): Promise<{ success: boolean; message: string; session_id: string }> {
return request({
url: `/mid/share/${shareToken}/message`,
method: 'post',
data: { user_message: userMessage }
})
}

View File

@ -0,0 +1,528 @@
<template>
<div class="playground-page">
<el-card shadow="never" class="control-card">
<template #header>
<div class="header-row">
<span>中台联调工作台</span>
<el-tag type="info">真人模拟 + 中台真实接口</el-tag>
</div>
</template>
<el-form inline>
<el-form-item label="Session ID">
<el-input v-model="sessionId" style="width: 220px" />
</el-form-item>
<el-form-item label="User ID">
<el-input v-model="userId" style="width: 180px" />
</el-form-item>
<el-form-item label="Agent 灰度">
<el-switch v-model="agentEnabled" />
</el-form-item>
<el-form-item label="回滚传统链路">
<el-switch v-model="rollbackToLegacy" />
</el-form-item>
<el-form-item label="会话模式">
<el-select v-model="sessionMode" style="width: 150px">
<el-option label="BOT_ACTIVE" value="BOT_ACTIVE" />
<el-option label="HUMAN_ACTIVE" value="HUMAN_ACTIVE" />
</el-select>
</el-form-item>
<el-form-item>
<el-button :loading="switchingMode" @click="handleSwitchMode">应用模式</el-button>
</el-form-item>
</el-form>
</el-card>
<el-row :gutter="16">
<el-col :span="16">
<el-card shadow="never" class="chat-card">
<template #header>
<div class="header-row">
<span>实时对话</span>
<el-space>
<el-button size="small" @click="clearConversation">清空</el-button>
<el-button size="small" type="warning" :loading="reporting" @click="handleReportMessages">上报消息</el-button>
<el-button size="small" type="primary" @click="showShareDialog">分享对话</el-button>
</el-space>
</div>
</template>
<div class="chat-list">
<div
v-for="(msg, idx) in conversation"
:key="idx"
class="chat-item"
:class="`role-${msg.role}`"
>
<div class="meta">
<el-tag size="small" :type="tagType(msg.role)">{{ msg.role }}</el-tag>
<span class="time">{{ msg.timestamp }}</span>
<span v-if="msg.segment_id" class="segment">{{ msg.segment_id }}</span>
</div>
<div class="content">{{ msg.content }}</div>
</div>
</div>
<div class="composer">
<el-input
v-model="userInput"
type="textarea"
:rows="3"
placeholder="输入用户消息后发送,触发 /mid/dialogue/respond"
/>
<div class="composer-actions">
<el-button type="primary" :loading="sending" @click="handleSend">发送</el-button>
</div>
</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card shadow="never" class="trace-card">
<template #header>
<div class="header-row">
<span>Trace 观测</span>
</div>
</template>
<div v-if="lastTrace">
<el-descriptions :column="1" border size="small">
<el-descriptions-item label="mode">{{ lastTrace.mode }}</el-descriptions-item>
<el-descriptions-item label="intent">{{ lastTrace.intent || '-' }}</el-descriptions-item>
<el-descriptions-item label="request_id">{{ lastTrace.request_id || '-' }}</el-descriptions-item>
<el-descriptions-item label="generation_id">{{ lastTrace.generation_id || '-' }}</el-descriptions-item>
<el-descriptions-item label="fallback_reason_code">{{ lastTrace.fallback_reason_code || '-' }}</el-descriptions-item>
<el-descriptions-item label="react_iterations">{{ lastTrace.react_iterations ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="guardrail_triggered">{{ lastTrace.guardrail_triggered ?? '-' }}</el-descriptions-item>
</el-descriptions>
<div class="json-panel">
<div class="json-title">tool_calls</div>
<pre>{{ JSON.stringify(lastTrace.tool_calls || [], null, 2) }}</pre>
</div>
<div class="json-panel">
<div class="json-title">metrics_snapshot</div>
<pre>{{ JSON.stringify(lastTrace.metrics_snapshot || {}, null, 2) }}</pre>
</div>
</div>
<el-empty v-else description="暂无 Trace" :image-size="90" />
</el-card>
<el-card shadow="never" class="shares-card" style="margin-top: 16px">
<template #header>
<div class="header-row">
<span>分享链接</span>
<el-button size="small" text @click="loadShares">刷新</el-button>
</div>
</template>
<div v-if="shares.length" class="shares-list">
<div v-for="share in shares" :key="share.share_token" class="share-item">
<div class="share-info">
<span class="share-title">{{ share.title || '无标题' }}</span>
<el-tag size="small" :type="share.is_active ? 'success' : 'info'">
{{ share.is_active ? '有效' : '已失效' }}
</el-tag>
</div>
<div class="share-meta">
<span>在线: {{ share.current_users }}/{{ share.max_concurrent_users }}</span>
<span>过期: {{ formatShareExpires(share.expires_at) }}</span>
</div>
<div class="share-actions">
<el-button size="small" text type="primary" @click="copyShareUrl(share.share_url)">复制链接</el-button>
<el-button size="small" text type="danger" @click="handleDeleteShare(share.share_token)">删除</el-button>
</div>
</div>
</div>
<el-empty v-else description="暂无分享链接" :image-size="60" />
</el-card>
</el-col>
</el-row>
<el-dialog v-model="shareDialogVisible" title="分享对话" width="500px">
<el-form :model="shareForm" label-width="120px">
<el-form-item label="标题">
<el-input v-model="shareForm.title" placeholder="可选,分享标题" maxlength="255" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="shareForm.description" type="textarea" :rows="2" placeholder="可选,分享描述" maxlength="1000" />
</el-form-item>
<el-form-item label="有效期">
<el-select v-model="shareForm.expires_in_days" style="width: 100%">
<el-option label="1 天" :value="1" />
<el-option label="7 天" :value="7" />
<el-option label="30 天" :value="30" />
<el-option label="90 天" :value="90" />
</el-select>
</el-form-item>
<el-form-item label="最大在线人数">
<el-input-number v-model="shareForm.max_concurrent_users" :min="1" :max="100" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="shareDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="creatingShare" @click="handleCreateShare">创建分享</el-button>
</template>
</el-dialog>
<el-dialog v-model="shareResultVisible" title="分享成功" width="500px">
<el-result icon="success" title="分享链接已创建" :sub-title="shareResult?.share_url">
<template #extra>
<el-input :model-value="shareResult?.share_url" readonly>
<template #append>
<el-button @click="copyShareUrl(shareResult?.share_url || '')">复制</el-button>
</template>
</el-input>
</template>
</el-result>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import {
respondDialogue,
switchSessionMode,
reportMessages,
createShare,
listShares,
deleteShare,
type DialogueMessage,
type DialogueResponse,
type SessionMode,
type ShareResponse,
type ShareListItem
} from '@/api/mid-platform'
type ChatRole = 'user' | 'assistant' | 'human' | 'system'
interface ChatItem {
role: ChatRole
content: string
timestamp: string
segment_id?: string
}
const sessionId = ref(`sess_${Date.now()}`)
const userId = ref(`user_${Date.now()}`)
const sessionMode = ref<SessionMode>('BOT_ACTIVE')
const agentEnabled = ref(true)
const rollbackToLegacy = ref(false)
const sending = ref(false)
const switchingMode = ref(false)
const reporting = ref(false)
const userInput = ref('')
const conversation = ref<ChatItem[]>([])
const lastTrace = ref<DialogueResponse['trace'] | null>(null)
const shares = ref<ShareListItem[]>([])
const shareDialogVisible = ref(false)
const shareResultVisible = ref(false)
const creatingShare = ref(false)
const shareResult = ref<ShareResponse | null>(null)
const shareForm = ref({
title: '',
description: '',
expires_in_days: 7,
max_concurrent_users: 10
})
const now = () => new Date().toLocaleTimeString()
const tagType = (role: ChatRole) => {
if (role === 'user') return 'info'
if (role === 'assistant') return 'success'
if (role === 'human') return 'warning'
return ''
}
const toHistory = (): DialogueMessage[] => {
return conversation.value
.filter((m) => m.role === 'user' || m.role === 'assistant' || m.role === 'human')
.map((m) => ({ role: m.role as 'user' | 'assistant' | 'human', content: m.content }))
}
const handleSend = async () => {
const content = userInput.value.trim()
if (!content) {
ElMessage.warning('请输入用户消息')
return
}
conversation.value.push({ role: 'user', content, timestamp: now() })
userInput.value = ''
sending.value = true
try {
const res = await respondDialogue({
session_id: sessionId.value,
user_id: userId.value,
user_message: content,
history: toHistory(),
feature_flags: {
agent_enabled: agentEnabled.value,
rollback_to_legacy: rollbackToLegacy.value
}
})
lastTrace.value = res.trace
for (const seg of res.segments || []) {
conversation.value.push({
role: 'assistant',
content: seg.text,
segment_id: seg.segment_id,
timestamp: now()
})
}
if (!res.segments?.length) {
ElMessage.warning('接口返回了空 segments')
}
} catch {
ElMessage.error('调用中台 respond 接口失败')
} finally {
sending.value = false
}
}
const handleSwitchMode = async () => {
switchingMode.value = true
try {
await switchSessionMode(sessionId.value, sessionMode.value, 'playground manual switch')
ElMessage.success(`会话模式已切换为 ${sessionMode.value}`)
} catch {
ElMessage.error('切换会话模式失败')
} finally {
switchingMode.value = false
}
}
const handleReportMessages = async () => {
if (!conversation.value.length) {
ElMessage.warning('暂无可上报消息')
return
}
reporting.value = true
try {
await reportMessages({
session_id: sessionId.value,
messages: conversation.value.map((m) => ({
role: m.role,
content: m.content,
source: m.role === 'human' ? 'human' : m.role === 'assistant' ? 'bot' : 'channel',
timestamp: new Date().toISOString(),
segment_id: m.segment_id
}))
})
ElMessage.success('消息上报成功')
} catch {
ElMessage.error('消息上报失败')
} finally {
reporting.value = false
}
}
const clearConversation = () => {
conversation.value = []
lastTrace.value = null
}
const showShareDialog = () => {
shareForm.value = {
title: '',
description: '',
expires_in_days: 7,
max_concurrent_users: 10
}
shareDialogVisible.value = true
}
const handleCreateShare = async () => {
creatingShare.value = true
try {
const result = await createShare(sessionId.value, shareForm.value)
shareResult.value = result
shareDialogVisible.value = false
shareResultVisible.value = true
await loadShares()
ElMessage.success('分享链接创建成功')
} catch {
ElMessage.error('创建分享链接失败')
} finally {
creatingShare.value = false
}
}
const loadShares = async () => {
try {
const result = await listShares(sessionId.value, true)
shares.value = result.shares
} catch {
// ignore
}
}
const handleDeleteShare = async (shareToken: string) => {
try {
await deleteShare(shareToken)
ElMessage.success('分享链接已删除')
await loadShares()
} catch {
ElMessage.error('删除分享链接失败')
}
}
const copyShareUrl = (url: string) => {
navigator.clipboard.writeText(url)
ElMessage.success('链接已复制到剪贴板')
}
const formatShareExpires = (expiresAt: string) => {
const expires = new Date(expiresAt)
return expires.toLocaleDateString()
}
onMounted(() => {
loadShares()
})
</script>
<style scoped>
.playground-page {
padding: 20px;
}
.control-card,
.chat-card,
.trace-card {
margin-bottom: 16px;
}
.header-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.chat-list {
max-height: 520px;
overflow: auto;
padding: 8px;
background: var(--el-fill-color-lighter);
border-radius: 8px;
}
.chat-item {
margin-bottom: 10px;
padding: 10px;
border-radius: 8px;
background: #fff;
}
.chat-item.role-user {
border-left: 4px solid var(--el-color-info);
}
.chat-item.role-assistant {
border-left: 4px solid var(--el-color-success);
}
.chat-item.role-human {
border-left: 4px solid var(--el-color-warning);
}
.meta {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.time,
.segment {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.content {
white-space: pre-wrap;
line-height: 1.5;
}
.composer {
margin-top: 12px;
}
.composer-actions {
margin-top: 8px;
display: flex;
justify-content: flex-end;
}
.json-panel {
margin-top: 12px;
}
.json-title {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-bottom: 4px;
}
pre {
background: var(--el-fill-color-lighter);
padding: 8px;
border-radius: 6px;
max-height: 220px;
overflow: auto;
font-size: 12px;
}
.shares-card {
margin-bottom: 16px;
}
.shares-list {
max-height: 200px;
overflow-y: auto;
}
.share-item {
padding: 8px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.share-item:last-child {
border-bottom: none;
}
.share-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
}
.share-title {
font-weight: 500;
font-size: 13px;
}
.share-meta {
display: flex;
gap: 12px;
font-size: 12px;
color: var(--el-text-color-secondary);
margin-bottom: 4px;
}
.share-actions {
display: flex;
gap: 8px;
}
</style>

View File

@ -0,0 +1,298 @@
<template>
<div class="share-page">
<div v-if="loading" class="loading-container">
<el-icon class="is-loading" :size="48">
<Loading />
</el-icon>
<p>加载中...</p>
</div>
<div v-else-if="error" class="error-container">
<el-result :icon="errorIcon" :title="errorTitle" :sub-title="errorMessage">
<template #extra>
<el-button type="primary" @click="goHome">返回首页</el-button>
</template>
</el-result>
</div>
<template v-else-if="sessionInfo">
<el-card shadow="never" class="header-card">
<div class="session-header">
<div class="session-info">
<h2>{{ sessionInfo.title || '共享对话' }}</h2>
<p v-if="sessionInfo.description" class="description">{{ sessionInfo.description }}</p>
<div class="meta-info">
<el-tag size="small" type="info">
<el-icon><User /></el-icon>
{{ sessionInfo.current_users }} / {{ sessionInfo.max_concurrent_users }} 在线
</el-tag>
<el-tag size="small" type="warning">
<el-icon><Clock /></el-icon>
{{ formatExpiresAt(sessionInfo.expires_at) }}
</el-tag>
</div>
</div>
</div>
</el-card>
<el-card shadow="never" class="chat-card">
<template #header>
<div class="chat-header">
<span>对话记录</span>
<el-tag size="small" type="info">{{ sessionInfo.history.length }} 条消息</el-tag>
</div>
</template>
<div class="chat-list" ref="chatListRef">
<div
v-for="(msg, idx) in sessionInfo.history"
:key="idx"
class="chat-item"
:class="`role-${msg.role}`"
>
<div class="meta">
<el-tag size="small" :type="getTagType(msg.role)">
{{ getRoleLabel(msg.role) }}
</el-tag>
</div>
<div class="content">{{ msg.content }}</div>
</div>
<el-empty v-if="!sessionInfo.history.length" description="暂无对话记录" :image-size="80" />
</div>
</el-card>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Loading, User, Clock } from '@element-plus/icons-vue'
import {
getSharedSession,
joinSharedSession,
leaveSharedSession,
type SharedSessionInfo
} from '@/api/mid-platform'
const route = useRoute()
const router = useRouter()
const loading = ref(true)
const error = ref<string | null>(null)
const errorTitle = ref('无法访问')
const errorMessage = ref('')
const sessionInfo = ref<SharedSessionInfo | null>(null)
const chatListRef = ref<HTMLElement | null>(null)
const errorIcon = computed(() => {
if (error.value === 'expired') return 'error'
if (error.value === 'not_found') return 'error'
if (error.value === 'too_many_users') return 'warning'
return 'error'
})
const getTagType = (role: string) => {
if (role === 'user') return 'info'
if (role === 'assistant') return 'success'
if (role === 'human') return 'warning'
return ''
}
const getRoleLabel = (role: string) => {
if (role === 'user') return '用户'
if (role === 'assistant') return '助手'
if (role === 'human') return '人工'
return role
}
const formatExpiresAt = (expiresAt: string) => {
const expires = new Date(expiresAt)
const now = new Date()
const diffMs = expires.getTime() - now.getTime()
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
const diffHours = Math.floor((diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))
if (diffDays > 0) {
return `${diffDays}${diffHours} 小时后过期`
} else if (diffHours > 0) {
return `${diffHours} 小时后过期`
} else {
return '即将过期'
}
}
const goHome = () => {
router.push('/')
}
const loadSession = async () => {
const shareToken = route.params.token as string
if (!shareToken) {
error.value = 'not_found'
errorTitle.value = '无效链接'
errorMessage.value = '分享链接无效,请检查链接是否正确'
loading.value = false
return
}
try {
loading.value = true
error.value = null
sessionInfo.value = await joinSharedSession(shareToken)
} catch (err: any) {
const status = err?.response?.status
const detail = err?.response?.data?.detail || '未知错误'
if (status === 404) {
error.value = 'not_found'
errorTitle.value = '分享不存在'
errorMessage.value = '该分享链接不存在或已被删除'
} else if (status === 410) {
error.value = 'expired'
errorTitle.value = '分享已过期'
errorMessage.value = detail
} else if (status === 429) {
error.value = 'too_many_users'
errorTitle.value = '访问人数过多'
errorMessage.value = '当前在线人数已达上限,请稍后再试'
} else {
error.value = 'error'
errorTitle.value = '加载失败'
errorMessage.value = detail
}
} finally {
loading.value = false
}
}
const handleLeave = async () => {
const shareToken = route.params.token as string
if (shareToken && sessionInfo.value) {
try {
await leaveSharedSession(shareToken)
} catch {
// ignore leave errors
}
}
}
onMounted(() => {
loadSession()
window.addEventListener('beforeunload', handleLeave)
})
onUnmounted(() => {
handleLeave()
window.removeEventListener('beforeunload', handleLeave)
})
</script>
<style scoped>
.share-page {
min-height: 100vh;
background: var(--el-bg-color-page);
padding: 20px;
max-width: 900px;
margin: 0 auto;
}
.loading-container,
.error-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
}
.loading-container p {
margin-top: 16px;
color: var(--el-text-color-secondary);
}
.header-card,
.chat-card {
margin-bottom: 16px;
}
.session-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.session-info h2 {
margin: 0 0 8px 0;
font-size: 20px;
font-weight: 600;
}
.session-info .description {
margin: 0 0 12px 0;
color: var(--el-text-color-secondary);
font-size: 14px;
}
.meta-info {
display: flex;
gap: 8px;
}
.meta-info .el-tag {
display: flex;
align-items: center;
gap: 4px;
}
.chat-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.chat-list {
max-height: 60vh;
overflow-y: auto;
padding: 8px;
background: var(--el-fill-color-lighter);
border-radius: 8px;
}
.chat-item {
margin-bottom: 12px;
padding: 12px;
border-radius: 8px;
background: #fff;
}
.chat-item.role-user {
border-left: 4px solid var(--el-color-info);
}
.chat-item.role-assistant {
border-left: 4px solid var(--el-color-success);
}
.chat-item.role-human {
border-left: 4px solid var(--el-color-warning);
}
.meta {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.content {
white-space: pre-wrap;
line-height: 1.6;
font-size: 14px;
}
</style>