1747 lines
55 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import { computed, onMounted, reactive, ref, watchEffect } from 'vue'
import AIAssistant from '../components/AIAssistant.vue'
import UploadButton from '../components/UploadButton.vue'
import { elements, type CircuitElement, type Preset } from './elements'
import { formatValue } from './utils'
import { usePreview } from '../composables/usePreview'
// 1) 左侧面板元件清单
type PaletteItem = CircuitElement
const palette: PaletteItem[] = [...elements]
// 2) 画布世界状态(平移/缩放)
const viewportRef = ref<HTMLDivElement | null>(null)
const worldRef = ref<HTMLDivElement | null>(null)
// 右侧 AI 面板显示开关
const { isPreview } = usePreview()
const showAI = ref(true)
const state = reactive({
scale: 1,
minScale: 0.2,
maxScale: 3,
translateX: 0,
translateY: 0,
worldSize: 5000, // 世界为正方形 5000x5000中心在 2500,2500
gridSize: 40, // 网格间距(未缩放前)
})
const worldStyle = computed(() => ({
width: state.worldSize + 'px',
height: state.worldSize + 'px',
transform: `translate(${state.translateX}px, ${state.translateY}px) scale(${state.scale})`,
transformOrigin: '0 0',
// 提供给 CSS 的网格尺寸变量
'--grid': state.gridSize + 'px',
'--grid5': state.gridSize * 5 + 'px',
}))
// 3) 已放置的元件
type Instance = {
id: string
key: string // 文件名键
url: string
x: number
y: number
size: number
rotation: number // 角度单位deg
props: Record<string, number | string>
state?: 'on' | 'off'
}
let idSeq = 0
const instances = reactive<Instance[]>([])
// 连接点与导线
type EndpointRef = { instId: string; cpIndex: number }
type Wire = { id: string; a: EndpointRef; b: EndpointRef }
let wireSeq = 0
const wires = reactive<Wire[]>([])
let pendingEndpoint: EndpointRef | null = null
// 选中与属性编辑
const selectedId = ref<string | null>(null)
const selectedInst = computed(() => instances.find(i => i.id === selectedId.value) || null)
function selectInstance(id: string) { selectedId.value = id }
function clearSelection() { selectedId.value = null }
// 选中元件的元数据(用于读取预设清单)
const selectedMeta = computed(() => {
const inst = selectedInst.value
if (!inst) return null
return elements.find(e => e.key === inst.key) || null
})
// 应用预设:将预设中的属性值写到实例 props
function applyPreset(p: Preset) {
const inst = selectedInst.value
if (!inst) return
const values = p?.propertyValues || {}
for (const [k, v] of Object.entries(values)) {
inst.props[k] = v as any
}
}
// 4) 视口 <-> 世界 坐标转换
function clientToWorld(clientX: number, clientY: number) {
const viewport = viewportRef.value!
const rect = viewport.getBoundingClientRect()
const x = (clientX - rect.left - state.translateX) / state.scale
const y = (clientY - rect.top - state.translateY) / state.scale
return { x, y }
}
function zoomAt(clientX: number, clientY: number, deltaY: number) {
const { x: wx, y: wy } = clientToWorld(clientX, clientY)
const zoomFactor = Math.exp(-deltaY * 0.0015) // 平滑缩放
const next = Math.min(state.maxScale, Math.max(state.minScale, state.scale * zoomFactor))
const prev = state.scale
if (next === prev) return
// 调整平移以保持光标下的世界点不变
state.translateX = clientX - viewportRef.value!.getBoundingClientRect().left - wx * next
state.translateY = clientY - viewportRef.value!.getBoundingClientRect().top - wy * next
state.scale = next
}
// 5) 拖拽放置(来自左侧面板)
const DND_MIME = 'application/x-circuit-element'
function onPaletteDragStart(e: DragEvent, item: PaletteItem) {
if (!e.dataTransfer) return
e.dataTransfer.setData(DND_MIME, item.key)
e.dataTransfer.setData('text/plain', item.key)
e.dataTransfer.effectAllowed = 'copy'
}
function onCanvasDragOver(e: DragEvent) {
e.preventDefault()
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
}
function onCanvasDrop(e: DragEvent) {
e.preventDefault()
const key = e.dataTransfer?.getData(DND_MIME) || e.dataTransfer?.getData('text/plain')
if (!key) return
const item = palette.find(p => p.key === key)
if (!item) return
const { x, y } = clientToWorld(e.clientX, e.clientY)
const defSize = item.defaultSize ?? 64
const props: Record<string, number | string> = {}
; (item.propertySchemas || []).forEach((p) => { props[p.key] = p.default })
// 通用:为所有元件注入“隐藏属性”控制,默认关闭
if (props['hideProps'] === undefined) props['hideProps'] = 'off'
const inst: Instance = { id: String(++idSeq), key: item.key, url: item.url, x: x, y: y, size: defSize, rotation: 0, props }
// 仍保留 state 字段用于灯泡等展示,但对于开关用 props.state 作为真值
if (item.stateImages) inst.state = String(props['state'] ?? 'off') as any
instances.push(inst)
}
// 6) 背景平移
let isPanning = false
let panStart = { x: 0, y: 0 }
let panOrigin = { x: 0, y: 0 }
function onViewportMouseDown(e: MouseEvent) {
// 只在点到空白区域时平移(元素自身会阻止冒泡)
if (e.button !== 0) return
isPanning = true
panStart = { x: e.clientX, y: e.clientY }
panOrigin = { x: state.translateX, y: state.translateY }
}
function onViewportMouseMove(e: MouseEvent) {
if (!isPanning) return
const dx = e.clientX - panStart.x
const dy = e.clientY - panStart.y
state.translateX = panOrigin.x + dx
state.translateY = panOrigin.y + dy
}
function onViewportMouseUp() {
isPanning = false
}
function onWheel(e: WheelEvent) {
// 触控板垂直滚轮缩放,按住 Ctrl 也缩放
if (e.ctrlKey || Math.abs(e.deltaY) > Math.abs(e.deltaX)) {
e.preventDefault()
zoomAt(e.clientX, e.clientY, e.deltaY)
}
}
// 7) 画布内元素拖动
let draggingId: string | null = null
let dragOffset = { x: 0, y: 0 } // 元素内相对偏移(世界坐标)
const mouseWorld = reactive({ x: 0, y: 0 })
// 滑动变阻器滑块拖动状态
let sliderDraggingId: string | null = null
function onInstanceMouseDown(e: MouseEvent, inst: Instance) {
if (e.button !== 0) return
e.stopPropagation()
draggingId = inst.id
const { x: wx, y: wy } = clientToWorld(e.clientX, e.clientY)
dragOffset = { x: wx - inst.x, y: wy - inst.y }
}
function onViewportMouseMoveDrag(e: MouseEvent) {
if (!draggingId) return
const inst = instances.find(i => i.id === draggingId)
if (!inst) return
const { x: wx, y: wy } = clientToWorld(e.clientX, e.clientY)
inst.x = wx - dragOffset.x
inst.y = wy - dragOffset.y
}
function onViewportMouseUpDrag() {
draggingId = null
}
// 将世界坐标转换为实例局部坐标(考虑旋转)
function worldToLocal(inst: Instance, wx: number, wy: number) {
const dx = wx - inst.x
const dy = wy - inst.y
const rad = (inst.rotation || 0) * Math.PI / 180
const cos = Math.cos(rad)
const sin = Math.sin(rad)
// 逆旋转
const lx = dx * cos + dy * sin
const ly = -dx * sin + dy * cos
return { lx, ly }
}
function onViewportMouseMoveSlider(e: MouseEvent) {
if (!sliderDraggingId) return
const inst = instances.find(i => i.id === sliderDraggingId)
if (!inst) return
if (inst.key !== 'sliding_rheostat') return
const { x: wx, y: wy } = clientToWorld(e.clientX, e.clientY)
const { lx } = worldToLocal(inst, wx, wy)
// 滑道范围(与 getRheostatPinOffset 对齐)
const leftFrac = 0.25, rightFrac = 0.75
const t = (lx / inst.size + 0.5 - leftFrac) / (rightFrac - leftFrac)
const pos = Math.max(0, Math.min(1, t))
inst.props['position'] = pos
}
function onViewportMouseUpSlider() {
sliderDraggingId = null
}
// 连接点点击逻辑
function onEndpointClick(instId: string, cpIndex: number, e: MouseEvent) {
e.stopPropagation()
const ep = { instId, cpIndex }
if (!pendingEndpoint) {
pendingEndpoint = ep
return
}
// 不允许同一端点两次
if (pendingEndpoint.instId === instId && pendingEndpoint.cpIndex === cpIndex) {
pendingEndpoint = null
return
}
// 创建导线
wires.push({ id: String(++wireSeq), a: pendingEndpoint, b: ep })
pendingEndpoint = null
}
function removeWire(id: string, e?: MouseEvent) {
if (e) e.stopPropagation()
const idx = wires.findIndex(w => w.id === id)
if (idx >= 0) wires.splice(idx, 1)
}
// 工具函数:获取实例、连接点坐标(世界坐标)
function getInstance(instId: string) {
return instances.find(i => i.id === instId)
}
function getConnectionPointWorldPos(inst: Instance, cpIndex: number) {
const elem = elements.find(e => e.key === inst.key)
const cp = elem?.connectionPoints[cpIndex]
if (!cp) return { x: inst.x, y: inst.y }
const offsetX = (cp.x - 0.5) * inst.size
const offsetY = (cp.y - 0.5) * inst.size
// 旋转偏移
const rad = (inst.rotation || 0) * Math.PI / 180
const cos = Math.cos(rad)
const sin = Math.sin(rad)
const rx = offsetX * cos - offsetY * sin
const ry = offsetX * sin + offsetY * cos
return { x: inst.x + rx, y: inst.y + ry }
}
function onViewportMouseMoveTrack(e: MouseEvent) {
const { x, y } = clientToWorld(e.clientX, e.clientY)
mouseWorld.x = x
mouseWorld.y = y
}
// 命中测试:鼠标是否靠近某连接点(返回最近的端点)
const SNAP_RADIUS = 12
function findClosestEndpoint() {
let best: { instId: string; cpIndex: number; dist: number } | null = null
for (const inst of instances) {
const elem = elements.find(e => e.key === inst.key)
const cps = elem?.connectionPoints || []
cps.forEach((_, idx) => {
const p = getConnectionPointWorldPos(inst, idx)
const dx = p.x - mouseWorld.x
const dy = p.y - mouseWorld.y
const d = Math.hypot(dx, dy)
if (d <= SNAP_RADIUS && (!best || d < best.dist)) best = { instId: inst.id, cpIndex: idx, dist: d }
})
}
return best
}
type Nearest = { instId: string; cpIndex: number; dist: number } | null
const nearestEndpoint = computed<Nearest>(() => findClosestEndpoint())
const previewTarget = computed(() => {
const n = nearestEndpoint.value
// 若靠近端点,且不是与 pendingEndpoint 相同的端点,则吸附到该端点;否则跟随鼠标
if (n && !(pendingEndpoint && n.instId === pendingEndpoint.instId && n.cpIndex === pendingEndpoint.cpIndex)) {
const inst = getInstance(n.instId)!
return getConnectionPointWorldPos(inst, n.cpIndex)
}
return { x: mouseWorld.x, y: mouseWorld.y }
})
function isNearest(instId: string, cpIndex: number) {
const n = nearestEndpoint.value
return !!n && n.instId === instId && n.cpIndex === cpIndex
}
// 悬停端点与其连通集高亮
const hoveredEndpoint = ref<EndpointRef | null>(null)
const connectedEndpointSet = computed<Set<string>>(() => {
const set = new Set<string>()
const start = hoveredEndpoint.value
if (!start) return set
// 构建无向邻接表(仅基于导线)
const key = (ep: EndpointRef) => `${ep.instId}:${ep.cpIndex}`
const adj = new Map<string, string[]>()
const add = (a: EndpointRef, b: EndpointRef) => {
const ka = key(a), kb = key(b)
if (!adj.has(ka)) adj.set(ka, [])
if (!adj.has(kb)) adj.set(kb, [])
adj.get(ka)!.push(kb)
adj.get(kb)!.push(ka)
}
for (const w of wires) add(w.a, w.b)
// BFS/DFS 查找连通端点
const startKey = key(start)
const stack = [startKey]
set.add(startKey)
while (stack.length) {
const cur = stack.pop()!
for (const nxt of adj.get(cur) || []) {
if (!set.has(nxt)) { set.add(nxt); stack.push(nxt) }
}
}
return set
})
function isConnectedEndpoint(instId: string, cpIndex: number) {
return connectedEndpointSet.value.has(`${instId}:${cpIndex}`)
}
// 7.5) 直流电路求解MNA
// - 支持:电压源(battery)、电阻(resistor/light_bulb/meter)、开关(ON=0V源OFF=断开)、电感(DC=0V源)、电容(DC=断开)
// - 结果每个实例的端口电压差与电流方向从连接点0 -> 连接点1 为正)
type InstLive = {
v: number | null // 端口电压 v = V(p0) - V(p1) 或电压源设定值
i: number | null // 支路电流,正方向 p0->p1
node0?: string
node1?: string
}
const instLive = reactive<Record<string, InstLive>>({})
function getElemMeta(inst: Instance) {
return elements.find(e => e.key === inst.key)
}
// 针对滑动变阻器计算滑块的屏幕内偏移未考虑旋转先期简化为只支持0°/180°朝向的水平滑动
function getRheostatPinOffset(inst: Instance) {
const pos = Math.max(0, Math.min(1, Number(inst.props?.['position'] ?? 0.5)))
const leftFrac = 0.25, rightFrac = 0.75
const cx = (leftFrac + (rightFrac - leftFrac) * pos) * inst.size
return cx
}
// 并查集用于将端点连成节点
class DSU {
parent = new Map<string, string>()
find(x: string): string {
let p = this.parent.get(x) || x
if (p !== x) {
p = this.find(p)
this.parent.set(x, p)
} else {
this.parent.set(x, x)
}
return p
}
union(a: string, b: string) {
const ra = this.find(a)
const rb = this.find(b)
if (ra !== rb) this.parent.set(ra, rb)
}
}
function safeNum(v: any, def = 0) {
const n = typeof v === 'number' ? v : Number(v)
return Number.isFinite(n) ? n : def
}
type BuiltNet = {
nodeIds: string[] // 所有节点根ID
ground: string | null
// 器件分类
resistors: { inst: Instance; nPlus: string; nMinus: string; R: number }[]
vsrcs: { inst: Instance; nPlus: string; nMinus: string; V: number }[]
}
function buildNet(): BuiltNet {
const dsu = new DSU()
const termId = (instId: string, cp: number) => `${instId}:${cp}`
// 先把显式导线合并
for (const w of wires) {
dsu.union(termId(w.a.instId, w.a.cpIndex), termId(w.b.instId, w.b.cpIndex))
}
// 将“短路类器件”作为0V电压源开关ON、DC电感
// OFF开关/电容视为断开,不合并
// 收集所有端点以便形成节点清单
const allTerms: string[] = []
for (const inst of instances) {
const meta = getElemMeta(inst)
const cps = meta?.connectionPoints?.length || 0
for (let i = 0; i < cps; i++) allTerms.push(termId(inst.id, i))
}
// 形成节点集合
const roots = new Set<string>()
for (const t of allTerms) roots.add(dsu.find(t))
// 仅基于真实端点选择参考地
const nodeIds = Array.from(roots)
const ground = nodeIds.length ? nodeIds[0] : null // 任取一个参考地
// 为后续引入的“内部节点”(例如串联内阻的中间点)保留集合,避免重复
const nodeSet = new Set(nodeIds)
const addNode = (nid: string) => {
if (!nodeSet.has(nid)) { nodeSet.add(nid); nodeIds.push(nid) }
}
// 分类器件
const resistors: BuiltNet['resistors'] = []
const vsrcs: BuiltNet['vsrcs'] = []
const getNode = (inst: Instance, cp: number) => dsu.find(termId(inst.id, cp))
for (const inst of instances) {
const meta = getElemMeta(inst)
if (!meta || meta.connectionPoints.length < 2) continue
const n0 = getNode(inst, 0)
const n1 = getNode(inst, 1)
const key = meta.key
if (key === 'battery') {
const V = safeNum(inst.props?.['voltage'], 0)
const Rint = safeNum(inst.props?.['internalResistance'], 0.1)
// 用内部节点将电压源与内阻串联n0 --[V]--> nInt --[Rint]--> n1
const nInt = `${inst.id}:int`
addNode(nInt)
vsrcs.push({ inst, nPlus: n0, nMinus: nInt, V })
resistors.push({ inst, nPlus: nInt, nMinus: n1, R: Math.max(1e-9, Rint) })
} else if (key === 'power_supply') {
const V = safeNum(inst.props?.['voltage'], 0)
const Rint = safeNum(inst.props?.['internalResistance'], 0.1)
// 用内部节点将电压源与内阻串联n0 --[V]--> nInt --[Rint]--> n1
const nInt = `${inst.id}:int`
addNode(nInt)
vsrcs.push({ inst, nPlus: n0, nMinus: nInt, V })
resistors.push({ inst, nPlus: nInt, nMinus: n1, R: Math.max(1e-9, Rint) })
} else if (key === 'switch') {
const sw = (inst.props?.['state'] as any) === 'on' || inst.state === 'on'
if (sw) {
vsrcs.push({ inst, nPlus: n0, nMinus: n1, V: 0 })
}
// off = open, 忽略
} else if (key === 'inductor') {
// DC 等效 0V 源
vsrcs.push({ inst, nPlus: n0, nMinus: n1, V: 0 })
} else if (key === 'capacitor') {
// DC 断开
// 忽略
} else if (key === 'sliding_rheostat') {
// 四脚A(0) B(1) C(2) D(3)
const n2 = getNode(inst, 2)
const n3 = getNode(inst, 3)
// AB 直通:用 0V 电压源建模
vsrcs.push({ inst, nPlus: n0, nMinus: n1, V: 0 })
// 变量电阻AB->C、AB->D 与滑块位置有关
const Rmax = Math.max(1e-6, safeNum(inst.props?.['maxResistance'], 100))
const pos = Math.max(0, Math.min(1, safeNum(inst.props?.['position'], 0.5)))
const Rac = Math.max(1e-6, Rmax * pos)
const Rad = Math.max(1e-6, Rmax * (1 - pos))
// 将 AB 汇成一个等效节点(任选 A 侧 n0到 C/D
resistors.push({ inst, nPlus: n0, nMinus: n2, R: Rac })
resistors.push({ inst, nPlus: n0, nMinus: n3, R: Rad })
// CD 固定为最大电阻
resistors.push({ inst, nPlus: n2, nMinus: n3, R: Rmax })
} else if (key === 'resistor' || key === 'light_bulb' || key === 'meter' || key === 'resistance_box') {
const R = Math.max(1e-6, safeNum(inst.props?.['resistance'], 0))
resistors.push({ inst, nPlus: n0, nMinus: n1, R })
} else {
// 其他未知器件,按开路处理
}
}
return { nodeIds, ground, resistors, vsrcs }
}
function solveMNA() {
const net = buildNet()
// 清空上一次结果
for (const inst of instances) instLive[inst.id] = { v: null, i: null }
if (!net.ground) return { ok: false }
// 建索引(去掉参考地)
const nodeIndex = new Map<string, number>()
let idx = 0
for (const nid of net.nodeIds) {
if (nid === net.ground) continue
nodeIndex.set(nid, idx++)
}
const n = nodeIndex.size
const m = net.vsrcs.length
const N = n + m
if (N === 0) return { ok: true }
// 构造 A x = z
const A: number[][] = Array.from({ length: N }, () => Array(N).fill(0))
const z: number[] = Array(N).fill(0)
const gmin = 1e-12
const mapNi = (nid: string) => (nid === net.ground ? -1 : (nodeIndex.get(nid) ?? -1))
// 电导矩阵 G顶部 n×n
for (const r of net.resistors) {
const a = mapNi(r.nPlus)
const b = mapNi(r.nMinus)
const g = 1 / r.R
if (a >= 0) A[a][a] += g + gmin
if (b >= 0) A[b][b] += g + gmin
if (a >= 0 && b >= 0) {
A[a][b] -= g
A[b][a] -= g
}
}
// 稳定性:对所有节点加 gmin 到地
for (let i = 0; i < n; i++) A[i][i] += gmin
// 电压源耦合矩阵 B、C
// B: (n×m),位于 A 的右上C = B^Tm×n位于 A 的左下
net.vsrcs.forEach((vs, k) => {
const p = mapNi(vs.nPlus)
const q = mapNi(vs.nMinus)
const row = n + k // 对应电流未知量的位置
z[row] = vs.V
if (p >= 0) { A[p][n + k] += 1; A[row][p] += 1 }
if (q >= 0) { A[q][n + k] -= 1; A[row][q] -= 1 }
// D 为 0已初始化
})
// 求解线性方程
const x = gaussianSolve(A, z)
if (!x) return { ok: false }
// 组装节点电压
const Vn = (nid: string) => {
const i = mapNi(nid)
return i >= 0 ? x[i] : 0 // 地节点 0V
}
// 写回每个器件的 v / i
for (const r of net.resistors) {
const vp = Vn(r.nPlus)
const vn = Vn(r.nMinus)
const v = vp - vn
const i = v / r.R
instLive[r.inst.id] = { v, i, node0: r.nPlus, node1: r.nMinus }
}
net.vsrcs.forEach((vs, k) => {
const vp = Vn(vs.nPlus)
const vn = Vn(vs.nMinus)
const v = vp - vn // 由解出的节点电压
const i = x[n + k] // 电压源支路电流,方向 nPlus -> nMinus
instLive[vs.inst.id] = { v, i, node0: vs.nPlus, node1: vs.nMinus }
})
// 对于未分类(断开)器件,保持 null
// 根据计算电流,自动切换需要按电流显示状态的元件(如灯泡)
const CURRENT_ON_THRESHOLD = 0.1 // A即 100 mA
for (const inst of instances) {
if (inst.key === 'light_bulb') {
const live = instLive[inst.id]
const iabs = live && typeof live.i === 'number' ? Math.abs(live.i) : 0
if (iabs > CURRENT_ON_THRESHOLD) inst.state = 'on'
else inst.state = 'off'
}
}
return { ok: true }
}
// 简单高斯消元(带部分选主元)
function gaussianSolve(A: number[][], b: number[]): number[] | null {
const n = b.length
// 扩展矩阵
for (let i = 0; i < n; i++) A[i].push(b[i])
for (let col = 0, row = 0; col < n && row < n; col++, row++) {
// 选主元
let piv = row
let maxAbs = Math.abs(A[row][col])
for (let r = row + 1; r < n; r++) {
const v = Math.abs(A[r][col])
if (v > maxAbs) { maxAbs = v; piv = r }
}
if (maxAbs < 1e-14) {
// 退化,放弃
return null
}
if (piv !== row) { const tmp = A[piv]; A[piv] = A[row]; A[row] = tmp }
// 归一化
const div = A[row][col]
for (let c = col; c <= n; c++) A[row][c] /= div
// 消元
for (let r = 0; r < n; r++) {
if (r === row) continue
const factor = A[r][col]
if (factor === 0) continue
for (let c = col; c <= n; c++) A[r][c] -= factor * A[row][c]
}
}
// 回读解
const x = new Array(n).fill(0)
for (let i = 0; i < n; i++) x[i] = A[i][n]
return x
}
// 绑定求解到响应式数据
watchEffect(() => { solveMNA() })
function meterText(inst: Instance) {
const live = instLive[inst.id]
const i = live?.i ?? 0
const r = inst.props?.['resistance'] ? safeNum(inst.props?.['resistance'], 0) : 0
const code = String(inst.props?.['renderFunc'] || '')
if (!code) return `${(i).toFixed(6)} A`
try {
// 允许两种形态:"(i)=>..." 或 "(i,v,props)=>..."
const fn = new Function('i', 'r', 'props', 'return (' + code + ')(i, r, props)')
const out = fn(i, r, inst.props)
return String(out)
} catch {
return `ERR_RF`
}
}
// 灯泡发光参数:随电流变化增加线条数量、长度、透明度
function clamp01(x: number) { return x < 0 ? 0 : x > 1 ? 1 : x }
function getBulbGlow(inst: Instance) {
if (inst.key !== 'light_bulb') return { t: 0, count: 0, opacity: 0, lenFrac: 0 }
const live = instLive[inst.id]
const iabs = live && typeof live.i === 'number' ? Math.abs(live.i) : 0
// 使 0.5A 达到比较饱和的光效0A 无光
const t = clamp01(iabs / 0.5)
const count = 12 + Math.round(8 * t) // 12..20 根射线
const opacity = 0.8 * t // 0..0.8
const lenFrac = 0.18 + 0.45 * t // 以尺寸为基准的长度比例
return { t, count, opacity, lenFrac }
}
// 电阻色环:根据实时电阻值绘制 4 环(两位有效数字 + 倍率 + 5% 容差金色)
const DIGIT_COLORS = ['#000000', '#8B4513', '#FF0000', '#FFA500', '#FFFF00', '#008000', '#0000FF', '#800080', '#808080', '#FFFFFF']
const GOLD = '#D4AF37'
const SILVER = '#C0C0C0'
function computeResistorColorBands(ohms: number): string[] {
// 4 环D1, D2, multiplier, tolerance(默认金色)
if (!Number.isFinite(ohms)) ohms = 0
const R = Math.max(0, ohms)
// 特殊0Ω 跳线,显示单一黑环加固定金色作为占位
if (R === 0) return ['#000000', '#000000', '#000000', GOLD]
// 将 R 逼近到 (10..99) × 10^k 的形式,以两位有效数字表示
let exp = Math.floor(Math.log10(R)) - 1
// 处理非常小的阻值(<0.01Ω)和非常大的阻值,限制到可表示范围
if (!Number.isFinite(exp)) exp = 0
let N = R / Math.pow(10, exp)
// 目标区间为 [10, 99]
let D = Math.round(N)
if (D < 10) {
// 提升到两位
while (D < 10) { D *= 10; exp -= 1 }
} else if (D > 99) {
// 回落并进位倍率
while (D > 99) { D = Math.round(D / 10); exp += 1 }
}
// 提取两位数字
D = Math.max(10, Math.min(99, D))
let d1 = Math.floor(D / 10)
let d2 = D % 10
// 倍率色环支持指数 -2..9-1 金、-2 银;其余 0..9 用数字色)
let mulColor: string
if (exp <= -2) mulColor = SILVER
else if (exp === -1) mulColor = GOLD
else mulColor = DIGIT_COLORS[Math.max(0, Math.min(9, exp))]
const c1 = DIGIT_COLORS[d1]
const c2 = DIGIT_COLORS[d2]
const tol = GOLD // 默认 5%
return [c1, c2, mulColor, tol]
}
function getResistorBandColors(inst: Instance): string[] {
if (inst.key !== 'resistor') return []
const R = safeNum(inst.props?.['resistance'], 0)
return computeResistorColorBands(R)
}
// 色环在图片中的相对位置(经验位置,适配当前素材)
const RES_BAND_X_FRACS = [0.4, 0.4666, 0.5334, 0.6]
const RES_BAND_WIDTH_FRAC = 0.03
const RES_BAND_TOP_FRAC = 0.432
const RES_BAND_HEIGHT_FRAC = 0.13
function getResBandX(inst: Instance, index: number) {
const cx = inst.size * (RES_BAND_X_FRACS[index] || 0.3)
const w = inst.size * RES_BAND_WIDTH_FRAC
return cx - w / 2
}
// 8) 初始居中到世界中心
function centerWorld() {
const vp = viewportRef.value
if (!vp) return
const rect = vp.getBoundingClientRect()
const center = state.worldSize / 2
state.translateX = rect.width / 2 - center * state.scale
state.translateY = rect.height / 2 - center * state.scale
}
onMounted(() => {
centerWorld()
// 检测从详情页跳转携带的导入数据
try {
const txt = sessionStorage.getItem('cvln_import_json')
if (txt) {
sessionStorage.removeItem('cvln_import_json')
loadFromJSONText(txt)
}
} catch { }
})
// 9) 导出 / 导入JSON 序列化)
type SaveWorld = Pick<typeof state, 'scale' | 'translateX' | 'translateY' | 'worldSize' | 'gridSize'>
// 新的连接点保存结构:每个实例在 props 下包含 __connections按连接点索引记录连接到的端点
type SaveConnectionRef = { instId: string; cpIndex: number }
type SaveConnections = Record<string /* cpIndex */, SaveConnectionRef[]>
type SaveInstance = Pick<Instance, 'id' | 'key' | 'x' | 'y' | 'size' | 'rotation' | 'state'> & {
props: Record<string, any> & { __connections?: SaveConnections }
}
type SaveFile = {
version: 3
world: SaveWorld
instances: SaveInstance[]
}
function makeSave(): SaveFile {
const world: SaveWorld = {
scale: state.scale,
translateX: state.translateX,
translateY: state.translateY,
worldSize: state.worldSize,
gridSize: state.gridSize,
}
// 基于当前 wires 构建每个实例的端点连接表
const connMap: Record<string, Record<number, SaveConnectionRef[]>> = {}
const addConn = (from: EndpointRef, to: EndpointRef) => {
if (!connMap[from.instId]) connMap[from.instId] = {}
if (!connMap[from.instId][from.cpIndex]) connMap[from.instId][from.cpIndex] = []
connMap[from.instId][from.cpIndex].push({ instId: to.instId, cpIndex: to.cpIndex })
}
for (const w of wires) {
addConn(w.a, w.b)
addConn(w.b, w.a)
}
const data: SaveFile = {
version: 3,
world,
instances: instances.map(i => {
const propsCopy: Record<string, any> = { ...i.props }
const m = connMap[i.id]
// 始终写入 __connections即使为空对象确保未连接元件也被保留
// 将 number 索引键转成字符串键,以便 JSON 键稳定
const mapped: SaveConnections = {}
if (m) {
for (const [k, arr] of Object.entries(m)) mapped[String(k)] = arr.map(x => ({ instId: x.instId, cpIndex: x.cpIndex }))
}
propsCopy.__connections = mapped
return {
id: i.id,
key: i.key,
x: i.x,
y: i.y,
size: i.size,
rotation: i.rotation,
props: propsCopy,
state: i.state,
}
}),
}
return data
}
function downloadJSON(filename: string, text: string) {
const blob = new Blob([text], { type: 'application/json;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
function pad(n: number) { return n.toString().padStart(2, '0') }
function timestampName() {
const d = new Date()
const y = d.getFullYear()
const m = pad(d.getMonth() + 1)
const day = pad(d.getDate())
const hh = pad(d.getHours())
const mm = pad(d.getMinutes())
const ss = pad(d.getSeconds())
return `${y}${m}${day}-${hh}${mm}${ss}`
}
function onExportJSON() {
const data = makeSave()
const json = JSON.stringify(data, null, 2)
downloadJSON(`circuit-${timestampName()}.json`, json)
}
const importInputRef = ref<HTMLInputElement | null>(null)
function triggerImport() { importInputRef.value?.click() }
function safeNumber(v: any, def = 0) {
const n = typeof v === 'number' ? v : Number(v)
return Number.isFinite(n) ? n : def
}
function loadFromJSONText(text: string) {
let obj: any
try {
obj = JSON.parse(text)
} catch (err) {
alert('JSON 解析失败:' + (err as Error).message)
return
}
if (!obj || typeof obj !== 'object') {
alert('无效的文件内容')
return
}
// 兼容校验
const world = obj.world || {}
const rawInstances = Array.isArray(obj.instances) ? obj.instances : []
// 过滤仅保留存在于库里的元件
const knownKeys = new Set(elements.map(e => e.key))
const validInstances: Instance[] = []
for (const r of rawInstances) {
if (!r || typeof r !== 'object') continue
if (!knownKeys.has(r.key)) continue
// 提前拿到连接信息(若为新格式)
const rawProps = (typeof r.props === 'object' && r.props) ? { ...r.props } : {}
const savedConnections: SaveConnections | undefined = rawProps.__connections
if ('__connections' in rawProps) delete (rawProps as any)['__connections']
// 兼容:若缺少通用隐藏属性开关,补上默认值
if (rawProps['hideProps'] === undefined) rawProps['hideProps'] = 'off'
const inst: Instance = {
id: String(r.id ?? (++idSeq)),
key: String(r.key),
url: elements.find(e => e.key === r.key)!.url,
x: safeNumber(r.x),
y: safeNumber(r.y),
size: safeNumber(r.size, elements.find(e => e.key === r.key)?.defaultSize ?? 64),
rotation: safeNumber(r.rotation, 0),
props: rawProps,
state: r.state === 'on' || r.state === 'off' ? r.state : undefined,
}
// 兼容老文件:若为开关且 props.state 缺失,则使用 legacy state 或默认 off
if (inst.key === 'switch') {
const cur = String((inst.props as any)['state'] ?? '')
if (cur !== 'on' && cur !== 'off') {
(inst.props as any)['state'] = (inst.state === 'on' ? 'on' : 'off')
}
}
// 暂存连接信息在实例对象上(不入 props以便后续重建导线
; (inst as any).__savedConnections = savedConnections
validInstances.push(inst)
}
// 基于实例与元件接线点数量校验导线
const instMap = new Map(validInstances.map(i => [i.id, i]))
const validWires: Wire[] = []
// 从每个实例 props.__connections 重建导线(去重)
const dedup = new Set<string>()
const getKey = (a: EndpointRef, b: EndpointRef) => {
const k1 = `${a.instId}:${a.cpIndex}`
const k2 = `${b.instId}:${b.cpIndex}`
return k1 < k2 ? `${k1}|${k2}` : `${k2}|${k1}`
}
for (const inst of validInstances) {
const conns: SaveConnections | undefined = (inst as any).__savedConnections
if (!conns) continue
const elem = elements.find(e => e.key === inst.key)
const cps = elem?.connectionPoints?.length ?? 0
for (const [cpStr, arr] of Object.entries(conns)) {
const cpi = safeNumber(cpStr)
if (cpi < 0 || cpi >= cps) continue
for (const ref of arr || []) {
const tgt = instMap.get(String(ref.instId))
if (!tgt) continue
const elemB = elements.find(e => e.key === tgt.key)
const cpsB = elemB?.connectionPoints?.length ?? 0
const cpb = safeNumber(ref.cpIndex)
if (cpb < 0 || cpb >= cpsB) continue
const a: EndpointRef = { instId: inst.id, cpIndex: cpi }
const b: EndpointRef = { instId: String(ref.instId), cpIndex: cpb }
const key = getKey(a, b)
if (dedup.has(key)) continue
dedup.add(key)
validWires.push({ id: String(++wireSeq), a, b })
}
}
}
// 写回世界与状态
state.scale = safeNumber(world.scale, state.scale)
state.translateX = safeNumber(world.translateX, state.translateX)
state.translateY = safeNumber(world.translateY, state.translateY)
// worldSize/gridSize 通常固定,如需覆盖可解开注释
// state.worldSize = safeNumber(world.worldSize, state.worldSize)
// state.gridSize = safeNumber(world.gridSize, state.gridSize)
// 覆盖当前电路
instances.splice(0, instances.length, ...validInstances)
wires.splice(0, wires.length, ...validWires)
selectedId.value = null
pendingEndpoint = null
// 续增 id 序列
const maxInstId = validInstances.reduce((m, i) => Math.max(m, Number(i.id) || 0), 0)
const maxWireId = validWires.reduce((m, w) => Math.max(m, Number(w.id) || 0), 0)
idSeq = Math.max(idSeq, maxInstId)
wireSeq = Math.max(wireSeq, maxWireId)
}
function onImportFileChange(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files && input.files[0]
if (!file) return
const reader = new FileReader()
reader.onload = () => {
const text = String(reader.result || '')
loadFromJSONText(text)
}
reader.onerror = () => alert('读取文件失败')
reader.readAsText(file, 'utf-8')
// 重置以便可重复选择同一文件
input.value = ''
}
// 提供给 AI 助手的实时电路状态(每次访问都会序列化最新数据)
const aiState = computed(() => makeSave())
function onAiError(e: string) {
// 采用函数方式,避免模板类型检查对全局 alert 的报错
alert(e)
}
// 删除当前选中的元件以及所有关联导线
function deleteSelected() {
const inst = selectedInst.value
if (!inst) return
// 删除连接到该实例的所有导线
for (let i = wires.length - 1; i >= 0; i--) {
const w = wires[i]
if (w.a.instId === inst.id || w.b.instId === inst.id) wires.splice(i, 1)
}
// 如果正在连线且端点来自该实例,取消预连
if (pendingEndpoint && pendingEndpoint.instId === inst.id) pendingEndpoint = null
// 如果正在拖拽该实例,停止拖拽
if (draggingId === inst.id) draggingId = null
// 删除实例本身
const idx = instances.findIndex(i => i.id === inst.id)
if (idx >= 0) instances.splice(idx, 1)
// 清理实时状态与选中
delete instLive[inst.id]
selectedId.value = null
}
// 文本框编辑函数
function editTextBox(inst: Instance) {
// 仅处理文本框
if (inst.key !== 'text_box') return
const cur = String(inst.props?.['text'] ?? '')
const next = globalThis.prompt ? globalThis.prompt('编辑文本', cur) : null
if (next !== null) inst.props['text'] = next
}
</script>
<template>
<div class="app" :style="{ '--ai-width': showAI ? '360px' : '0px' }"
:class="{ 'preview-mode': isPreview }">
<!-- 左侧元件面板 -->
<aside class="sidebar" v-if="!isPreview">
<RouterLink class="back-to-home" to="/">
<button class="btn">回到大厅</button>
</RouterLink>
<h2>元件</h2>
<div class="palette">
<div v-for="item in palette" :key="item.key" class="palette-item"
draggable="true" @dragstart="(e) => onPaletteDragStart(e, item)"
:title="item.name">
<img :src="item.url" :alt="item.name" draggable="false" />
<span class="label">{{ item.name }}</span>
</div>
</div>
<div class="file-actions">
<button class="btn" @click="onExportJSON">导出 JSON</button>
<button class="btn" @click="triggerImport">导入 JSON</button>
<!-- 上传模型到模型广场需登录 -->
<UploadButton :makeSave="makeSave" />
<input ref="importInputRef" type="file" accept="application/json,.json"
class="hidden-file" @change="onImportFileChange" />
</div>
</aside>
<!-- 画布视口可平移缩放接收拖拽 -->
<main class="viewport" ref="viewportRef" @pointerdown="onViewportMouseDown"
@pointermove="(e) => { onViewportMouseMove(e); onViewportMouseMoveDrag(e); onViewportMouseMoveTrack(e); onViewportMouseMoveSlider(e) }"
@pointerup="() => { onViewportMouseUp(); onViewportMouseUpDrag(); onViewportMouseUpSlider() }"
@pointerleave="() => { onViewportMouseUp(); onViewportMouseUpDrag(); onViewportMouseUpSlider() }"
@wheel="onWheel" @dragover="onCanvasDragOver" @drop="onCanvasDrop"
@click="clearSelection">
<!-- 世界平面网格背景应用 transform -->
<div class="world" :style="worldStyle" ref="worldRef">
<!-- 导线SVG 覆盖层 -->
<svg class="wires" :width="state.worldSize" :height="state.worldSize"
@mousemove="onViewportMouseMoveTrack">
<g>
<g v-for="w in wires" :key="w.id" class="wire-group"
@click.stop="(e) => removeWire(w.id, e)">
<line class="wire-base"
:x1="getConnectionPointWorldPos(getInstance(w.a.instId)!, w.a.cpIndex).x"
:y1="getConnectionPointWorldPos(getInstance(w.a.instId)!, w.a.cpIndex).y"
:x2="getConnectionPointWorldPos(getInstance(w.b.instId)!, w.b.cpIndex).x"
:y2="getConnectionPointWorldPos(getInstance(w.b.instId)!, w.b.cpIndex).y"
stroke-linecap="round" vector-effect="non-scaling-stroke" />
</g>
<!-- 预览线:当选中一个端点时,显示到鼠标或吸附端点的线 -->
<line v-if="pendingEndpoint"
:x1="getConnectionPointWorldPos(getInstance(pendingEndpoint.instId)!, pendingEndpoint.cpIndex).x"
:y1="getConnectionPointWorldPos(getInstance(pendingEndpoint.instId)!, pendingEndpoint.cpIndex).y"
:x2="previewTarget.x" :y2="previewTarget.y"
class="wire preview" />
</g>
</svg>
<!-- 已放置的元件(可拖动) -->
<div v-for="inst in instances" :key="inst.id" class="inst"
:class="{ selected: selectedId === inst.id }"
:style="{ left: inst.x + 'px', top: inst.y + 'px', transform: 'translate(-50%, -50%) rotate(' + (inst.rotation || 0) + 'deg)' }"
@pointerdown="(e) => onInstanceMouseDown(e, inst)"
@click.stop="() => selectInstance(inst.id)"
:title="(elements.find(e => e.key === inst.key)?.name || inst.key)">
<div class="inst-box"
:style="{ width: inst.size > 0 ? inst.size + 'px' : 'auto', height: inst.size > 0 ? inst.size + 'px' : 'auto' }">
<img v-if="inst.key !== 'text_box'" :src="(elements.find(e => e.key === inst.key)?.stateImages
? (((inst.key === 'switch' ? (inst.props['state'] as string) : inst.state) === 'on')
? elements.find(e => e.key === inst.key)!.stateImages!.on
: elements.find(e => e.key === inst.key)!.stateImages!.off)
: inst.url)" :alt="inst.key" :draggable="false"
@contextmenu.prevent="() => {
const meta = elements.find(e => e.key === inst.key);
if (!meta?.stateImages) return;
if (inst.key === 'switch') {
const cur = String(inst.props['state'] ?? 'off');
inst.props['state'] = cur === 'on' ? 'off' : 'on'
} else {
inst.state = inst.state === 'on' ? 'off' : 'on'
}
}" />
<!-- 文本框渲染层:不参与电路,仅显示可编辑文本 -->
<div v-if="inst.key === 'text_box'" class="text-box" :style="{
fontSize: (Number(inst.props?.['fontSize'] ?? 24)) + 'px',
color: String(inst.props?.['color'] ?? '#111827'),
}" @dblclick.stop="editTextBox(inst)" title="双击编辑文本">
{{ String(inst.props?.['text'] ?? '') }}
</div>
<!-- 电阻色环叠加层:根据实时电阻值渲染 4 个色带 -->
<template v-if="inst.key === 'resistor'">
<div v-for="(c, bi) in getResistorBandColors(inst)" :key="bi"
class="res-band" :style="{
left: getResBandX(inst, bi) + 'px',
top: (inst.size * RES_BAND_TOP_FRAC) + 'px',
width: (inst.size * RES_BAND_WIDTH_FRAC) + 'px',
height: (inst.size * RES_BAND_HEIGHT_FRAC) + 'px',
backgroundColor: c
}"></div>
</template>
<!-- 滑动变阻器滑块 -->
<img v-if="inst.key === 'sliding_rheostat'" class="rheo-pin"
:src="(elements.find(e => e.key === inst.key) as any)?.pinUrl"
alt="pin" draggable="false" :style="{
left: ((getRheostatPinOffset(inst) / inst.size) * 100) + '%',
}" @pointerdown.stop="() => { sliderDraggingId = inst.id }" />
<!-- 灯泡发散光线叠加层 -->
<svg v-if="inst.key === 'light_bulb'" class="bulb-glow"
:width="inst.size" :height="inst.size"
:viewBox="'0 0 ' + inst.size + ' ' + inst.size"
aria-hidden="true">
<g :opacity="getBulbGlow(inst).opacity">
<line v-for="k in getBulbGlow(inst).count" :key="k"
:x1="(inst.size / 2) + Math.cos(2 * Math.PI * (k - 1) / getBulbGlow(inst).count) * (inst.size * 0.42)"
:y1="(inst.size / 2) + Math.sin(2 * Math.PI * (k - 1) / getBulbGlow(inst).count) * (inst.size * 0.42)"
:x2="(inst.size / 2) + Math.cos(2 * Math.PI * (k - 1) / getBulbGlow(inst).count) * (inst.size * (0.42 + getBulbGlow(inst).lenFrac))"
:y2="(inst.size / 2) + Math.sin(2 * Math.PI * (k - 1) / getBulbGlow(inst).count) * (inst.size * (0.42 + getBulbGlow(inst).lenFrac))"
stroke="#f59e0b" stroke-width="2" stroke-linecap="round"
vector-effect="non-scaling-stroke" />
</g>
</svg>
<!-- meter 显示层 -->
<div v-if="inst.key === 'meter'" class="meter-label">
{{ meterText(inst) }}
</div>
<div v-if="inst.key === 'power_supply'" class="power-supply-label">
<span>{{ instLive[inst.id]?.v?.toFixed(2) }} V</span>
<span>{{ (-(instLive[inst.id]?.i ?? 0)).toFixed(2) }} A</span>
</div>
</div>
</div>
<!-- 连接点(显示为虚线圆圈) -->
<div v-for="inst in instances" :key="inst.id + '-cps'">
<template
v-for="(cp, idx) in (elements.find(e => e.key === inst.key)?.connectionPoints || [])"
:key="inst.id + '-' + idx">
<div class="endpoint" :class="{
active: pendingEndpoint && pendingEndpoint.instId === inst.id && pendingEndpoint.cpIndex === idx,
hover: isNearest(inst.id, idx),
connected: hoveredEndpoint && isConnectedEndpoint(inst.id, idx)
}" :style="{
left: getConnectionPointWorldPos(inst, idx).x + 'px',
top: getConnectionPointWorldPos(inst, idx).y + 'px'
}" @click.stop="(e) => onEndpointClick(inst.id, idx, e)"
@pointerenter="() => { hoveredEndpoint = { instId: inst.id, cpIndex: idx } }"
@pointerleave="() => { if (hoveredEndpoint && hoveredEndpoint.instId === inst.id && hoveredEndpoint.cpIndex === idx) hoveredEndpoint = null }"
:title="cp.name || ('P' + (idx + 1))"></div>
</template>
</div>
</div>
</main>
<!-- 右侧 AI 助手面板 -->
<aside v-if="!isPreview && showAI" class="ai-aside">
<AIAssistant :state="aiState" @error="onAiError"
@close="showAI = false" />
</aside>
<button v-else-if="!isPreview" class="ai-reopen" title="打开 AI 助手"
@click="showAI = true">◀</button>
<!-- 底部属性面板:编辑选中元件的业务属性(非尺寸) -->
<footer v-if="selectedInst" class="bottom-panel"
:style="{ right: showAI ? '360px' : '0' }">
<div class="panel-inner">
<div class="prop-title">{{ selectedInst.key }} 属性</div>
<!-- 预设按钮区域:显示当前元件的预设,点击一键应用(当未隐藏属性时) -->
<div
v-if="selectedMeta?.preset?.length && String(selectedInst!.props['hideProps'] ?? 'off') !== 'on'"
class="presets">
<span class="preset-label">预设</span>
<button v-for="p in selectedMeta!.preset" :key="p.name"
class="preset-btn" @click.stop="applyPreset(p)">{{
p.name
}}</button>
</div>
<div class="props">
<!-- 实时电学量(在隐藏属性=on时不显示 -->
<label class="prop-row"
v-if="String(selectedInst!.props['hideProps'] ?? 'off') !== 'on'">
<span class="label">电压</span>
<span>{{ formatValue(instLive[selectedInst!.id]?.v ?? null, 'V')
}}</span>
</label>
<label class="prop-row"
v-if="String(selectedInst!.props['hideProps'] ?? 'off') !== 'on'">
<span class="label">电流</span>
<span>{{ formatValue(instLive[selectedInst!.id]?.i ?? null, 'A')
}}</span>
</label>
<!-- 旋转控制(在隐藏属性=on时不显示 -->
<label class="prop-row"
v-if="String(selectedInst!.props['hideProps'] ?? 'off') !== 'on'">
<span class="label">旋转</span>
<input type="range" min="-180" max="180" step="1"
v-model.number="selectedInst!.rotation" />
<input type="number" class="deg-input" step="1"
v-model.number="selectedInst!.rotation" />
<span class="unit">deg</span>
<button class="rot-btn"
@click="selectedInst!.rotation = ((selectedInst!.rotation || 0) - 45 + 360) % 360">-45°</button>
<button class="rot-btn"
@click="selectedInst!.rotation = ((selectedInst!.rotation || 0) + 45) % 360">+45°</button>
</label>
<!-- 通用:隐藏属性开关(始终显示) -->
<label class="prop-row">
<span class="label">隐藏属性</span>
<select v-model="selectedInst!.props['hideProps'] as string">
<option value="off">off</option>
<option value="on">on</option>
</select>
</label>
<!-- 业务属性编辑:当未隐藏属性时显示 -->
<template
v-if="String(selectedInst!.props['hideProps'] ?? 'off') !== 'on'"
v-for="schema in (elements.find(e => e.key === selectedInst!.key)?.propertySchemas || [])"
:key="schema.key">
<label class="prop-row" v-if="schema.key !== 'hideProps'">
<span class="label">{{ schema.label }}</span>
<input v-if="schema.type === 'number'" type="number" step="any"
v-model.number="selectedInst!.props[schema.key]" />
<input v-else-if="schema.type === 'text'" type="text"
v-model="selectedInst!.props[schema.key] as string" />
<input v-else-if="schema.type === 'color'" type="color"
v-model="selectedInst!.props[schema.key] as string" />
<select v-else-if="schema.type === 'select'"
v-model="selectedInst!.props[schema.key] as string">
<option
v-for="opt in (Array.isArray((schema as any).options) ? (schema as any).options : [])"
:key="typeof opt === 'string' ? opt : (opt.value)"
:value="typeof opt === 'string' ? opt : (opt.value)">
{{ typeof opt === 'string' ? opt : (opt.label || opt.value) }}
</option>
</select>
<span class="unit"
v-if="schema.type === 'number' && (schema as any).unit">{{
(schema as any).unit }}</span>
</label>
</template>
</div>
<!-- 右侧操作区:删除按钮 -->
<div class="panel-actions">
<button class="danger-btn" title="删除此元件及其所有连接的导线"
@click="deleteSelected">删除元件</button>
</div>
</div>
</footer>
</div>
</template>
<style scoped>
.app {
display: grid;
grid-template-columns: 140px 1fr var(--ai-width, 360px);
/* 左栏 / 画布 / AI 辅助 */
grid-template-rows: 100vh;
overflow: hidden;
touch-action: none;
user-select: none;
}
.app.preview-mode {
grid-template-columns: 1fr;
}
.sidebar {
border-right: 1px solid #e5e7eb;
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
background: #fafafa;
}
.file-actions {
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
}
a {
text-decoration: none;
}
.hidden-file {
display: none;
}
.sidebar h2 {
margin: 0 0 8px 0;
font-size: 16px;
color: #111827;
}
.palette {
display: grid;
grid-template-columns: repeat(1, minmax(0, 1fr));
gap: 10px;
overflow: auto;
}
.palette-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 8px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: white;
cursor: grab;
user-select: none;
}
.palette-item:active {
cursor: grabbing;
}
.palette-item img {
width: 56px;
height: 56px;
object-fit: contain;
}
.palette-item .label {
font-size: 12px;
color: #374151;
}
.inspector {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #e5e7eb;
display: flex;
flex-direction: column;
gap: 10px;
}
.inspector h3 {
margin: 0;
font-size: 14px;
color: #111827;
}
.inspector .row {
display: grid;
grid-template-columns: 60px 1fr auto;
align-items: center;
gap: 8px;
}
.inspector .row label {
color: #4b5563;
font-size: 12px;
}
.inspector .row .num {
width: 70px;
}
.viewport {
position: relative;
overflow: hidden;
background: #fff;
}
/* 右侧 AI 面板容器 */
.ai-aside {
border-left: 1px solid #e5e7eb;
height: 100vh;
overflow: hidden;
}
/* 右侧重新打开箭头按钮(在隐藏时显示) */
.ai-reopen {
position: fixed;
right: 8px;
top: 50%;
transform: translateY(-50%);
z-index: 10;
padding: 8px 10px;
border: 1px solid #e5e7eb;
border-radius: 999px 0 0 999px;
background: #fff;
color: #111827;
cursor: pointer;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
}
.ai-reopen:hover {
background: #f3f4f6;
}
.world {
position: absolute;
left: 0;
top: 0;
/* 网格背景(细线 + 粗线,每 5 格一条) */
background-image:
linear-gradient(to right, rgba(0, 0, 0, 0.05) 1px, transparent 1px),
linear-gradient(to bottom, rgba(0, 0, 0, 0.05) 1px, transparent 1px),
linear-gradient(to right, rgba(0, 0, 0, 0.09) 1px, transparent 1px),
linear-gradient(to bottom, rgba(0, 0, 0, 0.09) 1px, transparent 1px);
background-size:
var(--grid) 1px,
1px var(--grid),
var(--grid5) 1px,
1px var(--grid5);
background-position: 0 0, 0 0, 0 0, 0 0;
}
.wires {
position: absolute;
left: 0;
top: 0;
z-index: 1;
}
/* 新增:分层导线样式 */
.wire-group {
cursor: pointer;
}
.wire-base {
stroke: #111827;
stroke-width: 3;
opacity: 0.85;
}
.wire-highlight {
stroke-width: 2;
mix-blend-mode: multiply;
}
/* 悬停时微弱高亮 */
.wire-group:hover .wire-base {
opacity: 0.95;
}
.wire-group:hover .wire-highlight {
filter: brightness(1.06);
}
/* 放大可点击区域(不可见)以提升命中,通过 pointer-events 控制组级点击 */
.wire-hit {
stroke: transparent;
stroke-width: 10;
}
/* 预览线保留原 class 名,便于最小改动 */
.wire {
stroke: #111827;
stroke-width: 2;
}
.wire.preview {
stroke: #3b82f6;
stroke-dasharray: 10 6;
animation: dash-move 1.2s linear infinite;
opacity: 0.9;
}
@keyframes dash-move {
from {
stroke-dashoffset: 0;
}
to {
stroke-dashoffset: -32;
}
}
.inst {
position: absolute;
transform: translate(-50%, -50%);
cursor: move;
user-select: none;
z-index: 2;
}
.inst .inst-box {
position: relative;
}
.inst img {
width: 100%;
height: 100%;
object-fit: contain;
image-rendering: -webkit-optimize-contrast;
}
/* 电阻色环样式 */
.res-band {
position: absolute;
transform: translateX(-50%);
opacity: 0.8;
top: 0;
left: 0;
border-radius: 2px;
box-shadow: inset 0 0 1px rgba(0, 0, 0, 0.4), 0 0 1px rgba(0, 0, 0, 0.2);
pointer-events: none;
}
.rheo-pin {
position: absolute;
z-index: 3;
cursor: ew-resize;
pointer-events: auto;
width: 15% !important;
height: auto !important;
top: 25.5% !important;
transform: translateX(-50%);
}
.bulb-glow {
position: absolute;
left: 0;
top: -20%;
pointer-events: none;
/* 轻微发光的滤镜 */
filter: drop-shadow(0 0 2px rgba(245, 158, 11, 0.6));
}
.inst.selected img {
outline: 2px solid #3b82f6;
outline-offset: 2px;
border-radius: 6px;
}
.meter-label {
position: absolute;
right: 25%;
top: 20%;
color: #111827;
font-family: "meter-font";
font-size: 20px;
white-space: nowrap;
pointer-events: none;
}
.power-supply-label {
position: absolute;
right: 44%;
height: 38%;
top: 17%;
color: #62D3EF;
text-shadow: 0 0 2px #62D3EF, 0 0 6px #62d3ef44;
font-family: "meter-font";
font-size: 22px;
white-space: nowrap;
pointer-events: none;
display: flex;
flex-direction: column;
align-items: flex-end;
justify-content: space-between;
}
.endpoint {
position: absolute;
width: 14px;
height: 14px;
transform: translate(-50%, -50%);
border: 2px dashed #3b82f6;
border-radius: 50%;
background: #ffffffcc;
z-index: 3;
cursor: crosshair;
}
.endpoint.active {
background: #dbeafe;
border-color: #2563eb;
}
.endpoint.hover {
transform: translate(-50%, -50%) scale(1.25);
}
/* 连通端点高亮:当某端点悬停时,其所有通过导线连通的端点以强调色显示 */
.endpoint.connected {
border-color: #10b981;
/* teal-500 */
background: #ecfdf5;
/* teal-50 */
transform: translate(-50%, -50%) scale(1.25);
}
.bottom-panel {
position: fixed;
left: 140px;
/* 与左侧栏对齐 */
right: 360px;
/* 默认让出 AI 侧栏宽度,实际由内联样式覆盖 */
bottom: 0;
background: #f9fafb;
border-top: 1px solid #e5e7eb;
}
.bottom-panel .panel-inner {
display: flex;
align-items: center;
gap: 16px;
padding: 10px 16px;
}
/* 右侧操作区 */
.panel-actions {
margin-left: auto;
}
.danger-btn {
padding: 6px 10px;
border: 1px solid #fecaca;
border-radius: 6px;
background: #fee2e2;
color: #b91c1c;
cursor: pointer;
width: max-content;
}
.danger-btn:hover {
background: #fecaca;
border-color: #ef4444;
}
/* 预设区域样式 */
.presets {
display: inline-flex;
align-items: center;
gap: 8px;
}
.preset-label {
color: #4b5563;
font-size: 12px;
}
.preset-btn {
padding: 4px 8px;
border: 1px solid #e5e7eb;
border-radius: 6px;
background: #fff;
color: #111827;
cursor: pointer;
}
.preset-btn:hover {
background: #f3f4f6;
}
.bottom-panel .props {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.prop-row {
display: inline-flex;
align-items: center;
gap: 6px;
}
.prop-row .label {
color: #374151;
font-size: 12px;
}
.prop-row .unit {
color: #6b7280;
font-size: 12px;
}
/* 旋转控制样式 */
.deg-input {
width: 70px;
}
.rot-btn {
padding: 4px 8px;
border: 1px solid #e5e7eb;
border-radius: 6px;
background: #fff;
color: #111827;
cursor: pointer;
}
.rot-btn:hover {
background: #f3f4f6;
}
/* 文本框样式 */
.text-box {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
line-height: 1.2;
padding: 6px;
word-break: break-word;
white-space: pre-wrap;
background: transparent;
pointer-events: auto;
user-select: text;
}
</style>