first commit
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user