From 51767241723348b31e36fd0e9f80198c8bce3a33 Mon Sep 17 00:00:00 2001 From: feie9456 Date: Sat, 13 Sep 2025 12:56:18 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E7=99=BB=E5=BD=95=E6=B3=A8?= =?UTF-8?q?=E5=86=8C=E4=B8=8E=E6=A8=A1=E5=9E=8B=E5=A4=A7=E5=8E=85=E3=80=81?= =?UTF-8?q?=E9=A2=84=E8=A7=88=E8=A7=86=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bun.lock | 3 + package.json | 1 + src/App.vue | 86 ++++++++++++++++++- src/api/client.ts | 138 ++++++++++++++++++++++++++++++ src/components/UploadButton.vue | 147 ++++++++++++++++++++++++++++++++ src/composables/usePreview.ts | 14 +++ src/main.ts | 11 +-- src/store/auth.ts | 80 +++++++++++++++++ src/style.css | 15 +++- src/views/Home.vue | 4 +- src/views/Login.vue | 63 ++++++++++++++ src/views/ModelDetail.vue | 76 +++++++++++++++++ src/views/ModelList.vue | 78 +++++++++++++++++ src/views/VirtualLab.vue | 47 ++++++---- vite.config.ts | 7 +- 15 files changed, 741 insertions(+), 29 deletions(-) create mode 100644 src/api/client.ts create mode 100644 src/components/UploadButton.vue create mode 100644 src/composables/usePreview.ts create mode 100644 src/store/auth.ts create mode 100644 src/views/Login.vue create mode 100644 src/views/ModelDetail.vue create mode 100644 src/views/ModelList.vue 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 @@ + + + + + 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 @@ \ 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 @@ + + + + + \ 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 @@ + + + + + \ 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 @@ + + + + + \ 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 @@