AI.Demo/src/views/ExamAnalysis.vue

1277 lines
32 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup>
import { ref, computed, onUnmounted } from "vue";
import { useRouter } from "vue-router";
import axios from "axios";
import {
GRS_API_KEY,
EXAM_API_URL,
EXAM_MODEL,
EXAM_TEMPERATURE,
EXAM_MAX_TOKENS,
IMAGE_MAX_SIZE_MB,
IMAGE_ALLOWED_TYPES_EXAM,
} from "@/config/index.js";
const router = useRouter();
const API_KEY = GRS_API_KEY;
const API_URL = EXAM_API_URL;
const SYSTEM_PROMPT = `你是一位专业的英语教师,请对以下英语试题进行深度解析。
请严格按照以下格式输出,每个部分用对应标题开头:
## 题干理解
(分析题目类型、题干含义、关键信息点)
## 考点识别
(识别本题考查的语法点、词汇点、语言技能等核心考点)
## 解题思路
(详细说明解题步骤和方法,帮助学生理解解题过程,思路讲解需符合中学生思维逻辑)
## 正确答案
(给出正确答案,如有选项请标明选项字母)
## 详细解析
(对答案进行深入解释,分析干扰项,总结规律和技巧)`;
// ── 状态 ──
const activeTab = ref("text"); // 'text' | 'image'
const status = ref("idle"); // 'idle' | 'analyzing' | 'done' | 'error'
const textInput = ref("");
const imageFile = ref(null); // { dataUrl, base64 }
const isDragOver = ref(false);
const fileInputRef = ref(null);
const errorMsg = ref("");
const rawContent = ref("");
const cancelTokenSource = ref(null);
// ── 五维度定义 ──
const DIMENSIONS = [
{ key: "understanding", title: "题干理解", icon: "book" },
{ key: "keypoints", title: "考点识别", icon: "target" },
{ key: "approach", title: "解题思路", icon: "lightbulb" },
{ key: "answer", title: "正确答案", icon: "check" },
{ key: "explanation", title: "详细解析", icon: "detail" },
];
// ── 解析流式内容为五个维度块 ──
const analysisBlocks = computed(() => {
const raw = rawContent.value;
const result = {};
const titles = ["题干理解", "考点识别", "解题思路", "正确答案", "详细解析"];
const keys = [
"understanding",
"keypoints",
"approach",
"answer",
"explanation",
];
titles.forEach((title, i) => {
const startReg = new RegExp(`##\\s*${title}`);
const nextTitles = titles
.slice(i + 1)
.map((t) => `##\\s*${t}`)
.join("|");
const endReg = nextTitles ? new RegExp(nextTitles) : null;
const startMatch = raw.match(startReg);
if (!startMatch) {
result[keys[i]] = "";
return;
}
const startIdx = startMatch.index + startMatch[0].length;
let endIdx = raw.length;
if (endReg) {
const endMatch = raw.slice(startIdx).match(endReg);
if (endMatch) endIdx = startIdx + endMatch.index;
}
result[keys[i]] = raw.slice(startIdx, endIdx).trim();
});
return result;
});
// 当前正在生成的维度(最后一个有内容的)
const activeBlockKey = computed(() => {
if (status.value !== "analyzing") return null;
const keys = [
"understanding",
"keypoints",
"approach",
"answer",
"explanation",
];
let last = null;
for (const k of keys) {
if (analysisBlocks.value[k] !== undefined && analysisBlocks.value[k] !== "")
last = k;
}
return last;
});
// ── 图片处理 ──
const readFileAsBase64 = (file) =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
const dataUrl = e.target.result;
resolve({ dataUrl, base64: dataUrl.split(",")[1] });
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
const handleFile = async (file) => {
if (!file) return;
const allowed = IMAGE_ALLOWED_TYPES_EXAM;
if (!allowed.includes(file.type)) {
errorMsg.value = "仅支持 JPG / PNG / WebP 格式的图片";
return;
}
if (file.size > IMAGE_MAX_SIZE_MB * 1024 * 1024) {
errorMsg.value = `图片大小不能超过 ${IMAGE_MAX_SIZE_MB}MB`;
return;
}
errorMsg.value = "";
const { dataUrl, base64 } = await readFileAsBase64(file);
imageFile.value = { dataUrl, base64, mimeType: file.type };
};
const onFileChange = (e) => handleFile(e.target.files[0]);
const onDrop = (e) => {
isDragOver.value = false;
handleFile(e.dataTransfer.files[0]);
};
const resetImage = () => {
imageFile.value = null;
if (fileInputRef.value) fileInputRef.value.value = "";
};
// ── 构建请求体OpenAI 兼容格式)──
const buildRequestBody = (imageBase64 = null, mimeType = "image/jpeg") => {
const userContent = [];
if (activeTab.value === "text") {
userContent.push({ type: "text", text: textInput.value });
} else {
userContent.push({ type: "text", text: "请分析图片中的英语试题" });
if (imageBase64) {
userContent.push({
type: "image_url",
image_url: { url: `data:${mimeType};base64,${imageBase64}` },
});
}
}
return {
model: EXAM_MODEL,
messages: [
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: userContent },
],
temperature: EXAM_TEMPERATURE,
max_tokens: EXAM_MAX_TOKENS,
stream: true,
};
};
// ── 提取 SSE delta 文本OpenAI 兼容格式)──
const extractTextDelta = (chunk) => {
let result = "";
const lines = chunk.split("\n");
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const jsonStr = line.slice(6).trim();
if (!jsonStr || jsonStr === "[DONE]") continue;
try {
const data = JSON.parse(jsonStr);
const text = data?.choices?.[0]?.delta?.content;
if (text) result += text;
} catch {
// 忽略解析失败的行
}
}
return result;
};
// ── 过滤 <think>...</think> 思考块 ──
const filterThinkBlocks = (text) => {
// 移除完整的 think 块
let filtered = text.replace(/<think>[\s\S]*?<\/think>/g, "");
// 如果 think 块还未闭合(流式中),移除从 <think> 开始到末尾的内容
filtered = filtered.replace(/<think>[\s\S]*$/, "");
return filtered;
};
// ── 开始分析 ──
const canAnalyze = computed(() => {
if (status.value === "analyzing") return false;
if (activeTab.value === "text") return textInput.value.trim().length > 0;
return imageFile.value !== null;
});
const startAnalysis = async () => {
if (!canAnalyze.value) return;
status.value = "analyzing";
errorMsg.value = "";
rawContent.value = "";
const imageBase64 =
activeTab.value === "image" ? imageFile.value?.base64 : null;
const mimeType =
activeTab.value === "image" ? imageFile.value?.mimeType : "image/jpeg";
cancelTokenSource.value = axios.CancelToken.source();
let lastLength = 0;
await axios
.post(API_URL, buildRequestBody(imageBase64, mimeType), {
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${API_KEY}`,
},
responseType: "text",
cancelToken: cancelTokenSource.value.token,
onDownloadProgress: (event) => {
const text = (event.target || event.event?.target)?.responseText || "";
if (!text) return;
const newChunk = text.slice(lastLength);
lastLength = text.length;
const delta = extractTextDelta(newChunk);
if (delta) {
rawContent.value = filterThinkBlocks(rawContent.value + delta);
}
},
})
.then(() => {
status.value = "done";
})
.catch((err) => {
if (axios.isCancel(err)) return;
console.error(err);
errorMsg.value =
err?.response?.data?.error?.message ||
err.message ||
"请求失败,请稍后重试";
status.value = "error";
});
};
const resetAll = () => {
status.value = "idle";
rawContent.value = "";
errorMsg.value = "";
};
// ── 基础 Markdown 渲染(加粗、换行)──
const renderContent = (text) => {
return text
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
.replace(/\n/g, "<br>");
};
const goBack = () => router.back();
onUnmounted(() => {
if (cancelTokenSource.value)
cancelTokenSource.value.cancel("组件卸载,取消请求");
});
</script>
<template>
<div class="page-container">
<!-- Nav Bar -->
<div class="nav-bar">
<button class="back-btn" @click="goBack">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="15 18 9 12 15 6" />
</svg>
<span>返回</span>
</button>
<h1 class="nav-title">英语试题 AI 分析</h1>
<div style="width: 72px"></div>
</div>
<!-- Error Banner -->
<div v-if="errorMsg" class="error-banner">
<span>{{ errorMsg }}</span>
<button class="error-close" @click="errorMsg = ''">✕</button>
</div>
<!-- Main Layout: Left + Right -->
<div class="main-layout">
<!-- Left Panel: Input -->
<div class="left-panel">
<!-- Input Card -->
<div class="input-card">
<!-- Tabs -->
<div class="tabs">
<button
class="tab-btn"
:class="{ active: activeTab === 'text' }"
@click="activeTab = 'text'"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
/>
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" />
</svg>
文本输入
</button>
<button
class="tab-btn"
:class="{ active: activeTab === 'image' }"
@click="activeTab = 'image'"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="3" y="3" width="18" height="18" rx="3" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" />
</svg>
图片上传
</button>
</div>
<!-- Text Input -->
<div v-if="activeTab === 'text'" class="tab-content">
<div class="text-input-header">
<button
class="example-btn"
@click="
textInput =
'A ______ is a small, clever animal with a red coat.\nAfox\nBfoxes\nCfox\'s\nDfoxes\''
"
>
示例试题
</button>
</div>
<textarea
v-model="textInput"
class="text-area"
placeholder="请粘贴英语试题文本,例如:&#10;&#10;Choose the best answer:&#10;The teacher asked the students _____ they had finished their homework.&#10;A. that B. if C. what D. whether"
:disabled="status === 'analyzing'"
></textarea>
<div class="char-count">{{ textInput.length }} 字符</div>
</div>
<!-- Image Upload -->
<div v-else class="tab-content">
<div
v-if="!imageFile"
class="upload-zone"
:class="{ 'drag-over': isDragOver }"
@click="fileInputRef.click()"
@dragover.prevent="isDragOver = true"
@dragleave.prevent="isDragOver = false"
@drop.prevent="onDrop"
>
<input
ref="fileInputRef"
type="file"
accept="image/jpeg,image/jpg,image/png,image/webp"
style="display: none"
@change="onFileChange"
/>
<div class="upload-icon-wrap">
<svg
width="36"
height="36"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
</div>
<p class="upload-title">点击或拖拽上传试题图片</p>
<p class="upload-hint">支持 JPG、PNG、WebP最大 10MB</p>
</div>
<div v-else class="image-preview-wrap">
<div class="image-preview-header">
<span class="preview-label">已上传图片</span>
<button
class="reupload-btn"
@click="resetImage"
:disabled="status === 'analyzing'"
>
重新上传
</button>
</div>
<img :src="imageFile.dataUrl" class="preview-img" />
</div>
</div>
</div>
<!-- Analyze Button -->
<div class="btn-wrap">
<button
class="analyze-btn"
:disabled="!canAnalyze"
@click="startAnalysis"
>
<span v-if="status === 'analyzing'" class="btn-spinner"></span>
<svg
v-else
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
</svg>
<span>{{
status === "analyzing" ? "分析中..." : "AI 深度分析"
}}</span>
</button>
<button
v-if="status === 'done' || status === 'error'"
class="reset-btn"
@click="resetAll"
>
重新分析
</button>
</div>
</div>
<!-- Right Panel: Result -->
<div class="right-panel">
<!-- Empty State -->
<div v-if="status === 'idle'" class="empty-state">
<div class="empty-icon">
<svg
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
</svg>
</div>
<p class="empty-title">等待分析</p>
<p class="empty-hint">在左侧输入试题内容点击「AI 深度分析」开始</p>
</div>
<!-- Result Area -->
<div
v-else-if="status === 'analyzing' || status === 'done'"
class="result-area"
>
<div class="result-header">
<div class="result-title-wrap">
<div
class="result-dot"
:class="{ pulse: status === 'analyzing' }"
></div>
<span class="result-title">{{
status === "analyzing" ? "AI 正在分析..." : "分析完成"
}}</span>
</div>
</div>
<div
v-for="(dim, idx) in DIMENSIONS"
:key="dim.key"
class="dim-card"
:class="{
'is-active': activeBlockKey === dim.key,
'has-content': analysisBlocks[dim.key],
}"
:style="{ animationDelay: `${idx * 0.08}s` }"
>
<div class="dim-header">
<!-- Book icon -->
<svg
v-if="dim.icon === 'book'"
class="dim-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
<path
d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"
/>
</svg>
<!-- Target icon -->
<svg
v-else-if="dim.icon === 'target'"
class="dim-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10" />
<circle cx="12" cy="12" r="6" />
<circle cx="12" cy="12" r="2" />
</svg>
<!-- Lightbulb icon -->
<svg
v-else-if="dim.icon === 'lightbulb'"
class="dim-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="9" y1="18" x2="15" y2="18" />
<line x1="10" y1="22" x2="14" y2="22" />
<path
d="M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0 0 18 8 6 6 0 0 0 6 8c0 1 .23 2.23 1.5 3.5A4.61 4.61 0 0 1 8.91 14"
/>
</svg>
<!-- Check icon -->
<svg
v-else-if="dim.icon === 'check'"
class="dim-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
<polyline points="22 4 12 14.01 9 11.01" />
</svg>
<!-- Detail icon -->
<svg
v-else
class="dim-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
<span class="dim-title">{{ dim.title }}</span>
</div>
<div class="dim-body">
<div
v-if="analysisBlocks[dim.key]"
class="dim-content"
v-html="renderContent(analysisBlocks[dim.key])"
></div>
<div v-else-if="status === 'analyzing'" class="dim-placeholder">
<span class="placeholder-dot"></span>
<span class="placeholder-dot"></span>
<span class="placeholder-dot"></span>
</div>
<span v-if="activeBlockKey === dim.key" class="cursor-blink"
>|</span
>
</div>
</div>
</div>
<!-- Error State -->
<div v-else-if="status === 'error'" class="empty-state error-state">
<div class="empty-icon error-icon">
<svg
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
</div>
<p class="empty-title">分析失败</p>
<p class="empty-hint">请检查网络连接后重试</p>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.page-container {
height: 100vh;
overflow: hidden;
background: var(--bg);
display: flex;
flex-direction: column;
font-family: "Inter", "PingFang SC", -apple-system, sans-serif;
}
/* Nav */
.nav-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
background: rgba(255, 255, 255, 0.03);
border-bottom: 1px solid var(--card-border);
backdrop-filter: blur(12px);
position: sticky;
top: 0;
z-index: 10;
}
.back-btn {
display: flex;
align-items: center;
gap: 0.35rem;
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
font-size: 0.95rem;
padding: 0.4rem 0.75rem;
border-radius: 8px;
transition: all 0.2s;
}
.back-btn:hover {
background: rgba(255, 255, 255, 0.07);
color: var(--text-primary);
}
.nav-title {
font-size: 1.2rem;
font-weight: 600;
margin: 0;
background: linear-gradient(135deg, #14b8a6, #0d9488);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
/* Main Layout */
.main-layout {
flex: 1;
min-height: 0;
display: flex;
overflow: hidden;
}
/* Left Panel */
.left-panel {
width: 420px;
min-width: 320px;
max-width: 480px;
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1.5rem;
border-right: 1px solid var(--card-border);
overflow-y: auto;
flex-shrink: 0;
}
/* Right Panel */
.right-panel {
flex: 1;
overflow-y: auto;
padding: 1.5rem;
display: flex;
flex-direction: column;
}
/* 自定义滚动条 */
.right-panel::-webkit-scrollbar {
width: 8px;
}
.right-panel::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.1);
border-radius: 4px;
margin: 4px 0;
}
.right-panel::-webkit-scrollbar-thumb {
background: rgba(20, 184, 166, 0.3);
border-radius: 4px;
transition: background 0.2s;
}
.right-panel::-webkit-scrollbar-thumb:hover {
background: rgba(20, 184, 166, 0.5);
}
.right-panel::-webkit-scrollbar-thumb:active {
background: rgba(20, 184, 166, 0.7);
}
/* Empty State */
.empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
color: var(--text-secondary);
opacity: 0.5;
}
.empty-icon {
color: var(--text-secondary);
opacity: 0.4;
}
.empty-title {
font-size: 1rem;
font-weight: 600;
margin: 0;
color: var(--text-primary);
}
.empty-hint {
font-size: 0.875rem;
margin: 0;
text-align: center;
}
.error-state {
opacity: 1;
}
.error-icon {
color: #f87171;
opacity: 1;
}
.error-state .empty-title {
color: #fca5a5;
}
/* Error Banner */
.error-banner {
display: flex;
align-items: center;
justify-content: space-between;
background: rgba(239, 68, 68, 0.12);
border: 1px solid rgba(239, 68, 68, 0.3);
border-bottom: 1px solid rgba(239, 68, 68, 0.3);
padding: 0.75rem 1.5rem;
color: #fca5a5;
font-size: 0.9rem;
animation: fadeInUp 0.2s ease;
flex-shrink: 0;
}
.error-close {
background: none;
border: none;
color: #fca5a5;
cursor: pointer;
font-size: 1rem;
opacity: 0.7;
transition: opacity 0.2s;
}
.error-close:hover {
opacity: 1;
}
/* Input Card */
.input-card {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--card-border);
border-radius: 20px;
overflow: hidden;
backdrop-filter: blur(12px);
}
/* Tabs */
.tabs {
display: flex;
border-bottom: 1px solid var(--card-border);
}
.tab-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 1rem;
background: none;
border: none;
color: var(--text-secondary);
font-size: 0.95rem;
cursor: pointer;
transition: all 0.2s;
position: relative;
}
.tab-btn::after {
content: "";
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(135deg, #14b8a6, #0d9488);
transform: scaleX(0);
transition: transform 0.25s;
}
.tab-btn.active {
color: #14b8a6;
}
.tab-btn.active::after {
transform: scaleX(1);
}
.tab-btn:hover:not(.active) {
color: var(--text-primary);
background: rgba(255, 255, 255, 0.03);
}
/* Tab Content */
.tab-content {
padding: 1.25rem;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.text-area {
width: 100%;
flex: 1;
min-height: 260px;
background: rgba(0, 0, 0, 0.2);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
color: var(--text-primary);
font-size: 0.95rem;
line-height: 1.7;
padding: 1rem;
resize: none;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
box-sizing: border-box;
font-family: inherit;
}
.text-area::placeholder {
color: var(--text-secondary);
opacity: 0.6;
}
.text-area:focus {
border-color: rgba(20, 184, 166, 0.5);
box-shadow: 0 0 0 3px rgba(20, 184, 166, 0.1);
}
.text-area:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.char-count {
text-align: right;
font-size: 0.8rem;
color: var(--text-secondary);
margin-top: 0.5rem;
opacity: 0.6;
}
/* Text Input Header */
.text-input-header {
display: flex;
align-items: center;
margin-bottom: 0.75rem;
}
.example-btn {
display: flex;
align-items: center;
gap: 0.35rem;
background: rgba(20, 184, 166, 0.1);
border: 1px solid rgba(20, 184, 166, 0.25);
color: #14b8a6;
border-radius: 8px;
padding: 0.35rem 0.85rem;
font-size: 0.85rem;
cursor: pointer;
transition: all 0.2s;
}
.example-btn:hover {
background: rgba(20, 184, 166, 0.2);
transform: translateY(-1px);
}
/* Upload Zone */
.upload-zone {
border: 2px dashed rgba(20, 184, 166, 0.3);
border-radius: 16px;
padding: 3rem 2rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
cursor: pointer;
transition: all 0.25s;
text-align: center;
}
.upload-zone:hover,
.upload-zone.drag-over {
border-color: #14b8a6;
background: rgba(20, 184, 166, 0.06);
transform: translateY(-2px);
box-shadow: 0 8px 32px rgba(20, 184, 166, 0.1);
}
.upload-icon-wrap {
width: 72px;
height: 72px;
border-radius: 50%;
background: rgba(20, 184, 166, 0.1);
border: 1px solid rgba(20, 184, 166, 0.25);
display: flex;
align-items: center;
justify-content: center;
color: #14b8a6;
transition: all 0.25s;
}
.upload-zone:hover .upload-icon-wrap,
.upload-zone.drag-over .upload-icon-wrap {
background: rgba(20, 184, 166, 0.18);
transform: scale(1.08);
}
.upload-title {
font-size: 1rem;
font-weight: 500;
color: var(--text-primary);
margin: 0;
}
.upload-hint {
font-size: 0.85rem;
color: var(--text-secondary);
margin: 0;
}
/* Image Preview */
.image-preview-wrap {
background: rgba(0, 0, 0, 0.15);
border: 1px solid var(--card-border);
border-radius: 12px;
overflow: hidden;
}
.image-preview-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--card-border);
}
.preview-label {
font-size: 0.875rem;
color: var(--text-secondary);
}
.reupload-btn {
background: rgba(20, 184, 166, 0.1);
border: 1px solid rgba(20, 184, 166, 0.25);
color: #14b8a6;
border-radius: 8px;
padding: 0.3rem 0.75rem;
font-size: 0.85rem;
cursor: pointer;
transition: all 0.2s;
}
.reupload-btn:hover:not(:disabled) {
background: rgba(20, 184, 166, 0.2);
}
.reupload-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.preview-img {
width: 100%;
max-height: 360px;
object-fit: contain;
display: block;
background: rgba(0, 0, 0, 0.2);
}
/* Analyze Button */
.btn-wrap {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
}
.analyze-btn {
display: flex;
align-items: center;
gap: 0.6rem;
background: linear-gradient(135deg, #14b8a6, #0d9488);
border: none;
color: white;
font-size: 1rem;
font-weight: 600;
padding: 0.875rem 2.5rem;
border-radius: 50px;
cursor: pointer;
transition: all 0.25s;
box-shadow: 0 4px 20px rgba(20, 184, 166, 0.35);
}
.analyze-btn:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 8px 28px rgba(20, 184, 166, 0.5);
}
.analyze-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
transform: none;
}
.reset-btn {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.12);
color: var(--text-secondary);
font-size: 0.9rem;
padding: 0.875rem 1.5rem;
border-radius: 50px;
cursor: pointer;
transition: all 0.2s;
}
.reset-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: var(--text-primary);
}
.btn-spinner {
width: 18px;
height: 18px;
border: 2px solid rgba(255, 255, 255, 0.35);
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
/* Result Area */
.result-area {
display: flex;
flex-direction: column;
gap: 1rem;
}
.result-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.result-title-wrap {
display: flex;
align-items: center;
gap: 0.6rem;
}
.result-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #14b8a6;
}
.result-dot.pulse {
animation: pulse 1.2s ease-in-out infinite;
}
.result-title {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
}
/* Dimension Cards */
.dim-card {
background: rgba(255, 255, 255, 0.02);
border: 1px solid var(--card-border);
border-radius: 16px;
overflow: hidden;
transition: border-color 0.3s, box-shadow 0.3s;
animation: fadeInUp 0.4s ease both;
}
.dim-card.is-active {
border-color: rgba(20, 184, 166, 0.4);
box-shadow: 0 0 24px rgba(20, 184, 166, 0.08);
}
.dim-card.has-content {
border-color: rgba(255, 255, 255, 0.1);
}
.dim-header {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.875rem 1.25rem;
border-bottom: 1px solid var(--card-border);
background: rgba(20, 184, 166, 0.04);
}
.dim-icon {
width: 18px;
height: 18px;
color: #14b8a6;
flex-shrink: 0;
}
.dim-title {
font-size: 0.95rem;
font-weight: 600;
color: #14b8a6;
}
.dim-body {
padding: 1rem 1.25rem;
min-height: 48px;
position: relative;
}
.dim-content {
font-size: 0.95rem;
color: var(--text-primary);
line-height: 1.8;
}
.dim-content :deep(strong) {
color: #14b8a6;
font-weight: 600;
}
/* Placeholder dots */
.dim-placeholder {
display: flex;
align-items: center;
gap: 6px;
padding: 0.5rem 0;
}
.placeholder-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: rgba(20, 184, 166, 0.4);
animation: bounce 1.2s ease-in-out infinite;
}
.placeholder-dot:nth-child(2) {
animation-delay: 0.2s;
}
.placeholder-dot:nth-child(3) {
animation-delay: 0.4s;
}
/* Cursor blink */
.cursor-blink {
display: inline-block;
color: #14b8a6;
font-weight: 300;
animation: blink 0.8s step-end infinite;
margin-left: 1px;
}
/* Animations */
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes pulse {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.5;
transform: scale(0.75);
}
}
@keyframes bounce {
0%,
80%,
100% {
transform: translateY(0);
}
40% {
transform: translateY(-6px);
}
}
@keyframes blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0;
}
}
@media (max-width: 768px) {
.main-layout {
flex-direction: column;
height: auto;
overflow: visible;
}
.left-panel {
width: 100%;
max-width: 100%;
border-right: none;
border-bottom: 1px solid var(--card-border);
overflow-y: visible;
}
.right-panel {
padding: 1.25rem 1rem;
}
.upload-zone {
padding: 2rem 1rem;
}
.analyze-btn {
padding: 0.875rem 1.75rem;
}
}
</style>