Authored by Bad

feat: 重构训练记录与超级组页面并更新API参数

@@ -4,7 +4,7 @@ NODE_ENV=production @@ -4,7 +4,7 @@ NODE_ENV=production
4 VITE_DEV=true 4 VITE_DEV=true
5 5
6 # 请求路径 6 # 请求路径
7 -VITE_BASE_URL='https://fitness.hcxtec.com' 7 +VITE_BASE_URL='https://xunji.admin.geaktec.com'
8 8
9 # 文件上传类型:server - 后端上传, client - 前端直连上传,仅支持S3服务 9 # 文件上传类型:server - 后端上传, client - 前端直连上传,仅支持S3服务
10 VITE_UPLOAD_TYPE=server 10 VITE_UPLOAD_TYPE=server
@@ -62,7 +62,7 @@ @@ -62,7 +62,7 @@
62 "editor.defaultFormatter": "esbenp.prettier-vscode" 62 "editor.defaultFormatter": "esbenp.prettier-vscode"
63 }, 63 },
64 "[typescript]": { 64 "[typescript]": {
65 - "editor.defaultFormatter": "esbenp.prettier-vscode" 65 + "editor.defaultFormatter": "vscode.typescript-language-features"
66 }, 66 },
67 "[typescriptreact]": { 67 "[typescriptreact]": {
68 "editor.defaultFormatter": "esbenp.prettier-vscode" 68 "editor.defaultFormatter": "esbenp.prettier-vscode"
1 import request from '@/config/axios' 1 import request from '@/config/axios'
2 2
3 -/** 前台用户信息 */  
4 -export interface Student {  
5 - id: number // ID  
6 - phone?: string // 手机号  
7 - identity?: string // 身份:1=学员、2=教练  
8 - nickname?: string // 昵称  
9 - avatar?: string // 头像  
10 - sex?: string // 性别:1=男、2=女  
11 - province?: string // 省  
12 - city?: string // 市  
13 - region?: string // 区  
14 - signature?: string // 个性签名(自我介绍)  
15 - backgroundImage?: string // 背景图片  
16 - openId?: string // 微信openId  
17 - isNewbie?: number // 是否是新人:1是0否 3 +// 获得前台用户分页
  4 +export const getStudentPage = (params) => {
  5 + return request.get({
  6 + url: '/user/student/page',
  7 +
  8 + params
  9 + })
18 } 10 }
19 11
20 -// 前台用户 API  
21 -export const StudentApi = {  
22 - // 查询前台用户分页  
23 - getUserPage: async (params: any) => {  
24 - return await request.get({ url: `/user/student/page`, params })  
25 - },  
26 -  
27 - // 查询前台用户详情  
28 - getUser: async (id: number) => {  
29 - return await request.get({ url: `/user/student/get?id=` + id })  
30 - },  
31 -  
32 - // 修改前台用户  
33 - updateUser: async (data: Student) => {  
34 - return await request.put({ url: `/user/student/update`, data })  
35 - },  
36 -  
37 - // 导出前台用户 Excel  
38 - exportUser: async (params) => {  
39 - return await request.download({ url: `/user/student/export`, params })  
40 - },  
41 -  
42 - // 获得学员健康资料  
43 - getUserHealth: async (id: number) => {  
44 - return await request.get({ url: `/user/student/health-data?userId=` + id })  
45 - } 12 +// 获得用户详细
  13 +export const getStudent = (id) => {
  14 + return request.get({
  15 + url: '/user/student/get',
  16 +
  17 + params: { id }
  18 + })
46 } 19 }
  20 +
  21 +// 更新用户
  22 +export const updateStudent = (data) => {
  23 + return request.put({
  24 + url: '/user/student/update',
  25 +
  26 + data
  27 + })
  28 +}
  29 +
  30 +// 获得学员健康资料
  31 +export const getStudentHealthData = (userId) => {
  32 + return request.get({
  33 + url: '/user/student/health-data',
  34 +
  35 + params: { userId }
  36 + })
  37 +}
@@ -48,7 +48,7 @@ export const SupersetsApi = { @@ -48,7 +48,7 @@ export const SupersetsApi = {
48 }, 48 },
49 49
50 // 获得超级组列表 50 // 获得超级组列表
51 - getSupersetList: () => {  
52 - return request.get({ url: '/motion/supersets/supersets-simple-list' }) 51 + getSupersetList: (isPersonal: boolean) => {
  52 + return request.get({ url: '/motion/supersets/supersets-simple-list?isPersonal=' + isPersonal})
53 } 53 }
54 } 54 }
@@ -87,7 +87,7 @@ export const ExercisesApi = { @@ -87,7 +87,7 @@ export const ExercisesApi = {
87 }) 87 })
88 }, 88 },
89 // 获取动作分类下拉列表 89 // 获取动作分类下拉列表
90 - getExercisesList: () => {  
91 - return request.get({ url: '/motion/supersets/simple-list' }) 90 + getExercisesList: (isPersonal: boolean) => {
  91 + return request.get({ url: '/motion/supersets/simple-list?isPersonal=' + isPersonal })
92 } 92 }
93 } 93 }
@@ -52,7 +52,7 @@ export const TrainingTemplatesApi = { @@ -52,7 +52,7 @@ export const TrainingTemplatesApi = {
52 }) 52 })
53 }, 53 },
54 // 获取模板下拉列表 54 // 获取模板下拉列表
55 - getTemplateList: () => {  
56 - return request.get({ url: '/templates/simple-list' }) 55 + getTemplateList: (isPersonal: boolean) => {
  56 + return request.get({ url: '/templates/simple-list?isPersonal=' + isPersonal})
57 } 57 }
58 } 58 }
@@ -50,5 +50,11 @@ export const trainingPlanApi = { @@ -50,5 +50,11 @@ export const trainingPlanApi = {
50 // 获得计划中的计划种类分页 50 // 获得计划中的计划种类分页
51 getPlanCategoryPage: async (params: any) => { 51 getPlanCategoryPage: async (params: any) => {
52 return await request.get({ url: `/plan/Category/page`, params }) 52 return await request.get({ url: `/plan/Category/page`, params })
  53 + },
  54 + // 获取计划下拉列表
  55 + getPlanList: async () => {
  56 + return await request.get({ url: `/plan/simple-list`, })
53 } 57 }
  58 +
  59 +
54 } 60 }
  1 +<template>
  2 + <Dialog title="修改前台用户" v-model="dialogVisible" width="600px">
  3 + <el-form ref="formRef" :model="formData" :rules="formRules" label-width="90px" v-loading="formLoading">
  4 + <el-form-item label="真实姓名" prop="realname">
  5 + <el-input v-model="formData.realname" placeholder="请输入真实姓名" />
  6 + </el-form-item>
  7 + <el-form-item label="昵称" prop="nickname">
  8 + <el-input v-model="formData.nickname" placeholder="请输入昵称" />
  9 + </el-form-item>
  10 + <el-form-item label="手机号" prop="phone">
  11 + <el-input v-model="formData.phone" placeholder="请输入手机号" />
  12 + </el-form-item>
  13 + <el-form-item label="性别" prop="sex">
  14 + <el-radio-group v-model="formData.sex">
  15 + <el-radio :label="1">男</el-radio>
  16 + <el-radio :label="2">女</el-radio>
  17 + </el-radio-group>
  18 + </el-form-item>
  19 + <el-form-item label="账号状态" prop="status">
  20 + <el-radio-group v-model="formData.status">
  21 + <el-radio :label="1">正常</el-radio>
  22 + <el-radio :label="0">禁用</el-radio>
  23 + </el-radio-group>
  24 + </el-form-item>
  25 + <el-form-item label="头像" prop="avatar">
  26 + <UploadImg v-model="formData.avatar" />
  27 + </el-form-item>
  28 + <el-form-item label="个性签名" prop="signature">
  29 + <el-input v-model="formData.signature" type="textarea" :rows="2" placeholder="请输入个性签名" />
  30 + </el-form-item>
  31 + <el-form-item label="背景图片" prop="backgroundImage">
  32 + <UploadImg v-model="formData.backgroundImage" />
  33 + </el-form-item>
  34 + </el-form>
  35 + <template #footer>
  36 + <el-button @click="dialogVisible = false">取 消</el-button>
  37 + <el-button type="primary" :loading="submitLoading" @click="submitForm">确 定</el-button>
  38 + </template>
  39 + </Dialog>
  40 +</template>
  41 +
  42 +<script setup>
  43 +import { ref, reactive } from 'vue'
  44 +import * as StudentApi from '@/api/store/student'
  45 +
  46 +const message = useMessage() // 芋道全局提示组件
  47 +
  48 +const dialogVisible = ref(false)
  49 +const formLoading = ref(false)
  50 +const submitLoading = ref(false)
  51 +const formRef = ref()
  52 +
  53 +const formData = reactive({
  54 + id: undefined,
  55 + realname: '',
  56 + phone: '',
  57 + nickname: '',
  58 + status: 1,
  59 + sex: 1,
  60 + avatar: '',
  61 + signature: '',
  62 + backgroundImage: ''
  63 +})
  64 +
  65 +const formRules = reactive({
  66 + realname: [{ required: true, message: '真实姓名不能为空', trigger: 'blur' }],
  67 + nickname: [{ required: true, message: '昵称不能为空', trigger: 'blur' }],
  68 + phone: [{ required: true, message: '手机号不能为空', trigger: 'blur' }],
  69 + status: [{ required: true, message: '账号状态不能为空', trigger: 'change' }],
  70 + sex: [{ required: true, message: '性别不能为空', trigger: 'change' }]
  71 +})
  72 +
  73 +/** 打开弹窗 */
  74 +const open = async (id) => {
  75 + dialogVisible.value = true
  76 + resetForm()
  77 + if (id) {
  78 + formLoading.value = true
  79 + try {
  80 + const data = await StudentApi.getStudent(id)
  81 + Object.assign(formData, data)
  82 + } finally {
  83 + formLoading.value = false
  84 + }
  85 + }
  86 +}
  87 +
  88 +defineExpose({ open })
  89 +
  90 +const emit = defineEmits(['success'])
  91 +
  92 +/** 提交表单 */
  93 +const submitForm = async () => {
  94 + if (!formRef.value) return
  95 + const valid = await formRef.value.validate()
  96 + if (!valid) return
  97 +
  98 + submitLoading.value = true
  99 + try {
  100 + await StudentApi.updateStudent(formData)
  101 + message.success('修改成功')
  102 + dialogVisible.value = false
  103 + emit('success')
  104 + } finally {
  105 + submitLoading.value = false
  106 + }
  107 +}
  108 +
  109 +/** 重置表单 */
  110 +const resetForm = () => {
  111 + formData.id = undefined
  112 + formData.realname = ''
  113 + formData.phone = ''
  114 + formData.nickname = ''
  115 + formData.status = 1
  116 + formData.sex = 1
  117 + formData.avatar = ''
  118 + formData.signature = ''
  119 + formData.backgroundImage = ''
  120 + formRef.value?.resetFields()
  121 +}
  122 +</script>
  1 +<template>
  2 + <el-drawer title="学员健康资料" v-model="drawerVisible" size="600px">
  3 + <div v-loading="loading" class="health-data-container">
  4 + <el-descriptions :column="2" border v-if="healthData">
  5 + <el-descriptions-item label="学员姓名" :span="2">
  6 + {{ healthData.name || '-' }}
  7 + </el-descriptions-item>
  8 + <el-descriptions-item label="性别">
  9 + {{ healthData.genderDesc || (healthData.gender === 1 ? '男' : '女') }}
  10 + </el-descriptions-item>
  11 + <el-descriptions-item label="生日">
  12 + {{ healthData.birthday || '-' }}
  13 + </el-descriptions-item>
  14 + <el-descriptions-item label="身高">
  15 + {{ healthData.height ? `${healthData.height} cm` : '-' }}
  16 + </el-descriptions-item>
  17 + <el-descriptions-item label="当前体重">
  18 + {{ healthData.weight ? `${healthData.weight} kg` : '-' }}
  19 + </el-descriptions-item>
  20 + <el-descriptions-item label="目标体重" :span="2">
  21 + {{ healthData.targetWeight ? `${healthData.targetWeight} kg` : '-' }}
  22 + </el-descriptions-item>
  23 + <el-descriptions-item label="健身基础">
  24 + {{ healthData.hasFitnessFoundationDesc || (healthData.hasFitnessFoundation === 1 ? '有' : '无') }}
  25 + </el-descriptions-item>
  26 + <el-descriptions-item label="训练频次">
  27 + {{ healthData.acceptableTrainingFrequency ? `${healthData.acceptableTrainingFrequency} 次/周` : '-' }}
  28 + </el-descriptions-item>
  29 + <el-descriptions-item label="训练目标" :span="2">
  30 + {{ healthData.trainingGoalDesc || '-' }}
  31 + </el-descriptions-item>
  32 + <el-descriptions-item label="目标锻炼部位" :span="2">
  33 + <template v-if="healthData.targetMuscleParts && healthData.targetMuscleParts.length">
  34 + <el-tag
  35 + v-for="(part, index) in healthData.targetMuscleParts"
  36 + :key="index"
  37 + class="tag-item"
  38 + >
  39 + {{ part }}
  40 + </el-tag>
  41 + </template>
  42 + <span v-else>-</span>
  43 + </el-descriptions-item>
  44 + <el-descriptions-item label="身体疼痛部位" :span="2">
  45 + <template v-if="healthData.painAreas && healthData.painAreas.length">
  46 + <el-tag
  47 + v-for="(area, index) in healthData.painAreas"
  48 + :key="index"
  49 + type="danger"
  50 + class="tag-item"
  51 + >
  52 + {{ area === '0' ? '无' : area }}
  53 + </el-tag>
  54 + </template>
  55 + <span v-else>-</span>
  56 + </el-descriptions-item>
  57 + <el-descriptions-item label="是否存在疾病">
  58 + <el-tag :type="healthData.hasDisease ? 'danger' : 'success'">
  59 + {{ healthData.hasDisease ? '是' : '否' }}
  60 + </el-tag>
  61 + </el-descriptions-item>
  62 + <el-descriptions-item label="是否在服药">
  63 + <el-tag :type="healthData.isTakingMedication ? 'warning' : 'success'">
  64 + {{ healthData.isTakingMedication ? '是' : '否' }}
  65 + </el-tag>
  66 + </el-descriptions-item>
  67 + <el-descriptions-item label="日常健身场景" :span="2">
  68 + {{ healthData.fitnessSceneDesc || '-' }}
  69 + </el-descriptions-item>
  70 + <el-descriptions-item label="训练奖励方式" :span="2">
  71 + {{ healthData.rewardMethodDesc || '-' }}
  72 + </el-descriptions-item>
  73 + </el-descriptions>
  74 +
  75 + <el-empty v-else-if="!loading" description="暂无健康资料" />
  76 + </div>
  77 + </el-drawer>
  78 +</template>
  79 +
  80 +<script setup>
  81 +import { ref } from 'vue'
  82 +import * as StudentApi from '@/api/store/student'
  83 +
  84 +const drawerVisible = ref(false)
  85 +const loading = ref(false)
  86 +const healthData = ref(null)
  87 +
  88 +/** 打开抽屉并加载数据 */
  89 +const open = async (userId) => {
  90 + drawerVisible.value = true
  91 + loading.value = true
  92 + healthData.value = null
  93 + try {
  94 + const res = await StudentApi.getStudentHealthData(userId)
  95 + healthData.value = res
  96 + } finally {
  97 + loading.value = false
  98 + }
  99 +}
  100 +
  101 +defineExpose({ open })
  102 +</script>
  103 +
  104 +<style scoped>
  105 +.health-data-container {
  106 + padding: 8px;
  107 +}
  108 +
  109 +.tag-item {
  110 + margin-right: 6px;
  111 + margin-bottom: 4px;
  112 +}
  113 +</style>
  1 +<template>
  2 + <!-- 搜索栏 -->
  3 + <ContentWrap>
  4 + <el-form :model="queryParams" ref="queryFormRef" :inline="true" label-width="68px">
  5 + <el-form-item label="昵称" prop="nickname">
  6 + <el-input
  7 + v-model="queryParams.nickname"
  8 + placeholder="请输入昵称"
  9 + clearable
  10 + @keyup.enter="handleQuery"
  11 + class="!w-240px"
  12 + />
  13 + </el-form-item>
  14 + <el-form-item label="手机号" prop="phone">
  15 + <el-input
  16 + v-model="queryParams.phone"
  17 + placeholder="请输入手机号"
  18 + clearable
  19 + @keyup.enter="handleQuery"
  20 + class="!w-240px"
  21 + />
  22 + </el-form-item>
  23 + <el-form-item>
  24 + <el-button @click="handleQuery"><Icon icon="ep:search" /> 搜索</el-button>
  25 + <el-button @click="resetQuery"><Icon icon="ep:refresh" /> 重置</el-button>
  26 + </el-form-item>
  27 + </el-form>
  28 + </ContentWrap>
  29 +
  30 + <!-- 列表栏 -->
  31 + <ContentWrap>
  32 + <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
  33 + <el-table-column label="编号" align="center" prop="id" width="80" />
  34 + <el-table-column label="头像" align="center" prop="avatar" width="80">
  35 + <template #default="scope">
  36 + <el-avatar :src="scope.row.avatar" size="small" />
  37 + </template>
  38 + </el-table-column>
  39 + <el-table-column label="昵称" align="center" prop="nickname" />
  40 + <el-table-column label="真实姓名" align="center" prop="realname" />
  41 + <el-table-column label="手机号" align="center" prop="phone" width="120" />
  42 + <el-table-column label="性别" align="center" prop="sexStr" width="100">
  43 + <template #default="scope">
  44 + <el-tag :type="scope.row.sex === 1 ? 'primary' : 'danger'">
  45 + {{ scope.row.sexStr || (scope.row.sex === 1 ? '男' : '女') }}
  46 + </el-tag>
  47 + </template>
  48 + </el-table-column>
  49 + <el-table-column label="状态" align="center" prop="status" width="100">
  50 + <template #default="scope">
  51 + <el-tag :type="scope.row.status === 1 ? 'success' : 'info'">
  52 + {{ scope.row.statusStr || (scope.row.status === 1 ? '正常' : '禁用') }}
  53 + </el-tag>
  54 + </template>
  55 + </el-table-column>
  56 + <el-table-column label="操作" align="center" width="180">
  57 + <template #default="scope">
  58 + <el-button
  59 + link
  60 + type="primary"
  61 + @click="openForm(scope.row.id)"
  62 + >
  63 + 编辑
  64 + </el-button>
  65 + <el-button
  66 + link
  67 + type="primary"
  68 + @click="openHealthData(scope.row.id)"
  69 + >
  70 + 健康资料
  71 + </el-button>
  72 + </template>
  73 + </el-table-column>
  74 + </el-table>
  75 +
  76 + <!-- 分页 -->
  77 + <Pagination
  78 + :total="total"
  79 + v-model:page="queryParams.pageNo"
  80 + v-model:limit="queryParams.pageSize"
  81 + @pagination="getList"
  82 + />
  83 + </ContentWrap>
  84 +
  85 + <!-- 表单弹窗:修改用户 -->
  86 + <StudentForm ref="formRef" @success="getList" />
  87 +
  88 + <!-- 抽屉:学员健康资料 -->
  89 + <StudentHealthData ref="healthDataRef" />
  90 +</template>
  91 +
  92 +<script setup>
  93 +import { ref, reactive, onMounted } from 'vue'
  94 +import * as StudentApi from '@/api/store/student'
  95 +import StudentForm from './StudentForm.vue'
  96 +import StudentHealthData from './StudentHealthData.vue'
  97 +
  98 +const loading = ref(true)
  99 +const total = ref(0)
  100 +const list = ref([])
  101 +const queryParams = reactive({
  102 + pageNo: 1,
  103 + pageSize: 10,
  104 + phone: undefined,
  105 + nickname: undefined
  106 +})
  107 +const queryFormRef = ref()
  108 +const formRef = ref()
  109 +const healthDataRef = ref()
  110 +
  111 +/** 查询列表 */
  112 +const getList = async () => {
  113 + loading.value = true
  114 + try {
  115 + const data = await StudentApi.getStudentPage(queryParams)
  116 + list.value = data.list
  117 + total.value = data.total
  118 + } finally {
  119 + loading.value = false
  120 + }
  121 +}
  122 +
  123 +/** 搜索按钮操作 */
  124 +const handleQuery = () => {
  125 + queryParams.pageNo = 1
  126 + getList()
  127 +}
  128 +
  129 +/** 重置按钮操作 */
  130 +const resetQuery = () => {
  131 + queryFormRef.value?.resetFields()
  132 + handleQuery()
  133 +}
  134 +
  135 +/** 打开修改弹窗 */
  136 +const openForm = (id) => {
  137 + formRef.value.open(id)
  138 +}
  139 +
  140 +/** 打开健康资料抽屉 */
  141 +const openHealthData = (userId) => {
  142 + healthDataRef.value.open(userId)
  143 +}
  144 +
  145 +/** 初始化 */
  146 +onMounted(() => {
  147 + getList()
  148 +})
  149 +</script>
@@ -10,7 +10,7 @@ @@ -10,7 +10,7 @@
10 <el-select v-model="formData.exerciseType" placeholder="请选择动作类型" class="w-full"> 10 <el-select v-model="formData.exerciseType" placeholder="请选择动作类型" class="w-full">
11 <el-option label="无氧运动" :value="0" /> 11 <el-option label="无氧运动" :value="0" />
12 <el-option label="有氧运动" :value="1" /> 12 <el-option label="有氧运动" :value="1" />
13 - <el-option label="次训练" :value="2" /> 13 + <el-option label="次训练" :value="2" />
14 <el-option label="计时训练" :value="3" /> 14 <el-option label="计时训练" :value="3" />
15 <el-option label="自重加重" :value="4" /> 15 <el-option label="自重加重" :value="4" />
16 <el-option label="自重减重" :value="5" /> 16 <el-option label="自重减重" :value="5" />
1 <template> 1 <template>
2 - <el-dialog v-model="dialogVisible" title="训练记录详情" width="600px"> 2 + <Dialog :title="dialogTitle" v-model="dialogVisible" width="700px">
3 <div v-loading="loading"> 3 <div v-loading="loading">
4 - <!-- 基础信息(一列) --> 4 + <!-- 基础信息 -->
5 <el-descriptions v-if="detailData" border :column="1" label-width="120px"> 5 <el-descriptions v-if="detailData" border :column="1" label-width="120px">
6 <el-descriptions-item label="学员"> 6 <el-descriptions-item label="学员">
7 {{ detailData.username || '-' }} 7 {{ detailData.username || '-' }}
@@ -13,15 +13,10 @@ @@ -13,15 +13,10 @@
13 13
14 <!-- 训练单元列表 --> 14 <!-- 训练单元列表 -->
15 <div v-if="detailData.units && detailData.units.length > 0" class="unit-section"> 15 <div v-if="detailData.units && detailData.units.length > 0" class="unit-section">
16 - <h3 style="margin: 20px 0 10px; color: #333;">训练单元列表</h3> 16 + <h3 class="section-title">训练单元列表</h3>
17 17
18 - <!-- 循环每个训练单元 -->  
19 <div v-for="(unit, unitIndex) in detailData.units" :key="unit.id || unitIndex" class="unit-card"> 18 <div v-for="(unit, unitIndex) in detailData.units" :key="unit.id || unitIndex" class="unit-card">
20 - <!-- 单元基础信息(改成一列) -->  
21 <el-descriptions border :column="1" label-width="120px" size="small"> 19 <el-descriptions border :column="1" label-width="120px" size="small">
22 - <!-- <el-descriptions-item label="单元ID">  
23 - {{ unit.id || '-' }}  
24 - </el-descriptions-item> -->  
25 <el-descriptions-item label="单元名称"> 20 <el-descriptions-item label="单元名称">
26 {{ unit.name || '-' }} 21 {{ unit.name || '-' }}
27 </el-descriptions-item> 22 </el-descriptions-item>
@@ -33,27 +28,26 @@ @@ -33,27 +28,26 @@
33 </el-descriptions-item> 28 </el-descriptions-item>
34 </el-descriptions> 29 </el-descriptions>
35 30
36 - <!-- 动作列表(改成一列描述列表) --> 31 + <!-- 动作列表 -->
37 <div v-if="unit.exercises && unit.exercises.length > 0" class="exercise-section"> 32 <div v-if="unit.exercises && unit.exercises.length > 0" class="exercise-section">
38 - <h4 style="margin: 15px 0 8px; color: #409EFF;">动作列表</h4> 33 + <h4 class="sub-title">动作列表</h4>
39 34
40 - <!-- 循环每个动作 -->  
41 - <div v-for="(exercise, exIndex) in unit.exercises" :key="exercise.exerciseId || exIndex"  
42 - class="exercise-card"> 35 + <div v-for="(exercise, exIndex) in unit.exercises" :key="exercise.exerciseId || exIndex" class="exercise-card">
43 <el-descriptions border :column="1" label-width="120px" size="small"> 36 <el-descriptions border :column="1" label-width="120px" size="small">
44 - <!-- <el-descriptions-item label="动作ID">  
45 - {{ exercise.exerciseId || '-' }}  
46 - </el-descriptions-item> -->  
47 <el-descriptions-item label="排序"> 37 <el-descriptions-item label="排序">
48 {{ exercise.innerOrder || '-' }} 38 {{ exercise.innerOrder || '-' }}
49 </el-descriptions-item> 39 </el-descriptions-item>
50 <el-descriptions-item label="动作名称"> 40 <el-descriptions-item label="动作名称">
51 {{ exercise.name || '-' }} 41 {{ exercise.name || '-' }}
52 </el-descriptions-item> 42 </el-descriptions-item>
53 - <!-- 训练组详情(一列) -->  
54 <el-descriptions-item label="训练组详情"> 43 <el-descriptions-item label="训练组详情">
55 - <el-table v-if="exercise.sets && exercise.sets.length > 0" :data="exercise.sets" border size="small"  
56 - stripe style="width: 100%;"> 44 + <el-table
  45 + v-if="exercise.sets && exercise.sets.length > 0"
  46 + :data="exercise.sets"
  47 + border
  48 + size="small"
  49 + stripe
  50 + >
57 <el-table-column label="组号" width="80"> 51 <el-table-column label="组号" width="80">
58 <template #default="scope"> 52 <template #default="scope">
59 {{ scope.$index + 1 }} 53 {{ scope.$index + 1 }}
@@ -68,9 +62,8 @@ @@ -68,9 +62,8 @@
68 </el-table-column> 62 </el-table-column>
69 <el-table-column prop="restTime" label="休息(秒)" width="100" /> 63 <el-table-column prop="restTime" label="休息(秒)" width="100" />
70 </el-table> 64 </el-table>
71 - <div v-else>无训练组数据</div> 65 + <div v-else class="empty-text">无训练组数据</div>
72 </el-descriptions-item> 66 </el-descriptions-item>
73 - <!-- -->  
74 </el-descriptions> 67 </el-descriptions>
75 </div> 68 </div>
76 </div> 69 </div>
@@ -79,69 +72,99 @@ @@ -79,69 +72,99 @@
79 </div> 72 </div>
80 73
81 <template #footer> 74 <template #footer>
82 - <el-button @click="dialogVisible = false">关闭</el-button> 75 + <el-button @click="dialogVisible = false">关 闭</el-button>
83 </template> 76 </template>
84 - </el-dialog> 77 + </Dialog>
85 </template> 78 </template>
86 79
87 -  
88 <script setup> 80 <script setup>
89 import { ref, watch, computed } from 'vue' 81 import { ref, watch, computed } from 'vue'
90 import { stuTrainingRecordsApi } from '@/api/store/training/stuTrainingRecord' 82 import { stuTrainingRecordsApi } from '@/api/store/training/stuTrainingRecord'
91 83
92 -const loading = ref(false)  
93 -const detailData = ref({})  
94 - 84 +// ===================== Props & Emits =====================
95 const props = defineProps({ 85 const props = defineProps({
96 visible: { type: Boolean, default: false }, 86 visible: { type: Boolean, default: false },
97 id: { type: [Number, String], default: null } 87 id: { type: [Number, String], default: null }
98 }) 88 })
99 -  
100 const emit = defineEmits(['update:visible']) 89 const emit = defineEmits(['update:visible'])
101 90
102 const dialogVisible = computed({ 91 const dialogVisible = computed({
103 get: () => props.visible, 92 get: () => props.visible,
104 set: (val) => emit('update:visible', val) 93 set: (val) => emit('update:visible', val)
105 }) 94 })
  95 +const dialogTitle = ref('训练记录详情')
106 96
107 -watch(() => props.id, (newId) => {  
108 - if (newId) getStuTrainingDetail(newId)  
109 -})  
110 -// 转换单元类型 97 +// ===================== 状态 =====================
  98 +const loading = ref(false)
  99 +const detailData = ref({})
  100 +
  101 +// ===================== 工具函数 =====================
111 const formatUnitType = (type) => { 102 const formatUnitType = (type) => {
112 if (type === 1) return '动作组' 103 if (type === 1) return '动作组'
113 if (type === 2) return '超级组' 104 if (type === 2) return '超级组'
114 return '未知类型' 105 return '未知类型'
115 } 106 }
  107 +
  108 +// ===================== 数据加载 =====================
116 const getStuTrainingDetail = async (id) => { 109 const getStuTrainingDetail = async (id) => {
117 loading.value = true 110 loading.value = true
118 try { 111 try {
119 const res = await stuTrainingRecordsApi.getStuTrainingRecordDetail(id) 112 const res = await stuTrainingRecordsApi.getStuTrainingRecordDetail(id)
120 detailData.value = res || {} 113 detailData.value = res || {}
121 - } catch (err) {  
122 - console.error('请求失败', err)  
123 } finally { 114 } finally {
124 loading.value = false 115 loading.value = false
125 } 116 }
126 } 117 }
  118 +
  119 +// ===================== 监听弹窗打开 / id 变化加载详情 =====================
  120 +watch(
  121 + [() => props.visible, () => props.id],
  122 + ([visible, newId]) => {
  123 + if (visible && newId) getStuTrainingDetail(newId)
  124 + },
  125 + { immediate: true }
  126 +)
127 </script> 127 </script>
128 128
129 <style scoped> 129 <style scoped>
130 -/* 单元卡片 */ 130 +.unit-section {
  131 + margin-top: 20px;
  132 +}
  133 +
  134 +.section-title {
  135 + margin: 0 0 12px;
  136 + padding-left: 10px;
  137 + font-size: 16px;
  138 + font-weight: 600;
  139 + color: var(--el-text-color-primary);
  140 + border-left: 4px solid var(--el-color-primary);
  141 +}
  142 +
  143 +.sub-title {
  144 + margin: 16px 0 10px;
  145 + font-size: 14px;
  146 + font-weight: 600;
  147 + color: var(--el-color-primary);
  148 +}
  149 +
131 .unit-card { 150 .unit-card {
132 padding: 15px; 151 padding: 15px;
133 margin-bottom: 20px; 152 margin-bottom: 20px;
134 - background: #fafafa;  
135 - border: 1px solid #ebeef5; 153 + background: var(--el-fill-color-lighter);
  154 + border: 1px solid var(--el-border-color-lighter);
136 border-radius: 8px; 155 border-radius: 8px;
137 } 156 }
138 157
139 -/* 动作卡片 */  
140 .exercise-card { 158 .exercise-card {
141 padding: 10px; 159 padding: 10px;
142 margin-top: 15px; 160 margin-top: 15px;
143 - background: #fff;  
144 - border: 1px solid #e4e7ed; 161 + background: var(--el-bg-color);
  162 + border: 1px solid var(--el-border-color-light);
145 border-radius: 6px; 163 border-radius: 6px;
146 } 164 }
147 -</style>  
  165 +
  166 +.empty-text {
  167 + padding: 10px;
  168 + color: var(--el-text-color-secondary);
  169 +}
  170 +</style>
1 <template> 1 <template>
2 <div class="page-container"> 2 <div class="page-container">
3 - <ContentWrap class="search-bar">  
4 - <div class="search-item">  
5 - <label class="search-label">学员</label>  
6 - <el-input v-model="searchForm.name" placeholder="请输入" class="search-input" @keyup.enter="handleSearch"  
7 - clearable />  
8 - <div class="search-buttons"> 3 + <!-- 搜索栏 -->
  4 + <ContentWrap class="mb-20px">
  5 + <el-form :inline="true" :model="queryParams">
  6 + <el-form-item label="学员" prop="name">
  7 + <el-input v-model="queryParams.name" placeholder="请输入" clearable class="!w-220px"
  8 + @keyup.enter="handleSearch" />
  9 + </el-form-item>
  10 + <el-form-item>
9 <el-button type="primary" @click="handleSearch"> 11 <el-button type="primary" @click="handleSearch">
10 - <el-icon>  
11 - <Search />  
12 - </el-icon>  
13 - 搜索 12 + <Icon icon="ep:search" class="mr-5px" />搜索
14 </el-button> 13 </el-button>
15 <el-button @click="handleReset"> 14 <el-button @click="handleReset">
16 - <el-icon>  
17 - <Refresh />  
18 - </el-icon>  
19 - 重置 15 + <Icon icon="ep:refresh" class="mr-5px" />重置
20 </el-button> 16 </el-button>
21 - <!-- <el-button type="primary" @click="openAddDialog">  
22 - <el-icon>  
23 - <Plus />  
24 - </el-icon>  
25 - 新增  
26 - </el-button> -->  
27 - </div>  
28 - </div> 17 + </el-form-item>
  18 + </el-form>
29 </ContentWrap> 19 </ContentWrap>
30 20
31 - <!-- 列表区域 -->  
32 - <!-- <div class="table-container"> --> 21 + <!-- 表格区域 -->
33 <ContentWrap> 22 <ContentWrap>
34 - <el-table :data="tableData" v-loading="loading" empty-text="暂无数据" border> 23 + <el-table v-loading="loading" :data="tableData" empty-text="暂无数据" stripe>
35 <el-table-column prop="username" label="学员" align="center" /> 24 <el-table-column prop="username" label="学员" align="center" />
36 <el-table-column prop="name" label="训练计划名称" align="center" /> 25 <el-table-column prop="name" label="训练计划名称" align="center" />
37 <el-table-column label="组数" align="center" :formatter="formatSetCount" /> 26 <el-table-column label="组数" align="center" :formatter="formatSetCount" />
38 <el-table-column label="训练重量" align="center" :formatter="formatWeight" /> 27 <el-table-column label="训练重量" align="center" :formatter="formatWeight" />
39 <el-table-column prop="duration" label="训练时长(分钟)" align="center" /> 28 <el-table-column prop="duration" label="训练时长(分钟)" align="center" />
40 - <!-- <el-table-column prop="date" label="训练日期" align="center" :formatter="dateFormatter" /> -->  
41 - <el-table-column prop="createTime" label="创建时间" align="center" :formatter="dateFormatter" />  
42 - <el-table-column prop="updateTime" label="更新时间" align="center" :formatter="dateFormatter" /> 29 + <el-table-column prop="startTime" label="开始时间" align="center" :formatter="dateFormatter" />
  30 + <el-table-column prop="endTime" label="结束时间" align="center" :formatter="dateFormatter" />
43 <el-table-column label="操作" align="center" width="150"> 31 <el-table-column label="操作" align="center" width="150">
44 <template #default="scope"> 32 <template #default="scope">
45 - <el-button type="text" size="small" style=" margin-right: 8px;color: #409EFF;"  
46 - @click="openDetailDialog(scope.row)">详情</el-button>  
47 - <el-button type="text" size="small" style="color: #F56C6C;"  
48 - @click="handleDelete(scope.row.id)">删除</el-button> 33 + <el-button link type="primary" @click="openDetailDialog(scope.row)">详情</el-button>
  34 + <el-button link type="danger" @click="handleDelete(scope.row.id)">删除</el-button>
49 </template> 35 </template>
50 </el-table-column> 36 </el-table-column>
51 </el-table> 37 </el-table>
52 - <!-- 分页组件 -->  
53 - <div class="pagination-wrapper" style="padding: 12px 16px;">  
54 - <el-pagination v-model:current-page="pagination.pageNo" v-model:page-size="pagination.pageSize"  
55 - :total="pagination.total" :page-sizes="[10, 20, 50, 100]" layout="total, sizes, prev, pager, next, jumper"  
56 - style="text-align: left;" @size-change="handleSizeChange" @current-change="handleCurrentChange" />  
57 - </div> 38 + <Pagination :total="queryParams.total" v-model:page="queryParams.pageNo" v-model:limit="queryParams.pageSize"
  39 + @pagination="getList" />
58 </ContentWrap> 40 </ContentWrap>
59 - <!-- </div> --> 41 +
60 <!-- 详情弹窗 --> 42 <!-- 详情弹窗 -->
61 <StuTrainingDetailDialog v-model:visible="detailVisible" :id="currentId" /> 43 <StuTrainingDetailDialog v-model:visible="detailVisible" :id="currentId" />
62 </div> 44 </div>
63 </template> 45 </template>
64 46
65 <script setup> 47 <script setup>
66 -import { ref, reactive } from 'vue'  
67 -// 导入图标  
68 -import { Search, Refresh, Plus } from '@element-plus/icons-vue'  
69 -import { stuTrainingRecordsApi } from '@/api/store/training/stuTrainingRecord.ts'  
70 -import { ElMessage } from 'element-plus'  
71 -import { dateFormatter } from '@/utils/formatTime'  
72 -import lostImg from '@/assets/imgs/lost.png' 48 +import { ref, reactive, onMounted } from 'vue'
  49 +
  50 +import { stuTrainingRecordsApi } from '@/api/store/training/stuTrainingRecord'
73 import StuTrainingDetailDialog from './StuTrainingDetailDialog.vue' 51 import StuTrainingDetailDialog from './StuTrainingDetailDialog.vue'
74 -// 搜索表单  
75 -const searchForm = reactive({ 52 +import { dateFormatter } from '@/utils/formatTime'
  53 +
  54 +// ===================== 查询参数 =====================
  55 +const queryParams = reactive({
  56 + pageNo: 1,
  57 + pageSize: 10,
  58 + total: 0,
76 name: '' 59 name: ''
77 }) 60 })
78 -  
79 -// 加载状态  
80 const loading = ref(false) 61 const loading = ref(false)
81 -  
82 -// 表格数据(3组模拟数据)  
83 const tableData = ref([]) 62 const tableData = ref([])
84 63
85 -// 分页参数  
86 -const pagination = reactive({  
87 - pageNo: 1,  
88 - pageSize: 10,  
89 - total: 0  
90 -})  
91 -// 详情弹窗 64 +// ===================== 详情弹窗 =====================
92 const detailVisible = ref(false) 65 const detailVisible = ref(false)
93 -const currentId = ref(null) // 存储当前点击的id  
94 -// ========================列表格式化==========  
95 -// 格式化组数(在数字后加“组”) 66 +const currentId = ref(null)
  67 +
  68 +// ===================== 表格格式化 =====================
96 const formatSetCount = (row) => { 69 const formatSetCount = (row) => {
97 - return row.setCount ? `${row.setCount}组` : '0组' 70 + return row.setCount ? row.setCount + '组' : '0组'
98 } 71 }
99 -// 格式化重量  
100 const formatWeight = (row) => { 72 const formatWeight = (row) => {
101 - return row.weight ? `${row.weight}kg` : '0kg' 73 + return row.weight ? row.weight + 'kg' : '0kg'
102 } 74 }
103 -// 获取学员训练记录列表 75 +
  76 +// ===================== 数据加载 =====================
104 const getList = async () => { 77 const getList = async () => {
105 loading.value = true 78 loading.value = true
106 try { 79 try {
107 - // 搜索条件  
108 - const params = {  
109 - pageNo: pagination.pageNo + '',  
110 - pageSize: pagination.pageSize + '',  
111 - name: searchForm.name,  
112 - }  
113 - const res = await stuTrainingRecordsApi.getstuTrainingRecordPage(params) 80 + const res = await stuTrainingRecordsApi.getstuTrainingRecordPage({
  81 + pageNo: queryParams.pageNo + '',
  82 + pageSize: queryParams.pageSize + '',
  83 + name: queryParams.name
  84 + })
114 tableData.value = res.list || [] 85 tableData.value = res.list || []
115 - pagination.total = res.total || 0  
116 - console.log('学员训练记录分页接口返回数据:', res) 86 + queryParams.total = res.total || 0
117 } catch (err) { 87 } catch (err) {
118 - console.error('❌ 请求失败:', err)  
119 ElMessage.error('请求失败,请检查网络或接口') 88 ElMessage.error('请求失败,请检查网络或接口')
120 } finally { 89 } finally {
121 loading.value = false 90 loading.value = false
122 } 91 }
123 } 92 }
124 -// 搜索 93 +
  94 +// ===================== 搜索与重置 =====================
125 const handleSearch = () => { 95 const handleSearch = () => {
126 - pagination.pageNo = 1 // 搜索时重置到第一页 96 + queryParams.pageNo = 1
127 getList() 97 getList()
128 } 98 }
129 99
130 -// 重置  
131 const handleReset = () => { 100 const handleReset = () => {
132 - pagination.pageNo = 1  
133 - pagination.pageSize = 10  
134 - searchForm.name = '' 101 + Object.assign(queryParams, { pageNo: 1, pageSize: 10, total: 0, name: '' })
135 getList() 102 getList()
136 -  
137 } 103 }
138 104
139 -// 详情 105 +// ===================== 详情 =====================
140 const openDetailDialog = (row) => { 106 const openDetailDialog = (row) => {
141 currentId.value = row.id 107 currentId.value = row.id
142 detailVisible.value = true 108 detailVisible.value = true
143 } 109 }
144 110
145 -// 处理删除 111 +// ===================== 删除 =====================
146 const handleDelete = async (id) => { 112 const handleDelete = async (id) => {
147 try { 113 try {
148 await ElMessageBox.confirm('确定要删除吗?', '提示', { 114 await ElMessageBox.confirm('确定要删除吗?', '提示', {
@@ -150,82 +116,27 @@ const handleDelete = async (id) => { @@ -150,82 +116,27 @@ const handleDelete = async (id) => {
150 cancelButtonText: '取消', 116 cancelButtonText: '取消',
151 type: 'warning' 117 type: 'warning'
152 }) 118 })
153 -  
154 - // 能走到这里 = 用户点了确定  
155 await stuTrainingRecordsApi.deleteStuTrainingRecordPage(id) 119 await stuTrainingRecordsApi.deleteStuTrainingRecordPage(id)
156 ElMessage.success('删除成功') 120 ElMessage.success('删除成功')
  121 + getList()
157 } catch (err) { 122 } catch (err) {
158 - // 先打印,方便调试  
159 - console.log('实际错误内容:', err, typeof err)  
160 - // 核心判断:err 是字符串 'cancel'  
161 - if (err === 'cancel') {  
162 - ElMessage.info('已取消删除')  
163 - return // 直接退出,不执行后面的错误处理 123 + if (err !== 'cancel') {
  124 + ElMessage.error('删除失败')
164 } 125 }
165 - // 只有非 cancel 的错误才会走到这里  
166 - console.error('❌ 请求失败:', err)  
167 - ElMessage.error('请求失败,请检查网络或接口')  
168 - } finally {  
169 - getList() // 刷新列表  
170 } 126 }
171 } 127 }
172 128
173 -// 处理分页大小变化  
174 -const handleSizeChange = (val) => {  
175 - pagination.pageSize = val  
176 - getList()  
177 -}  
178 -  
179 -const handleCurrentChange = (val) => {  
180 - pagination.pageNo = val  
181 - getList()  
182 -}  
183 - 129 +// ===================== 初始化 =====================
184 onMounted(() => { 130 onMounted(() => {
185 getList() 131 getList()
186 }) 132 })
187 </script> 133 </script>
188 -<style scoped>  
189 -/* .page-container {  
190 -  
191 -} */  
192 -  
193 -/* 分页居右 */  
194 -.pagination-wrapper {  
195 - display: flex;  
196 - padding: 12px 16px;  
197 - justify-content: flex-end;  
198 -}  
199 -  
200 -.search-bar {  
201 - /* margin-bottom: 20px; */  
202 - margin-bottom: 20px;  
203 - box-shadow: 0 1px 2px rgb(0 0 0 / 5%);  
204 -}  
205 -  
206 -.search-item {  
207 - display: flex;  
208 - align-items: center;  
209 - gap: 10px;  
210 - padding: 15px 20px;  
211 -}  
212 -  
213 -.search-label {  
214 - /* width: 160px; */  
215 - font-weight: 500;  
216 -}  
217 134
218 -.search-input {  
219 - width: 260px;  
220 -}  
221 -  
222 -.search-buttons {  
223 - display: flex;  
224 - gap: 10px;  
225 - margin-left: 10px;  
226 -}  
227 -  
228 -.table-container {  
229 - width: 100%; 135 +<style scoped>
  136 +.page-container {
  137 + padding: 20px;
  138 + min-height: 100vh;
  139 + background-color: var(--el-bg-color-page);
  140 + box-sizing: border-box;
230 } 141 }
231 -</style>  
  142 +</style>
1 <template> 1 <template>
2 <div class="supersets-page"> 2 <div class="supersets-page">
3 - <ContentWrap class="plan-search-bar">  
4 - <!-- 1. 搜索栏区域 -->  
5 - <div class="search-bar">  
6 - <div class="search-item">  
7 - <label class="search-label">超级组名称</label>  
8 - <el-input v-model="searchForm.name" placeholder="请输入" class="search-input" @keyup.enter="handleSearch"  
9 - clearable />  
10 - <!-- 按钮组 -->  
11 - <div class="search-buttons">  
12 - <el-button type="primary" @click="handleSearch">  
13 - <el-icon>  
14 - <Search />  
15 - </el-icon>  
16 - 搜索  
17 - </el-button>  
18 - <el-button @click="handleReset">  
19 - <el-icon>  
20 - <Refresh />  
21 - </el-icon>  
22 - 重置  
23 - </el-button>  
24 - <el-button type="primary" @click="openAddDialog">  
25 - <el-icon>  
26 - <Plus />  
27 - </el-icon>  
28 - 新增  
29 - </el-button>  
30 - </div>  
31 - </div>  
32 - </div> 3 + <!-- 搜索栏 -->
  4 + <ContentWrap class="mb-20px">
  5 + <el-form :inline="true" :model="queryParams" class="flex items-center">
  6 + <el-form-item label="超级组名称" prop="name">
  7 + <el-input
  8 + v-model="queryParams.name"
  9 + placeholder="请输入"
  10 + clearable
  11 + class="!w-220px"
  12 + @keyup.enter="handleSearch"
  13 + />
  14 + </el-form-item>
  15 + <el-form-item>
  16 + <el-button type="primary" @click="handleSearch">
  17 + <Icon icon="ep:search" class="mr-5px" />搜索
  18 + </el-button>
  19 + <el-button @click="handleReset">
  20 + <Icon icon="ep:refresh" class="mr-5px" />重置
  21 + </el-button>
  22 + <el-button type="primary" @click="openAddDialog">
  23 + <Icon icon="ep:plus" class="mr-5px" />新增
  24 + </el-button>
  25 + </el-form-item>
  26 + </el-form>
33 </ContentWrap> 27 </ContentWrap>
34 - <ContentWrap>  
35 - <!-- 2. 表格展示区域 -->  
36 - <div class="table-container">  
37 - <el-table :data="tableData" style="width: 100%" empty-text="暂无数据" stripe>  
38 - <el-table-column prop="name" label="超级组名称" align="center" min-width="200" />  
39 - <!-- <el-table-column label="封面" align="center" width="200">  
40 - <template #default>  
41 - <img :src="coverImgUrl" class="cover-img" alt="封面" />  
42 - </template>  
43 -</el-table-column> -->  
44 - <el-table-column prop="createTime" label="创建时间" align="center" width="220" :formatter="dateFormatter" />  
45 - <el-table-column prop="updateTime" label="更新时间" align="center" min-width="180" :formatter="dateFormatter" />  
46 - <el-table-column label="操作" align="center" width="200">  
47 - <template #default="scope">  
48 - <el-button type="text" class="edit-btn" @click="handleEdit(scope.row)">编辑</el-button>  
49 - <el-button type="text" class="delete-btn" @click="handleDelete(scope.row)">删除</el-button>  
50 - </template>  
51 - </el-table-column>  
52 - </el-table>  
53 - </div>  
54 28
55 - <!-- 3. 分页区域 -->  
56 - <div class="pagination-container">  
57 - <span class="total">共{{ total }}条</span>  
58 - <el-select v-model="pageSize" @change="handlePageSizeChange" class="page-size-select">  
59 - <el-option label="10条/页" :value="10" />  
60 - <el-option label="20条/页" :value="20" />  
61 - <el-option label="50条/页" :value="50" />  
62 - </el-select>  
63 - <el-pagination v-model:current-page="pageNo" v-model:page-size="pageSize" :total="total"  
64 - layout="prev, pager, next, jumper" @size-change="handlePageSizeChange" @current-change="handlePageChange" />  
65 - </div> 29 + <!-- 表格区域 -->
  30 + <ContentWrap>
  31 + <el-table v-loading="loading" :data="tableData" empty-text="暂无数据" stripe>
  32 + <el-table-column prop="name" label="超级组名称" align="center" min-width="200" />
  33 + <el-table-column prop="createTime" label="创建时间" align="center" width="220" :formatter="dateFormatter" />
  34 + <el-table-column prop="updateTime" label="更新时间" align="center" min-width="180" :formatter="dateFormatter" />
  35 + <el-table-column label="操作" align="center" width="200">
  36 + <template #default="scope">
  37 + <el-button link type="primary" @click="handleEdit(scope.row)">编辑</el-button>
  38 + <el-button link type="danger" @click="handleDelete(scope.row)">删除</el-button>
  39 + </template>
  40 + </el-table-column>
  41 + </el-table>
  42 + <Pagination
  43 + :total="total"
  44 + v-model:page="queryParams.pageNo"
  45 + v-model:limit="queryParams.pageSize"
  46 + @pagination="getList"
  47 + />
66 </ContentWrap> 48 </ContentWrap>
67 - <!-- 4. 新增/编辑弹窗 -->  
68 - <el-dialog v-model="dialogVisible" :title="dialogTitle" width="800px" :close-on-click-modal="false"  
69 - @close="resetForm" class="supersets-dialog">  
70 - <el-form ref="formRef" :model="formData" :rules="formRules" label-width="120px" class="add-form"> 49 +
  50 + <!-- 新增/编辑弹窗 -->
  51 + <Dialog :title="dialogTitle" v-model="dialogVisible" width="800px">
  52 + <el-form ref="formRef" :model="formData" :rules="formRules" label-width="120px">
71 <el-form-item label="超级组名称" prop="name"> 53 <el-form-item label="超级组名称" prop="name">
72 <el-input v-model="formData.name" placeholder="请输入" clearable /> 54 <el-input v-model="formData.name" placeholder="请输入" clearable />
73 </el-form-item> 55 </el-form-item>
74 <el-form-item label="动作" prop="exerciseIds"> 56 <el-form-item label="动作" prop="exerciseIds">
75 - <el-select v-model="formData.exerciseIds" placeholder="请选择动作" multiple clearable style="width: 100%"  
76 - filterable>  
77 - <el-option v-for="item in exercisesList" :key="item.id" :label="item.name" :value="item.id" /> 57 + <el-select
  58 + v-model="formData.exerciseIds"
  59 + placeholder="请选择动作"
  60 + multiple
  61 + clearable
  62 + filterable
  63 + style="width: 100%"
  64 + >
  65 + <el-option
  66 + v-for="item in exercisesList"
  67 + :key="item.id"
  68 + :label="item.name"
  69 + :value="item.id"
  70 + />
78 </el-select> 71 </el-select>
79 </el-form-item> 72 </el-form-item>
80 - <!-- 实现动作的主要,次要训练部位的回显,只能显示给用户看 -->  
81 <!-- 主要训练部位(自动回显,只读) --> 73 <!-- 主要训练部位(自动回显,只读) -->
82 <el-form-item label="主要训练部位"> 74 <el-form-item label="主要训练部位">
83 - <el-input :value="primaryMusclesText" disabled placeholder="选择动作后自动显示" style="width: 100%" /> 75 + <el-input :value="primaryMusclesText" disabled placeholder="选择动作后自动显示" />
84 </el-form-item> 76 </el-form-item>
85 -  
86 <!-- 次要训练部位(自动回显,只读) --> 77 <!-- 次要训练部位(自动回显,只读) -->
87 <el-form-item label="次要训练部位"> 78 <el-form-item label="次要训练部位">
88 - <el-input :value="secondaryMusclesText" disabled placeholder="选择动作后自动显示" style="width: 100%" /> 79 + <el-input :value="secondaryMusclesText" disabled placeholder="选择动作后自动显示" />
89 </el-form-item> 80 </el-form-item>
90 </el-form> 81 </el-form>
91 <template #footer> 82 <template #footer>
92 - <div class="dialog-footer">  
93 - <el-button @click="dialogVisible = false">取消</el-button>  
94 - <el-button type="primary" @click="isEdit ? submitEditForm() : submitAddForm()">  
95 - {{ isEdit ? '修改' : '保存' }}  
96 - </el-button>  
97 - </div> 83 + <el-button type="primary" @click="submitForm">确 定</el-button>
  84 + <el-button @click="dialogVisible = false">取 消</el-button>
98 </template> 85 </template>
99 - </el-dialog> 86 + </Dialog>
100 </div> 87 </div>
101 </template> 88 </template>
102 89
103 -<script setup lang="ts">  
104 -import { ref, onMounted, reactive, computed } from 'vue'  
105 -// import { useRouter } from 'vue-router'  
106 -import { ElMessage, ElMessageBox, ElForm } from 'element-plus'  
107 -import { Search, Refresh, Plus } from '@element-plus/icons-vue' 90 +<script setup>
  91 +import { ref, onMounted, reactive, computed, watch } from 'vue'
  92 +import { ElMessage, ElMessageBox } from 'element-plus'
108 import { SupersetsApi } from '@/api/store/training/Supersets' 93 import { SupersetsApi } from '@/api/store/training/Supersets'
109 import { ExercisesApi } from '@/api/store/training/pose' 94 import { ExercisesApi } from '@/api/store/training/pose'
110 -// import { MotionCategoryApi } from '@/api/store/training/partCategory'  
111 -import { MusclesApi } from '@/api/store/training/muscle'  
112 import { dateFormatter } from '@/utils/formatTime' 95 import { dateFormatter } from '@/utils/formatTime'
113 -import coverImgUrl from '@/assets/imgs/lost.png'  
114 -// const router = useRouter()  
115 -const searchForm = ref({ 96 +
  97 +// ===================== 查询参数 =====================
  98 +const queryParams = reactive({
  99 + pageNo: 1,
  100 + pageSize: 10,
116 name: '' 101 name: ''
117 }) 102 })
118 -const pageNo = ref(1)  
119 -const pageSize = ref(10)  
120 const total = ref(0) 103 const total = ref(0)
121 -const tableData = ref<any[]>([]) 104 +const loading = ref(false)
  105 +const tableData = ref([])
  106 +
  107 +// ===================== 弹窗状态 =====================
122 const dialogVisible = ref(false) 108 const dialogVisible = ref(false)
123 const isEdit = ref(false) 109 const isEdit = ref(false)
124 -const dialogTitle = ref('超级组')  
125 -const formRef = ref<InstanceType<typeof ElForm>>() 110 +const dialogTitle = ref('')
  111 +const formRef = ref(null)
126 112
127 -const exercisesList = ref<any[]>([])  
128 -// const categoryList = ref<any[]>([])  
129 -const muscleList = ref<any[]>([]) 113 +// ===================== 选项数据 =====================
  114 +const exercisesList = ref([])
  115 +const detailExercises = ref([]) // 编辑时存储详情接口返回的动作数据(含肌肉名称)
130 116
  117 +// ===================== 表单数据 =====================
131 const formData = reactive({ 118 const formData = reactive({
132 id: 0, 119 id: 0,
133 name: '', 120 name: '',
134 - primaryMuscles: [] as number[],  
135 - secondaryMuscles: [] as number[],  
136 - exerciseIds: [] as number[],  
137 - category: ''  
138 -})  
139 -  
140 -// ===================== 自动汇总显示动作的部位(只读) =====================  
141 -// 解析动作里的部位字符串  
142 -const parseMuscles = (str: string) => {  
143 - try { return JSON.parse(str) } catch { return [] }  
144 -}  
145 -  
146 -// 主要训练部位汇总文本  
147 -// 根据ID获取肌肉名称  
148 -const getMuscleName = (id) => {  
149 - const muscle = muscleList.value.find(item => item.id == id)  
150 - return muscle ? muscle.name : id  
151 -}  
152 -// 肌肉回显  
153 -const primaryMusclesText = computed(() => {  
154 - if (!formData.exerciseIds.length) return ''  
155 - const idSet = new Set()  
156 -  
157 - formData.exerciseIds.forEach(exId => {  
158 - const item = exercisesList.value.find(e => e.id === exId)  
159 - if (!item) return  
160 - const arr = parseMuscles(item.primaryMuscles || '[]')  
161 - arr.forEach(id => idSet.add(id))  
162 - })  
163 -  
164 - return Array.from(idSet).map(id => (id)).join('、')  
165 -})  
166 -  
167 -const secondaryMusclesText = computed(() => {  
168 - if (!formData.exerciseIds.length) return ''  
169 - const idSet = new Set()  
170 -  
171 - console.log('formData.exerciseIds=', formData.exerciseIds);  
172 -  
173 - formData.exerciseIds.forEach(exId => {  
174 - const item = exercisesList.value.find(e => e.id === exId)  
175 - console.log('item=', item);  
176 - if (!item) return  
177 - const arr = parseMuscles(item.secondaryMuscles || '[]')  
178 - console.log('arr=', arr);  
179 - arr.forEach(id => idSet.add(id))  
180 - })  
181 -  
182 - return Array.from(idSet).map(id => getMuscleName(id)).join('、') 121 + exerciseIds: []
183 }) 122 })
184 123
185 const formRules = reactive({ 124 const formRules = reactive({
@@ -189,133 +128,113 @@ const formRules = reactive({ @@ -189,133 +128,113 @@ const formRules = reactive({
189 ], 128 ],
190 exerciseIds: [ 129 exerciseIds: [
191 { required: true, message: '请选择组内动作', trigger: 'change' } 130 { required: true, message: '请选择组内动作', trigger: 'change' }
192 - ],  
193 - // primaryMuscles: [  
194 - // { required: true, message: '请选择主要训练部位', trigger: 'change' }  
195 - // ] 131 + ]
196 }) 132 })
197 -//加载数据  
198 -const loadExercisesList = async () => {  
199 - try {  
200 - const res = await ExercisesApi.getExercisesList()  
201 133
202 - exercisesList.value = res || []  
203 - } catch (err) {  
204 - console.error('加载动作列表失败:', err)  
205 - ElMessage.error('加载动作列表失败')  
206 - } 134 +// ===================== 工具函数 =====================
  135 +const parseMuscles = (value) => {
  136 + if (Array.isArray(value)) return value
  137 + try { return JSON.parse(value) } catch { return [] }
  138 +}
  139 +
  140 +// ===================== 计算属性:训练部位回显 =====================
  141 +// 查找动作对象:优先返回带肌肉名称(primaryMusclesName/secondaryMusclesName)的数据,
  142 +// 编辑时 detailExercises 可能不含名称字段,则回退到 exercisesList(含名称)
  143 +const findExercise = (exId) => {
  144 + const inDetail = detailExercises.value.find((e) => e.id === exId)
  145 + const inList = exercisesList.value.find((e) => e.id === exId)
  146 + if (inDetail && Array.isArray(inDetail.primaryMusclesName)) return inDetail
  147 + if (inList) return inList
  148 + return inDetail || inList
  149 +}
  150 +
  151 +// field 传入名称数组字段(primaryMusclesName / secondaryMusclesName)
  152 +const aggregateMuscles = (nameField) => {
  153 + if (!formData.exerciseIds.length) return ''
  154 + const nameSet = new Set()
  155 + formData.exerciseIds.forEach((exId) => {
  156 + const item = findExercise(exId)
  157 + if (!item) return
  158 + const list = Array.isArray(item[nameField]) ? item[nameField] : parseMuscles(item[nameField] || [])
  159 + list.forEach((name) => nameSet.add(name))
  160 + })
  161 + return Array.from(nameSet).join('、')
207 } 162 }
208 163
209 -// const loadCategoryList = async () => {  
210 -// try {  
211 -// const res = await MotionCategoryApi.getMotionCategoryPage({ pageNo: 1 + '', pageSize: 100 + '', name: '' })  
212 -// const data = res.data || res  
213 -// categoryList.value = data.list || []  
214 -// } catch (err) {  
215 -// console.error('加载部位分类失败:', err)  
216 -// ElMessage.error('加载部位分类失败')  
217 -// }  
218 -// } 164 +const primaryMusclesText = computed(() => aggregateMuscles('primaryMusclesName'))
  165 +const secondaryMusclesText = computed(() => aggregateMuscles('secondaryMusclesName'))
219 166
220 -const loadMuscleList = async () => { 167 +// ===================== 数据加载 =====================
  168 +const loadExercisesList = async () => {
221 try { 169 try {
222 - const res = await MusclesApi.getsubCategoriesList()  
223 - muscleList.value = res || [] 170 + const res = await ExercisesApi.getExercisesList(false)
  171 + exercisesList.value = res || []
224 } catch (err) { 172 } catch (err) {
225 - console.error('加载肌肉列表失败:', err)  
226 - ElMessage.error('加载肌肉列表失败') 173 + ElMessage.error('加载动作列表失败')
227 } 174 }
228 } 175 }
229 176
230 const getList = async () => { 177 const getList = async () => {
  178 + loading.value = true
231 try { 179 try {
232 const res = await SupersetsApi.getSupersetsPage({ 180 const res = await SupersetsApi.getSupersetsPage({
233 - pageNo: pageNo.value,  
234 - pageSize: pageSize.value,  
235 - name: searchForm.value.name 181 + pageNo: queryParams.pageNo,
  182 + pageSize: queryParams.pageSize,
  183 + name: queryParams.name
236 }) 184 })
237 const responseData = res.data || res 185 const responseData = res.data || res
238 - console.log('超级组分页接口返回数据', res)  
239 const { total: resTotal = 0, list = [] } = responseData 186 const { total: resTotal = 0, list = [] } = responseData
240 -  
241 tableData.value = list 187 tableData.value = list
242 total.value = resTotal 188 total.value = resTotal
243 - console.log('tableData.value', tableData.value);  
244 -  
245 } catch (err) { 189 } catch (err) {
246 ElMessage.error('获取超级组列表失败') 190 ElMessage.error('获取超级组列表失败')
247 - console.error(err) 191 + } finally {
  192 + loading.value = false
248 } 193 }
249 } 194 }
250 195
  196 +// ===================== 搜索与分页 =====================
251 const handleSearch = () => { 197 const handleSearch = () => {
252 - pageNo.value = 1 198 + queryParams.pageNo = 1
253 getList() 199 getList()
254 } 200 }
255 201
256 const handleReset = () => { 202 const handleReset = () => {
257 - searchForm.value = { name: '' }  
258 - pageNo.value = 1  
259 - getList()  
260 -}  
261 -  
262 -const handlePageChange = (val: number) => {  
263 - pageNo.value = val 203 + Object.assign(queryParams, { pageNo: 1, pageSize: 10, name: '' })
264 getList() 204 getList()
265 } 205 }
266 206
267 -const handlePageSizeChange = (val: number) => {  
268 - pageSize.value = val  
269 - pageNo.value = 1  
270 - getList()  
271 -}  
272 -//处理新增弹窗  
273 -const openAddDialog = async () => { 207 +// ===================== 新增 =====================
  208 +const openAddDialog = () => {
274 isEdit.value = false 209 isEdit.value = false
275 dialogTitle.value = '新增超级组' 210 dialogTitle.value = '新增超级组'
276 resetForm() 211 resetForm()
277 -  
278 - await Promise.all([  
279 - loadExercisesList(),  
280 - // loadCategoryList(),  
281 - loadMuscleList()  
282 - ])  
283 -  
284 dialogVisible.value = true 212 dialogVisible.value = true
285 } 213 }
286 -//处理编辑弹窗  
287 -const handleEdit = async (row: any) => { 214 +
  215 +// ===================== 编辑 =====================
  216 +const handleEdit = async (row) => {
288 try { 217 try {
289 isEdit.value = true 218 isEdit.value = true
290 dialogTitle.value = '编辑超级组' 219 dialogTitle.value = '编辑超级组'
291 resetForm() 220 resetForm()
292 - await Promise.all([  
293 - loadExercisesList(),  
294 - // loadCategoryList(),  
295 - loadMuscleList()  
296 - ])  
297 const res = await SupersetsApi.getSupersets(row.id) 221 const res = await SupersetsApi.getSupersets(row.id)
298 - //  
299 const detail = res.data || res 222 const detail = res.data || res
300 - console.log('detail单个超级组详情接口', detail)  
301 - console.log('res.data单个超级组详情接口', res.data)  
302 - const exerciseIds = (detail.exercises || []).map((item: any) => Number(item.id))  
303 - console.log('提取的动作ID数组', exerciseIds) // 验证是否正确提取  
304 - //回显  
305 - console.log('exerciseIds传递的参数', exerciseIds) 223 + detailExercises.value = detail.exercises || []
  224 + const exerciseIds = (detail.exercises || []).map((item) => Number(item.id))
306 Object.assign(formData, { 225 Object.assign(formData, {
307 id: detail.id, 226 id: detail.id,
308 name: detail.name || '', 227 name: detail.name || '',
309 - exerciseIds: exerciseIds || [], 228 + exerciseIds
310 }) 229 })
311 dialogVisible.value = true 230 dialogVisible.value = true
312 } catch (err) { 231 } catch (err) {
313 - console.error('获取超级组详情失败:', err)  
314 ElMessage.error('加载编辑数据失败') 232 ElMessage.error('加载编辑数据失败')
315 } 233 }
316 } 234 }
317 235
318 -const handleDelete = async (row: any) => { 236 +// ===================== 删除 =====================
  237 +const handleDelete = async (row) => {
319 try { 238 try {
320 await ElMessageBox.confirm(`确定要删除超级组「${row.name}」吗?`, '提示', { 239 await ElMessageBox.confirm(`确定要删除超级组「${row.name}」吗?`, '提示', {
321 confirmButtonText: '确定', 240 confirmButtonText: '确定',
@@ -325,221 +244,69 @@ const handleDelete = async (row: any) => { @@ -325,221 +244,69 @@ const handleDelete = async (row: any) => {
325 await SupersetsApi.deleteSupersets(row.id) 244 await SupersetsApi.deleteSupersets(row.id)
326 ElMessage.success('删除成功') 245 ElMessage.success('删除成功')
327 getList() 246 getList()
328 - } catch (err: any) { 247 + } catch (err) {
329 if (err !== 'cancel') { 248 if (err !== 'cancel') {
330 - ElMessage.error(`删除失败:${err.message || '服务器错误'}`)  
331 - console.error(err) 249 + ElMessage.error('删除失败')
332 } 250 }
333 } 251 }
334 } 252 }
335 253
  254 +// ===================== 表单操作 =====================
336 const resetForm = () => { 255 const resetForm = () => {
337 if (formRef.value) { 256 if (formRef.value) {
338 formRef.value.resetFields() 257 formRef.value.resetFields()
339 } 258 }
340 - Object.assign(formData, {  
341 - id: 0,  
342 - name: '',  
343 - // primaryMuscles: [],  
344 - // secondaryMuscles: [],  
345 - exerciseIds: [],  
346 - // category: ''  
347 - }) 259 + detailExercises.value = []
  260 + Object.assign(formData, { id: 0, name: '', exerciseIds: [] })
348 } 261 }
349 262
350 -const submitAddForm = async () => { 263 +const submitForm = async () => {
351 if (!formRef.value) return 264 if (!formRef.value) return
352 try { 265 try {
353 - await formRef.value.clearValidate()  
354 - const valid = await formRef.value.validate()  
355 - if (!valid) return  
356 - await SupersetsApi.createSupersets({  
357 - id: 0,  
358 - name: formData.name,  
359 - exerciseIds: formData.exerciseIds,  
360 - })  
361 - ElMessage.success('新增超级组成功!')  
362 - dialogVisible.value = false  
363 - getList()  
364 - } catch (err: any) {  
365 - console.error('新增超级组失败:', err)  
366 - ElMessage.error(`新增失败:${err.response?.data?.msg || err.message || '服务器错误'}`) 266 + await formRef.value.validate()
  267 + } catch {
  268 + return
367 } 269 }
368 -}  
369 -  
370 -const submitEditForm = async () => {  
371 - if (!formRef.value) return  
372 try { 270 try {
373 - await formRef.value.clearValidate()  
374 - const valid = await formRef.value.validate()  
375 - if (!valid) return  
376 - await SupersetsApi.updateSupersets({ 271 + const payload = {
377 id: formData.id, 272 id: formData.id,
378 name: formData.name, 273 name: formData.name,
379 - exerciseIds: formData.exerciseIds,  
380 - })  
381 -  
382 - ElMessage.success('修改超级组成功!') 274 + exerciseIds: formData.exerciseIds
  275 + }
  276 + if (isEdit.value) {
  277 + await SupersetsApi.updateSupersets(payload)
  278 + ElMessage.success('修改超级组成功!')
  279 + } else {
  280 + await SupersetsApi.createSupersets(payload)
  281 + ElMessage.success('新增超级组成功!')
  282 + }
383 dialogVisible.value = false 283 dialogVisible.value = false
384 getList() 284 getList()
385 - } catch (err: any) {  
386 - console.error('修改超级组失败:', err)  
387 - ElMessage.error(`修改失败:${err.response?.data?.msg || err.message || '服务器错误'}`) 285 + } catch (err) {
  286 + const msg = err?.response?.data?.msg || err?.message
  287 + if (msg && typeof msg !== 'object') {
  288 + ElMessage.error(msg)
  289 + }
388 } 290 }
389 } 291 }
390 292
  293 +// ===================== 弹窗关闭时重置表单 =====================
  294 +watch(dialogVisible, (val) => {
  295 + if (!val) resetForm()
  296 +})
  297 +
  298 +// ===================== 初始化 =====================
391 onMounted(async () => { 299 onMounted(async () => {
392 - await Promise.all([  
393 - loadExercisesList(),  
394 - loadMuscleList()  
395 - ]) 300 + await loadExercisesList()
396 getList() 301 getList()
397 }) 302 })
398 </script> 303 </script>
399 304
400 <style scoped> 305 <style scoped>
401 .supersets-page { 306 .supersets-page {
402 - min-height: 100vh;  
403 padding: 20px; 307 padding: 20px;
404 - overflow-x: hidden; 308 + min-height: 100vh;
405 background-color: #f5f7fa; 309 background-color: #f5f7fa;
406 box-sizing: border-box; 310 box-sizing: border-box;
407 } 311 }
408 -  
409 -.plan-search-bar {  
410 - margin-bottom: 20px;  
411 - box-shadow: 0 1px 2px rgb(0 0 0 / 5%);  
412 -}  
413 -  
414 -/* .search-bar {  
415 - display: flex;  
416 - align-items: center;  
417 - padding: 16px;  
418 - margin-bottom: 20px;  
419 - background-color: #fff;  
420 - border-radius: 4px;  
421 - gap: 10px;  
422 - flex-wrap: nowrap;  
423 -} */  
424 -  
425 -.search-item {  
426 - display: flex;  
427 - align-items: center;  
428 - gap: 10px;  
429 - padding: 15px 20px;  
430 -}  
431 -  
432 -.search-label {  
433 - font-size: 14px;  
434 - color: #606266;  
435 - white-space: nowrap;  
436 -}  
437 -  
438 -.search-input {  
439 - width: 220px;  
440 - flex: 0 0 auto;  
441 -}  
442 -  
443 -.search-buttons {  
444 - display: flex;  
445 - gap: 10px;  
446 - flex-shrink: 0;  
447 - margin: 0;  
448 -}  
449 -  
450 -.fixed-btn {  
451 - display: flex;  
452 - width: 120px;  
453 - padding: 8px 0 !important;  
454 - justify-content: center;  
455 - align-items: center;  
456 -}  
457 -  
458 -.cover-img {  
459 - display: block;  
460 - width: 100px;  
461 - height: 60px;  
462 - margin: 0 auto;  
463 - background-color: #f5f5f5;  
464 - border: 1px solid #e4e7ed;  
465 - border-radius: 15px;  
466 - object-fit: cover;  
467 -}  
468 -  
469 -.edit-btn {  
470 - margin-right: 15px;  
471 - color: #409eff;  
472 -}  
473 -  
474 -.delete-btn {  
475 - color: #f56c6c;  
476 -}  
477 -  
478 -.table-container {  
479 - padding: 0;  
480 - margin-bottom: 20px;  
481 - background-color: #fff;  
482 - border-radius: 4px;  
483 -}  
484 -  
485 -.pagination-container {  
486 - display: flex;  
487 - align-items: center;  
488 - justify-content: flex-end;  
489 - gap: 10px;  
490 - padding: 16px;  
491 - background-color: #fff;  
492 - border-radius: 4px;  
493 -}  
494 -  
495 -.total {  
496 - font-size: 14px;  
497 - color: #606266;  
498 -}  
499 -  
500 -.page-size-select {  
501 - width: 120px;  
502 -}  
503 -  
504 -:deep(.el-table) {  
505 - max-height: calc(100vh - 280px);  
506 - overflow-y: auto;  
507 -}  
508 -  
509 -:deep(.supersets-dialog .el-dialog__header) {  
510 - padding: 16px 20px;  
511 - border-bottom: 1px solid #e6e6e6;  
512 -}  
513 -  
514 -:deep(.supersets-dialog .el-dialog__title) {  
515 - font-size: 16px;  
516 - color: #333;  
517 -}  
518 -  
519 -:deep(.supersets-dialog .el-dialog__body) {  
520 - padding: 24px 20px;  
521 -}  
522 -  
523 -.add-form {  
524 - max-width: 100%;  
525 -}  
526 -  
527 -:deep(.add-form .el-form-item__label) {  
528 - font-weight: 500;  
529 - color: #333;  
530 -}  
531 -  
532 -:deep(.add-form .el-input__wrapper),  
533 -:deep(.add-form .el-select__wrapper) {  
534 - background-color: #f5f7fa;  
535 - border-radius: 4px;  
536 -}  
537 -  
538 -.dialog-footer {  
539 - display: flex;  
540 - justify-content: center;  
541 - gap: 16px;  
542 - padding-top: 16px;  
543 - border-top: 1px solid #e6e6e6;  
544 -}  
545 -</style>  
  312 +</style>
1 <!-- 原型图叫文件夹,接口命名为模板大类 --> 1 <!-- 原型图叫文件夹,接口命名为模板大类 -->
2 <template> 2 <template>
3 <div class="page-container"> 3 <div class="page-container">
4 - <!-- 搜索框区域 -->  
5 - <!-- <div class="equipments-page"> --> 4 + <!-- 搜索栏 -->
6 <ContentWrap class="plan-search-bar"> 5 <ContentWrap class="plan-search-bar">
7 - <!-- 搜索栏 -->  
8 - <div class="search-bar">  
9 - <label class="search-label">文件夹名称</label>  
10 - <el-input v-model="searchForm.name" placeholder="请输入" style="width: 200px;" size="default"  
11 - @keyup.enter="handleSearch" />  
12 - <div class="search-buttons">  
13 - <el-button type="primary" @click="handleSearch" size="default">  
14 - <el-icon>  
15 - <Search />  
16 - </el-icon>  
17 - 搜索 6 + <el-form :inline="true" :model="queryParams">
  7 + <el-form-item label="文件夹名称" prop="name">
  8 + <el-input
  9 + v-model="queryParams.name"
  10 + placeholder="请输入"
  11 + clearable
  12 + class="!w-220px"
  13 + @keyup.enter="handleSearch"
  14 + />
  15 + </el-form-item>
  16 + <el-form-item>
  17 + <el-button type="primary" @click="handleSearch">
  18 + <Icon icon="ep:search" class="mr-5px" />搜索
18 </el-button> 19 </el-button>
19 - <el-button @click="resetSearch" size="default">  
20 - <el-icon>  
21 - <Refresh />  
22 - </el-icon>  
23 - 重置 20 + <el-button @click="resetSearch">
  21 + <Icon icon="ep:refresh" class="mr-5px" />重置
24 </el-button> 22 </el-button>
25 - <el-button type="primary" @click="handleAdd" size="default">  
26 - <el-icon>  
27 - <Plus />  
28 - </el-icon>  
29 - 新增 23 + <el-button type="primary" @click="handleAdd">
  24 + <Icon icon="ep:plus" class="mr-5px" />新增
30 </el-button> 25 </el-button>
31 - </div>  
32 - </div> 26 + </el-form-item>
  27 + </el-form>
33 </ContentWrap> 28 </ContentWrap>
  29 +
34 <!-- 表格区域 --> 30 <!-- 表格区域 -->
35 <ContentWrap> 31 <ContentWrap>
36 - <div class="table-container">  
37 - <el-table :data="tableData" v-loading="loading" style="width: 100%" empty-text="暂无数据" stripe>  
38 - <el-table-column prop="name" label="文件夹名字" align="center" />  
39 - <el-table-column prop="cover" label="封面" align="center" min-width="120">  
40 - <template #default="scope">  
41 - <div class="cover-wrapper">  
42 - <!-- 优先显示行数据封面,无则显示占位图 -->  
43 - <img class="cover-img" :src="scope.row.urlCover || lostImg" alt="模板封面" />  
44 - </div>  
45 - </template>  
46 - </el-table-column>  
47 - <el-table-column prop="createTime" label="创建时间" align="center" width="220" :formatter="dateFormatter" />  
48 - <el-table-column prop="updateTime" label="更新时间" align="center" min-width="180" :formatter="dateFormatter" />  
49 - <el-table-column label="操作" align="center" width="200">  
50 - <template #default="scope">  
51 - <el-button type="text" class="edit-btn" @click="handleEdit(scope.row)">编辑</el-button>  
52 - <el-button type="text" class="delete-btn" @click="handleDelete(scope.row.id)">删除</el-button>  
53 - </template>  
54 - </el-table-column>  
55 - </el-table>  
56 - </div> 32 + <el-table
  33 + :data="tableData"
  34 + v-loading="loading"
  35 + empty-text="暂无数据"
  36 + stripe
  37 + >
  38 + <el-table-column prop="name" label="文件夹名字" align="center" />
  39 + <el-table-column prop="cover" label="封面" align="center" min-width="120">
  40 + <template #default="scope">
  41 + <img class="cover-img" :src="scope.row.urlCover || lostImg" alt="模板封面" />
  42 + </template>
  43 + </el-table-column>
  44 + <el-table-column prop="createTime" label="创建时间" align="center" width="220" :formatter="dateFormatter" />
  45 + <el-table-column prop="updateTime" label="更新时间" align="center" min-width="180" :formatter="dateFormatter" />
  46 + <el-table-column label="操作" align="center" width="200">
  47 + <template #default="scope">
  48 + <el-button link type="primary" @click="handleEdit(scope.row)">编辑</el-button>
  49 + <el-button link type="danger" @click="handleDelete(scope.row.id)">删除</el-button>
  50 + </template>
  51 + </el-table-column>
  52 + </el-table>
57 53
58 - <div class="pagination-wrapper" style="padding: 12px 16px;">  
59 - <el-pagination v-model:current-page="pagination.pageNo" v-model:page-size="pagination.pageSize"  
60 - :total="pagination.total" :page-sizes="[10, 20, 50, 100]" layout="total, sizes, prev, pager, next, jumper"  
61 - style="text-align: left;" @size-change="handleSizeChange" @current-change="handleCurrentChange" />  
62 - </div> 54 + <Pagination
  55 + :total="queryParams.total"
  56 + v-model:page="queryParams.pageNo"
  57 + v-model:limit="queryParams.pageSize"
  58 + @pagination="getList"
  59 + />
63 </ContentWrap> 60 </ContentWrap>
64 61
65 <!-- 编辑/新增弹窗 --> 62 <!-- 编辑/新增弹窗 -->
66 - <el-dialog v-model="dialogVisible" :title="dialogTitle" width="700px"> 63 + <Dialog :title="dialogTitle" v-model="dialogVisible" width="700px">
67 <el-form ref="formRef" :model="formData" :rules="formRules" label-width="150px"> 64 <el-form ref="formRef" :model="formData" :rules="formRules" label-width="150px">
68 - <el-form-item label="大类分类名称:" prop="name" label-suffix="*">  
69 - <el-input v-model="formData.name" placeholder="请输入分类名称" maxlength="50" show-word-limit style="width: 100%" /> 65 + <el-form-item label="大类分类名称:" prop="name">
  66 + <el-input v-model="formData.name" placeholder="请输入分类名称" maxlength="50" show-word-limit />
70 </el-form-item> 67 </el-form-item>
71 <el-form-item label="模板" prop="templateIds"> 68 <el-form-item label="模板" prop="templateIds">
72 - <el-select v-model="formData.templateIds" placeholder="请选择模板" multiple clearable style="width: 100%"  
73 - filterable> 69 + <el-select
  70 + v-model="formData.templateIds"
  71 + placeholder="请选择模板"
  72 + multiple
  73 + clearable
  74 + filterable
  75 + style="width: 100%"
  76 + >
74 <el-option v-for="item in templateList" :key="item.id" :label="item.name" :value="item.id" /> 77 <el-option v-for="item in templateList" :key="item.id" :label="item.name" :value="item.id" />
75 </el-select> 78 </el-select>
76 </el-form-item> 79 </el-form-item>
77 - <el-form-item label="封面" prop="urlCover" label-suffix="*">  
78 - <UploadImg v-model="formData.urlCover" placeholder="封面" maxlength="50" show-word-limit style="width: 100%" /> 80 + <el-form-item label="封面" prop="urlCover">
  81 + <UploadImg v-model="formData.urlCover" placeholder="封面" />
79 </el-form-item> 82 </el-form-item>
80 - <el-form-item label="模板大类介绍" prop="description" label-suffix="*">  
81 - <el-input v-model="formData.description" placeholder="请输入模板大类介绍" maxlength="50" show-word-limit  
82 - style="width: 100%" /> 83 + <el-form-item label="模板大类介绍" prop="description">
  84 + <el-input
  85 + v-model="formData.description"
  86 + type="textarea"
  87 + placeholder="请输入模板大类介绍"
  88 + maxlength="50"
  89 + show-word-limit
  90 + />
83 </el-form-item> 91 </el-form-item>
84 </el-form> 92 </el-form>
85 <template #footer> 93 <template #footer>
86 <el-button @click="dialogVisible = false">取 消</el-button> 94 <el-button @click="dialogVisible = false">取 消</el-button>
87 - <el-button type="primary" @click="submitForm" :loading="loading" :disabled="loading">确 定</el-button> 95 + <el-button type="primary" @click="submitForm" :loading="loading">确 定</el-button>
88 </template> 96 </template>
89 - </el-dialog>  
90 -  
91 -  
92 - <!-- 分页组件 -->  
93 -  
94 - <!-- </div> -->  
95 - 97 + </Dialog>
96 </div> 98 </div>
97 </template> 99 </template>
98 100
99 -<script setup lang="ts">  
100 -import { ref, onMounted, reactive } from 'vue'  
101 -import { Plus, Search, Refresh } from '@element-plus/icons-vue' 101 +<script setup>
  102 +import { ref, reactive, onMounted, watch } from 'vue'
102 import { dateFormatter } from '@/utils/formatTime' 103 import { dateFormatter } from '@/utils/formatTime'
103 import { templateCategorysApi } from '@/api/store/training/templateCategorys' 104 import { templateCategorysApi } from '@/api/store/training/templateCategorys'
104 import { TrainingTemplatesApi } from '@/api/store/training/templates' 105 import { TrainingTemplatesApi } from '@/api/store/training/templates'
105 import lostImg from '@/assets/imgs/lost.png' 106 import lostImg from '@/assets/imgs/lost.png'
106 import { ElMessage, ElMessageBox } from 'element-plus' 107 import { ElMessage, ElMessageBox } from 'element-plus'
107 -const tableData = ref<any[]>([]) 108 +
  109 +// ===================== 列表状态 =====================
  110 +const tableData = ref([])
108 const loading = ref(false) 111 const loading = ref(false)
  112 +const queryParams = reactive({
  113 + pageNo: 1,
  114 + pageSize: 10,
  115 + total: 0,
  116 + name: ''
  117 +})
  118 +
  119 +// ===================== 弹窗状态 =====================
109 const isEdit = ref(true) 120 const isEdit = ref(true)
110 const dialogVisible = ref(false) 121 const dialogVisible = ref(false)
111 const dialogTitle = ref('') 122 const dialogTitle = ref('')
112 const formRef = ref() 123 const formRef = ref()
113 -const templateList = ref<any[]>([]) 124 +const templateList = ref([])
114 125
115 const formRules = reactive({ 126 const formRules = reactive({
116 - name: [  
117 - { required: true, message: '请输入大类分类名称', trigger: 'blur' }  
118 - ] 127 + name: [{ required: true, message: '请输入大类分类名称', trigger: 'blur' }],
  128 + templateIds: [{ required: true, message: '请选择模板', trigger: 'change' }],
  129 + urlCover: [{ required: true, message: '请上传封面', trigger: 'change' }],
  130 + description: [{ required: true, message: '请输入模板大类介绍', trigger: 'blur' }]
119 }) 131 })
120 132
121 -const loadtemplateList = async () => {  
122 - try {  
123 - const res = await TrainingTemplatesApi.getTemplateList()  
124 -  
125 - templateList.value = res || []  
126 - } catch (err) {  
127 - console.error('加载模板列表失败:', err)  
128 - ElMessage.error('加载模板列表失败')  
129 - }  
130 -}  
131 -// 弹窗参数  
132 const formData = reactive({ 133 const formData = reactive({
133 id: 0, 134 id: 0,
134 name: '', 135 name: '',
135 urlCover: '', 136 urlCover: '',
136 description: '', 137 description: '',
137 - // 下拉框  
138 - templateIds: [] as number[]  
139 -})  
140 -const searchForm = reactive({  
141 - name: '', 138 + templateIds: []
142 }) 139 })
143 140
144 -// 分页参数  
145 -const pagination = reactive({  
146 - pageNo: 1,  
147 - pageSize: 10,  
148 - total: 0  
149 -  
150 -}) 141 +// ===================== 模板下拉列表 =====================
  142 +const loadTemplateList = async () => {
  143 + try {
  144 + const res = await TrainingTemplatesApi.getTemplateList(false)
  145 + templateList.value = res || []
  146 + } catch {
  147 + ElMessage.error('加载模板列表失败')
  148 + }
  149 +}
151 150
152 -//处理搜索 151 +// ===================== 搜索与重置 =====================
153 const handleSearch = () => { 152 const handleSearch = () => {
154 - pagination.pageNo = 1 // 搜索时重置到第一页 153 + queryParams.pageNo = 1
155 getList() 154 getList()
156 } 155 }
157 -// 重置搜索条件 156 +
158 const resetSearch = () => { 157 const resetSearch = () => {
159 - searchForm.name = ''  
160 - pagination.pageNo = 1  
161 - pagination.pageSize = 10 158 + queryParams.name = ''
  159 + queryParams.pageNo = 1
  160 + queryParams.pageSize = 10
162 getList() 161 getList()
163 } 162 }
164 163
165 -// 获取列表 164 +// ===================== 列表数据 =====================
166 const getList = async () => { 165 const getList = async () => {
167 loading.value = true 166 loading.value = true
168 try { 167 try {
169 - // 搜索条件  
170 const params = { 168 const params = {
171 - pageNo: pagination.pageNo + '',  
172 - pageSize: pagination.pageSize + '',  
173 - name: searchForm.name, 169 + pageNo: queryParams.pageNo + '',
  170 + pageSize: queryParams.pageSize + '',
  171 + name: queryParams.name
174 } 172 }
175 - //  
176 const res = await templateCategorysApi.getTemplateCategorysPage(params) 173 const res = await templateCategorysApi.getTemplateCategorysPage(params)
177 - console.log('模板大类分页接口返回数据:', res)  
178 tableData.value = res.list || [] 174 tableData.value = res.list || []
179 - pagination.total = res.total || 0  
180 - console.log('分页列表', res.list)  
181 - } catch (err) {  
182 - console.error('❌ 请求失败:', err) 175 + queryParams.total = res.total || 0
  176 + } catch {
183 ElMessage.error('请求失败,请检查网络或接口') 177 ElMessage.error('请求失败,请检查网络或接口')
184 } finally { 178 } finally {
185 loading.value = false 179 loading.value = false
186 } 180 }
187 } 181 }
188 182
  183 +// ===================== 表单重置 =====================
189 const resetForm = () => { 184 const resetForm = () => {
190 formData.id = 0 185 formData.id = 0
191 formData.name = '' 186 formData.name = ''
192 - formData.description = ''  
193 formData.urlCover = '' 187 formData.urlCover = ''
  188 + formData.description = ''
194 formData.templateIds = [] 189 formData.templateIds = []
195 -  
196 } 190 }
197 191
198 -//=======================处理新增================== 192 +// ===================== 新增 =====================
199 const handleAdd = () => { 193 const handleAdd = () => {
200 isEdit.value = false 194 isEdit.value = false
201 - dialogVisible.value = true  
202 dialogTitle.value = '新增模板大类' 195 dialogTitle.value = '新增模板大类'
203 - resetForm() // 调用重置函数清空表单 196 + resetForm()
  197 + dialogVisible.value = true
204 } 198 }
205 199
206 -  
207 -//=====================处理编辑=================  
208 -const handleEdit = async (row: any) => { 200 +// ===================== 编辑 =====================
  201 +const handleEdit = async (row) => {
209 isEdit.value = true 202 isEdit.value = true
210 dialogTitle.value = '编辑模板大类' 203 dialogTitle.value = '编辑模板大类'
211 - // 先确保模板列表已加载  
212 if (templateList.value.length === 0) { 204 if (templateList.value.length === 0) {
213 - await loadtemplateList() 205 + await loadTemplateList()
214 } 206 }
215 - // 表单回显  
216 formData.id = row.id 207 formData.id = row.id
217 formData.name = row.name 208 formData.name = row.name
218 formData.urlCover = row.urlCover || '' 209 formData.urlCover = row.urlCover || ''
219 formData.description = row.description || '' 210 formData.description = row.description || ''
220 formData.templateIds = row.templateIds || [] 211 formData.templateIds = row.templateIds || []
221 - console.log('编辑回显数据:', formData)  
222 - console.log('编辑行的ids', row.templateIds)  
223 - console.log('被输入编辑行的ids', formData.templateIds)  
224 - // 新增:打开编辑弹窗时,清空之前的验证状态  
225 - if (formRef.value) {  
226 - formRef.value.clearValidate()  
227 - }  
228 dialogVisible.value = true 212 dialogVisible.value = true
229 } 213 }
230 -//=====================提交新增/编辑表单================= 214 +
  215 +// ===================== 弹窗关闭时清理校验 =====================
  216 +watch(dialogVisible, (val) => {
  217 + if (!val && formRef.value) {
  218 + formRef.value.clearValidate()
  219 + }
  220 +})
  221 +
  222 +// ===================== 提交 =====================
231 const submitForm = async () => { 223 const submitForm = async () => {
  224 + if (!formRef.value) return
232 try { 225 try {
233 - // 表单验证  
234 await formRef.value.validate() 226 await formRef.value.validate()
235 - loading.value = true 227 + } catch {
  228 + return
  229 + }
  230 + loading.value = true
  231 + const payload = {
  232 + id: formData.id,
  233 + name: formData.name,
  234 + urlCover: formData.urlCover,
  235 + description: formData.description,
  236 + templateIds: formData.templateIds
  237 + }
  238 + try {
236 if (isEdit.value) { 239 if (isEdit.value) {
237 - // 编辑逻辑  
238 - const Editparams = {  
239 - id: formData.id,  
240 - name: formData.name,  
241 - urlCover: formData.urlCover,  
242 - description: formData.description,  
243 - templateIds: formData.templateIds  
244 - }  
245 - await templateCategorysApi.updateTemplateCategorys(Editparams) 240 + await templateCategorysApi.updateTemplateCategorys(payload)
246 ElMessage.success('编辑成功') 241 ElMessage.success('编辑成功')
247 } else { 242 } else {
248 - // 新增逻辑(调用新增接口)  
249 - const params = {  
250 - id: formData.id,  
251 - name: formData.name,  
252 - urlCover: formData.urlCover,  
253 - description: formData.description,  
254 - templateIds: formData.templateIds  
255 - }  
256 - console.log('提交给后端的参数:', params) // 👈 加这行  
257 - const res = await templateCategorysApi.createTemplateCategorys(params)  
258 - console.log('新增之后的数据', res) 243 + await templateCategorysApi.createTemplateCategorys(payload)
259 ElMessage.success('新增成功') 244 ElMessage.success('新增成功')
260 } 245 }
261 - } catch (error) {  
262 - console.error('❌ 提交失败:', error) 246 + } catch {
263 ElMessage.error('提交失败,请检查网络或接口') 247 ElMessage.error('提交失败,请检查网络或接口')
264 } finally { 248 } finally {
265 loading.value = false 249 loading.value = false
266 dialogVisible.value = false 250 dialogVisible.value = false
267 - getList() // 刷新列表 251 + getList()
268 } 252 }
269 } 253 }
270 254
271 -//处理删除  
272 -const handleDelete = async (id: number) => { 255 +// ===================== 删除 =====================
  256 +const handleDelete = async (id) => {
273 try { 257 try {
274 await ElMessageBox.confirm('确定要删除吗?', '提示', { 258 await ElMessageBox.confirm('确定要删除吗?', '提示', {
275 confirmButtonText: '确定', 259 confirmButtonText: '确定',
276 cancelButtonText: '取消', 260 cancelButtonText: '取消',
277 type: 'warning' 261 type: 'warning'
278 }) 262 })
279 - //调用删除接口  
280 await templateCategorysApi.deleteTemplateCategorys(id) 263 await templateCategorysApi.deleteTemplateCategorys(id)
281 ElMessage.success('删除成功') 264 ElMessage.success('删除成功')
282 } catch (err) { 265 } catch (err) {
283 - console.error('❌ 请求失败:', err)  
284 - ElMessage.error('请求失败,请检查网络或接口') 266 + if (err !== 'cancel') {
  267 + ElMessage.error('删除失败,请检查网络或接口')
  268 + }
285 } finally { 269 } finally {
286 - getList() // 刷新列表 270 + getList()
287 } 271 }
288 } 272 }
289 -// ===================分页方法==================  
290 -const handleCurrentChange = (val: number) => {  
291 - pagination.pageNo = val  
292 - getList()  
293 -}  
294 273
295 -// 处理分页大小变化  
296 -const handleSizeChange = (val: number) => {  
297 - pagination.pageSize = val  
298 - getList()  
299 -} 274 +// ===================== 初始化 =====================
300 onMounted(() => { 275 onMounted(() => {
301 getList() 276 getList()
302 - loadtemplateList() 277 + loadTemplateList()
303 }) 278 })
304 </script> 279 </script>
  280 +
305 <style scoped> 281 <style scoped>
306 .plan-search-bar { 282 .plan-search-bar {
307 margin-bottom: 20px; 283 margin-bottom: 20px;
308 box-shadow: 0 1px 2px rgb(0 0 0 / 5%); 284 box-shadow: 0 1px 2px rgb(0 0 0 / 5%);
309 } 285 }
310 286
311 -.search-bar {  
312 - display: flex;  
313 - align-items: center;  
314 - gap: 10px;  
315 - padding: 15px 20px;  
316 -  
317 -}  
318 -  
319 -.search-buttons {  
320 - display: flex;  
321 - gap: 10px;  
322 - margin-left: 10px;  
323 -}  
324 -  
325 -.pagination-wrapper {  
326 - display: flex;  
327 - padding: 12px 16px;  
328 - justify-content: flex-end;  
329 -}  
330 -  
331 -/* 新样式:更美观、不变大小、不影响其他布局 */  
332 .cover-img { 287 .cover-img {
333 width: 100px; 288 width: 100px;
334 - height: 50px !important;  
335 - border: 1px solid #e5e7eb; 289 + height: 50px;
  290 + border: 1px solid var(--el-border-color);
336 border-radius: 6px; 291 border-radius: 6px;
337 box-shadow: 0 1px 3px rgb(0 0 0 / 8%); 292 box-shadow: 0 1px 3px rgb(0 0 0 / 8%);
338 transition: transform 0.2s ease; 293 transition: transform 0.2s ease;
@@ -341,12 +296,5 @@ onMounted(() => { @@ -341,12 +296,5 @@ onMounted(() => {
341 296
342 .cover-img:hover { 297 .cover-img:hover {
343 transform: scale(1.03); 298 transform: scale(1.03);
344 -  
345 - /* 鼠标悬浮轻微放大 */  
346 -}  
347 -  
348 -.delete-btn {  
349 - color: #f5222d;  
350 - ;  
351 } 299 }
352 -</style>  
  300 +</style>
@@ -4,7 +4,7 @@ @@ -4,7 +4,7 @@
4 组数配置( 4 组数配置(
5 <span v-if="type === 0">无氧运动</span> 5 <span v-if="type === 0">无氧运动</span>
6 <span v-else-if="type === 1">有氧运动</span> 6 <span v-else-if="type === 1">有氧运动</span>
7 - <span v-else-if="type === 2">次训练</span> 7 + <span v-else-if="type === 2">次训练</span>
8 <span v-else-if="type === 3">计时训练</span> 8 <span v-else-if="type === 3">计时训练</span>
9 <span v-else-if="type === 4">自重加重</span> 9 <span v-else-if="type === 4">自重加重</span>
10 <span v-else-if="type === 5">自重减重</span> 10 <span v-else-if="type === 5">自重减重</span>
@@ -4,7 +4,7 @@ @@ -4,7 +4,7 @@
4 <el-form ref="formRef" :model="formData" :rules="rules"> 4 <el-form ref="formRef" :model="formData" :rules="rules">
5 <!-- 模板名称 --> 5 <!-- 模板名称 -->
6 <el-form-item label-suffix="*" prop="templateName" label="模板名称"> 6 <el-form-item label-suffix="*" prop="templateName" label="模板名称">
7 - <el-input type="text" v-model="formData.templateName" placeholder="请输入模板名称" /> 7 + <el-input v-model="formData.templateName" placeholder="请输入模板名称" />
8 </el-form-item> 8 </el-form-item>
9 <!-- 封面图 --> 9 <!-- 封面图 -->
10 <el-form-item label-suffix="*" prop="templateCover" label="封面图"> 10 <el-form-item label-suffix="*" prop="templateCover" label="封面图">
@@ -12,11 +12,8 @@ @@ -12,11 +12,8 @@
12 </el-form-item> 12 </el-form-item>
13 <!-- 训练场景 --> 13 <!-- 训练场景 -->
14 <el-form-item label-suffix="*" prop="scene" label="训练场景"> 14 <el-form-item label-suffix="*" prop="scene" label="训练场景">
15 - <el-select v-model="formData.scene" placeholder="请选择训练场景" style="width: 100%">  
16 - <el-option label="不限" :value="1" />  
17 - <el-option label="仅哑铃" :value="2" />  
18 - <el-option label="仅哑铃+杠铃" :value="3" />  
19 - <el-option label="健身房" :value="4" /> 15 + <el-select v-model="formData.scene" placeholder="请选择训练场景" class="w-full">
  16 + <el-option v-for="opt in SCENE_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
20 </el-select> 17 </el-select>
21 </el-form-item> 18 </el-form-item>
22 19
@@ -27,44 +24,52 @@ @@ -27,44 +24,52 @@
27 24
28 <!-- 动作组容器:循环渲染所有动作组 --> 25 <!-- 动作组容器:循环渲染所有动作组 -->
29 <div v-for="(unit, unitIndex) in formData.units" :key="unitIndex" class="unit-card"> 26 <div v-for="(unit, unitIndex) in formData.units" :key="unitIndex" class="unit-card">
  27 + <!-- 单元操作工具栏 -->
  28 + <div class="unit-toolbar">
  29 + <el-button @click="delUnit(unitIndex)" :disabled="formData.units.length <= 1">
  30 + <template #icon>
  31 + <Minus />
  32 + </template>
  33 + 删除单元
  34 + </el-button>
  35 + <el-button type="primary" @click="addUnit()">
  36 + <template #icon>
  37 + <Plus />
  38 + </template>
  39 + 新增单元
  40 + </el-button>
  41 + </div>
  42 +
30 <!-- 单元类型(动作组/超级组) --> 43 <!-- 单元类型(动作组/超级组) -->
31 <el-form-item label-suffix="*" :label="`单元${unitIndex + 1}类型`" :prop="`units[${unitIndex}].unitType`"> 44 <el-form-item label-suffix="*" :label="`单元${unitIndex + 1}类型`" :prop="`units[${unitIndex}].unitType`">
32 - <el-select v-model="unit.unitType" placeholder="请选择单元类型" style="width:100%">  
33 - <el-option label="动作组" :value="1" />  
34 - <el-option label="超级组" :value="2" /> 45 + <el-select
  46 + v-model="unit.unitType"
  47 + placeholder="请选择单元类型"
  48 + class="w-full"
  49 + @change="onUnitTypeChange(unitIndex)"
  50 + >
  51 + <el-option label="动作组" :value="UNIT_TYPE.GROUP" />
  52 + <el-option label="超级组" :value="UNIT_TYPE.SUPERSET" />
35 </el-select> 53 </el-select>
36 </el-form-item> 54 </el-form-item>
37 55
38 - <!-- 这里实现选择超级组或者动作组名称 -->  
39 - <el-form-item label-suffix="*" :label="unit.unitType === 1 ? '动作组名称' : '超级组名称'" class="row-form-item">  
40 - <div class="row-wrap">  
41 - <el-select v-model="unit.id" @change="(val) => onSelectItem(unitIndex, val)">  
42 - <template v-if="unit.unitType === 1">  
43 - <!-- 需要接入动作接口 -->  
44 - <el-option v-for="item in exercisesList" :key="item.id" :label="item.name" :value="item.id" />  
45 - </template>  
46 - <!-- 需要接入超级组接口 -->  
47 - <template v-else>  
48 - <el-option v-for="item in superGroupList" :key="item.id" :label="item.name" :value="item.id" />  
49 - </template>  
50 - </el-select>  
51 - <!-- 动作组头部 动作组名称+删除和增加动作组按钮-->  
52 - <div class="unit-header">  
53 - <el-button @click="delUnit(unitIndex)" :disabled="formData.units.length <= 1">  
54 - <template #icon>  
55 - <Minus />  
56 - </template>  
57 - 删除单元  
58 - </el-button>  
59 - <el-button @click="AddUnit()">  
60 - <template #icon>  
61 - <Plus />  
62 - </template>  
63 - 新增单元  
64 - </el-button>  
65 - </div>  
66 - </div> 56 + <!-- 选择超级组或者动作组名称 -->
  57 + <el-form-item label-suffix="*" :label="unit.unitType === UNIT_TYPE.GROUP ? '动作组名称' : '超级组名称'">
  58 + <el-select
  59 + v-model="unit.id"
  60 + :placeholder="unit.unitType === UNIT_TYPE.GROUP ? '请选择动作' : '请选择超级组'"
  61 + class="w-full"
  62 + @change="(val) => onSelectItem(unitIndex, val)"
  63 + >
  64 + <template v-if="unit.unitType === UNIT_TYPE.GROUP">
  65 + <el-option v-for="item in exercisesList" :key="item.id" :label="item.name" :value="item.id" />
  66 + </template>
  67 + <template v-else>
  68 + <el-option v-for="item in superGroupList" :key="item.id" :label="item.name" :value="item.id" />
  69 + </template>
  70 + </el-select>
67 </el-form-item> 71 </el-form-item>
  72 +
68 <!-- 排序 --> 73 <!-- 排序 -->
69 <el-form-item label="排序" :prop="`units[${unitIndex}].sortOrder`"> 74 <el-form-item label="排序" :prop="`units[${unitIndex}].sortOrder`">
70 <el-input v-model="unit.sortOrder" min="1" max="99" /> 75 <el-input v-model="unit.sortOrder" min="1" max="99" />
@@ -72,7 +77,7 @@ @@ -72,7 +77,7 @@
72 77
73 <!-- 循环:动作组 1 个、超级组 N 个,都会渲染 --> 78 <!-- 循环:动作组 1 个、超级组 N 个,都会渲染 -->
74 <template v-for="(item, idx) in unit.exercises" :key="idx"> 79 <template v-for="(item, idx) in unit.exercises" :key="idx">
75 - <div style="margin: 16px 0;"> 80 + <div class="exercise-item">
76 <div class="action-name">动作名称:{{ getExerciseName(item.exerciseId) }}</div> 81 <div class="action-name">动作名称:{{ getExerciseName(item.exerciseId) }}</div>
77 <ExerciseSetTable :type="item.exerciseType" :sets="item.sets" @update:sets="val => (item.sets = val)" /> 82 <ExerciseSetTable :type="item.exerciseType" :sets="item.sets" @update:sets="val => (item.sets = val)" />
78 </div> 83 </div>
@@ -90,87 +95,87 @@ @@ -90,87 +95,87 @@
90 </div> 95 </div>
91 </div> 96 </div>
92 </template> 97 </template>
93 -<script setup lang="ts">  
94 -import { ref, onMounted, reactive, nextTick } from 'vue'  
95 -import { MusclesApi } from '@/api/store/training/muscle' 98 +<script setup>
  99 +import { ref, onMounted, reactive, nextTick, watch } from 'vue'
96 import { ElMessage, ElMessageBox } from 'element-plus' 100 import { ElMessage, ElMessageBox } from 'element-plus'
97 import { Minus, Plus } from '@element-plus/icons-vue' 101 import { Minus, Plus } from '@element-plus/icons-vue'
98 -import type { FormInstance } from 'element-plus'  
99 -// import './TemplateAdd.css'  
100 import { TrainingTemplatesApi } from '@/api/store/training/templates' 102 import { TrainingTemplatesApi } from '@/api/store/training/templates'
101 import { ExercisesApi } from '@/api/store/training/pose' 103 import { ExercisesApi } from '@/api/store/training/pose'
102 import ExerciseSetTable from '../templates/ExerciseSetTable.vue' 104 import ExerciseSetTable from '../templates/ExerciseSetTable.vue'
103 import { SupersetsApi } from '@/api/store/training/Supersets' 105 import { SupersetsApi } from '@/api/store/training/Supersets'
104 106
105 -//定义参数  
106 -const formRef = ref<FormInstance>()  
107 -const muscleList = ref<any[]>([]) 107 +// ==================== 常量定义 ====================
  108 +const UNIT_TYPE = { GROUP: 1, SUPERSET: 2 }
  109 +
  110 +const EXERCISE_TYPE_MAP = {
  111 + '无氧运动': 0,
  112 + '有氧运动': 1,
  113 + '记次训练': 2,
  114 + '计时训练': 3,
  115 + '自重加重': 4,
  116 + '自重减重': 5,
  117 + '间歇训练': 6
  118 +}
  119 +
  120 +const SCENE_OPTIONS = [
  121 + { label: '不限', value: 1 },
  122 + { label: '仅哑铃', value: 2 },
  123 + { label: '仅哑铃+杠铃', value: 3 },
  124 + { label: '健身房', value: 4 }
  125 +]
  126 +
  127 +// ==================== 数据工厂函数 ====================
  128 +const createDefaultSet = () => ({
  129 + weight: 0, reps: 0, distance: 0, duration: 0, restTime: 0, setIndex: 1
  130 +})
  131 +
  132 +const createDefaultExercise = () => ({
  133 + exerciseId: 0, exerciseType: 1, innerOrder: 1, sets: [createDefaultSet()]
  134 +})
  135 +
  136 +const createDefaultUnit = (sortOrder) => ({
  137 + id: null, name: '', unitType: UNIT_TYPE.GROUP, sortOrder, exercises: [createDefaultExercise()]
  138 +})
  139 +
  140 +// ==================== 响应式状态 ====================
  141 +const formRef = ref(null)
108 const loading = ref(false) 142 const loading = ref(false)
109 const router = useRouter() 143 const router = useRouter()
110 const route = useRoute() 144 const route = useRoute()
111 -const exercisesList = ref<any[]>([])  
112 -const superGroupList = ref<any[]>([])  
113 - 145 +const exercisesList = ref([])
  146 +const superGroupList = ref([])
114 147
115 -// 表单数据  
116 const formData = reactive({ 148 const formData = reactive({
117 - // templateId: 0, // 新增接口要求的根级别id字段  
118 id: 0, 149 id: 0,
119 groupId: null, 150 groupId: null,
120 templateName: '', 151 templateName: '',
121 scene: null, 152 scene: null,
122 templateCover: '', 153 templateCover: '',
123 templateIntroduction: '', 154 templateIntroduction: '',
124 - units: [  
125 - {  
126 - id: 0,  
127 - name: '',  
128 - unitType: 1, //1=动作组,2=超级组  
129 - sortOrder: 1,  
130 - exercises: [  
131 - {  
132 - exerciseId: 0,  
133 - exerciseType: 1, //动作类型  
134 - innerOrder: 1,  
135 - sets: [  
136 - {  
137 - weight: 0,  
138 - reps: 0,  
139 - distance: 0,  
140 - duration: 0, // 接口唯一时间字段  
141 - restTime: 0,  
142 - setIndex: 1  
143 - }  
144 - ]  
145 - }  
146 - ]  
147 - }  
148 - ] 155 + units: [createDefaultUnit(1)]
149 }) 156 })
150 157
151 -//静态表单验证规则,对应不是循环的表单项检验  
152 -const rules = ref({ 158 +const rules = reactive({
153 templateName: [{ required: true, message: '请输入模板名称', trigger: 'blur' }], 159 templateName: [{ required: true, message: '请输入模板名称', trigger: 'blur' }],
  160 + templateCover: [{ required: true, message: '请上传封面图', trigger: 'change' }],
154 scene: [{ required: true, message: '请选择训练场景', trigger: 'change' }], 161 scene: [{ required: true, message: '请选择训练场景', trigger: 'change' }],
155 templateIntroduction: [{ required: true, message: '请输入模板简介', trigger: 'blur' }] 162 templateIntroduction: [{ required: true, message: '请输入模板简介', trigger: 'blur' }]
156 }) 163 })
157 164
158 -// ===========================加载数据======================================  
159 -//加载数据  
160 -const loadsuperGroupList = async () => { 165 +// ==================== 数据加载 ====================
  166 +const loadSuperGroupList = async () => {
161 try { 167 try {
162 - const res = await SupersetsApi.getSupersetList() 168 + const res = await SupersetsApi.getSupersetList(false)
163 superGroupList.value = res || [] 169 superGroupList.value = res || []
164 } catch (err) { 170 } catch (err) {
165 - console.error('加载动作列表失败:', err)  
166 - ElMessage.error('加载动作列表失败') 171 + console.error('加载超级组列表失败:', err)
  172 + ElMessage.error('加载超级组列表失败')
167 } 173 }
168 } 174 }
169 175
170 -//加载数据  
171 const loadExercisesList = async () => { 176 const loadExercisesList = async () => {
172 try { 177 try {
173 - const res = await ExercisesApi.getExercisesList() 178 + const res = await ExercisesApi.getExercisesList(false)
174 exercisesList.value = res || [] 179 exercisesList.value = res || []
175 } catch (err) { 180 } catch (err) {
176 console.error('加载动作列表失败:', err) 181 console.error('加载动作列表失败:', err)
@@ -178,13 +183,11 @@ const loadExercisesList = async () => { @@ -178,13 +183,11 @@ const loadExercisesList = async () => {
178 } 183 }
179 } 184 }
180 185
181 -// ====================== 【关键】编辑回显 ======================  
182 -const loadTemplateDetail = async (id: number) => { 186 +const loadTemplateDetail = async (id) => {
183 try { 187 try {
184 loading.value = true 188 loading.value = true
185 const res = await TrainingTemplatesApi.getTrainingTemplates(id) 189 const res = await TrainingTemplatesApi.getTrainingTemplates(id)
186 const detail = res.data || res 190 const detail = res.data || res
187 - console.log('编辑详细模板返回的数据detail', detail)  
188 191
189 formData.id = detail.id || 0 192 formData.id = detail.id || 0
190 formData.groupId = detail.groupId || null 193 formData.groupId = detail.groupId || null
@@ -193,27 +196,18 @@ const loadTemplateDetail = async (id: number) => { @@ -193,27 +196,18 @@ const loadTemplateDetail = async (id: number) => {
193 formData.templateCover = detail.urlCover || detail.templateCover || '' 196 formData.templateCover = detail.urlCover || detail.templateCover || ''
194 formData.templateIntroduction = detail.introduction || '' 197 formData.templateIntroduction = detail.introduction || ''
195 198
196 - // 🔥 修复:超级组多动作回显(不再写死 [0])  
197 if (detail.units && detail.units.length) { 199 if (detail.units && detail.units.length) {
198 - formData.units = detail.units.map((unit: any) => ({  
199 - id: unit.unitId || 0, 200 + formData.units = detail.units.map((unit) => ({
  201 + id: unit.unitId || null,
200 name: unit.unitName || '', 202 name: unit.unitName || '',
201 - unitType: unit.unitType || 1, 203 + unitType: unit.unitType || UNIT_TYPE.GROUP,
202 sortOrder: unit.sortOrder || 1, 204 sortOrder: unit.sortOrder || 1,
203 - // ✅ 这里修复:完整映射所有动作(动作组1个、超级组N个都正常)  
204 - exercises: unit.exercises?.map((item: any) => ({ 205 + exercises: unit.exercises?.map((item) => ({
205 exerciseId: item.exerciseId || 0, 206 exerciseId: item.exerciseId || 0,
206 exerciseType: item.exerciseType || 1, 207 exerciseType: item.exerciseType || 1,
207 innerOrder: item.innerOrder || 1, 208 innerOrder: item.innerOrder || 1,
208 - sets: item.sets || [  
209 - { weight: 0, reps: 0, distance: 0, duration: 0, restTime: 0, setIndex: 1 }  
210 - ]  
211 - })) || [{  
212 - exerciseId: 0,  
213 - exerciseType: 1,  
214 - innerOrder: 1,  
215 - sets: [{ weight: 0, reps: 0, distance: 0, duration: 0, restTime: 0, setIndex: 1 }]  
216 - }] 209 + sets: item.sets || [createDefaultSet()]
  210 + })) || [createDefaultExercise()]
217 })) 211 }))
218 } 212 }
219 213
@@ -221,80 +215,57 @@ const loadTemplateDetail = async (id: number) => { @@ -221,80 +215,57 @@ const loadTemplateDetail = async (id: number) => {
221 updateDynamicRules() 215 updateDynamicRules()
222 formRef.value?.clearValidate() 216 formRef.value?.clearValidate()
223 }) 217 })
224 -  
225 } catch (err) { 218 } catch (err) {
226 - ElMessage.error('获取详情失败')  
227 - console.error(err) 219 + console.error('获取模板详情失败:', err)
  220 + ElMessage.error('获取模板详情失败')
228 } finally { 221 } finally {
229 loading.value = false 222 loading.value = false
230 } 223 }
231 } 224 }
232 225
233 -//动态检验 226 +// ==================== 动态表单校验 ====================
234 const updateDynamicRules = () => { 227 const updateDynamicRules = () => {
235 - // 先清理所有动态校验规则(避免删除单元后残留旧规则报错)  
236 - Object.keys(rules.value).forEach(key => {  
237 - if (key.startsWith('units[')) { // 匹配所有循环生成的规则  
238 - delete rules.value[key] 228 + Object.keys(rules).forEach((key) => {
  229 + if (key.startsWith('units[')) {
  230 + delete rules[key]
239 } 231 }
240 }) 232 })
241 - //必须使用units[${index}].unitType这种,不然验证找不到真实的数据,验证出错。  
242 formData.units.forEach((unit, index) => { 233 formData.units.forEach((unit, index) => {
243 - rules.value[`units[${index}].unitType`] = [ 234 + rules[`units[${index}].unitType`] = [
244 { required: true, message: '请选择单元类型', trigger: 'change' } 235 { required: true, message: '请选择单元类型', trigger: 'change' }
245 ] 236 ]
246 - rules.value[`units[${index}].name`] = [ 237 + rules[`units[${index}].name`] = [
247 { required: true, message: '请选择动作', trigger: 'change' } 238 { required: true, message: '请选择动作', trigger: 'change' }
248 ] 239 ]
249 - rules.value[`units[${index}].sortOrder`] = [ 240 + rules[`units[${index}].sortOrder`] = [
250 { required: true, message: '请输入排序', trigger: 'blur' } 241 { required: true, message: '请输入排序', trigger: 'blur' }
251 ] 242 ]
252 }) 243 })
253 -  
254 - // 清除校验状态,防止新增就爆红  
255 nextTick(() => { 244 nextTick(() => {
256 if (formRef.value) formRef.value.clearValidate() 245 if (formRef.value) formRef.value.clearValidate()
257 }) 246 })
258 } 247 }
259 248
260 -// ---------------------- 交互方法 ----------------------  
261 -// 动作选择联动名称(新增unitIndex参数,绑定当前动作组)  
262 -const handleExerciseChange = (exerciseId: number, unitIndex: number) => {  
263 - const unit = formData.units[unitIndex]  
264 -  
265 - // 根据类型找对应的数据  
266 - let selectedItem  
267 - if (unit.unitType === 1) {  
268 - selectedItem = exercisesList.value.find(item => item.id === exerciseId)  
269 - } else {  
270 - selectedItem = superGroupList.value.find(item => item.id === exerciseId)  
271 - }  
272 -  
273 - if (selectedItem) {  
274 - unit.name = selectedItem.name  
275 - } 249 +// ==================== 交互方法 ====================
  250 +const getExerciseName = (id) => {
  251 + if (!id) return '未选择动作'
  252 + const item = exercisesList.value.find((i) => i.id === id)
  253 + return item ? item.name : '未知动作'
276 } 254 }
277 255
278 -// 根据ID获取动作名称  
279 -const getExerciseName = (id: number) => {  
280 - if (!id) return '未选择动作'  
281 - // 先从动作列表找  
282 - const item = exercisesList.value.find(i => i.id === id)  
283 - if (item) return item.name  
284 - return '未知动作' 256 +const addUnit = () => {
  257 + formData.units.push(createDefaultUnit(formData.units.length + 1))
  258 + updateDynamicRules()
  259 + ElMessage.success('新增单元成功(可切换为超级组)')
285 } 260 }
286 261
287 -// 删除动作组(接收unitIndex,删除指定动作组)  
288 -const delUnit = (unitIndex: number) => {  
289 - // 只保留至少 1 个单元 262 +const delUnit = (unitIndex) => {
290 if (formData.units.length <= 1) { 263 if (formData.units.length <= 1) {
291 ElMessage.warning('至少保留一个单元') 264 ElMessage.warning('至少保留一个单元')
292 return 265 return
293 } 266 }
294 -  
295 ElMessageBox.confirm('确定要删除这个单元吗?', '提示', { type: 'warning' }).then(() => { 267 ElMessageBox.confirm('确定要删除这个单元吗?', '提示', { type: 'warning' }).then(() => {
296 formData.units.splice(unitIndex, 1) 268 formData.units.splice(unitIndex, 1)
297 - // 重新排序  
298 formData.units.forEach((unit, index) => { 269 formData.units.forEach((unit, index) => {
299 unit.sortOrder = index + 1 270 unit.sortOrder = index + 1
300 }) 271 })
@@ -303,141 +274,70 @@ const delUnit = (unitIndex: number) => { @@ -303,141 +274,70 @@ const delUnit = (unitIndex: number) => {
303 }) 274 })
304 } 275 }
305 276
306 -//增加单元(动作组/超级组)  
307 -const AddUnit = () => {  
308 - // 默认新增动作组,用户之后可在页面切换为超级组  
309 - const newUnit = {  
310 - id: 0,  
311 - name: '',  
312 - unitType: 1, // 默认动作组  
313 - sortOrder: formData.units.length + 1,  
314 - exercises: [  
315 - {  
316 - exerciseId: 0,  
317 - exerciseType: 1,  
318 - innerOrder: 1,  
319 - sets: [  
320 - { weight: 0, reps: 0, distance: 0, duration: 0, restTime: 0, setIndex: 1 }  
321 - ]  
322 - }  
323 - ]  
324 - }  
325 - formData.units.push(newUnit)  
326 - updateDynamicRules()  
327 - ElMessage.success('新增单元成功(可在上方切换为超级组)') 277 +const onUnitTypeChange = (unitIndex) => {
  278 + const unit = formData.units[unitIndex]
  279 + // 单元类型切换后,原选中的动作组/超级组 id、名称及动作明细都不再适用,需重置
  280 + unit.id = null
  281 + unit.name = ''
  282 + unit.exercises = [createDefaultExercise()]
328 } 283 }
329 284
330 -// 选择 动作 或 超级组 后,加载详情  
331 -const onSelectItem = async (unitIndex: number, selectId: number) => { 285 +const onSelectItem = async (unitIndex, selectId) => {
332 const unit = formData.units[unitIndex] 286 const unit = formData.units[unitIndex]
333 -  
334 - // 清空之前的数据  
335 unit.exercises = [] 287 unit.exercises = []
336 288
337 - if (unit.unitType === 1) {  
338 - // ==================  
339 - // 1. 动作组:查单个动作详情  
340 - // ================== 289 + if (unit.unitType === UNIT_TYPE.GROUP) {
  290 + // 动作组:查询单个动作详情
341 const res = await ExercisesApi.getExercises(selectId) 291 const res = await ExercisesApi.getExercises(selectId)
342 - console.log('选择的动作组详情', res)  
343 const detail = res 292 const detail = res
344 - console.log('选择的动作详情:detail', detail)  
345 - console.log('【动作组详情完整数据】', JSON.stringify(detail, null, 2))  
346 - console.log('【动作名称】', detail.name)  
347 - console.log('【后端返回的原始exerciseType】', detail.exerciseType)  
348 -  
349 unit.name = detail.name || '' 293 unit.name = detail.name || ''
350 - // 关键2:只有动作详情是文字,做文字转数字映射  
351 - const typeMap: Record<string, number> = {  
352 - '无氧运动': 0,  
353 - '有氧运动': 1,  
354 - '记次训练': 2,  
355 - '计时训练': 3,  
356 - '自重加重': 4,  
357 - '自重减重': 5,  
358 - '间歇训练': 6  
359 - }  
360 - // 能映射就转数字,映射失败默认给0  
361 - const typeVal = typeMap[detail.exerciseType] ?? 0  
362 unit.exercises.push({ 294 unit.exercises.push({
363 exerciseId: detail.id, 295 exerciseId: detail.id,
364 - exerciseType: typeVal, 296 + exerciseType: EXERCISE_TYPE_MAP[detail.exerciseType] ?? 0,
365 innerOrder: 1, 297 innerOrder: 1,
366 - sets: [{ weight: 0, reps: 0, distance: 0, duration: 0, restTime: 0, setIndex: 1 }] 298 + sets: [createDefaultSet()]
367 }) 299 })
368 -  
369 - console.log('【映射后的exerciseType数字】', typeVal)  
370 -  
371 } else { 300 } else {
372 - // ==================  
373 - // 2. 超级组:查超级组详情(返回多个动作)  
374 - // ================== 301 + // 超级组:查询超级组详情(含多个动作)
375 const res = await SupersetsApi.getSupersets(selectId) 302 const res = await SupersetsApi.getSupersets(selectId)
376 - console.log('加载的超级组详情:', res)  
377 const detail = res 303 const detail = res
378 unit.name = detail.name || '' 304 unit.name = detail.name || ''
379 - console.log('加载选中的超级组详情:', detail)  
380 -  
381 - // 🔴 关键:超级组接口直接返回数字,不需要再做映射  
382 - unit.exercises = detail.exercises.map((item: any) => {  
383 - console.log('【超级组内动作原始exerciseType】', item.exerciseType)  
384 - // 直接用返回的数字即可  
385 - const typeVal = Number(item.exerciseType) || 0  
386 - console.log('【超级组内动作最终exerciseType数字】', typeVal)  
387 - return {  
388 - exerciseId: item.id,  
389 - exerciseType: typeVal,  
390 - innerOrder: item.innerOrder,  
391 - sets: item.sets || [{ weight: 0, reps: 0, restTime: 0, setIndex: 1 }]  
392 - }  
393 - }) 305 + unit.exercises = detail.exercises.map((item) => ({
  306 + exerciseId: item.id,
  307 + exerciseType: Number(item.exerciseType) || 0,
  308 + innerOrder: item.innerOrder,
  309 + sets: item.sets || [{ weight: 0, reps: 0, restTime: 0, setIndex: 1 }]
  310 + }))
394 } 311 }
395 } 312 }
396 313
397 -// 取消 314 +// ==================== 表单提交 ====================
398 const handleCancel = () => { 315 const handleCancel = () => {
399 ElMessage.info('取消保存表单内容') 316 ElMessage.info('取消保存表单内容')
400 formRef.value?.resetFields() 317 formRef.value?.resetFields()
401 - // 重置表单数据(可选)  
402 - formData.id = 0  
403 - formData.groupId = null  
404 - formData.templateName = ''  
405 - formData.scene = null  
406 - formData.templateCover = ''  
407 - formData.templateIntroduction = ''  
408 - formData.units = [  
409 - {  
410 - id: 0,  
411 - name: '',  
412 - unitType: 1,  
413 - sortOrder: 1,  
414 - exercises: [  
415 - {  
416 - exerciseId: 0,  
417 - exerciseType: 1,  
418 - innerOrder: 1,  
419 - sets: [  
420 - { weight: 0, reps: 0, distance: 0, duration: 0, restTime: 0, setIndex: 1 }  
421 - ]  
422 - }  
423 - ]  
424 - }  
425 - ]  
426 - // 重置后更新校验规则 318 + Object.assign(formData, {
  319 + id: 0,
  320 + groupId: null,
  321 + templateName: '',
  322 + scene: null,
  323 + templateCover: '',
  324 + templateIntroduction: '',
  325 + units: [createDefaultUnit(1)]
  326 + })
427 updateDynamicRules() 327 updateDynamicRules()
428 - // ✅ 关键:跳回主页面  
429 router.push('/training/templates/templatesForm') 328 router.push('/training/templates/templatesForm')
430 } 329 }
  330 +
431 const handleSubmit = async () => { 331 const handleSubmit = async () => {
432 if (!formRef.value) return 332 if (!formRef.value) return
433 try { 333 try {
434 await formRef.value.validate() 334 await formRef.value.validate()
435 - } catch (err) { 335 + } catch {
436 ElMessage.error('请按照要求填写') 336 ElMessage.error('请按照要求填写')
437 return 337 return
438 } 338 }
439 - // 动作组/超级组不能为空。  
440 - const hasEmptyExercise = formData.units.some(unit => !unit.exercises[0].exerciseId) 339 +
  340 + const hasEmptyExercise = formData.units.some((unit) => !unit.exercises[0].exerciseId)
441 if (hasEmptyExercise) { 341 if (hasEmptyExercise) {
442 ElMessage.warning('请为所有动作组选择动作名称') 342 ElMessage.warning('请为所有动作组选择动作名称')
443 return 343 return
@@ -445,24 +345,23 @@ const handleSubmit = async () => { @@ -445,24 +345,23 @@ const handleSubmit = async () => {
445 345
446 loading.value = true 346 loading.value = true
447 try { 347 try {
448 - // 按接口文档转换提交数据  
449 const submitData = { 348 const submitData = {
450 - templateId: formData.id, // 关键:前端id → 后端templateId 349 + templateId: formData.id,
451 groupId: formData.groupId, 350 groupId: formData.groupId,
452 templateName: formData.templateName, 351 templateName: formData.templateName,
453 scene: formData.scene, 352 scene: formData.scene,
454 templateCover: formData.templateCover, 353 templateCover: formData.templateCover,
455 templateIntroduction: formData.templateIntroduction, 354 templateIntroduction: formData.templateIntroduction,
456 - units: formData.units.map(unit => ({  
457 - id: unit.id, 355 + units: formData.units.map((unit) => ({
  356 + id: unit.id ?? 0,
458 name: unit.name, 357 name: unit.name,
459 unitType: unit.unitType, 358 unitType: unit.unitType,
460 sortOrder: unit.sortOrder, 359 sortOrder: unit.sortOrder,
461 - exercises: unit.exercises.map(exercise => ({ 360 + exercises: unit.exercises.map((exercise) => ({
462 exerciseId: exercise.exerciseId, 361 exerciseId: exercise.exerciseId,
463 exerciseType: exercise.exerciseType, 362 exerciseType: exercise.exerciseType,
464 innerOrder: exercise.innerOrder, 363 innerOrder: exercise.innerOrder,
465 - sets: exercise.sets.map(set => ({ 364 + sets: exercise.sets.map((set) => ({
466 weight: set.weight, 365 weight: set.weight,
467 reps: set.reps, 366 reps: set.reps,
468 distance: set.distance, 367 distance: set.distance,
@@ -474,65 +373,35 @@ const handleSubmit = async () => { @@ -474,65 +373,35 @@ const handleSubmit = async () => {
474 })) 373 }))
475 } 374 }
476 375
477 - console.log('提交给后端的完整数据:', submitData)  
478 -  
479 - let res: any  
480 if (route.query.id) { 376 if (route.query.id) {
481 - // 编辑:用转换后的submitData  
482 - res = await TrainingTemplatesApi.updateTrainingTemplates(submitData) 377 + await TrainingTemplatesApi.updateTrainingTemplates(submitData)
483 } else { 378 } else {
484 - // 新增:用转换后的submitData  
485 - res = await TrainingTemplatesApi.createTrainingTemplates(submitData) 379 + await TrainingTemplatesApi.createTrainingTemplates(submitData)
486 } 380 }
487 -  
488 - console.log('接口返回结果:', res)  
489 ElMessage.success('模板保存成功!') 381 ElMessage.success('模板保存成功!')
490 - // router.push('/training/templates/templatesForm')  
491 router.back() 382 router.back()
492 - } catch (err: any) {  
493 - // 👇 修复日志打印,避免undefined  
494 - console.error("【完整错误对象】", err);  
495 - console.error("【错误信息】", err?.message || '未知错误');  
496 - console.error("【响应数据】", err?.response?.data || '无响应数据');  
497 - console.error("【响应状态】", err?.response?.status || '无状态码');  
498 - ElMessage.error(`保存失败:${err?.message || '网络/服务异常'}`); 383 + } catch (err) {
  384 + console.error('保存失败:', err?.message || err)
  385 + ElMessage.error(`保存失败:${err?.message || '网络/服务异常'}`)
499 } finally { 386 } finally {
500 loading.value = false 387 loading.value = false
501 } 388 }
502 } 389 }
503 390
504 -// 加载肌肉列表  
505 -const loadMuscleList = async () => {  
506 - try {  
507 - const res = await MusclesApi.getMusclesPage({ pageNo: '1', pageSize: '100', name: '' })  
508 - const data = res.data || res  
509 - muscleList.value = data.list || []  
510 - } catch (err) {  
511 - console.error('加载肌肉列表失败:', err)  
512 - ElMessage.error('加载肌肉列表失败')  
513 - }  
514 -}  
515 -//生命周期 391 +// ==================== 生命周期 ====================
516 onMounted(async () => { 392 onMounted(async () => {
517 - // groupList.value = [{ id: 1, name: "默认大类" }]  
518 - loadMuscleList()  
519 loadExercisesList() 393 loadExercisesList()
520 - loadsuperGroupList()  
521 -  
522 - // 进入页面判断是否编辑 394 + loadSuperGroupList()
523 if (route.query.id) { 395 if (route.query.id) {
524 loadTemplateDetail(Number(route.query.id)) 396 loadTemplateDetail(Number(route.query.id))
525 } 397 }
526 }) 398 })
527 399
528 -// 监听路由ID变化,自动回显  
529 watch( 400 watch(
530 () => route.query.id, 401 () => route.query.id,
531 (newId) => { 402 (newId) => {
532 if (newId) { 403 if (newId) {
533 loadTemplateDetail(Number(newId)) 404 loadTemplateDetail(Number(newId))
534 - } else {  
535 - // handleCancel()  
536 } 405 }
537 }, 406 },
538 { immediate: true } 407 { immediate: true }
@@ -540,120 +409,78 @@ watch( @@ -540,120 +409,78 @@ watch(
540 </script> 409 </script>
541 410
542 <style scoped> 411 <style scoped>
543 -:deep(.el-form-item__label) { 412 +/* ==================== 表单项全局样式 ==================== */
  413 +::deep(.el-form-item__label) {
544 float: none !important; 414 float: none !important;
545 - width: 68px !important; 415 + width: 88px !important;
546 padding: 0 !important; 416 padding: 0 !important;
547 - margin-right: 20px !important; 417 + margin-right: 16px !important;
548 line-height: 32px; 418 line-height: 32px;
549 text-align: right; 419 text-align: right;
550 white-space: nowrap; 420 white-space: nowrap;
551 } 421 }
552 422
553 -/* 让表单内容区域横向排列 */  
554 -.row-form-item :deep(.el-form-item__content) {  
555 - display: flex;  
556 - align-items: center; 423 +/* ==================== 工具类 ==================== */
  424 +.w-full {
557 width: 100%; 425 width: 100%;
558 } 426 }
559 427
560 -/* 下拉框 + 按钮 同行容器 */  
561 -.row-wrap {  
562 - display: flex;  
563 - align-items: center;  
564 - gap: 14px;  
565 - width: 100%;  
566 -}  
567 -  
568 -/* 下拉框占剩余宽度 */  
569 -.sel {  
570 - flex: 1; 428 +/* ==================== 页面布局 ==================== */
  429 +.page-contain {
  430 + height: 100vh;
  431 + overflow-y: auto;
  432 + background-color: #f5f7fa;
  433 + box-sizing: border-box;
571 } 434 }
572 435
573 -/* 按钮容器:不被压缩 */  
574 -.unit-header {  
575 - display: flex;  
576 - gap: 10px;  
577 - flex-shrink: 0; 436 +.templateName {
  437 +
  438 + padding: 30px;
  439 + margin: 0 auto;
  440 + background: #fff;
  441 + border-radius: 8px;
  442 + box-shadow: 0 2px 12px rgb(0 0 0 / 5%);
578 } 443 }
579 444
580 -/* 下面是你原来保留的样式,不动 */  
581 -  
582 -/* 动作组卡片 */ 445 +/* ==================== 单元卡片 ==================== */
583 .unit-card { 446 .unit-card {
584 margin: 20px 0; 447 margin: 20px 0;
585 - border-radius: 6px;  
586 -}  
587 -  
588 -/* 表格容器 */  
589 -.sets-table-wrapper {  
590 - margin: 20px 0;  
591 - overflow-x: auto;  
592 -}  
593 -  
594 -.table-title {  
595 - margin-bottom: 10px;  
596 - font-size: 14px;  
597 - font-weight: 500;  
598 - color: #666;  
599 -}  
600 -  
601 -/* 表格样式 */  
602 -.sets-table {  
603 - width: 100%;  
604 - text-align: center;  
605 - border-collapse: collapse; 448 + padding: 16px 20px 20px;
  449 + background: #fafbfc;
  450 + border: 1px solid #ebeef5;
  451 + border-radius: 8px;
  452 + transition: border-color 0.2s;
606 } 453 }
607 454
608 -.sets-table th {  
609 - padding: 10px 0;  
610 - font-weight: 500;  
611 - background: #fafafa; 455 +.unit-card:hover {
  456 + border-color: #c0c4cc;
612 } 457 }
613 458
614 -.sets-table td {  
615 - padding: 8px 0; 459 +.unit-toolbar {
  460 + display: flex;
  461 + justify-content: flex-end;
  462 + gap: 10px;
  463 + margin-bottom: 4px;
616 } 464 }
617 465
618 -.set-row:hover {  
619 - background: #f9f9f9; 466 +.exercise-item {
  467 + margin: 16px 0;
620 } 468 }
621 469
622 .action-name { 470 .action-name {
623 - color: #666; 471 + margin-bottom: 8px;
  472 + font-size: 13px;
  473 + color: #606266;
624 } 474 }
625 475
626 -/* 按钮居中容器 */ 476 +/* ==================== 按钮区域 ==================== */
627 .btn-center-wrapper { 477 .btn-center-wrapper {
628 display: flex; 478 display: flex;
629 width: 100%; 479 width: 100%;
630 - padding-top: 15px; 480 + padding-top: 20px;
631 margin-top: 30px; 481 margin-top: 30px;
632 border-top: 1px solid #eee; 482 border-top: 1px solid #eee;
633 justify-content: center; 483 justify-content: center;
634 gap: 12px; 484 gap: 12px;
635 } 485 }
636 -  
637 -/* 封面图样式 */  
638 -.sub-image {  
639 - border: 1px solid #eee;  
640 - border-radius: 10px;  
641 -}  
642 -  
643 -/* 页面滚动容器 */  
644 -.page-contain {  
645 - height: 100vh;  
646 - overflow-y: auto;  
647 - background-color: #f5f5f5;  
648 - box-sizing: border-box;  
649 -}  
650 -  
651 -/* 表单容器 */  
652 -.templateName {  
653 - padding: 30px;  
654 - margin: 0 auto;  
655 - background: #fff;  
656 - border-radius: 8px;  
657 - box-shadow: 0 2px 12px rgb(0 0 0 / 5%);  
658 -}  
659 </style> 486 </style>
1 <template> 1 <template>
2 - <Dialog v-model="dialogVisible" :title="title" width="800px" height="100%" append-to-body>  
3 - <!-- 这里直接用 SonFormData -->  
4 - <el-form ref="formRef" :model="SonFormData" label-width="120px" :rules="formRules" label-position="right">  
5 - <el-form-item label="计划名称" prop="name" label-suffix="*"> 2 + <Dialog v-model="dialogVisible" :title="title" width="800px">
  3 + <el-form ref="formRef" :model="SonFormData" label-width="120px" :rules="formRules">
  4 + <el-form-item label="计划名称" prop="name">
6 <el-input v-model="SonFormData.name" placeholder="请输入计划名称" maxlength="50" show-word-limit /> 5 <el-input v-model="SonFormData.name" placeholder="请输入计划名称" maxlength="50" show-word-limit />
7 </el-form-item> 6 </el-form-item>
8 7
9 - <el-form-item label="封面图" prop="urlCover" class="form-item-margin" label-suffix="*"> 8 + <el-form-item label="封面图" prop="urlCover">
10 <UploadImg v-model="SonFormData.urlCover" /> 9 <UploadImg v-model="SonFormData.urlCover" />
11 </el-form-item> 10 </el-form-item>
12 - <!-- 计划分类/后来加上 -->  
13 - <el-form-item label="计划分类" prop="categoryId" label-suffix="*"> 11 +
  12 + <el-form-item label="计划分类" prop="categoryId">
14 <el-select v-model="SonFormData.categoryId" placeholder="请选择计划分类"> 13 <el-select v-model="SonFormData.categoryId" placeholder="请选择计划分类">
15 <el-option v-for="item in categoryList" :key="item.id" :label="item.name" :value="item.id" /> 14 <el-option v-for="item in categoryList" :key="item.id" :label="item.name" :value="item.id" />
16 </el-select> 15 </el-select>
17 </el-form-item> 16 </el-form-item>
18 17
19 - <el-form-item label="训练场景" prop="trainingScene" label-suffix="*">  
20 - <el-select v-model="SonFormData.trainingScene">  
21 - <el-option label="不限" :value="1" />  
22 - <el-option label="健身房" :value="2" />  
23 - <el-option label="仅哑铃" :value="3" />  
24 - <el-option label="仅哑铃+杠铃" :value="4" /> 18 + <el-form-item label="训练场景" prop="trainingScene">
  19 + <el-select v-model="SonFormData.trainingScene" placeholder="请选择训练场景">
  20 + <el-option v-for="item in trainingSceneOptions" :key="item.value" :label="item.label" :value="item.value" />
25 </el-select> 21 </el-select>
26 </el-form-item> 22 </el-form-item>
27 - <!-- 难度的不限是后来加上的 -->  
28 - <el-form-item label="计划难度" prop="difficultyLevel" label-suffix="*">  
29 - <el-select v-model="SonFormData.difficultyLevel">  
30 - <!-- <el-option label="不限" :value="0" /> -->  
31 - <el-option label="初阶" :value="1" />  
32 - <el-option label="中阶" :value="2" />  
33 - <el-option label="高阶" :value="3" /> 23 +
  24 + <el-form-item label="计划难度" prop="difficultyLevel">
  25 + <el-select v-model="SonFormData.difficultyLevel" placeholder="请选择难度">
  26 + <el-option v-for="item in difficultyLevelOptions" :key="item.value" :label="item.label" :value="item.value" />
34 </el-select> 27 </el-select>
35 </el-form-item> 28 </el-form-item>
36 29
37 - <el-form-item label="适合人群" prop="targetPeople" label-suffix="*">  
38 - <el-select v-model="SonFormData.targetPeople">  
39 - <el-option label="不限" :value="1" />  
40 - <el-option label="青少年" :value="2" />  
41 - <el-option label="成年人" :value="3" />  
42 - <el-option label="中年人" :value="4" />  
43 - <el-option label="运动损伤" :value="5" /> 30 + <el-form-item label="适合人群" prop="targetPeople">
  31 + <el-select v-model="SonFormData.targetPeople" placeholder="请选择适合人群">
  32 + <el-option v-for="item in targetPeopleOptions" :key="item.value" :label="item.label" :value="item.value" />
44 </el-select> 33 </el-select>
45 </el-form-item> 34 </el-form-item>
46 35
47 - <el-form-item label="频率" prop="frequencyPerWeek" label-suffix="*">  
48 - <el-select v-model="SonFormData.frequencyPerWeek" :disabled="templateCount > 1"> 36 + <el-form-item label="频率" prop="frequencyPerWeek">
  37 + <el-select v-model="SonFormData.frequencyPerWeek" :disabled="templateCount > 1" placeholder="请选择频率">
49 <el-option v-for="item in frequencyOptions" :key="item.value" :label="item.label" :value="item.value" /> 38 <el-option v-for="item in frequencyOptions" :key="item.value" :label="item.label" :value="item.value" />
50 </el-select> 39 </el-select>
51 </el-form-item> 40 </el-form-item>
52 41
53 - <el-form-item label="计划周期(周)" prop="durationWeeks" label-suffix="*"> 42 + <el-form-item label="计划周期(周)" prop="durationWeeks">
54 <el-input-number v-model="SonFormData.durationWeeks" class="w-full" /> 43 <el-input-number v-model="SonFormData.durationWeeks" class="w-full" />
55 </el-form-item> 44 </el-form-item>
56 - <!-- 选择模板,并且显示模板详情 -->  
57 - <div v-for="(templates, templatesIndex) in SonFormData.templateIds" :key="templatesIndex" style="width: 100%;">  
58 - <el-form-item label="模板" :prop="`templateIds[${templatesIndex}].templateId`" label-suffix="*"  
59 - style="width: 100%; margin: 0;" class="is-required">  
60 - <div style="display: flex; align-items: center; gap: 12px;width: 100%;">  
61 - <el-select v-model="templates.templateId" placeholder="请选择模板" style="flex: 1; width: 0;"  
62 - @change="() => loadTemplateDetail(templatesIndex, templates.templateId)"  
63 - @click="() => reloadTemplateDetail(templatesIndex)">  
64 - <el-option v-for="item in templateList" :key="item.id" :label="item.name" :value="item.id" />  
65 - </el-select>  
66 - <div style="display: flex; gap: 8px; flex-shrink: 0;">  
67 - <el-button @click="deltemplate(templatesIndex)"  
68 - :disabled="SonFormData.templateIds.length <= 1">删除</el-button>  
69 - <el-button @click="Addtemplate(templatesIndex)">新增</el-button>  
70 - </div>  
71 - </div> 45 +
  46 + <!-- 模板选择区域 -->
  47 + <div v-for="(tpl, tplIdx) in SonFormData.templateIds" :key="tplIdx" class="template-block">
  48 + <div class="template-block-toolbar">
  49 + <el-button :disabled="SonFormData.templateIds.length <= 1" @click="removeTemplate(tplIdx)">
  50 + <Icon icon="ep:minus" class="mr-5px" />删除
  51 + </el-button>
  52 + <el-button type="primary" @click="addTemplate(tplIdx)">
  53 + <Icon icon="ep:plus" class="mr-5px" />新增
  54 + </el-button>
  55 + </div>
  56 +
  57 + <el-form-item :prop="`templateIds[${tplIdx}].templateId`" label="模板">
  58 + <el-select v-model="tpl.templateId" placeholder="请选择模板" class="w-full"
  59 + @change="() => loadTemplateDetail(tplIdx, tpl.templateId)" @click="() => reloadTemplateDetail(tplIdx)">
  60 + <el-option v-for="item in templateList" :key="item.id" :label="item.name" :value="item.id" />
  61 + </el-select>
72 </el-form-item> 62 </el-form-item>
73 63
74 - <el-form-item :prop="`templateIds[${templatesIndex}].sortOrder`" label="排序" label-suffix="*"  
75 - class="is-required">  
76 - <el-input-number v-model="templates.sortOrder" :min="1" :max="99" placeholder="排序号" style="width: 100%" /> 64 + <el-form-item :prop="`templateIds[${tplIdx}].sortOrder`" label="排序">
  65 + <el-input-number v-model="tpl.sortOrder" :min="1" :max="99" placeholder="排序号" class="w-full" />
77 </el-form-item> 66 </el-form-item>
  67 +
78 <!-- 模板详情 --> 68 <!-- 模板详情 -->
79 - <!-- 模板详情回显(新增部分) -->  
80 - <el-form-item label="模板详情" v-if="templateDetailMap[templatesIndex]"  
81 - style="width: 100%; padding: 0; margin: 0 auto;" class="template-detail-container"> 69 + <el-form-item v-if="templateDetailMap[tplIdx]" label="模板详情" class="template-detail-container">
82 <el-collapse-transition> 70 <el-collapse-transition>
83 - <div v-loading="templateLoading[templatesIndex]">  
84 - <!-- 训练单元列表 -->  
85 - <el-table :data="templateDetailMap[templatesIndex].units || []" border  
86 - style="width: 100%; margin-bottom: 12px;" :show-header="true" class="expand-table-fix">  
87 - <el-table-column prop="unitName" label="训练单元名称" min-width="150" />  
88 - <el-table-column prop="sortOrder" label="排序" width="100" align="center" />  
89 - <!-- <el-table-column prop="setCount" label="组数" width="80" align="center" /> -->  
90 - </el-table>  
91 - <!-- 用纯DIV替代嵌套表格,彻底解决宽度问题 -->  
92 - <div v-for="unit in templateDetailMap[templatesIndex].units || []" :key="unit.unitId"  
93 - class="unit-container">  
94 - <!-- 训练单元行(可点击展开) --> 71 + <div v-loading="templateLoading[tplIdx]">
  72 + <div v-for="unit in templateDetailMap[tplIdx].units || []" :key="unit.unitId" class="unit-container">
95 <div class="unit-row" @click="unit.isExpand = !unit.isExpand"> 73 <div class="unit-row" @click="unit.isExpand = !unit.isExpand">
96 - <span class="expand-icon">{{ unit.isExpand ? '▼' : '▶' }}</span> 74 + <Icon icon="ep:arrow-right" class="expand-icon" :class="{ 'is-expanded': unit.isExpand }" />
97 <span class="unit-name">{{ unit.unitName }}</span> 75 <span class="unit-name">{{ unit.unitName }}</span>
98 </div> 76 </div>
99 -  
100 - <!-- 展开的动作列表 -->  
101 <div v-if="unit.isExpand" class="exercise-container"> 77 <div v-if="unit.isExpand" class="exercise-container">
102 - <div v-for="exercise in unit.exercises || []" :key="exercise.exerciseId" class="exercise-row">  
103 - <div class="exercise-header">  
104 - <!-- <span class="exercise-name">{{ exercise.exerciseName }}</span> -->  
105 - <!-- <span class="exercise-info">组数:{{ exercise.setCount }} | 重量:{{ exercise.weight }} | 次数:{{  
106 - exercise.reps }}</span> -->  
107 - <!-- <span class="exercise-expand" @click="exercise.isExpand = !exercise.isExpand">  
108 - {{ exercise.isExpand ? '▼ 收起组详情' : '▶ 展开详情' }}  
109 - </span> --> 78 + <div v-for="exercise in unit.exercises || []" :key="exercise.exerciseId" class="exercise-item">
  79 + <div class="set-header">
  80 + <span>重量</span>
  81 + <span>距离</span>
  82 + <span>次数</span>
  83 + <span>锻炼时长(秒)</span>
  84 + <span>休息(秒)</span>
110 </div> 85 </div>
111 -  
112 - <!-- 展开的组详情 -->  
113 - <div class="set-container">  
114 - <div class="set-header">  
115 - <span>重量</span>  
116 - <span>距离</span>  
117 - <span>次数</span>  
118 - <span>锻炼时长(秒)</span>  
119 - <span>休息(秒)</span>  
120 - </div>  
121 - <div v-for="set in exercise.sets || []" :key="set.setIndex" class="set-row">  
122 - <span>{{ set.weight }}</span>  
123 - <span>{{ set.distance }}</span>  
124 - <span>{{ set.reps }}</span>  
125 - <span>{{ set.duration }}</span>  
126 - <span>{{ set.restTime }}</span>  
127 - </div> 86 + <div v-for="set in exercise.sets || []" :key="set.setIndex" class="set-row">
  87 + <span>{{ set.weight }}</span>
  88 + <span>{{ set.distance }}</span>
  89 + <span>{{ set.reps }}</span>
  90 + <span>{{ set.duration }}</span>
  91 + <span>{{ set.restTime }}</span>
128 </div> 92 </div>
129 </div> 93 </div>
130 </div> 94 </div>
131 </div> 95 </div>
132 - <!-- -->  
133 </div> 96 </div>
134 </el-collapse-transition> 97 </el-collapse-transition>
135 </el-form-item> 98 </el-form-item>
136 </div> 99 </div>
137 100
138 - <!-- <el-form-item label="所需器械汇总" prop="equipmentsSummary" label-suffix="*">  
139 - <el-input v-model="SonFormData.equipmentsSummary" />  
140 - </el-form-item> -->  
141 - <el-form-item label="计划介绍" prop="introduction" label-suffix="*"> 101 + <el-form-item label="计划介绍" prop="introduction">
142 <el-input v-model="SonFormData.introduction" type="textarea" :rows="3" /> 102 <el-input v-model="SonFormData.introduction" type="textarea" :rows="3" />
143 </el-form-item> 103 </el-form-item>
144 </el-form> 104 </el-form>
145 105
146 <template #footer> 106 <template #footer>
147 - <el-button @click="handleCancel">取消</el-button>  
148 - <el-button type="primary" :loading="loading" @click="handleSubmit">保存</el-button> 107 + <el-button type="primary" :loading="loading" @click="handleSubmit">确 定</el-button>
  108 + <el-button @click="dialogVisible = false">取 消</el-button>
149 </template> 109 </template>
150 </Dialog> 110 </Dialog>
151 </template> 111 </template>
@@ -155,16 +115,13 @@ import { ref, reactive, watch, onMounted, computed } from 'vue' @@ -155,16 +115,13 @@ import { ref, reactive, watch, onMounted, computed } from 'vue'
155 import { ElMessage } from 'element-plus' 115 import { ElMessage } from 'element-plus'
156 import { TrainingTemplatesApi } from '@/api/store/training/templates' 116 import { TrainingTemplatesApi } from '@/api/store/training/templates'
157 import { trainingPlanApi } from '@/api/store/training/trainingPlan' 117 import { trainingPlanApi } from '@/api/store/training/trainingPlan'
158 -import { Minus, Plus } from '@element-plus/icons-vue'  
159 -import { Dialog } from '@/components/Dialog'  
160 -// import { Table } from '@/components/Table'  
161 -// 父组件传过来的值(只用来接收,不冲突) 118 +
  119 +// ==================== Props & Emits ====================
162 const props = defineProps({ 120 const props = defineProps({
163 visible: { type: Boolean, default: false }, 121 visible: { type: Boolean, default: false },
164 title: String, 122 title: String,
165 formData: { type: Object, default: null } 123 formData: { type: Object, default: null }
166 }) 124 })
167 -  
168 const emit = defineEmits(['submit', 'update:visible']) 125 const emit = defineEmits(['submit', 'update:visible'])
169 126
170 const dialogVisible = computed({ 127 const dialogVisible = computed({
@@ -172,78 +129,71 @@ const dialogVisible = computed({ @@ -172,78 +129,71 @@ const dialogVisible = computed({
172 set: (val) => emit('update:visible', val) 129 set: (val) => emit('update:visible', val)
173 }) 130 })
174 131
175 -// ========================  
176 -// 子组件自己的表单数据(唯一来源)  
177 -// ========================  
178 -const SonFormData = ref({  
179 - id: undefined,  
180 - categoryId: 0,  
181 - name: '',  
182 - urlCover: '',  
183 - introduction: '',  
184 - durationWeeks: 1,  
185 - frequencyPerWeek: 1,  
186 - difficultyLevel: 1,  
187 - targetPeople: 1,  
188 - trainingScene: 1,  
189 - equipmentsSummary: '', 132 +// ==================== 表单默认值(仅保留渲染所必需的字段) ====================
  133 +const defaultFormData = () => ({
190 templateIds: [{ templateId: undefined, sortOrder: 1 }] 134 templateIds: [{ templateId: undefined, sortOrder: 1 }]
191 }) 135 })
192 136
193 -// 是否是编辑功能  
194 -// 新增:专门的编辑状态标记 137 +// ==================== 状态 ====================
  138 +const SonFormData = ref(defaultFormData())
195 const isEdit = ref(false) 139 const isEdit = ref(false)
196 const formRef = ref() 140 const formRef = ref()
197 const loading = ref(false) 141 const loading = ref(false)
198 const templateList = ref([]) 142 const templateList = ref([])
199 -const categoryList = ref([]) // 计划分类列表  
200 -// 存储每个模板的详情数据,key 是模板 index,value 是接口返回的详情 143 +const categoryList = ref([])
201 const templateDetailMap = ref({}) 144 const templateDetailMap = ref({})
202 -// 模板详情加载状态  
203 const templateLoading = ref({}) 145 const templateLoading = ref({})
204 146
205 -// 控制练的频率 147 +// ==================== 计算属性 ====================
206 const templateCount = computed(() => SonFormData.value.templateIds.length) 148 const templateCount = computed(() => SonFormData.value.templateIds.length)
207 -// 频率选项:1个模板显示全部,多个模板只显示对应数量 149 +
208 const frequencyOptions = computed(() => { 150 const frequencyOptions = computed(() => {
209 - const count = templateCount.value  
210 - if (count === 1) {  
211 - return [  
212 - { label: '1练/周', value: 1 },  
213 - { label: '2练/周', value: 2 },  
214 - { label: '3练/周', value: 3 },  
215 - { label: '4练/周', value: 4 },  
216 - { label: '5练/周', value: 5 },  
217 - { label: '6练/周', value: 6 },  
218 - ]  
219 - } else {  
220 - // 多个模板 → 只显示和模板数量一样的选项  
221 - return [{ label: `${count}练/周`, value: count }] 151 + if (templateCount.value > 1) {
  152 + return [{ label: templateCount.value + '练/周', value: templateCount.value }]
222 } 153 }
  154 + return [1, 2, 3, 4, 5, 6].map((n) => ({ label: n + '练/周', value: n }))
223 }) 155 })
224 156
  157 +// ==================== 静态下拉选项 ====================
  158 +const trainingSceneOptions = [
  159 + { label: '不限', value: 1 },
  160 + { label: '健身房', value: 2 },
  161 + { label: '仅哑铃', value: 3 },
  162 + { label: '仅哑铃+杠铃', value: 4 }
  163 +]
  164 +
  165 +const difficultyLevelOptions = [
  166 + { label: '初阶', value: 1 },
  167 + { label: '中阶', value: 2 },
  168 + { label: '高阶', value: 3 }
  169 +]
  170 +
  171 +const targetPeopleOptions = [
  172 + { label: '不限', value: 1 },
  173 + { label: '青少年', value: 2 },
  174 + { label: '成年人', value: 3 },
  175 + { label: '中年人', value: 4 },
  176 + { label: '运动损伤', value: 5 }
  177 +]
  178 +
  179 +// ==================== 频率同步 ====================
225 const syncFrequency = () => { 180 const syncFrequency = () => {
226 - const count = templateCount.value  
227 - if (count > 1) {  
228 - // 超过1个模板 → 强制频率 = 模板数量  
229 - SonFormData.value.frequencyPerWeek = count 181 + if (templateCount.value > 1) {
  182 + SonFormData.value.frequencyPerWeek = templateCount.value
230 } 183 }
231 } 184 }
232 185
233 -// 监听模板数量变化,自动更新频率  
234 -watch(templateCount, () => {  
235 - syncFrequency()  
236 -}) 186 +watch(templateCount, syncFrequency)
237 187
  188 +// ==================== 表单验证规则 ====================
238 const formRules = reactive({ 189 const formRules = reactive({
239 name: [{ required: true, message: '请输入计划名称', trigger: 'blur' }], 190 name: [{ required: true, message: '请输入计划名称', trigger: 'blur' }],
240 durationWeeks: [{ required: true, message: '请输入计划周期', trigger: 'blur' }], 191 durationWeeks: [{ required: true, message: '请输入计划周期', trigger: 'blur' }],
241 frequencyPerWeek: [{ required: true, message: '请输入训练频率', trigger: 'change' }], 192 frequencyPerWeek: [{ required: true, message: '请输入训练频率', trigger: 'change' }],
242 - difficultyLevel: [{ required: true, message: '请输入难度', trigger: 'change' }],  
243 - targetPeople: [{ required: true, message: '请输入人群', trigger: 'change' }],  
244 - trainingScene: [{ required: true, message: '请输入场景', trigger: 'change' }],  
245 - urlCover: [{ required: true, message: '输入图片', trigger: 'change' }],  
246 - // equipmentsSummary: [{ required: true, message: '请输入所需器械汇总', trigger: 'blur' }], 193 + difficultyLevel: [{ required: true, message: '请选择难度', trigger: 'change' }],
  194 + targetPeople: [{ required: true, message: '请选择人群', trigger: 'change' }],
  195 + trainingScene: [{ required: true, message: '请选择场景', trigger: 'change' }],
  196 + urlCover: [{ required: true, message: '请上传封面图', trigger: 'change' }],
247 introduction: [{ required: true, message: '请输入计划介绍', trigger: 'blur' }], 197 introduction: [{ required: true, message: '请输入计划介绍', trigger: 'blur' }],
248 categoryId: [{ required: true, message: '请选择计划分类', trigger: 'change' }], 198 categoryId: [{ required: true, message: '请选择计划分类', trigger: 'change' }],
249 templateIds: [{ 199 templateIds: [{
@@ -254,93 +204,74 @@ const formRules = reactive({ @@ -254,93 +204,74 @@ const formRules = reactive({
254 return callback(new Error('至少添加一个模板')) 204 return callback(new Error('至少添加一个模板'))
255 } 205 }
256 for (let i = 0; i < value.length; i++) { 206 for (let i = 0; i < value.length; i++) {
257 - const item = value[i]  
258 - if (!item.templateId) {  
259 - return callback(new Error(`第 ${i + 1} 个模板未选择`)) 207 + if (!value[i].templateId) {
  208 + return callback(new Error('第 ' + (i + 1) + ' 个模板未选择'))
260 } 209 }
261 - if (!item.sortOrder || item.sortOrder < 1) {  
262 - return callback(new Error(`第 ${i + 1} 个排序号必须≥1`)) 210 + if (!value[i].sortOrder || value[i].sortOrder < 1) {
  211 + return callback(new Error('第 ' + (i + 1) + ' 个排序号必须≥1'))
263 } 212 }
264 } 213 }
265 callback() 214 callback()
266 } 215 }
267 - }], 216 + }]
268 }) 217 })
269 218
  219 +// ==================== 弹窗打开时初始化 ====================
  220 +const initAdd = () => {
  221 + isEdit.value = false
  222 + SonFormData.value = defaultFormData()
  223 + formRef.value?.clearValidate()
  224 +}
  225 +
  226 +const initEdit = async () => {
  227 + isEdit.value = true
  228 + const res = await trainingPlanApi.getTrainingPlanDetail(props.formData.id)
  229 + if (!res || !res.id) {
  230 + ElMessage.error('获取计划详情失败')
  231 + return
  232 + }
  233 + SonFormData.value = res
  234 + // 补全父组件传入但详情未返回的字段
  235 + SonFormData.value.trainingScene = props.formData.trainingScene
  236 + SonFormData.value.targetPeople = props.formData.targetPeople
  237 + SonFormData.value.categoryId = props.formData.categoryId
  238 + // 处理 equipmentsSummary 数组转字符串
  239 + if (Array.isArray(res.equipmentsSummary)) {
  240 + SonFormData.value.equipmentsSummary = res.equipmentsSummary.join(',')
  241 + }
  242 + // 映射模板 ID 和排序
  243 + if (res.templates && res.templates.length) {
  244 + SonFormData.value.templateIds = res.templates.map((item) => ({
  245 + templateId: item.id,
  246 + sortOrder: item.sortOrder
  247 + }))
  248 + }
  249 + if (!SonFormData.value.templateIds || SonFormData.value.templateIds.length === 0) {
  250 + SonFormData.value.templateIds = [{ templateId: undefined, sortOrder: 1 }]
  251 + }
  252 + // 自动加载已有模板的详情
  253 + SonFormData.value.templateIds.forEach((item, idx) => {
  254 + if (item.templateId) loadTemplateDetail(idx, item.templateId)
  255 + })
  256 + syncFrequency()
  257 + formRef.value?.clearValidate()
  258 +}
  259 +
270 watch(dialogVisible, async (val) => { 260 watch(dialogVisible, async (val) => {
271 if (!val) return 261 if (!val) return
272 - // 重置模板详情缓存,解决旧数据残留问题  
273 templateDetailMap.value = {} 262 templateDetailMap.value = {}
274 templateLoading.value = {} 263 templateLoading.value = {}
275 - SonFormData.value = {  
276 - id: undefined,  
277 - categoryId: 0,  
278 - name: '',  
279 - urlCover: '',  
280 - introduction: '',  
281 - durationWeeks: 1,  
282 - frequencyPerWeek: 1,  
283 - difficultyLevel: 1,  
284 - targetPeople: 1,  
285 - trainingScene: 1,  
286 - equipmentsSummary: '',  
287 - templateIds: [{ templateId: undefined, sortOrder: 1 }]  
288 - }  
289 - // 新增 264 + SonFormData.value = defaultFormData()
  265 +
290 if (!props.formData || Object.keys(props.formData).length === 0 || props.formData.id == null) { 266 if (!props.formData || Object.keys(props.formData).length === 0 || props.formData.id == null) {
291 - // 控制使用哪个接口  
292 - isEdit.value = false // 强制写死  
293 - // 新增:重置清除前面编辑可能留下的校验  
294 - formRef.value?.clearValidate()  
295 - console.log('新增接口isEdit的值', isEdit) 267 + initAdd()
296 } else { 268 } else {
297 - isEdit.value = true  
298 - // 编辑:赋值  
299 - // 调计划详情接口  
300 - const res = await trainingPlanApi.getTrainingPlanDetail(props.formData.id)  
301 - console.log('计划详情数据res================', res)  
302 - console.log('计划详情数据res.data================', res.data)  
303 - console.log('计划详情数据res.equipmentsSummary================', res.equipmentsSummary)  
304 - if (!res || !res.id) {  
305 - ElMessage.error('获取计划详情失败')  
306 - console.log('获取计划详情失败')  
307 - return  
308 - }  
309 - SonFormData.value = res  
310 - // 2. 补 res 里缺失、但父组件有值的字段  
311 - SonFormData.value.trainingScene = props.formData.trainingScene  
312 - SonFormData.value.targetPeople = props.formData.targetPeople  
313 - // 详情接口返回的字段和SonFormData的不一样,需要处理  
314 - SonFormData.value.categoryId = props.formData.categoryId  
315 - console.log("===== 父组件传过来的完整数据 阉割版=====")  
316 - console.log(props.formData)  
317 - // // 详情返回的是数组,但是这里要的是字符串  
318 - if (Array.isArray(res.equipmentsSummary)) {  
319 - SonFormData.value.equipmentsSummary = res.equipmentsSummary.join(',')  
320 - }  
321 - // 赋值模板的id和排序  
322 - if (res.templates && res.templates.length) {  
323 - SonFormData.value.templateIds = res.templates.map(item => ({  
324 - templateId: item.id,  
325 - sortOrder: item.sortOrder  
326 - }))  
327 - }  
328 - // 防止模板为空,导致模板选项不出现(虽然不太可能,因为这个是必填项)  
329 - if (!SonFormData.value.templateIds || SonFormData.value.templateIds.length === 0) {  
330 - SonFormData.value.templateIds = [{ templateId: undefined, sortOrder: 1 }]  
331 - }  
332 - // // 编辑状态:自动加载已有模板的详情  
333 - SonFormData.value.templateIds.forEach((item, index) => {  
334 - if (item.templateId) {  
335 - loadTemplateDetail(index, item.templateId)  
336 - }  
337 - })  
338 - syncFrequency() 269 + await initEdit()
339 } 270 }
340 formRef.value?.clearValidate() 271 formRef.value?.clearValidate()
341 }) 272 })
342 -// ==========================加载数据======================  
343 -// 加载模板列表 273 +
  274 +// ==================== 数据加载 ====================
344 const loadTemplateList = async () => { 275 const loadTemplateList = async () => {
345 const res = await TrainingTemplatesApi.getTrainingTemplatesPage({ pageNo: 1, pageSize: 100 }) 276 const res = await TrainingTemplatesApi.getTrainingTemplatesPage({ pageNo: 1, pageSize: 100 })
346 templateList.value = res.list || [] 277 templateList.value = res.list || []
@@ -348,49 +279,72 @@ const loadTemplateList = async () => { @@ -348,49 +279,72 @@ const loadTemplateList = async () => {
348 ElMessage.warning('暂无可用训练模板,请先创建模板') 279 ElMessage.warning('暂无可用训练模板,请先创建模板')
349 } 280 }
350 } 281 }
351 -// 获取模板详情并缓存 282 +
352 const loadTemplateDetail = async (index, templateId) => { 283 const loadTemplateDetail = async (index, templateId) => {
353 if (!templateId) return 284 if (!templateId) return
354 - // 标记加载中  
355 templateLoading.value[index] = true 285 templateLoading.value[index] = true
356 try { 286 try {
357 const res = await TrainingTemplatesApi.getTrainingTemplates(templateId) 287 const res = await TrainingTemplatesApi.getTrainingTemplates(templateId)
358 - // 给每个单元、动作加展开状态(默认收起)  
359 - res.units?.forEach(unit => { 288 + res.units?.forEach((unit) => {
360 unit.isExpand = false 289 unit.isExpand = false
361 - unit.exercises?.forEach(exercise => {  
362 - exercise.isExpand = false  
363 - }) 290 + unit.exercises?.forEach((ex) => { ex.isExpand = false })
364 }) 291 })
365 - // 缓存详情数据  
366 templateDetailMap.value[index] = res 292 templateDetailMap.value[index] = res
367 } catch (err) { 293 } catch (err) {
368 - ElMessage.error(`获取模板${index + 1}详情失败`)  
369 - console.error(err) 294 + ElMessage.error('获取模板' + (index + 1) + '详情失败')
370 } finally { 295 } finally {
371 - // 取消加载中  
372 templateLoading.value[index] = false 296 templateLoading.value[index] = false
373 } 297 }
374 } 298 }
375 299
376 -// 加载计划分类列表  
377 const loadCategoryList = async () => { 300 const loadCategoryList = async () => {
378 - const res = await trainingPlanApi.getPlanCategoryPage({ pageNo: 1, pageSize: 100 })  
379 - categoryList.value = res.list || [] 301 + const res = await trainingPlanApi.getPlanList()
  302 + categoryList.value = res || []
380 } 303 }
381 -// 强制重新加载当前 index 的模板详情(解决重复选择不触发问题) 304 +
382 const reloadTemplateDetail = (index) => { 305 const reloadTemplateDetail = (index) => {
383 const id = SonFormData.value.templateIds[index]?.templateId 306 const id = SonFormData.value.templateIds[index]?.templateId
384 if (id) loadTemplateDetail(index, id) 307 if (id) loadTemplateDetail(index, id)
385 } 308 }
386 -// 取消  
387 -const handleCancel = () => {  
388 - dialogVisible.value = false  
389 - ElMessage.info('取消保存') 309 +
  310 +// ==================== 模板增删 ====================
  311 +const addTemplate = (index) => {
  312 + const newIds = [...SonFormData.value.templateIds]
  313 + newIds.splice(index + 1, 0, {
  314 + templateId: undefined,
  315 + sortOrder: SonFormData.value.templateIds.length + 1
  316 + })
  317 + SonFormData.value.templateIds = newIds
  318 + reindexDetailMap()
  319 + syncFrequency()
  320 +}
  321 +
  322 +const removeTemplate = (index) => {
  323 + if (SonFormData.value.templateIds.length <= 1) return
  324 + SonFormData.value.templateIds = SonFormData.value.templateIds.filter((_, i) => i !== index)
  325 + reindexDetailMap()
  326 + syncFrequency()
  327 +}
  328 +
  329 +const reindexDetailMap = () => {
  330 + const oldMap = { ...templateDetailMap.value }
  331 + templateDetailMap.value = {}
  332 + templateLoading.value = {}
  333 + SonFormData.value.templateIds.forEach((item, i) => {
  334 + if (item.templateId && oldMap[i] !== undefined) {
  335 + templateDetailMap.value[i] = oldMap[i]
  336 + }
  337 + })
390 } 338 }
391 -// 提交 339 +
  340 +// ==================== 提交 ====================
392 const handleSubmit = async () => { 341 const handleSubmit = async () => {
393 - await formRef.value.validate() 342 + if (!formRef.value) return
  343 + try {
  344 + await formRef.value.validate()
  345 + } catch {
  346 + return
  347 + }
394 loading.value = true 348 loading.value = true
395 try { 349 try {
396 if (isEdit.value) { 350 if (isEdit.value) {
@@ -398,122 +352,112 @@ const handleSubmit = async () => { @@ -398,122 +352,112 @@ const handleSubmit = async () => {
398 } else { 352 } else {
399 await trainingPlanApi.createTrainingPlan(SonFormData.value) 353 await trainingPlanApi.createTrainingPlan(SonFormData.value)
400 } 354 }
401 - console.log('调用接口后isEdit的值', isEdit)  
402 ElMessage.success(isEdit.value ? '编辑成功' : '创建成功') 355 ElMessage.success(isEdit.value ? '编辑成功' : '创建成功')
403 dialogVisible.value = false 356 dialogVisible.value = false
404 emit('submit') 357 emit('submit')
405 } catch (err) { 358 } catch (err) {
406 - ElMessage.error((isEdit.value ? '编辑' : '创建') + '失败:' + err.message) 359 + ElMessage.error((isEdit.value ? '编辑' : '创建') + '失败')
407 } finally { 360 } finally {
408 loading.value = false 361 loading.value = false
409 } 362 }
410 } 363 }
411 364
412 -const Addtemplate = (index) => {  
413 - const newTemplate = {  
414 - templateId: 1,  
415 - sortOrder: SonFormData.value.templateIds.length + 1  
416 - }  
417 - const newTemplateIds = [...SonFormData.value.templateIds]  
418 - newTemplateIds.splice(index + 1, 0, newTemplate)  
419 - SonFormData.value.templateIds = newTemplateIds  
420 - // 重新同步所有模板详情(不移除、不清空,只重新对应位置)  
421 - const oldMap = { ...templateDetailMap.value }  
422 - templateDetailMap.value = {}  
423 - templateLoading.value = {}  
424 - SonFormData.value.templateIds.forEach((item, i) => {  
425 - if (item.templateId && oldMap[i] !== undefined) {  
426 - templateDetailMap.value[i] = oldMap[i]  
427 - }  
428 - })  
429 - syncFrequency()  
430 -}  
431 -  
432 -const deltemplate = (index) => {  
433 - if (SonFormData.value.templateIds.length <= 1) return  
434 - SonFormData.value.templateIds = SonFormData.value.templateIds.filter((_, i) => i !== index)  
435 - // 重新同步所有模板详情(不移除、不清空,只重新对应位置)  
436 - const oldMap = { ...templateDetailMap.value }  
437 - templateDetailMap.value = {}  
438 - templateLoading.value = {}  
439 - SonFormData.value.templateIds.forEach((item, i) => {  
440 - if (item.templateId && oldMap[i] !== undefined) {  
441 - templateDetailMap.value[i] = oldMap[i]  
442 - }  
443 - })  
444 - syncFrequency()  
445 -} 365 +// ==================== 初始化 ====================
446 onMounted(() => { 366 onMounted(() => {
447 loadTemplateList() 367 loadTemplateList()
448 loadCategoryList() 368 loadCategoryList()
449 }) 369 })
450 </script> 370 </script>
  371 +
451 <style scoped> 372 <style scoped>
452 -/* 模板详情容器 */ 373 +/* ==================== 工具类 ==================== */
  374 +.w-full {
  375 + width: 100%;
  376 +}
  377 +
  378 +/* ==================== 模板卡片 ==================== */
  379 +.template-block {
  380 + margin-bottom: 16px;
  381 + padding: 16px 20px 4px;
  382 + background: var(--el-fill-color-lighter);
  383 + border: 1px solid var(--el-border-color-lighter);
  384 + border-radius: 8px;
  385 +}
  386 +
  387 +.template-block-toolbar {
  388 + display: flex;
  389 + justify-content: flex-end;
  390 + gap: 10px;
  391 + margin-bottom: 8px;
  392 +}
  393 +
  394 +/* ==================== 模板详情 ==================== */
453 .template-detail-container { 395 .template-detail-container {
454 width: 100%; 396 width: 100%;
455 padding: 12px; 397 padding: 12px;
456 - background: #f9fafb; 398 + background: var(--el-fill-color-lighter);
457 border-radius: 6px; 399 border-radius: 6px;
458 box-sizing: border-box; 400 box-sizing: border-box;
459 } 401 }
460 402
461 -/* 训练单元容器 */ 403 +/* ==================== 训练单元 ==================== */
462 .unit-container { 404 .unit-container {
463 width: 100%; 405 width: 100%;
464 margin-bottom: 8px; 406 margin-bottom: 8px;
465 - background: #fff;  
466 - border: 1px solid #dcdfe6; 407 + background: var(--el-bg-color);
  408 + border: 1px solid var(--el-border-color);
467 border-radius: 4px; 409 border-radius: 4px;
468 } 410 }
469 411
470 -/* 训练单元行(点击展开) */  
471 .unit-row { 412 .unit-row {
472 display: flex; 413 display: flex;
  414 + align-items: center;
473 padding: 12px 16px; 415 padding: 12px 16px;
474 font-size: 14px; 416 font-size: 14px;
475 cursor: pointer; 417 cursor: pointer;
476 - border-bottom: 1px solid #ebeef5;  
477 - align-items: center; 418 + border-bottom: 1px solid var(--el-border-color-lighter);
478 } 419 }
479 420
480 .unit-row:hover { 421 .unit-row:hover {
481 - background: #f5f7fa; 422 + background: var(--el-fill-color-light);
482 } 423 }
483 424
484 .expand-icon { 425 .expand-icon {
485 margin-right: 8px; 426 margin-right: 8px;
486 - color: #909399; 427 + color: var(--el-text-color-secondary);
487 transition: transform 0.3s; 428 transition: transform 0.3s;
488 } 429 }
489 430
490 -.unit-name {  
491 - font-weight: 500;  
492 - color: #303133; 431 +.expand-icon.is-expanded {
  432 + transform: rotate(90deg);
493 } 433 }
494 434
495 -/* 组详情容器 */  
496 -.set-container {  
497 - padding: 12px;  
498 - border-top: 1px solid #ebeef5; 435 +.unit-name {
  436 + font-weight: 500;
  437 + color: var(--el-text-color-primary);
499 } 438 }
500 439
501 -.set-header { 440 +/* ==================== 训练组详情 ==================== */
  441 +.set-header,
  442 +.set-row {
502 display: grid; 443 display: grid;
503 padding: 8px 12px; 444 padding: 8px 12px;
504 - font-weight: 500;  
505 - color: #606266;  
506 text-align: center; 445 text-align: center;
507 - background: #f5f7fa;  
508 grid-template-columns: repeat(5, 1fr); 446 grid-template-columns: repeat(5, 1fr);
509 } 447 }
510 448
  449 +.set-header {
  450 + font-weight: 500;
  451 + color: var(--el-text-color-regular);
  452 + background: var(--el-fill-color-light);
  453 +}
  454 +
511 .set-row { 455 .set-row {
512 - display: grid;  
513 - padding: 8px 12px;  
514 - color: #303133;  
515 - text-align: center;  
516 - border-top: 1px solid #ebeef5;  
517 - grid-template-columns: repeat(5, 1fr); 456 + color: var(--el-text-color-primary);
  457 + border-top: 1px solid var(--el-border-color-lighter);
  458 +}
  459 +
  460 +.exercise-container {
  461 + padding: 4px;
518 } 462 }
519 -</style>  
  463 +</style>
1 <template> 1 <template>
2 <div class="page-container"> 2 <div class="page-container">
3 - <el-card class="search-bar">  
4 - <div class="search-item">  
5 - <label class="search-label">计划名称</label>  
6 - <el-input v-model="searchForm.name" placeholder="请输入计划名称" class="search-input" @keyup.enter="handleSearch"  
7 - clearable />  
8 - <div class="search-buttons"> 3 + <!-- 搜索栏 -->
  4 + <ContentWrap>
  5 + <el-form :inline="true" :model="queryParams" class="search-form">
  6 + <el-form-item label="计划名称">
  7 + <el-input
  8 + v-model="queryParams.name"
  9 + placeholder="请输入计划名称"
  10 + clearable
  11 + class="!w-220px"
  12 + @keyup.enter="handleSearch"
  13 + />
  14 + </el-form-item>
  15 + <el-form-item>
9 <el-button type="primary" @click="handleSearch"> 16 <el-button type="primary" @click="handleSearch">
10 - <el-icon>  
11 - <Search />  
12 - </el-icon>  
13 - 搜索 17 + <Icon icon="ep:search" class="mr-5px" />搜索
14 </el-button> 18 </el-button>
15 <el-button @click="handleReset"> 19 <el-button @click="handleReset">
16 - <el-icon>  
17 - <Refresh />  
18 - </el-icon>  
19 - 重置 20 + <Icon icon="ep:refresh" class="mr-5px" />重置
20 </el-button> 21 </el-button>
21 <el-button type="primary" @click="openAddDialog"> 22 <el-button type="primary" @click="openAddDialog">
22 - <el-icon>  
23 - <Plus />  
24 - </el-icon>  
25 - 新增 23 + <Icon icon="ep:plus" class="mr-5px" />新增
26 </el-button> 24 </el-button>
27 - </div>  
28 - </div>  
29 - </el-card>  
30 - <!-- 列表区域 -->  
31 - <el-card class="table-card">  
32 - <el-table v-loading="loading" :data="tableData" stripe style="width: 100%;">  
33 - <el-table-column prop="name" label="计划名称" align="center" sortable="custom" /> 25 + </el-form-item>
  26 + </el-form>
  27 + </ContentWrap>
  28 +
  29 + <!-- 表格区域 -->
  30 + <ContentWrap>
  31 + <el-table v-loading="loading" :data="tableData" stripe>
  32 + <el-table-column prop="name" label="计划名称" align="center" />
34 <el-table-column label="计划周期" align="center" :formatter="formatDurationWeeks" /> 33 <el-table-column label="计划周期" align="center" :formatter="formatDurationWeeks" />
35 <el-table-column label="场景" align="center" :formatter="formatTrainingScene" /> 34 <el-table-column label="场景" align="center" :formatter="formatTrainingScene" />
36 <el-table-column label="训练频率" align="center" :formatter="formatFrequency" /> 35 <el-table-column label="训练频率" align="center" :formatter="formatFrequency" />
37 <el-table-column label="难度" align="center" :formatter="formatDifficulty" /> 36 <el-table-column label="难度" align="center" :formatter="formatDifficulty" />
38 <el-table-column label="适合人群" align="center" :formatter="formatTargetPeople" /> 37 <el-table-column label="适合人群" align="center" :formatter="formatTargetPeople" />
39 <el-table-column prop="createTime" label="创建时间" align="center" :formatter="dateFormatter" /> 38 <el-table-column prop="createTime" label="创建时间" align="center" :formatter="dateFormatter" />
40 - <el-table-column prop="updateTime" label="更新时间" :formatter="dateFormatter" /> 39 + <el-table-column prop="updateTime" label="更新时间" align="center" :formatter="dateFormatter" />
41 <el-table-column label="操作" align="center" width="150"> 40 <el-table-column label="操作" align="center" width="150">
42 <template #default="scope"> 41 <template #default="scope">
43 - <el-button type="text" size="small" style=" margin-right: 8px;color: #409EFF;"  
44 - @click="handleEdit(scope.row)">编辑</el-button>  
45 - <el-button type="text" size="small" style="color: #F56C6C;"  
46 - @click="handleDelete(scope.row.id)">删除</el-button> 42 + <el-button link type="primary" @click="handleEdit(scope.row)">编辑</el-button>
  43 + <el-button link type="danger" @click="handleDelete(scope.row.id)">删除</el-button>
47 </template> 44 </template>
48 </el-table-column> 45 </el-table-column>
49 </el-table> 46 </el-table>
50 47
51 - <!-- 分页组件 -->  
52 - <div class="pagination-wrapper" style="padding: 12px 16px;">  
53 - <el-pagination v-model:current-page="pagination.pageNo" v-model:page-size="pagination.pageSize"  
54 - :total="pagination.total" :page-sizes="[10, 20, 50]" layout="total, sizes, prev, pager, next, jumper"  
55 - style="text-align: left;" @size-change="handleSizeChange" @current-change="handleCurrentChange" />  
56 - </div>  
57 - </el-card>  
58 - <!-- 弹窗组件 -->  
59 - <trainingPlanDialog v-model:visible="dialogVisible" :form-data="formData" :title="dialogTitle" @submit="getList" /> 48 + <Pagination
  49 + :total="queryParams.total"
  50 + v-model:page="queryParams.pageNo"
  51 + v-model:limit="queryParams.pageSize"
  52 + @pagination="getList"
  53 + />
  54 + </ContentWrap>
  55 +
  56 + <!-- 弹窗 -->
  57 + <TrainingPlanDialog
  58 + v-model:visible="dialogVisible"
  59 + :form-data="formData"
  60 + :title="dialogTitle"
  61 + @submit="getList"
  62 + />
60 </div> 63 </div>
61 </template> 64 </template>
62 65
63 <script setup> 66 <script setup>
64 -import { ref, reactive } from 'vue'  
65 -import { ElMessage } from 'element-plus'  
66 -import { Search, Refresh, Plus } from '@element-plus/icons-vue'  
67 -import lostImg from '@/assets/imgs/lost.png' // 丢失图片占位图  
68 -import { dateFormatter } from '@/utils/formatTime' 67 +import { ref, reactive, onMounted } from 'vue'
  68 +import { ElMessage, ElMessageBox } from 'element-plus'
  69 +
69 import { trainingPlanApi } from '@/api/store/training/trainingPlan' 70 import { trainingPlanApi } from '@/api/store/training/trainingPlan'
70 import TrainingPlanDialog from './trainingPlanDialog.vue' 71 import TrainingPlanDialog from './trainingPlanDialog.vue'
  72 +import { dateFormatter } from '@/utils/formatTime'
  73 +
  74 +// ==================== 常量映射 ====================
  75 +const DIFFICULTY_MAP = { 1: '初阶', 2: '中阶', 3: '高阶' }
  76 +const TARGET_PEOPLE_MAP = { 1: '不限', 2: '青少年', 3: '成年人', 4: '中年人', 5: '运动损伤' }
  77 +const TRAINING_SCENE_MAP = { 1: '不限', 2: '健身房', 3: '仅哑铃', 4: '仅哑铃 + 杠铃' }
  78 +
  79 +// ==================== 查询参数 ====================
  80 +const queryParams = reactive({
  81 + pageNo: 1,
  82 + pageSize: 10,
  83 + total: 0,
  84 + name: ''
  85 +})
71 const loading = ref(false) 86 const loading = ref(false)
72 -const isEdit = ref(true) 87 +const tableData = ref([])
  88 +
  89 +// ==================== 弹窗状态 ====================
73 const dialogVisible = ref(false) 90 const dialogVisible = ref(false)
74 const dialogTitle = ref('') 91 const dialogTitle = ref('')
75 const formData = ref({ 92 const formData = ref({
76 templateIds: [{ templateId: undefined, sortOrder: 1 }] 93 templateIds: [{ templateId: undefined, sortOrder: 1 }]
77 -}) // 传给弹窗的表单数据  
78 -// 分页参数  
79 -const pagination = reactive({  
80 - pageNo: 1,  
81 - pageSize: 10,  
82 - total: 0  
83 }) 94 })
84 -// 文本映射=============  
85 -// 难度映射  
86 -const difficultyMap = {  
87 - 1: '初阶',  
88 - 2: '中阶',  
89 - 3: '高阶'  
90 -}  
91 -  
92 -// 适合人群映射  
93 -const targetPeopleMap = {  
94 - 1: '不限',  
95 - 2: '青少年',  
96 - 3: '成年人',  
97 - 4: '中年人',  
98 - 5: '运动损伤'  
99 -}  
100 -  
101 -// 训练场景映射  
102 -const trainingSceneMap = {  
103 - 1: '不限',  
104 - 2: '健身房',  
105 - 3: '仅哑铃',  
106 - 4: '仅哑铃 + 杠铃'  
107 -}  
108 -// 格式化难度  
109 -const formatDifficulty = (row) => {  
110 - return difficultyMap[row.difficultyLevel] || '未知'  
111 -}  
112 -  
113 -// 格式化适合人群  
114 -const formatTargetPeople = (row) => {  
115 - return targetPeopleMap[row.targetPeople] || '未知'  
116 -}  
117 -  
118 -// 格式化训练场景  
119 -const formatTrainingScene = (row) => {  
120 - return trainingSceneMap[row.trainingScene] || '未知'  
121 -}  
122 -  
123 -// 格式化计划周期(在数字后加“周”)  
124 -const formatDurationWeeks = (row) => {  
125 - return row.durationWeeks ? `${row.durationWeeks}周` : '0周'  
126 -}  
127 -// 格式化训练频率(数字 + 练/周)  
128 -const formatFrequency = (row) => {  
129 - return row.frequencyPerWeek ? `${row.frequencyPerWeek}练/周` : '0练/周'  
130 -}  
131 95
132 -// 搜索表单  
133 -const searchForm = reactive({  
134 - name: ''  
135 -})  
136 -const tableData = ref([]) 96 +// ==================== 格式化函数 ====================
  97 +const formatDifficulty = (row) => DIFFICULTY_MAP[row.difficultyLevel] || '未知'
  98 +const formatTargetPeople = (row) => TARGET_PEOPLE_MAP[row.targetPeople] || '未知'
  99 +const formatTrainingScene = (row) => TRAINING_SCENE_MAP[row.trainingScene] || '未知'
  100 +const formatDurationWeeks = (row) => row.durationWeeks ? row.durationWeeks + '周' : '0周'
  101 +const formatFrequency = (row) => row.frequencyPerWeek ? row.frequencyPerWeek + '练/周' : '0练/周'
137 102
138 -// 获取学员训练记录列表 103 +// ==================== 数据加载 ====================
139 const getList = async () => { 104 const getList = async () => {
140 loading.value = true 105 loading.value = true
141 try { 106 try {
142 - // 搜索条件  
143 - const params = {  
144 - pageNo: pagination.pageNo + '',  
145 - pageSize: pagination.pageSize + '',  
146 - name: searchForm.name,  
147 - }  
148 - const res = await trainingPlanApi.getTrainingPlan(params) 107 + const res = await trainingPlanApi.getTrainingPlan({
  108 + pageNo: queryParams.pageNo + '',
  109 + pageSize: queryParams.pageSize + '',
  110 + name: queryParams.name
  111 + })
149 tableData.value = res.list || [] 112 tableData.value = res.list || []
150 - pagination.total = res.total || 0  
151 - console.log('计划分页接口返回数据:', res) 113 + queryParams.total = res.total || 0
152 } catch (err) { 114 } catch (err) {
153 - console.error('❌ 请求失败:', err)  
154 ElMessage.error('请求失败,请检查网络或接口') 115 ElMessage.error('请求失败,请检查网络或接口')
155 } finally { 116 } finally {
156 loading.value = false 117 loading.value = false
157 } 118 }
158 } 119 }
159 120
160 -// 搜索 121 +// ==================== 搜索与重置 ====================
161 const handleSearch = () => { 122 const handleSearch = () => {
162 - ElMessage.success('执行搜索:' + searchForm.name) 123 + queryParams.pageNo = 1
163 getList() 124 getList()
164 } 125 }
165 126
166 -// 重置  
167 const handleReset = () => { 127 const handleReset = () => {
168 - pagination.pageNo = 1  
169 - pagination.pageSize = 10  
170 - searchForm.name = ''  
171 - ElMessage.success('已重置搜索条件') 128 + Object.assign(queryParams, { pageNo: 1, pageSize: 10, total: 0, name: '' })
172 getList() 129 getList()
173 } 130 }
174 131
175 -// 新增 132 +// ==================== 新增 ====================
176 const openAddDialog = () => { 133 const openAddDialog = () => {
177 dialogTitle.value = '新增训练计划' 134 dialogTitle.value = '新增训练计划'
178 - // 新增时彻底重置表单,清空编辑残留  
179 - // 彻底重置 formData,100%清空所有编辑残留,和子组件 SonFormData 初始结构完全一致  
180 formData.value = { 135 formData.value = {
181 id: undefined, 136 id: undefined,
182 categoryId: 0, 137 categoryId: 0,
@@ -194,20 +149,14 @@ const openAddDialog = () => { @@ -194,20 +149,14 @@ const openAddDialog = () => {
194 dialogVisible.value = true 149 dialogVisible.value = true
195 } 150 }
196 151
197 -// 编辑 152 +// ==================== 编辑 ====================
198 const handleEdit = (row) => { 153 const handleEdit = (row) => {
199 dialogTitle.value = '编辑训练计划' 154 dialogTitle.value = '编辑训练计划'
200 - // 保留完整表单结构,只覆盖后端有的字段  
201 - formData.value = {  
202 - // 先保留完整结构(不丢字段,这里没有新增时的数据,只是我创建formData的初始数据)  
203 - ...formData.value,  
204 - // 再覆盖编辑数据,row里面是列表的字段,列表的字段和新增的字段的数量是不一样的。  
205 - ...row  
206 - } 155 + formData.value = { ...formData.value, ...row }
207 dialogVisible.value = true 156 dialogVisible.value = true
208 } 157 }
209 158
210 -// 删除 159 +// ==================== 删除 ====================
211 const handleDelete = async (id) => { 160 const handleDelete = async (id) => {
212 try { 161 try {
213 await ElMessageBox.confirm('确定要删除吗?', '提示', { 162 await ElMessageBox.confirm('确定要删除吗?', '提示', {
@@ -215,77 +164,31 @@ const handleDelete = async (id) => { @@ -215,77 +164,31 @@ const handleDelete = async (id) => {
215 cancelButtonText: '取消', 164 cancelButtonText: '取消',
216 type: 'warning' 165 type: 'warning'
217 }) 166 })
218 - // 能走到这里,说明用户点了确定  
219 await trainingPlanApi.deleteTrainingPlan(id) 167 await trainingPlanApi.deleteTrainingPlan(id)
220 ElMessage.success('删除成功') 168 ElMessage.success('删除成功')
  169 + getList()
221 } catch (err) { 170 } catch (err) {
222 - // 关键:判断是不是用户点击了取消  
223 - if (err === 'cancel' || err?.message === 'cancel') {  
224 - ElMessage.info('已取消删除')  
225 - return // 直接退出,不报错 171 + if (err !== 'cancel') {
  172 + ElMessage.error('删除失败')
226 } 173 }
227 - // 只有真正的请求错误才走这里  
228 - console.error('❌ 请求失败:', err)  
229 - ElMessage.error('请求失败,请检查网络或接口')  
230 - } finally {  
231 - getList() // 刷新列表  
232 } 174 }
233 } 175 }
234 176
235 -// 处理分页大小变化  
236 -const handleSizeChange = (val) => {  
237 - pagination.pageSize = val  
238 - getList()  
239 -}  
240 -//  
241 -const handleCurrentChange = (val) => {  
242 - pagination.pageNo = val  
243 - getList()  
244 -} 177 +// ==================== 初始化 ====================
245 onMounted(() => { 178 onMounted(() => {
246 getList() 179 getList()
247 }) 180 })
248 </script> 181 </script>
249 182
250 <style scoped> 183 <style scoped>
251 -.search-bar {  
252 - margin-bottom: 20px;  
253 - box-shadow: 0 1px 2px rgb(0 0 0 / 5%);  
254 -}  
255 -  
256 -.search-item {  
257 - display: flex;  
258 - align-items: center;  
259 - gap: 10px;  
260 -}  
261 -  
262 -.search-label {  
263 - /* width: 160px; */  
264 -  
265 - /* font-weight: 500; */  
266 -}  
267 -  
268 -.search-input {  
269 - width: 260px;  
270 -}  
271 -  
272 -.search-buttons {  
273 - display: flex;  
274 - gap: 10px;  
275 - margin-left: 10px;  
276 -}  
277 -  
278 -.table-card {  
279 - margin-top: 5px;  
280 - background: #fff;  
281 - border-radius: 4px;  
282 - box-shadow: 0 1px 2px rgb(0 0 0 / 5%); 184 +.page-container {
  185 + padding: 20px;
  186 + min-height: 100vh;
  187 + background-color: #f5f7fa;
  188 + box-sizing: border-box;
283 } 189 }
284 190
285 -/* 分页居右 */  
286 -.pagination-wrapper {  
287 - display: flex;  
288 - padding: 12px 16px;  
289 - justify-content: flex-end; 191 +.search-form :deep(.el-form-item) {
  192 + margin-bottom: 0;
290 } 193 }
291 </style> 194 </style>