比较提交
69 次代码提交
36907d1110
...
main
| 作者 | SHA1 | 提交日期 | |
|---|---|---|---|
|
|
6fa54f78f1 | ||
|
|
32ffad1545 | ||
|
|
495da60212 | ||
|
|
1adadbad8c | ||
|
|
b1752110fb | ||
|
|
0af88b3a15 | ||
|
|
902bd783c9 | ||
|
|
597f16d0b9 | ||
|
|
f3f7e1982c | ||
|
|
63dbfd2787 | ||
|
|
06b9701e03 | ||
|
|
8e9e4915e2 | ||
|
|
634a4704c7 | ||
|
|
bb46d26c0e | ||
|
|
bacd712dbc | ||
|
|
78a7c755e3 | ||
|
|
a211562860 | ||
|
|
09b1b95e2c | ||
|
|
922a9fb63f | ||
|
|
31bead3452 | ||
|
|
a5103685fb | ||
|
|
f9db6ef590 | ||
|
|
13e59b8e8a | ||
|
|
2b72ef9200 | ||
|
|
09cd5b4d85 | ||
|
|
7aba508247 | ||
|
|
cf06de944f | ||
|
|
4e4122d758 | ||
|
|
f0bbe4c82f | ||
|
|
4fb2d092d7 | ||
|
|
e3fe9a8e7b | ||
|
|
fe5e539a47 | ||
|
|
139dc61b61 | ||
|
|
264d49475b | ||
|
|
5c2dcf23ba | ||
|
|
3763f5b515 | ||
|
|
1ce94f6f57 | ||
|
|
669497e625 | ||
|
|
71caf0de19 | ||
|
|
67b27e3551 | ||
|
|
a9ea94fb78 | ||
|
|
c4ec397ed3 | ||
|
|
bd8998166b | ||
|
|
143c60a054 | ||
|
|
bee24d547d | ||
|
|
a1689ee95e | ||
|
|
cb643ac154 | ||
|
|
585fd5773d | ||
|
|
e43b969d28 | ||
|
|
afb013193d | ||
|
|
ae93269c62 | ||
|
|
f4f425de42 | ||
|
|
815f96d4e8 | ||
|
|
edc66ea5bc | ||
|
|
d1b6603061 | ||
|
|
ad83ce9c68 | ||
|
|
20e183d2da | ||
|
|
1cc863e60e | ||
|
|
6943754838 | ||
|
|
bc01a40564 | ||
|
|
bcdd790d91 | ||
|
|
8d3faecb15 | ||
|
|
8df0f91db7 | ||
|
|
f5ad0449a8 | ||
|
|
ba35e50528 | ||
|
|
914f015c30 | ||
|
|
d5431aee0e | ||
|
|
27083d5af9 | ||
|
|
2c418b482e |
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
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"query": "CREATE TABLE `daily_checkins` (\n\t`id` int AUTO_INCREMENT NOT NULL,\n\t`userId` int NOT NULL,\n\t`checkinDate` varchar(10) NOT NULL,\n\t`streakCount` int NOT NULL DEFAULT 1,\n\t`notes` text,\n\t`minutesTrained` int DEFAULT 0,\n\t`createdAt` timestamp NOT NULL DEFAULT (now()),\n\tCONSTRAINT `daily_checkins_id` PRIMARY KEY(`id`)\n);\n\nCREATE TABLE `user_badges` (\n\t`id` int AUTO_INCREMENT NOT NULL,\n\t`userId` int NOT NULL,\n\t`badgeKey` varchar(64) NOT NULL,\n\t`earnedAt` timestamp NOT NULL DEFAULT (now()),\n\tCONSTRAINT `user_badges_id` PRIMARY KEY(`id`)\n);\n\nALTER TABLE `users` ADD `currentStreak` int DEFAULT 0;\nALTER TABLE `users` ADD `longestStreak` int DEFAULT 0;\nALTER TABLE `users` ADD `totalShots` int DEFAULT 0;",
|
||||
"command": "mysql --batch --raw --column-names --default-character-set=utf8mb4 --host gateway04.us-east-1.prod.aws.tidbcloud.com --port 4000 --user 2DECURBBieadmmU.root --database auVVpV3E7dpuxwRrSUT9kL --execute CREATE TABLE `daily_checkins` (\n\t`id` int AUTO_INCREMENT NOT NULL,\n\t`userId` int NOT NULL,\n\t`checkinDate` varchar(10) NOT NULL,\n\t`streakCount` int NOT NULL DEFAULT 1,\n\t`notes` text,\n\t`minutesTrained` int DEFAULT 0,\n\t`createdAt` timestamp NOT NULL DEFAULT (now()),\n\tCONSTRAINT `daily_checkins_id` PRIMARY KEY(`id`)\n);\n\nCREATE TABLE `user_badges` (\n\t`id` int AUTO_INCREMENT NOT NULL,\n\t`userId` int NOT NULL,\n\t`badgeKey` varchar(64) NOT NULL,\n\t`earnedAt` timestamp NOT NULL DEFAULT (now()),\n\tCONSTRAINT `user_badges_id` PRIMARY KEY(`id`)\n);\n\nALTER TABLE `users` ADD `currentStreak` int DEFAULT 0;\nALTER TABLE `users` ADD `longestStreak` int DEFAULT 0;\nALTER TABLE `users` ADD `totalShots` int DEFAULT 0;",
|
||||
"rows": [],
|
||||
"messages": [],
|
||||
"stdout": "",
|
||||
"stderr": "",
|
||||
"execution_time_ms": 4522
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"query": "CREATE TABLE `notification_log` (\n\t`id` int AUTO_INCREMENT NOT NULL,\n\t`userId` int NOT NULL,\n\t`reminderId` int,\n\t`notificationType` varchar(32) NOT NULL,\n\t`title` varchar(256) NOT NULL,\n\t`message` text,\n\t`isRead` int DEFAULT 0,\n\t`createdAt` timestamp NOT NULL DEFAULT (now()),\n\tCONSTRAINT `notification_log_id` PRIMARY KEY(`id`)\n);\n\nCREATE TABLE `training_reminders` (\n\t`id` int AUTO_INCREMENT NOT NULL,\n\t`userId` int NOT NULL,\n\t`reminderType` varchar(32) NOT NULL,\n\t`title` varchar(256) NOT NULL,\n\t`message` text,\n\t`timeOfDay` varchar(5) NOT NULL,\n\t`daysOfWeek` json NOT NULL,\n\t`isActive` int DEFAULT 1,\n\t`lastTriggered` timestamp,\n\t`createdAt` timestamp NOT NULL DEFAULT (now()),\n\t`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,\n\tCONSTRAINT `training_reminders_id` PRIMARY KEY(`id`)\n);\n\nCREATE TABLE `tutorial_progress` (\n\t`id` int AUTO_INCREMENT NOT NULL,\n\t`userId` int NOT NULL,\n\t`tutorialId` int NOT NULL,\n\t`watched` int DEFAULT 0,\n\t`comparisonVideoId` int,\n\t`selfScore` float,\n\t`notes` text,\n\t`createdAt` timestamp NOT NULL DEFAULT (now()),\n\t`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,\n\tCONSTRAINT `tutorial_progress_id` PRIMARY KEY(`id`)\n);\n\nCREATE TABLE `tutorial_videos` (\n\t`id` int AUTO_INCREMENT NOT NULL,\n\t`title` varchar(256) NOT NULL,\n\t`category` varchar(64) NOT NULL,\n\t`skillLevel` enum('beginner','intermediate','advanced') DEFAULT 'beginner',\n\t`description` text,\n\t`keyPoints` json,\n\t`commonMistakes` json,\n\t`videoUrl` text,\n\t`thumbnailUrl` text,\n\t`duration` int,\n\t`sortOrder` int DEFAULT 0,\n\t`isPublished` int DEFAULT 1,\n\t`createdAt` timestamp NOT NULL DEFAULT (now()),\n\t`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,\n\tCONSTRAINT `tutorial_videos_id` PRIMARY KEY(`id`)\n);",
|
||||
"command": "mysql --batch --raw --column-names --default-character-set=utf8mb4 --host gateway04.us-east-1.prod.aws.tidbcloud.com --port 4000 --user 2DECURBBieadmmU.root --database auVVpV3E7dpuxwRrSUT9kL --execute CREATE TABLE `notification_log` (\n\t`id` int AUTO_INCREMENT NOT NULL,\n\t`userId` int NOT NULL,\n\t`reminderId` int,\n\t`notificationType` varchar(32) NOT NULL,\n\t`title` varchar(256) NOT NULL,\n\t`message` text,\n\t`isRead` int DEFAULT 0,\n\t`createdAt` timestamp NOT NULL DEFAULT (now()),\n\tCONSTRAINT `notification_log_id` PRIMARY KEY(`id`)\n);\n\nCREATE TABLE `training_reminders` (\n\t`id` int AUTO_INCREMENT NOT NULL,\n\t`userId` int NOT NULL,\n\t`reminderType` varchar(32) NOT NULL,\n\t`title` varchar(256) NOT NULL,\n\t`message` text,\n\t`timeOfDay` varchar(5) NOT NULL,\n\t`daysOfWeek` json NOT NULL,\n\t`isActive` int DEFAULT 1,\n\t`lastTriggered` timestamp,\n\t`createdAt` timestamp NOT NULL DEFAULT (now()),\n\t`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,\n\tCONSTRAINT `training_reminders_id` PRIMARY KEY(`id`)\n);\n\nCREATE TABLE `tutorial_progress` (\n\t`id` int AUTO_INCREMENT NOT NULL,\n\t`userId` int NOT NULL,\n\t`tutorialId` int NOT NULL,\n\t`watched` int DEFAULT 0,\n\t`comparisonVideoId` int,\n\t`selfScore` float,\n\t`notes` text,\n\t`createdAt` timestamp NOT NULL DEFAULT (now()),\n\t`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,\n\tCONSTRAINT `tutorial_progress_id` PRIMARY KEY(`id`)\n);\n\nCREATE TABLE `tutorial_videos` (\n\t`id` int AUTO_INCREMENT NOT NULL,\n\t`title` varchar(256) NOT NULL,\n\t`category` varchar(64) NOT NULL,\n\t`skillLevel` enum('beginner','intermediate','advanced') DEFAULT 'beginner',\n\t`description` text,\n\t`keyPoints` json,\n\t`commonMistakes` json,\n\t`videoUrl` text,\n\t`thumbnailUrl` text,\n\t`duration` int,\n\t`sortOrder` int DEFAULT 0,\n\t`isPublished` int DEFAULT 1,\n\t`createdAt` timestamp NOT NULL DEFAULT (now()),\n\t`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,\n\tCONSTRAINT `tutorial_videos_id` PRIMARY KEY(`id`)\n);",
|
||||
"rows": [],
|
||||
"messages": [],
|
||||
"stdout": "",
|
||||
"stderr": "",
|
||||
"execution_time_ms": 3057
|
||||
}
|
||||
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"]
|
||||
190
README.md
普通文件
@@ -0,0 +1,190 @@
|
||||
# Tennis Training Hub
|
||||
|
||||
网球训练管理与分析应用,提供训练计划、姿势分析、实时摄像头分析、在线视频录制、成就系统、管理员工作台与视频库管理。当前版本在媒体服务之外新增数据库驱动的后台任务系统,用于承接训练计划生成、动作纠正、多模态分析、录制归档与每日 NTRP 刷新这类高延迟任务。
|
||||
|
||||
## Architecture
|
||||
|
||||
- `client/`: React 19 + TypeScript + Tailwind CSS 4 + shadcn/ui
|
||||
- `server/`: Express + tRPC + Drizzle + MySQL/TiDB,负责业务 API、登录、训练数据与视频库元数据
|
||||
- `media/`: Go 媒体服务,负责录制会话、分段上传、WebRTC 信令、关键片段标记与 FFmpeg 归档
|
||||
- `server/worker.ts`: Node 后台 worker,负责执行重任务队列
|
||||
- `docker-compose.yml`: 单机部署编排
|
||||
- `deploy/nginx.te.hao.work.conf`: `te.hao.work` 的宿主机 nginx 入口配置
|
||||
|
||||
## Realtime Analysis
|
||||
|
||||
实时分析页现在采用“识别 + 录制 + 落库”一体化流程:
|
||||
|
||||
- 浏览器端基于 MediaPipe Pose 自动识别 `forehand / backhand / serve / volley / overhead / slice / lob / unknown`
|
||||
- 最近 6 帧动作结果会做时序加权稳定化,降低正手/反手/未知动作间的瞬时抖动
|
||||
- 连续同类动作会自动合并为片段,最长单段不超过 10 秒
|
||||
- 停止分析后会自动保存动作区间、评分维度、反馈摘要和可选本地录制视频
|
||||
- 实时分析结果会自动回写训练记录、日训练聚合、成就进度与 NTRP 评分链路
|
||||
- 移动端支持竖屏最大化预览,主要操作按钮固定在侧边
|
||||
|
||||
## Video Library And PC Editing
|
||||
|
||||
- 视频库支持直接打开 `PC 轻剪辑工作台`
|
||||
- 轻剪辑支持播放器预览、手动入点/出点、从当前播放位置快速设点
|
||||
- 分析关键时刻会自动生成建议片段;即使视频 metadata 尚未返回,也会按分析帧数估算时间轴
|
||||
- 剪辑草稿保存在浏览器本地,可导出 JSON 供后续后台剪辑任务或人工复核使用
|
||||
|
||||
## Online Recording
|
||||
|
||||
在线录制模块采用双链路设计:
|
||||
|
||||
- 浏览器端 `MediaRecorder` 本地压缩并每 60 秒自动分段上传
|
||||
- 浏览器端 `RTCPeerConnection` 同步建立 WebRTC 低延迟推流链路
|
||||
- 客户端运动检测自动写入关键片段 marker,也支持手动标记
|
||||
- 摄像头中断后自动重连,保留既有分段与会话
|
||||
- Go 媒体 worker 将分段合并归档,并产出 WebM 回放;FFmpeg 可用时额外生成 MP4
|
||||
- Node app worker 轮询媒体归档状态,归档完成后自动登记到视频库并向任务中心反馈结果
|
||||
- 服务端媒体会话校验兼容 `/media/sessions/...` 路径,避免录制结束时因路径不一致导致 404
|
||||
|
||||
## Background Tasks
|
||||
|
||||
统一后台任务覆盖以下路径:
|
||||
|
||||
- `training_plan_generate`
|
||||
- `training_plan_adjust`
|
||||
- `analysis_corrections`
|
||||
- `pose_correction_multimodal`
|
||||
- `media_finalize`
|
||||
- `ntrp_refresh_user`
|
||||
- `ntrp_refresh_all`
|
||||
|
||||
前端提供全局任务中心,页面本地也会显示任务提交、执行中、完成或失败状态。训练页、分析页和录制页都可以在用户离开页面后继续完成后台任务。
|
||||
|
||||
另外提供独立日志页 `/logs`,用于查看后台任务历史、失败原因与通知记录;管理员工作台 `/admin` 可集中查看用户、后台任务、实时分析会话、应用设置和审计日志。
|
||||
|
||||
## Multimodal LLM
|
||||
|
||||
- 文本类任务使用 `LLM_API_URL` / `LLM_API_KEY` / `LLM_MODEL`
|
||||
- 图片类任务可单独指定 `LLM_VISION_API_URL` / `LLM_VISION_API_KEY` / `LLM_VISION_MODEL`
|
||||
- 所有图片输入都要求可从公网访问,因此本地相对路径会通过 `APP_PUBLIC_BASE_URL` 规范化为绝对 URL
|
||||
- 若视觉模型链路返回非标准 JSON 或缺失数组字段,服务端会先做结构兼容和字段补全,再尝试生成视觉报告
|
||||
- 若视觉模型链路不可用,系统会自动回退到结构化指标驱动的文本纠正,避免任务直接失败
|
||||
- 系统内置“视觉标准图库”页面 `/vision-lab`,可把公网网球参考图入库并保存每次识别结果
|
||||
- `ADMIN_USERNAMES` 可指定哪些用户名账号拥有 admin 视角,例如 `H1`
|
||||
- 用户名登录支持直接进入系统;仅首次创建新用户时需要填写 `REGISTRATION_INVITE_CODE`
|
||||
- 新用户首次登录时只需提交一次用户名;若用户名不存在才需要额外填写邀请码
|
||||
- `vision-lab` 支持对历史 `fallback/failed` 记录重新排队,便于修复上游返回不稳定导致的旧数据
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Local development
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
cp .env.example .env
|
||||
set -a && source .env && set +a && pnpm exec drizzle-kit migrate
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
本地开发时:
|
||||
|
||||
- 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 |
@@ -13,6 +13,18 @@ import Analysis from "./pages/Analysis";
|
||||
import Videos from "./pages/Videos";
|
||||
import Progress from "./pages/Progress";
|
||||
import Rating from "./pages/Rating";
|
||||
import Leaderboard from "./pages/Leaderboard";
|
||||
import Checkin from "./pages/Checkin";
|
||||
import LiveCamera from "./pages/LiveCamera";
|
||||
import Recorder from "./pages/Recorder";
|
||||
import Tutorials from "./pages/Tutorials";
|
||||
import Reminders from "./pages/Reminders";
|
||||
import Market from "./pages/Market";
|
||||
import Matches from "./pages/Matches";
|
||||
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 (
|
||||
@@ -45,6 +57,45 @@ function Router() {
|
||||
<Route path="/rating">
|
||||
<DashboardRoute component={Rating} />
|
||||
</Route>
|
||||
<Route path="/leaderboard">
|
||||
<DashboardRoute component={Leaderboard} />
|
||||
</Route>
|
||||
<Route path="/checkin">
|
||||
<DashboardRoute component={Checkin} />
|
||||
</Route>
|
||||
<Route path="/achievements">
|
||||
<DashboardRoute component={Checkin} />
|
||||
</Route>
|
||||
<Route path="/live-camera">
|
||||
<DashboardRoute component={LiveCamera} />
|
||||
</Route>
|
||||
<Route path="/recorder">
|
||||
<DashboardRoute component={Recorder} />
|
||||
</Route>
|
||||
<Route path="/tutorials">
|
||||
<DashboardRoute component={Tutorials} />
|
||||
</Route>
|
||||
<Route path="/reminders">
|
||||
<DashboardRoute component={Reminders} />
|
||||
</Route>
|
||||
<Route path="/market">
|
||||
<DashboardRoute component={Market} />
|
||||
</Route>
|
||||
<Route path="/matches">
|
||||
<DashboardRoute component={Matches} />
|
||||
</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>
|
||||
|
||||
@@ -22,19 +22,49 @@ import {
|
||||
import { useIsMobile } from "@/hooks/useMobile";
|
||||
import {
|
||||
LayoutDashboard, LogOut, PanelLeft, Target, Video,
|
||||
Award, Activity, FileVideo
|
||||
Award, Activity, FileVideo, Trophy, Flame, Camera, CircleDot,
|
||||
BookOpen, Bell, Microscope, ScrollText, Shield, Radar, Swords
|
||||
} 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 = [
|
||||
{ icon: LayoutDashboard, label: "仪表盘", path: "/dashboard" },
|
||||
{ icon: Target, label: "训练计划", path: "/training" },
|
||||
{ icon: Video, label: "视频分析", path: "/analysis" },
|
||||
{ icon: FileVideo, label: "视频库", path: "/videos" },
|
||||
{ icon: Activity, label: "训练进度", path: "/progress" },
|
||||
{ icon: Award, label: "NTRP评分", path: "/rating" },
|
||||
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: Camera, label: "实时分析", path: "/live-camera", group: "analysis" },
|
||||
{ icon: CircleDot, label: "在线录制", path: "/recorder", group: "analysis" },
|
||||
{ icon: Video, label: "视频分析", path: "/analysis", group: "analysis" },
|
||||
{ icon: FileVideo, label: "视频库", path: "/videos", group: "analysis" },
|
||||
{ icon: Activity, label: "训练进度", path: "/progress", group: "stats" },
|
||||
{ icon: Radar, label: "球拍行情", path: "/market", group: "stats" },
|
||||
{ icon: Swords, label: "比赛入库", path: "/matches", group: "stats" },
|
||||
{ icon: Award, label: "NTRP评分", path: "/rating", group: "stats" },
|
||||
{ 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";
|
||||
@@ -95,7 +125,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(() => {
|
||||
@@ -163,7 +194,8 @@ function DashboardLayoutContent({
|
||||
|
||||
<SidebarContent className="gap-0">
|
||||
<SidebarMenu className="px-2 py-1">
|
||||
{menuItems.map(item => {
|
||||
{/* Main group */}
|
||||
{visibleMenuItems.filter(i => i.group === "main").map(item => {
|
||||
const isActive = location === item.path;
|
||||
return (
|
||||
<SidebarMenuItem key={item.path}>
|
||||
@@ -173,9 +205,70 @@ function DashboardLayoutContent({
|
||||
tooltip={item.label}
|
||||
className={`h-10 transition-all font-normal`}
|
||||
>
|
||||
<item.icon
|
||||
className={`h-4 w-4 ${isActive ? "text-primary" : ""}`}
|
||||
/>
|
||||
<item.icon className={`h-4 w-4 ${isActive ? "text-primary" : ""}`} />
|
||||
<span>{item.label}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Divider */}
|
||||
{!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>}
|
||||
|
||||
{visibleMenuItems.filter(i => i.group === "analysis").map(item => {
|
||||
const isActive = location === item.path;
|
||||
return (
|
||||
<SidebarMenuItem key={item.path}>
|
||||
<SidebarMenuButton
|
||||
isActive={isActive}
|
||||
onClick={() => setLocation(item.path)}
|
||||
tooltip={item.label}
|
||||
className={`h-10 transition-all font-normal`}
|
||||
>
|
||||
<item.icon className={`h-4 w-4 ${isActive ? "text-primary" : ""}`} />
|
||||
<span>{item.label}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Divider */}
|
||||
{!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>}
|
||||
|
||||
{visibleMenuItems.filter(i => i.group === "stats").map(item => {
|
||||
const isActive = location === item.path;
|
||||
return (
|
||||
<SidebarMenuItem key={item.path}>
|
||||
<SidebarMenuButton
|
||||
isActive={isActive}
|
||||
onClick={() => setLocation(item.path)}
|
||||
tooltip={item.label}
|
||||
className={`h-10 transition-all font-normal`}
|
||||
>
|
||||
<item.icon className={`h-4 w-4 ${isActive ? "text-primary" : ""}`} />
|
||||
<span>{item.label}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Divider */}
|
||||
{!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>}
|
||||
|
||||
{visibleMenuItems.filter(i => i.group === "learn").map(item => {
|
||||
const isActive = location === item.path;
|
||||
return (
|
||||
<SidebarMenuItem key={item.path}>
|
||||
<SidebarMenuButton
|
||||
isActive={isActive}
|
||||
onClick={() => setLocation(item.path)}
|
||||
tooltip={item.label}
|
||||
className={`h-10 transition-all font-normal`}
|
||||
>
|
||||
<item.icon className={`h-4 w-4 ${isActive ? "text-primary" : ""}`} />
|
||||
<span>{item.label}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
@@ -185,6 +278,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">
|
||||
@@ -238,9 +334,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;
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -164,3 +164,95 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Mobile-first responsive enhancements ===== */
|
||||
|
||||
/* Safe area insets for notched devices (iPhone X+, etc.) */
|
||||
@supports (padding-bottom: env(safe-area-inset-bottom)) {
|
||||
.mobile-safe-bottom {
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
}
|
||||
.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 */
|
||||
@media (pointer: coarse) {
|
||||
button, [role="button"], a, select, input[type="checkbox"], input[type="radio"] {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
}
|
||||
.compact-touch button, .compact-touch [role="button"] {
|
||||
min-height: 36px;
|
||||
min-width: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Prevent text size adjustment on orientation change */
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
/* Smooth scrolling with momentum on mobile */
|
||||
.mobile-scroll {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior-y: contain;
|
||||
}
|
||||
|
||||
/* Landscape video optimization */
|
||||
@media (orientation: landscape) and (max-height: 500px) {
|
||||
.landscape-compact-header {
|
||||
padding-top: 0.25rem;
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
.landscape-fullscreen-video {
|
||||
height: calc(100vh - 60px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Prevent pull-to-refresh during camera/recording */
|
||||
.no-overscroll {
|
||||
overscroll-behavior-y: contain;
|
||||
}
|
||||
|
||||
/* Video container responsive */
|
||||
@media (max-width: 639px) {
|
||||
.video-container {
|
||||
aspect-ratio: auto;
|
||||
min-height: 50vw;
|
||||
max-height: 70vh;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile bottom nav spacing */
|
||||
.mobile-bottom-spacing {
|
||||
padding-bottom: calc(4rem + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
/* Responsive grid for badge cards */
|
||||
@media (max-width: 374px) {
|
||||
.badge-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide scrollbar but keep functionality */
|
||||
.hide-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.hide-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Camera overlay text readability */
|
||||
.camera-overlay-text {
|
||||
text-shadow: 0 1px 3px rgba(0,0,0,0.8);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
423
client/src/lib/changelog.ts
普通文件
@@ -0,0 +1,423 @@
|
||||
export type ChangeLogEntry = {
|
||||
version: string;
|
||||
releaseDate: string;
|
||||
repoVersion: string;
|
||||
summary: string;
|
||||
features: string[];
|
||||
tests: string[];
|
||||
};
|
||||
|
||||
export const CHANGE_LOG_ENTRIES: ChangeLogEntry[] = [
|
||||
{
|
||||
version: "2026.03.27-match-review-hub",
|
||||
releaseDate: "2026-03-27",
|
||||
repoVersion: "32ffad1",
|
||||
summary:
|
||||
"新增比赛入库板块,支持双人双摄比赛建档、自动计分建议、H1 管理员审核确认、正式结算以及竞赛排行榜。",
|
||||
features: [
|
||||
"新增 `/matches` 页面和侧边栏入口,用户可以查看自己绑定的比赛、双机位状态、自动计分事件时间线以及正式入库结果",
|
||||
"服务端新增 `match_sessions`、`match_participants`、`match_score_events` 数据表,并把自动计分建议和正式结算接入 `background_tasks` 异步 worker 链路",
|
||||
"补齐 `0012_market_watch` 与 `0013_match_hub` 的 Drizzle statement breakpoint,让 MySQL 生产迁移可以顺序执行球拍行情与比赛入库表结构并完成上线",
|
||||
"管理后台新增“比赛入库”工作台,H1 / 管理员可以创建日常或竞赛双人比赛、固定两位用户与对应双机位、刷新自动计分建议、提交审核比分并发起正式结算",
|
||||
"排行榜新增训练榜 / 竞赛榜双视图;竞赛榜只统计已正式结算的竞赛比赛,避免未审核自动计分直接进入正式名次",
|
||||
"正式结算后会把比赛写入用户训练记录;日常比赛同步累计训练型指标,竞赛比赛单独进入正式比赛统计与排行榜,不直接用未审核结果污染 NTRP",
|
||||
],
|
||||
tests: [
|
||||
"pnpm check",
|
||||
"pnpm exec vitest run server/match.test.ts server/features.test.ts",
|
||||
"pnpm build",
|
||||
"docker compose up -d --build migrate app app-worker",
|
||||
"线上 smoke: 公开站点部署前仍停留在 2026-03-17 的旧 revision;重新部署后已切到包含比赛入库与更新日志修正的新构建,Playwright 真实登录 `H1` 验证 `/matches`、`/admin` 的“比赛入库”工作台、`/leaderboard` 的“训练榜 / 竞赛榜”以及 `/changelog` 最新条目均可访问;当前仅剩 `/favicon.ico` 404,不影响功能使用",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "2026.03.23-racket-market-watch",
|
||||
releaseDate: "2026-03-23",
|
||||
repoVersion: "32ffad1",
|
||||
summary:
|
||||
"新增球拍行情板块,支持闲鱼/京东/转转异步抓取、球拍结构化分级、用户监控规则和默认飞书 webhook 推送。",
|
||||
features: [
|
||||
"新增 `/market` 页面和侧边栏入口,集中展示低价雷达、监控规则、命中记录和市场后台任务状态",
|
||||
"服务端新增球拍行情数据表、监控规则与命中记录,并把市场刷新、来源抓取、飞书推送接入现有 `background_tasks` worker 链路",
|
||||
"抓取结果会对球拍做品牌、型号、系列、品类、重量、成色与价格分级;命中用户目标价后写入站内记录,并按去重规则推送到默认飞书 webhook",
|
||||
"全局设置新增默认飞书 webhook、抓取超时、重试次数、闲鱼/京东/转转抓取 UA/Cookie 与转转搜索模板配置,管理员可直接在后台调整",
|
||||
],
|
||||
tests: [
|
||||
"pnpm check",
|
||||
"pnpm exec vitest run server/market.test.ts server/market.routes.test.ts",
|
||||
"pnpm build",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "2026.03.17-live-camera-relay-mp4-hardening",
|
||||
releaseDate: "2026-03-17",
|
||||
repoVersion: "1adadba",
|
||||
summary:
|
||||
"修复实时分析 relay 预览在 Chrome `mp4` 分段下容易失效的问题,并让 live-camera 录制优先回到更稳定的 `webm`。",
|
||||
features: [
|
||||
"media 服务在 relay 会话收到第一段 `mp4` 时会额外保留初始化片段,后续滚动缓存即使裁掉旧分段,也能继续为 preview 重建可解码的输入源",
|
||||
"relay preview 构建会跳过明显异常的小 `mp4` 分段,并优先尝试把保留的初始化片段与当前缓存拼成单一输入后再转成 `preview.webm`",
|
||||
"如果 relay preview 本轮重建失败,但磁盘上仍有上一版可播放 `preview.webm`,worker 会保留旧预览继续对 viewer 提供播放,而不是直接把同步观看打成永久失败",
|
||||
"live-camera 的合成录制 mime 选择已改为优先 `video/webm`,Chrome 不再默认上传 fragmented `mp4` relay 分段,从源头减少 `trex/tfhd` 类 ffmpeg 拼接失败",
|
||||
],
|
||||
tests: [
|
||||
"cd media && go test ./...",
|
||||
"pnpm check",
|
||||
"pnpm build",
|
||||
"部署后线上 smoke: 已确认 `https://te.hao.work/` 正在提供新构建;当前线上仍有一条补丁前启动的旧 `mp4` relay 会话在运行,因此完整的 `webm` relay 端到端验证需要在重启该实时分析会话后继续确认",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "2026.03.17-live-camera-media-asset-url",
|
||||
releaseDate: "2026-03-17",
|
||||
repoVersion: "0af88b3",
|
||||
summary:
|
||||
"修复同步观看预览地址被重复拼接 `/media` 导致的 404,观看端可以继续打开 relay 缓存视频。",
|
||||
features: [
|
||||
"共享的 `getMediaAssetUrl()` 现在会保留已带 `/media/` 前缀的应用内路径,不再把 `/media/assets/...` 再次拼成 `/media/media/assets/...`",
|
||||
"当服务端直接返回完整 `https://...` 外链时,前端会原样使用该地址,避免对外部媒体地址做错误拼接",
|
||||
"其他仍是普通相对路径的媒体资源会继续自动补齐 `/media` 前缀,因此旧调用方无需改动",
|
||||
"同步观看点击“同步观看”后,请求的 preview 地址恢复为 `/media/assets/sessions/.../preview.webm`,不再因 `404 page not found` 导致无视频可播",
|
||||
"线上 smoke 已确认 `https://te.hao.work/` 已切换到包含本次修复的新构建,而不是继续提供部署前的旧资源 revision",
|
||||
],
|
||||
tests: [
|
||||
"pnpm vitest run client/src/lib/media.test.ts",
|
||||
"pnpm check",
|
||||
"pnpm build",
|
||||
"playwright-skill 线上 smoke: 登录 `H1` 后访问 `https://te.hao.work/live-camera`,确认 viewer 实际请求 `https://te.hao.work/media/assets/sessions/.../preview.webm?...` 并返回 `200`,同时不存在 `/media/media/...` 双前缀请求",
|
||||
"线上 smoke: 已确认部署前公开站点还是旧 revision;部署后 `https://te.hao.work/` 已切换到包含本次修复的新构建",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "2026.03.17-live-camera-pose-buffer-window",
|
||||
releaseDate: "2026-03-17",
|
||||
repoVersion: "f3f7e19+pose-buffer-window",
|
||||
summary:
|
||||
"修复实时分析启动时的 MediaPipe Pose 模块加载崩溃,并把多端同步缓存改为默认 2 分钟、可选 10 秒到 5 分钟。",
|
||||
features: [
|
||||
"live-camera 开始分析时不再直接解构 `import(\"@mediapipe/pose\")` 的返回值,而是兼容 `Pose`、`default.Pose` 和默认导出三种形态;模块缺失时会抛出明确错误,避免再次出现 `Cannot destructure property 'Pose' ... as it is undefined`",
|
||||
"同步观看的 relay 缓存时长改为按会话配置,范围 10 秒到 5 分钟,默认 2 分钟;viewer 文案、徽标和设置面板都会实时显示当前缓存窗口",
|
||||
"owner 端合成画布录制改为每 10 秒上传一次 relay 分片,同时继续维持每 60 秒一段的自动归档录像,因此观看端切到短缓存时不需要再等满 60 秒才出现平滑视频",
|
||||
"media 服务会按各自 relay 会话的缓存窗口裁剪预览分段,并在从磁盘恢复旧会话时自动归一化缓存秒数,避免旧数据继续按固定 60 秒窗口工作",
|
||||
"同步端渲染远端 recentSegments 时新增旧快照归一化,`keyFrames`、`issueSummary` 等数组字段缺失时也会自动补默认值,避免再出现 `Cannot read properties of undefined (reading 'length')`",
|
||||
"同步观看界面新增“已累积 / 还需多久才能看到首段回放 / 距离目标缓存还差多少”的提示,观看端不再只显示笼统的等待文案",
|
||||
"线上 smoke 已确认 `https://te.hao.work/` 已经提供本次新构建,而不是旧资源版本;首页、主样式和 `pose` 模块都已切到本次发布的最新资源 revision",
|
||||
],
|
||||
tests: [
|
||||
"cd media && go test ./...",
|
||||
"pnpm vitest run client/src/lib/liveCamera.test.ts",
|
||||
"pnpm check",
|
||||
"pnpm build",
|
||||
"pnpm exec playwright test tests/e2e/app.spec.ts",
|
||||
"playwright-skill 线上 smoke: 登录 `H1` 后访问 `https://te.hao.work/live-camera`,完成校准、启用假摄像头并点击“开始分析”,确认页面进入分析中状态、默认显示“缓存 2 分钟”、且无控制台与页面级错误",
|
||||
"curl -I https://te.hao.work/,并确认首页、主样式与 `pose` 模块资源均返回 `200` 和正确 MIME",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "2026.03.17-live-camera-relay-buffer",
|
||||
releaseDate: "2026-03-17",
|
||||
repoVersion: "63dbfd2+relay-buffer",
|
||||
summary:
|
||||
"实时分析同步观看改为服务端滚动视频缓存,观看端不再轮询单帧图片;media 服务同时新增最近 60 秒缓冲和 30 分钟缓存清理。",
|
||||
features: [
|
||||
"live-camera owner 端的 60 秒合成录像分段现在会额外上传到 media relay 会话,观看端改为播放服务端生成的滚动 preview 视频,不再依赖 `live-frame.jpg` 单帧轮询",
|
||||
"relay 会话只保留最近 60 秒分段,worker 会在新分段到达后按最新窗口重建 `preview.webm`,避免观看端继续看到旧一分钟缓存",
|
||||
"超过 30 分钟无活动的 relay 会话、分段目录和公开缓存文件会自动清理,避免多端同步长期堆积无用缓存",
|
||||
"实时分析 viewer 文案和占位提示同步调整为“缓冲最近 60 秒视频 / 加载缓存回放”,更贴近现在的服务端缓存播放行为",
|
||||
"media preview 非归档阶段跳过 mp4 转码,Chrome 观看直接使用 webm,降低 worker 处理时延和 CPU 消耗",
|
||||
],
|
||||
tests: [
|
||||
"cd media && go test ./...",
|
||||
"pnpm vitest run client/src/lib/liveCamera.test.ts",
|
||||
'pnpm exec playwright test tests/e2e/app.spec.ts --grep "live camera page exposes camera startup controls|live camera starts analysis and produces scores|live camera switches into viewer mode when another device already owns analysis|live camera recovers mojibake viewer titles before rendering|live camera no longer opens viewer peer retries when server relay is active"',
|
||||
"pnpm check",
|
||||
"pnpm build",
|
||||
"线上 smoke: 部署后确认 `https://te.hao.work/` 已提供新构建而不是旧资源版本,`/live-camera` viewer 端进入“服务端缓存同步”路径并返回正确的 JS/CSS MIME",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "2026.03.17-live-camera-preview-recovery",
|
||||
releaseDate: "2026-03-17",
|
||||
repoVersion: "06b9701",
|
||||
summary:
|
||||
"修复实时分析页标题乱码、同步观看残留状态导致的黑屏,以及切回本机摄像头后预览无法恢复的问题。",
|
||||
features: [
|
||||
"runtime 标题恢复逻辑新增更严格的乱码筛除与二次 UTF-8 解码兜底,`æœ...`、带替换字符的脏标题现在会优先恢复为正常中文,无法恢复时会安全回退到稳定默认标题",
|
||||
"同步观看退出时会完整重置 viewer 轮询、连接标记和帧版本,不再把旧 viewer 状态残留到 owner 或空闲态,避免页面继续停留在黑屏或“等待同步画面”",
|
||||
"本地摄像头预览新增独立重绑流程和多次 watchdog 重试,即使浏览器在首帧时没有及时绑定 `srcObject` 或 `play()` 被短暂打断,也会自动恢复预览",
|
||||
"视频区域是否显示画面改为按当前 runtime 角色分别判断,避免 viewer 的旧连接状态误导 owner 模式,导致本地没有预览时仍隐藏占位提示",
|
||||
],
|
||||
tests: [
|
||||
"pnpm check",
|
||||
"pnpm vitest run client/src/lib/liveCamera.test.ts",
|
||||
'pnpm exec playwright test tests/e2e/app.spec.ts --grep "live camera"',
|
||||
"pnpm build",
|
||||
"线上 smoke: `curl -I https://te.hao.work/`,并检查页面源码中的 `/assets/index-*.js`、`/assets/index-*.css`、`/assets/pose-*.js` 已切换到新构建且返回正确 MIME",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "2026.03.16-live-camera-runtime-refresh",
|
||||
releaseDate: "2026-03-16",
|
||||
repoVersion: "8e9e491",
|
||||
summary:
|
||||
"修复实时分析页偶发残留在同步观看状态、标题乱码,以及摄像头预览绑定波动导致的启动失败。",
|
||||
features: [
|
||||
"live-camera 在打开拍摄引导、启用摄像头、开始分析前,都会先向服务端强制刷新 runtime 状态,避免旧的 viewer 锁残留导致本机明明已释放却仍无法启动",
|
||||
"同步观看标题新增乱码恢复逻辑,可自动把 UTF-8 被误按 Latin-1 显示的标题恢复成正常中文,避免出现 `æœ...` 一类异常标题",
|
||||
"摄像头启动链路改为以 `getUserMedia` 成功为准;即使本地预览 `<video>` 的 `srcObject` 或 `play()` 在当前浏览器里短暂失败,也不会直接把整次启动判死",
|
||||
"e2e mock 的媒体流补齐为带假视频轨道的流对象,并把 viewer 回归改为校验“服务端 relay、无 viewer-signal”行为,减少和旧 P2P 逻辑混淆",
|
||||
],
|
||||
tests: [
|
||||
'pnpm exec playwright test tests/e2e/app.spec.ts --grep "live camera page exposes camera startup controls|live camera switches into viewer mode when another device already owns analysis|live camera recovers mojibake viewer titles before rendering|live camera no longer opens viewer peer retries when server relay is active"',
|
||||
"pnpm build",
|
||||
"部署后线上 smoke: `https://te.hao.work/live-camera` 登录 H1 后可见空闲态“启动摄像头”入口,确认不再被残留 viewer 锁卡住;公开站点前端资源为 `assets/index-33wVjC4p.js` 与 `assets/index-tNGuStgv.css`",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "2026.03.16-live-viewer-server-relay",
|
||||
releaseDate: "2026-03-16",
|
||||
repoVersion: "bb46d26",
|
||||
summary:
|
||||
"实时分析同步观看改为由 media 服务中转帧图,不再依赖浏览器之间的 P2P 视频连接。",
|
||||
features: [
|
||||
"owner 端现在会把带骨架、关键点和虚拟形象叠层的合成画布压缩成 JPEG 并持续上传到 media 服务",
|
||||
"viewer 端改为直接拉取 media 服务中的最新同步帧图,不再建立 WebRTC viewer peer 连接,因此跨网络和多端观看更稳定",
|
||||
"同步观看模式文案改为明确提示“通过 media 服务中转”,等待同步时也会自动轮询最新画面",
|
||||
"media 服务新增 live-frame 上传与静态分发能力,并记录最近同步帧的更新时间,方便后续扩展成更高频的服务端中转流",
|
||||
],
|
||||
tests: [
|
||||
"cd media && go test ./...",
|
||||
"pnpm build",
|
||||
"playwright-skill 线上 smoke: 先用 media 服务创建 relay session、上传 live-frame,并把 H1 的 `live_analysis_runtime` 注入为 active viewer 场景;随后访问 `https://te.hao.work/live-camera`,确认页面进入“同步观看模式”、同步帧来自 `/media/assets/sessions/.../live-frame.jpg`,且 `viewer-signal` 请求数为 0",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "2026.03.16-camera-startup-fallbacks",
|
||||
releaseDate: "2026-03-16",
|
||||
repoVersion: "a211562",
|
||||
summary:
|
||||
"修复部分设备上摄像头因后置镜头约束、分辨率约束或麦克风不可用而直接启动失败的问题。",
|
||||
features: [
|
||||
"live-camera 与 recorder 改为共用分级降级的摄像头请求流程,会在当前画质失败时自动降分辨率、降约束并回退到兼容镜头",
|
||||
"当设备不支持默认后置摄像头或当前镜头不可用时,页面会自动切换到实际可用的镜头方向,避免直接报错后卡死在未启动状态",
|
||||
"recorder 预览启动不再被麦克风权限或麦克风设备异常整体拖死;麦克风不可用时会自动回退到仅视频模式",
|
||||
"兼容模式命中时前端会给出明确提示,方便区分“已自动降级成功”与“仍然无法访问摄像头”的场景",
|
||||
],
|
||||
tests: [
|
||||
"pnpm build",
|
||||
"部署后线上 smoke: `https://te.hao.work/` 已提供 `assets/index-CRxtWK07.js` 与 `assets/index-tNGuStgv.css`;通过注入 `getUserMedia` 回归验证 `/live-camera` 首轮高约束失败后会自动切到兼容摄像头模式,`/recorder` 在麦克风不可用时会自动回退到仅视频模式并继续启动预览",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "2026.03.16-live-analysis-viewer-full-sync",
|
||||
releaseDate: "2026-03-16",
|
||||
repoVersion: "922a9fb",
|
||||
summary:
|
||||
"多端同步观看改为按持有端快照完整渲染,另一设备可同步看到视频状态、模式、画质、虚拟形象和保存阶段信息。",
|
||||
features: [
|
||||
"viewer 端现在同步显示持有端的会话标题、训练模式、设备端、拍摄视角、画质模式、虚拟形象状态和最近同步时间",
|
||||
"同步观看时的分析阶段、保存阶段、已完成状态也会跟随主端刷新,不再只显示本地默认状态",
|
||||
"viewer 页面会自动关闭拍摄校准弹窗,避免同步观看时被“启用摄像头”流程遮挡",
|
||||
"新增 viewer 同步信息卡,明确允许 1 秒级延迟,并持续显示最近心跳时间",
|
||||
],
|
||||
tests: [
|
||||
'pnpm exec playwright test tests/e2e/app.spec.ts --grep "live camera switches into viewer mode|viewer stream|recorder blocks"',
|
||||
"pnpm build",
|
||||
"部署后线上 smoke: `https://te.hao.work/` 已提供 `assets/index-HRdM3fxq.js` 与 `assets/index-tNGuStgv.css`;同账号 H1 双端登录后,移动端 owner 可开始实时分析,桌面端 `/live-camera` 自动进入同步观看并显示主端信息、同步视频流,owner 点击结束分析后 viewer 会同步进入保存阶段",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "2026.03.16-live-analysis-lock-hardening",
|
||||
releaseDate: "2026-03-16",
|
||||
repoVersion: "f9db6ef",
|
||||
summary:
|
||||
"修复同账号多端实时分析在旧登录态下仍可重复占用摄像头的问题,补强同步观看重试、录制页占用锁,并修复部署后启动阶段长时间 502。",
|
||||
features: [
|
||||
"旧用户名登录 token 即使缺少 `sid`,现在也会按 token 本身派生唯一会话标识,不再把不同设备错误识别成同一持有端",
|
||||
"同步观看模式新增 viewer 自动重试:当持有端刚启动推流、viewer 首次连接返回 `viewer stream not ready` 时,会自动重连而不是一直黑屏",
|
||||
"在线录制页接入实时分析占用锁;当其他设备正在 `/live-camera` 分析时,本页会禁止再次启动摄像头和录制",
|
||||
"应用启动改为先监听 HTTP 端口、再后台串行执行教程图同步和标准库预热,修复新容器上线时公网长时间返回 502 的问题",
|
||||
"线上 smoke 已确认 `https://te.hao.work/live-camera` 与 `/recorder` 都已切换到本次新构建,公开站点不再返回 502",
|
||||
],
|
||||
tests: [
|
||||
"curl -I https://te.hao.work/",
|
||||
"pnpm check",
|
||||
"pnpm exec vitest run server/_core/sdk.test.ts server/features.test.ts",
|
||||
'pnpm exec playwright test tests/e2e/app.spec.ts --grep "viewer mode|viewer stream|recorder blocks"',
|
||||
"pnpm build",
|
||||
"线上 smoke: H1 手机端开启实时分析后,PC 端 `/live-camera` 自动进入同步观看并显示同步画面,`/recorder` 禁止启动摄像头;结束分析后会话可正常释放",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "2026.03.16-live-analysis-runtime-migration",
|
||||
releaseDate: "2026-03-16",
|
||||
repoVersion: "2b72ef9",
|
||||
summary:
|
||||
"修复实时分析因缺失 `live_analysis_runtime` 表导致的启动失败,并补齐迁移记录避免后续部署再次漏表。",
|
||||
features: [
|
||||
"生产库补建 `live_analysis_runtime` 表,并补写 `__drizzle_migrations` 中缺失的 `0011_live_analysis_runtime` 记录",
|
||||
"仓库内 Drizzle migration journal 补齐 `0011_live_analysis_runtime` 条目,后续 `docker compose` 部署可正确感知该迁移",
|
||||
"实时分析启动链路恢复,`/live-camera` 再次可以读取 runtime 锁并正常进入分析准备流程",
|
||||
"线上 smoke 已确认 `https://te.hao.work/` 正在提供本次新构建,当前前端资源为 `assets/index-B3BN5hY-.js` 与 `assets/index-BL6GQzUF.css`",
|
||||
],
|
||||
tests: [
|
||||
"pnpm check",
|
||||
"pnpm exec vitest run server/features.test.ts",
|
||||
"pnpm build",
|
||||
"docker compose exec -T db mysql ... SHOW TABLES LIKE 'live_analysis_runtime'",
|
||||
"curl -I https://te.hao.work/live-camera",
|
||||
"Playwright smoke: 登录 `H1` 后访问 `/live-camera`,`analysis.runtimeGet` / `analysis.runtimeAcquire` / `analysis.runtimeRelease` 全部返回 200",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "2026.03.16-live-camera-multidevice-viewer",
|
||||
releaseDate: "2026-03-16",
|
||||
repoVersion: "4e4122d",
|
||||
summary:
|
||||
"实时分析新增同账号多端互斥和同步观看模式,分析持有端独占摄像头,其它端只能查看同步画面与核心识别结果。",
|
||||
features: [
|
||||
"同一账号在 `/live-camera` 进入实时分析后,会写入按用户维度的 runtime 锁,其他设备不能重复启动摄像头或分析",
|
||||
"其他设备会自动进入“同步观看模式”,可订阅持有端的实时画面,并同步看到动作、评分、反馈、最近片段和归档段数",
|
||||
"同步观看复用 media 服务的 WebRTC viewer 通道,传输的是带骨架、关键点和虚拟形象覆盖后的合成画面",
|
||||
"runtime 锁按 session sid 区分持有端,兼容缺少 sid 的旧 token,超过 15 秒无心跳会自动判定为陈旧并释放",
|
||||
"线上 smoke 已确认 `https://te.hao.work/live-camera` 已切换到本次新构建,公开站点正在提供这次发布的最新前端资源",
|
||||
],
|
||||
tests: [
|
||||
"pnpm check",
|
||||
"pnpm exec vitest run server/features.test.ts",
|
||||
"go test ./... && go build ./... (media)",
|
||||
"pnpm build",
|
||||
'pnpm exec playwright test tests/e2e/app.spec.ts --grep "live camera"',
|
||||
'pnpm exec playwright test tests/e2e/app.spec.ts --grep "recorder flow archives a session and exposes it in videos"',
|
||||
"curl -I https://te.hao.work/live-camera",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "2026.03.16-live-analysis-overlay-archive",
|
||||
releaseDate: "2026-03-16",
|
||||
repoVersion: "4fb2d09",
|
||||
summary:
|
||||
"实时分析新增 60 秒自动归档录像,录制内容会保留骨架、关键点和虚拟形象叠层,并同步进入视频库。",
|
||||
features: [
|
||||
"实时分析开始后会自动录制合成画布,每 60 秒自动切段归档",
|
||||
"归档录像会保留原视频、骨架线、关键点和当前虚拟形象覆盖效果",
|
||||
"归档片段会自动写入视频库,标签显示为“实时分析”",
|
||||
"删除视频库中的实时分析录像时,不会删除已写入的实时分析数据和训练记录",
|
||||
"线上 smoke 已确认 `https://te.hao.work/` 已切换到本次新构建,`/live-camera`、`/videos`、`/changelog` 页面均可正常访问",
|
||||
],
|
||||
tests: [
|
||||
"pnpm check",
|
||||
"pnpm test",
|
||||
"pnpm build",
|
||||
"pnpm test:e2e",
|
||||
"Playwright smoke: 真实站点登录 H1,完成 /live-camera 引导、开始/结束分析,并确认 /videos 可见实时分析条目",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "2026.03.15-live-analysis-leave-hint",
|
||||
releaseDate: "2026-03-15",
|
||||
repoVersion: "5c2dcf2",
|
||||
summary:
|
||||
"实时分析结束后增加离开提示,明确何时必须停留、何时可以安全关闭或切页。",
|
||||
features: [
|
||||
"分析进行中显示“不要关闭或切走页面”提示",
|
||||
"结束分析后保存阶段显示“请暂时停留当前页面”提示",
|
||||
"保存成功后明确提示“现在可以关闭浏览器或切换到其他页面”",
|
||||
"分析中和保存中挂接 beforeunload 提醒,减少误关页面导致的数据丢失",
|
||||
],
|
||||
tests: ["pnpm check", "pnpm build"],
|
||||
},
|
||||
{
|
||||
version: "2026.03.15-training-generator-collapse",
|
||||
releaseDate: "2026-03-15",
|
||||
repoVersion: "1ce94f6",
|
||||
summary: "训练计划生成面板在桌面端默认折叠到右侧,按需展开查看和重新生成。",
|
||||
features: [
|
||||
"训练页右侧生成器在桌面端默认折叠为窄栏",
|
||||
"点击右侧折叠栏可展开“重新生成计划”完整面板",
|
||||
"移动端继续直接展示完整生成器,避免隐藏关键操作",
|
||||
"未生成计划时点击“前往生成训练计划”会自动展开并滚动到生成面板",
|
||||
],
|
||||
tests: ["pnpm check", "pnpm build"],
|
||||
},
|
||||
{
|
||||
version: "2026.03.15-progress-time-actions",
|
||||
releaseDate: "2026-03-15",
|
||||
repoVersion: "71caf0d",
|
||||
summary: "最近训练记录默认显示具体上海时间,并直接展示录制动作数据摘要。",
|
||||
features: [
|
||||
"最近训练记录摘要行默认显示到秒的 Asia/Shanghai 时间",
|
||||
"录制记录列表直接展示主动作和前 3 个动作统计,无需先展开",
|
||||
"展开态动作明细统一用中文动作标签展示",
|
||||
"提醒页通知时间统一切换为 Asia/Shanghai",
|
||||
],
|
||||
tests: ["pnpm check", "pnpm build"],
|
||||
},
|
||||
{
|
||||
version: "2026.03.15-session-changelog",
|
||||
releaseDate: "2026-03-15",
|
||||
repoVersion: "a9ea94f",
|
||||
summary: "多端 session、更新日志页面、录制动作摘要与上海时区显示同步收口。",
|
||||
features: [
|
||||
"用户名登录生成独立 sid,同一账号多端登录保持并行有效",
|
||||
"新增 /changelog 页面和侧边栏入口,展示版本、仓库版本和验证记录",
|
||||
"训练进度页可展开查看最近训练记录的具体时间、动作统计和录制有效性",
|
||||
"录制页增加动作抽样摘要、无效录制标记与 media 预归档状态",
|
||||
"Dashboard、任务中心、管理台、评分、日志、视觉测试、视频库等页面统一使用 Asia/Shanghai 时间显示",
|
||||
],
|
||||
tests: [
|
||||
"pnpm check",
|
||||
"pnpm test",
|
||||
"pnpm test:go",
|
||||
"pnpm build",
|
||||
"Playwright smoke: https://te.hao.work/ 双上下文登录 H1 后 dashboard 均保持有效;线上 /changelog 仍显示旧构建,待部署后复测",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "2026.03.15-recorder-zoom",
|
||||
releaseDate: "2026-03-15",
|
||||
repoVersion: "c4ec397",
|
||||
summary: "补齐录制页与实时分析页的节省流量模式、镜头缩放和移动端控制。",
|
||||
features: [
|
||||
"在线录制默认切换为节省流量模式",
|
||||
"在线录制支持镜头焦距放大缩小",
|
||||
"实时分析支持镜头焦距放大缩小",
|
||||
"页面内增加拍摄与流量设置说明",
|
||||
],
|
||||
tests: [
|
||||
"pnpm check",
|
||||
"pnpm exec vitest run client/src/lib/media.test.ts client/src/lib/camera.test.ts",
|
||||
"Playwright 真实站点检查 /live-camera 与 /recorder 新控件可见",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "2026.03.15-videos-crud",
|
||||
releaseDate: "2026-03-15",
|
||||
repoVersion: "bd89981",
|
||||
summary: "视频库支持新增、编辑、删除训练视频记录。",
|
||||
features: [
|
||||
"视频库新增外部视频登记",
|
||||
"视频库支持编辑标题和动作类型",
|
||||
"视频库支持删除视频及关联分析引用",
|
||||
"视频详情读取按当前用户权限收敛",
|
||||
],
|
||||
tests: [
|
||||
"pnpm check",
|
||||
'pnpm exec vitest run server/features.test.ts -t "video\\\\."',
|
||||
"Playwright 真实站点完成 /videos 新增-编辑-删除全链路",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "v3.0.0",
|
||||
releaseDate: "2026-03-14",
|
||||
repoVersion: "历史版本",
|
||||
summary: "教程库、提醒、通知等学习能力上线。",
|
||||
features: [
|
||||
"训练视频教程库",
|
||||
"教程自评与学习进度",
|
||||
"训练提醒通知",
|
||||
"通知历史管理",
|
||||
],
|
||||
tests: ["教程库、提醒、通知相关测试通过"],
|
||||
},
|
||||
];
|
||||
129
client/src/lib/liveCamera.test.ts
普通文件
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ACTION_WINDOW_FRAMES,
|
||||
AVATAR_PRESETS,
|
||||
createStableActionState,
|
||||
getAvatarAnchors,
|
||||
getAvatarPreset,
|
||||
resolveAvatarKeyFromPrompt,
|
||||
stabilizeActionStream,
|
||||
type FrameActionSample,
|
||||
} from "./liveCamera";
|
||||
|
||||
function feedSamples(samples: Array<Omit<FrameActionSample, "timestamp">>, intervalMs = 33) {
|
||||
const history: FrameActionSample[] = [];
|
||||
const state = createStableActionState();
|
||||
let lastResult = null as ReturnType<typeof stabilizeActionStream> | null;
|
||||
|
||||
samples.forEach((sample, index) => {
|
||||
lastResult = stabilizeActionStream(
|
||||
{
|
||||
...sample,
|
||||
timestamp: index * intervalMs,
|
||||
},
|
||||
history,
|
||||
state,
|
||||
);
|
||||
});
|
||||
|
||||
return { history, state, lastResult };
|
||||
}
|
||||
|
||||
describe("live camera action stabilizer", () => {
|
||||
it("locks a dominant action after a full temporal window", () => {
|
||||
const samples = Array.from({ length: ACTION_WINDOW_FRAMES * 2 }, () => ({
|
||||
action: "forehand" as const,
|
||||
confidence: 0.84,
|
||||
}));
|
||||
const { lastResult } = feedSamples(samples);
|
||||
|
||||
expect(lastResult?.stableAction).toBe("forehand");
|
||||
expect(lastResult?.windowAction).toBe("forehand");
|
||||
expect(lastResult?.pending).toBe(false);
|
||||
expect(lastResult?.windowShare).toBeGreaterThan(0.9);
|
||||
});
|
||||
|
||||
it("ignores brief action spikes and keeps the stable action", () => {
|
||||
const stableFrames = Array.from({ length: ACTION_WINDOW_FRAMES * 2 }, () => ({
|
||||
action: "forehand" as const,
|
||||
confidence: 0.82,
|
||||
}));
|
||||
const noisyFrames = Array.from({ length: 5 }, () => ({
|
||||
action: "backhand" as const,
|
||||
confidence: 0.88,
|
||||
}));
|
||||
const { lastResult } = feedSamples([...stableFrames, ...noisyFrames]);
|
||||
|
||||
expect(lastResult?.stableAction).toBe("forehand");
|
||||
expect(lastResult?.pending).toBe(false);
|
||||
});
|
||||
|
||||
it("switches only after the next action persists long enough", () => {
|
||||
const forehandFrames = Array.from({ length: ACTION_WINDOW_FRAMES * 2 }, () => ({
|
||||
action: "forehand" as const,
|
||||
confidence: 0.8,
|
||||
}));
|
||||
const backhandFrames = Array.from({ length: ACTION_WINDOW_FRAMES * 2 }, () => ({
|
||||
action: "backhand" as const,
|
||||
confidence: 0.85,
|
||||
}));
|
||||
const { lastResult, state } = feedSamples([...forehandFrames, ...backhandFrames]);
|
||||
|
||||
expect(lastResult?.stableAction).toBe("backhand");
|
||||
expect(state.switchCount).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("requires a longer delay before falling back to unknown", () => {
|
||||
const forehandFrames = Array.from({ length: ACTION_WINDOW_FRAMES * 2 }, () => ({
|
||||
action: "forehand" as const,
|
||||
confidence: 0.83,
|
||||
}));
|
||||
const unknownFrames = Array.from({ length: 10 }, () => ({
|
||||
action: "unknown" as const,
|
||||
confidence: 0.4,
|
||||
}));
|
||||
const { lastResult } = feedSamples([...forehandFrames, ...unknownFrames]);
|
||||
|
||||
expect(lastResult?.stableAction).toBe("forehand");
|
||||
});
|
||||
});
|
||||
|
||||
describe("live camera avatar helpers", () => {
|
||||
it("maps prompt keywords into avatar presets", () => {
|
||||
expect(resolveAvatarKeyFromPrompt("切换成猩猩形象", "gorilla")).toBe("gorilla");
|
||||
expect(resolveAvatarKeyFromPrompt("dog mascot", "gorilla")).toBe("dog");
|
||||
expect(resolveAvatarKeyFromPrompt("狐狸风格", "gorilla")).toBe("fox");
|
||||
expect(resolveAvatarKeyFromPrompt("兔子教练", "gorilla")).toBe("rabbit");
|
||||
expect(resolveAvatarKeyFromPrompt("BeachKing 3D 替身", "gorilla")).toBe("beachKing");
|
||||
expect(resolveAvatarKeyFromPrompt("Juanita avatar", "gorilla")).toBe("juanita3d");
|
||||
expect(resolveAvatarKeyFromPrompt("", "pig")).toBe("pig");
|
||||
});
|
||||
|
||||
it("exposes full-body 3d avatar examples with CC0 metadata", () => {
|
||||
const presets = AVATAR_PRESETS.filter((preset) => preset.category === "full-body-3d");
|
||||
|
||||
expect(presets).toHaveLength(4);
|
||||
expect(presets.every((preset) => preset.license === "CC0")).toBe(true);
|
||||
expect(getAvatarPreset("sportTv")?.modelUrl).toContain("arweave.net");
|
||||
});
|
||||
|
||||
it("builds avatar anchors from pose landmarks", () => {
|
||||
const landmarks = Array.from({ length: 33 }, () => ({ x: 0.5, y: 0.5, visibility: 0.95 }));
|
||||
landmarks[0] = { x: 0.5, y: 0.16, visibility: 0.99 };
|
||||
landmarks[11] = { x: 0.4, y: 0.3, visibility: 0.99 };
|
||||
landmarks[12] = { x: 0.6, y: 0.3, visibility: 0.99 };
|
||||
landmarks[15] = { x: 0.28, y: 0.44, visibility: 0.99 };
|
||||
landmarks[16] = { x: 0.72, y: 0.44, visibility: 0.99 };
|
||||
landmarks[23] = { x: 0.44, y: 0.58, visibility: 0.99 };
|
||||
landmarks[24] = { x: 0.56, y: 0.58, visibility: 0.99 };
|
||||
landmarks[27] = { x: 0.43, y: 0.92, visibility: 0.99 };
|
||||
landmarks[28] = { x: 0.57, y: 0.92, visibility: 0.99 };
|
||||
|
||||
const anchors = getAvatarAnchors(landmarks, 1280, 720);
|
||||
|
||||
expect(anchors).not.toBeNull();
|
||||
expect(anchors?.headRadius).toBeGreaterThan(30);
|
||||
expect(anchors?.bodyHeight).toBeGreaterThan(120);
|
||||
expect(anchors?.rightHandX).toBeGreaterThan(anchors?.leftHandX || 0);
|
||||
});
|
||||
});
|
||||
744
client/src/lib/liveCamera.ts
普通文件
@@ -0,0 +1,744 @@
|
||||
export type LiveActionType = "forehand" | "backhand" | "serve" | "volley" | "overhead" | "slice" | "lob" | "unknown";
|
||||
|
||||
export type PosePoint = {
|
||||
x: number;
|
||||
y: number;
|
||||
visibility?: number;
|
||||
};
|
||||
|
||||
export type AvatarKey =
|
||||
| "gorilla"
|
||||
| "monkey"
|
||||
| "dog"
|
||||
| "pig"
|
||||
| "cat"
|
||||
| "fox"
|
||||
| "panda"
|
||||
| "lion"
|
||||
| "tiger"
|
||||
| "rabbit"
|
||||
| "beachKing"
|
||||
| "jenny3d"
|
||||
| "juanita3d"
|
||||
| "sportTv";
|
||||
|
||||
export type AvatarCategory = "animal" | "full-body-3d";
|
||||
|
||||
export type AvatarPreset = {
|
||||
key: AvatarKey;
|
||||
label: string;
|
||||
category: AvatarCategory;
|
||||
keywords: string[];
|
||||
description?: string;
|
||||
collection?: string;
|
||||
license?: string;
|
||||
sourceUrl?: string;
|
||||
modelUrl?: string;
|
||||
};
|
||||
|
||||
export type AvatarRenderState = {
|
||||
enabled: boolean;
|
||||
avatarKey: AvatarKey;
|
||||
customLabel?: string;
|
||||
};
|
||||
|
||||
export type FrameActionSample = {
|
||||
action: LiveActionType;
|
||||
confidence: number;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
export type StableActionState = {
|
||||
current: LiveActionType;
|
||||
currentSince: number | null;
|
||||
candidate: LiveActionType | null;
|
||||
candidateSince: number | null;
|
||||
candidateWindows: number;
|
||||
switchCount: number;
|
||||
};
|
||||
|
||||
export type StabilizedActionMeta = {
|
||||
stableAction: LiveActionType;
|
||||
stableConfidence: number;
|
||||
windowAction: LiveActionType;
|
||||
windowConfidence: number;
|
||||
windowShare: number;
|
||||
windowFrames: number;
|
||||
windowProgress: number;
|
||||
pending: boolean;
|
||||
pendingAction: LiveActionType | null;
|
||||
stableMs: number;
|
||||
candidateMs: number;
|
||||
rawVolatility: number;
|
||||
switchCount: number;
|
||||
};
|
||||
|
||||
type ActionStat = {
|
||||
count: number;
|
||||
totalConfidence: number;
|
||||
share: number;
|
||||
averageConfidence: number;
|
||||
strength: number;
|
||||
};
|
||||
|
||||
type AvatarAnchors = {
|
||||
headX: number;
|
||||
headY: number;
|
||||
headRadius: number;
|
||||
bodyX: number;
|
||||
bodyY: number;
|
||||
bodyWidth: number;
|
||||
bodyHeight: number;
|
||||
shoulderY: number;
|
||||
footY: number;
|
||||
leftHandX: number;
|
||||
leftHandY: number;
|
||||
rightHandX: number;
|
||||
rightHandY: number;
|
||||
};
|
||||
|
||||
type AvatarVisualSpec = {
|
||||
src: string;
|
||||
bodyFill: string;
|
||||
limbStroke: string;
|
||||
glow: string;
|
||||
renderMode: "badge" | "full-figure";
|
||||
figureScale?: number;
|
||||
figureOffsetY?: number;
|
||||
};
|
||||
|
||||
const ACTIONS: LiveActionType[] = ["forehand", "backhand", "serve", "volley", "overhead", "slice", "lob", "unknown"];
|
||||
|
||||
export const ACTION_WINDOW_FRAMES = 24;
|
||||
const ACTION_WINDOW_MIN_SHARE = 0.6;
|
||||
const ACTION_WINDOW_MIN_CONFIDENCE = 0.58;
|
||||
const ACTION_SWITCH_MIN_MS = 700;
|
||||
const ACTION_UNKNOWN_MIN_MS = 900;
|
||||
const ACTION_LOCK_IN_WINDOWS = 2;
|
||||
const ACTION_SWITCH_DELTA = 0.12;
|
||||
|
||||
export const AVATAR_PRESETS: AvatarPreset[] = [
|
||||
{ key: "gorilla", label: "猩猩", category: "animal", keywords: ["gorilla", "ape", "猩猩", "猩", "大猩猩"], description: "轻量动物替身,移动端负担最低。" },
|
||||
{ key: "monkey", label: "猴子", category: "animal", keywords: ["monkey", "ape", "猴", "猴子"], description: "轻量动物替身,适合快速练习。" },
|
||||
{ key: "dog", label: "狗", category: "animal", keywords: ["dog", "puppy", "犬", "狗", "小狗"], description: "轻量动物替身,覆盖速度快。" },
|
||||
{ key: "pig", label: "猪", category: "animal", keywords: ["pig", "猪", "小猪"], description: "轻量动物替身,适合低端设备。" },
|
||||
{ key: "cat", label: "猫", category: "animal", keywords: ["cat", "kitty", "猫", "小猫"], description: "轻量动物替身,适合低码率录制。" },
|
||||
{ key: "fox", label: "狐狸", category: "animal", keywords: ["fox", "狐狸"], description: "轻量动物替身,动作切换反馈清晰。" },
|
||||
{ key: "panda", label: "熊猫", category: "animal", keywords: ["panda", "熊猫"], description: "轻量动物替身,适合直播预览。" },
|
||||
{ key: "lion", label: "狮子", category: "animal", keywords: ["lion", "狮子"], description: "轻量动物替身,轮廓感更强。" },
|
||||
{ key: "tiger", label: "老虎", category: "animal", keywords: ["tiger", "虎", "老虎"], description: "轻量动物替身,适合训练 PK。" },
|
||||
{ key: "rabbit", label: "兔子", category: "animal", keywords: ["rabbit", "bunny", "兔", "兔子"], description: "轻量动物替身,适合日常训练。" },
|
||||
{
|
||||
key: "beachKing",
|
||||
label: "BeachKing",
|
||||
category: "full-body-3d",
|
||||
keywords: ["beachking", "beach king", "海滩王", "3d beach", "beach avatar"],
|
||||
description: "CC0 全身 3D 示例,适合覆盖竖屏全身站姿。",
|
||||
collection: "100Avatars R3",
|
||||
license: "CC0",
|
||||
sourceUrl: "https://github.com/ToxSam/open-source-avatars",
|
||||
modelUrl: "https://arweave.net/uKhDMselhdUyeJKjelpuVsL8s-a9v_Wqq75TQfCfnos",
|
||||
},
|
||||
{
|
||||
key: "jenny3d",
|
||||
label: "Jenny",
|
||||
category: "full-body-3d",
|
||||
keywords: ["jenny", "frog coach", "青蛙教练", "3d jenny", "jenny avatar"],
|
||||
description: "CC0 全身 3D 示例,适合想要更完整人物轮廓时使用。",
|
||||
collection: "100Avatars R3",
|
||||
license: "CC0",
|
||||
sourceUrl: "https://github.com/ToxSam/open-source-avatars",
|
||||
modelUrl: "https://arweave.net/kgTirc4OvUWbJhIKC2CB3_pYsYuB62KTj90IdE8s3sk",
|
||||
},
|
||||
{
|
||||
key: "juanita3d",
|
||||
label: "Juanita",
|
||||
category: "full-body-3d",
|
||||
keywords: ["juanita", "粉发学员", "pink avatar", "3d juanita", "juanita avatar"],
|
||||
description: "CC0 全身 3D 示例,适合教学演示和移动端预览。",
|
||||
collection: "100Avatars R3",
|
||||
license: "CC0",
|
||||
sourceUrl: "https://github.com/ToxSam/open-source-avatars",
|
||||
modelUrl: "https://arweave.net/nyMyZZx5lN2DXsmBgbGQSnt3PuXYN7AAjz9QJrjitLo",
|
||||
},
|
||||
{
|
||||
key: "sportTv",
|
||||
label: "SportTV",
|
||||
category: "full-body-3d",
|
||||
keywords: ["sporttv", "sport tv", "屏幕街头", "tv avatar", "hoodie avatar"],
|
||||
description: "CC0 全身 3D 示例,适合训练空间较宽的画面。",
|
||||
collection: "100Avatars R3",
|
||||
license: "CC0",
|
||||
sourceUrl: "https://github.com/ToxSam/open-source-avatars",
|
||||
modelUrl: "https://arweave.net/ISYr7xBXT_s4tLddbhFB3PpUhWg-H_BYs2UZhVLF1hA",
|
||||
},
|
||||
];
|
||||
|
||||
const AVATAR_VISUALS: Record<AvatarKey, AvatarVisualSpec> = {
|
||||
gorilla: {
|
||||
src: "/avatars/twemoji/gorilla.svg",
|
||||
bodyFill: "rgba(39,39,42,0.95)",
|
||||
limbStroke: "rgba(63,63,70,0.92)",
|
||||
glow: "rgba(161,161,170,0.32)",
|
||||
renderMode: "badge",
|
||||
},
|
||||
monkey: {
|
||||
src: "/avatars/twemoji/monkey.svg",
|
||||
bodyFill: "rgba(120,53,15,0.95)",
|
||||
limbStroke: "rgba(146,64,14,0.9)",
|
||||
glow: "rgba(180,83,9,0.3)",
|
||||
renderMode: "badge",
|
||||
},
|
||||
dog: {
|
||||
src: "/avatars/twemoji/dog.svg",
|
||||
bodyFill: "rgba(180,83,9,0.93)",
|
||||
limbStroke: "rgba(180,83,9,0.88)",
|
||||
glow: "rgba(217,119,6,0.26)",
|
||||
renderMode: "badge",
|
||||
},
|
||||
pig: {
|
||||
src: "/avatars/twemoji/pig.svg",
|
||||
bodyFill: "rgba(244,114,182,0.92)",
|
||||
limbStroke: "rgba(244,114,182,0.86)",
|
||||
glow: "rgba(244,114,182,0.28)",
|
||||
renderMode: "badge",
|
||||
},
|
||||
cat: {
|
||||
src: "/avatars/twemoji/cat.svg",
|
||||
bodyFill: "rgba(245,158,11,0.92)",
|
||||
limbStroke: "rgba(217,119,6,0.88)",
|
||||
glow: "rgba(251,191,36,0.28)",
|
||||
renderMode: "badge",
|
||||
},
|
||||
fox: {
|
||||
src: "/avatars/twemoji/fox.svg",
|
||||
bodyFill: "rgba(234,88,12,0.93)",
|
||||
limbStroke: "rgba(194,65,12,0.9)",
|
||||
glow: "rgba(251,146,60,0.3)",
|
||||
renderMode: "badge",
|
||||
},
|
||||
panda: {
|
||||
src: "/avatars/twemoji/panda.svg",
|
||||
bodyFill: "rgba(82,82,91,0.92)",
|
||||
limbStroke: "rgba(39,39,42,0.9)",
|
||||
glow: "rgba(228,228,231,0.28)",
|
||||
renderMode: "badge",
|
||||
},
|
||||
lion: {
|
||||
src: "/avatars/twemoji/lion.svg",
|
||||
bodyFill: "rgba(202,138,4,0.92)",
|
||||
limbStroke: "rgba(161,98,7,0.9)",
|
||||
glow: "rgba(250,204,21,0.28)",
|
||||
renderMode: "badge",
|
||||
},
|
||||
tiger: {
|
||||
src: "/avatars/twemoji/tiger.svg",
|
||||
bodyFill: "rgba(249,115,22,0.94)",
|
||||
limbStroke: "rgba(194,65,12,0.9)",
|
||||
glow: "rgba(251,146,60,0.3)",
|
||||
renderMode: "badge",
|
||||
},
|
||||
rabbit: {
|
||||
src: "/avatars/twemoji/rabbit.svg",
|
||||
bodyFill: "rgba(236,72,153,0.9)",
|
||||
limbStroke: "rgba(219,39,119,0.86)",
|
||||
glow: "rgba(244,114,182,0.28)",
|
||||
renderMode: "badge",
|
||||
},
|
||||
beachKing: {
|
||||
src: "/avatars/opensource3d/beach-king.webp",
|
||||
bodyFill: "rgba(15,23,42,0.16)",
|
||||
limbStroke: "rgba(125,211,252,0.28)",
|
||||
glow: "rgba(56,189,248,0.16)",
|
||||
renderMode: "full-figure",
|
||||
figureScale: 1.12,
|
||||
figureOffsetY: 0.02,
|
||||
},
|
||||
jenny3d: {
|
||||
src: "/avatars/opensource3d/jenny.webp",
|
||||
bodyFill: "rgba(34,197,94,0.16)",
|
||||
limbStroke: "rgba(16,185,129,0.24)",
|
||||
glow: "rgba(34,197,94,0.18)",
|
||||
renderMode: "full-figure",
|
||||
figureScale: 1.08,
|
||||
figureOffsetY: 0,
|
||||
},
|
||||
juanita3d: {
|
||||
src: "/avatars/opensource3d/juanita.webp",
|
||||
bodyFill: "rgba(244,114,182,0.14)",
|
||||
limbStroke: "rgba(236,72,153,0.26)",
|
||||
glow: "rgba(244,114,182,0.18)",
|
||||
renderMode: "full-figure",
|
||||
figureScale: 1.06,
|
||||
figureOffsetY: 0,
|
||||
},
|
||||
sportTv: {
|
||||
src: "/avatars/opensource3d/sport-tv.webp",
|
||||
bodyFill: "rgba(59,130,246,0.14)",
|
||||
limbStroke: "rgba(96,165,250,0.24)",
|
||||
glow: "rgba(96,165,250,0.18)",
|
||||
renderMode: "full-figure",
|
||||
figureScale: 1.1,
|
||||
figureOffsetY: 0.02,
|
||||
},
|
||||
};
|
||||
|
||||
const avatarImageCache = new Map<AvatarKey, HTMLImageElement | null>();
|
||||
|
||||
export function getAvatarPreset(key: AvatarKey) {
|
||||
return AVATAR_PRESETS.find((preset) => preset.key === key) ?? AVATAR_PRESETS[0];
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function getActionStat(samples: FrameActionSample[], action: LiveActionType): ActionStat {
|
||||
const matches = samples.filter((sample) => sample.action === action);
|
||||
const count = matches.length;
|
||||
const totalConfidence = matches.reduce((sum, sample) => sum + sample.confidence, 0);
|
||||
const share = samples.length > 0 ? count / samples.length : 0;
|
||||
const averageConfidence = count > 0 ? totalConfidence / count : 0;
|
||||
|
||||
return {
|
||||
count,
|
||||
totalConfidence,
|
||||
share,
|
||||
averageConfidence,
|
||||
strength: share * 0.7 + averageConfidence * 0.3,
|
||||
};
|
||||
}
|
||||
|
||||
function getWindowAction(samples: FrameActionSample[]) {
|
||||
const stats = new Map<LiveActionType, ActionStat>();
|
||||
ACTIONS.forEach((action) => {
|
||||
stats.set(action, getActionStat(samples, action));
|
||||
});
|
||||
|
||||
const ranked = ACTIONS
|
||||
.map((action) => ({ action, stats: stats.get(action)! }))
|
||||
.sort((a, b) => {
|
||||
if (b.stats.strength !== a.stats.strength) {
|
||||
return b.stats.strength - a.stats.strength;
|
||||
}
|
||||
return b.stats.totalConfidence - a.stats.totalConfidence;
|
||||
});
|
||||
|
||||
const winner = ranked[0] ?? { action: "unknown" as LiveActionType, stats: stats.get("unknown")! };
|
||||
const qualifies =
|
||||
winner.stats.share >= ACTION_WINDOW_MIN_SHARE &&
|
||||
winner.stats.averageConfidence >= ACTION_WINDOW_MIN_CONFIDENCE;
|
||||
|
||||
return {
|
||||
action: qualifies ? winner.action : "unknown",
|
||||
stats,
|
||||
winnerStats: winner.stats,
|
||||
};
|
||||
}
|
||||
|
||||
function getRawVolatility(samples: FrameActionSample[]) {
|
||||
if (samples.length <= 1) return 0;
|
||||
let switches = 0;
|
||||
for (let index = 1; index < samples.length; index += 1) {
|
||||
if (samples[index]?.action !== samples[index - 1]?.action) {
|
||||
switches += 1;
|
||||
}
|
||||
}
|
||||
return switches / (samples.length - 1);
|
||||
}
|
||||
|
||||
export function createStableActionState(initial: LiveActionType = "unknown"): StableActionState {
|
||||
return {
|
||||
current: initial,
|
||||
currentSince: null,
|
||||
candidate: null,
|
||||
candidateSince: null,
|
||||
candidateWindows: 0,
|
||||
switchCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function createEmptyStabilizedActionMeta(): StabilizedActionMeta {
|
||||
return {
|
||||
stableAction: "unknown",
|
||||
stableConfidence: 0,
|
||||
windowAction: "unknown",
|
||||
windowConfidence: 0,
|
||||
windowShare: 0,
|
||||
windowFrames: 0,
|
||||
windowProgress: 0,
|
||||
pending: false,
|
||||
pendingAction: null,
|
||||
stableMs: 0,
|
||||
candidateMs: 0,
|
||||
rawVolatility: 0,
|
||||
switchCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function stabilizeActionStream(
|
||||
sample: FrameActionSample,
|
||||
history: FrameActionSample[],
|
||||
state: StableActionState,
|
||||
) {
|
||||
history.push(sample);
|
||||
if (history.length > ACTION_WINDOW_FRAMES) {
|
||||
history.splice(0, history.length - ACTION_WINDOW_FRAMES);
|
||||
}
|
||||
|
||||
const { action: windowAction, stats } = getWindowAction(history);
|
||||
const windowStats = stats.get(windowAction) ?? getActionStat(history, "unknown");
|
||||
const currentStats = stats.get(state.current) ?? getActionStat(history, state.current);
|
||||
const pendingMinMs = windowAction === "unknown" ? ACTION_UNKNOWN_MIN_MS : ACTION_SWITCH_MIN_MS;
|
||||
const windowProgress = clamp(history.length / ACTION_WINDOW_FRAMES, 0, 1);
|
||||
|
||||
if (state.currentSince == null) {
|
||||
state.currentSince = sample.timestamp;
|
||||
}
|
||||
|
||||
if (windowAction === state.current) {
|
||||
state.candidate = null;
|
||||
state.candidateSince = null;
|
||||
state.candidateWindows = 0;
|
||||
} else if (windowProgress >= 0.7) {
|
||||
if (state.candidate !== windowAction) {
|
||||
state.candidate = windowAction;
|
||||
state.candidateSince = sample.timestamp;
|
||||
state.candidateWindows = 1;
|
||||
} else {
|
||||
state.candidateWindows += 1;
|
||||
}
|
||||
|
||||
const candidateStats = stats.get(windowAction) ?? getActionStat(history, windowAction);
|
||||
const currentStrength = state.current === "unknown" ? currentStats.strength * 0.55 : currentStats.strength;
|
||||
const candidateDuration = state.candidateSince == null ? 0 : sample.timestamp - state.candidateSince;
|
||||
const canSwitch =
|
||||
state.candidateWindows >= ACTION_LOCK_IN_WINDOWS &&
|
||||
candidateDuration >= pendingMinMs &&
|
||||
candidateStats.strength >= currentStrength + ACTION_SWITCH_DELTA;
|
||||
|
||||
if (canSwitch) {
|
||||
state.current = windowAction;
|
||||
state.currentSince = sample.timestamp;
|
||||
state.candidate = null;
|
||||
state.candidateSince = null;
|
||||
state.candidateWindows = 0;
|
||||
state.switchCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const stableStats = stats.get(state.current) ?? getActionStat(history, state.current);
|
||||
const stableConfidence = state.current === "unknown"
|
||||
? Math.max(sample.confidence * 0.45, stableStats.averageConfidence)
|
||||
: Math.max(stableStats.averageConfidence, windowStats.averageConfidence * 0.88);
|
||||
|
||||
return {
|
||||
stableAction: state.current,
|
||||
stableConfidence: clamp(stableConfidence, 0, 1),
|
||||
windowAction,
|
||||
windowConfidence: clamp(windowStats.averageConfidence, 0, 1),
|
||||
windowShare: clamp(windowStats.share, 0, 1),
|
||||
windowFrames: history.length,
|
||||
windowProgress,
|
||||
pending: Boolean(state.candidate),
|
||||
pendingAction: state.candidate,
|
||||
stableMs: state.currentSince == null ? 0 : sample.timestamp - state.currentSince,
|
||||
candidateMs: state.candidateSince == null ? 0 : sample.timestamp - state.candidateSince,
|
||||
rawVolatility: getRawVolatility(history),
|
||||
switchCount: state.switchCount,
|
||||
} satisfies StabilizedActionMeta;
|
||||
}
|
||||
|
||||
export function resolveAvatarKeyFromPrompt(prompt: string, fallback: AvatarKey): AvatarKey {
|
||||
const normalized = prompt.trim().toLowerCase();
|
||||
if (!normalized) return fallback;
|
||||
const matched = AVATAR_PRESETS.find((preset) => preset.keywords.some((keyword) => normalized.includes(keyword)));
|
||||
return matched?.key ?? fallback;
|
||||
}
|
||||
|
||||
function averagePoint(a: PosePoint | undefined, b: PosePoint | undefined, defaultX: number, defaultY: number) {
|
||||
return {
|
||||
x: ((a?.x ?? defaultX) + (b?.x ?? defaultX)) / 2,
|
||||
y: ((a?.y ?? defaultY) + (b?.y ?? defaultY)) / 2,
|
||||
};
|
||||
}
|
||||
|
||||
export function getAvatarAnchors(landmarks: PosePoint[], width: number, height: number): AvatarAnchors | null {
|
||||
const nose = landmarks[0];
|
||||
const leftShoulder = landmarks[11];
|
||||
const rightShoulder = landmarks[12];
|
||||
const leftHip = landmarks[23];
|
||||
const rightHip = landmarks[24];
|
||||
const leftWrist = landmarks[15];
|
||||
const rightWrist = landmarks[16];
|
||||
const leftAnkle = landmarks[27];
|
||||
const rightAnkle = landmarks[28];
|
||||
const leftEar = landmarks[7];
|
||||
const rightEar = landmarks[8];
|
||||
|
||||
if (!nose || !leftShoulder || !rightShoulder || !leftHip || !rightHip) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const shoulderCenter = averagePoint(leftShoulder, rightShoulder, 0.5, 0.32);
|
||||
const hipCenter = averagePoint(leftHip, rightHip, 0.5, 0.62);
|
||||
const ankleCenter = averagePoint(leftAnkle, rightAnkle, hipCenter.x, 0.92);
|
||||
const shoulderSpan = Math.abs(rightShoulder.x - leftShoulder.x) * width;
|
||||
const torsoHeight = Math.max((hipCenter.y - shoulderCenter.y) * height, shoulderSpan * 0.8);
|
||||
const headRadius = Math.max(
|
||||
shoulderSpan * 0.28,
|
||||
Math.abs((leftEar?.x ?? nose.x - 0.04) - (rightEar?.x ?? nose.x + 0.04)) * width * 0.45,
|
||||
34,
|
||||
);
|
||||
const bodyWidth = Math.max(shoulderSpan * 1.05, headRadius * 1.8);
|
||||
const bodyHeight = Math.max(torsoHeight * 1.1, headRadius * 2.2);
|
||||
|
||||
return {
|
||||
headX: nose.x * width,
|
||||
headY: Math.min(nose.y * height, shoulderCenter.y * height - headRadius * 0.2),
|
||||
headRadius,
|
||||
bodyX: shoulderCenter.x * width,
|
||||
bodyY: shoulderCenter.y * height + bodyHeight * 0.48,
|
||||
bodyWidth,
|
||||
bodyHeight,
|
||||
shoulderY: shoulderCenter.y * height,
|
||||
footY: Math.max(ankleCenter.y * height, hipCenter.y * height + bodyHeight * 1.35),
|
||||
leftHandX: (leftWrist?.x ?? leftShoulder.x - 0.08) * width,
|
||||
leftHandY: (leftWrist?.y ?? shoulderCenter.y + 0.1) * height,
|
||||
rightHandX: (rightWrist?.x ?? rightShoulder.x + 0.08) * width,
|
||||
rightHandY: (rightWrist?.y ?? shoulderCenter.y + 0.1) * height,
|
||||
};
|
||||
}
|
||||
|
||||
function drawRoundedBody(ctx: CanvasRenderingContext2D, anchors: AvatarAnchors, fill: string) {
|
||||
const radius = Math.min(anchors.bodyWidth, anchors.bodyHeight) * 0.18;
|
||||
const left = anchors.bodyX - anchors.bodyWidth / 2;
|
||||
const top = anchors.bodyY - anchors.bodyHeight / 2;
|
||||
const right = left + anchors.bodyWidth;
|
||||
const bottom = top + anchors.bodyHeight;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(left + radius, top);
|
||||
ctx.lineTo(right - radius, top);
|
||||
ctx.quadraticCurveTo(right, top, right, top + radius);
|
||||
ctx.lineTo(right, bottom - radius);
|
||||
ctx.quadraticCurveTo(right, bottom, right - radius, bottom);
|
||||
ctx.lineTo(left + radius, bottom);
|
||||
ctx.quadraticCurveTo(left, bottom, left, bottom - radius);
|
||||
ctx.lineTo(left, top + radius);
|
||||
ctx.quadraticCurveTo(left, top, left + radius, top);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = fill;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
function drawLimbs(ctx: CanvasRenderingContext2D, anchors: AvatarAnchors, stroke: string) {
|
||||
ctx.strokeStyle = stroke;
|
||||
ctx.lineWidth = Math.max(anchors.headRadius * 0.22, 10);
|
||||
ctx.lineCap = "round";
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(anchors.bodyX - anchors.bodyWidth * 0.24, anchors.shoulderY + anchors.headRadius * 0.65);
|
||||
ctx.lineTo(anchors.leftHandX, anchors.leftHandY);
|
||||
ctx.moveTo(anchors.bodyX + anchors.bodyWidth * 0.24, anchors.shoulderY + anchors.headRadius * 0.65);
|
||||
ctx.lineTo(anchors.rightHandX, anchors.rightHandY);
|
||||
ctx.moveTo(anchors.bodyX - anchors.bodyWidth * 0.14, anchors.bodyY + anchors.bodyHeight * 0.42);
|
||||
ctx.lineTo(anchors.bodyX - anchors.bodyWidth * 0.18, anchors.footY);
|
||||
ctx.moveTo(anchors.bodyX + anchors.bodyWidth * 0.14, anchors.bodyY + anchors.bodyHeight * 0.42);
|
||||
ctx.lineTo(anchors.bodyX + anchors.bodyWidth * 0.18, anchors.footY);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
function getAvatarImage(key: AvatarKey) {
|
||||
if (typeof Image === "undefined") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cached = avatarImageCache.get(key);
|
||||
if (cached) {
|
||||
return cached.complete && cached.naturalWidth > 0 ? cached : null;
|
||||
}
|
||||
|
||||
const image = new Image();
|
||||
image.decoding = "async";
|
||||
image.src = AVATAR_VISUALS[key].src;
|
||||
avatarImageCache.set(key, image);
|
||||
return null;
|
||||
}
|
||||
|
||||
function drawAvatarBadge(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
anchors: AvatarAnchors,
|
||||
avatarKey: AvatarKey,
|
||||
sprite: HTMLImageElement | null,
|
||||
) {
|
||||
const visual = AVATAR_VISUALS[avatarKey];
|
||||
const headSize = anchors.headRadius * 2.5;
|
||||
const torsoBadge = Math.max(anchors.headRadius * 0.95, 40);
|
||||
|
||||
drawRoundedBody(ctx, anchors, visual.bodyFill);
|
||||
drawLimbs(ctx, anchors, visual.limbStroke);
|
||||
|
||||
ctx.save();
|
||||
ctx.fillStyle = visual.glow;
|
||||
ctx.beginPath();
|
||||
ctx.arc(anchors.headX, anchors.headY, anchors.headRadius * 1.16, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
|
||||
if (sprite) {
|
||||
ctx.drawImage(
|
||||
sprite,
|
||||
anchors.headX - headSize / 2,
|
||||
anchors.headY - headSize / 2,
|
||||
headSize,
|
||||
headSize,
|
||||
);
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.94;
|
||||
ctx.drawImage(
|
||||
sprite,
|
||||
anchors.bodyX - torsoBadge / 2,
|
||||
anchors.bodyY - torsoBadge / 2,
|
||||
torsoBadge,
|
||||
torsoBadge,
|
||||
);
|
||||
ctx.restore();
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.fillStyle = "rgba(255,255,255,0.92)";
|
||||
ctx.beginPath();
|
||||
ctx.arc(anchors.headX, anchors.headY, anchors.headRadius * 0.88, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = "rgba(17,24,39,0.82)";
|
||||
ctx.beginPath();
|
||||
ctx.arc(anchors.headX - anchors.headRadius * 0.22, anchors.headY - anchors.headRadius * 0.08, anchors.headRadius * 0.08, 0, Math.PI * 2);
|
||||
ctx.arc(anchors.headX + anchors.headRadius * 0.22, anchors.headY - anchors.headRadius * 0.08, anchors.headRadius * 0.08, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
function drawFullFigureAvatar(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
anchors: AvatarAnchors,
|
||||
avatarKey: AvatarKey,
|
||||
sprite: HTMLImageElement | null,
|
||||
) {
|
||||
const visual = AVATAR_VISUALS[avatarKey];
|
||||
const topY = anchors.headY - anchors.headRadius * 1.55 + anchors.bodyHeight * (visual.figureOffsetY ?? 0);
|
||||
const baseHeight = Math.max(anchors.footY - topY, anchors.bodyHeight * 2.35);
|
||||
const figureHeight = baseHeight * (visual.figureScale ?? 1);
|
||||
const aspectRatio = sprite?.naturalWidth && sprite?.naturalHeight
|
||||
? sprite.naturalWidth / sprite.naturalHeight
|
||||
: 0.72;
|
||||
const figureWidth = figureHeight * aspectRatio;
|
||||
const figureLeft = anchors.bodyX - figureWidth / 2;
|
||||
|
||||
ctx.save();
|
||||
ctx.fillStyle = visual.glow;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(
|
||||
anchors.bodyX,
|
||||
anchors.footY - anchors.headRadius * 0.1,
|
||||
Math.max(anchors.bodyWidth * 0.42, 34),
|
||||
Math.max(anchors.headRadius * 0.22, 10),
|
||||
0,
|
||||
0,
|
||||
Math.PI * 2,
|
||||
);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
|
||||
if (sprite) {
|
||||
ctx.save();
|
||||
ctx.shadowColor = "rgba(15,23,42,0.28)";
|
||||
ctx.shadowBlur = 16;
|
||||
ctx.shadowOffsetY = 10;
|
||||
ctx.drawImage(sprite, figureLeft, topY, figureWidth, figureHeight);
|
||||
ctx.restore();
|
||||
return;
|
||||
}
|
||||
|
||||
drawRoundedBody(ctx, anchors, visual.bodyFill);
|
||||
drawLimbs(ctx, anchors, visual.limbStroke);
|
||||
}
|
||||
|
||||
export function renderLiveCameraOverlayToContext(
|
||||
ctx: CanvasRenderingContext2D | null,
|
||||
width: number,
|
||||
height: number,
|
||||
landmarks: PosePoint[] | undefined,
|
||||
avatarState?: AvatarRenderState,
|
||||
options?: { clear?: boolean },
|
||||
) {
|
||||
if (!ctx) return;
|
||||
if (options?.clear !== false) {
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
}
|
||||
if (!landmarks) return;
|
||||
|
||||
if (avatarState?.enabled) {
|
||||
const anchors = getAvatarAnchors(landmarks, width, height);
|
||||
if (anchors) {
|
||||
const sprite = getAvatarImage(avatarState.avatarKey);
|
||||
const visual = AVATAR_VISUALS[avatarState.avatarKey];
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.95;
|
||||
if (visual.renderMode === "full-figure") {
|
||||
drawFullFigureAvatar(ctx, anchors, avatarState.avatarKey, sprite);
|
||||
} else {
|
||||
drawAvatarBadge(ctx, anchors, avatarState.avatarKey, sprite);
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
if (visual.renderMode !== "full-figure") {
|
||||
ctx.save();
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.16)";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.setLineDash([8, 10]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(anchors.bodyX, anchors.shoulderY - anchors.headRadius * 1.25);
|
||||
ctx.lineTo(anchors.bodyX, anchors.footY);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const poseConnections: Array<[number, number]> = [
|
||||
[11, 12], [11, 13], [13, 15], [12, 14], [14, 16],
|
||||
[11, 23], [12, 24], [23, 24], [23, 25], [24, 26],
|
||||
[25, 27], [26, 28], [15, 17], [16, 18], [15, 19],
|
||||
[16, 20], [17, 19], [18, 20],
|
||||
];
|
||||
|
||||
ctx.strokeStyle = "rgba(25, 211, 155, 0.9)";
|
||||
ctx.lineWidth = 3;
|
||||
poseConnections.forEach(([from, to]) => {
|
||||
const start = landmarks[from];
|
||||
const end = landmarks[to];
|
||||
if (!start || !end || (start.visibility ?? 1) < 0.25 || (end.visibility ?? 1) < 0.25) return;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(start.x * width, start.y * height);
|
||||
ctx.lineTo(end.x * width, end.y * height);
|
||||
ctx.stroke();
|
||||
});
|
||||
|
||||
landmarks.forEach((point, index) => {
|
||||
if ((point.visibility ?? 1) < 0.25) return;
|
||||
ctx.fillStyle = index >= 11 && index <= 16 ? "rgba(253, 224, 71, 0.95)" : "rgba(255,255,255,0.88)";
|
||||
ctx.beginPath();
|
||||
ctx.arc(point.x * width, point.y * height, index >= 11 && index <= 16 ? 5 : 4, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
});
|
||||
}
|
||||
|
||||
export function drawLiveCameraOverlay(
|
||||
canvas: HTMLCanvasElement | null,
|
||||
landmarks: PosePoint[] | undefined,
|
||||
avatarState?: AvatarRenderState,
|
||||
) {
|
||||
const ctx = canvas?.getContext("2d");
|
||||
if (!canvas || !ctx) return;
|
||||
renderLiveCameraOverlayToContext(ctx, canvas.width, canvas.height, landmarks, avatarState, { clear: true });
|
||||
}
|
||||
118
client/src/lib/matches.ts
普通文件
@@ -0,0 +1,118 @@
|
||||
type PlayerSlot = "player_a" | "player_b";
|
||||
|
||||
type PlayerLike = {
|
||||
playerSlot: PlayerSlot;
|
||||
userId: number;
|
||||
userName?: string | null;
|
||||
isWinner?: number;
|
||||
cameraSlot?: string | null;
|
||||
cameraStatus?: string | null;
|
||||
suggestedGamesWon?: number | null;
|
||||
finalGamesWon?: number | null;
|
||||
suggestedSetsWon?: number | null;
|
||||
finalSetsWon?: number | null;
|
||||
suggestedPointsWon?: number | null;
|
||||
finalPointsWon?: number | null;
|
||||
suggestedStats?: unknown;
|
||||
finalStats?: unknown;
|
||||
};
|
||||
|
||||
export type ScoreLike = {
|
||||
sets?: Record<PlayerSlot, number>;
|
||||
games?: Record<PlayerSlot, number>;
|
||||
points?: Record<PlayerSlot, number>;
|
||||
winnerSlot?: PlayerSlot | null;
|
||||
};
|
||||
|
||||
export type MatchLike = {
|
||||
matchMode?: "daily" | "competitive" | string | null;
|
||||
workflowStatus?: string | null;
|
||||
suggestedScore?: unknown;
|
||||
finalScore?: unknown;
|
||||
participants?: PlayerLike[] | null;
|
||||
};
|
||||
|
||||
function toScore(raw: unknown): ScoreLike {
|
||||
if (!raw || typeof raw !== "object") {
|
||||
return {
|
||||
sets: { player_a: 0, player_b: 0 },
|
||||
games: { player_a: 0, player_b: 0 },
|
||||
points: { player_a: 0, player_b: 0 },
|
||||
winnerSlot: null,
|
||||
};
|
||||
}
|
||||
const value = raw as Record<string, any>;
|
||||
return {
|
||||
sets: {
|
||||
player_a: Number(value.sets?.player_a ?? 0),
|
||||
player_b: Number(value.sets?.player_b ?? 0),
|
||||
},
|
||||
games: {
|
||||
player_a: Number(value.games?.player_a ?? 0),
|
||||
player_b: Number(value.games?.player_b ?? 0),
|
||||
},
|
||||
points: {
|
||||
player_a: Number(value.points?.player_a ?? 0),
|
||||
player_b: Number(value.points?.player_b ?? 0),
|
||||
},
|
||||
winnerSlot: value.winnerSlot === "player_a" || value.winnerSlot === "player_b" ? value.winnerSlot : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function getMatchModeLabel(mode?: MatchLike["matchMode"]) {
|
||||
return mode === "competitive" ? "竞赛" : "日常";
|
||||
}
|
||||
|
||||
export function getMatchStatusLabel(status?: string | null) {
|
||||
switch (status) {
|
||||
case "draft":
|
||||
return "待入库";
|
||||
case "recording":
|
||||
return "采集中";
|
||||
case "review_pending":
|
||||
return "待审核";
|
||||
case "reviewed":
|
||||
return "待结算";
|
||||
case "finalizing":
|
||||
return "结算中";
|
||||
case "finalized":
|
||||
return "已入库";
|
||||
case "cancelled":
|
||||
return "已取消";
|
||||
default:
|
||||
return "未知";
|
||||
}
|
||||
}
|
||||
|
||||
export function getMatchDisplayScore(match: MatchLike) {
|
||||
return toScore(match.finalScore ?? match.suggestedScore);
|
||||
}
|
||||
|
||||
export function formatMatchScore(match: MatchLike) {
|
||||
const score = getMatchDisplayScore(match);
|
||||
return `盘 ${score.sets?.player_a ?? 0}:${score.sets?.player_b ?? 0} · 局 ${score.games?.player_a ?? 0}:${score.games?.player_b ?? 0} · 分 ${score.points?.player_a ?? 0}:${score.points?.player_b ?? 0}`;
|
||||
}
|
||||
|
||||
export function getMatchParticipant(match: MatchLike, slot: PlayerSlot) {
|
||||
return (match.participants || []).find((participant) => participant.playerSlot === slot) ?? null;
|
||||
}
|
||||
|
||||
export function getParticipantResult(match: MatchLike, userId?: number | null) {
|
||||
if (!userId) return null;
|
||||
const participant = (match.participants || []).find((item) => item.userId === userId);
|
||||
if (!participant) return null;
|
||||
|
||||
const score = getMatchDisplayScore(match);
|
||||
const winnerSlot = score.winnerSlot ?? ((match.participants || []).find((item) => item.isWinner === 1)?.playerSlot ?? null);
|
||||
if (!winnerSlot) {
|
||||
return "待确认";
|
||||
}
|
||||
return participant.playerSlot === winnerSlot ? "获胜" : "失利";
|
||||
}
|
||||
|
||||
export function getWinnerName(match: MatchLike) {
|
||||
const score = getMatchDisplayScore(match);
|
||||
const winnerSlot = score.winnerSlot ?? ((match.participants || []).find((item) => item.isWinner === 1)?.playerSlot ?? null);
|
||||
if (!winnerSlot) return "待确认";
|
||||
return getMatchParticipant(match, winnerSlot)?.userName || winnerSlot;
|
||||
}
|
||||
29
client/src/lib/media.test.ts
普通文件
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatRecordingTime, getMediaAssetUrl, 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);
|
||||
});
|
||||
|
||||
it("keeps already-prefixed media asset paths stable", () => {
|
||||
expect(getMediaAssetUrl("/media/assets/sessions/demo/preview.webm")).toBe(
|
||||
"/media/assets/sessions/demo/preview.webm"
|
||||
);
|
||||
expect(getMediaAssetUrl("https://cdn.example.com/demo.webm")).toBe(
|
||||
"https://cdn.example.com/demo.webm"
|
||||
);
|
||||
expect(getMediaAssetUrl("/assets/sessions/demo/preview.webm")).toBe(
|
||||
"/media/assets/sessions/demo/preview.webm"
|
||||
);
|
||||
});
|
||||
});
|
||||
254
client/src/lib/media.ts
普通文件
@@ -0,0 +1,254 @@
|
||||
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;
|
||||
purpose?: "recording" | "relay";
|
||||
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;
|
||||
relayBufferSeconds?: 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;
|
||||
purpose?: "recording" | "relay";
|
||||
relayBufferSeconds?: number;
|
||||
}) {
|
||||
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) {
|
||||
if (/^https?:\/\//i.test(path)) {
|
||||
return path;
|
||||
}
|
||||
if (path.startsWith(`${MEDIA_BASE}/`)) {
|
||||
return path;
|
||||
}
|
||||
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}>
|
||||
|
||||
744
client/src/pages/AdminConsole.tsx
普通文件
@@ -0,0 +1,744 @@
|
||||
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 { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { formatDateTimeShanghai } from "@/lib/time";
|
||||
import { formatMatchScore, getMatchModeLabel, getMatchParticipant, getMatchStatusLabel, getWinnerName } from "@/lib/matches";
|
||||
import { toast } from "sonner";
|
||||
import { Activity, Camera, ClipboardCheck, Database, RefreshCw, Settings2, Shield, Sparkles, Swords, 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 matchesQuery = trpc.match.list.useQuery({ limit: 50 }, { enabled: user?.role === "admin" });
|
||||
|
||||
const [settingsDrafts, setSettingsDrafts] = useState<Record<string, string>>({});
|
||||
const [selectedMatchId, setSelectedMatchId] = useState<number | null>(null);
|
||||
const [createDraft, setCreateDraft] = useState({
|
||||
title: "",
|
||||
matchMode: "daily",
|
||||
playerAUserId: "",
|
||||
playerBUserId: "",
|
||||
courtName: "",
|
||||
durationMinutes: "90",
|
||||
});
|
||||
const [reviewDraft, setReviewDraft] = useState({
|
||||
playerASet: "0",
|
||||
playerBSet: "0",
|
||||
playerAGame: "0",
|
||||
playerBGame: "0",
|
||||
playerAPoint: "0",
|
||||
playerBPoint: "0",
|
||||
reviewNotes: "",
|
||||
});
|
||||
const [cameraLabelDrafts, setCameraLabelDrafts] = useState<Record<string, string>>({});
|
||||
const matchDetailQuery = trpc.match.get.useQuery(
|
||||
{ matchId: selectedMatchId ?? 0 },
|
||||
{ enabled: user?.role === "admin" && selectedMatchId != null },
|
||||
);
|
||||
|
||||
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}`),
|
||||
});
|
||||
const createMatchMutation = trpc.match.create.useMutation({
|
||||
onSuccess: (detail) => {
|
||||
toast.success("比赛入库草稿已创建");
|
||||
utils.match.list.invalidate();
|
||||
if (detail?.id) {
|
||||
setSelectedMatchId(detail.id);
|
||||
}
|
||||
},
|
||||
onError: (error) => toast.error(`创建失败: ${error.message}`),
|
||||
});
|
||||
const bindCameraMutation = trpc.match.bindCamera.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("机位状态已更新");
|
||||
utils.match.list.invalidate();
|
||||
utils.match.get.invalidate();
|
||||
},
|
||||
onError: (error) => toast.error(`更新失败: ${error.message}`),
|
||||
});
|
||||
const requestSuggestionMutation = trpc.match.requestSuggestion.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("自动计分建议已加入后台队列");
|
||||
utils.match.list.invalidate();
|
||||
utils.match.get.invalidate();
|
||||
utils.admin.tasks.invalidate();
|
||||
},
|
||||
onError: (error) => toast.error(`提交失败: ${error.message}`),
|
||||
});
|
||||
const reviewSubmitMutation = trpc.match.reviewSubmit.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("审核比分已保存");
|
||||
utils.match.list.invalidate();
|
||||
utils.match.get.invalidate();
|
||||
utils.admin.auditLogs.invalidate();
|
||||
},
|
||||
onError: (error) => toast.error(`审核保存失败: ${error.message}`),
|
||||
});
|
||||
const finalizeMatchMutation = trpc.match.finalize.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("正式结算已加入后台队列");
|
||||
utils.match.list.invalidate();
|
||||
utils.match.get.invalidate();
|
||||
utils.admin.tasks.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]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedMatchId && matchesQuery.data?.length) {
|
||||
setSelectedMatchId(matchesQuery.data[0].id);
|
||||
}
|
||||
}, [matchesQuery.data, selectedMatchId]);
|
||||
|
||||
useEffect(() => {
|
||||
const detail = matchDetailQuery.data;
|
||||
if (!detail) return;
|
||||
const score: any = detail.finalScore || detail.suggestedScore || {};
|
||||
setReviewDraft({
|
||||
playerASet: String(score?.sets?.player_a ?? 0),
|
||||
playerBSet: String(score?.sets?.player_b ?? 0),
|
||||
playerAGame: String(score?.games?.player_a ?? 0),
|
||||
playerBGame: String(score?.games?.player_b ?? 0),
|
||||
playerAPoint: String(score?.points?.player_a ?? 0),
|
||||
playerBPoint: String(score?.points?.player_b ?? 0),
|
||||
reviewNotes: detail.reviewNotes || "",
|
||||
});
|
||||
const nextLabels: Record<string, string> = {};
|
||||
(detail.participants || []).forEach((item: any) => {
|
||||
nextLabels[item.playerSlot] = item.cameraLabel || "";
|
||||
});
|
||||
setCameraLabelDrafts(nextLabels);
|
||||
}, [matchDetailQuery.data]);
|
||||
|
||||
const totals = useMemo(() => ({
|
||||
users: (usersQuery.data || []).length,
|
||||
tasks: (tasksQuery.data || []).length,
|
||||
sessions: (liveSessionsQuery.data || []).length,
|
||||
matches: (matchesQuery.data || []).length,
|
||||
}), [liveSessionsQuery.data, matchesQuery.data, tasksQuery.data, usersQuery.data]);
|
||||
|
||||
const submitCreateMatch = () => {
|
||||
const playerAUserId = Number(createDraft.playerAUserId);
|
||||
const playerBUserId = Number(createDraft.playerBUserId);
|
||||
const durationMinutes = Number(createDraft.durationMinutes || 90);
|
||||
if (!createDraft.title.trim()) {
|
||||
toast.error("请填写比赛标题");
|
||||
return;
|
||||
}
|
||||
if (!Number.isFinite(playerAUserId) || !Number.isFinite(playerBUserId)) {
|
||||
toast.error("请选择两位参赛用户");
|
||||
return;
|
||||
}
|
||||
createMatchMutation.mutate({
|
||||
title: createDraft.title.trim(),
|
||||
matchMode: createDraft.matchMode as "daily" | "competitive",
|
||||
playerAUserId,
|
||||
playerBUserId,
|
||||
courtName: createDraft.courtName.trim() || undefined,
|
||||
durationMinutes,
|
||||
});
|
||||
};
|
||||
|
||||
const submitReview = () => {
|
||||
if (!selectedMatchId) return;
|
||||
reviewSubmitMutation.mutate({
|
||||
matchId: selectedMatchId,
|
||||
reviewNotes: reviewDraft.reviewNotes.trim() || undefined,
|
||||
finalScore: {
|
||||
sets: {
|
||||
player_a: Number(reviewDraft.playerASet || 0),
|
||||
player_b: Number(reviewDraft.playerBSet || 0),
|
||||
},
|
||||
games: {
|
||||
player_a: Number(reviewDraft.playerAGame || 0),
|
||||
player_b: Number(reviewDraft.playerBGame || 0),
|
||||
},
|
||||
points: {
|
||||
player_a: Number(reviewDraft.playerAPoint || 0),
|
||||
player_b: Number(reviewDraft.playerBPoint || 0),
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
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-4">
|
||||
<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>
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardContent className="pt-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<Swords className="h-5 w-5 text-amber-700" />
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">比赛入库</div>
|
||||
<div className="mt-1 text-xl font-semibold">{totals.matches}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="users" className="space-y-4">
|
||||
<TabsList className="grid w-full grid-cols-6">
|
||||
<TabsTrigger value="users">用户</TabsTrigger>
|
||||
<TabsTrigger value="matches">比赛入库</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="matches">
|
||||
<div className="space-y-4">
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<ClipboardCheck className="h-4 w-4 text-primary" />
|
||||
新建双人双摄比赛
|
||||
</CardTitle>
|
||||
<CardDescription>固定两位用户和两路机位归属,后续自动计分建议、审核和正式结算都围绕该记录展开。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 lg:grid-cols-6">
|
||||
<Input
|
||||
placeholder="比赛标题"
|
||||
value={createDraft.title}
|
||||
onChange={(event) => setCreateDraft((current) => ({ ...current, title: event.target.value }))}
|
||||
className="lg:col-span-2"
|
||||
/>
|
||||
<select
|
||||
className="h-11 rounded-2xl border border-border/70 bg-background px-3 text-sm"
|
||||
value={createDraft.matchMode}
|
||||
onChange={(event) => setCreateDraft((current) => ({ ...current, matchMode: event.target.value }))}
|
||||
>
|
||||
<option value="daily">日常</option>
|
||||
<option value="competitive">竞赛</option>
|
||||
</select>
|
||||
<select
|
||||
className="h-11 rounded-2xl border border-border/70 bg-background px-3 text-sm"
|
||||
value={createDraft.playerAUserId}
|
||||
onChange={(event) => setCreateDraft((current) => ({ ...current, playerAUserId: event.target.value }))}
|
||||
>
|
||||
<option value="">选择 A 用户</option>
|
||||
{(usersQuery.data || []).map((item: any) => (
|
||||
<option key={`a-${item.id}`} value={item.id}>{item.name || item.id}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="h-11 rounded-2xl border border-border/70 bg-background px-3 text-sm"
|
||||
value={createDraft.playerBUserId}
|
||||
onChange={(event) => setCreateDraft((current) => ({ ...current, playerBUserId: event.target.value }))}
|
||||
>
|
||||
<option value="">选择 B 用户</option>
|
||||
{(usersQuery.data || []).map((item: any) => (
|
||||
<option key={`b-${item.id}`} value={item.id}>{item.name || item.id}</option>
|
||||
))}
|
||||
</select>
|
||||
<Input
|
||||
placeholder="场地 / 场馆"
|
||||
value={createDraft.courtName}
|
||||
onChange={(event) => setCreateDraft((current) => ({ ...current, courtName: event.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
placeholder="时长(分钟)"
|
||||
value={createDraft.durationMinutes}
|
||||
onChange={(event) => setCreateDraft((current) => ({ ...current, durationMinutes: event.target.value }))}
|
||||
/>
|
||||
<div className="lg:col-span-6">
|
||||
<Button onClick={submitCreateMatch} disabled={createMatchMutation.isPending} className="gap-2">
|
||||
<Swords className="h-4 w-4" />
|
||||
创建比赛草稿
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-[360px,1fr]">
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">待处理比赛</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{(!matchesQuery.data || matchesQuery.data.length === 0) ? (
|
||||
<div className="rounded-2xl border border-dashed border-border/70 p-6 text-center text-sm text-muted-foreground">
|
||||
暂无比赛记录。
|
||||
</div>
|
||||
) : (
|
||||
(matchesQuery.data || []).map((match: any) => (
|
||||
<button
|
||||
key={match.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedMatchId(match.id)}
|
||||
className={`w-full rounded-2xl border p-4 text-left transition ${selectedMatchId === match.id ? "border-primary bg-primary/5" : "border-border/60 bg-muted/20 hover:bg-muted/40"}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">{match.title}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{getMatchParticipant(match, "player_a")?.userName || "A"} vs {getMatchParticipant(match, "player_b")?.userName || "B"}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={match.workflowStatus === "finalized" ? "secondary" : "outline"}>
|
||||
{getMatchStatusLabel(match.workflowStatus)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-3 text-xs text-muted-foreground">
|
||||
{getMatchModeLabel(match.matchMode)} · {formatMatchScore(match)}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">审核与结算</CardTitle>
|
||||
<CardDescription>H1 / 管理员可以在这里补机位、拉起自动计分、修正比分并提交正式结算。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{!selectedMatchId ? (
|
||||
<div className="rounded-2xl border border-dashed border-border/70 p-8 text-center text-sm text-muted-foreground">
|
||||
先从左侧选择一场比赛。
|
||||
</div>
|
||||
) : matchDetailQuery.isLoading ? (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-24 w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
) : !matchDetailQuery.data ? (
|
||||
<div className="rounded-2xl border border-dashed border-border/70 p-8 text-center text-sm text-muted-foreground">
|
||||
未找到比赛详情。
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="rounded-2xl border border-border/60 bg-muted/20 p-4">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-lg font-semibold">{matchDetailQuery.data.title}</span>
|
||||
<Badge variant="secondary">{getMatchModeLabel(matchDetailQuery.data.matchMode)}</Badge>
|
||||
<Badge variant="outline">{getMatchStatusLabel(matchDetailQuery.data.workflowStatus)}</Badge>
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-muted-foreground">
|
||||
胜者 {getWinnerName(matchDetailQuery.data)} · 当前比分 {formatMatchScore(matchDetailQuery.data)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => requestSuggestionMutation.mutate({ matchId: matchDetailQuery.data.id })}
|
||||
disabled={requestSuggestionMutation.isPending || matchDetailQuery.data.workflowStatus === "cancelled"}
|
||||
>
|
||||
刷新自动计分
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => finalizeMatchMutation.mutate({ matchId: matchDetailQuery.data.id })}
|
||||
disabled={finalizeMatchMutation.isPending || matchDetailQuery.data.workflowStatus === "finalized" || matchDetailQuery.data.workflowStatus === "cancelled"}
|
||||
>
|
||||
提交正式结算
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{(["player_a", "player_b"] as const).map((slot) => {
|
||||
const participant = getMatchParticipant(matchDetailQuery.data, slot);
|
||||
if (!participant) return null;
|
||||
return (
|
||||
<Card key={slot} className="border border-border/60 shadow-none">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">{participant.userName || slot}</CardTitle>
|
||||
<CardDescription>{slot === "player_a" ? "A 机位" : "B 机位"} · 当前 {participant.cameraStatus}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Input
|
||||
placeholder="机位标签"
|
||||
value={cameraLabelDrafts[slot] || ""}
|
||||
onChange={(event) => setCameraLabelDrafts((current) => ({ ...current, [slot]: event.target.value }))}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => bindCameraMutation.mutate({
|
||||
matchId: matchDetailQuery.data.id,
|
||||
playerSlot: slot,
|
||||
cameraStatus: "bound",
|
||||
cameraLabel: cameraLabelDrafts[slot] || undefined,
|
||||
})}
|
||||
>
|
||||
标记已绑定
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => bindCameraMutation.mutate({
|
||||
matchId: matchDetailQuery.data.id,
|
||||
playerSlot: slot,
|
||||
cameraStatus: "completed",
|
||||
cameraLabel: cameraLabelDrafts[slot] || undefined,
|
||||
})}
|
||||
>
|
||||
标记已完成
|
||||
</Button>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-muted/30 px-3 py-2 text-sm text-muted-foreground">
|
||||
建议局分 {participant.suggestedGamesWon || 0} · 正式局分 {participant.finalGamesWon || 0}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Card className="border border-border/60 shadow-none">
|
||||
<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-3">
|
||||
<Input value={reviewDraft.playerASet} onChange={(event) => setReviewDraft((current) => ({ ...current, playerASet: event.target.value }))} placeholder="A 盘数" />
|
||||
<Input value={reviewDraft.playerAGame} onChange={(event) => setReviewDraft((current) => ({ ...current, playerAGame: event.target.value }))} placeholder="A 局数" />
|
||||
<Input value={reviewDraft.playerAPoint} onChange={(event) => setReviewDraft((current) => ({ ...current, playerAPoint: event.target.value }))} placeholder="A 得分点" />
|
||||
<Input value={reviewDraft.playerBSet} onChange={(event) => setReviewDraft((current) => ({ ...current, playerBSet: event.target.value }))} placeholder="B 盘数" />
|
||||
<Input value={reviewDraft.playerBGame} onChange={(event) => setReviewDraft((current) => ({ ...current, playerBGame: event.target.value }))} placeholder="B 局数" />
|
||||
<Input value={reviewDraft.playerBPoint} onChange={(event) => setReviewDraft((current) => ({ ...current, playerBPoint: event.target.value }))} placeholder="B 得分点" />
|
||||
</div>
|
||||
<Textarea
|
||||
value={reviewDraft.reviewNotes}
|
||||
onChange={(event) => setReviewDraft((current) => ({ ...current, reviewNotes: event.target.value }))}
|
||||
placeholder="审核说明"
|
||||
className="min-h-[120px] rounded-2xl"
|
||||
/>
|
||||
<Button onClick={submitReview} disabled={reviewSubmitMutation.isPending}>
|
||||
保存审核比分
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-border/60 shadow-none">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Camera className="h-4 w-4 text-primary" />
|
||||
自动计分事件
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{(!matchDetailQuery.data.events || matchDetailQuery.data.events.length === 0) ? (
|
||||
<div className="rounded-2xl border border-dashed border-border/70 p-6 text-center text-sm text-muted-foreground">
|
||||
当前没有自动计分事件。
|
||||
</div>
|
||||
) : (
|
||||
matchDetailQuery.data.events.map((event: any) => (
|
||||
<div key={event.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 className="flex items-center gap-2">
|
||||
<Badge variant="outline">#{event.eventIndex}</Badge>
|
||||
<span className="font-medium">{event.eventType}</span>
|
||||
<Badge variant="secondary">{event.source}</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
置信度 {Number(event.confidence || 0).toFixed(2)} · {formatDateTimeShanghai(event.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
<pre className="mt-3 overflow-x-auto rounded-xl bg-background/80 p-3 text-xs text-muted-foreground">
|
||||
{JSON.stringify(event.payload ?? {}, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</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();
|
||||
@@ -315,9 +343,19 @@ export default function Analysis() {
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">视频姿势分析</h1>
|
||||
<p className="text-muted-foreground text-sm mt-1">上传训练视频,AI自动识别姿势并给出矫正建议</p>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
291
client/src/pages/Checkin.tsx
普通文件
@@ -0,0 +1,291 @@
|
||||
import { useMemo } from "react";
|
||||
import { useAuth } from "@/_core/hooks/useAuth";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { formatDateShanghai } from "@/lib/time";
|
||||
import { Award, Calendar, Flame, Radar, Sparkles, Swords, Trophy } from "lucide-react";
|
||||
|
||||
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 achievementQuery = trpc.achievement.list.useQuery();
|
||||
const statsQuery = trpc.profile.stats.useQuery();
|
||||
|
||||
const achievements = useMemo(() => achievementQuery.data ?? [], [achievementQuery.data]);
|
||||
const stats = statsQuery.data;
|
||||
|
||||
const groupedAchievements = useMemo(() => {
|
||||
const groups: Record<string, any[]> = {};
|
||||
achievements.forEach((item: any) => {
|
||||
const key = item.category || "other";
|
||||
if (!groups[key]) groups[key] = [];
|
||||
groups[key].push(item);
|
||||
});
|
||||
return groups;
|
||||
}, [achievements]);
|
||||
|
||||
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">
|
||||
<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">
|
||||
<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>
|
||||
<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="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>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ export default function Home() {
|
||||
<header className="container py-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Target className="h-6 w-6 text-primary" />
|
||||
<span className="font-bold text-lg tracking-tight">Tennis Training Hub</span>
|
||||
<span className="font-bold text-lg tracking-tight">Tennis Hub</span>
|
||||
</div>
|
||||
<Button onClick={() => setLocation("/login")} variant="default" size="sm">
|
||||
开始使用
|
||||
@@ -30,19 +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>
|
||||
@@ -51,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: "基于美国网球协会标准,从5个维度综合评估您的技术水平,自动更新评分",
|
||||
desc: "按 USTA 维度记录评分结果",
|
||||
color: "bg-purple-50 text-purple-600",
|
||||
},
|
||||
{
|
||||
icon: Zap,
|
||||
title: "击球统计分析",
|
||||
desc: "自动检测击球次数、挥拍速度、击球一致性,量化每次训练效果",
|
||||
title: "击球统计",
|
||||
desc: "记录击球次数、挥拍速度和一致性",
|
||||
color: "bg-orange-50 text-orange-600",
|
||||
},
|
||||
{
|
||||
icon: Footprints,
|
||||
title: "运动轨迹追踪",
|
||||
desc: "记录身体重心移动轨迹,分析脚步移动模式,提升步法灵活性",
|
||||
title: "运动轨迹",
|
||||
desc: "记录重心移动轨迹和脚步变化",
|
||||
color: "bg-teal-50 text-teal-600",
|
||||
},
|
||||
{
|
||||
icon: TrendingUp,
|
||||
title: "进度可视化",
|
||||
desc: "直观展示训练历史、能力提升趋势和评分变化,激励持续进步",
|
||||
title: "进度追踪",
|
||||
desc: "查看训练历史、趋势和评分变化",
|
||||
color: "bg-indigo-50 text-indigo-600",
|
||||
},
|
||||
].map((feature) => (
|
||||
@@ -104,13 +103,13 @@ 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: "3", title: "上传训练视频", desc: "录制挥拍视频并上传分析" },
|
||||
{ step: "4", title: "获取评分建议", desc: "查看分析结果和矫正建议" },
|
||||
{ step: "1", title: "输入用户名", desc: "用户名登录即可" },
|
||||
{ step: "2", title: "生成计划", desc: "生成训练安排" },
|
||||
{ step: "3", title: "上传视频", desc: "录制挥拍并分析" },
|
||||
{ step: "4", title: "获取反馈", desc: "评分与矫正建议" },
|
||||
].map((item) => (
|
||||
<div key={item.step} className="text-center">
|
||||
<div className="h-12 w-12 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-lg font-bold mx-auto mb-3">
|
||||
@@ -126,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>
|
||||
@@ -140,9 +139,9 @@ export default function Home() {
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<Target className="h-4 w-4" />
|
||||
<span>Tennis Training Hub</span>
|
||||
<span>Tennis Hub</span>
|
||||
</div>
|
||||
<span>AI驱动的在家网球训练助手</span>
|
||||
<span>训练与分析</span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
209
client/src/pages/Leaderboard.tsx
普通文件
@@ -0,0 +1,209 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { useAuth } from "@/_core/hooks/useAuth";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Trophy, Clock, Zap, Target, Crown, Medal, Award, Swords, Percent } from "lucide-react";
|
||||
|
||||
type LeaderboardScope = "training" | "competitive";
|
||||
type TrainingSortKey = "ntrpRating" | "totalMinutes" | "totalSessions" | "totalShots";
|
||||
type CompetitiveSortKey = "wins" | "winRate" | "setsWon" | "pointsWon" | "matches";
|
||||
type SortKey = TrainingSortKey | CompetitiveSortKey;
|
||||
|
||||
const trainingTabs: Array<{ key: TrainingSortKey; label: string; icon: React.ReactNode; unit: string }> = [
|
||||
{ key: "ntrpRating", label: "NTRP评分", icon: <Trophy className="h-4 w-4" />, unit: "" },
|
||||
{ key: "totalMinutes", label: "训练时长", icon: <Clock className="h-4 w-4" />, unit: "分钟" },
|
||||
{ key: "totalSessions", label: "训练次数", icon: <Target className="h-4 w-4" />, unit: "次" },
|
||||
{ key: "totalShots", label: "总击球数", icon: <Zap className="h-4 w-4" />, unit: "次" },
|
||||
];
|
||||
|
||||
const competitiveTabs: Array<{ key: CompetitiveSortKey; label: string; icon: React.ReactNode; unit: string }> = [
|
||||
{ key: "wins", label: "胜场", icon: <Trophy className="h-4 w-4" />, unit: "场" },
|
||||
{ key: "winRate", label: "胜率", icon: <Percent className="h-4 w-4" />, unit: "%" },
|
||||
{ key: "setsWon", label: "赢盘", icon: <Swords className="h-4 w-4" />, unit: "盘" },
|
||||
{ key: "pointsWon", label: "赢分", icon: <Zap className="h-4 w-4" />, unit: "分" },
|
||||
{ key: "matches", label: "场次", icon: <Target className="h-4 w-4" />, unit: "场" },
|
||||
];
|
||||
|
||||
const rankIcons = [
|
||||
<Crown className="h-5 w-5 text-yellow-500" />,
|
||||
<Medal className="h-5 w-5 text-gray-400" />,
|
||||
<Award className="h-5 w-5 text-amber-600" />,
|
||||
];
|
||||
|
||||
const skillLevelMap: Record<string, string> = {
|
||||
beginner: "初级",
|
||||
intermediate: "中级",
|
||||
advanced: "高级",
|
||||
};
|
||||
|
||||
export default function Leaderboard() {
|
||||
const { user } = useAuth();
|
||||
const [scope, setScope] = useState<LeaderboardScope>("training");
|
||||
const [trainingSortBy, setTrainingSortBy] = useState<TrainingSortKey>("ntrpRating");
|
||||
const [competitiveSortBy, setCompetitiveSortBy] = useState<CompetitiveSortKey>("wins");
|
||||
|
||||
const sortBy = scope === "training" ? trainingSortBy : competitiveSortBy;
|
||||
const tabConfig = scope === "training" ? trainingTabs : competitiveTabs;
|
||||
const { data: leaderboard, isLoading } = trpc.leaderboard.get.useQuery({ scope, sortBy, limit: 50 });
|
||||
|
||||
const myRank = useMemo(() => {
|
||||
if (!leaderboard || !user) return null;
|
||||
const idx = leaderboard.findIndex((item: any) => item.id === user.id);
|
||||
return idx >= 0 ? idx + 1 : null;
|
||||
}, [leaderboard, user]);
|
||||
|
||||
const myEntry = useMemo(() => {
|
||||
if (!leaderboard || !user) return null;
|
||||
return leaderboard.find((item: any) => item.id === user.id) || null;
|
||||
}, [leaderboard, user]);
|
||||
|
||||
const getValue = (item: any, key: SortKey) => {
|
||||
const value = item?.[key] ?? 0;
|
||||
if (key === "ntrpRating") return Number(value).toFixed(1);
|
||||
if (key === "winRate") return `${Number(value).toFixed(1)}`;
|
||||
return value;
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-24 w-full" />
|
||||
{[1, 2, 3, 4, 5].map((item) => <Skeleton key={item} className="h-16 w-full" />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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.14),_transparent_28%),linear-gradient(180deg,rgba(255,255,255,1),rgba(248,250,252,0.96))] p-6 shadow-sm">
|
||||
<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 text-sm leading-6 text-muted-foreground">
|
||||
训练榜按训练积累排序,竞赛榜只统计已正式结算的竞赛比赛,不把未审核的自动计分直接纳入名次。
|
||||
{myRank ? <span className="ml-2 font-medium text-primary">当前排名第 {myRank} 名</span> : null}
|
||||
</p>
|
||||
</div>
|
||||
<Tabs value={scope} onValueChange={(value) => setScope(value as LeaderboardScope)}>
|
||||
<TabsList className="grid w-full grid-cols-2 lg:w-[280px]">
|
||||
<TabsTrigger value="training">训练榜</TabsTrigger>
|
||||
<TabsTrigger value="competitive">竞赛榜</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{myRank && user && myEntry ? (
|
||||
<Card className="border-primary/20 bg-primary/5 shadow-sm">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 text-lg font-bold text-primary">
|
||||
#{myRank}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold">{user.name}</p>
|
||||
<p className="text-xs text-muted-foreground">我的当前排名</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-xl font-bold text-primary">{getValue(myEntry, sortBy)}</p>
|
||||
<p className="text-xs text-muted-foreground">{tabConfig.find((item) => item.key === sortBy)?.unit}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Tabs value={sortBy} onValueChange={(value) => {
|
||||
if (scope === "training") {
|
||||
setTrainingSortBy(value as TrainingSortKey);
|
||||
} else {
|
||||
setCompetitiveSortBy(value as CompetitiveSortKey);
|
||||
}
|
||||
}}>
|
||||
<TabsList className={`grid w-full ${scope === "training" ? "grid-cols-2 lg:grid-cols-4" : "grid-cols-2 lg:grid-cols-5"}`}>
|
||||
{tabConfig.map((tab) => (
|
||||
<TabsTrigger key={tab.key} value={tab.key} className="gap-1.5 text-xs sm:text-sm">
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{tabConfig.map((tab) => (
|
||||
<TabsContent key={tab.key} value={tab.key}>
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">{tab.label}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{(!leaderboard || leaderboard.length === 0) ? (
|
||||
<div className="py-16 text-center text-muted-foreground">
|
||||
<Trophy className="mx-auto mb-3 h-10 w-10 opacity-30" />
|
||||
<p>暂无排行数据</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{leaderboard.map((item: any, idx: number) => {
|
||||
const isMe = user && item.id === user.id;
|
||||
return (
|
||||
<div key={item.id} className={`flex items-center gap-3 px-4 py-3 transition-colors ${isMe ? "bg-primary/5" : "hover:bg-muted/50"}`}>
|
||||
<div className="w-8 shrink-0 text-center">
|
||||
{idx < 3 ? rankIcons[idx] : <span className="text-sm font-medium text-muted-foreground">{idx + 1}</span>}
|
||||
</div>
|
||||
|
||||
<Avatar className="h-9 w-9 shrink-0">
|
||||
<AvatarFallback className={`text-xs font-medium ${idx < 3 ? "bg-primary/10 text-primary" : ""}`}>
|
||||
{(item.name || "U").slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className={`truncate text-sm font-medium ${isMe ? "text-primary" : ""}`}>
|
||||
{item.name || "匿名用户"}
|
||||
</p>
|
||||
{isMe ? <Badge variant="secondary" className="px-1.5 py-0 text-[10px]">我</Badge> : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>NTRP {(item.ntrpRating || 1.5).toFixed(1)}</span>
|
||||
<span>·</span>
|
||||
<span>{skillLevelMap[item.skillLevel || "beginner"] || "初级"}</span>
|
||||
{scope === "training" ? (
|
||||
(item.currentStreak || 0) > 0 ? (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span className="text-orange-500">🔥{item.currentStreak}天</span>
|
||||
</>
|
||||
) : null
|
||||
) : (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span>{item.wins || 0} 胜 / {item.losses || 0} 负</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-right">
|
||||
<p className={`text-lg font-bold ${idx < 3 ? "text-primary" : ""}`}>
|
||||
{getValue(item, tab.key)}
|
||||
</p>
|
||||
{tab.unit ? <p className="text-[10px] text-muted-foreground">{tab.unit}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
3890
client/src/pages/LiveCamera.tsx
普通文件
@@ -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 (
|
||||
@@ -36,19 +58,20 @@ export default function Login() {
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-primary/10 mb-4">
|
||||
<Target className="w-8 h-8 text-primary" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Tennis Training Hub</h1>
|
||||
<p className="text-muted-foreground mt-2">AI驱动的在家网球训练助手</p>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Tennis Hub</h1>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
788
client/src/pages/Market.tsx
普通文件
@@ -0,0 +1,788 @@
|
||||
import { useState } from "react";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { formatDateShanghai, formatDateTimeShanghai } from "@/lib/time";
|
||||
import { toast } from "sonner";
|
||||
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, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
BellRing,
|
||||
CircleDollarSign,
|
||||
ExternalLink,
|
||||
Filter,
|
||||
Gauge,
|
||||
Plus,
|
||||
Radar,
|
||||
RefreshCcw,
|
||||
Search,
|
||||
ShieldCheck,
|
||||
Siren,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
|
||||
const SOURCE_LABELS = {
|
||||
xianyu: "闲鱼",
|
||||
jd: "京东",
|
||||
zhuanzhuan: "转转",
|
||||
} as const;
|
||||
|
||||
const CATEGORY_LABELS = {
|
||||
adult: "成人球拍",
|
||||
junior: "儿童球拍",
|
||||
competitive: "比赛拍",
|
||||
recreational: "娱乐拍",
|
||||
unknown: "未知",
|
||||
} as const;
|
||||
|
||||
const GRADE_LABELS = {
|
||||
high_value: "高性价比",
|
||||
standard: "标准价",
|
||||
overpriced: "偏高",
|
||||
pending_review: "待确认",
|
||||
} as const;
|
||||
|
||||
const GRADE_TONES = {
|
||||
high_value: "bg-emerald-50 text-emerald-700 border-emerald-200",
|
||||
standard: "bg-sky-50 text-sky-700 border-sky-200",
|
||||
overpriced: "bg-orange-50 text-orange-700 border-orange-200",
|
||||
pending_review: "bg-muted text-muted-foreground border-border",
|
||||
} as const;
|
||||
|
||||
const TASK_STATUS_LABELS = {
|
||||
queued: "排队中",
|
||||
running: "执行中",
|
||||
succeeded: "已完成",
|
||||
failed: "失败",
|
||||
} as const;
|
||||
|
||||
type RuleDraft = {
|
||||
ruleId?: number;
|
||||
title: string;
|
||||
brand: string;
|
||||
modelKeyword: string;
|
||||
seriesKeyword: string;
|
||||
category: "all" | keyof typeof CATEGORY_LABELS;
|
||||
weightMinGram: string;
|
||||
weightMaxGram: string;
|
||||
targetPrice: string;
|
||||
pushEnabled: boolean;
|
||||
};
|
||||
|
||||
const EMPTY_RULE: RuleDraft = {
|
||||
title: "",
|
||||
brand: "",
|
||||
modelKeyword: "",
|
||||
seriesKeyword: "",
|
||||
category: "all",
|
||||
weightMinGram: "",
|
||||
weightMaxGram: "",
|
||||
targetPrice: "",
|
||||
pushEnabled: true,
|
||||
};
|
||||
|
||||
function formatCurrency(value: number | null | undefined) {
|
||||
if (value == null) return "-";
|
||||
return `¥${Number(value).toFixed(0)}`;
|
||||
}
|
||||
|
||||
function normalizeDraftToPayload(draft: RuleDraft) {
|
||||
const targetPrice = Number.parseFloat(draft.targetPrice);
|
||||
if (!Number.isFinite(targetPrice) || targetPrice <= 0) {
|
||||
throw new Error("请输入有效的目标价格");
|
||||
}
|
||||
|
||||
const payload = {
|
||||
title: draft.title.trim() || undefined,
|
||||
brand: draft.brand.trim(),
|
||||
modelKeyword: draft.modelKeyword.trim() || undefined,
|
||||
seriesKeyword: draft.seriesKeyword.trim() || undefined,
|
||||
category: draft.category === "all" ? undefined : draft.category,
|
||||
weightMinGram: draft.weightMinGram ? Number.parseFloat(draft.weightMinGram) : undefined,
|
||||
weightMaxGram: draft.weightMaxGram ? Number.parseFloat(draft.weightMaxGram) : undefined,
|
||||
targetPrice,
|
||||
pushEnabled: draft.pushEnabled,
|
||||
};
|
||||
|
||||
if (!payload.brand) {
|
||||
throw new Error("请输入要监控的品牌");
|
||||
}
|
||||
|
||||
if (
|
||||
payload.weightMinGram != null &&
|
||||
payload.weightMaxGram != null &&
|
||||
payload.weightMinGram > payload.weightMaxGram
|
||||
) {
|
||||
throw new Error("最小重量不能大于最大重量");
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
export default function Market() {
|
||||
const utils = trpc.useUtils();
|
||||
const [filters, setFilters] = useState({
|
||||
source: "all",
|
||||
category: "all",
|
||||
keyword: "",
|
||||
lowPriceOnly: true,
|
||||
});
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [ruleDraft, setRuleDraft] = useState<RuleDraft>(EMPTY_RULE);
|
||||
const [webhookDialogOpen, setWebhookDialogOpen] = useState(false);
|
||||
const [webhookDraft, setWebhookDraft] = useState("");
|
||||
|
||||
const dashboardQuery = trpc.market.dashboard.useQuery(undefined, {
|
||||
refetchInterval: (query) => {
|
||||
const hasActive = (query.state.data?.recentTasks ?? []).some((task) => task.status === "queued" || task.status === "running");
|
||||
return hasActive ? 4_000 : 12_000;
|
||||
},
|
||||
});
|
||||
const listingsQuery = trpc.market.listings.useQuery({
|
||||
source: filters.source === "all" ? undefined : filters.source as "xianyu" | "jd" | "zhuanzhuan",
|
||||
category: filters.category === "all" ? undefined : filters.category as keyof typeof CATEGORY_LABELS,
|
||||
keyword: filters.keyword.trim() || undefined,
|
||||
lowPriceOnly: filters.lowPriceOnly,
|
||||
limit: 40,
|
||||
});
|
||||
const rulesQuery = trpc.market.watchRuleList.useQuery();
|
||||
const hitsQuery = trpc.market.watchHits.useQuery({ limit: 20 });
|
||||
const pushConfigQuery = trpc.market.pushConfigGet.useQuery();
|
||||
|
||||
const createRuleMutation = trpc.market.watchRuleCreate.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("监控规则已创建,后台开始抓取");
|
||||
setDialogOpen(false);
|
||||
setRuleDraft(EMPTY_RULE);
|
||||
void utils.market.watchRuleList.invalidate();
|
||||
void utils.market.dashboard.invalidate();
|
||||
},
|
||||
onError: (error) => toast.error(`创建失败: ${error.message}`),
|
||||
});
|
||||
|
||||
const updateRuleMutation = trpc.market.watchRuleUpdate.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("监控规则已更新");
|
||||
setDialogOpen(false);
|
||||
setRuleDraft(EMPTY_RULE);
|
||||
void utils.market.watchRuleList.invalidate();
|
||||
void utils.market.dashboard.invalidate();
|
||||
},
|
||||
onError: (error) => toast.error(`更新失败: ${error.message}`),
|
||||
});
|
||||
|
||||
const deleteRuleMutation = trpc.market.watchRuleDelete.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("监控规则已删除");
|
||||
void utils.market.watchRuleList.invalidate();
|
||||
void utils.market.dashboard.invalidate();
|
||||
},
|
||||
onError: (error) => toast.error(`删除失败: ${error.message}`),
|
||||
});
|
||||
|
||||
const toggleRuleMutation = trpc.market.watchRuleToggle.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.market.watchRuleList.invalidate();
|
||||
void utils.market.dashboard.invalidate();
|
||||
},
|
||||
onError: (error) => toast.error(`切换失败: ${error.message}`),
|
||||
});
|
||||
|
||||
const refreshMutation = trpc.market.triggerRefresh.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("刷新任务已加入后台队列");
|
||||
void utils.market.dashboard.invalidate();
|
||||
},
|
||||
onError: (error) => toast.error(`刷新失败: ${error.message}`),
|
||||
});
|
||||
|
||||
const updateWebhookMutation = trpc.market.pushConfigUpdate.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("默认飞书 webhook 已更新");
|
||||
setWebhookDialogOpen(false);
|
||||
void utils.market.pushConfigGet.invalidate();
|
||||
void utils.market.dashboard.invalidate();
|
||||
},
|
||||
onError: (error) => toast.error(`更新失败: ${error.message}`),
|
||||
});
|
||||
|
||||
const handleSubmitRule = () => {
|
||||
try {
|
||||
const payload = normalizeDraftToPayload(ruleDraft);
|
||||
if (ruleDraft.ruleId) {
|
||||
updateRuleMutation.mutate({ ruleId: ruleDraft.ruleId, ...payload });
|
||||
} else {
|
||||
createRuleMutation.mutate(payload);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "请检查规则输入");
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditRule = (rule: any) => {
|
||||
setRuleDraft({
|
||||
ruleId: rule.id,
|
||||
title: rule.title ?? "",
|
||||
brand: rule.brand ?? "",
|
||||
modelKeyword: rule.modelKeyword ?? "",
|
||||
seriesKeyword: rule.seriesKeyword ?? "",
|
||||
category: (rule.category as RuleDraft["category"]) ?? "all",
|
||||
weightMinGram: rule.weightMinGram != null ? String(rule.weightMinGram) : "",
|
||||
weightMaxGram: rule.weightMaxGram != null ? String(rule.weightMaxGram) : "",
|
||||
targetPrice: rule.targetPrice != null ? String(rule.targetPrice) : "",
|
||||
pushEnabled: rule.pushEnabled === 1,
|
||||
});
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const recentTasks = dashboardQuery.data?.recentTasks ?? [];
|
||||
const sourceSummary = dashboardQuery.data?.sourceSummary ?? [];
|
||||
const listings = listingsQuery.data ?? [];
|
||||
const rules = rulesQuery.data ?? [];
|
||||
const hits = hitsQuery.data ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="relative overflow-hidden rounded-[30px] border border-border/60 bg-[radial-gradient(circle_at_15%_20%,rgba(34,197,94,0.16),transparent_26%),radial-gradient(circle_at_85%_18%,rgba(251,146,60,0.16),transparent_28%),linear-gradient(180deg,rgba(255,255,255,0.98),rgba(248,250,252,0.94))] p-5 shadow-sm md:p-6">
|
||||
<div className="pointer-events-none absolute inset-0 bg-[linear-gradient(rgba(15,23,42,0.03)_1px,transparent_1px),linear-gradient(90deg,rgba(15,23,42,0.03)_1px,transparent_1px)] bg-[size:18px_18px]" />
|
||||
<div className="relative flex flex-col gap-5 xl:flex-row xl:items-end xl:justify-between">
|
||||
<div className="max-w-3xl">
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-xs font-medium text-emerald-700">
|
||||
<Radar className="h-3.5 w-3.5" />
|
||||
球拍行情雷达
|
||||
</div>
|
||||
<h1 className="mt-4 text-3xl font-semibold tracking-tight text-foreground">全网抓取、规则分级、低价监控与飞书推送</h1>
|
||||
<p className="mt-3 max-w-2xl text-sm leading-6 text-muted-foreground">
|
||||
这块面板会异步抓取闲鱼、京东和转转来源,对球拍做品牌、型号、重量、品类和价格分级。命中你的目标价后,系统会先写入站内记录,再走默认飞书 webhook 推送。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
onClick={() => refreshMutation.mutate(undefined)}
|
||||
disabled={refreshMutation.isPending}
|
||||
className="gap-2"
|
||||
>
|
||||
<RefreshCcw className={`h-4 w-4 ${refreshMutation.isPending ? "animate-spin" : ""}`} />
|
||||
立即刷新
|
||||
</Button>
|
||||
<Dialog open={webhookDialogOpen} onOpenChange={(open) => {
|
||||
setWebhookDialogOpen(open);
|
||||
if (open) setWebhookDraft("");
|
||||
}}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" className="gap-2" disabled={!pushConfigQuery.data?.canEdit}>
|
||||
<BellRing className="h-4 w-4" />
|
||||
默认推送
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>更新默认飞书 Webhook</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-2xl border border-border/60 bg-muted/30 p-3 text-sm text-muted-foreground">
|
||||
当前配置: {pushConfigQuery.data?.maskedWebhookUrl || "未配置"}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="market-webhook">Webhook URL</Label>
|
||||
<Input
|
||||
id="market-webhook"
|
||||
placeholder="https://open.larksuite.com/open-apis/bot/v2/hook/..."
|
||||
value={webhookDraft}
|
||||
onChange={(event) => setWebhookDraft(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setWebhookDialogOpen(false)}>取消</Button>
|
||||
<Button
|
||||
onClick={() => updateWebhookMutation.mutate({ webhookUrl: webhookDraft.trim() })}
|
||||
disabled={updateWebhookMutation.isPending || !webhookDraft.trim()}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative mt-6 grid gap-3 md:grid-cols-4">
|
||||
<Card className="border-0 bg-white/80 shadow-sm backdrop-blur">
|
||||
<CardContent className="pt-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<Gauge className="h-5 w-5 text-emerald-700" />
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.22em] text-muted-foreground">Active Rules</div>
|
||||
<div className="mt-1 text-2xl font-semibold">{dashboardQuery.data?.overview.activeRuleCount ?? 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-0 bg-white/80 shadow-sm backdrop-blur">
|
||||
<CardContent className="pt-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<CircleDollarSign className="h-5 w-5 text-sky-700" />
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.22em] text-muted-foreground">24H Listings</div>
|
||||
<div className="mt-1 text-2xl font-semibold">{dashboardQuery.data?.overview.recentListingCount ?? 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-0 bg-white/80 shadow-sm backdrop-blur">
|
||||
<CardContent className="pt-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<Siren className="h-5 w-5 text-orange-700" />
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.22em] text-muted-foreground">Recent Hits</div>
|
||||
<div className="mt-1 text-2xl font-semibold">{dashboardQuery.data?.overview.hitCount ?? 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-0 bg-white/80 shadow-sm backdrop-blur">
|
||||
<CardContent className="pt-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<ShieldCheck className="h-5 w-5 text-violet-700" />
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.22em] text-muted-foreground">Push Channel</div>
|
||||
<div className="mt-1 text-sm font-semibold">
|
||||
{pushConfigQuery.data?.hasWebhookConfigured ? "飞书默认通道已启用" : "尚未配置"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[1.3fr_0.9fr]">
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg">低价雷达</CardTitle>
|
||||
<CardDescription className="mt-1">
|
||||
这里展示已经被分级为低价候选,或命中过规则阈值的最新球拍。
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-4">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-9"
|
||||
placeholder="搜品牌/型号"
|
||||
value={filters.keyword}
|
||||
onChange={(event) => setFilters((current) => ({ ...current, keyword: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
value={filters.source}
|
||||
onValueChange={(value) => setFilters((current) => ({ ...current, source: value }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="来源" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部来源</SelectItem>
|
||||
<SelectItem value="xianyu">闲鱼</SelectItem>
|
||||
<SelectItem value="jd">京东</SelectItem>
|
||||
<SelectItem value="zhuanzhuan">转转</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={filters.category}
|
||||
onValueChange={(value) => setFilters((current) => ({ ...current, category: value }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="品类" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部品类</SelectItem>
|
||||
{Object.entries(CATEGORY_LABELS).map(([key, label]) => (
|
||||
<SelectItem key={key} value={key}>{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-center justify-between rounded-xl border border-border/60 bg-muted/20 px-3">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Filter className="h-4 w-4" />
|
||||
仅低价
|
||||
</div>
|
||||
<Switch
|
||||
checked={filters.lowPriceOnly}
|
||||
onCheckedChange={(checked) => setFilters((current) => ({ ...current, lowPriceOnly: checked }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{listingsQuery.isLoading ? (
|
||||
<div className="rounded-2xl border border-dashed border-border/70 p-8 text-center text-sm text-muted-foreground">
|
||||
正在拉取行情数据...
|
||||
</div>
|
||||
) : listings.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-border/70 p-8 text-center text-sm text-muted-foreground">
|
||||
当前筛选下还没有球拍数据。先添加监控规则,或点击“立即刷新”触发后台抓取。
|
||||
</div>
|
||||
) : (
|
||||
listings.map((listing) => (
|
||||
<article key={`${listing.source}-${listing.sourceListingId}-${listing.id}`} className="rounded-[24px] border border-border/60 bg-[linear-gradient(180deg,rgba(255,255,255,1),rgba(248,250,252,0.92))] p-4 shadow-sm">
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div className="min-w-0 space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline">{SOURCE_LABELS[listing.source as keyof typeof SOURCE_LABELS]}</Badge>
|
||||
<Badge className={GRADE_TONES[(listing.gradeLevel as keyof typeof GRADE_TONES) ?? "pending_review"]}>
|
||||
{GRADE_LABELS[(listing.gradeLevel as keyof typeof GRADE_LABELS) ?? "pending_review"]}
|
||||
</Badge>
|
||||
{listing.isLowPriceCandidate === 1 ? (
|
||||
<Badge variant="secondary" className="bg-emerald-50 text-emerald-700">低价候选</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-semibold leading-6 text-foreground">{listing.title}</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{listing.brand || "未识别品牌"}
|
||||
{listing.model ? ` · ${listing.model}` : ""}
|
||||
{listing.category ? ` · ${CATEGORY_LABELS[listing.category as keyof typeof CATEGORY_LABELS] ?? listing.category}` : ""}
|
||||
{listing.weightGram != null ? ` · ${Math.round(listing.weightGram)}g` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">{listing.gradeReason || "暂无分级说明"}</div>
|
||||
</div>
|
||||
<div className="flex min-w-[220px] flex-col items-start gap-3 rounded-2xl border border-border/60 bg-muted/10 p-4">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.22em] text-muted-foreground">Price Watch</div>
|
||||
<div className="mt-1 text-3xl font-semibold text-foreground">{formatCurrency(listing.price)}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
原价 {formatCurrency(listing.originalPrice)} · 抓取于 {formatDateTimeShanghai(listing.fetchedAt)}
|
||||
</div>
|
||||
</div>
|
||||
<Button asChild className="gap-2">
|
||||
<a href={listing.listingUrl} target="_blank" rel="noreferrer">
|
||||
打开原帖
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className="text-lg">监控规则</CardTitle>
|
||||
<CardDescription className="mt-1">按品牌、型号、重量和目标价管理你的关注清单。</CardDescription>
|
||||
</div>
|
||||
<Dialog open={dialogOpen} onOpenChange={(open) => {
|
||||
setDialogOpen(open);
|
||||
if (!open) setRuleDraft(EMPTY_RULE);
|
||||
}}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm" className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
新建规则
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[560px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{ruleDraft.ruleId ? "编辑监控规则" : "新建监控规则"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-1 sm:grid-cols-2">
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label htmlFor="rule-title">规则标题</Label>
|
||||
<Input
|
||||
id="rule-title"
|
||||
placeholder="留空会自动生成,例如 Yonex Ezone ≤ ¥500"
|
||||
value={ruleDraft.title}
|
||||
onChange={(event) => setRuleDraft((current) => ({ ...current, title: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rule-brand">品牌</Label>
|
||||
<Input
|
||||
id="rule-brand"
|
||||
placeholder="如 Yonex / Wilson"
|
||||
value={ruleDraft.brand}
|
||||
onChange={(event) => setRuleDraft((current) => ({ ...current, brand: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rule-target-price">目标价格</Label>
|
||||
<Input
|
||||
id="rule-target-price"
|
||||
type="number"
|
||||
placeholder="500"
|
||||
value={ruleDraft.targetPrice}
|
||||
onChange={(event) => setRuleDraft((current) => ({ ...current, targetPrice: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rule-series">系列关键词</Label>
|
||||
<Input
|
||||
id="rule-series"
|
||||
placeholder="如 Ezone / Blade"
|
||||
value={ruleDraft.seriesKeyword}
|
||||
onChange={(event) => setRuleDraft((current) => ({ ...current, seriesKeyword: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rule-model">型号关键词</Label>
|
||||
<Input
|
||||
id="rule-model"
|
||||
placeholder="如 98 / 100L"
|
||||
value={ruleDraft.modelKeyword}
|
||||
onChange={(event) => setRuleDraft((current) => ({ ...current, modelKeyword: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>品类</Label>
|
||||
<Select
|
||||
value={ruleDraft.category}
|
||||
onValueChange={(value) => setRuleDraft((current) => ({ ...current, category: value as RuleDraft["category"] }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="不限" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">不限</SelectItem>
|
||||
{Object.entries(CATEGORY_LABELS).map(([key, label]) => (
|
||||
<SelectItem key={key} value={key}>{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rule-weight-min">最小重量</Label>
|
||||
<Input
|
||||
id="rule-weight-min"
|
||||
type="number"
|
||||
placeholder="285"
|
||||
value={ruleDraft.weightMinGram}
|
||||
onChange={(event) => setRuleDraft((current) => ({ ...current, weightMinGram: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rule-weight-max">最大重量</Label>
|
||||
<Input
|
||||
id="rule-weight-max"
|
||||
type="number"
|
||||
placeholder="305"
|
||||
value={ruleDraft.weightMaxGram}
|
||||
onChange={(event) => setRuleDraft((current) => ({ ...current, weightMaxGram: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-2xl border border-border/60 bg-muted/20 px-4 py-3 sm:col-span-2">
|
||||
<div>
|
||||
<div className="font-medium">命中后推送飞书</div>
|
||||
<div className="text-sm text-muted-foreground">关闭后仍保留站内命中记录,但不发默认 webhook。</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={ruleDraft.pushEnabled}
|
||||
onCheckedChange={(checked) => setRuleDraft((current) => ({ ...current, pushEnabled: checked }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>取消</Button>
|
||||
<Button
|
||||
onClick={handleSubmitRule}
|
||||
disabled={createRuleMutation.isPending || updateRuleMutation.isPending}
|
||||
>
|
||||
保存规则
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{rules.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-border/70 p-6 text-sm text-muted-foreground">
|
||||
还没有监控规则。先添加你关心的品牌、型号和目标价,系统之后会自动后台更新。
|
||||
</div>
|
||||
) : (
|
||||
rules.map((rule) => (
|
||||
<div key={rule.id} className="rounded-[22px] border border-border/60 bg-muted/20 p-4">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="font-medium text-foreground">{rule.title}</div>
|
||||
<Badge variant="outline">{rule.brand}</Badge>
|
||||
<Badge variant={rule.isActive === 1 ? "secondary" : "outline"}>
|
||||
{rule.isActive === 1 ? "监控中" : "已暂停"}
|
||||
</Badge>
|
||||
{rule.pushEnabled === 1 ? (
|
||||
<Badge variant="secondary" className="bg-sky-50 text-sky-700">飞书推送</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
目标价 {formatCurrency(rule.targetPrice)}
|
||||
{rule.seriesKeyword ? ` · 系列 ${rule.seriesKeyword}` : ""}
|
||||
{rule.modelKeyword ? ` · 型号 ${rule.modelKeyword}` : ""}
|
||||
{rule.category ? ` · ${CATEGORY_LABELS[rule.category as keyof typeof CATEGORY_LABELS] ?? rule.category}` : ""}
|
||||
{rule.weightMinGram != null ? ` · ≥${Math.round(rule.weightMinGram)}g` : ""}
|
||||
{rule.weightMaxGram != null ? ` · ≤${Math.round(rule.weightMaxGram)}g` : ""}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-border/60 pt-3 text-xs text-muted-foreground">
|
||||
<div>
|
||||
上次检查 {rule.lastCheckedAt ? formatDateTimeShanghai(rule.lastCheckedAt) : "尚未检查"}
|
||||
{rule.lastMatchedAt ? ` · 最近命中 ${formatDateShanghai(rule.lastMatchedAt)}` : ""}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => refreshMutation.mutate({ ruleId: rule.id })}>
|
||||
刷新
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => handleEditRule(rule)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => toggleRuleMutation.mutate({ ruleId: rule.id, isActive: rule.isActive !== 1 })}
|
||||
>
|
||||
{rule.isActive === 1 ? "暂停" : "启用"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-destructive"
|
||||
onClick={() => deleteRuleMutation.mutate({ ruleId: rule.id })}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg">来源状态</CardTitle>
|
||||
<CardDescription className="mt-1">按最近抓取结果观察每个来源的健康度和低价产出。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{sourceSummary.map((item) => (
|
||||
<div key={item.source} 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">{SOURCE_LABELS[item.source as keyof typeof SOURCE_LABELS]}</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">
|
||||
最近抓取 {item.latestFetchedAt ? formatDateTimeShanghai(item.latestFetchedAt) : "暂无数据"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-xl font-semibold">{item.total}</div>
|
||||
<div className="text-xs text-muted-foreground">{item.lowPriceCount} 条低价候选</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[1.15fr_0.85fr]">
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg">命中记录</CardTitle>
|
||||
<CardDescription className="mt-1">命中过规则的商品会在这里保留历史,用来做去重和价格二次下降提醒。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{hits.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-border/70 p-6 text-sm text-muted-foreground">
|
||||
还没有命中记录。等后台抓取到低于目标价的球拍后,这里会显示推送状态和命中详情。
|
||||
</div>
|
||||
) : (
|
||||
hits.map((hit) => (
|
||||
<div key={hit.id} className="rounded-[22px] border border-border/60 bg-[linear-gradient(180deg,rgba(255,255,255,1),rgba(248,250,252,0.9))] p-4">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline">{SOURCE_LABELS[hit.listingSource as keyof typeof SOURCE_LABELS]}</Badge>
|
||||
<Badge className={GRADE_TONES[(hit.listingGradeLevel as keyof typeof GRADE_TONES) ?? "pending_review"]}>
|
||||
{GRADE_LABELS[(hit.listingGradeLevel as keyof typeof GRADE_LABELS) ?? "pending_review"]}
|
||||
</Badge>
|
||||
<Badge variant={hit.status === "pushed" ? "secondary" : hit.status === "push_queued" ? "outline" : "secondary"}>
|
||||
{hit.status === "matched" ? "已命中" : hit.status === "push_queued" ? "待推送" : hit.status === "pushed" ? "已推送" : "已抑制"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="font-medium text-foreground">{hit.ruleTitle}</div>
|
||||
<div className="text-sm text-muted-foreground">{hit.listingTitle}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
命中价 {formatCurrency(hit.matchedPrice)} · 当前价 {formatCurrency(hit.listingPrice)} · 推送 {hit.pushCount} 次
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
首次命中 {formatDateTimeShanghai(hit.firstMatchedAt)} · 最近更新 {formatDateTimeShanghai(hit.lastMatchedAt)}
|
||||
</div>
|
||||
</div>
|
||||
<Button asChild size="sm" className="gap-2">
|
||||
<a href={hit.listingUrl} target="_blank" rel="noreferrer">
|
||||
打开商品
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Sparkles className="h-4 w-4 text-primary" />
|
||||
最近后台动态
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1">抓取、规则刷新和飞书推送都会在这里留下最近状态。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{recentTasks.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-border/70 p-6 text-sm text-muted-foreground">
|
||||
暂无市场相关后台任务。
|
||||
</div>
|
||||
) : (
|
||||
recentTasks.map((task) => (
|
||||
<div key={task.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="font-medium">{task.title}</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">{task.message || "无额外说明"}</div>
|
||||
</div>
|
||||
<Badge variant={task.status === "failed" ? "destructive" : task.status === "succeeded" ? "secondary" : "outline"}>
|
||||
{TASK_STATUS_LABELS[task.status as keyof typeof TASK_STATUS_LABELS] ?? task.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{formatDateTimeShanghai(task.createdAt)}</span>
|
||||
<span>{task.progress}%</span>
|
||||
</div>
|
||||
{task.error ? (
|
||||
<div className="mt-3 rounded-xl bg-red-50 px-3 py-2 text-xs text-red-700">{task.error}</div>
|
||||
) : null}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
327
client/src/pages/Matches.tsx
普通文件
@@ -0,0 +1,327 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/_core/hooks/useAuth";
|
||||
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 { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { formatDateShanghai, formatDateTimeShanghai } from "@/lib/time";
|
||||
import { formatMatchScore, getMatchModeLabel, getMatchParticipant, getMatchStatusLabel, getParticipantResult, getWinnerName } from "@/lib/matches";
|
||||
import { toast } from "sonner";
|
||||
import { Camera, ClipboardCheck, ShieldCheck, Swords, Trophy } from "lucide-react";
|
||||
|
||||
type StatusFilter = "all" | "draft" | "review_pending" | "reviewed" | "finalized";
|
||||
|
||||
const statusOptions: Array<{ key: StatusFilter; label: string }> = [
|
||||
{ key: "all", label: "全部" },
|
||||
{ key: "review_pending", label: "待审核" },
|
||||
{ key: "reviewed", label: "待结算" },
|
||||
{ key: "finalized", label: "已入库" },
|
||||
];
|
||||
|
||||
function StatCard(props: { icon: React.ReactNode; label: string; value: string | number; hint: string }) {
|
||||
return (
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardContent className="pt-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
||||
{props.icon}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">{props.label}</div>
|
||||
<div className="mt-1 text-xl font-semibold">{props.value}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{props.hint}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Matches() {
|
||||
const { user } = useAuth();
|
||||
const utils = trpc.useUtils();
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
|
||||
const [selectedMatchId, setSelectedMatchId] = useState<number | null>(null);
|
||||
|
||||
const statsQuery = trpc.match.stats.useQuery();
|
||||
const listQuery = trpc.match.list.useQuery({
|
||||
limit: 50,
|
||||
workflowStatus: statusFilter,
|
||||
});
|
||||
const detailQuery = trpc.match.get.useQuery(
|
||||
{ matchId: selectedMatchId ?? 0 },
|
||||
{ enabled: selectedMatchId != null },
|
||||
);
|
||||
|
||||
const requestSuggestionMutation = trpc.match.requestSuggestion.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("已提交自动计分建议刷新");
|
||||
utils.match.list.invalidate();
|
||||
utils.match.get.invalidate();
|
||||
},
|
||||
onError: (error) => toast.error(`提交失败: ${error.message}`),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedMatchId && listQuery.data?.length) {
|
||||
setSelectedMatchId(listQuery.data[0].id);
|
||||
}
|
||||
}, [listQuery.data, selectedMatchId]);
|
||||
|
||||
const selectedMatch = detailQuery.data;
|
||||
const myResult = useMemo(() => getParticipantResult(selectedMatch || {}, user?.id), [selectedMatch, user?.id]);
|
||||
|
||||
if (statsQuery.isLoading || listQuery.isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-28 w-full" />
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{[1, 2, 3].map((item) => <Skeleton key={item} className="h-28 w-full" />)}
|
||||
</div>
|
||||
<Skeleton className="h-96 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="rounded-[28px] border border-border/60 bg-[radial-gradient(circle_at_top_left,_rgba(245,158,11,0.12),_transparent_28%),linear-gradient(180deg,rgba(255,255,255,1),rgba(248,250,252,0.96))] p-6 shadow-sm">
|
||||
<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-3xl text-sm leading-6 text-muted-foreground">
|
||||
这里展示双人双摄比赛的入库记录、自动计分建议、审核状态和正式结算结果。管理员负责最终确认,普通用户只能看到与自己绑定的比赛。
|
||||
</p>
|
||||
</div>
|
||||
{selectedMatch ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="secondary">{getMatchModeLabel(selectedMatch.matchMode)}</Badge>
|
||||
<Badge variant="outline">{getMatchStatusLabel(selectedMatch.workflowStatus)}</Badge>
|
||||
{myResult ? <Badge variant="outline">{myResult}</Badge> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<StatCard
|
||||
icon={<Swords className="h-5 w-5" />}
|
||||
label="我的比赛"
|
||||
value={statsQuery.data?.total || 0}
|
||||
hint={`日常 ${statsQuery.data?.daily || 0} 场 · 竞赛 ${statsQuery.data?.competitive || 0} 场`}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<ClipboardCheck className="h-5 w-5" />}
|
||||
label="已入库"
|
||||
value={statsQuery.data?.finalized || 0}
|
||||
hint={`待审核/待结算 ${statsQuery.data?.reviewPending || 0} 场`}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Trophy className="h-5 w-5" />}
|
||||
label="竞赛胜场"
|
||||
value={statsQuery.data?.competitiveWins || 0}
|
||||
hint={`已绑定机位 ${statsQuery.data?.camerasBound || 0} 个`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Tabs value={statusFilter} onValueChange={(value) => setStatusFilter(value as StatusFilter)} className="space-y-4">
|
||||
<TabsList className="grid w-full grid-cols-4 lg:w-auto">
|
||||
{statusOptions.map((option) => (
|
||||
<TabsTrigger key={option.key} value={option.key}>{option.label}</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[360px,1fr]">
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">比赛列表</CardTitle>
|
||||
<CardDescription>按入库流程查看自己可访问的比赛。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{(!listQuery.data || listQuery.data.length === 0) ? (
|
||||
<div className="rounded-2xl border border-dashed border-border/70 p-6 text-center text-sm text-muted-foreground">
|
||||
当前筛选下暂无比赛记录。
|
||||
</div>
|
||||
) : (
|
||||
listQuery.data.map((match: any) => {
|
||||
const playerA = getMatchParticipant(match, "player_a");
|
||||
const playerB = getMatchParticipant(match, "player_b");
|
||||
return (
|
||||
<button
|
||||
key={match.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedMatchId(match.id)}
|
||||
className={`w-full rounded-2xl border p-4 text-left transition ${selectedMatchId === match.id ? "border-primary bg-primary/5" : "border-border/60 bg-muted/20 hover:bg-muted/40"}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">{match.title}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{playerA?.userName || "A"} vs {playerB?.userName || "B"}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={match.workflowStatus === "finalized" ? "secondary" : "outline"}>
|
||||
{getMatchStatusLabel(match.workflowStatus)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2 text-xs text-muted-foreground">
|
||||
<span>{getMatchModeLabel(match.matchMode)}</span>
|
||||
<span>·</span>
|
||||
<span>{formatDateShanghai(match.scheduledAt || match.createdAt)}</span>
|
||||
<span>·</span>
|
||||
<span>{formatMatchScore(match)}</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">比赛详情</CardTitle>
|
||||
<CardDescription>包含双人双摄绑定状态、自动计分建议、事件时间线和正式结算结果。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
{!selectedMatchId ? (
|
||||
<div className="rounded-2xl border border-dashed border-border/70 p-8 text-center text-sm text-muted-foreground">
|
||||
先从左侧选择一场比赛。
|
||||
</div>
|
||||
) : detailQuery.isLoading ? (
|
||||
<Skeleton className="h-80 w-full" />
|
||||
) : !selectedMatch ? (
|
||||
<div className="rounded-2xl border border-dashed border-border/70 p-8 text-center text-sm text-muted-foreground">
|
||||
未找到该比赛详情。
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col gap-4 rounded-2xl border border-border/60 bg-muted/20 p-4">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-lg font-semibold">{selectedMatch.title}</h2>
|
||||
<Badge variant="secondary">{getMatchModeLabel(selectedMatch.matchMode)}</Badge>
|
||||
<Badge variant="outline">{getMatchStatusLabel(selectedMatch.workflowStatus)}</Badge>
|
||||
{myResult ? <Badge variant="outline">{myResult}</Badge> : null}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-muted-foreground">
|
||||
场地 {selectedMatch.courtName || "未填写"} · 计划 {selectedMatch.durationMinutes || 90} 分钟 · {formatDateTimeShanghai(selectedMatch.scheduledAt || selectedMatch.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => requestSuggestionMutation.mutate({ matchId: selectedMatch.id })}
|
||||
disabled={requestSuggestionMutation.isPending || selectedMatch.workflowStatus === "finalized" || selectedMatch.workflowStatus === "cancelled"}
|
||||
>
|
||||
刷新自动计分建议
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
当前显示比分:{formatMatchScore(selectedMatch)} · 胜者 {getWinnerName(selectedMatch)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{(["player_a", "player_b"] as const).map((slot) => {
|
||||
const participant = getMatchParticipant(selectedMatch, slot);
|
||||
const stats = participant?.finalStats || participant?.suggestedStats || {};
|
||||
return (
|
||||
<Card key={slot} className="border border-border/60 shadow-none">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">{participant?.userName || slot}</CardTitle>
|
||||
<CardDescription>
|
||||
{slot === "player_a" ? "主位 A" : "主位 B"} · 机位 {participant?.cameraSlot || "-"}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
<div className="flex items-center justify-between rounded-2xl bg-muted/30 px-3 py-2">
|
||||
<span className="text-muted-foreground">机位状态</span>
|
||||
<span className="inline-flex items-center gap-1 font-medium">
|
||||
<Camera className="h-4 w-4 text-primary" />
|
||||
{participant?.cameraStatus || "pending"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="rounded-2xl bg-muted/30 p-3">
|
||||
<div className="text-xs text-muted-foreground">局分</div>
|
||||
<div className="mt-1 text-lg font-semibold">{participant?.finalGamesWon ?? participant?.suggestedGamesWon ?? 0}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-muted/30 p-3">
|
||||
<div className="text-xs text-muted-foreground">盘分</div>
|
||||
<div className="mt-1 text-lg font-semibold">{participant?.finalSetsWon ?? participant?.suggestedSetsWon ?? 0}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-muted/30 p-3">
|
||||
<div className="text-xs text-muted-foreground">得分点</div>
|
||||
<div className="mt-1 text-lg font-semibold">{participant?.finalPointsWon ?? participant?.suggestedPointsWon ?? 0}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-muted/30 p-3">
|
||||
<div className="text-xs text-muted-foreground">一发进球率</div>
|
||||
<div className="mt-1 text-lg font-semibold">{Number((stats as any).firstServePct || 0).toFixed(1)}%</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-xs text-muted-foreground">
|
||||
<div className="rounded-xl bg-muted/20 p-2">ACE {(stats as any).aces || 0}</div>
|
||||
<div className="rounded-xl bg-muted/20 p-2">制胜分 {(stats as any).winners || 0}</div>
|
||||
<div className="rounded-xl bg-muted/20 p-2">失误 {(stats as any).unforcedErrors || 0}</div>
|
||||
</div>
|
||||
{participant?.isWinner === 1 ? (
|
||||
<div className="inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-3 py-1 text-xs font-medium text-emerald-700">
|
||||
<ShieldCheck className="h-3.5 w-3.5" />
|
||||
已确认胜者
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Card className="border border-border/60 shadow-none">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">事件时间线</CardTitle>
|
||||
<CardDescription>
|
||||
自动计分与人工审核事件共 {selectedMatch.events?.length || 0} 条。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{(!selectedMatch.events || selectedMatch.events.length === 0) ? (
|
||||
<div className="rounded-2xl border border-dashed border-border/70 p-6 text-center text-sm text-muted-foreground">
|
||||
暂无自动计分事件。
|
||||
</div>
|
||||
) : (
|
||||
selectedMatch.events.map((event: any) => (
|
||||
<div key={event.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 className="flex items-center gap-2">
|
||||
<Badge variant="outline">#{event.eventIndex}</Badge>
|
||||
<span className="font-medium">{event.eventType}</span>
|
||||
<Badge variant="secondary">{event.source}</Badge>
|
||||
{event.winnerSlot ? <Badge variant="outline">胜方 {event.winnerSlot}</Badge> : null}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{event.matchSecond != null ? `第 ${event.matchSecond} 秒 · ` : ""}
|
||||
置信度 {Number(event.confidence || 0).toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
<pre className="mt-3 overflow-x-auto rounded-xl bg-background/80 p-3 text-xs text-muted-foreground">
|
||||
{JSON.stringify(event.payload ?? {}, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
1870
client/src/pages/Recorder.tsx
普通文件
478
client/src/pages/Reminders.tsx
普通文件
@@ -0,0 +1,478 @@
|
||||
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";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { toast } from "sonner";
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import {
|
||||
Bell, BellRing, Plus, Trash2, Clock, Calendar,
|
||||
CheckCircle2, XCircle, Settings, BellOff, Volume2
|
||||
} from "lucide-react";
|
||||
|
||||
const DAY_NAMES = ["日", "一", "二", "三", "四", "五", "六"];
|
||||
|
||||
const REMINDER_TYPES = [
|
||||
{ value: "training", label: "训练提醒", icon: <Clock className="w-4 h-4" /> },
|
||||
{ value: "checkin", label: "打卡提醒", icon: <CheckCircle2 className="w-4 h-4" /> },
|
||||
{ value: "analysis", label: "分析提醒", icon: <Settings className="w-4 h-4" /> },
|
||||
];
|
||||
|
||||
export default function Reminders() {
|
||||
const { user } = useAuth();
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [newReminder, setNewReminder] = useState({
|
||||
reminderType: "training",
|
||||
title: "",
|
||||
message: "",
|
||||
timeOfDay: "08:00",
|
||||
daysOfWeek: [1, 2, 3, 4, 5] as number[],
|
||||
});
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
const { data: reminders, isLoading } = trpc.reminder.list.useQuery(undefined, { enabled: !!user });
|
||||
const { data: notifications } = trpc.notification.list.useQuery(undefined, { enabled: !!user });
|
||||
const { data: unreadCount } = trpc.notification.unreadCount.useQuery(undefined, { enabled: !!user });
|
||||
|
||||
const createReminder = trpc.reminder.create.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("提醒已创建");
|
||||
setShowCreate(false);
|
||||
setNewReminder({ reminderType: "training", title: "", message: "", timeOfDay: "08:00", daysOfWeek: [1, 2, 3, 4, 5] });
|
||||
utils.reminder.list.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const deleteReminder = trpc.reminder.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("提醒已删除");
|
||||
utils.reminder.list.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const toggleReminder = trpc.reminder.toggle.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.reminder.list.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const markAllRead = trpc.notification.markAllRead.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.notification.list.invalidate();
|
||||
utils.notification.unreadCount.invalidate();
|
||||
toast.success("全部已读");
|
||||
},
|
||||
});
|
||||
|
||||
const markRead = trpc.notification.markRead.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.notification.list.invalidate();
|
||||
utils.notification.unreadCount.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const toggleDay = useCallback((day: number) => {
|
||||
setNewReminder(prev => ({
|
||||
...prev,
|
||||
daysOfWeek: prev.daysOfWeek.includes(day)
|
||||
? prev.daysOfWeek.filter(d => d !== day)
|
||||
: [...prev.daysOfWeek, day].sort(),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!newReminder.title.trim()) {
|
||||
toast.error("请输入提醒标题");
|
||||
return;
|
||||
}
|
||||
if (newReminder.daysOfWeek.length === 0) {
|
||||
toast.error("请至少选择一天");
|
||||
return;
|
||||
}
|
||||
createReminder.mutate(newReminder);
|
||||
};
|
||||
|
||||
// Browser notification permission
|
||||
const [notifPermission, setNotifPermission] = useState<NotificationPermission>("default");
|
||||
|
||||
useEffect(() => {
|
||||
if ("Notification" in window) {
|
||||
setNotifPermission(Notification.permission);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const requestPermission = async () => {
|
||||
if ("Notification" in window) {
|
||||
const perm = await Notification.requestPermission();
|
||||
setNotifPermission(perm);
|
||||
if (perm === "granted") {
|
||||
toast.success("通知权限已开启");
|
||||
new Notification("Tennis Training Hub", { body: "训练提醒已开启!" });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Check reminders and trigger browser notifications
|
||||
useEffect(() => {
|
||||
if (!reminders || notifPermission !== "granted") return;
|
||||
|
||||
const checkInterval = setInterval(() => {
|
||||
const now = new Date();
|
||||
const currentTime = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
|
||||
const currentDay = now.getDay();
|
||||
|
||||
reminders.forEach((r: any) => {
|
||||
if (r.isActive && r.timeOfDay === currentTime) {
|
||||
const days = typeof r.daysOfWeek === "string" ? JSON.parse(r.daysOfWeek) : r.daysOfWeek;
|
||||
if (Array.isArray(days) && days.includes(currentDay)) {
|
||||
new Notification(r.title, {
|
||||
body: r.message || "该训练了!",
|
||||
icon: "/favicon.ico",
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}, 60000); // Check every minute
|
||||
|
||||
return () => clearInterval(checkInterval);
|
||||
}, [reminders, notifPermission]);
|
||||
|
||||
const activeReminders = useMemo(() => reminders?.filter((r: any) => r.isActive) || [], [reminders]);
|
||||
const inactiveReminders = useMemo(() => reminders?.filter((r: any) => !r.isActive) || [], [reminders]);
|
||||
|
||||
if (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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Bell className="w-6 h-6 text-primary" />
|
||||
训练提醒
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">设置定时提醒,保持训练节奏</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{notifPermission !== "granted" && (
|
||||
<Button variant="outline" size="sm" onClick={requestPermission} className="gap-1">
|
||||
<Volume2 className="w-4 h-4" />
|
||||
开启通知
|
||||
</Button>
|
||||
)}
|
||||
<Dialog open={showCreate} onOpenChange={setShowCreate}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm" className="gap-1">
|
||||
<Plus className="w-4 h-4" />
|
||||
新建提醒
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>创建训练提醒</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>提醒类型</Label>
|
||||
<Select value={newReminder.reminderType} onValueChange={v => setNewReminder(p => ({ ...p, reminderType: v }))}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{REMINDER_TYPES.map(t => (
|
||||
<SelectItem key={t.value} value={t.value}>
|
||||
<span className="flex items-center gap-2">{t.icon} {t.label}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>标题</Label>
|
||||
<Input
|
||||
placeholder="例:每日正手练习"
|
||||
value={newReminder.title}
|
||||
onChange={e => setNewReminder(p => ({ ...p, title: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>提醒内容(可选)</Label>
|
||||
<Textarea
|
||||
placeholder="例:记得做10分钟影子挥拍..."
|
||||
value={newReminder.message}
|
||||
onChange={e => setNewReminder(p => ({ ...p, message: e.target.value }))}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>提醒时间</Label>
|
||||
<Input
|
||||
type="time"
|
||||
value={newReminder.timeOfDay}
|
||||
onChange={e => setNewReminder(p => ({ ...p, timeOfDay: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>重复日期</Label>
|
||||
<div className="flex gap-1.5 mt-1.5">
|
||||
{DAY_NAMES.map((name, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => toggleDay(i)}
|
||||
className={`w-9 h-9 rounded-full text-sm font-medium transition-colors ${
|
||||
newReminder.daysOfWeek.includes(i)
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground hover:bg-muted/80"
|
||||
}`}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => setNewReminder(p => ({ ...p, daysOfWeek: [1, 2, 3, 4, 5] }))}>
|
||||
工作日
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setNewReminder(p => ({ ...p, daysOfWeek: [0, 6] }))}>
|
||||
周末
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setNewReminder(p => ({ ...p, daysOfWeek: [0, 1, 2, 3, 4, 5, 6] }))}>
|
||||
每天
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowCreate(false)}>取消</Button>
|
||||
<Button onClick={handleCreate} disabled={createReminder.isPending}>
|
||||
{createReminder.isPending ? "创建中..." : "创建"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notification Permission Banner */}
|
||||
{notifPermission === "default" && (
|
||||
<Card className="border-amber-200 bg-amber-50">
|
||||
<CardContent className="pt-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<BellRing className="w-5 h-5 text-amber-600" />
|
||||
<div>
|
||||
<p className="font-medium text-amber-900">开启浏览器通知</p>
|
||||
<p className="text-sm text-amber-700">允许通知后,到达设定时间时会收到提醒</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" onClick={requestPermission}>允许通知</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{notifPermission === "denied" && (
|
||||
<Card className="border-red-200 bg-red-50">
|
||||
<CardContent className="pt-4 flex items-center gap-3">
|
||||
<BellOff className="w-5 h-5 text-red-600" />
|
||||
<div>
|
||||
<p className="font-medium text-red-900">通知已被禁用</p>
|
||||
<p className="text-sm text-red-700">请在浏览器设置中手动开启本站通知权限</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Active Reminders */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-3 flex items-center gap-2">
|
||||
<BellRing className="w-5 h-5 text-green-600" />
|
||||
活跃提醒 ({activeReminders.length})
|
||||
</h2>
|
||||
{activeReminders.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="pt-6 text-center text-muted-foreground">
|
||||
<Bell className="w-10 h-10 mx-auto mb-2 opacity-50" />
|
||||
<p>暂无活跃提醒,点击"新建提醒"开始</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{activeReminders.map((reminder: any) => {
|
||||
const days = typeof reminder.daysOfWeek === "string" ? JSON.parse(reminder.daysOfWeek) : reminder.daysOfWeek;
|
||||
const type = REMINDER_TYPES.find(t => t.value === reminder.reminderType);
|
||||
return (
|
||||
<Card key={reminder.id} className="hover:shadow-sm transition-shadow">
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{type?.icon}
|
||||
<span className="font-medium">{reminder.title}</span>
|
||||
</div>
|
||||
{reminder.message && (
|
||||
<p className="text-sm text-muted-foreground mb-2">{reminder.message}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span className="flex items-center gap-1 text-primary font-mono">
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
{reminder.timeOfDay}
|
||||
</span>
|
||||
<div className="flex gap-0.5">
|
||||
{DAY_NAMES.map((name, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`w-5 h-5 rounded-full text-xs flex items-center justify-center ${
|
||||
Array.isArray(days) && days.includes(i)
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground/40"
|
||||
}`}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-2">
|
||||
<Switch
|
||||
checked={!!reminder.isActive}
|
||||
onCheckedChange={checked => toggleReminder.mutate({ reminderId: reminder.id, isActive: checked ? 1 : 0 })}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
onClick={() => deleteReminder.mutate({ reminderId: reminder.id })}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Inactive Reminders */}
|
||||
{inactiveReminders.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-3 flex items-center gap-2">
|
||||
<BellOff className="w-5 h-5 text-muted-foreground" />
|
||||
已暂停 ({inactiveReminders.length})
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{inactiveReminders.map((reminder: any) => {
|
||||
const days = typeof reminder.daysOfWeek === "string" ? JSON.parse(reminder.daysOfWeek) : reminder.daysOfWeek;
|
||||
return (
|
||||
<Card key={reminder.id} className="opacity-60">
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<span className="font-medium">{reminder.title}</span>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground mt-1">
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
{reminder.timeOfDay}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={false}
|
||||
onCheckedChange={checked => toggleReminder.mutate({ reminderId: reminder.id, isActive: checked ? 1 : 0 })}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
onClick={() => deleteReminder.mutate({ reminderId: reminder.id })}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Notification History */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Calendar className="w-5 h-5" />
|
||||
通知记录
|
||||
{(unreadCount as number) > 0 && (
|
||||
<Badge variant="destructive" className="ml-1">{unreadCount as number}</Badge>
|
||||
)}
|
||||
</h2>
|
||||
{(unreadCount as number) > 0 && (
|
||||
<Button variant="ghost" size="sm" onClick={() => markAllRead.mutate()}>
|
||||
全部已读
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<ScrollArea className="h-[300px]">
|
||||
{(!notifications || notifications.length === 0) ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<Bell className="w-8 h-8 mx-auto mb-2 opacity-50" />
|
||||
<p>暂无通知记录</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{notifications.map((notif: any) => (
|
||||
<div
|
||||
key={notif.id}
|
||||
className={`p-3 rounded-lg border transition-colors cursor-pointer ${
|
||||
notif.isRead ? "bg-background" : "bg-blue-50 border-blue-200"
|
||||
}`}
|
||||
onClick={() => !notif.isRead && markRead.mutate({ notificationId: notif.id })}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-2">
|
||||
{notif.isRead ? (
|
||||
<CheckCircle2 className="w-4 h-4 text-muted-foreground mt-0.5" />
|
||||
) : (
|
||||
<BellRing className="w-4 h-4 text-blue-600 mt-0.5" />
|
||||
)}
|
||||
<div>
|
||||
<p className={`text-sm ${notif.isRead ? "" : "font-medium"}`}>{notif.title}</p>
|
||||
{notif.message && <p className="text-xs text-muted-foreground mt-0.5">{notif.message}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap ml-2">
|
||||
{formatDateTimeShanghai(notif.createdAt, {
|
||||
year: undefined,
|
||||
second: undefined,
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
455
client/src/pages/Tutorials.tsx
普通文件
@@ -0,0 +1,455 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useAuth } from "@/_core/hooks/useAuth";
|
||||
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, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
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 {
|
||||
BookOpen,
|
||||
CheckCircle2,
|
||||
ChevronRight,
|
||||
Clock3,
|
||||
ExternalLink,
|
||||
Flame,
|
||||
Star,
|
||||
Target,
|
||||
Trophy,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
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_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 utils = trpc.useUtils();
|
||||
const [selectedCategory, setSelectedCategory] = useState("all");
|
||||
const [selectedSkill, setSelectedSkill] = useState("all");
|
||||
const [draftNotes, setDraftNotes] = useState<Record<number, string>>({});
|
||||
|
||||
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: async () => {
|
||||
await utils.tutorial.progress.invalidate();
|
||||
toast.success("教程进度已更新");
|
||||
},
|
||||
});
|
||||
|
||||
const tutorials = tutorialsQuery.data ?? [];
|
||||
const progressMap = useMemo(() => {
|
||||
const map: Record<number, TutorialRecord> = {};
|
||||
(progressQuery.data ?? []).forEach((item: TutorialRecord) => {
|
||||
map[item.tutorialId] = item;
|
||||
});
|
||||
return map;
|
||||
}, [progressQuery.data]);
|
||||
|
||||
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(
|
||||
() => Array.from(new Set(tutorials.map((tutorial) => tutorial.category).filter(Boolean))),
|
||||
[tutorials],
|
||||
);
|
||||
|
||||
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 });
|
||||
};
|
||||
|
||||
const handleComplete = (tutorialId: number) => {
|
||||
updateProgress.mutate({ tutorialId, completed: 1, watched: 1 });
|
||||
};
|
||||
|
||||
const handleSelfScore = (tutorialId: number, score: number) => {
|
||||
updateProgress.mutate({ tutorialId, selfScore: score });
|
||||
};
|
||||
|
||||
if (tutorialsQuery.isLoading) {
|
||||
return (
|
||||
<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">
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</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
|
||||
variant={selectedCategory === "all" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setSelectedCategory("all")}
|
||||
>
|
||||
全部分类
|
||||
</Button>
|
||||
{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-2">
|
||||
<Button
|
||||
variant={selectedSkill === "all" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setSelectedSkill("all")}
|
||||
>
|
||||
全部级别
|
||||
</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 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}>
|
||||
<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>
|
||||
{completed ? <CheckCircle2 className="h-5 w-5 text-emerald-600" /> : null}
|
||||
</div>
|
||||
|
||||
<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="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="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",
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" className="mt-4 w-full">查看详情</Button>
|
||||
</DialogTrigger>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<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>
|
||||
|
||||
<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}
|
||||
|
||||
<div>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
{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>
|
||||
);
|
||||
}
|
||||
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:
|
||||
384
docs/API.md
普通文件
@@ -0,0 +1,384 @@
|
||||
# Tennis Training Hub - API接口文档
|
||||
|
||||
本文档详细描述了Tennis Training Hub的所有tRPC API接口,包括输入参数、输出格式和认证要求。
|
||||
|
||||
## 认证说明
|
||||
|
||||
所有标记为 **需认证** 的接口需要用户已登录(通过Session Cookie)。未认证请求将返回 `UNAUTHORIZED` 错误。
|
||||
|
||||
## 接口列表
|
||||
|
||||
### 1. 认证模块 (`auth`)
|
||||
|
||||
#### `auth.me` - 获取当前用户信息
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| 类型 | Query |
|
||||
| 认证 | 不需要 |
|
||||
| 输入 | 无 |
|
||||
| 输出 | `User | null` |
|
||||
|
||||
返回当前登录用户的完整信息,未登录返回 `null`。
|
||||
|
||||
#### `auth.loginWithUsername` - 用户名登录
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| 类型 | Mutation |
|
||||
| 认证 | 不需要 |
|
||||
| 输入 | `{ username: string }` |
|
||||
| 输出 | `{ user: User, isNew: boolean }` |
|
||||
|
||||
**输入验证:**
|
||||
- `username`:1-64个字符
|
||||
|
||||
若用户名不存在则自动创建新账户。
|
||||
|
||||
#### `auth.logout` - 退出登录
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| 类型 | Mutation |
|
||||
| 认证 | 不需要 |
|
||||
| 输出 | `{ success: true }` |
|
||||
|
||||
---
|
||||
|
||||
### 2. 用户资料模块 (`profile`)
|
||||
|
||||
#### `profile.stats` - 获取用户统计数据
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| 类型 | Query |
|
||||
| 认证 | **需认证** |
|
||||
| 输出 | `UserStats` |
|
||||
|
||||
#### `profile.update` - 更新用户资料
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| 类型 | Mutation |
|
||||
| 认证 | **需认证** |
|
||||
| 输入 | `{ skillLevel?: "beginner" \| "intermediate" \| "advanced", trainingGoals?: string }` |
|
||||
| 输出 | `{ success: true }` |
|
||||
|
||||
---
|
||||
|
||||
### 3. 训练计划模块 (`plan`)
|
||||
|
||||
#### `plan.generate` - AI生成训练计划
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| 类型 | Mutation |
|
||||
| 认证 | **需认证** |
|
||||
| 输入 | `{ skillLevel: enum, durationDays: number, focusAreas?: string[] }` |
|
||||
| 输出 | `{ taskId: string, task: BackgroundTask }` |
|
||||
|
||||
**输入验证:**
|
||||
- `skillLevel`:`"beginner"` / `"intermediate"` / `"advanced"`
|
||||
- `durationDays`:1-30
|
||||
- `focusAreas`:可选,如 `["正手", "脚步"]`
|
||||
|
||||
#### `plan.list` - 获取用户所有训练计划
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| 类型 | Query |
|
||||
| 认证 | **需认证** |
|
||||
| 输出 | `TrainingPlan[]` |
|
||||
|
||||
#### `plan.active` - 获取当前激活的训练计划
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| 类型 | Query |
|
||||
| 认证 | **需认证** |
|
||||
| 输出 | `TrainingPlan | null` |
|
||||
|
||||
#### `plan.adjust` - AI自动调整训练计划
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| 类型 | Mutation |
|
||||
| 认证 | **需认证** |
|
||||
| 输入 | `{ planId: number }` |
|
||||
| 输出 | `{ taskId: string, task: BackgroundTask }` |
|
||||
|
||||
---
|
||||
|
||||
### 4. 视频管理模块 (`video`)
|
||||
|
||||
#### `video.upload` - 上传训练视频
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| 类型 | Mutation |
|
||||
| 认证 | **需认证** |
|
||||
| 输入 | `{ title: string, format: string, fileSize: number, fileBase64: string, exerciseType?: string }` |
|
||||
| 输出 | `{ videoId: number, url: string }` |
|
||||
|
||||
#### `video.list` - 获取用户视频列表
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| 类型 | Query |
|
||||
| 认证 | **需认证** |
|
||||
| 输出 | `TrainingVideo[]` |
|
||||
|
||||
#### `video.get` - 获取视频详情
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| 类型 | Query |
|
||||
| 认证 | **需认证** |
|
||||
| 输入 | `{ videoId: number }` |
|
||||
| 输出 | `TrainingVideo` |
|
||||
|
||||
#### `video.updateStatus` - 更新视频分析状态
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| 类型 | Mutation |
|
||||
| 认证 | **需认证** |
|
||||
| 输入 | `{ videoId: number, status: "pending" \| "analyzing" \| "completed" \| "failed" }` |
|
||||
| 输出 | `{ success: true }` |
|
||||
|
||||
---
|
||||
|
||||
### 5. 姿势分析模块 (`analysis`)
|
||||
|
||||
#### `analysis.save` - 保存姿势分析结果
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| 类型 | Mutation |
|
||||
| 认证 | **需认证** |
|
||||
| 输入 | 见下表 |
|
||||
| 输出 | `{ analysisId: number }` |
|
||||
|
||||
**输入参数:**
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| videoId | number | 是 | 关联视频ID |
|
||||
| overallScore | number | 否 | 总体评分(0-100) |
|
||||
| poseMetrics | object | 否 | 关节角度等详细指标 |
|
||||
| detectedIssues | array | 否 | 检测到的问题列表 |
|
||||
| exerciseType | string | 否 | 动作类型 |
|
||||
| framesAnalyzed | number | 否 | 分析帧数 |
|
||||
| shotCount | number | 否 | 击球次数 |
|
||||
| avgSwingSpeed | number | 否 | 平均挥拍速度 |
|
||||
| maxSwingSpeed | number | 否 | 最大挥拍速度 |
|
||||
| totalMovementDistance | number | 否 | 总移动距离 |
|
||||
| strokeConsistency | number | 否 | 击球一致性(0-100) |
|
||||
| footworkScore | number | 否 | 脚步评分(0-100) |
|
||||
| fluidityScore | number | 否 | 流畅性评分(0-100) |
|
||||
| keyMoments | array | 否 | 关键时刻标记 |
|
||||
| movementTrajectory | array | 否 | 运动轨迹数据 |
|
||||
|
||||
保存分析结果后会自动触发NTRP评分重新计算。
|
||||
|
||||
#### `analysis.getCorrections` - AI生成矫正建议
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| 类型 | Mutation |
|
||||
| 认证 | **需认证** |
|
||||
| 输入 | `{ poseMetrics: object, exerciseType: string, detectedIssues: array, imageUrls?: string[], imageDataUrls?: string[] }` |
|
||||
| 输出 | `{ taskId: string, task: BackgroundTask }` |
|
||||
|
||||
该接口始终走后台任务。若提供 `imageUrls` 或 `imageDataUrls`,服务端会优先走多模态纠正链路,并把相对地址规范化为可公网访问的绝对 URL。
|
||||
|
||||
#### `analysis.list` - 获取用户所有分析记录
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| 类型 | Query |
|
||||
| 认证 | **需认证** |
|
||||
| 输出 | `PoseAnalysis[]` |
|
||||
|
||||
#### `analysis.getByVideo` - 获取视频的分析结果
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| 类型 | Query |
|
||||
| 认证 | **需认证** |
|
||||
| 输入 | `{ videoId: number }` |
|
||||
| 输出 | `PoseAnalysis | null` |
|
||||
|
||||
---
|
||||
|
||||
### 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` - 创建训练记录
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| 类型 | Mutation |
|
||||
| 认证 | **需认证** |
|
||||
| 输入 | `{ exerciseName: string, planId?: number, durationMinutes?: number, notes?: string, poseScore?: number }` |
|
||||
| 输出 | `{ recordId: number }` |
|
||||
|
||||
#### `record.complete` - 标记训练完成
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| 类型 | Mutation |
|
||||
| 认证 | **需认证** |
|
||||
| 输入 | `{ recordId: number, poseScore?: number }` |
|
||||
| 输出 | `{ success: true }` |
|
||||
|
||||
#### `record.list` - 获取训练记录列表
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| 类型 | Query |
|
||||
| 认证 | **需认证** |
|
||||
| 输入 | `{ limit?: number }` (默认50) |
|
||||
| 输出 | `TrainingRecord[]` |
|
||||
|
||||
---
|
||||
|
||||
### 7. 评分模块 (`rating`)
|
||||
|
||||
#### `rating.history` - 获取NTRP评分历史
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| 类型 | Query |
|
||||
| 认证 | **需认证** |
|
||||
| 输出 | `RatingHistory[]` |
|
||||
|
||||
#### `rating.current` - 获取当前NTRP评分
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| 类型 | Query |
|
||||
| 认证 | **需认证** |
|
||||
| 输出 | `{ rating: number }` |
|
||||
|
||||
---
|
||||
|
||||
### 8. 打卡模块 (`checkin`)
|
||||
|
||||
#### `checkin.today` - 获取今日打卡状态
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| 类型 | Query |
|
||||
| 认证 | **需认证** |
|
||||
| 输出 | `DailyCheckin | null` |
|
||||
|
||||
#### `checkin.do` - 执行打卡
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| 类型 | Mutation |
|
||||
| 认证 | **需认证** |
|
||||
| 输入 | `{ notes?: string, minutesTrained?: number }` (可选) |
|
||||
| 输出 | `{ checkin: DailyCheckin, streak: number, newBadges: Badge[] }` |
|
||||
|
||||
打卡后会自动检查并授予新徽章。
|
||||
|
||||
#### `checkin.history` - 获取打卡历史
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| 类型 | Query |
|
||||
| 认证 | **需认证** |
|
||||
| 输入 | `{ limit?: number }` (默认60) |
|
||||
| 输出 | `DailyCheckin[]` |
|
||||
|
||||
---
|
||||
|
||||
### 9. 徽章模块 (`badge`)
|
||||
|
||||
#### `badge.list` - 获取用户徽章(含未获得)
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| 类型 | Query |
|
||||
| 认证 | **需认证** |
|
||||
| 输出 | `BadgeWithStatus[]` |
|
||||
|
||||
返回所有24种徽章,标记已获得/未获得状态。
|
||||
|
||||
#### `badge.check` - 检查并授予新徽章
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| 类型 | Mutation |
|
||||
| 认证 | **需认证** |
|
||||
| 输出 | `{ newBadges: Badge[] }` |
|
||||
|
||||
#### `badge.definitions` - 获取所有徽章定义
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| 类型 | Query |
|
||||
| 认证 | 不需要 |
|
||||
| 输出 | `BadgeDefinition[]` |
|
||||
|
||||
---
|
||||
|
||||
### 10. 排行榜模块 (`leaderboard`)
|
||||
|
||||
#### `leaderboard.get` - 获取排行榜
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| 类型 | Query |
|
||||
| 认证 | **需认证** |
|
||||
| 输入 | `{ sortBy?: enum, limit?: number }` |
|
||||
| 输出 | `LeaderboardEntry[]` |
|
||||
|
||||
**sortBy选项:**
|
||||
- `"ntrpRating"` - 按NTRP评分排名(默认)
|
||||
- `"totalMinutes"` - 按训练时长排名
|
||||
- `"totalSessions"` - 按训练次数排名
|
||||
- `"totalShots"` - 按击球数排名
|
||||
583
docs/CHANGELOG.md
普通文件
@@ -0,0 +1,583 @@
|
||||
# Tennis Training Hub - 变更日志
|
||||
|
||||
## 2026.03.27-match-review-hub (2026-03-27)
|
||||
|
||||
### 功能更新
|
||||
|
||||
- 新增 `/matches` 比赛入库板块和侧边栏入口,用户可以查看自己绑定的双人双摄比赛、自动计分事件、审核状态与正式入库结果
|
||||
- 服务端新增 `match_sessions`、`match_participants`、`match_score_events` 数据表,并把“自动计分建议”“正式结算”接入现有 `background_tasks` worker 异步链路
|
||||
- 补齐 `0012_market_watch` 与 `0013_match_hub` 的 Drizzle statement breakpoint,让 MySQL 生产迁移可以顺序执行球拍行情与比赛入库表结构并完成上线
|
||||
- 管理后台新增“比赛入库”工作台,H1 / 管理员可以创建日常或竞赛比赛、固定两位用户与对应双机位、刷新自动计分建议、提交审核比分并发起正式结算
|
||||
- 排行榜新增训练榜 / 竞赛榜双视图;竞赛榜只统计已正式结算的竞赛比赛,不会把未审核自动计分直接计入正式名次
|
||||
- 正式结算后会把比赛写入用户训练记录;日常比赛同步累计训练型指标,竞赛比赛单独进入正式比赛统计与排行榜,避免未审核比赛直接影响 NTRP
|
||||
|
||||
### 测试
|
||||
|
||||
- `pnpm check`
|
||||
- `pnpm exec vitest run server/match.test.ts server/features.test.ts`
|
||||
- `pnpm build`
|
||||
- `docker compose up -d --build migrate app app-worker`
|
||||
- 线上 smoke:公开站点部署前仍停留在 2026-03-17 的旧 revision;重新部署后已切到包含比赛入库与更新日志修正的新构建,Playwright 真实登录 `H1` 验证 `/matches`、`/admin` 的“比赛入库”工作台、`/leaderboard` 的“训练榜 / 竞赛榜”以及 `/changelog` 最新条目均可访问;当前仅剩 `/favicon.ico` 404,不影响功能使用
|
||||
|
||||
### 仓库版本
|
||||
|
||||
- `32ffad1`
|
||||
|
||||
## 2026.03.23-racket-market-watch (2026-03-23)
|
||||
|
||||
### 功能更新
|
||||
|
||||
- 新增 `/market` 球拍行情板块和侧边栏入口,集中展示低价雷达、监控规则、命中记录以及相关后台任务状态
|
||||
- 服务端新增球拍行情数据表、用户监控规则和命中历史,并把市场刷新、来源抓取、飞书推送接入现有 `background_tasks` worker 流程
|
||||
- 抓取结果会为球拍补齐品牌、型号、系列、品类、重量、成色与价格分级;命中用户目标价后,会先写入站内记录,再按去重规则推送到默认飞书 webhook
|
||||
- 全局设置新增默认飞书 webhook、抓取超时、重试次数、闲鱼/京东/转转抓取 UA/Cookie 与转转搜索模板配置,管理员可在后台直接调整
|
||||
|
||||
### 测试
|
||||
|
||||
- `pnpm check`
|
||||
- `pnpm exec vitest run server/market.test.ts server/market.routes.test.ts`
|
||||
- `pnpm build`
|
||||
|
||||
### 仓库版本
|
||||
|
||||
- `32ffad1`
|
||||
|
||||
## 2026.03.17-live-camera-relay-mp4-hardening (2026-03-17)
|
||||
|
||||
### 功能更新
|
||||
|
||||
- 修复实时分析 relay 预览在 Chrome `mp4` 分段下容易失效的问题;media 服务现在会在 relay 会话收到第一段 `mp4` 时额外保留初始化片段,供后续滚动 preview 重建使用
|
||||
- relay preview 构建会跳过明显异常的小 `mp4` 分段,并优先把初始化片段和当前缓存合成单一输入后再转成 `preview.webm`,降低 `trex/tfhd` 缺失导致的 ffmpeg 失败率
|
||||
- 如果 relay preview 本轮重建失败,但磁盘上仍有上一版可播放 `preview.webm`,worker 会保留旧预览继续服务 viewer,而不是直接把同步观看打成永久失败
|
||||
- `live-camera` 合成录制的 mime 选择已经改成优先 `video/webm`;Chrome 不再默认优先上传 fragmented `mp4` relay 分段,从源头减少 `concat failed` 与 `previewStatus=failed`
|
||||
|
||||
### 测试
|
||||
|
||||
- `cd media && go test ./...`
|
||||
- `pnpm check`
|
||||
- `pnpm build`
|
||||
- 部署后线上 smoke:已确认 `https://te.hao.work/` 正在提供新构建;当前线上仍有一条补丁前启动的旧 `mp4` relay 会话在运行,因此完整的 `webm` relay 端到端验证需要在重启该实时分析会话后继续确认
|
||||
|
||||
### 线上 smoke
|
||||
|
||||
- 已确认公开站点已切到包含此修复的新资源 revision
|
||||
- 当前线上仍有一条补丁前启动的旧 `mp4` relay 会话在运行,它会继续暴露旧分段问题;重新开始一条新的实时分析会话后,再继续验证 relay 分段格式、preview 更新稳定性和 viewer 播放状态
|
||||
|
||||
### 仓库版本
|
||||
|
||||
- `1adadba`
|
||||
|
||||
## 2026.03.17-live-camera-media-asset-url (2026-03-17)
|
||||
|
||||
### 功能更新
|
||||
|
||||
- 修复同步观看预览地址重复拼接 `/media` 的问题;当前端收到 `/media/assets/...` 这类已完整的应用内媒体路径时,会直接使用原值,不再错误请求 `/media/media/assets/...`
|
||||
- 当前端收到完整的 `https://...` 外部媒体地址时,也会保持原样,避免把外链错误改写成站内 media 路径
|
||||
- 其他仍是普通相对路径的媒体资源会继续自动补齐 `/media` 前缀,因此原有依赖相对路径的调用链不需要调整
|
||||
- `/live-camera` 点击“同步观看”后,请求的缓存视频地址恢复为 `/media/assets/sessions/.../preview.webm`,不再因 `404 page not found` 导致无视频可播
|
||||
|
||||
### 测试
|
||||
|
||||
- `pnpm vitest run client/src/lib/media.test.ts`
|
||||
- `pnpm check`
|
||||
- `pnpm build`
|
||||
- `playwright-skill` 线上 smoke:登录 `H1` 后访问 `https://te.hao.work/live-camera`,确认 viewer 实际请求 `https://te.hao.work/media/assets/sessions/.../preview.webm?...` 并返回 `200`,同时不存在 `/media/media/...` 双前缀请求
|
||||
- `curl -I https://te.hao.work/`
|
||||
- `curl -I https://te.hao.work/assets/index-*.js`
|
||||
- `curl -I https://te.hao.work/assets/index-*.css`
|
||||
|
||||
### 线上 smoke
|
||||
|
||||
- 部署前确认公开站点仍在旧资源 revision,尚未提供本次修复
|
||||
- 部署完成后,`https://te.hao.work/` 已切到本次新构建,而不是继续提供部署前的旧资源 revision
|
||||
- `/live-camera` 的同步观看请求地址已恢复为 `/media/assets/sessions/.../preview.webm`,Playwright 真实浏览器验证拿到的 preview 请求状态为 `200`
|
||||
- 已确认不存在 `/media/media/assets/...` 双重前缀请求
|
||||
|
||||
### 仓库版本
|
||||
|
||||
- `0af88b3`
|
||||
|
||||
## 2026.03.17-live-camera-pose-buffer-window (2026-03-17)
|
||||
|
||||
### 功能更新
|
||||
|
||||
- 修复 `/live-camera` 开始分析时报错 `Cannot destructure property 'Pose' ... as it is undefined` 的问题;MediaPipe Pose 动态加载现在兼容 `Pose`、`default.Pose` 和默认导出三种模块形态
|
||||
- 多端同步观看的 relay 缓存窗口改为按会话配置,默认 `2` 分钟,可选最短 `10` 秒、最长 `5` 分钟;viewer 页面、徽标和设置卡都会同步显示当前缓存时长
|
||||
- owner 端分析录制在继续保持“每 `60` 秒自动归档”之外,会额外每 `10` 秒上传一次 relay 分片,因此短缓存模式下其他端不需要等待整整 `60` 秒才看到平滑同步视频
|
||||
- media 服务会按各自 relay 会话的缓存秒数裁剪 preview 分段;从磁盘恢复旧 relay 会话时也会自动归一化到合法范围,避免旧会话继续沿用固定 `60` 秒窗口
|
||||
- 同步端渲染远端 `recentSegments` 时新增旧快照归一化;即使历史快照缺少 `keyFrames`、`issueSummary` 等数组字段,也会自动补默认值,不再触发 `Cannot read properties of undefined (reading 'length')`
|
||||
- 同步观看界面新增“已累积多少缓存、预计还需多久才能看到首段回放、距离目标缓存还差多少”的提示,观看端等待阶段会给出更明确的可观察时间说明
|
||||
- 线上 smoke 已确认 `https://te.hao.work/` 正在提供本次新构建,而不是旧资源版本;当前公开站点资源 revision 为 `assets/index-CYpJPG0R.js`、`assets/index-BHHHsAWc.css`、`assets/pose-C93FSit6.js`
|
||||
|
||||
### 测试
|
||||
|
||||
- `cd media && go test ./...`
|
||||
- `pnpm vitest run client/src/lib/liveCamera.test.ts`
|
||||
- `pnpm check`
|
||||
- `pnpm build`
|
||||
- `pnpm exec playwright test tests/e2e/app.spec.ts`
|
||||
- `playwright-skill` 线上 smoke:登录 `H1` 后访问 `https://te.hao.work/live-camera`,完成校准、启用假摄像头并点击“开始分析”,确认页面进入分析中状态、默认显示“缓存 2 分钟”,且无控制台与页面级错误
|
||||
- `curl -I https://te.hao.work/`
|
||||
- `curl -I https://te.hao.work/assets/index-CYpJPG0R.js`
|
||||
- `curl -I https://te.hao.work/assets/index-BHHHsAWc.css`
|
||||
- `curl -I https://te.hao.work/assets/pose-C93FSit6.js`
|
||||
|
||||
### 线上 smoke
|
||||
|
||||
- `https://te.hao.work/` 已切换到本次新构建,而不是旧资源版本
|
||||
- 当前公开站点前端资源 revision:`assets/index-CYpJPG0R.js`、`assets/index-BHHHsAWc.css`、`assets/pose-C93FSit6.js`
|
||||
- 已确认首页、主 JS、主 CSS 与 `pose` 模块均返回 `200`,且 MIME 分别为 `text/html`、`application/javascript`、`text/css`、`application/javascript`
|
||||
- 真实浏览器验证已通过:登录 `H1` 后进入 `/live-camera`,能够完成校准、启用摄像头并点击“开始分析”;页面会进入“分析进行中”状态,默认显示“缓存 2 分钟”,且未再出现 `Pose` 模块解构异常
|
||||
|
||||
### 仓库版本
|
||||
|
||||
- `f3f7e19+pose-buffer-window`
|
||||
|
||||
## 2026.03.17-live-camera-relay-buffer (2026-03-17)
|
||||
|
||||
### 功能更新
|
||||
|
||||
- `/live-camera` 的同步观看改为播放 media 服务生成的滚动缓存视频,不再轮询 `live-frame.jpg` 单帧图片,因此观看端的画面会按最近 60 秒缓存视频平滑播放
|
||||
- owner 端每个 60 秒的合成录像分段现在会额外上传到 `relay` 会话,worker 会在收到新分段后自动重建最近窗口的 `preview.webm`
|
||||
- `relay` 会话只保留最近 60 秒视频分段,旧分段会从会话元数据和磁盘同步清理,避免观看端继续读到旧一分钟之前的缓存
|
||||
- media worker 会自动清理超过 30 分钟无活动的 relay 会话、分段目录和公开缓存文件,降低磁盘堆积风险
|
||||
- viewer 页面文案、加载提示和按钮文案已同步更新为“缓存视频 / 缓存回放”语义;预览阶段跳过 mp4 转码,Chrome 直接使用 webm,降低处理时延
|
||||
|
||||
### 测试
|
||||
|
||||
- `cd media && go test ./...`
|
||||
- `pnpm vitest run client/src/lib/liveCamera.test.ts`
|
||||
- `pnpm exec playwright test tests/e2e/app.spec.ts --grep "live camera page exposes camera startup controls|live camera starts analysis and produces scores|live camera switches into viewer mode when another device already owns analysis|live camera recovers mojibake viewer titles before rendering|live camera no longer opens viewer peer retries when server relay is active"`
|
||||
- `pnpm check`
|
||||
- `pnpm build`
|
||||
- 线上 smoke:部署后确认 `https://te.hao.work/` 已提供新构建而不是旧资源版本,`/live-camera` viewer 端进入“服务端缓存同步”路径,首页与资源文件返回正确 MIME
|
||||
|
||||
### 线上 smoke
|
||||
|
||||
- 部署完成后已确认 `https://te.hao.work/` 提供的是本次新构建,而不是旧资源版本
|
||||
- `https://te.hao.work/live-camera` 的 viewer 端会走“服务端缓存同步”路径,不再请求旧的 `live-frame.jpg` 单帧同步
|
||||
- 首页、主 JS、主 CSS 与 `pose` 模块均返回 `200` 和正确 MIME,未再出现脚本/样式被回退成 `text/html` 的问题
|
||||
|
||||
### 仓库版本
|
||||
|
||||
- `63dbfd2+relay-buffer`
|
||||
|
||||
## 2026.03.17-live-camera-preview-recovery (2026-03-17)
|
||||
|
||||
### 功能更新
|
||||
|
||||
- `/live-camera` 的 runtime 标题恢复逻辑新增更严格的乱码筛除与二次 UTF-8 解码兜底,`æœ...` 这类异常标题会优先恢复为正常中文;无法恢复时会自动回退到稳定默认标题,避免继续显示脏字符串
|
||||
- 同步观看退出时会完整重置 viewer 轮询、连接标记和帧版本,不再把旧的 viewer 状态带回 owner 或空闲态,修复退出同步后仍黑屏、仍显示“等待同步画面”的问题
|
||||
- 本地摄像头预览增加独立重绑流程和多次 watchdog 重试,即使浏览器首帧没有及时绑定 `srcObject` 或 `play()` 被短暂中断,也会继续自动恢复本地预览
|
||||
- 视频区域是否显示画面改为按当前 runtime 角色分别判断,避免 viewer 旧连接状态误导 owner 模式,导致本地没有预览时仍错误隐藏占位提示
|
||||
|
||||
### 测试
|
||||
|
||||
- `pnpm check`
|
||||
- `pnpm vitest run client/src/lib/liveCamera.test.ts`
|
||||
- `pnpm exec playwright test tests/e2e/app.spec.ts --grep "live camera"`
|
||||
- `pnpm build`
|
||||
- 线上 smoke:`curl -I https://te.hao.work/`
|
||||
- 线上 smoke:`curl -I https://te.hao.work/assets/index-BJ7rV3xe.js`
|
||||
- 线上 smoke:`curl -I https://te.hao.work/assets/index-tNGuStgv.css`
|
||||
- 线上 smoke:`curl -I https://te.hao.work/assets/pose-CZKsH31a.js`
|
||||
|
||||
### 线上 smoke
|
||||
|
||||
- `https://te.hao.work/` 已切换到本次新构建
|
||||
- 当前公开站点前端资源 revision:`assets/index-BJ7rV3xe.js`、`assets/index-tNGuStgv.css`、`assets/pose-CZKsH31a.js`
|
||||
- 已确认 `index`、`css` 与 `pose` 模块均返回 `200`,且 MIME 分别为 `application/javascript`、`text/css`、`application/javascript`,不再出现此前的模块脚本和样式被当成 `text/html` 返回的问题
|
||||
|
||||
### 仓库版本
|
||||
|
||||
- `06b9701`
|
||||
|
||||
## 2026.03.16-live-camera-runtime-refresh (2026-03-16)
|
||||
|
||||
### 功能更新
|
||||
|
||||
- `/live-camera` 在打开拍摄引导、启用摄像头、开始分析前,都会先向服务端强制刷新 runtime 状态,避免旧的同步观看锁残留导致本机明明已释放却仍无法启动
|
||||
- 新增 runtime 标题乱码恢复逻辑,可自动把 UTF-8 被误按 Latin-1 显示的标题恢复成正常中文,避免出现 `æœ...` 一类异常标题
|
||||
- 摄像头启动链路改为以 `getUserMedia` 成功为准;即使本地预览 `<video>` 的 `srcObject` 或 `play()` 在当前浏览器中短暂失败,也不会直接把整次启动判死
|
||||
- e2e mock 的媒体流补齐为带假视频轨道的流对象,并把 viewer 回归改为校验“服务端 relay、无 viewer-signal”行为,避免继续按旧 P2P 逻辑断言
|
||||
|
||||
### 测试
|
||||
|
||||
- `pnpm exec playwright test tests/e2e/app.spec.ts --grep "live camera page exposes camera startup controls|live camera switches into viewer mode when another device already owns analysis|live camera recovers mojibake viewer titles before rendering|live camera no longer opens viewer peer retries when server relay is active"`
|
||||
- `pnpm build`
|
||||
- 部署后线上 smoke:登录 `H1` 后访问 `https://te.hao.work/live-camera`,确认空闲态“启动摄像头”入口可见,不再被残留 viewer 锁卡住
|
||||
|
||||
### 线上 smoke
|
||||
|
||||
- `https://te.hao.work/` 已切换到本次新构建
|
||||
- 当前公开站点前端资源 revision:`assets/index-33wVjC4p.js` 与 `assets/index-tNGuStgv.css`
|
||||
- 真实验证已通过:登录 `H1` 后访问 `https://te.hao.work/live-camera`,页面会正常显示“摄像头未启动 / 启动摄像头”,说明旧的 viewer 锁残留不会再把空闲设备卡在同步观看模式
|
||||
|
||||
### 仓库版本
|
||||
|
||||
- `8e9e491`
|
||||
|
||||
## 2026.03.16-live-viewer-server-relay (2026-03-16)
|
||||
|
||||
### 功能更新
|
||||
|
||||
- `/live-camera` 的同步观看改为由 media 服务中转最新合成帧图,不再依赖浏览器之间的 P2P WebRTC viewer 连接
|
||||
- owner 端会把“原视频 + 骨架/关键点 + 虚拟形象”的合成画布压缩成 JPEG 并持续上传到 media 服务
|
||||
- viewer 端改为自动轮询 media 服务中的最新同步帧图,因此即使浏览器之间无法直连,也能继续看到同步画面和状态
|
||||
- 同步观看模式文案已调整为明确提示“通过 media 服务中转”,等待阶段会继续自动刷新,而不是停留在 P2P 连接失败状态
|
||||
- media 服务新增 live-frame 上传与静态分发能力,并记录最近同步帧时间,方便后续继续扩展更高频的服务端 relay
|
||||
|
||||
### 测试
|
||||
|
||||
- `cd media && go test ./...`
|
||||
- `pnpm build`
|
||||
- `playwright-skill` 线上 smoke:先用 media 服务创建 relay session、上传 live-frame,并把 `H1` 的 `live_analysis_runtime` 注入为 active viewer 场景;随后访问 `https://te.hao.work/live-camera`,确认页面进入“同步观看模式”、同步帧来自 `/media/assets/sessions/.../live-frame.jpg`,且 `viewer-signal` 请求数为 `0`
|
||||
|
||||
### 线上 smoke
|
||||
|
||||
- `https://te.hao.work/` 已切换到本次新构建
|
||||
- 当前公开站点前端资源 revision:`assets/index-BC-IupO8.js` 与 `assets/index-tNGuStgv.css`
|
||||
- 真实验证已通过:viewer 端进入“同步观看模式”后,画面由 media 服务静态分发的 `live-frame.jpg` 提供,已确认不再触发 `/viewer-signal` P2P 观看请求
|
||||
|
||||
### 仓库版本
|
||||
|
||||
- `bb46d26`
|
||||
|
||||
## 2026.03.16-camera-startup-fallbacks (2026-03-16)
|
||||
|
||||
### 功能更新
|
||||
|
||||
- 修复部分设备在 `/live-camera` 和 `/recorder` 中因默认后置镜头、分辨率或帧率约束不兼容而直接启动摄像头失败的问题
|
||||
- 摄像头请求现在会自动按当前画质、去掉高约束、低分辨率、备用镜头、任意可用镜头依次降级重试
|
||||
- `/recorder` 在麦克风不可用或麦克风权限未给出时,会自动回退到仅视频模式,不再让整次预览启动失败
|
||||
- 如果实际启用的是兼容镜头或降级模式,页面会显示提示,帮助区分“自动修复成功”与“仍然无法访问摄像头”
|
||||
|
||||
### 测试
|
||||
|
||||
- `pnpm build`
|
||||
- `playwright-skill` 线上 smoke:通过注入 `getUserMedia` 回归验证 `/live-camera` 首轮高约束失败后会自动降级到兼容摄像头模式,`/recorder` 在麦克风不可用时会自动回退到仅视频模式并继续启动预览
|
||||
|
||||
### 线上 smoke
|
||||
|
||||
- `https://te.hao.work/` 已切换到本次新构建
|
||||
- 当前公开站点前端资源 revision:`assets/index-CRxtWK07.js` 与 `assets/index-tNGuStgv.css`
|
||||
- 真实回归已通过:模拟高约束失败时,`/live-camera` 会提示“当前设备已自动切换到兼容摄像头模式”并继续启动;模拟麦克风不可用时,`/recorder` 会提示“麦克风不可用,已切换为仅视频模式”并继续显示录制入口
|
||||
|
||||
### 仓库版本
|
||||
|
||||
- `a211562`
|
||||
|
||||
## 2026.03.16-live-analysis-viewer-full-sync (2026-03-16)
|
||||
|
||||
### 功能更新
|
||||
|
||||
- 同账号多端同步观看时,viewer 端现在会按持有端 runtime snapshot 完整渲染,不再混用本地默认状态
|
||||
- `/live-camera` viewer 端新增主端同步信息卡,可看到当前会话标题、训练模式、设备端、拍摄视角、画质模式、虚拟形象状态和最近同步时间
|
||||
- viewer 端现在会同步显示主端当前处于“分析中 / 保存中 / 已保存 / 保存失败”的阶段状态
|
||||
- viewer 页面在同步观看模式下会自动关闭拍摄校准弹窗,避免被“启用摄像头”引导遮挡画面和状态信息
|
||||
|
||||
### 测试
|
||||
|
||||
- `pnpm exec playwright test tests/e2e/app.spec.ts --grep "live camera switches into viewer mode|viewer stream|recorder blocks"`
|
||||
- `pnpm build`
|
||||
- `playwright-skill` 线上 smoke:同账号 `H1` 双端登录后,移动端 owner 开始实时分析,桌面端 `/live-camera` 进入同步观看并显示主端信息、同步视频流,owner 点击结束分析后 viewer 同步进入保存阶段
|
||||
|
||||
### 线上 smoke
|
||||
|
||||
- `https://te.hao.work/` 已切换到本次新构建
|
||||
- 当前公开站点前端资源 revision:`assets/index-HRdM3fxq.js` 与 `assets/index-tNGuStgv.css`
|
||||
- 真实双端验证已通过:同账号 `H1` 在移动端开启实时分析后,桌面端 `/live-camera` 会自动进入同步观看模式,显示主端设备信息、最近同步时间和远端视频流;owner 点击结束分析后,viewer 会同步进入“保存中”阶段
|
||||
|
||||
### 仓库版本
|
||||
|
||||
- `922a9fb`
|
||||
|
||||
## 2026.03.16-live-analysis-lock-hardening (2026-03-16)
|
||||
|
||||
### 功能更新
|
||||
|
||||
- 修复同账号多端实时分析在旧登录态下仍可重复占用摄像头的问题;缺少 `sid` 的旧 token 现在会按 token 本身派生唯一会话标识
|
||||
- `/live-camera` 的同步观看模式新增自动重试;当持有端刚启动推流、viewer 首次连接返回 `viewer stream not ready` 时,会继续重连,不再长时间停留在无画面状态
|
||||
- `/recorder` 接入实时分析占用锁;其他设备正在实时分析时,本页会禁止再次启动摄像头和开始录制,并提示前往 `/live-camera` 查看同步画面
|
||||
- 应用启动改为先监听 HTTP 端口、再后台串行执行教程图同步和标准库预热,修复新容器上线时公网长时间返回 `502`
|
||||
|
||||
### 测试
|
||||
|
||||
- `curl -I https://te.hao.work/`
|
||||
- `pnpm check`
|
||||
- `pnpm exec vitest run server/_core/sdk.test.ts server/features.test.ts`
|
||||
- `pnpm exec playwright test tests/e2e/app.spec.ts --grep "viewer mode|viewer stream|recorder blocks"`
|
||||
- `playwright-skill` 线上校验:登录 `H1` 后访问 `/changelog`,确认 `2026.03.16-live-analysis-lock-hardening` 与仓库版本 `f9db6ef` 已展示
|
||||
- `pnpm build`
|
||||
- Playwright 线上 smoke:`H1` 手机端开启实时分析后,PC 端 `/live-camera` 自动进入同步观看并显示同步画面,`/recorder` 禁止启动摄像头;结束分析后会话可正常释放
|
||||
|
||||
### 线上 smoke
|
||||
|
||||
- `https://te.hao.work/` 已切换到本次新构建,不再返回 `502`
|
||||
- 当前公开站点前端资源 revision:`assets/index-mi8CPCFI.js` 与 `assets/index-Cp_VJ8sf.css`
|
||||
- 真实双端验证已通过:同账号 `H1` 手机端开始实时分析后,PC 端 `/live-camera` 进入同步观看模式且可拉起同步流,`/recorder` 页面会阻止再次占用摄像头
|
||||
|
||||
### 仓库版本
|
||||
|
||||
- `f9db6ef`
|
||||
|
||||
## 2026.03.16-live-analysis-runtime-migration (2026-03-16)
|
||||
|
||||
### 功能更新
|
||||
|
||||
- 修复生产环境缺失 `live_analysis_runtime` 表导致 `/live-camera` 启动实时分析时报 SQL 查询失败的问题
|
||||
- 生产库已补建 `live_analysis_runtime` 表,并写入缺失的 `0011_live_analysis_runtime` 迁移记录,避免后续重复报错
|
||||
- 仓库内 `drizzle/meta/_journal.json` 已补齐 `0011_live_analysis_runtime` 条目,后续 `docker compose` 部署可正确识别该迁移
|
||||
- 实时分析 runtime 锁恢复正常后,同账号多端互斥与同步观看流程可继续工作
|
||||
|
||||
### 测试
|
||||
|
||||
- `pnpm check`
|
||||
- `pnpm exec vitest run server/features.test.ts`
|
||||
- `pnpm build`
|
||||
- `docker compose exec -T db mysql ... SHOW TABLES LIKE 'live_analysis_runtime'`
|
||||
- `curl -I https://te.hao.work/live-camera`
|
||||
- Playwright smoke:登录 `H1` 后访问 `/live-camera`,`analysis.runtimeGet` / `analysis.runtimeAcquire` / `analysis.runtimeRelease` 全部返回 `200`
|
||||
|
||||
### 线上 smoke
|
||||
|
||||
- `https://te.hao.work/` 已切换到本次新构建
|
||||
- 当前公开站点前端资源 revision:`assets/index-B3BN5hY-.js` 与 `assets/index-BL6GQzUF.css`
|
||||
- `/live-camera` 已恢复可用,线上不再出现 `live_analysis_runtime` 缺表导致的 SQL 查询失败
|
||||
|
||||
### 仓库版本
|
||||
|
||||
- `2b72ef9`
|
||||
|
||||
## 2026.03.16-live-camera-multidevice-viewer (2026-03-16)
|
||||
|
||||
### 功能更新
|
||||
|
||||
- `/live-camera` 新增同账号多端 runtime 锁;一个设备开始实时分析后,其他设备不能再次启动摄像头或分析
|
||||
- 其他设备会自动进入“同步观看模式”,可查看持有端同步推送的实时画面、当前动作、评分、反馈和最近动作片段
|
||||
- 同步观看复用 media 服务新增的 `/viewer-signal` WebRTC 通道,直接订阅“原视频 + 骨架 + 关键点 + 虚拟形象”的合成画面
|
||||
- runtime 心跳按 `sid` 维度识别持有端,兼容旧 token 缺失可选字段的情况;超过 15 秒无心跳会自动释放陈旧锁
|
||||
- `/live-camera` 前端新增 owner / viewer 双模式切换,观看端会禁用镜头切换、重新校准、质量调整和分析启动
|
||||
- e2e mock 新增 viewer 模式和 runtime 接口覆盖,保证浏览器测试可以直接验证多端互斥与同步观看
|
||||
|
||||
### 测试
|
||||
|
||||
- `pnpm check`
|
||||
- `pnpm exec vitest run server/features.test.ts`
|
||||
- `go test ./...`
|
||||
- `go build ./...`
|
||||
- `pnpm build`
|
||||
- `pnpm exec playwright test tests/e2e/app.spec.ts --grep "live camera"`
|
||||
- `pnpm exec playwright test tests/e2e/app.spec.ts --grep "recorder flow archives a session and exposes it in videos"`
|
||||
- `curl -I https://te.hao.work/live-camera`
|
||||
|
||||
### 线上 smoke
|
||||
|
||||
- `https://te.hao.work/live-camera` 已切换到本次新前端构建
|
||||
- 公开站点确认已经提供本次发布的最新前端资源
|
||||
|
||||
### 仓库版本
|
||||
|
||||
- `4e4122d`
|
||||
|
||||
## 2026.03.16-live-analysis-overlay-archive (2026-03-16)
|
||||
|
||||
### 功能更新
|
||||
|
||||
- `/live-camera` 新增 10 个免费动物虚拟形象,可将主体实时替换为猩猩、猴子、狗、猪、猫、狐狸、熊猫、狮子、老虎、兔子
|
||||
- `/live-camera` 再新增 4 个免费的全身 3D Avatar 示例,可直接覆盖人物轮廓,并提供对应的 CC0 模型源链接
|
||||
- `/live-camera` 新增实时分析自动录像,按 60 秒自动切段归档;归档视频写入视频库并标记为“实时分析”
|
||||
- 实时分析录像改为录制“视频画面 + 骨架线 + 关键点 + 虚拟形象覆盖”的合成画布,回放中可直接看到分析叠层
|
||||
- 实时分析记录与视频库解耦,用户删除视频库中的“实时分析”录像后,不会删除已保存的分析数据和训练记录
|
||||
- 增加形象别名输入,当前可按输入内容自动映射到内置形象
|
||||
- 实时分析动作稳定器从短窗口切换为 24 帧时间窗口,降低 1-2 秒内频繁跳动作的问题
|
||||
- 动作切换新增确认阶段与延迟入库逻辑,连续动作区间改为只按稳定动作聚合
|
||||
- 画面内新增稳定动作、原始候选、窗口占比、切换确认状态等实时状态提示
|
||||
- 实时分析会话保存新增稳定窗口、动作切换次数、原始波动率、虚拟形象状态等指标
|
||||
- 动物头像素材切换为本地集成的免费 Twemoji SVG,避免外链依赖
|
||||
- 新增 Open Source Avatars 的本地优化透明 WebP 全身素材,减少全身替身叠加时的页面流量和首帧加载时间
|
||||
|
||||
### 测试
|
||||
|
||||
- `pnpm check`
|
||||
- `pnpm test`
|
||||
- `pnpm build`
|
||||
- `pnpm test:e2e`
|
||||
- Playwright 线上 smoke:
|
||||
- `https://te.hao.work/live-camera` 真实登录 `H1` 后可完成引导、启用摄像头、开始分析、结束分析
|
||||
- `https://te.hao.work/videos` 可见“实时分析”录像条目
|
||||
- `https://te.hao.work/changelog` 已展示 `2026.03.16-live-analysis-overlay-archive` 条目与对应摘要
|
||||
- 最终线上资源 revision:`assets/index-BWEXNszf.js` 与 `assets/index-BL6GQzUF.css`
|
||||
|
||||
### 仓库版本
|
||||
|
||||
- `4fb2d09`
|
||||
|
||||
## 2026.03.15-live-analysis-leave-hint (2026-03-15)
|
||||
|
||||
### 功能更新
|
||||
|
||||
- 实时分析进行中显示“不要关闭浏览器或切走页面”提示
|
||||
- 点击“结束分析”后,保存阶段显示“请暂时停留当前页面”提示
|
||||
- 保存完成后明确提示“现在可以关闭浏览器或切换到其他页面”
|
||||
- 分析中和保存中增加离开页面提醒,减少误关导致的数据丢失
|
||||
|
||||
### 测试
|
||||
|
||||
- `pnpm check`
|
||||
- `pnpm build`
|
||||
|
||||
### 仓库版本
|
||||
|
||||
- `5c2dcf2`
|
||||
|
||||
## 2026.03.15-training-generator-collapse (2026-03-15)
|
||||
|
||||
### 功能更新
|
||||
|
||||
- 训练页右侧“重新生成计划”面板在桌面端默认折叠到右侧
|
||||
- 点击右侧折叠栏后展开完整生成器,可调整周期并重新生成计划
|
||||
- 移动端继续保持完整面板直接可见
|
||||
- 未生成计划时点击“前往生成训练计划”会先自动展开,再滚动到面板位置
|
||||
|
||||
### 测试
|
||||
|
||||
- `pnpm check`
|
||||
- `pnpm build`
|
||||
|
||||
### 仓库版本
|
||||
|
||||
- `1ce94f6`
|
||||
|
||||
## 2026.03.15-progress-time-actions (2026-03-15)
|
||||
|
||||
### 功能更新
|
||||
|
||||
- 最近训练记录摘要行默认显示到秒的具体时间,统一按 `Asia/Shanghai` 展示
|
||||
- 录制类训练记录在列表中直接显示动作数、主动作和前 3 个动作统计
|
||||
- 训练记录展开态中的动作明细改为中文动作标签,便于直接阅读
|
||||
- 提醒页通知时间统一切换为 `Asia/Shanghai`
|
||||
|
||||
### 测试
|
||||
|
||||
- `pnpm check`
|
||||
- `pnpm build`
|
||||
|
||||
### 仓库版本
|
||||
|
||||
- `71caf0d`
|
||||
|
||||
## 2026.03.15-session-changelog (2026-03-15)
|
||||
|
||||
### 功能更新
|
||||
|
||||
- 用户名登录生成独立 `sid`,同一账号在多个设备或浏览器上下文中登录时不再互相顶掉 session
|
||||
- 新增应用内更新日志页面 `/changelog`,展示版本号、发布日期、仓库版本和测试记录
|
||||
- 训练进度页最近训练记录支持展开,展示具体上海时间、动作数、主动作、动作明细、录制有效性和备注
|
||||
- 录制页补齐动作抽样摘要、无效录制标记与 media 预归档状态的前端展示
|
||||
- Dashboard、任务中心、管理台、训练页、评分页、日志页、视觉测试页、视频库等高频页面统一使用 `Asia/Shanghai` 时间显示
|
||||
|
||||
### 测试
|
||||
|
||||
- `pnpm check`
|
||||
- `pnpm test`
|
||||
- `pnpm test:go`
|
||||
- `pnpm build`
|
||||
- Playwright 线上 smoke:
|
||||
- `https://te.hao.work/` 使用两个浏览器上下文分别登录 `H1`,两端 dashboard 均保持有效
|
||||
- 当前线上 `/changelog` 仍返回旧前端构建,待部署最新版本后需要复测该页面
|
||||
|
||||
### 仓库版本
|
||||
|
||||
- `a9ea94f`
|
||||
|
||||
## v3.0.0 (2026-03-14)
|
||||
|
||||
### 新增功能
|
||||
|
||||
- **训练视频教程库**:分类浏览(正手/反手/发球/截击/脚步/体能),含要点说明和常见错误
|
||||
- **教程自评系统**:星级自评、学习笔记、已学标记、学习进度追踪
|
||||
- **训练提醒通知**:支持训练/打卡/分析多类型提醒,自定义时间和重复日期
|
||||
- **浏览器通知推送**:Notification API集成,权限管理和状态提示
|
||||
- **通知记录管理**:未读计数、全部标记已读、历史记录浏览
|
||||
- **文案调整**:去除冗余描述,简化为直接信息反馈
|
||||
|
||||
### 数据库变更
|
||||
|
||||
- 新增 `tutorial_videos` 表(教程视频库)
|
||||
- 新增 `tutorial_progress` 表(学习进度追踪)
|
||||
- 新增 `training_reminders` 表(训练提醒设置)
|
||||
- 新增 `notification_log` 表(通知记录)
|
||||
|
||||
### 测试
|
||||
|
||||
- 测试用例从47个增加到65个
|
||||
- 新增教程库、提醒、通知相关测试
|
||||
|
||||
---
|
||||
|
||||
## v2.0.0 (2026-03-14)
|
||||
|
||||
### 新增功能
|
||||
|
||||
- **社区排行榜**:支持按NTRP评分、训练时长、训练次数、击球数四种维度排名
|
||||
- **每日打卡系统**:日历视图展示打卡记录,自动计算连续打卡天数
|
||||
- **成就徽章系统**:24种成就徽章,涵盖里程碑、训练、连续打卡、视频、分析、评分6个类别
|
||||
- **实时摄像头分析**:支持手机/电脑摄像头实时捕捉和MediaPipe姿势分析
|
||||
- **摄像头位置确认提示**:引导用户调整摄像头位置
|
||||
- **在线录制系统**:稳定压缩流录制,自适应码率1-2.5Mbps
|
||||
- **断线自动重连**:摄像头意外断开时自动检测并重新连接
|
||||
- **自动剪辑功能**:基于运动检测自动标记关键时刻
|
||||
- **移动端适配**:安全区域、触摸优化、横屏支持
|
||||
- **手机摄像头优化**:前后摄像头切换、自适应分辨率
|
||||
|
||||
### 数据库变更
|
||||
|
||||
- 新增 `daily_checkins` 表
|
||||
- 新增 `user_badges` 表
|
||||
- `users` 表新增 `currentStreak`、`longestStreak`、`totalShots` 字段
|
||||
|
||||
### 测试
|
||||
|
||||
- 测试用例从15个增加到47个
|
||||
- 新增打卡、徽章、排行榜相关测试
|
||||
|
||||
### 文档
|
||||
|
||||
- 新增 README.md
|
||||
- 新增API接口文档
|
||||
- 新增数据库设计文档
|
||||
- 新增功能列表清单
|
||||
- 新增代码规范文档
|
||||
- 新增变更日志
|
||||
|
||||
---
|
||||
|
||||
## v1.0.0 (2026-03-14)
|
||||
|
||||
### 初始版本
|
||||
|
||||
- 用户名简单登录系统
|
||||
- AI训练计划生成(初/中/高级)
|
||||
- 视频上传功能(webm/mp4)
|
||||
- MediaPipe浏览器端姿势识别
|
||||
- 姿势矫正建议系统(AI生成)
|
||||
- 训练计划自动调整
|
||||
- NTRP自动评分系统(五维度加权)
|
||||
- 训练进度追踪(可视化图表)
|
||||
- 视频库管理
|
||||
- 击球次数统计
|
||||
- 挥拍速度估算
|
||||
- 运动轨迹可视化
|
||||
- 帧级别关键时刻标注
|
||||
- 球员统计面板
|
||||
|
||||
### 数据库
|
||||
|
||||
- 初始9张表设计
|
||||
- Drizzle ORM集成
|
||||
- 3次数据库迁移
|
||||
|
||||
### 测试
|
||||
|
||||
- 15个核心功能测试
|
||||
106
docs/CODING_STANDARDS.md
普通文件
@@ -0,0 +1,106 @@
|
||||
# Tennis Training Hub - 代码规范文档
|
||||
|
||||
## 项目约定
|
||||
|
||||
### 技术栈版本
|
||||
|
||||
| 技术 | 版本 | 用途 |
|
||||
|------|------|------|
|
||||
| React | 19 | UI框架 |
|
||||
| TypeScript | 5.9 | 类型安全 |
|
||||
| Tailwind CSS | 4 | 样式系统 |
|
||||
| tRPC | 11 | 端到端类型安全API |
|
||||
| Drizzle ORM | 0.44 | 数据库ORM |
|
||||
| Vitest | 2.1 | 测试框架 |
|
||||
| Express | 4 | HTTP服务器 |
|
||||
|
||||
### 文件命名规范
|
||||
|
||||
| 类型 | 规范 | 示例 |
|
||||
|------|------|------|
|
||||
| React页面 | PascalCase | `Dashboard.tsx`, `LiveCamera.tsx` |
|
||||
| React组件 | PascalCase | `DashboardLayout.tsx` |
|
||||
| 工具函数 | camelCase | `db.ts`, `storage.ts` |
|
||||
| 测试文件 | `*.test.ts` | `features.test.ts` |
|
||||
| 数据库迁移 | 自动生成 | `0001_public_prowler.sql` |
|
||||
| 文档 | UPPER_CASE.md | `API.md`, `DATABASE.md` |
|
||||
|
||||
### 代码风格
|
||||
|
||||
**TypeScript/React:**
|
||||
|
||||
- 使用函数组件和Hooks,不使用类组件
|
||||
- 使用 `const` 优先,必要时使用 `let`,禁止 `var`
|
||||
- 使用箭头函数作为回调
|
||||
- 使用模板字符串而非字符串拼接
|
||||
- 使用可选链 `?.` 和空值合并 `??`
|
||||
- 导出组件使用 `export default function ComponentName()`
|
||||
- 类型定义使用 `type` 而非 `interface`(除非需要继承)
|
||||
|
||||
**CSS/Tailwind:**
|
||||
|
||||
- 优先使用Tailwind工具类
|
||||
- 颜色使用OKLCH格式(Tailwind 4要求)
|
||||
- 响应式设计使用移动优先策略(`sm:`, `md:`, `lg:`)
|
||||
- 语义化颜色变量定义在 `index.css` 的 `:root` 中
|
||||
- 避免内联样式,除非动态计算值
|
||||
|
||||
**数据库:**
|
||||
|
||||
- 字段使用camelCase命名
|
||||
- 主键统一使用 `id: int().autoincrement().primaryKey()`
|
||||
- 时间字段使用 `timestamp` 类型
|
||||
- JSON字段用于存储结构化但不需要索引的数据
|
||||
- 所有表包含 `createdAt` 字段
|
||||
|
||||
### tRPC路由规范
|
||||
|
||||
```typescript
|
||||
// 公开接口使用 publicProcedure
|
||||
publicProcedure.query(...)
|
||||
|
||||
// 需要认证的接口使用 protectedProcedure
|
||||
protectedProcedure.query(...)
|
||||
protectedProcedure.mutation(...)
|
||||
|
||||
// 输入验证使用 Zod
|
||||
.input(z.object({
|
||||
field: z.string().min(1).max(64),
|
||||
optional: z.number().optional(),
|
||||
}))
|
||||
```
|
||||
|
||||
### 测试规范
|
||||
|
||||
- 每个API路由至少有一个认证测试
|
||||
- 输入验证测试覆盖边界值
|
||||
- 使用 `createMockContext()` 创建测试上下文
|
||||
- 数据库操作在测试中允许抛出连接错误,但输入验证不应失败
|
||||
- 测试文件放在 `server/` 目录下
|
||||
|
||||
### Git提交规范
|
||||
|
||||
| 前缀 | 用途 | 示例 |
|
||||
|------|------|------|
|
||||
| `feat:` | 新功能 | `feat: 添加排行榜功能` |
|
||||
| `fix:` | 修复 | `fix: 修复打卡连续天数计算` |
|
||||
| `docs:` | 文档 | `docs: 更新API文档` |
|
||||
| `test:` | 测试 | `test: 添加徽章系统测试` |
|
||||
| `refactor:` | 重构 | `refactor: 优化评分计算逻辑` |
|
||||
| `style:` | 样式 | `style: 调整移动端布局` |
|
||||
|
||||
### 安全规范
|
||||
|
||||
- 所有LLM调用必须在服务端执行
|
||||
- 文件上传通过服务端中转到S3
|
||||
- 用户输入使用Zod严格验证
|
||||
- Session使用HttpOnly Cookie
|
||||
- 敏感操作使用 `protectedProcedure`
|
||||
|
||||
### 性能规范
|
||||
|
||||
- MediaPipe推理在浏览器端执行,不占用服务器资源
|
||||
- 视频文件存储在S3,不存入数据库
|
||||
- 使用tRPC的React Query缓存减少重复请求
|
||||
- 大列表查询使用 `limit` 参数分页
|
||||
- 图片和媒体资源使用CDN URL
|
||||
204
docs/DATABASE.md
普通文件
@@ -0,0 +1,204 @@
|
||||
# Tennis Training Hub - 数据库设计文档
|
||||
|
||||
## 概述
|
||||
|
||||
本项目使用MySQL/TiDB数据库,通过Drizzle ORM进行数据访问。数据库包含9张核心表,支持用户管理、训练计划、视频分析、评分系统、打卡和徽章等功能。
|
||||
|
||||
## ER关系图
|
||||
|
||||
```
|
||||
users (1) ──── (N) username_accounts
|
||||
│
|
||||
├──── (N) training_plans
|
||||
│
|
||||
├──── (N) training_videos (1) ──── (1) pose_analyses
|
||||
│
|
||||
├──── (N) training_records
|
||||
│
|
||||
├──── (N) rating_history
|
||||
│
|
||||
├──── (N) daily_checkins
|
||||
│
|
||||
└──── (N) user_badges
|
||||
```
|
||||
|
||||
## 表结构详解
|
||||
|
||||
### 1. `users` - 用户表
|
||||
|
||||
核心用户表,支持OAuth和用户名两种登录方式。
|
||||
|
||||
| 字段 | 类型 | 约束 | 说明 |
|
||||
|------|------|------|------|
|
||||
| id | INT | PK, AUTO_INCREMENT | 主键 |
|
||||
| openId | VARCHAR(64) | UNIQUE, NOT NULL | OAuth标识符 |
|
||||
| name | TEXT | - | 用户名 |
|
||||
| email | VARCHAR(320) | - | 邮箱 |
|
||||
| loginMethod | VARCHAR(64) | - | 登录方式 |
|
||||
| role | ENUM('user','admin') | DEFAULT 'user' | 角色 |
|
||||
| skillLevel | ENUM('beginner','intermediate','advanced') | DEFAULT 'beginner' | 技能水平 |
|
||||
| trainingGoals | TEXT | - | 训练目标 |
|
||||
| ntrpRating | FLOAT | DEFAULT 1.5 | NTRP评分 |
|
||||
| totalSessions | INT | DEFAULT 0 | 总训练次数 |
|
||||
| totalMinutes | INT | DEFAULT 0 | 总训练分钟 |
|
||||
| currentStreak | INT | DEFAULT 0 | 当前连续打卡天数 |
|
||||
| longestStreak | INT | DEFAULT 0 | 最长连续打卡天数 |
|
||||
| totalShots | INT | DEFAULT 0 | 总击球数 |
|
||||
| createdAt | TIMESTAMP | DEFAULT NOW | 创建时间 |
|
||||
| updatedAt | TIMESTAMP | ON UPDATE NOW | 更新时间 |
|
||||
| lastSignedIn | TIMESTAMP | DEFAULT NOW | 最后登录 |
|
||||
|
||||
### 2. `username_accounts` - 用户名账户表
|
||||
|
||||
简单用户名登录的映射表,将用户名映射到users表的用户。
|
||||
|
||||
| 字段 | 类型 | 约束 | 说明 |
|
||||
|------|------|------|------|
|
||||
| id | INT | PK, AUTO_INCREMENT | 主键 |
|
||||
| username | VARCHAR(64) | UNIQUE, NOT NULL | 用户名 |
|
||||
| userId | INT | NOT NULL | 关联用户ID |
|
||||
| createdAt | TIMESTAMP | DEFAULT NOW | 创建时间 |
|
||||
|
||||
### 3. `training_plans` - 训练计划表
|
||||
|
||||
存储AI生成的训练计划,支持版本追踪和自动调整。
|
||||
|
||||
| 字段 | 类型 | 约束 | 说明 |
|
||||
|------|------|------|------|
|
||||
| id | INT | PK, AUTO_INCREMENT | 主键 |
|
||||
| userId | INT | NOT NULL | 用户ID |
|
||||
| title | VARCHAR(256) | NOT NULL | 计划标题 |
|
||||
| skillLevel | ENUM | NOT NULL | 技能水平 |
|
||||
| durationDays | INT | DEFAULT 7 | 计划天数 |
|
||||
| exercises | JSON | NOT NULL | 训练内容(JSON数组) |
|
||||
| isActive | INT | DEFAULT 1 | 是否激活 |
|
||||
| adjustmentNotes | TEXT | - | AI调整说明 |
|
||||
| version | INT | DEFAULT 1 | 版本号 |
|
||||
| createdAt | TIMESTAMP | DEFAULT NOW | 创建时间 |
|
||||
| updatedAt | TIMESTAMP | ON UPDATE NOW | 更新时间 |
|
||||
|
||||
**exercises JSON结构:**
|
||||
```json
|
||||
[
|
||||
{
|
||||
"day": 1,
|
||||
"name": "正手影子挥拍",
|
||||
"category": "挥拍练习",
|
||||
"duration": 15,
|
||||
"description": "...",
|
||||
"tips": "...",
|
||||
"sets": 3,
|
||||
"reps": 20
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### 4. `training_videos` - 训练视频表
|
||||
|
||||
| 字段 | 类型 | 约束 | 说明 |
|
||||
|------|------|------|------|
|
||||
| id | INT | PK, AUTO_INCREMENT | 主键 |
|
||||
| userId | INT | NOT NULL | 用户ID |
|
||||
| title | VARCHAR(256) | NOT NULL | 视频标题 |
|
||||
| fileKey | VARCHAR(512) | NOT NULL | S3文件键 |
|
||||
| url | TEXT | NOT NULL | CDN访问URL |
|
||||
| format | VARCHAR(16) | NOT NULL | 格式(webm/mp4) |
|
||||
| fileSize | INT | - | 文件大小(字节) |
|
||||
| duration | FLOAT | - | 时长(秒) |
|
||||
| exerciseType | VARCHAR(64) | - | 动作类型 |
|
||||
| analysisStatus | ENUM | DEFAULT 'pending' | 分析状态 |
|
||||
| createdAt | TIMESTAMP | DEFAULT NOW | 创建时间 |
|
||||
| updatedAt | TIMESTAMP | ON UPDATE NOW | 更新时间 |
|
||||
|
||||
### 5. `pose_analyses` - 姿势分析表
|
||||
|
||||
参考tennis_analysis项目设计,包含击球统计、挥拍速度、运动轨迹等高级分析字段。
|
||||
|
||||
| 字段 | 类型 | 约束 | 说明 |
|
||||
|------|------|------|------|
|
||||
| id | INT | PK, AUTO_INCREMENT | 主键 |
|
||||
| videoId | INT | NOT NULL | 视频ID |
|
||||
| userId | INT | NOT NULL | 用户ID |
|
||||
| overallScore | FLOAT | - | 总体评分(0-100) |
|
||||
| poseMetrics | JSON | - | 关节角度详细指标 |
|
||||
| detectedIssues | JSON | - | 检测到的问题 |
|
||||
| corrections | JSON | - | 矫正建议 |
|
||||
| exerciseType | VARCHAR(64) | - | 动作类型 |
|
||||
| framesAnalyzed | INT | - | 分析帧数 |
|
||||
| shotCount | INT | DEFAULT 0 | 击球次数 |
|
||||
| avgSwingSpeed | FLOAT | - | 平均挥拍速度 |
|
||||
| maxSwingSpeed | FLOAT | - | 最大挥拍速度 |
|
||||
| totalMovementDistance | FLOAT | - | 总移动距离 |
|
||||
| strokeConsistency | FLOAT | - | 击球一致性(0-100) |
|
||||
| footworkScore | FLOAT | - | 脚步评分(0-100) |
|
||||
| fluidityScore | FLOAT | - | 流畅性评分(0-100) |
|
||||
| keyMoments | JSON | - | 关键时刻标记 |
|
||||
| movementTrajectory | JSON | - | 运动轨迹数据 |
|
||||
| createdAt | TIMESTAMP | DEFAULT NOW | 创建时间 |
|
||||
|
||||
### 6. `training_records` - 训练记录表
|
||||
|
||||
| 字段 | 类型 | 约束 | 说明 |
|
||||
|------|------|------|------|
|
||||
| id | INT | PK, AUTO_INCREMENT | 主键 |
|
||||
| userId | INT | NOT NULL | 用户ID |
|
||||
| planId | INT | - | 关联计划ID |
|
||||
| exerciseName | VARCHAR(128) | NOT NULL | 训练名称 |
|
||||
| durationMinutes | INT | - | 时长(分钟) |
|
||||
| completed | INT | DEFAULT 0 | 是否完成 |
|
||||
| notes | TEXT | - | 备注 |
|
||||
| poseScore | FLOAT | - | 姿势评分 |
|
||||
| trainingDate | TIMESTAMP | DEFAULT NOW | 训练日期 |
|
||||
| createdAt | TIMESTAMP | DEFAULT NOW | 创建时间 |
|
||||
|
||||
### 7. `rating_history` - 评分历史表
|
||||
|
||||
| 字段 | 类型 | 约束 | 说明 |
|
||||
|------|------|------|------|
|
||||
| id | INT | PK, AUTO_INCREMENT | 主键 |
|
||||
| userId | INT | NOT NULL | 用户ID |
|
||||
| rating | FLOAT | NOT NULL | NTRP评分 |
|
||||
| reason | VARCHAR(256) | - | 评分原因 |
|
||||
| dimensionScores | JSON | - | 五维度分数明细 |
|
||||
| analysisId | INT | - | 关联分析ID |
|
||||
| createdAt | TIMESTAMP | DEFAULT NOW | 创建时间 |
|
||||
|
||||
**dimensionScores JSON结构:**
|
||||
```json
|
||||
{
|
||||
"poseAccuracy": 75.5,
|
||||
"strokeConsistency": 68.2,
|
||||
"footwork": 72.0,
|
||||
"fluidity": 65.8,
|
||||
"power": 58.3
|
||||
}
|
||||
```
|
||||
|
||||
### 8. `daily_checkins` - 每日打卡表
|
||||
|
||||
| 字段 | 类型 | 约束 | 说明 |
|
||||
|------|------|------|------|
|
||||
| id | INT | PK, AUTO_INCREMENT | 主键 |
|
||||
| userId | INT | NOT NULL | 用户ID |
|
||||
| checkinDate | VARCHAR(10) | NOT NULL | 日期(YYYY-MM-DD) |
|
||||
| streakCount | INT | DEFAULT 1 | 当时连续天数 |
|
||||
| notes | TEXT | - | 打卡备注 |
|
||||
| minutesTrained | INT | DEFAULT 0 | 当日训练分钟 |
|
||||
| createdAt | TIMESTAMP | DEFAULT NOW | 创建时间 |
|
||||
|
||||
### 9. `user_badges` - 用户徽章表
|
||||
|
||||
| 字段 | 类型 | 约束 | 说明 |
|
||||
|------|------|------|------|
|
||||
| id | INT | PK, AUTO_INCREMENT | 主键 |
|
||||
| userId | INT | NOT NULL | 用户ID |
|
||||
| badgeKey | VARCHAR(64) | NOT NULL | 徽章标识键 |
|
||||
| earnedAt | TIMESTAMP | DEFAULT NOW | 获得时间 |
|
||||
|
||||
## 迁移历史
|
||||
|
||||
| 版本 | 文件 | 内容 |
|
||||
|------|------|------|
|
||||
| 0001 | `0001_public_prowler.sql` | 初始表创建(users扩展、username_accounts、training_plans、training_videos、pose_analyses、training_records、rating_history) |
|
||||
| 0002 | `0002_overrated_shriek.sql` | 添加totalShots字段 |
|
||||
| 0003 | `0003_married_iron_lad.sql` | 添加daily_checkins和user_badges表、用户streak字段 |
|
||||
121
docs/FEATURES.md
普通文件
@@ -0,0 +1,121 @@
|
||||
# Tennis Training Hub 功能特性说明
|
||||
|
||||
本文档描述当前项目的核心能力、已交付功能边界和后续增强方向。它和 `docs/verified-features.md` 配套使用:
|
||||
|
||||
- 本文档回答“系统现在具备什么能力”
|
||||
- `verified-features.md` 回答“哪些能力已经通过自动测试或构建验证”
|
||||
|
||||
## 核心业务能力
|
||||
|
||||
### 用户与训练
|
||||
|
||||
- 用户名登录:无需注册,输入用户名即可进入训练工作台
|
||||
- 新用户邀请:首次创建用户名账号需要邀请码 `CA2026`
|
||||
- 训练计划:按技能等级和训练周期生成训练计划,改为后台异步生成
|
||||
- 训练进度:展示训练次数、时长、评分趋势、最近分析结果
|
||||
- 成就系统与提醒:训练日聚合、成就进度、连练统计、提醒、通知记录
|
||||
|
||||
### 视频与分析
|
||||
|
||||
- 视频上传分析:上传 `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 轻剪辑:视频库内可直接打开轻剪辑工作台,支持预览、设定入点/出点、建议片段和草稿导出
|
||||
|
||||
### 在线录制与媒体链路
|
||||
|
||||
- Go 媒体服务:独立处理录制会话、分段上传、marker、归档和回放资源
|
||||
- Node app worker:统一处理训练计划、动作纠正和录制归档结果登记
|
||||
- WebRTC 推流:录制时并行建立低延迟实时推流链路
|
||||
- MediaRecorder 分段:浏览器本地压缩录制并每 60 秒自动分段上传
|
||||
- 自动标记:客户端通过轻量运动检测创建关键片段 marker
|
||||
- 手动标记:录制中支持手动插入剪辑点
|
||||
- 自动重连:摄像头 track 断开时自动尝试恢复
|
||||
- 归档回放:worker 合并片段并生成 WebM,FFmpeg 可用时额外生成 MP4
|
||||
- 归档状态可视化:录制页在“合并分段 / 生成回放”阶段显示任务进度、已上传体积、待上传体积和片段总数
|
||||
- 视频库登记:归档完成后由 app worker 自动写回现有视频库
|
||||
- 上传稳定性:媒体分段上传遇到 `502/503/504` 会自动重试
|
||||
|
||||
### 评分、成就与管理
|
||||
|
||||
- 每日异步 NTRP:系统会在每日零点后自动排队全量 NTRP 刷新任务
|
||||
- 用户手动刷新:普通用户可刷新自己的 NTRP;管理员可刷新任意用户或全量用户
|
||||
- NTRP 快照:每次刷新都会生成可追踪的快照,保存维度评分和数据来源摘要
|
||||
- 成就定义表:成就系统已独立于旧徽章表,支持大规模扩展、分层、隐藏成就与分类
|
||||
- 管理系统:`/admin` 提供用户管理、任务列表、实时分析会话列表、应用设置和审计日志
|
||||
- H1 管理能力:当 `H1` 被配置为 admin 后,可查看全部视觉测试数据与后台管理数据
|
||||
|
||||
## 前端能力
|
||||
|
||||
### 移动端
|
||||
|
||||
- 安全区适配
|
||||
- 底部导航
|
||||
- 44px 触控热区
|
||||
- 横屏视频优先布局
|
||||
- 录制页和分析页防下拉刷新干扰
|
||||
- 录制时按设备场景自动调整码率和控件密度
|
||||
- 实时分析页支持竖屏最大化预览,主要操作按钮放在侧边
|
||||
|
||||
### 桌面端
|
||||
|
||||
- 统一工作台导航
|
||||
- 仪表盘、训练、视频、录制、分析等模块一致的布局结构
|
||||
- 全局任务中心:桌面侧边栏和移动端头部都可查看后台任务
|
||||
- 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 录制不在当前目标内
|
||||
240
docs/runtime-operations.md
普通文件
@@ -0,0 +1,240 @@
|
||||
# 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 中执行
|
||||
|
||||
### 1.4 media-worker 会话刷新
|
||||
|
||||
`media-worker` 现在每轮都会从磁盘重新读取 `/data/media/sessions/*/session.json`。
|
||||
|
||||
这样做是为了避免独立 worker 只在启动时加载一次会话,导致:
|
||||
|
||||
- 新创建的录制会话虽然已经写盘,但 worker 看不到
|
||||
- `archiveStatus = queued` 的会话长期卡住
|
||||
- `media_finalize` 任务反复重试直到耗尽次数
|
||||
|
||||
如果线上出现“归档一直 queued,但 media-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
|
||||
```
|
||||
|
||||
如问题涉及训练计划超时或录制归档卡住,优先使用全链路重建,而不是只重启单个容器:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build migrate app app-worker media media-worker
|
||||
```
|
||||
|
||||
重启后建议额外确认:
|
||||
|
||||
- `docker compose logs --tail=120 app-worker` 中不再出现旧的 `Request timed out after 45000ms`
|
||||
- `docker compose logs --tail=120 media-worker` 中 worker 已正常启动
|
||||
- 容器内 `dist/worker.js` 已包含新的训练计划超时/自动重试逻辑
|
||||
|
||||
## 6.1 损坏 media 会话处理
|
||||
|
||||
如果 `media-data` 中出现 `0` 字节或无法解析的 `session.json`,不要继续保留在 `sessions/*` 主目录。
|
||||
|
||||
建议处理方式:
|
||||
|
||||
1. 将损坏会话目录移动到隔离目录,例如 `sessions_broken/<session_id>`
|
||||
2. 保留原始 `segments/*` 作为排障材料
|
||||
3. 不直接猜测补写 `session.json`
|
||||
|
||||
原因:
|
||||
|
||||
- 空 `session.json` 往往意味着写盘被中断
|
||||
- 这类会话即使继续重试,也无法可靠恢复业务语义
|
||||
- 将其移出主扫描路径可以避免后续 worker 误处理
|
||||
|
||||
## 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 `daily_checkins` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`userId` int NOT NULL,
|
||||
`checkinDate` varchar(10) NOT NULL,
|
||||
`streakCount` int NOT NULL DEFAULT 1,
|
||||
`notes` text,
|
||||
`minutesTrained` int DEFAULT 0,
|
||||
`createdAt` timestamp NOT NULL DEFAULT (now()),
|
||||
CONSTRAINT `daily_checkins_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `user_badges` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`userId` int NOT NULL,
|
||||
`badgeKey` varchar(64) NOT NULL,
|
||||
`earnedAt` timestamp NOT NULL DEFAULT (now()),
|
||||
CONSTRAINT `user_badges_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `users` ADD `currentStreak` int DEFAULT 0;--> statement-breakpoint
|
||||
ALTER TABLE `users` ADD `longestStreak` int DEFAULT 0;--> statement-breakpoint
|
||||
ALTER TABLE `users` ADD `totalShots` int DEFAULT 0;
|
||||
57
drizzle/0004_exotic_randall.sql
普通文件
@@ -0,0 +1,57 @@
|
||||
CREATE TABLE `notification_log` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`userId` int NOT NULL,
|
||||
`reminderId` int,
|
||||
`notificationType` varchar(32) NOT NULL,
|
||||
`title` varchar(256) NOT NULL,
|
||||
`message` text,
|
||||
`isRead` int DEFAULT 0,
|
||||
`createdAt` timestamp NOT NULL DEFAULT (now()),
|
||||
CONSTRAINT `notification_log_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `training_reminders` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`userId` int NOT NULL,
|
||||
`reminderType` varchar(32) NOT NULL,
|
||||
`title` varchar(256) NOT NULL,
|
||||
`message` text,
|
||||
`timeOfDay` varchar(5) NOT NULL,
|
||||
`daysOfWeek` json NOT NULL,
|
||||
`isActive` int DEFAULT 1,
|
||||
`lastTriggered` timestamp,
|
||||
`createdAt` timestamp NOT NULL DEFAULT (now()),
|
||||
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `training_reminders_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `tutorial_progress` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`userId` int NOT NULL,
|
||||
`tutorialId` int NOT NULL,
|
||||
`watched` int DEFAULT 0,
|
||||
`comparisonVideoId` int,
|
||||
`selfScore` float,
|
||||
`notes` text,
|
||||
`createdAt` timestamp NOT NULL DEFAULT (now()),
|
||||
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `tutorial_progress_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `tutorial_videos` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`title` varchar(256) NOT NULL,
|
||||
`category` varchar(64) NOT NULL,
|
||||
`skillLevel` enum('beginner','intermediate','advanced') DEFAULT 'beginner',
|
||||
`description` text,
|
||||
`keyPoints` json,
|
||||
`commonMistakes` json,
|
||||
`videoUrl` text,
|
||||
`thumbnailUrl` text,
|
||||
`duration` int,
|
||||
`sortOrder` int DEFAULT 0,
|
||||
`isPublished` int DEFAULT 1,
|
||||
`createdAt` timestamp NOT NULL DEFAULT (now()),
|
||||
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `tutorial_videos_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
@@ -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,39 @@
|
||||
ALTER TABLE `tutorial_videos`
|
||||
ADD `slug` varchar(128),
|
||||
ADD `topicArea` varchar(32) DEFAULT 'tennis_skill',
|
||||
ADD `contentFormat` varchar(16) DEFAULT 'video',
|
||||
ADD `sourcePlatform` varchar(16) DEFAULT 'none',
|
||||
ADD `heroSummary` text,
|
||||
ADD `externalUrl` text,
|
||||
ADD `platformVideoId` varchar(64),
|
||||
ADD `estimatedEffortMinutes` int,
|
||||
ADD `prerequisites` json,
|
||||
ADD `learningObjectives` json,
|
||||
ADD `stepSections` json,
|
||||
ADD `deliverables` json,
|
||||
ADD `relatedDocPaths` json,
|
||||
ADD `viewCount` int,
|
||||
ADD `commentCount` int,
|
||||
ADD `metricsFetchedAt` timestamp NULL,
|
||||
ADD `completionAchievementKey` varchar(64),
|
||||
ADD `isFeatured` int DEFAULT 0,
|
||||
ADD `featuredOrder` int DEFAULT 0;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `tutorial_progress`
|
||||
ADD `completed` int DEFAULT 0,
|
||||
ADD `completedAt` timestamp NULL;
|
||||
--> statement-breakpoint
|
||||
UPDATE `tutorial_videos`
|
||||
SET
|
||||
`topicArea` = COALESCE(`topicArea`, 'tennis_skill'),
|
||||
`contentFormat` = COALESCE(`contentFormat`, 'video'),
|
||||
`sourcePlatform` = COALESCE(`sourcePlatform`, 'none'),
|
||||
`heroSummary` = COALESCE(`heroSummary`, `description`),
|
||||
`estimatedEffortMinutes` = COALESCE(`estimatedEffortMinutes`, CASE WHEN `duration` IS NULL THEN NULL ELSE ROUND(`duration` / 60) END),
|
||||
`isFeatured` = COALESCE(`isFeatured`, 0),
|
||||
`featuredOrder` = COALESCE(`featuredOrder`, 0);
|
||||
--> statement-breakpoint
|
||||
UPDATE `tutorial_progress`
|
||||
SET
|
||||
`completed` = CASE WHEN `watched` = 1 THEN 1 ELSE COALESCE(`completed`, 0) END,
|
||||
`completedAt` = CASE WHEN `watched` = 1 AND `completedAt` IS NULL THEN `updatedAt` ELSE `completedAt` END;
|
||||
@@ -0,0 +1,14 @@
|
||||
ALTER TABLE `users`
|
||||
ADD `manualNtrpRating` float,
|
||||
ADD `manualNtrpCapturedAt` timestamp NULL,
|
||||
ADD `heightCm` float,
|
||||
ADD `weightKg` float,
|
||||
ADD `sprintSpeedScore` int,
|
||||
ADD `explosivePowerScore` int,
|
||||
ADD `agilityScore` int,
|
||||
ADD `enduranceScore` int,
|
||||
ADD `flexibilityScore` int,
|
||||
ADD `coreStabilityScore` int,
|
||||
ADD `shoulderMobilityScore` int,
|
||||
ADD `hipMobilityScore` int,
|
||||
ADD `assessmentNotes` text;
|
||||
@@ -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`)
|
||||
);
|
||||
86
drizzle/0012_market_watch.sql
普通文件
@@ -0,0 +1,86 @@
|
||||
CREATE TABLE `racket_listings` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`source` enum('xianyu','jd','zhuanzhuan') NOT NULL,
|
||||
`sourceListingId` varchar(128) NOT NULL,
|
||||
`title` varchar(512) NOT NULL,
|
||||
`description` text,
|
||||
`listingUrl` text NOT NULL,
|
||||
`imageUrl` text,
|
||||
`price` float NOT NULL,
|
||||
`originalPrice` float,
|
||||
`sellerName` varchar(128),
|
||||
`location` varchar(128),
|
||||
`publishedAtRaw` varchar(128),
|
||||
`brand` varchar(64),
|
||||
`model` varchar(128),
|
||||
`series` varchar(128),
|
||||
`category` varchar(64),
|
||||
`weightGram` float,
|
||||
`conditionLevel` enum('brand_new','almost_new','used_good','used_fair','unknown') NOT NULL DEFAULT 'unknown',
|
||||
`gradeLevel` enum('high_value','standard','overpriced','pending_review') NOT NULL DEFAULT 'pending_review',
|
||||
`gradeReason` text,
|
||||
`isLowPriceCandidate` int NOT NULL DEFAULT 0,
|
||||
`fingerprint` varchar(128) NOT NULL,
|
||||
`extra` json,
|
||||
`fetchedAt` timestamp NOT NULL DEFAULT (now()),
|
||||
`createdAt` timestamp NOT NULL DEFAULT (now()),
|
||||
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `racket_listings_id` PRIMARY KEY(`id`),
|
||||
CONSTRAINT `racket_listings_source_listing_idx` UNIQUE(`source`,`sourceListingId`),
|
||||
CONSTRAINT `racket_listings_fingerprint_idx` UNIQUE(`fingerprint`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
|
||||
CREATE TABLE `racket_watch_rules` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`userId` int NOT NULL,
|
||||
`title` varchar(256) NOT NULL,
|
||||
`brand` varchar(64) NOT NULL,
|
||||
`modelKeyword` varchar(128),
|
||||
`seriesKeyword` varchar(128),
|
||||
`category` varchar(64),
|
||||
`weightMinGram` float,
|
||||
`weightMaxGram` float,
|
||||
`targetPrice` float NOT NULL,
|
||||
`pushEnabled` int NOT NULL DEFAULT 1,
|
||||
`isActive` int NOT NULL DEFAULT 1,
|
||||
`lastCheckedAt` timestamp,
|
||||
`lastMatchedAt` timestamp,
|
||||
`createdAt` timestamp NOT NULL DEFAULT (now()),
|
||||
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `racket_watch_rules_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
|
||||
CREATE TABLE `racket_watch_hits` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`watchRuleId` int NOT NULL,
|
||||
`userId` int NOT NULL,
|
||||
`listingId` int NOT NULL,
|
||||
`matchedPrice` float NOT NULL,
|
||||
`status` enum('matched','push_queued','pushed','suppressed') NOT NULL DEFAULT 'matched',
|
||||
`firstMatchedAt` timestamp NOT NULL DEFAULT (now()),
|
||||
`lastMatchedAt` timestamp NOT NULL DEFAULT (now()),
|
||||
`lastPushPrice` float,
|
||||
`pushedAt` timestamp,
|
||||
`pushCount` int NOT NULL DEFAULT 0,
|
||||
`createdAt` timestamp NOT NULL DEFAULT (now()),
|
||||
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `racket_watch_hits_id` PRIMARY KEY(`id`),
|
||||
CONSTRAINT `racket_watch_hits_rule_listing_idx` UNIQUE(`watchRuleId`,`listingId`)
|
||||
);
|
||||
--> 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',
|
||||
'market_source_sync',
|
||||
'market_watch_refresh',
|
||||
'market_push_delivery'
|
||||
) NOT NULL;
|
||||
90
drizzle/0013_match_hub.sql
普通文件
@@ -0,0 +1,90 @@
|
||||
CREATE TABLE `match_sessions` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`createdByUserId` int NOT NULL,
|
||||
`matchMode` enum('daily','competitive') NOT NULL DEFAULT 'daily',
|
||||
`workflowStatus` enum('draft','recording','review_pending','reviewed','finalizing','finalized','cancelled') NOT NULL DEFAULT 'draft',
|
||||
`title` varchar(256) NOT NULL,
|
||||
`courtName` varchar(128),
|
||||
`notes` text,
|
||||
`durationMinutes` int NOT NULL DEFAULT 90,
|
||||
`scheduledAt` timestamp,
|
||||
`startedAt` timestamp,
|
||||
`endedAt` timestamp,
|
||||
`suggestionStatus` enum('idle','queued','ready','failed') NOT NULL DEFAULT 'idle',
|
||||
`suggestionTaskId` varchar(64),
|
||||
`suggestedScore` json,
|
||||
`suggestedMetrics` json,
|
||||
`finalScore` json,
|
||||
`finalMetrics` json,
|
||||
`reviewNotes` text,
|
||||
`reviewSubmittedAt` timestamp,
|
||||
`reviewedByUserId` int,
|
||||
`reviewedAt` timestamp,
|
||||
`finalizedByUserId` int,
|
||||
`finalizedAt` timestamp,
|
||||
`createdAt` timestamp NOT NULL DEFAULT (now()),
|
||||
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `match_sessions_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
|
||||
CREATE TABLE `match_participants` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`matchId` int NOT NULL,
|
||||
`userId` int NOT NULL,
|
||||
`playerSlot` enum('player_a','player_b') NOT NULL,
|
||||
`cameraSlot` enum('camera_a','camera_b') NOT NULL,
|
||||
`cameraStatus` enum('pending','bound','active','completed','failed') NOT NULL DEFAULT 'pending',
|
||||
`cameraLabel` varchar(128),
|
||||
`cameraVideoId` int,
|
||||
`cameraVideoUrl` text,
|
||||
`cameraSnapshot` json,
|
||||
`isWinner` int NOT NULL DEFAULT 0,
|
||||
`suggestedSetsWon` int NOT NULL DEFAULT 0,
|
||||
`suggestedGamesWon` int NOT NULL DEFAULT 0,
|
||||
`suggestedPointsWon` int NOT NULL DEFAULT 0,
|
||||
`finalSetsWon` int NOT NULL DEFAULT 0,
|
||||
`finalGamesWon` int NOT NULL DEFAULT 0,
|
||||
`finalPointsWon` int NOT NULL DEFAULT 0,
|
||||
`suggestedStats` json,
|
||||
`finalStats` json,
|
||||
`createdAt` timestamp NOT NULL DEFAULT (now()),
|
||||
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `match_participants_id` PRIMARY KEY(`id`),
|
||||
CONSTRAINT `match_participants_match_player_idx` UNIQUE(`matchId`,`playerSlot`),
|
||||
CONSTRAINT `match_participants_match_user_idx` UNIQUE(`matchId`,`userId`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
|
||||
CREATE TABLE `match_score_events` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`matchId` int NOT NULL,
|
||||
`eventIndex` int NOT NULL,
|
||||
`source` enum('camera_a','camera_b','system','admin') NOT NULL DEFAULT 'system',
|
||||
`eventType` enum('point','game','set','metric','score_suggestion','review_adjustment','finalized') NOT NULL,
|
||||
`winnerSlot` enum('player_a','player_b'),
|
||||
`matchSecond` int,
|
||||
`confidence` float,
|
||||
`payload` json,
|
||||
`createdByUserId` int,
|
||||
`createdAt` timestamp NOT NULL DEFAULT (now()),
|
||||
CONSTRAINT `match_score_events_id` PRIMARY KEY(`id`),
|
||||
CONSTRAINT `match_score_events_match_event_idx` UNIQUE(`matchId`,`eventIndex`)
|
||||
);
|
||||
--> 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',
|
||||
'market_source_sync',
|
||||
'market_watch_refresh',
|
||||
'market_push_delivery',
|
||||
'match_score_suggest',
|
||||
'match_finalize'
|
||||
) NOT NULL;
|
||||
855
drizzle/meta/0003_snapshot.json
普通文件
@@ -0,0 +1,855 @@
|
||||
{
|
||||
"version": "5",
|
||||
"dialect": "mysql",
|
||||
"id": "0892fd57-f758-43a7-a72d-e372aca4d4e3",
|
||||
"prevId": "a9a3ce4f-a15b-4af1-b99f-d12a1644a83b",
|
||||
"tables": {
|
||||
"daily_checkins": {
|
||||
"name": "daily_checkins",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"userId": {
|
||||
"name": "userId",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"checkinDate": {
|
||||
"name": "checkinDate",
|
||||
"type": "varchar(10)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"streakCount": {
|
||||
"name": "streakCount",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 1
|
||||
},
|
||||
"notes": {
|
||||
"name": "notes",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"minutesTrained": {
|
||||
"name": "minutesTrained",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"daily_checkins_id": {
|
||||
"name": "daily_checkins_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"pose_analyses": {
|
||||
"name": "pose_analyses",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"videoId": {
|
||||
"name": "videoId",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"userId": {
|
||||
"name": "userId",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"overallScore": {
|
||||
"name": "overallScore",
|
||||
"type": "float",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"poseMetrics": {
|
||||
"name": "poseMetrics",
|
||||
"type": "json",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"detectedIssues": {
|
||||
"name": "detectedIssues",
|
||||
"type": "json",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"corrections": {
|
||||
"name": "corrections",
|
||||
"type": "json",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"exerciseType": {
|
||||
"name": "exerciseType",
|
||||
"type": "varchar(64)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"framesAnalyzed": {
|
||||
"name": "framesAnalyzed",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"shotCount": {
|
||||
"name": "shotCount",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"avgSwingSpeed": {
|
||||
"name": "avgSwingSpeed",
|
||||
"type": "float",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"maxSwingSpeed": {
|
||||
"name": "maxSwingSpeed",
|
||||
"type": "float",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"totalMovementDistance": {
|
||||
"name": "totalMovementDistance",
|
||||
"type": "float",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"strokeConsistency": {
|
||||
"name": "strokeConsistency",
|
||||
"type": "float",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"footworkScore": {
|
||||
"name": "footworkScore",
|
||||
"type": "float",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"fluidityScore": {
|
||||
"name": "fluidityScore",
|
||||
"type": "float",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"keyMoments": {
|
||||
"name": "keyMoments",
|
||||
"type": "json",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"movementTrajectory": {
|
||||
"name": "movementTrajectory",
|
||||
"type": "json",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"pose_analyses_id": {
|
||||
"name": "pose_analyses_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"rating_history": {
|
||||
"name": "rating_history",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"userId": {
|
||||
"name": "userId",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"rating": {
|
||||
"name": "rating",
|
||||
"type": "float",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"reason": {
|
||||
"name": "reason",
|
||||
"type": "varchar(256)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"dimensionScores": {
|
||||
"name": "dimensionScores",
|
||||
"type": "json",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"analysisId": {
|
||||
"name": "analysisId",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"rating_history_id": {
|
||||
"name": "rating_history_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"training_plans": {
|
||||
"name": "training_plans",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"userId": {
|
||||
"name": "userId",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "varchar(256)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"skillLevel": {
|
||||
"name": "skillLevel",
|
||||
"type": "enum('beginner','intermediate','advanced')",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"durationDays": {
|
||||
"name": "durationDays",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 7
|
||||
},
|
||||
"exercises": {
|
||||
"name": "exercises",
|
||||
"type": "json",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"isActive": {
|
||||
"name": "isActive",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 1
|
||||
},
|
||||
"adjustmentNotes": {
|
||||
"name": "adjustmentNotes",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"version": {
|
||||
"name": "version",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 1
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
},
|
||||
"updatedAt": {
|
||||
"name": "updatedAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"onUpdate": true,
|
||||
"default": "(now())"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"training_plans_id": {
|
||||
"name": "training_plans_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"training_records": {
|
||||
"name": "training_records",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"userId": {
|
||||
"name": "userId",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"planId": {
|
||||
"name": "planId",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"exerciseName": {
|
||||
"name": "exerciseName",
|
||||
"type": "varchar(128)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"durationMinutes": {
|
||||
"name": "durationMinutes",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"completed": {
|
||||
"name": "completed",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"notes": {
|
||||
"name": "notes",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"poseScore": {
|
||||
"name": "poseScore",
|
||||
"type": "float",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"trainingDate": {
|
||||
"name": "trainingDate",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"training_records_id": {
|
||||
"name": "training_records_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"training_videos": {
|
||||
"name": "training_videos",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"userId": {
|
||||
"name": "userId",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "varchar(256)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"fileKey": {
|
||||
"name": "fileKey",
|
||||
"type": "varchar(512)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"url": {
|
||||
"name": "url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"format": {
|
||||
"name": "format",
|
||||
"type": "varchar(16)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"fileSize": {
|
||||
"name": "fileSize",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"duration": {
|
||||
"name": "duration",
|
||||
"type": "float",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"exerciseType": {
|
||||
"name": "exerciseType",
|
||||
"type": "varchar(64)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"analysisStatus": {
|
||||
"name": "analysisStatus",
|
||||
"type": "enum('pending','analyzing','completed','failed')",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "'pending'"
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
},
|
||||
"updatedAt": {
|
||||
"name": "updatedAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"onUpdate": true,
|
||||
"default": "(now())"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"training_videos_id": {
|
||||
"name": "training_videos_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"user_badges": {
|
||||
"name": "user_badges",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"userId": {
|
||||
"name": "userId",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"badgeKey": {
|
||||
"name": "badgeKey",
|
||||
"type": "varchar(64)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"earnedAt": {
|
||||
"name": "earnedAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"user_badges_id": {
|
||||
"name": "user_badges_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"username_accounts": {
|
||||
"name": "username_accounts",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "varchar(64)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"userId": {
|
||||
"name": "userId",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"username_accounts_id": {
|
||||
"name": "username_accounts_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {
|
||||
"username_accounts_username_unique": {
|
||||
"name": "username_accounts_username_unique",
|
||||
"columns": [
|
||||
"username"
|
||||
]
|
||||
}
|
||||
},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"users": {
|
||||
"name": "users",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"openId": {
|
||||
"name": "openId",
|
||||
"type": "varchar(64)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "varchar(320)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"loginMethod": {
|
||||
"name": "loginMethod",
|
||||
"type": "varchar(64)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "enum('user','admin')",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'user'"
|
||||
},
|
||||
"skillLevel": {
|
||||
"name": "skillLevel",
|
||||
"type": "enum('beginner','intermediate','advanced')",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "'beginner'"
|
||||
},
|
||||
"trainingGoals": {
|
||||
"name": "trainingGoals",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"ntrpRating": {
|
||||
"name": "ntrpRating",
|
||||
"type": "float",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 1.5
|
||||
},
|
||||
"totalSessions": {
|
||||
"name": "totalSessions",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"totalMinutes": {
|
||||
"name": "totalMinutes",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"currentStreak": {
|
||||
"name": "currentStreak",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"longestStreak": {
|
||||
"name": "longestStreak",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"totalShots": {
|
||||
"name": "totalShots",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
},
|
||||
"updatedAt": {
|
||||
"name": "updatedAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"onUpdate": true,
|
||||
"default": "(now())"
|
||||
},
|
||||
"lastSignedIn": {
|
||||
"name": "lastSignedIn",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"users_id": {
|
||||
"name": "users_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {
|
||||
"users_openId_unique": {
|
||||
"name": "users_openId_unique",
|
||||
"columns": [
|
||||
"openId"
|
||||
]
|
||||
}
|
||||
},
|
||||
"checkConstraint": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"tables": {},
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
1233
drizzle/meta/0004_snapshot.json
普通文件
@@ -22,6 +22,83 @@
|
||||
"when": 1773487643444,
|
||||
"tag": "0002_overrated_shriek",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "5",
|
||||
"when": 1773488765349,
|
||||
"tag": "0003_married_iron_lad",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "5",
|
||||
"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
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "5",
|
||||
"when": 1773955200000,
|
||||
"tag": "0012_market_watch",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "5",
|
||||
"when": 1774569600000,
|
||||
"tag": "0013_match_hub",
|
||||
"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,10 +16,31 @@ 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 */
|
||||
totalMinutes: int("totalMinutes").default(0),
|
||||
/** Current consecutive check-in streak */
|
||||
currentStreak: int("currentStreak").default(0),
|
||||
/** Longest ever streak */
|
||||
longestStreak: int("longestStreak").default(0),
|
||||
/** Total shots across all analyses */
|
||||
totalShots: int("totalShots").default(0),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
lastSignedIn: timestamp("lastSignedIn").defaultNow().notNull(),
|
||||
@@ -146,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 */
|
||||
@@ -162,6 +195,207 @@ 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;
|
||||
|
||||
/**
|
||||
* Dual-player match sessions with admin-reviewed score settlement.
|
||||
*/
|
||||
export const matchSessions = mysqlTable("match_sessions", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
createdByUserId: int("createdByUserId").notNull(),
|
||||
matchMode: mysqlEnum("matchMode", ["daily", "competitive"]).default("daily").notNull(),
|
||||
workflowStatus: mysqlEnum("workflowStatus", ["draft", "recording", "review_pending", "reviewed", "finalizing", "finalized", "cancelled"]).default("draft").notNull(),
|
||||
title: varchar("title", { length: 256 }).notNull(),
|
||||
courtName: varchar("courtName", { length: 128 }),
|
||||
notes: text("notes"),
|
||||
durationMinutes: int("durationMinutes").default(90).notNull(),
|
||||
scheduledAt: timestamp("scheduledAt"),
|
||||
startedAt: timestamp("startedAt"),
|
||||
endedAt: timestamp("endedAt"),
|
||||
suggestionStatus: mysqlEnum("suggestionStatus", ["idle", "queued", "ready", "failed"]).default("idle").notNull(),
|
||||
suggestionTaskId: varchar("suggestionTaskId", { length: 64 }),
|
||||
suggestedScore: json("suggestedScore"),
|
||||
suggestedMetrics: json("suggestedMetrics"),
|
||||
finalScore: json("finalScore"),
|
||||
finalMetrics: json("finalMetrics"),
|
||||
reviewNotes: text("reviewNotes"),
|
||||
reviewSubmittedAt: timestamp("reviewSubmittedAt"),
|
||||
reviewedByUserId: int("reviewedByUserId"),
|
||||
reviewedAt: timestamp("reviewedAt"),
|
||||
finalizedByUserId: int("finalizedByUserId"),
|
||||
finalizedAt: timestamp("finalizedAt"),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
});
|
||||
|
||||
export type MatchSession = typeof matchSessions.$inferSelect;
|
||||
export type InsertMatchSession = typeof matchSessions.$inferInsert;
|
||||
|
||||
/**
|
||||
* Match-bound participants and their dedicated camera slot.
|
||||
*/
|
||||
export const matchParticipants = mysqlTable("match_participants", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
matchId: int("matchId").notNull(),
|
||||
userId: int("userId").notNull(),
|
||||
playerSlot: mysqlEnum("playerSlot", ["player_a", "player_b"]).notNull(),
|
||||
cameraSlot: mysqlEnum("cameraSlot", ["camera_a", "camera_b"]).notNull(),
|
||||
cameraStatus: mysqlEnum("cameraStatus", ["pending", "bound", "active", "completed", "failed"]).default("pending").notNull(),
|
||||
cameraLabel: varchar("cameraLabel", { length: 128 }),
|
||||
cameraVideoId: int("cameraVideoId"),
|
||||
cameraVideoUrl: text("cameraVideoUrl"),
|
||||
cameraSnapshot: json("cameraSnapshot"),
|
||||
isWinner: int("isWinner").default(0).notNull(),
|
||||
suggestedSetsWon: int("suggestedSetsWon").default(0).notNull(),
|
||||
suggestedGamesWon: int("suggestedGamesWon").default(0).notNull(),
|
||||
suggestedPointsWon: int("suggestedPointsWon").default(0).notNull(),
|
||||
finalSetsWon: int("finalSetsWon").default(0).notNull(),
|
||||
finalGamesWon: int("finalGamesWon").default(0).notNull(),
|
||||
finalPointsWon: int("finalPointsWon").default(0).notNull(),
|
||||
suggestedStats: json("suggestedStats"),
|
||||
finalStats: json("finalStats"),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
}, (table) => ({
|
||||
matchPlayerUnique: uniqueIndex("match_participants_match_player_idx").on(table.matchId, table.playerSlot),
|
||||
matchUserUnique: uniqueIndex("match_participants_match_user_idx").on(table.matchId, table.userId),
|
||||
}));
|
||||
|
||||
export type MatchParticipant = typeof matchParticipants.$inferSelect;
|
||||
export type InsertMatchParticipant = typeof matchParticipants.$inferInsert;
|
||||
|
||||
/**
|
||||
* Match score and metric events from camera automation or admin review.
|
||||
*/
|
||||
export const matchScoreEvents = mysqlTable("match_score_events", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
matchId: int("matchId").notNull(),
|
||||
eventIndex: int("eventIndex").notNull(),
|
||||
source: mysqlEnum("source", ["camera_a", "camera_b", "system", "admin"]).default("system").notNull(),
|
||||
eventType: mysqlEnum("eventType", ["point", "game", "set", "metric", "score_suggestion", "review_adjustment", "finalized"]).notNull(),
|
||||
winnerSlot: mysqlEnum("winnerSlot", ["player_a", "player_b"]),
|
||||
matchSecond: int("matchSecond"),
|
||||
confidence: float("confidence"),
|
||||
payload: json("payload"),
|
||||
createdByUserId: int("createdByUserId"),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
}, (table) => ({
|
||||
matchEventUnique: uniqueIndex("match_score_events_match_event_idx").on(table.matchId, table.eventIndex),
|
||||
}));
|
||||
|
||||
export type MatchScoreEvent = typeof matchScoreEvents.$inferSelect;
|
||||
export type InsertMatchScoreEvent = typeof matchScoreEvents.$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
|
||||
*/
|
||||
@@ -181,3 +415,412 @@ 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
|
||||
*/
|
||||
export const dailyCheckins = mysqlTable("daily_checkins", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
userId: int("userId").notNull(),
|
||||
/** Check-in date (YYYY-MM-DD stored as string for easy comparison) */
|
||||
checkinDate: varchar("checkinDate", { length: 10 }).notNull(),
|
||||
/** Current streak at the time of check-in */
|
||||
streakCount: int("streakCount").notNull().default(1),
|
||||
/** Optional notes for the day */
|
||||
notes: text("notes"),
|
||||
/** Training minutes logged this day */
|
||||
minutesTrained: int("minutesTrained").default(0),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export type DailyCheckin = typeof dailyCheckins.$inferSelect;
|
||||
export type InsertDailyCheckin = typeof dailyCheckins.$inferInsert;
|
||||
|
||||
/**
|
||||
* Achievement badges earned by users
|
||||
*/
|
||||
export const userBadges = mysqlTable("user_badges", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
userId: int("userId").notNull(),
|
||||
/** Badge identifier key */
|
||||
badgeKey: varchar("badgeKey", { length: 64 }).notNull(),
|
||||
/** When the badge was earned */
|
||||
earnedAt: timestamp("earnedAt").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
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(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
});
|
||||
|
||||
export type TutorialVideo = typeof tutorialVideos.$inferSelect;
|
||||
export type InsertTutorialVideo = typeof tutorialVideos.$inferInsert;
|
||||
|
||||
/**
|
||||
* User tutorial progress tracking
|
||||
*/
|
||||
export const tutorialProgress = mysqlTable("tutorial_progress", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
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"),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
});
|
||||
|
||||
export type TutorialProgress = typeof tutorialProgress.$inferSelect;
|
||||
export type InsertTutorialProgress = typeof tutorialProgress.$inferInsert;
|
||||
|
||||
/**
|
||||
* Training reminders
|
||||
*/
|
||||
export const trainingReminders = mysqlTable("training_reminders", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
userId: int("userId").notNull(),
|
||||
reminderType: varchar("reminderType", { length: 32 }).notNull(),
|
||||
title: varchar("title", { length: 256 }).notNull(),
|
||||
message: text("message"),
|
||||
timeOfDay: varchar("timeOfDay", { length: 5 }).notNull(),
|
||||
daysOfWeek: json("daysOfWeek").notNull(),
|
||||
isActive: int("isActive").default(1),
|
||||
lastTriggered: timestamp("lastTriggered"),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
});
|
||||
|
||||
export type TrainingReminder = typeof trainingReminders.$inferSelect;
|
||||
export type InsertTrainingReminder = typeof trainingReminders.$inferInsert;
|
||||
|
||||
/**
|
||||
* Notification log
|
||||
*/
|
||||
export const notificationLog = mysqlTable("notification_log", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
userId: int("userId").notNull(),
|
||||
reminderId: int("reminderId"),
|
||||
notificationType: varchar("notificationType", { length: 32 }).notNull(),
|
||||
title: varchar("title", { length: 256 }).notNull(),
|
||||
message: text("message"),
|
||||
isRead: int("isRead").default(0),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export type NotificationLogEntry = typeof notificationLog.$inferSelect;
|
||||
export type InsertNotificationLog = typeof notificationLog.$inferInsert;
|
||||
|
||||
/**
|
||||
* Normalized racket market listings aggregated from multiple public sources.
|
||||
*/
|
||||
export const racketListings = mysqlTable("racket_listings", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
source: mysqlEnum("source", ["xianyu", "jd", "zhuanzhuan"]).notNull(),
|
||||
sourceListingId: varchar("sourceListingId", { length: 128 }).notNull(),
|
||||
title: varchar("title", { length: 512 }).notNull(),
|
||||
description: text("description"),
|
||||
listingUrl: text("listingUrl").notNull(),
|
||||
imageUrl: text("imageUrl"),
|
||||
price: float("price").notNull(),
|
||||
originalPrice: float("originalPrice"),
|
||||
sellerName: varchar("sellerName", { length: 128 }),
|
||||
location: varchar("location", { length: 128 }),
|
||||
publishedAtRaw: varchar("publishedAtRaw", { length: 128 }),
|
||||
brand: varchar("brand", { length: 64 }),
|
||||
model: varchar("model", { length: 128 }),
|
||||
series: varchar("series", { length: 128 }),
|
||||
category: varchar("category", { length: 64 }),
|
||||
weightGram: float("weightGram"),
|
||||
conditionLevel: mysqlEnum("conditionLevel", ["brand_new", "almost_new", "used_good", "used_fair", "unknown"]).default("unknown").notNull(),
|
||||
gradeLevel: mysqlEnum("gradeLevel", ["high_value", "standard", "overpriced", "pending_review"]).default("pending_review").notNull(),
|
||||
gradeReason: text("gradeReason"),
|
||||
isLowPriceCandidate: int("isLowPriceCandidate").default(0).notNull(),
|
||||
fingerprint: varchar("fingerprint", { length: 128 }).notNull(),
|
||||
extra: json("extra"),
|
||||
fetchedAt: timestamp("fetchedAt").defaultNow().notNull(),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
}, (table) => ({
|
||||
sourceListingUnique: uniqueIndex("racket_listings_source_listing_idx").on(table.source, table.sourceListingId),
|
||||
fingerprintUnique: uniqueIndex("racket_listings_fingerprint_idx").on(table.fingerprint),
|
||||
}));
|
||||
|
||||
export type RacketListing = typeof racketListings.$inferSelect;
|
||||
export type InsertRacketListing = typeof racketListings.$inferInsert;
|
||||
|
||||
/**
|
||||
* User-defined racket watch rules for brand/model/price monitoring.
|
||||
*/
|
||||
export const racketWatchRules = mysqlTable("racket_watch_rules", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
userId: int("userId").notNull(),
|
||||
title: varchar("title", { length: 256 }).notNull(),
|
||||
brand: varchar("brand", { length: 64 }).notNull(),
|
||||
modelKeyword: varchar("modelKeyword", { length: 128 }),
|
||||
seriesKeyword: varchar("seriesKeyword", { length: 128 }),
|
||||
category: varchar("category", { length: 64 }),
|
||||
weightMinGram: float("weightMinGram"),
|
||||
weightMaxGram: float("weightMaxGram"),
|
||||
targetPrice: float("targetPrice").notNull(),
|
||||
pushEnabled: int("pushEnabled").default(1).notNull(),
|
||||
isActive: int("isActive").default(1).notNull(),
|
||||
lastCheckedAt: timestamp("lastCheckedAt"),
|
||||
lastMatchedAt: timestamp("lastMatchedAt"),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
});
|
||||
|
||||
export type RacketWatchRule = typeof racketWatchRules.$inferSelect;
|
||||
export type InsertRacketWatchRule = typeof racketWatchRules.$inferInsert;
|
||||
|
||||
/**
|
||||
* Historical watch hits to dedupe and manage push delivery state.
|
||||
*/
|
||||
export const racketWatchHits = mysqlTable("racket_watch_hits", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
watchRuleId: int("watchRuleId").notNull(),
|
||||
userId: int("userId").notNull(),
|
||||
listingId: int("listingId").notNull(),
|
||||
matchedPrice: float("matchedPrice").notNull(),
|
||||
status: mysqlEnum("status", ["matched", "push_queued", "pushed", "suppressed"]).default("matched").notNull(),
|
||||
firstMatchedAt: timestamp("firstMatchedAt").defaultNow().notNull(),
|
||||
lastMatchedAt: timestamp("lastMatchedAt").defaultNow().notNull(),
|
||||
lastPushPrice: float("lastPushPrice"),
|
||||
pushedAt: timestamp("pushedAt"),
|
||||
pushCount: int("pushCount").default(0).notNull(),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
}, (table) => ({
|
||||
watchRuleListingUnique: uniqueIndex("racket_watch_hits_rule_listing_idx").on(table.watchRuleId, table.listingId),
|
||||
}));
|
||||
|
||||
export type RacketWatchHit = typeof racketWatchHits.$inferSelect;
|
||||
export type InsertRacketWatchHit = typeof racketWatchHits.$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",
|
||||
"market_source_sync",
|
||||
"market_watch_refresh",
|
||||
"market_push_delivery",
|
||||
"match_score_suggest",
|
||||
"match_finalize",
|
||||
]).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=
|
||||
1518
media/main.go
普通文件
633
media/main_test.go
普通文件
@@ -0,0 +1,633 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelaySegmentUploadKeepsOnlyLatestMinute(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 Buffer", Purpose: "relay", RelayBufferSeconds: 60})
|
||||
if err != nil {
|
||||
t.Fatalf("createSession: %v", err)
|
||||
}
|
||||
|
||||
for sequence := 0; sequence < 3; sequence += 1 {
|
||||
req := httptest.NewRequest(http.MethodPost, "/media/sessions/"+session.ID+"/segments?sequence="+strconv.Itoa(sequence)+"&durationMs=30000", strings.NewReader("segment"))
|
||||
req.Header.Set("Content-Type", "video/webm")
|
||||
res := httptest.NewRecorder()
|
||||
server.routes().ServeHTTP(res, req)
|
||||
if res.Code != http.StatusAccepted {
|
||||
t.Fatalf("expected segment upload 202 for sequence %d, got %d", sequence, res.Code)
|
||||
}
|
||||
}
|
||||
|
||||
current, err := store.getSession(session.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("getSession: %v", err)
|
||||
}
|
||||
if current.Purpose != PurposeRelay {
|
||||
t.Fatalf("expected relay purpose, got %s", current.Purpose)
|
||||
}
|
||||
if len(current.Segments) != 2 {
|
||||
t.Fatalf("expected latest 2 relay segments to remain, got %d", len(current.Segments))
|
||||
}
|
||||
if current.Segments[0].Sequence != 1 || current.Segments[1].Sequence != 2 {
|
||||
t.Fatalf("expected relay segments 1 and 2 to remain, got %#v", current.Segments)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(store.segmentsDir(session.ID), "000000.webm")); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("expected earliest relay segment to be pruned from disk, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessRelayPreviewPublishesBufferedWebM(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: "Relay Preview", Purpose: "relay", RelayBufferSeconds: 60})
|
||||
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.Purpose = PurposeRelay
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("updateSession: %v", err)
|
||||
}
|
||||
|
||||
if err := processRollingPreview(store, session.ID); err != nil {
|
||||
t.Fatalf("processRollingPreview: %v", err)
|
||||
}
|
||||
|
||||
current, err := store.getSession(session.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("getSession: %v", err)
|
||||
}
|
||||
if current.Playback.PreviewURL == "" || !strings.HasSuffix(current.Playback.PreviewURL, "/preview.webm") {
|
||||
t.Fatalf("expected relay preview webm url, got %#v", current.Playback)
|
||||
}
|
||||
if current.Playback.MP4URL != "" {
|
||||
t.Fatalf("expected relay preview to skip mp4 generation, got %#v", current.Playback)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSegmentUploadPersistsRelayMP4InitSegment(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 MP4", Purpose: "relay", RelayBufferSeconds: 120})
|
||||
if err != nil {
|
||||
t.Fatalf("createSession: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/media/sessions/"+session.ID+"/segments?sequence=1&durationMs=10000", strings.NewReader("mp4-init"))
|
||||
req.Header.Set("Content-Type", "video/mp4;codecs=avc1")
|
||||
res := httptest.NewRecorder()
|
||||
server.routes().ServeHTTP(res, req)
|
||||
if res.Code != http.StatusAccepted {
|
||||
t.Fatalf("expected segment upload 202, got %d", res.Code)
|
||||
}
|
||||
|
||||
current, err := store.getSession(session.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("getSession: %v", err)
|
||||
}
|
||||
if current.RelayInitFilename != "000001.mp4" {
|
||||
t.Fatalf("expected relay init filename to be recorded, got %q", current.RelayInitFilename)
|
||||
}
|
||||
body, err := os.ReadFile(store.relayInitPath(session.ID))
|
||||
if err != nil {
|
||||
t.Fatalf("read relay init: %v", err)
|
||||
}
|
||||
if string(body) != "mp4-init" {
|
||||
t.Fatalf("unexpected relay init contents: %q", string(body))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessRelayPreviewUsesPersistedInitForMP4Fragments(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: "Relay MP4 Preview", Purpose: "relay", RelayBufferSeconds: 120})
|
||||
if err != nil {
|
||||
t.Fatalf("createSession: %v", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(store.relayInitPath(session.ID), []byte(strings.Repeat("i", 6000)), 0o644); err != nil {
|
||||
t.Fatalf("write relay init: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(store.segmentsDir(session.ID), "000082.mp4"), []byte(strings.Repeat("a", 6000)), 0o644); err != nil {
|
||||
t.Fatalf("write segment 82: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(store.segmentsDir(session.ID), "000083.mp4"), []byte(strings.Repeat("b", 6000)), 0o644); err != nil {
|
||||
t.Fatalf("write segment 83: %v", err)
|
||||
}
|
||||
|
||||
if _, err := store.updateSession(session.ID, func(current *Session) error {
|
||||
current.Purpose = PurposeRelay
|
||||
current.RelayInitFilename = "000001.mp4"
|
||||
current.Segments = []SegmentMeta{
|
||||
{
|
||||
Sequence: 82,
|
||||
Filename: "000082.mp4",
|
||||
DurationMS: 10000,
|
||||
SizeBytes: 6000,
|
||||
ContentType: "video/mp4;codecs=avc1",
|
||||
},
|
||||
{
|
||||
Sequence: 83,
|
||||
Filename: "000083.mp4",
|
||||
DurationMS: 10000,
|
||||
SizeBytes: 6000,
|
||||
ContentType: "video/mp4;codecs=avc1",
|
||||
},
|
||||
}
|
||||
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)
|
||||
}
|
||||
t.Setenv("PATH", tempDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
|
||||
if err := processRollingPreview(store, session.ID); err != nil {
|
||||
t.Fatalf("processRollingPreview: %v", err)
|
||||
}
|
||||
|
||||
current, err := store.getSession(session.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("getSession: %v", err)
|
||||
}
|
||||
if current.PreviewStatus != PreviewReady {
|
||||
t.Fatalf("expected preview ready, got %s", current.PreviewStatus)
|
||||
}
|
||||
if current.Playback.PreviewURL == "" {
|
||||
t.Fatalf("expected preview url to be populated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessRelayPreviewKeepsPreviousPreviewOnFailure(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: "Relay Existing Preview", Purpose: "relay", RelayBufferSeconds: 120})
|
||||
if err != nil {
|
||||
t.Fatalf("createSession: %v", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(store.publicDir(session.ID), 0o755); err != nil {
|
||||
t.Fatalf("mkdir public dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(store.publicDir(session.ID), "preview.webm"), []byte("existing-preview"), 0o644); err != nil {
|
||||
t.Fatalf("write preview: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(store.segmentsDir(session.ID), "000001.webm"), []byte("segment-one"), 0o644); err != nil {
|
||||
t.Fatalf("write segment 1: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(store.segmentsDir(session.ID), "000002.webm"), []byte("segment-two"), 0o644); err != nil {
|
||||
t.Fatalf("write segment 2: %v", err)
|
||||
}
|
||||
if _, err := store.updateSession(session.ID, func(current *Session) error {
|
||||
current.Purpose = PurposeRelay
|
||||
current.PreviewStatus = PreviewReady
|
||||
current.Playback.PreviewURL = fmt.Sprintf("/media/assets/sessions/%s/preview.webm", session.ID)
|
||||
current.Segments = []SegmentMeta{
|
||||
{
|
||||
Sequence: 1,
|
||||
Filename: "000001.webm",
|
||||
DurationMS: 10000,
|
||||
SizeBytes: int64(len("segment-one")),
|
||||
ContentType: "video/webm",
|
||||
},
|
||||
{
|
||||
Sequence: 2,
|
||||
Filename: "000002.webm",
|
||||
DurationMS: 10000,
|
||||
SizeBytes: int64(len("segment-two")),
|
||||
ContentType: "video/webm",
|
||||
},
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("updateSession: %v", err)
|
||||
}
|
||||
|
||||
fakeFFmpeg := filepath.Join(tempDir, "ffmpeg")
|
||||
script := "#!/bin/sh\nexit 1\n"
|
||||
if err := os.WriteFile(fakeFFmpeg, []byte(script), 0o755); err != nil {
|
||||
t.Fatalf("write fake ffmpeg: %v", err)
|
||||
}
|
||||
t.Setenv("PATH", tempDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
|
||||
if err := processRollingPreview(store, session.ID); err == nil {
|
||||
t.Fatalf("expected processRollingPreview to surface failure")
|
||||
}
|
||||
|
||||
current, err := store.getSession(session.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("getSession: %v", err)
|
||||
}
|
||||
if current.PreviewStatus != PreviewReady {
|
||||
t.Fatalf("expected previous preview to remain ready, got %s", current.PreviewStatus)
|
||||
}
|
||||
if current.Playback.PreviewURL == "" {
|
||||
t.Fatalf("expected preview url to remain available")
|
||||
}
|
||||
if current.LastError == "" {
|
||||
t.Fatalf("expected last error to be recorded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneExpiredRelaySessionsRemovesOldCache(t *testing.T) {
|
||||
store, err := newSessionStore(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("newSessionStore: %v", err)
|
||||
}
|
||||
|
||||
session, err := store.createSession(CreateSessionRequest{UserID: "1", Title: "Old Relay", Purpose: "relay", RelayBufferSeconds: 60})
|
||||
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 := os.MkdirAll(store.publicDir(session.ID), 0o755); err != nil {
|
||||
t.Fatalf("mkdir public dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(store.publicDir(session.ID), "preview.webm"), []byte("preview"), 0o644); err != nil {
|
||||
t.Fatalf("write preview: %v", err)
|
||||
}
|
||||
|
||||
store.mu.Lock()
|
||||
store.sessions[session.ID].Purpose = PurposeRelay
|
||||
store.sessions[session.ID].UpdatedAt = time.Now().UTC().Add(-31 * time.Minute).Format(time.RFC3339)
|
||||
store.mu.Unlock()
|
||||
|
||||
if err := store.pruneExpiredRelaySessions(relayCacheTTL, time.Now().UTC()); err != nil {
|
||||
t.Fatalf("pruneExpiredRelaySessions: %v", err)
|
||||
}
|
||||
|
||||
if _, err := store.getSession(session.ID); err == nil {
|
||||
t.Fatalf("expected relay session to be removed from store")
|
||||
}
|
||||
if _, err := os.Stat(store.sessionDir(session.ID)); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("expected relay session directory to be removed, got %v", err)
|
||||
}
|
||||
if _, err := os.Stat(store.publicDir(session.ID)); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("expected relay public directory to be removed, got %v", err)
|
||||
}
|
||||
}
|
||||
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: {}
|
||||
|
||||