feat(备课-教学设计): 教案设计功能
This commit is contained in:
parent
8b16bf4b0e
commit
a6acd8fd54
45
api/aifn.ts
45
api/aifn.ts
@ -7,15 +7,56 @@ export type AIGeneratedContentResponse = IResponse<{
|
|||||||
}
|
}
|
||||||
}>
|
}>
|
||||||
|
|
||||||
export const generateLessonPlan = async (params: {
|
export interface GenerateLessonPlanParams {
|
||||||
query: string
|
query: string
|
||||||
}) => {
|
moduleName?: string
|
||||||
|
urls?: string[]
|
||||||
|
useTemplate?: boolean
|
||||||
|
templateId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegenerateLessonPlanSectionParams {
|
||||||
|
query: string
|
||||||
|
conversationId: string
|
||||||
|
urls?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LessonPlanTemplate {
|
||||||
|
createBy: number
|
||||||
|
createTime: string
|
||||||
|
docxUrl: string
|
||||||
|
id: number
|
||||||
|
imgUrl: string
|
||||||
|
isActive: boolean
|
||||||
|
moduleName: string
|
||||||
|
name: string
|
||||||
|
remark: string
|
||||||
|
updateBy: number
|
||||||
|
updateTime: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const generateLessonPlan = async (params: GenerateLessonPlanParams) => {
|
||||||
return await http<AIGeneratedContentResponse>(`/ai/lesson-plan-design/text-block`, {
|
return await http<AIGeneratedContentResponse>(`/ai/lesson-plan-design/text-block`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: params,
|
body: params,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const regenerateLessonPlanSection = async (params: RegenerateLessonPlanSectionParams) => {
|
||||||
|
return await http<AIGeneratedContentResponse>(`/ai/lesson-plan-design/update-block`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: params,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getLessonPlanTemplate = async () => {
|
||||||
|
return await http<IResponse<{
|
||||||
|
data: LessonPlanTemplate[]
|
||||||
|
}>>(`/ai/lesson-plan-template/list`, {
|
||||||
|
method: 'GET',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export const generateCase = async (params: {
|
export const generateCase = async (params: {
|
||||||
query: string
|
query: string
|
||||||
}) => {
|
}) => {
|
||||||
|
@ -162,7 +162,9 @@ const onDeleteConversation = (conversationId: string) => {
|
|||||||
<div
|
<div
|
||||||
class="gradient-border"
|
class="gradient-border"
|
||||||
:class="
|
:class="
|
||||||
message.role === 'assistant' && !currentConversation?.finished_at && message.content
|
message.role === 'assistant'
|
||||||
|
&& !currentConversation?.finished_at
|
||||||
|
&& message.content
|
||||||
? ''
|
? ''
|
||||||
: 'inactive'
|
: 'inactive'
|
||||||
"
|
"
|
||||||
@ -188,6 +190,25 @@ const onDeleteConversation = (conversationId: string) => {
|
|||||||
思考中
|
思考中
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="message.role === 'assistant' && currentConversation?.finished_at && message.content"
|
||||||
|
class="pt-4 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<!-- download button -->
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<Icon name="tabler:plus" />
|
||||||
|
加入教案库
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
>
|
||||||
|
<Icon name="tabler:download" />
|
||||||
|
下载到本地
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -6,7 +6,8 @@ defineProps<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
defineEmits<{
|
defineEmits<{
|
||||||
(e: 'regenerate' | 'delete', itemIndex: number): void
|
(e: 'delete', itemIndex: number): void
|
||||||
|
(e: 'regenerate', itemIndex: number, conversationId: string): void
|
||||||
(e: 'regenerateAll' | 'download' | 'save'): void
|
(e: 'regenerateAll' | 'download' | 'save'): void
|
||||||
}>()
|
}>()
|
||||||
</script>
|
</script>
|
||||||
@ -66,15 +67,15 @@ defineEmits<{
|
|||||||
{{ item.title }}
|
{{ item.title }}
|
||||||
</span>
|
</span>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<!-- <Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
size="xs"
|
size="xs"
|
||||||
class="text-muted-foreground"
|
class="text-muted-foreground"
|
||||||
@click="$emit('regenerate', i)"
|
@click="$emit('regenerate', i, data.conversationId)"
|
||||||
>
|
>
|
||||||
<Icon name="tabler:reload" />
|
<Icon name="tabler:reload" />
|
||||||
重新生成
|
重新生成
|
||||||
</Button> -->
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
size="xs"
|
size="xs"
|
||||||
|
@ -1,9 +1,15 @@
|
|||||||
export interface AIGeneratedContent {
|
export interface AIGeneratedContent {
|
||||||
|
conversationId: string
|
||||||
|
createdAt: number
|
||||||
|
id: string
|
||||||
|
messageId: string
|
||||||
|
raw: string
|
||||||
|
sections: AIGeneratedContentSection[]
|
||||||
|
taskId: string
|
||||||
title: string
|
title: string
|
||||||
sections: AIGeneratedContentItem[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AIGeneratedContentItem {
|
export interface AIGeneratedContentSection {
|
||||||
title: string
|
title: string
|
||||||
content: string
|
content: string
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ChevronRight } from 'lucide-vue-next'
|
import { ChevronRight } from 'lucide-vue-next'
|
||||||
import type { SidebarNavItem } from './Sidebar.vue'
|
import type { SidebarNavItem } from '../index.vue'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
nav: {
|
nav: {
|
||||||
@ -23,7 +23,7 @@ defineProps<{
|
|||||||
v-for="item in group.items"
|
v-for="item in group.items"
|
||||||
:key="item.title"
|
:key="item.title"
|
||||||
as-child
|
as-child
|
||||||
:default-open="item.isActive"
|
default-open
|
||||||
class="group/collapsible"
|
class="group/collapsible"
|
||||||
>
|
>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
@ -74,20 +74,24 @@ defineProps<{
|
|||||||
<!-- 无跳转链接 -->
|
<!-- 无跳转链接 -->
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
v-else
|
v-else
|
||||||
|
as="a"
|
||||||
|
class="py-5"
|
||||||
:tooltip="item.title"
|
:tooltip="item.title"
|
||||||
>
|
>
|
||||||
<!-- 图标名 -->
|
<!-- 图标名 -->
|
||||||
<Icon
|
<Icon
|
||||||
v-if="item.icon && typeof item.icon === 'string'"
|
v-if="item.icon && typeof item.icon === 'string'"
|
||||||
:name="item.icon"
|
:name="item.icon"
|
||||||
size="16px"
|
class="!size-6"
|
||||||
/>
|
/>
|
||||||
<!-- 图标组件 -->
|
<!-- 图标组件 -->
|
||||||
<component
|
<component
|
||||||
:is="item.icon"
|
:is="item.icon"
|
||||||
v-else
|
v-else
|
||||||
|
class="!size-6"
|
||||||
/>
|
/>
|
||||||
<span>{{ item.title }}</span>
|
<span>{{ item.title }}</span>
|
||||||
|
<!-- 有子项目 -->
|
||||||
<ChevronRight
|
<ChevronRight
|
||||||
v-if="item.items"
|
v-if="item.items"
|
||||||
class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
|
class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
|
||||||
@ -108,6 +112,7 @@ defineProps<{
|
|||||||
>
|
>
|
||||||
<SidebarMenuSubButton
|
<SidebarMenuSubButton
|
||||||
as="a"
|
as="a"
|
||||||
|
class="py-4"
|
||||||
as-child
|
as-child
|
||||||
:is-active="isActive"
|
:is-active="isActive"
|
||||||
:href
|
:href
|
||||||
|
@ -6,12 +6,15 @@ defineProps<{
|
|||||||
deleteMode?: boolean
|
deleteMode?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'delete-course': [courseId: number]
|
'delete-course': [courseId: number]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const openCourse = (id: number) => {
|
const openCourse = (id: number) => {
|
||||||
window.open(`/course/${id}`, '_blank', 'noopener,noreferrer')
|
// window.open(`/course/${id}`, '_blank', 'noopener,noreferrer')
|
||||||
|
router.push(`/course/${id}`)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
@ -52,16 +52,13 @@ const onGenerateClick = () => {
|
|||||||
<span>文本生成</span>
|
<span>文本生成</span>
|
||||||
</div>
|
</div>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="chapter">
|
<!-- <TabsTrigger value="chapter">
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<Icon name="tabler:text-plus" />
|
<Icon name="tabler:text-plus" />
|
||||||
<span>章节生成</span>
|
<span>章节生成</span>
|
||||||
</div>
|
</div>
|
||||||
</TabsTrigger>
|
</TabsTrigger> -->
|
||||||
<TabsTrigger
|
<TabsTrigger value="bot">
|
||||||
value="bot"
|
|
||||||
disabled
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<Icon name="tabler:robot" />
|
<Icon name="tabler:robot" />
|
||||||
<span>课程智能体</span>
|
<span>课程智能体</span>
|
||||||
@ -71,19 +68,19 @@ const onGenerateClick = () => {
|
|||||||
</Tabs>
|
</Tabs>
|
||||||
<div class="flex items-start gap-4">
|
<div class="flex items-start gap-4">
|
||||||
<div
|
<div
|
||||||
v-if="tab === 'chapter'"
|
v-if="tab === 'bot'"
|
||||||
class="flex h-20 flex-col justify-center items-center gap-1 px-8 rounded-md border"
|
class="flex h-full flex-col justify-center items-center gap-1 px-8 rounded-md border"
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
name="tabler:text-plus"
|
name="tabler:text-plus"
|
||||||
class="text-3xl"
|
class="text-3xl"
|
||||||
/>
|
/>
|
||||||
<span class="text-xs font-medium">选择章节</span>
|
<span class="text-xs font-medium">选择智能体</span>
|
||||||
</div>
|
</div>
|
||||||
<Textarea
|
<Textarea
|
||||||
v-model="input"
|
v-model="input"
|
||||||
placeholder="请输入文本来生成内容"
|
placeholder="请输入文本来生成内容"
|
||||||
class="h-20 flex-1"
|
class="h-24 flex-1"
|
||||||
/>
|
/>
|
||||||
<div class="flex flex-col items-center gap-2">
|
<div class="flex flex-col items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
|
@ -1,42 +1,155 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import type { AcceptableValue } from 'reka-ui'
|
||||||
import { toast } from 'vue-sonner'
|
import { toast } from 'vue-sonner'
|
||||||
import { generateLessonPlan, type AIGeneratedContentResponse } from '~/api/aifn'
|
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 { AIGeneratedContent } from '~/components/ai'
|
||||||
import type { FetchError } from '~/types'
|
import type { FetchError } from '~/types'
|
||||||
|
|
||||||
const tab = ref('text')
|
const tab = ref('text')
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
const input = ref('')
|
// 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 data = ref<AIGeneratedContent | null>(null)
|
||||||
|
|
||||||
const onGenerateClick = () => {
|
const sections = [
|
||||||
if (input.value) {
|
'学情分析',
|
||||||
|
'教学目标',
|
||||||
|
'课程重点',
|
||||||
|
'课程难点',
|
||||||
|
'教学准备',
|
||||||
|
'教学过程',
|
||||||
|
'作业与评价',
|
||||||
|
'教学反思',
|
||||||
|
'思政案例',
|
||||||
|
'思政元素',
|
||||||
|
'教学价值与分析',
|
||||||
|
]
|
||||||
|
|
||||||
|
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
|
loading.value = true
|
||||||
toast.promise(
|
|
||||||
generateLessonPlan({
|
if (tab.value === 'text') {
|
||||||
query: input.value,
|
if (selectedFiles.value.length > 0) {
|
||||||
}),
|
for (const file of selectedFiles.value) {
|
||||||
{
|
form.urls.push(await uploadFile(file, 'temp'))
|
||||||
loading: '生成中...',
|
}
|
||||||
success: (res: AIGeneratedContentResponse) => {
|
console.log('uploaded urls', form.urls)
|
||||||
data.value = res.data
|
|
||||||
return '生成成功'
|
payload = {
|
||||||
},
|
...payload,
|
||||||
error: (err: FetchError) => {
|
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
|
data.value = null
|
||||||
return err.message
|
toast.error(res.msg || '生成失败')
|
||||||
},
|
return
|
||||||
finally: () => {
|
}
|
||||||
loading.value = false
|
data.value = res.data
|
||||||
},
|
return '生成成功'
|
||||||
},
|
},
|
||||||
)
|
error: (err: FetchError) => {
|
||||||
|
data.value = null
|
||||||
|
return err.message
|
||||||
|
},
|
||||||
|
finally: () => {
|
||||||
|
loading.value = false
|
||||||
|
},
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
data.value = null
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -52,16 +165,7 @@ const onGenerateClick = () => {
|
|||||||
<span>文本生成</span>
|
<span>文本生成</span>
|
||||||
</div>
|
</div>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="chapter">
|
<TabsTrigger value="bot">
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<Icon name="tabler:text-plus" />
|
|
||||||
<span>章节生成</span>
|
|
||||||
</div>
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger
|
|
||||||
value="bot"
|
|
||||||
disabled
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<Icon name="tabler:robot" />
|
<Icon name="tabler:robot" />
|
||||||
<span>课程智能体</span>
|
<span>课程智能体</span>
|
||||||
@ -69,21 +173,29 @@ const onGenerateClick = () => {
|
|||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<div class="flex items-start gap-4">
|
<div class="flex items-start gap-4">
|
||||||
<div
|
<div
|
||||||
v-if="tab === 'chapter'"
|
v-if="tab === 'bot'"
|
||||||
class="flex h-20 flex-col justify-center items-center gap-1 px-8 rounded-md border"
|
class="flex h-full flex-col justify-center items-center gap-1 px-8 rounded-md border"
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
name="tabler:text-plus"
|
name="tabler:text-plus"
|
||||||
class="text-3xl"
|
class="text-3xl"
|
||||||
/>
|
/>
|
||||||
<span class="text-xs font-medium">选择章节</span>
|
<span class="text-xs font-medium">选择智能体</span>
|
||||||
</div>
|
</div>
|
||||||
|
<UniFileSelector
|
||||||
|
v-if="tab === 'text'"
|
||||||
|
class="h-full"
|
||||||
|
placeholder="上传资料"
|
||||||
|
multiple
|
||||||
|
@change="onFileChange"
|
||||||
|
/>
|
||||||
<Textarea
|
<Textarea
|
||||||
v-model="input"
|
v-model="form.query"
|
||||||
placeholder="请输入文本来生成内容"
|
placeholder="请输入文本来生成内容"
|
||||||
class="h-20 flex-1"
|
class="h-24 flex-1"
|
||||||
/>
|
/>
|
||||||
<div class="flex flex-col items-center gap-2">
|
<div class="flex flex-col items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
@ -104,7 +216,102 @@ const onGenerateClick = () => {
|
|||||||
<p class="text-xs text-foreground/40">内容由 AI 生成,仅供参考</p>
|
<p class="text-xs text-foreground/40">内容由 AI 生成,仅供参考</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<AiGeneratedContent :data />
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
32
components/ui/toggle-group/ToggleGroup.vue
Normal file
32
components/ui/toggle-group/ToggleGroup.vue
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { VariantProps } from 'class-variance-authority'
|
||||||
|
import type { toggleVariants } from '@/components/ui/toggle'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { ToggleGroupRoot, type ToggleGroupRootEmits, type ToggleGroupRootProps, useForwardPropsEmits } from 'reka-ui'
|
||||||
|
import { type HTMLAttributes, provide } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
type ToggleGroupVariants = VariantProps<typeof toggleVariants>
|
||||||
|
|
||||||
|
const props = defineProps<ToggleGroupRootProps & {
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
variant?: ToggleGroupVariants['variant']
|
||||||
|
size?: ToggleGroupVariants['size']
|
||||||
|
}>()
|
||||||
|
const emits = defineEmits<ToggleGroupRootEmits>()
|
||||||
|
|
||||||
|
provide('toggleGroup', {
|
||||||
|
variant: props.variant,
|
||||||
|
size: props.size,
|
||||||
|
})
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ToggleGroupRoot v-slot="slotProps" v-bind="forwarded" :class="cn('flex items-center justify-center gap-1', props.class)">
|
||||||
|
<slot v-bind="slotProps" />
|
||||||
|
</ToggleGroupRoot>
|
||||||
|
</template>
|
34
components/ui/toggle-group/ToggleGroupItem.vue
Normal file
34
components/ui/toggle-group/ToggleGroupItem.vue
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { VariantProps } from 'class-variance-authority'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { ToggleGroupItem, type ToggleGroupItemProps, useForwardProps } from 'reka-ui'
|
||||||
|
import { type HTMLAttributes, inject } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { toggleVariants } from '@/components/ui/toggle'
|
||||||
|
|
||||||
|
type ToggleGroupVariants = VariantProps<typeof toggleVariants>
|
||||||
|
|
||||||
|
const props = defineProps<ToggleGroupItemProps & {
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
variant?: ToggleGroupVariants['variant']
|
||||||
|
size?: ToggleGroupVariants['size']
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const context = inject<ToggleGroupVariants>('toggleGroup')
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class', 'size', 'variant')
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ToggleGroupItem
|
||||||
|
v-slot="slotProps"
|
||||||
|
v-bind="forwardedProps" :class="cn(toggleVariants({
|
||||||
|
variant: context?.variant || variant,
|
||||||
|
size: context?.size || size,
|
||||||
|
}), props.class)"
|
||||||
|
>
|
||||||
|
<slot v-bind="slotProps" />
|
||||||
|
</ToggleGroupItem>
|
||||||
|
</template>
|
2
components/ui/toggle-group/index.ts
Normal file
2
components/ui/toggle-group/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { default as ToggleGroup } from './ToggleGroup.vue'
|
||||||
|
export { default as ToggleGroupItem } from './ToggleGroupItem.vue'
|
33
components/ui/toggle/Toggle.vue
Normal file
33
components/ui/toggle/Toggle.vue
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { Toggle, type ToggleEmits, type ToggleProps, useForwardPropsEmits } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { type ToggleVariants, toggleVariants } from '.'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<ToggleProps & {
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
variant?: ToggleVariants['variant']
|
||||||
|
size?: ToggleVariants['size']
|
||||||
|
}>(), {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default',
|
||||||
|
disabled: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emits = defineEmits<ToggleEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class', 'size', 'variant')
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Toggle
|
||||||
|
v-slot="slotProps"
|
||||||
|
v-bind="forwarded"
|
||||||
|
:class="cn(toggleVariants({ variant, size }), props.class)"
|
||||||
|
>
|
||||||
|
<slot v-bind="slotProps" />
|
||||||
|
</Toggle>
|
||||||
|
</template>
|
27
components/ui/toggle/index.ts
Normal file
27
components/ui/toggle/index.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
|
||||||
|
export { default as Toggle } from './Toggle.vue'
|
||||||
|
|
||||||
|
export const toggleVariants = cva(
|
||||||
|
'inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 gap-2',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-transparent',
|
||||||
|
outline:
|
||||||
|
'border border-input bg-transparent hover:bg-accent hover:text-accent-foreground',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: 'h-10 px-3 min-w-10',
|
||||||
|
sm: 'h-9 px-2.5 min-w-9',
|
||||||
|
lg: 'h-11 px-5 min-w-11',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export type ToggleVariants = VariantProps<typeof toggleVariants>
|
133
components/uni/file-selector/index.vue
Normal file
133
components/uni/file-selector/index.vue
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
const emit = defineEmits(['change'])
|
||||||
|
const props = defineProps({
|
||||||
|
placeholder: {
|
||||||
|
type: String,
|
||||||
|
default: '点击或拖拽文件到此处',
|
||||||
|
},
|
||||||
|
placeholderDragover: {
|
||||||
|
type: String,
|
||||||
|
default: '松开选择文件',
|
||||||
|
},
|
||||||
|
multiple: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
accept: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const inputRef = ref<HTMLInputElement | null>(null)
|
||||||
|
const dragover = ref(false)
|
||||||
|
|
||||||
|
const selectedFiles = ref<File[]>([])
|
||||||
|
|
||||||
|
const onIncomeFiles = (files?: FileList | null) => {
|
||||||
|
if (files && files.length > 0) {
|
||||||
|
const wantedFiles = Array.from(files).filter((file) => {
|
||||||
|
if (props.accept) {
|
||||||
|
const accept = props.accept.split(',').map((type) => type.trim())
|
||||||
|
return accept.includes(file.type)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
if (wantedFiles.length === 0) {
|
||||||
|
console.error('no acceptable file')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
selectedFiles.value = props.multiple ? wantedFiles : [wantedFiles[0]]
|
||||||
|
emit('change', files)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
:class="{
|
||||||
|
'bg-neutral-300 dark:bg-neutral-900 border-primary-300 dark:border-primary-800 shadow-inner':
|
||||||
|
dragover,
|
||||||
|
}"
|
||||||
|
class="p-2 px-4 min-w-40 relative rounded-md border-2 border-dashed border-neutral-200 dark:border-neutral-800 bg-inherit cursor-pointer select-none transition duration-200 hover:border-primary-300 dark:hover:border-primary-800 overflow-hidden"
|
||||||
|
@click="inputRef?.click()"
|
||||||
|
@dragover.prevent="dragover = true"
|
||||||
|
@dragleave.prevent="dragover = false"
|
||||||
|
@drop.prevent="
|
||||||
|
($event) => {
|
||||||
|
dragover = false
|
||||||
|
if (!$event.dataTransfer?.files) return
|
||||||
|
onIncomeFiles($event.dataTransfer?.files)
|
||||||
|
}
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref="inputRef"
|
||||||
|
:accept="accept"
|
||||||
|
:multiple="multiple"
|
||||||
|
class="hidden"
|
||||||
|
type="file"
|
||||||
|
@change="onIncomeFiles(inputRef?.files)"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
:class="{
|
||||||
|
'pb-6': selectedFiles.length > 0,
|
||||||
|
}"
|
||||||
|
class="w-full h-full flex flex-col justify-center items-center gap-2 transition-all"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
:name="dragover ? 'i-tabler-drag-drop' : 'i-tabler-upload'"
|
||||||
|
class="text-3xl text-neutral-400 dark:text-neutral-500"
|
||||||
|
/>
|
||||||
|
<p class="text-xs font-medium text-neutral-500 dark:text-neutral-400">
|
||||||
|
{{ dragover ? '松开选择文件' : '点击或拖拽文件到此处' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="selectedFiles.length > 0"
|
||||||
|
class="absolute inset-x-0 bottom-0 pl-2 pr-0.5 py-0.5 flex justify-between items-center bg-neutral-100 dark:bg-neutral-900 border-t dark:border-neutral-800"
|
||||||
|
>
|
||||||
|
<div class="flex-1 pr-4 overflow-hidden flex items-center gap-1">
|
||||||
|
<Icon
|
||||||
|
:name="
|
||||||
|
selectedFiles.length === 1 ? 'i-tabler-file' : 'i-tabler-files'
|
||||||
|
"
|
||||||
|
class="text-neutral-500 dark:text-neutral-400"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
:title="
|
||||||
|
selectedFiles
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((file) => file.name)
|
||||||
|
.join(', ')
|
||||||
|
"
|
||||||
|
class="text-[10px] font-medium overflow-hidden text-ellipsis whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
selectedFiles
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((file) => file.name)
|
||||||
|
.join(', ')
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
color="red"
|
||||||
|
size="xs"
|
||||||
|
variant="ghost"
|
||||||
|
@click.stop="
|
||||||
|
() => {
|
||||||
|
selectedFiles = []
|
||||||
|
inputRef!.value = ''
|
||||||
|
}
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<Icon name="i-tabler-x" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
@ -19,15 +19,26 @@ const sidebarNav: SidebarNavGroup[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'AI 备课',
|
title: 'AI 备课',
|
||||||
url: `/course/prep`,
|
|
||||||
icon: 'tabler:clipboard-list',
|
icon: 'tabler:clipboard-list',
|
||||||
isExternal: true,
|
items: [
|
||||||
|
{
|
||||||
|
title: 'AI 教学设计',
|
||||||
|
url: `/course/prep/teach`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'AI 课件设计',
|
||||||
|
url: `/course/prep/deck`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'AI 出题',
|
||||||
|
url: `/course/prep/quiz`,
|
||||||
|
},
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'AI 教科研',
|
title: 'AI 教科研',
|
||||||
url: `/course/research`,
|
url: `/course/research`,
|
||||||
icon: 'tabler:report-search',
|
icon: 'tabler:report-search',
|
||||||
isExternal: true,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
@ -23,7 +23,6 @@ const {
|
|||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
hideSidebar: true,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
|
@ -1,9 +1,6 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { nav } from './config'
|
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
hideSidebar: true,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
@ -26,7 +23,7 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppContainer :nav-secondary="nav">
|
<AppContainer :nav-secondary="[]">
|
||||||
<h1>AI 课件设计</h1>
|
<h1>AI 课件设计</h1>
|
||||||
</AppContainer>
|
</AppContainer>
|
||||||
</template>
|
</template>
|
||||||
|
@ -2,11 +2,9 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { useForm } from 'vee-validate'
|
import { useForm } from 'vee-validate'
|
||||||
import { toTypedSchema } from '@vee-validate/zod'
|
import { toTypedSchema } from '@vee-validate/zod'
|
||||||
import { nav } from './config'
|
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
hideSidebar: true,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
@ -63,7 +61,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppContainer
|
<AppContainer
|
||||||
:nav-secondary="nav"
|
:nav-secondary="[]"
|
||||||
content-class="flex items-start p-0 w-full h-full"
|
content-class="flex items-start p-0 w-full h-full"
|
||||||
>
|
>
|
||||||
<div class="h-full border-r shadow-xl flex flex-col gap-4">
|
<div class="h-full border-r shadow-xl flex flex-col gap-4">
|
||||||
|
@ -1,21 +1,16 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { nav } from './config'
|
|
||||||
import {
|
import {
|
||||||
FnTeachLessonPlan,
|
FnTeachLessonPlan,
|
||||||
FnTeachCaseGen,
|
FnTeachCaseGen,
|
||||||
FnTeachStdDesign,
|
FnTeachStdDesign,
|
||||||
FnTeachKnowledgeDiagram,
|
FnTeachKnowledgeDiagram,
|
||||||
FnTeachCourseChapter,
|
|
||||||
FnTeachPoliticalCase,
|
FnTeachPoliticalCase,
|
||||||
FnTeachResearchPlan,
|
|
||||||
FnTeachPlan,
|
|
||||||
FnTeachCourseOutline,
|
FnTeachCourseOutline,
|
||||||
} from '#components'
|
} from '#components'
|
||||||
import type { NavTertiaryItem } from '~/components/nav/Tertiary.vue'
|
import type { NavTertiaryItem } from '~/components/nav/Tertiary.vue'
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
hideSidebar: true,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
@ -27,15 +22,15 @@ const router = useRouter()
|
|||||||
const { setBreadcrumbs } = useBreadcrumbs()
|
const { setBreadcrumbs } = useBreadcrumbs()
|
||||||
|
|
||||||
const tertiaryNavs: NavTertiaryItem[] = [
|
const tertiaryNavs: NavTertiaryItem[] = [
|
||||||
{ label: '教案设计', component: FnTeachLessonPlan },
|
|
||||||
{ label: '案例设计', component: FnTeachCaseGen },
|
|
||||||
{ label: '课程标准', component: FnTeachStdDesign },
|
{ label: '课程标准', component: FnTeachStdDesign },
|
||||||
|
{ label: '教案设计', component: FnTeachLessonPlan },
|
||||||
{ label: '知识图谱', component: FnTeachKnowledgeDiagram, disabled: true },
|
{ label: '知识图谱', component: FnTeachKnowledgeDiagram, disabled: true },
|
||||||
{ label: '课程章节', component: FnTeachCourseChapter },
|
|
||||||
{ label: '思政案例', component: FnTeachPoliticalCase },
|
|
||||||
{ label: '教研计划', component: FnTeachResearchPlan },
|
|
||||||
{ label: '教学计划', component: FnTeachPlan },
|
|
||||||
{ label: '课程大纲', component: FnTeachCourseOutline },
|
{ label: '课程大纲', component: FnTeachCourseOutline },
|
||||||
|
{ label: '教学案例', component: FnTeachCaseGen },
|
||||||
|
{ label: '思政案例', component: FnTeachPoliticalCase },
|
||||||
|
// { label: '课程章节', component: FnTeachCourseChapter },
|
||||||
|
// { label: '教研计划', component: FnTeachResearchPlan },
|
||||||
|
// { label: '教学计划', component: FnTeachPlan },
|
||||||
]
|
]
|
||||||
|
|
||||||
const currentNav = ref(0)
|
const currentNav = ref(0)
|
||||||
@ -67,7 +62,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppContainer
|
<AppContainer
|
||||||
:nav-secondary="nav"
|
:nav-secondary="[]"
|
||||||
content-class="flex items-start p-0"
|
content-class="flex items-start p-0"
|
||||||
>
|
>
|
||||||
<div class="w-[188px] h-full border-r shadow-xl">
|
<div class="w-[188px] h-full border-r shadow-xl">
|
||||||
|
Loading…
Reference in New Issue
Block a user