实现登录注册与模型大厅、预览视图
This commit is contained in:
parent
2d08c9f2d0
commit
5176724172
3
bun.lock
3
bun.lock
@ -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=="],
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@zumer/snapdom": "^1.9.11",
|
||||
"openai": "^5.20.0",
|
||||
"vue": "^3.5.18",
|
||||
"vue-router": "4"
|
||||
|
||||
84
src/App.vue
84
src/App.vue
@ -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
138
src/api/client.ts
Normal 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}`)
|
||||
}
|
||||
147
src/components/UploadButton.vue
Normal file
147
src/components/UploadButton.vue
Normal 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>
|
||||
14
src/composables/usePreview.ts
Normal file
14
src/composables/usePreview.ts
Normal 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 }
|
||||
}
|
||||
11
src/main.ts
11
src/main.ts
@ -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
80
src/store/auth.ts
Normal 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()
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
<template>
|
||||
<div>Home</div>
|
||||
<div class="home-placeholder">
|
||||
<p>此页面已被“模型广场”替代。请使用顶部导航。</p>
|
||||
</div>
|
||||
</template>
|
||||
63
src/views/Login.vue
Normal file
63
src/views/Login.vue
Normal 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
76
src/views/ModelDetail.vue
Normal 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
78
src/views/ModelList.vue
Normal 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>
|
||||
@ -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 {
|
||||
|
||||
@ -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',
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user