trainingPlanDialog.vue
18.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
<template>
<Dialog v-model="dialogVisible" :title="title" width="800px" height="100%" append-to-body>
<!-- 这里直接用 SonFormData -->
<el-form ref="formRef" :model="SonFormData" label-width="120px" :rules="formRules" label-position="right">
<el-form-item label="计划名称" prop="name" label-suffix="*">
<el-input v-model="SonFormData.name" placeholder="请输入计划名称" maxlength="50" show-word-limit />
</el-form-item>
<el-form-item label="封面图" prop="urlCover" class="form-item-margin" label-suffix="*">
<UploadImg v-model="SonFormData.urlCover" />
</el-form-item>
<!-- 计划分类/后来加上 -->
<el-form-item label="计划分类" prop="categoryId" label-suffix="*">
<el-select v-model="SonFormData.categoryId" placeholder="请选择计划分类">
<el-option v-for="item in categoryList" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="训练场景" prop="trainingScene" label-suffix="*">
<el-select v-model="SonFormData.trainingScene">
<el-option label="不限" :value="1" />
<el-option label="健身房" :value="2" />
<el-option label="仅哑铃" :value="3" />
<el-option label="仅哑铃+杠铃" :value="4" />
</el-select>
</el-form-item>
<!-- 难度的不限是后来加上的 -->
<el-form-item label="计划难度" prop="difficultyLevel" label-suffix="*">
<el-select v-model="SonFormData.difficultyLevel">
<!-- <el-option label="不限" :value="0" /> -->
<el-option label="初阶" :value="1" />
<el-option label="中阶" :value="2" />
<el-option label="高阶" :value="3" />
</el-select>
</el-form-item>
<el-form-item label="适合人群" prop="targetPeople" label-suffix="*">
<el-select v-model="SonFormData.targetPeople">
<el-option label="不限" :value="1" />
<el-option label="青少年" :value="2" />
<el-option label="成年人" :value="3" />
<el-option label="中年人" :value="4" />
<el-option label="运动损伤" :value="5" />
</el-select>
</el-form-item>
<el-form-item label="频率" prop="frequencyPerWeek" label-suffix="*">
<el-select v-model="SonFormData.frequencyPerWeek">
<el-option label="不限" :value="0" />
<el-option label="1练/周" :value="1" />
<el-option label="2练/周" :value="2" />
<el-option label="3练/周" :value="3" />
<el-option label="4练/周" :value="4" />
<el-option label="5练/周" :value="5" />
<el-option label="6练/周" :value="6" />
<el-option label="7练/周" :value="7" />
</el-select>
</el-form-item>
<el-form-item label="计划周期(周)" prop="durationWeeks" label-suffix="*">
<el-input-number v-model="SonFormData.durationWeeks" class="w-full" />
</el-form-item>
<!-- 选择模板,并且显示模板详情 -->
<div v-for="(templates, templatesIndex) in SonFormData.templateIds" :key="templatesIndex" style="width: 100%;">
<el-form-item label="模板" :prop="`templateIds[${templatesIndex}].templateId`" label-suffix="*"
style="width: 100%; margin: 0;" class="is-required">
<div style="display: flex; align-items: center; gap: 12px;width: 100%;">
<el-select v-model="templates.templateId" placeholder="请选择模板" style="flex: 1; width: 0;"
@change="() => loadTemplateDetail(templatesIndex, templates.templateId)"
@click="() => reloadTemplateDetail(templatesIndex)">
<el-option v-for="item in templateList" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
<div style="display: flex; gap: 8px; flex-shrink: 0;">
<el-button @click="deltemplate(templatesIndex)"
:disabled="SonFormData.templateIds.length <= 1">删除</el-button>
<el-button @click="Addtemplate(templatesIndex)">新增</el-button>
</div>
</div>
</el-form-item>
<el-form-item :prop="`templateIds[${templatesIndex}].sortOrder`" label="排序" label-suffix="*"
class="is-required">
<el-input-number v-model="templates.sortOrder" :min="1" :max="99" placeholder="排序号" style="width: 100%" />
</el-form-item>
<!-- 模板详情 -->
<!-- 模板详情回显(新增部分) -->
<el-form-item label="模板详情" v-if="templateDetailMap[templatesIndex]"
style="width: 100%; padding: 0; margin: 0 auto;" class="template-detail-container">
<el-collapse-transition>
<div v-loading="templateLoading[templatesIndex]">
<!-- 训练单元列表 -->
<el-table :data="templateDetailMap[templatesIndex].units || []" border
style="width: 100%; margin-bottom: 12px;" :show-header="true" class="expand-table-fix">
<el-table-column prop="unitName" label="训练单元名称" min-width="150" />
<el-table-column prop="sortOrder" label="排序" width="100" align="center" />
<!-- <el-table-column prop="setCount" label="组数" width="80" align="center" /> -->
</el-table>
<!-- 用纯DIV替代嵌套表格,彻底解决宽度问题 -->
<div v-for="unit in templateDetailMap[templatesIndex].units || []" :key="unit.unitId"
class="unit-container">
<!-- 训练单元行(可点击展开) -->
<div class="unit-row" @click="unit.isExpand = !unit.isExpand">
<span class="expand-icon">{{ unit.isExpand ? '▼' : '▶' }}</span>
<span class="unit-name">{{ unit.unitName }}</span>
</div>
<!-- 展开的动作列表 -->
<div v-if="unit.isExpand" class="exercise-container">
<div v-for="exercise in unit.exercises || []" :key="exercise.exerciseId" class="exercise-row">
<div class="exercise-header">
<!-- <span class="exercise-name">{{ exercise.exerciseName }}</span> -->
<!-- <span class="exercise-info">组数:{{ exercise.setCount }} | 重量:{{ exercise.weight }} | 次数:{{
exercise.reps }}</span> -->
<!-- <span class="exercise-expand" @click="exercise.isExpand = !exercise.isExpand">
{{ exercise.isExpand ? '▼ 收起组详情' : '▶ 展开详情' }}
</span> -->
</div>
<!-- 展开的组详情 -->
<div class="set-container">
<div class="set-header">
<span>重量</span>
<span>距离</span>
<span>次数</span>
<span>锻炼时长(秒)</span>
<span>休息(秒)</span>
</div>
<div v-for="set in exercise.sets || []" :key="set.setIndex" class="set-row">
<span>{{ set.weight }}</span>
<span>{{ set.distance }}</span>
<span>{{ set.reps }}</span>
<span>{{ set.duration }}</span>
<span>{{ set.restTime }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- -->
</div>
</el-collapse-transition>
</el-form-item>
</div>
<!-- <el-form-item label="所需器械汇总" prop="equipmentsSummary" label-suffix="*">
<el-input v-model="SonFormData.equipmentsSummary" />
</el-form-item> -->
<el-form-item label="计划介绍" prop="introduction" label-suffix="*">
<el-input v-model="SonFormData.introduction" type="textarea" :rows="3" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleCancel">取消</el-button>
<el-button type="primary" :loading="loading" @click="handleSubmit">保存</el-button>
</template>
</Dialog>
</template>
<script setup>
import { ref, reactive, watch, onMounted, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { TrainingTemplatesApi } from '@/api/store/training/templates'
import { trainingPlanApi } from '@/api/store/training/trainingPlan'
import { Minus, Plus } from '@element-plus/icons-vue'
import { Dialog } from '@/components/Dialog'
// import { Table } from '@/components/Table'
// 父组件传过来的值(只用来接收,不冲突)
const props = defineProps({
visible: { type: Boolean, default: false },
title: String,
formData: { type: Object, default: null }
})
const emit = defineEmits(['submit', 'update:visible'])
const dialogVisible = computed({
get: () => props.visible,
set: (val) => emit('update:visible', val)
})
// ========================
// 子组件自己的表单数据(唯一来源)
// ========================
const SonFormData = ref({
id: undefined,
categoryId: 0,
name: '',
urlCover: '',
introduction: '',
durationWeeks: 1,
frequencyPerWeek: 1,
difficultyLevel: 1,
targetPeople: 1,
trainingScene: 1,
equipmentsSummary: '',
templateIds: [{ templateId: undefined, sortOrder: 1 }]
})
// 是否是编辑功能
// 新增:专门的编辑状态标记
const isEdit = ref(false)
const formRef = ref()
const loading = ref(false)
const templateList = ref([])
const categoryList = ref([]) // 计划分类列表
// 存储每个模板的详情数据,key 是模板 index,value 是接口返回的详情
const templateDetailMap = ref({})
// 模板详情加载状态
const templateLoading = ref({})
const formRules = reactive({
name: [{ required: true, message: '请输入计划名称', trigger: 'blur' }],
durationWeeks: [{ required: true, message: '请输入计划周期', trigger: 'blur' }],
frequencyPerWeek: [{ required: true, message: '请输入训练频率', trigger: 'change' }],
difficultyLevel: [{ required: true, message: '请输入难度', trigger: 'change' }],
targetPeople: [{ required: true, message: '请输入人群', trigger: 'change' }],
trainingScene: [{ required: true, message: '请输入场景', trigger: 'change' }],
urlCover: [{ required: true, message: '输入图片', trigger: 'change' }],
// equipmentsSummary: [{ required: true, message: '请输入所需器械汇总', trigger: 'blur' }],
introduction: [{ required: true, message: '请输入计划介绍', trigger: 'blur' }],
categoryId: [{ required: true, message: '请选择计划分类', trigger: 'change' }],
templateIds: [{
type: 'array',
required: true,
validator: (rule, value, callback) => {
if (!value || value.length === 0) {
return callback(new Error('至少添加一个模板'))
}
for (let i = 0; i < value.length; i++) {
const item = value[i]
if (!item.templateId) {
return callback(new Error(`第 ${i + 1} 个模板未选择`))
}
if (!item.sortOrder || item.sortOrder < 1) {
return callback(new Error(`第 ${i + 1} 个排序号必须≥1`))
}
}
callback()
}
}],
})
watch(dialogVisible, async (val) => {
if (!val) return
// 重置模板详情缓存,解决旧数据残留问题
templateDetailMap.value = {}
templateLoading.value = {}
SonFormData.value = {
id: undefined,
categoryId: 0,
name: '',
urlCover: '',
introduction: '',
durationWeeks: 1,
frequencyPerWeek: 1,
difficultyLevel: 1,
targetPeople: 1,
trainingScene: 1,
equipmentsSummary: '',
templateIds: [{ templateId: undefined, sortOrder: 1 }]
}
// 新增
if (!props.formData || Object.keys(props.formData).length === 0 || props.formData.id == null) {
// 控制使用哪个接口
isEdit.value = false // 强制写死
// 新增:重置清除前面编辑可能留下的校验
formRef.value?.clearValidate()
console.log('新增接口isEdit的值', isEdit)
} else {
isEdit.value = true
// 编辑:赋值
// 调计划详情接口
const res = await trainingPlanApi.getTrainingPlanDetail(props.formData.id)
console.log('计划详情数据res================', res)
console.log('计划详情数据res.data================', res.data)
console.log('计划详情数据res.equipmentsSummary================', res.equipmentsSummary)
if (!res || !res.id) {
ElMessage.error('获取计划详情失败')
console.log('获取计划详情失败')
return
}
SonFormData.value = res
// 2. 补 res 里缺失、但父组件有值的字段
SonFormData.value.trainingScene = props.formData.trainingScene
SonFormData.value.targetPeople = props.formData.targetPeople
// 详情接口返回的字段和SonFormData的不一样,需要处理
SonFormData.value.categoryId = props.formData.categoryId
console.log("===== 父组件传过来的完整数据 阉割版=====")
console.log(props.formData)
// // 详情返回的是数组,但是这里要的是字符串
if (Array.isArray(res.equipmentsSummary)) {
SonFormData.value.equipmentsSummary = res.equipmentsSummary.join(',')
}
// 赋值模板的id和排序
if (res.templates && res.templates.length) {
SonFormData.value.templateIds = res.templates.map(item => ({
templateId: item.id,
sortOrder: item.sortOrder
}))
}
// 防止模板为空,导致模板选项不出现(虽然不太可能,因为这个是必填项)
if (!SonFormData.value.templateIds || SonFormData.value.templateIds.length === 0) {
SonFormData.value.templateIds = [{ templateId: undefined, sortOrder: 1 }]
}
// // 编辑状态:自动加载已有模板的详情
SonFormData.value.templateIds.forEach((item, index) => {
if (item.templateId) {
loadTemplateDetail(index, item.templateId)
}
})
}
formRef.value?.clearValidate()
})
// ==========================加载数据======================
// 加载模板列表
const loadTemplateList = async () => {
const res = await TrainingTemplatesApi.getTrainingTemplatesPage({ pageNo: 1, pageSize: 100 })
templateList.value = res.list || []
if (templateList.value.length === 0) {
ElMessage.warning('暂无可用训练模板,请先创建模板')
}
}
// 获取模板详情并缓存
const loadTemplateDetail = async (index, templateId) => {
if (!templateId) return
// 标记加载中
templateLoading.value[index] = true
try {
const res = await TrainingTemplatesApi.getTrainingTemplates(templateId)
// 给每个单元、动作加展开状态(默认收起)
res.units?.forEach(unit => {
unit.isExpand = false
unit.exercises?.forEach(exercise => {
exercise.isExpand = false
})
})
// 缓存详情数据
templateDetailMap.value[index] = res
} catch (err) {
ElMessage.error(`获取模板${index + 1}详情失败`)
console.error(err)
} finally {
// 取消加载中
templateLoading.value[index] = false
}
}
// 加载计划分类列表
const loadCategoryList = async () => {
const res = await trainingPlanApi.getPlanCategoryPage({ pageNo: 1, pageSize: 100 })
categoryList.value = res.list || []
}
// 强制重新加载当前 index 的模板详情(解决重复选择不触发问题)
const reloadTemplateDetail = (index) => {
const id = SonFormData.value.templateIds[index]?.templateId
if (id) loadTemplateDetail(index, id)
}
// 取消
const handleCancel = () => {
dialogVisible.value = false
ElMessage.info('取消保存')
}
// 提交
const handleSubmit = async () => {
await formRef.value.validate()
loading.value = true
try {
if (isEdit.value) {
await trainingPlanApi.updateTrainingPlan(SonFormData.value)
} else {
await trainingPlanApi.createTrainingPlan(SonFormData.value)
}
console.log('调用接口后isEdit的值', isEdit)
ElMessage.success(isEdit.value ? '编辑成功' : '创建成功')
dialogVisible.value = false
emit('submit')
} catch (err) {
ElMessage.error((isEdit.value ? '编辑' : '创建') + '失败:' + err.message)
} finally {
loading.value = false
}
}
const Addtemplate = (index) => {
const newTemplate = {
templateId: 1,
sortOrder: SonFormData.value.templateIds.length + 1
}
const newTemplateIds = [...SonFormData.value.templateIds]
newTemplateIds.splice(index + 1, 0, newTemplate)
SonFormData.value.templateIds = newTemplateIds
// 重新同步所有模板详情(不移除、不清空,只重新对应位置)
const oldMap = { ...templateDetailMap.value }
templateDetailMap.value = {}
templateLoading.value = {}
SonFormData.value.templateIds.forEach((item, i) => {
if (item.templateId && oldMap[i] !== undefined) {
templateDetailMap.value[i] = oldMap[i]
}
})
}
const deltemplate = (index) => {
if (SonFormData.value.templateIds.length <= 1) return
SonFormData.value.templateIds = SonFormData.value.templateIds.filter((_, i) => i !== index)
// 重新同步所有模板详情(不移除、不清空,只重新对应位置)
const oldMap = { ...templateDetailMap.value }
templateDetailMap.value = {}
templateLoading.value = {}
SonFormData.value.templateIds.forEach((item, i) => {
if (item.templateId && oldMap[i] !== undefined) {
templateDetailMap.value[i] = oldMap[i]
}
})
}
onMounted(() => {
loadTemplateList()
loadCategoryList()
})
</script>
<style scoped>
/* 模板详情容器 */
.template-detail-container {
width: 100%;
padding: 12px;
background: #f9fafb;
border-radius: 6px;
box-sizing: border-box;
}
/* 训练单元容器 */
.unit-container {
width: 100%;
margin-bottom: 8px;
background: #fff;
border: 1px solid #dcdfe6;
border-radius: 4px;
}
/* 训练单元行(点击展开) */
.unit-row {
display: flex;
padding: 12px 16px;
font-size: 14px;
cursor: pointer;
border-bottom: 1px solid #ebeef5;
align-items: center;
}
.unit-row:hover {
background: #f5f7fa;
}
.expand-icon {
margin-right: 8px;
color: #909399;
transition: transform 0.3s;
}
.unit-name {
font-weight: 500;
color: #303133;
}
/* 组详情容器 */
.set-container {
padding: 12px;
border-top: 1px solid #ebeef5;
}
.set-header {
display: grid;
padding: 8px 12px;
font-weight: 500;
color: #606266;
text-align: center;
background: #f5f7fa;
grid-template-columns: repeat(5, 1fr);
}
.set-row {
display: grid;
padding: 8px 12px;
color: #303133;
text-align: center;
border-top: 1px solid #ebeef5;
grid-template-columns: repeat(5, 1fr);
}
</style>