- 将 Props 中 ID 字段从 number 改为 number | string,兼容后端 Long 序列化为 String - 修复分页组件 total 字段类型,使用 Number() 转换避免 Vue warn - 影响组件: PrepareNavigation, LessonCard, SelectLessonsModal 等 - 影响视图: StudentListView, TeacherListView, ParentListView 等
341 lines
7.4 KiB
Vue
341 lines
7.4 KiB
Vue
<template>
|
||
<div class="lesson-steps-editor">
|
||
<div class="steps-header">
|
||
<span class="title">教学环节 ({{ steps.length }}个)</span>
|
||
<a-button type="primary" size="small" @click="addStep">
|
||
<PlusOutlined /> 添加环节
|
||
</a-button>
|
||
</div>
|
||
|
||
<div v-if="steps.length === 0" class="empty-steps">
|
||
<a-empty description="暂无教学环节,请点击添加" :image="Empty.PRESENTED_IMAGE_SIMPLE" />
|
||
</div>
|
||
|
||
<div v-else class="steps-list">
|
||
<div
|
||
v-for="(step, index) in steps"
|
||
:key="step.id || step.tempId"
|
||
class="step-item"
|
||
>
|
||
<div class="step-header">
|
||
<span class="step-order">{{ index + 1 }}</span>
|
||
<div class="step-name-field">
|
||
<span class="field-label required">环节名称</span>
|
||
<a-input
|
||
v-model:value="step.name"
|
||
placeholder="如:导入、讲解、练习等"
|
||
style="flex: 1"
|
||
@change="handleChange"
|
||
/>
|
||
</div>
|
||
<span class="step-duration">
|
||
<a-input-number
|
||
v-model:value="step.duration"
|
||
:min="1"
|
||
:max="60"
|
||
size="small"
|
||
style="width: 70px"
|
||
@change="handleChange"
|
||
/>
|
||
分钟
|
||
</span>
|
||
<a-popconfirm
|
||
title="确定删除此环节吗?"
|
||
@confirm="removeStep(index)"
|
||
>
|
||
<a-button type="link" size="small" danger>
|
||
<DeleteOutlined />
|
||
</a-button>
|
||
</a-popconfirm>
|
||
</div>
|
||
|
||
<div class="step-content">
|
||
<span class="field-label required">环节内容</span>
|
||
<a-textarea
|
||
v-model:value="step.content"
|
||
placeholder="请输入教学环节的具体内容和操作步骤"
|
||
:rows="3"
|
||
@change="handleChange"
|
||
/>
|
||
</div>
|
||
|
||
<div class="step-objective">
|
||
<span class="field-label required">教学目标</span>
|
||
<a-input
|
||
v-model:value="step.objective"
|
||
placeholder="请输入该环节的教学目标"
|
||
@change="handleChange"
|
||
>
|
||
<template #prefix>
|
||
<AimOutlined style="color: #bfbfbf" />
|
||
</template>
|
||
</a-input>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 4环节模板按钮(可选) -->
|
||
<div v-if="showTemplate && steps.length === 0" class="template-section">
|
||
<a-divider>或使用模板</a-divider>
|
||
<a-button type="dashed" block @click="applyTemplate">
|
||
<AppstoreAddOutlined /> 应用4环节教学模板
|
||
</a-button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, watch, computed } from 'vue';
|
||
import { Empty } from 'ant-design-vue';
|
||
import {
|
||
PlusOutlined,
|
||
DeleteOutlined,
|
||
AimOutlined,
|
||
AppstoreAddOutlined,
|
||
} from '@ant-design/icons-vue';
|
||
|
||
export interface StepData {
|
||
id?: number | string;
|
||
tempId?: string;
|
||
name: string;
|
||
content: string;
|
||
duration: number;
|
||
objective: string;
|
||
isNew?: boolean;
|
||
}
|
||
|
||
interface Props {
|
||
modelValue: StepData[];
|
||
showTemplate?: boolean;
|
||
}
|
||
|
||
const props = withDefaults(defineProps<Props>(), {
|
||
modelValue: () => [],
|
||
showTemplate: false,
|
||
});
|
||
|
||
const emit = defineEmits<{
|
||
(e: 'update:modelValue', value: StepData[]): void;
|
||
(e: 'change'): void;
|
||
}>();
|
||
|
||
const steps = ref<StepData[]>([]);
|
||
|
||
// 监听外部值变化
|
||
watch(
|
||
() => props.modelValue,
|
||
(newVal) => {
|
||
steps.value = newVal ? [...newVal] : [];
|
||
},
|
||
{ immediate: true, deep: true }
|
||
);
|
||
|
||
// 计算总时长
|
||
const totalDuration = computed(() => {
|
||
return steps.value.reduce((sum, step) => sum + (step.duration || 0), 0);
|
||
});
|
||
|
||
// 生成临时ID
|
||
const generateTempId = () => `step_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||
|
||
// 添加环节
|
||
const addStep = () => {
|
||
const newStep: StepData = {
|
||
tempId: generateTempId(),
|
||
name: '',
|
||
content: '',
|
||
duration: 5,
|
||
objective: '',
|
||
isNew: true,
|
||
};
|
||
steps.value.push(newStep);
|
||
emitChange();
|
||
};
|
||
|
||
// 删除环节
|
||
const removeStep = (index: number) => {
|
||
steps.value.splice(index, 1);
|
||
emitChange();
|
||
};
|
||
|
||
// 应用4环节模板
|
||
const applyTemplate = () => {
|
||
const template: StepData[] = [
|
||
{
|
||
tempId: generateTempId(),
|
||
name: '导入环节',
|
||
content: '通过图片、视频或问题导入,激发幼儿兴趣',
|
||
duration: 3,
|
||
objective: '激发兴趣,引入主题',
|
||
isNew: true,
|
||
},
|
||
{
|
||
tempId: generateTempId(),
|
||
name: '展开环节',
|
||
content: '讲解绘本内容,引导幼儿观察、思考和表达',
|
||
duration: 10,
|
||
objective: '理解内容,发展语言能力',
|
||
isNew: true,
|
||
},
|
||
{
|
||
tempId: generateTempId(),
|
||
name: '深入环节',
|
||
content: '通过互动、讨论或游戏加深理解',
|
||
duration: 8,
|
||
objective: '深化理解,培养思维',
|
||
isNew: true,
|
||
},
|
||
{
|
||
tempId: generateTempId(),
|
||
name: '结束环节',
|
||
content: '总结回顾,延伸活动介绍',
|
||
duration: 4,
|
||
objective: '巩固学习,自然结束',
|
||
isNew: true,
|
||
},
|
||
];
|
||
steps.value = template;
|
||
emitChange();
|
||
};
|
||
|
||
// 处理变化
|
||
const handleChange = () => {
|
||
emitChange();
|
||
};
|
||
|
||
// 发送更新
|
||
const emitChange = () => {
|
||
emit('update:modelValue', [...steps.value]);
|
||
emit('change');
|
||
};
|
||
|
||
// 暴露方法
|
||
defineExpose({
|
||
totalDuration,
|
||
addStep,
|
||
removeStep,
|
||
});
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
.lesson-steps-editor {
|
||
.steps-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 16px;
|
||
|
||
.title {
|
||
font-weight: 500;
|
||
font-size: 14px;
|
||
}
|
||
}
|
||
|
||
.empty-steps {
|
||
padding: 30px 0;
|
||
text-align: center;
|
||
background: #fafafa;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.steps-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
|
||
.step-item {
|
||
background: #fff;
|
||
border: 1px solid #e8e8e8;
|
||
border-radius: 8px;
|
||
padding: 16px;
|
||
transition: all 0.3s;
|
||
|
||
&:hover {
|
||
border-color: #1890ff;
|
||
box-shadow: 0 2px 8px rgba(24, 144, 255, 0.1);
|
||
}
|
||
}
|
||
|
||
.field-label {
|
||
display: block;
|
||
font-size: 13px;
|
||
color: #666;
|
||
margin-bottom: 4px;
|
||
|
||
&.required::after {
|
||
content: ' *';
|
||
color: #ff4d4f;
|
||
}
|
||
}
|
||
|
||
.step-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
margin-bottom: 12px;
|
||
|
||
.step-name-field {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
gap: 8px;
|
||
min-width: 0;
|
||
|
||
.field-label {
|
||
flex-shrink: 0;
|
||
margin-bottom: 0;
|
||
white-space: nowrap;
|
||
}
|
||
}
|
||
|
||
.step-order {
|
||
width: 28px;
|
||
height: 28px;
|
||
border-radius: 50%;
|
||
background: linear-gradient(135deg, #1890ff, #40a9ff);
|
||
color: white;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 13px;
|
||
font-weight: bold;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.step-duration {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
color: #666;
|
||
font-size: 13px;
|
||
white-space: nowrap;
|
||
}
|
||
}
|
||
|
||
.step-content {
|
||
margin-bottom: 12px;
|
||
|
||
.field-label {
|
||
margin-bottom: 4px;
|
||
}
|
||
}
|
||
|
||
.step-objective {
|
||
.field-label {
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
:deep(.ant-input-affix-wrapper) {
|
||
background: #f5f5f5;
|
||
}
|
||
}
|
||
|
||
.template-section {
|
||
margin-top: 16px;
|
||
text-align: center;
|
||
}
|
||
}
|
||
</style>
|