Browse Source

1、调整完播答题问题处理

yys 4 tuần trước cách đây
mục cha
commit
d1eb547e75

+ 1158 - 0
src/views/live/liveConfig/completionReward.vue

@@ -0,0 +1,1158 @@
+<template>
+  <div class="watch-reward-page" v-loading="loading">
+    <template v-if="liveType == 2">
+      <div class="page-banner">
+        <div class="banner-icon tone-green-banner"><i class="el-icon-circle-check"></i></div>
+        <div class="banner-copy">
+          <div class="banner-title">完课奖励</div>
+          <div class="banner-desc">观看进度达到完课标准后自动发放积分 / 优惠券</div>
+        </div>
+      </div>
+
+      <div class="reward-switch-card">
+        <div class="switch-info">
+          <div class="switch-title">开启完课奖励</div>
+          <div class="switch-hint">与观看奖励相互独立,关闭后停止完课发放,不影响观看时长奖励</div>
+        </div>
+        <el-switch v-model="form.completionEnabled" active-color="#1890ff"></el-switch>
+      </div>
+
+      <div v-if="form.completionEnabled" class="config-flow">
+        <el-form
+          :model="form"
+          :rules="rules"
+          ref="completionForm"
+          label-width="120px"
+          class="reward-form"
+        >
+          <div class="flow-card">
+            <div class="flow-head">
+              <span class="step-no">1</span>
+              <div>
+                <div class="flow-title">完课条件</div>
+                <div class="flow-sub">设置用户达到完课标准后可领取奖励</div>
+              </div>
+            </div>
+            <div class="cond-fields" style="border-top: none; padding-top: 0; margin-top: 0;">
+              <el-form-item label="完课要求" prop="completionRate">
+                <el-input-number
+                  v-model="form.completionRate"
+                  :min="1"
+                  :max="100"
+                  :precision="0"
+                  class="field-sm"
+                ></el-input-number>
+                <span class="field-tip">%(观看时长 / 视频总时长)</span>
+              </el-form-item>
+            </div>
+          </div>
+
+          <div class="flow-card">
+            <div class="flow-head">
+              <span class="step-no">2</span>
+              <div>
+                <div class="flow-title">完课奖励</div>
+                <div class="flow-sub">可多选,完课达标后按所选类型发放</div>
+              </div>
+            </div>
+
+            <el-form-item label-width="0" prop="completionTypes" class="type-form-item">
+              <div class="reward-type-grid">
+                <div
+                  v-for="item in completionTypeCards"
+                  :key="item.value"
+                  class="reward-type-card"
+                  :class="{ active: hasCompletionType(item.value) }"
+                  @click="toggleCompletionType(item.value)"
+                >
+                  <div class="rtype-icon" :class="item.tone"><i :class="item.icon"></i></div>
+                  <div class="rtype-name">{{ item.label }}</div>
+                  <div class="rtype-desc">{{ item.desc }}</div>
+                  <i class="el-icon-check rtype-check" v-if="hasCompletionType(item.value)"></i>
+                </div>
+              </div>
+            </el-form-item>
+
+            <div v-if="hasCompletionType('2')" class="detail-panel">
+              <div class="detail-head">
+                <i class="el-icon-medal"></i>
+                <span>完课积分配置</span>
+              </div>
+              <el-form-item label-width="0" prop="pointsConfig">
+                <div class="points-grid">
+                  <div
+                    v-for="(point, index) in form.pointsConfig"
+                    :key="index"
+                    class="points-item"
+                  >
+                    <span class="points-day">第{{ index + 1 }}天</span>
+                    <el-input-number
+                      v-model="form.pointsConfig[index]"
+                      :min="0"
+                      :precision="0"
+                      controls-position="right"
+                      class="points-input"
+                    ></el-input-number>
+                    <span class="points-unit">积分</span>
+                  </div>
+                </div>
+              </el-form-item>
+            </div>
+
+            <div v-if="hasCompletionType('3')" class="detail-panel">
+              <div class="detail-head">
+                <i class="el-icon-ticket"></i>
+                <span>完课优惠券</span>
+              </div>
+              <el-form-item label="选择优惠券" prop="finishCoupons">
+                <el-select
+                  v-model="finishCouponSelectedIds"
+                  multiple
+                  filterable
+                  collapse-tags
+                  placeholder="可多选优惠券"
+                  class="field-lg"
+                  @change="onFinishCouponIdsChange"
+                >
+                  <el-option
+                    v-for="item in couponSelectOptions"
+                    :key="item.value"
+                    :label="item.label"
+                    :value="item.value"
+                  ></el-option>
+                </el-select>
+                <span class="field-tip">可多选,每张券可单独设置领取数量</span>
+              </el-form-item>
+              <div class="coupon-multi-list" v-if="form.finishCoupons && form.finishCoupons.length">
+                <div
+                  v-for="(row, idx) in form.finishCoupons"
+                  :key="'fc-' + row.couponId"
+                  class="coupon-multi-item"
+                >
+                  <div class="cmi-main">
+                    <div class="cmi-title">{{ resolveCouponTitle(row.couponId) }}</div>
+                    <div class="cmi-meta">
+                      <span v-for="kv in buildCouponMeta(row.couponId)" :key="kv">{{ kv }}</span>
+                    </div>
+                  </div>
+                  <div class="cmi-count">
+                    <span class="cmi-count-label">领取数量</span>
+                    <el-input-number
+                      v-model="row.count"
+                      :min="1"
+                      :precision="0"
+                      controls-position="right"
+                      size="small"
+                    ></el-input-number>
+                  </div>
+                  <el-button type="text" class="danger-text" icon="el-icon-delete" @click="removeFinishCoupon(idx)">移除</el-button>
+                </div>
+              </div>
+              <el-form-item label="完课答题" prop="finishQuestionIds" class="question-item">
+                <div class="question-block">
+                  <div class="question-toolbar">
+                    <el-button type="primary" plain icon="el-icon-plus" size="small" @click="openQuestionDialog">选择题目</el-button>
+                    <el-link type="primary" :underline="false" @click="handleToQuestionBank">前往直播题库 ></el-link>
+                  </div>
+                  <el-table
+                    v-if="selectedQuestionList.length > 0"
+                    :data="selectedQuestionList"
+                    class="question-table"
+                    size="small"
+                    border
+                    max-height="240"
+                  >
+                    <el-table-column prop="title" label="题目" show-overflow-tooltip />
+                    <el-table-column prop="type" label="类型" width="100">
+                      <template slot-scope="scope">
+                        {{ scope.row.type === 1 ? '单选题' : '多选题' }}
+                      </template>
+                    </el-table-column>
+                    <el-table-column label="操作" width="80">
+                      <template slot-scope="scope">
+                        <el-button type="text" class="danger-text" @click="removeSelectedQuestion(scope.row)">移除</el-button>
+                      </template>
+                    </el-table-column>
+                  </el-table>
+                  <div v-else class="empty-hint">观看完视频后将自动弹出完课题目,用户答对后发放优惠券</div>
+                </div>
+              </el-form-item>
+            </div>
+          </div>
+
+          <div class="form-actions">
+            <el-button type="primary" size="medium" icon="el-icon-check" @click="saveCompletionReward">保存配置</el-button>
+          </div>
+        </el-form>
+      </div>
+
+      <div v-else class="disabled-hint">
+        <i class="el-icon-info"></i>
+        <span>完课奖励已关闭,开启后可配置完课条件与奖励内容</span>
+      </div>
+    </template>
+
+    <div v-else class="disabled-hint">
+      <i class="el-icon-info"></i>
+      <span>仅录播支持完课奖励</span>
+    </div>
+
+    <el-dialog
+      title="选择完课题目"
+      :visible.sync="questionDialogVisible"
+      width="800px"
+      append-to-body
+      :close-on-click-modal="false"
+      custom-class="question-select-dialog"
+    >
+      <div class="dialog-search">
+        <el-input
+          v-model="questionSearchTitle"
+          placeholder="请输入题目标题"
+          class="field-md"
+          clearable
+          @keyup.enter.native="handleQuestionSearch"
+        >
+          <el-button slot="append" icon="el-icon-search" @click="handleQuestionSearch"></el-button>
+        </el-input>
+      </div>
+      <el-table
+        ref="questionTable"
+        :data="questionOptionList"
+        v-loading="questionLoading"
+        row-key="id"
+        @selection-change="handleQuestionSelectionChange"
+        @row-click="handleQuestionRowClick"
+      >
+        <el-table-column type="selection" width="55" :reserve-selection="true" />
+        <el-table-column prop="title" label="题目" show-overflow-tooltip />
+        <el-table-column prop="type" label="类型" width="100">
+          <template slot-scope="scope">
+            {{ scope.row.type === 1 ? '单选题' : '多选题' }}
+          </template>
+        </el-table-column>
+        <el-table-column prop="createBy" label="创建人" width="120" />
+        <el-table-column prop="createTime" label="创建时间" width="160" />
+      </el-table>
+      <pagination
+        v-show="questionOptionTotal > 0"
+        :total="questionOptionTotal"
+        :page.sync="questionQueryParams.pageNum"
+        :limit.sync="questionQueryParams.pageSize"
+        @pagination="loadQuestionOptions"
+        style="margin-top: 16px;"
+      />
+      <div slot="footer">
+        <span class="dialog-selected-count">
+          已选择 <em>{{ tempSelectedQuestions.length }}</em> 题
+        </span>
+        <el-button @click="questionDialogVisible = false">取 消</el-button>
+        <el-button type="primary" @click="confirmQuestionSelection">确 定</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import {addConfig, getConfig, updateConfig} from "@/api/live/liveQuestionLive";
+import {listLiveCouponOn} from "@/api/live/liveCoupon";
+import {listLiveQuestionBank, getLiveQuestionBank} from "@/api/live/liveQuestionBank";
+
+const DEFAULT_POINTS_CONFIG = [100, 110, 120, 130, 140, 150, 160, 170, 180, 200];
+
+export default {
+  props: {
+    liveType: {
+      type: Number,
+      default: 1
+    }
+  },
+  data() {
+    return {
+      loading: true,
+      liveId: null,
+      existingConfig: {},
+      form: {
+        id: null,
+        liveId: null,
+        completionEnabled: false,
+        completionRate: 90,
+        pointsConfig: [...DEFAULT_POINTS_CONFIG],
+        completionTypes: ['2'],
+        finishCouponId: null,
+        finishCouponCount: 1,
+        finishCoupons: [],
+        finishQuestionIds: ''
+      },
+      finishCouponSelectedIds: [],
+      rules: {
+        completionRate: [
+          {
+            validator: (rule, value, callback) => {
+              if (!value && value !== 0) {
+                callback(new Error('请输入完课要求'));
+              } else if (value < 1 || value > 100) {
+                callback(new Error('完课要求范围为1-100'));
+              } else {
+                callback();
+              }
+            },
+            trigger: 'blur'
+          }
+        ],
+        completionTypes: [
+          {
+            validator: (rule, value, callback) => {
+              if (!value || !value.length) {
+                callback(new Error('请至少选择一种完课奖励类型'));
+              } else {
+                callback();
+              }
+            },
+            trigger: 'change'
+          }
+        ],
+        finishCoupons: [
+          {
+            validator: (rule, value, callback) => {
+              if (this.hasCompletionType('3')) {
+                if (!value || !value.length) {
+                  callback(new Error('请至少选择一张完课优惠券'));
+                } else if (value.some(item => !item.count || item.count < 1)) {
+                  callback(new Error('每张优惠券领取数量至少为1'));
+                } else {
+                  callback();
+                }
+              } else {
+                callback();
+              }
+            },
+            trigger: 'change'
+          }
+        ],
+        finishQuestionIds: [
+          {
+            validator: (rule, value, callback) => {
+              if (this.hasCompletionType('3')) {
+                if (!value) {
+                  callback(new Error('请选择完课题目'));
+                } else {
+                  callback();
+                }
+              } else {
+                callback();
+              }
+            },
+            trigger: 'change'
+          }
+        ]
+      },
+      completionTypeCards: [
+        { label: '完课积分', value: '2', icon: 'el-icon-medal', tone: 'tone-orange', desc: '按天发放完课积分' },
+        { label: '完课优惠券', value: '3', icon: 'el-icon-ticket', tone: 'tone-green', desc: '答对题目后发放优惠券' }
+      ],
+      couponSelectOptions: [],
+      couponListData: [],
+      questionDialogVisible: false,
+      questionLoading: false,
+      questionSearchTitle: '',
+      questionOptionList: [],
+      questionOptionTotal: 0,
+      questionQueryParams: {
+        pageNum: 1,
+        pageSize: 10,
+        title: null,
+        status: 1
+      },
+      selectedQuestionList: [],
+      tempSelectedQuestions: []
+    };
+  },
+  watch: {
+    '$route.query': {
+      handler() {
+        this.liveId = this.$route.params.liveId;
+        this.form.liveId = this.liveId;
+        if (this.liveId == null) {
+          return;
+        }
+        this.getLiveConfig();
+      },
+      immediate: true
+    },
+    'form.completionEnabled': {
+      handler(newValue, oldValue) {
+        if (oldValue === true && newValue === false && this.liveId) {
+          this.autoSaveEnabled();
+        }
+      }
+    }
+  },
+  methods: {
+    couponTypeLabel(type) {
+      if (type === 0) return '普通券';
+      if (type === 1) return '套餐券';
+      if (type === 2) return '制单券';
+      return '无门槛券';
+    },
+    resolveCouponTitle(couponId) {
+      const coupon = this.couponListData.find(item => item.couponId === couponId);
+      return coupon ? coupon.title : `优惠券#${couponId}`;
+    },
+    buildCouponMeta(couponId) {
+      const coupon = this.couponListData.find(item => item.couponId === couponId);
+      if (!coupon) {
+        return [];
+      }
+      return [
+        `面值?${coupon.couponPrice}`,
+        `满?${coupon.useMinPrice}`,
+        `${coupon.couponTime}天`,
+        this.couponTypeLabel(coupon.type)
+      ];
+    },
+    syncLegacyCouponFields() {
+      const finishList = this.form.finishCoupons || [];
+      if (finishList.length) {
+        this.form.finishCouponId = finishList[0].couponId;
+        this.form.finishCouponCount = finishList[0].count || 1;
+      } else {
+        this.form.finishCouponId = null;
+        this.form.finishCouponCount = 1;
+      }
+    },
+    normalizeCouponItems(list, fallbackId, fallbackCount) {
+      let items = [];
+      if (Array.isArray(list) && list.length) {
+        items = list
+          .map(item => ({
+            couponId: item.couponId != null ? item.couponId : item.id,
+            count: item.count != null && item.count > 0 ? item.count : (item.couponCount > 0 ? item.couponCount : 1)
+          }))
+          .filter(item => item.couponId != null);
+      } else if (fallbackId != null && fallbackId !== '') {
+        items = [{
+          couponId: fallbackId,
+          count: fallbackCount && fallbackCount > 0 ? fallbackCount : 1
+        }];
+      }
+      const map = new Map();
+      items.forEach(item => {
+        map.set(item.couponId, { couponId: item.couponId, count: item.count || 1 });
+      });
+      return Array.from(map.values());
+    },
+    onFinishCouponIdsChange(ids) {
+      const prevMap = {};
+      (this.form.finishCoupons || []).forEach(item => {
+        prevMap[item.couponId] = item.count || 1;
+      });
+      this.form.finishCoupons = (ids || []).map(id => ({
+        couponId: id,
+        count: prevMap[id] || 1
+      }));
+      this.syncLegacyCouponFields();
+      this.$nextTick(() => {
+        this.$refs.completionForm && this.$refs.completionForm.validateField('finishCoupons');
+      });
+    },
+    removeFinishCoupon(idx) {
+      const list = [...(this.form.finishCoupons || [])];
+      list.splice(idx, 1);
+      this.form.finishCoupons = list;
+      this.finishCouponSelectedIds = list.map(item => item.couponId);
+      this.syncLegacyCouponFields();
+    },
+    toggleCompletionType(val) {
+      const list = (this.form.completionTypes || []).map(String);
+      const idx = list.indexOf(String(val));
+      if (idx >= 0) {
+        list.splice(idx, 1);
+      } else {
+        list.push(String(val));
+      }
+      this.form.completionTypes = list;
+      this.$nextTick(() => {
+        this.$refs.completionForm && this.$refs.completionForm.validateField('completionTypes');
+      });
+    },
+    hasCompletionType(type) {
+      return Array.isArray(this.form.completionTypes)
+        && this.form.completionTypes.map(String).includes(String(type));
+    },
+    hasValidWatchConfig(config) {
+      const watchDuration = config && config.watchDuration;
+      const actions = config && config.actions;
+      const hasDuration = watchDuration !== null && watchDuration !== undefined
+        && String(watchDuration).trim() !== ''
+        && Number(watchDuration) > 0;
+      const hasActions = Array.isArray(actions) && actions.length > 0;
+      return hasDuration && hasActions;
+    },
+    applyParsedConfig(parsedData) {
+      const data = parsedData || {};
+      this.existingConfig = Object.assign({}, data);
+
+      let completionTypes = data.completionTypes;
+      if (String(data.participateCondition) === '3') {
+        if (!completionTypes || !completionTypes.length) {
+          completionTypes = ['3'];
+        }
+      } else if (String(data.participateCondition) === '2') {
+        if (!completionTypes || !completionTypes.length) {
+          completionTypes = ['2'];
+        }
+      }
+      if (!Array.isArray(completionTypes)) {
+        completionTypes = [];
+      } else {
+        completionTypes = completionTypes.map(String);
+      }
+
+      const finishCoupons = this.normalizeCouponItems(
+        data.finishCoupons,
+        data.finishCouponId,
+        data.finishCouponCount
+      );
+      const pointsConfig = Array.isArray(data.pointsConfig) && data.pointsConfig.length
+        ? data.pointsConfig
+        : [...DEFAULT_POINTS_CONFIG];
+
+      const completionEnabled = !!data.enabled && completionTypes.length > 0;
+
+      this.form = {
+        id: data.id != null ? data.id : null,
+        liveId: this.liveId,
+        completionEnabled,
+        completionRate: data.completionRate != null ? data.completionRate : 90,
+        pointsConfig,
+        completionTypes: completionTypes.length ? completionTypes : ['2'],
+        finishCouponId: null,
+        finishCouponCount: 1,
+        finishCoupons,
+        finishQuestionIds: data.finishQuestionIds || ''
+      };
+      this.syncLegacyCouponFields();
+      this.finishCouponSelectedIds = (this.form.finishCoupons || []).map(item => item.couponId);
+      this.loadSelectedQuestions();
+    },
+    getLiveConfig() {
+      this.loadCouponList().then(() => {
+        getConfig(this.liveId).then(response => {
+          if (response.code === 200 && response.data != null && response.data.length > 0) {
+            const parsedData = JSON.parse(response.data);
+            this.applyParsedConfig(parsedData);
+          }
+          this.loading = false;
+        }).catch(() => {
+          this.loading = false;
+        });
+      });
+    },
+    loadCouponList() {
+      return listLiveCouponOn({ liveId: this.liveId }).then(response => {
+        const list = response.rows || [];
+        this.couponListData = list;
+        this.couponSelectOptions = list.map(item => ({
+          value: item.couponId,
+          label: item.title
+        }));
+      });
+    },
+    buildMergedPayload() {
+      this.syncLegacyCouponFields();
+      this.syncFinishQuestionIds();
+      const base = Object.assign({}, this.existingConfig);
+      const completionEnabled = !!this.form.completionEnabled;
+      const completionTypes = completionEnabled
+        ? (this.form.completionTypes || []).map(String)
+        : [];
+      const finishCoupons = completionEnabled && this.hasCompletionType('3')
+        ? (this.form.finishCoupons || []).map(item => ({
+          couponId: item.couponId,
+          count: item.count && item.count > 0 ? item.count : 1
+        }))
+        : [];
+
+      base.id = this.form.id;
+      base.liveId = this.liveId;
+      base.completionRate = this.form.completionRate;
+      base.pointsConfig = this.form.pointsConfig;
+      base.completionTypes = completionTypes;
+      base.finishCoupons = finishCoupons;
+      base.finishCouponId = finishCoupons.length ? finishCoupons[0].couponId : null;
+      base.finishCouponCount = finishCoupons.length ? (finishCoupons[0].count || 1) : 1;
+      base.finishQuestionIds = completionEnabled && this.hasCompletionType('3')
+        ? this.form.finishQuestionIds
+        : '';
+
+      // 与观看奖励共用 enabled:任一开启则保持 true
+      const watchOn = this.hasValidWatchConfig(base);
+      base.enabled = completionEnabled || watchOn;
+
+      if (completionEnabled) {
+        if (!watchOn) {
+          base.participateCondition = '2';
+        } else if (base.participateCondition == null || base.participateCondition === '') {
+          base.participateCondition = '1';
+        } else {
+          base.participateCondition = String(base.participateCondition);
+        }
+      } else if (watchOn) {
+        base.participateCondition = '1';
+      }
+
+      return base;
+    },
+    fetchLatestAndMerge() {
+      return getConfig(this.liveId).then(response => {
+        if (response.code === 200 && response.data != null && response.data.length > 0) {
+          try {
+            this.existingConfig = Object.assign({}, JSON.parse(response.data));
+            if (this.existingConfig.id != null) {
+              this.form.id = this.existingConfig.id;
+            }
+          } catch (e) {
+            // keep existingConfig
+          }
+        }
+        return this.buildMergedPayload();
+      });
+    },
+    persistPayload(payload, successMsg) {
+      if (payload.id == null) {
+        return addConfig(payload, this.liveId).then(res => {
+          if (res.code == 200) {
+            this.msgSuccess(successMsg);
+            if (res.data && res.data.id) {
+              this.form.id = res.data.id;
+            }
+            this.existingConfig = Object.assign({}, payload, { id: this.form.id });
+          }
+          return res;
+        });
+      }
+      return updateConfig(payload, this.liveId).then(response => {
+        if (response.code == 200) {
+          this.msgSuccess(successMsg);
+          this.existingConfig = Object.assign({}, payload);
+        }
+        return response;
+      });
+    },
+    autoSaveEnabled() {
+      this.fetchLatestAndMerge().then(payload => {
+        this.persistPayload(payload, '已关闭完课奖励').catch(() => {
+          this.form.completionEnabled = true;
+        });
+      }).catch(() => {
+        this.form.completionEnabled = true;
+      });
+    },
+    saveCompletionReward() {
+      this.syncFinishQuestionIds();
+      this.$refs['completionForm'].validate(valid => {
+        if (!valid) {
+          return;
+        }
+        this.fetchLatestAndMerge().then(payload => {
+          this.persistPayload(payload, '修改成功');
+        });
+      });
+    },
+    handleToQuestionBank() {
+      this.$router.push('/live/liveQuestionBank');
+    },
+    openQuestionDialog() {
+      this.questionDialogVisible = true;
+      this.tempSelectedQuestions = [...this.selectedQuestionList];
+      this.loadQuestionOptions();
+    },
+    loadQuestionOptions() {
+      this.questionLoading = true;
+      listLiveQuestionBank(this.questionQueryParams).then(response => {
+        this.questionOptionList = response.rows || [];
+        this.questionOptionTotal = response.total || 0;
+        this.questionLoading = false;
+        this.$nextTick(() => {
+          if (this.$refs.questionTable) {
+            this.questionOptionList.forEach(row => {
+              const selected = this.tempSelectedQuestions.some(item => item.id === row.id);
+              this.$refs.questionTable.toggleRowSelection(row, selected);
+            });
+          }
+        });
+      }).catch(() => {
+        this.questionLoading = false;
+      });
+    },
+    handleQuestionSearch() {
+      this.questionQueryParams.pageNum = 1;
+      this.questionQueryParams.title = this.questionSearchTitle;
+      this.loadQuestionOptions();
+    },
+    handleQuestionSelectionChange(selection) {
+      const currentPageIds = this.questionOptionList.map(item => item.id);
+      const otherPageSelected = this.tempSelectedQuestions.filter(item => !currentPageIds.includes(item.id));
+      this.tempSelectedQuestions = [...otherPageSelected, ...selection];
+    },
+    handleQuestionRowClick(row, column) {
+      if (column.type === 'selection') {
+        return;
+      }
+      const table = this.$refs.questionTable;
+      if (!table) {
+        return;
+      }
+      const isSelected = this.tempSelectedQuestions.some(item => item.id === row.id);
+      table.toggleRowSelection(row, !isSelected);
+    },
+    confirmQuestionSelection() {
+      if (this.tempSelectedQuestions.length === 0) {
+        this.$message.warning('请至少选择一题');
+        return;
+      }
+      this.selectedQuestionList = [...this.tempSelectedQuestions];
+      this.syncFinishQuestionIds();
+      this.questionDialogVisible = false;
+    },
+    removeSelectedQuestion(row) {
+      this.selectedQuestionList = this.selectedQuestionList.filter(item => item.id !== row.id);
+      this.syncFinishQuestionIds();
+    },
+    syncFinishQuestionIds() {
+      this.form.finishQuestionIds = this.selectedQuestionList.map(item => item.id).join(',');
+    },
+    loadSelectedQuestions() {
+      if (!this.form.finishQuestionIds) {
+        this.selectedQuestionList = [];
+        return;
+      }
+      const ids = this.form.finishQuestionIds.split(',').filter(Boolean);
+      if (ids.length === 0) {
+        this.selectedQuestionList = [];
+        return;
+      }
+      Promise.all(ids.map(id => getLiveQuestionBank(id))).then(results => {
+        this.selectedQuestionList = results
+          .filter(res => res.code === 200 && res.data)
+          .map(res => res.data);
+      });
+    }
+  }
+};
+</script>
+
+<style scoped>
+.watch-reward-page {
+  min-height: 100%;
+  padding: 4px 4px 40px;
+}
+
+.page-banner {
+  display: flex;
+  align-items: center;
+  gap: 14px;
+  margin-bottom: 14px;
+  padding: 16px 18px;
+  border-radius: 12px;
+  background: linear-gradient(135deg, #f0faf3 0%, #f5f9ff 55%, #eef7f3 100%);
+  border: 1px solid #e4f0e8;
+}
+
+.banner-icon {
+  width: 42px;
+  height: 42px;
+  border-radius: 12px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: linear-gradient(135deg, #1890ff, #36a3ff);
+  color: #fff;
+  font-size: 20px;
+  flex-shrink: 0;
+}
+
+.tone-green-banner {
+  background: linear-gradient(135deg, #52c41a, #73d13d);
+}
+
+.banner-title {
+  font-size: 17px;
+  font-weight: 700;
+  color: #1f2d3d;
+  margin-bottom: 4px;
+}
+
+.banner-desc {
+  font-size: 13px;
+  color: #6b7c93;
+  line-height: 1.5;
+}
+
+.reward-switch-card {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 14px;
+  padding: 16px 18px;
+  background: #fff;
+  border-radius: 12px;
+  border: 1px solid #eef2f7;
+  box-shadow: 0 1px 2px rgba(31, 45, 61, 0.04);
+}
+
+.switch-title {
+  font-size: 15px;
+  font-weight: 600;
+  color: #303133;
+  margin-bottom: 4px;
+}
+
+.switch-hint {
+  font-size: 12px;
+  color: #909399;
+}
+
+.disabled-hint {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 28px 18px;
+  border-radius: 12px;
+  background: #f8fafc;
+  border: 1px dashed #d9e2ec;
+  color: #909399;
+  font-size: 13px;
+}
+
+.config-flow {
+  display: flex;
+  flex-direction: column;
+  gap: 14px;
+}
+
+.flow-card {
+  background: #fff;
+  border-radius: 12px;
+  border: 1px solid #eef2f7;
+  box-shadow: 0 1px 2px rgba(31, 45, 61, 0.04);
+  padding: 18px 20px 8px;
+}
+
+.flow-head {
+  display: flex;
+  align-items: flex-start;
+  gap: 12px;
+  margin-bottom: 16px;
+}
+
+.step-no {
+  width: 26px;
+  height: 26px;
+  border-radius: 50%;
+  background: #1890ff;
+  color: #fff;
+  font-size: 13px;
+  font-weight: 700;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-shrink: 0;
+  margin-top: 1px;
+}
+
+.flow-title {
+  font-size: 15px;
+  font-weight: 700;
+  color: #1f2d3d;
+}
+
+.flow-sub {
+  font-size: 12px;
+  color: #909399;
+  margin-top: 2px;
+}
+
+.reward-type-grid {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 12px;
+  margin-bottom: 8px;
+}
+
+.reward-type-card {
+  position: relative;
+  display: flex;
+  flex-direction: column;
+  align-items: flex-start;
+  gap: 12px;
+  padding: 14px 16px;
+  border-radius: 12px;
+  border: 1px solid #e8eef5;
+  background: #fafbfc;
+  cursor: pointer;
+  transition: all 0.18s ease;
+  user-select: none;
+  min-height: 118px;
+}
+
+.reward-type-card:hover {
+  border-color: #cfe3ff;
+  background: #f7fbff;
+}
+
+.reward-type-card.active {
+  border-color: #1890ff;
+  background: linear-gradient(135deg, #f0f7ff 0%, #f7fbff 100%);
+  box-shadow: 0 4px 12px rgba(24, 144, 255, 0.1);
+}
+
+.rtype-icon {
+  width: 36px;
+  height: 36px;
+  border-radius: 10px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #fff;
+  font-size: 18px;
+  flex-shrink: 0;
+}
+
+.tone-green { background: linear-gradient(135deg, #52c41a, #73d13d); }
+.tone-orange { background: linear-gradient(135deg, #fa8c16, #ffa940); }
+
+.rtype-name {
+  font-size: 14px;
+  font-weight: 600;
+  color: #303133;
+  margin-bottom: 4px;
+}
+
+.rtype-desc {
+  font-size: 12px;
+  color: #909399;
+  line-height: 1.4;
+}
+
+.rtype-check {
+  position: absolute;
+  top: 10px;
+  right: 10px;
+  color: #1890ff;
+  font-size: 16px;
+  font-weight: 700;
+}
+
+.cond-fields {
+  margin-top: 8px;
+  padding-top: 8px;
+  border-top: 1px dashed #eef2f7;
+}
+
+.type-form-item {
+  margin-bottom: 8px;
+}
+
+.type-form-item >>> .el-form-item__content {
+  margin-left: 0 !important;
+  line-height: normal;
+}
+
+.detail-panel {
+  margin: 8px 0 16px;
+  padding: 14px 16px 4px;
+  background: #f8fafc;
+  border: 1px solid #eef2f7;
+  border-radius: 10px;
+}
+
+.detail-head {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  font-size: 13px;
+  font-weight: 600;
+  color: #4a5d73;
+  margin-bottom: 12px;
+}
+
+.detail-head i {
+  color: #1890ff;
+}
+
+.field-sm { width: 160px; }
+.field-md { width: 100%; max-width: 280px; }
+.field-full { width: 100%; }
+
+.field-tip {
+  margin-left: 10px;
+  color: #909399;
+  font-size: 12px;
+}
+
+.field-lg {
+  width: 100%;
+  max-width: 420px;
+}
+
+.coupon-multi-list {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+  margin: 0 0 16px;
+}
+
+.coupon-multi-item {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  padding: 12px 14px;
+  border-radius: 10px;
+  border: 1px solid #e8eef5;
+  background: #fafbfc;
+}
+
+.cmi-main {
+  flex: 1;
+  min-width: 0;
+}
+
+.cmi-title {
+  font-size: 14px;
+  font-weight: 600;
+  color: #303133;
+  margin-bottom: 4px;
+}
+
+.cmi-meta {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+  font-size: 12px;
+  color: #909399;
+}
+
+.cmi-count {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  flex-shrink: 0;
+}
+
+.cmi-count-label {
+  font-size: 12px;
+  color: #606266;
+  white-space: nowrap;
+}
+
+.points-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+  gap: 10px;
+  width: 100%;
+  margin-bottom: 12px;
+}
+
+.points-item {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  background: #fff;
+  border: 1px solid #ebeef5;
+  border-radius: 8px;
+  padding: 8px 10px;
+}
+
+.points-day {
+  width: 52px;
+  color: #606266;
+  font-size: 13px;
+  flex-shrink: 0;
+}
+
+.points-input {
+  width: 110px;
+}
+
+.points-unit {
+  color: #909399;
+  font-size: 12px;
+}
+
+.question-item {
+  margin-top: 4px;
+}
+
+.question-block {
+  width: 100%;
+}
+
+.question-toolbar {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  margin-bottom: 10px;
+}
+
+.question-table {
+  width: 100%;
+  margin-top: 4px;
+}
+
+.empty-hint {
+  margin-top: 4px;
+  padding: 12px 14px;
+  border-radius: 8px;
+  background: #fff;
+  border: 1px dashed #d9e2ec;
+  color: #909399;
+  font-size: 13px;
+  line-height: 1.6;
+}
+
+.danger-text {
+  color: #f56c6c;
+}
+
+.form-actions {
+  text-align: center;
+  padding: 8px 0 4px;
+}
+
+.form-actions .el-button {
+  min-width: 140px;
+  border-radius: 8px;
+}
+
+.dialog-search {
+  text-align: right;
+  margin-bottom: 16px;
+}
+
+.dialog-selected-count {
+  float: left;
+  line-height: 32px;
+  color: #606266;
+}
+
+.dialog-selected-count em {
+  font-style: normal;
+  color: #1890ff;
+  font-weight: 600;
+}
+
+@media (max-width: 900px) {
+  .reward-type-grid {
+    grid-template-columns: 1fr;
+  }
+}
+</style>
+
+<style>
+.question-select-dialog .el-dialog__body {
+  max-height: 65vh;
+  overflow-y: auto;
+}
+</style>

+ 18 - 3
src/views/live/liveConfig/index.vue

@@ -48,7 +48,7 @@
       <aside class="nav-panel">
         <div class="nav-label">配置项</div>
         <button
-          v-for="item in menuList"
+          v-for="item in filteredMenuList"
           :key="item.index"
           type="button"
           class="nav-item"
@@ -75,6 +75,7 @@
 
 <script>
 import WatchReward from './watchReward.vue';
+import CompletionReward from './completionReward.vue';
 import Answer from './answer.vue';
 import Goods from './goods.vue';
 import IdCard from './idCard.vue';
@@ -91,6 +92,7 @@ export default {
   name: 'LiveConfig',
   components: {
     WatchReward,
+    CompletionReward,
     LiveRedConf,
     LiveReplay,
     LiveLotteryConf,
@@ -111,7 +113,8 @@ export default {
       currentComponent: 'WatchReward',
       liveWsUrl: process.env.VUE_APP_LIVE_WS_URL + '/app/webSocket',
       menuList: [
-        { name: '观看奖励', index: 'watchReward', component: 'WatchReward', icon: 'el-icon-present', tone: 'blue', desc: '时长 / 完课奖励' },
+        { name: '观看奖励', index: 'watchReward', component: 'WatchReward', icon: 'el-icon-present', tone: 'blue', desc: '时长达标奖励' },
+        { name: '完课奖励', index: 'completionReward', component: 'CompletionReward', icon: 'el-icon-circle-check', tone: 'green', desc: '完课积分 / 优惠券', liveTypeOnly: 2 },
         { name: '直播预告', index: 'preview', component: 'Preview', icon: 'el-icon-bell', tone: 'cyan', desc: '预告信息配置' },
         { name: '红包配置', index: 'liveRedConf', component: 'LiveRedConf', icon: 'el-icon-coin', tone: 'orange', desc: '直播红包规则' },
         { name: '抽奖配置', index: 'liveLotteryConf', component: 'LiveLotteryConf', icon: 'el-icon-trophy', tone: 'gold', desc: '抽奖活动设置' },
@@ -140,6 +143,14 @@ export default {
     statusClass() {
       const map = { 1: 'pending', 2: 'living', 3: 'ended' }
       return map[this.liveInfo.status] || 'pending'
+    },
+    filteredMenuList() {
+      return this.menuList.filter(item => {
+        if (item.liveTypeOnly != null) {
+          return this.liveInfo.liveType == item.liveTypeOnly
+        }
+        return true
+      })
     }
   },
   methods: {
@@ -158,7 +169,7 @@ export default {
       // 配置页仅保持连接,消息由中控台处理
     },
     handleSelect(index) {
-      const item = this.menuList.find(m => m.index === index)
+      const item = this.filteredMenuList.find(m => m.index === index)
       if (!item) return
       this.activeMenu = index
       this.currentComponent = item.component
@@ -166,6 +177,10 @@ export default {
     getLiving() {
       getLive(this.liveId).then(res => {
         this.liveInfo = res.data || {}
+        // 非录播时若当前在完课奖励页,回退到观看奖励
+        if (this.liveInfo.liveType != 2 && this.activeMenu === 'completionReward') {
+          this.handleSelect('watchReward')
+        }
       })
     },
     goBack() {

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 263 - 641
src/views/live/liveConfig/watchReward.vue


Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác