Selaa lähdekoodia

feat: 增加卡片分类

lvkun996 3 kuukautta sitten
vanhempi
commit
7e5d7675d4

+ 70 - 0
README.md

@@ -9,3 +9,73 @@
   5 推钮卡
   21 点触卡
   
+
+## json生成逻辑
+
+  rule 7 (单点单播):
+    选择一个main_subject 的 music_name
+    touch_key: 三个空值
+    err_key: 默认一个空值,但是也可以自己配置一个
+    sub_subject:选择一个music_name,只有7是把music_name 绑定在ok上
+    ok_key: 每次单点都是ok_key下唯一的一个music_name
+    也就是在展示rule=7的卡片时,应该让把一个game_list下的所有items的ok_key在一个面板展示出来,
+    每新增一个ok_key,就是新增了一个items下的对象,所有的items下的对象的err_key和sub_subject都是一个值
+
+
+  <!-- 在这里我大胆认为9 和 10是一样的 只是rule不一样 -->
+  rule9 (无序单点):
+    选择一个main_subject 的 music_name
+    items.length = ok_key.voice.length
+    err_key 指定位置
+    每个按钮没有music_name
+    场景就是 ok_key.voice里读一个题目,然后用户无序点击对应的ok_key里的按钮
+  
+  rule10(有序单点):
+    选择一个main_subject 的 music_name
+    ok_key_voice就是一个对象的默认数组
+    err_key 指定位置
+    每个按钮没有music_name
+    <!-- 场景就是 ok_key.voice里读一个题目,然后用户无序点击对应的ok_key里的按钮 -->
+
+  <!-- 由上面的推算,rule13和14是一样的,只是rule不一样 -->
+  rule 13(有序双点)
+
+  rule 14(无序双点)
+    选择一个main_subject 的 music_name
+    没有错误语音
+    err_key是指定的
+    每个按钮没有music_name
+
+
+
+配置板子生成逻辑
+  9 10 13 14  无关有序还是无序 只分单双点击
+  每个一级目录的main_subject 的 music_name 需要配置
+
+  可以无限制的添加ok_key里的对象
+  ok_key里每存在一个value, 那么err_key就排除掉这个value
+
+  err_key让自己选择
+
+  预留is_break的位置和music_name的选项
+
+  ok_key_voice.length 和 items.length 的对应
+
+  touch_key 是一个数组,数组的长度就是items的长度
+
+  单双击如果你也不控制,那么其实单双击9 10 13 14 全都没有区别
+
+  每个二级目录的
+
+    一个touch_key[2]的music_name
+    
+    配置subject 的 music_name
+
+    ok_key_voice 的 music_name
+    
+
+
+
+
+
+

+ 32 - 0
src/api/card.ts

@@ -37,3 +37,35 @@ export const getCardJsonById = (id: string) => {
     method: 'GET'
   })
 }
+
+export const getFeedbackByCardId = (cardId: string) => {
+  return request<API.Feedback>({
+    url: `/feedback/${cardId}`,
+    method: 'GET'
+  })
+}
+
+export const saveOrUpdateFeedback = (data: API.Feedback) => {
+  return request<boolean>({
+    url: '/feedback',
+    method: 'POST',
+    data
+  })
+}
+
+// 根据ID获取分类
+export const getCategoryById = (id: string) => {
+  return request<API.Category[]>({
+    url: `/getCategoryById/${id}`,
+    method: 'GET'
+  })
+}
+
+// 设置卡片分类
+export const setCategoryById = (id: string, data: string[]) => {
+  return request<boolean>({
+    url: `/setCategoryById/${id}`,
+    method: 'PUT',
+    data
+  })
+}

+ 158 - 0
src/components/ui/folder.vue

@@ -0,0 +1,158 @@
+<template>
+
+  <div
+    style="height: 120px;"
+    class="config-game-item"
+    @dblclick="enterFolder"
+  >
+    <img class="folder-icon" :src="require('@/assets/common/folder.svg')" alt="">
+
+    <div class="folder-footer">
+      <div  v-if="game.rule">规则: {{ game.rule }}({{CardController.ruleList.find(item => item.value === game.rule)?.title}})</div>
+      <div v-else >游戏</div>
+      <div>
+        <a-dropdown :trigger="['hover']">
+          <SmallDashOutlined class="action-icon" />
+          <template #overlay>
+            <a-menu>
+              <a-menu-item key="edit" @click="handleEdit">
+                <EditOutlined />
+                <span style="margin-left: 8px;">编辑</span>
+              </a-menu-item>
+              <a-menu-item key="delete" @click="handleDelete">
+                <DeleteOutlined style="color: #ff4d4f;" />
+                <span style="margin-left: 8px;">删除</span>
+              </a-menu-item>
+            </a-menu>
+          </template>
+        </a-dropdown>
+      </div>
+    </div>
+  </div>
+
+</template>
+<script lang='ts'  setup >
+import { CardController } from '@/controller'
+
+interface IProps {
+  game: API.CardJson21.GameList[0]
+}
+
+const props = defineProps<IProps>()
+
+const emits = defineEmits(['edit', 'delete', 'dblclick'])
+
+const enterFolder = () => {
+  emits('dblclick', props.game)
+}
+
+const handleEdit = () => {
+  emits('edit', props.game)
+}
+
+const handleDelete = () => {
+  emits('delete', props.game)
+}
+
+</script>
+<style lang='less' scoped >
+  .config-game-item {
+    width: 100%;
+    height: 100%;
+    display: flex;
+    flex-direction: column;
+    justify-content: space-between;
+    align-items: center;
+    cursor: pointer;
+    border-radius: 8px;
+    // transition: all 0.3s;
+    background-color: #f5f5f5;
+    padding: 0px !important;
+    border: 1px solid #e9ecf1;
+    box-sizing: border-box !important;
+    // margin-right: 16px;
+    // margin-bottom: 16px;
+    position: relative;
+    overflow: hidden;
+    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
+
+    .folder-icon {
+      width: 86px;
+      height: 86px;
+    }
+    .folder-footer {
+      width: 100%;
+      height: 32px;
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      border-top: 1px solid #e9ecf1;
+      background-color: #fff;
+      box-sizing: border-box;
+      padding: 0 12px;
+      position: absolute;
+      bottom: 0;
+      left: 0;
+      right: 0;
+
+      .action-icon {
+        cursor: pointer;
+        opacity: 0;
+        transition: opacity 0.3s;
+        font-size: 16px;
+        color: #8c8c8c;
+
+        &:hover {
+          color: #1890ff;
+        }
+      }
+    }
+
+    .folder-info {
+      width: 100%;
+      display: flex;
+      flex-direction: column;
+      align-items: center;
+      // padding: 12px 0;
+      // margin-bottom: 32px;
+
+      .folder-title {
+        font-weight: 500;
+        margin-bottom: 8px;
+        font-size: 14px;
+        color: #333;
+      }
+
+      .music-badge {
+        display: flex;
+        align-items: center;
+        gap: 4px;
+        border-radius: 12px;
+        font-size: 12px;
+        .anticon {
+          color: #1890ff;
+          font-size: 12px;
+        }
+
+        .music-name {
+          color: #1890ff;
+          white-space: nowrap;
+          overflow: hidden;
+          text-overflow: ellipsis;
+          max-width: 80px;
+        }
+      }
+    }
+
+    &:hover {
+      scale: 1.02;
+
+      .folder-footer .action-icon {
+        opacity: 1;
+      }
+    }
+    &:hover {
+      scale: 1.02;
+    }
+  }
+</style>

+ 65 - 8
src/controller/CardController.ts

@@ -1,5 +1,9 @@
 
-import { getCardList, addCard, addDefaultCard, getCardJsonById, getDefaultCard } from '@/api/card'
+import {
+  getCardList, addCard, addDefaultCard, getCardJsonById, getDefaultCard,
+  getFeedbackByCardId,
+  saveOrUpdateFeedback
+} from '@/api/card'
 import { message } from 'ant-design-vue'
 
 export class CardController {
@@ -8,13 +12,58 @@ export class CardController {
     { value: 21, title: '点触卡' }
   ]
 
+  // 7 9 10 13 14
+  /*
+  *  main_subject music_name: kk.mp3
+
+    错误语音 都配置一个
+
+    7 sub_subject 下的music_name必须有值 其他sub_subject 下的music_name可以为空  ok_key只有一个值  无touch_key 的 第三个
+
+    err_key 是错误的值  单or多的情况下 music_name都必须有值
+
+    ok_key 是正确的值  单or多的情况下 music_name都必须有值  如果是7那么就是有多个items,ok_key都只有一个值
+    如果其他的4个,那么items不固定,ok_key数量不固定
+
+    ok_key_voice 是每个小节的结束语,永远比小题数量多一个,多的是空的
+
+    无 刷新 和 提示 按钮
+
+    无序 9 14 吧
+
+    有序 10 13
+
+    如果是7
+      1. 一个主题音乐 (main_subject -> music_name)
+      3. items下的的ok_key只有一个值, 但是多个items
+      4. 无ok_key_voice
+      5. touch_key 的提示是默认的
+    其他的
+      1. 一个主题音乐 (main_subject -> music_name)
+      2. 多选  items下的的ok_key 有多个值是,length是偶数, 单选是奇数
+      3. 多选  items下的的err_key 有多个值是,length是偶数, 单选是奇数
+      4. ok_key_voice的长度是items.length + 1. 永远多出现一个
+      5. sub_subject 下的music_name必须有值
+      6. 顺序问题和前端无关,只需要传递好对应的rule,那么硬件端会自己取对应的字段完成“顺序”
+      7. touch_key 的提示第一个是默认的,touch_key[2].lenght = items.length  touch_key里的对象music_name必须有值
+
+      7 一个er_key 每个ok_key 没有music_name
+
+      10 除了正确答案全是err_key 单点每个按钮有music_name
+      9 items.length = ok_key.voice.length  err_key 制定位置 无序单点每个按钮没有music_name
+
+      14 没有错误语音 ok_key_voice有一个  err_key是指定的  每个按钮没有music_name
+
+      无序单点 和 有序单点的区别是 无序单点的err_key 是指定的,有序单点的err_key 除去正确答案所有的
+
+  */
   static ruleList = [
     { title: '单点单播', value: 7, desc: '无err_key, 只加ok_key' },
     /**
      *  rule 7 点一下播放一个语音,此时语音放在ok_key里
      * sub_subject 字段 音频绑定到ok上
      */
-    { title: '无序多点', value: 9, desc: '单点操作,多个答案的无序单选' },
+    { title: '无序点', value: 9, desc: '单点操作,多个答案的无序单选' },
     /**
      *  rule 9 点完所有答案播放语音,语音放在ok_key_voice里 按照步骤
      * sub_subject 字段 音频绑定到music_name 上
@@ -22,13 +71,13 @@ export class CardController {
     { title: '有序单点', value: 10, desc: '单点操作,多个正确答案的有序单选' }, // 点完所有答案播放语音,语音放在ok_key_voice里 按照步骤
     // { title: '双点', value: 11, desc: '' },
     // { title: '无序多选(有一个固定按钮)', value: 12, desc: '' },
-    // { title: '有序多选(有一个固定按钮)', value: 13, desc: '' },
-    { title: '无序多选', value: 14, desc: '' }, // 每组有两个答案,每个答案有一个语音,语音放在ok_key_voice里 按照步骤
+    { title: '有序双点', value: 13, desc: '' },
+    { title: '无序双点', value: 14, desc: '' } // 每组有两个答案,每个答案有一个语音,语音放在ok_key_voice里 按照步骤
 
-    { title: '有序多选', value: 15, desc: '' }, // 每组有两个答案,每个答案有一个语音,语音放在ok_key_voice里 按照步骤
-    { title: 'pk对战', value: 16, desc: '' }, // ok_key下的item同时有两项语音放在ok_key_voice里 按照步骤,错误的err_key数量位置,语音放在err_key_voice里
-    { title: '有序单点和无序单点混搭', value: 17, desc: '' }, // 17时order代表是有序单点还是无序单点
-    { title: '新手指引', value: 18, desc: '' }
+    // { title: '有序多选', value: 15, desc: '' }, // 每组有两个答案,每个答案有一个语音,语音放在ok_key_voice里 按照步骤
+    // { title: 'pk对战', value: 16, desc: '' }, // ok_key下的item同时有两项语音放在ok_key_voice里 按照步骤,错误的err_key数量位置,语音放在err_key_voice里
+    // { title: '有序单点和无序单点混搭', value: 17, desc: '' }, // 17时order代表是有序单点还是无序单点
+    // { title: '新手指引', value: 18, desc: '' }
   ]
 
   static cardButtonTitleMap = new Map([
@@ -328,4 +377,12 @@ export class CardController {
       })
     }
   }
+
+  static getFeedbackByCardId (cardId: string) {
+    return getFeedbackByCardId(cardId)
+  }
+
+  static saveOrUpdateFeedback (feedback: API.Feedback) {
+    return saveOrUpdateFeedback(feedback)
+  }
 }

+ 42 - 7
src/pages/card/components/card-21-json-panel.vue

@@ -5,12 +5,20 @@
     placement="left"
     :open="open"
     :closable="false"
-    @close="onClose"
     :keyboard="false"
     :mask="false"
  >
     <template #extra>
       <a-space>
+        <a-input v-model:value="audioName" placeholder="输入音频名称" style="width: 200px" />
+        <a-button
+          type="default"
+          :loading="audioLoading"
+          :disabled="!audioName"
+          @click="clickAudio"
+        >
+          播放
+        </a-button>
         <a-button  @click="onClose">取消</a-button>
         <a-button type="primary" load @click="save">保存</a-button>
       </a-space>
@@ -19,14 +27,40 @@
       v-model="cacheJson"
       @change="handleChange"
       :options="editorOptions"
-      style="height: 96%"
+      style="height: 96%;"
     />
+
  </a-drawer>
 </template>
 <script lang='ts'  setup >
 import { CardController } from '@/controller/index'
-import { ref, watch } from 'vue'
+import { ref, watch, onMounted } from 'vue'
 import JsonEditorVue from 'json-editor-vue3'
+import { MediaController } from '@/controller/MediaController'
+import { audioManager } from '@/utils/AudioManaer'
+import { useRoute } from 'vue-router'
+
+const audioName = ref('')
+const audioLoading = ref(false)
+const audioUrl = ref('')
+const audioState = ref('')
+
+const clickAudio = async () => {
+  console.log('audioName.value:', audioName.value)
+
+  if (!audioName.value) return
+  audioLoading.value = true
+  const url = await MediaController.getAudioUrlByName(audioName.value.endsWith('.mp3') ? audioName.value : audioName.value + '.mp3')
+  audioLoading.value = false
+
+  console.log(url)
+  audioState.value = 'playing'
+
+  audioManager.playFromUrl(url.data, () => {
+    // 播放结束时将当前音频状态设置为停止
+    audioState.value = 'stopped'
+  })
+}
 
 interface IProps {
   jsonData,
@@ -56,20 +90,21 @@ watch(
   }
 )
 
+const onClose = () => {
+  emits('close')
+}
+
 const editorOptions = {
   mode: 'tree',
   modes: ['tree', 'code', 'form', 'text', 'view']
 }
 
-const onClose = () => emits('close')
-
 const save = () => {
   CardController.add({
     ...cacheJson.value
   }).then(r => {
-    emits('close')
+    audioManager.stop()
   })
-  // emits('save', cacheJson.value)
 }
 
 </script>

+ 18 - 3
src/pages/card/components/card-template-21.vue

@@ -182,8 +182,8 @@ function draw () {
       ctx.lineWidth = 2
       const isSelected = rect.id === state.selectedRectId
       ctx.strokeStyle = isSelected ? '#ff6600' : '#007bff'
-      ctx.globalAlpha = 0.5
-      ctx.fillStyle = isSelected ? '#ff660033' : '#007bff22'
+      ctx.globalAlpha = 0.7
+      ctx.fillStyle = isSelected ? '#ff660033' : '#3374e5dd'
       ctx.fillRect(rx, ry, rw, rh)
       ctx.globalAlpha = 1
       ctx.strokeRect(rx, ry, rw, rh)
@@ -194,8 +194,22 @@ function draw () {
       ctx.textAlign = 'center'
       ctx.textBaseline = 'middle'
       ctx.setLineDash([]) // 设置为实线
-      ctx.strokeText(rect.name, rx + rw / 2, ry + rh / 2)
+      if (rect.id <= 3) {
+        ctx.strokeText(rect.name, rx + rw / 2, ry + rh / 2)
+      } else {
+        ctx.strokeText(rect.name, rx + rw / 2, ry + rh / 4)
+      }
       ctx.restore()
+      if (rect.id > 3) {
+        ctx.font = `bold ${Math.floor(rh / 1.7)}px sans-serif`
+        ctx.strokeStyle = '#333'
+        // ctx.lineWidth = 1
+        ctx.textAlign = 'center'
+        ctx.textBaseline = 'middle'
+        // ctx.setLineDash([6, 4]) // 设置为实线
+        ctx.strokeText((rect.id - 3).toString(), rx + rw / 2, ry + rh / 1.3)
+        ctx.restore()
+      }
     })
 
     // 画选择框
@@ -424,5 +438,6 @@ canvas {
   width: 100vw;
   height: 100vh;
   z-index: 10;
+  color: #3374e5dd;
 }
 </style>

+ 22 - 4
src/pages/card/components/card-template.vue

@@ -172,6 +172,7 @@ function draw () {
     const imgW = img.width * state.scale
     const imgH = img.height * state.scale
     ctx.drawImage(img, state.offsetX, state.offsetY, imgW, imgH)
+    console.log('rects:', rects)
 
     // 画矩形(随图片缩放和移动)
     rects.forEach(rect => {
@@ -183,8 +184,8 @@ function draw () {
       ctx.lineWidth = 2
       const isSelected = rect.selected
       ctx.strokeStyle = isSelected ? '#ff6600' : '#007bff'
-      ctx.globalAlpha = 0.5
-      ctx.fillStyle = isSelected ? '#ff660033' : '#007bff22'
+      ctx.globalAlpha = 0.7
+      ctx.fillStyle = isSelected ? '#ff660033' : '#3374e5dd'
       ctx.fillRect(rx, ry, rw, rh)
       ctx.globalAlpha = 1
       ctx.setLineDash([]) // 确保边框为实线
@@ -195,10 +196,27 @@ function draw () {
       ctx.lineWidth = 1
       ctx.textAlign = 'center'
       ctx.textBaseline = 'middle'
+
       // 根据选中状态设置线条样式
       ctx.setLineDash(isSelected ? [] : []) // 选中时使用虚线,否则实线
-      ctx.strokeText(rect.name, rx + rw / 2, ry + rh / 2)
+      if (rect.id < 3) {
+        ctx.strokeText(rect.name, rx + rw / 2, ry + rh / 2)
+      } else {
+        ctx.strokeText(rect.name, rx + rw / 2, ry + rh / 4)
+      }
+
       ctx.restore()
+
+      if (rect.id >= 3) {
+        ctx.font = `bold ${Math.floor(rh / 1.7)}px sans-serif`
+        ctx.strokeStyle = '#333'
+        // ctx.lineWidth = 1
+        ctx.textAlign = 'center'
+        ctx.textBaseline = 'middle'
+        // ctx.setLineDash([6, 4]) // 设置为实线
+        ctx.strokeText((rect.id + 1).toString(), rx + rw / 2, ry + rh / 1.3)
+        ctx.restore()
+      }
     })
   }
 }
@@ -349,7 +367,7 @@ onMounted(() => {
   canvas.addEventListener('mousedown', onMouseDown)
   window.addEventListener('mousemove', onMouseMove)
   window.addEventListener('mouseup', onMouseUp)
-  canvas.addEventListener('click', onClick)
+  // canvas.addEventListener('click', onClick)
 })
 
 const delSelectedRectIds = () => draw()

+ 121 - 608
src/pages/card/components/config-game.vue

@@ -9,10 +9,9 @@
       zIndex: 1000,
     }"
   >
-
   <a-drawer
-    :label="getModalTitle()"
-    size="600px"
+    label="游戏配置"
+    size="700px"
     :open="open"
     :mask="false"
     :closable="false"
@@ -20,12 +19,11 @@
     :get-container="false"
     :style="{ position: 'absolute',  top: '0px', right: '0px', width: '600px' }"
   >
-  <!--     -->
     <template #title>
       <div  style="display: flex;justify-content: space-between;" >
       <div>
         <a-space>
-          <div>游戏配置 {{gameStateComputed ? `:   ${gameStateComputed}` : gameStateComputed}}</div>
+          <div>游戏配置 {{gameStateComputed}}</div>
           <a-tooltip placement="bottom" :getPopupContainer="(node) => node.offsetParent">
             <template #title>
               <span v-html="operationTip"></span>
@@ -34,7 +32,6 @@
           </a-tooltip>
         </a-space>
       </div>
-
       </div>
     </template>
     <template #extra  >
@@ -55,7 +52,7 @@
             <template #icon><left-outlined /></template>
             返回上级
           </a-button>
-          <a-button v-if="currentLevel === 1" type="primary" size="small" @click="createSubItem">
+          <a-button v-if="currentLevel === 1" type="primary" size="small" @click="handleSaveSubItem">
             <template #icon><plus-outlined /></template>
             新增
           </a-button>
@@ -64,106 +61,75 @@
         <!-- 顶级文件夹内容 -->
         <a-row v-if="currentLevel === 0" justify="flex-start" :gutter="[8, 8]" class="config-game-list">
           <a-col
-            :span="7"
+            :span="8"
             style="height: 120px;"
-            class="config-game-item"
             v-for="(folder, index) in cardJson.game_list"
             :key="index"
-            @dblclick="enterFolder(index)"
           >
-            <!-- 状态图标 -->
-            <div class="status-icon">
-              <CheckCircleFilled  v-if="validateGameConfig(folder).isValid"   class="status-complete" />
-              <ExclamationCircleFilled v-else    class="status-incomplete"   />
-            </div>
-
-            <img class="folder-icon" :src="require('@/assets/common/folder.svg')" alt="">
-
-            <div class="folder-footer">
-              <div>游戏 {{ index + 1 }}</div>
-              <div>
-                <a-dropdown :trigger="['hover']">
-                  <SmallDashOutlined class="action-icon" />
-                  <template #overlay>
-                    <a-menu>
-                      <a-menu-item key="edit" @click="handleEdit(index)">
-                        <EditOutlined />
-                        <span style="margin-left: 8px;">编辑</span>
-                      </a-menu-item>
-                      <a-menu-item key="delete" @click="handleDelete(index)">
-                        <DeleteOutlined style="color: #ff4d4f;" />
-                        <span style="margin-left: 8px;">删除</span>
-                      </a-menu-item>
-                    </a-menu>
-                  </template>
-                </a-dropdown>
-              </div>
-            </div>
+            <FolderComponens
+              :game="folder"
+              @dblclick="enterFolder(index)"
+            />
           </a-col>
           <a-empty style="margin: 0 auto;" v-if="cardJson.game_list.length === 0" description="暂无游戏"> </a-empty>
         </a-row>
 
         <div v-else-if="currentLevel === 1">
-        <a-row :gutter="[8, 8]">
-          <VueDraggableNext
-            :list="cardJson.game_list[currentGameIndex].items"
-            group="people"
-            item-key="id"
-            :animation="300"
-            class="config-game-list"
-          >
-            <a-col
-              style="height: 120px;"
-              :span="8"
-              class="config-game-item"
-              v-for="(item, index) in cardJson.game_list[currentGameIndex].items"
-              :key="index"
-              @dblclick="enterCardConfig(index)"
-            >
-              <img class="folder-icon" :src="require('@/assets/common/folder.svg')" alt="">
-
-              <div class="folder-footer">
-                <a-space>
-                  <div>游戏 {{ index + 1 }}</div>
-                  <div class="folder-info">
-                    <div v-if="item.sub_subject?.music_name ? item.sub_subject.music_name : item.sub_subject.ok" class="music-badge">
-                      <sound-outlined />
-                      <span class="music-name">{{ item.sub_subject?.music_name ? item.sub_subject.music_name : item.sub_subject.ok }}</span>
-                    </div>
-                  </div>
-                </a-space>
-                <div>
-                  <a-dropdown :trigger="['hover']">
-                    <SmallDashOutlined class="action-icon" />
-                    <template #overlay>
-                      <a-menu>
-                        <a-menu-item key="edit" @click="handleEditSubItem(index)">
-                          <EditOutlined />
-                          <span style="margin-left: 8px;">编辑</span>
-                        </a-menu-item>
-                        <a-menu-item key="delete" @click="handleDeleteSubItem(index)">
-                          <DeleteOutlined style="color: #ff4d4f;" />
-                          <span style="margin-left: 8px;">删除</span>
-                        </a-menu-item>
-                      </a-menu>
-                    </template>
-                  </a-dropdown>
-                </div>
-              </div>
-            </a-col>
-          </VueDraggableNext>
-          </a-row>
-          <a-empty  style="margin: 0 auto;" v-if="cardJson.game_list[currentGameIndex].items.length === 0" description="暂无游戏"> </a-empty>
+          <!-- 位置文件的rule和main_subject的music_name -->
+          <a-descriptions :column="1" >
+                <a-descriptions-item label="规则">
+                  <a-select style="width: 100%" v-model:value="cardJson.game_list[currentGameIndex].rule" placeholder="请选择规则">
+                    <a-select-option
+                      v-for="rule in CardController.ruleList"
+                      :key="rule.value"
+                      :value="rule.value"
+                    >
+                      {{ rule.title }}
+                    </a-select-option>
+                  </a-select>
+                </a-descriptions-item>
+                <a-descriptions-item label="音乐">
+                  <SelectAudioNew
+                    v-model="cardJson.game_list[currentGameIndex].main_subject.music_name"
+                    placeholder="请选择主题音乐"
+                  />
+                </a-descriptions-item>
+                <a-descriptions-item label="items">
+                    <a-row :gutter="[8, 8]" style="width: 100%;">
+                      <a-col
+                        style="height: 120px;"
+                        :span="8"
+                        v-for="(item, index) in transformJSON.items"
+                        :key="index"
+                        @dblclick="enterCardConfig(index)"
+                      >
+                        <FolderComponens
+                          :game="item"
+                          @dblclick="enterCardConfig(index)"
+                        />
+                      </a-col>
+                    </a-row>
+                    <a-empty  style="margin: 0 auto;" v-if="cardJson.game_list[currentGameIndex].items.length === 0" description="暂无游戏"> </a-empty>
+                </a-descriptions-item>
+          </a-descriptions>
         </div>
 
         <!-- 三级内容:游戏步骤配置 -->
         <div v-else-if="currentLevel === 2">
           <div class="card-config-content">
+            <gameStage3Rule7
+              v-if="transformJSON.rule === 7"
+              v-model="cardJson.game_list[currentGameIndex].items"
+              :rule="cardJson.game_list[currentGameIndex].rule"
+              @update:modelValue="changedSteps"
+              @step-selected="emits('step-selected', $event)"
+            />
             <gameStage3
-              v-if="cardJson.game_list[currentGameIndex]?.items?.[currentSubFolderIndex]"
+              v-if="cardJson.game_list[currentGameIndex]?.items?.[currentSubFolderIndex] && transformJSON.rule !== 7"
               v-model="cardJson.game_list[currentGameIndex].items[currentSubFolderIndex]"
               :gameIndex="currentSubFolderIndex"
               v-model:touch_key="cardJson.game_list[currentGameIndex].touch_key"
+              v-model:ok_key_voice="cardJson.game_list[currentGameIndex].ok_key_voice"
               :rule="cardJson.game_list[currentGameIndex].rule"
               @update:modelValue="changedSteps"
               @step-selected="emits('step-selected', $event)"
@@ -173,150 +139,6 @@
       </div>
     </div>
   </a-drawer>
-
-  <!-- 新增子项目弹窗 -->
-  <a-modal
-    v-model:open="subItemModalVisible"
-    title="新增子项目"
-    width="400px"
-    @ok="handleSaveSubItem"
-  >
-    <a-form :model="subItemForm" layout="vertical">
-      <a-form-item label="子主题音乐">
-        <SelectAudioNew
-          v-model="subItemForm.sub_subject[editForm.rule == '7' ? 'ok' : 'music_name']"
-          placeholder="请选择子主题音乐"
-        />
-
-        <!-- 已选择的音乐名称展示区域 -->
-        <div v-if="subItemForm.sub_subject[editForm.rule == '7' ? 'ok' : 'music_name']" class="selected-music-display">
-          <div class="music-info">
-            <sound-outlined />
-            <span class="music-name">{{ subItemForm.sub_subject[editForm.rule == '7' ? 'ok' : 'music_name'] }}</span>
-          </div>
-        </div>
-      </a-form-item>
-    </a-form>
-  </a-modal>
-
-  <!-- 编辑子项目弹窗 -->
-  <a-modal
-    v-model:open="editSubItemModalVisible"
-    title="编辑子项目"
-    width="400px"
-    @ok="handleSaveSubItemEdit"
-    @cancel="handleCancelSubItemEdit"
-  >
-    <a-form :model="editSubItemForm" layout="vertical">
-      <a-form-item label="子主题音乐">
-        <SelectAudioNew
-          v-model="editSubItemForm.sub_subject[editForm.rule == '7' ? 'ok' : 'music_name']"
-          placeholder="请选择子主题音乐"
-        />
-
-        <!-- 已选择的音乐名称展示区域 -->
-        <div v-if="editSubItemForm.sub_subject[editForm.rule == '7' ? 'ok' : 'music_name']" class="selected-music-display">
-          <div class="music-info">
-            <sound-outlined />
-            <span class="music-name">{{ editSubItemForm.sub_subject[editForm.rule == '7' ? 'ok' : 'music_name'] }}</span>
-          </div>
-        </div>
-      </a-form-item>
-    </a-form>
-  </a-modal>
-
-  <!-- 编辑游戏弹窗 -->
-  <a-modal
-    v-model:open="editModalVisible"
-    title="编辑游戏配置"
-    width="600px"
-    @ok="handleSaveEdit"
-    @cancel="handleCancelEdit"
-  >
-    <a-form :model="editForm" layout="vertical">
-      <!-- 规则单独一行 -->
-      <a-form-item label="规则">
-        <a-select v-model:value="editForm.rule" placeholder="请选择规则">
-          <a-select-option v-for="rule in ruleOptions" :key="rule.value" :value="rule.value">
-            {{ rule.title }}
-          </a-select-option>
-        </a-select>
-      </a-form-item>
-
-      <!-- 音频选择器两列布局 -->
-      <a-row :gutter="16">
-        <a-col :span="12">
-          <a-form-item label="主题音乐:">
-            <SelectAudioNew
-              v-model="editForm.main_subject.music_name"
-              placeholder="请选择主题音乐"
-            />
-          </a-form-item>
-        </a-col>
-        <!-- <a-col :span="12">
-          <a-form-item label="顺序多选错误音乐:">
-            <SelectAudioNew
-              v-model="editForm.ordered_multiple_err.music_name"
-              placeholder="请选择顺序多选错误音乐"
-            />
-          </a-form-item>
-        </a-col> -->
-      </a-row>
-
-      <!-- <a-row :gutter="16">
-        <a-col :span="12">
-          <a-form-item label="重复单击音乐:">
-            <SelectAudioNew
-              v-model="editForm.has_click_single.music_name"
-              placeholder="请选择重复点击音乐"
-            />
-          </a-form-item>
-        </a-col>
-        <a-col :span="12">
-          <a-form-item label="再次点击音乐:">
-            <SelectAudioNew
-              v-model="editForm.still_have.music_name"
-              placeholder="请选择再次点击音乐"
-            />
-          </a-form-item>
-        </a-col>
-      </a-row>
-
-      <a-row :gutter="16">
-        <a-col :span="12">
-          <a-form-item label="重复组点击音乐:">
-            <SelectAudioNew
-              v-model="editForm.has_click_group.music_name"
-              placeholder="请选择重复组点击音乐乐"
-            />
-          </a-form-item>
-        </a-col>
-        <a-col :span="12">
-          <a-form-item label="等待30秒音乐:">
-            <SelectAudioNew
-              v-model="editForm.wait_30s.music_name"
-              placeholder="请选择等待30秒音乐"
-            />
-          </a-form-item>
-        </a-col>
-      </a-row>
-
-      <a-row :gutter="16">
-        <a-col :span="12">
-          <a-form-item label="等待90秒音乐:">
-            <SelectAudioNew
-              v-model="editForm.wait_90s.music_name"
-              placeholder="请选择等待90秒音乐"
-            />
-          </a-form-item>
-        </a-col>
-        <a-col :span="12">
-
-        </a-col>
-      </a-row> -->
-    </a-form>
-  </a-modal>
-
  </div>
 </template>
 
@@ -325,19 +147,14 @@ import { CardController } from '@/controller'
 import {
   LeftOutlined,
   PlusOutlined,
-  SmallDashOutlined,
-  EditOutlined,
-  DeleteOutlined,
-  CheckCircleFilled,
-  ExclamationCircleFilled,
-  SoundOutlined,
   InfoCircleOutlined
 } from '@ant-design/icons-vue'
 import { reactive, ref, PropType, computed } from 'vue'
 import { message } from 'ant-design-vue'
 import SelectAudioNew from './select-audio-new.vue'
 import gameStage3 from './game-stage-3.vue'
-import { VueDraggableNext } from 'vue-draggable-next'
+import gameStage3Rule7 from './game-stage-3-rule7.vue'
+import FolderComponens from '@/components/ui/folder.vue'
 
 const operationTip = `
   1. 双击文件进入下一级<br>
@@ -366,6 +183,29 @@ const gameStateComputed = computed(() => {
   return CardController.ruleList.toMap('value', 'title').get(rule) || ''
 })
 
+// 转换rule=7时与其他规则ok_key格式不同的问题
+const transformJSON = computed(() => {
+  const _game_list = cardJson.value.game_list[currentGameIndex.value]
+
+  if (_game_list.rule === 7) {
+    // _game_list.ok_key = _game_list.ok_key.join(',')
+    const _ok_key = _game_list.items.map(item => {
+      return {
+        ok_key_value: item.ok_key
+      }
+    }).flat(2)
+    return {
+      ..._game_list,
+      items: [{
+        ..._game_list.items[0],
+        ok_key: _ok_key
+      }]
+    }
+  } else {
+    return _game_list
+  }
+})
+
 const emits = defineEmits(['update:open', 'step-selected', 'update:config'])
 
 // 卡片category为21的json
@@ -378,151 +218,47 @@ const cardJson = computed({
 const game1FormJson = {
   rule: '',
   main_subject: { music_name: '', is_break: 1 },
-  ordered_multiple_err: { music_name: '', is_break: 1 },
-  has_click_single: { music_name: '', is_break: 1 },
-  still_have: { music_name: '', is_break: 1 },
-  has_click_group: { music_name: '', is_break: 1 },
-  wait_30s: { music_name: '', is_break: 1 },
-  wait_90s: { music_name: '', is_break: 1 }
+  err_key_voice: [{ is_break: 1, music_name: '', value: '' }] as any,
+  ok_key_voice: [{ is_break: 1, music_name: '', value: '' }] as any,
+  items: []
 }
 
 // 当前层级:0表示顶级文件夹,1表示sub文件夹,2表示游戏配置
 const currentLevel = ref(0)
 
-// 规则下拉选项
-const ruleOptions = CardController.ruleList
-
-// 编辑弹窗相关
-const editModalVisible = ref(false)
-const editingIndex = ref(-1)
-const editForm = reactive({
-  rule: '',
-  main_subject: { music_name: '', is_break: 1 },
-  ordered_multiple_err: { music_name: '', is_break: 1 },
-  has_click_single: { music_name: '', is_break: 1 },
-  still_have: { music_name: '', is_break: 1 },
-  has_click_group: { music_name: '', is_break: 1 },
-  wait_30s: { music_name: '', is_break: 1 },
-  wait_90s: { music_name: '', is_break: 1 }
-})
-
-// 编辑子项目弹窗相关
-const editSubItemModalVisible = ref(false)
-const editingSubItemIndex = ref(-1)
-const editSubItemForm = reactive({
-  sub_subject: {
-    music_name: '',
-    mb: '',
-    ok: '',
-    ob: '',
-    err: '',
-    eb: ''
-  },
-  ok_key: [],
-  err_key: [],
-  ok_key_voice: {},
-  err_key_voice: {}
-})
-
-// 子项目弹窗相关
-const subItemModalVisible = ref(false)
-const subItemForm = reactive({
-  sub_subject: {
-    music_name: '',
-    mb: '',
-    ok: '',
-    ob: '',
-    err: '',
-    eb: ''
-  },
-  ok_key: [],
-  err_key: [],
-  ok_key_voice: {},
-  err_key_voice: {}
-})
-
 // 当前选中的游戏索引
 const currentGameIndex = ref(0)
 
 // 当前选中的子文件夹索引
 const currentSubFolderIndex = ref(0)
 
-// 获取弹窗标题
-const getModalTitle = () => {
-  switch (currentLevel.value) {
-    case 0:
-      return '游戏配置'
-    case 1:
-      return '游戏配置'
-    case 2:
-      return '游戏配置'
-    default:
-      return '游戏配置'
-  }
-}
-
 const createGameOne = () => {
-  cardJson.value.game_list.push(JSON.parse(JSON.stringify(game1FormJson)))
-}
+  const newGameList: API.CardJson21['game_list'][0] = JSON.parse(JSON.stringify(cardJson.value.game_list[0]))
 
-// 校验游戏配置是否完整
-const validateGameConfig = (gameConfig: any) => {
-  const requiredFields = [
-    'rule',
-    'main_subject.music_name'
-    // 'ordered_multiple_err.music_name',
-    // 'has_click_single.music_name',
-    // 'still_have.music_name',
-    // 'has_click_group.music_name',
-    // 'wait_30s.music_name',
-    // 'wait_90s.music_name'
-  ]
-
-  const missingFields: string[] = []
-
-  requiredFields.forEach(field => {
-    const fieldParts = field.split('.')
-    let value = gameConfig
-
-    for (const part of fieldParts) {
-      value = value?.[part]
-    }
+  newGameList.rule = ''
 
-    if (!value || value === '') {
-      missingFields.push(field)
-    }
-  })
-
-  return {
-    isValid: missingFields.length === 0,
-    missingFields
-  }
-}
-
-// 进入二级文件夹
-const enterFolder = (index: number) => {
-  const gameConfig = cardJson.value.game_list[index]
-  const validation = validateGameConfig(gameConfig)
+  newGameList.main_subject = { music_name: '', is_break: 1 }
+  newGameList.items = []
 
-  // if (!validation.isValid) {
-  //   const fieldNames = {
-  //     rule: '规则',
-  //     'main_subject.music_name': '主题音乐',
-  //     'ordered_multiple_err.music_name': '顺序多选错误音乐',
-  //     'has_click_single.music_name': '重复单击音乐',
-  //     'still_have.music_name': '再次点击音乐',
-  //     'has_click_group.music_name': '重复组点击音乐',
-  //     'wait_30s.music_name': '等待30秒音乐',
-  //     'wait_90s.music_name': '等待90秒音乐'
-  //   }
+  newGameList.err_key_voice = [{
+    is_break: 1,
+    music_name: '',
+    value: ''
+  }] as any
 
-  //   const missingFieldNames = validation.missingFields.map(field => fieldNames[field] || field)
+  newGameList.ok_key_voice = [{
+    is_break: 1,
+    music_name: '',
+    value: ''
+  }] as any
 
-  //   message.error(`游戏 ${index + 1} 配置不完整,缺少以下字段:${missingFieldNames.join('、').replaceAll('、', '、\n')}`)
+  cardJson.value.game_list.push(newGameList)
 
-  //   return
-  // }
+  cardJson.value.game_list.push(JSON.parse(JSON.stringify(game1FormJson)))
+}
 
+// 进入二级文件夹
+const enterFolder = (index: number) => {
   // 验证通过,创建 items 数组(如果不存在)
   if (!cardJson.value.game_list[index].items) {
     cardJson.value.game_list[index].items = []
@@ -536,65 +272,31 @@ const changedSteps = (steps: any) => {
   console.log('changedSteps:', cardJson.value.game_list[currentGameIndex.value].items)
 }
 
-const createSubItem = () => {
-  // 重置表单并打开弹窗
-  subItemForm.sub_subject.music_name = ''
-  subItemForm.sub_subject.mb = ''
-  subItemForm.sub_subject.ok = ''
-  subItemForm.sub_subject.ob = ''
-  subItemForm.sub_subject.err = ''
-  subItemForm.sub_subject.eb = ''
-  subItemForm.ok_key = []
-  subItemForm.err_key = []
-  subItemForm.ok_key_voice = {}
-  subItemForm.err_key_voice = {}
-
-  subItemModalVisible.value = true
-}
-
-// 新建二级文件夹
+// 新增子游戏
 const handleSaveSubItem = () => {
-  // 基础校验:必须选择子主题音乐
-  // if (!subItemForm.sub_subject.music_name) {
-  //   message.warning('请选择子主题音乐')
-  //   return
-  // }
-
-  const index = currentGameIndex.value
-  const currentGame = cardJson.value.game_list[index]
-  if (!currentGame) {
-    message.error('未选择有效的游戏')
-    return
-  }
+  // 构造 items 下的新对象(遵循 API.CardJson21[game_list].items 的结构)
+  const newItem: API.CardJson21['game_list'][0]['items'][0] = JSON.parse(JSON.stringify(cardJson.value.game_list[0].items[0]))
 
-  // 确保 items 数组存在
-  if (!currentGame.items) {
-    currentGame.items = []
-  }
+  newItem.ok_key = []
 
-  // 构造 items 下的新对象(遵循 API.CardJson21[game_list].items 的结构)
-  const newItem = {
-    sub_subject: {
-      music_name: subItemForm.sub_subject.music_name,
-      mb: 1,
-      ok: '',
-      ob: 1,
-      err: '',
-      eb: 1
-    },
-    ok_key: [],
-    err_key: [],
-    ok_key_voice: {},
-    err_key_voice: {}
+  newItem.err_key = []
+
+  newItem.sub_subject = {
+    music_name: '',
+    mb: 0,
+    ok: '',
+    ob: 0,
+    err: '',
+    eb: 0
   }
 
-  // 追加到 items
-  currentGame.items.push(newItem)
-  console.log('游戏类容', cardJson.value.game_list)
+  cardJson.value.game_list[currentGameIndex.value].ok_key_voice.push({
+    is_break: 1,
+    music_name: '',
+    value: ''
+  })
 
-  // 关闭弹窗并重置关键字段,便于下次新增
-  subItemModalVisible.value = false
-  subItemForm.sub_subject.music_name = ''
+  cardJson.value.game_list[currentGameIndex.value].items.push(newItem)
 }
 
 // 进入卡片配置
@@ -625,85 +327,9 @@ const goBack = () => {
   }
 }
 
-// 处理编辑游戏
-const handleEdit = (index: number) => {
-  editingIndex.value = index
-  // 将当前游戏数据复制到编辑表单
-  Object.assign(editForm, JSON.parse(JSON.stringify(cardJson.value.game_list[index])))
-  editModalVisible.value = true
-}
-
-// 处理删除游戏
-const handleDelete = (index: number) => {
-  // 从游戏列表中删除指定索引的游戏
-  cardJson.value.game_list.splice(index, 1)
-}
-
-// 处理编辑子项目
-const handleEditSubItem = (index: number) => {
-  editingSubItemIndex.value = index
-  // 将当前子项目数据复制到编辑表单
-  const currentItem = cardJson.value.game_list[currentGameIndex.value].items[index]
-  editSubItemForm.sub_subject.music_name = currentItem.sub_subject.music_name
-  editSubItemForm.sub_subject.ok = currentItem.sub_subject.ok
-  editSubItemForm.sub_subject.err = currentItem.sub_subject.err
-  editSubItemModalVisible.value = true
-  console.log(editForm)
-}
-
-// 处理删除子项目
-const handleDeleteSubItem = (index: number) => {
-  // 从子项目列表中删除指定索引的项目
-  cardJson.value.game_list[currentGameIndex.value].items.splice(index, 1)
-}
-
-// 保存子项目编辑
-const handleSaveSubItemEdit = () => {
-  if (editingSubItemIndex.value >= 0) {
-    // 将编辑表单的数据保存到子项目列表
-    const currentItem = cardJson.value.game_list[currentGameIndex.value].items[editingSubItemIndex.value]
-    currentItem.sub_subject.music_name = editSubItemForm.sub_subject.music_name || ''
-    currentItem.sub_subject.ok = editSubItemForm.sub_subject.ok || ''
-    currentItem.sub_subject.err = editSubItemForm.sub_subject.err || ''
-  }
-  editSubItemModalVisible.value = false
-  editingSubItemIndex.value = -1
-}
-
-// 取消子项目编辑
-const handleCancelSubItemEdit = () => {
-  editSubItemModalVisible.value = false
-  editingSubItemIndex.value = -1
-}
-
-// 保存编辑
-const handleSaveEdit = () => {
-  if (editingIndex.value >= 0) {
-    // 将编辑表单的数据保存到游戏列表
-    Object.assign(cardJson.value.game_list[editingIndex.value], JSON.parse(JSON.stringify(editForm)))
-
-    if (!cardJson.value.game_list[editingIndex.value].touch_key) {
-      cardJson.value.game_list[editingIndex.value].touch_key = [
-        [{ value: 0, music_name: '', is_break: 1 }],
-        [{ value: 1, music_name: '', is_break: 1 }],
-        [{ value: 2, music_name: '', is_break: 1 }]
-      ]
-    }
-  }
-  editModalVisible.value = false
-  editingIndex.value = -1
-}
-
-// 取消编辑
-const handleCancelEdit = () => {
-  editModalVisible.value = false
-  editingIndex.value = -1
-}
-
 // 保存游戏配置
 const saveGameConfig = async () => {
   saveLoading.value = true
-  // const defaultJson = await Car dController.getDefaultJson()
   await CardController.add({
     header: cardJson.value.header,
     game_list: cardJson.value.game_list
@@ -746,119 +372,6 @@ const saveGameConfig = async () => {
     height: 100%;
   }
 
-  .config-game-item {
-    display: flex;
-    flex-direction: column;
-    justify-content: space-between;
-    align-items: center;
-    cursor: pointer;
-    border-radius: 8px;
-    // transition: all 0.3s;
-    background-color: #f5f5f5;
-    padding: 0px !important;
-    border: 1px solid #e9ecf1;
-    box-sizing: border-box !important;
-    margin-right: 16px;
-    margin-bottom: 16px;
-    position: relative;
-    overflow: hidden;
-    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
-
-    .status-icon {
-      position: absolute;
-      top: 8px;
-      left: 8px;
-      z-index: 10;
-      font-size: 16px;
-
-      .status-complete {
-        color: #52c41a;
-      }
-
-      .status-incomplete {
-        color: #ff4d4f;
-      }
-    }
-
-    .folder-icon {
-      width: 86px;
-      height: 86px;
-    }
-    .folder-footer {
-      width: 100%;
-      height: 32px;
-      display: flex;
-      align-items: center;
-      justify-content: space-between;
-      border-top: 1px solid #e9ecf1;
-      background-color: #fff;
-      box-sizing: border-box;
-      padding: 0 12px;
-      position: absolute;
-      bottom: 0;
-      left: 0;
-      right: 0;
-
-      .action-icon {
-        cursor: pointer;
-        opacity: 0;
-        transition: opacity 0.3s;
-        font-size: 16px;
-        color: #8c8c8c;
-
-        &:hover {
-          color: #1890ff;
-        }
-      }
-    }
-
-    .folder-info {
-      width: 100%;
-      display: flex;
-      flex-direction: column;
-      align-items: center;
-      // padding: 12px 0;
-      // margin-bottom: 32px;
-
-      .folder-title {
-        font-weight: 500;
-        margin-bottom: 8px;
-        font-size: 14px;
-        color: #333;
-      }
-
-      .music-badge {
-        display: flex;
-        align-items: center;
-        gap: 4px;
-        border-radius: 12px;
-        font-size: 12px;
-        .anticon {
-          color: #1890ff;
-          font-size: 12px;
-        }
-
-        .music-name {
-          color: #1890ff;
-          white-space: nowrap;
-          overflow: hidden;
-          text-overflow: ellipsis;
-          max-width: 80px;
-        }
-      }
-    }
-
-    &:hover {
-      scale: 1.02;
-
-      .folder-footer .action-icon {
-        opacity: 1;
-      }
-    }
-    &:hover {
-      scale: 1.02;
-    }
-  }
 }
 
 .card-config-content {

+ 432 - 0
src/pages/card/components/game-stage-3-rule7.vue

@@ -0,0 +1,432 @@
+<template>
+  <div class="game-stage-3">
+    <div class="step-list" >
+      <a-collapse :bordered="false" v-model:activeKey="collapseKeys.success">
+        <a-collapse-panel key="2" >
+          <template #header>
+            <div class="collapse-header">
+              <span>正确按钮 ({{ steps.length }})</span>
+              <a-button type="primary" size="small" @click.stop="showAddStepModal">新增</a-button>
+            </div>
+          </template>
+
+          <VueDraggableNext
+            :list="steps"
+            group="people"
+            item-key="id"
+            :animation="300"
+            @end="onDragEnd"
+          >
+            <div v-for="(element, index) in steps" :key="index" class="step-item-wrapper">
+              <div
+                class="step-item"
+                :class="{ 'step-item-active': activeStepIndex === element.ok_key[0].valu }"
+              >
+                <div class="step-info">
+                  <div class="step-number">按钮 {{getButtonLabel(element.ok_key[0].value) }}-{{element.ok_key[0].value}}</div>
+                  <div class="step-details">
+                    <div class="step-detail-row">
+                      <span class="detail-label">音频:</span>
+                      <strong class="audio-name"  @click="showAddStepModal" >{{ element.sub_subject.ok || '点击选择音频' }}</strong>
+                    </div>
+                  </div>
+                </div>
+                <div class="step-actions">
+                  <a-popconfirm
+                    title="确定要删除这个步骤吗?"
+                    ok-text="确定"
+                    cancel-text="取消"
+                    @confirm.stop="deleteGameStep(index)"
+                    @cancel.stop
+                  >
+                    <delete-outlined @click.stop />
+                  </a-popconfirm>
+                </div>
+              </div>
+            </div>
+          </VueDraggableNext>
+          <a-empty v-if="steps.length === 0" description="暂无正确按钮,请点击新增按钮" />
+        </a-collapse-panel>
+      </a-collapse>
+    </div>
+    <a-modal
+      v-model:open="addStepModalVisible"
+      width="600px"
+      :closable="false"
+      @ok="handleAddStep"
+      @cancel="cancelAddStep"
+      wrap-class-name="game-stage-3-modal"
+    >
+      <template #title>
+        <div class="header">
+          <span>新增步骤</span>
+        </div>
+      </template>
+      <a-form  layout="vertical" >
+        <a-form-item label="选择按钮">
+          <div class="button-grid">
+            <div
+              v-for="btn in buttonOptions"
+              :key="btn.value"
+              class="button-wrapper"
+              @mouseenter="handleMouseEnter(btn.value)"
+              @mouseleave="handleMouseLeave"
+            >
+              <a-button
+                :type="getBtnTypeByKey(btn.value)"
+                @click="selectButton(btn)"
+              >
+                {{ btn.label }}-{{btn.value}}
+              </a-button>
+              <delete-outlined
+                v-if="hoveredButton === btn.value && isButtonSelected(btn.value)"
+                class="delete-icon"
+                @click.stop="handleDeleteButton(btn.value)"
+              />
+            </div>
+          </div>
+        </a-form-item>
+        <a-form-item label="选择语音">
+          <div
+            style="margin-bottom: 8px;"
+            :key="item.sub_subject.ok"
+             v-for="item in steps"
+          >
+            <SelectAudioNew
+              :buttonLabel="getButtonLabel(item.ok_key[0].value)"
+              v-model="item.sub_subject.ok"
+              :placeholder="'请选择' + getButtonLabel(item.ok_key[0].value) + '音频'"
+            />
+          </div>
+        </a-form-item>
+      </a-form>
+    </a-modal>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { ref, computed } from 'vue'
+import { DeleteOutlined } from '@ant-design/icons-vue'
+import { VueDraggableNext } from 'vue-draggable-next'
+import SelectAudioNew from './select-audio-new.vue'
+
+interface Props {
+  modelValue: API.CardJson21Item[],
+  rule: API.Rule
+}
+
+const collapseKeys = ref({
+  top: '1',
+  success: '2',
+  error: '3'
+})
+
+const props = defineProps<Props>()
+const emits = defineEmits(['update:modelValue', 'step-selected', 'update:touch_key'])
+
+const steps = computed({
+  get: () => props.modelValue,
+  set: (value) => emits('update:modelValue', value)
+})
+
+const addStepModalVisible = ref(false)
+const activeStepIndex = ref<number | null>(null)
+const ok_key_values = computed(() => {
+  return steps.value.map(step => step.ok_key.map(ok => ok.value)).flat(2)
+})
+
+const buttonOptions = (() => {
+  const options: {label: string, value: number}[] = []
+  const letters = ['A', 'B', 'C', 'D', 'E', 'F']
+  let value = 4
+  for (const letter of letters) {
+    for (let i = 1; i <= 6; i++) {
+      options.push({
+        label: `${letter}${i}`,
+        value: value
+      })
+      value++
+    }
+  }
+  return options
+})()
+
+const buttonMap = new Map<number, string>()
+buttonOptions.forEach(opt => buttonMap.set(opt.value, opt.label))
+const getButtonLabel = (value: number) => buttonMap.get(value) || '未知'
+
+const getBtnTypeByKey = (key: number) => {
+  return ok_key_values.value.includes(key) ? 'primary' : 'default'
+}
+
+const showAddStepModal = () => addStepModalVisible.value = true
+
+const handleAddStep = () => {
+  emits('update:modelValue', steps.value)
+
+  addStepModalVisible.value = false
+}
+
+const cancelAddStep = () => addStepModalVisible.value = false
+
+const deleteGameStep = (index: number) => {
+  steps.value.splice(index, 1)
+  emits('update:modelValue', steps.value)
+  if (activeStepIndex.value === index) {
+    activeStepIndex.value = null
+    emits('step-selected', null)
+  }
+}
+
+const selectButton = (btn: { label: string, value: number }) => {
+  if (!ok_key_values.value.includes(btn.value)) {
+    const _items = JSON.parse(JSON.stringify(steps.value[0]))
+    _items.ok_key = [{ value: btn.value, music_name: '', is_break: 1 }]
+    _items.sub_subject.ok = ''
+    steps.value.push(_items)
+  }
+}
+
+const onDragEnd = () => emits('update:modelValue', steps.value)
+
+// 添加按钮悬停相关的状态和方法
+const hoveredButton = ref<number | null>(null)
+
+const handleMouseEnter = (value: number) => hoveredButton.value = value
+
+const handleMouseLeave = () => hoveredButton.value = null
+
+const isButtonSelected = (value: number) => ok_key_values.value.some(item => item === value)
+
+const handleDeleteButton = (value: number) => {
+  // 查找按钮在哪个数组中
+  const okIndex = steps.value.findIndex(item => item.ok_key[0].value === value)
+  if (okIndex !== -1) {
+    steps.value.splice(okIndex, 1)
+    emits('update:modelValue', steps.value)
+  }
+}
+</script>
+
+<style lang="less" scoped>
+.game-stage-3 {
+  .form-row {
+    display: flex;
+    flex-direction: column;
+    align-items: flex-start;
+    margin-bottom: 16px;
+  }
+
+  .form-label {
+    width: 100%;
+    text-align: left;
+    display: flex;
+    align-items: center;
+    color: rgba(0, 0, 0, 0.85);
+    font-size: 14px;
+    margin-bottom: 8px;
+  }
+
+  .form-content {
+    flex: 1;
+  }
+
+  .form-break {
+    width: 180px;
+    margin-left: 8px;
+  }
+
+  .audio-select {
+    width: 100%;
+  }
+
+  .break-select-inline {
+    width: 100%;
+  }
+  .header {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    margin-bottom: 16px;
+  }
+
+  .step-item-wrapper {
+    margin-bottom: 12px;
+  }
+
+  .step-item {
+    width: 90%;
+    display: flex;
+    align-items: flex-start;
+    justify-content: space-between;
+    padding: 16px;
+    border: 1px solid #d9d9d9;
+    border-radius: 8px;
+    cursor: grab;
+    background-color: #fafafa;
+    transition: all 0.3s;
+
+    &:hover {
+      border-color: #40a9ff;
+      box-shadow: 0 2px 8px rgba(0, 0, 0, 0.09);
+    }
+  }
+
+  .step-item-active {
+    border-color: #1890ff;
+    background-color: #e6f7ff;
+    box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
+  }
+
+  .step-info {
+    display: flex;
+    flex-direction: column;
+    width: 100%;
+  }
+
+  .step-number {
+    font-weight: 600;
+    font-size: 16px;
+    margin-bottom: 12px;
+    color: #1890ff;
+  }
+
+  .step-details {
+    display: flex;
+    flex-direction: column;
+    gap: 8px;
+    font-size: 14px;
+    color: #555;
+    width: 100%;
+    padding-left: 0;
+  }
+
+  .step-detail-row {
+    display: flex;
+    align-items: center;
+    padding: 4px 0;
+  }
+
+  .detail-label {
+    width: 80px;
+    color: #666;
+    font-weight: 500;
+  }
+
+  .audio-name {
+    color: #1890ff;
+    max-width: 200px;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+
+  .break-row {
+    margin-top: 4px;
+  }
+
+  .break-select {
+    width: 100%;
+  }
+
+  .step-actions {
+    cursor: pointer;
+    color: #ff4d4f;
+    font-size: 16px;
+    padding-top: 4px;
+  }
+}
+</style>
+<style lang="less" scoped>
+.game-stage-3-modal {
+  .header {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+  }
+  .button-grid {
+    display: grid;
+    grid-template-columns: repeat(6, 1fr);
+    gap: 10px;
+    padding: 16px;
+    border-radius: 6px;
+    background-color: #f7f7f7;
+    border: 1px solid #e8e8e8;
+  }
+}
+
+.game-stage-3-modal .button-grid .ant-btn {
+  width: 100%;
+  padding: 0;
+  height: 40px;
+  font-weight: 500;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+}
+
+.button-wrapper {
+  width: 100%;
+  position: relative;
+}
+
+.delete-icon {
+  position: absolute;
+  top: -8px;
+  right: -8px;
+  color: #ff4d4f;
+  background-color: #fff;
+  border-radius: 50%;
+  padding: 2px;
+  font-size: 14px;
+  box-shadow: 0 2px 5px rgba(0, 0, 0, 0.15);
+  cursor: pointer;
+  z-index: 10;
+}
+
+.top-config {
+  margin-bottom: 24px;
+}
+
+.button-type-hint {
+  font-size: 12px;
+  color: #999;
+  margin-top: 4px;
+}
+
+.panel-header {
+  display: flex;
+  justify-content: flex-end;
+  margin-bottom: 16px;
+}
+
+.collapse-header {
+  width: 100%;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+
+.step-list {
+  width: 100%;
+  margin-bottom: 24px;
+}
+
+:deep(.ant-collapse-content-box) {
+  padding: 16px;
+  width: 100%;
+}
+
+:deep(.ant-collapse) {
+  width: 100%;
+}
+
+:deep(.ant-collapse-item) {
+  width: 100%;
+}
+
+// :deep(.ant-select-selector) {
+//   border: none !important;
+//   padding: 0;
+// }
+</style>

+ 84 - 119
src/pages/card/components/game-stage-3.vue

@@ -1,53 +1,34 @@
 <template>
   <div class="game-stage-3">
-    <div class="top-config" v-if="rule != 7">
-      <a-collapse :bordered="false" v-model:activeKey="collapseKeys.top">
+    <div class="top-config">
+      <a-collapse :bordered="false" v-model:activeKey="collapseKeys.sub">
         <a-collapse-panel key="1">
           <template #header>
             <div class="collapse-header">
-              <span>顶部按钮配置</span>
+              <span>sub_music_name</span>
             </div>
           </template>
-          <a-form layout="horizontal" :label-col="{ span: 4 }" :wrapper-col="{ span: 20 }">
-            <!-- <div class="form-row">
-              <div class="form-label">
-                <a-space>
-                  <div>空白按钮音频:</div>
-                  <select-break :bordered="false" v-model:value="touch_keys[0][0].is_break" class="break-select-inline" />
-                </a-space>
-              </div>
-               <SelectAudioNew
-                  v-model="touch_keys[0][0].music_name"
-                  placeholder="请选择按钮音频"
-                  class="audio-select"
-                />
+           <SelectAudioNew  v-model="steps.sub_subject.music_name"   placeholder="sub_subject_music_name" />
+        </a-collapse-panel>
+      </a-collapse>
+      <a-collapse :bordered="false" v-model:activeKey="collapseKeys.top">
+        <a-collapse-panel key="1">
+          <template #header>
+            <div class="collapse-header">
+              <span>touch_key</span>
             </div>
-            <div class="form-row">
-              <div class="form-label">
-                <a-space>
-                  <div>刷新按钮音频:</div>
-                  <select-break :bordered="false" v-model:value="touch_keys[1][0].is_break" class="break-select-inline" />
-                </a-space>
-              </div>
-               <SelectAudioNew
-                  v-model="touch_keys[1][0].music_name"
-                  placeholder="刷新按钮音频"
-                  class="audio-select"
-                />
-            </div> -->
-            <div class="form-row">
-              <div class="form-label">
-                <a-space>
-                  <div>提示按钮音频:</div>
-                </a-space>
-              </div>
-               <SelectAudioNew
-                  v-model="touch_keys[2][gameIndex + 1].music_name"
-                  placeholder="刷新按钮音频"
-                  class="audio-select"
-                />
+          </template>
+         <SelectAudioNew  v-model="touch_keys[2][gameIndex + 1].music_name"    placeholder="touch_key"    class="audio-select"  />
+        </a-collapse-panel>
+      </a-collapse>
+      <a-collapse :bordered="false" v-model:activeKey="collapseKeys.ok_key_voice">
+        <a-collapse-panel key="1">
+          <template #header>
+            <div class="collapse-header">
+              <span>ok_key_voice</span>
             </div>
-          </a-form>
+          </template>
+         <SelectAudioNew  v-model="ok_key_voice[gameIndex].music_name" placeholder="ok_key_voice"    class="audio-select"  />
         </a-collapse-panel>
       </a-collapse>
     </div>
@@ -75,7 +56,7 @@
 
               >
                 <div class="step-info">
-                  <div class="step-number">按钮 {{getButtonLabel(element.value) }}</div>
+                  <div class="step-number">按钮 {{getButtonLabel(element.value) }}-{{element.value}}</div>
                   <div class="step-details">
                     <div class="step-detail-row">
                       <span class="detail-label">音频:</span>
@@ -105,7 +86,7 @@
         </a-collapse-panel>
       </a-collapse>
     </div>
-    <div class="step-list" v-if="getBtnRuleByRule === 'multiple'">
+    <div class="step-list" >
       <a-collapse :bordered="false" v-model:activeKey="collapseKeys.error">
         <a-collapse-panel key="3">
           <template #header>
@@ -129,7 +110,7 @@
 
               >
                 <div class="step-info">
-                  <div class="step-number">按钮 {{ getButtonLabel(element.value)}}</div>
+                  <div class="step-number">按钮 {{ getButtonLabel(element.value)}}-{{element.value}}</div>
                   <div class="step-details">
                     <div class="step-detail-row">
                       <span class="detail-label">音频:</span>
@@ -170,7 +151,7 @@
       <template #title>
         <div class="header">
           <span>新增步骤</span>
-          <a-radio-group v-if="getBtnRuleByRule === 'multiple'" v-model:value="btnMode">
+          <a-radio-group v-model:value="btnMode">
             <a-radio-button value="success">正确</a-radio-button>
             <a-radio-button value="error">错误</a-radio-button>
           </a-radio-group>
@@ -190,7 +171,7 @@
                 :type="getBtnTypeByKey(btn.value)"
                 @click="selectButton(btn)"
               >
-                {{ btn.label }}
+                {{ btn.label }}-{{ btn.value }}
               </a-button>
               <delete-outlined
                 v-if="hoveredButton === btn.value && isButtonSelected(btn.value)"
@@ -200,17 +181,31 @@
             </div>
           </div>
         </a-form-item>
-        <a-form-item label="选择语音">
+        <a-form-item label="选择语音"
+
+        >
           <div
-            style="margin-bottom: 8px;"
-            :key="item.value"
-             v-for="item in steps.ok_key"
+            style="
+              overflow: hidden;
+              overflow-y: auto;
+              height: 500px;
+              margin-top: 20px;
+              display: flex;
+              flex-direction: column;
+              justify-content: flex-start;
+            "
           >
-            <SelectAudioNew
-              :buttonLabel="getButtonLabel(item.value)"
-              v-model="item.music_name"
-              :placeholder="'请选择' + getButtonLabel(item.value) + '音频'"
-            />
+            <div
+              style="margin-bottom: 8px;"
+              :key="item.value"
+              v-for="item in steps.ok_key"
+            >
+              <SelectAudioNew
+                :buttonLabel="getButtonLabel(item.value)"
+                v-model="item.music_name"
+                :placeholder="'请选择' + getButtonLabel(item.value) + '音频'"
+              />
+            </div>
           </div>
         </a-form-item>
       </a-form>
@@ -224,21 +219,32 @@
               :type="getBtnTypeByKey(btn.value)"
               @click="selectButton(btn)"
             >
-              {{ btn.label }}
+              {{ btn.label }}-{{ btn.value }}
             </a-button>
           </div>
         </a-form-item>
-        <a-form-item label="选择语音">
-          <div
-            style="margin-bottom: 8px;"
-            v-for="item in steps.err_key"
-            :key="item.value"
+        <a-form-item label="选择语音"
           >
-            <SelectAudioNew
-              :buttonLabel="getButtonLabel(item.value)"
-              v-model="item.music_name"
-              :placeholder="'请选择' + getButtonLabel(item.value) + '音频'"
-            />
+          <div      style="
+              overflow: hidden;
+              overflow-y: auto;
+              height: 500px;
+              margin-top: 20px;
+              display: flex;
+              flex-direction: column;
+              justify-content: flex-start;
+            ">
+            <div
+              style="margin-bottom: 8px;"
+              v-for="item in steps.err_key"
+              :key="item.value"
+            >
+              <SelectAudioNew
+                :buttonLabel="getButtonLabel(item.value)"
+                v-model="item.music_name"
+                :placeholder="'请选择' + getButtonLabel(item.value) + '音频'"
+              />
+            </div>
           </div>
         </a-form-item>
       </a-form>
@@ -257,34 +263,21 @@ import { message } from 'ant-design-vue'
 interface Props {
   modelValue: API.CardJson21Item,
   touch_key: API.CardJson21TouchKey
+  ok_key_voice: API.CardJson21['game_list'][0]['ok_key_voice']
   rule: API.Rule
   gameIndex: number
 }
 
-watch(
-  () => props.rule,
-  (newVal) => {
-    console.log('Rule 的改变:', props.rule)
-  },
-  { immediate: true }
-)
-
-watch(
-  () => props.touch_key,
-  (newVal) => {
-    console.log('touch_keys 的改变:', props.touch_key)
-  },
-  { immediate: true }
-)
-
 const collapseKeys = ref({
+  sub: '0',
   top: '1',
   success: '2',
-  error: '3'
+  error: '3',
+  ok_key_voice: '4'
 })
 
 const props = defineProps<Props>()
-const emits = defineEmits(['update:modelValue', 'step-selected', 'update:touch_key'])
+const emits = defineEmits(['update:modelValue', 'update:ok_key_voice', 'step-selected', 'update:touch_key'])
 
 const steps = computed({
   get: () => {
@@ -300,12 +293,9 @@ const touch_keys = computed({
   set: (value) => emits('update:touch_key', value)
 })
 
-// 有的是单选(7、10) 有的是多选(9 , 11, 12, 13, 14, 15, 16) 17 单和多都可以,直接用多选
-const getBtnRuleByRule = computed(() => {
-  if (props.rule === 7 || props.rule === 10) {
-    return 'single'
-  }
-  return 'multiple'
+const ok_key_voice = computed({
+  get: () => props.ok_key_voice,
+  set: (value) => emits('update:ok_key_voice', value)
 })
 
 const addStepModalVisible = ref(false)
@@ -349,15 +339,11 @@ const showAddStepModal = (mode: 'success' | 'error') => {
 
 const selectButton = (btn: { label: string, value: number }) => {
   if (btnMode.value === 'success') {
-    if (getBtnRuleByRule.value === 'single') {
-      steps.value.ok_key[0] = { value: btn.value, music_name: '', is_break: 1 }
+    const targetIndex = steps.value.ok_key.findIndex(item => btn.value === item.value)
+    if (targetIndex === -1) {
+      steps.value.ok_key.push({ value: btn.value, music_name: '', is_break: 1 })
     } else {
-      const targetIndex = steps.value.ok_key.findIndex(item => btn.value === item.value)
-      if (targetIndex === -1) {
-        steps.value.ok_key.push({ value: btn.value, music_name: '', is_break: 1 })
-      } else {
-        steps.value.ok_key.splice(targetIndex, 1)
-      }
+      steps.value.ok_key.splice(targetIndex, 1)
     }
   } else {
     const targetIndex = steps.value.err_key.findIndex(item => btn.value === item.value)
@@ -370,27 +356,6 @@ const selectButton = (btn: { label: string, value: number }) => {
 }
 
 const handleAddStep = () => {
-  // if (getBtnRuleByRule.value === 'single' && steps.value.ok_key.length === 0) {
-  //   message.error('请至少选择一个正确按钮')
-  //   return
-  // } else {
-  //   for (let index = 0; index < steps.value.ok_key.length; index++) {
-  //     const item = steps.value.ok_key[index]
-  //     if (!item.music_name) {
-  //       message.error('请选择语音')
-  //       return
-  //     }
-  //   }
-
-  //   for (let index = 0; index < steps.value.err_key.length; index++) {
-  //     const item = steps.value.err_key[index]
-  //     if (!item.music_name) {
-  //       message.error('请选择语音')
-  //       return
-  //     }
-  //   }
-  // }
-
   emits('update:modelValue', steps.value)
 
   addStepModalVisible.value = false

+ 70 - 0
src/pages/card/components/question-modal.vue

@@ -0,0 +1,70 @@
+<template>
+<a-modal
+  :open="visible"
+  title="情景问题"
+  @ok="save"
+  @cancel="onClose"
+  width="20%"
+>
+ <a-textarea  v-model:value="feedback.feedback" height="200px" placeholder="输入问题" style="margin-top: 4px;height: 200px;" />
+</a-modal>
+</template>
+<script lang='ts'  setup >
+import { ref, watch } from 'vue'
+import { CardController } from '@/controller/index'
+import { useRoute } from 'vue-router'
+
+interface IProps {
+  visible: Boolean,
+}
+
+const route = useRoute()
+
+const props = defineProps<IProps>()
+
+const feedback = ref<API.Feedback>({
+  cardId: '',
+  page: '',
+  feedback: ''
+})
+
+const emits = defineEmits(['close'])
+
+const onClose = () => {
+  emits('close')
+}
+
+const getFeedback = async () => {
+  const { data } = await CardController.getFeedbackByCardId(route.query.id as string)
+  feedback.value = data || {}
+}
+
+const saveOrUpdateFeedback = async () => {
+  const { data, status } = await CardController.saveOrUpdateFeedback({
+    id: feedback.value.id,
+    cardId: route.query.id as string,
+    page: route.query.page as string,
+    feedback: feedback.value.feedback
+  })
+  if (status === 200) {
+    onClose()
+  }
+}
+
+const save = () => {
+  saveOrUpdateFeedback()
+}
+
+watch(
+  () => props.visible,
+  (visible) => {
+    if (visible) {
+      getFeedback()
+    }
+  }
+)
+
+</script>
+<style lang='less' scoped >
+
+</style>

+ 2 - 1
src/pages/card/components/select-audio-new.vue

@@ -40,7 +40,7 @@
     <!-- 音频选择弹窗 -->
     <a-modal
       v-model:open="showAudioModal"
-      title="选择音频"
+      :title="`选择${buttonLabel}音频`"
       width="600px"
       :footer="null"
       @cancel="handleModalCancel"
@@ -120,6 +120,7 @@ interface Props {
   modelValue?: string
   placeholder?: string
   buttonLabel?: string
+
 }
 
 const props = withDefaults(defineProps<Props>(), {

+ 81 - 3
src/pages/card/index.vue

@@ -25,11 +25,11 @@
       </a-space>
     </div>
     <a-space>
+      <a-button type="primary" @click="openQuestionModel">情景问题</a-button>
       <a-button   @click="openState.jsonPanelOpen = !openState.jsonPanelOpen">JSON面板</a-button>
-      <!-- <a-button v-if="Number(cardType) === 5" @click="openDefaultConfig(5)">基础配置</a-button>
-      <a-button v-if="Number(cardType) === 21"  @click="openDefaultConfig(21)">基础配置</a-button> -->
       <a-button v-if="Number(cardType) === 5" type="primary" @click="openConfigCard(5)">打开配置板</a-button>
       <a-button v-if="Number(cardType) === 21" type="primary" @click="openConfigCard(21)">打开配置板</a-button>
+      <a-button type="primary" @click="openCategoryModal">卡片分类设置</a-button>
     </a-space>
   </div>
 
@@ -160,7 +160,7 @@
       <a-button type="primary" size="large" @click="handleCardTypeSelect">确认选择</a-button>
     </div>
   </a-modal>
-
+<!--      -->
   <CardJsonPanel
     v-if="openState.jsonPanelOpen"
     :open="openState.jsonPanelOpen"
@@ -168,6 +168,35 @@
     @close="openState.jsonPanelOpen = false"
     @change="changeJsonData"
   />
+
+  <QuestionModal
+   :visible="questionModelVisible"
+    @close="questionModelVisible = false"
+  />
+
+  <!-- 卡片分类设置 Modal -->
+  <a-modal
+    v-model:open="categoryModalVisible"
+    title="卡片分类设置"
+    width="800px"
+    @ok="saveCategorySettings"
+    @cancel="categoryModalVisible = false"
+  >
+    <a-spin :spinning="categoryLoading">
+      <div v-if="categoryList.length > 0" style="max-height: 400px; overflow-y: auto;">
+        <a-checkbox-group v-model:value="selectedCategoryIds" style="width: 100%;">
+          <div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px;">
+            <div v-for="category in categoryList" :key="category.id">
+              <a-checkbox :value="category.id">
+                {{ category.title }}
+              </a-checkbox>
+            </div>
+          </div>
+        </a-checkbox-group>
+      </div>
+      <a-empty v-else description="暂无分类数据" />
+    </a-spin>
+  </a-modal>
 </template>
 <script lang='ts'  setup >
 import { InfoCircleOutlined } from '@ant-design/icons-vue'
@@ -183,6 +212,8 @@ import { CardController } from '@/controller'
 import { message } from 'ant-design-vue'
 import ConfigGame from './components/config-game.vue'
 import CardJsonPanel from './components/card-21-json-panel.vue'
+import QuestionModal from './components/question-modal.vue'
+import { getCategoryById, setCategoryById } from '@/api/card'
 
 const operationTip = `
   1. 点击矩形可以选中,选中后进行配置<br>
@@ -242,6 +273,53 @@ watch(
   }
 )
 
+const questionModelVisible = ref(false)
+
+const openQuestionModel = () => {
+  questionModelVisible.value = true
+}
+
+// 卡片分类相关状态
+const categoryModalVisible = ref(false)
+const categoryLoading = ref(false)
+const categoryList = ref<API.Category[]>([])
+const selectedCategoryIds = ref<string[]>([])
+
+// 打开卡片分类设置 Modal
+const openCategoryModal = async () => {
+  categoryModalVisible.value = true
+  categoryLoading.value = true
+  try {
+    const { data } = await getCategoryById(cardInfo.id!)
+    // data 是一个 Category 数组
+    if (Array.isArray(data)) {
+      categoryList.value = data
+      // 筛选出已选择的分类ID
+      selectedCategoryIds.value = data
+        .filter(item => item.choose)
+        .map(item => item.id)
+    }
+  } catch (error) {
+    message.error('获取分类信息失败')
+  } finally {
+    categoryLoading.value = false
+  }
+}
+
+// 保存卡片分类设置
+const saveCategorySettings = async () => {
+  categoryLoading.value = true
+  try {
+    await setCategoryById(cardInfo.id!, selectedCategoryIds.value)
+    message.success('保存成功')
+    categoryModalVisible.value = false
+  } catch (error) {
+    message.error('保存失败')
+  } finally {
+    categoryLoading.value = false
+  }
+}
+
 const cardTemplateDom = ref()
 
 const cardTemplateDom21 = ref()

+ 1 - 1
src/pages/card/preview.vue

@@ -16,7 +16,7 @@
           :bordered="false"
           hoverable
           style="width: 100%;height: 100%;"
-          :title="`${card.name}    (${card.page})`"
+          :title="`${card.name}  (${card.page})`"
         >
           <div  class="card-bg" >
              <img style="width: 80%;Object-fit: contain;display: block;" :src="card.thumbnail" alt="example" />

+ 16 - 4
src/typeing.d.ts

@@ -46,6 +46,13 @@ declare namespace API {
     card_type: 5 | 21
   }
 
+  interface Feedback {
+    'id'?: string,
+    cardId: string
+    'page': string,
+    feedback: string
+  }
+
   interface Audio {
     id?: string,
     title: string,
@@ -256,12 +263,17 @@ declare namespace API {
                   'err': string,
                   'eb': number
               },
-              'ok_key': ({ 'value': number} & BaseItem & CardDot)[] // 正确的键位key
+              'ok_key': ({ 'value': number } & BaseItem & CardDot)[] // 正确的键位key
               'err_key': ({ 'value': number} & BaseItem & CardDot)[], // 错误的键位key
-              'ok_key_voice': ({ 'value': number} & BaseItem & CardDot) // 正确的声音
-              'err_key_voice': ({ 'value': number} & BaseItem & CardDot) // 点击错误的声音
-
             }[]
+            'ok_key_voice': ({ 'value': number | string} & BaseItem & CardDot)[] // 正确的声音
+            'err_key_voice': ({ 'value': number | string} & BaseItem & CardDot)[] // 点击错误的声音
       }[]
   }
+
+  interface Category {
+    id: string
+    title: string
+    choose: boolean
+  }
 }