Compare commits
3
Commits
8f673e0095
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d14b2e5ca8 | ||
|
|
c6d10b6221 | ||
|
|
a23facccba |
+247
-138
@@ -3,6 +3,7 @@ import { ref, computed, reactive, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import DeterminationMachine from './components/DeterminationMachine.vue';
|
||||
//@ts-ignore
|
||||
import SilverKnob from './components/SilverKnob.vue';
|
||||
import DraggableInstrumentWrapper from './components/DraggableInstrumentWrapper.vue';
|
||||
import Oscilloscope from './components/Oscilloscope.vue';
|
||||
import SignalGen from './components/SignalGen.vue';
|
||||
import Notebook from './components/Notebook.vue';
|
||||
@@ -30,12 +31,61 @@ const trackerSessionId = (() => {
|
||||
}
|
||||
})();
|
||||
|
||||
// =========================
|
||||
// 学号(入门引导确认后弹窗)
|
||||
// =========================
|
||||
|
||||
const STUDENT_ID_KEY = 'sound-speed:student-id';
|
||||
|
||||
const studentId = ref<string | null>(null);
|
||||
const showStudentIdModal = ref(false);
|
||||
const studentIdInput = ref('');
|
||||
|
||||
const loadStudentId = () => {
|
||||
try {
|
||||
const saved = window.localStorage.getItem(STUDENT_ID_KEY);
|
||||
if (typeof saved === 'string' && saved.trim()) {
|
||||
studentId.value = saved.trim();
|
||||
studentIdInput.value = saved.trim();
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const persistStudentId = (v: string | null) => {
|
||||
try {
|
||||
if (!v) window.localStorage.removeItem(STUDENT_ID_KEY);
|
||||
else window.localStorage.setItem(STUDENT_ID_KEY, v);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const normalizedStudentIdInput = computed(() => studentIdInput.value.trim());
|
||||
const canContinueWithStudentId = computed(() => normalizedStudentIdInput.value.length > 0);
|
||||
|
||||
const chooseGuestMode = () => {
|
||||
studentId.value = null;
|
||||
studentIdInput.value = '';
|
||||
persistStudentId(null);
|
||||
showStudentIdModal.value = false;
|
||||
};
|
||||
|
||||
const continueWithStudentId = () => {
|
||||
if (!canContinueWithStudentId.value) return;
|
||||
studentId.value = normalizedStudentIdInput.value;
|
||||
persistStudentId(studentId.value);
|
||||
showStudentIdModal.value = false;
|
||||
};
|
||||
|
||||
const postTracker = (event: string, data?: unknown) => {
|
||||
const body = JSON.stringify({
|
||||
event,
|
||||
data,
|
||||
ts: Date.now(),
|
||||
sessionId: trackerSessionId,
|
||||
studentId: studentId.value ?? undefined,
|
||||
step: currentStepTitle.value,
|
||||
});
|
||||
|
||||
@@ -228,6 +278,7 @@ const guideImgRef = ref<HTMLImageElement | null>(null);
|
||||
|
||||
const closeGuide = () => {
|
||||
showGuide.value = false;
|
||||
showStudentIdModal.value = true;
|
||||
};
|
||||
|
||||
const onGuideClick = (e: MouseEvent) => {
|
||||
@@ -336,6 +387,23 @@ const wiringConnected = reactive<Record<WireKey, boolean>>({
|
||||
rxTransducer_to_right: false,
|
||||
});
|
||||
|
||||
// =========================
|
||||
// 示波器信号有效性(由接线决定)
|
||||
// =========================
|
||||
|
||||
// CH1:仅当 [信号发生器 发射端 波形] -> [示波器 CH1] 接入时才有波形
|
||||
const oscCh1HasSignal = computed(() => wiringConnected.txWave_to_ch1);
|
||||
|
||||
// CH2:仅当这三条线都接入时才有波形
|
||||
// [信号发生器 发射端 换能器] -> [换能器 左侧输入]
|
||||
// [信号发生器 接收端 波形] -> [示波器 CH2]
|
||||
// [信号发生器 接收端 换能器] -> [换能器 右侧输出]
|
||||
const oscCh2HasSignal = computed(() =>
|
||||
wiringConnected.txTransducer_to_left
|
||||
&& wiringConnected.rxWave_to_ch2
|
||||
&& wiringConnected.rxTransducer_to_right
|
||||
);
|
||||
|
||||
// =========================
|
||||
// 步骤标题(document.title)
|
||||
// =========================
|
||||
@@ -389,6 +457,17 @@ const tooltip = reactive({
|
||||
side: 'right' as TooltipSide,
|
||||
});
|
||||
|
||||
// 接线错误:短暂提示(不打断交互)
|
||||
const wiringErrorToast = reactive({
|
||||
visible: false,
|
||||
text: '',
|
||||
x: 0,
|
||||
y: 0,
|
||||
side: 'right' as TooltipSide,
|
||||
});
|
||||
|
||||
let wiringErrorToastTimer: number | null = null;
|
||||
|
||||
const tooltipTargetEl = ref<HTMLElement | null>(null);
|
||||
|
||||
const hideTooltip = () => {
|
||||
@@ -396,7 +475,7 @@ const hideTooltip = () => {
|
||||
tooltipTargetEl.value = null;
|
||||
};
|
||||
|
||||
const updateTooltipPosition = (el: HTMLElement) => {
|
||||
const calcBubblePosition = (el: HTMLElement) => {
|
||||
const table = tableRef.value;
|
||||
if (!table) return;
|
||||
|
||||
@@ -412,11 +491,21 @@ const updateTooltipPosition = (el: HTMLElement) => {
|
||||
const leftX = r.left - tableRect.left - margin;
|
||||
|
||||
const hasRoomOnRight = rightX + approxBubbleW <= tableRect.width;
|
||||
tooltip.side = hasRoomOnRight ? 'right' : 'left';
|
||||
tooltip.x = hasRoomOnRight ? rightX : leftX;
|
||||
const side = hasRoomOnRight ? 'right' : 'left';
|
||||
const x = hasRoomOnRight ? rightX : leftX;
|
||||
|
||||
// y 以上方为主,避免贴边
|
||||
tooltip.y = Math.max(10, Math.min(tableRect.height - 10, anchorY));
|
||||
const y = Math.max(10, Math.min(tableRect.height - 10, anchorY));
|
||||
|
||||
return { x, y, side } as const;
|
||||
};
|
||||
|
||||
const updateTooltipPosition = (el: HTMLElement) => {
|
||||
const pos = calcBubblePosition(el);
|
||||
if (!pos) return;
|
||||
tooltip.side = pos.side;
|
||||
tooltip.x = pos.x;
|
||||
tooltip.y = pos.y;
|
||||
};
|
||||
|
||||
const showTooltipForEl = (el: HTMLElement) => {
|
||||
@@ -430,6 +519,27 @@ const showTooltipForEl = (el: HTMLElement) => {
|
||||
updateTooltipPosition(el);
|
||||
};
|
||||
|
||||
const showWiringErrorToast = (text: string, anchorEl?: HTMLElement) => {
|
||||
const el = anchorEl ?? null;
|
||||
if (el) {
|
||||
const pos = calcBubblePosition(el);
|
||||
if (pos) {
|
||||
wiringErrorToast.side = pos.side;
|
||||
wiringErrorToast.x = pos.x;
|
||||
wiringErrorToast.y = pos.y;
|
||||
}
|
||||
}
|
||||
|
||||
wiringErrorToast.text = text;
|
||||
wiringErrorToast.visible = true;
|
||||
|
||||
if (wiringErrorToastTimer !== null) window.clearTimeout(wiringErrorToastTimer);
|
||||
wiringErrorToastTimer = window.setTimeout(() => {
|
||||
wiringErrorToastTimer = null;
|
||||
wiringErrorToast.visible = false;
|
||||
}, 1600);
|
||||
};
|
||||
|
||||
const findTooltipEl = (target: EventTarget | null): HTMLElement | null => {
|
||||
if (!target) return null;
|
||||
const t = target as HTMLElement;
|
||||
@@ -473,6 +583,15 @@ const tooltipStyle = computed(() => {
|
||||
return base;
|
||||
});
|
||||
|
||||
const wiringErrorToastStyle = computed(() => {
|
||||
const base: Record<string, string> = {
|
||||
left: `${wiringErrorToast.x}px`,
|
||||
top: `${wiringErrorToast.y}px`,
|
||||
};
|
||||
base.transform = wiringErrorToast.side === 'right' ? 'translate(0, -50%)' : 'translate(-100%, -50%)';
|
||||
return base;
|
||||
});
|
||||
|
||||
const pinEls = new Map<PinId, HTMLElement>();
|
||||
const selectedPin = ref<PinId | null>(null);
|
||||
|
||||
@@ -584,13 +703,16 @@ const addCableIfNeeded = (key: WireKey, a: PinId, b: PinId) => {
|
||||
scheduleUpdateCablePaths();
|
||||
};
|
||||
|
||||
const alertWiringError = () => {
|
||||
window.alert('接线错误:请按接线示意连接对应端口。');
|
||||
const alertWiringError = (anchorEl?: HTMLElement) => {
|
||||
showWiringErrorToast('接线错误:请按接线示意连接对应端口。', anchorEl);
|
||||
};
|
||||
|
||||
const onPinClick = (id: PinId) => {
|
||||
ea.play('线缆插入')
|
||||
|
||||
// 有任何点击就把“错误提示”收起,避免遮挡
|
||||
if (wiringErrorToast.visible) wiringErrorToast.visible = false;
|
||||
|
||||
postTracker('pin_click', { pinId: id, hasSelected: selectedPin.value !== null });
|
||||
|
||||
if (selectedPin.value === null) {
|
||||
@@ -608,7 +730,7 @@ const onPinClick = (id: PinId) => {
|
||||
const key = requiredByPair.get(normalizePair(first, id));
|
||||
if (!key) {
|
||||
postTracker('wire_error', { a: first, b: id });
|
||||
alertWiringError();
|
||||
alertWiringError(pinEls.get(id) ?? pinEls.get(first));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -669,6 +791,7 @@ const updateCablePaths = (onlyPins?: Set<PinId>) => {
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadStudentId();
|
||||
scheduleUpdateCablePaths();
|
||||
window.addEventListener('resize', scheduleUpdateCablePaths);
|
||||
});
|
||||
@@ -677,6 +800,7 @@ onBeforeUnmount(() => {
|
||||
if (cablesRaf !== null) cancelAnimationFrame(cablesRaf);
|
||||
window.removeEventListener('resize', scheduleUpdateCablePaths);
|
||||
if (dmRightCableTimer !== null) window.clearTimeout(dmRightCableTimer);
|
||||
if (wiringErrorToastTimer !== null) window.clearTimeout(wiringErrorToastTimer);
|
||||
});
|
||||
|
||||
</script>
|
||||
@@ -691,10 +815,30 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<Transition name="guide">
|
||||
<div v-if="showStudentIdModal" class="studentid-overlay" @pointerdown.stop>
|
||||
<div class="studentid-modal" role="dialog" aria-modal="true" aria-label="学号确认">
|
||||
<div class="studentid-title">请输入学号</div>
|
||||
<input v-model="studentIdInput" class="studentid-input" type="text" inputmode="numeric"
|
||||
autocomplete="off" placeholder="学号" @keydown.enter.prevent="continueWithStudentId" />
|
||||
<div class="studentid-actions">
|
||||
<button class="studentid-btn" type="button" @click="chooseGuestMode">游客模式</button>
|
||||
<button class="studentid-btn primary" type="button" :disabled="!canContinueWithStudentId"
|
||||
@click="continueWithStudentId">继续</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<div v-if="tooltip.visible" class="tooltip" :class="`tooltip--${tooltip.side}`" :style="tooltipStyle">
|
||||
<div class="tooltip-inner">{{ tooltip.text }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="wiringErrorToast.visible" class="tooltip" :class="`tooltip--${wiringErrorToast.side}`"
|
||||
:style="wiringErrorToastStyle">
|
||||
<div class="tooltip-inner">{{ wiringErrorToast.text }}</div>
|
||||
</div>
|
||||
|
||||
<svg class="cables" :width="1600" :height="900" viewBox="0 0 1600 900"
|
||||
aria-hidden="true">
|
||||
<defs>
|
||||
@@ -711,91 +855,30 @@ onBeforeUnmount(() => {
|
||||
</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>
|
||||
<DraggableInstrumentWrapper :style="machineStyle" :instrument="machine" @start-drag="startDrag">
|
||||
<DeterminationMachine v-model="distance" class="machine"
|
||||
@register-pin="registerPin" @unregister-pin="unregisterPin"
|
||||
@pin-click="onPinClick" @change="onDistanceChange" />
|
||||
</div>
|
||||
@register-pin="registerPin" @unregister-pin="unregisterPin" @pin-click="onPinClick"
|
||||
@change="onDistanceChange" />
|
||||
</DraggableInstrumentWrapper>
|
||||
|
||||
<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>
|
||||
<DraggableInstrumentWrapper :style="notebookStyle" :instrument="notebook" @start-drag="startDrag">
|
||||
<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>
|
||||
:signal-frequency-k-hz="signalFrequencyKHz" :lissajous-line-streak="lissajousLineStreak"
|
||||
:student-id="studentId ?? undefined" :tracker-session-id="trackerSessionId" />
|
||||
</DraggableInstrumentWrapper>
|
||||
|
||||
<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>
|
||||
<DraggableInstrumentWrapper :style="oscilloscopeStyle" :instrument="oscilloscope" @start-drag="startDrag">
|
||||
<Oscilloscope :distance="distance" :frequency="signalFrequencyKHz * 1000" :ch1-has-signal="oscCh1HasSignal"
|
||||
:ch2-has-signal="oscCh2HasSignal" class="machine" @register-pin="registerPin"
|
||||
@unregister-pin="unregisterPin" @pin-click="onPinClick" @settings="onOscSettings"
|
||||
@lissajous-line="onLissajousLine" />
|
||||
</DraggableInstrumentWrapper>
|
||||
|
||||
<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>
|
||||
<DraggableInstrumentWrapper :style="signalGenStyle" :instrument="signalGen" @start-drag="startDrag">
|
||||
<SignalGen v-model="signalFrequencyKHz" class="machine" @register-pin="registerPin"
|
||||
@unregister-pin="unregisterPin" @pin-click="onPinClick" />
|
||||
</DraggableInstrumentWrapper>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -822,6 +905,84 @@ onBeforeUnmount(() => {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.studentid-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9998;
|
||||
background: rgba(0, 0, 0, 0.70);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.studentid-modal {
|
||||
width: 420px;
|
||||
max-width: calc(100vw - 40px);
|
||||
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
|
||||
);
|
||||
border: 2px solid rgba(20, 20, 20, 0.70);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 16px 28px rgba(0, 0, 0, 0.35);
|
||||
padding: 14px 14px 12px;
|
||||
color: rgba(20, 20, 20, 0.95);
|
||||
}
|
||||
|
||||
.studentid-title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.studentid-input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
border-radius: 10px;
|
||||
border: 2px solid rgba(20, 20, 20, 0.45);
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
padding: 10px 10px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.studentid-input:focus {
|
||||
border-color: rgba(20, 20, 20, 0.70);
|
||||
box-shadow: 0 0 0 4px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.studentid-actions {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.studentid-btn {
|
||||
appearance: none;
|
||||
border: 2px solid rgba(20, 20, 20, 0.55);
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
border-radius: 999px;
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.studentid-btn.primary {
|
||||
background: rgba(20, 20, 20, 0.90);
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
.studentid-btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.guide-img {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
@@ -863,62 +1024,10 @@ onBeforeUnmount(() => {
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
<script setup lang="ts">
|
||||
import type { PropType } from 'vue';
|
||||
|
||||
interface InstrumentState {
|
||||
left: number;
|
||||
bottom: number;
|
||||
baseScale: number;
|
||||
zIndex: number;
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
instrument: {
|
||||
type: Object as PropType<InstrumentState>,
|
||||
required: true,
|
||||
},
|
||||
tooltip: {
|
||||
type: String,
|
||||
default: '拖动把手可移动仪器位置',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'start-drag', instrument: InstrumentState, ev: PointerEvent): void;
|
||||
}>();
|
||||
|
||||
const onPointerDown = (ev: PointerEvent) => {
|
||||
emit('start-drag', props.instrument, ev);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="instrument-wrapper">
|
||||
<div class="drag-handle" :data-tooltip="tooltip" @pointerdown="onPointerDown">
|
||||
<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>
|
||||
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.instrument-wrapper {
|
||||
position: absolute;
|
||||
filter: drop-shadow(0 10px 10px black);
|
||||
transition: filter 0.2s;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
</style>
|
||||
@@ -21,6 +21,8 @@ const props = defineProps<{
|
||||
osc: OscStatus;
|
||||
signalFrequencyKHz: number;
|
||||
lissajousLineStreak: number;
|
||||
studentId?: string;
|
||||
trackerSessionId?: string;
|
||||
}>();
|
||||
|
||||
const inRange = (v: number, a: number, b: number) => v >= a && v <= b;
|
||||
@@ -144,7 +146,13 @@ const postJson = async (url: string, data: unknown) => {
|
||||
};
|
||||
|
||||
const postTracker = (event: string, data?: unknown) => {
|
||||
const body = JSON.stringify({ event, data, ts: Date.now() });
|
||||
const body = JSON.stringify({
|
||||
event,
|
||||
data,
|
||||
ts: Date.now(),
|
||||
sessionId: props.trackerSessionId,
|
||||
studentId: props.studentId,
|
||||
});
|
||||
try {
|
||||
if (typeof navigator !== 'undefined' && 'sendBeacon' in navigator) {
|
||||
const blob = new Blob([body], { type: 'application/json' });
|
||||
@@ -183,6 +191,7 @@ const submitRecord = async () => {
|
||||
|
||||
const payload = {
|
||||
ts: Date.now(),
|
||||
studentId: props.studentId,
|
||||
raw: {
|
||||
naturalFreqKHz: rawData.value.naturalFreqKHz,
|
||||
rows: rawData.value.rows,
|
||||
@@ -194,6 +203,8 @@ const submitRecord = async () => {
|
||||
validDistancesMm: parsedDistances.value,
|
||||
},
|
||||
context: {
|
||||
trackerSessionId: props.trackerSessionId,
|
||||
studentId: props.studentId,
|
||||
wiring: props.wiring,
|
||||
osc: props.osc,
|
||||
signalFrequencyKHz: props.signalFrequencyKHz,
|
||||
|
||||
@@ -31,6 +31,15 @@ const props = defineProps({
|
||||
// 声速,单位m/s
|
||||
soundSpeed: { type: Number, default: 340 },
|
||||
|
||||
// 接线状态:控制通道是否真正有信号
|
||||
// CH1:仅当 [信号发生器 发射端 波形] -> [示波器 CH1] 接入时为 true
|
||||
ch1HasSignal: { type: Boolean, default: false },
|
||||
// CH2:仅当三条线都接入时为 true
|
||||
// [信号发生器 发射端 换能器] -> [换能器 左侧输入]
|
||||
// [信号发生器 接收端 波形] -> [示波器 CH2]
|
||||
// [信号发生器 接收端 换能器] -> [换能器 右侧输出]
|
||||
ch2HasSignal: { type: Boolean, default: false },
|
||||
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -147,8 +156,8 @@ function drawOscilloscope() {
|
||||
const gainCH1 = getGain(vdch1.value);
|
||||
const gainCH2 = getGain(vdch2.value);
|
||||
|
||||
// X-Y 模式下检测“李萨如图形出现直线”的进入事件
|
||||
if (power.value && isXYMode.value) {
|
||||
// X-Y 模式下检测“李萨如图形出现直线”的进入事件(需两路都有信号)
|
||||
if (power.value && isXYMode.value && props.ch1HasSignal && props.ch2HasSignal) {
|
||||
const phase = phy.phaseRadTotal;
|
||||
const isLineNow = Math.abs(Math.sin(phase)) < 0.10 && phy.resonanceFactor > 0.05;
|
||||
if (isLineNow && !lastWasLine.value) {
|
||||
@@ -171,7 +180,8 @@ function drawOscilloscope() {
|
||||
|
||||
if (isXYMode.value) {
|
||||
// ================= X-Y Mode (李萨如图形) =================
|
||||
const opacity = 0.3 + 0.7 * phy.resonanceFactor;
|
||||
const hasAnySignal = props.ch1HasSignal || props.ch2HasSignal;
|
||||
const opacity = hasAnySignal ? (0.3 + 0.7 * phy.resonanceFactor) : 0.15;
|
||||
ctx.strokeStyle = `rgba(0, 255, 0, ${opacity})`;
|
||||
|
||||
// 强辉光效果 - 多层叠加
|
||||
@@ -180,8 +190,8 @@ function drawOscilloscope() {
|
||||
|
||||
ctx.beginPath();
|
||||
const phase = phy.phaseRadTotal;
|
||||
const ampX = gainCH1;
|
||||
const ampY = phy.resonanceFactor * gainCH2;
|
||||
const ampX = props.ch1HasSignal ? gainCH1 : 0;
|
||||
const ampY = props.ch2HasSignal ? (phy.resonanceFactor * gainCH2) : 0;
|
||||
|
||||
for (let t = 0; t <= 2 * Math.PI; t += 0.03) {
|
||||
const x = scale * ampX * Math.cos(t);
|
||||
@@ -197,6 +207,12 @@ function drawOscilloscope() {
|
||||
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// 两路都没信号时,画一个中心点(避免完全不可见)
|
||||
if (!props.ch1HasSignal && !props.ch2HasSignal) {
|
||||
ctx.fillStyle = 'rgba(0, 255, 0, 0.25)';
|
||||
ctx.fillRect(cx - 1.5, cy - 1.5, 3, 3);
|
||||
}
|
||||
|
||||
// 中心点装饰
|
||||
ctx.fillStyle = 'rgba(0, 255, 0, 0.3)';
|
||||
ctx.fillRect(cx - 1, cy - 5, 2, 10);
|
||||
@@ -218,19 +234,53 @@ function drawOscilloscope() {
|
||||
|
||||
ctx.lineWidth = 3;
|
||||
|
||||
// CH1 (Source) - 黄色参考信号(在CH1、双踪、叠加模式下显示)
|
||||
if (currentModeIndex.value === 0 || currentModeIndex.value === 2 || currentModeIndex.value === 3) {
|
||||
// 叠加模式:直接绘制 CH1 + CH2 的合成波形(形状更复杂)
|
||||
if (currentModeIndex.value === 3) {
|
||||
const opacity = 0.45 + 0.55 * phy.resonanceFactor;
|
||||
ctx.beginPath();
|
||||
// 用 CH1 的黄色线条 + CH2 的绿色辉光,便于与单通道/双踪区分
|
||||
ctx.strokeStyle = `rgba(255, 230, 0, ${opacity})`;
|
||||
ctx.shadowBlur = 14 * (0.4 + 0.6 * phy.resonanceFactor);
|
||||
ctx.shadowColor = '#00ff00';
|
||||
|
||||
const phaseShift = phy.phaseRadTotal;
|
||||
const amp1 = gainCH1 * 0.8;
|
||||
const amp2 = phy.resonanceFactor * gainCH2 * 0.8;
|
||||
|
||||
for (let x = 0; x < w; x += 2) {
|
||||
const t = (x / pixPerCycle) * 2 * Math.PI;
|
||||
const y1 = props.ch1HasSignal ? (amp1 * Math.sin(t + time)) : 0;
|
||||
const y2 = props.ch2HasSignal ? (amp2 * Math.sin(t - phaseShift + time)) : 0;
|
||||
const yVal = scale * (y1 + y2);
|
||||
if (x === 0) ctx.moveTo(x, cy - yVal);
|
||||
else ctx.lineTo(x, cy - yVal);
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
// 第二层辉光
|
||||
ctx.shadowBlur = 22 * (0.4 + 0.6 * phy.resonanceFactor);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// CH1 (Source) - 黄色参考信号(在CH1、双踪模式下显示)
|
||||
if (currentModeIndex.value === 0 || currentModeIndex.value === 2) {
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = 'rgba(255, 230, 0, 0.9)';
|
||||
ctx.shadowBlur = 12;
|
||||
ctx.shadowColor = '#ffff00';
|
||||
|
||||
if (props.ch1HasSignal) {
|
||||
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);
|
||||
}
|
||||
} else {
|
||||
// 未接线:显示中心直线
|
||||
ctx.moveTo(0, cy);
|
||||
ctx.lineTo(w, cy);
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
// 第二层辉光
|
||||
@@ -238,8 +288,8 @@ function drawOscilloscope() {
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// CH2 (Receiver) - 绿色接收信号(在CH2、双踪、叠加模式下显示)
|
||||
if (currentModeIndex.value === 1 || currentModeIndex.value === 2 || currentModeIndex.value === 3) {
|
||||
// CH2 (Receiver) - 绿色接收信号(在CH2、双踪模式下显示)
|
||||
if (currentModeIndex.value === 1 || currentModeIndex.value === 2) {
|
||||
const opacity = 0.3 + 0.7 * phy.resonanceFactor;
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = `rgba(0, 255, 0, ${opacity})`;
|
||||
@@ -249,12 +299,19 @@ function drawOscilloscope() {
|
||||
const phaseShift = phy.phaseRadTotal;
|
||||
const ampY = phy.resonanceFactor * gainCH2 * 0.8;
|
||||
|
||||
if (props.ch2HasSignal) {
|
||||
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);
|
||||
}
|
||||
} else {
|
||||
// 未接线:显示中心直线
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.moveTo(0, cy);
|
||||
ctx.lineTo(w, cy);
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
// 第二层辉光
|
||||
|
||||
Reference in New Issue
Block a user