IntelliClass_FE/components/fn/teach/LessonPlan.vue
Timothy Yin a6acd8fd54
All checks were successful
CI / lint (push) Successful in 50s
CI / test (push) Successful in 51s
feat(备课-教学设计): 教案设计功能
2025-05-23 20:43:39 +08:00

319 lines
8.5 KiB
Vue

<script lang="ts" setup>
import type { AcceptableValue } from 'reka-ui'
import { toast } from 'vue-sonner'
import {
generateLessonPlan,
getLessonPlanTemplate,
regenerateLessonPlanSection,
type LessonPlanTemplate,
type AIGeneratedContentResponse,
type GenerateLessonPlanParams,
} from '~/api/aifn'
import { uploadFile } from '~/api/file'
import type { AIGeneratedContent } from '~/components/ai'
import type { FetchError } from '~/types'
const tab = ref('text')
const loading = ref(false)
// const input = ref('')
const selectedModuleNames = ref([])
const selectedFiles = ref<File[]>([])
const onFileChange = (files: FileList) => {
selectedFiles.value = Array.from(files)
}
const form = reactive({
query: '',
urls: [] as string[],
moduleName: '',
})
const contentRequirements = ref<'custom' | 'template'>('custom')
const data = ref<AIGeneratedContent | null>(null)
const sections = [
'学情分析',
'教学目标',
'课程重点',
'课程难点',
'教学准备',
'教学过程',
'作业与评价',
'教学反思',
'思政案例',
'思政元素',
'教学价值与分析',
]
const { data: templates } = useAsyncData(() => getLessonPlanTemplate())
const selectedTemplate = ref<LessonPlanTemplate | null>(null)
const onGenerateClick = async () => {
if (form.query) {
let payload: GenerateLessonPlanParams = {
query: form.query,
}
loading.value = true
if (tab.value === 'text') {
if (selectedFiles.value.length > 0) {
for (const file of selectedFiles.value) {
form.urls.push(await uploadFile(file, 'temp'))
}
console.log('uploaded urls', form.urls)
payload = {
...payload,
urls: form.urls,
}
}
if (contentRequirements.value === 'custom') {
if (!form.moduleName) {
toast.error('请选择要生成的内容模块')
return
}
payload = {
...payload,
moduleName: form.moduleName,
}
} else if (contentRequirements.value === 'template') {
if (!selectedTemplate.value) {
toast.error('请选择模版')
return
}
payload = {
...payload,
useTemplate: true,
templateId: `${selectedTemplate.value.id}`,
}
}
}
toast.promise(generateLessonPlan(payload), {
loading: '生成中...',
success: (res: AIGeneratedContentResponse) => {
if (res.code !== 200) {
data.value = null
toast.error(res.msg || '生成失败')
return
}
data.value = res.data
return '生成成功'
},
error: (err: FetchError) => {
data.value = null
return err.message
},
finally: () => {
loading.value = false
},
})
} else {
data.value = null
}
}
const onSectionRegenerateClick = (
itemIndex: number,
conversationId: string,
) => {
if (data.value) {
const item = data.value.sections[itemIndex]
if (item) {
loading.value = true
toast.promise(
regenerateLessonPlanSection({
query: `重新生成${item.title}`,
conversationId,
}),
{
loading: `重新生成${item.title}中...`,
success: (res: AIGeneratedContentResponse) => {
if (res.code !== 200) {
toast.error(res.msg || '生成失败')
return
}
data.value?.sections.splice(itemIndex, 1, res.data.sections[0])
return `重新生成${item.title}成功`
},
error: (err: FetchError) => {
return err.message
},
finally: () => {
loading.value = false
},
},
)
}
}
}
</script>
<template>
<div class="flex flex-col gap-4">
<Tabs
v-model="tab"
class="w-[400px]"
>
<TabsList>
<TabsTrigger value="text">
<div class="flex items-center gap-1">
<Icon name="tabler:article" />
<span>文本生成</span>
</div>
</TabsTrigger>
<TabsTrigger value="bot">
<div class="flex items-center gap-1">
<Icon name="tabler:robot" />
<span>课程智能体</span>
</div>
</TabsTrigger>
</TabsList>
</Tabs>
<div class="flex items-start gap-4">
<div
v-if="tab === 'bot'"
class="flex h-full flex-col justify-center items-center gap-1 px-8 rounded-md border"
>
<Icon
name="tabler:text-plus"
class="text-3xl"
/>
<span class="text-xs font-medium">选择智能体</span>
</div>
<UniFileSelector
v-if="tab === 'text'"
class="h-full"
placeholder="上传资料"
multiple
@change="onFileChange"
/>
<Textarea
v-model="form.query"
placeholder="请输入文本来生成内容"
class="h-24 flex-1"
/>
<div class="flex flex-col items-center gap-2">
<Button
size="lg"
:disabled="loading"
@click="onGenerateClick"
>
<Icon
v-if="loading"
name="svg-spinners:180-ring-with-bg"
/>
<Icon
v-else
name="mage:stars-c-fill"
/>
生成教案
</Button>
<p class="text-xs text-foreground/40">内容由 AI 生成,仅供参考</p>
</div>
</div>
<div class="flex gap-4 items-start pb-2">
<!-- <p class="text-sm text-muted-foreground font-medium">内容要求</p> -->
<div class="flex flex-col gap-4 pt-1">
<RadioGroup
v-model="contentRequirements"
default-value="custom"
class="flex flex-row gap-4"
>
<div class="flex items-center space-x-2">
<RadioGroupItem
id="custom"
value="custom"
/>
<Label for="custom">自定义教案内容</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroupItem
id="template"
value="template"
/>
<Label for="template">模版库选择</Label>
</div>
</RadioGroup>
<ToggleGroup
v-if="contentRequirements === 'custom'"
v-model="selectedModuleNames"
type="multiple"
variant="outline"
size="sm"
class="flex flex-wrap justify-start items-center gap-2 *:!px-2 *:!h-7"
@update:model-value="
(value) => {
form.moduleName = Array.from(value as AcceptableValue[]).join(',')
}
"
>
<ToggleGroupItem
v-for="section in sections"
:key="section"
class="data-[state=on]:bg-primary data-[state=on]:text-primary-foreground"
:value="section"
>
{{ section }}
</ToggleGroupItem>
</ToggleGroup>
<div
v-else
class="flex gap-4 overflow-hidden overflow-x-auto snap-x snap-mandatory"
>
<!-- {{ templates?.data }} -->
<Card
v-for="template in templates?.data || []"
:key="template.id"
class="flex-shrink-0 p-0 overflow-hidden relative snap-start snap-always snap-x"
:class="{
'border-primary': selectedTemplate?.id === template.id,
}"
@click="
() => {
selectedTemplate = template
}
"
>
<CardHeader class="px-4 pt-3 pb-1">
<h1 class="text-sm font-medium flex items-center gap-1">
<Icon
name="fluent-color:document-text-24"
class="text-lg text-muted-foreground !stroke-[5px]"
/>
{{ template.name }}
</h1>
</CardHeader>
<CardContent class="p-0">
<div
v-if="selectedTemplate?.id === template.id"
class="absolute inset-0"
>
<Icon
name="tabler:check"
class="absolute top-2 right-2 text-primary"
/>
</div>
<NuxtImg
:src="template.imgUrl"
class="h-72"
/>
</CardContent>
</Card>
</div>
</div>
</div>
<AiGeneratedContent
:data
@regenerate="onSectionRegenerateClick"
@regenerate-all="onGenerateClick"
/>
</div>
</template>
<style scoped></style>