diff --git a/index.html b/index.html index dde16aa..65d8f35 100644 --- a/index.html +++ b/index.html @@ -2,9 +2,11 @@ - - - Vite + Vue + TS + + + + + AI 电路实验室
diff --git a/public/icon.png b/public/icon.png new file mode 100644 index 0000000..963b57e Binary files /dev/null and b/public/icon.png differ diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest new file mode 100644 index 0000000..89efe8f --- /dev/null +++ b/public/manifest.webmanifest @@ -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" + } + ] +} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..da1bac2 --- /dev/null +++ b/public/sw.js @@ -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) + ) + ); + } +}); diff --git a/public/vite.svg b/public/vite.svg deleted file mode 100644 index e7b8dfb..0000000 --- a/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/components/AIAssistant.vue b/src/components/AIAssistant.vue index e54fcca..a08656e 100644 --- a/src/components/AIAssistant.vue +++ b/src/components/AIAssistant.vue @@ -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) { diff --git a/src/main.ts b/src/main.ts index ce28821..816c0ab 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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)); + }); + } +} diff --git a/src/style.css b/src/style.css index fbae52a..0919f0f 100644 --- a/src/style.css +++ b/src/style.css @@ -23,3 +23,8 @@ body{ .btn:hover { background: #f3f4f6; } + + +*{ + box-sizing: border-box; +} \ No newline at end of file diff --git a/src/views/VirtualLab.vue b/src/views/VirtualLab.vue index d3f7368..5923110 100644 --- a/src/views/VirtualLab.vue +++ b/src/views/VirtualLab.vue @@ -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(null) +const recogFile = ref(null) +const recogPreviewBase64 = ref('') +const recogCircuit = ref(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) {
- - + +
+ + + @@ -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; }