Browse Source

组件背景图

dsy 4 days ago
parent
commit
b6ea23f1b8
54 changed files with 1466 additions and 131 deletions
  1. 12 0
      .github/hooks/builder-win-version.json
  2. 226 0
      .github/hooks/scripts/update-env-version-on-builder-win.js
  3. 1 0
      package.json
  4. 27 0
      src/api/book.js
  5. 17 0
      src/components/CommonPreview.vue
  6. 976 0
      src/views/book/courseware/create/components/SetComponentBackground.vue
  7. 0 2
      src/views/book/courseware/create/components/base/audio/AudioSetting.vue
  8. 0 2
      src/views/book/courseware/create/components/base/rich_text/RichTextSetting.vue
  9. 0 2
      src/views/book/courseware/create/components/base/video/VideoSetting.vue
  10. 0 2
      src/views/book/courseware/create/components/common/SettingMixin.js
  11. 4 4
      src/views/book/courseware/create/components/question/article/ArticleSetting.vue
  12. 2 2
      src/views/book/courseware/create/components/question/character/CharacterSetting.vue
  13. 1 1
      src/views/book/courseware/create/components/question/character_structure/CharacterStructureSetting.vue
  14. 4 4
      src/views/book/courseware/create/components/question/dialogue_article/ArticleSetting.vue
  15. 0 1
      src/views/book/courseware/create/components/question/drawing/DrawingSetting.vue
  16. 1 1
      src/views/book/courseware/create/components/question/fill/FillSetting.vue
  17. 0 1
      src/views/book/courseware/create/components/question/image_text/ImageTextSetting.vue
  18. 0 1
      src/views/book/courseware/create/components/question/judge/JudgeSetting.vue
  19. 0 2
      src/views/book/courseware/create/components/question/matching/MatchingSetting.vue
  20. 1 1
      src/views/book/courseware/create/components/question/newWord_template/NewWordTemplateSetting.vue
  21. 1 1
      src/views/book/courseware/create/components/question/new_word/NewWordSetting.vue
  22. 1 1
      src/views/book/courseware/create/components/question/notes/NotesSetting.vue
  23. 1 1
      src/views/book/courseware/create/components/question/pinyin_base/PinyinBaseSetting.vue
  24. 1 1
      src/views/book/courseware/create/components/question/record_input/RecordInputSetting.vue
  25. 0 1
      src/views/book/courseware/create/components/question/select/SelectSetting.vue
  26. 1 1
      src/views/book/courseware/create/components/question/sort/SortSetting.vue
  27. 0 1
      src/views/book/courseware/create/components/question/table/TableSetting.vue
  28. 1 1
      src/views/book/courseware/create/components/question/video_interaction/VideoInteractionSetting.vue
  29. 1 1
      src/views/book/courseware/create/components/question/voice_matrix/VoiceMatrixSetting.vue
  30. 0 3
      src/views/book/courseware/data/article.js
  31. 0 2
      src/views/book/courseware/data/audio.js
  32. 0 3
      src/views/book/courseware/data/character.js
  33. 0 3
      src/views/book/courseware/data/characterStructure.js
  34. 1 3
      src/views/book/courseware/data/dialogueArticle.js
  35. 0 2
      src/views/book/courseware/data/drawing.js
  36. 0 2
      src/views/book/courseware/data/fill.js
  37. 0 3
      src/views/book/courseware/data/imageText.js
  38. 0 2
      src/views/book/courseware/data/judge.js
  39. 0 2
      src/views/book/courseware/data/matching.js
  40. 0 2
      src/views/book/courseware/data/newWord.js
  41. 0 3
      src/views/book/courseware/data/newWordTemplate.js
  42. 0 2
      src/views/book/courseware/data/notes.js
  43. 0 3
      src/views/book/courseware/data/pinyinBase.js
  44. 0 2
      src/views/book/courseware/data/recordInput.js
  45. 0 2
      src/views/book/courseware/data/richText.js
  46. 0 2
      src/views/book/courseware/data/select.js
  47. 0 2
      src/views/book/courseware/data/sort.js
  48. 0 3
      src/views/book/courseware/data/table.js
  49. 0 2
      src/views/book/courseware/data/video.js
  50. 1 3
      src/views/book/courseware/data/videoInteraction.js
  51. 0 2
      src/views/book/courseware/data/voiceMatrix.js
  52. 32 4
      src/views/book/courseware/preview/CoursewarePreview.vue
  53. 65 31
      src/views/book/courseware/preview/components/common/PreviewMixin.js
  54. 88 8
      src/views/personal_workbench/edit_task/preview/index.vue

+ 12 - 0
.github/hooks/builder-win-version.json

@@ -0,0 +1,12 @@
+{
+  "hooks": {
+    "PreToolUse": [
+      {
+        "type": "command",
+        "command": "node .github/hooks/scripts/update-env-version-on-builder-win.js",
+        "cwd": ".",
+        "timeout": 15
+      }
+    ]
+  }
+}

+ 226 - 0
.github/hooks/scripts/update-env-version-on-builder-win.js

@@ -0,0 +1,226 @@
+const fs = require('fs');
+const path = require('path');
+
+// PreToolUse hook 的输入结构,只关心本脚本实际会用到的字段。
+/**
+ * @typedef {{
+ *   tool_name?: string,
+ *   tool_input?: {
+ *     command?: string,
+ *     args?: string[],
+ *     task?: {
+ *       command?: string,
+ *       args?: string[]
+ *     }
+ *   },
+ *   cwd?: string
+ * }} HookPayload
+ */
+
+// 读取 hook 通过 stdin 传入的 JSON 负载。
+function readStdin() {
+  return new Promise((resolve, reject) => {
+    let buffer = '';
+    process.stdin.setEncoding('utf8');
+    process.stdin.on('data', (chunk) => {
+      buffer += chunk;
+    });
+    process.stdin.on('end', () => resolve(buffer));
+    process.stdin.on('error', reject);
+  });
+}
+
+// 通过命令行参数区分“普通脚本直跑”与“hook 回调”两种模式。
+function isDirectMode() {
+  return process.argv.includes('--direct');
+}
+
+/**
+ * 从不同工具的输入结构里提取实际命令,统一做字符串匹配。
+ * @param {HookPayload['tool_input']} toolInput
+ */
+function getToolCommand(toolInput) {
+  if (!toolInput || typeof toolInput !== 'object') {
+    return '';
+  }
+
+  if (typeof toolInput.command === 'string') {
+    const args = Array.isArray(toolInput.args) ? toolInput.args.join(' ') : '';
+    return [toolInput.command, args].filter(Boolean).join(' ');
+  }
+
+  if (toolInput.task && typeof toolInput.task.command === 'string') {
+    const args = Array.isArray(toolInput.task.args) ? toolInput.task.args.join(' ') : '';
+    return [toolInput.task.command, args].filter(Boolean).join(' ');
+  }
+
+  return '';
+}
+
+/**
+ * 只在即将执行 npm run builder:win 时触发版本更新。
+ * @param {HookPayload} payload
+ */
+function shouldHandle(payload) {
+  const toolName = payload.tool_name;
+  const command = getToolCommand(payload.tool_input);
+
+  if (toolName === 'run_in_terminal') {
+    return command.includes('npm run builder:win');
+  }
+
+  if (toolName === 'create_and_run_task') {
+    return command.includes('npm') && command.includes('builder:win');
+  }
+
+  return false;
+}
+
+/**
+ * 将当前日期格式化为 VUE_APP_VERSION 使用的 YYYY.MM.DD 形式。
+ * @param {Date} date
+ */
+function formatDateForVersion(date) {
+  const year = date.getFullYear();
+  const month = String(date.getMonth() + 1).padStart(2, '0');
+  const day = String(date.getDate()).padStart(2, '0');
+  return `${year}.${month}.${day}`;
+}
+
+/**
+ * 更新根目录 .env 中的 VUE_APP_VERSION;缺失文件或变量时返回失败。
+ * @param {string} workspaceRoot
+ * @param {string} version
+ */
+function updateEnvVersion(workspaceRoot, version) {
+  const envPath = path.join(workspaceRoot, '.env');
+
+  if (!fs.existsSync(envPath)) {
+    return {
+      ok: false,
+      reason: '.env 文件不存在,已阻止 npm run builder:win。',
+    };
+  }
+
+  const original = fs.readFileSync(envPath, 'utf8');
+  const eol = original.includes('\r\n') ? '\r\n' : '\n';
+  const lines = original.split(/\r?\n/);
+  let found = false;
+
+  // 保留原有缩进和等号前后的空白,只替换版本值本身。
+  const updatedLines = lines.map((line) => {
+    if (!/^\s*VUE_APP_VERSION\s*=/.test(line)) {
+      return line;
+    }
+
+    found = true;
+    const prefixMatch = line.match(/^(\s*VUE_APP_VERSION\s*=\s*)/);
+    const prefix = prefixMatch ? prefixMatch[1] : 'VUE_APP_VERSION = ';
+    return `${prefix}'${version}'`;
+  });
+
+  if (!found) {
+    return {
+      ok: false,
+      reason: '.env 中未找到 VUE_APP_VERSION,已阻止 npm run builder:win。',
+    };
+  }
+
+  const updated = updatedLines.join(eol);
+  if (updated !== original) {
+    fs.writeFileSync(envPath, updated, 'utf8');
+  }
+
+  return {
+    ok: true,
+    envPath,
+    version,
+  };
+}
+
+/**
+ * hook 放行时的统一输出;只有命中目标命令时才附带提示信息。
+ * @param {string=} message
+ */
+function allowWithMessage(message) {
+  /** @type {{ hookSpecificOutput: { hookEventName: string, permissionDecision: 'allow', additionalContext?: string }, systemMessage?: string }} */
+  const output = {
+    hookSpecificOutput: {
+      hookEventName: 'PreToolUse',
+      permissionDecision: 'allow',
+    },
+  };
+
+  if (message) {
+    output.hookSpecificOutput.additionalContext = message;
+    output.systemMessage = message;
+  }
+
+  process.stdout.write(JSON.stringify(output));
+}
+
+/**
+ * hook 拒绝执行时的统一输出。
+ * @param {string} reason
+ */
+function denyWithReason(reason) {
+  process.stdout.write(
+    JSON.stringify({
+      hookSpecificOutput: {
+        hookEventName: 'PreToolUse',
+        permissionDecision: 'deny',
+        permissionDecisionReason: reason,
+      },
+      systemMessage: reason,
+    }),
+  );
+}
+
+/**
+ * 直接运行模式:不给 hook 返回 JSON,只输出普通日志并用退出码表示成功或失败。
+ * @param {string} workspaceRoot
+ */
+function runDirectMode(workspaceRoot) {
+  const version = formatDateForVersion(new Date());
+  const result = updateEnvVersion(workspaceRoot, version);
+
+  if (!result.ok) {
+    process.stderr.write(`${result.reason || '更新 .env 失败。'}\n`);
+    process.exitCode = 1;
+    return;
+  }
+
+  process.stdout.write(`已在 .env 中将 VUE_APP_VERSION 更新为 ${version}。\n`);
+}
+
+async function main() {
+  // npm script 直接调用时,不依赖 stdin 里的 hook 负载。
+  if (isDirectMode()) {
+    runDirectMode(process.cwd());
+    return;
+  }
+
+  // hook 模式:读取 VS Code 传入的事件负载,再判断是否要拦截 builder:win。
+  const rawInput = await readStdin();
+  const payload = rawInput ? JSON.parse(rawInput) : {};
+
+  if (!shouldHandle(payload)) {
+    allowWithMessage();
+    return;
+  }
+
+  const workspaceRoot = payload.cwd || process.cwd();
+  const version = formatDateForVersion(new Date());
+  const result = updateEnvVersion(workspaceRoot, version);
+
+  if (!result.ok) {
+    denyWithReason(result.reason || '更新 .env 失败,已阻止 npm run builder:win。');
+    return;
+  }
+
+  allowWithMessage(`已在 .env 中将 VUE_APP_VERSION 更新为 ${version},继续执行 npm run builder:win。`);
+}
+
+main().catch((error) => {
+  denyWithReason(`更新 .env 失败,已阻止 npm run builder:win。原因: ${error.message}`);
+});

+ 1 - 0
package.json

@@ -14,6 +14,7 @@
     "lint:style:fix": "stylelint \"src/**/*.{css,scss,vue}\" --fix",
     "lint:fix": "eslint \"src/**/*.{js,vue}\" --fix",
     "postinstall": "patch-package",
+    "prebuilder:win": "node .github/hooks/scripts/update-env-version-on-builder-win.js --direct",
     "builder": "electron-builder",
     "builder:win": "npm run build && npm run builder",
     "builder:beiyu": "npm run build:beiyu && npm run builder",

+ 27 - 0
src/api/book.js

@@ -513,3 +513,30 @@ export function SubmitChapterAllCoursewareToAuditFlow(data) {
     data,
   );
 }
+
+/**
+ * @description 得到课件组件背景设置
+ * @param {object} data
+ * @param {string} data.courseware_id - 互动组件ID
+ * @param {string} data.component_id - 组件在课件内部的 ID
+ */
+export function GetCoursewareComponentBackground(data) {
+  return http.post(
+    `${process.env.VUE_APP_EepServer}?MethodName=book_content_manager-GetCoursewareComponentBackground`,
+    data,
+  );
+}
+
+/**
+ * @description 保存课件组件背景设置
+ * @param {object} data
+ * @param {string} data.courseware_id - 互动组件ID
+ * @param {string} data.component_id - 组件在课件内部的 ID
+ * @param {string} data.background - 背景样式
+ */
+export function SaveCoursewareComponentBackground(data) {
+  return http.post(
+    `${process.env.VUE_APP_EepServer}?MethodName=book_content_manager-SaveCoursewareComponentBackground`,
+    data,
+  );
+}

+ 17 - 0
src/components/CommonPreview.vue

@@ -139,6 +139,7 @@
             @editNote="handEditNote"
             @saveCollect="saveCollect"
             @getTranslate="getTranslate"
+            @selectedComponent="$emit('selectedComponent', $event)"
           />
           <!-- <div
               v-if="isSelecting"
@@ -1649,6 +1650,22 @@ export default {
         }
       }
     },
+    /**
+     * 更新组件背景信息
+     * @param {Object} param - 参数对象
+     * @param {string} param.courseware_id - 课件ID
+     * @param {string} param.component_id - 组件ID
+     * @param {object} background - 背景信息对象
+     */
+    updateComponentBackground({ courseware_id, component_id }, background) {
+      const component = this.component_list.find(
+        (c) => c.courseware_id === courseware_id && c.component_id === component_id,
+      );
+
+      if (component) {
+        this.$set(component, 'background', background);
+      }
+    },
   },
 };
 </script>

+ 976 - 0
src/views/book/courseware/create/components/SetComponentBackground.vue

@@ -0,0 +1,976 @@
+<template>
+  <el-dialog
+    custom-class="component-background"
+    :width="dialogWidth"
+    :close-on-click-modal="false"
+    :visible="visible"
+    :before-close="handleClose"
+  >
+    <div class="background-title">组件背景设置</div>
+    <div class="set-background">
+      <div
+        ref="backgroundImg"
+        class="background-img"
+        :style="{ width: `${maxWidth}px`, height: `${maxHeight}px` }"
+        :class="{ 'is-crop-mode': cropMode }"
+        @mousedown="onBackgroundMouseDown"
+      >
+        <div v-if="file_url" class="img-set" :style="{ top: imgTop, left: imgLeft, position: 'relative' }">
+          <div class="dot top-left" @mousedown="dragStart($event, 'nwse-resize', 'top-left')"></div>
+          <div class="horizontal-line" @mousedown="dragStart($event, 'ns-resize', 'top')"></div>
+          <div class="dot top-right" @mousedown="dragStart($event, 'nesw-resize', 'top-right')"></div>
+          <div class="vertical-line" @mousedown="dragStart($event, 'ew-resize', 'left')"></div>
+          <img
+            ref="img"
+            :src="file_url"
+            draggable="false"
+            :style="{ width: imgWidth, height: imgHeight }"
+            @load="handleImageLoad"
+            @mousedown="dragStart($event, 'move', 'move')"
+          />
+          <div class="vertical-line" @mousedown="dragStart($event, 'ew-resize', 'right')"></div>
+          <div class="dot bottom-left" @mousedown="dragStart($event, 'nesw-resize', 'bottom-left')"></div>
+          <div class="horizontal-line" @mousedown="dragStart($event, 'ns-resize', 'bottom')"></div>
+          <div class="dot bottom-right" @mousedown="dragStart($event, 'nwse-resize', 'bottom-right')"></div>
+        </div>
+        <div v-if="file_url && cropMode" class="crop-tip">在图片上拖拽框选裁切区域</div>
+        <div v-if="file_url && cropMode && hasCropSelection" class="crop-selection" :style="cropSelectionStyle"></div>
+      </div>
+
+      <div class="setup">
+        <div class="setup-item">
+          <div class="setup-top">
+            <el-radio v-model="background.mode" label="image">图片</el-radio>
+            <SvgIcon v-if="file_url" icon-class="delete" style="cursor: pointer" size="14" @click="file_url = ''" />
+          </div>
+          <div class="setup-content">
+            <div class="image-set">
+              <el-upload
+                ref="upload"
+                class="file-uploader"
+                action="no"
+                accept="image/*"
+                :show-file-list="false"
+                :limit="1"
+                :http-request="uploadFile"
+              >
+                <span class="select-button">选择图片</span>
+              </el-upload>
+              <div>
+                <span class="opacity-icon"></span>
+                <el-input v-model="background.image_opacity" size="mini" :min="0" :max="100" style="width: 55px" />%
+              </div>
+            </div>
+            <div class="mode-list">
+              <span
+                v-for="mode in imageModeList"
+                :key="mode.value"
+                :class="{ active: mode.value === background.imageMode }"
+                :style="{ fontSize: mode.value === imageModeList[3].value ? '12px' : '' }"
+                @click="background.imageMode = mode.value"
+              >
+                {{ mode.label }}
+              </span>
+            </div>
+            <div v-if="file_url" class="crop-actions">
+              <el-button size="mini" plain @click="startRectCrop">裁切</el-button>
+              <el-button v-if="cropMode" size="mini" @click="cancelRectCrop">取消</el-button>
+              <el-button v-if="cropMode" size="mini" type="primary" @click="applyRectCrop">应用</el-button>
+            </div>
+          </div>
+        </div>
+        <div class="setup-item">
+          <div class="setup-top">
+            <el-radio v-model="background.mode" label="color">颜色</el-radio>
+          </div>
+          <div class="setup-content">
+            <el-color-picker v-model="background.color" show-alpha />
+          </div>
+        </div>
+        <div class="setup-item">
+          <div class="setup-top">
+            <el-checkbox v-model="background.enable_border" label="边框" />
+          </div>
+          <div class="setup-content">
+            <el-color-picker v-model="background.border_color" show-alpha />
+            <div>
+              <el-input v-model="background.border_width" style="width: 70px; margin-right: 10px">
+                <i slot="prefix" class="el-icon-s-fold" style="line-height: 32px"></i>
+              </el-input>
+              <el-select v-model="background.border_style" placeholder="边框样式" style="width: 120px">
+                <el-option label="实线" value="solid" />
+                <el-option label="虚线" value="dashed" />
+                <el-option label="点线" value="dotted" />
+              </el-select>
+            </div>
+          </div>
+        </div>
+        <div class="setup-item">
+          <div class="setup-top">
+            <el-checkbox v-model="background.enable_radius" label="圆角" />
+          </div>
+          <div class="setup-content border-radius">
+            <div class="radius-item">
+              <span class="span-radius" :style="computedBorderRadius('top', 'left')"></span>
+              <el-input v-model="background.top_left_radius" :min="0" type="number" />
+            </div>
+            <div class="radius-item">
+              <span class="span-radius" :style="computedBorderRadius('top', 'right')"></span>
+              <el-input v-model="background.top_right_radius" :min="0" type="number" />
+            </div>
+            <div class="radius-item">
+              <span class="span-radius" :style="computedBorderRadius('bottom', 'left')"></span>
+              <el-input v-model="background.bottom_left_radius" :min="0" type="number" />
+            </div>
+            <div class="radius-item">
+              <span class="span-radius" :style="computedBorderRadius('bottom', 'right')"></span>
+              <el-input v-model="background.bottom_right_radius" :min="0" type="number" />
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <div slot="footer">
+      <el-button @click="handleClose">取 消</el-button>
+      <el-button type="primary" @click="confirm">确 定</el-button>
+    </div>
+  </el-dialog>
+</template>
+
+<script>
+import { fileUpload } from '@/api/app';
+
+export default {
+  name: 'SetBackground',
+  props: {
+    visible: {
+      type: Boolean,
+      required: true,
+    },
+    url: {
+      type: String,
+      default: '',
+    },
+    position: {
+      type: Object,
+      default: () => ({ width: 0, height: 0, top: 0, left: 0 }),
+    },
+    backgroundData: {
+      type: Object,
+      default: () => ({}),
+    },
+    componentData: {
+      type: Object,
+      default: () => ({ courseware_id: '', component_id: '' }),
+    },
+  },
+  data() {
+    return {
+      maxWidth: 500,
+      maxHeight: 450,
+
+      imgData: {
+        width: 0,
+        height: 0,
+        top: 0,
+        left: 0,
+      },
+      drag: {
+        dragging: false,
+        startX: 0,
+        startY: 0,
+        type: '',
+      },
+      cropMode: false,
+      crop: {
+        drawing: false,
+        startX: 0,
+        startY: 0,
+        x: 0,
+        y: 0,
+        width: 0,
+        height: 0,
+      },
+      imageDisplay: {
+        top: 0,
+        left: 0,
+        width: 0,
+        height: 0,
+      },
+      file_url: '', // 背景图片地址
+      background: {
+        mode: 'image', // 背景模式
+        imageMode: 'auto', // 图片背景模式
+        image_opacity: 100, // 背景图片透明度
+        color: 'rgba(255, 255, 255, 1)', // 背景颜色
+        enable_border: false, // 是否启用边框
+        border_color: 'rgba(0, 0, 0, 1)', // 边框颜色
+        border_width: 1, // 边框宽度
+        border_style: 'solid', // 边框样式
+        enable_radius: false, // 是否启用圆角
+        top_left_radius: 4, // 左上圆角
+        top_right_radius: 4, // 右上圆角
+        bottom_left_radius: 4, // 左下圆角
+        bottom_right_radius: 4, // 右下圆角
+      },
+      imageModeList: [
+        { label: '适应', value: 'adapt' },
+        { label: '拉伸', value: 'stretch' },
+        { label: '平铺', value: 'fill' },
+        { label: '自定义', value: 'auto' },
+      ],
+    };
+  },
+  computed: {
+    imgTop() {
+      return `${this.imgData.top - 9}px`;
+    },
+    imgLeft() {
+      return `${this.imgData.left}px`;
+    },
+    imgWidth() {
+      return `${this.imgData.width}px`;
+    },
+    imgHeight() {
+      return `${this.imgData.height}px`;
+    },
+    hasCropSelection() {
+      return this.crop.width > 1 && this.crop.height > 1;
+    },
+    cropSelectionStyle() {
+      return {
+        top: `${this.imageDisplay.top + this.crop.y}px`,
+        left: `${this.imageDisplay.left + this.crop.x}px`,
+        width: `${this.crop.width}px`,
+        height: `${this.crop.height}px`,
+      };
+    },
+    dialogWidth() {
+      return `${this.maxWidth + 250}px`;
+    },
+  },
+  watch: {
+    visible(newVal) {
+      if (!newVal) {
+        this.revertData();
+      }
+
+      const component = document.querySelector(`div.${this.componentData.component_id}`);
+      if (component) {
+        const rect = component.getBoundingClientRect();
+        const componentRatio = rect.width / rect.height;
+        const imgRatio = this.maxWidth / this.maxHeight;
+        if (componentRatio > imgRatio) {
+          this.maxHeight = this.maxWidth / componentRatio;
+        } else {
+          this.maxWidth = this.maxHeight * componentRatio;
+        }
+      }
+
+      this.file_url = this.url;
+      this.imgData = {
+        width: (this.position.width * this.maxWidth) / 100,
+        height: (this.position.height * this.maxHeight) / 100,
+        top: (this.position.top * this.maxHeight) / 100,
+        left: (this.position.left * this.maxWidth) / 100,
+      };
+      if (this.backgroundData && Object.keys(this.backgroundData).length) {
+        this.background = { ...this.backgroundData };
+      }
+      this.cropMode = false;
+      this.resetCropRect();
+
+      this.$nextTick(() => {
+        document.querySelector('.background-img').addEventListener('mousemove', this.mouseMove);
+        this.syncImageDisplayRect();
+      });
+    },
+  },
+  mounted() {
+    document.body.addEventListener('mouseup', this.mouseUp);
+  },
+  beforeDestroy() {
+    document.querySelector('.background-img')?.removeEventListener('mousemove', this.mouseMove);
+    document.body.removeEventListener('mouseup', this.mouseUp);
+  },
+  methods: {
+    handleClose() {
+      this.$emit('update:visible', false);
+    },
+    /**
+     * 还原数据
+     */
+    revertData() {
+      document.querySelector('.background-img')?.removeEventListener('mousemove', this.mouseMove);
+      this.cropMode = false;
+      this.resetCropRect();
+      this.maxWidth = 500;
+      this.maxHeight = 450;
+      this.imgData = {
+        width: 0,
+        height: 0,
+        top: 0,
+        left: 0,
+      };
+      this.crop = {
+        drawing: false,
+        startX: 0,
+        startY: 0,
+        x: 0,
+        y: 0,
+        width: 0,
+        height: 0,
+      };
+      this.imageDisplay = {
+        top: 0,
+        left: 0,
+        width: 0,
+        height: 0,
+      };
+      this.file_url = ''; // 背景图片地址
+      this.background = {
+        mode: 'image', // 背景模式
+        imageMode: 'auto', // 图片背景模式
+        image_opacity: 100, // 背景图片透明度
+        color: 'rgba(255, 255, 255, 1)', // 背景颜色
+        enable_border: false, // 是否启用边框
+        border_color: 'rgba(0, 0, 0, 1)', // 边框颜色
+        border_width: 1, // 边框宽度
+        border_style: 'solid', // 边框样式
+        enable_radius: false, // 是否启用圆角
+        top_left_radius: 4, // 左上圆角
+        top_right_radius: 4, // 右上圆角
+        bottom_left_radius: 4, // 左下圆角
+        bottom_right_radius: 4, // 右下圆角
+      };
+    },
+    /**
+     * 拖拽开始
+     * @param {MouseEvent} event
+     * @param {string} cursor
+     * @param {string} type
+     */
+    dragStart(event, cursor, type) {
+      if (this.cropMode) return;
+      const { clientX, clientY } = event;
+      this.drag = {
+        dragging: true,
+        startX: clientX,
+        startY: clientY,
+        type,
+      };
+
+      document.querySelector('.el-dialog__wrapper').style.cursor = cursor;
+    },
+    /**
+     * 鼠标移动
+     * @param {MouseEvent} event
+     */
+    mouseMove(event) {
+      if (this.cropMode) {
+        if (!this.crop.drawing) return;
+        const point = this.getPointInImage(event);
+        if (!point) return;
+
+        const x = Math.min(this.crop.startX, point.x);
+        const y = Math.min(this.crop.startY, point.y);
+        const width = Math.abs(point.x - this.crop.startX);
+        const height = Math.abs(point.y - this.crop.startY);
+        this.crop = {
+          ...this.crop,
+          x,
+          y,
+          width,
+          height,
+        };
+        return;
+      }
+
+      if (!this.drag.dragging) return;
+      const { clientX, clientY } = event;
+      const { startX, startY, type } = this.drag;
+
+      const widthDiff = clientX - startX;
+      const heightDiff = clientY - startY;
+
+      if (type === 'top-left') {
+        this.imgData.width = Math.min(this.maxWidth, Math.max(0, this.imgData.width - widthDiff));
+        this.imgData.height = Math.min(this.maxHeight, Math.max(0, this.imgData.height - heightDiff));
+        this.imgData.top = Math.min(this.maxHeight - this.imgData.height, Math.max(0, this.imgData.top + heightDiff));
+        this.imgData.left = Math.min(this.maxWidth - this.imgData.width, Math.max(0, this.imgData.left + widthDiff));
+      } else if (type === 'top-right') {
+        this.imgData.width = Math.min(this.maxWidth, this.imgData.width + widthDiff);
+        this.imgData.height = Math.min(this.maxHeight, Math.max(0, this.imgData.height - heightDiff));
+        this.imgData.top = Math.min(this.maxHeight - this.imgData.height, Math.max(0, this.imgData.top + heightDiff));
+        this.imgData.left = Math.min(this.maxWidth - this.imgData.width, Math.max(0, this.imgData.left));
+      } else if (type === 'bottom-left') {
+        this.imgData.width = Math.min(this.maxWidth, Math.max(0, this.imgData.width - widthDiff));
+        this.imgData.height = Math.min(this.maxHeight, this.imgData.height + heightDiff);
+        this.imgData.top = Math.min(this.maxHeight - this.imgData.height, Math.max(0, this.imgData.top));
+        this.imgData.left = Math.min(this.maxWidth - this.imgData.width, Math.max(0, this.imgData.left + widthDiff));
+      } else if (type === 'bottom-right') {
+        this.imgData.width = Math.min(this.maxWidth, this.imgData.width + widthDiff);
+        this.imgData.height = Math.min(this.maxHeight, this.imgData.height + heightDiff);
+        this.imgData.top = Math.min(this.maxHeight - this.imgData.height, Math.max(0, this.imgData.top));
+        this.imgData.left = Math.min(this.maxWidth - this.imgData.width, Math.max(0, this.imgData.left));
+      }
+
+      if (type === 'top') {
+        this.imgData.height = Math.min(this.maxHeight, Math.max(0, this.imgData.height - heightDiff));
+        this.imgData.top = Math.min(this.maxHeight - this.imgData.height, Math.max(0, this.imgData.top + heightDiff));
+      } else if (type === 'bottom') {
+        this.imgData.height = Math.min(this.maxHeight, this.imgData.height + heightDiff);
+        this.imgData.top = Math.min(this.maxHeight - this.imgData.height, Math.max(0, this.imgData.top));
+      } else if (type === 'left') {
+        this.imgData.width = Math.min(this.maxWidth, this.imgData.width - widthDiff);
+        this.imgData.left = Math.min(this.maxWidth - this.imgData.width, Math.max(0, this.imgData.left + widthDiff));
+      } else if (type === 'right') {
+        this.imgData.width = Math.min(this.maxWidth, this.imgData.width + widthDiff);
+        this.imgData.left = Math.min(this.maxWidth - this.imgData.width, Math.max(0, this.imgData.left));
+      }
+
+      if (type === 'move') {
+        this.imgData.top = Math.min(this.maxHeight - this.imgData.height, Math.max(0, this.imgData.top + heightDiff));
+        this.imgData.left = Math.min(this.maxWidth - this.imgData.width, Math.max(0, this.imgData.left + widthDiff));
+      }
+
+      this.drag.startX = clientX;
+      this.drag.startY = clientY;
+      this.syncImageDisplayRect();
+    },
+    /**
+     * 鼠标抬起
+     */
+    mouseUp() {
+      this.crop.drawing = false;
+      this.drag.dragging = false;
+      document.querySelector('.el-dialog__wrapper').style.cursor = 'auto';
+    },
+    onBackgroundMouseDown(event) {
+      if (!this.cropMode || !this.file_url) return;
+      const point = this.getPointInImage(event);
+      if (!point) return;
+
+      this.crop = {
+        ...this.crop,
+        drawing: true,
+        startX: point.x,
+        startY: point.y,
+        x: point.x,
+        y: point.y,
+        width: 0,
+        height: 0,
+      };
+      event.preventDefault();
+    },
+    getPointInImage(event) {
+      const bg = this.$refs.backgroundImg;
+      if (!bg || this.imageDisplay.width <= 0 || this.imageDisplay.height <= 0) return null;
+
+      const bgRect = bg.getBoundingClientRect();
+      const rawX = event.clientX - bgRect.left - this.imageDisplay.left;
+      const rawY = event.clientY - bgRect.top - this.imageDisplay.top;
+
+      if (rawX < 0 || rawY < 0 || rawX > this.imageDisplay.width || rawY > this.imageDisplay.height) {
+        return null;
+      }
+
+      return {
+        x: Math.min(this.imageDisplay.width, Math.max(0, rawX)),
+        y: Math.min(this.imageDisplay.height, Math.max(0, rawY)),
+      };
+    },
+    /**
+     * 同步图片显示区域位置和尺寸信息
+     */
+    syncImageDisplayRect() {
+      this.$nextTick(() => {
+        const bg = this.$refs.backgroundImg;
+        const img = this.$refs.img;
+        if (!bg || !img) {
+          this.imageDisplay = {
+            top: 0,
+            left: 0,
+            width: 0,
+            height: 0,
+          };
+          return;
+        }
+        const bgRect = bg.getBoundingClientRect();
+        const imgRect = img.getBoundingClientRect();
+        this.imageDisplay = {
+          top: imgRect.top - bgRect.top,
+          left: imgRect.left - bgRect.left,
+          width: imgRect.width,
+          height: imgRect.height,
+        };
+      });
+    },
+    resetCropRect() {
+      this.crop = {
+        drawing: false,
+        startX: 0,
+        startY: 0,
+        x: 0,
+        y: 0,
+        width: 0,
+        height: 0,
+      };
+    },
+    startRectCrop() {
+      if (!this.file_url) return;
+      this.cropMode = true;
+      this.syncImageDisplayRect();
+      this.$nextTick(() => {
+        const { width, height } = this.imageDisplay;
+        if (!width || !height) return;
+        this.crop = {
+          drawing: false,
+          startX: 0,
+          startY: 0,
+          x: width * 0.25,
+          y: height * 0.25,
+          width: width * 0.5,
+          height: height * 0.5,
+        };
+      });
+    },
+    cancelRectCrop() {
+      this.cropMode = false;
+      this.resetCropRect();
+    },
+    async applyRectCrop() {
+      if (!this.hasCropSelection) {
+        this.$message.warning('请先框选裁切区域');
+        return;
+      }
+
+      if (!this.file_url || !this.imageDisplay.width || !this.imageDisplay.height) return;
+
+      let sourceImg = null;
+      let revokeObjectURL = null;
+
+      try {
+        const cropSource = await this.getCropSourceImage();
+        sourceImg = cropSource.img;
+        revokeObjectURL = cropSource.revokeObjectURL;
+      } catch (error) {
+        this.$message.error('当前图片不支持裁切,请检查图片服务跨域配置');
+        return;
+      }
+
+      const naturalWidth = sourceImg.naturalWidth;
+      const naturalHeight = sourceImg.naturalHeight;
+      const displayWidth = this.imageDisplay.width;
+      const displayHeight = this.imageDisplay.height;
+
+      const sx = Math.max(0, Math.round((this.crop.x / displayWidth) * naturalWidth));
+      const sy = Math.max(0, Math.round((this.crop.y / displayHeight) * naturalHeight));
+      const sw = Math.max(1, Math.round((this.crop.width / displayWidth) * naturalWidth));
+      const sh = Math.max(1, Math.round((this.crop.height / displayHeight) * naturalHeight));
+      const safeWidth = Math.max(1, Math.min(sw, naturalWidth - sx));
+      const safeHeight = Math.max(1, Math.min(sh, naturalHeight - sy));
+
+      try {
+        const canvas = document.createElement('canvas');
+        canvas.width = safeWidth;
+        canvas.height = safeHeight;
+        const ctx = canvas.getContext('2d');
+        if (!ctx) throw new Error('canvas context unavailable');
+        ctx.drawImage(sourceImg, sx, sy, safeWidth, safeHeight, 0, 0, safeWidth, safeHeight);
+
+        const blob = await new Promise((resolve, reject) => {
+          canvas.toBlob((result) => {
+            if (result) {
+              resolve(result);
+              return;
+            }
+            reject(new Error('toBlob failed'));
+          }, 'image/png');
+        });
+
+        const cropFile = new File([blob], `background-crop-${Date.now()}.png`, { type: 'image/png' });
+        const uploadPayload = {
+          filename: 'file',
+          file: cropFile,
+        };
+
+        const { file_info_list } = await fileUpload('Mid', uploadPayload, { isGlobalprogress: true });
+        if (!file_info_list || !file_info_list.length) {
+          throw new Error('upload failed');
+        }
+
+        this.file_url = file_info_list[0].file_url;
+        this.normalComputed(safeWidth, safeHeight);
+        this.cancelRectCrop();
+        this.$message.success('裁切成功');
+      } catch (error) {
+        this.$message.error('裁切失败');
+        console.error('Rect crop error:', error);
+      } finally {
+        revokeObjectURL?.();
+      }
+    },
+    async getCropSourceImage() {
+      try {
+        const img = await this.loadImage(this.file_url, 'anonymous');
+        return {
+          img,
+          revokeObjectURL: null,
+        };
+      } catch (crossOriginError) {
+        const blob = await this.fetchImageBlob(this.file_url);
+        const objectUrl = URL.createObjectURL(blob);
+
+        try {
+          const img = await this.loadImage(objectUrl);
+          return {
+            img,
+            revokeObjectURL: () => URL.revokeObjectURL(objectUrl),
+          };
+        } catch (loadBlobError) {
+          URL.revokeObjectURL(objectUrl);
+          throw loadBlobError;
+        }
+      }
+    },
+    loadImage(src, crossOrigin = '') {
+      return new Promise((resolve, reject) => {
+        const img = new Image();
+        if (crossOrigin) {
+          img.crossOrigin = crossOrigin;
+        }
+        img.onload = () => resolve(img);
+        img.onerror = () => reject(new Error('image load failed'));
+        img.src = src;
+      });
+    },
+    async fetchImageBlob(url) {
+      const response = await fetch(url, {
+        credentials: 'include',
+      });
+
+      if (!response.ok) {
+        throw new Error('fetch image blob failed');
+      }
+
+      return response.blob();
+    },
+    handleImageLoad() {
+      this.syncImageDisplayRect();
+    },
+    /**
+     * 计算圆角样式
+     * @param {string} position 位置,top/bottom
+     * @param {string} direction 方向,left/right
+     * @returns {Object} 圆角样式对象
+     */
+    computedBorderRadius(position, direction) {
+      const radius = this.background[`${position}_${direction}_radius`];
+      let borderWidth = {};
+      if (position === 'top') {
+        borderWidth['border-bottom-width'] = 0;
+      } else {
+        borderWidth['border-top-width'] = 0;
+      }
+      if (direction === 'left') {
+        borderWidth['border-right-width'] = 0;
+      } else {
+        borderWidth['border-left-width'] = 0;
+      }
+      return {
+        [`border-${position}-${direction}-radius`]: `${radius}px`,
+        ...borderWidth,
+      };
+    },
+    uploadFile(file) {
+      fileUpload('Mid', file, { isGlobalprogress: true })
+        .then(({ file_info_list }) => {
+          this.$refs.upload.clearFiles();
+          if (file_info_list.length > 0) {
+            const fileUrl = file_info_list[0].file_url_open;
+            const img = new Image();
+            img.src = fileUrl;
+            img.onload = () => {
+              const { width, height } = img;
+              this.normalComputed(width, height);
+              this.file_url = fileUrl;
+            };
+          }
+        })
+        .catch(() => {
+          this.$message.error('上传失败');
+        });
+    },
+    /**
+     * 正常填充
+     * @param {number} width 图片宽度
+     * @param {number} height 图片高度
+     */
+    normalComputed(width, height) {
+      if (width > this.maxWidth || height > this.maxHeight) {
+        const wScale = width / this.maxWidth;
+        const hScale = height / this.maxHeight;
+        const scale = wScale > hScale ? this.maxWidth / 2 / width : this.maxHeight / 2 / height;
+
+        this.imgData = {
+          width: width * scale,
+          height: height * scale,
+          top: 0,
+          left: 0,
+        };
+      } else {
+        this.imgData = {
+          width,
+          height,
+          top: 0,
+          left: 0,
+        };
+      }
+    },
+    confirm() {
+      this.$emit(
+        'setComponentBackground',
+        this.file_url,
+        {
+          width: (this.imgData.width / this.maxWidth) * 100,
+          height: (this.imgData.height / this.maxHeight) * 100,
+          top: (this.imgData.top / this.maxHeight) * 100,
+          left: (this.imgData.left / this.maxWidth) * 100,
+          imgX: (this.imgData.left / (this.maxWidth - this.imgData.width)) * 100,
+          imgY: (this.imgData.top / (this.maxHeight - this.imgData.height)) * 100,
+        },
+        this.background,
+      );
+      this.handleClose();
+    },
+  },
+};
+</script>
+
+<style lang="scss" scoped>
+.background-title {
+  margin-bottom: 12px;
+  font-size: 16px;
+  font-weight: 500;
+  color: $font-light-color;
+}
+
+.set-background {
+  display: flex;
+  column-gap: 8px;
+
+  .background-img {
+    position: relative;
+    border: 1px dashed rgba(0, 0, 0, 8%);
+
+    &.is-crop-mode {
+      cursor: crosshair;
+    }
+
+    .crop-tip {
+      position: absolute;
+      top: 8px;
+      left: 8px;
+      z-index: 3;
+      padding: 2px 8px;
+      font-size: 12px;
+      color: #fff;
+      pointer-events: none;
+      background: rgba(0, 0, 0, 45%);
+      border-radius: 10px;
+    }
+
+    .crop-selection {
+      position: absolute;
+      z-index: 2;
+      pointer-events: none;
+      border: 1px dashed #fff;
+      box-shadow: 0 0 0 9999px rgba(0, 0, 0, 35%);
+    }
+
+    .img-set {
+      display: inline-grid;
+      grid-template:
+        ' . . . ' 2px
+        ' . img . ' auto
+        ' . . . ' 2px
+        / 2px auto 2px;
+
+      img {
+        object-fit: cover;
+      }
+
+      .horizontal-line,
+      .vertical-line {
+        background-color: $main-color;
+      }
+
+      .horizontal-line {
+        width: 100%;
+        height: 2px;
+        cursor: ns-resize;
+      }
+
+      .vertical-line {
+        width: 2px;
+        height: 100%;
+        cursor: ew-resize;
+      }
+
+      .dot {
+        z-index: 1;
+        width: 6px;
+        height: 6px;
+        background-color: $main-color;
+
+        &.top-left {
+          top: -2px;
+          left: -2px;
+        }
+
+        &.top-right {
+          top: -2px;
+          right: 2px;
+        }
+
+        &.bottom-left {
+          bottom: 2px;
+          left: -2px;
+        }
+
+        &.bottom-right {
+          right: 2px;
+          bottom: 2px;
+        }
+
+        &.top-left,
+        &.bottom-right {
+          position: relative;
+          cursor: nwse-resize;
+        }
+
+        &.top-right,
+        &.bottom-left {
+          position: relative;
+          cursor: nesw-resize;
+        }
+      }
+    }
+  }
+
+  .setup {
+    display: flex;
+    flex-direction: column;
+    row-gap: 12px;
+    width: 200px;
+
+    &-item {
+      display: flex;
+      flex-direction: column;
+      column-gap: 8px;
+
+      .setup-top {
+        margin-bottom: 6px;
+      }
+
+      .setup-content {
+        display: flex;
+        flex-direction: column;
+        row-gap: 8px;
+
+        .image-set {
+          display: flex;
+          align-items: center;
+          justify-content: space-around;
+          height: 32px;
+          color: $font-light-color;
+          background-color: #f2f3f5;
+          border: 1px solid #d9d9d9;
+          border-radius: 4px;
+
+          .opacity-icon {
+            display: inline-block;
+            width: 20px;
+            height: 20px;
+            vertical-align: middle;
+            background: url('@/assets/icon/opacity.png');
+          }
+
+          .select-button {
+            color: #165dff;
+            cursor: pointer;
+            background-color: #fff;
+            box-shadow: 0 0 0 3px #fff;
+          }
+        }
+
+        .mode-list {
+          display: flex;
+          padding: 2px 3px;
+          background-color: #f2f3f5;
+          border: 1px solid #d9d9d9;
+          border-radius: 4px;
+
+          span {
+            flex: 1;
+            padding: 2px 6px;
+            line-height: 21px;
+            text-align: center;
+            cursor: pointer;
+
+            &.active {
+              color: $main-color;
+              background-color: #fff;
+            }
+          }
+        }
+
+        .crop-actions {
+          z-index: 3001;
+          display: flex;
+        }
+
+        &.border-radius {
+          display: grid;
+          grid-template-rows: 1fr 1fr;
+          grid-template-columns: 1fr 1fr;
+          gap: 8px 8px;
+
+          .radius-item {
+            display: flex;
+            flex: 1;
+            column-gap: 4px;
+            align-items: center;
+            height: 32px;
+            padding: 2px 6px;
+            background-color: #f2f3f5;
+
+            .span-radius {
+              display: inline-block;
+              width: 16px;
+              height: 16px;
+              border-color: #000;
+              border-style: solid;
+              border-width: 2px;
+            }
+          }
+        }
+      }
+    }
+  }
+}
+</style>
+
+<style lang="scss">
+.el-dialog.component-background {
+  .el-dialog__header {
+    display: none;
+  }
+
+  .el-dialog__body {
+    padding: 8px 16px;
+  }
+}
+</style>

+ 0 - 2
src/views/book/courseware/create/components/base/audio/AudioSetting.vue

@@ -3,8 +3,6 @@
     <el-form :model="property" :label-position="labelPosition" label-width="90px">
       <SerialNumber :property="property" />
 
-      <BackgroundSet :property="property" />
-
       <el-form-item v-if="false" label="文件名称">
         <el-radio
           v-for="{ value, label } in displayList"

+ 0 - 2
src/views/book/courseware/create/components/base/rich_text/RichTextSetting.vue

@@ -3,8 +3,6 @@
     <el-form :model="property" :label-position="labelPosition" label-width="72px">
       <SerialNumber :property="property" />
 
-      <BackgroundSet :property="property" />
-
       <el-form-item label="是否标题">
         <el-switch v-model="property.is_title" active-value="true" inactive-value="false" />
       </el-form-item>

+ 0 - 2
src/views/book/courseware/create/components/base/video/VideoSetting.vue

@@ -3,8 +3,6 @@
     <el-form :model="property" :label-position="labelPosition" label-width="72px">
       <SerialNumber :property="property" />
 
-      <BackgroundSet :property="property" />
-
       <el-form-item label="查看方式">
         <el-radio v-for="{ value, label } in viewMethodList" :key="value" v-model="property.view_method" :label="value">
           {{ label }}

+ 0 - 2
src/views/book/courseware/create/components/common/SettingMixin.js

@@ -1,7 +1,6 @@
 import { checkString, isEnable, pinyinPositionList } from '@/views/book/courseware/data/common';
 
 import SerialNumber from '@/views/book/courseware/create/components/common/SerialNumber.vue';
-import BackgroundSet from '@/views/book/courseware/create/components/common/BackgroundSet.vue';
 import AddAnswer from '@/views/book/courseware/create/components/common/AddAnswer.vue';
 
 const mixin = {
@@ -15,7 +14,6 @@ const mixin = {
   },
   components: {
     SerialNumber,
-    BackgroundSet,
     AddAnswer,
   },
   watch: {

+ 4 - 4
src/views/book/courseware/create/components/question/article/ArticleSetting.vue

@@ -2,7 +2,7 @@
   <div>
     <el-form :model="property" label-width="72px" label-position="left">
       <SerialNumber :property="property" />
-      <BackgroundSet :property="property" />
+
       <el-form-item label="音频位置">
         <el-radio-group v-model="property.mp3_position">
           <el-radio v-for="{ value, label } in positionList" :key="value" :label="value" :value="value">
@@ -122,6 +122,9 @@ export default {
       speedRatioList,
     };
   },
+  created() {
+    this.getTextToAudioConfParamList();
+  },
   methods: {
     // 得到文本转音频的配置参数列表
     getTextToAudioConfParamList() {
@@ -135,9 +138,6 @@ export default {
         .catch(() => {});
     },
   },
-  created() {
-    this.getTextToAudioConfParamList();
-  },
 };
 </script>
 

+ 2 - 2
src/views/book/courseware/create/components/question/character/CharacterSetting.vue

@@ -2,7 +2,7 @@
   <div>
     <el-form :model="property" label-width="72px" label-position="left">
       <SerialNumber :property="property" />
-      <BackgroundSet :property="property" />
+
       <el-form-item label="汉字框">
         <el-radio-group v-model="property.frame_type">
           <el-radio v-for="{ value, label } in frameList" :key="value" :label="value" :value="value">
@@ -72,7 +72,7 @@
         </el-radio-group>
       </el-form-item>
 
-      <el-form-item label="读音" v-if="property.is_enable_voice === 'true'">
+      <el-form-item v-if="property.is_enable_voice === 'true'" label="读音">
         <el-select v-model="property.audio_generation_method" placeholder="请选择">
           <el-option v-for="{ value, label } in audioGenerationMethodList" :key="value" :label="label" :value="value" />
         </el-select>

+ 1 - 1
src/views/book/courseware/create/components/question/character_structure/CharacterStructureSetting.vue

@@ -2,7 +2,7 @@
   <div>
     <el-form :model="property" label-width="72px" label-position="left">
       <SerialNumber :property="property" />
-      <BackgroundSet :property="property" />
+
       <el-form-item label="框大小">
         <el-radio-group v-model="property.frame_size">
           <el-radio v-for="{ value, label } in frameSizeList" :key="value" :label="value">

+ 4 - 4
src/views/book/courseware/create/components/question/dialogue_article/ArticleSetting.vue

@@ -2,7 +2,7 @@
   <div>
     <el-form :model="property" label-width="72px" label-position="left">
       <SerialNumber :property="property" />
-      <BackgroundSet :property="property" />
+
       <el-form-item label="音频位置">
         <el-radio-group v-model="property.mp3_position">
           <el-radio v-for="{ value, label } in positionList" :key="value" :label="value" :value="value">
@@ -163,7 +163,7 @@
         <el-input v-model="property.notice_size"><template slot="append">px</template></el-input>
       </el-form-item>
       <el-form-item label="语境颜色">
-        <el-color-picker v-model="property.notice_color"></el-color-picker>
+        <el-color-picker v-model="property.notice_color" />
       </el-form-item>
     </el-form>
   </div>
@@ -276,7 +276,7 @@ export default {
     // 自动生成拼音
     handlePinyin(text) {
       let data = {
-        text: text,
+        text,
         is_rich_text: 'false',
         is_first_sentence_first_hz_pinyin_first_char_upper_case: 'false',
         is_fill_space: 'false',
@@ -289,7 +289,7 @@ export default {
             res.parsed_text.paragraph_list.map((outerArr, i) =>
               outerArr.map((innerArr, j) =>
                 innerArr.map((newItem, k) => {
-                  mergedData += newItem.pinyin + ' ';
+                  mergedData += `${newItem.pinyin} `;
                 }),
               ),
             );

+ 0 - 1
src/views/book/courseware/create/components/question/drawing/DrawingSetting.vue

@@ -2,7 +2,6 @@
   <div>
     <el-form :model="property" label-width="72px" label-position="left">
       <SerialNumber :property="property" />
-      <BackgroundSet :property="property" />
     </el-form>
   </div>
 </template>

+ 1 - 1
src/views/book/courseware/create/components/question/fill/FillSetting.vue

@@ -2,7 +2,7 @@
   <div>
     <el-form :model="property" label-width="72px" label-position="left">
       <SerialNumber :property="property" />
-      <BackgroundSet :property="property" />
+
       <el-form-item label="排列">
         <el-radio-group v-model="property.arrange_type">
           <el-radio v-for="{ value, label } in arrangeTypeList" :key="value" :label="value">

+ 0 - 1
src/views/book/courseware/create/components/question/image_text/ImageTextSetting.vue

@@ -2,7 +2,6 @@
   <div>
     <el-form :model="property" label-width="72px" label-position="left">
       <SerialNumber :property="property" />
-      <BackgroundSet :property="property" />
 
       <el-form-item label="拼音">
         <el-switch v-model="property.view_pinyin" active-value="true" inactive-value="false" />

+ 0 - 1
src/views/book/courseware/create/components/question/judge/JudgeSetting.vue

@@ -2,7 +2,6 @@
   <div>
     <el-form :model="property" label-width="72px" label-position="left">
       <SerialNumber :property="property" />
-      <BackgroundSet :property="property" />
 
       <el-form-item label="启用选项序号" label-width="100px">
         <el-switch v-model="property.enable_serial" active-value="true" inactive-value="false" />

+ 0 - 2
src/views/book/courseware/create/components/question/matching/MatchingSetting.vue

@@ -3,8 +3,6 @@
     <el-form :model="property" label-width="42px">
       <SerialNumber :property="property" />
 
-      <BackgroundSet :property="property" />
-
       <el-form-item class="option-serial-number" label-width="0">
         <div>选项序号</div>
         <div class="option-serial-number-list">

+ 1 - 1
src/views/book/courseware/create/components/question/newWord_template/NewWordTemplateSetting.vue

@@ -2,7 +2,7 @@
   <div>
     <el-form :model="property" label-width="72px" label-position="left">
       <SerialNumber :property="property" />
-      <BackgroundSet :property="property" />
+
       <el-form-item label="框大小">
         <el-radio-group v-model="property.frame_size">
           <el-radio v-for="{ value, label } in frameSizeList" :key="value" :label="value">

+ 1 - 1
src/views/book/courseware/create/components/question/new_word/NewWordSetting.vue

@@ -2,7 +2,7 @@
   <div>
     <el-form :model="property" label-width="72px" label-position="left">
       <SerialNumber :property="property" />
-      <BackgroundSet :property="property" />
+
       <el-form-item label="详细信息">
         <el-radio-group v-model="property.is_has_infor">
           <el-radio v-for="{ value, label } in inforList" :key="value" :label="value">

+ 1 - 1
src/views/book/courseware/create/components/question/notes/NotesSetting.vue

@@ -2,7 +2,7 @@
   <div>
     <el-form :model="property" label-width="72px" label-position="left">
       <SerialNumber :property="property" />
-      <BackgroundSet :property="property" />
+
       <!-- <el-form-item label="预览展开">
         <el-radio-group v-model="property.is_word_show">
           <el-radio v-for="{ value, label } in wordShowList" :key="value" :label="value">

+ 1 - 1
src/views/book/courseware/create/components/question/pinyin_base/PinyinBaseSetting.vue

@@ -2,7 +2,7 @@
   <div>
     <el-form :model="property" label-width="72px" label-position="left">
       <SerialNumber :property="property" />
-      <BackgroundSet :property="property" />
+
       <!-- <el-form-item label="功能">
         <el-select v-model="property.fun_type" placeholder="请选择">
           <el-option v-for="{ value, label } in funList" :key="value" :label="label" :value="value" />

+ 1 - 1
src/views/book/courseware/create/components/question/record_input/RecordInputSetting.vue

@@ -2,7 +2,7 @@
   <div>
     <el-form :model="property" label-width="72px" label-position="left">
       <SerialNumber :property="property" />
-      <BackgroundSet :property="property" />
+
       <AddAnswer />
     </el-form>
   </div>

+ 0 - 1
src/views/book/courseware/create/components/question/select/SelectSetting.vue

@@ -2,7 +2,6 @@
   <div>
     <el-form :model="property" label-width="72px">
       <SerialNumber :property="property" />
-      <BackgroundSet :property="property" />
 
       <el-form-item label="启用选项序号" label-width="100px">
         <el-switch v-model="property.enable_serial" active-value="true" inactive-value="false" />

+ 1 - 1
src/views/book/courseware/create/components/question/sort/SortSetting.vue

@@ -2,7 +2,7 @@
   <div>
     <el-form :model="property" label-width="72px">
       <SerialNumber :property="property" />
-      <BackgroundSet :property="property" />
+
       <el-form-item label="选项数">
         <el-input-number v-model="property.option_count" :min="1" />
       </el-form-item>

+ 0 - 1
src/views/book/courseware/create/components/question/table/TableSetting.vue

@@ -2,7 +2,6 @@
   <div>
     <el-form :model="property" label-width="72px">
       <SerialNumber :property="property" />
-      <BackgroundSet :property="property" />
 
       <el-form-item label="宽度">
         <el-input-number v-model="property.width" :min="1" :step="50" />

+ 1 - 1
src/views/book/courseware/create/components/question/video_interaction/VideoInteractionSetting.vue

@@ -2,7 +2,7 @@
   <div>
     <el-form :model="property" label-width="72px" label-position="left">
       <SerialNumber :property="property" />
-      <BackgroundSet :property="property" />
+
       <el-form-item label="反馈方式">
         <el-radio-group v-model="property.feed_back">
           <el-radio v-for="{ value, label } in feedBackList" :key="value" :label="value" :value="value">

+ 1 - 1
src/views/book/courseware/create/components/question/voice_matrix/VoiceMatrixSetting.vue

@@ -2,7 +2,7 @@
   <div>
     <el-form :model="property" label-width="72px">
       <SerialNumber :property="property" />
-      <BackgroundSet :property="property" />
+
       <el-form-item label="行数">
         <el-input-number v-model="property.row_count" :min="1" />
       </el-form-item>

+ 0 - 3
src/views/book/courseware/data/article.js

@@ -6,7 +6,6 @@ import {
   switchOption,
   isEnable,
   pinyinPositionList,
-  commonComponentProperty,
   serialNumberStyleList,
 } from '@/views/book/courseware/data/common';
 
@@ -85,8 +84,6 @@ export function getArticleProperty() {
     voice_type: '', // 音色
     emotion: '', // 风格,情感
     speed_ratio: '', // 语速
-
-    ...commonComponentProperty,
   };
 }
 

+ 0 - 2
src/views/book/courseware/data/audio.js

@@ -3,7 +3,6 @@ import {
   serialNumberStyleList,
   serialNumberPositionList,
   displayList,
-  commonComponentProperty,
 } from '@/views/book/courseware/data/common';
 
 // 音频查看方式
@@ -28,7 +27,6 @@ export function getAudioProperty() {
     view_method: audioViewMethodList[0].value,
     file_name_display_mode: displayList[1].value,
     style_mode: audioViewStyleList[0].value,
-    ...commonComponentProperty,
   };
 }
 

+ 0 - 3
src/views/book/courseware/data/character.js

@@ -5,7 +5,6 @@ import {
   arrangeTypeList,
   switchOption,
   isEnable,
-  commonComponentProperty,
   serialNumberStyleList,
 } from '@/views/book/courseware/data/common';
 import { getRandomNumber } from '@/utils';
@@ -68,7 +67,6 @@ export const frameSizeList = [
   },
 ];
 
-
 // 读音生成方式
 export const audioGenerationMethodList = [
   {
@@ -126,7 +124,6 @@ export function getCharacterProperty() {
     voice_type: '', // 音色
     emotion: '', // 风格,情感
     speed_ratio: '', // 语速
-    ...commonComponentProperty,
     is_enable_voice_answer: switchOption[1].value,
     audio_generation_method: audioGenerationMethodList[0].value,
     frame_size: frameSizeList[0].value,

+ 0 - 3
src/views/book/courseware/data/characterStructure.js

@@ -5,7 +5,6 @@ import {
   arrangeTypeList,
   switchOption,
   isEnable,
-  commonComponentProperty,
   serialNumberStyleList,
 } from '@/views/book/courseware/data/common';
 import { getRandomNumber } from '@/utils';
@@ -141,8 +140,6 @@ export function getCharacterStructureProperty() {
 
     view_pinyin: showList[0].value,
     frame_size: frameSizeList[0].value,
-
-    ...commonComponentProperty,
   };
 }
 

+ 1 - 3
src/views/book/courseware/data/dialogueArticle.js

@@ -6,7 +6,6 @@ import {
   switchOption,
   isEnable,
   pinyinPositionList,
-  commonComponentProperty,
   serialNumberStyleList,
 } from '@/views/book/courseware/data/common';
 import { getRandomNumber } from '@/utils';
@@ -125,10 +124,9 @@ export function getArticleProperty() {
     voice_type: '', // 音色
     emotion: '', // 风格,情感
     speed_ratio: '', // 语速
-    ...commonComponentProperty,
     notice_size: '14',
     notice_color: '#000',
-    noitce_family:''
+    noitce_family: '',
   };
 }
 

+ 0 - 2
src/views/book/courseware/data/drawing.js

@@ -3,7 +3,6 @@ import {
   serialNumberTypeList,
   serialNumberPositionList,
   isEnable,
-  commonComponentProperty,
   serialNumberStyleList,
 } from '@/views/book/courseware/data/common';
 
@@ -17,7 +16,6 @@ export function getDrawingProperty() {
     sn_style: serialNumberStyleList[0].value,
     sn_background_color: '#ea3232', // 序号背景色
     sn_display_mode: displayList[1].value,
-    ...commonComponentProperty,
   };
 }
 

+ 0 - 2
src/views/book/courseware/data/fill.js

@@ -5,7 +5,6 @@ import {
   arrangeTypeList,
   switchOption,
   pinyinPositionList,
-  commonComponentProperty,
   serialNumberStyleList,
 } from '@/views/book/courseware/data/common';
 
@@ -69,7 +68,6 @@ export function getFillProperty() {
     emotion: '', // 风格,情感
     speed_ratio: '', // 语速
     input_default_width: 80, // 输入框默认宽度
-    ...commonComponentProperty,
   };
 }
 

+ 0 - 3
src/views/book/courseware/data/imageText.js

@@ -4,7 +4,6 @@ import {
   serialNumberPositionList,
   isEnable,
   pinyinPositionList,
-  commonComponentProperty,
   serialNumberStyleList,
 } from '@/views/book/courseware/data/common';
 
@@ -22,8 +21,6 @@ export function getImageTextProperty() {
     view_pinyin: 'true', // 显示拼音
     pinyin_position: pinyinPositionList[0].value, // top bottom
     is_first_sentence_first_hz_pinyin_first_char_upper_case: 'true', // 句首大写
-
-    ...commonComponentProperty,
   };
 }
 

+ 0 - 2
src/views/book/courseware/data/judge.js

@@ -5,7 +5,6 @@ import {
   switchOption,
   isEnable,
   pinyinPositionList,
-  commonComponentProperty,
   serialNumberStyleList,
 } from '@/views/book/courseware/data/common';
 import { getRandomNumber } from '@/utils';
@@ -34,7 +33,6 @@ export function getJudgeProperty() {
     view_pinyin: 'false', // 显示拼音
     pinyin_position: pinyinPositionList[0].value,
     is_first_sentence_first_hz_pinyin_first_char_upper_case: displayList[0].value, // 句首大写
-    ...commonComponentProperty,
   };
 }
 

+ 0 - 2
src/views/book/courseware/data/matching.js

@@ -3,7 +3,6 @@ import {
   displayList,
   serialNumberPositionList,
   pinyinPositionList,
-  commonComponentProperty,
   serialNumberStyleList,
 } from '@/views/book/courseware/data/common';
 import { getRandomNumber } from '@/utils';
@@ -30,7 +29,6 @@ export function getMatchingProperty(column_num = 2) {
     view_pinyin: 'false', // 显示拼音
     pinyin_position: pinyinPositionList[0].value,
     is_first_sentence_first_hz_pinyin_first_char_upper_case: displayList[0].value, // 句首大写
-    ...commonComponentProperty,
   };
 }
 

+ 0 - 2
src/views/book/courseware/data/newWord.js

@@ -5,7 +5,6 @@ import {
   arrangeTypeList,
   switchOption,
   isEnable,
-  commonComponentProperty,
   serialNumberStyleList,
 } from '@/views/book/courseware/data/common';
 
@@ -79,7 +78,6 @@ export function getNewWordProperty() {
     voice_type: '', // 音色
     emotion: '', // 风格,情感
     speed_ratio: '', // 语速
-    ...commonComponentProperty,
   };
 }
 

+ 0 - 3
src/views/book/courseware/data/newWordTemplate.js

@@ -5,7 +5,6 @@ import {
   arrangeTypeList,
   switchOption,
   isEnable,
-  commonComponentProperty,
   serialNumberStyleList,
 } from '@/views/book/courseware/data/common';
 import { getRandomNumber } from '@/utils';
@@ -117,8 +116,6 @@ export function getNewWordTemplateProperty() {
     // input模式
     is_enable_shiyi: showList[0].value,
     frame_size: frameSizeList[0].value,
-
-    ...commonComponentProperty,
   };
 }
 

+ 0 - 2
src/views/book/courseware/data/notes.js

@@ -5,7 +5,6 @@ import {
   arrangeTypeList,
   switchOption,
   isEnable,
-  commonComponentProperty,
   serialNumberStyleList,
 } from '@/views/book/courseware/data/common';
 
@@ -46,7 +45,6 @@ export function getNotesProperty() {
     view_pinyin: 'false', // 显示拼音
     pinyin_position: pinyinPositionLists[1].value, // top bottom
     is_first_sentence_first_hz_pinyin_first_char_upper_case: 'true', // 句首大写
-    ...commonComponentProperty,
   };
 }
 

+ 0 - 3
src/views/book/courseware/data/pinyinBase.js

@@ -4,7 +4,6 @@ import {
   serialNumberPositionList,
   arrangeTypeList,
   switchOption,
-  commonComponentProperty,
   serialNumberStyleList,
 } from '@/views/book/courseware/data/common';
 import { getRandomNumber } from '@/utils';
@@ -80,8 +79,6 @@ export function getPinyinBaseProperty() {
     voice_type: '', // 音色
     emotion: '', // 风格,情感
     speed_ratio: '', // 语速
-
-    ...commonComponentProperty,
   };
 }
 

+ 0 - 2
src/views/book/courseware/data/recordInput.js

@@ -3,7 +3,6 @@ import {
   serialNumberTypeList,
   serialNumberPositionList,
   switchOption,
-  commonComponentProperty,
   serialNumberStyleList,
 } from '@/views/book/courseware/data/common';
 
@@ -25,7 +24,6 @@ export function getRecordInputProperty() {
     sn_style: serialNumberStyleList[0].value,
     sn_background_color: '#ea3232', // 序号背景色
     sn_display_mode: displayList[1].value,
-    ...commonComponentProperty,
   };
 }
 

+ 0 - 2
src/views/book/courseware/data/richText.js

@@ -5,7 +5,6 @@ import {
   serialNumberPositionList,
   pinyinPositionList,
   pinyinOverallPositionList,
-  commonComponentProperty,
   audioGenerationMethodList,
 } from '@/views/book/courseware/data/common';
 
@@ -467,7 +466,6 @@ export function getRichTextProperty() {
     is_first_sentence_first_hz_pinyin_first_char_upper_case: 'true', // 句首大写
     title_style_level: '', // 标题样式级别
     is_title: 'false', // 是否标题
-    ...commonComponentProperty,
     is_enable_voice: 'false',
     voice_type: '', // 音色
     emotion: '', // 风格,情感

+ 0 - 2
src/views/book/courseware/data/select.js

@@ -4,7 +4,6 @@ import {
   serialNumberPositionList,
   arrangeTypeList,
   pinyinPositionList,
-  commonComponentProperty,
   serialNumberStyleList,
 } from '@/views/book/courseware/data/common';
 import { getRandomNumber } from '@/utils';
@@ -34,7 +33,6 @@ export function getSelectProperty() {
     view_pinyin: 'false', // 显示拼音
     pinyin_position: pinyinPositionList[0].value,
     is_first_sentence_first_hz_pinyin_first_char_upper_case: displayList[0].value, // 句首大写
-    ...commonComponentProperty,
   };
 }
 

+ 0 - 2
src/views/book/courseware/data/sort.js

@@ -4,7 +4,6 @@ import {
   serialNumberPositionList,
   displayList,
   pinyinPositionList,
-  commonComponentProperty,
   serialNumberStyleList,
 } from '@/views/book/courseware/data/common';
 import { getRandomNumber } from '@/utils';
@@ -44,7 +43,6 @@ export function getSortProperty() {
     view_pinyin: 'false', // 显示拼音
     pinyin_position: pinyinPositionList[0].value,
     is_first_sentence_first_hz_pinyin_first_char_upper_case: displayList[0].value, // 句首大写
-    ...commonComponentProperty,
   };
 }
 

+ 0 - 3
src/views/book/courseware/data/table.js

@@ -4,7 +4,6 @@ import {
   serialNumberPositionList,
   switchOption,
   pinyinPositionList,
-  commonComponentProperty,
   serialNumberStyleList,
 } from '@/views/book/courseware/data/common';
 import { getRandomNumber } from '@/utils';
@@ -55,8 +54,6 @@ export function getTableProperty() {
     view_pinyin: 'false', // 显示拼音
     pinyin_position: pinyinPositionList[0].value, // top bottom
     is_first_sentence_first_hz_pinyin_first_char_upper_case: 'true', // 句首大写
-
-    ...commonComponentProperty,
   };
 }
 

+ 0 - 2
src/views/book/courseware/data/video.js

@@ -4,7 +4,6 @@ import {
   serialNumberTypeList,
   serialNumberStyleList,
   serialNumberPositionList,
-  commonComponentProperty,
 } from '@/views/book/courseware/data/common';
 
 export function getVideoProperty() {
@@ -16,7 +15,6 @@ export function getVideoProperty() {
     sn_style: serialNumberStyleList[0].value,
     sn_background_color: '#ea3232', // 序号背景色
     view_method: viewMethodList[0].value, // 查看方式:independent独立 list列表
-    ...commonComponentProperty,
   };
 }
 

+ 1 - 3
src/views/book/courseware/data/videoInteraction.js

@@ -3,7 +3,6 @@ import {
   serialNumberTypeList,
   serialNumberPositionList,
   isEnable,
-  commonComponentProperty,
   serialNumberStyleList,
 } from '@/views/book/courseware/data/common';
 
@@ -28,8 +27,7 @@ export function getVideoInteractionProperty() {
     sn_style: serialNumberStyleList[0].value,
     sn_background_color: '#ea3232', // 序号背景色
     sn_display_mode: displayList[1].value,
-    ...commonComponentProperty,
-    feed_back: feedBackList[0].value
+    feed_back: feedBackList[0].value,
   };
 }
 

+ 0 - 2
src/views/book/courseware/data/voiceMatrix.js

@@ -3,7 +3,6 @@ import {
   serialNumberTypeList,
   serialNumberPositionList,
   switchOption,
-  commonComponentProperty,
   serialNumberStyleList,
 } from '@/views/book/courseware/data/common';
 import { getRandomNumber } from '@/utils';
@@ -30,7 +29,6 @@ export function getVoiceMatrixProperty() {
     is_enable_record: switchOption[0].value, // 是否开启录音
     row_count: 2,
     column_count: 5,
-    ...commonComponentProperty,
   };
 }
 

+ 32 - 4
src/views/book/courseware/preview/CoursewarePreview.vue

@@ -21,12 +21,14 @@
                 :key="k"
                 ref="preview"
                 :content="computedColContent(grid.id)"
-                :class="[grid.id]"
+                :background="computedColBackground(grid.id)"
+                :class="[grid.id, { active: curSelectId === grid.id }]"
                 :data-id="grid.id"
                 :style="{
                   gridArea: grid.grid_area,
                   height: grid.height,
                 }"
+                @click.native="selectedComponent(grid.id)"
                 @contextmenu.native.prevent="handleContextMenu($event, grid.id)"
                 @handleHeightChange="handleHeightChange"
               />
@@ -81,7 +83,9 @@
         <span class="line"></span>
         <span class="button" @click="setCollect"><SvgIcon icon-class="sidebar-collect" size="14" /> 收藏 </span>
         <span class="line"></span>
-        <span class="button" @click="setTranslate"><img style="width: 14px; height: 14px;" :src="require('@/assets/icon/sidebar-translate.png')" /> 翻译 </span>
+        <span class="button" @click="setTranslate">
+          <img style="width: 14px; height: 14px" :src="require('@/assets/icon/sidebar-translate.png')" /> 翻译
+        </span>
       </template>
     </div>
   </div>
@@ -168,6 +172,7 @@ export default {
       },
       selectedInfo: null,
       selectHandleInfo: null,
+      curSelectId: '', // 当前选中组件id
     };
   },
   watch: {
@@ -202,6 +207,16 @@ export default {
   },
   methods: {
     /**
+     * 处理选中组件事件
+     * @param {string} component_id 组件在课件内部的 ID
+     */
+    selectedComponent(component_id) {
+      this.curSelectId = component_id;
+      const selectedComponent = this.componentList.find((item) => item.component_id === component_id);
+
+      this.$emit('selectedComponent', { courseware_id: selectedComponent?.courseware_id, component_id });
+    },
+    /**
      * 计算组件内容
      * @param {string} id 组件id
      * @returns {string} 组件内容
@@ -210,6 +225,16 @@ export default {
       if (!id) return '';
       return this.componentList.find((item) => item.component_id === id)?.content || '';
     },
+    /**
+     * 计算组件背景
+     * @param {string} id 组件id
+     * @returns {object} 组件背景样式
+     */
+    computedColBackground(id) {
+      if (!id) return {};
+      const background = this.componentList.find((item) => item.component_id === id)?.background;
+      return background ? JSON.parse(background) : {};
+    },
     getMultipleColStyle(i) {
       let row = this.data.row_list[i];
       let col = row.col_list;
@@ -716,7 +741,7 @@ export default {
       this.$emit('saveCollect', info);
       this.selectedInfo = null;
     },
-// 翻译
+    // 翻译
     setTranslate() {
       this.showToolbar = false;
 
@@ -992,7 +1017,10 @@ export default {
     .col {
       display: grid;
       gap: $component-spacing;
-      overflow: hidden;
+
+      .active {
+        box-shadow: 0 0 6px 1px $main-hover-color;
+      }
     }
 
     .row-checkbox {

+ 65 - 31
src/views/book/courseware/preview/components/common/PreviewMixin.js

@@ -40,6 +40,10 @@ const mixin = {
       type: String,
       default: '',
     },
+    background: {
+      type: Object,
+      default: () => ({}),
+    },
     coursewareId: {
       type: String,
       default: '',
@@ -183,45 +187,75 @@ const mixin = {
       };
     },
     /**
-     * 得到背景图、背景色及边框样式
+     * 计算组件背景样式
+     * @returns {Object} 组件背景样式对象
      */
     getComponentStyle() {
-      let backgroundData = {};
-      if (Object.hasOwn(this.data.property, 'background_image_url')) {
-        // 保护性读取位置/大小值,避免 undefined 导致字符串 "undefined%"
-        const { background_position: pos } = this.data.property;
-        const widthPct = typeof pos.width === 'undefined' ? '' : pos.width;
-        const heightPct = typeof pos.height === 'undefined' ? '' : pos.height;
-        const leftPct = typeof pos.left === 'undefined' ? '' : pos.left;
-        const topPct = typeof pos.top === 'undefined' ? '' : pos.top;
+      const {
+        background_image_url: bcImgUrl = '',
+        background_position: pos = {},
+        background: back,
+      } = this.background || {};
+
+      let canvasStyle = {
+        backgroundSize: bcImgUrl ? `${pos.width}% ${pos.height}%` : '',
+        backgroundPosition: bcImgUrl ? `${pos.left}% ${pos.top}%` : '',
+        backgroundImage: bcImgUrl ? `url(${bcImgUrl})` : '',
+        backgroundColor: bcImgUrl ? `rgba(255, 255, 255, ${1 - back?.image_opacity / 100})` : '',
+      };
 
-        backgroundData.backgroundImage = `url(${this.data.property.background_image_url})`;
-        let bcSize = '';
-        if (pos.image_mode === 'fill') {
-          bcSize = '100% 100%';
-        } else if (pos.image_mode === 'repeat') {
-          bcSize = 'auto';
+      if (back) {
+        if (back.mode === 'image') {
+          canvasStyle['backgroundBlendMode'] = 'lighten';
         } else {
-          bcSize = `${widthPct}% ${heightPct}%`;
+          canvasStyle['backgroundBlendMode'] = '';
+        }
+
+        if (back.imageMode === 'fill') {
+          canvasStyle['backgroundRepeat'] = 'repeat';
+          canvasStyle['backgroundSize'] = '';
+          canvasStyle['backgroundPosition'] = '';
+        } else {
+          canvasStyle['backgroundRepeat'] = 'no-repeat';
+        }
+
+        if (back.imageMode === 'stretch') {
+          canvasStyle['backgroundSize'] = '100% 100%';
+        }
+
+        if (back.imageMode === 'adapt') {
+          canvasStyle['backgroundSize'] = 'contain';
         }
-        backgroundData.backgroundSize = bcSize;
-        backgroundData.backgroundPosition = pos.image_mode === 'fill' ? '0% 0%' : `${leftPct}% ${topPct}%`;
-        backgroundData.backgroundRepeat = pos.image_mode === 'repeat' ? 'repeat' : 'no-repeat';
-      }
 
-      let borderData = {};
-      if (Object.hasOwn(this.data.property, 'is_border')) {
-        const isBorder = isEnable(this.data.property.is_border);
-        borderData.borderWidth = isBorder ? (this.data.property.border_style === 'double' ? '3px' : '1px') : '0px';
-        borderData.borderStyle = isBorder ? this.data.property.border_style : 'none';
-        borderData.borderColor = isBorder ? this.data.property.border_color : 'transparent';
+        if (back.imageMode === 'auto') {
+          canvasStyle['backgroundPosition'] = `${pos.imgX}% ${pos.imgY}%`;
+        }
+
+        if (back.mode === 'color') {
+          canvasStyle['backgroundColor'] = back.color;
+          canvasStyle['backgroundImage'] = '';
+          canvasStyle['backgroundRepeat'] = '';
+          canvasStyle['backgroundPosition'] = '';
+          canvasStyle['backgroundSize'] = '';
+        }
+
+        if (back.enable_border) {
+          canvasStyle['border'] = `${back.border_width}px ${back.border_style} ${back.border_color}`;
+        } else {
+          canvasStyle['border'] = 'none';
+        }
+
+        if (back.enable_radius) {
+          canvasStyle['border-top-left-radius'] = `${back.top_left_radius}px`;
+          canvasStyle['border-top-right-radius'] = `${back.top_right_radius}px`;
+          canvasStyle['border-bottom-left-radius'] = `${back.bottom_left_radius}px`;
+          canvasStyle['border-bottom-right-radius'] = `${back.bottom_right_radius}px`;
+        } else {
+          canvasStyle['border-radius'] = '0';
+        }
       }
 
-      return {
-        backgroundColor: this.data.property?.background_color,
-        ...backgroundData,
-        ...borderData,
-      };
+      return canvasStyle;
     },
     // 显示答案与解析页面
     showAnswerAnalysis() {

+ 88 - 8
src/views/personal_workbench/edit_task/preview/index.vue

@@ -8,6 +8,7 @@
       :project-id="project_id"
       type="edit_preview"
       @updateBackground="background = $event"
+      @selectedComponent="curComponentData = $event"
     >
       <template #operator="{ courseware }">
         <el-popover placement="bottom" width="155" trigger="click">
@@ -18,8 +19,11 @@
           <span slot="reference" class="link save-template">保存为个人模板</span>
         </el-popover>
         <span class="link"></span>
-        <span v-if="isTrue(courseware.is_can_start_edit)" class="link" @click="showSetBackground">背景设置</span>
-        <span v-if="isTrue(courseware.is_can_start_edit)" class="link" @click="editTask()">开始编辑</span>
+        <template v-if="isTrue(courseware.is_can_start_edit)">
+          <span class="link" @click="showSetBackground">背景设置</span>
+          <span class="link" @click="showSetComponentBackground">组件背景设置</span>
+          <span class="link" @click="editTask()">开始编辑</span>
+        </template>
         <span v-if="isTrue(courseware.is_can_submit_audit)" class="link" @click="submitCoursewareToAuditFlow()">
           提交审核
         </span>
@@ -40,6 +44,15 @@
       :background-data="background.background"
       @setBackground="setBackground"
     />
+
+    <SetComponentBackground
+      :visible.sync="visibleComponentBackground"
+      :url="componentBackground.background_image_url"
+      :position="componentBackground.background_position"
+      :background-data="componentBackground.background"
+      :component-data="curComponentData"
+      @setComponentBackground="saveCoursewareComponentBackground"
+    />
   </div>
 </template>
 
@@ -48,11 +61,17 @@ import MenuPage from '@/views/personal_workbench/common/menu.vue';
 import CommonPreview from '@/components/CommonPreview.vue';
 import CreateCoursewareAsTemplateVue from '@/views/personal_workbench/edit_task/preview/CreateCoursewareAsTemplate.vue';
 import SetBackground from '@/views/book/courseware/create/components/SetBackground.vue';
+import SetComponentBackground from '@/views/book/courseware/create/components/SetComponentBackground.vue';
 
 import { SubmitBookCoursewareToAuditFlow } from '@/api/project';
 import { isTrue } from '@/utils/validate';
 import { SaveCoursewareAsTemplatePersonal } from '@/api/template';
-import { SaveCoursewareBackground, GetCoursewareBackground } from '@/api/book';
+import {
+  SaveCoursewareBackground,
+  GetCoursewareBackground,
+  GetCoursewareComponentBackground,
+  SaveCoursewareComponentBackground,
+} from '@/api/book';
 
 export default {
   name: 'TaskPreviewPage',
@@ -61,6 +80,7 @@ export default {
     CommonPreview,
     CreateCoursewareAsTemplateVue,
     SetBackground,
+    SetComponentBackground,
   },
   data() {
     return {
@@ -76,6 +96,17 @@ export default {
         background: {},
       },
       visibleBackground: false,
+      visibleComponentBackground: false,
+      // 当前选中组件数据
+      curComponentData: {
+        courseware_id: '',
+        component_id: '',
+      },
+      componentBackground: {
+        background_image_url: '',
+        background_position: {},
+        background: {},
+      },
     };
   },
   created() {
@@ -151,14 +182,63 @@ export default {
      * 显示背景设置弹窗
      */
     showSetBackground() {
-      // const createData = this.$refs.create.$refs.createCanvas.data;
-
-      // this.data.background_image_url = createData.background_image_url;
-      // this.data.background_position = createData.background_position;
-      // this.data.background = createData.background;
       this.visibleBackground = true;
     },
     /**
+     * 显示组件背景设置弹窗
+     */
+    async showSetComponentBackground() {
+      if (this.curComponentData.component_id === '') {
+        this.$message.warning('请先选择一个组件');
+        return;
+      }
+      const loading = this.$loading('加载组件数据中...');
+      await GetCoursewareComponentBackground(this.curComponentData).then(({ background }) => {
+        if (background) {
+          this.componentBackground = JSON.parse(background);
+        } else {
+          this.revertComponentBackground();
+        }
+      });
+      loading.close();
+      this.visibleComponentBackground = true;
+    },
+    /**
+     * 设置组件背景
+     * @param {string} url 背景图片地址
+     * @param {string} position 背景图片位置
+     * @param {string} background
+     */
+    saveCoursewareComponentBackground(url, position, background) {
+      this.componentBackground = {
+        background_image_url: url,
+        background_position: position,
+        background,
+      };
+      const backgroundStr = JSON.stringify(this.componentBackground);
+      SaveCoursewareComponentBackground({
+        ...this.curComponentData,
+        background: backgroundStr,
+      })
+        .then(() => {
+          this.$message.success('组件背景设置成功');
+          this.$refs.preview.updateComponentBackground(this.curComponentData, backgroundStr);
+        })
+        .finally(() => {
+          this.revertComponentBackground();
+        });
+    },
+    /**
+     * 还原组件背景数据
+     */
+    revertComponentBackground() {
+      this.componentBackground = {
+        background_image_url: '',
+        background_position: {},
+        background: {},
+      };
+    },
+    /**
      * 设置背景
      * @param {string} url 背景图片地址
      * @param {string} position 背景图片位置