feat: scaffold FastAPI backend and Vue 3 frontend
Backend (backend/):
- FastAPI app with layered app/{api,core,crud,db,models,schemas} structure
- TiDB connection via SQLAlchemy. The URL is built with URL.create rather
than string formatting: the password contains '@', which would otherwise be
parsed as the user/host separator and silently truncate the credential.
- Alembic environment wired to Base.metadata and the app settings, so
backend/.env stays the single source of truth for credentials
- /api/health endpoint reporting database reachability
- models/ and crud/ are intentionally empty: no model classes are defined and
no tables are created, at import time or otherwise
Frontend (frontend/):
- Vue 3 + Vite + TypeScript scaffold (create-vue, --bare)
- axios instance with a normalized error shape
- vue-router with a home route and a catch-all 404
- pinia + pinia-plugin-persistedstate; the app store persists selected keys
- Element Plus with its icons registered globally
- dev proxy forwards /api to the FastAPI service on port 8000
The database paper_doc was created in TiDB out of band; no table exists yet.
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterLink, RouterView } from 'vue-router'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
const appStore = useAppStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-container class="app-shell">
|
||||
<el-header class="app-header">
|
||||
<div class="brand">paper-doc</div>
|
||||
<el-menu mode="horizontal" :ellipsis="false" router class="app-nav">
|
||||
<el-menu-item index="/">Home</el-menu-item>
|
||||
</el-menu>
|
||||
<el-button link @click="appStore.toggleSidebar()">
|
||||
{{ appStore.sidebarCollapsed ? 'Expand' : 'Collapse' }}
|
||||
</el-button>
|
||||
</el-header>
|
||||
|
||||
<el-main>
|
||||
<RouterView />
|
||||
</el-main>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-shell {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
border-bottom: 1px solid var(--el-border-color);
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.app-nav {
|
||||
flex: 1;
|
||||
border-bottom: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,48 @@
|
||||
import axios, { AxiosError, type AxiosInstance } from 'axios'
|
||||
|
||||
/**
|
||||
* Shared axios instance.
|
||||
*
|
||||
* `baseURL` defaults to the same-origin `/api` prefix, which Vite proxies to
|
||||
* the FastAPI service in development (see vite.config.ts). Point
|
||||
* `VITE_API_BASE_URL` at an absolute URL to bypass the proxy.
|
||||
*/
|
||||
export const http: AxiosInstance = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL ?? '/api',
|
||||
timeout: 15000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
/** Normalized failure shape, so views never have to inspect AxiosError. */
|
||||
export interface ApiError {
|
||||
message: string
|
||||
status?: number
|
||||
detail?: unknown
|
||||
}
|
||||
|
||||
function normalizeError(error: AxiosError): ApiError {
|
||||
const status = error.response?.status
|
||||
const payload = error.response?.data as { detail?: unknown } | undefined
|
||||
|
||||
let message: string
|
||||
if (payload?.detail && typeof payload.detail === 'string') {
|
||||
message = payload.detail
|
||||
} else if (error.code === 'ECONNABORTED') {
|
||||
message = 'The request timed out.'
|
||||
} else if (!error.response) {
|
||||
message = 'Could not reach the API. Is the backend running on port 8000?'
|
||||
} else {
|
||||
message = error.message
|
||||
}
|
||||
|
||||
return { message, status, detail: payload?.detail }
|
||||
}
|
||||
|
||||
http.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error: AxiosError) => Promise.reject(normalizeError(error)),
|
||||
)
|
||||
|
||||
export default http
|
||||
@@ -0,0 +1,15 @@
|
||||
import http from './client'
|
||||
|
||||
/** Mirrors `app.schemas.health.HealthResponse` on the backend. */
|
||||
export interface HealthResponse {
|
||||
status: string
|
||||
app: string
|
||||
database: string
|
||||
database_target: string
|
||||
}
|
||||
|
||||
/** Probe the backend, including its TiDB connection. */
|
||||
export async function fetchHealth(): Promise<HealthResponse> {
|
||||
const { data } = await http.get<HealthResponse>('/health')
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
|
||||
import ElementPlus from 'element-plus'
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||
|
||||
import 'element-plus/dist/index.css'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
const pinia = createPinia()
|
||||
// Registered once here; individual stores opt in with the `persist` option.
|
||||
pinia.use(piniaPluginPersistedstate)
|
||||
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
app.use(ElementPlus)
|
||||
|
||||
// Element Plus icons are components, not part of the plugin, so they are
|
||||
// registered globally to keep templates free of per-file imports.
|
||||
for (const [name, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(name, component)
|
||||
}
|
||||
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,21 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
component: () => import('@/views/HomeView.vue'),
|
||||
meta: { title: 'Home' },
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'not-found',
|
||||
component: () => import('@/views/NotFoundView.vue'),
|
||||
meta: { title: 'Not found' },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,33 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
/**
|
||||
* UI preferences that should survive a page reload.
|
||||
*
|
||||
* Persistence is opt-in per store via the `persist` option; the plugin itself
|
||||
* is registered in `main.ts`. `pick` limits what is written to storage, so
|
||||
* transient server data (like the last health probe) is never persisted.
|
||||
*/
|
||||
export const useAppStore = defineStore(
|
||||
'app',
|
||||
() => {
|
||||
const sidebarCollapsed = ref(false)
|
||||
const lastCheckedAt = ref<string | null>(null)
|
||||
|
||||
function toggleSidebar(): void {
|
||||
sidebarCollapsed.value = !sidebarCollapsed.value
|
||||
}
|
||||
|
||||
function markChecked(): void {
|
||||
lastCheckedAt.value = new Date().toISOString()
|
||||
}
|
||||
|
||||
return { sidebarCollapsed, lastCheckedAt, toggleSidebar, markChecked }
|
||||
},
|
||||
{
|
||||
persist: {
|
||||
key: 'paper-doc:app',
|
||||
pick: ['sidebarCollapsed', 'lastCheckedAt'],
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,100 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
import { fetchHealth, type HealthResponse } from '@/api/health'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
const appStore = useAppStore()
|
||||
|
||||
const health = ref<HealthResponse | null>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
const databaseTagType = computed(() => (health.value?.database === 'ok' ? 'success' : 'danger'))
|
||||
|
||||
async function loadHealth(): Promise<void> {
|
||||
loading.value = true
|
||||
try {
|
||||
health.value = await fetchHealth()
|
||||
appStore.markChecked()
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
ElMessage.error(message)
|
||||
health.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadHealth)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="home">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>Backend connectivity</span>
|
||||
<el-button :icon="Refresh" :loading="loading" @click="loadHealth">Re-check</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-descriptions v-if="health" :column="1" border>
|
||||
<el-descriptions-item label="API">
|
||||
{{ health.app }}
|
||||
<el-tag type="success" size="small">{{ health.status }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="TiDB">
|
||||
<el-tag :type="databaseTagType" size="small">{{ health.database }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="Target">
|
||||
<code>{{ health.database_target }}</code>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<el-empty v-else description="No response from the API yet" />
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>Persisted state</span>
|
||||
<el-switch
|
||||
v-model="appStore.sidebarCollapsed"
|
||||
active-text="Sidebar collapsed"
|
||||
inline-prompt
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<p>
|
||||
Last probe:
|
||||
<strong>{{ appStore.lastCheckedAt ?? 'never' }}</strong>
|
||||
</p>
|
||||
<p class="hint">
|
||||
Both values are stored in <code>localStorage</code> under
|
||||
<code>paper-doc:app</code>. Reload the page — they survive.
|
||||
</p>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.home {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-result icon="warning" title="404" sub-title="This page does not exist.">
|
||||
<template #extra>
|
||||
<el-button type="primary" @click="router.push('/')">Back to home</el-button>
|
||||
</template>
|
||||
</el-result>
|
||||
</template>
|
||||
Reference in New Issue
Block a user