1747 lines
55 KiB
Vue
1747 lines
55 KiB
Vue
<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^T(m×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>
|