957 lines
35 KiB
Vue
957 lines
35 KiB
Vue
|
|
<template>
|
|||
|
|
<div class="course-detail">
|
|||
|
|
<a-page-header
|
|||
|
|
:title="course.name || '课程包详情'"
|
|||
|
|
@back="() => router.back()"
|
|||
|
|
>
|
|||
|
|
<template #extra>
|
|||
|
|
<a-space>
|
|||
|
|
<a-button @click="editCourse">
|
|||
|
|
<EditOutlined /> 编辑
|
|||
|
|
</a-button>
|
|||
|
|
<a-button @click="viewStats">
|
|||
|
|
<BarChartOutlined /> 数据统计
|
|||
|
|
</a-button>
|
|||
|
|
<a-dropdown v-if="course.status === 'published'">
|
|||
|
|
<template #overlay>
|
|||
|
|
<a-menu @click="handleMenuClick">
|
|||
|
|
<a-menu-item key="unpublish">下架</a-menu-item>
|
|||
|
|
<a-menu-item key="iterate">迭代版本</a-menu-item>
|
|||
|
|
</a-menu>
|
|||
|
|
</template>
|
|||
|
|
<a-button>
|
|||
|
|
更多 <DownOutlined />
|
|||
|
|
</a-button>
|
|||
|
|
</a-dropdown>
|
|||
|
|
<a-popconfirm
|
|||
|
|
v-else-if="course.status === 'draft' || course.status === 'archived'"
|
|||
|
|
title="确定删除此课程包吗?"
|
|||
|
|
@confirm="deleteCourse"
|
|||
|
|
>
|
|||
|
|
<a-button danger>
|
|||
|
|
<DeleteOutlined /> 删除
|
|||
|
|
</a-button>
|
|||
|
|
</a-popconfirm>
|
|||
|
|
</a-space>
|
|||
|
|
</template>
|
|||
|
|
</a-page-header>
|
|||
|
|
|
|||
|
|
<a-spin :spinning="loading">
|
|||
|
|
<div style="margin-top: 16px;">
|
|||
|
|
<a-row :gutter="16">
|
|||
|
|
<!-- 左侧主要信息 -->
|
|||
|
|
<a-col :span="16">
|
|||
|
|
<!-- 基本信息 -->
|
|||
|
|
<a-card title="基本信息" :bordered="false" style="margin-bottom: 16px;">
|
|||
|
|
<!-- 课程封面 -->
|
|||
|
|
<div v-if="course.coverImagePath" style="margin-bottom: 16px; text-align: center;">
|
|||
|
|
<img
|
|||
|
|
:src="getFileUrl(course.coverImagePath)"
|
|||
|
|
alt="课程封面"
|
|||
|
|
style="max-width: 100%; max-height: 300px; border-radius: 8px; object-fit: cover;"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<a-descriptions :column="2" bordered>
|
|||
|
|
<a-descriptions-item label="课程包名称" :span="2">
|
|||
|
|
{{ course.name }}
|
|||
|
|
</a-descriptions-item>
|
|||
|
|
<a-descriptions-item label="适用年级" :span="2">
|
|||
|
|
<a-tag v-for="grade in grades" :key="grade" :style="getGradeTagStyle(grade)">
|
|||
|
|
{{ grade }}
|
|||
|
|
</a-tag>
|
|||
|
|
<span v-if="grades.length === 0">-</span>
|
|||
|
|
</a-descriptions-item>
|
|||
|
|
<a-descriptions-item label="关联绘本" :span="2">
|
|||
|
|
{{ course.pictureBookName || '未关联' }}
|
|||
|
|
</a-descriptions-item>
|
|||
|
|
<a-descriptions-item label="课程时长" :span="2">
|
|||
|
|
{{ course.duration || 25 }} 分钟
|
|||
|
|
</a-descriptions-item>
|
|||
|
|
<a-descriptions-item label="核心发展目标" :span="2">
|
|||
|
|
<a-tag v-for="tag in domainTags" :key="tag" :style="getDomainTagStyle(tag)">
|
|||
|
|
{{ tag }}
|
|||
|
|
</a-tag>
|
|||
|
|
<span v-if="domainTags.length === 0">-</span>
|
|||
|
|
</a-descriptions-item>
|
|||
|
|
<a-descriptions-item label="课程简介" :span="2">
|
|||
|
|
{{ course.description || '暂无简介' }}
|
|||
|
|
</a-descriptions-item>
|
|||
|
|
<a-descriptions-item label="状态" :span="2">
|
|||
|
|
<a-tag :style="getCourseStatusStyle(course.status)">
|
|||
|
|
{{ translateCourseStatus(course.status) }}
|
|||
|
|
</a-tag>
|
|||
|
|
</a-descriptions-item>
|
|||
|
|
<a-descriptions-item label="版本">
|
|||
|
|
v{{ course.version }}
|
|||
|
|
</a-descriptions-item>
|
|||
|
|
<a-descriptions-item label="创建时间">
|
|||
|
|
{{ formatDate(course.createdAt) }}
|
|||
|
|
</a-descriptions-item>
|
|||
|
|
</a-descriptions>
|
|||
|
|
</a-card>
|
|||
|
|
|
|||
|
|
<!-- 教学流程 -->
|
|||
|
|
<a-card title="教学流程" :bordered="false" style="margin-bottom: 16px;">
|
|||
|
|
<a-timeline v-if="scripts && scripts.length > 0">
|
|||
|
|
<a-timeline-item v-for="script in scripts" :key="script.id">
|
|||
|
|
<template #dot>
|
|||
|
|
<ClockCircleOutlined style="font-size: 16px;" />
|
|||
|
|
</template>
|
|||
|
|
<div>
|
|||
|
|
<div style="font-weight: 500; margin-bottom: 4px;">
|
|||
|
|
{{ script.stepName }}
|
|||
|
|
<a-tag size="small" :style="getStepTypeStyle(translateStepType(script.stepType))" style="margin-left: 8px;">
|
|||
|
|
{{ translateStepType(script.stepType) }}
|
|||
|
|
</a-tag>
|
|||
|
|
<span style="color: #999; font-size: 12px; margin-left: 8px;">
|
|||
|
|
{{ script.duration }} 分钟
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
<div v-if="script.objective" style="color: #666; margin-bottom: 4px;">
|
|||
|
|
<strong>目标:</strong> {{ script.objective }}
|
|||
|
|
</div>
|
|||
|
|
<div v-if="script.teacherScript" style="color: #333; margin-bottom: 4px; background: #f9f9f9; padding: 8px 12px; border-radius: 6px; border-left: 3px solid #FF8C42;">
|
|||
|
|
<strong style="color: #FF8C42;">教师讲稿:</strong>
|
|||
|
|
<div style="margin-top: 4px; white-space: pre-wrap;">{{ script.teacherScript }}</div>
|
|||
|
|
</div>
|
|||
|
|
<!-- 关联资源展示 -->
|
|||
|
|
<div v-if="getScriptResources(script).length > 0" style="margin-top: 8px; padding-top: 8px; border-top: 1px dashed #e8e8e8;">
|
|||
|
|
<strong style="color: #666; font-size: 13px;">关联资源:</strong>
|
|||
|
|
<div style="display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px;">
|
|||
|
|
<a-tag
|
|||
|
|
v-for="res in getScriptResources(script)"
|
|||
|
|
:key="res.id"
|
|||
|
|
style="margin: 0; cursor: pointer;"
|
|||
|
|
@click="previewResource(res)"
|
|||
|
|
>
|
|||
|
|
<span style="margin-right: 4px;">{{ res.icon }}</span>
|
|||
|
|
{{ res.name }}
|
|||
|
|
<span style="color: #999; margin-left: 4px;">({{ res.typeName }})</span>
|
|||
|
|
</a-tag>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<!-- 逐页脚本展示 -->
|
|||
|
|
<div v-if="script.pages && script.pages.length > 0" style="margin-top: 12px; padding-top: 12px; border-top: 1px dashed #e8e8e8;">
|
|||
|
|
<strong style="color: #666; font-size: 13px;">逐页脚本 ({{ script.pages.length }}页):</strong>
|
|||
|
|
<div style="margin-top: 8px;">
|
|||
|
|
<a-collapse size="small" :activeKey="getExpandedPages(script)">
|
|||
|
|
<a-collapse-panel v-for="(page, pIndex) in script.pages" :key="pIndex">
|
|||
|
|
<template #header>
|
|||
|
|
<span>第 {{ page.pageNumber }} 页</span>
|
|||
|
|
<span v-if="hasPageContent(page)" style="color: #52c41a; margin-left: 8px; font-size: 12px;">●</span>
|
|||
|
|
</template>
|
|||
|
|
<div class="script-page-detail">
|
|||
|
|
<div v-if="page.questions" style="margin-bottom: 8px;">
|
|||
|
|
<strong>教师话术:</strong>
|
|||
|
|
<div style="background: #f5f5f5; padding: 6px 10px; border-radius: 4px; margin-top: 4px; white-space: pre-wrap; font-size: 13px;">
|
|||
|
|
{{ page.questions }}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div v-if="page.teacherNotes" style="margin-bottom: 8px;">
|
|||
|
|
<strong>教学备注:</strong>
|
|||
|
|
<span style="color: #666; font-size: 13px;">{{ page.teacherNotes }}</span>
|
|||
|
|
</div>
|
|||
|
|
<div v-if="getPageResources(script, page).length > 0">
|
|||
|
|
<strong>关联资源:</strong>
|
|||
|
|
<div style="display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px;">
|
|||
|
|
<a-tag
|
|||
|
|
v-for="res in getPageResources(script, page)"
|
|||
|
|
:key="res.id"
|
|||
|
|
size="small"
|
|||
|
|
style="cursor: pointer;"
|
|||
|
|
@click="previewResource(res)"
|
|||
|
|
>
|
|||
|
|
<span style="margin-right: 2px;">{{ res.icon }}</span>
|
|||
|
|
{{ res.name }}
|
|||
|
|
</a-tag>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</a-collapse-panel>
|
|||
|
|
</a-collapse>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</a-timeline-item>
|
|||
|
|
</a-timeline>
|
|||
|
|
<a-empty v-else description="暂无教学流程" />
|
|||
|
|
</a-card>
|
|||
|
|
|
|||
|
|
<!-- 延伸活动 -->
|
|||
|
|
<a-card title="延伸活动" :bordered="false" style="margin-bottom: 16px;">
|
|||
|
|
<a-list v-if="activities && activities.length > 0" :data-source="activities" item-layout="horizontal">
|
|||
|
|
<template #renderItem="{ item }">
|
|||
|
|
<a-list-item>
|
|||
|
|
<template #actions>
|
|||
|
|
<span></span>
|
|||
|
|
</template>
|
|||
|
|
<a-list-item-meta>
|
|||
|
|
<template #title>
|
|||
|
|
{{ item.name }}
|
|||
|
|
<a-tag size="small" :style="getActivityTypeStyle(translateActivityType(item.activityType))" style="margin-left: 8px;">
|
|||
|
|
{{ translateActivityType(item.activityType) }}
|
|||
|
|
</a-tag>
|
|||
|
|
<span style="color: #999; font-size: 12px; margin-left: 8px;">
|
|||
|
|
{{ item.duration }} 分钟
|
|||
|
|
</span>
|
|||
|
|
</template>
|
|||
|
|
<template #description>
|
|||
|
|
<div v-if="item.content" style="margin-bottom: 4px;">
|
|||
|
|
<strong>活动内容:</strong>{{ item.content }}
|
|||
|
|
</div>
|
|||
|
|
<div v-if="item.materials">
|
|||
|
|
<strong>所需材料:</strong>{{ item.materials }}
|
|||
|
|
</div>
|
|||
|
|
<div v-if="!item.content && !item.materials" style="color: #999;">
|
|||
|
|
暂无详细说明
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
</a-list-item-meta>
|
|||
|
|
</a-list-item>
|
|||
|
|
</template>
|
|||
|
|
</a-list>
|
|||
|
|
<a-empty v-else description="暂无延伸活动" />
|
|||
|
|
</a-card>
|
|||
|
|
|
|||
|
|
<!-- 测评工具 -->
|
|||
|
|
<a-card title="测评工具" :bordered="false" style="margin-bottom: 16px;" v-if="assessment">
|
|||
|
|
<div v-if="assessment.enabled">
|
|||
|
|
<a-descriptions :column="2" bordered size="small">
|
|||
|
|
<a-descriptions-item label="测评状态" :span="2">
|
|||
|
|
<a-tag color="success">已启用</a-tag>
|
|||
|
|
</a-descriptions-item>
|
|||
|
|
<a-descriptions-item label="测评类型" :span="2">
|
|||
|
|
<a-tag v-for="type in assessment.types" :key="type" color="processing" style="margin-right: 4px;">
|
|||
|
|
{{ translateAssessmentType(type) }}
|
|||
|
|
</a-tag>
|
|||
|
|
<span v-if="!assessment.types || assessment.types.length === 0">-</span>
|
|||
|
|
</a-descriptions-item>
|
|||
|
|
<a-descriptions-item label="评价指标" :span="2">
|
|||
|
|
<div v-if="assessment.indicators && assessment.indicators.length > 0">
|
|||
|
|
<a-tag v-for="indicator in assessment.indicators" :key="indicator.name" style="margin-right: 4px; margin-bottom: 4px;">
|
|||
|
|
{{ indicator.name }} ({{ translateLevel(indicator.level) }})
|
|||
|
|
</a-tag>
|
|||
|
|
</div>
|
|||
|
|
<span v-else>-</span>
|
|||
|
|
</a-descriptions-item>
|
|||
|
|
<a-descriptions-item label="评语模板" :span="2">
|
|||
|
|
{{ assessment.commentTemplate || '暂无' }}
|
|||
|
|
</a-descriptions-item>
|
|||
|
|
<a-descriptions-item label="家长反馈" :span="2">
|
|||
|
|
<a-tag :color="assessment.parentFeedback ? 'success' : 'default'">
|
|||
|
|
{{ assessment.parentFeedback ? '需要' : '不需要' }}
|
|||
|
|
</a-tag>
|
|||
|
|
</a-descriptions-item>
|
|||
|
|
</a-descriptions>
|
|||
|
|
</div>
|
|||
|
|
<div v-else>
|
|||
|
|
<a-tag color="default">未启用测评</a-tag>
|
|||
|
|
</div>
|
|||
|
|
</a-card>
|
|||
|
|
</a-col>
|
|||
|
|
|
|||
|
|
<!-- 右侧信息 -->
|
|||
|
|
<a-col :span="8">
|
|||
|
|
<!-- 使用统计 -->
|
|||
|
|
<a-card title="使用统计" :bordered="false" style="margin-bottom: 16px;">
|
|||
|
|
<a-statistic
|
|||
|
|
title="使用次数"
|
|||
|
|
:value="course.usageCount || 0"
|
|||
|
|
style="margin-bottom: 16px;"
|
|||
|
|
/>
|
|||
|
|
<a-statistic
|
|||
|
|
title="使用教师"
|
|||
|
|
:value="course.teacherCount || 0"
|
|||
|
|
style="margin-bottom: 16px;"
|
|||
|
|
/>
|
|||
|
|
<a-statistic
|
|||
|
|
title="平均评分"
|
|||
|
|
:value="course.avgRating || 0"
|
|||
|
|
:precision="1"
|
|||
|
|
suffix="/ 5"
|
|||
|
|
/>
|
|||
|
|
</a-card>
|
|||
|
|
|
|||
|
|
<!-- 数字资源 -->
|
|||
|
|
<a-card title="数字资源" :bordered="false" style="margin-bottom: 16px;">
|
|||
|
|
<!-- 电子绘本 -->
|
|||
|
|
<div v-if="ebookPaths && ebookPaths.length > 0" style="margin-bottom: 12px;">
|
|||
|
|
<div style="font-weight: 500; margin-bottom: 8px; color: #1890ff;">
|
|||
|
|
<FileTextOutlined /> 电子绘本
|
|||
|
|
</div>
|
|||
|
|
<div v-for="(item, index) in ebookPaths" :key="index" class="resource-item" @click="previewFile(item.path, item.name)">
|
|||
|
|
<FileTextOutlined class="resource-icon" style="color: #1890ff;" />
|
|||
|
|
<span class="resource-name">{{ item.name || `电子绘本${index + 1}` }}</span>
|
|||
|
|
<EyeOutlined class="resource-preview-icon" />
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<!-- 音频 -->
|
|||
|
|
<div v-if="audioPaths && audioPaths.length > 0" style="margin-bottom: 12px;">
|
|||
|
|
<div style="font-weight: 500; margin-bottom: 8px; color: #52c41a;">
|
|||
|
|
<AudioOutlined /> 音频资源
|
|||
|
|
</div>
|
|||
|
|
<div v-for="(item, index) in audioPaths" :key="index" class="resource-item" @click="previewFile(item.path, item.name)">
|
|||
|
|
<AudioOutlined class="resource-icon" style="color: #52c41a;" />
|
|||
|
|
<span class="resource-name">{{ item.name || `音频${index + 1}` }}</span>
|
|||
|
|
<PlayCircleOutlined class="resource-preview-icon" />
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<!-- 视频 -->
|
|||
|
|
<div v-if="videoPaths && videoPaths.length > 0" style="margin-bottom: 12px;">
|
|||
|
|
<div style="font-weight: 500; margin-bottom: 8px; color: #722ed1;">
|
|||
|
|
<VideoCameraOutlined /> 视频资源
|
|||
|
|
</div>
|
|||
|
|
<div v-for="(item, index) in videoPaths" :key="index" class="resource-item" @click="previewFile(item.path, item.name)">
|
|||
|
|
<VideoCameraOutlined class="resource-icon" style="color: #722ed1;" />
|
|||
|
|
<span class="resource-name">{{ item.name || `视频${index + 1}` }}</span>
|
|||
|
|
<PlayCircleOutlined class="resource-preview-icon" />
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<!-- 其他素材 -->
|
|||
|
|
<div v-if="otherResources && otherResources.length > 0" style="margin-bottom: 12px;">
|
|||
|
|
<div style="font-weight: 500; margin-bottom: 8px; color: #fa8c16;">
|
|||
|
|
<FileOutlined /> 其他素材
|
|||
|
|
</div>
|
|||
|
|
<div v-for="(item, index) in otherResources" :key="index" class="resource-item" @click="previewFile(item.path, item.name)">
|
|||
|
|
<FileOutlined class="resource-icon" style="color: #fa8c16;" />
|
|||
|
|
<span class="resource-name">{{ item.name || `素材${index + 1}` }}</span>
|
|||
|
|
<EyeOutlined class="resource-preview-icon" />
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<a-empty v-if="(!ebookPaths || ebookPaths.length === 0) && (!audioPaths || audioPaths.length === 0) && (!videoPaths || videoPaths.length === 0) && (!otherResources || otherResources.length === 0)" description="暂无数字资源" />
|
|||
|
|
</a-card>
|
|||
|
|
|
|||
|
|
<!-- 教学材料 -->
|
|||
|
|
<a-card title="教学材料" :bordered="false" style="margin-bottom: 16px;">
|
|||
|
|
<!-- 教学PPT -->
|
|||
|
|
<div v-if="course.pptPath" style="margin-bottom: 12px;">
|
|||
|
|
<div style="font-weight: 500; margin-bottom: 8px; color: #1890ff;">
|
|||
|
|
<FilePptOutlined /> 教学PPT
|
|||
|
|
</div>
|
|||
|
|
<div class="resource-item" @click="previewFile(course.pptPath, course.pptName || '教学PPT')">
|
|||
|
|
<FilePptOutlined class="resource-icon" style="color: #1890ff;" />
|
|||
|
|
<span class="resource-name">{{ course.pptName || '教学PPT' }}</span>
|
|||
|
|
<EyeOutlined class="resource-preview-icon" />
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<!-- 教学挂图 -->
|
|||
|
|
<div v-if="posterPaths && posterPaths.length > 0" style="margin-bottom: 12px;">
|
|||
|
|
<div style="font-weight: 500; margin-bottom: 8px; color: #52c41a;">
|
|||
|
|
<PictureOutlined /> 教学挂图
|
|||
|
|
</div>
|
|||
|
|
<div class="poster-grid">
|
|||
|
|
<img v-for="(item, index) in posterPaths" :key="index"
|
|||
|
|
:src="getFileUrl(item.path)"
|
|||
|
|
:alt="item.name || `挂图${index + 1}`"
|
|||
|
|
class="poster-thumbnail"
|
|||
|
|
@click="previewImage(getFileUrl(item.path))"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<!-- 实体教具 -->
|
|||
|
|
<div v-if="tools && tools.length > 0" style="margin-bottom: 12px;">
|
|||
|
|
<div style="font-weight: 500; margin-bottom: 8px; color: #722ed1;">
|
|||
|
|
<ToolOutlined /> 实体教具
|
|||
|
|
</div>
|
|||
|
|
<div v-for="(item, index) in tools" :key="index" style="padding: 4px 0; border-bottom: 1px solid #f0f0f0;">
|
|||
|
|
{{ item.name }} x {{ item.quantity }}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<!-- 学生材料 -->
|
|||
|
|
<div v-if="course.studentMaterials" style="margin-bottom: 12px;">
|
|||
|
|
<div style="font-weight: 500; margin-bottom: 8px; color: #fa8c16;">
|
|||
|
|
<FormOutlined /> 学生材料
|
|||
|
|
</div>
|
|||
|
|
<div style="padding: 4px 0; white-space: pre-wrap;">{{ course.studentMaterials }}</div>
|
|||
|
|
</div>
|
|||
|
|
<a-empty v-if="!course.pptPath && (!posterPaths || posterPaths.length === 0) && (!tools || tools.length === 0) && !course.studentMaterials" description="暂无教学材料" />
|
|||
|
|
</a-card>
|
|||
|
|
|
|||
|
|
<!-- 版本记录 -->
|
|||
|
|
<a-card title="版本记录" :bordered="false">
|
|||
|
|
<a-timeline mode="left" size="small">
|
|||
|
|
<a-timeline-item color="green">
|
|||
|
|
<div>创建课程</div>
|
|||
|
|
<div style="color: #999; font-size: 12px;">{{ formatDate(course.createdAt) }}</div>
|
|||
|
|
</a-timeline-item>
|
|||
|
|
<a-timeline-item v-if="course.publishedAt" color="blue">
|
|||
|
|
<div>发布课程</div>
|
|||
|
|
<div style="color: #999; font-size: 12px;">{{ formatDate(course.publishedAt) }}</div>
|
|||
|
|
</a-timeline-item>
|
|||
|
|
<a-timeline-item color="gray">
|
|||
|
|
<div>最后更新</div>
|
|||
|
|
<div style="color: #999; font-size: 12px;">{{ formatDate(course.updatedAt) }}</div>
|
|||
|
|
</a-timeline-item>
|
|||
|
|
</a-timeline>
|
|||
|
|
</a-card>
|
|||
|
|
</a-col>
|
|||
|
|
</a-row>
|
|||
|
|
</div>
|
|||
|
|
</a-spin>
|
|||
|
|
|
|||
|
|
<!-- 图片预览 -->
|
|||
|
|
<a-modal v-model:open="imagePreviewVisible" :footer="null" centered>
|
|||
|
|
<img :src="previewImageUrl" style="width: 100%;" />
|
|||
|
|
</a-modal>
|
|||
|
|
|
|||
|
|
<!-- 文件预览弹窗 -->
|
|||
|
|
<FilePreviewModal
|
|||
|
|
v-model:open="previewModalVisible"
|
|||
|
|
:file-url="previewFileUrl"
|
|||
|
|
:file-name="previewFileName"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<script setup lang="ts">
|
|||
|
|
import { ref, onMounted, computed } from 'vue';
|
|||
|
|
import { useRouter, useRoute } from 'vue-router';
|
|||
|
|
import { message, Modal } from 'ant-design-vue';
|
|||
|
|
import {
|
|||
|
|
EditOutlined,
|
|||
|
|
BarChartOutlined,
|
|||
|
|
DeleteOutlined,
|
|||
|
|
DownOutlined,
|
|||
|
|
ClockCircleOutlined,
|
|||
|
|
FileTextOutlined,
|
|||
|
|
FilePptOutlined,
|
|||
|
|
VideoCameraOutlined,
|
|||
|
|
AudioOutlined,
|
|||
|
|
FileOutlined,
|
|||
|
|
PictureOutlined,
|
|||
|
|
ToolOutlined,
|
|||
|
|
FormOutlined,
|
|||
|
|
EyeOutlined,
|
|||
|
|
PlayCircleOutlined,
|
|||
|
|
} from '@ant-design/icons-vue';
|
|||
|
|
import * as courseApi from '@/api/course';
|
|||
|
|
import FilePreviewModal from '@/components/FilePreviewModal.vue';
|
|||
|
|
import {
|
|||
|
|
translateGradeTag,
|
|||
|
|
translateDomainTag,
|
|||
|
|
getGradeTagStyle,
|
|||
|
|
getDomainTagStyle,
|
|||
|
|
translateActivityType,
|
|||
|
|
getActivityTypeStyle,
|
|||
|
|
translateStepType,
|
|||
|
|
getStepTypeStyle,
|
|||
|
|
translateCourseStatus,
|
|||
|
|
getCourseStatusStyle,
|
|||
|
|
} from '@/utils/tagMaps';
|
|||
|
|
|
|||
|
|
const router = useRouter();
|
|||
|
|
const route = useRoute();
|
|||
|
|
|
|||
|
|
// 获取完整的文件 URL
|
|||
|
|
const getFileUrl = (filePath: string | null | undefined): string => {
|
|||
|
|
if (!filePath) return '';
|
|||
|
|
if (filePath.startsWith('http://') || filePath.startsWith('https://')) {
|
|||
|
|
return filePath;
|
|||
|
|
}
|
|||
|
|
const SERVER_BASE = import.meta.env.VITE_SERVER_BASE_URL || 'http://localhost:3000';
|
|||
|
|
return `${SERVER_BASE}${filePath}`;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const loading = ref(false);
|
|||
|
|
const course = ref<any>({
|
|||
|
|
name: '',
|
|||
|
|
status: 'DRAFT',
|
|||
|
|
version: '1.0',
|
|||
|
|
duration: 25,
|
|||
|
|
gradeTags: '[]',
|
|||
|
|
domainTags: '[]',
|
|||
|
|
description: '',
|
|||
|
|
pictureBookName: '',
|
|||
|
|
usageCount: 0,
|
|||
|
|
teacherCount: 0,
|
|||
|
|
avgRating: 0,
|
|||
|
|
createdAt: null,
|
|||
|
|
updatedAt: null,
|
|||
|
|
publishedAt: null,
|
|||
|
|
pptPath: '',
|
|||
|
|
pptName: '',
|
|||
|
|
studentMaterials: '',
|
|||
|
|
});
|
|||
|
|
const scripts = ref<any[]>([]);
|
|||
|
|
const activities = ref<any[]>([]);
|
|||
|
|
const assessment = ref<any>(null);
|
|||
|
|
|
|||
|
|
// 数字资源
|
|||
|
|
const ebookPaths = ref<any[]>([]);
|
|||
|
|
const audioPaths = ref<any[]>([]);
|
|||
|
|
const videoPaths = ref<any[]>([]);
|
|||
|
|
const otherResources = ref<any[]>([]);
|
|||
|
|
|
|||
|
|
// 教学材料
|
|||
|
|
const posterPaths = ref<any[]>([]);
|
|||
|
|
const tools = ref<any[]>([]);
|
|||
|
|
|
|||
|
|
// 图片预览
|
|||
|
|
const imagePreviewVisible = ref(false);
|
|||
|
|
const previewImageUrl = ref('');
|
|||
|
|
|
|||
|
|
// 文件预览
|
|||
|
|
const previewModalVisible = ref(false);
|
|||
|
|
const previewFileUrl = ref('');
|
|||
|
|
const previewFileName = ref('');
|
|||
|
|
|
|||
|
|
const grades = computed(() => {
|
|||
|
|
if (!course.value.gradeTags) return [];
|
|||
|
|
try {
|
|||
|
|
const tags = JSON.parse(course.value.gradeTags);
|
|||
|
|
return tags.map((tag: string) => translateGradeTag(tag));
|
|||
|
|
} catch {
|
|||
|
|
return [];
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const domainTags = computed(() => {
|
|||
|
|
if (!course.value.domainTags) return [];
|
|||
|
|
try {
|
|||
|
|
const tags = JSON.parse(course.value.domainTags);
|
|||
|
|
return tags.map((tag: string) => translateDomainTag(tag));
|
|||
|
|
} catch {
|
|||
|
|
return [];
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 根据脚本的 resourceIds 获取资源详情列表
|
|||
|
|
const getScriptResources = (script: any) => {
|
|||
|
|
// 处理 resourceIds,可能是字符串或数组
|
|||
|
|
let resourceIds: string[] = [];
|
|||
|
|
if (script.resourceIds) {
|
|||
|
|
if (typeof script.resourceIds === 'string') {
|
|||
|
|
try {
|
|||
|
|
resourceIds = JSON.parse(script.resourceIds);
|
|||
|
|
} catch {
|
|||
|
|
resourceIds = [];
|
|||
|
|
}
|
|||
|
|
} else if (Array.isArray(script.resourceIds)) {
|
|||
|
|
resourceIds = script.resourceIds;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!resourceIds.length) return [];
|
|||
|
|
|
|||
|
|
const resources: { id: string; name: string; typeName: string; icon: string }[] = [];
|
|||
|
|
|
|||
|
|
// 资源类型信息
|
|||
|
|
const typeInfo: Record<string, { name: string; icon: string }> = {
|
|||
|
|
ebook: { name: '电子绘本', icon: '📄' },
|
|||
|
|
audio: { name: '音频', icon: '🎵' },
|
|||
|
|
video: { name: '视频', icon: '📹' },
|
|||
|
|
ppt: { name: 'PPT', icon: '📊' },
|
|||
|
|
poster: { name: '挂图', icon: '🖼️' },
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
resourceIds.forEach((resId: string) => {
|
|||
|
|
const parts = resId.split('-');
|
|||
|
|
if (parts.length !== 2) return;
|
|||
|
|
|
|||
|
|
const type = parts[0];
|
|||
|
|
const index = parseInt(parts[1]);
|
|||
|
|
const info = typeInfo[type] || { name: '资源', icon: '📁' };
|
|||
|
|
|
|||
|
|
let name = '';
|
|||
|
|
switch (type) {
|
|||
|
|
case 'ebook':
|
|||
|
|
name = ebookPaths.value[index]?.name || `电子绘本${index + 1}`;
|
|||
|
|
break;
|
|||
|
|
case 'audio':
|
|||
|
|
name = audioPaths.value[index]?.name || `音频${index + 1}`;
|
|||
|
|
break;
|
|||
|
|
case 'video':
|
|||
|
|
name = videoPaths.value[index]?.name || `视频${index + 1}`;
|
|||
|
|
break;
|
|||
|
|
case 'ppt':
|
|||
|
|
name = course.value.pptName || '教学PPT';
|
|||
|
|
break;
|
|||
|
|
case 'poster':
|
|||
|
|
name = posterPaths.value[index]?.name || `挂图${index + 1}`;
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (name) {
|
|||
|
|
resources.push({
|
|||
|
|
id: resId,
|
|||
|
|
name,
|
|||
|
|
typeName: info.name,
|
|||
|
|
icon: info.icon,
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
return resources;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 获取逐页脚本的资源列表
|
|||
|
|
const getPageResources = (script: any, page: any) => {
|
|||
|
|
if (!page.resourceIds || page.resourceIds.length === 0) return [];
|
|||
|
|
|
|||
|
|
// 获取当前环节的所有资源
|
|||
|
|
const scriptResources = getScriptResources(script);
|
|||
|
|
const scriptResourceIds = scriptResources.map((r: any) => r.id);
|
|||
|
|
|
|||
|
|
// 过滤出页面关联的资源
|
|||
|
|
let pageResourceIds: string[] = [];
|
|||
|
|
if (typeof page.resourceIds === 'string') {
|
|||
|
|
try {
|
|||
|
|
pageResourceIds = JSON.parse(page.resourceIds);
|
|||
|
|
} catch {
|
|||
|
|
pageResourceIds = [];
|
|||
|
|
}
|
|||
|
|
} else if (Array.isArray(page.resourceIds)) {
|
|||
|
|
pageResourceIds = page.resourceIds;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return scriptResources.filter((r: any) => pageResourceIds.includes(r.id));
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 检查页面是否有内容
|
|||
|
|
const hasPageContent = (page: any) => {
|
|||
|
|
return (page.questions && page.questions.trim()) ||
|
|||
|
|
(page.teacherNotes && page.teacherNotes.trim()) ||
|
|||
|
|
(page.resourceIds && page.resourceIds.length > 0);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 获取有内容的页面索引(用于默认展开)
|
|||
|
|
const getExpandedPages = (script: any) => {
|
|||
|
|
if (!script.pages) return [];
|
|||
|
|
return script.pages
|
|||
|
|
.map((page: any, index: number) => hasPageContent(page) ? String(index) : null)
|
|||
|
|
.filter((idx: string | null) => idx !== null);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 预览资源(教学环节和逐页脚本中的资源)
|
|||
|
|
const previewResource = (res: any) => {
|
|||
|
|
// 获取资源的实际URL
|
|||
|
|
const resId = res.id;
|
|||
|
|
const parts = resId.split('-');
|
|||
|
|
if (parts.length !== 2) return;
|
|||
|
|
|
|||
|
|
const type = parts[0];
|
|||
|
|
const index = parseInt(parts[1]);
|
|||
|
|
|
|||
|
|
let path = '';
|
|||
|
|
switch (type) {
|
|||
|
|
case 'ebook':
|
|||
|
|
path = ebookPaths.value[index]?.path || '';
|
|||
|
|
break;
|
|||
|
|
case 'audio':
|
|||
|
|
path = audioPaths.value[index]?.path || '';
|
|||
|
|
break;
|
|||
|
|
case 'video':
|
|||
|
|
path = videoPaths.value[index]?.path || '';
|
|||
|
|
break;
|
|||
|
|
case 'ppt':
|
|||
|
|
path = course.value.pptPath || '';
|
|||
|
|
break;
|
|||
|
|
case 'poster':
|
|||
|
|
path = posterPaths.value[index]?.path || '';
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (path) {
|
|||
|
|
previewFile(path, res.name);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
onMounted(async () => {
|
|||
|
|
await fetchCourseDetail();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const fetchCourseDetail = async () => {
|
|||
|
|
loading.value = true;
|
|||
|
|
try {
|
|||
|
|
const courseId = +route.params.id;
|
|||
|
|
const data = await courseApi.getCourse(courseId);
|
|||
|
|
course.value = data;
|
|||
|
|
// 处理教学流程数据,包含逐页脚本
|
|||
|
|
scripts.value = (data.scripts || []).map((script: any) => ({
|
|||
|
|
...script,
|
|||
|
|
pages: (script.pages || []).map((page: any) => ({
|
|||
|
|
...page,
|
|||
|
|
resourceIds: typeof page.resourceIds === 'string' ? JSON.parse(page.resourceIds) : (page.resourceIds || []),
|
|||
|
|
})),
|
|||
|
|
}));
|
|||
|
|
|
|||
|
|
// 处理活动数据
|
|||
|
|
activities.value = (data.activities || []).map((activity: any) => {
|
|||
|
|
let content = '';
|
|||
|
|
if (activity.onlineMaterials) {
|
|||
|
|
if (typeof activity.onlineMaterials === 'object') {
|
|||
|
|
content = activity.onlineMaterials.content || '';
|
|||
|
|
} else if (typeof activity.onlineMaterials === 'string') {
|
|||
|
|
try {
|
|||
|
|
const parsed = JSON.parse(activity.onlineMaterials);
|
|||
|
|
content = parsed.content || '';
|
|||
|
|
} catch {
|
|||
|
|
content = activity.onlineMaterials;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return {
|
|||
|
|
...activity,
|
|||
|
|
content: content,
|
|||
|
|
materials: activity.offlineMaterials || '',
|
|||
|
|
};
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 解析数字资源 - admin API返回原始JSON字符串
|
|||
|
|
if (data.ebookPaths) {
|
|||
|
|
if (typeof data.ebookPaths === 'string') {
|
|||
|
|
try { ebookPaths.value = JSON.parse(data.ebookPaths); } catch { ebookPaths.value = []; }
|
|||
|
|
} else if (Array.isArray(data.ebookPaths)) {
|
|||
|
|
ebookPaths.value = data.ebookPaths;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if (data.audioPaths) {
|
|||
|
|
if (typeof data.audioPaths === 'string') {
|
|||
|
|
try { audioPaths.value = JSON.parse(data.audioPaths); } catch { audioPaths.value = []; }
|
|||
|
|
} else if (Array.isArray(data.audioPaths)) {
|
|||
|
|
audioPaths.value = data.audioPaths;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if (data.videoPaths) {
|
|||
|
|
if (typeof data.videoPaths === 'string') {
|
|||
|
|
try { videoPaths.value = JSON.parse(data.videoPaths); } catch { videoPaths.value = []; }
|
|||
|
|
} else if (Array.isArray(data.videoPaths)) {
|
|||
|
|
videoPaths.value = data.videoPaths;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if (data.otherResources) {
|
|||
|
|
if (typeof data.otherResources === 'string') {
|
|||
|
|
try { otherResources.value = JSON.parse(data.otherResources); } catch { otherResources.value = []; }
|
|||
|
|
} else if (Array.isArray(data.otherResources)) {
|
|||
|
|
otherResources.value = data.otherResources;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 解析教学材料
|
|||
|
|
if (data.posterPaths) {
|
|||
|
|
if (typeof data.posterPaths === 'string') {
|
|||
|
|
try { posterPaths.value = JSON.parse(data.posterPaths); } catch { posterPaths.value = []; }
|
|||
|
|
} else if (Array.isArray(data.posterPaths)) {
|
|||
|
|
posterPaths.value = data.posterPaths;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if (data.tools) {
|
|||
|
|
if (typeof data.tools === 'string') {
|
|||
|
|
try { tools.value = JSON.parse(data.tools); } catch { tools.value = []; }
|
|||
|
|
} else if (Array.isArray(data.tools)) {
|
|||
|
|
tools.value = data.tools;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 解析测评工具
|
|||
|
|
if (data.assessmentData) {
|
|||
|
|
if (typeof data.assessmentData === 'string') {
|
|||
|
|
try {
|
|||
|
|
assessment.value = JSON.parse(data.assessmentData);
|
|||
|
|
} catch {
|
|||
|
|
assessment.value = { enabled: false };
|
|||
|
|
}
|
|||
|
|
} else if (typeof data.assessmentData === 'object') {
|
|||
|
|
assessment.value = data.assessmentData;
|
|||
|
|
} else {
|
|||
|
|
assessment.value = { enabled: false };
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
assessment.value = { enabled: false };
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('获取课程详情失败:', error);
|
|||
|
|
message.error('获取课程详情失败');
|
|||
|
|
} finally {
|
|||
|
|
loading.value = false;
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 翻译页面动作
|
|||
|
|
const translateAction = (action: string): string => {
|
|||
|
|
const actionMap: Record<string, string> = {
|
|||
|
|
show: '展示页面',
|
|||
|
|
read: '朗读文字',
|
|||
|
|
discuss: '引导讨论',
|
|||
|
|
interact: '互动提问',
|
|||
|
|
};
|
|||
|
|
return actionMap[action] || action;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 翻译测评类型
|
|||
|
|
const translateAssessmentType = (type: string): string => {
|
|||
|
|
const typeMap: Record<string, string> = {
|
|||
|
|
observation: '观察记录',
|
|||
|
|
work: '作品评价',
|
|||
|
|
participation: '参与度评价',
|
|||
|
|
quiz: '问答测验',
|
|||
|
|
};
|
|||
|
|
return typeMap[type] || type;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 翻译等级
|
|||
|
|
const translateLevel = (level: string): string => {
|
|||
|
|
const levelMap: Record<string, string> = {
|
|||
|
|
high: '高',
|
|||
|
|
medium: '中',
|
|||
|
|
low: '低',
|
|||
|
|
};
|
|||
|
|
return levelMap[level] || level;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 图片预览
|
|||
|
|
const previewImage = (url: string) => {
|
|||
|
|
previewImageUrl.value = url;
|
|||
|
|
imagePreviewVisible.value = true;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 文件预览
|
|||
|
|
const previewFile = (filePath: string, fileName: string) => {
|
|||
|
|
if (!filePath) {
|
|||
|
|
message.warning('该资源暂无可预览的文件');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
const SERVER_BASE = import.meta.env.VITE_SERVER_BASE_URL || 'http://localhost:3000';
|
|||
|
|
const fullUrl = filePath.startsWith('http') ? filePath : `${SERVER_BASE}${filePath}`;
|
|||
|
|
previewFileUrl.value = fullUrl;
|
|||
|
|
previewFileName.value = fileName || '资源预览';
|
|||
|
|
previewModalVisible.value = true;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const editCourse = () => {
|
|||
|
|
router.push(`/admin/courses/${route.params.id}/edit`);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const viewStats = () => {
|
|||
|
|
router.push(`/admin/courses/${route.params.id}/stats`);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const deleteCourse = async () => {
|
|||
|
|
try {
|
|||
|
|
await courseApi.deleteCourse(+route.params.id);
|
|||
|
|
message.success('删除成功');
|
|||
|
|
router.push('/admin/courses');
|
|||
|
|
} catch (error) {
|
|||
|
|
message.error('删除失败');
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleMenuClick = ({ key }: { key: string | number }) => {
|
|||
|
|
handleMoreAction(String(key));
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleMoreAction = (key: string) => {
|
|||
|
|
if (key === 'unpublish') {
|
|||
|
|
unpublishCourse();
|
|||
|
|
} else if (key === 'iterate') {
|
|||
|
|
iterateCourse();
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const unpublishCourse = async () => {
|
|||
|
|
Modal.confirm({
|
|||
|
|
title: '确认下架',
|
|||
|
|
content: '下架后教师端将无法看到此课程包,确认继续?',
|
|||
|
|
onOk: async () => {
|
|||
|
|
try {
|
|||
|
|
await courseApi.unpublishCourse(+route.params.id);
|
|||
|
|
message.success('下架成功');
|
|||
|
|
await fetchCourseDetail();
|
|||
|
|
} catch (error) {
|
|||
|
|
message.error('下架失败');
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const iterateCourse = () => {
|
|||
|
|
router.push(`/admin/courses/${route.params.id}/iterate`);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const formatDate = (dateStr: string) => {
|
|||
|
|
if (!dateStr) return '-';
|
|||
|
|
const date = new Date(dateStr);
|
|||
|
|
return date.toLocaleString('zh-CN');
|
|||
|
|
};
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<style scoped lang="scss">
|
|||
|
|
.course-detail {
|
|||
|
|
background: #f5f5f5;
|
|||
|
|
min-height: calc(100vh - 64px);
|
|||
|
|
|
|||
|
|
:deep(.ant-page-header) {
|
|||
|
|
background: white;
|
|||
|
|
padding: 16px 24px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
:deep(.ant-card) {
|
|||
|
|
margin-bottom: 16px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
:deep(.ant-descriptions-item-label) {
|
|||
|
|
font-weight: 500;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.script-page-detail {
|
|||
|
|
padding: 8px 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.poster-grid {
|
|||
|
|
display: grid;
|
|||
|
|
grid-template-columns: repeat(3, 1fr);
|
|||
|
|
gap: 8px;
|
|||
|
|
|
|||
|
|
.poster-thumbnail {
|
|||
|
|
width: 100%;
|
|||
|
|
height: 80px;
|
|||
|
|
object-fit: cover;
|
|||
|
|
border-radius: 4px;
|
|||
|
|
cursor: pointer;
|
|||
|
|
transition: transform 0.2s;
|
|||
|
|
|
|||
|
|
&:hover {
|
|||
|
|
transform: scale(1.05);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 资源项样式
|
|||
|
|
.resource-item {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
padding: 8px 12px;
|
|||
|
|
border-bottom: 1px solid #f0f0f0;
|
|||
|
|
cursor: pointer;
|
|||
|
|
transition: all 0.2s ease;
|
|||
|
|
border-radius: 4px;
|
|||
|
|
margin-bottom: 4px;
|
|||
|
|
|
|||
|
|
&:hover {
|
|||
|
|
background: #f5f5f5;
|
|||
|
|
|
|||
|
|
.resource-preview-icon {
|
|||
|
|
opacity: 1;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.resource-icon {
|
|||
|
|
font-size: 16px;
|
|||
|
|
margin-right: 8px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.resource-name {
|
|||
|
|
flex: 1;
|
|||
|
|
color: #333;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.resource-preview-icon {
|
|||
|
|
color: #1890ff;
|
|||
|
|
opacity: 0;
|
|||
|
|
transition: opacity 0.2s ease;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
</style>
|