实物电路上传识别,PWA支持
This commit is contained in:
parent
b9abbd09b6
commit
07df9c9209
@ -2,9 +2,11 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/icon.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
<title>Vite + Vue + TS</title>
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
BIN
public/icon.png
Normal file
BIN
public/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 574 KiB |
18
public/manifest.webmanifest
Normal file
18
public/manifest.webmanifest
Normal 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
61
public/sw.js
Normal 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)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -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 |
@ -40,9 +40,11 @@ const sending = ref(false)
|
|||||||
function summarizeState(s: any): string {
|
function summarizeState(s: any): string {
|
||||||
try {
|
try {
|
||||||
if (!s) return '无状态'
|
if (!s) return '无状态'
|
||||||
const full = JSON.stringify(s)
|
const circuit = {instances: s.instances}
|
||||||
|
console.log(circuit);
|
||||||
|
const full = JSON.stringify(circuit)
|
||||||
// 将完整电路状态作为隐藏的 system 上下文传递
|
// 将完整电路状态作为隐藏的 system 上下文传递
|
||||||
return `电路状态: ${full}`
|
return `电路状态: ${full}\n这是一个单臂直流电桥测电阻电路。`
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return '状态序列化失败'
|
return '状态序列化失败'
|
||||||
}
|
}
|
||||||
@ -79,11 +81,17 @@ async function ask() {
|
|||||||
messages: [persona, stateMsg, ...recent]
|
messages: [persona, stateMsg, ...recent]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 注意:不要直接持有原始对象并修改其属性(那样不会触发响应式)。
|
||||||
|
// 先 push,再通过数组中的“代理对象”按索引去修改,才能保证 UI 实时刷新。
|
||||||
const assistantMsg: ChatMessage = { role: 'assistant', content: '' }
|
const assistantMsg: ChatMessage = { role: 'assistant', content: '' }
|
||||||
messages.value.push(assistantMsg)
|
messages.value.push(assistantMsg)
|
||||||
|
const idx = messages.value.length - 1
|
||||||
for await (const part of stream as any) {
|
for await (const part of stream as any) {
|
||||||
const delta: string = part?.choices?.[0]?.delta?.content ?? ''
|
const delta: string = part?.choices?.[0]?.delta?.content ?? ''
|
||||||
if (delta) assistantMsg.content += delta
|
if (delta) {
|
||||||
|
// 通过响应式代理更新内容,触发视图更新
|
||||||
|
messages.value[idx].content += delta
|
||||||
|
}
|
||||||
await nextTick(); scrollToBottom()
|
await nextTick(); scrollToBottom()
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
|||||||
12
src/main.ts
12
src/main.ts
@ -15,3 +15,15 @@ const router = createRouter({
|
|||||||
})
|
})
|
||||||
|
|
||||||
createApp(App).use(router).mount('#app')
|
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));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -23,3 +23,8 @@ body{
|
|||||||
.btn:hover {
|
.btn:hover {
|
||||||
background: #f3f4f6;
|
background: #f3f4f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
*{
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
@ -5,6 +5,7 @@ import UploadButton from '../components/UploadButton.vue'
|
|||||||
import { elements, type CircuitElement, type Preset } from './elements'
|
import { elements, type CircuitElement, type Preset } from './elements'
|
||||||
import { formatValue } from './utils'
|
import { formatValue } from './utils'
|
||||||
import { usePreview } from '../composables/usePreview'
|
import { usePreview } from '../composables/usePreview'
|
||||||
|
import { api } from '../api/client'
|
||||||
|
|
||||||
// 1) 左侧面板元件清单
|
// 1) 左侧面板元件清单
|
||||||
type PaletteItem = CircuitElement
|
type PaletteItem = CircuitElement
|
||||||
@ -984,6 +985,127 @@ function onImportFileChange(e: Event) {
|
|||||||
input.value = ''
|
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 助手的实时电路状态(每次访问都会序列化最新数据)
|
// 提供给 AI 助手的实时电路状态(每次访问都会序列化最新数据)
|
||||||
const aiState = computed(() => makeSave())
|
const aiState = computed(() => makeSave())
|
||||||
|
|
||||||
@ -1041,8 +1163,9 @@ function editTextBox(inst: Instance) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="file-actions">
|
<div class="file-actions">
|
||||||
<button class="btn" @click="onExportJSON">导出 JSON</button>
|
<!-- <button class="btn" @click="onExportJSON">导出 JSON</button>
|
||||||
<button class="btn" @click="triggerImport">导入 JSON</button>
|
<button class="btn" @click="triggerImport">导入 JSON</button> -->
|
||||||
|
<button class="btn" @click="openRecognition">实物电路识别</button>
|
||||||
<!-- 上传模型到模型广场(需登录) -->
|
<!-- 上传模型到模型广场(需登录) -->
|
||||||
<UploadButton :makeSave="makeSave" />
|
<UploadButton :makeSave="makeSave" />
|
||||||
<input ref="importInputRef" type="file" accept="application/json,.json" class="hidden-file"
|
<input ref="importInputRef" type="file" accept="application/json,.json" class="hidden-file"
|
||||||
@ -1248,6 +1371,76 @@ function editTextBox(inst: Instance) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</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>
|
</div>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
@ -1709,4 +1902,76 @@ a {
|
|||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
user-select: text;
|
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>
|
</style>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user