first commit

This commit is contained in:
2026-01-05 11:09:24 +08:00
commit 8f673e0095
47 changed files with 4317 additions and 0 deletions
+973
View File
@@ -0,0 +1,973 @@
<script setup lang="ts">
import { ref, computed, reactive, onMounted, onBeforeUnmount, watch } from 'vue';
import DeterminationMachine from './components/DeterminationMachine.vue';
//@ts-ignore
import SilverKnob from './components/SilverKnob.vue';
import Oscilloscope from './components/Oscilloscope.vue';
import SignalGen from './components/SignalGen.vue';
import Notebook from './components/Notebook.vue';
import ea from './assets/sounds';
import guidePng from './assets/入门引导.webp';
const distance = ref(20);
// Signal generator frequency (kHz)
const signalFrequencyKHz = ref(35.702);
// =========================
// 埋点(POST /api/tracker)
// =========================
const trackerSessionId = (() => {
try {
const key = 'sound-speed:tracker-session';
const existed = window.localStorage.getItem(key);
if (existed) return existed;
const id = `${Date.now()}-${Math.random().toString(16).slice(2)}`;
window.localStorage.setItem(key, id);
return id;
} catch {
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
})();
const postTracker = (event: string, data?: unknown) => {
const body = JSON.stringify({
event,
data,
ts: Date.now(),
sessionId: trackerSessionId,
step: currentStepTitle.value,
});
try {
if (typeof navigator !== 'undefined' && 'sendBeacon' in navigator) {
const blob = new Blob([body], { type: 'application/json' });
// @ts-ignore
navigator.sendBeacon('/api/tracker', blob);
return;
}
} catch {
// ignore
}
fetch('/api/tracker', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body,
keepalive: true,
}).catch(() => { /* ignore */ });
};
const makeThrottle = <T extends any[]>(fn: (...args: T) => void, ms: number) => {
let last = 0;
let timer: number | null = null;
let pending: T | null = null;
return (...args: T) => {
const now = Date.now();
const remain = ms - (now - last);
if (remain <= 0) {
last = now;
fn(...args);
return;
}
pending = args;
if (timer !== null) return;
timer = window.setTimeout(() => {
timer = null;
last = Date.now();
if (pending) fn(...pending);
pending = null;
}, remain);
};
};
const trackDistanceChange = makeThrottle((v: number) => {
postTracker('transducer_distance_change', { distanceMm: v });
}, 300);
const onDistanceChange = (v: number) => {
trackDistanceChange(v);
scheduleUpdateDmRightCablePaths();
};
const trackFrequencyChange = makeThrottle((v: number) => {
postTracker('signal_frequency_change', { frequencyKHz: v });
}, 300);
watch(signalFrequencyKHz, (v, prev) => {
// 初始化时避免打一条“变更”
if (prev === undefined) return;
trackFrequencyChange(v);
});
// 仪器位置状态
interface InstrumentState {
left: number; // 百分比
bottom: number; // 百分比
baseScale: number; // 基础缩放
zIndex: number; // 层级
}
const machine = ref<InstrumentState>({ left: 35, bottom: 40, baseScale: 0.8, zIndex: 0 });
const oscilloscope = ref<InstrumentState>({ left: 10, bottom: 23, baseScale: 1.2, zIndex: 0 });
const signalGen = ref<InstrumentState>({ left: 65, bottom: 17, baseScale: 0.95, zIndex: 0 });
const notebook = ref<InstrumentState>({ left: 12, bottom: 40, baseScale: 0.9, zIndex: 0 });
// 基于bottom值自动计算zIndex:bottom越小(越靠下),zIndex越大
const getAutoZIndex = (instrument: InstrumentState) => {
// 将bottom值反转,使得bottom=0时zIndex最大
return Math.round(1000 - instrument.bottom * 10);
};
// 透视计算:bottom越小(越靠下),scale应该越大(近大远小)
// bottom范围: 0-100%, 映射到scale: 1.5-0.5倍
const getPerspectiveScale = (instrument: InstrumentState) => {
// bottom越小越靠下,scale越大
const perspectiveFactor = 1.5 - (instrument.bottom / 100) * 1.0; // bottom=0时1.5, bottom=100时0.5
return instrument.baseScale * perspectiveFactor;
};
const machineStyle = computed(() => ({
left: `${machine.value.left}%`,
bottom: `${machine.value.bottom}%`,
transform: `scale(${getPerspectiveScale(machine.value)})`,
zIndex: getAutoZIndex(machine.value)
}));
const oscilloscopeStyle = computed(() => ({
left: `${oscilloscope.value.left}%`,
bottom: `${oscilloscope.value.bottom}%`,
transform: `scale(${getPerspectiveScale(oscilloscope.value)})`,
zIndex: getAutoZIndex(oscilloscope.value)
}));
const signalGenStyle = computed(() => ({
left: `${signalGen.value.left}%`,
bottom: `${signalGen.value.bottom}%`,
transform: `scale(${getPerspectiveScale(signalGen.value)})`,
zIndex: getAutoZIndex(signalGen.value)
}));
const notebookStyle = computed(() => ({
left: `${notebook.value.left}%`,
bottom: `${notebook.value.bottom}%`,
transform: `scale(${getPerspectiveScale(notebook.value)})`,
zIndex: getAutoZIndex(notebook.value)
}));
// 拖动功能
interface DragState {
isDragging: boolean;
startX: number;
startY: number;
startLeft: number;
startBottom: number;
}
const dragState = ref<DragState>({
isDragging: false,
startX: 0,
startY: 0,
startLeft: 0,
startBottom: 0
});
const currentInstrument = ref<InstrumentState | null>(null);
const startDrag = (instrument: InstrumentState, event: PointerEvent) => {
hideTooltip();
dragState.value = {
isDragging: true,
startX: event.clientX,
startY: event.clientY,
startLeft: instrument.left,
startBottom: instrument.bottom
};
currentInstrument.value = instrument;
// 防止文本选择和默认触摸行为
event.preventDefault();
event.stopPropagation();
};
const onPointerMove = (event: PointerEvent) => {
if (tooltip.visible && tooltipTargetEl.value) {
updateTooltipPosition(tooltipTargetEl.value);
}
if (!dragState.value.isDragging || !currentInstrument.value) return;
const tableWidth = 1600;
const tableHeight = 900;
// 计算移动距离(像素)
const deltaX = event.clientX - dragState.value.startX;
const deltaY = event.clientY - dragState.value.startY;
// 转换为百分比
const deltaLeftPercent = (deltaX / tableWidth) * 100;
const deltaBottomPercent = -(deltaY / tableHeight) * 100; // Y轴向下为正,bottom向上为正
// 更新位置
currentInstrument.value.left = Math.max(0, Math.min(100, dragState.value.startLeft + deltaLeftPercent));
currentInstrument.value.bottom = Math.max(0, Math.min(55, dragState.value.startBottom + deltaBottomPercent));
scheduleUpdateCablePaths();
};
const onPointerUp = () => {
dragState.value.isDragging = false;
currentInstrument.value = null;
};
// =========================
// 入门引导(全屏图片)
// =========================
const showGuide = ref(true);
const guideImgRef = ref<HTMLImageElement | null>(null);
const closeGuide = () => {
showGuide.value = false;
};
const onGuideClick = (e: MouseEvent) => {
const img = guideImgRef.value;
if (!img) return;
const rect = img.getBoundingClientRect();
// 仅当点击在图片范围内,且位于图片底部 1/8 区域时关闭
const x = e.clientX;
const y = e.clientY;
const inImg = x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
if (!inImg) return;
const yIn = y - rect.top;
if (yIn >= rect.height * 7 / 8) {
closeGuide();
}
};
// =========================
// 任务追踪(示波器/李萨如)
// =========================
const oscStatus = reactive({
vdch1: 320,
vdch2: 320,
currentModeIndex: 0,
xyMode: false,
power: false,
});
const onOscSettings = (payload: { vdch1: number; vdch2: number; currentModeIndex: number; xyMode: boolean; power: boolean }) => {
oscStatus.vdch1 = payload.vdch1;
oscStatus.vdch2 = payload.vdch2;
oscStatus.currentModeIndex = payload.currentModeIndex;
oscStatus.xyMode = payload.xyMode;
oscStatus.power = payload.power;
};
const trackOscSettings = makeThrottle((p: { vdch1: number; vdch2: number; currentModeIndex: number; xyMode: boolean; power: boolean }) => {
postTracker('osc_settings_change', p);
}, 300);
watch(() => ({
vdch1: oscStatus.vdch1,
vdch2: oscStatus.vdch2,
currentModeIndex: oscStatus.currentModeIndex,
xyMode: oscStatus.xyMode,
power: oscStatus.power,
}), (p) => {
trackOscSettings(p);
}, { deep: true });
const lissajousLineStreak = ref(0);
const lastLineK = ref<number | null>(null);
const onLissajousLine = (payload: { k: number; phaseRadTotal: number }) => {
// 仅在 X-Y 模式下计数
if (!oscStatus.xyMode) return;
if (lastLineK.value === null) {
lissajousLineStreak.value = 1;
lastLineK.value = payload.k;
return;
}
// 连续:k 必须递增 1;否则从 1 重新计数
if (payload.k === lastLineK.value + 1) {
lissajousLineStreak.value += 1;
} else {
lissajousLineStreak.value = 1;
}
lastLineK.value = payload.k;
};
watch(lissajousLineStreak, (v, prev) => {
if (v !== prev) postTracker('lissajous_line_streak', { streak: v });
});
// =========================
// 接线仿真(pin 点击连线)
// =========================
type PinId =
| 'osc:ch1'
| 'osc:ch2'
| 'sg:tx-wave'
| 'sg:tx-transducer'
| 'sg:rx-wave'
| 'sg:rx-transducer'
| 'dm:left'
| 'dm:right';
type WireKey =
| 'txWave_to_ch1'
| 'txTransducer_to_left'
| 'rxWave_to_ch2'
| 'rxTransducer_to_right';
// App.vue 中的 4 条线是否接上的追踪变量
const wiringConnected = reactive<Record<WireKey, boolean>>({
txWave_to_ch1: false,
txTransducer_to_left: false,
rxWave_to_ch2: false,
rxTransducer_to_right: false,
});
// =========================
// 步骤标题(document.title)
// =========================
const inRange = (v: number, a: number, b: number) => v >= a && v <= b;
const stage1Done = computed(() => {
return wiringConnected.txWave_to_ch1
&& wiringConnected.txTransducer_to_left
&& wiringConnected.rxWave_to_ch2
&& wiringConnected.rxTransducer_to_right;
});
const stage2Done = computed(() => {
return oscStatus.power
&& inRange(oscStatus.vdch1, 140, 220)
&& inRange(oscStatus.vdch2, 140, 220)
&& oscStatus.currentModeIndex === 2;
});
const stage3Done = computed(() => inRange(signalFrequencyKHz.value, 35.98, 36.02));
const stage4Done = computed(() => {
return oscStatus.xyMode && lissajousLineStreak.value >= 4;
});
const currentStepTitle = computed(() => {
if (!stage1Done.value) return '步骤一:仪器接线';
if (!stage2Done.value) return '步骤二:调整示波器设置';
if (!stage3Done.value) return '步骤三:找到固有频率';
if (!stage4Done.value) return '步骤四:相位比较法测声速';
return '全部完成';
});
watch(currentStepTitle, (t) => {
document.title = `声速测量实验 - ${t}`;
}, { immediate: true });
const tableRef = ref<HTMLDivElement | null>(null);
// =========================
// 悬停引导(Tooltip)
// =========================
type TooltipSide = 'right' | 'left';
const tooltip = reactive({
visible: false,
text: '',
x: 0,
y: 0,
side: 'right' as TooltipSide,
});
const tooltipTargetEl = ref<HTMLElement | null>(null);
const hideTooltip = () => {
tooltip.visible = false;
tooltipTargetEl.value = null;
};
const updateTooltipPosition = (el: HTMLElement) => {
const table = tableRef.value;
if (!table) return;
const tableRect = table.getBoundingClientRect();
const r = el.getBoundingClientRect();
const margin = 10;
const approxBubbleW = 280; // 用于判断是否需要翻转到左侧
// 锚点:元素右上角(必要时翻转到左上角)
const anchorY = r.top - tableRect.top - margin;
const rightX = r.right - tableRect.left + margin;
const leftX = r.left - tableRect.left - margin;
const hasRoomOnRight = rightX + approxBubbleW <= tableRect.width;
tooltip.side = hasRoomOnRight ? 'right' : 'left';
tooltip.x = hasRoomOnRight ? rightX : leftX;
// y 以上方为主,避免贴边
tooltip.y = Math.max(10, Math.min(tableRect.height - 10, anchorY));
};
const showTooltipForEl = (el: HTMLElement) => {
const raw = (el.dataset.tooltip ?? '').trim();
// 模板里为了可读性可能写成 "\\n",这里统一转换成真实换行
const text = raw.replace(/\\n/g, '\n');
if (!text) return;
tooltipTargetEl.value = el;
tooltip.text = text;
tooltip.visible = true;
updateTooltipPosition(el);
};
const findTooltipEl = (target: EventTarget | null): HTMLElement | null => {
if (!target) return null;
const t = target as HTMLElement;
if (!t?.closest) return null;
const el = t.closest<HTMLElement>('[data-tooltip]');
if (!el) return null;
const table = tableRef.value;
if (table && !table.contains(el)) return null;
return el;
};
const onTooltipPointerOver = (e: PointerEvent) => {
if (showGuide.value) return;
const el = findTooltipEl(e.target);
if (!el) return;
if (tooltipTargetEl.value === el && tooltip.visible) return;
showTooltipForEl(el);
};
const onTooltipPointerOut = (e: PointerEvent) => {
const current = tooltipTargetEl.value;
if (!current) return;
const to = e.relatedTarget as HTMLElement | null;
if (to && (current === to || current.contains(to))) return;
const nextEl = findTooltipEl(to);
if (nextEl) {
showTooltipForEl(nextEl);
return;
}
hideTooltip();
};
const tooltipStyle = computed(() => {
const base: Record<string, string> = {
left: `${tooltip.x}px`,
top: `${tooltip.y}px`,
};
base.transform = tooltip.side === 'right' ? 'translate(0, -50%)' : 'translate(-100%, -50%)';
return base;
});
const pinEls = new Map<PinId, HTMLElement>();
const selectedPin = ref<PinId | null>(null);
const syncPinConnectingClass = () => {
const selected = selectedPin.value;
for (const [id, el] of pinEls) {
el.classList.toggle('pin-connecting', selected === id);
}
};
type Cable = {
key: WireKey;
a: PinId;
b: PinId;
d: string;
};
const cables = ref<Cable[]>([]);
let cablesRaf: number | null = null;
let pendingCableUpdateAll = false;
let pendingCableUpdatePins: Set<PinId> | null = null;
const flushCablePathUpdate = () => {
const doAll = pendingCableUpdateAll;
const pins = pendingCableUpdatePins;
pendingCableUpdateAll = false;
pendingCableUpdatePins = null;
if (doAll || !pins) {
updateCablePaths();
} else {
updateCablePaths(pins);
}
};
const scheduleUpdateCablePaths = () => {
pendingCableUpdateAll = true;
pendingCableUpdatePins = null;
if (cablesRaf !== null) return;
cablesRaf = requestAnimationFrame(() => {
cablesRaf = null;
flushCablePathUpdate();
});
};
const scheduleUpdateCablePathsForPins = (pins: Iterable<PinId>) => {
if (pendingCableUpdateAll) return;
if (!pendingCableUpdatePins) pendingCableUpdatePins = new Set<PinId>();
for (const p of pins) pendingCableUpdatePins.add(p);
if (cablesRaf !== null) return;
cablesRaf = requestAnimationFrame(() => {
cablesRaf = null;
flushCablePathUpdate();
});
};
let dmRightCableLast = 0;
let dmRightCableTimer: number | null = null;
const scheduleUpdateDmRightCablePaths = () => {
const now = Date.now();
const remain = 1000 - (now - dmRightCableLast);
if (remain <= 0) {
dmRightCableLast = now;
scheduleUpdateCablePathsForPins(['dm:right']);
return;
}
if (dmRightCableTimer !== null) return;
dmRightCableTimer = window.setTimeout(() => {
dmRightCableTimer = null;
dmRightCableLast = Date.now();
scheduleUpdateCablePathsForPins(['dm:right']);
}, remain);
};
const normalizePair = (a: PinId, b: PinId) => {
return a < b ? `${a}|${b}` : `${b}|${a}`;
};
const REQUIRED: Array<{ a: PinId; b: PinId; key: WireKey }> = [
{ a: 'sg:tx-wave', b: 'osc:ch1', key: 'txWave_to_ch1' },
{ a: 'sg:tx-transducer', b: 'dm:left', key: 'txTransducer_to_left' },
{ a: 'sg:rx-wave', b: 'osc:ch2', key: 'rxWave_to_ch2' },
{ a: 'sg:rx-transducer', b: 'dm:right', key: 'rxTransducer_to_right' },
];
const requiredByPair = new Map<string, WireKey>();
for (const c of REQUIRED) {
requiredByPair.set(normalizePair(c.a, c.b), c.key);
}
const registerPin = (payload: { id: PinId; el: HTMLElement }) => {
pinEls.set(payload.id, payload.el);
// 新注册的 pin 也要立即同步“正在连接”样式
payload.el.classList.toggle('pin-connecting', selectedPin.value === payload.id);
if (payload.id === 'dm:right') scheduleUpdateDmRightCablePaths();
else scheduleUpdateCablePaths();
};
const unregisterPin = (id: PinId) => {
pinEls.delete(id);
if (id === 'dm:right') scheduleUpdateDmRightCablePaths();
else scheduleUpdateCablePaths();
};
const addCableIfNeeded = (key: WireKey, a: PinId, b: PinId) => {
if (cables.value.some((c) => c.key === key)) return;
cables.value.push({ key, a, b, d: '' });
scheduleUpdateCablePaths();
};
const alertWiringError = () => {
window.alert('接线错误:请按接线示意连接对应端口。');
};
const onPinClick = (id: PinId) => {
ea.play('线缆插入')
postTracker('pin_click', { pinId: id, hasSelected: selectedPin.value !== null });
if (selectedPin.value === null) {
selectedPin.value = id;
syncPinConnectingClass();
return;
}
const first = selectedPin.value;
selectedPin.value = null;
syncPinConnectingClass();
if (first === id) return;
const key = requiredByPair.get(normalizePair(first, id));
if (!key) {
postTracker('wire_error', { a: first, b: id });
alertWiringError();
return;
}
// 已经连过就不重复画线,但依然视为已接
const was = wiringConnected[key];
wiringConnected[key] = true;
addCableIfNeeded(key, first, id);
// 已存在的线也刷新一次,避免 DOM/布局变动导致的偏差
scheduleUpdateCablePaths();
if (!was) {
postTracker('wire_connected', { key, a: first, b: id });
}
};
const getPinCenterInTable = (pin: HTMLElement) => {
const table = tableRef.value;
if (!table) return null;
const tableRect = table.getBoundingClientRect();
const r = pin.getBoundingClientRect();
const x = r.left + r.width / 2 - tableRect.left;
const y = r.top + r.height / 2 - tableRect.top;
return { x, y };
};
const buildSagCablePath = (p1: { x: number; y: number }, p2: { x: number; y: number }) => {
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
const dist = Math.hypot(dx, dy);
const sag = Math.min(220, Math.max(50, dist * 0.18));
const c1x = p1.x + dx * 0.25;
const c1y = p1.y + sag;
const c2x = p1.x + dx * 0.75;
const c2y = p2.y + sag;
return `M ${p1.x.toFixed(2)} ${p1.y.toFixed(2)} C ${c1x.toFixed(2)} ${c1y.toFixed(2)} ${c2x.toFixed(2)} ${c2y.toFixed(2)} ${p2.x.toFixed(2)} ${p2.y.toFixed(2)}`;
};
const updateCablePaths = (onlyPins?: Set<PinId>) => {
for (const cable of cables.value) {
if (onlyPins && !onlyPins.has(cable.a) && !onlyPins.has(cable.b)) continue;
const aEl = pinEls.get(cable.a);
const bEl = pinEls.get(cable.b);
if (!aEl || !bEl) {
cable.d = '';
continue;
}
const p1 = getPinCenterInTable(aEl);
const p2 = getPinCenterInTable(bEl);
if (!p1 || !p2) {
cable.d = '';
continue;
}
cable.d = buildSagCablePath(p1, p2);
}
};
onMounted(() => {
scheduleUpdateCablePaths();
window.addEventListener('resize', scheduleUpdateCablePaths);
});
onBeforeUnmount(() => {
if (cablesRaf !== null) cancelAnimationFrame(cablesRaf);
window.removeEventListener('resize', scheduleUpdateCablePaths);
if (dmRightCableTimer !== null) window.clearTimeout(dmRightCableTimer);
});
</script>
<template>
<div ref="tableRef" class="table" @pointermove="onPointerMove"
@pointerover="onTooltipPointerOver" @pointerout="onTooltipPointerOut"
@pointerup="onPointerUp" @pointerleave="onPointerUp(); hideTooltip()">
<Transition name="guide">
<div v-if="showGuide" class="guide-overlay" @click="onGuideClick">
<img ref="guideImgRef" class="guide-img" :src="guidePng" alt="入门引导" draggable="false" />
</div>
</Transition>
<div v-if="tooltip.visible" class="tooltip" :class="`tooltip--${tooltip.side}`" :style="tooltipStyle">
<div class="tooltip-inner">{{ tooltip.text }}</div>
</div>
<svg class="cables" :width="1600" :height="900" viewBox="0 0 1600 900"
aria-hidden="true">
<defs>
<filter id="cableShadow" x="-50%" y="-50%" width="200%" height="200%">
<feDropShadow dx="0" dy="2" stdDeviation="2"
flood-color="rgba(0,0,0,0.55)" />
</filter>
</defs>
<g filter="url(#cableShadow)">
<g v-for="c in cables" :key="c.key">
<path v-if="c.d" :d="c.d" class="cable-base" />
<path v-if="c.d" :d="c.d" class="cable-highlight" />
</g>
</g>
</svg>
<div class="instrument-wrapper" :style="machineStyle">
<div class="drag-handle" data-tooltip="拖动把手可移动仪器位置" @pointerdown="startDrag(machine, $event)">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"
viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<circle cx="12" cy="5" r="1" />
<circle cx="12" cy="12" r="1" />
<circle cx="12" cy="19" r="1" />
<circle cx="8" cy="5" r="1" />
<circle cx="8" cy="12" r="1" />
<circle cx="8" cy="19" r="1" />
<circle cx="16" cy="5" r="1" />
<circle cx="16" cy="12" r="1" />
<circle cx="16" cy="19" r="1" />
</svg>
</div>
<DeterminationMachine v-model="distance" class="machine"
@register-pin="registerPin" @unregister-pin="unregisterPin"
@pin-click="onPinClick" @change="onDistanceChange" />
</div>
<div class="instrument-wrapper" :style="notebookStyle">
<div class="drag-handle" data-tooltip="拖动把手可移动仪器位置" @pointerdown="startDrag(notebook, $event)">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"
viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<circle cx="12" cy="5" r="1" />
<circle cx="12" cy="12" r="1" />
<circle cx="12" cy="19" r="1" />
<circle cx="8" cy="5" r="1" />
<circle cx="8" cy="12" r="1" />
<circle cx="8" cy="19" r="1" />
<circle cx="16" cy="5" r="1" />
<circle cx="16" cy="12" r="1" />
<circle cx="16" cy="19" r="1" />
</svg>
</div>
<Notebook class="machine" :wiring="wiringConnected"
:osc="{ vdch1: oscStatus.vdch1, vdch2: oscStatus.vdch2, currentModeIndex: oscStatus.currentModeIndex, xyMode: oscStatus.xyMode, power: oscStatus.power }"
:signal-frequency-k-hz="signalFrequencyKHz"
:lissajous-line-streak="lissajousLineStreak" />
</div>
<div class="instrument-wrapper" :style="oscilloscopeStyle">
<div class="drag-handle" data-tooltip="拖动把手可移动仪器位置" @pointerdown="startDrag(oscilloscope, $event)">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"
viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<circle cx="12" cy="5" r="1" />
<circle cx="12" cy="12" r="1" />
<circle cx="12" cy="19" r="1" />
<circle cx="8" cy="5" r="1" />
<circle cx="8" cy="12" r="1" />
<circle cx="8" cy="19" r="1" />
<circle cx="16" cy="5" r="1" />
<circle cx="16" cy="12" r="1" />
<circle cx="16" cy="19" r="1" />
</svg>
</div>
<Oscilloscope :distance="distance" :frequency="signalFrequencyKHz * 1000"
class="machine" @register-pin="registerPin"
@unregister-pin="unregisterPin" @pin-click="onPinClick"
@settings="onOscSettings" @lissajous-line="onLissajousLine" />
</div>
<div class="instrument-wrapper" :style="signalGenStyle">
<div class="drag-handle" data-tooltip="拖动把手可移动仪器位置" @pointerdown="startDrag(signalGen, $event)">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"
viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<circle cx="12" cy="5" r="1" />
<circle cx="12" cy="12" r="1" />
<circle cx="12" cy="19" r="1" />
<circle cx="8" cy="5" r="1" />
<circle cx="8" cy="12" r="1" />
<circle cx="8" cy="19" r="1" />
<circle cx="16" cy="5" r="1" />
<circle cx="16" cy="12" r="1" />
<circle cx="16" cy="19" r="1" />
</svg>
</div>
<SignalGen v-model="signalFrequencyKHz" class="machine"
@register-pin="registerPin" @unregister-pin="unregisterPin"
@pin-click="onPinClick" />
</div>
</div>
</template>
<style scoped>
.table {
width: 1700px;
height: 900px;
margin: 0 auto;
position: relative;
overflow: hidden;
background-image: url(./assets/桌面.webp);
background-size: cover;
background-position: center;
user-select: none;
}
.guide-overlay {
position: fixed;
inset: 0;
z-index: 9999;
background: rgba(0, 0, 0, 0.85);
display: flex;
align-items: center;
justify-content: center;
}
.guide-img {
width: 100vw;
height: 100vh;
object-fit: contain;
user-select: none;
}
.guide-enter-active,
.guide-leave-active {
transition: opacity 0.35s ease, transform 0.35s ease;
}
.guide-enter-from,
.guide-leave-to {
opacity: 0;
transform: translateY(10px);
}
.cables {
position: absolute;
inset: 0;
z-index: 850;
pointer-events: none;
}
.cable-base {
fill: none;
stroke: rgba(35, 35, 35, 0.95);
stroke-width: 10;
stroke-linecap: round;
stroke-linejoin: round;
}
.cable-highlight {
fill: none;
stroke: rgba(255, 255, 255, 0.16);
stroke-width: 4;
stroke-linecap: round;
stroke-linejoin: round;
}
.instrument-wrapper {
position: absolute;
filter: drop-shadow(0 10px 10px black);
transition: filter 0.2s;
}
.machine {
pointer-events: auto;
}
.drag-handle {
touch-action: none;
position: absolute;
top: -12px;
left: 50%;
transform: translateX(-50%);
width: 40px;
height: 24px;
background: linear-gradient(135deg, rgba(100, 100, 100, 0.9), rgba(60, 60, 60, 0.95));
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
cursor: grab;
z-index: 1000;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3), inset 0 1px 2px rgba(255, 255, 255, 0.2);
opacity: 0.7;
transition: all 0.2s;
border: 1px solid rgba(255, 255, 255, 0.1);
backdrop-filter: blur(4px);
}
.drag-handle:hover {
opacity: 1;
background: linear-gradient(135deg, rgba(120, 120, 120, 0.95), rgba(80, 80, 80, 1));
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.5), inset 0 1px 2px rgba(255, 255, 255, 0.3);
transform: translateX(-50%) scale(1.1);
}
.drag-handle:active {
cursor: grabbing;
background: linear-gradient(135deg, rgba(80, 80, 80, 1), rgba(50, 50, 50, 1));
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.6), inset 0 1px 2px rgba(255, 255, 255, 0.2);
}
.drag-handle svg {
width: 20px;
height: 20px;
color: rgba(255, 255, 255, 0.8);
filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.5));
}
.instrument-wrapper:hover .drag-handle {
opacity: 0.9;
}
.tooltip {
position: absolute;
z-index: 9000;
pointer-events: none;
max-width: 280px;
}
.tooltip-inner {
background:
linear-gradient(rgba(255, 255, 255, 0.98), rgba(255, 255, 255, 0.96)),
repeating-linear-gradient(
0deg,
rgba(0, 0, 0, 0.035) 0px,
rgba(0, 0, 0, 0.035) 1px,
rgba(0, 0, 0, 0.0) 10px,
rgba(0, 0, 0, 0.0) 18px
);
color: rgba(20, 20, 20, 0.95);
border: 2px solid rgba(20, 20, 20, 0.70);
padding: 10px 12px;
border-radius: 12px;
font-size: 14px;
line-height: 1.3;
box-shadow: 4px 5px 0 rgba(0, 0, 0, 0.18), 0 12px 18px rgba(0, 0, 0, 0.18);
white-space: pre-line;
transform: rotate(-0.6deg);
}
.tooltip::before {
content: '';
position: absolute;
z-index: 1;
bottom: 10px;
width: 10px;
height: 10px;
background: rgba(255, 255, 255, 0.97);
border: 2px solid rgba(20, 20, 20, 0.70);
transform: rotate(45deg);
}
.tooltip--right::before {
left: -5px;
border-right: 0;
border-top: 0;
}
.tooltip--left::before {
right: -5px;
border-left: 0;
border-bottom: 0;
}
</style>
Binary file not shown.
Binary file not shown.
+14
View File
@@ -0,0 +1,14 @@
import { EasyAudio } from "./utils";
import 电源按钮 from "./电源按钮.mp3";
import 发生器按钮 from './发生器按钮.mp3'
import 换挡 from './换挡.mp3'
import 线缆插入 from './线缆插入.mp3'
const ea = new EasyAudio([
{ name: "电源按钮", audioUrl: 电源按钮, volume: 1 },
{ name: "发生器按钮", audioUrl: 发生器按钮, volume: 1 },
{ name: "换挡", audioUrl: 换挡, volume: 1 },
{ name: "线缆插入", audioUrl: 线缆插入, volume: 1 },
]);
export default ea;
+114
View File
@@ -0,0 +1,114 @@
export class EasyAudio {
private static audioCtx: AudioContext | null = null;
private buffers: Map<string, AudioBuffer> = new Map();
private sources: Map<string, AudioBufferSourceNode> = new Map();
private audioOptions: Map<
string,
{ audioUrl: string; loop?: boolean; volume?: number }
> = new Map();
private get audioCtx(): AudioContext {
if (!EasyAudio.audioCtx) {
EasyAudio.audioCtx = new AudioContext();
}
return EasyAudio.audioCtx;
}
constructor(
audios?:
| { name: string; audioUrl: string | URL; loop?: boolean; volume?: number }
| { name: string; audioUrl: string | URL; loop?: boolean; volume?: number }[]
) {
if (audios) {
this.add(audios);
}
}
private async createAudioBuffer(audioUrl: string): Promise<AudioBuffer> {
const response = await fetch(audioUrl);
const arrayBuffer = await response.arrayBuffer();
return await this.audioCtx.decodeAudioData(arrayBuffer);
}
private async loadAudio(name: string) {
const options = this.audioOptions.get(name);
if (!options) {
throw new Error(`音频 ${name} 未找到`);
}
const buffer = await this.createAudioBuffer(options.audioUrl);
this.buffers.set(name, buffer);
}
async load(): Promise<void> {
const promises = Array.from(this.audioOptions.keys()).map(name =>
this.loadAudio(name)
);
await Promise.all(promises);
}
add(
audios:
| { name: string; audioUrl: string | URL; loop?: boolean; volume?: number }
| { name: string; audioUrl: string | URL; loop?: boolean; volume?: number }[]
): void {
if (Array.isArray(audios)) {
audios.forEach(audio => {
this.audioOptions.set(audio.name, {
audioUrl: typeof (audio.audioUrl) === "string" ? audio.audioUrl : audio.audioUrl.href,
loop: audio.loop,
volume: audio.volume,
});
});
} else {
this.audioOptions.set(audios.name, {
audioUrl: typeof (audios.audioUrl) === "string" ? audios.audioUrl : audios.audioUrl.href,
loop: audios.loop,
volume: audios.volume,
});
}
}
async play(name: string) {
if (!this.buffers.has(name)) {
await this.loadAudio(name);
}
const buffer = this.buffers.get(name);
const options = this.audioOptions.get(name);
if (!buffer || !options) {
throw new Error(`音频 ${name} 未找到`);
}
const source = this.audioCtx.createBufferSource();
source.buffer = buffer;
source.loop = options.loop || false;
let gainNode: GainNode | null = null;
if (options.volume !== undefined) {
gainNode = this.audioCtx.createGain();
gainNode.gain.value = options.volume;
source.connect(gainNode);
gainNode.connect(this.audioCtx.destination);
} else {
source.connect(this.audioCtx.destination);
}
source.start();
this.sources.set(name, source);
console.log(`音频 ${name} 播放`);
}
stop(name: string) {
const source = this.sources.get(name);
if (source) {
source.stop();
this.sources.delete(name);
}
}
stopAll() {
this.sources.forEach(source => source.stop());
this.sources.clear();
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+8
View File
@@ -0,0 +1,8 @@
@font-face {
font-family: 'AF-LED7 Seg-3';
src: url('AF-LED7Seg-3.woff2') format('woff2');
font-weight: 500;
font-style: normal;
font-display: swap;
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

+121
View File
@@ -0,0 +1,121 @@
<template>
<div class="dm">
<MetalRuler :model-value="totalValue" class="ruler" />
<img draggable="false" src="../assets/仪器.webp" alt="">
<img draggable="false" src="../assets/仪器臂.webp" alt="" class="handle"
:style="{ '--p': totalValue / 300 }">
<SilverKnob :initial-value="totalValue * 100" :sensitivity="0.5" data-tooltip="距离调节旋钮:改变两换能器间距(mm),用于测相位差"
@rotate="handleRotation" class="knob" />
<!-- 左侧输入 -->
<div ref="pinLeftEl" class="pin" data-tooltip="左端口:应连接信号发生器“发射端-换能器”\n提示:先点一个端口,再点另一个端口完成连线" style="left: 9.5%; top: 8.7%;"
@click.stop="handlePinClick('dm:left')"></div>
<!-- 右侧输出 -->
<div ref="pinRightEl" class="pin" data-tooltip="右端口:应连接信号发生器“接收端-换能器”\n提示:先点一个端口,再点另一个端口完成连线" style=" top: 8.7%;"
:style="{ left: (0.244 + totalValue / 300*.54)*100 + '%' }"
@click.stop="handlePinClick('dm:right')"></div>
</div>
</template>
<style lang="scss" scoped>
.pin {
height: 30px;
width: 30px;
position: absolute;
background-color: black;
border-radius: 50%;
cursor: pointer;
}
.dm {
height: 300px;
width: 800px;
position: relative;
margin-top: 40px;
}
img {
height: 100%;
}
.handle {
position: absolute;
inset: 0;
left: calc(19% + var(--p) * 54%);
}
.ruler {
position: absolute;
inset: 0;
width: 100%;
top: -70px;
transform: scale(0.9);
}
.knob {
position: absolute;
right: -130px;
}
</style>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, nextTick, watch } from 'vue';
//@ts-ignore
import MetalRuler from './MetalRuler.vue';
//@ts-ignore
import SilverKnob from './SilverKnob.vue';
const emit = defineEmits<{
(e: "update:modelValue", value: number): void;
(e: "change", value: number): void; // 距离改变时触发
(e: 'register-pin', payload: { id: 'dm:left' | 'dm:right'; el: HTMLElement }): void;
(e: 'unregister-pin', id: 'dm:left' | 'dm:right'): void;
(e: 'pin-click', id: 'dm:left' | 'dm:right'): void;
}>();
const totalValue = ref(20);
const pinLeftEl = ref<HTMLElement | null>(null);
const pinRightEl = ref<HTMLElement | null>(null);
const registerPins = () => {
if (pinLeftEl.value) emit('register-pin', { id: 'dm:left', el: pinLeftEl.value });
if (pinRightEl.value) emit('register-pin', { id: 'dm:right', el: pinRightEl.value });
};
const unregisterPins = () => {
emit('unregister-pin', 'dm:left');
emit('unregister-pin', 'dm:right');
};
const handlePinClick = (id: 'dm:left' | 'dm:right') => {
emit('pin-click', id);
};
const handleRotation = (data: any) => {
const newValue = data.total / 100;
totalValue.value = newValue;
emit("update:modelValue", newValue);
emit("change", newValue);
};
onMounted(async () => {
await nextTick();
registerPins();
});
// 右侧输出 pin 会随 totalValue 平移,确保它变动后仍然注册到最新 DOM
watch(totalValue, async () => {
await nextTick();
registerPins();
});
onBeforeUnmount(() => {
unregisterPins();
});
</script>
+186
View File
@@ -0,0 +1,186 @@
<template>
<div class="ruler-container" >
<div class="ruler-body" :style="{transform: showing?'rotateX(0deg)':'rotateX(60deg) translateY(36px)'}" @pointerdown.prevent="showing=!showing">
<div class="ticks-wrapper">
<div
v-for="i in (max + 1)"
:key="i"
class="tick-item"
:style="{ left: `${((i - 1) / max) * 100}%` }"
>
<div
class="line"
:class="{
'line-major': (i - 1) % 10 === 0,
'line-medium': (i - 1) % 10 !== 0 && (i - 1) % 5 === 0,
'line-minor': (i - 1) % 5 !== 0
}"
></div>
<div
v-if="(i - 1) % 10 === 0"
class="tick-label"
>
{{ i - 1 }}
<span v-if="i - 1 === max" class="unit-mm">mm</span>
</div>
</div>
<div
class="pointer-indicator"
:style="{ left: `${(clampedValue / max) * 100}%` }"
>
<div class="pointer-head"></div>
<div class="pointer-line"></div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, ref } from 'vue';
const showing = ref(false);
const props = defineProps({
modelValue: { type: Number, default: 0 },
max: { type: Number, default: 300 }
});
const clampedValue = computed(() => {
return Math.min(Math.max(props.modelValue, 0), props.max);
});
</script>
<style scoped>
.ruler-container {
perspective: 800px;
width: 100%;
min-width: 800px;
box-sizing: border-box;
}
.ruler-body {
position: relative;
height: 80px;
width: 100%;
border-radius: 4px;
/* 金属质感背景 */
background: linear-gradient(
180deg,
#f2f2f2 0%,
#e0e0e0 15%,
#d1d1d1 50%,
#e0e0e0 85%,
#f2f2f2 100%
);
box-shadow:
0 2px 5px rgba(0,0,0,0.3),
inset 0 1px 2px rgba(255,255,255,0.9),
inset 0 -1px 2px rgba(0,0,0,0.1);
border: 1px solid #999;
font-family: 'Arial', sans-serif;
user-select: none;
transition: transform 0.3s ease;
}
.brand-logo {
position: absolute;
top: 25px;
left: 15px;
color: #cc3333;
font-weight: bold;
font-size: 16px;
font-family: 'Impact', sans-serif;
z-index: 1;
letter-spacing: 1px;
}
/* 刻度容器:核心定位层 */
.ticks-wrapper {
position: relative;
/* 这里留出的边距是整个尺子的“安全区” */
width: calc(100% - 60px);
height: 100%;
margin: 0 auto; /* 居中,左右各留30px */
}
.tick-item {
position: absolute;
bottom: 0;
height: 100%;
width: 0; /* 设为0,完全依赖 visible 的边框或子元素,避免自身宽度影响定位 */
display: flex;
flex-direction: column;
align-items: center;
z-index: 2; /* 刻度在下层 */
}
.line {
margin-top: 15px;
width: 1px; /* 默认线宽 */
background-color: #333;
}
.line-major { height: 22px; background-color: #000; width: 2px; }
.line-medium { height: 14px; background-color: #333; }
.line-minor { height: 8px; background-color: #666; }
/* 文本标签 */
.tick-label {
position: absolute;
top: 42px;
font-size: 12px;
color: #333;
font-weight: 600;
white-space: nowrap;
}
/* 修复单位问题:绝对定位到数字右侧 */
.unit-mm {
position: absolute;
left: 100%; /* 紧贴数字右侧 */
bottom: 0; /* 底部对齐 */
font-size: 10px;
margin-left: 2px;
color: #555;
font-weight: normal;
}
/* --- 指针样式 --- */
.pointer-indicator {
position: absolute;
top: 0;
height: 100%;
width: 0; /* 自身宽度为0,完全靠left定位中心 */
z-index: 10; /* 浮在刻度之上 */
pointer-events: none;
}
.pointer-line {
position: absolute;
top: 15px; /* 这里不需要顶满,根据图示,指针是在刻度区域 */
height: 35px; /* 覆盖刻度线长度 */
width: 2px;
background-color: rgba(220, 0, 0, 0.9);
/* 居中核心:向左移自身宽度的一半 */
transform: translateX(-50%);
box-shadow: 0 0 2px rgba(255, 0, 0, 0.5);
}
.pointer-head {
position: absolute;
top: 8px;
left: 0;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 6px solid #cc0000; /* 倒三角 */
/* 或者做成图中的游标滑块样式 */
}
</style>
+742
View File
@@ -0,0 +1,742 @@
<script setup lang="ts">
import { computed, ref, onMounted, watch } from 'vue';
type WiringStatus = {
txWave_to_ch1: boolean;
txTransducer_to_left: boolean;
rxWave_to_ch2: boolean;
rxTransducer_to_right: boolean;
};
type OscStatus = {
vdch1: number;
vdch2: number;
currentModeIndex: number;
xyMode: boolean;
power: boolean;
};
const props = defineProps<{
wiring: WiringStatus;
osc: OscStatus;
signalFrequencyKHz: number;
lissajousLineStreak: number;
}>();
const inRange = (v: number, a: number, b: number) => v >= a && v <= b;
const stage1Done = computed(() => {
const w = props.wiring;
return w.txWave_to_ch1 && w.txTransducer_to_left && w.rxWave_to_ch2 && w.rxTransducer_to_right;
});
const vdch1Ok = computed(() => inRange(props.osc.vdch1, 140, 220));
const vdch2Ok = computed(() => inRange(props.osc.vdch2, 140, 220));
const modeOk = computed(() => props.osc.currentModeIndex === 2);
const powerOk = computed(() => props.osc.power === true);
const stage2Done = computed(() => powerOk.value && vdch1Ok.value && vdch2Ok.value && modeOk.value);
const stage3Done = computed(() => inRange(props.signalFrequencyKHz, 35.98, 36.02));
const xyOk = computed(() => props.osc.xyMode === true);
const lineCount = computed(() => Math.max(0, Math.min(4, Math.trunc(props.lissajousLineStreak))));
const stage4Done = computed(() => xyOk.value && lineCount.value >= 4);
const allDone = computed(() => stage1Done.value && stage2Done.value && stage3Done.value && stage4Done.value);
const fmtKHz = (v: number) => (Number.isFinite(v) ? v.toFixed(3) : '--');
// ===== 翻页 =====
const currentPage = ref<1 | 2 | 3>(1);
const goPage = (p: 1 | 2 | 3) => {
currentPage.value = p;
};
// ===== 原始数据填写(第三页,自动保存) =====
type RawRow = { distanceMm: string; note: string };
type RawData = {
// 记录“直线出现”时对应的距离(mm),可自由扩展
rows: RawRow[];
// 找到的固有频率(kHz)
naturalFreqKHz: string;
};
const RAW_STORAGE_KEY = 'sound-speed-notebook:raw-data';
const rawData = ref<RawData>({
rows: Array.from({ length: 4 }, () => ({ distanceMm: '', note: '' })),
naturalFreqKHz: '',
});
const isValidNumber = (s: string) => {
const v = Number(s);
return s.trim() !== '' && Number.isFinite(v);
};
const rawComplete = computed(() => {
const freqOk = isValidNumber(rawData.value.naturalFreqKHz);
const distances = rawData.value.rows.map((r) => Number(r.distanceMm)).filter((v) => Number.isFinite(v));
return freqOk && distances.length >= 2;
});
const addRawRow = () => {
rawData.value.rows.push({ distanceMm: '', note: '' });
};
const removeRawRow = () => {
if (rawData.value.rows.length <= 1) return;
rawData.value.rows.pop();
};
const parsedDistances = computed(() => {
// 保留输入顺序,忽略空/非法
return rawData.value.rows
.map((r) => Number(r.distanceMm))
.filter((v) => Number.isFinite(v));
});
const deltasMm = computed(() => {
const ds = parsedDistances.value;
const deltas: number[] = [];
for (let i = 1; i < ds.length; i++) {
const prev = ds[i - 1];
const cur = ds[i];
if (prev === undefined || cur === undefined) continue;
deltas.push(Math.abs(cur - prev));
}
return deltas;
});
const avgDeltaMm = computed(() => {
const deltas = deltasMm.value;
if (deltas.length === 0) return null;
const sum = deltas.reduce((a, b) => a + b, 0);
return sum / deltas.length;
});
const naturalFreqKHz = computed(() => {
const v = Number(rawData.value.naturalFreqKHz);
// 若未填写/非法,回退到当前信号发生器频率(用于演示)
if (!Number.isFinite(v)) return null;
return v;
});
// 相位比较法:直线条件为相位差 nπ,连续直线对应 Δl = λ/2 => v = fλ = 2 f Δl
const soundSpeedMs = computed(() => {
const f = naturalFreqKHz.value;
const dl = avgDeltaMm.value;
if (f === null || dl === null) return null;
// 单位:f(kHz) * dl(mm) => m/s(因为 1000 Hz * 1e-3 m)
return 2 * f * dl;
});
// ===== 提交(第三页) =====
type SubmitState = 'idle' | 'submitting' | 'success' | 'error';
const submitState = ref<SubmitState>('idle');
const submitMessage = ref('');
const postJson = async (url: string, data: unknown) => {
const res = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(data),
});
return res;
};
const postTracker = (event: string, data?: unknown) => {
const body = JSON.stringify({ event, data, ts: Date.now() });
try {
if (typeof navigator !== 'undefined' && 'sendBeacon' in navigator) {
const blob = new Blob([body], { type: 'application/json' });
// @ts-ignore
navigator.sendBeacon('/api/tracker', blob);
return;
}
} catch {
// ignore
}
fetch('/api/tracker', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body,
keepalive: true,
}).catch(() => { /* ignore */ });
};
const submitRecord = async () => {
if (submitState.value === 'submitting') return;
if (!rawComplete.value) {
submitState.value = 'error';
submitMessage.value = '请至少填写 2 组有效距离,并填写固有频率(kHz)。';
postTracker('record_submit_blocked', {
reason: 'incomplete',
rows: rawData.value.rows.length,
validDistances: parsedDistances.value.length,
hasFreq: isValidNumber(rawData.value.naturalFreqKHz),
});
return;
}
submitState.value = 'submitting';
submitMessage.value = '提交中…';
const payload = {
ts: Date.now(),
raw: {
naturalFreqKHz: rawData.value.naturalFreqKHz,
rows: rawData.value.rows,
},
computed: {
avgDeltaMm: avgDeltaMm.value,
soundSpeedMs: soundSpeedMs.value,
deltasMm: deltasMm.value,
validDistancesMm: parsedDistances.value,
},
context: {
wiring: props.wiring,
osc: props.osc,
signalFrequencyKHz: props.signalFrequencyKHz,
lissajousLineStreak: props.lissajousLineStreak,
},
notes: notesText.value,
};
try {
const res = await postJson('/api/record', payload);
if (!res.ok) {
submitState.value = 'error';
submitMessage.value = `提交失败(${res.status})`;
postTracker('record_submit_failed', { status: res.status });
return;
}
submitState.value = 'success';
submitMessage.value = '提交成功';
postTracker('record_submitted', {
rows: rawData.value.rows.length,
validDistances: parsedDistances.value.length,
avgDeltaMm: avgDeltaMm.value,
soundSpeedMs: soundSpeedMs.value,
});
} catch (e: any) {
submitState.value = 'error';
submitMessage.value = '提交失败(网络错误)';
postTracker('record_submit_failed', { error: String(e?.message ?? e) });
}
};
// ===== 学生笔记(自由输入 + 自动保存) =====
const NOTES_STORAGE_KEY = 'sound-speed-notebook:notes';
const notesText = ref('');
onMounted(() => {
try {
const saved = window.localStorage.getItem(NOTES_STORAGE_KEY);
if (typeof saved === 'string') notesText.value = saved;
} catch {
// ignore
}
try {
const savedRaw = window.localStorage.getItem(RAW_STORAGE_KEY);
if (typeof savedRaw === 'string' && savedRaw.trim()) {
const parsed = JSON.parse(savedRaw) as Partial<RawData>;
const rows = parsed.rows;
const naturalFreqKHz = typeof (parsed as any)?.naturalFreqKHz === 'string' ? (parsed as any).naturalFreqKHz : '';
if (Array.isArray(rows) && rows.length > 0) {
rawData.value = {
rows: rows.map((r: any) => ({
distanceMm: String(r?.distanceMm ?? ''),
note: String(r?.note ?? ''),
})),
naturalFreqKHz,
};
} else {
rawData.value.naturalFreqKHz = naturalFreqKHz;
}
}
} catch {
// ignore
}
});
let saveTimer: number | undefined;
watch(notesText, () => {
if (saveTimer !== undefined) window.clearTimeout(saveTimer);
saveTimer = window.setTimeout(() => {
try {
window.localStorage.setItem(NOTES_STORAGE_KEY, notesText.value);
} catch {
// ignore
}
}, 250);
});
let saveRawTimer: number | undefined;
watch(rawData, () => {
if (saveRawTimer !== undefined) window.clearTimeout(saveRawTimer);
saveRawTimer = window.setTimeout(() => {
try {
window.localStorage.setItem(RAW_STORAGE_KEY, JSON.stringify(rawData.value));
} catch {
// ignore
}
}, 250);
}, { deep: true });
</script>
<template>
<div class="notebook">
<div class="header">
<div class="title">记事本</div>
<div class="right">
<div class="pager" role="tablist" aria-label="记事本翻页">
<button class="page-btn" :class="{ active: currentPage === 1 }" type="button" @click="goPage(1)">步骤</button>
<button class="page-btn" :class="{ active: currentPage === 2 }" type="button" @click="goPage(2)">记录</button>
<button class="page-btn" :class="{ active: currentPage === 3 }" type="button" @click="goPage(3)">提交</button>
</div>
<div class="summary" :class="{ done: allDone }">{{ allDone ? '全部完成' : '进行中' }}</div>
</div>
</div>
<div class="body">
<template v-if="currentPage === 1">
<div class="section" :class="{ done: stage1Done }">
<div class="section-title">阶段一:仪器接线</div>
<label class="item"><input type="checkbox" :checked="wiring.txWave_to_ch1" disabled /> 信号发生器 发射端 波形 → 示波器 CH1</label>
<label class="item"><input type="checkbox" :checked="wiring.txTransducer_to_left" disabled /> 信号发生器 发射端 换能器 → 换能器 左侧输入</label>
<label class="item"><input type="checkbox" :checked="wiring.rxWave_to_ch2" disabled /> 信号发生器 接收端 波形 → 示波器 CH2</label>
<label class="item"><input type="checkbox" :checked="wiring.rxTransducer_to_right" disabled /> 信号发生器 接收端 换能器 → 换能器 右侧输出</label>
<div class="hint" :class="{ ok: stage1Done }">{{ stage1Done ? '完成' : '未完成' }}</div>
</div>
<div class="section" :class="{ done: stage2Done }">
<div class="section-title">阶段二:调整示波器设置</div>
<div class="item text">示波器开机 <span :class="{ ok: powerOk }">{{ powerOk ? '✓' : '✗' }}</span></div>
<div class="item text">调整 VOLTS/DIV X<span :class="{ ok: vdch1Ok }">{{ vdch1Ok ? '✓' : '✗' }}</span></div>
<div class="item text">调整 VOLTS/DIV Y<span :class="{ ok: vdch2Ok }">{{ vdch2Ok ? '✓' : '✗' }}</span></div>
<div class="item text">模式切换至 双踪 <span :class="{ ok: modeOk }">{{ modeOk ? '✓' : '✗' }}</span></div>
<div class="hint" :class="{ ok: stage2Done }">{{ stage2Done ? '完成' : '未完成' }}</div>
</div>
<div class="section" :class="{ done: stage3Done }">
<div class="section-title">阶段三:找到固有频率</div>
<div class="item text">信号频率:35.980–36.020 kHz(当前 {{ fmtKHz(signalFrequencyKHz) }}) <span :class="{ ok: stage3Done }">{{ stage3Done ? '✓' : '✗' }}</span></div>
<div class="hint" :class="{ ok: stage3Done }">{{ stage3Done ? '完成' : '未完成' }}</div>
</div>
<div class="section" :class="{ done: stage4Done }">
<div class="section-title">阶段四:相位比较法测声速</div>
<div class="item text">1) 切换 X-Y 模式 <span :class="{ ok: xyOk }">{{ xyOk ? '✓' : '✗' }}</span></div>
<div class="item text">2) 连续 4 次李萨如图形出现直线:{{ lineCount }}/4 <span :class="{ ok: lineCount >= 4 }">{{ lineCount >= 4 ? '✓' : '✗' }}</span></div>
<div class="hint" :class="{ ok: stage4Done }">{{ stage4Done ? '完成' : '未完成' }}</div>
</div>
<div class="notes">
<div class="section-title">注意事项</div>
<div class="note">- 点击一个端口再点击另一个端口以连线。</div>
<div class="note">- 若接线不符合示意,会弹窗提示。</div>
<div class="note">- 阶段四需要在 X-Y 模式下通过移动换能器距离多次让图形变直线。</div>
</div>
</template>
<template v-else-if="currentPage === 2">
<div class="notes" style="margin-top: 0; padding-top: 0; border-top: none;">
<div class="section-title">学生笔记</div>
<div class="note">- 可记录观察、数据、计算过程;会自动保存。</div>
<textarea
v-model="notesText"
class="notes-input"
rows="10"
placeholder="在这里记录你的观察、数据、计算过程……(会自动保存)"
/>
</div>
</template>
<template v-else>
<div class="notes" style="margin-top: 0; padding-top: 0; border-top: none;">
<div class="section-title">答题:填写实验数据</div>
<div class="note">- 请输入“出现直线”时的换能器距离(mm),序号可自由扩展。</div>
<div class="note">- 并填写你找到的固有频率(kHz),系统将自动计算平均 Δl 与声速。</div>
<div class="raw-status" :class="{ ok: rawComplete }">{{ rawComplete ? '原始数据:已填写(可计算)' : '原始数据:请至少填 2 组距离 + 固有频率' }}</div>
<div class="raw-controls">
<button class="raw-btn" type="button" @click="addRawRow">+ 增加一行</button>
<button class="raw-btn" type="button" @click="removeRawRow">- 删除末行</button>
</div>
<div class="raw-freq">
<div class="raw-head">固有频率 (kHz)</div>
<input v-model="rawData.naturalFreqKHz" class="raw-input" inputmode="decimal" placeholder="例如 36.000" />
</div>
<div class="raw-table">
<div class="raw-head">序号</div>
<div class="raw-head">距离 (mm)</div>
<div class="raw-head">备注</div>
<template v-for="(r, idx) in rawData.rows" :key="idx">
<div class="raw-cell">{{ idx + 1 }}</div>
<div class="raw-cell">
<input
v-model="r.distanceMm"
class="raw-input"
inputmode="decimal"
placeholder="例如 120"
/>
</div>
<div class="raw-cell">
<input v-model="r.note" class="raw-input" placeholder="可选" />
</div>
</template>
</div>
<div class="calc">
<div class="calc-row">有效距离数:{{ parsedDistances.length }}(Δl 组数:{{ deltasMm.length }})</div>
<div class="calc-row">平均 Δl:{{ avgDeltaMm === null ? '--' : avgDeltaMm.toFixed(3) }} mm</div>
<div class="calc-row">声速:{{ soundSpeedMs === null ? '--' : soundSpeedMs.toFixed(2) }} m/s</div>
<div class="calc-row hint">公式:连续直线 Δl = λ/2 → $v=f\lambda=2f\Delta l$</div>
</div>
<div class="submit-row">
<button class="submit-btn" type="button" :disabled="submitState === 'submitting'" @click="submitRecord">
{{ submitState === 'submitting' ? '提交中…' : '提交' }}
</button>
<div class="submit-msg" :class="{ ok: submitState === 'success', err: submitState === 'error' }">
{{ submitMessage }}
</div>
</div>
<div class="note" style="margin-top: 8px;">
- 当前直线计数:{{ lineCount }}/4(用于提醒,不影响填写)
</div>
</div>
</template>
</div>
</div>
</template>
<style scoped>
.notebook {
width: 340px;
height: 420px;
background:
linear-gradient(rgba(255, 255, 255, 0.55), rgba(255, 255, 255, 0.55)),
repeating-linear-gradient(
0deg,
rgba(0, 0, 0, 0.00) 0px,
rgba(0, 0, 0, 0.00) 20px,
rgba(0, 0, 0, 0.04) 21px
),
rgba(245, 245, 245, 0.92);
border-radius: 10px;
border: 1px solid rgba(0, 0, 0, 0.25);
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.35);
padding: 12px;
box-sizing: border-box;
color: rgba(20, 20, 20, 0.95);
overflow: hidden;
display: flex;
flex-direction: column;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
flex: 0 0 auto;
}
.right {
display: flex;
align-items: center;
gap: 10px;
}
.pager {
display: inline-flex;
background: rgba(0, 0, 0, 0.06);
border-radius: 999px;
padding: 2px;
gap: 2px;
}
.page-btn {
appearance: none;
border: none;
background: transparent;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
color: rgba(20, 20, 20, 0.85);
cursor: pointer;
}
.page-btn.active {
background: rgba(255, 255, 255, 0.85);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
color: rgba(20, 20, 20, 0.95);
}
.body {
flex: 1 1 auto;
overflow: auto;
padding-right: 6px;
}
/* 美观滚动条(Chromium/WebKit) */
.body::-webkit-scrollbar {
width: 10px;
}
.body::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.06);
border-radius: 999px;
}
.body::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.22);
border-radius: 999px;
border: 2px solid rgba(245, 245, 245, 0.92);
}
.body::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.32);
}
/* Firefox */
.body {
scrollbar-width: thin;
scrollbar-color: rgba(0, 0, 0, 0.28) rgba(0, 0, 0, 0.06);
}
.title {
font-weight: 700;
letter-spacing: 1px;
}
.summary {
font-size: 12px;
padding: 2px 8px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.08);
}
.summary.done {
background: rgba(0, 0, 0, 0.12);
}
.section {
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed rgba(0, 0, 0, 0.18);
}
.section.done {
background: rgba(34, 197, 94, 0.12);
border-radius: 8px;
padding: 10px 10px 8px;
border-top: 1px dashed rgba(34, 197, 94, 0.25);
}
.section-title {
font-size: 13px;
font-weight: 700;
margin-bottom: 6px;
}
.item {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
line-height: 1.25;
margin: 4px 0;
}
.item.text {
justify-content: space-between;
}
.item input {
width: 14px;
height: 14px;
}
.hint {
margin-top: 4px;
font-size: 12px;
opacity: 0.8;
}
.ok {
font-weight: 700;
}
.notes {
margin-top: 10px;
padding-top: 8px;
border-top: 1px dashed rgba(0, 0, 0, 0.18);
}
.note {
font-size: 12px;
margin: 4px 0;
opacity: 0.9;
}
.notes-input {
width: 100%;
resize: vertical;
min-height: 120px;
border-radius: 8px;
border: 1px solid rgba(0, 0, 0, 0.22);
background: rgba(255, 255, 255, 0.75);
padding: 8px;
box-sizing: border-box;
font-size: 12px;
line-height: 1.35;
color: rgba(20, 20, 20, 0.95);
outline: none;
}
.notes-input:focus {
border-color: rgba(0, 0, 0, 0.35);
box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.10);
}
.raw-status {
margin-top: 8px;
font-size: 12px;
padding: 6px 8px;
border-radius: 8px;
background: rgba(0, 0, 0, 0.06);
}
.raw-status.ok {
background: rgba(34, 197, 94, 0.14);
}
.raw-controls {
margin-top: 8px;
display: flex;
gap: 8px;
}
.raw-btn {
appearance: none;
border: 1px solid rgba(0, 0, 0, 0.18);
background: rgba(255, 255, 255, 0.72);
border-radius: 999px;
padding: 4px 10px;
font-size: 12px;
cursor: pointer;
}
.raw-btn:hover {
background: rgba(255, 255, 255, 0.90);
}
.raw-freq {
margin-top: 10px;
display: grid;
grid-template-columns: 120px 1fr;
gap: 8px;
align-items: center;
}
.raw-table {
margin-top: 8px;
display: grid;
grid-template-columns: 50px 110px 1fr;
gap: 6px;
align-items: center;
}
.raw-head {
font-size: 12px;
font-weight: 700;
opacity: 0.85;
}
.raw-cell {
font-size: 12px;
}
.raw-input {
width: 100%;
border-radius: 8px;
border: 1px solid rgba(0, 0, 0, 0.20);
background: rgba(255, 255, 255, 0.75);
padding: 6px 8px;
box-sizing: border-box;
font-size: 12px;
outline: none;
}
.raw-input:focus {
border-color: rgba(0, 0, 0, 0.35);
box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.10);
}
.calc {
margin-top: 10px;
padding: 8px;
border-radius: 10px;
background: rgba(0, 0, 0, 0.05);
}
.calc-row {
font-size: 12px;
margin: 4px 0;
}
.calc-row.hint {
opacity: 0.75;
}
.submit-row {
margin-top: 10px;
display: flex;
align-items: center;
gap: 10px;
}
.submit-btn {
appearance: none;
border: 1px solid rgba(0, 0, 0, 0.18);
background: rgba(255, 255, 255, 0.85);
border-radius: 10px;
padding: 8px 14px;
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.submit-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.submit-msg {
flex: 1 1 auto;
font-size: 12px;
opacity: 0.85;
}
.submit-msg.ok {
color: rgba(18, 120, 60, 0.95);
opacity: 1;
font-weight: 700;
}
.submit-msg.err {
color: rgba(180, 30, 30, 0.95);
opacity: 1;
font-weight: 700;
}
</style>
+414
View File
@@ -0,0 +1,414 @@
<script setup lang="ts">
import { computed, ref, onMounted, watch, onUnmounted, nextTick } from 'vue';
import RetroKnob from './RetroKnob.vue';
//@ts-ignore
import SwitchLever from './SwitchLever.vue';
import ea from '../assets/sounds';
// 40 -> 320 bigger is smaller volts, 40 means 5V/div, 320 means 1mV/div, not linear
const vdch1 = ref(320)
const vdch2 = ref(320)
const xymode = ref(false)
const power = ref(false)
const currentModeIndex = ref(0);
const labels = ['CH1', 'CH2', '双踪', '叠加'];
const modeText = computed(() => labels[currentModeIndex.value]);
const handleModeChange = (val: string) => {
console.log('模式切换为:', val);
ea.play('换挡')
};
const props = defineProps({
// 接收器距离发射器的距离,单位mm
distance: { type: Number, default: 0 },
// 信号频率,单位Hz
frequency: { type: Number, default: 36000 },
// 声速,单位m/s
soundSpeed: { type: Number, default: 340 },
});
const emit = defineEmits<{
(e: 'register-pin', payload: { id: 'osc:ch1' | 'osc:ch2'; el: HTMLElement }): void;
(e: 'unregister-pin', id: 'osc:ch1' | 'osc:ch2'): void;
(e: 'pin-click', id: 'osc:ch1' | 'osc:ch2'): void;
(e: 'settings', payload: { vdch1: number; vdch2: number; currentModeIndex: number; xyMode: boolean; power: boolean }): void;
(e: 'lissajous-line', payload: { k: number; phaseRadTotal: number }): void;
}>();
const emitSettings = () => {
emit('settings', {
vdch1: vdch1.value,
vdch2: vdch2.value,
currentModeIndex: currentModeIndex.value,
xyMode: xymode.value,
power: power.value,
});
};
watch([vdch1, vdch2, currentModeIndex, xymode, power], () => {
emitSettings();
}, { immediate: true });
const lastWasLine = ref(false);
const lastLineK = ref<number | null>(null);
const pinCh1El = ref<HTMLElement | null>(null);
const pinCh2El = ref<HTMLElement | null>(null);
const registerPins = () => {
if (pinCh1El.value) emit('register-pin', { id: 'osc:ch1', el: pinCh1El.value });
if (pinCh2El.value) emit('register-pin', { id: 'osc:ch2', el: pinCh2El.value });
};
const unregisterPins = () => {
emit('unregister-pin', 'osc:ch1');
emit('unregister-pin', 'osc:ch2');
};
const handlePinClick = (id: 'osc:ch1' | 'osc:ch2') => {
emit('pin-click', id);
};
// Canvas相关
const canvasRef = ref<HTMLCanvasElement | null>(null);
let ctx: CanvasRenderingContext2D | null = null;
let animationId: number | null = null;
let time = 0;
// 固有频率和品质因数
const NATURAL_FREQ = 36000; // Hz,与props.frequency保持一致
const Q_FACTOR = 100; // 品质因数(越大则频率影响越强:偏离固有频率时衰减更快)
// 核心物理计算
function calculatePhysics() {
// 将距离从mm转换为cm,再转换为m
const distanceM = props.distance / 1000; // mm -> m
// 计算波长 (m)
const wavelengthM = props.soundSpeed / props.frequency;
// 计算相位差
const numCycles = distanceM / wavelengthM;
const phaseRadTotal = numCycles * 2 * Math.PI;
const phaseRadEffective = phaseRadTotal % (2 * Math.PI);
// 计算谐振幅度(接收器在固有频率附近时信号最强)
const f_ratio = props.frequency / NATURAL_FREQ;
const f_term = (f_ratio - (1 / f_ratio));
let resonanceFactor = 1 / Math.sqrt(1 + (Q_FACTOR * Q_FACTOR * f_term * f_term));
if (isNaN(resonanceFactor)) resonanceFactor = 0;
return {
wavelengthM,
phaseRadTotal,
phaseRadEffective,
resonanceFactor
};
}
// 判断当前显示模式
const isXYMode = computed(() => xymode.value);
const isDualTrace = computed(() => currentModeIndex.value === 2); // 双踪模式
// 根据旋钮值计算增益(40=最小,320=最大,非线性)
const getGain = (knobValue: number) => {
// 将40-320映射到0.01-5.0的增益范围(非线性)
const normalized = (knobValue - 40) / 280; // 0-1
return 0.01 + normalized * normalized * 4.99; // 平方关系,使低端更敏感
};
// 绘制示波器屏幕
function drawOscilloscope() {
if (!ctx || !canvasRef.value) {
return;
}
// 清空画布
const w = canvasRef.value.width;
const h = canvasRef.value.height;
ctx.clearRect(0, 0, w, h);
// 如果未开机,直接返回(保持黑屏)
if (!power.value) {
return;
}
const cx = w / 2;
const cy = h / 2;
const scale = Math.min(w, h) * 0.35;
const phy = calculatePhysics();
const gainCH1 = getGain(vdch1.value);
const gainCH2 = getGain(vdch2.value);
// X-Y 模式下检测“李萨如图形出现直线”的进入事件
if (power.value && isXYMode.value) {
const phase = phy.phaseRadTotal;
const isLineNow = Math.abs(Math.sin(phase)) < 0.10 && phy.resonanceFactor > 0.05;
if (isLineNow && !lastWasLine.value) {
const k = Math.round(phase / Math.PI);
// 去重:同一个 k 不重复触发
if (lastLineK.value !== k) {
lastLineK.value = k;
emit('lissajous-line', { k, phaseRadTotal: phase });
}
}
lastWasLine.value = isLineNow;
} else {
lastWasLine.value = false;
}
// 公共属性
ctx.lineWidth = 3;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
if (isXYMode.value) {
// ================= X-Y Mode (李萨如图形) =================
const opacity = 0.3 + 0.7 * phy.resonanceFactor;
ctx.strokeStyle = `rgba(0, 255, 0, ${opacity})`;
// 强辉光效果 - 多层叠加
ctx.shadowBlur = 15 * phy.resonanceFactor;
ctx.shadowColor = '#00ff00';
ctx.beginPath();
const phase = phy.phaseRadTotal;
const ampX = gainCH1;
const ampY = phy.resonanceFactor * gainCH2;
for (let t = 0; t <= 2 * Math.PI; t += 0.03) {
const x = scale * ampX * Math.cos(t);
const y = scale * ampY * Math.cos(t - phase);
if (t === 0) ctx.moveTo(cx + x, cy - y);
else ctx.lineTo(cx + x, cy - y);
}
ctx.stroke();
// 第二层辉光(更强)
ctx.shadowBlur = 25 * phy.resonanceFactor;
ctx.stroke();
ctx.shadowBlur = 0;
// 中心点装饰
ctx.fillStyle = 'rgba(0, 255, 0, 0.3)';
ctx.fillRect(cx - 1, cy - 5, 2, 10);
ctx.fillRect(cx - 5, cy - 1, 10, 2);
} else {
// ================= Y-t Mode (CH1/CH2/双踪/叠加波形) =================
// 绘制中心轴
ctx.strokeStyle = 'rgba(255, 255, 255, 0.15)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0, cy);
ctx.lineTo(w, cy);
ctx.stroke();
const cyclesToShow = 2.5; // 显示2.5个周期
const pixPerCycle = w / cyclesToShow;
ctx.lineWidth = 3;
// CH1 (Source) - 黄色参考信号(在CH1、双踪、叠加模式下显示)
if (currentModeIndex.value === 0 || currentModeIndex.value === 2 || currentModeIndex.value === 3) {
ctx.beginPath();
ctx.strokeStyle = 'rgba(255, 230, 0, 0.9)';
ctx.shadowBlur = 12;
ctx.shadowColor = '#ffff00';
for (let x = 0; x < w; x += 2) {
const t = (x / pixPerCycle) * 2 * Math.PI;
const yVal = scale * gainCH1 * 0.8 * Math.sin(t + time);
if (x === 0) ctx.moveTo(x, cy - yVal);
else ctx.lineTo(x, cy - yVal);
}
ctx.stroke();
// 第二层辉光
ctx.shadowBlur = 20;
ctx.stroke();
}
// CH2 (Receiver) - 绿色接收信号(在CH2、双踪、叠加模式下显示)
if (currentModeIndex.value === 1 || currentModeIndex.value === 2 || currentModeIndex.value === 3) {
const opacity = 0.3 + 0.7 * phy.resonanceFactor;
ctx.beginPath();
ctx.strokeStyle = `rgba(0, 255, 0, ${opacity})`;
ctx.shadowBlur = 12 * phy.resonanceFactor;
ctx.shadowColor = '#00ff00';
const phaseShift = phy.phaseRadTotal;
const ampY = phy.resonanceFactor * gainCH2 * 0.8;
for (let x = 0; x < w; x += 2) {
const t = (x / pixPerCycle) * 2 * Math.PI;
const yVal = scale * ampY * Math.sin(t - phaseShift + time);
if (x === 0) ctx.moveTo(x, cy - yVal);
else ctx.lineTo(x, cy - yVal);
}
ctx.stroke();
// 第二层辉光
ctx.shadowBlur = 20 * phy.resonanceFactor;
ctx.stroke();
}
ctx.shadowBlur = 0;
}
}
// 动画循环
function animate() {
time += 0.05;
drawOscilloscope();
animationId = requestAnimationFrame(animate);
}
// 初始化Canvas
function initCanvas() {
const canvas = canvasRef.value;
if (!canvas) return;
ctx = canvas.getContext('2d');
if (!ctx) return;
// 设置Canvas尺寸(保持高分辨率)
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = 1072;
canvas.height = 812;
// 启动动画
animate();
}
// 监听相关参数变化
watch([() => props.distance, () => props.frequency, () => props.soundSpeed,
isXYMode, currentModeIndex, power], () => {
// 参数变化时会在下一帧自动重绘
});
onMounted(() => {
initCanvas();
nextTick().then(registerPins);
});
onUnmounted(() => {
if (animationId !== null) {
cancelAnimationFrame(animationId);
}
unregisterPins();
});
</script>
<template>
<div class="osc">
<img src="../assets/示波器.webp" alt="" draggable="false">
<RetroKnob v-model="vdch1" class="btn" data-tooltip="CH1 垂直灵敏度(Volts/Div):调到合适幅度便于观察"
style="left: 34.3%; top: 28.4%;" />
<RetroKnob v-model="vdch2" class="btn" data-tooltip="CH2 垂直灵敏度(Volts/Div):调到合适幅度便于观察"
style="left: 47.3%; top: 28.4%;" />
<div class="powerl" draggable="false" :style="{ opacity: power ? 0.8 : 0 }"></div>
<img class="power-btn" data-tooltip="电源开关:开机后才会显示波形/李萨如图形" @click="power = !power; ea.play('电源按钮')"
:style="{ opacity: power ? '80%' : '0' }" src="../assets/按钮按下.webp">
<!-- <div>{{ vdch1 }} {{ vdch2 }}</div> -->
<SwitchLever v-model="currentModeIndex" data-tooltip="显示模式:CH1 / CH2 / 双踪 / 叠加(步骤二需要切到“双踪”)" @change="handleModeChange"
scale="0.20" class="mode-swtc" />
<div class="xymode" data-tooltip="X-Y(李萨如)模式:用于相位比较法(步骤四)" @click="xymode = !xymode; ea.play('电源按钮')"></div>
<canvas ref="canvasRef" class="screen" width="1072"
height="812"></canvas>
<!-- CH1 输入 -->
<div ref="pinCh1El" class="pin" data-tooltip="CH1 输入端口:建议连接信号发生器“发射端-波形”\n提示:先点一个端口,再点另一个端口完成连线" style="left: 49.8%; top: 87.5%;"
@click.stop="handlePinClick('osc:ch1')"></div>
<!-- CH2 输入 -->
<div ref="pinCh2El" class="pin" data-tooltip="CH2 输入端口:建议连接信号发生器“接收端-波形”\n提示:先点一个端口,再点另一个端口完成连线" style="left: 62.7%; top: 87.5%;"
@click.stop="handlePinClick('osc:ch2')"></div>
</div>
</template>
<style lang="scss" scoped>
.pin {
height: 30px;
width: 30px;
position: absolute;
background-color: black;
border-radius: 50%;
cursor: pointer;
}
.osc {
position: relative;
height: 300px;
width: fit-content;
}
.screen {
position: absolute;
left: 7.5%;
top: 31.2%;
width: 32.2%;
background-image:
linear-gradient(rgba(0, 255, 0, 0.1) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 255, 0, 0.1) 1px, transparent 1px);
background-size: calc(1072px / 10.72) calc(812px / 8.12);
border-radius: 4px;
}
.xymode {
position: absolute;
left: 80.2%;
top: 44.4%;
width: 20px;
height: 17px;
cursor: pointer;
}
img {
height: 100%;
}
.mode-swtc {
position: absolute;
left: 57.8%;
top: 42.4%;
}
.btn {
position: absolute;
transform: scale(0.164);
inset: 0;
}
.powerl {
background-color: orangered;
position: absolute;
width: 5px;
height: 5px;
border-radius: 50%;
left: 36.55%;
top: 93.8%;
transform: translate(-50%, -50%);
box-shadow: 0 0 8px 2px orangered;
transition: all 0.3s ease;
opacity: .8;
}
.power-btn {
position: absolute;
height: 29px;
left: 42.32%;
top: 90.7%;
transform: translate(-50%, -50%);
cursor: pointer;
}
</style>
+386
View File
@@ -0,0 +1,386 @@
<template>
<div class="knob-container" ref="containerRef">
<div class="side-layer">
<div class="side-texture" :style="{ transform: `rotate(${angle}deg)` }"></div>
<div class="side-shade"></div>
</div>
<div class="top-layer">
<div class="top-rotator" :style="{ transform: `rotate(${angle}deg)` }">
<div class="top-bevel"></div>
<div class="inner-cap"></div>
<div class="pointer"></div>
</div>
</div>
<div
class="interaction-mask"
ref="knobAreaRef"
:class="{ dragging: isDragging, disabled }"
@pointerdown="onPointerDown"
/>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from "vue";
type Props = {
modelValue?: number;
disabled?: boolean;
/** 角度限制范围(deg) */
minAngle?: number;
maxAngle?: number;
/** 步进(deg),<=0 表示不启用 */
step?: number;
};
const props = withDefaults(defineProps<Props>(), {
modelValue: 0,
disabled: false,
minAngle: 40,
maxAngle: 320,
step: 0,
});
const emit = defineEmits<{
(e: "update:modelValue", value: number): void;
(e: "change", value: number): void; // 松手触发一次
}>();
const containerRef = ref<HTMLElement | null>(null);
const knobAreaRef = ref<HTMLElement | null>(null);
const isDragging = ref(false);
const range = computed(() => {
// 防御:如果传反了就自动交换
const a = Number(props.minAngle);
const b = Number(props.maxAngle);
return a <= b ? { min: a, max: b } : { min: b, max: a };
});
function clamp(v: number) {
return Math.min(range.value.max, Math.max(range.value.min, v));
}
function applyStep(v: number) {
const s = Number(props.step);
if (!Number.isFinite(s) || s <= 0) return v;
// 以 min 为基准量化(更好控)
const q = Math.round((v - range.value.min) / s) * s + range.value.min;
return q;
}
/** 把 rawAngle 变成与 prevAngle 最接近的等价角(±360k),避免跨越 -180/180 时跳变 */
function unwrapAngle(rawAngle: number, prevAngle: number) {
let a = rawAngle;
let best = a;
let bestDiff = Math.abs(a - prevAngle);
const candidates = [a - 360, a, a + 360];
for (const c of candidates) {
const d = Math.abs(c - prevAngle);
if (d < bestDiff) {
bestDiff = d;
best = c;
}
}
return best;
}
const angle = computed(() => {
const v = Number(props.modelValue ?? 0);
const safe = Number.isFinite(v) ? v : 0;
// modelValue 外部改动也强制落在范围内(并考虑 step)
return clamp(applyStep(clamp(safe)));
});
function getCenter(el: HTMLElement) {
const rect = el.getBoundingClientRect();
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
}
function calculateAngleFromClientPoint(clientX: number, clientY: number) {
const el = knobAreaRef.value;
if (!el) return angle.value;
const center = getCenter(el);
const dx = clientX - center.x;
const dy = clientY - center.y;
// 与你原逻辑一致:atan2 -> deg -> -90(让 0° 在正上方)
const raw = Math.atan2(dy, dx) * (180 / Math.PI) - 90;
return raw;
}
function emitAngle(next: number) {
const stepped = applyStep(next);
const clamped = clamp(stepped);
emit("update:modelValue", clamped);
}
let activePointerId: number | null = null;
// 记录连续角(未 clamp 前,用于 unwrap)
let lastContinuousAngle = angle.value;
function onPointerDown(e: PointerEvent) {
if (props.disabled) return;
if (e.pointerType === "mouse" && e.button !== 0) return;
const el = knobAreaRef.value;
if (!el) return;
isDragging.value = true;
activePointerId = e.pointerId;
try {
el.setPointerCapture(e.pointerId);
} catch {}
e.preventDefault();
const raw = calculateAngleFromClientPoint(e.clientX, e.clientY);
// 起手以当前角为参考,避免第一次就跳
lastContinuousAngle = unwrapAngle(raw, angle.value);
emitAngle(lastContinuousAngle);
}
function onPointerMove(e: PointerEvent) {
if (!isDragging.value) return;
if (activePointerId !== null && e.pointerId !== activePointerId) return;
e.preventDefault();
const raw = calculateAngleFromClientPoint(e.clientX, e.clientY);
const continuous = unwrapAngle(raw, lastContinuousAngle);
lastContinuousAngle = continuous;
emitAngle(continuous);
}
function endDrag(e?: PointerEvent) {
if (!isDragging.value) return;
isDragging.value = false;
activePointerId = null;
emit("change", angle.value);
if (e) {
const el = knobAreaRef.value;
if (el) {
try {
el.releasePointerCapture(e.pointerId);
} catch {}
}
}
}
function onPointerUp(e: PointerEvent) {
if (activePointerId !== null && e.pointerId !== activePointerId) return;
endDrag(e);
}
function onPointerCancel(e: PointerEvent) {
if (activePointerId !== null && e.pointerId !== activePointerId) return;
endDrag(e);
}
window.addEventListener("pointermove", onPointerMove, { passive: false });
window.addEventListener("pointerup", onPointerUp, { passive: false });
window.addEventListener("pointercancel", onPointerCancel, { passive: false });
onBeforeUnmount(() => {
window.removeEventListener("pointermove", onPointerMove as any);
window.removeEventListener("pointerup", onPointerUp as any);
window.removeEventListener("pointercancel", onPointerCancel as any);
});
// 当范围/step 改变时,保证当前值也被收敛到新规则内
watch(
() => [props.minAngle, props.maxAngle, props.step],
() => {
emitAngle(Number(props.modelValue ?? 0));
}
);
</script>
<style scoped>
/* 组件本身不管页面背景;外层自己设置 */
.knob-container {
position: relative;
width: 200px;
height: 200px;
border-radius: 50%;
user-select: none;
-webkit-user-select: none;
touch-action: none;
filter: drop-shadow(0 30px 45px rgba(0, 0, 0, 0.1));
}
/* 外圈:更强的俯视感(高光集中在上方,底部更暗) */
.knob-container::before {
content: "";
position: absolute;
inset: -6px;
border-radius: 50%;
background: radial-gradient(
120% 90% at 40% 20%,
rgba(255, 255, 255, 0.35),
rgba(255, 255, 255, 0.05) 45%,
rgba(0, 0, 0, 0.35) 100%
),
radial-gradient(
60% 60% at 50% 50%,
rgba(255, 255, 255, 0.08),
rgba(0, 0, 0, 0.45)
);
box-shadow: inset 0 10px 22px rgba(255, 255, 255, 0.18),
inset 0 -14px 26px rgba(0, 0, 0, 0.55);
z-index: 0;
}
/* === 侧面层:露出更多(通过 top-layer 下移 + 缩小实现) === */
.side-layer {
position: absolute;
inset: 0;
border-radius: 50%;
overflow: hidden;
z-index: 1;
background: #dcdabb;
box-shadow: inset 0 18px 30px -8px rgba(255, 253, 240, 0.85),
inset 0 -10px 22px rgba(0, 0, 0, 0.55);
}
.side-texture {
position: absolute;
top: -55%;
left: -55%;
width: 210%;
height: 210%;
background: repeating-conic-gradient(
from 0deg,
#f2f0e0 0deg 2deg,
#c4c2a8 2deg 4deg
);
z-index: 1;
}
/* 给侧面加一点“俯视遮光”(上亮下暗) */
.side-shade {
position: absolute;
inset: 0;
border-radius: 50%;
background: radial-gradient(
120% 90% at 45% 18%,
rgba(255, 255, 255, 0.20),
rgba(255, 255, 255, 0.02) 45%,
rgba(0, 0, 0, 0.25) 100%
);
z-index: 2;
}
/* === 顶面层:下移更多、尺寸更小 => 上侧面露出更大 === */
.top-layer {
position: absolute;
width: 178px;
height: 178px;
top: 30px; /* 关键:更明显露出上侧面 */
left: 11px;
border-radius: 50%;
overflow: hidden;
z-index: 3;
background: radial-gradient(
130% 110% at 40% 22%,
#fffef5,
#f1f0e3 55%,
#dbd9c5 100%
),
linear-gradient(to bottom, rgba(255, 255, 255, 0.35), rgba(0, 0, 0, 0.15));
box-shadow: 0 10px 18px rgba(0, 0, 0, 0.40),
inset 0 10px 18px rgba(255, 255, 255, 0.55),
inset 0 -16px 24px rgba(0, 0, 0, 0.30);
}
.top-rotator {
position: relative;
width: 100%;
height: 100%;
}
/* 顶面倒角环,增强“俯视立体” */
.top-bevel {
position: absolute;
inset: 8px;
border-radius: 50%;
box-shadow: inset 0 6px 10px rgba(255, 255, 255, 0.55),
inset 0 -10px 16px rgba(0, 0, 0, 0.25);
pointer-events: none;
}
.inner-cap {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 66%;
height: 66%;
border-radius: 50%;
background: radial-gradient(
130% 110% at 40% 22%,
#ffffff,
#eeece0 60%,
#d9d7c3 100%
),
linear-gradient(160deg, rgba(255, 255, 255, 0.6), rgba(0, 0, 0, 0.12));
box-shadow: 0 6px 10px rgba(0, 0, 0, 0.18),
inset 0 4px 8px rgba(255, 255, 255, 0.85),
inset 0 -10px 14px rgba(0, 0, 0, 0.18);
}
.pointer {
position: absolute;
bottom: 10px;
left: 50%;
transform: translateX(-50%);
width: 6px;
height: 26px;
background: linear-gradient(to bottom, #7a776f, #4e4b45);
border-radius: 4px;
box-shadow: 0 1px 1px rgba(255, 255, 255, 0.55),
inset 0 1px 2px rgba(255, 255, 255, 0.25);
}
/* 交互遮罩 */
.interaction-mask {
position: absolute;
top: -20px;
left: -20px;
right: -20px;
bottom: -20px;
border-radius: 50%;
cursor: grab;
z-index: 10;
}
.interaction-mask.dragging {
cursor: grabbing;
}
.interaction-mask.disabled {
cursor: not-allowed;
}
</style>
+267
View File
@@ -0,0 +1,267 @@
<template>
<div class="sigen">
<img src="../assets/信号发生.webp" draggable="false" alt="">
<span class="num"
:class="[{ 'has-dot': digits[0]!.hasDot }, { selected: selectedIndex === 0 }, { blinking: selectedIndex === 0 && isBlinking }]"
style="left: 16.5%; top: 40.4%;">{{ digits[0]!.value }}</span>
<span class="num"
:class="[{ 'has-dot': digits[1]!.hasDot }, { selected: selectedIndex === 1 }, { blinking: selectedIndex === 1 && isBlinking }]"
style="left: 23.8%; top: 40.4%;">{{ digits[1]!.value }}</span>
<span class="num"
:class="[{ 'has-dot': digits[2]!.hasDot }, { selected: selectedIndex === 2 }, { blinking: selectedIndex === 2 && isBlinking }]"
style="left: 30.9%; top: 40.4%;">{{ digits[2]!.value }}</span>
<span class="num"
:class="[{ 'has-dot': digits[3]!.hasDot }, { selected: selectedIndex === 3 }, { blinking: selectedIndex === 3 && isBlinking }]"
style="left: 38.1%; top: 40.4%;">{{ digits[3]!.value }}</span>
<span class="num"
:class="[{ 'has-dot': digits[4]!.hasDot }, { selected: selectedIndex === 4 }, { blinking: selectedIndex === 4 && isBlinking }]"
style="left: 45.1%; top: 40.4%;">{{ digits[4]!.value }}</span>
<div style="left: 77.5%; top: 36.9%;" class="btn btn-increase" data-tooltip="增大频率(对当前选中的那一位生效)" @click.stop="increase"></div>
<div style="left: 77.5%; top: 51.9%;" class="btn btn-decrease" data-tooltip="减小频率(对当前选中的那一位生效)" @click.stop="decrease"></div>
<div style="left: 70.6%; top: 44.9%;" class="btn btn-left" data-tooltip="选择更高位" @click.stop="selectLeft"></div>
<div style="left: 84.2%; top: 44.9%;" class="btn btn-right" data-tooltip="选择更低位" @click.stop="selectRight"></div>
<!-- 发射端 波形 -->
<div ref="pinTxWaveEl" class="pin" data-tooltip="发射端:波形输出(应连接到示波器 CH1)\n提示:先点一个端口,再点另一个端口完成连线" style="left: 22.3%; top: 72.9%;"
@click.stop="handlePinClick('sg:tx-wave')"></div>
<!-- 发射端 换能器 -->
<div ref="pinTxTransducerEl" class="pin" data-tooltip="发射端:换能器端口(应连接到测定仪 左端口)\n提示:先点一个端口,再点另一个端口完成连线" style="left: 13.2%; top: 72.9%;"
@click.stop="handlePinClick('sg:tx-transducer')"></div>
<!-- 接收端 波形 -->
<div ref="pinRxWaveEl" class="pin" data-tooltip="接收端:波形输出(应连接到示波器 CH2)\n提示:先点一个端口,再点另一个端口完成连线" style="left: 34.9%; top: 72.9%;"
@click.stop="handlePinClick('sg:rx-wave')"></div>
<!-- 接收端 换能器 -->
<div ref="pinRxTransducerEl" class="pin" data-tooltip="接收端:换能器端口(应连接到测定仪 右端口)\n提示:先点一个端口,再点另一个端口完成连线" style="left: 44%; top: 72.9%;"
@click.stop="handlePinClick('sg:rx-transducer')"></div>
</div>
</template>
<style lang="scss" scoped>
.pin{
height: 30px;
width: 30px;
position: absolute;
background-color: black;
border-radius: 50%;
cursor: pointer;
}
.btn {
height: 24px;
width: 24px;
position: absolute;
cursor: pointer;
}
.sigen {
position: relative;
height: 300px;
width: fit-content;
}
.num {
font-family: 'AF-LED7 Seg-3';
position: absolute;
color: red;
font-size: 50px;
}
.num.selected.blinking {
animation: seg-blink 0.9s steps(1, end) infinite;
}
@keyframes seg-blink {
0% {
opacity: 1;
}
50% {
opacity: 0;
}
100% {
opacity: 1;
}
}
.num.has-dot::after {
content: '.';
position: absolute;
right: -0.6px;
bottom: 0px;
background: red;
}
.dot {
font-family: 'AF-LED7 Seg-3';
position: absolute;
color: red;
font-size: 50px;
}
img {
height: 100%;
}
</style>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue';
import ea from '../assets/sounds';
const props = withDefaults(
defineProps<{
modelValue?: number;
}>(),
{
modelValue: 35.22,
}
);
const emit = defineEmits<{
(e: 'update:modelValue', value: number): void;
(e: 'change', value: number): void;
(e: 'register-pin', payload: { id: 'sg:tx-wave' | 'sg:tx-transducer' | 'sg:rx-wave' | 'sg:rx-transducer'; el: HTMLElement }): void;
(e: 'unregister-pin', id: 'sg:tx-wave' | 'sg:tx-transducer' | 'sg:rx-wave' | 'sg:rx-transducer'): void;
(e: 'pin-click', id: 'sg:tx-wave' | 'sg:tx-transducer' | 'sg:rx-wave' | 'sg:rx-transducer'): void;
}>();
const pinTxWaveEl = ref<HTMLElement | null>(null);
const pinTxTransducerEl = ref<HTMLElement | null>(null);
const pinRxWaveEl = ref<HTMLElement | null>(null);
const pinRxTransducerEl = ref<HTMLElement | null>(null);
const registerPins = () => {
if (pinTxWaveEl.value) emit('register-pin', { id: 'sg:tx-wave', el: pinTxWaveEl.value });
if (pinTxTransducerEl.value) emit('register-pin', { id: 'sg:tx-transducer', el: pinTxTransducerEl.value });
if (pinRxWaveEl.value) emit('register-pin', { id: 'sg:rx-wave', el: pinRxWaveEl.value });
if (pinRxTransducerEl.value) emit('register-pin', { id: 'sg:rx-transducer', el: pinRxTransducerEl.value });
};
const unregisterPins = () => {
emit('unregister-pin', 'sg:tx-wave');
emit('unregister-pin', 'sg:tx-transducer');
emit('unregister-pin', 'sg:rx-wave');
emit('unregister-pin', 'sg:rx-transducer');
};
const handlePinClick = (id: 'sg:tx-wave' | 'sg:tx-transducer' | 'sg:rx-wave' | 'sg:rx-transducer') => {
emit('pin-click', id);
};
// KHz
const freq = ref(Number(props.modelValue ?? 36.0))
watch(
() => props.modelValue,
(v) => {
if (typeof v !== 'number' || Number.isNaN(v)) return;
freq.value = clampFreq(v);
}
);
const selectedIndex = ref(4);
const isBlinking = ref(false);
let blinkTimer: number | undefined;
const resetBlinkTimer = () => {
isBlinking.value = true;
if (blinkTimer !== undefined) {
window.clearTimeout(blinkTimer);
}
blinkTimer = window.setTimeout(() => {
isBlinking.value = false;
}, 5000);
};
onMounted(() => {
// resetBlinkTimer();
nextTick().then(registerPins);
});
onBeforeUnmount(() => {
if (blinkTimer !== undefined) {
window.clearTimeout(blinkTimer);
}
unregisterPins();
});
const roundTo3 = (v: number) => Math.round(v * 1000) / 1000;
const clampFreq = (v: number) => Math.min(99.999, Math.max(0, roundTo3(v)));
const formatFreqForDisplay = (v: number) => {
const clamped = clampFreq(v);
const fixed = clamped.toFixed(3); // e.g. "9.000" / "36.125"
const [intPartRaw = '0', fracPart = '000'] = fixed.split('.');
const intPart = intPartRaw.padStart(2, '0').slice(-2);
return `${intPart}.${fracPart}`; // always "dd.ddd"
};
const stepByIndex = (idx: number) => {
// 对应显示格式 dd.ddd
// 0: 十位(10) 1: 个位(1) 2: 0.1 3: 0.01 4: 0.001
const steps = [10, 1, 0.1, 0.01, 0.001] as const;
const clampedIdx = Math.max(0, Math.min(4, Math.trunc(idx))) as 0 | 1 | 2 | 3 | 4;
return steps[clampedIdx];
};
const emitFreq = () => {
const v = clampFreq(freq.value);
freq.value = v;
emit('update:modelValue', v);
emit('change', v);
};
const selectLeft = () => {
selectedIndex.value = (selectedIndex.value + 4) % 5;
resetBlinkTimer();
ea.play('发生器按钮')
};
const selectRight = () => {
selectedIndex.value = (selectedIndex.value + 1) % 5;
resetBlinkTimer();
ea.play('发生器按钮')
};
const increase = () => {
freq.value = clampFreq(freq.value + stepByIndex(selectedIndex.value));
emitFreq();
resetBlinkTimer();
ea.play('发生器按钮')
};
const decrease = () => {
freq.value = clampFreq(freq.value - stepByIndex(selectedIndex.value));
emitFreq();
resetBlinkTimer();
ea.play('发生器按钮')
};
// 将频率转换为数码管显示的数字数组
const digits = computed(() => {
const freqStr = formatFreqForDisplay(freq.value); // 始终 "dd.ddd"
const result: Array<{ value: string; hasDot: boolean }> = [];
let digitIndex = 0;
for (let i = 0; i < freqStr.length && digitIndex < 5; i++) {
if (freqStr[i] !== '.') {
// 检查下一个字符是否是小数点
const hasDot = i + 1 < freqStr.length && freqStr[i + 1] === '.';
result.push({
value: freqStr[i] as string,
hasDot: hasDot
});
digitIndex++;
}
}
// 如果不足5位,补0
while (result.length < 5) {
result.push({ value: '0', hasDot: false });
}
return result;
});
</script>
+397
View File
@@ -0,0 +1,397 @@
<template>
<div class="silver-knob-wrapper">
<div class="device-casing" @mousedown="handleMouseDown"
@touchstart.passive="handleTouchStart" @wheel.prevent="handleWheel">
<div class="indicator-mark"></div>
<div class="knob-component" ref="knobRef">
<div class="dial-face">
<div class="dial-overlay"></div>
<div class="dial-strip" :style="stripStyle">
<template v-for="(cycle, cycleIndex) in 3" :key="cycleIndex">
<div v-for="(tick, i) in config.ticksPerCycle"
:key="`${cycleIndex}-${i}`" class="tick-item"
:style="{ height: config.stepHeight + 'px' }">
<span class="tick-num">{{ i * 10 }}</span>
<div class="tick-sub-group">
<div v-for="n in config.subTicks" :key="n" class="tick-sub"
:style="getSubTickStyle(n)"></div>
</div>
</div>
</template>
</div>
</div>
<div class="knob-grip"></div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
const props = defineProps({
initialValue: { type: Number, default: 0 },
sensitivity: { type: Number, default: 0.5 },
// ✅ 新增:范围限制(比如 0 ~ 1000)
min: { type: Number, default: 0 },
max: { type: Number, default: 30000 },
// ✅ 可选:滚轮一次走多少“数值单位”(不是像素)
wheelStepUnits: { type: Number, default: 5 }
});
const emit = defineEmits(['update:value', 'rotate']);
// --- 配置 ---
const config = {
stepHeight: 40, // 主刻度高度 (代表 10 个数值单位)
ticksPerCycle: 10, // 一个周期 10 个主刻度 => 100 units
containerHeight: 200,
subTicks: 9
};
const unitsPerMainTick = 10; // 1 个主刻度=10 units
const cycleUnits = config.ticksPerCycle * unitsPerMainTick; // 100 units/圈
const pxPerUnit = config.stepHeight / unitsPerMainTick; // 4px / unit (40px/10)
const cycleHeight = config.stepHeight * config.ticksPerCycle;
// --- 状态 ---
const currentY = ref(0);
const accumulatedValue = ref(0);
const isDragging = ref(false);
let lastDragY = 0;
let startStripY = 0;
// --- utils ---
const clamp = (v, min, max) => Math.min(max, Math.max(min, v));
const sanitizeBounds = () => {
// 防止 min/max 传反
const min = Number.isFinite(props.min) ? props.min : -Infinity;
const max = Number.isFinite(props.max) ? props.max : Infinity;
return min <= max ? { min, max } : { min: max, max: min };
};
const valueToVisualOffsetPx = (value) => {
// 只决定“显示在刻度条上的那一圈位置”,不决定总圈数
const mod = ((value % cycleUnits) + cycleUnits) % cycleUnits; // 0..99
return mod * pxPerUnit; // 0..(cycleUnits-1)*pxPerUnit
};
// --- 计算属性 ---
const stripStyle = computed(() => ({
transform: `translateY(${currentY.value}px)`,
height: `${cycleHeight * 3}px`
}));
// ✅ 建议:显示值直接从 accumulatedValue 推导,更稳定(不受回卷影响)
const displayValue = computed(() => {
const v = Math.round(accumulatedValue.value);
const mod = ((v % cycleUnits) + cycleUnits) % cycleUnits; // 0..99
const snapped = Math.round(mod / 10) * 10; // 显示到 10 的倍数
return snapped === cycleUnits ? 0 : snapped; // 100 显示为 0
});
// --- 辅助方法:计算小刻度样式 ---
const getSubTickStyle = (n) => {
const gap = config.stepHeight / (config.subTicks + 1);
const topPos = n * gap;
const isMiddle = n === Math.ceil(config.subTicks / 2);
const width = isMiddle ? '16px' : '10px';
const color = isMiddle ? '#666' : '#888';
return {
top: `${topPos}px`,
width,
backgroundColor: color
};
};
// ✅ 把“数值变化”应用到视图(带边界)
const applyDeltaValue = (deltaValue, dragging = false) => {
if (!deltaValue) return;
const { min, max } = sanitizeBounds();
const prev = accumulatedValue.value;
const next = clamp(prev + deltaValue, min, max);
const realDeltaValue = next - prev;
// 到边界就不动
if (realDeltaValue === 0) return;
// value -> px:value 增加 => 条带上移(Y 减小)
const realDeltaY = -(realDeltaValue * pxPerUnit);
currentY.value += realDeltaY;
accumulatedValue.value = next;
emit('rotate', {
delta: realDeltaValue,
total: accumulatedValue.value,
display: displayValue.value
});
emit('update:value', accumulatedValue.value);
// 视觉无缝回卷(只回卷视图,不改变 value)
const topThreshold = -cycleHeight * 0.5;
const bottomThreshold = -cycleHeight * 1.5;
if (currentY.value > topThreshold) {
currentY.value -= cycleHeight;
if (dragging) startStripY -= cycleHeight;
} else if (currentY.value < bottomThreshold) {
currentY.value += cycleHeight;
if (dragging) startStripY += cycleHeight;
}
};
// ✅ 原来的 updateView(deltaY px) 改为:先把 px 换算成 value,再走 applyDeltaValue
const updateViewByDeltaY = (deltaY, dragging = false) => {
// 向上滑动(负Y) -> 数值增加
const deltaValue = -(deltaY / config.stepHeight) * unitsPerMainTick; // 等价 -(deltaY/pxPerUnit)
applyDeltaValue(deltaValue, dragging);
};
// --- 生命周期 ---
onMounted(() => {
const { min, max } = sanitizeBounds();
accumulatedValue.value = clamp(props.initialValue, min, max);
const containerCenterOffset = config.containerHeight / 2;
const visualOffsetPx = valueToVisualOffsetPx(accumulatedValue.value);
// 放在中间那一圈,避免顶部空白
currentY.value = -cycleHeight + containerCenterOffset - visualOffsetPx;
window.addEventListener('mousemove', handleGlobalMouseMove);
window.addEventListener('mouseup', handleGlobalMouseUp);
window.addEventListener('touchmove', handleGlobalTouchMove, { passive: false });
window.addEventListener('touchend', handleGlobalMouseUp);
});
onUnmounted(() => {
window.removeEventListener('mousemove', handleGlobalMouseMove);
window.removeEventListener('mouseup', handleGlobalMouseUp);
window.removeEventListener('touchmove', handleGlobalTouchMove);
window.removeEventListener('touchend', handleGlobalMouseUp);
});
// 可选:当外部改 initialValue 时同步(如果你需要受控)
watch(
() => props.initialValue,
(v) => {
const { min, max } = sanitizeBounds();
accumulatedValue.value = clamp(v, min, max);
const containerCenterOffset = config.containerHeight / 2;
const visualOffsetPx = valueToVisualOffsetPx(accumulatedValue.value);
currentY.value = -cycleHeight + containerCenterOffset - visualOffsetPx;
}
);
// --- 事件 ---
const handleMouseDown = (e) => {
isDragging.value = true;
startStripY = currentY.value;
lastDragY = e.clientY;
document.body.style.cursor = 'grabbing';
e.preventDefault();
};
const handleTouchStart = (e) => {
isDragging.value = true;
startStripY = currentY.value;
lastDragY = e.touches[0].clientY;
};
const handleGlobalMouseMove = (e) => {
if (!isDragging.value) return;
const deltaY = (e.clientY - lastDragY) * props.sensitivity;
lastDragY = e.clientY;
updateViewByDeltaY(deltaY, true);
};
const handleGlobalTouchMove = (e) => {
if (!isDragging.value) return;
const clientY = e.touches[0].clientY;
const deltaY = (clientY - lastDragY) * props.sensitivity;
lastDragY = clientY;
updateViewByDeltaY(deltaY, true);
if (e.cancelable) e.preventDefault();
};
const handleGlobalMouseUp = () => {
isDragging.value = false;
document.body.style.cursor = '';
};
const handleWheel = (e) => {
// ✅ 滚轮用“数值单位”驱动,更可控
// e.deltaY > 0 通常代表向下滚:你可以按习惯调整正负
const deltaValue = e.deltaY > 0 ? props.wheelStepUnits : -props.wheelStepUnits;
applyDeltaValue(deltaValue, false);
};
</script>
<style scoped>
/* 样式保持不变,已包含在上面 */
.silver-knob-wrapper {
display: inline-block;
}
.device-casing {
padding: 0px 0 0 6px;
display: flex;
align-items: center;
position: relative;
user-select: none;
}
.knob-component {
display: flex;
height: 200px;
perspective: 1000px;
cursor: ns-resize;
position: relative;
filter: drop-shadow(2px 4px 6px rgba(0, 0, 0, 0.3));
}
.knob-component:active {
cursor: grabbing;
}
.dial-face {
width: 80px;
height: 100%;
background: linear-gradient(to right, #999, #f0f0f0 40%, #ddd 100%);
position: relative;
overflow: hidden;
border-top-left-radius: 10px;
border-bottom-left-radius: 10px;
border-left: 1px solid #888;
}
.dial-strip {
position: absolute;
left: 0;
width: 100%;
will-change: transform;
}
.tick-item {
display: flex;
align-items: center;
padding-left: 30px;
position: relative;
box-sizing: border-box;
}
.tick-num {
color: #333;
font-size: 18px;
font-family: Arial, sans-serif;
font-weight: bold;
text-shadow: 0 1px 1px rgba(255, 255, 255, 0.5);
margin-top: -20px;
margin-left: 5px;
}
.tick-item::before {
content: '';
position: absolute;
left: 0;
top: -1px;
width: 25px;
height: 2px;
background-color: #444;
z-index: 1;
}
.tick-sub-group {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.tick-sub {
position: absolute;
left: 0;
height: 1px;
background-color: #888;
}
.dial-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
background: linear-gradient(to bottom, rgba(100, 100, 100, 0.8) 0%, rgba(255, 255, 255, 0.3) 20%, rgba(255, 255, 255, 0.0) 50%, rgba(255, 255, 255, 0.3) 80%, rgba(100, 100, 100, 0.8) 100%);
z-index: 10;
mix-blend-mode: multiply;
}
.knob-grip {
width: 25px;
height: 100%;
background-color: #ccc;
border-top-right-radius: 12px;
border-bottom-right-radius: 12px;
position: relative;
border: 1px solid #bbb;
border-left: none;
background-image:
linear-gradient(90deg, rgba(150, 150, 150, 1) 0%, rgba(255, 255, 255, 1) 45%, rgba(255, 255, 255, 1) 55%, rgba(180, 180, 180, 1) 100%),
repeating-linear-gradient(45deg, transparent, transparent 2px, rgba(50, 50, 50, 0.3) 2px, rgba(50, 50, 50, 0.4) 4px),
repeating-linear-gradient(-45deg, transparent, transparent 2px, rgba(50, 50, 50, 0.3) 2px, rgba(50, 50, 50, 0.4) 4px);
background-size: 100% 100%, 10px 10px, 10px 10px;
background-blend-mode: multiply;
}
.knob-grip::before,
.knob-grip::after {
content: '';
position: absolute;
width: 100%;
height: 15px;
background: linear-gradient(to bottom, rgba(100, 100, 100, 0.5), transparent);
left: 0;
border-top-right-radius: 12px;
}
.knob-grip::before {
top: 0;
}
.knob-grip::after {
bottom: 0;
transform: scaleY(-1);
border-bottom-right-radius: 12px;
border-top-right-radius: 0;
}
.indicator-mark {
position: absolute;
left: 0px;
top: 50%;
transform: translateY(-50%);
width: 25px;
height: 4px;
background: #d00;
z-index: 20;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
clip-path: polygon(0 0, 85% 0, 100% 50%, 85% 100%, 0 100%);
}
</style>
+198
View File
@@ -0,0 +1,198 @@
<template>
<div class="slider-wrapper" :style="wrapperStyle">
<div class="slider-inner" ref="containerRef" :style="innerStyle">
<div class="track-slot"></div>
<div
class="knob"
:style="knobStyle"
@mousedown="startDrag"
@touchstart.prevent="startDrag"
>
<div class="knob-face"></div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue';
const props = defineProps({
modelValue: {
type: Number,
default: 0
},
// 新增 scale 属性,默认为 1 (原始大小)
scale: {
type: Number,
default: 1.0
}
});
const emit = defineEmits(['update:modelValue', 'change']);
// --- 内部常量定义 (基于原始设计尺寸) ---
const ORIGINAL_WIDTH = 40; // 原始宽度 px
const ORIGINAL_HEIGHT = 150; // 原始高度 px
const STEPS = 4;
const STEP_DISTANCE = 40;
const KNOB_HEIGHT = 24;
const TRACK_PADDING_TOP = 2;
// --- 状态 ---
const currentIndex = ref(props.modelValue);
watch(() => props.modelValue, (newVal) => {
currentIndex.value = newVal;
});
// --- 样式计算 ---
// 1. 外层容器样式:计算缩放后实际占用的宽高
const wrapperStyle = computed(() => ({
width: `${ORIGINAL_WIDTH * props.scale}px`,
height: `${ORIGINAL_HEIGHT * props.scale}px`,
}));
// 2. 内层容器样式:固定原始尺寸,使用 scale 缩放
const innerStyle = computed(() => ({
width: `${ORIGINAL_WIDTH}px`,
height: `${ORIGINAL_HEIGHT}px`,
transform: `scale(${props.scale})`,
transformOrigin: 'top left' // 关键:从左上角开始缩放,确保与外层容器对齐
}));
// 3. 旋钮位置样式
const knobStyle = computed(() => {
const topOffset = TRACK_PADDING_TOP + (currentIndex.value * STEP_DISTANCE);
return {
transform: `translateY(${topOffset}px)`
};
});
// --- 拖拽逻辑 (含缩放修正) ---
let isDragging = false;
let startMouseY = 0;
let startKnobTop = 0;
const startDrag = (e) => {
isDragging = true;
startMouseY = e.clientY || e.touches[0].clientY;
startKnobTop = TRACK_PADDING_TOP + (currentIndex.value * STEP_DISTANCE);
window.addEventListener('mousemove', onDrag);
window.addEventListener('mouseup', stopDrag);
window.addEventListener('touchmove', onDrag, { passive: false });
window.addEventListener('touchend', stopDrag);
};
const onDrag = (e) => {
if (!isDragging) return;
if (e.cancelable) e.preventDefault();
const currentMouseY = e.clientY || e.touches[0].clientY;
// [关键修正]
// 鼠标在屏幕上移动的距离 (screen delta)
const screenDeltaY = currentMouseY - startMouseY;
// 转换为组件内部的距离 (internal delta)
// 比如:如果缩放是 0.5,屏幕移动 10px,相当于内部移动了 20px
const internalDeltaY = screenDeltaY / props.scale;
let newRawTop = startKnobTop + internalDeltaY;
// 计算吸附
let relativeTop = newRawTop - TRACK_PADDING_TOP;
let newIndex = Math.round(relativeTop / STEP_DISTANCE);
const maxIndex = STEPS - 1;
if (newIndex < 0) newIndex = 0;
if (newIndex > maxIndex) newIndex = maxIndex;
if (newIndex !== currentIndex.value) {
currentIndex.value = newIndex;
emit('update:modelValue', newIndex);
emit('change', newIndex);
}
};
const stopDrag = () => {
isDragging = false;
window.removeEventListener('mousemove', onDrag);
window.removeEventListener('mouseup', stopDrag);
window.removeEventListener('touchmove', onDrag);
window.removeEventListener('touchend', stopDrag);
};
</script>
<style scoped>
/* 样式部分主要针对 slider-inner 内部
保持原本的尺寸定义即可,缩放由内联样式 style 处理
*/
.slider-wrapper {
/* 防止溢出裁剪阴影 */
overflow: visible;
/* 禁止选中 */
user-select: none;
touch-action: none;
/* 方便父级布局 */
display: inline-block;
vertical-align: middle;
}
.slider-inner {
position: relative;
/* 这里的宽高现在由 innerStyle 动态控制,但写上默认值是个好习惯 */
width: 40px;
height: 150px;
}
.track-slot {
position: absolute;
top: 0;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 8px;
background-color: #1a1a1a;
border-radius: 4px;
box-shadow:
inset 1px 1px 3px rgba(0,0,0,0.7),
inset -1px -1px 1px rgba(255,255,255,0.1);
}
.knob {
position: absolute;
top: 0;
left: 50%;
margin-left: -23px;
width: 46px;
height: 24px;
z-index: 10;
cursor: grab;
transition: transform 0.2s cubic-bezier(0.25, 1, 0.5, 1);
backface-visibility: hidden; /* 优化缩放后的渲染 */
}
.knob:active {
cursor: grabbing;
}
.knob-face {
width: 100%;
height: 100%;
background: linear-gradient(to bottom, #ffffff 0%, #e4e4e4 100%);
border-radius: 4px;
box-shadow:
inset 0 1px 0 rgba(255,255,255,1),
inset 0 -1px 0 rgba(0,0,0,0.1),
0 3px 6px rgba(0,0,0,0.35),
0 0 0 1px rgba(0,0,0,0.08);
}
</style>
+6
View File
@@ -0,0 +1,6 @@
import { createApp } from 'vue'
import './style.css'
import './assets/stylesheet.css'
import App from './App.vue'
createApp(App).mount('#app')
+38
View File
@@ -0,0 +1,38 @@
body{
margin: 0;
}
*{
user-select: none;
box-sizing: border-box;
}
*[data-tooltip] {
touch-action: none;
}
/* ===== 接线 pin 交互样式(全局) ===== */
.pin {
background-color: transparent !important;
border: 2px dashed transparent;
border-radius: 50%;
box-sizing: border-box;
transition: border-color 0.15s ease, box-shadow 0.15s ease, background-color 0.15s ease;
}
.pin:hover {
border-color: rgba(59, 130, 246, 0.95);
background-color: rgba(255, 255, 255, 0.06);
}
.pin.pin-connecting {
border-color: rgba(59, 130, 246, 0.95);
background-color: rgba(59, 130, 246, 0.10);
box-shadow:
0 0 0 3px rgba(59, 130, 246, 0.25),
0 0 14px rgba(59, 130, 246, 0.55);
}
.btn, .xymode, .power-btn{
touch-action: none;
}