2026-03-12 17:27:13 +08:00
|
|
|
|
<template>
|
|
|
|
|
|
<div class="step1-basic-info">
|
|
|
|
|
|
<a-form
|
2026-03-18 16:06:35 +08:00
|
|
|
|
ref="formRef"
|
2026-03-12 17:27:13 +08:00
|
|
|
|
:model="formData"
|
2026-03-18 16:06:35 +08:00
|
|
|
|
:rules="formRules"
|
2026-03-12 17:27:13 +08:00
|
|
|
|
:label-col="{ span: 4 }"
|
|
|
|
|
|
:wrapper-col="{ span: 16 }"
|
|
|
|
|
|
>
|
2026-03-18 16:06:35 +08:00
|
|
|
|
<a-form-item label="课程包名称" name="name" required>
|
2026-03-12 17:27:13 +08:00
|
|
|
|
<a-input
|
|
|
|
|
|
v-model:value="formData.name"
|
|
|
|
|
|
placeholder="请输入课程包名称"
|
|
|
|
|
|
show-count
|
|
|
|
|
|
:maxlength="50"
|
|
|
|
|
|
@change="handleChange"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</a-form-item>
|
|
|
|
|
|
|
2026-03-18 16:06:35 +08:00
|
|
|
|
<a-form-item label="关联主题" name="themeId" required>
|
2026-03-12 17:27:13 +08:00
|
|
|
|
<a-select
|
|
|
|
|
|
v-model:value="formData.themeId"
|
|
|
|
|
|
placeholder="请选择主题"
|
|
|
|
|
|
style="width: 100%"
|
|
|
|
|
|
:loading="themesLoading"
|
|
|
|
|
|
@change="handleChange"
|
|
|
|
|
|
>
|
|
|
|
|
|
<a-select-option v-for="theme in themes" :key="theme.id" :value="theme.id">
|
|
|
|
|
|
{{ theme.name }}
|
|
|
|
|
|
</a-select-option>
|
|
|
|
|
|
</a-select>
|
|
|
|
|
|
</a-form-item>
|
|
|
|
|
|
|
2026-03-18 16:06:35 +08:00
|
|
|
|
<a-form-item label="适用年级" name="grades" required>
|
2026-03-12 17:27:13 +08:00
|
|
|
|
<a-checkbox-group v-model:value="formData.grades" @change="handleChange">
|
|
|
|
|
|
<a-checkbox value="小班">小班</a-checkbox>
|
|
|
|
|
|
<a-checkbox value="中班">中班</a-checkbox>
|
|
|
|
|
|
<a-checkbox value="大班">大班</a-checkbox>
|
|
|
|
|
|
</a-checkbox-group>
|
|
|
|
|
|
</a-form-item>
|
|
|
|
|
|
|
2026-03-18 16:06:35 +08:00
|
|
|
|
<a-form-item label="关联绘本" name="pictureBookName">
|
2026-03-12 17:27:13 +08:00
|
|
|
|
<a-input
|
|
|
|
|
|
v-model:value="formData.pictureBookName"
|
|
|
|
|
|
placeholder="请输入关联绘本名称(可选)"
|
2026-03-18 16:06:35 +08:00
|
|
|
|
:maxlength="100"
|
2026-03-12 17:27:13 +08:00
|
|
|
|
@change="handleChange"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</a-form-item>
|
|
|
|
|
|
|
2026-03-18 16:06:35 +08:00
|
|
|
|
<a-form-item label="核心内容" name="coreContent" required>
|
2026-03-12 17:27:13 +08:00
|
|
|
|
<a-textarea
|
|
|
|
|
|
v-model:value="formData.coreContent"
|
|
|
|
|
|
:rows="3"
|
|
|
|
|
|
placeholder="请输入课程包核心内容概述(200字以内)"
|
|
|
|
|
|
show-count
|
|
|
|
|
|
:maxlength="200"
|
|
|
|
|
|
@change="handleChange"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</a-form-item>
|
|
|
|
|
|
|
2026-03-18 16:06:35 +08:00
|
|
|
|
<a-form-item label="课程时长" name="duration">
|
2026-03-12 17:27:13 +08:00
|
|
|
|
<a-input-number
|
|
|
|
|
|
v-model:value="formData.duration"
|
|
|
|
|
|
:min="5"
|
|
|
|
|
|
:max="120"
|
|
|
|
|
|
style="width: 150px"
|
|
|
|
|
|
@change="handleChange"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<span class="duration-unit">分钟</span>
|
|
|
|
|
|
</a-form-item>
|
|
|
|
|
|
|
2026-03-18 16:06:35 +08:00
|
|
|
|
<a-form-item label="核心发展目标" name="domainTags">
|
2026-03-12 17:27:13 +08:00
|
|
|
|
<a-select
|
|
|
|
|
|
v-model:value="formData.domainTags"
|
|
|
|
|
|
mode="multiple"
|
2026-03-24 16:58:27 +08:00
|
|
|
|
show-search
|
2026-03-12 17:27:13 +08:00
|
|
|
|
placeholder="请选择核心发展目标(可多选)"
|
|
|
|
|
|
style="width: 100%"
|
2026-03-24 16:58:27 +08:00
|
|
|
|
:filter-option="filterDomainTagOption"
|
2026-03-12 17:27:13 +08:00
|
|
|
|
@change="handleChange"
|
|
|
|
|
|
>
|
|
|
|
|
|
<a-select-opt-group label="健康">
|
|
|
|
|
|
<a-select-option value="health_motor">身体动作发展</a-select-option>
|
|
|
|
|
|
<a-select-option value="health_hygiene">生活习惯与能力</a-select-option>
|
|
|
|
|
|
</a-select-opt-group>
|
|
|
|
|
|
<a-select-opt-group label="语言">
|
|
|
|
|
|
<a-select-option value="lang_listen">倾听与表达</a-select-option>
|
|
|
|
|
|
<a-select-option value="lang_read">早期阅读</a-select-option>
|
|
|
|
|
|
</a-select-opt-group>
|
|
|
|
|
|
<a-select-opt-group label="社会">
|
|
|
|
|
|
<a-select-option value="social_interact">人际交往</a-select-option>
|
|
|
|
|
|
<a-select-option value="social_adapt">社会适应</a-select-option>
|
|
|
|
|
|
</a-select-opt-group>
|
|
|
|
|
|
<a-select-opt-group label="科学">
|
|
|
|
|
|
<a-select-option value="science_explore">科学探究</a-select-option>
|
|
|
|
|
|
<a-select-option value="math_cog">数学认知</a-select-option>
|
|
|
|
|
|
</a-select-opt-group>
|
|
|
|
|
|
<a-select-opt-group label="艺术">
|
|
|
|
|
|
<a-select-option value="art_music">音乐表现</a-select-option>
|
|
|
|
|
|
<a-select-option value="art_create">美术创作</a-select-option>
|
|
|
|
|
|
</a-select-opt-group>
|
|
|
|
|
|
</a-select>
|
|
|
|
|
|
</a-form-item>
|
|
|
|
|
|
|
2026-03-24 14:04:39 +08:00
|
|
|
|
<a-form-item label="课程封面" required>
|
2026-03-12 17:27:13 +08:00
|
|
|
|
<a-upload
|
|
|
|
|
|
v-model:file-list="coverImages"
|
|
|
|
|
|
list-type="picture-card"
|
|
|
|
|
|
:max-count="1"
|
|
|
|
|
|
accept="image/*"
|
|
|
|
|
|
:before-upload="beforeCoverUpload"
|
|
|
|
|
|
@remove="handleCoverRemove"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div v-if="coverImages.length < 1">
|
|
|
|
|
|
<PlusOutlined />
|
|
|
|
|
|
<div style="margin-top: 8px">上传封面</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</a-upload>
|
|
|
|
|
|
<div class="upload-hint">建议尺寸 400x300,支持 JPG、PNG 格式,最大 5MB</div>
|
|
|
|
|
|
</a-form-item>
|
|
|
|
|
|
</a-form>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
|
import { ref, reactive, watch, onMounted } from 'vue';
|
|
|
|
|
|
import { message } from 'ant-design-vue';
|
|
|
|
|
|
import { PlusOutlined } from '@ant-design/icons-vue';
|
|
|
|
|
|
import { getThemeList } from '@/api/theme';
|
2026-03-23 15:15:46 +08:00
|
|
|
|
import { uploadFile, getFileUrl } from '@/api/file';
|
2026-03-12 17:27:13 +08:00
|
|
|
|
import type { Theme } from '@/api/theme';
|
|
|
|
|
|
|
|
|
|
|
|
interface BasicInfoData {
|
|
|
|
|
|
name: string;
|
|
|
|
|
|
themeId: number | undefined;
|
|
|
|
|
|
grades: string[];
|
|
|
|
|
|
pictureBookName: string;
|
|
|
|
|
|
coreContent: string;
|
|
|
|
|
|
duration: number;
|
|
|
|
|
|
domainTags: string[];
|
|
|
|
|
|
coverImagePath: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface Props {
|
|
|
|
|
|
modelValue: Partial<BasicInfoData>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const props = defineProps<Props>();
|
|
|
|
|
|
|
|
|
|
|
|
const emit = defineEmits<{
|
|
|
|
|
|
(e: 'update:modelValue', value: BasicInfoData): void;
|
|
|
|
|
|
(e: 'change'): void;
|
|
|
|
|
|
}>();
|
|
|
|
|
|
|
2026-03-18 16:06:35 +08:00
|
|
|
|
const formRef = ref();
|
2026-03-12 17:27:13 +08:00
|
|
|
|
const themesLoading = ref(false);
|
|
|
|
|
|
const themes = ref<Theme[]>([]);
|
|
|
|
|
|
const coverImages = ref<any[]>([]);
|
|
|
|
|
|
|
2026-03-24 16:58:27 +08:00
|
|
|
|
/** 核心发展目标:叶子项 + 父级领域名,用于搜索时同时匹配子项与分组 */
|
|
|
|
|
|
const DOMAIN_TAG_OPTIONS: { group: string; value: string; label: string }[] = [
|
|
|
|
|
|
{ group: '健康', value: 'health_motor', label: '身体动作发展' },
|
|
|
|
|
|
{ group: '健康', value: 'health_hygiene', label: '生活习惯与能力' },
|
|
|
|
|
|
{ group: '语言', value: 'lang_listen', label: '倾听与表达' },
|
|
|
|
|
|
{ group: '语言', value: 'lang_read', label: '早期阅读' },
|
|
|
|
|
|
{ group: '社会', value: 'social_interact', label: '人际交往' },
|
|
|
|
|
|
{ group: '社会', value: 'social_adapt', label: '社会适应' },
|
|
|
|
|
|
{ group: '科学', value: 'science_explore', label: '科学探究' },
|
|
|
|
|
|
{ group: '科学', value: 'math_cog', label: '数学认知' },
|
|
|
|
|
|
{ group: '艺术', value: 'art_music', label: '音乐表现' },
|
|
|
|
|
|
{ group: '艺术', value: 'art_create', label: '美术创作' },
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
const filterDomainTagOption = (input: string, option: any) => {
|
|
|
|
|
|
if (!input?.trim()) return true;
|
|
|
|
|
|
const q = input.trim();
|
|
|
|
|
|
const key = option?.value ?? option?.key;
|
|
|
|
|
|
const row = DOMAIN_TAG_OPTIONS.find((o) => o.value === key);
|
|
|
|
|
|
if (row) {
|
|
|
|
|
|
return row.label.includes(q) || row.group.includes(q);
|
|
|
|
|
|
}
|
|
|
|
|
|
const label =
|
|
|
|
|
|
typeof option?.label === 'string'
|
|
|
|
|
|
? option.label
|
|
|
|
|
|
: option?.children?.[0]?.children ?? option?.children;
|
|
|
|
|
|
if (typeof label === 'string') {
|
|
|
|
|
|
return label.includes(q);
|
|
|
|
|
|
}
|
|
|
|
|
|
return true;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-18 16:06:35 +08:00
|
|
|
|
const formRules = {
|
|
|
|
|
|
name: [
|
|
|
|
|
|
{ required: true, message: '请输入课程包名称' },
|
|
|
|
|
|
{ max: 50, message: '课程包名称不能超过50个字' },
|
|
|
|
|
|
{
|
|
|
|
|
|
validator: (_: unknown, value: string) => {
|
|
|
|
|
|
if (value && value.trim().length === 0) {
|
|
|
|
|
|
return Promise.reject(new Error('请输入课程包名称'));
|
|
|
|
|
|
}
|
|
|
|
|
|
return Promise.resolve();
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
themeId: [{ required: true, message: '请选择关联主题' }],
|
|
|
|
|
|
grades: [
|
|
|
|
|
|
{
|
|
|
|
|
|
required: true,
|
|
|
|
|
|
message: '请至少选择一个适用年级',
|
|
|
|
|
|
type: 'array',
|
|
|
|
|
|
min: 1,
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
coreContent: [
|
|
|
|
|
|
{ required: true, message: '请输入核心内容' },
|
|
|
|
|
|
{ max: 200, message: '核心内容不能超过200个字' },
|
|
|
|
|
|
],
|
|
|
|
|
|
duration: [
|
|
|
|
|
|
{ type: 'number' as const, min: 5, max: 120, message: '课程时长需为 5-120 的整数' },
|
|
|
|
|
|
],
|
|
|
|
|
|
pictureBookName: [{ max: 100, message: '关联绘本名称不能超过100个字' }],
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-12 17:27:13 +08:00
|
|
|
|
const formData = reactive<BasicInfoData>({
|
|
|
|
|
|
name: '',
|
|
|
|
|
|
themeId: undefined,
|
|
|
|
|
|
grades: [],
|
|
|
|
|
|
pictureBookName: '',
|
|
|
|
|
|
coreContent: '',
|
|
|
|
|
|
duration: 25,
|
|
|
|
|
|
domainTags: [],
|
|
|
|
|
|
coverImagePath: '',
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 监听外部值变化
|
|
|
|
|
|
watch(
|
|
|
|
|
|
() => props.modelValue,
|
|
|
|
|
|
(newVal) => {
|
|
|
|
|
|
if (newVal) {
|
|
|
|
|
|
Object.assign(formData, newVal);
|
|
|
|
|
|
|
2026-03-23 15:15:46 +08:00
|
|
|
|
// 处理封面图片回显
|
|
|
|
|
|
if (newVal.coverImagePath) {
|
2026-03-12 17:27:13 +08:00
|
|
|
|
coverImages.value = [{
|
|
|
|
|
|
uid: '-1',
|
|
|
|
|
|
name: 'cover',
|
|
|
|
|
|
status: 'done',
|
2026-03-23 15:15:46 +08:00
|
|
|
|
url: getFileUrl(newVal.coverImagePath),
|
2026-03-12 17:27:13 +08:00
|
|
|
|
}];
|
2026-03-23 15:15:46 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
coverImages.value = [];
|
2026-03-12 17:27:13 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
{ immediate: true, deep: true }
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// 获取主题列表
|
|
|
|
|
|
const fetchThemes = async () => {
|
|
|
|
|
|
themesLoading.value = true;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await getThemeList() as any;
|
|
|
|
|
|
themes.value = res || [];
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('获取主题列表失败', error);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
themesLoading.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 封面上传
|
|
|
|
|
|
const beforeCoverUpload = async (file: any) => {
|
|
|
|
|
|
const isImage = file.type.startsWith('image/');
|
|
|
|
|
|
if (!isImage) {
|
|
|
|
|
|
message.error('只能上传图片文件');
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const isLt5M = file.size / 1024 / 1024 < 5;
|
|
|
|
|
|
if (!isLt5M) {
|
|
|
|
|
|
message.error('图片大小不能超过 5MB');
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const result = await uploadFile(file, 'cover');
|
|
|
|
|
|
formData.coverImagePath = result.filePath;
|
|
|
|
|
|
coverImages.value = [{
|
|
|
|
|
|
uid: file.uid,
|
|
|
|
|
|
name: file.name,
|
|
|
|
|
|
status: 'done',
|
2026-03-23 15:15:46 +08:00
|
|
|
|
url: getFileUrl(result.filePath),
|
2026-03-12 17:27:13 +08:00
|
|
|
|
}];
|
|
|
|
|
|
handleChange();
|
|
|
|
|
|
message.success('封面上传成功');
|
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
|
message.error('上传失败: ' + (error.response?.data?.message || error.message));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return false;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleCoverRemove = () => {
|
|
|
|
|
|
coverImages.value = [];
|
|
|
|
|
|
formData.coverImagePath = '';
|
|
|
|
|
|
handleChange();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 处理变化
|
|
|
|
|
|
const handleChange = () => {
|
|
|
|
|
|
emit('update:modelValue', { ...formData });
|
|
|
|
|
|
emit('change');
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 验证
|
2026-03-18 16:06:35 +08:00
|
|
|
|
const validate = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await formRef.value?.validate();
|
2026-03-24 14:04:39 +08:00
|
|
|
|
|
|
|
|
|
|
// 校验课程封面
|
|
|
|
|
|
if (!formData.coverImagePath) {
|
|
|
|
|
|
return { valid: false, errors: ['请上传课程封面'] };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-18 16:06:35 +08:00
|
|
|
|
return { valid: true, errors: [] as string[] };
|
|
|
|
|
|
} catch (err: any) {
|
|
|
|
|
|
const errorFields = err?.errorFields || [];
|
|
|
|
|
|
const errors = errorFields
|
|
|
|
|
|
.map((f: any) => f.errors?.[0])
|
|
|
|
|
|
.filter(Boolean) as string[];
|
|
|
|
|
|
return { valid: false, errors: errors.length ? errors : ['请完成基本信息'] };
|
2026-03-12 17:27:13 +08:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
|
fetchThemes();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
defineExpose({
|
|
|
|
|
|
validate,
|
|
|
|
|
|
formData,
|
|
|
|
|
|
});
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped lang="scss">
|
|
|
|
|
|
.step1-basic-info {
|
|
|
|
|
|
.duration-unit {
|
|
|
|
|
|
margin-left: 8px;
|
|
|
|
|
|
color: #666;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.upload-hint {
|
|
|
|
|
|
margin-top: 8px;
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
color: #999;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
</style>
|