This commit is contained in:
2025-08-10 10:01:43 +08:00
commit b9f9eb8b3a
33 changed files with 3625 additions and 0 deletions
+779
View File
@@ -0,0 +1,779 @@
<template>
<div class="pendulum-analysis" role="dialog" aria-modal="true">
<div class="safe-area">
<header class="header">
<h2 class="title">单摆分析</h2>
<div class="header-actions">
<button
class="btn ghost"
@click="onRetry"
:disabled="loading"
v-show="!showLengthInput"
>
{{ loading ? '分析中…' : '重新分析' }}
</button>
<button class="btn danger" @click="closeAnalysis" aria-label="关闭">×</button>
</div>
</header>
<!-- 状态条 -->
<div class="status-bar" v-if="loading || error || lastUpdated">
<div class="loading" v-if="loading">
<div class="spinner" aria-hidden="true"></div>
<span>正在分析数据,请稍候…</span>
</div>
<div class="error" v-else-if="error">
<span>{{ error }}</span>
<button class="btn small" @click="onRetry">重试</button>
</div>
<div class="meta" v-else-if="lastUpdated">
<span>上次更新:{{ lastUpdated }}</span>
<span v-if="sessionId">会话:{{ sessionId.slice(0, 8) }}</span>
</div>
</div>
<main class="content">
<!-- 摆长输入界面 -->
<section v-if="showLengthInput" class="length-input-section">
<h3>请输入摆长参数</h3>
<div class="input-form">
<div class="input-group">
<label for="pendulum-length">摆长 (米):</label>
<input
id="pendulum-length"
v-model.number="pendulumLength"
type="number"
step="0.001"
min="0.1"
max="10"
class="length-input"
placeholder="请输入摆长,如 1.0"
/>
</div>
<div class="input-actions">
<button class="btn primary" @click="startAnalysis" :disabled="!isValidLength">
开始分析
</button>
<button class="btn ghost" @click="closeAnalysis">
取消
</button>
</div>
</div>
<div class="input-tips">
<p>提示:</p>
<ul>
<li>摆长是从悬挂点到小球中心的距离</li>
<li>请确保输入的摆长单位为米</li>
<li>典型的实验室单摆摆长范围:0.5-2.0米</li>
</ul>
</div>
</section>
<!-- 分析结果界面(原有内容) -->
<template v-else>
<!-- 左侧:图片栅格(横屏优先) -->
<section class="results" aria-label="分析图像">
<!-- 骨架屏 -->
<div v-if="loading && !results.length" class="skeleton-grid">
<div class="skeleton-card" v-for="i in 3" :key="'skel-' + i"></div>
</div>
<div v-else-if="results.length" class="image-grid">
<figure v-for="(image, index) in results" :key="index" class="result-item" @click="openLightbox(index)">
<img :src="image.url" :alt="image.title" class="result-image" loading="eager" decoding="async" />
<figcaption class="result-title">{{ image.title }}</figcaption>
</figure>
</div>
<!-- 空状态 -->
<div v-else class="empty">
上传数据后将显示分析图像
</div>
</section>
<!-- 右侧:参数信息(粘性面板) -->
<aside class="analysis-info" v-if="analysisInfo">
<h3>分析结果</h3>
<div class="info-grid">
<div class="info-item" v-for="row in formattedInfo" :key="row.key">
<div class="info-label">{{ row.label }}</div>
<div class="info-value">{{ row.value }}</div>
</div>
</div>
<div class="tips">
<p>提示:</p>
<ul>
<li>点击左侧图像可全屏查看。</li>
<li>如网络抖动,可点“重新分析”。</li>
</ul>
</div>
</aside>
</template>
</main>
</div>
<!-- 图片灯箱 -->
<div v-if="lightbox.open" class="lightbox" @click.self="closeLightbox">
<button class="btn danger close-lightbox" @click="closeLightbox" aria-label="关闭预览">×</button>
<div class="lightbox-inner">
<img :src="results[lightbox.index].url" :alt="results[lightbox.index].title" />
<div class="lightbox-caption">{{ results[lightbox.index].title }}</div>
</div>
<button v-if="results.length > 1" class="nav prev" @click.stop="navLightbox(-1)" aria-label="上一张">‹</button>
<button v-if="results.length > 1" class="nav next" @click.stop="navLightbox(1)" aria-label="下一张">›</button>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue';
type RawPoint = { frame: number; time: number; x: number; y: number };
const props = withDefaults(defineProps<{
data: RawPoint[],
/** 可选:后端基础地址,如 'http://localhost:5000';默认同源 */
apiBase?: string,
/** 可选:组件打开时自动分析 */
autoAnalyze?: boolean
}>(), {
apiBase: '',
autoAnalyze: false
});
const emit = defineEmits(['close']);
const loading = ref(false);
const error = ref('');
const results = ref<{ url: string; title: string }[]>([]);
const analysisInfo = ref<Record<string, any> | null>(null);
const sessionId = ref<string | null>(null);
const lastUpdated = ref<string | null>(null);
const pendulumLength = ref<number>(1.0); // 摆长,单位:米
const showLengthInput = ref(true); // 显示摆长输入界面
let controller: AbortController | null = null;
const closeAnalysis = () => emit('close');
const onRetry = () => analyzeData(true);
/** 验证摆长输入是否有效 */
const isValidLength = computed(() => {
return pendulumLength.value > 0.1 && pendulumLength.value <= 10;
});
/** 开始分析 */
const startAnalysis = () => {
if (!isValidLength.value) return;
showLengthInput.value = false;
analyzeData(true);
};
/** 将相对 URL 前缀化为 {apiBase}+path,保证跨域部署可用 */
const withBase = (url: string) => {
if (!url) return url;
try {
// 已是绝对 URL
new URL(url);
return url;
} catch {
const base = (props.apiBase || '').replace(/\/+$/, '');
return base + url;
}
};
/** 新后端字段映射与格式化 */
const labelMap: Record<string, string> = {
pendulum_length_m: '摆长',
gravity_acceleration_m_s2: '重力加速度',
damping_coefficient_s_inv: '阻尼系数 γ',
period_s: '周期',
angle_max_rad: '最大摆角'
};
const unitMap: Record<string, string> = {
pendulum_length_m: ' m',
gravity_acceleration_m_s2: ' m/s²',
damping_coefficient_s_inv: ' s⁻¹',
period_s: ' s',
angle_max_rad: ' rad'
};
const formatNumber = (v: any) => typeof v === 'number' ? (Math.abs(v) < 1e-3 ? v.toExponential(3) : v.toFixed(4)) : v;
const formattedInfo = computed(() => {
if (!analysisInfo.value) return [];
return Object.entries(analysisInfo.value).map(([key, val]) => ({
key,
label: labelMap[key] || key,
value: `${formatNumber(val)}${unitMap[key] || ''}`
}));
});
/** 生成 CSV(列满足后端:time, x, y, pendulum_length) */
const buildCsv = () => {
const lines = ['time,x,y,pendulum_length']; // 添加摆长列
for (const r of props.data) {
lines.push(`${r.time},${r.x},${r.y},${pendulumLength.value}`);
}
return lines.join('\n');
};
/** 图片标题 */
const getImageTitle = (index: number) => {
const titles = [
'单摆拟合分析',
'物理参数摘要',
'周期与摆角关系',
];
return titles[index] || `图像 ${index + 1}`;
};
/** 主流程:发送到 {apiBase}/api/analyze */
const analyzeData = async (force = false) => {
if (!props.data?.length) return;
if (loading.value && !force) return;
// 取消上一次请求
if (controller) controller.abort();
controller = new AbortController();
loading.value = true;
error.value = '';
if (force) results.value = [];
try {
const csv = buildCsv();
const fd = new FormData();
fd.append('csv_file', new Blob([csv], { type: 'text/csv' }), 'pendulum_data.csv');
const timeout = setTimeout(() => controller?.abort(), 60_000);
const endpoint = `${(props.apiBase || '').replace(/\/+$/, '')}/api/analyze`;
const resp = await fetch(endpoint, {
method: 'POST',
body: fd,
signal: controller.signal
}).finally(() => clearTimeout(timeout));
// 解析错误
if (!resp.ok) {
let msg = `服务器错误: ${resp.status}`;
try {
const j = await resp.json();
if (j?.error) msg = j.error;
} catch { /* ignore */ }
throw new Error(msg);
}
const json = await resp.json();
if (json.success === false) {
throw new Error(json.error || '分析失败');
}
// 结果
sessionId.value = json.session_id || null;
if (Array.isArray(json.images)) {
results.value = json.images.map((p: string, i: number) => ({
url: withBase(p),
title: getImageTitle(i)
}));
} else {
results.value = [];
}
analysisInfo.value = json.analysis_data || null;
lastUpdated.value = new Date().toLocaleString();
} catch (e: any) {
if (e?.name === 'AbortError') {
error.value = '请求已取消';
} else if (
e?.message?.includes('Failed to fetch') ||
e?.message?.includes('NetworkError') ||
e?.message?.includes('Network request failed')
) {
error.value = '网络异常,请检查连接后重试';
} else {
error.value = e?.message || '分析过程中发生错误';
}
} finally {
loading.value = false;
}
};
onMounted(() => {
if (props.autoAnalyze && props.data?.length) {
// 如果设置了自动分析,跳过摆长输入界面
showLengthInput.value = false;
analyzeData(true);
}
});
onBeforeUnmount(() => {
console.log('PendulumAnalysis component unmounted');
});
/** 灯箱逻辑 */
const lightbox = ref({ open: false, index: 0 });
const openLightbox = (i: number) => {
lightbox.value.open = true;
lightbox.value.index = i;
nextTick(() => {
// 禁止页面滚动(移动端体验更好)
document.body.style.overflow = 'hidden';
});
};
const closeLightbox = () => {
lightbox.value.open = false;
document.body.style.overflow = '';
};
const navLightbox = (step: number) => {
if (!results.value.length) return;
const n = results.value.length;
lightbox.value.index = (lightbox.value.index + step + n) % n;
};
</script>
<style scoped>
/* ========== 基础布局(横屏优先) ========== */
.pendulum-analysis {
position: fixed;
inset: 0;
z-index: 1000;
background: radial-gradient(1200px 800px at 70% -10%, rgba(0, 255, 170, 0.08), transparent 60%),
#0b0f12;
color: #e8f8f4;
-webkit-font-smoothing: antialiased;
overflow: auto;
}
.safe-area {
padding: calc(12px + env(safe-area-inset-top)) 16px calc(16px + env(safe-area-inset-bottom));
max-width: 1400px;
margin: 0 auto;
}
/* Header */
.header {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: 12px;
margin-bottom: 14px;
}
.title {
margin: 0;
font-size: 22px;
letter-spacing: 0.5px;
color: #00ffaa;
text-shadow: 0 0 12px rgba(0, 255, 170, 0.25);
}
.header-actions {
display: inline-flex;
align-items: center;
gap: 8px;
}
/* Buttons */
.btn {
appearance: none;
border: 1px solid rgba(255, 255, 255, 0.18);
background: rgba(255, 255, 255, 0.06);
color: #e8f8f4;
border-radius: 10px;
padding: 8px 12px;
font-size: 14px;
line-height: 1;
cursor: pointer;
transition: transform .05s ease, background .2s ease, border-color .2s ease;
}
.btn:hover {
background: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 255, 255, 0.28);
}
.btn:active {
transform: translateY(1px);
}
.btn.small {
padding: 6px 10px;
font-size: 12px;
}
.btn.ghost {
background: transparent;
}
.btn.danger {
border-color: rgba(255, 90, 90, 0.45);
color: #ffdede;
}
.btn.danger:hover {
background: rgba(255, 90, 90, 0.15);
}
/* 状态条 */
.status-bar {
display: flex;
align-items: center;
gap: 12px;
min-height: 44px;
padding: 8px 12px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(0, 0, 0, 0.25);
border-radius: 12px;
margin-bottom: 12px;
}
.loading,
.error,
.meta {
display: inline-flex;
align-items: center;
gap: 10px;
}
.spinner {
width: 18px;
height: 18px;
border: 2px solid rgba(0, 255, 170, 0.35);
border-top-color: #00ffaa;
border-radius: 50%;
animation: spin 0.9s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.error {
color: #ff7a7a;
}
/* 主内容区:横屏优先双列 */
.content {
display: grid;
grid-template-columns: 1.5fr 1fr;
/* 图像区域更大 */
gap: 14px;
}
@media (max-width: 1100px),
(orientation: portrait) {
.content {
grid-template-columns: 1fr;
}
}
/* 摆长输入界面 */
.length-input-section {
grid-column: 1 / -1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
text-align: center;
}
.length-input-section h3 {
margin: 0 0 24px 0;
color: #00ffaa;
font-size: 20px;
text-shadow: 0 0 12px rgba(0, 255, 170, 0.25);
}
.input-form {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 16px;
padding: 24px;
max-width: 400px;
width: 100%;
backdrop-filter: blur(10px);
}
.input-group {
display: grid;
grid-template-columns: 1fr;
gap: 8px;
margin-bottom: 20px;
}
.input-group label {
color: #d6efe8;
font-size: 14px;
text-align: left;
}
.length-input {
appearance: none;
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.08);
color: #e8f8f4;
border-radius: 10px;
padding: 12px 16px;
font-size: 16px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
transition: border-color 0.2s ease, background 0.2s ease;
}
.length-input:focus {
outline: none;
border-color: #00ffaa;
background: rgba(255, 255, 255, 0.12);
}
.length-input::placeholder {
color: rgba(232, 248, 244, 0.5);
}
.input-actions {
display: flex;
gap: 12px;
justify-content: center;
}
.btn.primary {
background: linear-gradient(135deg, #00ffaa, #00cc88);
border-color: #00ffaa;
color: #0b0f12;
font-weight: 600;
}
.btn.primary:hover {
background: linear-gradient(135deg, #00cc88, #00aa77);
}
.btn.primary:disabled {
opacity: 0.5;
cursor: not-allowed;
background: rgba(255, 255, 255, 0.1);
color: rgba(232, 248, 244, 0.5);
}
.input-tips {
margin-top: 16px;
font-size: 12px;
opacity: 0.85;
text-align: left;
max-width: 400px;
}
.input-tips ul {
margin: 4px 0 0 0;
line-height: 1.6;
}
/* 左侧:图像区 */
.results {
min-height: 40vh;
}
.image-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 12px;
}
@media (orientation: landscape) {
.image-grid {
grid-auto-rows: minmax(200px, 36vh);
}
}
.result-item {
position: relative;
display: grid;
grid-template-rows: 1fr auto;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.03);
border-radius: 14px;
overflow: hidden;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.35);
cursor: zoom-in;
min-height: 200px;
}
.result-item:hover {
border-color: rgba(0, 255, 170, 0.25);
}
.result-image {
width: 100%;
height: 100%;
min-height: 0;
object-fit: contain;
background: rgba(0, 0, 0, 0.25);
}
.result-title {
font-size: 12px;
padding: 6px 10px;
color: #cfeee6;
border-top: 1px solid rgba(255, 255, 255, 0.06);
backdrop-filter: blur(2px);
}
/* 骨架屏 */
.skeleton-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 12px;
}
.skeleton-card {
height: min(36vh, 320px);
border-radius: 14px;
background:
linear-gradient(90deg, rgba(255, 255, 255, 0.06) 25%, rgba(255, 255, 255, 0.12) 37%, rgba(255, 255, 255, 0.06) 63%) 0 0 / 400% 100%,
rgba(255, 255, 255, 0.04);
animation: shimmer 1.6s infinite;
border: 1px solid rgba(255, 255, 255, 0.06);
}
@keyframes shimmer {
0% {
background-position: 100% 0;
}
100% {
background-position: 0 0;
}
}
.empty {
opacity: 0.7;
text-align: center;
padding: 24px 0;
}
/* 右侧:参数面板(粘性) */
.analysis-info {
position: sticky;
top: 8px;
align-self: start;
border: 1px solid rgba(255, 255, 255, 0.12);
background: linear-gradient(180deg, rgba(0, 0, 0, 0.35), rgba(0, 0, 0, 0.2));
border-radius: 14px;
padding: 14px 16px;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.35);
}
.analysis-info h3 {
margin: 0 0 10px 0;
color: #00ffaa;
font-size: 16px;
text-align: left;
}
.info-grid {
display: grid;
grid-template-columns: 1fr;
gap: 8px;
}
.info-item {
display: grid;
grid-template-columns: 1fr auto;
gap: 10px;
border-bottom: 1px dashed rgba(255, 255, 255, 0.08);
padding-bottom: 8px;
}
.info-label {
color: #d6efe8;
font-size: 13px;
}
.info-value {
color: #00ffaa;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.tips {
margin-top: 10px;
font-size: 12px;
opacity: 0.85;
}
ul{
margin: 2px 0 0 0;
line-height: 1.5;
}
/* 灯箱 */
.lightbox {
position: fixed;
inset: 0;
z-index: 2000;
background: rgba(0, 0, 0, 0.86);
display: grid;
place-items: center;
padding: env(safe-area-inset-top) 20px env(safe-area-inset-bottom);
}
.lightbox-inner {
width: 95vw;
height: 86vh;
display: flex;
flex-direction: column;
gap: 8px;
}
.lightbox-inner img {
width: 100%;
height: 100%;
object-fit: contain;
background: #000;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.2);
}
.lightbox-caption {
text-align: center;
font-size: 13px;
color: #cfeee6;
}
.close-lightbox {
position: absolute;
top: 8px;
right: 8px;
}
.nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.2);
padding: 8px 12px;
border-radius: 10px;
cursor: pointer;
color: #fff;
font-size: 22px;
line-height: 1;
user-select: none;
}
.nav:hover {
background: rgba(255, 255, 255, 0.16);
}
.nav.prev {
left: 10px;
}
.nav.next {
right: 10px;
}
</style>