| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336 |
- <template>
- <div class="layer2-speaking">
- <!-- 创建方式切换 -->
- <CreationModeSwitch v-model="creationMode" />
- <!-- AI 生成模式 -->
- <AIGenerationForm v-if="creationMode === 'ai'" />
- <!-- 手动创建模式 —— 直接进入配置页 -->
- <div v-else-if="creationMode === 'manual'" class="manual-create">
- <div class="manual-hint">
- <p class="manual-text">选择一种口语任务类型开始手动创建</p>
- </div>
- <div class="manual-task-list">
- <button
- v-for="taskType in manualTaskTypes"
- :key="taskType.id"
- class="manual-task-item"
- @click="handleManualCreate(taskType.id)"
- >
- <div class="task-item-icon" v-html="taskType.icon"></div>
- <div class="task-item-info">
- <span class="task-item-name">{{ taskType.name }}</span>
- <span class="task-item-category">{{ taskType.category }}</span>
- </div>
- <svg class="task-item-arrow" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
- <path d="M9 18l6-6-6-6" />
- </svg>
- </button>
- </div>
- </div>
- <!-- 智能推荐模式 -->
- <template v-else>
- <!-- 任务类型筛选 -->
- <div class="filter-bar">
- <div class="filter-left">
- <span class="filter-label">任务类型</span>
- <div class="filter-select-wrap">
- <select v-model="selectedTaskType" class="filter-select">
- <option v-for="opt in taskTypeOptions" :key="opt.id" :value="opt.id">
- {{ opt.name }}
- </option>
- </select>
- <svg class="select-arrow" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
- <path d="M6 9l6 6 6-6" />
- </svg>
- </div>
- </div>
- </div>
- <!-- 推荐卡片列表 -->
- <div v-if="filteredTasks.length > 0" class="card-grid">
- <RecommendCard
- v-for="task in filteredTasks"
- :key="task.id"
- :task="task"
- @select="handleSelectTask(task)"
- />
- </div>
- <!-- 空状态 -->
- <div v-else class="empty-state">
- <svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
- <path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2" />
- <rect x="9" y="3" width="6" height="4" rx="1" />
- <line x1="9" y1="12" x2="15" y2="12" />
- <line x1="9" y1="16" x2="13" y2="16" />
- </svg>
- <p class="empty-text">{{ lang.ssNoRecommendations }}</p>
- </div>
- </template>
- </div>
- </template>
- <script lang="ts" setup>
- import { ref, computed } from 'vue'
- import { lang } from '@/main'
- import type { CreationMode, TopicDiscussionTask } from '@/types/englishSpeaking'
- import { useSpeakingStore } from '@/store/speaking'
- import useCreateElement from '@/hooks/useCreateElement'
- import useSlideHandler from '@/hooks/useSlideHandler'
- import { createSpeakingConfig } from '@/services/speaking'
- import message from '@/utils/message'
- import tasksData from '../data/topicDiscussionTasks.json'
- import CreationModeSwitch from '../components/CreationModeSwitch.vue'
- import RecommendCard from '../components/RecommendCard.vue'
- import AIGenerationForm from '../components/AIGenerationForm.vue'
- const props = defineProps<{
- unitId: string
- }>()
- const emit = defineEmits<{
- (e: 'openConfig'): void
- }>()
- const speakingStore = useSpeakingStore()
- const creationMode = ref<CreationMode>('smart')
- const selectedTaskType = ref('all')
- // 任务类型筛选选项(本次仅口语 - 话题讨论)
- const taskTypeOptions = [
- { id: 'all', name: lang.ssAll as string },
- { id: 'topic-discussion', name: lang.ssTopicDiscussion as string },
- ]
- // 手动创建的任务类型列表
- const manualTaskTypes = [
- {
- id: 'topic-discussion',
- name: lang.ssTopicDiscussion as string,
- category: lang.ssFreeDialogue as string,
- icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>',
- },
- ]
- // 根据 unitId 获取推荐任务
- const unitTasks = computed<TopicDiscussionTask[]>(() => {
- const data = tasksData as Record<string, TopicDiscussionTask[]>
- return data[props.unitId] || []
- })
- // 按任务类型筛选
- const filteredTasks = computed(() => {
- if (selectedTaskType.value === 'all') return unitTasks.value
- // 目前所有数据都是话题讨论类型,所以 topic-discussion 返回全部
- return unitTasks.value
- })
- // 新交互:点卡片即新建一页 + 创建配置 + 插入画布元素;配置页改由"点击画布里的 77 型元素"唤起
- const { createFrameElement } = useCreateElement()
- const { createSlide } = useSlideHandler()
- const inserting = ref(false)
- async function insertSpeakingToolToCanvas(source: 'select' | 'manual') {
- if (inserting.value) return
- inserting.value = true
- try {
- const { id } = await createSpeakingConfig(speakingStore.config)
- createSlide()
- createFrameElement(id, 77)
- // 创建后立即唤起左侧配置面板(与点击画布 77 型 frame 同款信号),使用户可直接编辑
- speakingStore.openConfigPanel()
- message.success(source === 'select' ? '已添加口语练习到幻灯片' : '已添加空白口语工具,点击画布上的元素进行配置')
- } catch (err: any) {
- console.error('[speaking] create failed:', err)
- message.error(err?.message || '创建口语工具失败')
- } finally {
- inserting.value = false
- }
- }
- // 选择推荐卡片 → 预填充 store → 立即创建配置 + 插入元素
- const handleSelectTask = (task: TopicDiscussionTask) => {
- speakingStore.prefillFromTask(task.titleEn, task.vocabulary, task.sentences)
- insertSpeakingToolToCanvas('select')
- }
- // 手动创建 → 清空 store → 立即创建配置 + 插入元素
- const handleManualCreate = (_taskTypeId: string) => {
- speakingStore.resetConfigEmpty()
- insertSpeakingToolToCanvas('manual')
- }
- </script>
- <style lang="scss" scoped>
- .layer2-speaking {
- padding: 16px 20px;
- display: flex;
- flex-direction: column;
- gap: 16px;
- }
- // 筛选栏
- .filter-bar {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
- }
- .filter-left {
- display: flex;
- align-items: center;
- gap: 8px;
- flex: 1;
- }
- .filter-label {
- font-size: 12px;
- font-weight: 500;
- color: #6b7280;
- white-space: nowrap;
- }
- .filter-select-wrap {
- position: relative;
- flex: 1;
- }
- .filter-select {
- width: 100%;
- appearance: none;
- padding: 6px 28px 6px 12px;
- border: 1px solid rgba(0, 0, 0, 0.08);
- border-radius: 12px;
- background: #f9fafb;
- font-size: 13px;
- color: #374151;
- cursor: pointer;
- transition: all 0.2s;
- &:focus {
- outline: none;
- border-color: #f97316;
- background: #fff;
- box-shadow: 0 0 0 2px rgba(249, 115, 22, 0.1);
- }
- }
- .select-arrow {
- position: absolute;
- right: 10px;
- top: 50%;
- transform: translateY(-50%);
- color: #9ca3af;
- pointer-events: none;
- }
- // 推荐卡片网格
- .card-grid {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: 10px;
- }
- // 空状态
- .empty-state {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 8px;
- padding: 32px 16px;
- color: #d1d5db;
- }
- .empty-text {
- font-size: 13px;
- color: #9ca3af;
- margin: 0;
- }
- // 手动创建
- .manual-create {
- display: flex;
- flex-direction: column;
- gap: 12px;
- }
- .manual-hint {
- padding: 0;
- }
- .manual-text {
- font-size: 13px;
- color: #6b7280;
- margin: 0;
- }
- .manual-task-list {
- display: flex;
- flex-direction: column;
- gap: 8px;
- }
- .manual-task-item {
- display: flex;
- align-items: center;
- gap: 10px;
- padding: 12px 14px;
- background: #fff;
- border: 1px solid rgba(0, 0, 0, 0.06);
- border-radius: 14px;
- cursor: pointer;
- transition: all 0.2s;
- text-align: left;
- &:hover {
- border-color: rgba(0, 0, 0, 0.12);
- box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
- .task-item-arrow {
- color: #f97316;
- }
- }
- }
- .task-item-icon {
- display: flex;
- align-items: center;
- justify-content: center;
- width: 32px;
- height: 32px;
- border-radius: 10px;
- background: linear-gradient(135deg, #fff7ed, #fff);
- border: 1px solid rgba(249, 115, 22, 0.15);
- color: #f97316;
- flex-shrink: 0;
- }
- .task-item-info {
- flex: 1;
- display: flex;
- flex-direction: column;
- gap: 2px;
- }
- .task-item-name {
- font-size: 13px;
- font-weight: 500;
- color: #111827;
- }
- .task-item-category {
- font-size: 11px;
- color: #9ca3af;
- }
- .task-item-arrow {
- color: #d1d5db;
- flex-shrink: 0;
- transition: color 0.2s;
- }
- </style>
|