Procházet zdrojové kódy

feat(collapsibleToolbar): 新增KWL表格跨学科学习工具

1.  新增侧边栏跨学科菜单入口,添加KWL表格工具卡片
2.  实现KWL表格详情配置页面,支持自定义任务说明、上传素材、配置占位符与反思区
3.  完成国际化适配,将保存/发布按钮替换为多语言字段
4.  实现工具添加到幻灯片的交互逻辑
lsc před 4 dny
rodič
revize
f92a604671

binární
src/assets/img/kwlBanner.jpg


+ 906 - 0
src/components/CollapsibleToolbar/cocolearnCom/KwlDetail.vue

@@ -0,0 +1,906 @@
+<template>
+  <div class="kwl-detail">
+    <!-- 头部导航 -->
+    <div class="detail-header">
+      <div class="back-btn" @click="$emit('back')">
+        <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+          <path d="M15 18l-6-6 6-6" />
+        </svg>
+      </div>
+      <div class="detail-title">{{ lang.ssKwlTitle }}</div>
+    </div>
+
+    <!-- Banner 区域 -->
+    <div class="detail-banner">
+      <img :src="kwl" :alt="lang.ssKwlTitle" />
+      <div class="tool-overly"></div>
+        <div class="tool-icon-badge">
+          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <path d="M9 18h6M10 22h4M12 2a7 7 0 00-4 12.7c.6.5 1 1.3 1 2.3h6c0-1 .4-1.8 1-2.3A7 7 0 0012 2z"></path>
+          </svg>
+        </div>
+    </div>
+
+    <!-- 描述与标签 -->
+    <div class="detail-desc-row">
+      <div class="detail-desc-text">{{ lang.ssKwlDesc }}</div>
+      <div class="help-icon" @mouseenter="showHelp = true" @mouseleave="showHelp = false">
+        <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+          <circle cx="12" cy="12" r="10" />
+          <path d="M9.09 9a3 3 0 015.83 1c0 2-3 3-3 3" />
+          <line x1="12" y1="17" x2="12.01" y2="17" />
+        </svg>
+        <!-- 帮助气泡 -->
+        <div v-if="showHelp" class="help-tooltip">
+          <div class="help-title">{{ lang.ssKwlUsageScenario }}</div>
+          <div class="help-text">{{ lang.ssKwlScenarioText }}</div>
+          <div class="help-title">{{ lang.ssKwlSteps }}</div>
+          <div class="help-list">
+            <div>{{ lang.ssKwlStep1 }}</div>
+            <div>{{ lang.ssKwlStep2 }}</div>
+            <div>{{ lang.ssKwlStep3 }}</div>
+            <div>{{ lang.ssKwlStep4 }}</div>
+          </div>
+          <div class="help-source">{{ lang.ssKwlSource }}</div>
+          <div class="help-purpose">{{ lang.ssKwlPurpose }}</div>
+        </div>
+      </div>
+    </div>
+    <div class="detail-tags">
+      <span class="detail-tag">{{ lang.ssKwlGradeRange }}</span>
+    </div>
+
+    <!-- 工具配置区 -->
+    <div class="config-section">
+      <div class="config-title">{{ lang.ssKwlToolConfig }}</div>
+      <div class="config-subtitle">{{ lang.ssKwlConfigSubtitle }}</div>
+    </div>
+
+    <!-- 情境/任务说明 -->
+    <div class="form-group">
+      <div class="form-label">{{ lang.ssKwlTaskLabel }}</div>
+      <div class="form-textarea">
+        <textarea v-model="formData.taskDescription"
+          :placeholder="lang.ssKwlTaskPlaceholder"></textarea>
+      </div>
+    </div>
+
+    <!-- 添加配图/视频 -->
+    <div class="form-group">
+      <div class="upload-area" @click="triggerFileSelect">
+        <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+          <rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
+          <circle cx="8.5" cy="8.5" r="1.5" />
+          <polyline points="21 15 16 10 5 21" />
+        </svg>
+        <span>{{ lang.ssKwlUploadMedia }}</span>
+      </div>
+      <input
+        ref="fileInputRef"
+        type="file"
+        accept="image/*,video/*"
+        multiple
+        style="display: none"
+        @change="handleFileChange"
+      />
+      <!-- 已上传预览列表 -->
+      <div v-if="uploadedFiles.length > 0" class="upload-preview-list">
+        <div
+          v-for="(file, index) in uploadedFiles"
+          :key="index"
+          class="upload-preview-item"
+        >
+          <img v-if="file.type.startsWith('image/')" :src="file.url" :alt="file.name" @click="openPreview(index)" />
+          <video v-else :src="file.url" muted @click="openPreview(index)"></video>
+          <div class="preview-delete" @click="removeFile(index)">
+            <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+              <line x1="18" y1="6" x2="6" y2="18" />
+              <line x1="6" y1="6" x2="18" y2="18" />
+            </svg>
+          </div>
+          <div v-if="file.uploading" class="preview-loading">
+            <div class="loading-spinner"></div>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <!-- 默认展开操作提示 -->
+    <div class="form-row">
+      <div class="form-text-block">
+        <div class="form-label">{{ lang.ssKwlShowHowTo }}</div>
+        <div class="form-sublabel">{{ lang.ssKwlShowHowToDesc }}</div>
+      </div>
+      <div class="toggle-switch" :class="{ active: formData.showHowTo }"
+        @click="formData.showHowTo = !formData.showHowTo">
+        <div class="toggle-thumb"></div>
+      </div>
+    </div>
+
+    <!-- 高级配置 -->
+    <div class="advanced-section">
+      <div class="advanced-header" @click="showAdvanced = !showAdvanced">
+        <span class="advanced-title">{{ lang.ssKwlAdvanced }}</span>
+        <svg class="advanced-arrow" :class="{ expanded: showAdvanced }" width="16" height="16" viewBox="0 0 24 24"
+          fill="none" stroke="currentColor" stroke-width="2">
+          <polyline points="6 9 12 15 18 9" />
+        </svg>
+      </div>
+
+      <div v-if="showAdvanced" class="advanced-body">
+        <div class="form-group">
+          <div class="form-label">{{ lang.ssKwlKPrompt }}</div>
+          <div class="form-input">
+            <input v-model="formData.kplaceholder" type="text" :placeholder="lang.ssKwlKDefault" />
+          </div>
+        </div>
+
+        <div class="form-group">
+          <div class="form-label">{{ lang.ssKwlWPrompt }}</div>
+          <div class="form-input">
+            <input v-model="formData.wplaceholder" type="text" :placeholder="lang.ssKwlWDefault" />
+          </div>
+        </div>
+
+        <div class="form-group">
+          <div class="form-label">{{ lang.ssKwlLPrompt }}</div>
+          <div class="form-input">
+            <input v-model="formData.lplaceholder" type="text" :placeholder="lang.ssKwlLDefault" />
+          </div>
+        </div>
+
+        <div class="form-row">
+          <div class="form-text-block">
+            <div class="form-label">{{ lang.ssKwlShowReflection }}</div>
+          </div>
+          <div class="toggle-switch" :class="{ active: formData.isShowReflection }"
+            @click="formData.isShowReflection = !formData.isShowReflection">
+            <div class="toggle-thumb"></div>
+          </div>
+        </div>
+
+        <!-- <div class="form-group">
+          <div class="form-row-with-value">
+            <div class="form-label">{{ lang.ssKwlMinEntries }}</div>
+            <div class="range-value">{{ formData.minEntries }}</div>
+          </div>
+          <div class="slider-wrapper">
+            <input v-model.number="formData.minEntries" type="range" min="0" max="10" step="1" class="range-slider" />
+          </div>
+        </div> -->
+      </div>
+    </div>
+
+    <!-- 添加到幻灯片按钮 -->
+    <div class="footer-btn">
+      <div class="add-btn" @click="handleAddToSlide">{{ lang.ssKwlAddToSlide }}</div>
+    </div>
+
+    <!-- 图片预览:复用项目已有组件 -->
+    <PreviewImageTool ref="previewImageToolRef" />
+
+    <!-- 视频预览弹窗 -->
+    <Teleport to="body">
+      <div v-if="videoPreviewVisible" class="kwl-preview-modal" @click.self="closeVideoPreview">
+        <div class="kwl-preview-close" @click="closeVideoPreview">
+          <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <line x1="18" y1="6" x2="6" y2="18" />
+            <line x1="6" y1="6" x2="18" y2="18" />
+          </svg>
+        </div>
+        <video
+          :src="videoPreviewUrl"
+          class="kwl-preview-video"
+          controls
+          autoplay
+        ></video>
+      </div>
+    </Teleport>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { ref, reactive } from 'vue'
+import kwl from '@/assets/img/kwlBanner.jpg'
+import useImport from '@/hooks/useImport'
+import { lang } from '@/main'
+
+const { uploadFileToS3 } = useImport()
+
+interface UploadedFile {
+  name: string
+  url: string
+  type: string
+  uploading: boolean
+}
+
+const emit = defineEmits<{
+  (e: 'back'): void
+  (e: 'add', data: any): void
+}>()
+
+const showHelp = ref(false)
+const showAdvanced = ref(false)
+
+const fileInputRef = ref<HTMLInputElement | null>(null)
+const uploadedFiles = ref<UploadedFile[]>([])
+
+const triggerFileSelect = () => {
+  fileInputRef.value?.click()
+}
+
+const handleFileChange = async (event: Event) => {
+  const target = event.target as HTMLInputElement
+  const files = target.files
+  if (!files || files.length === 0) return
+
+  const validTypes = ['image/', 'video/']
+  const validFiles: File[] = []
+
+  for (let i = 0; i < files.length; i++) {
+    const file = files[i]
+    if (validTypes.some(type => file.type.startsWith(type))) {
+      validFiles.push(file)
+    }
+  }
+
+  for (const file of validFiles) {
+    const item: UploadedFile = {
+      name: file.name,
+      url: '',
+      type: file.type,
+      uploading: true
+    }
+    uploadedFiles.value.push(item)
+    // 通过索引获取响应式对象进行修改
+    const idx = uploadedFiles.value.length - 1
+
+    try {
+      const url = await uploadFileToS3(file)
+      uploadedFiles.value[idx].url = url
+    }
+    catch (err) {
+      console.error(lang.ssKwlUploadFail + ':', file.name, err)
+      uploadedFiles.value.splice(idx, 1)
+    }
+    finally {
+      if (uploadedFiles.value[idx]) {
+        uploadedFiles.value[idx].uploading = false
+      }
+    }
+  }
+
+  // 清空 input value,允许重复选择相同文件
+  if (fileInputRef.value) {
+    fileInputRef.value.value = ''
+  }
+}
+
+const removeFile = (index: number) => {
+  uploadedFiles.value.splice(index, 1)
+}
+
+// 预览相关:图片使用项目已有 previewImageTool 组件
+import PreviewImageTool from '@/views/components/tool/previewImageTool.vue'
+
+const previewImageToolRef = ref<InstanceType<typeof PreviewImageTool> | null>(null)
+const videoPreviewVisible = ref(false)
+const videoPreviewUrl = ref('')
+
+const openPreview = (index: number) => {
+  const file = uploadedFiles.value[index]
+  if (!file || file.uploading) return
+
+  if (file.type.startsWith('image/')) {
+    // 使用项目已有的图片预览组件
+    previewImageToolRef.value?.previewImage(file.url)
+  }
+  else {
+    // 视频预览:使用简单弹窗
+    videoPreviewUrl.value = file.url
+    videoPreviewVisible.value = true
+    document.body.style.overflow = 'hidden'
+  }
+}
+
+const closeVideoPreview = () => {
+  videoPreviewVisible.value = false
+  document.body.style.overflow = ''
+}
+
+const formData = reactive({
+  know: [],
+  want: [],
+  learned: [],
+  reflection: '',
+  taskDescription: '',
+  taskUrl: [] as string[],
+  showHowTo: false,
+  kplaceholder: lang.ssKwlKDefault,
+  wplaceholder: lang.ssKwlWDefault,
+  lplaceholder: lang.ssKwlLDefault,
+  isShowReflection: true
+})
+
+const handleAddToSlide = () => {
+  formData.taskUrl = uploadedFiles.value.map(f => f.url).filter(Boolean)
+  emit('add', { ...formData })
+}
+</script>
+
+<style lang="scss" scoped>
+.kwl-detail {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+  overflow-y: auto;
+  padding: 0 0 20px;
+}
+
+/* 头部 */
+.detail-header {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  padding: 0 14px;
+  position: sticky;
+  top: 0;
+  background: #fff;
+  z-index: 10;
+  margin-bottom: 20px;
+
+  .back-btn {
+    width: 32px;
+    height: 32px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    border-radius: 8px;
+    cursor: pointer;
+    color: #6b7280;
+    transition: all 0.2s ease;
+
+    &:hover {
+      background: #f3f4f6;
+      color: #374151;
+    }
+  }
+
+  .detail-title {
+    font-size: 16px;
+    font-weight: 600;
+    color: #1f2937;
+  }
+}
+
+/* Banner */
+.detail-banner {
+  margin: 0 14px 14px;
+  border-radius: 12px;
+  overflow: hidden;
+  // width: 100%;
+  height: 140px;
+  position: relative;
+  box-sizing: border-box;
+
+  .tool-overly {
+    position: absolute;
+    inset: 0;
+    background: rgba(37, 99, 235, 0.15);
+    opacity: .45;
+    pointer-events: none;
+  }
+
+
+  .tool-icon-badge {
+    position: absolute;
+    top: 10px;
+    left: 10px;
+    width: 32px;
+    height: 32px;
+    border-radius: 8px;
+    background: rgba(255, 255, 255, 0.95);
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
+
+    svg {
+      width: 18px;
+      height: 18px;
+      // color: #FF9300;
+    }
+  }
+
+  .tool-icon-badge svg {
+    color: #2563eb;
+  }
+
+  .tool-overly {
+    background-color: #2563eb;
+  }
+
+  img {
+    width: 100%;
+    height: 140px;
+    object-fit: cover;
+    display: block;
+  }
+}
+
+/* 描述 */
+.detail-desc-row {
+  display: flex;
+  align-items: flex-start;
+  gap: 8px;
+  padding: 0 14px;
+
+  .detail-desc-text {
+    flex: 1;
+    font-size: 13px;
+    color: #4b5563;
+    line-height: 1.6;
+  }
+
+  .help-icon {
+    position: relative;
+    color: #9ca3af;
+    cursor: pointer;
+    flex-shrink: 0;
+    margin-top: 2px;
+
+    &:hover {
+      color: #FF9300;
+    }
+
+    .help-tooltip {
+      position: absolute;
+      top: 100%;
+      right: 0;
+      margin-top: 8px;
+      width: 240px;
+      background: #fff;
+      border: 1px solid #e5e7eb;
+      border-radius: 10px;
+      padding: 14px;
+      box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
+      z-index: 100;
+
+      .help-title {
+        font-size: 13px;
+        font-weight: 600;
+        color: #1f2937;
+        margin-bottom: 4px;
+        margin-top: 8px;
+
+        &:first-child {
+          margin-top: 0;
+        }
+      }
+
+      .help-text {
+        font-size: 12px;
+        color: #6b7280;
+        margin-bottom: 4px;
+      }
+
+      .help-list {
+        font-size: 12px;
+        color: #6b7280;
+        line-height: 1.8;
+      }
+
+      .help-source,
+      .help-purpose {
+        font-size: 11px;
+        color: #9ca3af;
+        margin-top: 8px;
+        line-height: 1.5;
+      }
+    }
+  }
+}
+
+.detail-tags {
+  display: flex;
+  gap: 6px;
+  padding: 10px 14px 0;
+
+  .detail-tag {
+    display: inline-block;
+    padding: 3px 10px;
+    font-size: 11px;
+    color: #3b82f6;
+    background: #eff6ff;
+    border-radius: 4px;
+    font-weight: 500;
+  }
+}
+
+/* 配置区 */
+.config-section {
+  padding: 20px 14px 12px;
+
+  .config-title {
+    font-size: 16px;
+    font-weight: 600;
+    color: #1f2937;
+    margin-bottom: 6px;
+  }
+
+  .config-subtitle {
+    font-size: 12px;
+    color: #6b7280;
+    line-height: 1.6;
+  }
+}
+
+/* 表单 */
+.form-group {
+  padding: 0 14px;
+  margin-bottom: 16px;
+
+  .form-label {
+    font-size: 13px;
+    font-weight: 500;
+    color: #374151;
+    margin-bottom: 8px;
+  }
+
+  .form-textarea {
+    textarea {
+      width: 100%;
+      min-height: 80px;
+      padding: 10px 12px;
+      border: 1px solid #e5e7eb;
+      border-radius: 10px;
+      font-size: 13px;
+      color: #374151;
+      resize: vertical;
+      background: #fff;
+      outline: none;
+      transition: border-color 0.2s ease;
+
+      &::placeholder {
+        color: #9ca3af;
+      }
+
+      &:focus {
+        border-color: #FF9300;
+      }
+    }
+  }
+
+  .form-input {
+    input {
+      width: 100%;
+      padding: 10px 12px;
+      border: 1px solid #e5e7eb;
+      border-radius: 10px;
+      font-size: 13px;
+      color: #374151;
+      background: #fff;
+      outline: none;
+      transition: border-color 0.2s ease;
+
+      &::placeholder {
+        color: #9ca3af;
+      }
+
+      &:focus {
+        border-color: #FF9300;
+      }
+    }
+  }
+}
+
+/* 上传区 */
+.upload-area {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 12px 14px;
+  border: 1px dashed #d1d5db;
+  border-radius: 10px;
+  color: #6b7280;
+  font-size: 13px;
+  cursor: pointer;
+  transition: all 0.2s ease;
+
+  svg {
+    color: #9ca3af;
+  }
+
+  &:hover {
+    border-color: #FF9300;
+    color: #FF9300;
+
+    svg {
+      color: #FF9300;
+    }
+  }
+}
+
+/* 上传预览列表 */
+.upload-preview-list {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 10px;
+  margin-top: 12px;
+}
+
+.upload-preview-item {
+  position: relative;
+  width: 80px;
+  height: 80px;
+  border-radius: 8px;
+  overflow: hidden;
+  background: #f3f4f6;
+  border: 1px solid #e5e7eb;
+
+  img, video {
+    width: 100%;
+    height: 100%;
+    object-fit: cover;
+    display: block;
+  }
+
+  .preview-delete {
+    position: absolute;
+    top: 4px;
+    right: 4px;
+    width: 20px;
+    height: 20px;
+    border-radius: 50%;
+    background: rgba(0, 0, 0, 0.55);
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    color: #fff;
+    cursor: pointer;
+    transition: background 0.2s ease;
+
+    &:hover {
+      background: rgba(0, 0, 0, 0.75);
+    }
+  }
+
+  .preview-loading {
+    position: absolute;
+    inset: 0;
+    background: rgba(255, 255, 255, 1);
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+
+  .loading-spinner {
+    width: 24px;
+    height: 24px;
+    border: 3px solid #e5e7eb;
+    border-top-color: #FF9300;
+    border-radius: 50%;
+    animation: kwlSpin 0.8s linear infinite;
+  }
+}
+
+@keyframes kwlSpin {
+  to { transform: rotate(360deg); }
+}
+
+/* 预览弹窗 */
+.kwl-preview-modal {
+  position: fixed;
+  inset: 0;
+  background: rgba(0, 0, 0, 0.85);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 9999;
+  cursor: zoom-out;
+}
+
+.kwl-preview-close {
+  position: absolute;
+  top: 20px;
+  right: 20px;
+  width: 40px;
+  height: 40px;
+  border-radius: 50%;
+  background: rgba(255, 255, 255, 0.15);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #fff;
+  cursor: pointer;
+  transition: background 0.2s ease;
+
+  &:hover {
+    background: rgba(255, 255, 255, 0.25);
+  }
+}
+
+.kwl-preview-video {
+  max-width: 90vw;
+  max-height: 85vh;
+  object-fit: contain;
+  border-radius: 8px;
+}
+
+/* 表单项行 */
+.form-row {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 0 14px;
+  margin-bottom: 16px;
+
+  .form-text-block {
+    .form-label {
+      font-size: 13px;
+      font-weight: 500;
+      color: #374151;
+    }
+
+    .form-sublabel {
+      font-size: 12px;
+      color: #9ca3af;
+      margin-top: 2px;
+    }
+  }
+}
+
+.form-row-with-value {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+
+  .form-label {
+    font-size: 13px;
+    font-weight: 500;
+    color: #374151;
+  }
+
+  .range-value {
+    font-size: 13px;
+    color: #FF9300;
+    font-weight: 600;
+  }
+}
+
+/* 开关 */
+.toggle-switch {
+  width: 44px;
+  height: 24px;
+  border-radius: 12px;
+  background: #e5e7eb;
+  position: relative;
+  cursor: pointer;
+  transition: background 0.25s ease;
+  flex-shrink: 0;
+
+  .toggle-thumb {
+    width: 20px;
+    height: 20px;
+    border-radius: 50%;
+    background: #fff;
+    position: absolute;
+    top: 2px;
+    left: 2px;
+    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
+    transition: transform 0.25s ease;
+  }
+
+  &.active {
+    background: #FF9300;
+
+    .toggle-thumb {
+      transform: translateX(20px);
+    }
+  }
+}
+
+/* 高级配置 */
+.advanced-section {
+  padding: 0 14px;
+  margin-bottom: 16px;
+
+  .advanced-header {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    padding: 12px 0;
+    cursor: pointer;
+
+    .advanced-title {
+      font-size: 14px;
+      font-weight: 600;
+      color: #374151;
+    }
+
+    .advanced-arrow {
+      color: #9ca3af;
+      transition: transform 0.2s ease;
+
+      &.expanded {
+        transform: rotate(180deg);
+      }
+    }
+  }
+
+  .advanced-body {
+    display: flex;
+    flex-direction: column;
+    padding-top: 8px;
+    border-top: 1px solid #f3f4f6;
+
+    .form-group {
+      padding: 0;
+    }
+  }
+}
+
+/* 滑块 */
+.slider-wrapper {
+  padding: 8px 0;
+
+  .range-slider {
+    -webkit-appearance: none;
+    appearance: none;
+    width: 100%;
+    height: 4px;
+    border-radius: 2px;
+    background: #e5e7eb;
+    outline: none;
+
+    &::-webkit-slider-thumb {
+      -webkit-appearance: none;
+      appearance: none;
+      width: 18px;
+      height: 18px;
+      border-radius: 50%;
+      background: #FF9300;
+      cursor: pointer;
+      box-shadow: 0 1px 3px rgba(255, 147, 0, 0.4);
+    }
+
+    &::-moz-range-thumb {
+      width: 18px;
+      height: 18px;
+      border-radius: 50%;
+      background: #FF9300;
+      cursor: pointer;
+      border: none;
+      box-shadow: 0 1px 3px rgba(255, 147, 0, 0.4);
+    }
+  }
+}
+
+/* 底部按钮 */
+.footer-btn {
+  padding: 8px 14px 0;
+
+  .add-btn {
+    width: 100%;
+    padding: 14px;
+    background: #FF9300;
+    color: #fff;
+    font-size: 15px;
+    font-weight: 600;
+    text-align: center;
+    border-radius: 12px;
+    cursor: pointer;
+    transition: all 0.2s ease;
+
+    &:hover {
+      background: #f57c00;
+      transform: translateY(-1px);
+      box-shadow: 0 4px 12px rgba(255, 147, 0, 0.3);
+    }
+
+    &:active {
+      transform: translateY(0);
+    }
+  }
+}
+</style>

+ 206 - 4
src/components/CollapsibleToolbar/index2.vue

@@ -103,6 +103,11 @@
           </svg>
           <span class="item-label">{{ lang.ssEnglish }}</span>
         </div>
+        <div class="sidebar-item" v-if="!onlyEnglish" :class="{ active: activeSubmenu === 'cocoLearn' }"
+          @click="toggleSubmenu('cocoLearn')">
+          <svg class="item-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"></circle><path d="M12 1v6m0 6v6"></path><path d="M1 12h6m6 0h6"></path></svg>
+          <span class="item-label">{{ lang.ssCrossSubject }}</span>
+        </div>
       </div>
     </div>
     <div class="submenu" :class="{ visible: activeSubmenu === 'cocoai' }">
@@ -203,18 +208,18 @@
               <span class="progress-percent">{{ importProgress }}%</span> -->
               <div class="upload-task-main">
                 <div class="upload-task-name">{{ currentFileName }}</div>
-                <div class="upload-task-meta">课件文件 · {{ formatFileSize(currentFileSize) }}</div>
+                <div class="upload-task-meta">{{ lang.ssCoursewareFile }} · {{ formatFileSize(currentFileSize) }}</div>
               </div>
               <div class="upload-task-side">
                 <div class="upload-task-percent">{{ importProgress }}%</div>
                 <button type="button" class="upload-task-action" :class="{ 'upload-task-close': !exporting }"
-                  @click="handleParsingClose">{{ exporting ? '取消' : '×' }}</button>
+                  @click="handleParsingClose">{{ exporting ? lang.ssCancel : '×' }}</button>
               </div>
             </div>
             <div class="progress-bar">
               <div class="progress-fill" :style="{ width: importProgress + '%' }"></div>
             </div>
-            <div class="progress-loading" v-if="exporting">上传中...</div>
+            <div class="progress-loading" v-if="exporting">{{ lang.ssUploading }}</div>
             <!-- <button class="close-btn" @click="handleParsingClose">{{ lang.ssClose }}</button> -->
           </div>
         </template>
@@ -709,7 +714,44 @@
     <div class="submenu submenu-english" :class="{ visible: activeSubmenu === 'english' }">
       <SpeakingPanel />
     </div>
-
+    <div class="submenu" :class="{ visible: activeSubmenu === 'cocoLearn' }">
+      <div class="submenu-title">
+        <div class="title">{{ lang.ssCrossSubjectCourse }}</div>
+        <div class="close-icon" @click="toggleSubmenu('cocoLearn')">
+          <svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
+            <g id="Component 3">
+              <g id="Component 1">
+                <path id="Vector" d="M16 18L12 14L16 10" stroke="#9CA3AF" stroke-width="1.33333" />
+              </g>
+            </g>
+          </svg>
+        </div>
+      </div>
+      <div class="submenu-item-box2" v-if="!showKwlDetail">
+        <div class="tool-card kwl-card" @click="handleToolClick('kwl')">
+          <div class="tool-banner">
+            <img :src="kwl" :alt="lang.ssKwlTitle" />
+            <div class="tool-overly"></div>
+            <div class="tool-icon-badge">
+              <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+                <path d="M9 18h6M10 22h4M12 2a7 7 0 00-4 12.7c.6.5 1 1.3 1 2.3h6c0-1 .4-1.8 1-2.3A7 7 0 0012 2z"></path>
+              </svg>
+            </div>
+          </div>
+          <div class="tool-info">
+            <div class="tool-title">{{ lang.ssKwlTitle }}</div>
+            <div class="tool-desc">{{ lang.ssKwlDesc }}</div>
+            <div class="tool-tags">
+              <span class="tool-tag">{{ lang.ssKwlGradeRange }}</span>
+            </div>
+          </div>
+          <div class="tool-hover-overlay">
+              <span>{{ lang.ssClickToViewDetail }}</span>
+          </div>
+        </div>
+      </div>
+      <KwlDetail :banner="kwl" v-else @back="showKwlDetail = false" @add="handleKwlAdd" />
+    </div>
 
 
     <AiWeb :visible="showAiWebModal" :web-id="webId" @close="showAiWebModal = false" @add="addAiWeb" :is-loading="isLoading" />
@@ -735,6 +777,8 @@ import toolAnswer from '@/assets/img/tool_answer.png'
 import toolVote from '@/assets/img/tool_vote.png'
 import toolPhoto from '@/assets/img/tool_photo.png'
 import axios from '@/services/config'
+import kwl from '@/assets/img/kwlBanner.jpg'
+import KwlDetail from './cocolearnCom/KwlDetail.vue'
 
 interface ContentItem {
   tool?: number
@@ -778,6 +822,7 @@ const isCollapsed = ref(props.defaultCollapsed)
 const activeSubmenu = ref<string | null>(props.onlyEnglish ? 'english' : null)
 const contentList = ref<ContentItem[]>([])
 const hoveredTool = ref<string | null>(null)
+const showKwlDetail = ref(false)
 const webpageUrl = ref('')
 const isLoading = ref(false)
 const isValidUrl = ref<boolean | null>(null) // null: 未输入, true: 有效, false: 无效
@@ -1005,6 +1050,9 @@ const toggleCollapse = () => {
 const toggleSubmenu = (menu: string) => {
   if (activeSubmenu.value === menu) {
     activeSubmenu.value = null
+    if (menu === 'cocoLearn') {
+      showKwlDetail.value = false
+    }
   }
   else {
     activeSubmenu.value = menu
@@ -1111,8 +1159,21 @@ const handleToolClick = _.debounce((tool: string) => {
   else if (tool === 'uploadCode') {
     activeSubmenu.value = 'uploadCode'
   }
+  else if (tool === 'kwl') {
+    showKwlDetail.value = true
+  }
 }, 300)
 
+const handleKwlAdd = (data: any) => {
+  interface ParentWindowWithToolList extends Window {
+    addTool2?: (id: number, data: any) => void;
+  }
+  const parentWindow = window.parent as ParentWindowWithToolList
+  // parentWindow?.addTool?.(80)
+  // console.log('KWL配置:', data)
+  parentWindow?.addTool2?.(831, data)
+}
+
 const loadContentList = () => {
   try {
     interface ParentWindowWithToolList extends Window {
@@ -1885,6 +1946,147 @@ const addAiWeb = async (code: string) => {
   }
 }
 
+.tool-card {
+  width: 100%;
+  border-radius: 12px;
+  overflow: hidden;
+  background: #fff;
+  border: 1px solid #e5e7eb;
+  cursor: pointer;
+  transition: all 0.25s ease;
+  position: relative;
+
+  &:hover {
+    border-color: #FF9300;
+    box-shadow: 0 4px 16px rgba(255, 147, 0, 0.15);
+    transform: translateY(-2px);
+
+    .tool-hover-overlay {
+      opacity: 1;
+    }
+
+    .tool-info .tool-title {
+      color: #FF9300;
+    }
+  }
+
+  .tool-banner {
+    position: relative;
+    width: 100%;
+    padding-top: 65%;
+    overflow: hidden;
+
+    .tool-overly {
+      position: absolute;
+      inset: 0;
+      background: rgba(37, 99, 235, 0.15);
+      opacity: .45;
+      pointer-events: none;
+    }
+
+    img {
+      position: absolute;
+      top: 0;
+      left: 0;
+      width: 100%;
+      height: 100%;
+      object-fit: cover;
+      transition: transform 0.3s ease;
+    }
+
+    .tool-icon-badge {
+      position: absolute;
+      top: 10px;
+      left: 10px;
+      width: 32px;
+      height: 32px;
+      border-radius: 8px;
+      background: rgba(255, 255, 255, 0.95);
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
+
+      svg {
+        width: 18px;
+        height: 18px;
+        // color: #FF9300;
+      }
+    }
+  }
+
+  .tool-info {
+    padding: 12px 14px 14px;
+
+    .tool-title {
+      font-size: 15px;
+      font-weight: 600;
+      color: #1f2937;
+      margin-bottom: 4px;
+      transition: color 0.2s ease;
+    }
+
+    .tool-desc {
+      font-size: 12px;
+      color: #6b7280;
+      line-height: 1.5;
+      margin-bottom: 10px;
+      display: -webkit-box;
+      -webkit-line-clamp: 2;
+      -webkit-box-orient: vertical;
+      overflow: hidden;
+    }
+
+    .tool-tags {
+      display: flex;
+      gap: 6px;
+    }
+
+    .tool-tag {
+      display: inline-block;
+      padding: 3px 10px;
+      font-size: 11px;
+      color: #3b82f6;
+      background: #eff6ff;
+      border-radius: 4px;
+      font-weight: 500;
+    }
+  }
+
+  .tool-hover-overlay {
+      position: absolute;
+      bottom: 0;
+      left: 0;
+      right: 0;
+      top: 0;
+      background: rgba(0, 0, 0, 0.45);
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      opacity: 0;
+      transition: opacity 0.25s ease;
+
+      span {
+        color: #fff;
+        font-size: 14px;
+        font-weight: 600;
+      }
+    }
+}
+
+// KWL 专属样式(目前无额外覆盖,预留扩展)
+.kwl-card {
+  // 如需 KWL 专属样式,在此处覆盖 .tool-card 中的对应规则
+
+  .tool-icon-badge svg{
+    color: #2563eb;
+  }
+
+  .tool-overly {
+    background-color: #2563eb;
+  }
+}
+
 .submenu-upload {
   display: flex;
   align-items: center;

+ 2 - 2
src/views/Editor/index3.vue

@@ -145,7 +145,7 @@
               <path id="Vector_3" d="M4.6665 2V5.33333H9.99984" stroke="#374151" stroke-width="1.33333" />
             </g>
           </svg>
-          保存
+          {{ lang.ssSave }}
         </button>
         <button class="action-btn publish-btn" @click="handlePublish">
           <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -156,7 +156,7 @@
               <path id="Vector_2" d="M8 4V8L10.6667 9.33333" stroke="white" stroke-width="1.33333" />
             </g>
           </svg>
-          发布
+          {{ lang.ssPublish }}
         </button>
       </div>
     </div>

+ 35 - 1
src/views/lang/cn.json

@@ -1051,5 +1051,39 @@
   "ssBatchInteractiveWebConfirmDesc": "是,立即添加",
   "ssBatchInteractiveWebConfirmDesc2": "暂不需要",
   "ssPendingCount": "待处理",
-  "ssShowAnswer": "展示答案"
+  "ssShowAnswer": "展示答案",
+  "ssCrossSubject": "跨学科",
+  "ssCrossSubjectCourse": "跨学科课程",
+  "ssCoursewareFile": "课件文件",
+  "ssClickToViewDetail": "点击查看详情 →",
+  "ssKwlTitle": "KWL表格",
+  "ssKwlDesc": "帮助学生梳理已知、想知和已学内容,建立学习框架",
+  "ssKwlGradeRange": "小学-高中",
+  "ssKwlUsageScenario": "使用场景",
+  "ssKwlScenarioText": "学生独立完成KWL表格,梳理个人学习思路",
+  "ssKwlSteps": "操作步骤",
+  "ssKwlStep1": "1. 在K栏填写:我已经知道什么(Know)",
+  "ssKwlStep2": "2. 在W栏填写:我想知道什么(Want to know)",
+  "ssKwlStep3": "3. 学习后在L栏填写:我学到了什么(Learned)",
+  "ssKwlStep4": "4. 反思:对比K和L栏,总结学习收获",
+  "ssKwlSource": "来源:Donna Ogle 于1986年提出的阅读理解教学策略",
+  "ssKwlPurpose": "目的:帮助学生在学习前明确已知与待知,学习后检验收获,建立主动学习的元认知习惯",
+  "ssKwlToolConfig": "工具配置",
+  "ssKwlConfigSubtitle": "配置完成后点击下方\"添加到幻灯片\",可在幻灯片中查看学生实际看到的效果",
+  "ssKwlTaskLabel": "情境 / 任务说明",
+  "ssKwlTaskPlaceholder": "写一段学习主题或阅读材料,学生打开KWL表格时会看到,例如:# 水的三态变化 水在自然界以三种形态存在...",
+  "ssKwlUploadMedia": "添加配图/视频(可多选)",
+  "ssKwlShowHowTo": "默认展开操作提示",
+  "ssKwlShowHowToDesc": "关闭则学生需要点击\"怎么做\"才能看到操作步骤",
+  "ssKwlAdvanced": "高级配置",
+  "ssKwlKPrompt": "K栏提示语",
+  "ssKwlKDefault": "我已经知道...",
+  "ssKwlWPrompt": "W栏提示语",
+  "ssKwlWDefault": "我想知道...",
+  "ssKwlLPrompt": "L栏提示语",
+  "ssKwlLDefault": "我学到了...",
+  "ssKwlShowReflection": "显示反思区",
+  "ssKwlMinEntries": "每栏最少条目数",
+  "ssKwlAddToSlide": "添加到幻灯片",
+  "ssKwlUploadFail": "上传失败"
 }

+ 35 - 1
src/views/lang/en.json

@@ -1051,5 +1051,39 @@
   "ssBatchInteractiveWebConfirmDesc": "Yes, add now",
   "ssBatchInteractiveWebConfirmDesc2": "No",
   "ssPendingCount": "Pending Count",
-  "ssShowAnswer": "Show Answer"
+  "ssShowAnswer": "Show Answer",
+  "ssCrossSubject": "Cross-subject",
+  "ssCrossSubjectCourse": "Cross-subject Course",
+  "ssCoursewareFile": "Courseware File",
+  "ssClickToViewDetail": "Click to view details →",
+  "ssKwlTitle": "KWL Chart",
+  "ssKwlDesc": "Help students organize what they know, want to know, and have learned to build a learning framework",
+  "ssKwlGradeRange": "Elementary-High School",
+  "ssKwlUsageScenario": "Usage Scenario",
+  "ssKwlScenarioText": "Students complete the KWL chart independently to organize their learning ideas",
+  "ssKwlSteps": "Steps",
+  "ssKwlStep1": "1. In the K column: What I already know",
+  "ssKwlStep2": "2. In the W column: What I want to know",
+  "ssKwlStep3": "3. After learning, in the L column: What I learned",
+  "ssKwlStep4": "4. Reflect: Compare K and L columns to summarize learning gains",
+  "ssKwlSource": "Source: Reading comprehension teaching strategy proposed by Donna Ogle in 1986",
+  "ssKwlPurpose": "Purpose: Help students clarify what they know and want to know before learning, verify gains after learning, and build metacognitive habits for active learning",
+  "ssKwlToolConfig": "Tool Configuration",
+  "ssKwlConfigSubtitle": "After configuration, click \"Add to Slide\" below to preview what students will see",
+  "ssKwlTaskLabel": "Scenario / Task Description",
+  "ssKwlTaskPlaceholder": "Write a learning topic or reading material that students will see when opening the KWL chart, e.g.: # Three states of water...",
+  "ssKwlUploadMedia": "Add images/videos (multiple)",
+  "ssKwlShowHowTo": "Show operation tips by default",
+  "ssKwlShowHowToDesc": "If off, students need to click \"How to\" to see steps",
+  "ssKwlAdvanced": "Advanced Settings",
+  "ssKwlKPrompt": "K Column Placeholder",
+  "ssKwlKDefault": "I already know...",
+  "ssKwlWPrompt": "W Column Placeholder",
+  "ssKwlWDefault": "I want to know...",
+  "ssKwlLPrompt": "L Column Placeholder",
+  "ssKwlLDefault": "I learned...",
+  "ssKwlShowReflection": "Show Reflection Area",
+  "ssKwlMinEntries": "Min entries per column",
+  "ssKwlAddToSlide": "Add to Slide",
+  "ssKwlUploadFail": "Upload failed"
 }

+ 35 - 1
src/views/lang/hk.json

@@ -1051,5 +1051,39 @@
   "ssBatchInteractiveWebConfirmDesc": "是,立即添加",
   "ssBatchInteractiveWebConfirmDesc2": "暫不需要添加",
   "ssPendingCount": "待處理",
-  "ssShowAnswer": "展示答案"
+  "ssShowAnswer": "展示答案",
+  "ssCrossSubject": "跨學科",
+  "ssCrossSubjectCourse": "跨學科課程",
+  "ssCoursewareFile": "課件文件",
+  "ssClickToViewDetail": "點擊查看詳情 →",
+  "ssKwlTitle": "KWL表格",
+  "ssKwlDesc": "幫助學生梳理已知、想知和已學內容,建立學習框架",
+  "ssKwlGradeRange": "小學-高中",
+  "ssKwlUsageScenario": "使用場景",
+  "ssKwlScenarioText": "學生獨立完成KWL表格,梳理個人學習思路",
+  "ssKwlSteps": "操作步驟",
+  "ssKwlStep1": "1. 在K欄填寫:我已經知道什麼(Know)",
+  "ssKwlStep2": "2. 在W欄填寫:我想知道什麼(Want to know)",
+  "ssKwlStep3": "3. 學習後在L欄填寫:我學到了什麼(Learned)",
+  "ssKwlStep4": "4. 反思:對比K和L欄,總結學習收穫",
+  "ssKwlSource": "來源:Donna Ogle 於1986年提出的閱讀理解教學策略",
+  "ssKwlPurpose": "目的:幫助學生在學習前明確已知與待知,學習後檢驗收穫,建立主動學習的元認知習慣",
+  "ssKwlToolConfig": "工具配置",
+  "ssKwlConfigSubtitle": "配置完成後點擊下方\"添加到幻燈片\",可在幻燈片中查看學生實際看到的效果",
+  "ssKwlTaskLabel": "情境 / 任務說明",
+  "ssKwlTaskPlaceholder": "寫一段學習主題或閱讀材料,學生打開KWL表格時會看到,例如:# 水的三態變化 水在自然界以三種形態存在...",
+  "ssKwlUploadMedia": "添加配圖/視頻(可多選)",
+  "ssKwlShowHowTo": "預設展開操作提示",
+  "ssKwlShowHowToDesc": "關閉則學生需要點擊\"怎麼做\"才能看到操作步驟",
+  "ssKwlAdvanced": "進階配置",
+  "ssKwlKPrompt": "K欄提示語",
+  "ssKwlKDefault": "我已經知道...",
+  "ssKwlWPrompt": "W欄提示語",
+  "ssKwlWDefault": "我想知道...",
+  "ssKwlLPrompt": "L欄提示語",
+  "ssKwlLDefault": "我學到了...",
+  "ssKwlShowReflection": "顯示反思區",
+  "ssKwlMinEntries": "每欄最少條目數",
+  "ssKwlAddToSlide": "添加到幻燈片",
+  "ssKwlUploadFail": "上傳失敗"
 }