Initial commit: Happy Up monorepo through Sprint 5.

Document-driven MVP with FastAPI backend, Vue H5, WeChat mini shell, product demo, and Docker dev stack.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-07-23 11:42:40 +08:00
commit 1aaef71f52
116 changed files with 10550 additions and 0 deletions
+32
View File
@@ -0,0 +1,32 @@
# Happy Up Web (H5)
Vue 3 + Vite 移动端 H5,对接 FastAPI 后端。Sprint 5 起四 Tab 导航对齐产品 Demo 配色。
## 开发
```bash
# 根目录先启动 API
make up && make api-seed && make api-dev
# 另开终端
cd apps/web
npm install
npm run dev
```
访问 http://localhost:5173
- 登录验证码:`682139`
- 代理:`/api``http://127.0.0.1:8000`
## 页面
| 路由 | 说明 |
|------|------|
| `/login` | 手机验证码登录 |
| `/` | 儿童档案列表 |
| `/screening` | 直传 + 分析 + 报告联调 |
| `/reports` | 报告列表 + PDF 下载 |
| `/training` | 训练计划列表 |
小程序壳见 `apps/mini/`
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0" />
<title>Happy Up · 儿童体态管理</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+21
View File
@@ -0,0 +1,21 @@
{
"name": "happy-up-web",
"private": true,
"version": "0.5.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"typescript": "~5.7.2",
"vite": "^6.0.7",
"vue-tsc": "^2.2.0"
}
}
+37
View File
@@ -0,0 +1,37 @@
<template>
<div class="app-shell">
<header class="topbar">
<strong>Happy Up</strong>
<span class="stage">Sprint 5 H5</span>
</header>
<div class="page">
<RouterView />
</div>
<nav v-if="showNav" class="bottom-nav">
<RouterLink to="/" :class="{ active: route.path === '/' }">
<span class="dot"></span>
首页
</RouterLink>
<RouterLink to="/screening" :class="{ active: route.path === '/screening' }">
<span class="dot"></span>
筛查
</RouterLink>
<RouterLink to="/reports" :class="{ active: route.path === '/reports' }">
<span class="dot"></span>
报告
</RouterLink>
<RouterLink to="/training" :class="{ active: route.path === '/training' }">
<span class="dot"></span>
训练
</RouterLink>
</nav>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const showNav = computed(() => route.path !== '/login')
</script>
+161
View File
@@ -0,0 +1,161 @@
const TOKEN_KEY = 'happy_up_token'
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY)
}
export function setToken(token: string) {
localStorage.setItem(TOKEN_KEY, token)
}
export function clearToken() {
localStorage.removeItem(TOKEN_KEY)
}
type ApiResponse<T> = {
code: number
message: string
data: T
}
export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
const headers = new Headers(init.headers)
if (!headers.has('Content-Type') && init.body) {
headers.set('Content-Type', 'application/json')
}
const token = getToken()
if (token) {
headers.set('Authorization', `Bearer ${token}`)
}
const resp = await fetch(path, { ...init, headers })
const json = (await resp.json()) as ApiResponse<T>
if (!resp.ok || json.code !== 0) {
throw new Error(json.message || `HTTP ${resp.status}`)
}
return json.data
}
export async function login(phone: string, code: string) {
const data = await apiFetch<{ token: string }>('/api/auth/login', {
method: 'POST',
body: JSON.stringify({
loginType: 'phone_code',
credential: phone,
code,
}),
})
setToken(data.token)
return data
}
export type Child = {
id: number
name: string
birthday: string
gender: string
}
export async function listChildren() {
return apiFetch<{ list: Child[] }>('/api/children')
}
export async function createUploadToken(childId: number) {
return apiFetch<{
uploadUrl: string
objectKey: string
uploadToken: string
storage: string
method: string
}>('/api/videos/upload-token', {
method: 'POST',
body: JSON.stringify({
childId,
fileName: 'screening.mp4',
contentType: 'video/mp4',
size: 2048,
}),
})
}
export async function directUpload(
uploadUrl: string,
uploadToken: string,
objectKey: string,
blob: Blob,
) {
const resp = await fetch(uploadUrl, {
method: 'PUT',
headers: {
'Content-Type': 'video/mp4',
'X-Upload-Token': uploadToken,
'X-Object-Key': objectKey,
},
body: blob,
})
if (!resp.ok) {
throw new Error('直传失败')
}
}
export async function registerVideo(childId: number, objectKey: string) {
return apiFetch<{ id: number }>('/api/videos', {
method: 'POST',
body: JSON.stringify({
childId,
objectKey,
scene: 'front_posture',
}),
})
}
export async function createAnalysisTask(childId: number, videoId: number) {
return apiFetch<{ id: number; status: string; reportId?: number }>('/api/analysis/tasks', {
method: 'POST',
headers: { 'Idempotency-Key': `h5-${Date.now()}` },
body: JSON.stringify({
childId,
videoId,
taskType: 'posture_screening',
}),
})
}
export async function getReport(reportId: number) {
return apiFetch<{
id?: number
summary: string
riskLevel: string
metrics: Array<{ name: string; level: string; value: number }>
recommendations?: string[]
}>(`/api/reports/${reportId}`)
}
export async function listReports(childId: number) {
return apiFetch<{ list: Array<{ id: number; summary: string; riskLevel: string }> }>(
`/api/reports?childId=${childId}`,
)
}
export function reportPdfUrl(reportId: number) {
return `/api/reports/${reportId}/pdf`
}
export async function listTrainingPlans(childId: number) {
return apiFetch<{
list: Array<{
id: number
status: string
detail: { goal: string; cycleDays: number; currentDay: number; exercises: Array<{ name: string }> }
}>
}>(`/api/training/plans?childId=${childId}`)
}
export function saveLastReportId(reportId: number) {
sessionStorage.setItem('happy_up_last_report', String(reportId))
}
export function getLastReportId(): number | null {
const raw = sessionStorage.getItem('happy_up_last_report')
return raw ? Number(raw) : null
}
+6
View File
@@ -0,0 +1,6 @@
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import './style.css'
createApp(App).use(router).mount('#app')
+26
View File
@@ -0,0 +1,26 @@
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
import LoginView from '../views/LoginView.vue'
import ReportView from '../views/ReportView.vue'
import ScreeningView from '../views/ScreeningView.vue'
import TrainingView from '../views/TrainingView.vue'
import { getToken } from '../api'
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/login', component: LoginView },
{ path: '/', component: HomeView },
{ path: '/screening', component: ScreeningView },
{ path: '/reports', component: ReportView },
{ path: '/training', component: TrainingView },
],
})
router.beforeEach((to) => {
if (to.path !== '/login' && !getToken()) {
return '/login'
}
})
export default router
+191
View File
@@ -0,0 +1,191 @@
:root {
--primary: #1a6fb5;
--primary-dark: #0f4f86;
--accent: #2bb673;
--accent-soft: #e8f7ef;
--warn: #f59e0b;
--danger: #ef4444;
--bg: #eef2f7;
--surface: #fff;
--text: #1f2937;
--muted: #6b7280;
--border: #e5e7eb;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', sans-serif;
color: var(--text);
background: var(--bg);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: linear-gradient(160deg, #0f4f86 0%, #1a6fb5 50%, #2bb673 100%);
min-height: 100vh;
}
.app-shell {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.topbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
color: #fff;
}
.stage {
font-size: 11px;
background: rgba(255, 255, 255, 0.2);
padding: 4px 10px;
border-radius: 999px;
}
.page {
flex: 1;
padding: 12px 16px 88px;
max-width: 480px;
width: 100%;
margin: 0 auto;
}
.card {
background: var(--surface);
border-radius: 16px;
padding: 16px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
margin-bottom: 12px;
}
.card h2 {
margin: 0 0 8px;
font-size: 18px;
}
.btn {
width: 100%;
border: none;
border-radius: 12px;
padding: 12px;
font-size: 15px;
background: var(--primary);
color: #fff;
cursor: pointer;
}
.btn.secondary {
background: var(--accent-soft);
color: var(--primary-dark);
}
.btn:disabled {
opacity: 0.6;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 12px;
}
.field input {
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 10px;
}
.list-item {
padding: 12px 0;
border-bottom: 1px solid var(--border);
}
.list-item:last-child {
border-bottom: none;
}
.muted {
color: var(--muted);
font-size: 13px;
}
.progress {
height: 8px;
background: #e5e7eb;
border-radius: 999px;
overflow: hidden;
margin: 12px 0;
}
.progress > span {
display: block;
height: 100%;
background: var(--accent);
transition: width 0.3s ease;
}
.error {
color: var(--danger);
font-size: 14px;
}
.metric-row {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px dashed var(--border);
}
.risk-badge {
display: inline-block;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
background: var(--accent-soft);
color: var(--primary-dark);
}
.bottom-nav {
position: fixed;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.96);
border-top: 1px solid var(--border);
display: flex;
justify-content: space-around;
padding: 8px 0 calc(8px + env(safe-area-inset-bottom));
backdrop-filter: blur(8px);
}
.bottom-nav a {
text-decoration: none;
color: var(--muted);
font-size: 12px;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
min-width: 56px;
}
.bottom-nav a.active {
color: var(--primary);
font-weight: 600;
}
.bottom-nav .dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: transparent;
}
.bottom-nav a.active .dot {
background: var(--accent);
}
+44
View File
@@ -0,0 +1,44 @@
<template>
<section class="card">
<h2>儿童档案</h2>
<p v-if="loading" class="muted">加载中</p>
<p v-else-if="error" class="error">{{ error }}</p>
<div v-else>
<div v-for="child in children" :key="child.id" class="list-item">
<strong>{{ child.name }}</strong>
<div class="muted">{{ child.birthday }} · {{ child.gender }}</div>
</div>
<button class="btn" style="margin-top: 16px" @click="goScreening">开始筛查</button>
</div>
</section>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { listChildren, type Child } from '../api'
const router = useRouter()
const children = ref<Child[]>([])
const loading = ref(true)
const error = ref('')
onMounted(async () => {
try {
const data = await listChildren()
children.value = data.list
} catch (err) {
error.value = err instanceof Error ? err.message : '加载失败'
} finally {
loading.value = false
}
})
function goScreening() {
if (!children.value.length) {
error.value = '请先在后端 seed 或 Demo 中创建儿童档案'
return
}
router.push('/screening')
}
</script>
+41
View File
@@ -0,0 +1,41 @@
<template>
<section class="card">
<h2>登录</h2>
<p class="muted">开发环境验证码682139</p>
<label class="field">
<span>手机号</span>
<input v-model="phone" type="tel" placeholder="18600000000" />
</label>
<label class="field">
<span>验证码</span>
<input v-model="code" type="text" placeholder="682139" />
</label>
<p v-if="error" class="error">{{ error }}</p>
<button class="btn" :disabled="loading" @click="submit">登录</button>
</section>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { login } from '../api'
const router = useRouter()
const phone = ref('18600000000')
const code = ref('682139')
const loading = ref(false)
const error = ref('')
async function submit() {
loading.value = true
error.value = ''
try {
await login(phone.value, code.value)
router.push('/')
} catch (err) {
error.value = err instanceof Error ? err.message : '登录失败'
} finally {
loading.value = false
}
}
</script>
+77
View File
@@ -0,0 +1,77 @@
<template>
<section class="card">
<h2>报告中心</h2>
<p v-if="loading" class="muted">加载中</p>
<p v-else-if="error" class="error">{{ error }}</p>
<template v-else>
<div v-for="item in reports" :key="item.id" class="list-item">
<strong>#{{ item.id }}</strong>
<p>{{ item.summary }}</p>
<p class="muted">风险{{ riskLabel(item.riskLevel) }}</p>
<button class="btn secondary" style="margin-top: 8px" @click="downloadPdf(item.id)">
下载 PDF
</button>
</div>
<p v-if="!reports.length" class="muted">暂无报告请先在筛查页完成一次分析</p>
</template>
</section>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { getLastReportId, getToken, listChildren, listReports, reportPdfUrl } from '../api'
const reports = ref<Array<{ id: number; summary: string; riskLevel: string }>>([])
const loading = ref(true)
const error = ref('')
const riskMap: Record<string, string> = {
low: '正常',
medium: '中度关注',
high: '高度关注',
normal: '良好',
}
function riskLabel(level: string) {
return riskMap[level] || level
}
async function downloadPdf(reportId: number) {
const token = getToken()
const resp = await fetch(reportPdfUrl(reportId), {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
if (!resp.ok) {
error.value = 'PDF 下载失败'
return
}
const blob = await resp.blob()
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `happy-up-report-${reportId}.pdf`
link.click()
URL.revokeObjectURL(url)
}
onMounted(async () => {
try {
const { list } = await listChildren()
const child = list[0]
if (!child) {
reports.value = []
return
}
const data = await listReports(child.id)
reports.value = data.list
const lastId = getLastReportId()
if (lastId && !reports.value.some((item) => item.id === lastId)) {
reports.value.unshift({ id: lastId, summary: '最近一次筛查报告', riskLevel: 'medium' })
}
} catch (err) {
error.value = err instanceof Error ? err.message : '加载失败'
} finally {
loading.value = false
}
})
</script>
+95
View File
@@ -0,0 +1,95 @@
<template>
<section class="card">
<h2>筛查流程</h2>
<p class="muted">{{ statusText }}</p>
<div class="progress"><span :style="{ width: progress + '%' }"></span></div>
<div v-if="report" class="list-item">
<span class="risk-badge">{{ riskLabel }}</span>
<p style="margin-top: 8px">{{ report.summary }}</p>
<div v-for="metric in report.metrics" :key="metric.name" class="metric-row">
<span>{{ metric.name }}</span>
<span>{{ metric.level }} · {{ metric.value }}%</span>
</div>
<button class="btn secondary" style="margin-top: 12px" @click="goReports">查看报告页</button>
</div>
<button class="btn" :disabled="running" @click="runFlow">一键联调模拟上传+分析</button>
</section>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import {
createAnalysisTask,
createUploadToken,
directUpload,
getReport,
listChildren,
registerVideo,
saveLastReportId,
} from '../api'
const router = useRouter()
const progress = ref(0)
const statusText = ref('准备就绪')
const running = ref(false)
const report = ref<Awaited<ReturnType<typeof getReport>> | null>(null)
const lastReportId = ref<number | null>(null)
const riskMap: Record<string, string> = {
low: '正常',
medium: '中度关注',
high: '高度关注',
normal: '良好',
}
const riskLabel = ref('')
async function runFlow() {
running.value = true
report.value = null
progress.value = 10
statusText.value = '读取儿童档案…'
try {
const { list } = await listChildren()
const child = list[0]
if (!child) throw new Error('暂无儿童档案')
progress.value = 30
statusText.value = '获取上传凭证…'
const token = await createUploadToken(child.id)
if (token.storage === 'local') {
progress.value = 50
statusText.value = '直传本地存储…'
const blob = new Blob([new Uint8Array([0, 0, 0, 24, 102, 116, 121, 112, 105, 115, 111, 109])], {
type: 'video/mp4',
})
await directUpload(token.uploadUrl, token.uploadToken, token.objectKey, blob)
}
progress.value = 70
statusText.value = '登记视频并提交分析…'
const video = await registerVideo(child.id, token.objectKey)
const task = await createAnalysisTask(child.id, video.id)
if (!task.reportId) throw new Error('分析未完成')
progress.value = 90
statusText.value = '拉取报告…'
report.value = await getReport(task.reportId)
lastReportId.value = task.reportId
saveLastReportId(task.reportId)
riskLabel.value = riskMap[report.value.riskLevel] || report.value.riskLevel
progress.value = 100
statusText.value = '完成'
} catch (err) {
statusText.value = err instanceof Error ? err.message : '流程失败'
} finally {
running.value = false
}
}
function goReports() {
router.push('/reports')
}
</script>
+45
View File
@@ -0,0 +1,45 @@
<template>
<section class="card">
<h2>训练计划</h2>
<p v-if="loading" class="muted">加载中</p>
<p v-else-if="error" class="error">{{ error }}</p>
<template v-else>
<div v-for="plan in plans" :key="plan.id" class="list-item">
<strong>{{ plan.detail.goal }}</strong>
<p class="muted">
{{ plan.detail.currentDay }} / {{ plan.detail.cycleDays }} · 状态 {{ plan.status }}
</p>
<div v-for="ex in plan.detail.exercises" :key="ex.name" class="metric-row">
<span>{{ ex.name }}</span>
</div>
</div>
<p v-if="!plans.length" class="muted">暂无训练计划可在 Demo API 中基于报告创建</p>
</template>
</section>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { listChildren, listTrainingPlans } from '../api'
const plans = ref<Awaited<ReturnType<typeof listTrainingPlans>>['list']>([])
const loading = ref(true)
const error = ref('')
onMounted(async () => {
try {
const { list } = await listChildren()
const child = list[0]
if (!child) {
plans.value = []
return
}
const data = await listTrainingPlans(child.id)
plans.value = data.list
} catch (err) {
error.value = err instanceof Error ? err.message : '加载失败'
} finally {
loading.value = false
}
})
</script>
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<object, object, unknown>
export default component
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler"
},
"include": ["vite.config.ts"]
}
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://127.0.0.1:8000',
changeOrigin: true,
},
},
},
})