智教助手平台:完整初始化

- 前端:Vue3 + TS + Element Plus,24 个页面路由(课件/组题/教案/动画/思维导图/作文批改/命题/课堂/资源/社区等)
- 后端:FastAPI + SQLAlchemy + SQLite,17 个路由模块,AI 服务层含降级模板
- AI:glm-5.x 推理模型已禁用思维链,确保输出真实内容
- 修复:ai_service 两处请求体注入 enable_thinking/thinking disabled
- 测试账号:13900999999 / test1234
This commit is contained in:
Zhang Jing Xuan
2026-07-29 16:29:10 +08:00
commit 0841b1a103
193 changed files with 44995 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
<template>
<router-view />
</template>
<script setup lang="ts">
</script>
+22
View File
@@ -0,0 +1,22 @@
import request from '@/utils/request'
export const getAdminDashboard = () =>
request.get('/api/admin/dashboard') as unknown as Promise<any>
export const getAdminUsers = (params?: any) =>
request.get('/api/admin/users', { params }) as unknown as Promise<any[]>
export const updateAdminUser = (id: number, data: any) =>
request.put(`/api/admin/users/${id}`, data) as unknown as Promise<any>
export const grantAdminCredits = (id: number, amount: number) =>
request.post(`/api/admin/users/${id}/grant-credits`, { amount }) as unknown as Promise<any>
export const getAdminResources = (params?: any) =>
request.get('/api/admin/resources', { params }) as unknown as Promise<any[]>
export const moderateResource = (id: number, status: string) =>
request.put(`/api/admin/resources/${id}/moderate?status=${status}`) as unknown as Promise<any>
export const getAdminAuditLogs = (params?: any) =>
request.get('/api/admin/audit-logs', { params }) as unknown as Promise<any[]>
+21
View File
@@ -0,0 +1,21 @@
import request from '@/utils/request'
export const gradeEssay = (data: { essay_text: string; grade_level?: string; essay_type?: string; total_score?: number }) =>
request.post('/api/ai/essay-grade', data)
export const generateExam = (data: { subject: string; grade?: string; knowledge_points?: string[]; difficulty?: string; question_types?: string[]; count?: number; total_score?: number }) =>
request.post('/api/ai/exam-generate', data)
export const parseMaterial = (file: File) => {
const formData = new FormData()
formData.append('file', file)
return request.post('/api/ai/materials/parse', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
}) as unknown as Promise<{ success: boolean; data: { material_id: number; filename: string; title: string; material_type: string; size: number; summary: string; char_count: number } }>
}
export const exportHtml = (data: { title: string; html: string }): Promise<Blob> =>
request.post('/api/ai/export/html', data, { responseType: 'blob' }) as unknown as Promise<Blob>
export const exportExamDocx = (data: { title: string; subject?: string; grade?: string; questions: any[]; answers?: any[] }): Promise<Blob> =>
request.post('/api/ai/export/exam-docx', data, { responseType: 'blob' }) as unknown as Promise<Blob>
+27
View File
@@ -0,0 +1,27 @@
import request from '@/utils/request'
export const getAnimations = (params?: any) =>
request.get('/api/animations/', { params })
export const getAnimation = (id: number) =>
request.get(`/api/animations/${id}`)
export const createAnimation = (data: any) =>
request.post('/api/animations/', data)
export const updateAnimation = (id: number, data: any) =>
request.put(`/api/animations/${id}`, data)
export const remixAnimation = (id: number) =>
request.post(`/api/animations/${id}/remix`)
export const aiGenerateAnimation = (data: { prompt: string; anim_type?: string; subject?: string }) =>
request.post('/api/animations/ai-generate', data, {
headers: { 'Content-Type': 'application/json; charset=utf-8' },
})
export const deleteAnimation = (id: number) =>
request.delete(`/api/animations/${id}`)
export const exportAnimationHtml = (id: number): Promise<Blob> =>
request.get(`/api/animations/${id}/export-html`, { responseType: 'blob' }) as unknown as Promise<Blob>
+44
View File
@@ -0,0 +1,44 @@
import request from '@/utils/request'
export interface ChatMessage {
id?: number
role: 'user' | 'assistant' | 'system'
content: string
created_at?: string
}
export interface ChatConversation {
id: number
title: string
subject?: string
grade?: string
pinned?: boolean
created_at?: string
updated_at?: string
messages: ChatMessage[]
}
export interface SendMessageResult {
success: boolean
data: ChatMessage
title?: string
credits?: { balance: number; cost: number; action: string }
}
export const listConversations = (params?: { keyword?: string }) =>
request.get('/api/chat/conversations', { params }) as unknown as Promise<ChatConversation[]>
export const createConversation = (data: { title?: string; subject?: string; grade?: string }) =>
request.post('/api/chat/conversations', data) as unknown as Promise<ChatConversation>
export const getConversation = (id: number) =>
request.get(`/api/chat/conversations/${id}`) as unknown as Promise<ChatConversation>
export const renameConversation = (id: number, data: { title: string; pinned?: boolean }) =>
request.put(`/api/chat/conversations/${id}`, data) as unknown as Promise<ChatConversation>
export const deleteConversation = (id: number) =>
request.delete(`/api/chat/conversations/${id}`) as unknown as Promise<void>
export const sendMessage = (id: number, data: { content: string; subject?: string; grade?: string }) =>
request.post(`/api/chat/conversations/${id}/messages`, data) as unknown as Promise<SendMessageResult>
+28
View File
@@ -0,0 +1,28 @@
import request from '@/utils/request'
export const getClassroomActivities = (params?: any) =>
request.get('/api/classroom/activities', { params }) as unknown as Promise<any[]>
export const getClassroomAnalysis = (id: number) =>
request.get(`/api/classroom/activities/${id}/analysis`) as unknown as Promise<any>
export const exportClassroomAnalysis = (id: number): Promise<Blob> =>
request.get(`/api/classroom/activities/${id}/analysis/export`, { responseType: 'blob' }) as unknown as Promise<Blob>
export const exportClassroomResponses = (id: number): Promise<Blob> =>
request.get(`/api/classroom/activities/${id}/responses/export`, { responseType: 'blob' }) as unknown as Promise<Blob>
export const getPublicClassroomActivity = (id: number) =>
request.get(`/api/classroom/public/activities/${id}`) as unknown as Promise<any>
export const createClassroomActivity = (data: any) =>
request.post('/api/classroom/activities', data) as unknown as Promise<any>
export const updateClassroomActivity = (id: number, data: any) =>
request.put(`/api/classroom/activities/${id}`, data) as unknown as Promise<any>
export const submitClassroomResponse = (id: number, data: any) =>
request.post(`/api/classroom/activities/${id}/responses`, data) as unknown as Promise<any>
export const deleteClassroomActivity = (id: number) =>
request.delete(`/api/classroom/activities/${id}`)
+37
View File
@@ -0,0 +1,37 @@
import request from '@/utils/request'
export const getPosts = (params?: any) =>
request.get('/api/community/posts', { params })
export const getPostRanking = (params?: any) =>
request.get('/api/community/posts/ranking', { params })
export const createPost = (data: any) =>
request.post('/api/community/posts', data)
export const getPost = (id: number) =>
request.get(`/api/community/posts/${id}`)
export const updatePost = (id: number, data: any) =>
request.put(`/api/community/posts/${id}`, data)
export const deletePost = (id: number) =>
request.delete(`/api/community/posts/${id}`)
export const likePost = (id: number) =>
request.post(`/api/community/posts/${id}/like`)
export const unlikePost = (id: number) =>
request.delete(`/api/community/posts/${id}/like`)
export const getComments = (postId: number) =>
request.get(`/api/community/posts/${postId}/comments`)
export const createComment = (postId: number, data: { content: string; parent_id?: number }) =>
request.post(`/api/community/posts/${postId}/comments`, data)
export const deleteComment = (postId: number, commentId: number) =>
request.delete(`/api/community/posts/${postId}/comments/${commentId}`)
export const getFavoritePosts = (params?: any) =>
request.get('/api/community/posts', { params: { ...(params || {}), scope: 'favorite' } })
+37
View File
@@ -0,0 +1,37 @@
import request from '@/utils/request'
export const getCoursewares = (params?: any) =>
request.get('/api/coursewares/', { params })
export const getCourseware = (id: number) =>
request.get(`/api/coursewares/${id}`)
export const createCourseware = (data: any) =>
request.post('/api/coursewares/', data)
export const updateCourseware = (id: number, data: any) =>
request.put(`/api/coursewares/${id}`, data)
export const remixCourseware = (id: number) =>
request.post(`/api/coursewares/${id}/remix`)
export const createCoursewareShare = (id: number) =>
request.post(`/api/coursewares/${id}/share`) as unknown as Promise<any>
export const getCoursewareShare = (token: string) =>
request.get(`/api/coursewares/shares/${token}`) as unknown as Promise<any>
export const getMyCoursewareShares = (params?: any) =>
request.get('/api/coursewares/shares/mine/list', { params }) as unknown as Promise<any[]>
export const revokeCoursewareShare = (id: number) =>
request.delete(`/api/coursewares/shares/mine/${id}`)
export const deleteCourseware = (id: number) =>
request.delete(`/api/coursewares/${id}`)
export const exportCoursewareDocx = (id: number): Promise<Blob> =>
request.get(`/api/coursewares/${id}/export-docx`, { responseType: 'blob' }) as unknown as Promise<Blob>
export const aiGenerateCourseware = (data: { prompt: string; subject?: string; grade?: string; page_count?: number; aspect_ratio?: string }) =>
request.post('/api/coursewares/ai-generate', data)
+13
View File
@@ -0,0 +1,13 @@
import request from '@/utils/request'
export const getEssayGrades = (params?: any) =>
request.get('/api/essay-grades/', { params })
export const getEssayGrade = (id: number) =>
request.get(`/api/essay-grades/${id}`)
export const createEssayGrade = (data: any) =>
request.post('/api/essay-grades/', data)
export const deleteEssayGrade = (id: number) =>
request.delete(`/api/essay-grades/${id}`)
+22
View File
@@ -0,0 +1,22 @@
import request from '@/utils/request'
export const getExams = (params?: any) =>
request.get('/api/exams/', { params })
export const getExam = (id: number) =>
request.get(`/api/exams/${id}`)
export const createExam = (data: any) =>
request.post('/api/exams/', data)
export const updateExam = (id: number, data: any) =>
request.put(`/api/exams/${id}`, data)
export const remixExam = (id: number) =>
request.post(`/api/exams/${id}/remix`)
export const deleteExam = (id: number) =>
request.delete(`/api/exams/${id}`)
export const exportSavedExamDocx = (id: number): Promise<Blob> =>
request.get(`/api/exams/${id}/export-docx`, { responseType: 'blob' }) as unknown as Promise<Blob>
+40
View File
@@ -0,0 +1,40 @@
import request from '@/utils/request'
export const getExercises = (params?: any) =>
request.get('/api/exercises/', { params })
export const getExercise = (id: number) =>
request.get(`/api/exercises/${id}`)
export const createExercise = (data: any) =>
request.post('/api/exercises/', data)
export const updateExercise = (id: number, data: any) =>
request.put(`/api/exercises/${id}`, data)
export const remixExercise = (id: number) =>
request.post(`/api/exercises/${id}/remix`)
export const aiGenerateExercise = (data: { prompt: string; exercise_type?: string; subject?: string; knowledge_points?: string[]; count?: number }) =>
request.post('/api/exercises/ai-generate', data)
export const deleteExercise = (id: number) =>
request.delete(`/api/exercises/${id}`)
export const exportExerciseDocx = (id: number): Promise<Blob> =>
request.get(`/api/exercises/${id}/export-docx`, { responseType: 'blob' }) as unknown as Promise<Blob>
export interface ExerciseAttemptData {
student_name?: string
answers: any[]
score: number
total: number
duration?: number
}
export const submitExerciseAttempt = (id: number, data: ExerciseAttemptData) =>
request.post(`/api/exercises/${id}/attempt`, data)
export const getExerciseAttempts = (id: number) =>
request.get(`/api/exercises/${id}/attempts`)
+39
View File
@@ -0,0 +1,39 @@
import request from '@/utils/request'
export const login = (data: { phone: string; password: string }) =>
request.post('/api/auth/login', data)
export const register = (data: { phone: string; password: string; name: string; subject?: string; school?: string }) =>
request.post('/api/auth/register', data)
export const getProfile = () =>
request.get('/api/auth/me')
export const updateProfile = (data: any) =>
request.put('/api/auth/me', data)
export const getProfileStats = () =>
request.get('/api/auth/stats')
export const getCreditBenefits = () =>
request.get('/api/auth/credits/benefits')
export const claimDailyCheckin = () =>
request.post('/api/auth/credits/daily-checkin')
export const changePassword = (data: { old_password: string; new_password: string }) =>
request.post('/api/auth/change-password', data)
export const uploadAvatar = (file: File) => {
const fd = new FormData()
fd.append('file', file)
return request.post('/api/auth/avatar', fd, {
headers: { 'Content-Type': 'multipart/form-data' },
})
}
export const refreshAccessToken = (refreshToken: string) =>
request.post('/api/auth/refresh', null, { params: { refresh_token: refreshToken } })
export const sendResetCode = (phone: string) =>
request.post('/api/auth/send-code', { phone })
export const resetPassword = (data: { phone: string; code: string; new_password: string }) =>
request.post('/api/auth/reset-password', data)
+25
View File
@@ -0,0 +1,25 @@
import request from '@/utils/request'
export const getLessonPlans = (params?: any) =>
request.get('/api/lesson-plans/', { params })
export const getLessonPlan = (id: number) =>
request.get(`/api/lesson-plans/${id}`)
export const createLessonPlan = (data: any) =>
request.post('/api/lesson-plans/', data)
export const updateLessonPlan = (id: number, data: any) =>
request.put(`/api/lesson-plans/${id}`, data)
export const remixLessonPlan = (id: number) =>
request.post(`/api/lesson-plans/${id}/remix`)
export const aiGenerateLessonPlan = (data: { title: string; subject?: string; grade?: string; objectives?: string[]; duration?: number; extra_requirements?: string }) =>
request.post('/api/lesson-plans/ai-generate', data)
export const deleteLessonPlan = (id: number) =>
request.delete(`/api/lesson-plans/${id}`)
export const exportLessonPlanDocx = (id: number): Promise<Blob> =>
request.get(`/api/lesson-plans/${id}/export-docx`, { responseType: 'blob' }) as unknown as Promise<Blob>
+16
View File
@@ -0,0 +1,16 @@
import request from '@/utils/request'
export const getMaterials = (params?: any) =>
request.get('/api/materials/', { params })
export const getMaterial = (id: number) =>
request.get(`/api/materials/${id}`)
export const createMaterial = (data: any) =>
request.post('/api/materials/', data)
export const updateMaterial = (id: number, data: any) =>
request.put(`/api/materials/${id}`, data)
export const deleteMaterial = (id: number) =>
request.delete(`/api/materials/${id}`)
+30
View File
@@ -0,0 +1,30 @@
import request from '@/utils/request'
export interface MindMapNode {
title: string
color?: string
children?: MindMapNode[]
}
export interface MindMapData {
title: string
nodes: MindMapNode[]
}
export const aiGenerateMindMap = (data: { topic: string; subject?: string; grade?: string }) =>
request.post('/api/mind-maps/ai-generate', data)
export const getMindMaps = (params?: any) =>
request.get('/api/mind-maps/', { params })
export const getMindMap = (id: number) =>
request.get(`/api/mind-maps/${id}`)
export const createMindMap = (data: { title: string; subject?: string; nodes: MindMapNode[] }) =>
request.post('/api/mind-maps/', data)
export const updateMindMap = (id: number, data: { title?: string; nodes?: MindMapNode[] }) =>
request.put(`/api/mind-maps/${id}`, data)
export const deleteMindMap = (id: number) =>
request.delete(`/api/mind-maps/${id}`)
+28
View File
@@ -0,0 +1,28 @@
import request from '@/utils/request'
export interface NotificationItem {
id: number
user_id: number
actor_id: number | null
ntype: string
title: string
content: string
link: string
is_read: boolean
created_at: string | null
}
export const getNotifications = (params?: { unread_only?: boolean; limit?: number }) =>
request.get('/api/notifications', { params })
export const getUnreadCount = () =>
request.get('/api/notifications/unread-count')
export const markRead = (id: number) =>
request.put(`/api/notifications/${id}/read`)
export const markAllRead = () =>
request.put('/api/notifications/read-all')
export const deleteNotification = (id: number) =>
request.delete(`/api/notifications/${id}`)
+34
View File
@@ -0,0 +1,34 @@
import request from '@/utils/request'
export const getResources = (params?: any) =>
request.get('/api/resources/', { params })
export const createResource = (data: any) =>
request.post('/api/resources/', data)
export const publishResource = (data: any) =>
request.post('/api/resources/publish', data)
export const getResource = (id: number) =>
request.get(`/api/resources/${id}`)
export const updateResource = (id: number, data: any) =>
request.put(`/api/resources/${id}`, data)
export const likeResource = (id: number) =>
request.post(`/api/resources/${id}/like`)
export const unlikeResource = (id: number) =>
request.delete(`/api/resources/${id}/like`)
export const getFavoriteResources = (params?: any) =>
request.get('/api/resources/favorites/me', { params })
export const getMyResources = (params?: any) =>
request.get('/api/resources/mine', { params })
export const deleteResource = (id: number) =>
request.delete(`/api/resources/${id}`)
export const downloadResourceStat = (id: number) =>
request.post(`/api/resources/${id}/download`)
+4
View File
@@ -0,0 +1,4 @@
import request from '@/utils/request'
export const globalSearch = (params: { keyword?: string; limit?: number }) =>
request.get('/api/search', { params }) as unknown as Promise<any>
+88
View File
@@ -0,0 +1,88 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
const EffectScope: typeof import('vue')['EffectScope']
const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
const computed: typeof import('vue')['computed']
const createApp: typeof import('vue')['createApp']
const createPinia: typeof import('pinia')['createPinia']
const customRef: typeof import('vue')['customRef']
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
const defineComponent: typeof import('vue')['defineComponent']
const defineStore: typeof import('pinia')['defineStore']
const effectScope: typeof import('vue')['effectScope']
const getActivePinia: typeof import('pinia')['getActivePinia']
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentScope: typeof import('vue')['getCurrentScope']
const h: typeof import('vue')['h']
const inject: typeof import('vue')['inject']
const isProxy: typeof import('vue')['isProxy']
const isReactive: typeof import('vue')['isReactive']
const isReadonly: typeof import('vue')['isReadonly']
const isRef: typeof import('vue')['isRef']
const mapActions: typeof import('pinia')['mapActions']
const mapGetters: typeof import('pinia')['mapGetters']
const mapState: typeof import('pinia')['mapState']
const mapStores: typeof import('pinia')['mapStores']
const mapWritableState: typeof import('pinia')['mapWritableState']
const markRaw: typeof import('vue')['markRaw']
const nextTick: typeof import('vue')['nextTick']
const onActivated: typeof import('vue')['onActivated']
const onBeforeMount: typeof import('vue')['onBeforeMount']
const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave']
const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate']
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
const onDeactivated: typeof import('vue')['onDeactivated']
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
const onMounted: typeof import('vue')['onMounted']
const onRenderTracked: typeof import('vue')['onRenderTracked']
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
const onScopeDispose: typeof import('vue')['onScopeDispose']
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
const onUnmounted: typeof import('vue')['onUnmounted']
const onUpdated: typeof import('vue')['onUpdated']
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
const provide: typeof import('vue')['provide']
const reactive: typeof import('vue')['reactive']
const readonly: typeof import('vue')['readonly']
const ref: typeof import('vue')['ref']
const resolveComponent: typeof import('vue')['resolveComponent']
const setActivePinia: typeof import('pinia')['setActivePinia']
const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix']
const shallowReactive: typeof import('vue')['shallowReactive']
const shallowReadonly: typeof import('vue')['shallowReadonly']
const shallowRef: typeof import('vue')['shallowRef']
const storeToRefs: typeof import('pinia')['storeToRefs']
const toRaw: typeof import('vue')['toRaw']
const toRef: typeof import('vue')['toRef']
const toRefs: typeof import('vue')['toRefs']
const toValue: typeof import('vue')['toValue']
const triggerRef: typeof import('vue')['triggerRef']
const unref: typeof import('vue')['unref']
const useAttrs: typeof import('vue')['useAttrs']
const useCssModule: typeof import('vue')['useCssModule']
const useCssVars: typeof import('vue')['useCssVars']
const useId: typeof import('vue')['useId']
const useLink: typeof import('vue-router')['useLink']
const useModel: typeof import('vue')['useModel']
const useRoute: typeof import('vue-router')['useRoute']
const useRouter: typeof import('vue-router')['useRouter']
const useSlots: typeof import('vue')['useSlots']
const useTemplateRef: typeof import('vue')['useTemplateRef']
const watch: typeof import('vue')['watch']
const watchEffect: typeof import('vue')['watchEffect']
const watchPostEffect: typeof import('vue')['watchPostEffect']
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
}
// for type re-export
declare global {
// @ts-ignore
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
}
+47
View File
@@ -0,0 +1,47 @@
/* eslint-disable */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
AppHeader: typeof import('./components/layout/AppHeader.vue')['default']
AppLayout: typeof import('./components/layout/AppLayout.vue')['default']
AppSidebar: typeof import('./components/layout/AppSidebar.vue')['default']
ElAside: typeof import('element-plus/es')['ElAside']
ElButton: typeof import('element-plus/es')['ElButton']
ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
ElContainer: typeof import('element-plus/es')['ElContainer']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElDropdown: typeof import('element-plus/es')['ElDropdown']
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
ElEmpty: typeof import('element-plus/es')['ElEmpty']
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElHeader: typeof import('element-plus/es')['ElHeader']
ElIcon: typeof import('element-plus/es')['ElIcon']
ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElMain: typeof import('element-plus/es')['ElMain']
ElOption: typeof import('element-plus/es')['ElOption']
ElPopconfirm: typeof import('element-plus/es')['ElPopconfirm']
ElPopover: typeof import('element-plus/es')['ElPopover']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTag: typeof import('element-plus/es')['ElTag']
HtmlPreview: typeof import('./components/common/HtmlPreview.vue')['default']
MarkdownContent: typeof import('./components/common/MarkdownContent.vue')['default']
MaterialPicker: typeof import('./components/common/MaterialPicker.vue')['default']
MindNode: typeof import('./components/common/MindNode.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
TemplateGallery: typeof import('./components/common/TemplateGallery.vue')['default']
}
export interface ComponentCustomProperties {
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
}
}
@@ -0,0 +1,649 @@
<template>
<div class="html-preview" :class="{ 'edit-mode': isEditing }" :style="{ height: height }">
<!-- Edit toolbar -->
<div class="edit-toolbar" v-if="isEditing">
<button class="tb-btn" @click="execCmd('bold')" title="加粗"><b>B</b></button>
<button class="tb-btn" @click="execCmd('italic')" title="斜体"><i>I</i></button>
<button class="tb-btn" @click="execCmd('underline')" title="下划线"><u>U</u></button>
<span class="tb-sep"></span>
<button class="tb-btn" @click="execCmd('justifyLeft')" title="左对齐">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="15" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
<button class="tb-btn" @click="execCmd('justifyCenter')" title="居中">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="6" y1="12" x2="18" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
<span class="tb-sep"></span>
<button class="tb-btn" @click="showColorPicker = !showColorPicker" title="文字颜色">
<span style="font-size:13px">A</span>
<span class="color-indicator" :style="{ background: selectedColor }"></span>
</button>
<div class="color-panel" v-if="showColorPicker">
<div v-for="c in colors" :key="c" class="color-swatch" :style="{ background: c }" @click="applyColor(c)"></div>
</div>
<span class="tb-sep"></span>
<button class="tb-btn" @click="openInsertImageDialog(false)" title="插入图片">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/></svg>
</button>
<button class="tb-btn" @click="execCmd('insertUnorderedList')" title="无序列表">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="9" y1="6" x2="20" y2="6"/><line x1="9" y1="12" x2="20" y2="12"/><line x1="9" y1="18" x2="20" y2="18"/><circle cx="5" cy="6" r="1" fill="currentColor"/><circle cx="5" cy="12" r="1" fill="currentColor"/><circle cx="5" cy="18" r="1" fill="currentColor"/></svg>
</button>
<span class="tb-sep"></span>
<button class="tb-btn" @click="showAnimDialog = true" title="插入动画">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
</button>
</div>
<iframe ref="iframeRef" class="preview-iframe" frameborder="0" />
<!-- Resize handles (fixed position, overlay on iframe) -->
<template v-if="isEditing && selectedImg && !insertImageDialog">
<div v-for="h in handles" :key="h.id" class="rh" :class="h.id"
:style="{ left: hPos[h.id]?.left, top: hPos[h.id]?.top, cursor: h.cursor }"
@mousedown.prevent="startResize($event, h.id)" />
<!-- Dimmed overlay outside image -->
<!-- Image mini toolbar -->
<div class="img-bar" v-if="hPos.toolbar" :style="{ left: hPos.toolbar.left, top: hPos.toolbar.top }">
<button class="bar-btn" @click="setImgSize('small')" :class="{ active: currentImgSize === 'small' }">小</button>
<button class="bar-btn" @click="setImgSize('medium')" :class="{ active: currentImgSize === 'medium' }">中</button>
<button class="bar-btn" @click="setImgSize('large')" :class="{ active: currentImgSize === 'large' }">大</button>
<span class="bar-sep"></span>
<button class="bar-btn" @click="setImgAlign('left')" :class="{ active: currentImgAlign === 'left' }" title="左浮动">↙</button>
<button class="bar-btn" @click="setImgAlign('center')" :class="{ active: currentImgAlign === 'center' }" title="居中">↔</button>
<button class="bar-btn" @click="setImgAlign('right')" :class="{ active: currentImgAlign === 'right' }" title="右浮动">↘</button>
<span class="bar-sep"></span>
<button class="bar-btn" @click="openInsertImageDialog(true)">替换</button>
<button class="bar-btn del" @click="deleteSelectedImg">删除</button>
</div>
</template>
<!-- Insert / Replace image dialog -->
<div class="img-dialog-overlay" v-if="insertImageDialog" @click.self="closeImgDialog">
<div class="img-dialog">
<h4>{{ replacingImg ? '替换图片' : '插入图片' }}</h4>
<div class="img-dialog-body">
<label>图片链接</label>
<el-input v-model="imageUrl" placeholder="粘贴图片URL地址" size="default" />
<label style="margin-top:10px">或上传本地图片</label>
<button type="button" class="upload-img-btn" @click="triggerImageUpload">
<span>上传图片</span>
</button>
<input ref="imageFileInput" type="file" accept="image/*" class="hidden-file-input" @change="handleFileSelect" />
<div class="img-preview-box" v-if="imagePreviewUrl">
<img :src="imagePreviewUrl" />
</div>
</div>
<div class="img-dialog-footer">
<button class="dialog-btn cancel" @click="closeImgDialog">取消</button>
<button class="dialog-btn confirm" @click="doInsertImage" :disabled="!imageUrl">{{ replacingImg ? '替换' : '插入' }}</button>
</div>
</div>
</div>
<!-- Insert animation dialog -->
<div class="img-dialog-overlay" v-if="showAnimDialog" @click.self="showAnimDialog = false">
<div class="img-dialog" style="max-width:520px">
<h4>插入动画</h4>
<div class="img-dialog-body">
<div v-if="animLoading" style="text-align:center;padding:20px;color:#999;">加载中...</div>
<div v-else-if="!animList.length" style="text-align:center;padding:20px;color:#999;">暂无已保存的动画,请先到"教学动画"页面生成并保存</div>
<div v-else class="anim-list">
<div v-for="a in animList" :key="a.id" class="anim-item" @click="insertAnimation(a)">
<div class="anim-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#6366F1" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
</div>
<div class="anim-info">
<div class="anim-title">{{ a.title }}</div>
<div class="anim-desc">{{ a.description || a.anim_type }}</div>
</div>
<span class="anim-insert-btn">插入</span>
</div>
</div>
</div>
<div class="img-dialog-footer">
<button class="dialog-btn cancel" @click="showAnimDialog = false">关闭</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onMounted, nextTick, onUnmounted } from 'vue'
import { getAnimations } from '@/api/animation'
const props = withDefaults(defineProps<{
html: string
height?: string
editable?: boolean
}>(), { height: '480px', editable: false })
const emit = defineEmits<{ (e: 'update:html', val: string): void }>()
const iframeRef = ref<HTMLIFrameElement>()
const isEditing = ref(false)
const showColorPicker = ref(false)
const selectedColor = ref('#000000')
const insertImageDialog = ref(false)
const imageUrl = ref('')
const imagePreviewUrl = ref('')
const replacingImg = ref(false)
const imageFileInput = ref<HTMLInputElement>()
// --- Animation dialog ---
const showAnimDialog = ref(false)
const animList = ref<any[]>([])
const animLoading = ref(false)
// --- Image selection & resize ---
const selectedImg = ref<HTMLImageElement | null>(null)
const currentImgSize = ref('medium')
const currentImgAlign = ref('none')
const hPos = ref<Record<string, { left: string; top: string }>>({})
const handles = [
{ id: 'nw', cursor: 'nw-resize' },
{ id: 'ne', cursor: 'ne-resize' },
{ id: 'sw', cursor: 'sw-resize' },
{ id: 'se', cursor: 'se-resize' },
{ id: 'n', cursor: 'n-resize' },
{ id: 's', cursor: 's-resize' },
{ id: 'w', cursor: 'w-resize' },
{ id: 'e', cursor: 'e-resize' },
]
const sizeMap: Record<string, string> = { small: '120px', medium: '260px', large: '420px', full: '100%' }
const colors = [
'#000000', '#434343', '#666666', '#999999',
'#EF4444', '#F97316', '#F59E0B', '#EAB308',
'#22C55E', '#10B981', '#06B6D4', '#3B82F6',
'#6366F1', '#8B5CF6', '#EC4899', '#F43F5E',
]
// Resize drag state
let resizeCorner = ''
let startX = 0
let startY = 0
let startW = 0
let startH = 0
let aspectRatio = 1
function calcImgScreenRect() {
const iframe = iframeRef.value
const img = selectedImg.value
if (!iframe || !img) return null
const iRect = iframe.getBoundingClientRect()
const iDoc = iframe.contentDocument
if (!iDoc) return null
// Image rect relative to iframe viewport
const r = img.getBoundingClientRect()
return {
left: iRect.left + r.left,
top: iRect.top + r.top,
right: iRect.left + r.right,
bottom: iRect.top + r.bottom,
width: r.width,
height: r.height,
}
}
function syncHandlePos() {
const r = calcImgScreenRect()
if (!r) { hPos.value = {}; return }
const hs = 5 // half handle size
hPos.value = {
nw: { left: (r.left - hs) + 'px', top: (r.top - hs) + 'px' },
ne: { left: (r.right - hs) + 'px', top: (r.top - hs) + 'px' },
sw: { left: (r.left - hs) + 'px', top: (r.bottom - hs) + 'px' },
se: { left: (r.right - hs) + 'px', top: (r.bottom - hs) + 'px' },
n: { left: ((r.left + r.right) / 2 - hs) + 'px', top: (r.top - hs) + 'px' },
s: { left: ((r.left + r.right) / 2 - hs) + 'px', top: (r.bottom - hs) + 'px' },
w: { left: (r.left - hs) + 'px', top: ((r.top + r.bottom) / 2 - hs) + 'px' },
e: { left: (r.right - hs) + 'px', top: ((r.top + r.bottom) / 2 - hs) + 'px' },
toolbar: {
left: r.left + 'px',
top: (r.top - 40) + 'px',
},
}
}
function startResize(e: MouseEvent, corner: string) {
if (!selectedImg.value) return
resizeCorner = corner
startX = e.clientX
startY = e.clientY
const img = selectedImg.value
startW = img.getBoundingClientRect().width
startH = img.getBoundingClientRect().height
aspectRatio = startW / startH || 1
document.addEventListener('mousemove', onDragMove)
document.addEventListener('mouseup', onDragEnd)
}
function onDragMove(e: MouseEvent) {
if (!selectedImg.value) return
const dx = e.clientX - startX
const dy = e.clientY - startY
const img = selectedImg.value
let newW = startW
let newH = startH
switch (resizeCorner) {
case 'se': newW = startW + dx; break
case 'sw': newW = startW - dx; break
case 'ne': newW = startW + dx; break
case 'nw': newW = startW - dx; break
case 'e': newW = startW + dx; break
case 'w': newW = startW - dx; break
case 'n': newH = startH - dy; newW = newH * aspectRatio; break
case 's': newH = startH + dy; newW = newH * aspectRatio; break
}
// Clamp
newW = Math.max(30, Math.min(newW, 1200))
img.style.width = newW + 'px'
img.style.height = 'auto'
img.style.maxWidth = newW + 'px'
syncHandlePos()
}
function onDragEnd() {
resizeCorner = ''
document.removeEventListener('mousemove', onDragMove)
document.removeEventListener('mouseup', onDragEnd)
}
function handleIframeClick(e: Event) {
if (!isEditing.value) return
const target = e.target as HTMLElement
if (selectedImg.value) {
selectedImg.value.style.outline = ''
selectedImg.value = null
}
if (target.tagName === 'IMG') {
selectedImg.value = target as HTMLImageElement
selectedImg.value.style.outline = '2px solid #4F46E5'
selectedImg.value.style.cursor = 'pointer'
// Read current state
const w = selectedImg.value.style.width
if (w === '120px') currentImgSize.value = 'small'
else if (w === '260px') currentImgSize.value = 'medium'
else if (w === '420px') currentImgSize.value = 'large'
else if (w === '100%') currentImgSize.value = 'full'
else currentImgSize.value = ''
const f = selectedImg.value.style.float
if (f === 'left') currentImgAlign.value = 'left'
else if (f === 'right') currentImgAlign.value = 'right'
else currentImgAlign.value = 'center'
nextTick(syncHandlePos)
} else {
hPos.value = {}
}
}
function setImgSize(size: string) {
if (!selectedImg.value) return
currentImgSize.value = size
const img = selectedImg.value
img.style.width = sizeMap[size] || '260px'
img.style.maxWidth = size === 'full' ? '100%' : sizeMap[size]
img.style.height = 'auto'
nextTick(syncHandlePos)
}
function setImgAlign(align: string) {
if (!selectedImg.value) return
currentImgAlign.value = align
const img = selectedImg.value
img.style.float = ''
img.style.display = ''
img.style.margin = ''
if (align === 'left') { img.style.float = 'left'; img.style.margin = '0 12px 8px 0' }
else if (align === 'right') { img.style.float = 'right'; img.style.margin = '0 0 8px 12px' }
else { img.style.display = 'block'; img.style.margin = '8px auto' }
nextTick(syncHandlePos)
}
function deleteSelectedImg() {
if (!selectedImg.value) return
selectedImg.value.remove()
selectedImg.value = null
hPos.value = {}
}
function openInsertImageDialog(replace: boolean) {
replacingImg.value = replace
imageUrl.value = ''
imagePreviewUrl.value = ''
insertImageDialog.value = true
}
function closeImgDialog() {
insertImageDialog.value = false
replacingImg.value = false
imageUrl.value = ''
imagePreviewUrl.value = ''
nextTick(syncHandlePos)
}
// --- Animation ---
watch(showAnimDialog, async (v) => {
if (!v) return
if (!localStorage.getItem('access_token')) {
animList.value = []
animLoading.value = false
return
}
animLoading.value = true
try {
const res: any = await getAnimations({ limit: 50 })
animList.value = res.data || res || []
} catch { animList.value = [] }
finally { animLoading.value = false }
})
function insertAnimation(anim: any) {
const html = anim.config?.html
if (!html) return
const doc = iframeRef.value?.contentDocument
if (!doc) return
// Wrap animation in a container div
const wrapper = doc.createElement('div')
wrapper.style.cssText = 'width:100%;max-width:800px;margin:16px auto;border-radius:8px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,0.1);'
wrapper.innerHTML = html
const sel = doc.getSelection()
if (sel && sel.rangeCount) {
const range = sel.getRangeAt(0)
range.deleteContents()
range.insertNode(wrapper)
} else {
doc.body.appendChild(wrapper)
}
showAnimDialog.value = false
const edited = doc.body.innerHTML
if (edited !== props.html) emit('update:html', edited)
}
// --- Core iframe logic ---
function writeContent() {
const iframe = iframeRef.value
if (!iframe) return
// Clear lingering setInterval/setTimeout from the previously rendered template
// so animation loops don't fire after the DOM is wiped (causes null-ref errors).
try {
const cw = iframe.contentWindow as any
if (cw) { for (let i = 1; i < 10000; i++) { cw.clearInterval(i); cw.clearTimeout(i) } }
} catch { /* iframe not ready */ }
let content = props.html || ''
if (!content) return
content = content.replace(/100vh/g, '100%').replace(/100vw/g, '100%')
const isFullDoc = /<html/i.test(content)
const finalHtml = isFullDoc
? content
: `<!DOCTYPE html><html><head><meta charset="UTF-8"><style>html,body{margin:0;padding:0;width:100%;height:100%;min-height:100%;overflow:auto;background:#fff;}body{font-family:"Microsoft YaHei","PingFang SC",sans-serif;}body>div:first-child{min-height:100%;}img{max-width:100%;height:auto;}</style></head><body>${content}</body></html>`
const doc = iframe.contentDocument
if (doc) {
doc.open()
doc.write(finalHtml)
doc.close()
if (isEditing.value) enableEditing()
}
}
function enableEditing() {
const iframe = iframeRef.value
if (!iframe) return
const doc = iframe.contentDocument
if (!doc) return
doc.designMode = 'on'
doc.body.style.outline = 'none'
doc.body.style.cursor = 'text'
doc.execCommand('enableObjectResizing', false, 'false')
doc.addEventListener('click', handleIframeClick)
doc.addEventListener('scroll', syncHandlePos)
}
function disableEditing() {
const iframe = iframeRef.value
if (!iframe) return
const doc = iframe.contentDocument
if (!doc) return
doc.removeEventListener('click', handleIframeClick)
doc.removeEventListener('scroll', syncHandlePos)
if (selectedImg.value) {
selectedImg.value.style.outline = ''
selectedImg.value = null
}
hPos.value = {}
doc.designMode = 'off'
const edited = doc.body.innerHTML
if (edited !== props.html) emit('update:html', edited)
}
function getHtml(): string {
const iframe = iframeRef.value
if (!iframe) return props.html
const doc = iframe.contentDocument
return doc ? doc.body.innerHTML : props.html
}
function execCmd(cmd: string, value?: string) {
iframeRef.value?.contentDocument?.execCommand(cmd, false, value)
iframeRef.value?.contentWindow?.focus()
}
function applyColor(color: string) {
selectedColor.value = color
showColorPicker.value = false
execCmd('foreColor', color)
}
function handleFileSelect(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = () => {
imageUrl.value = reader.result as string
imagePreviewUrl.value = reader.result as string
}
reader.readAsDataURL(file)
}
function triggerImageUpload() {
imageFileInput.value?.click()
}
function doInsertImage() {
if (!imageUrl.value) return
if (replacingImg.value && selectedImg.value) {
selectedImg.value.src = imageUrl.value
} else {
execCmd('insertImage', imageUrl.value)
}
closeImgDialog()
}
function handleGlobalClick(e: MouseEvent) {
const t = e.target as HTMLElement
if (showColorPicker.value && !t.closest('.color-panel') && !t.closest('.tb-btn')) {
showColorPicker.value = false
}
}
watch(() => props.html, () => nextTick(writeContent))
watch(() => props.editable, (val) => {
if (val) { isEditing.value = true; nextTick(enableEditing) }
else { if (isEditing.value) disableEditing(); isEditing.value = false }
})
onMounted(() => {
nextTick(writeContent)
document.addEventListener('click', handleGlobalClick)
window.addEventListener('resize', syncHandlePos)
window.addEventListener('scroll', syncHandlePos, true)
})
onUnmounted(() => {
document.removeEventListener('click', handleGlobalClick)
window.removeEventListener('resize', syncHandlePos)
window.removeEventListener('scroll', syncHandlePos, true)
const doc = iframeRef.value?.contentDocument
if (doc) { doc.removeEventListener('click', handleIframeClick); doc.removeEventListener('scroll', syncHandlePos) }
document.removeEventListener('mousemove', onDragMove)
document.removeEventListener('mouseup', onDragEnd)
})
defineExpose({ getHtml })
</script>
<style scoped>
.html-preview {
width: 100%;
border-radius: var(--radius-md);
overflow: visible;
background: #fff;
position: relative;
display: flex;
flex-direction: column;
}
.html-preview.edit-mode {
outline: 2px solid var(--primary);
outline-offset: -1px;
}
/* Toolbar */
.edit-toolbar {
display: flex; align-items: center; gap: 2px;
padding: 6px 10px;
background: #F8FAFC;
border-bottom: 1px solid #E2E8F0;
flex-shrink: 0;
position: relative;
flex-wrap: wrap;
z-index: 10;
}
.tb-btn {
width: 30px; height: 30px;
display: flex; align-items: center; justify-content: center;
border: none; background: none; border-radius: 6px;
cursor: pointer; font-size: 14px; color: #475569;
transition: all 0.15s; position: relative;
}
.tb-btn:hover { background: #E2E8F0; color: #1E293B; }
.tb-sep { width: 1px; height: 20px; background: #E2E8F0; margin: 0 4px; }
.color-indicator { position: absolute; bottom: 3px; left: 50%; transform: translateX(-50%); width: 14px; height: 3px; border-radius: 1px; }
.color-panel {
position: absolute; top: 42px; left: 10px;
background: #fff; border: 1px solid #E2E8F0; border-radius: 10px; padding: 10px;
display: grid; grid-template-columns: repeat(8, 1fr); gap: 4px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); z-index: 100;
}
.color-swatch { width: 24px; height: 24px; border-radius: 6px; cursor: pointer; transition: transform 0.15s; border: 2px solid transparent; }
.color-swatch:hover { transform: scale(1.2); border-color: #fff; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); }
.preview-iframe { width: 100%; height: 100%; flex: 1; border: none; display: block; min-height: 0; background: #fff; }
/* Resize handles */
.rh {
position: fixed;
width: 10px;
height: 10px;
background: #fff;
border: 2px solid #4F46E5;
border-radius: 2px;
z-index: 500;
transition: transform 0.1s;
}
.rh:hover {
transform: scale(1.4);
background: #4F46E5;
}
/* Image toolbar */
.img-bar {
position: fixed;
display: flex;
align-items: center;
gap: 2px;
padding: 4px 8px;
background: #fff;
border: 1px solid #E2E8F0;
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
z-index: 500;
white-space: nowrap;
}
.bar-btn {
padding: 3px 8px;
border: none;
background: none;
border-radius: 4px;
font-size: 12px;
color: #64748B;
cursor: pointer;
transition: all 0.15s;
}
.bar-btn:hover { background: #F1F5F9; color: #4F46E5; }
.bar-btn.active { background: #4F46E5; color: #fff; }
.bar-btn.del { color: #EF4444; }
.bar-btn.del:hover { background: #FEF2F2; }
.bar-sep { width: 1px; height: 16px; background: #E2E8F0; margin: 0 2px; }
/* Image dialog */
.img-dialog-overlay {
position: absolute; inset: 0;
background: rgba(0, 0, 0, 0.3);
display: flex; align-items: center; justify-content: center;
z-index: 600;
}
.img-dialog { background: #fff; border-radius: 12px; width: 380px; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2); overflow: hidden; }
.img-dialog h4 { padding: 16px 20px; font-size: 15px; font-weight: 600; color: #1E293B; border-bottom: 1px solid #E2E8F0; }
.img-dialog-body { padding: 16px 20px; }
.img-dialog-body label { display: block; font-size: 13px; font-weight: 500; color: #64748B; margin-bottom: 6px; }
.hidden-file-input { display: none; }
.upload-img-btn {
height: 34px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 14px;
border: 1px solid #C7D2FE;
border-radius: 8px;
background: #EEF2FF;
color: #4F46E5;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.upload-img-btn:hover { background: #E0E7FF; }
.img-preview-box { margin-top: 10px; border-radius: 8px; overflow: hidden; border: 1px solid #E2E8F0; }
.img-preview-box img { max-width: 100%; max-height: 120px; display: block; margin: 0 auto; }
.img-dialog-footer { display: flex; justify-content: flex-end; gap: 8px; padding: 12px 20px; border-top: 1px solid #E2E8F0; }
.dialog-btn { padding: 7px 18px; border-radius: 8px; font-size: 13px; font-weight: 500; cursor: pointer; border: none; transition: all 0.2s; }
.dialog-btn.cancel { background: #F1F5F9; color: #64748B; }
.dialog-btn.cancel:hover { background: #E2E8F0; }
.dialog-btn.confirm { background: var(--primary); color: #fff; }
.dialog-btn.confirm:hover:not(:disabled) { opacity: 0.9; }
.dialog-btn.confirm:disabled { opacity: 0.4; cursor: not-allowed; }
/* Animation list */
.anim-list { display: flex; flex-direction: column; gap: 8px; max-height: 360px; overflow-y: auto; }
.anim-item { display: flex; align-items: center; gap: 12px; padding: 10px 14px; background: #F8FAFC; border: 1px solid #E2E8F0; border-radius: 8px; cursor: pointer; transition: all 0.2s; }
.anim-item:hover { border-color: #6366F1; background: #EEF2FF; }
.anim-icon { width: 32px; height: 32px; background: #EEF2FF; border-radius: 8px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.anim-info { flex: 1; min-width: 0; }
.anim-title { font-size: 13px; font-weight: 600; color: #1F2937; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.anim-desc { font-size: 11px; color: #9CA3AF; margin-top: 2px; }
.anim-insert-btn { padding: 4px 12px; background: #6366F1; color: #fff; border-radius: 6px; font-size: 11px; font-weight: 500; flex-shrink: 0; }
.anim-item:hover .anim-insert-btn { background: #4F46E5; }
</style>
@@ -0,0 +1,128 @@
<template>
<div class="md-content" v-html="rendered"></div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { marked } from 'marked'
marked.setOptions({
breaks: true,
gfm: true,
})
const props = defineProps<{ content: string }>()
const rendered = computed(() => {
if (!props.content) return ''
return marked.parse(props.content) as string
})
</script>
<style scoped>
.md-content {
font-size: 14px;
color: var(--text-secondary);
line-height: 1.8;
word-break: break-word;
}
.md-content :deep(h1),
.md-content :deep(h2),
.md-content :deep(h3),
.md-content :deep(h4) {
color: var(--text-primary);
font-weight: 600;
margin: 16px 0 8px;
}
.md-content :deep(h3) { font-size: 16px; }
.md-content :deep(h4) { font-size: 15px; }
.md-content :deep(p) {
margin-bottom: 8px;
}
.md-content :deep(strong) {
color: var(--text-primary);
font-weight: 600;
}
.md-content :deep(em) {
color: var(--primary);
font-style: normal;
font-weight: 500;
}
.md-content :deep(ul),
.md-content :deep(ol) {
padding-left: 20px;
margin-bottom: 8px;
}
.md-content :deep(li) {
margin-bottom: 4px;
}
.md-content :deep(code) {
background: #F1F5F9;
padding: 2px 6px;
border-radius: 4px;
font-size: 13px;
color: #E11D48;
}
.md-content :deep(pre) {
background: #1E293B;
color: #E2E8F0;
padding: 16px;
border-radius: var(--radius-sm);
overflow-x: auto;
margin: 12px 0;
}
.md-content :deep(pre code) {
background: none;
color: inherit;
padding: 0;
}
.md-content :deep(blockquote) {
border-left: 3px solid var(--primary-light);
padding: 8px 16px;
margin: 12px 0;
background: var(--primary-bg);
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
color: var(--text-secondary);
}
.md-content :deep(hr) {
border: none;
border-top: 1px solid var(--border-color);
margin: 16px 0;
}
.md-content :deep(table) {
width: 100%;
border-collapse: collapse;
margin: 12px 0;
}
.md-content :deep(th),
.md-content :deep(td) {
border: 1px solid var(--border-color);
padding: 8px 12px;
text-align: left;
}
.md-content :deep(th) {
background: #F8FAFC;
font-weight: 600;
color: var(--text-primary);
}
.md-content :deep(img) {
max-width: 100%;
border-radius: var(--radius-sm);
}
</style>
@@ -0,0 +1,282 @@
<template>
<div class="material-picker">
<button class="material-btn" type="button" :disabled="loading" @click="openLibrary">
<el-icon><FolderOpened /></el-icon>
素材库
</button>
<div v-if="showSelected && modelValue.length" class="materials-row">
<span v-for="item in modelValue" :key="item.id">
<el-icon><component :is="item.icon" /></el-icon>
{{ item.name }}
<em>{{ item.typeLabel }} · {{ item.sizeLabel }}</em>
<button type="button" @click="removeMaterial(item.id)">×</button>
</span>
</div>
<el-dialog v-model="visible" title="选择素材库材料" width="760px">
<div class="material-library-toolbar">
<div class="library-search">
<el-icon><Search /></el-icon>
<input v-model="keyword" placeholder="搜索素材标题、文件名或摘要" @keyup.enter="loadLibrary" />
<button v-if="keyword" type="button" @click="clearKeyword">清除</button>
</div>
<el-button :loading="loading" @click="loadLibrary">刷新</el-button>
</div>
<div v-loading="loading" class="material-library-list">
<article v-for="item in materials" :key="item.id" class="library-item">
<div>
<strong>{{ item.title || item.filename }}</strong>
<span>{{ materialTypeLabel(item.material_type, item) }} · {{ item.subject || '通用' }} · {{ formatMaterialFileSize(item.size || 0) }}</span>
<p>{{ item.summary || '暂无摘要' }}</p>
</div>
<button type="button" @click="addMaterial(item)">加入任务</button>
</article>
<div v-if="!loading && materials.length === 0" class="library-empty">
<strong>素材库暂无匹配材料</strong>
<span>可以先到素材库上传解析,或换个关键词再试。</span>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { getMaterials } from '@/api/material'
import { formatMaterialFileSize, materialToContext, materialTypeLabel, type MaterialContextItem } from '@/utils/materialContext'
const props = withDefaults(defineProps<{
modelValue: MaterialContextItem[]
showSelected?: boolean
}>(), {
showSelected: true,
})
const emit = defineEmits<{ 'update:modelValue': [value: MaterialContextItem[]] }>()
const router = useRouter()
const visible = ref(false)
const loading = ref(false)
const keyword = ref('')
const materials = ref<any[]>([])
async function openLibrary() {
if (!localStorage.getItem('access_token')) {
ElMessage.warning('请先登录后使用素材库')
router.push({ path: '/login', query: { redirect: router.currentRoute.value.fullPath } })
return
}
visible.value = true
await loadLibrary()
}
async function loadLibrary() {
loading.value = true
try {
const data: any = await getMaterials({
keyword: keyword.value.trim() || undefined,
limit: 50,
})
materials.value = Array.isArray(data?.data) ? data.data : Array.isArray(data) ? data : []
} finally {
loading.value = false
}
}
function clearKeyword() {
keyword.value = ''
loadLibrary()
}
function addMaterial(item: any) {
const context = materialToContext(item)
if (props.modelValue.some(material => material.id === context.id)) {
ElMessage.info('该素材已在当前任务中')
return
}
emit('update:modelValue', [...props.modelValue, context])
ElMessage.success('已加入当前生成任务')
}
function removeMaterial(id: string) {
emit('update:modelValue', props.modelValue.filter(item => item.id !== id))
}
</script>
<style scoped>
.material-picker {
display: contents;
}
.material-btn {
height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
border: 1px solid #dbe8de;
border-radius: 18px;
background: #fff;
color: #31584d;
padding: 0 13px;
font-size: 13px;
font-weight: 800;
cursor: pointer;
}
.material-btn:hover {
border-color: #9ed0aa;
background: #f6fbf6;
color: #00543d;
}
.material-btn:disabled {
opacity: .55;
cursor: not-allowed;
}
.materials-row {
flex-basis: 100%;
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 4px;
}
.materials-row span {
max-width: 100%;
min-height: 30px;
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid #dbe8de;
border-radius: 15px;
background: #f6fbf6;
color: #31584d;
padding: 5px 8px 5px 11px;
font-size: 12px;
font-weight: 800;
}
.materials-row em {
color: #8a9992;
font-style: normal;
font-weight: 500;
}
.materials-row button {
border: none;
background: transparent;
color: #78908a;
cursor: pointer;
}
.material-library-toolbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 14px;
}
.library-search {
height: 36px;
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
border: 1px solid #dbe8de;
border-radius: 18px;
background: #f8faf8;
padding: 0 12px;
}
.library-search input {
flex: 1;
min-width: 0;
border: none;
outline: none;
background: transparent;
color: #173f35;
}
.library-search button {
border: none;
background: transparent;
color: #00724f;
font-weight: 800;
cursor: pointer;
}
.material-library-list {
min-height: 240px;
max-height: 520px;
overflow-y: auto;
display: grid;
gap: 10px;
}
.library-item {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 14px;
align-items: center;
border: 1px solid #dce8de;
border-radius: 12px;
background: #fff;
padding: 14px;
}
.library-item strong {
display: block;
color: #173f35;
font-size: 15px;
margin-bottom: 5px;
}
.library-item span {
display: block;
color: #6a7f77;
font-size: 12px;
margin-bottom: 8px;
}
.library-item p {
color: #51665f;
font-size: 13px;
line-height: 1.55;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.library-item button {
height: 34px;
border: none;
border-radius: 17px;
background: #00543d;
color: #fff;
padding: 0 15px;
font-size: 13px;
font-weight: 900;
cursor: pointer;
}
.library-empty {
min-height: 220px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
border: 1px dashed #cfe0d3;
border-radius: 12px;
color: #75867f;
}
.library-empty strong {
color: #173f35;
}
</style>
+158
View File
@@ -0,0 +1,158 @@
<template>
<li class="mind-node" :class="{ leaf: !hasChildren }">
<span class="connector" :style="{ borderColor: branchColor }" />
<div
class="node-card"
:class="[levelClass, { clickable: hasChildren }]"
:style="cardStyle"
@click="toggle"
>
<span class="node-dot" v-if="level > 1" :style="{ background: branchColor }" />
<span class="node-title">{{ node.title }}</span>
<el-icon v-if="hasChildren" class="toggle-icon" :class="{ collapsed: !expanded }">
<CaretBottom />
</el-icon>
</div>
<transition name="mm-expand">
<ul v-if="hasChildren && expanded" class="mind-children">
<MindNode
v-for="(child, i) in node.children"
:key="i"
:node="child"
:level="level + 1"
:branch-color="branchColor"
/>
</ul>
</transition>
</li>
</template>
<script setup lang="ts">
import { computed, ref, inject, watch } from 'vue'
import { CaretBottom } from '@element-plus/icons-vue'
import type { MindMapNode } from '@/api/mindmap'
const props = withDefaults(defineProps<{
node: MindMapNode
level?: number
branchColor?: string
}>(), {
level: 1,
branchColor: '#00a870',
})
defineOptions({ name: 'MindNode' })
const mm: any = inject('mmExpand', null)
const expanded = ref(true)
watch(() => mm?.expandVersion?.value, () => {
if (mm) expanded.value = mm.expandMode.value === 'open'
})
const hasChildren = computed(() => Array.isArray(props.node.children) && props.node.children.length > 0)
const levelClass = computed(() => `lvl-${Math.min(props.level, 3)}`)
const cardStyle = computed(() => {
if (props.level === 1) {
return {
background: props.branchColor + '14',
borderColor: props.branchColor,
color: '#1f2937',
}
}
return {}
})
function toggle() {
if (hasChildren.value) expanded.value = !expanded.value
}
</script>
<style scoped>
.mind-node {
display: flex;
align-items: center;
position: relative;
padding-left: 30px;
}
.connector {
position: absolute;
left: 0;
top: 50%;
width: 22px;
height: 0;
border-top: 2px solid #d1d5db;
border-radius: 2px;
}
.leaf .connector {
border-color: #e5e7eb;
}
.mind-children {
display: flex;
flex-direction: column;
gap: 10px;
list-style: none;
margin: 0;
padding: 10px 0 10px 8px;
}
.node-card {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 9px 16px;
border-radius: 10px;
border: 1.5px solid #e5e7eb;
background: #ffffff;
color: #1f2937;
font-size: 14px;
font-weight: 600;
white-space: nowrap;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
transition: transform 0.15s, box-shadow 0.15s;
user-select: none;
}
.node-card.clickable {
cursor: pointer;
}
.node-card.clickable:hover {
transform: translateX(3px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.node-dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
}
.node-title {
line-height: 1.4;
}
.toggle-icon {
font-size: 12px;
color: #9ca3af;
transition: transform 0.2s;
}
.toggle-icon.collapsed {
transform: rotate(-90deg);
}
.node-card.lvl-1 {
font-size: 15px;
font-weight: 700;
border-width: 2px;
}
.node-card.lvl-3 {
font-size: 13px;
font-weight: 500;
background: #fafafa;
}
.mm-expand-enter-active,
.mm-expand-leave-active {
transition: opacity 0.2s, transform 0.2s;
}
.mm-expand-enter-from,
.mm-expand-leave-to {
opacity: 0;
transform: translateX(-10px);
}
</style>
@@ -0,0 +1,306 @@
<template>
<el-dialog
v-model="visible"
:title="title"
width="92%"
top="3vh"
class="tpl-gallery-dialog"
:close-on-click-modal="false"
append-to-body
destroy-on-close
>
<div class="tg-layout">
<!-- Left: template list -->
<aside class="tg-sidebar">
<div class="tg-search">
<el-input v-model="keyword" placeholder="搜索模板" size="small" clearable>
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
</div>
<div class="tg-cat-row">
<button
v-for="c in categories"
:key="c.value"
class="tg-cat-chip"
:class="{ active: cat === c.value }"
@click="cat = c.value"
>
{{ c.label }}
</button>
</div>
<div class="tg-list">
<button
v-for="t in filteredTemplates"
:key="t.id"
class="tg-card"
:class="{ active: selected?.id === t.id }"
@click="selectTemplate(t)"
>
<div class="tg-card-icon" :style="{ background: t.accent }">
<el-icon :size="18" color="#fff"><component :is="t.icon" /></el-icon>
</div>
<div class="tg-card-body">
<div class="tg-card-name">{{ t.name }}</div>
<div class="tg-card-desc">{{ t.description }}</div>
</div>
</button>
<div v-if="!filteredTemplates.length" class="tg-empty">未找到匹配模板</div>
</div>
</aside>
<!-- Right: params + preview -->
<section class="tg-main" v-if="selected">
<header class="tg-main-head">
<div>
<h3>{{ selected.name }}</h3>
<p>{{ selected.description }}</p>
</div>
<span class="tg-cat-badge">{{ categoryLabelOf(selected) }}</span>
</header>
<div class="tg-params" v-if="selected.params.length">
<div class="tg-param" v-for="p in selected.params" :key="p.key">
<label>
{{ p.label }}
<span class="tg-param-val" v-if="p.type === 'number'">{{ params[p.key] }}</span>
</label>
<el-input-number
v-if="p.type === 'number'"
v-model="params[p.key]"
:min="p.min"
:max="p.max"
:step="p.step"
size="small"
@change="renderPreview"
/>
<el-select v-else-if="p.type === 'select'" v-model="params[p.key]" size="small" @change="renderPreview">
<el-option v-for="o in p.options" :key="String(o.value)" :label="o.label" :value="o.value" />
</el-select>
<el-input
v-else-if="p.type === 'text'"
v-model="params[p.key]"
type="textarea"
:rows="Math.min(6, String(p.default).split(/\n/).length + 1)"
size="small"
@input="renderPreview"
/>
<el-color-picker v-else-if="p.type === 'color'" v-model="params[p.key]" @change="renderPreview" />
</div>
</div>
<div class="tg-preview-wrap">
<div class="tg-preview-head">
<span>实时预览</span>
<button class="tg-refresh" @click="renderPreview" title="刷新预览">
<el-icon><Refresh /></el-icon> 刷新
</button>
</div>
<HtmlPreview :html="previewHtml" height="460px" />
</div>
<footer class="tg-actions">
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" @click="handleUse">
<el-icon><Check /></el-icon> 使用此模板
</el-button>
</footer>
</section>
<section class="tg-main tg-empty-main" v-else>
<el-icon :size="48" color="#cbd5e1"><Grid /></el-icon>
<p>从左侧选择一个模板开始</p>
</section>
</div>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { Search, Refresh, Check, Grid } from '@element-plus/icons-vue'
import HtmlPreview from '@/components/common/HtmlPreview.vue'
import {
animationTemplates,
defaultParams,
animationCategoryLabels,
type AnimationTemplate,
} from '@/utils/animationTemplates'
import {
gameTemplates,
defaultGameParams,
gameCategoryLabels,
type GameTemplate,
} from '@/utils/gameTemplates'
type AnyTemplate = (AnimationTemplate & { _kind: 'anim' }) | (GameTemplate & { _kind: 'game' })
const props = defineProps<{
modelValue: boolean
kind: 'animation' | 'game'
title?: string
}>()
const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void
(e: 'use', payload: { kind: 'animation' | 'game'; template: any; params: Record<string, any>; html: string; title: string }): void
}>()
const visible = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
})
const title = computed(() => props.title || (props.kind === 'animation' ? '教学动画模板库' : '教学游戏模板库'))
const allTemplates = computed<AnyTemplate[]>(() => {
if (props.kind === 'animation') {
return animationTemplates.map((t) => ({ ...t, _kind: 'anim' as const }))
}
return gameTemplates.map((t) => ({ ...t, _kind: 'game' as const }))
})
const categories = computed(() => {
if (props.kind === 'animation') {
return [
{ value: 'all', label: '全部' },
...Object.entries(animationCategoryLabels).map(([value, label]) => ({ value, label })),
]
}
return [
{ value: 'all', label: '全部' },
...Object.entries(gameCategoryLabels).map(([value, label]) => ({ value, label })),
]
})
const cat = ref('all')
const keyword = ref('')
const selected = ref<AnyTemplate | null>(null)
const params = ref<Record<string, any>>({})
const previewHtml = ref('')
const filteredTemplates = computed(() => {
const kw = keyword.value.trim().toLowerCase()
return allTemplates.value.filter((t) => {
if (cat.value !== 'all' && t.category !== cat.value) return false
if (!kw) return true
return t.name.toLowerCase().includes(kw) || t.description.toLowerCase().includes(kw)
})
})
function categoryLabelOf(t: AnyTemplate): string {
if (t._kind === 'anim') return animationCategoryLabels[t.category]
return gameCategoryLabels[t.category]
}
function selectTemplate(t: AnyTemplate) {
selected.value = t
params.value = t._kind === 'anim' ? defaultParams(t) : defaultGameParams(t)
renderPreview()
}
function renderPreview() {
if (!selected.value) return
previewHtml.value = selected.value.render(params.value)
}
watch(
() => props.modelValue,
(v) => {
if (v) {
cat.value = 'all'
keyword.value = ''
const first = allTemplates.value[0]
if (first) selectTemplate(first)
else {
selected.value = null
previewHtml.value = ''
}
}
},
)
function handleUse() {
if (!selected.value) return
const t = selected.value
const html = t.render(params.value)
const t2 = t as any
const generatedTitle = typeof t2.defaultTitle === 'function' ? t2.defaultTitle(params.value) : t.name
emit('use', {
kind: props.kind,
template: t,
params: { ...params.value },
html,
title: generatedTitle,
})
visible.value = false
}
</script>
<style scoped>
.tg-layout {
display: grid;
grid-template-columns: 280px 1fr;
gap: 16px;
height: calc(90vh - 110px);
min-height: 480px;
}
.tg-sidebar {
display: flex; flex-direction: column; gap: 10px;
border-right: 1px solid #e5e7eb; padding-right: 12px; overflow: hidden;
}
.tg-search :deep(.el-input) { width: 100%; }
.tg-cat-row { display: flex; flex-wrap: wrap; gap: 6px; }
.tg-cat-chip {
padding: 4px 10px; border-radius: 14px; border: 1px solid #e5e7eb;
background: #fff; color: #6b7280; font-size: 12px; cursor: pointer;
transition: all 0.15s;
}
.tg-cat-chip:hover { border-color: #6366f1; color: #6366f1; }
.tg-cat-chip.active { background: #4f46e5; color: #fff; border-color: #4f46e5; }
.tg-list { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 8px; padding-right: 4px; }
.tg-card {
display: flex; gap: 10px; align-items: flex-start;
padding: 10px; border: 1px solid #e5e7eb; border-radius: 10px;
background: #fff; cursor: pointer; text-align: left;
transition: all 0.15s;
}
.tg-card:hover { border-color: #c7d2fe; background: #f5f7ff; }
.tg-card.active { border-color: #4f46e5; background: #eef2ff; box-shadow: 0 2px 8px rgba(79,70,229,0.1); }
.tg-card-icon { width: 34px; height: 34px; border-radius: 8px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.tg-card-body { flex: 1; min-width: 0; }
.tg-card-name { font-size: 13px; font-weight: 600; color: #1f2937; }
.tg-card-desc { font-size: 11px; color: #6b7280; margin-top: 2px; line-height: 1.4; }
.tg-empty { padding: 24px; text-align: center; color: #9ca3af; font-size: 13px; }
.tg-main { display: flex; flex-direction: column; gap: 12px; overflow: hidden; }
.tg-empty-main { align-items: center; justify-content: center; color: #9ca3af; }
.tg-empty-main p { margin-top: 10px; font-size: 14px; }
.tg-main-head {
display: flex; justify-content: space-between; align-items: flex-start; gap: 12px;
padding-bottom: 10px; border-bottom: 1px solid #e5e7eb;
}
.tg-main-head h3 { font-size: 17px; font-weight: 700; color: #111827; }
.tg-main-head p { font-size: 12px; color: #6b7280; margin-top: 3px; }
.tg-cat-badge {
padding: 3px 10px; border-radius: 12px; background: #eef2ff; color: #4f46e5;
font-size: 11px; font-weight: 600; white-space: nowrap;
}
.tg-params {
display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 10px 16px; padding: 12px; background: #f9fafb; border-radius: 10px;
}
.tg-param { display: flex; flex-direction: column; gap: 4px; }
.tg-param label { font-size: 12px; color: #4b5563; font-weight: 500; display: flex; justify-content: space-between; align-items: center; }
.tg-param-val { color: #4f46e5; font-weight: 600; }
.tg-preview-wrap { flex: 1; display: flex; flex-direction: column; min-height: 0; }
.tg-preview-head {
display: flex; justify-content: space-between; align-items: center;
font-size: 13px; color: #6b7280; font-weight: 600; margin-bottom: 6px;
}
.tg-refresh {
display: inline-flex; align-items: center; gap: 4px; border: 1px solid #e5e7eb;
background: #fff; color: #6b7280; font-size: 12px; padding: 4px 10px;
border-radius: 6px; cursor: pointer; transition: all 0.15s;
}
.tg-refresh:hover { border-color: #6366f1; color: #6366f1; }
.tg-preview-wrap :deep(.html-preview) { flex: 1; border: 1px solid #e5e7eb; border-radius: 10px; overflow: hidden; }
.tg-actions { display: flex; justify-content: flex-end; gap: 10px; padding-top: 8px; border-top: 1px solid #e5e7eb; }
</style>
@@ -0,0 +1,387 @@
<template>
<div class="zj-header">
<div class="header-left">
<button class="collapse-btn" @click="$emit('toggle')" :title="isCollapse ? '展开侧边栏' : '收起侧边栏'">
<el-icon>
<Fold v-if="!isCollapse" />
<Expand v-else />
</el-icon>
</button>
<router-link to="/" class="back-square">
<el-icon><HomeFilled /></el-icon>
</router-link>
<span class="page-title">{{ currentTitle }}</span>
</div>
<form class="header-center" @submit.prevent="submitSearch">
<el-icon><Search /></el-icon>
<input v-model="searchText" placeholder="搜索资源、模板或输入知识点快速创作" />
<button v-if="searchText" type="button" title="清空搜索" @click="searchText = ''">×</button>
</form>
<div class="header-right">
<button class="ghost-btn" type="button" @click="router.push('/materials')">素材库</button>
<el-popover trigger="click" placement="bottom-end" :width="380" @show="onBellOpen" popper-class="notif-popover">
<template #reference>
<button class="bell-btn" type="button" title="通知">
<el-icon><Bell /></el-icon>
<span v-if="unreadCount > 0" class="bell-badge">{{ unreadCount > 99 ? '99+' : unreadCount }}</span>
</button>
</template>
<div class="notif-panel">
<div class="notif-header">
<span>通知</span>
<button v-if="unreadCount > 0" type="button" class="notif-read-all" @click="handleMarkAll">全部已读</button>
</div>
<div class="notif-list" v-if="notifications.length">
<div
v-for="n in notifications"
:key="n.id"
class="notif-item"
:class="{ unread: !n.is_read }"
@click="handleNotifClick(n)"
>
<span class="notif-dot" v-if="!n.is_read"></span>
<div class="notif-body">
<div class="notif-title">{{ n.title }}</div>
<div class="notif-time">{{ formatTime(n.created_at) }}</div>
</div>
</div>
</div>
<el-empty v-else description="暂无通知" :image-size="48" />
</div>
</el-popover>
<button class="try-btn" type="button" @click="router.push('/courseware/create')">立即体验</button>
<el-dropdown trigger="click" @command="handleCommand">
<button class="user-btn" type="button">
<span>{{ userStore.user?.name?.[0] || '未' }}</span>
</button>
<template #dropdown>
<el-dropdown-menu>
<template v-if="userStore.user">
<el-dropdown-item command="profile">个人资料</el-dropdown-item>
<el-dropdown-item command="works">我的作品</el-dropdown-item>
<el-dropdown-item command="materials">我的素材库</el-dropdown-item>
<el-dropdown-item command="resources">资源广场</el-dropdown-item>
<el-dropdown-item command="logout" divided>退出登录</el-dropdown-item>
</template>
<template v-else>
<el-dropdown-item command="login">登录</el-dropdown-item>
<el-dropdown-item command="register">注册</el-dropdown-item>
<el-dropdown-item command="resources" divided>先看资源</el-dropdown-item>
</template>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { Bell } from '@element-plus/icons-vue'
import { getNotifications, getUnreadCount, markRead, markAllRead, type NotificationItem } from '@/api/notification'
import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
defineProps<{ isCollapse: boolean }>()
defineEmits(['toggle'])
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const currentTitle = computed(() => (route.meta.title as string) || '创作广场')
const unreadCount = ref(0)
const notifications = ref<NotificationItem[]>([])
async function fetchUnread() {
if (!userStore.user) return
try {
const res: any = await getUnreadCount()
unreadCount.value = res.count || 0
} catch { /* ignore */ }
}
async function onBellOpen() {
if (!userStore.user) return
try {
const res: any = await getNotifications({ limit: 20 })
notifications.value = res || []
} catch { /* ignore */ }
}
async function handleMarkAll() {
await markAllRead()
notifications.value = notifications.value.map((n: NotificationItem) => ({ ...n, is_read: true }))
unreadCount.value = 0
}
async function handleNotifClick(n: NotificationItem) {
if (!n.is_read) {
await markRead(n.id).catch(() => undefined)
n.is_read = true
unreadCount.value = Math.max(0, unreadCount.value - 1)
}
if (n.link) router.push(n.link)
}
function formatTime(ts: string | null): string {
if (!ts) return ''
const d = new Date(ts)
if (isNaN(d.getTime())) return ''
const diff = Date.now() - d.getTime()
if (diff < 60000) return '刚刚'
if (diff < 3600000) return Math.floor(diff / 60000) + '分钟前'
if (diff < 86400000) return Math.floor(diff / 3600000) + '小时前'
return (d.getMonth() + 1) + '/' + d.getDate()
}
const searchText = ref('')
onMounted(() => {
fetchUnread()
setInterval(fetchUnread, 60000)
if (localStorage.getItem('access_token') && !userStore.user) {
userStore.fetchProfile()
}
})
function handleCommand(command: string) {
if (command === 'login') router.push('/login')
if (command === 'register') router.push({ path: '/login', query: { tab: 'register' } })
if (command === 'profile') router.push('/profile')
if (command === 'works') router.push('/courseware')
if (command === 'materials') router.push('/materials')
if (command === 'resources') router.push('/resources')
if (command === 'logout') {
userStore.clearToken()
router.push('/')
}
}
function submitSearch() {
const keyword = searchText.value.trim()
if (keyword) {
if (router.currentRoute.value.path === '/materials') {
router.push({ path: '/materials', query: { keyword } })
return
}
router.push({ path: '/search', query: { keyword } })
return
}
router.push('/search')
}
watch(() => route.query.keyword, (keyword) => {
searchText.value = typeof keyword === 'string' ? keyword : ''
}, { immediate: true })
</script>
<style scoped>
.zj-header {
width: 100%;
display: grid;
grid-template-columns: minmax(170px, 1fr) minmax(260px, 440px) minmax(150px, 1fr);
align-items: center;
gap: 16px;
}
.header-left,
.header-right {
display: flex;
align-items: center;
min-width: 0;
}
.header-left {
gap: 8px;
}
.header-right {
justify-content: flex-end;
gap: 10px;
}
.collapse-btn,
.back-square,
.user-btn {
width: 34px;
height: 34px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid #d6e4d8;
border-radius: 9px;
background: #fff;
color: #31584d;
cursor: pointer;
text-decoration: none;
}
.page-title {
color: #173f35;
font-size: 15px;
font-weight: 800;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.header-center {
height: 36px;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 8px;
border-radius: 19px;
background: #eef1ec;
color: #9aa7a0;
padding: 0 10px 0 15px;
min-width: 0;
}
.header-center input {
min-width: 0;
flex: 1;
border: none;
outline: none;
background: transparent;
color: #173f35;
font-size: 13px;
}
.header-center input::placeholder {
color: #9aa7a0;
}
.header-center button {
width: 20px;
height: 20px;
flex: 0 0 20px;
border: none;
border-radius: 50%;
background: #dce8de;
color: #51665f;
cursor: pointer;
line-height: 20px;
}
.header-center button:hover {
background: #cfe0d3;
color: #00543d;
}
.header-center span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
}
.try-btn {
height: 34px;
border: none;
border-radius: 18px;
background: #00543d;
color: #fff;
padding: 0 18px;
font-size: 14px;
font-weight: 800;
cursor: pointer;
}
.bell-btn {
position: relative;
width: 36px; height: 36px;
border: none; border-radius: 50%;
background: transparent;
cursor: pointer;
display: flex; align-items: center; justify-content: center;
color: #64748b;
transition: background .2s;
}
.bell-btn:hover { background: #f1f5f9; color: #4f46e5; }
.bell-badge {
position: absolute; top: 2px; right: 2px;
min-width: 16px; height: 16px;
background: #ef4444; color: #fff;
font-size: 10px; font-weight: 700;
border-radius: 8px; line-height: 16px;
text-align: center; padding: 0 4px;
}
.notif-panel { max-height: 420px; overflow-y: auto; }
.notif-header {
display: flex; justify-content: space-between; align-items: center;
padding: 4px 4px 12px; font-size: 15px; font-weight: 700;
}
.notif-read-all {
border: none; background: transparent;
color: #4f46e5; font-size: 12px; cursor: pointer;
}
.notif-read-all:hover { text-decoration: underline; }
.notif-list { display: flex; flex-direction: column; }
.notif-item {
display: flex; align-items: flex-start; gap: 8px;
padding: 10px 8px; border-radius: 8px;
cursor: pointer; transition: background .15s;
}
.notif-item:hover { background: #f8fafc; }
.notif-item.unread { background: #eef2ff; }
.notif-item.unread:hover { background: #e0e7ff; }
.notif-dot {
width: 8px; height: 8px; border-radius: 50%;
background: #4f46e5; flex-shrink: 0; margin-top: 5px;
}
.notif-body { flex: 1; min-width: 0; }
.notif-title { font-size: 13px; color: #1e293b; line-height: 1.5; }
.notif-time { font-size: 11px; color: #94a3b8; margin-top: 2px; }
.ghost-btn {
height: 34px;
border: 1px solid #d6e4d8;
border-radius: 18px;
background: #fff;
color: #31584d;
padding: 0 14px;
font-size: 13px;
font-weight: 800;
cursor: pointer;
}
.ghost-btn:hover {
border-color: #a8cfab;
background: #f6fbf6;
color: #00543d;
}
.user-btn span {
width: 24px;
height: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 7px;
background: #d9f0d6;
color: #00543d;
font-size: 12px;
font-weight: 800;
}
@media (max-width: 980px) {
.zj-header {
grid-template-columns: 1fr auto;
}
.header-center {
display: none;
}
}
@media (max-width: 720px) {
.ghost-btn {
display: none;
}
}
</style>
@@ -0,0 +1,92 @@
<template>
<router-view v-if="route.meta.fullPage" />
<el-container v-else class="app-layout">
<el-aside :width="isCollapse ? 'var(--sidebar-collapsed)' : 'var(--sidebar-width)'" class="sidebar" :class="{ collapsed: isCollapse }">
<AppSidebar :is-collapse="isCollapse" />
</el-aside>
<el-container class="main-container">
<el-header class="app-header-bar" height="60px">
<AppHeader :is-collapse="isCollapse" @toggle="isCollapse = !isCollapse" />
</el-header>
<el-main class="app-main">
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</el-main>
</el-container>
</el-container>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRoute } from 'vue-router'
import AppSidebar from './AppSidebar.vue'
import AppHeader from './AppHeader.vue'
const isCollapse = ref(false)
const route = useRoute()
</script>
<style scoped>
.app-layout {
height: 100vh;
min-width: 0;
overflow: hidden;
background: #f8faf7;
}
.sidebar {
background: #eef5ee;
border-right: 1px solid #cfded2;
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
z-index: 10;
flex-shrink: 0;
}
.app-layout:has(.sidebar.collapsed) {
--active-sidebar-width: var(--sidebar-collapsed);
}
.app-layout:not(:has(.sidebar.collapsed)) {
--active-sidebar-width: var(--sidebar-width);
}
.main-container {
height: 100vh;
min-width: 0;
flex-direction: column;
}
.app-header-bar {
background: #fff;
border-bottom: 1px solid #e8ede8;
padding: 0 24px;
display: flex;
align-items: center;
z-index: 5;
flex-shrink: 0;
}
.app-main {
position: relative;
flex: 1;
background: #f8faf7;
padding: 0;
overflow-y: auto;
overflow-x: hidden;
min-width: 0;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
@@ -0,0 +1,356 @@
<template>
<aside class="zj-sidebar" :class="{ compact: isCollapse }">
<div class="brand-row">
<router-link to="/" class="brand" :title="isCollapse ? '智教助手' : undefined">
<strong>{{ isCollapse ? '智' : '智教助手' }}</strong>
<sup v-if="!isCollapse">®</sup>
</router-link>
</div>
<button class="new-task" type="button" @click="goCreate('courseware')">
<el-icon><Plus /></el-icon>
<span v-if="!isCollapse">新建任务</span>
</button>
<nav class="nav-list">
<button
v-for="item in taskNav"
:key="item.mode"
type="button"
class="nav-item"
:class="{ active: isModeActive(item.mode) }"
@click="goCreate(item.mode)"
:title="isCollapse ? item.label : undefined"
>
<el-icon><component :is="item.icon" /></el-icon>
<span v-if="!isCollapse">{{ item.label }}</span>
</button>
</nav>
<nav class="nav-list secondary-nav">
<button
v-for="item in workspaceNav"
:key="item.path"
type="button"
class="nav-item"
:class="{ active: route.path === item.path }"
@click="router.push(item.path)"
:title="isCollapse ? item.label : undefined"
>
<el-icon><component :is="item.icon" /></el-icon>
<span v-if="!isCollapse">{{ item.label }}</span>
</button>
</nav>
<button v-if="!isCollapse" class="points-banner" type="button" @click="router.push('/profile')">
<span class="medal"><el-icon><Medal /></el-icon></span>
<strong>智教助手2.0 积分活动</strong>
</button>
<div class="sidebar-bottom">
<button class="score-pill" type="button" @click="router.push('/profile')" :title="pointsText">
<el-icon><Collection /></el-icon>
<span v-if="!isCollapse">{{ pointsText }}</span>
</button>
<button class="user-entry" type="button" @click="handleUserClick" :title="userStore.user?.name || '智教用户'">
<span class="avatar"></span>
<span v-if="!isCollapse">{{ userStore.user?.name || '智教用户' }}</span>
</button>
</div>
</aside>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
defineProps<{ isCollapse: boolean }>()
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const taskNav = [
{ mode: 'courseware', label: 'AI互动课件', icon: 'MagicStick' },
{ mode: 'animation', label: '教学动画', icon: 'Film' },
{ mode: 'exam', label: 'AI命题', icon: 'Finished' },
{ mode: 'exercise', label: 'AI组题', icon: 'List' },
{ mode: 'lesson', label: 'AI教案 · 大单元', icon: 'Tickets' },
{ mode: 'essay', label: '作文批改', icon: 'EditPen' },
{ mode: 'mindmap', label: 'AI思维导图', icon: 'Connection' },
{ mode: 'assistant', label: 'AI教学助手', icon: 'ChatLineRound' },
{ mode: 'analytics', label: '数据回收', icon: 'DataLine' },
]
const baseWorkspaceNav = [
{ path: '/courseware', label: '我的作品', icon: 'Collection' },
{ path: '/favorites', label: '我的收藏', icon: 'Star' },
{ path: '/materials', label: '我的素材库', icon: 'FolderOpened' },
{ path: '/resources', label: '资源广场', icon: 'Share' },
{ path: '/classroom', label: '课堂工具', icon: 'Monitor' },
{ path: '/community', label: '模板社区', icon: 'ChatDotRound' },
{ path: '/help', label: '使用帮助', icon: 'QuestionFilled' },
]
const workspaceNav = computed(() => {
const isAdmin = userStore.user?.role === 'admin'
return isAdmin ? [...baseWorkspaceNav, { path: '/admin', label: '管理后台', icon: 'Setting' }] : baseWorkspaceNav
})
const pointsText = computed(() => {
const raw = userStore.user?.credits ?? userStore.user?.points ?? userStore.user?.quota
return `剩余积分: ${raw ?? 130}`
})
onMounted(() => {
if (localStorage.getItem('access_token') && !userStore.user) {
userStore.fetchProfile().catch(() => undefined)
}
})
function isModeActive(mode: string) {
const pathMode: Record<string, string> = {
'/courseware/create': 'courseware',
'/animation': 'animation',
'/exam': 'exam',
'/exercise': 'exercise',
'/lesson-plan': 'lesson',
'/essay-grade': 'essay',
'/mindmap': 'mindmap',
'/assistant': 'assistant',
'/classroom': 'analytics',
}
if (pathMode[route.path]) return pathMode[route.path] === mode
if (route.path !== '/') return false
return (route.query.mode || 'courseware') === mode
}
function goCreate(mode: string) {
if (mode === 'animation') {
router.push('/animation')
return
}
if (mode === 'analytics') {
router.push('/classroom')
return
}
if (mode === 'essay') {
router.push('/essay-grade')
return
}
if (mode === 'mindmap') {
router.push('/mindmap')
return
}
if (mode === 'assistant') {
router.push('/assistant')
return
}
router.push({ path: '/', query: { mode } })
}
function handleUserClick() {
router.push(userStore.user ? '/profile' : '/login')
}
</script>
<style scoped>
.zj-sidebar {
height: 100%;
display: flex;
flex-direction: column;
padding: 15px 12px 24px;
background: #edf5ed;
color: #183f35;
overflow-x: hidden;
overflow-y: auto;
}
.brand-row {
height: 34px;
display: flex;
align-items: center;
margin-bottom: 11px;
}
.brand {
display: inline-flex;
align-items: flex-start;
gap: 2px;
color: #0f4f3e;
text-decoration: none;
}
.brand strong {
font-size: 22px;
line-height: 1;
font-weight: 900;
}
.brand sup {
color: #345b50;
font-size: 9px;
line-height: 1;
}
.compact .brand {
width: 100%;
justify-content: center;
}
.compact .brand strong {
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 9px;
background: #d9f0d6;
}
.new-task {
height: 40px;
width: 100%;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
border: 1px solid #a8cfab;
border-radius: 8px;
background: #d9f0d6;
color: #0f4f3e;
font-size: 14px;
font-weight: 800;
cursor: pointer;
margin: 10px 0 10px;
}
.nav-list {
display: grid;
gap: 4px;
}
.secondary-nav {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #d6e4d8;
}
.nav-item {
height: 40px;
display: flex;
align-items: center;
gap: 10px;
border: none;
border-radius: 7px;
background: transparent;
color: #173f35;
padding: 0 16px;
font-size: 14px;
font-weight: 800;
cursor: pointer;
text-align: left;
}
.compact .nav-item {
justify-content: center;
padding: 0;
}
.nav-item:hover,
.nav-item.active {
background: #fff;
color: #00724f;
}
.points-banner {
height: 39px;
display: flex;
align-items: center;
gap: 8px;
border: 1px solid rgba(38, 206, 85, .5);
border-radius: 7px;
background:
radial-gradient(circle at 23% 52%, rgba(145, 245, 121, .6), transparent 24%),
linear-gradient(100deg, #13cb3c, #003b24 78%);
color: #dfffbc;
padding: 0 25px;
margin-top: 22px;
cursor: pointer;
box-shadow: inset 0 0 13px rgba(255,255,255,.22);
}
.medal {
width: 23px;
height: 23px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: #fff;
color: #00a845;
flex-shrink: 0;
}
.points-banner strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
}
.sidebar-bottom {
margin-top: auto;
display: grid;
gap: 14px;
}
.score-pill,
.user-entry {
min-width: 0;
height: 29px;
display: inline-flex;
align-items: center;
gap: 7px;
border: none;
background: transparent;
color: #173f35;
cursor: pointer;
}
.score-pill {
width: fit-content;
border: 1px solid #b9d8c1;
border-radius: 6px;
background: #f5fbf5;
padding: 0 10px;
color: #0f4f3e;
font-size: 13px;
}
.compact .score-pill {
width: 100%;
justify-content: center;
}
.user-entry {
font-size: 14px;
font-weight: 700;
}
.compact .user-entry {
justify-content: center;
}
.avatar {
width: 30px;
height: 30px;
flex: 0 0 30px;
border-radius: 8px;
background:
linear-gradient(135deg, rgba(78, 116, 255, .65), rgba(24, 68, 57, .6)),
url('/favicon.svg') center/20px 20px no-repeat,
#26446a;
}
</style>
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
+165
View File
@@ -0,0 +1,165 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import {
ArrowLeft,
ArrowRight,
ChatDotRound,
Check,
Collection,
CopyDocument,
DataLine,
Delete,
Download,
Document,
Edit,
EditPen,
Expand,
Film,
Finished,
Flag,
Fold,
FolderOpened,
FullScreen,
HomeFilled,
InfoFilled,
Iphone,
List,
Lock,
MagicStick,
Medal,
Microphone,
Monitor,
MoreFilled,
Paperclip,
Camera,
Picture,
Plus,
PriceTag,
Refresh,
Search,
Share,
Star,
Sunrise,
Tickets,
Top,
Trophy,
Upload,
UploadFilled,
User,
VideoPlay,
View,
Warning,
Aim,
Clock,
Cloudy,
Coin,
Connection,
Cpu,
Grid,
Lightning,
Moon,
RefreshRight,
Sort,
Sunny,
TrendCharts,
TrophyBase,
ChatLineRound,
Setting,
PieChart,
Brush,
Histogram,
Key,
Link,
Position,
Promotion,
Timer,
CircleCheck,
} from '@element-plus/icons-vue'
import App from './App.vue'
import router from './router'
import './styles/global.css'
const app = createApp(App)
const icons = {
ArrowLeft,
ArrowRight,
ChatDotRound,
Check,
Collection,
CopyDocument,
DataLine,
Delete,
Download,
Document,
Edit,
EditPen,
Expand,
Film,
Finished,
Flag,
Fold,
FolderOpened,
FullScreen,
HomeFilled,
InfoFilled,
Iphone,
List,
Lock,
MagicStick,
Medal,
Microphone,
Monitor,
MoreFilled,
Paperclip,
Camera,
Picture,
Plus,
PriceTag,
Refresh,
Search,
Share,
Star,
Sunrise,
Tickets,
Top,
Trophy,
Upload,
UploadFilled,
User,
VideoPlay,
View,
Warning,
Aim,
Clock,
Cloudy,
Coin,
Connection,
Cpu,
Grid,
Lightning,
Moon,
RefreshRight,
Sort,
Sunny,
TrendCharts,
TrophyBase,
ChatLineRound,
Setting,
PieChart,
Brush,
Histogram,
Key,
Link,
Position,
Promotion,
Timer,
CircleCheck,
}
for (const [name, component] of Object.entries(icons)) {
app.component(name, component)
}
app.use(createPinia())
app.use(router)
+99
View File
@@ -0,0 +1,99 @@
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/login',
name: 'Login',
component: () => import('@/views/Login.vue'),
meta: { title: '登录' },
},
{
path: '/preview/:id?',
name: 'CoursewarePreview',
component: () => import('@/views/CoursewarePreview.vue'),
meta: { title: '课件预览' },
},
{
path: '/preview/share/:token',
name: 'CoursewareSharedPreview',
component: () => import('@/views/CoursewarePreview.vue'),
meta: { title: '课件分享', public: true },
},
{
path: '/demo/:id',
name: 'DemoResource',
component: () => import('@/views/DemoResource.vue'),
meta: { title: '资源演示', public: true },
},
{
path: '/classroom/join/:id',
name: 'ClassroomJoin',
component: () => import('@/views/ClassroomJoin.vue'),
meta: { title: '课堂数据回收', public: true },
},
{
path: '/classroom/join',
name: 'ClassroomJoinCode',
component: () => import('@/views/ClassroomJoin.vue'),
meta: { title: '课堂数据回收', public: true },
},
{
path: '/',
component: () => import('@/components/layout/AppLayout.vue'),
children: [
{ path: '', name: 'Home', component: () => import('@/views/Home.vue'), meta: { title: '首页', public: true } },
{ path: 'favorites', name: 'Favorites', component: () => import('@/views/Favorites.vue'), meta: { title: '我的收藏' } },
{ path: 'courseware', name: 'CoursewareList', component: () => import('@/views/CoursewareList.vue'), meta: { title: '我的作品' } },
{ path: 'profile', name: 'Profile', component: () => import('@/views/Profile.vue'), meta: { title: '个人资料' } },
{ path: 'materials', name: 'Materials', component: () => import('@/views/Materials.vue'), meta: { title: '我的素材库' } },
{ path: 'search', name: 'Search', component: () => import('@/views/Search.vue'), meta: { title: '全局搜索', public: true } },
{ path: 'courseware/create', name: 'CoursewareCreate', component: () => import('@/views/CoursewareCreate.vue'), meta: { title: 'AI互动课件' } },
{ path: 'courseware/:id', name: 'CoursewareDetail', component: () => import('@/views/CoursewareDetail.vue'), meta: { title: '课件详情' } },
{ path: 'animation', name: 'Animation', component: () => import('@/views/Animation.vue'), meta: { title: '教学动画' } },
{ path: 'exercise', name: 'Exercise', component: () => import('@/views/Exercise.vue'), meta: { title: 'AI组题' } },
{ path: 'lesson-plan', name: 'LessonPlan', component: () => import('@/views/LessonPlan.vue'), meta: { title: 'AI教案 · 大单元' } },
{ path: 'essay-grade', name: 'EssayGrade', component: () => import('@/views/EssayGrade.vue'), meta: { title: '作文批改' } },
{ path: 'mindmap', name: 'MindMap', component: () => import('@/views/MindMap.vue'), meta: { title: 'AI思维导图' } },
{ path: 'assistant', name: 'Assistant', component: () => import('@/views/Assistant.vue'), meta: { title: 'AI教学助手' } },
{ path: 'exam', name: 'Exam', component: () => import('@/views/Exam.vue'), meta: { title: 'AI命题' } },
{ path: 'classroom', name: 'Classroom', component: () => import('@/views/Classroom.vue'), meta: { title: '课堂工具' } },
{ path: 'resources', name: 'Resources', component: () => import('@/views/Resources.vue'), meta: { title: '资源广场', public: true } },
{ path: 'resources/:id', name: 'ResourceDetail', component: () => import('@/views/ResourceDetail.vue'), meta: { title: '资源详情', public: true } },
{ path: 'community', name: 'Community', component: () => import('@/views/Community.vue'), meta: { title: '模板社区', public: true } },
{ path: 'community/:id', name: 'CommunityDetail', component: () => import('@/views/CommunityDetail.vue'), meta: { title: '模板详情', public: true } },
{ path: 'help', name: 'Help', component: () => import('@/views/Help.vue'), meta: { title: '使用帮助', public: true } },
{ path: 'admin', name: 'Admin', component: () => import('@/views/Admin.vue'), meta: { title: '管理后台' } },
{ path: ':pathMatch(.*)*', name: 'NotFound', component: () => import('@/views/NotFound.vue'), meta: { title: '页面未找到', public: true } },
],
},
{
path: '/:pathMatch(.*)*',
name: 'RootNotFound',
component: () => import('@/views/NotFound.vue'),
meta: { title: '页面未找到', public: true },
},
],
})
router.beforeEach((to, _from, next) => {
const token = localStorage.getItem('access_token')
const publicPaths = ['/login', '/preview']
const isPublic = Boolean(to.meta.public) || publicPaths.some(p => to.path.startsWith(p))
document.title = `${to.meta.title || '智教助手'} - 智教助手`
if (!isPublic && !token) {
next({ path: '/login', query: { redirect: to.fullPath } })
return
}
if (to.path === '/login' && token) {
next('/')
return
}
next()
})
export default router
+71
View File
@@ -0,0 +1,71 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { getProfile } from '@/api'
export const useUserStore = defineStore('user', () => {
const user = ref<any>(null)
const token = ref(localStorage.getItem('access_token') || '')
function setToken(newToken: string) {
if (!newToken) return
token.value = newToken
localStorage.setItem('access_token', newToken)
}
function setTokens(accessToken: string, refreshToken?: string) {
if (!accessToken) return
token.value = accessToken
localStorage.setItem('access_token', accessToken)
if (refreshToken) {
localStorage.setItem('refresh_token', refreshToken)
}
}
function getRefreshToken(): string {
return localStorage.getItem('refresh_token') || ''
}
function clearToken() {
token.value = ''
user.value = null
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
}
async function fetchProfile() {
if (!token.value && localStorage.getItem('access_token')) {
token.value = localStorage.getItem('access_token') || ''
}
if (!token.value) {
user.value = null
return null
}
try {
user.value = await getProfile()
return user.value
} catch (error) {
clearToken()
throw error
}
}
async function refreshCreditsFromResponse(response: any) {
const credits = response?.credits
const balance = typeof credits?.balance === 'number' ? credits.balance : undefined
if (typeof balance !== 'number') return
if (!user.value) {
await fetchProfile().catch(() => undefined)
}
user.value = {
...(user.value || {}),
credits: balance,
total_credits_used: typeof credits?.cost === 'number'
? Number(user.value?.total_credits_used || 0) + credits.cost
: user.value?.total_credits_used,
}
}
const isLoggedIn = () => !!token.value
return { user, token, setToken, setTokens, getRefreshToken, clearToken, fetchProfile, refreshCreditsFromResponse, isLoggedIn }
})
+403
View File
@@ -0,0 +1,403 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body, #app {
height: 100%;
font-family: 'PingFang SC', -apple-system, BlinkMacSystemFont, 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
:root {
--primary: #00543D;
--primary-light: #98D997;
--primary-dark: #004532;
--primary-bg: #E8F5EA;
--accent: #2563EB;
--warning: #F59E0B;
--ink: #14213D;
--gradient: linear-gradient(135deg, #0F766E 0%, #14B8A6 52%, #2DD4BF 100%);
--gradient-blue: linear-gradient(135deg, #2563EB 0%, #06B6D4 100%);
--gradient-green: linear-gradient(135deg, #0F766E 0%, #22C55E 100%);
--gradient-orange: linear-gradient(135deg, #F59E0B 0%, #F97316 100%);
--gradient-red: linear-gradient(135deg, #DC2626 0%, #FB7185 100%);
--gradient-pink: linear-gradient(135deg, #DB2777 0%, #F472B6 100%);
--gradient-cyan: linear-gradient(135deg, #0891B2 0%, #22D3EE 100%);
--text-primary: #111827;
--text-secondary: #4B5563;
--text-muted: #8A94A6;
--border-color: #DCE8DE;
--bg-color: #F7FAF6;
--bg-white: #FFFFFF;
--sidebar-width: 252px;
--sidebar-collapsed: 82px;
--header-height: 58px;
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 12px;
--radius-xl: 16px;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.07), 0 2px 4px -2px rgba(0, 0, 0, 0.05);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.08), 0 4px 6px -4px rgba(0, 0, 0, 0.04);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.06);
}
.page-container {
width: 100%;
max-width: 1480px;
margin: 0 auto;
padding: 22px 32px 28px;
background: var(--bg-color);
min-height: calc(100vh - var(--header-height));
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
margin-bottom: 28px;
}
.page-header h2 {
font-size: 25px;
font-weight: 900;
color: var(--primary);
}
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 16px;
}
.gen-input-section,
.guide-card,
.question-card,
.phase-content,
.resource-card,
.post-card {
border-radius: 14px !important;
border-color: #dce8de !important;
box-shadow: none !important;
}
.guide-cards {
display: grid !important;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)) !important;
gap: 12px !important;
}
.settings-row,
.settings-grid,
.filter-bar {
flex-wrap: wrap;
}
.settings-grid {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)) !important;
}
.gen-hero {
max-width: 1120px;
margin: 0 auto 18px !important;
align-items: center !important;
}
.gen-hero-icon {
width: 42px !important;
height: 42px !important;
border-radius: 50% !important;
background: #fff !important;
border: 1px solid #d6e4d8 !important;
color: var(--primary) !important;
}
.gen-hero-icon .el-icon,
.gen-hero-icon svg {
color: var(--primary) !important;
}
.gen-hero h2 {
font-size: 25px !important;
color: var(--primary) !important;
font-weight: 900 !important;
}
.gen-hero p {
color: #75867f !important;
font-size: 14px !important;
}
.gen-input-section {
max-width: 1120px;
margin: 0 auto 22px !important;
background: rgba(255,255,255,0.86) !important;
border: 1px solid #cfe0d3 !important;
border-radius: 18px !important;
box-shadow: 0 18px 42px rgba(17, 69, 52, 0.07) !important;
}
.prompt-box :deep(.el-textarea__inner),
.input-area :deep(.el-textarea__inner),
.essay-input :deep(.el-textarea__inner),
.setting-full :deep(.el-textarea__inner) {
background: #fff !important;
border: 1px solid #dce8de !important;
border-radius: 14px !important;
color: #173f35 !important;
}
.prompt-tag,
.sug-tag,
.type-check,
.hot-tag {
border-radius: 18px !important;
background: #fff !important;
border: 1px solid #dce8de !important;
color: #51665f !important;
}
.prompt-tag:hover,
.sug-tag:hover,
.type-check.checked,
.hot-tag:hover {
background: #e8f5ea !important;
border-color: #a8cfab !important;
color: var(--primary) !important;
}
.gen-btn,
.generate-btn,
.create-btn,
.upload-btn,
.grade-btn,
.save-btn,
.action-btn.primary {
background: #00543d !important;
border-radius: 13px !important;
box-shadow: none !important;
}
.guide-section,
.result-section,
.essay-input-panel,
.essay-result-panel,
.info-tags,
.homework-section,
.filter-bar,
.community-layout,
.content-area {
max-width: 1120px;
margin-left: auto;
margin-right: auto;
}
.guide-card,
.guide-item,
.question-card,
.phase-content,
.resource-card,
.post-card,
.cw-card,
.input-card,
.result-panel,
.essay-input-panel,
.essay-result-panel,
.hot-tags-card {
border: 1px solid #dce8de !important;
border-radius: 14px !important;
box-shadow: none !important;
}
.cw-cover,
.template-cover,
.card-thumb {
border-radius: 10px;
margin: 5px;
}
.el-input__wrapper,
.el-textarea__inner,
.el-select__wrapper {
box-shadow: none !important;
border: 1px solid #dce8de !important;
background: #fff !important;
}
@media (max-width: 760px) {
.page-container {
padding: 14px;
}
.page-header {
align-items: flex-start;
flex-direction: column;
margin-bottom: 18px;
}
.prompt-footer,
.result-header,
.header-actions {
align-items: stretch !important;
flex-direction: column !important;
}
}
/* Element Plus overrides */
.el-card {
border-radius: var(--radius-lg) !important;
border: 1px solid var(--border-color) !important;
box-shadow: var(--shadow-sm) !important;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;
}
.el-card:hover {
box-shadow: var(--shadow-lg) !important;
transform: translateY(-2px);
}
.el-button--primary {
background: var(--gradient) !important;
border: none !important;
border-radius: var(--radius-sm) !important;
font-weight: 500 !important;
}
.el-button--primary:hover {
opacity: 0.9;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(79, 70, 229, 0.4) !important;
}
.el-input__wrapper,
.el-textarea__inner,
.el-select__wrapper {
border-radius: var(--radius-sm) !important;
}
.el-form-item__label {
font-weight: 500 !important;
color: var(--text-secondary) !important;
}
/* Dialog */
.el-dialog {
border-radius: var(--radius-lg) !important;
overflow: hidden;
}
.el-dialog__header {
border-bottom: 1px solid var(--border-color);
padding: 20px 24px !important;
margin-right: 0 !important;
}
.el-dialog__title {
font-weight: 600 !important;
font-size: 17px !important;
color: var(--text-primary) !important;
}
.el-dialog__body {
padding: 24px !important;
}
.el-dialog__footer {
border-top: 1px solid var(--border-color);
padding: 16px 24px !important;
}
/* Tag */
.el-tag {
border-radius: 6px !important;
}
/* Dropdown */
.el-dropdown-menu__item {
border-radius: 6px !important;
margin: 2px 6px !important;
padding: 8px 16px !important;
}
/* Message */
.el-message {
border-radius: var(--radius-sm) !important;
box-shadow: var(--shadow-lg) !important;
}
/* Scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #CBD5E1;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #94A3B8;
}
/* Skeleton loading */
.skeleton {
background: linear-gradient(90deg, #F1F5F9 25%, #E2E8F0 50%, #F1F5F9 75%);
background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite;
border-radius: var(--radius-sm);
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* Animations */
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideInLeft {
from { opacity: 0; transform: translateX(-20px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes float {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-20px); }
}
@keyframes floatSlow {
0%, 100% { transform: translateY(0px) rotate(0deg); }
50% { transform: translateY(-15px) rotate(3deg); }
}
@keyframes pulse {
0%, 100% { opacity: 0.6; }
50% { opacity: 1; }
}
@keyframes bounce {
0%, 80%, 100% { transform: scale(0); }
40% { transform: scale(1); }
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Utility classes */
.fade-in-up {
animation: fadeInUp 0.5s ease both;
}
/* Selection */
::selection {
background: var(--primary-bg);
color: var(--primary);
}
File diff suppressed because it is too large Load Diff
+131
View File
@@ -0,0 +1,131 @@
// 课件主题模板 —— 通过注入 CSS 变量与背景为每一页换肤
// 每个主题返回一段可插入到页面 HTML 开头的 <style> 片段
// 换肤 = 移除旧主题 style + 插入新主题 style
export interface CoursewareTheme {
id: string
name: string
description: string
swatch: string // CSS background for preview chip
accent: string // accent color for UI
// 返回注入到页面 HTML 开头的 <style> 片段
styleBlock: () => string
}
const THEME_MARKER = '/* @courseware-theme:'
const THEME_END = ' */'
function themeOpen(id: string) {
return THEME_MARKER + id + THEME_END
}
// 检测页面是否已经是完整 HTML 文档(含 <html>)
function isFullDoc(html: string): boolean {
return /<html[s>]/i.test(html)
}
// 移除已存在的主题 style 块(以 THEME_MARKER 开始的 <style>...</style>)
function stripTheme(html: string): string {
const re = new RegExp('<style>[\\s\\S]*?' + THEME_MARKER.replace(/[/*]/g, '\\$&') + '[\\s\\S]*?<\\/style>\\s*', 'i')
let out = html.replace(re, '')
// 二次清理:可能存在仅有标记无 style 包裹的残留
const re2 = new RegExp('<!--' + THEME_MARKER.replace(/[/*]/g, '\\$&') + '[\\s\\S]*?-->\\s*', 'i')
out = out.replace(re2, '')
return out
}
/** 应用主题到单页 HTML */
export function applyThemeToPage(html: string, theme: CoursewareTheme | null): string {
let base = stripTheme(html || '')
if (!theme) return base
const marker = '<!-- ' + themeOpen(theme.id) + ' -->'
if (isFullDoc(base)) {
// 完整文档:在 </head> 前注入
if (/<\/head>/i.test(base)) {
return base.replace(/<\/head>/i, '<style>' + theme.styleBlock() + '</style></head>')
}
return base
}
// 片段:直接前置
return marker + '<style>' + theme.styleBlock() + '</style>' + base
}
/** 检测页面当前主题 id */
export function detectPageTheme(html: string): string | null {
if (!html) return null
const m = html.match(new RegExp(THEME_MARKER.replace(/[/*]/g, '\\$&') + '(\\w+)'))
return m ? m[1] : null
}
export const coursewareThemes: CoursewareTheme[] = [
{
id: 'clean',
name: '简洁明亮',
description: '干净的白色背景,深色正文',
swatch: 'linear-gradient(135deg,#ffffff,#f8fafc)',
accent: '#4f46e5',
styleBlock: () => `:root{--tk-bg:#ffffff;--tk-fg:#1f2937;--tk-muted:#6b7280;--tk-accent:#4f46e5;}
html,body{background:var(--tk-bg)!important;color:var(--tk-fg)!important;}
body *{color:inherit;}
h1,h2,h3,h4{color:var(--tk-fg)!important;}`,
},
{
id: 'warm',
name: '暖橙课堂',
description: '暖色调,适合低年级与人文课堂',
swatch: 'linear-gradient(135deg,#fff7ed,#fed7aa)',
accent: '#ea580c',
styleBlock: () => `:root{--tk-bg:#fff7ed;--tk-fg:#7c2d12;--tk-muted:#9a3412;--tk-accent:#ea580c;}
html,body{background:var(--tk-bg)!important;color:var(--tk-fg)!important;}
body{background-image:radial-gradient(circle at 90% 10%,rgba(251,191,36,0.15),transparent 40%)!important;}
h1,h2,h3,h4{color:var(--tk-accent)!important;}`,
},
{
id: 'ocean',
name: '海洋蓝',
description: '冷静的蓝色系,适合理科与综合课程',
swatch: 'linear-gradient(135deg,#eff6ff,#bfdbfe)',
accent: '#2563eb',
styleBlock: () => `:root{--tk-bg:#eff6ff;--tk-fg:#1e3a8a;--tk-muted:#1e40af;--tk-accent:#2563eb;}
html,body{background:var(--tk-bg)!important;color:var(--tk-fg)!important;}
body{background-image:linear-gradient(135deg,rgba(59,130,246,0.08),rgba(14,165,233,0.05))!important;}
h1,h2,h3,h4{color:var(--tk-accent)!important;}`,
},
{
id: 'forest',
name: '森林绿',
description: '自然绿色调,适合生物/地理/环保主题',
swatch: 'linear-gradient(135deg,#f0fdf4,#bbf7d0)',
accent: '#059669',
styleBlock: () => `:root{--tk-bg:#f0fdf4;--tk-fg:#14532d;--tk-muted:#166534;--tk-accent:#059669;}
html,body{background:var(--tk-bg)!important;color:var(--tk-fg)!important;}
body{background-image:radial-gradient(circle at 10% 90%,rgba(34,197,94,0.12),transparent 40%)!important;}
h1,h2,h3,h4{color:var(--tk-accent)!important;}`,
},
{
id: 'dark',
name: '深夜模式',
description: '深色背景,高对比投影展示',
swatch: 'linear-gradient(135deg,#1e293b,#0f172a)',
accent: '#a78bfa',
styleBlock: () => `:root{--tk-bg:#0f172a;--tk-fg:#f1f5f9;--tk-muted:#cbd5e1;--tk-accent:#a78bfa;}
html,body{background:var(--tk-bg)!important;color:var(--tk-fg)!important;}
body{background-image:radial-gradient(circle at 80% 20%,rgba(167,139,250,0.15),transparent 50%)!important;}
h1,h2,h3,h4{color:var(--tk-accent)!important;}`,
},
{
id: 'sunset',
name: '日落渐变',
description: '粉紫渐变,适合艺术与展示型课件',
swatch: 'linear-gradient(135deg,#fdf2f8,#fbcfe8)',
accent: '#db2777',
styleBlock: () => `:root{--tk-bg:#fdf2f8;--tk-fg:#831843;--tk-muted:#9d174d;--tk-accent:#db2777;}
html,body{background:var(--tk-bg)!important;color:var(--tk-fg)!important;}
body{background-image:linear-gradient(135deg,rgba(244,114,182,0.18),rgba(168,85,247,0.1))!important;}
h1,h2,h3,h4{color:var(--tk-accent)!important;}`,
},
]
export function getTheme(id: string): CoursewareTheme | undefined {
return coursewareThemes.find((t) => t.id === id)
}
+387
View File
@@ -0,0 +1,387 @@
import { createAnimation } from '@/api/animation'
import { createCourseware } from '@/api/courseware'
import { createExam } from '@/api/exam'
import { createExercise } from '@/api/exercise'
import { createLessonPlan } from '@/api/lessonPlan'
type DemoDraftType = 'courseware' | 'animation' | 'exercise' | 'lesson_plan' | 'exam'
type DemoDraftSpec = {
type: DemoDraftType
title: string
subject: string
grade: string
description: string
tags: string[]
payload: () => any
}
export type DemoDraftResult = {
type: DemoDraftType
draft: any
openPath: string
}
const aliases: Record<string, string> = {
'demo-cyl': 'cylinder',
cylinder: 'cylinder',
'demo-guide': 'guide',
guide: 'guide',
'demo-tips': 'tips',
tips: 'tips',
'demo-data': 'data',
data: 'data',
'demo-food': 'food',
food: 'food',
'english-food': 'food',
'demo-case': 'case',
case: 'case',
'demo-creator': 'creator',
creator: 'creator',
'demo-ai': 'vision',
vision: 'vision',
'demo-exam': 'exam',
exam: 'exam',
}
const specs: Record<string, DemoDraftSpec> = {
cylinder: {
type: 'courseware',
title: '小学数学六年级圆柱的体积(示例改编)',
subject: '数学',
grade: '六年级下册',
description: '围绕圆柱体积公式推导组织课堂,包含旧知回顾、操作探究、公式推导、巩固练习和拓展应用。',
tags: ['六年级', '圆柱体积', '公式推导'],
payload: () => coursewarePayload('小学数学六年级圆柱的体积(示例改编)', '数学', '六年级下册', [
['封面', '小学数学六年级圆柱的体积', '从已有体积经验出发,理解圆柱体积公式。', 'title'],
['知识回顾', '长方体、正方体体积公式', '复习 V = 底面积 x 高,为转化思想做准备。', 'content'],
['操作探究', '把圆柱切拼成近似长方体', '观察底面积不变、高不变,体积也不变。', 'interaction'],
['公式推导', 'V = S x h', '用底面积乘高计算圆柱体积,并解释每个量的意义。', 'summary'],
['巩固练习', '分层计算与应用', '完成基础计算、逆向求高和生活情境题。', 'exercise'],
], ['六年级', '圆柱体积', '公式推导']),
},
guide: {
type: 'courseware',
title: '智教助手新手指引(示例改编)',
subject: '课堂工具',
grade: '通用',
description: '展示从输入需求、上传材料到生成、预览、改编和发布的完整创作流程。',
tags: ['快速入门', '生成课件', '发布资源'],
payload: () => coursewarePayload('智教助手新手指引(示例改编)', '课堂工具', '通用', [
['封面', '智教助手新手指引', '用一个主题完成从生成到发布的完整流程。', 'title'],
['输入教学需求', '描述课题、学段和课堂目标', '把教材版本、知识点、互动要求写清楚。', 'content'],
['选择生成类型', '课件、动画、练习、教案、试卷', '按课堂任务选择最合适的 AI 工具。', 'interaction'],
['预览与微调', '检查页面结构与教学节奏', '根据课堂实际调整标题、图片、互动和练习。', 'content'],
['发布与改编', '沉淀为可复用资源', '发布到资源广场后支持收藏、下载和二次改编。', 'summary'],
], ['快速入门', '生成课件', '发布资源']),
},
tips: {
type: 'courseware',
title: '玩转互动课件必看小技巧(示例改编)',
subject: '课堂工具',
grade: '通用',
description: '整理课件编辑、图片替换、动画插入和课堂展示的高频技巧。',
tags: ['互动课件', '图片替换', '课堂展示'],
payload: () => coursewarePayload('玩转互动课件必看小技巧(示例改编)', '课堂工具', '通用', [
['封面', '互动课件小技巧', '快速掌握高频编辑与课堂展示方法。', 'title'],
['替换图片素材', '选中图片后替换或调整大小', '保持图片和文字的教学信息一致。', 'content'],
['插入动画片段', '把动画放到关键解释处', '用动画承接抽象概念和课堂提问。', 'interaction'],
['复用优秀结构', '从热门资源改编为原创课件', '保留结构,替换主题、例题和教师引导语。', 'summary'],
], ['互动课件', '图片替换', '课堂展示']),
},
data: {
type: 'animation',
title: '课堂数据回收新手指引(示例改编)',
subject: '课堂工具',
grade: '通用',
description: '演示问答收集、投票统计和学生反馈汇总流程。',
tags: ['数据回收', '问答收集', '课堂反馈'],
payload: () => ({
title: '课堂数据回收新手指引(示例改编)',
anim_type: 'general',
description: '演示问答收集、投票统计和学生反馈汇总流程。',
config: {
title: '课堂数据回收新手指引',
description: '从创建任务到统计反馈的课堂闭环。',
html: animationHtml('课堂数据回收', ['创建收集任务', '学生扫码提交', '实时统计分布', '生成复盘建议'], '#063a2d', '#0f8a5f'),
scenes: [
{ id: 1, name: '创建任务', duration: 8, narration: '选择问答、投票或打卡模板。' },
{ id: 2, name: '学生提交', duration: 10, narration: '学生扫码后实时提交答案。' },
{ id: 3, name: '数据统计', duration: 12, narration: '教师端显示选项分布和关键词。' },
{ id: 4, name: '复盘建议', duration: 8, narration: '根据数据生成讲评重点。' },
],
totalDuration: 38,
interactive: true,
},
}),
},
food: {
type: 'exercise',
title: 'Unit4 Healthy Food 第一课时(示例改编)',
subject: '英语',
grade: '七年级',
description: '围绕健康饮食主题组织单词认读、句型表达、听说操练和闯关反馈。',
tags: ['Healthy Food', '词汇认读', '句型操练'],
payload: () => ({
title: 'Unit4 Healthy Food 第一课时(示例改编)',
exercise_type: 'choice',
subject: '英语',
knowledge_points: ['Healthy Food', 'food vocabulary', 'would like'],
questions: [
question('Which one is a healthy drink?', ['Cola', 'Water', 'Candy', 'Cake'], 'Water', 'Water is a healthy drink.'),
question('What would you like?', ['I like play.', 'I would like an apple.', 'I am seven.', 'It is blue.'], 'I would like an apple.', 'Use “would like” to express what you want.'),
question('Choose the fruit.', ['Carrot', 'Milk', 'Banana', 'Bread'], 'Banana', 'Banana is a fruit.'),
question('Which food should we eat more often?', ['Vegetables', 'Chips', 'Candy', 'Ice cream'], 'Vegetables', 'Vegetables are healthier for daily meals.'),
],
settings: { show_analysis: true, shuffle: false, mode: 'class_game' },
}),
},
case: {
type: 'lesson_plan',
title: '智教助手经典案例解析合集(示例改编)',
subject: '课堂工具',
grade: '通用',
description: '拆解优秀课件的结构、互动设计和课堂使用场景,适合备课复盘。',
tags: ['优秀案例', '结构拆解', '教学设计'],
payload: () => lessonPlanPayload('智教助手经典案例解析合集(示例改编)', '课堂工具', '通用', [
['案例导入', 8, ['展示优秀互动课件封面和学习目标。'], ['引导教师观察结构。'], ['记录课件亮点。']],
['结构拆解', 18, ['拆分封面、导入、探究、练习、总结五类页面。'], ['讲解每类页面的作用。'], ['对照自己的课题迁移。']],
['互动分析', 14, ['分析点击、拖拽、投票和即时反馈的使用位置。'], ['指出互动与目标的关系。'], ['判断互动是否服务学习。']],
['改编实践', 15, ['选择一个课题完成原创改编方案。'], ['提供改编检查表。'], ['完成小组互评。']],
]),
},
creator: {
type: 'courseware',
title: 'AI好课晒一晒创作者激励活动(示例改编)',
subject: '课堂工具',
grade: '通用',
description: '沉淀教师作品、活动说明和优秀模板推荐,支持发布后持续改编。',
tags: ['发布作品', '资源复用', '教学案例'],
payload: () => coursewarePayload('AI好课晒一晒创作者激励活动(示例改编)', '课堂工具', '通用', [
['封面', 'AI好课晒一晒', '把好课发布出来,让更多老师复用和改编。', 'title'],
['活动目标', '沉淀优秀课堂资源', '鼓励教师发布可预览、可改编、可复用的教学作品。', 'content'],
['发布流程', '保存作品后发布到资源广场', '补充标题、学科、年级、标签和使用说明。', 'interaction'],
['反馈迭代', '查看收藏、下载和改编反馈', '根据使用数据持续优化资源。', 'summary'],
], ['发布作品', '资源复用', '教学案例']),
},
vision: {
type: 'animation',
title: 'AI如何看世界:图像识别(示例改编)',
subject: '信息科技',
grade: '人工智能体验课',
description: '用流程动画解释图像识别的基本工作过程。',
tags: ['人工智能', '图像识别', '流程动画'],
payload: () => ({
title: 'AI如何看世界:图像识别(示例改编)',
anim_type: 'general',
description: '用流程动画解释图像识别的基本工作过程。',
config: {
title: 'AI如何看世界',
description: '图像输入、特征提取、分类判断和反思边界。',
html: animationHtml('图像识别流程', ['输入图像', '拆分像素', '提取特征', '分类预测', '讨论偏差'], '#080b12', '#38bdf8'),
scenes: [
{ id: 1, name: '输入图像', duration: 8, narration: '图像被拆成像素和颜色信息。' },
{ id: 2, name: '提取特征', duration: 12, narration: '模型寻找边缘、纹理和形状。' },
{ id: 3, name: '分类预测', duration: 10, narration: '输出类别和置信度。' },
{ id: 4, name: '讨论边界', duration: 8, narration: '分析 AI 可能出错的场景。' },
],
totalDuration: 38,
interactive: true,
},
}),
},
exam: {
type: 'exam',
title: '七年级数学有理数运算测试(示例改编)',
subject: '数学',
grade: '七年级',
description: '围绕有理数加减乘除组织基础检测,含答案与解析。',
tags: ['智能命题', '有理数', '基础检测'],
payload: () => ({
title: '七年级数学有理数运算测试(示例改编)',
subject: '数学',
grade: '七年级',
questions: [
examQuestion('计算:-3 + 8 = ?', ['-11', '-5', '5', '11'], '5', '异号两数相加,取绝对值较大的符号,用 8 - 3 = 5。', 5),
examQuestion('计算:(-2) x (-6) = ?', ['-12', '12', '8', '-8'], '12', '两个负数相乘结果为正。', 5),
examQuestion('下列数中最小的是?', ['-5', '-1', '0', '2'], '-5', '数轴上越靠左的数越小。', 5),
],
answers: ['5', '12', '-5'],
duration: 45,
total_score: 100,
difficulty: 'medium',
knowledge_points: ['有理数运算'],
}),
},
}
export function normalizeDemoDraftKey(input: any) {
const raw = typeof input === 'string' ? input : String(input?.id || input?.demoId || '')
return aliases[raw] || ''
}
export function isDemoDraftAvailable(input: any) {
const key = normalizeDemoDraftKey(input)
return Boolean(key && specs[key])
}
export function getDemoDraftType(input: any): DemoDraftType | '' {
const key = normalizeDemoDraftKey(input)
return key && specs[key] ? specs[key].type : ''
}
export async function createDemoDraft(input: any): Promise<DemoDraftResult> {
const key = normalizeDemoDraftKey(input)
const spec = key ? specs[key] : null
if (!spec) throw new Error('示例资源暂不支持直接改编')
const payload = spec.payload()
let draft: any
if (spec.type === 'courseware') draft = unwrap(await createCourseware(payload))
if (spec.type === 'animation') draft = unwrap(await createAnimation(payload))
if (spec.type === 'exercise') draft = unwrap(await createExercise(payload))
if (spec.type === 'lesson_plan') draft = unwrap(await createLessonPlan(payload))
if (spec.type === 'exam') draft = unwrap(await createExam(payload))
return { type: spec.type, draft, openPath: openPath(spec.type, draft.id) }
}
function unwrap(res: any) {
return res?.data || res
}
function openPath(type: DemoDraftType, id: number) {
if (type === 'courseware') return `/courseware/${id}`
if (type === 'animation') return `/animation?open=${id}`
if (type === 'exercise') return `/exercise?open=${id}`
if (type === 'lesson_plan') return `/lesson-plan?open=${id}`
return `/exam?open=${id}`
}
function coursewarePayload(title: string, subject: string, grade: string, pages: Array<[string, string, string, string]>, tags: string[]) {
return {
title,
subject,
grade,
description: `${subject}${grade ? ` · ${grade}` : ''}示例课件,可继续编辑、授课预览和发布分享。`,
content: pages.map(([pageTitle, heading, subtitle, type], index) => ({
title: pageTitle,
type,
content: slideHtml(heading, subtitle, pageTitle, index),
notes: subtitle,
})),
tags,
status: 'draft',
}
}
function lessonPlanPayload(title: string, subject: string, grade: string, phases: any[]) {
const normalizedPhases = phases.map(([name, duration, activities, teacher_actions, student_actions]) => ({
name,
duration,
activities,
teacher_actions,
student_actions,
resources: ['互动课件', '学习单'],
}))
return {
title,
subject,
grade,
objectives: ['理解优秀教学资源的结构特征', '能根据本班学情完成原创改编', '形成资源复盘与迭代意识'],
key_points: ['结构拆解', '互动设计', '改编实践'],
difficulties: ['让互动服务教学目标', '从案例迁移到真实课题'],
content: { title, phases: normalizedPhases },
duration: normalizedPhases.reduce((sum, item) => sum + Number(item.duration || 0), 0),
materials: ['案例资源', '改编检查表', '小组评价表'],
homework: ['选择一个个人课题,完成一份资源改编提纲。'],
}
}
function question(stem: string, options: string[], answer: string, explanation: string) {
return {
type: 'choice',
question: stem,
stem,
options,
answer,
explanation,
difficulty: 'medium',
html: questionHtml(stem, options),
}
}
function examQuestion(content: string, options: string[], answer: string, analysis: string, score: number) {
return {
type: 'choice',
content,
options,
answer,
analysis,
score,
}
}
function slideHtml(title: string, subtitle: string, badge: string, index: number) {
const themes = [
['#fff3c4', '#7a5600', '#f7c948'],
['#e8f7ef', '#00543d', '#8ecf9a'],
['#dbe8ff', '#003f68', '#7fb3ff'],
['#fff1a1', '#654100', '#ffd84d'],
]
const [bg, fg, accent] = themes[index % themes.length]
return `
<div style="width:100%;height:100%;box-sizing:border-box;padding:48px;background:${bg};color:${fg};font-family:Microsoft YaHei,Arial,sans-serif;position:relative;overflow:hidden;">
<div style="display:inline-flex;padding:7px 13px;border-radius:18px;background:#fff;color:${fg};font-size:13px;font-weight:900;">${escapeHtml(badge)}</div>
<h1 style="max-width:68%;margin:34px 0 18px;font-size:42px;line-height:1.12;">${escapeHtml(title)}</h1>
<p style="max-width:62%;font-size:19px;line-height:1.8;margin:0;">${escapeHtml(subtitle)}</p>
<div style="position:absolute;right:58px;top:72px;width:230px;height:230px;border-radius:28px;background:#fff;box-shadow:0 18px 42px rgba(17,69,52,.12);">
<i style="position:absolute;left:32px;top:42px;width:166px;height:22px;border-radius:11px;background:${accent};display:block;"></i>
<i style="position:absolute;left:32px;top:86px;width:126px;height:18px;border-radius:9px;background:${accent};opacity:.62;display:block;"></i>
<i style="position:absolute;left:32px;top:130px;width:150px;height:58px;border-radius:16px;background:${accent};opacity:.36;display:block;"></i>
</div>
</div>`
}
function animationHtml(title: string, steps: string[], bg: string, accent: string) {
return `
<div style="width:100%;height:100%;position:relative;overflow:hidden;background:${bg};font-family:Microsoft YaHei,Arial,sans-serif;color:#fff;">
<h1 style="position:absolute;left:42px;top:34px;margin:0;font-size:34px;">${escapeHtml(title)}</h1>
<p style="position:absolute;left:42px;top:86px;margin:0;color:rgba(255,255,255,.72);font-size:15px;">点击演示时,按流程观察课堂任务如何推进。</p>
<div style="position:absolute;left:54px;right:54px;top:182px;height:4px;background:rgba(255,255,255,.18);"></div>
${steps.map((step, index) => {
const left = 58 + index * Math.max(118, 640 / Math.max(steps.length, 1))
return `<div style="position:absolute;left:${left}px;top:138px;width:104px;text-align:center;animation:demoPulse 2.8s ease-in-out ${index * 0.22}s infinite;">
<span style="width:62px;height:62px;margin:0 auto 12px;border-radius:50%;background:${accent};display:flex;align-items:center;justify-content:center;font-weight:900;box-shadow:0 0 28px rgba(255,255,255,.2);">${index + 1}</span>
<strong style="font-size:15px;line-height:1.35;">${escapeHtml(step)}</strong>
</div>`
}).join('')}
<style>
@keyframes demoPulse {
0%,100% { transform: translateY(0); opacity: .72; }
50% { transform: translateY(-14px); opacity: 1; }
}
</style>
</div>`
}
function questionHtml(stem: string, options: string[]) {
return `
<div style="width:100%;height:100%;box-sizing:border-box;padding:40px;background:#fff8df;color:#654100;font-family:Microsoft YaHei,Arial,sans-serif;overflow:auto;">
<h1 style="font-size:30px;line-height:1.35;margin:0 0 24px;">${escapeHtml(stem)}</h1>
<div style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;">
${options.map((option, index) => `<div style="min-height:58px;border:1px solid #f0dca3;border-radius:14px;background:#fff;padding:16px;display:flex;gap:12px;align-items:center;">
<strong style="width:30px;height:30px;border-radius:9px;background:#f59e0b;color:#fff;display:inline-flex;align-items:center;justify-content:center;">${String.fromCharCode(65 + index)}</strong>
<span>${escapeHtml(option)}</span>
</div>`).join('')}
</div>
</div>`
}
function escapeHtml(value: string) {
return String(value || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
+10
View File
@@ -0,0 +1,10 @@
export function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
File diff suppressed because it is too large Load Diff
+59
View File
@@ -0,0 +1,59 @@
export type MaterialContextItem = {
id: string
name: string
icon: string
typeLabel: string
sizeLabel: string
excerpt: string
}
export function formatMaterialFileSize(size: number) {
const n = Number(size || 0)
if (n < 1024) return `${n}B`
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}KB`
return `${(n / 1024 / 1024).toFixed(1)}MB`
}
export function materialTypeLabel(type: string, file?: { type?: string; name?: string }) {
if (file?.type?.startsWith('image/') || type === 'image') return '图片'
const map: Record<string, string> = {
docx: 'Word材料',
pptx: 'PPT材料',
csv: '表格材料',
json: '结构化材料',
md: 'Markdown材料',
pdf: 'PDF材料',
xlsx: '表格材料',
xls: '表格材料',
txt: '文本材料',
text: '文本材料',
}
return map[type] || '教学材料'
}
export function materialToContext(item: any): MaterialContextItem {
return {
id: `library-${item.id}`,
name: item.title || item.filename || '素材',
icon: item.material_type === 'image' ? 'Picture' : 'Document',
typeLabel: materialTypeLabel(item.material_type || 'text', { type: item.material_type || '', name: item.filename || '' }),
sizeLabel: formatMaterialFileSize(item.size || 0),
excerpt: item.summary || '',
}
}
export function fileParseToContext(file: File, data: any): MaterialContextItem {
return {
id: `${Date.now()}-${file.name}`,
name: file.name,
icon: file.type.startsWith('image/') ? 'Picture' : 'Document',
typeLabel: materialTypeLabel(data.material_type, file),
sizeLabel: formatMaterialFileSize(data.size || file.size),
excerpt: data.summary || '',
}
}
export function buildMaterialPromptBlock(materials: MaterialContextItem[], title = '已选素材') {
if (!materials.length) return ''
return `\n\n【${title}】\n${materials.map(item => `- ${item.name}(${item.typeLabel}):${item.excerpt || '暂无摘要,请结合素材标题和类型进行创作。'}`).join('\n')}`
}
+97
View File
@@ -0,0 +1,97 @@
import axios, { type AxiosError, type InternalAxiosRequestConfig } from 'axios'
import { ElMessage } from 'element-plus'
import router from '@/router'
import { useUserStore } from '@/stores/user'
const request = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '',
timeout: 300000,
})
request.interceptors.request.use(
(config) => {
const token = localStorage.getItem('access_token')
if (token) {
config.headers.set('Authorization', `Bearer ${token}`)
}
return config
},
(error) => Promise.reject(error),
)
// 单飞刷新:并发 401 时只发起一次刷新,其他请求等待结果
let refreshPromise: Promise<string | null> | null = null
async function doRefresh(): Promise<string | null> {
if (refreshPromise) return refreshPromise
const refreshToken = localStorage.getItem('refresh_token')
if (!refreshToken) return null
refreshPromise = (async () => {
try {
const res: any = await axios.post('/api/auth/refresh', null, { params: { refresh_token: refreshToken } })
const data = res?.data || res
if (data?.access_token) {
localStorage.setItem('access_token', data.access_token)
if (data.refresh_token) localStorage.setItem('refresh_token', data.refresh_token)
return data.access_token as string
}
return null
} catch {
return null
} finally {
refreshPromise = null
}
})()
return refreshPromise
}
request.interceptors.response.use(
(response) => response.data,
async (error: AxiosError) => {
const detail = (error.response?.data as any)?.detail
let msg = '请求失败,请稍后重试'
if (typeof detail === 'string') {
msg = detail
} else if (Array.isArray(detail)) {
msg = detail.map((e: any) => e.msg || JSON.stringify(e)).join('; ')
}
console.error('API Error:', error.response?.status, detail)
const originalRequest = error.config as InternalAxiosRequestConfig & { _retried?: boolean }
const status = error.response?.status
const isAuthError =
status === 401 ||
(status === 403 && detail === 'Not authenticated')
// 自动刷新:401 且未重试过 且不是刷新接口本身
if (status === 401 && originalRequest && !originalRequest._retried && !originalRequest.url?.includes('/auth/refresh')) {
const newToken = await doRefresh()
if (newToken) {
originalRequest._retried = true
originalRequest.headers.set('Authorization', `Bearer ${newToken}`)
return request(originalRequest)
}
// 刷新失败:清理并跳转登录
try { useUserStore().clearToken() } catch { localStorage.removeItem('access_token') }
if (router.currentRoute.value.path !== '/login') router.push('/login')
return Promise.reject(error)
}
if (isAuthError) {
try {
useUserStore().clearToken()
} catch {
localStorage.removeItem('access_token')
}
if (router.currentRoute.value.path !== '/login') {
router.push('/login')
}
} else {
ElMessage.error(msg)
}
return Promise.reject(error)
},
)
export default request
+51
View File
@@ -0,0 +1,51 @@
export type SpeechController = {
start: () => void
stop: () => void
isSupported: () => boolean
}
type SpeechOptions = {
onStart?: () => void
onText: (text: string) => void
onError?: () => void
onEnd?: () => void
}
export function createSpeechController(options: SpeechOptions): SpeechController {
let recognition: any = null
function getSpeechRecognition() {
return (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition
}
function stop() {
recognition?.stop()
}
function start() {
const SpeechRecognition = getSpeechRecognition()
if (!SpeechRecognition) {
options.onError?.()
return
}
stop()
recognition = new SpeechRecognition()
recognition.lang = 'zh-CN'
recognition.interimResults = false
recognition.continuous = false
recognition.onstart = () => options.onStart?.()
recognition.onresult = (event: any) => {
const text = Array.from(event.results).map((result: any) => result[0]?.transcript || '').join('')
if (text.trim()) options.onText(text.trim())
}
recognition.onerror = () => options.onError?.()
recognition.onend = () => options.onEnd?.()
recognition.start()
}
return {
start,
stop,
isSupported: () => !!getSpeechRecognition(),
}
}
+447
View File
@@ -0,0 +1,447 @@
<template>
<div class="admin-page">
<header class="admin-head">
<div>
<h1>管理后台</h1>
<p>运营数据、用户管理与资源审核</p>
</div>
<div class="tab-bar">
<button v-for="tab in tabs" :key="tab.value" type="button" :class="{ active: activeTab === tab.value }" @click="activeTab = tab.value">{{ tab.label }}</button>
</div>
</header>
<!-- Dashboard -->
<section v-if="activeTab === 'dashboard'" v-loading="loading" class="dashboard-grid">
<div class="stat-card" v-for="card in statCards" :key="card.label">
<el-icon :size="20" :color="card.color"><component :is="card.icon" /></el-icon>
<strong>{{ card.value }}</strong>
<span>{{ card.label }}</span>
</div>
</section>
<!-- Audit Logs -->
<section v-else-if="activeTab === 'audit'" class="data-section">
<div class="filter-row">
<el-input v-model="auditFilter.keyword" placeholder="搜索用户/详情/IP" clearable style="width: 240px" @keyup.enter="loadAuditLogs" />
<el-select v-model="auditFilter.action" placeholder="全部操作" clearable style="width: 130px" @change="loadAuditLogs">
<el-option v-for="o in actionOptions" :key="o.value" :label="o.label" :value="o.value" />
</el-select>
<el-select v-model="auditFilter.status" placeholder="全部状态" clearable style="width: 120px" @change="loadAuditLogs">
<el-option v-for="o in statusOptions" :key="o.value" :label="o.label" :value="o.value" />
</el-select>
<button class="ghost-btn" type="button" @click="loadAuditLogs">查询</button>
</div>
<el-table :data="auditLogs" v-loading="loading" stripe style="width: 100%" @row-click="() => {}">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="created_at" label="时间" width="160">
<template #default="{ row }">{{ formatTime(row.created_at) }}</template>
</el-table-column>
<el-table-column prop="username" label="用户" width="120" show-overflow-tooltip />
<el-table-column prop="action" label="操作" width="110">
<template #default="{ row }">{{ actionLabel(row.action) }}</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.status === 'success' ? 'success' : row.status === 'failed' ? 'warning' : 'danger'" size="small">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="ip" label="IP" width="130" show-overflow-tooltip />
<el-table-column prop="detail" label="详情" min-width="220" show-overflow-tooltip />
<el-table-column prop="target_type" label="对象" width="100" />
</el-table>
<p v-if="!auditLogs.length && !loading" class="empty-tip">暂无审计日志,切换筛选或进行登录/上传操作后再查询</p>
</section>
<!-- Users -->
<section v-else-if="activeTab === 'users'" class="data-section">
<div class="filter-row">
<el-input v-model="userFilter.keyword" placeholder="搜索手机号或姓名" clearable style="width: 240px" @keyup.enter="loadUsers" />
<el-select v-model="userFilter.role" placeholder="全部角色" clearable style="width: 130px" @change="loadUsers">
<el-option label="教师" value="teacher" />
<el-option label="管理员" value="admin" />
</el-select>
<button class="ghost-btn" type="button" @click="loadUsers">查询</button>
</div>
<el-table :data="users" v-loading="loading" stripe style="width: 100%">
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="name" label="姓名" width="100" />
<el-table-column prop="phone" label="手机号" width="130" />
<el-table-column prop="role" label="角色" width="90">
<template #default="{ row }">
<el-tag :type="row.role === 'admin' ? 'danger' : 'info'" size="small" effect="plain">{{ row.role === 'admin' ? '管理员' : '教师' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="subject" label="学科" width="80" />
<el-table-column prop="credits" label="积分" width="70" />
<el-table-column prop="is_active" label="状态" width="70">
<template #default="{ row }">
<el-tag :type="row.is_active ? 'success' : 'warning'" size="small">{{ row.is_active ? '正常' : '禁用' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="注册时间" width="150">
<template #default="{ row }">{{ formatDate(row.created_at) }}</template>
</el-table-column>
<el-table-column label="操作" width="240" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="toggleUserActive(row)">{{ row.is_active ? '禁用' : '启用' }}</el-button>
<el-button size="small" @click="toggleUserRole(row)">{{ row.role === 'admin' ? '降为教师' : '升为管理员' }}</el-button>
<el-button size="small" type="warning" @click="grantCredits(row)">发积分</el-button>
</template>
</el-table-column>
</el-table>
</section>
<!-- Resources -->
<section v-else-if="activeTab === 'resources'" class="data-section">
<div class="filter-row">
<el-input v-model="resFilter.keyword" placeholder="搜索资源标题" clearable style="width: 240px" @keyup.enter="loadResources" />
<el-select v-model="resFilter.resource_type" placeholder="全部类型" clearable style="width: 130px" @change="loadResources">
<el-option label="课件" value="courseware" />
<el-option label="动画" value="animation" />
<el-option label="练习" value="exercise" />
<el-option label="教案" value="lesson_plan" />
<el-option label="其他" value="other" />
</el-select>
<el-select v-model="resFilter.status" placeholder="状态" style="width: 110px" @change="loadResources">
<el-option label="上架" value="active" />
<el-option label="已下架" value="archived" />
</el-select>
<button class="ghost-btn" type="button" @click="loadResources">查询</button>
</div>
<el-table :data="resources" v-loading="loading" stripe style="width: 100%">
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="title" label="标题" min-width="180" show-overflow-tooltip />
<el-table-column prop="resource_type" label="类型" width="90">
<template #default="{ row }">{{ typeLabel(row.resource_type) }}</template>
</el-table-column>
<el-table-column prop="subject" label="学科" width="80" />
<el-table-column prop="views" label="浏览" width="70" />
<el-table-column prop="downloads" label="下载" width="70" />
<el-table-column prop="likes" label="收藏" width="70" />
<el-table-column prop="status" label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.status === 'active' ? 'success' : 'info'" size="small">{{ row.status === 'active' ? '上架' : '下架' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-popconfirm :title="row.status === 'active' ? '确定下架该资源?' : '确定恢复上架?'" @confirm="moderate(row)">
<template #reference>
<el-button size="small" :type="row.status === 'active' ? 'danger' : 'success'">{{ row.status === 'active' ? '下架' : '上架' }}</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
</section>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getAdminAuditLogs, getAdminDashboard, getAdminResources, getAdminUsers, grantAdminCredits, moderateResource, updateAdminUser } from '@/api/admin'
const tabs = [
{ value: 'dashboard', label: '运营看板' },
{ value: 'users', label: '用户管理' },
{ value: 'resources', label: '资源审核' },
{ value: 'audit', label: '安全审计' },
]
const activeTab = ref('dashboard')
const loading = ref(false)
const dashboard = ref<any>({})
const users = ref<any[]>([])
const resources = ref<any[]>([])
const userFilter = reactive({ keyword: '', role: '' })
const auditLogs = ref<any[]>([])
const auditFilter = reactive({ action: '', status: '', keyword: '' })
const resFilter = reactive({ keyword: '', resource_type: '', status: 'active' })
const statCards = computed(() => [
{ label: '注册用户', value: dashboard.value.total_users ?? 0, icon: 'User', color: '#00543d' },
{ label: '今日新增', value: dashboard.value.new_users_today ?? 0, icon: 'Plus', color: '#10b981' },
{ label: '上架资源', value: dashboard.value.public_resources ?? 0, icon: 'Share', color: '#0ea5e9' },
{ label: '课件总数', value: dashboard.value.total_coursewares ?? 0, icon: 'Document', color: '#8b5cf6' },
{ label: '总浏览量', value: dashboard.value.total_views ?? 0, icon: 'View', color: '#f59e0b' },
{ label: '总下载量', value: dashboard.value.total_downloads ?? 0, icon: 'Download', color: '#ec4899' },
{ label: '收藏总数', value: dashboard.value.total_favorites ?? 0, icon: 'Star', color: '#eab308' },
{ label: '消耗积分', value: dashboard.value.total_credits_used ?? 0, icon: 'GoldMedal', color: '#ef4444' },
])
function typeLabel(type: string) {
const map: Record<string, string> = { courseware: '课件', animation: '动画', exercise: '练习', lesson_plan: '教案', exam: '试卷', other: '其他' }
return map[type] || type
}
function formatDate(value: any) {
if (!value) return ''
const d = new Date(value)
return Number.isNaN(d.getTime()) ? '' : d.toLocaleDateString('zh-CN')
}
async function loadDashboard() {
loading.value = true
try {
dashboard.value = await getAdminDashboard()
} catch {
dashboard.value = {}
} finally {
loading.value = false
}
}
async function loadUsers() {
loading.value = true
try {
users.value = await getAdminUsers({ keyword: userFilter.keyword, role: userFilter.role })
} catch {
users.value = []
} finally {
loading.value = false
}
}
async function loadResources() {
loading.value = true
try {
resources.value = await getAdminResources({ keyword: resFilter.keyword, resource_type: resFilter.resource_type, status: resFilter.status })
} catch {
resources.value = []
} finally {
loading.value = false
}
}
async function toggleUserActive(row: any) {
try {
const updated: any = await updateAdminUser(row.id, { is_active: !row.is_active })
Object.assign(row, updated)
ElMessage.success(row.is_active ? '已启用' : '已禁用')
} catch (e: any) {
ElMessage.error(e.response?.data?.detail || '操作失败')
}
}
async function toggleUserRole(row: any) {
const newRole = row.role === 'admin' ? 'teacher' : 'admin'
try {
const updated: any = await updateAdminUser(row.id, { role: newRole })
Object.assign(row, updated)
ElMessage.success('角色已更新')
} catch (e: any) {
ElMessage.error(e.response?.data?.detail || '操作失败')
}
}
async function grantCredits(row: any) {
try {
const { value } = await ElMessageBox.prompt('输入要发放的积分数量(1-10000)', '发放积分', {
confirmButtonText: '发放',
cancelButtonText: '取消',
inputPattern: /^\d+$/,
inputErrorMessage: '请输入正整数',
})
const amount = Number(value)
if (amount < 1 || amount > 10000) {
ElMessage.warning('数量需在 1-10000 之间')
return
}
const updated: any = await grantAdminCredits(row.id, amount)
Object.assign(row, updated)
ElMessage.success(`已发放 ${amount} 积分`)
} catch {
// cancelled
}
}
async function moderate(row: any) {
const status = row.status === 'active' ? 'archived' : 'active'
try {
await moderateResource(row.id, status)
row.status = status
ElMessage.success(status === 'active' ? '已上架' : '已下架')
} catch (e: any) {
ElMessage.error(e.response?.data?.detail || '操作失败')
}
}
async function loadAuditLogs() {
loading.value = true
try {
auditLogs.value = await getAdminAuditLogs({
action: auditFilter.action || undefined,
status: auditFilter.status || undefined,
keyword: auditFilter.keyword || undefined,
limit: 100,
})
} catch {
auditLogs.value = []
} finally {
loading.value = false
}
}
const actionOptions = [
{ value: '', label: '全部操作' },
{ value: 'login', label: '登录' },
{ value: 'register', label: '注册' },
{ value: 'material_parse', label: '材料解析' },
{ value: 'publish_resource', label: '发布资源' },
{ value: 'admin_grant_credits', label: '管理员发积分' },
{ value: 'admin_update_user', label: '管理员改用户' },
{ value: 'admin_moderate_resource', label: '管理员审核' },
]
const statusOptions = [
{ value: '', label: '全部状态' },
{ value: 'success', label: '成功' },
{ value: 'failed', label: '失败' },
{ value: 'denied', label: '拒绝' },
]
function actionLabel(a: string) {
const m: Record<string, string> = {
login: '登录', register: '注册', material_parse: '材料解析',
publish_resource: '发布资源', admin_grant_credits: '管理员发积分',
admin_update_user: '管理员改用户', admin_moderate_resource: '管理员审核',
}
return m[a] || a || '-'
}
function statusLabel(s: string) {
const m: Record<string, string> = { success: '成功', failed: '失败', denied: '拒绝' }
return m[s] || s || '-'
}
function formatTime(t: any) {
if (!t) return '-'
try {
const d = new Date(t)
return d.toLocaleString('zh-CN', { hour12: false })
} catch { return String(t) }
}
watch(activeTab, (v) => {
if (v === 'audit' && !auditLogs.value.length) loadAuditLogs()
if (v === 'users' && !users.value.length) loadUsers()
if (v === 'resources' && !resources.value.length) loadResources()
})
onMounted(() => {
loadDashboard()
})
</script>
<style scoped>
.admin-page {
min-height: calc(100vh - var(--header-height));
padding: 28px;
max-width: 1280px;
margin: 0 auto;
}
.admin-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 24px;
flex-wrap: wrap;
gap: 16px;
}
.admin-head h1 {
font-size: 24px;
color: #173f35;
margin: 0;
}
.admin-head p {
color: #71847c;
font-size: 14px;
margin: 4px 0 0;
}
.tab-bar {
display: inline-flex;
gap: 4px;
border: 1px solid #dce8de;
border-radius: 10px;
padding: 4px;
background: #fff;
}
.tab-bar button {
height: 34px;
border: none;
border-radius: 7px;
background: transparent;
color: #51665f;
padding: 0 18px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
}
.tab-bar button.active {
background: #00543d;
color: #fff;
}
.dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 16px;
}
.stat-card {
border: 1px solid #dce8de;
border-radius: 14px;
background: #fff;
padding: 20px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
}
.stat-card strong {
font-size: 32px;
color: #173f35;
line-height: 1;
}
.stat-card span {
color: #71847c;
font-size: 13px;
}
.data-section {
background: #fff;
border: 1px solid #dce8de;
border-radius: 14px;
padding: 20px;
}
.filter-row {
display: flex;
gap: 10px;
align-items: center;
margin-bottom: 16px;
flex-wrap: wrap;
}
.ghost-btn {
height: 34px;
border: 1px solid #dbe8de;
border-radius: 8px;
background: #fff;
color: #00543d;
padding: 0 16px;
font-weight: 700;
cursor: pointer;
}
.ghost-btn:hover {
background: #f0fdf4;
}
</style>
+733
View File
@@ -0,0 +1,733 @@
<template>
<div class="page-container">
<div class="gen-hero">
<div class="gen-hero-icon" style="background: var(--gradient-green)">
<el-icon :size="28" color="#fff"><Film /></el-icon>
</div>
<div>
<h2>教学动画</h2>
<p>描述教学场景,AI生成可交互的动画页面</p>
</div>
</div>
<div class="gen-input-section">
<div v-if="quickPromptNotice" class="quick-notice">
<el-icon><MagicStick /></el-icon>
<span>{{ quickPromptNotice }}</span>
</div>
<div class="prompt-box">
<el-input
v-model="form.prompt"
type="textarea"
:rows="4"
placeholder="描述你想要的教学动画,例如:展示二次函数y=ax²+bx+c的图像随参数a、b、c变化的过程..."
resize="none"
/>
<div class="prompt-footer">
<div class="prompt-tags">
<span class="prompt-tag" v-for="t in suggestions" :key="t" @click="form.prompt = t">{{ t }}</span>
</div>
<div class="prompt-actions">
<button class="tpl-btn" type="button" @click="showTemplateGallery = true">
<el-icon><Grid /></el-icon>
<span>用模板</span>
</button>
<button class="voice-btn" type="button" title="语音输入" :class="{ active: listening }" @click="toggleVoice">
<el-icon><Microphone /></el-icon>
</button>
<button class="gen-btn" style="background: var(--gradient-green)" :disabled="!form.prompt || generating" @click="handleGenerate">
<el-icon v-if="!generating"><MagicStick /></el-icon>
<span v-if="generating" class="btn-loading"></span>
{{ generating ? 'AI生成中...' : '生成动画' }}
</button>
</div>
</div>
</div>
<div class="material-row">
<MaterialPicker v-model="selectedMaterials" />
</div>
<div class="settings-row">
<div class="setting-item">
<label>动画类型</label>
<el-select v-model="form.anim_type" size="default">
<el-option label="数学" value="math" />
<el-option label="物理" value="physics" />
<el-option label="化学" value="chemistry" />
<el-option label="通用" value="general" />
</el-select>
</div>
<div class="setting-item">
<label>学科</label>
<el-input v-model="form.subject" placeholder="例如:数学" size="default" style="width: 140px" />
</div>
</div>
</div>
<div v-if="generating" class="loading-section">
<div class="loading-bar"><div class="loading-bar-fill"></div></div>
<p>AI正在生成动画页面...</p>
<p style="font-size:13px;color:var(--text-secondary);opacity:.65;margin-top:10px">模型深度推理中,通常需要 1-2 分钟,请耐心等待</p>
</div>
<div v-else-if="!result" class="guide-section">
<div class="capability-row">
<div v-for="item in capabilities" :key="item.title">
<strong>{{ item.title }}</strong>
<span>{{ item.desc }}</span>
</div>
</div>
<div class="guide-cards">
<div class="guide-card" v-for="g in guideExamples" :key="g.title" @click="form.prompt = g.prompt">
<div class="guide-icon" :style="{ background: g.bg }">
<el-icon :size="18" color="#fff"><component :is="g.icon" /></el-icon>
</div>
<div class="guide-info">
<h4>{{ g.title }}</h4>
<p>{{ g.prompt }}</p>
</div>
<span class="guide-arrow">试试 →</span>
</div>
</div>
</div>
<div v-if="result" class="result-section">
<div class="result-header">
<div>
<h3>{{ result.title }}</h3>
<p class="result-desc">{{ result.description }}</p>
</div>
<div class="result-actions">
<button class="tool-btn" type="button" @click="reuseCurrent">
<el-icon><MagicStick /></el-icon>
用它生成
</button>
<button class="tool-btn" type="button" @click="handleExportHtml">
<el-icon><Download /></el-icon>
下载HTML
</button>
<button class="tool-btn publish" type="button" :disabled="publishing" @click="publishCurrent">
<el-icon><Share /></el-icon>
{{ publishing ? '发布中' : '发布资源' }}
</button>
<button class="save-btn" type="button" @click="handleSave">
<el-icon><Check /></el-icon>
保存动画
</button>
</div>
</div>
<div class="animation-preview">
<HtmlPreview :html="result.html" height="600px" />
</div>
<div v-if="result.scenes?.length" class="scenes-section">
<h4>动画场景</h4>
<div class="scenes-list">
<div v-for="scene in result.scenes" :key="scene.id" class="scene-card">
<div class="scene-num">{{ scene.id }}</div>
<div class="scene-info">
<div class="scene-name">{{ scene.name }}</div>
<div class="scene-meta">
<span>时长:{{ scene.duration }}秒</span>
<span class="scene-narration">{{ scene.narration }}</span>
</div>
</div>
</div>
</div>
</div>
<div class="result-meta-bar">
总时长:{{ result.totalDuration || '--' }}秒 · {{ result.interactive ? '支持交互控制' : '自动播放' }}
</div>
</div>
<section class="saved-section">
<div class="saved-header">
<div>
<h3>已保存动画</h3>
<p>保存后的教学动画会出现在这里,也可以在课件编辑器中插入使用</p>
</div>
<button class="refresh-btn" type="button" @click="handleRefreshSavedAnimations">
<el-icon><Refresh /></el-icon>
刷新
</button>
</div>
<div v-if="savedLoading" class="saved-loading">正在加载已保存动画...</div>
<div v-else-if="savedAnimations.length === 0" class="saved-empty">
<el-icon><Film /></el-icon>
<span>暂无已保存动画</span>
</div>
<div v-else class="saved-grid">
<article v-for="item in savedAnimations" :key="item.id" class="saved-card">
<div class="saved-cover">
<el-icon><Film /></el-icon>
<strong>{{ item.title }}</strong>
<span>{{ typeLabel(item.anim_type) }}</span>
</div>
<div class="saved-body">
<h4>{{ item.title }}</h4>
<p>{{ item.description || '暂无描述' }}</p>
<div class="saved-actions">
<button type="button" @click="previewSaved(item)">
<el-icon><View /></el-icon>
预览
</button>
<button type="button" @click="publishSaved(item)">
<el-icon><Share /></el-icon>
发布
</button>
<button type="button" @click="exportSavedAnimation(item)">
<el-icon><Download /></el-icon>
下载
</button>
<el-popconfirm title="确定删除这个动画?关联资源会同步下架。" @confirm="handleDeleteSaved(item.id)">
<template #reference>
<button type="button" class="danger">
<el-icon><Delete /></el-icon>
删除
</button>
</template>
</el-popconfirm>
</div>
</div>
</article>
</div>
</section>
<TemplateGallery v-model="showTemplateGallery" kind="animation" @use="handleUseTemplate" />
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { aiGenerateAnimation, createAnimation, deleteAnimation, exportAnimationHtml, getAnimation, getAnimations, updateAnimation } from '@/api/animation'
import { exportHtml } from '@/api/ai'
import { publishResource } from '@/api/resource'
import HtmlPreview from '@/components/common/HtmlPreview.vue'
import MaterialPicker from '@/components/common/MaterialPicker.vue'
import TemplateGallery from '@/components/common/TemplateGallery.vue'
import { useUserStore } from '@/stores/user'
import { downloadBlob } from '@/utils/download'
import { buildMaterialPromptBlock, type MaterialContextItem } from '@/utils/materialContext'
import { createSpeechController } from '@/utils/speech'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const generating = ref(false)
const result = ref<any>(null)
const savedLoading = ref(false)
const savedAnimations = ref<any[]>([])
const quickPromptNotice = ref('')
const savedResultId = ref<number | null>(null)
const publishing = ref(false)
const listening = ref(false)
const selectedMaterials = ref<MaterialContextItem[]>([])
const showTemplateGallery = ref(false)
const suggestions = ['数学动画', '课堂演示', '知识讲解']
const guideExamples = [
{ title: '函数图像', prompt: '展示二次函数 y=ax²+bx+c 的图像随参数 a、b、c 变化的过程', icon: 'DataLine', bg: 'var(--gradient-green)' },
{ title: '实验演示', prompt: '用动画演示一个课堂实验的关键步骤和变化过程', icon: 'Film', bg: 'var(--gradient-blue)' },
{ title: '知识推演', prompt: '用连贯动画解释一个概念的形成、转化和结论', icon: 'Sunrise', bg: 'var(--gradient-orange)' },
]
const capabilities = [
{ title: '知识可视化', desc: '把抽象概念转成图形、标注和动态变化' },
{ title: '课堂投屏', desc: '生成适合大屏展示的轻量 HTML 页面' },
{ title: '可复用保存', desc: '保存后可在课件编辑器中插入使用' },
]
const form = reactive({ prompt: '', anim_type: 'general', subject: '' })
const speech = createSpeechController({
onStart: () => { listening.value = true },
onText: (text) => { form.prompt = `${form.prompt} ${text}`.trim() },
onError: () => {
listening.value = false
ElMessage.warning('语音识别失败,请检查浏览器权限或直接输入文字')
},
onEnd: () => { listening.value = false },
})
function handleUseTemplate(payload: { kind: string; template: any; params: Record<string, any>; html: string; title: string }) {
result.value = {
title: payload.title,
description: payload.template.description || '',
html: payload.html,
interactive: true,
}
savedResultId.value = null
if (payload.template.category) {
const catMap: Record<string, string> = {
math: 'math', physics: 'physics', chemistry: 'chemistry',
biology: 'general', geography: 'general', general: 'general',
}
form.anim_type = catMap[payload.template.category] || form.anim_type
}
ElMessage.success('已应用模板,可在右侧预览并保存')
}
async function handleGenerate() {
if (!(await ensureLoggedIn())) return
generating.value = true
try {
const res: any = await aiGenerateAnimation({
...form,
prompt: buildPromptWithMaterials(form.prompt),
})
userStore.refreshCreditsFromResponse(res)
const data = res.data || res
if (data.html) {
data.html = ensureVisibleAnimation(data.html, data.title || form.prompt)
result.value = data
savedResultId.value = null
ElMessage.success('动画生成成功')
} else if (data.raw_content) {
try {
const parsed = typeof data.raw_content === 'string' ? JSON.parse(data.raw_content) : data.raw_content
const htmlVal = parsed?.html || parsed?.content || ''
if (htmlVal) {
result.value = {
title: parsed?.title || '教学动画',
description: parsed?.description || '',
html: htmlVal,
scenes: parsed?.scenes || [],
totalDuration: parsed?.totalDuration || 0,
interactive: !!parsed?.interactive,
}
savedResultId.value = null
ElMessage.warning('已生成 HTML,但内容可能不完整')
} else {
ElMessage.error('未能解析 AI 返回的 HTML')
}
} catch {
ElMessage.error('动画生成失败,无法解析 AI 返回内容')
}
} else {
ElMessage.error('未获取到可用的动画内容')
}
} catch (e: any) {
if (handleAuthError(e)) return
ElMessage.error('动画生成失败:' + (e.response?.data?.detail || e.message || ''))
} finally {
generating.value = false
}
}
function buildPromptWithMaterials(prompt: string) {
return `${prompt}${buildMaterialPromptBlock(selectedMaterials.value, '参考素材')}`.trim()
}
async function handleSave() {
if (!result.value) return
if (!(await ensureLoggedIn())) return
try {
const payload = {
status: 'draft',
title: result.value.title,
anim_type: form.anim_type,
description: result.value.description || '',
config: result.value,
}
const isUpdate = !!savedResultId.value
const saved: any = isUpdate
? await updateAnimation(savedResultId.value!, payload)
: await createAnimation(payload)
const item = saved.data || saved
savedResultId.value = item?.id || null
ElMessage.success(isUpdate ? '动画已更新' : '动画已保存')
await loadSavedAnimations()
} catch (e: any) {
if (handleAuthError(e)) return
ElMessage.error('保存失败:' + (e.response?.data?.detail || e.message || ''))
}
}
async function ensureLoggedIn(options: { silent?: boolean } = {}) {
if (!userStore.isLoggedIn()) {
if (!options.silent) ElMessage.warning('请先登录后再使用动画生成功能')
await router.push('/login')
return false
}
if (!userStore.user) {
try {
await userStore.fetchProfile()
} catch {
if (!options.silent) ElMessage.warning('登录状态失效,请重新登录')
await router.push('/login')
return false
}
}
return true
}
function isAuthError(error: any) {
return error?.response?.status === 401 ||
(error?.response?.status === 403 && error?.response?.data?.detail === 'Not authenticated')
}
function handleAuthError(error: any) {
if (!isAuthError(error)) return false
userStore.clearToken()
ElMessage.warning('登录状态失效,请重新登录')
router.push('/login')
return true
}
function toggleVoice() {
if (listening.value) {
speech.stop()
return
}
if (!speech.isSupported()) {
ElMessage.warning('当前浏览器不支持语音识别,请使用 Chrome/Edge 或直接输入文字')
return
}
speech.start()
}
async function loadSavedAnimations(options: { silent?: boolean } = {}) {
if (!(await ensureLoggedIn({ silent: true }))) {
savedAnimations.value = []
savedLoading.value = false
if (!options.silent) ElMessage.warning('请先登录后再查看已保存内容')
return
}
savedLoading.value = true
try {
const res: any = await getAnimations({ limit: 50 })
savedAnimations.value = res.data || res || []
} catch (e: any) {
if (handleAuthError(e)) {
savedAnimations.value = []
return
}
ElMessage.error('加载已保存动画失败:' + (e.response?.data?.detail || e.message || ''))
} finally {
savedLoading.value = false
}
}
async function handleRefreshSavedAnimations() {
await loadSavedAnimations()
}
function previewSaved(item: any) {
if (!item.config?.html) {
ElMessage.warning('当前动画没有可预览的 HTML')
return
}
result.value = {
...item.config,
html: ensureVisibleAnimation(item.config.html, item.title),
}
savedResultId.value = item.id
form.anim_type = item.anim_type || 'general'
window.scrollTo({ top: 0, behavior: 'smooth' })
}
async function ensureSavedCurrent() {
if (!result.value) return null
if (!(await ensureLoggedIn())) return null
const payload = {
status: 'draft',
title: result.value.title || '教学动画',
anim_type: form.anim_type,
description: result.value.description || '',
config: result.value,
}
const saved: any = savedResultId.value
? await updateAnimation(savedResultId.value, payload)
: await createAnimation(payload)
const item = saved.data || saved
savedResultId.value = item?.id || null
await loadSavedAnimations({ silent: true })
return savedResultId.value
}
async function publishCurrent() {
if (!result.value) return
publishing.value = true
try {
const id = await ensureSavedCurrent()
if (!id) return
await publishAnimationAsset({
id,
title: result.value.title || '教学动画',
anim_type: form.anim_type,
description: result.value.description || '',
config: result.value,
})
} finally {
publishing.value = false
}
}
async function publishSaved(item: any) {
if (!(await ensureLoggedIn())) return
await publishAnimationAsset(item)
}
async function publishAnimationAsset(item: any) {
const config = item.config || result.value || {}
const html = ensureVisibleAnimation(config.html || '', item.title)
const updated: any = await updateAnimation(item.id, {
title: item.title,
description: item.description || config.description || buildAnimationDescription(item),
config: { ...config, html },
status: 'published',
})
const saved = updated.data || updated
const created: any = await publishResource({
resource_type: 'animation',
title: saved.title,
description: saved.description || buildAnimationDescription(saved),
content_ref: `animation:${saved.id}`,
file_url: '',
tags: ['教学动画', typeLabel(saved.anim_type)],
subject: form.subject || typeLabel(saved.anim_type),
grade: '',
is_public: 1,
})
const resource = created.data || created
ElMessage.success('已发布到资源广场')
if (resource?.id) router.push(`/resources/${resource.id}`)
}
function buildAnimationDescription(item: any) {
const sceneCount = item.config?.scenes?.length || result.value?.scenes?.length || 0
return `${typeLabel(item.anim_type || form.anim_type)}教学动画,包含${sceneCount || '多个'}演示场景,可用于课堂投屏、知识讲解和课件插入。`
}
async function handleExportHtml() {
if (!result.value?.html) return
const html = ensureVisibleAnimation(result.value.html, result.value.title)
const blob = await exportHtml({ title: result.value.title || '教学动画', html }) as Blob
downloadBlob(blob, `${result.value.title || '教学动画'}.html`)
}
async function exportSavedAnimation(item: any) {
if (!(await ensureLoggedIn())) return
try {
const blob = await exportAnimationHtml(item.id)
downloadBlob(blob, `${safeFilename(item.title || '教学动画')}-动画.html`)
} catch (e: any) {
if (handleAuthError(e)) return
ElMessage.error('下载失败:' + (e.response?.data?.detail || e.message || ''))
}
}
function reuseCurrent() {
if (!result.value) return
localStorage.setItem('quick_create_prompt', `参考动画《${result.value.title || '教学动画'}》,重新生成一份原创教学动画。要求保留核心知识点,优化场景节奏、交互控制和课堂提问。`)
result.value = null
savedResultId.value = null
window.scrollTo({ top: 0, behavior: 'smooth' })
}
async function handleDeleteSaved(id: number) {
if (!(await ensureLoggedIn())) return
try {
await deleteAnimation(id)
ElMessage.success('动画已删除,关联资源已下架')
await loadSavedAnimations()
} catch (e: any) {
if (handleAuthError(e)) return
ElMessage.error('删除失败:' + (e.response?.data?.detail || e.message || ''))
}
}
function typeLabel(type: string) {
const map: Record<string, string> = {
math: '数学',
physics: '物理',
chemistry: '化学',
biology: '生物',
geography: '地理',
general: '通用',
}
return map[type] || '通用'
}
function safeFilename(value: string) {
return (value || '教学动画').replace(/[\\/:*?"<>|\r\n]+/g, '_').trim() || '教学动画'
}
function ensureVisibleAnimation(html: string, title?: string) {
const fallback = `
<div style='width:100%;height:100%;position:relative;overflow:hidden;background:linear-gradient(135deg,#052e25,#0f766e);font-family:Microsoft YaHei,sans-serif;color:#fff;'>
<div style='position:absolute;left:36px;top:30px;max-width:460px;'>
<h2 style='font-size:28px;margin:0 0 8px;line-height:1.2;'>${title || '教学动画'}</h2>
<p style='font-size:15px;opacity:.82;margin:0;'>当生成结果缺少可渲染内容时,这里会展示稳定的基础动画预览。</p>
</div>
<div style='position:absolute;left:80px;top:220px;width:72px;height:72px;border-radius:50%;background:#facc15;animation:floatBall 6s ease-in-out infinite,pulseGlow 2s ease-in-out infinite;'></div>
<style>
@keyframes floatBall {
0%,100% { transform: translateX(0) translateY(0); }
25% { transform: translateX(160px) translateY(-34px); }
50% { transform: translateX(320px) translateY(0); }
75% { transform: translateX(480px) translateY(-24px); }
}
@keyframes pulseGlow {
0%,100% { box-shadow: 0 0 18px rgba(250,204,21,.35); }
50% { box-shadow: 0 0 34px rgba(250,204,21,.7); }
}
</style>
</div>`
if (!html || html.trim().length < 120) return fallback
if (!/animation|@keyframes|canvas|svg|script|style/i.test(html)) return fallback
return html
}
onMounted(async () => {
const quickPrompt = localStorage.getItem('quick_create_prompt')
if (quickPrompt) {
form.prompt = quickPrompt
form.subject = inferSubject(quickPrompt)
form.anim_type = inferAnimationType(quickPrompt)
quickPromptNotice.value = '已带入首页动画指令,可直接生成或继续调整类型。'
localStorage.removeItem('quick_create_prompt')
}
await loadSavedAnimations({ silent: true })
await openSavedFromRoute()
})
async function openSavedFromRoute() {
const openId = Number(route.query.open)
if (!Number.isFinite(openId) || openId <= 0) return
try {
const data: any = await getAnimation(openId)
const item = data.data || data
previewSaved(item)
} catch {
ElMessage.warning('未能打开改编后的动画')
}
}
function inferSubject(text: string) {
const subjects = ['数学', '物理', '化学', '英语', '语文', '信息科技']
return subjects.find(item => text.includes(item)) || ''
}
function inferAnimationType(text: string) {
if (/数学|函数|几何|圆柱|公式/.test(text)) return 'math'
if (/物理|力|电|光/.test(text)) return 'physics'
if (/化学|实验|反应/.test(text)) return 'chemistry'
return 'general'
}
</script>
<style scoped>
.gen-hero { display: flex; align-items: center; gap: 16px; margin-bottom: 24px; }
.gen-hero-icon { width: 52px; height: 52px; border-radius: 14px; display: flex; align-items: center; justify-content: center; }
.gen-hero h2 { font-size: 24px; font-weight: 700; color: var(--text-primary); }
.gen-hero p { font-size: 14px; color: var(--text-muted); }
.gen-input-section { background: #fff; border: 1px solid var(--border-color); border-radius: var(--radius-lg); padding: 24px; margin-bottom: 24px; }
.quick-notice { min-height: 38px; display: flex; align-items: center; gap: 8px; border: 1px solid #dce8de; border-radius: 12px; background: #f0fbef; color: #00543d; padding: 8px 12px; margin-bottom: 14px; font-size: 13px; font-weight: 800; }
.prompt-box { margin-bottom: 16px; }
.prompt-box :deep(.el-textarea__inner) { border: none; background: #F8FAFC; border-radius: var(--radius-md); padding: 16px; font-size: 15px; line-height: 1.6; }
.prompt-footer { display: flex; justify-content: space-between; align-items: center; margin-top: 12px; }
.prompt-tags { display: flex; gap: 6px; }
.prompt-tag { padding: 4px 12px; background: #ECFDF5; color: #10B981; border-radius: 20px; font-size: 12px; cursor: pointer; transition: all 0.2s; }
.prompt-tag:hover { background: #10B981; color: #fff; }
.prompt-actions { display: flex; align-items: center; gap: 8px; }
.material-row { display: flex; flex-wrap: wrap; gap: 8px; padding: 14px 0 0; margin-top: 4px; border-top: 1px solid var(--border-color); }
.tpl-btn { display: inline-flex; align-items: center; gap: 6px; height: 38px; padding: 0 14px; border: 1px solid #c7d2fe; border-radius: 10px; background: #fff; color: #4f46e5; cursor: pointer; font-size: 13px; font-weight: 600; transition: all 0.2s; }
.tpl-btn:hover { border-color: #4f46e5; background: #eef2ff; }
.voice-btn { width: 38px; height: 38px; display: inline-flex; align-items: center; justify-content: center; border: 1px solid #cfe7d8; border-radius: 10px; background: #fff; color: #087f5b; cursor: pointer; }
.voice-btn:hover,
.voice-btn.active { border-color: #10b981; background: #ecfdf5; color: #047857; }
.gen-btn { display: flex; align-items: center; gap: 8px; border: none; color: #fff; padding: 10px 24px; border-radius: var(--radius-sm); font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.3s; }
.gen-btn:hover:not(:disabled) { opacity: 0.9; transform: translateY(-1px); }
.gen-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.settings-row { display: flex; gap: 20px; align-items: center; padding-top: 16px; border-top: 1px solid var(--border-color); }
.setting-item { display: flex; align-items: center; gap: 8px; }
.setting-item label { font-size: 13px; color: var(--text-muted); white-space: nowrap; }
.loading-section { text-align: center; padding: 48px 0; }
.loading-bar { width: 300px; height: 4px; background: #E2E8F0; border-radius: 2px; margin: 0 auto 20px; overflow: hidden; }
.loading-bar-fill { height: 100%; width: 40%; background: var(--gradient-green); border-radius: 2px; animation: loadingSlide 1.5s ease-in-out infinite; }
@keyframes loadingSlide { 0% { transform: translateX(-100%); } 100% { transform: translateX(350%); } }
.loading-section p { font-size: 14px; color: var(--text-secondary); }
.guide-section { animation: fadeIn 0.4s ease; }
.capability-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 14px; }
.capability-row div { min-height: 78px; border: 1px solid #dce8de; border-radius: 12px; background: #fff; padding: 14px 16px; }
.capability-row strong { display: block; color: #00543d; font-size: 15px; margin-bottom: 6px; }
.capability-row span { color: #6a7f77; font-size: 12px; line-height: 1.45; }
.guide-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
.guide-card { display: flex; flex-direction: column; gap: 10px; background: #fff; border: 1px solid var(--border-color); border-radius: var(--radius-md); padding: 18px; cursor: pointer; transition: all 0.3s; }
.guide-card:hover { box-shadow: var(--shadow-md); border-color: #10B981; transform: translateY(-2px); }
.guide-icon { width: 36px; height: 36px; border-radius: 10px; display: flex; align-items: center; justify-content: center; }
.guide-info h4 { font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 4px; }
.guide-info p { font-size: 12px; color: var(--text-muted); line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.guide-arrow { font-size: 12px; color: #10B981; font-weight: 500; margin-top: auto; }
.result-section { animation: fadeInUp 0.5s ease; }
.result-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; margin-bottom: 20px; }
.result-header h3 { font-size: 20px; font-weight: 600; color: var(--text-primary); margin-bottom: 4px; }
.result-desc { color: var(--text-muted); font-size: 14px; }
.result-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap; flex-shrink: 0; }
.save-btn { display: flex; align-items: center; gap: 6px; background: var(--gradient-green); border: none; color: #fff; padding: 8px 20px; border-radius: var(--radius-sm); font-size: 14px; font-weight: 500; cursor: pointer; transition: opacity 0.2s; flex-shrink: 0; }
.save-btn:hover { opacity: 0.9; }
.tool-btn { height: 34px; display: inline-flex; align-items: center; justify-content: center; gap: 6px; border: 1px solid #d9e7dc; border-radius: 8px; background: #fff; color: #31584d; padding: 0 12px; font-size: 13px; font-weight: 800; cursor: pointer; }
.tool-btn:hover { border-color: #a8cfab; background: #f6fbf6; color: #00543d; }
.tool-btn.publish { border-color: #0f8a5f; color: #0f8a5f; }
.tool-btn:disabled { opacity: .58; cursor: not-allowed; }
.animation-preview { background: #F1F5F9; border-radius: var(--radius-lg); padding: 24px; margin-bottom: 20px; }
.animation-preview :deep(.html-preview) { border-radius: var(--radius-md); box-shadow: 0 4px 24px rgba(0,0,0,0.08); }
.scenes-section { background: #fff; border: 1px solid var(--border-color); border-radius: var(--radius-md); padding: 20px; margin-bottom: 16px; }
.scenes-section h4 { font-size: 15px; font-weight: 600; color: var(--text-primary); margin-bottom: 14px; }
.scenes-list { display: flex; flex-direction: column; gap: 10px; }
.scene-card { display: flex; align-items: flex-start; gap: 12px; padding: 12px; background: #F8FAFC; border-radius: var(--radius-sm); }
.scene-num { width: 28px; height: 28px; background: var(--gradient-green); border-radius: 8px; display: flex; align-items: center; justify-content: center; color: #fff; font-size: 12px; font-weight: 700; flex-shrink: 0; }
.scene-info { flex: 1; }
.scene-name { font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 4px; }
.scene-meta { font-size: 12px; color: var(--text-muted); display: flex; gap: 12px; }
.scene-narration { color: var(--text-secondary); }
.result-meta-bar { padding: 12px 0; border-top: 1px solid var(--border-color); color: var(--text-muted); font-size: 13px; }
.btn-loading { width: 14px; height: 14px; border: 2px solid rgba(255,255,255,0.3); border-top-color: #fff; border-radius: 50%; animation: spin 0.6s linear infinite; }
.saved-section { margin-top: 28px; background: #fff; border: 1px solid var(--border-color); border-radius: var(--radius-lg); padding: 22px; }
.saved-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; margin-bottom: 18px; }
.saved-header h3 { font-size: 18px; font-weight: 700; color: var(--text-primary); margin-bottom: 4px; }
.saved-header p { font-size: 13px; color: var(--text-muted); }
.refresh-btn { height: 34px; display: inline-flex; align-items: center; gap: 6px; border: 1px solid #d9e7dc; border-radius: 17px; background: #fff; color: #00543d; padding: 0 14px; font-weight: 700; cursor: pointer; }
.saved-loading,
.saved-empty { min-height: 120px; display: flex; align-items: center; justify-content: center; gap: 8px; color: var(--text-muted); border: 1px dashed #dbe7de; border-radius: var(--radius-md); background: #f8faf8; }
.saved-empty .el-icon { font-size: 24px; color: #9aaba4; }
.saved-grid { display: grid; grid-template-columns: repeat(3, minmax(220px, 1fr)); gap: 16px; }
.saved-card { overflow: hidden; border: 1px solid #dce8de; border-radius: 12px; background: #fff; }
.saved-cover { height: 132px; display: flex; flex-direction: column; justify-content: center; gap: 8px; margin: 5px; padding: 18px; border-radius: 9px; color: #fff; background: linear-gradient(135deg, #063a2d, #00854f); }
.saved-cover .el-icon { font-size: 22px; opacity: .85; }
.saved-cover strong { font-size: 18px; line-height: 1.25; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.saved-cover span { width: fit-content; padding: 3px 8px; border-radius: 12px; background: rgba(255,255,255,.2); font-size: 12px; font-weight: 800; }
.saved-body { padding: 12px 16px 16px; }
.saved-body h4 { font-size: 15px; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-bottom: 6px; }
.saved-body p { min-height: 36px; color: var(--text-muted); font-size: 12px; line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.saved-actions { display: flex; gap: 8px; margin-top: 12px; }
.saved-actions button { height: 32px; display: inline-flex; align-items: center; justify-content: center; gap: 5px; border: 1px solid #d9e7dc; border-radius: 8px; background: #f8faf8; color: #31584d; padding: 0 12px; cursor: pointer; font-weight: 700; }
.saved-actions button:hover { border-color: #a8cfab; color: #00543d; }
.saved-actions .danger:hover { border-color: #fca5a5; background: #fef2f2; color: #ef4444; }
@media (max-width: 1080px) {
.saved-grid { grid-template-columns: repeat(2, minmax(220px, 1fr)); }
}
@media (max-width: 760px) {
.prompt-footer,
.settings-row,
.result-header,
.saved-header,
.result-actions { flex-direction: column; align-items: stretch; }
.capability-row,
.guide-cards,
.saved-grid { grid-template-columns: 1fr; }
}
</style>
+585
View File
@@ -0,0 +1,585 @@
<template>
<div class="assistant-page">
<aside class="conv-rail">
<button class="new-chat-btn" type="button" @click="startNewChat">
<el-icon><Plus /></el-icon>
<span>新建对话</span>
</button>
<div class="conv-search">
<el-input
v-model="keyword"
placeholder="搜索对话"
:prefix-icon="Search"
clearable
@input="loadConversations"
/>
</div>
<div class="conv-list">
<div v-if="loadingList && !conversations.length" class="conv-empty">加载中…</div>
<div v-else-if="!conversations.length" class="conv-empty">还没有对话,点上方“新建对话”开始吧。</div>
<div
v-for="conv in conversations"
:key="conv.id"
class="conv-item"
:class="{ active: conv.id === activeId }"
@click="selectConversation(conv.id)"
>
<el-icon class="pin" v-if="conv.pinned"><Top /></el-icon>
<div class="conv-meta">
<div class="conv-title">{{ conv.title }}</div>
<div class="conv-sub">{{ formatTime(conv.updated_at) }} · {{ conv.messages.length }} 条</div>
</div>
<el-dropdown trigger="click" @command="(c: string) => onConvMenu(c, conv)" @click.stop>
<span class="conv-more" @click.stop><el-icon><MoreFilled /></el-icon></span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="rename" :icon="Edit">重命名</el-dropdown-item>
<el-dropdown-item command="pin" :icon="Top">{{ conv.pinned ? '取消置顶' : '置顶' }}</el-dropdown-item>
<el-dropdown-item command="delete" :icon="Delete" divided>删除</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</aside>
<section class="chat-main">
<header class="chat-header">
<div class="chat-title-block">
<el-icon class="spark"><MagicStick /></el-icon>
<div>
<h1>{{ activeConversation?.title || 'AI教学助手' }}</h1>
<p class="chat-sub">备课 · 讲解 · 活动设计 · 学情诊断 · 班级管理,随时问我</p>
</div>
</div>
<div class="ctx-selectors">
<el-select v-model="ctxSubject" placeholder="学科" size="small" class="ctx-sel" filterable>
<el-option v-for="s in subjectOptions" :key="s" :label="s" :value="s" />
</el-select>
<el-select v-model="ctxGrade" placeholder="学段" size="small" class="ctx-sel">
<el-option v-for="g in gradeOptions" :key="g" :label="g" :value="g" />
</el-select>
</div>
</header>
<div ref="scrollRef" class="message-area">
<template v-if="activeConversation && activeConversation.messages.length">
<div
v-for="msg in activeConversation.messages"
:key="msg.id"
class="msg-row"
:class="msg.role"
>
<div class="avatar" :class="msg.role">
<el-icon v-if="msg.role === 'user'"><User /></el-icon>
<el-icon v-else><MagicStick /></el-icon>
</div>
<div class="bubble" :class="msg.role">
<MarkdownContent v-if="msg.role === 'assistant'" :content="msg.content" />
<p v-else class="user-text">{{ msg.content }}</p>
<button v-if="msg.role === 'assistant'" class="copy-btn" type="button" @click="copyText(msg.content)">
<el-icon><DocumentCopy /></el-icon>
</button>
</div>
</div>
<div v-if="sending" class="msg-row assistant">
<div class="avatar assistant"><el-icon><MagicStick /></el-icon></div>
<div class="bubble assistant typing">
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
</div>
</div>
</template>
<div v-else-if="!sending" class="welcome">
<div class="welcome-icon"><el-icon><MagicStick /></el-icon></div>
<h2>你好,我是你的AI教学助手</h2>
<p>告诉我你正在教的学科与年级,或者直接抛出一个教学问题,我会给你可落地的方案。</p>
<div class="suggest-grid">
<button
v-for="(s, i) in suggestions"
:key="i"
class="suggest-card"
type="button"
@click="useSuggestion(s)"
>
<span class="suggest-tag">{{ s.tag }}</span>
<span class="suggest-text">{{ s.text }}</span>
</button>
</div>
</div>
</div>
<footer class="composer">
<div class="composer-inner">
<el-input
v-model="draft"
type="textarea"
:autosize="{ minRows: 1, maxRows: 6 }"
placeholder="输入你的问题,Enter 发送,Shift+Enter 换行"
resize="none"
:disabled="sending"
@keydown.enter="onEnter"
/>
<button class="send-btn" type="button" :disabled="!draft.trim() || sending" @click="onSend">
<el-icon><Promotion /></el-icon>
</button>
</div>
<p class="composer-tip">AI 生成内容仅供参考,请教师结合实际学情审阅使用。</p>
</footer>
</section>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Plus, Edit, Delete, Top, MoreFilled, User, MagicStick, Promotion, DocumentCopy } from '@element-plus/icons-vue'
import {
listConversations, createConversation, getConversation,
renameConversation, deleteConversation, sendMessage,
type ChatConversation, type ChatMessage,
} from '@/api/chat'
import { useUserStore } from '@/stores/user'
import MarkdownContent from '@/components/common/MarkdownContent.vue'
const userStore = useUserStore()
const conversations = ref<ChatConversation[]>([])
const activeId = ref<number | null>(null)
const activeConversation = ref<ChatConversation | null>(null)
const keyword = ref('')
const draft = ref('')
const sending = ref(false)
const loadingList = ref(false)
const scrollRef = ref<HTMLElement | null>(null)
const ctxSubject = ref('')
const ctxGrade = ref('')
const subjectOptions = ['语文', '数学', '英语', '物理', '化学', '生物', '历史', '地理', '政治', '科学', '道德与法治', '信息技术', '通用技术', '音乐', '美术', '体育', '心理健康', '综合实践']
const gradeOptions = ['幼儿园', '小学低年级', '小学中年级', '小学高年级', '初中', '高中']
const suggestions = [
{ tag: '活动设计', text: '帮我设计一节小学三年级《分数初步认识》的导入活动,要有悬念感' },
{ tag: '知识点讲解', text: '怎样给初二学生讲透“浮力”的产生原因?请给出讲解话术' },
{ tag: '课堂提问', text: '《草船借箭》这课,设计5个能引发深度思考的追问' },
{ tag: '学情诊断', text: '学生在“一元二次方程”常见有哪些易错点?怎样针对性突破?' },
{ tag: '作业设计', text: '为《人体的呼吸》设计一份分层作业(基础/提升/拓展)' },
{ tag: '家校沟通', text: '家长反映孩子写作业拖拉,帮我拟一段委婉但有建议的回复' },
]
const activeMessages = computed(() => activeConversation.value?.messages ?? [])
async function loadConversations() {
loadingList.value = true
try {
conversations.value = await listConversations({ keyword: keyword.value || undefined })
} catch {
/* handled by interceptor */
} finally {
loadingList.value = false
}
}
async function selectConversation(id: number) {
if (activeId.value === id && activeConversation.value) return
activeId.value = id
try {
activeConversation.value = await getConversation(id)
ctxSubject.value = activeConversation.value.subject || ctxSubject.value
ctxGrade.value = activeConversation.value.grade || ctxGrade.value
scrollToBottom()
} catch { /* */ }
}
async function startNewChat() {
activeId.value = null
activeConversation.value = null
draft.value = ''
}
async function ensureConversation(): Promise<ChatConversation | null> {
if (activeConversation.value) return activeConversation.value
try {
const conv = await createConversation({ subject: ctxSubject.value, grade: ctxGrade.value })
conversations.value.unshift(conv)
activeId.value = conv.id
activeConversation.value = conv
return conv
} catch {
return null
}
}
function onEnter(e: KeyboardEvent | Event) {
if (!('shiftKey' in e)) return
if (e.shiftKey) return
e.preventDefault()
onSend()
}
async function onSend() {
const content = draft.value.trim()
if (!content || sending.value) return
if (userStore.user && !localStorage.getItem('access_token')) return
const conv = await ensureConversation()
if (!conv) return
const userMsg: ChatMessage = { role: 'user', content }
conv.messages.push(userMsg)
draft.value = ''
sending.value = true
scrollToBottom()
try {
const res = await sendMessage(conv.id, { content, subject: ctxSubject.value, grade: ctxGrade.value })
conv.messages.push(res.data)
if (res.title && conv.title === '新对话') {
conv.title = res.title
}
if (res.credits && userStore.user) {
userStore.user.credits = res.credits.balance
}
scrollToBottom()
} catch {
conv.messages.pop()
draft.value = content
} finally {
sending.value = false
scrollToBottom()
await loadConversations()
}
}
function useSuggestion(s: { text: string }) {
draft.value = s.text
}
async function onConvMenu(cmd: string, conv: ChatConversation) {
if (cmd === 'delete') {
try {
await ElMessageBox.confirm(`确定删除对话“${conv.title}”吗?`, '删除对话', { type: 'warning' })
} catch { return }
await deleteConversation(conv.id)
conversations.value = conversations.value.filter((c) => c.id !== conv.id)
if (activeId.value === conv.id) {
activeId.value = null
activeConversation.value = null
}
ElMessage.success('已删除')
} else if (cmd === 'rename') {
try {
const { value } = await ElMessageBox.prompt('请输入新的对话标题', '重命名', {
inputValue: conv.title, inputPattern: /.+/, inputErrorMessage: '标题不能为空',
})
const updated = await renameConversation(conv.id, { title: value })
conv.title = updated.title
} catch { /* */ }
} else if (cmd === 'pin') {
const updated = await renameConversation(conv.id, { title: conv.title, pinned: !conv.pinned })
conv.pinned = updated.pinned
await loadConversations()
}
}
function copyText(text: string) {
navigator.clipboard?.writeText(text).then(() => ElMessage.success('已复制')).catch(() => undefined)
}
function scrollToBottom() {
nextTick(() => {
const el = scrollRef.value
if (el) el.scrollTop = el.scrollHeight
})
}
function formatTime(t?: string) {
if (!t) return ''
const d = new Date(t)
const now = new Date()
const sameDay = d.toDateString() === now.toDateString()
return sameDay
? d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
: `${d.getMonth() + 1}/${d.getDate()}`
}
onMounted(async () => {
await loadConversations()
if (conversations.value.length) {
await selectConversation(conversations.value[0].id)
}
})
</script>
<style scoped>
.assistant-page {
display: grid;
grid-template-columns: 280px 1fr;
gap: 0;
height: 100%;
min-height: 0;
background: var(--bg-color);
}
.conv-rail {
display: flex;
flex-direction: column;
background: #fff;
border-right: 1px solid var(--border-color);
padding: 16px 12px;
min-height: 0;
}
.new-chat-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
height: 42px;
width: 100%;
border: none;
border-radius: var(--radius-sm);
background: var(--gradient-green);
color: #fff;
font-size: 14px;
font-weight: 700;
cursor: pointer;
box-shadow: var(--shadow-sm);
}
.new-chat-btn:hover { opacity: 0.92; }
.conv-search { margin: 14px 0 10px; }
.conv-list {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 4px;
min-height: 0;
}
.conv-empty {
color: var(--text-muted);
font-size: 13px;
text-align: center;
padding: 24px 8px;
line-height: 1.7;
}
.conv-item {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 10px;
border-radius: var(--radius-sm);
cursor: pointer;
border: 1px solid transparent;
}
.conv-item:hover { background: var(--primary-bg); }
.conv-item.active { background: var(--primary-bg); border-color: var(--primary-light); }
.conv-item .pin { color: var(--primary); font-size: 13px; flex-shrink: 0; }
.conv-meta { flex: 1; min-width: 0; }
.conv-title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.conv-sub { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
.conv-more {
width: 26px; height: 26px;
display: inline-flex; align-items: center; justify-content: center;
border-radius: 6px; color: var(--text-muted); cursor: pointer; flex-shrink: 0;
}
.conv-more:hover { background: #eef; color: var(--text-primary); }
.chat-main {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
height: 100%;
}
.chat-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px 28px;
background: #fff;
border-bottom: 1px solid var(--border-color);
}
.chat-title-block { display: flex; align-items: center; gap: 12px; min-width: 0; }
.chat-title-block .spark {
width: 42px; height: 42px;
display: inline-flex; align-items: center; justify-content: center;
border-radius: 12px;
background: var(--gradient-green);
color: #fff;
font-size: 22px;
flex-shrink: 0;
}
.chat-title-block h1 { font-size: 18px; font-weight: 700; color: var(--text-primary); }
.chat-sub { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
.ctx-selectors { display: flex; gap: 8px; flex-shrink: 0; }
.ctx-sel { width: 116px; }
.message-area {
flex: 1;
overflow-y: auto;
padding: 24px 28px 8px;
display: flex;
flex-direction: column;
gap: 18px;
min-height: 0;
}
.msg-row { display: flex; gap: 12px; align-items: flex-start; }
.msg-row.user { flex-direction: row-reverse; }
.avatar {
width: 36px; height: 36px;
flex-shrink: 0;
border-radius: 10px;
display: inline-flex; align-items: center; justify-content: center;
font-size: 18px;
}
.avatar.user { background: var(--gradient-blue); color: #fff; }
.avatar.assistant { background: var(--gradient-green); color: #fff; }
.bubble {
position: relative;
max-width: min(720px, 78%);
padding: 12px 16px;
border-radius: 14px;
font-size: 14px;
line-height: 1.75;
word-break: break-word;
}
.bubble.user {
background: var(--primary);
color: #fff;
border-top-right-radius: 4px;
}
.bubble.assistant {
background: #fff;
color: var(--text-primary);
border: 1px solid var(--border-color);
border-top-left-radius: 4px;
}
.user-text { white-space: pre-wrap; }
.copy-btn {
position: absolute;
right: 8px; bottom: 6px;
width: 26px; height: 26px;
display: inline-flex; align-items: center; justify-content: center;
border: none; background: transparent;
color: var(--text-muted); cursor: pointer;
border-radius: 6px; opacity: 0;
transition: opacity 0.2s;
}
.bubble.assistant:hover .copy-btn { opacity: 1; }
.copy-btn:hover { background: var(--primary-bg); color: var(--primary); }
.typing { display: inline-flex; align-items: center; gap: 5px; }
.typing .dot {
width: 7px; height: 7px; border-radius: 50%;
background: var(--primary-light);
animation: blink 1.2s infinite ease-in-out both;
}
.typing .dot:nth-child(2) { animation-delay: 0.2s; }
.typing .dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes blink { 0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); } 40% { opacity: 1; transform: scale(1); } }
.welcome {
margin: auto;
max-width: 720px;
text-align: center;
padding: 32px 16px;
}
.welcome-icon {
width: 64px; height: 64px;
margin: 0 auto 16px;
display: inline-flex; align-items: center; justify-content: center;
border-radius: 18px;
background: var(--gradient-green);
color: #fff; font-size: 32px;
box-shadow: var(--shadow-md);
}
.welcome h2 { font-size: 22px; font-weight: 700; color: var(--text-primary); margin-bottom: 8px; }
.welcome p { color: var(--text-secondary); font-size: 14px; line-height: 1.7; margin-bottom: 24px; }
.suggest-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
text-align: left;
}
.suggest-card {
display: flex; flex-direction: column; gap: 8px;
background: #fff;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 14px 16px;
cursor: pointer;
transition: all 0.2s;
}
.suggest-card:hover { border-color: var(--primary-light); box-shadow: var(--shadow-md); transform: translateY(-1px); }
.suggest-tag {
align-self: flex-start;
font-size: 12px; font-weight: 600;
color: var(--primary);
background: var(--primary-bg);
padding: 2px 8px; border-radius: 6px;
}
.suggest-text { font-size: 13px; color: var(--text-secondary); line-height: 1.6; }
.composer {
padding: 12px 28px 18px;
background: var(--bg-color);
border-top: 1px solid var(--border-color);
}
.composer-inner {
display: flex;
align-items: flex-end;
gap: 10px;
background: #fff;
border: 1px solid var(--border-color);
border-radius: 14px;
padding: 8px 8px 8px 14px;
box-shadow: var(--shadow-sm);
}
.composer-inner :deep(.el-textarea__inner) {
border: none; background: transparent; box-shadow: none;
padding: 6px 0; font-size: 14px; line-height: 1.6; resize: none;
}
.send-btn {
width: 38px; height: 38px;
flex-shrink: 0;
border: none; border-radius: 10px;
background: var(--gradient-green); color: #fff;
font-size: 18px; cursor: pointer;
display: inline-flex; align-items: center; justify-content: center;
transition: opacity 0.2s;
}
.send-btn:hover:not(:disabled) { opacity: 0.9; }
.send-btn:disabled { background: #c9d6cc; cursor: not-allowed; }
.composer-tip { font-size: 12px; color: var(--text-muted); margin-top: 8px; text-align: center; }
@media (max-width: 768px) {
.assistant-page { grid-template-columns: 1fr; }
.conv-rail { display: none; }
.suggest-grid { grid-template-columns: 1fr; }
.bubble { max-width: 88%; }
}
</style>
File diff suppressed because it is too large Load Diff
+529
View File
@@ -0,0 +1,529 @@
<template>
<div class="join-page">
<main class="join-shell">
<header class="join-header">
<span class="brand-mark">智</span>
<div>
<strong>智教助手 · 数据回收</strong>
<p>提交后老师端会实时看到统计结果</p>
</div>
</header>
<section v-if="loading" class="state-card">
<span class="loader"></span>
<strong>正在加载课堂活动</strong>
</section>
<section v-else-if="needsCode" class="code-card">
<div class="code-head">
<span>投放码</span>
<strong>输入老师给出的活动 ID</strong>
<p>也可以让老师复制完整投放链接,打开后会直接进入提交页。</p>
</div>
<form class="code-form" @submit.prevent="joinByCode">
<input
v-model="joinCode"
class="code-input"
inputmode="numeric"
maxlength="12"
placeholder="例如:1024"
/>
<button type="submit">进入活动</button>
</form>
</section>
<section v-else-if="!activity" class="state-card">
<el-icon><Warning /></el-icon>
<strong>活动不存在或已关闭</strong>
<p>请核对投放码,或让老师重新发布活动。</p>
<button type="button" @click="backToCodeEntry">重新输入投放码</button>
</section>
<section v-else-if="activity.status !== 'active'" class="state-card">
<el-icon><Warning /></el-icon>
<strong>{{ activity.status === 'closed' ? '活动已结束' : '活动暂未发布' }}</strong>
<p>请等待老师重新发布或切换到新的投放链接。</p>
<button type="button" @click="loadActivity">重新加载</button>
</section>
<section v-else-if="submitted" class="state-card success">
<el-icon><Check /></el-icon>
<strong>提交成功</strong>
<p>你的回答已进入课堂数据统计。</p>
<button type="button" @click="submitAnother">继续提交</button>
</section>
<section v-else class="activity-card">
<div class="activity-head">
<span>{{ activityTypeLabel(activity.activity_type) }}</span>
<small>{{ statusText[activity.status] || activity.status }}</small>
</div>
<h1>{{ activity.title }}</h1>
<p>{{ activity.description || '请根据课堂要求完成提交。' }}</p>
<div v-if="displayQuestion" class="question-box">
{{ displayQuestion }}
</div>
<label class="field-label">姓名</label>
<input v-model="form.student_name" class="text-input" placeholder="请输入姓名,可匿名" />
<template v-if="hasOptions">
<label class="field-label">选择答案</label>
<div class="option-list">
<button
v-for="option in activity.config.options"
:key="option"
type="button"
:class="{ active: form.answer === option }"
@click="form.answer = option"
>
{{ option }}
</button>
</div>
</template>
<template v-else>
<label class="field-label">我的回答</label>
<textarea v-model="form.answer" class="answer-input" placeholder="请输入你的回答"></textarea>
</template>
<button class="submit-btn" type="button" :disabled="submitting" @click="handleSubmit">
<span v-if="submitting" class="loader small"></span>
{{ submitting ? '提交中' : '提交回答' }}
</button>
</section>
</main>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { getPublicClassroomActivity, submitClassroomResponse } from '@/api/classroom'
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const submitting = ref(false)
const submitted = ref(false)
const activity = ref<any>(null)
const joinCode = ref('')
const form = reactive({
student_name: '',
answer: '',
})
const statusText: Record<string, string> = {
draft: '草稿',
active: '进行中',
closed: '已结束',
}
const hasOptions = computed(() => Array.isArray(activity.value?.config?.options) && activity.value.config.options.length > 0)
const displayQuestion = computed(() => {
const raw = activity.value?.config?.question || activity.value?.description || ''
return String(raw).replace(/\n\n【参考素材】[\s\S]*$/g, '').trim()
})
const needsCode = computed(() => !route.params.id && !route.query.code)
onMounted(loadActivity)
async function loadActivity() {
const id = Number(route.params.id || route.query.code)
if (!Number.isFinite(id) || id <= 0) {
loading.value = false
activity.value = null
return
}
loading.value = true
try {
activity.value = await getPublicClassroomActivity(id)
form.answer = activity.value?.config?.options?.[0] || ''
} catch {
activity.value = null
} finally {
loading.value = false
}
}
function joinByCode() {
const code = joinCode.value.trim()
if (!/^\d+$/.test(code)) {
ElMessage.warning('请输入数字投放码')
return
}
router.push(`/classroom/join/${code}`)
}
function backToCodeEntry() {
activity.value = null
joinCode.value = ''
router.push('/classroom/join')
}
async function handleSubmit() {
if (!activity.value) return
const answer = String(form.answer || '').trim()
if (!answer) {
ElMessage.warning('请填写或选择回答')
return
}
submitting.value = true
try {
await submitClassroomResponse(activity.value.id, {
student_name: form.student_name.trim() || '匿名',
answer,
meta: {
source: 'student_public_page',
user_agent: navigator.userAgent,
},
})
submitted.value = true
} finally {
submitting.value = false
}
}
function submitAnother() {
submitted.value = false
form.student_name = ''
form.answer = activity.value?.config?.options?.[0] || ''
}
function activityTypeLabel(type: string) {
const map: Record<string, string> = {
poll: '课堂投票',
qa: '即时问答',
quiz: '随堂测验',
checkin: '课堂签到',
feedback: '学习反馈',
}
return map[type] || '课堂活动'
}
</script>
<style scoped>
.join-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background:
linear-gradient(180deg, rgba(255,255,255,.72), rgba(248,250,247,.92)),
#f8faf7;
color: #173f35;
}
.join-shell {
width: min(100%, 520px);
}
.join-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 18px;
}
.brand-mark {
width: 44px;
height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid #cfe0d3;
border-radius: 50%;
background: #fff;
color: #0f4f3e;
font-size: 20px;
font-weight: 900;
}
.join-header strong {
display: block;
color: #0f4f3e;
font-size: 20px;
}
.join-header p {
margin: 4px 0 0;
color: #71847c;
font-size: 13px;
}
.activity-card,
.code-card,
.state-card {
border: 1px solid #cfe0d3;
border-radius: 18px;
background: rgba(255,255,255,.92);
box-shadow: 0 18px 42px rgba(17, 69, 52, .08);
padding: 24px;
}
.code-head {
display: grid;
gap: 8px;
text-align: center;
}
.code-head span {
width: fit-content;
display: inline-flex;
justify-self: center;
border-radius: 999px;
background: #e8f5ea;
color: #0f4f3e;
padding: 5px 12px;
font-size: 13px;
font-weight: 900;
}
.code-head strong {
color: #0f4f3e;
font-size: 24px;
}
.code-head p {
margin: 0;
color: #71847c;
line-height: 1.65;
}
.code-form {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
margin-top: 22px;
}
.code-input {
width: 100%;
height: 46px;
border: 1px solid #dce8de;
border-radius: 13px;
background: #fff;
color: #173f35;
outline: none;
padding: 0 15px;
font-family: inherit;
font-size: 18px;
font-weight: 900;
letter-spacing: 0;
}
.code-input:focus {
border-color: #0f8a5f;
}
.code-form button {
height: 46px;
border: none;
border-radius: 13px;
background: #0f5a42;
color: #fff;
padding: 0 18px;
font-weight: 900;
cursor: pointer;
}
.activity-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.activity-head span,
.activity-head small {
height: 26px;
display: inline-flex;
align-items: center;
border-radius: 13px;
padding: 0 10px;
font-size: 12px;
font-weight: 900;
}
.activity-head span {
background: #e8f5ea;
color: #0f4f3e;
}
.activity-head small {
background: #f1f5f9;
color: #5c6f67;
}
.activity-card h1 {
margin: 18px 0 8px;
color: #0f4f3e;
font-size: 28px;
line-height: 1.2;
}
.activity-card p {
margin: 0 0 22px;
color: #5d7169;
line-height: 1.7;
}
.question-box {
margin: -6px 0 22px;
border: 1px solid #dce8de;
border-radius: 12px;
background: #f7faf6;
color: #173f35;
padding: 13px;
line-height: 1.7;
font-weight: 800;
}
.field-label {
display: block;
margin: 18px 0 8px;
color: #31584d;
font-size: 13px;
font-weight: 900;
}
.text-input,
.answer-input {
width: 100%;
border: 1px solid #dce8de;
border-radius: 12px;
background: #fff;
color: #173f35;
outline: none;
font-family: inherit;
font-size: 15px;
}
.text-input {
height: 42px;
padding: 0 13px;
}
.answer-input {
min-height: 136px;
resize: vertical;
padding: 13px;
line-height: 1.65;
}
.text-input:focus,
.answer-input:focus {
border-color: #0f8a5f;
}
.option-list {
display: grid;
gap: 10px;
}
.option-list button {
min-height: 48px;
border: 1px solid #dce8de;
border-radius: 12px;
background: #fff;
color: #31584d;
font-size: 15px;
font-weight: 800;
text-align: left;
padding: 0 14px;
cursor: pointer;
}
.option-list button.active {
border-color: #0f8a5f;
background: #e8f5ea;
color: #0f4f3e;
}
.submit-btn {
width: 100%;
height: 46px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-top: 22px;
border: none;
border-radius: 13px;
background: #0f5a42;
color: #fff;
font-size: 16px;
font-weight: 900;
cursor: pointer;
}
.submit-btn:disabled {
opacity: .62;
cursor: not-allowed;
}
.state-card {
min-height: 220px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: #75867f;
text-align: center;
}
.state-card .el-icon {
font-size: 36px;
color: #0f8a5f;
}
.state-card strong {
color: #173f35;
font-size: 20px;
}
.state-card p {
margin: 0;
}
.state-card button {
height: 38px;
border: none;
border-radius: 19px;
background: #0f5a42;
color: #fff;
padding: 0 18px;
font-weight: 900;
cursor: pointer;
}
.loader {
width: 28px;
height: 28px;
display: inline-block;
border: 3px solid #dce8de;
border-top-color: #0f8a5f;
border-radius: 50%;
animation: spin .7s linear infinite;
}
.loader.small {
width: 16px;
height: 16px;
border-width: 2px;
border-top-color: #fff;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@media (max-width: 560px) {
.code-form {
grid-template-columns: 1fr;
}
}
</style>
+493
View File
@@ -0,0 +1,493 @@
<template>
<div class="page-container">
<div class="page-header">
<div>
<h2>模板社区</h2>
<p>交流互动课件、教学动画和课堂工具模板,沉淀可改编的教学案例。</p>
</div>
<button class="create-btn" @click="openCreateDialog">
<el-icon><EditPen /></el-icon> 发布模板
</button>
</div>
<div class="scope-tabs">
<button
v-for="scope in scopeOptions"
:key="scope.value"
type="button"
:class="{ active: activeScope === scope.value }"
@click="setScope(scope.value)"
>
{{ scope.label }}
</button>
</div>
<div class="community-tabs">
<button
v-for="tag in ['全部', ...hotTags]"
:key="tag"
type="button"
:class="{ active: activeTag === tag }"
@click="activeTag = tag"
>
{{ tag }}
</button>
</div>
<div class="community-layout">
<div class="posts-section">
<div v-loading="loading" class="post-list">
<div v-for="post in filteredPosts" :key="post.id" class="post-card" @click="openPost(post)">
<div class="post-cover" :class="postCover(post)">
<span>{{ postTypeLabel(post.post_type) }}</span>
</div>
<div class="post-body">
<div class="post-type-badge">
<el-tag :type="post.post_type === 'share' ? 'success' : post.post_type === 'help' ? 'warning' : undefined" size="small" effect="plain">
{{ postTypeLabel(post.post_type) }}
</el-tag>
</div>
<h3>{{ post.title }}</h3>
<div class="post-meta">
<span>{{ post.author?.name || '社区教师' }}</span>
<span v-if="primaryAttachment(post)">{{ resourceTypeLabel(primaryAttachment(post).resource_type) }}</span>
<span v-if="post.is_favorited">已收藏</span>
</div>
<p class="post-excerpt">{{ postExcerpt(post) }}</p>
<div class="post-tags">
<el-tag v-for="tag in post.tags" :key="tag" size="small" effect="plain" style="margin-right: 4px">{{ tag }}</el-tag>
</div>
<div class="post-stats">
<span><el-icon><View /></el-icon> {{ post.views }}</span>
<span><el-icon><Star /></el-icon> {{ post.likes }}</span>
<span><el-icon><ChatDotRound /></el-icon> {{ post.comments_count }}</span>
</div>
<div class="post-actions">
<button type="button" @click.stop="openPost(post)">查看详情</button>
<button type="button" class="primary" @click.stop="useTemplate(post)">使用模板</button>
<template v-if="isOwner(post)">
<button type="button" @click.stop="openEditDialog(post)">
<el-icon><Edit /></el-icon> 编辑
</button>
<el-popconfirm title="确定删除这个社区模板?" @confirm="handleDeletePost(post)">
<template #reference>
<button type="button" class="danger" @click.stop>
<el-icon><Delete /></el-icon> 删除
</button>
</template>
</el-popconfirm>
</template>
</div>
</div>
</div>
</div>
<div v-if="!loading && filteredPosts.length === 0" class="empty-state">
<el-icon :size="48" color="#CBD5E1"><ChatDotRound /></el-icon>
<p>{{ emptyText }}</p>
<button type="button" @click="openCreateDialog">发布第一个模板</button>
</div>
</div>
<div class="sidebar-section">
<div class="hot-tags-card">
<h4>热门标签</h4>
<div class="hot-tags">
<span v-for="tag in hotTags" :key="tag" class="hot-tag" @click="activeTag = tag">{{ tag }}</span>
</div>
</div>
<div class="hot-tags-card">
<h4>推荐模板</h4>
<div v-if="rankedPosts.length" class="template-rank">
<button v-for="(item, index) in rankedPosts" :key="item.id" type="button" @click="openPost(item)">
<span :class="rankTheme(index)">{{ index + 1 }}</span>
<div>
<strong>{{ item.title }}</strong>
<small>{{ primaryTag(item) }} · {{ formatCount(item.views) }}浏览</small>
</div>
</button>
</div>
<div v-else class="rank-empty">暂无推荐模板</div>
</div>
</div>
</div>
<el-dialog v-model="showCreate" :title="editingPost ? '编辑模板' : '发布模板'" width="600px" :close-on-click-modal="false">
<el-form :model="postForm" label-position="top">
<el-form-item label="标题">
<el-input v-model="postForm.title" placeholder="模板标题" />
</el-form-item>
<el-form-item label="类型">
<div class="type-select">
<label v-for="t in postTypes" :key="t.value" class="type-option" :class="{ active: postForm.post_type === t.value }" @click="postForm.post_type = t.value">
{{ t.label }}
</label>
</div>
</el-form-item>
<el-form-item label="内容">
<el-input v-model="postForm.content" type="textarea" :rows="8" placeholder="描述模板适用的学科、年级、教学场景和使用方法..." />
</el-form-item>
<el-form-item label="标签">
<el-select v-model="postForm.tags" multiple filterable allow-create placeholder="添加标签" style="width: 100%">
</el-select>
</el-form-item>
<el-form-item label="关联资源">
<el-select v-model="postForm.resource_id" clearable filterable placeholder="选择一个已发布资源作为模板来源" style="width: 100%" @visible-change="loadMyResourcesIfNeeded">
<el-option
v-for="item in myResources"
:key="item.id"
:label="`${item.title} · ${resourceTypeLabel(item.resource_type)}`"
:value="item.id"
/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showCreate = false">取消</el-button>
<el-button type="primary" @click="handleSubmitPost">{{ editingPost ? '保存修改' : '发布' }}</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, ref, reactive, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { getPosts, getPostRanking, createPost, updatePost, deletePost } from '@/api/community'
import { getMyResources } from '@/api/resource'
import { useUserStore } from '@/stores/user'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const loading = ref(false)
const posts = ref<any[]>([])
const rankedPosts = ref<any[]>([])
const hotTags = ['互动课件', '教学动画', '课堂游戏', 'AI工具', '公开课', '单元复习', '实验模拟']
const postTypes = [{ value: 'discussion', label: '案例' }, { value: 'share', label: '模板' }, { value: 'help', label: '需求' }]
const scopeOptions = [
{ value: 'all', label: '全部模板' },
{ value: 'mine', label: '我的发布' },
{ value: 'favorite', label: '我的收藏' },
]
const resourceTypes = [
{ value: 'courseware', label: '课件' },
{ value: 'animation', label: '动画' },
{ value: 'exercise', label: '习题' },
{ value: 'lesson_plan', label: '教案' },
{ value: 'exam', label: '试卷' },
{ value: 'other', label: '资源' },
]
const activeTag = ref('全部')
const activeScope = ref('all')
const showCreate = ref(false)
const editingPost = ref<any>(null)
const myResources = ref<any[]>([])
const loadingResources = ref(false)
const postForm = reactive({ title: '', content: '', post_type: 'discussion', tags: [] as string[], resource_id: null as number | null })
const isLoggedIn = computed(() => !!localStorage.getItem('access_token'))
const currentUserId = computed(() => userStore.user?.id)
const emptyText = computed(() => {
if (activeScope.value === 'mine') return '还没有发布模板'
if (activeScope.value === 'favorite') return '还没有收藏模板'
return '暂无模板内容'
})
const filteredPosts = computed(() => {
if (activeTag.value === '全部') return posts.value
return posts.value.filter(post => post.tags?.includes(activeTag.value))
})
function postTypeLabel(type: string) {
return postTypes.find(item => item.value === type)?.label || '讨论'
}
function resourceTypeLabel(type: string) {
return resourceTypes.find(item => item.value === type)?.label || '资源'
}
function primaryTag(post: any) {
return post.tags?.[0] || postTypeLabel(post.post_type)
}
function postExcerpt(post: any) {
const content = String(post.content || '')
return content.length > 150 ? `${content.slice(0, 150)}...` : content
}
function rankTheme(index: number) {
return ['rank-cream', 'rank-yellow', 'rank-mint'][index % 3]
}
function formatCount(value: any) {
const n = Number(value || 0)
if (n >= 10000) return `${(n / 10000).toFixed(1)}万`
return String(n)
}
function postCover(post: any) {
if (post.post_type === 'share') return 'cover-green'
if (post.post_type === 'help') return 'cover-yellow'
return 'cover-blue'
}
function targetPathForPost(post: any) {
const attachment = primaryAttachment(post)
if (attachment?.content_ref) return pathFromContentRef(attachment.content_ref)
const tags = post.tags || []
if (tags.includes('教学动画') || tags.includes('实验模拟')) return '/animation'
if (tags.includes('课堂游戏')) return '/exercise'
if (tags.includes('AI工具')) return '/classroom'
if (tags.includes('单元复习')) return '/lesson-plan'
return '/courseware/create'
}
function useTemplate(post: any) {
const attachment = primaryAttachment(post)
if (attachment?.resource_id) {
router.push(`/resources/${attachment.resource_id}`)
return
}
const prompt = `参考社区模板《${post.title}》进行原创改编。模板说明:${post.content || ''}`
localStorage.setItem('quick_create_prompt', prompt)
router.push(targetPathForPost(post))
}
function primaryAttachment(post: any) {
return Array.isArray(post.attachments) ? post.attachments[0] : null
}
function pathFromContentRef(ref: string) {
const [type, id] = String(ref).split(':')
if (type === 'courseware') return `/courseware/${id}`
if (type === 'animation') return `/animation?open=${id}`
if (type === 'exercise') return `/exercise?open=${id}`
if (type === 'lesson_plan') return `/lesson-plan?open=${id}`
if (type === 'exam') return `/exam?open=${id}`
return '/courseware/create'
}
async function loadData() {
loading.value = true
try {
const params = activeScope.value === 'all' ? undefined : { scope: activeScope.value }
const [res, ranking] = await Promise.all([
getPosts(params),
getPostRanking({ limit: 5 }),
]) as any[]
posts.value = Array.isArray(res) ? res : []
rankedPosts.value = Array.isArray(ranking) ? ranking : []
} catch {
posts.value = []
rankedPosts.value = []
}
finally { loading.value = false }
}
function syncScopeFromRoute() {
const scope = route.query.scope
const nextScope = scopeOptions.some(item => item.value === scope) ? String(scope) : 'all'
if (nextScope !== 'all' && !ensureLoggedIn(`/community?scope=${nextScope}`)) {
activeScope.value = 'all'
return false
}
activeScope.value = nextScope
activeTag.value = '全部'
return true
}
function resetPostForm() {
postForm.title = ''
postForm.content = ''
postForm.post_type = 'discussion'
postForm.tags = []
postForm.resource_id = null
editingPost.value = null
}
function openCreateDialog() {
if (!ensureLoggedIn('/community')) return
resetPostForm()
showCreate.value = true
}
function openEditDialog(post: any) {
if (!ensureLoggedIn('/community')) return
editingPost.value = post
postForm.title = post.title || ''
postForm.content = post.content || ''
postForm.post_type = post.post_type || 'discussion'
postForm.tags = Array.isArray(post.tags) ? [...post.tags] : []
postForm.resource_id = primaryAttachment(post)?.resource_id || null
showCreate.value = true
}
async function handleSubmitPost() {
if (!ensureLoggedIn('/community')) return
if (!postForm.title.trim() || !postForm.content.trim()) {
ElMessage.warning('请填写标题和内容')
return
}
const selectedResource = myResources.value.find(item => item.id === postForm.resource_id)
const payload = {
title: postForm.title,
content: postForm.content,
post_type: postForm.post_type,
tags: postForm.tags,
attachments: selectedResource ? [{
resource_id: selectedResource.id,
resource_type: selectedResource.resource_type,
content_ref: selectedResource.content_ref,
title: selectedResource.title,
}] : [],
}
if (editingPost.value) {
await updatePost(editingPost.value.id, payload)
ElMessage.success('已保存修改')
} else {
await createPost(payload)
ElMessage.success('发布成功')
}
showCreate.value = false
resetPostForm()
loadData()
}
async function openPost(post: any) {
router.push(`/community/${post.id}`)
}
async function handleDeletePost(post: any) {
if (!ensureLoggedIn('/community')) return
await deletePost(post.id)
ElMessage.success('已删除模板')
loadData()
}
async function setScope(scope: string) {
if (scope !== 'all' && !ensureLoggedIn('/community')) return
activeScope.value = scope
activeTag.value = '全部'
const nextScope = scope === 'all' ? undefined : scope
if (route.query.scope !== nextScope) {
await router.replace({ path: '/community', query: scope === 'all' ? {} : { scope } })
return
}
await loadData()
}
function isOwner(post: any) {
return currentUserId.value && post.user_id === currentUserId.value
}
async function loadMyResourcesIfNeeded(open: boolean) {
if (!open || myResources.value.length || loadingResources.value) return
if (!ensureLoggedIn('/community')) return
loadingResources.value = true
try {
const data: any = await getMyResources({ limit: 100 })
myResources.value = Array.isArray(data?.data) ? data.data : Array.isArray(data) ? data : []
} finally {
loadingResources.value = false
}
}
function ensureLoggedIn(redirect: string) {
if (localStorage.getItem('access_token')) return true
ElMessage.warning('请先登录后再参与社区互动')
router.push({ path: '/login', query: { redirect } })
return false
}
onMounted(async () => {
if (localStorage.getItem('access_token') && !userStore.user) {
await userStore.fetchProfile().catch(() => undefined)
}
if (syncScopeFromRoute()) loadData()
})
watch(() => route.query.scope, () => {
if (syncScopeFromRoute()) loadData()
})
</script>
<style scoped>
.page-header p { margin-top: 6px; color: var(--text-muted); font-size: 14px; }
.create-btn { display: flex; align-items: center; gap: 6px; background: var(--gradient); border: none; color: #fff; padding: 10px 20px; border-radius: var(--radius-sm); font-size: 14px; font-weight: 500; cursor: pointer; transition: all 0.3s; }
.create-btn:hover { opacity: 0.9; }
.scope-tabs,
.community-tabs { display: flex; align-items: center; gap: 10px; overflow-x: auto; scrollbar-width: none; }
.scope-tabs { margin-bottom: 10px; }
.community-tabs { margin-bottom: 18px; }
.scope-tabs::-webkit-scrollbar,
.community-tabs::-webkit-scrollbar { display: none; }
.scope-tabs button,
.community-tabs button { height: 34px; border: 1px solid #dbe8de; border-radius: 17px; background: #fff; color: #51665f; padding: 0 14px; white-space: nowrap; cursor: pointer; }
.scope-tabs button.active,
.scope-tabs button:hover,
.community-tabs button.active,
.community-tabs button:hover { border-color: #9ed0aa; background: #eaf7e9; color: #00543d; font-weight: 800; }
.community-layout { display: grid; grid-template-columns: 1fr 280px; gap: 24px; }
.post-list { display: flex; flex-direction: column; gap: 12px; }
.post-card { display: grid; grid-template-columns: 170px minmax(0, 1fr); gap: 16px; background: #fff; border: 1px solid var(--border-color); border-radius: var(--radius-lg); padding: 12px; cursor: pointer; transition: all 0.3s; }
.post-card:hover { box-shadow: var(--shadow-md); transform: translateY(-1px); }
.post-cover { position: relative; min-height: 124px; border-radius: 10px; overflow: hidden; }
.post-cover span { position: absolute; left: 10px; bottom: 10px; height: 24px; display: inline-flex; align-items: center; padding: 0 9px; border-radius: 12px; background: rgba(255,255,255,.88); color: #00543d; font-size: 12px; font-weight: 900; }
.cover-green { background: linear-gradient(135deg, #064231, #00844e); }
.cover-yellow { background: linear-gradient(135deg, #fff1a1, #ffd84d); }
.cover-blue { background: linear-gradient(135deg, #dbe8ff, #c8ddff); }
.post-body { min-width: 0; padding: 6px 4px; }
.post-type-badge { margin-bottom: 8px; }
.post-body h3 { font-size: 16px; font-weight: 600; color: var(--text-primary); margin-bottom: 8px; }
.post-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 7px; margin-bottom: 7px; color: #75867f; font-size: 12px; }
.post-meta span { display: inline-flex; align-items: center; height: 22px; border-radius: 11px; background: #f1f6f1; padding: 0 8px; }
.post-excerpt { font-size: 14px; color: var(--text-muted); line-height: 1.6; margin-bottom: 12px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.post-tags { margin-bottom: 12px; }
.post-stats { display: flex; gap: 16px; font-size: 13px; color: var(--text-muted); }
.post-stats span { display: flex; align-items: center; gap: 4px; }
.post-actions { display: flex; gap: 8px; margin-top: 12px; }
.post-actions button { height: 32px; border: 1px solid #dbe8de; border-radius: 8px; background: #fff; color: #31584d; padding: 0 12px; font-weight: 800; cursor: pointer; }
.post-actions button .el-icon { margin-right: 4px; vertical-align: -2px; }
.post-actions button.primary { border-color: #00543d; background: #00543d; color: #fff; }
.post-actions button.danger { border-color: #f3c9c9; color: #b42318; }
.post-actions button:hover { border-color: #9ed0aa; background: #f6fbf6; color: #00543d; }
.post-actions button.primary:hover { background: #004532; color: #fff; }
.post-actions button.danger:hover { border-color: #f0a8a8; background: #fff5f5; color: #9f1c14; }
.sidebar-section { position: sticky; top: 32px; }
.hot-tags-card { background: #fff; border: 1px solid var(--border-color); border-radius: var(--radius-lg); padding: 20px; margin-bottom: 14px; }
.hot-tags-card h4 { font-size: 15px; font-weight: 600; color: var(--text-primary); margin-bottom: 12px; }
.hot-tags { display: flex; flex-wrap: wrap; gap: 8px; }
.hot-tag { padding: 4px 12px; background: var(--primary-bg); color: var(--primary); border-radius: 20px; font-size: 13px; cursor: pointer; transition: all 0.2s; }
.hot-tag:hover { background: var(--primary); color: #fff; }
.template-rank { display: flex; flex-direction: column; gap: 10px; }
.template-rank button { display: grid; grid-template-columns: 56px minmax(0, 1fr); gap: 10px; align-items: center; border: none; background: transparent; padding: 0; text-align: left; cursor: pointer; }
.template-rank button > span { height: 42px; border-radius: 8px; display: inline-flex; align-items: center; justify-content: center; color: #173f35; font-weight: 900; }
.rank-cream { background: #f7e3a1; }
.rank-yellow { background: #ffd84d; }
.rank-mint { background: #dff7e5; }
.template-rank strong { display: block; color: #173f35; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.template-rank small { color: #87958f; font-size: 12px; }
.rank-empty { border: 1px dashed #dbe8de; border-radius: 10px; color: #87958f; font-size: 13px; padding: 14px; text-align: center; }
.type-select { display: flex; gap: 8px; }
.type-option { padding: 8px 20px; border: 1px solid var(--border-color); border-radius: var(--radius-sm); font-size: 14px; color: var(--text-secondary); cursor: pointer; transition: all 0.2s; }
.type-option.active { background: var(--primary-bg); border-color: var(--primary); color: var(--primary); font-weight: 500; }
.empty-state { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 60px 0; }
.empty-state p { color: var(--text-muted); font-size: 15px; }
.empty-state button { border: none; border-radius: 18px; background: #00543d; color: #fff; padding: 8px 18px; font-weight: 800; cursor: pointer; }
@media (max-width: 980px) {
.community-layout { grid-template-columns: 1fr; }
.sidebar-section { position: static; }
}
@media (max-width: 640px) {
.post-card { grid-template-columns: 1fr; }
.post-actions { flex-direction: column; }
}
</style>
+461
View File
@@ -0,0 +1,461 @@
<template>
<div class="community-detail-page">
<section class="detail-shell" v-loading="loading">
<div class="top-row">
<button type="button" class="back-btn" @click="router.push('/community')">
<el-icon><ArrowLeft /></el-icon>
返回模板社区
</button>
<div class="top-actions">
<button v-if="isOwner" type="button" class="share-btn" @click="openEditDialog">
<el-icon><Edit /></el-icon>
编辑
</button>
<el-popconfirm v-if="isOwner" title="确定删除这个社区模板?" @confirm="handleDeletePost">
<template #reference>
<button type="button" class="share-btn danger">
<el-icon><Delete /></el-icon>
删除
</button>
</template>
</el-popconfirm>
<button type="button" class="share-btn" @click="sharePost">
<el-icon><Share /></el-icon>
分享
</button>
</div>
</div>
<div v-if="post" class="detail-layout">
<main class="post-panel">
<div class="post-cover" :class="postCover(post)">
<span>{{ postTypeLabel(post.post_type) }}</span>
<h1>{{ post.title }}</h1>
<p>{{ post.tags?.join(' · ') || '教学模板' }}</p>
</div>
<article class="content-block">
<h2>模板说明</h2>
<div class="author-line" v-if="post.author">
<span>{{ post.author.name?.[0] || '师' }}</span>
<strong>{{ post.author.name || '智教教师' }}</strong>
<em>{{ [post.author.school, post.author.subject].filter(Boolean).join(' · ') || '社区创作者' }}</em>
</div>
<p>{{ post.content }}</p>
</article>
<article class="content-block" v-if="primaryAttachment">
<h2>关联资源</h2>
<button type="button" class="attachment-card" @click="openAttachment">
<span>{{ resourceTypeLabel(primaryAttachment.resource_type) }}</span>
<strong>{{ primaryAttachment.title || '社区模板资源' }}</strong>
<em>打开资源详情或作品编辑入口</em>
</button>
</article>
<article class="content-block">
<h2>使用建议</h2>
<div class="tips-grid">
<div v-for="item in usageTips" :key="item.title">
<strong>{{ item.title }}</strong>
<span>{{ item.desc }}</span>
</div>
</div>
</article>
<article class="content-block">
<h2>评论 {{ post.comments_count || comments.length }}</h2>
<div class="comment-list" v-if="comments.length">
<div v-for="comment in comments" :key="comment.id" class="comment-item">
<div class="comment-avatar">{{ comment.author?.name?.[0] || 'U' }}</div>
<div>
<p>{{ comment.content }}</p>
<span>{{ comment.created_at || '刚刚' }}</span>
</div>
<el-popconfirm v-if="canDeleteComment(comment)" title="确定删除这条评论?" @confirm="handleDeleteComment(comment)">
<template #reference>
<button type="button" class="comment-delete">
<el-icon><Delete /></el-icon>
</button>
</template>
</el-popconfirm>
</div>
</div>
<div v-else class="comment-empty">暂无评论,登录后可以参与讨论。</div>
<div class="comment-input">
<el-input v-model="commentText" placeholder="写评论...">
<template #append>
<el-button @click="handleComment">{{ isLoggedIn ? '发送' : '登录评论' }}</el-button>
</template>
</el-input>
</div>
</article>
</main>
<aside class="side-panel">
<div class="stats-card">
<div><strong>{{ formatCount(post.views) }}</strong><span>浏览</span></div>
<div><strong>{{ formatCount(post.likes) }}</strong><span>点赞</span></div>
<div><strong>{{ formatCount(post.comments_count || comments.length) }}</strong><span>评论</span></div>
</div>
<div class="action-stack">
<button type="button" class="primary" @click="useTemplate(post)">
<el-icon><MagicStick /></el-icon>
使用模板生成
</button>
<button type="button" @click="likeCurrent">
<el-icon><Star /></el-icon>
{{ post.is_favorited ? '已收藏,点击取消' : '收藏模板' }}
</button>
<button type="button" @click="sharePost">
<el-icon><Share /></el-icon>
复制链接
</button>
</div>
<div class="action-stack danger-zone" v-if="isOwner">
<button type="button" @click="openEditDialog">
<el-icon><Edit /></el-icon>
编辑模板
</button>
<el-popconfirm title="确定删除这个社区模板?" @confirm="handleDeletePost">
<template #reference>
<button type="button" class="danger">
<el-icon><Delete /></el-icon>
删除模板
</button>
</template>
</el-popconfirm>
</div>
<div class="tag-card" v-if="post.tags?.length">
<strong>标签</strong>
<div>
<span v-for="tag in post.tags" :key="tag">{{ tag }}</span>
</div>
</div>
</aside>
</div>
<div v-else-if="!loading" class="empty-state">
<el-icon><ChatDotRound /></el-icon>
<strong>帖子不存在</strong>
<button type="button" @click="router.push('/community')">返回模板社区</button>
</div>
</section>
<el-dialog v-model="showEdit" title="编辑模板" width="600px" :close-on-click-modal="false">
<el-form :model="editForm" label-position="top">
<el-form-item label="标题">
<el-input v-model="editForm.title" placeholder="模板标题" />
</el-form-item>
<el-form-item label="类型">
<div class="type-select">
<label
v-for="t in postTypes"
:key="t.value"
class="type-option"
:class="{ active: editForm.post_type === t.value }"
@click="editForm.post_type = t.value"
>
{{ t.label }}
</label>
</div>
</el-form-item>
<el-form-item label="内容">
<el-input v-model="editForm.content" type="textarea" :rows="8" />
</el-form-item>
<el-form-item label="标签">
<el-select v-model="editForm.tags" multiple filterable allow-create placeholder="添加标签" style="width: 100%" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showEdit = false">取消</el-button>
<el-button type="primary" @click="handleUpdatePost">保存修改</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { createComment, deleteComment, deletePost, getComments, getPost, likePost, unlikePost, updatePost } from '@/api/community'
import { useUserStore } from '@/stores/user'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const loading = ref(false)
const post = ref<any>(null)
const comments = ref<any[]>([])
const commentText = ref('')
const showEdit = ref(false)
const editForm = reactive({ title: '', content: '', post_type: 'discussion', tags: [] as string[] })
const isLoggedIn = computed(() => !!localStorage.getItem('access_token'))
const currentUserId = computed(() => userStore.user?.id)
const isOwner = computed(() => post.value?.user_id && post.value.user_id === currentUserId.value)
const postTypes = [
{ value: 'discussion', label: '案例' },
{ value: 'share', label: '模板' },
{ value: 'help', label: '需求' },
]
const resourceTypes = [
{ value: 'courseware', label: '课件' },
{ value: 'animation', label: '动画' },
{ value: 'exercise', label: '习题' },
{ value: 'lesson_plan', label: '教案' },
{ value: 'exam', label: '试卷' },
{ value: 'other', label: '资源' },
]
const usageTips = computed(() => [
{ title: '快速改编', desc: '一键带入生成器,保留结构但输出原创内容。' },
{ title: '课堂复用', desc: '适合公开课、单元复习、课堂互动或课后练习。' },
{ title: '教研沉淀', desc: '可作为备课组案例,继续补充评论与改进建议。' },
])
const primaryAttachment = computed(() => Array.isArray(post.value?.attachments) ? post.value.attachments[0] : null)
function postTypeLabel(type: string) {
return postTypes.find(item => item.value === type)?.label || '讨论'
}
function resourceTypeLabel(type: string) {
return resourceTypes.find(item => item.value === type)?.label || '资源'
}
function postCover(item: any) {
if (item.post_type === 'share') return 'cover-green'
if (item.post_type === 'help') return 'cover-yellow'
return 'cover-blue'
}
function formatCount(value: any) {
const n = Number(value || 0)
if (n >= 10000) return `${(n / 10000).toFixed(1)}万`
return String(n)
}
function targetPathForPost(item: any) {
const attachment = Array.isArray(item.attachments) ? item.attachments[0] : null
if (attachment?.content_ref) return pathFromContentRef(attachment.content_ref)
const tags = item.tags || []
if (tags.includes('教学动画') || tags.includes('实验模拟')) return '/animation'
if (tags.includes('课堂游戏')) return '/exercise'
if (tags.includes('AI工具')) return '/classroom'
if (tags.includes('单元复习')) return '/lesson-plan'
return '/courseware/create'
}
function useTemplate(item: any) {
const attachment = Array.isArray(item.attachments) ? item.attachments[0] : null
if (attachment?.resource_id) {
router.push(`/resources/${attachment.resource_id}`)
return
}
const prompt = `参考社区模板《${item.title}》进行原创改编。模板说明:${item.content || ''}`
localStorage.setItem('quick_create_prompt', prompt)
router.push(targetPathForPost(item))
}
function pathFromContentRef(ref: string) {
const [type, id] = String(ref).split(':')
if (type === 'courseware') return `/courseware/${id}`
if (type === 'animation') return `/animation?open=${id}`
if (type === 'exercise') return `/exercise?open=${id}`
if (type === 'lesson_plan') return `/lesson-plan?open=${id}`
if (type === 'exam') return `/exam?open=${id}`
return '/courseware/create'
}
function openAttachment() {
if (!primaryAttachment.value) return
if (primaryAttachment.value.resource_id) {
router.push(`/resources/${primaryAttachment.value.resource_id}`)
return
}
if (primaryAttachment.value.content_ref) {
router.push(pathFromContentRef(primaryAttachment.value.content_ref))
}
}
async function likeCurrent() {
if (!post.value?.id) return
if (!isLoggedIn.value) {
ElMessage.warning('请先登录后再收藏模板')
router.push({ path: '/login', query: { redirect: route.fullPath } })
return
}
const data: any = post.value.is_favorited
? await unlikePost(post.value.id)
: await likePost(post.value.id)
post.value.likes = data.likes
post.value.is_favorited = data.is_favorited
ElMessage.success(data.is_favorited ? '已收藏' : '已取消收藏')
}
async function handleComment() {
if (!post.value || !commentText.value.trim()) return
if (!isLoggedIn.value) {
ElMessage.warning('请先登录后再评论')
router.push({ path: '/login', query: { redirect: route.fullPath } })
return
}
await createComment(post.value.id, { content: commentText.value })
commentText.value = ''
comments.value = (await getComments(post.value.id)) as any
post.value.comments_count = comments.value.length
}
function openEditDialog() {
if (!post.value) return
editForm.title = post.value.title || ''
editForm.content = post.value.content || ''
editForm.post_type = post.value.post_type || 'discussion'
editForm.tags = Array.isArray(post.value.tags) ? [...post.value.tags] : []
showEdit.value = true
}
async function handleUpdatePost() {
if (!post.value?.id) return
if (!editForm.title.trim() || !editForm.content.trim()) {
ElMessage.warning('请填写标题和内容')
return
}
const updated: any = await updatePost(post.value.id, {
title: editForm.title,
content: editForm.content,
post_type: editForm.post_type,
tags: editForm.tags,
})
post.value = updated
showEdit.value = false
ElMessage.success('已保存修改')
}
async function handleDeletePost() {
if (!post.value?.id) return
await deletePost(post.value.id)
ElMessage.success('已删除模板')
router.push('/community')
}
function canDeleteComment(comment: any) {
return currentUserId.value && (comment.user_id === currentUserId.value || post.value?.user_id === currentUserId.value)
}
async function handleDeleteComment(comment: any) {
if (!post.value?.id) return
await deleteComment(post.value.id, comment.id)
comments.value = comments.value.filter(item => item.id !== comment.id)
post.value.comments_count = comments.value.length
ElMessage.success('已删除评论')
}
function sharePost() {
navigator.clipboard?.writeText(window.location.href)
ElMessage.success('链接已复制')
}
onMounted(async () => {
loading.value = true
try {
if (localStorage.getItem('access_token') && !userStore.user) {
await userStore.fetchProfile().catch(() => undefined)
}
const id = Number(route.params.id)
post.value = await getPost(id) as any
comments.value = await getComments(id) as any
} catch {
post.value = null
comments.value = []
} finally {
loading.value = false
}
})
</script>
<style scoped>
.community-detail-page { min-height: calc(100vh - var(--header-height)); background: #f7faf6; padding: 28px 36px; }
.detail-shell { max-width: 1200px; margin: 0 auto; }
.top-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 18px; }
.top-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
.back-btn,
.share-btn { height: 36px; display: inline-flex; align-items: center; gap: 7px; border: 1px solid #dbe8de; border-radius: 18px; background: #fff; color: #31584d; padding: 0 14px; font-weight: 800; cursor: pointer; }
.share-btn.danger { border-color: #f3c9c9; color: #b42318; }
.share-btn.danger:hover { border-color: #f0a8a8; background: #fff5f5; }
.detail-layout { display: grid; grid-template-columns: minmax(0, 1fr) 300px; gap: 22px; }
.post-panel,
.side-panel,
.content-block,
.stats-card,
.tag-card { border: 1px solid #dce8de; border-radius: 14px; background: #fff; }
.post-panel { overflow: hidden; }
.post-cover { min-height: 300px; display: flex; flex-direction: column; justify-content: flex-end; padding: 38px; }
.post-cover span { width: fit-content; border-radius: 16px; background: rgba(255,255,255,.86); color: #00543d; padding: 5px 12px; font-size: 13px; font-weight: 900; }
.post-cover h1 { max-width: 720px; margin: 18px 0 10px; font-size: 40px; line-height: 1.14; }
.post-cover p { font-size: 14px; opacity: .78; }
.cover-green { color: #fff; background: linear-gradient(135deg, #064231, #00844e); }
.cover-yellow { color: #654100; background: linear-gradient(135deg, #fff1a1, #ffd84d); }
.cover-blue { color: #003e6b; background: linear-gradient(135deg, #dbe8ff, #c8ddff); }
.content-block { margin: 18px; padding: 20px; }
.content-block h2 { color: #173f35; font-size: 18px; margin-bottom: 12px; }
.content-block > p { color: #51665f; line-height: 1.9; white-space: pre-line; }
.author-line { display: flex; align-items: center; gap: 9px; margin-bottom: 14px; color: #75867f; }
.author-line span { width: 32px; height: 32px; display: inline-flex; align-items: center; justify-content: center; border-radius: 10px; background: #d9f0d6; color: #00543d; font-weight: 900; }
.author-line strong { color: #173f35; font-size: 14px; }
.author-line em { font-style: normal; font-size: 12px; }
.attachment-card { width: 100%; display: grid; gap: 7px; border: 1px solid #dce8de; border-radius: 12px; background: #f8faf8; padding: 14px; text-align: left; cursor: pointer; }
.attachment-card:hover { border-color: #9ed0aa; background: #f1faf3; }
.attachment-card span { width: fit-content; border-radius: 999px; background: #eaf7e9; color: #00543d; padding: 4px 9px; font-size: 12px; font-weight: 900; }
.attachment-card strong { color: #173f35; font-size: 16px; }
.attachment-card em { color: #75867f; font-size: 12px; font-style: normal; }
.tips-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.tips-grid div { border: 1px solid #dce8de; border-radius: 12px; background: #f8faf8; padding: 14px; }
.tips-grid strong { display: block; color: #00543d; margin-bottom: 6px; }
.tips-grid span { color: #6a7f77; font-size: 13px; line-height: 1.5; }
.comment-list { display: grid; gap: 12px; margin-bottom: 14px; }
.comment-item { position: relative; display: flex; gap: 10px; border: 1px solid #eef3ef; border-radius: 12px; padding: 12px; background: #fbfdfb; }
.comment-avatar { width: 32px; height: 32px; border-radius: 10px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; background: #d9f0d6; color: #00543d; font-weight: 900; }
.comment-item p { color: #31584d; line-height: 1.6; }
.comment-item span,
.comment-empty { color: #75867f; font-size: 12px; }
.comment-empty { margin-bottom: 14px; }
.comment-delete { position: absolute; top: 10px; right: 10px; width: 28px; height: 28px; display: inline-flex; align-items: center; justify-content: center; border: 1px solid #f3c9c9; border-radius: 8px; background: #fff; color: #b42318; cursor: pointer; }
.comment-delete:hover { background: #fff5f5; }
.side-panel { height: fit-content; position: sticky; top: 82px; padding: 16px; }
.stats-card { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; padding: 12px; text-align: center; }
.stats-card strong { display: block; color: #00543d; font-size: 20px; }
.stats-card span { color: #75867f; font-size: 12px; }
.action-stack { display: grid; gap: 10px; margin-top: 12px; }
.action-stack button { height: 40px; display: inline-flex; align-items: center; justify-content: center; gap: 7px; border: 1px solid #dbe8de; border-radius: 10px; background: #fff; color: #31584d; font-weight: 900; cursor: pointer; }
.action-stack .primary { border-color: #00543d; background: #00543d; color: #fff; }
.action-stack .danger { border-color: #f3c9c9; color: #b42318; }
.action-stack .danger:hover { border-color: #f0a8a8; background: #fff5f5; }
.danger-zone { border-top: 1px solid #e7eee8; padding-top: 12px; }
.type-select { display: flex; gap: 8px; flex-wrap: wrap; }
.type-option { padding: 8px 20px; border: 1px solid #dce8de; border-radius: 8px; color: #51665f; cursor: pointer; transition: all .2s; }
.type-option.active { background: #eaf7e9; border-color: #00543d; color: #00543d; font-weight: 800; }
.tag-card { margin-top: 12px; padding: 14px; }
.tag-card strong { display: block; color: #173f35; margin-bottom: 10px; }
.tag-card div { display: flex; flex-wrap: wrap; gap: 8px; }
.tag-card span { border-radius: 999px; background: #edf7ee; color: #00543d; padding: 6px 10px; font-size: 12px; font-weight: 800; }
.empty-state { min-height: 360px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; border: 1px dashed #cfe0d3; border-radius: 14px; background: #fff; color: #75867f; }
.empty-state .el-icon { font-size: 36px; color: #9aaba4; }
.empty-state button { border: none; border-radius: 18px; background: #00543d; color: #fff; padding: 8px 18px; font-weight: 800; cursor: pointer; }
@media (max-width: 980px) {
.detail-layout,
.tips-grid { grid-template-columns: 1fr; }
.side-panel { position: static; }
}
@media (max-width: 640px) {
.community-detail-page { padding: 16px; }
.top-row { align-items: stretch; flex-direction: column; gap: 10px; }
.top-actions { justify-content: flex-start; }
.post-cover { min-height: 240px; padding: 28px; }
.post-cover h1 { font-size: 30px; }
}
</style>
File diff suppressed because it is too large Load Diff
+621
View File
@@ -0,0 +1,621 @@
<template>
<div class="page-container detail-page">
<div class="detail-topbar">
<div class="topbar-left">
<button class="back-btn" @click="router.back()">
<el-icon :size="16"><ArrowLeft /></el-icon>
<span>返回</span>
</button>
<div class="topbar-divider"></div>
<h2 class="detail-title" v-if="!editingTitle">{{ courseware?.title || '课件详情' }}</h2>
<el-input v-else v-model="courseware.title" size="default" style="max-width: 360px" @blur="editingTitle = false" @keyup.enter="editingTitle = false" />
<button class="icon-btn" v-if="courseware && !editingTitle" @click="editingTitle = true">
<el-icon :size="13"><Edit /></el-icon>
</button>
</div>
<div class="topbar-right">
<el-tag v-if="courseware?.status" :type="courseware.status === 'published' ? 'success' : 'info'" size="default" effect="plain" round>
{{ courseware.status === 'published' ? '已发布' : '草稿' }}
</el-tag>
<button class="action-btn" @click="openFullPreview">
<el-icon :size="15"><FullScreen /></el-icon>
全屏预览
</button>
<button class="action-btn" @click="handleExportHtml">
<el-icon :size="15"><Upload /></el-icon>
下载HTML
</button>
<button class="action-btn" :disabled="exportingDocx" @click="handleExportDocx">
<el-icon :size="15"><Document /></el-icon>
{{ exportingDocx ? '导出中' : '下载Word' }}
</button>
<button class="action-btn" :disabled="sharing" @click="handleShare">
<el-icon :size="15"><Share /></el-icon>
{{ sharing ? '生成中' : '分享链接' }}
</button>
<button class="action-btn publish" :disabled="publishing" @click="handlePublish">
<el-icon :size="15"><Share /></el-icon>
{{ publishing ? '发布中' : '发布资源' }}
</button>
<button class="action-btn primary" @click="handleSave">
<el-icon :size="15"><Check /></el-icon>
保存
</button>
</div>
</div>
<div class="meta-bar" v-if="courseware">
<div class="meta-chips">
<div class="meta-chip"><el-icon :size="14" color="var(--primary)"><Document /></el-icon><span>{{ courseware.content?.length || 0 }} 页</span></div>
<div class="meta-chip" v-if="courseware.subject"><el-icon :size="14" color="#10B981"><Collection /></el-icon><span>{{ courseware.subject }}</span></div>
<div class="meta-chip" v-if="courseware.grade"><el-icon :size="14" color="#F59E0B"><User /></el-icon><span>{{ courseware.grade }}</span></div>
</div>
</div>
<div v-loading="loading">
<div v-if="courseware?.content?.length" class="content-area">
<div class="page-tabs">
<div class="page-tabs-scroll">
<div v-for="(page, idx) in courseware.content" :key="idx" class="page-tab" :class="{ active: currentPage === idx }" @click="currentPage = idx">
<span class="page-tab-num">{{ idx + 1 }}</span>
<span class="page-tab-label">{{ page.title || getPageTypeLabel(page.type) }}</span>
<span class="page-tab-actions" v-if="currentPage === idx">
<button type="button" class="tab-mini" title="左移" @click.stop="movePage(idx, -1)" :disabled="idx === 0"><el-icon :size="12"><ArrowLeft /></el-icon></button>
<button type="button" class="tab-mini" title="右移" @click.stop="movePage(idx, 1)" :disabled="idx === courseware.content.length - 1"><el-icon :size="12"><ArrowRight /></el-icon></button>
<button type="button" class="tab-mini" title="复制页" @click.stop="duplicatePage(idx)"><el-icon :size="12"><CopyDocument /></el-icon></button>
<button type="button" class="tab-mini danger" title="删除页" @click.stop="deletePage(idx)" :disabled="courseware.content.length <= 1"><el-icon :size="12"><Delete /></el-icon></button>
</span>
</div>
<button type="button" class="page-tab add-tab" @click="addPage">
<el-icon :size="14"><Plus /></el-icon>
<span>添加页</span>
</button>
</div>
</div>
<div class="page-edit-area">
<div class="page-edit-bar">
<div class="page-edit-fields">
<el-input v-model="courseware.content[currentPage].title" size="default" placeholder="页面标题" class="title-input">
<template #prefix><span style="font-size:12px;color:var(--text-muted)">标题</span></template>
</el-input>
<el-select v-model="courseware.content[currentPage].type" size="default" style="width: 100px">
<el-option label="封面" value="title" />
<el-option label="正文" value="content" />
<el-option label="互动" value="interactive" />
<el-option label="总结" value="summary" />
<el-option label="练习" value="exercise" />
</el-select>
</div>
<div class="undo-redo-group">
<button class="icon-btn" :disabled="!canUndo" @click="undo" title="撤销 (Ctrl+Z)">
<el-icon :size="15"><RefreshLeft /></el-icon>
</button>
<button class="icon-btn" :disabled="!canRedo" @click="redo" title="重做 (Ctrl+Shift+Z)">
<el-icon :size="15"><RefreshRight /></el-icon>
</button>
</div>
<el-dropdown trigger="click" @command="applyTheme" class="theme-dropdown">
<button class="icon-btn theme-btn" title="切换主题">
<el-icon :size="15"><Brush /></el-icon>
<span class="theme-label">主题</span>
</button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-for="t in themes" :key="t.id" :command="t.id">
<span class="theme-swatch" :style="{ background: t.swatch }"></span>
<span>{{ t.name }}</span>
<el-icon v-if="currentPageTheme === t.id" color="#10B981" style="margin-left:auto"><Check /></el-icon>
</el-dropdown-item>
<el-dropdown-item divided command="__none">
<span style="color:#6b7280">清除主题</span>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<button class="icon-btn insert-tpl-btn" @click="showTemplateGallery = true" title="插入模板页">
<el-icon :size="15"><Files /></el-icon>
<span class="theme-label">插入模板</span>
</button>
<button class="edit-toggle-btn" :class="{ active: editingPage }" @click="toggleEdit">
<el-icon :size="15"><Edit /></el-icon>
{{ editingPage ? '完成编辑' : '编辑内容' }}
</button>
</div>
<div class="page-preview-frame">
<HtmlPreview
ref="htmlPreviewRef"
v-model:html="courseware.content[currentPage].content"
:editable="editingPage"
height="100%"
/>
</div>
<div class="notes-edit">
<el-icon :size="14" color="var(--text-muted)"><InfoFilled /></el-icon>
<el-input v-model="courseware.content[currentPage].notes" size="default" placeholder="添加教师备注(不会显示在课件中)..." />
</div>
<div class="page-assist-bar">
<div>
<strong>第 {{ currentPage + 1 }} 页</strong>
<span>{{ getPageTypeLabel(courseware.content[currentPage].type) }} · {{ editingPage ? '正在编辑' : '预览模式' }}</span>
</div>
<div class="page-assist-actions">
<button type="button" class="action-btn small" @click="duplicatePage(currentPage)"><el-icon :size="14"><CopyDocument /></el-icon>复制此页</button>
<button type="button" class="action-btn small danger-action" @click="deletePage(currentPage)" :disabled="courseware.content.length <= 1"><el-icon :size="14"><Delete /></el-icon>删除此页</button>
</div>
</div>
</div>
</div>
<div v-else-if="!loading" class="empty-state">
<div class="empty-icon-wrap"><el-icon :size="36" color="#CBD5E1"><Document /></el-icon></div>
<h4>暂无课件内容</h4>
<p>请先创建课件</p>
</div>
<TemplateGallery v-model="showTemplateGallery" kind="animation" @use="handleInsertTemplate" />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { exportHtml } from '@/api/ai'
import { createCoursewareShare, exportCoursewareDocx, getCourseware, updateCourseware } from '@/api/courseware'
import { publishResource } from '@/api/resource'
import HtmlPreview from '@/components/common/HtmlPreview.vue'
import TemplateGallery from '@/components/common/TemplateGallery.vue'
import { downloadBlob } from '@/utils/download'
import { coursewareThemes, applyThemeToPage, detectPageTheme, getTheme } from '@/utils/coursewareThemes'
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const courseware = ref<any>(null)
const currentPage = ref(0)
const editingTitle = ref(false)
const editingPage = ref(false)
const publishing = ref(false)
const sharing = ref(false)
const exportingDocx = ref(false)
const htmlPreviewRef = ref<InstanceType<typeof HtmlPreview>>()
const showTemplateGallery = ref(false)
const themes = coursewareThemes
const currentPageTheme = computed(() => {
if (!courseware.value?.content?.[currentPage.value]) return null
return detectPageTheme(courseware.value.content[currentPage.value].content || '')
})
const undoStack = ref<Array<{ content: any[]; currentPage: number }>>([])
const redoStack = ref<Array<{ content: any[]; currentPage: number }>>([])
const HISTORY_LIMIT = 50
const canUndo = computed(() => undoStack.value.length > 0)
const canRedo = computed(() => redoStack.value.length > 0)
function snapshotState(): { content: any[]; currentPage: number } {
return {
content: JSON.parse(JSON.stringify(courseware.value?.content || [])),
currentPage: currentPage.value,
}
}
function pushHistory() {
if (!courseware.value?.content) return
undoStack.value.push(snapshotState())
if (undoStack.value.length > HISTORY_LIMIT) undoStack.value.shift()
redoStack.value = []
}
function undo() {
if (!undoStack.value.length) return
redoStack.value.push(snapshotState())
const prev = undoStack.value.pop()!
courseware.value.content = prev.content
currentPage.value = prev.currentPage
editingPage.value = false
}
function redo() {
if (!redoStack.value.length) return
undoStack.value.push(snapshotState())
const next = redoStack.value.pop()!
courseware.value.content = next.content
currentPage.value = next.currentPage
editingPage.value = false
}
function handleKeydown(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key === 'z') {
e.preventDefault()
if (e.shiftKey) redo()
else undo()
} else if ((e.ctrlKey || e.metaKey) && e.key === 'y') {
e.preventDefault()
redo()
}
}
function getPageTypeLabel(type: string) {
const map: Record<string, string> = { title: '封面', content: '正文', interaction: '互动', interactive: '互动', summary: '总结', exercise: '练习' }
return map[type] || type
}
function applyTheme(themeId: string) {
if (!courseware.value?.content?.length) return
pushHistory()
const theme = themeId === '__none' ? null : (getTheme(themeId) || null)
const page = courseware.value.content[currentPage.value]
if (!page) return
page.content = applyThemeToPage(page.content || '', theme)
}
function handleInsertTemplate(payload: { kind: string; template: any; params: Record<string, any>; html: string; title: string }) {
if (!courseware.value?.content) return
syncCurrentPageEdit()
pushHistory()
const newPage = {
title: payload.title || payload.template.name || '模板页',
type: 'interactive',
content: payload.html,
notes: '由模板插入:' + (payload.template.description || ''),
}
courseware.value.content.splice(currentPage.value + 1, 0, newPage)
currentPage.value = currentPage.value + 1
ElMessage.success('已插入模板页')
}
function toggleEdit() {
if (editingPage.value) {
pushHistory()
syncCurrentPageEdit()
}
editingPage.value = !editingPage.value
}
function addPage() {
if (!courseware.value?.content) return
syncCurrentPageEdit()
pushHistory()
const newPage = { title: '', type: 'content', content: '<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;color:#94a3b8;font-size:18px;">点击「编辑内容」开始制作新页面</div>', notes: '' }
courseware.value.content.push(newPage)
currentPage.value = courseware.value.content.length - 1
}
function duplicatePage(idx: number) {
if (!courseware.value?.content || idx < 0 || idx >= courseware.value.content.length) return
syncCurrentPageEdit()
pushHistory()
const src = courseware.value.content[idx]
const copy = { ...src, title: src.title ? src.title + '(副本)' : '' }
courseware.value.content.splice(idx + 1, 0, copy)
currentPage.value = idx + 1
}
function movePage(idx: number, dir: number) {
const pages = courseware.value?.content
if (!pages) return
const target = idx + dir
if (target < 0 || target >= pages.length) return
syncCurrentPageEdit()
pushHistory()
const item = pages.splice(idx, 1)[0]
pages.splice(target, 0, item)
currentPage.value = target
}
async function deletePage(idx: number) {
const pages = courseware.value?.content
if (!pages || pages.length <= 1) return
pushHistory()
try {
await ElMessageBox.confirm('确定删除这一页吗?', '删除页面', { confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' })
} catch { return }
pages.splice(idx, 1)
if (currentPage.value >= pages.length) currentPage.value = pages.length - 1
}
function syncCurrentPageEdit() {
const html = htmlPreviewRef.value?.getHtml()
if (html && courseware.value?.content?.[currentPage.value]) {
courseware.value.content[currentPage.value].content = html
}
}
onMounted(async () => {
window.addEventListener('keydown', handleKeydown)
loading.value = true
try {
const res: any = await getCourseware(Number(route.params.id))
courseware.value = res.data || res
} finally {
loading.value = false
}
})
onUnmounted(() => {
window.removeEventListener('keydown', handleKeydown)
})
function openFullPreview() {
if (!courseware.value?.content?.length) return
const data = JSON.stringify({ title: courseware.value.title, pages: courseware.value.content })
localStorage.setItem('preview_courseware', data)
const url = router.resolve({ name: 'CoursewarePreview' }).href
window.open(url, '_blank')
}
async function saveCourseware() {
if (!courseware.value) return
syncCurrentPageEdit()
await updateCourseware(Number(route.params.id), {
title: courseware.value.title,
content: courseware.value.content,
subject: courseware.value.subject,
grade: courseware.value.grade,
description: courseware.value.description,
tags: courseware.value.tags || [],
})
}
async function handleSave() {
if (!courseware.value) return
try {
await saveCourseware()
ElMessage.success('保存成功')
} catch (e: any) {
ElMessage.error('保存失败:' + (e.message || ''))
}
}
async function handlePublish() {
if (!courseware.value) return
publishing.value = true
try {
syncCurrentPageEdit()
const id = Number(route.params.id)
const updated: any = await updateCourseware(id, {
title: courseware.value.title,
subject: courseware.value.subject,
grade: courseware.value.grade,
description: courseware.value.description || buildDescription(),
content: courseware.value.content,
tags: courseware.value.tags || [],
status: 'published',
})
courseware.value = updated.data || updated
const created: any = await publishResource({
resource_type: 'courseware',
title: courseware.value.title,
description: courseware.value.description || buildDescription(),
content_ref: `courseware:${id}`,
file_url: `/preview/${id}`,
tags: courseware.value.tags || ['互动课件'],
subject: courseware.value.subject || '',
grade: courseware.value.grade || '',
is_public: 1,
})
const resource = created.data || created
ElMessage.success('已发布到资源广场')
if (resource?.id) router.push(`/resources/${resource.id}`)
} finally {
publishing.value = false
}
}
async function handleShare() {
if (!courseware.value) return
sharing.value = true
try {
await saveCourseware()
const res: any = await createCoursewareShare(Number(route.params.id))
const share = res.data || res
const url = share.share_url?.startsWith('http') ? share.share_url : `${window.location.origin}${share.share_url || ''}`
await navigator.clipboard?.writeText(url)
ElMessage.success('公开分享链接已复制')
} finally {
sharing.value = false
}
}
function buildDescription() {
const parts = [courseware.value?.subject, courseware.value?.grade].filter(Boolean).join(' · ')
return `${parts || '通用'}互动课件,包含${courseware.value?.content?.length || 0}页,可用于课堂展示、二次改编和校本资源沉淀。`
}
async function handleExportHtml() {
if (!courseware.value) return
syncCurrentPageEdit()
const pages = courseware.value.content || []
const html = pages.map((page: any, index: number) => `
<section style='width:960px;min-height:540px;margin:24px auto;border:1px solid #dce8de;overflow:hidden;'>
<h2 style='font-family:sans-serif;margin:12px 16px;color:#00543d;'>${index + 1}. ${page.title || ''}</h2>
<div style='width:960px;height:540px;'>${page.content || ''}</div>
</section>`).join('\n')
const blob = await exportHtml({ title: courseware.value.title || '课件', html }) as Blob
downloadBlob(blob, `${courseware.value.title || '课件'}.html`)
}
async function handleExportDocx() {
if (!courseware.value) return
exportingDocx.value = true
try {
await saveCourseware()
const blob = await exportCoursewareDocx(Number(route.params.id))
downloadBlob(blob, `${courseware.value.title || '课件'}-讲义.docx`)
} catch (e: any) {
ElMessage.error('导出失败:' + (e.message || ''))
} finally {
exportingDocx.value = false
}
}
</script>
<style scoped>
.detail-page {
padding: 0 !important;
background: var(--bg-color);
display: flex;
flex-direction: column;
height: calc(100vh - var(--header-height));
overflow: hidden;
}
.detail-topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 24px;
background: #fff;
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
.topbar-left { display: flex; align-items: center; gap: 10px; }
.back-btn {
display: flex; align-items: center; gap: 6px;
background: none; border: 1px solid var(--border-color);
border-radius: 8px; padding: 6px 12px;
font-size: 13px; color: var(--text-secondary);
cursor: pointer; transition: all 0.2s;
}
.back-btn:hover { background: var(--primary-bg); color: var(--primary); border-color: var(--primary); }
.topbar-divider { width: 1px; height: 20px; background: var(--border-color); }
.detail-title { font-size: 16px; font-weight: 600; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.icon-btn {
width: 28px; height: 28px;
display: flex; align-items: center; justify-content: center;
background: none; border: 1px solid transparent; border-radius: 6px;
color: var(--text-muted); cursor: pointer; transition: all 0.2s; opacity: 0;
}
.topbar-left:hover .icon-btn { opacity: 1; }
.icon-btn:hover { background: var(--primary-bg); color: var(--primary); border-color: var(--primary); }
.topbar-right { display: flex; align-items: center; gap: 10px; }
.action-btn {
display: flex; align-items: center; gap: 6px;
padding: 7px 16px; border-radius: 8px; font-size: 13px; font-weight: 500;
cursor: pointer; transition: all 0.2s; border: 1px solid var(--primary);
background: #fff; color: var(--primary);
}
.action-btn:hover { background: var(--primary-bg); }
.action-btn:disabled { opacity: .5; cursor: not-allowed; }
.action-btn.publish { border-color: #0f8a5f; color: #0f8a5f; }
.action-btn.publish:hover:not(:disabled) { background: #e8f7ef; }
.action-btn.primary { background: var(--gradient); color: #fff; border: none; }
.action-btn.primary:hover { opacity: 0.9; transform: translateY(-1px); box-shadow: 0 4px 12px rgba(79, 70, 229, 0.3); }
.meta-bar { padding: 12px 24px; border-bottom: 1px solid var(--border-color); background: #FAFBFC; flex-shrink: 0; }
.meta-chips { display: flex; gap: 12px; }
.meta-chip { display: flex; align-items: center; gap: 6px; font-size: 13px; color: var(--text-secondary); }
.content-area { flex: 1; overflow-y: auto; padding-bottom: 24px; }
.page-tabs { border-bottom: 1px solid var(--border-color); background: #fff; position: sticky; top: 0; z-index: 5; }
.page-tabs-scroll { display: flex; overflow-x: auto; padding: 0 24px; gap: 2px; }
.page-tabs-scroll::-webkit-scrollbar { height: 0; }
.page-tab { display: flex; align-items: center; gap: 6px; padding: 10px 14px; font-size: 13px; color: var(--text-muted); cursor: pointer; border-bottom: 2px solid transparent; transition: all 0.2s; white-space: nowrap; flex-shrink: 0; }
.page-tab:hover { color: var(--text-secondary); }
.page-tab.active { color: var(--primary); font-weight: 600; border-bottom-color: var(--primary); }
.page-tab-num { width: 20px; height: 20px; border-radius: 5px; background: #E2E8F0; display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 600; }
.page-tab.active .page-tab-num { background: var(--primary); color: #fff; }
.page-tab-actions { display: inline-flex; gap: 2px; margin-left: 4px; }
.tab-mini { width: 22px; height: 22px; border: none; border-radius: 5px; background: transparent; color: var(--text-muted); cursor: pointer; display: inline-flex; align-items: center; justify-content: center; }
.tab-mini:hover:not(:disabled) { background: #E2E8F0; color: var(--text-secondary); }
.tab-mini:disabled { opacity: .35; cursor: not-allowed; }
.tab-mini.danger:hover:not(:disabled) { background: #fee2e2; color: #dc2626; }
.page-tab.add-tab { display: flex; align-items: center; gap: 4px; padding: 10px 14px; font-size: 13px; color: var(--primary); cursor: pointer; border: 1px dashed var(--border-color); border-radius: 8px; margin-left: 6px; flex-shrink: 0; transition: all 0.2s; white-space: nowrap; background: transparent; }
.page-tab.add-tab:hover { border-color: var(--primary); background: #f0fdf4; }
.page-assist-actions { display: flex; gap: 8px; align-items: center; }
.action-btn.small { height: 30px; padding: 0 12px; font-size: 12px; }
.action-btn.danger-action { color: #dc2626; }
.action-btn.danger-action:hover { background: #fef2f2; color: #b91c1c; }
.page-edit-area { padding: 16px 24px; }
.page-edit-bar { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 12px; }
.page-edit-fields { display: flex; gap: 8px; flex: 1; }
.page-edit-fields .title-input { flex: 1; }
.undo-redo-group { display: inline-flex; gap: 2px; }
.undo-redo-group .icon-btn { width: 34px; height: 34px; border: 1px solid var(--border-color); border-radius: 8px; background: #fff; color: var(--text-muted); display: inline-flex; align-items: center; justify-content: center; cursor: pointer; transition: all 0.2s; }
.undo-redo-group .icon-btn:hover:not(:disabled) { border-color: var(--primary); color: var(--primary); background: #f0fdf4; }
.undo-redo-group .icon-btn:disabled { opacity: .35; cursor: not-allowed; }
.theme-dropdown { margin: 0 4px; }
.theme-btn, .insert-tpl-btn { display: inline-flex; align-items: center; gap: 4px; padding: 0 10px; width: auto; }
.theme-label { font-size: 12px; margin-left: 2px; }
.theme-swatch { display: inline-block; width: 16px; height: 16px; border-radius: 4px; border: 1px solid #e5e7eb; margin-right: 8px; vertical-align: middle; }
.edit-toggle-btn {
display: flex; align-items: center; gap: 5px;
padding: 7px 16px; background: #fff; border: 1px solid var(--border-color);
border-radius: 8px; font-size: 13px; font-weight: 500; color: var(--text-secondary);
cursor: pointer; transition: all 0.2s; white-space: nowrap;
}
.edit-toggle-btn:hover { border-color: var(--primary); color: var(--primary); }
.edit-toggle-btn.active { background: var(--primary); border-color: var(--primary); color: #fff; }
.page-preview-frame {
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
overflow: hidden;
background: #F8FAFC;
margin-bottom: 12px;
width: 100%;
aspect-ratio: 16 / 9;
min-height: 0;
}
.notes-edit { display: flex; align-items: center; gap: 10px; }
.notes-edit .el-input { flex: 1; }
.page-assist-bar {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: center;
margin-top: 12px;
border: 1px solid #dce8de;
border-radius: 12px;
background: #fff;
padding: 12px 14px;
}
.page-assist-bar strong {
display: block;
color: #00543d;
font-size: 14px;
margin-bottom: 3px;
}
.page-assist-bar span {
color: #75867f;
font-size: 12px;
}
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; padding: 80px 0; }
.empty-icon-wrap { width: 64px; height: 64px; background: #F8FAFC; border-radius: 18px; display: flex; align-items: center; justify-content: center; }
.empty-state h4 { font-size: 15px; font-weight: 600; color: var(--text-primary); }
.empty-state p { font-size: 13px; color: var(--text-muted); }
@media (max-width: 900px) {
.detail-topbar,
.page-edit-bar,
.page-assist-bar {
align-items: stretch;
flex-direction: column;
}
.topbar-right,
.page-edit-fields {
flex-wrap: wrap;
}
}
</style>
+812
View File
@@ -0,0 +1,812 @@
<template>
<div class="page-container works-page">
<div class="page-header works-header">
<div>
<span class="eyebrow">作品空间</span>
<h2>我的作品</h2>
<p>统一管理课件、动画、练习、教案和试卷,继续编辑、预览、发布或删除。</p>
</div>
<div class="header-actions">
<button class="ghost-create" type="button" @click="router.push('/resources')">
<el-icon><FolderOpened /></el-icon>
资源广场
</button>
<el-dropdown trigger="click" @command="startCreate">
<button class="create-btn" type="button">
<el-icon><Plus /></el-icon> 新建任务
</button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-for="item in createOptions" :key="item.type" :command="item.type">
{{ item.label }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
<section class="work-stats">
<div>
<strong>{{ works.length }}</strong>
<span>全部作品</span>
</div>
<div>
<strong>{{ publishedCount }}</strong>
<span>已发布</span>
</div>
<div>
<strong>{{ draftCount }}</strong>
<span>草稿</span>
</div>
<div>
<strong>{{ totalUnits }}</strong>
<span>内容单元</span>
</div>
</section>
<section class="works-toolbar">
<div class="segmented">
<button
v-for="item in typeTabs"
:key="item.value"
type="button"
:class="{ active: filters.type === item.value }"
@click="filters.type = item.value"
>
{{ item.label }}
</button>
</div>
<div class="filter-tools">
<el-select v-model="filters.status" placeholder="状态" clearable style="width: 116px">
<el-option label="草稿" value="draft" />
<el-option label="已发布" value="published" />
</el-select>
<el-select v-model="filters.subject" placeholder="筛选学科" clearable style="width: 142px">
<el-option v-for="s in subjectOptions" :key="s" :label="s" :value="s" />
</el-select>
<div class="search-box">
<el-icon><Search /></el-icon>
<input v-model="keyword" placeholder="搜索标题、学科、年级" />
<button v-if="keyword" type="button" title="清空搜索" @click="keyword = ''">×</button>
</div>
</div>
</section>
<div class="card-grid" v-loading="loading">
<article v-for="work in filteredWorks" :key="work.key" class="work-card" @click="openWork(work)">
<div class="work-cover" :class="coverClass(work)">
<span class="type-pill">{{ typeLabel(work.type) }}</span>
<span class="status-pill" :class="{ published: work.status === 'published' }">
{{ work.status === 'published' ? '已发布' : '草稿' }}
</span>
<span class="unit-count">{{ unitText(work) }}</span>
<span class="cover-kicker">{{ work.subject || typeLabel(work.type) }}</span>
<strong>{{ work.title }}</strong>
<em>{{ work.grade || metaText(work) }}</em>
<div class="cover-art" :class="`art-${work.type}`"><span></span><span></span><span></span></div>
</div>
<div class="work-body">
<h3>{{ work.title }}</h3>
<p class="work-desc">{{ work.description || defaultDescription(work) }}</p>
<p class="work-meta">{{ metaLine(work) }}</p>
<div class="tag-row">
<span v-for="tag in visibleTags(work)" :key="tag">{{ tag }}</span>
</div>
<div class="work-footer">
<el-tag :type="work.status === 'published' ? 'success' : 'info'" size="small" effect="plain">
{{ work.status === 'published' ? '已发布' : '草稿' }}
</el-tag>
<div class="work-actions">
<button class="icon-btn" title="快速预览" @click.stop="previewWork(work)">
<el-icon :size="16"><FullScreen /></el-icon>
</button>
<button class="icon-btn" title="继续编辑" @click.stop="openWork(work)">
<el-icon :size="16"><Edit /></el-icon>
</button>
<button v-if="canDownloadWork(work)" class="icon-btn" :title="downloadTitle(work)" :disabled="downloadingKey === work.key" @click.stop="downloadWork(work)">
<el-icon :size="16"><Download /></el-icon>
</button>
<button v-if="work.resource_id" class="icon-btn" title="查看资源" @click.stop="openPublishedResource(work)">
<el-icon :size="16"><View /></el-icon>
</button>
<button v-else class="icon-btn" title="用它生成" @click.stop="reuseWork(work)">
<el-icon :size="16"><MagicStick /></el-icon>
</button>
<el-popconfirm v-if="work.resource_id" title="只下架资源,保留作品草稿?" @confirm="unpublishWork(work)">
<template #reference>
<button class="icon-btn warning" title="下架资源" @click.stop>
<el-icon :size="16"><Download /></el-icon>
</button>
</template>
</el-popconfirm>
<el-popconfirm :title="`确定删除这个${typeLabel(work.type)}?关联资源会同步下架。`" @confirm="deleteWork(work)">
<template #reference>
<button class="icon-btn danger" title="删除" @click.stop>
<el-icon :size="16"><Delete /></el-icon>
</button>
</template>
</el-popconfirm>
</div>
</div>
<div class="card-actions">
<button type="button" @click.stop="reuseWork(work)">用它生成</button>
<button v-if="canDownloadWork(work)" type="button" :disabled="downloadingKey === work.key" @click.stop="downloadWork(work)">
{{ downloadingKey === work.key ? '下载中' : '下载' }}
</button>
<button v-if="work.resource_id" type="button" @click.stop="openPublishedResource(work)">查看资源</button>
<button v-else type="button" @click.stop="previewWork(work)">预览</button>
<button type="button" class="primary" :disabled="publishingKey === work.key" @click.stop="publishWork(work)">
{{ publishingKey === work.key ? '发布中' : work.resource_id ? '更新发布' : '发布资源' }}
</button>
</div>
</div>
</article>
</div>
<div v-if="!loading && filteredWorks.length === 0" class="empty-state">
<el-icon :size="48" color="#CBD5E1"><Document /></el-icon>
<p>{{ works.length ? '没有匹配的作品' : '暂无作品' }}</p>
<button class="create-btn" type="button" @click="router.push('/courseware/create')">创建第一个作品</button>
</div>
<el-dialog v-model="showPreview" :title="previewTitle" width="920px" class="quick-preview-dialog">
<div v-if="previewWorkItem?.type === 'courseware'" class="quick-preview">
<aside class="preview-thumbs">
<button
v-for="(page, index) in previewPages"
:key="index"
type="button"
:class="{ active: previewPage === index }"
@click="previewPage = index"
>
<span>{{ index + 1 }}</span>
<strong>{{ page.title || getPageTypeLabel(page.type) }}</strong>
</button>
</aside>
<main class="preview-stage">
<HtmlPreview
v-if="currentPreviewPage?.content"
:html="currentPreviewPage.content"
height="100%"
:editable="false"
/>
<div v-else class="preview-empty">暂无页面内容</div>
</main>
</div>
<div v-else-if="previewWorkItem?.type === 'animation'" class="single-preview">
<HtmlPreview v-if="previewAnimationHtml" :html="previewAnimationHtml" height="520px" />
<div v-else class="preview-empty">当前动画没有可预览的 HTML</div>
</div>
<div v-else-if="previewWorkItem" class="structured-preview">
<div class="structured-head">
<span>{{ typeLabel(previewWorkItem.type) }}</span>
<strong>{{ previewWorkItem.title }}</strong>
<em>{{ metaLine(previewWorkItem) }}</em>
</div>
<div class="structured-list">
<div v-for="(line, index) in previewLines" :key="index">
<span>{{ index + 1 }}</span>
<p>{{ line }}</p>
</div>
</div>
</div>
<template #footer>
<button type="button" class="dialog-btn" @click="openWork(previewWorkItem)">编辑</button>
<button type="button" class="dialog-btn" @click="reuseWork(previewWorkItem)">用它生成</button>
<button type="button" class="dialog-btn primary" :disabled="!previewWorkItem" @click="publishWork(previewWorkItem)">发布到资源广场</button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { deleteAnimation, exportAnimationHtml, getAnimations, updateAnimation } from '@/api/animation'
import { deleteCourseware, exportCoursewareDocx, getCoursewares, updateCourseware } from '@/api/courseware'
import { deleteExam, exportSavedExamDocx, getExams } from '@/api/exam'
import { deleteExercise, exportExerciseDocx, getExercises } from '@/api/exercise'
import { deleteLessonPlan, exportLessonPlanDocx, getLessonPlans } from '@/api/lessonPlan'
import { deleteResource, getMyResources, publishResource } from '@/api/resource'
import HtmlPreview from '@/components/common/HtmlPreview.vue'
import { downloadBlob } from '@/utils/download'
type WorkType = 'courseware' | 'animation' | 'exercise' | 'lesson_plan' | 'exam'
type WorkItem = {
key: string
id: number
type: WorkType
title: string
subject: string
grade: string
description: string
status: 'draft' | 'published'
resource_id?: number
updated_at?: string
raw: any
}
const router = useRouter()
const loading = ref(false)
const works = ref<WorkItem[]>([])
const keyword = ref('')
const publishingKey = ref('')
const downloadingKey = ref('')
const showPreview = ref(false)
const previewWorkItem = ref<WorkItem | null>(null)
const previewPage = ref(0)
const filters = reactive({ type: '' as '' | WorkType, subject: '', status: '' })
const createOptions = [
{ type: 'courseware', label: 'AI互动课件', path: '/courseware/create' },
{ type: 'animation', label: '教学动画', path: '/animation' },
{ type: 'exercise', label: '教学游戏', path: '/exercise' },
{ type: 'lesson_plan', label: '大单元教案', path: '/lesson-plan' },
{ type: 'exam', label: '智能命题', path: '/exam' },
]
const typeTabs = [
{ value: '', label: '全部' },
{ value: 'courseware', label: '课件' },
{ value: 'animation', label: '动画' },
{ value: 'exercise', label: '练习' },
{ value: 'lesson_plan', label: '教案' },
{ value: 'exam', label: '试卷' },
] as const
const coverClasses = ['cover-green', 'cover-yellow', 'cover-mint', 'cover-blue', 'cover-cream']
const filteredWorks = computed(() => {
const kw = keyword.value.trim().toLowerCase()
return works.value.filter(item => {
if (filters.type && item.type !== filters.type) return false
if (filters.subject && item.subject !== filters.subject) return false
if (filters.status && item.status !== filters.status) return false
if (!kw) return true
return `${item.title}${item.description}${item.subject}${item.grade}${visibleTags(item).join('')}`.toLowerCase().includes(kw)
})
})
const subjectOptions = computed(() => Array.from(new Set(works.value.map(item => item.subject).filter(Boolean))).sort())
const publishedCount = computed(() => works.value.filter(item => item.status === 'published').length)
const draftCount = computed(() => works.value.filter(item => item.status !== 'published').length)
const totalUnits = computed(() => works.value.reduce((sum, item) => sum + unitCount(item), 0))
const previewPages = computed(() => previewWorkItem.value?.raw?.content || [])
const currentPreviewPage = computed(() => previewPages.value[previewPage.value] || null)
const previewTitle = computed(() => previewWorkItem.value?.title || '作品预览')
const previewAnimationHtml = computed(() => previewWorkItem.value?.raw?.config?.html || '')
const previewLines = computed(() => buildPreviewLines(previewWorkItem.value).slice(0, 12))
async function loadData() {
loading.value = true
try {
const [coursewaresRes, animationsRes, exercisesRes, lessonPlansRes, examsRes, resourcesRes]: any[] = await Promise.all([
getCoursewares({ limit: 100 }),
getAnimations({ limit: 100 }),
getExercises({ limit: 100 }),
getLessonPlans({ limit: 100 }),
getExams({ limit: 100 }),
getMyResources({ limit: 100 }),
])
const publishedResources: Map<string, any> = new Map(
normalizeArray(resourcesRes)
.filter((item: any) => typeof item?.content_ref === 'string' && item.content_ref)
.map((item: any) => [String(item.content_ref), item] as [string, any]),
)
const coursewares = normalizeArray(coursewaresRes).map((item: any) => toCoursewareWork(item, publishedResources))
const animations = normalizeArray(animationsRes).map((item: any) => toAnimationWork(item, publishedResources))
const exercises = normalizeArray(exercisesRes).map((item: any) => toExerciseWork(item, publishedResources))
const lessonPlans = normalizeArray(lessonPlansRes).map((item: any) => toLessonPlanWork(item, publishedResources))
const exams = normalizeArray(examsRes).map((item: any) => toExamWork(item, publishedResources))
works.value = [...coursewares, ...animations, ...exercises, ...lessonPlans, ...exams]
.filter((item): item is WorkItem => Boolean(item))
.sort((a, b) => timestamp(b.updated_at) - timestamp(a.updated_at))
} finally {
loading.value = false
}
}
function normalizeArray(res: any) {
return Array.isArray(res?.data) ? res.data : Array.isArray(res) ? res : []
}
function resourceFor(item: any, type: WorkType, resources: Map<string, any>) {
return resources.get(`${type}:${item.id}`)
}
function itemStatus(item: any, type: WorkType, resources: Map<string, any>) {
return item.status === 'published' || resourceFor(item, type, resources) ? 'published' : 'draft'
}
function toCoursewareWork(item: any, resources: Map<string, any>): WorkItem {
const resource = resourceFor(item, 'courseware', resources)
return {
key: `courseware:${item.id}`,
id: item.id,
type: 'courseware',
title: item.title || '未命名课件',
subject: item.subject || '',
grade: item.grade || '',
description: item.description || '',
status: itemStatus(item, 'courseware', resources),
resource_id: resource?.id,
updated_at: item.updated_at || item.created_at,
raw: item,
}
}
function toAnimationWork(item: any, resources: Map<string, any>): WorkItem {
const resource = resourceFor(item, 'animation', resources)
return {
key: `animation:${item.id}`,
id: item.id,
type: 'animation',
title: item.title || '未命名动画',
subject: animTypeLabel(item.anim_type),
grade: '',
description: item.description || item.config?.description || '',
status: itemStatus(item, 'animation', resources),
resource_id: resource?.id,
updated_at: item.updated_at || item.created_at,
raw: item,
}
}
function toExerciseWork(item: any, resources: Map<string, any>): WorkItem {
const resource = resourceFor(item, 'exercise', resources)
return {
key: `exercise:${item.id}`,
id: item.id,
type: 'exercise',
title: item.title || '未命名练习',
subject: item.subject || '',
grade: '',
description: '',
status: itemStatus(item, 'exercise', resources),
resource_id: resource?.id,
updated_at: item.updated_at || item.created_at,
raw: item,
}
}
function toLessonPlanWork(item: any, resources: Map<string, any>): WorkItem {
const resource = resourceFor(item, 'lesson_plan', resources)
return {
key: `lesson_plan:${item.id}`,
id: item.id,
type: 'lesson_plan',
title: item.title || '未命名教案',
subject: item.subject || '',
grade: item.grade || '',
description: '',
status: itemStatus(item, 'lesson_plan', resources),
resource_id: resource?.id,
updated_at: item.updated_at || item.created_at,
raw: item,
}
}
function toExamWork(item: any, resources: Map<string, any>): WorkItem {
const resource = resourceFor(item, 'exam', resources)
return {
key: `exam:${item.id}`,
id: item.id,
type: 'exam',
title: item.title || '未命名试卷',
subject: item.subject || '',
grade: item.grade || '',
description: '',
status: itemStatus(item, 'exam', resources),
resource_id: resource?.id,
updated_at: item.updated_at || item.created_at,
raw: item,
}
}
function timestamp(value?: string) {
return value ? new Date(value).getTime() || 0 : 0
}
function typeLabel(type: WorkType | string) {
const map: Record<string, string> = {
courseware: '课件',
animation: '动画',
exercise: '练习',
lesson_plan: '教案',
exam: '试卷',
}
return map[type] || '作品'
}
function animTypeLabel(type: string) {
const map: Record<string, string> = { math: '数学', physics: '物理', chemistry: '化学', general: '通用' }
return map[type] || '通用'
}
function getPageTypeLabel(type: string) {
const map: Record<string, string> = { title: '封面', content: '正文', interaction: '互动', interactive: '互动', summary: '总结', exercise: '练习' }
return map[type] || '页面'
}
function coverClass(work: WorkItem) {
return coverClasses[(work.id + typeTabs.findIndex(item => item.value === work.type)) % coverClasses.length]
}
function unitCount(work: WorkItem) {
if (work.type === 'courseware') return work.raw.content?.length || 0
if (work.type === 'animation') return work.raw.config?.scenes?.length || (work.raw.config?.html ? 1 : 0)
if (work.type === 'exercise' || work.type === 'exam') return work.raw.questions?.length || 0
if (work.type === 'lesson_plan') return work.raw.content?.phases?.length || work.raw.objectives?.length || 0
return 0
}
function unitText(work: WorkItem) {
const count = unitCount(work)
if (work.type === 'courseware') return `${count} 页`
if (work.type === 'animation') return count ? `${count} 场景` : '动画'
if (work.type === 'lesson_plan') return count ? `${count} 环节` : '教案'
return `${count} 题`
}
function metaText(work: WorkItem) {
if (work.type === 'animation') return animTypeLabel(work.raw.anim_type)
if (work.type === 'exercise') return exerciseTypeLabel(work.raw.exercise_type)
if (work.type === 'exam') return difficultyLabel(work.raw.difficulty)
return typeLabel(work.type)
}
function metaLine(work: WorkItem) {
const parts = [work.subject, work.grade, metaText(work)].filter(Boolean)
return parts.join(' · ') || typeLabel(work.type)
}
function visibleTags(work: WorkItem) {
if (work.type === 'courseware') return (work.raw.tags || []).slice(0, 3)
if (work.type === 'animation') return ['教学动画', animTypeLabel(work.raw.anim_type)].filter(Boolean)
if (work.type === 'exercise') return [exerciseTypeLabel(work.raw.exercise_type), ...(work.raw.knowledge_points || [])].slice(0, 3)
if (work.type === 'lesson_plan') return ['大单元教案', work.subject, ...(work.raw.key_points || [])].filter(Boolean).slice(0, 3)
return ['智能命题', difficultyLabel(work.raw.difficulty), ...(work.raw.knowledge_points || [])].filter(Boolean).slice(0, 3)
}
function defaultDescription(work: WorkItem) {
if (work.type === 'courseware') {
return `${work.subject || '通用'}互动课件,包含${unitCount(work)}页,可用于课堂展示、二次编辑和发布分享。`
}
if (work.type === 'animation') {
return `${animTypeLabel(work.raw.anim_type)}教学动画,可用于课堂投屏、知识讲解和课件插入。`
}
if (work.type === 'exercise') {
const points = work.raw.knowledge_points?.length ? `,覆盖${work.raw.knowledge_points.join('、')}` : ''
return `${work.subject || '通用'}课堂练习,共${unitCount(work)}题${points},支持互动预览和答案解析。`
}
if (work.type === 'lesson_plan') {
return `${work.subject || '通用'}${work.grade ? ` · ${work.grade}` : ''}大单元教案,覆盖目标、重难点、活动和作业。`
}
return `${work.subject || '通用'}${work.grade ? ` · ${work.grade}` : ''}智能试卷,共${unitCount(work)}题,含答案与解析。`
}
function exerciseTypeLabel(type: string) {
const map: Record<string, string> = {
choice: '选择题',
fill_blank: '填空题',
true_false: '判断题',
matching: '匹配题',
drag_sort: '排序题',
game_snake: '贪吃蛇游戏',
game_match: '配对游戏',
game_adventure: '闯关游戏',
}
return map[type] || '互动练习'
}
function difficultyLabel(difficulty: string) {
const map: Record<string, string> = { easy: '简单', medium: '中等', hard: '困难' }
return map[difficulty] || '中等'
}
function safeFilename(value: string) {
return (value || '作品').replace(/[\\/:*?"<>|\r\n]+/g, '_').trim() || '作品'
}
function startCreate(type: string) {
const target = createOptions.find(item => item.type === type)
router.push(target?.path || '/courseware/create')
}
function openPath(work: WorkItem) {
if (work.type === 'courseware') return `/courseware/${work.id}`
if (work.type === 'animation') return `/animation?open=${work.id}`
if (work.type === 'exercise') return `/exercise?open=${work.id}`
if (work.type === 'lesson_plan') return `/lesson-plan?open=${work.id}`
return `/exam?open=${work.id}`
}
function openWork(work?: WorkItem | null) {
if (!work) return
router.push(openPath(work))
}
function openPublishedResource(work?: WorkItem | null) {
if (!work?.resource_id) return
router.push(`/resources/${work.resource_id}`)
}
function canDownloadWork(work?: WorkItem | null) {
return Boolean(work && ['courseware', 'animation', 'exercise', 'lesson_plan', 'exam'].includes(work.type))
}
function downloadTitle(work: WorkItem) {
return work.type === 'animation' ? '下载HTML' : '下载Word'
}
async function downloadWork(work?: WorkItem | null) {
if (!work || !canDownloadWork(work)) return
downloadingKey.value = work.key
try {
let blob: Blob
let suffix = '作品'
if (work.type === 'courseware') {
blob = await exportCoursewareDocx(work.id)
suffix = '讲义'
} else if (work.type === 'animation') {
blob = await exportAnimationHtml(work.id)
suffix = '动画'
} else if (work.type === 'exercise') {
blob = await exportExerciseDocx(work.id)
suffix = '练习'
} else if (work.type === 'lesson_plan') {
blob = await exportLessonPlanDocx(work.id)
suffix = '教案'
} else {
blob = await exportSavedExamDocx(work.id)
suffix = '试卷'
}
downloadBlob(blob, `${safeFilename(work.title)}-${suffix}.${work.type === 'animation' ? 'html' : 'docx'}`)
} catch (e: any) {
ElMessage.error('下载失败:' + (e.response?.data?.detail || e.message || ''))
} finally {
downloadingKey.value = ''
}
}
function previewWork(work?: WorkItem | null) {
if (!work) return
if (work.type === 'courseware' || work.type === 'animation') {
previewWorkItem.value = work
previewPage.value = 0
showPreview.value = true
return
}
previewWorkItem.value = work
showPreview.value = true
}
function buildPreviewLines(work?: WorkItem | null) {
if (!work) return []
if (work.type === 'exercise') {
return (work.raw.questions || []).map((q: any) => q.question || q.stem || q.content || '未命名题目')
}
if (work.type === 'lesson_plan') {
const content = work.raw.content || {}
const phases = content.phases || content.steps || []
return phases.length
? phases.map((p: any) => `${p.name || '教学环节'}:${(p.activities || p.teacher_actions || []).join('、') || `${p.duration || ''}分钟`}`)
: [
...(work.raw.objectives || []).map((item: string) => `目标:${item}`),
...(work.raw.key_points || []).map((item: string) => `重点:${item}`),
...(work.raw.homework || []).map((item: string) => `作业:${item}`),
]
}
if (work.type === 'exam') {
return (work.raw.questions || []).map((q: any) => q.content || q.question || q.stem || '未命名题目')
}
return []
}
function reuseWork(work?: WorkItem | null) {
if (!work) return
localStorage.setItem('quick_create_prompt', reusePrompt(work))
router.push(createOptions.find(item => item.type === work.type)?.path || '/courseware/create')
}
function reusePrompt(work: WorkItem) {
const scope = [work.grade, work.subject].filter(Boolean).join('')
const typeName = typeLabel(work.type)
return `参考我的作品《${work.title}》,为${scope || '通用课堂'}重新生成一份原创${typeName}。保留核心知识结构,优化课堂互动、练习反馈和教师引导语。`
}
async function publishWork(work?: WorkItem | null) {
if (!work) return
publishingKey.value = work.key
try {
let item = work.raw
if (work.type === 'courseware') {
const updated: any = await updateCourseware(work.id, {
title: item.title,
subject: item.subject || '',
grade: item.grade || '',
description: item.description || defaultDescription(work),
content: item.content || [],
tags: item.tags || ['互动课件'],
status: 'published',
})
item = updated.data || updated
Object.assign(work.raw, item)
work.status = 'published'
}
if (work.type === 'animation') {
const updated: any = await updateAnimation(work.id, {
title: item.title,
description: item.description || defaultDescription(work),
config: item.config || {},
status: 'published',
})
item = updated.data || updated
Object.assign(work.raw, item)
work.status = 'published'
}
const created: any = await publishResource({
resource_type: work.type,
title: item.title || work.title,
description: item.description || defaultDescription(work),
content_ref: `${work.type}:${work.id}`,
file_url: work.type === 'courseware' ? `/preview/${work.id}` : '',
tags: visibleTags(work),
subject: work.subject || item.subject || '',
grade: work.grade || item.grade || '',
is_public: 1,
})
const resource = created.data || created
work.status = 'published'
work.resource_id = resource?.id
ElMessage.success('已发布到资源广场')
if (resource?.id) router.push(`/resources/${resource.id}`)
} finally {
publishingKey.value = ''
}
}
async function unpublishWork(work: WorkItem) {
if (!work.resource_id) return
await deleteResource(work.resource_id)
if (work.type === 'courseware') {
await updateCourseware(work.id, {
title: work.raw.title,
subject: work.raw.subject || '',
grade: work.raw.grade || '',
description: work.raw.description || defaultDescription(work),
content: work.raw.content || [],
tags: work.raw.tags || [],
status: 'draft',
})
work.raw.status = 'draft'
}
if (work.type === 'animation') {
await updateAnimation(work.id, {
title: work.raw.title,
description: work.raw.description || defaultDescription(work),
config: work.raw.config || {},
status: 'draft',
})
work.raw.status = 'draft'
}
work.status = 'draft'
work.resource_id = undefined
ElMessage.success('资源已下架,作品仍保留在草稿中')
}
async function deleteWork(work: WorkItem) {
if (work.type === 'courseware') await deleteCourseware(work.id)
if (work.type === 'animation') await deleteAnimation(work.id)
if (work.type === 'exercise') await deleteExercise(work.id)
if (work.type === 'lesson_plan') await deleteLessonPlan(work.id)
if (work.type === 'exam') await deleteExam(work.id)
ElMessage.success('作品已删除,关联资源已下架')
await loadData()
}
onMounted(loadData)
</script>
<style scoped>
.works-page { max-width: 1480px; }
.works-header { max-width: 1180px; margin-left: auto; margin-right: auto; }
.page-header p { margin-top: 6px; color: var(--text-muted); font-size: 14px; }
.eyebrow { display: inline-flex; height: 24px; align-items: center; border-radius: 12px; background: #e6f6e5; color: #00543d; padding: 0 10px; font-size: 12px; font-weight: 900; margin-bottom: 10px; }
.header-actions { display: flex; align-items: center; gap: 10px; }
.work-stats { max-width: 1180px; margin: 0 auto 18px; display: grid; grid-template-columns: repeat(4, minmax(150px, 1fr)); gap: 12px; }
.work-stats div { min-height: 78px; display: flex; flex-direction: column; justify-content: center; gap: 5px; border: 1px solid #dce8de; border-radius: 14px; background: #fff; padding: 0 18px; }
.work-stats strong { color: #00543d; font-size: 26px; line-height: 1; }
.work-stats span { color: #6a7f77; font-size: 13px; }
.works-toolbar { max-width: 1180px; margin: 0 auto 18px; display: flex; align-items: center; justify-content: space-between; gap: 18px; }
.segmented { display: flex; align-items: center; gap: 8px; overflow-x: auto; scrollbar-width: none; }
.segmented::-webkit-scrollbar { display: none; }
.segmented button { height: 34px; border: 1px solid #dbe8de; border-radius: 17px; background: #fff; color: #51665f; padding: 0 14px; white-space: nowrap; cursor: pointer; }
.segmented button.active, .segmented button:hover { border-color: #9ed0aa; background: #eaf7e9; color: #00543d; font-weight: 800; }
.filter-tools { display: flex; align-items: center; gap: 10px; }
.search-box { width: 280px; height: 36px; display: flex; align-items: center; gap: 8px; border-radius: 18px; background: #eef1ec; color: #9aa7a0; padding: 0 12px; }
.search-box input { min-width: 0; flex: 1; border: none; outline: none; background: transparent; color: #173f35; }
.search-box button { width: 20px; height: 20px; border: none; border-radius: 50%; background: #dce8de; color: #51665f; cursor: pointer; }
.card-grid { max-width: 1180px; margin-left: auto; margin-right: auto; grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); }
.create-btn { display: flex; align-items: center; gap: 6px; background: #00543d; border: none; color: #fff; padding: 10px 20px; border-radius: 18px; font-size: 14px; font-weight: 800; cursor: pointer; transition: all 0.2s; }
.create-btn:hover { opacity: 0.9; transform: translateY(-1px); }
.ghost-create { display: flex; align-items: center; gap: 6px; border: 1px solid #d6e4d8; border-radius: 18px; background: #fff; color: #31584d; padding: 9px 16px; font-size: 14px; font-weight: 800; cursor: pointer; }
.ghost-create:hover { border-color: #9ed0aa; background: #f6fbf6; color: #00543d; }
.work-card { background: #fff; border: 1px solid #dce8de; border-radius: 14px; overflow: hidden; cursor: pointer; transition: all 0.2s ease; }
.work-card:hover { box-shadow: 0 12px 24px rgba(17, 69, 52, 0.1); transform: translateY(-3px); }
.work-cover { position: relative; height: 168px; display: flex; flex-direction: column; justify-content: center; gap: 8px; margin: 5px; padding: 22px; border-radius: 10px; color: #fff; overflow: hidden; }
.work-cover strong { position: relative; z-index: 1; max-width: 72%; font-size: 22px; line-height: 1.2; font-weight: 900; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.work-cover em { position: relative; z-index: 1; font-style: normal; font-size: 13px; opacity: .86; }
.cover-kicker { position: relative; z-index: 1; width: fit-content; max-width: 70%; padding: 4px 8px; border-radius: 12px; background: rgba(255,255,255,.22); font-size: 12px; font-weight: 800; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.type-pill, .status-pill, .unit-count { position: absolute; z-index: 2; height: 24px; display: inline-flex; align-items: center; border-radius: 12px; font-size: 12px; font-weight: 900; }
.type-pill { left: 8px; top: 8px; background: rgba(0,0,0,.22); color: #fff; padding: 0 8px; }
.status-pill { left: 8px; bottom: 8px; background: rgba(255,255,255,.88); color: #68756f; padding: 0 8px; }
.status-pill.published { color: #00543d; }
.unit-count { top: 8px; right: 8px; background: rgba(0,0,0,.25); color: #fff; padding: 0 9px; }
.cover-art { position: absolute; right: 22px; bottom: 28px; display: grid; gap: 7px; opacity: .86; }
.cover-art span { width: 86px; height: 34px; border-radius: 7px; background: rgba(255,255,255,.62); box-shadow: 0 6px 14px rgba(0,0,0,.12); }
.art-animation { bottom: 24px; gap: 0; }
.art-animation span { width: 68px; height: 68px; border-radius: 50%; border: 14px solid rgba(255,255,255,.42); background: transparent; grid-area: 1 / 1; }
.art-animation span:nth-child(2) { transform: translate(-28px, 16px) scale(.55); }
.art-animation span:nth-child(3) { transform: translate(22px, -18px) scale(.42); }
.art-exercise span, .art-exam span { width: 68px; height: 10px; border-radius: 10px; }
.art-lesson_plan span { width: 74px; height: 24px; }
.cover-green { background: linear-gradient(135deg, #064231, #00844e); }
.cover-yellow { background: linear-gradient(135deg, #ffd84d, #ffe475); color: #654100; }
.cover-mint { background: linear-gradient(135deg, #e7ffe9, #dff7e5); color: #00543d; }
.cover-blue { background: linear-gradient(135deg, #dbe8ff, #c8ddff); color: #003f68; }
.cover-cream { background: linear-gradient(135deg, #fff3c4, #f7e3a1); color: #7a5600; }
.work-body { padding: 16px 20px; }
.work-body h3 { font-size: 15px; font-weight: 700; color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-bottom: 6px; }
.work-desc { min-height: 38px; color: #6a7f77; font-size: 12px; line-height: 1.55; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; margin-bottom: 8px; }
.work-meta { font-size: 13px; color: var(--text-muted); margin-bottom: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tag-row { min-height: 24px; display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; }
.tag-row span { max-width: 100%; border-radius: 12px; background: #edf7ee; color: #00543d; padding: 4px 8px; font-size: 11px; font-weight: 800; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.work-footer { display: flex; justify-content: space-between; align-items: center; }
.work-actions { display: flex; gap: 5px; }
.icon-btn { width: 30px; height: 30px; display: flex; align-items: center; justify-content: center; background: #F8FAFC; border: 1px solid var(--border-color); border-radius: 6px; color: var(--text-secondary); cursor: pointer; transition: all 0.2s; }
.icon-btn:hover { background: var(--primary-bg); color: var(--primary); border-color: var(--primary-light); }
.icon-btn.warning:hover { background: #fff7ed; color: #c2410c; border-color: #fdba74; }
.icon-btn.danger:hover { background: #FEF2F2; color: #EF4444; border-color: #FCA5A5; }
.card-actions { display: grid; grid-template-columns: repeat(auto-fit, minmax(72px, 1fr)); gap: 8px; margin-top: 13px; }
.card-actions button, .dialog-btn { height: 32px; border: 1px solid #dbe8de; border-radius: 8px; background: #fff; color: #31584d; font-weight: 800; cursor: pointer; }
.card-actions button:hover, .dialog-btn:hover { border-color: #9ed0aa; background: #f6fbf6; color: #00543d; }
.card-actions button.primary, .dialog-btn.primary { border-color: #00543d; background: #00543d; color: #fff; }
.card-actions button.primary:hover, .dialog-btn.primary:hover { background: #004532; color: #fff; }
.card-actions button:disabled { opacity: .58; cursor: not-allowed; }
.empty-state { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 60px 0; }
.empty-state p { color: var(--text-muted); font-size: 15px; }
.quick-preview { height: 520px; display: grid; grid-template-columns: 150px minmax(0, 1fr); gap: 14px; }
.preview-thumbs { overflow-y: auto; display: grid; align-content: start; gap: 8px; border-right: 1px solid #edf3ee; padding-right: 10px; }
.preview-thumbs button { min-height: 62px; display: grid; grid-template-columns: 28px minmax(0, 1fr); align-items: center; gap: 8px; border: 1px solid #dce8de; border-radius: 10px; background: #fff; color: #51665f; padding: 8px; text-align: left; cursor: pointer; }
.preview-thumbs button.active { border-color: #00543d; background: #eaf7e9; color: #00543d; }
.preview-thumbs span { width: 28px; height: 28px; display: inline-flex; align-items: center; justify-content: center; border-radius: 8px; background: #edf3ee; font-weight: 900; }
.preview-thumbs strong { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
.preview-stage { overflow: hidden; border: 1px solid #dce8de; border-radius: 12px; background: #fff; }
.preview-stage :deep(.html-preview) { height: 100%; border-radius: 0; }
.single-preview { min-height: 520px; }
.structured-preview { min-height: 430px; display: grid; grid-template-rows: auto minmax(0, 1fr); gap: 14px; }
.structured-head { border: 1px solid #dce8de; border-radius: 12px; background: #f8fbf7; padding: 16px; }
.structured-head span { display: inline-flex; height: 24px; align-items: center; border-radius: 12px; background: #eaf7e9; color: #00543d; padding: 0 10px; font-size: 12px; font-weight: 900; }
.structured-head strong { display: block; margin-top: 10px; color: #173f35; font-size: 20px; }
.structured-head em { display: block; margin-top: 6px; color: #6a7f77; font-style: normal; font-size: 13px; }
.structured-list { display: grid; align-content: start; gap: 10px; max-height: 390px; overflow-y: auto; }
.structured-list div { display: grid; grid-template-columns: 30px minmax(0, 1fr); gap: 10px; border: 1px solid #e4eee6; border-radius: 10px; background: #fff; padding: 10px; }
.structured-list span { width: 30px; height: 30px; display: inline-flex; align-items: center; justify-content: center; border-radius: 9px; background: #edf7ee; color: #00543d; font-weight: 900; }
.structured-list p { min-width: 0; color: #294f45; font-size: 13px; line-height: 1.6; overflow-wrap: anywhere; }
.preview-empty { height: 100%; min-height: 260px; display: flex; align-items: center; justify-content: center; color: #8a9a93; }
.quick-preview-dialog :deep(.el-dialog__footer) { display: flex; justify-content: flex-end; gap: 8px; }
@media (max-width: 900px) {
.works-toolbar, .filter-tools, .header-actions { align-items: stretch; flex-direction: column; width: 100%; }
.search-box { width: 100%; }
}
@media (max-width: 760px) {
.work-stats { grid-template-columns: 1fr; }
.quick-preview { height: 520px; grid-template-columns: 1fr; }
.preview-thumbs { display: flex; overflow-x: auto; border-right: none; padding-right: 0; }
.preview-thumbs button { flex: 0 0 130px; }
}
</style>
File diff suppressed because it is too large Load Diff
+886
View File
@@ -0,0 +1,886 @@
<template>
<div class="demo-page">
<header class="demo-header">
<div class="demo-brand">
<strong>智教助手</strong>
<span>zhijiao.local</span>
</div>
<div class="demo-search">
<el-icon><Search /></el-icon>
<input placeholder="输入知识点搜索资源" />
</div>
<button class="try-btn" @click="router.push('/')">立即体验</button>
</header>
<main class="demo-main">
<aside class="thumb-rail">
<button
v-for="(page, index) in pages"
:key="page.title"
:class="{ active: currentPage === index }"
type="button"
@click="currentPage = index"
>
<div class="thumb-card" :class="page.theme">
<strong>{{ page.short }}</strong>
<small>{{ page.badge }}</small>
</div>
<span>{{ index + 1 }}</span>
</button>
</aside>
<section class="stage-wrap">
<button class="fullscreen-btn" type="button" @click="isFullscreen = true">
<el-icon><FullScreen /></el-icon>
全屏
</button>
<div class="slide-stage" :class="current.theme">
<div class="slide-page-meta">
<span>{{ currentPage + 1 }} / {{ pages.length }}</span>
<strong>{{ current.badge }}</strong>
</div>
<div class="slide-text">
<h1>{{ current.title }}</h1>
<p>{{ current.subtitle }}</p>
<em>{{ current.badge }}</em>
</div>
<div class="slide-visual">
<div v-if="current.visual === 'tower'" class="big-tower"></div>
<div v-else-if="current.visual === 'chart'" class="big-chart"><span></span><span></span><span></span></div>
<div v-else-if="current.visual === 'dashboard'" class="big-dashboard">
<span></span><span></span><span></span><span></span>
</div>
<div v-else-if="current.visual === 'devices'" class="big-devices">
<span></span><span></span><span></span>
</div>
<div v-else-if="current.visual === 'food'" class="big-food">
<span></span><span></span><span></span><span></span>
</div>
<div v-else-if="current.visual === 'vision'" class="big-vision">
<span></span><span></span><span></span><span></span>
</div>
<div v-else class="big-cards"><span></span><span></span><span></span></div>
</div>
<button class="play-btn" type="button" @click="isPlaying = !isPlaying">
<el-icon><VideoPlay /></el-icon>
{{ isPlaying ? '暂停' : '演示' }}
</button>
</div>
<div class="author-bar">
<div class="author">
<span class="face"></span>
<div>
<strong>{{ resource.author }}</strong>
<small>{{ resource.school }} · {{ resource.type }}</small>
</div>
<button type="button"><el-icon><Star /></el-icon>关注</button>
</div>
<div class="metrics">
<span><el-icon><View /></el-icon>{{ resource.views }}</span>
<span><el-icon><Star /></el-icon>{{ resource.likes }}</span>
<button type="button" @click="share">分享</button>
<button type="button" class="edit" @click="remix">一键改编</button>
<button type="button" class="download" @click="downloadDemo">下载</button>
</div>
</div>
<div class="resource-summary">
<span v-for="tag in resource.tags" :key="tag">{{ tag }}</span>
<p>{{ resource.summary }}</p>
</div>
</section>
<aside class="recommend">
<div class="recommend-head">
<h2>相关推荐</h2>
<button type="button" @click="router.push('/')">更多</button>
</div>
<article v-for="item in recommendations" :key="item.title" @click="switchResource(item.id)">
<div class="rec-cover" :class="item.theme">
<span v-if="item.hot">🔥热门</span>
</div>
<div>
<h3>{{ item.title }}</h3>
<p>{{ item.author }}</p>
<small><el-icon><View /></el-icon>{{ item.views }} <el-icon><Star /></el-icon>{{ item.likes }}</small>
</div>
</article>
</aside>
</main>
<div v-if="isFullscreen" class="fullscreen-layer" @click.self="isFullscreen = false">
<button type="button" @click="isFullscreen = false">退出</button>
<div class="slide-stage large" :class="current.theme">
<div class="slide-text">
<h1>{{ current.title }}</h1>
<p>{{ current.subtitle }}</p>
<em>{{ current.badge }}</em>
</div>
<div class="slide-visual">
<div v-if="current.visual === 'tower'" class="big-tower"></div>
<div v-else-if="current.visual === 'chart'" class="big-chart"><span></span><span></span><span></span></div>
<div v-else-if="current.visual === 'dashboard'" class="big-dashboard">
<span></span><span></span><span></span><span></span>
</div>
<div v-else-if="current.visual === 'devices'" class="big-devices">
<span></span><span></span><span></span>
</div>
<div v-else-if="current.visual === 'food'" class="big-food">
<span></span><span></span><span></span><span></span>
</div>
<div v-else-if="current.visual === 'vision'" class="big-vision">
<span></span><span></span><span></span><span></span>
</div>
<div v-else class="big-cards"><span></span><span></span><span></span></div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import { createDemoDraft, isDemoDraftAvailable } from '@/utils/demoDrafts'
const route = useRoute()
const router = useRouter()
const currentPage = ref(0)
const isFullscreen = ref(false)
const isPlaying = ref(false)
const resources: Record<string, any> = {
cylinder: { title: '【互动课件】小学数学六年级圆柱的体积', author: '小叶老师用AI', views: '6.5万', likes: 5509, theme: 'cream', school: '小学数学', type: '互动课件', tags: ['六年级', '圆柱体积', '公式推导'], summary: '围绕圆柱体积公式展开,包含旧知回顾、操作探究、公式推导、巩固练习和拓展应用。' },
guide: { title: '智教助手新手指引', author: 'TeacherYO', views: '8.2万', likes: 6211, theme: 'green', school: '平台指南', type: '新手教程', tags: ['快速入门', '上传材料', '生成课件'], summary: '帮助教师了解从输入需求到生成、预览、改编和发布作品的完整流程。' },
tips: { title: '【玩转互动课件】必看小技巧', author: 'TeacherYO', views: '7.2万', likes: 6065, theme: 'yellow', school: '课堂工具', type: '技巧合集', tags: ['互动课件', '图片替换', '课堂展示'], summary: '整理课件编辑、图片替换、动画插入和课堂展示的高频技巧。' },
data: { title: '【数据回收】新手指引', author: 'TeacherYO', views: '6.4万', likes: 6006, theme: 'mint', school: '课堂工具', type: '数据回收', tags: ['问答收集', '投票', '统计'], summary: '演示如何创建课堂收集任务,并把学生回答汇总成可读数据。' },
food: { title: 'Unit4 Healthy Food 第一课时', author: '海淀老师调AI图', views: '6.6万', likes: 5866, theme: 'yellow', school: '初中英语', type: '教学游戏', tags: ['Healthy Food', '词汇认读', '句型操练'], summary: '围绕健康饮食主题组织单词认读、句型表达、听说操练和闯关反馈。' },
'english-food': { title: 'Unit4 Healthy Food 第一课时', author: '海淀老师调AI图', views: '6.6万', likes: 5866, theme: 'yellow', school: '初中英语', type: '教学游戏', tags: ['Healthy Food', '词汇认读', '句型操练'], summary: '围绕健康饮食主题组织单词认读、句型表达、听说操练和闯关反馈。' },
case: { title: '经典案例解析合集', author: 'TeacherYO', views: '8.7万', likes: 5910, theme: 'blue', school: '案例中心', type: '案例解析', tags: ['优秀案例', '结构拆解', '教学设计'], summary: '用真实课例拆解互动设计、页面结构和课堂节奏。' },
creator: { title: '【AI好课晒一晒】创作者激励活动', author: 'TeacherYO', views: '6.1万', likes: 5776, theme: 'yellow', school: '活动中心', type: '创作者活动', tags: ['发布作品', '资源复用', '教学案例'], summary: '把优秀互动课件发布出来,让更多老师复用、改编和反馈。' },
vision: { title: 'AI如何“看”世界?', author: '蒋老师的AI实验室', views: '5.6万', likes: 5443, theme: 'dark', school: '信息科技', type: '教学动画', tags: ['人工智能', '图像识别', '流程动画'], summary: '通过视觉流程和问题引导解释图像识别的基本工作过程。' },
}
const resource = computed(() => resources[String(route.params.id)] || resources.cylinder)
const pagePresets: Record<string, any[]> = {
cylinder: [
{ title: resources.cylinder.title, short: '封面', theme: 'cream', visual: 'tower', subtitle: '小学数学\n人教版六年级下册', badge: '天坛 · 祈年殿' },
{ title: '知识回顾', short: '回顾', theme: 'cream', visual: 'cards', subtitle: '复习长方体、正方体体积公式,为圆柱体积推导做准备。', badge: '旧知迁移' },
{ title: '操作探究', short: '探究', theme: 'mint', visual: 'chart', subtitle: '拖动滑块观察底面积和高如何影响圆柱体积。', badge: '互动实验' },
{ title: '公式推导', short: '公式', theme: 'blue', visual: 'cards', subtitle: '把圆柱转化为近似长方体,理解 V = S × h。', badge: '转化思想' },
{ title: '巩固练习', short: '练习', theme: 'yellow', visual: 'chart', subtitle: '完成分层练习,系统自动统计正确率。', badge: '课堂检测' },
{ title: '拓展应用', short: '应用', theme: 'green', visual: 'cards', subtitle: '解决生活中的圆柱容积问题。', badge: '真实情境' },
],
data: [
{ title: resources.data.title, short: '封面', theme: 'mint', visual: 'dashboard', subtitle: '课堂工具\n问答、投票、统计一体化', badge: '数据回收' },
{ title: '创建收集任务', short: '创建', theme: 'green', visual: 'cards', subtitle: '选择问答、投票或打卡模板,30 秒生成可投放任务。', badge: '任务配置' },
{ title: '学生扫码提交', short: '提交', theme: 'blue', visual: 'devices', subtitle: '学生使用手机或平板提交答案,教师端实时刷新。', badge: '课堂投放' },
{ title: '自动统计结果', short: '统计', theme: 'mint', visual: 'dashboard', subtitle: '自动统计选项分布、关键词和未提交名单。', badge: '即时反馈' },
{ title: '生成复盘建议', short: '复盘', theme: 'cream', visual: 'cards', subtitle: '根据答题数据生成讲评重点和二次练习建议。', badge: '教学闭环' },
],
food: [
{ title: resources.food.title, short: '封面', theme: 'yellow', visual: 'food', subtitle: '初中英语\nUnit 4 Healthy Food', badge: "Let's speak" },
{ title: 'Warm Up', short: '热身', theme: 'cream', visual: 'cards', subtitle: '观察食物图片,说出 healthy / unhealthy food。', badge: 'Look and say' },
{ title: 'Words Practice', short: '词汇', theme: 'yellow', visual: 'food', subtitle: '听音选择、拖拽分类,巩固 fruit、vegetable、drink 等词汇。', badge: 'Vocabulary' },
{ title: 'Sentence Pattern', short: '句型', theme: 'blue', visual: 'devices', subtitle: '练习 What would you like? I would like... 的问答表达。', badge: 'Speaking' },
{ title: 'Class Game', short: '闯关', theme: 'green', visual: 'cards', subtitle: '小组闯关统计得分,形成课堂即时反馈。', badge: 'Challenge' },
],
vision: [
{ title: resources.vision.title, short: '封面', theme: 'dark', visual: 'vision', subtitle: '信息科技\n揭秘图像识别的工作过程', badge: 'AI 体验课' },
{ title: '输入图像', short: '输入', theme: 'dark', visual: 'devices', subtitle: '把一张图片拆成像素、颜色和边缘等可计算信息。', badge: 'Image input' },
{ title: '提取特征', short: '特征', theme: 'blue', visual: 'vision', subtitle: '模型寻找轮廓、纹理、形状等关键特征。', badge: 'Feature' },
{ title: '分类判断', short: '分类', theme: 'mint', visual: 'chart', subtitle: '比较相似度并输出最可能的类别和置信度。', badge: 'Prediction' },
{ title: '讨论边界', short: '讨论', theme: 'green', visual: 'cards', subtitle: '分析 AI 会出错的场景,理解数据和模型偏差。', badge: 'Reflection' },
],
guide: [
{ title: resources.guide.title, short: '封面', theme: 'green', visual: 'cards', subtitle: '一句话生成专业级互动课件\n让教学更生动', badge: '新手指引' },
{ title: '输入教学需求', short: '输入', theme: 'cream', visual: 'cards', subtitle: '输入知识点、年级、教材版本和课堂目标。', badge: 'Prompt' },
{ title: '选择生成类型', short: '类型', theme: 'mint', visual: 'dashboard', subtitle: '选择互动课件、教学动画、课堂游戏或数据回收。', badge: 'Workflow' },
{ title: '编辑并发布', short: '发布', theme: 'blue', visual: 'devices', subtitle: '预览、微调、导出,并发布到资源广场供二次改编。', badge: 'Publish' },
],
tips: [
{ title: resources.tips.title, short: '封面', theme: 'yellow', visual: 'cards', subtitle: '快速掌握互动课件改编技巧', badge: '技巧合集' },
{ title: '替换图片素材', short: '图片', theme: 'cream', visual: 'devices', subtitle: '在预览中选中图片即可替换或调整大小。', badge: 'Image' },
{ title: '插入动画片段', short: '动画', theme: 'green', visual: 'cards', subtitle: '把动画、提问、练习加入同一节课的节奏里。', badge: 'Animation' },
{ title: '复用优秀结构', short: '复用', theme: 'blue', visual: 'dashboard', subtitle: '参考热门资源结构,改写成自己的原创课堂内容。', badge: 'Remix' },
],
case: [
{ title: resources.case.title, short: '封面', theme: 'blue', visual: 'devices', subtitle: '拆解优秀互动课件\n学习结构和课堂节奏', badge: '案例解析' },
{ title: '看封面定位', short: '封面', theme: 'cream', visual: 'cards', subtitle: '用标题、学段、教材和视觉主题快速建立课堂期待。', badge: 'Cover' },
{ title: '看互动设计', short: '互动', theme: 'mint', visual: 'dashboard', subtitle: '拆解点击、拖拽、投票、反馈等互动环节。', badge: 'Interaction' },
{ title: '看复盘沉淀', short: '复盘', theme: 'green', visual: 'cards', subtitle: '把课堂结果沉淀成下一轮备课素材。', badge: 'Review' },
],
creator: [
{ title: resources.creator.title, short: '封面', theme: 'yellow', visual: 'cards', subtitle: '把好课发布出来\n让更多老师复用和改编', badge: '活动中心' },
{ title: '发布作品', short: '发布', theme: 'green', visual: 'devices', subtitle: '从课件详情一键发布到资源广场。', badge: 'Publish' },
{ title: '获得反馈', short: '反馈', theme: 'mint', visual: 'dashboard', subtitle: '查看浏览、收藏、下载和改编数据。', badge: 'Stats' },
{ title: '持续迭代', short: '迭代', theme: 'blue', visual: 'cards', subtitle: '根据教师反馈持续优化资源。', badge: 'Iterate' },
],
}
pagePresets['english-food'] = pagePresets.food
const pages = computed(() => pagePresets[String(route.params.id)] || pagePresets.cylinder)
const current = computed(() => pages.value[currentPage.value])
const recommendations = [
{ id: 'cylinder', title: '小学数学六年级圆柱的体积', author: '小叶老师用AI', views: '6.5万', likes: 5509, theme: 'cream', hot: true },
{ id: 'food', title: 'Unit4 Healthy Food 第一课时', author: '海淀老师调AI图', views: '6.6万', likes: 5866, theme: 'yellow', hot: true },
{ id: 'data', title: '课堂数据回收新手指引', author: 'TeacherYO', views: '6.4万', likes: 6006, theme: 'mint', hot: true },
{ id: 'vision', title: 'AI如何“看”世界?', author: '蒋老师的AI实验室', views: '5.6万', likes: 5443, theme: 'dark', hot: false },
{ id: 'guide', title: '智教助手新手指引', author: 'TeacherYO', views: '8.2万', likes: 6211, theme: 'green', hot: false },
]
watch(() => route.params.id, () => {
currentPage.value = 0
})
function switchResource(id: string) {
currentPage.value = 0
router.push(`/demo/${id}`)
}
async function remix() {
if (isDemoDraftAvailable(route.params.id)) {
if (!localStorage.getItem('access_token')) {
ElMessage.warning('请先登录后再改编示例资源')
router.push({ path: '/login', query: { redirect: route.fullPath } })
return
}
try {
const result = await createDemoDraft(route.params.id)
ElMessage.success('已创建可编辑示例草稿')
router.push(result.openPath)
return
} catch {
ElMessage.warning('示例草稿创建失败,已为你带入创作指令')
}
}
localStorage.setItem('quick_create_prompt', `基于《${resource.value.title}》改编一份同风格互动课件`)
router.push('/courseware/create')
}
function share() {
navigator.clipboard?.writeText(window.location.href)
ElMessage.success('链接已复制')
}
function downloadDemo() {
ElMessage.success('演示文件已加入下载队列')
}
</script>
<style scoped>
.demo-page {
min-height: 100vh;
background: #f8faf7;
color: #233c35;
}
.demo-header {
height: 58px;
display: grid;
grid-template-columns: 330px minmax(240px, 440px) 1fr;
align-items: center;
gap: 20px;
padding: 0 16px;
border-bottom: 1px solid #e1e8e2;
background: #fff;
}
.demo-brand {
display: flex;
align-items: center;
gap: 18px;
}
.demo-brand strong {
color: #00543d;
font-size: 19px;
}
.demo-brand span {
color: #75867f;
letter-spacing: 1px;
}
.demo-search {
height: 36px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 14px;
border-radius: 18px;
background: #f0f2ee;
color: #a2aaa5;
}
.demo-search input {
flex: 1;
border: none;
outline: none;
background: transparent;
}
.try-btn {
justify-self: end;
width: 92px;
height: 34px;
border: none;
border-radius: 18px;
background: #00543d;
color: #fff;
font-weight: 800;
cursor: pointer;
}
.demo-main {
display: grid;
grid-template-columns: 156px minmax(0, 1fr) 330px;
gap: 18px;
padding: 0 16px 20px;
}
.thumb-rail {
height: calc(100vh - 58px);
overflow-y: auto;
border-right: 1px solid #d8e2da;
padding: 20px 14px 18px 0;
}
.thumb-rail button {
position: relative;
width: 122px;
display: block;
border: none;
background: transparent;
cursor: pointer;
margin-bottom: 10px;
}
.thumb-card {
height: 68px;
border: 1px solid #d6dfd8;
border-radius: 4px;
padding: 8px;
text-align: left;
font-size: 12px;
overflow: hidden;
}
.thumb-card strong {
display: block;
}
.thumb-card small {
display: block;
margin-top: 6px;
color: rgba(0, 0, 0, 0.48);
font-size: 11px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.thumb-rail button.active .thumb-card {
border: 2px solid #ff8a00;
}
.thumb-rail button > span {
position: absolute;
right: 1px;
bottom: 1px;
width: 20px;
height: 20px;
border-radius: 5px 0 4px 0;
background: #7b9787;
color: #fff;
font-size: 12px;
font-weight: 800;
}
.stage-wrap {
position: relative;
min-width: 0;
padding-top: 58px;
}
.fullscreen-btn {
position: absolute;
top: 16px;
right: 14px;
z-index: 2;
height: 36px;
display: inline-flex;
align-items: center;
gap: 7px;
border: none;
border-radius: 8px;
background: rgba(0, 0, 0, 0.34);
color: #fff;
padding: 0 14px;
cursor: pointer;
}
.slide-stage {
position: relative;
aspect-ratio: 16 / 9;
border: 1px solid #e1e8e2;
border-radius: 14px;
overflow: hidden;
display: grid;
grid-template-columns: 1fr 1fr;
padding: 58px 42px;
}
.slide-page-meta {
position: absolute;
left: 22px;
right: 22px;
bottom: 20px;
display: flex;
align-items: center;
gap: 10px;
color: rgba(0, 0, 0, 0.45);
font-size: 13px;
}
.slide-page-meta strong {
color: inherit;
font-size: 13px;
}
.slide-stage.large {
width: min(92vw, 1280px);
}
.slide-text {
display: flex;
flex-direction: column;
justify-content: center;
}
.slide-text h1 {
color: inherit;
font-size: clamp(36px, 4vw, 72px);
line-height: 1.12;
font-weight: 900;
margin-bottom: 34px;
}
.slide-text p {
white-space: pre-line;
font-size: clamp(18px, 1.9vw, 30px);
line-height: 1.65;
font-weight: 700;
}
.slide-text em {
position: absolute;
top: 44px;
right: 44px;
padding: 8px 14px;
border-radius: 5px;
background: rgba(120, 83, 0, 0.92);
color: #fff;
font-style: normal;
font-size: 22px;
font-weight: 900;
}
.slide-visual {
display: flex;
align-items: center;
justify-content: center;
}
.cream { color: #755300; background: #fff4c8; }
.mint { color: #00543d; background: #e9f8eb; }
.blue { color: #003e6b; background: #dceaff; }
.yellow { color: #654100; background: #ffe37a; }
.green { color: #fff; background: linear-gradient(135deg, #064231, #00844e); }
.dark { color: #fff; background: #080b12; }
.big-tower {
width: 340px;
height: 360px;
background:
linear-gradient(#0a6b7b, #0a6b7b) 105px 0 / 130px 32px no-repeat,
linear-gradient(#ba5a2a, #ba5a2a) 70px 80px / 200px 42px no-repeat,
linear-gradient(#0a6b7b, #0a6b7b) 44px 122px / 252px 54px no-repeat,
linear-gradient(#ba5a2a, #ba5a2a) 86px 210px / 168px 48px no-repeat,
linear-gradient(#e5dfd0, #e5dfd0) 0 282px / 340px 70px no-repeat;
border-radius: 50% 50% 10px 10px;
}
.big-chart {
display: flex;
align-items: flex-end;
gap: 26px;
}
.big-chart span {
width: 70px;
border-radius: 18px 18px 0 0;
background: rgba(0, 84, 61, 0.55);
}
.big-chart span:nth-child(1) { height: 120px; }
.big-chart span:nth-child(2) { height: 240px; }
.big-chart span:nth-child(3) { height: 180px; }
.big-cards {
display: grid;
gap: 22px;
}
.big-cards span {
width: 320px;
height: 78px;
border-radius: 14px;
background: rgba(255,255,255,0.55);
}
.big-dashboard {
width: min(360px, 90%);
display: grid;
grid-template-columns: 1.25fr .75fr;
gap: 14px;
}
.big-dashboard span {
min-height: 92px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.72);
box-shadow: 0 16px 28px rgba(17, 69, 52, 0.1);
}
.big-dashboard span:first-child {
grid-row: span 2;
background:
radial-gradient(circle at 50% 46%, transparent 0 38px, rgba(0, 84, 61, .24) 39px 52px, transparent 53px),
conic-gradient(from 30deg, #0f8a5f 0 38%, #fed766 38% 64%, #7fb3ff 64% 100%);
}
.big-dashboard span:nth-child(2) { background: linear-gradient(135deg, rgba(255,255,255,.84), rgba(198,238,207,.8)); }
.big-dashboard span:nth-child(3) { background: linear-gradient(135deg, rgba(255,255,255,.84), rgba(207,223,255,.8)); }
.big-dashboard span:nth-child(4) { grid-column: span 2; min-height: 58px; background: rgba(255,255,255,.58); }
.big-devices {
position: relative;
width: 360px;
height: 290px;
}
.big-devices span {
position: absolute;
border-radius: 18px;
background: rgba(255,255,255,.72);
box-shadow: 0 18px 34px rgba(17, 69, 52, .14);
}
.big-devices span:first-child {
left: 18px;
top: 34px;
width: 250px;
height: 158px;
}
.big-devices span:nth-child(2) {
right: 32px;
bottom: 24px;
width: 92px;
height: 168px;
}
.big-devices span:nth-child(3) {
left: 78px;
bottom: 42px;
width: 150px;
height: 22px;
border-radius: 11px;
background: rgba(0, 84, 61, .28);
}
.big-food {
width: 360px;
height: 280px;
position: relative;
}
.big-food span {
position: absolute;
border-radius: 50%;
box-shadow: 0 16px 28px rgba(117, 83, 0, .16);
}
.big-food span:first-child { left: 24px; top: 92px; width: 106px; height: 106px; background: #f46f45; }
.big-food span:nth-child(2) { right: 52px; top: 54px; width: 126px; height: 126px; background: #58b96f; }
.big-food span:nth-child(3) { left: 132px; bottom: 34px; width: 150px; height: 70px; border-radius: 36px; background: #ffcf4c; }
.big-food span:nth-child(4) { right: 18px; bottom: 52px; width: 84px; height: 84px; background: #fff; }
.big-vision {
position: relative;
width: 380px;
height: 300px;
}
.big-vision span {
position: absolute;
border: 1px solid rgba(91, 226, 255, .36);
border-radius: 18px;
background: rgba(45, 79, 128, .34);
box-shadow: 0 0 34px rgba(74, 210, 255, .18);
}
.big-vision span:first-child { left: 128px; top: 84px; width: 118px; height: 118px; border-radius: 50%; background: radial-gradient(circle, #6be7ff 0 9px, rgba(45,79,128,.36) 10px); }
.big-vision span:nth-child(2) { left: 16px; top: 42px; width: 108px; height: 68px; transform: rotate(-10deg); }
.big-vision span:nth-child(3) { right: 28px; top: 30px; width: 124px; height: 76px; transform: rotate(8deg); }
.big-vision span:nth-child(4) { left: 98px; bottom: 28px; width: 188px; height: 74px; }
.play-btn {
position: absolute;
right: 24px;
bottom: 20px;
display: inline-flex;
align-items: center;
gap: 7px;
border: 1px solid #a9d2b4;
border-radius: 9px;
background: #d7f0d9;
color: #00543d;
padding: 9px 17px;
font-weight: 800;
cursor: pointer;
}
.author-bar {
min-height: 76px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
padding: 18px 0;
}
.resource-summary {
margin-bottom: 18px;
padding: 16px 18px;
border: 1px solid #dfe8e1;
border-radius: 12px;
background: #fff;
}
.resource-summary span {
display: inline-flex;
align-items: center;
height: 24px;
margin: 0 8px 8px 0;
padding: 0 10px;
border-radius: 12px;
background: #edf7ee;
color: #00543d;
font-size: 12px;
font-weight: 800;
}
.resource-summary p {
color: #50665e;
font-size: 14px;
line-height: 1.7;
}
.author,
.metrics {
display: flex;
align-items: center;
gap: 12px;
}
.face {
width: 42px;
height: 42px;
border-radius: 50%;
background: linear-gradient(135deg, #f7c7b0, #5b8def);
}
.author small {
display: block;
color: #8a9a93;
margin-top: 2px;
}
.author button,
.metrics button {
border: none;
border-radius: 9px;
background: #e8f5ea;
color: #00543d;
padding: 9px 16px;
font-weight: 800;
cursor: pointer;
}
.metrics span {
display: inline-flex;
align-items: center;
gap: 5px;
color: #4c625a;
}
.metrics .edit {
background: #dceee0;
}
.metrics .download {
background: #00543d;
color: #fff;
}
.recommend {
height: calc(100vh - 58px);
overflow-y: auto;
padding: 0 0 20px;
}
.recommend-head {
display: flex;
align-items: center;
justify-content: space-between;
margin: 28px 0 14px;
}
.recommend h2 {
font-size: 18px;
}
.recommend-head button {
border: none;
background: transparent;
color: #00543d;
font-size: 13px;
font-weight: 800;
cursor: pointer;
}
.recommend article {
display: grid;
grid-template-columns: 154px 1fr;
gap: 12px;
cursor: pointer;
margin-bottom: 14px;
}
.rec-cover {
position: relative;
height: 84px;
border-radius: 7px;
overflow: hidden;
}
.rec-cover span {
position: absolute;
top: 6px;
left: 6px;
background: #fff;
color: #ff4b20;
font-size: 12px;
font-weight: 800;
border-radius: 10px;
padding: 3px 7px;
}
.recommend h3 {
font-size: 16px;
line-height: 1.35;
font-weight: 500;
margin-bottom: 8px;
}
.recommend p,
.recommend small {
color: #7d8d86;
font-size: 12px;
}
.recommend small {
display: flex;
align-items: center;
gap: 5px;
margin-top: 8px;
}
.fullscreen-layer {
position: fixed;
inset: 0;
z-index: 30;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.85);
}
.fullscreen-layer > button {
position: fixed;
top: 20px;
right: 24px;
border: none;
border-radius: 18px;
background: #fff;
color: #00543d;
padding: 9px 18px;
font-weight: 800;
cursor: pointer;
}
@media (max-width: 1180px) {
.demo-main {
grid-template-columns: 132px minmax(0, 1fr);
}
.recommend {
display: none;
}
}
@media (max-width: 760px) {
.demo-header {
grid-template-columns: 1fr auto;
}
.demo-search {
display: none;
}
.demo-main {
grid-template-columns: 1fr;
}
.thumb-rail {
height: auto;
display: flex;
gap: 8px;
border-right: none;
overflow-x: auto;
padding: 12px 0;
}
.stage-wrap {
padding-top: 8px;
}
.slide-stage {
grid-template-columns: 1fr;
padding: 28px 22px;
}
.author-bar {
align-items: flex-start;
flex-direction: column;
}
}
</style>
+518
View File
@@ -0,0 +1,518 @@
<template>
<div class="page-container">
<div class="gen-hero">
<div class="gen-hero-icon" style="background: var(--gradient-cyan)">
<el-icon :size="28" color="#fff"><EditPen /></el-icon>
</div>
<div>
<h2>作文智能批改</h2>
<p>AI多维度智能批改,即时反馈作文质量</p>
</div>
</div>
<div class="essay-workflow">
<button
v-for="sample in essaySamples"
:key="sample.title"
type="button"
class="sample-card"
@click="applySample(sample)"
>
<strong>{{ sample.title }}</strong>
<span>{{ sample.desc }}</span>
</button>
</div>
<div class="essay-layout">
<div class="essay-input-panel">
<div class="panel-header">
<h3>输入作文</h3>
<div class="settings-row">
<el-select v-model="form.grade_level" size="small" style="width: 90px">
<el-option label="小学" value="小学" />
<el-option label="初中" value="初中" />
<el-option label="高中" value="高中" />
</el-select>
<el-select v-model="form.essay_type" size="small" style="width: 90px">
<el-option label="记叙文" value="记叙文" />
<el-option label="议论文" value="议论文" />
<el-option label="说明文" value="说明文" />
<el-option label="散文" value="散文" />
</el-select>
<el-input-number v-model="form.total_score" :min="10" :max="100" size="small" style="width: 100px" />
</div>
</div>
<el-input
v-model="form.essay_text"
type="textarea"
:rows="20"
placeholder="请在此粘贴学生作文内容..."
resize="none"
class="essay-input"
/>
<button class="grade-btn" :disabled="!form.essay_text || grading" @click="handleGrade">
<el-icon v-if="!grading"><EditPen /></el-icon>
<span v-if="grading" class="btn-loading"></span>
{{ grading ? 'AI批改中...' : '开始批改' }}
</button>
<div class="grading-dimensions">
<div v-for="item in gradingDimensions" :key="item.title">
<strong>{{ item.title }}</strong>
<span>{{ item.desc }}</span>
</div>
</div>
</div>
<div class="essay-result-panel">
<div v-if="grading && !result" class="grading-section">
<div class="grading-spinner"></div>
<p>AI正在多维度批改作文...</p>
<p style="font-size:13px;opacity:.6;margin-top:8px">模型深度推理中,通常需要 1-2 分钟,请耐心等待</p>
</div>
<div v-if="result" class="result-content">
<div class="result-actions">
<button type="button" @click="downloadReport">
<el-icon><Download /></el-icon>
下载报告
</button>
</div>
<div class="score-banner" :class="result.level">
<div class="score-main">
<span class="score-value">{{ result.total_score }}</span>
<span class="score-total">/ {{ form.total_score }}</span>
</div>
<el-tag :type="(getLevelType(result.level) as any)" size="large" effect="dark">{{ result.level }}</el-tag>
</div>
<div class="score-details">
<div class="score-item" v-for="(item, key) in (result.scores as Record<string, any>)" :key="key">
<div class="score-ring">
<svg viewBox="0 0 36 36">
<path d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" fill="none" stroke="#E2E8F0" stroke-width="3" />
<path d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" fill="none" stroke="#6366F1" stroke-width="3" :stroke-dasharray="`${(item.score / item.max) * 100}, 100`" />
</svg>
<span class="ring-text">{{ item.score }}/{{ item.max }}</span>
</div>
<span class="score-name">{{ getScoreLabel(String(key)) }}</span>
</div>
</div>
<div class="comment-block">
<h4>总评</h4>
<MarkdownContent :content="result.overall_comment" />
</div>
<div v-if="result.strengths?.length" class="comment-block">
<h4 class="good">优点</h4>
<ul><li v-for="(s, i) in result.strengths" :key="i"><MarkdownContent :content="s" /></li></ul>
</div>
<div v-if="result.weaknesses?.length" class="comment-block">
<h4 class="bad">不足</h4>
<ul><li v-for="(w, i) in result.weaknesses" :key="i"><MarkdownContent :content="w" /></li></ul>
</div>
<div v-if="result.suggestions?.length" class="comment-block">
<h4 class="warn">改进建议</h4>
<ul><li v-for="(s, i) in result.suggestions" :key="i"><MarkdownContent :content="s" /></li></ul>
</div>
<div v-if="result.annotations?.length" class="comment-block">
<h4>逐句批注</h4>
<div class="annotation-list">
<div v-for="(a, i) in result.annotations" :key="i" class="annotation-item" :class="a.type">
<el-tag :type="a.type === 'error' ? 'danger' : a.type === 'good' ? 'success' : 'warning'" size="small" effect="plain">
{{ a.type === 'error' ? '错误' : a.type === 'good' ? '佳句' : '建议' }}
</el-tag>
<span class="annotation-text">{{ a.text }}</span>
<span class="annotation-comment">{{ a.comment }}</span>
</div>
</div>
</div>
</div>
<div v-else class="empty-result">
<el-icon :size="48" color="#CBD5E1"><EditPen /></el-icon>
<p>输入作文内容后点击"开始批改"</p>
<div class="empty-points">
<span>总分档位</span>
<span>分项得分</span>
<span>亮点不足</span>
<span>逐句批注</span>
</div>
</div>
</div>
</div>
<section class="saved-section">
<div class="saved-header">
<div>
<h3>已批改作文</h3>
<p>每次批改会自动保存,可再次打开、下载报告或删除。</p>
</div>
<button type="button" class="refresh-btn" @click="refreshSavedGrades">
<el-icon><Refresh /></el-icon>
刷新
</button>
</div>
<div v-if="savedLoading" class="saved-loading">正在加载批改记录...</div>
<div v-else-if="savedGrades.length === 0" class="saved-empty">
<el-icon><EditPen /></el-icon>
<span>暂无批改记录</span>
</div>
<div v-else class="saved-grid">
<article v-for="item in savedGrades" :key="item.id" class="saved-card">
<div>
<span>{{ item.grade_level }} · {{ item.essay_type }}</span>
<strong>{{ item.title }}</strong>
<small>{{ item.result?.total_score ?? '-' }}/{{ item.total_score }} · {{ item.result?.level || '已批改' }}</small>
</div>
<p>{{ item.essay_text }}</p>
<div class="saved-actions">
<button type="button" @click="openSaved(item)"><el-icon><View /></el-icon>打开</button>
<button type="button" @click="downloadSaved(item)"><el-icon><Download /></el-icon>下载</button>
<el-popconfirm title="确定删除这条批改记录?" @confirm="removeSaved(item.id)">
<template #reference>
<button type="button" class="danger"><el-icon><Delete /></el-icon>删除</button>
</template>
</el-popconfirm>
</div>
</article>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { gradeEssay } from '@/api/ai'
import { createEssayGrade, deleteEssayGrade, getEssayGrade, getEssayGrades } from '@/api/essay'
import MarkdownContent from '@/components/common/MarkdownContent.vue'
import { useUserStore } from '@/stores/user'
import { downloadBlob } from '@/utils/download'
const grading = ref(false)
const savedLoading = ref(false)
const result = ref<any>(null)
const savedGrades = ref<any[]>([])
const userStore = useUserStore()
const route = useRoute()
const form = reactive({ essay_text: '', grade_level: '初中', essay_type: '记叙文', total_score: 50 })
const gradingDimensions = [
{ title: '立意内容', desc: '审题、中心、材料选择' },
{ title: '结构表达', desc: '段落层次、过渡衔接' },
{ title: '语言文采', desc: '词句准确度与感染力' },
{ title: '修改建议', desc: '给出可直接训练的提升点' },
]
const essaySamples = [
{
title: '记叙文样例',
desc: '适合日常写人记事训练',
grade: '初中',
type: '记叙文',
text: '那天放学后,雨下得很急。我站在校门口,看见同桌把伞递给了一个低年级的同学,自己却把书包顶在头上往公交站跑。后来我才知道,那把伞是他妈妈特意送来的。这个小小的动作让我明白,真正的善良不是说出来的,而是在别人需要时自然地伸出手。',
},
{
title: '议论文样例',
desc: '适合观点论证与素材训练',
grade: '高中',
type: '议论文',
text: '成长需要独立思考。信息越丰富,越需要我们保持判断力。面对网络上的观点,如果只是简单转发和接受,就容易失去自己的立场。独立思考并不意味着固执己见,而是在倾听之后追问依据,在比较之后形成判断。这样的能力会帮助我们在复杂环境中做出更稳妥的选择。',
},
]
function getLevelType(level: string) {
const map: Record<string, string> = { '优秀': 'success', '良好': '', '中等': 'warning', '及格': 'info', '不及格': 'danger' }
return map[level] || 'info'
}
function getScoreLabel(key: string) {
const map: Record<string, string> = { content: '内容立意', structure: '结构条理', language: '语言表达', writing: '书写规范' }
return map[key] || key
}
async function handleGrade() {
grading.value = true
try {
const res: any = await gradeEssay(form)
userStore.refreshCreditsFromResponse(res)
result.value = res.data
await saveCurrentResult()
ElMessage.success('批改完成')
} catch { ElMessage.error('批改失败') }
finally { grading.value = false }
}
async function saveCurrentResult() {
if (!result.value) return
try {
await createEssayGrade({
title: essayTitle(),
essay_text: form.essay_text,
grade_level: form.grade_level,
essay_type: form.essay_type,
total_score: form.total_score,
result: result.value,
})
await loadSavedGrades({ silent: true })
} catch {
ElMessage.warning('批改结果已生成,但保存记录失败')
}
}
async function loadSavedGrades(options: { silent?: boolean } = {}) {
savedLoading.value = true
try {
const data: any = await getEssayGrades({ limit: 50 })
savedGrades.value = data.data || data || []
} catch (e: any) {
if (!options.silent) ElMessage.warning('未能加载批改记录')
if (e?.response?.status === 401 || e?.response?.status === 403) savedGrades.value = []
} finally {
savedLoading.value = false
}
}
function refreshSavedGrades() {
loadSavedGrades()
}
function openSaved(item: any) {
form.essay_text = item.essay_text || ''
form.grade_level = item.grade_level || '初中'
form.essay_type = item.essay_type || '记叙文'
form.total_score = item.total_score || 50
result.value = item.result || null
window.scrollTo({ top: 0, behavior: 'smooth' })
}
async function removeSaved(id: number) {
await deleteEssayGrade(id)
ElMessage.success('批改记录已删除')
await loadSavedGrades({ silent: true })
}
function applySample(sample: typeof essaySamples[0]) {
form.grade_level = sample.grade
form.essay_type = sample.type
form.essay_text = sample.text
}
function downloadReport() {
if (!result.value) return
const html = buildReportHtml({
result: result.value,
essayText: form.essay_text,
gradeLevel: form.grade_level,
essayType: form.essay_type,
totalScore: form.total_score,
})
const blob = new Blob([html], { type: 'application/msword;charset=utf-8' })
downloadBlob(blob, `${safeFilename(`作文批改报告-${result.value.level || '结果'}`)}.doc`)
ElMessage.success('已开始下载')
}
function downloadSaved(item: any) {
const html = buildReportHtml({
result: item.result || {},
essayText: item.essay_text || '',
gradeLevel: item.grade_level || '',
essayType: item.essay_type || '',
totalScore: item.total_score || 50,
})
const blob = new Blob([html], { type: 'application/msword;charset=utf-8' })
downloadBlob(blob, `${safeFilename(item.title || '作文批改报告')}.doc`)
ElMessage.success('已开始下载')
}
function buildReportHtml(data: { result: any; essayText: string; gradeLevel: string; essayType: string; totalScore: number }) {
const gradeResult = data.result || {}
const scores = gradeResult.scores || {}
const scoreRows = Object.entries(scores).map(([key, value]: any) => `
<tr>
<td>${escapeHtml(getScoreLabel(key))}</td>
<td>${escapeHtml(`${value?.score ?? ''}/${value?.max ?? ''}`)}</td>
<td>${escapeHtml(value?.comment || '')}</td>
</tr>
`).join('')
const list = (title: string, items: string[] = []) => items.length
? `<h2>${escapeHtml(title)}</h2><ul>${items.map(item => `<li>${escapeHtml(item)}</li>`).join('')}</ul>`
: ''
const annotations = (gradeResult.annotations || []).length
? `<h2>逐句批注</h2><table><tr><th>类型</th><th>原文</th><th>批注</th></tr>${gradeResult.annotations.map((item: any) => `
<tr><td>${escapeHtml(annotationTypeLabel(item.type))}</td><td>${escapeHtml(item.text || '')}</td><td>${escapeHtml(item.comment || '')}</td></tr>
`).join('')}</table>`
: ''
return `<!doctype html>
<html><head><meta charset="utf-8"><title>作文批改报告</title>
<style>
body{font-family:"Microsoft YaHei",Arial,sans-serif;color:#173f35;line-height:1.75;padding:28px;}
h1{font-size:24px;margin:0 0 12px;}h2{font-size:18px;margin:24px 0 10px;color:#00543d;}
table{width:100%;border-collapse:collapse;margin:12px 0;}th,td{border:1px solid #cfe0d3;padding:8px;text-align:left;vertical-align:top;}
.score{font-size:20px;font-weight:700;color:#00543d;}
</style></head><body>
<h1>作文批改报告</h1>
<p>学段:${escapeHtml(data.gradeLevel)} 文体:${escapeHtml(data.essayType)} 满分:${data.totalScore}</p>
<p class="score">总分:${escapeHtml(`${gradeResult.total_score ?? ''}/${data.totalScore}`)} 等级:${escapeHtml(gradeResult.level || '')}</p>
<h2>原文</h2><p>${escapeHtml(data.essayText).replace(/\n/g, '<br>')}</p>
<h2>分项得分</h2><table><tr><th>维度</th><th>得分</th><th>评语</th></tr>${scoreRows}</table>
<h2>总评</h2><p>${escapeHtml(gradeResult.overall_comment || '')}</p>
${list('优点', gradeResult.strengths)}
${list('不足', gradeResult.weaknesses)}
${list('改进建议', gradeResult.suggestions)}
${annotations}
</body></html>`
}
function essayTitle() {
const text = form.essay_text.replace(/\s+/g, '').slice(0, 18)
return `${form.grade_level}${form.essay_type}批改-${text || '未命名作文'}`
}
function annotationTypeLabel(type: string) {
const map: Record<string, string> = { error: '错误', good: '佳句', suggestion: '建议' }
return map[type] || '批注'
}
function escapeHtml(value: any) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
function safeFilename(value: string) {
return (value || '作文批改报告').replace(/[\\/:*?"<>|\r\n]+/g, '_').trim() || '作文批改报告'
}
onMounted(async () => {
const quickPrompt = localStorage.getItem('quick_create_prompt')
if (quickPrompt) {
form.essay_text = quickPrompt
localStorage.removeItem('quick_create_prompt')
}
await loadSavedGrades({ silent: true })
await openSavedFromRoute()
})
async function openSavedFromRoute() {
const openId = Number(route.query.open)
if (!Number.isFinite(openId) || openId <= 0) return
try {
const data: any = await getEssayGrade(openId)
openSaved(data.data || data)
} catch {
ElMessage.warning('未能打开批改记录')
}
}
</script>
<style scoped>
.gen-hero { display: flex; align-items: center; gap: 16px; margin-bottom: 24px; }
.gen-hero-icon { width: 52px; height: 52px; border-radius: 14px; display: flex; align-items: center; justify-content: center; }
.gen-hero h2 { font-size: 24px; font-weight: 700; color: var(--text-primary); }
.gen-hero p { font-size: 14px; color: var(--text-muted); }
.essay-workflow { max-width: 1120px; margin: 0 auto 16px; display: grid; grid-template-columns: repeat(2, minmax(220px, 1fr)); gap: 12px; }
.sample-card { text-align: left; border: 1px solid #dce8de; border-radius: 14px; background: #fff; padding: 14px 16px; cursor: pointer; }
.sample-card strong { display: block; color: #00543d; font-size: 15px; margin-bottom: 6px; }
.sample-card span { color: #6a7f77; font-size: 12px; }
.essay-layout { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }
.essay-input-panel { background: #fff; border: 1px solid var(--border-color); border-radius: var(--radius-lg); padding: 24px; display: flex; flex-direction: column; }
.panel-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.panel-header h3 { font-size: 16px; font-weight: 600; color: var(--text-primary); }
.settings-row { display: flex; gap: 8px; align-items: center; }
.essay-input :deep(.el-textarea__inner) { background: #F8FAFC; border-radius: var(--radius-sm); padding: 16px; font-size: 15px; line-height: 2; border: none; }
.grade-btn { display: flex; align-items: center; justify-content: center; gap: 8px; background: var(--gradient-cyan); border: none; color: #fff; padding: 12px; border-radius: var(--radius-sm); font-size: 15px; font-weight: 600; cursor: pointer; transition: all 0.3s; margin-top: 16px; }
.grade-btn:hover:not(:disabled) { opacity: 0.9; }
.grade-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.grading-dimensions { display: grid; grid-template-columns: repeat(2, minmax(160px, 1fr)); gap: 10px; margin-top: 14px; }
.grading-dimensions div { border: 1px solid #dce8de; border-radius: 10px; background: #f8faf8; padding: 10px 12px; }
.grading-dimensions strong { display: block; color: #00543d; font-size: 13px; margin-bottom: 4px; }
.grading-dimensions span { color: #75867f; font-size: 12px; line-height: 1.45; }
.essay-result-panel { background: #fff; border: 1px solid var(--border-color); border-radius: var(--radius-lg); padding: 24px; min-height: 500px; }
.result-content { animation: fadeIn 0.5s ease; }
.result-actions { display: flex; justify-content: flex-end; margin-bottom: 12px; }
.result-actions button { height: 34px; display: inline-flex; align-items: center; gap: 6px; border: 1px solid #cfe0d3; border-radius: 8px; background: #f7faf6; color: #00543d; padding: 0 13px; font-weight: 800; cursor: pointer; }
.result-actions button:hover { border-color: #9ed0aa; background: #eaf7e9; }
.score-banner { display: flex; align-items: center; justify-content: space-between; padding: 20px 24px; border-radius: var(--radius-md); margin-bottom: 20px; background: #EEF2FF; }
.score-banner.优秀 { background: #ECFDF5; }
.score-banner.良好 { background: #EEF2FF; }
.score-banner.中等 { background: #FFFBEB; }
.score-banner.不及格 { background: #FEF2F2; }
.score-main { display: flex; align-items: baseline; gap: 4px; }
.score-value { font-size: 36px; font-weight: 800; color: var(--primary); }
.score-total { font-size: 16px; color: var(--text-muted); }
.score-details { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 20px; }
.score-item { display: flex; flex-direction: column; align-items: center; gap: 8px; }
.score-ring { position: relative; width: 64px; height: 64px; }
.score-ring svg { width: 100%; height: 100%; transform: rotate(-90deg); }
.ring-text { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: 11px; font-weight: 600; color: var(--text-primary); }
.score-name { font-size: 12px; color: var(--text-muted); }
.comment-block { margin-bottom: 16px; }
.comment-block h4 { font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 8px; }
.comment-block h4.good { color: #10B981; }
.comment-block h4.bad { color: #EF4444; }
.comment-block h4.warn { color: #F59E0B; }
.comment-block p { font-size: 14px; color: var(--text-secondary); line-height: 1.8; }
.comment-block ul { padding-left: 20px; }
.comment-block li { font-size: 14px; color: var(--text-secondary); line-height: 1.8; }
.annotation-list { display: flex; flex-direction: column; gap: 8px; }
.annotation-item { display: flex; align-items: flex-start; gap: 8px; padding: 10px 12px; background: #F8FAFC; border-radius: var(--radius-sm); }
.annotation-item.error { background: #FEF2F2; }
.annotation-item.good { background: #ECFDF5; }
.annotation-text { font-size: 13px; color: var(--text-primary); }
.annotation-comment { font-size: 13px; color: var(--text-muted); }
.empty-result { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; min-height: 400px; }
.empty-result p { color: var(--text-muted); font-size: 14px; }
.empty-points { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; max-width: 320px; }
.empty-points span { border: 1px solid #dce8de; border-radius: 999px; background: #f8faf8; color: #51665f; padding: 5px 10px; font-size: 12px; font-weight: 700; }
.saved-section { margin-top: 24px; border: 1px solid #dce8de; border-radius: var(--radius-lg); background: #fff; padding: 22px; }
.saved-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; margin-bottom: 16px; }
.saved-header h3 { font-size: 18px; color: var(--text-primary); margin-bottom: 4px; }
.saved-header p { color: var(--text-muted); font-size: 13px; }
.refresh-btn { height: 34px; display: inline-flex; align-items: center; gap: 6px; border: 1px solid #cfe0d3; border-radius: 17px; background: #fff; color: #00543d; padding: 0 14px; font-weight: 800; cursor: pointer; }
.saved-loading,
.saved-empty { min-height: 110px; display: flex; align-items: center; justify-content: center; gap: 8px; border: 1px dashed #cfe0d3; border-radius: var(--radius-md); background: #f8faf8; color: var(--text-muted); }
.saved-grid { display: grid; grid-template-columns: repeat(3, minmax(220px, 1fr)); gap: 14px; }
.saved-card { border: 1px solid #dce8de; border-radius: 12px; background: #fff; padding: 14px; }
.saved-card > div:first-child { display: grid; gap: 6px; }
.saved-card span { width: fit-content; border-radius: 999px; background: #eaf7e9; color: #00543d; padding: 3px 8px; font-size: 12px; font-weight: 900; }
.saved-card strong { color: var(--text-primary); line-height: 1.35; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.saved-card small { color: var(--text-muted); font-size: 12px; }
.saved-card p { min-height: 56px; margin-top: 10px; color: var(--text-muted); font-size: 13px; line-height: 1.55; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.saved-actions { display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap; }
.saved-actions button { height: 30px; display: inline-flex; align-items: center; gap: 4px; border: 1px solid #cfe0d3; border-radius: 8px; background: #f7faf6; color: #00543d; padding: 0 10px; font-weight: 800; cursor: pointer; }
.saved-actions button.danger { color: #b42318; background: #fff7f7; border-color: #f0d2d2; }
.btn-loading { width: 16px; height: 16px; border: 2px solid rgba(255,255,255,0.3); border-top-color: #fff; border-radius: 50%; animation: spin 0.6s linear infinite; }
.grading-section { text-align: center; padding: 60px 20px; }
.grading-spinner { width: 36px; height: 36px; border: 3px solid #e0e0e0; border-top-color: #6366f1; border-radius: 50%; animation: spin 0.8s linear infinite; margin: 0 auto 16px; }
.grading-section p { font-size: 15px; color: var(--text-secondary, #666); margin: 4px 0; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (max-width: 980px) {
.essay-layout,
.essay-workflow,
.saved-grid { grid-template-columns: 1fr; }
}
@media (max-width: 640px) {
.panel-header { align-items: stretch; flex-direction: column; gap: 10px; }
.settings-row,
.grading-dimensions { grid-template-columns: 1fr; flex-wrap: wrap; }
}
</style>
+647
View File
@@ -0,0 +1,647 @@
<template>
<div class="page-container">
<div class="gen-hero">
<div class="gen-hero-icon" style="background: var(--gradient-red)">
<el-icon :size="28" color="#fff"><Finished /></el-icon>
</div>
<div>
<h2>智能命题</h2>
<p>按学科、年级、知识点和难度自动生成结构化试卷</p>
</div>
</div>
<div class="gen-input-section">
<div v-if="quickPromptNotice" class="quick-notice">
<el-icon><MagicStick /></el-icon>
<span>{{ quickPromptNotice }}</span>
</div>
<div class="settings-grid">
<div class="setting-item">
<label>学科</label>
<el-input v-model="form.subject" placeholder="学科" size="default" />
</div>
<div class="setting-item">
<label>年级</label>
<el-input v-model="form.grade" placeholder="年级" size="default" />
</div>
<div class="setting-item">
<label>难度</label>
<el-select v-model="form.difficulty" size="default" style="width: 100%">
<el-option label="简单" value="easy" />
<el-option label="中等" value="medium" />
<el-option label="困难" value="hard" />
</el-select>
</div>
<div class="setting-item">
<label>题目数量</label>
<el-input-number v-model="form.count" :min="1" :max="50" size="default" style="width: 100%" />
</div>
</div>
<div class="type-row">
<label>题型</label>
<div class="type-group">
<label v-for="t in questionTypes" :key="t.value" class="type-check" :class="{ checked: form.question_types.includes(t.value) }">
<input type="checkbox" :value="t.value" v-model="form.question_types" hidden />
{{ t.label }}
</label>
</div>
</div>
<div class="kp-row">
<label>知识点</label>
<el-select v-model="form.knowledge_points" multiple filterable allow-create placeholder="输入知识点后回车" size="default" style="flex: 1">
</el-select>
</div>
<div class="material-row">
<MaterialPicker v-model="selectedMaterials" />
</div>
<div class="gen-action">
<button class="gen-btn" style="background: var(--gradient-red)" :disabled="!form.subject || generating" @click="handleGenerate">
<el-icon v-if="!generating"><MagicStick /></el-icon>
<span v-if="generating" class="btn-loading"></span>
{{ generating ? 'AI生成中...' : '生成试卷' }}
</button>
</div>
</div>
<div v-if="generating" class="loading-section">
<div class="loading-bar"><div class="loading-bar-fill"></div></div>
<p>AI正在生成试卷...</p>
<p style="font-size:13px;color:var(--text-secondary);opacity:.65;margin-top:10px">模型深度推理中,通常需要 1-2 分钟,请耐心等待</p>
</div>
<div v-else-if="!result" class="guide-section">
<div class="capability-row">
<div v-for="item in capabilities" :key="item.title" class="capability-item">
<strong>{{ item.title }}</strong>
<span>{{ item.desc }}</span>
</div>
</div>
<div class="guide-cards">
<div class="guide-card" v-for="g in guideExamples" :key="g.title" @click="applyGuide(g)">
<div class="guide-icon" :style="{ background: g.bg }">
<el-icon :size="18" color="#fff"><component :is="g.icon" /></el-icon>
</div>
<div class="guide-info">
<h4>{{ g.title }}</h4>
<p>{{ g.desc }}</p>
</div>
<span class="guide-arrow">试试 →</span>
</div>
</div>
</div>
<div v-if="result" class="result-section">
<div class="result-header">
<h3>{{ result.title }}</h3>
<div class="result-actions">
<button class="save-btn ghost" type="button" @click="reuseCurrent">
<el-icon><MagicStick /></el-icon>
用它生成
</button>
<button class="save-btn ghost" type="button" :disabled="publishing" @click="publishCurrent">
<el-icon><Share /></el-icon>
{{ publishing ? '发布中' : '发布资源' }}
</button>
<button class="save-btn" type="button" @click="handleSave">
<el-icon><Check /></el-icon>
保存试卷
</button>
<button class="save-btn ghost" type="button" @click="handleExportDocx">
<el-icon><Upload /></el-icon>
导出Word
</button>
<button class="save-btn ghost" type="button" @click="printExam">
<el-icon><Printer /></el-icon>
打印PDF
</button>
</div>
</div>
<div class="exam-questions">
<div v-for="(q, idx) in result.questions" :key="idx" class="question-card">
<div class="q-header">
<span class="q-num">{{ idx + 1 }}</span>
<el-tag size="small" effect="plain">{{ q.type }}</el-tag>
<el-tag size="small" type="warning" effect="plain">{{ q.score }}分</el-tag>
</div>
<MarkdownContent :content="q.content" class="q-content" />
<div v-if="q.options" class="q-options">
<div v-for="(opt, oi) in q.options" :key="oi" class="q-option" :class="{ correct: opt === q.answer }">
<span class="opt-letter">{{ String.fromCharCode(65 + oi) }}</span>
<span>{{ opt }}</span>
</div>
</div>
<div class="q-analysis">
<div class="q-answer"><strong>答案:</strong>{{ q.answer }}</div>
<MarkdownContent :content="q.analysis" class="q-detail" />
</div>
</div>
</div>
</div>
<section class="saved-section">
<div class="saved-header">
<div>
<h3>已保存试卷</h3>
<p>保存后的智能命题结果会出现在这里,方便回看和复用</p>
</div>
<button class="refresh-btn" type="button" @click="loadSavedExams">
<el-icon><Refresh /></el-icon>
刷新
</button>
</div>
<div v-if="savedLoading" class="saved-loading">正在加载已保存试卷...</div>
<div v-else-if="savedExams.length === 0" class="saved-empty">
<el-icon><Finished /></el-icon>
<span>暂无已保存试卷</span>
</div>
<div v-else class="saved-grid">
<article v-for="item in savedExams" :key="item.id" class="saved-card">
<div class="saved-cover">
<el-icon><Finished /></el-icon>
<strong>{{ item.title }}</strong>
<span>{{ item.subject || '未设置学科' }} · {{ item.questions?.length || 0 }}题</span>
</div>
<div class="saved-body">
<h4>{{ item.title }}</h4>
<p>{{ item.grade || '未设置年级' }} · {{ difficultyLabel(item.difficulty) }} · {{ item.total_score || 100 }}分</p>
<div class="saved-actions">
<button type="button" @click="previewSaved(item)"><el-icon><View /></el-icon>预览</button>
<button type="button" @click="publishSaved(item)"><el-icon><Share /></el-icon>发布</button>
<button type="button" @click="downloadSaved(item)"><el-icon><Download /></el-icon>下载</button>
<el-popconfirm title="确定删除这个试卷?关联资源会同步下架。" @confirm="handleDeleteSaved(item.id)">
<template #reference>
<button type="button" class="danger"><el-icon><Delete /></el-icon>删除</button>
</template>
</el-popconfirm>
</div>
</div>
</article>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { exportExamDocx, generateExam } from '@/api/ai'
import { createExam, deleteExam, exportSavedExamDocx, getExam, getExams, updateExam } from '@/api/exam'
import { publishResource } from '@/api/resource'
import MarkdownContent from '@/components/common/MarkdownContent.vue'
import MaterialPicker from '@/components/common/MaterialPicker.vue'
import { downloadBlob } from '@/utils/download'
import { buildMaterialPromptBlock, type MaterialContextItem } from '@/utils/materialContext'
import { useUserStore } from '@/stores/user'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const generating = ref(false)
const result = ref<any>(null)
const savedLoading = ref(false)
const savedExams = ref<any[]>([])
const quickPromptNotice = ref('')
const savedResultId = ref<number | null>(null)
const publishing = ref(false)
const selectedMaterials = ref<MaterialContextItem[]>([])
const questionTypes = [
{ value: 'choice', label: '选择题' },
{ value: 'fill_blank', label: '填空题' },
{ value: 'true_false', label: '判断题' },
{ value: 'short_answer', label: '简答题' },
{ value: 'calculation', label: '计算题' },
]
const form = reactive({
subject: '', grade: '', difficulty: 'medium', count: 10, total_score: 100,
question_types: ['choice'] as string[], knowledge_points: [] as string[],
})
const guideExamples = [
{ title: '数学单元测试', desc: '初一数学有理数运算,10道选择题和5道计算题', icon: 'Finished', bg: 'var(--gradient-red)', subject_val: '数学', grade_val: '初一' },
{ title: '英语语法专项', desc: '高二英语定语从句专项练习,难度中等', icon: 'Document', bg: 'var(--gradient-blue)', subject_val: '英语', grade_val: '高二' },
{ title: '物理综合检测', desc: '初三物理电学综合,包含选择、填空和计算', icon: 'DataLine', bg: 'var(--gradient-green)', subject_val: '物理', grade_val: '初三' },
]
const capabilities = [
{ title: '按知识点组卷', desc: '支持输入多个知识点,自动匹配题型、难度和解析口径。' },
{ title: '答案解析同步生成', desc: '每道题附带答案与讲解,便于课堂订正和课后复盘。' },
{ title: '一键保存导出', desc: '生成结果可保存到题库,也可导出 Word 继续编辑。' },
]
function applyGuide(g: typeof guideExamples[0]) {
form.subject = g.subject_val
form.grade = g.grade_val
}
async function handleGenerate() {
if (!(await ensureLoggedIn())) return
generating.value = true
try {
const res: any = await generateExam(buildExamGeneratePayload())
userStore.refreshCreditsFromResponse(res)
result.value = res.data
savedResultId.value = null
ElMessage.success('试卷生成成功')
} catch (e: any) {
if (handleAuthError(e)) return
ElMessage.error('生成失败:' + (e.response?.data?.detail || e.message || ''))
}
finally { generating.value = false }
}
function buildExamGeneratePayload() {
const materialBlock = buildMaterialPromptBlock(selectedMaterials.value, '参考素材')
return {
...form,
knowledge_points: materialBlock
? [...form.knowledge_points, materialBlock]
: form.knowledge_points,
}
}
async function handleSave() {
if (!result.value) return
if (!(await ensureLoggedIn())) return
const payload = buildExamPayload()
const isUpdate = !!savedResultId.value
const saved: any = isUpdate
? await updateExam(savedResultId.value!, payload)
: await createExam(payload)
const item = saved.data || saved
savedResultId.value = item?.id || null
ElMessage.success(isUpdate ? '试卷已更新' : '试卷已保存')
await loadSavedExams()
}
function printExam() {
const r = result.value
if (!r) return
const w = window.open('', '_blank', 'width=800,height=600')
if (!w) { ElMessage.warning('请允许弹出窗口以打印试卷'); return }
const qHtml = (r.questions || []).map((q: any, i: number) => {
const opts = (q.options || []).map((o: any, oi: number) => '<li>' + String.fromCharCode(65 + oi) + '. ' + (o || '') + '</li>').join('')
return '<div class="q"><p><b>' + (i+1) + '. [' + (q.type||'') + ']</b>' + (q.score ? '('+q.score+'分)' : '') + '</p><p>' + (q.content||q.question||'') + '</p>' + (opts ? '<ol>' + opts + '</ol>' : '') + '</div>'
}).join('')
const ansHtml = (r.answers || []).map((a: any, i: number) => {
const q = r.questions[i]
const an = (typeof a === 'string' || typeof a === 'number') ? a : (a?.answer || '')
const ex = q?.analysis || q?.explanation || ''
return '<p>' + (i+1) + '. ' + an + (ex ? ' 解析:' + ex : '') + '</p>'
}).join('')
w.document.write('<html><head><title>' + (r.title||'试卷') + '</title><style>' +
'body{font-family:SimSun,serif;padding:40px;max-width:720px;margin:0 auto;color:#1f2937}' +
'h1{text-align:center;border-bottom:2px solid #333;padding-bottom:10px}' +
'.meta{text-align:center;color:#666;margin:8px 0 24px}' +
'.q{margin:16px 0;padding:8px 0;border-bottom:1px dashed #ddd}' +
'.q ol{padding-left:30px}h2{margin-top:30px;border-left:4px solid #4f46e5;padding-left:8px}' +
'@media print{button{display:none}}' +
'</style></head><body>' +
'<button onclick="window.print()" style="position:fixed;top:10px;right:10px;padding:8px 20px;background:#4f46e5;color:#fff;border:none;border-radius:6px;cursor:pointer">打印 / 另存为PDF</button>' +
'<h1>' + (r.title||'智能试卷') + '</h1>' +
'<p class="meta">' + [r.subject,r.grade,r.duration?(r.duration+'分钟'):null,r.total_score?('总分'+r.total_score+'分'):null].filter(Boolean).join(' / ') + '</p>' +
qHtml +
(ansHtml ? '<h2>参考答案与解析</h2>' + ansHtml : '') +
'</body></html>')
w.document.close()
}
async function handleExportDocx() {
if (!result.value) return
try {
const blob = await exportExamDocx({
title: result.value.title || '试卷',
subject: form.subject,
grade: form.grade,
questions: result.value.questions || [],
answers: (result.value.questions || []).map((q: any) => q.answer),
}) as Blob
downloadBlob(blob, `${safeFilename(result.value.title || '试卷')}.docx`)
ElMessage.success('已开始下载')
} catch (e: any) {
ElMessage.error('导出失败:' + (e.response?.data?.detail || e.message || ''))
}
}
async function downloadSaved(item: any) {
if (!(await ensureLoggedIn())) return
try {
const blob = await exportSavedExamDocx(item.id)
downloadBlob(blob, `${safeFilename(item.title || '智能试卷')}-试卷.docx`)
ElMessage.success('已开始下载')
} catch (e: any) {
ElMessage.error('下载失败:' + (e.response?.data?.detail || e.message || ''))
}
}
async function loadSavedExams() {
if (!(await ensureLoggedIn({ silent: true }))) {
savedExams.value = []
savedLoading.value = false
return
}
savedLoading.value = true
try {
const res: any = await getExams({ limit: 50 })
savedExams.value = res.data || res || []
} catch (e: any) {
if (handleAuthError(e)) {
savedExams.value = []
return
}
ElMessage.error('加载已保存试卷失败:' + (e.response?.data?.detail || e.message || ''))
} finally {
savedLoading.value = false
}
}
function previewSaved(item: any) {
result.value = {
title: item.title,
questions: item.questions || [],
}
form.subject = item.subject || ''
form.grade = item.grade || ''
form.difficulty = item.difficulty || 'medium'
form.total_score = item.total_score || 100
form.knowledge_points = item.knowledge_points || []
savedResultId.value = item.id
window.scrollTo({ top: 0, behavior: 'smooth' })
}
async function handleDeleteSaved(id: number) {
if (!(await ensureLoggedIn())) return
await deleteExam(id)
ElMessage.success('试卷已删除,关联资源已下架')
await loadSavedExams()
}
function buildExamPayload() {
return {
title: result.value?.title || `${form.subject || '通用'}试卷`,
subject: form.subject,
grade: form.grade,
questions: result.value?.questions || [],
answers: (result.value?.questions || []).map((q: any) => q.answer),
duration: 90,
total_score: form.total_score,
difficulty: form.difficulty,
knowledge_points: form.knowledge_points,
}
}
async function ensureSavedCurrent() {
if (!result.value) return null
if (!(await ensureLoggedIn())) return null
const payload = buildExamPayload()
const saved: any = savedResultId.value
? await updateExam(savedResultId.value, payload)
: await createExam(payload)
const item = saved.data || saved
savedResultId.value = item?.id || null
await loadSavedExams()
return savedResultId.value
}
async function publishCurrent() {
if (!result.value) return
publishing.value = true
try {
const id = await ensureSavedCurrent()
if (!id) return
await publishExamAsset({ id, ...buildExamPayload() })
} finally {
publishing.value = false
}
}
async function publishSaved(item: any) {
if (!(await ensureLoggedIn())) return
await publishExamAsset(item)
}
async function publishExamAsset(item: any) {
const created: any = await publishResource({
resource_type: 'exam',
title: item.title,
description: buildExamDescription(item),
content_ref: `exam:${item.id}`,
file_url: '',
tags: ['智能命题', difficultyLabel(item.difficulty), ...(item.knowledge_points || []).slice(0, 3)],
subject: item.subject || '',
grade: item.grade || '',
is_public: 1,
})
const resource = created.data || created
ElMessage.success('已发布到资源广场')
if (resource?.id) router.push(`/resources/${resource.id}`)
}
function buildExamDescription(item: any) {
const points = item.knowledge_points?.length ? `,覆盖${item.knowledge_points.join('、')}` : ''
return `${item.subject || '通用'}${item.grade ? ` · ${item.grade}` : ''}智能试卷,共${item.questions?.length || 0}题,${difficultyLabel(item.difficulty)}难度${points},含答案与解析。`
}
function reuseCurrent() {
if (!result.value) return
localStorage.setItem('quick_create_prompt', `参考试卷《${result.value.title || '智能试卷'}》,重新生成一套原创命题。要求保留知识点覆盖,优化题型结构、难度梯度和答案解析。`)
result.value = null
savedResultId.value = null
window.scrollTo({ top: 0, behavior: 'smooth' })
}
async function ensureLoggedIn(options: { silent?: boolean } = {}) {
if (!userStore.isLoggedIn()) {
if (!options.silent) ElMessage.warning('请先登录后再使用智能命题功能')
await router.push('/login')
return false
}
if (!userStore.user) {
try {
await userStore.fetchProfile()
} catch {
if (!options.silent) ElMessage.warning('登录状态失效,请重新登录')
await router.push('/login')
return false
}
}
return true
}
function isAuthError(error: any) {
return error?.response?.status === 401 ||
(error?.response?.status === 403 && error?.response?.data?.detail === 'Not authenticated')
}
function handleAuthError(error: any) {
if (!isAuthError(error)) return false
userStore.clearToken()
ElMessage.warning('登录状态失效,请重新登录')
router.push('/login')
return true
}
function difficultyLabel(difficulty: string) {
const map: Record<string, string> = {
easy: '简单',
medium: '中等',
hard: '困难',
}
return map[difficulty] || '中等'
}
function safeFilename(value: string) {
return (value || '资源').replace(/[\\/:*?"<>|\r\n]+/g, '_').trim() || '资源'
}
onMounted(async () => {
const quickPrompt = localStorage.getItem('quick_create_prompt')
if (quickPrompt) {
form.knowledge_points = [quickPrompt]
form.subject = inferSubject(quickPrompt) || '通用'
form.grade = inferGrade(quickPrompt)
if (/简单|基础/.test(quickPrompt)) form.difficulty = 'easy'
if (/困难|拔高|综合/.test(quickPrompt)) form.difficulty = 'hard'
quickPromptNotice.value = '已带入首页命题指令,可继续调整题型、数量和难度。'
localStorage.removeItem('quick_create_prompt')
}
await loadSavedExams()
await openSavedFromRoute()
})
async function openSavedFromRoute() {
const openId = Number(route.query.open)
if (!Number.isFinite(openId) || openId <= 0) return
try {
const data: any = await getExam(openId)
const item = data.data || data
previewSaved(item)
} catch {
ElMessage.warning('未能打开改编后的试卷')
}
}
function inferSubject(text: string) {
const subjects = ['语文', '数学', '英语', '物理', '化学', '生物', '历史', '地理', '政治', '信息科技']
return subjects.find(item => text.includes(item)) || ''
}
function inferGrade(text: string) {
const grades = ['一年级', '二年级', '三年级', '四年级', '五年级', '六年级', '初一', '初二', '初三', '高一', '高二', '高三']
return grades.find(item => text.includes(item)) || ''
}
</script>
<style scoped>
.gen-hero { display: flex; align-items: center; gap: 16px; margin-bottom: 24px; }
.gen-hero-icon { width: 52px; height: 52px; border-radius: 14px; display: flex; align-items: center; justify-content: center; }
.gen-hero h2 { font-size: 24px; font-weight: 700; color: var(--text-primary); }
.gen-hero p { font-size: 14px; color: var(--text-muted); }
.gen-input-section { background: #fff; border: 1px solid var(--border-color); border-radius: var(--radius-lg); padding: 24px; margin-bottom: 24px; }
.quick-notice { min-height: 38px; display: flex; align-items: center; gap: 8px; border: 1px solid #dce8de; border-radius: 12px; background: #f0fbef; color: #00543d; padding: 8px 12px; margin-bottom: 14px; font-size: 13px; font-weight: 800; }
.settings-grid { display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap: 16px; margin-bottom: 16px; }
.setting-item { display: flex; flex-direction: column; gap: 6px; }
.setting-item label { font-size: 13px; font-weight: 500; color: var(--text-secondary); }
.type-row { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
.type-row > label { font-size: 13px; font-weight: 500; color: var(--text-secondary); min-width: 40px; }
.type-group { display: flex; gap: 8px; }
.type-check { padding: 6px 14px; border: 1px solid var(--border-color); border-radius: 20px; font-size: 13px; color: var(--text-secondary); cursor: pointer; transition: all 0.2s; }
.type-check.checked { background: #FEF2F2; border-color: #EF4444; color: #EF4444; }
.type-check:hover { border-color: #EF4444; }
.kp-row { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
.kp-row > label { font-size: 13px; font-weight: 500; color: var(--text-secondary); min-width: 50px; }
.material-row { display: flex; flex-wrap: wrap; gap: 8px; padding: 14px 0; border-top: 1px solid var(--border-color); }
.gen-action { display: flex; justify-content: flex-end; padding-top: 16px; border-top: 1px solid var(--border-color); }
.gen-btn { display: flex; align-items: center; gap: 8px; border: none; color: #fff; padding: 10px 28px; border-radius: var(--radius-sm); font-size: 15px; font-weight: 600; cursor: pointer; transition: all 0.3s; }
.gen-btn:hover:not(:disabled) { opacity: 0.9; transform: translateY(-1px); }
.gen-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.loading-section { text-align: center; padding: 48px 0; }
.loading-bar { width: 300px; height: 4px; background: #E2E8F0; border-radius: 2px; margin: 0 auto 20px; overflow: hidden; }
.loading-bar-fill { height: 100%; width: 40%; background: var(--gradient-red); border-radius: 2px; animation: loadingSlide 1.5s ease-in-out infinite; }
@keyframes loadingSlide { 0% { transform: translateX(-100%); } 100% { transform: translateX(350%); } }
.loading-section p { font-size: 14px; color: var(--text-secondary); }
.guide-section { animation: fadeIn 0.4s ease; }
.capability-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 14px; }
.capability-item { min-height: 78px; border: 1px solid #f0d2d2; border-radius: 12px; background: #fff; padding: 14px 16px; }
.capability-item strong { display: block; color: #991b1b; font-size: 15px; margin-bottom: 6px; }
.capability-item span { color: #6a7f77; font-size: 12px; line-height: 1.45; }
.guide-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
.guide-card { display: flex; flex-direction: column; gap: 10px; background: #fff; border: 1px solid var(--border-color); border-radius: var(--radius-md); padding: 18px; cursor: pointer; transition: all 0.3s; }
.guide-card:hover { box-shadow: var(--shadow-md); border-color: #EF4444; transform: translateY(-2px); }
.guide-icon { width: 36px; height: 36px; border-radius: 10px; display: flex; align-items: center; justify-content: center; }
.guide-info h4 { font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 4px; }
.guide-info p { font-size: 12px; color: var(--text-muted); line-height: 1.5; }
.guide-arrow { font-size: 12px; color: #EF4444; font-weight: 500; margin-top: auto; }
.result-section { animation: fadeInUp 0.5s ease; }
.result-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 20px; }
.result-header h3 { font-size: 20px; font-weight: 600; color: var(--text-primary); }
.result-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
.save-btn { display: inline-flex; align-items: center; gap: 6px; border: none; border-radius: var(--radius-sm); background: var(--gradient-red); color: #fff; padding: 8px 20px; font-size: 14px; font-weight: 600; cursor: pointer; }
.save-btn.ghost { background: #fff7f7; color: #b91c1c; border: 1px solid #f0d2d2; }
.save-btn:hover { opacity: .9; }
.save-btn:disabled { opacity: .58; cursor: not-allowed; }
.exam-questions { display: flex; flex-direction: column; gap: 20px; }
.question-card { background: #fff; border: 1px solid var(--border-color); border-radius: var(--radius-md); padding: 24px; transition: all 0.3s; }
.question-card:hover { box-shadow: var(--shadow-md); }
.q-header { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
.q-num { width: 28px; height: 28px; background: var(--gradient-red); border-radius: 8px; display: flex; align-items: center; justify-content: center; color: #fff; font-size: 13px; font-weight: 700; }
.q-content { font-size: 15px; line-height: 1.7; color: var(--text-primary); margin-bottom: 14px; }
.q-options { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 14px; }
.q-option { display: flex; align-items: center; gap: 8px; padding: 10px 14px; border: 1px solid var(--border-color); border-radius: var(--radius-sm); font-size: 14px; transition: all 0.2s; }
.q-option.correct { border-color: #10B981; background: #ECFDF5; }
.opt-letter { width: 22px; height: 22px; background: #F1F5F9; border-radius: 6px; display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 600; color: var(--text-secondary); flex-shrink: 0; }
.q-option.correct .opt-letter { background: #10B981; color: #fff; }
.q-analysis { padding-top: 14px; border-top: 1px dashed var(--border-color); }
.q-answer { font-size: 14px; color: var(--text-primary); margin-bottom: 6px; }
.q-detail { font-size: 13px; color: var(--text-muted); line-height: 1.6; }
.btn-loading { width: 16px; height: 16px; border: 2px solid rgba(255,255,255,0.3); border-top-color: #fff; border-radius: 50%; animation: spin 0.6s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.saved-section { margin-top: 28px; background: #fff; border: 1px solid var(--border-color); border-radius: var(--radius-lg); padding: 22px; }
.saved-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; margin-bottom: 18px; }
.saved-header h3 { font-size: 18px; font-weight: 700; color: var(--text-primary); margin-bottom: 4px; }
.saved-header p { font-size: 13px; color: var(--text-muted); }
.refresh-btn { height: 34px; display: inline-flex; align-items: center; gap: 6px; border: 1px solid #f0d2d2; border-radius: 17px; background: #fff; color: #b91c1c; padding: 0 14px; font-weight: 700; cursor: pointer; }
.saved-loading,
.saved-empty { min-height: 120px; display: flex; align-items: center; justify-content: center; gap: 8px; color: var(--text-muted); border: 1px dashed #f0d2d2; border-radius: var(--radius-md); background: #fff7f7; }
.saved-empty .el-icon { font-size: 24px; color: #ef4444; }
.saved-grid { display: grid; grid-template-columns: repeat(3, minmax(220px, 1fr)); gap: 16px; }
.saved-card { overflow: hidden; border: 1px solid #f0d2d2; border-radius: 12px; background: #fff; }
.saved-cover { height: 132px; display: flex; flex-direction: column; justify-content: center; gap: 8px; margin: 5px; padding: 18px; border-radius: 9px; color: #fff; background: linear-gradient(135deg, #991b1b, #ef4444); }
.saved-cover .el-icon { font-size: 22px; opacity: .85; }
.saved-cover strong { font-size: 18px; line-height: 1.25; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.saved-cover span { width: fit-content; padding: 3px 8px; border-radius: 12px; background: rgba(255,255,255,.2); font-size: 12px; font-weight: 800; }
.saved-body { padding: 12px 16px 16px; }
.saved-body h4 { font-size: 15px; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-bottom: 6px; }
.saved-body p { min-height: 36px; color: var(--text-muted); font-size: 12px; line-height: 1.5; }
.saved-actions { display: flex; gap: 8px; margin-top: 12px; }
.saved-actions button { height: 32px; display: inline-flex; align-items: center; justify-content: center; gap: 5px; border: 1px solid #f0d2d2; border-radius: 8px; background: #fff7f7; color: #b91c1c; padding: 0 12px; cursor: pointer; font-weight: 700; }
.saved-actions .danger:hover { border-color: #fca5a5; background: #fef2f2; color: #ef4444; }
@media (max-width: 1080px) {
.settings-grid,
.saved-grid { grid-template-columns: repeat(2, minmax(220px, 1fr)); }
}
@media (max-width: 760px) {
.settings-grid,
.capability-row,
.guide-cards,
.saved-grid,
.q-options { grid-template-columns: 1fr; }
.type-row,
.kp-row,
.result-header,
.saved-header,
.result-actions { flex-direction: column; align-items: stretch; }
.type-group { flex-wrap: wrap; }
}
</style>
+873
View File
@@ -0,0 +1,873 @@
<template>
<div class="page-container">
<div class="gen-hero">
<div class="gen-hero-icon" style="background: var(--gradient-orange)">
<el-icon :size="28" color="#fff"><Trophy /></el-icon>
</div>
<div>
<h2>教学游戏</h2>
<p>AI生成闯关、选择、填空等互动练习,学生可以直接在页面作答</p>
</div>
</div>
<div class="gen-input-section">
<div v-if="quickPromptNotice" class="quick-notice">
<el-icon><MagicStick /></el-icon>
<span>{{ quickPromptNotice }}</span>
</div>
<div class="prompt-box">
<el-input
v-model="form.prompt"
type="textarea"
:rows="4"
placeholder="描述你需要的练习,例如:生成10道初中数学一元二次方程的选择题..."
resize="none"
/>
<div class="prompt-footer">
<div class="prompt-tags">
<span class="prompt-tag" v-for="t in suggestions" :key="t" @click="form.prompt = t">{{ t }}</span>
</div>
<button class="tpl-btn" type="button" @click="showTemplateGallery = true">
<el-icon><Files /></el-icon>
<span>用模板</span>
</button>
<button class="gen-btn" style="background: var(--gradient-orange)" :disabled="!form.prompt || generating" @click="handleGenerate">
<el-icon v-if="!generating"><MagicStick /></el-icon>
<span v-if="generating" class="btn-loading"></span>
{{ generating ? 'AI生成中...' : '生成游戏' }}
</button>
</div>
</div>
<div class="material-row">
<MaterialPicker v-model="selectedMaterials" />
</div>
<div class="settings-row">
<div class="setting-item">
<label>练习类型</label>
<el-select v-model="form.exercise_type" size="default">
<el-option label="选择题" value="choice" />
<el-option label="填空题" value="fill_blank" />
<el-option label="判断题" value="true_false" />
<el-option label="匹配题" value="matching" />
<el-option label="排序拖拽" value="drag_sort" />
<el-option label="贪吃蛇闯关" value="game_snake" />
<el-option label="翻牌配对" value="game_match" />
<el-option label="冒险闯关" value="game_adventure" />
</el-select>
</div>
<div class="setting-item">
<label>学科</label>
<el-input v-model="form.subject" placeholder="学科" size="default" style="width: 120px" />
</div>
<div class="setting-item">
<label>题数</label>
<el-input-number v-model="form.count" :min="1" :max="50" size="default" />
</div>
</div>
</div>
<div v-if="generating" class="loading-section">
<div class="loading-bar"><div class="loading-bar-fill"></div></div>
<p>AI正在生成练习题...</p>
<p style="font-size:13px;color:var(--text-secondary);opacity:.65;margin-top:10px">模型深度推理中,通常需要 1-2 分钟,请耐心等待</p>
</div>
<div v-else-if="!result" class="guide-section">
<div class="capability-row">
<div v-for="item in capabilities" :key="item.title">
<strong>{{ item.title }}</strong>
<span>{{ item.desc }}</span>
</div>
</div>
<div class="guide-cards">
<div class="guide-card" v-for="g in guideExamples" :key="g.title" @click="form.prompt = g.prompt">
<div class="guide-icon" :style="{ background: g.bg }">
<el-icon :size="18" color="#fff"><component :is="g.icon" /></el-icon>
</div>
<div class="guide-info">
<h4>{{ g.title }}</h4>
<p>{{ g.prompt }}</p>
</div>
<span class="guide-arrow">试试 →</span>
</div>
</div>
</div>
<div v-if="result" class="result-section">
<div class="result-header">
<div>
<h3>{{ result.title }}</h3>
<span class="result-meta">共 {{ result.questions?.length || 0 }} 题 · {{ typeLabel(form.exercise_type) }}</span>
</div>
<div class="header-actions">
<button class="mode-btn" :class="{ active: viewMode === 'interactive' }" @click="viewMode = 'interactive'">
<el-icon><Monitor /></el-icon> 互动
</button>
<button class="mode-btn" :class="{ active: viewMode === 'detail' }" @click="viewMode = 'detail'">
<el-icon><List /></el-icon> 详情
</button>
<button class="mode-btn" @click="reuseCurrent">
<el-icon><MagicStick /></el-icon> 用它生成
</button>
<button class="mode-btn publish" :disabled="publishing" @click="publishCurrent">
<el-icon><Share /></el-icon> {{ publishing ? '发布中' : '发布资源' }}
</button>
<button class="save-btn" style="background: var(--gradient-orange)" @click="handleSave"><el-icon><Check /></el-icon> 保存练习</button>
<button class="mode-btn" @click="downloadCurrent"><el-icon><Download /></el-icon> 下载Word</button>
</div>
</div>
<!-- Interactive Mode: answer questions and get feedback -->
<div v-if="viewMode === 'interactive' && result.questions?.length" class="interactive-mode">
<!-- Score bar -->
<div class="quiz-score-bar">
<span>第 {{ currentQIdx + 1 }} / {{ result.questions.length }} 题</span>
<span v-if="answeredCount > 0">已答 {{ answeredCount }} · 正确 {{ correctCount }} · 正确率 {{ scorePercent }}%</span>
<span v-if="attemptSummary" class="attempt-summary">已有 {{ attemptSummary.count }} 人完成 · 平均正确率 {{ attemptSummary.avgScore }}%</span>
<button v-if="answeredCount > 0" type="button" class="reset-btn" @click="resetQuiz">重新作答</button>
</div>
<!-- Question -->
<div class="quiz-question-card">
<div class="quiz-q-header">
<span class="q-num">{{ currentQIdx + 1 }}</span>
<el-tag size="small" effect="plain">{{ currentQuestion.type || form.exercise_type }}</el-tag>
</div>
<MarkdownContent :content="currentQuestion.question" class="q-text" />
<div v-if="currentQuestion.html" class="quiz-html-preview">
<HtmlPreview :html="currentQuestion.html" height="280px" />
</div>
<!-- Choice / True-False: option buttons -->
<div v-if="currentQuestion.options?.length" class="quiz-options">
<button
v-for="(opt, oi) in currentQuestion.options"
:key="oi"
type="button"
class="quiz-option"
:class="optionClass(oi)"
:disabled="isRevealed(currentQIdx)"
@click="selectOption(opt)"
>
<span class="opt-letter">{{ String.fromCharCode(65 + oi) }}</span>
<span>{{ opt }}</span>
<el-icon v-if="isRevealed(currentQIdx) && opt === currentQuestion.answer" class="opt-correct"><CircleCheckFilled /></el-icon>
<el-icon v-else-if="isRevealed(currentQIdx) && opt === getUserAnswer(currentQIdx) && opt !== currentQuestion.answer" class="opt-wrong"><CircleCloseFilled /></el-icon>
</button>
</div>
<!-- Fill-blank: text input -->
<div v-else class="quiz-fill-input">
<input
v-model="fillAnswer"
type="text"
class="fill-input"
placeholder="输入你的答案..."
:disabled="isRevealed(currentQIdx)"
@keyup.enter="submitFillAnswer"
/>
<button v-if="!isRevealed(currentQIdx)" type="button" class="submit-answer-btn" @click="submitFillAnswer">提交答案</button>
</div>
<!-- Feedback after answering -->
<div v-if="isRevealed(currentQIdx)" class="quiz-feedback" :class="{ correct: isCurrentCorrect, wrong: !isCurrentCorrect }">
<div class="feedback-head">
<el-icon v-if="isCurrentCorrect" :size="20"><CircleCheckFilled /></el-icon>
<el-icon v-else :size="20"><CircleCloseFilled /></el-icon>
<strong>{{ isCurrentCorrect ? '回答正确!' : '回答错误' }}</strong>
<span v-if="!isCurrentCorrect">正确答案:{{ currentQuestion.answer }}</span>
</div>
<div v-if="currentQuestion.explanation" class="feedback-explain">
<MarkdownContent :content="currentQuestion.explanation" />
</div>
</div>
</div>
<!-- Navigation -->
<div class="interactive-nav">
<button class="nav-btn" :disabled="currentQIdx <= 0" @click="currentQIdx--">
<el-icon><ArrowLeft /></el-icon>
</button>
<div class="nav-dots">
<span
v-for="(q, idx) in result.questions"
:key="idx"
class="dot"
:class="{
active: currentQIdx === idx,
correct: isRevealed(idx) && isCorrect(idx),
wrong: isRevealed(idx) && !isCorrect(idx),
}"
@click="currentQIdx = idx"
>{{ idx + 1 }}</span>
</div>
<button class="nav-btn" :disabled="currentQIdx >= result.questions.length - 1" @click="currentQIdx++">
<el-icon><ArrowRight /></el-icon>
</button>
</div>
</div>
<!-- Detail Mode: show all questions -->
<div v-if="viewMode === 'detail'" class="detail-mode">
<div v-for="(q, idx) in result.questions" :key="idx" class="question-card">
<div class="q-header">
<span class="q-num">{{ idx + 1 }}</span>
<el-tag size="small" effect="plain">{{ q.type }}</el-tag>
<el-tag v-if="q.difficulty" size="small" effect="plain" :type="q.difficulty === 'easy' ? 'success' : q.difficulty === 'hard' ? 'danger' : 'warning'">
{{ q.difficulty === 'easy' ? '简单' : q.difficulty === 'hard' ? '困难' : '中等' }}
</el-tag>
</div>
<MarkdownContent :content="q.question" class="q-text" />
<div v-if="q.html" class="q-preview">
<HtmlPreview :html="q.html" height="300px" />
</div>
<div v-if="q.options" class="q-options">
<div v-for="(opt, oi) in q.options" :key="oi" class="q-option" :class="{ correct: opt === q.answer }">
<span class="opt-letter">{{ String.fromCharCode(65 + oi) }}</span>
<span>{{ opt }}</span>
</div>
</div>
<div class="q-answer">
<el-icon :size="14" color="#10B981"><Check /></el-icon>
<span>答案:{{ q.answer }}</span>
</div>
<div v-if="q.explanation" class="q-explanation"><MarkdownContent :content="q.explanation" /></div>
</div>
</div>
</div>
<section class="saved-section">
<div class="saved-header">
<div>
<h3>已保存练习</h3>
<p>保存后的课堂练习会出现在这里,方便二次打开和管理</p>
</div>
<button class="refresh-btn" type="button" @click="loadSavedExercises">
<el-icon><Refresh /></el-icon>
刷新
</button>
</div>
<div v-if="savedLoading" class="saved-loading">正在加载已保存练习...</div>
<div v-else-if="savedExercises.length === 0" class="saved-empty">
<el-icon><Trophy /></el-icon>
<span>暂无已保存练习</span>
</div>
<div v-else class="saved-grid">
<article v-for="item in savedExercises" :key="item.id" class="saved-card">
<div class="saved-cover">
<el-icon><Trophy /></el-icon>
<strong>{{ item.title }}</strong>
<span>{{ typeLabel(item.exercise_type) }} · {{ item.questions?.length || 0 }}题</span>
</div>
<div class="saved-body">
<h4>{{ item.title }}</h4>
<p>{{ item.subject || '未设置学科' }} · {{ item.knowledge_points?.join('、') || '未设置知识点' }}</p>
<div class="saved-actions">
<button type="button" @click="previewSaved(item)"><el-icon><View /></el-icon>预览</button>
<button type="button" @click="publishSaved(item)"><el-icon><Share /></el-icon>发布</button>
<button type="button" @click="downloadSaved(item)"><el-icon><Download /></el-icon>下载</button>
<el-popconfirm title="确定删除这个练习?关联资源会同步下架。" @confirm="handleDeleteSaved(item.id)">
<template #reference>
<button type="button" class="danger"><el-icon><Delete /></el-icon>删除</button>
</template>
</el-popconfirm>
</div>
</div>
</article>
</div>
<TemplateGallery v-model="showTemplateGallery" kind="game" @use="handleUseTemplate" />
</section>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { aiGenerateExercise, createExercise, deleteExercise, exportExerciseDocx, getExercise, getExercises, getExerciseAttempts, submitExerciseAttempt, updateExercise } from '@/api/exercise'
import { publishResource } from '@/api/resource'
import MarkdownContent from '@/components/common/MarkdownContent.vue'
import HtmlPreview from '@/components/common/HtmlPreview.vue'
import TemplateGallery from '@/components/common/TemplateGallery.vue'
import MaterialPicker from '@/components/common/MaterialPicker.vue'
import { useUserStore } from '@/stores/user'
import { downloadBlob } from '@/utils/download'
import { buildMaterialPromptBlock, type MaterialContextItem } from '@/utils/materialContext'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const generating = ref(false)
const result = ref<any>(null)
const savedLoading = ref(false)
const savedExercises = ref<any[]>([])
const quickPromptNotice = ref('')
const viewMode = ref<'interactive' | 'detail'>('interactive')
const currentQIdx = ref(0)
const userAnswers = reactive<Record<number, string>>({})
const revealedSet = reactive<Set<number>>(new Set())
const fillAnswer = ref('')
const attemptRecorded = ref(false)
const attemptSummary = ref<{ count: number; avgScore: number } | null>(null)
const answeredCount = computed(() => Object.keys(userAnswers).length)
const correctCount = computed(() => [...revealedSet].filter(idx => isCorrect(idx)).length)
const scorePercent = computed(() => answeredCount.value > 0 ? Math.round((correctCount.value / answeredCount.value) * 100) : 0)
const isCurrentCorrect = computed(() => isCorrect(currentQIdx.value))
watch(currentQIdx, () => {
fillAnswer.value = isRevealed(currentQIdx.value) ? getUserAnswer(currentQIdx.value) : ''
})
watch(answeredCount, (n) => {
const total = result.value?.questions?.length || 0
if (total > 0 && n >= total) recordAttempt()
})
const savedResultId = ref<number | null>(null)
const publishing = ref(false)
const selectedMaterials = ref<MaterialContextItem[]>([])
const showTemplateGallery = ref(false)
const suggestions = ['一元二次方程选择题', '英语语法填空题', '历史知识判断题']
const guideExamples = [
{ title: '数学选择题', prompt: '生成10道初中数学一元二次方程的选择题,难度中等,包含详细解析', icon: 'Trophy', bg: 'var(--gradient-orange)' },
{ title: '英语语法填空', prompt: '生成8道英语语法填空题,考查时态和语态的用法', icon: 'MagicStick', bg: 'var(--gradient-green)' },
{ title: '历史知识判断', prompt: '生成15道中国古代史判断题,涵盖秦汉至明清', icon: 'Flag', bg: 'var(--gradient-blue)' },
]
const capabilities = [
{ title: '即时作答', desc: '生成可直接在页面完成的互动题目' },
{ title: '答案解析', desc: '每道题保留答案、难度和解析说明' },
{ title: '课堂复用', desc: '保存后可按题型和知识点再次打开' },
]
const form = reactive({ prompt: '', exercise_type: 'choice', subject: '', count: 5, knowledge_points: [] as string[] })
const currentQuestion = computed(() => {
if (!result.value?.questions?.length) return { answer: '', explanation: '', html: '' }
return result.value.questions[currentQIdx.value] || { answer: '', explanation: '', html: '' }
})
function getUserAnswer(idx: number): string {
return userAnswers[idx] || ''
}
function isRevealed(idx: number): boolean {
return revealedSet.has(idx)
}
function isCorrect(idx: number): boolean {
if (!isRevealed(idx)) return false
const q = result.value?.questions?.[idx]
if (!q) return false
const ua = String(userAnswers[idx] || '').trim().toLowerCase()
const ca = String(q.answer || '').trim().toLowerCase()
return ua === ca
}
function selectOption(opt: string) {
if (isRevealed(currentQIdx.value)) return
userAnswers[currentQIdx.value] = opt
revealedSet.add(currentQIdx.value)
}
function submitFillAnswer() {
if (isRevealed(currentQIdx.value)) return
const ans = fillAnswer.value.trim()
if (!ans) return
userAnswers[currentQIdx.value] = ans
revealedSet.add(currentQIdx.value)
}
function optionClass(oi: number): string {
if (!isRevealed(currentQIdx.value)) return ''
const opt = currentQuestion.value.options?.[oi] || ''
if (opt === currentQuestion.value.answer) return 'is-correct'
if (opt === getUserAnswer(currentQIdx.value) && opt !== currentQuestion.value.answer) return 'is-wrong'
return 'is-dimmed'
}
function resetQuiz() {
recordAttempt()
Object.keys(userAnswers).forEach(k => delete userAnswers[Number(k)])
revealedSet.clear()
fillAnswer.value = ''
currentQIdx.value = 0
attemptRecorded.value = false
}
async function recordAttempt() {
if (attemptRecorded.value || answeredCount.value === 0 || !savedResultId.value) return
const total = result.value?.questions?.length || 0
attemptRecorded.value = true
try {
await submitExerciseAttempt(savedResultId.value, {
answers: Object.entries(userAnswers).map(([idx, ans]) => ({ q: Number(idx) + 1, a: ans })),
score: correctCount.value,
total: total || answeredCount.value,
})
} catch {
attemptRecorded.value = false
}
}
function handleUseTemplate(payload: { kind: string; template: any; params: Record<string, any>; html: string; title: string }) {
result.value = {
title: payload.title,
questions: [{
question: payload.template.name || '教学游戏',
html: payload.html,
answer: '',
explanation: payload.template.description || '',
options: [],
difficulty: '简单',
}],
}
savedResultId.value = null
resetQuiz()
const catMap: Record<string, string> = {
game_snake: 'game_snake', game_match: 'game_match', game_adventure: 'game_adventure',
drag_sort: 'drag_sort', matching: 'matching', memory: 'game_match',
}
if (payload.template.category && catMap[payload.template.category]) {
form.exercise_type = catMap[payload.template.category]
}
ElMessage.success('已应用模板,可直接预览或保存')
}
async function handleGenerate() {
if (!(await ensureLoggedIn())) return
generating.value = true
currentQIdx.value = 0
resetQuiz()
try {
const res: any = await aiGenerateExercise({
...form,
prompt: buildPromptWithMaterials(form.prompt),
})
userStore.refreshCreditsFromResponse(res)
result.value = res.data
savedResultId.value = null
ElMessage.success('练习生成成功')
} catch (e: any) {
if (handleAuthError(e)) return
ElMessage.error('生成失败:' + (e.response?.data?.detail || e.message || ''))
}
finally { generating.value = false }
}
function buildPromptWithMaterials(prompt: string) {
return `${prompt}${buildMaterialPromptBlock(selectedMaterials.value, '参考素材')}`.trim()
}
async function handleSave() {
if (!result.value) return
if (!(await ensureLoggedIn())) return
const payload = {
title: result.value.title,
exercise_type: form.exercise_type,
subject: form.subject,
knowledge_points: form.knowledge_points,
questions: result.value.questions,
}
const isUpdate = !!savedResultId.value
const saved: any = isUpdate
? await updateExercise(savedResultId.value!, payload)
: await createExercise(payload)
const item = saved.data || saved
savedResultId.value = item?.id || null
ElMessage.success(isUpdate ? '练习已更新' : '练习已保存')
await loadSavedExercises()
}
async function downloadCurrent() {
const id = await ensureSavedCurrent()
if (id) await downloadSaved({ id, title: result.value?.title || '课堂练习' })
}
async function downloadSaved(item: any) {
if (!(await ensureLoggedIn())) return
try {
const blob = await exportExerciseDocx(item.id)
downloadBlob(blob, `${safeFilename(item.title || '课堂练习')}-练习.docx`)
ElMessage.success('已开始下载')
} catch (e: any) {
ElMessage.error('下载失败:' + (e.response?.data?.detail || e.message || ''))
}
}
async function loadSavedExercises() {
if (!(await ensureLoggedIn({ silent: true }))) {
savedExercises.value = []
savedLoading.value = false
return
}
savedLoading.value = true
try {
const res: any = await getExercises({ limit: 50 })
savedExercises.value = res.data || res || []
} catch (e: any) {
if (handleAuthError(e)) {
savedExercises.value = []
return
}
ElMessage.error('加载已保存练习失败:' + (e.response?.data?.detail || e.message || ''))
} finally {
savedLoading.value = false
}
}
async function previewSaved(item: any) {
result.value = {
title: item.title,
questions: item.questions || [],
}
savedResultId.value = item.id
form.exercise_type = item.exercise_type || 'choice'
form.subject = item.subject || ''
form.knowledge_points = item.knowledge_points || []
currentQIdx.value = 0
viewMode.value = 'interactive'
attemptSummary.value = null
attemptRecorded.value = false
window.scrollTo({ top: 0, behavior: 'smooth' })
try {
const attempts: any = await getExerciseAttempts(item.id)
if (Array.isArray(attempts) && attempts.length > 0) {
const avg = Math.round(attempts.reduce((s: number, a: any) => s + (a.total > 0 ? (a.score / a.total) * 100 : 0), 0) / attempts.length)
attemptSummary.value = { count: attempts.length, avgScore: avg }
}
} catch { /* ignore */ }
}
async function handleDeleteSaved(id: number) {
if (!(await ensureLoggedIn())) return
await deleteExercise(id)
ElMessage.success('练习已删除,关联资源已下架')
await loadSavedExercises()
}
async function ensureSavedCurrent() {
if (!result.value) return null
if (!(await ensureLoggedIn())) return null
const payload = {
title: result.value.title || '课堂练习',
exercise_type: form.exercise_type,
subject: form.subject,
knowledge_points: form.knowledge_points,
questions: result.value.questions || [],
}
const saved: any = savedResultId.value
? await updateExercise(savedResultId.value, payload)
: await createExercise(payload)
const item = saved.data || saved
savedResultId.value = item?.id || null
await loadSavedExercises()
return savedResultId.value
}
async function publishCurrent() {
if (!result.value) return
publishing.value = true
try {
const id = await ensureSavedCurrent()
if (!id) return
await publishExerciseAsset({
id,
title: result.value.title || '课堂练习',
exercise_type: form.exercise_type,
subject: form.subject,
knowledge_points: form.knowledge_points,
questions: result.value.questions || [],
})
} finally {
publishing.value = false
}
}
async function publishSaved(item: any) {
if (!(await ensureLoggedIn())) return
await publishExerciseAsset(item)
}
async function publishExerciseAsset(item: any) {
const created: any = await publishResource({
resource_type: 'exercise',
title: item.title,
description: buildExerciseDescription(item),
content_ref: `exercise:${item.id}`,
file_url: '',
tags: ['教学游戏', typeLabel(item.exercise_type), ...(item.knowledge_points || []).slice(0, 3)],
subject: item.subject || '',
grade: '',
is_public: 1,
})
const resource = created.data || created
ElMessage.success('已发布到资源广场')
if (resource?.id) router.push(`/resources/${resource.id}`)
}
function buildExerciseDescription(item: any) {
const points = item.knowledge_points?.length ? `,覆盖${item.knowledge_points.join('、')}` : ''
return `${item.subject || '通用'}${typeLabel(item.exercise_type)}课堂练习,共${item.questions?.length || 0}题${points},支持互动预览、答案解析和二次改编。`
}
function reuseCurrent() {
if (!result.value) return
localStorage.setItem('quick_create_prompt', `参考练习《${result.value.title || '课堂练习'}》,重新生成一套原创教学游戏。要求保留核心知识点,优化题目梯度、即时反馈和解析。`)
result.value = null
savedResultId.value = null
window.scrollTo({ top: 0, behavior: 'smooth' })
}
async function ensureLoggedIn(options: { silent?: boolean } = {}) {
if (!userStore.isLoggedIn()) {
if (!options.silent) ElMessage.warning('请先登录后再使用教学游戏功能')
await router.push('/login')
return false
}
if (!userStore.user) {
try {
await userStore.fetchProfile()
} catch {
if (!options.silent) ElMessage.warning('登录状态失效,请重新登录')
await router.push('/login')
return false
}
}
return true
}
function isAuthError(error: any) {
return error?.response?.status === 401 ||
(error?.response?.status === 403 && error?.response?.data?.detail === 'Not authenticated')
}
function handleAuthError(error: any) {
if (!isAuthError(error)) return false
userStore.clearToken()
ElMessage.warning('登录状态失效,请重新登录')
router.push('/login')
return true
}
function typeLabel(type: string) {
const map: Record<string, string> = {
choice: '选择题',
fill_blank: '填空题',
true_false: '判断题',
matching: '匹配题',
drag_sort: '排序拖拽',
game_snake: '贪吃蛇闯关',
game_match: '翻牌配对',
game_adventure: '冒险闯关',
}
return map[type] || '练习'
}
function safeFilename(value: string) {
return (value || '资源').replace(/[\\/:*?"<>|\r\n]+/g, '_').trim() || '资源'
}
onMounted(async () => {
const quickPrompt = localStorage.getItem('quick_create_prompt')
if (quickPrompt) {
form.prompt = quickPrompt
form.subject = inferSubject(quickPrompt)
form.exercise_type = inferExerciseType(quickPrompt)
form.count = inferCount(quickPrompt)
quickPromptNotice.value = '已带入首页游戏/练习指令,可直接生成或继续调整题型。'
localStorage.removeItem('quick_create_prompt')
}
await loadSavedExercises()
await openSavedFromRoute()
})
async function openSavedFromRoute() {
const openId = Number(route.query.open)
if (!Number.isFinite(openId) || openId <= 0) return
try {
const data: any = await getExercise(openId)
const item = data.data || data
previewSaved(item)
} catch {
ElMessage.warning('未能打开改编后的练习')
}
}
function inferSubject(text: string) {
const subjects = ['数学', '英语', '语文', '物理', '化学', '历史', '信息科技']
return subjects.find(item => text.includes(item)) || ''
}
function inferExerciseType(text: string) {
if (/填空/.test(text)) return 'fill_blank'
if (/判断|对错/.test(text)) return 'true_false'
if (/匹配|连线/.test(text)) return 'matching'
return 'choice'
}
function inferCount(text: string) {
const match = text.match(/(\d+)\s*道/)
const count = match ? Number(match[1]) : 5
return Number.isFinite(count) ? Math.min(Math.max(count, 1), 50) : 5
}
</script>
<style scoped>
.gen-hero { display: flex; align-items: center; gap: 16px; margin-bottom: 24px; }
.gen-hero-icon { width: 52px; height: 52px; border-radius: 14px; display: flex; align-items: center; justify-content: center; }
.gen-hero h2 { font-size: 24px; font-weight: 700; color: var(--text-primary); }
.gen-hero p { font-size: 14px; color: var(--text-muted); }
.gen-input-section { background: #fff; border: 1px solid var(--border-color); border-radius: var(--radius-lg); padding: 24px; margin-bottom: 24px; }
.quick-notice { min-height: 38px; display: flex; align-items: center; gap: 8px; border: 1px solid #dce8de; border-radius: 12px; background: #f0fbef; color: #00543d; padding: 8px 12px; margin-bottom: 14px; font-size: 13px; font-weight: 800; }
.prompt-box { margin-bottom: 16px; }
.prompt-box :deep(.el-textarea__inner) { border: none; background: #F8FAFC; border-radius: var(--radius-md); padding: 16px; font-size: 15px; line-height: 1.6; }
.prompt-footer { display: flex; justify-content: space-between; align-items: center; margin-top: 12px; }
.prompt-tags { display: flex; gap: 8px; }
.prompt-tag { padding: 4px 12px; background: #FFFBEB; color: #F59E0B; border-radius: 20px; font-size: 12px; cursor: pointer; transition: all 0.2s; }
.prompt-tag:hover { background: #F59E0B; color: #fff; }
.material-row { display: flex; flex-wrap: wrap; gap: 8px; padding: 14px 0 0; margin-top: 4px; border-top: 1px solid var(--border-color); }
.tpl-btn { display: inline-flex; align-items: center; gap: 6px; height: 40px; padding: 0 16px; border: 1px solid #fed7aa; border-radius: var(--radius-sm); background: #fff; color: #c2410c; cursor: pointer; font-size: 14px; font-weight: 600; transition: all 0.2s; }
.tpl-btn:hover { border-color: #ea580c; background: #fff7ed; }
.gen-btn { display: flex; align-items: center; gap: 8px; border: none; color: #fff; padding: 10px 28px; border-radius: var(--radius-sm); font-size: 15px; font-weight: 600; cursor: pointer; transition: all 0.3s; }
.gen-btn:hover:not(:disabled) { opacity: 0.9; transform: translateY(-1px); }
.gen-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.settings-row { display: flex; gap: 24px; align-items: center; padding-top: 16px; border-top: 1px solid var(--border-color); }
.setting-item { display: flex; align-items: center; gap: 8px; }
.setting-item label { font-size: 13px; color: var(--text-muted); white-space: nowrap; }
.loading-section { text-align: center; padding: 48px 0; }
.loading-bar { width: 300px; height: 4px; background: #E2E8F0; border-radius: 2px; margin: 0 auto 20px; overflow: hidden; }
.loading-bar-fill { height: 100%; width: 40%; background: var(--gradient-orange); border-radius: 2px; animation: loadingSlide 1.5s ease-in-out infinite; }
@keyframes loadingSlide { 0% { transform: translateX(-100%); } 100% { transform: translateX(350%); } }
.loading-section p { font-size: 14px; color: var(--text-secondary); }
.guide-section { animation: fadeIn 0.4s ease; }
.capability-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 14px; }
.capability-row div { min-height: 78px; border: 1px solid #eadfcb; border-radius: 12px; background: #fff; padding: 14px 16px; }
.capability-row strong { display: block; color: #9a5f00; font-size: 15px; margin-bottom: 6px; }
.capability-row span { color: #7a6850; font-size: 12px; line-height: 1.45; }
.guide-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
.guide-card { display: flex; flex-direction: column; gap: 10px; background: #fff; border: 1px solid var(--border-color); border-radius: var(--radius-md); padding: 18px; cursor: pointer; transition: all 0.3s; }
.guide-card:hover { box-shadow: var(--shadow-md); border-color: #F59E0B; transform: translateY(-2px); }
.guide-icon { width: 36px; height: 36px; border-radius: 10px; display: flex; align-items: center; justify-content: center; }
.guide-info h4 { font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 4px; }
.guide-info p { font-size: 12px; color: var(--text-muted); line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.guide-arrow { font-size: 12px; color: #F59E0B; font-weight: 500; margin-top: auto; }
.result-section { animation: fadeInUp 0.5s ease; }
.result-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; margin-bottom: 20px; }
.result-header h3 { font-size: 20px; font-weight: 600; color: var(--text-primary); margin-bottom: 4px; }
.result-meta { font-size: 13px; color: var(--text-muted); }
.header-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
.mode-btn { display: flex; align-items: center; gap: 4px; padding: 6px 14px; border: 1px solid var(--border-color); border-radius: var(--radius-sm); background: #fff; font-size: 13px; color: var(--text-secondary); cursor: pointer; transition: all 0.2s; }
.mode-btn.active { background: #FFF7ED; border-color: #F59E0B; color: #F59E0B; }
.mode-btn:hover { border-color: #F59E0B; }
.mode-btn.publish { border-color: #f59e0b; color: #9a5f00; font-weight: 800; }
.mode-btn:disabled { opacity: .58; cursor: not-allowed; }
.save-btn { display: flex; align-items: center; gap: 6px; border: none; color: #fff; padding: 8px 20px; border-radius: var(--radius-sm); font-size: 14px; font-weight: 500; cursor: pointer; transition: opacity 0.2s; }
.save-btn:hover { opacity: 0.9; }
/* Interactive Mode */
.interactive-mode { }
.interactive-stage { background: #F1F5F9; border-radius: var(--radius-lg); padding: 24px; margin-bottom: 16px; }
.interactive-stage :deep(.html-preview) { border-radius: var(--radius-md); box-shadow: 0 4px 24px rgba(0,0,0,0.08); }
.interactive-nav { display: flex; align-items: center; justify-content: center; gap: 16px; margin-bottom: 16px; }
.nav-btn { width: 40px; height: 40px; border-radius: 50%; border: 1px solid var(--border-color); background: #fff; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: all 0.2s; }
.nav-btn:hover:not(:disabled) { border-color: #F59E0B; color: #F59E0B; }
.nav-btn:disabled { opacity: 0.3; cursor: not-allowed; }
.nav-dots { display: flex; gap: 6px; flex-wrap: wrap; justify-content: center; }
.dot { width: 32px; height: 32px; border-radius: 8px; border: 1px solid var(--border-color); display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 600; color: var(--text-muted); cursor: pointer; transition: all 0.2s; }
.dot.active { background: var(--gradient-orange); border-color: transparent; color: #fff; }
.dot:hover:not(.active) { border-color: #F59E0B; }
.interactive-answer { background: #F8FAFC; border: 1px solid var(--border-color); border-radius: var(--radius-md); padding: 16px; }
.quiz-score-bar { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 16px; border-radius: 10px; background: #f0fdf4; border: 1px solid #dce8de; margin-bottom: 14px; font-size: 13px; font-weight: 700; color: #00543d; flex-wrap: wrap; }
.quiz-score-bar .reset-btn { border: 1px solid #dbe8de; border-radius: 8px; background: #fff; color: #00543d; padding: 4px 12px; font-size: 12px; cursor: pointer; font-weight: 700; }
.quiz-score-bar .attempt-summary { color: #6b7f76; font-weight: 600; font-size: 12px; padding: 2px 10px; background: #fff; border-radius: 6px; border: 1px solid #e2ece4; }
.quiz-question-card { background: #fff; border: 1px solid var(--border-color); border-radius: 14px; padding: 20px; margin-bottom: 16px; }
.quiz-q-header { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
.quiz-html-preview { margin: 12px 0; border-radius: 10px; overflow: hidden; border: 1px solid var(--border-color); }
.quiz-options { display: flex; flex-direction: column; gap: 8px; margin-top: 14px; }
.quiz-option { display: flex; align-items: center; gap: 10px; padding: 12px 16px; border: 2px solid var(--border-color); border-radius: 10px; background: #fff; cursor: pointer; transition: all 0.2s; font-size: 14px; text-align: left; }
.quiz-option:hover:not(:disabled) { border-color: #F59E0B; background: #fffbeb; }
.quiz-option .opt-letter { width: 28px; height: 28px; border-radius: 50%; background: #f1f5f9; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 13px; color: var(--text-muted); flex-shrink: 0; }
.quiz-option.is-correct { border-color: #10B981; background: #ecfdf5; }
.quiz-option.is-correct .opt-letter { background: #10B981; color: #fff; }
.quiz-option.is-wrong { border-color: #EF4444; background: #fef2f2; }
.quiz-option.is-wrong .opt-letter { background: #EF4444; color: #fff; }
.quiz-option.is-dimmed { opacity: 0.5; }
.quiz-option:disabled { cursor: default; }
.opt-correct { color: #10B981; margin-left: auto; }
.opt-wrong { color: #EF4444; margin-left: auto; }
.quiz-fill-input { display: flex; gap: 8px; margin-top: 14px; }
.fill-input { flex: 1; height: 42px; border: 2px solid var(--border-color); border-radius: 10px; padding: 0 14px; font-size: 14px; outline: none; }
.fill-input:focus { border-color: #F59E0B; }
.fill-input:disabled { background: #f8fafc; }
.submit-answer-btn { height: 42px; padding: 0 18px; border: none; border-radius: 10px; background: #F59E0B; color: #fff; font-weight: 700; cursor: pointer; }
.quiz-feedback { margin-top: 16px; border-radius: 10px; padding: 14px 16px; }
.quiz-feedback.correct { background: #ecfdf5; border: 1px solid #a7f3d0; }
.quiz-feedback.wrong { background: #fef2f2; border: 1px solid #fecaca; }
.feedback-head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; font-size: 14px; }
.feedback-head strong { font-size: 15px; }
.quiz-feedback.correct .feedback-head { color: #065f46; }
.quiz-feedback.wrong .feedback-head { color: #991b1b; }
.feedback-explain { padding-top: 8px; border-top: 1px solid rgba(0,0,0,0.06); font-size: 13px; line-height: 1.6; }
.dot.correct { background: #10B981; border-color: transparent; color: #fff; }
.dot.wrong { background: #EF4444; border-color: transparent; color: #fff; }
.answer-explain { margin-top: 8px; font-size: 13px; color: var(--text-muted); line-height: 1.6; }
/* Detail Mode */
.detail-mode { display: flex; flex-direction: column; gap: 16px; }
.question-card { background: #fff; border: 1px solid var(--border-color); border-radius: var(--radius-md); padding: 20px; transition: all 0.3s; }
.question-card:hover { box-shadow: var(--shadow-md); }
.q-header { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
.q-num { width: 28px; height: 28px; background: var(--gradient-orange); border-radius: 8px; display: flex; align-items: center; justify-content: center; color: #fff; font-size: 13px; font-weight: 700; }
.q-text { font-size: 15px; line-height: 1.7; color: var(--text-primary); margin-bottom: 14px; }
.q-preview { margin-bottom: 14px; border-radius: var(--radius-sm); overflow: hidden; }
.q-options { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 14px; }
.q-option { display: flex; align-items: center; gap: 8px; padding: 10px 14px; border: 1px solid var(--border-color); border-radius: var(--radius-sm); font-size: 14px; transition: all 0.2s; }
.q-option.correct { border-color: #10B981; background: #ECFDF5; color: #059669; }
.opt-letter { width: 22px; height: 22px; background: #F1F5F9; border-radius: 6px; display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 600; color: var(--text-secondary); flex-shrink: 0; }
.q-option.correct .opt-letter { background: #10B981; color: #fff; }
.q-answer { display: flex; align-items: center; gap: 6px; color: #10B981; font-size: 14px; font-weight: 500; }
.q-explanation { margin-top: 10px; padding: 10px 14px; background: #F8FAFC; border-radius: var(--radius-sm); font-size: 13px; color: var(--text-muted); line-height: 1.6; }
.btn-loading { width: 16px; height: 16px; border: 2px solid rgba(255,255,255,0.3); border-top-color: #fff; border-radius: 50%; animation: spin 0.6s linear infinite; }
.saved-section { margin-top: 28px; background: #fff; border: 1px solid var(--border-color); border-radius: var(--radius-lg); padding: 22px; }
.saved-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; margin-bottom: 18px; }
.saved-header h3 { font-size: 18px; font-weight: 700; color: var(--text-primary); margin-bottom: 4px; }
.saved-header p { font-size: 13px; color: var(--text-muted); }
.refresh-btn { height: 34px; display: inline-flex; align-items: center; gap: 6px; border: 1px solid #eadfcb; border-radius: 17px; background: #fff; color: #9a5f00; padding: 0 14px; font-weight: 700; cursor: pointer; }
.saved-loading,
.saved-empty { min-height: 120px; display: flex; align-items: center; justify-content: center; gap: 8px; color: var(--text-muted); border: 1px dashed #eadfcb; border-radius: var(--radius-md); background: #fffaf0; }
.saved-empty .el-icon { font-size: 24px; color: #d89a2a; }
.saved-grid { display: grid; grid-template-columns: repeat(3, minmax(220px, 1fr)); gap: 16px; }
.saved-card { overflow: hidden; border: 1px solid #eadfcb; border-radius: 12px; background: #fff; }
.saved-cover { height: 132px; display: flex; flex-direction: column; justify-content: center; gap: 8px; margin: 5px; padding: 18px; border-radius: 9px; color: #fff; background: linear-gradient(135deg, #c2410c, #f59e0b); }
.saved-cover .el-icon { font-size: 22px; opacity: .85; }
.saved-cover strong { font-size: 18px; line-height: 1.25; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.saved-cover span { width: fit-content; padding: 3px 8px; border-radius: 12px; background: rgba(255,255,255,.2); font-size: 12px; font-weight: 800; }
.saved-body { padding: 12px 16px 16px; }
.saved-body h4 { font-size: 15px; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-bottom: 6px; }
.saved-body p { min-height: 36px; color: var(--text-muted); font-size: 12px; line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.saved-actions { display: flex; gap: 8px; margin-top: 12px; }
.saved-actions button { height: 32px; display: inline-flex; align-items: center; justify-content: center; gap: 5px; border: 1px solid #eadfcb; border-radius: 8px; background: #fffaf0; color: #9a5f00; padding: 0 12px; cursor: pointer; font-weight: 700; }
.saved-actions .danger:hover { border-color: #fca5a5; background: #fef2f2; color: #ef4444; }
@media (max-width: 1080px) {
.saved-grid { grid-template-columns: repeat(2, minmax(220px, 1fr)); }
}
@media (max-width: 760px) {
.prompt-footer,
.settings-row,
.result-header,
.saved-header,
.header-actions { flex-direction: column; align-items: stretch; }
.capability-row,
.guide-cards,
.saved-grid,
.q-options { grid-template-columns: 1fr; }
}
</style>
+266
View File
@@ -0,0 +1,266 @@
<template>
<div class="favorites-page">
<section class="fav-hero">
<div>
<span class="eyebrow">个人收藏</span>
<h1>我的收藏</h1>
<p>统一管理你收藏的教学资源和社区帖子,随时回顾、改编或取消收藏。</p>
</div>
<div class="fav-stats">
<span><el-icon><Collection /></el-icon>{{ resourceTotal }} 个资源</span>
<span><el-icon><ChatDotRound /></el-icon>{{ postTotal }} 篇帖子</span>
</div>
</section>
<section class="tab-panel">
<div class="segmented">
<button type="button" :class="{ active: activeTab === 'resource' }" @click="switchTab('resource')">
<el-icon><Collection /></el-icon>教学资源
</button>
<button type="button" :class="{ active: activeTab === 'post' }" @click="switchTab('post')">
<el-icon><ChatDotRound /></el-icon>社区帖子
</button>
</div>
<div class="sort-bar" v-if="activeTab === 'resource'">
<div class="sort-segmented">
<button v-for="opt in sortOptions" :key="opt.value" type="button" :class="{ active: sort === opt.value }" @click="setSort(opt.value)">{{ opt.label }}</button>
</div>
</div>
</section>
<!-- 资源收藏 -->
<section v-if="activeTab === 'resource'" class="fav-grid" v-loading="loading">
<article v-for="res in resources" :key="'r' + res.id" class="fav-card" @click="openResource(res)">
<div class="cover" :class="coverClass(res.resource_type)">
<span class="type-pill">{{ typeLabel(res.resource_type) }}</span>
<span class="fav-pill active"><el-icon><Star /></el-icon></span>
<div class="cover-text">
<small>{{ res.subject || '通用' }}</small>
<strong>{{ res.title }}</strong>
<em>{{ res.grade || res.resource_type || 'AI教学资源' }}</em>
</div>
</div>
<div class="card-body">
<h3>{{ res.title }}</h3>
<p>{{ res.description || 'AI 生成的教学资源,可直接预览或改编复用。' }}</p>
<div class="card-meta">
<span><el-icon><User /></el-icon>{{ res.name || '匿名教师' }}</span>
<span><el-icon><View /></el-icon>{{ formatCount(res.views) }}</span>
<span><el-icon><Download /></el-icon>{{ formatCount(res.downloads) }}</span>
</div>
<div class="card-actions">
<button type="button" @click.stop="previewResource(res)">预览</button>
<button type="button" class="primary" @click.stop="reuseResource(res)">改编</button>
<button type="button" class="danger" @click.stop="unfavResource(res)">取消收藏</button>
</div>
</div>
</article>
</section>
<div v-if="activeTab === 'resource' && !loading && resources.length === 0" class="empty-state">
<el-icon><Star /></el-icon>
<strong>还没有收藏任何资源</strong>
<span>去资源广场逛逛,点击星标即可收藏到这里。</span>
<button type="button" @click="$router.push('/resources')">浏览资源广场</button>
</div>
<!-- 帖子收藏 -->
<section v-if="activeTab === 'post'" class="post-list" v-loading="loading">
<article v-for="post in posts" :key="'p' + post.id" class="post-card" @click="openPost(post)">
<div class="post-head">
<div class="post-avatar">{{ (post.name || '匿')[0] }}</div>
<div class="post-author">
<strong>{{ post.name || '匿名教师' }}</strong>
<small>{{ post.subject || '通用' }}{{ post.school ? ' · ' + post.school : '' }}</small>
</div>
<span class="post-type">{{ postTypeLabel(post.post_type) }}</span>
</div>
<h3 class="post-title">{{ post.title }}</h3>
<p class="post-content">{{ post.content }}</p>
<div class="post-tags" v-if="post.tags && post.tags.length">
<span v-for="tag in post.tags.slice(0, 5)" :key="tag" class="tag">#{{ tag }}</span>
</div>
<div class="post-foot">
<span><el-icon><View /></el-icon>{{ formatCount(post.views) }}</span>
<span><el-icon><ChatLineRound /></el-icon>{{ formatCount(post.comments_count) }}</span>
<span><el-icon><Star /></el-icon>{{ formatCount(post.likes) }}</span>
<button type="button" class="danger" @click.stop="unfavPost(post)">取消收藏</button>
</div>
</article>
</section>
<div v-if="activeTab === 'post' && !loading && posts.length === 0" class="empty-state">
<el-icon><ChatDotRound /></el-icon>
<strong>还没有收藏任何帖子</strong>
<span>在模板社区找到感兴趣的内容,点击星标收藏。</span>
<button type="button" @click="$router.push('/community')">逛逛社区</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { getFavoriteResources, unlikeResource } from '@/api/resource'
import { getFavoritePosts, unlikePost } from '@/api/community'
const router = useRouter()
const activeTab = ref<'resource' | 'post'>('resource')
const loading = ref(false)
const sort = ref('latest')
const resources = ref<any[]>([])
const posts = ref<any[]>([])
const resourceTotal = ref(0)
const postTotal = ref(0)
const sortOptions = [
{ value: 'latest', label: '最新收藏' },
{ value: 'popular', label: '最多下载' },
]
const typeLabels: Record<string, string> = {
courseware: '课件', animation: '动画', exercise: '练习',
lesson_plan: '教案', exam: '试卷', essay: '作文', mindmap: '思维导图', other: '资源',
}
const postTypeLabels: Record<string, string> = {
discussion: '讨论', share: '分享', question: '提问', template: '模板',
}
function typeLabel(t: string) { return typeLabels[t] || '资源' }
function postTypeLabel(t: string) { return postTypeLabels[t] || '帖子' }
function coverClass(t: string) { return `cover-${t || 'other'}` }
function formatCount(n?: number) {
if (!n || n <= 0) return '0'
if (n >= 10000) return (n / 10000).toFixed(1) + 'w'
return String(n)
}
function setSort(v: string) { sort.value = v; loadResources() }
function switchTab(tab: 'resource' | 'post') {
activeTab.value = tab
if (tab === 'resource' && resources.value.length === 0) loadResources()
if (tab === 'post' && posts.value.length === 0) loadPosts()
}
async function loadResources() {
loading.value = true
try {
const res = await getFavoriteResources({ sort: sort.value, limit: 60 })
const data = res.data?.data ?? res.data ?? []
resources.value = Array.isArray(data) ? data : []
resourceTotal.value = resources.value.length
} catch { resources.value = [] } finally { loading.value = false }
}
async function loadPosts() {
loading.value = true
try {
const res = await getFavoritePosts({ limit: 60 })
const data = res.data?.data ?? res.data ?? []
posts.value = Array.isArray(data) ? data : []
postTotal.value = posts.value.length
} catch { posts.value = [] } finally { loading.value = false }
}
function openResource(res: any) { router.push(`/resources/${res.id}`) }
function openPost(post: any) { router.push(`/community/${post.id}`) }
function previewResource(res: any) { router.push(`/resources/${res.id}`) }
function reuseResource(res: any) {
const map: Record<string, string> = {
courseware: '/courseware/create', animation: '/animation',
exercise: '/exercise', lesson_plan: '/lesson-plan', exam: '/exam', mindmap: '/mindmap',
}
router.push(map[res.resource_type] || '/resources/' + res.id)
}
async function unfavResource(res: any) {
try {
await unlikeResource(res.id)
resources.value = resources.value.filter(r => r.id !== res.id)
resourceTotal.value = resources.value.length
ElMessage.success('已取消收藏')
} catch { ElMessage.error('操作失败') }
}
async function unfavPost(post: any) {
try {
await unlikePost(post.id)
posts.value = posts.value.filter(p => p.id !== post.id)
postTotal.value = posts.value.length
ElMessage.success('已取消收藏')
} catch { ElMessage.error('操作失败') }
}
onMounted(loadResources)
</script>
<style scoped>
.favorites-page { max-width: 1200px; margin: 0 auto; padding: 24px 28px 60px; }
.fav-hero { display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 24px; }
.fav-hero .eyebrow { font-size: 12px; color: #0f9d6e; font-weight: 700; letter-spacing: .5px; }
.fav-hero h1 { font-size: 28px; font-weight: 900; color: #0f2e22; margin: 4px 0; }
.fav-hero p { font-size: 14px; color: #5a7268; margin: 0; }
.fav-stats { display: flex; gap: 16px; }
.fav-stats span { display: inline-flex; align-items: center; gap: 5px; font-size: 13px; font-weight: 700; color: #3a5a50; background: #e8f5ef; padding: 7px 14px; border-radius: 20px; }
.tab-panel { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; flex-wrap: wrap; gap: 12px; }
.segmented { display: inline-flex; background: #e8f0ec; border-radius: 10px; padding: 4px; }
.segmented button { display: inline-flex; align-items: center; gap: 6px; border: none; background: transparent; padding: 8px 20px; border-radius: 8px; font-size: 14px; font-weight: 700; color: #4a6a60; cursor: pointer; transition: all .2s; }
.segmented button.active { background: #fff; color: #0f9d6e; box-shadow: 0 1px 4px rgba(0,0,0,.08); }
.sort-segmented { display: inline-flex; gap: 4px; }
.sort-segmented button { border: 1px solid #cde0d6; background: #fff; padding: 6px 14px; border-radius: 7px; font-size: 13px; font-weight: 600; color: #4a6a60; cursor: pointer; }
.sort-segmented button.active { background: #0f9d6e; color: #fff; border-color: #0f9d6e; }
.fav-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 18px; }
.fav-card { border: 1px solid #e0ebe5; border-radius: 12px; overflow: hidden; background: #fff; cursor: pointer; transition: box-shadow .2s, transform .2s; }
.fav-card:hover { box-shadow: 0 6px 20px rgba(15,80,60,.12); transform: translateY(-2px); }
.cover { position: relative; height: 130px; display: flex; flex-direction: column; justify-content: flex-end; padding: 14px; color: #fff; overflow: hidden; }
.cover-courseware { background: linear-gradient(135deg,#2e7d6b,#0f4f3e); }
.cover-animation { background: linear-gradient(135deg,#5b3fa8,#3a2575); }
.cover-exercise { background: linear-gradient(135deg,#d97706,#92400e); }
.cover-lesson_plan { background: linear-gradient(135deg,#2563eb,#1e3a8a); }
.cover-exam { background: linear-gradient(135deg,#dc2626,#7f1d1d); }
.cover-mindmap { background: linear-gradient(135deg,#0891b2,#155e75); }
.cover-other { background: linear-gradient(135deg,#4a6a60,#2d4640); }
.type-pill { position: absolute; top: 10px; left: 12px; background: rgba(255,255,255,.92); color: #0f4f3e; font-size: 11px; font-weight: 800; padding: 3px 9px; border-radius: 5px; }
.fav-pill { position: absolute; top: 10px; right: 12px; color: #ffd700; font-size: 18px; }
.cover-text small { font-size: 11px; opacity: .85; }
.cover-text strong { display: block; font-size: 16px; font-weight: 800; margin: 2px 0; line-height: 1.3; }
.cover-text em { font-size: 12px; opacity: .8; font-style: normal; }
.card-body { padding: 14px; }
.card-body h3 { font-size: 15px; font-weight: 700; color: #1a3a30; margin: 0 0 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.card-body p { font-size: 13px; color: #6b8580; margin: 0 0 12px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.card-meta { display: flex; gap: 12px; font-size: 12px; color: #8aa39c; margin-bottom: 12px; }
.card-meta span { display: inline-flex; align-items: center; gap: 3px; }
.card-actions { display: flex; gap: 8px; }
.card-actions button { flex: 1; border: 1px solid #d0e0d8; background: #fff; color: #3a5a50; font-size: 13px; font-weight: 600; padding: 7px 0; border-radius: 7px; cursor: pointer; }
.card-actions button:hover { border-color: #0f9d6e; color: #0f9d6e; }
.card-actions button.primary { background: #0f9d6e; color: #fff; border-color: #0f9d6e; }
.card-actions button.danger { color: #c0392b; }
.post-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 18px; }
.post-card { border: 1px solid #e0ebe5; border-radius: 12px; background: #fff; padding: 18px; cursor: pointer; transition: box-shadow .2s; }
.post-card:hover { box-shadow: 0 6px 20px rgba(15,80,60,.1); }
.post-head { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
.post-avatar { width: 36px; height: 36px; border-radius: 50%; background: linear-gradient(135deg,#2e7d6b,#0f4f3e); color: #fff; display: flex; align-items: center; justify-content: center; font-weight: 800; font-size: 15px; }
.post-author { flex: 1; }
.post-author strong { display: block; font-size: 14px; color: #1a3a30; }
.post-author small { font-size: 12px; color: #8aa39c; }
.post-type { font-size: 11px; font-weight: 700; background: #e8f5ef; color: #0f9d6e; padding: 3px 9px; border-radius: 5px; }
.post-title { font-size: 16px; font-weight: 800; color: #0f2e22; margin: 0 0 8px; }
.post-content { font-size: 13px; color: #5a7268; line-height: 1.6; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; margin: 0 0 10px; }
.post-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; }
.tag { font-size: 12px; color: #0f9d6e; background: #f0f8f4; padding: 2px 8px; border-radius: 4px; }
.post-foot { display: flex; align-items: center; gap: 14px; font-size: 12px; color: #8aa39c; padding-top: 10px; border-top: 1px solid #f0f5f2; }
.post-foot span { display: inline-flex; align-items: center; gap: 3px; }
.post-foot button { margin-left: auto; border: none; background: transparent; color: #c0392b; font-size: 12px; font-weight: 600; cursor: pointer; }
.empty-state { text-align: center; padding: 60px 20px; color: #8aa39c; }
.empty-state .el-icon { font-size: 48px; color: #c8d8d0; }
.empty-state strong { display: block; font-size: 16px; color: #4a6a60; margin: 12px 0 4px; }
.empty-state span { display: block; font-size: 13px; margin-bottom: 16px; }
.empty-state button { border: none; background: #0f9d6e; color: #fff; font-size: 14px; font-weight: 600; padding: 9px 24px; border-radius: 8px; cursor: pointer; }
</style>
+179
View File
@@ -0,0 +1,179 @@
<template>
<div class="help-page">
<section class="help-hero">
<div>
<span class="eyebrow">使用帮助</span>
<h1>快速上手智教助手</h1>
<p>从一句话生成互动课件,到命题组卷、教案设计——几分钟掌握全部核心功能。</p>
</div>
<div class="hero-art">
<el-icon><Reading /></el-icon>
</div>
</section>
<section class="quick-nav">
<button v-for="s in sections" :key="s.id" type="button" :class="{ active: activeSection === s.id }" @click="scrollTo(s.id)">
<el-icon><component :is="s.icon" /></el-icon>{{ s.title }}
</button>
</section>
<section :id="'sec-start'" class="help-section">
<h2><el-icon><Promotion /></el-icon>三步生成你的第一份课件</h2>
<div class="steps">
<div class="step-card">
<span class="step-num">1</span>
<strong>描述需求</strong>
<p>在首页输入框用一句话描述教学想法,例如「六年级分数的意义与性质」。可上传教材材料辅助 AI 理解。</p>
</div>
<div class="step-card">
<span class="step-num">2</span>
<strong>选择类型</strong>
<p>在左侧导航选择创作模式:互动课件、教学动画、AI组题、教案、思维导图等,不同模式产出不同格式。</p>
</div>
<div class="step-card">
<span class="step-num">3</span>
<strong>生成与保存</strong>
<p>点击生成按钮,AI 在数秒内完成创作。预览满意后保存到「我的作品」,随时编辑、导出或分享给学生。</p>
</div>
</div>
</section>
<section :id="'sec-modules'" class="help-section">
<h2><el-icon><Grid /></el-icon>核心功能模块</h2>
<div class="module-grid">
<article v-for="m in modules" :key="m.title" class="module-card" @click="$router.push(m.path)">
<div class="module-icon" :style="{ background: m.color }"><el-icon><component :is="m.icon" /></el-icon></div>
<div>
<h3>{{ m.title }}</h3>
<p>{{ m.desc }}</p>
</div>
</article>
</div>
</section>
<section :id="'sec-credits'" class="help-section">
<h2><el-icon><GoldMedal /></el-icon>积分与会员</h2>
<div class="credit-info">
<div class="credit-card">
<strong>每日签到</strong>
<p>每天可在个人中心签到领取积分,连续签到奖励递增。</p>
</div>
<div class="credit-card">
<strong>生成消耗</strong>
<p>AI 生成课件、动画、教案等内容时会消耗对应积分,余额实时同步。</p>
</div>
<div class="credit-card">
<strong>积分流水</strong>
<p>个人中心可查看完整的积分收支记录,包括签到、生成、充值等。</p>
</div>
</div>
</section>
<section :id="'sec-faq'" class="help-section">
<h2><el-icon><QuestionFilled /></el-icon>常见问题</h2>
<div class="faq-list">
<details v-for="(item, i) in faqs" :key="i" class="faq-item" :open="openFaq === i">
<summary><span>{{ item.q }}</span><el-icon><ArrowDown /></el-icon></summary>
<p>{{ item.a }}</p>
</details>
</div>
</section>
<section class="help-footer">
<el-icon><Headset /></el-icon>
<div>
<strong>还有疑问?</strong>
<p>可通过 AI 教学助手随时提问,或在模板社区与其他教师交流经验。</p>
</div>
<button type="button" class="cta-btn" @click="$router.push('/assistant')">咨询AI助手</button>
</section>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const activeSection = ref('start')
const openFaq = ref(0)
const sections = [
{ id: 'start', title: '快速上手', icon: 'Promotion' },
{ id: 'modules', title: '功能模块', icon: 'Grid' },
{ id: 'credits', title: '积分说明', icon: 'GoldMedal' },
{ id: 'faq', title: '常见问题', icon: 'QuestionFilled' },
]
const modules = [
{ title: 'AI互动课件', desc: '一句话生成多页可交互 HTML 课件,支持编辑、导出和分享。', icon: 'MagicStick', color: 'linear-gradient(135deg,#2e7d6b,#0f4f3e)', path: '/courseware/create' },
{ title: '教学动画', desc: '生成数学公式、物理实验等可视化教学动画,36+ 学科模板。', icon: 'Film', color: 'linear-gradient(135deg,#5b3fa8,#3a2575)', path: '/animation' },
{ title: 'AI命题', desc: '按知识点、题型、难度自动组卷,支持 Word 导出与打印。', icon: 'Finished', color: 'linear-gradient(135deg,#dc2626,#7f1d1d)', path: '/exam' },
{ title: 'AI组题', desc: '8 种题型的互动练习生成,含游戏化模板和作答统计。', icon: 'List', color: 'linear-gradient(135deg,#d97706,#92400e)', path: '/exercise' },
{ title: 'AI教案', desc: '按课题、学科、年级生成结构化教学设计。', icon: 'Tickets', color: 'linear-gradient(135deg,#db2777,#831843)', path: '/lesson-plan' },
{ title: '作文批改', desc: '多维度智能批改作文,提供评分、评语和修改建议。', icon: 'EditPen', color: 'linear-gradient(135deg,#2563eb,#1e3a8a)', path: '/essay-grade' },
{ title: 'AI思维导图', desc: '生成层次化知识思维导图,可折叠交互、HTML 导出。', icon: 'Connection', color: 'linear-gradient(135deg,#0891b2,#155e75)', path: '/mindmap' },
{ title: '课堂工具', desc: '投票、问答、测验、签到,支持学生扫码实时提交与统计。', icon: 'Monitor', color: 'linear-gradient(135deg,#16a34a,#14532d)', path: '/classroom' },
]
const faqs = [
{ q: '生成的内容会自动保存吗?', a: '课件、动画、练习等在生成后会进入预览状态。你需要点击「保存」按钮才会存入「我的作品」。未保存的内容刷新页面后会丢失。' },
{ q: '积分不够了怎么办?', a: '每日签到可领取免费积分,连续签到奖励递增。也可联系管理员获取更多积分。所有积分变动均可在个人中心查看流水。' },
{ q: '如何把课件分享给学生?', a: '在课件详情页点击「分享」生成专属链接或投放码,学生无需登录即可打开预览。你随时可以撤销分享。' },
{ q: '生成的动画/课件可以编辑吗?', a: '可以。课件支持完整的页面编辑器(增删页面、撤销重做、主题换肤)。动画和练习可在保存后二次生成或改编。' },
{ q: '课堂工具怎么收集学生答题数据?', a: '创建课堂活动后,系统生成投放大屏和加入链接/投放码。学生扫码提交答案后,教师端实时查看统计图表,支持 Excel 导出。' },
{ q: '支持哪些格式的材料上传?', a: '素材库支持 docx、pptx、pdf、xlsx、txt、md、csv、json 文档解析,以及 png/jpg 等图片(经 AI 视觉 OCR 提取文字)。' },
{ q: '忘记密码怎么办?', a: '在登录页点击「忘记密码」,输入手机号获取验证码即可重置。验证码 5 分钟内有效。' },
]
function scrollTo(id: string) {
activeSection.value = id
document.getElementById('sec-' + id)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
</script>
<style scoped>
.help-page { max-width: 960px; margin: 0 auto; padding: 24px 28px 60px; }
.help-hero { display: flex; justify-content: space-between; align-items: center; margin-bottom: 28px; }
.help-hero .eyebrow { font-size: 12px; color: #0f9d6e; font-weight: 700; letter-spacing: .5px; }
.help-hero h1 { font-size: 30px; font-weight: 900; color: #0f2e22; margin: 4px 0; }
.help-hero p { font-size: 14px; color: #5a7268; margin: 0; max-width: 520px; }
.hero-art { width: 80px; height: 80px; border-radius: 20px; background: linear-gradient(135deg,#d9f0d6,#b8e6cc); display: flex; align-items: center; justify-content: center; font-size: 40px; color: #0f9d6e; flex-shrink: 0; }
.quick-nav { display: flex; gap: 8px; margin-bottom: 32px; flex-wrap: wrap; position: sticky; top: 0; z-index: 10; background: #f7faf8; padding: 10px 0; }
.quick-nav button { display: inline-flex; align-items: center; gap: 6px; border: 1px solid #d0e0d8; background: #fff; padding: 8px 16px; border-radius: 8px; font-size: 13px; font-weight: 600; color: #4a6a60; cursor: pointer; transition: all .2s; }
.quick-nav button.active { background: #0f9d6e; color: #fff; border-color: #0f9d6e; }
.help-section { margin-bottom: 40px; scroll-margin-top: 70px; }
.help-section h2 { display: flex; align-items: center; gap: 8px; font-size: 20px; font-weight: 800; color: #0f2e22; margin: 0 0 18px; }
.help-section h2 .el-icon { color: #0f9d6e; }
.steps { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
.step-card { border: 1px solid #e0ebe5; border-radius: 12px; padding: 20px; background: #fff; position: relative; }
.step-num { display: inline-flex; width: 32px; height: 32px; border-radius: 50%; background: #0f9d6e; color: #fff; font-weight: 900; font-size: 15px; align-items: center; justify-content: center; margin-bottom: 12px; }
.step-card strong { display: block; font-size: 16px; color: #0f2e22; margin-bottom: 6px; }
.step-card p { font-size: 13px; color: #5a7268; line-height: 1.6; margin: 0; }
.module-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 14px; }
.module-card { display: flex; gap: 14px; border: 1px solid #e0ebe5; border-radius: 12px; padding: 16px; background: #fff; cursor: pointer; transition: box-shadow .2s, transform .2s; }
.module-card:hover { box-shadow: 0 4px 16px rgba(15,80,60,.1); transform: translateY(-2px); }
.module-icon { width: 44px; height: 44px; border-radius: 11px; display: flex; align-items: center; justify-content: center; color: #fff; font-size: 22px; flex-shrink: 0; }
.module-card h3 { font-size: 15px; font-weight: 700; color: #0f2e22; margin: 2px 0 4px; }
.module-card p { font-size: 13px; color: #6b8580; line-height: 1.5; margin: 0; }
.credit-info { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
.credit-card { border: 1px solid #e0ebe5; border-radius: 12px; padding: 18px; background: linear-gradient(180deg,#f0f9f4,#fff); }
.credit-card strong { display: flex; align-items: center; gap: 6px; font-size: 15px; color: #0f4f3e; margin-bottom: 8px; }
.credit-card p { font-size: 13px; color: #5a7268; line-height: 1.6; margin: 0; }
.faq-list { display: grid; gap: 10px; }
.faq-item { border: 1px solid #e0ebe5; border-radius: 10px; background: #fff; overflow: hidden; transition: border-color .2s; }
.faq-item[open] { border-color: #0f9d6e; }
.faq-item summary { display: flex; justify-content: space-between; align-items: center; padding: 14px 18px; font-size: 14px; font-weight: 700; color: #1a3a30; cursor: pointer; list-style: none; }
.faq-item summary::-webkit-details-marker { display: none; }
.faq-item summary .el-icon { transition: transform .2s; color: #0f9d6e; }
.faq-item[open] summary .el-icon { transform: rotate(180deg); }
.faq-item p { padding: 0 18px 16px; font-size: 13px; color: #5a7268; line-height: 1.7; margin: 0; }
.help-footer { display: flex; align-items: center; gap: 16px; border-radius: 14px; background: linear-gradient(135deg,#0f4f3e,#0f9d6e); color: #fff; padding: 22px 26px; }
.help-footer .el-icon { font-size: 36px; opacity: .85; }
.help-footer strong { display: block; font-size: 17px; }
.help-footer p { font-size: 13px; opacity: .85; margin: 4px 0 0; }
.cta-btn { margin-left: auto; border: none; background: #fff; color: #0f9d6e; font-size: 14px; font-weight: 700; padding: 11px 24px; border-radius: 9px; cursor: pointer; white-space: nowrap; }
@media (max-width: 768px) {
.steps, .module-grid, .credit-info { grid-template-columns: 1fr; }
.help-footer { flex-wrap: wrap; }
.cta-btn { margin-left: 0; }
}
</style>
File diff suppressed because it is too large Load Diff
+650
View File
@@ -0,0 +1,650 @@
<template>
<div class="page-container">
<div class="gen-hero">
<div class="gen-hero-icon" style="background: var(--gradient-pink)">
<el-icon :size="28" color="#fff"><Tickets /></el-icon>
</div>
<div>
<h2>大单元教案</h2>
<p>AI生成结构化教学设计,支持目标、重难点、活动、作业和评价环节</p>
</div>
</div>
<div class="gen-input-section">
<div v-if="quickPromptNotice" class="quick-notice">
<el-icon><MagicStick /></el-icon>
<span>{{ quickPromptNotice }}</span>
</div>
<div class="settings-grid">
<div class="setting-item">
<label>课题名称</label>
<el-input v-model="form.title" placeholder="例如:一元二次方程" size="default" />
</div>
<div class="setting-item">
<label>学科</label>
<el-select v-model="form.subject" placeholder="选择学科" size="default" style="width: 100%">
<el-option v-for="s in subjects" :key="s" :label="s" :value="s" />
</el-select>
</div>
<div class="setting-item">
<label>年级</label>
<el-select v-model="form.grade" placeholder="选择年级" size="default" style="width: 100%">
<el-option v-for="g in grades" :key="g" :label="g" :value="g" />
</el-select>
</div>
<div class="setting-item">
<label>课时(分钟)</label>
<el-input-number v-model="form.duration" :min="20" :max="120" size="default" style="width: 100%" />
</div>
</div>
<div class="setting-full">
<label>教学目标</label>
<el-select v-model="form.objectives" multiple filterable allow-create placeholder="输入教学目标后回车" size="default" style="width: 100%">
</el-select>
</div>
<div class="setting-full">
<label>附加要求</label>
<el-input v-model="form.extra_requirements" type="textarea" :rows="2" placeholder="其他特殊要求..." resize="none" />
</div>
<div class="material-row">
<MaterialPicker v-model="selectedMaterials" />
</div>
<div class="gen-action">
<button class="gen-btn" style="background: var(--gradient-pink)" :disabled="!form.title || generating" @click="handleGenerate">
<el-icon v-if="!generating"><MagicStick /></el-icon>
<span v-if="generating" class="btn-loading"></span>
{{ generating ? 'AI生成中...' : '生成教案' }}
</button>
</div>
</div>
<div v-if="generating" class="loading-section">
<div class="loading-bar"><div class="loading-bar-fill"></div></div>
<p>AI正在生成教案...</p>
<p style="font-size:13px;color:var(--text-secondary);opacity:.65;margin-top:10px">模型深度推理中,通常需要 1-2 分钟,请耐心等待</p>
</div>
<div v-else-if="!result" class="guide-section">
<div class="capability-row">
<div v-for="item in capabilities" :key="item.title" class="capability-item">
<strong>{{ item.title }}</strong>
<span>{{ item.desc }}</span>
</div>
</div>
<div class="guide-cards">
<div class="guide-card" v-for="g in guideExamples" :key="g.title" @click="applyGuide(g)">
<div class="guide-icon" :style="{ background: g.bg }">
<el-icon :size="18" color="#fff"><component :is="g.icon" /></el-icon>
</div>
<div class="guide-info">
<h4>{{ g.title }}</h4>
<p>{{ g.desc }}</p>
</div>
<span class="guide-arrow">试试 →</span>
</div>
</div>
</div>
<div v-if="result" class="result-section">
<div class="result-header">
<h3>{{ result.title }}</h3>
<div class="result-actions">
<button class="tool-btn" type="button" @click="reuseCurrent">
<el-icon><MagicStick /></el-icon>
用它生成
</button>
<button class="tool-btn publish" type="button" :disabled="publishing" @click="publishCurrent">
<el-icon><Share /></el-icon>
{{ publishing ? '发布中' : '发布资源' }}
</button>
<button class="save-btn" style="background: var(--gradient-pink)" @click="handleSave"><el-icon><Check /></el-icon> 保存教案</button>
<button class="tool-btn" type="button" @click="downloadCurrent">
<el-icon><Download /></el-icon>
下载Word
</button>
<button class="tool-btn" type="button" @click="printCurrent">
<el-icon><Printer /></el-icon>
打印PDF
</button>
</div>
</div>
<div class="info-tags">
<div class="info-group">
<span class="info-label">教学目标</span>
<div class="tag-list">
<el-tag v-for="(obj, i) in result.objectives" :key="i" size="small" effect="plain">{{ obj }}</el-tag>
</div>
</div>
<div class="info-group">
<span class="info-label">教学重点</span>
<div class="tag-list">
<el-tag v-for="(kp, i) in result.key_points" :key="i" type="warning" size="small" effect="plain">{{ kp }}</el-tag>
</div>
</div>
<div class="info-group">
<span class="info-label">教学难点</span>
<div class="tag-list">
<el-tag v-for="(d, i) in result.difficulties" :key="i" type="danger" size="small" effect="plain">{{ d }}</el-tag>
</div>
</div>
</div>
<div class="phases-timeline">
<div v-for="(phase, i) in result.phases" :key="i" class="phase-card">
<div class="phase-marker">
<div class="phase-dot" :style="{ background: phaseColors[i % phaseColors.length] }">{{ i + 1 }}</div>
<div class="phase-line" v-if="i < result.phases.length - 1"></div>
</div>
<div class="phase-content">
<div class="phase-header">
<h4>{{ phase.name }}</h4>
<span class="phase-duration">{{ phase.duration }}分钟</span>
</div>
<div v-if="phase.activities?.length" class="phase-block">
<span class="block-label">教学活动</span>
<div class="phase-list"><MarkdownContent v-for="(a, j) in phase.activities" :key="j" :content="'- ' + a" /></div>
</div>
<div v-if="phase.teacher_actions?.length" class="phase-block">
<span class="block-label teacher">教师行为</span>
<div class="phase-list"><MarkdownContent v-for="(a, j) in phase.teacher_actions" :key="j" :content="'- ' + a" /></div>
</div>
<div v-if="phase.student_actions?.length" class="phase-block">
<span class="block-label student">学生行为</span>
<div class="phase-list"><MarkdownContent v-for="(a, j) in phase.student_actions" :key="j" :content="'- ' + a" /></div>
</div>
</div>
</div>
</div>
<div v-if="result.homework?.length" class="homework-section">
<h4>课后作业</h4>
<ol><li v-for="(h, i) in result.homework" :key="i"><MarkdownContent :content="h" /></li></ol>
</div>
</div>
<section class="saved-section">
<div class="saved-header">
<div>
<h3>已保存教案</h3>
<p>保存后的大单元教案会出现在这里,方便回看和复用</p>
</div>
<button class="refresh-btn" type="button" @click="loadSavedPlans">
<el-icon><Refresh /></el-icon>
刷新
</button>
</div>
<div v-if="savedLoading" class="saved-loading">正在加载已保存教案...</div>
<div v-else-if="savedPlans.length === 0" class="saved-empty">
<el-icon><Tickets /></el-icon>
<span>暂无已保存教案</span>
</div>
<div v-else class="saved-grid">
<article v-for="item in savedPlans" :key="item.id" class="saved-card">
<div class="saved-cover">
<el-icon><Tickets /></el-icon>
<strong>{{ item.title }}</strong>
<span>{{ item.subject || '未设置学科' }} · {{ item.grade || '未设置年级' }}</span>
</div>
<div class="saved-body">
<h4>{{ item.title }}</h4>
<p>{{ item.objectives?.join('、') || '暂无教学目标' }}</p>
<div class="saved-actions">
<button type="button" @click="previewSaved(item)"><el-icon><View /></el-icon>预览</button>
<button type="button" @click="publishSaved(item)"><el-icon><Share /></el-icon>发布</button>
<button type="button" @click="downloadSaved(item)"><el-icon><Download /></el-icon>下载</button>
<el-popconfirm title="确定删除这个教案?关联资源会同步下架。" @confirm="handleDeleteSaved(item.id)">
<template #reference>
<button type="button" class="danger"><el-icon><Delete /></el-icon>删除</button>
</template>
</el-popconfirm>
</div>
</div>
</article>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { aiGenerateLessonPlan, createLessonPlan, deleteLessonPlan, exportLessonPlanDocx, getLessonPlan, getLessonPlans, updateLessonPlan } from '@/api/lessonPlan'
import { publishResource } from '@/api/resource'
import MarkdownContent from '@/components/common/MarkdownContent.vue'
import MaterialPicker from '@/components/common/MaterialPicker.vue'
import { useUserStore } from '@/stores/user'
import { downloadBlob } from '@/utils/download'
import { buildMaterialPromptBlock, type MaterialContextItem } from '@/utils/materialContext'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const generating = ref(false)
const result = ref<any>(null)
const savedLoading = ref(false)
const savedPlans = ref<any[]>([])
const quickPromptNotice = ref('')
const savedResultId = ref<number | null>(null)
const publishing = ref(false)
const selectedMaterials = ref<MaterialContextItem[]>([])
const subjects = ['语文', '数学', '英语', '物理', '化学', '生物', '历史', '地理', '政治']
const grades = ['一年级', '二年级', '三年级', '四年级', '五年级', '六年级', '初一', '初二', '初三', '高一', '高二', '高三']
const phaseColors = ['#6366F1', '#06B6D4', '#10B981', '#F59E0B', '#EC4899']
const guideExamples = [
{ title: '数学一元二次方程', desc: '初三数学,45分钟,包含引入、讲解、练习、总结四个环节', icon: 'Tickets', bg: 'var(--gradient-pink)', title_val: '一元二次方程', subject_val: '数学', grade_val: '初三' },
{ title: '语文古诗词鉴赏', desc: '高一语文,讲解李白《将进酒》,90分钟大课', icon: 'Document', bg: 'var(--gradient-blue)', title_val: '古诗词鉴赏:将进酒', subject_val: '语文', grade_val: '高一' },
{ title: '英语口语对话课', desc: '初二英语,日常对话练习,注重口语表达训练', icon: 'ChatDotRound', bg: 'var(--gradient-green)', title_val: '日常英语口语对话', subject_val: '英语', grade_val: '初二' },
]
const capabilities = [
{ title: '目标到活动闭环', desc: '围绕教学目标生成重点、难点、活动、作业与评价建议。' },
{ title: '大单元可拆课时', desc: '可用课时长度控制节奏,适配导入、探究、练习、总结等环节。' },
{ title: '沉淀校本模板', desc: '保存后的教案可复用为同学科、同年级的备课资产。' },
]
const form = reactive({ title: '', subject: '', grade: '', objectives: [] as string[], duration: 45, extra_requirements: '' })
function applyGuide(g: typeof guideExamples[0]) {
form.title = g.title_val
form.subject = g.subject_val
form.grade = g.grade_val
}
async function handleGenerate() {
if (!(await ensureLoggedIn())) return
generating.value = true
try {
const res: any = await aiGenerateLessonPlan({
...form,
extra_requirements: buildExtraRequirementsWithMaterials(),
})
userStore.refreshCreditsFromResponse(res)
result.value = res.data
savedResultId.value = null
ElMessage.success('教案生成成功')
} catch (e: any) {
if (handleAuthError(e)) return
ElMessage.error('生成失败:' + (e.response?.data?.detail || e.message || ''))
}
finally { generating.value = false }
}
function buildExtraRequirementsWithMaterials() {
return `${form.extra_requirements}${buildMaterialPromptBlock(selectedMaterials.value, '参考素材')}`.trim()
}
async function handleSave() {
if (!result.value) return
if (!(await ensureLoggedIn())) return
const payload = buildLessonPlanPayload()
const isUpdate = !!savedResultId.value
const saved: any = isUpdate
? await updateLessonPlan(savedResultId.value!, payload)
: await createLessonPlan(payload)
const item = saved.data || saved
savedResultId.value = item?.id || null
ElMessage.success(isUpdate ? '教案已更新' : '教案已保存')
await loadSavedPlans()
}
function printCurrent() {
const r = result.value
if (!r) return
const w = window.open('', '_blank', 'width=800,height=600')
if (!w) { ElMessage.warning('请允许弹出窗口以打印教案'); return }
const esc = (s: any) => String(s||'').replace(/</g,'&lt;').replace(/>/g,'&gt;')
const objHtml = (r.objectives||[]).map((o:any)=>'<li>'+esc(o)+'</li>').join('')
const keyHtml = (r.key_points||[]).map((o:any)=>'<li>'+esc(o)+'</li>').join('')
const diffHtml = (r.difficulties||[]).map((o:any)=>'<li>'+esc(o)+'</li>').join('')
const phases = r.content?.phases || r.content?.steps || r.phases || []
const phaseHtml = (Array.isArray(phases)?phases:[]).map((ph:any,i:number)=>{
const name = ph?.name||ph?.title||'环节'+(i+1)
const acts = ph?.activities||ph?.student_actions||[]
const actHtml = (Array.isArray(acts)?acts:[]).map((a:any)=>'<li>'+esc(a)+'</li>').join('')
return '<div class="phase"><h3>'+(i+1)+'. '+esc(name)+(ph?.duration?'('+ph.duration+'分钟)':'')+'</h3>'+(actHtml?'<ul>'+actHtml+'</ul>':'')+'</div>'
}).join('')
const hwHtml = (r.homework||[]).map((o:any)=>'<li>'+esc(o)+'</li>').join('')
w.document.write('<html><head><title>'+esc(r.title||'教案')+'</title><style>'+
'body{font-family:SimSun,serif;padding:40px;max-width:760px;margin:0 auto;color:#1f2937;line-height:1.8}'+
'h1{text-align:center;border-bottom:2px solid #333;padding-bottom:10px}'+
'.meta{text-align:center;color:#666;margin:8px 0 24px}'+
'h2{margin-top:24px;border-left:4px solid #4f46e5;padding-left:8px}'+
'ul{padding-left:24px}.phase{margin:16px 0;padding:10px;background:#f8fafc;border-radius:6px}'+
'@media print{button{display:none}}'+
'</style></head><body>'+
'<button onclick="window.print()" style="position:fixed;top:10px;right:10px;padding:8px 20px;background:#4f46e5;color:#fff;border:none;border-radius:6px;cursor:pointer">打印 / 另存为PDF</button>'+
'<h1>'+esc(r.title||'大单元教案')+'</h1>'+
'<p class="meta">'+[r.subject,r.grade,r.duration?(r.duration+'分钟'):null].filter(Boolean).join(' / ')+'</p>'+
(objHtml?'<h2>教学目标</h2><ul>'+objHtml+'</ul>':'')+
(keyHtml?'<h2>教学重点</h2><ul>'+keyHtml+'</ul>':'')+
(diffHtml?'<h2>教学难点</h2><ul>'+diffHtml+'</ul>':'')+
(phaseHtml?'<h2>教学过程</h2>'+phaseHtml:'')+
(hwHtml?'<h2>课后作业</h2><ul>'+hwHtml+'</ul>':'')+
'</body></html>')
w.document.close()
}
async function downloadCurrent() {
const id = await ensureSavedCurrent()
if (id) await downloadSaved({ id, title: result.value?.title || form.title || '大单元教案' })
}
async function downloadSaved(item: any) {
if (!(await ensureLoggedIn())) return
try {
const blob = await exportLessonPlanDocx(item.id)
downloadBlob(blob, `${safeFilename(item.title || '大单元教案')}-教案.docx`)
ElMessage.success('已开始下载')
} catch (e: any) {
ElMessage.error('下载失败:' + (e.response?.data?.detail || e.message || ''))
}
}
async function loadSavedPlans() {
if (!(await ensureLoggedIn({ silent: true }))) {
savedPlans.value = []
savedLoading.value = false
return
}
savedLoading.value = true
try {
const res: any = await getLessonPlans({ limit: 50 })
savedPlans.value = res.data || res || []
} catch (e: any) {
if (handleAuthError(e)) {
savedPlans.value = []
return
}
ElMessage.error('加载已保存教案失败:' + (e.response?.data?.detail || e.message || ''))
} finally {
savedLoading.value = false
}
}
function previewSaved(item: any) {
result.value = item.content || item
form.title = item.title || ''
form.subject = item.subject || ''
form.grade = item.grade || ''
form.duration = item.duration || 45
form.objectives = item.objectives || []
savedResultId.value = item.id
window.scrollTo({ top: 0, behavior: 'smooth' })
}
async function handleDeleteSaved(id: number) {
if (!(await ensureLoggedIn())) return
await deleteLessonPlan(id)
ElMessage.success('教案已删除,关联资源已下架')
await loadSavedPlans()
}
function buildLessonPlanPayload() {
return {
...form,
title: result.value?.title || form.title || '大单元教案',
objectives: result.value?.objectives || form.objectives || [],
key_points: result.value?.key_points || [],
difficulties: result.value?.difficulties || [],
homework: result.value?.homework || [],
duration: form.duration,
content: result.value || {},
}
}
async function ensureSavedCurrent() {
if (!result.value) return null
if (!(await ensureLoggedIn())) return null
const payload = buildLessonPlanPayload()
const saved: any = savedResultId.value
? await updateLessonPlan(savedResultId.value, payload)
: await createLessonPlan(payload)
const item = saved.data || saved
savedResultId.value = item?.id || null
await loadSavedPlans()
return savedResultId.value
}
async function publishCurrent() {
if (!result.value) return
publishing.value = true
try {
const id = await ensureSavedCurrent()
if (!id) return
await publishLessonPlanAsset({
id,
...buildLessonPlanPayload(),
})
} finally {
publishing.value = false
}
}
async function publishSaved(item: any) {
if (!(await ensureLoggedIn())) return
await publishLessonPlanAsset(item)
}
async function publishLessonPlanAsset(item: any) {
const content = item.content || item
const created: any = await publishResource({
resource_type: 'lesson_plan',
title: item.title || content.title || '大单元教案',
description: buildLessonPlanDescription(item),
content_ref: `lesson_plan:${item.id}`,
file_url: '',
tags: ['大单元教案', item.subject || '通用', ...(item.key_points || content.key_points || []).slice(0, 2)],
subject: item.subject || '',
grade: item.grade || '',
is_public: 1,
})
const resource = created.data || created
ElMessage.success('已发布到资源广场')
if (resource?.id) router.push(`/resources/${resource.id}`)
}
function buildLessonPlanDescription(item: any) {
const content = item.content || item
const phases = content.phases?.length || 0
return `${item.subject || content.subject || '通用'}${item.grade ? ` · ${item.grade}` : ''}大单元教案,包含${phases || '多个'}教学环节,覆盖目标、重难点、活动、作业和评价建议。`
}
function reuseCurrent() {
if (!result.value) return
localStorage.setItem('quick_create_prompt', `参考教案《${result.value.title || form.title || '大单元教案'}》,重新生成一份原创大单元教学设计。要求保留目标结构,优化活动、评价和作业闭环。`)
result.value = null
savedResultId.value = null
window.scrollTo({ top: 0, behavior: 'smooth' })
}
async function ensureLoggedIn(options: { silent?: boolean } = {}) {
if (!userStore.isLoggedIn()) {
if (!options.silent) ElMessage.warning('请先登录后再使用教案生成功能')
await router.push('/login')
return false
}
if (!userStore.user) {
try {
await userStore.fetchProfile()
} catch {
if (!options.silent) ElMessage.warning('登录状态失效,请重新登录')
await router.push('/login')
return false
}
}
return true
}
function isAuthError(error: any) {
return error?.response?.status === 401 ||
(error?.response?.status === 403 && error?.response?.data?.detail === 'Not authenticated')
}
function handleAuthError(error: any) {
if (!isAuthError(error)) return false
userStore.clearToken()
ElMessage.warning('登录状态失效,请重新登录')
router.push('/login')
return true
}
function safeFilename(value: string) {
return (value || '资源').replace(/[\\/:*?"<>|\r\n]+/g, '_').trim() || '资源'
}
onMounted(async () => {
const quickPrompt = localStorage.getItem('quick_create_prompt')
if (quickPrompt) {
form.title = inferLessonTitle(quickPrompt)
form.extra_requirements = quickPrompt
form.subject = inferSubject(quickPrompt)
form.grade = inferGrade(quickPrompt)
quickPromptNotice.value = '已带入首页创作指令,可继续调整后生成教案。'
localStorage.removeItem('quick_create_prompt')
}
await loadSavedPlans()
await openSavedFromRoute()
})
async function openSavedFromRoute() {
const openId = Number(route.query.open)
if (!Number.isFinite(openId) || openId <= 0) return
try {
const data: any = await getLessonPlan(openId)
const item = data.data || data
previewSaved(item)
} catch {
ElMessage.warning('未能打开改编后的教案')
}
}
function inferLessonTitle(text: string) {
const match = text.match(/[《「](.*?)[》」]/)
if (match?.[1]) return match[1].slice(0, 24)
return text.replace(/生成一份|生成|大单元教学设计|教案|,.*$/g, '').slice(0, 24) || '大单元教学设计'
}
function inferSubject(text: string) {
return subjects.find(item => text.includes(item)) || ''
}
function inferGrade(text: string) {
return grades.find(item => text.includes(item)) || ''
}
</script>
<style scoped>
.gen-hero { display: flex; align-items: center; gap: 16px; margin-bottom: 24px; }
.gen-hero-icon { width: 52px; height: 52px; border-radius: 14px; display: flex; align-items: center; justify-content: center; }
.gen-hero h2 { font-size: 24px; font-weight: 700; color: var(--text-primary); }
.gen-hero p { font-size: 14px; color: var(--text-muted); }
.gen-input-section { background: #fff; border: 1px solid var(--border-color); border-radius: var(--radius-lg); padding: 24px; margin-bottom: 24px; }
.quick-notice { min-height: 38px; display: flex; align-items: center; gap: 8px; border: 1px solid #dce8de; border-radius: 12px; background: #f0fbef; color: #00543d; padding: 8px 12px; margin-bottom: 14px; font-size: 13px; font-weight: 800; }
.settings-grid { display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap: 16px; margin-bottom: 16px; }
.setting-item { display: flex; flex-direction: column; gap: 6px; }
.setting-item label { font-size: 13px; font-weight: 500; color: var(--text-secondary); }
.setting-full { margin-bottom: 16px; }
.setting-full label { display: block; font-size: 13px; font-weight: 500; color: var(--text-secondary); margin-bottom: 6px; }
.setting-full :deep(.el-textarea__inner) { background: #F8FAFC; border-radius: var(--radius-sm); }
.material-row { display: flex; flex-wrap: wrap; gap: 8px; padding: 14px 0; border-top: 1px solid var(--border-color); }
.gen-action { display: flex; justify-content: flex-end; padding-top: 16px; border-top: 1px solid var(--border-color); }
.gen-btn { display: flex; align-items: center; gap: 8px; border: none; color: #fff; padding: 10px 28px; border-radius: var(--radius-sm); font-size: 15px; font-weight: 600; cursor: pointer; transition: all 0.3s; }
.gen-btn:hover:not(:disabled) { opacity: 0.9; transform: translateY(-1px); }
.gen-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.loading-section { text-align: center; padding: 48px 0; }
.loading-bar { width: 300px; height: 4px; background: #E2E8F0; border-radius: 2px; margin: 0 auto 20px; overflow: hidden; }
.loading-bar-fill { height: 100%; width: 40%; background: var(--gradient-pink); border-radius: 2px; animation: loadingSlide 1.5s ease-in-out infinite; }
@keyframes loadingSlide { 0% { transform: translateX(-100%); } 100% { transform: translateX(350%); } }
.loading-section p { font-size: 14px; color: var(--text-secondary); }
.guide-section { animation: fadeIn 0.4s ease; }
.capability-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 14px; }
.capability-item { min-height: 78px; border: 1px solid #ead3e3; border-radius: 12px; background: #fff; padding: 14px 16px; }
.capability-item strong { display: block; color: #a01867; font-size: 15px; margin-bottom: 6px; }
.capability-item span { color: #6a7f77; font-size: 12px; line-height: 1.45; }
.guide-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
.guide-card { display: flex; flex-direction: column; gap: 10px; background: #fff; border: 1px solid var(--border-color); border-radius: var(--radius-md); padding: 18px; cursor: pointer; transition: all 0.3s; }
.guide-card:hover { box-shadow: var(--shadow-md); border-color: #EC4899; transform: translateY(-2px); }
.guide-icon { width: 36px; height: 36px; border-radius: 10px; display: flex; align-items: center; justify-content: center; }
.guide-info h4 { font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 4px; }
.guide-info p { font-size: 12px; color: var(--text-muted); line-height: 1.5; }
.guide-arrow { font-size: 12px; color: #EC4899; font-weight: 500; margin-top: auto; }
.result-section { animation: fadeInUp 0.5s ease; }
.result-header { display: flex; justify-content: space-between; align-items: center; gap: 16px; margin-bottom: 20px; }
.result-header h3 { font-size: 20px; font-weight: 600; color: var(--text-primary); }
.result-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
.save-btn { display: flex; align-items: center; gap: 6px; border: none; color: #fff; padding: 8px 20px; border-radius: var(--radius-sm); font-size: 14px; font-weight: 500; cursor: pointer; transition: opacity 0.2s; }
.save-btn:hover { opacity: 0.9; }
.tool-btn { height: 34px; display: inline-flex; align-items: center; justify-content: center; gap: 6px; border: 1px solid #ead3e3; border-radius: 8px; background: #fff; color: #a01867; padding: 0 12px; font-size: 13px; font-weight: 800; cursor: pointer; }
.tool-btn:hover { background: #fff7fc; border-color: #ec4899; }
.tool-btn.publish { border-color: #ec4899; color: #a01867; }
.tool-btn:disabled { opacity: .58; cursor: not-allowed; }
.info-tags { display: flex; flex-direction: column; gap: 12px; background: #fff; border: 1px solid var(--border-color); border-radius: var(--radius-md); padding: 20px; margin-bottom: 24px; }
.info-group { display: flex; align-items: flex-start; gap: 12px; }
.info-label { font-size: 13px; font-weight: 500; color: var(--text-muted); min-width: 60px; padding-top: 2px; }
.tag-list { display: flex; flex-wrap: wrap; gap: 6px; }
.phases-timeline { display: flex; flex-direction: column; gap: 0; }
.phase-card { display: flex; gap: 20px; }
.phase-marker { display: flex; flex-direction: column; align-items: center; flex-shrink: 0; }
.phase-dot { width: 32px; height: 32px; border-radius: 10px; display: flex; align-items: center; justify-content: center; color: #fff; font-size: 14px; font-weight: 700; }
.phase-line { width: 2px; flex: 1; background: var(--border-color); margin: 4px 0; }
.phase-content { flex: 1; background: #fff; border: 1px solid var(--border-color); border-radius: var(--radius-md); padding: 20px; margin-bottom: 16px; }
.phase-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.phase-header h4 { font-size: 16px; font-weight: 600; color: var(--text-primary); }
.phase-duration { font-size: 13px; color: var(--text-muted); background: #F8FAFC; padding: 4px 10px; border-radius: 6px; }
.phase-block { margin-top: 10px; }
.block-label { font-size: 12px; font-weight: 600; display: inline-block; padding: 2px 8px; border-radius: 4px; margin-bottom: 6px; background: #EEF2FF; color: #4F46E5; }
.block-label.teacher { background: #EEF2FF; color: #4F46E5; }
.block-label.student { background: #ECFDF5; color: #10B981; }
.phase-block ul { padding-left: 20px; }
.phase-block li { font-size: 14px; line-height: 1.8; color: var(--text-secondary); }
.homework-section { background: #fff; border: 1px solid var(--border-color); border-radius: var(--radius-md); padding: 20px; margin-top: 16px; }
.homework-section h4 { font-size: 16px; font-weight: 600; color: var(--text-primary); margin-bottom: 12px; }
.homework-section ol { padding-left: 20px; }
.homework-section li { font-size: 14px; line-height: 1.8; color: var(--text-secondary); }
.btn-loading { width: 16px; height: 16px; border: 2px solid rgba(255,255,255,0.3); border-top-color: #fff; border-radius: 50%; animation: spin 0.6s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.saved-section { margin-top: 28px; background: #fff; border: 1px solid var(--border-color); border-radius: var(--radius-lg); padding: 22px; }
.saved-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; margin-bottom: 18px; }
.saved-header h3 { font-size: 18px; font-weight: 700; color: var(--text-primary); margin-bottom: 4px; }
.saved-header p { font-size: 13px; color: var(--text-muted); }
.refresh-btn { height: 34px; display: inline-flex; align-items: center; gap: 6px; border: 1px solid #ead3e3; border-radius: 17px; background: #fff; color: #a01867; padding: 0 14px; font-weight: 700; cursor: pointer; }
.saved-loading,
.saved-empty { min-height: 120px; display: flex; align-items: center; justify-content: center; gap: 8px; color: var(--text-muted); border: 1px dashed #ead3e3; border-radius: var(--radius-md); background: #fff7fc; }
.saved-empty .el-icon { font-size: 24px; color: #d64e98; }
.saved-grid { display: grid; grid-template-columns: repeat(3, minmax(220px, 1fr)); gap: 16px; }
.saved-card { overflow: hidden; border: 1px solid #ead3e3; border-radius: 12px; background: #fff; }
.saved-cover { height: 132px; display: flex; flex-direction: column; justify-content: center; gap: 8px; margin: 5px; padding: 18px; border-radius: 9px; color: #fff; background: linear-gradient(135deg, #be185d, #ec4899); }
.saved-cover .el-icon { font-size: 22px; opacity: .85; }
.saved-cover strong { font-size: 18px; line-height: 1.25; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.saved-cover span { width: fit-content; padding: 3px 8px; border-radius: 12px; background: rgba(255,255,255,.2); font-size: 12px; font-weight: 800; }
.saved-body { padding: 12px 16px 16px; }
.saved-body h4 { font-size: 15px; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-bottom: 6px; }
.saved-body p { min-height: 36px; color: var(--text-muted); font-size: 12px; line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.saved-actions { display: flex; gap: 8px; margin-top: 12px; }
.saved-actions button { height: 32px; display: inline-flex; align-items: center; justify-content: center; gap: 5px; border: 1px solid #ead3e3; border-radius: 8px; background: #fff7fc; color: #a01867; padding: 0 12px; cursor: pointer; font-weight: 700; }
.saved-actions .danger:hover { border-color: #fca5a5; background: #fef2f2; color: #ef4444; }
@media (max-width: 1080px) {
.settings-grid,
.saved-grid { grid-template-columns: repeat(2, minmax(220px, 1fr)); }
}
@media (max-width: 760px) {
.settings-grid,
.capability-row,
.guide-cards,
.saved-grid { grid-template-columns: 1fr; }
.result-header,
.saved-header,
.result-actions { flex-direction: column; align-items: stretch; }
}
</style>
+631
View File
@@ -0,0 +1,631 @@
<template>
<div class="login-page">
<div class="login-left">
<div class="deco deco-1"></div>
<div class="deco deco-2"></div>
<div class="deco deco-3"></div>
<div class="deco deco-4"></div>
<div class="deco deco-5"></div>
<div class="login-brand">
<div class="brand-icon">
<el-icon :size="44" color="#fff"><MagicStick /></el-icon>
</div>
<h1>智教助手</h1>
<p>面向教师的AI创作平台<br/>一句话生成互动课件、教学动画、课堂游戏和智能测练</p>
</div>
<div class="login-features">
<div class="feature-item" v-for="f in features" :key="f.text">
<div class="feature-icon-wrap" :style="{ background: f.bg }">
<el-icon :size="16" color="#fff"><component :is="f.icon" /></el-icon>
</div>
<div class="feature-text">
<span class="feature-title">{{ f.text }}</span>
<span class="feature-desc">{{ f.desc }}</span>
</div>
</div>
</div>
<div class="login-bottom">
<div class="login-stats">
<div v-for="item in stats" :key="item.label">
<strong>{{ item.value }}</strong>
<span>{{ item.label }}</span>
</div>
</div>
<div class="trust-badges">
<span>支持 <strong>全学科</strong> 备课、授课、练习与评价</span>
</div>
</div>
</div>
<div class="login-right">
<div class="login-form-wrapper">
<div class="form-header">
<h2>{{ activeTab === 'login' ? '欢迎回来' : activeTab === 'forgot' ? '找回密码' : '创建账号' }}</h2>
<p class="form-subtitle">{{ activeTab === 'login' ? '登录进入AI创作工作台' : activeTab === 'forgot' ? '通过手机验证码重置密码' : '注册账号,开始生成教学内容' }}</p>
</div>
<div class="tab-switch">
<button :class="['tab-btn', { active: activeTab === 'login' }]" @click="activeTab = 'login'">登录</button>
<button :class="['tab-btn', { active: activeTab === 'register' }]" @click="activeTab = 'register'">注册</button>
<div class="tab-indicator" :class="{ right: activeTab === 'register' }"></div>
</div>
<transition name="form-fade" mode="out-in">
<el-form v-if="activeTab === 'login'" ref="loginFormRef" :model="loginForm" :rules="rules" size="large" class="login-form" key="login">
<el-form-item prop="phone">
<el-input v-model="loginForm.phone" placeholder="请输入手机号" prefix-icon="Iphone" />
</el-form-item>
<el-form-item prop="password">
<el-input v-model="loginForm.password" type="password" placeholder="请输入密码" prefix-icon="Lock" show-password />
</el-form-item>
<el-form-item>
<button type="button" class="gradient-btn" :disabled="loading" @click="handleLogin">
<span v-if="loading" class="btn-loading"></span>
{{ loading ? '登录中...' : '登 录' }}
</button>
</el-form-item>
<div class="forgot-link" @click="activeTab = 'forgot'">忘记密码?</div>
</el-form>
<el-form v-else-if="activeTab === 'register'" ref="registerFormRef" :model="registerForm" :rules="rules" size="large" class="login-form" key="register">
<el-form-item prop="phone">
<el-input v-model="registerForm.phone" placeholder="请输入手机号" prefix-icon="Iphone" />
</el-form-item>
<el-form-item prop="name">
<el-input v-model="registerForm.name" placeholder="请输入姓名" prefix-icon="User" />
</el-form-item>
<el-form-item prop="password">
<el-input v-model="registerForm.password" type="password" placeholder="请输入密码(6位以上)" prefix-icon="Lock" show-password />
</el-form-item>
<el-form-item>
<el-select v-model="registerForm.subject" placeholder="选择教授学科" style="width: 100%">
<el-option v-for="s in subjects" :key="s" :label="s" :value="s" />
</el-select>
</el-form-item>
<el-form-item>
<button type="button" class="gradient-btn" :disabled="loading" @click="handleRegister">
<span v-if="loading" class="btn-loading"></span>
{{ loading ? '注册中...' : '注 册' }}
</button>
</el-form-item>
</el-form>
<el-form v-else ref="forgotFormRef" :model="forgotForm" :rules="forgotRules" size="large" class="login-form" key="forgot">
<el-form-item prop="phone">
<el-input v-model="forgotForm.phone" placeholder="请输入注册手机号" prefix-icon="Iphone" />
</el-form-item>
<el-form-item prop="code">
<div class="code-row">
<el-input v-model="forgotForm.code" placeholder="6位验证码" prefix-icon="Key" maxlength="6" />
<button type="button" class="code-btn" :disabled="codeCooldown > 0" @click="handleSendCode">
{{ codeCooldown > 0 ? codeCooldown + 's' : '获取验证码' }}
</button>
</div>
</el-form-item>
<el-form-item prop="new_password">
<el-input v-model="forgotForm.new_password" type="password" placeholder="新密码(需含字母和数字)" prefix-icon="Lock" show-password />
</el-form-item>
<el-form-item>
<button type="button" class="gradient-btn" :disabled="loading" @click="handleReset">
<span v-if="loading" class="btn-loading"></span>
{{ loading ? '重置中...' : '重置密码' }}
</button>
</el-form-item>
<div class="forgot-link" @click="activeTab = 'login'">返回登录</div>
</el-form>
</transition>
<div class="public-entry">
<button type="button" @click="router.push('/')">先浏览创作广场</button>
<button type="button" @click="router.push('/resources')">查看资源案例</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref, reactive, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import type { FormInstance } from 'element-plus'
import { login, register, sendResetCode, resetPassword } from '@/api'
import { useUserStore } from '@/stores/user'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const activeTab = ref('login')
const loading = ref(false)
const loginFormRef = ref<FormInstance>()
const registerFormRef = ref<FormInstance>()
const forgotFormRef = ref<FormInstance>()
const codeCooldown = ref(0)
const forgotForm = reactive({ phone: '', code: '', new_password: '' })
const forgotRules = {
phone: [{ required: true, message: '请输入手机号', trigger: 'blur' }, { pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' }],
code: [{ required: true, message: '请输入验证码', trigger: 'blur' }],
new_password: [{ required: true, message: '请输入新密码', trigger: 'blur' }, { min: 6, message: '密码至少6位', trigger: 'blur' }],
}
async function handleSendCode() {
if (!/^1[3-9]\d{9}$/.test(forgotForm.phone)) {
ElMessage.warning('请先输入正确的手机号')
return
}
try {
const res: any = await sendResetCode(forgotForm.phone)
codeCooldown.value = 60
const timer = setInterval(() => {
codeCooldown.value--
if (codeCooldown.value <= 0) clearInterval(timer)
}, 1000)
ElMessage.success('验证码已发送')
} catch (error: any) {
ElMessage.error(error?.response?.data?.detail || '发送失败,请稍后重试')
}
}
async function handleReset() {
const valid = await forgotFormRef.value?.validate().catch(() => false)
if (!valid) return
if (!/[a-zA-Z]/.test(forgotForm.new_password) || !/\d/.test(forgotForm.new_password)) {
ElMessage.warning('密码必须同时含有字母和数字')
return
}
loading.value = true
try {
await resetPassword({ phone: forgotForm.phone, code: forgotForm.code, new_password: forgotForm.new_password })
ElMessage.success('密码重置成功,请使用新密码登录')
activeTab.value = 'login'
loginForm.phone = forgotForm.phone
forgotForm.code = ''
forgotForm.new_password = ''
} catch (error: any) {
ElMessage.error(error?.response?.data?.detail || '重置失败,请检查验证码')
} finally {
loading.value = false
}
}
const subjects = ['语文', '数学', '英语', '物理', '化学', '生物', '历史', '地理', '政治', '音乐', '美术', '体育', '信息技术']
const features = [
{ text: 'AI课件生成', desc: '一句话生成可编辑互动课件', icon: 'Document', bg: 'linear-gradient(135deg, #0F766E, #14B8A6)' },
{ text: '教学动画与游戏', desc: '抽象知识可视化,练习游戏化', icon: 'Film', bg: 'linear-gradient(135deg, #2563EB, #06B6D4)' },
{ text: 'AI命题与教案', desc: '从教学设计到试卷测评一站完成', icon: 'Finished', bg: 'linear-gradient(135deg, #F59E0B, #F97316)' },
{ text: '案例资源', desc: '沉淀可复用的课堂案例和素材', icon: 'FolderOpened', bg: 'linear-gradient(135deg, #334155, #64748B)' },
]
const stats = [
{ value: '8+', label: 'AI工具' },
{ value: '12', label: '学科覆盖' },
{ value: '3步', label: '生成课件' },
]
const loginForm = reactive({ phone: '', password: '' })
const registerForm = reactive({ phone: '', name: '', password: '', subject: '' })
const rules = {
phone: [{ required: true, message: '请输入手机号', trigger: 'blur' }, { pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }, { min: 6, message: '密码至少6位', trigger: 'blur' }],
name: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
}
function getRedirectPath() {
const redirect = route.query.redirect
return typeof redirect === 'string' && redirect.startsWith('/') ? redirect : '/'
}
async function handleLogin() {
const valid = await loginFormRef.value?.validate().catch(() => false)
if (!valid) return
loading.value = true
try {
const res: any = await login(loginForm)
userStore.setTokens(res.access_token, res.refresh_token)
await userStore.fetchProfile().catch(() => undefined)
ElMessage.success('登录成功')
await router.replace(getRedirectPath())
} catch (error: any) {
ElMessage.error(error?.response?.data?.detail || '登录失败,请检查账号密码或稍后重试')
} finally {
loading.value = false
}
}
async function handleRegister() {
const valid = await registerFormRef.value?.validate().catch(() => false)
if (!valid) return
loading.value = true
try {
const res: any = await register(registerForm)
userStore.setTokens(res.access_token, res.refresh_token)
await userStore.fetchProfile().catch(() => undefined)
ElMessage.success('注册成功')
await router.replace(getRedirectPath())
} catch (error: any) {
ElMessage.error(error?.response?.data?.detail || '注册失败,请稍后重试')
} finally {
loading.value = false
}
}
function syncTabFromRoute() {
activeTab.value = route.query.tab === 'register' ? 'register' : 'login'
}
onMounted(syncTabFromRoute)
watch(() => route.query.tab, syncTabFromRoute)
</script>
<style scoped>
.login-page {
min-height: 100vh;
display: flex;
background: #fff;
}
.login-left {
flex: 1;
background:
linear-gradient(135deg, rgba(15, 118, 110, 0.96), rgba(20, 33, 61, 0.96)),
radial-gradient(circle at 82% 18%, rgba(45, 212, 191, 0.34), transparent 32%);
display: flex;
flex-direction: column;
justify-content: center;
padding: 60px;
position: relative;
overflow: hidden;
}
/* Floating decorative shapes */
.deco {
position: absolute;
border-radius: 50%;
opacity: 0.08;
background: #fff;
}
.deco-1 { width: 300px; height: 300px; top: -80px; right: -60px; animation: float 8s ease-in-out infinite; }
.deco-2 { width: 200px; height: 200px; bottom: 60px; left: -40px; animation: floatSlow 10s ease-in-out infinite; }
.deco-3 { width: 100px; height: 100px; top: 40%; right: 15%; animation: float 6s ease-in-out infinite 1s; opacity: 0.06; }
.deco-4 { width: 150px; height: 150px; bottom: 10%; right: 30%; animation: floatSlow 12s ease-in-out infinite 2s; opacity: 0.05; border-radius: 30%; }
.deco-5 { width: 60px; height: 60px; top: 20%; left: 20%; animation: float 7s ease-in-out infinite 0.5s; opacity: 0.1; border-radius: 16px; }
.login-brand {
position: relative;
z-index: 1;
margin-bottom: 40px;
}
.brand-icon {
width: 72px;
height: 72px;
background: rgba(255, 255, 255, 0.2);
backdrop-filter: blur(10px);
border-radius: 18px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 24px;
}
.login-brand h1 {
font-size: 34px;
font-weight: 700;
color: #fff;
margin-bottom: 14px;
letter-spacing: 0.5px;
}
.login-brand p {
font-size: 15px;
color: rgba(255, 255, 255, 0.75);
line-height: 1.8;
max-width: 380px;
}
.login-features {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 14px;
margin-bottom: 40px;
}
.feature-item {
display: flex;
align-items: center;
gap: 14px;
padding: 12px 16px;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(8px);
border-radius: var(--radius-md);
border: 1px solid rgba(255, 255, 255, 0.12);
transition: all 0.3s;
}
.feature-item:hover {
background: rgba(255, 255, 255, 0.16);
transform: translateX(4px);
}
.feature-icon-wrap {
width: 34px;
height: 34px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.feature-text {
display: flex;
flex-direction: column;
gap: 2px;
}
.feature-title {
color: #fff;
font-size: 14px;
font-weight: 600;
}
.feature-desc {
color: rgba(255, 255, 255, 0.6);
font-size: 12px;
}
.login-bottom {
position: relative;
z-index: 1;
}
.login-stats {
display: grid;
grid-template-columns: repeat(3, minmax(82px, 1fr));
gap: 10px;
max-width: 390px;
margin-bottom: 18px;
}
.login-stats div {
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 12px;
background: rgba(255, 255, 255, 0.1);
padding: 12px;
}
.login-stats strong {
display: block;
color: #fff;
font-size: 22px;
line-height: 1;
}
.login-stats span {
display: block;
margin-top: 8px;
color: rgba(255, 255, 255, 0.62);
font-size: 12px;
font-weight: 700;
}
.trust-badges {
font-size: 13px;
color: rgba(255, 255, 255, 0.6);
}
.trust-badges strong {
color: rgba(255, 255, 255, 0.9);
}
.login-right {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
background: linear-gradient(180deg, #fff 0%, #F8FAFC 100%);
}
.login-form-wrapper {
width: 100%;
max-width: 400px;
animation: fadeInUp 0.6s ease;
}
.form-header {
margin-bottom: 28px;
}
.form-header h2 {
font-size: 26px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 6px;
}
.form-subtitle {
color: var(--text-muted);
font-size: 14px;
}
.tab-switch {
display: flex;
position: relative;
background: #F1F5F9;
border-radius: var(--radius-sm);
padding: 4px;
margin-bottom: 28px;
}
.tab-btn {
flex: 1;
padding: 10px 0;
background: none;
border: none;
font-size: 14px;
font-weight: 500;
color: var(--text-secondary);
cursor: pointer;
position: relative;
z-index: 1;
transition: color 0.3s;
}
.tab-btn.active {
color: var(--primary);
}
.tab-indicator {
position: absolute;
top: 4px;
left: 4px;
width: calc(50% - 4px);
height: calc(100% - 8px);
background: #fff;
border-radius: 6px;
box-shadow: var(--shadow-sm);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.tab-indicator.right {
transform: translateX(100%);
}
.login-form :deep(.el-input__wrapper) {
padding: 4px 12px;
}
.forgot-link {
text-align: right;
font-size: 13px;
color: var(--brand-primary, #4f46e5);
cursor: pointer;
margin-top: -4px;
margin-bottom: 8px;
transition: opacity .2s;
}
.forgot-link:hover { opacity: .75; }
.code-row { display: flex; gap: 10px; width: 100%; }
.code-row :deep(.el-input) { flex: 1; }
.code-btn {
flex-shrink: 0;
white-space: nowrap;
padding: 0 16px;
border: 1px solid #c7d2fe;
border-radius: 8px;
background: #f5f3ff;
color: #4f46e5;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all .2s;
}
.code-btn:hover:not(:disabled) { background: #ede9fe; border-color: #a5b4fc; }
.code-btn:disabled { opacity: .5; cursor: not-allowed; }
.public-entry {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
margin-top: 10px;
}
.public-entry button {
height: 38px;
border: 1px solid #dce8de;
border-radius: 10px;
background: #fff;
color: #31584d;
font-weight: 800;
cursor: pointer;
}
.public-entry button:hover {
border-color: #a8cfab;
background: #f6fbf6;
color: #00543d;
}
.gradient-btn {
width: 100%;
height: 46px;
background: var(--gradient);
border: none;
border-radius: var(--radius-sm);
color: #fff;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.gradient-btn:hover:not(:disabled) {
opacity: 0.9;
transform: translateY(-1px);
box-shadow: 0 6px 20px rgba(79, 70, 229, 0.4);
}
.gradient-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.btn-loading {
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
.form-fade-enter-active,
.form-fade-leave-active {
transition: all 0.25s ease;
}
.form-fade-enter-from {
opacity: 0;
transform: translateX(10px);
}
.form-fade-leave-to {
opacity: 0;
transform: translateX(-10px);
}
@media (max-width: 920px) {
.login-page {
flex-direction: column;
}
.login-left,
.login-right {
padding: 34px 22px;
}
.login-brand p {
max-width: none;
}
}
@media (max-width: 520px) {
.login-stats,
.public-entry {
grid-template-columns: 1fr;
}
}
</style>
+392
View File
@@ -0,0 +1,392 @@
<template>
<div class="materials-page">
<section class="materials-hero">
<div>
<span class="eyebrow">我的素材库</span>
<h1>沉淀教材、课件、试题和课堂资料</h1>
<p>上传解析后的材料会自动保存,可按学科、类型和关键词检索,并一键带入课件、组题、教案、命题或数据回收。</p>
</div>
<div class="hero-actions">
<button class="upload-btn secondary" type="button" :disabled="uploading" @click="pickFile('image')">
<el-icon><Camera /></el-icon>
{{ uploadingMode === 'image' ? '识别中' : '拍照/图片' }}
</button>
<button class="upload-btn" type="button" :disabled="uploading" @click="pickFile('file')">
<el-icon><Upload /></el-icon>
{{ uploadingMode === 'file' ? '解析中' : '上传解析' }}
</button>
</div>
<input ref="fileInputRef" type="file" class="hidden-input" :accept="uploadAccept" @change="handleFileChange" />
</section>
<section class="filter-panel">
<div class="segmented">
<button
v-for="type in materialTypes"
:key="type.value"
type="button"
:class="{ active: filters.material_type === type.value }"
@click="setType(type.value)"
>
{{ type.label }}
</button>
</div>
<div class="filter-line">
<el-select v-model="filters.subject" placeholder="全部学科" clearable @change="loadData" style="width: 150px">
<el-option v-for="s in subjects" :key="s" :label="s" :value="s" />
</el-select>
<div class="search-box">
<el-icon><Search /></el-icon>
<input v-model="filters.keyword" placeholder="搜索素材标题、文件名或正文摘要" @keyup.enter="loadData" />
<button v-if="filters.keyword" type="button" @click="clearKeyword">清除</button>
</div>
</div>
</section>
<section class="material-grid" v-loading="loading">
<article v-for="item in materials" :key="item.id" class="material-card">
<div class="material-cover" :class="coverClass(item)">
<span>{{ typeLabel(item.material_type) }}</span>
<strong>{{ item.title }}</strong>
<em>{{ item.subject || '通用' }} · {{ item.grade || formatFileSize(item.size) }}</em>
</div>
<div class="material-body">
<div class="material-title-row">
<h3>{{ item.title }}</h3>
<el-dropdown trigger="click" @command="(cmd: string) => reuseMaterial(item, cmd)">
<button class="more-btn" type="button"><el-icon><MoreFilled /></el-icon></button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="courseware">生成课件</el-dropdown-item>
<el-dropdown-item command="animation">生成动画</el-dropdown-item>
<el-dropdown-item command="exercise">生成练习</el-dropdown-item>
<el-dropdown-item command="lesson">生成教案</el-dropdown-item>
<el-dropdown-item command="exam">智能命题</el-dropdown-item>
<el-dropdown-item command="classroom">数据回收</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<p>{{ item.summary || '暂无摘要' }}</p>
<div class="material-meta">
<span>{{ item.filename }}</span>
<span>{{ formatCount(item.char_count) }}字</span>
</div>
<div class="tag-line" v-if="item.tags?.length">
<el-tag v-for="tag in item.tags" :key="tag" size="small" effect="plain">{{ tag }}</el-tag>
</div>
<div class="card-actions">
<button type="button" @click="openEdit(item)">编辑</button>
<button type="button" class="primary" @click="reuseMaterial(item, 'courseware')">带入课件</button>
<button type="button" @click="reuseMaterial(item, 'animation')">生成动画</button>
<el-popconfirm title="确定删除这个素材?" @confirm="removeMaterial(item.id)">
<template #reference>
<button type="button" class="danger">删除</button>
</template>
</el-popconfirm>
</div>
</div>
</article>
</section>
<div v-if="!loading && materials.length === 0" class="empty-state">
<el-icon><FolderOpened /></el-icon>
<strong>还没有素材</strong>
<span>上传教材、课件、试题或图片,解析后会自动进入素材库。</span>
<button type="button" @click="pickFile('file')">上传第一个素材</button>
</div>
<el-dialog v-model="showEdit" title="编辑素材信息" width="640px" :close-on-click-modal="false">
<el-form :model="editForm" label-position="top" class="edit-form">
<el-form-item label="素材标题">
<el-input v-model="editForm.title" />
</el-form-item>
<div class="edit-grid">
<el-form-item label="学科">
<el-select v-model="editForm.subject" filterable allow-create clearable style="width: 100%">
<el-option v-for="s in subjects" :key="s" :label="s" :value="s" />
</el-select>
</el-form-item>
<el-form-item label="年级">
<el-input v-model="editForm.grade" placeholder="例如:六年级下册" />
</el-form-item>
</div>
<el-form-item label="标签">
<el-select v-model="editForm.tags" multiple filterable allow-create placeholder="输入标签后回车" style="width: 100%" />
</el-form-item>
<el-form-item label="摘要">
<el-input v-model="editForm.summary" type="textarea" :rows="8" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showEdit = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="saveEdit">保存修改</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { parseMaterial } from '@/api/ai'
import { deleteMaterial, getMaterials, updateMaterial } from '@/api/material'
import { useUserStore } from '@/stores/user'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const fileInputRef = ref<HTMLInputElement>()
const uploadAccept = ref('.txt,.md,.csv,.json,.docx,.pptx,.pdf,.xlsx,image/*')
const uploadMode = ref<'file' | 'image'>('file')
const uploadingMode = ref<'file' | 'image' | ''>('')
const loading = ref(false)
const uploading = ref(false)
const saving = ref(false)
const showEdit = ref(false)
const materials = ref<any[]>([])
const subjects = ['语文', '数学', '英语', '物理', '化学', '生物', '历史', '地理', '信息科技', '课堂工具']
const materialTypes = [
{ value: '', label: '全部' },
{ value: 'txt', label: '文本' },
{ value: 'docx', label: 'Word' },
{ value: 'pptx', label: 'PPT' },
{ value: 'image', label: '图片' },
{ value: 'csv', label: '表格' },
{ value: 'json', label: '结构化' },
]
const filters = reactive({ material_type: '', subject: '', keyword: '' })
const editForm = reactive({
id: 0,
title: '',
subject: '',
grade: '',
tags: [] as string[],
summary: '',
})
onMounted(() => {
filters.keyword = typeof route.query.keyword === 'string' ? route.query.keyword : ''
loadData()
})
watch(() => route.query.keyword, (keyword) => {
filters.keyword = typeof keyword === 'string' ? keyword : ''
loadData()
})
function setType(type: string) {
filters.material_type = type
loadData()
}
function clearKeyword() {
filters.keyword = ''
loadData()
}
async function loadData() {
if (!ensureLoggedIn(false)) return
loading.value = true
try {
const data: any = await getMaterials({
material_type: filters.material_type || undefined,
subject: filters.subject || undefined,
keyword: filters.keyword || undefined,
limit: 100,
})
materials.value = Array.isArray(data?.data) ? data.data : Array.isArray(data) ? data : []
} finally {
loading.value = false
}
}
function pickFile(type: 'file' | 'image') {
if (!ensureLoggedIn(true)) return
uploadMode.value = type
uploadAccept.value = type === 'image' ? 'image/*' : '.txt,.md,.csv,.json,.docx,.pptx,.pdf,.xlsx,image/*'
if (fileInputRef.value) {
if (type === 'image') fileInputRef.value.setAttribute('capture', 'environment')
else fileInputRef.value.removeAttribute('capture')
}
fileInputRef.value?.click()
}
async function handleFileChange(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0]
if (!file) return
uploading.value = true
uploadingMode.value = uploadMode.value
try {
const parsed: any = await parseMaterial(file)
userStore.refreshCreditsFromResponse(parsed)
const data = parsed.data || parsed
ElMessage.success(data.material_type === 'image' ? '图片素材已保存,可带入生成' : '素材已解析并保存')
await loadData()
} finally {
uploading.value = false
uploadingMode.value = ''
;(event.target as HTMLInputElement).value = ''
}
}
function openEdit(item: any) {
editForm.id = item.id
editForm.title = item.title || item.filename || ''
editForm.subject = item.subject || ''
editForm.grade = item.grade || ''
editForm.tags = Array.isArray(item.tags) ? [...item.tags] : []
editForm.summary = item.summary || ''
showEdit.value = true
}
async function saveEdit() {
if (!editForm.id) return
saving.value = true
try {
await updateMaterial(editForm.id, {
title: editForm.title,
subject: editForm.subject,
grade: editForm.grade,
tags: editForm.tags,
summary: editForm.summary,
})
ElMessage.success('素材已更新')
showEdit.value = false
await loadData()
} finally {
saving.value = false
}
}
async function removeMaterial(id: number) {
await deleteMaterial(id)
ElMessage.success('素材已删除')
await loadData()
}
function reuseMaterial(item: any, mode: string) {
const prompt = buildPrompt(item, mode)
localStorage.setItem('quick_create_prompt', prompt)
if (mode === 'exam') router.push('/exam')
else if (mode === 'exercise') router.push('/exercise')
else if (mode === 'lesson') router.push('/lesson-plan')
else if (mode === 'animation') router.push('/animation')
else if (mode === 'classroom') router.push('/classroom')
else router.push('/courseware/create')
}
function buildPrompt(item: any, mode: string) {
const imageHint = item.material_type === 'image'
? '\n\n【图片使用要求】\n这是一份拍照/图片素材,请优先围绕图片中的教材页、题目、板书、图表或课堂场景设计内容;如果图片文字未完全识别,请保留“请教师补充图片关键文字”的提示位。'
: ''
const base = `请基于素材《${item.title || item.filename}》进行教学创作。\n\n【素材摘要】\n${item.summary || '暂无摘要,请结合素材标题和类型进行创作。'}${imageHint}`
if (mode === 'exam') return `${base}\n\n请据此生成一套原创试题,覆盖核心知识点,附答案和解析。`
if (mode === 'exercise') return `${base}\n\n请据此生成可课堂互动的练习/教学游戏,题目有梯度并附解析。`
if (mode === 'lesson') return `${base}\n\n请据此生成结构化大单元教案,包含目标、重难点、活动、评价和作业。`
if (mode === 'animation') return `${base}\n\n请据此生成一个课堂教学动画,突出知识形成过程、关键变化和可观察的步骤标注。`
if (mode === 'classroom') return `${base}\n\n请据此设计一个课堂数据回收活动,用于收集学生理解、选择或反馈。`
return `${base}\n\n请据此生成专业级互动课件,包含导入、讲解、互动练习和课堂总结。`
}
function ensureLoggedIn(redirect: boolean) {
if (localStorage.getItem('access_token')) return true
ElMessage.warning('请先登录后使用素材库')
if (redirect) router.push({ path: '/login', query: { redirect: '/materials' } })
return false
}
function typeLabel(type: string) {
const map: Record<string, string> = {
docx: 'Word',
pptx: 'PPT',
csv: '表格',
json: '结构化',
md: 'Markdown',
txt: '文本',
text: '文本',
image: '图片',
}
return map[type] || '素材'
}
function coverClass(item: any) {
if (item.material_type === 'image') return 'cover-mint'
if (item.material_type === 'pptx') return 'cover-blue'
if (item.material_type === 'docx') return 'cover-green'
if (item.material_type === 'csv' || item.material_type === 'json') return 'cover-yellow'
return 'cover-cream'
}
function formatFileSize(size: number) {
const n = Number(size || 0)
if (n < 1024) return `${n}B`
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}KB`
return `${(n / 1024 / 1024).toFixed(1)}MB`
}
function formatCount(value: number) {
const n = Number(value || 0)
if (n >= 10000) return `${(n / 10000).toFixed(1)}万`
return String(n)
}
</script>
<style scoped>
.materials-page { min-height: 100vh; background: #f7faf6; padding: 26px 36px 40px; color: #173f35; }
.materials-hero { max-width: 1280px; margin: 0 auto 18px; display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; border: 1px solid #dce8de; border-radius: 14px; background: #fff; padding: 24px; }
.eyebrow { color: #00724f; font-size: 13px; font-weight: 900; }
.materials-hero h1 { margin: 8px 0 8px; color: #173f35; font-size: 28px; line-height: 1.2; }
.materials-hero p { max-width: 760px; color: #6a7f77; line-height: 1.7; }
.hero-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; justify-content: flex-end; }
.upload-btn { height: 38px; display: inline-flex; align-items: center; gap: 7px; border: none; border-radius: 19px; background: #00543d; color: #fff; padding: 0 18px; font-weight: 900; cursor: pointer; white-space: nowrap; }
.upload-btn.secondary { border: 1px solid #9ed0aa; background: #eaf7e9; color: #00543d; }
.upload-btn:disabled { opacity: .62; cursor: not-allowed; }
.hidden-input { display: none; }
.filter-panel { max-width: 1280px; margin: 0 auto 18px; border: 1px solid #dce8de; border-radius: 14px; background: #fff; padding: 14px; }
.segmented { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; }
.segmented button { height: 32px; border: 1px solid #dbe8de; border-radius: 16px; background: #fff; color: #51665f; padding: 0 13px; cursor: pointer; }
.segmented button.active, .segmented button:hover { border-color: #9ed0aa; background: #eaf7e9; color: #00543d; font-weight: 900; }
.filter-line { display: flex; align-items: center; gap: 10px; }
.search-box { height: 34px; flex: 1; min-width: 220px; display: flex; align-items: center; gap: 8px; border: 1px solid #dbe8de; border-radius: 17px; padding: 0 10px; background: #f8faf8; }
.search-box input { flex: 1; min-width: 0; border: none; outline: none; background: transparent; color: #173f35; }
.search-box button { border: none; background: transparent; color: #00724f; cursor: pointer; font-weight: 800; }
.material-grid { max-width: 1280px; margin: 0 auto; display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 14px; }
.material-card { overflow: hidden; border: 1px solid #dce8de; border-radius: 14px; background: #fff; }
.material-cover { min-height: 148px; display: flex; flex-direction: column; justify-content: flex-end; padding: 18px; }
.material-cover span { width: fit-content; border-radius: 999px; background: rgba(255,255,255,.86); color: #00543d; padding: 4px 10px; font-size: 12px; font-weight: 900; }
.material-cover strong { margin-top: 12px; color: #173f35; font-size: 19px; line-height: 1.25; }
.material-cover em { margin-top: 7px; color: #51665f; font-style: normal; font-size: 13px; }
.cover-green { background: linear-gradient(135deg, #dff7e5, #9ed0aa); }
.cover-blue { background: linear-gradient(135deg, #dbe8ff, #a8c7ff); }
.cover-mint { background: linear-gradient(135deg, #d7f8ef, #8be1c6); }
.cover-yellow { background: linear-gradient(135deg, #fff1a1, #ffd84d); }
.cover-cream { background: linear-gradient(135deg, #f6edd3, #e6d0a3); }
.material-body { padding: 16px; }
.material-title-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
.material-title-row h3 { color: #173f35; font-size: 16px; line-height: 1.35; }
.more-btn { width: 30px; height: 30px; border: 1px solid #dbe8de; border-radius: 8px; background: #fff; color: #31584d; cursor: pointer; }
.material-body p { height: 86px; margin-top: 8px; color: #6a7f77; font-size: 13px; line-height: 1.6; display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; }
.material-meta { margin-top: 12px; display: flex; justify-content: space-between; gap: 10px; color: #87958f; font-size: 12px; }
.material-meta span:first-child { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tag-line { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 10px; }
.card-actions { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin-top: 14px; }
.card-actions button { height: 32px; border: 1px solid #dbe8de; border-radius: 8px; background: #fff; color: #31584d; font-weight: 900; cursor: pointer; }
.card-actions button.primary { border-color: #00543d; background: #00543d; color: #fff; }
.card-actions button.danger { color: #b42318; }
.empty-state { max-width: 1280px; min-height: 320px; margin: 0 auto; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; border: 1px dashed #cfe0d3; border-radius: 14px; background: #fff; color: #75867f; }
.empty-state .el-icon { font-size: 40px; color: #9aaba4; }
.empty-state strong { color: #173f35; }
.empty-state button { border: none; border-radius: 18px; background: #00543d; color: #fff; padding: 8px 18px; font-weight: 900; cursor: pointer; }
.edit-grid { display: grid; grid-template-columns: repeat(2, minmax(180px, 1fr)); gap: 0 14px; }
@media (max-width: 760px) {
.materials-page { padding: 16px; }
.materials-hero { align-items: flex-start; flex-direction: column; }
.hero-actions { width: 100%; justify-content: flex-start; }
.filter-line { align-items: stretch; flex-direction: column; }
.search-box { width: 100%; }
.card-actions { grid-template-columns: repeat(2, 1fr); }
.edit-grid { grid-template-columns: 1fr; }
}
</style>
+366
View File
@@ -0,0 +1,366 @@
<template>
<div class="page-container">
<div class="gen-hero">
<div class="gen-hero-icon" style="background: linear-gradient(135deg, #6366f1, #8b5cf6)">
<el-icon :size="28" color="#fff"><Share /></el-icon>
</div>
<div>
<h2>AI 思维导图</h2>
<p>输入主题,一键生成层次清晰的知识思维导图,支持折叠、导出与保存</p>
</div>
</div>
<div class="mm-layout">
<div class="mm-input-panel">
<div class="panel-header">
<h3>生成导图</h3>
</div>
<el-form label-position="top" @submit.prevent="handleGenerate">
<el-form-item label="主题">
<el-input
v-model="form.topic"
placeholder="例:光合作用、一元一次方程、唐宋八大家、地球公转与四季"
maxlength="120"
clearable
/>
</el-form-item>
<div class="form-row">
<el-form-item label="学科" class="flex1">
<el-select v-model="form.subject" placeholder="选学科" filterable allow-create>
<el-option v-for="s in subjects" :key="s" :label="s" :value="s" />
</el-select>
</el-form-item>
<el-form-item label="学段" class="flex1">
<el-select v-model="form.grade" placeholder="选学段" filterable clearable allow-create>
<el-option v-for="g in grades" :key="g" :label="g" :value="g" />
</el-select>
</el-form-item>
</div>
<div class="quick-topics">
<span class="quick-label">试试:</span>
<button
v-for="t in quickTopics"
:key="t"
type="button"
class="quick-chip"
@click="applyQuick(t)"
>{{ t }}</button>
</div>
<el-button
type="primary"
:loading="generating"
:disabled="!form.topic.trim()"
@click="handleGenerate"
class="gen-btn"
>
<el-icon><MagicStick /></el-icon>
生成导图
</el-button>
</el-form>
<div class="history-section" v-if="history.length">
<div class="panel-header">
<h3>我的导图</h3>
<span class="count-badge">{{ history.length }}</span>
</div>
<div class="history-list">
<div v-for="m in history" :key="m.id" class="history-item" @click="loadSaved(m)">
<div class="hist-title">{{ m.title }}</div>
<div class="hist-meta">
<span>{{ m.subject }}</span>
<el-button text size="small" @click.stop="removeSaved(m.id)" class="del-btn">
<el-icon><Delete /></el-icon>
</el-button>
</div>
</div>
</div>
</div>
</div>
<div class="mm-canvas-panel">
<div class="canvas-toolbar" v-if="result">
<h3 :title="result.title">{{ result.title }}</h3>
<div class="toolbar-actions">
<el-button text @click="expandAll(true)">全部展开</el-button>
<el-button text @click="expandAll(false)">全部折叠</el-button>
<el-button type="primary" plain @click="exportHtml">
<el-icon><Download /></el-icon> 导出HTML
</el-button>
<el-button type="success" plain :loading="saving" @click="saveMap">
<el-icon><Star /></el-icon> 保存
</el-button>
</div>
</div>
<el-empty v-if="!result && !generating" description="生成后在此展示知识思维导图" :image-size="80">
<template #image>
<el-icon :size="60" color="#c0c4cc"><Share /></el-icon>
</template>
</el-empty>
<div v-if="generating" class="loading-block">
<el-icon class="loading-spin"><Loading /></el-icon>
<span>AI 正在梳理知识结构……</span>
<span style="font-size:13px;color:var(--el-text-color-secondary);opacity:.65;margin-top:8px">模型深度推理中,通常需要 1-2 分钟,请耐心等待</span>
</div>
<div v-if="result && !generating" class="canvas-scroll">
<div class="mind-stage">
<div class="root-node">{{ result.title }}</div>
<ul class="root-branches">
<MindNode
v-for="(branch, i) in result.nodes"
:key="i"
:node="branch"
:level="1"
:branch-color="branch.color || palette[i % palette.length]"
/>
</ul>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, provide, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import {
Share, MagicStick, Download, Star, Delete, Loading,
} from '@element-plus/icons-vue'
import MindNode from '@/components/common/MindNode.vue'
import { useUserStore } from '@/stores/user'
import {
aiGenerateMindMap, getMindMaps, getMindMap, createMindMap, deleteMindMap,
type MindMapNode, type MindMapData,
} from '@/api/mindmap'
const userStore = useUserStore()
const route = useRoute()
const subjects = ['语文', '数学', '英语', '物理', '化学', '生物', '历史', '地理', '政治', '科学', '综合']
const grades = ['一年级', '二年级', '三年级', '四年级', '五年级', '六年级', '初一', '初二', '初三', '高一', '高二', '高三']
const palette = ['#00a870', '#2563eb', '#d97706', '#db2777', '#7c3aed', '#0891b2', '#4f46e5', '#ea580c']
const quickTopics = ['光合作用', '一元一次方程', '光的折射', '细胞结构与功能', '唐朝由盛转衰']
const form = reactive({ topic: '', subject: '综合', grade: '' })
const generating = ref(false)
const saving = ref(false)
const result = ref<MindMapData | null>(null)
const history = ref<any[]>([])
// provide a shared expand signal so parent can collapse/expand all nodes
const expandVersion = ref(0)
const expandMode = ref<'open' | 'close'>('open')
provide('mmExpand', { expandVersion, expandMode })
async function handleGenerate() {
if (!form.topic.trim()) return
generating.value = true
result.value = null
try {
const res: any = await aiGenerateMindMap({
topic: form.topic.trim(),
subject: form.subject,
grade: form.grade,
})
if (res?.success && res?.data) {
result.value = res.data
userStore.refreshCreditsFromResponse(res)
} else {
ElMessage.error(res?.detail || '生成失败,请稍后重试')
}
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || '生成失败,请稍后重试')
} finally {
generating.value = false
}
}
function applyQuick(t: string) {
form.topic = t
}
function expandAll(open: boolean) {
expandMode.value = open ? 'open' : 'close'
expandVersion.value++
}
async function loadHistory() {
try {
const res: any = await getMindMaps({ limit: 50 })
history.value = res || []
} catch {
history.value = []
}
}
function loadSaved(m: any) {
result.value = { title: m.title, nodes: m.nodes || [] }
}
async function removeSaved(id: number) {
try {
await deleteMindMap(id)
history.value = history.value.filter((x: any) => x.id !== id)
ElMessage.success('已删除')
} catch {
ElMessage.error('删除失败')
}
}
async function saveMap() {
if (!result.value) return
saving.value = true
try {
await createMindMap({
title: result.value.title,
subject: form.subject,
nodes: result.value.nodes,
})
ElMessage.success('已保存到我的导图')
await loadHistory()
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || '保存失败')
} finally {
saving.value = false
}
}
function esc(s: string): string {
return s.replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
function buildExportHtml(): string {
if (!result.value) return ''
const d = result.value
const nodeHtml = (n: MindMapNode, lvl: number, color: string): string => {
const titleEsc = esc(n.title)
const has = Array.isArray(n.children) && n.children.length
let style = ''
let cls = 'mm-card'
if (lvl === 1) {
cls += ' branch'
style = ` style="border-color:${color};background:${color}14"`
}
let inner = `<div class="${cls}"${style}><span class="mm-title">${titleEsc}</span></div>`
if (has) {
const kids = n.children!.map((c) => nodeHtml(c, lvl + 1, color)).join('')
inner += `<ul class="mm-children">${kids}</ul>`
}
return `<li>${inner}</li>`
}
const branches = d.nodes.map((b, i) => nodeHtml(b, 1, b.color || palette[i % palette.length])).join('')
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${esc(d.title)} - 思维导图</title>
<style>
*{box-sizing:border-box;margin:0;padding:0;font-family:'PingFang SC','Microsoft YaHei',-apple-system,sans-serif;}
body{background:#f7f8fa;padding:40px 20px;color:#1f2937;}
.wrap{max-width:960px;margin:0 auto;background:#fff;border-radius:16px;padding:36px;box-shadow:0 8px 30px rgba(0,0,0,.08);}
.title-bar{display:flex;align-items:center;gap:12px;margin-bottom:28px;}
.title-bar .dot{width:40px;height:40px;border-radius:11px;background:linear-gradient(135deg,#6366f1,#8b5cf6);display:flex;align-items:center;justify-content:center;color:#fff;font-size:20px;font-weight:700;}
.title-bar h1{font-size:22px;font-weight:700;}
.root-node{display:inline-block;padding:14px 28px;border-radius:12px;background:linear-gradient(135deg,#4f46e5,#6366f1);color:#fff;font-size:20px;font-weight:700;margin:0 0 24px;box-shadow:0 6px 18px rgba(79,70,229,.35);}
ul{list-style:none;}
.mm-children{display:flex;flex-direction:column;gap:10px;padding:10px 0 10px 14px;position:relative;}
.mm-children::before{content:'';position:absolute;left:0;top:0;bottom:0;width:2px;background:#e5e7eb;}
.mm-node,.root-list>li{display:flex;align-items:flex-start;position:relative;padding-left:26px;}
.root-list>li{padding-left:0;}
.root-list>li::before{display:none;}
.mm-children>li::before{content:'';position:absolute;left:0;top:24px;width:22px;height:2px;background:#e5e7eb;}
.mm-card{display:inline-block;padding:10px 18px;border-radius:10px;border:1.5px solid #e5e7eb;background:#fff;font-size:14px;font-weight:600;white-space:nowrap;}
.mm-card.branch{font-size:15px;font-weight:700;border-width:2px;}
</style>
</head>
<body>
<div class="wrap">
<div class="title-bar"><div class="dot">导</div><h1>${esc(d.title)}</h1></div>
<div class="root-node">${esc(d.title)}</div>
<ul class="root-list">
${branches}
</ul>
</div>
</body>
</html>`
}
function exportHtml() {
if (!result.value) return
const html = buildExportHtml()
const blob = new Blob([html], { type: 'text/html;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
const safeName = (result.value.title || '思维导图').replace(/[\\/:*?"<>|]/g, '_').slice(0, 40)
a.download = `${safeName}-思维导图.html`
a.click()
URL.revokeObjectURL(url)
}
onMounted(async () => {
await loadHistory()
const openId = route.query.open
if (openId) {
try {
const res: any = await getMindMap(Number(openId))
if (res?.id) {
result.value = { title: res.title, nodes: res.nodes || [] }
}
} catch {
// ignore load error
}
}
})
</script>
<style scoped>
.gen-hero-icon { width: 52px; height: 52px; border-radius: 14px; display: flex; align-items: center; justify-content: center; }
.gen-hero { display: flex; align-items: center; gap: 16px; margin-bottom: 24px; }
.gen-hero h2 { font-size: 24px; font-weight: 700; color: var(--text-primary); }
.gen-hero p { font-size: 14px; color: var(--text-muted); }
.mm-layout { display: grid; grid-template-columns: 360px 1fr; gap: 24px; align-items: start; }
.mm-input-panel { background: #fff; border-radius: 16px; padding: 24px; box-shadow: 0 2px 12px rgba(0,0,0,.04); position: sticky; top: 20px; }
.panel-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.panel-header h3 { font-size: 16px; font-weight: 600; color: var(--text-primary); }
.form-row { display: flex; gap: 12px; }
.flex1 { flex: 1; }
.gen-btn { width: 100%; margin-top: 8px; }
.quick-topics { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 4px 0 16px; }
.quick-label { font-size: 12px; color: var(--text-muted); }
.quick-chip { border: 1px solid #e5e7eb; background: #f9fafb; color: #374151; font-size: 12px; padding: 4px 12px; border-radius: 14px; cursor: pointer; transition: all .15s; }
.quick-chip:hover { border-color: #6366f1; color: #6366f1; background: #f5f3ff; }
.history-section { margin-top: 28px; }
.count-badge { background: #f3f4f6; color: #6366f1; font-size: 12px; font-weight: 600; padding: 2px 10px; border-radius: 10px; }
.history-list { display: flex; flex-direction: column; gap: 8px; max-height: 340px; overflow-y: auto; }
.history-item { border: 1px solid #eee; border-radius: 10px; padding: 12px 14px; cursor: pointer; transition: all .15s; }
.history-item:hover { border-color: #c4b5fd; background: #faf8ff; }
.hist-title { font-size: 14px; font-weight: 600; color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.hist-meta { display: flex; justify-content: space-between; align-items: center; margin-top: 6px; }
.hist-meta span { font-size: 12px; color: var(--text-muted); }
.del-btn { color: #ef4444; }
.mm-canvas-panel { background: #fff; border-radius: 16px; box-shadow: 0 2px 12px rgba(0,0,0,.04); min-height: 520px; display: flex; flex-direction: column; overflow: hidden; }
.canvas-toolbar { display: flex; justify-content: space-between; align-items: center; padding: 18px 24px; border-bottom: 1px solid #f0f0f0; flex-shrink: 0; gap: 12px; }
.canvas-toolbar h3 { font-size: 17px; font-weight: 700; color: var(--text-primary); max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.toolbar-actions { display: flex; gap: 6px; align-items: center; flex-shrink: 0; }
.loading-block { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 16px; color: var(--text-muted); font-size: 14px; }
.loading-spin { font-size: 32px; animation: spin 1s linear infinite; color: #6366f1; }
@keyframes spin { to { transform: rotate(360deg); } }
.canvas-scroll { flex: 1; overflow: auto; padding: 28px 24px; }
.mind-stage { display: flex; align-items: flex-start; gap: 28px; min-width: max-content; }
.root-node { flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; padding: 16px 28px; border-radius: 14px; background: linear-gradient(135deg, #4f46e5, #6366f1); color: #fff; font-size: 20px; font-weight: 700; box-shadow: 0 8px 22px rgba(79,70,229,.32); white-space: nowrap; }
.root-branches { list-style: none; display: flex; flex-direction: column; gap: 16px; padding: 0; margin: 0; }
@media (max-width: 900px) {
.mm-layout { grid-template-columns: 1fr; }
.mm-input-panel { position: static; }
.canvas-toolbar { flex-direction: column; align-items: stretch; }
}
</style>
+204
View File
@@ -0,0 +1,204 @@
<template>
<div class="not-found">
<div class="nf-inner">
<div class="nf-code">404</div>
<h1 class="nf-title">页面走丢了</h1>
<p class="nf-desc">你访问的页面不存在或已被移动,可能是链接输入有误。</p>
<div class="nf-actions">
<button class="nf-btn primary" type="button" @click="goHome">
<el-icon><HomeFilled /></el-icon>
<span>返回首页</span>
</button>
<button class="nf-btn ghost" type="button" @click="goBack">
<el-icon><ArrowLeft /></el-icon>
<span>返回上一页</span>
</button>
</div>
<div class="nf-links">
<span>或前往:</span>
<router-link to="/courseware/create">AI课件</router-link>
<router-link to="/animation">教学动画</router-link>
<router-link to="/resources">资源广场</router-link>
<router-link to="/community">模板社区</router-link>
</div>
</div>
<div class="nf-deco nf-deco-1"></div>
<div class="nf-deco nf-deco-2"></div>
<div class="nf-deco nf-deco-3"></div>
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { HomeFilled, ArrowLeft } from '@element-plus/icons-vue'
const router = useRouter()
function goHome() {
router.push('/')
}
function goBack() {
if (window.history.length > 1) {
router.back()
} else {
router.push('/')
}
}
</script>
<style scoped>
.not-found {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #f5f7fa 0%, #e8f5ea 100%);
position: relative;
overflow: hidden;
padding: 24px;
}
.nf-inner {
text-align: center;
position: relative;
z-index: 2;
max-width: 520px;
}
.nf-code {
font-size: 120px;
font-weight: 900;
line-height: 1;
background: linear-gradient(135deg, #0f766e 0%, #14b8a6 52%, #2dd4bf 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
letter-spacing: -2px;
margin-bottom: 8px;
}
.nf-title {
font-size: 26px;
font-weight: 800;
color: #14213d;
margin-bottom: 12px;
}
.nf-desc {
font-size: 15px;
color: #6b7280;
line-height: 1.6;
margin-bottom: 28px;
}
.nf-actions {
display: flex;
gap: 12px;
justify-content: center;
margin-bottom: 28px;
}
.nf-btn {
height: 44px;
padding: 0 22px;
display: inline-flex;
align-items: center;
gap: 8px;
border: none;
border-radius: 10px;
font-size: 15px;
font-weight: 700;
cursor: pointer;
transition: all 0.18s;
}
.nf-btn.primary {
background: linear-gradient(135deg, #0f766e 0%, #14b8a6 100%);
color: #fff;
box-shadow: 0 6px 16px rgba(15, 118, 110, 0.3);
}
.nf-btn.primary:hover {
transform: translateY(-2px);
box-shadow: 0 10px 24px rgba(15, 118, 110, 0.4);
}
.nf-btn.ghost {
background: #fff;
color: #14213d;
border: 1px solid #dce8de;
}
.nf-btn.ghost:hover {
background: #f5f7fa;
border-color: #0f766e;
color: #0f766e;
}
.nf-links {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
flex-wrap: wrap;
font-size: 13px;
color: #8a94a6;
}
.nf-links span {
color: #8a94a6;
}
.nf-links a {
color: #0f766e;
text-decoration: none;
font-weight: 600;
padding: 4px 8px;
border-radius: 6px;
transition: background 0.15s;
}
.nf-links a:hover {
background: #e8f5ea;
}
.nf-deco {
position: absolute;
border-radius: 50%;
filter: blur(40px);
opacity: 0.5;
z-index: 1;
}
.nf-deco-1 {
width: 280px;
height: 280px;
background: rgba(20, 184, 166, 0.18);
top: -80px;
left: -80px;
}
.nf-deco-2 {
width: 220px;
height: 220px;
background: rgba(45, 212, 191, 0.16);
bottom: -60px;
right: -40px;
}
.nf-deco-3 {
width: 160px;
height: 160px;
background: rgba(37, 99, 235, 0.1);
top: 40%;
right: 18%;
}
@media (max-width: 600px) {
.nf-code { font-size: 88px; }
.nf-title { font-size: 22px; }
.nf-actions { flex-direction: column; }
.nf-btn { width: 100%; justify-content: center; }
}
</style>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+778
View File
@@ -0,0 +1,778 @@
<template>
<div class="resource-plaza">
<section class="resource-hero">
<div>
<span class="eyebrow">资源中心</span>
<h1>查找、收藏和改编教学资源</h1>
<p>按学科、类型和关键词筛选课件、动画、练习、教案与试卷,快速进入预览或二次创作。</p>
</div>
<button class="upload-btn" type="button" @click="showUpload = true">
<el-icon><Upload /></el-icon>
上传素材
</button>
</section>
<section class="filter-panel">
<div class="segmented">
<button
v-for="type in resourceTypes"
:key="type.value"
type="button"
:class="{ active: filters.resource_type === type.value }"
@click="setType(type.value)"
>
{{ type.label }}
</button>
</div>
<div class="filter-line">
<el-select v-model="filters.subject" placeholder="全部学科" clearable @change="loadData" style="width: 150px">
<el-option v-for="s in subjects" :key="s" :label="s" :value="s" />
</el-select>
<div class="search-box">
<el-icon><Search /></el-icon>
<input v-model="filters.keyword" placeholder="搜索课件、动画、教案素材" @keyup.enter="loadData" />
<button v-if="filters.keyword" type="button" @click="clearKeyword">清除</button>
</div>
<div class="sort-segmented">
<button v-for="opt in sortOptions" :key="opt.value" type="button" :class="{ active: filters.sort === opt.value }" @click="setSort(opt.value)">{{ opt.label }}</button>
</div>
</div>
</section>
<section class="resource-grid" v-loading="loading">
<article v-for="res in displayResources" :key="res.id" class="resource-card" @click="openResource(res)">
<div class="cover" :class="coverClass(res)">
<span class="type-pill">{{ typeLabel(res.resource_type) }}</span>
<span v-if="res.is_favorited" class="fav-pill"><el-icon><Star /></el-icon>已收藏</span>
<span class="view-pill"><el-icon><View /></el-icon>{{ formatCount(res.views) }}</span>
<div class="cover-text">
<small>{{ res.subject || '通用' }}</small>
<strong>{{ res.title }}</strong>
<em>{{ res.grade || res.resource_type || 'AI教学资源' }}</em>
</div>
<div class="cover-art" :class="artClass(res)"><span></span><span></span><span></span></div>
</div>
<div class="card-body">
<h3>{{ res.title }}</h3>
<p>{{ res.description || fallbackDescription(res) }}</p>
<div class="card-meta">
<span>{{ res.subject || '未设置学科' }}</span>
<span>{{ resourceAuthorName(res) }}</span>
<span>{{ formatCount(res.downloads) }} 下载</span>
<button type="button" :class="{ active: res.is_favorited }" @click.stop="toggleFavorite(res)">
<el-icon><Star /></el-icon>
{{ formatCount(res.likes) }}
</button>
</div>
<div class="card-actions">
<button type="button" @click.stop="previewResource(res)">预览</button>
<button type="button" class="primary" @click.stop="reuseResource(res)">改编</button>
<button v-if="filters.resource_type === 'mine' && typeof res.id === 'number'" type="button" @click.stop="unpublishResource(res)">下架</button>
<button v-else type="button" @click.stop="downloadResource(res)">下载</button>
</div>
</div>
</article>
</section>
<div v-if="!loading && displayResources.length === 0" class="empty-state">
<el-icon><FolderOpened /></el-icon>
<strong>没有找到相关资源</strong>
<span>换个筛选条件,或回到推荐资源。</span>
<button type="button" @click="resetFilters">查看推荐</button>
</div>
<el-dialog v-model="showDetail" :title="currentResource?.title" width="720px">
<div v-if="currentResource" class="resource-detail">
<div class="detail-cover" :class="coverClass(currentResource)">
<span>{{ typeLabel(currentResource.resource_type) }}</span>
<strong>{{ currentResource.title }}</strong>
<em>{{ currentResource.subject || '通用' }} · {{ currentResource.grade || '教学资源' }}</em>
</div>
<p>{{ currentResource.description || fallbackDescription(currentResource) }}</p>
<div class="detail-stats">
<span><el-icon><View /></el-icon>{{ formatCount(currentResource.views) }} 浏览</span>
<span><el-icon><Download /></el-icon>{{ formatCount(currentResource.downloads) }} 下载</span>
<span><el-icon><Star /></el-icon>{{ formatCount(currentResource.likes) }} 收藏</span>
</div>
<div class="detail-actions">
<button type="button" @click="openResource(currentResource)">打开预览</button>
<button type="button" class="primary" @click="reuseResource(currentResource)">用它生成</button>
<button v-if="filters.resource_type === 'mine' && typeof currentResource.id === 'number'" type="button" @click="openEditResource(currentResource)">编辑资源</button>
<button v-else type="button" @click="toggleFavorite(currentResource)">{{ currentResource.is_favorited ? '取消收藏' : '收藏资源' }}</button>
<button v-if="filters.resource_type === 'mine' && typeof currentResource.id === 'number'" type="button" class="danger-action" @click="unpublishResource(currentResource)">下架资源</button>
<button v-else type="button" @click="downloadResource(currentResource)">{{ resourceDownloadLabel(currentResource) }}</button>
</div>
</div>
</el-dialog>
<el-dialog v-model="showUpload" title="上传教学资源" width="640px" :close-on-click-modal="false">
<el-form :model="uploadForm" label-position="top" class="upload-form">
<el-form-item label="资源标题">
<el-input v-model="uploadForm.title" placeholder="例如:圆柱体积公式推导互动课件" />
</el-form-item>
<div class="upload-grid">
<el-form-item label="资源类型">
<el-select v-model="uploadForm.resource_type" style="width: 100%">
<el-option v-for="type in publishableTypes" :key="type.value" :label="type.label" :value="type.value" />
</el-select>
</el-form-item>
<el-form-item label="学科">
<el-select v-model="uploadForm.subject" filterable allow-create style="width: 100%">
<el-option v-for="s in subjects" :key="s" :label="s" :value="s" />
</el-select>
</el-form-item>
<el-form-item label="年级">
<el-input v-model="uploadForm.grade" placeholder="例如:六年级下册" />
</el-form-item>
<el-form-item label="是否公开">
<el-select v-model="uploadForm.is_public" style="width: 100%">
<el-option label="公开到资源广场" :value="1" />
<el-option label="仅自己可见" :value="0" />
</el-select>
</el-form-item>
</div>
<el-form-item label="资源说明">
<el-input v-model="uploadForm.description" type="textarea" :rows="4" placeholder="描述适用场景、知识点、使用方式和可改编建议..." />
</el-form-item>
<el-form-item label="标签">
<el-select v-model="uploadForm.tags" multiple filterable allow-create placeholder="输入标签后回车" style="width: 100%" />
</el-form-item>
<el-form-item label="资源链接">
<el-input v-model="uploadForm.file_url" placeholder="可填写网盘、校本资源或课件预览链接" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showUpload = false">取消</el-button>
<el-button type="primary" :loading="uploading" @click="handleCreateResource">发布资源</el-button>
</template>
</el-dialog>
<el-dialog v-model="showEdit" title="编辑资源信息" width="640px" :close-on-click-modal="false">
<el-form :model="editForm" label-position="top" class="upload-form">
<el-form-item label="资源标题">
<el-input v-model="editForm.title" />
</el-form-item>
<div class="upload-grid">
<el-form-item label="资源类型">
<el-select v-model="editForm.resource_type" style="width: 100%">
<el-option v-for="type in publishableTypes" :key="type.value" :label="type.label" :value="type.value" />
</el-select>
</el-form-item>
<el-form-item label="学科">
<el-select v-model="editForm.subject" filterable allow-create style="width: 100%">
<el-option v-for="s in subjects" :key="s" :label="s" :value="s" />
</el-select>
</el-form-item>
<el-form-item label="年级">
<el-input v-model="editForm.grade" />
</el-form-item>
<el-form-item label="是否公开">
<el-select v-model="editForm.is_public" style="width: 100%">
<el-option label="公开到资源广场" :value="1" />
<el-option label="仅自己可见" :value="0" />
</el-select>
</el-form-item>
</div>
<el-form-item label="资源说明">
<el-input v-model="editForm.description" type="textarea" :rows="4" />
</el-form-item>
<el-form-item label="标签">
<el-select v-model="editForm.tags" multiple filterable allow-create style="width: 100%" />
</el-form-item>
<el-form-item label="资源链接">
<el-input v-model="editForm.file_url" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showEdit = false">取消</el-button>
<el-button type="primary" :loading="savingEdit" @click="saveResourceEdit">保存修改</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, reactive, ref, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { createResource, deleteResource, downloadResourceStat, getFavoriteResources, getMyResources, getResource, getResources, likeResource, unlikeResource, updateResource } from '@/api/resource'
import { exportCoursewareDocx, remixCourseware } from '@/api/courseware'
import { exportAnimationHtml, remixAnimation } from '@/api/animation'
import { exportExerciseDocx, remixExercise } from '@/api/exercise'
import { exportLessonPlanDocx, remixLessonPlan } from '@/api/lessonPlan'
import { exportSavedExamDocx, remixExam } from '@/api/exam'
import { createDemoDraft, isDemoDraftAvailable } from '@/utils/demoDrafts'
import { downloadBlob } from '@/utils/download'
const router = useRouter()
const route = useRoute()
const loading = ref(false)
const resources = ref<any[]>([])
const showUpload = ref(false)
const showDetail = ref(false)
const showEdit = ref(false)
const currentResource = ref<any>(null)
const uploading = ref(false)
const savingEdit = ref(false)
const subjects = ['语文', '数学', '英语', '物理', '化学', '生物', '历史', '地理', '信息科技', '课堂工具']
const resourceTypes = [
{ value: '', label: '推荐' },
{ value: 'mine', label: '我的发布' },
{ value: 'favorite', label: '我的收藏' },
{ value: 'courseware', label: '课件' },
{ value: 'animation', label: '动画' },
{ value: 'exercise', label: '练习' },
{ value: 'lesson_plan', label: '教案' },
{ value: 'exam', label: '试卷' },
{ value: 'other', label: '其他' },
]
const sortOptions = [
{ value: 'latest', label: '最新' },
{ value: 'popular', label: '最热' },
{ value: 'downloads', label: '下载' },
{ value: 'likes', label: '收藏' },
]
const filters = reactive({ resource_type: '', subject: '', keyword: '', sort: 'latest' })
const uploadForm = reactive({
resource_type: 'courseware',
title: '',
description: '',
file_url: '',
tags: [] as string[],
subject: '',
grade: '',
is_public: 1,
})
const editForm = reactive({
id: 0,
resource_type: 'courseware',
title: '',
description: '',
content_ref: '',
file_url: '',
cover_image: '',
tags: [] as string[],
subject: '',
grade: '',
is_public: 1,
})
const demoResources = [
{ id: 'demo-guide', title: '智教助手新手指引', subject: '课堂工具', grade: '通用', resource_type: 'courseware', description: '从输入主题、上传材料到生成、预览、改编和发布,完整展示创作流程。', downloads: 2400, views: 82000, likes: 6211 },
{ id: 'demo-tips', title: '玩转互动课件必看小技巧', subject: '课堂工具', grade: '通用', resource_type: 'courseware', description: '整理课堂演示、页面交互、图片替换、动画插入和二次编辑技巧。', downloads: 2280, views: 72000, likes: 6065 },
{ id: 'demo-case', title: '智教助手经典案例解析合集', subject: '课堂工具', grade: '通用', resource_type: 'lesson_plan', description: '拆解优秀课件的结构、互动设计和课堂使用场景,适合备课复盘。', downloads: 2210, views: 87000, likes: 5910 },
{ id: 'demo-cyl', title: '小学数学六年级圆柱的体积', subject: '数学', grade: '六年级下册', resource_type: 'courseware', description: '包含旧知回顾、操作探究、公式推导和巩固练习。', downloads: 3260, views: 65000, likes: 5509 },
{ id: 'demo-data', title: '课堂数据回收新手指引', subject: '课堂工具', grade: '通用', resource_type: 'animation', description: '演示问答收集、投票统计和学生反馈汇总流程。', downloads: 1860, views: 64000, likes: 6006 },
{ id: 'demo-food', title: 'Unit4 Healthy Food 第一课时', subject: '英语', grade: '七年级', resource_type: 'exercise', description: '英语单词认读、句型操练和闯关游戏。', downloads: 2140, views: 66000, likes: 5866 },
{ id: 'demo-creator', title: 'AI好课晒一晒创作者激励活动', subject: '课堂工具', grade: '通用', resource_type: 'other', description: '沉淀教师作品、活动说明和优秀模板推荐,支持发布后持续改编。', downloads: 1680, views: 61000, likes: 5776 },
{ id: 'demo-ai', title: 'AI如何看世界:图像识别', subject: '信息科技', grade: '人工智能体验课', resource_type: 'animation', description: '用流程动画解释图像识别的基本工作过程。', downloads: 1780, views: 56000, likes: 5443 },
]
const demoRoutes: Record<string, string> = {
'demo-guide': '/demo/guide',
'demo-tips': '/demo/tips',
'demo-case': '/demo/case',
'demo-cyl': '/demo/cylinder',
'demo-data': '/demo/data',
'demo-food': '/demo/food',
'demo-creator': '/demo/creator',
'demo-ai': '/demo/vision',
}
const publishableTypes = computed(() => resourceTypes.filter(item =>
['courseware', 'animation', 'exercise', 'lesson_plan', 'exam', 'other'].includes(item.value),
))
function syncFiltersFromRoute() {
const keyword = route.query.keyword
const tab = route.query.tab
filters.keyword = typeof keyword === 'string' ? keyword : ''
if (tab === 'mine') filters.resource_type = 'mine'
if (tab === 'favorites' || tab === 'favorite') filters.resource_type = 'favorite'
}
const displayResources = computed(() => {
const source = filters.resource_type === 'favorite' || filters.resource_type === 'mine' ? resources.value : [...demoResources, ...resources.value]
const kw = filters.keyword.trim().toLowerCase()
return source.filter(item => {
const typeOk = !filters.resource_type || filters.resource_type === 'favorite' || filters.resource_type === 'mine' || item.resource_type === filters.resource_type
const subjectOk = !filters.subject || item.subject === filters.subject
const keywordOk = !kw || `${item.title}${item.description || ''}${item.subject || ''}`.toLowerCase().includes(kw)
return typeOk && subjectOk && keywordOk
})
})
function typeLabel(type: string) {
return resourceTypes.find(item => item.value === type)?.label || '资源'
}
function authRedirect() {
if (localStorage.getItem('access_token')) return true
ElMessage.warning('请先登录后再收藏资源')
router.push({ path: '/login', query: { redirect: route.fullPath } })
return false
}
function ensureResourceLogin(message = '请先登录后再操作资源') {
if (localStorage.getItem('access_token')) return true
ElMessage.warning(message)
router.push({ path: '/login', query: { redirect: route.fullPath } })
return false
}
function formatCount(value: any) {
const n = Number(value || 0)
if (n >= 10000) return `${(n / 10000).toFixed(1)}万`
return String(n)
}
function coverClass(res: any) {
const map: Record<string, string> = {
courseware: 'cover-cream',
animation: 'cover-green',
exercise: 'cover-yellow',
lesson_plan: 'cover-blue',
exam: 'cover-mint',
other: 'cover-pink',
}
return map[res.resource_type] || 'cover-green'
}
function artClass(res: any) {
if (res.resource_type === 'courseware') return 'art-slides'
if (res.resource_type === 'exercise') return 'art-bars'
if (res.resource_type === 'animation') return 'art-orbit'
return 'art-cards'
}
function fallbackDescription(res: any) {
return `${res.subject || '通用'}资源,可用于课堂展示、备课改编和学生练习。`
}
function resourceAuthorName(res: any) {
return res?.author?.name || (typeof res?.id === 'number' ? '社区教师' : '智教助手')
}
function setType(type: string) {
filters.resource_type = type
loadData()
}
function setSort(value: string) {
filters.sort = value
loadData()
}
function clearKeyword() {
filters.keyword = ''
loadData()
}
function resetFilters() {
filters.resource_type = ''
filters.subject = ''
filters.keyword = ''
filters.sort = 'latest'
loadData()
}
function openResource(res: any) {
if (typeof res.id === 'number') {
router.push(`/resources/${res.id}`)
return
}
router.push(demoRoutes[String(res.id)] || '/demo/cylinder')
}
async function previewResource(res: any) {
if (typeof res.id === 'number') {
try {
const data: any = await getResource(res.id)
currentResource.value = data.data || data
} catch {
currentResource.value = res
}
} else {
currentResource.value = res
}
showDetail.value = true
}
function openEditResource(res: any) {
if (typeof res?.id !== 'number') return
editForm.id = res.id
editForm.resource_type = res.resource_type || 'other'
editForm.title = res.title || ''
editForm.description = res.description || ''
editForm.content_ref = res.content_ref || ''
editForm.file_url = res.file_url || ''
editForm.cover_image = res.cover_image || ''
editForm.tags = Array.isArray(res.tags) ? [...res.tags] : []
editForm.subject = res.subject || ''
editForm.grade = res.grade || ''
editForm.is_public = Number(res.is_public ?? 1)
showEdit.value = true
}
async function saveResourceEdit() {
if (!editForm.id || !editForm.title.trim()) {
ElMessage.warning('请填写资源标题')
return
}
savingEdit.value = true
try {
const saved: any = await updateResource(editForm.id, {
resource_type: editForm.resource_type,
title: editForm.title,
description: editForm.description,
content_ref: editForm.content_ref,
file_url: editForm.file_url,
cover_image: editForm.cover_image,
tags: editForm.tags,
subject: editForm.subject,
grade: editForm.grade,
is_public: editForm.is_public,
status: 'active',
})
const resource = saved.data || saved
const index = resources.value.findIndex(item => item.id === resource.id)
if (index >= 0) resources.value[index] = resource
if (currentResource.value?.id === resource.id) currentResource.value = resource
showEdit.value = false
ElMessage.success('资源信息已更新')
} finally {
savingEdit.value = false
}
}
async function reuseResource(res: any) {
if (isDemoDraftAvailable(res)) {
if (!localStorage.getItem('access_token')) {
ElMessage.warning('请先登录后再改编示例资源')
router.push({ path: '/login', query: { redirect: route.fullPath } })
return
}
try {
const result = await createDemoDraft(res)
ElMessage.success('已创建可编辑示例草稿')
router.push(result.openPath)
return
} catch {
ElMessage.warning('示例草稿创建失败,已为你带入创作指令')
}
}
const remixTarget = remixTargetFromResource(res)
if (remixTarget) {
if (!localStorage.getItem('access_token')) {
ElMessage.warning('请先登录后再改编资源')
router.push({ path: '/login', query: { redirect: route.fullPath } })
return
}
try {
const data: any = await remixTarget.action(remixTarget.id)
const draft = data.data || data
ElMessage.success('已创建可编辑副本')
router.push(remixTarget.openPath(draft.id))
return
} catch {
ElMessage.warning('改编失败,已为你带入创作指令')
}
}
const prompt = `参考资源《${res.title}》,为${res.grade || '通用年级'}${res.subject || ''}生成一份可直接授课的${typeLabel(res.resource_type)}。要求保留核心知识点,并改写成原创内容。`
localStorage.setItem('quick_create_prompt', prompt)
const routeMap: Record<string, string> = {
courseware: '/courseware/create',
animation: '/animation',
exercise: '/exercise',
lesson_plan: '/lesson-plan',
exam: '/exam',
}
router.push(routeMap[res.resource_type] || '/courseware/create')
}
function remixTargetFromResource(res: any) {
const targets: Record<string, { prefix: string; action: (id: number) => Promise<any>; openPath: (id: number) => string }> = {
courseware: { prefix: 'courseware:', action: remixCourseware, openPath: (id: number) => `/courseware/${id}` },
animation: { prefix: 'animation:', action: remixAnimation, openPath: (id: number) => `/animation?open=${id}` },
exercise: { prefix: 'exercise:', action: remixExercise, openPath: (id: number) => `/exercise?open=${id}` },
lesson_plan: { prefix: 'lesson_plan:', action: remixLessonPlan, openPath: (id: number) => `/lesson-plan?open=${id}` },
exam: { prefix: 'exam:', action: remixExam, openPath: (id: number) => `/exam?open=${id}` },
}
const target = targets[res?.resource_type]
if (!target || typeof res?.content_ref !== 'string' || !res.content_ref.startsWith(target.prefix)) return null
const id = Number(res.content_ref.slice(target.prefix.length))
return Number.isFinite(id) && id > 0 ? { ...target, id } : null
}
function coursewareIdFromResource(res: any) {
if (typeof res?.content_ref === 'string' && res.content_ref.startsWith('courseware:')) {
const id = Number(res.content_ref.split(':')[1])
return Number.isFinite(id) && id > 0 ? id : 0
}
return 0
}
function resourceDownloadLabel(res: any) {
const target = exportTargetFromResource(res)
if (target) return target.extension === 'html' ? '下载HTML' : '下载Word'
return previewUrlFromResource(res) ? '打开资源' : '下载素材'
}
async function downloadResource(res: any) {
if (typeof res.id !== 'number') {
if (typeof res.downloads === 'number') res.downloads += 1
const route = demoRoutes[String(res.id)]
if (route) {
window.open(route, '_blank')
ElMessage.success('已打开演示资源')
} else {
ElMessage.success(`已加入下载队列:${res.title}`)
}
return
}
try {
const data: any = await downloadResourceStat(res.id)
res.downloads = data.downloads
const exported = await downloadStructuredResource(res)
if (exported) return
const url = data.file_url || previewUrlFromResource(res)
if (url) {
window.open(url, '_blank')
ElMessage.success('已打开资源链接')
} else {
ElMessage.success('已加入下载队列')
}
} catch (e: any) {
ElMessage.error('下载失败:' + (e.response?.data?.detail || e.message || ''))
}
}
async function downloadStructuredResource(res: any) {
const target = exportTargetFromResource(res)
if (!target) return false
try {
const blob = await target.action(target.id)
downloadBlob(blob, `${safeFilename(res.title)}-${target.suffix}.${target.extension}`)
ElMessage.success('已开始下载')
return true
} catch (e: any) {
ElMessage.error('下载失败:' + (e.response?.data?.detail || e.message || ''))
return true
}
}
function exportTargetFromResource(res: any) {
const targets: Record<string, { prefix: string; suffix: string; extension: string; action: (id: number) => Promise<Blob> }> = {
courseware: { prefix: 'courseware:', suffix: '讲义', extension: 'docx', action: exportCoursewareDocx },
animation: { prefix: 'animation:', suffix: '动画', extension: 'html', action: exportAnimationHtml },
exercise: { prefix: 'exercise:', suffix: '练习', extension: 'docx', action: exportExerciseDocx },
lesson_plan: { prefix: 'lesson_plan:', suffix: '教案', extension: 'docx', action: exportLessonPlanDocx },
exam: { prefix: 'exam:', suffix: '试卷', extension: 'docx', action: exportSavedExamDocx },
}
const target = targets[res?.resource_type]
if (!target || typeof res?.content_ref !== 'string' || !res.content_ref.startsWith(target.prefix)) return null
const id = Number(res.content_ref.slice(target.prefix.length))
return Number.isFinite(id) && id > 0 ? { ...target, id } : null
}
function safeFilename(value: string) {
return (value || '资源').replace(/[\\/:*?"<>|\r\n]+/g, '_').trim() || '资源'
}
function previewUrlFromResource(res: any) {
const id = coursewareIdFromResource(res)
if (id) return `/preview/${id}`
return res?.file_url || ''
}
async function handleCreateResource() {
if (!uploadForm.title.trim()) {
ElMessage.warning('请填写资源标题')
return
}
if (!localStorage.getItem('access_token')) {
ElMessage.warning('请先登录后再发布资源')
router.push({ path: '/login', query: { redirect: '/resources' } })
return
}
uploading.value = true
try {
await createResource({ ...uploadForm })
ElMessage.success('资源已发布')
showUpload.value = false
filters.resource_type = 'mine'
uploadForm.title = ''
uploadForm.description = ''
uploadForm.file_url = ''
uploadForm.tags = []
uploadForm.subject = ''
uploadForm.grade = ''
uploadForm.resource_type = 'courseware'
uploadForm.is_public = 1
await loadData()
} finally {
uploading.value = false
}
}
async function loadData() {
loading.value = true
try {
if ((filters.resource_type === 'favorite' || filters.resource_type === 'mine') && !ensureResourceLogin('请先登录后再查看个人资源')) {
resources.value = []
return
}
const params = {
resource_type: filters.resource_type === 'favorite' || filters.resource_type === 'mine' ? '' : filters.resource_type,
subject: filters.subject,
keyword: filters.keyword,
sort: filters.sort,
}
const res: any = filters.resource_type === 'favorite'
? await getFavoriteResources(params)
: filters.resource_type === 'mine'
? await getMyResources(params)
: await getResources(params)
resources.value = Array.isArray(res) ? res : []
} catch {
resources.value = []
} finally {
loading.value = false
}
}
async function unpublishResource(res: any) {
if (typeof res.id !== 'number') return
if (!ensureResourceLogin('请先登录后再下架资源')) return
try {
await deleteResource(res.id)
resources.value = resources.value.filter(item => item.id !== res.id)
if (currentResource.value?.id === res.id) {
showDetail.value = false
currentResource.value = null
}
ElMessage.success('资源已下架')
} catch {
ElMessage.warning('下架失败,请稍后重试')
}
}
async function toggleFavorite(res: any) {
if (typeof res.id !== 'number') {
res.is_favorited = !res.is_favorited
res.likes = Math.max(0, Number(res.likes || 0) + (res.is_favorited ? 1 : -1))
return
}
if (!authRedirect()) return
try {
const data: any = res.is_favorited ? await unlikeResource(res.id) : await likeResource(res.id)
res.likes = data.likes
res.is_favorited = data.is_favorited
if (currentResource.value?.id === res.id) {
currentResource.value.likes = data.likes
currentResource.value.is_favorited = data.is_favorited
}
if (filters.resource_type === 'favorite' && !data.is_favorited) {
resources.value = resources.value.filter(item => item.id !== res.id)
}
ElMessage.success(data.is_favorited ? '已收藏' : '已取消收藏')
} catch {
ElMessage.warning('收藏失败,请稍后重试')
}
}
onMounted(() => {
syncFiltersFromRoute()
loadData()
})
watch(() => [route.query.keyword, route.query.tab], () => {
syncFiltersFromRoute()
loadData()
})
</script>
<style scoped>
.resource-plaza { min-height: calc(100vh - var(--header-height)); padding: 28px 44px 36px; background: #f7faf6; color: #173f35; }
.resource-hero { max-width: 1474px; margin: 0 auto 20px; display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; }
.eyebrow { display: inline-flex; align-items: center; height: 24px; padding: 0 10px; border-radius: 12px; background: #e6f6e5; color: #00543d; font-size: 12px; font-weight: 900; }
.resource-hero h1 { margin: 12px 0 8px; font-size: 28px; line-height: 1.2; color: #00543d; }
.resource-hero p { max-width: 680px; color: #617971; line-height: 1.7; }
.upload-btn { height: 38px; display: inline-flex; align-items: center; gap: 7px; border: none; border-radius: 19px; background: #00543d; color: #fff; padding: 0 18px; font-weight: 900; cursor: pointer; }
.filter-panel { max-width: 1474px; margin: 0 auto 18px; display: flex; align-items: center; justify-content: space-between; gap: 18px; }
.segmented { display: flex; align-items: center; gap: 8px; overflow-x: auto; scrollbar-width: none; }
.segmented::-webkit-scrollbar { display: none; }
.segmented button { height: 34px; border: 1px solid #dbe8de; border-radius: 17px; background: #fff; color: #51665f; padding: 0 14px; white-space: nowrap; cursor: pointer; }
.segmented button.active, .segmented button:hover { border-color: #9ed0aa; background: #eaf7e9; color: #00543d; font-weight: 800; }
.filter-line { display: flex; align-items: center; gap: 10px; }
.search-box { width: 300px; height: 36px; display: flex; align-items: center; gap: 8px; border-radius: 18px; background: #eef1ec; color: #9aa7a0; padding: 0 12px; }
.search-box input { min-width: 0; flex: 1; border: none; outline: none; background: transparent; color: #173f35; }
.search-box button { border: none; background: transparent; color: #00543d; font-weight: 800; cursor: pointer; }
.sort-segmented { display: inline-flex; gap: 4px; align-items: center; }
.sort-segmented button { height: 34px; border: 1px solid #dbe8de; border-radius: 17px; background: #fff; color: #51665f; padding: 0 14px; white-space: nowrap; cursor: pointer; font-size: 13px; }
.sort-segmented button.active, .sort-segmented button:hover { border-color: #9ed0aa; background: #eaf7e9; color: #00543d; font-weight: 800; }
.resource-grid { max-width: 1474px; margin: 0 auto; display: grid; grid-template-columns: repeat(4, minmax(240px, 1fr)); gap: 22px; }
.resource-card { overflow: hidden; border: 1px solid #dfe8e1; border-radius: 14px; background: #fff; cursor: pointer; transition: transform .18s ease, box-shadow .18s ease; }
.resource-card:hover { transform: translateY(-3px); box-shadow: 0 12px 24px rgba(17, 69, 52, .1); }
.cover { position: relative; height: 178px; margin: 5px; padding: 22px; border-radius: 10px; overflow: hidden; }
.type-pill, .view-pill, .fav-pill { position: absolute; z-index: 2; height: 24px; display: inline-flex; align-items: center; border-radius: 12px; font-size: 12px; font-weight: 900; }
.type-pill { left: 8px; bottom: 8px; background: rgba(255,255,255,.86); color: #00543d; padding: 0 8px; }
.view-pill { top: 8px; right: 8px; gap: 4px; background: rgba(0,0,0,.24); color: #fff; padding: 0 9px; }
.fav-pill { left: 8px; top: 8px; gap: 4px; background: rgba(255,255,255,.9); color: #00543d; padding: 0 9px; }
.cover-text { position: relative; z-index: 1; max-width: 70%; display: flex; flex-direction: column; gap: 8px; }
.cover-text small { opacity: .72; font-size: 12px; }
.cover-text strong { font-size: 24px; line-height: 1.18; font-weight: 900; }
.cover-text em { font-style: normal; font-size: 13px; line-height: 1.45; }
.cover-cream { color: #7a5600; background: linear-gradient(135deg, #fff3c4, #f7e3a1); }
.cover-green { color: #fff; background: linear-gradient(135deg, #063a2d, #00854f); }
.cover-yellow { color: #654100; background: linear-gradient(135deg, #fff1a1, #ffd84d); }
.cover-blue { color: #003f68; background: linear-gradient(135deg, #dbe8ff, #c8ddff); }
.cover-mint { color: #00543d; background: linear-gradient(135deg, #e7ffe9, #dff7e5); }
.cover-pink { color: #613400; background: linear-gradient(135deg, #ffe7bc, #ffd869); }
.cover-art { position: absolute; right: 24px; bottom: 28px; z-index: 0; }
.art-slides { display: grid; gap: 7px; }
.art-slides span { width: 88px; height: 38px; border-radius: 7px; background: rgba(255,255,255,.7); box-shadow: 0 6px 14px rgba(0,0,0,.12); }
.art-bars { display: flex; align-items: flex-end; gap: 8px; }
.art-bars span { width: 24px; border-radius: 6px 6px 0 0; background: rgba(255,255,255,.62); }
.art-bars span:nth-child(1) { height: 38px; } .art-bars span:nth-child(2) { height: 78px; } .art-bars span:nth-child(3) { height: 56px; }
.art-orbit { width: 102px; height: 102px; border: 2px dashed rgba(255,255,255,.55); border-radius: 50%; }
.art-orbit span:first-child { position: absolute; left: 34px; top: 34px; width: 32px; height: 32px; border-radius: 50%; background: rgba(255,255,255,.75); }
.art-cards { display: grid; gap: 8px; }
.art-cards span { width: 94px; height: 24px; border-radius: 8px; background: rgba(255,255,255,.58); }
.card-body { padding: 12px 16px 15px; }
.card-body h3 { color: #233c35; font-size: 16px; line-height: 1.35; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.card-body p { min-height: 38px; margin-top: 7px; color: #6a7f77; font-size: 12px; line-height: 1.55; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.card-meta { margin-top: 12px; display: flex; align-items: center; gap: 8px; color: #87958f; font-size: 12px; }
.card-meta button { margin-left: auto; height: 28px; display: inline-flex; align-items: center; gap: 4px; border: 1px solid #dbe8de; border-radius: 14px; background: #fff; color: #00543d; padding: 0 10px; cursor: pointer; }
.card-meta button.active { border-color: #00543d; background: #e8f7ef; font-weight: 900; }
.card-actions { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: 12px; }
.card-actions button,
.detail-actions button { height: 32px; border: 1px solid #dbe8de; border-radius: 8px; background: #fff; color: #31584d; font-weight: 800; cursor: pointer; }
.card-actions button.primary,
.detail-actions button.primary { border-color: #00543d; background: #00543d; color: #fff; }
.detail-actions button.danger-action { border-color: #fca5a5; background: #fff; color: #dc2626; }
.card-actions button:hover,
.detail-actions button:hover { border-color: #9ed0aa; background: #f6fbf6; color: #00543d; }
.card-actions button.primary:hover,
.detail-actions button.primary:hover { background: #004532; color: #fff; }
.detail-actions button.danger-action:hover { border-color: #dc2626; background: #fef2f2; color: #b91c1c; }
.resource-detail { display: grid; gap: 16px; }
.detail-cover { min-height: 180px; display: flex; flex-direction: column; justify-content: flex-end; gap: 8px; border-radius: 12px; padding: 24px; }
.detail-cover span { width: fit-content; border-radius: 14px; background: rgba(255,255,255,.84); color: #00543d; padding: 4px 10px; font-size: 12px; font-weight: 900; }
.detail-cover strong { max-width: 520px; font-size: 28px; line-height: 1.15; }
.detail-cover em { font-style: normal; font-size: 13px; opacity: .8; }
.resource-detail p { color: #51665f; line-height: 1.8; }
.detail-stats { display: flex; flex-wrap: wrap; gap: 10px; color: #75867f; font-size: 13px; }
.detail-stats span { display: inline-flex; align-items: center; gap: 5px; border-radius: 16px; background: #f6fbf6; padding: 6px 10px; }
.detail-actions { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; }
.upload-grid { display: grid; grid-template-columns: repeat(2, minmax(180px, 1fr)); gap: 0 14px; }
.upload-form :deep(.el-form-item) { margin-bottom: 14px; }
.empty-state { max-width: 1474px; min-height: 220px; margin: 0 auto; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 9px; border: 1px dashed #cfe0d3; border-radius: 14px; background: rgba(255,255,255,.72); color: #75867f; }
.empty-state .el-icon { font-size: 32px; color: #9aaba4; }
.empty-state strong { color: #173f35; }
.empty-state button { margin-top: 6px; border: none; border-radius: 18px; background: #00543d; color: #fff; padding: 8px 18px; font-weight: 800; cursor: pointer; }
@media (max-width: 1380px) { .resource-grid { grid-template-columns: repeat(3, minmax(240px, 1fr)); } }
@media (max-width: 1080px) { .resource-grid { grid-template-columns: repeat(2, minmax(220px, 1fr)); } .filter-panel { align-items: stretch; flex-direction: column; } .filter-line { justify-content: space-between; } }
@media (max-width: 760px) { .resource-plaza { padding: 16px; } .resource-hero { align-items: flex-start; flex-direction: column; } .filter-line { align-items: stretch; flex-direction: column; } .search-box { width: 100%; } .resource-grid, .detail-actions, .upload-grid { grid-template-columns: 1fr; } }
</style>
+428
View File
@@ -0,0 +1,428 @@
<template>
<div class="search-page">
<section class="search-head">
<div>
<span class="eyebrow">全局搜索</span>
<h1>{{ keyword ? `搜索:${keyword}` : '搜索资源、模板和我的内容' }}</h1>
<p>一次检索资源广场、模板社区、我的作品、素材库和课堂活动,也可以直接把关键词变成创作任务。</p>
</div>
<div class="search-box">
<el-icon><Search /></el-icon>
<input v-model="inputKeyword" placeholder="输入知识点、资源标题或课堂主题" @keyup.enter="submitSearch" />
<button type="button" @click="submitSearch">搜索</button>
</div>
</section>
<section class="quick-create">
<div>
<strong>快速创作</strong>
<span>{{ keyword ? `用“${keyword}”开始生成新的教学内容` : '输入关键词后可直接进入创作' }}</span>
</div>
<div class="quick-actions">
<button type="button" @click="quickCreate('courseware')">课件</button>
<button type="button" @click="quickCreate('animation')">动画</button>
<button type="button" @click="quickCreate('exercise')">练习</button>
<button type="button" @click="quickCreate('lesson')">教案</button>
<button type="button" @click="quickCreate('exam')">试卷</button>
</div>
</section>
<section v-loading="loading" class="result-layout">
<ResultBlock title="资源广场" :items="results.resources" empty="没有匹配资源" @open="openItem" @use="useItem" />
<ResultBlock title="模板社区" :items="results.community" empty="没有匹配模板" @open="openItem" @use="useItem" />
<ResultBlock title="我的作品" :items="results.works" empty="没有匹配作品" @open="openItem" @use="useItem" />
<ResultBlock title="素材库" :items="results.materials" empty="没有匹配素材" @open="openItem" @use="useItem" />
<ResultBlock title="课堂活动" :items="results.classroom" empty="没有匹配课堂活动" @open="openItem" @use="useItem" />
</section>
<div v-if="!loading && keyword && !results.total" class="empty-state">
<el-icon><Search /></el-icon>
<strong>没有找到“{{ keyword }}”</strong>
<span>可以直接把这个关键词作为创作提示词,生成新的课件、动画、练习、教案或试卷。</span>
<button type="button" @click="quickCreate('courseware')">用它生成课件</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, defineComponent, h, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { globalSearch } from '@/api/search'
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const inputKeyword = ref('')
const results = reactive({
keyword: '',
total: 0,
resources: [] as any[],
community: [] as any[],
works: [] as any[],
materials: [] as any[],
classroom: [] as any[],
})
const keyword = computed(() => typeof route.query.keyword === 'string' ? route.query.keyword.trim() : '')
const ResultBlock = defineComponent({
props: {
title: { type: String, required: true },
items: { type: Array, required: true },
empty: { type: String, required: true },
},
emits: ['open', 'use'],
setup(props, { emit }) {
return () => h('article', { class: 'result-block' }, [
h('div', { class: 'block-head' }, [
h('strong', props.title),
h('span', `${props.items.length} 条`),
]),
props.items.length
? h('div', { class: 'result-list' }, props.items.map((item: any) => h('button', {
key: `${item.source}-${item.id}`,
type: 'button',
class: 'result-item',
onClick: () => emit('open', item),
}, [
h('span', { class: 'source-pill' }, item.source_label),
h('div', [
h('strong', item.title),
h('p', item.description || `${item.subject || '通用'} ${item.grade || ''}`.trim() || '可打开查看详情'),
h('small', [item.subject, item.grade, ...(item.tags || []).slice(0, 2)].filter(Boolean).join(' · ')),
]),
h('em', { onClick: (event: MouseEvent) => { event.stopPropagation(); emit('use', item) } }, '使用'),
])))
: h('div', { class: 'block-empty' }, props.empty),
])
},
})
function fillResults(payload: any) {
Object.assign(results, {
keyword: payload?.keyword || '',
total: payload?.total || 0,
resources: payload?.resources || [],
community: payload?.community || [],
works: payload?.works || [],
materials: payload?.materials || [],
classroom: payload?.classroom || [],
})
}
async function loadData() {
inputKeyword.value = keyword.value
loading.value = true
try {
fillResults(await globalSearch({ keyword: keyword.value, limit: 8 }))
} finally {
loading.value = false
}
}
function submitSearch() {
const next = inputKeyword.value.trim()
router.push({ path: '/search', query: next ? { keyword: next } : {} })
}
function openItem(item: any) {
if (item.source === 'material') {
router.push({ path: '/materials', query: { keyword: item.title } })
return
}
router.push(item.route || '/resources')
}
function useItem(item: any) {
const prompt = `参考“${item.title}”生成一份原创教学内容。${item.description || ''}`
localStorage.setItem('quick_create_prompt', prompt)
if (item.source === 'resource' || item.source === 'community') {
router.push(item.action_route || item.route || '/courseware/create')
return
}
router.push(item.action_route || item.route || '/courseware/create')
}
function quickCreate(type: string) {
const text = keyword.value || inputKeyword.value.trim()
if (text) localStorage.setItem('quick_create_prompt', text)
const routes: Record<string, string> = {
courseware: '/courseware/create',
animation: '/animation',
exercise: '/exercise',
lesson: '/lesson-plan',
exam: '/exam',
}
router.push(routes[type] || '/courseware/create')
}
onMounted(loadData)
watch(() => route.query.keyword, loadData)
</script>
<style scoped>
.search-page {
min-height: calc(100vh - var(--header-height));
padding: 28px 44px 36px;
background: #f7faf6;
color: #173f35;
}
.search-head,
.quick-create,
.result-layout,
.empty-state {
width: min(100%, 1180px);
margin: 0 auto;
}
.search-head {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(320px, 420px);
gap: 20px;
align-items: end;
}
.eyebrow {
display: inline-flex;
align-items: center;
height: 24px;
border-radius: 12px;
background: #e6f6e5;
color: #00543d;
padding: 0 10px;
font-size: 12px;
font-weight: 900;
}
.search-head h1 {
margin: 12px 0 8px;
color: #00543d;
font-size: 28px;
}
.search-head p {
color: #617971;
line-height: 1.7;
}
.search-box {
height: 44px;
display: flex;
align-items: center;
gap: 10px;
border: 1px solid #dce8de;
border-radius: 22px;
background: #fff;
padding: 0 8px 0 16px;
}
.search-box input {
min-width: 0;
flex: 1;
border: none;
outline: none;
color: #173f35;
}
.search-box button,
.quick-actions button,
.empty-state button {
height: 32px;
border: none;
border-radius: 16px;
background: #00543d;
color: #fff;
padding: 0 14px;
font-weight: 900;
cursor: pointer;
}
.quick-create {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
border: 1px solid #dce8de;
border-radius: 14px;
background: #fff;
padding: 16px;
margin-top: 20px;
}
.quick-create div:first-child {
display: grid;
gap: 5px;
}
.quick-create span,
.block-head span,
.result-item p,
.result-item small,
.empty-state span {
color: #75867f;
font-size: 13px;
}
.quick-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
}
.quick-actions button {
background: #eaf7e9;
color: #00543d;
}
.result-layout {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 16px;
margin-top: 18px;
}
.result-block {
min-width: 0;
border: 1px solid #dce8de;
border-radius: 14px;
background: #fff;
padding: 16px;
}
.block-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.result-list {
display: grid;
gap: 10px;
}
.result-item {
min-width: 0;
display: grid;
grid-template-columns: 76px minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
border: 1px solid #e2ece4;
border-radius: 10px;
background: #fff;
padding: 10px;
text-align: left;
cursor: pointer;
}
.result-item:hover {
border-color: #9ed0aa;
background: #f6fbf6;
}
.source-pill {
min-height: 30px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 8px;
background: #eaf7e9;
color: #00543d;
font-size: 12px;
font-weight: 900;
}
.result-item div {
min-width: 0;
}
.result-item strong,
.result-item p,
.result-item small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.result-item strong {
display: block;
color: #173f35;
font-size: 14px;
}
.result-item p {
margin-top: 4px;
}
.result-item small {
display: block;
margin-top: 3px;
}
.result-item em {
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 14px;
background: #eef5ee;
color: #00543d;
padding: 0 10px;
font-style: normal;
font-size: 12px;
font-weight: 900;
}
.block-empty,
.empty-state {
border: 1px dashed #cfe0d3;
border-radius: 12px;
background: #fbfdfb;
color: #75867f;
padding: 18px;
}
.empty-state {
min-height: 180px;
display: grid;
justify-items: center;
align-content: center;
gap: 10px;
margin-top: 18px;
text-align: center;
}
.empty-state .el-icon {
color: #aebdb5;
font-size: 34px;
}
@media (max-width: 760px) {
.search-page {
padding: 18px;
}
.search-head,
.quick-create {
grid-template-columns: 1fr;
}
.quick-create {
align-items: flex-start;
flex-direction: column;
}
.result-item {
grid-template-columns: 1fr auto;
}
.source-pill {
grid-column: 1 / -1;
justify-content: flex-start;
padding: 0 10px;
}
}
</style>