实现登录注册与模型大厅、预览视图

This commit is contained in:
feie9456 2025-09-13 12:56:18 +08:00
parent 2d08c9f2d0
commit 5176724172
15 changed files with 741 additions and 29 deletions

View File

@ -4,6 +4,7 @@
"": {
"name": "circuit-virtual-lab",
"dependencies": {
"@zumer/snapdom": "^1.9.11",
"openai": "^5.20.0",
"vue": "^3.5.18",
"vue-router": "4",
@ -160,6 +161,8 @@
"@vue/tsconfig": ["@vue/tsconfig@0.7.0", "", { "peerDependencies": { "typescript": "5.x", "vue": "^3.4.0" }, "optionalPeers": ["typescript", "vue"] }, "sha512-ku2uNz5MaZ9IerPPUyOHzyjhXoX2kVJaVf7hL315DC17vS6IiZRmmCPfggNbU16QTvM80+uYYy3eYJB59WCtvg=="],
"@zumer/snapdom": ["@zumer/snapdom@1.9.11", "", {}, "sha512-7yEDRCz9AaksPaKEZxH6BlIZ2tpNN0/gI/8coCYgRIYE1ZIDQ8f5KsMPADtupcdj4t3et97+BP9AQYifGOvYdw=="],
"alien-signals": ["alien-signals@2.0.7", "", {}, "sha512-wE7y3jmYeb0+h6mr5BOovuqhFv22O/MV9j5p0ndJsa7z1zJNPGQ4ph5pQk/kTTCWRC3xsA4SmtwmkzQO+7NCNg=="],
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],

View File

@ -9,6 +9,7 @@
"preview": "vite preview"
},
"dependencies": {
"@zumer/snapdom": "^1.9.11",
"openai": "^5.20.0",
"vue": "^3.5.18",
"vue-router": "4"

View File

@ -1,3 +1,85 @@
<template>
<RouterView />
<div class="layout" :class="{ 'preview': isPreview }">
<!-- 在主页时显示预览模式隐藏 -->
<header class="topbar" v-if="$route.path === '/' && !isPreview">
<nav class="nav">
<RouterLink to="/">模型广场</RouterLink>
<RouterLink to="/lab">虚拟实验室</RouterLink>
</nav>
<div class="auth">
<template v-if="auth.state.me">
<span class="hello">你好{{ auth.state.me.username }}</span>
<button class="btn" @click="auth.logout()">登出</button>
</template>
<template v-else>
<RouterLink class="btn" to="/login">登录/注册</RouterLink>
</template>
</div>
</header>
<main class="page">
<RouterView />
</main>
</div>
</template>
<script setup lang="ts">
import { useAuth } from './store/auth'
import { usePreview } from './composables/usePreview'
const auth = useAuth()
const { isPreview } = usePreview()
</script>
<style scoped>
.layout {
display: grid;
grid-template-rows: 56px 1fr;
height: 100vh;
}
.layout.preview {
grid-template-rows: 0 1fr;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
border-bottom: 1px solid #e5e7eb;
background: #fff;
}
.nav {
display: flex;
gap: 12px;
}
.nav a {
color: #111827;
text-decoration: none;
padding: 6px 8px;
border-radius: 6px;
}
.nav a.router-link-active {
background: #f3f4f6;
}
.auth {
display: flex;
align-items: center;
gap: 10px;
}
.page {
height: calc(100vh - 56px);
}
.page.full {
height: 100vh;
}
.hello {
color: #374151;
}
</style>

138
src/api/client.ts Normal file
View File

@ -0,0 +1,138 @@
// Lightweight API client for Basic Auth backend
// Base URL can be configured via Vite env VITE_API_BASE, defaults to '/api'
export type User = {
id: string
username: string
}
export type Me = User & {
avatarUrl?: string
}
export type ModelSummary = {
id: string
title: string
desc?: string
authorId: string
authorName?: string
createdAt?: string
updatedAt?: string
}
export type ModelDetail = ModelSummary & {
model: any
}
export type ListModelsQuery = {
q?: string
authorId?: string
page?: number
pageSize?: number
sort?: 'new' | 'old'
}
const API_BASE: string = (typeof import.meta !== 'undefined' && (import.meta as any).env?.VITE_API_BASE) || '/api'
function buildAuthHeader(basicToken?: string): Record<string, string> {
if (!basicToken) return {}
return { Authorization: `Basic ${basicToken}` }
}
async function handle<T>(res: Response): Promise<T> {
if (!res.ok) {
let msg = res.status + ' ' + res.statusText
try {
const data = await res.json()
if (data && data.message) msg = data.message
} catch {}
throw new Error(msg)
}
const ct = res.headers.get('content-type') || ''
if (ct.includes('application/json')) return (await res.json()) as T
return (await res.text()) as unknown as T
}
export const api = {
base: API_BASE,
// Users
async register(username: string, password: string) {
const res = await fetch(`${API_BASE}/users/register`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username, password })
})
return handle<User>(res)
},
async me(basicToken?: string) {
const res = await fetch(`${API_BASE}/users/me`, {
headers: { ...buildAuthHeader(basicToken) },
})
return handle<Me>(res)
},
async updateMe(payload: { username?: string; avatarBase64?: string; avatarMime?: string }, basicToken?: string) {
const res = await fetch(`${API_BASE}/users/me`, {
method: 'PUT',
headers: { 'content-type': 'application/json', ...buildAuthHeader(basicToken) },
body: JSON.stringify(payload)
})
return handle<Me>(res)
},
avatarUrl(userId: string) {
return `${API_BASE}/users/${encodeURIComponent(userId)}/avatar`
},
// Models
async createModel(
payload: { title: string; desc?: string; model: any; previewBase64?: string; previewMime?: string },
basicToken?: string
) {
const res = await fetch(`${API_BASE}/models`, {
method: 'POST',
headers: { 'content-type': 'application/json', ...buildAuthHeader(basicToken) },
body: JSON.stringify(payload)
})
return handle<ModelDetail>(res)
},
async updateModel(id: string, payload: Partial<{ title: string; desc: string; model: any; previewBase64: string; previewMime: string }>, basicToken?: string) {
const res = await fetch(`${API_BASE}/models/${encodeURIComponent(id)}`, {
method: 'PUT',
headers: { 'content-type': 'application/json', ...buildAuthHeader(basicToken) },
body: JSON.stringify(payload)
})
return handle<ModelDetail>(res)
},
async deleteModel(id: string, basicToken?: string) {
const res = await fetch(`${API_BASE}/models/${encodeURIComponent(id)}`, {
method: 'DELETE',
headers: { ...buildAuthHeader(basicToken) },
})
return handle<{ ok: true }>(res)
},
async getModel(id: string) {
const res = await fetch(`${API_BASE}/models/${encodeURIComponent(id)}`)
return handle<ModelDetail>(res)
},
async listModels(query: ListModelsQuery = {}) {
const usp = new URLSearchParams()
if (query.q) usp.set('q', query.q)
if (query.authorId) usp.set('authorId', query.authorId)
if (query.page) usp.set('page', String(query.page))
if (query.pageSize) usp.set('pageSize', String(query.pageSize))
if (query.sort) usp.set('sort', query.sort)
const res = await fetch(`${API_BASE}/models${usp.toString() ? '?' + usp.toString() : ''}`)
return handle<{ items: ModelSummary[]; total?: number; page?: number; pageSize?: number }>(res)
},
async downloadModelJson(id: string) {
const res = await fetch(`${API_BASE}/models/${encodeURIComponent(id)}/model.json`)
return handle<any>(res)
},
previewUrl(id: string) {
return `${API_BASE}/models/${encodeURIComponent(id)}/preview`
},
}
export type BasicCredential = { username: string; password: string }
export function encodeBasic({ username, password }: BasicCredential) {
return btoa(`${username}:${password}`)
}

View File

@ -0,0 +1,147 @@
<script setup lang="ts">
import { useAuth } from '../store/auth'
import { api } from '../api/client'
import { ref } from 'vue'
import { useRouter } from 'vue-router'
const props = defineProps<{ makeSave: () => any }>()
const auth = useAuth()
const router = useRouter()
//
const showPanel = ref(false)
const title = ref('')
const desc = ref('')
const loading = ref(false)
const error = ref<string | null>(null)
function openUpload() {
if (!auth.state.basicToken) {
//
const redirect = router.currentRoute.value.fullPath
router.push({ path: '/login', query: { redirect } })
return
}
//
error.value = null
showPanel.value = true
}
function closePanel() {
if (loading.value) return
showPanel.value = false
}
async function submitUpload() {
if (!auth.state.basicToken) {
//
const redirect = router.currentRoute.value.fullPath
router.push({ path: '/login', query: { redirect } })
return
}
error.value = null
loading.value = true
try {
const model = props.makeSave()
await api.createModel({ title: title.value.trim() || '未命名模型', desc: desc.value.trim(), model }, auth.state.basicToken || undefined)
showPanel.value = false
alert('上传成功,前往“模型广场”查看。')
} catch (e: any) {
error.value = e?.message || String(e)
} finally {
loading.value = false
}
}
</script>
<template>
<button class="btn" @click="openUpload">上传模型</button>
<!-- 上传信息面板 -->
<div v-if="showPanel" class="upload-overlay" @click.self="closePanel">
<div class="upload-panel">
<div class="panel-header">
<h3>上传模型</h3>
<button class="icon-btn" title="关闭" @click="closePanel"></button>
</div>
<div class="panel-body">
<label class="field">
<span class="label">标题</span>
<input class="input" v-model="title" :disabled="loading" placeholder="请输入模型标题" />
</label>
<label class="field">
<span class="label">描述</span>
<textarea class="textarea" v-model="desc" :disabled="loading" placeholder="必填:介绍一下你的模型"></textarea>
</label>
<p v-if="error" class="error">{{ error }}</p>
</div>
<div class="panel-footer">
<button class="btn secondary" @click="closePanel" :disabled="loading">取消</button>
<button class="btn primary" @click="submitUpload" :disabled="!title || !desc || loading">
{{ loading ? '正在上传…' : '确认上传' }}
</button>
</div>
</div>
</div>
</template>
<style scoped>
.upload-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.upload-panel {
width: min(520px, 92vw);
background: #fff;
border-radius: 10px;
box-shadow: 0 12px 30px rgba(0,0,0,0.2);
display: flex;
flex-direction: column;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid #eee;
}
.panel-header h3 { margin: 0; font-size: 16px; }
.icon-btn {
border: none;
background: transparent;
font-size: 16px;
cursor: pointer;
color: #666;
}
.icon-btn:hover { color: #000; }
.panel-body { padding: 16px; }
.field { display: block; margin-bottom: 12px; }
.field .label { display: block; font-size: 12px; color: #666; margin-bottom: 6px; }
.input, .textarea {
width: 100%;
box-sizing: border-box;
padding: 10px 12px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
}
.textarea { min-height: 100px; resize: vertical; }
.error { color: #c00; font-size: 12px; margin: 8px 0 0; }
.panel-footer {
padding: 12px 16px;
display: flex;
gap: 10px;
justify-content: flex-end;
border-top: 1px solid #eee;
}
.btn.primary {
background: #3b82f6;
color: white;
}
.btn:disabled { opacity: 0.6; cursor: not-allowed; }
</style>

View File

@ -0,0 +1,14 @@
import { computed } from 'vue'
import { useRoute } from 'vue-router'
// Detects preview mode from the URL query: ?preview=true
// Usage: const { isPreview } = usePreview()
export function usePreview() {
const route = useRoute()
const isPreview = computed(() => {
const v = route.query.preview
if (Array.isArray(v)) return v.includes('true')
return String(v ?? '').toLowerCase() === 'true'
})
return { isPreview }
}

View File

@ -6,13 +6,10 @@ import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
component: () => import('./views/Home.vue')
}, {
path: '/lab',
component: () => import('./views/VirtualLab.vue')
}
{ path: '/', component: () => import('./views/ModelList.vue') },
{ path: '/models/:id', component: () => import('./views/ModelDetail.vue') },
{ path: '/lab', component: () => import('./views/VirtualLab.vue') },
{ path: '/login', component: () => import('./views/Login.vue') },
]
})

80
src/store/auth.ts Normal file
View File

@ -0,0 +1,80 @@
import { reactive, readonly } from 'vue'
import { api, encodeBasic, type Me } from '../api/client'
type State = {
me: Me | null
basicToken: string | null
loading: boolean
error: string | null
}
const KEY = 'cvln_basic_token'
const state = reactive<State>({
me: null,
basicToken: localStorage.getItem(KEY),
loading: false,
error: null,
})
async function refreshMe() {
if (!state.basicToken) { state.me = null; return }
state.loading = true; state.error = null
try {
state.me = await api.me(state.basicToken)
} catch (e: any) {
state.error = e?.message || String(e)
// token 失效则清理
state.basicToken = null
localStorage.removeItem(KEY)
state.me = null
} finally { state.loading = false }
}
async function login(username: string, password: string) {
state.loading = true; state.error = null
try {
const token = encodeBasic({ username, password })
state.basicToken = token
localStorage.setItem(KEY, token)
await refreshMe()
return true
} catch (e: any) {
state.error = e?.message || String(e)
logout()
return false
} finally { state.loading = false }
}
async function register(username: string, password: string) {
state.loading = true; state.error = null
try {
await api.register(username, password)
// 注册后直接登录
return await login(username, password)
} catch (e: any) {
state.error = e?.message || String(e)
return false
} finally { state.loading = false }
}
function logout() {
state.basicToken = null
localStorage.removeItem(KEY)
state.me = null
}
export function useAuth() {
return {
state: readonly(state),
login,
register,
logout,
refreshMe,
}
}
// try fetch me on module load
if (state.basicToken) {
refreshMe()
}

View File

@ -10,3 +10,16 @@ body{
font-family: "meter-font";
src: url('./assets/Jersey20-Regular.ttf') format('truetype');
}
.btn {
padding: 6px 10px;
border: 1px solid #e5e7eb;
border-radius: 6px;
background: #fff;
color: #111827;
cursor: pointer;
}
.btn:hover {
background: #f3f4f6;
}

View File

@ -1,3 +1,5 @@
<template>
<div>Home</div>
<div class="home-placeholder">
<p>此页面已被模型广场替代请使用顶部导航</p>
</div>
</template>

63
src/views/Login.vue Normal file
View File

@ -0,0 +1,63 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuth } from '../store/auth'
const auth = useAuth()
const router = useRouter()
const route = useRoute()
const mode = ref<'login' | 'register'>('login')
const username = ref('')
const password = ref('')
async function submit() {
if (!username.value || !password.value) return alert('请输入用户名和密码')
const ok = mode.value === 'login'
? await auth.login(username.value, password.value)
: await auth.register(username.value, password.value)
if (ok) {
const to = (route.query.redirect as string) || '/'
router.replace(to)
}
}
</script>
<template>
<div class="auth-page">
<div class="card">
<h1>{{ mode === 'login' ? '登录' : '注册' }}</h1>
<label>
<span>用户名</span>
<input v-model="username" placeholder="用户名" />
</label>
<label>
<span>密码</span>
<input v-model="password" type="password" placeholder="密码" />
</label>
<button class="btn" @click="submit" :disabled="auth.state.loading">
{{ auth.state.loading ? '提交中…' : (mode === 'login' ? '登录' : '注册并登录') }}
</button>
<p class="tip">
<button class="link" @click="mode = mode === 'login' ? 'register' : 'login'">
{{ mode === 'login' ? '没有账户?去注册' : '已有账户?去登录' }}
</button>
</p>
<p v-if="auth.state.error" class="error">{{ auth.state.error }}</p>
<p class="seed">种子用户admin / admin123</p>
</div>
</div>
</template>
<style scoped>
.auth-page{height:100%;display:flex;align-items:center;justify-content:center;background:#f9fafb}
.card{width:320px;display:flex;flex-direction:column;gap:10px;background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:16px}
label{display:flex;flex-direction:column;gap:6px}
input{padding:8px 10px;border:1px solid #e5e7eb;border-radius:8px}
.btn{padding:8px 10px;border:1px solid #e5e7eb;border-radius:8px;background:#111827;color:#fff}
.link{border:none;background:none;color:#2563eb;cursor:pointer}
.tip{margin:4px 0 0 0}
.error{color:#b91c1c}
.seed{color:#6b7280;font-size:12px}
</style>

76
src/views/ModelDetail.vue Normal file
View File

@ -0,0 +1,76 @@
<script setup lang="ts">
import { onMounted, reactive, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api, type ModelDetail as MD } from '../api/client'
const route = useRoute()
const router = useRouter()
const st = reactive<{ model: MD | null; loading: boolean; error: string | null }>({ model: null, loading: false, error: null })
const id = computed(() => String(route.params.id))
async function load() {
st.loading = true; st.error = null
try {
st.model = await api.getModel(id.value)
} catch (e: any) {
st.error = e?.message || String(e)
} finally { st.loading = false }
}
async function downloadJson() {
try {
const content = await api.downloadModelJson(id.value)
const blob = new Blob([JSON.stringify(content, null, 2)], { type: 'application/json;charset=utf-8' })
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = (st.model?.title || 'model') + '.json'
a.click()
URL.revokeObjectURL(a.href)
} catch (e: any) { alert(e?.message || String(e)) }
}
async function importToLab() {
try {
const content = await api.downloadModelJson(id.value)
sessionStorage.setItem('cvln_import_json', JSON.stringify(content))
router.push('/lab')
} catch (e: any) { alert(e?.message || String(e)) }
}
onMounted(load)
</script>
<template>
<div class="detail-page">
<div v-if="st.loading">加载中</div>
<div v-else-if="st.error" class="error">{{ st.error }}</div>
<template v-else-if="st.model">
<div class="head">
<img :src="api.previewUrl(st.model.id)" alt="preview" />
<div class="meta">
<h1>{{ st.model.title }}</h1>
<p>{{ st.model.desc }}</p>
<div class="ops">
<button class="btn" @click="downloadJson">下载 JSON</button>
<button class="btn primary" @click="importToLab">导入到实验室</button>
</div>
</div>
</div>
<pre class="json">{{ JSON.stringify(st.model.model, null, 2) }}</pre>
</template>
</div>
</template>
<style scoped>
.detail-page{padding:12px;height:100%;overflow:auto;box-sizing:border-box}
.head{display:flex;gap:12px}
.head img{width:280px;height:180px;object-fit:cover;background:#f3f4f6;border-radius:8px;border:1px solid #e5e7eb}
.meta h1{margin:0 0 6px 0}
.ops{display:flex;gap:8px;margin-top:8px}
.btn{padding:6px 10px;border:1px solid #e5e7eb;border-radius:8px;background:#fff}
.btn.primary{background:#111827;color:#fff}
.json{margin-top:12px;background:#0b1020;color:#e5e7eb;padding:12px;border-radius:8px;overflow:auto;max-height:40vh}
.error{color:#b91c1c}
</style>

78
src/views/ModelList.vue Normal file
View File

@ -0,0 +1,78 @@
<script setup lang="ts">
import { onMounted, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { api, type ModelSummary } from '../api/client'
const router = useRouter()
const st = reactive({
q: '',
sort: 'new' as 'new' | 'old',
page: 1,
pageSize: 20,
total: 0,
items: [] as ModelSummary[],
loading: false,
error: '' as string | null,
})
async function load() {
st.loading = true; st.error = null
try {
const res = await api.listModels({ q: st.q || undefined, page: st.page, pageSize: st.pageSize, sort: st.sort })
st.items = res.items || []
st.total = res.total || st.items.length
} catch (e: any) {
st.error = e?.message || String(e)
} finally { st.loading = false }
}
function goDetail(id: string) { router.push(`/models/${id}`) }
onMounted(load)
</script>
<template>
<div class="list-page">
<div class="toolbar">
<input v-model="st.q" placeholder="搜索标题/描述" @keydown.enter="st.page=1; load()" />
<select v-model="st.sort" @change="st.page=1; load()">
<option value="new">最新</option>
<option value="old">最早</option>
</select>
<button class="btn" @click="st.page=1; load()" :disabled="st.loading">搜索</button>
</div>
<div v-if="st.error" class="error">{{ st.error }}</div>
<div v-else class="grid">
<div v-for="m in st.items" :key="m.id" class="card" @click="goDetail(m.id)">
<img :src="api.previewUrl(m.id)" alt="preview" />
<div class="meta">
<h3>{{ m.title }}</h3>
<p>{{ m.desc }}</p>
</div>
</div>
</div>
<div class="pager" v-if="st.total > st.pageSize">
<button :disabled="st.page<=1" @click="st.page--; load()">上一页</button>
<span> {{ st.page }} </span>
<button :disabled="st.page*st.pageSize>=st.total" @click="st.page++; load()">下一页</button>
</div>
</div>
</template>
<style scoped>
.list-page{padding:12px;height:100%;box-sizing:border-box;overflow:auto}
.toolbar{display:flex;gap:8px;margin-bottom:12px}
input,select{padding:6px 8px;border:1px solid #e5e7eb;border-radius:8px}
.btn{padding:6px 10px;border:1px solid #e5e7eb;border-radius:8px;background:#fff}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px}
.card{border:1px solid #e5e7eb;border-radius:10px;overflow:hidden;cursor:pointer;background:#fff;display:flex;flex-direction:column}
.card img{width:100%;height:140px;object-fit:cover;background:#f3f4f6}
.meta{padding:10px}
.meta h3{margin:0 0 6px 0;font-size:14px}
.meta p{margin:0;color:#6b7280;font-size:12px}
.pager{display:flex;gap:10px;align-items:center;justify-content:center;margin-top:12px}
.error{color:#b91c1c}
</style>

View File

@ -1,10 +1,12 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watchEffect } from 'vue'
import AIAssistant from '../components/AIAssistant.vue'
import UploadButton from '../components/UploadButton.vue'
import { elements, type CircuitElement, type Preset } from './elements'
import { formatValue } from './utils'
import { usePreview } from '../composables/usePreview'
// 1)
// 1)
type PaletteItem = CircuitElement
const palette: PaletteItem[] = [...elements]
@ -13,8 +15,10 @@ const viewportRef = ref<HTMLDivElement | null>(null)
const worldRef = ref<HTMLDivElement | null>(null)
// AI
const { isPreview } = usePreview()
const showAI = ref(true)
const state = reactive({
scale: 1,
minScale: 0.2,
@ -748,6 +752,14 @@ function centerWorld() {
onMounted(() => {
centerWorld()
//
try {
const txt = sessionStorage.getItem('cvln_import_json')
if (txt) {
sessionStorage.removeItem('cvln_import_json')
loadFromJSONText(txt)
}
} catch { }
})
// 9) / JSON
@ -1015,9 +1027,14 @@ function editTextBox(inst: Instance) {
</script>
<template>
<div class="app" :style="{ '--ai-width': showAI ? '360px' : '0px' }">
<div class="app" :style="{ '--ai-width': showAI ? '360px' : '0px' }"
:class="{ 'preview-mode': isPreview }">
<!-- 左侧元件面板 -->
<aside class="sidebar">
<aside class="sidebar" v-if="!isPreview">
<RouterLink class="back-to-home" to="/">
<button class="btn">回到大厅</button>
</RouterLink>
<h2>元件</h2>
<div class="palette">
<div v-for="item in palette" :key="item.key" class="palette-item"
@ -1031,6 +1048,8 @@ function editTextBox(inst: Instance) {
<div class="file-actions">
<button class="btn" @click="onExportJSON">导出 JSON</button>
<button class="btn" @click="triggerImport">导入 JSON</button>
<!-- 上传模型到模型广场需登录 -->
<UploadButton :makeSave="makeSave" />
<input ref="importInputRef" type="file" accept="application/json,.json"
class="hidden-file" @change="onImportFileChange" />
</div>
@ -1170,11 +1189,11 @@ function editTextBox(inst: Instance) {
</main>
<!-- 右侧 AI 助手面板 -->
<aside v-if="showAI" class="ai-aside">
<aside v-if="!isPreview && showAI" class="ai-aside">
<AIAssistant :state="aiState" @error="onAiError"
@close="showAI = false" />
</aside>
<button v-else class="ai-reopen" title="打开 AI 助手"
<button v-else-if="!isPreview" class="ai-reopen" title="打开 AI 助手"
@click="showAI = true"></button>
<!-- 底部属性面板编辑选中元件的业务属性非尺寸 -->
@ -1278,6 +1297,10 @@ function editTextBox(inst: Instance) {
user-select: none;
}
.app.preview-mode {
grid-template-columns: 1fr;
}
.sidebar {
border-right: 1px solid #e5e7eb;
padding: 12px;
@ -1289,21 +1312,13 @@ function editTextBox(inst: Instance) {
.file-actions {
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
}
.file-actions .btn {
padding: 6px 10px;
border: 1px solid #e5e7eb;
border-radius: 6px;
background: #fff;
color: #111827;
cursor: pointer;
}
.file-actions .btn:hover {
background: #f3f4f6;
a {
text-decoration: none;
}
.hidden-file {

View File

@ -4,7 +4,10 @@ import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
server:{
host: '0.0.0.0'
server: {
host: '0.0.0.0',
proxy: {
'/api': 'http://localhost:3000',
}
}
})