Compare commits

..

2 Commits

9 changed files with 379 additions and 9 deletions

View File

@ -2,9 +2,11 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + Vue + TS</title>
<link rel="icon" type="image/svg+xml" href="/icon.png" />
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="theme-color" content="#ffffff" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>AI 电路实验室</title>
</head>
<body>
<div id="app"></div>

BIN
public/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 574 KiB

View File

@ -0,0 +1,18 @@
{
"name": "AI 电路实验室",
"short_name": "AI电路实验室",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#ffffff",
"description": "一个可安装的 PWA在浏览器中体验交互式电路虚拟实验室。",
"icons": [
{
"src": "/icon.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}

61
public/sw.js Normal file
View File

@ -0,0 +1,61 @@
// Basic service worker for PWA installability and future caching
// Generated by Copilot — you can extend with runtime caching if needed.
const CACHE_NAME = 'cvl-cache-v1';
const APP_SHELL = [
'/',
'/index.html',
'/icon.png',
// Vite will handle asset hashing; we keep shell minimal to avoid stale caches.
];
self.addEventListener('install', (event) => {
// Skip waiting so updates activate faster.
self.skipWaiting();
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL)).catch(() => void 0)
);
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys.map((k) => (k === CACHE_NAME ? undefined : caches.delete(k)))
)
)
);
// Take control of uncontrolled clients as soon as possible.
self.clients.claim();
});
self.addEventListener('fetch', (event) => {
const { request } = event;
// Only handle GET requests and same-origin.
if (request.method !== 'GET' || new URL(request.url).origin !== self.location.origin) {
return;
}
// Network-first for HTML navigations; cache-first for others.
if (request.mode === 'navigate') {
event.respondWith(
fetch(request)
.then((resp) => {
const copy = resp.clone();
caches.open(CACHE_NAME).then((cache) => cache.put('/', copy)).catch(() => void 0);
return resp;
})
.catch(() => caches.match('/') || caches.match('/index.html'))
);
} else {
event.respondWith(
caches.match(request).then((cached) =>
cached ||
fetch(request).then((resp) => {
const copy = resp.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(request, copy)).catch(() => void 0);
return resp;
}).catch(() => cached)
)
);
}
});

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -40,9 +40,11 @@ const sending = ref(false)
function summarizeState(s: any): string {
try {
if (!s) return '无状态'
const full = JSON.stringify(s)
const circuit = {instances: s.instances}
console.log(circuit);
const full = JSON.stringify(circuit)
// system
return `电路状态: ${full}`
return `电路状态: ${full}\n这是一个单臂直流电桥测电阻电路。`
} catch (e) {
return '状态序列化失败'
}
@ -79,11 +81,17 @@ async function ask() {
messages: [persona, stateMsg, ...recent]
})
//
// push UI
const assistantMsg: ChatMessage = { role: 'assistant', content: '' }
messages.value.push(assistantMsg)
const idx = messages.value.length - 1
for await (const part of stream as any) {
const delta: string = part?.choices?.[0]?.delta?.content ?? ''
if (delta) assistantMsg.content += delta
if (delta) {
//
messages.value[idx].content += delta
}
await nextTick(); scrollToBottom()
}
} catch (err: any) {

View File

@ -15,3 +15,15 @@ const router = createRouter({
})
createApp(App).use(router).mount('#app')
// Register Service Worker for PWA (only in production or localhost)
if ('serviceWorker' in navigator) {
const isLocalhost = /^(localhost|127\.0\.0\.1|\[::1\])$/.test(location.hostname);
if (import.meta.env.PROD || isLocalhost) {
window.addEventListener('load', () => {
navigator.serviceWorker
.register('/sw.js')
.catch((err) => console.warn('[SW] registration failed:', err));
});
}
}

View File

@ -23,3 +23,8 @@ body{
.btn:hover {
background: #f3f4f6;
}
*{
box-sizing: border-box;
}

View File

@ -5,6 +5,7 @@ import UploadButton from '../components/UploadButton.vue'
import { elements, type CircuitElement, type Preset } from './elements'
import { formatValue } from './utils'
import { usePreview } from '../composables/usePreview'
import { api } from '../api/client'
// 1)
type PaletteItem = CircuitElement
@ -984,6 +985,127 @@ function onImportFileChange(e: Event) {
input.value = ''
}
// -> ->
type RecognitionResp = { preview: string; circuit: any }
const showRecognition = ref(false)
const recogStage = ref<'select' | 'review'>('select')
const recogUploading = ref(false)
const recogError = ref<string | null>(null)
const recogFile = ref<File | null>(null)
const recogPreviewBase64 = ref<string>('')
const recogCircuit = ref<any>(null)
const recogPreviewSrc = ref('')
// 1 2 3
const recogStep = computed(() => {
if (recogUploading.value) return 2
return recogStage.value === 'review' ? 3 : 1
})
//
const recogDragOver = ref(false)
function openRecognition() {
showRecognition.value = true
resetRecognition()
}
function closeRecognition() {
showRecognition.value = false
}
function resetRecognition() {
recogStage.value = 'select'
recogUploading.value = false
recogError.value = null
recogFile.value = null
recogPreviewBase64.value = ''
recogCircuit.value = null
}
async function onRecognitionFileChange(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files && input.files[0]
if (!file) return
recogFile.value = file
recogPreviewSrc.value = ''
recogError.value = null
//
await doRecognitionUpload(file)
//
input.value = ''
}
async function doRecognitionUpload(file: File) {
try {
recogUploading.value = true
recogError.value = null
const fd = new FormData()
fd.append('file', file, file.name)
const res = await fetch(`${api.base}/recognition`, {
method: 'POST',
// body: fd,
})
if (!res.ok) {
const txt = await res.text().catch(() => '')
throw new Error(txt || `${res.status} ${res.statusText}`)
}
const data = (await res.json()) as RecognitionResp
if (!data || typeof data !== 'object' || !data.preview || !data.circuit) {
throw new Error('识别接口返回格式不正确')
}
recogPreviewSrc.value = String(data.preview)
recogCircuit.value = data.circuit
recogStage.value = 'review'
} catch (err: any) {
recogError.value = err?.message || String(err)
} finally {
recogUploading.value = false
}
}
function confirmRecognitionImport() {
try {
const payload = recogCircuit.value
const text = typeof payload === 'string' ? payload : JSON.stringify(payload)
loadFromJSONText(text)
closeRecognition()
} catch (err: any) {
alert('导入失败:' + (err?.message || String(err)))
}
}
function backToSelectStage() {
// MIME
resetRecognition()
}
//
function onUploadDragOver(e: DragEvent) {
e.preventDefault()
recogDragOver.value = true
}
function onUploadDragEnter(e: DragEvent) {
e.preventDefault()
recogDragOver.value = true
}
function onUploadDragLeave() {
recogDragOver.value = false
}
function onUploadDrop(e: DragEvent) {
e.preventDefault()
const file = e.dataTransfer?.files?.[0]
if (!file) return
//
if (!file.type.startsWith('image/')) {
recogError.value = '仅支持图片文件'
return
}
recogFile.value = file
doRecognitionUpload(file)
recogDragOver.value = false
}
// AI 访
const aiState = computed(() => makeSave())
@ -1041,8 +1163,9 @@ function editTextBox(inst: Instance) {
</div>
<div class="file-actions">
<button class="btn" @click="onExportJSON">导出 JSON</button>
<button class="btn" @click="triggerImport">导入 JSON</button>
<!-- <button class="btn" @click="onExportJSON">导出 JSON</button>
<button class="btn" @click="triggerImport">导入 JSON</button> -->
<button class="btn" @click="openRecognition">实物电路识别</button>
<!-- 上传模型到模型广场需登录 -->
<UploadButton :makeSave="makeSave" />
<input ref="importInputRef" type="file" accept="application/json,.json" class="hidden-file"
@ -1248,6 +1371,76 @@ function editTextBox(inst: Instance) {
</div>
</div>
</footer>
<!-- 实物电路识别弹窗 -->
<div v-if="showRecognition" class="modal-overlay" @click.self="closeRecognition">
<div class="modal">
<div class="modal-header">
<h3>实物电路识别</h3>
<button class="modal-close" title="关闭" @click="closeRecognition">×</button>
</div>
<!-- 三步指示条 -->
<div class="stepper">
<div class="step" :class="{ completed: recogStep > 1, active: recogStep === 1 }">
<div class="circle">1</div>
<div class="label">选择图片</div>
</div>
<div class="bar" :class="{ filled: recogStep > 1 }"></div>
<div class="step" :class="{ completed: recogStep > 2, active: recogStep === 2 }">
<div class="circle">2</div>
<div class="label">上传等待识别</div>
</div>
<div class="bar" :class="{ filled: recogStep > 2 }"></div>
<div class="step" :class="{ active: recogStep === 3 }">
<div class="circle">3</div>
<div class="label">确认识别结果</div>
</div>
</div>
<Transition name="fade-slide" mode="out-in">
<div v-if="recogStage === 'select'" key="select" class="modal-body modal-body--select">
<p class="muted">选择或拖入一张电路照片系统将自动识别并生成电路</p>
<label class="upload-box" :class="{ dragover: recogDragOver }"
@dragover.prevent="onUploadDragOver"
@dragenter.prevent="onUploadDragEnter"
@dragleave="onUploadDragLeave"
@drop="onUploadDrop">
<input type="file" accept="image/*" @change="onRecognitionFileChange" />
<div class="upload-hint">
<span>点击选择图片或将图片拖拽到此处</span>
</div>
</label>
<div v-if="recogError" class="error">{{ recogError }}</div>
<!-- 加载遮罩与动画 -->
<div v-if="recogUploading" class="loader">
<div class="spinner">
<div class="dot"></div>
<div class="dot"></div>
<div class="dot"></div>
</div>
<div class="loader-text">正在上传并识别</div>
</div>
</div>
<div v-else key="review" class="modal-body modal-body--review">
<div class="preview-wrap">
<img :src="recogPreviewSrc" alt="识别预览" />
</div>
<div v-if="recogError" class="error">{{ recogError }}</div>
</div>
</Transition>
<div class="modal-actions" v-if="recogStage === 'review'">
<button class="btn" @click="backToSelectStage">返回重选</button>
<button class="btn primary" :disabled="!recogCircuit || recogUploading" @click="confirmRecognitionImport">确认导入</button>
</div>
<div class="modal-actions" v-else>
<button class="btn" @click="closeRecognition">关闭</button>
</div>
</div>
</div>
</div>
</template>
@ -1709,4 +1902,76 @@ a {
pointer-events: auto;
user-select: text;
}
/* 识别弹窗样式 */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
width: min(92vw, 720px);
max-height: 88vh;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 10px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
display: flex;
flex-direction: column;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
border-bottom: 1px solid #e5e7eb;
}
.modal-header h3 { margin: 0; font-size: 16px; color: #111827; }
.modal-close { border: none; background: transparent; font-size: 22px; cursor: pointer; color: #6b7280; }
.modal-close:hover { color: #111827; }
.modal-body { padding: 16px; overflow: auto; }
.modal-body--select { position: relative; min-height: 220px; }
.modal-body--review { position: relative; }
.muted { color: #6b7280; font-size: 13px; margin: 0 0 8px; }
.upload-box { display: inline-block; position: relative; border: 2px dashed #cbd5e1; border-radius: 10px; padding: 22px; width: 100%; text-align: center; background: #f8fafc; transition: all .2s ease; }
.upload-box.dragover { border-color: #60a5fa; background: #eff6ff; box-shadow: 0 0 0 4px #93c5fd33; }
.upload-box input[type="file"] { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; }
.upload-hint { color: #374151; }
.error { color: #b91c1c; background: #fee2e2; border: 1px solid #fecaca; padding: 8px; border-radius: 8px; margin-top: 10px; }
.preview-wrap { width: 100%; display: flex; align-items: center; justify-content: center; }
.preview-wrap img { max-width: 100%; max-height: 60vh; border-radius: 8px; border: 1px solid #e5e7eb; }
.modal-actions { display: flex; justify-content: flex-end; gap: 8px; padding: 12px 16px; border-top: 1px solid #e5e7eb; }
.btn { padding: 6px 12px; border: 1px solid #e5e7eb; border-radius: 8px; background: #fff; color: #111827; cursor: pointer; }
.btn:hover { background: #f3f4f6; }
.btn.primary { background: #2563eb; border-color: #1d4ed8; color: #fff; }
.btn.primary:hover { background: #1d4ed8; }
/* Stepper */
.stepper { display: flex; align-items: center; gap: 8px; padding: 10px 16px 0; }
.step { display: flex; align-items: center; gap: 8px; color: #6b7280; }
.step .circle { width: 22px; height: 22px; border-radius: 50%; background: #e5e7eb; color: #111827; display: flex; align-items: center; justify-content: center; font-size: 12px; }
.step .label { font-size: 12px; white-space: nowrap; }
.step.active .circle { background: #2563eb; color: #fff; }
.step.active { color: #111827; }
.step.completed .circle { background: #16a34a; color: #fff; }
.bar { height: 2px; background: #e5e7eb; flex: 1; }
.bar.filled { background: linear-gradient(90deg, #60a5fa, #22c55e); }
/* 动画过渡 */
.fade-slide-enter-active, .fade-slide-leave-active { transition: all .22s ease; }
.fade-slide-enter-from { opacity: 0; transform: translateY(6px); }
.fade-slide-leave-to { opacity: 0; transform: translateY(-6px); }
/* 加载遮罩 */
.loader { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; background: rgba(255,255,255,0.65); border-radius: 8px; }
.spinner { display: inline-flex; gap: 8px; }
.spinner .dot { width: 10px; height: 10px; border-radius: 50%; background: #2563eb; animation: bounce 0.9s infinite ease-in-out; }
.spinner .dot:nth-child(2) { animation-delay: 0.12s; }
.spinner .dot:nth-child(3) { animation-delay: 0.24s; }
@keyframes bounce { 0%, 80%, 100% { transform: scale(0.7); opacity: .6 } 40% { transform: scale(1); opacity: 1 } }
.loader-text { color: #1f2937; font-size: 13px; }
</style>