Checkpoint: v3.0 - 新增训练视频教程库(分类浏览、自评系统)、训练提醒通知(多类型提醒、浏览器推送)、通知记录管理、去除冗余文字。65个测试全部通过。

这个提交包含在:
Manus
2026-03-14 08:28:57 -04:00
父节点 2c418b482e
当前提交 27083d5af9
修改 19 个文件,包含 2856 行新增32 行删除

查看文件

@@ -10,6 +10,10 @@ import {
ratingHistory, InsertRatingHistory,
dailyCheckins, InsertDailyCheckin,
userBadges, InsertUserBadge,
tutorialVideos, InsertTutorialVideo,
tutorialProgress, InsertTutorialProgress,
trainingReminders, InsertTrainingReminder,
notificationLog, InsertNotificationLog,
} from "../drizzle/schema";
import { ENV } from './_core/env';
@@ -429,6 +433,233 @@ export async function getLeaderboard(sortBy: "ntrpRating" | "totalMinutes" | "to
}).from(users).orderBy(desc(sortColumn)).limit(limit);
}
// ===== TUTORIAL OPERATIONS =====
export const TUTORIAL_SEED_DATA: Omit<InsertTutorialVideo, "id">[] = [
{
title: "正手击球基础",
category: "forehand",
skillLevel: "beginner",
description: "学习正手击球的基本站位、握拍方式和挥拍轨迹,建立稳定的正手基础。",
keyPoints: JSON.stringify(["东方式或半西方式握拍", "侧身引拍,肩膀转动90度", "从低到高的挥拍轨迹", "随挥至对侧肩膀", "重心转移从后脚到前脚"]),
commonMistakes: JSON.stringify(["手腕过度发力", "没有转体", "击球点太靠后", "随挥不充分"]),
duration: 300,
sortOrder: 1,
},
{
title: "反手击球基础",
category: "backhand",
skillLevel: "beginner",
description: "掌握单手和双手反手的核心技术,包括握拍转换和击球时机。",
keyPoints: JSON.stringify(["双手反手更适合初学者", "早引拍,肩膀充分转动", "击球点在身体前方", "保持手臂伸展"]),
commonMistakes: JSON.stringify(["只用手臂发力", "击球点太迟", "缺少随挥", "脚步不到位"]),
duration: 300,
sortOrder: 2,
},
{
title: "发球技术",
category: "serve",
skillLevel: "beginner",
description: "从抛球、引拍到击球的完整发球动作分解与练习。",
keyPoints: JSON.stringify(["稳定的抛球是关键", "大陆式握拍", "引拍时身体充分弓身", "最高点击球", "手腕内旋加速"]),
commonMistakes: JSON.stringify(["抛球不稳定", "手臂弯曲击球", "重心没有向前", "发力时机不对"]),
duration: 360,
sortOrder: 3,
},
{
title: "截击技术",
category: "volley",
skillLevel: "intermediate",
description: "网前截击的站位、准备姿势和击球技巧。",
keyPoints: JSON.stringify(["分腿弯膝准备姿势", "拍头保持在视线前方", "短促的击球动作", "步伐迎向球"]),
commonMistakes: JSON.stringify(["挥拍幅度太大", "站位太远", "拍面角度不对", "重心太高"]),
duration: 240,
sortOrder: 4,
},
{
title: "脚步移动训练",
category: "footwork",
skillLevel: "beginner",
description: "网球基础脚步训练,包括分步、交叉步、滑步和回位。",
keyPoints: JSON.stringify(["分步判断球的方向", "交叉步快速移动", "小碎步调整位置", "击球后快速回中"]),
commonMistakes: JSON.stringify(["脚步懒散不移动", "重心太高", "回位太慢", "没有分步"]),
duration: 240,
sortOrder: 5,
},
{
title: "正手上旋",
category: "forehand",
skillLevel: "intermediate",
description: "掌握正手上旋球的发力技巧和拍面角度控制。",
keyPoints: JSON.stringify(["半西方式或西方式握拍", "从低到高的刷球动作", "加速手腕内旋", "随挥结束在头部上方"]),
commonMistakes: JSON.stringify(["拍面太开放", "没有刷球动作", "随挥不充分"]),
duration: 300,
sortOrder: 6,
},
{
title: "发球变化(切削/上旋)",
category: "serve",
skillLevel: "advanced",
description: "高级发球技术,包括切削发球和Kick发球的动作要领。",
keyPoints: JSON.stringify(["切削发球:侧旋切球", "Kick发球从下到上刷球", "抛球位置根据发球类型调整", "手腕加速是关键"]),
commonMistakes: JSON.stringify(["抛球位置没有变化", "旋转不足", "发力方向错误"]),
duration: 360,
sortOrder: 7,
},
{
title: "影子挥拍练习",
category: "shadow",
skillLevel: "beginner",
description: "不需要球的挥拍练习,专注于动作轨迹和肌肉记忆。",
keyPoints: JSON.stringify(["慢动作分解每个环节", "关注脚步和重心转移", "对着镜子检查姿势", "逐渐加快速度"]),
commonMistakes: JSON.stringify(["动作太快不规范", "忽略脚步", "没有完整的随挥"]),
duration: 180,
sortOrder: 8,
},
{
title: "墙壁练习技巧",
category: "wall",
skillLevel: "beginner",
description: "利用墙壁进行的各种练习方法,提升控球和反应能力。",
keyPoints: JSON.stringify(["保持适当距离", "控制力量和方向", "交替练习正反手", "注意脚步移动"]),
commonMistakes: JSON.stringify(["力量太大控制不住", "站位太近或太远", "只练习一种击球"]),
duration: 240,
sortOrder: 9,
},
{
title: "体能训练",
category: "fitness",
skillLevel: "beginner",
description: "网球专项体能训练,提升爆发力、敏捷性和耐力。",
keyPoints: JSON.stringify(["核心力量训练", "下肢爆发力练习", "敏捷性梯子训练", "拉伸和灵活性"]),
commonMistakes: JSON.stringify(["忽略热身", "训练过度", "动作不标准"]),
duration: 300,
sortOrder: 10,
},
{
title: "比赛策略基础",
category: "strategy",
skillLevel: "intermediate",
description: "网球比赛中的基本战术和策略运用。",
keyPoints: JSON.stringify(["控制球场深度", "变换节奏和方向", "利用对手弱点", "网前战术时机"]),
commonMistakes: JSON.stringify(["打法单一", "没有计划", "心态波动大"]),
duration: 300,
sortOrder: 11,
},
];
export async function seedTutorials() {
const db = await getDb();
if (!db) return;
const existing = await db.select().from(tutorialVideos).limit(1);
if (existing.length > 0) return; // Already seeded
for (const t of TUTORIAL_SEED_DATA) {
await db.insert(tutorialVideos).values(t);
}
}
export async function getTutorials(category?: string, skillLevel?: string) {
const db = await getDb();
if (!db) return [];
let conditions = [eq(tutorialVideos.isPublished, 1)];
if (category) conditions.push(eq(tutorialVideos.category, category));
if (skillLevel) conditions.push(eq(tutorialVideos.skillLevel, skillLevel as any));
return db.select().from(tutorialVideos).where(and(...conditions)).orderBy(tutorialVideos.sortOrder);
}
export async function getTutorialById(id: number) {
const db = await getDb();
if (!db) return undefined;
const result = await db.select().from(tutorialVideos).where(eq(tutorialVideos.id, id)).limit(1);
return result.length > 0 ? result[0] : undefined;
}
export async function getUserTutorialProgress(userId: number) {
const db = await getDb();
if (!db) return [];
return db.select().from(tutorialProgress).where(eq(tutorialProgress.userId, userId));
}
export async function updateTutorialProgress(userId: number, tutorialId: number, data: { watched?: number; selfScore?: number; notes?: string; comparisonVideoId?: number }) {
const db = await getDb();
if (!db) return;
const existing = await db.select().from(tutorialProgress)
.where(and(eq(tutorialProgress.userId, userId), eq(tutorialProgress.tutorialId, tutorialId)))
.limit(1);
if (existing.length > 0) {
await db.update(tutorialProgress).set(data).where(eq(tutorialProgress.id, existing[0].id));
} else {
await db.insert(tutorialProgress).values({ userId, tutorialId, ...data });
}
}
// ===== TRAINING REMINDER OPERATIONS =====
export async function getUserReminders(userId: number) {
const db = await getDb();
if (!db) return [];
return db.select().from(trainingReminders).where(eq(trainingReminders.userId, userId)).orderBy(desc(trainingReminders.createdAt));
}
export async function createReminder(data: InsertTrainingReminder) {
const db = await getDb();
if (!db) throw new Error("Database not available");
const result = await db.insert(trainingReminders).values(data);
return result[0].insertId;
}
export async function updateReminder(reminderId: number, userId: number, data: Partial<InsertTrainingReminder>) {
const db = await getDb();
if (!db) return;
await db.update(trainingReminders).set(data).where(and(eq(trainingReminders.id, reminderId), eq(trainingReminders.userId, userId)));
}
export async function deleteReminder(reminderId: number, userId: number) {
const db = await getDb();
if (!db) return;
await db.delete(trainingReminders).where(and(eq(trainingReminders.id, reminderId), eq(trainingReminders.userId, userId)));
}
export async function toggleReminder(reminderId: number, userId: number, isActive: number) {
const db = await getDb();
if (!db) return;
await db.update(trainingReminders).set({ isActive }).where(and(eq(trainingReminders.id, reminderId), eq(trainingReminders.userId, userId)));
}
// ===== NOTIFICATION OPERATIONS =====
export async function getUserNotifications(userId: number, limit = 50) {
const db = await getDb();
if (!db) return [];
return db.select().from(notificationLog).where(eq(notificationLog.userId, userId)).orderBy(desc(notificationLog.createdAt)).limit(limit);
}
export async function createNotification(data: InsertNotificationLog) {
const db = await getDb();
if (!db) return;
await db.insert(notificationLog).values(data);
}
export async function markNotificationRead(notificationId: number, userId: number) {
const db = await getDb();
if (!db) return;
await db.update(notificationLog).set({ isRead: 1 }).where(and(eq(notificationLog.id, notificationId), eq(notificationLog.userId, userId)));
}
export async function markAllNotificationsRead(userId: number) {
const db = await getDb();
if (!db) return;
await db.update(notificationLog).set({ isRead: 1 }).where(eq(notificationLog.userId, userId));
}
export async function getUnreadNotificationCount(userId: number) {
const db = await getDb();
if (!db) return 0;
const result = await db.select({ count: sql<number>`count(*)` }).from(notificationLog)
.where(and(eq(notificationLog.userId, userId), eq(notificationLog.isRead, 0)));
return result[0]?.count || 0;
}
// ===== STATS HELPERS =====
export async function getUserStats(userId: number) {