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

<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>