789 lines
22 KiB
Vue
789 lines
22 KiB
Vue
<template>
|
||
<div class="chat-page">
|
||
<!-- 消息列表 -->
|
||
<div ref="messagesRef" class="messages">
|
||
<div v-if="messages.length === 0" class="empty-hint">
|
||
<el-icon :size="40" color="#c0c4cc"><ChatDotRound /></el-icon>
|
||
<p>启动服务后开始对话</p>
|
||
</div>
|
||
<div v-for="msg in messages" :key="msg.id" class="bubble-wrap" :class="msg.role">
|
||
<div class="bubble">
|
||
<!-- 推理过程(仅助手消息显示) -->
|
||
<div v-if="msg.parts?.filter((p) => p.type === 'reasoning').length > 0" class="reasoning-section">
|
||
<div class="reasoning-header" @click="msg.showReasoning = !msg.showReasoning">
|
||
<el-icon :size="14"><ArrowRight v-if="!msg.showReasoning" /><ArrowDown v-else /></el-icon>
|
||
<span>思考过程</span>
|
||
</div>
|
||
<div v-show="msg.showReasoning" class="reasoning-content">
|
||
<div v-for="(r, idx) in msg.parts.filter((p) => p.type === 'reasoning')" :key="idx" class="reasoning-item">
|
||
{{ r.text }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- 文本内容 -->
|
||
<MarkdownRender v-if="msg.text" :content="msg.text"></MarkdownRender>
|
||
<!-- question 类型 part 展示 -->
|
||
<template v-if="msg.parts">
|
||
<div v-for="(p, idx) in msg.parts.filter((p) => p.type === 'question')" :key="'question-' + idx" class="question-part">
|
||
<div v-for="(q, qi) in p.questions" :key="qi" class="question-item">
|
||
<div class="question-header">{{ q.header }}</div>
|
||
<div class="question-text">{{ q.question }}</div>
|
||
<div class="question-options">
|
||
<div v-for="(opt, oi) in q.options" :key="oi" class="question-option" @click="sendAnswer(p.id, opt.label)">
|
||
<span class="option-label">{{ opt.label }}</span>
|
||
<span v-if="opt.description" class="option-desc">{{ opt.description }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
<!-- tool 类型 part 展示 -->
|
||
<template v-if="msg.parts">
|
||
<template v-for="(p, idx) in msg.parts.filter((p) => p.type === 'tool')" :key="'tool-' + idx">
|
||
<!-- question 工具:展示问答卡片(历史加载,状态为 running 时仍可回复) -->
|
||
<div v-if="p.tool === 'question'" class="question-part">
|
||
<div v-for="(q, qi) in p.state?.input?.questions || []" :key="qi" class="question-item">
|
||
<div class="question-header">{{ q.header }}</div>
|
||
<div class="question-text">{{ q.question }}</div>
|
||
<div class="question-options">
|
||
<div
|
||
v-for="(opt, oi) in q.options"
|
||
:key="oi"
|
||
class="question-option"
|
||
:class="{ disabled: p.state?.status !== 'running' }"
|
||
@click="p.state?.status === 'running' && p._questionId && sendAnswer(p._questionId, opt.label)"
|
||
>
|
||
<span class="option-radio"></span>
|
||
<span class="option-content">
|
||
<span class="option-label">{{ opt.label }}</span>
|
||
<span v-if="opt.description" class="option-desc">{{ opt.description }}</span>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- 普通工具:展示终端卡片 -->
|
||
<div v-else class="tool-part">
|
||
<div class="tool-header" @click="p._expanded = !p._expanded">
|
||
<el-icon :size="13"><ArrowRight v-if="!p._expanded" /><ArrowDown v-else /></el-icon>
|
||
<span class="tool-name">{{ p.tool }}</span>
|
||
<span class="tool-desc">{{ p.state?.input?.description || p.state?.title || '' }}</span>
|
||
<span class="tool-status" :class="'status-' + (p.state?.status || 'unknown')">{{ p.state?.status || '' }}</span>
|
||
</div>
|
||
<div v-show="p._expanded" class="tool-body">
|
||
<div v-if="p.state?.input?.command" class="tool-command">
|
||
<span class="tool-label">$ </span><code>{{ p.state.input.command }}</code>
|
||
</div>
|
||
<pre v-if="p.state?.output" class="tool-output">{{ p.state.output }}</pre>
|
||
<div v-if="p.state?.time" class="tool-time">耗时 {{ p.state.time.end - p.state.time.start }} ms</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</template>
|
||
<!-- 未知类型 part 原始展示 -->
|
||
<template v-if="msg.parts">
|
||
<div
|
||
v-for="(p, idx) in msg.parts.filter(
|
||
(p) =>
|
||
p.type !== 'text' &&
|
||
p.type !== 'reasoning' &&
|
||
p.type !== 'tool' &&
|
||
p.type !== 'question' &&
|
||
p.type !== 'step-start' &&
|
||
p.type !== 'step-finish'
|
||
)"
|
||
:key="'raw-' + idx"
|
||
class="raw-part"
|
||
>
|
||
<span class="raw-part-type">[{{ p.type }}]</span>
|
||
<pre class="raw-part-content">{{ JSON.stringify(p, null, 2) }}</pre>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 输入区 -->
|
||
<div v-if="!hasActiveQuestion" class="input-area">
|
||
<el-input
|
||
v-model="inputText"
|
||
type="textarea"
|
||
:autosize="{ minRows: 2, maxRows: 5 }"
|
||
placeholder="输入消息,Ctrl+Enter 发送"
|
||
:disabled="isSending"
|
||
resize="none"
|
||
@keydown.enter="send"
|
||
/>
|
||
<el-button type="primary" :disabled="isSending || !inputText.trim()" :loading="isSending" @click="send"> 发送 </el-button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed, onMounted, onUnmounted, nextTick, watch, reactive } from 'vue';
|
||
import { useRoute, useRouter } from 'vue-router';
|
||
import { ElMessage } from 'element-plus';
|
||
import { ChatDotRound, ArrowRight, ArrowDown } from '@element-plus/icons-vue';
|
||
import { useAppStore } from '@/stores/app.js';
|
||
import { sseManager } from '@/http/sse.js';
|
||
import axios from 'axios';
|
||
import MarkdownRender from '@/components/MarkdownRender/index.vue';
|
||
|
||
const route = useRoute();
|
||
const router = useRouter();
|
||
const appStore = useAppStore();
|
||
const isSending = ref(false);
|
||
const inputText = ref('');
|
||
const messages = ref([]);
|
||
const messagesRef = ref(null);
|
||
const currentSessionId = ref(null);
|
||
const localAssistantMessageIds = new Set();
|
||
|
||
// 是否有正在等待回答的 question
|
||
const hasActiveQuestion = computed(() => {
|
||
for (const msg of messages.value) {
|
||
if (!msg.parts) continue;
|
||
for (const p of msg.parts) {
|
||
if (p.type === 'tool' && p.tool === 'question' && p.state?.status === 'running' && p._questionId) return true;
|
||
if (p.type === 'question') return true;
|
||
}
|
||
}
|
||
return false;
|
||
});
|
||
let unsubscribeCallbacks = [];
|
||
|
||
// 从路由参数中获取 sessionId
|
||
const routeSessionId = computed(() => route.params.id || route.query.sessionId);
|
||
|
||
// 加载历史消息
|
||
async function loadHistoryMessages(sessionId) {
|
||
if (!sessionId) return;
|
||
try {
|
||
const baseUrl = window.__opencodeBaseUrl || 'http://127.0.0.1:4096';
|
||
const requestUrl = `${baseUrl}/session/${sessionId}/message`;
|
||
console.log('[loadHistoryMessages] ========================================');
|
||
console.log('[loadHistoryMessages] 开始获取消息,sessionId:', sessionId);
|
||
console.log('[loadHistoryMessages] 请求URL:', requestUrl);
|
||
|
||
const response = await axios.get(requestUrl);
|
||
const messagesData = response.data || [];
|
||
|
||
console.log('[loadHistoryMessages] 获取成功!消息数量:', messagesData.length);
|
||
console.log('[loadHistoryMessages] 原始消息数据 (JSON):', JSON.stringify(messagesData, null, 2));
|
||
console.log('[loadHistoryMessages] ========================================');
|
||
|
||
// 清空当前消息
|
||
messages.value = [];
|
||
localAssistantMessageIds.clear();
|
||
appStore.clearAssistantMessageIds();
|
||
|
||
// 处理历史消息
|
||
messagesData.forEach((item) => {
|
||
const { info, parts } = item;
|
||
if (!info || !parts) return;
|
||
|
||
// 提取文本内容(用于展示)
|
||
const text = parts
|
||
.filter((p) => p.type === 'text')
|
||
.map((part) => part.text)
|
||
.join('');
|
||
// 为每个 part 初始化响应式字段
|
||
const normalizedParts = parts.map((p) => (p.type === 'tool' ? { ...p, _expanded: true, _freeInput: p.tool === 'question' ? '' : undefined } : p));
|
||
// 提取 question 类型 part(来自 question.asked 事件,历史中可能存在)
|
||
|
||
if (text || info.role === 'assistant') {
|
||
messages.value.push({
|
||
id: info.id,
|
||
role: info.role,
|
||
text: text,
|
||
parts: normalizedParts,
|
||
showReasoning: true, // 默认展开推理过程
|
||
});
|
||
|
||
// 记录 assistant 消息 ID
|
||
if (info.role === 'assistant') {
|
||
localAssistantMessageIds.add(info.id);
|
||
appStore.addAssistantMessageId(info.id);
|
||
}
|
||
}
|
||
});
|
||
|
||
console.log('[loadHistoryMessages] 处理后的消息列表:', messages.value);
|
||
scrollToBottom();
|
||
} catch (err) {
|
||
console.error('加载历史消息失败:', err);
|
||
ElMessage.error(`加载历史消息失败: ${err.message}`);
|
||
}
|
||
}
|
||
|
||
// 监听路由参数变化,加载对应会话
|
||
watch(
|
||
routeSessionId,
|
||
async (newSessionId) => {
|
||
if (newSessionId) {
|
||
currentSessionId.value = newSessionId;
|
||
// 等待历史消息加载完毕,避免在 send 时 messages 被清空
|
||
await loadHistoryMessages(newSessionId);
|
||
|
||
// 处理从首页带过来的初始消息
|
||
const text = route.query.text;
|
||
if (text) {
|
||
inputText.value = text;
|
||
// 清除 query 中的 text,防止刷新页面时重复发送
|
||
const query = { ...route.query };
|
||
delete query.text;
|
||
router.replace({ query });
|
||
|
||
// 触发发送逻辑
|
||
send();
|
||
}
|
||
|
||
// 确保 SSE 连接已建立
|
||
if (!appStore.sseConnected) {
|
||
appStore.initSSE();
|
||
}
|
||
}
|
||
},
|
||
{ immediate: true }
|
||
);
|
||
|
||
function scrollToBottom() {
|
||
nextTick(() => {
|
||
if (messagesRef.value) {
|
||
messagesRef.value.scrollTop = messagesRef.value.scrollHeight;
|
||
}
|
||
});
|
||
}
|
||
|
||
function upsertAssistantPart(msgId, part) {
|
||
let existing = messages.value.find((m) => m.id === msgId);
|
||
if (!existing) {
|
||
existing = { id: msgId, role: 'assistant', text: '', parts: [], showReasoning: true };
|
||
messages.value.push(existing);
|
||
}
|
||
if (!existing.parts) existing.parts = [];
|
||
|
||
// 找到同类型同 partID 的 part,有则更新,无则追加
|
||
const partId = part.id || part.partID;
|
||
const idx = partId ? existing.parts.findIndex((p) => (p.id || p.partID) === partId) : -1;
|
||
if (idx >= 0) {
|
||
const preserved = { _expanded: existing.parts[idx]._expanded, _freeInput: existing.parts[idx]._freeInput, _questionId: existing.parts[idx]._questionId };
|
||
existing.parts[idx] = { ...existing.parts[idx], ...part, ...preserved };
|
||
} else {
|
||
const newPart = { ...part };
|
||
if (newPart.type === 'tool' && newPart._expanded === undefined) newPart._expanded = true;
|
||
if (newPart.type === 'tool' && newPart.tool === 'question' && newPart._freeInput === undefined) newPart._freeInput = '';
|
||
existing.parts.push(newPart);
|
||
}
|
||
|
||
// 同步 text 字段(取所有 text 类型 part 拼接)
|
||
existing.text = existing.parts
|
||
.filter((p) => p.type === 'text')
|
||
.map((p) => p.text || '')
|
||
.join('');
|
||
scrollToBottom();
|
||
}
|
||
|
||
/**
|
||
* 注册 SSE 事件监听器
|
||
*/
|
||
function registerSSEListeners() {
|
||
// 监听消息部分更新事件
|
||
const unsubscribePartUpdated = sseManager.on('message.part.updated', (data) => {
|
||
const props = data.properties || {};
|
||
const part = props.part;
|
||
if (!part) return;
|
||
if (part.sessionID !== currentSessionId.value) return;
|
||
// 通过 messageID 前缀区分用户/助手消息:只渲染 assistant 消息的 part
|
||
// assistant 消息的 messageID 会在 message.updated 事件中记录,用 localAssistantMessageIds 集合过滤
|
||
if (!localAssistantMessageIds.has(part.messageID) && !appStore.isAssistantMessage(part.messageID)) return;
|
||
upsertAssistantPart(part.messageID, part);
|
||
});
|
||
|
||
// 监听消息更新事件
|
||
const unsubscribeMessageUpdated = sseManager.on('message.updated', (data) => {
|
||
const props = data.properties || {};
|
||
const info = props.info;
|
||
// 记录 assistant 消息的 ID,供 message.part.updated 过滤使用
|
||
if (info && info.role === 'assistant' && info.sessionID === currentSessionId.value) {
|
||
localAssistantMessageIds.add(info.id);
|
||
appStore.addAssistantMessageId(info.id);
|
||
}
|
||
});
|
||
|
||
// 监听会话空闲事件
|
||
const unsubscribeSessionIdle = sseManager.on('session.idle', (data) => {
|
||
const props = data.properties || {};
|
||
// session.idle 表示 AI 响应已全部完成,重置发送状态
|
||
if (props.sessionID === currentSessionId.value) {
|
||
isSending.value = false;
|
||
}
|
||
});
|
||
|
||
// 监听 question.asked 事件
|
||
const unsubscribeQuestionAsked = sseManager.on('question.asked', (data) => {
|
||
const props = data.properties || {};
|
||
if (props.sessionID !== currentSessionId.value) return;
|
||
const msgId = props.tool?.messageID;
|
||
const callId = props.tool?.callID;
|
||
if (!msgId) return;
|
||
// 将 question 作为一个 part 插入对应消息
|
||
upsertAssistantPart(msgId, {
|
||
id: props.id,
|
||
type: 'question',
|
||
questions: props.questions || [],
|
||
});
|
||
// 同时将 question id 关联到对应的 tool part(通过 callID 匹配)
|
||
if (callId) {
|
||
const msg = messages.value.find((m) => m.id === msgId);
|
||
if (msg && msg.parts) {
|
||
const toolPart = msg.parts.find((p) => p.type === 'tool' && p.callID === callId);
|
||
if (toolPart) toolPart._questionId = props.id;
|
||
}
|
||
}
|
||
});
|
||
|
||
// 保存取消订阅函数
|
||
unsubscribeCallbacks.push(unsubscribePartUpdated, unsubscribeMessageUpdated, unsubscribeSessionIdle, unsubscribeQuestionAsked);
|
||
}
|
||
|
||
/**
|
||
* 注销 SSE 事件监听器
|
||
*/
|
||
function unregisterSSEListeners() {
|
||
unsubscribeCallbacks.forEach((unsubscribe) => {
|
||
if (typeof unsubscribe === 'function') {
|
||
unsubscribe();
|
||
}
|
||
});
|
||
unsubscribeCallbacks = [];
|
||
}
|
||
|
||
async function sendAnswer(questionId, label) {
|
||
if (!currentSessionId.value) return;
|
||
messages.value.push({ id: Date.now(), role: 'user', text: label });
|
||
isSending.value = true;
|
||
scrollToBottom();
|
||
try {
|
||
const baseUrl = window.__opencodeBaseUrl || 'http://127.0.0.1:4096';
|
||
await axios.post(`${baseUrl}/question/${questionId}/reply`, { answers: [[label]] });
|
||
} catch (err) {
|
||
console.error('发送答案失败:', err);
|
||
ElMessage.error(`发送失败: ${err.message}`);
|
||
isSending.value = false;
|
||
}
|
||
}
|
||
|
||
async function send() {
|
||
const text = inputText.value.trim();
|
||
if (!text || isSending.value) return;
|
||
|
||
// 首次发送时创建会话,使用用户输入的第一条消息作为 title
|
||
if (!currentSessionId.value) {
|
||
try {
|
||
const session = await window.opencode.createSession({ title: text });
|
||
currentSessionId.value = session.id;
|
||
} catch (err) {
|
||
ElMessage.error(`创建会话失败: ${err.message}`);
|
||
return;
|
||
}
|
||
}
|
||
|
||
messages.value.push({ id: Date.now(), role: 'user', text });
|
||
inputText.value = '';
|
||
isSending.value = true;
|
||
scrollToBottom();
|
||
|
||
try {
|
||
await window.opencode.promptAsync(currentSessionId.value, text);
|
||
// 发送成功后等待 SSE 事件流推送 AI 响应,isSending 由 session.idle 事件重置
|
||
} catch (err) {
|
||
console.error('发送指令失败:', err);
|
||
// 如果是服务未运行,尝试启动服务
|
||
if (appStore.serviceStatus !== appStore.SERVICE_STATUS.RUNNING) {
|
||
ElMessage.info('服务未运行,正在尝试启动...');
|
||
appStore.triggerStartService();
|
||
}
|
||
ElMessage.error(`发送失败: ${err.message}`);
|
||
isSending.value = false;
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
// 组件挂载时注册 SSE 监听器
|
||
registerSSEListeners();
|
||
// 确保全局 SSE 连接已建立
|
||
if (!appStore.sseConnected) {
|
||
appStore.initSSE();
|
||
}
|
||
});
|
||
|
||
onUnmounted(() => {
|
||
// 组件卸载时注销 SSE 监听器
|
||
unregisterSSEListeners();
|
||
});
|
||
</script>
|
||
|
||
<style scoped>
|
||
.chat-page {
|
||
display: flex;
|
||
flex-direction: column;
|
||
height: 100%;
|
||
min-height: 0;
|
||
gap: 12px;
|
||
}
|
||
|
||
.messages {
|
||
flex: 1;
|
||
min-height: 0;
|
||
overflow-y: auto;
|
||
padding: 8px 4px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
|
||
.empty-hint {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 12px;
|
||
color: #c0c4cc;
|
||
font-size: 14px;
|
||
margin: auto;
|
||
}
|
||
|
||
.bubble-wrap {
|
||
display: flex;
|
||
}
|
||
|
||
.bubble-wrap.user {
|
||
justify-content: flex-end;
|
||
}
|
||
.bubble-wrap.assistant {
|
||
justify-content: flex-start;
|
||
}
|
||
|
||
.bubble {
|
||
max-width: 75%;
|
||
padding: 10px 14px;
|
||
border-radius: 12px;
|
||
font-size: 14px;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.bubble-wrap.user .bubble {
|
||
background: #409eff;
|
||
color: #fff;
|
||
border-bottom-right-radius: 4px;
|
||
}
|
||
|
||
.bubble-wrap.assistant .bubble {
|
||
background: #f0f2f5;
|
||
color: #303133;
|
||
border-bottom-left-radius: 4px;
|
||
}
|
||
|
||
.bubble-text {
|
||
margin: 0;
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
font-family: inherit;
|
||
}
|
||
|
||
.reasoning-section {
|
||
margin-bottom: 8px;
|
||
border-radius: 6px;
|
||
background: rgba(0, 0, 0, 0.03);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.reasoning-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
padding: 6px 10px;
|
||
font-size: 12px;
|
||
color: #666;
|
||
cursor: pointer;
|
||
user-select: none;
|
||
transition: background 0.2s;
|
||
}
|
||
|
||
.reasoning-header:hover {
|
||
background: rgba(0, 0, 0, 0.05);
|
||
}
|
||
|
||
.reasoning-content {
|
||
padding: 0 10px 8px;
|
||
}
|
||
|
||
.reasoning-item {
|
||
font-size: 12px;
|
||
color: #888;
|
||
line-height: 1.5;
|
||
padding: 4px 0;
|
||
border-left: 2px solid #ddd;
|
||
padding-left: 8px;
|
||
margin: 4px 0;
|
||
}
|
||
|
||
.tool-part {
|
||
margin-top: 6px;
|
||
border-radius: 6px;
|
||
background: #1e1e2e;
|
||
color: #cdd6f4;
|
||
overflow: hidden;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.tool-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 6px 10px;
|
||
cursor: pointer;
|
||
user-select: none;
|
||
transition: background 0.2s;
|
||
}
|
||
|
||
.tool-header:hover {
|
||
background: rgba(255, 255, 255, 0.05);
|
||
}
|
||
|
||
.tool-name {
|
||
font-weight: bold;
|
||
color: #89b4fa;
|
||
font-family: monospace;
|
||
}
|
||
|
||
.tool-desc {
|
||
flex: 1;
|
||
color: #a6adc8;
|
||
font-size: 12px;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.tool-status {
|
||
font-size: 11px;
|
||
padding: 1px 6px;
|
||
border-radius: 10px;
|
||
background: rgba(255, 255, 255, 0.08);
|
||
}
|
||
|
||
.tool-status.status-completed {
|
||
color: #a6e3a1;
|
||
}
|
||
|
||
.tool-status.status-running {
|
||
color: #f9e2af;
|
||
}
|
||
|
||
.tool-status.status-error {
|
||
color: #f38ba8;
|
||
}
|
||
|
||
.tool-body {
|
||
padding: 0 10px 8px;
|
||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||
}
|
||
|
||
.tool-command {
|
||
padding: 6px 0 4px;
|
||
font-family: monospace;
|
||
font-size: 13px;
|
||
color: #cba6f7;
|
||
}
|
||
|
||
.tool-label {
|
||
color: #a6e3a1;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.tool-output {
|
||
margin: 4px 0 0;
|
||
padding: 6px 8px;
|
||
background: rgba(0, 0, 0, 0.3);
|
||
border-radius: 4px;
|
||
font-size: 12px;
|
||
color: #cdd6f4;
|
||
white-space: pre-wrap;
|
||
word-break: break-all;
|
||
font-family: monospace;
|
||
max-height: 300px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.tool-time {
|
||
font-size: 11px;
|
||
color: #6c7086;
|
||
margin-top: 4px;
|
||
text-align: right;
|
||
}
|
||
|
||
.question-part {
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.question-item {
|
||
border: 1px solid #e0e4ea;
|
||
border-radius: 8px;
|
||
padding: 10px 12px;
|
||
margin-bottom: 6px;
|
||
background: #fff;
|
||
}
|
||
|
||
.question-header {
|
||
font-size: 12px;
|
||
font-weight: bold;
|
||
color: #409eff;
|
||
margin-bottom: 4px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
|
||
.question-text {
|
||
font-size: 13px;
|
||
color: #303133;
|
||
margin-bottom: 8px;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.question-options {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
|
||
.question-option {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: flex-start;
|
||
gap: 8px;
|
||
padding: 7px 10px;
|
||
border-radius: 6px;
|
||
border: 1px solid #dcdfe6;
|
||
cursor: pointer;
|
||
transition:
|
||
background 0.15s,
|
||
border-color 0.15s;
|
||
}
|
||
|
||
.option-radio {
|
||
flex-shrink: 0;
|
||
width: 14px;
|
||
height: 14px;
|
||
margin-top: 2px;
|
||
border-radius: 50%;
|
||
border: 2px solid #c0c4cc;
|
||
background: #fff;
|
||
transition: border-color 0.15s;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.question-option:hover .option-radio {
|
||
border-color: #409eff;
|
||
}
|
||
|
||
.question-option.disabled .option-radio {
|
||
border-color: #dcdfe6;
|
||
}
|
||
|
||
.option-content {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
}
|
||
|
||
.question-option:hover {
|
||
background: #ecf5ff;
|
||
border-color: #409eff;
|
||
}
|
||
|
||
.question-option.disabled {
|
||
cursor: not-allowed;
|
||
opacity: 0.5;
|
||
}
|
||
|
||
.question-option.disabled:hover {
|
||
background: transparent;
|
||
border-color: #dcdfe6;
|
||
}
|
||
|
||
.option-label {
|
||
font-size: 13px;
|
||
color: #303133;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.option-desc {
|
||
font-size: 11px;
|
||
color: #909399;
|
||
}
|
||
|
||
.question-free-input {
|
||
display: flex;
|
||
gap: 8px;
|
||
align-items: flex-end;
|
||
margin-top: 6px;
|
||
}
|
||
|
||
.question-free-input .el-textarea {
|
||
flex: 1;
|
||
}
|
||
|
||
.question-free-input .el-button {
|
||
height: 60px;
|
||
padding: 0 16px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.raw-part {
|
||
margin-top: 6px;
|
||
border-radius: 6px;
|
||
background: rgba(0, 0, 0, 0.03);
|
||
padding: 6px 10px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.raw-part-type {
|
||
font-size: 11px;
|
||
color: #999;
|
||
font-weight: bold;
|
||
display: block;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.raw-part-content {
|
||
margin: 0;
|
||
font-size: 12px;
|
||
color: #555;
|
||
white-space: pre-wrap;
|
||
word-break: break-all;
|
||
font-family: monospace;
|
||
}
|
||
|
||
.input-area {
|
||
display: flex;
|
||
gap: 10px;
|
||
align-items: flex-end;
|
||
padding: 12px 16px;
|
||
background: #fff;
|
||
border-radius: 10px;
|
||
border: 1px solid #e4e7ed;
|
||
}
|
||
|
||
.input-area .el-textarea {
|
||
flex: 1;
|
||
}
|
||
|
||
.input-area .el-button {
|
||
height: 60px;
|
||
padding: 0 20px;
|
||
}
|
||
</style>
|