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:
@@ -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/`。
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Vendored
+7
@@ -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
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler"
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user