1277 lines
32 KiB
Vue
1277 lines
32 KiB
Vue
<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.\nA:fox\nB:foxes\nC:fox\'s\nD:foxes\''
|
||
"
|
||
>
|
||
示例试题
|
||
</button>
|
||
</div>
|
||
<textarea
|
||
v-model="textInput"
|
||
class="text-area"
|
||
placeholder="请粘贴英语试题文本,例如: Choose the best answer: The teacher asked the students _____ they had finished their homework. 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>
|