比较提交

...

53 次代码提交

作者 SHA1 备注 提交日期
cryptocommuniums-afk
bacd712dbc docs record camera fallback rollout 2026-03-16 22:26:52 +08:00
cryptocommuniums-afk
78a7c755e3 docs add camera startup fallback changelog 2026-03-16 22:24:50 +08:00
cryptocommuniums-afk
a211562860 fix camera startup fallbacks 2026-03-16 22:23:58 +08:00
cryptocommuniums-afk
09b1b95e2c docs record live viewer sync rollout 2026-03-16 19:31:40 +08:00
cryptocommuniums-afk
922a9fb63f feat sync live analysis viewer state 2026-03-16 19:19:46 +08:00
cryptocommuniums-afk
31bead3452 docs finalize rollout verification 2026-03-16 18:09:49 +08:00
cryptocommuniums-afk
a5103685fb docs record live analysis rollout 2026-03-16 18:08:03 +08:00
cryptocommuniums-afk
f9db6ef590 fix live analysis multi-device lock 2026-03-16 18:05:58 +08:00
cryptocommuniums-afk
13e59b8e8a docs: sync live analysis runtime changelog 2026-03-16 17:29:30 +08:00
cryptocommuniums-afk
2b72ef9200 fix: restore live analysis runtime migration 2026-03-16 17:29:06 +08:00
cryptocommuniums-afk
09cd5b4d85 docs: finalize live camera release notes 2026-03-16 16:44:18 +08:00
cryptocommuniums-afk
7aba508247 docs: refresh deployed asset revision 2026-03-16 16:41:35 +08:00
cryptocommuniums-afk
cf06de944f docs: sync changelog repo version 2026-03-16 16:39:47 +08:00
cryptocommuniums-afk
4e4122d758 feat: add live camera multi-device viewer mode 2026-03-16 16:39:14 +08:00
cryptocommuniums-afk
f0bbe4c82f Update changelog release metadata 2026-03-16 12:01:58 +08:00
cryptocommuniums-afk
4fb2d092d7 Add auto archived overlay recordings for live analysis 2026-03-16 11:59:51 +08:00
cryptocommuniums-afk
e3fe9a8e7b Add free full-body 3D live camera avatar examples 2026-03-15 22:32:09 +08:00
cryptocommuniums-afk
fe5e539a47 Add 10 free animal live camera avatars 2026-03-15 22:02:18 +08:00
cryptocommuniums-afk
139dc61b61 Fix live camera gorilla avatar preset 2026-03-15 21:03:06 +08:00
cryptocommuniums-afk
264d49475b Sync changelog with repo version 5c2dcf2 2026-03-15 18:05:45 +08:00
cryptocommuniums-afk
5c2dcf23ba Add leave-state hints for live analysis 2026-03-15 18:05:33 +08:00
cryptocommuniums-afk
3763f5b515 Sync changelog with repo version 1ce94f6 2026-03-15 17:39:34 +08:00
cryptocommuniums-afk
1ce94f6f57 Collapse training generator into right rail 2026-03-15 17:39:20 +08:00
cryptocommuniums-afk
669497e625 Sync changelog with repo version 71caf0d 2026-03-15 17:34:34 +08:00
cryptocommuniums-afk
71caf0de19 Show precise record times and action summaries 2026-03-15 17:34:24 +08:00
cryptocommuniums-afk
67b27e3551 Sync changelog with repo version a9ea94f 2026-03-15 17:30:32 +08:00
cryptocommuniums-afk
a9ea94fb78 Add multi-session auth and changelog tracking 2026-03-15 17:30:19 +08:00
cryptocommuniums-afk
c4ec397ed3 Add camera zoom and data saver controls 2026-03-15 16:17:34 +08:00
cryptocommuniums-afk
bd8998166b Add CRUD support for training videos 2026-03-15 14:17:59 +08:00
cryptocommuniums-afk
143c60a054 Add optimized tutorial cover images 2026-03-15 12:01:21 +08:00
cryptocommuniums-afk
bee24d547d Clean non-tennis tutorials 2026-03-15 11:28:08 +08:00
cryptocommuniums-afk
a1689ee95e Document runtime operations and latest verification 2026-03-15 08:22:46 +08:00
cryptocommuniums-afk
cb643ac154 Harden async task flows and enhance analysis tooling 2026-03-15 08:05:37 +08:00
cryptocommuniums-afk
585fd5773d Handle stale frontend assets and harden worker startup 2026-03-15 02:57:44 +08:00
cryptocommuniums-afk
e43b969d28 Clarify recorder leave-page guidance 2026-03-15 02:43:35 +08:00
cryptocommuniums-afk
afb013193d Show compressed previews in vision lab 2026-03-15 02:37:12 +08:00
cryptocommuniums-afk
ae93269c62 Repair multimodal vision parsing and rerun fallback history 2026-03-15 02:31:44 +08:00
cryptocommuniums-afk
f4f425de42 Show upload size during media finalization 2026-03-15 02:16:32 +08:00
cryptocommuniums-afk
815f96d4e8 Improve live analysis stability and video clip drafting 2026-03-15 02:11:34 +08:00
cryptocommuniums-afk
edc66ea5bc Implement live analysis achievements and admin console 2026-03-15 01:39:34 +08:00
cryptocommuniums-afk
d1b6603061 Fix recorder finalize path and add invite-gated login 2026-03-15 00:52:11 +08:00
cryptocommuniums-afk
ad83ce9c68 Add admin vision lab and LLM vision verification 2026-03-15 00:41:09 +08:00
cryptocommuniums-afk
20e183d2da feat: async task pipeline for media and llm workflows 2026-03-15 00:12:26 +08:00
cryptocommuniums-afk
1cc863e60e Fix training plan generation flow 2026-03-14 23:16:19 +08:00
cryptocommuniums-afk
6943754838 Fix live camera analysis loop 2026-03-14 22:54:15 +08:00
cryptocommuniums-afk
bc01a40564 Improve mobile recorder focus mode 2026-03-14 22:44:46 +08:00
cryptocommuniums-afk
bcdd790d91 Fix first-login username flow 2026-03-14 22:37:15 +08:00
cryptocommuniums-afk
8d3faecb15 Add public four-digit port access 2026-03-14 22:32:41 +08:00
cryptocommuniums-afk
8df0f91db7 Self-host compose stack and production stability fixes 2026-03-14 22:25:19 +08:00
cryptocommuniums-afk
f5ad0449a8 Feat: add env-driven LLM configuration and smoke test 2026-03-14 21:54:51 +08:00
cryptocommuniums-afk
ba35e50528 Chore: remove promotional copy and home-training wording 2026-03-14 21:50:09 +08:00
cryptocommuniums-afk
914f015c30 Chore: remove built media binary from repo 2026-03-14 21:46:31 +08:00
cryptocommuniums-afk
d5431aee0e Checkpoint: v4.0 media service, compose deploy, and verified docs 2026-03-14 21:45:31 +08:00
修改 119 个文件,包含 22676 行新增2785 行删除

11
.dockerignore 普通文件
查看文件

@@ -0,0 +1,11 @@
.git
.env
.manus
.manus-logs
node_modules
dist
test-results
playwright-report
coverage
tmp
temp

42
.env.example 普通文件
查看文件

@@ -0,0 +1,42 @@
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
MYSQL_USER=tennis
MYSQL_PASSWORD=replace-with-db-password
MYSQL_ROOT_PASSWORD=replace-with-root-password
# LLM chat completion endpoint
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
# Optional direct media URL override for browser builds
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

3
.gitignore vendored
查看文件

@@ -6,6 +6,7 @@
dist/
build/
*.dist
media/media
# Environment variables
.env
@@ -49,6 +50,8 @@ pids
# Coverage directory used by tools like istanbul
coverage/
*.lcov
playwright-report/
test-results/
# nyc test coverage
.nyc_output

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`.

27
Dockerfile 普通文件
查看文件

@@ -0,0 +1,27 @@
FROM node:22-bookworm-slim AS deps
WORKDIR /app
RUN corepack enable
COPY package.json pnpm-lock.yaml ./
COPY patches ./patches
RUN pnpm install --frozen-lockfile
FROM node:22-bookworm-slim AS build
WORKDIR /app
RUN corepack enable
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN pnpm build
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/_core/index.js"]

322
README.md
查看文件

@@ -1,186 +1,190 @@
# Tennis Training Hub - AI网球训练助手
# Tennis Training Hub
一个基于AI的在家网球训练平台,通过MediaPipe姿势识别技术帮助用户在只有球拍的条件下进行科学训练,自动分析挥拍姿势并生成个性化训练计划
网球训练管理与分析应用,提供训练计划、姿势分析、实时摄像头分析、在线视频录制、成就系统、管理员工作台与视频库管理。当前版本在媒体服务之外新增数据库驱动的后台任务系统,用于承接训练计划生成、动作纠正、多模态分析、录制归档与每日 NTRP 刷新这类高延迟任务
## 功能概览
## Architecture
| 功能模块 | 描述 | 技术实现 |
|---------|------|---------|
| 用户名登录 | 无需注册,输入用户名即可使用 | tRPC + JWT Session |
| 训练计划生成 | 根据用户水平(初/中/高级AI生成训练计划 | LLM结构化输出 |
| 视频上传分析 | 上传训练视频进行姿势识别 | MediaPipe Pose + S3 |
| 实时摄像头分析 | 手机/电脑摄像头实时捕捉分析 | MediaPipe实时推理 |
| 在线录制 | 稳定压缩流录制、断线重连、自动剪辑 | MediaRecorder API |
| 姿势矫正建议 | AI根据分析结果生成矫正方案 | LLM + 姿势数据 |
| NTRP自动评分 | 基于USTA标准的五维度加权评分 | 自动算法 |
| 训练计划自动调整 | 根据分析结果智能调整后续计划 | LLM + 历史数据 |
| 每日打卡 | 连续打卡追踪、训练时长记录 | 日期计算 + 数据库 |
| 成就徽章 | 24种成就徽章激励系统 | 自动检测 + 授予 |
| 社区排行榜 | NTRP评分、训练时长、击球数排名 | 数据库排序查询 |
| 训练进度追踪 | 可视化展示训练历史和改进趋势 | Recharts图表 |
| 视频库管理 | 保存管理所有训练视频及分析结果 | S3 + 数据库 |
| 移动端适配 | 全面响应式设计,手机摄像头优化 | Tailwind响应式 |
- `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
**前端:**
- React 19 + TypeScript
- Tailwind CSS 4 + shadcn/ui
- MediaPipe Pose浏览器端姿势识别
- Recharts数据可视化
- Framer Motion动画效果
- wouter路由
实时分析页现在采用“识别 + 录制 + 落库”一体化流程:
**后端:**
- Express 4 + tRPC 11
- Drizzle ORM + MySQL/TiDB
- S3文件存储
- LLM集成训练计划生成、姿势矫正建议
- 浏览器端基于 MediaPipe Pose 自动识别 `forehand / backhand / serve / volley / overhead / slice / lob / unknown`
- 最近 6 帧动作结果会做时序加权稳定化,降低正手/反手/未知动作间的瞬时抖动
- 连续同类动作会自动合并为片段,最长单段不超过 10 秒
- 停止分析后会自动保存动作区间、评分维度、反馈摘要和可选本地录制视频
- 实时分析结果会自动回写训练记录、日训练聚合、成就进度与 NTRP 评分链路
- 移动端支持竖屏最大化预览,主要操作按钮固定在侧边
## 项目结构
## Video Library And PC Editing
```
tennis-training-hub/
├── client/
│ ├── src/
│ │ ├── pages/
│ │ │ ├── Home.tsx # 落地页
│ │ │ ├── Login.tsx # 用户名登录
│ │ │ ├── Dashboard.tsx # 仪表盘
│ │ │ ├── Training.tsx # 训练计划
│ │ │ ├── Analysis.tsx # 视频分析MediaPipe
│ │ │ ├── LiveCamera.tsx # 实时摄像头分析
│ │ │ ├── Recorder.tsx # 在线录制
│ │ │ ├── Videos.tsx # 视频库
│ │ │ ├── Progress.tsx # 训练进度
│ │ │ ├── Rating.tsx # NTRP评分详情
│ │ │ ├── Leaderboard.tsx # 社区排行榜
│ │ │ └── Checkin.tsx # 每日打卡+徽章
│ │ ├── components/
│ │ │ └── DashboardLayout.tsx # 侧边栏导航布局
│ │ ├── App.tsx # 路由配置
│ │ └── index.css # 主题样式
│ └── index.html
├── server/
│ ├── routers.ts # tRPC路由定义
│ ├── db.ts # 数据库查询助手
│ ├── storage.ts # S3存储助手
│ ├── features.test.ts # 功能测试47个
│ └── _core/ # 框架核心(勿修改)
├── drizzle/
│ └── schema.ts # 数据库表结构
└── shared/
└── const.ts # 共享常量
```
- 视频库支持直接打开 `PC 轻剪辑工作台`
- 轻剪辑支持播放器预览、手动入点/出点、从当前播放位置快速设点
- 分析关键时刻会自动生成建议片段;即使视频 metadata 尚未返回,也会按分析帧数估算时间轴
- 剪辑草稿保存在浏览器本地,可导出 JSON 供后续后台剪辑任务或人工复核使用
## 数据库设计
## Online Recording
### 核心表
在线录制模块采用双链路设计:
| 表名 | 用途 | 关键字段 |
|------|------|---------|
| `users` | 用户信息 | openId, name, skillLevel, ntrpRating, totalSessions, currentStreak |
| `username_accounts` | 用户名登录映射 | username, userId |
| `training_plans` | AI训练计划 | exercises(JSON), skillLevel, durationDays, version |
| `training_videos` | 训练视频 | fileKey, url, format, analysisStatus |
| `pose_analyses` | 姿势分析结果 | overallScore, shotCount, avgSwingSpeed, strokeConsistency |
| `training_records` | 训练记录 | exerciseName, durationMinutes, completed, poseScore |
| `rating_history` | NTRP评分历史 | rating, dimensionScores(JSON), analysisId |
| `daily_checkins` | 每日打卡 | checkinDate, streakCount, minutesTrained |
| `user_badges` | 成就徽章 | badgeKey, earnedAt |
- 浏览器端 `MediaRecorder` 本地压缩并每 60 秒自动分段上传
- 浏览器端 `RTCPeerConnection` 同步建立 WebRTC 低延迟推流链路
- 客户端运动检测自动写入关键片段 marker,也支持手动标记
- 摄像头中断后自动重连,保留既有分段与会话
- Go 媒体 worker 将分段合并归档,并产出 WebM 回放;FFmpeg 可用时额外生成 MP4
- Node app worker 轮询媒体归档状态,归档完成后自动登记到视频库并向任务中心反馈结果
- 服务端媒体会话校验兼容 `/media/sessions/...` 路径,避免录制结束时因路径不一致导致 404
## NTRP自动评分系统
## Background Tasks
评分基于USTA美国网球协会的NTRP标准,范围1.0-5.0,采用五维度加权计算
统一后台任务覆盖以下路径
| 维度 | 权重 | 说明 |
|------|------|------|
| 姿势正确性 | 30% | 基于MediaPipe关键点角度分析 |
| 击球一致性 | 25% | 多次挥拍动作的稳定性 |
| 脚步移动 | 20% | 身体重心移动和步法评估 |
| 动作流畅性 | 15% | 挥拍动作的连贯性和自然度 |
| 力量表现 | 10% | 基于挥拍速度估算 |
- `training_plan_generate`
- `training_plan_adjust`
- `analysis_corrections`
- `pose_correction_multimodal`
- `media_finalize`
- `ntrp_refresh_user`
- `ntrp_refresh_all`
**评分映射规则:**
- 0-20分 → NTRP 1.0-1.5(初学者)
- 20-40分 → NTRP 1.5-2.5(初级)
- 40-60分 → NTRP 2.5-3.5(中级)
- 60-80分 → NTRP 3.5-4.5(中高级)
- 80-100分 → NTRP 4.5-5.0(高级)
前端提供全局任务中心,页面本地也会显示任务提交、执行中、完成或失败状态。训练页、分析页和录制页都可以在用户离开页面后继续完成后台任务。
评分会根据最近20次视频分析结果自动更新,近期分析权重更高
另外提供独立日志页 `/logs`,用于查看后台任务历史、失败原因与通知记录;管理员工作台 `/admin` 可集中查看用户、后台任务、实时分析会话、应用设置和审计日志
## 成就徽章系统
## Multimodal LLM
共24种成就徽章,分为6个类别
- 文本类任务使用 `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` 记录重新排队,便于修复上游返回不稳定导致的旧数据
| 类别 | 徽章数 | 示例 |
|------|--------|------|
| 里程碑 | 1 | 初来乍到(首次登录) |
| 训练 | 6 | 初试身手、十次训练、百次训练、训练时长里程碑 |
| 连续打卡 | 4 | 三日坚持、一周达人、两周勇士、月度冠军 |
| 视频 | 3 | 影像记录、视频达人、视频大师 |
| 分析 | 4 | AI教练、优秀姿势、完美姿势、击球里程碑 |
| 评分 | 3 | NTRP 2.0/3.0/4.0 |
## Quick Start
## 在线录制功能
在线录制模块提供专业级录制体验:
- **稳定压缩流**使用MediaRecorder API,自适应码率1-2.5Mbps,支持webm/mp4格式
- **断线自动重连**:摄像头意外断开时自动检测并重新连接,保存已录制片段
- **自动剪辑**:基于运动检测自动标记关键时刻,支持手动设置剪辑点
- **分段录制**每60秒自动分段,防止数据丢失
- **手机摄像头优化**:支持前后摄像头切换,自适应分辨率
## 移动端适配
- 安全区域适配iPhone X+刘海屏)
- 触摸友好的44px最小点击区域
- 横屏视频优化
- 防误触下拉刷新(录制/分析模式)
- 响应式侧边栏导航
- 移动端底部导航栏
## 测试
项目包含47个vitest测试用例,覆盖所有核心后端功能
### Local development
```bash
pnpm test
```
测试覆盖范围:
- 认证系统(登录、登出、用户名验证)
- 用户资料管理
- 训练计划生成(输入验证)
- 视频上传和管理
- 姿势分析保存和查询
- 训练记录创建和完成
- NTRP评分系统
- 每日打卡系统
- 成就徽章系统
- 社区排行榜
## 开发
```bash
# 安装依赖
pnpm install
# 启动开发服务器
cp .env.example .env
set -a && source .env && set +a && pnpm exec drizzle-kit migrate
pnpm dev
# 运行测试
pnpm test
# 类型检查
pnpm check
# 构建生产版本
pnpm build
```
## 许可证
本地开发时:
MIT License
- Node 应用默认运行在 `http://localhost:3000`
- 若设置 `MEDIA_SERVICE_URL=http://127.0.0.1:8081`,Express 会把 `/media` 代理到 Go 服务
- Go 媒体服务可单独启动:
```bash
cd media
go mod tidy
go run .
```
### Checks
```bash
pnpm check
pnpm test
pnpm test:go
pnpm build
pnpm test:e2e
pnpm verify
cd media
go build ./...
```
首次运行浏览器测试前执行:
```bash
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 + 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/`
详细步骤见:
- `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
- `docs/FEATURES.md`: 当前功能特性与能力边界
- `docs/testing.md`: 自动测试分层与运行方式
- `docs/verified-features.md`: 已验证通过的项目清单
- `docs/developer-workflow.md`: 阶段可中断的开发与本地提交流程
- `docs/deploy.md`: 部署指南
- `docs/media-architecture.md`: 媒体服务架构
- `docs/frontend-recording.md`: 前端录制与移动端适配说明
- `docs/runtime-operations.md`: 运行时任务稳定性、日志清理、重启与 smoke 流程
## Environment
关键环境变量见 `.env.example`,重点包括:
- `DATABASE_URL`
- `JWT_SECRET`
- `ADMIN_USERNAMES`
- `REGISTRATION_INVITE_CODE`
- `MYSQL_DATABASE`
- `MYSQL_USER`
- `MYSQL_PASSWORD`
- `MYSQL_ROOT_PASSWORD`
- `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`
LLM 烟雾测试:
```bash
pnpm test:llm
pnpm test:llm -- "你好,做个自我介绍"
```
## Notes
- 浏览器兼容目标以 Chrome 为主
- 录制文件优先产出 WebM,MP4 为服务端可选归档产物
- 存储策略当前为本地卷优先,适合单机 Compose 部署
- 浏览器测试会启动真实 Node 服务,因此要求本地测试库已完成 Drizzle 迁移

查看文件

@@ -15,10 +15,6 @@
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
<script
defer
src="%VITE_ANALYTICS_ENDPOINT%/umami"
data-website-id="%VITE_ANALYTICS_WEBSITE_ID%"></script>
</body>
</html>

查看文件

@@ -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,18 @@ 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 = [
{ icon: LayoutDashboard, label: "首页", path: "/dashboard" },
{ icon: Target, label: "计划", path: "/training" },
{ icon: CircleDot, label: "录制", path: "/recorder" },
{ icon: FileVideo, label: "视频", path: "/videos" },
{ icon: Activity, label: "进度", path: "/progress" },
];
const SIDEBAR_WIDTH_KEY = "sidebar-width";
@@ -102,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(() => {
@@ -171,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}>
@@ -192,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}>
@@ -213,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}>
@@ -234,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}>
@@ -254,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">
@@ -307,9 +332,34 @@ function DashboardLayoutContent({
</div>
</div>
</div>
<TaskCenter compact />
</div>
)}
<main className="flex-1 p-4 md:p-6">{children}</main>
<main className={`flex-1 p-4 md:p-6 ${isMobile ? "pb-28" : ""}`}>{children}</main>
{isMobile && (
<nav className="mobile-safe-bottom fixed inset-x-0 bottom-0 z-50 border-t border-border/70 bg-background/95 px-2 pb-[calc(env(safe-area-inset-bottom,0px)+0.35rem)] pt-2 backdrop-blur supports-[backdrop-filter]:backdrop-blur">
<div className="mx-auto grid max-w-xl grid-cols-5 gap-1">
{mobileNavItems.map((item) => {
const isActive = location === item.path;
return (
<button
key={item.path}
type="button"
onClick={() => setLocation(item.path)}
className={`flex min-h-[52px] flex-col items-center justify-center rounded-2xl px-1 py-2 text-[11px] transition ${
isActive
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:bg-muted/70"
}`}
>
<item.icon className="mb-1 h-4 w-4" />
<span>{item.label}</span>
</button>
);
})}
</div>
</nav>
)}
</SidebarInset>
</>
);

查看文件

@@ -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>
);
}

29
client/src/const.test.ts 普通文件
查看文件

@@ -0,0 +1,29 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { getLoginUrl } from "./const";
describe("getLoginUrl", () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it("falls back to the in-app login route when oauth portal is unset", () => {
vi.stubEnv("VITE_OAUTH_PORTAL_URL", "");
expect(getLoginUrl()).toBe("/login");
});
it("builds the external oauth login url when portal is configured", () => {
vi.stubEnv("VITE_OAUTH_PORTAL_URL", "https://oauth.example.com");
vi.stubEnv("VITE_APP_ID", "tennis-training-hub");
const url = new URL(getLoginUrl());
expect(url.origin).toBe("https://oauth.example.com");
expect(url.pathname).toBe("/app-auth");
expect(url.searchParams.get("appId")).toBe("tennis-training-hub");
expect(url.searchParams.get("redirectUri")).toBe(
`${window.location.origin}/api/oauth/callback`
);
expect(url.searchParams.get("type")).toBe("signIn");
});
});

查看文件

@@ -2,8 +2,13 @@ export { COOKIE_NAME, ONE_YEAR_MS } from "@shared/const";
// Generate login URL at runtime so redirect URI reflects the current origin.
export const getLoginUrl = () => {
const oauthPortalUrl = import.meta.env.VITE_OAUTH_PORTAL_URL;
const oauthPortalUrl = import.meta.env.VITE_OAUTH_PORTAL_URL?.trim();
const appId = import.meta.env.VITE_APP_ID;
if (!oauthPortalUrl) {
return "/login";
}
const redirectUri = `${window.location.origin}/api/oauth/callback`;
const state = btoa(redirectUri);

查看文件

@@ -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;
},
}
);
}

查看文件

@@ -175,6 +175,10 @@
.mobile-safe-top {
padding-top: env(safe-area-inset-top, 0px);
}
.mobile-safe-inline {
padding-left: env(safe-area-inset-left, 0px);
padding-right: env(safe-area-inset-right, 0px);
}
}
/* Touch-friendly tap targets */
@@ -215,8 +219,7 @@ html {
/* Prevent pull-to-refresh during camera/recording */
.no-overscroll {
overscroll-behavior: none;
touch-action: none;
overscroll-behavior-y: contain;
}
/* Video container responsive */

查看文件

@@ -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),
};
}

查看文件

@@ -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);
}

244
client/src/lib/changelog.ts 普通文件
查看文件

@@ -0,0 +1,244 @@
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-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: [
"教程库、提醒、通知相关测试通过",
],
},
];

查看文件

@@ -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 });
}

查看文件

@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { formatRecordingTime, pickBitrate } from "./media";
describe("media utilities", () => {
it("formats recording time with minute and second padding", () => {
expect(formatRecordingTime(0)).toBe("00:00");
expect(formatRecordingTime(61_000)).toBe("01:01");
expect(formatRecordingTime(12 * 60_000 + 9_000)).toBe("12:09");
});
it("selects bitrates by preset and device class", () => {
expect(pickBitrate("economy", true)).toBe(1_000_000);
expect(pickBitrate("clarity", false)).toBe(2_500_000);
expect(pickBitrate("balanced", true)).toBe(1_400_000);
expect(pickBitrate("balanced", false)).toBe(1_900_000);
});
});

202
client/src/lib/media.ts 普通文件
查看文件

@@ -0,0 +1,202 @@
export type MediaSessionStatus =
| "created"
| "recording"
| "streaming"
| "reconnecting"
| "finalizing"
| "archived"
| "failed";
export type ArchiveStatus =
| "idle"
| "queued"
| "processing"
| "completed"
| "failed";
export type PreviewStatus =
| "idle"
| "processing"
| "ready"
| "failed";
export type MediaMarker = {
id: string;
type: string;
label: string;
timestampMs: number;
confidence?: number;
createdAt: string;
};
export type MediaSession = {
id: string;
userId: string;
title: string;
status: MediaSessionStatus;
archiveStatus: ArchiveStatus;
previewStatus: PreviewStatus;
format: string;
mimeType: string;
qualityPreset: string;
facingMode: string;
deviceKind: string;
reconnectCount: number;
uploadedSegments: number;
uploadedBytes: number;
previewSegments: number;
durationMs: number;
lastError?: string;
previewUpdatedAt?: string;
streamConnected: boolean;
lastStreamAt?: string;
viewerCount?: number;
playback: {
webmUrl?: string;
mp4Url?: string;
webmSize?: number;
mp4Size?: number;
ready: boolean;
previewUrl?: string;
};
markers: MediaMarker[];
};
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> {
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;
}
}
throw lastError || new Error("Media request failed");
}
export async function createMediaSession(payload: {
userId: string;
title: string;
format: string;
mimeType: string;
qualityPreset: string;
facingMode: string;
deviceKind: string;
}) {
return request<{ session: MediaSession }>("/sessions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
}
export async function signalMediaSession(sessionId: string, payload: { sdp: string; type: string }) {
return request<{ sdp: string; type: string }>(`/sessions/${sessionId}/signal`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
}
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 uploadMediaSegment(
sessionId: string,
sequence: number,
durationMs: number,
blob: Blob
) {
return request<{ session: MediaSession }>(
`/sessions/${sessionId}/segments?sequence=${sequence}&durationMs=${durationMs}`,
{
method: "POST",
headers: { "Content-Type": blob.type || "video/webm" },
body: blob,
}
);
}
export async function createMediaMarker(
sessionId: string,
payload: { type: string; label: string; timestampMs: number; confidence?: number }
) {
return request<{ session: MediaSession }>(`/sessions/${sessionId}/markers`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
}
export async function finalizeMediaSession(
sessionId: string,
payload: { title: string; durationMs: number }
) {
return request<{ session: MediaSession }>(`/sessions/${sessionId}/finalize`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
}
export async function getMediaSession(sessionId: string) {
return request<{ session: MediaSession }>(`/sessions/${sessionId}`);
}
export function formatRecordingTime(milliseconds: number) {
const totalSeconds = Math.max(0, Math.floor(milliseconds / 1000));
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
}
export function pickRecorderMimeType() {
const candidates = [
"video/webm;codecs=vp9,opus",
"video/webm;codecs=vp8,opus",
"video/webm;codecs=h264,opus",
"video/webm",
];
return candidates.find((candidate) => window.MediaRecorder?.isTypeSupported(candidate)) || "video/webm";
}
export function pickBitrate(preset: string, isMobile: boolean) {
switch (preset) {
case "economy":
return 1_000_000;
case "clarity":
return 2_500_000;
default:
return isMobile ? 1_400_000 : 1_900_000;
}
}

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;
@@ -52,6 +107,17 @@ const trpcClient = trpc.createClient({
],
});
const analyticsEndpoint = import.meta.env.VITE_ANALYTICS_ENDPOINT;
const analyticsWebsiteId = import.meta.env.VITE_ANALYTICS_WEBSITE_ID;
if (analyticsEndpoint && analyticsWebsiteId && typeof document !== "undefined") {
const script = document.createElement("script");
script.defer = true;
script.src = `${analyticsEndpoint.replace(/\/$/, "")}/umami`;
script.dataset.websiteId = analyticsWebsiteId;
document.head.appendChild(script);
}
createRoot(document.getElementById("root")!).render(
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>

查看文件

@@ -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;
}

查看文件

@@ -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">
{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 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>AI分析</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">AI定制个人训练方案</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">MediaPipe AI姿势识别</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>
);
}

查看文件

@@ -30,18 +30,18 @@ export default function Home() {
<div className="max-w-3xl mx-auto text-center">
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-primary/10 text-primary text-sm font-medium mb-6">
<Zap className="h-3.5 w-3.5" />
AI网球训练助手
</div>
<h1 className="text-4xl md:text-5xl lg:text-6xl font-bold tracking-tight leading-tight">
<span className="text-primary block mt-1"></span>
<span className="text-primary block mt-1"></span>
</h1>
<p className="text-lg text-muted-foreground mt-6 max-w-xl mx-auto leading-relaxed">
AI姿势识别 · · ·
· 姿 · ·
</p>
<div className="flex items-center justify-center gap-3 mt-8">
<Button onClick={() => setLocation("/login")} size="lg" className="gap-2 h-12 px-6">
<ChevronRight className="h-4 w-4" />
</Button>
</div>
@@ -50,43 +50,43 @@ export default function Home() {
{/* Features */}
<section className="container py-16">
<h2 className="text-2xl font-bold text-center mb-12"></h2>
<h2 className="text-2xl font-bold text-center mb-12"></h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 max-w-5xl mx-auto">
{[
{
icon: Video,
title: "AI姿势识别",
desc: "MediaPipe实时分析33个关键点,精准评估挥拍动作",
title: "姿势识别",
desc: "使用 MediaPipe 分析 33 个关键点并记录挥拍数据",
color: "bg-blue-50 text-blue-600",
},
{
icon: Target,
title: "智能训练计划",
desc: "根据水平和分析结果,AI自动生成和调整训练方案",
title: "训练计划",
desc: "根据水平和分析结果生成训练安排",
color: "bg-green-50 text-green-600",
},
{
icon: Award,
title: "NTRP自动评分",
desc: "USTA标准五维度评估,每次分析自动更新评分",
desc: "USTA 维度记录评分结果",
color: "bg-purple-50 text-purple-600",
},
{
icon: Zap,
title: "击球统计",
desc: "击球次数、挥拍速度一致性,量化训练效果",
desc: "记录击球次数、挥拍速度一致性",
color: "bg-orange-50 text-orange-600",
},
{
icon: Footprints,
title: "运动轨迹",
desc: "重心移动轨迹分析,优化脚步移动模式",
desc: "记录重心移动轨迹和脚步变化",
color: "bg-teal-50 text-teal-600",
},
{
icon: TrendingUp,
title: "进度追踪",
desc: "训练历史、能力趋势评分变化一目了然",
desc: "查看训练历史、趋势评分变化",
color: "bg-indigo-50 text-indigo-600",
},
].map((feature) => (
@@ -103,11 +103,11 @@ export default function Home() {
{/* How it works */}
<section className="container py-16">
<h2 className="text-2xl font-bold text-center mb-12">使</h2>
<h2 className="text-2xl font-bold text-center mb-12">使</h2>
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 max-w-4xl mx-auto">
{[
{ step: "1", title: "输入用户名", desc: "用户名登录即可" },
{ step: "2", title: "生成计划", desc: "AI个性化训练方案" },
{ step: "2", title: "生成计划", desc: "生成训练安排" },
{ step: "3", title: "上传视频", desc: "录制挥拍并分析" },
{ step: "4", title: "获取反馈", desc: "评分与矫正建议" },
].map((item) => (
@@ -125,10 +125,10 @@ export default function Home() {
{/* CTA */}
<section className="container py-16">
<div className="max-w-2xl mx-auto text-center p-8 rounded-2xl bg-primary/5">
<h2 className="text-2xl font-bold mb-3"></h2>
<p className="text-muted-foreground mb-6">使</p>
<h2 className="text-2xl font-bold mb-3"></h2>
<p className="text-muted-foreground mb-6"></p>
<Button onClick={() => setLocation("/login")} size="lg" className="gap-2">
<ChevronRight className="h-4 w-4" />
</Button>
</div>
@@ -141,7 +141,7 @@ export default function Home() {
<Target className="h-4 w-4" />
<span>Tennis Hub</span>
</div>
<span>AI网球训练助手</span>
<span></span>
</div>
</footer>
</div>

文件差异内容过多而无法显示 加载差异

查看文件

@@ -9,24 +9,46 @@ import { Target, Loader2 } from "lucide-react";
export default function Login() {
const [username, setUsername] = useState("");
const [inviteCode, setInviteCode] = useState("");
const [, setLocation] = useLocation();
const loginMutation = trpc.auth.loginWithUsername.useMutation({
onSuccess: (data) => {
toast.success(data.isNew ? `欢迎加入,${data.user.name}` : `欢迎回来,${data.user.name}`);
setLocation("/dashboard");
},
onError: (err) => {
toast.error("登录失败: " + err.message);
},
});
const utils = trpc.useUtils();
const loginMutation = trpc.auth.loginWithUsername.useMutation();
const handleLogin = (e: React.FormEvent) => {
const syncAuthenticatedUser = async (fallbackUser: Awaited<ReturnType<typeof loginMutation.mutateAsync>>["user"]) => {
// Seed the cache immediately so protected routes do not bounce back to /login.
utils.auth.me.setData(undefined, fallbackUser);
for (let attempt = 0; attempt < 3; attempt++) {
const user = await utils.auth.me.fetch();
if (user) {
utils.auth.me.setData(undefined, user);
return user;
}
await new Promise(resolve => window.setTimeout(resolve, 120 * (attempt + 1)));
}
return fallbackUser;
};
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
if (!username.trim()) {
toast.error("请输入用户名");
return;
}
loginMutation.mutate({ username: username.trim() });
try {
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}`);
setLocation("/dashboard");
} catch (err) {
const message = err instanceof Error ? err.message : "未知错误";
toast.error("登录失败: " + message);
}
};
return (
@@ -37,18 +59,19 @@ export default function Login() {
<Target className="w-8 h-8 text-primary" />
</div>
<h1 className="text-3xl font-bold tracking-tight">Tennis Hub</h1>
<p className="text-muted-foreground mt-2">AI网球训练助手</p>
<p className="text-muted-foreground mt-2"></p>
</div>
<Card className="border-0 shadow-xl">
<CardHeader className="text-center pb-2">
<CardTitle className="text-xl"></CardTitle>
<CardDescription>使</CardDescription>
<CardTitle className="text-xl" data-testid="login-title"></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleLogin} className="space-y-4">
<div className="space-y-2">
<Input
data-testid="login-username-input"
type="text"
placeholder="请输入您的用户名"
value={username}
@@ -58,7 +81,22 @@ 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"
className="w-full h-12 text-base font-medium"
disabled={loginMutation.isPending || !username.trim()}
@@ -94,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"></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 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">
<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>
);
}

查看文件

@@ -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>
);
}

查看文件

@@ -0,0 +1,63 @@
server {
listen 80;
listen [::]:80;
server_name te.hao.work;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name te.hao.work;
ssl_certificate /etc/letsencrypt/live/te.hao.work/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/te.hao.work/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
client_max_body_size 512m;
add_header Strict-Transport-Security "max-age=15552000" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
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;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /media/ {
proxy_pass http://127.0.0.1:8081;
proxy_http_version 1.1;
proxy_buffering off;
proxy_request_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

118
docker-compose.yml 普通文件
查看文件

@@ -0,0 +1,118 @@
services:
db:
image: mysql:8.4
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
environment:
MYSQL_DATABASE: ${MYSQL_DATABASE:-tennis_training_hub}
MYSQL_USER: ${MYSQL_USER:-tennis}
MYSQL_PASSWORD: ${MYSQL_PASSWORD:-tennis_password}
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-change-this-root-password}
volumes:
- db-data:/var/lib/mysql
ports:
- "127.0.0.1:3306:3306"
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -p$$MYSQL_ROOT_PASSWORD --silent"]
interval: 10s
timeout: 5s
retries: 12
restart: unless-stopped
migrate:
build:
context: .
dockerfile: Dockerfile
target: build
env_file:
- .env
environment:
DATABASE_URL: mysql://${MYSQL_USER:-tennis}:${MYSQL_PASSWORD:-tennis_password}@db:3306/${MYSQL_DATABASE:-tennis_training_hub}
command: ["pnpm", "exec", "drizzle-kit", "migrate"]
depends_on:
db:
condition: service_healthy
restart: "no"
app:
build:
context: .
dockerfile: Dockerfile
env_file:
- .env
environment:
PORT: 3000
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
ports:
- "127.0.0.1:3002:3000"
- "8302:3000"
volumes:
- app-data:/data/app
depends_on:
db:
condition: service_healthy
migrate:
condition: service_completed_successfully
media:
condition: service_started
restart: unless-stopped
media:
build:
context: ./media
dockerfile: Dockerfile
environment:
MEDIA_ADDR: ":8081"
MEDIA_DATA_DIR: /data/media
MEDIA_EMBEDDED_WORKER: "0"
ports:
- "127.0.0.1:8081:8081"
volumes:
- media-data:/data/media
restart: unless-stopped
media-worker:
build:
context: ./media
dockerfile: Dockerfile
command: ["media-service"]
environment:
MEDIA_MODE: worker
MEDIA_DATA_DIR: /data/media
volumes:
- media-data:/data/media
depends_on:
- 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:
media-data:

查看文件

@@ -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,253 @@
# Tennis Training Hub - 变更日志
## 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)
### 新增功能
@@ -9,7 +257,7 @@
- **训练提醒通知**:支持训练/打卡/分析多类型提醒,自定义时间和重复日期
- **浏览器通知推送**Notification API集成,权限管理和状态提示
- **通知记录管理**:未读计数、全部标记已读、历史记录浏览
- **文案优化**:去除“在家”等冗余描述,简化为直接信息反馈
- **文案调整**:去除冗余描述,简化为直接信息反馈
### 数据库变更
@@ -33,11 +281,11 @@
- **每日打卡系统**:日历视图展示打卡记录,自动计算连续打卡天数
- **成就徽章系统**24种成就徽章,涵盖里程碑、训练、连续打卡、视频、分析、评分6个类别
- **实时摄像头分析**:支持手机/电脑摄像头实时捕捉和MediaPipe姿势分析
- **摄像头位置确认提示**:引导用户调整摄像头到最佳位置
- **摄像头位置确认提示**:引导用户调整摄像头位置
- **在线录制系统**稳定压缩流录制,自适应码率1-2.5Mbps
- **断线自动重连**:摄像头意外断开时自动检测并重新连接
- **自动剪辑功能**:基于运动检测自动标记关键时刻
- **移动端全面适配**:安全区域、触摸优化、横屏支持
- **移动端适配**:安全区域、触摸优化、横屏支持
- **手机摄像头优化**:前后摄像头切换、自适应分辨率
### 数据库变更
@@ -53,7 +301,7 @@
### 文档
- 新增完整README.md
- 新增 README.md
- 新增API接口文档
- 新增数据库设计文档
- 新增功能列表清单

查看文件

@@ -1,94 +1,121 @@
# Tennis Training Hub - 功能列表清单与开发记录
# Tennis Training Hub 功能特性说明
## 功能完成状态
本文档描述当前项目的核心能力、已交付功能边界和后续增强方向。它和 `docs/verified-features.md` 配套使用:
### 核心功能
- 本文档回答“系统现在具备什么能力”
- `verified-features.md` 回答“哪些能力已经通过自动测试或构建验证”
| 编号 | 功能 | 状态 | 版本 | 说明 |
|------|------|------|------|------|
| F-001 | 用户名简单登录 | 已完成 | v1.0 | 输入用户名即可登录,自动创建账户 |
| F-002 | 训练计划AI生成 | 已完成 | v1.0 | 支持初/中/高级,1-30天计划 |
| F-003 | 视频上传功能 | 已完成 | v1.0 | 支持webm/mp4格式,S3存储 |
| F-004 | MediaPipe姿势识别 | 已完成 | v1.0 | 浏览器端实时分析33个关键点 |
| F-005 | 姿势矫正建议 | 已完成 | v1.0 | AI根据分析数据生成矫正方案 |
| F-006 | 训练计划自动调整 | 已完成 | v1.0 | 基于分析结果智能调整计划 |
| F-007 | 训练进度追踪 | 已完成 | v1.0 | 可视化图表展示训练历史 |
| F-008 | 视频库管理 | 已完成 | v1.0 | 视频列表、详情、分析状态 |
## 核心业务能力
### 参考tennis_analysis增强功能
### 用户与训练
| 编号 | 功能 | 状态 | 版本 | 说明 |
|------|------|------|------|------|
| F-009 | 击球次数统计 | 已完成 | v1.0 | 基于手腕关键点位移检测 |
| F-010 | 挥拍速度估算 | 已完成 | v1.0 | 手臂关键点帧间位移计算 |
| F-011 | 运动轨迹可视化 | 已完成 | v1.0 | 身体中心点移动轨迹绘制 |
| F-012 | 迷你球场叠加 | 已完成 | v1.0 | 视频分析界面球场示意图 |
| F-013 | 球员统计面板 | 已完成 | v1.0 | Dashboard综合数据展示 |
| F-014 | 帧级别关键时刻标注 | 已完成 | v1.0 | 自动标记击球、准备等关键帧 |
- 用户名登录:无需注册,输入用户名即可进入训练工作台
- 新用户邀请:首次创建用户名账号需要邀请码 `CA2026`
- 训练计划:按技能等级和训练周期生成训练计划,改为后台异步生成
- 训练进度:展示训练次数、时长、评分趋势、最近分析结果
- 成就系统与提醒:训练日聚合、成就进度、连练统计、提醒、通知记录
### NTRP评分系统
### 视频与分析
| 编号 | 功能 | 状态 | 版本 | 说明 |
|------|------|------|------|------|
| F-015 | NTRP自动评分 | 已完成 | v1.0 | 1.0-5.0评分,五维度加权 |
| F-016 | 历史评分自动更新 | 已完成 | v1.0 | 每次分析后自动重新计算 |
| F-017 | 多维度评分展示 | 已完成 | v1.0 | 雷达图展示五维度得分 |
| F-018 | 评分趋势图表 | 已完成 | v1.0 | 折线图展示评分变化趋势 |
- 视频上传分析:上传 `webm/mp4` 视频进入视频库并触发分析流程
- 实时摄像头分析:浏览器端调用 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 轻剪辑:视频库内可直接打开轻剪辑工作台,支持预览、设定入点/出点、建议片段和草稿导出
### v2.0 新增功能
### 在线录制与媒体链路
| 编号 | 功能 | 状态 | 版本 | 说明 |
|------|------|------|------|------|
| F-019 | 社区排行榜 - NTRP排名 | 已完成 | v2.0 | 按评分排序的用户排名 |
| F-020 | 社区排行榜 - 训练时长排名 | 已完成 | v2.0 | 按训练分钟排序 |
| F-021 | 社区排行榜 - 训练次数排名 | 已完成 | v2.0 | 按训练次数排序 |
| F-022 | 社区排行榜 - 击球数排名 | 已完成 | v2.0 | 按总击球数排序 |
| F-023 | 每日打卡系统 | 已完成 | v2.0 | 日历视图、连续天数追踪 |
| F-024 | 成就徽章系统 | 已完成 | v2.0 | 24种徽章,6个类别 |
| F-025 | 实时摄像头分析 | 已完成 | v2.0 | 手机/电脑摄像头实时捕捉 |
| F-026 | 摄像头位置确认提示 | 已完成 | v2.0 | 引导用户调整摄像头位置 |
| F-027 | 在线录制 | 已完成 | v2.0 | 稳定压缩流录制 |
| F-028 | 断线自动重连 | 已完成 | v2.0 | 摄像头断开自动恢复 |
| F-029 | 自动剪辑 | 已完成 | v2.0 | 基于运动检测标记关键片段 |
| F-030 | 移动端全面适配 | 已完成 | v2.0 | 响应式设计、安全区域、触摸优化 |
| F-031 | 手机摄像头优化 | 已完成 | v2.0 | 前后摄像头切换、自适应分辨率 |
- Go 媒体服务独立处理录制会话、分段上传、marker、归档和回放资源
- Node app worker统一处理训练计划、动作纠正和录制归档结果登记
- WebRTC 推流:录制时并行建立低延迟实时推流链路
- MediaRecorder 分段:浏览器本地压缩录制并每 60 秒自动分段上传
- 自动标记:客户端通过轻量运动检测创建关键片段 marker
- 手动标记:录制中支持手动插入剪辑点
- 自动重连:摄像头 track 断开时自动尝试恢复
- 归档回放worker 合并片段并生成 WebM,FFmpeg 可用时额外生成 MP4
- 归档状态可视化:录制页在“合并分段 / 生成回放”阶段显示任务进度、已上传体积、待上传体积和片段总数
- 视频库登记:归档完成后由 app worker 自动写回现有视频库
- 上传稳定性:媒体分段上传遇到 `502/503/504` 会自动重试
### v3.0 新增功能
### 评分、成就与管理
| 编号 | 功能 | 状态 | 版本 | 说明 |
|------|------|------|------|------|
| F-032 | 训练视频教程库 | 已完成 | v3.0 | 分类浏览、要点说明、常见错误、学习进度 |
| F-033 | 教程自评系统 | 已完成 | v3.0 | 星级自评、学习笔记、已学标记 |
| F-034 | 训练提醒通知 | 已完成 | v3.0 | 多类型提醒、自定义时间和重复日期 |
| F-035 | 浏览器通知推送 | 已完成 | v3.0 | Notification API集成、权限管理 |
| F-036 | 通知记录管理 | 已完成 | v3.0 | 未读计数、全部已读、历史记录 |
| F-037 | 去除冗余文字 | 已完成 | v3.0 | 简化UI文案,直接信息反馈 |
- 每日异步 NTRP系统会在每日零点后自动排队全量 NTRP 刷新任务
- 用户手动刷新:普通用户可刷新自己的 NTRP;管理员可刷新任意用户或全量用户
- NTRP 快照:每次刷新都会生成可追踪的快照,保存维度评分和数据来源摘要
- 成就定义表:成就系统已独立于旧徽章表,支持大规模扩展、分层、隐藏成就与分类
- 管理系统:`/admin` 提供用户管理、任务列表、实时分析会话列表、应用设置和审计日志
- H1 管理能力:当 `H1` 被配置为 admin 后,可查看全部视觉测试数据与后台管理数据
## 开发时间线
## 前端能力
| 日期 | 版本 | 里程碑 |
|------|------|--------|
| 2026-03-14 | v1.0 | 项目初始化、数据库设计、核心功能开发 |
| 2026-03-14 | v1.0 | 完成所有核心页面、MediaPipe集成、NTRP评分 |
| 2026-03-14 | v2.0 | 添加排行榜、打卡、徽章、实时摄像头、在线录制 |
| 2026-03-14 | v2.0 | 移动端适配、测试套件、文档编写 |
| 2026-03-14 | v3.0 | 教程库、训练提醒、通知系统、文案优化 |
### 移动端
## 测试覆盖
- 安全区适配
- 底部导航
- 44px 触控热区
- 横屏视频优先布局
- 录制页和分析页防下拉刷新干扰
- 录制时按设备场景自动调整码率和控件密度
- 实时分析页支持竖屏最大化预览,主要操作按钮放在侧边
| 模块 | 测试数 | 覆盖内容 |
|------|--------|---------|
| auth | 5 | me查询、logout、用户名登录验证 |
| profile | 4 | 认证检查、技能等级验证 |
| plan | 5 | 生成验证、列表、激活计划、调整 |
| video | 4 | 上传验证、列表、详情 |
| analysis | 4 | 保存验证、矫正建议、列表、视频查询 |
| record | 4 | 创建验证、完成、列表 |
| rating | 2 | 历史、当前评分 |
| checkin | 5 | 今日状态、打卡、历史 |
| badge | 5 | 列表、检查、定义、数据完整性 |
| leaderboard | 3 | 认证、排序参数、无效参数 |
| tutorial | 4 | 列表查询、分类过滤、进度更新 |
| reminder | 5 | 创建验证、切换、删除、认证 |
| notification | 4 | 列表、未读计数、标记已读 |
| **总计** | **65** | **全部通过** |
### 桌面端
- 统一工作台导航
- 仪表盘、训练、视频、录制、分析等模块一致的布局结构
- 全局任务中心:桌面侧边栏和移动端头部都可查看后台任务
- Admin 视觉测试页:`H1` 这类 admin 用户可查看全部视觉测试数据
- 视频库内置轻剪辑工作台,可在桌面端快速完成粗剪草稿、建议片段复核和导出
## 架构能力
- Node 应用负责业务 API、登录、训练数据与视频库元数据
- Go 服务负责媒体链路与归档
- 后台任务表 `background_tasks` 统一承接重任务
- `Docker Compose + 宿主机 nginx` 作为标准单机部署方式
- 统一的本地验证命令:
- `pnpm check`
- `pnpm test`
- `pnpm test:go`
- `pnpm build`
- `pnpm test:e2e`
- `pnpm verify`
## 已知边界
- 浏览器录制兼容目标以 Chrome 为主
- 当前 WebRTC 重点是浏览器到服务端的实时上行,不是多观众直播分发
- 当前 PC 剪辑已交付轻量草稿工作台,但未交付完整多轨编辑器、批量转码和最终成片渲染
- 当前存储策略为本地卷优先,未接入对象存储归档
- 当前 `.env` 配置的视觉网关若忽略 `LLM_VISION_MODEL`,系统会回退到文本纠正;代码已支持独立视觉模型配置,但上游网关能力仍需单独确认
- 当前实时动作识别仍基于姿态启发式分类,不是专门训练的动作识别模型
## 后续增强方向
### 移动端个性化增强
- 根据网络、机型和电量状态动态切换录制档位、分段大小与上传节流策略
- 将录制焦点视图扩展为单手操作布局,支持拇指热区、自定义主按钮顺序和横竖屏独立面板
- 为不同训练项目提供场景化预设,例如发球、正手、反手、步伐训练各自保存摄像头方向、裁切比例和提示文案
- 增加弱网回传面板,向用户展示排队片段、预计上传耗时和失败重试建议
### PC 轻剪与训练回放
- 在当前轻剪辑工作台基础上继续交付单轨时间线粗剪:片段拖拽、片段删除、关键帧封面和 marker 跳转
- 增加“剪辑计划”实体,允许把自动 marker、手动 marker 和 AI 建议片段一起保存
- 提供双栏回放模式:左侧原视频,右侧姿态轨迹、节奏评分和文字纠正同步滚动
- 支持从视频库直接发起导出任务,在后台生成训练集锦或问题片段合集
### 高性能前端重构
- 将训练、分析、录制、视频库拆分为按域加载的路由包,继续降低首屏主包体积
- 把共享媒体状态、任务状态和用户状态从页面本地逻辑收拢为稳定的数据域层
- 统一上传、任务轮询、错误提示和绝对 URL 规范化逻辑,减少当前多处重复实现
- 为重计算页面增加惰性加载、按需图表加载和更严格的移动端资源预算

89
docs/deploy.md 普通文件
查看文件

@@ -0,0 +1,89 @@
# Deployment Guide
## Topology
- 宿主机 nginx负责 `te.hao.work` 的 TLS、反向代理与大文件上传入口
- `db` 容器MySQL 8,数据持久化到 `db-data`
- `migrate` 容器:一次性执行 Drizzle 迁移,成功后退出
- `app` 容器Node 应用,端口 `3000`
- `app-worker` 容器Node 后台任务 worker,共享应用卷与数据库
- 宿主机公开调试端口:`8302 -> app:3000`
- `media` 容器Go 媒体服务,端口 `8081`
- `media-worker` 容器Go 媒体归档 worker,共享媒体卷
- `app-data` 卷:上传视频等本地文件存储
- `db-data`MySQL 数据目录
- `media-data` 卷:录制片段、会话状态、归档成片
## Required files
- `.env`
- `docker-compose.yml`
- `deploy/nginx.te.hao.work.conf`
## Startup
```bash
cp .env.example .env
docker compose up -d --build
```
建议在 `.env` 中至少设置:
- `JWT_SECRET`
- `MYSQL_PASSWORD`
- `MYSQL_ROOT_PASSWORD`
- `LLM_API_KEY`
- `APP_PUBLIC_BASE_URL`
- `LLM_VISION_MODEL`
如需启用独立视觉模型端点,再补:
- `LLM_VISION_API_URL`
- `LLM_VISION_API_KEY`
## nginx
`deploy/nginx.te.hao.work.conf` 放到宿主机 nginx 站点目录,确认:
- `ssl_certificate`
- `ssl_certificate_key`
- `proxy_pass http://127.0.0.1:3002` 对应前端、业务 API 和 `/uploads/*`
- `proxy_pass http://127.0.0.1:8081` 对应媒体服务
启用后重载 nginx
```bash
nginx -t
systemctl reload nginx
```
## Health checks
- `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
- 主站 HTTPS`https://te.hao.work/`
- 主站公网 4 位端口直连:`http://te.hao.work:8302/`
## Persistent data
媒体数据默认位于 Docker volume `media-data` 下,目录结构:
- `sessions/<session_id>/session.json`
- `sessions/<session_id>/segments/*.webm`
- `public/sessions/<session_id>/recording.webm`
- `public/sessions/<session_id>/recording.mp4`
应用本地上传文件默认位于 Docker volume `app-data` 下的 `/data/app/storage`
## Rollback
1. 保留 `.env``media-data`
2. 回退 Git 版本
3. 重新执行 `docker compose up -d --build`
如果只需停止录制链路,可单独关闭 `media``media-worker`,主站业务仍可继续运行;如需暂停训练计划/动作纠正等后台任务,再额外停止 `app-worker`

88
docs/developer-workflow.md 普通文件
查看文件

@@ -0,0 +1,88 @@
# Developer Workflow
## Working model
本项目采用“阶段可停可跑”的开发方式。任何较大的改动都应满足:
- 阶段结束即可本地启动
- 阶段结束即可执行验证命令
- 阶段结束即可提交本地 commit
## Recommended loop
```bash
set -a && source .env && set +a && pnpm exec drizzle-kit migrate
pnpm check
pnpm test
pnpm test:go
pnpm build
pnpm test:e2e
```
全部通过后再提交:
```bash
git status
git add .
git commit -m "..."
```
## Interrupt-safe development
如果业务开发中被打断,恢复时按以下顺序:
1. `git status` 查看当前工作树
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
不要在一半状态下长时间保留“能编译但主流程不可用”的改动。
## Deployment-safe checks
涉及 compose、nginx、数据库或媒体服务调整时,提交前至少确认
- `docker compose config` 可通过
- `docker compose ps``app``db``media``worker` 正常
- 一次性迁移容器 `migrate` 成功退出
- `curl -I https://te.hao.work/` 返回 `200`
- `curl http://127.0.0.1:8081/media/health` 返回 `{"ok":true,...}`
## Media-related changes
修改录制链路时至少检查:
- `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`
媒体改动完成后至少验证:
- 会话创建
- marker 写入
- finalize
- 视频库登记
- 实时分析停止后的会话保存和训练数据回写
## Documentation discipline
以下改动必须同步更新文档:
- 新增脚本或验证入口
- 新增或变更媒体 API
- 部署拓扑变化
- 功能能力边界变化
- 新增自动化测试覆盖范围
至少更新:
- `README.md`
- `docs/testing.md`
- `docs/verified-features.md`
- 相关专题文档

44
docs/frontend-recording.md 普通文件
查看文件

@@ -0,0 +1,44 @@
# Frontend Recording Flow
## UX goals
- Chrome 优先的低流量录制
- 录制和实时推流并行
- 断线后尽量自动恢复
- 移动端可直接使用
## Browser pipeline
1. 用户启动摄像头预览
2. 点击开始录制时创建媒体会话
3. 复用已有预览流,避免重复申请摄像头
4. 同一条 `MediaStream` 同时接入:
- `MediaRecorder`
- `RTCPeerConnection`
5. 每 60 秒主动 `requestData()` 形成一个上传分段
6. 画面抽样比较生成自动 marker
7. 结束录制后 flush 队列并调用 finalize
8. 轮询归档状态,完成后把结果注册进视频库
## Mobile adaptation
- 使用安全区样式 `env(safe-area-inset-*)`
- 底部固定导航
- 最小点击区域 `44px`
- 横屏时保留尽量大的预览画面
- 录制页容器使用 `overscroll-behavior-y: contain`,避免下拉刷新干扰
## Reconnect behavior
- 摄像头 track 结束时触发重连
- 主动停止摄像头时使用 suppression 标志,避免误判为故障
- 重连过程:
- 停止 recorder
- 保留已上传和待上传分段
- 重新获取摄像头
- 重建 WebRTC 连接
- 恢复 recorder
## Video library sync
归档完成后前端调用 `video.registerExternal`,把回放资源登记到现有视频库中,避免重写整个视频管理模块。

65
docs/media-architecture.md 普通文件
查看文件

@@ -0,0 +1,65 @@
# Media Architecture
## Responsibilities
Node 应用负责:
- 用户登录
- 训练计划与分析业务
- 视频库元数据写入
- 开发时 `/media` 同源代理
Go 媒体服务负责:
- 创建录制会话
- WebRTC 信令交换
- 接收分段上传
- 写入关键片段标记
- 会话 finalize
- 归档与回放资源生成
## HTTP API
- `POST /media/sessions`
- `POST /media/sessions/{id}/signal`
- `POST /media/sessions/{id}/segments?sequence={n}&durationMs={ms}`
- `POST /media/sessions/{id}/markers`
- `POST /media/sessions/{id}/finalize`
- `GET /media/sessions/{id}`
- `GET /media/sessions/{id}/playback`
- `GET /media/health`
## Session lifecycle
- `created`: 会话已建立,等待录制
- `recording`: 正在录制或接收分段
- `streaming`: WebRTC 连接已建立
- `reconnecting`: 摄像头或连接中断,客户端正在恢复
- `finalizing`: 会话完成,等待 worker 归档
- `archived`: 回放已生成
- `failed`: 上传、信令或归档失败
## Storage model
每个会话目录包含:
- `session.json`: 会话状态和 marker 元数据
- `segments/`: 原始分段
- `concat.txt`: FFmpeg 拼接清单
- `public/sessions/<id>/recording.webm`
- `public/sessions/<id>/recording.mp4`
## Archive flow
1. 浏览器 `finalize`
2. 会话状态变为 `ArchiveQueued`
3. worker 读取全部分段
4. 优先直接 concat,失败则重新编码为 WebM
5. 可用时生成 MP4 归档
6. 写回 playback URL 和文件大小
## Constraints
- 当前为单机本地卷模型,不依赖对象存储
- 当前 WebRTC 仅用于浏览器到服务端的实时上行,不做多观众直播分发
- Safari 原生 MP4 录制不在当前目标内

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`

182
docs/testing.md 普通文件
查看文件

@@ -0,0 +1,182 @@
# Testing Guide
## Test layers
项目当前采用四层测试结构:
### 1. 静态检查
- `pnpm check`
- `pnpm build`
- `go build ./...`
用于保证类型、打包和 Go 媒体服务编译可通过。
### 2. 单元测试
- `pnpm test`
当前覆盖:
- Node/tRPC 路由输入校验与权限检查
- 实时分析会话保存、管理员权限与异步 NTRP 刷新入队
- LLM 模块请求配置与环境变量回退逻辑
- 视觉模型 per-request model override 能力
- 视觉标准图库路由与 admin/H1 全量可见性逻辑
- 媒体工具函数,例如录制时长格式化与码率选择
- 实时分析动作片段保存、成就回写和 NTRP 刷新入队逻辑
### 3. Go 媒体服务测试
- `pnpm test:go`
当前覆盖:
- `media/health`
- 会话创建与状态聚合
- 归档流程的基础回放产物生成
### 4. 浏览器 E2E
- `pnpm test:e2e`
使用 Playwright。为保证稳定性
- 启动本地测试服务器 `pnpm dev:test`
- 测试服务器启动前要求数据库已完成 Drizzle 迁移
- 通过路由拦截模拟 tRPC 和 `/media` 接口
- 注入假媒体设备、假 `MediaRecorder` 和假 `RTCPeerConnection`
这样可以自动验证前端主流程,而不依赖真实摄像头权限和真实 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
一次性执行全部自动验证:
```bash
pnpm verify
```
执行顺序:
1. `pnpm check`
2. `pnpm test`
3. `pnpm test:go`
4. `pnpm build`
5. `pnpm test:e2e`
## Live LLM smoke test
使用真实 LLM 网关验证当前 `.env` 中的配置:
```bash
pnpm test:llm
pnpm test:llm -- "你好,做个自我介绍"
```
说明:
- 该命令会直接请求 `LLM_API_URL`
- 适合验证 `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
部署到宿主机后,建议至少补以下联测:
```bash
docker compose ps
curl -I https://te.hao.work/
curl http://127.0.0.1:8081/media/health
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 前执行:
```bash
pnpm exec playwright install chromium
```
## Notes
- E2E 目前验证的是“模块主流程是否正常”,不是媒体编码质量本身
- 若需要真实录制验证,可额外用本地 Chrome 和真实摄像头做手工联调
-`pnpm test:e2e` 失败,优先检查:
- 本地数据库是否已执行最新 Drizzle 迁移
- `PORT=3100` 是否被占用
- 浏览器依赖是否安装
- 前端路由或测试标识是否被改动

131
docs/verified-features.md 普通文件
查看文件

@@ -0,0 +1,131 @@
# Verified Features
本文档记录当前已经通过自动化验证或构建验证的项目。更新时间2026-03-15 11:58 CST。
## 最新完整验证记录
- 通过命令:`pnpm verify`
- 验证时间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 登录真实站点并跳转 `/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 -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 未捕获页面异常 | 通过 |
## 构建与编译通过
| 项目 | 验证方式 | 状态 |
|------|----------|------|
| TypeScript 类型检查 | `pnpm check` | 通过 |
| Node 应用生产构建 | `pnpm build` | 通过 |
| Go 媒体服务编译 | `pnpm test:go` 中的 `go build ./...` | 通过 |
## 单元与路由验证
| 模块 | 验证方式 | 状态 |
|------|----------|------|
| auth | `pnpm test` | 通过 |
| profile | `pnpm test` | 通过 |
| plan | `pnpm test` | 通过 |
| video | `pnpm test` | 通过 |
| analysis | `pnpm test` | 通过 |
| live analysis 会话保存 | `pnpm test` | 通过 |
| record | `pnpm test` | 通过 |
| rating | `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 媒体服务验证
| 功能 | 验证方式 | 状态 |
|------|----------|------|
| `/media/health` | `go test ./...` | 通过 |
| 会话状态聚合 | `go test ./...` | 通过 |
| 单片段归档回放产物生成 | `go test ./...` | 通过 |
## 浏览器 E2E 已验证主流程
| 模块 | 验证内容 | 状态 |
|------|----------|------|
| 登录 | 用户名输入、登录提交、跳转仪表盘 | 通过 |
| 仪表盘 | 认证后主标题与入口按钮渲染 | 通过 |
| 训练计划 | 训练计划页加载与生成入口可见 | 通过 |
| 视频库 | 视频卡片渲染 | 通过 |
| 视频库 CRUD | 新增视频记录、编辑视频信息、删除视频记录 | 通过 |
| 视频库轻剪辑 | 打开轻剪辑工作台、显示建议片段、展示导出草稿入口 | 通过 |
| 视频库轻剪辑增强 | 循环预览、区间快速载入、草稿复制、cue sheet 导出 | 通过 |
| 实时分析 | 摄像头启动入口渲染 | 通过 |
| 实时分析打分 | 启动分析后出现实时评分结果 | 通过 |
| 实时分析增强 | 动作分布、区间筛选、有效识别率和会话质量带渲染 | 通过 |
| 在线录制 | 启动摄像头、开始录制、手动标记、结束归档 | 通过 |
| 在线录制归档进度展示 | 录制页显示归档进度、已上传体积、待上传体积与片段总数 | 通过 |
| 录制焦点视图 | 移动端最大化焦点视图与主操作按钮渲染 | 通过 |
| 录制结果入库 | 归档完成后视频库可见录制结果 | 通过 |
## LLM 模块验证
| 项目 | 验证方式 | 状态 |
|------|----------|------|
| `.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 和自动化测试不受影响
## 当前未纳入自动验证的内容
- 真实摄像头权限与真实编码质量
- 真实 WebRTC 网络连通性
- 真正的 FFmpeg 多片段重编码质量
- 真机 iOS / Android 浏览器的真实媒体兼容差异
以上内容仍建议在预发或本地联调时补充人工验证。

查看文件

@@ -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`)
);

查看文件

@@ -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;

17
media/Dockerfile 普通文件
查看文件

@@ -0,0 +1,17 @@
FROM golang:1.23-bookworm AS build
WORKDIR /src
COPY go.mod ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/media-service ./main.go
FROM debian:bookworm-slim
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates ffmpeg \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /out/media-service /usr/local/bin/media-service
ENV MEDIA_ADDR=:8081
ENV MEDIA_DATA_DIR=/data/media
EXPOSE 8081
CMD ["media-service"]

28
media/go.mod 普通文件
查看文件

@@ -0,0 +1,28 @@
module tennis-training-hub/media
go 1.23.0
require github.com/pion/webrtc/v4 v4.1.2
require (
github.com/google/uuid v1.6.0 // indirect
github.com/pion/datachannel v1.5.10 // indirect
github.com/pion/dtls/v3 v3.0.6 // indirect
github.com/pion/ice/v4 v4.0.10 // indirect
github.com/pion/interceptor v0.1.40 // indirect
github.com/pion/logging v0.2.3 // indirect
github.com/pion/mdns/v2 v2.0.7 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pion/rtcp v1.2.15 // indirect
github.com/pion/rtp v1.8.18 // indirect
github.com/pion/sctp v1.8.39 // indirect
github.com/pion/sdp/v3 v3.0.13 // indirect
github.com/pion/srtp/v3 v3.0.5 // indirect
github.com/pion/stun/v3 v3.0.0 // indirect
github.com/pion/transport/v3 v3.0.7 // indirect
github.com/pion/turn/v4 v4.0.0 // indirect
github.com/wlynxg/anet v0.0.5 // indirect
golang.org/x/crypto v0.33.0 // indirect
golang.org/x/net v0.35.0 // indirect
golang.org/x/sys v0.30.0 // indirect
)

50
media/go.sum 普通文件
查看文件

@@ -0,0 +1,50 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o=
github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M=
github.com/pion/dtls/v3 v3.0.6 h1:7Hkd8WhAJNbRgq9RgdNh1aaWlZlGpYTzdqjy9x9sK2E=
github.com/pion/dtls/v3 v3.0.6/go.mod h1:iJxNQ3Uhn1NZWOMWlLxEEHAN5yX7GyPvvKw04v9bzYU=
github.com/pion/ice/v4 v4.0.10 h1:P59w1iauC/wPk9PdY8Vjl4fOFL5B+USq1+xbDcN6gT4=
github.com/pion/ice/v4 v4.0.10/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw=
github.com/pion/interceptor v0.1.40 h1:e0BjnPcGpr2CFQgKhrQisBU7V3GXK6wrfYrGYaU6Jq4=
github.com/pion/interceptor v0.1.40/go.mod h1:Z6kqH7M/FYirg3frjGJ21VLSRJGBXB/KqaTIrdqnOic=
github.com/pion/logging v0.2.3 h1:gHuf0zpoh1GW67Nr6Gj4cv5Z9ZscU7g/EaoC/Ke/igI=
github.com/pion/logging v0.2.3/go.mod h1:z8YfknkquMe1csOrxK5kc+5/ZPAzMxbKLX5aXpbpC90=
github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM=
github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA=
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
github.com/pion/rtcp v1.2.15 h1:LZQi2JbdipLOj4eBjK4wlVoQWfrZbh3Q6eHtWtJBZBo=
github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0=
github.com/pion/rtp v1.8.18 h1:yEAb4+4a8nkPCecWzQB6V/uEU18X1lQCGAQCjP+pyvU=
github.com/pion/rtp v1.8.18/go.mod h1:bAu2UFKScgzyFqvUKmbvzSdPr+NGbZtv6UB2hesqXBk=
github.com/pion/sctp v1.8.39 h1:PJma40vRHa3UTO3C4MyeJDQ+KIobVYRZQZ0Nt7SjQnE=
github.com/pion/sctp v1.8.39/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE=
github.com/pion/sdp/v3 v3.0.13 h1:uN3SS2b+QDZnWXgdr69SM8KB4EbcnPnPf2Laxhty/l4=
github.com/pion/sdp/v3 v3.0.13/go.mod h1:88GMahN5xnScv1hIMTqLdu/cOcUkj6a9ytbncwMCq2E=
github.com/pion/srtp/v3 v3.0.5 h1:8XLB6Dt3QXkMkRFpoqC3314BemkpMQK2mZeJc4pUKqo=
github.com/pion/srtp/v3 v3.0.5/go.mod h1:r1G7y5r1scZRLe2QJI/is+/O83W2d+JoEsuIexpw+uM=
github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw=
github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU=
github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0=
github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo=
github.com/pion/turn/v4 v4.0.0 h1:qxplo3Rxa9Yg1xXDxxH8xaqcyGUtbHYw4QSCvmFWvhM=
github.com/pion/turn/v4 v4.0.0/go.mod h1:MuPDkm15nYSklKpN8vWJ9W2M0PlyQZqYt1McGuxG7mA=
github.com/pion/webrtc/v4 v4.1.2 h1:mpuUo/EJ1zMNKGE79fAdYNFZBX790KE7kQQpLMjjR54=
github.com/pion/webrtc/v4 v4.1.2/go.mod h1:xsCXiNAmMEjIdFxAYU0MbB3RwRieJsegSB2JZsGN+8U=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

1139
media/main.go 普通文件

文件差异内容过多而无法显示 加载差异

280
media/main_test.go 普通文件
查看文件

@@ -0,0 +1,280 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func TestMediaHealthAndSessionLifecycle(t *testing.T) {
store, err := newSessionStore(t.TempDir())
if err != nil {
t.Fatalf("newSessionStore: %v", err)
}
server := newMediaServer(store)
healthReq := httptest.NewRequest(http.MethodGet, "/media/health", nil)
healthRes := httptest.NewRecorder()
server.routes().ServeHTTP(healthRes, healthReq)
if healthRes.Code != http.StatusOK {
t.Fatalf("expected health 200, got %d", healthRes.Code)
}
session, err := store.createSession(CreateSessionRequest{
UserID: "1",
Title: "Test Session",
Format: "webm",
MimeType: "video/webm",
QualityPreset: "balanced",
FacingMode: "environment",
DeviceKind: "desktop",
})
if err != nil {
t.Fatalf("createSession: %v", err)
}
if _, err := store.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.Markers = append(current.Markers, Marker{
ID: "marker-1",
Type: "manual",
Label: "关键片段",
Timestamp: 5000,
CreatedAt: "2026-03-14T00:00:00Z",
})
return nil
}); err != nil {
t.Fatalf("updateSession: %v", err)
}
if err := os.WriteFile(filepath.Join(store.segmentsDir(session.ID), "000000.webm"), []byte("segment"), 0o644); err != nil {
t.Fatalf("write segment: %v", err)
}
current, err := store.getSession(session.ID)
if err != nil {
t.Fatalf("getSession: %v", err)
}
if current.UploadedSegments != 1 {
t.Fatalf("expected uploaded segment count to be recomputed")
}
if current.UploadedBytes != 7 {
t.Fatalf("expected uploaded bytes to be recomputed, got %d", current.UploadedBytes)
}
}
func TestProcessSessionArchivesPlayback(t *testing.T) {
tempDir := t.TempDir()
store, err := newSessionStore(tempDir)
if err != nil {
t.Fatalf("newSessionStore: %v", err)
}
session, err := store.createSession(CreateSessionRequest{UserID: "1", Title: "Archive Session"})
if err != nil {
t.Fatalf("createSession: %v", err)
}
if err := os.WriteFile(filepath.Join(store.segmentsDir(session.ID), "000000.webm"), []byte("segment"), 0o644); err != nil {
t.Fatalf("write segment: %v", err)
}
if _, err := store.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
return nil
}); err != nil {
t.Fatalf("updateSession: %v", err)
}
fakeFFmpeg := filepath.Join(tempDir, "ffmpeg")
script := "#!/bin/sh\ninput=''\noutput=''\nprev=''\nfor arg in \"$@\"; do\n if [ \"$prev\" = '-i' ]; then input=\"$arg\"; fi\n prev=\"$arg\"\n output=\"$arg\"\ndone\nif [ -n \"$input\" ] && [ -f \"$input\" ]; then cp \"$input\" \"$output\"; else : > \"$output\"; fi\n"
if err := os.WriteFile(fakeFFmpeg, []byte(script), 0o755); err != nil {
t.Fatalf("write fake ffmpeg: %v", err)
}
originalPath := os.Getenv("PATH")
t.Setenv("PATH", tempDir+string(os.PathListSeparator)+originalPath)
if err := processSession(store, session.ID); err != nil {
t.Fatalf("processSession: %v", err)
}
archived, err := store.getSession(session.ID)
if err != nil {
t.Fatalf("getSession: %v", err)
}
if archived.ArchiveStatus != ArchiveCompleted {
t.Fatalf("expected archive completed, got %s", archived.ArchiveStatus)
}
if archived.Playback.WebMURL == "" || !strings.HasSuffix(archived.Playback.WebMURL, ".webm") {
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)
}
}

查看文件

@@ -5,16 +5,23 @@
"license": "MIT",
"scripts": {
"dev": "NODE_ENV=development tsx watch 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",
"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 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",
"test:go": "cd media && go test ./... && go build ./...",
"test:e2e": "playwright test",
"test:llm": "tsx scripts/llm-smoke.ts",
"verify": "pnpm check && pnpm test && pnpm test:go && pnpm build && pnpm test:e2e",
"db:push": "drizzle-kit generate && drizzle-kit migrate"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.693.0",
"@aws-sdk/s3-request-presigner": "^3.693.0",
"@builder.io/vite-plugin-jsx-loc": "^0.1.1",
"@hookform/resolvers": "^5.2.2",
"@mediapipe/drawing_utils": "^0.3.1675466124",
"@mediapipe/pose": "^0.5.1675469404",
@@ -45,9 +52,11 @@
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.2",
"@tailwindcss/vite": "^4.1.3",
"@trpc/client": "^11.6.0",
"@trpc/react-query": "^11.6.0",
"@trpc/server": "^11.6.0",
"@vitejs/plugin-react": "^5.0.4",
"axios": "^1.12.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -77,23 +86,24 @@
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7",
"vaul": "^1.1.2",
"vite": "^7.1.7",
"vite-plugin-manus-runtime": "^0.0.57",
"wouter": "^3.3.5",
"zod": "^4.1.12"
},
"devDependencies": {
"@builder.io/vite-plugin-jsx-loc": "^0.1.1",
"@playwright/test": "^1.55.0",
"@tailwindcss/typography": "^0.5.15",
"@tailwindcss/vite": "^4.1.3",
"@types/express": "4.17.21",
"@types/google.maps": "^3.58.1",
"@types/node": "^24.7.0",
"@types/react": "^19.2.1",
"@types/react-dom": "^19.2.1",
"@vitejs/plugin-react": "^5.0.4",
"add": "^2.0.6",
"autoprefixer": "^10.4.20",
"drizzle-kit": "^0.31.4",
"esbuild": "^0.25.0",
"jsdom": "^28.1.0",
"pnpm": "^10.15.1",
"postcss": "^8.4.47",
"prettier": "^3.6.2",
@@ -101,8 +111,6 @@
"tsx": "^4.19.1",
"tw-animate-css": "^1.4.0",
"typescript": "5.9.3",
"vite": "^7.1.7",
"vite-plugin-manus-runtime": "^0.0.57",
"vitest": "^2.1.4"
},
"packageManager": "pnpm@10.4.1+sha512.c753b6c3ad7afa13af388fa6d808035a008e30ea9993f58c6663e2bc5ff21679aa834db094987129aa4d488b86df57f7b634981b2f827cdcacc698cc0cfb88af",
@@ -114,4 +122,4 @@
"tailwindcss>nanoid": "3.3.7"
}
}
}
}

24
playwright.config.ts 普通文件
查看文件

@@ -0,0 +1,24 @@
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./tests/e2e",
timeout: 60_000,
expect: {
timeout: 10_000,
},
fullyParallel: false,
retries: process.env.CI ? 2 : 0,
use: {
baseURL: "http://127.0.0.1:41731",
headless: true,
trace: "on-first-retry",
screenshot: "only-on-failure",
video: "retain-on-failure",
},
webServer: {
command: "pnpm dev:test",
url: "http://127.0.0.1:41731",
timeout: 120_000,
reuseExistingServer: false,
},
});

443
pnpm-lock.yaml 自动生成的
查看文件

@@ -22,6 +22,9 @@ importers:
'@aws-sdk/s3-request-presigner':
specifier: ^3.693.0
version: 3.907.0
'@builder.io/vite-plugin-jsx-loc':
specifier: ^0.1.1
version: 0.1.1(vite@7.1.9(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6))
'@hookform/resolvers':
specifier: ^5.2.2
version: 5.2.2(react-hook-form@7.64.0(react@19.2.1))
@@ -109,6 +112,9 @@ importers:
'@radix-ui/react-tooltip':
specifier: ^1.2.8
version: 1.2.8(@types/react-dom@19.2.1(@types/react@19.2.1))(@types/react@19.2.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
'@tailwindcss/vite':
specifier: ^4.1.3
version: 4.1.14(vite@7.1.9(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6))
'@tanstack/react-query':
specifier: ^5.90.2
version: 5.90.2(react@19.2.1)
@@ -121,6 +127,9 @@ importers:
'@trpc/server':
specifier: ^11.6.0
version: 11.6.0(typescript@5.9.3)
'@vitejs/plugin-react':
specifier: ^5.0.4
version: 5.0.4(vite@7.1.9(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6))
axios:
specifier: ^1.12.0
version: 1.12.2
@@ -208,6 +217,12 @@ importers:
vaul:
specifier: ^1.1.2
version: 1.1.2(@types/react-dom@19.2.1(@types/react@19.2.1))(@types/react@19.2.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
vite:
specifier: ^7.1.7
version: 7.1.9(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6)
vite-plugin-manus-runtime:
specifier: ^0.0.57
version: 0.0.57
wouter:
specifier: ^3.3.5
version: 3.7.1(patch_hash=4e16e6ff3fde7d6c1024d3e0c8605dc9eb6afb690d0d49958c2f449091813072)(react@19.2.1)
@@ -215,15 +230,12 @@ importers:
specifier: ^4.1.12
version: 4.1.12
devDependencies:
'@builder.io/vite-plugin-jsx-loc':
specifier: ^0.1.1
version: 0.1.1(vite@7.1.9(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6))
'@playwright/test':
specifier: ^1.55.0
version: 1.58.2
'@tailwindcss/typography':
specifier: ^0.5.15
version: 0.5.19(tailwindcss@4.1.14)
'@tailwindcss/vite':
specifier: ^4.1.3
version: 4.1.14(vite@7.1.9(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6))
'@types/express':
specifier: 4.17.21
version: 4.17.21
@@ -239,9 +251,6 @@ importers:
'@types/react-dom':
specifier: ^19.2.1
version: 19.2.1(@types/react@19.2.1)
'@vitejs/plugin-react':
specifier: ^5.0.4
version: 5.0.4(vite@7.1.9(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6))
add:
specifier: ^2.0.6
version: 2.0.6
@@ -254,6 +263,9 @@ importers:
esbuild:
specifier: ^0.25.0
version: 0.25.10
jsdom:
specifier: ^28.1.0
version: 28.1.0
pnpm:
specifier: ^10.15.1
version: 10.18.0
@@ -275,24 +287,31 @@ importers:
typescript:
specifier: 5.9.3
version: 5.9.3
vite:
specifier: ^7.1.7
version: 7.1.9(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6)
vite-plugin-manus-runtime:
specifier: ^0.0.57
version: 0.0.57
vitest:
specifier: ^2.1.4
version: 2.1.9(@types/node@24.7.0)(lightningcss@1.30.1)
version: 2.1.9(@types/node@24.7.0)(jsdom@28.1.0)(lightningcss@1.30.1)
packages:
'@acemir/cssom@0.9.31':
resolution: {integrity: sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==}
'@antfu/install-pkg@1.1.0':
resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==}
'@antfu/utils@9.3.0':
resolution: {integrity: sha512-9hFT4RauhcUzqOE4f1+frMKLZrgNog5b06I7VmZQV1BkvwvqrbC8EBZf3L1eEL2AKb6rNKjER0sEvJiSP1FXEA==}
'@asamuzakjp/css-color@5.0.1':
resolution: {integrity: sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
'@asamuzakjp/dom-selector@6.8.1':
resolution: {integrity: sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==}
'@asamuzakjp/nwsapi@2.3.9':
resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==}
'@aws-crypto/crc32@5.2.0':
resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==}
engines: {node: '>=16.0.0'}
@@ -546,6 +565,10 @@ packages:
'@braintree/sanitize-url@7.1.1':
resolution: {integrity: sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw==}
'@bramus/specificity@2.4.2':
resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==}
hasBin: true
'@builder.io/jsx-loc-internals@0.0.1':
resolution: {integrity: sha512-cSADapVCi07DDhcuDmcAVItqSVmji7DNyD3xxYTHyNCwhWMNnTpZjyvDIWwYFJLleyDCJ9VUtbaXtUjjqBiRqw==}
@@ -569,6 +592,37 @@ packages:
'@chevrotain/utils@11.0.3':
resolution: {integrity: sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==}
'@csstools/color-helpers@6.0.2':
resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==}
engines: {node: '>=20.19.0'}
'@csstools/css-calc@3.1.1':
resolution: {integrity: sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==}
engines: {node: '>=20.19.0'}
peerDependencies:
'@csstools/css-parser-algorithms': ^4.0.0
'@csstools/css-tokenizer': ^4.0.0
'@csstools/css-color-parser@4.0.2':
resolution: {integrity: sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==}
engines: {node: '>=20.19.0'}
peerDependencies:
'@csstools/css-parser-algorithms': ^4.0.0
'@csstools/css-tokenizer': ^4.0.0
'@csstools/css-parser-algorithms@4.0.0':
resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==}
engines: {node: '>=20.19.0'}
peerDependencies:
'@csstools/css-tokenizer': ^4.0.0
'@csstools/css-syntax-patches-for-csstree@1.1.0':
resolution: {integrity: sha512-H4tuz2nhWgNKLt1inYpoVCfbJbMwX/lQKp3g69rrrIMIYlFD9+zTykOKhNR8uGrAmbS/kT9n6hTFkmDkxLgeTA==}
'@csstools/css-tokenizer@4.0.0':
resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
engines: {node: '>=20.19.0'}
'@date-fns/tz@1.4.1':
resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==}
@@ -1009,6 +1063,15 @@ packages:
cpu: [x64]
os: [win32]
'@exodus/bytes@1.15.0':
resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
peerDependencies:
'@noble/hashes': ^1.8.0 || ^2.0.0
peerDependenciesMeta:
'@noble/hashes':
optional: true
'@floating-ui/core@1.7.3':
resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==}
@@ -1067,6 +1130,11 @@ packages:
'@mermaid-js/parser@0.6.3':
resolution: {integrity: sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==}
'@playwright/test@1.58.2':
resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==}
engines: {node: '>=18'}
hasBin: true
'@radix-ui/number@1.1.1':
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
@@ -1716,56 +1784,67 @@ packages:
resolution: {integrity: sha512-xRiOu9Of1FZ4SxVbB0iEDXc4ddIcjCv2aj03dmW8UrZIW7aIQ9jVJdLBIhxBI+MaTnGAKyvMwPwQnoOEvP7FgQ==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.52.4':
resolution: {integrity: sha512-FbhM2p9TJAmEIEhIgzR4soUcsW49e9veAQCziwbR+XWB2zqJ12b4i/+hel9yLiD8pLncDH4fKIPIbt5238341Q==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.52.4':
resolution: {integrity: sha512-4n4gVwhPHR9q/g8lKCyz0yuaD0MvDf7dV4f9tHt0C73Mp8h38UCtSCSE6R9iBlTbXlmA8CjpsZoujhszefqueg==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.52.4':
resolution: {integrity: sha512-u0n17nGA0nvi/11gcZKsjkLj1QIpAuPFQbR48Subo7SmZJnGxDpspyw2kbpuoQnyK+9pwf3pAoEXerJs/8Mi9g==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.52.4':
resolution: {integrity: sha512-0G2c2lpYtbTuXo8KEJkDkClE/+/2AFPdPAbmaHoE870foRFs4pBrDehilMcrSScrN/fB/1HTaWO4bqw+ewBzMQ==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-gnu@4.52.4':
resolution: {integrity: sha512-teSACug1GyZHmPDv14VNbvZFX779UqWTsd7KtTM9JIZRDI5NUwYSIS30kzI8m06gOPB//jtpqlhmraQ68b5X2g==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-gnu@4.52.4':
resolution: {integrity: sha512-/MOEW3aHjjs1p4Pw1Xk4+3egRevx8Ji9N6HUIA1Ifh8Q+cg9dremvFCUbOX2Zebz80BwJIgCBUemjqhU5XI5Eg==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.52.4':
resolution: {integrity: sha512-1HHmsRyh845QDpEWzOFtMCph5Ts+9+yllCrREuBR/vg2RogAQGGBRC8lDPrPOMnrdOJ+mt1WLMOC2Kao/UwcvA==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.52.4':
resolution: {integrity: sha512-seoeZp4L/6D1MUyjWkOMRU6/iLmCU2EjbMTyAG4oIOs1/I82Y5lTeaxW0KBfkUdHAWN7j25bpkt0rjnOgAcQcA==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.52.4':
resolution: {integrity: sha512-Wi6AXf0k0L7E2gteNsNHUs7UMwCIhsCTs6+tqQ5GPwVRWMaflqGec4Sd8n6+FNFDw9vGcReqk2KzBDhCa1DLYg==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.52.4':
resolution: {integrity: sha512-dtBZYjDmCQ9hW+WgEkaffvRRCKm767wWhxsFW3Lw86VXz/uJRuD438/XvbZT//B96Vs8oTA8Q4A0AfHbrxP9zw==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openharmony-arm64@4.52.4':
resolution: {integrity: sha512-1ox+GqgRWqaB1RnyZXL8PD6E5f7YyRUJYnCqKpNzxzP0TkaUh112NDrR9Tt+C8rJ4x5G9Mk8PQR3o7Ku2RKqKA==}
@@ -2070,24 +2149,28 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.1.14':
resolution: {integrity: sha512-ISZjT44s59O8xKsPEIesiIydMG/sCXoMBCqsphDm/WcbnuWLxxb+GcvSIIA5NjUw6F8Tex7s5/LM2yDy8RqYBQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.1.14':
resolution: {integrity: sha512-02c6JhLPJj10L2caH4U0zF8Hji4dOeahmuMl23stk0MU1wfd1OraE7rOloidSF8W5JTHkFdVo/O7uRUJJnUAJg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.1.14':
resolution: {integrity: sha512-TNGeLiN1XS66kQhxHG/7wMeQDOoL0S33x9BgmydbrWAb9Qw0KYdd8o1ifx4HOGDWhVmJ+Ul+JQ7lyknQFilO3Q==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.1.14':
resolution: {integrity: sha512-uZYAsaW/jS/IYkd6EWPJKW/NlPNSkWkBlaeVBi/WsFQNP05/bzkebUL8FH1pdsqx4f2fH/bWFcUABOM9nfiJkQ==}
@@ -2391,6 +2474,10 @@ packages:
add@2.0.6:
resolution: {integrity: sha512-j5QzrmsokwWWp6kUcJQySpbG+xfOBqqKnup3OIk1pz+kB/80SLorZ9V8zHFLO92Lcd+hbvq8bT+zOGoPkmBV0Q==}
agent-base@7.1.4:
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
engines: {node: '>= 14'}
aria-hidden@1.2.6:
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
engines: {node: '>=10'}
@@ -2426,6 +2513,9 @@ packages:
resolution: {integrity: sha512-vAPMQdnyKCBtkmQA6FMCBvU9qFIppS3nzyXnEM+Lo2IAhG4Mpjv9cCxMudhgV3YdNNJv6TNqXy97dfRVL2LmaQ==}
hasBin: true
bidi-js@1.0.3:
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
body-parser@1.20.3:
resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==}
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
@@ -2561,11 +2651,19 @@ packages:
cose-base@2.2.0:
resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==}
css-tree@3.2.1:
resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==}
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
cssesc@3.0.0:
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
engines: {node: '>=4'}
hasBin: true
cssstyle@6.2.0:
resolution: {integrity: sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==}
engines: {node: '>=20'}
csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
@@ -2725,6 +2823,10 @@ packages:
dagre-d3-es@7.0.11:
resolution: {integrity: sha512-tvlJLyQf834SylNKax8Wkzco/1ias1OPw8DcUMDE7oUIoSEW25riQVuiu/0OWEFqT0cxHT3Pa9/D82Jr47IONw==}
data-urls@7.0.0:
resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
date-fns-jalali@4.1.0-0:
resolution: {integrity: sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==}
@@ -2754,6 +2856,9 @@ packages:
decimal.js-light@2.5.1:
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
decimal.js@10.6.0:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
decode-named-character-reference@1.2.0:
resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==}
@@ -3078,6 +3183,11 @@ packages:
resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==}
engines: {node: '>= 0.6'}
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -3173,6 +3283,10 @@ packages:
hastscript@9.0.1:
resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==}
html-encoding-sniffer@6.0.0:
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
html-url-attributes@3.0.1:
resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
@@ -3183,6 +3297,14 @@ packages:
resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==}
engines: {node: '>= 0.8'}
http-proxy-agent@7.0.2:
resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==}
engines: {node: '>= 14'}
https-proxy-agent@7.0.6:
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
engines: {node: '>= 14'}
iconv-lite@0.4.24:
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
engines: {node: '>=0.10.0'}
@@ -3234,6 +3356,9 @@ packages:
resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==}
engines: {node: '>=12'}
is-potential-custom-element-name@1.0.1:
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
is-property@1.0.2:
resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==}
@@ -3251,6 +3376,15 @@ packages:
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
jsdom@28.1.0:
resolution: {integrity: sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
peerDependencies:
canvas: ^3.0.0
peerDependenciesMeta:
canvas:
optional: true
jsesc@3.1.0:
resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
engines: {node: '>=6'}
@@ -3310,24 +3444,28 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.30.1:
resolution: {integrity: sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.30.1:
resolution: {integrity: sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.30.1:
resolution: {integrity: sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.30.1:
resolution: {integrity: sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==}
@@ -3368,6 +3506,10 @@ packages:
loupe@3.2.1:
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
lru-cache@11.2.7:
resolution: {integrity: sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==}
engines: {node: 20 || >=22}
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
@@ -3452,6 +3594,9 @@ packages:
mdast-util-to-string@4.0.0:
resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==}
mdn-data@2.27.1:
resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
media-typer@0.3.0:
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
engines: {node: '>= 0.6'}
@@ -3657,6 +3802,9 @@ packages:
parse5@7.3.0:
resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
parse5@8.0.0:
resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==}
parseurl@1.3.3:
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
engines: {node: '>= 0.8'}
@@ -3690,6 +3838,16 @@ packages:
pkg-types@2.3.0:
resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==}
playwright-core@1.58.2:
resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==}
engines: {node: '>=18'}
hasBin: true
playwright@1.58.2:
resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==}
engines: {node: '>=18'}
hasBin: true
pnpm@10.18.0:
resolution: {integrity: sha512-6AT4ifHOzEDVctsITuw+SIFzn43sacD/ENLRvv+aTjCTg7ontbdQBZ1/TBSVNbbNDSyx7Trrc5I5pChKaPQM+g==}
engines: {node: '>=18.12'}
@@ -3733,6 +3891,10 @@ packages:
proxy-from-env@1.1.0:
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
qs@6.13.0:
resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==}
engines: {node: '>=0.6'}
@@ -3880,6 +4042,10 @@ packages:
remark-stringify@11.0.0:
resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==}
require-from-string@2.0.2:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
resolve-pkg-maps@1.0.0:
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
@@ -3903,6 +4069,10 @@ packages:
safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
saxes@6.0.0:
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
engines: {node: '>=v12.22.7'}
scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
@@ -4004,6 +4174,9 @@ packages:
resolution: {integrity: sha512-mJiVjfd2vokfDxsQPOwJ/PtanO87LhpYY88ubI5dUB1Ab58Txbyje3+jpm+/83R/fevaq/107NNhtYBLuoTrFg==}
engines: {node: '>=10'}
symbol-tree@3.2.4:
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
tailwind-merge@3.3.1:
resolution: {integrity: sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==}
@@ -4051,10 +4224,25 @@ packages:
resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==}
engines: {node: '>=14.0.0'}
tldts-core@7.0.25:
resolution: {integrity: sha512-ZjCZK0rppSBu7rjHYDYsEaMOIbbT+nWF57hKkv4IUmZWBNrBWBOjIElc0mKRgLM8bm7x/BBlof6t2gi/Oq/Asw==}
tldts@7.0.25:
resolution: {integrity: sha512-keinCnPbwXEUG3ilrWQZU+CqcTTzHq9m2HhoUP2l7Xmi8l1LuijAXLpAJ5zRW+ifKTNscs4NdCkfkDCBYm352w==}
hasBin: true
toidentifier@1.0.1:
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
engines: {node: '>=0.6'}
tough-cookie@6.0.1:
resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==}
engines: {node: '>=16'}
tr46@6.0.0:
resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
engines: {node: '>=20'}
trim-lines@3.0.1:
resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
@@ -4091,6 +4279,10 @@ packages:
undici-types@7.14.0:
resolution: {integrity: sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==}
undici@7.24.2:
resolution: {integrity: sha512-P9J1HWYV/ajFr8uCqk5QixwiRKmB1wOamgS0e+o2Z4A44Ej2+thFVRLG/eA7qprx88XXhnV5Bl8LHXTURpzB3Q==}
engines: {node: '>=20.18.1'}
unified@11.0.5:
resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
@@ -4307,9 +4499,25 @@ packages:
vscode-uri@3.0.8:
resolution: {integrity: sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==}
w3c-xmlserializer@5.0.0:
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
engines: {node: '>=18'}
web-namespaces@2.0.1:
resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==}
webidl-conversions@8.0.1:
resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==}
engines: {node: '>=20'}
whatwg-mimetype@5.0.0:
resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==}
engines: {node: '>=20'}
whatwg-url@16.0.1:
resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
why-is-node-running@2.3.0:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'}
@@ -4320,6 +4528,13 @@ packages:
peerDependencies:
react: '>=16.8.0'
xml-name-validator@5.0.0:
resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
engines: {node: '>=18'}
xmlchars@2.2.0:
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
@@ -4335,6 +4550,8 @@ packages:
snapshots:
'@acemir/cssom@0.9.31': {}
'@antfu/install-pkg@1.1.0':
dependencies:
package-manager-detector: 1.5.0
@@ -4342,6 +4559,24 @@ snapshots:
'@antfu/utils@9.3.0': {}
'@asamuzakjp/css-color@5.0.1':
dependencies:
'@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
'@csstools/css-color-parser': 4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
'@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
'@csstools/css-tokenizer': 4.0.0
lru-cache: 11.2.7
'@asamuzakjp/dom-selector@6.8.1':
dependencies:
'@asamuzakjp/nwsapi': 2.3.9
bidi-js: 1.0.3
css-tree: 3.2.1
is-potential-custom-element-name: 1.0.1
lru-cache: 11.2.7
'@asamuzakjp/nwsapi@2.3.9': {}
'@aws-crypto/crc32@5.2.0':
dependencies:
'@aws-crypto/util': 5.2.0
@@ -4944,6 +5179,10 @@ snapshots:
'@braintree/sanitize-url@7.1.1': {}
'@bramus/specificity@2.4.2':
dependencies:
css-tree: 3.2.1
'@builder.io/jsx-loc-internals@0.0.1':
dependencies:
'@babel/parser': 7.28.4
@@ -4972,6 +5211,28 @@ snapshots:
'@chevrotain/utils@11.0.3': {}
'@csstools/color-helpers@6.0.2': {}
'@csstools/css-calc@3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
dependencies:
'@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
'@csstools/css-tokenizer': 4.0.0
'@csstools/css-color-parser@4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
dependencies:
'@csstools/color-helpers': 6.0.2
'@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
'@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
'@csstools/css-tokenizer': 4.0.0
'@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)':
dependencies:
'@csstools/css-tokenizer': 4.0.0
'@csstools/css-syntax-patches-for-csstree@1.1.0': {}
'@csstools/css-tokenizer@4.0.0': {}
'@date-fns/tz@1.4.1': {}
'@drizzle-team/brocli@0.10.2': {}
@@ -5199,6 +5460,8 @@ snapshots:
'@esbuild/win32-x64@0.25.10':
optional: true
'@exodus/bytes@1.15.0': {}
'@floating-ui/core@1.7.3':
dependencies:
'@floating-ui/utils': 0.2.10
@@ -5269,6 +5532,10 @@ snapshots:
dependencies:
langium: 3.3.1
'@playwright/test@1.58.2':
dependencies:
playwright: 1.58.2
'@radix-ui/number@1.1.1': {}
'@radix-ui/primitive@1.1.3': {}
@@ -6755,6 +7022,8 @@ snapshots:
add@2.0.6: {}
agent-base@7.1.4: {}
aria-hidden@1.2.6:
dependencies:
tslib: 2.8.1
@@ -6789,6 +7058,10 @@ snapshots:
baseline-browser-mapping@2.8.12: {}
bidi-js@1.0.3:
dependencies:
require-from-string: 2.0.2
body-parser@1.20.3:
dependencies:
bytes: 3.1.2
@@ -6928,8 +7201,20 @@ snapshots:
dependencies:
layout-base: 2.0.1
css-tree@3.2.1:
dependencies:
mdn-data: 2.27.1
source-map-js: 1.2.1
cssesc@3.0.0: {}
cssstyle@6.2.0:
dependencies:
'@asamuzakjp/css-color': 5.0.1
'@csstools/css-syntax-patches-for-csstree': 1.1.0
css-tree: 3.2.1
lru-cache: 11.2.7
csstype@3.1.3: {}
cytoscape-cose-bilkent@4.1.0(cytoscape@3.33.1):
@@ -7116,6 +7401,13 @@ snapshots:
d3: 7.9.0
lodash-es: 4.17.21
data-urls@7.0.0:
dependencies:
whatwg-mimetype: 5.0.0
whatwg-url: 16.0.1
transitivePeerDependencies:
- '@noble/hashes'
date-fns-jalali@4.1.0-0: {}
date-fns@4.1.0: {}
@@ -7132,6 +7424,8 @@ snapshots:
decimal.js-light@2.5.1: {}
decimal.js@10.6.0: {}
decode-named-character-reference@1.2.0:
dependencies:
character-entities: 2.0.2
@@ -7428,6 +7722,9 @@ snapshots:
fresh@0.5.2: {}
fsevents@2.3.2:
optional: true
fsevents@2.3.3:
optional: true
@@ -7601,6 +7898,12 @@ snapshots:
property-information: 7.1.0
space-separated-tokens: 2.0.2
html-encoding-sniffer@6.0.0:
dependencies:
'@exodus/bytes': 1.15.0
transitivePeerDependencies:
- '@noble/hashes'
html-url-attributes@3.0.1: {}
html-void-elements@3.0.0: {}
@@ -7613,6 +7916,20 @@ snapshots:
statuses: 2.0.1
toidentifier: 1.0.1
http-proxy-agent@7.0.2:
dependencies:
agent-base: 7.1.4
debug: 4.4.3
transitivePeerDependencies:
- supports-color
https-proxy-agent@7.0.6:
dependencies:
agent-base: 7.1.4
debug: 4.4.3
transitivePeerDependencies:
- supports-color
iconv-lite@0.4.24:
dependencies:
safer-buffer: 2.1.2
@@ -7653,6 +7970,8 @@ snapshots:
is-plain-obj@4.1.0: {}
is-potential-custom-element-name@1.0.1: {}
is-property@1.0.2: {}
is-what@4.1.16: {}
@@ -7663,6 +7982,33 @@ snapshots:
js-tokens@4.0.0: {}
jsdom@28.1.0:
dependencies:
'@acemir/cssom': 0.9.31
'@asamuzakjp/dom-selector': 6.8.1
'@bramus/specificity': 2.4.2
'@exodus/bytes': 1.15.0
cssstyle: 6.2.0
data-urls: 7.0.0
decimal.js: 10.6.0
html-encoding-sniffer: 6.0.0
http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.6
is-potential-custom-element-name: 1.0.1
parse5: 8.0.0
saxes: 6.0.0
symbol-tree: 3.2.4
tough-cookie: 6.0.1
undici: 7.24.2
w3c-xmlserializer: 5.0.0
webidl-conversions: 8.0.1
whatwg-mimetype: 5.0.0
whatwg-url: 16.0.1
xml-name-validator: 5.0.0
transitivePeerDependencies:
- '@noble/hashes'
- supports-color
jsesc@3.1.0: {}
json5@2.2.3: {}
@@ -7752,6 +8098,8 @@ snapshots:
loupe@3.2.1: {}
lru-cache@11.2.7: {}
lru-cache@5.1.1:
dependencies:
yallist: 3.1.1
@@ -7943,6 +8291,8 @@ snapshots:
dependencies:
'@types/mdast': 4.0.4
mdn-data@2.27.1: {}
media-typer@0.3.0: {}
merge-descriptors@1.0.3: {}
@@ -8273,6 +8623,10 @@ snapshots:
dependencies:
entities: 6.0.1
parse5@8.0.0:
dependencies:
entities: 6.0.1
parseurl@1.3.3: {}
path-data-parser@0.1.0: {}
@@ -8301,6 +8655,14 @@ snapshots:
exsolve: 1.0.7
pathe: 2.0.3
playwright-core@1.58.2: {}
playwright@1.58.2:
dependencies:
playwright-core: 1.58.2
optionalDependencies:
fsevents: 2.3.2
pnpm@10.18.0: {}
points-on-curve@0.2.0: {}
@@ -8342,6 +8704,8 @@ snapshots:
proxy-from-env@1.1.0: {}
punycode@2.3.1: {}
qs@6.13.0:
dependencies:
side-channel: 1.1.0
@@ -8538,6 +8902,8 @@ snapshots:
mdast-util-to-markdown: 2.1.2
unified: 11.0.5
require-from-string@2.0.2: {}
resolve-pkg-maps@1.0.0: {}
robust-predicates@3.0.2: {}
@@ -8583,6 +8949,10 @@ snapshots:
safer-buffer@2.1.2: {}
saxes@6.0.0:
dependencies:
xmlchars: 2.2.0
scheduler@0.27.0: {}
semver@6.3.1: {}
@@ -8724,6 +9094,8 @@ snapshots:
dependencies:
copy-anything: 3.0.5
symbol-tree@3.2.4: {}
tailwind-merge@3.3.1: {}
tailwindcss-animate@1.0.7(tailwindcss@4.1.14):
@@ -8761,8 +9133,22 @@ snapshots:
tinyspy@3.0.2: {}
tldts-core@7.0.25: {}
tldts@7.0.25:
dependencies:
tldts-core: 7.0.25
toidentifier@1.0.1: {}
tough-cookie@6.0.1:
dependencies:
tldts: 7.0.25
tr46@6.0.0:
dependencies:
punycode: 2.3.1
trim-lines@3.0.1: {}
trough@2.2.0: {}
@@ -8791,6 +9177,8 @@ snapshots:
undici-types@7.14.0: {}
undici@7.24.2: {}
unified@11.0.5:
dependencies:
'@types/unist': 3.0.3
@@ -8963,7 +9351,7 @@ snapshots:
lightningcss: 1.30.1
tsx: 4.20.6
vitest@2.1.9(@types/node@24.7.0)(lightningcss@1.30.1):
vitest@2.1.9(@types/node@24.7.0)(jsdom@28.1.0)(lightningcss@1.30.1):
dependencies:
'@vitest/expect': 2.1.9
'@vitest/mocker': 2.1.9(vite@5.4.20(@types/node@24.7.0)(lightningcss@1.30.1))
@@ -8987,6 +9375,7 @@ snapshots:
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 24.7.0
jsdom: 28.1.0
transitivePeerDependencies:
- less
- lightningcss
@@ -9015,8 +9404,24 @@ snapshots:
vscode-uri@3.0.8: {}
w3c-xmlserializer@5.0.0:
dependencies:
xml-name-validator: 5.0.0
web-namespaces@2.0.1: {}
webidl-conversions@8.0.1: {}
whatwg-mimetype@5.0.0: {}
whatwg-url@16.0.1:
dependencies:
'@exodus/bytes': 1.15.0
tr46: 6.0.0
webidl-conversions: 8.0.1
transitivePeerDependencies:
- '@noble/hashes'
why-is-node-running@2.3.0:
dependencies:
siginfo: 2.0.0
@@ -9029,6 +9434,10 @@ snapshots:
regexparam: 3.0.0
use-sync-external-store: 1.6.0(react@19.2.1)
xml-name-validator@5.0.0: {}
xmlchars@2.2.0: {}
yallist@3.1.1: {}
yallist@5.0.0: {}

28
scripts/llm-smoke.ts 普通文件
查看文件

@@ -0,0 +1,28 @@
import "dotenv/config";
import { invokeLLM } from "../server/_core/llm";
async function main() {
const prompt = process.argv.slice(2).join(" ").trim() || "你好,做个自我介绍";
const result = await invokeLLM({
messages: [{ role: "user", content: prompt }],
});
const firstChoice = result.choices[0];
const content = firstChoice?.message?.content;
console.log(`model=${result.model}`);
console.log(`finish_reason=${firstChoice?.finish_reason ?? "unknown"}`);
if (typeof content === "string") {
console.log(content);
return;
}
console.log(JSON.stringify(content, null, 2));
}
main().catch(error => {
console.error("[LLM smoke test] failed");
console.error(error);
process.exit(1);
});

查看文件

@@ -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,
};
}

查看文件

@@ -1,10 +1,63 @@
const parseInteger = (value: string | undefined, fallback: number) => {
if (!value) return fallback;
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : fallback;
};
const parseBoolean = (value: string | undefined, fallback: boolean) => {
if (value == null || value === "") return fallback;
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 ?? "",
localStorageDir: process.env.LOCAL_STORAGE_DIR ?? "./data/storage",
llmApiUrl:
process.env.LLM_API_URL ??
(process.env.BUILT_IN_FORGE_API_URL
? `${process.env.BUILT_IN_FORGE_API_URL.replace(/\/$/, "")}/v1/chat/completions`
: ""),
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");
}

查看文件

@@ -2,11 +2,67 @@ import "dotenv/config";
import express from "express";
import { createServer } from "http";
import net from "net";
import path from "node:path";
import { createExpressMiddleware } from "@trpc/server/adapters/express";
import { registerOAuthRoutes } from "./oauth";
import { appRouter } from "../routers";
import { createContext } from "./context";
import { serveStatic, setupVite } from "./vite";
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 => {
@@ -30,9 +86,14 @@ async function findAvailablePort(startPort: number = 3000): Promise<number> {
async function startServer() {
const app = express();
const server = createServer(app);
registerMediaProxy(app);
// Configure body parser with larger size limit for file uploads
app.use(express.json({ limit: "50mb" }));
app.use(express.urlencoded({ limit: "50mb", extended: true }));
app.use(
"/uploads",
express.static(path.resolve(process.env.LOCAL_STORAGE_DIR || "data/storage"))
);
// OAuth callback under /api/oauth/callback
registerOAuthRoutes(app);
// tRPC API
@@ -45,13 +106,15 @@ async function startServer() {
);
// development mode uses Vite, production mode uses static files
if (process.env.NODE_ENV === "development") {
const { setupVite } = await import("./vite");
await setupVite(app, server);
} else {
serveStatic(app);
}
const preferredPort = parseInt(process.env.PORT || "3000");
const port = await findAvailablePort(preferredPort);
const strictPort = process.env.STRICT_PORT === "1";
const port = strictPort ? preferredPort : await findAvailablePort(preferredPort);
if (port !== preferredPort) {
console.log(`Port ${preferredPort} is busy, using port ${port} instead`);
@@ -59,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);

129
server/_core/llm.test.ts 普通文件
查看文件

@@ -0,0 +1,129 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const ORIGINAL_ENV = { ...process.env };
const mockSuccessResponse = {
id: "chatcmpl-test",
created: 1,
model: "qwen3.5-plus",
choices: [
{
index: 0,
message: {
role: "assistant",
content: "你好,我是测试响应。",
},
finish_reason: "stop",
},
],
};
describe("invokeLLM", () => {
beforeEach(() => {
vi.resetModules();
vi.restoreAllMocks();
process.env = { ...ORIGINAL_ENV };
});
afterEach(() => {
process.env = { ...ORIGINAL_ENV };
vi.unstubAllGlobals();
});
it("uses LLM_* environment variables for request config", 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";
process.env.LLM_MAX_TOKENS = "4096";
process.env.LLM_ENABLE_THINKING = "0";
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => mockSuccessResponse,
});
vi.stubGlobal("fetch", fetchMock);
const { invokeLLM } = await import("./llm");
await invokeLLM({
messages: [{ role: "user", content: "你好" }],
});
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
"https://one.hao.work/v1/chat/completions",
expect.objectContaining({
method: "POST",
headers: expect.objectContaining({
authorization: "Bearer test-key",
}),
})
);
const [, request] = fetchMock.mock.calls[0] as [string, { body: string }];
expect(JSON.parse(request.body)).toMatchObject({
model: "qwen3.5-plus",
max_tokens: 4096,
messages: [{ role: "user", content: "你好" }],
});
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;
delete process.env.LLM_MODEL;
delete process.env.LLM_MAX_TOKENS;
delete process.env.LLM_ENABLE_THINKING;
delete process.env.LLM_THINKING_BUDGET;
process.env.BUILT_IN_FORGE_API_URL = "https://forge.example.com";
process.env.BUILT_IN_FORGE_API_KEY = "legacy-key";
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => mockSuccessResponse,
});
vi.stubGlobal("fetch", fetchMock);
const { invokeLLM } = await import("./llm");
await invokeLLM({
messages: [{ role: "user", content: "legacy" }],
});
expect(fetchMock).toHaveBeenCalledWith(
"https://forge.example.com/v1/chat/completions",
expect.objectContaining({
headers: expect.objectContaining({
authorization: "Bearer legacy-key",
}),
})
);
const [, request] = fetchMock.mock.calls[0] as [string, { body: string }];
expect(JSON.parse(request.body)).toMatchObject({
model: "gemini-2.5-flash",
});
});
});

查看文件

@@ -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,14 +213,16 @@ const normalizeToolChoice = (
return toolChoice;
};
const resolveApiUrl = () =>
ENV.forgeApiUrl && ENV.forgeApiUrl.trim().length > 0
? `${ENV.forgeApiUrl.replace(/\/$/, "")}/v1/chat/completions`
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.forgeApiKey) {
throw new Error("OPENAI_API_KEY is not configured");
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: "gemini-2.5-flash",
model: model || ENV.llmModel,
messages: messages.map(normalizeMessage),
};
@@ -296,9 +305,12 @@ export async function invokeLLM(params: InvokeParams): Promise<InvokeResult> {
payload.tool_choice = normalizedToolChoice;
}
payload.max_tokens = 32768
payload.thinking = {
"budget_tokens": 128
payload.max_tokens = ENV.llmMaxTokens;
if (ENV.llmEnableThinking && ENV.llmThinkingBudget > 0) {
payload.thinking = {
budget_tokens: ENV.llmThinkingBudget,
};
}
const normalizedResponseFormat = normalizeResponseFormat({
@@ -312,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.forgeApiKey}`,
authorization: `Bearer ${apiKey || ENV.llmApiKey}`,
},
body: JSON.stringify(payload),
}, {
timeoutMs: ENV.llmTimeoutMs,
retries: ENV.llmRetryCount,
retryMethods: ["POST"],
});
if (!response.ok) {

56
server/_core/mediaProxy.ts 普通文件
查看文件

@@ -0,0 +1,56 @@
import type { Express, RequestHandler } from "express";
import http from "node:http";
import https from "node:https";
function createMediaProxy(targetUrl: string): RequestHandler {
const target = new URL(targetUrl);
const transport = target.protocol === "https:" ? https : http;
return (req, res) => {
const upstreamUrl = new URL(req.originalUrl, target);
const proxyRequest = transport.request(
upstreamUrl,
{
method: req.method,
headers: {
...req.headers,
host: target.host,
connection: "keep-alive",
},
},
(proxyResponse) => {
if (proxyResponse.statusCode) {
res.status(proxyResponse.statusCode);
}
Object.entries(proxyResponse.headers).forEach(([key, value]) => {
if (value !== undefined) {
res.setHeader(key, value);
}
});
proxyResponse.pipe(res);
}
);
proxyRequest.on("error", (error) => {
if (!res.headersSent) {
res.status(502).json({
error: "media_service_unavailable",
message: error.message,
});
} else {
res.end();
}
});
req.pipe(proxyRequest);
};
}
export function registerMediaProxy(app: Express) {
const mediaServiceUrl = process.env.MEDIA_SERVICE_URL;
if (!mediaServiceUrl) {
return;
}
app.use("/media", createMediaProxy(mediaServiceUrl));
}

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,
};
}
}

29
server/_core/static.ts 普通文件
查看文件

@@ -0,0 +1,29 @@
import express, { type Express } from "express";
import fs from "fs";
import path from "path";
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");
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, { 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;
}
res.sendFile(path.resolve(distPath, "index.html"));
});
}

查看文件

@@ -46,22 +46,3 @@ export async function setupVite(app: Express, server: Server) {
}
});
}
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");
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));
// fall through to index.html if the file doesn't exist
app.use("*", (_req, res) => {
res.sendFile(path.resolve(distPath, "index.html"));
});
}

文件差异内容过多而无法显示 加载差异

查看文件

@@ -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", () => {
@@ -260,6 +410,37 @@ describe("video.list", () => {
});
});
describe("video.registerExternal input validation", () => {
it("requires authentication", async () => {
const { ctx } = createMockContext(null);
const caller = appRouter.createCaller(ctx);
await expect(
caller.video.registerExternal({
title: "session",
url: "/media/assets/sessions/demo/recording.webm",
fileKey: "media/sessions/demo/recording.webm",
format: "webm",
})
).rejects.toThrow();
});
it("rejects missing url", async () => {
const user = createTestUser();
const { ctx } = createMockContext(user);
const caller = appRouter.createCaller(ctx);
await expect(
caller.video.registerExternal({
title: "session",
url: "",
fileKey: "media/sessions/demo/recording.webm",
format: "webm",
})
).rejects.toThrow();
});
});
describe("video.get input validation", () => {
it("requires authentication", async () => {
const { ctx } = createMockContext(null);
@@ -268,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", () => {
@@ -592,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", () => {
@@ -621,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);
@@ -630,6 +968,7 @@ describe("tutorial.updateProgress input validation", () => {
await caller.tutorial.updateProgress({
tutorialId: 1,
watched: 1,
completed: 1,
selfScore: 4,
notes: "Great tutorial",
});
@@ -773,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");
}

某些文件未显示,因为此 diff 中更改的文件太多 显示更多