比较提交
55 次代码提交
27083d5af9
...
main
| 作者 | SHA1 | 提交日期 | |
|---|---|---|---|
|
|
634a4704c7 | ||
|
|
bb46d26c0e | ||
|
|
bacd712dbc | ||
|
|
78a7c755e3 | ||
|
|
a211562860 | ||
|
|
09b1b95e2c | ||
|
|
922a9fb63f | ||
|
|
31bead3452 | ||
|
|
a5103685fb | ||
|
|
f9db6ef590 | ||
|
|
13e59b8e8a | ||
|
|
2b72ef9200 | ||
|
|
09cd5b4d85 | ||
|
|
7aba508247 | ||
|
|
cf06de944f | ||
|
|
4e4122d758 | ||
|
|
f0bbe4c82f | ||
|
|
4fb2d092d7 | ||
|
|
e3fe9a8e7b | ||
|
|
fe5e539a47 | ||
|
|
139dc61b61 | ||
|
|
264d49475b | ||
|
|
5c2dcf23ba | ||
|
|
3763f5b515 | ||
|
|
1ce94f6f57 | ||
|
|
669497e625 | ||
|
|
71caf0de19 | ||
|
|
67b27e3551 | ||
|
|
a9ea94fb78 | ||
|
|
c4ec397ed3 | ||
|
|
bd8998166b | ||
|
|
143c60a054 | ||
|
|
bee24d547d | ||
|
|
a1689ee95e | ||
|
|
cb643ac154 | ||
|
|
585fd5773d | ||
|
|
e43b969d28 | ||
|
|
afb013193d | ||
|
|
ae93269c62 | ||
|
|
f4f425de42 | ||
|
|
815f96d4e8 | ||
|
|
edc66ea5bc | ||
|
|
d1b6603061 | ||
|
|
ad83ce9c68 | ||
|
|
20e183d2da | ||
|
|
1cc863e60e | ||
|
|
6943754838 | ||
|
|
bc01a40564 | ||
|
|
bcdd790d91 | ||
|
|
8d3faecb15 | ||
|
|
8df0f91db7 | ||
|
|
f5ad0449a8 | ||
|
|
ba35e50528 | ||
|
|
914f015c30 | ||
|
|
d5431aee0e |
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>
|
||||
</>
|
||||
);
|
||||
|
||||
184
client/src/components/TaskCenter.tsx
普通文件
@@ -0,0 +1,184 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { formatDateTimeShanghai } from "@/lib/time";
|
||||
import { toast } from "sonner";
|
||||
import { AlertTriangle, BellRing, CheckCircle2, Loader2, RefreshCcw } from "lucide-react";
|
||||
|
||||
function formatTaskStatus(status: string) {
|
||||
switch (status) {
|
||||
case "running":
|
||||
return "执行中";
|
||||
case "succeeded":
|
||||
return "已完成";
|
||||
case "failed":
|
||||
return "失败";
|
||||
default:
|
||||
return "排队中";
|
||||
}
|
||||
}
|
||||
|
||||
function formatTaskTiming(task: {
|
||||
createdAt: string | Date;
|
||||
startedAt?: string | Date | null;
|
||||
completedAt?: string | Date | null;
|
||||
}) {
|
||||
const createdAt = new Date(task.createdAt).getTime();
|
||||
const startedAt = task.startedAt ? new Date(task.startedAt).getTime() : null;
|
||||
const completedAt = task.completedAt ? new Date(task.completedAt).getTime() : null;
|
||||
const durationMs = (completedAt ?? Date.now()) - (startedAt ?? createdAt);
|
||||
const seconds = Math.max(0, Math.round(durationMs / 1000));
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const rest = seconds % 60;
|
||||
return `${minutes}m ${rest.toString().padStart(2, "0")}s`;
|
||||
}
|
||||
|
||||
export function TaskCenter({ compact = false }: { compact?: boolean }) {
|
||||
const utils = trpc.useUtils();
|
||||
const retryMutation = trpc.task.retry.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.task.list.invalidate();
|
||||
toast.success("任务已重新排队");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`任务重试失败: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const taskListQuery = trpc.task.list.useQuery(
|
||||
{ limit: 20 },
|
||||
{
|
||||
retry: 3,
|
||||
retryDelay: (attempt) => Math.min(1_000 * 2 ** attempt, 8_000),
|
||||
placeholderData: (previous) => previous,
|
||||
refetchInterval: (query) => {
|
||||
const hasActiveTask = (query.state.data ?? []).some((task) => task.status === "queued" || task.status === "running");
|
||||
return hasActiveTask ? 3_000 : 8_000;
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const previousStatusesRef = useRef<Record<string, string>>({});
|
||||
useEffect(() => {
|
||||
for (const task of taskListQuery.data ?? []) {
|
||||
const previous = previousStatusesRef.current[task.id];
|
||||
if (previous && previous !== task.status) {
|
||||
if (task.status === "succeeded") {
|
||||
toast.success(`${task.title} 已完成`);
|
||||
if (task.type === "training_plan_generate" || task.type === "training_plan_adjust") {
|
||||
utils.plan.active.invalidate();
|
||||
utils.plan.list.invalidate();
|
||||
}
|
||||
if (task.type === "media_finalize") {
|
||||
utils.video.list.invalidate();
|
||||
}
|
||||
}
|
||||
if (task.status === "failed") {
|
||||
toast.error(`${task.title} 失败${task.error ? `: ${task.error}` : ""}`);
|
||||
}
|
||||
}
|
||||
previousStatusesRef.current[task.id] = task.status;
|
||||
}
|
||||
}, [taskListQuery.data, utils.plan.active, utils.plan.list, utils.video.list]);
|
||||
|
||||
const activeCount = useMemo(
|
||||
() => (taskListQuery.data ?? []).filter((task) => task.status === "queued" || task.status === "running").length,
|
||||
[taskListQuery.data]
|
||||
);
|
||||
|
||||
return (
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant={compact ? "ghost" : "outline"} size="sm" className="gap-2">
|
||||
<BellRing className="h-4 w-4" />
|
||||
<span>{compact ? "任务" : "任务中心"}</span>
|
||||
{activeCount > 0 ? <Badge variant="secondary">{activeCount}</Badge> : null}
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right" className="w-[380px] sm:w-[420px]">
|
||||
<SheetHeader>
|
||||
<SheetTitle>任务中心</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="h-[calc(100vh-6rem)] pr-4">
|
||||
<div className="mt-6 space-y-3">
|
||||
{taskListQuery.isError ? (
|
||||
<Alert>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
任务列表刷新失败,当前显示最近一次成功结果。
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{(taskListQuery.data ?? []).length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed p-4 text-sm text-muted-foreground">
|
||||
当前没有后台任务。
|
||||
</div>
|
||||
) : (
|
||||
(taskListQuery.data ?? []).map((task) => (
|
||||
<div key={task.id} className="rounded-2xl border p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium leading-5">{task.title}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{task.message || formatTaskStatus(task.status)}</p>
|
||||
</div>
|
||||
<Badge variant={task.status === "failed" ? "destructive" : "secondary"}>
|
||||
{formatTaskStatus(task.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<Progress value={task.progress} className="h-2" />
|
||||
</div>
|
||||
|
||||
{task.error ? (
|
||||
<div className="mt-3 rounded-xl bg-red-50 px-3 py-2 text-xs text-red-700">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
<span>{task.error}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-3 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
{formatDateTimeShanghai(task.createdAt)} · 耗时 {formatTaskTiming(task)}
|
||||
</span>
|
||||
{task.status === "failed" ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 gap-1 px-2"
|
||||
onClick={() => retryMutation.mutate({ taskId: task.id })}
|
||||
disabled={retryMutation.isPending}
|
||||
>
|
||||
{retryMutation.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCcw className="h-3.5 w-3.5" />}
|
||||
重试
|
||||
</Button>
|
||||
) : task.status === "succeeded" ? (
|
||||
<span className="inline-flex items-center gap-1 text-emerald-600">
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
已完成
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-primary">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
处理中
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
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 */
|
||||
|
||||
242
client/src/lib/actionRecognition.ts
普通文件
@@ -0,0 +1,242 @@
|
||||
export type ActionType =
|
||||
| "forehand"
|
||||
| "backhand"
|
||||
| "serve"
|
||||
| "volley"
|
||||
| "overhead"
|
||||
| "slice"
|
||||
| "lob"
|
||||
| "unknown";
|
||||
|
||||
export type Point = {
|
||||
x: number;
|
||||
y: number;
|
||||
visibility?: number;
|
||||
};
|
||||
|
||||
export type TrackingState = {
|
||||
prevTimestamp?: number;
|
||||
prevRightWrist?: Point;
|
||||
prevLeftWrist?: Point;
|
||||
prevHipCenter?: Point;
|
||||
};
|
||||
|
||||
export type ActionObservation = {
|
||||
action: ActionType;
|
||||
confidence: number;
|
||||
};
|
||||
|
||||
export type ActionFrame = {
|
||||
action: ActionType;
|
||||
confidence: number;
|
||||
};
|
||||
|
||||
export const ACTION_LABELS: Record<ActionType, string> = {
|
||||
forehand: "正手挥拍",
|
||||
backhand: "反手挥拍",
|
||||
serve: "发球",
|
||||
volley: "截击",
|
||||
overhead: "高压",
|
||||
slice: "切削",
|
||||
lob: "挑高球",
|
||||
unknown: "未知动作",
|
||||
};
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function distance(a?: Point, b?: Point) {
|
||||
if (!a || !b) return 0;
|
||||
const dx = a.x - b.x;
|
||||
const dy = a.y - b.y;
|
||||
return Math.sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
|
||||
function getAngle(a?: Point, b?: Point, c?: Point) {
|
||||
if (!a || !b || !c) return 0;
|
||||
const radians = Math.atan2(c.y - b.y, c.x - b.x) - Math.atan2(a.y - b.y, a.x - b.x);
|
||||
let angle = Math.abs((radians * 180) / Math.PI);
|
||||
if (angle > 180) angle = 360 - angle;
|
||||
return angle;
|
||||
}
|
||||
|
||||
export function recognizeActionFrame(landmarks: Point[], tracking: TrackingState, timestamp: number): ActionFrame {
|
||||
const nose = landmarks[0];
|
||||
const leftShoulder = landmarks[11];
|
||||
const rightShoulder = landmarks[12];
|
||||
const leftElbow = landmarks[13];
|
||||
const rightElbow = landmarks[14];
|
||||
const leftWrist = landmarks[15];
|
||||
const rightWrist = landmarks[16];
|
||||
const leftHip = landmarks[23];
|
||||
const rightHip = landmarks[24];
|
||||
const leftKnee = landmarks[25];
|
||||
const rightKnee = landmarks[26];
|
||||
const leftAnkle = landmarks[27];
|
||||
const rightAnkle = landmarks[28];
|
||||
|
||||
const hipCenter = {
|
||||
x: ((leftHip?.x ?? 0.5) + (rightHip?.x ?? 0.5)) / 2,
|
||||
y: ((leftHip?.y ?? 0.7) + (rightHip?.y ?? 0.7)) / 2,
|
||||
};
|
||||
|
||||
const dtMs = tracking.prevTimestamp ? Math.max(16, timestamp - tracking.prevTimestamp) : 33;
|
||||
const rightSpeed = distance(rightWrist, tracking.prevRightWrist) * (1000 / dtMs);
|
||||
const leftSpeed = distance(leftWrist, tracking.prevLeftWrist) * (1000 / dtMs);
|
||||
const hipSpeed = distance(hipCenter, tracking.prevHipCenter) * (1000 / dtMs);
|
||||
const rightVerticalMotion = tracking.prevRightWrist ? tracking.prevRightWrist.y - (rightWrist?.y ?? tracking.prevRightWrist.y) : 0;
|
||||
|
||||
const shoulderTilt = Math.abs((leftShoulder?.y ?? 0.3) - (rightShoulder?.y ?? 0.3));
|
||||
const hipTilt = Math.abs((leftHip?.y ?? 0.55) - (rightHip?.y ?? 0.55));
|
||||
const headOffset = Math.abs((nose?.x ?? 0.5) - (((leftShoulder?.x ?? 0.45) + (rightShoulder?.x ?? 0.55)) / 2));
|
||||
const kneeBend = ((getAngle(leftHip, leftKnee, leftAnkle) || 165) + (getAngle(rightHip, rightKnee, rightAnkle) || 165)) / 2;
|
||||
const rightElbowAngle = getAngle(rightShoulder, rightElbow, rightWrist) || 145;
|
||||
const leftElbowAngle = getAngle(leftShoulder, leftElbow, leftWrist) || 145;
|
||||
const footSpread = Math.abs((leftAnkle?.x ?? 0.42) - (rightAnkle?.x ?? 0.58));
|
||||
const shoulderSpan = Math.abs((rightShoulder?.x ?? 0.56) - (leftShoulder?.x ?? 0.44));
|
||||
const wristSpread = Math.abs((rightWrist?.x ?? 0.62) - (leftWrist?.x ?? 0.38));
|
||||
const shoulderCenterX = ((leftShoulder?.x ?? 0.45) + (rightShoulder?.x ?? 0.55)) / 2;
|
||||
const torsoOffset = Math.abs(shoulderCenterX - hipCenter.x);
|
||||
const rightForward = (rightWrist?.x ?? shoulderCenterX) - hipCenter.x;
|
||||
const leftForward = hipCenter.x - (leftWrist?.x ?? shoulderCenterX);
|
||||
const contactHeight = hipCenter.y - (rightWrist?.y ?? hipCenter.y);
|
||||
const landmarkVisibility = landmarks
|
||||
.filter((item) => typeof item?.visibility === "number")
|
||||
.map((item) => item.visibility as number);
|
||||
const averageVisibility = landmarkVisibility.length > 0
|
||||
? landmarkVisibility.reduce((sum, item) => sum + item, 0) / landmarkVisibility.length
|
||||
: 0.8;
|
||||
|
||||
tracking.prevTimestamp = timestamp;
|
||||
tracking.prevRightWrist = rightWrist;
|
||||
tracking.prevLeftWrist = leftWrist;
|
||||
tracking.prevHipCenter = hipCenter;
|
||||
|
||||
if (averageVisibility < 0.58 || shoulderSpan < 0.08 || footSpread < 0.05 || headOffset > 0.26) {
|
||||
return { action: "unknown", confidence: 0.28 };
|
||||
}
|
||||
|
||||
const serveConfidence = clamp(
|
||||
rightVerticalMotion * 2.2 +
|
||||
Math.max(0, (hipCenter.y - (rightWrist?.y ?? hipCenter.y)) * 3.4) +
|
||||
(rightWrist?.y ?? 1) < (nose?.y ?? 0.3) ? 0.34 : 0 +
|
||||
rightElbowAngle > 145 ? 0.12 : 0 -
|
||||
shoulderTilt * 1.8,
|
||||
0,
|
||||
1,
|
||||
);
|
||||
|
||||
const overheadConfidence = clamp(
|
||||
serveConfidence * 0.62 +
|
||||
((rightWrist?.y ?? 1) < (nose?.y ?? 0.3) ? 0.22 : 0) +
|
||||
(rightSpeed > 0.34 ? 0.16 : 0) -
|
||||
(kneeBend < 150 ? 0.08 : 0),
|
||||
0,
|
||||
1,
|
||||
);
|
||||
|
||||
const forehandConfidence = clamp(
|
||||
(rightSpeed * 1.5) +
|
||||
Math.max(0, rightForward * 2.3) +
|
||||
(rightElbowAngle > 120 ? 0.1 : 0) +
|
||||
(hipSpeed > 0.07 ? 0.08 : 0) +
|
||||
(footSpread > 0.12 ? 0.05 : 0) -
|
||||
shoulderTilt * 1.1,
|
||||
0,
|
||||
1,
|
||||
);
|
||||
|
||||
const backhandConfidence = clamp(
|
||||
(leftSpeed * 1.45) +
|
||||
Math.max(0, leftForward * 2.15) +
|
||||
(leftElbowAngle > 118 ? 0.1 : 0) +
|
||||
(wristSpread > shoulderSpan * 1.2 ? 0.08 : 0) +
|
||||
(torsoOffset > 0.04 ? 0.06 : 0),
|
||||
0,
|
||||
1,
|
||||
);
|
||||
|
||||
const volleyConfidence = clamp(
|
||||
((rightSpeed + leftSpeed) * 0.8) +
|
||||
(footSpread < 0.12 ? 0.12 : 0) +
|
||||
(kneeBend < 155 ? 0.12 : 0) +
|
||||
(Math.abs(contactHeight) < 0.16 ? 0.1 : 0) +
|
||||
(hipSpeed > 0.08 ? 0.08 : 0),
|
||||
0,
|
||||
1,
|
||||
);
|
||||
|
||||
const sliceConfidence = clamp(
|
||||
forehandConfidence * 0.68 +
|
||||
((rightWrist?.y ?? 0.5) > hipCenter.y ? 0.12 : 0) +
|
||||
(contactHeight < 0.05 ? 0.1 : 0),
|
||||
0,
|
||||
1,
|
||||
);
|
||||
|
||||
const lobConfidence = clamp(
|
||||
overheadConfidence * 0.55 +
|
||||
((rightWrist?.y ?? 1) < (leftShoulder?.y ?? 0.3) ? 0.14 : 0) +
|
||||
(hipSpeed < 0.08 ? 0.06 : 0),
|
||||
0,
|
||||
1,
|
||||
);
|
||||
|
||||
const candidates = ([
|
||||
["serve", serveConfidence],
|
||||
["overhead", overheadConfidence],
|
||||
["forehand", forehandConfidence],
|
||||
["backhand", backhandConfidence],
|
||||
["volley", volleyConfidence],
|
||||
["slice", sliceConfidence],
|
||||
["lob", lobConfidence],
|
||||
] as Array<[ActionType, number]>).sort((left, right) => right[1] - left[1]);
|
||||
|
||||
const [action, confidence] = candidates[0] || ["unknown", 0];
|
||||
if (confidence < 0.45) {
|
||||
return { action: "unknown", confidence: clamp(confidence, 0.18, 0.42) };
|
||||
}
|
||||
|
||||
return { action, confidence: clamp(confidence, 0, 1) };
|
||||
}
|
||||
|
||||
export function stabilizeActionFrame(frame: ActionFrame, history: ActionObservation[]) {
|
||||
const nextHistory = [...history, { action: frame.action, confidence: frame.confidence }].slice(-6);
|
||||
history.splice(0, history.length, ...nextHistory);
|
||||
|
||||
const weights = nextHistory.map((_, index) => index + 1);
|
||||
const scores = nextHistory.reduce<Record<ActionType, number>>((acc, sample, index) => {
|
||||
acc[sample.action] = (acc[sample.action] || 0) + sample.confidence * weights[index];
|
||||
return acc;
|
||||
}, {
|
||||
forehand: 0,
|
||||
backhand: 0,
|
||||
serve: 0,
|
||||
volley: 0,
|
||||
overhead: 0,
|
||||
slice: 0,
|
||||
lob: 0,
|
||||
unknown: 0,
|
||||
});
|
||||
|
||||
const ranked = Object.entries(scores).sort((a, b) => b[1] - a[1]) as Array<[ActionType, number]>;
|
||||
const [winner = "unknown", winnerScore = 0] = ranked[0] || [];
|
||||
const [, runnerScore = 0] = ranked[1] || [];
|
||||
const winnerSamples = nextHistory.filter((sample) => sample.action === winner);
|
||||
const averageConfidence = winnerSamples.length > 0
|
||||
? winnerSamples.reduce((sum, sample) => sum + sample.confidence, 0) / winnerSamples.length
|
||||
: frame.confidence;
|
||||
|
||||
const stableAction =
|
||||
winner === "unknown" && frame.action !== "unknown" && frame.confidence >= 0.52
|
||||
? frame.action
|
||||
: winnerScore - runnerScore < 0.2 && frame.confidence >= 0.65
|
||||
? frame.action
|
||||
: winner;
|
||||
|
||||
return {
|
||||
action: stableAction,
|
||||
confidence: clamp(stableAction === frame.action ? Math.max(frame.confidence, averageConfidence) : averageConfidence, 0, 1),
|
||||
};
|
||||
}
|
||||
74
client/src/lib/camera.test.ts
普通文件
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyTrackZoom,
|
||||
getCameraVideoConstraints,
|
||||
getLiveAnalysisBitrate,
|
||||
readTrackZoomState,
|
||||
} from "./camera";
|
||||
|
||||
describe("camera utilities", () => {
|
||||
it("builds economy constraints for mobile capture", () => {
|
||||
expect(getCameraVideoConstraints("environment", true, "economy")).toEqual({
|
||||
facingMode: "environment",
|
||||
width: { ideal: 960 },
|
||||
height: { ideal: 540 },
|
||||
frameRate: { ideal: 24, max: 24 },
|
||||
});
|
||||
});
|
||||
|
||||
it("builds clarity constraints for desktop capture", () => {
|
||||
expect(getCameraVideoConstraints("user", false, "clarity")).toEqual({
|
||||
facingMode: "user",
|
||||
width: { ideal: 1920 },
|
||||
height: { ideal: 1080 },
|
||||
frameRate: { ideal: 30, max: 30 },
|
||||
});
|
||||
});
|
||||
|
||||
it("selects live analysis bitrates by preset", () => {
|
||||
expect(getLiveAnalysisBitrate("economy", true)).toBe(900_000);
|
||||
expect(getLiveAnalysisBitrate("balanced", false)).toBe(1_900_000);
|
||||
expect(getLiveAnalysisBitrate("clarity", false)).toBe(2_500_000);
|
||||
});
|
||||
|
||||
it("reads zoom capability from the active video track", () => {
|
||||
const track = {
|
||||
getCapabilities: () => ({
|
||||
zoom: { min: 1, max: 4, step: 0.5 },
|
||||
focusMode: ["continuous", "manual"],
|
||||
}),
|
||||
getSettings: () => ({
|
||||
zoom: 2,
|
||||
focusMode: "continuous",
|
||||
}),
|
||||
} as unknown as MediaStreamTrack;
|
||||
|
||||
expect(readTrackZoomState(track)).toEqual({
|
||||
supported: true,
|
||||
min: 1,
|
||||
max: 4,
|
||||
step: 0.5,
|
||||
current: 2,
|
||||
focusMode: "continuous",
|
||||
});
|
||||
});
|
||||
|
||||
it("applies zoom using media track constraints", async () => {
|
||||
let currentZoom = 1;
|
||||
const track = {
|
||||
getCapabilities: () => ({
|
||||
zoom: { min: 1, max: 3, step: 0.25 },
|
||||
}),
|
||||
getSettings: () => ({
|
||||
zoom: currentZoom,
|
||||
}),
|
||||
applyConstraints: async (constraints: MediaTrackConstraints & { advanced?: Array<{ zoom?: number }> }) => {
|
||||
currentZoom = constraints.advanced?.[0]?.zoom ?? (constraints as { zoom?: number }).zoom ?? currentZoom;
|
||||
},
|
||||
} as unknown as MediaStreamTrack;
|
||||
|
||||
const result = await applyTrackZoom(track, 2.5);
|
||||
|
||||
expect(result.current).toBe(2.5);
|
||||
});
|
||||
});
|
||||
250
client/src/lib/camera.ts
普通文件
@@ -0,0 +1,250 @@
|
||||
export type CameraQualityPreset = "economy" | "balanced" | "clarity";
|
||||
|
||||
export type CameraZoomState = {
|
||||
supported: boolean;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
current: number;
|
||||
focusMode: string;
|
||||
};
|
||||
|
||||
export type CameraRequestResult = {
|
||||
stream: MediaStream;
|
||||
appliedFacingMode: "user" | "environment";
|
||||
audioEnabled: boolean;
|
||||
usedFallback: boolean;
|
||||
};
|
||||
|
||||
type NumericRange = {
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
};
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function parseNumericRange(value: unknown): NumericRange | null {
|
||||
if (!value || typeof value !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidate = value as { min?: unknown; max?: unknown; step?: unknown };
|
||||
if (typeof candidate.min !== "number" || typeof candidate.max !== "number") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
min: candidate.min,
|
||||
max: candidate.max,
|
||||
step: typeof candidate.step === "number" && candidate.step > 0 ? candidate.step : 0.1,
|
||||
};
|
||||
}
|
||||
|
||||
export function getCameraVideoConstraints(
|
||||
facingMode: "user" | "environment",
|
||||
isMobile: boolean,
|
||||
preset: CameraQualityPreset,
|
||||
): MediaTrackConstraints {
|
||||
switch (preset) {
|
||||
case "economy":
|
||||
return {
|
||||
facingMode,
|
||||
width: { ideal: isMobile ? 960 : 1280 },
|
||||
height: { ideal: isMobile ? 540 : 720 },
|
||||
frameRate: { ideal: 24, max: 24 },
|
||||
};
|
||||
case "clarity":
|
||||
return {
|
||||
facingMode,
|
||||
width: { ideal: isMobile ? 1280 : 1920 },
|
||||
height: { ideal: isMobile ? 720 : 1080 },
|
||||
frameRate: { ideal: 30, max: 30 },
|
||||
};
|
||||
default:
|
||||
return {
|
||||
facingMode,
|
||||
width: { ideal: isMobile ? 1280 : 1600 },
|
||||
height: { ideal: isMobile ? 720 : 900 },
|
||||
frameRate: { ideal: 30, max: 30 },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeVideoConstraintCandidate(candidate: MediaTrackConstraints | true) {
|
||||
if (candidate === true) {
|
||||
return { label: "camera-any", video: true as const };
|
||||
}
|
||||
|
||||
return {
|
||||
label: JSON.stringify(candidate),
|
||||
video: candidate,
|
||||
};
|
||||
}
|
||||
|
||||
function createFallbackVideoCandidates(
|
||||
facingMode: "user" | "environment",
|
||||
isMobile: boolean,
|
||||
preset: CameraQualityPreset,
|
||||
) {
|
||||
const base = getCameraVideoConstraints(facingMode, isMobile, preset);
|
||||
const alternateFacing = facingMode === "environment" ? "user" : "environment";
|
||||
const lowRes = {
|
||||
facingMode,
|
||||
width: { ideal: isMobile ? 640 : 960 },
|
||||
height: { ideal: isMobile ? 360 : 540 },
|
||||
} satisfies MediaTrackConstraints;
|
||||
const lowResAlternate = {
|
||||
facingMode: alternateFacing,
|
||||
width: { ideal: isMobile ? 640 : 960 },
|
||||
height: { ideal: isMobile ? 360 : 540 },
|
||||
} satisfies MediaTrackConstraints;
|
||||
const anyCamera = {
|
||||
width: { ideal: isMobile ? 640 : 960 },
|
||||
height: { ideal: isMobile ? 360 : 540 },
|
||||
} satisfies MediaTrackConstraints;
|
||||
|
||||
const candidates = [
|
||||
normalizeVideoConstraintCandidate(base),
|
||||
normalizeVideoConstraintCandidate({
|
||||
...base,
|
||||
frameRate: undefined,
|
||||
}),
|
||||
normalizeVideoConstraintCandidate(lowRes),
|
||||
normalizeVideoConstraintCandidate(lowResAlternate),
|
||||
normalizeVideoConstraintCandidate(anyCamera),
|
||||
normalizeVideoConstraintCandidate(true),
|
||||
];
|
||||
|
||||
const deduped = new Map<string, { video: MediaTrackConstraints | true }>();
|
||||
candidates.forEach((candidate) => {
|
||||
if (!deduped.has(candidate.label)) {
|
||||
deduped.set(candidate.label, { video: candidate.video });
|
||||
}
|
||||
});
|
||||
return Array.from(deduped.values());
|
||||
}
|
||||
|
||||
export async function requestCameraStream(options: {
|
||||
facingMode: "user" | "environment";
|
||||
isMobile: boolean;
|
||||
preset: CameraQualityPreset;
|
||||
audio?: false | MediaTrackConstraints;
|
||||
}) {
|
||||
const videoCandidates = createFallbackVideoCandidates(options.facingMode, options.isMobile, options.preset);
|
||||
const audioCandidates = options.audio ? [options.audio, false] : [false];
|
||||
let lastError: unknown = null;
|
||||
|
||||
for (const audio of audioCandidates) {
|
||||
for (let index = 0; index < videoCandidates.length; index += 1) {
|
||||
const video = videoCandidates[index]?.video ?? true;
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ video, audio });
|
||||
const videoTrack = stream.getVideoTracks()[0] || null;
|
||||
const settings = (
|
||||
videoTrack && typeof (videoTrack as MediaStreamTrack & { getSettings?: () => unknown }).getSettings === "function"
|
||||
? (videoTrack as MediaStreamTrack & { getSettings: () => unknown }).getSettings()
|
||||
: {}
|
||||
) as Record<string, unknown>;
|
||||
const appliedFacingMode = settings.facingMode === "user" ? "user" : settings.facingMode === "environment" ? "environment" : options.facingMode;
|
||||
|
||||
return {
|
||||
stream,
|
||||
appliedFacingMode,
|
||||
audioEnabled: stream.getAudioTracks().length > 0,
|
||||
usedFallback: index > 0 || audio === false && Boolean(options.audio),
|
||||
} satisfies CameraRequestResult;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError instanceof Error ? lastError : new Error("无法访问摄像头");
|
||||
}
|
||||
|
||||
export function getLiveAnalysisBitrate(preset: CameraQualityPreset, isMobile: boolean) {
|
||||
switch (preset) {
|
||||
case "economy":
|
||||
return isMobile ? 900_000 : 1_100_000;
|
||||
case "clarity":
|
||||
return isMobile ? 1_900_000 : 2_500_000;
|
||||
default:
|
||||
return isMobile ? 1_300_000 : 1_900_000;
|
||||
}
|
||||
}
|
||||
|
||||
export function readTrackZoomState(track: MediaStreamTrack | null): CameraZoomState {
|
||||
if (!track) {
|
||||
return {
|
||||
supported: false,
|
||||
min: 1,
|
||||
max: 1,
|
||||
step: 0.1,
|
||||
current: 1,
|
||||
focusMode: "auto",
|
||||
};
|
||||
}
|
||||
|
||||
const capabilities = (
|
||||
typeof (track as MediaStreamTrack & { getCapabilities?: () => unknown }).getCapabilities === "function"
|
||||
? (track as MediaStreamTrack & { getCapabilities: () => unknown }).getCapabilities()
|
||||
: {}
|
||||
) as Record<string, unknown>;
|
||||
const settings = (
|
||||
typeof (track as MediaStreamTrack & { getSettings?: () => unknown }).getSettings === "function"
|
||||
? (track as MediaStreamTrack & { getSettings: () => unknown }).getSettings()
|
||||
: {}
|
||||
) as Record<string, unknown>;
|
||||
|
||||
const zoomRange = parseNumericRange(capabilities.zoom);
|
||||
const focusModes = Array.isArray(capabilities.focusMode)
|
||||
? capabilities.focusMode.filter((item: unknown): item is string => typeof item === "string")
|
||||
: [];
|
||||
const focusMode = typeof settings.focusMode === "string"
|
||||
? settings.focusMode
|
||||
: focusModes.includes("continuous")
|
||||
? "continuous"
|
||||
: focusModes[0] || "auto";
|
||||
|
||||
if (!zoomRange || zoomRange.max - zoomRange.min <= 0.001) {
|
||||
return {
|
||||
supported: false,
|
||||
min: 1,
|
||||
max: 1,
|
||||
step: 0.1,
|
||||
current: 1,
|
||||
focusMode,
|
||||
};
|
||||
}
|
||||
|
||||
const current = typeof settings.zoom === "number"
|
||||
? clamp(settings.zoom, zoomRange.min, zoomRange.max)
|
||||
: zoomRange.min;
|
||||
|
||||
return {
|
||||
supported: true,
|
||||
min: zoomRange.min,
|
||||
max: zoomRange.max,
|
||||
step: zoomRange.step,
|
||||
current,
|
||||
focusMode,
|
||||
};
|
||||
}
|
||||
|
||||
export async function applyTrackZoom(track: MediaStreamTrack | null, rawZoom: number) {
|
||||
const currentState = readTrackZoomState(track);
|
||||
if (!track || !currentState.supported) {
|
||||
return currentState;
|
||||
}
|
||||
|
||||
const zoom = clamp(rawZoom, currentState.min, currentState.max);
|
||||
try {
|
||||
await track.applyConstraints({ advanced: [{ zoom }] } as unknown as MediaTrackConstraints);
|
||||
} catch {
|
||||
await track.applyConstraints({ zoom } as unknown as MediaTrackConstraints);
|
||||
}
|
||||
return readTrackZoomState(track);
|
||||
}
|
||||
261
client/src/lib/changelog.ts
普通文件
@@ -0,0 +1,261 @@
|
||||
export type ChangeLogEntry = {
|
||||
version: string;
|
||||
releaseDate: string;
|
||||
repoVersion: string;
|
||||
summary: string;
|
||||
features: string[];
|
||||
tests: string[];
|
||||
};
|
||||
|
||||
export const CHANGE_LOG_ENTRIES: ChangeLogEntry[] = [
|
||||
{
|
||||
version: "2026.03.16-live-viewer-server-relay",
|
||||
releaseDate: "2026-03-16",
|
||||
repoVersion: "bb46d26",
|
||||
summary: "实时分析同步观看改为由 media 服务中转帧图,不再依赖浏览器之间的 P2P 视频连接。",
|
||||
features: [
|
||||
"owner 端现在会把带骨架、关键点和虚拟形象叠层的合成画布压缩成 JPEG 并持续上传到 media 服务",
|
||||
"viewer 端改为直接拉取 media 服务中的最新同步帧图,不再建立 WebRTC viewer peer 连接,因此跨网络和多端观看更稳定",
|
||||
"同步观看模式文案改为明确提示“通过 media 服务中转”,等待同步时也会自动轮询最新画面",
|
||||
"media 服务新增 live-frame 上传与静态分发能力,并记录最近同步帧的更新时间,方便后续扩展成更高频的服务端中转流",
|
||||
],
|
||||
tests: [
|
||||
"cd media && go test ./...",
|
||||
"pnpm build",
|
||||
"playwright-skill 线上 smoke: 先用 media 服务创建 relay session、上传 live-frame,并把 H1 的 `live_analysis_runtime` 注入为 active viewer 场景;随后访问 `https://te.hao.work/live-camera`,确认页面进入“同步观看模式”、同步帧来自 `/media/assets/sessions/.../live-frame.jpg`,且 `viewer-signal` 请求数为 0",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "2026.03.16-camera-startup-fallbacks",
|
||||
releaseDate: "2026-03-16",
|
||||
repoVersion: "a211562",
|
||||
summary: "修复部分设备上摄像头因后置镜头约束、分辨率约束或麦克风不可用而直接启动失败的问题。",
|
||||
features: [
|
||||
"live-camera 与 recorder 改为共用分级降级的摄像头请求流程,会在当前画质失败时自动降分辨率、降约束并回退到兼容镜头",
|
||||
"当设备不支持默认后置摄像头或当前镜头不可用时,页面会自动切换到实际可用的镜头方向,避免直接报错后卡死在未启动状态",
|
||||
"recorder 预览启动不再被麦克风权限或麦克风设备异常整体拖死;麦克风不可用时会自动回退到仅视频模式",
|
||||
"兼容模式命中时前端会给出明确提示,方便区分“已自动降级成功”与“仍然无法访问摄像头”的场景",
|
||||
],
|
||||
tests: [
|
||||
"pnpm build",
|
||||
"部署后线上 smoke: `https://te.hao.work/` 已提供 `assets/index-CRxtWK07.js` 与 `assets/index-tNGuStgv.css`;通过注入 `getUserMedia` 回归验证 `/live-camera` 首轮高约束失败后会自动切到兼容摄像头模式,`/recorder` 在麦克风不可用时会自动回退到仅视频模式并继续启动预览",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "2026.03.16-live-analysis-viewer-full-sync",
|
||||
releaseDate: "2026-03-16",
|
||||
repoVersion: "922a9fb",
|
||||
summary: "多端同步观看改为按持有端快照完整渲染,另一设备可同步看到视频状态、模式、画质、虚拟形象和保存阶段信息。",
|
||||
features: [
|
||||
"viewer 端现在同步显示持有端的会话标题、训练模式、设备端、拍摄视角、画质模式、虚拟形象状态和最近同步时间",
|
||||
"同步观看时的分析阶段、保存阶段、已完成状态也会跟随主端刷新,不再只显示本地默认状态",
|
||||
"viewer 页面会自动关闭拍摄校准弹窗,避免同步观看时被“启用摄像头”流程遮挡",
|
||||
"新增 viewer 同步信息卡,明确允许 1 秒级延迟,并持续显示最近心跳时间",
|
||||
],
|
||||
tests: [
|
||||
"pnpm exec playwright test tests/e2e/app.spec.ts --grep \"live camera switches into viewer mode|viewer stream|recorder blocks\"",
|
||||
"pnpm build",
|
||||
"部署后线上 smoke: `https://te.hao.work/` 已提供 `assets/index-HRdM3fxq.js` 与 `assets/index-tNGuStgv.css`;同账号 H1 双端登录后,移动端 owner 可开始实时分析,桌面端 `/live-camera` 自动进入同步观看并显示主端信息、同步视频流,owner 点击结束分析后 viewer 会同步进入保存阶段",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "2026.03.16-live-analysis-lock-hardening",
|
||||
releaseDate: "2026-03-16",
|
||||
repoVersion: "f9db6ef",
|
||||
summary: "修复同账号多端实时分析在旧登录态下仍可重复占用摄像头的问题,补强同步观看重试、录制页占用锁,并修复部署后启动阶段长时间 502。",
|
||||
features: [
|
||||
"旧用户名登录 token 即使缺少 `sid`,现在也会按 token 本身派生唯一会话标识,不再把不同设备错误识别成同一持有端",
|
||||
"同步观看模式新增 viewer 自动重试:当持有端刚启动推流、viewer 首次连接返回 `viewer stream not ready` 时,会自动重连而不是一直黑屏",
|
||||
"在线录制页接入实时分析占用锁;当其他设备正在 `/live-camera` 分析时,本页会禁止再次启动摄像头和录制",
|
||||
"应用启动改为先监听 HTTP 端口、再后台串行执行教程图同步和标准库预热,修复新容器上线时公网长时间返回 502 的问题",
|
||||
"线上 smoke 已确认 `https://te.hao.work/live-camera` 与 `/recorder` 都已切换到本次新构建,公开站点不再返回 502",
|
||||
],
|
||||
tests: [
|
||||
"curl -I https://te.hao.work/",
|
||||
"pnpm check",
|
||||
"pnpm exec vitest run server/_core/sdk.test.ts server/features.test.ts",
|
||||
"pnpm exec playwright test tests/e2e/app.spec.ts --grep \"viewer mode|viewer stream|recorder blocks\"",
|
||||
"pnpm build",
|
||||
"线上 smoke: H1 手机端开启实时分析后,PC 端 `/live-camera` 自动进入同步观看并显示同步画面,`/recorder` 禁止启动摄像头;结束分析后会话可正常释放",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "2026.03.16-live-analysis-runtime-migration",
|
||||
releaseDate: "2026-03-16",
|
||||
repoVersion: "2b72ef9",
|
||||
summary: "修复实时分析因缺失 `live_analysis_runtime` 表导致的启动失败,并补齐迁移记录避免后续部署再次漏表。",
|
||||
features: [
|
||||
"生产库补建 `live_analysis_runtime` 表,并补写 `__drizzle_migrations` 中缺失的 `0011_live_analysis_runtime` 记录",
|
||||
"仓库内 Drizzle migration journal 补齐 `0011_live_analysis_runtime` 条目,后续 `docker compose` 部署可正确感知该迁移",
|
||||
"实时分析启动链路恢复,`/live-camera` 再次可以读取 runtime 锁并正常进入分析准备流程",
|
||||
"线上 smoke 已确认 `https://te.hao.work/` 正在提供本次新构建,当前前端资源为 `assets/index-B3BN5hY-.js` 与 `assets/index-BL6GQzUF.css`",
|
||||
],
|
||||
tests: [
|
||||
"pnpm check",
|
||||
"pnpm exec vitest run server/features.test.ts",
|
||||
"pnpm build",
|
||||
"docker compose exec -T db mysql ... SHOW TABLES LIKE 'live_analysis_runtime'",
|
||||
"curl -I https://te.hao.work/live-camera",
|
||||
"Playwright smoke: 登录 `H1` 后访问 `/live-camera`,`analysis.runtimeGet` / `analysis.runtimeAcquire` / `analysis.runtimeRelease` 全部返回 200",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "2026.03.16-live-camera-multidevice-viewer",
|
||||
releaseDate: "2026-03-16",
|
||||
repoVersion: "4e4122d",
|
||||
summary: "实时分析新增同账号多端互斥和同步观看模式,分析持有端独占摄像头,其它端只能查看同步画面与核心识别结果。",
|
||||
features: [
|
||||
"同一账号在 `/live-camera` 进入实时分析后,会写入按用户维度的 runtime 锁,其他设备不能重复启动摄像头或分析",
|
||||
"其他设备会自动进入“同步观看模式”,可订阅持有端的实时画面,并同步看到动作、评分、反馈、最近片段和归档段数",
|
||||
"同步观看复用 media 服务的 WebRTC viewer 通道,传输的是带骨架、关键点和虚拟形象覆盖后的合成画面",
|
||||
"runtime 锁按 session sid 区分持有端,兼容缺少 sid 的旧 token,超过 15 秒无心跳会自动判定为陈旧并释放",
|
||||
"线上 smoke 已确认 `https://te.hao.work/live-camera` 已切换到本次新构建,公开站点正在提供这次发布的最新前端资源",
|
||||
],
|
||||
tests: [
|
||||
"pnpm check",
|
||||
"pnpm exec vitest run server/features.test.ts",
|
||||
"go test ./... && go build ./... (media)",
|
||||
"pnpm build",
|
||||
"pnpm exec playwright test tests/e2e/app.spec.ts --grep \"live camera\"",
|
||||
"pnpm exec playwright test tests/e2e/app.spec.ts --grep \"recorder flow archives a session and exposes it in videos\"",
|
||||
"curl -I https://te.hao.work/live-camera",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "2026.03.16-live-analysis-overlay-archive",
|
||||
releaseDate: "2026-03-16",
|
||||
repoVersion: "4fb2d09",
|
||||
summary: "实时分析新增 60 秒自动归档录像,录制内容会保留骨架、关键点和虚拟形象叠层,并同步进入视频库。",
|
||||
features: [
|
||||
"实时分析开始后会自动录制合成画布,每 60 秒自动切段归档",
|
||||
"归档录像会保留原视频、骨架线、关键点和当前虚拟形象覆盖效果",
|
||||
"归档片段会自动写入视频库,标签显示为“实时分析”",
|
||||
"删除视频库中的实时分析录像时,不会删除已写入的实时分析数据和训练记录",
|
||||
"线上 smoke 已确认 `https://te.hao.work/` 已切换到本次新构建,`/live-camera`、`/videos`、`/changelog` 页面均可正常访问",
|
||||
],
|
||||
tests: [
|
||||
"pnpm check",
|
||||
"pnpm test",
|
||||
"pnpm build",
|
||||
"pnpm test:e2e",
|
||||
"Playwright smoke: 真实站点登录 H1,完成 /live-camera 引导、开始/结束分析,并确认 /videos 可见实时分析条目",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "2026.03.15-live-analysis-leave-hint",
|
||||
releaseDate: "2026-03-15",
|
||||
repoVersion: "5c2dcf2",
|
||||
summary: "实时分析结束后增加离开提示,明确何时必须停留、何时可以安全关闭或切页。",
|
||||
features: [
|
||||
"分析进行中显示“不要关闭或切走页面”提示",
|
||||
"结束分析后保存阶段显示“请暂时停留当前页面”提示",
|
||||
"保存成功后明确提示“现在可以关闭浏览器或切换到其他页面”",
|
||||
"分析中和保存中挂接 beforeunload 提醒,减少误关页面导致的数据丢失",
|
||||
],
|
||||
tests: [
|
||||
"pnpm check",
|
||||
"pnpm build",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "2026.03.15-training-generator-collapse",
|
||||
releaseDate: "2026-03-15",
|
||||
repoVersion: "1ce94f6",
|
||||
summary: "训练计划生成面板在桌面端默认折叠到右侧,按需展开查看和重新生成。",
|
||||
features: [
|
||||
"训练页右侧生成器在桌面端默认折叠为窄栏",
|
||||
"点击右侧折叠栏可展开“重新生成计划”完整面板",
|
||||
"移动端继续直接展示完整生成器,避免隐藏关键操作",
|
||||
"未生成计划时点击“前往生成训练计划”会自动展开并滚动到生成面板",
|
||||
],
|
||||
tests: [
|
||||
"pnpm check",
|
||||
"pnpm build",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "2026.03.15-progress-time-actions",
|
||||
releaseDate: "2026-03-15",
|
||||
repoVersion: "71caf0d",
|
||||
summary: "最近训练记录默认显示具体上海时间,并直接展示录制动作数据摘要。",
|
||||
features: [
|
||||
"最近训练记录摘要行默认显示到秒的 Asia/Shanghai 时间",
|
||||
"录制记录列表直接展示主动作和前 3 个动作统计,无需先展开",
|
||||
"展开态动作明细统一用中文动作标签展示",
|
||||
"提醒页通知时间统一切换为 Asia/Shanghai",
|
||||
],
|
||||
tests: [
|
||||
"pnpm check",
|
||||
"pnpm build",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "2026.03.15-session-changelog",
|
||||
releaseDate: "2026-03-15",
|
||||
repoVersion: "a9ea94f",
|
||||
summary: "多端 session、更新日志页面、录制动作摘要与上海时区显示同步收口。",
|
||||
features: [
|
||||
"用户名登录生成独立 sid,同一账号多端登录保持并行有效",
|
||||
"新增 /changelog 页面和侧边栏入口,展示版本、仓库版本和验证记录",
|
||||
"训练进度页可展开查看最近训练记录的具体时间、动作统计和录制有效性",
|
||||
"录制页增加动作抽样摘要、无效录制标记与 media 预归档状态",
|
||||
"Dashboard、任务中心、管理台、评分、日志、视觉测试、视频库等页面统一使用 Asia/Shanghai 时间显示",
|
||||
],
|
||||
tests: [
|
||||
"pnpm check",
|
||||
"pnpm test",
|
||||
"pnpm test:go",
|
||||
"pnpm build",
|
||||
"Playwright smoke: https://te.hao.work/ 双上下文登录 H1 后 dashboard 均保持有效;线上 /changelog 仍显示旧构建,待部署后复测",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "2026.03.15-recorder-zoom",
|
||||
releaseDate: "2026-03-15",
|
||||
repoVersion: "c4ec397",
|
||||
summary: "补齐录制页与实时分析页的节省流量模式、镜头缩放和移动端控制。",
|
||||
features: [
|
||||
"在线录制默认切换为节省流量模式",
|
||||
"在线录制支持镜头焦距放大缩小",
|
||||
"实时分析支持镜头焦距放大缩小",
|
||||
"页面内增加拍摄与流量设置说明",
|
||||
],
|
||||
tests: [
|
||||
"pnpm check",
|
||||
"pnpm exec vitest run client/src/lib/media.test.ts client/src/lib/camera.test.ts",
|
||||
"Playwright 真实站点检查 /live-camera 与 /recorder 新控件可见",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "2026.03.15-videos-crud",
|
||||
releaseDate: "2026-03-15",
|
||||
repoVersion: "bd89981",
|
||||
summary: "视频库支持新增、编辑、删除训练视频记录。",
|
||||
features: [
|
||||
"视频库新增外部视频登记",
|
||||
"视频库支持编辑标题和动作类型",
|
||||
"视频库支持删除视频及关联分析引用",
|
||||
"视频详情读取按当前用户权限收敛",
|
||||
],
|
||||
tests: [
|
||||
"pnpm check",
|
||||
"pnpm exec vitest run server/features.test.ts -t \"video\\\\.\"",
|
||||
"Playwright 真实站点完成 /videos 新增-编辑-删除全链路",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "v3.0.0",
|
||||
releaseDate: "2026-03-14",
|
||||
repoVersion: "历史版本",
|
||||
summary: "教程库、提醒、通知等学习能力上线。",
|
||||
features: [
|
||||
"训练视频教程库",
|
||||
"教程自评与学习进度",
|
||||
"训练提醒通知",
|
||||
"通知历史管理",
|
||||
],
|
||||
tests: [
|
||||
"教程库、提醒、通知相关测试通过",
|
||||
],
|
||||
},
|
||||
];
|
||||
129
client/src/lib/liveCamera.test.ts
普通文件
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ACTION_WINDOW_FRAMES,
|
||||
AVATAR_PRESETS,
|
||||
createStableActionState,
|
||||
getAvatarAnchors,
|
||||
getAvatarPreset,
|
||||
resolveAvatarKeyFromPrompt,
|
||||
stabilizeActionStream,
|
||||
type FrameActionSample,
|
||||
} from "./liveCamera";
|
||||
|
||||
function feedSamples(samples: Array<Omit<FrameActionSample, "timestamp">>, intervalMs = 33) {
|
||||
const history: FrameActionSample[] = [];
|
||||
const state = createStableActionState();
|
||||
let lastResult = null as ReturnType<typeof stabilizeActionStream> | null;
|
||||
|
||||
samples.forEach((sample, index) => {
|
||||
lastResult = stabilizeActionStream(
|
||||
{
|
||||
...sample,
|
||||
timestamp: index * intervalMs,
|
||||
},
|
||||
history,
|
||||
state,
|
||||
);
|
||||
});
|
||||
|
||||
return { history, state, lastResult };
|
||||
}
|
||||
|
||||
describe("live camera action stabilizer", () => {
|
||||
it("locks a dominant action after a full temporal window", () => {
|
||||
const samples = Array.from({ length: ACTION_WINDOW_FRAMES * 2 }, () => ({
|
||||
action: "forehand" as const,
|
||||
confidence: 0.84,
|
||||
}));
|
||||
const { lastResult } = feedSamples(samples);
|
||||
|
||||
expect(lastResult?.stableAction).toBe("forehand");
|
||||
expect(lastResult?.windowAction).toBe("forehand");
|
||||
expect(lastResult?.pending).toBe(false);
|
||||
expect(lastResult?.windowShare).toBeGreaterThan(0.9);
|
||||
});
|
||||
|
||||
it("ignores brief action spikes and keeps the stable action", () => {
|
||||
const stableFrames = Array.from({ length: ACTION_WINDOW_FRAMES * 2 }, () => ({
|
||||
action: "forehand" as const,
|
||||
confidence: 0.82,
|
||||
}));
|
||||
const noisyFrames = Array.from({ length: 5 }, () => ({
|
||||
action: "backhand" as const,
|
||||
confidence: 0.88,
|
||||
}));
|
||||
const { lastResult } = feedSamples([...stableFrames, ...noisyFrames]);
|
||||
|
||||
expect(lastResult?.stableAction).toBe("forehand");
|
||||
expect(lastResult?.pending).toBe(false);
|
||||
});
|
||||
|
||||
it("switches only after the next action persists long enough", () => {
|
||||
const forehandFrames = Array.from({ length: ACTION_WINDOW_FRAMES * 2 }, () => ({
|
||||
action: "forehand" as const,
|
||||
confidence: 0.8,
|
||||
}));
|
||||
const backhandFrames = Array.from({ length: ACTION_WINDOW_FRAMES * 2 }, () => ({
|
||||
action: "backhand" as const,
|
||||
confidence: 0.85,
|
||||
}));
|
||||
const { lastResult, state } = feedSamples([...forehandFrames, ...backhandFrames]);
|
||||
|
||||
expect(lastResult?.stableAction).toBe("backhand");
|
||||
expect(state.switchCount).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("requires a longer delay before falling back to unknown", () => {
|
||||
const forehandFrames = Array.from({ length: ACTION_WINDOW_FRAMES * 2 }, () => ({
|
||||
action: "forehand" as const,
|
||||
confidence: 0.83,
|
||||
}));
|
||||
const unknownFrames = Array.from({ length: 10 }, () => ({
|
||||
action: "unknown" as const,
|
||||
confidence: 0.4,
|
||||
}));
|
||||
const { lastResult } = feedSamples([...forehandFrames, ...unknownFrames]);
|
||||
|
||||
expect(lastResult?.stableAction).toBe("forehand");
|
||||
});
|
||||
});
|
||||
|
||||
describe("live camera avatar helpers", () => {
|
||||
it("maps prompt keywords into avatar presets", () => {
|
||||
expect(resolveAvatarKeyFromPrompt("切换成猩猩形象", "gorilla")).toBe("gorilla");
|
||||
expect(resolveAvatarKeyFromPrompt("dog mascot", "gorilla")).toBe("dog");
|
||||
expect(resolveAvatarKeyFromPrompt("狐狸风格", "gorilla")).toBe("fox");
|
||||
expect(resolveAvatarKeyFromPrompt("兔子教练", "gorilla")).toBe("rabbit");
|
||||
expect(resolveAvatarKeyFromPrompt("BeachKing 3D 替身", "gorilla")).toBe("beachKing");
|
||||
expect(resolveAvatarKeyFromPrompt("Juanita avatar", "gorilla")).toBe("juanita3d");
|
||||
expect(resolveAvatarKeyFromPrompt("", "pig")).toBe("pig");
|
||||
});
|
||||
|
||||
it("exposes full-body 3d avatar examples with CC0 metadata", () => {
|
||||
const presets = AVATAR_PRESETS.filter((preset) => preset.category === "full-body-3d");
|
||||
|
||||
expect(presets).toHaveLength(4);
|
||||
expect(presets.every((preset) => preset.license === "CC0")).toBe(true);
|
||||
expect(getAvatarPreset("sportTv")?.modelUrl).toContain("arweave.net");
|
||||
});
|
||||
|
||||
it("builds avatar anchors from pose landmarks", () => {
|
||||
const landmarks = Array.from({ length: 33 }, () => ({ x: 0.5, y: 0.5, visibility: 0.95 }));
|
||||
landmarks[0] = { x: 0.5, y: 0.16, visibility: 0.99 };
|
||||
landmarks[11] = { x: 0.4, y: 0.3, visibility: 0.99 };
|
||||
landmarks[12] = { x: 0.6, y: 0.3, visibility: 0.99 };
|
||||
landmarks[15] = { x: 0.28, y: 0.44, visibility: 0.99 };
|
||||
landmarks[16] = { x: 0.72, y: 0.44, visibility: 0.99 };
|
||||
landmarks[23] = { x: 0.44, y: 0.58, visibility: 0.99 };
|
||||
landmarks[24] = { x: 0.56, y: 0.58, visibility: 0.99 };
|
||||
landmarks[27] = { x: 0.43, y: 0.92, visibility: 0.99 };
|
||||
landmarks[28] = { x: 0.57, y: 0.92, visibility: 0.99 };
|
||||
|
||||
const anchors = getAvatarAnchors(landmarks, 1280, 720);
|
||||
|
||||
expect(anchors).not.toBeNull();
|
||||
expect(anchors?.headRadius).toBeGreaterThan(30);
|
||||
expect(anchors?.bodyHeight).toBeGreaterThan(120);
|
||||
expect(anchors?.rightHandX).toBeGreaterThan(anchors?.leftHandX || 0);
|
||||
});
|
||||
});
|
||||
744
client/src/lib/liveCamera.ts
普通文件
@@ -0,0 +1,744 @@
|
||||
export type LiveActionType = "forehand" | "backhand" | "serve" | "volley" | "overhead" | "slice" | "lob" | "unknown";
|
||||
|
||||
export type PosePoint = {
|
||||
x: number;
|
||||
y: number;
|
||||
visibility?: number;
|
||||
};
|
||||
|
||||
export type AvatarKey =
|
||||
| "gorilla"
|
||||
| "monkey"
|
||||
| "dog"
|
||||
| "pig"
|
||||
| "cat"
|
||||
| "fox"
|
||||
| "panda"
|
||||
| "lion"
|
||||
| "tiger"
|
||||
| "rabbit"
|
||||
| "beachKing"
|
||||
| "jenny3d"
|
||||
| "juanita3d"
|
||||
| "sportTv";
|
||||
|
||||
export type AvatarCategory = "animal" | "full-body-3d";
|
||||
|
||||
export type AvatarPreset = {
|
||||
key: AvatarKey;
|
||||
label: string;
|
||||
category: AvatarCategory;
|
||||
keywords: string[];
|
||||
description?: string;
|
||||
collection?: string;
|
||||
license?: string;
|
||||
sourceUrl?: string;
|
||||
modelUrl?: string;
|
||||
};
|
||||
|
||||
export type AvatarRenderState = {
|
||||
enabled: boolean;
|
||||
avatarKey: AvatarKey;
|
||||
customLabel?: string;
|
||||
};
|
||||
|
||||
export type FrameActionSample = {
|
||||
action: LiveActionType;
|
||||
confidence: number;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
export type StableActionState = {
|
||||
current: LiveActionType;
|
||||
currentSince: number | null;
|
||||
candidate: LiveActionType | null;
|
||||
candidateSince: number | null;
|
||||
candidateWindows: number;
|
||||
switchCount: number;
|
||||
};
|
||||
|
||||
export type StabilizedActionMeta = {
|
||||
stableAction: LiveActionType;
|
||||
stableConfidence: number;
|
||||
windowAction: LiveActionType;
|
||||
windowConfidence: number;
|
||||
windowShare: number;
|
||||
windowFrames: number;
|
||||
windowProgress: number;
|
||||
pending: boolean;
|
||||
pendingAction: LiveActionType | null;
|
||||
stableMs: number;
|
||||
candidateMs: number;
|
||||
rawVolatility: number;
|
||||
switchCount: number;
|
||||
};
|
||||
|
||||
type ActionStat = {
|
||||
count: number;
|
||||
totalConfidence: number;
|
||||
share: number;
|
||||
averageConfidence: number;
|
||||
strength: number;
|
||||
};
|
||||
|
||||
type AvatarAnchors = {
|
||||
headX: number;
|
||||
headY: number;
|
||||
headRadius: number;
|
||||
bodyX: number;
|
||||
bodyY: number;
|
||||
bodyWidth: number;
|
||||
bodyHeight: number;
|
||||
shoulderY: number;
|
||||
footY: number;
|
||||
leftHandX: number;
|
||||
leftHandY: number;
|
||||
rightHandX: number;
|
||||
rightHandY: number;
|
||||
};
|
||||
|
||||
type AvatarVisualSpec = {
|
||||
src: string;
|
||||
bodyFill: string;
|
||||
limbStroke: string;
|
||||
glow: string;
|
||||
renderMode: "badge" | "full-figure";
|
||||
figureScale?: number;
|
||||
figureOffsetY?: number;
|
||||
};
|
||||
|
||||
const ACTIONS: LiveActionType[] = ["forehand", "backhand", "serve", "volley", "overhead", "slice", "lob", "unknown"];
|
||||
|
||||
export const ACTION_WINDOW_FRAMES = 24;
|
||||
const ACTION_WINDOW_MIN_SHARE = 0.6;
|
||||
const ACTION_WINDOW_MIN_CONFIDENCE = 0.58;
|
||||
const ACTION_SWITCH_MIN_MS = 700;
|
||||
const ACTION_UNKNOWN_MIN_MS = 900;
|
||||
const ACTION_LOCK_IN_WINDOWS = 2;
|
||||
const ACTION_SWITCH_DELTA = 0.12;
|
||||
|
||||
export const AVATAR_PRESETS: AvatarPreset[] = [
|
||||
{ key: "gorilla", label: "猩猩", category: "animal", keywords: ["gorilla", "ape", "猩猩", "猩", "大猩猩"], description: "轻量动物替身,移动端负担最低。" },
|
||||
{ key: "monkey", label: "猴子", category: "animal", keywords: ["monkey", "ape", "猴", "猴子"], description: "轻量动物替身,适合快速练习。" },
|
||||
{ key: "dog", label: "狗", category: "animal", keywords: ["dog", "puppy", "犬", "狗", "小狗"], description: "轻量动物替身,覆盖速度快。" },
|
||||
{ key: "pig", label: "猪", category: "animal", keywords: ["pig", "猪", "小猪"], description: "轻量动物替身,适合低端设备。" },
|
||||
{ key: "cat", label: "猫", category: "animal", keywords: ["cat", "kitty", "猫", "小猫"], description: "轻量动物替身,适合低码率录制。" },
|
||||
{ key: "fox", label: "狐狸", category: "animal", keywords: ["fox", "狐狸"], description: "轻量动物替身,动作切换反馈清晰。" },
|
||||
{ key: "panda", label: "熊猫", category: "animal", keywords: ["panda", "熊猫"], description: "轻量动物替身,适合直播预览。" },
|
||||
{ key: "lion", label: "狮子", category: "animal", keywords: ["lion", "狮子"], description: "轻量动物替身,轮廓感更强。" },
|
||||
{ key: "tiger", label: "老虎", category: "animal", keywords: ["tiger", "虎", "老虎"], description: "轻量动物替身,适合训练 PK。" },
|
||||
{ key: "rabbit", label: "兔子", category: "animal", keywords: ["rabbit", "bunny", "兔", "兔子"], description: "轻量动物替身,适合日常训练。" },
|
||||
{
|
||||
key: "beachKing",
|
||||
label: "BeachKing",
|
||||
category: "full-body-3d",
|
||||
keywords: ["beachking", "beach king", "海滩王", "3d beach", "beach avatar"],
|
||||
description: "CC0 全身 3D 示例,适合覆盖竖屏全身站姿。",
|
||||
collection: "100Avatars R3",
|
||||
license: "CC0",
|
||||
sourceUrl: "https://github.com/ToxSam/open-source-avatars",
|
||||
modelUrl: "https://arweave.net/uKhDMselhdUyeJKjelpuVsL8s-a9v_Wqq75TQfCfnos",
|
||||
},
|
||||
{
|
||||
key: "jenny3d",
|
||||
label: "Jenny",
|
||||
category: "full-body-3d",
|
||||
keywords: ["jenny", "frog coach", "青蛙教练", "3d jenny", "jenny avatar"],
|
||||
description: "CC0 全身 3D 示例,适合想要更完整人物轮廓时使用。",
|
||||
collection: "100Avatars R3",
|
||||
license: "CC0",
|
||||
sourceUrl: "https://github.com/ToxSam/open-source-avatars",
|
||||
modelUrl: "https://arweave.net/kgTirc4OvUWbJhIKC2CB3_pYsYuB62KTj90IdE8s3sk",
|
||||
},
|
||||
{
|
||||
key: "juanita3d",
|
||||
label: "Juanita",
|
||||
category: "full-body-3d",
|
||||
keywords: ["juanita", "粉发学员", "pink avatar", "3d juanita", "juanita avatar"],
|
||||
description: "CC0 全身 3D 示例,适合教学演示和移动端预览。",
|
||||
collection: "100Avatars R3",
|
||||
license: "CC0",
|
||||
sourceUrl: "https://github.com/ToxSam/open-source-avatars",
|
||||
modelUrl: "https://arweave.net/nyMyZZx5lN2DXsmBgbGQSnt3PuXYN7AAjz9QJrjitLo",
|
||||
},
|
||||
{
|
||||
key: "sportTv",
|
||||
label: "SportTV",
|
||||
category: "full-body-3d",
|
||||
keywords: ["sporttv", "sport tv", "屏幕街头", "tv avatar", "hoodie avatar"],
|
||||
description: "CC0 全身 3D 示例,适合训练空间较宽的画面。",
|
||||
collection: "100Avatars R3",
|
||||
license: "CC0",
|
||||
sourceUrl: "https://github.com/ToxSam/open-source-avatars",
|
||||
modelUrl: "https://arweave.net/ISYr7xBXT_s4tLddbhFB3PpUhWg-H_BYs2UZhVLF1hA",
|
||||
},
|
||||
];
|
||||
|
||||
const AVATAR_VISUALS: Record<AvatarKey, AvatarVisualSpec> = {
|
||||
gorilla: {
|
||||
src: "/avatars/twemoji/gorilla.svg",
|
||||
bodyFill: "rgba(39,39,42,0.95)",
|
||||
limbStroke: "rgba(63,63,70,0.92)",
|
||||
glow: "rgba(161,161,170,0.32)",
|
||||
renderMode: "badge",
|
||||
},
|
||||
monkey: {
|
||||
src: "/avatars/twemoji/monkey.svg",
|
||||
bodyFill: "rgba(120,53,15,0.95)",
|
||||
limbStroke: "rgba(146,64,14,0.9)",
|
||||
glow: "rgba(180,83,9,0.3)",
|
||||
renderMode: "badge",
|
||||
},
|
||||
dog: {
|
||||
src: "/avatars/twemoji/dog.svg",
|
||||
bodyFill: "rgba(180,83,9,0.93)",
|
||||
limbStroke: "rgba(180,83,9,0.88)",
|
||||
glow: "rgba(217,119,6,0.26)",
|
||||
renderMode: "badge",
|
||||
},
|
||||
pig: {
|
||||
src: "/avatars/twemoji/pig.svg",
|
||||
bodyFill: "rgba(244,114,182,0.92)",
|
||||
limbStroke: "rgba(244,114,182,0.86)",
|
||||
glow: "rgba(244,114,182,0.28)",
|
||||
renderMode: "badge",
|
||||
},
|
||||
cat: {
|
||||
src: "/avatars/twemoji/cat.svg",
|
||||
bodyFill: "rgba(245,158,11,0.92)",
|
||||
limbStroke: "rgba(217,119,6,0.88)",
|
||||
glow: "rgba(251,191,36,0.28)",
|
||||
renderMode: "badge",
|
||||
},
|
||||
fox: {
|
||||
src: "/avatars/twemoji/fox.svg",
|
||||
bodyFill: "rgba(234,88,12,0.93)",
|
||||
limbStroke: "rgba(194,65,12,0.9)",
|
||||
glow: "rgba(251,146,60,0.3)",
|
||||
renderMode: "badge",
|
||||
},
|
||||
panda: {
|
||||
src: "/avatars/twemoji/panda.svg",
|
||||
bodyFill: "rgba(82,82,91,0.92)",
|
||||
limbStroke: "rgba(39,39,42,0.9)",
|
||||
glow: "rgba(228,228,231,0.28)",
|
||||
renderMode: "badge",
|
||||
},
|
||||
lion: {
|
||||
src: "/avatars/twemoji/lion.svg",
|
||||
bodyFill: "rgba(202,138,4,0.92)",
|
||||
limbStroke: "rgba(161,98,7,0.9)",
|
||||
glow: "rgba(250,204,21,0.28)",
|
||||
renderMode: "badge",
|
||||
},
|
||||
tiger: {
|
||||
src: "/avatars/twemoji/tiger.svg",
|
||||
bodyFill: "rgba(249,115,22,0.94)",
|
||||
limbStroke: "rgba(194,65,12,0.9)",
|
||||
glow: "rgba(251,146,60,0.3)",
|
||||
renderMode: "badge",
|
||||
},
|
||||
rabbit: {
|
||||
src: "/avatars/twemoji/rabbit.svg",
|
||||
bodyFill: "rgba(236,72,153,0.9)",
|
||||
limbStroke: "rgba(219,39,119,0.86)",
|
||||
glow: "rgba(244,114,182,0.28)",
|
||||
renderMode: "badge",
|
||||
},
|
||||
beachKing: {
|
||||
src: "/avatars/opensource3d/beach-king.webp",
|
||||
bodyFill: "rgba(15,23,42,0.16)",
|
||||
limbStroke: "rgba(125,211,252,0.28)",
|
||||
glow: "rgba(56,189,248,0.16)",
|
||||
renderMode: "full-figure",
|
||||
figureScale: 1.12,
|
||||
figureOffsetY: 0.02,
|
||||
},
|
||||
jenny3d: {
|
||||
src: "/avatars/opensource3d/jenny.webp",
|
||||
bodyFill: "rgba(34,197,94,0.16)",
|
||||
limbStroke: "rgba(16,185,129,0.24)",
|
||||
glow: "rgba(34,197,94,0.18)",
|
||||
renderMode: "full-figure",
|
||||
figureScale: 1.08,
|
||||
figureOffsetY: 0,
|
||||
},
|
||||
juanita3d: {
|
||||
src: "/avatars/opensource3d/juanita.webp",
|
||||
bodyFill: "rgba(244,114,182,0.14)",
|
||||
limbStroke: "rgba(236,72,153,0.26)",
|
||||
glow: "rgba(244,114,182,0.18)",
|
||||
renderMode: "full-figure",
|
||||
figureScale: 1.06,
|
||||
figureOffsetY: 0,
|
||||
},
|
||||
sportTv: {
|
||||
src: "/avatars/opensource3d/sport-tv.webp",
|
||||
bodyFill: "rgba(59,130,246,0.14)",
|
||||
limbStroke: "rgba(96,165,250,0.24)",
|
||||
glow: "rgba(96,165,250,0.18)",
|
||||
renderMode: "full-figure",
|
||||
figureScale: 1.1,
|
||||
figureOffsetY: 0.02,
|
||||
},
|
||||
};
|
||||
|
||||
const avatarImageCache = new Map<AvatarKey, HTMLImageElement | null>();
|
||||
|
||||
export function getAvatarPreset(key: AvatarKey) {
|
||||
return AVATAR_PRESETS.find((preset) => preset.key === key) ?? AVATAR_PRESETS[0];
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function getActionStat(samples: FrameActionSample[], action: LiveActionType): ActionStat {
|
||||
const matches = samples.filter((sample) => sample.action === action);
|
||||
const count = matches.length;
|
||||
const totalConfidence = matches.reduce((sum, sample) => sum + sample.confidence, 0);
|
||||
const share = samples.length > 0 ? count / samples.length : 0;
|
||||
const averageConfidence = count > 0 ? totalConfidence / count : 0;
|
||||
|
||||
return {
|
||||
count,
|
||||
totalConfidence,
|
||||
share,
|
||||
averageConfidence,
|
||||
strength: share * 0.7 + averageConfidence * 0.3,
|
||||
};
|
||||
}
|
||||
|
||||
function getWindowAction(samples: FrameActionSample[]) {
|
||||
const stats = new Map<LiveActionType, ActionStat>();
|
||||
ACTIONS.forEach((action) => {
|
||||
stats.set(action, getActionStat(samples, action));
|
||||
});
|
||||
|
||||
const ranked = ACTIONS
|
||||
.map((action) => ({ action, stats: stats.get(action)! }))
|
||||
.sort((a, b) => {
|
||||
if (b.stats.strength !== a.stats.strength) {
|
||||
return b.stats.strength - a.stats.strength;
|
||||
}
|
||||
return b.stats.totalConfidence - a.stats.totalConfidence;
|
||||
});
|
||||
|
||||
const winner = ranked[0] ?? { action: "unknown" as LiveActionType, stats: stats.get("unknown")! };
|
||||
const qualifies =
|
||||
winner.stats.share >= ACTION_WINDOW_MIN_SHARE &&
|
||||
winner.stats.averageConfidence >= ACTION_WINDOW_MIN_CONFIDENCE;
|
||||
|
||||
return {
|
||||
action: qualifies ? winner.action : "unknown",
|
||||
stats,
|
||||
winnerStats: winner.stats,
|
||||
};
|
||||
}
|
||||
|
||||
function getRawVolatility(samples: FrameActionSample[]) {
|
||||
if (samples.length <= 1) return 0;
|
||||
let switches = 0;
|
||||
for (let index = 1; index < samples.length; index += 1) {
|
||||
if (samples[index]?.action !== samples[index - 1]?.action) {
|
||||
switches += 1;
|
||||
}
|
||||
}
|
||||
return switches / (samples.length - 1);
|
||||
}
|
||||
|
||||
export function createStableActionState(initial: LiveActionType = "unknown"): StableActionState {
|
||||
return {
|
||||
current: initial,
|
||||
currentSince: null,
|
||||
candidate: null,
|
||||
candidateSince: null,
|
||||
candidateWindows: 0,
|
||||
switchCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function createEmptyStabilizedActionMeta(): StabilizedActionMeta {
|
||||
return {
|
||||
stableAction: "unknown",
|
||||
stableConfidence: 0,
|
||||
windowAction: "unknown",
|
||||
windowConfidence: 0,
|
||||
windowShare: 0,
|
||||
windowFrames: 0,
|
||||
windowProgress: 0,
|
||||
pending: false,
|
||||
pendingAction: null,
|
||||
stableMs: 0,
|
||||
candidateMs: 0,
|
||||
rawVolatility: 0,
|
||||
switchCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function stabilizeActionStream(
|
||||
sample: FrameActionSample,
|
||||
history: FrameActionSample[],
|
||||
state: StableActionState,
|
||||
) {
|
||||
history.push(sample);
|
||||
if (history.length > ACTION_WINDOW_FRAMES) {
|
||||
history.splice(0, history.length - ACTION_WINDOW_FRAMES);
|
||||
}
|
||||
|
||||
const { action: windowAction, stats } = getWindowAction(history);
|
||||
const windowStats = stats.get(windowAction) ?? getActionStat(history, "unknown");
|
||||
const currentStats = stats.get(state.current) ?? getActionStat(history, state.current);
|
||||
const pendingMinMs = windowAction === "unknown" ? ACTION_UNKNOWN_MIN_MS : ACTION_SWITCH_MIN_MS;
|
||||
const windowProgress = clamp(history.length / ACTION_WINDOW_FRAMES, 0, 1);
|
||||
|
||||
if (state.currentSince == null) {
|
||||
state.currentSince = sample.timestamp;
|
||||
}
|
||||
|
||||
if (windowAction === state.current) {
|
||||
state.candidate = null;
|
||||
state.candidateSince = null;
|
||||
state.candidateWindows = 0;
|
||||
} else if (windowProgress >= 0.7) {
|
||||
if (state.candidate !== windowAction) {
|
||||
state.candidate = windowAction;
|
||||
state.candidateSince = sample.timestamp;
|
||||
state.candidateWindows = 1;
|
||||
} else {
|
||||
state.candidateWindows += 1;
|
||||
}
|
||||
|
||||
const candidateStats = stats.get(windowAction) ?? getActionStat(history, windowAction);
|
||||
const currentStrength = state.current === "unknown" ? currentStats.strength * 0.55 : currentStats.strength;
|
||||
const candidateDuration = state.candidateSince == null ? 0 : sample.timestamp - state.candidateSince;
|
||||
const canSwitch =
|
||||
state.candidateWindows >= ACTION_LOCK_IN_WINDOWS &&
|
||||
candidateDuration >= pendingMinMs &&
|
||||
candidateStats.strength >= currentStrength + ACTION_SWITCH_DELTA;
|
||||
|
||||
if (canSwitch) {
|
||||
state.current = windowAction;
|
||||
state.currentSince = sample.timestamp;
|
||||
state.candidate = null;
|
||||
state.candidateSince = null;
|
||||
state.candidateWindows = 0;
|
||||
state.switchCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const stableStats = stats.get(state.current) ?? getActionStat(history, state.current);
|
||||
const stableConfidence = state.current === "unknown"
|
||||
? Math.max(sample.confidence * 0.45, stableStats.averageConfidence)
|
||||
: Math.max(stableStats.averageConfidence, windowStats.averageConfidence * 0.88);
|
||||
|
||||
return {
|
||||
stableAction: state.current,
|
||||
stableConfidence: clamp(stableConfidence, 0, 1),
|
||||
windowAction,
|
||||
windowConfidence: clamp(windowStats.averageConfidence, 0, 1),
|
||||
windowShare: clamp(windowStats.share, 0, 1),
|
||||
windowFrames: history.length,
|
||||
windowProgress,
|
||||
pending: Boolean(state.candidate),
|
||||
pendingAction: state.candidate,
|
||||
stableMs: state.currentSince == null ? 0 : sample.timestamp - state.currentSince,
|
||||
candidateMs: state.candidateSince == null ? 0 : sample.timestamp - state.candidateSince,
|
||||
rawVolatility: getRawVolatility(history),
|
||||
switchCount: state.switchCount,
|
||||
} satisfies StabilizedActionMeta;
|
||||
}
|
||||
|
||||
export function resolveAvatarKeyFromPrompt(prompt: string, fallback: AvatarKey): AvatarKey {
|
||||
const normalized = prompt.trim().toLowerCase();
|
||||
if (!normalized) return fallback;
|
||||
const matched = AVATAR_PRESETS.find((preset) => preset.keywords.some((keyword) => normalized.includes(keyword)));
|
||||
return matched?.key ?? fallback;
|
||||
}
|
||||
|
||||
function averagePoint(a: PosePoint | undefined, b: PosePoint | undefined, defaultX: number, defaultY: number) {
|
||||
return {
|
||||
x: ((a?.x ?? defaultX) + (b?.x ?? defaultX)) / 2,
|
||||
y: ((a?.y ?? defaultY) + (b?.y ?? defaultY)) / 2,
|
||||
};
|
||||
}
|
||||
|
||||
export function getAvatarAnchors(landmarks: PosePoint[], width: number, height: number): AvatarAnchors | null {
|
||||
const nose = landmarks[0];
|
||||
const leftShoulder = landmarks[11];
|
||||
const rightShoulder = landmarks[12];
|
||||
const leftHip = landmarks[23];
|
||||
const rightHip = landmarks[24];
|
||||
const leftWrist = landmarks[15];
|
||||
const rightWrist = landmarks[16];
|
||||
const leftAnkle = landmarks[27];
|
||||
const rightAnkle = landmarks[28];
|
||||
const leftEar = landmarks[7];
|
||||
const rightEar = landmarks[8];
|
||||
|
||||
if (!nose || !leftShoulder || !rightShoulder || !leftHip || !rightHip) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const shoulderCenter = averagePoint(leftShoulder, rightShoulder, 0.5, 0.32);
|
||||
const hipCenter = averagePoint(leftHip, rightHip, 0.5, 0.62);
|
||||
const ankleCenter = averagePoint(leftAnkle, rightAnkle, hipCenter.x, 0.92);
|
||||
const shoulderSpan = Math.abs(rightShoulder.x - leftShoulder.x) * width;
|
||||
const torsoHeight = Math.max((hipCenter.y - shoulderCenter.y) * height, shoulderSpan * 0.8);
|
||||
const headRadius = Math.max(
|
||||
shoulderSpan * 0.28,
|
||||
Math.abs((leftEar?.x ?? nose.x - 0.04) - (rightEar?.x ?? nose.x + 0.04)) * width * 0.45,
|
||||
34,
|
||||
);
|
||||
const bodyWidth = Math.max(shoulderSpan * 1.05, headRadius * 1.8);
|
||||
const bodyHeight = Math.max(torsoHeight * 1.1, headRadius * 2.2);
|
||||
|
||||
return {
|
||||
headX: nose.x * width,
|
||||
headY: Math.min(nose.y * height, shoulderCenter.y * height - headRadius * 0.2),
|
||||
headRadius,
|
||||
bodyX: shoulderCenter.x * width,
|
||||
bodyY: shoulderCenter.y * height + bodyHeight * 0.48,
|
||||
bodyWidth,
|
||||
bodyHeight,
|
||||
shoulderY: shoulderCenter.y * height,
|
||||
footY: Math.max(ankleCenter.y * height, hipCenter.y * height + bodyHeight * 1.35),
|
||||
leftHandX: (leftWrist?.x ?? leftShoulder.x - 0.08) * width,
|
||||
leftHandY: (leftWrist?.y ?? shoulderCenter.y + 0.1) * height,
|
||||
rightHandX: (rightWrist?.x ?? rightShoulder.x + 0.08) * width,
|
||||
rightHandY: (rightWrist?.y ?? shoulderCenter.y + 0.1) * height,
|
||||
};
|
||||
}
|
||||
|
||||
function drawRoundedBody(ctx: CanvasRenderingContext2D, anchors: AvatarAnchors, fill: string) {
|
||||
const radius = Math.min(anchors.bodyWidth, anchors.bodyHeight) * 0.18;
|
||||
const left = anchors.bodyX - anchors.bodyWidth / 2;
|
||||
const top = anchors.bodyY - anchors.bodyHeight / 2;
|
||||
const right = left + anchors.bodyWidth;
|
||||
const bottom = top + anchors.bodyHeight;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(left + radius, top);
|
||||
ctx.lineTo(right - radius, top);
|
||||
ctx.quadraticCurveTo(right, top, right, top + radius);
|
||||
ctx.lineTo(right, bottom - radius);
|
||||
ctx.quadraticCurveTo(right, bottom, right - radius, bottom);
|
||||
ctx.lineTo(left + radius, bottom);
|
||||
ctx.quadraticCurveTo(left, bottom, left, bottom - radius);
|
||||
ctx.lineTo(left, top + radius);
|
||||
ctx.quadraticCurveTo(left, top, left + radius, top);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = fill;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
function drawLimbs(ctx: CanvasRenderingContext2D, anchors: AvatarAnchors, stroke: string) {
|
||||
ctx.strokeStyle = stroke;
|
||||
ctx.lineWidth = Math.max(anchors.headRadius * 0.22, 10);
|
||||
ctx.lineCap = "round";
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(anchors.bodyX - anchors.bodyWidth * 0.24, anchors.shoulderY + anchors.headRadius * 0.65);
|
||||
ctx.lineTo(anchors.leftHandX, anchors.leftHandY);
|
||||
ctx.moveTo(anchors.bodyX + anchors.bodyWidth * 0.24, anchors.shoulderY + anchors.headRadius * 0.65);
|
||||
ctx.lineTo(anchors.rightHandX, anchors.rightHandY);
|
||||
ctx.moveTo(anchors.bodyX - anchors.bodyWidth * 0.14, anchors.bodyY + anchors.bodyHeight * 0.42);
|
||||
ctx.lineTo(anchors.bodyX - anchors.bodyWidth * 0.18, anchors.footY);
|
||||
ctx.moveTo(anchors.bodyX + anchors.bodyWidth * 0.14, anchors.bodyY + anchors.bodyHeight * 0.42);
|
||||
ctx.lineTo(anchors.bodyX + anchors.bodyWidth * 0.18, anchors.footY);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
function getAvatarImage(key: AvatarKey) {
|
||||
if (typeof Image === "undefined") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cached = avatarImageCache.get(key);
|
||||
if (cached) {
|
||||
return cached.complete && cached.naturalWidth > 0 ? cached : null;
|
||||
}
|
||||
|
||||
const image = new Image();
|
||||
image.decoding = "async";
|
||||
image.src = AVATAR_VISUALS[key].src;
|
||||
avatarImageCache.set(key, image);
|
||||
return null;
|
||||
}
|
||||
|
||||
function drawAvatarBadge(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
anchors: AvatarAnchors,
|
||||
avatarKey: AvatarKey,
|
||||
sprite: HTMLImageElement | null,
|
||||
) {
|
||||
const visual = AVATAR_VISUALS[avatarKey];
|
||||
const headSize = anchors.headRadius * 2.5;
|
||||
const torsoBadge = Math.max(anchors.headRadius * 0.95, 40);
|
||||
|
||||
drawRoundedBody(ctx, anchors, visual.bodyFill);
|
||||
drawLimbs(ctx, anchors, visual.limbStroke);
|
||||
|
||||
ctx.save();
|
||||
ctx.fillStyle = visual.glow;
|
||||
ctx.beginPath();
|
||||
ctx.arc(anchors.headX, anchors.headY, anchors.headRadius * 1.16, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
|
||||
if (sprite) {
|
||||
ctx.drawImage(
|
||||
sprite,
|
||||
anchors.headX - headSize / 2,
|
||||
anchors.headY - headSize / 2,
|
||||
headSize,
|
||||
headSize,
|
||||
);
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.94;
|
||||
ctx.drawImage(
|
||||
sprite,
|
||||
anchors.bodyX - torsoBadge / 2,
|
||||
anchors.bodyY - torsoBadge / 2,
|
||||
torsoBadge,
|
||||
torsoBadge,
|
||||
);
|
||||
ctx.restore();
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.fillStyle = "rgba(255,255,255,0.92)";
|
||||
ctx.beginPath();
|
||||
ctx.arc(anchors.headX, anchors.headY, anchors.headRadius * 0.88, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = "rgba(17,24,39,0.82)";
|
||||
ctx.beginPath();
|
||||
ctx.arc(anchors.headX - anchors.headRadius * 0.22, anchors.headY - anchors.headRadius * 0.08, anchors.headRadius * 0.08, 0, Math.PI * 2);
|
||||
ctx.arc(anchors.headX + anchors.headRadius * 0.22, anchors.headY - anchors.headRadius * 0.08, anchors.headRadius * 0.08, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
function drawFullFigureAvatar(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
anchors: AvatarAnchors,
|
||||
avatarKey: AvatarKey,
|
||||
sprite: HTMLImageElement | null,
|
||||
) {
|
||||
const visual = AVATAR_VISUALS[avatarKey];
|
||||
const topY = anchors.headY - anchors.headRadius * 1.55 + anchors.bodyHeight * (visual.figureOffsetY ?? 0);
|
||||
const baseHeight = Math.max(anchors.footY - topY, anchors.bodyHeight * 2.35);
|
||||
const figureHeight = baseHeight * (visual.figureScale ?? 1);
|
||||
const aspectRatio = sprite?.naturalWidth && sprite?.naturalHeight
|
||||
? sprite.naturalWidth / sprite.naturalHeight
|
||||
: 0.72;
|
||||
const figureWidth = figureHeight * aspectRatio;
|
||||
const figureLeft = anchors.bodyX - figureWidth / 2;
|
||||
|
||||
ctx.save();
|
||||
ctx.fillStyle = visual.glow;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(
|
||||
anchors.bodyX,
|
||||
anchors.footY - anchors.headRadius * 0.1,
|
||||
Math.max(anchors.bodyWidth * 0.42, 34),
|
||||
Math.max(anchors.headRadius * 0.22, 10),
|
||||
0,
|
||||
0,
|
||||
Math.PI * 2,
|
||||
);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
|
||||
if (sprite) {
|
||||
ctx.save();
|
||||
ctx.shadowColor = "rgba(15,23,42,0.28)";
|
||||
ctx.shadowBlur = 16;
|
||||
ctx.shadowOffsetY = 10;
|
||||
ctx.drawImage(sprite, figureLeft, topY, figureWidth, figureHeight);
|
||||
ctx.restore();
|
||||
return;
|
||||
}
|
||||
|
||||
drawRoundedBody(ctx, anchors, visual.bodyFill);
|
||||
drawLimbs(ctx, anchors, visual.limbStroke);
|
||||
}
|
||||
|
||||
export function renderLiveCameraOverlayToContext(
|
||||
ctx: CanvasRenderingContext2D | null,
|
||||
width: number,
|
||||
height: number,
|
||||
landmarks: PosePoint[] | undefined,
|
||||
avatarState?: AvatarRenderState,
|
||||
options?: { clear?: boolean },
|
||||
) {
|
||||
if (!ctx) return;
|
||||
if (options?.clear !== false) {
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
}
|
||||
if (!landmarks) return;
|
||||
|
||||
if (avatarState?.enabled) {
|
||||
const anchors = getAvatarAnchors(landmarks, width, height);
|
||||
if (anchors) {
|
||||
const sprite = getAvatarImage(avatarState.avatarKey);
|
||||
const visual = AVATAR_VISUALS[avatarState.avatarKey];
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.95;
|
||||
if (visual.renderMode === "full-figure") {
|
||||
drawFullFigureAvatar(ctx, anchors, avatarState.avatarKey, sprite);
|
||||
} else {
|
||||
drawAvatarBadge(ctx, anchors, avatarState.avatarKey, sprite);
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
if (visual.renderMode !== "full-figure") {
|
||||
ctx.save();
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.16)";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.setLineDash([8, 10]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(anchors.bodyX, anchors.shoulderY - anchors.headRadius * 1.25);
|
||||
ctx.lineTo(anchors.bodyX, anchors.footY);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const poseConnections: Array<[number, number]> = [
|
||||
[11, 12], [11, 13], [13, 15], [12, 14], [14, 16],
|
||||
[11, 23], [12, 24], [23, 24], [23, 25], [24, 26],
|
||||
[25, 27], [26, 28], [15, 17], [16, 18], [15, 19],
|
||||
[16, 20], [17, 19], [18, 20],
|
||||
];
|
||||
|
||||
ctx.strokeStyle = "rgba(25, 211, 155, 0.9)";
|
||||
ctx.lineWidth = 3;
|
||||
poseConnections.forEach(([from, to]) => {
|
||||
const start = landmarks[from];
|
||||
const end = landmarks[to];
|
||||
if (!start || !end || (start.visibility ?? 1) < 0.25 || (end.visibility ?? 1) < 0.25) return;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(start.x * width, start.y * height);
|
||||
ctx.lineTo(end.x * width, end.y * height);
|
||||
ctx.stroke();
|
||||
});
|
||||
|
||||
landmarks.forEach((point, index) => {
|
||||
if ((point.visibility ?? 1) < 0.25) return;
|
||||
ctx.fillStyle = index >= 11 && index <= 16 ? "rgba(253, 224, 71, 0.95)" : "rgba(255,255,255,0.88)";
|
||||
ctx.beginPath();
|
||||
ctx.arc(point.x * width, point.y * height, index >= 11 && index <= 16 ? 5 : 4, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
});
|
||||
}
|
||||
|
||||
export function drawLiveCameraOverlay(
|
||||
canvas: HTMLCanvasElement | null,
|
||||
landmarks: PosePoint[] | undefined,
|
||||
avatarState?: AvatarRenderState,
|
||||
) {
|
||||
const ctx = canvas?.getContext("2d");
|
||||
if (!canvas || !ctx) return;
|
||||
renderLiveCameraOverlayToContext(ctx, canvas.width, canvas.height, landmarks, avatarState, { clear: true });
|
||||
}
|
||||
17
client/src/lib/media.test.ts
普通文件
@@ -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);
|
||||
});
|
||||
});
|
||||
216
client/src/lib/media.ts
普通文件
@@ -0,0 +1,216 @@
|
||||
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;
|
||||
liveFrameUrl?: string;
|
||||
liveFrameUpdatedAt?: string;
|
||||
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 uploadMediaLiveFrame(sessionId: string, blob: Blob) {
|
||||
return request<{ session: MediaSession }>(`/sessions/${sessionId}/live-frame`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": blob.type || "image/jpeg" },
|
||||
body: blob,
|
||||
});
|
||||
}
|
||||
|
||||
export async function uploadMediaSegment(
|
||||
sessionId: string,
|
||||
sequence: number,
|
||||
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 getMediaAssetUrl(path: string) {
|
||||
return `${MEDIA_BASE}${path.startsWith("/") ? path : `/${path}`}`;
|
||||
}
|
||||
|
||||
export function formatRecordingTime(milliseconds: number) {
|
||||
const totalSeconds = Math.max(0, Math.floor(milliseconds / 1000));
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
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}>
|
||||
|
||||
317
client/src/pages/AdminConsole.tsx
普通文件
@@ -0,0 +1,317 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/_core/hooks/useAuth";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { formatDateTimeShanghai } from "@/lib/time";
|
||||
import { toast } from "sonner";
|
||||
import { Activity, Database, RefreshCw, Settings2, Shield, Sparkles, Users } from "lucide-react";
|
||||
|
||||
export default function AdminConsole() {
|
||||
const { user } = useAuth();
|
||||
const utils = trpc.useUtils();
|
||||
const usersQuery = trpc.admin.users.useQuery({ limit: 100 }, { enabled: user?.role === "admin" });
|
||||
const tasksQuery = trpc.admin.tasks.useQuery({ limit: 100 }, { enabled: user?.role === "admin" });
|
||||
const liveSessionsQuery = trpc.admin.liveSessions.useQuery({ limit: 50 }, { enabled: user?.role === "admin" });
|
||||
const settingsQuery = trpc.admin.settings.useQuery(undefined, { enabled: user?.role === "admin" });
|
||||
const auditQuery = trpc.admin.auditLogs.useQuery({ limit: 100 }, { enabled: user?.role === "admin" });
|
||||
|
||||
const [settingsDrafts, setSettingsDrafts] = useState<Record<string, string>>({});
|
||||
|
||||
const refreshAllMutation = trpc.admin.refreshAllNtrp.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("已提交全量 NTRP 刷新任务");
|
||||
utils.admin.tasks.invalidate();
|
||||
},
|
||||
onError: (error) => toast.error(`提交失败: ${error.message}`),
|
||||
});
|
||||
const refreshUserMutation = trpc.admin.refreshUserNtrp.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("已提交用户 NTRP 刷新任务");
|
||||
utils.admin.tasks.invalidate();
|
||||
},
|
||||
onError: (error) => toast.error(`提交失败: ${error.message}`),
|
||||
});
|
||||
const refreshUserNowMutation = trpc.admin.refreshUserNtrpNow.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("用户 NTRP 已即时刷新");
|
||||
utils.admin.users.invalidate();
|
||||
utils.admin.auditLogs.invalidate();
|
||||
},
|
||||
onError: (error) => toast.error(`即时刷新失败: ${error.message}`),
|
||||
});
|
||||
const updateSettingMutation = trpc.admin.updateSetting.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("设置已更新");
|
||||
utils.admin.settings.invalidate();
|
||||
utils.admin.auditLogs.invalidate();
|
||||
},
|
||||
onError: (error) => toast.error(`设置更新失败: ${error.message}`),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const drafts: Record<string, string> = {};
|
||||
(settingsQuery.data || []).forEach((item: any) => {
|
||||
drafts[item.settingKey] = JSON.stringify(item.value ?? null);
|
||||
});
|
||||
setSettingsDrafts(drafts);
|
||||
}, [settingsQuery.data]);
|
||||
|
||||
const totals = useMemo(() => ({
|
||||
users: (usersQuery.data || []).length,
|
||||
tasks: (tasksQuery.data || []).length,
|
||||
sessions: (liveSessionsQuery.data || []).length,
|
||||
}), [liveSessionsQuery.data, tasksQuery.data, usersQuery.data]);
|
||||
|
||||
if (user?.role !== "admin") {
|
||||
return (
|
||||
<Alert>
|
||||
<Shield className="h-4 w-4" />
|
||||
<AlertTitle>需要管理员权限</AlertTitle>
|
||||
<AlertDescription>当前账号没有管理系统访问权限。</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="rounded-[28px] border border-border/60 bg-[radial-gradient(circle_at_top_left,_rgba(16,185,129,0.12),_transparent_30%),linear-gradient(180deg,rgba(255,255,255,1),rgba(248,250,252,0.96))] p-5 shadow-sm md:p-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">管理系统</h1>
|
||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground">
|
||||
这里集中查看用户、后台任务、实时分析记录、全局设置和审计日志。H1 管理员可以提交和执行用户级评分刷新。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button onClick={() => refreshAllMutation.mutate()} disabled={refreshAllMutation.isPending} className="gap-2">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
刷新全部 NTRP
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardContent className="pt-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<Users className="h-5 w-5 text-emerald-700" />
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">用户数</div>
|
||||
<div className="mt-1 text-xl font-semibold">{totals.users}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardContent className="pt-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<Activity className="h-5 w-5 text-sky-700" />
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">后台任务</div>
|
||||
<div className="mt-1 text-xl font-semibold">{totals.tasks}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardContent className="pt-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<Sparkles className="h-5 w-5 text-orange-700" />
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">实时分析会话</div>
|
||||
<div className="mt-1 text-xl font-semibold">{totals.sessions}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="users" className="space-y-4">
|
||||
<TabsList className="grid w-full grid-cols-5">
|
||||
<TabsTrigger value="users">用户</TabsTrigger>
|
||||
<TabsTrigger value="tasks">任务</TabsTrigger>
|
||||
<TabsTrigger value="sessions">会话</TabsTrigger>
|
||||
<TabsTrigger value="settings">设置</TabsTrigger>
|
||||
<TabsTrigger value="audit">审计</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="users">
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">用户列表</CardTitle>
|
||||
<CardDescription>支持排队刷新和即时刷新单个用户的 NTRP。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{(usersQuery.data || []).map((item: any) => (
|
||||
<div key={item.id} className="rounded-2xl border border-border/60 bg-muted/20 p-4">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{item.name}</span>
|
||||
<Badge variant="outline">{item.role}</Badge>
|
||||
<Badge variant="outline">NTRP {Number(item.ntrpRating || 1.5).toFixed(1)}</Badge>
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-muted-foreground">
|
||||
训练 {item.totalSessions || 0} 次 · {item.totalMinutes || 0} 分钟 · 连练 {item.currentStreak || 0} 天
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => refreshUserMutation.mutate({ userId: item.id })}>
|
||||
排队刷新
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => refreshUserNowMutation.mutate({ userId: item.id })}>
|
||||
立即刷新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="tasks">
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">后台任务</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{(tasksQuery.data || []).map((task: any) => (
|
||||
<div key={task.id} className="rounded-2xl border border-border/60 bg-muted/20 p-4">
|
||||
<div className="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{task.title}</span>
|
||||
<Badge variant="outline">{task.type}</Badge>
|
||||
<Badge variant={task.status === "failed" ? "destructive" : task.status === "succeeded" ? "secondary" : "outline"}>
|
||||
{task.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-muted-foreground">
|
||||
{task.userName || task.userId} · {formatDateTimeShanghai(task.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-[180px]">
|
||||
<div className="mb-2 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{task.message || "无描述"}</span>
|
||||
<span>{task.progress || 0}%</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-muted">
|
||||
<div className="h-full rounded-full bg-emerald-500" style={{ width: `${task.progress || 0}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="sessions">
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">实时分析会话</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{(liveSessionsQuery.data || []).map((session: any) => (
|
||||
<div key={session.id} className="rounded-2xl border border-border/60 bg-muted/20 p-4">
|
||||
<div className="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{session.title}</span>
|
||||
<Badge variant="outline">{session.userName || session.userId}</Badge>
|
||||
<Badge variant="outline">{session.sessionMode}</Badge>
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-muted-foreground">
|
||||
主动作 {session.dominantAction || "unknown"} · 有效片段 {session.effectiveSegments || 0}/{session.totalSegments || 0}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{Math.round(session.overallScore || 0)} 分 · {Math.round((session.durationMs || 0) / 1000)} 秒
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="settings">
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Settings2 className="h-4 w-4 text-primary" />
|
||||
全局设置
|
||||
</CardTitle>
|
||||
<CardDescription>设置值以 JSON 形式保存,适合阈值、开关和结构化配置。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{(settingsQuery.data || []).map((setting: any) => (
|
||||
<div key={setting.settingKey} className="rounded-2xl border border-border/60 bg-muted/20 p-4">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-end">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium">{setting.label}</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">{setting.description}</div>
|
||||
<Input
|
||||
value={settingsDrafts[setting.settingKey] || ""}
|
||||
onChange={(event) => setSettingsDrafts((current) => ({ ...current, [setting.settingKey]: event.target.value }))}
|
||||
className="mt-3 h-11 rounded-2xl"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
className="gap-2"
|
||||
onClick={() => {
|
||||
try {
|
||||
const parsed = JSON.parse(settingsDrafts[setting.settingKey] || "null");
|
||||
updateSettingMutation.mutate({ settingKey: setting.settingKey, value: parsed });
|
||||
} catch {
|
||||
toast.error("设置值必须是合法 JSON");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Database className="h-4 w-4" />
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="audit">
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">审计日志</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{(auditQuery.data || []).map((item: any) => (
|
||||
<div key={item.id} className="rounded-2xl border border-border/60 bg-muted/20 p-4">
|
||||
<div className="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{item.actionType}</span>
|
||||
<Badge variant="outline">{item.entityType}</Badge>
|
||||
{item.targetUserId ? <Badge variant="outline">目标用户 {item.targetUserId}</Badge> : null}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-muted-foreground">
|
||||
管理员 {item.adminName || item.adminUserId} · {formatDateTimeShanghai(item.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
{item.entityId ? <div className="text-sm text-muted-foreground">实体 {item.entityId}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,10 +7,12 @@ import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { useBackgroundTask } from "@/hooks/useBackgroundTask";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Upload, Video, Loader2, Play, Pause, RotateCcw,
|
||||
Zap, Target, Activity, TrendingUp, Eye
|
||||
Zap, Target, Activity, TrendingUp, Eye, ListTodo
|
||||
} from "lucide-react";
|
||||
import { Streamdown } from "streamdown";
|
||||
|
||||
@@ -39,6 +41,8 @@ export default function Analysis() {
|
||||
const [analysisProgress, setAnalysisProgress] = useState(0);
|
||||
const [analysisResult, setAnalysisResult] = useState<AnalysisResult | null>(null);
|
||||
const [corrections, setCorrections] = useState<string>("");
|
||||
const [correctionReport, setCorrectionReport] = useState<any>(null);
|
||||
const [correctionTaskId, setCorrectionTaskId] = useState<string | null>(null);
|
||||
const [showSkeleton, setShowSkeleton] = useState(false);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
@@ -55,7 +59,16 @@ export default function Analysis() {
|
||||
utils.rating.history.invalidate();
|
||||
},
|
||||
});
|
||||
const correctionMutation = trpc.analysis.getCorrections.useMutation();
|
||||
const correctionMutation = trpc.analysis.getCorrections.useMutation({
|
||||
onSuccess: (data) => {
|
||||
setCorrectionTaskId(data.taskId);
|
||||
toast.success("动作纠正任务已提交");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("动作纠正任务提交失败: " + error.message);
|
||||
},
|
||||
});
|
||||
const correctionTaskQuery = useBackgroundTask(correctionTaskId);
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
@@ -73,8 +86,22 @@ export default function Analysis() {
|
||||
setVideoUrl(URL.createObjectURL(file));
|
||||
setAnalysisResult(null);
|
||||
setCorrections("");
|
||||
setCorrectionReport(null);
|
||||
setCorrectionTaskId(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (correctionTaskQuery.data?.status === "succeeded") {
|
||||
const result = correctionTaskQuery.data.result as { corrections?: string; report?: any } | null;
|
||||
setCorrections(result?.corrections || "暂无建议");
|
||||
setCorrectionReport(result?.report || null);
|
||||
setCorrectionTaskId(null);
|
||||
} else if (correctionTaskQuery.data?.status === "failed") {
|
||||
toast.error(`动作纠正失败: ${correctionTaskQuery.data.error || "未知错误"}`);
|
||||
setCorrectionTaskId(null);
|
||||
}
|
||||
}, [correctionTaskQuery.data]);
|
||||
|
||||
const analyzeVideo = useCallback(async () => {
|
||||
if (!videoRef.current || !canvasRef.current || !videoFile) return;
|
||||
|
||||
@@ -267,6 +294,8 @@ export default function Analysis() {
|
||||
};
|
||||
|
||||
setAnalysisResult(result);
|
||||
setCorrections("");
|
||||
setCorrectionReport(null);
|
||||
|
||||
// Upload video and save analysis
|
||||
const reader = new FileReader();
|
||||
@@ -293,13 +322,12 @@ export default function Analysis() {
|
||||
};
|
||||
reader.readAsDataURL(videoFile);
|
||||
|
||||
// Get AI corrections
|
||||
const snapshots = await extractFrameSnapshots(videoUrl);
|
||||
correctionMutation.mutate({
|
||||
poseMetrics: result.poseMetrics,
|
||||
exerciseType,
|
||||
detectedIssues: result.detectedIssues,
|
||||
}, {
|
||||
onSuccess: (data) => setCorrections(data.corrections as string),
|
||||
imageDataUrls: snapshots,
|
||||
});
|
||||
|
||||
pose.close();
|
||||
@@ -318,6 +346,16 @@ export default function Analysis() {
|
||||
<p className="text-muted-foreground text-sm mt-1">AI姿势识别与矫正反馈</p>
|
||||
</div>
|
||||
|
||||
{(correctionMutation.isPending || correctionTaskQuery.data?.status === "queued" || correctionTaskQuery.data?.status === "running") ? (
|
||||
<Alert>
|
||||
<ListTodo className="h-4 w-4" />
|
||||
<AlertTitle>后台任务执行中</AlertTitle>
|
||||
<AlertDescription>
|
||||
多模态动作纠正正在后台生成。可以先查看分析结果,完成后任务中心和当前页面都会更新。
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{/* Upload section */}
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
@@ -532,7 +570,12 @@ export default function Analysis() {
|
||||
{correctionMutation.isPending ? (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span className="text-sm">AI正在生成矫正建议...</span>
|
||||
<span className="text-sm">正在提交动作纠正任务...</span>
|
||||
</div>
|
||||
) : correctionTaskQuery.data?.status === "queued" || correctionTaskQuery.data?.status === "running" ? (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span className="text-sm">{correctionTaskQuery.data.message || "AI正在后台生成多模态矫正建议..."}</span>
|
||||
</div>
|
||||
) : corrections ? (
|
||||
<div className="prose prose-sm max-w-none">
|
||||
@@ -543,6 +586,24 @@ export default function Analysis() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{correctionReport?.priorityFixes?.length ? (
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">优先修正项</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{correctionReport.priorityFixes.map((item: any, index: number) => (
|
||||
<div key={`${item.title}-${index}`} className="rounded-xl border p-3">
|
||||
<p className="font-medium text-sm">{item.title}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{item.why}</p>
|
||||
<p className="mt-2 text-sm"><strong>练习:</strong>{item.howToPractice}</p>
|
||||
<p className="mt-1 text-xs text-primary"><strong>达标:</strong>{item.successMetric}</p>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -667,3 +728,39 @@ function averageAngles(anglesHistory: any[]) {
|
||||
}
|
||||
return avg;
|
||||
}
|
||||
|
||||
async function extractFrameSnapshots(sourceUrl: string) {
|
||||
if (!sourceUrl) return [];
|
||||
|
||||
const video = document.createElement("video");
|
||||
video.src = sourceUrl;
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
video.crossOrigin = "anonymous";
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
video.onloadedmetadata = () => resolve();
|
||||
video.onerror = () => reject(new Error("无法读取视频元数据"));
|
||||
});
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = video.videoWidth || 1280;
|
||||
canvas.height = video.videoHeight || 720;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return [];
|
||||
|
||||
const duration = Math.max(video.duration || 0, 1);
|
||||
const checkpoints = [0.15, 0.5, 0.85].map((ratio) => Math.min(duration - 0.05, duration * ratio)).filter((time, index, array) => time >= 0 && array.indexOf(time) === index);
|
||||
const snapshots: string[] = [];
|
||||
|
||||
for (const checkpoint of checkpoints) {
|
||||
await new Promise<void>((resolve) => {
|
||||
video.onseeked = () => resolve();
|
||||
video.currentTime = checkpoint;
|
||||
});
|
||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
snapshots.push(canvas.toDataURL("image/jpeg", 0.82));
|
||||
}
|
||||
|
||||
return snapshots;
|
||||
}
|
||||
|
||||
66
client/src/pages/ChangeLog.tsx
普通文件
@@ -0,0 +1,66 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { CHANGE_LOG_ENTRIES } from "@/lib/changelog";
|
||||
import { formatDateShanghai } from "@/lib/time";
|
||||
import { GitBranch, ListChecks, ScrollText } from "lucide-react";
|
||||
|
||||
export default function ChangeLog() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="rounded-[28px] border border-border/60 bg-[radial-gradient(circle_at_top_left,_rgba(14,165,233,0.1),_transparent_28%),linear-gradient(180deg,rgba(255,255,255,1),rgba(248,250,252,0.96))] p-6 shadow-sm">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
||||
<ScrollText className="h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">更新日志</h1>
|
||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">
|
||||
这里会按版本记录已上线的新功能、对应仓库版本和验证结果。后续每次改动测试通过并提交后,都会继续追加到这里。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="space-y-4">
|
||||
{CHANGE_LOG_ENTRIES.map((entry) => (
|
||||
<Card key={`${entry.version}-${entry.repoVersion}`} className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg">{entry.version}</CardTitle>
|
||||
<CardDescription className="mt-2">{entry.summary}</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="secondary">{formatDateShanghai(entry.releaseDate)}</Badge>
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<GitBranch className="h-3 w-3" />
|
||||
{entry.repoVersion}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium">上线内容</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{entry.features.map((feature) => (
|
||||
<Badge key={feature} variant="secondary">{feature}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<ListChecks className="h-4 w-4 text-primary" />
|
||||
验证记录
|
||||
</div>
|
||||
<ul className="mt-2 list-disc space-y-1 pl-5 text-sm text-muted-foreground">
|
||||
{entry.tests.map((item) => <li key={item}>{item}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,238 +1,290 @@
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { useMemo } from "react";
|
||||
import { useAuth } from "@/_core/hooks/useAuth";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { toast } from "sonner";
|
||||
import { Flame, Calendar, Award, CheckCircle2, Lock, Star, Trophy, Zap } from "lucide-react";
|
||||
import { useState, useMemo } from "react";
|
||||
import { formatDateShanghai } from "@/lib/time";
|
||||
import { Award, Calendar, Flame, Radar, Sparkles, Swords, Trophy } from "lucide-react";
|
||||
|
||||
const categoryLabels: Record<string, { label: string; color: string }> = {
|
||||
milestone: { label: "里程碑", color: "bg-blue-100 text-blue-700" },
|
||||
training: { label: "训练", color: "bg-green-100 text-green-700" },
|
||||
video: { label: "视频", color: "bg-purple-100 text-purple-700" },
|
||||
analysis: { label: "分析", color: "bg-orange-100 text-orange-700" },
|
||||
streak: { label: "连续打卡", color: "bg-red-100 text-red-700" },
|
||||
rating: { label: "评分", color: "bg-yellow-100 text-yellow-700" },
|
||||
const CATEGORY_META: Record<string, { label: string; tone: string }> = {
|
||||
consistency: { label: "稳定性", tone: "bg-rose-500/10 text-rose-700" },
|
||||
volume: { label: "训练量", tone: "bg-emerald-500/10 text-emerald-700" },
|
||||
technique: { label: "动作质量", tone: "bg-sky-500/10 text-sky-700" },
|
||||
recording: { label: "录制归档", tone: "bg-amber-500/10 text-amber-700" },
|
||||
analysis: { label: "分析进度", tone: "bg-indigo-500/10 text-indigo-700" },
|
||||
quality: { label: "高分片段", tone: "bg-fuchsia-500/10 text-fuchsia-700" },
|
||||
rating: { label: "评分", tone: "bg-violet-500/10 text-violet-700" },
|
||||
pk: { label: "训练 PK", tone: "bg-orange-500/10 text-orange-700" },
|
||||
plan: { label: "计划匹配", tone: "bg-cyan-500/10 text-cyan-700" },
|
||||
tutorial: { label: "教程路径", tone: "bg-violet-500/10 text-violet-700" },
|
||||
};
|
||||
|
||||
function getProgressText(item: any) {
|
||||
if (item.unlockedAt) {
|
||||
return `已于 ${formatDateShanghai(item.unlockedAt)} 解锁`;
|
||||
}
|
||||
return `${Math.round(item.currentValue || 0)} / ${Math.round(item.targetValue || 0)}`;
|
||||
}
|
||||
|
||||
export default function Checkin() {
|
||||
const { user } = useAuth();
|
||||
const [notes, setNotes] = useState("");
|
||||
const [checkinDone, setCheckinDone] = useState(false);
|
||||
const achievementQuery = trpc.achievement.list.useQuery();
|
||||
const statsQuery = trpc.profile.stats.useQuery();
|
||||
|
||||
const { data: todayCheckin, isLoading: loadingToday } = trpc.checkin.today.useQuery();
|
||||
const { data: checkinHistory } = trpc.checkin.history.useQuery({ limit: 60 });
|
||||
const { data: badges, isLoading: loadingBadges, refetch: refetchBadges } = trpc.badge.list.useQuery();
|
||||
const achievements = useMemo(() => achievementQuery.data ?? [], [achievementQuery.data]);
|
||||
const stats = statsQuery.data;
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
const checkinMutation = trpc.checkin.do.useMutation({
|
||||
onSuccess: (data) => {
|
||||
if (data.alreadyCheckedIn) {
|
||||
toast.info("今天已经打卡过了!");
|
||||
} else {
|
||||
toast.success(`打卡成功!连续 ${data.streak} 天 🔥`);
|
||||
if (data.newBadges && data.newBadges.length > 0) {
|
||||
data.newBadges.forEach((key: string) => {
|
||||
toast.success(`🏆 获得新徽章!`, { duration: 5000 });
|
||||
});
|
||||
}
|
||||
setCheckinDone(true);
|
||||
}
|
||||
utils.checkin.today.invalidate();
|
||||
utils.checkin.history.invalidate();
|
||||
refetchBadges();
|
||||
},
|
||||
onError: () => toast.error("打卡失败,请重试"),
|
||||
});
|
||||
|
||||
const handleCheckin = () => {
|
||||
checkinMutation.mutate({ notes: notes || undefined });
|
||||
};
|
||||
|
||||
const alreadyCheckedIn = !!todayCheckin || checkinDone;
|
||||
|
||||
// Build calendar heatmap for last 60 days
|
||||
const heatmapData = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
(checkinHistory || []).forEach((c: any) => {
|
||||
map.set(c.checkinDate, c.streakCount);
|
||||
});
|
||||
const days = [];
|
||||
for (let i = 59; i >= 0; i--) {
|
||||
const d = new Date(Date.now() - i * 86400000);
|
||||
const key = d.toISOString().slice(0, 10);
|
||||
days.push({ date: key, checked: map.has(key), streak: map.get(key) || 0, day: d.getDate() });
|
||||
}
|
||||
return days;
|
||||
}, [checkinHistory]);
|
||||
|
||||
const earnedCount = badges?.filter((b: any) => b.earned).length || 0;
|
||||
const totalCount = badges?.length || 0;
|
||||
|
||||
// Group badges by category
|
||||
const groupedBadges = useMemo(() => {
|
||||
const groupedAchievements = useMemo(() => {
|
||||
const groups: Record<string, any[]> = {};
|
||||
(badges || []).forEach((b: any) => {
|
||||
if (!groups[b.category]) groups[b.category] = [];
|
||||
groups[b.category].push(b);
|
||||
achievements.forEach((item: any) => {
|
||||
const key = item.category || "other";
|
||||
if (!groups[key]) groups[key] = [];
|
||||
groups[key].push(item);
|
||||
});
|
||||
return groups;
|
||||
}, [badges]);
|
||||
}, [achievements]);
|
||||
|
||||
if (loadingToday || loadingBadges) {
|
||||
const unlockedCount = achievements.filter((item: any) => item.unlocked).length;
|
||||
const nextTarget = achievements
|
||||
.filter((item: any) => !item.unlocked)
|
||||
.sort((a: any, b: any) => (b.progressPct || 0) - (a.progressPct || 0))[0];
|
||||
|
||||
const heatmapDays = useMemo(() => {
|
||||
const dayMap = new Map<string, any>();
|
||||
(stats?.dailyTraining || []).forEach((day: any) => dayMap.set(day.trainingDate, day));
|
||||
const days = [];
|
||||
for (let offset = 34; offset >= 0; offset -= 1) {
|
||||
const current = new Date(Date.now() - offset * 24 * 60 * 60 * 1000);
|
||||
const key = current.toISOString().slice(0, 10);
|
||||
const entry = dayMap.get(key);
|
||||
days.push({
|
||||
date: key,
|
||||
sessions: entry?.sessionCount || 0,
|
||||
minutes: entry?.totalMinutes || 0,
|
||||
score: entry?.averageScore || 0,
|
||||
day: current.getDate(),
|
||||
});
|
||||
}
|
||||
return days;
|
||||
}, [stats?.dailyTraining]);
|
||||
|
||||
if (achievementQuery.isLoading || statsQuery.isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map(i => <Skeleton key={i} className="h-32 w-full" />)}
|
||||
<Skeleton className="h-40 w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
<Skeleton className="h-80 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">训练打卡</h1>
|
||||
<p className="text-muted-foreground text-sm mt-1">坚持每日打卡,解锁成就徽章</p>
|
||||
</div>
|
||||
|
||||
{/* Check-in card */}
|
||||
<Card className={`border-0 shadow-sm ${alreadyCheckedIn ? "bg-green-50/50" : "bg-gradient-to-br from-primary/5 to-primary/10"}`}>
|
||||
<CardContent className="py-6">
|
||||
<div className="flex flex-col sm:flex-row items-center gap-4 sm:gap-6">
|
||||
<div className={`h-20 w-20 rounded-full flex items-center justify-center shrink-0 ${
|
||||
alreadyCheckedIn ? "bg-green-100" : "bg-primary/10"
|
||||
}`}>
|
||||
{alreadyCheckedIn ? (
|
||||
<CheckCircle2 className="h-10 w-10 text-green-600" />
|
||||
) : (
|
||||
<Flame className="h-10 w-10 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 text-center sm:text-left">
|
||||
<h2 className="text-xl font-bold">
|
||||
{alreadyCheckedIn ? "今日已打卡 ✅" : "今日尚未打卡"}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{alreadyCheckedIn
|
||||
? `当前连续打卡 ${todayCheckin?.streakCount || (checkinHistory?.[0] as any)?.streakCount || 1} 天`
|
||||
: "记录今天的训练,保持连续打卡!"
|
||||
}
|
||||
</p>
|
||||
{!alreadyCheckedIn && (
|
||||
<div className="mt-3 space-y-2">
|
||||
<Textarea
|
||||
placeholder="今天练了什么?(可选)"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
className="max-w-md text-sm resize-none"
|
||||
rows={2}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleCheckin}
|
||||
disabled={checkinMutation.isPending}
|
||||
className="gap-2"
|
||||
size="lg"
|
||||
>
|
||||
<Flame className="h-4 w-4" />
|
||||
{checkinMutation.isPending ? "打卡中..." : "立即打卡"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 shrink-0">
|
||||
<div className="text-center px-3 py-2 rounded-lg bg-white/80">
|
||||
<p className="text-2xl font-bold text-primary">{user?.currentStreak || todayCheckin?.streakCount || 0}</p>
|
||||
<p className="text-[10px] text-muted-foreground">连续天数</p>
|
||||
</div>
|
||||
<div className="text-center px-3 py-2 rounded-lg bg-white/80">
|
||||
<p className="text-2xl font-bold text-orange-500">{user?.longestStreak || 0}</p>
|
||||
<p className="text-[10px] text-muted-foreground">最长连续</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Calendar heatmap */}
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4 text-primary" />
|
||||
打卡日历(近60天)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-10 sm:grid-cols-15 lg:grid-cols-20 gap-1">
|
||||
{heatmapData.map((d, i) => (
|
||||
<div
|
||||
key={i}
|
||||
title={`${d.date}${d.checked ? ` · 连续${d.streak}天` : ""}`}
|
||||
className={`aspect-square rounded-sm text-[9px] flex items-center justify-center transition-colors ${
|
||||
d.checked
|
||||
? d.streak >= 7 ? "bg-green-600 text-white" : d.streak >= 3 ? "bg-green-400 text-white" : "bg-green-200 text-green-800"
|
||||
: "bg-muted/50 text-muted-foreground/50"
|
||||
}`}
|
||||
>
|
||||
{d.day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-3 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1"><div className="h-3 w-3 rounded-sm bg-muted/50" />未打卡</div>
|
||||
<div className="flex items-center gap-1"><div className="h-3 w-3 rounded-sm bg-green-200" />1-2天</div>
|
||||
<div className="flex items-center gap-1"><div className="h-3 w-3 rounded-sm bg-green-400" />3-6天</div>
|
||||
<div className="flex items-center gap-1"><div className="h-3 w-3 rounded-sm bg-green-600" />7天+</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Badges section */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<section className="rounded-[28px] border border-border/60 bg-[radial-gradient(circle_at_top_left,_rgba(244,63,94,0.12),_transparent_28%),linear-gradient(180deg,rgba(255,255,255,1),rgba(248,250,252,0.96))] p-5 shadow-sm md:p-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold flex items-center gap-2">
|
||||
<Award className="h-5 w-5 text-primary" />
|
||||
成就徽章
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">已解锁 {earnedCount}/{totalCount}</p>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">成就系统</h1>
|
||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground">
|
||||
每次训练、录制、实时分析和综合评分都会自动累计进度,持续生成新的阶段目标与解锁记录。
|
||||
</p>
|
||||
</div>
|
||||
<div className="h-2 w-32 bg-muted rounded-full overflow-hidden">
|
||||
<div className="h-full bg-primary rounded-full transition-all" style={{ width: `${totalCount > 0 ? (earnedCount / totalCount) * 100 : 0}%` }} />
|
||||
<div className="grid grid-cols-3 gap-2 text-center text-xs">
|
||||
<div className="rounded-2xl border border-border/60 bg-background/90 px-3 py-3">
|
||||
<div className="text-muted-foreground">已解锁</div>
|
||||
<div className="mt-2 text-xl font-semibold">{unlockedCount}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/60 bg-background/90 px-3 py-3">
|
||||
<div className="text-muted-foreground">当前连练</div>
|
||||
<div className="mt-2 text-xl font-semibold">{user?.currentStreak || 0}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/60 bg-background/90 px-3 py-3">
|
||||
<div className="text-muted-foreground">最长连练</div>
|
||||
<div className="mt-2 text-xl font-semibold">{user?.longestStreak || 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{Object.entries(groupedBadges).map(([category, items]) => {
|
||||
const catInfo = categoryLabels[category] || { label: category, color: "bg-gray-100 text-gray-700" };
|
||||
return (
|
||||
<div key={category} className="mb-4">
|
||||
<Badge className={`${catInfo.color} mb-2 text-xs`}>{catInfo.label}</Badge>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
{items.map((badge: any) => (
|
||||
<Card key={badge.key} className={`border-0 shadow-sm transition-all ${
|
||||
badge.earned ? "bg-white" : "bg-muted/30 opacity-60"
|
||||
}`}>
|
||||
<CardContent className="p-3 text-center">
|
||||
<div className="text-3xl mb-1">{badge.icon}</div>
|
||||
<p className="text-xs font-medium truncate">{badge.name}</p>
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5 line-clamp-2">{badge.description}</p>
|
||||
{badge.earned ? (
|
||||
<p className="text-[10px] text-green-600 mt-1">
|
||||
✅ {new Date(badge.earnedAt).toLocaleDateString("zh-CN")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex items-center justify-center gap-1 mt-1 text-[10px] text-muted-foreground">
|
||||
<Lock className="h-2.5 w-2.5" />未解锁
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1.4fr)_minmax(320px,0.9fr)]">
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Calendar className="h-4 w-4 text-primary" />
|
||||
训练热力图
|
||||
</CardTitle>
|
||||
<CardDescription>最近 35 天内,只要有训练、录制或分析写回,就会点亮对应日期。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-7 gap-2 sm:grid-cols-10 lg:grid-cols-7 xl:grid-cols-10">
|
||||
{heatmapDays.map((day) => {
|
||||
const level =
|
||||
day.sessions === 0 ? "bg-muted/45 text-muted-foreground/50" :
|
||||
day.minutes >= 45 ? "bg-emerald-600 text-white" :
|
||||
day.minutes >= 20 ? "bg-emerald-400 text-white" :
|
||||
"bg-emerald-200 text-emerald-900";
|
||||
return (
|
||||
<div
|
||||
key={day.date}
|
||||
title={`${day.date} · ${day.minutes} 分钟 · ${day.sessions} 次`}
|
||||
className={`aspect-square rounded-xl text-[11px] transition-colors flex items-center justify-center ${level}`}
|
||||
>
|
||||
{day.day}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap items-center gap-4 text-xs text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-2"><span className="h-3 w-3 rounded bg-muted/45" />无训练</span>
|
||||
<span className="inline-flex items-center gap-2"><span className="h-3 w-3 rounded bg-emerald-200" />基础训练</span>
|
||||
<span className="inline-flex items-center gap-2"><span className="h-3 w-3 rounded bg-emerald-400" />高频训练</span>
|
||||
<span className="inline-flex items-center gap-2"><span className="h-3 w-3 rounded bg-emerald-600" />高负荷训练日</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Sparkles className="h-4 w-4 text-primary" />
|
||||
下一目标
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{nextTarget ? (
|
||||
<>
|
||||
<div className="rounded-2xl border border-border/60 bg-muted/20 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-base font-semibold">{nextTarget.name}</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">{nextTarget.description}</div>
|
||||
</div>
|
||||
<Badge className={CATEGORY_META[nextTarget.category]?.tone || "bg-muted text-foreground"}>
|
||||
{CATEGORY_META[nextTarget.category]?.label || nextTarget.category}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<div className="mb-2 flex items-center justify-between text-sm">
|
||||
<span>完成度</span>
|
||||
<span className="font-medium">{Math.round(nextTarget.progressPct || 0)}%</span>
|
||||
</div>
|
||||
<Progress value={nextTarget.progressPct || 0} className="h-2" />
|
||||
<div className="mt-2 text-xs text-muted-foreground">{getProgressText(nextTarget)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div className="rounded-2xl border border-border/60 bg-muted/20 p-4">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Trophy className="h-4 w-4" />
|
||||
稀有度
|
||||
</div>
|
||||
<div className="mt-2 font-medium">{nextTarget.rarity || "common"}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/60 bg-muted/20 p-4">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Award className="h-4 w-4" />
|
||||
阶段
|
||||
</div>
|
||||
<div className="mt-2 font-medium">Tier {nextTarget.tier || 1}</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dashed border-border/60 px-4 py-10 text-center text-sm text-muted-foreground">
|
||||
当前成就已全部解锁。
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">成就列表</CardTitle>
|
||||
<CardDescription>每日签到已被训练日聚合和成就进度替代,所有进度由训练数据自动驱动。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
{Object.entries(groupedAchievements).map(([category, items]) => (
|
||||
<section key={category} className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className={CATEGORY_META[category]?.tone || "bg-muted text-foreground"}>
|
||||
{CATEGORY_META[category]?.label || category}
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{(items as any[]).filter((item) => item.unlocked).length}/{(items as any[]).length} 已解锁
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{(items as any[]).map((item) => (
|
||||
<div
|
||||
key={item.key}
|
||||
className={`rounded-2xl border p-4 transition-colors ${item.unlocked ? "border-emerald-200 bg-emerald-50/70" : "border-border/60 bg-muted/20"}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl">{item.icon || "🎾"}</span>
|
||||
<div className="font-medium">{item.name}</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="mt-2 text-sm text-muted-foreground">{item.description}</div>
|
||||
</div>
|
||||
<Badge variant={item.unlocked ? "secondary" : "outline"}>
|
||||
{item.unlocked ? "已解锁" : "进行中"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<div className="mb-2 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{getProgressText(item)}</span>
|
||||
<span>{Math.round(item.progressPct || 0)}%</span>
|
||||
</div>
|
||||
<Progress value={item.progressPct || 0} className="h-2" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardContent className="pt-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<Flame className="h-5 w-5 text-rose-600" />
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">连续训练日</div>
|
||||
<div className="mt-1 text-xl font-semibold">{user?.currentStreak || 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardContent className="pt-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<Radar className="h-5 w-5 text-sky-600" />
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">实时分析会话</div>
|
||||
<div className="mt-1 text-xl font-semibold">{(stats?.recentLiveSessions || []).length}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardContent className="pt-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<Swords className="h-5 w-5 text-orange-600" />
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">当前评分</div>
|
||||
<div className="mt-1 text-xl font-semibold">{(stats?.latestNtrpSnapshot?.rating || stats?.ntrpRating || 1.5).toFixed(1)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,30 +1,25 @@
|
||||
import { useMemo } from "react";
|
||||
import { useAuth } from "@/_core/hooks/useAuth";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Target, Video, Activity, TrendingUp, Award, Clock,
|
||||
Zap, BarChart3, ChevronRight
|
||||
} from "lucide-react";
|
||||
import { formatDateTimeShanghai } from "@/lib/time";
|
||||
import { Activity, Award, ChevronRight, Clock3, Sparkles, Swords, Target, Video } from "lucide-react";
|
||||
import { useLocation } from "wouter";
|
||||
import { ResponsiveContainer, LineChart, Line, XAxis, YAxis, Tooltip, CartesianGrid, AreaChart, Area } from "recharts";
|
||||
|
||||
function NTRPBadge({ rating }: { rating: number }) {
|
||||
let level = "初学者";
|
||||
let color = "bg-gray-100 text-gray-700";
|
||||
if (rating >= 4.0) { level = "高级竞技"; color = "bg-purple-100 text-purple-700"; }
|
||||
else if (rating >= 3.0) { level = "中高级"; color = "bg-blue-100 text-blue-700"; }
|
||||
else if (rating >= 2.5) { level = "中级"; color = "bg-green-100 text-green-700"; }
|
||||
else if (rating >= 2.0) { level = "初中级"; color = "bg-yellow-100 text-yellow-700"; }
|
||||
else if (rating >= 1.5) { level = "初级"; color = "bg-orange-100 text-orange-700"; }
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${color}`}>
|
||||
NTRP {rating.toFixed(1)} · {level}
|
||||
</span>
|
||||
);
|
||||
const level =
|
||||
rating >= 4.0 ? "高级竞技" :
|
||||
rating >= 3.5 ? "高级" :
|
||||
rating >= 3.0 ? "中高级" :
|
||||
rating >= 2.5 ? "中级" :
|
||||
rating >= 2.0 ? "初中级" :
|
||||
rating >= 1.5 ? "初级" :
|
||||
"入门";
|
||||
return <Badge className="bg-emerald-500/10 text-emerald-700">NTRP {rating.toFixed(1)} · {level}</Badge>;
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
@@ -32,248 +27,218 @@ export default function Dashboard() {
|
||||
const { data: stats, isLoading } = trpc.profile.stats.useQuery();
|
||||
const [, setLocation] = useLocation();
|
||||
|
||||
const unlockedAchievements = useMemo(
|
||||
() => (stats?.achievements || []).filter((item: any) => item.unlocked).length,
|
||||
[stats?.achievements],
|
||||
);
|
||||
|
||||
const recentTrainingDays = useMemo(
|
||||
() => [...(stats?.dailyTraining || [])].slice(-7).reverse(),
|
||||
[stats?.dailyTraining],
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-32 w-full" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[1, 2, 3, 4].map(i => <Skeleton key={i} className="h-28" />)}
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-40 w-full" />
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{[1, 2, 3, 4].map((index) => <Skeleton key={index} className="h-32" />)}
|
||||
</div>
|
||||
<Skeleton className="h-80 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ratingData = stats?.ratingHistory?.map((r: any) => ({
|
||||
date: new Date(r.createdAt).toLocaleDateString("zh-CN", { month: "short", day: "numeric" }),
|
||||
rating: r.rating,
|
||||
...((r.dimensionScores as any) || {}),
|
||||
})) || [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Welcome header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
欢迎回来,{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>
|
||||
);
|
||||
}
|
||||
|
||||
423
client/src/pages/VisionLab.tsx
普通文件
@@ -0,0 +1,423 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/_core/hooks/useAuth";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { formatDateTimeShanghai } from "@/lib/time";
|
||||
import { toast } from "sonner";
|
||||
import { useBackgroundTask } from "@/hooks/useBackgroundTask";
|
||||
import { Database, Image as ImageIcon, Loader2, Microscope, ShieldCheck, Sparkles } from "lucide-react";
|
||||
|
||||
type ReferenceImage = {
|
||||
id: number;
|
||||
slug: string;
|
||||
title: string;
|
||||
exerciseType: string;
|
||||
imageUrl: string;
|
||||
sourcePageUrl: string;
|
||||
sourceLabel: string;
|
||||
author: string | null;
|
||||
license: string | null;
|
||||
expectedFocus: string[] | null;
|
||||
tags: string[] | null;
|
||||
notes: string | null;
|
||||
};
|
||||
|
||||
type VisionRun = {
|
||||
id: number;
|
||||
taskId: string;
|
||||
userId: number;
|
||||
userName: string | null;
|
||||
referenceImageId: number | null;
|
||||
referenceTitle: string | null;
|
||||
title: string;
|
||||
exerciseType: string;
|
||||
imageUrl: string;
|
||||
status: "queued" | "succeeded" | "failed";
|
||||
visionStatus: "pending" | "ok" | "fallback" | "failed";
|
||||
configuredModel: string | null;
|
||||
expectedFocus: string[] | null;
|
||||
summary: string | null;
|
||||
corrections: string | null;
|
||||
warning: string | null;
|
||||
error: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
const COMMONS_SPECIAL_FILE_PATH = "/wiki/Special:FilePath/";
|
||||
const COMMONS_FILE_PAGE_PATH = "/wiki/File:";
|
||||
|
||||
function getCompressedVisionImageUrl(imageUrl: string, width = 960) {
|
||||
try {
|
||||
const url = new URL(imageUrl);
|
||||
if (url.hostname !== "commons.wikimedia.org") {
|
||||
return imageUrl;
|
||||
}
|
||||
|
||||
let fileName: string | null = null;
|
||||
if (url.pathname.startsWith(COMMONS_SPECIAL_FILE_PATH)) {
|
||||
fileName = url.pathname.slice(COMMONS_SPECIAL_FILE_PATH.length);
|
||||
} else if (url.pathname.startsWith(COMMONS_FILE_PAGE_PATH)) {
|
||||
fileName = url.pathname.slice(COMMONS_FILE_PAGE_PATH.length);
|
||||
}
|
||||
|
||||
if (!fileName) {
|
||||
return imageUrl;
|
||||
}
|
||||
|
||||
const decodedFileName = decodeURIComponent(fileName);
|
||||
return `https://commons.wikimedia.org/wiki/Special:Redirect/file/${encodeURIComponent(decodedFileName)}?width=${width}`;
|
||||
} catch {
|
||||
return imageUrl;
|
||||
}
|
||||
}
|
||||
|
||||
function VisionPreviewImage({
|
||||
src,
|
||||
alt,
|
||||
className,
|
||||
width = 960,
|
||||
}: {
|
||||
src: string;
|
||||
alt: string;
|
||||
className: string;
|
||||
width?: number;
|
||||
}) {
|
||||
const [displaySrc, setDisplaySrc] = useState(() => getCompressedVisionImageUrl(src, width));
|
||||
|
||||
useEffect(() => {
|
||||
setDisplaySrc(getCompressedVisionImageUrl(src, width));
|
||||
}, [src, width]);
|
||||
|
||||
return (
|
||||
<img
|
||||
src={displaySrc}
|
||||
alt={alt}
|
||||
className={className}
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
onError={() => {
|
||||
if (displaySrc !== src) {
|
||||
setDisplaySrc(src);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function statusBadge(run: VisionRun) {
|
||||
if (run.status === "failed" || run.visionStatus === "failed") {
|
||||
return <Badge variant="destructive">失败</Badge>;
|
||||
}
|
||||
if (run.status === "queued" || run.visionStatus === "pending") {
|
||||
return <Badge variant="secondary">排队中</Badge>;
|
||||
}
|
||||
if (run.visionStatus === "fallback") {
|
||||
return <Badge variant="outline">文本降级</Badge>;
|
||||
}
|
||||
return <Badge className="bg-emerald-600 hover:bg-emerald-600">视觉成功</Badge>;
|
||||
}
|
||||
|
||||
export default function VisionLab() {
|
||||
const { user } = useAuth();
|
||||
const utils = trpc.useUtils();
|
||||
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
|
||||
const activeTask = useBackgroundTask(activeTaskId);
|
||||
|
||||
const libraryQuery = trpc.vision.library.useQuery();
|
||||
const runsQuery = trpc.vision.runs.useQuery(
|
||||
{ limit: 50 },
|
||||
{ refetchInterval: 4000 }
|
||||
);
|
||||
|
||||
const seedMutation = trpc.vision.seedLibrary.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success(`标准图库已就绪,共 ${data.count} 张`);
|
||||
utils.vision.library.invalidate();
|
||||
},
|
||||
onError: (error) => toast.error(`标准图库初始化失败: ${error.message}`),
|
||||
});
|
||||
|
||||
const runReferenceMutation = trpc.vision.runReference.useMutation({
|
||||
onSuccess: (data) => {
|
||||
setActiveTaskId(data.taskId);
|
||||
toast.success("视觉测试任务已提交");
|
||||
utils.vision.runs.invalidate();
|
||||
},
|
||||
onError: (error) => toast.error(`视觉测试提交失败: ${error.message}`),
|
||||
});
|
||||
|
||||
const runAllMutation = trpc.vision.runAll.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success(`已提交 ${data.count} 个视觉测试任务`);
|
||||
if (data.queued[0]?.taskId) {
|
||||
setActiveTaskId(data.queued[0].taskId);
|
||||
}
|
||||
utils.vision.runs.invalidate();
|
||||
},
|
||||
onError: (error) => toast.error(`批量视觉测试提交失败: ${error.message}`),
|
||||
});
|
||||
|
||||
const retryRunMutation = trpc.vision.retryRun.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("视觉记录已重新加入队列");
|
||||
utils.vision.runs.invalidate();
|
||||
},
|
||||
onError: (error) => toast.error(`重新执行失败: ${error.message}`),
|
||||
});
|
||||
|
||||
const retryFallbacksMutation = trpc.vision.retryFallbacks.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success(`已重新排队 ${data.count} 条历史视觉记录`);
|
||||
utils.vision.runs.invalidate();
|
||||
},
|
||||
onError: (error) => toast.error(`批量修复失败: ${error.message}`),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTask.data?.status === "succeeded" || activeTask.data?.status === "failed") {
|
||||
utils.vision.runs.invalidate();
|
||||
setActiveTaskId(null);
|
||||
}
|
||||
}, [activeTask.data, utils.vision.runs]);
|
||||
|
||||
const references = useMemo(() => (libraryQuery.data ?? []) as ReferenceImage[], [libraryQuery.data]);
|
||||
const runs = useMemo(() => (runsQuery.data ?? []) as VisionRun[], [runsQuery.data]);
|
||||
|
||||
if (libraryQuery.isLoading && runsQuery.isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-28 w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">视觉标准图库</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
用公网可访问的网球标准图验证多模态纠正链路,并持久化每次测试结果。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{user?.role === "admin" ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => seedMutation.mutate()}
|
||||
disabled={seedMutation.isPending}
|
||||
className="gap-2"
|
||||
>
|
||||
{seedMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Database className="h-4 w-4" />}
|
||||
初始化标准库
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => retryFallbacksMutation.mutate({ limit: 20 })}
|
||||
disabled={retryFallbacksMutation.isPending}
|
||||
className="gap-2"
|
||||
>
|
||||
{retryFallbacksMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
|
||||
修复历史降级
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
<Button
|
||||
onClick={() => runAllMutation.mutate()}
|
||||
disabled={runAllMutation.isPending || references.length === 0}
|
||||
className="gap-2"
|
||||
>
|
||||
{runAllMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Microscope className="h-4 w-4" />}
|
||||
批量跑测试
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{user?.role === "admin" ? (
|
||||
<Alert>
|
||||
<ShieldCheck className="h-4 w-4" />
|
||||
<AlertTitle>Admin 视角</AlertTitle>
|
||||
<AlertDescription>
|
||||
当前账号可查看全部视觉测试记录。若用户名为 `H1` 且被配置进 `ADMIN_USERNAMES`,登录后会自动拥有此视角。
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert>
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
<AlertTitle>个人测试视角</AlertTitle>
|
||||
<AlertDescription>当前页面展示标准图库,以及你自己提交的视觉测试结果。</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{activeTask.data?.status === "queued" || activeTask.data?.status === "running" ? (
|
||||
<Alert>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
<AlertTitle>后台执行中</AlertTitle>
|
||||
<AlertDescription>{activeTask.data.message || "视觉测试正在后台执行。"}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">标准图片库</h2>
|
||||
<Badge variant="secondary">{references.length} 张</Badge>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{references.map((reference) => (
|
||||
<Card key={reference.id} className="overflow-hidden border-0 shadow-sm">
|
||||
<div className="aspect-[4/3] overflow-hidden bg-muted">
|
||||
<VisionPreviewImage
|
||||
src={reference.imageUrl}
|
||||
alt={reference.title}
|
||||
className="h-full w-full object-cover"
|
||||
width={960}
|
||||
/>
|
||||
</div>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">{reference.title}</CardTitle>
|
||||
<CardDescription className="flex flex-wrap gap-2">
|
||||
<Badge variant="outline">{reference.exerciseType}</Badge>
|
||||
{reference.license ? <Badge variant="secondary">{reference.license}</Badge> : null}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{reference.notes ? (
|
||||
<p className="text-sm text-muted-foreground">{reference.notes}</p>
|
||||
) : null}
|
||||
{reference.expectedFocus?.length ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{reference.expectedFocus.map((item) => (
|
||||
<Badge key={item} variant="outline">{item}</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<a
|
||||
href={reference.sourcePageUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-sm text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
来源页
|
||||
</a>
|
||||
<Button
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
onClick={() => runReferenceMutation.mutate({ referenceImageId: reference.id })}
|
||||
disabled={runReferenceMutation.isPending}
|
||||
>
|
||||
{runReferenceMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Microscope className="h-4 w-4" />}
|
||||
运行测试
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">视觉测试记录</h2>
|
||||
<Badge variant="secondary">{runs.length} 条</Badge>
|
||||
</div>
|
||||
<div className="grid gap-4">
|
||||
{runs.map((run) => (
|
||||
<Card key={run.id} className="border-0 shadow-sm">
|
||||
<CardContent className="pt-5 space-y-3">
|
||||
<div className="flex flex-col gap-4 lg:flex-row">
|
||||
<a
|
||||
href={run.imageUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="block overflow-hidden rounded-xl bg-muted lg:w-72 lg:flex-none"
|
||||
>
|
||||
<div className="aspect-[4/3]">
|
||||
<VisionPreviewImage
|
||||
src={run.imageUrl}
|
||||
alt={run.title}
|
||||
className="h-full w-full object-cover"
|
||||
width={720}
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div className="min-w-0 flex-1 space-y-3">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="font-semibold">{run.title}</h3>
|
||||
{statusBadge(run)}
|
||||
<Badge variant="outline">{run.exerciseType}</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatDateTimeShanghai(run.createdAt)}
|
||||
{user?.role === "admin" && run.userName ? ` · 提交人:${run.userName}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
{run.configuredModel ? (
|
||||
<Badge variant="secondary">{run.configuredModel}</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{run.summary ? <p className="text-sm">{run.summary}</p> : null}
|
||||
{run.warning ? (
|
||||
<p className="text-sm text-amber-700">降级说明:{run.warning}</p>
|
||||
) : null}
|
||||
{run.error ? (
|
||||
<p className="text-sm text-destructive">错误:{run.error}</p>
|
||||
) : null}
|
||||
|
||||
{(run.visionStatus === "fallback" || run.status === "failed") ? (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="gap-2"
|
||||
onClick={() => retryRunMutation.mutate({ runId: run.id })}
|
||||
disabled={retryRunMutation.isPending}
|
||||
>
|
||||
{retryRunMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Microscope className="h-4 w-4" />}
|
||||
重新视觉识别
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{run.expectedFocus?.length ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{run.expectedFocus.map((item) => (
|
||||
<Badge key={item} variant="outline">{item}</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{run.corrections ? (
|
||||
<div className="rounded-xl bg-muted/50 p-3 text-sm leading-6 whitespace-pre-wrap">
|
||||
{run.corrections}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
{runs.length === 0 ? (
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="pt-6 text-sm text-muted-foreground">
|
||||
还没有视觉测试记录。先运行一张标准图测试,结果会自动入库并显示在这里。
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
63
deploy/nginx.te.hao.work.conf
普通文件
@@ -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:
|
||||
52
docs/API.md
@@ -75,7 +75,7 @@
|
||||
| 类型 | Mutation |
|
||||
| 认证 | **需认证** |
|
||||
| 输入 | `{ skillLevel: enum, durationDays: number, focusAreas?: string[] }` |
|
||||
| 输出 | `{ planId: number, plan: TrainingPlanData }` |
|
||||
| 输出 | `{ taskId: string, task: BackgroundTask }` |
|
||||
|
||||
**输入验证:**
|
||||
- `skillLevel`:`"beginner"` / `"intermediate"` / `"advanced"`
|
||||
@@ -105,7 +105,7 @@
|
||||
| 类型 | Mutation |
|
||||
| 认证 | **需认证** |
|
||||
| 输入 | `{ planId: number }` |
|
||||
| 输出 | `{ success: true, adjustmentNotes: string }` |
|
||||
| 输出 | `{ taskId: string, task: BackgroundTask }` |
|
||||
|
||||
---
|
||||
|
||||
@@ -187,8 +187,10 @@
|
||||
|------|-----|
|
||||
| 类型 | Mutation |
|
||||
| 认证 | **需认证** |
|
||||
| 输入 | `{ poseMetrics: object, exerciseType: string, detectedIssues: array }` |
|
||||
| 输出 | `{ corrections: string }` |
|
||||
| 输入 | `{ poseMetrics: object, exerciseType: string, detectedIssues: array, imageUrls?: string[], imageDataUrls?: string[] }` |
|
||||
| 输出 | `{ taskId: string, task: BackgroundTask }` |
|
||||
|
||||
该接口始终走后台任务。若提供 `imageUrls` 或 `imageDataUrls`,服务端会优先走多模态纠正链路,并把相对地址规范化为可公网访问的绝对 URL。
|
||||
|
||||
#### `analysis.list` - 获取用户所有分析记录
|
||||
|
||||
@@ -211,6 +213,48 @@
|
||||
|
||||
### 6. 训练记录模块 (`record`)
|
||||
|
||||
### 5.1 后台任务模块 (`task`)
|
||||
|
||||
#### `task.list` - 获取当前用户后台任务
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| 类型 | Query |
|
||||
| 认证 | **需认证** |
|
||||
| 输入 | `{ limit?: number }` |
|
||||
| 输出 | `BackgroundTask[]` |
|
||||
|
||||
#### `task.get` - 获取单个后台任务
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| 类型 | Query |
|
||||
| 认证 | **需认证** |
|
||||
| 输入 | `{ taskId: string }` |
|
||||
| 输出 | `BackgroundTask | null` |
|
||||
|
||||
#### `task.retry` - 重试失败任务
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| 类型 | Mutation |
|
||||
| 认证 | **需认证** |
|
||||
| 输入 | `{ taskId: string }` |
|
||||
| 输出 | `{ task: BackgroundTask }` |
|
||||
|
||||
#### `task.createMediaFinalize` - 提交录制归档后台任务
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| 类型 | Mutation |
|
||||
| 认证 | **需认证** |
|
||||
| 输入 | `{ sessionId: string, title: string, exerciseType?: string }` |
|
||||
| 输出 | `{ taskId: string, task: BackgroundTask }` |
|
||||
|
||||
该接口会校验媒体会话所属用户,并由后台 worker 轮询 Go 媒体服务状态,归档完成后自动登记到视频库。
|
||||
|
||||
### 6. 训练记录模块 (`record`)
|
||||
|
||||
#### `record.create` - 创建训练记录
|
||||
|
||||
| 属性 | 值 |
|
||||
|
||||
@@ -1,5 +1,279 @@
|
||||
# Tennis Training Hub - 变更日志
|
||||
|
||||
## 2026.03.16-live-viewer-server-relay (2026-03-16)
|
||||
|
||||
### 功能更新
|
||||
|
||||
- `/live-camera` 的同步观看改为由 media 服务中转最新合成帧图,不再依赖浏览器之间的 P2P WebRTC viewer 连接
|
||||
- owner 端会把“原视频 + 骨架/关键点 + 虚拟形象”的合成画布压缩成 JPEG 并持续上传到 media 服务
|
||||
- viewer 端改为自动轮询 media 服务中的最新同步帧图,因此即使浏览器之间无法直连,也能继续看到同步画面和状态
|
||||
- 同步观看模式文案已调整为明确提示“通过 media 服务中转”,等待阶段会继续自动刷新,而不是停留在 P2P 连接失败状态
|
||||
- media 服务新增 live-frame 上传与静态分发能力,并记录最近同步帧时间,方便后续继续扩展更高频的服务端 relay
|
||||
|
||||
### 测试
|
||||
|
||||
- `cd media && go test ./...`
|
||||
- `pnpm build`
|
||||
- `playwright-skill` 线上 smoke:先用 media 服务创建 relay session、上传 live-frame,并把 `H1` 的 `live_analysis_runtime` 注入为 active viewer 场景;随后访问 `https://te.hao.work/live-camera`,确认页面进入“同步观看模式”、同步帧来自 `/media/assets/sessions/.../live-frame.jpg`,且 `viewer-signal` 请求数为 `0`
|
||||
|
||||
### 线上 smoke
|
||||
|
||||
- `https://te.hao.work/` 已切换到本次新构建
|
||||
- 当前公开站点前端资源 revision:`assets/index-BC-IupO8.js` 与 `assets/index-tNGuStgv.css`
|
||||
- 真实验证已通过:viewer 端进入“同步观看模式”后,画面由 media 服务静态分发的 `live-frame.jpg` 提供,已确认不再触发 `/viewer-signal` P2P 观看请求
|
||||
|
||||
### 仓库版本
|
||||
|
||||
- `bb46d26`
|
||||
|
||||
## 2026.03.16-camera-startup-fallbacks (2026-03-16)
|
||||
|
||||
### 功能更新
|
||||
|
||||
- 修复部分设备在 `/live-camera` 和 `/recorder` 中因默认后置镜头、分辨率或帧率约束不兼容而直接启动摄像头失败的问题
|
||||
- 摄像头请求现在会自动按当前画质、去掉高约束、低分辨率、备用镜头、任意可用镜头依次降级重试
|
||||
- `/recorder` 在麦克风不可用或麦克风权限未给出时,会自动回退到仅视频模式,不再让整次预览启动失败
|
||||
- 如果实际启用的是兼容镜头或降级模式,页面会显示提示,帮助区分“自动修复成功”与“仍然无法访问摄像头”
|
||||
|
||||
### 测试
|
||||
|
||||
- `pnpm build`
|
||||
- `playwright-skill` 线上 smoke:通过注入 `getUserMedia` 回归验证 `/live-camera` 首轮高约束失败后会自动降级到兼容摄像头模式,`/recorder` 在麦克风不可用时会自动回退到仅视频模式并继续启动预览
|
||||
|
||||
### 线上 smoke
|
||||
|
||||
- `https://te.hao.work/` 已切换到本次新构建
|
||||
- 当前公开站点前端资源 revision:`assets/index-CRxtWK07.js` 与 `assets/index-tNGuStgv.css`
|
||||
- 真实回归已通过:模拟高约束失败时,`/live-camera` 会提示“当前设备已自动切换到兼容摄像头模式”并继续启动;模拟麦克风不可用时,`/recorder` 会提示“麦克风不可用,已切换为仅视频模式”并继续显示录制入口
|
||||
|
||||
### 仓库版本
|
||||
|
||||
- `a211562`
|
||||
|
||||
## 2026.03.16-live-analysis-viewer-full-sync (2026-03-16)
|
||||
|
||||
### 功能更新
|
||||
|
||||
- 同账号多端同步观看时,viewer 端现在会按持有端 runtime snapshot 完整渲染,不再混用本地默认状态
|
||||
- `/live-camera` viewer 端新增主端同步信息卡,可看到当前会话标题、训练模式、设备端、拍摄视角、画质模式、虚拟形象状态和最近同步时间
|
||||
- viewer 端现在会同步显示主端当前处于“分析中 / 保存中 / 已保存 / 保存失败”的阶段状态
|
||||
- viewer 页面在同步观看模式下会自动关闭拍摄校准弹窗,避免被“启用摄像头”引导遮挡画面和状态信息
|
||||
|
||||
### 测试
|
||||
|
||||
- `pnpm exec playwright test tests/e2e/app.spec.ts --grep "live camera switches into viewer mode|viewer stream|recorder blocks"`
|
||||
- `pnpm build`
|
||||
- `playwright-skill` 线上 smoke:同账号 `H1` 双端登录后,移动端 owner 开始实时分析,桌面端 `/live-camera` 进入同步观看并显示主端信息、同步视频流,owner 点击结束分析后 viewer 同步进入保存阶段
|
||||
|
||||
### 线上 smoke
|
||||
|
||||
- `https://te.hao.work/` 已切换到本次新构建
|
||||
- 当前公开站点前端资源 revision:`assets/index-HRdM3fxq.js` 与 `assets/index-tNGuStgv.css`
|
||||
- 真实双端验证已通过:同账号 `H1` 在移动端开启实时分析后,桌面端 `/live-camera` 会自动进入同步观看模式,显示主端设备信息、最近同步时间和远端视频流;owner 点击结束分析后,viewer 会同步进入“保存中”阶段
|
||||
|
||||
### 仓库版本
|
||||
|
||||
- `922a9fb`
|
||||
|
||||
## 2026.03.16-live-analysis-lock-hardening (2026-03-16)
|
||||
|
||||
### 功能更新
|
||||
|
||||
- 修复同账号多端实时分析在旧登录态下仍可重复占用摄像头的问题;缺少 `sid` 的旧 token 现在会按 token 本身派生唯一会话标识
|
||||
- `/live-camera` 的同步观看模式新增自动重试;当持有端刚启动推流、viewer 首次连接返回 `viewer stream not ready` 时,会继续重连,不再长时间停留在无画面状态
|
||||
- `/recorder` 接入实时分析占用锁;其他设备正在实时分析时,本页会禁止再次启动摄像头和开始录制,并提示前往 `/live-camera` 查看同步画面
|
||||
- 应用启动改为先监听 HTTP 端口、再后台串行执行教程图同步和标准库预热,修复新容器上线时公网长时间返回 `502`
|
||||
|
||||
### 测试
|
||||
|
||||
- `curl -I https://te.hao.work/`
|
||||
- `pnpm check`
|
||||
- `pnpm exec vitest run server/_core/sdk.test.ts server/features.test.ts`
|
||||
- `pnpm exec playwright test tests/e2e/app.spec.ts --grep "viewer mode|viewer stream|recorder blocks"`
|
||||
- `playwright-skill` 线上校验:登录 `H1` 后访问 `/changelog`,确认 `2026.03.16-live-analysis-lock-hardening` 与仓库版本 `f9db6ef` 已展示
|
||||
- `pnpm build`
|
||||
- Playwright 线上 smoke:`H1` 手机端开启实时分析后,PC 端 `/live-camera` 自动进入同步观看并显示同步画面,`/recorder` 禁止启动摄像头;结束分析后会话可正常释放
|
||||
|
||||
### 线上 smoke
|
||||
|
||||
- `https://te.hao.work/` 已切换到本次新构建,不再返回 `502`
|
||||
- 当前公开站点前端资源 revision:`assets/index-mi8CPCFI.js` 与 `assets/index-Cp_VJ8sf.css`
|
||||
- 真实双端验证已通过:同账号 `H1` 手机端开始实时分析后,PC 端 `/live-camera` 进入同步观看模式且可拉起同步流,`/recorder` 页面会阻止再次占用摄像头
|
||||
|
||||
### 仓库版本
|
||||
|
||||
- `f9db6ef`
|
||||
|
||||
## 2026.03.16-live-analysis-runtime-migration (2026-03-16)
|
||||
|
||||
### 功能更新
|
||||
|
||||
- 修复生产环境缺失 `live_analysis_runtime` 表导致 `/live-camera` 启动实时分析时报 SQL 查询失败的问题
|
||||
- 生产库已补建 `live_analysis_runtime` 表,并写入缺失的 `0011_live_analysis_runtime` 迁移记录,避免后续重复报错
|
||||
- 仓库内 `drizzle/meta/_journal.json` 已补齐 `0011_live_analysis_runtime` 条目,后续 `docker compose` 部署可正确识别该迁移
|
||||
- 实时分析 runtime 锁恢复正常后,同账号多端互斥与同步观看流程可继续工作
|
||||
|
||||
### 测试
|
||||
|
||||
- `pnpm check`
|
||||
- `pnpm exec vitest run server/features.test.ts`
|
||||
- `pnpm build`
|
||||
- `docker compose exec -T db mysql ... SHOW TABLES LIKE 'live_analysis_runtime'`
|
||||
- `curl -I https://te.hao.work/live-camera`
|
||||
- Playwright smoke:登录 `H1` 后访问 `/live-camera`,`analysis.runtimeGet` / `analysis.runtimeAcquire` / `analysis.runtimeRelease` 全部返回 `200`
|
||||
|
||||
### 线上 smoke
|
||||
|
||||
- `https://te.hao.work/` 已切换到本次新构建
|
||||
- 当前公开站点前端资源 revision:`assets/index-B3BN5hY-.js` 与 `assets/index-BL6GQzUF.css`
|
||||
- `/live-camera` 已恢复可用,线上不再出现 `live_analysis_runtime` 缺表导致的 SQL 查询失败
|
||||
|
||||
### 仓库版本
|
||||
|
||||
- `2b72ef9`
|
||||
|
||||
## 2026.03.16-live-camera-multidevice-viewer (2026-03-16)
|
||||
|
||||
### 功能更新
|
||||
|
||||
- `/live-camera` 新增同账号多端 runtime 锁;一个设备开始实时分析后,其他设备不能再次启动摄像头或分析
|
||||
- 其他设备会自动进入“同步观看模式”,可查看持有端同步推送的实时画面、当前动作、评分、反馈和最近动作片段
|
||||
- 同步观看复用 media 服务新增的 `/viewer-signal` WebRTC 通道,直接订阅“原视频 + 骨架 + 关键点 + 虚拟形象”的合成画面
|
||||
- runtime 心跳按 `sid` 维度识别持有端,兼容旧 token 缺失可选字段的情况;超过 15 秒无心跳会自动释放陈旧锁
|
||||
- `/live-camera` 前端新增 owner / viewer 双模式切换,观看端会禁用镜头切换、重新校准、质量调整和分析启动
|
||||
- e2e mock 新增 viewer 模式和 runtime 接口覆盖,保证浏览器测试可以直接验证多端互斥与同步观看
|
||||
|
||||
### 测试
|
||||
|
||||
- `pnpm check`
|
||||
- `pnpm exec vitest run server/features.test.ts`
|
||||
- `go test ./...`
|
||||
- `go build ./...`
|
||||
- `pnpm build`
|
||||
- `pnpm exec playwright test tests/e2e/app.spec.ts --grep "live camera"`
|
||||
- `pnpm exec playwright test tests/e2e/app.spec.ts --grep "recorder flow archives a session and exposes it in videos"`
|
||||
- `curl -I https://te.hao.work/live-camera`
|
||||
|
||||
### 线上 smoke
|
||||
|
||||
- `https://te.hao.work/live-camera` 已切换到本次新前端构建
|
||||
- 公开站点确认已经提供本次发布的最新前端资源
|
||||
|
||||
### 仓库版本
|
||||
|
||||
- `4e4122d`
|
||||
|
||||
## 2026.03.16-live-analysis-overlay-archive (2026-03-16)
|
||||
|
||||
### 功能更新
|
||||
|
||||
- `/live-camera` 新增 10 个免费动物虚拟形象,可将主体实时替换为猩猩、猴子、狗、猪、猫、狐狸、熊猫、狮子、老虎、兔子
|
||||
- `/live-camera` 再新增 4 个免费的全身 3D Avatar 示例,可直接覆盖人物轮廓,并提供对应的 CC0 模型源链接
|
||||
- `/live-camera` 新增实时分析自动录像,按 60 秒自动切段归档;归档视频写入视频库并标记为“实时分析”
|
||||
- 实时分析录像改为录制“视频画面 + 骨架线 + 关键点 + 虚拟形象覆盖”的合成画布,回放中可直接看到分析叠层
|
||||
- 实时分析记录与视频库解耦,用户删除视频库中的“实时分析”录像后,不会删除已保存的分析数据和训练记录
|
||||
- 增加形象别名输入,当前可按输入内容自动映射到内置形象
|
||||
- 实时分析动作稳定器从短窗口切换为 24 帧时间窗口,降低 1-2 秒内频繁跳动作的问题
|
||||
- 动作切换新增确认阶段与延迟入库逻辑,连续动作区间改为只按稳定动作聚合
|
||||
- 画面内新增稳定动作、原始候选、窗口占比、切换确认状态等实时状态提示
|
||||
- 实时分析会话保存新增稳定窗口、动作切换次数、原始波动率、虚拟形象状态等指标
|
||||
- 动物头像素材切换为本地集成的免费 Twemoji SVG,避免外链依赖
|
||||
- 新增 Open Source Avatars 的本地优化透明 WebP 全身素材,减少全身替身叠加时的页面流量和首帧加载时间
|
||||
|
||||
### 测试
|
||||
|
||||
- `pnpm check`
|
||||
- `pnpm test`
|
||||
- `pnpm build`
|
||||
- `pnpm test:e2e`
|
||||
- Playwright 线上 smoke:
|
||||
- `https://te.hao.work/live-camera` 真实登录 `H1` 后可完成引导、启用摄像头、开始分析、结束分析
|
||||
- `https://te.hao.work/videos` 可见“实时分析”录像条目
|
||||
- `https://te.hao.work/changelog` 已展示 `2026.03.16-live-analysis-overlay-archive` 条目与对应摘要
|
||||
- 最终线上资源 revision:`assets/index-BWEXNszf.js` 与 `assets/index-BL6GQzUF.css`
|
||||
|
||||
### 仓库版本
|
||||
|
||||
- `4fb2d09`
|
||||
|
||||
## 2026.03.15-live-analysis-leave-hint (2026-03-15)
|
||||
|
||||
### 功能更新
|
||||
|
||||
- 实时分析进行中显示“不要关闭浏览器或切走页面”提示
|
||||
- 点击“结束分析”后,保存阶段显示“请暂时停留当前页面”提示
|
||||
- 保存完成后明确提示“现在可以关闭浏览器或切换到其他页面”
|
||||
- 分析中和保存中增加离开页面提醒,减少误关导致的数据丢失
|
||||
|
||||
### 测试
|
||||
|
||||
- `pnpm check`
|
||||
- `pnpm build`
|
||||
|
||||
### 仓库版本
|
||||
|
||||
- `5c2dcf2`
|
||||
|
||||
## 2026.03.15-training-generator-collapse (2026-03-15)
|
||||
|
||||
### 功能更新
|
||||
|
||||
- 训练页右侧“重新生成计划”面板在桌面端默认折叠到右侧
|
||||
- 点击右侧折叠栏后展开完整生成器,可调整周期并重新生成计划
|
||||
- 移动端继续保持完整面板直接可见
|
||||
- 未生成计划时点击“前往生成训练计划”会先自动展开,再滚动到面板位置
|
||||
|
||||
### 测试
|
||||
|
||||
- `pnpm check`
|
||||
- `pnpm build`
|
||||
|
||||
### 仓库版本
|
||||
|
||||
- `1ce94f6`
|
||||
|
||||
## 2026.03.15-progress-time-actions (2026-03-15)
|
||||
|
||||
### 功能更新
|
||||
|
||||
- 最近训练记录摘要行默认显示到秒的具体时间,统一按 `Asia/Shanghai` 展示
|
||||
- 录制类训练记录在列表中直接显示动作数、主动作和前 3 个动作统计
|
||||
- 训练记录展开态中的动作明细改为中文动作标签,便于直接阅读
|
||||
- 提醒页通知时间统一切换为 `Asia/Shanghai`
|
||||
|
||||
### 测试
|
||||
|
||||
- `pnpm check`
|
||||
- `pnpm build`
|
||||
|
||||
### 仓库版本
|
||||
|
||||
- `71caf0d`
|
||||
|
||||
## 2026.03.15-session-changelog (2026-03-15)
|
||||
|
||||
### 功能更新
|
||||
|
||||
- 用户名登录生成独立 `sid`,同一账号在多个设备或浏览器上下文中登录时不再互相顶掉 session
|
||||
- 新增应用内更新日志页面 `/changelog`,展示版本号、发布日期、仓库版本和测试记录
|
||||
- 训练进度页最近训练记录支持展开,展示具体上海时间、动作数、主动作、动作明细、录制有效性和备注
|
||||
- 录制页补齐动作抽样摘要、无效录制标记与 media 预归档状态的前端展示
|
||||
- Dashboard、任务中心、管理台、训练页、评分页、日志页、视觉测试页、视频库等高频页面统一使用 `Asia/Shanghai` 时间显示
|
||||
|
||||
### 测试
|
||||
|
||||
- `pnpm check`
|
||||
- `pnpm test`
|
||||
- `pnpm test:go`
|
||||
- `pnpm build`
|
||||
- Playwright 线上 smoke:
|
||||
- `https://te.hao.work/` 使用两个浏览器上下文分别登录 `H1`,两端 dashboard 均保持有效
|
||||
- 当前线上 `/changelog` 仍返回旧前端构建,待部署最新版本后需要复测该页面
|
||||
|
||||
### 仓库版本
|
||||
|
||||
- `a9ea94f`
|
||||
|
||||
## v3.0.0 (2026-03-14)
|
||||
|
||||
### 新增功能
|
||||
@@ -9,7 +283,7 @@
|
||||
- **训练提醒通知**:支持训练/打卡/分析多类型提醒,自定义时间和重复日期
|
||||
- **浏览器通知推送**:Notification API集成,权限管理和状态提示
|
||||
- **通知记录管理**:未读计数、全部标记已读、历史记录浏览
|
||||
- **文案优化**:去除“在家”等冗余描述,简化为直接信息反馈
|
||||
- **文案调整**:去除冗余描述,简化为直接信息反馈
|
||||
|
||||
### 数据库变更
|
||||
|
||||
@@ -33,11 +307,11 @@
|
||||
- **每日打卡系统**:日历视图展示打卡记录,自动计算连续打卡天数
|
||||
- **成就徽章系统**:24种成就徽章,涵盖里程碑、训练、连续打卡、视频、分析、评分6个类别
|
||||
- **实时摄像头分析**:支持手机/电脑摄像头实时捕捉和MediaPipe姿势分析
|
||||
- **摄像头位置确认提示**:引导用户调整摄像头到最佳位置
|
||||
- **摄像头位置确认提示**:引导用户调整摄像头位置
|
||||
- **在线录制系统**:稳定压缩流录制,自适应码率1-2.5Mbps
|
||||
- **断线自动重连**:摄像头意外断开时自动检测并重新连接
|
||||
- **自动剪辑功能**:基于运动检测自动标记关键时刻
|
||||
- **移动端全面适配**:安全区域、触摸优化、横屏支持
|
||||
- **移动端适配**:安全区域、触摸优化、横屏支持
|
||||
- **手机摄像头优化**:前后摄像头切换、自适应分辨率
|
||||
|
||||
### 数据库变更
|
||||
@@ -53,7 +327,7 @@
|
||||
|
||||
### 文档
|
||||
|
||||
- 新增完整README.md
|
||||
- 新增 README.md
|
||||
- 新增API接口文档
|
||||
- 新增数据库设计文档
|
||||
- 新增功能列表清单
|
||||
|
||||
185
docs/FEATURES.md
@@ -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`)
|
||||
);
|
||||
159
drizzle/0007_grounded_live_ops.sql
普通文件
@@ -0,0 +1,159 @@
|
||||
ALTER TABLE `training_records`
|
||||
ADD `exerciseType` varchar(64),
|
||||
ADD `sourceType` varchar(32) DEFAULT 'manual',
|
||||
ADD `sourceId` varchar(64),
|
||||
ADD `videoId` int,
|
||||
ADD `linkedPlanId` int,
|
||||
ADD `matchConfidence` float,
|
||||
ADD `actionCount` int DEFAULT 0,
|
||||
ADD `metadata` json;
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `live_analysis_sessions` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`userId` int NOT NULL,
|
||||
`title` varchar(256) NOT NULL,
|
||||
`sessionMode` enum('practice','pk') NOT NULL DEFAULT 'practice',
|
||||
`status` enum('active','completed','aborted') NOT NULL DEFAULT 'completed',
|
||||
`startedAt` timestamp NOT NULL DEFAULT (now()),
|
||||
`endedAt` timestamp,
|
||||
`durationMs` int NOT NULL DEFAULT 0,
|
||||
`dominantAction` varchar(64),
|
||||
`overallScore` float,
|
||||
`postureScore` float,
|
||||
`balanceScore` float,
|
||||
`techniqueScore` float,
|
||||
`footworkScore` float,
|
||||
`consistencyScore` float,
|
||||
`unknownActionRatio` float,
|
||||
`totalSegments` int NOT NULL DEFAULT 0,
|
||||
`effectiveSegments` int NOT NULL DEFAULT 0,
|
||||
`totalActionCount` int NOT NULL DEFAULT 0,
|
||||
`videoId` int,
|
||||
`videoUrl` text,
|
||||
`summary` text,
|
||||
`feedback` json,
|
||||
`metrics` json,
|
||||
`createdAt` timestamp NOT NULL DEFAULT (now()),
|
||||
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `live_analysis_sessions_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `live_action_segments` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`sessionId` int NOT NULL,
|
||||
`actionType` varchar(64) NOT NULL,
|
||||
`isUnknown` int NOT NULL DEFAULT 0,
|
||||
`startMs` int NOT NULL,
|
||||
`endMs` int NOT NULL,
|
||||
`durationMs` int NOT NULL,
|
||||
`confidenceAvg` float,
|
||||
`score` float,
|
||||
`peakScore` float,
|
||||
`frameCount` int NOT NULL DEFAULT 0,
|
||||
`issueSummary` json,
|
||||
`keyFrames` json,
|
||||
`clipLabel` varchar(128),
|
||||
`createdAt` timestamp NOT NULL DEFAULT (now()),
|
||||
CONSTRAINT `live_action_segments_id` PRIMARY KEY(`id`),
|
||||
CONSTRAINT `live_action_segments_session_start_idx` UNIQUE(`sessionId`,`startMs`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `daily_training_aggregates` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`dayKey` varchar(32) NOT NULL,
|
||||
`userId` int NOT NULL,
|
||||
`trainingDate` varchar(10) NOT NULL,
|
||||
`totalMinutes` int NOT NULL DEFAULT 0,
|
||||
`sessionCount` int NOT NULL DEFAULT 0,
|
||||
`analysisCount` int NOT NULL DEFAULT 0,
|
||||
`liveAnalysisCount` int NOT NULL DEFAULT 0,
|
||||
`recordingCount` int NOT NULL DEFAULT 0,
|
||||
`pkCount` int NOT NULL DEFAULT 0,
|
||||
`totalActions` int NOT NULL DEFAULT 0,
|
||||
`effectiveActions` int NOT NULL DEFAULT 0,
|
||||
`unknownActions` int NOT NULL DEFAULT 0,
|
||||
`totalScore` float NOT NULL DEFAULT 0,
|
||||
`averageScore` float NOT NULL DEFAULT 0,
|
||||
`metadata` json,
|
||||
`createdAt` timestamp NOT NULL DEFAULT (now()),
|
||||
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `daily_training_aggregates_id` PRIMARY KEY(`id`),
|
||||
CONSTRAINT `daily_training_aggregates_dayKey_unique` UNIQUE(`dayKey`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `ntrp_snapshots` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`snapshotKey` varchar(64) NOT NULL,
|
||||
`userId` int NOT NULL,
|
||||
`snapshotDate` varchar(10) NOT NULL,
|
||||
`rating` float NOT NULL,
|
||||
`triggerType` enum('analysis','daily','manual') NOT NULL DEFAULT 'daily',
|
||||
`taskId` varchar(64),
|
||||
`dimensionScores` json,
|
||||
`sourceSummary` json,
|
||||
`createdAt` timestamp NOT NULL DEFAULT (now()),
|
||||
CONSTRAINT `ntrp_snapshots_id` PRIMARY KEY(`id`),
|
||||
CONSTRAINT `ntrp_snapshots_snapshotKey_unique` UNIQUE(`snapshotKey`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `achievement_definitions` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`key` varchar(64) NOT NULL,
|
||||
`name` varchar(128) NOT NULL,
|
||||
`description` text,
|
||||
`category` varchar(32) NOT NULL,
|
||||
`rarity` varchar(16) NOT NULL DEFAULT 'common',
|
||||
`icon` varchar(16) NOT NULL DEFAULT '🎾',
|
||||
`metricKey` varchar(64) NOT NULL,
|
||||
`targetValue` float NOT NULL,
|
||||
`tier` int NOT NULL DEFAULT 1,
|
||||
`isHidden` int NOT NULL DEFAULT 0,
|
||||
`isActive` int NOT NULL DEFAULT 1,
|
||||
`sortOrder` int NOT NULL DEFAULT 0,
|
||||
`createdAt` timestamp NOT NULL DEFAULT (now()),
|
||||
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `achievement_definitions_id` PRIMARY KEY(`id`),
|
||||
CONSTRAINT `achievement_definitions_key_unique` UNIQUE(`key`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `user_achievements` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`progressKey` varchar(96) NOT NULL,
|
||||
`userId` int NOT NULL,
|
||||
`achievementKey` varchar(64) NOT NULL,
|
||||
`currentValue` float NOT NULL DEFAULT 0,
|
||||
`progressPct` float NOT NULL DEFAULT 0,
|
||||
`unlockedAt` timestamp,
|
||||
`lastEvaluatedAt` timestamp NOT NULL DEFAULT (now()),
|
||||
`createdAt` timestamp NOT NULL DEFAULT (now()),
|
||||
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `user_achievements_id` PRIMARY KEY(`id`),
|
||||
CONSTRAINT `user_achievements_progressKey_unique` UNIQUE(`progressKey`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `background_tasks`
|
||||
MODIFY COLUMN `type` enum('media_finalize','training_plan_generate','training_plan_adjust','analysis_corrections','pose_correction_multimodal','ntrp_refresh_user','ntrp_refresh_all') NOT NULL;
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `admin_audit_logs` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`adminUserId` int NOT NULL,
|
||||
`actionType` varchar(64) NOT NULL,
|
||||
`entityType` varchar(64) NOT NULL,
|
||||
`entityId` varchar(96),
|
||||
`targetUserId` int,
|
||||
`payload` json,
|
||||
`createdAt` timestamp NOT NULL DEFAULT (now()),
|
||||
CONSTRAINT `admin_audit_logs_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `app_settings` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`settingKey` varchar(64) NOT NULL,
|
||||
`label` varchar(128) NOT NULL,
|
||||
`description` text,
|
||||
`value` json,
|
||||
`createdAt` timestamp NOT NULL DEFAULT (now()),
|
||||
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `app_settings_id` PRIMARY KEY(`id`),
|
||||
CONSTRAINT `app_settings_settingKey_unique` UNIQUE(`settingKey`)
|
||||
);
|
||||
@@ -0,0 +1,8 @@
|
||||
DELETE FROM `tutorial_progress`
|
||||
WHERE `tutorialId` IN (
|
||||
SELECT `id` FROM `tutorial_videos`
|
||||
WHERE `topicArea` IS NOT NULL AND `topicArea` <> 'tennis_skill'
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DELETE FROM `tutorial_videos`
|
||||
WHERE `topicArea` IS NOT NULL AND `topicArea` <> 'tennis_skill';
|
||||
@@ -0,0 +1,17 @@
|
||||
CREATE TABLE `live_analysis_runtime` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`userId` int NOT NULL,
|
||||
`ownerSid` varchar(96),
|
||||
`status` enum('idle','active','ended') NOT NULL DEFAULT 'idle',
|
||||
`title` varchar(256),
|
||||
`sessionMode` enum('practice','pk') NOT NULL DEFAULT 'practice',
|
||||
`mediaSessionId` varchar(96),
|
||||
`startedAt` timestamp,
|
||||
`endedAt` timestamp,
|
||||
`lastHeartbeatAt` timestamp,
|
||||
`snapshot` json,
|
||||
`createdAt` timestamp NOT NULL DEFAULT (now()),
|
||||
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `live_analysis_runtime_id` PRIMARY KEY(`id`),
|
||||
CONSTRAINT `live_analysis_runtime_user_idx` UNIQUE(`userId`)
|
||||
);
|
||||
@@ -36,6 +36,55 @@
|
||||
"when": 1773490358606,
|
||||
"tag": "0004_exotic_randall",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "5",
|
||||
"when": 1773504000000,
|
||||
"tag": "0005_lively_taskmaster",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "5",
|
||||
"when": 1773510000000,
|
||||
"tag": "0006_solid_vision_library",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "5",
|
||||
"when": 1773543600000,
|
||||
"tag": "0007_grounded_live_ops",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "5",
|
||||
"when": 1773600000000,
|
||||
"tag": "0008_tutorial_academy_refresh",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "5",
|
||||
"when": 1773633600000,
|
||||
"tag": "0009_training_profile_baseline",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "5",
|
||||
"when": 1773662400000,
|
||||
"tag": "0010_remove_non_tennis_tutorials",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "5",
|
||||
"when": 1773691200000,
|
||||
"tag": "0011_live_analysis_runtime",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { int, mysqlEnum, mysqlTable, text, timestamp, varchar, json, float } from "drizzle-orm/mysql-core";
|
||||
import { int, mysqlEnum, mysqlTable, text, timestamp, varchar, json, float, uniqueIndex } from "drizzle-orm/mysql-core";
|
||||
|
||||
/**
|
||||
* Core user table - supports both OAuth and simple username login
|
||||
@@ -16,6 +16,21 @@ export const users = mysqlTable("users", {
|
||||
trainingGoals: text("trainingGoals"),
|
||||
/** NTRP rating (1.0 - 5.0) */
|
||||
ntrpRating: float("ntrpRating").default(1.5),
|
||||
/** Manual NTRP baseline before automated rating is established */
|
||||
manualNtrpRating: float("manualNtrpRating"),
|
||||
manualNtrpCapturedAt: timestamp("manualNtrpCapturedAt"),
|
||||
/** Training assessment profile */
|
||||
heightCm: float("heightCm"),
|
||||
weightKg: float("weightKg"),
|
||||
sprintSpeedScore: int("sprintSpeedScore"),
|
||||
explosivePowerScore: int("explosivePowerScore"),
|
||||
agilityScore: int("agilityScore"),
|
||||
enduranceScore: int("enduranceScore"),
|
||||
flexibilityScore: int("flexibilityScore"),
|
||||
coreStabilityScore: int("coreStabilityScore"),
|
||||
shoulderMobilityScore: int("shoulderMobilityScore"),
|
||||
hipMobilityScore: int("hipMobilityScore"),
|
||||
assessmentNotes: text("assessmentNotes"),
|
||||
/** Total training sessions completed */
|
||||
totalSessions: int("totalSessions").default(0),
|
||||
/** Total training minutes */
|
||||
@@ -152,6 +167,18 @@ export const trainingRecords = mysqlTable("training_records", {
|
||||
planId: int("planId"),
|
||||
/** Exercise name/type */
|
||||
exerciseName: varchar("exerciseName", { length: 128 }).notNull(),
|
||||
exerciseType: varchar("exerciseType", { length: 64 }),
|
||||
/** Source of the training fact */
|
||||
sourceType: varchar("sourceType", { length: 32 }).default("manual"),
|
||||
/** Reference id from source system */
|
||||
sourceId: varchar("sourceId", { length: 64 }),
|
||||
/** Optional linked video */
|
||||
videoId: int("videoId"),
|
||||
/** Optional linked plan match */
|
||||
linkedPlanId: int("linkedPlanId"),
|
||||
matchConfidence: float("matchConfidence"),
|
||||
actionCount: int("actionCount").default(0),
|
||||
metadata: json("metadata"),
|
||||
/** Duration in minutes */
|
||||
durationMinutes: int("durationMinutes"),
|
||||
/** Completion status */
|
||||
@@ -168,6 +195,118 @@ export const trainingRecords = mysqlTable("training_records", {
|
||||
export type TrainingRecord = typeof trainingRecords.$inferSelect;
|
||||
export type InsertTrainingRecord = typeof trainingRecords.$inferInsert;
|
||||
|
||||
/**
|
||||
* Live analysis sessions captured from the realtime camera workflow.
|
||||
*/
|
||||
export const liveAnalysisSessions = mysqlTable("live_analysis_sessions", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
userId: int("userId").notNull(),
|
||||
title: varchar("title", { length: 256 }).notNull(),
|
||||
sessionMode: mysqlEnum("sessionMode", ["practice", "pk"]).default("practice").notNull(),
|
||||
status: mysqlEnum("status", ["active", "completed", "aborted"]).default("completed").notNull(),
|
||||
startedAt: timestamp("startedAt").defaultNow().notNull(),
|
||||
endedAt: timestamp("endedAt"),
|
||||
durationMs: int("durationMs").default(0).notNull(),
|
||||
dominantAction: varchar("dominantAction", { length: 64 }),
|
||||
overallScore: float("overallScore"),
|
||||
postureScore: float("postureScore"),
|
||||
balanceScore: float("balanceScore"),
|
||||
techniqueScore: float("techniqueScore"),
|
||||
footworkScore: float("footworkScore"),
|
||||
consistencyScore: float("consistencyScore"),
|
||||
unknownActionRatio: float("unknownActionRatio"),
|
||||
totalSegments: int("totalSegments").default(0).notNull(),
|
||||
effectiveSegments: int("effectiveSegments").default(0).notNull(),
|
||||
totalActionCount: int("totalActionCount").default(0).notNull(),
|
||||
videoId: int("videoId"),
|
||||
videoUrl: text("videoUrl"),
|
||||
summary: text("summary"),
|
||||
feedback: json("feedback"),
|
||||
metrics: json("metrics"),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
});
|
||||
|
||||
export type LiveAnalysisSession = typeof liveAnalysisSessions.$inferSelect;
|
||||
export type InsertLiveAnalysisSession = typeof liveAnalysisSessions.$inferInsert;
|
||||
|
||||
/**
|
||||
* Per-user runtime state for the current live-camera analysis lock.
|
||||
*/
|
||||
export const liveAnalysisRuntime = mysqlTable("live_analysis_runtime", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
userId: int("userId").notNull(),
|
||||
ownerSid: varchar("ownerSid", { length: 96 }),
|
||||
status: mysqlEnum("status", ["idle", "active", "ended"]).default("idle").notNull(),
|
||||
title: varchar("title", { length: 256 }),
|
||||
sessionMode: mysqlEnum("sessionMode", ["practice", "pk"]).default("practice").notNull(),
|
||||
mediaSessionId: varchar("mediaSessionId", { length: 96 }),
|
||||
startedAt: timestamp("startedAt"),
|
||||
endedAt: timestamp("endedAt"),
|
||||
lastHeartbeatAt: timestamp("lastHeartbeatAt"),
|
||||
snapshot: json("snapshot"),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
}, (table) => ({
|
||||
userIdUnique: uniqueIndex("live_analysis_runtime_user_idx").on(table.userId),
|
||||
}));
|
||||
|
||||
export type LiveAnalysisRuntime = typeof liveAnalysisRuntime.$inferSelect;
|
||||
export type InsertLiveAnalysisRuntime = typeof liveAnalysisRuntime.$inferInsert;
|
||||
|
||||
/**
|
||||
* Action segments extracted from a realtime analysis session.
|
||||
*/
|
||||
export const liveActionSegments = mysqlTable("live_action_segments", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
sessionId: int("sessionId").notNull(),
|
||||
actionType: varchar("actionType", { length: 64 }).notNull(),
|
||||
isUnknown: int("isUnknown").default(0).notNull(),
|
||||
startMs: int("startMs").notNull(),
|
||||
endMs: int("endMs").notNull(),
|
||||
durationMs: int("durationMs").notNull(),
|
||||
confidenceAvg: float("confidenceAvg"),
|
||||
score: float("score"),
|
||||
peakScore: float("peakScore"),
|
||||
frameCount: int("frameCount").default(0).notNull(),
|
||||
issueSummary: json("issueSummary"),
|
||||
keyFrames: json("keyFrames"),
|
||||
clipLabel: varchar("clipLabel", { length: 128 }),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
}, (table) => ({
|
||||
sessionIndex: uniqueIndex("live_action_segments_session_start_idx").on(table.sessionId, table.startMs),
|
||||
}));
|
||||
|
||||
export type LiveActionSegment = typeof liveActionSegments.$inferSelect;
|
||||
export type InsertLiveActionSegment = typeof liveActionSegments.$inferInsert;
|
||||
|
||||
/**
|
||||
* Daily training aggregate used for streaks, achievements and daily NTRP refresh.
|
||||
*/
|
||||
export const dailyTrainingAggregates = mysqlTable("daily_training_aggregates", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
dayKey: varchar("dayKey", { length: 32 }).notNull().unique(),
|
||||
userId: int("userId").notNull(),
|
||||
trainingDate: varchar("trainingDate", { length: 10 }).notNull(),
|
||||
totalMinutes: int("totalMinutes").default(0).notNull(),
|
||||
sessionCount: int("sessionCount").default(0).notNull(),
|
||||
analysisCount: int("analysisCount").default(0).notNull(),
|
||||
liveAnalysisCount: int("liveAnalysisCount").default(0).notNull(),
|
||||
recordingCount: int("recordingCount").default(0).notNull(),
|
||||
pkCount: int("pkCount").default(0).notNull(),
|
||||
totalActions: int("totalActions").default(0).notNull(),
|
||||
effectiveActions: int("effectiveActions").default(0).notNull(),
|
||||
unknownActions: int("unknownActions").default(0).notNull(),
|
||||
totalScore: float("totalScore").default(0).notNull(),
|
||||
averageScore: float("averageScore").default(0).notNull(),
|
||||
metadata: json("metadata"),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
});
|
||||
|
||||
export type DailyTrainingAggregate = typeof dailyTrainingAggregates.$inferSelect;
|
||||
export type InsertDailyTrainingAggregate = typeof dailyTrainingAggregates.$inferInsert;
|
||||
|
||||
/**
|
||||
* NTRP Rating history - tracks rating changes over time
|
||||
*/
|
||||
@@ -188,6 +327,25 @@ export const ratingHistory = mysqlTable("rating_history", {
|
||||
export type RatingHistory = typeof ratingHistory.$inferSelect;
|
||||
export type InsertRatingHistory = typeof ratingHistory.$inferInsert;
|
||||
|
||||
/**
|
||||
* Daily NTRP snapshots generated by async refresh jobs.
|
||||
*/
|
||||
export const ntrpSnapshots = mysqlTable("ntrp_snapshots", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
snapshotKey: varchar("snapshotKey", { length: 64 }).notNull().unique(),
|
||||
userId: int("userId").notNull(),
|
||||
snapshotDate: varchar("snapshotDate", { length: 10 }).notNull(),
|
||||
rating: float("rating").notNull(),
|
||||
triggerType: mysqlEnum("triggerType", ["analysis", "daily", "manual"]).default("daily").notNull(),
|
||||
taskId: varchar("taskId", { length: 64 }),
|
||||
dimensionScores: json("dimensionScores"),
|
||||
sourceSummary: json("sourceSummary"),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export type NtrpSnapshot = typeof ntrpSnapshots.$inferSelect;
|
||||
export type InsertNtrpSnapshot = typeof ntrpSnapshots.$inferInsert;
|
||||
|
||||
/**
|
||||
* Daily check-in records for streak tracking
|
||||
*/
|
||||
@@ -223,20 +381,82 @@ export const userBadges = mysqlTable("user_badges", {
|
||||
export type UserBadge = typeof userBadges.$inferSelect;
|
||||
export type InsertUserBadge = typeof userBadges.$inferInsert;
|
||||
|
||||
/**
|
||||
* Achievement definitions that can scale beyond the legacy badge system.
|
||||
*/
|
||||
export const achievementDefinitions = mysqlTable("achievement_definitions", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
key: varchar("key", { length: 64 }).notNull().unique(),
|
||||
name: varchar("name", { length: 128 }).notNull(),
|
||||
description: text("description"),
|
||||
category: varchar("category", { length: 32 }).notNull(),
|
||||
rarity: varchar("rarity", { length: 16 }).default("common").notNull(),
|
||||
icon: varchar("icon", { length: 16 }).default("🎾").notNull(),
|
||||
metricKey: varchar("metricKey", { length: 64 }).notNull(),
|
||||
targetValue: float("targetValue").notNull(),
|
||||
tier: int("tier").default(1).notNull(),
|
||||
isHidden: int("isHidden").default(0).notNull(),
|
||||
isActive: int("isActive").default(1).notNull(),
|
||||
sortOrder: int("sortOrder").default(0).notNull(),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
});
|
||||
|
||||
export type AchievementDefinition = typeof achievementDefinitions.$inferSelect;
|
||||
export type InsertAchievementDefinition = typeof achievementDefinitions.$inferInsert;
|
||||
|
||||
/**
|
||||
* User achievement progress and unlock records.
|
||||
*/
|
||||
export const userAchievements = mysqlTable("user_achievements", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
progressKey: varchar("progressKey", { length: 96 }).notNull().unique(),
|
||||
userId: int("userId").notNull(),
|
||||
achievementKey: varchar("achievementKey", { length: 64 }).notNull(),
|
||||
currentValue: float("currentValue").default(0).notNull(),
|
||||
progressPct: float("progressPct").default(0).notNull(),
|
||||
unlockedAt: timestamp("unlockedAt"),
|
||||
lastEvaluatedAt: timestamp("lastEvaluatedAt").defaultNow().notNull(),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
});
|
||||
|
||||
export type UserAchievement = typeof userAchievements.$inferSelect;
|
||||
export type InsertUserAchievement = typeof userAchievements.$inferInsert;
|
||||
|
||||
/**
|
||||
* Tutorial video library - professional coaching reference videos
|
||||
*/
|
||||
export const tutorialVideos = mysqlTable("tutorial_videos", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
slug: varchar("slug", { length: 128 }),
|
||||
title: varchar("title", { length: 256 }).notNull(),
|
||||
category: varchar("category", { length: 64 }).notNull(),
|
||||
skillLevel: mysqlEnum("skillLevel", ["beginner", "intermediate", "advanced"]).default("beginner"),
|
||||
topicArea: varchar("topicArea", { length: 32 }).default("tennis_skill"),
|
||||
contentFormat: varchar("contentFormat", { length: 16 }).default("video"),
|
||||
sourcePlatform: varchar("sourcePlatform", { length: 16 }).default("none"),
|
||||
description: text("description"),
|
||||
heroSummary: text("heroSummary"),
|
||||
keyPoints: json("keyPoints"),
|
||||
commonMistakes: json("commonMistakes"),
|
||||
videoUrl: text("videoUrl"),
|
||||
externalUrl: text("externalUrl"),
|
||||
platformVideoId: varchar("platformVideoId", { length: 64 }),
|
||||
thumbnailUrl: text("thumbnailUrl"),
|
||||
duration: int("duration"),
|
||||
estimatedEffortMinutes: int("estimatedEffortMinutes"),
|
||||
prerequisites: json("prerequisites"),
|
||||
learningObjectives: json("learningObjectives"),
|
||||
stepSections: json("stepSections"),
|
||||
deliverables: json("deliverables"),
|
||||
relatedDocPaths: json("relatedDocPaths"),
|
||||
viewCount: int("viewCount"),
|
||||
commentCount: int("commentCount"),
|
||||
metricsFetchedAt: timestamp("metricsFetchedAt"),
|
||||
completionAchievementKey: varchar("completionAchievementKey", { length: 64 }),
|
||||
isFeatured: int("isFeatured").default(0),
|
||||
featuredOrder: int("featuredOrder").default(0),
|
||||
sortOrder: int("sortOrder").default(0),
|
||||
isPublished: int("isPublished").default(1),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
@@ -254,6 +474,8 @@ export const tutorialProgress = mysqlTable("tutorial_progress", {
|
||||
userId: int("userId").notNull(),
|
||||
tutorialId: int("tutorialId").notNull(),
|
||||
watched: int("watched").default(0),
|
||||
completed: int("completed").default(0),
|
||||
completedAt: timestamp("completedAt"),
|
||||
comparisonVideoId: int("comparisonVideoId"),
|
||||
selfScore: float("selfScore"),
|
||||
notes: text("notes"),
|
||||
@@ -301,3 +523,123 @@ export const notificationLog = mysqlTable("notification_log", {
|
||||
export type NotificationLogEntry = typeof notificationLog.$inferSelect;
|
||||
export type InsertNotificationLog = typeof notificationLog.$inferInsert;
|
||||
|
||||
/**
|
||||
* Background task queue for long-running or retryable work.
|
||||
*/
|
||||
export const backgroundTasks = mysqlTable("background_tasks", {
|
||||
id: varchar("id", { length: 36 }).primaryKey(),
|
||||
userId: int("userId").notNull(),
|
||||
type: mysqlEnum("type", [
|
||||
"media_finalize",
|
||||
"training_plan_generate",
|
||||
"training_plan_adjust",
|
||||
"analysis_corrections",
|
||||
"pose_correction_multimodal",
|
||||
"ntrp_refresh_user",
|
||||
"ntrp_refresh_all",
|
||||
]).notNull(),
|
||||
status: mysqlEnum("status", ["queued", "running", "succeeded", "failed"]).notNull().default("queued"),
|
||||
title: varchar("title", { length: 256 }).notNull(),
|
||||
message: text("message"),
|
||||
progress: int("progress").notNull().default(0),
|
||||
payload: json("payload").notNull(),
|
||||
result: json("result"),
|
||||
error: text("error"),
|
||||
attempts: int("attempts").notNull().default(0),
|
||||
maxAttempts: int("maxAttempts").notNull().default(3),
|
||||
workerId: varchar("workerId", { length: 96 }),
|
||||
runAfter: timestamp("runAfter").defaultNow().notNull(),
|
||||
lockedAt: timestamp("lockedAt"),
|
||||
startedAt: timestamp("startedAt"),
|
||||
completedAt: timestamp("completedAt"),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
});
|
||||
|
||||
export type BackgroundTask = typeof backgroundTasks.$inferSelect;
|
||||
export type InsertBackgroundTask = typeof backgroundTasks.$inferInsert;
|
||||
|
||||
/**
|
||||
* Admin audit trail for privileged actions.
|
||||
*/
|
||||
export const adminAuditLogs = mysqlTable("admin_audit_logs", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
adminUserId: int("adminUserId").notNull(),
|
||||
actionType: varchar("actionType", { length: 64 }).notNull(),
|
||||
entityType: varchar("entityType", { length: 64 }).notNull(),
|
||||
entityId: varchar("entityId", { length: 96 }),
|
||||
targetUserId: int("targetUserId"),
|
||||
payload: json("payload"),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export type AdminAuditLog = typeof adminAuditLogs.$inferSelect;
|
||||
export type InsertAdminAuditLog = typeof adminAuditLogs.$inferInsert;
|
||||
|
||||
/**
|
||||
* App settings editable from the admin console.
|
||||
*/
|
||||
export const appSettings = mysqlTable("app_settings", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
settingKey: varchar("settingKey", { length: 64 }).notNull().unique(),
|
||||
label: varchar("label", { length: 128 }).notNull(),
|
||||
description: text("description"),
|
||||
value: json("value"),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
});
|
||||
|
||||
export type AppSetting = typeof appSettings.$inferSelect;
|
||||
export type InsertAppSetting = typeof appSettings.$inferInsert;
|
||||
|
||||
/**
|
||||
* Vision reference library - canonical public tennis images used for multimodal evaluation
|
||||
*/
|
||||
export const visionReferenceImages = mysqlTable("vision_reference_images", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
slug: varchar("slug", { length: 128 }).notNull().unique(),
|
||||
title: varchar("title", { length: 256 }).notNull(),
|
||||
exerciseType: varchar("exerciseType", { length: 64 }).notNull(),
|
||||
imageUrl: text("imageUrl").notNull(),
|
||||
sourcePageUrl: text("sourcePageUrl").notNull(),
|
||||
sourceLabel: varchar("sourceLabel", { length: 128 }).notNull(),
|
||||
author: varchar("author", { length: 128 }),
|
||||
license: varchar("license", { length: 128 }),
|
||||
expectedFocus: json("expectedFocus"),
|
||||
tags: json("tags"),
|
||||
notes: text("notes"),
|
||||
sortOrder: int("sortOrder").default(0).notNull(),
|
||||
isPublished: int("isPublished").default(1).notNull(),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
});
|
||||
|
||||
export type VisionReferenceImage = typeof visionReferenceImages.$inferSelect;
|
||||
export type InsertVisionReferenceImage = typeof visionReferenceImages.$inferInsert;
|
||||
|
||||
/**
|
||||
* Vision test run history - records each multimodal evaluation against the standard library
|
||||
*/
|
||||
export const visionTestRuns = mysqlTable("vision_test_runs", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
taskId: varchar("taskId", { length: 64 }).notNull().unique(),
|
||||
userId: int("userId").notNull(),
|
||||
referenceImageId: int("referenceImageId"),
|
||||
title: varchar("title", { length: 256 }).notNull(),
|
||||
exerciseType: varchar("exerciseType", { length: 64 }).notNull(),
|
||||
imageUrl: text("imageUrl").notNull(),
|
||||
status: mysqlEnum("status", ["queued", "succeeded", "failed"]).default("queued").notNull(),
|
||||
visionStatus: mysqlEnum("visionStatus", ["pending", "ok", "fallback", "failed"]).default("pending").notNull(),
|
||||
configuredModel: varchar("configuredModel", { length: 128 }),
|
||||
expectedFocus: json("expectedFocus"),
|
||||
summary: text("summary"),
|
||||
corrections: text("corrections"),
|
||||
report: json("report"),
|
||||
warning: text("warning"),
|
||||
error: text("error"),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
});
|
||||
|
||||
export type VisionTestRun = typeof visionTestRuns.$inferSelect;
|
||||
export type InsertVisionTestRun = typeof visionTestRuns.$inferInsert;
|
||||
|
||||
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=
|
||||
1208
media/main.go
普通文件
322
media/main_test.go
普通文件
@@ -0,0 +1,322 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLiveFrameUploadPublishesRelayFrame(t *testing.T) {
|
||||
store, err := newSessionStore(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("newSessionStore: %v", err)
|
||||
}
|
||||
|
||||
server := newMediaServer(store)
|
||||
session, err := store.createSession(CreateSessionRequest{UserID: "1", Title: "Relay Session"})
|
||||
if err != nil {
|
||||
t.Fatalf("createSession: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/media/sessions/"+session.ID+"/live-frame", strings.NewReader("jpeg-frame"))
|
||||
req.Header.Set("Content-Type", "image/jpeg")
|
||||
res := httptest.NewRecorder()
|
||||
server.routes().ServeHTTP(res, req)
|
||||
|
||||
if res.Code != http.StatusAccepted {
|
||||
t.Fatalf("expected live-frame upload 202, got %d", res.Code)
|
||||
}
|
||||
|
||||
current, err := store.getSession(session.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("getSession: %v", err)
|
||||
}
|
||||
if current.LiveFrameURL == "" || current.LiveFrameUpdated == "" {
|
||||
t.Fatalf("expected live frame metadata to be recorded, got %#v", current)
|
||||
}
|
||||
if !current.StreamConnected {
|
||||
t.Fatalf("expected session stream connected after frame upload")
|
||||
}
|
||||
|
||||
framePath := store.liveFramePath(session.ID)
|
||||
body, err := os.ReadFile(framePath)
|
||||
if err != nil {
|
||||
t.Fatalf("read live frame: %v", err)
|
||||
}
|
||||
if string(body) != "jpeg-frame" {
|
||||
t.Fatalf("unexpected live frame content: %q", string(body))
|
||||
}
|
||||
}
|
||||
24
package.json
@@ -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"));
|
||||
});
|
||||
}
|
||||
|
||||
1770
server/db.ts
@@ -1,7 +1,11 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { appRouter } from "./routers";
|
||||
import { COOKIE_NAME } from "../shared/const";
|
||||
import type { TrpcContext } from "./_core/context";
|
||||
import * as db from "./db";
|
||||
import * as trainingAutomation from "./trainingAutomation";
|
||||
import { ENV } from "./_core/env";
|
||||
import { sdk } from "./_core/sdk";
|
||||
|
||||
type AuthenticatedUser = NonNullable<TrpcContext["user"]>;
|
||||
|
||||
@@ -16,6 +20,19 @@ function createTestUser(overrides?: Partial<AuthenticatedUser>): AuthenticatedUs
|
||||
skillLevel: "beginner",
|
||||
trainingGoals: null,
|
||||
ntrpRating: 1.5,
|
||||
manualNtrpRating: null,
|
||||
manualNtrpCapturedAt: null,
|
||||
heightCm: null,
|
||||
weightKg: null,
|
||||
sprintSpeedScore: null,
|
||||
explosivePowerScore: null,
|
||||
agilityScore: null,
|
||||
enduranceScore: null,
|
||||
flexibilityScore: null,
|
||||
coreStabilityScore: null,
|
||||
shoulderMobilityScore: null,
|
||||
hipMobilityScore: null,
|
||||
assessmentNotes: null,
|
||||
totalSessions: 0,
|
||||
totalMinutes: 0,
|
||||
totalShots: 0,
|
||||
@@ -28,7 +45,7 @@ function createTestUser(overrides?: Partial<AuthenticatedUser>): AuthenticatedUs
|
||||
};
|
||||
}
|
||||
|
||||
function createMockContext(user: AuthenticatedUser | null = null): {
|
||||
function createMockContext(user: AuthenticatedUser | null = null, sessionSid = "test-session-sid"): {
|
||||
ctx: TrpcContext;
|
||||
clearedCookies: { name: string; options: Record<string, unknown> }[];
|
||||
setCookies: { name: string; value: string; options: Record<string, unknown> }[];
|
||||
@@ -39,6 +56,7 @@ function createMockContext(user: AuthenticatedUser | null = null): {
|
||||
return {
|
||||
ctx: {
|
||||
user,
|
||||
sessionSid: user ? sessionSid : null,
|
||||
req: {
|
||||
protocol: "https",
|
||||
headers: {},
|
||||
@@ -97,6 +115,28 @@ describe("auth.logout", () => {
|
||||
path: "/",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses lax non-secure cookies for plain http requests", async () => {
|
||||
const user = createTestUser();
|
||||
const { ctx, clearedCookies } = createMockContext(user);
|
||||
ctx.req = {
|
||||
protocol: "http",
|
||||
headers: {},
|
||||
} as TrpcContext["req"];
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
|
||||
const result = await caller.auth.logout();
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(clearedCookies).toHaveLength(1);
|
||||
expect(clearedCookies[0]?.options).toMatchObject({
|
||||
maxAge: -1,
|
||||
secure: false,
|
||||
sameSite: "lax",
|
||||
httpOnly: true,
|
||||
path: "/",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("auth.loginWithUsername input validation", () => {
|
||||
@@ -113,6 +153,68 @@ describe("auth.loginWithUsername input validation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("auth.loginWithUsername invite flow", () => {
|
||||
const originalInviteCode = ENV.registrationInviteCode;
|
||||
|
||||
beforeEach(() => {
|
||||
ENV.registrationInviteCode = "CA2026";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
ENV.registrationInviteCode = originalInviteCode;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("allows existing users to log in without an invite code", async () => {
|
||||
const existingUser = createTestUser({ name: "ExistingPlayer", openId: "existing-1" });
|
||||
const { ctx, setCookies } = createMockContext(null);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
|
||||
vi.spyOn(db, "getUserByUsername").mockResolvedValueOnce(existingUser);
|
||||
const createUsernameAccountSpy = vi.spyOn(db, "createUsernameAccount").mockResolvedValueOnce({
|
||||
user: existingUser,
|
||||
isNew: false,
|
||||
});
|
||||
vi.spyOn(sdk, "createSessionToken").mockResolvedValueOnce("session-token");
|
||||
|
||||
const result = await caller.auth.loginWithUsername({ username: "ExistingPlayer" });
|
||||
|
||||
expect(result.isNew).toBe(false);
|
||||
expect(createUsernameAccountSpy).toHaveBeenCalledWith("ExistingPlayer", undefined);
|
||||
expect(setCookies[0]?.name).toBe(COOKIE_NAME);
|
||||
});
|
||||
|
||||
it("rejects new users without the correct invite code", async () => {
|
||||
const { ctx } = createMockContext(null);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
|
||||
vi.spyOn(db, "getUserByUsername").mockResolvedValueOnce(undefined);
|
||||
const createUsernameAccountSpy = vi.spyOn(db, "createUsernameAccount");
|
||||
|
||||
await expect(caller.auth.loginWithUsername({ username: "NewPlayer" })).rejects.toThrow("新用户注册需要正确的邀请码");
|
||||
expect(createUsernameAccountSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows new users with the correct invite code", async () => {
|
||||
const newUser = createTestUser({ name: "NewPlayer", openId: "new-1" });
|
||||
const { ctx, setCookies } = createMockContext(null);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
|
||||
vi.spyOn(db, "getUserByUsername").mockResolvedValueOnce(undefined);
|
||||
const createUsernameAccountSpy = vi.spyOn(db, "createUsernameAccount").mockResolvedValueOnce({
|
||||
user: newUser,
|
||||
isNew: true,
|
||||
});
|
||||
vi.spyOn(sdk, "createSessionToken").mockResolvedValueOnce("session-token");
|
||||
|
||||
const result = await caller.auth.loginWithUsername({ username: "NewPlayer", inviteCode: "CA2026" });
|
||||
|
||||
expect(result.isNew).toBe(true);
|
||||
expect(createUsernameAccountSpy).toHaveBeenCalledWith("NewPlayer", "CA2026");
|
||||
expect(setCookies[0]?.name).toBe(COOKIE_NAME);
|
||||
});
|
||||
});
|
||||
|
||||
// ===== PROFILE TESTS =====
|
||||
|
||||
describe("profile.stats", () => {
|
||||
@@ -151,6 +253,30 @@ describe("profile.update input validation", () => {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts training assessment fields", async () => {
|
||||
const user = createTestUser();
|
||||
const { ctx } = createMockContext(user);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
|
||||
try {
|
||||
await caller.profile.update({
|
||||
heightCm: 178,
|
||||
weightKg: 68,
|
||||
sprintSpeedScore: 4,
|
||||
explosivePowerScore: 3,
|
||||
agilityScore: 4,
|
||||
enduranceScore: 3,
|
||||
flexibilityScore: 3,
|
||||
coreStabilityScore: 4,
|
||||
shoulderMobilityScore: 3,
|
||||
hipMobilityScore: 4,
|
||||
manualNtrpRating: 2.5,
|
||||
});
|
||||
} catch (e: any) {
|
||||
expect(e.message).not.toContain("invalid_type");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ===== TRAINING PLAN TESTS =====
|
||||
@@ -193,6 +319,19 @@ describe("plan.generate input validation", () => {
|
||||
caller.plan.generate({ skillLevel: "beginner", durationDays: 7 })
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("rejects generation when training profile is incomplete", async () => {
|
||||
const user = createTestUser();
|
||||
const { ctx } = createMockContext(user);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
|
||||
vi.spyOn(db, "getUserById").mockResolvedValueOnce(user);
|
||||
vi.spyOn(db, "getLatestNtrpSnapshot").mockResolvedValueOnce(null as any);
|
||||
|
||||
await expect(
|
||||
caller.plan.generate({ skillLevel: "beginner", durationDays: 7 })
|
||||
).rejects.toThrow(/训练计划生成前请先完善训练档案/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("plan.list", () => {
|
||||
@@ -209,6 +348,17 @@ describe("plan.active", () => {
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
await expect(caller.plan.active()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("returns null when the user has no active plan", async () => {
|
||||
const user = createTestUser();
|
||||
const { ctx } = createMockContext(user);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
const getActivePlanSpy = vi.spyOn(db, "getActivePlan").mockResolvedValueOnce(null);
|
||||
|
||||
await expect(caller.plan.active()).resolves.toBeNull();
|
||||
|
||||
getActivePlanSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("plan.adjust input validation", () => {
|
||||
@@ -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");
|
||||
}
|
||||