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,33 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
api-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
- name: Install API dependencies
|
||||
run: pip install -e "./apps/api[dev]"
|
||||
- name: Run pytest
|
||||
run: cd apps/api && pytest -q
|
||||
|
||||
web-build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
- name: Install and build web
|
||||
run: |
|
||||
cd apps/web
|
||||
npm install
|
||||
npm run build
|
||||
@@ -73,7 +73,17 @@ cd apps/web && npm install && npm run dev
|
||||
| **Sprint 3** | **✅** | **训练计划/打卡 + Admin 看板 + 全链路 Demo** |
|
||||
| **Sprint 4** | **✅** | **本地/MinIO 直传 + AI 骨架 + Vue H5 + 租户隔离** |
|
||||
| **Sprint 5** | **✅** | **MediaPipe 评分 + PDF 导出 + 阿里云 OSS + 小程序壳** |
|
||||
| Sprint 6 | 🔜 | 模型标定、完整小程序、复核工作流、生产部署 |
|
||||
| **Sprint 6** | **✅** | **报告复核 + 标定 + CI/K8s + H5/小程序 Tab 跟练** |
|
||||
| Sprint 7 | 🔜 | 生产镜像、Admin 复核 UI、消息通知 |
|
||||
|
||||
## 远程仓库
|
||||
|
||||
- **Gitea**:https://git.tkmind.cn/tkmind/happy-up
|
||||
- **Clone**:`ssh://git@git.tkmind.cn:2222/tkmind/happy-up.git`
|
||||
|
||||
## 部署
|
||||
|
||||
Kubernetes 骨架见 [deploy/README.md](deploy/README.md),CI 见 [.gitea/workflows/ci.yaml](.gitea/workflows/ci.yaml)。
|
||||
|
||||
## 文档索引
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,34 @@
|
||||
# Kubernetes 部署骨架(Sprint 6)
|
||||
|
||||
## 前置
|
||||
|
||||
- 集群已安装 Ingress Controller
|
||||
- 镜像推送到私有 Registry
|
||||
- MySQL / Redis / MinIO 可用(云 RDS 或集群内 Helm)
|
||||
|
||||
## 部署
|
||||
|
||||
```bash
|
||||
# 1. 创建 Secret(勿提交明文)
|
||||
kubectl create secret generic happy-up-api-secret \
|
||||
--from-literal=DATABASE_URL='mysql+pymysql://...' \
|
||||
--from-literal=REDIS_URL='redis://...' \
|
||||
--from-literal=JWT_SECRET='...' \
|
||||
--from-literal=OSS_ACCESS_KEY='...' \
|
||||
--from-literal=OSS_SECRET_KEY='...'
|
||||
|
||||
# 2. 应用清单
|
||||
kubectl apply -f deploy/k8s/api.yaml
|
||||
|
||||
# 3. 验证
|
||||
kubectl rollout status deployment/happy-up-api
|
||||
curl https://api.happy-up.example.com/health
|
||||
```
|
||||
|
||||
## Worker
|
||||
|
||||
分析 Worker 可单独 Deployment,环境变量与 API 相同,`command: ["python", "-m", "app.worker"]`。
|
||||
|
||||
## CI
|
||||
|
||||
Gitea Actions 见 `.gitea/workflows/ci.yaml`,推送 main 自动跑 API 测试与 Web 构建。
|
||||
@@ -0,0 +1,84 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: happy-up-api
|
||||
labels:
|
||||
app: happy-up-api
|
||||
spec:
|
||||
replicas: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app: happy-up-api
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: happy-up-api
|
||||
spec:
|
||||
containers:
|
||||
- name: api
|
||||
image: registry.example.com/happy-up/api:latest
|
||||
ports:
|
||||
- containerPort: 8000
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: happy-up-api-config
|
||||
- secretRef:
|
||||
name: happy-up-api-secret
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8000
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8000
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 20
|
||||
resources:
|
||||
requests:
|
||||
cpu: 200m
|
||||
memory: 256Mi
|
||||
limits:
|
||||
cpu: "1"
|
||||
memory: 512Mi
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: happy-up-api
|
||||
spec:
|
||||
selector:
|
||||
app: happy-up-api
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 8000
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: happy-up-api-config
|
||||
data:
|
||||
APP_ENV: production
|
||||
APP_DEBUG: "false"
|
||||
ANALYSIS_INLINE_PROCESS: "false"
|
||||
OSS_ENABLED: "true"
|
||||
OSS_PROVIDER: aliyun
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: happy-up-api
|
||||
spec:
|
||||
rules:
|
||||
- host: api.happy-up.example.com
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: happy-up-api
|
||||
port:
|
||||
number: 80
|
||||
@@ -662,7 +662,7 @@
|
||||
<div class="demo-topbar">
|
||||
<div>
|
||||
<span class="title">产品交互 Demo</span>
|
||||
<span class="badge" id="demoStageBadge">Sprint 5 · AI 评分 / PDF / H5</span>
|
||||
<span class="badge" id="demoStageBadge">Sprint 6 · 复核 / CI / K8s</span>
|
||||
</div>
|
||||
<div class="demo-topbar-actions">
|
||||
<button type="button" class="btn-api-toggle" id="apiModeToggle" title="连接本地 FastAPI">API 联调关</button>
|
||||
|
||||
@@ -377,12 +377,19 @@ happy-up/
|
||||
- H5 四 Tab UI(首页/筛查/报告/训练)对齐 Demo 配色
|
||||
- 微信小程序壳 `apps/mini`
|
||||
|
||||
### 10.9 Sprint 6 下一步
|
||||
### 10.9 Sprint 6 已完成
|
||||
|
||||
1. 真实视频数据集标定与模型精度调优
|
||||
2. 小程序完整 Tab + 跟练页
|
||||
3. 报告复核工作流 + 消息通知
|
||||
4. 生产部署(K8s / CI-CD)
|
||||
- 报告复核工作流(高风险 → `pending_review` → 教练 approve/reject)
|
||||
- 姿态指标标定配置 `app/ai/calibration.py`
|
||||
- Gitea CI(`.gitea/workflows/ci.yaml`)+ K8s 部署骨架(`deploy/k8s/`)
|
||||
- H5 跟练页 + 小程序 TabBar(首页/筛查/训练)
|
||||
|
||||
### 10.10 Sprint 7 下一步
|
||||
|
||||
1. 生产镜像构建与 Registry 推送
|
||||
2. 报告复核后台 UI(Admin Web)
|
||||
3. 消息通知(短信/模板消息)
|
||||
4. 真实数据集标定与 A/B 指标对比
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user