Sprint 6: report review workflow, CI/K8s, and client tabs.
Add coach review APIs, pose calibration thresholds, Gitea CI, Kubernetes skeleton, H5 practice page, and mini program tab bar. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
"""Configurable thresholds for pose metric calibration."""
|
||||
|
||||
CALIBRATION = {
|
||||
"head_forward_scale": 180.0,
|
||||
"shoulder_asymmetry_scale": 220.0,
|
||||
"pelvic_tilt_scale": 4.5,
|
||||
"normal_max": 35,
|
||||
"low_max": 55,
|
||||
"medium_max": 75,
|
||||
"review_required_min": 75,
|
||||
}
|
||||
|
||||
METRIC_NAMES = ("头前伸", "高低肩", "骨盆倾斜")
|
||||
@@ -15,7 +15,7 @@ RIGHT_HIP = 24
|
||||
Landmark = dict[str, float]
|
||||
FrameLandmarks = dict[int, Landmark]
|
||||
|
||||
METRIC_NAMES = ("头前伸", "高低肩", "骨盆倾斜")
|
||||
from app.ai.calibration import CALIBRATION, METRIC_NAMES
|
||||
|
||||
|
||||
def _point(frame: FrameLandmarks, idx: int) -> Landmark | None:
|
||||
@@ -43,7 +43,7 @@ def score_head_forward(frame: FrameLandmarks) -> float | None:
|
||||
return None
|
||||
mid_x = (left["x"] + right["x"]) / 2
|
||||
offset = abs(nose["x"] - mid_x) / width
|
||||
return min(100.0, max(0.0, offset * 180))
|
||||
return min(100.0, max(0.0, offset * CALIBRATION["head_forward_scale"]))
|
||||
|
||||
|
||||
def score_shoulder_asymmetry(frame: FrameLandmarks) -> float | None:
|
||||
@@ -53,7 +53,7 @@ def score_shoulder_asymmetry(frame: FrameLandmarks) -> float | None:
|
||||
if not left or not right or not width:
|
||||
return None
|
||||
diff = abs(left["y"] - right["y"]) / width
|
||||
return min(100.0, max(0.0, diff * 220))
|
||||
return min(100.0, max(0.0, diff * CALIBRATION["shoulder_asymmetry_scale"]))
|
||||
|
||||
|
||||
def score_pelvic_tilt(frame: FrameLandmarks) -> float | None:
|
||||
@@ -63,15 +63,15 @@ def score_pelvic_tilt(frame: FrameLandmarks) -> float | None:
|
||||
return None
|
||||
angle = abs(math.degrees(math.atan2(right["y"] - left["y"], right["x"] - left["x"])))
|
||||
tilt = min(angle, 180 - angle)
|
||||
return min(100.0, max(0.0, tilt * 4.5))
|
||||
return min(100.0, max(0.0, tilt * CALIBRATION["pelvic_tilt_scale"]))
|
||||
|
||||
|
||||
def value_to_level(value: float) -> str:
|
||||
if value < 35:
|
||||
if value < CALIBRATION["normal_max"]:
|
||||
return "normal"
|
||||
if value < 55:
|
||||
if value < CALIBRATION["low_max"]:
|
||||
return "low"
|
||||
if value < 75:
|
||||
if value < CALIBRATION["medium_max"]:
|
||||
return "medium"
|
||||
return "high"
|
||||
|
||||
@@ -111,12 +111,16 @@ def build_screening_report(frames: list[FrameLandmarks]) -> dict:
|
||||
|
||||
worst = max((m["value"] for m in metrics), default=0)
|
||||
risk_level = value_to_level(worst)
|
||||
if worst >= CALIBRATION["review_required_min"]:
|
||||
risk_level = "review_required"
|
||||
if risk_level == "normal":
|
||||
summary = "体态指标整体正常,建议保持日常活动与姿势习惯"
|
||||
elif risk_level == "low":
|
||||
summary = "存在轻度体态偏差,建议开始基础纠正训练"
|
||||
elif risk_level == "medium":
|
||||
summary = "建议关注头前伸与高低肩,开始针对性训练"
|
||||
elif risk_level == "review_required":
|
||||
summary = "指标偏高,已提交机构教练复核"
|
||||
else:
|
||||
summary = "多项指标偏高,建议尽快安排专业评估与干预"
|
||||
|
||||
|
||||
+18
-1
@@ -35,6 +35,20 @@ def seed() -> None:
|
||||
db.add(user)
|
||||
db.flush()
|
||||
|
||||
coach_hash = hash_phone("18600000001")
|
||||
coach = db.scalar(select(User).where(User.phone_hash == coach_hash))
|
||||
if not coach:
|
||||
coach = User(
|
||||
tenant_id=tenant.id,
|
||||
phone="18600000001",
|
||||
phone_hash=coach_hash,
|
||||
role="coach",
|
||||
status="active",
|
||||
consent_signed=True,
|
||||
)
|
||||
db.add(coach)
|
||||
db.flush()
|
||||
|
||||
child = db.scalar(
|
||||
select(Child).where(Child.parent_user_id == user.id, Child.name == "小明")
|
||||
)
|
||||
@@ -80,7 +94,10 @@ def seed() -> None:
|
||||
child_row = db.scalar(
|
||||
select(Child).where(Child.parent_user_id == user.id, Child.name == "小明")
|
||||
)
|
||||
print(f"Seed OK · tenant={tenant.id} user={user.id} child={child_row.id if child_row else '-'}")
|
||||
print(
|
||||
f"Seed OK · tenant={tenant.id} user={user.id} coach={coach.id if coach else '-'} "
|
||||
f"child={child_row.id if child_row else '-'}"
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@@ -11,10 +11,10 @@ CONTRACTS_OPENAPI = Path(__file__).resolve().parents[3] / "contracts" / "openapi
|
||||
|
||||
app = FastAPI(
|
||||
title="Kids AI Posture Platform API",
|
||||
version="0.6.0",
|
||||
version="0.7.0",
|
||||
description=(
|
||||
"儿童 AI 体态管理平台 API · Sprint 5。"
|
||||
"MediaPipe 帧级评分 + PDF 导出 + OSS 生产配置 + H5/小程序对接。"
|
||||
"儿童 AI 体态管理平台 API · Sprint 6。"
|
||||
"报告复核工作流 + 模型标定 + CI/K8s 部署骨架。"
|
||||
),
|
||||
)
|
||||
|
||||
@@ -47,7 +47,7 @@ async def http_exception_handler(request: Request, exc: HTTPException):
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "ok", "env": settings.app_env, "stage": "sprint5"}
|
||||
return {"status": "ok", "env": settings.app_env, "stage": "sprint6"}
|
||||
|
||||
|
||||
@app.get("/")
|
||||
@@ -56,5 +56,5 @@ def root():
|
||||
"name": settings.app_name,
|
||||
"docs": "/docs",
|
||||
"contract": str(CONTRACTS_OPENAPI),
|
||||
"stage": "sprint5-mediapipe-pdf-oss",
|
||||
"stage": "sprint6-review-ci-k8s",
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.response import ok
|
||||
from app.core.response import error, ok
|
||||
from app.db.models import User
|
||||
from app.db.session import get_db
|
||||
from app.deps import require_staff
|
||||
from app.schemas.models import ReportReviewRequest
|
||||
from app.services import admin as admin_service
|
||||
from app.services import report_review as review_service
|
||||
from app.services.reports import report_to_dict
|
||||
|
||||
router = APIRouter(prefix="/api/admin", tags=["Admin"])
|
||||
|
||||
@@ -18,3 +22,47 @@ def admin_dashboard(
|
||||
):
|
||||
metrics = admin_service.get_dashboard_metrics(db, tenant_id=current_user.tenant_id)
|
||||
return ok(metrics, request_id=request.state.request_id)
|
||||
|
||||
|
||||
@router.get("/reports/pending")
|
||||
def pending_reports(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_staff),
|
||||
):
|
||||
items = review_service.list_pending_reports(db, tenant_id=current_user.tenant_id)
|
||||
return ok({"list": items}, request_id=request.state.request_id)
|
||||
|
||||
|
||||
@router.post("/reports/{report_id}/review")
|
||||
def review_report(
|
||||
report_id: int,
|
||||
body: ReportReviewRequest,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_staff),
|
||||
):
|
||||
report, err = review_service.review_report(
|
||||
db,
|
||||
current_user.id,
|
||||
report_id,
|
||||
body.action,
|
||||
tenant_id=current_user.tenant_id,
|
||||
note=body.note,
|
||||
)
|
||||
if err == "report_not_found":
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content=error(10008, "report_not_found", request.state.request_id),
|
||||
)
|
||||
if err == "invalid_status":
|
||||
return JSONResponse(
|
||||
status_code=409,
|
||||
content=error(10012, "invalid_report_status", request.state.request_id),
|
||||
)
|
||||
if err == "invalid_action":
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content=error(10013, "invalid_review_action", request.state.request_id),
|
||||
)
|
||||
return ok(report_to_dict(report), message="reviewed", request_id=request.state.request_id)
|
||||
|
||||
@@ -163,3 +163,8 @@ class ReportMetric(BaseModel):
|
||||
value: float
|
||||
level: str
|
||||
confidence: float
|
||||
|
||||
|
||||
class ReportReviewRequest(BaseModel):
|
||||
action: str = Field(description="approve or reject")
|
||||
note: str | None = None
|
||||
|
||||
@@ -56,16 +56,18 @@ def create_report_for_task(db: Session, task: AnalysisTask, analysis: dict) -> R
|
||||
)
|
||||
else:
|
||||
payload = {**MOCK_REPORT, **report_data, "childId": task.child_id, "taskId": task.id}
|
||||
risk_level = payload.get("riskLevel", "medium")
|
||||
report_status = "pending_review" if risk_level in ("high", "review_required") else "published"
|
||||
report = Report(
|
||||
child_id=task.child_id,
|
||||
task_id=task.id,
|
||||
report_type="posture_screening",
|
||||
risk_level=payload.get("riskLevel", "medium"),
|
||||
risk_level=risk_level,
|
||||
summary=payload.get("summary", MOCK_REPORT["summary"]),
|
||||
metrics=payload.get("metrics", MOCK_REPORT["metrics"]),
|
||||
recommendations=payload.get("recommendations", MOCK_REPORT["recommendations"]),
|
||||
disclaimer=payload.get("disclaimer", DISCLAIMER_SCREENING),
|
||||
status="published",
|
||||
status=report_status,
|
||||
)
|
||||
db.add(report)
|
||||
db.flush()
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Report review workflow for staff."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.models import Child, Report
|
||||
|
||||
|
||||
def report_admin_dict(report: Report, child: Child | None = None) -> dict:
|
||||
return {
|
||||
"id": report.id,
|
||||
"childId": report.child_id,
|
||||
"childName": child.name if child else None,
|
||||
"taskId": report.task_id,
|
||||
"reportType": report.report_type,
|
||||
"riskLevel": report.risk_level,
|
||||
"summary": report.summary,
|
||||
"status": report.status,
|
||||
"reviewedBy": report.reviewed_by,
|
||||
"createdAt": report.created_at.isoformat() if report.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
def list_pending_reports(db: Session, tenant_id: int | None = None) -> list[dict]:
|
||||
query = (
|
||||
select(Report, Child)
|
||||
.join(Child, Child.id == Report.child_id)
|
||||
.where(Report.status == "pending_review")
|
||||
.order_by(Report.id.desc())
|
||||
)
|
||||
if tenant_id:
|
||||
query = query.where(Child.tenant_id == tenant_id)
|
||||
|
||||
rows = db.execute(query).all()
|
||||
return [report_admin_dict(report, child) for report, child in rows]
|
||||
|
||||
|
||||
def get_report_for_review(db: Session, report_id: int, tenant_id: int | None = None) -> Report | None:
|
||||
report = db.get(Report, report_id)
|
||||
if not report:
|
||||
return None
|
||||
child = db.get(Child, report.child_id)
|
||||
if not child:
|
||||
return None
|
||||
if tenant_id and child.tenant_id != tenant_id:
|
||||
return None
|
||||
return report
|
||||
|
||||
|
||||
def review_report(
|
||||
db: Session,
|
||||
reviewer_id: int,
|
||||
report_id: int,
|
||||
action: str,
|
||||
tenant_id: int | None = None,
|
||||
note: str | None = None,
|
||||
) -> tuple[Report | None, str | None]:
|
||||
report = get_report_for_review(db, report_id, tenant_id)
|
||||
if not report:
|
||||
return None, "report_not_found"
|
||||
if report.status != "pending_review":
|
||||
return None, "invalid_status"
|
||||
|
||||
if action == "approve":
|
||||
report.status = "published"
|
||||
elif action == "reject":
|
||||
report.status = "rejected"
|
||||
else:
|
||||
return None, "invalid_action"
|
||||
|
||||
report.reviewed_by = reviewer_id
|
||||
report.updated_at = datetime.now(timezone.utc)
|
||||
if note:
|
||||
recommendations = list(report.recommendations or [])
|
||||
recommendations.append(f"复核备注:{note}")
|
||||
report.recommendations = recommendations
|
||||
|
||||
db.commit()
|
||||
db.refresh(report)
|
||||
return report, None
|
||||
@@ -37,6 +37,7 @@ def report_to_dict(report: Report) -> dict:
|
||||
"metrics": report.metrics,
|
||||
"recommendations": report.recommendations,
|
||||
"disclaimer": report.disclaimer,
|
||||
"status": report.status,
|
||||
"reviewedBy": report.reviewed_by,
|
||||
}
|
||||
|
||||
|
||||
@@ -44,3 +44,36 @@ def auth_headers(client):
|
||||
assert resp.status_code == 200, resp.text
|
||||
token = resp.json()["data"]["token"]
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def staff_headers(client, db_session):
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.security import hash_phone
|
||||
from app.db.models import User
|
||||
|
||||
resp = client.post(
|
||||
"/api/auth/login",
|
||||
json={"loginType": "phone_code", "credential": "18600000001", "code": "682139"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
token = resp.json()["data"]["token"]
|
||||
user = db_session.scalar(select(User).where(User.phone_hash == hash_phone("18600000001")))
|
||||
if user:
|
||||
user.role = "coach"
|
||||
db_session.commit()
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def db_session(client):
|
||||
gen = app.dependency_overrides[get_db]()
|
||||
db = next(gen)
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
try:
|
||||
next(gen)
|
||||
except StopIteration:
|
||||
pass
|
||||
|
||||
@@ -4,7 +4,7 @@ from fastapi.testclient import TestClient
|
||||
def test_health(client):
|
||||
resp = client.get("/health")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["stage"] == "sprint5"
|
||||
assert resp.json()["stage"] == "sprint6"
|
||||
|
||||
|
||||
def test_login_invalid_code(client):
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
def test_review_required_risk_level():
|
||||
from app.ai.pose_metrics import LEFT_HIP, LEFT_SHOULDER, NOSE, RIGHT_HIP, RIGHT_SHOULDER, build_screening_report
|
||||
|
||||
bad_frame = {
|
||||
NOSE: {"x": 0.85, "y": 0.28, "visibility": 0.95},
|
||||
LEFT_SHOULDER: {"x": 0.42, "y": 0.35, "visibility": 0.95},
|
||||
RIGHT_SHOULDER: {"x": 0.58, "y": 0.48, "visibility": 0.95},
|
||||
LEFT_HIP: {"x": 0.44, "y": 0.62, "visibility": 0.95},
|
||||
RIGHT_HIP: {"x": 0.56, "y": 0.50, "visibility": 0.95},
|
||||
}
|
||||
report = build_screening_report([bad_frame] * 6)
|
||||
assert report["riskLevel"] == "review_required"
|
||||
|
||||
|
||||
def test_admin_report_review_flow(client, auth_headers, staff_headers, db_session):
|
||||
from app.db.models import Report
|
||||
|
||||
child_id = client.post(
|
||||
"/api/children",
|
||||
headers=auth_headers,
|
||||
json={"name": "复核测试", "birthday": "2016-06-01", "gender": "female"},
|
||||
).json()["data"]["id"]
|
||||
|
||||
object_key = f"videos/{child_id}/review.mp4"
|
||||
video_id = client.post(
|
||||
"/api/videos",
|
||||
headers=auth_headers,
|
||||
json={"childId": child_id, "objectKey": object_key, "scene": "front_posture"},
|
||||
).json()["data"]["id"]
|
||||
|
||||
task = client.post(
|
||||
"/api/analysis/tasks",
|
||||
headers={**auth_headers, "Idempotency-Key": "sprint6-review"},
|
||||
json={"childId": child_id, "videoId": video_id, "taskType": "posture_screening"},
|
||||
).json()["data"]
|
||||
report_id = task["reportId"]
|
||||
assert report_id
|
||||
|
||||
report = db_session.get(Report, report_id)
|
||||
report.status = "pending_review"
|
||||
report.risk_level = "high"
|
||||
db_session.commit()
|
||||
|
||||
pending = client.get("/api/admin/reports/pending", headers=staff_headers)
|
||||
assert pending.status_code == 200
|
||||
assert any(item["id"] == report_id for item in pending.json()["data"]["list"])
|
||||
|
||||
approved = client.post(
|
||||
f"/api/admin/reports/{report_id}/review",
|
||||
headers=staff_headers,
|
||||
json={"action": "approve", "note": "指标可接受,发布报告"},
|
||||
)
|
||||
assert approved.status_code == 200
|
||||
assert approved.json()["data"]["status"] == "published"
|
||||
assert approved.json()["data"]["reviewedBy"]
|
||||
|
||||
detail = client.get(f"/api/reports/{report_id}", headers=auth_headers)
|
||||
assert detail.json()["data"]["status"] == "published"
|
||||
|
||||
|
||||
def test_health_sprint6(client):
|
||||
resp = client.get("/health")
|
||||
assert resp.json()["stage"] == "sprint6"
|
||||
+22
-2
@@ -1,8 +1,9 @@
|
||||
{
|
||||
"pages": [
|
||||
"pages/login/login",
|
||||
"pages/home/home",
|
||||
"pages/screening/screening"
|
||||
"pages/screening/screening",
|
||||
"pages/training/training",
|
||||
"pages/login/login"
|
||||
],
|
||||
"window": {
|
||||
"navigationBarTitleText": "Happy Up",
|
||||
@@ -10,6 +11,25 @@
|
||||
"navigationBarTextStyle": "white",
|
||||
"backgroundColor": "#eef2f7"
|
||||
},
|
||||
"tabBar": {
|
||||
"color": "#6b7280",
|
||||
"selectedColor": "#1a6fb5",
|
||||
"backgroundColor": "#ffffff",
|
||||
"list": [
|
||||
{
|
||||
"pagePath": "pages/home/home",
|
||||
"text": "首页"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/screening/screening",
|
||||
"text": "筛查"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/training/training",
|
||||
"text": "训练"
|
||||
}
|
||||
]
|
||||
},
|
||||
"style": "v2",
|
||||
"sitemapLocation": "sitemap.json"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
const api = require('../../utils/api')
|
||||
|
||||
Page({
|
||||
data: {
|
||||
goal: '',
|
||||
exerciseName: '',
|
||||
planId: null,
|
||||
score: 82,
|
||||
status: '准备就绪',
|
||||
},
|
||||
onShow() {
|
||||
this.loadPlan()
|
||||
},
|
||||
async loadPlan() {
|
||||
try {
|
||||
const children = await api.listChildren()
|
||||
const child = (children.list || [])[0]
|
||||
if (!child) return
|
||||
const plans = await api.request(`/api/training/plans?childId=${child.id}`)
|
||||
const plan = (plans.list || [])[0]
|
||||
if (!plan) return
|
||||
this.setData({
|
||||
planId: plan.id,
|
||||
goal: plan.detail.goal,
|
||||
exerciseName: (plan.detail.exercises[0] || {}).name || '训练动作',
|
||||
})
|
||||
} catch (err) {
|
||||
this.setData({ status: err.message || '加载失败' })
|
||||
}
|
||||
},
|
||||
async submit() {
|
||||
if (!this.data.planId) return
|
||||
this.setData({ status: '提交打卡…' })
|
||||
try {
|
||||
await api.request(`/api/training/plans/${this.data.planId}/records`, {
|
||||
method: 'POST',
|
||||
data: {
|
||||
exerciseId: 1,
|
||||
completed: true,
|
||||
score: this.data.score,
|
||||
durationSeconds: 300,
|
||||
note: '小程序跟练打卡',
|
||||
},
|
||||
})
|
||||
this.setData({ status: '打卡成功 ✓' })
|
||||
} catch (err) {
|
||||
this.setData({ status: err.message || '打卡失败' })
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "训练跟练"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<view class="card">
|
||||
<view>训练计划</view>
|
||||
<view class="muted">{{goal}}</view>
|
||||
<view>{{exerciseName}}</view>
|
||||
<view class="muted">{{status}}</view>
|
||||
<button class="btn-primary" bindtap="submit">提交跟练打卡</button>
|
||||
</view>
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="app-shell">
|
||||
<header class="topbar">
|
||||
<strong>Happy Up</strong>
|
||||
<span class="stage">Sprint 5 H5</span>
|
||||
<span class="stage">Sprint 6 H5</span>
|
||||
</header>
|
||||
<div class="page">
|
||||
<RouterView />
|
||||
@@ -20,6 +20,10 @@
|
||||
<span class="dot"></span>
|
||||
报告
|
||||
</RouterLink>
|
||||
<RouterLink to="/practice" :class="{ active: route.path === '/practice' }">
|
||||
<span class="dot"></span>
|
||||
跟练
|
||||
</RouterLink>
|
||||
<RouterLink to="/training" :class="{ active: route.path === '/training' }">
|
||||
<span class="dot"></span>
|
||||
训练
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import HomeView from '../views/HomeView.vue'
|
||||
import LoginView from '../views/LoginView.vue'
|
||||
import PracticeView from '../views/PracticeView.vue'
|
||||
import ReportView from '../views/ReportView.vue'
|
||||
import ScreeningView from '../views/ScreeningView.vue'
|
||||
import TrainingView from '../views/TrainingView.vue'
|
||||
@@ -14,6 +15,7 @@ const router = createRouter({
|
||||
{ path: '/screening', component: ScreeningView },
|
||||
{ path: '/reports', component: ReportView },
|
||||
{ path: '/training', component: TrainingView },
|
||||
{ path: '/practice', component: PracticeView },
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<div class="muted">{{ child.birthday }} · {{ child.gender }}</div>
|
||||
</div>
|
||||
<button class="btn" style="margin-top: 16px" @click="goScreening">开始筛查</button>
|
||||
<button class="btn secondary" style="margin-top: 8px" @click="goPractice">跟练打卡</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -41,4 +42,8 @@ function goScreening() {
|
||||
}
|
||||
router.push('/screening')
|
||||
}
|
||||
|
||||
function goPractice() {
|
||||
router.push('/practice')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<section class="card">
|
||||
<h2>跟练打卡</h2>
|
||||
<p class="muted">{{ planGoal || '加载训练计划…' }}</p>
|
||||
<div v-if="exercise" class="list-item">
|
||||
<strong>{{ exercise.name }}</strong>
|
||||
<p class="muted">目标 {{ durationMinutes }} 分钟 · 得分 {{ score }}</p>
|
||||
</div>
|
||||
<div class="progress"><span :style="{ width: progress + '%' }"></span></div>
|
||||
<p class="muted">{{ statusText }}</p>
|
||||
<button class="btn" :disabled="running || !planId" @click="startPractice">开始跟练</button>
|
||||
<button v-if="planId" class="btn secondary" style="margin-top: 8px" :disabled="running" @click="submitRecord">
|
||||
提交打卡
|
||||
</button>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { apiFetch, listChildren, listTrainingPlans } from '../api'
|
||||
|
||||
const planId = ref<number | null>(null)
|
||||
const planGoal = ref('')
|
||||
const exercise = ref<{ name: string } | null>(null)
|
||||
const durationMinutes = ref(5)
|
||||
const progress = ref(0)
|
||||
const score = ref(80)
|
||||
const statusText = ref('准备就绪')
|
||||
const running = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const { list } = await listChildren()
|
||||
const child = list[0]
|
||||
if (!child) return
|
||||
const plans = await listTrainingPlans(child.id)
|
||||
const plan = plans.list[0]
|
||||
if (!plan) return
|
||||
planId.value = plan.id
|
||||
planGoal.value = plan.detail.goal
|
||||
exercise.value = plan.detail.exercises[0] || null
|
||||
durationMinutes.value = plan.detail.exercises[0]?.durationMinutes || 5
|
||||
} catch {
|
||||
statusText.value = '暂无训练计划'
|
||||
}
|
||||
})
|
||||
|
||||
async function startPractice() {
|
||||
running.value = true
|
||||
progress.value = 0
|
||||
statusText.value = '跟练中…'
|
||||
for (let step = 1; step <= 5; step += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 400))
|
||||
progress.value = step * 20
|
||||
}
|
||||
score.value = 78 + Math.floor(Math.random() * 15)
|
||||
statusText.value = '跟练完成,可提交打卡'
|
||||
running.value = false
|
||||
}
|
||||
|
||||
async function submitRecord() {
|
||||
if (!planId.value || !exercise.value) return
|
||||
running.value = true
|
||||
try {
|
||||
await apiFetch(`/api/training/plans/${planId.value}/records`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
exerciseId: 1,
|
||||
completed: true,
|
||||
score: score.value,
|
||||
durationSeconds: durationMinutes.value * 60,
|
||||
note: 'H5 跟练打卡',
|
||||
}),
|
||||
})
|
||||
statusText.value = '打卡成功 ✓'
|
||||
} catch (err) {
|
||||
statusText.value = err instanceof Error ? err.message : '打卡失败'
|
||||
} finally {
|
||||
running.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user