Add CRUD support for training videos
这个提交包含在:
@@ -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 };
|
||||
}),
|
||||
}),
|
||||
|
||||
|
||||
在新工单中引用
屏蔽一个用户