Compare commits

...

2 Commits

Author SHA1 Message Date
feie9454
11f4fec721 0911-noon 2025-09-11 19:22:18 +08:00
feie9454
29e674a81b 0911-m 2025-09-11 10:19:03 +08:00
6 changed files with 240 additions and 133 deletions

View File

@ -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=0VOFF=)(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]
@ -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))
@ -742,26 +755,8 @@ function loadFromJSONText(text: string) {
// 线线
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 线
// props.__connections 线
const dedup = new Set<string>()
const getKey = (a: EndpointRef, b: EndpointRef) => {
const k1 = `${a.instId}:${a.cpIndex}`
@ -791,7 +786,7 @@ function loadFromJSONText(text: string) {
validWires.push({ id: String(++wireSeq), a, b })
}
}
}
}
//
@ -861,7 +856,7 @@ function deleteSelected() {
</script>
<template>
<div class="app">
<div class="app" :style="{ '--ai-width': showAI ? '360px' : '0px' }">
<!-- 左侧元件面板 -->
<aside class="sidebar">
<h2>元件</h2>
@ -911,21 +906,16 @@ function deleteSelected() {
<!-- 分层导线底层暗色顶层渐变高光圆角端点更有质感 -->
<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"
<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)"
@ -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>
}" @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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

View File

@ -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;

View 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
View 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}`
}