You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
dianshang-qianduan/src/views/components/TrainingAiSidebar.vue

1060 lines
33 KiB
Vue

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<template>
<aside :class="['training-ai-sidebar', { 'is-collapsed': sidebarCollapsed }]">
<!-- 收起态:右下角 3D 助手形象 + 文字胶囊悬浮按钮 -->
<button
v-if="sidebarCollapsed"
:class="['ai-fab', { 'is-hidden': fabHidden }]"
type="button"
aria-label="展开 AI 学习助手"
title="AI 学习助手"
@click="sidebarCollapsed = false"
>
<span class="ai-fab__mascot" aria-hidden="true">
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="aiFabBody" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#8ab6ff" />
<stop offset="0.55" stop-color="#3b82f6" />
<stop offset="1" stop-color="#1d4ed8" />
</linearGradient>
<linearGradient id="aiFabVisor" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#1e3a8a" />
<stop offset="1" stop-color="#152757" />
</linearGradient>
<linearGradient id="aiFabTip" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#fde68a" />
<stop offset="1" stop-color="#f59e0b" />
</linearGradient>
</defs>
<!-- 地面投影 -->
<ellipse cx="32" cy="59" rx="15" ry="3" fill="#1e3a8a" opacity="0.2" />
<!-- 天线 + 顶珠 -->
<line x1="32" y1="13" x2="32" y2="7" stroke="#1e40af" stroke-width="2.4" stroke-linecap="round" />
<circle cx="32" cy="5.5" r="3.2" fill="url(#aiFabTip)" />
<!-- 侧耳 -->
<rect x="7" y="28" width="7" height="12" rx="3.5" fill="#1e40af" />
<rect x="50" y="28" width="7" height="12" rx="3.5" fill="#1e40af" />
<!-- 3D 黏土头部 -->
<ellipse cx="32" cy="34" rx="20" ry="19" fill="url(#aiFabBody)" />
<!-- 高光 -->
<ellipse cx="24" cy="21" rx="7.5" ry="3.6" fill="#ffffff" opacity="0.35" transform="rotate(-16 24 21)" />
<!-- 显示器面罩 -->
<rect x="17" y="25" width="30" height="15" rx="7.5" fill="url(#aiFabVisor)" />
<!-- 发光眼睛 -->
<circle cx="26.5" cy="32.5" r="2.7" fill="#7dd3fc" />
<circle cx="37.5" cy="32.5" r="2.7" fill="#7dd3fc" />
<circle cx="27.3" cy="31.7" r="0.9" fill="#ffffff" />
<circle cx="38.3" cy="31.7" r="0.9" fill="#ffffff" />
<!-- 微笑 -->
<path d="M29 36.5 Q32 39 35 36.5" stroke="#7dd3fc" stroke-width="1.6" fill="none" stroke-linecap="round" />
<!-- 星光点缀 -->
<path d="M50 12 C50.7 15.3 53.4 16.3 56 17 C53.4 17.7 50.7 18.7 50 22 C49.3 18.7 46.6 17.7 44 17 C46.6 16.3 49.3 15.3 50 12 Z" fill="#fbbf24" />
</svg>
</span>
<span class="ai-fab__label">
<strong>AI 学习助手</strong>
<em>学习有疑问?随时问我</em>
</span>
</button>
<!-- 展开态 -->
<div v-else class="ai-sidebar-body">
<header class="ai-head">
<span class="ai-head__brand">
<el-icon><MagicStick /></el-icon>
AI 学习助手
</span>
<button
class="ai-head__close"
type="button"
aria-label="收起 AI 助学与助评"
title="收起"
@click="sidebarCollapsed = true"
>
<span aria-hidden="true">›</span>
</button>
</header>
<div class="ai-segmented" role="tablist">
<button
type="button"
role="tab"
:class="['ai-segmented__item', { on: activePanel === 'study' }]"
:aria-selected="String(activePanel === 'study')"
@click="activePanel = 'study'"
>
<el-icon><ChatDotRound /></el-icon>AI 助学
</button>
<button
type="button"
role="tab"
:class="['ai-segmented__item', { on: activePanel === 'review' }]"
:aria-selected="String(activePanel === 'review')"
@click="activePanel = 'review'"
>
<el-icon><DataAnalysis /></el-icon>AI 助评
</button>
</div>
<!-- ========== AI 助学 ========== -->
<div v-show="activePanel === 'study'" class="ai-pane">
<div ref="chatStreamRef" class="chat-stream" aria-live="polite">
<div v-if="!assistMessages.length" class="chat-line chat-line--ai">
<span class="chat-avatar">AI</span>
<p class="chat-text chat-text--ai">{{ displayAssistMessage }}</p>
</div>
<template v-for="message in assistMessages" :key="message.id">
<div v-if="message.role === 'student'" class="chat-line chat-line--user">
<p class="chat-text chat-text--user">{{ message.content }}</p>
</div>
<div v-else class="chat-line chat-line--ai">
<span class="chat-avatar">AI</span>
<p class="chat-text chat-text--ai">{{ message.content }}</p>
</div>
</template>
<div v-if="assistLoading" class="chat-line chat-line--ai">
<span class="chat-avatar">AI</span>
<p class="chat-thinking">
AI 正在思考
<span class="thinking-dots" aria-hidden="true"><i></i><i></i><i></i></span>
</p>
</div>
</div>
<button v-if="referenceText" type="button" class="reference-card" @click="useReferencePrompt">
<span class="reference-card__title">
<el-icon><Reading /></el-icon>{{ referenceTitle }}
</span>
<span class="reference-card__text" v-html="referenceText"></span>
</button>
<div class="composer">
<textarea
ref="assistInputRef"
v-model="assistQuestion"
placeholder="输入问题,Enter 发送 / Shift+Enter 换行"
rows="1"
@input="autoGrowAssist"
@keydown.enter.exact.prevent="requestAssist"
></textarea>
<button
type="button"
class="composer__send"
aria-label="发送助学问题"
:disabled="assistLoading"
@click="requestAssist"
>
<el-icon><Top /></el-icon>
</button>
</div>
</div>
<!-- ========== AI 助评 ========== -->
<div v-show="activePanel === 'review'" class="ai-pane">
<div v-if="displayScoreVisible" class="score-row">
<span class="score-row__label">综合评分</span>
<span class="score-row__value">
<strong>{{ displayFinalScore }}</strong><em>/ 100</em>
</span>
</div>
<div class="review-scroll">
<div v-if="hasStructuredAssessment" class="review-result">
<p v-if="reports.assessmentSummary" class="review-summary">{{ reports.assessmentSummary }}</p>
<div v-if="displayScoreCriteria.length" class="criteria" aria-label="评分明细">
<div v-for="item in displayScoreCriteria" :key="`${item.name}-${item.score}`" class="criterion">
<div class="criterion__top">
<span class="criterion__name">{{ item.name }}</span>
<span v-if="item.score !== ''" class="criterion__score">
{{ item.score }}<em v-if="item.maxScore !== ''"> / {{ item.maxScore }}</em>
</span>
</div>
<p v-if="item.description" class="criterion__desc">{{ item.description }}</p>
</div>
</div>
<div v-if="reports.improvements.length" class="improvements">
<span class="improvements__title">优先改进</span>
<ul>
<li v-for="item in reports.improvements" :key="item">{{ item }}</li>
</ul>
</div>
</div>
<div v-else class="review-empty">
<el-icon><DataLine /></el-icon>
<p>{{ displayEvalMessage }}</p>
</div>
</div>
<p v-if="currentTaskKey && !hasSavedAnswer" class="review-hint">请先至少填写并保存一项实训内容,再请求 AI 助评。</p>
<p v-else-if="currentTaskKey" class="review-hint">修改并保存答案后可重新助评;答案未变时保留上次结果。</p>
<button
type="button"
class="review-primary"
:disabled="!currentTaskKey || !hasSavedAnswer || assessmentLoading"
@click="requestEvaluation"
>
{{ assessmentLoading ? "AI 评分中…" : reports.assessment.status === "SUCCEEDED" ? "重新助评" : "AI 智能评分" }}
</button>
</div>
</div>
</aside>
</template>
<script setup>
import { ChatDotRound, DataAnalysis, DataLine, MagicStick, Reading, Top } from "@element-plus/icons-vue";
import { getAiTrainingEvaluation, requestAiTrainingAssessment, requestAiTrainingHelp } from "@/api/aiTrainingEvaluation";
import { normalizeEvaluationReports } from "./trainingAiEvaluationReport";
import { resolveTrainingTaskKey } from "@/views/training/taskKeyMap";
import { buildReferencePrompt } from "./studentTrainingAiPanel";
import { friendlyAiEvaluationMessage as friendlyAiMessage } from "@/api/aiTrainingEvaluation";
const props = defineProps({
studyTip: {
type: String,
default: "当前任务建议:先明确任务要求,再结合真实业务场景逐步补全实训内容。",
},
referenceTitle: {
type: String,
default: "填写提醒",
},
referenceText: {
type: String,
default: "",
},
assistText: {
type: String,
default: "建议已生成:请围绕任务目标补充关键数据、分析过程和结论依据。",
},
evalMessage: {
type: String,
default: "完成实训内容后,可以请求AI助评获取智能评分和改进建议。",
},
finalScore: {
type: [String, Number],
default: "--",
},
scoreVisible: {
type: Boolean,
default: false,
},
progressItems: {
type: Array,
default: () => [],
},
taskKey: { type: String, default: "" },
hasSavedAnswer: { type: Boolean, default: true },
});
const emit = defineEmits(["request-assist", "request-evaluation"]);
const assistQuestion = ref("");
const reviewQuestion = ref("");
const assistMessage = ref("");
const internalEvalMessage = ref("");
const internalScore = ref("--");
const internalScoreVisible = ref(false);
const assessmentLoading = ref(false);
const assistLoading = ref(false);
const evaluation = ref({});
const assistMessages = ref([]);
const sidebarCollapsed = ref(true);
const activePanel = ref("study");
const chatStreamRef = ref(null);
const assistInputRef = ref(null);
/* 悬浮按钮滚动方向感知:向下滚动时隐藏(避免遮挡底部导航/内容),向上滚动时滑出 */
const fabHidden = ref(false);
let lastScrollY = 0;
let fabScrollTicking = false;
function handleWindowScroll() {
if (fabScrollTicking) return;
fabScrollTicking = true;
requestAnimationFrame(() => {
fabScrollTicking = false;
const y = window.scrollY || 0;
const delta = y - lastScrollY;
if (Math.abs(delta) < 6) return;
fabHidden.value = delta > 0 && y > 40;
lastScrollY = y;
});
}
onMounted(() => window.addEventListener("scroll", handleWindowScroll, { passive: true }));
onBeforeUnmount(() => window.removeEventListener("scroll", handleWindowScroll));
/** 输入框最大高度(约 6 行):到此停止增高,内容在框内滚动(DeepSeek 式) */
const ASSIST_INPUT_MAX_HEIGHT = 168;
function autoGrowAssist() {
const el = assistInputRef.value;
if (!el) return;
el.style.height = "auto";
el.style.height = `${Math.min(el.scrollHeight, ASSIST_INPUT_MAX_HEIGHT)}px`;
}
const route = useRoute();
const currentTaskKey = computed(() => resolveTrainingTaskKey(route, props.taskKey));
const reports = computed(() => normalizeEvaluationReports(evaluation.value));
const currentStepContext = computed(() => [props.studyTip, props.referenceTitle, plainText(props.referenceText)].filter(Boolean).join("\n"));
const displayScoreCriteria = computed(() => {
const criteria = reports.value.scoreCriteria;
const stepNames = props.progressItems.map((item) => String(item?.text || "").trim()).filter(Boolean);
if (!stepNames.length || stepNames.length !== criteria.length) return criteria;
return criteria.map((item, index) => ({ ...item, name: stepNames[index] }));
});
const displayAssistMessage = computed(() => assistMessage.value || props.studyTip);
const displayScoreVisible = computed(() => props.scoreVisible || internalScoreVisible.value || reports.value.score !== null);
const displayFinalScore = computed(() => (props.scoreVisible ? props.finalScore : reports.value.score ?? internalScore.value));
const hasStructuredAssessment = computed(() => Boolean(reports.value.assessmentSummary || displayScoreCriteria.value.length || reports.value.improvements.length));
const displayEvalMessage = computed(() => {
if (props.scoreVisible) return props.evalMessage;
return internalEvalMessage.value || props.evalMessage;
});
onMounted(loadEvaluation);
watch(currentTaskKey, (nextTaskKey, previousTaskKey) => {
if (nextTaskKey !== previousTaskKey) assistMessages.value = [];
loadEvaluation();
});
/* 新消息/思考态出现时,对话流自动滚到底部 */
watch([assistMessages, assistLoading], async () => {
await nextTick();
if (chatStreamRef.value) chatStreamRef.value.scrollTop = chatStreamRef.value.scrollHeight;
});
/* 输入框按内容自适应增高:覆盖参考提示词填充、发送清空两种程序化变更 */
watch(assistQuestion, async () => {
await nextTick();
autoGrowAssist();
});
/* 面板展开后 textarea 重新挂载,初始校准一次 */
watch(sidebarCollapsed, async (collapsed) => {
if (!collapsed) {
await nextTick();
autoGrowAssist();
}
});
async function loadEvaluation() {
if (!currentTaskKey.value) return;
try { evaluation.value = (await getAiTrainingEvaluation(currentTaskKey.value))?.data || {}; } catch (error) { evaluation.value = {}; }
}
function useReferencePrompt() {
assistQuestion.value = buildReferencePrompt(props.referenceText);
activePanel.value = "study";
}
async function requestAssist() {
emit("request-assist", assistQuestion.value);
const question = assistQuestion.value.trim();
if (!question) {
assistMessage.value = "请输入问题,或点击上方参考提示词后发送。";
return;
}
if (currentTaskKey.value && !props.hasSavedAnswer) {
assistMessage.value = "请先至少填写并保存一项实训内容,再请求 AI 助学。";
return;
}
assistMessages.value.push({ id: `student-${Date.now()}`, role: "student", content: question });
assistQuestion.value = "";
if (currentTaskKey.value) {
assistLoading.value = true;
try {
await requestAiTrainingHelp(currentTaskKey.value, question, currentStepContext.value);
await loadEvaluation();
assistMessage.value = reports.value.helpReport || props.assistText;
assistMessages.value.push({ id: `assistant-${Date.now()}`, role: "assistant", content: assistMessage.value });
} catch (error) {
assistMessages.value.push({ id: `assistant-${Date.now()}`, role: "assistant", content: friendlyAiMessage(error, "AI 助学暂时不可用,请稍后重试。") });
} finally {
assistLoading.value = false;
}
return;
}
assistMessage.value = `围绕“${question}”,建议先对照任务目标检查信息是否完整,再补充数据依据和分析结论。`;
assistMessages.value.push({ id: `assistant-${Date.now()}`, role: "assistant", content: assistMessage.value });
}
async function requestEvaluation() {
emit("request-evaluation", reviewQuestion.value);
activePanel.value = "review";
if (!currentTaskKey.value || !props.hasSavedAnswer) {
internalEvalMessage.value = "请先至少填写并保存一项实训内容,再请求 AI 助评。";
return;
}
assessmentLoading.value = true;
try {
await requestAiTrainingAssessment(currentTaskKey.value);
await loadEvaluation();
internalScoreVisible.value = reports.value.score !== null;
internalScore.value = reports.value.score ?? "--";
internalEvalMessage.value = reports.value.assessmentSummary || "AI 评分已生成,请查看评分明细与改进建议。";
} catch (error) {
internalEvalMessage.value = friendlyAiMessage(error, "AI 助评暂时不可用,请稍后重试。");
} finally {
assessmentLoading.value = false;
}
}
function plainText(value) {
return String(value || "")
.replace(/<[^>]*>/g, " ")
.replace(/&nbsp;/g, " ")
.replace(/\s+/g, " ")
.trim();
}
</script>
<style lang="scss" scoped>
/*
* 组件自带「墨黑极简」局部令牌(不依赖页面级 var(--student-*) 令牌),
* 保证在所有实训页(含未做墨黑换肤的页面)视觉一致。
* 宽度/定位不在这里设置——由全局 student-training.scss 的 shell grid 控制。
*
* 字体规范(对齐大厂悬浮面板):
* - Claude 设计指南:三级字号(标题/正文/标注)+ 两种字重(常规/强调)
* - Fluent 2(Copilot 侧栏):Caption 12/16、Body 14/20、Title3 24/32
* - Material 3 / Ant Design 5:正文锚点 14px,标注 12px
* - 中文按 M3「中高语言」类别行高上调,正文 14/22(≈1.6)
*/
.training-ai-sidebar {
--ai-accent: #2563eb;
--ai-accent-deep: #1d4ed8;
--ai-gray: #6b7280;
--ai-gray-light: #9ca3af;
--ai-soft: #f3f4f6;
--ai-soft-2: #fafbfc;
--ai-border: #e5e7eb;
--ai-border-strong: #d1d5db;
--ai-text: #1f2937;
--ai-text-2: #4b5563;
--ai-success: #4a9d6e;
/* 字体栈:跟随系统,中文优先苹方/雅黑 */
--ai-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Helvetica Neue",
Helvetica, Arial, sans-serif;
/* 三级字号 */
--ai-fs-caption: 12px; /* 标注:提示、元信息、空状态、分段、标签 */
--ai-fs-body: 14px; /* 正文:对话、输入器、列表、摘要、按钮 */
--ai-fs-display: 24px; /* 展示:综合评分(Fluent Title 3) */
/* 行高 */
--ai-lh-caption: 18px; /* 1.5 */
--ai-lh-body: 22px; /* ≈1.57,中文阅读 */
/* 两种字重 */
--ai-fw-regular: 400;
--ai-fw-emphasized: 600;
font-family: var(--ai-font-family);
/* Web 悬浮面板标准:开启字体抗锯齿(ChatGPT/Copilot 同款渲染) */
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
/* 真正悬浮:脱离文档流,不占用页面宽度与 grid 列 */
position: fixed;
z-index: 1001;
top: 64px;
right: 16px;
bottom: 16px;
/* 笔记本紧凑适配:宽度上限 420px,避免挤压主内容;窄屏留 24px 边距 */
width: min(420px, calc(100vw - 24px));
/* 根透明:不铺任何背景色,白底仅限卡片本身 */
background: transparent;
display: flex;
flex-direction: column;
}
/* 收起态:根不占任何空间,由悬浮球自行定位 */
.training-ai-sidebar.is-collapsed {
top: auto;
right: auto;
bottom: auto;
width: auto;
}
/* ===== 收起态:待机仅 3D 头像,悬停像扇子一样展开文字 ===== */
.ai-fab {
position: fixed;
right: 24px;
bottom: 28px;
display: inline-flex;
align-items: center;
padding: 4px;
border: 1px solid rgba(255, 255, 255, 0);
border-radius: 999px;
color: #ffffff;
background: transparent;
box-shadow: 0 8px 22px rgba(37, 99, 235, 0.32);
font-family: inherit;
text-align: left;
cursor: pointer;
transition: transform 0.22s ease, box-shadow 0.22s ease, opacity 0.22s ease,
border-color 0.25s ease, padding 0.28s ease;
}
/* 胶囊底色:默认透明,悬停时淡入,避免渐变背景无法过渡的问题 */
.ai-fab::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 55%, #1d4ed8 100%);
opacity: 0;
transition: opacity 0.2s ease;
}
/* 展开态:悬停 / 键盘聚焦(慢速展开,带 ease-out 回弹感) */
.ai-fab:hover:not(.is-hidden),
.ai-fab:focus-visible:not(.is-hidden) {
padding: 4px 20px 4px 4px;
border-color: rgba(255, 255, 255, 0.55);
box-shadow: 0 14px 30px rgba(37, 99, 235, 0.46);
transition: padding 0.5s cubic-bezier(0.22, 1, 0.36, 1),
border-color 0.4s ease, box-shadow 0.35s ease,
transform 0.22s ease, opacity 0.22s ease;
}
.ai-fab:hover:not(.is-hidden)::before,
.ai-fab:focus-visible:not(.is-hidden)::before {
opacity: 1;
transition: opacity 0.45s ease;
}
/* 向下滚动时自动隐藏:滑出视口底部,完全不遮挡内容 */
.ai-fab.is-hidden {
transform: translateY(150%);
opacity: 0;
pointer-events: none;
}
.ai-fab:hover:not(.is-hidden) {
transform: translateY(-3px);
}
.ai-fab:active { transform: translateY(-1px); }
/* 3D 助手头像:浅色圆底衬托 SVG 形象,缓慢上下浮动 */
.ai-fab__mascot {
position: relative;
z-index: 1;
display: inline-flex;
flex: 0 0 auto;
width: 44px;
height: 44px;
border-radius: 50%;
background: radial-gradient(circle at 30% 25%, #ffffff 0%, #dbeafe 62%, #bfdbfe 100%);
box-shadow: inset 0 -2px 5px rgba(30, 58, 138, 0.18), 0 2px 6px rgba(15, 23, 42, 0.22);
animation: mascot-bob 3.2s ease-in-out infinite;
}
.ai-fab__mascot svg {
display: block;
width: 100%;
height: 100%;
}
/* 呼吸光环:吸引注意但不刺眼 */
.ai-fab__mascot::before {
content: "";
position: absolute;
inset: -4px;
border: 2px solid rgba(147, 197, 253, 0.75);
border-radius: 50%;
animation: mascot-pulse 2.4s ease-out infinite;
}
/* 文字描述:默认折叠在头像后方,悬停时像扇子一样横向展开 */
.ai-fab__label {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 1px;
max-width: 0;
margin-left: 0;
overflow: hidden;
white-space: nowrap;
opacity: 0;
transform: translateX(-10px) scale(0.96);
transform-origin: left center;
/* 收合速度(离开悬停时稍快回收) */
transition: max-width 0.32s ease-in, margin-left 0.32s ease-in, opacity 0.2s ease-in,
transform 0.32s ease-in;
strong {
color: #ffffff;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.5px;
line-height: 18px;
}
em {
color: rgba(255, 255, 255, 0.78);
font-size: 11px;
font-style: normal;
line-height: 15px;
}
}
.ai-fab:hover:not(.is-hidden) .ai-fab__label,
.ai-fab:focus-visible:not(.is-hidden) .ai-fab__label {
max-width: 180px;
margin-left: 10px;
opacity: 1;
transform: translateX(0) scale(1);
/* 展开速度(放慢 + ease-out,扇子徐徐展开) */
transition: max-width 0.55s cubic-bezier(0.22, 1, 0.36, 1),
margin-left 0.55s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.4s ease-out 0.08s,
transform 0.55s cubic-bezier(0.22, 1, 0.36, 1);
}
/* 悬停时小助手挥手式摆动 */
.ai-fab:hover .ai-fab__mascot svg { animation: mascot-wiggle 0.55s ease; }
@keyframes mascot-bob {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-3px); }
}
@keyframes mascot-pulse {
0% { transform: scale(0.85); opacity: 0.9; }
70%, 100% { transform: scale(1.35); opacity: 0; }
}
@keyframes mascot-wiggle {
0%, 100% { transform: rotate(0); }
25% { transform: rotate(-7deg); }
60% { transform: rotate(6deg); }
}
@media (prefers-reduced-motion: reduce) {
.ai-fab:not(.is-hidden),
.ai-fab:hover:not(.is-hidden) { transform: none; }
.ai-fab__mascot,
.ai-fab__mascot::before,
.ai-fab:hover .ai-fab__mascot svg { animation: none; }
}
/* ===== 展开态骨架:白底卡片,撑满 fixed 根 ===== */
.ai-sidebar-body {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
border: 1px solid var(--ai-border);
border-radius: 12px;
background: #ffffff;
/* 柔和小投影,避免在页面底色上形成灰色边缘带 */
box-shadow: 0 6px 20px rgba(17, 24, 39, 0.12);
overflow: hidden;
}
.ai-head {
display: flex;
align-items: center;
justify-content: space-between;
height: 44px;
padding: 0 8px 0 12px;
border-bottom: 1px solid var(--ai-border);
}
.ai-head__brand {
display: inline-flex;
align-items: center;
gap: 7px;
/* 防窄屏挤出:标题不换行 */
white-space: nowrap;
min-width: 0;
color: var(--ai-text);
font-size: var(--ai-fs-body);
font-weight: var(--ai-fw-emphasized);
.el-icon { color: var(--ai-accent); font-size: 16px; }
}
.ai-head__close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 0;
border-radius: 7px;
color: var(--ai-gray);
background: transparent;
cursor: pointer;
span { font-size: 18px; line-height: 1; }
&:hover { color: var(--ai-accent); background: var(--ai-soft); }
}
/* 分段控件:助学 / 助评 */
.ai-segmented {
display: flex;
gap: 4px;
padding: 10px 12px 0;
}
.ai-segmented__item {
display: inline-flex;
flex: 1 1 0;
align-items: center;
justify-content: center;
gap: 4px;
/* 防窄屏换行:图标+文字必须同行 */
white-space: nowrap;
min-width: 0;
height: 32px;
padding: 0 6px;
border: 1px solid transparent;
border-radius: 8px;
color: var(--ai-gray);
background: transparent;
font-family: inherit;
font-size: var(--ai-fs-caption);
font-weight: var(--ai-fw-emphasized);
cursor: pointer;
transition: color 0.16s ease, background 0.16s ease, border-color 0.16s ease;
.el-icon { font-size: 14px; flex: 0 0 auto; }
/* 文字超出时省略号兜底,避免溢出按钮框 */
span, & > :not(.el-icon) { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
&:hover { color: var(--ai-text); background: var(--ai-soft); }
&.on {
color: #ffffff;
background: var(--ai-accent);
box-shadow: 0 2px 6px rgba(37, 99, 235, 0.22);
}
}
.ai-pane {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
padding: 4px 12px 12px;
}
/* ===== 助学:对话流(AI 去气泡 / 用户浅灰气泡) ===== */
.chat-stream {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: 12px;
min-height: 140px;
padding: 12px 2px;
overflow-y: auto;
}
.chat-line { display: flex; margin: 0; }
.chat-line--ai { align-items: flex-start; gap: 8px; }
.chat-line--user { justify-content: flex-end; }
.chat-avatar {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
margin-top: 1px;
border-radius: 5px;
color: #ffffff;
background: var(--ai-accent);
font-size: 10px;
font-weight: var(--ai-fw-emphasized);
}
.chat-text {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
}
.chat-text--ai {
color: var(--ai-text);
font-size: var(--ai-fs-body);
line-height: var(--ai-lh-body);
}
.chat-text--user {
max-width: 82%;
padding: 8px 11px;
border-radius: 10px;
color: var(--ai-text);
background: var(--ai-soft);
font-size: var(--ai-fs-body);
line-height: var(--ai-lh-body);
}
/* 思考态:AI 正在思考 + 跳动蓝点 */
.chat-thinking {
display: flex;
align-items: center;
margin: 2px 0 0;
color: var(--ai-gray);
font-size: var(--ai-fs-caption);
line-height: 1;
}
.thinking-dots {
display: inline-flex;
gap: 4px;
margin-left: 6px;
i {
width: 5px;
height: 5px;
border-radius: 50%;
background: #3b82f6;
animation: thinking-bounce 1.2s ease-in-out infinite;
}
i:nth-child(2) { animation-delay: 0.2s; }
i:nth-child(3) { animation-delay: 0.4s; }
}
@keyframes thinking-bounce {
0%, 80%, 100% { opacity: 0.25; transform: translateY(0); }
40% { opacity: 1; transform: translateY(-3px); }
}
/* 参考提示卡片 */
.reference-card {
display: block;
margin: 10px 0 0;
padding: 9px 10px;
border: 1px solid var(--ai-border);
border-radius: 10px;
color: var(--ai-text-2);
background: var(--ai-soft-2);
text-align: left;
cursor: pointer;
transition: border-color 0.16s ease, background 0.16s ease;
&:hover { border-color: var(--ai-accent); background: var(--ai-soft); }
}
.reference-card__title {
display: inline-flex;
align-items: center;
gap: 5px;
margin-bottom: 5px;
color: var(--ai-text);
font-size: var(--ai-fs-caption);
font-weight: var(--ai-fw-emphasized);
.el-icon { color: var(--ai-accent); font-size: 14px; }
}
.reference-card__text {
display: block;
color: var(--ai-text-2);
font-size: var(--ai-fs-caption);
line-height: var(--ai-lh-caption);
}
/* 一体化输入器 */
.composer {
position: relative;
margin-top: 10px;
border: 1px solid var(--ai-border-strong);
border-radius: 10px;
background: #ffffff;
transition: border-color 0.16s ease, box-shadow 0.16s ease;
&:focus-within {
border-color: var(--ai-accent);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
textarea {
display: block;
width: 100%;
/* 单行起步:正文 22 + 上下内边距 19 = 41,取 44;高度由 JS 按内容撑开 */
min-height: 44px;
/* 与 JS 上限一致:约 6 行,超出后框内滚动 */
max-height: 168px;
overflow-y: auto;
padding: 10px 38px 9px 11px;
border: 0;
outline: none;
resize: none;
color: var(--ai-text);
background: transparent;
font-family: inherit;
font-size: var(--ai-fs-body);
line-height: var(--ai-lh-body);
&::placeholder { color: var(--ai-gray-light); }
}
}
.composer__send {
position: absolute;
right: 6px;
bottom: 6px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 0;
border-radius: 7px;
color: #ffffff;
background: var(--ai-accent);
cursor: pointer;
transition: background 0.16s ease;
.el-icon { font-size: 16px; }
&:hover:not(:disabled) { background: var(--ai-accent-deep); }
&:disabled { cursor: not-allowed; opacity: 0.45; }
}
/* ===== 助评:分数行 + 明细 ===== */
.score-row {
display: flex;
align-items: baseline;
justify-content: space-between;
padding: 12px 2px 10px;
border-bottom: 1px solid var(--ai-border);
}
.score-row__label {
color: var(--ai-text-2);
font-size: var(--ai-fs-caption);
font-weight: var(--ai-fw-emphasized);
}
.score-row__value { display: inline-flex; align-items: baseline; gap: 3px; }
.score-row__value strong {
color: var(--ai-text);
font-size: var(--ai-fs-display);
font-weight: 700;
line-height: 1.1;
font-variant-numeric: tabular-nums;
}
.score-row__value em {
color: var(--ai-gray);
font-size: var(--ai-fs-caption);
font-style: normal;
}
.review-scroll {
flex: 1 1 auto;
min-height: 120px;
padding: 4px 2px;
overflow-y: auto;
}
.review-result { display: flex; flex-direction: column; }
.review-summary {
margin: 10px 0 0;
color: var(--ai-text);
font-size: var(--ai-fs-body);
font-weight: var(--ai-fw-emphasized);
line-height: var(--ai-lh-body);
}
/* 评分明细:分隔线列表,不铺底色 */
.criteria { display: flex; flex-direction: column; margin-top: 4px; }
.criterion {
padding: 8px 0;
border-bottom: 1px solid var(--ai-border);
}
.criterion__top {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
}
.criterion__name {
color: var(--ai-text);
font-size: var(--ai-fs-body);
font-weight: var(--ai-fw-emphasized);
}
.criterion__score {
flex: 0 0 auto;
color: var(--ai-text);
font-size: var(--ai-fs-body);
font-weight: var(--ai-fw-emphasized);
font-variant-numeric: tabular-nums;
em {
color: var(--ai-gray);
font-size: var(--ai-fs-caption);
font-style: normal;
}
}
.criterion__desc {
margin: 4px 0 0;
color: var(--ai-gray);
font-size: var(--ai-fs-caption);
line-height: var(--ai-lh-caption);
}
/* 优先改进:浅灰块 + 墨黑左条 */
.improvements {
margin-top: 10px;
padding: 9px 10px 9px 11px;
border-left: 3px solid var(--ai-accent);
border-radius: 0 8px 8px 0;
background: var(--ai-soft-2);
}
.improvements__title {
display: block;
color: var(--ai-text);
font-size: var(--ai-fs-body);
font-weight: var(--ai-fw-emphasized);
}
.improvements ul {
display: grid;
gap: 4px;
margin: 6px 0 0;
padding-left: 16px;
color: var(--ai-text-2);
font-size: var(--ai-fs-caption);
line-height: var(--ai-lh-caption);
}
/* 空状态 */
.review-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 180px;
color: var(--ai-gray);
.el-icon { color: var(--ai-gray-light); font-size: 22px; }
p {
margin: 0;
font-size: var(--ai-fs-caption);
line-height: var(--ai-lh-caption);
text-align: center;
}
}
.review-hint {
margin: 10px 0 0;
color: var(--ai-gray);
font-size: var(--ai-fs-caption);
line-height: var(--ai-lh-caption);
}
/* 助评主按钮:墨黑实底 */
.review-primary {
display: inline-flex;
align-items: center;
justify-content: center;
/* 防窄屏换行:按钮文字必须单行 */
white-space: nowrap;
width: 100%;
height: 40px;
margin-top: 10px;
padding: 0 12px;
border: 1px solid var(--ai-accent);
border-radius: 10px;
color: #ffffff;
background: var(--ai-accent);
box-shadow: 0 3px 10px rgba(37, 99, 235, 0.22);
font-family: inherit;
font-size: var(--ai-fs-body);
font-weight: var(--ai-fw-emphasized);
cursor: pointer;
transition: background 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease;
&:hover:not(:disabled) {
border-color: var(--ai-accent-deep);
background: var(--ai-accent-deep);
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.28);
}
&:disabled { cursor: not-allowed; opacity: 0.5; box-shadow: none; }
}
</style>