diff --git a/bun.lock b/bun.lock
index 673e8a1..275f983 100644
--- a/bun.lock
+++ b/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=="],
diff --git a/package.json b/package.json
index 44c6019..898773d 100644
--- a/package.json
+++ b/package.json
@@ -9,6 +9,7 @@
"preview": "vite preview"
},
"dependencies": {
+ "@zumer/snapdom": "^1.9.11",
"openai": "^5.20.0",
"vue": "^3.5.18",
"vue-router": "4"
diff --git a/src/App.vue b/src/App.vue
index b7d7195..33a529b 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -1,3 +1,85 @@
-
-
\ No newline at end of file
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/api/client.ts b/src/api/client.ts
new file mode 100644
index 0000000..005bdc6
--- /dev/null
+++ b/src/api/client.ts
@@ -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 {
+ if (!basicToken) return {}
+ return { Authorization: `Basic ${basicToken}` }
+}
+
+async function handle(res: Response): Promise {
+ 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(res)
+ },
+ async me(basicToken?: string) {
+ const res = await fetch(`${API_BASE}/users/me`, {
+ headers: { ...buildAuthHeader(basicToken) },
+ })
+ return handle(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(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(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(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(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(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}`)
+}
diff --git a/src/components/UploadButton.vue b/src/components/UploadButton.vue
new file mode 100644
index 0000000..288626e
--- /dev/null
+++ b/src/components/UploadButton.vue
@@ -0,0 +1,147 @@
+
+
+
+ 上传模型
+
+
+
+
+
+
+
+ 标题
+
+
+
+ 描述
+
+
+
{{ error }}
+
+
+
+
+
+
+
diff --git a/src/composables/usePreview.ts b/src/composables/usePreview.ts
new file mode 100644
index 0000000..1e03ac1
--- /dev/null
+++ b/src/composables/usePreview.ts
@@ -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 }
+}
diff --git a/src/main.ts b/src/main.ts
index 8345d45..0fd0296 100644
--- a/src/main.ts
+++ b/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') },
]
})
diff --git a/src/store/auth.ts b/src/store/auth.ts
new file mode 100644
index 0000000..9854305
--- /dev/null
+++ b/src/store/auth.ts
@@ -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({
+ 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()
+}
diff --git a/src/style.css b/src/style.css
index ca50bb3..fbae52a 100644
--- a/src/style.css
+++ b/src/style.css
@@ -9,4 +9,17 @@ body{
@font-face {
font-family: "meter-font";
src: url('./assets/Jersey20-Regular.ttf') format('truetype');
-}
\ No newline at end of file
+}
+
+.btn {
+ padding: 6px 10px;
+ border: 1px solid #e5e7eb;
+ border-radius: 6px;
+ background: #fff;
+ color: #111827;
+ cursor: pointer;
+}
+
+.btn:hover {
+ background: #f3f4f6;
+}
diff --git a/src/views/Home.vue b/src/views/Home.vue
index 63cf83f..845db57 100644
--- a/src/views/Home.vue
+++ b/src/views/Home.vue
@@ -1,3 +1,5 @@
- Home
+
+
此页面已被“模型广场”替代。请使用顶部导航。
+
\ No newline at end of file
diff --git a/src/views/Login.vue b/src/views/Login.vue
new file mode 100644
index 0000000..f43fc9c
--- /dev/null
+++ b/src/views/Login.vue
@@ -0,0 +1,63 @@
+
+
+
+
+
+
{{ mode === 'login' ? '登录' : '注册' }}
+
+ 用户名
+
+
+
+ 密码
+
+
+
+ {{ auth.state.loading ? '提交中…' : (mode === 'login' ? '登录' : '注册并登录') }}
+
+
+
+ {{ mode === 'login' ? '没有账户?去注册' : '已有账户?去登录' }}
+
+
+
{{ auth.state.error }}
+
种子用户:admin / admin123
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/views/ModelDetail.vue b/src/views/ModelDetail.vue
new file mode 100644
index 0000000..dfd6ac6
--- /dev/null
+++ b/src/views/ModelDetail.vue
@@ -0,0 +1,76 @@
+
+
+
+
+
加载中…
+
{{ st.error }}
+
+
+
+
+
+ {{ JSON.stringify(st.model.model, null, 2) }}
+
+
+
+
+
\ No newline at end of file
diff --git a/src/views/ModelList.vue b/src/views/ModelList.vue
new file mode 100644
index 0000000..c1659e6
--- /dev/null
+++ b/src/views/ModelList.vue
@@ -0,0 +1,78 @@
+
+
+
+
+
+
+
+ 最新
+ 最早
+
+ 搜索
+
+
+
{{ st.error }}
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/views/VirtualLab.vue b/src/views/VirtualLab.vue
index d6351cc..7aaa992 100644
--- a/src/views/VirtualLab.vue
+++ b/src/views/VirtualLab.vue
@@ -1,10 +1,12 @@
-