Add CRUD support for training videos

这个提交包含在:
cryptocommuniums-afk
2026-03-15 14:17:59 +08:00
父节点 143c60a054
当前提交 bd8998166b
修改 5 个文件,包含 877 行新增71 行删除

查看文件

@@ -54,6 +54,25 @@ async function auditAdminAction(params: {
});
}
const manualNtrpSchema = z.number().min(1).max(5);
const scoreSchema = z.number().int().min(1).max(5);
const trainingProfileUpdateSchema = z.object({
skillLevel: z.enum(["beginner", "intermediate", "advanced"]).optional(),
trainingGoals: z.string().max(2000).optional(),
manualNtrpRating: manualNtrpSchema.nullable().optional(),
heightCm: z.number().min(100).max(240).nullable().optional(),
weightKg: z.number().min(30).max(250).nullable().optional(),
sprintSpeedScore: scoreSchema.nullable().optional(),
explosivePowerScore: scoreSchema.nullable().optional(),
agilityScore: scoreSchema.nullable().optional(),
enduranceScore: scoreSchema.nullable().optional(),
flexibilityScore: scoreSchema.nullable().optional(),
coreStabilityScore: scoreSchema.nullable().optional(),
shoulderMobilityScore: scoreSchema.nullable().optional(),
hipMobilityScore: scoreSchema.nullable().optional(),
assessmentNotes: z.string().max(2000).nullable().optional(),
});
export const appRouter = router({
system: systemRouter,
@@ -92,12 +111,12 @@ export const appRouter = router({
// User profile management
profile: router({
update: protectedProcedure
.input(z.object({
skillLevel: z.enum(["beginner", "intermediate", "advanced"]).optional(),
trainingGoals: z.string().optional(),
}))
.input(trainingProfileUpdateSchema)
.mutation(async ({ ctx, input }) => {
await db.updateUserProfile(ctx.user.id, input);
await db.updateUserProfile(ctx.user.id, {
...input,
manualNtrpCapturedAt: input.manualNtrpRating != null ? new Date() : input.manualNtrpRating === null ? null : undefined,
});
return { success: true };
}),
stats: protectedProcedure.query(async ({ ctx }) => {
@@ -114,6 +133,21 @@ export const appRouter = router({
focusAreas: z.array(z.string()).optional(),
}))
.mutation(async ({ ctx, input }) => {
const currentUser = await db.getUserById(ctx.user.id);
if (!currentUser) {
throw new TRPCError({ code: "NOT_FOUND", message: "用户不存在" });
}
const latestSnapshot = await db.getLatestNtrpSnapshot(ctx.user.id);
const missingFields = db.getMissingTrainingProfileFields(currentUser, Boolean(latestSnapshot?.rating != null));
if (missingFields.length > 0) {
const missingLabels = missingFields.map((field) => db.TRAINING_PROFILE_FIELD_LABELS[field]).join("、");
throw new TRPCError({
code: "BAD_REQUEST",
message: `训练计划生成前请先完善训练档案:${missingLabels}`,
});
}
return enqueueTask({
userId: ctx.user.id,
type: "training_plan_generate",
@@ -210,8 +244,12 @@ export const appRouter = router({
get: protectedProcedure
.input(z.object({ videoId: z.number() }))
.query(async ({ input }) => {
return db.getVideoById(input.videoId);
.query(async ({ ctx, input }) => {
const video = await db.getUserVideoById(ctx.user.id, input.videoId);
if (!video) {
throw new TRPCError({ code: "NOT_FOUND", message: "视频不存在" });
}
return video;
}),
updateStatus: protectedProcedure
@@ -223,6 +261,33 @@ export const appRouter = router({
await db.updateVideoStatus(input.videoId, input.status);
return { success: true };
}),
update: protectedProcedure
.input(z.object({
videoId: z.number(),
title: z.string().trim().min(1).max(256),
exerciseType: z.string().trim().max(64).optional(),
}))
.mutation(async ({ ctx, input }) => {
const updated = await db.updateUserVideo(ctx.user.id, input.videoId, {
title: input.title,
exerciseType: input.exerciseType?.trim() ? input.exerciseType.trim() : null,
});
if (!updated) {
throw new TRPCError({ code: "NOT_FOUND", message: "视频不存在" });
}
return { success: true };
}),
delete: protectedProcedure
.input(z.object({ videoId: z.number() }))
.mutation(async ({ ctx, input }) => {
const deleted = await db.deleteUserVideo(ctx.user.id, input.videoId);
if (!deleted) {
throw new TRPCError({ code: "NOT_FOUND", message: "视频不存在" });
}
return { success: true };
}),
}),
// Pose analysis
@@ -836,16 +901,17 @@ export const appRouter = router({
.input(z.object({
category: z.string().optional(),
skillLevel: z.string().optional(),
topicArea: z.string().optional(),
}).optional())
.query(async ({ input }) => {
// Auto-seed tutorials on first request
await db.seedTutorials();
return db.getTutorials(input?.category, input?.skillLevel);
return db.getTutorials(input?.category, input?.skillLevel, input?.topicArea);
}),
get: publicProcedure
.input(z.object({ id: z.number() }))
.query(async ({ input }) => {
await db.seedTutorials();
return db.getTutorialById(input.id);
}),
@@ -857,6 +923,7 @@ export const appRouter = router({
.input(z.object({
tutorialId: z.number(),
watched: z.number().optional(),
completed: z.number().optional(),
selfScore: z.number().optional(),
notes: z.string().optional(),
comparisonVideoId: z.number().optional(),
@@ -864,7 +931,8 @@ export const appRouter = router({
.mutation(async ({ ctx, input }) => {
const { tutorialId, ...data } = input;
await db.updateTutorialProgress(ctx.user.id, tutorialId, data);
return { success: true };
const unlockedKeys = await db.refreshAchievementsForUser(ctx.user.id);
return { success: true, unlockedAchievementKeys: unlockedKeys };
}),
}),