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:
2026-09-17 12:51:37 +08:00
parent 4438b04ff1
commit 5128e1551c
43 changed files with 3321 additions and 0 deletions
+47
View File
@@ -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>
+48
View File
@@ -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
+15
View File
@@ -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
}
+28
View File
@@ -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')
+21
View File
@@ -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
+33
View File
@@ -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'],
},
},
)
+100
View File
@@ -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>
+13
View File
@@ -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>