Compare commits
2 Commits
21a21c1dfd
...
11f4fec721
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11f4fec721 | ||
|
|
29e674a81b |
330
src/App.vue
330
src/App.vue
@ -2,6 +2,7 @@
|
||||
import { computed, onMounted, reactive, ref, watchEffect } from 'vue'
|
||||
import AIAssistant from './components/AIAssistant.vue'
|
||||
import { elements, type CircuitElement, type Preset } from './elements'
|
||||
import { formatValue } from './utils'
|
||||
|
||||
// 1) 左侧面板元件清单(显式导入)
|
||||
type PaletteItem = CircuitElement
|
||||
@ -11,6 +12,9 @@ const palette: PaletteItem[] = [...elements]
|
||||
const viewportRef = ref<HTMLDivElement | null>(null)
|
||||
const worldRef = ref<HTMLDivElement | null>(null)
|
||||
|
||||
// 右侧 AI 面板显示开关
|
||||
const showAI = ref(true)
|
||||
|
||||
const state = reactive({
|
||||
scale: 1,
|
||||
minScale: 0.2,
|
||||
@ -102,8 +106,6 @@ function zoomAt(clientX: number, clientY: number, deltaY: number) {
|
||||
const DND_MIME = 'application/x-circuit-element'
|
||||
|
||||
function onPaletteDragStart(e: DragEvent, item: PaletteItem) {
|
||||
console.log(e);
|
||||
|
||||
if (!e.dataTransfer) return
|
||||
e.dataTransfer.setData(DND_MIME, item.key)
|
||||
e.dataTransfer.setData('text/plain', item.key)
|
||||
@ -122,13 +124,10 @@ function onCanvasDrop(e: DragEvent) {
|
||||
const item = palette.find(p => p.key === key)
|
||||
if (!item) return
|
||||
const { x, y } = clientToWorld(e.clientX, e.clientY)
|
||||
// 吸附到网格(可选)
|
||||
const gx = Math.round(x / state.gridSize) * state.gridSize
|
||||
const gy = Math.round(y / state.gridSize) * state.gridSize
|
||||
const defSize = item.defaultSize ?? 64
|
||||
const props: Record<string, number | string> = {}
|
||||
; (item.propertySchemas || []).forEach((p) => { props[p.key] = p.default })
|
||||
const inst: Instance = { id: String(++idSeq), key: item.key, url: item.url, x: gx, y: gy, size: defSize, rotation: 0, props }
|
||||
const inst: Instance = { id: String(++idSeq), key: item.key, url: item.url, x: x, y: y, size: defSize, rotation: 0, props }
|
||||
if (item.stateImages) inst.state = 'off'
|
||||
instances.push(inst)
|
||||
}
|
||||
@ -277,6 +276,39 @@ function isNearest(instId: string, cpIndex: number) {
|
||||
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 为正)
|
||||
@ -528,25 +560,7 @@ function gaussianSolve(A: number[][], b: number[]): number[] | null {
|
||||
// 绑定求解到响应式数据
|
||||
watchEffect(() => { solveMNA() })
|
||||
|
||||
function formatValue(v: number | null, unit: string) {
|
||||
if (v == null || !Number.isFinite(v)) return '—'
|
||||
// 简易工程计数法
|
||||
const absv = Math.abs(v)
|
||||
const units = [
|
||||
{ s: 1e-9, u: 'n' },
|
||||
{ s: 1e-6, u: 'µ' },
|
||||
{ s: 1e-3, u: 'm' },
|
||||
{ s: 1, u: '' },
|
||||
{ s: 1e3, u: 'k' },
|
||||
{ s: 1e6, u: 'M' },
|
||||
]
|
||||
let u = ''
|
||||
let val = v
|
||||
for (let i = units.length - 1; i >= 0; i--) {
|
||||
if (absv >= units[i].s) { u = units[i].u; val = v / units[i].s; break }
|
||||
}
|
||||
return `${val.toFixed(3)} ${u}${unit}`
|
||||
}
|
||||
|
||||
|
||||
function meterText(inst: Instance) {
|
||||
const live = instLive[inst.id]
|
||||
@ -557,7 +571,7 @@ function meterText(inst: Instance) {
|
||||
try {
|
||||
// 允许两种形态:"(i)=>..." 或 "(i,v,props)=>..."
|
||||
// 使用字符串拼接避免模板字符串与用户代码中的反引号冲突
|
||||
const fn = new Function('i','r','props', 'return (' + code + ')(i, r, props)')
|
||||
const fn = new Function('i', 'r', 'props', 'return (' + code + ')(i, r, props)')
|
||||
const out = fn(i, r, inst.props)
|
||||
return String(out)
|
||||
} catch {
|
||||
@ -711,7 +725,6 @@ function loadFromJSONText(text: string) {
|
||||
// 兼容校验
|
||||
const world = obj.world || {}
|
||||
const rawInstances = Array.isArray(obj.instances) ? obj.instances : []
|
||||
const rawWires = Array.isArray(obj.wires) ? obj.wires : []
|
||||
|
||||
// 过滤仅保留存在于库里的元件
|
||||
const knownKeys = new Set(elements.map(e => e.key))
|
||||
@ -734,64 +747,46 @@ function loadFromJSONText(text: string) {
|
||||
props: rawProps,
|
||||
state: r.state === 'on' || r.state === 'off' ? r.state : undefined,
|
||||
}
|
||||
// 暂存连接信息在实例对象上(不入 props)以便后续重建导线
|
||||
;(inst as any).__savedConnections = savedConnections
|
||||
// 暂存连接信息在实例对象上(不入 props)以便后续重建导线
|
||||
; (inst as any).__savedConnections = savedConnections
|
||||
validInstances.push(inst)
|
||||
}
|
||||
|
||||
// 基于实例与元件接线点数量校验导线
|
||||
const instMap = new Map(validInstances.map(i => [i.id, i]))
|
||||
const validWires: Wire[] = []
|
||||
if (rawWires.length > 0) {
|
||||
// 旧格式:直接读取 wires
|
||||
for (const w of rawWires) {
|
||||
if (!w || typeof w !== 'object') continue
|
||||
const a = w.a, b = w.b
|
||||
if (!a || !b) continue
|
||||
const ia = instMap.get(String(a.instId))
|
||||
const ib = instMap.get(String(b.instId))
|
||||
if (!ia || !ib) continue
|
||||
const elemA = elements.find(e => e.key === ia.key)
|
||||
const elemB = elements.find(e => e.key === ib.key)
|
||||
const cpsA = elemA?.connectionPoints?.length ?? 0
|
||||
const cpsB = elemB?.connectionPoints?.length ?? 0
|
||||
const cpa = safeNumber(a.cpIndex)
|
||||
const cpb = safeNumber(b.cpIndex)
|
||||
if (cpa < 0 || cpa >= cpsA || cpb < 0 || cpb >= cpsB) continue
|
||||
validWires.push({ id: String(w.id ?? (++wireSeq)), a: { instId: String(a.instId), cpIndex: cpa }, b: { instId: String(b.instId), cpIndex: cpb } })
|
||||
}
|
||||
} else {
|
||||
// 新格式:从每个实例 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 })
|
||||
}
|
||||
|
||||
// 从每个实例 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 })
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 写回世界与状态
|
||||
@ -861,14 +856,14 @@ function deleteSelected() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app">
|
||||
<div class="app" :style="{ '--ai-width': showAI ? '360px' : '0px' }">
|
||||
<!-- 左侧元件面板 -->
|
||||
<aside class="sidebar">
|
||||
<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"/>
|
||||
<img :src="item.url" :alt="item.name" draggable="false" />
|
||||
<span class="label">{{ item.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -877,11 +872,11 @@ function deleteSelected() {
|
||||
<button class="btn" @click="onExportJSON">导出 JSON</button>
|
||||
<button class="btn" @click="triggerImport">导入 JSON</button>
|
||||
<input ref="importInputRef" type="file" accept="application/json,.json" class="hidden-file"
|
||||
@change="onImportFileChange" />
|
||||
@change="onImportFileChange" />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 画布视口(可平移、缩放、接收拖拽) -->
|
||||
<!-- 画布视口(可平移、缩放、接收拖拽) -->
|
||||
<main class="viewport" ref="viewportRef" @mousedown="onViewportMouseDown"
|
||||
@mousemove="(e) => { onViewportMouseMove(e); onViewportMouseMoveDrag(e); onViewportMouseMoveTrack(e) }"
|
||||
@mouseup="() => { onViewportMouseUp(); onViewportMouseUpDrag() }"
|
||||
@ -902,30 +897,25 @@ function deleteSelected() {
|
||||
: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">
|
||||
<stop offset="0%" stop-color="#f3f4f6"/>
|
||||
<stop offset="100%" stop-color="#6b7280"/>
|
||||
<stop offset="0%" stop-color="#f3f4f6" />
|
||||
<stop offset="100%" stop-color="#6b7280" />
|
||||
</linearGradient>
|
||||
</template>
|
||||
</defs>
|
||||
<g>
|
||||
<!-- 分层导线:底层暗色,顶层渐变高光,圆角端点,更有质感 -->
|
||||
<g v-for="w in wires" :key="w.id" class="wire-group" filter="url(#wireShadow)"
|
||||
@click.stop="(e) => removeWire(w.id, e)">
|
||||
<line class="wire-base"
|
||||
:x1="getConnectionPointWorldPos(getInstance(w.a.instId)!, w.a.cpIndex).x"
|
||||
@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" />
|
||||
<line class="wire-highlight" :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" />
|
||||
<line class="wire-highlight"
|
||||
: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="'url(#wireGrad-' + w.id + ')'"
|
||||
stroke-linecap="round"
|
||||
vector-effect="non-scaling-stroke" />
|
||||
:stroke="'url(#wireGrad-' + w.id + ')'" stroke-linecap="round" vector-effect="non-scaling-stroke" />
|
||||
</g>
|
||||
|
||||
<!-- 预览线:当选中一个端点时,显示到鼠标或吸附端点的线 -->
|
||||
@ -938,8 +928,9 @@ function deleteSelected() {
|
||||
|
||||
<!-- 已放置的元件(可拖动) -->
|
||||
<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)'}" @mousedown="(e) => onInstanceMouseDown(e, inst)"
|
||||
@click.stop="() => selectInstance(inst.id)" :title="inst.key">
|
||||
:style="{ left: inst.x + 'px', top: inst.y + 'px', transform: 'translate(-50%, -50%) rotate(' + (inst.rotation || 0) + 'deg)' }"
|
||||
@mousedown="(e) => onInstanceMouseDown(e, inst)" @click.stop="() => selectInstance(inst.id)"
|
||||
:title="inst.key">
|
||||
<div class="inst-box" :style="{ width: inst.size + 'px', height: inst.size + 'px' }">
|
||||
<img
|
||||
:src="(elements.find(e => e.key === inst.key)?.stateImages ? (inst.state === 'on' ? elements.find(e => e.key === inst.key)!.stateImages!.on : elements.find(e => e.key === inst.key)!.stateImages!.off) : inst.url)"
|
||||
@ -948,14 +939,14 @@ function deleteSelected() {
|
||||
|
||||
<!-- 灯泡发散光线叠加层 -->
|
||||
<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">
|
||||
: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" />
|
||||
: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>
|
||||
|
||||
@ -972,34 +963,36 @@ function deleteSelected() {
|
||||
:key="inst.id + '-' + idx">
|
||||
<div class="endpoint" :class="{
|
||||
active: pendingEndpoint && pendingEndpoint.instId === inst.id && pendingEndpoint.cpIndex === idx,
|
||||
hover: isNearest(inst.id, 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)" :title="cp.name || ('P' + (idx + 1))"></div>
|
||||
left: getConnectionPointWorldPos(inst, idx).x + 'px',
|
||||
top: getConnectionPointWorldPos(inst, idx).y + 'px'
|
||||
}" @click.stop="(e) => onEndpointClick(inst.id, idx, e)"
|
||||
@mouseenter="() => { hoveredEndpoint = { instId: inst.id, cpIndex: idx } }"
|
||||
@mouseleave="() => { if (hoveredEndpoint && hoveredEndpoint.instId === inst.id && hoveredEndpoint.cpIndex === idx) hoveredEndpoint = null }"
|
||||
:title="cp.name || ('P' + (idx + 1))"></div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 右侧 AI 助手面板 -->
|
||||
<aside class="ai-aside">
|
||||
<AIAssistant :state="aiState" @error="onAiError" />
|
||||
<aside v-if="showAI" class="ai-aside">
|
||||
<AIAssistant :state="aiState" @error="onAiError" @close="showAI = false" />
|
||||
</aside>
|
||||
<button v-else class="ai-reopen" title="打开 AI 助手" @click="showAI = true">◀</button>
|
||||
|
||||
<!-- 底部属性面板:编辑选中元件的业务属性(非尺寸) -->
|
||||
<footer v-if="selectedInst" class="bottom-panel">
|
||||
<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" 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>
|
||||
<button v-for="p in selectedMeta!.preset" :key="p.name" class="preset-btn" @click.stop="applyPreset(p)">{{
|
||||
p.name
|
||||
}}</button>
|
||||
</div>
|
||||
<div class="props">
|
||||
<!-- 实时电学量 -->
|
||||
@ -1017,8 +1010,10 @@ function deleteSelected() {
|
||||
<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>
|
||||
<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>
|
||||
<template v-for="schema in (elements.find(e => e.key === selectedInst!.key)?.propertySchemas || [])"
|
||||
:key="schema.key">
|
||||
@ -1044,7 +1039,8 @@ function deleteSelected() {
|
||||
<style scoped>
|
||||
.app {
|
||||
display: grid;
|
||||
grid-template-columns: 140px 1fr 360px; /* 左栏 / 画布 / AI 辅助 */
|
||||
grid-template-columns: 140px 1fr var(--ai-width, 360px);
|
||||
/* 左栏 / 画布 / AI 辅助 */
|
||||
grid-template-rows: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
@ -1063,6 +1059,7 @@ function deleteSelected() {
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.file-actions .btn {
|
||||
padding: 6px 10px;
|
||||
border: 1px solid #e5e7eb;
|
||||
@ -1071,8 +1068,14 @@ function deleteSelected() {
|
||||
color: #111827;
|
||||
cursor: pointer;
|
||||
}
|
||||
.file-actions .btn:hover { background: #f3f4f6; }
|
||||
.hidden-file { display: none; }
|
||||
|
||||
.file-actions .btn:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.hidden-file {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar h2 {
|
||||
margin: 0 0 8px 0;
|
||||
@ -1159,6 +1162,26 @@ function deleteSelected() {
|
||||
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;
|
||||
@ -1185,19 +1208,30 @@ function deleteSelected() {
|
||||
}
|
||||
|
||||
/* 新增:分层导线样式 */
|
||||
.wire-group { cursor: pointer; }
|
||||
.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); }
|
||||
.wire-group:hover .wire-base {
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.wire-group:hover .wire-highlight {
|
||||
filter: brightness(1.06);
|
||||
}
|
||||
|
||||
/* 放大可点击区域(不可见)以提升命中,通过 pointer-events 控制组级点击 */
|
||||
.wire-hit {
|
||||
stroke: transparent;
|
||||
@ -1205,7 +1239,11 @@ function deleteSelected() {
|
||||
}
|
||||
|
||||
/* 预览线保留原 class 名,便于最小改动 */
|
||||
.wire { stroke: #111827; stroke-width: 2; }
|
||||
.wire {
|
||||
stroke: #111827;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.wire.preview {
|
||||
stroke: #3b82f6;
|
||||
stroke-dasharray: 10 6;
|
||||
@ -1214,8 +1252,13 @@ function deleteSelected() {
|
||||
}
|
||||
|
||||
@keyframes dash-move {
|
||||
from { stroke-dashoffset: 0; }
|
||||
to { stroke-dashoffset: -32; }
|
||||
from {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
stroke-dashoffset: -32;
|
||||
}
|
||||
}
|
||||
|
||||
.inst {
|
||||
@ -1284,11 +1327,21 @@ function deleteSelected() {
|
||||
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 侧栏宽度 */
|
||||
right: 360px;
|
||||
/* 默认让出 AI 侧栏宽度,实际由内联样式覆盖 */
|
||||
bottom: 0;
|
||||
background: #f9fafb;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
@ -1305,6 +1358,7 @@ function deleteSelected() {
|
||||
.panel-actions {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.danger-btn {
|
||||
padding: 6px 10px;
|
||||
border: 1px solid #fecaca;
|
||||
@ -1314,6 +1368,7 @@ function deleteSelected() {
|
||||
cursor: pointer;
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.danger-btn:hover {
|
||||
background: #fecaca;
|
||||
border-color: #ef4444;
|
||||
@ -1325,10 +1380,12 @@ function deleteSelected() {
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.preset-label {
|
||||
color: #4b5563;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.preset-btn {
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #e5e7eb;
|
||||
@ -1337,7 +1394,10 @@ function deleteSelected() {
|
||||
color: #111827;
|
||||
cursor: pointer;
|
||||
}
|
||||
.preset-btn:hover { background: #f3f4f6; }
|
||||
|
||||
.preset-btn:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.bottom-panel .props {
|
||||
display: flex;
|
||||
@ -1362,7 +1422,10 @@ function deleteSelected() {
|
||||
}
|
||||
|
||||
/* 旋转控制样式 */
|
||||
.deg-input { width: 70px; }
|
||||
.deg-input {
|
||||
width: 70px;
|
||||
}
|
||||
|
||||
.rot-btn {
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #e5e7eb;
|
||||
@ -1371,5 +1434,8 @@ function deleteSelected() {
|
||||
color: #111827;
|
||||
cursor: pointer;
|
||||
}
|
||||
.rot-btn:hover { background: #f3f4f6; }
|
||||
|
||||
.rot-btn:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
</style>
|
||||
|
||||
BIN
src/circuit-ele/sliding_rheostat.png
Normal file
BIN
src/circuit-ele/sliding_rheostat.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 254 KiB |
BIN
src/circuit-ele/sliding_rheostat_pin.png
Normal file
BIN
src/circuit-ele/sliding_rheostat_pin.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
@ -11,6 +11,7 @@ const props = defineProps<{
|
||||
// 向父组件发出事件:可选,用于保存/载入 AI 偏好设置
|
||||
const emit = defineEmits<{
|
||||
(e: 'error', err: string): void
|
||||
(e: 'close'): void
|
||||
}>()
|
||||
|
||||
let client: OpenAI = new OpenAI({
|
||||
@ -109,6 +110,7 @@ onMounted(() => nextTick(scrollToBottom))
|
||||
<div class="ai-panel">
|
||||
<header class="ai-header">
|
||||
<div class="title">AI 助手</div>
|
||||
<button class="close-btn" title="关闭" @click="emit('close')">×</button>
|
||||
</header>
|
||||
|
||||
<div class="chat-list" ref="listRef">
|
||||
@ -152,6 +154,18 @@ onMounted(() => nextTick(scrollToBottom))
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
color: #111827;
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
.close-btn:hover { background: #f3f4f6; }
|
||||
|
||||
.key-box {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
|
||||
8
src/components/PalettePanel.vue
Normal file
8
src/components/PalettePanel.vue
Normal file
@ -0,0 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import { elements, type CircuitElement, type Preset } from '../elements'
|
||||
|
||||
// 1) 左侧面板元件清单(显式导入)
|
||||
type PaletteItem = CircuitElement
|
||||
const palette: PaletteItem[] = [...elements]
|
||||
</script>
|
||||
19
src/utils.ts
Normal file
19
src/utils.ts
Normal file
@ -0,0 +1,19 @@
|
||||
export function formatValue(v: number | null, unit: string) {
|
||||
if (v == null || !Number.isFinite(v)) return '—'
|
||||
// 简易工程计数法
|
||||
const absv = Math.abs(v)
|
||||
const units = [
|
||||
{ s: 1e-9, u: 'n' },
|
||||
{ s: 1e-6, u: 'µ' },
|
||||
{ s: 1e-3, u: 'm' },
|
||||
{ s: 1, u: '' },
|
||||
{ s: 1e3, u: 'k' },
|
||||
{ s: 1e6, u: 'M' },
|
||||
]
|
||||
let u = ''
|
||||
let val = v
|
||||
for (let i = units.length - 1; i >= 0; i--) {
|
||||
if (absv >= units[i].s) { u = units[i].u; val = v / units[i].s; break }
|
||||
}
|
||||
return `${val.toFixed(3)} ${u}${unit}`
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user