比较提交
48 次代码提交
6943754838
...
main
| 作者 | SHA1 | 提交日期 | |
|---|---|---|---|
|
|
06b9701e03 | ||
|
|
8e9e4915e2 | ||
|
|
634a4704c7 | ||
|
|
bb46d26c0e | ||
|
|
bacd712dbc | ||
|
|
78a7c755e3 | ||
|
|
a211562860 | ||
|
|
09b1b95e2c | ||
|
|
922a9fb63f | ||
|
|
31bead3452 | ||
|
|
a5103685fb | ||
|
|
f9db6ef590 | ||
|
|
13e59b8e8a | ||
|
|
2b72ef9200 | ||
|
|
09cd5b4d85 | ||
|
|
7aba508247 | ||
|
|
cf06de944f | ||
|
|
4e4122d758 | ||
|
|
f0bbe4c82f | ||
|
|
4fb2d092d7 | ||
|
|
e3fe9a8e7b | ||
|
|
fe5e539a47 | ||
|
|
139dc61b61 | ||
|
|
264d49475b | ||
|
|
5c2dcf23ba | ||
|
|
3763f5b515 | ||
|
|
1ce94f6f57 | ||
|
|
669497e625 | ||
|
|
71caf0de19 | ||
|
|
67b27e3551 | ||
|
|
a9ea94fb78 | ||
|
|
c4ec397ed3 | ||
|
|
bd8998166b | ||
|
|
143c60a054 | ||
|
|
bee24d547d | ||
|
|
a1689ee95e | ||
|
|
cb643ac154 | ||
|
|
585fd5773d | ||
|
|
e43b969d28 | ||
|
|
afb013193d | ||
|
|
ae93269c62 | ||
|
|
f4f425de42 | ||
|
|
815f96d4e8 | ||
|
|
edc66ea5bc | ||
|
|
d1b6603061 | ||
|
|
ad83ce9c68 | ||
|
|
20e183d2da | ||
|
|
1cc863e60e |
@@ -3,15 +3,18 @@ PORT=3000
|
||||
# App auth / storage / database
|
||||
DATABASE_URL=mysql://tennis:replace-with-db-password@db:3306/tennis_training_hub
|
||||
JWT_SECRET=replace-with-strong-secret
|
||||
REGISTRATION_INVITE_CODE=CA2026
|
||||
VITE_APP_ID=tennis-training-hub
|
||||
OAUTH_SERVER_URL=
|
||||
OWNER_OPEN_ID=
|
||||
ADMIN_USERNAMES=H1
|
||||
BUILT_IN_FORGE_API_URL=
|
||||
BUILT_IN_FORGE_API_KEY=
|
||||
VITE_OAUTH_PORTAL_URL=
|
||||
VITE_FRONTEND_FORGE_API_URL=
|
||||
VITE_FRONTEND_FORGE_API_KEY=
|
||||
LOCAL_STORAGE_DIR=/data/app/storage
|
||||
APP_PUBLIC_BASE_URL=https://te.hao.work/
|
||||
|
||||
# Compose MySQL
|
||||
MYSQL_DATABASE=tennis_training_hub
|
||||
@@ -23,6 +26,9 @@ MYSQL_ROOT_PASSWORD=replace-with-root-password
|
||||
LLM_API_URL=https://one.hao.work/v1/chat/completions
|
||||
LLM_API_KEY=replace-with-llm-api-key
|
||||
LLM_MODEL=qwen3.5-plus
|
||||
LLM_VISION_API_URL=https://one.hao.work/v1/chat/completions
|
||||
LLM_VISION_API_KEY=replace-with-llm-api-key
|
||||
LLM_VISION_MODEL=qwen3-vl-235b-a22b
|
||||
LLM_MAX_TOKENS=32768
|
||||
LLM_ENABLE_THINKING=0
|
||||
LLM_THINKING_BUDGET=128
|
||||
@@ -32,3 +38,5 @@ VITE_MEDIA_BASE_URL=/media
|
||||
|
||||
# Local app-to-media proxy for development or direct container access
|
||||
MEDIA_SERVICE_URL=http://127.0.0.1:8081
|
||||
BACKGROUND_TASK_POLL_MS=3000
|
||||
BACKGROUND_TASK_STALE_MS=300000
|
||||
|
||||
27
AGENTS.md
普通文件
@@ -0,0 +1,27 @@
|
||||
# AGENTS
|
||||
|
||||
## Update Discipline
|
||||
|
||||
- Every shipped feature change must be recorded in the in-app update log page at `/changelog`.
|
||||
- Every shipped feature change must also be recorded in [docs/CHANGELOG.md](/root/auto/tennis/docs/CHANGELOG.md).
|
||||
- When online smoke tests are run, record whether the public site is already serving the new build or still on an older asset revision.
|
||||
- Each update log entry must include:
|
||||
- release date
|
||||
- feature summary
|
||||
- tested modules or commands
|
||||
- corresponding repository version identifier
|
||||
- prefer the git short commit hash
|
||||
- After implementation, run the relevant tests before pushing.
|
||||
- Only record an entry as shipped after the related tests pass.
|
||||
- When a feature is deployed successfully, append the update entry before or together with the repository submission so the changelog stays in sync with the codebase.
|
||||
|
||||
## Session Policy
|
||||
|
||||
- Username login must support multiple active sessions across multiple devices.
|
||||
- New logins must not invalidate prior valid sessions for the same user.
|
||||
- Session validation should be tolerant of older token payloads where optional display fields are absent.
|
||||
|
||||
## Timezone Policy
|
||||
|
||||
- User-facing time displays should use `Asia/Shanghai`.
|
||||
- Daily aggregation keys and schedule-related server calculations should also use `Asia/Shanghai`.
|
||||
@@ -16,9 +16,12 @@ FROM node:22-bookworm-slim AS runtime
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
RUN corepack enable
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
COPY patches ./patches
|
||||
RUN pnpm install --prod --frozen-lockfile
|
||||
COPY --from=build /app/dist ./dist
|
||||
EXPOSE 3000
|
||||
CMD ["node", "dist/index.js"]
|
||||
CMD ["node", "dist/_core/index.js"]
|
||||
|
||||
78
README.md
@@ -1,15 +1,34 @@
|
||||
# Tennis Training Hub
|
||||
|
||||
网球训练管理与分析应用,提供训练计划、姿势分析、实时摄像头分析、在线视频录制与视频库管理。当前版本新增独立 Go 媒体服务,用于处理在线录制、分段上传、实时推流信令和归档回放。
|
||||
网球训练管理与分析应用,提供训练计划、姿势分析、实时摄像头分析、在线视频录制、成就系统、管理员工作台与视频库管理。当前版本在媒体服务之外新增数据库驱动的后台任务系统,用于承接训练计划生成、动作纠正、多模态分析、录制归档与每日 NTRP 刷新这类高延迟任务。
|
||||
|
||||
## Architecture
|
||||
|
||||
- `client/`: React 19 + TypeScript + Tailwind CSS 4 + shadcn/ui
|
||||
- `server/`: Express + tRPC + Drizzle + MySQL/TiDB,负责业务 API、登录、训练数据与视频库元数据
|
||||
- `media/`: Go 媒体服务,负责录制会话、分段上传、WebRTC 信令、关键片段标记与 FFmpeg 归档
|
||||
- `server/worker.ts`: Node 后台 worker,负责执行重任务队列
|
||||
- `docker-compose.yml`: 单机部署编排
|
||||
- `deploy/nginx.te.hao.work.conf`: `te.hao.work` 的宿主机 nginx 入口配置
|
||||
|
||||
## Realtime Analysis
|
||||
|
||||
实时分析页现在采用“识别 + 录制 + 落库”一体化流程:
|
||||
|
||||
- 浏览器端基于 MediaPipe Pose 自动识别 `forehand / backhand / serve / volley / overhead / slice / lob / unknown`
|
||||
- 最近 6 帧动作结果会做时序加权稳定化,降低正手/反手/未知动作间的瞬时抖动
|
||||
- 连续同类动作会自动合并为片段,最长单段不超过 10 秒
|
||||
- 停止分析后会自动保存动作区间、评分维度、反馈摘要和可选本地录制视频
|
||||
- 实时分析结果会自动回写训练记录、日训练聚合、成就进度与 NTRP 评分链路
|
||||
- 移动端支持竖屏最大化预览,主要操作按钮固定在侧边
|
||||
|
||||
## Video Library And PC Editing
|
||||
|
||||
- 视频库支持直接打开 `PC 轻剪辑工作台`
|
||||
- 轻剪辑支持播放器预览、手动入点/出点、从当前播放位置快速设点
|
||||
- 分析关键时刻会自动生成建议片段;即使视频 metadata 尚未返回,也会按分析帧数估算时间轴
|
||||
- 剪辑草稿保存在浏览器本地,可导出 JSON 供后续后台剪辑任务或人工复核使用
|
||||
|
||||
## Online Recording
|
||||
|
||||
在线录制模块采用双链路设计:
|
||||
@@ -18,7 +37,38 @@
|
||||
- 浏览器端 `RTCPeerConnection` 同步建立 WebRTC 低延迟推流链路
|
||||
- 客户端运动检测自动写入关键片段 marker,也支持手动标记
|
||||
- 摄像头中断后自动重连,保留既有分段与会话
|
||||
- 服务端 worker 将分段合并归档,并产出 WebM 回放;FFmpeg 可用时额外生成 MP4
|
||||
- Go 媒体 worker 将分段合并归档,并产出 WebM 回放;FFmpeg 可用时额外生成 MP4
|
||||
- Node app worker 轮询媒体归档状态,归档完成后自动登记到视频库并向任务中心反馈结果
|
||||
- 服务端媒体会话校验兼容 `/media/sessions/...` 路径,避免录制结束时因路径不一致导致 404
|
||||
|
||||
## Background Tasks
|
||||
|
||||
统一后台任务覆盖以下路径:
|
||||
|
||||
- `training_plan_generate`
|
||||
- `training_plan_adjust`
|
||||
- `analysis_corrections`
|
||||
- `pose_correction_multimodal`
|
||||
- `media_finalize`
|
||||
- `ntrp_refresh_user`
|
||||
- `ntrp_refresh_all`
|
||||
|
||||
前端提供全局任务中心,页面本地也会显示任务提交、执行中、完成或失败状态。训练页、分析页和录制页都可以在用户离开页面后继续完成后台任务。
|
||||
|
||||
另外提供独立日志页 `/logs`,用于查看后台任务历史、失败原因与通知记录;管理员工作台 `/admin` 可集中查看用户、后台任务、实时分析会话、应用设置和审计日志。
|
||||
|
||||
## Multimodal LLM
|
||||
|
||||
- 文本类任务使用 `LLM_API_URL` / `LLM_API_KEY` / `LLM_MODEL`
|
||||
- 图片类任务可单独指定 `LLM_VISION_API_URL` / `LLM_VISION_API_KEY` / `LLM_VISION_MODEL`
|
||||
- 所有图片输入都要求可从公网访问,因此本地相对路径会通过 `APP_PUBLIC_BASE_URL` 规范化为绝对 URL
|
||||
- 若视觉模型链路返回非标准 JSON 或缺失数组字段,服务端会先做结构兼容和字段补全,再尝试生成视觉报告
|
||||
- 若视觉模型链路不可用,系统会自动回退到结构化指标驱动的文本纠正,避免任务直接失败
|
||||
- 系统内置“视觉标准图库”页面 `/vision-lab`,可把公网网球参考图入库并保存每次识别结果
|
||||
- `ADMIN_USERNAMES` 可指定哪些用户名账号拥有 admin 视角,例如 `H1`
|
||||
- 用户名登录支持直接进入系统;仅首次创建新用户时需要填写 `REGISTRATION_INVITE_CODE`
|
||||
- 新用户首次登录时只需提交一次用户名;若用户名不存在才需要额外填写邀请码
|
||||
- `vision-lab` 支持对历史 `fallback/failed` 记录重新排队,便于修复上游返回不稳定导致的旧数据
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -27,6 +77,7 @@
|
||||
```bash
|
||||
pnpm install
|
||||
cp .env.example .env
|
||||
set -a && source .env && set +a && pnpm exec drizzle-kit migrate
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
@@ -62,12 +113,18 @@ go build ./...
|
||||
pnpm exec playwright install chromium
|
||||
```
|
||||
|
||||
若本地数据库是空库或刚新增了 schema,先执行:
|
||||
|
||||
```bash
|
||||
set -a && source .env && set +a && pnpm exec drizzle-kit migrate
|
||||
```
|
||||
|
||||
## Production Deployment
|
||||
|
||||
单机部署推荐:
|
||||
|
||||
1. 宿主机 nginx 处理 `80/443` 和 TLS
|
||||
2. `docker compose up -d --build` 启动 `app + media + worker + db`
|
||||
2. `docker compose up -d --build` 启动 `app + app-worker + media + media-worker + db`
|
||||
3. nginx 将 `/` 转发到宿主机 `127.0.0.1:3002 -> app:3000`,`/media/` 转发到 `127.0.0.1:8081 -> media:8081`
|
||||
4. 如需绕过 nginx 直连调试,也可通过公网 4 位端口访问主站:`http://te.hao.work:8302/`
|
||||
|
||||
@@ -76,6 +133,13 @@ pnpm exec playwright install chromium
|
||||
- `docs/deploy.md`
|
||||
- `docs/media-architecture.md`
|
||||
- `docs/frontend-recording.md`
|
||||
- `docs/runtime-operations.md`
|
||||
|
||||
2026-03-15 已在真实环境执行一次重建与 smoke test:
|
||||
|
||||
- `docker compose up -d --build migrate app app-worker`
|
||||
- Playwright 复测 `https://te.hao.work/login`、`/checkin`、`/videos`、`/recorder`、`/live-camera`、`/admin`
|
||||
- 复测后关键链路全部通过,确认线上已切换到最新前端与业务版本
|
||||
|
||||
## Documentation Index
|
||||
|
||||
@@ -86,6 +150,7 @@ pnpm exec playwright install chromium
|
||||
- `docs/deploy.md`: 部署指南
|
||||
- `docs/media-architecture.md`: 媒体服务架构
|
||||
- `docs/frontend-recording.md`: 前端录制与移动端适配说明
|
||||
- `docs/runtime-operations.md`: 运行时任务稳定性、日志清理、重启与 smoke 流程
|
||||
|
||||
## Environment
|
||||
|
||||
@@ -93,6 +158,8 @@ pnpm exec playwright install chromium
|
||||
|
||||
- `DATABASE_URL`
|
||||
- `JWT_SECRET`
|
||||
- `ADMIN_USERNAMES`
|
||||
- `REGISTRATION_INVITE_CODE`
|
||||
- `MYSQL_DATABASE`
|
||||
- `MYSQL_USER`
|
||||
- `MYSQL_PASSWORD`
|
||||
@@ -100,6 +167,10 @@ pnpm exec playwright install chromium
|
||||
- `LLM_API_URL`
|
||||
- `LLM_API_KEY`
|
||||
- `LLM_MODEL`
|
||||
- `LLM_VISION_API_URL`
|
||||
- `LLM_VISION_API_KEY`
|
||||
- `LLM_VISION_MODEL`
|
||||
- `APP_PUBLIC_BASE_URL`
|
||||
- `LOCAL_STORAGE_DIR`
|
||||
- `MEDIA_SERVICE_URL`
|
||||
- `VITE_MEDIA_BASE_URL`
|
||||
@@ -116,3 +187,4 @@ pnpm test:llm -- "你好,做个自我介绍"
|
||||
- 浏览器兼容目标以 Chrome 为主
|
||||
- 录制文件优先产出 WebM,MP4 为服务端可选归档产物
|
||||
- 存储策略当前为本地卷优先,适合单机 Compose 部署
|
||||
- 浏览器测试会启动真实 Node 服务,因此要求本地测试库已完成 Drizzle 迁移
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
3D full-body avatar preview cutouts in this folder are derived from the Open Source Avatars registry:
|
||||
https://github.com/ToxSam/open-source-avatars
|
||||
|
||||
Registry summary:
|
||||
- Registry metadata/docs license: CC0
|
||||
- Individual avatars used here: CC0 from collection "100Avatars R3"
|
||||
|
||||
Integrated examples:
|
||||
- BeachKing
|
||||
Preview source: https://arweave.net/EGCdxkfTjjmNS4RGiAT_or17mG3717qnZ7R1EnZxLg8
|
||||
Model source: https://arweave.net/uKhDMselhdUyeJKjelpuVsL8s-a9v_Wqq75TQfCfnos
|
||||
- Jenny
|
||||
Preview source: https://arweave.net/4a6_AfH-PHvFMXqja7V42pF9hCn9ceIj5z5NAsK2SSs
|
||||
Model source: https://arweave.net/kgTirc4OvUWbJhIKC2CB3_pYsYuB62KTj90IdE8s3sk
|
||||
- Juanita
|
||||
Preview source: https://arweave.net/5RHeIXD9fezkpuFJS1TRtGkNIVfTKZP7Rkmh9pDmaTs
|
||||
Model source: https://arweave.net/nyMyZZx5lN2DXsmBgbGQSnt3PuXYN7AAjz9QJrjitLo
|
||||
- SportTV
|
||||
Preview source: https://arweave.net/_Qic8KV5P5mo5wJ2N3lbqX0iGVxtVDn4CxCUiM5-Qcg
|
||||
Model source: https://arweave.net/ISYr7xBXT_s4tLddbhFB3PpUhWg-H_BYs2UZhVLF1hA
|
||||
|
||||
Local files are optimized transparent WebP derivatives for faster in-browser overlay rendering.
|
||||
|
之后 宽度: | 高度: | 大小: 18 KiB |
|
之后 宽度: | 高度: | 大小: 26 KiB |
|
之后 宽度: | 高度: | 大小: 20 KiB |
|
之后 宽度: | 高度: | 大小: 19 KiB |
@@ -0,0 +1,20 @@
|
||||
Animal avatar SVG assets in this folder are sourced from Twemoji.
|
||||
|
||||
Source:
|
||||
https://github.com/jdecked/twemoji
|
||||
|
||||
Selected assets:
|
||||
- gorilla.svg
|
||||
- monkey.svg
|
||||
- dog.svg
|
||||
- pig.svg
|
||||
- cat.svg
|
||||
- fox.svg
|
||||
- panda.svg
|
||||
- lion.svg
|
||||
- tiger.svg
|
||||
- rabbit.svg
|
||||
|
||||
License:
|
||||
CC-BY 4.0
|
||||
https://creativecommons.org/licenses/by/4.0/
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><path fill="#FFCC4D" d="M32.348 13.999s3.445-8.812 1.651-11.998c-.604-1.073-8 1.998-10.723 5.442 0 0-2.586-.86-5.276-.86s-5.276.86-5.276.86C10.001 3.999 2.605.928 2.001 2.001.207 5.187 3.652 13.999 3.652 13.999c-.897 1.722-1.233 4.345-1.555 7.16-.354 3.086.35 5.546.658 6.089.35.617 2.123 2.605 4.484 4.306 3.587 2.583 8.967 3.445 10.761 3.445s7.174-.861 10.761-3.445c2.361-1.701 4.134-3.689 4.484-4.306.308-.543 1.012-3.003.659-6.089-.324-2.814-.659-5.438-1.556-7.16z"/><path fill="#F18F26" d="M2.359 2.971c.2-.599 5.348 2.173 6.518 5.404 0 0-3.808 2.624-4.528 4.624 0 0-2.99-7.028-1.99-10.028z"/><path fill="#FFCC4D" d="M5.98 7.261c0-1.414 5.457 2.733 4.457 3.733s-1.255.72-2.255 1.72S5.98 8.261 5.98 7.261z"/><path fill="#F18F26" d="M33.641 2.971c-.2-.599-5.348 2.173-6.518 5.404 0 0 3.808 2.624 4.528 4.624 0 0 2.99-7.028 1.99-10.028z"/><path fill="#FFCC4D" d="M30.02 7.261c0-1.414-5.457 2.733-4.457 3.733s1.255.72 2.255 1.72 2.202-4.453 2.202-5.453z"/><path fill="#292F33" d="M14.001 20.001c0 1.105-.896 1.999-2 1.999s-2-.894-2-1.999c0-1.104.896-1.999 2-1.999s2 .896 2 1.999zm11.998 0c0 1.105-.896 1.999-2 1.999-1.105 0-2-.894-2-1.999 0-1.104.895-1.999 2-1.999s2 .896 2 1.999z"/><path fill="#FEE7B8" d="M2.201 30.458c-.148 0-.294-.065-.393-.19-.171-.217-.134-.531.083-.702.162-.127 4.02-3.12 10.648-2.605.275.021.481.261.46.536-.021.275-.257.501-.537.46-6.233-.474-9.915 2.366-9.951 2.395-.093.07-.202.106-.31.106zm8.868-4.663c-.049 0-.1-.007-.149-.022-4.79-1.497-8.737-.347-8.777-.336-.265.081-.543-.07-.623-.335-.079-.265.071-.543.335-.622.173-.052 4.286-1.247 9.362.338.264.083.411.363.328.627-.066.213-.263.35-.476.35zm22.73 4.663c.148 0 .294-.065.393-.19.171-.217.134-.531-.083-.702-.162-.127-4.02-3.12-10.648-2.605-.275.021-.481.261-.46.536.022.275.257.501.537.46 6.233-.474 9.915 2.366 9.951 2.395.093.07.202.106.31.106zm-8.868-4.663c.049 0 .1-.007.149-.022 4.79-1.497 8.737-.347 8.777-.336.265.081.543-.07.623-.335.079-.265-.071-.543-.335-.622-.173-.052-4.286-1.247-9.362.338-.264.083-.411.363-.328.627.066.213.263.35.476.35z"/><path fill="#67757F" d="M24.736 30.898c-.097-.258-.384-.392-.643-.294-.552.206-1.076.311-1.559.311-1.152 0-1.561-.306-2.033-.659-.451-.338-.956-.715-1.99-.803v-2.339c0-.276-.224-.5-.5-.5s-.5.224-.5.5v2.373c-.81.115-1.346.439-1.816.743-.568.367-1.059.685-2.083.685-.482 0-1.006-.104-1.558-.311-.258-.095-.547.035-.643.294-.097.259.035.547.293.644.664.247 1.306.373 1.907.373 1.319 0 2.014-.449 2.627-.845.524-.339.98-.631 1.848-.635.992.008 1.358.278 1.815.621.538.403 1.147.859 2.633.859.601 0 1.244-.126 1.908-.373.259-.097.391-.385.294-.644z"/><path fill="#E75A70" d="M19.4 24.807h-2.8c-.64 0-1.163.523-1.163 1.163 0 .639.523 1.163 1.163 1.163h.237v.345c0 .639.523 1.163 1.163 1.163s1.163-.523 1.163-1.163v-.345h.237c.639 0 1.163-.523 1.163-1.163s-.524-1.163-1.163-1.163z"/><path fill="#F18F26" d="M18.022 17.154c-.276 0-.5-.224-.5-.5V8.37c0-.276.224-.5.5-.5s.5.224.5.5v8.284c0 .277-.223.5-.5.5zM21 15.572c-.276 0-.5-.224-.5-.5 0-2.882 1.232-5.21 1.285-5.308.13-.244.435-.334.677-.204.243.13.334.433.204.677-.012.021-1.166 2.213-1.166 4.835 0 .276-.224.5-.5.5zm-6 0c-.276 0-.5-.224-.5-.5 0-2.623-1.155-4.814-1.167-4.835-.13-.244-.038-.546.205-.677.242-.131.545-.039.676.204.053.098 1.285 2.426 1.285 5.308.001.276-.223.5-.499.5z"/></svg>
|
||||
|
之后 宽度: | 高度: | 大小: 3.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><path fill="#DD2E44" d="M15 27v6s0 3 3 3 3-3 3-3v-6h-6z"/><path fill="#BE1931" d="M15 33l.001.037c1.041-.035 2.016-.274 2.632-1.286.171-.281.563-.281.735 0 .616 1.011 1.591 1.251 2.632 1.286V27h-6v6z"/><path fill="#D99E82" d="M31.954 21.619c0 6.276-5 6.276-5 6.276h-18s-5 0-5-6.276c0-6.724 5-18.619 14-18.619s14 12.895 14 18.619z"/><path fill="#F4C7B5" d="M18 20c-7 0-10 3.527-10 6.395 0 3.037 2.462 5.5 5.5 5.5 1.605 0 3.042-.664 4.049-2.767.185-.386.716-.386.901 0 1.007 2.103 2.445 2.767 4.049 2.767 3.038 0 5.5-2.463 5.5-5.5C28 23.527 25 20 18 20z"/><path fill="#292F33" d="M15 22.895c-1 1 2 4 3 4s4-3 3-4-5-1-6 0zM13 19c-1.1 0-2-.9-2-2v-2c0-1.1.9-2 2-2s2 .9 2 2v2c0 1.1-.9 2-2 2zm10 0c-1.1 0-2-.9-2-2v-2c0-1.1.9-2 2-2s2 .9 2 2v2c0 1.1-.9 2-2 2z"/><path fill="#662113" d="M15 3.608C13.941 2.199 11.681.881 2.828 4.2-1.316 5.754.708 17.804 3.935 18.585c1.106 0 4.426 0 4.426-8.852 0-.22-.002-.423-.005-.625C10.35 6.298 12.5 4.857 15 3.608zm18.172.592C24.319.881 22.059 2.199 21 3.608c2.5 1.25 4.65 2.691 6.644 5.501-.003.201-.005.404-.005.625 0 8.852 3.319 8.852 4.426 8.852 3.227-.782 5.251-12.832 1.107-14.386z"/><circle fill="#D99E82" cx="23.5" cy="25.5" r=".5"/><circle fill="#D99E82" cx="11.5" cy="25.5" r=".5"/><circle fill="#D99E82" cx="25.5" cy="27.5" r=".5"/><circle fill="#D99E82" cx="10.5" cy="27.5" r=".5"/><circle fill="#D99E82" cx="23" cy="28" r="1"/><circle fill="#D99E82" cx="13" cy="28" r="1"/><path fill="#380F09" d="M9.883 7.232c-.259-.673-.634-1.397-1.176-1.939-.391-.391-1.023-.391-1.414 0s-.391 1.023 0 1.414c.57.57 1.066 1.934 1.068 2.346.145-.404.839-1.15 1.522-1.821zm16.217 0c.259-.672.634-1.397 1.176-1.939.391-.391 1.023-.391 1.414 0s.391 1.023 0 1.414c-.57.57-1.066 1.934-1.068 2.346-.145-.404-.839-1.15-1.522-1.821z"/></svg>
|
||||
|
之后 宽度: | 高度: | 大小: 1.8 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><path fill="#F4900C" d="M13.431 9.802c.658 2.638-8.673 10.489-11.244 4.098C.696 10.197-.606 2.434.874 2.065c1.48-.368 11.9 5.098 12.557 7.737z"/><path fill="#A0041E" d="M11.437 10.355c.96 1.538-1.831 4.561-3.368 5.522-1.538.961-2.899-.552-4.414-4.414-.662-1.689-1.666-6.27-1.103-6.622.562-.351 7.924 3.976 8.885 5.514z"/><path fill="#F4900C" d="M22.557 9.802C21.9 12.441 31.23 20.291 33.802 13.9c1.49-3.703 2.792-11.466 1.312-11.835-1.48-.368-11.899 5.098-12.557 7.737z"/><path fill="#A0041E" d="M24.552 10.355c-.96 1.538 1.831 4.561 3.368 5.522 1.537.961 2.898-.552 4.413-4.414.662-1.688 1.666-6.269 1.104-6.621-.563-.352-7.924 3.975-8.885 5.513z"/><path fill="#F18F26" d="M32.347 26.912c0-.454-.188-1.091-.407-1.687.585.028 1.519.191 2.77.817-.008-.536-.118-.984-.273-1.393.041.02.075.034.116.055-1.103-3.31-3.309-5.517-3.309-5.517h2.206c-2.331-4.663-4.965-8.015-8.075-9.559-1.39-.873-3.688-1.338-7.373-1.339h-.003c-3.695 0-5.996.468-7.385 1.346-3.104 1.547-5.734 4.896-8.061 9.552H4.76s-2.207 2.206-3.311 5.517c.03-.015.055-.025.084-.04-.201.392-.307.847-.282 1.377 1.263-.632 2.217-.792 2.813-.818-.189.513-.343 1.044-.386 1.475-.123.371-.191.812-.135 1.343C6.75 26.584 8.25 26.792 10 27.667 11.213 31.29 14.206 34 18.001 34c3.793 0 6.746-2.794 7.958-6.416 1.458-1.25 3.708-.875 6.416.416.066-.414.036-.773-.036-1.093l.008.005z"/><path fill="#FFD983" d="M31.243 23.601c.006 0 1.108.003 3.309 1.103-1.249-2.839-7.525-4.07-9.931-3.291-1.171 1.954-1.281 5.003-3.383 6.622-1.741 1.431-4.713 1.458-6.479 0-2.345-1.924-2.559-5.813-3.382-6.622-2.407-.781-8.681.454-9.931 3.291 2.201-1.101 3.304-1.103 3.309-1.103 0 .001-1.103 2.208-1.103 3.311l.001-.001v.001c2.398-1.573 5.116-2.271 7.429-.452 1.666 7.921 12.293 7.545 13.833 0 2.314-1.818 5.03-1.122 7.429.452v-.001l.001.001c.002-1.103-1.101-3.311-1.102-3.311z"/><path fill="#272B2B" d="M11 17s0-1.5 1.5-1.5S14 17 14 17v1.5s0 1.5-1.5 1.5-1.5-1.5-1.5-1.5V17zm11 0s0-1.5 1.5-1.5S25 17 25 17v1.5s0 1.5-1.5 1.5-1.5-1.5-1.5-1.5V17zm-7.061 10.808c-1.021.208 2.041 3.968 3.062 3.968 1.02 0 4.082-3.76 3.062-3.968-1.021-.208-5.103-.208-6.124 0z"/></svg>
|
||||
|
之后 宽度: | 高度: | 大小: 2.1 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><path fill="#31373D" d="M5 16c0-4-5-3-4 1s3 5 3 5l1-6zm26 0c0-4 5-3 4 1s-3 5-3 5l-1-6z"/><path fill="#31373D" d="M32.65 21.736c0 10.892-4.691 14.087-14.65 14.087-9.958 0-14.651-3.195-14.651-14.087S8.042.323 18 .323c9.959 0 14.65 10.521 14.65 21.413z"/><path fill="#66757F" d="M27.567 23c1.49-4.458 2.088-7.312-.443-7.312H8.876c-2.532 0-1.933 2.854-.444 7.312C3.504 34.201 17.166 34.823 18 34.823S32.303 33.764 27.567 23z"/><path fill="#31373D" d="M15 18.003c0 1.105-.896 2-2 2s-2-.895-2-2c0-1.104.896-1 2-1s2-.105 2 1zm10 0c0 1.105-.896 2-2 2s-2-.895-2-2c0-1.104.896-1 2-1s2-.105 2 1z"/><ellipse fill="#31373D" cx="15.572" cy="23.655" rx="1.428" ry="1"/><path fill="#31373D" d="M21.856 23.655c0 .553-.639 1-1.428 1-.79 0-1.429-.447-1.429-1 0-.553.639-1 1.429-1s1.428.448 1.428 1z"/><path fill="#99AAB5" d="M21.02 21.04c-1.965-.26-3.02.834-3.02.834s-1.055-1.094-3.021-.834c-3.156.417-3.285 3.287-1.939 3.105.766-.104.135-.938 1.713-1.556 1.579-.616 3.247.66 3.247.66s1.667-1.276 3.246-.659.947 1.452 1.714 1.556c1.346.181 1.218-2.689-1.94-3.106z"/><path fill="#31373D" d="M24.835 30.021c-1.209.323-3.204.596-6.835.596s-5.625-.272-6.835-.596c-3.205-.854-1.923-1.735 0-1.477 1.923.259 3.631.415 6.835.415 3.205 0 4.914-.156 6.835-.415 1.923-.258 3.204.623 0 1.477z"/><path fill="#66757F" d="M4.253 16.625c1.403-1.225-1.078-3.766-2.196-2.544-.341.373.921-.188 1.336 1.086.308.942.001 2.208.86 1.458zm27.493 0c-1.402-1.225 1.078-3.766 2.196-2.544.341.373-.921-.188-1.337 1.086-.306.942 0 2.208-.859 1.458z"/></svg>
|
||||
|
之后 宽度: | 高度: | 大小: 1.5 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><path fill="#662113" d="M32.325 10.958s2.315.024 3.511 1.177c-.336-4.971-2.104-8.249-5.944-10.13-3.141-1.119-6.066 1.453-6.066 1.453s.862-1.99 2.19-2.746C23.789.236 21.146 0 18 0c-3.136 0-5.785.227-8.006.701 1.341.745 2.215 2.758 2.215 2.758S9.194.803 6 2.053C2.221 3.949.481 7.223.158 12.174c1.183-1.19 3.55-1.215 3.55-1.215S-.105 13.267.282 16.614c.387 2.947 1.394 5.967 2.879 8.722C3.039 22.15 5.917 20 5.917 20s-2.492 5.96-.581 8.738c1.935 2.542 4.313 4.641 6.976 5.916-.955-1.645-.136-3.044-.103-2.945.042.125.459 3.112 2.137 3.743 1.178.356 2.4.548 3.654.548 1.292 0 2.55-.207 3.761-.583 1.614-.691 2.024-3.585 2.064-3.708.032-.098.843 1.287-.09 2.921 2.706-1.309 5.118-3.463 7.064-6.073 1.699-2.846-.683-8.557-.683-8.557s2.85 2.13 2.757 5.288c1.556-2.906 2.585-6.104 2.911-9.2-.035-3.061-3.459-5.13-3.459-5.13z"/><path fill="#FFCC4D" d="M13.859 9.495c.596 2.392.16 4.422-2.231 5.017-2.392.596-6.363.087-6.958-2.304-.596-2.392.469-5.39 1.81-5.724 1.341-.334 6.784.62 7.379 3.011zm9.104 18.432c0 2.74-2.222 4.963-4.963 4.963s-4.963-2.223-4.963-4.963c0-2.741 2.223-4.964 4.963-4.964 2.741 0 4.963 2.222 4.963 4.964z"/><path fill="#DD2E44" d="M21.309 27.927c0 1.827-1.481 3.309-3.309 3.309s-3.309-1.481-3.309-3.309c0-1.827 1.481-3.31 3.309-3.31s3.309 1.483 3.309 3.31z"/><path fill="#E6AAAA" d="M11.052 8.997c.871 1.393.447 3.229-.946 4.1-1.394.871-2.608.797-3.479-.596-.871-1.394-.186-4.131.324-4.45.51-.319 3.23-.448 4.101.946z"/><path fill="#FFCC4D" d="M22.141 9.495c-.596 2.392-.159 4.422 2.232 5.017 2.392.596 6.363.087 6.959-2.304.596-2.392-.47-5.39-1.811-5.724-1.342-.334-6.786.62-7.38 3.011z"/><path fill="#E6AAAA" d="M24.948 8.997c-.871 1.393-.447 3.229.945 4.1 1.394.871 2.608.797 3.479-.596.871-1.394.185-4.131-.324-4.45-.51-.319-3.229-.448-4.1.946z"/><path fill="#FFCC4D" d="M18 7.125h-.002C5.167 7.126 7.125 12.083 8.5 18.667 9.875 25.25 10.384 27 10.384 27h15.228s.51-1.75 1.885-8.333C28.872 12.083 30.829 7.126 18 7.125z"/><path fill="#272B2B" d="M12 16s0-1.5 1.5-1.5S15 16 15 16v1.5s0 1.5-1.5 1.5-1.5-1.5-1.5-1.5V16zm9 0s0-1.5 1.5-1.5S24 16 24 16v1.5s0 1.5-1.5 1.5-1.5-1.5-1.5-1.5V16z"/><path fill="#FFE8B6" d="M20.168 21.521c-1.598 0-1.385.848-2.168 2.113-.783-1.266-.571-2.113-2.168-2.113-6.865 0-6.837.375-6.865 2.828-.058 4.986 2.802 6.132 5.257 6.06 1.597-.048 2.994-.88 3.777-2.131.783 1.251 2.179 2.083 3.776 2.131 2.455.072 5.315-1.073 5.257-6.06-.029-2.453-.001-2.828-6.866-2.828z"/><path fill="#272B2B" d="M14.582 21.411c-1.14.233 2.279 4.431 3.418 4.431s4.559-4.198 3.419-4.431c-1.14-.232-5.698-.232-6.837 0z"/><circle fill="#D99E82" cx="11.5" cy="24.5" r=".5"/><circle fill="#D99E82" cx="10.5" cy="26.5" r=".5"/><circle fill="#D99E82" cx="12.5" cy="27.5" r=".5"/><circle fill="#D99E82" cx="24.5" cy="24.5" r=".5"/><circle fill="#D99E82" cx="25.5" cy="26.5" r=".5"/><circle fill="#D99E82" cx="23.5" cy="27.5" r=".5"/></svg>
|
||||
|
之后 宽度: | 高度: | 大小: 2.8 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><ellipse transform="rotate(-14.999 5.05 17.456)" fill="#D79E84" cx="5.05" cy="17.455" rx="3.818" ry="5.455"/><ellipse transform="rotate(-75.001 31.05 17.455)" fill="#D79E84" cx="31.05" cy="17.455" rx="5.455" ry="3.818"/><path fill="#BF6952" d="M19.018 36h-2.036C10.264 36 3.75 30.848 3.75 23.636c0-4.121 1.527-6.182 1.527-6.182s-.509-2.061-.509-4.121C4.768 7.152 11.282 2 18 2c6.718 0 13.232 6.182 13.232 11.333 0 2.061-.509 4.121-.509 4.121s1.527 2.061 1.527 6.182C32.25 30.848 25.736 36 19.018 36z"/><path fill="#D79E84" d="M30 16.042C30 12.153 26.825 9 22.909 9c-1.907 0-3.635.752-4.909 1.968C16.726 9.752 14.998 9 13.091 9 9.175 9 6 12.153 6 16.042c0 2.359 1.172 4.441 2.965 5.719-.503 1.238-.783 2.6-.783 4.031C8.182 31.476 12.578 35 18 35s9.818-3.524 9.818-9.208c0-1.431-.28-2.793-.783-4.031C28.828 20.483 30 18.4 30 16.042z"/><ellipse fill="#292F33" cx="13" cy="17" rx="2.25" ry="3.25"/><ellipse fill="#292F33" cx="23" cy="17" rx="2.25" ry="3.25"/><path fill="#642116" d="M18 32.727c2.838 0 5.254-1.505 6.162-3.61.375-.871-.262-1.844-1.21-1.844h-9.904c-.948 0-1.585.974-1.21 1.844.908 2.105 3.324 3.61 6.162 3.61z"/><circle fill="#642116" cx="16.25" cy="23" r="1"/><circle fill="#642116" cx="19.75" cy="23" r="1"/><path fill="#BF6952" d="M22.66.175s-5.455-1.091-7.636 2.182 4.364 1.091 4.364 1.091S20.478.175 22.66.175z"/></svg>
|
||||
|
之后 宽度: | 高度: | 大小: 1.4 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><circle fill="#272B2B" cx="7" cy="6" r="6"/><circle fill="#272B2B" cx="29" cy="6" r="6"/><circle fill="#66757F" cx="7" cy="6" r="4"/><circle fill="#66757F" cx="29" cy="6" r="4"/><path fill="#EEE" d="M35 22c0 7-6.375 12-17 12S1 29 1 22C1 22 2.308 0 18 0s17 22 17 22z"/><circle fill="#CCD6DD" cx="18" cy="30" r="6"/><circle fill="#DD2E44" cx="18" cy="30" r="4"/><path fill="#272B2B" d="M20.709 12.654C25.163 9.878 32 17 26.952 22.67 23.463 26.591 20 25 20 25s-2.636-10.26.709-12.346zm-5.442.011C10.813 9.888 3.976 17.01 9.023 22.681c3.49 3.92 6.953 2.329 6.953 2.329s2.636-10.26-.709-12.345z"/><path fill="#66757F" d="M11 17s0-2 2-2 2 2 2 2v2s0 2-2 2-2-2-2-2v-2z"/><path fill="#FFF" d="M18 20S7 23.687 7 27s2.687 6 6 6c2.088 0 3.925-1.067 5-2.685C19.074 31.933 20.912 33 23 33c3.313 0 6-2.687 6-6s-11-7-11-7z"/><path fill="#66757F" d="M21 17s0-2 2-2 2 2 2 2v2s0 2-2 2-2-2-2-2v-2z"/><path fill="#272B2B" d="M13.125 25c-1.624 1 3.25 4 4.875 4s6.499-3 4.874-4-8.124-1-9.749 0z"/></svg>
|
||||
|
之后 宽度: | 高度: | 大小: 1.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><path fill="#F4ABBA" d="M34.193 13.329c.387-.371.733-.795 1.019-1.28 1.686-2.854.27-10.292-.592-10.8-.695-.411-5.529 1.05-8.246 3.132C23.876 2.884 21.031 2 18 2c-3.021 0-5.856.879-8.349 2.367C6.93 2.293 2.119.839 1.424 1.249c-.861.508-2.276 7.947-.592 10.8.278.471.615.884.989 1.249C.666 15.85 0 18.64 0 21.479 0 31.468 8.011 34 18 34s18-2.532 18-12.521c0-2.828-.66-5.606-1.807-8.15z"/><path fill="#EA596E" d="M7.398 5.965c-2.166-1.267-4.402-2.08-4.8-1.845-.57.337-1.083 4.998-.352 8.265 1.273-2.483 3.04-4.682 5.152-6.42zm26.355 6.419c.733-3.267.219-7.928-.351-8.265-.398-.235-2.635.578-4.801 1.845 2.114 1.739 3.88 3.938 5.152 6.42zM28 23.125c0 4.487-3.097 9.375-10 9.375-6.904 0-10-4.888-10-9.375S11.096 17.5 18 17.5c6.903 0 10 1.138 10 5.625z"/><path fill="#662113" d="M15 24.6c0 1.857-.34 2.4-1.5 2.4s-1.5-.543-1.5-2.4c0-1.856.34-2.399 1.5-2.399s1.5.542 1.5 2.399zm9 0c0 1.857-.34 2.4-1.5 2.4s-1.5-.543-1.5-2.4c0-1.856.34-2.399 1.5-2.399s1.5.542 1.5 2.399z"/><circle fill="#292F33" cx="7" cy="17" r="2"/><circle fill="#292F33" cx="29" cy="17" r="2"/></svg>
|
||||
|
之后 宽度: | 高度: | 大小: 1.1 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><path fill="#99AAB5" d="M33.799.005c-.467-.178-7.998 3.971-9.969 9.131-1.166 3.052-1.686 6.058-1.652 8.112C20.709 16.459 19.257 16 18 16s-2.709.458-4.178 1.249c.033-2.055-.486-5.061-1.652-8.112C10.2 3.977 2.668-.173 2.201.005c-.455.174 4.268 16.044 7.025 20.838C6.805 23.405 5 26.661 5 29.828c0 3.234 1.635 5.14 4 5.94 2.531.857 5-.94 9-.94s6.469 1.798 9 .94c2.365-.801 4-2.706 4-5.94 0-3.166-1.805-6.423-4.225-8.984C29.53 16.049 34.255.179 33.799.005z"/><path fill="#F4ABBA" d="M12.692 17.922c-.178-1.54-.68-3.55-1.457-5.584-1.534-4.016-5.686-7.245-6.049-7.107-.319.122 2.627 10.14 4.783 14.863.866-.824 1.786-1.563 2.723-2.172zm13.338 2.172c2.156-4.723 5.102-14.741 4.784-14.862-.363-.139-4.516 3.091-6.05 7.107-.777 2.034-1.279 4.043-1.457 5.583.937.609 1.857 1.348 2.723 2.172z"/><path fill="#CCD6DD" d="M25 30c0 2.762-3.06 5-6.834 5-3.773 0-6.833-2.238-6.833-5s3.06-5 6.833-5C21.94 25 25 27.238 25 30z"/><path fill="#FFF" d="M21 30.578c0 2.762-.238 3-3 3-2.761 0-3-.238-3-3 0-1 6-1 6 0z"/><circle fill="#292F33" cx="12.5" cy="24.328" r="1.5"/><circle fill="#292F33" cx="23.5" cy="24.328" r="1.5"/><path fill="#F4ABBA" d="M21 25.828c0 1.657-2 3-3 3s-3-1.343-3-3 6-1.657 6 0z"/></svg>
|
||||
|
之后 宽度: | 高度: | 大小: 1.2 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><circle fill="#FFCC4D" cx="7" cy="6" r="6"/><circle fill="#FFCC4D" cx="18" cy="30" r="6"/><circle fill="#DD2E44" cx="18" cy="30" r="4"/><circle fill="#FFCC4D" cx="29" cy="6" r="6"/><circle fill="#E6AAAA" cx="7" cy="6" r="4"/><circle fill="#E6AAAA" cx="29" cy="6" r="4"/><path fill="#FFCC4D" d="M34 22c0 7-4.923 7-4.923 7H6.923S2 29 2 22C2 22 3.231 0 18 0c14.77 0 16 22 16 22z"/><path fill="#272B2B" d="M11 17s0-2 2-2 2 2 2 2v2s0 2-2 2-2-2-2-2v-2zm10 0s0-2 2-2 2 2 2 2v2s0 2-2 2-2-2-2-2v-2z"/><path fill="#FFF" d="M23.678 23c-2.402 0-4.501.953-5.678 2.378C16.823 23.953 14.723 23 12.321 23 2 23 2.043 23.421 2 26.182c-.087 5.61 6.63 6.9 10.321 6.818 2.401-.053 4.502-.989 5.679-2.397 1.177 1.408 3.276 2.345 5.678 2.397 3.691.082 10.409-1.208 10.321-6.818-.043-2.761 0-3.182-10.321-3.182z"/><path fill="#272B2B" d="M33.66 25.242c.204.279.333.588.339.939.03 1.905-.745 3.303-1.915 4.327L26.999 31l6.661-5.758zM15 25c-1 1 2 4 3 4s4-3 3-4-5-1-6 0zM10 3c2.667 2 8 4 8 4s5.333-2 8-4l-8 1-8-1zm8-1s1.652-.62 3.576-1.514C20.48.178 19.295 0 18 0s-2.481.178-3.576.486C16.348 1.38 18 2 18 2zm-7 7c3 2 7 4 7 4s4-2 7-4l-7 1-7-1zm20.645 2.285L27 15l6.006.75c-.334-1.401-.777-2.928-1.361-4.465zm1.911 7.159L28 24h5.835c.102-.595.165-1.251.165-2 0 0-.081-1.43-.444-3.556zm-31.112 0C2.082 20.57 2 22 2 22c0 .748.063 1.405.165 2H8l-5.556-5.556zm-.105 6.798c-.204.279-.333.588-.339.94-.03 1.905.745 3.303 1.916 4.327L9 31l-6.661-5.758zM9 15l-4.644-3.715c-.584 1.537-1.028 3.064-1.361 4.466L9 15z"/></svg>
|
||||
|
之后 宽度: | 高度: | 大小: 1.5 KiB |
@@ -19,6 +19,10 @@ import LiveCamera from "./pages/LiveCamera";
|
||||
import Recorder from "./pages/Recorder";
|
||||
import Tutorials from "./pages/Tutorials";
|
||||
import Reminders from "./pages/Reminders";
|
||||
import VisionLab from "./pages/VisionLab";
|
||||
import Logs from "./pages/Logs";
|
||||
import AdminConsole from "./pages/AdminConsole";
|
||||
import ChangeLog from "./pages/ChangeLog";
|
||||
|
||||
function DashboardRoute({ component: Component }: { component: React.ComponentType }) {
|
||||
return (
|
||||
@@ -57,6 +61,9 @@ function Router() {
|
||||
<Route path="/checkin">
|
||||
<DashboardRoute component={Checkin} />
|
||||
</Route>
|
||||
<Route path="/achievements">
|
||||
<DashboardRoute component={Checkin} />
|
||||
</Route>
|
||||
<Route path="/live-camera">
|
||||
<DashboardRoute component={LiveCamera} />
|
||||
</Route>
|
||||
@@ -69,6 +76,18 @@ function Router() {
|
||||
<Route path="/reminders">
|
||||
<DashboardRoute component={Reminders} />
|
||||
</Route>
|
||||
<Route path="/logs">
|
||||
<DashboardRoute component={Logs} />
|
||||
</Route>
|
||||
<Route path="/changelog">
|
||||
<DashboardRoute component={ChangeLog} />
|
||||
</Route>
|
||||
<Route path="/vision-lab">
|
||||
<DashboardRoute component={VisionLab} />
|
||||
</Route>
|
||||
<Route path="/admin">
|
||||
<DashboardRoute component={AdminConsole} />
|
||||
</Route>
|
||||
<Route path="/404" component={NotFound} />
|
||||
<Route component={NotFound} />
|
||||
</Switch>
|
||||
|
||||
@@ -23,16 +23,25 @@ import { useIsMobile } from "@/hooks/useMobile";
|
||||
import {
|
||||
LayoutDashboard, LogOut, PanelLeft, Target, Video,
|
||||
Award, Activity, FileVideo, Trophy, Flame, Camera, CircleDot,
|
||||
BookOpen, Bell
|
||||
BookOpen, Bell, Microscope, ScrollText, Shield
|
||||
} from "lucide-react";
|
||||
import { CSSProperties, useEffect, useRef, useState } from "react";
|
||||
import { useLocation, Redirect } from "wouter";
|
||||
import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton';
|
||||
import { TaskCenter } from "./TaskCenter";
|
||||
|
||||
const menuItems = [
|
||||
type MenuItem = {
|
||||
icon: typeof LayoutDashboard;
|
||||
label: string;
|
||||
path: string;
|
||||
group: "main" | "analysis" | "stats" | "learn";
|
||||
adminOnly?: boolean;
|
||||
};
|
||||
|
||||
const menuItems: MenuItem[] = [
|
||||
{ icon: LayoutDashboard, label: "仪表盘", path: "/dashboard", group: "main" },
|
||||
{ icon: Target, label: "训练计划", path: "/training", group: "main" },
|
||||
{ icon: Flame, label: "每日打卡", path: "/checkin", group: "main" },
|
||||
{ icon: Flame, label: "成就系统", path: "/checkin", group: "main" },
|
||||
{ icon: Camera, label: "实时分析", path: "/live-camera", group: "analysis" },
|
||||
{ icon: CircleDot, label: "在线录制", path: "/recorder", group: "analysis" },
|
||||
{ icon: Video, label: "视频分析", path: "/analysis", group: "analysis" },
|
||||
@@ -42,6 +51,10 @@ const menuItems = [
|
||||
{ icon: Trophy, label: "排行榜", path: "/leaderboard", group: "stats" },
|
||||
{ icon: BookOpen, label: "教程库", path: "/tutorials", group: "learn" },
|
||||
{ icon: Bell, label: "训练提醒", path: "/reminders", group: "learn" },
|
||||
{ icon: ScrollText, label: "更新日志", path: "/changelog", group: "learn" },
|
||||
{ icon: ScrollText, label: "系统日志", path: "/logs", group: "learn" },
|
||||
{ icon: Microscope, label: "视觉测试", path: "/vision-lab", group: "learn", adminOnly: true },
|
||||
{ icon: Shield, label: "管理系统", path: "/admin", group: "learn", adminOnly: true },
|
||||
];
|
||||
|
||||
const mobileNavItems = [
|
||||
@@ -110,7 +123,8 @@ function DashboardLayoutContent({
|
||||
const isCollapsed = state === "collapsed";
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const sidebarRef = useRef<HTMLDivElement>(null);
|
||||
const activeMenuItem = menuItems.find(item => item.path === location);
|
||||
const visibleMenuItems = menuItems.filter(item => !item.adminOnly || user?.role === "admin");
|
||||
const activeMenuItem = visibleMenuItems.find(item => item.path === location);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -179,7 +193,7 @@ function DashboardLayoutContent({
|
||||
<SidebarContent className="gap-0">
|
||||
<SidebarMenu className="px-2 py-1">
|
||||
{/* Main group */}
|
||||
{menuItems.filter(i => i.group === "main").map(item => {
|
||||
{visibleMenuItems.filter(i => i.group === "main").map(item => {
|
||||
const isActive = location === item.path;
|
||||
return (
|
||||
<SidebarMenuItem key={item.path}>
|
||||
@@ -200,7 +214,7 @@ function DashboardLayoutContent({
|
||||
{!isCollapsed && <div className="my-2 mx-2 border-t border-border/50" />}
|
||||
{!isCollapsed && <p className="px-3 text-[10px] font-medium text-muted-foreground uppercase tracking-wider mb-1">分析与录制</p>}
|
||||
|
||||
{menuItems.filter(i => i.group === "analysis").map(item => {
|
||||
{visibleMenuItems.filter(i => i.group === "analysis").map(item => {
|
||||
const isActive = location === item.path;
|
||||
return (
|
||||
<SidebarMenuItem key={item.path}>
|
||||
@@ -221,7 +235,7 @@ function DashboardLayoutContent({
|
||||
{!isCollapsed && <div className="my-2 mx-2 border-t border-border/50" />}
|
||||
{!isCollapsed && <p className="px-3 text-[10px] font-medium text-muted-foreground uppercase tracking-wider mb-1">统计与排名</p>}
|
||||
|
||||
{menuItems.filter(i => i.group === "stats").map(item => {
|
||||
{visibleMenuItems.filter(i => i.group === "stats").map(item => {
|
||||
const isActive = location === item.path;
|
||||
return (
|
||||
<SidebarMenuItem key={item.path}>
|
||||
@@ -242,7 +256,7 @@ function DashboardLayoutContent({
|
||||
{!isCollapsed && <div className="my-2 mx-2 border-t border-border/50" />}
|
||||
{!isCollapsed && <p className="px-3 text-[10px] font-medium text-muted-foreground uppercase tracking-wider mb-1">学习与提醒</p>}
|
||||
|
||||
{menuItems.filter(i => i.group === "learn").map(item => {
|
||||
{visibleMenuItems.filter(i => i.group === "learn").map(item => {
|
||||
const isActive = location === item.path;
|
||||
return (
|
||||
<SidebarMenuItem key={item.path}>
|
||||
@@ -262,6 +276,9 @@ function DashboardLayoutContent({
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter className="p-3">
|
||||
<div className="mb-3">
|
||||
<TaskCenter />
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="flex items-center gap-3 rounded-lg px-1 py-1 hover:bg-accent/50 transition-colors w-full text-left group-data-[collapsible=icon]:justify-center focus:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
@@ -315,6 +332,7 @@ function DashboardLayoutContent({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<TaskCenter compact />
|
||||
</div>
|
||||
)}
|
||||
<main className={`flex-1 p-4 md:p-6 ${isMobile ? "pb-28" : ""}`}>{children}</main>
|
||||
|
||||
184
client/src/components/TaskCenter.tsx
普通文件
@@ -0,0 +1,184 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { formatDateTimeShanghai } from "@/lib/time";
|
||||
import { toast } from "sonner";
|
||||
import { AlertTriangle, BellRing, CheckCircle2, Loader2, RefreshCcw } from "lucide-react";
|
||||
|
||||
function formatTaskStatus(status: string) {
|
||||
switch (status) {
|
||||
case "running":
|
||||
return "执行中";
|
||||
case "succeeded":
|
||||
return "已完成";
|
||||
case "failed":
|
||||
return "失败";
|
||||
default:
|
||||
return "排队中";
|
||||
}
|
||||
}
|
||||
|
||||
function formatTaskTiming(task: {
|
||||
createdAt: string | Date;
|
||||
startedAt?: string | Date | null;
|
||||
completedAt?: string | Date | null;
|
||||
}) {
|
||||
const createdAt = new Date(task.createdAt).getTime();
|
||||
const startedAt = task.startedAt ? new Date(task.startedAt).getTime() : null;
|
||||
const completedAt = task.completedAt ? new Date(task.completedAt).getTime() : null;
|
||||
const durationMs = (completedAt ?? Date.now()) - (startedAt ?? createdAt);
|
||||
const seconds = Math.max(0, Math.round(durationMs / 1000));
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const rest = seconds % 60;
|
||||
return `${minutes}m ${rest.toString().padStart(2, "0")}s`;
|
||||
}
|
||||
|
||||
export function TaskCenter({ compact = false }: { compact?: boolean }) {
|
||||
const utils = trpc.useUtils();
|
||||
const retryMutation = trpc.task.retry.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.task.list.invalidate();
|
||||
toast.success("任务已重新排队");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`任务重试失败: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const taskListQuery = trpc.task.list.useQuery(
|
||||
{ limit: 20 },
|
||||
{
|
||||
retry: 3,
|
||||
retryDelay: (attempt) => Math.min(1_000 * 2 ** attempt, 8_000),
|
||||
placeholderData: (previous) => previous,
|
||||
refetchInterval: (query) => {
|
||||
const hasActiveTask = (query.state.data ?? []).some((task) => task.status === "queued" || task.status === "running");
|
||||
return hasActiveTask ? 3_000 : 8_000;
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const previousStatusesRef = useRef<Record<string, string>>({});
|
||||
useEffect(() => {
|
||||
for (const task of taskListQuery.data ?? []) {
|
||||
const previous = previousStatusesRef.current[task.id];
|
||||
if (previous && previous !== task.status) {
|
||||
if (task.status === "succeeded") {
|
||||
toast.success(`${task.title} 已完成`);
|
||||
if (task.type === "training_plan_generate" || task.type === "training_plan_adjust") {
|
||||
utils.plan.active.invalidate();
|
||||
utils.plan.list.invalidate();
|
||||
}
|
||||
if (task.type === "media_finalize") {
|
||||
utils.video.list.invalidate();
|
||||
}
|
||||
}
|
||||
if (task.status === "failed") {
|
||||
toast.error(`${task.title} 失败${task.error ? `: ${task.error}` : ""}`);
|
||||
}
|
||||
}
|
||||
previousStatusesRef.current[task.id] = task.status;
|
||||
}
|
||||
}, [taskListQuery.data, utils.plan.active, utils.plan.list, utils.video.list]);
|
||||
|
||||
const activeCount = useMemo(
|
||||
() => (taskListQuery.data ?? []).filter((task) => task.status === "queued" || task.status === "running").length,
|
||||
[taskListQuery.data]
|
||||
);
|
||||
|
||||
return (
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant={compact ? "ghost" : "outline"} size="sm" className="gap-2">
|
||||
<BellRing className="h-4 w-4" />
|
||||
<span>{compact ? "任务" : "任务中心"}</span>
|
||||
{activeCount > 0 ? <Badge variant="secondary">{activeCount}</Badge> : null}
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right" className="w-[380px] sm:w-[420px]">
|
||||
<SheetHeader>
|
||||
<SheetTitle>任务中心</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="h-[calc(100vh-6rem)] pr-4">
|
||||
<div className="mt-6 space-y-3">
|
||||
{taskListQuery.isError ? (
|
||||
<Alert>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
任务列表刷新失败,当前显示最近一次成功结果。
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{(taskListQuery.data ?? []).length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed p-4 text-sm text-muted-foreground">
|
||||
当前没有后台任务。
|
||||
</div>
|
||||
) : (
|
||||
(taskListQuery.data ?? []).map((task) => (
|
||||
<div key={task.id} className="rounded-2xl border p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium leading-5">{task.title}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{task.message || formatTaskStatus(task.status)}</p>
|
||||
</div>
|
||||
<Badge variant={task.status === "failed" ? "destructive" : "secondary"}>
|
||||
{formatTaskStatus(task.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<Progress value={task.progress} className="h-2" />
|
||||
</div>
|
||||
|
||||
{task.error ? (
|
||||
<div className="mt-3 rounded-xl bg-red-50 px-3 py-2 text-xs text-red-700">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
<span>{task.error}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-3 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
{formatDateTimeShanghai(task.createdAt)} · 耗时 {formatTaskTiming(task)}
|
||||
</span>
|
||||
{task.status === "failed" ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 gap-1 px-2"
|
||||
onClick={() => retryMutation.mutate({ taskId: task.id })}
|
||||
disabled={retryMutation.isPending}
|
||||
>
|
||||
{retryMutation.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCcw className="h-3.5 w-3.5" />}
|
||||
重试
|
||||
</Button>
|
||||
) : task.status === "succeeded" ? (
|
||||
<span className="inline-flex items-center gap-1 text-emerald-600">
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
已完成
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-primary">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
处理中
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { trpc } from "@/lib/trpc";
|
||||
|
||||
export function useBackgroundTask(taskId: string | null | undefined) {
|
||||
return trpc.task.get.useQuery(
|
||||
{ taskId: taskId || "" },
|
||||
{
|
||||
enabled: Boolean(taskId),
|
||||
retry: 3,
|
||||
retryDelay: (attempt) => Math.min(1_000 * 2 ** attempt, 8_000),
|
||||
placeholderData: (previous) => previous,
|
||||
refetchInterval: (query) => {
|
||||
const task = query.state.data;
|
||||
if (!task) return 3_000;
|
||||
return task.status === "queued" || task.status === "running" ? 3_000 : false;
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
242
client/src/lib/actionRecognition.ts
普通文件
@@ -0,0 +1,242 @@
|
||||
export type ActionType =
|
||||
| "forehand"
|
||||
| "backhand"
|
||||
| "serve"
|
||||
| "volley"
|
||||
| "overhead"
|
||||
| "slice"
|
||||
| "lob"
|
||||
| "unknown";
|
||||
|
||||
export type Point = {
|
||||
x: number;
|
||||
y: number;
|
||||
visibility?: number;
|
||||
};
|
||||
|
||||
export type TrackingState = {
|
||||
prevTimestamp?: number;
|
||||
prevRightWrist?: Point;
|
||||
prevLeftWrist?: Point;
|
||||
prevHipCenter?: Point;
|
||||
};
|
||||
|
||||
export type ActionObservation = {
|
||||
action: ActionType;
|
||||
confidence: number;
|
||||
};
|
||||
|
||||
export type ActionFrame = {
|
||||
action: ActionType;
|
||||
confidence: number;
|
||||
};
|
||||
|
||||
export const ACTION_LABELS: Record<ActionType, string> = {
|
||||
forehand: "正手挥拍",
|
||||
backhand: "反手挥拍",
|
||||
serve: "发球",
|
||||
volley: "截击",
|
||||
overhead: "高压",
|
||||
slice: "切削",
|
||||
lob: "挑高球",
|
||||
unknown: "未知动作",
|
||||
};
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function distance(a?: Point, b?: Point) {
|
||||
if (!a || !b) return 0;
|
||||
const dx = a.x - b.x;
|
||||
const dy = a.y - b.y;
|
||||
return Math.sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
|
||||
function getAngle(a?: Point, b?: Point, c?: Point) {
|
||||
if (!a || !b || !c) return 0;
|
||||
const radians = Math.atan2(c.y - b.y, c.x - b.x) - Math.atan2(a.y - b.y, a.x - b.x);
|
||||
let angle = Math.abs((radians * 180) / Math.PI);
|
||||
if (angle > 180) angle = 360 - angle;
|
||||
return angle;
|
||||
}
|
||||
|
||||
export function recognizeActionFrame(landmarks: Point[], tracking: TrackingState, timestamp: number): ActionFrame {
|
||||
const nose = landmarks[0];
|
||||
const leftShoulder = landmarks[11];
|
||||
const rightShoulder = landmarks[12];
|
||||
const leftElbow = landmarks[13];
|
||||
const rightElbow = landmarks[14];
|
||||
const leftWrist = landmarks[15];
|
||||
const rightWrist = landmarks[16];
|
||||
const leftHip = landmarks[23];
|
||||
const rightHip = landmarks[24];
|
||||
const leftKnee = landmarks[25];
|
||||
const rightKnee = landmarks[26];
|
||||
const leftAnkle = landmarks[27];
|
||||
const rightAnkle = landmarks[28];
|
||||
|
||||
const hipCenter = {
|
||||
x: ((leftHip?.x ?? 0.5) + (rightHip?.x ?? 0.5)) / 2,
|
||||
y: ((leftHip?.y ?? 0.7) + (rightHip?.y ?? 0.7)) / 2,
|
||||
};
|
||||
|
||||
const dtMs = tracking.prevTimestamp ? Math.max(16, timestamp - tracking.prevTimestamp) : 33;
|
||||
const rightSpeed = distance(rightWrist, tracking.prevRightWrist) * (1000 / dtMs);
|
||||
const leftSpeed = distance(leftWrist, tracking.prevLeftWrist) * (1000 / dtMs);
|
||||
const hipSpeed = distance(hipCenter, tracking.prevHipCenter) * (1000 / dtMs);
|
||||
const rightVerticalMotion = tracking.prevRightWrist ? tracking.prevRightWrist.y - (rightWrist?.y ?? tracking.prevRightWrist.y) : 0;
|
||||
|
||||
const shoulderTilt = Math.abs((leftShoulder?.y ?? 0.3) - (rightShoulder?.y ?? 0.3));
|
||||
const hipTilt = Math.abs((leftHip?.y ?? 0.55) - (rightHip?.y ?? 0.55));
|
||||
const headOffset = Math.abs((nose?.x ?? 0.5) - (((leftShoulder?.x ?? 0.45) + (rightShoulder?.x ?? 0.55)) / 2));
|
||||
const kneeBend = ((getAngle(leftHip, leftKnee, leftAnkle) || 165) + (getAngle(rightHip, rightKnee, rightAnkle) || 165)) / 2;
|
||||
const rightElbowAngle = getAngle(rightShoulder, rightElbow, rightWrist) || 145;
|
||||
const leftElbowAngle = getAngle(leftShoulder, leftElbow, leftWrist) || 145;
|
||||
const footSpread = Math.abs((leftAnkle?.x ?? 0.42) - (rightAnkle?.x ?? 0.58));
|
||||
const shoulderSpan = Math.abs((rightShoulder?.x ?? 0.56) - (leftShoulder?.x ?? 0.44));
|
||||
const wristSpread = Math.abs((rightWrist?.x ?? 0.62) - (leftWrist?.x ?? 0.38));
|
||||
const shoulderCenterX = ((leftShoulder?.x ?? 0.45) + (rightShoulder?.x ?? 0.55)) / 2;
|
||||
const torsoOffset = Math.abs(shoulderCenterX - hipCenter.x);
|
||||
const rightForward = (rightWrist?.x ?? shoulderCenterX) - hipCenter.x;
|
||||
const leftForward = hipCenter.x - (leftWrist?.x ?? shoulderCenterX);
|
||||
const contactHeight = hipCenter.y - (rightWrist?.y ?? hipCenter.y);
|
||||
const landmarkVisibility = landmarks
|
||||
.filter((item) => typeof item?.visibility === "number")
|
||||
.map((item) => item.visibility as number);
|
||||
const averageVisibility = landmarkVisibility.length > 0
|
||||
? landmarkVisibility.reduce((sum, item) => sum + item, 0) / landmarkVisibility.length
|
||||
: 0.8;
|
||||
|
||||
tracking.prevTimestamp = timestamp;
|
||||
tracking.prevRightWrist = rightWrist;
|
||||
tracking.prevLeftWrist = leftWrist;
|
||||
tracking.prevHipCenter = hipCenter;
|
||||
|
||||
if (averageVisibility < 0.58 || shoulderSpan < 0.08 || footSpread < 0.05 || headOffset > 0.26) {
|
||||
return { action: "unknown", confidence: 0.28 };
|
||||
}
|
||||
|
||||
const serveConfidence = clamp(
|
||||
rightVerticalMotion * 2.2 +
|
||||
Math.max(0, (hipCenter.y - (rightWrist?.y ?? hipCenter.y)) * 3.4) +
|
||||
(rightWrist?.y ?? 1) < (nose?.y ?? 0.3) ? 0.34 : 0 +
|
||||
rightElbowAngle > 145 ? 0.12 : 0 -
|
||||
shoulderTilt * 1.8,
|
||||
0,
|
||||
1,
|
||||
);
|
||||
|
||||
const overheadConfidence = clamp(
|
||||
serveConfidence * 0.62 +
|
||||
((rightWrist?.y ?? 1) < (nose?.y ?? 0.3) ? 0.22 : 0) +
|
||||
(rightSpeed > 0.34 ? 0.16 : 0) -
|
||||
(kneeBend < 150 ? 0.08 : 0),
|
||||
0,
|
||||
1,
|
||||
);
|
||||
|
||||
const forehandConfidence = clamp(
|
||||
(rightSpeed * 1.5) +
|
||||
Math.max(0, rightForward * 2.3) +
|
||||
(rightElbowAngle > 120 ? 0.1 : 0) +
|
||||
(hipSpeed > 0.07 ? 0.08 : 0) +
|
||||
(footSpread > 0.12 ? 0.05 : 0) -
|
||||
shoulderTilt * 1.1,
|
||||
0,
|
||||
1,
|
||||
);
|
||||
|
||||
const backhandConfidence = clamp(
|
||||
(leftSpeed * 1.45) +
|
||||
Math.max(0, leftForward * 2.15) +
|
||||
(leftElbowAngle > 118 ? 0.1 : 0) +
|
||||
(wristSpread > shoulderSpan * 1.2 ? 0.08 : 0) +
|
||||
(torsoOffset > 0.04 ? 0.06 : 0),
|
||||
0,
|
||||
1,
|
||||
);
|
||||
|
||||
const volleyConfidence = clamp(
|
||||
((rightSpeed + leftSpeed) * 0.8) +
|
||||
(footSpread < 0.12 ? 0.12 : 0) +
|
||||
(kneeBend < 155 ? 0.12 : 0) +
|
||||
(Math.abs(contactHeight) < 0.16 ? 0.1 : 0) +
|
||||
(hipSpeed > 0.08 ? 0.08 : 0),
|
||||
0,
|
||||
1,
|
||||
);
|
||||
|
||||
const sliceConfidence = clamp(
|
||||
forehandConfidence * 0.68 +
|
||||
((rightWrist?.y ?? 0.5) > hipCenter.y ? 0.12 : 0) +
|
||||
(contactHeight < 0.05 ? 0.1 : 0),
|
||||
0,
|
||||
1,
|
||||
);
|
||||
|
||||
const lobConfidence = clamp(
|
||||
overheadConfidence * 0.55 +
|
||||
((rightWrist?.y ?? 1) < (leftShoulder?.y ?? 0.3) ? 0.14 : 0) +
|
||||
(hipSpeed < 0.08 ? 0.06 : 0),
|
||||
0,
|
||||
1,
|
||||
);
|
||||
|
||||
const candidates = ([
|
||||
["serve", serveConfidence],
|
||||
["overhead", overheadConfidence],
|
||||
["forehand", forehandConfidence],
|
||||
["backhand", backhandConfidence],
|
||||
["volley", volleyConfidence],
|
||||
["slice", sliceConfidence],
|
||||
["lob", lobConfidence],
|
||||
] as Array<[ActionType, number]>).sort((left, right) => right[1] - left[1]);
|
||||
|
||||
const [action, confidence] = candidates[0] || ["unknown", 0];
|
||||
if (confidence < 0.45) {
|
||||
return { action: "unknown", confidence: clamp(confidence, 0.18, 0.42) };
|
||||
}
|
||||
|
||||
return { action, confidence: clamp(confidence, 0, 1) };
|
||||
}
|
||||
|
||||
export function stabilizeActionFrame(frame: ActionFrame, history: ActionObservation[]) {
|
||||
const nextHistory = [...history, { action: frame.action, confidence: frame.confidence }].slice(-6);
|
||||
history.splice(0, history.length, ...nextHistory);
|
||||
|
||||
const weights = nextHistory.map((_, index) => index + 1);
|
||||
const scores = nextHistory.reduce<Record<ActionType, number>>((acc, sample, index) => {
|
||||
acc[sample.action] = (acc[sample.action] || 0) + sample.confidence * weights[index];
|
||||
return acc;
|
||||
}, {
|
||||
forehand: 0,
|
||||
backhand: 0,
|
||||
serve: 0,
|
||||
volley: 0,
|
||||
overhead: 0,
|
||||
slice: 0,
|
||||
lob: 0,
|
||||
unknown: 0,
|
||||
});
|
||||
|
||||
const ranked = Object.entries(scores).sort((a, b) => b[1] - a[1]) as Array<[ActionType, number]>;
|
||||
const [winner = "unknown", winnerScore = 0] = ranked[0] || [];
|
||||
const [, runnerScore = 0] = ranked[1] || [];
|
||||
const winnerSamples = nextHistory.filter((sample) => sample.action === winner);
|
||||
const averageConfidence = winnerSamples.length > 0
|
||||
? winnerSamples.reduce((sum, sample) => sum + sample.confidence, 0) / winnerSamples.length
|
||||
: frame.confidence;
|
||||
|
||||
const stableAction =
|
||||
winner === "unknown" && frame.action !== "unknown" && frame.confidence >= 0.52
|
||||
? frame.action
|
||||
: winnerScore - runnerScore < 0.2 && frame.confidence >= 0.65
|
||||
? frame.action
|
||||
: winner;
|
||||
|
||||
return {
|
||||
action: stableAction,
|
||||
confidence: clamp(stableAction === frame.action ? Math.max(frame.confidence, averageConfidence) : averageConfidence, 0, 1),
|
||||
};
|
||||
}
|
||||
74
client/src/lib/camera.test.ts
普通文件
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyTrackZoom,
|
||||
getCameraVideoConstraints,
|
||||
getLiveAnalysisBitrate,
|
||||
readTrackZoomState,
|
||||
} from "./camera";
|
||||
|
||||
describe("camera utilities", () => {
|
||||
it("builds economy constraints for mobile capture", () => {
|
||||
expect(getCameraVideoConstraints("environment", true, "economy")).toEqual({
|
||||
facingMode: "environment",
|
||||
width: { ideal: 960 },
|
||||
height: { ideal: 540 },
|
||||
frameRate: { ideal: 24, max: 24 },
|
||||
});
|
||||
});
|
||||
|
||||
it("builds clarity constraints for desktop capture", () => {
|
||||
expect(getCameraVideoConstraints("user", false, "clarity")).toEqual({
|
||||
facingMode: "user",
|
||||
width: { ideal: 1920 },
|
||||
height: { ideal: 1080 },
|
||||
frameRate: { ideal: 30, max: 30 },
|
||||
});
|
||||
});
|
||||
|
||||
it("selects live analysis bitrates by preset", () => {
|
||||
expect(getLiveAnalysisBitrate("economy", true)).toBe(900_000);
|
||||
expect(getLiveAnalysisBitrate("balanced", false)).toBe(1_900_000);
|
||||
expect(getLiveAnalysisBitrate("clarity", false)).toBe(2_500_000);
|
||||
});
|
||||
|
||||
it("reads zoom capability from the active video track", () => {
|
||||
const track = {
|
||||
getCapabilities: () => ({
|
||||
zoom: { min: 1, max: 4, step: 0.5 },
|
||||
focusMode: ["continuous", "manual"],
|
||||
}),
|
||||
getSettings: () => ({
|
||||
zoom: 2,
|
||||
focusMode: "continuous",
|
||||
}),
|
||||
} as unknown as MediaStreamTrack;
|
||||
|
||||
expect(readTrackZoomState(track)).toEqual({
|
||||
supported: true,
|
||||
min: 1,
|
||||
max: 4,
|
||||
step: 0.5,
|
||||
current: 2,
|
||||
focusMode: "continuous",
|
||||
});
|
||||
});
|
||||
|
||||
it("applies zoom using media track constraints", async () => {
|
||||
let currentZoom = 1;
|
||||
const track = {
|
||||
getCapabilities: () => ({
|
||||
zoom: { min: 1, max: 3, step: 0.25 },
|
||||
}),
|
||||
getSettings: () => ({
|
||||
zoom: currentZoom,
|
||||
}),
|
||||
applyConstraints: async (constraints: MediaTrackConstraints & { advanced?: Array<{ zoom?: number }> }) => {
|
||||
currentZoom = constraints.advanced?.[0]?.zoom ?? (constraints as { zoom?: number }).zoom ?? currentZoom;
|
||||
},
|
||||
} as unknown as MediaStreamTrack;
|
||||
|
||||
const result = await applyTrackZoom(track, 2.5);
|
||||
|
||||
expect(result.current).toBe(2.5);
|
||||
});
|
||||
});
|
||||
250
client/src/lib/camera.ts
普通文件
@@ -0,0 +1,250 @@
|
||||
export type CameraQualityPreset = "economy" | "balanced" | "clarity";
|
||||
|
||||
export type CameraZoomState = {
|
||||
supported: boolean;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
current: number;
|
||||
focusMode: string;
|
||||
};
|
||||
|
||||
export type CameraRequestResult = {
|
||||
stream: MediaStream;
|
||||
appliedFacingMode: "user" | "environment";
|
||||
audioEnabled: boolean;
|
||||
usedFallback: boolean;
|
||||
};
|
||||
|
||||
type NumericRange = {
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
};
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function parseNumericRange(value: unknown): NumericRange | null {
|
||||
if (!value || typeof value !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidate = value as { min?: unknown; max?: unknown; step?: unknown };
|
||||
if (typeof candidate.min !== "number" || typeof candidate.max !== "number") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
min: candidate.min,
|
||||
max: candidate.max,
|
||||
step: typeof candidate.step === "number" && candidate.step > 0 ? candidate.step : 0.1,
|
||||
};
|
||||
}
|
||||
|
||||
export function getCameraVideoConstraints(
|
||||
facingMode: "user" | "environment",
|
||||
isMobile: boolean,
|
||||
preset: CameraQualityPreset,
|
||||
): MediaTrackConstraints {
|
||||
switch (preset) {
|
||||
case "economy":
|
||||
return {
|
||||
facingMode,
|
||||
width: { ideal: isMobile ? 960 : 1280 },
|
||||
height: { ideal: isMobile ? 540 : 720 },
|
||||
frameRate: { ideal: 24, max: 24 },
|
||||
};
|
||||
case "clarity":
|
||||
return {
|
||||
facingMode,
|
||||
width: { ideal: isMobile ? 1280 : 1920 },
|
||||
height: { ideal: isMobile ? 720 : 1080 },
|
||||
frameRate: { ideal: 30, max: 30 },
|
||||
};
|
||||
default:
|
||||
return {
|
||||
facingMode,
|
||||
width: { ideal: isMobile ? 1280 : 1600 },
|
||||
height: { ideal: isMobile ? 720 : 900 },
|
||||
frameRate: { ideal: 30, max: 30 },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeVideoConstraintCandidate(candidate: MediaTrackConstraints | true) {
|
||||
if (candidate === true) {
|
||||
return { label: "camera-any", video: true as const };
|
||||
}
|
||||
|
||||
return {
|
||||
label: JSON.stringify(candidate),
|
||||
video: candidate,
|
||||
};
|
||||
}
|
||||
|
||||
function createFallbackVideoCandidates(
|
||||
facingMode: "user" | "environment",
|
||||
isMobile: boolean,
|
||||
preset: CameraQualityPreset,
|
||||
) {
|
||||
const base = getCameraVideoConstraints(facingMode, isMobile, preset);
|
||||
const alternateFacing = facingMode === "environment" ? "user" : "environment";
|
||||
const lowRes = {
|
||||
facingMode,
|
||||
width: { ideal: isMobile ? 640 : 960 },
|
||||
height: { ideal: isMobile ? 360 : 540 },
|
||||
} satisfies MediaTrackConstraints;
|
||||
const lowResAlternate = {
|
||||
facingMode: alternateFacing,
|
||||
width: { ideal: isMobile ? 640 : 960 },
|
||||
height: { ideal: isMobile ? 360 : 540 },
|
||||
} satisfies MediaTrackConstraints;
|
||||
const anyCamera = {
|
||||
width: { ideal: isMobile ? 640 : 960 },
|
||||
height: { ideal: isMobile ? 360 : 540 },
|
||||
} satisfies MediaTrackConstraints;
|
||||
|
||||
const candidates = [
|
||||
normalizeVideoConstraintCandidate(base),
|
||||
normalizeVideoConstraintCandidate({
|
||||
...base,
|
||||
frameRate: undefined,
|
||||
}),
|
||||
normalizeVideoConstraintCandidate(lowRes),
|
||||
normalizeVideoConstraintCandidate(lowResAlternate),
|
||||
normalizeVideoConstraintCandidate(anyCamera),
|
||||
normalizeVideoConstraintCandidate(true),
|
||||
];
|
||||
|
||||
const deduped = new Map<string, { video: MediaTrackConstraints | true }>();
|
||||
candidates.forEach((candidate) => {
|
||||
if (!deduped.has(candidate.label)) {
|
||||
deduped.set(candidate.label, { video: candidate.video });
|
||||
}
|
||||
});
|
||||
return Array.from(deduped.values());
|
||||
}
|
||||
|
||||
export async function requestCameraStream(options: {
|
||||
facingMode: "user" | "environment";
|
||||
isMobile: boolean;
|
||||
preset: CameraQualityPreset;
|
||||
audio?: false | MediaTrackConstraints;
|
||||
}) {
|
||||
const videoCandidates = createFallbackVideoCandidates(options.facingMode, options.isMobile, options.preset);
|
||||
const audioCandidates = options.audio ? [options.audio, false] : [false];
|
||||
let lastError: unknown = null;
|
||||
|
||||
for (const audio of audioCandidates) {
|
||||
for (let index = 0; index < videoCandidates.length; index += 1) {
|
||||
const video = videoCandidates[index]?.video ?? true;
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ video, audio });
|
||||
const videoTrack = stream.getVideoTracks()[0] || null;
|
||||
const settings = (
|
||||
videoTrack && typeof (videoTrack as MediaStreamTrack & { getSettings?: () => unknown }).getSettings === "function"
|
||||
? (videoTrack as MediaStreamTrack & { getSettings: () => unknown }).getSettings()
|
||||
: {}
|
||||
) as Record<string, unknown>;
|
||||
const appliedFacingMode = settings.facingMode === "user" ? "user" : settings.facingMode === "environment" ? "environment" : options.facingMode;
|
||||
|
||||
return {
|
||||
stream,
|
||||
appliedFacingMode,
|
||||
audioEnabled: stream.getAudioTracks().length > 0,
|
||||
usedFallback: index > 0 || audio === false && Boolean(options.audio),
|
||||
} satisfies CameraRequestResult;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError instanceof Error ? lastError : new Error("无法访问摄像头");
|
||||
}
|
||||
|
||||
export function getLiveAnalysisBitrate(preset: CameraQualityPreset, isMobile: boolean) {
|
||||
switch (preset) {
|
||||
case "economy":
|
||||
return isMobile ? 900_000 : 1_100_000;
|
||||
case "clarity":
|
||||
return isMobile ? 1_900_000 : 2_500_000;
|
||||
default:
|
||||
return isMobile ? 1_300_000 : 1_900_000;
|
||||
}
|
||||
}
|
||||
|
||||
export function readTrackZoomState(track: MediaStreamTrack | null): CameraZoomState {
|
||||
if (!track) {
|
||||
return {
|
||||
supported: false,
|
||||
min: 1,
|
||||
max: 1,
|
||||
step: 0.1,
|
||||
current: 1,
|
||||
focusMode: "auto",
|
||||
};
|
||||
}
|
||||
|
||||
const capabilities = (
|
||||
typeof (track as MediaStreamTrack & { getCapabilities?: () => unknown }).getCapabilities === "function"
|
||||
? (track as MediaStreamTrack & { getCapabilities: () => unknown }).getCapabilities()
|
||||
: {}
|
||||
) as Record<string, unknown>;
|
||||
const settings = (
|
||||
typeof (track as MediaStreamTrack & { getSettings?: () => unknown }).getSettings === "function"
|
||||
? (track as MediaStreamTrack & { getSettings: () => unknown }).getSettings()
|
||||
: {}
|
||||
) as Record<string, unknown>;
|
||||
|
||||
const zoomRange = parseNumericRange(capabilities.zoom);
|
||||
const focusModes = Array.isArray(capabilities.focusMode)
|
||||
? capabilities.focusMode.filter((item: unknown): item is string => typeof item === "string")
|
||||
: [];
|
||||
const focusMode = typeof settings.focusMode === "string"
|
||||
? settings.focusMode
|
||||
: focusModes.includes("continuous")
|
||||
? "continuous"
|
||||
: focusModes[0] || "auto";
|
||||
|
||||
if (!zoomRange || zoomRange.max - zoomRange.min <= 0.001) {
|
||||
return {
|
||||
supported: false,
|
||||
min: 1,
|
||||
max: 1,
|
||||
step: 0.1,
|
||||
current: 1,
|
||||
focusMode,
|
||||
};
|
||||
}
|
||||
|
||||
const current = typeof settings.zoom === "number"
|
||||
? clamp(settings.zoom, zoomRange.min, zoomRange.max)
|
||||
: zoomRange.min;
|
||||
|
||||
return {
|
||||
supported: true,
|
||||
min: zoomRange.min,
|
||||
max: zoomRange.max,
|
||||
step: zoomRange.step,
|
||||
current,
|
||||
focusMode,
|
||||
};
|
||||
}
|
||||
|
||||
export async function applyTrackZoom(track: MediaStreamTrack | null, rawZoom: number) {
|
||||
const currentState = readTrackZoomState(track);
|
||||
if (!track || !currentState.supported) {
|
||||
return currentState;
|
||||
}
|
||||
|
||||
const zoom = clamp(rawZoom, currentState.min, currentState.max);
|
||||
try {
|
||||
await track.applyConstraints({ advanced: [{ zoom }] } as unknown as MediaTrackConstraints);
|
||||
} catch {
|
||||
await track.applyConstraints({ zoom } as unknown as MediaTrackConstraints);
|
||||
}
|
||||
return readTrackZoomState(track);
|
||||
}
|
||||
278
client/src/lib/changelog.ts
普通文件
@@ -0,0 +1,278 @@
|
||||
export type ChangeLogEntry = {
|
||||
version: string;
|
||||
releaseDate: string;
|
||||
repoVersion: string;
|
||||
summary: string;
|
||||
features: string[];
|
||||
tests: string[];
|
||||
};
|
||||
|
||||
export const CHANGE_LOG_ENTRIES: ChangeLogEntry[] = [
|
||||
{
|
||||
version: "2026.03.16-live-camera-runtime-refresh",
|
||||
releaseDate: "2026-03-16",
|
||||
repoVersion: "8e9e491",
|
||||
summary: "修复实时分析页偶发残留在同步观看状态、标题乱码,以及摄像头预览绑定波动导致的启动失败。",
|
||||
features: [
|
||||
"live-camera 在打开拍摄引导、启用摄像头、开始分析前,都会先向服务端强制刷新 runtime 状态,避免旧的 viewer 锁残留导致本机明明已释放却仍无法启动",
|
||||
"同步观看标题新增乱码恢复逻辑,可自动把 UTF-8 被误按 Latin-1 显示的标题恢复成正常中文,避免出现 `æœ...` 一类异常标题",
|
||||
"摄像头启动链路改为以 `getUserMedia` 成功为准;即使本地预览 `<video>` 的 `srcObject` 或 `play()` 在当前浏览器里短暂失败,也不会直接把整次启动判死",
|
||||
"e2e mock 的媒体流补齐为带假视频轨道的流对象,并把 viewer 回归改为校验“服务端 relay、无 viewer-signal”行为,减少和旧 P2P 逻辑混淆",
|
||||
],
|
||||
tests: [
|
||||
"pnpm exec playwright test tests/e2e/app.spec.ts --grep \"live camera page exposes camera startup controls|live camera switches into viewer mode when another device already owns analysis|live camera recovers mojibake viewer titles before rendering|live camera no longer opens viewer peer retries when server relay is active\"",
|
||||
"pnpm build",
|
||||
"部署后线上 smoke: `https://te.hao.work/live-camera` 登录 H1 后可见空闲态“启动摄像头”入口,确认不再被残留 viewer 锁卡住;公开站点前端资源为 `assets/index-33wVjC4p.js` 与 `assets/index-tNGuStgv.css`",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "2026.03.16-live-viewer-server-relay",
|
||||
releaseDate: "2026-03-16",
|
||||
repoVersion: "bb46d26",
|
||||
summary: "实时分析同步观看改为由 media 服务中转帧图,不再依赖浏览器之间的 P2P 视频连接。",
|
||||
features: [
|
||||
"owner 端现在会把带骨架、关键点和虚拟形象叠层的合成画布压缩成 JPEG 并持续上传到 media 服务",
|
||||
"viewer 端改为直接拉取 media 服务中的最新同步帧图,不再建立 WebRTC viewer peer 连接,因此跨网络和多端观看更稳定",
|
||||
"同步观看模式文案改为明确提示“通过 media 服务中转”,等待同步时也会自动轮询最新画面",
|
||||
"media 服务新增 live-frame 上传与静态分发能力,并记录最近同步帧的更新时间,方便后续扩展成更高频的服务端中转流",
|
||||
],
|
||||
tests: [
|
||||
"cd media && go test ./...",
|
||||
"pnpm build",
|
||||
"playwright-skill 线上 smoke: 先用 media 服务创建 relay session、上传 live-frame,并把 H1 的 `live_analysis_runtime` 注入为 active viewer 场景;随后访问 `https://te.hao.work/live-camera`,确认页面进入“同步观看模式”、同步帧来自 `/media/assets/sessions/.../live-frame.jpg`,且 `viewer-signal` 请求数为 0",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "2026.03.16-camera-startup-fallbacks",
|
||||
releaseDate: "2026-03-16",
|
||||
repoVersion: "a211562",
|
||||
summary: "修复部分设备上摄像头因后置镜头约束、分辨率约束或麦克风不可用而直接启动失败的问题。",
|
||||
features: [
|
||||
"live-camera 与 recorder 改为共用分级降级的摄像头请求流程,会在当前画质失败时自动降分辨率、降约束并回退到兼容镜头",
|
||||
"当设备不支持默认后置摄像头或当前镜头不可用时,页面会自动切换到实际可用的镜头方向,避免直接报错后卡死在未启动状态",
|
||||
"recorder 预览启动不再被麦克风权限或麦克风设备异常整体拖死;麦克风不可用时会自动回退到仅视频模式",
|
||||
"兼容模式命中时前端会给出明确提示,方便区分“已自动降级成功”与“仍然无法访问摄像头”的场景",
|
||||
],
|
||||
tests: [
|
||||
"pnpm build",
|
||||
"部署后线上 smoke: `https://te.hao.work/` 已提供 `assets/index-CRxtWK07.js` 与 `assets/index-tNGuStgv.css`;通过注入 `getUserMedia` 回归验证 `/live-camera` 首轮高约束失败后会自动切到兼容摄像头模式,`/recorder` 在麦克风不可用时会自动回退到仅视频模式并继续启动预览",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "2026.03.16-live-analysis-viewer-full-sync",
|
||||
releaseDate: "2026-03-16",
|
||||
repoVersion: "922a9fb",
|
||||
summary: "多端同步观看改为按持有端快照完整渲染,另一设备可同步看到视频状态、模式、画质、虚拟形象和保存阶段信息。",
|
||||
features: [
|
||||
"viewer 端现在同步显示持有端的会话标题、训练模式、设备端、拍摄视角、画质模式、虚拟形象状态和最近同步时间",
|
||||
"同步观看时的分析阶段、保存阶段、已完成状态也会跟随主端刷新,不再只显示本地默认状态",
|
||||
"viewer 页面会自动关闭拍摄校准弹窗,避免同步观看时被“启用摄像头”流程遮挡",
|
||||
"新增 viewer 同步信息卡,明确允许 1 秒级延迟,并持续显示最近心跳时间",
|
||||
],
|
||||
tests: [
|
||||
"pnpm exec playwright test tests/e2e/app.spec.ts --grep \"live camera switches into viewer mode|viewer stream|recorder blocks\"",
|
||||
"pnpm build",
|
||||
"部署后线上 smoke: `https://te.hao.work/` 已提供 `assets/index-HRdM3fxq.js` 与 `assets/index-tNGuStgv.css`;同账号 H1 双端登录后,移动端 owner 可开始实时分析,桌面端 `/live-camera` 自动进入同步观看并显示主端信息、同步视频流,owner 点击结束分析后 viewer 会同步进入保存阶段",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "2026.03.16-live-analysis-lock-hardening",
|
||||
releaseDate: "2026-03-16",
|
||||
repoVersion: "f9db6ef",
|
||||
summary: "修复同账号多端实时分析在旧登录态下仍可重复占用摄像头的问题,补强同步观看重试、录制页占用锁,并修复部署后启动阶段长时间 502。",
|
||||
features: [
|
||||
"旧用户名登录 token 即使缺少 `sid`,现在也会按 token 本身派生唯一会话标识,不再把不同设备错误识别成同一持有端",
|
||||
"同步观看模式新增 viewer 自动重试:当持有端刚启动推流、viewer 首次连接返回 `viewer stream not ready` 时,会自动重连而不是一直黑屏",
|
||||
"在线录制页接入实时分析占用锁;当其他设备正在 `/live-camera` 分析时,本页会禁止再次启动摄像头和录制",
|
||||
"应用启动改为先监听 HTTP 端口、再后台串行执行教程图同步和标准库预热,修复新容器上线时公网长时间返回 502 的问题",
|
||||
"线上 smoke 已确认 `https://te.hao.work/live-camera` 与 `/recorder` 都已切换到本次新构建,公开站点不再返回 502",
|
||||
],
|
||||
tests: [
|
||||
"curl -I https://te.hao.work/",
|
||||
"pnpm check",
|
||||
"pnpm exec vitest run server/_core/sdk.test.ts server/features.test.ts",
|
||||
"pnpm exec playwright test tests/e2e/app.spec.ts --grep \"viewer mode|viewer stream|recorder blocks\"",
|
||||
"pnpm build",
|
||||
"线上 smoke: H1 手机端开启实时分析后,PC 端 `/live-camera` 自动进入同步观看并显示同步画面,`/recorder` 禁止启动摄像头;结束分析后会话可正常释放",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "2026.03.16-live-analysis-runtime-migration",
|
||||
releaseDate: "2026-03-16",
|
||||
repoVersion: "2b72ef9",
|
||||
summary: "修复实时分析因缺失 `live_analysis_runtime` 表导致的启动失败,并补齐迁移记录避免后续部署再次漏表。",
|
||||
features: [
|
||||
"生产库补建 `live_analysis_runtime` 表,并补写 `__drizzle_migrations` 中缺失的 `0011_live_analysis_runtime` 记录",
|
||||
"仓库内 Drizzle migration journal 补齐 `0011_live_analysis_runtime` 条目,后续 `docker compose` 部署可正确感知该迁移",
|
||||
"实时分析启动链路恢复,`/live-camera` 再次可以读取 runtime 锁并正常进入分析准备流程",
|
||||
"线上 smoke 已确认 `https://te.hao.work/` 正在提供本次新构建,当前前端资源为 `assets/index-B3BN5hY-.js` 与 `assets/index-BL6GQzUF.css`",
|
||||
],
|
||||
tests: [
|
||||
"pnpm check",
|
||||
"pnpm exec vitest run server/features.test.ts",
|
||||
"pnpm build",
|
||||
"docker compose exec -T db mysql ... SHOW TABLES LIKE 'live_analysis_runtime'",
|
||||
"curl -I https://te.hao.work/live-camera",
|
||||
"Playwright smoke: 登录 `H1` 后访问 `/live-camera`,`analysis.runtimeGet` / `analysis.runtimeAcquire` / `analysis.runtimeRelease` 全部返回 200",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "2026.03.16-live-camera-multidevice-viewer",
|
||||
releaseDate: "2026-03-16",
|
||||
repoVersion: "4e4122d",
|
||||
summary: "实时分析新增同账号多端互斥和同步观看模式,分析持有端独占摄像头,其它端只能查看同步画面与核心识别结果。",
|
||||
features: [
|
||||
"同一账号在 `/live-camera` 进入实时分析后,会写入按用户维度的 runtime 锁,其他设备不能重复启动摄像头或分析",
|
||||
"其他设备会自动进入“同步观看模式”,可订阅持有端的实时画面,并同步看到动作、评分、反馈、最近片段和归档段数",
|
||||
"同步观看复用 media 服务的 WebRTC viewer 通道,传输的是带骨架、关键点和虚拟形象覆盖后的合成画面",
|
||||
"runtime 锁按 session sid 区分持有端,兼容缺少 sid 的旧 token,超过 15 秒无心跳会自动判定为陈旧并释放",
|
||||
"线上 smoke 已确认 `https://te.hao.work/live-camera` 已切换到本次新构建,公开站点正在提供这次发布的最新前端资源",
|
||||
],
|
||||
tests: [
|
||||
"pnpm check",
|
||||
"pnpm exec vitest run server/features.test.ts",
|
||||
"go test ./... && go build ./... (media)",
|
||||
"pnpm build",
|
||||
"pnpm exec playwright test tests/e2e/app.spec.ts --grep \"live camera\"",
|
||||
"pnpm exec playwright test tests/e2e/app.spec.ts --grep \"recorder flow archives a session and exposes it in videos\"",
|
||||
"curl -I https://te.hao.work/live-camera",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "2026.03.16-live-analysis-overlay-archive",
|
||||
releaseDate: "2026-03-16",
|
||||
repoVersion: "4fb2d09",
|
||||
summary: "实时分析新增 60 秒自动归档录像,录制内容会保留骨架、关键点和虚拟形象叠层,并同步进入视频库。",
|
||||
features: [
|
||||
"实时分析开始后会自动录制合成画布,每 60 秒自动切段归档",
|
||||
"归档录像会保留原视频、骨架线、关键点和当前虚拟形象覆盖效果",
|
||||
"归档片段会自动写入视频库,标签显示为“实时分析”",
|
||||
"删除视频库中的实时分析录像时,不会删除已写入的实时分析数据和训练记录",
|
||||
"线上 smoke 已确认 `https://te.hao.work/` 已切换到本次新构建,`/live-camera`、`/videos`、`/changelog` 页面均可正常访问",
|
||||
],
|
||||
tests: [
|
||||
"pnpm check",
|
||||
"pnpm test",
|
||||
"pnpm build",
|
||||
"pnpm test:e2e",
|
||||
"Playwright smoke: 真实站点登录 H1,完成 /live-camera 引导、开始/结束分析,并确认 /videos 可见实时分析条目",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "2026.03.15-live-analysis-leave-hint",
|
||||
releaseDate: "2026-03-15",
|
||||
repoVersion: "5c2dcf2",
|
||||
summary: "实时分析结束后增加离开提示,明确何时必须停留、何时可以安全关闭或切页。",
|
||||
features: [
|
||||
"分析进行中显示“不要关闭或切走页面”提示",
|
||||
"结束分析后保存阶段显示“请暂时停留当前页面”提示",
|
||||
"保存成功后明确提示“现在可以关闭浏览器或切换到其他页面”",
|
||||
"分析中和保存中挂接 beforeunload 提醒,减少误关页面导致的数据丢失",
|
||||
],
|
||||
tests: [
|
||||
"pnpm check",
|
||||
"pnpm build",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "2026.03.15-training-generator-collapse",
|
||||
releaseDate: "2026-03-15",
|
||||
repoVersion: "1ce94f6",
|
||||
summary: "训练计划生成面板在桌面端默认折叠到右侧,按需展开查看和重新生成。",
|
||||
features: [
|
||||
"训练页右侧生成器在桌面端默认折叠为窄栏",
|
||||
"点击右侧折叠栏可展开“重新生成计划”完整面板",
|
||||
"移动端继续直接展示完整生成器,避免隐藏关键操作",
|
||||
"未生成计划时点击“前往生成训练计划”会自动展开并滚动到生成面板",
|
||||
],
|
||||
tests: [
|
||||
"pnpm check",
|
||||
"pnpm build",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "2026.03.15-progress-time-actions",
|
||||
releaseDate: "2026-03-15",
|
||||
repoVersion: "71caf0d",
|
||||
summary: "最近训练记录默认显示具体上海时间,并直接展示录制动作数据摘要。",
|
||||
features: [
|
||||
"最近训练记录摘要行默认显示到秒的 Asia/Shanghai 时间",
|
||||
"录制记录列表直接展示主动作和前 3 个动作统计,无需先展开",
|
||||
"展开态动作明细统一用中文动作标签展示",
|
||||
"提醒页通知时间统一切换为 Asia/Shanghai",
|
||||
],
|
||||
tests: [
|
||||
"pnpm check",
|
||||
"pnpm build",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "2026.03.15-session-changelog",
|
||||
releaseDate: "2026-03-15",
|
||||
repoVersion: "a9ea94f",
|
||||
summary: "多端 session、更新日志页面、录制动作摘要与上海时区显示同步收口。",
|
||||
features: [
|
||||
"用户名登录生成独立 sid,同一账号多端登录保持并行有效",
|
||||
"新增 /changelog 页面和侧边栏入口,展示版本、仓库版本和验证记录",
|
||||
"训练进度页可展开查看最近训练记录的具体时间、动作统计和录制有效性",
|
||||
"录制页增加动作抽样摘要、无效录制标记与 media 预归档状态",
|
||||
"Dashboard、任务中心、管理台、评分、日志、视觉测试、视频库等页面统一使用 Asia/Shanghai 时间显示",
|
||||
],
|
||||
tests: [
|
||||
"pnpm check",
|
||||
"pnpm test",
|
||||
"pnpm test:go",
|
||||
"pnpm build",
|
||||
"Playwright smoke: https://te.hao.work/ 双上下文登录 H1 后 dashboard 均保持有效;线上 /changelog 仍显示旧构建,待部署后复测",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "2026.03.15-recorder-zoom",
|
||||
releaseDate: "2026-03-15",
|
||||
repoVersion: "c4ec397",
|
||||
summary: "补齐录制页与实时分析页的节省流量模式、镜头缩放和移动端控制。",
|
||||
features: [
|
||||
"在线录制默认切换为节省流量模式",
|
||||
"在线录制支持镜头焦距放大缩小",
|
||||
"实时分析支持镜头焦距放大缩小",
|
||||
"页面内增加拍摄与流量设置说明",
|
||||
],
|
||||
tests: [
|
||||
"pnpm check",
|
||||
"pnpm exec vitest run client/src/lib/media.test.ts client/src/lib/camera.test.ts",
|
||||
"Playwright 真实站点检查 /live-camera 与 /recorder 新控件可见",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "2026.03.15-videos-crud",
|
||||
releaseDate: "2026-03-15",
|
||||
repoVersion: "bd89981",
|
||||
summary: "视频库支持新增、编辑、删除训练视频记录。",
|
||||
features: [
|
||||
"视频库新增外部视频登记",
|
||||
"视频库支持编辑标题和动作类型",
|
||||
"视频库支持删除视频及关联分析引用",
|
||||
"视频详情读取按当前用户权限收敛",
|
||||
],
|
||||
tests: [
|
||||
"pnpm check",
|
||||
"pnpm exec vitest run server/features.test.ts -t \"video\\\\.\"",
|
||||
"Playwright 真实站点完成 /videos 新增-编辑-删除全链路",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "v3.0.0",
|
||||
releaseDate: "2026-03-14",
|
||||
repoVersion: "历史版本",
|
||||
summary: "教程库、提醒、通知等学习能力上线。",
|
||||
features: [
|
||||
"训练视频教程库",
|
||||
"教程自评与学习进度",
|
||||
"训练提醒通知",
|
||||
"通知历史管理",
|
||||
],
|
||||
tests: [
|
||||
"教程库、提醒、通知相关测试通过",
|
||||
],
|
||||
},
|
||||
];
|
||||
129
client/src/lib/liveCamera.test.ts
普通文件
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ACTION_WINDOW_FRAMES,
|
||||
AVATAR_PRESETS,
|
||||
createStableActionState,
|
||||
getAvatarAnchors,
|
||||
getAvatarPreset,
|
||||
resolveAvatarKeyFromPrompt,
|
||||
stabilizeActionStream,
|
||||
type FrameActionSample,
|
||||
} from "./liveCamera";
|
||||
|
||||
function feedSamples(samples: Array<Omit<FrameActionSample, "timestamp">>, intervalMs = 33) {
|
||||
const history: FrameActionSample[] = [];
|
||||
const state = createStableActionState();
|
||||
let lastResult = null as ReturnType<typeof stabilizeActionStream> | null;
|
||||
|
||||
samples.forEach((sample, index) => {
|
||||
lastResult = stabilizeActionStream(
|
||||
{
|
||||
...sample,
|
||||
timestamp: index * intervalMs,
|
||||
},
|
||||
history,
|
||||
state,
|
||||
);
|
||||
});
|
||||
|
||||
return { history, state, lastResult };
|
||||
}
|
||||
|
||||
describe("live camera action stabilizer", () => {
|
||||
it("locks a dominant action after a full temporal window", () => {
|
||||
const samples = Array.from({ length: ACTION_WINDOW_FRAMES * 2 }, () => ({
|
||||
action: "forehand" as const,
|
||||
confidence: 0.84,
|
||||
}));
|
||||
const { lastResult } = feedSamples(samples);
|
||||
|
||||
expect(lastResult?.stableAction).toBe("forehand");
|
||||
expect(lastResult?.windowAction).toBe("forehand");
|
||||
expect(lastResult?.pending).toBe(false);
|
||||
expect(lastResult?.windowShare).toBeGreaterThan(0.9);
|
||||
});
|
||||
|
||||
it("ignores brief action spikes and keeps the stable action", () => {
|
||||
const stableFrames = Array.from({ length: ACTION_WINDOW_FRAMES * 2 }, () => ({
|
||||
action: "forehand" as const,
|
||||
confidence: 0.82,
|
||||
}));
|
||||
const noisyFrames = Array.from({ length: 5 }, () => ({
|
||||
action: "backhand" as const,
|
||||
confidence: 0.88,
|
||||
}));
|
||||
const { lastResult } = feedSamples([...stableFrames, ...noisyFrames]);
|
||||
|
||||
expect(lastResult?.stableAction).toBe("forehand");
|
||||
expect(lastResult?.pending).toBe(false);
|
||||
});
|
||||
|
||||
it("switches only after the next action persists long enough", () => {
|
||||
const forehandFrames = Array.from({ length: ACTION_WINDOW_FRAMES * 2 }, () => ({
|
||||
action: "forehand" as const,
|
||||
confidence: 0.8,
|
||||
}));
|
||||
const backhandFrames = Array.from({ length: ACTION_WINDOW_FRAMES * 2 }, () => ({
|
||||
action: "backhand" as const,
|
||||
confidence: 0.85,
|
||||
}));
|
||||
const { lastResult, state } = feedSamples([...forehandFrames, ...backhandFrames]);
|
||||
|
||||
expect(lastResult?.stableAction).toBe("backhand");
|
||||
expect(state.switchCount).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("requires a longer delay before falling back to unknown", () => {
|
||||
const forehandFrames = Array.from({ length: ACTION_WINDOW_FRAMES * 2 }, () => ({
|
||||
action: "forehand" as const,
|
||||
confidence: 0.83,
|
||||
}));
|
||||
const unknownFrames = Array.from({ length: 10 }, () => ({
|
||||
action: "unknown" as const,
|
||||
confidence: 0.4,
|
||||
}));
|
||||
const { lastResult } = feedSamples([...forehandFrames, ...unknownFrames]);
|
||||
|
||||
expect(lastResult?.stableAction).toBe("forehand");
|
||||
});
|
||||
});
|
||||
|
||||
describe("live camera avatar helpers", () => {
|
||||
it("maps prompt keywords into avatar presets", () => {
|
||||
expect(resolveAvatarKeyFromPrompt("切换成猩猩形象", "gorilla")).toBe("gorilla");
|
||||
expect(resolveAvatarKeyFromPrompt("dog mascot", "gorilla")).toBe("dog");
|
||||
expect(resolveAvatarKeyFromPrompt("狐狸风格", "gorilla")).toBe("fox");
|
||||
expect(resolveAvatarKeyFromPrompt("兔子教练", "gorilla")).toBe("rabbit");
|
||||
expect(resolveAvatarKeyFromPrompt("BeachKing 3D 替身", "gorilla")).toBe("beachKing");
|
||||
expect(resolveAvatarKeyFromPrompt("Juanita avatar", "gorilla")).toBe("juanita3d");
|
||||
expect(resolveAvatarKeyFromPrompt("", "pig")).toBe("pig");
|
||||
});
|
||||
|
||||
it("exposes full-body 3d avatar examples with CC0 metadata", () => {
|
||||
const presets = AVATAR_PRESETS.filter((preset) => preset.category === "full-body-3d");
|
||||
|
||||
expect(presets).toHaveLength(4);
|
||||
expect(presets.every((preset) => preset.license === "CC0")).toBe(true);
|
||||
expect(getAvatarPreset("sportTv")?.modelUrl).toContain("arweave.net");
|
||||
});
|
||||
|
||||
it("builds avatar anchors from pose landmarks", () => {
|
||||
const landmarks = Array.from({ length: 33 }, () => ({ x: 0.5, y: 0.5, visibility: 0.95 }));
|
||||
landmarks[0] = { x: 0.5, y: 0.16, visibility: 0.99 };
|
||||
landmarks[11] = { x: 0.4, y: 0.3, visibility: 0.99 };
|
||||
landmarks[12] = { x: 0.6, y: 0.3, visibility: 0.99 };
|
||||
landmarks[15] = { x: 0.28, y: 0.44, visibility: 0.99 };
|
||||
landmarks[16] = { x: 0.72, y: 0.44, visibility: 0.99 };
|
||||
landmarks[23] = { x: 0.44, y: 0.58, visibility: 0.99 };
|
||||
landmarks[24] = { x: 0.56, y: 0.58, visibility: 0.99 };
|
||||
landmarks[27] = { x: 0.43, y: 0.92, visibility: 0.99 };
|
||||
landmarks[28] = { x: 0.57, y: 0.92, visibility: 0.99 };
|
||||
|
||||
const anchors = getAvatarAnchors(landmarks, 1280, 720);
|
||||
|
||||
expect(anchors).not.toBeNull();
|
||||
expect(anchors?.headRadius).toBeGreaterThan(30);
|
||||
expect(anchors?.bodyHeight).toBeGreaterThan(120);
|
||||
expect(anchors?.rightHandX).toBeGreaterThan(anchors?.leftHandX || 0);
|
||||
});
|
||||
});
|
||||
744
client/src/lib/liveCamera.ts
普通文件
@@ -0,0 +1,744 @@
|
||||
export type LiveActionType = "forehand" | "backhand" | "serve" | "volley" | "overhead" | "slice" | "lob" | "unknown";
|
||||
|
||||
export type PosePoint = {
|
||||
x: number;
|
||||
y: number;
|
||||
visibility?: number;
|
||||
};
|
||||
|
||||
export type AvatarKey =
|
||||
| "gorilla"
|
||||
| "monkey"
|
||||
| "dog"
|
||||
| "pig"
|
||||
| "cat"
|
||||
| "fox"
|
||||
| "panda"
|
||||
| "lion"
|
||||
| "tiger"
|
||||
| "rabbit"
|
||||
| "beachKing"
|
||||
| "jenny3d"
|
||||
| "juanita3d"
|
||||
| "sportTv";
|
||||
|
||||
export type AvatarCategory = "animal" | "full-body-3d";
|
||||
|
||||
export type AvatarPreset = {
|
||||
key: AvatarKey;
|
||||
label: string;
|
||||
category: AvatarCategory;
|
||||
keywords: string[];
|
||||
description?: string;
|
||||
collection?: string;
|
||||
license?: string;
|
||||
sourceUrl?: string;
|
||||
modelUrl?: string;
|
||||
};
|
||||
|
||||
export type AvatarRenderState = {
|
||||
enabled: boolean;
|
||||
avatarKey: AvatarKey;
|
||||
customLabel?: string;
|
||||
};
|
||||
|
||||
export type FrameActionSample = {
|
||||
action: LiveActionType;
|
||||
confidence: number;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
export type StableActionState = {
|
||||
current: LiveActionType;
|
||||
currentSince: number | null;
|
||||
candidate: LiveActionType | null;
|
||||
candidateSince: number | null;
|
||||
candidateWindows: number;
|
||||
switchCount: number;
|
||||
};
|
||||
|
||||
export type StabilizedActionMeta = {
|
||||
stableAction: LiveActionType;
|
||||
stableConfidence: number;
|
||||
windowAction: LiveActionType;
|
||||
windowConfidence: number;
|
||||
windowShare: number;
|
||||
windowFrames: number;
|
||||
windowProgress: number;
|
||||
pending: boolean;
|
||||
pendingAction: LiveActionType | null;
|
||||
stableMs: number;
|
||||
candidateMs: number;
|
||||
rawVolatility: number;
|
||||
switchCount: number;
|
||||
};
|
||||
|
||||
type ActionStat = {
|
||||
count: number;
|
||||
totalConfidence: number;
|
||||
share: number;
|
||||
averageConfidence: number;
|
||||
strength: number;
|
||||
};
|
||||
|
||||
type AvatarAnchors = {
|
||||
headX: number;
|
||||
headY: number;
|
||||
headRadius: number;
|
||||
bodyX: number;
|
||||
bodyY: number;
|
||||
bodyWidth: number;
|
||||
bodyHeight: number;
|
||||
shoulderY: number;
|
||||
footY: number;
|
||||
leftHandX: number;
|
||||
leftHandY: number;
|
||||
rightHandX: number;
|
||||
rightHandY: number;
|
||||
};
|
||||
|
||||
type AvatarVisualSpec = {
|
||||
src: string;
|
||||
bodyFill: string;
|
||||
limbStroke: string;
|
||||
glow: string;
|
||||
renderMode: "badge" | "full-figure";
|
||||
figureScale?: number;
|
||||
figureOffsetY?: number;
|
||||
};
|
||||
|
||||
const ACTIONS: LiveActionType[] = ["forehand", "backhand", "serve", "volley", "overhead", "slice", "lob", "unknown"];
|
||||
|
||||
export const ACTION_WINDOW_FRAMES = 24;
|
||||
const ACTION_WINDOW_MIN_SHARE = 0.6;
|
||||
const ACTION_WINDOW_MIN_CONFIDENCE = 0.58;
|
||||
const ACTION_SWITCH_MIN_MS = 700;
|
||||
const ACTION_UNKNOWN_MIN_MS = 900;
|
||||
const ACTION_LOCK_IN_WINDOWS = 2;
|
||||
const ACTION_SWITCH_DELTA = 0.12;
|
||||
|
||||
export const AVATAR_PRESETS: AvatarPreset[] = [
|
||||
{ key: "gorilla", label: "猩猩", category: "animal", keywords: ["gorilla", "ape", "猩猩", "猩", "大猩猩"], description: "轻量动物替身,移动端负担最低。" },
|
||||
{ key: "monkey", label: "猴子", category: "animal", keywords: ["monkey", "ape", "猴", "猴子"], description: "轻量动物替身,适合快速练习。" },
|
||||
{ key: "dog", label: "狗", category: "animal", keywords: ["dog", "puppy", "犬", "狗", "小狗"], description: "轻量动物替身,覆盖速度快。" },
|
||||
{ key: "pig", label: "猪", category: "animal", keywords: ["pig", "猪", "小猪"], description: "轻量动物替身,适合低端设备。" },
|
||||
{ key: "cat", label: "猫", category: "animal", keywords: ["cat", "kitty", "猫", "小猫"], description: "轻量动物替身,适合低码率录制。" },
|
||||
{ key: "fox", label: "狐狸", category: "animal", keywords: ["fox", "狐狸"], description: "轻量动物替身,动作切换反馈清晰。" },
|
||||
{ key: "panda", label: "熊猫", category: "animal", keywords: ["panda", "熊猫"], description: "轻量动物替身,适合直播预览。" },
|
||||
{ key: "lion", label: "狮子", category: "animal", keywords: ["lion", "狮子"], description: "轻量动物替身,轮廓感更强。" },
|
||||
{ key: "tiger", label: "老虎", category: "animal", keywords: ["tiger", "虎", "老虎"], description: "轻量动物替身,适合训练 PK。" },
|
||||
{ key: "rabbit", label: "兔子", category: "animal", keywords: ["rabbit", "bunny", "兔", "兔子"], description: "轻量动物替身,适合日常训练。" },
|
||||
{
|
||||
key: "beachKing",
|
||||
label: "BeachKing",
|
||||
category: "full-body-3d",
|
||||
keywords: ["beachking", "beach king", "海滩王", "3d beach", "beach avatar"],
|
||||
description: "CC0 全身 3D 示例,适合覆盖竖屏全身站姿。",
|
||||
collection: "100Avatars R3",
|
||||
license: "CC0",
|
||||
sourceUrl: "https://github.com/ToxSam/open-source-avatars",
|
||||
modelUrl: "https://arweave.net/uKhDMselhdUyeJKjelpuVsL8s-a9v_Wqq75TQfCfnos",
|
||||
},
|
||||
{
|
||||
key: "jenny3d",
|
||||
label: "Jenny",
|
||||
category: "full-body-3d",
|
||||
keywords: ["jenny", "frog coach", "青蛙教练", "3d jenny", "jenny avatar"],
|
||||
description: "CC0 全身 3D 示例,适合想要更完整人物轮廓时使用。",
|
||||
collection: "100Avatars R3",
|
||||
license: "CC0",
|
||||
sourceUrl: "https://github.com/ToxSam/open-source-avatars",
|
||||
modelUrl: "https://arweave.net/kgTirc4OvUWbJhIKC2CB3_pYsYuB62KTj90IdE8s3sk",
|
||||
},
|
||||
{
|
||||
key: "juanita3d",
|
||||
label: "Juanita",
|
||||
category: "full-body-3d",
|
||||
keywords: ["juanita", "粉发学员", "pink avatar", "3d juanita", "juanita avatar"],
|
||||
description: "CC0 全身 3D 示例,适合教学演示和移动端预览。",
|
||||
collection: "100Avatars R3",
|
||||
license: "CC0",
|
||||
sourceUrl: "https://github.com/ToxSam/open-source-avatars",
|
||||
modelUrl: "https://arweave.net/nyMyZZx5lN2DXsmBgbGQSnt3PuXYN7AAjz9QJrjitLo",
|
||||
},
|
||||
{
|
||||
key: "sportTv",
|
||||
label: "SportTV",
|
||||
category: "full-body-3d",
|
||||
keywords: ["sporttv", "sport tv", "屏幕街头", "tv avatar", "hoodie avatar"],
|
||||
description: "CC0 全身 3D 示例,适合训练空间较宽的画面。",
|
||||
collection: "100Avatars R3",
|
||||
license: "CC0",
|
||||
sourceUrl: "https://github.com/ToxSam/open-source-avatars",
|
||||
modelUrl: "https://arweave.net/ISYr7xBXT_s4tLddbhFB3PpUhWg-H_BYs2UZhVLF1hA",
|
||||
},
|
||||
];
|
||||
|
||||
const AVATAR_VISUALS: Record<AvatarKey, AvatarVisualSpec> = {
|
||||
gorilla: {
|
||||
src: "/avatars/twemoji/gorilla.svg",
|
||||
bodyFill: "rgba(39,39,42,0.95)",
|
||||
limbStroke: "rgba(63,63,70,0.92)",
|
||||
glow: "rgba(161,161,170,0.32)",
|
||||
renderMode: "badge",
|
||||
},
|
||||
monkey: {
|
||||
src: "/avatars/twemoji/monkey.svg",
|
||||
bodyFill: "rgba(120,53,15,0.95)",
|
||||
limbStroke: "rgba(146,64,14,0.9)",
|
||||
glow: "rgba(180,83,9,0.3)",
|
||||
renderMode: "badge",
|
||||
},
|
||||
dog: {
|
||||
src: "/avatars/twemoji/dog.svg",
|
||||
bodyFill: "rgba(180,83,9,0.93)",
|
||||
limbStroke: "rgba(180,83,9,0.88)",
|
||||
glow: "rgba(217,119,6,0.26)",
|
||||
renderMode: "badge",
|
||||
},
|
||||
pig: {
|
||||
src: "/avatars/twemoji/pig.svg",
|
||||
bodyFill: "rgba(244,114,182,0.92)",
|
||||
limbStroke: "rgba(244,114,182,0.86)",
|
||||
glow: "rgba(244,114,182,0.28)",
|
||||
renderMode: "badge",
|
||||
},
|
||||
cat: {
|
||||
src: "/avatars/twemoji/cat.svg",
|
||||
bodyFill: "rgba(245,158,11,0.92)",
|
||||
limbStroke: "rgba(217,119,6,0.88)",
|
||||
glow: "rgba(251,191,36,0.28)",
|
||||
renderMode: "badge",
|
||||
},
|
||||
fox: {
|
||||
src: "/avatars/twemoji/fox.svg",
|
||||
bodyFill: "rgba(234,88,12,0.93)",
|
||||
limbStroke: "rgba(194,65,12,0.9)",
|
||||
glow: "rgba(251,146,60,0.3)",
|
||||
renderMode: "badge",
|
||||
},
|
||||
panda: {
|
||||
src: "/avatars/twemoji/panda.svg",
|
||||
bodyFill: "rgba(82,82,91,0.92)",
|
||||
limbStroke: "rgba(39,39,42,0.9)",
|
||||
glow: "rgba(228,228,231,0.28)",
|
||||
renderMode: "badge",
|
||||
},
|
||||
lion: {
|
||||
src: "/avatars/twemoji/lion.svg",
|
||||
bodyFill: "rgba(202,138,4,0.92)",
|
||||
limbStroke: "rgba(161,98,7,0.9)",
|
||||
glow: "rgba(250,204,21,0.28)",
|
||||
renderMode: "badge",
|
||||
},
|
||||
tiger: {
|
||||
src: "/avatars/twemoji/tiger.svg",
|
||||
bodyFill: "rgba(249,115,22,0.94)",
|
||||
limbStroke: "rgba(194,65,12,0.9)",
|
||||
glow: "rgba(251,146,60,0.3)",
|
||||
renderMode: "badge",
|
||||
},
|
||||
rabbit: {
|
||||
src: "/avatars/twemoji/rabbit.svg",
|
||||
bodyFill: "rgba(236,72,153,0.9)",
|
||||
limbStroke: "rgba(219,39,119,0.86)",
|
||||
glow: "rgba(244,114,182,0.28)",
|
||||
renderMode: "badge",
|
||||
},
|
||||
beachKing: {
|
||||
src: "/avatars/opensource3d/beach-king.webp",
|
||||
bodyFill: "rgba(15,23,42,0.16)",
|
||||
limbStroke: "rgba(125,211,252,0.28)",
|
||||
glow: "rgba(56,189,248,0.16)",
|
||||
renderMode: "full-figure",
|
||||
figureScale: 1.12,
|
||||
figureOffsetY: 0.02,
|
||||
},
|
||||
jenny3d: {
|
||||
src: "/avatars/opensource3d/jenny.webp",
|
||||
bodyFill: "rgba(34,197,94,0.16)",
|
||||
limbStroke: "rgba(16,185,129,0.24)",
|
||||
glow: "rgba(34,197,94,0.18)",
|
||||
renderMode: "full-figure",
|
||||
figureScale: 1.08,
|
||||
figureOffsetY: 0,
|
||||
},
|
||||
juanita3d: {
|
||||
src: "/avatars/opensource3d/juanita.webp",
|
||||
bodyFill: "rgba(244,114,182,0.14)",
|
||||
limbStroke: "rgba(236,72,153,0.26)",
|
||||
glow: "rgba(244,114,182,0.18)",
|
||||
renderMode: "full-figure",
|
||||
figureScale: 1.06,
|
||||
figureOffsetY: 0,
|
||||
},
|
||||
sportTv: {
|
||||
src: "/avatars/opensource3d/sport-tv.webp",
|
||||
bodyFill: "rgba(59,130,246,0.14)",
|
||||
limbStroke: "rgba(96,165,250,0.24)",
|
||||
glow: "rgba(96,165,250,0.18)",
|
||||
renderMode: "full-figure",
|
||||
figureScale: 1.1,
|
||||
figureOffsetY: 0.02,
|
||||
},
|
||||
};
|
||||
|
||||
const avatarImageCache = new Map<AvatarKey, HTMLImageElement | null>();
|
||||
|
||||
export function getAvatarPreset(key: AvatarKey) {
|
||||
return AVATAR_PRESETS.find((preset) => preset.key === key) ?? AVATAR_PRESETS[0];
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function getActionStat(samples: FrameActionSample[], action: LiveActionType): ActionStat {
|
||||
const matches = samples.filter((sample) => sample.action === action);
|
||||
const count = matches.length;
|
||||
const totalConfidence = matches.reduce((sum, sample) => sum + sample.confidence, 0);
|
||||
const share = samples.length > 0 ? count / samples.length : 0;
|
||||
const averageConfidence = count > 0 ? totalConfidence / count : 0;
|
||||
|
||||
return {
|
||||
count,
|
||||
totalConfidence,
|
||||
share,
|
||||
averageConfidence,
|
||||
strength: share * 0.7 + averageConfidence * 0.3,
|
||||
};
|
||||
}
|
||||
|
||||
function getWindowAction(samples: FrameActionSample[]) {
|
||||
const stats = new Map<LiveActionType, ActionStat>();
|
||||
ACTIONS.forEach((action) => {
|
||||
stats.set(action, getActionStat(samples, action));
|
||||
});
|
||||
|
||||
const ranked = ACTIONS
|
||||
.map((action) => ({ action, stats: stats.get(action)! }))
|
||||
.sort((a, b) => {
|
||||
if (b.stats.strength !== a.stats.strength) {
|
||||
return b.stats.strength - a.stats.strength;
|
||||
}
|
||||
return b.stats.totalConfidence - a.stats.totalConfidence;
|
||||
});
|
||||
|
||||
const winner = ranked[0] ?? { action: "unknown" as LiveActionType, stats: stats.get("unknown")! };
|
||||
const qualifies =
|
||||
winner.stats.share >= ACTION_WINDOW_MIN_SHARE &&
|
||||
winner.stats.averageConfidence >= ACTION_WINDOW_MIN_CONFIDENCE;
|
||||
|
||||
return {
|
||||
action: qualifies ? winner.action : "unknown",
|
||||
stats,
|
||||
winnerStats: winner.stats,
|
||||
};
|
||||
}
|
||||
|
||||
function getRawVolatility(samples: FrameActionSample[]) {
|
||||
if (samples.length <= 1) return 0;
|
||||
let switches = 0;
|
||||
for (let index = 1; index < samples.length; index += 1) {
|
||||
if (samples[index]?.action !== samples[index - 1]?.action) {
|
||||
switches += 1;
|
||||
}
|
||||
}
|
||||
return switches / (samples.length - 1);
|
||||
}
|
||||
|
||||
export function createStableActionState(initial: LiveActionType = "unknown"): StableActionState {
|
||||
return {
|
||||
current: initial,
|
||||
currentSince: null,
|
||||
candidate: null,
|
||||
candidateSince: null,
|
||||
candidateWindows: 0,
|
||||
switchCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function createEmptyStabilizedActionMeta(): StabilizedActionMeta {
|
||||
return {
|
||||
stableAction: "unknown",
|
||||
stableConfidence: 0,
|
||||
windowAction: "unknown",
|
||||
windowConfidence: 0,
|
||||
windowShare: 0,
|
||||
windowFrames: 0,
|
||||
windowProgress: 0,
|
||||
pending: false,
|
||||
pendingAction: null,
|
||||
stableMs: 0,
|
||||
candidateMs: 0,
|
||||
rawVolatility: 0,
|
||||
switchCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function stabilizeActionStream(
|
||||
sample: FrameActionSample,
|
||||
history: FrameActionSample[],
|
||||
state: StableActionState,
|
||||
) {
|
||||
history.push(sample);
|
||||
if (history.length > ACTION_WINDOW_FRAMES) {
|
||||
history.splice(0, history.length - ACTION_WINDOW_FRAMES);
|
||||
}
|
||||
|
||||
const { action: windowAction, stats } = getWindowAction(history);
|
||||
const windowStats = stats.get(windowAction) ?? getActionStat(history, "unknown");
|
||||
const currentStats = stats.get(state.current) ?? getActionStat(history, state.current);
|
||||
const pendingMinMs = windowAction === "unknown" ? ACTION_UNKNOWN_MIN_MS : ACTION_SWITCH_MIN_MS;
|
||||
const windowProgress = clamp(history.length / ACTION_WINDOW_FRAMES, 0, 1);
|
||||
|
||||
if (state.currentSince == null) {
|
||||
state.currentSince = sample.timestamp;
|
||||
}
|
||||
|
||||
if (windowAction === state.current) {
|
||||
state.candidate = null;
|
||||
state.candidateSince = null;
|
||||
state.candidateWindows = 0;
|
||||
} else if (windowProgress >= 0.7) {
|
||||
if (state.candidate !== windowAction) {
|
||||
state.candidate = windowAction;
|
||||
state.candidateSince = sample.timestamp;
|
||||
state.candidateWindows = 1;
|
||||
} else {
|
||||
state.candidateWindows += 1;
|
||||
}
|
||||
|
||||
const candidateStats = stats.get(windowAction) ?? getActionStat(history, windowAction);
|
||||
const currentStrength = state.current === "unknown" ? currentStats.strength * 0.55 : currentStats.strength;
|
||||
const candidateDuration = state.candidateSince == null ? 0 : sample.timestamp - state.candidateSince;
|
||||
const canSwitch =
|
||||
state.candidateWindows >= ACTION_LOCK_IN_WINDOWS &&
|
||||
candidateDuration >= pendingMinMs &&
|
||||
candidateStats.strength >= currentStrength + ACTION_SWITCH_DELTA;
|
||||
|
||||
if (canSwitch) {
|
||||
state.current = windowAction;
|
||||
state.currentSince = sample.timestamp;
|
||||
state.candidate = null;
|
||||
state.candidateSince = null;
|
||||
state.candidateWindows = 0;
|
||||
state.switchCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const stableStats = stats.get(state.current) ?? getActionStat(history, state.current);
|
||||
const stableConfidence = state.current === "unknown"
|
||||
? Math.max(sample.confidence * 0.45, stableStats.averageConfidence)
|
||||
: Math.max(stableStats.averageConfidence, windowStats.averageConfidence * 0.88);
|
||||
|
||||
return {
|
||||
stableAction: state.current,
|
||||
stableConfidence: clamp(stableConfidence, 0, 1),
|
||||
windowAction,
|
||||
windowConfidence: clamp(windowStats.averageConfidence, 0, 1),
|
||||
windowShare: clamp(windowStats.share, 0, 1),
|
||||
windowFrames: history.length,
|
||||
windowProgress,
|
||||
pending: Boolean(state.candidate),
|
||||
pendingAction: state.candidate,
|
||||
stableMs: state.currentSince == null ? 0 : sample.timestamp - state.currentSince,
|
||||
candidateMs: state.candidateSince == null ? 0 : sample.timestamp - state.candidateSince,
|
||||
rawVolatility: getRawVolatility(history),
|
||||
switchCount: state.switchCount,
|
||||
} satisfies StabilizedActionMeta;
|
||||
}
|
||||
|
||||
export function resolveAvatarKeyFromPrompt(prompt: string, fallback: AvatarKey): AvatarKey {
|
||||
const normalized = prompt.trim().toLowerCase();
|
||||
if (!normalized) return fallback;
|
||||
const matched = AVATAR_PRESETS.find((preset) => preset.keywords.some((keyword) => normalized.includes(keyword)));
|
||||
return matched?.key ?? fallback;
|
||||
}
|
||||
|
||||
function averagePoint(a: PosePoint | undefined, b: PosePoint | undefined, defaultX: number, defaultY: number) {
|
||||
return {
|
||||
x: ((a?.x ?? defaultX) + (b?.x ?? defaultX)) / 2,
|
||||
y: ((a?.y ?? defaultY) + (b?.y ?? defaultY)) / 2,
|
||||
};
|
||||
}
|
||||
|
||||
export function getAvatarAnchors(landmarks: PosePoint[], width: number, height: number): AvatarAnchors | null {
|
||||
const nose = landmarks[0];
|
||||
const leftShoulder = landmarks[11];
|
||||
const rightShoulder = landmarks[12];
|
||||
const leftHip = landmarks[23];
|
||||
const rightHip = landmarks[24];
|
||||
const leftWrist = landmarks[15];
|
||||
const rightWrist = landmarks[16];
|
||||
const leftAnkle = landmarks[27];
|
||||
const rightAnkle = landmarks[28];
|
||||
const leftEar = landmarks[7];
|
||||
const rightEar = landmarks[8];
|
||||
|
||||
if (!nose || !leftShoulder || !rightShoulder || !leftHip || !rightHip) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const shoulderCenter = averagePoint(leftShoulder, rightShoulder, 0.5, 0.32);
|
||||
const hipCenter = averagePoint(leftHip, rightHip, 0.5, 0.62);
|
||||
const ankleCenter = averagePoint(leftAnkle, rightAnkle, hipCenter.x, 0.92);
|
||||
const shoulderSpan = Math.abs(rightShoulder.x - leftShoulder.x) * width;
|
||||
const torsoHeight = Math.max((hipCenter.y - shoulderCenter.y) * height, shoulderSpan * 0.8);
|
||||
const headRadius = Math.max(
|
||||
shoulderSpan * 0.28,
|
||||
Math.abs((leftEar?.x ?? nose.x - 0.04) - (rightEar?.x ?? nose.x + 0.04)) * width * 0.45,
|
||||
34,
|
||||
);
|
||||
const bodyWidth = Math.max(shoulderSpan * 1.05, headRadius * 1.8);
|
||||
const bodyHeight = Math.max(torsoHeight * 1.1, headRadius * 2.2);
|
||||
|
||||
return {
|
||||
headX: nose.x * width,
|
||||
headY: Math.min(nose.y * height, shoulderCenter.y * height - headRadius * 0.2),
|
||||
headRadius,
|
||||
bodyX: shoulderCenter.x * width,
|
||||
bodyY: shoulderCenter.y * height + bodyHeight * 0.48,
|
||||
bodyWidth,
|
||||
bodyHeight,
|
||||
shoulderY: shoulderCenter.y * height,
|
||||
footY: Math.max(ankleCenter.y * height, hipCenter.y * height + bodyHeight * 1.35),
|
||||
leftHandX: (leftWrist?.x ?? leftShoulder.x - 0.08) * width,
|
||||
leftHandY: (leftWrist?.y ?? shoulderCenter.y + 0.1) * height,
|
||||
rightHandX: (rightWrist?.x ?? rightShoulder.x + 0.08) * width,
|
||||
rightHandY: (rightWrist?.y ?? shoulderCenter.y + 0.1) * height,
|
||||
};
|
||||
}
|
||||
|
||||
function drawRoundedBody(ctx: CanvasRenderingContext2D, anchors: AvatarAnchors, fill: string) {
|
||||
const radius = Math.min(anchors.bodyWidth, anchors.bodyHeight) * 0.18;
|
||||
const left = anchors.bodyX - anchors.bodyWidth / 2;
|
||||
const top = anchors.bodyY - anchors.bodyHeight / 2;
|
||||
const right = left + anchors.bodyWidth;
|
||||
const bottom = top + anchors.bodyHeight;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(left + radius, top);
|
||||
ctx.lineTo(right - radius, top);
|
||||
ctx.quadraticCurveTo(right, top, right, top + radius);
|
||||
ctx.lineTo(right, bottom - radius);
|
||||
ctx.quadraticCurveTo(right, bottom, right - radius, bottom);
|
||||
ctx.lineTo(left + radius, bottom);
|
||||
ctx.quadraticCurveTo(left, bottom, left, bottom - radius);
|
||||
ctx.lineTo(left, top + radius);
|
||||
ctx.quadraticCurveTo(left, top, left + radius, top);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = fill;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
function drawLimbs(ctx: CanvasRenderingContext2D, anchors: AvatarAnchors, stroke: string) {
|
||||
ctx.strokeStyle = stroke;
|
||||
ctx.lineWidth = Math.max(anchors.headRadius * 0.22, 10);
|
||||
ctx.lineCap = "round";
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(anchors.bodyX - anchors.bodyWidth * 0.24, anchors.shoulderY + anchors.headRadius * 0.65);
|
||||
ctx.lineTo(anchors.leftHandX, anchors.leftHandY);
|
||||
ctx.moveTo(anchors.bodyX + anchors.bodyWidth * 0.24, anchors.shoulderY + anchors.headRadius * 0.65);
|
||||
ctx.lineTo(anchors.rightHandX, anchors.rightHandY);
|
||||
ctx.moveTo(anchors.bodyX - anchors.bodyWidth * 0.14, anchors.bodyY + anchors.bodyHeight * 0.42);
|
||||
ctx.lineTo(anchors.bodyX - anchors.bodyWidth * 0.18, anchors.footY);
|
||||
ctx.moveTo(anchors.bodyX + anchors.bodyWidth * 0.14, anchors.bodyY + anchors.bodyHeight * 0.42);
|
||||
ctx.lineTo(anchors.bodyX + anchors.bodyWidth * 0.18, anchors.footY);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
function getAvatarImage(key: AvatarKey) {
|
||||
if (typeof Image === "undefined") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cached = avatarImageCache.get(key);
|
||||
if (cached) {
|
||||
return cached.complete && cached.naturalWidth > 0 ? cached : null;
|
||||
}
|
||||
|
||||
const image = new Image();
|
||||
image.decoding = "async";
|
||||
image.src = AVATAR_VISUALS[key].src;
|
||||
avatarImageCache.set(key, image);
|
||||
return null;
|
||||
}
|
||||
|
||||
function drawAvatarBadge(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
anchors: AvatarAnchors,
|
||||
avatarKey: AvatarKey,
|
||||
sprite: HTMLImageElement | null,
|
||||
) {
|
||||
const visual = AVATAR_VISUALS[avatarKey];
|
||||
const headSize = anchors.headRadius * 2.5;
|
||||
const torsoBadge = Math.max(anchors.headRadius * 0.95, 40);
|
||||
|
||||
drawRoundedBody(ctx, anchors, visual.bodyFill);
|
||||
drawLimbs(ctx, anchors, visual.limbStroke);
|
||||
|
||||
ctx.save();
|
||||
ctx.fillStyle = visual.glow;
|
||||
ctx.beginPath();
|
||||
ctx.arc(anchors.headX, anchors.headY, anchors.headRadius * 1.16, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
|
||||
if (sprite) {
|
||||
ctx.drawImage(
|
||||
sprite,
|
||||
anchors.headX - headSize / 2,
|
||||
anchors.headY - headSize / 2,
|
||||
headSize,
|
||||
headSize,
|
||||
);
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.94;
|
||||
ctx.drawImage(
|
||||
sprite,
|
||||
anchors.bodyX - torsoBadge / 2,
|
||||
anchors.bodyY - torsoBadge / 2,
|
||||
torsoBadge,
|
||||
torsoBadge,
|
||||
);
|
||||
ctx.restore();
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.fillStyle = "rgba(255,255,255,0.92)";
|
||||
ctx.beginPath();
|
||||
ctx.arc(anchors.headX, anchors.headY, anchors.headRadius * 0.88, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = "rgba(17,24,39,0.82)";
|
||||
ctx.beginPath();
|
||||
ctx.arc(anchors.headX - anchors.headRadius * 0.22, anchors.headY - anchors.headRadius * 0.08, anchors.headRadius * 0.08, 0, Math.PI * 2);
|
||||
ctx.arc(anchors.headX + anchors.headRadius * 0.22, anchors.headY - anchors.headRadius * 0.08, anchors.headRadius * 0.08, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
function drawFullFigureAvatar(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
anchors: AvatarAnchors,
|
||||
avatarKey: AvatarKey,
|
||||
sprite: HTMLImageElement | null,
|
||||
) {
|
||||
const visual = AVATAR_VISUALS[avatarKey];
|
||||
const topY = anchors.headY - anchors.headRadius * 1.55 + anchors.bodyHeight * (visual.figureOffsetY ?? 0);
|
||||
const baseHeight = Math.max(anchors.footY - topY, anchors.bodyHeight * 2.35);
|
||||
const figureHeight = baseHeight * (visual.figureScale ?? 1);
|
||||
const aspectRatio = sprite?.naturalWidth && sprite?.naturalHeight
|
||||
? sprite.naturalWidth / sprite.naturalHeight
|
||||
: 0.72;
|
||||
const figureWidth = figureHeight * aspectRatio;
|
||||
const figureLeft = anchors.bodyX - figureWidth / 2;
|
||||
|
||||
ctx.save();
|
||||
ctx.fillStyle = visual.glow;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(
|
||||
anchors.bodyX,
|
||||
anchors.footY - anchors.headRadius * 0.1,
|
||||
Math.max(anchors.bodyWidth * 0.42, 34),
|
||||
Math.max(anchors.headRadius * 0.22, 10),
|
||||
0,
|
||||
0,
|
||||
Math.PI * 2,
|
||||
);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
|
||||
if (sprite) {
|
||||
ctx.save();
|
||||
ctx.shadowColor = "rgba(15,23,42,0.28)";
|
||||
ctx.shadowBlur = 16;
|
||||
ctx.shadowOffsetY = 10;
|
||||
ctx.drawImage(sprite, figureLeft, topY, figureWidth, figureHeight);
|
||||
ctx.restore();
|
||||
return;
|
||||
}
|
||||
|
||||
drawRoundedBody(ctx, anchors, visual.bodyFill);
|
||||
drawLimbs(ctx, anchors, visual.limbStroke);
|
||||
}
|
||||
|
||||
export function renderLiveCameraOverlayToContext(
|
||||
ctx: CanvasRenderingContext2D | null,
|
||||
width: number,
|
||||
height: number,
|
||||
landmarks: PosePoint[] | undefined,
|
||||
avatarState?: AvatarRenderState,
|
||||
options?: { clear?: boolean },
|
||||
) {
|
||||
if (!ctx) return;
|
||||
if (options?.clear !== false) {
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
}
|
||||
if (!landmarks) return;
|
||||
|
||||
if (avatarState?.enabled) {
|
||||
const anchors = getAvatarAnchors(landmarks, width, height);
|
||||
if (anchors) {
|
||||
const sprite = getAvatarImage(avatarState.avatarKey);
|
||||
const visual = AVATAR_VISUALS[avatarState.avatarKey];
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.95;
|
||||
if (visual.renderMode === "full-figure") {
|
||||
drawFullFigureAvatar(ctx, anchors, avatarState.avatarKey, sprite);
|
||||
} else {
|
||||
drawAvatarBadge(ctx, anchors, avatarState.avatarKey, sprite);
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
if (visual.renderMode !== "full-figure") {
|
||||
ctx.save();
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.16)";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.setLineDash([8, 10]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(anchors.bodyX, anchors.shoulderY - anchors.headRadius * 1.25);
|
||||
ctx.lineTo(anchors.bodyX, anchors.footY);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const poseConnections: Array<[number, number]> = [
|
||||
[11, 12], [11, 13], [13, 15], [12, 14], [14, 16],
|
||||
[11, 23], [12, 24], [23, 24], [23, 25], [24, 26],
|
||||
[25, 27], [26, 28], [15, 17], [16, 18], [15, 19],
|
||||
[16, 20], [17, 19], [18, 20],
|
||||
];
|
||||
|
||||
ctx.strokeStyle = "rgba(25, 211, 155, 0.9)";
|
||||
ctx.lineWidth = 3;
|
||||
poseConnections.forEach(([from, to]) => {
|
||||
const start = landmarks[from];
|
||||
const end = landmarks[to];
|
||||
if (!start || !end || (start.visibility ?? 1) < 0.25 || (end.visibility ?? 1) < 0.25) return;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(start.x * width, start.y * height);
|
||||
ctx.lineTo(end.x * width, end.y * height);
|
||||
ctx.stroke();
|
||||
});
|
||||
|
||||
landmarks.forEach((point, index) => {
|
||||
if ((point.visibility ?? 1) < 0.25) return;
|
||||
ctx.fillStyle = index >= 11 && index <= 16 ? "rgba(253, 224, 71, 0.95)" : "rgba(255,255,255,0.88)";
|
||||
ctx.beginPath();
|
||||
ctx.arc(point.x * width, point.y * height, index >= 11 && index <= 16 ? 5 : 4, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
});
|
||||
}
|
||||
|
||||
export function drawLiveCameraOverlay(
|
||||
canvas: HTMLCanvasElement | null,
|
||||
landmarks: PosePoint[] | undefined,
|
||||
avatarState?: AvatarRenderState,
|
||||
) {
|
||||
const ctx = canvas?.getContext("2d");
|
||||
if (!canvas || !ctx) return;
|
||||
renderLiveCameraOverlayToContext(ctx, canvas.width, canvas.height, landmarks, avatarState, { clear: true });
|
||||
}
|
||||
@@ -14,6 +14,12 @@ export type ArchiveStatus =
|
||||
| "completed"
|
||||
| "failed";
|
||||
|
||||
export type PreviewStatus =
|
||||
| "idle"
|
||||
| "processing"
|
||||
| "ready"
|
||||
| "failed";
|
||||
|
||||
export type MediaMarker = {
|
||||
id: string;
|
||||
type: string;
|
||||
@@ -29,6 +35,7 @@ export type MediaSession = {
|
||||
title: string;
|
||||
status: MediaSessionStatus;
|
||||
archiveStatus: ArchiveStatus;
|
||||
previewStatus: PreviewStatus;
|
||||
format: string;
|
||||
mimeType: string;
|
||||
qualityPreset: string;
|
||||
@@ -37,10 +44,15 @@ export type MediaSession = {
|
||||
reconnectCount: number;
|
||||
uploadedSegments: number;
|
||||
uploadedBytes: number;
|
||||
previewSegments: number;
|
||||
durationMs: number;
|
||||
lastError?: string;
|
||||
previewUpdatedAt?: string;
|
||||
streamConnected: boolean;
|
||||
lastStreamAt?: string;
|
||||
viewerCount?: number;
|
||||
liveFrameUrl?: string;
|
||||
liveFrameUpdatedAt?: string;
|
||||
playback: {
|
||||
webmUrl?: string;
|
||||
mp4Url?: string;
|
||||
@@ -53,14 +65,40 @@ export type MediaSession = {
|
||||
};
|
||||
|
||||
const MEDIA_BASE = (import.meta.env.VITE_MEDIA_BASE_URL || "/media").replace(/\/$/, "");
|
||||
const RETRYABLE_STATUS = new Set([502, 503, 504]);
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(`${MEDIA_BASE}${path}`, init);
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.json().catch(() => ({}));
|
||||
throw new Error(errorBody.error || errorBody.message || `Media service error (${response.status})`);
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
const response = await fetch(`${MEDIA_BASE}${path}`, init);
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.json().catch(() => ({}));
|
||||
const error = new Error(errorBody.error || errorBody.message || `Media service error (${response.status})`);
|
||||
if (RETRYABLE_STATUS.has(response.status) && attempt < 2) {
|
||||
lastError = error;
|
||||
await sleep(400 * (attempt + 1));
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error("Media request failed");
|
||||
if (attempt < 2) {
|
||||
await sleep(400 * (attempt + 1));
|
||||
continue;
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
|
||||
throw lastError || new Error("Media request failed");
|
||||
}
|
||||
|
||||
export async function createMediaSession(payload: {
|
||||
@@ -87,6 +125,22 @@ export async function signalMediaSession(sessionId: string, payload: { sdp: stri
|
||||
});
|
||||
}
|
||||
|
||||
export async function signalMediaViewerSession(sessionId: string, payload: { sdp: string; type: string }) {
|
||||
return request<{ viewerId: string; sdp: string; type: string }>(`/sessions/${sessionId}/viewer-signal`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function uploadMediaLiveFrame(sessionId: string, blob: Blob) {
|
||||
return request<{ session: MediaSession }>(`/sessions/${sessionId}/live-frame`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": blob.type || "image/jpeg" },
|
||||
body: blob,
|
||||
});
|
||||
}
|
||||
|
||||
export async function uploadMediaSegment(
|
||||
sessionId: string,
|
||||
sequence: number,
|
||||
@@ -129,6 +183,10 @@ export async function getMediaSession(sessionId: string) {
|
||||
return request<{ session: MediaSession }>(`/sessions/${sessionId}`);
|
||||
}
|
||||
|
||||
export function getMediaAssetUrl(path: string) {
|
||||
return `${MEDIA_BASE}${path.startsWith("/") ? path : `/${path}`}`;
|
||||
}
|
||||
|
||||
export function formatRecordingTime(milliseconds: number) {
|
||||
const totalSeconds = Math.max(0, Math.floor(milliseconds / 1000));
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
|
||||
57
client/src/lib/time.ts
普通文件
@@ -0,0 +1,57 @@
|
||||
const APP_TIME_ZONE = "Asia/Shanghai";
|
||||
|
||||
type DateLike = string | number | Date | null | undefined;
|
||||
|
||||
function toDate(value: DateLike) {
|
||||
if (value == null) return null;
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
return date;
|
||||
}
|
||||
|
||||
export function formatDateTimeShanghai(
|
||||
value: DateLike,
|
||||
options?: Intl.DateTimeFormatOptions,
|
||||
) {
|
||||
const date = toDate(value);
|
||||
if (!date) return "";
|
||||
return date.toLocaleString("zh-CN", {
|
||||
timeZone: APP_TIME_ZONE,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: options?.second,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
export function formatDateShanghai(
|
||||
value: DateLike,
|
||||
options?: Intl.DateTimeFormatOptions,
|
||||
) {
|
||||
const date = toDate(value);
|
||||
if (!date) return "";
|
||||
return date.toLocaleDateString("zh-CN", {
|
||||
timeZone: APP_TIME_ZONE,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
export function formatMonthDayShanghai(value: DateLike) {
|
||||
const date = toDate(value);
|
||||
if (!date) return "";
|
||||
return date.toLocaleDateString("zh-CN", {
|
||||
timeZone: APP_TIME_ZONE,
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export function getAppTimeZoneLabel() {
|
||||
return APP_TIME_ZONE;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { getLoginUrl } from "./const";
|
||||
import "./index.css";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
const ASSET_REFRESH_KEY = "asset-recovery-reloaded";
|
||||
|
||||
const redirectToLoginIfUnauthorized = (error: unknown) => {
|
||||
if (!(error instanceof TRPCClientError)) return;
|
||||
@@ -21,6 +22,60 @@ const redirectToLoginIfUnauthorized = (error: unknown) => {
|
||||
window.location.href = getLoginUrl();
|
||||
};
|
||||
|
||||
function reloadForStaleAsset(reason: string) {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const alreadyReloaded = window.sessionStorage.getItem(ASSET_REFRESH_KEY) === "1";
|
||||
if (alreadyReloaded) {
|
||||
console.error("[Asset Recovery] stale asset still failing after reload", reason);
|
||||
return;
|
||||
}
|
||||
|
||||
window.sessionStorage.setItem(ASSET_REFRESH_KEY, "1");
|
||||
console.warn("[Asset Recovery] reloading page due to stale asset failure:", reason);
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
function clearAssetRecoveryFlag() {
|
||||
if (typeof window === "undefined") return;
|
||||
window.sessionStorage.removeItem(ASSET_REFRESH_KEY);
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("load", () => {
|
||||
clearAssetRecoveryFlag();
|
||||
}, { once: true });
|
||||
|
||||
window.addEventListener("vite:preloadError", (event) => {
|
||||
const customEvent = event as Event & { payload?: unknown; preventDefault: () => void };
|
||||
customEvent.preventDefault();
|
||||
reloadForStaleAsset(String(customEvent.payload ?? "vite preload error"));
|
||||
});
|
||||
|
||||
window.addEventListener("error", (event) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof HTMLLinkElement || target instanceof HTMLScriptElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const assetUrl = target instanceof HTMLLinkElement ? target.href : target.src;
|
||||
if (assetUrl.includes("/assets/")) {
|
||||
reloadForStaleAsset(assetUrl);
|
||||
}
|
||||
}, true);
|
||||
|
||||
window.addEventListener("unhandledrejection", (event) => {
|
||||
const reason = event.reason instanceof Error ? event.reason.message : String(event.reason ?? "");
|
||||
if (
|
||||
reason.includes("Failed to fetch dynamically imported module") ||
|
||||
reason.includes("Importing a module script failed") ||
|
||||
reason.includes("Unable to preload CSS")
|
||||
) {
|
||||
reloadForStaleAsset(reason);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
queryClient.getQueryCache().subscribe(event => {
|
||||
if (event.type === "updated" && event.action.type === "error") {
|
||||
const error = event.query.state.error;
|
||||
|
||||
317
client/src/pages/AdminConsole.tsx
普通文件
@@ -0,0 +1,317 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/_core/hooks/useAuth";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { formatDateTimeShanghai } from "@/lib/time";
|
||||
import { toast } from "sonner";
|
||||
import { Activity, Database, RefreshCw, Settings2, Shield, Sparkles, Users } from "lucide-react";
|
||||
|
||||
export default function AdminConsole() {
|
||||
const { user } = useAuth();
|
||||
const utils = trpc.useUtils();
|
||||
const usersQuery = trpc.admin.users.useQuery({ limit: 100 }, { enabled: user?.role === "admin" });
|
||||
const tasksQuery = trpc.admin.tasks.useQuery({ limit: 100 }, { enabled: user?.role === "admin" });
|
||||
const liveSessionsQuery = trpc.admin.liveSessions.useQuery({ limit: 50 }, { enabled: user?.role === "admin" });
|
||||
const settingsQuery = trpc.admin.settings.useQuery(undefined, { enabled: user?.role === "admin" });
|
||||
const auditQuery = trpc.admin.auditLogs.useQuery({ limit: 100 }, { enabled: user?.role === "admin" });
|
||||
|
||||
const [settingsDrafts, setSettingsDrafts] = useState<Record<string, string>>({});
|
||||
|
||||
const refreshAllMutation = trpc.admin.refreshAllNtrp.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("已提交全量 NTRP 刷新任务");
|
||||
utils.admin.tasks.invalidate();
|
||||
},
|
||||
onError: (error) => toast.error(`提交失败: ${error.message}`),
|
||||
});
|
||||
const refreshUserMutation = trpc.admin.refreshUserNtrp.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("已提交用户 NTRP 刷新任务");
|
||||
utils.admin.tasks.invalidate();
|
||||
},
|
||||
onError: (error) => toast.error(`提交失败: ${error.message}`),
|
||||
});
|
||||
const refreshUserNowMutation = trpc.admin.refreshUserNtrpNow.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("用户 NTRP 已即时刷新");
|
||||
utils.admin.users.invalidate();
|
||||
utils.admin.auditLogs.invalidate();
|
||||
},
|
||||
onError: (error) => toast.error(`即时刷新失败: ${error.message}`),
|
||||
});
|
||||
const updateSettingMutation = trpc.admin.updateSetting.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("设置已更新");
|
||||
utils.admin.settings.invalidate();
|
||||
utils.admin.auditLogs.invalidate();
|
||||
},
|
||||
onError: (error) => toast.error(`设置更新失败: ${error.message}`),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const drafts: Record<string, string> = {};
|
||||
(settingsQuery.data || []).forEach((item: any) => {
|
||||
drafts[item.settingKey] = JSON.stringify(item.value ?? null);
|
||||
});
|
||||
setSettingsDrafts(drafts);
|
||||
}, [settingsQuery.data]);
|
||||
|
||||
const totals = useMemo(() => ({
|
||||
users: (usersQuery.data || []).length,
|
||||
tasks: (tasksQuery.data || []).length,
|
||||
sessions: (liveSessionsQuery.data || []).length,
|
||||
}), [liveSessionsQuery.data, tasksQuery.data, usersQuery.data]);
|
||||
|
||||
if (user?.role !== "admin") {
|
||||
return (
|
||||
<Alert>
|
||||
<Shield className="h-4 w-4" />
|
||||
<AlertTitle>需要管理员权限</AlertTitle>
|
||||
<AlertDescription>当前账号没有管理系统访问权限。</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="rounded-[28px] border border-border/60 bg-[radial-gradient(circle_at_top_left,_rgba(16,185,129,0.12),_transparent_30%),linear-gradient(180deg,rgba(255,255,255,1),rgba(248,250,252,0.96))] p-5 shadow-sm md:p-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">管理系统</h1>
|
||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground">
|
||||
这里集中查看用户、后台任务、实时分析记录、全局设置和审计日志。H1 管理员可以提交和执行用户级评分刷新。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button onClick={() => refreshAllMutation.mutate()} disabled={refreshAllMutation.isPending} className="gap-2">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
刷新全部 NTRP
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardContent className="pt-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<Users className="h-5 w-5 text-emerald-700" />
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">用户数</div>
|
||||
<div className="mt-1 text-xl font-semibold">{totals.users}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardContent className="pt-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<Activity className="h-5 w-5 text-sky-700" />
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">后台任务</div>
|
||||
<div className="mt-1 text-xl font-semibold">{totals.tasks}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardContent className="pt-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<Sparkles className="h-5 w-5 text-orange-700" />
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">实时分析会话</div>
|
||||
<div className="mt-1 text-xl font-semibold">{totals.sessions}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="users" className="space-y-4">
|
||||
<TabsList className="grid w-full grid-cols-5">
|
||||
<TabsTrigger value="users">用户</TabsTrigger>
|
||||
<TabsTrigger value="tasks">任务</TabsTrigger>
|
||||
<TabsTrigger value="sessions">会话</TabsTrigger>
|
||||
<TabsTrigger value="settings">设置</TabsTrigger>
|
||||
<TabsTrigger value="audit">审计</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="users">
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">用户列表</CardTitle>
|
||||
<CardDescription>支持排队刷新和即时刷新单个用户的 NTRP。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{(usersQuery.data || []).map((item: any) => (
|
||||
<div key={item.id} className="rounded-2xl border border-border/60 bg-muted/20 p-4">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{item.name}</span>
|
||||
<Badge variant="outline">{item.role}</Badge>
|
||||
<Badge variant="outline">NTRP {Number(item.ntrpRating || 1.5).toFixed(1)}</Badge>
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-muted-foreground">
|
||||
训练 {item.totalSessions || 0} 次 · {item.totalMinutes || 0} 分钟 · 连练 {item.currentStreak || 0} 天
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => refreshUserMutation.mutate({ userId: item.id })}>
|
||||
排队刷新
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => refreshUserNowMutation.mutate({ userId: item.id })}>
|
||||
立即刷新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="tasks">
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">后台任务</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{(tasksQuery.data || []).map((task: any) => (
|
||||
<div key={task.id} className="rounded-2xl border border-border/60 bg-muted/20 p-4">
|
||||
<div className="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{task.title}</span>
|
||||
<Badge variant="outline">{task.type}</Badge>
|
||||
<Badge variant={task.status === "failed" ? "destructive" : task.status === "succeeded" ? "secondary" : "outline"}>
|
||||
{task.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-muted-foreground">
|
||||
{task.userName || task.userId} · {formatDateTimeShanghai(task.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-[180px]">
|
||||
<div className="mb-2 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{task.message || "无描述"}</span>
|
||||
<span>{task.progress || 0}%</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-muted">
|
||||
<div className="h-full rounded-full bg-emerald-500" style={{ width: `${task.progress || 0}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="sessions">
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">实时分析会话</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{(liveSessionsQuery.data || []).map((session: any) => (
|
||||
<div key={session.id} className="rounded-2xl border border-border/60 bg-muted/20 p-4">
|
||||
<div className="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{session.title}</span>
|
||||
<Badge variant="outline">{session.userName || session.userId}</Badge>
|
||||
<Badge variant="outline">{session.sessionMode}</Badge>
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-muted-foreground">
|
||||
主动作 {session.dominantAction || "unknown"} · 有效片段 {session.effectiveSegments || 0}/{session.totalSegments || 0}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{Math.round(session.overallScore || 0)} 分 · {Math.round((session.durationMs || 0) / 1000)} 秒
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="settings">
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Settings2 className="h-4 w-4 text-primary" />
|
||||
全局设置
|
||||
</CardTitle>
|
||||
<CardDescription>设置值以 JSON 形式保存,适合阈值、开关和结构化配置。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{(settingsQuery.data || []).map((setting: any) => (
|
||||
<div key={setting.settingKey} className="rounded-2xl border border-border/60 bg-muted/20 p-4">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-end">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium">{setting.label}</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">{setting.description}</div>
|
||||
<Input
|
||||
value={settingsDrafts[setting.settingKey] || ""}
|
||||
onChange={(event) => setSettingsDrafts((current) => ({ ...current, [setting.settingKey]: event.target.value }))}
|
||||
className="mt-3 h-11 rounded-2xl"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
className="gap-2"
|
||||
onClick={() => {
|
||||
try {
|
||||
const parsed = JSON.parse(settingsDrafts[setting.settingKey] || "null");
|
||||
updateSettingMutation.mutate({ settingKey: setting.settingKey, value: parsed });
|
||||
} catch {
|
||||
toast.error("设置值必须是合法 JSON");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Database className="h-4 w-4" />
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="audit">
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">审计日志</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{(auditQuery.data || []).map((item: any) => (
|
||||
<div key={item.id} className="rounded-2xl border border-border/60 bg-muted/20 p-4">
|
||||
<div className="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{item.actionType}</span>
|
||||
<Badge variant="outline">{item.entityType}</Badge>
|
||||
{item.targetUserId ? <Badge variant="outline">目标用户 {item.targetUserId}</Badge> : null}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-muted-foreground">
|
||||
管理员 {item.adminName || item.adminUserId} · {formatDateTimeShanghai(item.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
{item.entityId ? <div className="text-sm text-muted-foreground">实体 {item.entityId}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,10 +7,12 @@ import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { useBackgroundTask } from "@/hooks/useBackgroundTask";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Upload, Video, Loader2, Play, Pause, RotateCcw,
|
||||
Zap, Target, Activity, TrendingUp, Eye
|
||||
Zap, Target, Activity, TrendingUp, Eye, ListTodo
|
||||
} from "lucide-react";
|
||||
import { Streamdown } from "streamdown";
|
||||
|
||||
@@ -39,6 +41,8 @@ export default function Analysis() {
|
||||
const [analysisProgress, setAnalysisProgress] = useState(0);
|
||||
const [analysisResult, setAnalysisResult] = useState<AnalysisResult | null>(null);
|
||||
const [corrections, setCorrections] = useState<string>("");
|
||||
const [correctionReport, setCorrectionReport] = useState<any>(null);
|
||||
const [correctionTaskId, setCorrectionTaskId] = useState<string | null>(null);
|
||||
const [showSkeleton, setShowSkeleton] = useState(false);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
@@ -55,7 +59,16 @@ export default function Analysis() {
|
||||
utils.rating.history.invalidate();
|
||||
},
|
||||
});
|
||||
const correctionMutation = trpc.analysis.getCorrections.useMutation();
|
||||
const correctionMutation = trpc.analysis.getCorrections.useMutation({
|
||||
onSuccess: (data) => {
|
||||
setCorrectionTaskId(data.taskId);
|
||||
toast.success("动作纠正任务已提交");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("动作纠正任务提交失败: " + error.message);
|
||||
},
|
||||
});
|
||||
const correctionTaskQuery = useBackgroundTask(correctionTaskId);
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
@@ -73,8 +86,22 @@ export default function Analysis() {
|
||||
setVideoUrl(URL.createObjectURL(file));
|
||||
setAnalysisResult(null);
|
||||
setCorrections("");
|
||||
setCorrectionReport(null);
|
||||
setCorrectionTaskId(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (correctionTaskQuery.data?.status === "succeeded") {
|
||||
const result = correctionTaskQuery.data.result as { corrections?: string; report?: any } | null;
|
||||
setCorrections(result?.corrections || "暂无建议");
|
||||
setCorrectionReport(result?.report || null);
|
||||
setCorrectionTaskId(null);
|
||||
} else if (correctionTaskQuery.data?.status === "failed") {
|
||||
toast.error(`动作纠正失败: ${correctionTaskQuery.data.error || "未知错误"}`);
|
||||
setCorrectionTaskId(null);
|
||||
}
|
||||
}, [correctionTaskQuery.data]);
|
||||
|
||||
const analyzeVideo = useCallback(async () => {
|
||||
if (!videoRef.current || !canvasRef.current || !videoFile) return;
|
||||
|
||||
@@ -267,6 +294,8 @@ export default function Analysis() {
|
||||
};
|
||||
|
||||
setAnalysisResult(result);
|
||||
setCorrections("");
|
||||
setCorrectionReport(null);
|
||||
|
||||
// Upload video and save analysis
|
||||
const reader = new FileReader();
|
||||
@@ -293,13 +322,12 @@ export default function Analysis() {
|
||||
};
|
||||
reader.readAsDataURL(videoFile);
|
||||
|
||||
// Get AI corrections
|
||||
const snapshots = await extractFrameSnapshots(videoUrl);
|
||||
correctionMutation.mutate({
|
||||
poseMetrics: result.poseMetrics,
|
||||
exerciseType,
|
||||
detectedIssues: result.detectedIssues,
|
||||
}, {
|
||||
onSuccess: (data) => setCorrections(data.corrections as string),
|
||||
imageDataUrls: snapshots,
|
||||
});
|
||||
|
||||
pose.close();
|
||||
@@ -318,6 +346,16 @@ export default function Analysis() {
|
||||
<p className="text-muted-foreground text-sm mt-1">AI姿势识别与矫正反馈</p>
|
||||
</div>
|
||||
|
||||
{(correctionMutation.isPending || correctionTaskQuery.data?.status === "queued" || correctionTaskQuery.data?.status === "running") ? (
|
||||
<Alert>
|
||||
<ListTodo className="h-4 w-4" />
|
||||
<AlertTitle>后台任务执行中</AlertTitle>
|
||||
<AlertDescription>
|
||||
多模态动作纠正正在后台生成。可以先查看分析结果,完成后任务中心和当前页面都会更新。
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{/* Upload section */}
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
@@ -532,7 +570,12 @@ export default function Analysis() {
|
||||
{correctionMutation.isPending ? (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span className="text-sm">AI正在生成矫正建议...</span>
|
||||
<span className="text-sm">正在提交动作纠正任务...</span>
|
||||
</div>
|
||||
) : correctionTaskQuery.data?.status === "queued" || correctionTaskQuery.data?.status === "running" ? (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span className="text-sm">{correctionTaskQuery.data.message || "AI正在后台生成多模态矫正建议..."}</span>
|
||||
</div>
|
||||
) : corrections ? (
|
||||
<div className="prose prose-sm max-w-none">
|
||||
@@ -543,6 +586,24 @@ export default function Analysis() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{correctionReport?.priorityFixes?.length ? (
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">优先修正项</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{correctionReport.priorityFixes.map((item: any, index: number) => (
|
||||
<div key={`${item.title}-${index}`} className="rounded-xl border p-3">
|
||||
<p className="font-medium text-sm">{item.title}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{item.why}</p>
|
||||
<p className="mt-2 text-sm"><strong>练习:</strong>{item.howToPractice}</p>
|
||||
<p className="mt-1 text-xs text-primary"><strong>达标:</strong>{item.successMetric}</p>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -667,3 +728,39 @@ function averageAngles(anglesHistory: any[]) {
|
||||
}
|
||||
return avg;
|
||||
}
|
||||
|
||||
async function extractFrameSnapshots(sourceUrl: string) {
|
||||
if (!sourceUrl) return [];
|
||||
|
||||
const video = document.createElement("video");
|
||||
video.src = sourceUrl;
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
video.crossOrigin = "anonymous";
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
video.onloadedmetadata = () => resolve();
|
||||
video.onerror = () => reject(new Error("无法读取视频元数据"));
|
||||
});
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = video.videoWidth || 1280;
|
||||
canvas.height = video.videoHeight || 720;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return [];
|
||||
|
||||
const duration = Math.max(video.duration || 0, 1);
|
||||
const checkpoints = [0.15, 0.5, 0.85].map((ratio) => Math.min(duration - 0.05, duration * ratio)).filter((time, index, array) => time >= 0 && array.indexOf(time) === index);
|
||||
const snapshots: string[] = [];
|
||||
|
||||
for (const checkpoint of checkpoints) {
|
||||
await new Promise<void>((resolve) => {
|
||||
video.onseeked = () => resolve();
|
||||
video.currentTime = checkpoint;
|
||||
});
|
||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
snapshots.push(canvas.toDataURL("image/jpeg", 0.82));
|
||||
}
|
||||
|
||||
return snapshots;
|
||||
}
|
||||
|
||||
66
client/src/pages/ChangeLog.tsx
普通文件
@@ -0,0 +1,66 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { CHANGE_LOG_ENTRIES } from "@/lib/changelog";
|
||||
import { formatDateShanghai } from "@/lib/time";
|
||||
import { GitBranch, ListChecks, ScrollText } from "lucide-react";
|
||||
|
||||
export default function ChangeLog() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="rounded-[28px] border border-border/60 bg-[radial-gradient(circle_at_top_left,_rgba(14,165,233,0.1),_transparent_28%),linear-gradient(180deg,rgba(255,255,255,1),rgba(248,250,252,0.96))] p-6 shadow-sm">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
||||
<ScrollText className="h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">更新日志</h1>
|
||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">
|
||||
这里会按版本记录已上线的新功能、对应仓库版本和验证结果。后续每次改动测试通过并提交后,都会继续追加到这里。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="space-y-4">
|
||||
{CHANGE_LOG_ENTRIES.map((entry) => (
|
||||
<Card key={`${entry.version}-${entry.repoVersion}`} className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg">{entry.version}</CardTitle>
|
||||
<CardDescription className="mt-2">{entry.summary}</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="secondary">{formatDateShanghai(entry.releaseDate)}</Badge>
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<GitBranch className="h-3 w-3" />
|
||||
{entry.repoVersion}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium">上线内容</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{entry.features.map((feature) => (
|
||||
<Badge key={feature} variant="secondary">{feature}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<ListChecks className="h-4 w-4 text-primary" />
|
||||
验证记录
|
||||
</div>
|
||||
<ul className="mt-2 list-disc space-y-1 pl-5 text-sm text-muted-foreground">
|
||||
{entry.tests.map((item) => <li key={item}>{item}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,238 +1,290 @@
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { useMemo } from "react";
|
||||
import { useAuth } from "@/_core/hooks/useAuth";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { toast } from "sonner";
|
||||
import { Flame, Calendar, Award, CheckCircle2, Lock, Star, Trophy, Zap } from "lucide-react";
|
||||
import { useState, useMemo } from "react";
|
||||
import { formatDateShanghai } from "@/lib/time";
|
||||
import { Award, Calendar, Flame, Radar, Sparkles, Swords, Trophy } from "lucide-react";
|
||||
|
||||
const categoryLabels: Record<string, { label: string; color: string }> = {
|
||||
milestone: { label: "里程碑", color: "bg-blue-100 text-blue-700" },
|
||||
training: { label: "训练", color: "bg-green-100 text-green-700" },
|
||||
video: { label: "视频", color: "bg-purple-100 text-purple-700" },
|
||||
analysis: { label: "分析", color: "bg-orange-100 text-orange-700" },
|
||||
streak: { label: "连续打卡", color: "bg-red-100 text-red-700" },
|
||||
rating: { label: "评分", color: "bg-yellow-100 text-yellow-700" },
|
||||
const CATEGORY_META: Record<string, { label: string; tone: string }> = {
|
||||
consistency: { label: "稳定性", tone: "bg-rose-500/10 text-rose-700" },
|
||||
volume: { label: "训练量", tone: "bg-emerald-500/10 text-emerald-700" },
|
||||
technique: { label: "动作质量", tone: "bg-sky-500/10 text-sky-700" },
|
||||
recording: { label: "录制归档", tone: "bg-amber-500/10 text-amber-700" },
|
||||
analysis: { label: "分析进度", tone: "bg-indigo-500/10 text-indigo-700" },
|
||||
quality: { label: "高分片段", tone: "bg-fuchsia-500/10 text-fuchsia-700" },
|
||||
rating: { label: "评分", tone: "bg-violet-500/10 text-violet-700" },
|
||||
pk: { label: "训练 PK", tone: "bg-orange-500/10 text-orange-700" },
|
||||
plan: { label: "计划匹配", tone: "bg-cyan-500/10 text-cyan-700" },
|
||||
tutorial: { label: "教程路径", tone: "bg-violet-500/10 text-violet-700" },
|
||||
};
|
||||
|
||||
function getProgressText(item: any) {
|
||||
if (item.unlockedAt) {
|
||||
return `已于 ${formatDateShanghai(item.unlockedAt)} 解锁`;
|
||||
}
|
||||
return `${Math.round(item.currentValue || 0)} / ${Math.round(item.targetValue || 0)}`;
|
||||
}
|
||||
|
||||
export default function Checkin() {
|
||||
const { user } = useAuth();
|
||||
const [notes, setNotes] = useState("");
|
||||
const [checkinDone, setCheckinDone] = useState(false);
|
||||
const achievementQuery = trpc.achievement.list.useQuery();
|
||||
const statsQuery = trpc.profile.stats.useQuery();
|
||||
|
||||
const { data: todayCheckin, isLoading: loadingToday } = trpc.checkin.today.useQuery();
|
||||
const { data: checkinHistory } = trpc.checkin.history.useQuery({ limit: 60 });
|
||||
const { data: badges, isLoading: loadingBadges, refetch: refetchBadges } = trpc.badge.list.useQuery();
|
||||
const achievements = useMemo(() => achievementQuery.data ?? [], [achievementQuery.data]);
|
||||
const stats = statsQuery.data;
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
const checkinMutation = trpc.checkin.do.useMutation({
|
||||
onSuccess: (data) => {
|
||||
if (data.alreadyCheckedIn) {
|
||||
toast.info("今天已经打卡过了!");
|
||||
} else {
|
||||
toast.success(`打卡成功!连续 ${data.streak} 天 🔥`);
|
||||
if (data.newBadges && data.newBadges.length > 0) {
|
||||
data.newBadges.forEach((key: string) => {
|
||||
toast.success(`🏆 获得新徽章!`, { duration: 5000 });
|
||||
});
|
||||
}
|
||||
setCheckinDone(true);
|
||||
}
|
||||
utils.checkin.today.invalidate();
|
||||
utils.checkin.history.invalidate();
|
||||
refetchBadges();
|
||||
},
|
||||
onError: () => toast.error("打卡失败,请重试"),
|
||||
});
|
||||
|
||||
const handleCheckin = () => {
|
||||
checkinMutation.mutate({ notes: notes || undefined });
|
||||
};
|
||||
|
||||
const alreadyCheckedIn = !!todayCheckin || checkinDone;
|
||||
|
||||
// Build calendar heatmap for last 60 days
|
||||
const heatmapData = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
(checkinHistory || []).forEach((c: any) => {
|
||||
map.set(c.checkinDate, c.streakCount);
|
||||
});
|
||||
const days = [];
|
||||
for (let i = 59; i >= 0; i--) {
|
||||
const d = new Date(Date.now() - i * 86400000);
|
||||
const key = d.toISOString().slice(0, 10);
|
||||
days.push({ date: key, checked: map.has(key), streak: map.get(key) || 0, day: d.getDate() });
|
||||
}
|
||||
return days;
|
||||
}, [checkinHistory]);
|
||||
|
||||
const earnedCount = badges?.filter((b: any) => b.earned).length || 0;
|
||||
const totalCount = badges?.length || 0;
|
||||
|
||||
// Group badges by category
|
||||
const groupedBadges = useMemo(() => {
|
||||
const groupedAchievements = useMemo(() => {
|
||||
const groups: Record<string, any[]> = {};
|
||||
(badges || []).forEach((b: any) => {
|
||||
if (!groups[b.category]) groups[b.category] = [];
|
||||
groups[b.category].push(b);
|
||||
achievements.forEach((item: any) => {
|
||||
const key = item.category || "other";
|
||||
if (!groups[key]) groups[key] = [];
|
||||
groups[key].push(item);
|
||||
});
|
||||
return groups;
|
||||
}, [badges]);
|
||||
}, [achievements]);
|
||||
|
||||
if (loadingToday || loadingBadges) {
|
||||
const unlockedCount = achievements.filter((item: any) => item.unlocked).length;
|
||||
const nextTarget = achievements
|
||||
.filter((item: any) => !item.unlocked)
|
||||
.sort((a: any, b: any) => (b.progressPct || 0) - (a.progressPct || 0))[0];
|
||||
|
||||
const heatmapDays = useMemo(() => {
|
||||
const dayMap = new Map<string, any>();
|
||||
(stats?.dailyTraining || []).forEach((day: any) => dayMap.set(day.trainingDate, day));
|
||||
const days = [];
|
||||
for (let offset = 34; offset >= 0; offset -= 1) {
|
||||
const current = new Date(Date.now() - offset * 24 * 60 * 60 * 1000);
|
||||
const key = current.toISOString().slice(0, 10);
|
||||
const entry = dayMap.get(key);
|
||||
days.push({
|
||||
date: key,
|
||||
sessions: entry?.sessionCount || 0,
|
||||
minutes: entry?.totalMinutes || 0,
|
||||
score: entry?.averageScore || 0,
|
||||
day: current.getDate(),
|
||||
});
|
||||
}
|
||||
return days;
|
||||
}, [stats?.dailyTraining]);
|
||||
|
||||
if (achievementQuery.isLoading || statsQuery.isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map(i => <Skeleton key={i} className="h-32 w-full" />)}
|
||||
<Skeleton className="h-40 w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
<Skeleton className="h-80 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">训练打卡</h1>
|
||||
<p className="text-muted-foreground text-sm mt-1">坚持每日打卡,解锁成就徽章</p>
|
||||
</div>
|
||||
|
||||
{/* Check-in card */}
|
||||
<Card className={`border-0 shadow-sm ${alreadyCheckedIn ? "bg-green-50/50" : "bg-gradient-to-br from-primary/5 to-primary/10"}`}>
|
||||
<CardContent className="py-6">
|
||||
<div className="flex flex-col sm:flex-row items-center gap-4 sm:gap-6">
|
||||
<div className={`h-20 w-20 rounded-full flex items-center justify-center shrink-0 ${
|
||||
alreadyCheckedIn ? "bg-green-100" : "bg-primary/10"
|
||||
}`}>
|
||||
{alreadyCheckedIn ? (
|
||||
<CheckCircle2 className="h-10 w-10 text-green-600" />
|
||||
) : (
|
||||
<Flame className="h-10 w-10 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 text-center sm:text-left">
|
||||
<h2 className="text-xl font-bold">
|
||||
{alreadyCheckedIn ? "今日已打卡 ✅" : "今日尚未打卡"}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{alreadyCheckedIn
|
||||
? `当前连续打卡 ${todayCheckin?.streakCount || (checkinHistory?.[0] as any)?.streakCount || 1} 天`
|
||||
: "记录今天的训练,保持连续打卡!"
|
||||
}
|
||||
</p>
|
||||
{!alreadyCheckedIn && (
|
||||
<div className="mt-3 space-y-2">
|
||||
<Textarea
|
||||
placeholder="今天练了什么?(可选)"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
className="max-w-md text-sm resize-none"
|
||||
rows={2}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleCheckin}
|
||||
disabled={checkinMutation.isPending}
|
||||
className="gap-2"
|
||||
size="lg"
|
||||
>
|
||||
<Flame className="h-4 w-4" />
|
||||
{checkinMutation.isPending ? "打卡中..." : "立即打卡"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 shrink-0">
|
||||
<div className="text-center px-3 py-2 rounded-lg bg-white/80">
|
||||
<p className="text-2xl font-bold text-primary">{user?.currentStreak || todayCheckin?.streakCount || 0}</p>
|
||||
<p className="text-[10px] text-muted-foreground">连续天数</p>
|
||||
</div>
|
||||
<div className="text-center px-3 py-2 rounded-lg bg-white/80">
|
||||
<p className="text-2xl font-bold text-orange-500">{user?.longestStreak || 0}</p>
|
||||
<p className="text-[10px] text-muted-foreground">最长连续</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Calendar heatmap */}
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4 text-primary" />
|
||||
打卡日历(近60天)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-10 sm:grid-cols-15 lg:grid-cols-20 gap-1">
|
||||
{heatmapData.map((d, i) => (
|
||||
<div
|
||||
key={i}
|
||||
title={`${d.date}${d.checked ? ` · 连续${d.streak}天` : ""}`}
|
||||
className={`aspect-square rounded-sm text-[9px] flex items-center justify-center transition-colors ${
|
||||
d.checked
|
||||
? d.streak >= 7 ? "bg-green-600 text-white" : d.streak >= 3 ? "bg-green-400 text-white" : "bg-green-200 text-green-800"
|
||||
: "bg-muted/50 text-muted-foreground/50"
|
||||
}`}
|
||||
>
|
||||
{d.day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-3 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1"><div className="h-3 w-3 rounded-sm bg-muted/50" />未打卡</div>
|
||||
<div className="flex items-center gap-1"><div className="h-3 w-3 rounded-sm bg-green-200" />1-2天</div>
|
||||
<div className="flex items-center gap-1"><div className="h-3 w-3 rounded-sm bg-green-400" />3-6天</div>
|
||||
<div className="flex items-center gap-1"><div className="h-3 w-3 rounded-sm bg-green-600" />7天+</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Badges section */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<section className="rounded-[28px] border border-border/60 bg-[radial-gradient(circle_at_top_left,_rgba(244,63,94,0.12),_transparent_28%),linear-gradient(180deg,rgba(255,255,255,1),rgba(248,250,252,0.96))] p-5 shadow-sm md:p-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold flex items-center gap-2">
|
||||
<Award className="h-5 w-5 text-primary" />
|
||||
成就徽章
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">已解锁 {earnedCount}/{totalCount}</p>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">成就系统</h1>
|
||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground">
|
||||
每次训练、录制、实时分析和综合评分都会自动累计进度,持续生成新的阶段目标与解锁记录。
|
||||
</p>
|
||||
</div>
|
||||
<div className="h-2 w-32 bg-muted rounded-full overflow-hidden">
|
||||
<div className="h-full bg-primary rounded-full transition-all" style={{ width: `${totalCount > 0 ? (earnedCount / totalCount) * 100 : 0}%` }} />
|
||||
<div className="grid grid-cols-3 gap-2 text-center text-xs">
|
||||
<div className="rounded-2xl border border-border/60 bg-background/90 px-3 py-3">
|
||||
<div className="text-muted-foreground">已解锁</div>
|
||||
<div className="mt-2 text-xl font-semibold">{unlockedCount}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/60 bg-background/90 px-3 py-3">
|
||||
<div className="text-muted-foreground">当前连练</div>
|
||||
<div className="mt-2 text-xl font-semibold">{user?.currentStreak || 0}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/60 bg-background/90 px-3 py-3">
|
||||
<div className="text-muted-foreground">最长连练</div>
|
||||
<div className="mt-2 text-xl font-semibold">{user?.longestStreak || 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{Object.entries(groupedBadges).map(([category, items]) => {
|
||||
const catInfo = categoryLabels[category] || { label: category, color: "bg-gray-100 text-gray-700" };
|
||||
return (
|
||||
<div key={category} className="mb-4">
|
||||
<Badge className={`${catInfo.color} mb-2 text-xs`}>{catInfo.label}</Badge>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
{items.map((badge: any) => (
|
||||
<Card key={badge.key} className={`border-0 shadow-sm transition-all ${
|
||||
badge.earned ? "bg-white" : "bg-muted/30 opacity-60"
|
||||
}`}>
|
||||
<CardContent className="p-3 text-center">
|
||||
<div className="text-3xl mb-1">{badge.icon}</div>
|
||||
<p className="text-xs font-medium truncate">{badge.name}</p>
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5 line-clamp-2">{badge.description}</p>
|
||||
{badge.earned ? (
|
||||
<p className="text-[10px] text-green-600 mt-1">
|
||||
✅ {new Date(badge.earnedAt).toLocaleDateString("zh-CN")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex items-center justify-center gap-1 mt-1 text-[10px] text-muted-foreground">
|
||||
<Lock className="h-2.5 w-2.5" />未解锁
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1.4fr)_minmax(320px,0.9fr)]">
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Calendar className="h-4 w-4 text-primary" />
|
||||
训练热力图
|
||||
</CardTitle>
|
||||
<CardDescription>最近 35 天内,只要有训练、录制或分析写回,就会点亮对应日期。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-7 gap-2 sm:grid-cols-10 lg:grid-cols-7 xl:grid-cols-10">
|
||||
{heatmapDays.map((day) => {
|
||||
const level =
|
||||
day.sessions === 0 ? "bg-muted/45 text-muted-foreground/50" :
|
||||
day.minutes >= 45 ? "bg-emerald-600 text-white" :
|
||||
day.minutes >= 20 ? "bg-emerald-400 text-white" :
|
||||
"bg-emerald-200 text-emerald-900";
|
||||
return (
|
||||
<div
|
||||
key={day.date}
|
||||
title={`${day.date} · ${day.minutes} 分钟 · ${day.sessions} 次`}
|
||||
className={`aspect-square rounded-xl text-[11px] transition-colors flex items-center justify-center ${level}`}
|
||||
>
|
||||
{day.day}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap items-center gap-4 text-xs text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-2"><span className="h-3 w-3 rounded bg-muted/45" />无训练</span>
|
||||
<span className="inline-flex items-center gap-2"><span className="h-3 w-3 rounded bg-emerald-200" />基础训练</span>
|
||||
<span className="inline-flex items-center gap-2"><span className="h-3 w-3 rounded bg-emerald-400" />高频训练</span>
|
||||
<span className="inline-flex items-center gap-2"><span className="h-3 w-3 rounded bg-emerald-600" />高负荷训练日</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Sparkles className="h-4 w-4 text-primary" />
|
||||
下一目标
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{nextTarget ? (
|
||||
<>
|
||||
<div className="rounded-2xl border border-border/60 bg-muted/20 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-base font-semibold">{nextTarget.name}</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">{nextTarget.description}</div>
|
||||
</div>
|
||||
<Badge className={CATEGORY_META[nextTarget.category]?.tone || "bg-muted text-foreground"}>
|
||||
{CATEGORY_META[nextTarget.category]?.label || nextTarget.category}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<div className="mb-2 flex items-center justify-between text-sm">
|
||||
<span>完成度</span>
|
||||
<span className="font-medium">{Math.round(nextTarget.progressPct || 0)}%</span>
|
||||
</div>
|
||||
<Progress value={nextTarget.progressPct || 0} className="h-2" />
|
||||
<div className="mt-2 text-xs text-muted-foreground">{getProgressText(nextTarget)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div className="rounded-2xl border border-border/60 bg-muted/20 p-4">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Trophy className="h-4 w-4" />
|
||||
稀有度
|
||||
</div>
|
||||
<div className="mt-2 font-medium">{nextTarget.rarity || "common"}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/60 bg-muted/20 p-4">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Award className="h-4 w-4" />
|
||||
阶段
|
||||
</div>
|
||||
<div className="mt-2 font-medium">Tier {nextTarget.tier || 1}</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dashed border-border/60 px-4 py-10 text-center text-sm text-muted-foreground">
|
||||
当前成就已全部解锁。
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">成就列表</CardTitle>
|
||||
<CardDescription>每日签到已被训练日聚合和成就进度替代,所有进度由训练数据自动驱动。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
{Object.entries(groupedAchievements).map(([category, items]) => (
|
||||
<section key={category} className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className={CATEGORY_META[category]?.tone || "bg-muted text-foreground"}>
|
||||
{CATEGORY_META[category]?.label || category}
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{(items as any[]).filter((item) => item.unlocked).length}/{(items as any[]).length} 已解锁
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{(items as any[]).map((item) => (
|
||||
<div
|
||||
key={item.key}
|
||||
className={`rounded-2xl border p-4 transition-colors ${item.unlocked ? "border-emerald-200 bg-emerald-50/70" : "border-border/60 bg-muted/20"}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl">{item.icon || "🎾"}</span>
|
||||
<div className="font-medium">{item.name}</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="mt-2 text-sm text-muted-foreground">{item.description}</div>
|
||||
</div>
|
||||
<Badge variant={item.unlocked ? "secondary" : "outline"}>
|
||||
{item.unlocked ? "已解锁" : "进行中"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<div className="mb-2 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{getProgressText(item)}</span>
|
||||
<span>{Math.round(item.progressPct || 0)}%</span>
|
||||
</div>
|
||||
<Progress value={item.progressPct || 0} className="h-2" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardContent className="pt-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<Flame className="h-5 w-5 text-rose-600" />
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">连续训练日</div>
|
||||
<div className="mt-1 text-xl font-semibold">{user?.currentStreak || 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardContent className="pt-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<Radar className="h-5 w-5 text-sky-600" />
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">实时分析会话</div>
|
||||
<div className="mt-1 text-xl font-semibold">{(stats?.recentLiveSessions || []).length}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardContent className="pt-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<Swords className="h-5 w-5 text-orange-600" />
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">当前评分</div>
|
||||
<div className="mt-1 text-xl font-semibold">{(stats?.latestNtrpSnapshot?.rating || stats?.ntrpRating || 1.5).toFixed(1)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,30 +1,25 @@
|
||||
import { useMemo } from "react";
|
||||
import { useAuth } from "@/_core/hooks/useAuth";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Target, Video, Activity, TrendingUp, Award, Clock,
|
||||
Zap, BarChart3, ChevronRight
|
||||
} from "lucide-react";
|
||||
import { formatDateTimeShanghai } from "@/lib/time";
|
||||
import { Activity, Award, ChevronRight, Clock3, Sparkles, Swords, Target, Video } from "lucide-react";
|
||||
import { useLocation } from "wouter";
|
||||
import { ResponsiveContainer, LineChart, Line, XAxis, YAxis, Tooltip, CartesianGrid, AreaChart, Area } from "recharts";
|
||||
|
||||
function NTRPBadge({ rating }: { rating: number }) {
|
||||
let level = "初学者";
|
||||
let color = "bg-gray-100 text-gray-700";
|
||||
if (rating >= 4.0) { level = "高级竞技"; color = "bg-purple-100 text-purple-700"; }
|
||||
else if (rating >= 3.0) { level = "中高级"; color = "bg-blue-100 text-blue-700"; }
|
||||
else if (rating >= 2.5) { level = "中级"; color = "bg-green-100 text-green-700"; }
|
||||
else if (rating >= 2.0) { level = "初中级"; color = "bg-yellow-100 text-yellow-700"; }
|
||||
else if (rating >= 1.5) { level = "初级"; color = "bg-orange-100 text-orange-700"; }
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${color}`}>
|
||||
NTRP {rating.toFixed(1)} · {level}
|
||||
</span>
|
||||
);
|
||||
const level =
|
||||
rating >= 4.0 ? "高级竞技" :
|
||||
rating >= 3.5 ? "高级" :
|
||||
rating >= 3.0 ? "中高级" :
|
||||
rating >= 2.5 ? "中级" :
|
||||
rating >= 2.0 ? "初中级" :
|
||||
rating >= 1.5 ? "初级" :
|
||||
"入门";
|
||||
return <Badge className="bg-emerald-500/10 text-emerald-700">NTRP {rating.toFixed(1)} · {level}</Badge>;
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
@@ -32,248 +27,218 @@ export default function Dashboard() {
|
||||
const { data: stats, isLoading } = trpc.profile.stats.useQuery();
|
||||
const [, setLocation] = useLocation();
|
||||
|
||||
const unlockedAchievements = useMemo(
|
||||
() => (stats?.achievements || []).filter((item: any) => item.unlocked).length,
|
||||
[stats?.achievements],
|
||||
);
|
||||
|
||||
const recentTrainingDays = useMemo(
|
||||
() => [...(stats?.dailyTraining || [])].slice(-7).reverse(),
|
||||
[stats?.dailyTraining],
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-32 w-full" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[1, 2, 3, 4].map(i => <Skeleton key={i} className="h-28" />)}
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-40 w-full" />
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{[1, 2, 3, 4].map((index) => <Skeleton key={index} className="h-32" />)}
|
||||
</div>
|
||||
<Skeleton className="h-80 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ratingData = stats?.ratingHistory?.map((r: any) => ({
|
||||
date: new Date(r.createdAt).toLocaleDateString("zh-CN", { month: "short", day: "numeric" }),
|
||||
rating: r.rating,
|
||||
...((r.dimensionScores as any) || {}),
|
||||
})) || [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Welcome header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight" data-testid="dashboard-title">
|
||||
欢迎回来,{user?.name || "球友"}
|
||||
</h1>
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<NTRPBadge rating={stats?.ntrpRating || 1.5} />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
已完成 {stats?.totalSessions || 0} 次训练
|
||||
</span>
|
||||
<section className="rounded-[28px] border border-border/60 bg-[radial-gradient(circle_at_top_left,_rgba(16,185,129,0.12),_transparent_30%),linear-gradient(180deg,rgba(255,255,255,1),rgba(248,250,252,0.96))] p-5 shadow-sm md:p-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight" data-testid="dashboard-title">
|
||||
当前用户:{user?.name || "未命名用户"}
|
||||
</h1>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<NTRPBadge rating={stats?.latestNtrpSnapshot?.rating || stats?.ntrpRating || 1.5} />
|
||||
<Badge variant="outline">已完成 {stats?.totalSessions || 0} 次训练</Badge>
|
||||
<Badge variant="outline">已解锁 {unlockedAchievements} 项成就</Badge>
|
||||
</div>
|
||||
<p className="mt-3 max-w-2xl text-sm leading-6 text-muted-foreground">
|
||||
实时分析、录制归档、视频分析和训练计划都已接入同一条训练数据链路,后续会自动累计到成就、评分与训练汇总。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button data-testid="dashboard-training-button" onClick={() => setLocation("/training")} className="gap-2">
|
||||
<Target className="h-4 w-4" />
|
||||
训练计划
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setLocation("/live-camera")} className="gap-2">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
实时分析
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setLocation("/analysis")} className="gap-2">
|
||||
<Video className="h-4 w-4" />
|
||||
视频分析
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button data-testid="dashboard-training-button" onClick={() => setLocation("/training")} className="gap-2">
|
||||
<Target className="h-4 w-4" />
|
||||
训练计划
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setLocation("/analysis")} className="gap-2">
|
||||
<Video className="h-4 w-4" />
|
||||
视频分析
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Stats cards */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card className="border-0 shadow-sm bg-gradient-to-br from-green-50 to-emerald-50">
|
||||
<CardContent className="pt-5 pb-4">
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardContent className="pt-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground">NTRP评分</p>
|
||||
<p className="text-2xl font-bold text-primary mt-1">
|
||||
{(stats?.ntrpRating || 1.5).toFixed(1)}
|
||||
</p>
|
||||
<div className="text-sm text-muted-foreground">当前 NTRP</div>
|
||||
<div className="mt-2 text-2xl font-semibold">{(stats?.latestNtrpSnapshot?.rating || stats?.ntrpRating || 1.5).toFixed(1)}</div>
|
||||
</div>
|
||||
<div className="h-10 w-10 rounded-xl bg-primary/10 flex items-center justify-center">
|
||||
<Award className="h-5 w-5 text-primary" />
|
||||
<div className="rounded-2xl bg-emerald-500/10 p-3 text-emerald-700">
|
||||
<Award className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardContent className="pt-5 pb-4">
|
||||
<CardContent className="pt-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground">训练次数</p>
|
||||
<p className="text-2xl font-bold mt-1">{stats?.totalSessions || 0}</p>
|
||||
<div className="text-sm text-muted-foreground">累计训练时长</div>
|
||||
<div className="mt-2 text-2xl font-semibold">{stats?.totalMinutes || 0}<span className="ml-1 text-sm font-normal text-muted-foreground">分钟</span></div>
|
||||
</div>
|
||||
<div className="h-10 w-10 rounded-xl bg-blue-50 flex items-center justify-center">
|
||||
<Activity className="h-5 w-5 text-blue-600" />
|
||||
<div className="rounded-2xl bg-sky-500/10 p-3 text-sky-700">
|
||||
<Clock3 className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardContent className="pt-5 pb-4">
|
||||
<CardContent className="pt-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground">训练时长</p>
|
||||
<p className="text-2xl font-bold mt-1">{stats?.totalMinutes || 0}<span className="text-sm font-normal text-muted-foreground ml-1">分钟</span></p>
|
||||
<div className="text-sm text-muted-foreground">累计有效动作</div>
|
||||
<div className="mt-2 text-2xl font-semibold">{stats?.totalShots || 0}</div>
|
||||
</div>
|
||||
<div className="h-10 w-10 rounded-xl bg-orange-50 flex items-center justify-center">
|
||||
<Clock className="h-5 w-5 text-orange-600" />
|
||||
<div className="rounded-2xl bg-amber-500/10 p-3 text-amber-700">
|
||||
<Activity className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardContent className="pt-5 pb-4">
|
||||
<CardContent className="pt-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground">总击球数</p>
|
||||
<p className="text-2xl font-bold mt-1">{stats?.totalShots || 0}</p>
|
||||
<div className="text-sm text-muted-foreground">最近实时分析</div>
|
||||
<div className="mt-2 text-2xl font-semibold">{(stats?.recentLiveSessions || []).length}</div>
|
||||
</div>
|
||||
<div className="h-10 w-10 rounded-xl bg-purple-50 flex items-center justify-center">
|
||||
<Zap className="h-5 w-5 text-purple-600" />
|
||||
<div className="rounded-2xl bg-rose-500/10 p-3 text-rose-700">
|
||||
<Swords className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Rating trend chart */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.3fr)_minmax(320px,0.85fr)]">
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||
<TrendingUp className="h-4 w-4 text-primary" />
|
||||
NTRP评分趋势
|
||||
</CardTitle>
|
||||
<Button variant="ghost" size="sm" onClick={() => setLocation("/rating")} className="text-xs gap-1">
|
||||
查看详情 <ChevronRight className="h-3 w-3" />
|
||||
<div>
|
||||
<CardTitle className="text-base">最近 7 天训练脉冲</CardTitle>
|
||||
<CardDescription>每次训练、录制和实时分析都会自动计入这里。</CardDescription>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => setLocation("/progress")} className="gap-1 text-xs">
|
||||
查看进度
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{ratingData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<AreaChart data={ratingData}>
|
||||
<defs>
|
||||
<linearGradient id="ratingGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="oklch(0.55 0.16 145)" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="oklch(0.55 0.16 145)" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 11 }} />
|
||||
<YAxis domain={[1, 5]} tick={{ fontSize: 11 }} />
|
||||
<Tooltip />
|
||||
<Area type="monotone" dataKey="rating" stroke="oklch(0.55 0.16 145)" fill="url(#ratingGradient)" strokeWidth={2} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
<CardContent className="space-y-3">
|
||||
{recentTrainingDays.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-border/60 px-4 py-10 text-center text-sm text-muted-foreground">
|
||||
暂无训练数据。
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-[200px] flex items-center justify-center text-muted-foreground text-sm">
|
||||
<div className="text-center">
|
||||
<BarChart3 className="h-8 w-8 mx-auto mb-2 opacity-30" />
|
||||
<p>完成视频分析后将显示评分趋势</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Recent analyses */}
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||
<Video className="h-4 w-4 text-primary" />
|
||||
最近分析
|
||||
</CardTitle>
|
||||
<Button variant="ghost" size="sm" onClick={() => setLocation("/videos")} className="text-xs gap-1">
|
||||
查看全部 <ChevronRight className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{(stats?.recentAnalyses?.length || 0) > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{stats!.recentAnalyses.slice(0, 4).map((a: any) => (
|
||||
<div key={a.id} className="flex items-center justify-between py-2 border-b last:border-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-8 w-8 rounded-lg bg-primary/5 flex items-center justify-center text-xs font-bold text-primary">
|
||||
{Math.round(a.overallScore || 0)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{a.exerciseType || "综合分析"}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{new Date(a.createdAt).toLocaleDateString("zh-CN")}
|
||||
{a.shotCount ? ` · ${a.shotCount}次击球` : ""}
|
||||
</p>
|
||||
recentTrainingDays.map((day: any) => (
|
||||
<div key={day.trainingDate} className="rounded-2xl border border-border/60 bg-muted/20 p-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div className="font-medium">{day.trainingDate}</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">
|
||||
{day.sessionCount || 0} 次训练 · {day.totalMinutes || 0} 分钟 · {day.effectiveActions || 0} 个有效动作
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Progress value={a.overallScore || 0} className="w-16 h-1.5" />
|
||||
<span className="text-xs text-muted-foreground">{Math.round(a.overallScore || 0)}分</span>
|
||||
<div className="min-w-[150px]">
|
||||
<div className="mb-2 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>平均得分</span>
|
||||
<span>{Math.round(day.averageScore || 0)}</span>
|
||||
</div>
|
||||
<Progress value={day.averageScore || 0} className="h-2" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-[200px] flex items-center justify-center text-muted-foreground text-sm">
|
||||
<div className="text-center">
|
||||
<Video className="h-8 w-8 mx-auto mb-2 opacity-30" />
|
||||
<p>上传训练视频后可查看分析结果</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Quick actions */}
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base font-semibold">常用入口</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<button
|
||||
onClick={() => setLocation("/training")}
|
||||
className="flex items-center gap-3 p-4 rounded-xl border hover:bg-accent transition-colors text-left"
|
||||
>
|
||||
<div className="h-10 w-10 rounded-xl bg-green-100 flex items-center justify-center shrink-0">
|
||||
<Target className="h-5 w-5 text-green-700" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-sm">生成训练计划</p>
|
||||
<p className="text-xs text-muted-foreground">按当前设置生成训练安排</p>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setLocation("/analysis")}
|
||||
className="flex items-center gap-3 p-4 rounded-xl border hover:bg-accent transition-colors text-left"
|
||||
>
|
||||
<div className="h-10 w-10 rounded-xl bg-blue-100 flex items-center justify-center shrink-0">
|
||||
<Video className="h-5 w-5 text-blue-700" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-sm">上传视频分析</p>
|
||||
<p className="text-xs text-muted-foreground">导入视频并查看姿势分析</p>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setLocation("/rating")}
|
||||
className="flex items-center gap-3 p-4 rounded-xl border hover:bg-accent transition-colors text-left"
|
||||
>
|
||||
<div className="h-10 w-10 rounded-xl bg-purple-100 flex items-center justify-center shrink-0">
|
||||
<Award className="h-5 w-5 text-purple-700" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-sm">查看NTRP评分</p>
|
||||
<p className="text-xs text-muted-foreground">查看评分记录和维度结果</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="space-y-4">
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">最近实时分析</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{(stats?.recentLiveSessions || []).length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-border/60 px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
暂无实时分析记录。
|
||||
</div>
|
||||
) : (
|
||||
(stats?.recentLiveSessions || []).slice(-4).reverse().map((session: any) => (
|
||||
<div key={session.id} className="rounded-2xl border border-border/60 bg-muted/20 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="font-medium">{session.title}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{formatDateTimeShanghai(session.createdAt)}</div>
|
||||
</div>
|
||||
<Badge variant="outline">{Math.round(session.overallScore || 0)} 分</Badge>
|
||||
</div>
|
||||
<div className="mt-3 text-sm text-muted-foreground">
|
||||
{session.totalSegments || 0} 段动作 · 有效 {session.effectiveSegments || 0} 段
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">成就进展</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{(stats?.achievements || []).slice(0, 4).map((item: any) => (
|
||||
<div key={item.key} className="rounded-2xl border border-border/60 bg-muted/20 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="font-medium">{item.name}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{item.description}</div>
|
||||
</div>
|
||||
<Badge variant={item.unlocked ? "secondary" : "outline"}>
|
||||
{item.unlocked ? "已解锁" : `${Math.round(item.progressPct || 0)}%`}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Button variant="outline" className="w-full gap-2" onClick={() => setLocation("/checkin")}>
|
||||
查看成就系统
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Target, Loader2 } from "lucide-react";
|
||||
|
||||
export default function Login() {
|
||||
const [username, setUsername] = useState("");
|
||||
const [inviteCode, setInviteCode] = useState("");
|
||||
const [, setLocation] = useLocation();
|
||||
const utils = trpc.useUtils();
|
||||
const loginMutation = trpc.auth.loginWithUsername.useMutation();
|
||||
@@ -37,9 +38,12 @@ export default function Login() {
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await loginMutation.mutateAsync({ username: username.trim() });
|
||||
const data = await loginMutation.mutateAsync({
|
||||
username: username.trim(),
|
||||
inviteCode: inviteCode.trim() || undefined,
|
||||
});
|
||||
const user = await syncAuthenticatedUser(data.user);
|
||||
toast.success(data.isNew ? `欢迎加入,${user.name}!` : `欢迎回来,${user.name}!`);
|
||||
toast.success(data.isNew ? `已创建用户:${user.name}` : `已登录:${user.name}`);
|
||||
setLocation("/dashboard");
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "未知错误";
|
||||
@@ -77,6 +81,20 @@ export default function Login() {
|
||||
maxLength={64}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
data-testid="login-invite-code-input"
|
||||
type="text"
|
||||
placeholder="邀请码,仅新用户首次登录需要"
|
||||
value={inviteCode}
|
||||
onChange={(e) => setInviteCode(e.target.value)}
|
||||
className="h-12 text-base"
|
||||
maxLength={64}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
已存在账号只需输入用户名。新用户首次登录需要邀请码。
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
data-testid="login-submit-button"
|
||||
type="submit"
|
||||
@@ -114,7 +132,7 @@ export default function Login() {
|
||||
</Card>
|
||||
|
||||
<p className="text-center text-xs text-muted-foreground mt-6">
|
||||
输入用户名后进入系统
|
||||
直接输入用户名登录;新用户首次登录需填写邀请码
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
260
client/src/pages/Logs.tsx
普通文件
@@ -0,0 +1,260 @@
|
||||
import { useMemo } from "react";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { formatDateTimeShanghai } from "@/lib/time";
|
||||
import { toast } from "sonner";
|
||||
import { AlertTriangle, BellRing, CheckCircle2, ClipboardList, Loader2, RefreshCcw } from "lucide-react";
|
||||
|
||||
function formatTaskStatus(status: string) {
|
||||
switch (status) {
|
||||
case "running":
|
||||
return "执行中";
|
||||
case "succeeded":
|
||||
return "已完成";
|
||||
case "failed":
|
||||
return "失败";
|
||||
default:
|
||||
return "排队中";
|
||||
}
|
||||
}
|
||||
|
||||
function formatNotificationState(isRead: number | boolean | null | undefined) {
|
||||
return isRead ? "已读" : "未读";
|
||||
}
|
||||
|
||||
function formatStructuredValue(value: unknown) {
|
||||
if (!value) return "";
|
||||
if (typeof value === "string") return value;
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function formatTaskTiming(task: {
|
||||
createdAt: string | Date;
|
||||
startedAt?: string | Date | null;
|
||||
completedAt?: string | Date | null;
|
||||
}) {
|
||||
const createdAt = new Date(task.createdAt).getTime();
|
||||
const startedAt = task.startedAt ? new Date(task.startedAt).getTime() : null;
|
||||
const completedAt = task.completedAt ? new Date(task.completedAt).getTime() : null;
|
||||
const durationMs = (completedAt ?? Date.now()) - (startedAt ?? createdAt);
|
||||
const seconds = Math.max(0, Math.round(durationMs / 1000));
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const rest = seconds % 60;
|
||||
return `${minutes}m ${rest.toString().padStart(2, "0")}s`;
|
||||
}
|
||||
|
||||
export default function Logs() {
|
||||
const utils = trpc.useUtils();
|
||||
const taskListQuery = trpc.task.list.useQuery(
|
||||
{ limit: 50 },
|
||||
{
|
||||
retry: 3,
|
||||
retryDelay: (attempt) => Math.min(1_000 * 2 ** attempt, 8_000),
|
||||
placeholderData: (previous) => previous,
|
||||
refetchInterval: (query) => {
|
||||
const hasActiveTask = (query.state.data ?? []).some((task) => task.status === "queued" || task.status === "running");
|
||||
return hasActiveTask ? 3_000 : 10_000;
|
||||
},
|
||||
},
|
||||
);
|
||||
const notificationQuery = trpc.notification.list.useQuery({ limit: 50 });
|
||||
const retryMutation = trpc.task.retry.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.task.list.invalidate();
|
||||
toast.success("任务已重新排队");
|
||||
},
|
||||
onError: (error) => toast.error(`任务重试失败: ${error.message}`),
|
||||
});
|
||||
|
||||
const activeTaskCount = useMemo(
|
||||
() => (taskListQuery.data ?? []).filter((task) => task.status === "queued" || task.status === "running").length,
|
||||
[taskListQuery.data],
|
||||
);
|
||||
const failedTaskCount = useMemo(
|
||||
() => (taskListQuery.data ?? []).filter((task) => task.status === "failed").length,
|
||||
[taskListQuery.data],
|
||||
);
|
||||
const unreadNotificationCount = useMemo(
|
||||
() => (notificationQuery.data ?? []).filter((item) => !item.isRead).length,
|
||||
[notificationQuery.data],
|
||||
);
|
||||
|
||||
if (taskListQuery.isLoading && notificationQuery.isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-28 w-full" />
|
||||
<Skeleton className="h-96 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">系统日志</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
查看后台任务、归档失败原因和通知记录。录制结束失败、训练计划生成失败等信息会保留在这里。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="secondary">活动任务 {activeTaskCount}</Badge>
|
||||
<Badge variant={failedTaskCount > 0 ? "destructive" : "secondary"}>失败任务 {failedTaskCount}</Badge>
|
||||
<Badge variant="outline">未读通知 {unreadNotificationCount}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Alert>
|
||||
<ClipboardList className="h-4 w-4" />
|
||||
<AlertTitle>排障入口</AlertTitle>
|
||||
<AlertDescription>
|
||||
如果录制归档、视频分析或训练计划生成失败,先看“后台任务”里的错误信息,再根据任务标题定位具体模块。
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{taskListQuery.isError ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>任务列表刷新失败</AlertTitle>
|
||||
<AlertDescription>
|
||||
当前显示最近一次成功拉取的数据。服务恢复后页面会自动继续刷新。
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Tabs defaultValue="tasks" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="tasks">后台任务</TabsTrigger>
|
||||
<TabsTrigger value="notifications">通知记录</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="tasks">
|
||||
<ScrollArea className="max-h-[70vh] pr-3">
|
||||
<div className="space-y-4">
|
||||
{(taskListQuery.data ?? []).length === 0 ? (
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="pt-6 text-sm text-muted-foreground">
|
||||
还没有后台任务记录。
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
(taskListQuery.data ?? []).map((task) => (
|
||||
<Card key={task.id} className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-base">{task.title}</CardTitle>
|
||||
<CardDescription>
|
||||
{formatDateTimeShanghai(task.createdAt)} · {task.type}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant={task.status === "failed" ? "destructive" : "secondary"}>
|
||||
{formatTaskStatus(task.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">{task.message || formatTaskStatus(task.status)}</p>
|
||||
|
||||
{task.error ? (
|
||||
<div className="rounded-xl bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span className="whitespace-pre-wrap break-words">{task.error}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{task.result ? (
|
||||
<pre className="overflow-x-auto rounded-xl bg-muted/60 p-3 text-xs leading-5 whitespace-pre-wrap break-words">
|
||||
{formatStructuredValue(task.result)}
|
||||
</pre>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
进度 {task.progress}% · 尝试 {task.attempts}/{task.maxAttempts} · 耗时 {formatTaskTiming(task)}
|
||||
</span>
|
||||
{task.status === "failed" ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
onClick={() => retryMutation.mutate({ taskId: task.id })}
|
||||
disabled={retryMutation.isPending}
|
||||
>
|
||||
{retryMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCcw className="h-4 w-4" />}
|
||||
重试
|
||||
</Button>
|
||||
) : task.status === "succeeded" ? (
|
||||
<span className="inline-flex items-center gap-1 text-emerald-600">
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
已完成
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-primary">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
处理中
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="notifications">
|
||||
<ScrollArea className="max-h-[70vh] pr-3">
|
||||
<div className="space-y-4">
|
||||
{(notificationQuery.data ?? []).length === 0 ? (
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="pt-6 text-sm text-muted-foreground">
|
||||
还没有通知记录。
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
(notificationQuery.data ?? []).map((item) => (
|
||||
<Card key={item.id} className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-base">{item.title}</CardTitle>
|
||||
<CardDescription>
|
||||
{formatDateTimeShanghai(item.createdAt)} · {item.notificationType}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant={item.isRead ? "secondary" : "outline"}>
|
||||
{formatNotificationState(item.isRead)}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-start gap-2 text-sm">
|
||||
<BellRing className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<p className="whitespace-pre-wrap break-words">{item.message || "无附加内容"}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,19 +4,44 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/com
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Activity, Calendar, CheckCircle2, Clock, TrendingUp, Target } from "lucide-react";
|
||||
import { Activity, Calendar, CheckCircle2, ChevronDown, ChevronUp, Clock, TrendingUp, Target, Sparkles } from "lucide-react";
|
||||
import { formatDateTimeShanghai, formatMonthDayShanghai } from "@/lib/time";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||
LineChart, Line, Legend
|
||||
} from "recharts";
|
||||
import { useLocation } from "wouter";
|
||||
|
||||
const ACTION_LABEL_MAP: Record<string, string> = {
|
||||
forehand: "正手挥拍",
|
||||
backhand: "反手挥拍",
|
||||
serve: "发球",
|
||||
volley: "截击",
|
||||
overhead: "高压",
|
||||
slice: "切削",
|
||||
lob: "挑高球",
|
||||
unknown: "未知动作",
|
||||
};
|
||||
|
||||
function getRecordMetadata(record: any) {
|
||||
if (!record?.metadata || typeof record.metadata !== "object") {
|
||||
return null;
|
||||
}
|
||||
return record.metadata as Record<string, any>;
|
||||
}
|
||||
|
||||
function getActionLabel(actionType: string) {
|
||||
return ACTION_LABEL_MAP[actionType] || actionType;
|
||||
}
|
||||
|
||||
export default function Progress() {
|
||||
const { user } = useAuth();
|
||||
const { data: records, isLoading } = trpc.record.list.useQuery({ limit: 100 });
|
||||
const { data: analyses } = trpc.analysis.list.useQuery();
|
||||
const { data: stats } = trpc.profile.stats.useQuery();
|
||||
const [, setLocation] = useLocation();
|
||||
const [expandedRecordId, setExpandedRecordId] = useState<number | null>(null);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -29,7 +54,7 @@ export default function Progress() {
|
||||
// Aggregate data by date for charts
|
||||
const dateMap = new Map<string, { date: string; sessions: number; minutes: number; avgScore: number; scores: number[] }>();
|
||||
(records || []).forEach((r: any) => {
|
||||
const date = new Date(r.trainingDate || r.createdAt).toLocaleDateString("zh-CN", { month: "short", day: "numeric" });
|
||||
const date = formatMonthDayShanghai(r.trainingDate || r.createdAt);
|
||||
const existing = dateMap.get(date) || { date, sessions: 0, minutes: 0, avgScore: 0, scores: [] };
|
||||
existing.sessions++;
|
||||
existing.minutes += r.durationMinutes || 0;
|
||||
@@ -44,7 +69,7 @@ export default function Progress() {
|
||||
|
||||
// Analysis score trend
|
||||
const scoreTrend = (analyses || []).map((a: any) => ({
|
||||
date: new Date(a.createdAt).toLocaleDateString("zh-CN", { month: "short", day: "numeric" }),
|
||||
date: formatMonthDayShanghai(a.createdAt),
|
||||
overall: Math.round(a.overallScore || 0),
|
||||
consistency: Math.round(a.strokeConsistency || 0),
|
||||
footwork: Math.round(a.footworkScore || 0),
|
||||
@@ -95,6 +120,14 @@ export default function Progress() {
|
||||
<p className="text-2xl font-bold">{analyses?.length || 0}<span className="text-sm font-normal ml-1">次</span></p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardContent className="pt-4 pb-3">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-1">
|
||||
<Sparkles className="h-3 w-3" />实时分析
|
||||
</div>
|
||||
<p className="text-2xl font-bold">{stats?.recentLiveSessions?.length || 0}<span className="text-sm font-normal ml-1">条</span></p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
@@ -170,34 +203,129 @@ export default function Progress() {
|
||||
<CardContent>
|
||||
{(records?.length || 0) > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{(records || []).slice(0, 20).map((record: any) => (
|
||||
<div key={record.id} className="flex items-center justify-between py-2 border-b last:border-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`h-8 w-8 rounded-lg flex items-center justify-center ${
|
||||
record.completed ? "bg-green-50 text-green-600" : "bg-muted text-muted-foreground"
|
||||
}`}>
|
||||
{record.completed ? <CheckCircle2 className="h-4 w-4" /> : <Activity className="h-4 w-4" />}
|
||||
{(records || []).slice(0, 20).map((record: any) => {
|
||||
const metadata = getRecordMetadata(record);
|
||||
const actionSummary = metadata?.actionSummary && typeof metadata.actionSummary === "object"
|
||||
? Object.entries(metadata.actionSummary as Record<string, number>).filter(([, count]) => Number(count) > 0)
|
||||
: [];
|
||||
const topActions = actionSummary
|
||||
.sort((left, right) => Number(right[1]) - Number(left[1]))
|
||||
.slice(0, 3);
|
||||
|
||||
return (
|
||||
<div key={record.id} className="border-b py-2 last:border-0">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`mt-0.5 h-8 w-8 rounded-lg flex items-center justify-center ${
|
||||
record.completed ? "bg-green-50 text-green-600" : "bg-muted text-muted-foreground"
|
||||
}`}>
|
||||
{record.completed ? <CheckCircle2 className="h-4 w-4" /> : <Activity className="h-4 w-4" />}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{record.exerciseName}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatDateTimeShanghai(record.trainingDate || record.createdAt, { second: "2-digit" })}
|
||||
{record.durationMinutes ? ` · ${record.durationMinutes}分钟` : ""}
|
||||
{record.sourceType ? ` · ${record.sourceType}` : ""}
|
||||
</p>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||||
{record.actionCount ? (
|
||||
<Badge variant="outline" className="text-[11px]">
|
||||
动作数 {record.actionCount}
|
||||
</Badge>
|
||||
) : null}
|
||||
{metadata?.dominantAction ? (
|
||||
<Badge variant="secondary" className="text-[11px]">
|
||||
主动作 {getActionLabel(String(metadata.dominantAction))}
|
||||
</Badge>
|
||||
) : null}
|
||||
{topActions.map(([actionType, count]) => (
|
||||
<Badge key={`${record.id}-${actionType}`} variant="secondary" className="text-[11px]">
|
||||
{getActionLabel(actionType)} {count} 次
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{record.exerciseName}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{new Date(record.trainingDate || record.createdAt).toLocaleDateString("zh-CN")}
|
||||
{record.durationMinutes ? ` · ${record.durationMinutes}分钟` : ""}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{record.poseScore && (
|
||||
<Badge variant="secondary" className="text-xs">{Math.round(record.poseScore)}分</Badge>
|
||||
)}
|
||||
{record.completed ? (
|
||||
<Badge className="bg-green-100 text-green-700 text-xs">已完成</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-xs">进行中</Badge>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setExpandedRecordId((current) => current === record.id ? null : record.id)}
|
||||
>
|
||||
{expandedRecordId === record.id ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{record.poseScore && (
|
||||
<Badge variant="secondary" className="text-xs">{Math.round(record.poseScore)}分</Badge>
|
||||
)}
|
||||
{record.completed ? (
|
||||
<Badge className="bg-green-100 text-green-700 text-xs">已完成</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-xs">进行中</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{expandedRecordId === record.id ? (
|
||||
<div className="mt-3 rounded-2xl border border-border/60 bg-muted/20 p-4 text-sm">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">记录时间</div>
|
||||
<div className="mt-1 font-medium">{formatDateTimeShanghai(record.trainingDate || record.createdAt, { second: "2-digit" })}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">动作数据</div>
|
||||
<div className="mt-1 font-medium">动作数 {record.actionCount || 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{metadata ? (
|
||||
<div className="mt-4 space-y-3">
|
||||
{metadata.dominantAction ? (
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">主动作</div>
|
||||
<div className="mt-1 font-medium">{getActionLabel(String(metadata.dominantAction))}</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{metadata.actionSummary && Object.keys(metadata.actionSummary).length > 0 ? (
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">动作明细</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{Object.entries(metadata.actionSummary as Record<string, number>)
|
||||
.filter(([, count]) => Number(count) > 0)
|
||||
.map(([actionType, count]) => (
|
||||
<Badge key={actionType} variant="secondary">
|
||||
{getActionLabel(actionType)} {count} 次
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{metadata.validityStatus ? (
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">录制有效性</div>
|
||||
<div className="mt-1 font-medium">{String(metadata.validityStatus)}</div>
|
||||
{metadata.invalidReason ? (
|
||||
<div className="mt-1 text-xs text-muted-foreground">{String(metadata.invalidReason)}</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{record.notes ? (
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">备注</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">{record.notes}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-8 text-center text-muted-foreground text-sm">
|
||||
|
||||
@@ -1,228 +1,272 @@
|
||||
import { useAuth } from "@/_core/hooks/useAuth";
|
||||
import { useMemo, useState } from "react";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Award, TrendingUp, Target, Zap, Footprints, Activity, Wind } from "lucide-react";
|
||||
import { useBackgroundTask } from "@/hooks/useBackgroundTask";
|
||||
import { formatDateTimeShanghai, formatMonthDayShanghai } from "@/lib/time";
|
||||
import { toast } from "sonner";
|
||||
import { Activity, Award, Loader2, RefreshCw, Radar, TrendingUp } from "lucide-react";
|
||||
import {
|
||||
ResponsiveContainer, RadarChart, PolarGrid, PolarAngleAxis,
|
||||
PolarRadiusAxis, Radar, AreaChart, Area, XAxis, YAxis,
|
||||
CartesianGrid, Tooltip, Legend
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
PolarAngleAxis,
|
||||
PolarGrid,
|
||||
PolarRadiusAxis,
|
||||
Radar as RadarChartShape,
|
||||
RadarChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
const NTRP_LEVELS = [
|
||||
{ min: 1.0, max: 1.5, label: "初学者", desc: "刚开始学习网球,正在学习基本击球动作", color: "bg-gray-100 text-gray-700" },
|
||||
{ min: 1.5, max: 2.0, label: "初级", desc: "能够进行简单的来回击球,但缺乏一致性", color: "bg-orange-100 text-orange-700" },
|
||||
{ min: 2.0, max: 2.5, label: "初中级", desc: "击球更加稳定,开始理解基本策略", color: "bg-yellow-100 text-yellow-700" },
|
||||
{ min: 2.5, max: 3.0, label: "中级", desc: "能够稳定地进行中速击球,具备基本的网前技术", color: "bg-green-100 text-green-700" },
|
||||
{ min: 3.0, max: 3.5, label: "中高级", desc: "击球力量和控制力增强,开始使用旋转", color: "bg-blue-100 text-blue-700" },
|
||||
{ min: 3.5, max: 4.0, label: "高级", desc: "具备全面的技术,能够在比赛中运用战术", color: "bg-indigo-100 text-indigo-700" },
|
||||
{ min: 4.0, max: 4.5, label: "高级竞技", desc: "技术精湛,具备强大的进攻和防守能力", color: "bg-purple-100 text-purple-700" },
|
||||
{ min: 4.5, max: 5.0, label: "专业水平", desc: "接近职业水平,全面的技术和战术能力", color: "bg-red-100 text-red-700" },
|
||||
{ min: 1.0, max: 1.5, label: "入门" },
|
||||
{ min: 1.5, max: 2.0, label: "初级" },
|
||||
{ min: 2.0, max: 2.5, label: "初中级" },
|
||||
{ min: 2.5, max: 3.0, label: "中级" },
|
||||
{ min: 3.0, max: 3.5, label: "中高级" },
|
||||
{ min: 3.5, max: 4.0, label: "高级" },
|
||||
{ min: 4.0, max: 4.5, label: "高级竞技" },
|
||||
{ min: 4.5, max: 5.1, label: "接近专业" },
|
||||
];
|
||||
|
||||
function getNTRPLevel(rating: number) {
|
||||
return NTRP_LEVELS.find(l => rating >= l.min && rating < l.max) || NTRP_LEVELS[0];
|
||||
function getLevel(rating: number) {
|
||||
return NTRP_LEVELS.find((item) => rating >= item.min && rating < item.max)?.label || "入门";
|
||||
}
|
||||
|
||||
export default function Rating() {
|
||||
const { user } = useAuth();
|
||||
const { data: ratingData } = trpc.rating.current.useQuery();
|
||||
const { data: history, isLoading } = trpc.rating.history.useQuery();
|
||||
const { data: stats } = trpc.profile.stats.useQuery();
|
||||
const [taskId, setTaskId] = useState<string | null>(null);
|
||||
const currentQuery = trpc.rating.current.useQuery();
|
||||
const historyQuery = trpc.rating.history.useQuery();
|
||||
const refreshMineMutation = trpc.rating.refreshMine.useMutation({
|
||||
onSuccess: (data) => {
|
||||
setTaskId(data.taskId);
|
||||
toast.success("NTRP 刷新任务已加入后台队列");
|
||||
},
|
||||
onError: (error) => toast.error(`NTRP 刷新失败: ${error.message}`),
|
||||
});
|
||||
const taskQuery = useBackgroundTask(taskId);
|
||||
|
||||
const currentRating = ratingData?.rating || 1.5;
|
||||
const level = getNTRPLevel(currentRating);
|
||||
const currentRating = currentQuery.data?.rating || 1.5;
|
||||
const latestSnapshot = currentQuery.data?.latestSnapshot as any;
|
||||
const history = historyQuery.data ?? [];
|
||||
|
||||
// Get latest dimension scores
|
||||
const latestWithDimensions = history?.find((h: any) => h.dimensionScores);
|
||||
const dimensions = (latestWithDimensions as any)?.dimensionScores || {};
|
||||
const radarData = useMemo(() => {
|
||||
const scores = latestSnapshot?.dimensionScores || {};
|
||||
return [
|
||||
{ dimension: "姿态", value: scores.poseAccuracy || 0 },
|
||||
{ dimension: "一致性", value: scores.strokeConsistency || 0 },
|
||||
{ dimension: "脚步", value: scores.footwork || 0 },
|
||||
{ dimension: "流畅度", value: scores.fluidity || 0 },
|
||||
{ dimension: "时机", value: scores.timing || 0 },
|
||||
{ dimension: "比赛准备", value: scores.matchReadiness || 0 },
|
||||
];
|
||||
}, [latestSnapshot?.dimensionScores]);
|
||||
|
||||
const radarData = [
|
||||
{ dimension: "姿势准确", value: dimensions.poseAccuracy || 0, fullMark: 100 },
|
||||
{ dimension: "击球一致", value: dimensions.strokeConsistency || 0, fullMark: 100 },
|
||||
{ dimension: "脚步移动", value: dimensions.footwork || 0, fullMark: 100 },
|
||||
{ dimension: "动作流畅", value: dimensions.fluidity || 0, fullMark: 100 },
|
||||
{ dimension: "力量", value: dimensions.power || 0, fullMark: 100 },
|
||||
];
|
||||
const trendData = useMemo(
|
||||
() => history.map((item: any) => ({
|
||||
date: formatMonthDayShanghai(item.createdAt),
|
||||
rating: item.rating,
|
||||
})).reverse(),
|
||||
[history],
|
||||
);
|
||||
|
||||
const trendData = (history || []).map((h: any) => ({
|
||||
date: new Date(h.createdAt).toLocaleDateString("zh-CN", { month: "short", day: "numeric" }),
|
||||
rating: h.rating,
|
||||
}));
|
||||
|
||||
if (isLoading) {
|
||||
if (currentQuery.isLoading || historyQuery.isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-40 w-full" />
|
||||
<Skeleton className="h-60 w-full" />
|
||||
<Skeleton className="h-80 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">NTRP评分系统</h1>
|
||||
<p className="text-muted-foreground text-sm mt-1">基于所有历史训练记录自动计算的综合评分</p>
|
||||
</div>
|
||||
<section className="rounded-[28px] border border-border/60 bg-[radial-gradient(circle_at_top_left,_rgba(16,185,129,0.12),_transparent_32%),linear-gradient(180deg,rgba(255,255,255,1),rgba(248,250,252,0.96))] p-5 shadow-sm md:p-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">NTRP 评分系统</h1>
|
||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground">
|
||||
评分由历史训练、实时分析、录制归档与动作质量共同计算。每日零点后会自动异步刷新,当前用户也可以手动提交刷新任务。
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => refreshMineMutation.mutate()} disabled={refreshMineMutation.isPending} className="gap-2">
|
||||
{refreshMineMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
||||
刷新我的 NTRP
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Current rating card */}
|
||||
<Card className="border-0 shadow-sm overflow-hidden">
|
||||
<div className="bg-gradient-to-r from-primary/10 via-primary/5 to-transparent p-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-20 w-20 rounded-2xl bg-primary/10 flex items-center justify-center">
|
||||
<span className="text-3xl font-bold text-primary">{currentRating.toFixed(1)}</span>
|
||||
</div>
|
||||
{(taskQuery.data?.status === "queued" || taskQuery.data?.status === "running") ? (
|
||||
<Alert>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
<AlertTitle>后台执行中</AlertTitle>
|
||||
<AlertDescription>{taskQuery.data.message || "NTRP 刷新任务正在后台执行。"}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(320px,360px)]">
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col gap-5 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold">{level.label}</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1 max-w-md">{level.desc}</p>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Award className="h-4 w-4 text-primary" />
|
||||
<span className="text-sm font-medium">NTRP {currentRating.toFixed(1)}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-3xl bg-emerald-500/10 px-5 py-4 text-4xl font-semibold text-emerald-700">
|
||||
{currentRating.toFixed(1)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-semibold">{getLevel(currentRating)}</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">最新综合评分</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
<Badge className="bg-emerald-500/10 text-emerald-700">
|
||||
<Award className="mr-1 h-3.5 w-3.5" />
|
||||
NTRP {currentRating.toFixed(1)}
|
||||
</Badge>
|
||||
{latestSnapshot?.triggerType ? <Badge variant="outline">来源 {latestSnapshot.triggerType}</Badge> : null}
|
||||
{latestSnapshot?.createdAt ? (
|
||||
<Badge variant="outline">
|
||||
刷新于 {formatDateTimeShanghai(latestSnapshot.createdAt)}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div className="rounded-2xl border border-border/60 bg-muted/20 p-4">
|
||||
<div className="text-muted-foreground">训练日</div>
|
||||
<div className="mt-2 font-semibold">{latestSnapshot?.sourceSummary?.activeDays || 0}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/60 bg-muted/20 p-4">
|
||||
<div className="text-muted-foreground">有效动作</div>
|
||||
<div className="mt-2 font-semibold">{latestSnapshot?.sourceSummary?.totalEffectiveActions || 0}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/60 bg-muted/20 p-4">
|
||||
<div className="text-muted-foreground">实时分析</div>
|
||||
<div className="mt-2 font-semibold">{latestSnapshot?.sourceSummary?.liveSessions || 0}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/60 bg-muted/20 p-4">
|
||||
<div className="text-muted-foreground">PK 会话</div>
|
||||
<div className="mt-2 font-semibold">{latestSnapshot?.sourceSummary?.totalPk || 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Radar chart */}
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Target className="h-4 w-4 text-primary" />
|
||||
能力雷达图
|
||||
</CardTitle>
|
||||
<CardDescription>五维度综合能力评估</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{Object.keys(dimensions).length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<RadarChart data={radarData}>
|
||||
<PolarGrid stroke="#e5e7eb" />
|
||||
<PolarAngleAxis dataKey="dimension" tick={{ fontSize: 12 }} />
|
||||
<PolarRadiusAxis angle={90} domain={[0, 100]} tick={{ fontSize: 10 }} />
|
||||
<Radar
|
||||
name="能力值"
|
||||
dataKey="value"
|
||||
stroke="oklch(0.55 0.16 145)"
|
||||
fill="oklch(0.55 0.16 145)"
|
||||
fillOpacity={0.3}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</RadarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-[280px] flex items-center justify-center text-muted-foreground text-sm">
|
||||
<div className="text-center">
|
||||
<Target className="h-8 w-8 mx-auto mb-2 opacity-30" />
|
||||
<p>完成视频分析后将显示能力雷达图</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Rating trend */}
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<TrendingUp className="h-4 w-4 text-primary" />
|
||||
评分变化趋势
|
||||
</CardTitle>
|
||||
<CardDescription>NTRP评分随时间的变化</CardDescription>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">评分维度</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{trendData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<AreaChart data={trendData}>
|
||||
<defs>
|
||||
<linearGradient id="ratingGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="oklch(0.55 0.16 145)" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="oklch(0.55 0.16 145)" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 11 }} />
|
||||
<YAxis domain={[1, 5]} tick={{ fontSize: 11 }} />
|
||||
<Tooltip />
|
||||
<Area type="monotone" dataKey="rating" stroke="oklch(0.55 0.16 145)" fill="url(#ratingGrad)" strokeWidth={2} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-[280px] flex items-center justify-center text-muted-foreground text-sm">
|
||||
<div className="text-center">
|
||||
<TrendingUp className="h-8 w-8 mx-auto mb-2 opacity-30" />
|
||||
<p>完成视频分析后将显示评分趋势</p>
|
||||
<CardContent className="space-y-3">
|
||||
{radarData.map((item) => (
|
||||
<div key={item.dimension}>
|
||||
<div className="mb-2 flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">{item.dimension}</span>
|
||||
<span className="font-medium">{Math.round(item.value)}</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-muted/70">
|
||||
<div className="h-full rounded-full bg-emerald-500" style={{ width: `${item.value}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Dimension details */}
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">评分维度说明</CardTitle>
|
||||
<CardDescription>NTRP评分由以下五个维度加权计算</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||
{[
|
||||
{ icon: Target, label: "姿势准确性", weight: "30%", desc: "关节角度与标准动作的匹配度", value: dimensions.poseAccuracy },
|
||||
{ icon: Zap, label: "击球一致性", weight: "25%", desc: "多次击球动作的稳定性", value: dimensions.strokeConsistency },
|
||||
{ icon: Footprints, label: "脚步移动", weight: "20%", desc: "步法灵活性和重心转移", value: dimensions.footwork },
|
||||
{ icon: Wind, label: "动作流畅性", weight: "15%", desc: "动作连贯性和平滑度", value: dimensions.fluidity },
|
||||
{ icon: Activity, label: "力量", weight: "10%", desc: "挥拍速度和爆发力", value: dimensions.power },
|
||||
].map(item => (
|
||||
<div key={item.label} className="p-4 rounded-xl border bg-card">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<item.icon className="h-4 w-4 text-primary" />
|
||||
<span className="text-sm font-medium">{item.label}</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold">{item.value ? Math.round(item.value) : "--"}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">权重 {item.weight}</p>
|
||||
<p className="text-xs text-muted-foreground">{item.desc}</p>
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<TrendingUp className="h-4 w-4 text-primary" />
|
||||
NTRP 趋势
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{trendData.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-border/60 px-4 py-14 text-center text-sm text-muted-foreground">
|
||||
暂无评分趋势数据。
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<AreaChart data={trendData}>
|
||||
<defs>
|
||||
<linearGradient id="rating-fill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#10b981" stopOpacity={0.26} />
|
||||
<stop offset="95%" stopColor="#10b981" stopOpacity={0.02} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 12 }} />
|
||||
<YAxis domain={[1, 5]} tick={{ fontSize: 12 }} />
|
||||
<Tooltip />
|
||||
<Area type="monotone" dataKey="rating" stroke="#10b981" strokeWidth={2} fill="url(#rating-fill)" />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Radar className="h-4 w-4 text-primary" />
|
||||
最新雷达图
|
||||
</CardTitle>
|
||||
<CardDescription>按最近一次 NTRP 快照展示维度得分。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<RadarChart data={radarData}>
|
||||
<PolarGrid />
|
||||
<PolarAngleAxis dataKey="dimension" tick={{ fontSize: 12 }} />
|
||||
<PolarRadiusAxis angle={90} domain={[0, 100]} />
|
||||
<RadarChartShape dataKey="value" stroke="#10b981" fill="#10b981" fillOpacity={0.25} />
|
||||
</RadarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* NTRP level reference */}
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">NTRP等级参考</CardTitle>
|
||||
<CardDescription>美国网球协会(USTA)标准评级体系</CardDescription>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">历史快照</CardTitle>
|
||||
<CardDescription>这里展示异步评分任务生成的最新记录。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{NTRP_LEVELS.map(l => (
|
||||
<div
|
||||
key={l.label}
|
||||
className={`flex items-center gap-3 p-3 rounded-lg transition-colors ${
|
||||
currentRating >= l.min && currentRating < l.max
|
||||
? "bg-primary/5 border border-primary/20"
|
||||
: "hover:bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
<Badge className={`${l.color} border shrink-0`}>
|
||||
{l.min.toFixed(1)}-{l.max.toFixed(1)}
|
||||
</Badge>
|
||||
<div>
|
||||
<span className="text-sm font-medium">{l.label}</span>
|
||||
<p className="text-xs text-muted-foreground">{l.desc}</p>
|
||||
<CardContent className="space-y-3">
|
||||
{history.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-border/60 px-4 py-10 text-center text-sm text-muted-foreground">
|
||||
暂无历史快照。
|
||||
</div>
|
||||
) : (
|
||||
history.map((item: any) => (
|
||||
<div key={item.id} className="rounded-2xl border border-border/60 bg-muted/20 p-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">NTRP {Number(item.rating || 0).toFixed(1)}</span>
|
||||
<Badge variant="outline">{item.triggerType}</Badge>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{formatDateTimeShanghai(item.createdAt)}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Activity className="h-4 w-4" />
|
||||
分析 {item.sourceSummary?.analyses || 0}
|
||||
</span>
|
||||
<span>实时 {item.sourceSummary?.liveSessions || 0}</span>
|
||||
<span>动作 {item.sourceSummary?.totalEffectiveActions || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
{currentRating >= l.min && currentRating < l.max && (
|
||||
<Badge variant="default" className="ml-auto shrink-0">当前等级</Badge>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useAuth } from "@/_core/hooks/useAuth";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { formatDateTimeShanghai } from "@/lib/time";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -458,7 +459,12 @@ export default function Reminders() {
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap ml-2">
|
||||
{new Date(notif.createdAt).toLocaleString("zh-CN", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" })}
|
||||
{formatDateTimeShanghai(notif.createdAt, {
|
||||
year: undefined,
|
||||
second: undefined,
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,320 +1,455 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useAuth } from "@/_core/hooks/useAuth";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
import { useState, useMemo } from "react";
|
||||
import {
|
||||
BookOpen, Play, CheckCircle2, Star, Target,
|
||||
ChevronRight, Filter, AlertTriangle, Lightbulb,
|
||||
ArrowUpDown, Clock, Dumbbell
|
||||
BookOpen,
|
||||
CheckCircle2,
|
||||
ChevronRight,
|
||||
Clock3,
|
||||
ExternalLink,
|
||||
Flame,
|
||||
Star,
|
||||
Target,
|
||||
Trophy,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
const CATEGORY_LABELS: Record<string, { label: string; icon: React.ReactNode; color: string }> = {
|
||||
forehand: { label: "正手", icon: <Target className="w-4 h-4" />, color: "bg-green-100 text-green-700" },
|
||||
backhand: { label: "反手", icon: <ArrowUpDown className="w-4 h-4" />, color: "bg-blue-100 text-blue-700" },
|
||||
serve: { label: "发球", icon: <Dumbbell className="w-4 h-4" />, color: "bg-purple-100 text-purple-700" },
|
||||
volley: { label: "截击", icon: <Target className="w-4 h-4" />, color: "bg-orange-100 text-orange-700" },
|
||||
footwork: { label: "脚步", icon: <Dumbbell className="w-4 h-4" />, color: "bg-yellow-100 text-yellow-700" },
|
||||
shadow: { label: "影子挥拍", icon: <Play className="w-4 h-4" />, color: "bg-indigo-100 text-indigo-700" },
|
||||
wall: { label: "墙壁练习", icon: <Target className="w-4 h-4" />, color: "bg-pink-100 text-pink-700" },
|
||||
fitness: { label: "体能", icon: <Dumbbell className="w-4 h-4" />, color: "bg-red-100 text-red-700" },
|
||||
strategy: { label: "战术", icon: <Lightbulb className="w-4 h-4" />, color: "bg-teal-100 text-teal-700" },
|
||||
type TutorialRecord = Record<string, any>;
|
||||
|
||||
const CATEGORY_META: Record<string, { label: string; icon: LucideIcon; tone: string }> = {
|
||||
forehand: { label: "正手", icon: Target, tone: "bg-green-500/10 text-green-700" },
|
||||
backhand: { label: "反手", icon: Target, tone: "bg-blue-500/10 text-blue-700" },
|
||||
serve: { label: "发球", icon: Target, tone: "bg-violet-500/10 text-violet-700" },
|
||||
volley: { label: "截击", icon: Target, tone: "bg-orange-500/10 text-orange-700" },
|
||||
footwork: { label: "脚步", icon: Flame, tone: "bg-yellow-500/10 text-yellow-700" },
|
||||
shadow: { label: "影子挥拍", icon: BookOpen, tone: "bg-indigo-500/10 text-indigo-700" },
|
||||
wall: { label: "墙壁练习", icon: Target, tone: "bg-pink-500/10 text-pink-700" },
|
||||
fitness: { label: "体能", icon: Flame, tone: "bg-rose-500/10 text-rose-700" },
|
||||
strategy: { label: "战术", icon: Trophy, tone: "bg-teal-500/10 text-teal-700" },
|
||||
};
|
||||
|
||||
const SKILL_LABELS: Record<string, { label: string; color: string }> = {
|
||||
beginner: { label: "初级", color: "bg-emerald-100 text-emerald-700" },
|
||||
intermediate: { label: "中级", color: "bg-amber-100 text-amber-700" },
|
||||
advanced: { label: "高级", color: "bg-rose-100 text-rose-700" },
|
||||
const SKILL_META: Record<string, { label: string; tone: string }> = {
|
||||
beginner: { label: "初级", tone: "bg-emerald-500/10 text-emerald-700" },
|
||||
intermediate: { label: "中级", tone: "bg-amber-500/10 text-amber-700" },
|
||||
advanced: { label: "高级", tone: "bg-rose-500/10 text-rose-700" },
|
||||
};
|
||||
|
||||
function parseStringArray(value: unknown) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter((item): item is string => typeof item === "string");
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === "string") : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function isTutorialCompleted(progress: TutorialRecord | undefined) {
|
||||
return progress?.completed === 1 || progress?.watched === 1;
|
||||
}
|
||||
|
||||
function formatEffortMinutes(tutorial: TutorialRecord) {
|
||||
const effort = tutorial.estimatedEffortMinutes || (tutorial.duration ? Math.round(tutorial.duration / 60) : 0);
|
||||
return effort > 0 ? `${effort} 分钟` : "按需学习";
|
||||
}
|
||||
|
||||
export default function Tutorials() {
|
||||
const { user } = useAuth();
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>("all");
|
||||
const [selectedSkill, setSelectedSkill] = useState<string>("all");
|
||||
const [selectedTutorial, setSelectedTutorial] = useState<number | null>(null);
|
||||
const [notes, setNotes] = useState("");
|
||||
const utils = trpc.useUtils();
|
||||
const [selectedCategory, setSelectedCategory] = useState("all");
|
||||
const [selectedSkill, setSelectedSkill] = useState("all");
|
||||
const [draftNotes, setDraftNotes] = useState<Record<number, string>>({});
|
||||
|
||||
const { data: tutorials, isLoading } = trpc.tutorial.list.useQuery({
|
||||
category: selectedCategory === "all" ? undefined : selectedCategory,
|
||||
skillLevel: selectedSkill === "all" ? undefined : selectedSkill,
|
||||
});
|
||||
|
||||
const { data: progressData } = trpc.tutorial.progress.useQuery(undefined, { enabled: !!user });
|
||||
const tutorialsQuery = trpc.tutorial.list.useQuery({ topicArea: "tennis_skill" });
|
||||
const progressQuery = trpc.tutorial.progress.useQuery(undefined, { enabled: !!user });
|
||||
|
||||
const updateProgress = trpc.tutorial.updateProgress.useMutation({
|
||||
onSuccess: () => toast.success("进度已更新"),
|
||||
onSuccess: async () => {
|
||||
await utils.tutorial.progress.invalidate();
|
||||
toast.success("教程进度已更新");
|
||||
},
|
||||
});
|
||||
|
||||
const tutorials = tutorialsQuery.data ?? [];
|
||||
const progressMap = useMemo(() => {
|
||||
const map: Record<number, any> = {};
|
||||
progressData?.forEach((p: any) => { map[p.tutorialId] = p; });
|
||||
const map: Record<number, TutorialRecord> = {};
|
||||
(progressQuery.data ?? []).forEach((item: TutorialRecord) => {
|
||||
map[item.tutorialId] = item;
|
||||
});
|
||||
return map;
|
||||
}, [progressData]);
|
||||
}, [progressQuery.data]);
|
||||
|
||||
const totalTutorials = tutorials?.length || 0;
|
||||
const watchedCount = tutorials?.filter((t: any) => progressMap[t.id]?.watched).length || 0;
|
||||
const progressPercent = totalTutorials > 0 ? Math.round((watchedCount / totalTutorials) * 100) : 0;
|
||||
const filteredTutorials = useMemo(
|
||||
() => tutorials.filter((tutorial) => {
|
||||
if (selectedCategory !== "all" && tutorial.category !== selectedCategory) return false;
|
||||
if (selectedSkill !== "all" && tutorial.skillLevel !== selectedSkill) return false;
|
||||
return true;
|
||||
}),
|
||||
[selectedCategory, selectedSkill, tutorials],
|
||||
);
|
||||
|
||||
const categories = useMemo(() => {
|
||||
const cats = new Set<string>();
|
||||
tutorials?.forEach((t: any) => cats.add(t.category));
|
||||
return Array.from(cats);
|
||||
}, [tutorials]);
|
||||
const categories = useMemo(
|
||||
() => Array.from(new Set(tutorials.map((tutorial) => tutorial.category).filter(Boolean))),
|
||||
[tutorials],
|
||||
);
|
||||
|
||||
const handleMarkWatched = (tutorialId: number) => {
|
||||
updateProgress.mutate({ tutorialId, watched: 1 });
|
||||
};
|
||||
const completedTutorials = useMemo(
|
||||
() => tutorials.filter((tutorial) => isTutorialCompleted(progressMap[tutorial.id])),
|
||||
[progressMap, tutorials],
|
||||
);
|
||||
|
||||
const handleSaveNotes = (tutorialId: number) => {
|
||||
const notes = draftNotes[tutorialId] ?? progressMap[tutorialId]?.notes ?? "";
|
||||
updateProgress.mutate({ tutorialId, notes });
|
||||
setNotes("");
|
||||
toast.success("笔记已保存");
|
||||
};
|
||||
|
||||
const handleComplete = (tutorialId: number) => {
|
||||
updateProgress.mutate({ tutorialId, completed: 1, watched: 1 });
|
||||
};
|
||||
|
||||
const handleSelfScore = (tutorialId: number, score: number) => {
|
||||
updateProgress.mutate({ tutorialId, selfScore: score });
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
if (tutorialsQuery.isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
<div className="flex min-h-[60vh] items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<BookOpen className="w-6 h-6 text-primary" />
|
||||
教程库
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">查看动作分解、要点说明和常见错误</p>
|
||||
</div>
|
||||
|
||||
{/* Progress Overview */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium">学习进度</span>
|
||||
<span className="text-sm text-muted-foreground">{watchedCount}/{totalTutorials} 已学习</span>
|
||||
<section className="overflow-hidden rounded-[30px] border border-border/60 bg-[radial-gradient(circle_at_top_left,_rgba(34,197,94,0.18),_transparent_24%),radial-gradient(circle_at_82%_18%,_rgba(59,130,246,0.14),_transparent_24%),linear-gradient(135deg,rgba(255,255,255,0.98),rgba(248,250,252,0.96))] p-5 shadow-sm md:p-7">
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.2fr)_360px]">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge className="bg-emerald-500/10 text-emerald-700">
|
||||
<BookOpen className="mr-1 h-3 w-3" />
|
||||
网球教程库
|
||||
</Badge>
|
||||
<Badge variant="outline">仅保留网球训练相关内容</Badge>
|
||||
</div>
|
||||
<h1 className="mt-4 text-3xl font-semibold tracking-tight">专注正手、反手、发球、脚步和比赛能力</h1>
|
||||
<p className="mt-3 max-w-3xl text-sm leading-7 text-muted-foreground">
|
||||
这里现在只保留和网球训练直接相关的教程。你可以按动作类别和水平筛选,记录自评与训练笔记,把教程真正沉淀到自己的日常练习里。
|
||||
</p>
|
||||
</div>
|
||||
<Progress value={progressPercent} className="h-2" />
|
||||
<p className="text-xs text-muted-foreground mt-1">{progressPercent}% 完成</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">分类:</span>
|
||||
<div className="grid gap-3 sm:grid-cols-3 xl:grid-cols-1">
|
||||
<Card className="border-0 bg-background/90 shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription>教程总数</CardDescription>
|
||||
<CardTitle className="text-3xl">{tutorials.length}</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card className="border-0 bg-background/90 shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription>已完成</CardDescription>
|
||||
<CardTitle className="text-3xl">{completedTutorials.length}</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card className="border-0 bg-background/90 shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription>当前筛选</CardDescription>
|
||||
<CardTitle className="text-base">
|
||||
{selectedCategory === "all" ? "全部分类" : (CATEGORY_META[selectedCategory] || { label: selectedCategory }).label}
|
||||
{" · "}
|
||||
{selectedSkill === "all" ? "全部级别" : (SKILL_META[selectedSkill] || { label: selectedSkill }).label}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<Button
|
||||
variant={selectedCategory === "all" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setSelectedCategory("all")}
|
||||
>
|
||||
全部
|
||||
</Button>
|
||||
{Object.entries(CATEGORY_LABELS).map(([key, { label, icon }]) => (
|
||||
</section>
|
||||
|
||||
<section className="space-y-5">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold tracking-tight">网球基础教程</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">选择一个动作主题,完成学习、自评和训练复盘。</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/60 bg-background/85 px-4 py-3 text-sm text-muted-foreground">
|
||||
已完成 {completedTutorials.length}/{tutorials.length}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
key={key}
|
||||
variant={selectedCategory === key ? "default" : "outline"}
|
||||
variant={selectedCategory === "all" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setSelectedCategory(key)}
|
||||
className="gap-1"
|
||||
onClick={() => setSelectedCategory("all")}
|
||||
>
|
||||
{icon} {label}
|
||||
全部分类
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{categories.map((category) => (
|
||||
<Button
|
||||
key={category}
|
||||
variant={selectedCategory === category ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setSelectedCategory(category)}
|
||||
>
|
||||
{(CATEGORY_META[category] || { label: category }).label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Star className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">级别:</span>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<Button
|
||||
variant={selectedSkill === "all" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setSelectedSkill("all")}
|
||||
>
|
||||
全部
|
||||
</Button>
|
||||
{Object.entries(SKILL_LABELS).map(([key, { label }]) => (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
key={key}
|
||||
variant={selectedSkill === key ? "default" : "outline"}
|
||||
variant={selectedSkill === "all" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setSelectedSkill(key)}
|
||||
onClick={() => setSelectedSkill("all")}
|
||||
>
|
||||
{label}
|
||||
全部级别
|
||||
</Button>
|
||||
))}
|
||||
{Object.entries(SKILL_META).map(([key, meta]) => (
|
||||
<Button
|
||||
key={key}
|
||||
variant={selectedSkill === key ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setSelectedSkill(key)}
|
||||
>
|
||||
{meta.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tutorial Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{tutorials?.map((tutorial: any) => {
|
||||
const cat = CATEGORY_LABELS[tutorial.category] || { label: tutorial.category, color: "bg-gray-100 text-gray-700" };
|
||||
const skill = SKILL_LABELS[tutorial.skillLevel] || { label: tutorial.skillLevel, color: "bg-gray-100 text-gray-700" };
|
||||
const progress = progressMap[tutorial.id];
|
||||
const isWatched = progress?.watched === 1;
|
||||
const keyPoints = typeof tutorial.keyPoints === "string" ? JSON.parse(tutorial.keyPoints) : tutorial.keyPoints || [];
|
||||
const mistakes = typeof tutorial.commonMistakes === "string" ? JSON.parse(tutorial.commonMistakes) : tutorial.commonMistakes || [];
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{filteredTutorials.map((tutorial) => {
|
||||
const progress = progressMap[tutorial.id];
|
||||
const completed = isTutorialCompleted(progress);
|
||||
const category = CATEGORY_META[tutorial.category || "forehand"] || CATEGORY_META.forehand;
|
||||
const skill = SKILL_META[tutorial.skillLevel || "beginner"] || SKILL_META.beginner;
|
||||
const keyPoints = parseStringArray(tutorial.keyPoints);
|
||||
const commonMistakes = parseStringArray(tutorial.commonMistakes);
|
||||
|
||||
return (
|
||||
<Dialog key={tutorial.id}>
|
||||
<DialogTrigger asChild>
|
||||
<Card className={`cursor-pointer hover:shadow-md transition-all ${isWatched ? "border-green-300 bg-green-50/30" : ""}`}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Badge variant="secondary" className={cat.color}>{cat.label}</Badge>
|
||||
<Badge variant="secondary" className={skill.color}>{skill.label}</Badge>
|
||||
return (
|
||||
<Dialog key={tutorial.id}>
|
||||
<Card className={cn(
|
||||
"overflow-hidden border-0 shadow-sm transition-shadow hover:shadow-md",
|
||||
completed && "ring-1 ring-emerald-200",
|
||||
)}>
|
||||
<div className="relative h-48 overflow-hidden bg-[radial-gradient(circle_at_top_left,_rgba(34,197,94,0.28),_transparent_30%),radial-gradient(circle_at_bottom_right,_rgba(59,130,246,0.18),_transparent_28%),linear-gradient(135deg,rgba(255,255,255,1),rgba(248,250,252,0.92))] px-5 py-4">
|
||||
{tutorial.thumbnailUrl ? (
|
||||
<>
|
||||
<img
|
||||
src={tutorial.thumbnailUrl}
|
||||
alt={`${tutorial.title} 标准配图`}
|
||||
loading="lazy"
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(15,23,42,0.18),rgba(15,23,42,0.58))]" />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div className="relative flex items-start justify-between gap-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge className={category.tone}>{category.label}</Badge>
|
||||
<Badge className={skill.tone}>{skill.label}</Badge>
|
||||
</div>
|
||||
{isWatched && <CheckCircle2 className="w-5 h-5 text-green-600 shrink-0" />}
|
||||
{completed ? <CheckCircle2 className="h-5 w-5 text-emerald-600" /> : null}
|
||||
</div>
|
||||
<CardTitle className="text-base mt-2">{tutorial.title}</CardTitle>
|
||||
<CardDescription className="line-clamp-2">{tutorial.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
|
||||
<div className="relative mt-6">
|
||||
<div className={cn("text-xl font-semibold", tutorial.thumbnailUrl && "text-white drop-shadow-sm")}>{tutorial.title}</div>
|
||||
<div className={cn(
|
||||
"mt-2 line-clamp-2 text-sm leading-6",
|
||||
tutorial.thumbnailUrl ? "text-white/88 drop-shadow-sm" : "text-muted-foreground",
|
||||
)}>
|
||||
{tutorial.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{Math.round((tutorial.duration || 0) / 60)}分钟
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
{keyPoints.length}个要点
|
||||
<ChevronRight className="w-3 h-3" />
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Clock3 className="h-3.5 w-3.5" />
|
||||
{formatEffortMinutes(tutorial)}
|
||||
</span>
|
||||
<span>{keyPoints.length} 个要点</span>
|
||||
</div>
|
||||
{progress?.selfScore && (
|
||||
<div className="flex items-center gap-1 mt-2">
|
||||
{[1, 2, 3, 4, 5].map(s => (
|
||||
<Star key={s} className={`w-3 h-3 ${s <= progress.selfScore ? "fill-yellow-400 text-yellow-400" : "text-gray-300"}`} />
|
||||
|
||||
{progress?.selfScore ? (
|
||||
<div className="mt-3 flex items-center gap-1">
|
||||
{[1, 2, 3, 4, 5].map((score) => (
|
||||
<Star
|
||||
key={score}
|
||||
className={cn(
|
||||
"h-4 w-4",
|
||||
score <= progress.selfScore ? "fill-yellow-400 text-yellow-400" : "text-slate-300",
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
<span className="text-xs text-muted-foreground ml-1">自评</span>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" className="mt-4 w-full">查看详情</Button>
|
||||
</DialogTrigger>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent className="max-w-2xl max-h-[85vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<Badge variant="secondary" className={cat.color}>{cat.label}</Badge>
|
||||
<Badge variant="secondary" className={skill.color}>{skill.label}</Badge>
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{Math.round((tutorial.duration || 0) / 60)}分钟
|
||||
</Badge>
|
||||
</div>
|
||||
<DialogTitle className="text-xl">{tutorial.title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="flex-1 pr-4">
|
||||
<div className="space-y-5">
|
||||
<p className="text-muted-foreground">{tutorial.description}</p>
|
||||
|
||||
{/* Key Points */}
|
||||
<div>
|
||||
<h3 className="font-semibold flex items-center gap-2 mb-3">
|
||||
<Lightbulb className="w-4 h-4 text-yellow-500" />
|
||||
技术要点
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{keyPoints.map((point: string, i: number) => (
|
||||
<div key={i} className="flex items-start gap-2 p-2 rounded-lg bg-green-50 border border-green-100">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-600 mt-0.5 shrink-0" />
|
||||
<span className="text-sm">{point}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<DialogContent className="max-h-[85vh] max-w-2xl overflow-hidden">
|
||||
<DialogHeader>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge className={category.tone}>{category.label}</Badge>
|
||||
<Badge className={skill.tone}>{skill.label}</Badge>
|
||||
</div>
|
||||
<DialogTitle className="text-xl">{tutorial.title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Common Mistakes */}
|
||||
<div>
|
||||
<h3 className="font-semibold flex items-center gap-2 mb-3">
|
||||
<AlertTriangle className="w-4 h-4 text-orange-500" />
|
||||
常见错误
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{mistakes.map((mistake: string, i: number) => (
|
||||
<div key={i} className="flex items-start gap-2 p-2 rounded-lg bg-orange-50 border border-orange-100">
|
||||
<AlertTriangle className="w-4 h-4 text-orange-500 mt-0.5 shrink-0" />
|
||||
<span className="text-sm">{mistake}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea className="max-h-[68vh] pr-4">
|
||||
<div className="space-y-5">
|
||||
{tutorial.thumbnailUrl ? (
|
||||
<div className="overflow-hidden rounded-[24px] border border-border/60 bg-muted/20">
|
||||
<img
|
||||
src={tutorial.thumbnailUrl}
|
||||
alt={`${tutorial.title} 标准配图`}
|
||||
loading="lazy"
|
||||
className="h-64 w-full object-cover sm:h-80"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<p className="text-sm leading-7 text-muted-foreground">{tutorial.description}</p>
|
||||
|
||||
{tutorial.externalUrl ? (
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-2xl border border-border/60 bg-muted/20 px-4 py-3 text-sm text-muted-foreground">
|
||||
<span>标准配图来源</span>
|
||||
<a
|
||||
href={tutorial.externalUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 font-medium text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
Wikimedia Commons
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Self Assessment */}
|
||||
{user && (
|
||||
<div>
|
||||
<h3 className="font-semibold mb-3">自我评估</h3>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-sm text-muted-foreground">掌握程度:</span>
|
||||
{[1, 2, 3, 4, 5].map(s => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => handleSelfScore(tutorial.id, s)}
|
||||
className="hover:scale-110 transition-transform"
|
||||
>
|
||||
<Star className={`w-6 h-6 ${s <= (progress?.selfScore || 0) ? "fill-yellow-400 text-yellow-400" : "text-gray-300 hover:text-yellow-300"}`} />
|
||||
</button>
|
||||
<h4 className="text-sm font-semibold uppercase tracking-[0.24em] text-muted-foreground">技术要点</h4>
|
||||
<div className="mt-3 space-y-2">
|
||||
{keyPoints.map((item) => (
|
||||
<div key={item} className="rounded-2xl border border-emerald-200 bg-emerald-50/70 px-4 py-3 text-sm">
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
placeholder="记录学习笔记和心得..."
|
||||
value={notes || progress?.notes || ""}
|
||||
onChange={e => setNotes(e.target.value)}
|
||||
className="mb-2"
|
||||
rows={3}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={() => handleSaveNotes(tutorial.id)}>
|
||||
保存笔记
|
||||
</Button>
|
||||
{!isWatched && (
|
||||
<Button size="sm" variant="outline" onClick={() => handleMarkWatched(tutorial.id)} className="gap-1">
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
标记已学习
|
||||
</Button>
|
||||
)}
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold uppercase tracking-[0.24em] text-muted-foreground">常见错误</h4>
|
||||
<div className="mt-3 space-y-2">
|
||||
{commonMistakes.map((item) => (
|
||||
<div key={item} className="rounded-2xl border border-amber-200 bg-amber-50/70 px-4 py-3 text-sm">
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{tutorials?.length === 0 && (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<BookOpen className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p>暂无匹配的教程</p>
|
||||
{user ? (
|
||||
<div className="rounded-[24px] border border-border/60 bg-muted/20 p-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-base font-semibold">自我评估与训练笔记</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">学完后给自己打分,并记录本次训练最需要修正的点。</div>
|
||||
</div>
|
||||
{!completed ? (
|
||||
<Button size="sm" onClick={() => handleComplete(tutorial.id)}>
|
||||
标记已学习
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Badge className="bg-emerald-500/10 text-emerald-700">
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
已完成
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<div className="mb-2 text-sm font-medium">掌握程度</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{[1, 2, 3, 4, 5].map((score) => (
|
||||
<button
|
||||
key={score}
|
||||
onClick={() => handleSelfScore(tutorial.id, score)}
|
||||
className="transition-transform hover:scale-110"
|
||||
>
|
||||
<Star className={cn(
|
||||
"h-6 w-6",
|
||||
score <= (progress?.selfScore || 0) ? "fill-yellow-400 text-yellow-400" : "text-slate-300",
|
||||
)} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<div className="mb-2 text-sm font-medium">学习笔记</div>
|
||||
<Textarea
|
||||
rows={4}
|
||||
placeholder="记录今天的挥拍体感、移动节奏、失误原因和下次训练目标。"
|
||||
value={draftNotes[tutorial.id] ?? progress?.notes ?? ""}
|
||||
onChange={(event) => setDraftNotes((current) => ({
|
||||
...current,
|
||||
[tutorial.id]: event.target.value,
|
||||
}))}
|
||||
/>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Button size="sm" onClick={() => handleSaveNotes(tutorial.id)}>保存笔记</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{keyPoints.length > 0 ? (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold uppercase tracking-[0.24em] text-muted-foreground">训练建议</h4>
|
||||
<div className="mt-3 space-y-2">
|
||||
{keyPoints.slice(0, 3).map((item) => (
|
||||
<div key={item} className="flex items-start gap-2 rounded-2xl bg-muted/20 px-4 py-3 text-sm">
|
||||
<ChevronRight className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
|
||||
<span>下次练习时优先检查:{item}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredTutorials.length === 0 ? (
|
||||
<div className="rounded-[26px] border border-dashed border-border/60 px-6 py-14 text-center text-muted-foreground">
|
||||
当前筛选下暂无匹配教程。
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,58 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useAuth } from "@/_core/hooks/useAuth";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Video, Play, BarChart3, Clock, Zap, ChevronRight, FileVideo } from "lucide-react";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { formatDateShanghai, formatDateTimeShanghai } from "@/lib/time";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
BarChart3,
|
||||
Clock,
|
||||
Copy,
|
||||
Download,
|
||||
FileVideo,
|
||||
Pencil,
|
||||
Play,
|
||||
PlayCircle,
|
||||
Plus,
|
||||
Scissors,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
Video,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { useLocation } from "wouter";
|
||||
|
||||
type ClipDraft = {
|
||||
id: string;
|
||||
startSec: number;
|
||||
endSec: number;
|
||||
label: string;
|
||||
notes: string;
|
||||
source: "manual" | "suggested";
|
||||
};
|
||||
|
||||
type VideoCreateDraft = {
|
||||
title: string;
|
||||
url: string;
|
||||
format: string;
|
||||
exerciseType: string;
|
||||
fileSizeMb: string;
|
||||
durationSec: string;
|
||||
};
|
||||
|
||||
type VideoEditDraft = {
|
||||
videoId: number | null;
|
||||
title: string;
|
||||
exerciseType: string;
|
||||
};
|
||||
|
||||
const statusMap: Record<string, { label: string; color: string }> = {
|
||||
pending: { label: "待分析", color: "bg-yellow-100 text-yellow-700" },
|
||||
analyzing: { label: "分析中", color: "bg-blue-100 text-blue-700" },
|
||||
@@ -22,52 +68,346 @@ const exerciseTypeMap: Record<string, string> = {
|
||||
footwork: "脚步移动",
|
||||
shadow: "影子挥拍",
|
||||
wall: "墙壁练习",
|
||||
recording: "录制归档",
|
||||
live_analysis: "实时分析",
|
||||
};
|
||||
|
||||
function formatSeconds(totalSeconds: number) {
|
||||
const seconds = Math.max(0, Math.floor(totalSeconds));
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const rest = seconds % 60;
|
||||
return `${minutes.toString().padStart(2, "0")}:${rest.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function localStorageKey(videoId: number) {
|
||||
return `clip-plan:${videoId}`;
|
||||
}
|
||||
|
||||
function resolveTimelineDurationSec(analysis: any, durationSec: number) {
|
||||
if (durationSec > 0) return durationSec;
|
||||
if (typeof analysis?.durationSec === "number" && analysis.durationSec > 0) return analysis.durationSec;
|
||||
if (typeof analysis?.durationMs === "number" && analysis.durationMs > 0) return analysis.durationMs / 1000;
|
||||
if (typeof analysis?.framesAnalyzed === "number" && analysis.framesAnalyzed > 0) {
|
||||
return Math.max(5, Math.round((analysis.framesAnalyzed / 30) * 10) / 10);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function buildSuggestedClips(analysis: any, durationSec: number) {
|
||||
const timelineDurationSec = resolveTimelineDurationSec(analysis, durationSec);
|
||||
if (!analysis?.keyMoments || !Array.isArray(analysis.keyMoments) || timelineDurationSec <= 0) {
|
||||
return [] as ClipDraft[];
|
||||
}
|
||||
|
||||
const framesAnalyzed = Math.max(analysis.framesAnalyzed || 0, 1);
|
||||
return analysis.keyMoments.slice(0, 6).map((moment: any, index: number) => {
|
||||
const centerSec = clamp(((moment.frame || 0) / framesAnalyzed) * timelineDurationSec, 0, timelineDurationSec);
|
||||
const startSec = clamp(centerSec - 1.5, 0, Math.max(0, timelineDurationSec - 0.5));
|
||||
const endSec = clamp(centerSec + 2.5, startSec + 0.5, timelineDurationSec);
|
||||
return {
|
||||
id: `suggested-${index}-${moment.frame || index}`,
|
||||
startSec,
|
||||
endSec,
|
||||
label: moment.description || `建议片段 ${index + 1}`,
|
||||
notes: moment.type ? `来源于分析事件:${moment.type}` : "来源于分析关键时刻",
|
||||
source: "suggested" as const,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function downloadJson(filename: string, data: unknown) {
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function downloadText(filename: string, data: string) {
|
||||
const blob = new Blob([data], { type: "text/plain;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function buildClipCueSheet(title: string, clips: ClipDraft[]) {
|
||||
return clips.map((clip, index) => (
|
||||
`${index + 1}. ${clip.label}\n` +
|
||||
` 区间: ${formatSeconds(clip.startSec)} - ${formatSeconds(clip.endSec)}\n` +
|
||||
` 时长: ${formatSeconds(Math.max(0, clip.endSec - clip.startSec))}\n` +
|
||||
` 来源: ${clip.source === "manual" ? "手动" : "分析建议"}\n` +
|
||||
` 备注: ${clip.notes || "无"}`
|
||||
)).join("\n\n") + `\n\n视频: ${title}\n导出时间: ${formatDateTimeShanghai(new Date())}\n`;
|
||||
}
|
||||
|
||||
function createEmptyVideoDraft(): VideoCreateDraft {
|
||||
return {
|
||||
title: "",
|
||||
url: "",
|
||||
format: "mp4",
|
||||
exerciseType: "recording",
|
||||
fileSizeMb: "",
|
||||
durationSec: "",
|
||||
};
|
||||
}
|
||||
|
||||
export default function Videos() {
|
||||
const { user } = useAuth();
|
||||
useAuth();
|
||||
const utils = trpc.useUtils();
|
||||
const { data: videos, isLoading } = trpc.video.list.useQuery();
|
||||
const { data: analyses } = trpc.analysis.list.useQuery();
|
||||
const [, setLocation] = useLocation();
|
||||
const registerExternalMutation = trpc.video.registerExternal.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.video.list.invalidate();
|
||||
toast.success("视频记录已新增");
|
||||
},
|
||||
onError: (error) => toast.error(`新增失败: ${error.message}`),
|
||||
});
|
||||
const updateVideoMutation = trpc.video.update.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.video.list.invalidate();
|
||||
toast.success("视频信息已更新");
|
||||
},
|
||||
onError: (error) => toast.error(`更新失败: ${error.message}`),
|
||||
});
|
||||
const deleteVideoMutation = trpc.video.delete.useMutation({
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
utils.video.list.invalidate(),
|
||||
utils.analysis.list.invalidate(),
|
||||
]);
|
||||
toast.success("视频记录已删除");
|
||||
},
|
||||
onError: (error) => toast.error(`删除失败: ${error.message}`),
|
||||
});
|
||||
|
||||
const getAnalysis = (videoId: number) => {
|
||||
return analyses?.find((a: any) => a.videoId === videoId);
|
||||
};
|
||||
const previewRef = useRef<HTMLVideoElement>(null);
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [selectedVideo, setSelectedVideo] = useState<any | null>(null);
|
||||
const [videoDurationSec, setVideoDurationSec] = useState(0);
|
||||
const [playbackSec, setPlaybackSec] = useState(0);
|
||||
const [clipRange, setClipRange] = useState<[number, number]>([0, 5]);
|
||||
const [clipLabel, setClipLabel] = useState("");
|
||||
const [clipNotes, setClipNotes] = useState("");
|
||||
const [clipDrafts, setClipDrafts] = useState<ClipDraft[]>([]);
|
||||
const [activePreviewRange, setActivePreviewRange] = useState<[number, number] | null>(null);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [createDraft, setCreateDraft] = useState<VideoCreateDraft>(() => createEmptyVideoDraft());
|
||||
const [editDraft, setEditDraft] = useState<VideoEditDraft>({ videoId: null, title: "", exerciseType: "" });
|
||||
|
||||
const getAnalysis = useCallback((videoId: number) => {
|
||||
return analyses?.find((analysis: any) => analysis.videoId === videoId);
|
||||
}, [analyses]);
|
||||
|
||||
const activeAnalysis = selectedVideo ? getAnalysis(selectedVideo.id) : null;
|
||||
const timelineDurationSec = useMemo(
|
||||
() => resolveTimelineDurationSec(activeAnalysis, videoDurationSec),
|
||||
[activeAnalysis, videoDurationSec],
|
||||
);
|
||||
const suggestedClips = useMemo(
|
||||
() => buildSuggestedClips(activeAnalysis, timelineDurationSec),
|
||||
[activeAnalysis, timelineDurationSec],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editorOpen || timelineDurationSec <= 0) return;
|
||||
setClipRange((current) => {
|
||||
const start = clamp(current[0] ?? 0, 0, Math.max(0, timelineDurationSec - 0.5));
|
||||
const minEnd = clamp(start + 0.5, 0.5, timelineDurationSec);
|
||||
const end = clamp(current[1] ?? Math.min(timelineDurationSec, 5), minEnd, timelineDurationSec);
|
||||
if (start === current[0] && end === current[1]) {
|
||||
return current;
|
||||
}
|
||||
return [start, end];
|
||||
});
|
||||
}, [editorOpen, timelineDurationSec]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedVideo) return;
|
||||
try {
|
||||
const saved = localStorage.getItem(localStorageKey(selectedVideo.id));
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved) as ClipDraft[];
|
||||
setClipDrafts(parsed);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Ignore corrupted local clip drafts and fall back to suggested clips.
|
||||
}
|
||||
setClipDrafts(suggestedClips);
|
||||
}, [selectedVideo, suggestedClips]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedVideo) return;
|
||||
localStorage.setItem(localStorageKey(selectedVideo.id), JSON.stringify(clipDrafts));
|
||||
}, [clipDrafts, selectedVideo]);
|
||||
|
||||
useEffect(() => {
|
||||
const video = previewRef.current;
|
||||
if (!video || !activePreviewRange) return;
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
if (video.currentTime >= activePreviewRange[1]) {
|
||||
video.currentTime = activePreviewRange[0];
|
||||
}
|
||||
};
|
||||
|
||||
video.addEventListener("timeupdate", handleTimeUpdate);
|
||||
return () => {
|
||||
video.removeEventListener("timeupdate", handleTimeUpdate);
|
||||
};
|
||||
}, [activePreviewRange]);
|
||||
|
||||
const openEditor = useCallback((video: any) => {
|
||||
setSelectedVideo(video);
|
||||
setEditorOpen(true);
|
||||
setVideoDurationSec(0);
|
||||
setPlaybackSec(0);
|
||||
setClipLabel("");
|
||||
setClipNotes("");
|
||||
setClipRange([0, 5]);
|
||||
setActivePreviewRange(null);
|
||||
}, []);
|
||||
|
||||
const openEditDialog = useCallback((video: any) => {
|
||||
setEditDraft({
|
||||
videoId: video.id,
|
||||
title: video.title || "",
|
||||
exerciseType: video.exerciseType || "",
|
||||
});
|
||||
setEditOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleCreateVideo = useCallback(async () => {
|
||||
if (!createDraft.title.trim() || !createDraft.url.trim() || !createDraft.format.trim()) {
|
||||
toast.error("请填写标题、视频地址和格式");
|
||||
return;
|
||||
}
|
||||
|
||||
const fileKey = `external/manual/${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${createDraft.format}`;
|
||||
await registerExternalMutation.mutateAsync({
|
||||
title: createDraft.title.trim(),
|
||||
url: createDraft.url.trim(),
|
||||
fileKey,
|
||||
format: createDraft.format.trim(),
|
||||
fileSize: createDraft.fileSizeMb.trim() ? Math.round(Number(createDraft.fileSizeMb) * 1024 * 1024) : undefined,
|
||||
duration: createDraft.durationSec.trim() ? Number(createDraft.durationSec) : undefined,
|
||||
exerciseType: createDraft.exerciseType.trim() || undefined,
|
||||
});
|
||||
setCreateOpen(false);
|
||||
setCreateDraft(createEmptyVideoDraft());
|
||||
}, [createDraft, registerExternalMutation]);
|
||||
|
||||
const handleUpdateVideo = useCallback(async () => {
|
||||
if (!editDraft.videoId || !editDraft.title.trim()) {
|
||||
toast.error("请填写视频标题");
|
||||
return;
|
||||
}
|
||||
|
||||
await updateVideoMutation.mutateAsync({
|
||||
videoId: editDraft.videoId,
|
||||
title: editDraft.title.trim(),
|
||||
exerciseType: editDraft.exerciseType.trim() || undefined,
|
||||
});
|
||||
setEditOpen(false);
|
||||
setEditDraft({ videoId: null, title: "", exerciseType: "" });
|
||||
}, [editDraft, updateVideoMutation]);
|
||||
|
||||
const handleDeleteVideo = useCallback(async (video: any) => {
|
||||
if (!window.confirm(`确认删除视频“${video.title}”?该视频的分析结果和视频索引会一并移除。`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await deleteVideoMutation.mutateAsync({ videoId: video.id });
|
||||
|
||||
if (selectedVideo?.id === video.id) {
|
||||
setEditorOpen(false);
|
||||
setSelectedVideo(null);
|
||||
}
|
||||
}, [deleteVideoMutation, selectedVideo]);
|
||||
|
||||
const addClip = useCallback((source: "manual" | "suggested", preset?: ClipDraft) => {
|
||||
const nextStart = preset?.startSec ?? clipRange[0];
|
||||
const nextEnd = preset?.endSec ?? clipRange[1];
|
||||
const clip: ClipDraft = {
|
||||
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
startSec: nextStart,
|
||||
endSec: nextEnd,
|
||||
label: preset?.label || clipLabel || `片段 ${clipDrafts.length + 1}`,
|
||||
notes: preset?.notes || clipNotes,
|
||||
source,
|
||||
};
|
||||
|
||||
setClipDrafts((current) => [...current, clip].sort((a, b) => a.startSec - b.startSec));
|
||||
setClipLabel("");
|
||||
setClipNotes("");
|
||||
toast.success("片段已加入轻剪辑草稿");
|
||||
}, [clipDrafts.length, clipLabel, clipNotes, clipRange]);
|
||||
|
||||
const totalClipDurationSec = useMemo(
|
||||
() => clipDrafts.reduce((sum, clip) => sum + Math.max(0, clip.endSec - clip.startSec), 0),
|
||||
[clipDrafts],
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-20 w-full" />
|
||||
{[1, 2, 3].map(i => <Skeleton key={i} className="h-32 w-full" />)}
|
||||
{[1, 2, 3].map((index) => <Skeleton key={index} className="h-32 w-full" />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight" data-testid="videos-title">训练视频库</h1>
|
||||
<p className="text-muted-foreground text-sm mt-1">
|
||||
管理您的所有训练视频及分析结果 · 共 {videos?.length || 0} 个视频
|
||||
</p>
|
||||
<section className="rounded-[28px] border border-border/60 bg-[radial-gradient(circle_at_top_left,_rgba(14,165,233,0.12),_transparent_28%),linear-gradient(180deg,rgba(255,255,255,1),rgba(248,250,252,0.96))] p-5 shadow-sm md:p-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight" data-testid="videos-title">训练视频库</h1>
|
||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground">
|
||||
集中管理录制归档、上传分析和实时分析视频。桌面端已提供轻剪辑工作台,可按建议片段或手动入点/出点生成剪辑草稿。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" onClick={() => setCreateOpen(true)} className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
新增视频记录
|
||||
</Button>
|
||||
<Button data-testid="videos-upload-button" onClick={() => setLocation("/analysis")} className="gap-2">
|
||||
<Video className="h-4 w-4" />
|
||||
上传新视频
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Button data-testid="videos-upload-button" onClick={() => setLocation("/analysis")} className="gap-2">
|
||||
<Video className="h-4 w-4" />
|
||||
上传新视频
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{(!videos || videos.length === 0) ? (
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardContent className="py-16 text-center">
|
||||
<FileVideo className="h-12 w-12 mx-auto mb-4 text-muted-foreground/30" />
|
||||
<h3 className="font-semibold text-lg mb-2">还没有训练视频</h3>
|
||||
<p className="text-muted-foreground text-sm mb-4">上传您的训练视频,AI将自动分析姿势并给出建议</p>
|
||||
<Button onClick={() => setLocation("/analysis")} className="gap-2">
|
||||
<Video className="h-4 w-4" />
|
||||
上传第一个视频
|
||||
</Button>
|
||||
<FileVideo className="mx-auto mb-4 h-12 w-12 text-muted-foreground/30" />
|
||||
<h3 className="mb-2 text-lg font-semibold">还没有训练视频</h3>
|
||||
<p className="mb-4 text-sm text-muted-foreground">上传训练视频后,这里会自动汇总分析结果,并提供轻剪辑入口。</p>
|
||||
<div className="flex justify-center gap-2">
|
||||
<Button variant="outline" onClick={() => setCreateOpen(true)} className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
新增视频记录
|
||||
</Button>
|
||||
<Button onClick={() => setLocation("/analysis")} className="gap-2">
|
||||
<Video className="h-4 w-4" />
|
||||
上传第一个视频
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
@@ -77,11 +417,10 @@ export default function Videos() {
|
||||
const status = statusMap[video.analysisStatus] || statusMap.pending;
|
||||
|
||||
return (
|
||||
<Card key={video.id} className="border-0 shadow-sm hover:shadow-md transition-shadow" data-testid="video-card">
|
||||
<Card key={video.id} className="border-0 shadow-sm transition-shadow hover:shadow-md" data-testid="video-card">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Thumbnail / icon */}
|
||||
<div className="h-20 w-28 rounded-lg bg-black/5 flex items-center justify-center shrink-0 overflow-hidden">
|
||||
<div className="flex h-20 w-28 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-black/5">
|
||||
{video.url ? (
|
||||
<video src={video.url} className="h-full w-full object-cover" muted preload="metadata" />
|
||||
) : (
|
||||
@@ -89,54 +428,78 @@ export default function Videos() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<h3 className="font-medium text-sm truncate">{video.title}</h3>
|
||||
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
||||
<h3 className="truncate text-sm font-medium">{video.title}</h3>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||||
<Badge className={`${status.color} border text-xs`}>{status.label}</Badge>
|
||||
{video.exerciseType && (
|
||||
{video.exerciseType ? (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{exerciseTypeMap[video.exerciseType] || video.exerciseType}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
) : null}
|
||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Clock className="h-3 w-3" />
|
||||
{new Date(video.createdAt).toLocaleDateString("zh-CN")}
|
||||
{formatDateShanghai(video.createdAt)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{(video.fileSize / 1024 / 1024).toFixed(1)}MB
|
||||
{((video.fileSize || 0) / 1024 / 1024).toFixed(1)}MB
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{video.url ? (
|
||||
<Button variant="outline" size="sm" className="gap-2" onClick={() => window.open(video.url, "_blank", "noopener,noreferrer")}>
|
||||
<PlayCircle className="h-4 w-4" />
|
||||
播放
|
||||
</Button>
|
||||
) : null}
|
||||
<Button variant="outline" size="sm" className="gap-2" onClick={() => openEditDialog(video)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
编辑
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="gap-2" onClick={() => openEditor(video)}>
|
||||
<Scissors className="h-4 w-4" />
|
||||
轻剪辑
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2 text-red-600 hover:text-red-700"
|
||||
onClick={() => void handleDeleteVideo(video)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Analysis summary */}
|
||||
{analysis && (
|
||||
<div className="flex items-center gap-4 mt-3 text-xs">
|
||||
{analysis ? (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-4 text-xs">
|
||||
<div className="flex items-center gap-1">
|
||||
<BarChart3 className="h-3 w-3 text-primary" />
|
||||
<span className="font-medium">{Math.round(analysis.overallScore || 0)}分</span>
|
||||
</div>
|
||||
{(analysis.shotCount ?? 0) > 0 && (
|
||||
{(analysis.shotCount ?? 0) > 0 ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Zap className="h-3 w-3 text-orange-500" />
|
||||
<span>{analysis.shotCount}次击球</span>
|
||||
<span>{analysis.shotCount} 次击球</span>
|
||||
</div>
|
||||
)}
|
||||
{(analysis.avgSwingSpeed ?? 0) > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
速度 {(analysis.avgSwingSpeed ?? 0).toFixed(1)}
|
||||
</div>
|
||||
)}
|
||||
{(analysis.strokeConsistency ?? 0) > 0 && (
|
||||
<div className="flex items-center gap-1 text-muted-foreground">
|
||||
) : null}
|
||||
{(analysis.strokeConsistency ?? 0) > 0 ? (
|
||||
<div className="text-muted-foreground">
|
||||
一致性 {Math.round(analysis.strokeConsistency ?? 0)}%
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
{Array.isArray(analysis.keyMoments) && analysis.keyMoments.length > 0 ? (
|
||||
<Badge variant="outline" className="gap-1 text-xs">
|
||||
<Sparkles className="h-3 w-3" />
|
||||
{analysis.keyMoments.length} 个建议片段
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -145,6 +508,406 @@ export default function Videos() {
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={editorOpen} onOpenChange={setEditorOpen}>
|
||||
<DialogContent className="max-h-[92vh] max-w-5xl overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Scissors className="h-5 w-5 text-primary" />
|
||||
PC 轻剪辑工作台
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
支持手动设置入点/出点、按分析关键时刻生成建议片段,并把剪辑草稿导出为 JSON。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{selectedVideo ? (
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.35fr)_minmax(320px,0.9fr)]">
|
||||
<section className="space-y-4">
|
||||
<div className="overflow-hidden rounded-3xl border border-border/60 bg-black">
|
||||
<video
|
||||
ref={previewRef}
|
||||
src={selectedVideo.url}
|
||||
className="aspect-video w-full object-contain"
|
||||
controls
|
||||
playsInline
|
||||
onLoadedMetadata={(event) => {
|
||||
const duration = event.currentTarget.duration || 0;
|
||||
setVideoDurationSec(duration);
|
||||
setClipRange([0, Math.min(duration, 5)]);
|
||||
}}
|
||||
onTimeUpdate={(event) => setPlaybackSec(event.currentTarget.currentTime || 0)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">片段设置</CardTitle>
|
||||
<CardDescription>建议先在播放器中定位,再设置入点和出点。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-3 md:grid-cols-4">
|
||||
<div className="rounded-2xl border border-border/60 bg-muted/20 p-4">
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">草稿片段</div>
|
||||
<div className="mt-2 text-lg font-semibold">{clipDrafts.length}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/60 bg-muted/20 p-4">
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">总剪辑时长</div>
|
||||
<div className="mt-2 text-lg font-semibold">{formatSeconds(totalClipDurationSec)}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/60 bg-muted/20 p-4">
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">建议片段</div>
|
||||
<div className="mt-2 text-lg font-semibold">{suggestedClips.length}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/60 bg-muted/20 p-4">
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">当前区间时长</div>
|
||||
<div className="mt-2 text-lg font-semibold">{formatSeconds(Math.max(0, clipRange[1] - clipRange[0]))}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<div className="rounded-2xl border border-border/60 bg-muted/20 p-4">
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">当前播放</div>
|
||||
<div className="mt-2 text-lg font-semibold">{formatSeconds(playbackSec)}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/60 bg-muted/20 p-4">
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">入点</div>
|
||||
<div className="mt-2 text-lg font-semibold">{formatSeconds(clipRange[0])}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/60 bg-muted/20 p-4">
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">出点</div>
|
||||
<div className="mt-2 text-lg font-semibold">{formatSeconds(clipRange[1])}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{timelineDurationSec > 0 ? (
|
||||
<Slider
|
||||
value={clipRange}
|
||||
min={0}
|
||||
max={timelineDurationSec}
|
||||
step={0.1}
|
||||
onValueChange={(value) => {
|
||||
if (value.length === 2) {
|
||||
setClipRange([value[0] || 0, value[1] || Math.max(0.5, timelineDurationSec)]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setClipRange(([_, end]) => [clamp(playbackSec, 0, Math.max(0, end - 0.5)), end])}
|
||||
>
|
||||
设为入点
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setClipRange(([start]) => [start, clamp(playbackSec, start + 0.5, timelineDurationSec || playbackSec + 0.5)])}
|
||||
>
|
||||
设为出点
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (previewRef.current) previewRef.current.currentTime = clipRange[0];
|
||||
}}
|
||||
>
|
||||
跳到入点
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
if (!previewRef.current) return;
|
||||
setActivePreviewRange([clipRange[0], clipRange[1]]);
|
||||
previewRef.current.currentTime = clipRange[0];
|
||||
await previewRef.current.play().catch(() => undefined);
|
||||
}}
|
||||
>
|
||||
循环预览当前区间
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setActivePreviewRange(null);
|
||||
previewRef.current?.pause();
|
||||
}}
|
||||
>
|
||||
停止循环
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<Input
|
||||
value={clipLabel}
|
||||
onChange={(event) => setClipLabel(event.target.value)}
|
||||
placeholder="片段名称,例如:正手节奏稳定段"
|
||||
className="h-11 rounded-2xl"
|
||||
/>
|
||||
<Button onClick={() => addClip("manual")} className="h-11 rounded-2xl gap-2">
|
||||
<Scissors className="h-4 w-4" />
|
||||
加入剪辑草稿
|
||||
</Button>
|
||||
</div>
|
||||
<Textarea
|
||||
value={clipNotes}
|
||||
onChange={(event) => setClipNotes(event.target.value)}
|
||||
placeholder="记录这个片段为什么要保留,或后续想怎么讲解"
|
||||
className="min-h-24 rounded-2xl"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<aside className="space-y-4">
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">建议片段</CardTitle>
|
||||
<CardDescription>来自视频分析关键时刻,可一键加入剪辑草稿。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{suggestedClips.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-border/60 px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
当前视频暂无自动建议片段。
|
||||
</div>
|
||||
) : (
|
||||
suggestedClips.map((clip: ClipDraft) => (
|
||||
<div key={clip.id} className="rounded-2xl border border-border/60 bg-muted/20 p-4">
|
||||
<div className="font-medium">{clip.label}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{formatSeconds(clip.startSec)} - {formatSeconds(clip.endSec)}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-muted-foreground">{clip.notes}</div>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setClipRange([clip.startSec, clip.endSec]);
|
||||
if (previewRef.current) previewRef.current.currentTime = clip.startSec;
|
||||
}}
|
||||
>
|
||||
载入区间
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
setActivePreviewRange([clip.startSec, clip.endSec]);
|
||||
if (previewRef.current) {
|
||||
previewRef.current.currentTime = clip.startSec;
|
||||
await previewRef.current.play().catch(() => undefined);
|
||||
}
|
||||
}}
|
||||
>
|
||||
循环预览
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => addClip("suggested", clip)}>加入草稿</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">剪辑草稿</CardTitle>
|
||||
<CardDescription>草稿保存在浏览器本地,可随时导出给后续后台剪辑任务使用。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{clipDrafts.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-border/60 px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
还没有片段草稿。
|
||||
</div>
|
||||
) : (
|
||||
clipDrafts.map((clip: ClipDraft) => (
|
||||
<div key={clip.id} className="rounded-2xl border border-border/60 bg-muted/20 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{clip.label}</span>
|
||||
<Badge variant="outline">{clip.source === "manual" ? "手动" : "建议"}</Badge>
|
||||
<Badge variant="secondary">{formatSeconds(Math.max(0, clip.endSec - clip.startSec))}</Badge>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{formatSeconds(clip.startSec)} - {formatSeconds(clip.endSec)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
setClipRange([clip.startSec, clip.endSec]);
|
||||
setClipLabel(clip.label);
|
||||
setClipNotes(clip.notes);
|
||||
if (previewRef.current) {
|
||||
previewRef.current.currentTime = clip.startSec;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
const value = `${clip.label} ${formatSeconds(clip.startSec)}-${formatSeconds(clip.endSec)} ${clip.notes || ""}`.trim();
|
||||
if (!navigator.clipboard) {
|
||||
toast.error("当前浏览器不支持剪贴板复制");
|
||||
return;
|
||||
}
|
||||
void navigator.clipboard.writeText(value).then(
|
||||
() => toast.success("片段信息已复制"),
|
||||
() => toast.error("片段复制失败"),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setClipDrafts((current) => current.filter((item) => item.id !== clip.id))}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{clip.notes ? <div className="mt-2 text-sm text-muted-foreground">{clip.notes}</div> : null}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</aside>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<DialogFooter className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (!selectedVideo) return;
|
||||
const payload = {
|
||||
videoId: selectedVideo.id,
|
||||
title: selectedVideo.title,
|
||||
url: selectedVideo.url,
|
||||
clipDrafts,
|
||||
exportedAt: new Date().toISOString(),
|
||||
};
|
||||
downloadJson(`${selectedVideo.title}-clip-plan.json`, payload);
|
||||
}}
|
||||
className="gap-2"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
导出草稿
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (!selectedVideo) return;
|
||||
downloadText(`${selectedVideo.title}-clip-cuesheet.txt`, buildClipCueSheet(selectedVideo.title, clipDrafts));
|
||||
}}
|
||||
className="gap-2"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
导出清单
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setEditorOpen(false)}>关闭</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogContent className="max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>新增视频记录</DialogTitle>
|
||||
<DialogDescription>
|
||||
可录入已有外部视频地址或历史归档链接,纳入当前视频库统一管理。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
value={createDraft.title}
|
||||
onChange={(event) => setCreateDraft((current) => ({ ...current, title: event.target.value }))}
|
||||
placeholder="视频标题"
|
||||
/>
|
||||
<Input
|
||||
value={createDraft.url}
|
||||
onChange={(event) => setCreateDraft((current) => ({ ...current, url: event.target.value }))}
|
||||
placeholder="视频地址,例如 https://... 或 /uploads/..."
|
||||
/>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<Input
|
||||
value={createDraft.format}
|
||||
onChange={(event) => setCreateDraft((current) => ({ ...current, format: event.target.value }))}
|
||||
placeholder="格式,例如 mp4 / webm"
|
||||
/>
|
||||
<Input
|
||||
value={createDraft.exerciseType}
|
||||
onChange={(event) => setCreateDraft((current) => ({ ...current, exerciseType: event.target.value }))}
|
||||
placeholder="动作类型,例如 forehand / recording"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<Input
|
||||
value={createDraft.fileSizeMb}
|
||||
onChange={(event) => setCreateDraft((current) => ({ ...current, fileSizeMb: event.target.value }))}
|
||||
placeholder="文件大小(MB,可选)"
|
||||
inputMode="decimal"
|
||||
/>
|
||||
<Input
|
||||
value={createDraft.durationSec}
|
||||
onChange={(event) => setCreateDraft((current) => ({ ...current, durationSec: event.target.value }))}
|
||||
placeholder="时长(秒,可选)"
|
||||
inputMode="decimal"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>取消</Button>
|
||||
<Button onClick={() => void handleCreateVideo()} disabled={registerExternalMutation.isPending}>
|
||||
{registerExternalMutation.isPending ? "新增中..." : "新增记录"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑视频信息</DialogTitle>
|
||||
<DialogDescription>
|
||||
可调整视频标题和动作类型,列表与分析归档会同步显示最新信息。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
value={editDraft.title}
|
||||
onChange={(event) => setEditDraft((current) => ({ ...current, title: event.target.value }))}
|
||||
placeholder="视频标题"
|
||||
/>
|
||||
<Input
|
||||
value={editDraft.exerciseType}
|
||||
onChange={(event) => setEditDraft((current) => ({ ...current, exerciseType: event.target.value }))}
|
||||
placeholder="动作类型,例如 forehand / recording"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEditOpen(false)}>取消</Button>
|
||||
<Button onClick={() => void handleUpdateVideo()} disabled={updateVideoMutation.isPending}>
|
||||
{updateVideoMutation.isPending ? "保存中..." : "保存修改"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
423
client/src/pages/VisionLab.tsx
普通文件
@@ -0,0 +1,423 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/_core/hooks/useAuth";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { formatDateTimeShanghai } from "@/lib/time";
|
||||
import { toast } from "sonner";
|
||||
import { useBackgroundTask } from "@/hooks/useBackgroundTask";
|
||||
import { Database, Image as ImageIcon, Loader2, Microscope, ShieldCheck, Sparkles } from "lucide-react";
|
||||
|
||||
type ReferenceImage = {
|
||||
id: number;
|
||||
slug: string;
|
||||
title: string;
|
||||
exerciseType: string;
|
||||
imageUrl: string;
|
||||
sourcePageUrl: string;
|
||||
sourceLabel: string;
|
||||
author: string | null;
|
||||
license: string | null;
|
||||
expectedFocus: string[] | null;
|
||||
tags: string[] | null;
|
||||
notes: string | null;
|
||||
};
|
||||
|
||||
type VisionRun = {
|
||||
id: number;
|
||||
taskId: string;
|
||||
userId: number;
|
||||
userName: string | null;
|
||||
referenceImageId: number | null;
|
||||
referenceTitle: string | null;
|
||||
title: string;
|
||||
exerciseType: string;
|
||||
imageUrl: string;
|
||||
status: "queued" | "succeeded" | "failed";
|
||||
visionStatus: "pending" | "ok" | "fallback" | "failed";
|
||||
configuredModel: string | null;
|
||||
expectedFocus: string[] | null;
|
||||
summary: string | null;
|
||||
corrections: string | null;
|
||||
warning: string | null;
|
||||
error: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
const COMMONS_SPECIAL_FILE_PATH = "/wiki/Special:FilePath/";
|
||||
const COMMONS_FILE_PAGE_PATH = "/wiki/File:";
|
||||
|
||||
function getCompressedVisionImageUrl(imageUrl: string, width = 960) {
|
||||
try {
|
||||
const url = new URL(imageUrl);
|
||||
if (url.hostname !== "commons.wikimedia.org") {
|
||||
return imageUrl;
|
||||
}
|
||||
|
||||
let fileName: string | null = null;
|
||||
if (url.pathname.startsWith(COMMONS_SPECIAL_FILE_PATH)) {
|
||||
fileName = url.pathname.slice(COMMONS_SPECIAL_FILE_PATH.length);
|
||||
} else if (url.pathname.startsWith(COMMONS_FILE_PAGE_PATH)) {
|
||||
fileName = url.pathname.slice(COMMONS_FILE_PAGE_PATH.length);
|
||||
}
|
||||
|
||||
if (!fileName) {
|
||||
return imageUrl;
|
||||
}
|
||||
|
||||
const decodedFileName = decodeURIComponent(fileName);
|
||||
return `https://commons.wikimedia.org/wiki/Special:Redirect/file/${encodeURIComponent(decodedFileName)}?width=${width}`;
|
||||
} catch {
|
||||
return imageUrl;
|
||||
}
|
||||
}
|
||||
|
||||
function VisionPreviewImage({
|
||||
src,
|
||||
alt,
|
||||
className,
|
||||
width = 960,
|
||||
}: {
|
||||
src: string;
|
||||
alt: string;
|
||||
className: string;
|
||||
width?: number;
|
||||
}) {
|
||||
const [displaySrc, setDisplaySrc] = useState(() => getCompressedVisionImageUrl(src, width));
|
||||
|
||||
useEffect(() => {
|
||||
setDisplaySrc(getCompressedVisionImageUrl(src, width));
|
||||
}, [src, width]);
|
||||
|
||||
return (
|
||||
<img
|
||||
src={displaySrc}
|
||||
alt={alt}
|
||||
className={className}
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
onError={() => {
|
||||
if (displaySrc !== src) {
|
||||
setDisplaySrc(src);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function statusBadge(run: VisionRun) {
|
||||
if (run.status === "failed" || run.visionStatus === "failed") {
|
||||
return <Badge variant="destructive">失败</Badge>;
|
||||
}
|
||||
if (run.status === "queued" || run.visionStatus === "pending") {
|
||||
return <Badge variant="secondary">排队中</Badge>;
|
||||
}
|
||||
if (run.visionStatus === "fallback") {
|
||||
return <Badge variant="outline">文本降级</Badge>;
|
||||
}
|
||||
return <Badge className="bg-emerald-600 hover:bg-emerald-600">视觉成功</Badge>;
|
||||
}
|
||||
|
||||
export default function VisionLab() {
|
||||
const { user } = useAuth();
|
||||
const utils = trpc.useUtils();
|
||||
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
|
||||
const activeTask = useBackgroundTask(activeTaskId);
|
||||
|
||||
const libraryQuery = trpc.vision.library.useQuery();
|
||||
const runsQuery = trpc.vision.runs.useQuery(
|
||||
{ limit: 50 },
|
||||
{ refetchInterval: 4000 }
|
||||
);
|
||||
|
||||
const seedMutation = trpc.vision.seedLibrary.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success(`标准图库已就绪,共 ${data.count} 张`);
|
||||
utils.vision.library.invalidate();
|
||||
},
|
||||
onError: (error) => toast.error(`标准图库初始化失败: ${error.message}`),
|
||||
});
|
||||
|
||||
const runReferenceMutation = trpc.vision.runReference.useMutation({
|
||||
onSuccess: (data) => {
|
||||
setActiveTaskId(data.taskId);
|
||||
toast.success("视觉测试任务已提交");
|
||||
utils.vision.runs.invalidate();
|
||||
},
|
||||
onError: (error) => toast.error(`视觉测试提交失败: ${error.message}`),
|
||||
});
|
||||
|
||||
const runAllMutation = trpc.vision.runAll.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success(`已提交 ${data.count} 个视觉测试任务`);
|
||||
if (data.queued[0]?.taskId) {
|
||||
setActiveTaskId(data.queued[0].taskId);
|
||||
}
|
||||
utils.vision.runs.invalidate();
|
||||
},
|
||||
onError: (error) => toast.error(`批量视觉测试提交失败: ${error.message}`),
|
||||
});
|
||||
|
||||
const retryRunMutation = trpc.vision.retryRun.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("视觉记录已重新加入队列");
|
||||
utils.vision.runs.invalidate();
|
||||
},
|
||||
onError: (error) => toast.error(`重新执行失败: ${error.message}`),
|
||||
});
|
||||
|
||||
const retryFallbacksMutation = trpc.vision.retryFallbacks.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success(`已重新排队 ${data.count} 条历史视觉记录`);
|
||||
utils.vision.runs.invalidate();
|
||||
},
|
||||
onError: (error) => toast.error(`批量修复失败: ${error.message}`),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTask.data?.status === "succeeded" || activeTask.data?.status === "failed") {
|
||||
utils.vision.runs.invalidate();
|
||||
setActiveTaskId(null);
|
||||
}
|
||||
}, [activeTask.data, utils.vision.runs]);
|
||||
|
||||
const references = useMemo(() => (libraryQuery.data ?? []) as ReferenceImage[], [libraryQuery.data]);
|
||||
const runs = useMemo(() => (runsQuery.data ?? []) as VisionRun[], [runsQuery.data]);
|
||||
|
||||
if (libraryQuery.isLoading && runsQuery.isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-28 w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">视觉标准图库</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
用公网可访问的网球标准图验证多模态纠正链路,并持久化每次测试结果。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{user?.role === "admin" ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => seedMutation.mutate()}
|
||||
disabled={seedMutation.isPending}
|
||||
className="gap-2"
|
||||
>
|
||||
{seedMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Database className="h-4 w-4" />}
|
||||
初始化标准库
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => retryFallbacksMutation.mutate({ limit: 20 })}
|
||||
disabled={retryFallbacksMutation.isPending}
|
||||
className="gap-2"
|
||||
>
|
||||
{retryFallbacksMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
|
||||
修复历史降级
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
<Button
|
||||
onClick={() => runAllMutation.mutate()}
|
||||
disabled={runAllMutation.isPending || references.length === 0}
|
||||
className="gap-2"
|
||||
>
|
||||
{runAllMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Microscope className="h-4 w-4" />}
|
||||
批量跑测试
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{user?.role === "admin" ? (
|
||||
<Alert>
|
||||
<ShieldCheck className="h-4 w-4" />
|
||||
<AlertTitle>Admin 视角</AlertTitle>
|
||||
<AlertDescription>
|
||||
当前账号可查看全部视觉测试记录。若用户名为 `H1` 且被配置进 `ADMIN_USERNAMES`,登录后会自动拥有此视角。
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert>
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
<AlertTitle>个人测试视角</AlertTitle>
|
||||
<AlertDescription>当前页面展示标准图库,以及你自己提交的视觉测试结果。</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{activeTask.data?.status === "queued" || activeTask.data?.status === "running" ? (
|
||||
<Alert>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
<AlertTitle>后台执行中</AlertTitle>
|
||||
<AlertDescription>{activeTask.data.message || "视觉测试正在后台执行。"}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">标准图片库</h2>
|
||||
<Badge variant="secondary">{references.length} 张</Badge>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{references.map((reference) => (
|
||||
<Card key={reference.id} className="overflow-hidden border-0 shadow-sm">
|
||||
<div className="aspect-[4/3] overflow-hidden bg-muted">
|
||||
<VisionPreviewImage
|
||||
src={reference.imageUrl}
|
||||
alt={reference.title}
|
||||
className="h-full w-full object-cover"
|
||||
width={960}
|
||||
/>
|
||||
</div>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">{reference.title}</CardTitle>
|
||||
<CardDescription className="flex flex-wrap gap-2">
|
||||
<Badge variant="outline">{reference.exerciseType}</Badge>
|
||||
{reference.license ? <Badge variant="secondary">{reference.license}</Badge> : null}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{reference.notes ? (
|
||||
<p className="text-sm text-muted-foreground">{reference.notes}</p>
|
||||
) : null}
|
||||
{reference.expectedFocus?.length ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{reference.expectedFocus.map((item) => (
|
||||
<Badge key={item} variant="outline">{item}</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<a
|
||||
href={reference.sourcePageUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-sm text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
来源页
|
||||
</a>
|
||||
<Button
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
onClick={() => runReferenceMutation.mutate({ referenceImageId: reference.id })}
|
||||
disabled={runReferenceMutation.isPending}
|
||||
>
|
||||
{runReferenceMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Microscope className="h-4 w-4" />}
|
||||
运行测试
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">视觉测试记录</h2>
|
||||
<Badge variant="secondary">{runs.length} 条</Badge>
|
||||
</div>
|
||||
<div className="grid gap-4">
|
||||
{runs.map((run) => (
|
||||
<Card key={run.id} className="border-0 shadow-sm">
|
||||
<CardContent className="pt-5 space-y-3">
|
||||
<div className="flex flex-col gap-4 lg:flex-row">
|
||||
<a
|
||||
href={run.imageUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="block overflow-hidden rounded-xl bg-muted lg:w-72 lg:flex-none"
|
||||
>
|
||||
<div className="aspect-[4/3]">
|
||||
<VisionPreviewImage
|
||||
src={run.imageUrl}
|
||||
alt={run.title}
|
||||
className="h-full w-full object-cover"
|
||||
width={720}
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div className="min-w-0 flex-1 space-y-3">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="font-semibold">{run.title}</h3>
|
||||
{statusBadge(run)}
|
||||
<Badge variant="outline">{run.exerciseType}</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatDateTimeShanghai(run.createdAt)}
|
||||
{user?.role === "admin" && run.userName ? ` · 提交人:${run.userName}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
{run.configuredModel ? (
|
||||
<Badge variant="secondary">{run.configuredModel}</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{run.summary ? <p className="text-sm">{run.summary}</p> : null}
|
||||
{run.warning ? (
|
||||
<p className="text-sm text-amber-700">降级说明:{run.warning}</p>
|
||||
) : null}
|
||||
{run.error ? (
|
||||
<p className="text-sm text-destructive">错误:{run.error}</p>
|
||||
) : null}
|
||||
|
||||
{(run.visionStatus === "fallback" || run.status === "failed") ? (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="gap-2"
|
||||
onClick={() => retryRunMutation.mutate({ runId: run.id })}
|
||||
disabled={retryRunMutation.isPending}
|
||||
>
|
||||
{retryRunMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Microscope className="h-4 w-4" />}
|
||||
重新视觉识别
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{run.expectedFocus?.length ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{run.expectedFocus.map((item) => (
|
||||
<Badge key={item} variant="outline">{item}</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{run.corrections ? (
|
||||
<div className="rounded-xl bg-muted/50 p-3 text-sm leading-6 whitespace-pre-wrap">
|
||||
{run.corrections}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
{runs.length === 0 ? (
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="pt-6 text-sm text-muted-foreground">
|
||||
还没有视觉测试记录。先运行一张标准图测试,结果会自动入库并显示在这里。
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -33,6 +33,11 @@ server {
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3002;
|
||||
proxy_http_version 1.1;
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
proxy_connect_timeout 300s;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
|
||||
@@ -75,7 +75,7 @@ services:
|
||||
- media-data:/data/media
|
||||
restart: unless-stopped
|
||||
|
||||
worker:
|
||||
media-worker:
|
||||
build:
|
||||
context: ./media
|
||||
dockerfile: Dockerfile
|
||||
@@ -89,6 +89,29 @@ services:
|
||||
- media
|
||||
restart: unless-stopped
|
||||
|
||||
app-worker:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
command: ["node", "dist/worker.js"]
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
DATABASE_URL: mysql://${MYSQL_USER:-tennis}:${MYSQL_PASSWORD:-tennis_password}@db:3306/${MYSQL_DATABASE:-tennis_training_hub}
|
||||
MEDIA_SERVICE_URL: http://media:8081
|
||||
LOCAL_STORAGE_DIR: /data/app/storage
|
||||
NODE_ENV: production
|
||||
volumes:
|
||||
- app-data:/data/app
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
migrate:
|
||||
condition: service_completed_successfully
|
||||
media:
|
||||
condition: service_started
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
app-data:
|
||||
db-data:
|
||||
|
||||
52
docs/API.md
@@ -75,7 +75,7 @@
|
||||
| 类型 | Mutation |
|
||||
| 认证 | **需认证** |
|
||||
| 输入 | `{ skillLevel: enum, durationDays: number, focusAreas?: string[] }` |
|
||||
| 输出 | `{ planId: number, plan: TrainingPlanData }` |
|
||||
| 输出 | `{ taskId: string, task: BackgroundTask }` |
|
||||
|
||||
**输入验证:**
|
||||
- `skillLevel`:`"beginner"` / `"intermediate"` / `"advanced"`
|
||||
@@ -105,7 +105,7 @@
|
||||
| 类型 | Mutation |
|
||||
| 认证 | **需认证** |
|
||||
| 输入 | `{ planId: number }` |
|
||||
| 输出 | `{ success: true, adjustmentNotes: string }` |
|
||||
| 输出 | `{ taskId: string, task: BackgroundTask }` |
|
||||
|
||||
---
|
||||
|
||||
@@ -187,8 +187,10 @@
|
||||
|------|-----|
|
||||
| 类型 | Mutation |
|
||||
| 认证 | **需认证** |
|
||||
| 输入 | `{ poseMetrics: object, exerciseType: string, detectedIssues: array }` |
|
||||
| 输出 | `{ corrections: string }` |
|
||||
| 输入 | `{ poseMetrics: object, exerciseType: string, detectedIssues: array, imageUrls?: string[], imageDataUrls?: string[] }` |
|
||||
| 输出 | `{ taskId: string, task: BackgroundTask }` |
|
||||
|
||||
该接口始终走后台任务。若提供 `imageUrls` 或 `imageDataUrls`,服务端会优先走多模态纠正链路,并把相对地址规范化为可公网访问的绝对 URL。
|
||||
|
||||
#### `analysis.list` - 获取用户所有分析记录
|
||||
|
||||
@@ -211,6 +213,48 @@
|
||||
|
||||
### 6. 训练记录模块 (`record`)
|
||||
|
||||
### 5.1 后台任务模块 (`task`)
|
||||
|
||||
#### `task.list` - 获取当前用户后台任务
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| 类型 | Query |
|
||||
| 认证 | **需认证** |
|
||||
| 输入 | `{ limit?: number }` |
|
||||
| 输出 | `BackgroundTask[]` |
|
||||
|
||||
#### `task.get` - 获取单个后台任务
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| 类型 | Query |
|
||||
| 认证 | **需认证** |
|
||||
| 输入 | `{ taskId: string }` |
|
||||
| 输出 | `BackgroundTask | null` |
|
||||
|
||||
#### `task.retry` - 重试失败任务
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| 类型 | Mutation |
|
||||
| 认证 | **需认证** |
|
||||
| 输入 | `{ taskId: string }` |
|
||||
| 输出 | `{ task: BackgroundTask }` |
|
||||
|
||||
#### `task.createMediaFinalize` - 提交录制归档后台任务
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| 类型 | Mutation |
|
||||
| 认证 | **需认证** |
|
||||
| 输入 | `{ sessionId: string, title: string, exerciseType?: string }` |
|
||||
| 输出 | `{ taskId: string, task: BackgroundTask }` |
|
||||
|
||||
该接口会校验媒体会话所属用户,并由后台 worker 轮询 Go 媒体服务状态,归档完成后自动登记到视频库。
|
||||
|
||||
### 6. 训练记录模块 (`record`)
|
||||
|
||||
#### `record.create` - 创建训练记录
|
||||
|
||||
| 属性 | 值 |
|
||||
|
||||
@@ -1,5 +1,304 @@
|
||||
# Tennis Training Hub - 变更日志
|
||||
|
||||
## 2026.03.16-live-camera-runtime-refresh (2026-03-16)
|
||||
|
||||
### 功能更新
|
||||
|
||||
- `/live-camera` 在打开拍摄引导、启用摄像头、开始分析前,都会先向服务端强制刷新 runtime 状态,避免旧的同步观看锁残留导致本机明明已释放却仍无法启动
|
||||
- 新增 runtime 标题乱码恢复逻辑,可自动把 UTF-8 被误按 Latin-1 显示的标题恢复成正常中文,避免出现 `æœ...` 一类异常标题
|
||||
- 摄像头启动链路改为以 `getUserMedia` 成功为准;即使本地预览 `<video>` 的 `srcObject` 或 `play()` 在当前浏览器中短暂失败,也不会直接把整次启动判死
|
||||
- e2e mock 的媒体流补齐为带假视频轨道的流对象,并把 viewer 回归改为校验“服务端 relay、无 viewer-signal”行为,避免继续按旧 P2P 逻辑断言
|
||||
|
||||
### 测试
|
||||
|
||||
- `pnpm exec playwright test tests/e2e/app.spec.ts --grep "live camera page exposes camera startup controls|live camera switches into viewer mode when another device already owns analysis|live camera recovers mojibake viewer titles before rendering|live camera no longer opens viewer peer retries when server relay is active"`
|
||||
- `pnpm build`
|
||||
- 部署后线上 smoke:登录 `H1` 后访问 `https://te.hao.work/live-camera`,确认空闲态“启动摄像头”入口可见,不再被残留 viewer 锁卡住
|
||||
|
||||
### 线上 smoke
|
||||
|
||||
- `https://te.hao.work/` 已切换到本次新构建
|
||||
- 当前公开站点前端资源 revision:`assets/index-33wVjC4p.js` 与 `assets/index-tNGuStgv.css`
|
||||
- 真实验证已通过:登录 `H1` 后访问 `https://te.hao.work/live-camera`,页面会正常显示“摄像头未启动 / 启动摄像头”,说明旧的 viewer 锁残留不会再把空闲设备卡在同步观看模式
|
||||
|
||||
### 仓库版本
|
||||
|
||||
- `8e9e491`
|
||||
|
||||
## 2026.03.16-live-viewer-server-relay (2026-03-16)
|
||||
|
||||
### 功能更新
|
||||
|
||||
- `/live-camera` 的同步观看改为由 media 服务中转最新合成帧图,不再依赖浏览器之间的 P2P WebRTC viewer 连接
|
||||
- owner 端会把“原视频 + 骨架/关键点 + 虚拟形象”的合成画布压缩成 JPEG 并持续上传到 media 服务
|
||||
- viewer 端改为自动轮询 media 服务中的最新同步帧图,因此即使浏览器之间无法直连,也能继续看到同步画面和状态
|
||||
- 同步观看模式文案已调整为明确提示“通过 media 服务中转”,等待阶段会继续自动刷新,而不是停留在 P2P 连接失败状态
|
||||
- media 服务新增 live-frame 上传与静态分发能力,并记录最近同步帧时间,方便后续继续扩展更高频的服务端 relay
|
||||
|
||||
### 测试
|
||||
|
||||
- `cd media && go test ./...`
|
||||
- `pnpm build`
|
||||
- `playwright-skill` 线上 smoke:先用 media 服务创建 relay session、上传 live-frame,并把 `H1` 的 `live_analysis_runtime` 注入为 active viewer 场景;随后访问 `https://te.hao.work/live-camera`,确认页面进入“同步观看模式”、同步帧来自 `/media/assets/sessions/.../live-frame.jpg`,且 `viewer-signal` 请求数为 `0`
|
||||
|
||||
### 线上 smoke
|
||||
|
||||
- `https://te.hao.work/` 已切换到本次新构建
|
||||
- 当前公开站点前端资源 revision:`assets/index-BC-IupO8.js` 与 `assets/index-tNGuStgv.css`
|
||||
- 真实验证已通过:viewer 端进入“同步观看模式”后,画面由 media 服务静态分发的 `live-frame.jpg` 提供,已确认不再触发 `/viewer-signal` P2P 观看请求
|
||||
|
||||
### 仓库版本
|
||||
|
||||
- `bb46d26`
|
||||
|
||||
## 2026.03.16-camera-startup-fallbacks (2026-03-16)
|
||||
|
||||
### 功能更新
|
||||
|
||||
- 修复部分设备在 `/live-camera` 和 `/recorder` 中因默认后置镜头、分辨率或帧率约束不兼容而直接启动摄像头失败的问题
|
||||
- 摄像头请求现在会自动按当前画质、去掉高约束、低分辨率、备用镜头、任意可用镜头依次降级重试
|
||||
- `/recorder` 在麦克风不可用或麦克风权限未给出时,会自动回退到仅视频模式,不再让整次预览启动失败
|
||||
- 如果实际启用的是兼容镜头或降级模式,页面会显示提示,帮助区分“自动修复成功”与“仍然无法访问摄像头”
|
||||
|
||||
### 测试
|
||||
|
||||
- `pnpm build`
|
||||
- `playwright-skill` 线上 smoke:通过注入 `getUserMedia` 回归验证 `/live-camera` 首轮高约束失败后会自动降级到兼容摄像头模式,`/recorder` 在麦克风不可用时会自动回退到仅视频模式并继续启动预览
|
||||
|
||||
### 线上 smoke
|
||||
|
||||
- `https://te.hao.work/` 已切换到本次新构建
|
||||
- 当前公开站点前端资源 revision:`assets/index-CRxtWK07.js` 与 `assets/index-tNGuStgv.css`
|
||||
- 真实回归已通过:模拟高约束失败时,`/live-camera` 会提示“当前设备已自动切换到兼容摄像头模式”并继续启动;模拟麦克风不可用时,`/recorder` 会提示“麦克风不可用,已切换为仅视频模式”并继续显示录制入口
|
||||
|
||||
### 仓库版本
|
||||
|
||||
- `a211562`
|
||||
|
||||
## 2026.03.16-live-analysis-viewer-full-sync (2026-03-16)
|
||||
|
||||
### 功能更新
|
||||
|
||||
- 同账号多端同步观看时,viewer 端现在会按持有端 runtime snapshot 完整渲染,不再混用本地默认状态
|
||||
- `/live-camera` viewer 端新增主端同步信息卡,可看到当前会话标题、训练模式、设备端、拍摄视角、画质模式、虚拟形象状态和最近同步时间
|
||||
- viewer 端现在会同步显示主端当前处于“分析中 / 保存中 / 已保存 / 保存失败”的阶段状态
|
||||
- viewer 页面在同步观看模式下会自动关闭拍摄校准弹窗,避免被“启用摄像头”引导遮挡画面和状态信息
|
||||
|
||||
### 测试
|
||||
|
||||
- `pnpm exec playwright test tests/e2e/app.spec.ts --grep "live camera switches into viewer mode|viewer stream|recorder blocks"`
|
||||
- `pnpm build`
|
||||
- `playwright-skill` 线上 smoke:同账号 `H1` 双端登录后,移动端 owner 开始实时分析,桌面端 `/live-camera` 进入同步观看并显示主端信息、同步视频流,owner 点击结束分析后 viewer 同步进入保存阶段
|
||||
|
||||
### 线上 smoke
|
||||
|
||||
- `https://te.hao.work/` 已切换到本次新构建
|
||||
- 当前公开站点前端资源 revision:`assets/index-HRdM3fxq.js` 与 `assets/index-tNGuStgv.css`
|
||||
- 真实双端验证已通过:同账号 `H1` 在移动端开启实时分析后,桌面端 `/live-camera` 会自动进入同步观看模式,显示主端设备信息、最近同步时间和远端视频流;owner 点击结束分析后,viewer 会同步进入“保存中”阶段
|
||||
|
||||
### 仓库版本
|
||||
|
||||
- `922a9fb`
|
||||
|
||||
## 2026.03.16-live-analysis-lock-hardening (2026-03-16)
|
||||
|
||||
### 功能更新
|
||||
|
||||
- 修复同账号多端实时分析在旧登录态下仍可重复占用摄像头的问题;缺少 `sid` 的旧 token 现在会按 token 本身派生唯一会话标识
|
||||
- `/live-camera` 的同步观看模式新增自动重试;当持有端刚启动推流、viewer 首次连接返回 `viewer stream not ready` 时,会继续重连,不再长时间停留在无画面状态
|
||||
- `/recorder` 接入实时分析占用锁;其他设备正在实时分析时,本页会禁止再次启动摄像头和开始录制,并提示前往 `/live-camera` 查看同步画面
|
||||
- 应用启动改为先监听 HTTP 端口、再后台串行执行教程图同步和标准库预热,修复新容器上线时公网长时间返回 `502`
|
||||
|
||||
### 测试
|
||||
|
||||
- `curl -I https://te.hao.work/`
|
||||
- `pnpm check`
|
||||
- `pnpm exec vitest run server/_core/sdk.test.ts server/features.test.ts`
|
||||
- `pnpm exec playwright test tests/e2e/app.spec.ts --grep "viewer mode|viewer stream|recorder blocks"`
|
||||
- `playwright-skill` 线上校验:登录 `H1` 后访问 `/changelog`,确认 `2026.03.16-live-analysis-lock-hardening` 与仓库版本 `f9db6ef` 已展示
|
||||
- `pnpm build`
|
||||
- Playwright 线上 smoke:`H1` 手机端开启实时分析后,PC 端 `/live-camera` 自动进入同步观看并显示同步画面,`/recorder` 禁止启动摄像头;结束分析后会话可正常释放
|
||||
|
||||
### 线上 smoke
|
||||
|
||||
- `https://te.hao.work/` 已切换到本次新构建,不再返回 `502`
|
||||
- 当前公开站点前端资源 revision:`assets/index-mi8CPCFI.js` 与 `assets/index-Cp_VJ8sf.css`
|
||||
- 真实双端验证已通过:同账号 `H1` 手机端开始实时分析后,PC 端 `/live-camera` 进入同步观看模式且可拉起同步流,`/recorder` 页面会阻止再次占用摄像头
|
||||
|
||||
### 仓库版本
|
||||
|
||||
- `f9db6ef`
|
||||
|
||||
## 2026.03.16-live-analysis-runtime-migration (2026-03-16)
|
||||
|
||||
### 功能更新
|
||||
|
||||
- 修复生产环境缺失 `live_analysis_runtime` 表导致 `/live-camera` 启动实时分析时报 SQL 查询失败的问题
|
||||
- 生产库已补建 `live_analysis_runtime` 表,并写入缺失的 `0011_live_analysis_runtime` 迁移记录,避免后续重复报错
|
||||
- 仓库内 `drizzle/meta/_journal.json` 已补齐 `0011_live_analysis_runtime` 条目,后续 `docker compose` 部署可正确识别该迁移
|
||||
- 实时分析 runtime 锁恢复正常后,同账号多端互斥与同步观看流程可继续工作
|
||||
|
||||
### 测试
|
||||
|
||||
- `pnpm check`
|
||||
- `pnpm exec vitest run server/features.test.ts`
|
||||
- `pnpm build`
|
||||
- `docker compose exec -T db mysql ... SHOW TABLES LIKE 'live_analysis_runtime'`
|
||||
- `curl -I https://te.hao.work/live-camera`
|
||||
- Playwright smoke:登录 `H1` 后访问 `/live-camera`,`analysis.runtimeGet` / `analysis.runtimeAcquire` / `analysis.runtimeRelease` 全部返回 `200`
|
||||
|
||||
### 线上 smoke
|
||||
|
||||
- `https://te.hao.work/` 已切换到本次新构建
|
||||
- 当前公开站点前端资源 revision:`assets/index-B3BN5hY-.js` 与 `assets/index-BL6GQzUF.css`
|
||||
- `/live-camera` 已恢复可用,线上不再出现 `live_analysis_runtime` 缺表导致的 SQL 查询失败
|
||||
|
||||
### 仓库版本
|
||||
|
||||
- `2b72ef9`
|
||||
|
||||
## 2026.03.16-live-camera-multidevice-viewer (2026-03-16)
|
||||
|
||||
### 功能更新
|
||||
|
||||
- `/live-camera` 新增同账号多端 runtime 锁;一个设备开始实时分析后,其他设备不能再次启动摄像头或分析
|
||||
- 其他设备会自动进入“同步观看模式”,可查看持有端同步推送的实时画面、当前动作、评分、反馈和最近动作片段
|
||||
- 同步观看复用 media 服务新增的 `/viewer-signal` WebRTC 通道,直接订阅“原视频 + 骨架 + 关键点 + 虚拟形象”的合成画面
|
||||
- runtime 心跳按 `sid` 维度识别持有端,兼容旧 token 缺失可选字段的情况;超过 15 秒无心跳会自动释放陈旧锁
|
||||
- `/live-camera` 前端新增 owner / viewer 双模式切换,观看端会禁用镜头切换、重新校准、质量调整和分析启动
|
||||
- e2e mock 新增 viewer 模式和 runtime 接口覆盖,保证浏览器测试可以直接验证多端互斥与同步观看
|
||||
|
||||
### 测试
|
||||
|
||||
- `pnpm check`
|
||||
- `pnpm exec vitest run server/features.test.ts`
|
||||
- `go test ./...`
|
||||
- `go build ./...`
|
||||
- `pnpm build`
|
||||
- `pnpm exec playwright test tests/e2e/app.spec.ts --grep "live camera"`
|
||||
- `pnpm exec playwright test tests/e2e/app.spec.ts --grep "recorder flow archives a session and exposes it in videos"`
|
||||
- `curl -I https://te.hao.work/live-camera`
|
||||
|
||||
### 线上 smoke
|
||||
|
||||
- `https://te.hao.work/live-camera` 已切换到本次新前端构建
|
||||
- 公开站点确认已经提供本次发布的最新前端资源
|
||||
|
||||
### 仓库版本
|
||||
|
||||
- `4e4122d`
|
||||
|
||||
## 2026.03.16-live-analysis-overlay-archive (2026-03-16)
|
||||
|
||||
### 功能更新
|
||||
|
||||
- `/live-camera` 新增 10 个免费动物虚拟形象,可将主体实时替换为猩猩、猴子、狗、猪、猫、狐狸、熊猫、狮子、老虎、兔子
|
||||
- `/live-camera` 再新增 4 个免费的全身 3D Avatar 示例,可直接覆盖人物轮廓,并提供对应的 CC0 模型源链接
|
||||
- `/live-camera` 新增实时分析自动录像,按 60 秒自动切段归档;归档视频写入视频库并标记为“实时分析”
|
||||
- 实时分析录像改为录制“视频画面 + 骨架线 + 关键点 + 虚拟形象覆盖”的合成画布,回放中可直接看到分析叠层
|
||||
- 实时分析记录与视频库解耦,用户删除视频库中的“实时分析”录像后,不会删除已保存的分析数据和训练记录
|
||||
- 增加形象别名输入,当前可按输入内容自动映射到内置形象
|
||||
- 实时分析动作稳定器从短窗口切换为 24 帧时间窗口,降低 1-2 秒内频繁跳动作的问题
|
||||
- 动作切换新增确认阶段与延迟入库逻辑,连续动作区间改为只按稳定动作聚合
|
||||
- 画面内新增稳定动作、原始候选、窗口占比、切换确认状态等实时状态提示
|
||||
- 实时分析会话保存新增稳定窗口、动作切换次数、原始波动率、虚拟形象状态等指标
|
||||
- 动物头像素材切换为本地集成的免费 Twemoji SVG,避免外链依赖
|
||||
- 新增 Open Source Avatars 的本地优化透明 WebP 全身素材,减少全身替身叠加时的页面流量和首帧加载时间
|
||||
|
||||
### 测试
|
||||
|
||||
- `pnpm check`
|
||||
- `pnpm test`
|
||||
- `pnpm build`
|
||||
- `pnpm test:e2e`
|
||||
- Playwright 线上 smoke:
|
||||
- `https://te.hao.work/live-camera` 真实登录 `H1` 后可完成引导、启用摄像头、开始分析、结束分析
|
||||
- `https://te.hao.work/videos` 可见“实时分析”录像条目
|
||||
- `https://te.hao.work/changelog` 已展示 `2026.03.16-live-analysis-overlay-archive` 条目与对应摘要
|
||||
- 最终线上资源 revision:`assets/index-BWEXNszf.js` 与 `assets/index-BL6GQzUF.css`
|
||||
|
||||
### 仓库版本
|
||||
|
||||
- `4fb2d09`
|
||||
|
||||
## 2026.03.15-live-analysis-leave-hint (2026-03-15)
|
||||
|
||||
### 功能更新
|
||||
|
||||
- 实时分析进行中显示“不要关闭浏览器或切走页面”提示
|
||||
- 点击“结束分析”后,保存阶段显示“请暂时停留当前页面”提示
|
||||
- 保存完成后明确提示“现在可以关闭浏览器或切换到其他页面”
|
||||
- 分析中和保存中增加离开页面提醒,减少误关导致的数据丢失
|
||||
|
||||
### 测试
|
||||
|
||||
- `pnpm check`
|
||||
- `pnpm build`
|
||||
|
||||
### 仓库版本
|
||||
|
||||
- `5c2dcf2`
|
||||
|
||||
## 2026.03.15-training-generator-collapse (2026-03-15)
|
||||
|
||||
### 功能更新
|
||||
|
||||
- 训练页右侧“重新生成计划”面板在桌面端默认折叠到右侧
|
||||
- 点击右侧折叠栏后展开完整生成器,可调整周期并重新生成计划
|
||||
- 移动端继续保持完整面板直接可见
|
||||
- 未生成计划时点击“前往生成训练计划”会先自动展开,再滚动到面板位置
|
||||
|
||||
### 测试
|
||||
|
||||
- `pnpm check`
|
||||
- `pnpm build`
|
||||
|
||||
### 仓库版本
|
||||
|
||||
- `1ce94f6`
|
||||
|
||||
## 2026.03.15-progress-time-actions (2026-03-15)
|
||||
|
||||
### 功能更新
|
||||
|
||||
- 最近训练记录摘要行默认显示到秒的具体时间,统一按 `Asia/Shanghai` 展示
|
||||
- 录制类训练记录在列表中直接显示动作数、主动作和前 3 个动作统计
|
||||
- 训练记录展开态中的动作明细改为中文动作标签,便于直接阅读
|
||||
- 提醒页通知时间统一切换为 `Asia/Shanghai`
|
||||
|
||||
### 测试
|
||||
|
||||
- `pnpm check`
|
||||
- `pnpm build`
|
||||
|
||||
### 仓库版本
|
||||
|
||||
- `71caf0d`
|
||||
|
||||
## 2026.03.15-session-changelog (2026-03-15)
|
||||
|
||||
### 功能更新
|
||||
|
||||
- 用户名登录生成独立 `sid`,同一账号在多个设备或浏览器上下文中登录时不再互相顶掉 session
|
||||
- 新增应用内更新日志页面 `/changelog`,展示版本号、发布日期、仓库版本和测试记录
|
||||
- 训练进度页最近训练记录支持展开,展示具体上海时间、动作数、主动作、动作明细、录制有效性和备注
|
||||
- 录制页补齐动作抽样摘要、无效录制标记与 media 预归档状态的前端展示
|
||||
- Dashboard、任务中心、管理台、训练页、评分页、日志页、视觉测试页、视频库等高频页面统一使用 `Asia/Shanghai` 时间显示
|
||||
|
||||
### 测试
|
||||
|
||||
- `pnpm check`
|
||||
- `pnpm test`
|
||||
- `pnpm test:go`
|
||||
- `pnpm build`
|
||||
- Playwright 线上 smoke:
|
||||
- `https://te.hao.work/` 使用两个浏览器上下文分别登录 `H1`,两端 dashboard 均保持有效
|
||||
- 当前线上 `/changelog` 仍返回旧前端构建,待部署最新版本后需要复测该页面
|
||||
|
||||
### 仓库版本
|
||||
|
||||
- `a9ea94f`
|
||||
|
||||
## v3.0.0 (2026-03-14)
|
||||
|
||||
### 新增功能
|
||||
|
||||
@@ -10,26 +10,49 @@
|
||||
### 用户与训练
|
||||
|
||||
- 用户名登录:无需注册,输入用户名即可进入训练工作台
|
||||
- 训练计划:按技能等级和训练周期生成训练计划
|
||||
- 新用户邀请:首次创建用户名账号需要邀请码 `CA2026`
|
||||
- 训练计划:按技能等级和训练周期生成训练计划,改为后台异步生成
|
||||
- 训练进度:展示训练次数、时长、评分趋势、最近分析结果
|
||||
- 每日打卡与提醒:支持训练打卡、提醒、通知记录
|
||||
- 成就系统与提醒:训练日聚合、成就进度、连练统计、提醒、通知记录
|
||||
|
||||
### 视频与分析
|
||||
|
||||
- 视频上传分析:上传 `webm/mp4` 视频进入视频库并触发分析流程
|
||||
- 实时摄像头分析:浏览器端调用 MediaPipe,进行姿势识别和反馈展示
|
||||
- 实时摄像头分析:浏览器端调用 MediaPipe,自动识别 `forehand/backhand/serve/volley/overhead/slice/lob/unknown`
|
||||
- 识别稳定化:最近 6 帧动作结果会做时序加权和 winner/runner-up 比较,降低动作标签抖动
|
||||
- 连续动作片段:自动聚合连续同类动作区间,单段不超过 10 秒,并保存得分、置信度与反馈摘要
|
||||
- 实时分析录制:分析阶段可同步保留浏览器端本地录制视频,停止分析后自动登记到系统
|
||||
- 训练数据回写:实时分析与录制数据自动写入训练记录、日训练聚合、成就系统和 NTRP 评分
|
||||
- 动作纠正:支持文本纠正和多模态纠正两条链路,统一通过后台任务执行
|
||||
- 多模态图片输入:上传关键帧后会转换为公网可访问的绝对 URL,再提交给视觉模型
|
||||
- 视觉结果规范化:即使上游模型返回的是宽松 JSON、Markdown 包裹 JSON 或缺失数组字段,服务端也会先做结构兼容与默认值补齐
|
||||
- 视觉标准图库:内置网球公网参考图,可直接发起视觉识别测试并保存结果
|
||||
- 历史视觉修复:`vision-lab` 支持对旧的 `fallback/failed` 视觉记录重新排队修复,admin 可批量修复历史降级记录
|
||||
- 视频库:集中展示录制结果、上传结果和分析摘要
|
||||
- PC 轻剪辑:视频库内可直接打开轻剪辑工作台,支持预览、设定入点/出点、建议片段和草稿导出
|
||||
|
||||
### 在线录制与媒体链路
|
||||
|
||||
- Go 媒体服务:独立处理录制会话、分段上传、marker、归档和回放资源
|
||||
- Node app worker:统一处理训练计划、动作纠正和录制归档结果登记
|
||||
- WebRTC 推流:录制时并行建立低延迟实时推流链路
|
||||
- MediaRecorder 分段:浏览器本地压缩录制并每 60 秒自动分段上传
|
||||
- 自动标记:客户端通过轻量运动检测创建关键片段 marker
|
||||
- 手动标记:录制中支持手动插入剪辑点
|
||||
- 自动重连:摄像头 track 断开时自动尝试恢复
|
||||
- 归档回放:worker 合并片段并生成 WebM,FFmpeg 可用时额外生成 MP4
|
||||
- 视频库登记:归档完成后自动写回现有视频库
|
||||
- 归档状态可视化:录制页在“合并分段 / 生成回放”阶段显示任务进度、已上传体积、待上传体积和片段总数
|
||||
- 视频库登记:归档完成后由 app worker 自动写回现有视频库
|
||||
- 上传稳定性:媒体分段上传遇到 `502/503/504` 会自动重试
|
||||
|
||||
### 评分、成就与管理
|
||||
|
||||
- 每日异步 NTRP:系统会在每日零点后自动排队全量 NTRP 刷新任务
|
||||
- 用户手动刷新:普通用户可刷新自己的 NTRP;管理员可刷新任意用户或全量用户
|
||||
- NTRP 快照:每次刷新都会生成可追踪的快照,保存维度评分和数据来源摘要
|
||||
- 成就定义表:成就系统已独立于旧徽章表,支持大规模扩展、分层、隐藏成就与分类
|
||||
- 管理系统:`/admin` 提供用户管理、任务列表、实时分析会话列表、应用设置和审计日志
|
||||
- H1 管理能力:当 `H1` 被配置为 admin 后,可查看全部视觉测试数据与后台管理数据
|
||||
|
||||
## 前端能力
|
||||
|
||||
@@ -41,17 +64,21 @@
|
||||
- 横屏视频优先布局
|
||||
- 录制页和分析页防下拉刷新干扰
|
||||
- 录制时按设备场景自动调整码率和控件密度
|
||||
- 实时分析页支持竖屏最大化预览,主要操作按钮放在侧边
|
||||
|
||||
### 桌面端
|
||||
|
||||
- 统一工作台导航
|
||||
- 仪表盘、训练、视频、录制、分析等模块一致的布局结构
|
||||
- 为后续 PC 粗剪时间线预留媒体域与文档规范
|
||||
- 全局任务中心:桌面侧边栏和移动端头部都可查看后台任务
|
||||
- Admin 视觉测试页:`H1` 这类 admin 用户可查看全部视觉测试数据
|
||||
- 视频库内置轻剪辑工作台,可在桌面端快速完成粗剪草稿、建议片段复核和导出
|
||||
|
||||
## 架构能力
|
||||
|
||||
- Node 应用负责业务 API、登录、训练数据与视频库元数据
|
||||
- Go 服务负责媒体链路与归档
|
||||
- 后台任务表 `background_tasks` 统一承接重任务
|
||||
- `Docker Compose + 宿主机 nginx` 作为标准单机部署方式
|
||||
- 统一的本地验证命令:
|
||||
- `pnpm check`
|
||||
@@ -65,12 +92,30 @@
|
||||
|
||||
- 浏览器录制兼容目标以 Chrome 为主
|
||||
- 当前 WebRTC 重点是浏览器到服务端的实时上行,不是多观众直播分发
|
||||
- 当前 PC 剪辑仍处于基础媒体域准备阶段,未交付完整多轨编辑器
|
||||
- 当前 PC 剪辑已交付轻量草稿工作台,但未交付完整多轨编辑器、批量转码和最终成片渲染
|
||||
- 当前存储策略为本地卷优先,未接入对象存储归档
|
||||
- 当前 `.env` 配置的视觉网关若忽略 `LLM_VISION_MODEL`,系统会回退到文本纠正;代码已支持独立视觉模型配置,但上游网关能力仍需单独确认
|
||||
- 当前实时动作识别仍基于姿态启发式分类,不是专门训练的动作识别模型
|
||||
|
||||
## 后续增强方向
|
||||
|
||||
- PC 时间线粗剪与 clip plan 持久化
|
||||
- 更细粒度的设备能力自适应
|
||||
- 更强的媒体回放和片段导出能力
|
||||
- 更深入的前端域拆分和懒加载优化
|
||||
### 移动端个性化增强
|
||||
|
||||
- 根据网络、机型和电量状态动态切换录制档位、分段大小与上传节流策略
|
||||
- 将录制焦点视图扩展为单手操作布局,支持拇指热区、自定义主按钮顺序和横竖屏独立面板
|
||||
- 为不同训练项目提供场景化预设,例如发球、正手、反手、步伐训练各自保存摄像头方向、裁切比例和提示文案
|
||||
- 增加弱网回传面板,向用户展示排队片段、预计上传耗时和失败重试建议
|
||||
|
||||
### PC 轻剪与训练回放
|
||||
|
||||
- 在当前轻剪辑工作台基础上继续交付单轨时间线粗剪:片段拖拽、片段删除、关键帧封面和 marker 跳转
|
||||
- 增加“剪辑计划”实体,允许把自动 marker、手动 marker 和 AI 建议片段一起保存
|
||||
- 提供双栏回放模式:左侧原视频,右侧姿态轨迹、节奏评分和文字纠正同步滚动
|
||||
- 支持从视频库直接发起导出任务,在后台生成训练集锦或问题片段合集
|
||||
|
||||
### 高性能前端重构
|
||||
|
||||
- 将训练、分析、录制、视频库拆分为按域加载的路由包,继续降低首屏主包体积
|
||||
- 把共享媒体状态、任务状态和用户状态从页面本地逻辑收拢为稳定的数据域层
|
||||
- 统一上传、任务轮询、错误提示和绝对 URL 规范化逻辑,减少当前多处重复实现
|
||||
- 为重计算页面增加惰性加载、按需图表加载和更严格的移动端资源预算
|
||||
|
||||
@@ -6,9 +6,10 @@
|
||||
- `db` 容器:MySQL 8,数据持久化到 `db-data`
|
||||
- `migrate` 容器:一次性执行 Drizzle 迁移,成功后退出
|
||||
- `app` 容器:Node 应用,端口 `3000`
|
||||
- `app-worker` 容器:Node 后台任务 worker,共享应用卷与数据库
|
||||
- 宿主机公开调试端口:`8302 -> app:3000`
|
||||
- `media` 容器:Go 媒体服务,端口 `8081`
|
||||
- `worker` 容器:Go 媒体归档 worker,共享媒体卷
|
||||
- `media-worker` 容器:Go 媒体归档 worker,共享媒体卷
|
||||
- `app-data` 卷:上传视频等本地文件存储
|
||||
- `db-data` 卷:MySQL 数据目录
|
||||
- `media-data` 卷:录制片段、会话状态、归档成片
|
||||
@@ -32,6 +33,13 @@ docker compose up -d --build
|
||||
- `MYSQL_PASSWORD`
|
||||
- `MYSQL_ROOT_PASSWORD`
|
||||
- `LLM_API_KEY`
|
||||
- `APP_PUBLIC_BASE_URL`
|
||||
- `LLM_VISION_MODEL`
|
||||
|
||||
如需启用独立视觉模型端点,再补:
|
||||
|
||||
- `LLM_VISION_API_URL`
|
||||
- `LLM_VISION_API_KEY`
|
||||
|
||||
## nginx
|
||||
|
||||
@@ -54,6 +62,7 @@ systemctl reload nginx
|
||||
- `curl http://127.0.0.1:3002/api/trpc/auth.me`
|
||||
- `curl http://te.hao.work:8302/`
|
||||
- `curl http://127.0.0.1:8081/media/health`
|
||||
- `docker compose exec app-worker node dist/worker.js --help` 不适用;应通过 `docker compose ps app-worker` 确认 worker 常驻
|
||||
|
||||
## External access links
|
||||
|
||||
@@ -77,4 +86,4 @@ systemctl reload nginx
|
||||
2. 回退 Git 版本
|
||||
3. 重新执行 `docker compose up -d --build`
|
||||
|
||||
如果只需停止录制链路,可单独关闭 `media` 与 `worker`,主站业务仍可继续运行。
|
||||
如果只需停止录制链路,可单独关闭 `media` 与 `media-worker`,主站业务仍可继续运行;如需暂停训练计划/动作纠正等后台任务,再额外停止 `app-worker`。
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
## Recommended loop
|
||||
|
||||
```bash
|
||||
set -a && source .env && set +a && pnpm exec drizzle-kit migrate
|
||||
pnpm check
|
||||
pnpm test
|
||||
pnpm test:go
|
||||
@@ -31,11 +32,12 @@ git commit -m "..."
|
||||
如果业务开发中被打断,恢复时按以下顺序:
|
||||
|
||||
1. `git status` 查看当前工作树
|
||||
2. 先跑 `pnpm check`
|
||||
3. 再跑 `pnpm test`
|
||||
4. 若涉及媒体链路,再跑 `pnpm test:go`
|
||||
5. 最后跑 `pnpm test:e2e`
|
||||
6. 若当前分支包含部署改动,再执行 `docker compose config` 与基础 smoke check
|
||||
2. 若 schema 或环境变量改动过,先执行 `set -a && source .env && set +a && pnpm exec drizzle-kit migrate`
|
||||
3. 再跑 `pnpm check`
|
||||
4. 再跑 `pnpm test`
|
||||
5. 若涉及媒体链路,再跑 `pnpm test:go`
|
||||
6. 最后跑 `pnpm test:e2e`
|
||||
7. 若当前分支包含部署改动,再执行 `docker compose config` 与基础 smoke check
|
||||
|
||||
不要在一半状态下长时间保留“能编译但主流程不可用”的改动。
|
||||
|
||||
@@ -55,6 +57,7 @@ git commit -m "..."
|
||||
|
||||
- `client/src/lib/media.ts`
|
||||
- `client/src/pages/Recorder.tsx`
|
||||
- `client/src/pages/LiveCamera.tsx`
|
||||
- `media/main.go`
|
||||
- `server/routers.ts`
|
||||
- `server/_core/mediaProxy.ts`
|
||||
@@ -65,6 +68,7 @@ git commit -m "..."
|
||||
- marker 写入
|
||||
- finalize
|
||||
- 视频库登记
|
||||
- 实时分析停止后的会话保存和训练数据回写
|
||||
|
||||
## Documentation discipline
|
||||
|
||||
|
||||
200
docs/runtime-operations.md
普通文件
@@ -0,0 +1,200 @@
|
||||
# Runtime Operations
|
||||
|
||||
更新时间:2026-03-15 08:20 CST。
|
||||
|
||||
本文档说明以下几类运行时能力与维护动作:
|
||||
|
||||
- 后台任务的超时、重试、心跳与失败收敛
|
||||
- 日志页面和任务中心的降级行为
|
||||
- 实时分析增强项与 PC 轻剪辑增强项
|
||||
- 全量重启、日志清理和线上 smoke check 的标准步骤
|
||||
|
||||
## 1. 后台任务稳定性
|
||||
|
||||
### 1.1 外部请求超时与重试
|
||||
|
||||
服务端新增了统一的 `fetchWithTimeout` 封装,当前已接入:
|
||||
|
||||
- LLM 请求
|
||||
- media service 会话查询
|
||||
|
||||
相关环境变量:
|
||||
|
||||
- `LLM_TIMEOUT_MS`
|
||||
- `LLM_RETRY_COUNT`
|
||||
- `MEDIA_FETCH_TIMEOUT_MS`
|
||||
- `MEDIA_FETCH_RETRY_COUNT`
|
||||
|
||||
默认策略:
|
||||
|
||||
- LLM:超时 45 秒,失败后按配置重试
|
||||
- media session 查询:超时 12 秒,失败后按配置重试
|
||||
|
||||
这样做的目的:
|
||||
|
||||
- 降低上游网关偶发慢响应导致的前台直接失败
|
||||
- 把超时边界显式化,避免请求悬挂
|
||||
- 为后台任务提供更稳定的失败判定
|
||||
|
||||
### 1.2 Worker 心跳与失败收敛
|
||||
|
||||
后台任务 worker 当前行为:
|
||||
|
||||
- 领取任务后定期写入 `lockedAt` 心跳
|
||||
- 服务异常重启后,超时未续约的 running 任务会重新入队
|
||||
- 超过 `maxAttempts` 的 queued 任务会自动转为 failed,不再无限重试
|
||||
|
||||
相关环境变量:
|
||||
|
||||
- `BACKGROUND_TASK_POLL_MS`
|
||||
- `BACKGROUND_TASK_STALE_MS`
|
||||
- `BACKGROUND_TASK_HEARTBEAT_MS`
|
||||
|
||||
### 1.3 录制归档完全异步化
|
||||
|
||||
`task.createMediaFinalize` 现在只负责入队,不再在 API 请求阶段同步查询 media service。
|
||||
|
||||
效果:
|
||||
|
||||
- 录制页结束录制时更快返回
|
||||
- media service 暂时抖动时,不会把前台提交动作直接拖成超时
|
||||
- 真正的会话校验、归档、回放可用性判断都在 worker 中执行
|
||||
|
||||
## 2. 前端任务观测与降级
|
||||
|
||||
### 2.1 任务中心
|
||||
|
||||
`TaskCenter` 当前增强:
|
||||
|
||||
- 查询失败时保留最近一次成功结果
|
||||
- 自动重试
|
||||
- 显示任务耗时
|
||||
- 后台任务成功/失败继续触发前端提示
|
||||
|
||||
适用页面:
|
||||
|
||||
- 顶部任务中心
|
||||
- `/logs`
|
||||
|
||||
### 2.2 日志页
|
||||
|
||||
`/logs` 当前用于查看:
|
||||
|
||||
- 后台任务状态
|
||||
- 错误原因
|
||||
- 尝试次数
|
||||
- 执行耗时
|
||||
- 通知记录
|
||||
|
||||
当 `task.list` 拉取失败时:
|
||||
|
||||
- 页面会提示“当前显示最近一次成功结果”
|
||||
- 不会因为一次 502 就直接清空日志视图
|
||||
|
||||
## 3. 实时分析增强
|
||||
|
||||
`/live-camera` 当前新增与强化内容:
|
||||
|
||||
- 低可见度守卫:人体关键点可见度不足时优先判定为未知动作
|
||||
- 更稳的动作判定:补充前移、躯干偏移、触球高度、双腕展开等启发式
|
||||
- 动作分布面板:按非未知动作统计区间数、时长、平均得分、平均置信度
|
||||
- 区间筛选:可按动作类型只看正手、反手、发球等片段
|
||||
- 会话质量带:根据总分、有效识别率和有效区间数给出“高质量 / 稳定 / 待加强”
|
||||
- 最佳片段摘要:显示当前会话的最佳片段得分
|
||||
- 最近会话回放入口:已有 `videoUrl` 时可直接打开回放
|
||||
|
||||
这部分的设计目标不是替代专业模型,而是让前台实时分析在弱模型条件下仍然具备:
|
||||
|
||||
- 可判断
|
||||
- 可筛选
|
||||
- 可回看
|
||||
|
||||
## 4. PC 轻剪辑增强
|
||||
|
||||
`/videos` 中的轻剪辑工作台当前新增:
|
||||
|
||||
- 草稿片段数量、总剪辑时长、建议片段数、当前区间时长概览
|
||||
- 当前区间循环预览
|
||||
- 建议片段一键载入区间
|
||||
- 建议片段循环预览
|
||||
- 草稿片段快速回填到编辑区
|
||||
- 草稿片段信息复制
|
||||
- JSON 草稿导出
|
||||
- cue sheet 文本清单导出
|
||||
|
||||
当前仍属于“粗剪计划”层,不直接产出服务器端裁剪视频文件。
|
||||
|
||||
适合的使用方式:
|
||||
|
||||
- 先在浏览器里完成粗剪与讲解思路整理
|
||||
- 导出草稿或清单
|
||||
- 再交给后续的后台剪辑任务或人工剪辑流程
|
||||
|
||||
## 5. 运行日志清理
|
||||
|
||||
如需清理所有用户的任务和通知运行日志,可执行:
|
||||
|
||||
```sql
|
||||
DELETE FROM background_tasks;
|
||||
DELETE FROM notification_log;
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- 这会清空 `/logs` 和任务中心中与后台任务相关的历史记录
|
||||
- 不影响训练记录、视频、分析结果、成就、评分等业务数据
|
||||
- 建议在确认当前没有需要保留的任务审计信息后再执行
|
||||
|
||||
## 6. 标准重启流程
|
||||
|
||||
使用 Docker Compose 重启全部服务:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
应至少确认以下服务状态正常:
|
||||
|
||||
- `app`
|
||||
- `app-worker`
|
||||
- `db`
|
||||
- `media`
|
||||
- `media-worker`
|
||||
|
||||
检查命令:
|
||||
|
||||
```bash
|
||||
docker compose ps
|
||||
docker compose logs --tail=80 app-worker
|
||||
curl http://127.0.0.1:8081/media/health
|
||||
```
|
||||
|
||||
## 7. 线上 Smoke Check
|
||||
|
||||
全量重启后建议至少执行:
|
||||
|
||||
```bash
|
||||
curl -I https://te.hao.work/
|
||||
curl -I https://te.hao.work/assets/index-BS2QgeEv.css
|
||||
pnpm test:llm
|
||||
```
|
||||
|
||||
其中旧资源 URL 返回 `404` 是正确行为,表示缺失静态资源不会再回退成 `index.html`。
|
||||
|
||||
浏览器级 smoke check 继续复用:
|
||||
|
||||
```bash
|
||||
cd /root/.codex/skills/playwright-skill
|
||||
node run.js /tmp/playwright-test-te-full-smoke.js
|
||||
```
|
||||
|
||||
期望结果:
|
||||
|
||||
- `dashboardOk: true`
|
||||
- `trainingOk: true`
|
||||
- `videosOk: true`
|
||||
- `visionOk: true`
|
||||
- `liveCameraOk: true`
|
||||
- `adminOk: true`
|
||||
- `recorderOk: true`
|
||||
- `issueCount: 0`
|
||||
@@ -19,8 +19,12 @@
|
||||
当前覆盖:
|
||||
|
||||
- Node/tRPC 路由输入校验与权限检查
|
||||
- 实时分析会话保存、管理员权限与异步 NTRP 刷新入队
|
||||
- LLM 模块请求配置与环境变量回退逻辑
|
||||
- 视觉模型 per-request model override 能力
|
||||
- 视觉标准图库路由与 admin/H1 全量可见性逻辑
|
||||
- 媒体工具函数,例如录制时长格式化与码率选择
|
||||
- 实时分析动作片段保存、成就回写和 NTRP 刷新入队逻辑
|
||||
|
||||
### 3. Go 媒体服务测试
|
||||
|
||||
@@ -39,10 +43,28 @@
|
||||
使用 Playwright。为保证稳定性:
|
||||
|
||||
- 启动本地测试服务器 `pnpm dev:test`
|
||||
- 测试服务器启动前要求数据库已完成 Drizzle 迁移
|
||||
- 通过路由拦截模拟 tRPC 和 `/media` 接口
|
||||
- 注入假媒体设备、假 `MediaRecorder` 和假 `RTCPeerConnection`
|
||||
|
||||
这样可以自动验证前端主流程,而不依赖数据库、真实摄像头权限和真实 WebRTC 网络环境。
|
||||
这样可以自动验证前端主流程,而不依赖真实摄像头权限和真实 WebRTC 网络环境。
|
||||
当前 E2E 已覆盖新的后台任务流、实时分析入口、录制焦点视图和任务中心依赖的接口 mock。
|
||||
当前 E2E 还覆盖视频库轻剪辑工作台,包括建议片段渲染、轻剪辑入口和草稿导出入口。
|
||||
|
||||
2026-03-15 新增的回归重点:
|
||||
|
||||
- 后台任务查询失败时保留最近一次成功结果
|
||||
- LLM 与 media service 外部请求超时/重试
|
||||
- worker 心跳与超限任务失败收敛
|
||||
- 实时分析动作分布、区间筛选和会话质量带
|
||||
- PC 轻剪辑循环预览、清单导出和草稿增强
|
||||
- 教程库非网球数据清洗与 `topicArea=tennis_skill` 线上返回校验
|
||||
|
||||
首次在新库或新 schema 上执行前,先跑:
|
||||
|
||||
```bash
|
||||
set -a && source .env && set +a && pnpm exec drizzle-kit migrate
|
||||
```
|
||||
|
||||
## Unified verification
|
||||
|
||||
@@ -75,6 +97,28 @@ pnpm test:llm -- "你好,做个自我介绍"
|
||||
- 适合验证 `LLM_API_KEY`、`LLM_MODEL` 和网关连通性
|
||||
- 不建议纳入 `pnpm verify`,因为它依赖外部网络和真实密钥
|
||||
|
||||
多模态链路建议额外执行一次手工 smoke test:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx -e 'import "dotenv/config"; import { invokeLLM } from "./server/_core/llm"; const result = await invokeLLM({ model: process.env.LLM_VISION_MODEL, apiUrl: process.env.LLM_VISION_API_URL, apiKey: process.env.LLM_VISION_API_KEY, messages: [{ role: "user", content: [{ type: "text", text: "请用中文一句话描述图片" }, { type: "image_url", image_url: { url: "https://..." } }] }] }); console.log(result.model, result.choices[0]?.message?.content);'
|
||||
```
|
||||
|
||||
如果返回模型与 `LLM_VISION_MODEL` 不一致,说明上游网关忽略了视觉模型选择,业务任务会自动回退到文本纠正结果。
|
||||
|
||||
视觉标准图库的真实 smoke test 可直接复用内置数据:
|
||||
|
||||
- 初始化 `ADMIN_USERNAMES=H1`
|
||||
- 登录 `H1` 后访问 `/vision-lab`
|
||||
- 检查标准图是否已经入库
|
||||
- 运行单张或批量测试,确认结果会写入 `vision_test_runs`
|
||||
- 若上游视觉网关不可用,记录应显示 `fallback`
|
||||
|
||||
2026-03-15 额外完成了多模态兼容与历史修复验证:
|
||||
|
||||
- 使用真实公网网球图片调用视觉链路,确认服务端能兼容上游返回的非标准 JSON 字段
|
||||
- 重跑历史 3 条 `fallback` 标准图记录,确认已全部转为 `visionStatus=ok`
|
||||
- Playwright 真实站点检查 `https://te.hao.work/vision-lab`,确认页面不再出现 `Cannot read properties of undefined (reading 'join')`
|
||||
|
||||
## Production smoke checks
|
||||
|
||||
部署到宿主机后,建议至少补以下联测:
|
||||
@@ -90,9 +134,35 @@ pnpm test:llm
|
||||
|
||||
- 打开 `https://te.hao.work/`
|
||||
- 打开 `https://te.hao.work/login`
|
||||
- 打开 `https://te.hao.work/checkin`
|
||||
- 打开 `https://te.hao.work/admin`(管理员)
|
||||
- 打开 `https://te.hao.work/recorder`
|
||||
- 打开 `https://te.hao.work/live-camera`
|
||||
- 确认没有 `pageerror` 或首屏 `console.error`
|
||||
|
||||
真实站点 Playwright smoke script 可直接复用:
|
||||
|
||||
```bash
|
||||
xvfb-run -a bash -lc 'cd /root/.codex/skills/playwright-skill && node run.js /tmp/playwright-te-smoke.js'
|
||||
```
|
||||
|
||||
2026-03-15 已实际完成一次真实环境联调:
|
||||
|
||||
- 初次 smoke 发现 `https://te.hao.work/checkin` 仍显示旧版“每日打卡 / 训练打卡”,确认现网落后于仓库代码
|
||||
- 执行 `docker compose up -d --build migrate app app-worker` 后再次 smoke
|
||||
- 复测 `login / checkin / videos / recorder / live-camera / admin` 全部通过,且未捕获 `pageerror` / `console.error`
|
||||
|
||||
2026-03-15 08:00 CST 又完成一轮完整验证:
|
||||
|
||||
- `pnpm check` 通过
|
||||
- `pnpm test` 通过(104/104)
|
||||
- `pnpm test:go` 通过
|
||||
- `pnpm build` 通过
|
||||
- `pnpm test:e2e` 通过(7/7)
|
||||
- `pnpm test:llm` 通过
|
||||
- 本地测试库补齐并应用 `0008_tutorial_academy_refresh` migration 后,`pnpm dev:test` 与 E2E 链路恢复正常
|
||||
- 线上 smoke 复测 `dashboard / training / videos / vision-lab / live-camera / recorder / admin` 全部通过,`issueCount: 0`
|
||||
|
||||
## Local browser prerequisites
|
||||
|
||||
首次运行 Playwright 前执行:
|
||||
@@ -106,6 +176,7 @@ pnpm exec playwright install chromium
|
||||
- E2E 目前验证的是“模块主流程是否正常”,不是媒体编码质量本身
|
||||
- 若需要真实录制验证,可额外用本地 Chrome 和真实摄像头做手工联调
|
||||
- 若 `pnpm test:e2e` 失败,优先检查:
|
||||
- 本地数据库是否已执行最新 Drizzle 迁移
|
||||
- `PORT=3100` 是否被占用
|
||||
- 浏览器依赖是否安装
|
||||
- 前端路由或测试标识是否被改动
|
||||
|
||||
@@ -1,24 +1,43 @@
|
||||
# Verified Features
|
||||
|
||||
本文档记录当前已经通过自动化验证或构建验证的项目。更新时间:2026-03-14 22:24 CST。
|
||||
本文档记录当前已经通过自动化验证或构建验证的项目。更新时间:2026-03-15 11:58 CST。
|
||||
|
||||
## 最新完整验证记录
|
||||
|
||||
- 通过命令:`pnpm verify`
|
||||
- 验证时间:2026-03-14 22:23 CST
|
||||
- 结果摘要:`pnpm check` 通过,`pnpm test` 通过(74/74),`pnpm test:go` 通过,`pnpm build` 通过,`pnpm test:e2e` 通过(5/5)
|
||||
- 验证时间:2026-03-15 07:58 - 08:14 CST
|
||||
- 结果摘要:`pnpm check`、`pnpm test`(104/104)、`pnpm test:go`、`pnpm build`、`pnpm test:e2e`(7/7)、`pnpm test:llm` 全部通过
|
||||
- 数据库状态:已执行 `set -a && source .env && set +a && pnpm exec drizzle-kit migrate`,`0010_remove_non_tennis_tutorials` 已成功应用
|
||||
|
||||
## 生产部署联测
|
||||
|
||||
| 项目 | 验证方式 | 状态 |
|
||||
|------|----------|------|
|
||||
| `https://te.hao.work/` HTTPS 访问 | `curl -I https://te.hao.work/` | 通过 |
|
||||
| `https://te.hao.work/checkin` 成就系统路由 | Playwright 登录后检查“成就系统” | 通过 |
|
||||
| `https://te.hao.work/tutorials` 教程库清洗 | Playwright smoke + `tutorial.list` 线上接口校验,仅返回 `topicArea=tennis_skill` 的 11 条网球教程 | 通过 |
|
||||
| `https://te.hao.work/tutorials` 教程标准配图 | 登录后 Playwright 检查 11 张压缩配图渲染、首图为 `/uploads/tutorials/forehand-fundamentals.webp`,并验证图片资源 `200 OK` | 通过 |
|
||||
| `https://te.hao.work/logs` 日志页访问 | `curl -I https://te.hao.work/logs` | 通过 |
|
||||
| `https://te.hao.work/vision-lab` 视觉测试页访问 | `curl -I https://te.hao.work/vision-lab` | 通过 |
|
||||
| `http://te.hao.work:8302/` 4 位端口访问 | `curl -I http://te.hao.work:8302/` | 通过 |
|
||||
| 站点 TLS 证书 | Let’s Encrypt ECDSA 证书已签发并由宿主机 nginx 加载 | 通过 |
|
||||
| 生产首页、登录页、录制页浏览器打开 | Playwright 访问 `https://te.hao.work/`、`/login`、`/recorder` | 通过 |
|
||||
| 生产登录与首次进入工作台 | Playwright 登录真实站点并跳转 `/dashboard` | 通过 |
|
||||
| 新用户邀请码校验 | Playwright 验证无邀请码被拦截、正确邀请码 `CA2026` 可创建新账号 | 通过 |
|
||||
| 日志页访问 | Playwright 以 `H1` 登录并访问 `/logs` | 通过 |
|
||||
| 生产训练 / 实时分析 / 录制 / 视频库页面加载 | Playwright 访问 `/training`、`/live-camera`、`/recorder`、`/videos` | 通过 |
|
||||
| 生产视觉标准图库页面 | Playwright 登录后访问 `/vision-lab`,未捕获 `pageerror` / `console.error` | 通过 |
|
||||
| 生产视觉历史修复 | 重跑历史 3 条 `fallback` 标准图记录后,`visionStatus` 全部恢复为 `ok` | 通过 |
|
||||
| 生产视频库轻剪辑入口 | 本地 `pnpm test:e2e` + 真实站点 `/videos` smoke | 通过 |
|
||||
| 生产视频库 CRUD | Playwright 真实站点登录 `H1` 后完成 `/videos` 新增外部视频记录、编辑标题、删除记录整链路验证 | 通过 |
|
||||
| 生产训练计划后台任务提交 | Playwright 点击训练计划生成按钮并收到后台任务反馈 | 通过 |
|
||||
| 生产移动端录制焦点视图 | Playwright 移动端视口打开 `/recorder` 并验证焦点入口与操作壳层 | 通过 |
|
||||
| 生产前端运行时异常检查 | Playwright `pageerror` / `console.error` 检查 | 通过 |
|
||||
| 媒体健康检查 | `curl http://127.0.0.1:8081/media/health` | 通过 |
|
||||
| compose 自包含服务 | `docker compose ps` 中 `app` / `db` / `media` / `worker` 正常运行,`migrate` 成功退出 | 通过 |
|
||||
| compose 自包含服务 | `docker compose ps -a` 中 `app` / `app-worker` / `db` / `media` / `media-worker` 正常运行,`migrate` 成功退出 | 通过 |
|
||||
| 生产版本追平 | `docker compose up -d --build migrate app app-worker` 后复测 `login / checkin / videos / recorder / live-camera / admin` | 通过 |
|
||||
| 全量 compose 重启 | 使用干净 worktree 执行 `docker compose up -d --build` 并确认 `app / app-worker / db / media / media-worker` 全部正常 | 通过 |
|
||||
| 生产旧 hash 静态资源回退修复 | `curl -I https://te.hao.work/assets/index-BS2QgeEv.css` 返回 `404`,不再返回 HTML | 通过 |
|
||||
| 生产后台任务刷新容错 | 任务中心与日志页在请求失败时保留最近一次成功结果,线上 smoke 未捕获页面异常 | 通过 |
|
||||
|
||||
## 构建与编译通过
|
||||
|
||||
@@ -37,13 +56,20 @@
|
||||
| plan | `pnpm test` | 通过 |
|
||||
| video | `pnpm test` | 通过 |
|
||||
| analysis | `pnpm test` | 通过 |
|
||||
| live analysis 会话保存 | `pnpm test` | 通过 |
|
||||
| record | `pnpm test` | 通过 |
|
||||
| rating | `pnpm test` | 通过 |
|
||||
| checkin | `pnpm test` | 通过 |
|
||||
| achievement | `pnpm test` | 通过 |
|
||||
| admin | `pnpm test` | 通过 |
|
||||
| checkin 兼容路由 | `pnpm test` | 通过 |
|
||||
| badge | `pnpm test` | 通过 |
|
||||
| leaderboard | `pnpm test` | 通过 |
|
||||
| tutorial / reminder / notification 路由校验 | `pnpm test` | 通过 |
|
||||
| task 后台任务路由 | `pnpm test` / `pnpm test:e2e` | 通过 |
|
||||
| 后台任务超限失败收敛 | `pnpm test` + worker 代码审查 | 通过 |
|
||||
| media 工具函数 | `pnpm test` | 通过 |
|
||||
| 媒体服务 `/media` 路径回退 | `pnpm test` | 通过 |
|
||||
| LLM / media 请求超时重试封装 | `pnpm test` / `pnpm build` | 通过 |
|
||||
| 登录 URL 回退逻辑 | `pnpm test` | 通过 |
|
||||
|
||||
## Go 媒体服务验证
|
||||
@@ -62,8 +88,15 @@
|
||||
| 仪表盘 | 认证后主标题与入口按钮渲染 | 通过 |
|
||||
| 训练计划 | 训练计划页加载与生成入口可见 | 通过 |
|
||||
| 视频库 | 视频卡片渲染 | 通过 |
|
||||
| 视频库 CRUD | 新增视频记录、编辑视频信息、删除视频记录 | 通过 |
|
||||
| 视频库轻剪辑 | 打开轻剪辑工作台、显示建议片段、展示导出草稿入口 | 通过 |
|
||||
| 视频库轻剪辑增强 | 循环预览、区间快速载入、草稿复制、cue sheet 导出 | 通过 |
|
||||
| 实时分析 | 摄像头启动入口渲染 | 通过 |
|
||||
| 实时分析打分 | 启动分析后出现实时评分结果 | 通过 |
|
||||
| 实时分析增强 | 动作分布、区间筛选、有效识别率和会话质量带渲染 | 通过 |
|
||||
| 在线录制 | 启动摄像头、开始录制、手动标记、结束归档 | 通过 |
|
||||
| 在线录制归档进度展示 | 录制页显示归档进度、已上传体积、待上传体积与片段总数 | 通过 |
|
||||
| 录制焦点视图 | 移动端最大化焦点视图与主操作按钮渲染 | 通过 |
|
||||
| 录制结果入库 | 归档完成后视频库可见录制结果 | 通过 |
|
||||
|
||||
## LLM 模块验证
|
||||
@@ -72,12 +105,21 @@
|
||||
|------|----------|------|
|
||||
| `.env` 中的 `LLM_API_URL` / `LLM_API_KEY` / `LLM_MODEL` | `pnpm test:llm` | 通过 |
|
||||
| `https://one.hao.work/v1/chat/completions` 联通性 | `pnpm test:llm` 实际返回文本 | 通过 |
|
||||
| LLM 超时与重试配置 | `pnpm build` + 真实 `pnpm test:llm` | 通过 |
|
||||
| 视觉模型独立配置路径 | `server/_core/llm.test.ts` + 手工 smoke 检查 | 通过 |
|
||||
| 视觉返回兼容解析 | `server/vision.test.ts` + 真实图片 smoke | 通过 |
|
||||
| 视觉标准图库入库 | MySQL 中 `vision_reference_images` 已写入 5 张 Commons 网球参考图 | 通过 |
|
||||
| 视觉测试结果入库 | MySQL 中 `vision_test_runs` 已写入 3 条真实测试结果,且历史 `fallback` 已修复为 `ok` | 通过 |
|
||||
| H1 全量可见性 | `H1` 用户已提升为 `admin`,可读取全部视觉测试记录;Playwright 真实站点检查通过 | 通过 |
|
||||
|
||||
## 已知非阻断警告
|
||||
|
||||
- 测试与开发日志中会出现 `OAUTH_SERVER_URL` 未配置提示;当前 mocked auth 和本地验证链路不依赖真实 OAuth 服务,因此不会导致失败
|
||||
- `pnpm build` 仍有 Vite 大 chunk 警告;当前属于性能优化待办,不影响本次产物生成
|
||||
- Playwright 运行依赖 mocked media/network,不等价于真机摄像头、真实弱网和真实 WebRTC 质量验收
|
||||
- 当前上游视觉网关可能忽略 `LLM_VISION_MODEL` 并回退为文本模型;服务端已实现自动降级,任务不会因此直接失败
|
||||
- 上游视觉网关当前返回的 `model` 仍可能显示为 `qwen3.5-plus`,且响应格式不稳定;服务端已增加兼容解析与默认值补齐,避免再次因结构差异直接降级
|
||||
- 开发服务器启动阶段仍会打印 `OAUTH_SERVER_URL` 未配置提示;当前用户名登录、mock auth 和自动化测试不受影响
|
||||
|
||||
## 当前未纳入自动验证的内容
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
CREATE TABLE `background_tasks` (
|
||||
`id` varchar(36) NOT NULL,
|
||||
`userId` int NOT NULL,
|
||||
`type` enum('media_finalize','training_plan_generate','training_plan_adjust','analysis_corrections','pose_correction_multimodal') NOT NULL,
|
||||
`status` enum('queued','running','succeeded','failed') NOT NULL DEFAULT 'queued',
|
||||
`title` varchar(256) NOT NULL,
|
||||
`message` text,
|
||||
`progress` int NOT NULL DEFAULT 0,
|
||||
`payload` json NOT NULL,
|
||||
`result` json,
|
||||
`error` text,
|
||||
`attempts` int NOT NULL DEFAULT 0,
|
||||
`maxAttempts` int NOT NULL DEFAULT 3,
|
||||
`workerId` varchar(96),
|
||||
`runAfter` timestamp NOT NULL DEFAULT (now()),
|
||||
`lockedAt` timestamp,
|
||||
`startedAt` timestamp,
|
||||
`completedAt` timestamp,
|
||||
`createdAt` timestamp NOT NULL DEFAULT (now()),
|
||||
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `background_tasks_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
@@ -0,0 +1,43 @@
|
||||
CREATE TABLE `vision_reference_images` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`slug` varchar(128) NOT NULL,
|
||||
`title` varchar(256) NOT NULL,
|
||||
`exerciseType` varchar(64) NOT NULL,
|
||||
`imageUrl` text NOT NULL,
|
||||
`sourcePageUrl` text NOT NULL,
|
||||
`sourceLabel` varchar(128) NOT NULL,
|
||||
`author` varchar(128),
|
||||
`license` varchar(128),
|
||||
`expectedFocus` json,
|
||||
`tags` json,
|
||||
`notes` text,
|
||||
`sortOrder` int NOT NULL DEFAULT 0,
|
||||
`isPublished` int NOT NULL DEFAULT 1,
|
||||
`createdAt` timestamp NOT NULL DEFAULT (now()),
|
||||
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `vision_reference_images_id` PRIMARY KEY(`id`),
|
||||
CONSTRAINT `vision_reference_images_slug_unique` UNIQUE(`slug`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `vision_test_runs` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`taskId` varchar(64) NOT NULL,
|
||||
`userId` int NOT NULL,
|
||||
`referenceImageId` int,
|
||||
`title` varchar(256) NOT NULL,
|
||||
`exerciseType` varchar(64) NOT NULL,
|
||||
`imageUrl` text NOT NULL,
|
||||
`status` enum('queued','succeeded','failed') NOT NULL DEFAULT 'queued',
|
||||
`visionStatus` enum('pending','ok','fallback','failed') NOT NULL DEFAULT 'pending',
|
||||
`configuredModel` varchar(128),
|
||||
`expectedFocus` json,
|
||||
`summary` text,
|
||||
`corrections` text,
|
||||
`report` json,
|
||||
`warning` text,
|
||||
`error` text,
|
||||
`createdAt` timestamp NOT NULL DEFAULT (now()),
|
||||
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `vision_test_runs_id` PRIMARY KEY(`id`),
|
||||
CONSTRAINT `vision_test_runs_taskId_unique` UNIQUE(`taskId`)
|
||||
);
|
||||
159
drizzle/0007_grounded_live_ops.sql
普通文件
@@ -0,0 +1,159 @@
|
||||
ALTER TABLE `training_records`
|
||||
ADD `exerciseType` varchar(64),
|
||||
ADD `sourceType` varchar(32) DEFAULT 'manual',
|
||||
ADD `sourceId` varchar(64),
|
||||
ADD `videoId` int,
|
||||
ADD `linkedPlanId` int,
|
||||
ADD `matchConfidence` float,
|
||||
ADD `actionCount` int DEFAULT 0,
|
||||
ADD `metadata` json;
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `live_analysis_sessions` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`userId` int NOT NULL,
|
||||
`title` varchar(256) NOT NULL,
|
||||
`sessionMode` enum('practice','pk') NOT NULL DEFAULT 'practice',
|
||||
`status` enum('active','completed','aborted') NOT NULL DEFAULT 'completed',
|
||||
`startedAt` timestamp NOT NULL DEFAULT (now()),
|
||||
`endedAt` timestamp,
|
||||
`durationMs` int NOT NULL DEFAULT 0,
|
||||
`dominantAction` varchar(64),
|
||||
`overallScore` float,
|
||||
`postureScore` float,
|
||||
`balanceScore` float,
|
||||
`techniqueScore` float,
|
||||
`footworkScore` float,
|
||||
`consistencyScore` float,
|
||||
`unknownActionRatio` float,
|
||||
`totalSegments` int NOT NULL DEFAULT 0,
|
||||
`effectiveSegments` int NOT NULL DEFAULT 0,
|
||||
`totalActionCount` int NOT NULL DEFAULT 0,
|
||||
`videoId` int,
|
||||
`videoUrl` text,
|
||||
`summary` text,
|
||||
`feedback` json,
|
||||
`metrics` json,
|
||||
`createdAt` timestamp NOT NULL DEFAULT (now()),
|
||||
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `live_analysis_sessions_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `live_action_segments` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`sessionId` int NOT NULL,
|
||||
`actionType` varchar(64) NOT NULL,
|
||||
`isUnknown` int NOT NULL DEFAULT 0,
|
||||
`startMs` int NOT NULL,
|
||||
`endMs` int NOT NULL,
|
||||
`durationMs` int NOT NULL,
|
||||
`confidenceAvg` float,
|
||||
`score` float,
|
||||
`peakScore` float,
|
||||
`frameCount` int NOT NULL DEFAULT 0,
|
||||
`issueSummary` json,
|
||||
`keyFrames` json,
|
||||
`clipLabel` varchar(128),
|
||||
`createdAt` timestamp NOT NULL DEFAULT (now()),
|
||||
CONSTRAINT `live_action_segments_id` PRIMARY KEY(`id`),
|
||||
CONSTRAINT `live_action_segments_session_start_idx` UNIQUE(`sessionId`,`startMs`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `daily_training_aggregates` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`dayKey` varchar(32) NOT NULL,
|
||||
`userId` int NOT NULL,
|
||||
`trainingDate` varchar(10) NOT NULL,
|
||||
`totalMinutes` int NOT NULL DEFAULT 0,
|
||||
`sessionCount` int NOT NULL DEFAULT 0,
|
||||
`analysisCount` int NOT NULL DEFAULT 0,
|
||||
`liveAnalysisCount` int NOT NULL DEFAULT 0,
|
||||
`recordingCount` int NOT NULL DEFAULT 0,
|
||||
`pkCount` int NOT NULL DEFAULT 0,
|
||||
`totalActions` int NOT NULL DEFAULT 0,
|
||||
`effectiveActions` int NOT NULL DEFAULT 0,
|
||||
`unknownActions` int NOT NULL DEFAULT 0,
|
||||
`totalScore` float NOT NULL DEFAULT 0,
|
||||
`averageScore` float NOT NULL DEFAULT 0,
|
||||
`metadata` json,
|
||||
`createdAt` timestamp NOT NULL DEFAULT (now()),
|
||||
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `daily_training_aggregates_id` PRIMARY KEY(`id`),
|
||||
CONSTRAINT `daily_training_aggregates_dayKey_unique` UNIQUE(`dayKey`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `ntrp_snapshots` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`snapshotKey` varchar(64) NOT NULL,
|
||||
`userId` int NOT NULL,
|
||||
`snapshotDate` varchar(10) NOT NULL,
|
||||
`rating` float NOT NULL,
|
||||
`triggerType` enum('analysis','daily','manual') NOT NULL DEFAULT 'daily',
|
||||
`taskId` varchar(64),
|
||||
`dimensionScores` json,
|
||||
`sourceSummary` json,
|
||||
`createdAt` timestamp NOT NULL DEFAULT (now()),
|
||||
CONSTRAINT `ntrp_snapshots_id` PRIMARY KEY(`id`),
|
||||
CONSTRAINT `ntrp_snapshots_snapshotKey_unique` UNIQUE(`snapshotKey`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `achievement_definitions` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`key` varchar(64) NOT NULL,
|
||||
`name` varchar(128) NOT NULL,
|
||||
`description` text,
|
||||
`category` varchar(32) NOT NULL,
|
||||
`rarity` varchar(16) NOT NULL DEFAULT 'common',
|
||||
`icon` varchar(16) NOT NULL DEFAULT '🎾',
|
||||
`metricKey` varchar(64) NOT NULL,
|
||||
`targetValue` float NOT NULL,
|
||||
`tier` int NOT NULL DEFAULT 1,
|
||||
`isHidden` int NOT NULL DEFAULT 0,
|
||||
`isActive` int NOT NULL DEFAULT 1,
|
||||
`sortOrder` int NOT NULL DEFAULT 0,
|
||||
`createdAt` timestamp NOT NULL DEFAULT (now()),
|
||||
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `achievement_definitions_id` PRIMARY KEY(`id`),
|
||||
CONSTRAINT `achievement_definitions_key_unique` UNIQUE(`key`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `user_achievements` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`progressKey` varchar(96) NOT NULL,
|
||||
`userId` int NOT NULL,
|
||||
`achievementKey` varchar(64) NOT NULL,
|
||||
`currentValue` float NOT NULL DEFAULT 0,
|
||||
`progressPct` float NOT NULL DEFAULT 0,
|
||||
`unlockedAt` timestamp,
|
||||
`lastEvaluatedAt` timestamp NOT NULL DEFAULT (now()),
|
||||
`createdAt` timestamp NOT NULL DEFAULT (now()),
|
||||
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `user_achievements_id` PRIMARY KEY(`id`),
|
||||
CONSTRAINT `user_achievements_progressKey_unique` UNIQUE(`progressKey`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `background_tasks`
|
||||
MODIFY COLUMN `type` enum('media_finalize','training_plan_generate','training_plan_adjust','analysis_corrections','pose_correction_multimodal','ntrp_refresh_user','ntrp_refresh_all') NOT NULL;
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `admin_audit_logs` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`adminUserId` int NOT NULL,
|
||||
`actionType` varchar(64) NOT NULL,
|
||||
`entityType` varchar(64) NOT NULL,
|
||||
`entityId` varchar(96),
|
||||
`targetUserId` int,
|
||||
`payload` json,
|
||||
`createdAt` timestamp NOT NULL DEFAULT (now()),
|
||||
CONSTRAINT `admin_audit_logs_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `app_settings` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`settingKey` varchar(64) NOT NULL,
|
||||
`label` varchar(128) NOT NULL,
|
||||
`description` text,
|
||||
`value` json,
|
||||
`createdAt` timestamp NOT NULL DEFAULT (now()),
|
||||
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `app_settings_id` PRIMARY KEY(`id`),
|
||||
CONSTRAINT `app_settings_settingKey_unique` UNIQUE(`settingKey`)
|
||||
);
|
||||
@@ -0,0 +1,8 @@
|
||||
DELETE FROM `tutorial_progress`
|
||||
WHERE `tutorialId` IN (
|
||||
SELECT `id` FROM `tutorial_videos`
|
||||
WHERE `topicArea` IS NOT NULL AND `topicArea` <> 'tennis_skill'
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DELETE FROM `tutorial_videos`
|
||||
WHERE `topicArea` IS NOT NULL AND `topicArea` <> 'tennis_skill';
|
||||
@@ -0,0 +1,17 @@
|
||||
CREATE TABLE `live_analysis_runtime` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`userId` int NOT NULL,
|
||||
`ownerSid` varchar(96),
|
||||
`status` enum('idle','active','ended') NOT NULL DEFAULT 'idle',
|
||||
`title` varchar(256),
|
||||
`sessionMode` enum('practice','pk') NOT NULL DEFAULT 'practice',
|
||||
`mediaSessionId` varchar(96),
|
||||
`startedAt` timestamp,
|
||||
`endedAt` timestamp,
|
||||
`lastHeartbeatAt` timestamp,
|
||||
`snapshot` json,
|
||||
`createdAt` timestamp NOT NULL DEFAULT (now()),
|
||||
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `live_analysis_runtime_id` PRIMARY KEY(`id`),
|
||||
CONSTRAINT `live_analysis_runtime_user_idx` UNIQUE(`userId`)
|
||||
);
|
||||
@@ -36,6 +36,55 @@
|
||||
"when": 1773490358606,
|
||||
"tag": "0004_exotic_randall",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "5",
|
||||
"when": 1773504000000,
|
||||
"tag": "0005_lively_taskmaster",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "5",
|
||||
"when": 1773510000000,
|
||||
"tag": "0006_solid_vision_library",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "5",
|
||||
"when": 1773543600000,
|
||||
"tag": "0007_grounded_live_ops",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "5",
|
||||
"when": 1773600000000,
|
||||
"tag": "0008_tutorial_academy_refresh",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "5",
|
||||
"when": 1773633600000,
|
||||
"tag": "0009_training_profile_baseline",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "5",
|
||||
"when": 1773662400000,
|
||||
"tag": "0010_remove_non_tennis_tutorials",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "5",
|
||||
"when": 1773691200000,
|
||||
"tag": "0011_live_analysis_runtime",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { int, mysqlEnum, mysqlTable, text, timestamp, varchar, json, float } from "drizzle-orm/mysql-core";
|
||||
import { int, mysqlEnum, mysqlTable, text, timestamp, varchar, json, float, uniqueIndex } from "drizzle-orm/mysql-core";
|
||||
|
||||
/**
|
||||
* Core user table - supports both OAuth and simple username login
|
||||
@@ -16,6 +16,21 @@ export const users = mysqlTable("users", {
|
||||
trainingGoals: text("trainingGoals"),
|
||||
/** NTRP rating (1.0 - 5.0) */
|
||||
ntrpRating: float("ntrpRating").default(1.5),
|
||||
/** Manual NTRP baseline before automated rating is established */
|
||||
manualNtrpRating: float("manualNtrpRating"),
|
||||
manualNtrpCapturedAt: timestamp("manualNtrpCapturedAt"),
|
||||
/** Training assessment profile */
|
||||
heightCm: float("heightCm"),
|
||||
weightKg: float("weightKg"),
|
||||
sprintSpeedScore: int("sprintSpeedScore"),
|
||||
explosivePowerScore: int("explosivePowerScore"),
|
||||
agilityScore: int("agilityScore"),
|
||||
enduranceScore: int("enduranceScore"),
|
||||
flexibilityScore: int("flexibilityScore"),
|
||||
coreStabilityScore: int("coreStabilityScore"),
|
||||
shoulderMobilityScore: int("shoulderMobilityScore"),
|
||||
hipMobilityScore: int("hipMobilityScore"),
|
||||
assessmentNotes: text("assessmentNotes"),
|
||||
/** Total training sessions completed */
|
||||
totalSessions: int("totalSessions").default(0),
|
||||
/** Total training minutes */
|
||||
@@ -152,6 +167,18 @@ export const trainingRecords = mysqlTable("training_records", {
|
||||
planId: int("planId"),
|
||||
/** Exercise name/type */
|
||||
exerciseName: varchar("exerciseName", { length: 128 }).notNull(),
|
||||
exerciseType: varchar("exerciseType", { length: 64 }),
|
||||
/** Source of the training fact */
|
||||
sourceType: varchar("sourceType", { length: 32 }).default("manual"),
|
||||
/** Reference id from source system */
|
||||
sourceId: varchar("sourceId", { length: 64 }),
|
||||
/** Optional linked video */
|
||||
videoId: int("videoId"),
|
||||
/** Optional linked plan match */
|
||||
linkedPlanId: int("linkedPlanId"),
|
||||
matchConfidence: float("matchConfidence"),
|
||||
actionCount: int("actionCount").default(0),
|
||||
metadata: json("metadata"),
|
||||
/** Duration in minutes */
|
||||
durationMinutes: int("durationMinutes"),
|
||||
/** Completion status */
|
||||
@@ -168,6 +195,118 @@ export const trainingRecords = mysqlTable("training_records", {
|
||||
export type TrainingRecord = typeof trainingRecords.$inferSelect;
|
||||
export type InsertTrainingRecord = typeof trainingRecords.$inferInsert;
|
||||
|
||||
/**
|
||||
* Live analysis sessions captured from the realtime camera workflow.
|
||||
*/
|
||||
export const liveAnalysisSessions = mysqlTable("live_analysis_sessions", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
userId: int("userId").notNull(),
|
||||
title: varchar("title", { length: 256 }).notNull(),
|
||||
sessionMode: mysqlEnum("sessionMode", ["practice", "pk"]).default("practice").notNull(),
|
||||
status: mysqlEnum("status", ["active", "completed", "aborted"]).default("completed").notNull(),
|
||||
startedAt: timestamp("startedAt").defaultNow().notNull(),
|
||||
endedAt: timestamp("endedAt"),
|
||||
durationMs: int("durationMs").default(0).notNull(),
|
||||
dominantAction: varchar("dominantAction", { length: 64 }),
|
||||
overallScore: float("overallScore"),
|
||||
postureScore: float("postureScore"),
|
||||
balanceScore: float("balanceScore"),
|
||||
techniqueScore: float("techniqueScore"),
|
||||
footworkScore: float("footworkScore"),
|
||||
consistencyScore: float("consistencyScore"),
|
||||
unknownActionRatio: float("unknownActionRatio"),
|
||||
totalSegments: int("totalSegments").default(0).notNull(),
|
||||
effectiveSegments: int("effectiveSegments").default(0).notNull(),
|
||||
totalActionCount: int("totalActionCount").default(0).notNull(),
|
||||
videoId: int("videoId"),
|
||||
videoUrl: text("videoUrl"),
|
||||
summary: text("summary"),
|
||||
feedback: json("feedback"),
|
||||
metrics: json("metrics"),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
});
|
||||
|
||||
export type LiveAnalysisSession = typeof liveAnalysisSessions.$inferSelect;
|
||||
export type InsertLiveAnalysisSession = typeof liveAnalysisSessions.$inferInsert;
|
||||
|
||||
/**
|
||||
* Per-user runtime state for the current live-camera analysis lock.
|
||||
*/
|
||||
export const liveAnalysisRuntime = mysqlTable("live_analysis_runtime", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
userId: int("userId").notNull(),
|
||||
ownerSid: varchar("ownerSid", { length: 96 }),
|
||||
status: mysqlEnum("status", ["idle", "active", "ended"]).default("idle").notNull(),
|
||||
title: varchar("title", { length: 256 }),
|
||||
sessionMode: mysqlEnum("sessionMode", ["practice", "pk"]).default("practice").notNull(),
|
||||
mediaSessionId: varchar("mediaSessionId", { length: 96 }),
|
||||
startedAt: timestamp("startedAt"),
|
||||
endedAt: timestamp("endedAt"),
|
||||
lastHeartbeatAt: timestamp("lastHeartbeatAt"),
|
||||
snapshot: json("snapshot"),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
}, (table) => ({
|
||||
userIdUnique: uniqueIndex("live_analysis_runtime_user_idx").on(table.userId),
|
||||
}));
|
||||
|
||||
export type LiveAnalysisRuntime = typeof liveAnalysisRuntime.$inferSelect;
|
||||
export type InsertLiveAnalysisRuntime = typeof liveAnalysisRuntime.$inferInsert;
|
||||
|
||||
/**
|
||||
* Action segments extracted from a realtime analysis session.
|
||||
*/
|
||||
export const liveActionSegments = mysqlTable("live_action_segments", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
sessionId: int("sessionId").notNull(),
|
||||
actionType: varchar("actionType", { length: 64 }).notNull(),
|
||||
isUnknown: int("isUnknown").default(0).notNull(),
|
||||
startMs: int("startMs").notNull(),
|
||||
endMs: int("endMs").notNull(),
|
||||
durationMs: int("durationMs").notNull(),
|
||||
confidenceAvg: float("confidenceAvg"),
|
||||
score: float("score"),
|
||||
peakScore: float("peakScore"),
|
||||
frameCount: int("frameCount").default(0).notNull(),
|
||||
issueSummary: json("issueSummary"),
|
||||
keyFrames: json("keyFrames"),
|
||||
clipLabel: varchar("clipLabel", { length: 128 }),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
}, (table) => ({
|
||||
sessionIndex: uniqueIndex("live_action_segments_session_start_idx").on(table.sessionId, table.startMs),
|
||||
}));
|
||||
|
||||
export type LiveActionSegment = typeof liveActionSegments.$inferSelect;
|
||||
export type InsertLiveActionSegment = typeof liveActionSegments.$inferInsert;
|
||||
|
||||
/**
|
||||
* Daily training aggregate used for streaks, achievements and daily NTRP refresh.
|
||||
*/
|
||||
export const dailyTrainingAggregates = mysqlTable("daily_training_aggregates", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
dayKey: varchar("dayKey", { length: 32 }).notNull().unique(),
|
||||
userId: int("userId").notNull(),
|
||||
trainingDate: varchar("trainingDate", { length: 10 }).notNull(),
|
||||
totalMinutes: int("totalMinutes").default(0).notNull(),
|
||||
sessionCount: int("sessionCount").default(0).notNull(),
|
||||
analysisCount: int("analysisCount").default(0).notNull(),
|
||||
liveAnalysisCount: int("liveAnalysisCount").default(0).notNull(),
|
||||
recordingCount: int("recordingCount").default(0).notNull(),
|
||||
pkCount: int("pkCount").default(0).notNull(),
|
||||
totalActions: int("totalActions").default(0).notNull(),
|
||||
effectiveActions: int("effectiveActions").default(0).notNull(),
|
||||
unknownActions: int("unknownActions").default(0).notNull(),
|
||||
totalScore: float("totalScore").default(0).notNull(),
|
||||
averageScore: float("averageScore").default(0).notNull(),
|
||||
metadata: json("metadata"),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
});
|
||||
|
||||
export type DailyTrainingAggregate = typeof dailyTrainingAggregates.$inferSelect;
|
||||
export type InsertDailyTrainingAggregate = typeof dailyTrainingAggregates.$inferInsert;
|
||||
|
||||
/**
|
||||
* NTRP Rating history - tracks rating changes over time
|
||||
*/
|
||||
@@ -188,6 +327,25 @@ export const ratingHistory = mysqlTable("rating_history", {
|
||||
export type RatingHistory = typeof ratingHistory.$inferSelect;
|
||||
export type InsertRatingHistory = typeof ratingHistory.$inferInsert;
|
||||
|
||||
/**
|
||||
* Daily NTRP snapshots generated by async refresh jobs.
|
||||
*/
|
||||
export const ntrpSnapshots = mysqlTable("ntrp_snapshots", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
snapshotKey: varchar("snapshotKey", { length: 64 }).notNull().unique(),
|
||||
userId: int("userId").notNull(),
|
||||
snapshotDate: varchar("snapshotDate", { length: 10 }).notNull(),
|
||||
rating: float("rating").notNull(),
|
||||
triggerType: mysqlEnum("triggerType", ["analysis", "daily", "manual"]).default("daily").notNull(),
|
||||
taskId: varchar("taskId", { length: 64 }),
|
||||
dimensionScores: json("dimensionScores"),
|
||||
sourceSummary: json("sourceSummary"),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export type NtrpSnapshot = typeof ntrpSnapshots.$inferSelect;
|
||||
export type InsertNtrpSnapshot = typeof ntrpSnapshots.$inferInsert;
|
||||
|
||||
/**
|
||||
* Daily check-in records for streak tracking
|
||||
*/
|
||||
@@ -223,20 +381,82 @@ export const userBadges = mysqlTable("user_badges", {
|
||||
export type UserBadge = typeof userBadges.$inferSelect;
|
||||
export type InsertUserBadge = typeof userBadges.$inferInsert;
|
||||
|
||||
/**
|
||||
* Achievement definitions that can scale beyond the legacy badge system.
|
||||
*/
|
||||
export const achievementDefinitions = mysqlTable("achievement_definitions", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
key: varchar("key", { length: 64 }).notNull().unique(),
|
||||
name: varchar("name", { length: 128 }).notNull(),
|
||||
description: text("description"),
|
||||
category: varchar("category", { length: 32 }).notNull(),
|
||||
rarity: varchar("rarity", { length: 16 }).default("common").notNull(),
|
||||
icon: varchar("icon", { length: 16 }).default("🎾").notNull(),
|
||||
metricKey: varchar("metricKey", { length: 64 }).notNull(),
|
||||
targetValue: float("targetValue").notNull(),
|
||||
tier: int("tier").default(1).notNull(),
|
||||
isHidden: int("isHidden").default(0).notNull(),
|
||||
isActive: int("isActive").default(1).notNull(),
|
||||
sortOrder: int("sortOrder").default(0).notNull(),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
});
|
||||
|
||||
export type AchievementDefinition = typeof achievementDefinitions.$inferSelect;
|
||||
export type InsertAchievementDefinition = typeof achievementDefinitions.$inferInsert;
|
||||
|
||||
/**
|
||||
* User achievement progress and unlock records.
|
||||
*/
|
||||
export const userAchievements = mysqlTable("user_achievements", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
progressKey: varchar("progressKey", { length: 96 }).notNull().unique(),
|
||||
userId: int("userId").notNull(),
|
||||
achievementKey: varchar("achievementKey", { length: 64 }).notNull(),
|
||||
currentValue: float("currentValue").default(0).notNull(),
|
||||
progressPct: float("progressPct").default(0).notNull(),
|
||||
unlockedAt: timestamp("unlockedAt"),
|
||||
lastEvaluatedAt: timestamp("lastEvaluatedAt").defaultNow().notNull(),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
});
|
||||
|
||||
export type UserAchievement = typeof userAchievements.$inferSelect;
|
||||
export type InsertUserAchievement = typeof userAchievements.$inferInsert;
|
||||
|
||||
/**
|
||||
* Tutorial video library - professional coaching reference videos
|
||||
*/
|
||||
export const tutorialVideos = mysqlTable("tutorial_videos", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
slug: varchar("slug", { length: 128 }),
|
||||
title: varchar("title", { length: 256 }).notNull(),
|
||||
category: varchar("category", { length: 64 }).notNull(),
|
||||
skillLevel: mysqlEnum("skillLevel", ["beginner", "intermediate", "advanced"]).default("beginner"),
|
||||
topicArea: varchar("topicArea", { length: 32 }).default("tennis_skill"),
|
||||
contentFormat: varchar("contentFormat", { length: 16 }).default("video"),
|
||||
sourcePlatform: varchar("sourcePlatform", { length: 16 }).default("none"),
|
||||
description: text("description"),
|
||||
heroSummary: text("heroSummary"),
|
||||
keyPoints: json("keyPoints"),
|
||||
commonMistakes: json("commonMistakes"),
|
||||
videoUrl: text("videoUrl"),
|
||||
externalUrl: text("externalUrl"),
|
||||
platformVideoId: varchar("platformVideoId", { length: 64 }),
|
||||
thumbnailUrl: text("thumbnailUrl"),
|
||||
duration: int("duration"),
|
||||
estimatedEffortMinutes: int("estimatedEffortMinutes"),
|
||||
prerequisites: json("prerequisites"),
|
||||
learningObjectives: json("learningObjectives"),
|
||||
stepSections: json("stepSections"),
|
||||
deliverables: json("deliverables"),
|
||||
relatedDocPaths: json("relatedDocPaths"),
|
||||
viewCount: int("viewCount"),
|
||||
commentCount: int("commentCount"),
|
||||
metricsFetchedAt: timestamp("metricsFetchedAt"),
|
||||
completionAchievementKey: varchar("completionAchievementKey", { length: 64 }),
|
||||
isFeatured: int("isFeatured").default(0),
|
||||
featuredOrder: int("featuredOrder").default(0),
|
||||
sortOrder: int("sortOrder").default(0),
|
||||
isPublished: int("isPublished").default(1),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
@@ -254,6 +474,8 @@ export const tutorialProgress = mysqlTable("tutorial_progress", {
|
||||
userId: int("userId").notNull(),
|
||||
tutorialId: int("tutorialId").notNull(),
|
||||
watched: int("watched").default(0),
|
||||
completed: int("completed").default(0),
|
||||
completedAt: timestamp("completedAt"),
|
||||
comparisonVideoId: int("comparisonVideoId"),
|
||||
selfScore: float("selfScore"),
|
||||
notes: text("notes"),
|
||||
@@ -301,3 +523,123 @@ export const notificationLog = mysqlTable("notification_log", {
|
||||
export type NotificationLogEntry = typeof notificationLog.$inferSelect;
|
||||
export type InsertNotificationLog = typeof notificationLog.$inferInsert;
|
||||
|
||||
/**
|
||||
* Background task queue for long-running or retryable work.
|
||||
*/
|
||||
export const backgroundTasks = mysqlTable("background_tasks", {
|
||||
id: varchar("id", { length: 36 }).primaryKey(),
|
||||
userId: int("userId").notNull(),
|
||||
type: mysqlEnum("type", [
|
||||
"media_finalize",
|
||||
"training_plan_generate",
|
||||
"training_plan_adjust",
|
||||
"analysis_corrections",
|
||||
"pose_correction_multimodal",
|
||||
"ntrp_refresh_user",
|
||||
"ntrp_refresh_all",
|
||||
]).notNull(),
|
||||
status: mysqlEnum("status", ["queued", "running", "succeeded", "failed"]).notNull().default("queued"),
|
||||
title: varchar("title", { length: 256 }).notNull(),
|
||||
message: text("message"),
|
||||
progress: int("progress").notNull().default(0),
|
||||
payload: json("payload").notNull(),
|
||||
result: json("result"),
|
||||
error: text("error"),
|
||||
attempts: int("attempts").notNull().default(0),
|
||||
maxAttempts: int("maxAttempts").notNull().default(3),
|
||||
workerId: varchar("workerId", { length: 96 }),
|
||||
runAfter: timestamp("runAfter").defaultNow().notNull(),
|
||||
lockedAt: timestamp("lockedAt"),
|
||||
startedAt: timestamp("startedAt"),
|
||||
completedAt: timestamp("completedAt"),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
});
|
||||
|
||||
export type BackgroundTask = typeof backgroundTasks.$inferSelect;
|
||||
export type InsertBackgroundTask = typeof backgroundTasks.$inferInsert;
|
||||
|
||||
/**
|
||||
* Admin audit trail for privileged actions.
|
||||
*/
|
||||
export const adminAuditLogs = mysqlTable("admin_audit_logs", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
adminUserId: int("adminUserId").notNull(),
|
||||
actionType: varchar("actionType", { length: 64 }).notNull(),
|
||||
entityType: varchar("entityType", { length: 64 }).notNull(),
|
||||
entityId: varchar("entityId", { length: 96 }),
|
||||
targetUserId: int("targetUserId"),
|
||||
payload: json("payload"),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export type AdminAuditLog = typeof adminAuditLogs.$inferSelect;
|
||||
export type InsertAdminAuditLog = typeof adminAuditLogs.$inferInsert;
|
||||
|
||||
/**
|
||||
* App settings editable from the admin console.
|
||||
*/
|
||||
export const appSettings = mysqlTable("app_settings", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
settingKey: varchar("settingKey", { length: 64 }).notNull().unique(),
|
||||
label: varchar("label", { length: 128 }).notNull(),
|
||||
description: text("description"),
|
||||
value: json("value"),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
});
|
||||
|
||||
export type AppSetting = typeof appSettings.$inferSelect;
|
||||
export type InsertAppSetting = typeof appSettings.$inferInsert;
|
||||
|
||||
/**
|
||||
* Vision reference library - canonical public tennis images used for multimodal evaluation
|
||||
*/
|
||||
export const visionReferenceImages = mysqlTable("vision_reference_images", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
slug: varchar("slug", { length: 128 }).notNull().unique(),
|
||||
title: varchar("title", { length: 256 }).notNull(),
|
||||
exerciseType: varchar("exerciseType", { length: 64 }).notNull(),
|
||||
imageUrl: text("imageUrl").notNull(),
|
||||
sourcePageUrl: text("sourcePageUrl").notNull(),
|
||||
sourceLabel: varchar("sourceLabel", { length: 128 }).notNull(),
|
||||
author: varchar("author", { length: 128 }),
|
||||
license: varchar("license", { length: 128 }),
|
||||
expectedFocus: json("expectedFocus"),
|
||||
tags: json("tags"),
|
||||
notes: text("notes"),
|
||||
sortOrder: int("sortOrder").default(0).notNull(),
|
||||
isPublished: int("isPublished").default(1).notNull(),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
});
|
||||
|
||||
export type VisionReferenceImage = typeof visionReferenceImages.$inferSelect;
|
||||
export type InsertVisionReferenceImage = typeof visionReferenceImages.$inferInsert;
|
||||
|
||||
/**
|
||||
* Vision test run history - records each multimodal evaluation against the standard library
|
||||
*/
|
||||
export const visionTestRuns = mysqlTable("vision_test_runs", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
taskId: varchar("taskId", { length: 64 }).notNull().unique(),
|
||||
userId: int("userId").notNull(),
|
||||
referenceImageId: int("referenceImageId"),
|
||||
title: varchar("title", { length: 256 }).notNull(),
|
||||
exerciseType: varchar("exerciseType", { length: 64 }).notNull(),
|
||||
imageUrl: text("imageUrl").notNull(),
|
||||
status: mysqlEnum("status", ["queued", "succeeded", "failed"]).default("queued").notNull(),
|
||||
visionStatus: mysqlEnum("visionStatus", ["pending", "ok", "fallback", "failed"]).default("pending").notNull(),
|
||||
configuredModel: varchar("configuredModel", { length: 128 }),
|
||||
expectedFocus: json("expectedFocus"),
|
||||
summary: text("summary"),
|
||||
corrections: text("corrections"),
|
||||
report: json("report"),
|
||||
warning: text("warning"),
|
||||
error: text("error"),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
});
|
||||
|
||||
export type VisionTestRun = typeof visionTestRuns.$inferSelect;
|
||||
export type InsertVisionTestRun = typeof visionTestRuns.$inferInsert;
|
||||
|
||||
425
media/main.go
@@ -44,6 +44,15 @@ const (
|
||||
ArchiveFailed ArchiveStatus = "failed"
|
||||
)
|
||||
|
||||
type PreviewStatus string
|
||||
|
||||
const (
|
||||
PreviewIdle PreviewStatus = "idle"
|
||||
PreviewProcessing PreviewStatus = "processing"
|
||||
PreviewReady PreviewStatus = "ready"
|
||||
PreviewFailed PreviewStatus = "failed"
|
||||
)
|
||||
|
||||
type PlaybackInfo struct {
|
||||
WebMURL string `json:"webmUrl,omitempty"`
|
||||
MP4URL string `json:"mp4Url,omitempty"`
|
||||
@@ -77,6 +86,7 @@ type Session struct {
|
||||
Title string `json:"title"`
|
||||
Status SessionStatus `json:"status"`
|
||||
ArchiveStatus ArchiveStatus `json:"archiveStatus"`
|
||||
PreviewStatus PreviewStatus `json:"previewStatus"`
|
||||
Format string `json:"format"`
|
||||
MimeType string `json:"mimeType"`
|
||||
QualityPreset string `json:"qualityPreset"`
|
||||
@@ -85,13 +95,18 @@ type Session struct {
|
||||
ReconnectCount int `json:"reconnectCount"`
|
||||
UploadedSegments int `json:"uploadedSegments"`
|
||||
UploadedBytes int64 `json:"uploadedBytes"`
|
||||
PreviewSegments int `json:"previewSegments"`
|
||||
DurationMS int64 `json:"durationMs"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
FinalizedAt string `json:"finalizedAt,omitempty"`
|
||||
PreviewUpdatedAt string `json:"previewUpdatedAt,omitempty"`
|
||||
StreamConnected bool `json:"streamConnected"`
|
||||
LastStreamAt string `json:"lastStreamAt,omitempty"`
|
||||
ViewerCount int `json:"viewerCount"`
|
||||
LiveFrameURL string `json:"liveFrameUrl,omitempty"`
|
||||
LiveFrameUpdated string `json:"liveFrameUpdatedAt,omitempty"`
|
||||
Playback PlaybackInfo `json:"playback"`
|
||||
Segments []SegmentMeta `json:"segments"`
|
||||
Markers []Marker `json:"markers"`
|
||||
@@ -139,19 +154,23 @@ type FinalizeRequest struct {
|
||||
}
|
||||
|
||||
type sessionStore struct {
|
||||
rootDir string
|
||||
public string
|
||||
mu sync.RWMutex
|
||||
sessions map[string]*Session
|
||||
peers map[string]*webrtc.PeerConnection
|
||||
rootDir string
|
||||
public string
|
||||
mu sync.RWMutex
|
||||
sessions map[string]*Session
|
||||
peers map[string]*webrtc.PeerConnection
|
||||
viewerPeers map[string]map[string]*webrtc.PeerConnection
|
||||
videoTracks map[string]*webrtc.TrackLocalStaticRTP
|
||||
}
|
||||
|
||||
func newSessionStore(rootDir string) (*sessionStore, error) {
|
||||
store := &sessionStore{
|
||||
rootDir: rootDir,
|
||||
public: filepath.Join(rootDir, "public"),
|
||||
sessions: map[string]*Session{},
|
||||
peers: map[string]*webrtc.PeerConnection{},
|
||||
rootDir: rootDir,
|
||||
public: filepath.Join(rootDir, "public"),
|
||||
sessions: map[string]*Session{},
|
||||
peers: map[string]*webrtc.PeerConnection{},
|
||||
viewerPeers: map[string]map[string]*webrtc.PeerConnection{},
|
||||
videoTracks: map[string]*webrtc.TrackLocalStaticRTP{},
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(rootDir, "sessions"), 0o755); err != nil {
|
||||
return nil, err
|
||||
@@ -159,7 +178,7 @@ func newSessionStore(rootDir string) (*sessionStore, error) {
|
||||
if err := os.MkdirAll(store.public, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := store.load(); err != nil {
|
||||
if err := store.refreshFromDisk(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, session := range store.sessions {
|
||||
@@ -168,12 +187,13 @@ func newSessionStore(rootDir string) (*sessionStore, error) {
|
||||
return store, nil
|
||||
}
|
||||
|
||||
func (s *sessionStore) load() error {
|
||||
func (s *sessionStore) loadSessionsFromDisk() (map[string]*Session, error) {
|
||||
pattern := filepath.Join(s.rootDir, "sessions", "*", "session.json")
|
||||
files, err := filepath.Glob(pattern)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
sessions := make(map[string]*Session, len(files))
|
||||
for _, file := range files {
|
||||
body, readErr := os.ReadFile(file)
|
||||
if readErr != nil {
|
||||
@@ -183,8 +203,19 @@ func (s *sessionStore) load() error {
|
||||
if unmarshalErr := json.Unmarshal(body, &session); unmarshalErr != nil {
|
||||
continue
|
||||
}
|
||||
s.sessions[session.ID] = &session
|
||||
sessions[session.ID] = &session
|
||||
}
|
||||
return sessions, nil
|
||||
}
|
||||
|
||||
func (s *sessionStore) refreshFromDisk() error {
|
||||
sessions, err := s.loadSessionsFromDisk()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.sessions = sessions
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -200,6 +231,14 @@ func (s *sessionStore) publicDir(id string) string {
|
||||
return filepath.Join(s.public, "sessions", id)
|
||||
}
|
||||
|
||||
func (s *sessionStore) liveFramePath(id string) string {
|
||||
return filepath.Join(s.publicDir(id), "live-frame.jpg")
|
||||
}
|
||||
|
||||
func (s *sessionStore) liveFrameURL(id string) string {
|
||||
return fmt.Sprintf("/media/assets/sessions/%s/live-frame.jpg", id)
|
||||
}
|
||||
|
||||
func (s *sessionStore) saveSession(session *Session) error {
|
||||
session.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
dir := s.sessionDir(session.ID)
|
||||
@@ -228,6 +267,7 @@ func (s *sessionStore) createSession(input CreateSessionRequest) (*Session, erro
|
||||
Title: strings.TrimSpace(input.Title),
|
||||
Status: StatusCreated,
|
||||
ArchiveStatus: ArchiveIdle,
|
||||
PreviewStatus: PreviewIdle,
|
||||
Format: defaultString(input.Format, "webm"),
|
||||
MimeType: defaultString(input.MimeType, "video/webm"),
|
||||
QualityPreset: defaultString(input.QualityPreset, "balanced"),
|
||||
@@ -269,6 +309,42 @@ func (s *sessionStore) replacePeer(id string, peer *webrtc.PeerConnection) {
|
||||
s.peers[id] = peer
|
||||
}
|
||||
|
||||
func (s *sessionStore) replaceViewerPeer(sessionID string, viewerID string, peer *webrtc.PeerConnection) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.viewerPeers[sessionID]; !ok {
|
||||
s.viewerPeers[sessionID] = map[string]*webrtc.PeerConnection{}
|
||||
}
|
||||
if existing, ok := s.viewerPeers[sessionID][viewerID]; ok {
|
||||
_ = existing.Close()
|
||||
}
|
||||
s.viewerPeers[sessionID][viewerID] = peer
|
||||
if session, ok := s.sessions[sessionID]; ok {
|
||||
session.ViewerCount = len(s.viewerPeers[sessionID])
|
||||
_ = s.saveSession(session)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *sessionStore) removeViewerPeer(sessionID string, viewerID string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
viewers, ok := s.viewerPeers[sessionID]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if existing, ok := viewers[viewerID]; ok {
|
||||
_ = existing.Close()
|
||||
delete(viewers, viewerID)
|
||||
}
|
||||
if len(viewers) == 0 {
|
||||
delete(s.viewerPeers, sessionID)
|
||||
}
|
||||
if session, ok := s.sessions[sessionID]; ok {
|
||||
session.ViewerCount = len(s.viewerPeers[sessionID])
|
||||
_ = s.saveSession(session)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *sessionStore) closePeer(id string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
@@ -276,6 +352,38 @@ func (s *sessionStore) closePeer(id string) {
|
||||
_ = existing.Close()
|
||||
delete(s.peers, id)
|
||||
}
|
||||
if viewers, ok := s.viewerPeers[id]; ok {
|
||||
for viewerID, peer := range viewers {
|
||||
_ = peer.Close()
|
||||
delete(viewers, viewerID)
|
||||
}
|
||||
delete(s.viewerPeers, id)
|
||||
}
|
||||
delete(s.videoTracks, id)
|
||||
if session, ok := s.sessions[id]; ok {
|
||||
session.ViewerCount = 0
|
||||
_ = s.saveSession(session)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *sessionStore) getVideoTrack(sessionID string) *webrtc.TrackLocalStaticRTP {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.videoTracks[sessionID]
|
||||
}
|
||||
|
||||
func (s *sessionStore) ensureVideoTrack(sessionID string, codec webrtc.RTPCodecCapability) (*webrtc.TrackLocalStaticRTP, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if track, ok := s.videoTracks[sessionID]; ok {
|
||||
return track, nil
|
||||
}
|
||||
track, err := webrtc.NewTrackLocalStaticRTP(codec, "video", fmt.Sprintf("livecam-%s", sessionID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.videoTracks[sessionID] = track
|
||||
return track, nil
|
||||
}
|
||||
|
||||
func (s *sessionStore) updateSession(id string, update func(*Session) error) (*Session, error) {
|
||||
@@ -295,13 +403,20 @@ func (s *sessionStore) updateSession(id string, update func(*Session) error) (*S
|
||||
return cloneSession(session), nil
|
||||
}
|
||||
|
||||
func (s *sessionStore) listFinalizingSessions() []*Session {
|
||||
func (s *sessionStore) listProcessableSessions() []*Session {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
items := make([]*Session, 0, len(s.sessions))
|
||||
for _, session := range s.sessions {
|
||||
if len(session.Segments) == 0 {
|
||||
continue
|
||||
}
|
||||
if session.ArchiveStatus == ArchiveQueued || session.ArchiveStatus == ArchiveProcessing {
|
||||
items = append(items, cloneSession(session))
|
||||
continue
|
||||
}
|
||||
if session.PreviewSegments < len(session.Segments) && session.PreviewStatus != PreviewProcessing {
|
||||
items = append(items, cloneSession(session))
|
||||
}
|
||||
}
|
||||
return items
|
||||
@@ -315,6 +430,10 @@ func newMediaServer(store *sessionStore) *mediaServer {
|
||||
return &mediaServer{store: store}
|
||||
}
|
||||
|
||||
func (m *mediaServer) refreshSessionsForRead() error {
|
||||
return m.store.refreshFromDisk()
|
||||
}
|
||||
|
||||
func (m *mediaServer) routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/media/health", m.handleHealth)
|
||||
@@ -359,6 +478,10 @@ func (m *mediaServer) handleSession(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
sessionID := parts[0]
|
||||
if len(parts) == 1 && r.Method == http.MethodGet {
|
||||
if err := m.refreshSessionsForRead(); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
session, err := m.store.getSession(sessionID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
@@ -379,12 +502,24 @@ func (m *mediaServer) handleSession(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
m.handleSignal(sessionID, w, r)
|
||||
case "viewer-signal":
|
||||
if r.Method != http.MethodPost {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
m.handleViewerSignal(sessionID, w, r)
|
||||
case "segments":
|
||||
if r.Method != http.MethodPost {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
m.handleSegmentUpload(sessionID, w, r)
|
||||
case "live-frame":
|
||||
if r.Method != http.MethodPost {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
m.handleLiveFrameUpload(sessionID, w, r)
|
||||
case "markers":
|
||||
if r.Method != http.MethodPost {
|
||||
http.NotFound(w, r)
|
||||
@@ -402,6 +537,10 @@ func (m *mediaServer) handleSession(w http.ResponseWriter, r *http.Request) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if err := m.refreshSessionsForRead(); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
session, err := m.store.getSession(sessionID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
@@ -465,12 +604,23 @@ func (m *mediaServer) handleSignal(sessionID string, w http.ResponseWriter, r *h
|
||||
|
||||
peer.OnTrack(func(track *webrtc.TrackRemote, receiver *webrtc.RTPReceiver) {
|
||||
_ = receiver
|
||||
if track.Kind() != webrtc.RTPCodecTypeVideo {
|
||||
return
|
||||
}
|
||||
localTrack, trackErr := m.store.ensureVideoTrack(sessionID, track.Codec().RTPCodecCapability)
|
||||
if trackErr != nil {
|
||||
log.Printf("failed to create local viewer track for session %s: %v", sessionID, trackErr)
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
buffer := make([]byte, 1600)
|
||||
for {
|
||||
if _, _, readErr := track.Read(buffer); readErr != nil {
|
||||
packet, _, readErr := track.ReadRTP()
|
||||
if readErr != nil {
|
||||
return
|
||||
}
|
||||
if writeErr := localTrack.WriteRTP(packet); writeErr != nil && !errors.Is(writeErr, io.ErrClosedPipe) {
|
||||
log.Printf("failed to fan out RTP packet for session %s: %v", sessionID, writeErr)
|
||||
}
|
||||
_, _ = m.store.updateSession(sessionID, func(session *Session) error {
|
||||
session.StreamConnected = true
|
||||
session.Status = StatusStreaming
|
||||
@@ -512,6 +662,139 @@ func (m *mediaServer) handleSignal(sessionID string, w http.ResponseWriter, r *h
|
||||
})
|
||||
}
|
||||
|
||||
func (m *mediaServer) handleViewerSignal(sessionID string, w http.ResponseWriter, r *http.Request) {
|
||||
var input SignalRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if _, err := m.store.getSession(sessionID); err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
localTrack := m.store.getVideoTrack(sessionID)
|
||||
if localTrack == nil {
|
||||
writeError(w, http.StatusConflict, "viewer stream not ready")
|
||||
return
|
||||
}
|
||||
|
||||
config := webrtc.Configuration{
|
||||
ICEServers: []webrtc.ICEServer{{URLs: []string{"stun:stun.l.google.com:19302"}}},
|
||||
}
|
||||
peer, err := webrtc.NewPeerConnection(config)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to create viewer peer connection")
|
||||
return
|
||||
}
|
||||
viewerID := randomID()
|
||||
m.store.replaceViewerPeer(sessionID, viewerID, peer)
|
||||
|
||||
sender, err := peer.AddTrack(localTrack)
|
||||
if err != nil {
|
||||
m.store.removeViewerPeer(sessionID, viewerID)
|
||||
writeError(w, http.StatusInternalServerError, "failed to add viewer track")
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
rtcpBuf := make([]byte, 1500)
|
||||
for {
|
||||
if _, _, readErr := sender.Read(rtcpBuf); readErr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
peer.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
|
||||
switch state {
|
||||
case webrtc.PeerConnectionStateDisconnected, webrtc.PeerConnectionStateFailed, webrtc.PeerConnectionStateClosed:
|
||||
m.store.removeViewerPeer(sessionID, viewerID)
|
||||
}
|
||||
})
|
||||
|
||||
offer := webrtc.SessionDescription{
|
||||
Type: parseSDPType(input.Type),
|
||||
SDP: input.SDP,
|
||||
}
|
||||
if err := peer.SetRemoteDescription(offer); err != nil {
|
||||
m.store.removeViewerPeer(sessionID, viewerID)
|
||||
writeError(w, http.StatusBadRequest, "failed to set remote description")
|
||||
return
|
||||
}
|
||||
|
||||
answer, err := peer.CreateAnswer(nil)
|
||||
if err != nil {
|
||||
m.store.removeViewerPeer(sessionID, viewerID)
|
||||
writeError(w, http.StatusInternalServerError, "failed to create viewer answer")
|
||||
return
|
||||
}
|
||||
gatherComplete := webrtc.GatheringCompletePromise(peer)
|
||||
if err := peer.SetLocalDescription(answer); err != nil {
|
||||
m.store.removeViewerPeer(sessionID, viewerID)
|
||||
writeError(w, http.StatusInternalServerError, "failed to set viewer local description")
|
||||
return
|
||||
}
|
||||
<-gatherComplete
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"viewerId": viewerID,
|
||||
"type": strings.ToLower(peer.LocalDescription().Type.String()),
|
||||
"sdp": peer.LocalDescription().SDP,
|
||||
})
|
||||
}
|
||||
|
||||
func (m *mediaServer) handleLiveFrameUpload(sessionID string, w http.ResponseWriter, r *http.Request) {
|
||||
if _, err := m.store.getSession(sessionID); err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
body := http.MaxBytesReader(w, r.Body, 4<<20)
|
||||
defer body.Close()
|
||||
|
||||
frame, err := io.ReadAll(body)
|
||||
if err != nil || len(frame) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "invalid live frame payload")
|
||||
return
|
||||
}
|
||||
|
||||
publicDir := m.store.publicDir(sessionID)
|
||||
if err := os.MkdirAll(publicDir, 0o755); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to create live frame directory")
|
||||
return
|
||||
}
|
||||
|
||||
tmpFile := filepath.Join(publicDir, fmt.Sprintf("live-frame-%s.tmp", randomID()))
|
||||
if err := os.WriteFile(tmpFile, frame, 0o644); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to write live frame")
|
||||
return
|
||||
}
|
||||
defer os.Remove(tmpFile)
|
||||
|
||||
finalFile := m.store.liveFramePath(sessionID)
|
||||
if err := os.Rename(tmpFile, finalFile); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to publish live frame")
|
||||
return
|
||||
}
|
||||
|
||||
session, err := m.store.updateSession(sessionID, func(session *Session) error {
|
||||
session.LiveFrameURL = m.store.liveFrameURL(sessionID)
|
||||
session.LiveFrameUpdated = time.Now().UTC().Format(time.RFC3339)
|
||||
session.StreamConnected = true
|
||||
session.LastStreamAt = session.LiveFrameUpdated
|
||||
if session.Status == StatusCreated || session.Status == StatusReconnecting {
|
||||
session.Status = StatusStreaming
|
||||
}
|
||||
session.LastError = ""
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to update live frame session state")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{"session": session})
|
||||
}
|
||||
|
||||
func (m *mediaServer) handleSegmentUpload(sessionID string, w http.ResponseWriter, r *http.Request) {
|
||||
sequence, err := strconv.Atoi(r.URL.Query().Get("sequence"))
|
||||
if err != nil || sequence < 0 {
|
||||
@@ -632,7 +915,11 @@ func runWorkerLoop(ctx context.Context, store *sessionStore, interval time.Durat
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
sessions := store.listFinalizingSessions()
|
||||
if err := store.refreshFromDisk(); err != nil {
|
||||
log.Printf("[worker] failed to refresh session store: %v", err)
|
||||
continue
|
||||
}
|
||||
sessions := store.listProcessableSessions()
|
||||
for _, session := range sessions {
|
||||
if err := processSession(store, session.ID); err != nil {
|
||||
log.Printf("[worker] failed to process session %s: %v", session.ID, err)
|
||||
@@ -643,6 +930,42 @@ func runWorkerLoop(ctx context.Context, store *sessionStore, interval time.Durat
|
||||
}
|
||||
|
||||
func processSession(store *sessionStore, sessionID string) error {
|
||||
current, err := store.getSession(sessionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if current.ArchiveStatus == ArchiveQueued || current.ArchiveStatus == ArchiveProcessing {
|
||||
return processFinalArchive(store, sessionID)
|
||||
}
|
||||
|
||||
if current.PreviewSegments < len(current.Segments) {
|
||||
return processRollingPreview(store, sessionID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func processRollingPreview(store *sessionStore, sessionID string) error {
|
||||
session, err := store.updateSession(sessionID, func(session *Session) error {
|
||||
if session.PreviewStatus == PreviewProcessing {
|
||||
return errors.New("preview already processing")
|
||||
}
|
||||
session.PreviewStatus = PreviewProcessing
|
||||
session.LastError = ""
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "preview already processing") {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
return buildPlaybackArtifacts(store, session, false)
|
||||
}
|
||||
|
||||
func processFinalArchive(store *sessionStore, sessionID string) error {
|
||||
session, err := store.updateSession(sessionID, func(session *Session) error {
|
||||
if session.ArchiveStatus == ArchiveProcessing {
|
||||
return errors.New("already processing")
|
||||
@@ -668,12 +991,22 @@ func processSession(store *sessionStore, sessionID string) error {
|
||||
return errors.New("no uploaded segments found")
|
||||
}
|
||||
|
||||
return buildPlaybackArtifacts(store, session, true)
|
||||
}
|
||||
|
||||
func buildPlaybackArtifacts(store *sessionStore, session *Session, finalize bool) error {
|
||||
sessionID := session.ID
|
||||
|
||||
publicDir := store.publicDir(sessionID)
|
||||
if err := os.MkdirAll(publicDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
outputWebM := filepath.Join(publicDir, "recording.webm")
|
||||
outputMP4 := filepath.Join(publicDir, "recording.mp4")
|
||||
baseName := "preview"
|
||||
if finalize {
|
||||
baseName = "recording"
|
||||
}
|
||||
outputWebM := filepath.Join(publicDir, baseName+".webm")
|
||||
outputMP4 := filepath.Join(publicDir, baseName+".mp4")
|
||||
listFile := filepath.Join(store.sessionDir(sessionID), "concat.txt")
|
||||
|
||||
inputs := make([]string, 0, len(session.Segments))
|
||||
@@ -684,23 +1017,23 @@ func processSession(store *sessionStore, sessionID string) error {
|
||||
inputs = append(inputs, filepath.Join(store.segmentsDir(sessionID), segment.Filename))
|
||||
}
|
||||
if err := writeConcatList(listFile, inputs); err != nil {
|
||||
return markArchiveError(store, sessionID, err)
|
||||
return markProcessingError(store, sessionID, err, finalize)
|
||||
}
|
||||
|
||||
if len(inputs) == 1 {
|
||||
body, copyErr := os.ReadFile(inputs[0])
|
||||
if copyErr != nil {
|
||||
return markArchiveError(store, sessionID, copyErr)
|
||||
return markProcessingError(store, sessionID, copyErr, finalize)
|
||||
}
|
||||
if writeErr := os.WriteFile(outputWebM, body, 0o644); writeErr != nil {
|
||||
return markArchiveError(store, sessionID, writeErr)
|
||||
return markProcessingError(store, sessionID, writeErr, finalize)
|
||||
}
|
||||
} else {
|
||||
copyErr := runFFmpeg("-y", "-f", "concat", "-safe", "0", "-i", listFile, "-c", "copy", outputWebM)
|
||||
if copyErr != nil {
|
||||
reencodeErr := runFFmpeg("-y", "-f", "concat", "-safe", "0", "-i", listFile, "-c:v", "libvpx-vp9", "-b:v", "1800k", "-c:a", "libopus", outputWebM)
|
||||
if reencodeErr != nil {
|
||||
return markArchiveError(store, sessionID, fmt.Errorf("concat failed: %w / %v", copyErr, reencodeErr))
|
||||
return markProcessingError(store, sessionID, fmt.Errorf("concat failed: %w / %v", copyErr, reencodeErr), finalize)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -712,7 +1045,7 @@ func processSession(store *sessionStore, sessionID string) error {
|
||||
|
||||
webmInfo, webmStatErr := os.Stat(outputWebM)
|
||||
if webmStatErr != nil {
|
||||
return markArchiveError(store, sessionID, webmStatErr)
|
||||
return markProcessingError(store, sessionID, webmStatErr, finalize)
|
||||
}
|
||||
var mp4Size int64
|
||||
var mp4URL string
|
||||
@@ -720,27 +1053,41 @@ func processSession(store *sessionStore, sessionID string) error {
|
||||
mp4Size = info.Size()
|
||||
mp4URL = fmt.Sprintf("/media/assets/sessions/%s/recording.mp4", sessionID)
|
||||
}
|
||||
_, err = store.updateSession(sessionID, func(session *Session) error {
|
||||
session.ArchiveStatus = ArchiveCompleted
|
||||
session.Status = StatusArchived
|
||||
session.Playback = PlaybackInfo{
|
||||
WebMURL: fmt.Sprintf("/media/assets/sessions/%s/recording.webm", sessionID),
|
||||
MP4URL: mp4URL,
|
||||
WebMSize: webmInfo.Size(),
|
||||
MP4Size: mp4Size,
|
||||
Ready: true,
|
||||
PreviewURL: fmt.Sprintf("/media/assets/sessions/%s/recording.webm", sessionID),
|
||||
}
|
||||
previewURL := fmt.Sprintf("/media/assets/sessions/%s/%s.webm", sessionID, baseName)
|
||||
if mp4URL != "" {
|
||||
previewURL = mp4URL
|
||||
}
|
||||
|
||||
_, updateErr := store.updateSession(sessionID, func(session *Session) error {
|
||||
session.Playback.PreviewURL = previewURL
|
||||
session.PreviewSegments = len(inputs)
|
||||
session.PreviewUpdatedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
session.PreviewStatus = PreviewReady
|
||||
session.LastError = ""
|
||||
if finalize {
|
||||
session.ArchiveStatus = ArchiveCompleted
|
||||
session.Status = StatusArchived
|
||||
session.Playback = PlaybackInfo{
|
||||
WebMURL: fmt.Sprintf("/media/assets/sessions/%s/recording.webm", sessionID),
|
||||
MP4URL: mp4URL,
|
||||
WebMSize: webmInfo.Size(),
|
||||
MP4Size: mp4Size,
|
||||
Ready: true,
|
||||
PreviewURL: previewURL,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return err
|
||||
return updateErr
|
||||
}
|
||||
|
||||
func markArchiveError(store *sessionStore, sessionID string, err error) error {
|
||||
func markProcessingError(store *sessionStore, sessionID string, err error, finalize bool) error {
|
||||
_, _ = store.updateSession(sessionID, func(session *Session) error {
|
||||
session.ArchiveStatus = ArchiveFailed
|
||||
session.Status = StatusFailed
|
||||
session.PreviewStatus = PreviewFailed
|
||||
if finalize {
|
||||
session.ArchiveStatus = ArchiveFailed
|
||||
session.Status = StatusFailed
|
||||
}
|
||||
session.LastError = err.Error()
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -128,3 +129,194 @@ func TestProcessSessionArchivesPlayback(t *testing.T) {
|
||||
t.Fatalf("expected webm playback url, got %#v", archived.Playback)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshFromDiskPicksUpSessionsCreatedAfterWorkerStartup(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
workerStore, err := newSessionStore(tempDir)
|
||||
if err != nil {
|
||||
t.Fatalf("newSessionStore(worker): %v", err)
|
||||
}
|
||||
if got := len(workerStore.listProcessableSessions()); got != 0 {
|
||||
t.Fatalf("expected no processable sessions at startup, got %d", got)
|
||||
}
|
||||
|
||||
appStore, err := newSessionStore(tempDir)
|
||||
if err != nil {
|
||||
t.Fatalf("newSessionStore(app): %v", err)
|
||||
}
|
||||
|
||||
session, err := appStore.createSession(CreateSessionRequest{UserID: "1", Title: "Queued Session"})
|
||||
if err != nil {
|
||||
t.Fatalf("createSession: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(appStore.segmentsDir(session.ID), "000000.webm"), []byte("segment"), 0o644); err != nil {
|
||||
t.Fatalf("write segment: %v", err)
|
||||
}
|
||||
if _, err := appStore.updateSession(session.ID, func(current *Session) error {
|
||||
current.Segments = append(current.Segments, SegmentMeta{
|
||||
Sequence: 0,
|
||||
Filename: "000000.webm",
|
||||
DurationMS: 60000,
|
||||
SizeBytes: 7,
|
||||
ContentType: "video/webm",
|
||||
})
|
||||
current.ArchiveStatus = ArchiveQueued
|
||||
current.Status = StatusFinalizing
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("updateSession: %v", err)
|
||||
}
|
||||
|
||||
if err := workerStore.refreshFromDisk(); err != nil {
|
||||
t.Fatalf("refreshFromDisk: %v", err)
|
||||
}
|
||||
|
||||
processable := workerStore.listProcessableSessions()
|
||||
if len(processable) != 1 {
|
||||
t.Fatalf("expected worker to pick up queued session after refresh, got %d", len(processable))
|
||||
}
|
||||
if processable[0].ID != session.ID {
|
||||
t.Fatalf("expected session %s, got %s", session.ID, processable[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSessionGetRefreshesSessionStateFromDisk(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
serverStore, err := newSessionStore(tempDir)
|
||||
if err != nil {
|
||||
t.Fatalf("newSessionStore(server): %v", err)
|
||||
}
|
||||
server := newMediaServer(serverStore)
|
||||
|
||||
writerStore, err := newSessionStore(tempDir)
|
||||
if err != nil {
|
||||
t.Fatalf("newSessionStore(writer): %v", err)
|
||||
}
|
||||
|
||||
session, err := writerStore.createSession(CreateSessionRequest{UserID: "1", Title: "Fresh Session"})
|
||||
if err != nil {
|
||||
t.Fatalf("createSession: %v", err)
|
||||
}
|
||||
if _, err := writerStore.updateSession(session.ID, func(current *Session) error {
|
||||
current.Status = StatusFinalizing
|
||||
current.ArchiveStatus = ArchiveQueued
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("queue session: %v", err)
|
||||
}
|
||||
|
||||
getReq := httptest.NewRequest(http.MethodGet, "/media/sessions/"+session.ID, nil)
|
||||
getRes := httptest.NewRecorder()
|
||||
server.routes().ServeHTTP(getRes, getReq)
|
||||
if getRes.Code != http.StatusOK {
|
||||
t.Fatalf("expected get session 200, got %d", getRes.Code)
|
||||
}
|
||||
|
||||
var queuedResponse struct {
|
||||
Session Session `json:"session"`
|
||||
}
|
||||
if err := json.NewDecoder(getRes.Body).Decode(&queuedResponse); err != nil {
|
||||
t.Fatalf("decode queued response: %v", err)
|
||||
}
|
||||
if queuedResponse.Session.ArchiveStatus != ArchiveQueued {
|
||||
t.Fatalf("expected queued archive status, got %s", queuedResponse.Session.ArchiveStatus)
|
||||
}
|
||||
|
||||
if _, err := writerStore.updateSession(session.ID, func(current *Session) error {
|
||||
current.Status = StatusArchived
|
||||
current.ArchiveStatus = ArchiveCompleted
|
||||
current.Playback = PlaybackInfo{
|
||||
WebMURL: "/media/assets/sessions/" + session.ID + "/recording.webm",
|
||||
Ready: true,
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("complete session: %v", err)
|
||||
}
|
||||
|
||||
refreshReq := httptest.NewRequest(http.MethodGet, "/media/sessions/"+session.ID, nil)
|
||||
refreshRes := httptest.NewRecorder()
|
||||
server.routes().ServeHTTP(refreshRes, refreshReq)
|
||||
if refreshRes.Code != http.StatusOK {
|
||||
t.Fatalf("expected refreshed get session 200, got %d", refreshRes.Code)
|
||||
}
|
||||
|
||||
var completedResponse struct {
|
||||
Session Session `json:"session"`
|
||||
}
|
||||
if err := json.NewDecoder(refreshRes.Body).Decode(&completedResponse); err != nil {
|
||||
t.Fatalf("decode completed response: %v", err)
|
||||
}
|
||||
if completedResponse.Session.ArchiveStatus != ArchiveCompleted {
|
||||
t.Fatalf("expected completed archive status, got %s", completedResponse.Session.ArchiveStatus)
|
||||
}
|
||||
if !completedResponse.Session.Playback.Ready {
|
||||
t.Fatalf("expected playback ready after refresh")
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewerSignalReturnsConflictBeforePublisherTrackReady(t *testing.T) {
|
||||
store, err := newSessionStore(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("newSessionStore: %v", err)
|
||||
}
|
||||
|
||||
server := newMediaServer(store)
|
||||
session, err := store.createSession(CreateSessionRequest{UserID: "1", Title: "Viewer Pending"})
|
||||
if err != nil {
|
||||
t.Fatalf("createSession: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/media/sessions/"+session.ID+"/viewer-signal", strings.NewReader(`{"type":"offer","sdp":"mock-offer"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
server.routes().ServeHTTP(res, req)
|
||||
|
||||
if res.Code != http.StatusConflict {
|
||||
t.Fatalf("expected viewer-signal 409 before video track is ready, got %d", res.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLiveFrameUploadPublishesRelayFrame(t *testing.T) {
|
||||
store, err := newSessionStore(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("newSessionStore: %v", err)
|
||||
}
|
||||
|
||||
server := newMediaServer(store)
|
||||
session, err := store.createSession(CreateSessionRequest{UserID: "1", Title: "Relay Session"})
|
||||
if err != nil {
|
||||
t.Fatalf("createSession: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/media/sessions/"+session.ID+"/live-frame", strings.NewReader("jpeg-frame"))
|
||||
req.Header.Set("Content-Type", "image/jpeg")
|
||||
res := httptest.NewRecorder()
|
||||
server.routes().ServeHTTP(res, req)
|
||||
|
||||
if res.Code != http.StatusAccepted {
|
||||
t.Fatalf("expected live-frame upload 202, got %d", res.Code)
|
||||
}
|
||||
|
||||
current, err := store.getSession(session.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("getSession: %v", err)
|
||||
}
|
||||
if current.LiveFrameURL == "" || current.LiveFrameUpdated == "" {
|
||||
t.Fatalf("expected live frame metadata to be recorded, got %#v", current)
|
||||
}
|
||||
if !current.StreamConnected {
|
||||
t.Fatalf("expected session stream connected after frame upload")
|
||||
}
|
||||
|
||||
framePath := store.liveFramePath(session.ID)
|
||||
body, err := os.ReadFile(framePath)
|
||||
if err != nil {
|
||||
t.Fatalf("read live frame: %v", err)
|
||||
}
|
||||
if string(body) != "jpeg-frame" {
|
||||
t.Fatalf("unexpected live frame content: %q", string(body))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
"scripts": {
|
||||
"dev": "NODE_ENV=development tsx watch server/_core/index.ts",
|
||||
"dev:test": "PORT=41731 STRICT_PORT=1 VITE_APP_ID=test-app VITE_OAUTH_PORTAL_URL=http://127.0.0.1:41731 NODE_ENV=development tsx server/_core/index.ts",
|
||||
"build": "vite build && esbuild server/_core/index.ts --platform=node --packages=external --bundle --format=esm --outdir=dist",
|
||||
"start": "NODE_ENV=production node dist/index.js",
|
||||
"build": "vite build && esbuild server/_core/index.ts server/worker.ts --platform=node --packages=external --bundle --format=esm --outdir=dist",
|
||||
"start": "NODE_ENV=production node dist/_core/index.js",
|
||||
"start:worker": "NODE_ENV=production node dist/worker.js",
|
||||
"check": "tsc --noEmit",
|
||||
"format": "prettier --write .",
|
||||
"test": "vitest run",
|
||||
|
||||
@@ -6,23 +6,29 @@ export type TrpcContext = {
|
||||
req: CreateExpressContextOptions["req"];
|
||||
res: CreateExpressContextOptions["res"];
|
||||
user: User | null;
|
||||
sessionSid: string | null;
|
||||
};
|
||||
|
||||
export async function createContext(
|
||||
opts: CreateExpressContextOptions
|
||||
): Promise<TrpcContext> {
|
||||
let user: User | null = null;
|
||||
let sessionSid: string | null = null;
|
||||
|
||||
try {
|
||||
user = await sdk.authenticateRequest(opts.req);
|
||||
const authenticated = await sdk.authenticateRequestWithSession(opts.req);
|
||||
user = authenticated.user;
|
||||
sessionSid = authenticated.sid;
|
||||
} catch (error) {
|
||||
// Authentication is optional for public procedures.
|
||||
user = null;
|
||||
sessionSid = null;
|
||||
}
|
||||
|
||||
return {
|
||||
req: opts.req,
|
||||
res: opts.res,
|
||||
user,
|
||||
sessionSid,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,12 +9,21 @@ const parseBoolean = (value: string | undefined, fallback: boolean) => {
|
||||
return value === "1" || value.toLowerCase() === "true";
|
||||
};
|
||||
|
||||
const parseList = (value: string | undefined) =>
|
||||
(value ?? "")
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
export const ENV = {
|
||||
appId: process.env.VITE_APP_ID ?? "",
|
||||
appPublicBaseUrl: process.env.APP_PUBLIC_BASE_URL ?? "",
|
||||
cookieSecret: process.env.JWT_SECRET ?? "",
|
||||
databaseUrl: process.env.DATABASE_URL ?? "",
|
||||
registrationInviteCode: process.env.REGISTRATION_INVITE_CODE ?? "CA2026",
|
||||
oAuthServerUrl: process.env.OAUTH_SERVER_URL ?? "",
|
||||
ownerOpenId: process.env.OWNER_OPEN_ID ?? "",
|
||||
adminUsernames: parseList(process.env.ADMIN_USERNAMES),
|
||||
isProduction: process.env.NODE_ENV === "production",
|
||||
forgeApiUrl: process.env.BUILT_IN_FORGE_API_URL ?? "",
|
||||
forgeApiKey: process.env.BUILT_IN_FORGE_API_KEY ?? "",
|
||||
@@ -27,7 +36,28 @@ export const ENV = {
|
||||
llmApiKey:
|
||||
process.env.LLM_API_KEY ?? process.env.BUILT_IN_FORGE_API_KEY ?? "",
|
||||
llmModel: process.env.LLM_MODEL ?? "gemini-2.5-flash",
|
||||
llmVisionApiUrl:
|
||||
process.env.LLM_VISION_API_URL ??
|
||||
process.env.LLM_API_URL ??
|
||||
(process.env.BUILT_IN_FORGE_API_URL
|
||||
? `${process.env.BUILT_IN_FORGE_API_URL.replace(/\/$/, "")}/v1/chat/completions`
|
||||
: ""),
|
||||
llmVisionApiKey:
|
||||
process.env.LLM_VISION_API_KEY ??
|
||||
process.env.LLM_API_KEY ??
|
||||
process.env.BUILT_IN_FORGE_API_KEY ??
|
||||
"",
|
||||
llmVisionModel: process.env.LLM_VISION_MODEL ?? process.env.LLM_MODEL ?? "gemini-2.5-flash",
|
||||
llmMaxTokens: parseInteger(process.env.LLM_MAX_TOKENS, 32768),
|
||||
llmEnableThinking: parseBoolean(process.env.LLM_ENABLE_THINKING, false),
|
||||
llmThinkingBudget: parseInteger(process.env.LLM_THINKING_BUDGET, 128),
|
||||
llmTimeoutMs: parseInteger(process.env.LLM_TIMEOUT_MS, 45000),
|
||||
llmRetryCount: parseInteger(process.env.LLM_RETRY_COUNT, 1),
|
||||
mediaServiceUrl: process.env.MEDIA_SERVICE_URL ?? "",
|
||||
mediaFetchTimeoutMs: parseInteger(process.env.MEDIA_FETCH_TIMEOUT_MS, 12000),
|
||||
mediaFetchRetryCount: parseInteger(process.env.MEDIA_FETCH_RETRY_COUNT, 2),
|
||||
youtubeApiKey: process.env.YOUTUBE_API_KEY ?? "",
|
||||
backgroundTaskPollMs: parseInteger(process.env.BACKGROUND_TASK_POLL_MS, 3000),
|
||||
backgroundTaskStaleMs: parseInteger(process.env.BACKGROUND_TASK_STALE_MS, 300000),
|
||||
backgroundTaskHeartbeatMs: parseInteger(process.env.BACKGROUND_TASK_HEARTBEAT_MS, 5000),
|
||||
};
|
||||
|
||||
85
server/_core/fetch.ts
普通文件
@@ -0,0 +1,85 @@
|
||||
type FetchRetryOptions = {
|
||||
timeoutMs: number;
|
||||
retries?: number;
|
||||
retryStatuses?: number[];
|
||||
retryMethods?: string[];
|
||||
baseDelayMs?: number;
|
||||
};
|
||||
|
||||
const DEFAULT_RETRY_STATUSES = [408, 425, 429, 502, 503, 504];
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function shouldRetryResponse(method: string, response: Response, options: FetchRetryOptions) {
|
||||
const allowedMethods = options.retryMethods ?? ["GET", "HEAD"];
|
||||
const retryStatuses = options.retryStatuses ?? DEFAULT_RETRY_STATUSES;
|
||||
return allowedMethods.includes(method) && retryStatuses.includes(response.status);
|
||||
}
|
||||
|
||||
function shouldRetryError(method: string, error: unknown, options: FetchRetryOptions) {
|
||||
const allowedMethods = options.retryMethods ?? ["GET", "HEAD"];
|
||||
if (!allowedMethods.includes(method)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return error.name === "AbortError" || error.name === "TimeoutError" || error.message.includes("fetch");
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function fetchWithTimeout(input: string | URL, init: RequestInit | undefined, options: FetchRetryOptions) {
|
||||
const method = (init?.method ?? "GET").toUpperCase();
|
||||
const retries = Math.max(0, options.retries ?? 0);
|
||||
const baseDelayMs = Math.max(150, options.baseDelayMs ?? 350);
|
||||
let lastError: unknown;
|
||||
|
||||
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
||||
const controller = new AbortController();
|
||||
const upstreamSignal = init?.signal;
|
||||
let didTimeout = false;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
didTimeout = true;
|
||||
controller.abort();
|
||||
}, options.timeoutMs);
|
||||
|
||||
const abortHandler = () => controller.abort();
|
||||
upstreamSignal?.addEventListener("abort", abortHandler, { once: true });
|
||||
|
||||
try {
|
||||
const response = await fetch(input, {
|
||||
...init,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (attempt < retries && shouldRetryResponse(method, response, options)) {
|
||||
await response.text().catch(() => undefined);
|
||||
await sleep(baseDelayMs * (attempt + 1));
|
||||
continue;
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (didTimeout) {
|
||||
lastError = new Error(`Request timed out after ${options.timeoutMs}ms`);
|
||||
} else {
|
||||
lastError = error;
|
||||
}
|
||||
|
||||
if (attempt >= retries || !shouldRetryError(method, lastError, options)) {
|
||||
throw lastError instanceof Error ? lastError : new Error("Request failed");
|
||||
}
|
||||
|
||||
await sleep(baseDelayMs * (attempt + 1));
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
upstreamSignal?.removeEventListener("abort", abortHandler);
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError instanceof Error ? lastError : new Error("Request failed");
|
||||
}
|
||||
@@ -9,6 +9,60 @@ import { appRouter } from "../routers";
|
||||
import { createContext } from "./context";
|
||||
import { registerMediaProxy } from "./mediaProxy";
|
||||
import { serveStatic } from "./static";
|
||||
import { createBackgroundTask, getAdminUserId, hasRecentBackgroundTaskOfType, seedAchievementDefinitions, seedAppSettings, seedTutorials, seedVisionReferenceImages } from "../db";
|
||||
import { nanoid } from "nanoid";
|
||||
import { syncTutorialImages } from "../tutorialImages";
|
||||
|
||||
async function warmupApplicationData() {
|
||||
const tasks: Array<{ label: string; run: () => Promise<unknown> }> = [
|
||||
{ label: "seedTutorials", run: () => seedTutorials() },
|
||||
{ label: "syncTutorialImages", run: () => syncTutorialImages() },
|
||||
{ label: "seedVisionReferenceImages", run: () => seedVisionReferenceImages() },
|
||||
{ label: "seedAchievementDefinitions", run: () => seedAchievementDefinitions() },
|
||||
{ label: "seedAppSettings", run: () => seedAppSettings() },
|
||||
];
|
||||
|
||||
for (const task of tasks) {
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
await task.run();
|
||||
console.log(`[startup] ${task.label} finished in ${Date.now() - startedAt}ms`);
|
||||
} catch (error) {
|
||||
console.error(`[startup] ${task.label} failed`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function scheduleDailyNtrpRefresh() {
|
||||
const now = new Date();
|
||||
if (now.getHours() !== 0 || now.getMinutes() > 5) {
|
||||
return;
|
||||
}
|
||||
|
||||
const midnight = new Date();
|
||||
midnight.setHours(0, 0, 0, 0);
|
||||
const exists = await hasRecentBackgroundTaskOfType("ntrp_refresh_all", midnight);
|
||||
if (exists) {
|
||||
return;
|
||||
}
|
||||
|
||||
const adminUserId = await getAdminUserId();
|
||||
if (!adminUserId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const taskId = nanoid();
|
||||
await createBackgroundTask({
|
||||
id: taskId,
|
||||
userId: adminUserId,
|
||||
type: "ntrp_refresh_all",
|
||||
title: "每日 NTRP 刷新",
|
||||
message: "系统已自动创建每日 NTRP 刷新任务",
|
||||
payload: { source: "scheduler", scheduledAt: now.toISOString() },
|
||||
progress: 0,
|
||||
maxAttempts: 3,
|
||||
});
|
||||
}
|
||||
|
||||
function isPortAvailable(port: number): Promise<boolean> {
|
||||
return new Promise(resolve => {
|
||||
@@ -68,7 +122,14 @@ async function startServer() {
|
||||
|
||||
server.listen(port, () => {
|
||||
console.log(`Server running on http://localhost:${port}/`);
|
||||
void warmupApplicationData();
|
||||
});
|
||||
|
||||
setInterval(() => {
|
||||
void scheduleDailyNtrpRefresh().catch((error) => {
|
||||
console.error("[scheduler] failed to schedule NTRP refresh", error);
|
||||
});
|
||||
}, 60_000);
|
||||
}
|
||||
|
||||
startServer().catch(console.error);
|
||||
|
||||
@@ -68,6 +68,29 @@ describe("invokeLLM", () => {
|
||||
expect(JSON.parse(request.body)).not.toHaveProperty("thinking");
|
||||
});
|
||||
|
||||
it("allows overriding the model per request", async () => {
|
||||
process.env.LLM_API_URL = "https://one.hao.work/v1/chat/completions";
|
||||
process.env.LLM_API_KEY = "test-key";
|
||||
process.env.LLM_MODEL = "qwen3.5-plus";
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockSuccessResponse,
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const { invokeLLM } = await import("./llm");
|
||||
await invokeLLM({
|
||||
model: "qwen3-vl-235b-a22b",
|
||||
messages: [{ role: "user", content: "describe image" }],
|
||||
});
|
||||
|
||||
const [, request] = fetchMock.mock.calls[0] as [string, { body: string }];
|
||||
expect(JSON.parse(request.body)).toMatchObject({
|
||||
model: "qwen3-vl-235b-a22b",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to legacy forge variables when LLM_* values are absent", async () => {
|
||||
delete process.env.LLM_API_URL;
|
||||
delete process.env.LLM_API_KEY;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ENV } from "./env";
|
||||
import { fetchWithTimeout } from "./fetch";
|
||||
|
||||
export type Role = "system" | "user" | "assistant" | "tool" | "function";
|
||||
|
||||
@@ -57,6 +58,9 @@ export type ToolChoice =
|
||||
|
||||
export type InvokeParams = {
|
||||
messages: Message[];
|
||||
model?: string;
|
||||
apiUrl?: string;
|
||||
apiKey?: string;
|
||||
tools?: Tool[];
|
||||
toolChoice?: ToolChoice;
|
||||
tool_choice?: ToolChoice;
|
||||
@@ -209,13 +213,15 @@ const normalizeToolChoice = (
|
||||
return toolChoice;
|
||||
};
|
||||
|
||||
const resolveApiUrl = () =>
|
||||
ENV.llmApiUrl && ENV.llmApiUrl.trim().length > 0
|
||||
const resolveApiUrl = (apiUrl?: string) =>
|
||||
apiUrl && apiUrl.trim().length > 0
|
||||
? apiUrl
|
||||
: ENV.llmApiUrl && ENV.llmApiUrl.trim().length > 0
|
||||
? ENV.llmApiUrl
|
||||
: "https://forge.manus.im/v1/chat/completions";
|
||||
|
||||
const assertApiKey = () => {
|
||||
if (!ENV.llmApiKey) {
|
||||
const assertApiKey = (apiKey?: string) => {
|
||||
if (!(apiKey || ENV.llmApiKey)) {
|
||||
throw new Error("LLM_API_KEY is not configured");
|
||||
}
|
||||
};
|
||||
@@ -266,10 +272,13 @@ const normalizeResponseFormat = ({
|
||||
};
|
||||
|
||||
export async function invokeLLM(params: InvokeParams): Promise<InvokeResult> {
|
||||
assertApiKey();
|
||||
assertApiKey(params.apiKey);
|
||||
|
||||
const {
|
||||
messages,
|
||||
model,
|
||||
apiUrl,
|
||||
apiKey,
|
||||
tools,
|
||||
toolChoice,
|
||||
tool_choice,
|
||||
@@ -280,7 +289,7 @@ export async function invokeLLM(params: InvokeParams): Promise<InvokeResult> {
|
||||
} = params;
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
model: ENV.llmModel,
|
||||
model: model || ENV.llmModel,
|
||||
messages: messages.map(normalizeMessage),
|
||||
};
|
||||
|
||||
@@ -315,13 +324,17 @@ export async function invokeLLM(params: InvokeParams): Promise<InvokeResult> {
|
||||
payload.response_format = normalizedResponseFormat;
|
||||
}
|
||||
|
||||
const response = await fetch(resolveApiUrl(), {
|
||||
const response = await fetchWithTimeout(resolveApiUrl(apiUrl), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
authorization: `Bearer ${ENV.llmApiKey}`,
|
||||
authorization: `Bearer ${apiKey || ENV.llmApiKey}`,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
}, {
|
||||
timeoutMs: ENV.llmTimeoutMs,
|
||||
retries: ENV.llmRetryCount,
|
||||
retryMethods: ["POST"],
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
57
server/_core/sdk.test.ts
普通文件
@@ -0,0 +1,57 @@
|
||||
import { SignJWT } from "jose";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
async function loadSdkForTest() {
|
||||
process.env.JWT_SECRET = "test-cookie-secret";
|
||||
process.env.VITE_APP_ID = "test-app";
|
||||
vi.resetModules();
|
||||
|
||||
const [{ sdk }, { ENV }] = await Promise.all([
|
||||
import("./sdk"),
|
||||
import("./env"),
|
||||
]);
|
||||
|
||||
return { sdk, ENV };
|
||||
}
|
||||
|
||||
async function signLegacyToken(openId: string, appId: string, name: string) {
|
||||
const secret = new TextEncoder().encode(process.env.JWT_SECRET || "");
|
||||
return new SignJWT({
|
||||
openId,
|
||||
appId,
|
||||
name,
|
||||
})
|
||||
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
|
||||
.setExpirationTime(Math.floor((Date.now() + 60_000) / 1000))
|
||||
.sign(secret);
|
||||
}
|
||||
|
||||
describe("sdk.verifySession", () => {
|
||||
it("derives a stable legacy sid when the token payload does not include sid", async () => {
|
||||
const { sdk, ENV } = await loadSdkForTest();
|
||||
const legacyToken = await signLegacyToken("username_H1_legacy", ENV.appId, "H1");
|
||||
|
||||
const session = await sdk.verifySession(legacyToken);
|
||||
|
||||
expect(session).not.toBeNull();
|
||||
expect(session?.sid).toMatch(/^legacy-token:/);
|
||||
expect(session?.sid).toHaveLength("legacy-token:".length + 32);
|
||||
});
|
||||
|
||||
it("derives different legacy sid values for different legacy login tokens", async () => {
|
||||
const firstLoad = await loadSdkForTest();
|
||||
const tokenA = await signLegacyToken("username_H1_legacy", firstLoad.ENV.appId, "H1");
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
|
||||
const secondLoad = await loadSdkForTest();
|
||||
const tokenB = await signLegacyToken("username_H1_legacy", secondLoad.ENV.appId, "H1-second");
|
||||
|
||||
const sessionA = await firstLoad.sdk.verifySession(tokenA);
|
||||
const sessionB = await secondLoad.sdk.verifySession(tokenB);
|
||||
|
||||
expect(sessionA?.sid).toMatch(/^legacy-token:/);
|
||||
expect(sessionB?.sid).toMatch(/^legacy-token:/);
|
||||
expect(sessionA?.sid).not.toBe(sessionB?.sid);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import axios, { type AxiosInstance } from "axios";
|
||||
import { parse as parseCookieHeader } from "cookie";
|
||||
import type { Request } from "express";
|
||||
import { SignJWT, jwtVerify } from "jose";
|
||||
import { createHash } from "node:crypto";
|
||||
import type { User } from "../../drizzle/schema";
|
||||
import * as db from "../db";
|
||||
import { ENV } from "./env";
|
||||
@@ -21,7 +22,8 @@ const isNonEmptyString = (value: unknown): value is string =>
|
||||
export type SessionPayload = {
|
||||
openId: string;
|
||||
appId: string;
|
||||
name: string;
|
||||
name?: string;
|
||||
sid?: string;
|
||||
};
|
||||
|
||||
const EXCHANGE_TOKEN_PATH = `/webdev.v1.WebDevAuthPublicService/ExchangeToken`;
|
||||
@@ -173,6 +175,7 @@ class SDKServer {
|
||||
openId,
|
||||
appId: ENV.appId,
|
||||
name: options.name || "",
|
||||
sid: crypto.randomUUID(),
|
||||
},
|
||||
options
|
||||
);
|
||||
@@ -190,7 +193,8 @@ class SDKServer {
|
||||
return new SignJWT({
|
||||
openId: payload.openId,
|
||||
appId: payload.appId,
|
||||
name: payload.name,
|
||||
name: payload.name || "",
|
||||
sid: payload.sid || crypto.randomUUID(),
|
||||
})
|
||||
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
|
||||
.setExpirationTime(expirationSeconds)
|
||||
@@ -199,7 +203,7 @@ class SDKServer {
|
||||
|
||||
async verifySession(
|
||||
cookieValue: string | undefined | null
|
||||
): Promise<{ openId: string; appId: string; name: string } | null> {
|
||||
): Promise<{ openId: string; appId: string; name?: string; sid?: string } | null> {
|
||||
if (!cookieValue) {
|
||||
console.warn("[Auth] Missing session cookie");
|
||||
return null;
|
||||
@@ -210,21 +214,25 @@ class SDKServer {
|
||||
const { payload } = await jwtVerify(cookieValue, secretKey, {
|
||||
algorithms: ["HS256"],
|
||||
});
|
||||
const { openId, appId, name } = payload as Record<string, unknown>;
|
||||
const { openId, appId, name, sid } = payload as Record<string, unknown>;
|
||||
|
||||
if (
|
||||
!isNonEmptyString(openId) ||
|
||||
!isNonEmptyString(appId) ||
|
||||
!isNonEmptyString(name)
|
||||
!isNonEmptyString(appId)
|
||||
) {
|
||||
console.warn("[Auth] Session payload missing required fields");
|
||||
return null;
|
||||
}
|
||||
|
||||
const derivedSid = typeof sid === "string" && sid.length > 0
|
||||
? sid
|
||||
: `legacy-token:${createHash("sha256").update(cookieValue).digest("hex").slice(0, 32)}`;
|
||||
|
||||
return {
|
||||
openId,
|
||||
appId,
|
||||
name,
|
||||
name: typeof name === "string" ? name : undefined,
|
||||
sid: derivedSid,
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn("[Auth] Session verification failed", String(error));
|
||||
@@ -257,7 +265,11 @@ class SDKServer {
|
||||
}
|
||||
|
||||
async authenticateRequest(req: Request): Promise<User> {
|
||||
// Regular authentication flow
|
||||
const authenticated = await this.authenticateRequestWithSession(req);
|
||||
return authenticated.user;
|
||||
}
|
||||
|
||||
async authenticateRequestWithSession(req: Request): Promise<{ user: User; sid: string | null }> {
|
||||
const cookies = this.parseCookies(req.headers.cookie);
|
||||
const sessionCookie = cookies.get(COOKIE_NAME);
|
||||
const session = await this.verifySession(sessionCookie);
|
||||
@@ -270,7 +282,6 @@ class SDKServer {
|
||||
const signedInAt = new Date();
|
||||
let user = await db.getUserByOpenId(sessionUserId);
|
||||
|
||||
// If user not in DB, sync from OAuth server automatically
|
||||
if (!user) {
|
||||
try {
|
||||
const userInfo = await this.getUserInfoWithJwt(sessionCookie ?? "");
|
||||
@@ -297,7 +308,10 @@ class SDKServer {
|
||||
lastSignedIn: signedInAt,
|
||||
});
|
||||
|
||||
return user;
|
||||
return {
|
||||
user,
|
||||
sid: session.sid ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,16 +6,24 @@ export function serveStatic(app: Express) {
|
||||
const distPath =
|
||||
process.env.NODE_ENV === "development"
|
||||
? path.resolve(import.meta.dirname, "../..", "dist", "public")
|
||||
: path.resolve(import.meta.dirname, "public");
|
||||
: path.resolve(import.meta.dirname, "..", "public");
|
||||
if (!fs.existsSync(distPath)) {
|
||||
console.error(
|
||||
`Could not find the build directory: ${distPath}, make sure to build the client first`
|
||||
);
|
||||
}
|
||||
|
||||
app.use(express.static(distPath));
|
||||
app.use(express.static(distPath, { index: false }));
|
||||
|
||||
app.use("*", (req, res) => {
|
||||
// Missing files under /assets or any path with an extension must return 404.
|
||||
// Falling back to index.html causes browsers to report MIME errors on stale chunks.
|
||||
const requestPath = req.originalUrl.split("?")[0];
|
||||
if (path.extname(requestPath)) {
|
||||
res.status(404).type("text/plain").send("Not found");
|
||||
return;
|
||||
}
|
||||
|
||||
app.use("*", (_req, res) => {
|
||||
res.sendFile(path.resolve(distPath, "index.html"));
|
||||
});
|
||||
}
|
||||
|
||||
1770
server/db.ts
@@ -1,7 +1,11 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { appRouter } from "./routers";
|
||||
import { COOKIE_NAME } from "../shared/const";
|
||||
import type { TrpcContext } from "./_core/context";
|
||||
import * as db from "./db";
|
||||
import * as trainingAutomation from "./trainingAutomation";
|
||||
import { ENV } from "./_core/env";
|
||||
import { sdk } from "./_core/sdk";
|
||||
|
||||
type AuthenticatedUser = NonNullable<TrpcContext["user"]>;
|
||||
|
||||
@@ -16,6 +20,19 @@ function createTestUser(overrides?: Partial<AuthenticatedUser>): AuthenticatedUs
|
||||
skillLevel: "beginner",
|
||||
trainingGoals: null,
|
||||
ntrpRating: 1.5,
|
||||
manualNtrpRating: null,
|
||||
manualNtrpCapturedAt: null,
|
||||
heightCm: null,
|
||||
weightKg: null,
|
||||
sprintSpeedScore: null,
|
||||
explosivePowerScore: null,
|
||||
agilityScore: null,
|
||||
enduranceScore: null,
|
||||
flexibilityScore: null,
|
||||
coreStabilityScore: null,
|
||||
shoulderMobilityScore: null,
|
||||
hipMobilityScore: null,
|
||||
assessmentNotes: null,
|
||||
totalSessions: 0,
|
||||
totalMinutes: 0,
|
||||
totalShots: 0,
|
||||
@@ -28,7 +45,7 @@ function createTestUser(overrides?: Partial<AuthenticatedUser>): AuthenticatedUs
|
||||
};
|
||||
}
|
||||
|
||||
function createMockContext(user: AuthenticatedUser | null = null): {
|
||||
function createMockContext(user: AuthenticatedUser | null = null, sessionSid = "test-session-sid"): {
|
||||
ctx: TrpcContext;
|
||||
clearedCookies: { name: string; options: Record<string, unknown> }[];
|
||||
setCookies: { name: string; value: string; options: Record<string, unknown> }[];
|
||||
@@ -39,6 +56,7 @@ function createMockContext(user: AuthenticatedUser | null = null): {
|
||||
return {
|
||||
ctx: {
|
||||
user,
|
||||
sessionSid: user ? sessionSid : null,
|
||||
req: {
|
||||
protocol: "https",
|
||||
headers: {},
|
||||
@@ -97,6 +115,28 @@ describe("auth.logout", () => {
|
||||
path: "/",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses lax non-secure cookies for plain http requests", async () => {
|
||||
const user = createTestUser();
|
||||
const { ctx, clearedCookies } = createMockContext(user);
|
||||
ctx.req = {
|
||||
protocol: "http",
|
||||
headers: {},
|
||||
} as TrpcContext["req"];
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
|
||||
const result = await caller.auth.logout();
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(clearedCookies).toHaveLength(1);
|
||||
expect(clearedCookies[0]?.options).toMatchObject({
|
||||
maxAge: -1,
|
||||
secure: false,
|
||||
sameSite: "lax",
|
||||
httpOnly: true,
|
||||
path: "/",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("auth.loginWithUsername input validation", () => {
|
||||
@@ -113,6 +153,68 @@ describe("auth.loginWithUsername input validation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("auth.loginWithUsername invite flow", () => {
|
||||
const originalInviteCode = ENV.registrationInviteCode;
|
||||
|
||||
beforeEach(() => {
|
||||
ENV.registrationInviteCode = "CA2026";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
ENV.registrationInviteCode = originalInviteCode;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("allows existing users to log in without an invite code", async () => {
|
||||
const existingUser = createTestUser({ name: "ExistingPlayer", openId: "existing-1" });
|
||||
const { ctx, setCookies } = createMockContext(null);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
|
||||
vi.spyOn(db, "getUserByUsername").mockResolvedValueOnce(existingUser);
|
||||
const createUsernameAccountSpy = vi.spyOn(db, "createUsernameAccount").mockResolvedValueOnce({
|
||||
user: existingUser,
|
||||
isNew: false,
|
||||
});
|
||||
vi.spyOn(sdk, "createSessionToken").mockResolvedValueOnce("session-token");
|
||||
|
||||
const result = await caller.auth.loginWithUsername({ username: "ExistingPlayer" });
|
||||
|
||||
expect(result.isNew).toBe(false);
|
||||
expect(createUsernameAccountSpy).toHaveBeenCalledWith("ExistingPlayer", undefined);
|
||||
expect(setCookies[0]?.name).toBe(COOKIE_NAME);
|
||||
});
|
||||
|
||||
it("rejects new users without the correct invite code", async () => {
|
||||
const { ctx } = createMockContext(null);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
|
||||
vi.spyOn(db, "getUserByUsername").mockResolvedValueOnce(undefined);
|
||||
const createUsernameAccountSpy = vi.spyOn(db, "createUsernameAccount");
|
||||
|
||||
await expect(caller.auth.loginWithUsername({ username: "NewPlayer" })).rejects.toThrow("新用户注册需要正确的邀请码");
|
||||
expect(createUsernameAccountSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows new users with the correct invite code", async () => {
|
||||
const newUser = createTestUser({ name: "NewPlayer", openId: "new-1" });
|
||||
const { ctx, setCookies } = createMockContext(null);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
|
||||
vi.spyOn(db, "getUserByUsername").mockResolvedValueOnce(undefined);
|
||||
const createUsernameAccountSpy = vi.spyOn(db, "createUsernameAccount").mockResolvedValueOnce({
|
||||
user: newUser,
|
||||
isNew: true,
|
||||
});
|
||||
vi.spyOn(sdk, "createSessionToken").mockResolvedValueOnce("session-token");
|
||||
|
||||
const result = await caller.auth.loginWithUsername({ username: "NewPlayer", inviteCode: "CA2026" });
|
||||
|
||||
expect(result.isNew).toBe(true);
|
||||
expect(createUsernameAccountSpy).toHaveBeenCalledWith("NewPlayer", "CA2026");
|
||||
expect(setCookies[0]?.name).toBe(COOKIE_NAME);
|
||||
});
|
||||
});
|
||||
|
||||
// ===== PROFILE TESTS =====
|
||||
|
||||
describe("profile.stats", () => {
|
||||
@@ -151,6 +253,30 @@ describe("profile.update input validation", () => {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts training assessment fields", async () => {
|
||||
const user = createTestUser();
|
||||
const { ctx } = createMockContext(user);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
|
||||
try {
|
||||
await caller.profile.update({
|
||||
heightCm: 178,
|
||||
weightKg: 68,
|
||||
sprintSpeedScore: 4,
|
||||
explosivePowerScore: 3,
|
||||
agilityScore: 4,
|
||||
enduranceScore: 3,
|
||||
flexibilityScore: 3,
|
||||
coreStabilityScore: 4,
|
||||
shoulderMobilityScore: 3,
|
||||
hipMobilityScore: 4,
|
||||
manualNtrpRating: 2.5,
|
||||
});
|
||||
} catch (e: any) {
|
||||
expect(e.message).not.toContain("invalid_type");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ===== TRAINING PLAN TESTS =====
|
||||
@@ -193,6 +319,19 @@ describe("plan.generate input validation", () => {
|
||||
caller.plan.generate({ skillLevel: "beginner", durationDays: 7 })
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("rejects generation when training profile is incomplete", async () => {
|
||||
const user = createTestUser();
|
||||
const { ctx } = createMockContext(user);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
|
||||
vi.spyOn(db, "getUserById").mockResolvedValueOnce(user);
|
||||
vi.spyOn(db, "getLatestNtrpSnapshot").mockResolvedValueOnce(null as any);
|
||||
|
||||
await expect(
|
||||
caller.plan.generate({ skillLevel: "beginner", durationDays: 7 })
|
||||
).rejects.toThrow(/训练计划生成前请先完善训练档案/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("plan.list", () => {
|
||||
@@ -209,6 +348,17 @@ describe("plan.active", () => {
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
await expect(caller.plan.active()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("returns null when the user has no active plan", async () => {
|
||||
const user = createTestUser();
|
||||
const { ctx } = createMockContext(user);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
const getActivePlanSpy = vi.spyOn(db, "getActivePlan").mockResolvedValueOnce(null);
|
||||
|
||||
await expect(caller.plan.active()).resolves.toBeNull();
|
||||
|
||||
getActivePlanSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("plan.adjust input validation", () => {
|
||||
@@ -299,6 +449,152 @@ describe("video.get input validation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("video.get", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns the current user's video", async () => {
|
||||
const user = createTestUser({ id: 42 });
|
||||
const { ctx } = createMockContext(user);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
const createdAt = new Date("2026-03-15T06:00:00.000Z");
|
||||
|
||||
vi.spyOn(db, "getUserVideoById").mockResolvedValueOnce({
|
||||
id: 9,
|
||||
userId: 42,
|
||||
title: "Forehand Session",
|
||||
fileKey: "videos/42/forehand.mp4",
|
||||
url: "https://cdn.example.com/videos/42/forehand.mp4",
|
||||
format: "mp4",
|
||||
fileSize: 1024,
|
||||
duration: 12,
|
||||
exerciseType: "forehand",
|
||||
analysisStatus: "completed",
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
} as any);
|
||||
|
||||
const result = await caller.video.get({ videoId: 9 });
|
||||
|
||||
expect(result.title).toBe("Forehand Session");
|
||||
expect(db.getUserVideoById).toHaveBeenCalledWith(42, 9);
|
||||
});
|
||||
|
||||
it("throws not found for videos outside the current user scope", async () => {
|
||||
const user = createTestUser({ id: 42 });
|
||||
const { ctx } = createMockContext(user);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
|
||||
vi.spyOn(db, "getUserVideoById").mockResolvedValueOnce(undefined);
|
||||
|
||||
await expect(caller.video.get({ videoId: 999 })).rejects.toThrow("视频不存在");
|
||||
});
|
||||
});
|
||||
|
||||
describe("video.update input validation", () => {
|
||||
it("requires authentication", async () => {
|
||||
const { ctx } = createMockContext(null);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
|
||||
await expect(
|
||||
caller.video.update({ videoId: 1, title: "updated title" })
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("rejects empty title", async () => {
|
||||
const user = createTestUser();
|
||||
const { ctx } = createMockContext(user);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
|
||||
await expect(
|
||||
caller.video.update({ videoId: 1, title: "" })
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("video.update", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("updates the current user's video metadata", async () => {
|
||||
const user = createTestUser({ id: 7 });
|
||||
const { ctx } = createMockContext(user);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
|
||||
const updateSpy = vi.spyOn(db, "updateUserVideo").mockResolvedValueOnce(true);
|
||||
|
||||
const result = await caller.video.update({
|
||||
videoId: 14,
|
||||
title: "Updated Backhand Session",
|
||||
exerciseType: "backhand",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(updateSpy).toHaveBeenCalledWith(7, 14, {
|
||||
title: "Updated Backhand Session",
|
||||
exerciseType: "backhand",
|
||||
});
|
||||
});
|
||||
|
||||
it("throws not found when the video cannot be updated by the current user", async () => {
|
||||
const user = createTestUser({ id: 7 });
|
||||
const { ctx } = createMockContext(user);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
|
||||
vi.spyOn(db, "updateUserVideo").mockResolvedValueOnce(false);
|
||||
|
||||
await expect(
|
||||
caller.video.update({
|
||||
videoId: 14,
|
||||
title: "Updated Backhand Session",
|
||||
exerciseType: "backhand",
|
||||
})
|
||||
).rejects.toThrow("视频不存在");
|
||||
});
|
||||
});
|
||||
|
||||
describe("video.delete input validation", () => {
|
||||
it("requires authentication", async () => {
|
||||
const { ctx } = createMockContext(null);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
|
||||
await expect(
|
||||
caller.video.delete({ videoId: 1 })
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("video.delete", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("deletes the current user's video", async () => {
|
||||
const user = createTestUser({ id: 11 });
|
||||
const { ctx } = createMockContext(user);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
|
||||
const deleteSpy = vi.spyOn(db, "deleteUserVideo").mockResolvedValueOnce(true);
|
||||
|
||||
const result = await caller.video.delete({ videoId: 20 });
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(deleteSpy).toHaveBeenCalledWith(11, 20);
|
||||
});
|
||||
|
||||
it("throws not found when the current user does not own the video", async () => {
|
||||
const user = createTestUser({ id: 11 });
|
||||
const { ctx } = createMockContext(user);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
|
||||
vi.spyOn(db, "deleteUserVideo").mockResolvedValueOnce(false);
|
||||
|
||||
await expect(caller.video.delete({ videoId: 20 })).rejects.toThrow("视频不存在");
|
||||
});
|
||||
});
|
||||
|
||||
// ===== ANALYSIS TESTS =====
|
||||
|
||||
describe("analysis.save input validation", () => {
|
||||
@@ -623,6 +919,17 @@ describe("tutorial.list", () => {
|
||||
expect(e.message).not.toContain("invalid_type");
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts topicArea filter", async () => {
|
||||
const { ctx } = createMockContext(null);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
|
||||
try {
|
||||
await caller.tutorial.list({ topicArea: "tennis_skill" });
|
||||
} catch (e: any) {
|
||||
expect(e.message).not.toContain("invalid_type");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("tutorial.progress", () => {
|
||||
@@ -652,7 +959,7 @@ describe("tutorial.updateProgress input validation", () => {
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("accepts optional watched, selfScore, notes", async () => {
|
||||
it("accepts optional watched, completed, selfScore, notes", async () => {
|
||||
const user = createTestUser();
|
||||
const { ctx } = createMockContext(user);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
@@ -661,6 +968,7 @@ describe("tutorial.updateProgress input validation", () => {
|
||||
await caller.tutorial.updateProgress({
|
||||
tutorialId: 1,
|
||||
watched: 1,
|
||||
completed: 1,
|
||||
selfScore: 4,
|
||||
notes: "Great tutorial",
|
||||
});
|
||||
@@ -804,3 +1112,405 @@ describe("notification.markAllRead", () => {
|
||||
await expect(caller.notification.markAllRead()).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ===== VISION LIBRARY TESTS =====
|
||||
|
||||
describe("vision.library", () => {
|
||||
it("requires authentication", async () => {
|
||||
const { ctx } = createMockContext(null);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
await expect(caller.vision.library()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("returns seeded references for authenticated users", async () => {
|
||||
const user = createTestUser();
|
||||
const { ctx } = createMockContext(user);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
const seedSpy = vi.spyOn(db, "seedVisionReferenceImages").mockResolvedValueOnce();
|
||||
const listSpy = vi.spyOn(db, "listVisionReferenceImages").mockResolvedValueOnce([
|
||||
{
|
||||
id: 1,
|
||||
slug: "ref-1",
|
||||
title: "标准图:正手挥拍",
|
||||
exerciseType: "forehand",
|
||||
imageUrl: "https://example.com/forehand.jpg",
|
||||
sourcePageUrl: "https://example.com/source",
|
||||
sourceLabel: "Example",
|
||||
author: null,
|
||||
license: null,
|
||||
expectedFocus: ["肩髋转动"],
|
||||
tags: ["forehand"],
|
||||
notes: null,
|
||||
sortOrder: 1,
|
||||
isPublished: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
] as any);
|
||||
|
||||
const result = await caller.vision.library();
|
||||
|
||||
expect(seedSpy).toHaveBeenCalledTimes(1);
|
||||
expect(listSpy).toHaveBeenCalledTimes(1);
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("vision.runs", () => {
|
||||
it("limits regular users to their own vision test runs", async () => {
|
||||
const user = createTestUser({ id: 7, role: "user" });
|
||||
const { ctx } = createMockContext(user);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
const listSpy = vi.spyOn(db, "listVisionTestRuns").mockResolvedValueOnce([]);
|
||||
|
||||
await caller.vision.runs({ limit: 20 });
|
||||
|
||||
expect(listSpy).toHaveBeenCalledWith(7, 20);
|
||||
});
|
||||
|
||||
it("allows admin users to view all vision test runs", async () => {
|
||||
const admin = createTestUser({ id: 9, role: "admin", name: "H1" });
|
||||
const { ctx } = createMockContext(admin);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
const listSpy = vi.spyOn(db, "listVisionTestRuns").mockResolvedValueOnce([]);
|
||||
|
||||
await caller.vision.runs({ limit: 30 });
|
||||
|
||||
expect(listSpy).toHaveBeenCalledWith(undefined, 30);
|
||||
});
|
||||
});
|
||||
|
||||
describe("vision.seedLibrary", () => {
|
||||
it("rejects non-admin users", async () => {
|
||||
const user = createTestUser({ role: "user" });
|
||||
const { ctx } = createMockContext(user);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
|
||||
await expect(caller.vision.seedLibrary()).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("achievement.list", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns achievement progress for authenticated users", async () => {
|
||||
const user = createTestUser({ id: 12 });
|
||||
const { ctx } = createMockContext(user);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
const listSpy = vi.spyOn(db, "listUserAchievements").mockResolvedValueOnce([
|
||||
{
|
||||
id: 1,
|
||||
key: "training_day_1",
|
||||
name: "开练",
|
||||
description: "完成首个训练日",
|
||||
category: "consistency",
|
||||
rarity: "common",
|
||||
icon: "🎾",
|
||||
metricKey: "training_days",
|
||||
targetValue: 1,
|
||||
tier: 1,
|
||||
isHidden: 0,
|
||||
isActive: 1,
|
||||
sortOrder: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
currentValue: 1,
|
||||
progressPct: 100,
|
||||
unlockedAt: new Date(),
|
||||
unlocked: true,
|
||||
},
|
||||
] as any);
|
||||
|
||||
const result = await caller.achievement.list();
|
||||
|
||||
expect(listSpy).toHaveBeenCalledWith(12);
|
||||
expect(result).toHaveLength(1);
|
||||
expect((result[0] as any).key).toBe("training_day_1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("analysis.liveSessionSave", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("persists a live session and syncs training data", async () => {
|
||||
const user = createTestUser({ id: 5 });
|
||||
const { ctx } = createMockContext(user);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
|
||||
const createSessionSpy = vi.spyOn(db, "createLiveAnalysisSession").mockResolvedValueOnce(101);
|
||||
const createSegmentsSpy = vi.spyOn(db, "createLiveActionSegments").mockResolvedValueOnce();
|
||||
const syncSpy = vi.spyOn(trainingAutomation, "syncLiveTrainingData").mockResolvedValueOnce({
|
||||
recordId: 88,
|
||||
unlocked: ["training_day_1"],
|
||||
});
|
||||
|
||||
const result = await caller.analysis.liveSessionSave({
|
||||
title: "实时分析 正手",
|
||||
sessionMode: "practice",
|
||||
startedAt: Date.now() - 4_000,
|
||||
endedAt: Date.now(),
|
||||
durationMs: 4_000,
|
||||
dominantAction: "forehand",
|
||||
overallScore: 84,
|
||||
postureScore: 82,
|
||||
balanceScore: 78,
|
||||
techniqueScore: 86,
|
||||
footworkScore: 75,
|
||||
consistencyScore: 80,
|
||||
totalActionCount: 3,
|
||||
effectiveSegments: 2,
|
||||
totalSegments: 3,
|
||||
unknownSegments: 1,
|
||||
feedback: ["节奏稳定"],
|
||||
metrics: { sampleCount: 12 },
|
||||
segments: [
|
||||
{
|
||||
actionType: "forehand",
|
||||
isUnknown: false,
|
||||
startMs: 500,
|
||||
endMs: 2_500,
|
||||
durationMs: 2_000,
|
||||
confidenceAvg: 0.82,
|
||||
score: 84,
|
||||
peakScore: 90,
|
||||
frameCount: 24,
|
||||
issueSummary: ["击球点前移"],
|
||||
keyFrames: [500, 1500, 2500],
|
||||
clipLabel: "正手挥拍 00:00 - 00:02",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(createSessionSpy).toHaveBeenCalledTimes(1);
|
||||
expect(createSegmentsSpy).toHaveBeenCalledTimes(1);
|
||||
expect(syncSpy).toHaveBeenCalledWith(expect.objectContaining({
|
||||
userId: 5,
|
||||
sessionId: 101,
|
||||
dominantAction: "forehand",
|
||||
sessionMode: "practice",
|
||||
}));
|
||||
expect(result).toEqual({ sessionId: 101, trainingRecordId: 88 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("analysis.runtime", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("acquires owner mode when runtime is idle", async () => {
|
||||
const user = createTestUser({ id: 7 });
|
||||
const { ctx } = createMockContext(user, "sid-owner");
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
|
||||
vi.spyOn(db, "getUserLiveAnalysisRuntime").mockResolvedValueOnce(undefined);
|
||||
const upsertSpy = vi.spyOn(db, "upsertUserLiveAnalysisRuntime").mockResolvedValueOnce({
|
||||
id: 11,
|
||||
userId: 7,
|
||||
ownerSid: "sid-owner",
|
||||
status: "active",
|
||||
title: "实时分析 正手",
|
||||
sessionMode: "practice",
|
||||
mediaSessionId: null,
|
||||
startedAt: new Date(),
|
||||
endedAt: null,
|
||||
lastHeartbeatAt: new Date(),
|
||||
snapshot: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} as any);
|
||||
|
||||
const result = await caller.analysis.runtimeAcquire({
|
||||
title: "实时分析 正手",
|
||||
sessionMode: "practice",
|
||||
});
|
||||
|
||||
expect(upsertSpy).toHaveBeenCalledWith(7, expect.objectContaining({
|
||||
ownerSid: "sid-owner",
|
||||
status: "active",
|
||||
title: "实时分析 正手",
|
||||
sessionMode: "practice",
|
||||
}));
|
||||
expect(result.role).toBe("owner");
|
||||
expect((result.runtimeSession as any)?.ownerSid).toBe("sid-owner");
|
||||
});
|
||||
|
||||
it("returns viewer mode when another session sid already holds the runtime", async () => {
|
||||
const user = createTestUser({ id: 7 });
|
||||
const { ctx } = createMockContext(user, "sid-viewer");
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
const activeRuntime = {
|
||||
id: 15,
|
||||
userId: 7,
|
||||
ownerSid: "sid-owner",
|
||||
status: "active",
|
||||
title: "实时分析 练习",
|
||||
sessionMode: "pk",
|
||||
mediaSessionId: "media-sync-1",
|
||||
startedAt: new Date(),
|
||||
endedAt: null,
|
||||
lastHeartbeatAt: new Date(),
|
||||
snapshot: { phase: "analyzing" },
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
vi.spyOn(db, "getUserLiveAnalysisRuntime").mockResolvedValueOnce(activeRuntime as any);
|
||||
|
||||
const result = await caller.analysis.runtimeAcquire({
|
||||
title: "实时分析 练习",
|
||||
sessionMode: "pk",
|
||||
});
|
||||
|
||||
expect(result.role).toBe("viewer");
|
||||
expect((result.runtimeSession as any)?.mediaSessionId).toBe("media-sync-1");
|
||||
});
|
||||
|
||||
it("keeps owner mode when the same sid reacquires the runtime", async () => {
|
||||
const user = createTestUser({ id: 7 });
|
||||
const { ctx } = createMockContext(user, "sid-owner");
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
const activeRuntime = {
|
||||
id: 19,
|
||||
userId: 7,
|
||||
ownerSid: "sid-owner",
|
||||
status: "active",
|
||||
title: "旧标题",
|
||||
sessionMode: "practice",
|
||||
mediaSessionId: "media-sync-2",
|
||||
startedAt: new Date("2026-03-16T00:00:00.000Z"),
|
||||
endedAt: null,
|
||||
lastHeartbeatAt: new Date(),
|
||||
snapshot: { phase: "analyzing" },
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
vi.spyOn(db, "getUserLiveAnalysisRuntime").mockResolvedValueOnce(activeRuntime as any);
|
||||
const updateSpy = vi.spyOn(db, "updateUserLiveAnalysisRuntime").mockResolvedValueOnce({
|
||||
...activeRuntime,
|
||||
title: "新标题",
|
||||
} as any);
|
||||
|
||||
const result = await caller.analysis.runtimeAcquire({
|
||||
title: "新标题",
|
||||
sessionMode: "practice",
|
||||
});
|
||||
|
||||
expect(updateSpy).toHaveBeenCalledWith(7, expect.objectContaining({
|
||||
ownerSid: "sid-owner",
|
||||
title: "新标题",
|
||||
status: "active",
|
||||
}));
|
||||
expect(result.role).toBe("owner");
|
||||
});
|
||||
|
||||
it("rejects heartbeat from a non-owner sid", async () => {
|
||||
const user = createTestUser({ id: 7 });
|
||||
const { ctx } = createMockContext(user, "sid-viewer");
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
|
||||
vi.spyOn(db, "updateLiveAnalysisRuntimeHeartbeat").mockResolvedValueOnce(undefined);
|
||||
|
||||
await expect(caller.analysis.runtimeHeartbeat({
|
||||
runtimeId: 20,
|
||||
mediaSessionId: "media-sync-3",
|
||||
snapshot: { phase: "analyzing" },
|
||||
})).rejects.toThrow("当前设备不是实时分析持有端");
|
||||
});
|
||||
|
||||
it("rejects release from a non-owner sid", async () => {
|
||||
const user = createTestUser({ id: 7 });
|
||||
const { ctx } = createMockContext(user, "sid-viewer");
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
|
||||
vi.spyOn(db, "endUserLiveAnalysisRuntime").mockResolvedValueOnce(undefined);
|
||||
vi.spyOn(db, "getUserLiveAnalysisRuntime").mockResolvedValueOnce({
|
||||
id: 23,
|
||||
userId: 7,
|
||||
ownerSid: "sid-owner",
|
||||
status: "active",
|
||||
title: "实时分析",
|
||||
sessionMode: "practice",
|
||||
mediaSessionId: "media-sync-4",
|
||||
startedAt: new Date(),
|
||||
endedAt: null,
|
||||
lastHeartbeatAt: new Date(),
|
||||
snapshot: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} as any);
|
||||
|
||||
await expect(caller.analysis.runtimeRelease({
|
||||
runtimeId: 23,
|
||||
snapshot: { phase: "failed" },
|
||||
})).rejects.toThrow("当前设备不是实时分析持有端");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rating.refreshMine", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("creates an async NTRP refresh task for the current user", async () => {
|
||||
const user = createTestUser({ id: 22 });
|
||||
const { ctx } = createMockContext(user);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
const createTaskSpy = vi.spyOn(db, "createBackgroundTask").mockResolvedValueOnce();
|
||||
|
||||
const result = await caller.rating.refreshMine();
|
||||
|
||||
expect(createTaskSpy).toHaveBeenCalledWith(expect.objectContaining({
|
||||
userId: 22,
|
||||
type: "ntrp_refresh_user",
|
||||
payload: { targetUserId: 22 },
|
||||
}));
|
||||
expect(result.taskId).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("admin.users", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("rejects non-admin users", async () => {
|
||||
const user = createTestUser({ role: "user" });
|
||||
const { ctx } = createMockContext(user);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
|
||||
await expect(caller.admin.users({ limit: 20 })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("returns user list for admin users", async () => {
|
||||
const admin = createTestUser({ id: 1, role: "admin", name: "H1" });
|
||||
const { ctx } = createMockContext(admin);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
const usersSpy = vi.spyOn(db, "listUsersForAdmin").mockResolvedValueOnce([
|
||||
{
|
||||
id: 1,
|
||||
name: "H1",
|
||||
role: "admin",
|
||||
ntrpRating: 3.4,
|
||||
totalSessions: 10,
|
||||
totalMinutes: 320,
|
||||
totalShots: 240,
|
||||
currentStreak: 6,
|
||||
longestStreak: 12,
|
||||
createdAt: new Date(),
|
||||
lastSignedIn: new Date(),
|
||||
},
|
||||
] as any);
|
||||
|
||||
const result = await caller.admin.users({ limit: 20 });
|
||||
|
||||
expect(usersSpy).toHaveBeenCalledWith(20);
|
||||
expect(result).toHaveLength(1);
|
||||
expect((result[0] as any).name).toBe("H1");
|
||||
});
|
||||
});
|
||||
|
||||
84
server/mediaService.test.ts
普通文件
@@ -0,0 +1,84 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { ENV } from "./_core/env";
|
||||
import { getRemoteMediaSession } from "./mediaService";
|
||||
|
||||
const originalMediaServiceUrl = ENV.mediaServiceUrl;
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
ENV.mediaServiceUrl = originalMediaServiceUrl;
|
||||
global.fetch = originalFetch;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("getRemoteMediaSession", () => {
|
||||
it("falls back to /media-prefixed routes when the root route returns 404", async () => {
|
||||
ENV.mediaServiceUrl = "http://127.0.0.1:8081";
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 404,
|
||||
text: vi.fn().mockResolvedValue("404 page not found\n"),
|
||||
statusText: "Not Found",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
session: {
|
||||
id: "session-1",
|
||||
userId: "1",
|
||||
title: "demo",
|
||||
archiveStatus: "idle",
|
||||
playback: {
|
||||
ready: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
global.fetch = fetchMock as typeof fetch;
|
||||
|
||||
const session = await getRemoteMediaSession("session-1");
|
||||
|
||||
expect(session.id).toBe("session-1");
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"http://127.0.0.1:8081/sessions/session-1",
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
||||
);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"http://127.0.0.1:8081/media/sessions/session-1",
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the configured /media base URL directly when already present", async () => {
|
||||
ENV.mediaServiceUrl = "http://media:8081/media";
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
session: {
|
||||
id: "session-2",
|
||||
userId: "2",
|
||||
title: "demo",
|
||||
archiveStatus: "processing",
|
||||
playback: {
|
||||
ready: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
global.fetch = fetchMock as typeof fetch;
|
||||
|
||||
const session = await getRemoteMediaSession("session-2");
|
||||
|
||||
expect(session.id).toBe("session-2");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"http://media:8081/media/sessions/session-2",
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
||||
);
|
||||
});
|
||||
});
|
||||
67
server/mediaService.ts
普通文件
@@ -0,0 +1,67 @@
|
||||
import { ENV } from "./_core/env";
|
||||
import { fetchWithTimeout } from "./_core/fetch";
|
||||
|
||||
export type RemoteMediaSession = {
|
||||
id: string;
|
||||
userId: string;
|
||||
title: string;
|
||||
archiveStatus: "idle" | "queued" | "processing" | "completed" | "failed";
|
||||
previewStatus?: "idle" | "processing" | "ready" | "failed";
|
||||
previewSegments?: number;
|
||||
markers?: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
label: string;
|
||||
timestampMs: number;
|
||||
confidence?: number;
|
||||
createdAt: string;
|
||||
}>;
|
||||
playback: {
|
||||
webmUrl?: string;
|
||||
mp4Url?: string;
|
||||
webmSize?: number;
|
||||
mp4Size?: number;
|
||||
ready: boolean;
|
||||
previewUrl?: string;
|
||||
};
|
||||
lastError?: string;
|
||||
};
|
||||
|
||||
function getMediaBaseUrl() {
|
||||
if (!ENV.mediaServiceUrl) {
|
||||
throw new Error("MEDIA_SERVICE_URL is not configured");
|
||||
}
|
||||
return ENV.mediaServiceUrl.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function getMediaCandidateUrls(path: string) {
|
||||
const baseUrl = getMediaBaseUrl();
|
||||
if (baseUrl.endsWith("/media")) {
|
||||
return [`${baseUrl}${path}`];
|
||||
}
|
||||
return [`${baseUrl}${path}`, `${baseUrl}/media${path}`];
|
||||
}
|
||||
|
||||
export async function getRemoteMediaSession(sessionId: string) {
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (const url of getMediaCandidateUrls(`/sessions/${encodeURIComponent(sessionId)}`)) {
|
||||
const response = await fetchWithTimeout(url, undefined, {
|
||||
timeoutMs: ENV.mediaFetchTimeoutMs,
|
||||
retries: ENV.mediaFetchRetryCount,
|
||||
retryMethods: ["GET"],
|
||||
});
|
||||
if (response.ok) {
|
||||
const payload = await response.json() as { session: RemoteMediaSession };
|
||||
return payload.session;
|
||||
}
|
||||
|
||||
const message = await response.text().catch(() => response.statusText);
|
||||
lastError = new Error(`Media service request failed (${response.status}): ${message}`);
|
||||
if (response.status !== 404) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError ?? new Error("Media service request failed");
|
||||
}
|
||||
255
server/prompts.ts
普通文件
@@ -0,0 +1,255 @@
|
||||
type RecentScore = {
|
||||
score: number | null;
|
||||
issues: unknown;
|
||||
exerciseType: string | null;
|
||||
shotCount: number | null;
|
||||
strokeConsistency: number | null;
|
||||
footworkScore: number | null;
|
||||
};
|
||||
|
||||
type RecentAnalysis = {
|
||||
score: number | null;
|
||||
issues: unknown;
|
||||
corrections: unknown;
|
||||
shotCount: number | null;
|
||||
strokeConsistency: number | null;
|
||||
footworkScore: number | null;
|
||||
fluidityScore: number | null;
|
||||
};
|
||||
|
||||
function skillLevelLabel(skillLevel: "beginner" | "intermediate" | "advanced") {
|
||||
switch (skillLevel) {
|
||||
case "intermediate":
|
||||
return "中级";
|
||||
case "advanced":
|
||||
return "高级";
|
||||
default:
|
||||
return "初级";
|
||||
}
|
||||
}
|
||||
|
||||
export function buildTrainingPlanPrompt(input: {
|
||||
skillLevel: "beginner" | "intermediate" | "advanced";
|
||||
durationDays: number;
|
||||
focusAreas?: string[];
|
||||
recentScores: RecentScore[];
|
||||
}) {
|
||||
return [
|
||||
`你是一位专业网球教练。请为一位${skillLevelLabel(input.skillLevel)}水平的网球学员生成 ${input.durationDays} 天训练计划。`,
|
||||
"训练条件与要求:",
|
||||
"- 训练以个人可执行为主,可使用球拍、弹力带、标志盘、墙面等常见器材。",
|
||||
"- 每天训练 30-60 分钟,结构要清晰:热身、专项、脚步、力量/稳定、放松。",
|
||||
"- 输出内容要适合直接执行,不写空话,不写营销语,不写额外说明。",
|
||||
input.focusAreas?.length ? `- 重点关注:${input.focusAreas.join("、")}` : "- 如未指定重点,请自动平衡技术、脚步和体能。",
|
||||
input.recentScores.length > 0
|
||||
? `- 用户最近分析摘要:${JSON.stringify(input.recentScores)}`
|
||||
: "- 暂无历史分析数据,请基于该水平的常见薄弱项设计。",
|
||||
"每个训练项都要给出目标、动作描述、组次/次数、关键提示,避免重复堆砌。",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function buildAdjustedTrainingPlanPrompt(input: {
|
||||
currentExercises: unknown;
|
||||
recentAnalyses: RecentAnalysis[];
|
||||
}) {
|
||||
return [
|
||||
"你是一位专业网球教练,需要根据最近训练分析结果调整现有训练计划。",
|
||||
`当前计划:${JSON.stringify(input.currentExercises)}`,
|
||||
`最近分析结果:${JSON.stringify(input.recentAnalyses)}`,
|
||||
"请优先修复最近最频繁、最影响击球质量的问题。",
|
||||
"要求:",
|
||||
"- 保留原计划中仍然有效的训练项,不要全部推倒重来。",
|
||||
"- 增加动作纠正、脚步节奏、稳定性和专项力量训练。",
|
||||
"- adjustmentNotes 需要说明为什么这样调整,以及下一阶段重点。",
|
||||
"- 输出仅返回结构化 JSON。",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function buildTextCorrectionPrompt(input: {
|
||||
exerciseType: string;
|
||||
poseMetrics: unknown;
|
||||
detectedIssues: unknown;
|
||||
}) {
|
||||
return [
|
||||
"你是一位网球技术教练与动作纠正分析师。",
|
||||
`动作类型:${input.exerciseType}`,
|
||||
`姿态指标:${JSON.stringify(input.poseMetrics)}`,
|
||||
`已检测问题:${JSON.stringify(input.detectedIssues)}`,
|
||||
"请用中文输出专业、直接、可执行的纠正建议,使用 Markdown。",
|
||||
"内容结构必须包括:",
|
||||
"1. 动作概览",
|
||||
"2. 最高优先级的 3 个修正点",
|
||||
"3. 每个修正点对应的练习方法、感受提示、完成标准",
|
||||
"4. 下一次拍摄或训练时的注意事项",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export const multimodalCorrectionSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
summary: { type: "string" },
|
||||
overallScore: { type: "number" },
|
||||
confidence: { type: "number" },
|
||||
phaseFindings: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
phase: { type: "string" },
|
||||
score: { type: "number" },
|
||||
observation: { type: "string" },
|
||||
impact: { type: "string" },
|
||||
},
|
||||
required: ["phase", "score", "observation", "impact"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
bodyPartFindings: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
bodyPart: { type: "string" },
|
||||
issue: { type: "string" },
|
||||
recommendation: { type: "string" },
|
||||
},
|
||||
required: ["bodyPart", "issue", "recommendation"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
priorityFixes: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
title: { type: "string" },
|
||||
why: { type: "string" },
|
||||
howToPractice: { type: "string" },
|
||||
successMetric: { type: "string" },
|
||||
},
|
||||
required: ["title", "why", "howToPractice", "successMetric"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
drills: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
purpose: { type: "string" },
|
||||
durationMinutes: { type: "number" },
|
||||
steps: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
},
|
||||
coachingCues: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
},
|
||||
},
|
||||
required: ["name", "purpose", "durationMinutes", "steps", "coachingCues"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
safetyRisks: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
},
|
||||
nextSessionFocus: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
},
|
||||
recommendedCaptureTips: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
},
|
||||
},
|
||||
required: [
|
||||
"summary",
|
||||
"overallScore",
|
||||
"confidence",
|
||||
"phaseFindings",
|
||||
"bodyPartFindings",
|
||||
"priorityFixes",
|
||||
"drills",
|
||||
"safetyRisks",
|
||||
"nextSessionFocus",
|
||||
"recommendedCaptureTips",
|
||||
],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
export function buildMultimodalCorrectionPrompt(input: {
|
||||
exerciseType: string;
|
||||
poseMetrics: unknown;
|
||||
detectedIssues: unknown;
|
||||
imageCount: number;
|
||||
}) {
|
||||
return [
|
||||
"你是一位专业网球技术教练,正在审阅学员的动作截图。",
|
||||
`动作类型:${input.exerciseType}`,
|
||||
`结构化姿态指标:${JSON.stringify(input.poseMetrics)}`,
|
||||
`已有问题标签:${JSON.stringify(input.detectedIssues)}`,
|
||||
`本次共提供 ${input.imageCount} 张关键帧图片。`,
|
||||
"请严格依据图片和结构化指标交叉判断,不要编造看不到的动作细节。",
|
||||
"分析要求:",
|
||||
"- 识别准备、引拍、击球/发力、收拍几个阶段的质量。",
|
||||
"- 指出躯干、肩髋、击球臂、非持拍手、重心和脚步的主要问题。",
|
||||
"- priorityFixes 只保留最重要、最值得优先修正的项目。",
|
||||
"- drills 要足够具体,适合下一次训练直接执行。",
|
||||
"- recommendedCaptureTips 说明下次如何补拍,以便提高判断准确度。",
|
||||
"输出仅返回 JSON,不要附加解释。",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function renderMultimodalCorrectionMarkdown(report: {
|
||||
summary: string;
|
||||
overallScore: number;
|
||||
confidence: number;
|
||||
priorityFixes: Array<{ title: string; why: string; howToPractice: string; successMetric: string }>;
|
||||
drills: Array<{ name: string; purpose: string; durationMinutes: number; coachingCues: string[] }>;
|
||||
safetyRisks: string[];
|
||||
nextSessionFocus: string[];
|
||||
recommendedCaptureTips: string[];
|
||||
}) {
|
||||
const priorityFixes = report.priorityFixes
|
||||
.map((item, index) => [
|
||||
`${index + 1}. ${item.title}`,
|
||||
`- 原因:${item.why}`,
|
||||
`- 练习:${item.howToPractice}`,
|
||||
`- 达标:${item.successMetric}`,
|
||||
].join("\n"))
|
||||
.join("\n");
|
||||
|
||||
const drills = report.drills
|
||||
.map((item) => [
|
||||
`- ${item.name}(${item.durationMinutes} 分钟)`,
|
||||
` 目的:${item.purpose}`,
|
||||
` 口令:${item.coachingCues.join(";")}`,
|
||||
].join("\n"))
|
||||
.join("\n");
|
||||
|
||||
return [
|
||||
`## 动作概览`,
|
||||
report.summary,
|
||||
"",
|
||||
`- 综合评分:${Math.round(report.overallScore)}/100`,
|
||||
`- 置信度:${Math.round(report.confidence)}%`,
|
||||
"",
|
||||
"## 优先修正",
|
||||
priorityFixes || "- 暂无",
|
||||
"",
|
||||
"## 推荐练习",
|
||||
drills || "- 暂无",
|
||||
"",
|
||||
"## 风险提醒",
|
||||
report.safetyRisks.length > 0 ? report.safetyRisks.map(item => `- ${item}`).join("\n") : "- 暂无明显风险",
|
||||
"",
|
||||
"## 下次训练重点",
|
||||
report.nextSessionFocus.length > 0 ? report.nextSessionFocus.map(item => `- ${item}`).join("\n") : "- 保持当前节奏",
|
||||
"",
|
||||
"## 下次拍摄建议",
|
||||
report.recommendedCaptureTips.length > 0 ? report.recommendedCaptureTips.map(item => `- ${item}`).join("\n") : "- 保持当前拍摄方式",
|
||||
].join("\n");
|
||||
}
|
||||
22
server/publicUrl.ts
普通文件
@@ -0,0 +1,22 @@
|
||||
import { ENV } from "./_core/env";
|
||||
|
||||
function hasProtocol(value: string) {
|
||||
return /^[a-z][a-z0-9+.-]*:\/\//i.test(value);
|
||||
}
|
||||
|
||||
export function toPublicUrl(pathOrUrl: string) {
|
||||
const value = pathOrUrl.trim();
|
||||
if (!value) {
|
||||
throw new Error("Public URL value is empty");
|
||||
}
|
||||
|
||||
if (hasProtocol(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (!ENV.appPublicBaseUrl) {
|
||||
throw new Error("APP_PUBLIC_BASE_URL is required for externally accessible asset URLs");
|
||||
}
|
||||
|
||||
return new URL(value.startsWith("/") ? value : `/${value}`, ENV.appPublicBaseUrl).toString();
|
||||
}
|
||||
@@ -37,4 +37,16 @@ describe("storage fallback", () => {
|
||||
url: "/uploads/videos/test/sample.webm",
|
||||
});
|
||||
});
|
||||
|
||||
it("builds externally accessible URLs for local assets", async () => {
|
||||
process.env.APP_PUBLIC_BASE_URL = "https://te.hao.work/";
|
||||
const { toExternalAssetUrl } = await import("./storage");
|
||||
|
||||
expect(toExternalAssetUrl("/uploads/videos/test/sample.webm")).toBe(
|
||||
"https://te.hao.work/uploads/videos/test/sample.webm"
|
||||
);
|
||||
expect(toExternalAssetUrl("https://cdn.example.com/demo.jpg")).toBe(
|
||||
"https://cdn.example.com/demo.jpg"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { ENV } from './_core/env';
|
||||
import { toPublicUrl } from "./publicUrl";
|
||||
|
||||
type StorageConfig = { baseUrl: string; apiKey: string };
|
||||
|
||||
@@ -141,3 +142,7 @@ export async function storageGet(relKey: string): Promise<{ key: string; url: st
|
||||
url: await buildDownloadUrl(baseUrl, key, apiKey),
|
||||
};
|
||||
}
|
||||
|
||||
export function toExternalAssetUrl(pathOrUrl: string) {
|
||||
return toPublicUrl(pathOrUrl);
|
||||
}
|
||||
|
||||
584
server/taskWorker.ts
普通文件
@@ -0,0 +1,584 @@
|
||||
import { nanoid } from "nanoid";
|
||||
import { ENV } from "./_core/env";
|
||||
import { invokeLLM, type Message } from "./_core/llm";
|
||||
import * as db from "./db";
|
||||
import { getRemoteMediaSession } from "./mediaService";
|
||||
import {
|
||||
buildAdjustedTrainingPlanPrompt,
|
||||
buildMultimodalCorrectionPrompt,
|
||||
buildTextCorrectionPrompt,
|
||||
buildTrainingPlanPrompt,
|
||||
multimodalCorrectionSchema,
|
||||
renderMultimodalCorrectionMarkdown,
|
||||
} from "./prompts";
|
||||
import { toPublicUrl } from "./publicUrl";
|
||||
import { storagePut } from "./storage";
|
||||
import { extractStructuredJsonContent, normalizeMultimodalCorrectionReport } from "./vision";
|
||||
import {
|
||||
normalizeAdjustedPlanResponse,
|
||||
normalizeTrainingPlanResponse,
|
||||
} from "./trainingPlan";
|
||||
import { refreshAllUsersNtrp, refreshUserNtrp, syncRecordingTrainingData } from "./trainingAutomation";
|
||||
|
||||
type TaskRow = Awaited<ReturnType<typeof db.getBackgroundTaskById>>;
|
||||
|
||||
type StructuredParams<T> = {
|
||||
model?: string;
|
||||
baseMessages: Array<{ role: "system" | "user"; content: string | Message["content"] }>;
|
||||
responseFormat: {
|
||||
type: "json_schema";
|
||||
json_schema: {
|
||||
name: string;
|
||||
strict: true;
|
||||
schema: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
parse: (content: unknown) => T;
|
||||
timeoutMs?: number;
|
||||
retryCount?: number;
|
||||
};
|
||||
|
||||
const TRAINING_PLAN_LLM_TIMEOUT_MS = Math.max(ENV.llmTimeoutMs, 120_000);
|
||||
const TRAINING_PLAN_LLM_RETRY_COUNT = Math.max(ENV.llmRetryCount, 2);
|
||||
|
||||
async function invokeStructured<T>(params: StructuredParams<T>) {
|
||||
let lastError: unknown;
|
||||
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
const retryHint =
|
||||
attempt === 0 || !(lastError instanceof Error)
|
||||
? []
|
||||
: [{
|
||||
role: "user" as const,
|
||||
content:
|
||||
`上一次输出无法被系统解析,错误是:${lastError.message}。` +
|
||||
"请只返回合法完整的 JSON 对象,不要附加 Markdown 或说明。",
|
||||
}];
|
||||
|
||||
const response = await invokeLLM({
|
||||
apiUrl: params.model === ENV.llmVisionModel ? ENV.llmVisionApiUrl : undefined,
|
||||
apiKey: params.model === ENV.llmVisionModel ? ENV.llmVisionApiKey : undefined,
|
||||
model: params.model,
|
||||
messages: [...params.baseMessages, ...retryHint],
|
||||
response_format: params.responseFormat,
|
||||
timeoutMs: params.timeoutMs,
|
||||
retryCount: params.retryCount,
|
||||
});
|
||||
|
||||
try {
|
||||
return params.parse(response.choices[0]?.message?.content);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError instanceof Error ? lastError : new Error("Failed to parse structured LLM response");
|
||||
}
|
||||
|
||||
function contentToPlainText(content: Message["content"]) {
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
}
|
||||
|
||||
const parts = Array.isArray(content) ? content : [content];
|
||||
|
||||
return parts
|
||||
.map((part) => {
|
||||
if (typeof part === "string") {
|
||||
return part;
|
||||
}
|
||||
if (part.type === "text") {
|
||||
return part.text;
|
||||
}
|
||||
if (part.type === "image_url") {
|
||||
return `[image] ${part.image_url.url}`;
|
||||
}
|
||||
if (part.type === "file_url") {
|
||||
return `[file] ${part.file_url.url}`;
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function parseDataUrl(input: string) {
|
||||
const match = input.match(/^data:(.+?);base64,(.+)$/);
|
||||
if (!match) {
|
||||
throw new Error("Invalid image data URL");
|
||||
}
|
||||
return {
|
||||
contentType: match[1],
|
||||
buffer: Buffer.from(match[2], "base64"),
|
||||
};
|
||||
}
|
||||
|
||||
async function persistInlineImages(userId: number, imageDataUrls: string[]) {
|
||||
const persistedUrls: string[] = [];
|
||||
for (let index = 0; index < imageDataUrls.length; index++) {
|
||||
const { contentType, buffer } = parseDataUrl(imageDataUrls[index]);
|
||||
const extension = contentType.includes("png") ? "png" : "jpg";
|
||||
const key = `analysis-images/${userId}/${nanoid()}.${extension}`;
|
||||
const uploaded = await storagePut(key, buffer, contentType);
|
||||
persistedUrls.push(toPublicUrl(uploaded.url));
|
||||
}
|
||||
return persistedUrls;
|
||||
}
|
||||
|
||||
export async function prepareCorrectionImageUrls(input: {
|
||||
userId: number;
|
||||
imageUrls?: string[];
|
||||
imageDataUrls?: string[];
|
||||
}) {
|
||||
const directUrls = (input.imageUrls ?? []).map((item) => toPublicUrl(item));
|
||||
const uploadedUrls = input.imageDataUrls?.length
|
||||
? await persistInlineImages(input.userId, input.imageDataUrls)
|
||||
: [];
|
||||
return [...directUrls, ...uploadedUrls];
|
||||
}
|
||||
|
||||
async function runTrainingPlanGenerateTask(task: NonNullable<TaskRow>) {
|
||||
const payload = task.payload as {
|
||||
skillLevel: "beginner" | "intermediate" | "advanced";
|
||||
durationDays: number;
|
||||
focusAreas?: string[];
|
||||
};
|
||||
const user = await db.getUserById(task.userId);
|
||||
if (!user) {
|
||||
throw new Error("User not found");
|
||||
}
|
||||
const latestSnapshot = await db.getLatestNtrpSnapshot(task.userId);
|
||||
const trainingProfileStatus = db.getTrainingProfileStatus(user, latestSnapshot);
|
||||
if (!trainingProfileStatus.isComplete) {
|
||||
const missingLabels = trainingProfileStatus.missingFields.map((field) => db.TRAINING_PROFILE_FIELD_LABELS[field]).join("、");
|
||||
throw new Error(`训练计划生成前请先完善训练档案:${missingLabels}`);
|
||||
}
|
||||
|
||||
const analyses = await db.getUserAnalyses(task.userId);
|
||||
const recentScores = analyses.slice(0, 5).map((analysis) => ({
|
||||
score: analysis.overallScore ?? null,
|
||||
issues: analysis.detectedIssues,
|
||||
exerciseType: analysis.exerciseType ?? null,
|
||||
shotCount: analysis.shotCount ?? null,
|
||||
strokeConsistency: analysis.strokeConsistency ?? null,
|
||||
footworkScore: analysis.footworkScore ?? null,
|
||||
}));
|
||||
|
||||
const parsed = await invokeStructured({
|
||||
baseMessages: [
|
||||
{ role: "system", content: "你是网球训练计划生成器。返回严格的 JSON 格式。" },
|
||||
{
|
||||
role: "user",
|
||||
content: buildTrainingPlanPrompt({
|
||||
...payload,
|
||||
recentScores,
|
||||
effectiveNtrpRating: trainingProfileStatus.effectiveNtrp,
|
||||
ntrpSource: trainingProfileStatus.ntrpSource,
|
||||
assessmentSnapshot: trainingProfileStatus.assessmentSnapshot,
|
||||
}),
|
||||
},
|
||||
],
|
||||
responseFormat: {
|
||||
type: "json_schema",
|
||||
json_schema: {
|
||||
name: "training_plan",
|
||||
strict: true,
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
title: { type: "string" },
|
||||
exercises: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
day: { type: "number" },
|
||||
name: { type: "string" },
|
||||
category: { type: "string" },
|
||||
duration: { type: "number" },
|
||||
description: { type: "string" },
|
||||
tips: { type: "string" },
|
||||
sets: { type: "number" },
|
||||
reps: { type: "number" },
|
||||
},
|
||||
required: ["day", "name", "category", "duration", "description", "tips", "sets", "reps"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ["title", "exercises"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
parse: (content) => normalizeTrainingPlanResponse({
|
||||
content,
|
||||
fallbackTitle: `${payload.durationDays}天训练计划`,
|
||||
}),
|
||||
timeoutMs: TRAINING_PLAN_LLM_TIMEOUT_MS,
|
||||
retryCount: TRAINING_PLAN_LLM_RETRY_COUNT,
|
||||
});
|
||||
|
||||
const planId = await db.createTrainingPlan({
|
||||
userId: task.userId,
|
||||
title: parsed.title,
|
||||
skillLevel: payload.skillLevel,
|
||||
durationDays: payload.durationDays,
|
||||
exercises: parsed.exercises,
|
||||
isActive: 1,
|
||||
version: 1,
|
||||
});
|
||||
|
||||
return {
|
||||
kind: "training_plan_generate" as const,
|
||||
planId,
|
||||
plan: parsed,
|
||||
};
|
||||
}
|
||||
|
||||
async function runTrainingPlanAdjustTask(task: NonNullable<TaskRow>) {
|
||||
const payload = task.payload as { planId: number };
|
||||
const analyses = await db.getUserAnalyses(task.userId);
|
||||
const recentAnalyses = analyses.slice(0, 5);
|
||||
const currentPlan = (await db.getUserTrainingPlans(task.userId)).find((plan) => plan.id === payload.planId);
|
||||
|
||||
if (!currentPlan) {
|
||||
throw new Error("Plan not found");
|
||||
}
|
||||
|
||||
const parsed = await invokeStructured({
|
||||
baseMessages: [
|
||||
{ role: "system", content: "你是网球训练计划调整器。返回严格的 JSON 格式。" },
|
||||
{
|
||||
role: "user",
|
||||
content: buildAdjustedTrainingPlanPrompt({
|
||||
currentExercises: currentPlan.exercises,
|
||||
recentAnalyses: recentAnalyses.map((analysis) => ({
|
||||
score: analysis.overallScore ?? null,
|
||||
issues: analysis.detectedIssues,
|
||||
corrections: analysis.corrections,
|
||||
shotCount: analysis.shotCount ?? null,
|
||||
strokeConsistency: analysis.strokeConsistency ?? null,
|
||||
footworkScore: analysis.footworkScore ?? null,
|
||||
fluidityScore: analysis.fluidityScore ?? null,
|
||||
})),
|
||||
}),
|
||||
},
|
||||
],
|
||||
responseFormat: {
|
||||
type: "json_schema",
|
||||
json_schema: {
|
||||
name: "adjusted_plan",
|
||||
strict: true,
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
title: { type: "string" },
|
||||
adjustmentNotes: { type: "string" },
|
||||
exercises: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
day: { type: "number" },
|
||||
name: { type: "string" },
|
||||
category: { type: "string" },
|
||||
duration: { type: "number" },
|
||||
description: { type: "string" },
|
||||
tips: { type: "string" },
|
||||
sets: { type: "number" },
|
||||
reps: { type: "number" },
|
||||
},
|
||||
required: ["day", "name", "category", "duration", "description", "tips", "sets", "reps"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ["title", "adjustmentNotes", "exercises"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
parse: (content) => normalizeAdjustedPlanResponse({
|
||||
content,
|
||||
fallbackTitle: currentPlan.title,
|
||||
}),
|
||||
timeoutMs: TRAINING_PLAN_LLM_TIMEOUT_MS,
|
||||
retryCount: TRAINING_PLAN_LLM_RETRY_COUNT,
|
||||
});
|
||||
|
||||
await db.updateTrainingPlan(payload.planId, {
|
||||
exercises: parsed.exercises,
|
||||
adjustmentNotes: parsed.adjustmentNotes,
|
||||
version: (currentPlan.version || 1) + 1,
|
||||
});
|
||||
|
||||
return {
|
||||
kind: "training_plan_adjust" as const,
|
||||
planId: payload.planId,
|
||||
plan: parsed,
|
||||
adjustmentNotes: parsed.adjustmentNotes,
|
||||
};
|
||||
}
|
||||
|
||||
async function runTextCorrectionTask(task: NonNullable<TaskRow>) {
|
||||
const payload = task.payload as {
|
||||
exerciseType: string;
|
||||
poseMetrics: unknown;
|
||||
detectedIssues: unknown;
|
||||
};
|
||||
return createTextCorrectionResult(payload);
|
||||
}
|
||||
|
||||
async function createTextCorrectionResult(payload: {
|
||||
exerciseType: string;
|
||||
poseMetrics: unknown;
|
||||
detectedIssues: unknown;
|
||||
}) {
|
||||
const response = await invokeLLM({
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: "你是一位专业网球技术教练。输出中文 Markdown,内容具体、克制、可执行。",
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: buildTextCorrectionPrompt(payload),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return {
|
||||
kind: "analysis_corrections" as const,
|
||||
corrections: contentToPlainText(response.choices[0]?.message?.content || "暂无建议"),
|
||||
};
|
||||
}
|
||||
|
||||
async function runMultimodalCorrectionTask(task: NonNullable<TaskRow>) {
|
||||
const payload = task.payload as {
|
||||
exerciseType: string;
|
||||
poseMetrics: unknown;
|
||||
detectedIssues: unknown;
|
||||
imageUrls: string[];
|
||||
};
|
||||
try {
|
||||
const report = await invokeStructured({
|
||||
model: ENV.llmVisionModel,
|
||||
baseMessages: [
|
||||
{ role: "system", content: "你是专业网球教练。请基于图片和结构化姿态指标输出严格 JSON。" },
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: buildMultimodalCorrectionPrompt({
|
||||
exerciseType: payload.exerciseType,
|
||||
poseMetrics: payload.poseMetrics,
|
||||
detectedIssues: payload.detectedIssues,
|
||||
imageCount: payload.imageUrls.length,
|
||||
}) },
|
||||
...payload.imageUrls.map((url) => ({
|
||||
type: "image_url" as const,
|
||||
image_url: {
|
||||
url,
|
||||
detail: "high" as const,
|
||||
},
|
||||
})),
|
||||
],
|
||||
},
|
||||
],
|
||||
responseFormat: {
|
||||
type: "json_schema",
|
||||
json_schema: {
|
||||
name: "pose_correction_multimodal",
|
||||
strict: true,
|
||||
schema: multimodalCorrectionSchema,
|
||||
},
|
||||
},
|
||||
parse: (content) => normalizeMultimodalCorrectionReport(extractStructuredJsonContent(content)),
|
||||
});
|
||||
|
||||
const result = {
|
||||
kind: "pose_correction_multimodal" as const,
|
||||
imageUrls: payload.imageUrls,
|
||||
report,
|
||||
corrections: renderMultimodalCorrectionMarkdown(report as Parameters<typeof renderMultimodalCorrectionMarkdown>[0]),
|
||||
visionStatus: "ok" as const,
|
||||
};
|
||||
|
||||
await db.completeVisionTestRun(task.id, {
|
||||
visionStatus: "ok",
|
||||
summary: (report as { summary?: string }).summary ?? null,
|
||||
corrections: result.corrections,
|
||||
report,
|
||||
warning: null,
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const fallback = await createTextCorrectionResult(payload);
|
||||
const result = {
|
||||
kind: "pose_correction_multimodal" as const,
|
||||
imageUrls: payload.imageUrls,
|
||||
report: null,
|
||||
corrections: fallback.corrections,
|
||||
visionStatus: "fallback" as const,
|
||||
warning: error instanceof Error ? error.message : "Vision model unavailable",
|
||||
};
|
||||
|
||||
await db.completeVisionTestRun(task.id, {
|
||||
visionStatus: "fallback",
|
||||
summary: null,
|
||||
corrections: result.corrections,
|
||||
report: null,
|
||||
warning: result.warning,
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
async function runMediaFinalizeTask(task: NonNullable<TaskRow>) {
|
||||
const payload = task.payload as {
|
||||
sessionId: string;
|
||||
title: string;
|
||||
exerciseType?: string;
|
||||
sessionMode?: "practice" | "pk";
|
||||
durationMinutes?: number;
|
||||
actionCount?: number;
|
||||
actionSummary?: Record<string, number>;
|
||||
dominantAction?: string;
|
||||
validityStatus?: string;
|
||||
invalidReason?: string;
|
||||
};
|
||||
const session = await getRemoteMediaSession(payload.sessionId);
|
||||
|
||||
if (session.userId !== String(task.userId)) {
|
||||
throw new Error("Media session does not belong to the task user");
|
||||
}
|
||||
|
||||
if (session.archiveStatus === "queued") {
|
||||
await db.rescheduleBackgroundTask(task.id, {
|
||||
progress: 45,
|
||||
message: "录制文件已入队,等待归档",
|
||||
delayMs: 4_000,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
if (session.archiveStatus === "processing") {
|
||||
await db.rescheduleBackgroundTask(task.id, {
|
||||
progress: 78,
|
||||
message: "录制文件正在整理与转码",
|
||||
delayMs: 4_000,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
if (session.archiveStatus === "failed") {
|
||||
throw new Error(session.lastError || "Media archive failed");
|
||||
}
|
||||
|
||||
if (!session.playback.ready) {
|
||||
await db.rescheduleBackgroundTask(task.id, {
|
||||
progress: 70,
|
||||
message: "等待回放文件就绪",
|
||||
delayMs: 4_000,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const preferredUrl = session.playback.mp4Url || session.playback.webmUrl;
|
||||
const format = session.playback.mp4Url ? "mp4" : "webm";
|
||||
if (!preferredUrl) {
|
||||
throw new Error("Media session did not expose a playback URL");
|
||||
}
|
||||
|
||||
const fileKey = `media/sessions/${session.id}/recording.${format}`;
|
||||
const existing = await db.getVideoByFileKey(task.userId, fileKey);
|
||||
if (existing) {
|
||||
return {
|
||||
kind: "media_finalize" as const,
|
||||
sessionId: session.id,
|
||||
videoId: existing.id,
|
||||
url: existing.url,
|
||||
fileKey,
|
||||
format,
|
||||
};
|
||||
}
|
||||
|
||||
const publicUrl = toPublicUrl(preferredUrl);
|
||||
const videoId = await db.createVideo({
|
||||
userId: task.userId,
|
||||
title: payload.title || session.title,
|
||||
fileKey,
|
||||
url: publicUrl,
|
||||
format,
|
||||
fileSize: format === "mp4" ? (session.playback.mp4Size ?? null) : (session.playback.webmSize ?? null),
|
||||
duration: null,
|
||||
exerciseType: payload.exerciseType || "recording",
|
||||
analysisStatus: "completed",
|
||||
});
|
||||
|
||||
await syncRecordingTrainingData({
|
||||
userId: task.userId,
|
||||
videoId,
|
||||
exerciseType: payload.exerciseType || "unknown",
|
||||
title: payload.title || session.title,
|
||||
sessionMode: payload.sessionMode || "practice",
|
||||
durationMinutes: payload.durationMinutes ?? 5,
|
||||
actionCount: payload.actionCount ?? 0,
|
||||
actionSummary: payload.actionSummary ?? {},
|
||||
dominantAction: payload.dominantAction ?? null,
|
||||
validityStatus: payload.validityStatus ?? "pending",
|
||||
invalidReason: payload.invalidReason ?? null,
|
||||
});
|
||||
|
||||
return {
|
||||
kind: "media_finalize" as const,
|
||||
sessionId: session.id,
|
||||
videoId,
|
||||
url: publicUrl,
|
||||
fileKey,
|
||||
format,
|
||||
};
|
||||
}
|
||||
|
||||
async function runNtrpRefreshUserTask(task: NonNullable<TaskRow>) {
|
||||
const payload = task.payload as { targetUserId?: number };
|
||||
const targetUserId = payload.targetUserId ?? task.userId;
|
||||
const snapshot = await refreshUserNtrp(targetUserId, { triggerType: "manual", taskId: task.id });
|
||||
return {
|
||||
kind: "ntrp_refresh_user" as const,
|
||||
targetUserId,
|
||||
snapshot,
|
||||
};
|
||||
}
|
||||
|
||||
async function runNtrpRefreshAllTask(task: NonNullable<TaskRow>) {
|
||||
const results = await refreshAllUsersNtrp({ triggerType: "daily", taskId: task.id });
|
||||
return {
|
||||
kind: "ntrp_refresh_all" as const,
|
||||
refreshedUsers: results.length,
|
||||
results,
|
||||
};
|
||||
}
|
||||
|
||||
export async function processBackgroundTask(task: NonNullable<TaskRow>) {
|
||||
switch (task.type) {
|
||||
case "training_plan_generate":
|
||||
return runTrainingPlanGenerateTask(task);
|
||||
case "training_plan_adjust":
|
||||
return runTrainingPlanAdjustTask(task);
|
||||
case "analysis_corrections":
|
||||
return runTextCorrectionTask(task);
|
||||
case "pose_correction_multimodal":
|
||||
return runMultimodalCorrectionTask(task);
|
||||
case "media_finalize":
|
||||
return runMediaFinalizeTask(task);
|
||||
case "ntrp_refresh_user":
|
||||
return runNtrpRefreshUserTask(task);
|
||||
case "ntrp_refresh_all":
|
||||
return runNtrpRefreshAllTask(task);
|
||||
default:
|
||||
throw new Error(`Unsupported task type: ${String(task.type)}`);
|
||||
}
|
||||
}
|
||||
323
server/trainingAutomation.ts
普通文件
@@ -0,0 +1,323 @@
|
||||
import * as db from "./db";
|
||||
|
||||
export const ACTION_LABELS: Record<string, string> = {
|
||||
forehand: "正手挥拍",
|
||||
backhand: "反手挥拍",
|
||||
serve: "发球",
|
||||
volley: "截击",
|
||||
overhead: "高压",
|
||||
slice: "切削",
|
||||
lob: "挑高球",
|
||||
unknown: "未知动作",
|
||||
};
|
||||
|
||||
function toMinutes(durationMs?: number | null) {
|
||||
if (!durationMs || durationMs <= 0) return 1;
|
||||
return Math.max(1, Math.round(durationMs / 60000));
|
||||
}
|
||||
|
||||
function normalizeScore(value?: number | null) {
|
||||
if (value == null || Number.isNaN(value)) return 0;
|
||||
return Math.max(0, Math.min(100, value));
|
||||
}
|
||||
|
||||
type NtrpTrigger = "analysis" | "daily" | "manual";
|
||||
|
||||
export async function refreshUserNtrp(userId: number, options: { triggerType: NtrpTrigger; taskId?: string | null }) {
|
||||
const analyses = await db.getUserAnalyses(userId);
|
||||
const aggregates = await db.listDailyTrainingAggregates(userId, 90);
|
||||
const liveSessions = await db.listLiveAnalysisSessions(userId, 30);
|
||||
const records = await db.getUserTrainingRecords(userId, 500);
|
||||
|
||||
const avgAnalysisScore = analyses.length > 0
|
||||
? analyses.reduce((sum, item) => sum + (item.overallScore || 0), 0) / analyses.length
|
||||
: 0;
|
||||
const avgLiveScore = liveSessions.length > 0
|
||||
? liveSessions.reduce((sum, item) => sum + (item.overallScore || 0), 0) / liveSessions.length
|
||||
: 0;
|
||||
const avgScore = avgAnalysisScore > 0 || avgLiveScore > 0
|
||||
? ((avgAnalysisScore || 0) * 0.65 + (avgLiveScore || 0) * 0.35)
|
||||
: 0;
|
||||
|
||||
const avgConsistency = analyses.length > 0
|
||||
? analyses.reduce((sum, item) => sum + (item.strokeConsistency || 0), 0) / analyses.length
|
||||
: liveSessions.length > 0
|
||||
? liveSessions.reduce((sum, item) => sum + (item.consistencyScore || 0), 0) / liveSessions.length
|
||||
: 0;
|
||||
const avgFootwork = analyses.length > 0
|
||||
? analyses.reduce((sum, item) => sum + (item.footworkScore || 0), 0) / analyses.length
|
||||
: liveSessions.length > 0
|
||||
? liveSessions.reduce((sum, item) => sum + (item.footworkScore || 0), 0) / liveSessions.length
|
||||
: 0;
|
||||
const avgFluidity = analyses.length > 0
|
||||
? analyses.reduce((sum, item) => sum + (item.fluidityScore || 0), 0) / analyses.length
|
||||
: liveSessions.length > 0
|
||||
? liveSessions.reduce((sum, item) => sum + (item.techniqueScore || 0), 0) / liveSessions.length
|
||||
: 0;
|
||||
|
||||
const totalMinutes = aggregates.reduce((sum, item) => sum + (item.totalMinutes || 0), 0);
|
||||
const totalEffectiveActions = aggregates.reduce((sum, item) => sum + (item.effectiveActions || 0), 0);
|
||||
const totalPk = aggregates.reduce((sum, item) => sum + (item.pkCount || 0), 0);
|
||||
const activeDays = aggregates.filter(item => (item.sessionCount || 0) > 0).length;
|
||||
|
||||
const dimensions = {
|
||||
poseAccuracy: normalizeScore(avgScore),
|
||||
strokeConsistency: normalizeScore(avgConsistency),
|
||||
footwork: normalizeScore(avgFootwork),
|
||||
fluidity: normalizeScore(avgFluidity),
|
||||
timing: normalizeScore(avgConsistency * 0.6 + avgScore * 0.4),
|
||||
matchReadiness: normalizeScore(
|
||||
Math.min(100, totalPk * 12) * 0.4 +
|
||||
Math.min(100, activeDays * 3) * 0.3 +
|
||||
Math.min(100, totalEffectiveActions / 5) * 0.3,
|
||||
),
|
||||
activityWeight: normalizeScore(Math.min(100, totalMinutes / 8 + activeDays * 2)),
|
||||
};
|
||||
|
||||
const composite = (
|
||||
dimensions.poseAccuracy * 0.22 +
|
||||
dimensions.strokeConsistency * 0.18 +
|
||||
dimensions.footwork * 0.16 +
|
||||
dimensions.fluidity * 0.12 +
|
||||
dimensions.timing * 0.12 +
|
||||
dimensions.matchReadiness * 0.10 +
|
||||
dimensions.activityWeight * 0.10
|
||||
);
|
||||
|
||||
let ntrpRating: number;
|
||||
if (composite <= 20) ntrpRating = 1.0 + (composite / 20) * 0.5;
|
||||
else if (composite <= 40) ntrpRating = 1.5 + ((composite - 20) / 20) * 1.0;
|
||||
else if (composite <= 60) ntrpRating = 2.5 + ((composite - 40) / 20) * 1.0;
|
||||
else if (composite <= 80) ntrpRating = 3.5 + ((composite - 60) / 20) * 1.0;
|
||||
else ntrpRating = 4.5 + ((composite - 80) / 20) * 0.5;
|
||||
|
||||
ntrpRating = Math.max(1.0, Math.min(5.0, Math.round(ntrpRating * 10) / 10));
|
||||
const snapshotDate = db.getDateKey();
|
||||
const snapshotKey = `${userId}:${snapshotDate}:${options.triggerType}`;
|
||||
|
||||
await db.createRatingEntry({
|
||||
userId,
|
||||
rating: ntrpRating,
|
||||
reason: options.triggerType === "daily" ? "每日异步综合评分刷新" : "手动或分析触发综合评分刷新",
|
||||
dimensionScores: dimensions,
|
||||
analysisId: null,
|
||||
});
|
||||
await db.createNtrpSnapshot({
|
||||
snapshotKey,
|
||||
userId,
|
||||
snapshotDate,
|
||||
rating: ntrpRating,
|
||||
triggerType: options.triggerType,
|
||||
taskId: options.taskId ?? null,
|
||||
dimensionScores: dimensions,
|
||||
sourceSummary: {
|
||||
analyses: analyses.length,
|
||||
liveSessions: liveSessions.length,
|
||||
records: records.length,
|
||||
activeDays,
|
||||
totalMinutes,
|
||||
totalEffectiveActions,
|
||||
totalPk,
|
||||
},
|
||||
});
|
||||
await db.updateUserProfile(userId, { ntrpRating });
|
||||
await db.refreshAchievementsForUser(userId);
|
||||
|
||||
return {
|
||||
rating: ntrpRating,
|
||||
dimensions,
|
||||
snapshotDate,
|
||||
};
|
||||
}
|
||||
|
||||
export async function refreshAllUsersNtrp(options: { triggerType: NtrpTrigger; taskId?: string | null }) {
|
||||
const userIds = await db.listUserIds();
|
||||
const results = [];
|
||||
for (const user of userIds) {
|
||||
const snapshot = await refreshUserNtrp(user.id, options);
|
||||
results.push({ userId: user.id, ...snapshot });
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function syncAnalysisTrainingData(input: {
|
||||
userId: number;
|
||||
videoId: number;
|
||||
exerciseType?: string | null;
|
||||
overallScore?: number | null;
|
||||
shotCount?: number | null;
|
||||
framesAnalyzed?: number | null;
|
||||
}) {
|
||||
const trainingDate = db.getDateKey();
|
||||
const planMatch = await db.matchActivePlanForExercise(input.userId, input.exerciseType);
|
||||
const exerciseLabel = ACTION_LABELS[input.exerciseType || "unknown"] || input.exerciseType || "视频分析";
|
||||
const recordResult = await db.upsertTrainingRecordBySource({
|
||||
userId: input.userId,
|
||||
planId: planMatch?.planId ?? null,
|
||||
linkedPlanId: planMatch?.planId ?? null,
|
||||
matchConfidence: planMatch?.confidence ?? null,
|
||||
exerciseName: exerciseLabel,
|
||||
exerciseType: input.exerciseType || "unknown",
|
||||
sourceType: "analysis_upload",
|
||||
sourceId: `analysis:${input.videoId}`,
|
||||
videoId: input.videoId,
|
||||
actionCount: input.shotCount ?? 0,
|
||||
durationMinutes: Math.max(1, Math.round((input.framesAnalyzed || 0) / 60)),
|
||||
completed: 1,
|
||||
poseScore: input.overallScore ?? null,
|
||||
trainingDate: new Date(),
|
||||
metadata: {
|
||||
source: "analysis_upload",
|
||||
shotCount: input.shotCount ?? 0,
|
||||
},
|
||||
notes: "自动写入:视频分析",
|
||||
});
|
||||
|
||||
if (recordResult.isNew) {
|
||||
await db.upsertDailyTrainingAggregate({
|
||||
userId: input.userId,
|
||||
trainingDate,
|
||||
deltaMinutes: Math.max(1, Math.round((input.framesAnalyzed || 0) / 60)),
|
||||
deltaSessions: 1,
|
||||
deltaAnalysisCount: 1,
|
||||
deltaTotalActions: input.shotCount ?? 0,
|
||||
deltaEffectiveActions: input.shotCount ?? 0,
|
||||
score: input.overallScore ?? null,
|
||||
metadata: { latestAnalysisExerciseType: input.exerciseType || "unknown" },
|
||||
});
|
||||
}
|
||||
|
||||
const unlocked = await db.refreshAchievementsForUser(input.userId);
|
||||
await refreshUserNtrp(input.userId, { triggerType: "analysis" });
|
||||
return { recordId: recordResult.recordId, unlocked };
|
||||
}
|
||||
|
||||
export async function syncRecordingTrainingData(input: {
|
||||
userId: number;
|
||||
videoId: number;
|
||||
exerciseType?: string | null;
|
||||
title: string;
|
||||
sessionMode?: "practice" | "pk";
|
||||
durationMinutes?: number | null;
|
||||
actionCount?: number | null;
|
||||
actionSummary?: Record<string, number> | null;
|
||||
dominantAction?: string | null;
|
||||
validityStatus?: string | null;
|
||||
invalidReason?: string | null;
|
||||
}) {
|
||||
const trainingDate = db.getDateKey();
|
||||
const resolvedExerciseType = input.exerciseType || input.dominantAction || "recording";
|
||||
const planMatch = await db.matchActivePlanForExercise(input.userId, resolvedExerciseType);
|
||||
const exerciseLabel = ACTION_LABELS[resolvedExerciseType || "unknown"] || resolvedExerciseType || input.title;
|
||||
const totalActions = Math.max(0, input.actionCount ?? 0);
|
||||
const recordResult = await db.upsertTrainingRecordBySource({
|
||||
userId: input.userId,
|
||||
planId: planMatch?.planId ?? null,
|
||||
linkedPlanId: planMatch?.planId ?? null,
|
||||
matchConfidence: planMatch?.confidence ?? null,
|
||||
exerciseName: exerciseLabel,
|
||||
exerciseType: resolvedExerciseType,
|
||||
sourceType: "recording",
|
||||
sourceId: `recording:${input.videoId}`,
|
||||
videoId: input.videoId,
|
||||
actionCount: totalActions,
|
||||
durationMinutes: Math.max(1, input.durationMinutes ?? 5),
|
||||
completed: 1,
|
||||
poseScore: null,
|
||||
trainingDate: new Date(),
|
||||
metadata: {
|
||||
source: "recording",
|
||||
sessionMode: input.sessionMode || "practice",
|
||||
title: input.title,
|
||||
actionCount: totalActions,
|
||||
actionSummary: input.actionSummary ?? {},
|
||||
dominantAction: input.dominantAction ?? null,
|
||||
validityStatus: input.validityStatus ?? "pending",
|
||||
invalidReason: input.invalidReason ?? null,
|
||||
},
|
||||
notes: input.validityStatus?.startsWith("invalid")
|
||||
? `自动写入:录制归档(无效录制)${input.invalidReason ? ` · ${input.invalidReason}` : ""}`
|
||||
: "自动写入:录制归档",
|
||||
});
|
||||
|
||||
if (recordResult.isNew) {
|
||||
await db.upsertDailyTrainingAggregate({
|
||||
userId: input.userId,
|
||||
trainingDate,
|
||||
deltaMinutes: Math.max(1, input.durationMinutes ?? 5),
|
||||
deltaSessions: 1,
|
||||
deltaRecordingCount: 1,
|
||||
deltaPkCount: input.sessionMode === "pk" ? 1 : 0,
|
||||
deltaTotalActions: totalActions,
|
||||
deltaEffectiveActions: input.validityStatus?.startsWith("invalid") ? 0 : totalActions,
|
||||
metadata: {
|
||||
latestRecordingExerciseType: resolvedExerciseType,
|
||||
latestRecordingValidity: input.validityStatus ?? "pending",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const unlocked = await db.refreshAchievementsForUser(input.userId);
|
||||
return { recordId: recordResult.recordId, unlocked };
|
||||
}
|
||||
|
||||
export async function syncLiveTrainingData(input: {
|
||||
userId: number;
|
||||
sessionId: number;
|
||||
title: string;
|
||||
sessionMode: "practice" | "pk";
|
||||
dominantAction?: string | null;
|
||||
durationMs: number;
|
||||
overallScore?: number | null;
|
||||
effectiveSegments: number;
|
||||
totalSegments: number;
|
||||
unknownSegments: number;
|
||||
videoId?: number | null;
|
||||
}) {
|
||||
const trainingDate = db.getDateKey();
|
||||
const planMatch = await db.matchActivePlanForExercise(input.userId, input.dominantAction);
|
||||
const exerciseLabel = ACTION_LABELS[input.dominantAction || "unknown"] || input.title;
|
||||
const recordResult = await db.upsertTrainingRecordBySource({
|
||||
userId: input.userId,
|
||||
planId: planMatch?.planId ?? null,
|
||||
linkedPlanId: planMatch?.planId ?? null,
|
||||
matchConfidence: planMatch?.confidence ?? null,
|
||||
exerciseName: exerciseLabel,
|
||||
exerciseType: input.dominantAction || "unknown",
|
||||
sourceType: "live_analysis",
|
||||
sourceId: `live:${input.sessionId}`,
|
||||
videoId: input.videoId ?? null,
|
||||
actionCount: input.effectiveSegments,
|
||||
durationMinutes: toMinutes(input.durationMs),
|
||||
completed: 1,
|
||||
poseScore: input.overallScore ?? null,
|
||||
trainingDate: new Date(),
|
||||
metadata: {
|
||||
source: "live_analysis",
|
||||
sessionMode: input.sessionMode,
|
||||
totalSegments: input.totalSegments,
|
||||
unknownSegments: input.unknownSegments,
|
||||
},
|
||||
notes: "自动写入:实时分析",
|
||||
});
|
||||
|
||||
if (recordResult.isNew) {
|
||||
await db.upsertDailyTrainingAggregate({
|
||||
userId: input.userId,
|
||||
trainingDate,
|
||||
deltaMinutes: toMinutes(input.durationMs),
|
||||
deltaSessions: 1,
|
||||
deltaLiveAnalysisCount: 1,
|
||||
deltaPkCount: input.sessionMode === "pk" ? 1 : 0,
|
||||
deltaTotalActions: input.totalSegments,
|
||||
deltaEffectiveActions: input.effectiveSegments,
|
||||
deltaUnknownActions: input.unknownSegments,
|
||||
score: input.overallScore ?? null,
|
||||
metadata: { latestLiveDominantAction: input.dominantAction || "unknown" },
|
||||
});
|
||||
}
|
||||
|
||||
const unlocked = await db.refreshAchievementsForUser(input.userId);
|
||||
await refreshUserNtrp(input.userId, { triggerType: "analysis" });
|
||||
return { recordId: recordResult.recordId, unlocked };
|
||||
}
|
||||
96
server/trainingPlan.test.ts
普通文件
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
normalizeAdjustedPlanResponse,
|
||||
normalizeTrainingPlanResponse,
|
||||
} from "./trainingPlan";
|
||||
|
||||
describe("normalizeTrainingPlanResponse", () => {
|
||||
it("accepts canonical title/exercises output", () => {
|
||||
const result = normalizeTrainingPlanResponse({
|
||||
content: JSON.stringify({
|
||||
title: "7天训练计划",
|
||||
exercises: [
|
||||
{
|
||||
day: 1,
|
||||
name: "正手影子挥拍",
|
||||
category: "影子挥拍",
|
||||
duration: 15,
|
||||
description: "完成正手挥拍练习",
|
||||
tips: "保持重心稳定",
|
||||
sets: 3,
|
||||
reps: 12,
|
||||
},
|
||||
],
|
||||
}),
|
||||
fallbackTitle: "fallback",
|
||||
});
|
||||
|
||||
expect(result.title).toBe("7天训练计划");
|
||||
expect(result.exercises).toHaveLength(1);
|
||||
expect(result.exercises[0]?.category).toBe("影子挥拍");
|
||||
});
|
||||
|
||||
it("normalizes qwen day map output into plan exercises", () => {
|
||||
const result = normalizeTrainingPlanResponse({
|
||||
content: JSON.stringify({
|
||||
day_1: {
|
||||
duration_minutes: 45,
|
||||
focus: "基础握拍与正手影子挥拍",
|
||||
exercises: [
|
||||
{
|
||||
name: "握拍方式学习",
|
||||
description: "学习大陆式与东方式握拍",
|
||||
duration_minutes: 10,
|
||||
},
|
||||
{
|
||||
name: "原地小碎步热身与放松",
|
||||
description: "30秒快速小碎步 + 30秒休息",
|
||||
duration_minutes: 10,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
fallbackTitle: "7天训练计划",
|
||||
});
|
||||
|
||||
expect(result.title).toBe("7天训练计划");
|
||||
expect(result.exercises).toHaveLength(2);
|
||||
expect(result.exercises[0]).toMatchObject({
|
||||
day: 1,
|
||||
name: "握拍方式学习",
|
||||
duration: 10,
|
||||
sets: 3,
|
||||
reps: 10,
|
||||
});
|
||||
expect(result.exercises[1]?.category).toBe("脚步移动");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeAdjustedPlanResponse", () => {
|
||||
it("fills missing adjustment notes for day map output", () => {
|
||||
const result = normalizeAdjustedPlanResponse({
|
||||
content: JSON.stringify({
|
||||
day_1: {
|
||||
duration_minutes: 30,
|
||||
focus: "脚步移动",
|
||||
exercises: [
|
||||
{
|
||||
name: "交叉步移动",
|
||||
description: "左右移动并快速回位",
|
||||
duration_minutes: 12,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
fallbackTitle: "当前训练计划",
|
||||
});
|
||||
|
||||
expect(result.title).toBe("当前训练计划");
|
||||
expect(result.adjustmentNotes).toContain("已根据最近分析结果调整");
|
||||
expect(result.exercises[0]).toMatchObject({
|
||||
day: 1,
|
||||
name: "交叉步移动",
|
||||
category: "脚步移动",
|
||||
});
|
||||
});
|
||||
});
|
||||
200
server/trainingPlan.ts
普通文件
@@ -0,0 +1,200 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const exerciseSchema = z.object({
|
||||
day: z.number().int().min(1),
|
||||
name: z.string().min(1),
|
||||
category: z.string().min(1),
|
||||
duration: z.number().positive(),
|
||||
description: z.string().min(1),
|
||||
tips: z.string().min(1),
|
||||
sets: z.number().int().positive(),
|
||||
reps: z.number().int().positive(),
|
||||
});
|
||||
|
||||
const normalizedPlanSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
exercises: z.array(exerciseSchema).min(1),
|
||||
});
|
||||
|
||||
const normalizedAdjustedPlanSchema = normalizedPlanSchema.extend({
|
||||
adjustmentNotes: z.string().min(1),
|
||||
});
|
||||
|
||||
type NormalizedExercise = z.infer<typeof exerciseSchema>;
|
||||
type NormalizedPlan = z.infer<typeof normalizedPlanSchema>;
|
||||
type NormalizedAdjustedPlan = z.infer<typeof normalizedAdjustedPlanSchema>;
|
||||
|
||||
const dayKeyPattern = /^day[_\s-]?(\d+)$/i;
|
||||
|
||||
function extractTextContent(content: unknown) {
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
}
|
||||
|
||||
if (Array.isArray(content)) {
|
||||
const text = content
|
||||
.map(item => (item && typeof item === "object" && "text" in item ? String((item as { text?: unknown }).text ?? "") : ""))
|
||||
.join("")
|
||||
.trim();
|
||||
|
||||
return text.length > 0 ? text : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseJsonContent(content: unknown) {
|
||||
const text = extractTextContent(content);
|
||||
if (!text) {
|
||||
throw new Error("LLM did not return text content");
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(text) as Record<string, unknown>;
|
||||
} catch (error) {
|
||||
throw new Error(`LLM returned invalid JSON: ${error instanceof Error ? error.message : "unknown error"}`);
|
||||
}
|
||||
}
|
||||
|
||||
function toPositiveNumber(value: unknown, fallback: number) {
|
||||
const parsed = typeof value === "number" ? value : Number(value);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function toPositiveInteger(value: unknown, fallback: number) {
|
||||
const parsed = typeof value === "number" ? value : Number.parseInt(String(value), 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function inferCategory(...values: Array<unknown>) {
|
||||
const text = values
|
||||
.filter((value): value is string => typeof value === "string")
|
||||
.join(" ");
|
||||
|
||||
if (/(墙|wall)/i.test(text)) return "墙壁练习";
|
||||
if (/(步|移动|footwork|shuffle|split step)/i.test(text)) return "脚步移动";
|
||||
if (/(挥拍|shadow|正手|反手|发球|截击)/i.test(text)) return "影子挥拍";
|
||||
return "体能训练";
|
||||
}
|
||||
|
||||
function normalizeExercise(
|
||||
day: number,
|
||||
exercise: Record<string, unknown>,
|
||||
section?: Record<string, unknown>
|
||||
): NormalizedExercise {
|
||||
const name =
|
||||
typeof exercise.name === "string" && exercise.name.trim().length > 0
|
||||
? exercise.name.trim()
|
||||
: typeof section?.focus === "string" && section.focus.trim().length > 0
|
||||
? section.focus.trim()
|
||||
: `第${day}天训练项目`;
|
||||
|
||||
const description =
|
||||
typeof exercise.description === "string" && exercise.description.trim().length > 0
|
||||
? exercise.description.trim()
|
||||
: typeof section?.focus === "string" && section.focus.trim().length > 0
|
||||
? section.focus.trim()
|
||||
: `${name}训练`;
|
||||
|
||||
const tips =
|
||||
typeof exercise.tips === "string" && exercise.tips.trim().length > 0
|
||||
? exercise.tips.trim()
|
||||
: typeof section?.focus === "string" && section.focus.trim().length > 0
|
||||
? `重点关注:${section.focus.trim()}`
|
||||
: "保持动作稳定,注意训练节奏。";
|
||||
|
||||
return {
|
||||
day,
|
||||
name,
|
||||
category: inferCategory(exercise.category, name, description, section?.focus),
|
||||
duration: toPositiveNumber(
|
||||
exercise.duration ?? exercise.duration_minutes,
|
||||
toPositiveNumber(section?.duration_minutes, 10)
|
||||
),
|
||||
description,
|
||||
tips,
|
||||
sets: toPositiveInteger(exercise.sets, 3),
|
||||
reps: toPositiveInteger(exercise.reps, 10),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDayMapPlan(
|
||||
raw: Record<string, unknown>,
|
||||
fallbackTitle: string
|
||||
): NormalizedPlan {
|
||||
const exercises: NormalizedExercise[] = [];
|
||||
|
||||
for (const [key, value] of Object.entries(raw)) {
|
||||
const match = key.match(dayKeyPattern);
|
||||
if (!match || !value || typeof value !== "object" || Array.isArray(value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const day = Number.parseInt(match[1] ?? "", 10);
|
||||
if (!Number.isFinite(day) || day <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const section = value as Record<string, unknown>;
|
||||
const sectionExercises = Array.isArray(section.exercises)
|
||||
? section.exercises.filter(
|
||||
(item): item is Record<string, unknown> =>
|
||||
Boolean(item) && typeof item === "object" && !Array.isArray(item)
|
||||
)
|
||||
: [];
|
||||
|
||||
for (const exercise of sectionExercises) {
|
||||
exercises.push(normalizeExercise(day, exercise, section));
|
||||
}
|
||||
}
|
||||
|
||||
return normalizedPlanSchema.parse({
|
||||
title:
|
||||
typeof raw.title === "string" && raw.title.trim().length > 0
|
||||
? raw.title.trim()
|
||||
: fallbackTitle,
|
||||
exercises,
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeTrainingPlanResponse(params: {
|
||||
content: unknown;
|
||||
fallbackTitle: string;
|
||||
}): NormalizedPlan {
|
||||
const raw = parseJsonContent(params.content);
|
||||
|
||||
if (Array.isArray(raw.exercises)) {
|
||||
return normalizedPlanSchema.parse(raw);
|
||||
}
|
||||
|
||||
return normalizeDayMapPlan(raw, params.fallbackTitle);
|
||||
}
|
||||
|
||||
export function normalizeAdjustedPlanResponse(params: {
|
||||
content: unknown;
|
||||
fallbackTitle: string;
|
||||
}): NormalizedAdjustedPlan {
|
||||
const raw = parseJsonContent(params.content);
|
||||
|
||||
if (Array.isArray(raw.exercises)) {
|
||||
return normalizedAdjustedPlanSchema.parse({
|
||||
...raw,
|
||||
adjustmentNotes:
|
||||
typeof raw.adjustmentNotes === "string" && raw.adjustmentNotes.trim().length > 0
|
||||
? raw.adjustmentNotes.trim()
|
||||
: "已根据最近分析结果调整训练内容。",
|
||||
});
|
||||
}
|
||||
|
||||
const normalized = normalizeDayMapPlan(raw, params.fallbackTitle);
|
||||
|
||||
return normalizedAdjustedPlanSchema.parse({
|
||||
...normalized,
|
||||
adjustmentNotes:
|
||||
typeof raw.adjustmentNotes === "string" && raw.adjustmentNotes.trim().length > 0
|
||||
? raw.adjustmentNotes.trim()
|
||||
: typeof raw.adjustment_notes === "string" && raw.adjustment_notes.trim().length > 0
|
||||
? raw.adjustment_notes.trim()
|
||||
: "已根据最近分析结果调整训练内容。",
|
||||
});
|
||||
}
|
||||
69
server/tutorialImageCatalog.ts
普通文件
@@ -0,0 +1,69 @@
|
||||
export type TutorialImageSpec = {
|
||||
imageUrl: string;
|
||||
sourcePageUrl: string;
|
||||
sourceLabel: string;
|
||||
};
|
||||
|
||||
export const TUTORIAL_IMAGE_LIBRARY = {
|
||||
forehand: {
|
||||
imageUrl: "https://upload.wikimedia.org/wikipedia/commons/0/00/Ray_Dunlop_forehand.jpg",
|
||||
sourcePageUrl: "https://commons.wikimedia.org/wiki/File:Ray_Dunlop_forehand.jpg",
|
||||
sourceLabel: "Wikimedia Commons",
|
||||
},
|
||||
backhand: {
|
||||
imageUrl: "https://upload.wikimedia.org/wikipedia/commons/8/8c/Backhand_Federer.jpg",
|
||||
sourcePageUrl: "https://commons.wikimedia.org/wiki/File:Backhand_Federer.jpg",
|
||||
sourceLabel: "Wikimedia Commons",
|
||||
},
|
||||
serve: {
|
||||
imageUrl: "https://upload.wikimedia.org/wikipedia/commons/8/85/Serena_Williams_Serves.JPG",
|
||||
sourcePageUrl: "https://commons.wikimedia.org/wiki/File:Serena_Williams_Serves.JPG",
|
||||
sourceLabel: "Wikimedia Commons",
|
||||
},
|
||||
volley: {
|
||||
imageUrl: "https://upload.wikimedia.org/wikipedia/commons/a/af/Ernest_w._lewis%2C_volleying.jpg",
|
||||
sourcePageUrl: "https://commons.wikimedia.org/wiki/File:Ernest_w._lewis,_volleying.jpg",
|
||||
sourceLabel: "Wikimedia Commons",
|
||||
},
|
||||
action: {
|
||||
imageUrl: "https://upload.wikimedia.org/wikipedia/commons/3/34/Frances_Tiafoe_Backhand.jpg",
|
||||
sourcePageUrl: "https://commons.wikimedia.org/wiki/File:Frances_Tiafoe_Backhand.jpg",
|
||||
sourceLabel: "Wikimedia Commons",
|
||||
},
|
||||
wall: {
|
||||
imageUrl: "https://upload.wikimedia.org/wikipedia/commons/2/2c/Tennis_wall.jpg",
|
||||
sourcePageUrl: "https://commons.wikimedia.org/wiki/File:Tennis_wall.jpg",
|
||||
sourceLabel: "Wikimedia Commons",
|
||||
},
|
||||
strategy: {
|
||||
imageUrl: "https://upload.wikimedia.org/wikipedia/commons/f/f3/Court_plan.png",
|
||||
sourcePageUrl: "https://commons.wikimedia.org/wiki/File:Court_plan.png",
|
||||
sourceLabel: "Wikimedia Commons",
|
||||
},
|
||||
} satisfies Record<string, TutorialImageSpec>;
|
||||
|
||||
const CATEGORY_TO_IMAGE: Record<string, keyof typeof TUTORIAL_IMAGE_LIBRARY> = {
|
||||
forehand: "forehand",
|
||||
backhand: "backhand",
|
||||
serve: "serve",
|
||||
volley: "volley",
|
||||
footwork: "action",
|
||||
shadow: "forehand",
|
||||
wall: "wall",
|
||||
fitness: "action",
|
||||
strategy: "strategy",
|
||||
};
|
||||
|
||||
export function buildTutorialImageKey(slug: string) {
|
||||
return `tutorials/${slug}.webp`;
|
||||
}
|
||||
|
||||
export function buildTutorialImageUrl(slug: string) {
|
||||
return `/uploads/${buildTutorialImageKey(slug)}`;
|
||||
}
|
||||
|
||||
export function getTutorialImageSpec(category: string): TutorialImageSpec | null {
|
||||
const imageKey = CATEGORY_TO_IMAGE[category];
|
||||
if (!imageKey) return null;
|
||||
return TUTORIAL_IMAGE_LIBRARY[imageKey];
|
||||
}
|
||||
43
server/tutorialImages.test.ts
普通文件
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildTutorialImageFfmpegArgs,
|
||||
buildTutorialImageKey,
|
||||
buildTutorialImageUrl,
|
||||
getTutorialImageSpec,
|
||||
} from "./tutorialImages";
|
||||
|
||||
describe("tutorialImages", () => {
|
||||
it("maps tennis categories to image specs", () => {
|
||||
expect(getTutorialImageSpec("forehand")?.sourcePageUrl).toContain("Ray_Dunlop_forehand");
|
||||
expect(getTutorialImageSpec("backhand")?.sourcePageUrl).toContain("Backhand_Federer");
|
||||
expect(getTutorialImageSpec("serve")?.sourcePageUrl).toContain("Serena_Williams_Serves");
|
||||
expect(getTutorialImageSpec("wall")?.sourcePageUrl).toContain("Tennis_wall");
|
||||
expect(getTutorialImageSpec("unknown")).toBeNull();
|
||||
});
|
||||
|
||||
it("builds stable storage keys and public URLs", () => {
|
||||
expect(buildTutorialImageKey("forehand-fundamentals")).toBe("tutorials/forehand-fundamentals.webp");
|
||||
expect(buildTutorialImageUrl("forehand-fundamentals")).toBe("/uploads/tutorials/forehand-fundamentals.webp");
|
||||
});
|
||||
|
||||
it("uses a fixed compression pipeline for tutorial images", () => {
|
||||
const args = buildTutorialImageFfmpegArgs("/tmp/input.jpg", "/tmp/output.webp");
|
||||
|
||||
expect(args).toEqual([
|
||||
"-y",
|
||||
"-i",
|
||||
"/tmp/input.jpg",
|
||||
"-vf",
|
||||
"scale=1200:675:force_original_aspect_ratio=increase,crop=1200:675",
|
||||
"-frames:v",
|
||||
"1",
|
||||
"-c:v",
|
||||
"libwebp",
|
||||
"-quality",
|
||||
"78",
|
||||
"-compression_level",
|
||||
"6",
|
||||
"/tmp/output.webp",
|
||||
]);
|
||||
});
|
||||
});
|
||||
178
server/tutorialImages.ts
普通文件
@@ -0,0 +1,178 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import { tutorialVideos } from "../drizzle/schema";
|
||||
import { getDb } from "./db";
|
||||
import { ENV } from "./_core/env";
|
||||
import { storagePut } from "./storage";
|
||||
import {
|
||||
buildTutorialImageKey,
|
||||
buildTutorialImageUrl,
|
||||
getTutorialImageSpec,
|
||||
type TutorialImageSpec,
|
||||
} from "./tutorialImageCatalog";
|
||||
|
||||
export { buildTutorialImageKey, buildTutorialImageUrl, getTutorialImageSpec } from "./tutorialImageCatalog";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const OUTPUT_WIDTH = 1200;
|
||||
const OUTPUT_HEIGHT = 675;
|
||||
const OUTPUT_QUALITY = 78;
|
||||
const OUTPUT_COMPRESSION_LEVEL = 6;
|
||||
|
||||
type TutorialRow = {
|
||||
id: number;
|
||||
slug: string | null;
|
||||
category: string;
|
||||
title: string;
|
||||
thumbnailUrl: string | null;
|
||||
externalUrl: string | null;
|
||||
sourcePlatform: string | null;
|
||||
};
|
||||
|
||||
function normalizeSlug(slug: string | null, tutorialId: number) {
|
||||
return slug?.trim() ? slug.trim() : `tutorial-${tutorialId}`;
|
||||
}
|
||||
|
||||
export function buildTutorialImageFfmpegArgs(inputPath: string, outputPath: string) {
|
||||
return [
|
||||
"-y",
|
||||
"-i",
|
||||
inputPath,
|
||||
"-vf",
|
||||
`scale=${OUTPUT_WIDTH}:${OUTPUT_HEIGHT}:force_original_aspect_ratio=increase,crop=${OUTPUT_WIDTH}:${OUTPUT_HEIGHT}`,
|
||||
"-frames:v",
|
||||
"1",
|
||||
"-c:v",
|
||||
"libwebp",
|
||||
"-quality",
|
||||
`${OUTPUT_QUALITY}`,
|
||||
"-compression_level",
|
||||
`${OUTPUT_COMPRESSION_LEVEL}`,
|
||||
outputPath,
|
||||
];
|
||||
}
|
||||
|
||||
function isTutorialImageCurrent(tutorial: TutorialRow, spec: TutorialImageSpec, expectedUrl: string) {
|
||||
return tutorial.thumbnailUrl === expectedUrl
|
||||
&& tutorial.externalUrl === spec.sourcePageUrl
|
||||
&& tutorial.sourcePlatform === "wikimedia";
|
||||
}
|
||||
|
||||
function canUseRemoteStorage() {
|
||||
return Boolean(ENV.forgeApiUrl && ENV.forgeApiKey);
|
||||
}
|
||||
|
||||
async function localAssetExists(slug: string) {
|
||||
const filePath = path.join(ENV.localStorageDir, buildTutorialImageKey(slug));
|
||||
try {
|
||||
await access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadSourceImage(url: string) {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
"accept": "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
|
||||
"user-agent": "tennis-training-hub/1.0 (+https://te.hao.work/)",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Image download failed (${response.status}): ${url}`);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
if (!contentType.startsWith("image/")) {
|
||||
throw new Error(`Unexpected image content-type: ${contentType || "unknown"}`);
|
||||
}
|
||||
|
||||
return Buffer.from(await response.arrayBuffer());
|
||||
}
|
||||
|
||||
async function normalizeTutorialImage(buffer: Buffer) {
|
||||
const tempDir = await mkdtemp(path.join(os.tmpdir(), "tutorial-image-"));
|
||||
const inputPath = path.join(tempDir, "source.input");
|
||||
const outputPath = path.join(tempDir, "tutorial.webp");
|
||||
|
||||
try {
|
||||
await writeFile(inputPath, buffer);
|
||||
await execFileAsync("ffmpeg", buildTutorialImageFfmpegArgs(inputPath, outputPath), {
|
||||
timeout: 30_000,
|
||||
maxBuffer: 8 * 1024 * 1024,
|
||||
});
|
||||
return await readFile(outputPath);
|
||||
} finally {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncTutorialImages() {
|
||||
const db = await getDb();
|
||||
if (!db) return { updated: 0, skipped: 0, failed: 0 };
|
||||
|
||||
const tutorials = await db.select({
|
||||
id: tutorialVideos.id,
|
||||
slug: tutorialVideos.slug,
|
||||
category: tutorialVideos.category,
|
||||
title: tutorialVideos.title,
|
||||
thumbnailUrl: tutorialVideos.thumbnailUrl,
|
||||
externalUrl: tutorialVideos.externalUrl,
|
||||
sourcePlatform: tutorialVideos.sourcePlatform,
|
||||
}).from(tutorialVideos)
|
||||
.where(and(eq(tutorialVideos.topicArea, "tennis_skill"), eq(tutorialVideos.isPublished, 1)))
|
||||
.orderBy(asc(tutorialVideos.sortOrder), asc(tutorialVideos.id));
|
||||
|
||||
let updated = 0;
|
||||
let skipped = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const tutorial of tutorials) {
|
||||
const spec = getTutorialImageSpec(tutorial.category);
|
||||
if (!spec) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const slug = normalizeSlug(tutorial.slug, tutorial.id);
|
||||
const expectedUrl = buildTutorialImageUrl(slug);
|
||||
const assetExists = await localAssetExists(slug);
|
||||
|
||||
if (canUseRemoteStorage() && tutorial.thumbnailUrl && tutorial.externalUrl === spec.sourcePageUrl && tutorial.sourcePlatform === "wikimedia") {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (assetExists && isTutorialImageCurrent(tutorial, spec, expectedUrl)) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const sourceBuffer = await downloadSourceImage(spec.imageUrl);
|
||||
const webpBuffer = await normalizeTutorialImage(sourceBuffer);
|
||||
const stored = await storagePut(buildTutorialImageKey(slug), webpBuffer, "image/webp");
|
||||
|
||||
await db.update(tutorialVideos).set({
|
||||
thumbnailUrl: stored.url,
|
||||
externalUrl: spec.sourcePageUrl,
|
||||
sourcePlatform: "wikimedia",
|
||||
}).where(eq(tutorialVideos.id, tutorial.id));
|
||||
|
||||
updated += 1;
|
||||
} catch (error) {
|
||||
failed += 1;
|
||||
console.error(`[TutorialImages] Failed to sync ${tutorial.title}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[TutorialImages] sync complete: updated=${updated} skipped=${skipped} failed=${failed}`);
|
||||
return { updated, skipped, failed };
|
||||
}
|
||||
54
server/vision.test.ts
普通文件
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { extractStructuredJsonContent, normalizeMultimodalCorrectionReport } from "./vision";
|
||||
|
||||
describe("extractStructuredJsonContent", () => {
|
||||
it("parses JSON wrapped in markdown code fences", () => {
|
||||
const parsed = extractStructuredJsonContent("```json\n{\"summary\":\"ok\",\"drills\":[]}\n```");
|
||||
expect(parsed).toMatchObject({ summary: "ok", drills: [] });
|
||||
});
|
||||
|
||||
it("parses text content arrays returned by chat completions", () => {
|
||||
const parsed = extractStructuredJsonContent([
|
||||
{ type: "text", text: "{\"summary\":\"ok\",\"drills\":[]}" },
|
||||
]);
|
||||
expect(parsed).toMatchObject({ summary: "ok", drills: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeMultimodalCorrectionReport", () => {
|
||||
it("fills missing drill arrays so markdown rendering does not crash", () => {
|
||||
const report = normalizeMultimodalCorrectionReport({
|
||||
summary: "反手动作可继续优化",
|
||||
overallScore: 81,
|
||||
confidence: 76,
|
||||
drills: [
|
||||
{
|
||||
name: "反手节奏重建",
|
||||
purpose: "稳定击球点",
|
||||
durationMinutes: 8,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(report.drills[0]?.steps.length).toBeGreaterThan(0);
|
||||
expect(report.drills[0]?.coachingCues.length).toBeGreaterThan(0);
|
||||
expect(report.nextSessionFocus.length).toBeGreaterThan(0);
|
||||
expect(report.recommendedCaptureTips.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("maps provider-specific phaseAssessment payloads into phase findings", () => {
|
||||
const report = normalizeMultimodalCorrectionReport({
|
||||
phaseAssessment: {
|
||||
preparation: "肩膀转动信息不足",
|
||||
contact: "无法判断击球点",
|
||||
},
|
||||
issueTags: ["补拍侧后方连续帧"],
|
||||
recommendedCaptureTips: "提供连续关键帧",
|
||||
});
|
||||
|
||||
expect(report.phaseFindings.length).toBe(2);
|
||||
expect(report.summary).toContain("preparation");
|
||||
expect(report.nextSessionFocus).toContain("补拍侧后方连续帧");
|
||||
expect(report.recommendedCaptureTips).toContain("提供连续关键帧");
|
||||
});
|
||||
});
|
||||
193
server/vision.ts
普通文件
@@ -0,0 +1,193 @@
|
||||
import type { Message } from "./_core/llm";
|
||||
|
||||
export type MultimodalCorrectionReport = {
|
||||
summary: string;
|
||||
overallScore: number;
|
||||
confidence: number;
|
||||
phaseFindings: Array<{
|
||||
phase: string;
|
||||
score: number;
|
||||
observation: string;
|
||||
impact: string;
|
||||
}>;
|
||||
bodyPartFindings: Array<{
|
||||
bodyPart: string;
|
||||
issue: string;
|
||||
recommendation: string;
|
||||
}>;
|
||||
priorityFixes: Array<{
|
||||
title: string;
|
||||
why: string;
|
||||
howToPractice: string;
|
||||
successMetric: string;
|
||||
}>;
|
||||
drills: Array<{
|
||||
name: string;
|
||||
purpose: string;
|
||||
durationMinutes: number;
|
||||
steps: string[];
|
||||
coachingCues: string[];
|
||||
}>;
|
||||
safetyRisks: string[];
|
||||
nextSessionFocus: string[];
|
||||
recommendedCaptureTips: string[];
|
||||
};
|
||||
|
||||
function toObject(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
function toString(value: unknown, fallback: string) {
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
return value.trim();
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function toNumber(value: unknown, fallback: number) {
|
||||
const parsed = typeof value === "number" ? value : Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
function toStringArray(value: unknown, fallback: string[] = []) {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((item) => toString(item, ""))
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
return [value.trim()];
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function extractTextContent(content: unknown) {
|
||||
if (typeof content === "string") {
|
||||
return content.trim();
|
||||
}
|
||||
|
||||
if (!Array.isArray(content)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return content
|
||||
.map((part) => {
|
||||
if (typeof part === "string") return part;
|
||||
const record = part as Message["content"];
|
||||
if (record && typeof record === "object" && "type" in record && record.type === "text") {
|
||||
return typeof record.text === "string" ? record.text : "";
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.join("\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function extractJsonBlock(text: string) {
|
||||
const fencedMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
||||
if (fencedMatch?.[1]) {
|
||||
return fencedMatch[1].trim();
|
||||
}
|
||||
|
||||
const objectStart = text.indexOf("{");
|
||||
const objectEnd = text.lastIndexOf("}");
|
||||
if (objectStart >= 0 && objectEnd > objectStart) {
|
||||
return text.slice(objectStart, objectEnd + 1);
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
export function extractStructuredJsonContent(content: unknown) {
|
||||
if (content && typeof content === "object" && !Array.isArray(content)) {
|
||||
return content as Record<string, unknown>;
|
||||
}
|
||||
|
||||
const text = extractTextContent(content);
|
||||
if (!text) {
|
||||
throw new Error("Vision model returned empty content");
|
||||
}
|
||||
|
||||
const jsonText = extractJsonBlock(text);
|
||||
return JSON.parse(jsonText) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function normalizeMultimodalCorrectionReport(raw: unknown): MultimodalCorrectionReport {
|
||||
const source = toObject(raw);
|
||||
const phaseAssessment = toObject(source.phaseAssessment);
|
||||
const derivedPhaseFindings =
|
||||
Array.isArray(source.phaseFindings) && source.phaseFindings.length > 0
|
||||
? source.phaseFindings
|
||||
: Object.entries(phaseAssessment).map(([phase, observation]) => ({
|
||||
phase,
|
||||
score: 60,
|
||||
observation: toString(observation, "当前图片信息不足,建议补充连续动作帧。"),
|
||||
impact: "该阶段信息不足会限制系统对发力链条和节奏的判断。",
|
||||
}));
|
||||
const derivedSummary =
|
||||
typeof source.summary === "string" && source.summary.trim().length > 0
|
||||
? source.summary
|
||||
: derivedPhaseFindings.length > 0
|
||||
? `已完成图片审阅。当前可见结论:${derivedPhaseFindings
|
||||
.slice(0, 2)
|
||||
.map((item) => `${item.phase}${item.observation}`)
|
||||
.join(";")}`
|
||||
: "已完成图片审阅,请结合关键修正点继续训练。";
|
||||
|
||||
return {
|
||||
summary: toString(derivedSummary, "已完成图片审阅,请结合关键修正点继续训练。"),
|
||||
overallScore: Math.max(0, Math.min(100, toNumber(source.overallScore, 75))),
|
||||
confidence: Math.max(0, Math.min(100, toNumber(source.confidence, 70))),
|
||||
phaseFindings: derivedPhaseFindings.map((item, index) => {
|
||||
const row = toObject(item);
|
||||
return {
|
||||
phase: toString(row.phase, `阶段 ${index + 1}`),
|
||||
score: Math.max(0, Math.min(100, toNumber(row.score, 70))),
|
||||
observation: toString(row.observation, "该阶段已完成基础识别。"),
|
||||
impact: toString(row.impact, "建议结合连续视频继续观察动作节奏。"),
|
||||
};
|
||||
}),
|
||||
bodyPartFindings: Array.isArray(source.bodyPartFindings)
|
||||
? source.bodyPartFindings.map((item, index) => {
|
||||
const row = toObject(item);
|
||||
return {
|
||||
bodyPart: toString(row.bodyPart, `部位 ${index + 1}`),
|
||||
issue: toString(row.issue, "需要继续观察该部位的发力与稳定性。"),
|
||||
recommendation: toString(row.recommendation, "下次拍摄时提供更完整角度并重复同类动作。"),
|
||||
};
|
||||
})
|
||||
: [],
|
||||
priorityFixes: Array.isArray(source.priorityFixes)
|
||||
? source.priorityFixes.map((item, index) => {
|
||||
const row = toObject(item);
|
||||
return {
|
||||
title: toString(row.title, `修正重点 ${index + 1}`),
|
||||
why: toString(row.why, "该问题会影响击球质量与动作稳定性。"),
|
||||
howToPractice: toString(row.howToPractice, "请使用影子挥拍和定点重复练习进行修正。"),
|
||||
successMetric: toString(row.successMetric, "连续 3 组动作保持稳定且节奏一致。"),
|
||||
};
|
||||
})
|
||||
: [],
|
||||
drills: Array.isArray(source.drills)
|
||||
? source.drills.map((item, index) => {
|
||||
const row = toObject(item);
|
||||
return {
|
||||
name: toString(row.name, `练习 ${index + 1}`),
|
||||
purpose: toString(row.purpose, "针对当前视觉识别出的重点问题做专项修正。"),
|
||||
durationMinutes: Math.max(3, toNumber(row.durationMinutes, 8)),
|
||||
steps: toStringArray(row.steps, ["从慢速影子挥拍开始,逐步加入完整节奏。"]),
|
||||
coachingCues: toStringArray(row.coachingCues, ["保持击球点在身体前侧", "注意转体与重心传递"]),
|
||||
};
|
||||
})
|
||||
: [],
|
||||
safetyRisks: toStringArray(source.safetyRisks),
|
||||
nextSessionFocus: toStringArray(source.nextSessionFocus, toStringArray(source.issueTags, ["保持同一动作连续拍摄 6-10 次"])),
|
||||
recommendedCaptureTips: toStringArray(source.recommendedCaptureTips, ["保证全身入镜,并保持拍摄角度稳定"]),
|
||||
};
|
||||
}
|
||||
62
server/worker.ts
普通文件
@@ -0,0 +1,62 @@
|
||||
import "dotenv/config";
|
||||
import { ENV } from "./_core/env";
|
||||
import * as db from "./db";
|
||||
import { processBackgroundTask } from "./taskWorker";
|
||||
|
||||
const workerId = `app-worker-${process.pid}`;
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function workOnce() {
|
||||
await db.failExhaustedBackgroundTasks();
|
||||
await db.requeueStaleBackgroundTasks(new Date(Date.now() - ENV.backgroundTaskStaleMs));
|
||||
|
||||
const task = await db.claimNextBackgroundTask(workerId);
|
||||
if (!task) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const heartbeatTimer = setInterval(() => {
|
||||
void db.heartbeatBackgroundTask(task.id, workerId).catch((error) => {
|
||||
console.error(`[worker] heartbeat failed for ${task.id}:`, error);
|
||||
});
|
||||
}, ENV.backgroundTaskHeartbeatMs);
|
||||
|
||||
try {
|
||||
const result = await processBackgroundTask(task);
|
||||
if (result !== null) {
|
||||
await db.completeBackgroundTask(task.id, result, "任务执行完成");
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown background task error";
|
||||
await db.failBackgroundTask(task.id, message);
|
||||
await db.failVisionTestRun(task.id, message);
|
||||
console.error(`[worker] task ${task.id} failed:`, error);
|
||||
} finally {
|
||||
clearInterval(heartbeatTimer);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`[worker] ${workerId} started`);
|
||||
for (;;) {
|
||||
try {
|
||||
const hasWorked = await workOnce();
|
||||
if (!hasWorked) {
|
||||
await sleep(ENV.backgroundTaskPollMs);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[worker] loop error", error);
|
||||
await sleep(Math.max(ENV.backgroundTaskPollMs, 3_000));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error("[worker] fatal error", error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -22,7 +22,10 @@ test("training page shows plan generation flow", async ({ page }) => {
|
||||
|
||||
await page.goto("/training");
|
||||
await expect(page.getByTestId("training-title")).toBeVisible();
|
||||
await expect(page.getByTestId("training-generate-button")).toBeVisible();
|
||||
const generateButton = page.getByRole("button", { name: "生成训练计划" }).last();
|
||||
await expect(generateButton).toBeVisible();
|
||||
await generateButton.click();
|
||||
await expect(page).toHaveURL(/\/training$/);
|
||||
});
|
||||
|
||||
test("videos page renders video library items", async ({ page }) => {
|
||||
@@ -33,6 +36,16 @@ test("videos page renders video library items", async ({ page }) => {
|
||||
await expect(page.getByTestId("video-card")).toHaveCount(1);
|
||||
});
|
||||
|
||||
test("videos page opens lightweight clip editor", async ({ page }) => {
|
||||
await installAppMocks(page, { authenticated: true });
|
||||
|
||||
await page.goto("/videos");
|
||||
await page.getByRole("button", { name: "轻剪辑" }).click();
|
||||
await expect(page.getByText("PC 轻剪辑工作台")).toBeVisible();
|
||||
await expect(page.locator("text=建议保留:正手启动").first()).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "导出草稿" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("live camera page exposes camera startup controls", async ({ page }) => {
|
||||
await installAppMocks(page, { authenticated: true });
|
||||
|
||||
@@ -55,6 +68,72 @@ test("live camera starts analysis and produces scores", async ({ page }) => {
|
||||
await expect(page.getByTestId("live-camera-score-overall")).toBeVisible();
|
||||
});
|
||||
|
||||
test("live camera switches into viewer mode when another device already owns analysis", async ({ page }) => {
|
||||
await installAppMocks(page, { authenticated: true, liveViewerMode: true });
|
||||
|
||||
await page.goto("/live-camera");
|
||||
await expect(page.getByText("同步观看模式")).toBeVisible();
|
||||
await expect(page.getByText(/同步观看|重新同步/).first()).toBeVisible();
|
||||
await expect(page.getByText("当前设备已锁定为观看模式")).toBeVisible();
|
||||
await expect(page.getByTestId("live-camera-viewer-sync-card")).toContainText("其他设备实时分析");
|
||||
await expect(page.getByTestId("live-camera-viewer-sync-card")).toContainText("移动端");
|
||||
await expect(page.getByTestId("live-camera-viewer-sync-card")).toContainText("均衡模式");
|
||||
await expect(page.getByTestId("live-camera-viewer-sync-card")).toContainText("猩猩");
|
||||
await expect(page.getByTestId("live-camera-score-overall")).toBeVisible();
|
||||
});
|
||||
|
||||
test("live camera recovers mojibake viewer titles before rendering", async ({ page }) => {
|
||||
const state = await installAppMocks(page, { authenticated: true, liveViewerMode: true });
|
||||
const mojibakeTitle = Buffer.from("服务端同步烟雾测试", "utf8").toString("latin1");
|
||||
if (state.liveRuntime.runtimeSession) {
|
||||
state.liveRuntime.runtimeSession.title = mojibakeTitle;
|
||||
state.liveRuntime.runtimeSession.snapshot = {
|
||||
...state.liveRuntime.runtimeSession.snapshot,
|
||||
title: mojibakeTitle,
|
||||
};
|
||||
}
|
||||
|
||||
await page.goto("/live-camera");
|
||||
await expect(page.getByRole("heading", { name: "服务端同步烟雾测试" })).toBeVisible();
|
||||
await expect(page.getByText(mojibakeTitle)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("live camera no longer opens viewer peer retries when server relay is active", async ({ page }) => {
|
||||
const state = await installAppMocks(page, {
|
||||
authenticated: true,
|
||||
liveViewerMode: true,
|
||||
viewerSignalConflictOnce: true,
|
||||
});
|
||||
|
||||
await page.goto("/live-camera");
|
||||
await expect(page.getByText("同步观看模式")).toBeVisible();
|
||||
await expect.poll(() => state.viewerSignalConflictRemaining).toBe(1);
|
||||
await expect.poll(() => state.mediaSession?.viewerCount ?? 0).toBe(0);
|
||||
await expect(page.locator('img[alt="同步中的实时分析画面"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test("live camera archives overlay videos into the library after analysis stops", async ({ page }) => {
|
||||
await installAppMocks(page, { authenticated: true, videos: [] });
|
||||
|
||||
await page.goto("/live-camera");
|
||||
await page.getByRole("button", { name: "下一步" }).click();
|
||||
await page.getByRole("button", { name: "下一步" }).click();
|
||||
await page.getByRole("button", { name: "下一步" }).click();
|
||||
await page.getByRole("button", { name: /启用摄像头/ }).click();
|
||||
|
||||
await expect(page.getByTestId("live-camera-analyze-button")).toBeVisible();
|
||||
await page.getByTestId("live-camera-analyze-button").click();
|
||||
await expect(page.getByTestId("live-camera-score-overall")).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "结束分析" }).click();
|
||||
await expect(page.getByText("分析结果已保存")).toBeVisible({ timeout: 8_000 });
|
||||
|
||||
await page.goto("/videos");
|
||||
await expect(page.getByTestId("video-card")).toHaveCount(1);
|
||||
await expect(page.getByText("实时分析录像").first()).toBeVisible();
|
||||
await expect(page.getByText("实时分析").first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("recorder flow archives a session and exposes it in videos", async ({ page }) => {
|
||||
await installAppMocks(page, { authenticated: true, videos: [] });
|
||||
|
||||
@@ -81,3 +160,11 @@ test("recorder flow archives a session and exposes it in videos", async ({ page
|
||||
await expect(page.getByTestId("video-card")).toHaveCount(1);
|
||||
await expect(page.getByText("E2E 录制")).toBeVisible();
|
||||
});
|
||||
|
||||
test("recorder blocks local camera when another device owns live analysis", async ({ page }) => {
|
||||
await installAppMocks(page, { authenticated: true, liveViewerMode: true });
|
||||
|
||||
await page.goto("/recorder");
|
||||
await expect(page.getByText("当前账号已有其他设备正在实时分析")).toBeVisible();
|
||||
await expect(page.getByTestId("recorder-start-camera-button")).toBeDisabled();
|
||||
});
|
||||
|
||||
@@ -10,6 +10,19 @@ type MockUser = {
|
||||
skillLevel: string;
|
||||
trainingGoals: string | null;
|
||||
ntrpRating: number;
|
||||
manualNtrpRating: number | null;
|
||||
manualNtrpCapturedAt: string | null;
|
||||
heightCm: number | null;
|
||||
weightKg: number | null;
|
||||
sprintSpeedScore: number | null;
|
||||
explosivePowerScore: number | null;
|
||||
agilityScore: number | null;
|
||||
enduranceScore: number | null;
|
||||
flexibilityScore: number | null;
|
||||
coreStabilityScore: number | null;
|
||||
shoulderMobilityScore: number | null;
|
||||
hipMobilityScore: number | null;
|
||||
assessmentNotes: string | null;
|
||||
totalSessions: number;
|
||||
totalMinutes: number;
|
||||
totalShots: number;
|
||||
@@ -36,6 +49,7 @@ type MockMediaSession = {
|
||||
uploadedBytes: number;
|
||||
durationMs: number;
|
||||
streamConnected: boolean;
|
||||
viewerCount?: number;
|
||||
playback: {
|
||||
webmUrl?: string;
|
||||
mp4Url?: string;
|
||||
@@ -59,9 +73,34 @@ type MockAppState = {
|
||||
user: MockUser;
|
||||
videos: any[];
|
||||
analyses: any[];
|
||||
tasks: any[];
|
||||
activePlan: {
|
||||
id: number;
|
||||
title: string;
|
||||
skillLevel: string;
|
||||
durationDays: number;
|
||||
exercises: Array<{
|
||||
day: number;
|
||||
name: string;
|
||||
category: string;
|
||||
duration: number;
|
||||
description: string;
|
||||
tips: string;
|
||||
sets: number;
|
||||
reps: number;
|
||||
}>;
|
||||
version: number;
|
||||
adjustmentNotes: string | null;
|
||||
} | null;
|
||||
mediaSession: MockMediaSession | null;
|
||||
liveRuntime: {
|
||||
role: "idle" | "owner" | "viewer";
|
||||
runtimeSession: any | null;
|
||||
};
|
||||
nextVideoId: number;
|
||||
nextTaskId: number;
|
||||
authMeNullResponsesAfterLogin: number;
|
||||
viewerSignalConflictRemaining: number;
|
||||
};
|
||||
|
||||
function trpcResult(json: unknown) {
|
||||
@@ -83,6 +122,19 @@ function buildUser(name = "TestPlayer"): MockUser {
|
||||
skillLevel: "beginner",
|
||||
trainingGoals: null,
|
||||
ntrpRating: 2.8,
|
||||
manualNtrpRating: 2.5,
|
||||
manualNtrpCapturedAt: nowIso(),
|
||||
heightCm: 178,
|
||||
weightKg: 68,
|
||||
sprintSpeedScore: 4,
|
||||
explosivePowerScore: 4,
|
||||
agilityScore: 4,
|
||||
enduranceScore: 3,
|
||||
flexibilityScore: 3,
|
||||
coreStabilityScore: 4,
|
||||
shoulderMobilityScore: 3,
|
||||
hipMobilityScore: 4,
|
||||
assessmentNotes: "每周可练 3 次,右肩偶尔偏紧。",
|
||||
totalSessions: 12,
|
||||
totalMinutes: 320,
|
||||
totalShots: 280,
|
||||
@@ -95,6 +147,7 @@ function buildUser(name = "TestPlayer"): MockUser {
|
||||
}
|
||||
|
||||
function buildStats(user: MockUser) {
|
||||
const hasSystemNtrp = user.ntrpRating != null;
|
||||
return {
|
||||
ntrpRating: user.ntrpRating,
|
||||
totalSessions: user.totalSessions,
|
||||
@@ -114,9 +167,97 @@ function buildStats(user: MockUser) {
|
||||
shotCount: 18,
|
||||
},
|
||||
],
|
||||
recentLiveSessions: [],
|
||||
dailyTraining: [
|
||||
{
|
||||
trainingDate: "2026-03-13",
|
||||
totalMinutes: 48,
|
||||
sessionCount: 2,
|
||||
effectiveActions: 36,
|
||||
averageScore: 80,
|
||||
},
|
||||
{
|
||||
trainingDate: "2026-03-14",
|
||||
totalMinutes: 32,
|
||||
sessionCount: 1,
|
||||
effectiveActions: 18,
|
||||
averageScore: 84,
|
||||
},
|
||||
],
|
||||
achievements: [
|
||||
{
|
||||
key: "training_day_1",
|
||||
name: "开练",
|
||||
description: "完成首个训练日",
|
||||
progressPct: 100,
|
||||
unlocked: true,
|
||||
},
|
||||
{
|
||||
key: "analyses_1",
|
||||
name: "分析首秀",
|
||||
description: "完成首个分析会话",
|
||||
progressPct: 100,
|
||||
unlocked: true,
|
||||
},
|
||||
{
|
||||
key: "live_analysis_5",
|
||||
name: "实时观察者",
|
||||
description: "完成 5 次实时分析",
|
||||
progressPct: 40,
|
||||
unlocked: false,
|
||||
},
|
||||
],
|
||||
latestNtrpSnapshot: {
|
||||
rating: user.ntrpRating,
|
||||
createdAt: nowIso(),
|
||||
dimensionScores: {
|
||||
poseAccuracy: 82,
|
||||
strokeConsistency: 78,
|
||||
footwork: 74,
|
||||
fluidity: 79,
|
||||
timing: 77,
|
||||
matchReadiness: 70,
|
||||
},
|
||||
},
|
||||
trainingProfileStatus: {
|
||||
hasSystemNtrp,
|
||||
isComplete: true,
|
||||
missingFields: [],
|
||||
effectiveNtrp: user.ntrpRating,
|
||||
ntrpSource: hasSystemNtrp ? "system" : "manual",
|
||||
assessmentSnapshot: {
|
||||
heightCm: user.heightCm,
|
||||
weightKg: user.weightKg,
|
||||
sprintSpeedScore: user.sprintSpeedScore,
|
||||
explosivePowerScore: user.explosivePowerScore,
|
||||
agilityScore: user.agilityScore,
|
||||
enduranceScore: user.enduranceScore,
|
||||
flexibilityScore: user.flexibilityScore,
|
||||
coreStabilityScore: user.coreStabilityScore,
|
||||
shoulderMobilityScore: user.shoulderMobilityScore,
|
||||
hipMobilityScore: user.hipMobilityScore,
|
||||
assessmentNotes: user.assessmentNotes,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function readTrpcInput(route: Route, operationIndex: number) {
|
||||
const url = new URL(route.request().url());
|
||||
const rawSearchInput = url.searchParams.get("input");
|
||||
|
||||
if (rawSearchInput) {
|
||||
const parsed = JSON.parse(rawSearchInput);
|
||||
return parsed?.json ?? parsed?.[operationIndex]?.json ?? null;
|
||||
}
|
||||
|
||||
const postData = route.request().postData();
|
||||
if (!postData) return null;
|
||||
|
||||
const parsed = JSON.parse(postData);
|
||||
return parsed?.json ?? parsed?.[operationIndex]?.json ?? parsed?.[String(operationIndex)]?.json ?? null;
|
||||
}
|
||||
|
||||
function buildMediaSession(user: MockUser, title: string): MockMediaSession {
|
||||
return {
|
||||
id: "session-e2e",
|
||||
@@ -141,6 +282,36 @@ function buildMediaSession(user: MockUser, title: string): MockMediaSession {
|
||||
};
|
||||
}
|
||||
|
||||
function createTask(state: MockAppState, input: {
|
||||
type: string;
|
||||
title: string;
|
||||
status?: string;
|
||||
progress?: number;
|
||||
message?: string;
|
||||
result?: any;
|
||||
error?: string | null;
|
||||
}) {
|
||||
const task = {
|
||||
id: `task-${state.nextTaskId++}`,
|
||||
userId: state.user.id,
|
||||
type: input.type,
|
||||
status: input.status ?? "succeeded",
|
||||
title: input.title,
|
||||
message: input.message ?? "任务执行完成",
|
||||
progress: input.progress ?? 100,
|
||||
result: input.result ?? null,
|
||||
error: input.error ?? null,
|
||||
attempts: input.status === "failed" ? 2 : 1,
|
||||
maxAttempts: input.type === "media_finalize" ? 90 : 3,
|
||||
startedAt: nowIso(),
|
||||
completedAt: input.status === "queued" || input.status === "running" ? null : nowIso(),
|
||||
createdAt: nowIso(),
|
||||
updatedAt: nowIso(),
|
||||
};
|
||||
state.tasks = [task, ...state.tasks];
|
||||
return task;
|
||||
}
|
||||
|
||||
async function fulfillJson(route: Route, body: unknown) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
@@ -152,7 +323,7 @@ async function fulfillJson(route: Route, body: unknown) {
|
||||
async function handleTrpc(route: Route, state: MockAppState) {
|
||||
const url = new URL(route.request().url());
|
||||
const operations = url.pathname.replace("/api/trpc/", "").split(",");
|
||||
const results = operations.map((operation) => {
|
||||
const results = await Promise.all(operations.map(async (operation, operationIndex) => {
|
||||
switch (operation) {
|
||||
case "auth.me":
|
||||
if (state.authenticated && state.authMeNullResponsesAfterLogin > 0) {
|
||||
@@ -165,14 +336,230 @@ async function handleTrpc(route: Route, state: MockAppState) {
|
||||
return trpcResult({ user: state.user, isNew: false });
|
||||
case "profile.stats":
|
||||
return trpcResult(buildStats(state.user));
|
||||
case "profile.update": {
|
||||
const input = await readTrpcInput(route, operationIndex);
|
||||
state.user = {
|
||||
...state.user,
|
||||
...input,
|
||||
updatedAt: nowIso(),
|
||||
manualNtrpCapturedAt:
|
||||
input?.manualNtrpRating !== undefined
|
||||
? input.manualNtrpRating == null
|
||||
? null
|
||||
: nowIso()
|
||||
: state.user.manualNtrpCapturedAt,
|
||||
};
|
||||
return trpcResult({ success: true });
|
||||
}
|
||||
case "plan.active":
|
||||
return trpcResult(null);
|
||||
return trpcResult(state.activePlan);
|
||||
case "plan.list":
|
||||
return trpcResult([]);
|
||||
return trpcResult(state.activePlan ? [state.activePlan] : []);
|
||||
case "plan.generate": {
|
||||
const input = await readTrpcInput(route, operationIndex);
|
||||
const durationDays = Number(input?.durationDays ?? 7);
|
||||
const skillLevel = input?.skillLevel ?? state.user.skillLevel;
|
||||
state.activePlan = {
|
||||
id: 200,
|
||||
title: `${state.user.name} 的训练计划`,
|
||||
skillLevel,
|
||||
durationDays,
|
||||
version: 1,
|
||||
adjustmentNotes: null,
|
||||
exercises: [
|
||||
{
|
||||
day: 1,
|
||||
name: "正手影子挥拍",
|
||||
category: "影子挥拍",
|
||||
duration: 15,
|
||||
description: "练习完整引拍和收拍动作。",
|
||||
tips: "保持重心稳定,击球点在身体前侧。",
|
||||
sets: 3,
|
||||
reps: 12,
|
||||
},
|
||||
{
|
||||
day: 1,
|
||||
name: "交叉步移动",
|
||||
category: "脚步移动",
|
||||
duration: 12,
|
||||
description: "强化启动和回位节奏。",
|
||||
tips: "每次移动后快速回到准备姿势。",
|
||||
sets: 4,
|
||||
reps: 10,
|
||||
},
|
||||
],
|
||||
};
|
||||
return trpcResult({
|
||||
taskId: createTask(state, {
|
||||
type: "training_plan_generate",
|
||||
title: `${durationDays}天训练计划生成`,
|
||||
result: {
|
||||
kind: "training_plan_generate",
|
||||
planId: state.activePlan.id,
|
||||
plan: state.activePlan,
|
||||
},
|
||||
}).id,
|
||||
});
|
||||
}
|
||||
case "plan.adjust":
|
||||
return trpcResult({
|
||||
taskId: createTask(state, {
|
||||
type: "training_plan_adjust",
|
||||
title: "训练计划调整",
|
||||
result: {
|
||||
kind: "training_plan_adjust",
|
||||
adjustmentNotes: "已根据最近分析结果调整训练重点。",
|
||||
},
|
||||
}).id,
|
||||
});
|
||||
case "video.list":
|
||||
return trpcResult(state.videos);
|
||||
case "video.upload": {
|
||||
const input = await readTrpcInput(route, operationIndex);
|
||||
const video = {
|
||||
id: state.nextVideoId++,
|
||||
title: input?.title || `实时分析录像 ${state.nextVideoId}`,
|
||||
url: `/uploads/${state.nextVideoId}.${input?.format || "webm"}`,
|
||||
format: input?.format || "webm",
|
||||
fileSize: input?.fileSize || 1024 * 1024,
|
||||
duration: input?.duration || 60,
|
||||
exerciseType: input?.exerciseType || "live_analysis",
|
||||
analysisStatus: "completed",
|
||||
createdAt: nowIso(),
|
||||
};
|
||||
state.videos = [video, ...state.videos];
|
||||
return trpcResult({ videoId: video.id, url: video.url });
|
||||
}
|
||||
case "analysis.list":
|
||||
return trpcResult(state.analyses);
|
||||
case "analysis.liveSessionList":
|
||||
return trpcResult([]);
|
||||
case "analysis.runtimeGet":
|
||||
return trpcResult(state.liveRuntime);
|
||||
case "analysis.runtimeAcquire":
|
||||
if (state.liveRuntime.runtimeSession?.status === "active" && state.liveRuntime.role === "viewer") {
|
||||
return trpcResult(state.liveRuntime);
|
||||
}
|
||||
state.liveRuntime = {
|
||||
role: "owner",
|
||||
runtimeSession: {
|
||||
id: 501,
|
||||
title: "实时分析 正手",
|
||||
sessionMode: "practice",
|
||||
mediaSessionId: state.mediaSession?.id || null,
|
||||
status: "active",
|
||||
startedAt: nowIso(),
|
||||
endedAt: null,
|
||||
lastHeartbeatAt: nowIso(),
|
||||
snapshot: {
|
||||
phase: "analyzing",
|
||||
currentAction: "forehand",
|
||||
rawAction: "forehand",
|
||||
visibleSegments: 1,
|
||||
unknownSegments: 0,
|
||||
durationMs: 1500,
|
||||
feedback: ["节奏稳定"],
|
||||
},
|
||||
},
|
||||
};
|
||||
return trpcResult(state.liveRuntime);
|
||||
case "analysis.runtimeHeartbeat": {
|
||||
const input = await readTrpcInput(route, operationIndex);
|
||||
if (state.liveRuntime.runtimeSession) {
|
||||
state.liveRuntime.runtimeSession = {
|
||||
...state.liveRuntime.runtimeSession,
|
||||
mediaSessionId: input?.mediaSessionId ?? state.liveRuntime.runtimeSession.mediaSessionId,
|
||||
snapshot: input?.snapshot ?? state.liveRuntime.runtimeSession.snapshot,
|
||||
lastHeartbeatAt: nowIso(),
|
||||
};
|
||||
}
|
||||
return trpcResult(state.liveRuntime);
|
||||
}
|
||||
case "analysis.runtimeRelease":
|
||||
state.liveRuntime = { role: "idle", runtimeSession: null };
|
||||
return trpcResult({ success: true, runtimeSession: null });
|
||||
case "analysis.liveSessionSave":
|
||||
return trpcResult({ sessionId: 1, trainingRecordId: 1 });
|
||||
case "task.list":
|
||||
return trpcResult(state.tasks);
|
||||
case "task.get": {
|
||||
const rawInput = url.searchParams.get("input");
|
||||
const parsedInput = rawInput ? JSON.parse(rawInput) : {};
|
||||
const taskId = parsedInput.json?.taskId || parsedInput[0]?.json?.taskId;
|
||||
return trpcResult(state.tasks.find((task) => task.id === taskId) || null);
|
||||
}
|
||||
case "task.retry": {
|
||||
const rawInput = url.searchParams.get("input");
|
||||
const parsedInput = rawInput ? JSON.parse(rawInput) : {};
|
||||
const taskId = parsedInput.json?.taskId || parsedInput[0]?.json?.taskId;
|
||||
const task = state.tasks.find((item) => item.id === taskId);
|
||||
if (task) {
|
||||
task.status = "succeeded";
|
||||
task.progress = 100;
|
||||
task.error = null;
|
||||
task.message = "任务执行完成";
|
||||
}
|
||||
return trpcResult({ task });
|
||||
}
|
||||
case "task.createMediaFinalize": {
|
||||
if (state.mediaSession) {
|
||||
state.mediaSession.status = "archived";
|
||||
state.mediaSession.archiveStatus = "completed";
|
||||
state.mediaSession.playback = {
|
||||
ready: true,
|
||||
webmUrl: "/media/assets/sessions/session-e2e/recording.webm",
|
||||
mp4Url: "/media/assets/sessions/session-e2e/recording.mp4",
|
||||
webmSize: 2_400_000,
|
||||
mp4Size: 1_800_000,
|
||||
previewUrl: "/media/assets/sessions/session-e2e/recording.webm",
|
||||
};
|
||||
state.videos = [
|
||||
{
|
||||
id: state.nextVideoId++,
|
||||
title: state.mediaSession.title,
|
||||
url: state.mediaSession.playback.webmUrl,
|
||||
format: "webm",
|
||||
fileSize: state.mediaSession.playback.webmSize,
|
||||
exerciseType: "recording",
|
||||
analysisStatus: "completed",
|
||||
createdAt: nowIso(),
|
||||
},
|
||||
...state.videos,
|
||||
];
|
||||
}
|
||||
return trpcResult({
|
||||
taskId: createTask(state, {
|
||||
type: "media_finalize",
|
||||
title: "录制归档",
|
||||
result: {
|
||||
kind: "media_finalize",
|
||||
sessionId: state.mediaSession?.id,
|
||||
videoId: state.videos[0]?.id,
|
||||
url: state.videos[0]?.url,
|
||||
},
|
||||
}).id,
|
||||
});
|
||||
}
|
||||
case "analysis.getCorrections":
|
||||
return trpcResult({
|
||||
taskId: createTask(state, {
|
||||
type: "pose_correction_multimodal",
|
||||
title: "动作纠正",
|
||||
result: {
|
||||
corrections: "## 动作概览\n整体节奏稳定,建议继续优化击球点前置。",
|
||||
report: {
|
||||
priorityFixes: [
|
||||
{
|
||||
title: "击球点前置",
|
||||
why: "击球点略靠后会影响挥拍连贯性。",
|
||||
howToPractice: "每组 8 次影子挥拍,刻意在身体前侧完成触球动作。",
|
||||
successMetric: "连续 3 组都能稳定在身体前侧完成挥拍。",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}).id,
|
||||
});
|
||||
case "video.registerExternal":
|
||||
if (state.mediaSession?.playback.webmUrl || state.mediaSession?.playback.mp4Url) {
|
||||
state.videos = [
|
||||
@@ -190,10 +577,43 @@ async function handleTrpc(route: Route, state: MockAppState) {
|
||||
];
|
||||
}
|
||||
return trpcResult({ videoId: state.nextVideoId, url: state.mediaSession?.playback.webmUrl });
|
||||
case "achievement.list":
|
||||
return trpcResult(buildStats(state.user).achievements);
|
||||
case "rating.current":
|
||||
return trpcResult({
|
||||
rating: state.user.ntrpRating,
|
||||
latestSnapshot: buildStats(state.user).latestNtrpSnapshot,
|
||||
});
|
||||
case "rating.history":
|
||||
return trpcResult([
|
||||
{
|
||||
id: 1,
|
||||
rating: 2.4,
|
||||
triggerType: "daily",
|
||||
createdAt: nowIso(),
|
||||
dimensionScores: {
|
||||
poseAccuracy: 72,
|
||||
strokeConsistency: 70,
|
||||
footwork: 66,
|
||||
fluidity: 69,
|
||||
timing: 68,
|
||||
matchReadiness: 60,
|
||||
},
|
||||
sourceSummary: { analyses: 1, liveSessions: 0, totalEffectiveActions: 12, totalPk: 0, activeDays: 1 },
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
rating: state.user.ntrpRating,
|
||||
triggerType: "daily",
|
||||
createdAt: nowIso(),
|
||||
dimensionScores: buildStats(state.user).latestNtrpSnapshot.dimensionScores,
|
||||
sourceSummary: { analyses: 2, liveSessions: 1, totalEffectiveActions: 36, totalPk: 0, activeDays: 2 },
|
||||
},
|
||||
]);
|
||||
default:
|
||||
return trpcResult(null);
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
await fulfillJson(route, results);
|
||||
}
|
||||
@@ -218,6 +638,21 @@ async function handleMedia(route: Route, state: MockAppState) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.endsWith("/viewer-signal")) {
|
||||
if (state.viewerSignalConflictRemaining > 0) {
|
||||
state.viewerSignalConflictRemaining -= 1;
|
||||
await route.fulfill({
|
||||
status: 409,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ error: "viewer stream not ready" }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
state.mediaSession.viewerCount = (state.mediaSession.viewerCount || 0) + 1;
|
||||
await fulfillJson(route, { viewerId: `viewer-${state.mediaSession.viewerCount}`, type: "answer", sdp: "mock-answer" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.endsWith("/signal")) {
|
||||
state.mediaSession.status = "recording";
|
||||
await fulfillJson(route, { type: "answer", sdp: "mock-answer" });
|
||||
@@ -288,8 +723,11 @@ export async function installAppMocks(
|
||||
analyses?: any[];
|
||||
userName?: string;
|
||||
authMeNullResponsesAfterLogin?: number;
|
||||
liveViewerMode?: boolean;
|
||||
viewerSignalConflictOnce?: boolean;
|
||||
}
|
||||
) {
|
||||
const seededViewerSession = options?.liveViewerMode ? buildMediaSession(buildUser(options?.userName), "其他设备实时分析") : null;
|
||||
const state: MockAppState = {
|
||||
authenticated: options?.authenticated ?? false,
|
||||
user: buildUser(options?.userName),
|
||||
@@ -313,12 +751,93 @@ export async function installAppMocks(
|
||||
shotCount: 16,
|
||||
avgSwingSpeed: 6.2,
|
||||
strokeConsistency: 82,
|
||||
framesAnalyzed: 180,
|
||||
keyMoments: [
|
||||
{ frame: 45, type: "shot", description: "建议保留:正手启动" },
|
||||
{ frame: 110, type: "shot", description: "建议保留:击球后收拍" },
|
||||
],
|
||||
createdAt: nowIso(),
|
||||
},
|
||||
],
|
||||
mediaSession: null,
|
||||
tasks: [],
|
||||
activePlan: null,
|
||||
mediaSession: seededViewerSession,
|
||||
liveRuntime: options?.liveViewerMode
|
||||
? {
|
||||
role: "viewer",
|
||||
runtimeSession: {
|
||||
id: 777,
|
||||
title: "其他设备实时分析",
|
||||
sessionMode: "practice",
|
||||
mediaSessionId: seededViewerSession?.id || null,
|
||||
status: "active",
|
||||
startedAt: nowIso(),
|
||||
endedAt: null,
|
||||
lastHeartbeatAt: nowIso(),
|
||||
snapshot: {
|
||||
phase: "analyzing",
|
||||
title: "其他设备实时分析",
|
||||
sessionMode: "practice",
|
||||
qualityPreset: "balanced",
|
||||
facingMode: "environment",
|
||||
deviceKind: "mobile",
|
||||
avatarEnabled: true,
|
||||
avatarKey: "gorilla",
|
||||
avatarLabel: "猩猩",
|
||||
updatedAt: Date.parse(nowIso()),
|
||||
currentAction: "forehand",
|
||||
rawAction: "forehand",
|
||||
durationMs: 3200,
|
||||
visibleSegments: 2,
|
||||
unknownSegments: 0,
|
||||
archivedVideoCount: 1,
|
||||
feedback: ["同步观看测试数据"],
|
||||
liveScore: {
|
||||
overall: 82,
|
||||
posture: 80,
|
||||
balance: 78,
|
||||
technique: 84,
|
||||
footwork: 76,
|
||||
consistency: 79,
|
||||
confidence: 88,
|
||||
},
|
||||
stabilityMeta: {
|
||||
windowFrames: 24,
|
||||
windowShare: 1,
|
||||
windowProgress: 1,
|
||||
switchCount: 1,
|
||||
stableMs: 1800,
|
||||
rawVolatility: 0.12,
|
||||
pending: false,
|
||||
candidateMs: 0,
|
||||
},
|
||||
recentSegments: [
|
||||
{
|
||||
actionType: "forehand",
|
||||
isUnknown: false,
|
||||
startMs: 800,
|
||||
endMs: 2800,
|
||||
durationMs: 2000,
|
||||
confidenceAvg: 0.82,
|
||||
score: 84,
|
||||
peakScore: 88,
|
||||
frameCount: 24,
|
||||
issueSummary: ["击球点略靠后"],
|
||||
keyFrames: [1000, 1800, 2600],
|
||||
clipLabel: "正手挥拍 00:00 - 00:02",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
: {
|
||||
role: "idle",
|
||||
runtimeSession: null,
|
||||
},
|
||||
nextVideoId: 100,
|
||||
nextTaskId: 1,
|
||||
authMeNullResponsesAfterLogin: options?.authMeNullResponsesAfterLogin ?? 0,
|
||||
viewerSignalConflictRemaining: options?.viewerSignalConflictOnce ? 1 : 0,
|
||||
};
|
||||
|
||||
await page.addInitScript(() => {
|
||||
@@ -347,6 +866,73 @@ export async function installAppMocks(
|
||||
return points;
|
||||
};
|
||||
|
||||
class FakeVideoTrack {
|
||||
kind = "video";
|
||||
enabled = true;
|
||||
muted = false;
|
||||
readyState = "live";
|
||||
id = "fake-video-track";
|
||||
label = "Fake Camera";
|
||||
|
||||
stop() {}
|
||||
|
||||
getSettings() {
|
||||
return {
|
||||
facingMode: "environment",
|
||||
width: 1280,
|
||||
height: 720,
|
||||
frameRate: 30,
|
||||
};
|
||||
}
|
||||
|
||||
getCapabilities() {
|
||||
return {};
|
||||
}
|
||||
|
||||
async applyConstraints() {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeAudioTrack {
|
||||
kind = "audio";
|
||||
enabled = true;
|
||||
muted = false;
|
||||
readyState = "live";
|
||||
id = "fake-audio-track";
|
||||
label = "Fake Mic";
|
||||
|
||||
stop() {}
|
||||
|
||||
getSettings() {
|
||||
return {};
|
||||
}
|
||||
|
||||
getCapabilities() {
|
||||
return {};
|
||||
}
|
||||
|
||||
async applyConstraints() {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const createFakeMediaStream = (withAudio = false) => {
|
||||
const videoTrack = new FakeVideoTrack();
|
||||
const audioTrack = withAudio ? new FakeAudioTrack() : null;
|
||||
const tracks = audioTrack ? [videoTrack, audioTrack] : [videoTrack];
|
||||
return {
|
||||
active: true,
|
||||
id: `fake-stream-${Math.random().toString(36).slice(2)}`,
|
||||
getTracks: () => tracks,
|
||||
getVideoTracks: () => [videoTrack],
|
||||
getAudioTracks: () => (audioTrack ? [audioTrack] : []),
|
||||
addTrack: () => undefined,
|
||||
removeTrack: () => undefined,
|
||||
clone: () => createFakeMediaStream(withAudio),
|
||||
} as unknown as MediaStream;
|
||||
};
|
||||
|
||||
class FakePose {
|
||||
callback = null;
|
||||
|
||||
@@ -375,6 +961,21 @@ export async function installAppMocks(
|
||||
value: async () => undefined,
|
||||
});
|
||||
|
||||
Object.defineProperty(HTMLMediaElement.prototype, "srcObject", {
|
||||
configurable: true,
|
||||
get() {
|
||||
return (this as HTMLMediaElement & { __srcObject?: MediaStream }).__srcObject ?? null;
|
||||
},
|
||||
set(value) {
|
||||
(this as HTMLMediaElement & { __srcObject?: MediaStream }).__srcObject = value as MediaStream;
|
||||
},
|
||||
});
|
||||
|
||||
Object.defineProperty(HTMLCanvasElement.prototype, "captureStream", {
|
||||
configurable: true,
|
||||
value: () => createFakeMediaStream(),
|
||||
});
|
||||
|
||||
class FakeMediaRecorder extends EventTarget {
|
||||
state = "inactive";
|
||||
mimeType = "video/webm";
|
||||
@@ -418,9 +1019,12 @@ export async function installAppMocks(
|
||||
localDescription: { type: string; sdp: string } | null = null;
|
||||
remoteDescription: { type: string; sdp: string } | null = null;
|
||||
onconnectionstatechange: (() => void) | null = null;
|
||||
ontrack: ((event: { streams: MediaStream[] }) => void) | null = null;
|
||||
|
||||
addTrack() {}
|
||||
|
||||
addTransceiver() {}
|
||||
|
||||
async createOffer() {
|
||||
return { type: "offer", sdp: "mock-offer" };
|
||||
}
|
||||
@@ -434,6 +1038,7 @@ export async function installAppMocks(
|
||||
async setRemoteDescription(description: { type: string; sdp: string }) {
|
||||
this.remoteDescription = description;
|
||||
this.connectionState = "connected";
|
||||
this.ontrack?.({ streams: [createFakeMediaStream()] });
|
||||
this.onconnectionstatechange?.();
|
||||
}
|
||||
|
||||
@@ -456,7 +1061,7 @@ export async function installAppMocks(
|
||||
Object.defineProperty(navigator, "mediaDevices", {
|
||||
configurable: true,
|
||||
value: {
|
||||
getUserMedia: async () => new MediaStream(),
|
||||
getUserMedia: async (constraints?: { audio?: unknown }) => createFakeMediaStream(Boolean(constraints?.audio)),
|
||||
enumerateDevices: async () => [
|
||||
{ deviceId: "cam-1", kind: "videoinput", label: "Front Camera", groupId: "g1" },
|
||||
{ deviceId: "cam-2", kind: "videoinput", label: "Back Camera", groupId: "g1" },
|
||||
|
||||