Преглед изворни кода

Merge branch 'bjcz_his_scrm' into jsjk_his_scrm

xw пре 3 недеља
родитељ
комит
bd91be1a63

+ 7 - 0
src/api/qw/externalContact.js

@@ -162,6 +162,13 @@ export function resignedTransfer(data) {
     data: data
   })
 }
+export function transferPreCheck(data) {
+  return request({
+    url: '/qw/externalContact/transferPreCheck',
+    method: 'post',
+    data: data
+  })
+}
 export function transfer(data) {
   return request({
     url: '/qw/externalContact/transfer',

+ 481 - 0
src/components/RandomTextListEditor/index.vue

@@ -0,0 +1,481 @@
+<template>
+  <div class="random-text-list-editor">
+    <div class="random-text-list-editor__summary" @click="openDrawer">
+      <div class="random-text-list-editor__summary-main">
+        <i class="el-icon-document" />
+        <span>文本文本 <strong>{{ item.randomValueList.length }}</strong> 条</span>
+      </div>
+      <el-button type="text" icon="el-icon-edit">
+        编辑文本
+      </el-button>
+    </div>
+
+    <el-drawer
+      :title="drawerTitle"
+      :visible.sync="drawerVisible"
+      direction="rtl"
+      size="520px"
+      append-to-body
+      :wrapper-closable="true"
+      custom-class="random-text-drawer"
+      @open="handleDrawerOpen"
+    >
+      <div class="random-text-drawer__body">
+        <div class="random-text-drawer__toolbar">
+          <span class="random-text-drawer__count">共 {{ item.randomValueList.length }} 条内容</span>
+          <el-button
+            v-if="!disabled"
+            type="primary"
+            size="mini"
+            icon="el-icon-plus"
+            @click="handleAdd"
+          >
+            新增文本
+          </el-button>
+        </div>
+
+        <div class="random-text-drawer__list">
+          <div
+            v-for="(randomValue, randomIndex) in item.randomValueList"
+            :key="randomIndex"
+            class="random-text-drawer__item"
+            :class="{ 'is-active': editingIndex === randomIndex }"
+            @click="selectItem(randomIndex)"
+          >
+            <div class="random-text-drawer__item-main">
+              <span class="random-text-drawer__label">文本 {{ randomIndex + 1 }}</span>
+              <span class="random-text-drawer__preview">{{ getPreview(randomValue) }}</span>
+            </div>
+            <el-button
+              v-if="!disabled && item.randomValueList.length > minCount"
+              type="text"
+              icon="el-icon-delete"
+              class="random-text-drawer__delete"
+              @click.stop="handleRemove(randomIndex)"
+            />
+          </div>
+        </div>
+
+        <div v-if="editingIndex !== -1" class="random-text-drawer__editor">
+          <div class="random-text-drawer__editor-title">编辑文本 {{ editingIndex + 1 }}</div>
+          <el-input
+            :disabled="disabled"
+            v-model="item.randomValueList[editingIndex]"
+            type="textarea"
+            :rows="6"
+            placeholder="请输入文本内容"
+            @keydown.native="handleKeydown($event, editingIndex)"
+            :ref="`textarea-${refId}-${editingIndex}`"
+          />
+          <div class="random-text-drawer__actions">
+            <el-link
+              v-if="!disabled"
+              type="primary"
+              @click="toggleSalesCall(editingIndex)"
+            >
+              {{ item.randomSalesCallAdded && item.randomSalesCallAdded[editingIndex] ? `移除${salesCallTag}` : `插入${salesCallTag}` }}
+            </el-link>
+            <el-link
+              v-if="!disabled"
+              type="primary"
+              @click="toggleUserCall(editingIndex)"
+            >
+              {{ item.randomUserNameCallAdded && item.randomUserNameCallAdded[editingIndex] ? `移除${userCallTag}` : `插入${userCallTag}` }}
+            </el-link>
+          </div>
+        </div>
+        <div v-else class="random-text-drawer__empty-editor">
+          请先选择一条文本进行编辑
+        </div>
+      </div>
+
+      <div class="random-text-drawer__footer">
+        <el-button @click="drawerVisible = false">关闭</el-button>
+      </div>
+    </el-drawer>
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'RandomTextListEditor',
+  props: {
+    item: {
+      type: Object,
+      required: true
+    },
+    disabled: {
+      type: Boolean,
+      default: false
+    },
+    salesCallTag: {
+      type: String,
+      default: '#客户称呼#'
+    },
+    userCallTag: {
+      type: String,
+      default: '#用户姓名#'
+    },
+    minCount: {
+      type: Number,
+      default: 2
+    },
+    refId: {
+      type: String,
+      required: true
+    }
+  },
+  data() {
+    return {
+      drawerVisible: false,
+      editingIndex: 0
+    }
+  },
+  computed: {
+    drawerTitle() {
+      return `文本编辑(共${this.item.randomValueList.length}条)`
+    }
+  },
+  watch: {
+    'item.randomValueList.length'(length) {
+      if (this.editingIndex >= length) {
+        this.editingIndex = Math.max(0, length - 1)
+      }
+    }
+  },
+  mounted() {
+    this.initFields()
+  },
+  methods: {
+    openDrawer() {
+      this.drawerVisible = true
+    },
+    handleDrawerOpen() {
+      this.initFields()
+      if (this.item.randomValueList.length > 0 && this.editingIndex === -1) {
+        this.editingIndex = 0
+      }
+    },
+    initFields() {
+      const item = this.item
+      if (!item) {
+        return
+      }
+      if (!Array.isArray(item.randomValueList)) {
+        const initialValue = item.value || ''
+        this.$set(item, 'randomValueList', initialValue ? [initialValue] : ['', ''])
+      }
+      if (!Array.isArray(item.randomSalesCallAdded)) {
+        this.$set(item, 'randomSalesCallAdded', item.randomValueList.map(() => false))
+      }
+      if (!Array.isArray(item.randomUserNameCallAdded)) {
+        this.$set(item, 'randomUserNameCallAdded', item.randomValueList.map(() => false))
+      }
+    },
+    getPreview(text) {
+      const value = (text || '').replace(/\s+/g, ' ').trim()
+      if (!value) {
+        return '暂无内容'
+      }
+      return value.length > 36 ? `${value.slice(0, 36)}...` : value
+    },
+    selectItem(randomIndex) {
+      this.editingIndex = randomIndex
+      this.$nextTick(() => {
+        const textarea = this.getTextarea(randomIndex)
+        if (textarea) {
+          textarea.focus()
+        }
+      })
+    },
+    handleAdd() {
+      this.initFields()
+      this.item.randomValueList.push('')
+      this.item.randomSalesCallAdded.push(false)
+      this.item.randomUserNameCallAdded.push(false)
+      this.editingIndex = this.item.randomValueList.length - 1
+      this.$nextTick(() => {
+        const textarea = this.getTextarea(this.editingIndex)
+        if (textarea) {
+          textarea.focus()
+        }
+      })
+    },
+    handleRemove(randomIndex) {
+      if (!this.item.randomValueList || this.item.randomValueList.length <= this.minCount) {
+        return
+      }
+      this.$confirm('确定删除当前文本文本?', '提示', {
+        confirmButtonText: '确定',
+        cancelButtonText: '取消',
+        type: 'warning'
+      }).then(() => {
+        this.item.randomValueList.splice(randomIndex, 1)
+        this.item.randomSalesCallAdded.splice(randomIndex, 1)
+        this.item.randomUserNameCallAdded.splice(randomIndex, 1)
+        if (this.editingIndex === randomIndex) {
+          this.editingIndex = Math.min(randomIndex, this.item.randomValueList.length - 1)
+        } else if (this.editingIndex > randomIndex) {
+          this.editingIndex -= 1
+        }
+      }).catch(() => {})
+    },
+    getTextarea(randomIndex) {
+      const ref = this.$refs[`textarea-${this.refId}-${randomIndex}`]
+      return ref && (Array.isArray(ref) ? ref[0] : ref)?.$refs?.textarea
+    },
+    toggleSalesCall(randomIndex) {
+      this.initFields()
+      const textarea = this.getTextarea(randomIndex)
+      const cursorPosition = textarea ? textarea.selectionStart : (this.item.randomValueList[randomIndex] || '').length
+
+      if (this.item.randomSalesCallAdded[randomIndex]) {
+        this.$set(
+          this.item.randomValueList,
+          randomIndex,
+          (this.item.randomValueList[randomIndex] || '').replace(new RegExp(this.escapeRegExp(this.salesCallTag), 'g'), '')
+        )
+        this.$set(this.item.randomSalesCallAdded, randomIndex, false)
+      } else {
+        const currentValue = this.item.randomValueList[randomIndex] || ''
+        this.$set(
+          this.item.randomValueList,
+          randomIndex,
+          currentValue.slice(0, cursorPosition) + this.salesCallTag + currentValue.slice(cursorPosition)
+        )
+        this.$set(this.item.randomSalesCallAdded, randomIndex, true)
+      }
+    },
+    toggleUserCall(randomIndex) {
+      this.initFields()
+      const textarea = this.getTextarea(randomIndex)
+      const cursorPosition = textarea ? textarea.selectionStart : (this.item.randomValueList[randomIndex] || '').length
+
+      if (this.item.randomUserNameCallAdded[randomIndex]) {
+        this.$set(
+          this.item.randomValueList,
+          randomIndex,
+          (this.item.randomValueList[randomIndex] || '').replace(new RegExp(this.escapeRegExp(this.userCallTag), 'g'), '')
+        )
+        this.$set(this.item.randomUserNameCallAdded, randomIndex, false)
+      } else {
+        const currentValue = this.item.randomValueList[randomIndex] || ''
+        this.$set(
+          this.item.randomValueList,
+          randomIndex,
+          currentValue.slice(0, cursorPosition) + this.userCallTag + currentValue.slice(cursorPosition)
+        )
+        this.$set(this.item.randomUserNameCallAdded, randomIndex, true)
+      }
+    },
+    handleKeydown(event, randomIndex) {
+      if (event.key !== 'Backspace' && event.key !== 'Delete') {
+        return
+      }
+      this.initFields()
+      const value = this.item.randomValueList[randomIndex] || ''
+      const cursorPosition = event.target.selectionStart
+      const tags = [this.salesCallTag, this.userCallTag]
+
+      tags.forEach(tag => {
+        const tagIndex = value.indexOf(tag)
+        if (tagIndex !== -1) {
+          const tagEnd = tagIndex + tag.length
+          if ((event.key === 'Backspace' && cursorPosition > tagIndex && cursorPosition <= tagEnd) ||
+            (event.key === 'Delete' && cursorPosition >= tagIndex && cursorPosition < tagEnd)) {
+            event.preventDefault()
+            this.$set(this.item.randomValueList, randomIndex, value.replace(tag, ''))
+            if (tag === this.salesCallTag) {
+              this.$set(this.item.randomSalesCallAdded, randomIndex, false)
+            }
+            if (tag === this.userCallTag) {
+              this.$set(this.item.randomUserNameCallAdded, randomIndex, false)
+            }
+          }
+        }
+      })
+    },
+    escapeRegExp(text) {
+      return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+    }
+  }
+}
+</script>
+
+<style scoped>
+.random-text-list-editor__summary {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 10px 12px;
+  border: 1px dashed #dcdfe6;
+  border-radius: 4px;
+  background: #f8fafc;
+  cursor: pointer;
+  transition: border-color 0.2s, background 0.2s;
+}
+
+.random-text-list-editor__summary:hover {
+  border-color: #409eff;
+  background: #f0f7ff;
+}
+
+.random-text-list-editor__summary-main {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  color: #606266;
+  font-size: 13px;
+}
+
+.random-text-list-editor__summary-main i {
+  color: #409eff;
+  font-size: 16px;
+}
+
+.random-text-list-editor__summary-main strong {
+  color: #409eff;
+  font-weight: 600;
+}
+
+.random-text-drawer__body {
+  display: flex;
+  flex-direction: column;
+  height: calc(100vh - 140px);
+  padding: 0 20px;
+}
+
+.random-text-drawer__toolbar {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 12px;
+  flex-shrink: 0;
+}
+
+.random-text-drawer__count {
+  color: #909399;
+  font-size: 13px;
+}
+
+.random-text-drawer__list {
+  flex: 0 0 auto;
+  max-height: 240px;
+  overflow-y: auto;
+  border: 1px solid #ebeef5;
+  border-radius: 4px;
+  margin-bottom: 16px;
+}
+
+.random-text-drawer__item {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 8px;
+  padding: 10px 12px;
+  border-bottom: 1px solid #ebeef5;
+  cursor: pointer;
+  transition: background 0.2s;
+}
+
+.random-text-drawer__item:last-child {
+  border-bottom: none;
+}
+
+.random-text-drawer__item:hover {
+  background: #f5f7fa;
+}
+
+.random-text-drawer__item.is-active {
+  background: #ecf5ff;
+  border-left: 3px solid #409eff;
+  padding-left: 9px;
+}
+
+.random-text-drawer__item-main {
+  flex: 1;
+  min-width: 0;
+  display: flex;
+  align-items: center;
+  gap: 10px;
+}
+
+.random-text-drawer__label {
+  flex-shrink: 0;
+  color: #303133;
+  font-size: 13px;
+  font-weight: 500;
+  min-width: 48px;
+}
+
+.random-text-drawer__preview {
+  flex: 1;
+  color: #909399;
+  font-size: 12px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.random-text-drawer__item.is-active .random-text-drawer__preview {
+  color: #606266;
+}
+
+.random-text-drawer__delete {
+  color: #f56c6c;
+  flex-shrink: 0;
+}
+
+.random-text-drawer__editor {
+  flex: 1;
+  min-height: 0;
+  display: flex;
+  flex-direction: column;
+}
+
+.random-text-drawer__editor-title {
+  color: #303133;
+  font-size: 14px;
+  font-weight: 500;
+  margin-bottom: 10px;
+  flex-shrink: 0;
+}
+
+.random-text-drawer__actions {
+  margin-top: 10px;
+  flex-shrink: 0;
+}
+
+.random-text-drawer__actions .el-link + .el-link {
+  margin-left: 20px;
+}
+
+.random-text-drawer__empty-editor {
+  flex: 1;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #c0c4cc;
+  font-size: 13px;
+  border: 1px dashed #ebeef5;
+  border-radius: 4px;
+}
+
+.random-text-drawer__footer {
+  position: absolute;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  padding: 12px 20px;
+  border-top: 1px solid #ebeef5;
+  background: #fff;
+  text-align: right;
+}
+</style>
+
+<style>
+.random-text-drawer .el-drawer__body {
+  padding: 0;
+  overflow: hidden;
+}
+</style>

+ 2 - 0
src/layout/components/Navbar.vue

@@ -119,10 +119,12 @@ export default {
     this.getMsgCount();
     // 启动消息轮询,每30秒检查一次新消息
     this.startMsgPolling();
+    this.$bus.$on('transfer-submitted', this.getMsgCount);
   },
   beforeDestroy() {
     // 组件销毁前清除定时器
     this.stopMsgPolling();
+    this.$bus.$off('transfer-submitted', this.getMsgCount);
   },
   methods: {
     startMsgPolling() {

+ 12 - 0
src/main.js

@@ -68,6 +68,18 @@ Vue.prototype.msgInfo = function (msg) {
   this.$message.info(msg);
 }
 
+/** 企微转接任务已提交:展示 res.msg,引导用户在「转接通知」查看最终结果 */
+Vue.prototype.msgTransferSubmitted = function (msg) {
+  const text = msg || '转接任务已提交,完成后请留意转接通知';
+  this.$message({
+    showClose: true,
+    message: text,
+    type: 'info',
+    duration: 5000
+  });
+  this.$bus.$emit('transfer-submitted');
+}
+
 // 全局事件总线:项目里大量组件使用 this.$bus.$emit / $on,但之前没有挂载,未挂载导致 undefined.$emit 报错
 Vue.prototype.$bus = new Vue()
 

+ 36 - 0
src/utils/transferMsg.js

@@ -0,0 +1,36 @@
+/**
+ * 解析转接通知 content(按【中文原因】分组,不含数字错误码)
+ * 示例:转接成功:2,转接失败:3。失败原因:【当前客户正在转接中】Admin、大碗面;【没有好友关系】张三;
+ */
+export function parseTransferContent(content) {
+  if (!content || typeof content !== 'string') {
+    return null;
+  }
+  if (!/(?:转接|接替)成功:/.test(content)) {
+    return null;
+  }
+  // 旧格式「客户名(错误码)」走纯文本展示
+  if (/(\d+)/.test(content) && !content.includes('【')) {
+    return null;
+  }
+
+  const groups = [];
+  const groupRegex = /【([^】]+)】([^【]*)/g;
+  let match;
+  while ((match = groupRegex.exec(content)) !== null) {
+    const reason = match[1].trim();
+    const names = match[2].replace(/^[::\s]+/, '').replace(/[;;\s]+$/, '').trim();
+    if (reason) {
+      groups.push({ reason, names });
+    }
+  }
+
+  const summaryPart = content.split(/失败原因[::]/)[0] || content;
+  const summary = summaryPart.replace(/[。\s]+$/, '').trim();
+
+  return {
+    summary,
+    groups,
+    hasGroups: groups.length > 0
+  };
+}

+ 246 - 92
src/views/company/companyDept/index.vue

@@ -1,6 +1,6 @@
 <template>
-  <div class="app-container">
-    <el-form :model="queryParams" ref="queryForm" :inline="true" v-show="showSearch">
+  <div class="app-container dept-page">
+    <el-form :model="queryParams" ref="queryForm" :inline="true" v-show="showSearch" class="dept-query-form">
       <el-form-item label="部门名称" prop="deptName">
         <el-input
           v-model="queryParams.deptName"
@@ -29,7 +29,7 @@
     <el-row :gutter="10" class="mb8">
       <el-col :span="1.5">
         <el-button
-         plain
+          plain
           type="primary"
           icon="el-icon-plus"
           size="mini"
@@ -47,18 +47,40 @@
       row-key="deptId"
       default-expand-all
       :tree-props="{children: 'children', hasChildren: 'hasChildren'}"
+      class="dept-table"
     >
-
-      <el-table-column prop="deptId" label="部门编号" width="260"></el-table-column>
-      <el-table-column prop="deptName" label="部门名称" width="260"></el-table-column>
-      <el-table-column prop="orderNum" label="排序" width="200"></el-table-column>
-      <el-table-column prop="status" label="状态" :formatter="statusFormat" width="100"></el-table-column>
-      <el-table-column label="创建时间" align="center" prop="createTime" width="200">
+      <el-table-column prop="deptName" label="部门名称" min-width="220" show-overflow-tooltip />
+      <el-table-column prop="deptId" label="部门编号" width="100" align="center" />
+      <el-table-column prop="orderNum" label="排序" width="70" align="center" />
+      <el-table-column prop="status" label="状态" width="80" align="center">
+        <template slot-scope="scope">
+          <el-tag :type="scope.row.status === '0' ? 'success' : 'info'" size="mini">
+            {{ statusFormat(scope.row) }}
+          </el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="主要小程序" min-width="160" align="center">
+        <template slot-scope="scope">
+          <el-tag v-if="scope.row.miniAppMaster" type="primary" size="mini" effect="plain">
+            {{ getMiniAppName(scope.row.miniAppMaster) }}
+          </el-tag>
+          <span v-else class="text-muted">跟随公司</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="备用小程序" min-width="160" align="center">
+        <template slot-scope="scope">
+          <el-tag v-if="scope.row.miniAppServer" type="warning" size="mini" effect="plain">
+            {{ getMiniAppName(scope.row.miniAppServer) }}
+          </el-tag>
+          <span v-else class="text-muted">跟随公司</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="创建时间" align="center" prop="createTime" width="170">
         <template slot-scope="scope">
           <span>{{ parseTime(scope.row.createTime) }}</span>
         </template>
       </el-table-column>
-      <el-table-column label="操作" align="center" class-name="small-padding fixed-width">
+      <el-table-column label="操作" align="center" width="200" fixed="right">
         <template slot-scope="scope">
           <el-button
             size="mini"
@@ -79,6 +101,7 @@
             size="mini"
             type="text"
             icon="el-icon-delete"
+            class="danger-text"
             @click="handleDelete(scope.row)"
             v-hasPermi="['company:dept:remove']"
           >删除</el-button>
@@ -86,56 +109,112 @@
       </el-table-column>
     </el-table>
 
-    <!-- 添加或修改部门对话框 -->
-    <el-dialog :title="title" :visible.sync="open" width="600px" append-to-body>
-      <el-form ref="form" :model="form" :rules="rules" label-width="80px">
-        <el-row>
-          <el-col :span="24" v-if="form.parentId !== 0">
-            <el-form-item label="上级部门" prop="parentId">
-              <treeselect v-model="form.parentId" :options="deptOptions" :normalizer="normalizer" placeholder="选择上级部门" />
-            </el-form-item>
-          </el-col>
-          <el-col :span="12">
-            <el-form-item label="部门名称" prop="deptName">
-              <el-input v-model="form.deptName" placeholder="请输入部门名称" />
-            </el-form-item>
-          </el-col>
-          <el-col :span="12">
-            <el-form-item label="显示排序" prop="orderNum">
-              <el-input-number v-model="form.orderNum" controls-position="right" :min="0" />
-            </el-form-item>
-          </el-col>
-          <el-col :span="12">
-            <el-form-item label="负责人" prop="leader">
-              <el-input v-model="form.leader" placeholder="请输入负责人" maxlength="20" />
-            </el-form-item>
-          </el-col>
-          <el-col :span="12">
-            <el-form-item label="联系电话" prop="phone">
-              <el-input v-model="form.phone" placeholder="请输入联系电话" maxlength="11" />
-            </el-form-item>
-          </el-col>
-          <el-col :span="12">
-            <el-form-item label="邮箱" prop="email">
-              <el-input v-model="form.email" placeholder="请输入邮箱" maxlength="50" />
-            </el-form-item>
-          </el-col>
-          <el-col :span="12">
-            <el-form-item label="部门状态">
-              <el-radio-group v-model="form.status">
-                <el-radio
-                  v-for="dict in statusOptions"
-                  :key="dict.dictValue"
-                  :label="dict.dictValue"
-                >{{dict.dictLabel}}</el-radio>
-              </el-radio-group>
-            </el-form-item>
-          </el-col>
-        </el-row>
+    <el-dialog :title="title" :visible.sync="open" width="720px" append-to-body class="dept-dialog">
+      <el-form ref="form" :model="form" :rules="rules" label-width="100px" class="dept-form">
+        <div class="form-section">
+          <div class="form-section-title">基本信息</div>
+          <el-row :gutter="20">
+            <el-col :span="24" v-if="form.parentId !== 0">
+              <el-form-item label="上级部门" prop="parentId">
+                <treeselect
+                  v-model="form.parentId"
+                  :options="deptOptions"
+                  :normalizer="normalizer"
+                  placeholder="选择上级部门"
+                />
+              </el-form-item>
+            </el-col>
+            <el-col :span="12">
+              <el-form-item label="部门名称" prop="deptName">
+                <el-input v-model="form.deptName" placeholder="请输入部门名称" />
+              </el-form-item>
+            </el-col>
+            <el-col :span="12">
+              <el-form-item label="显示排序" prop="orderNum">
+                <el-input-number v-model="form.orderNum" controls-position="right" :min="0" style="width: 100%" />
+              </el-form-item>
+            </el-col>
+            <el-col :span="12">
+              <el-form-item label="负责人" prop="leader">
+                <el-input v-model="form.leader" placeholder="请输入负责人" maxlength="20" />
+              </el-form-item>
+            </el-col>
+            <el-col :span="12">
+              <el-form-item label="联系电话" prop="phone">
+                <el-input v-model="form.phone" placeholder="请输入联系电话" maxlength="11" />
+              </el-form-item>
+            </el-col>
+            <el-col :span="12">
+              <el-form-item label="邮箱" prop="email">
+                <el-input v-model="form.email" placeholder="请输入邮箱" maxlength="50" />
+              </el-form-item>
+            </el-col>
+            <el-col :span="12">
+              <el-form-item label="部门状态">
+                <el-radio-group v-model="form.status">
+                  <el-radio
+                    v-for="dict in statusOptions"
+                    :key="dict.dictValue"
+                    :label="dict.dictValue"
+                  >{{ dict.dictLabel }}</el-radio>
+                </el-radio-group>
+              </el-form-item>
+            </el-col>
+          </el-row>
+        </div>
+
+        <div class="form-section">
+          <div class="form-section-title">小程序配置</div>
+          <p class="form-section-tip">不选择时,SOP 发课将使用公司级主备小程序配置</p>
+          <el-row :gutter="20">
+            <el-col :span="24">
+              <el-form-item label="主要小程序">
+                <el-select
+                  v-model="form.miniAppMaster"
+                  placeholder="请选择主要小程序"
+                  clearable
+                  filterable
+                  style="width: 100%"
+                >
+                  <el-option
+                    v-for="item in companyMiniAppList"
+                    :key="item.id"
+                    :label="item.name"
+                    :value="item.appid || item.appId"
+                  >
+                    <span>{{ item.name }}</span>
+                    <span class="option-appid">{{ item.appid || item.appId }}</span>
+                  </el-option>
+                </el-select>
+              </el-form-item>
+            </el-col>
+            <el-col :span="24">
+              <el-form-item label="备用小程序">
+                <el-select
+                  v-model="form.miniAppServer"
+                  placeholder="请选择备用小程序"
+                  clearable
+                  filterable
+                  style="width: 100%"
+                >
+                  <el-option
+                    v-for="item in companyMiniAppList"
+                    :key="'backup-' + item.id"
+                    :label="item.name"
+                    :value="item.appid || item.appId"
+                  >
+                    <span>{{ item.name }}</span>
+                    <span class="option-appid">{{ item.appid || item.appId }}</span>
+                  </el-option>
+                </el-select>
+              </el-form-item>
+            </el-col>
+          </el-row>
+        </div>
       </el-form>
       <div slot="footer" class="dialog-footer">
-        <el-button type="primary" @click="submitForm">确 定</el-button>
         <el-button @click="cancel">取 消</el-button>
+        <el-button type="primary" @click="submitForm">确 定</el-button>
       </div>
     </el-dialog>
   </div>
@@ -143,6 +222,7 @@
 
 <script>
 import { listDept, getDept, delDept, addDept, updateDept, listDeptExcludeChild } from "@/api/company/companyDept";
+import { getCompanyMiniAppList } from "@/api/company/companyConfig";
 import Treeselect from "@riophae/vue-treeselect";
 import "@riophae/vue-treeselect/dist/vue-treeselect.css";
 
@@ -151,28 +231,20 @@ export default {
   components: { Treeselect },
   data() {
     return {
-      // 遮罩层
       loading: true,
-      // 显示搜索条件
       showSearch: true,
-      // 表格树数据
       deptList: [],
-      // 部门树选项
       deptOptions: [],
-      // 弹出层标题
       title: "",
-      // 是否显示弹出层
       open: false,
-      // 状态数据字典
       statusOptions: [],
-      // 查询参数
       queryParams: {
         deptName: undefined,
         status: undefined
       },
-      // 表单参数
       form: {},
-      // 表单校验
+      companyMiniAppList: [],
+      miniAppNameMap: {},
       rules: {
         parentId: [
           { required: true, message: "上级部门不能为空", trigger: "blur" }
@@ -186,7 +258,7 @@ export default {
         email: [
           {
             type: "email",
-            message: "'请输入正确的邮箱地址",
+            message: "请输入正确的邮箱地址",
             trigger: ["blur", "change"]
           }
         ],
@@ -202,12 +274,34 @@ export default {
   },
   created() {
     this.getList();
+    this.loadCompanyMiniAppList();
     this.getDicts("sys_normal_disable").then(response => {
       this.statusOptions = response.data;
     });
   },
   methods: {
-    /** 查询部门列表 */
+    loadCompanyMiniAppList() {
+      getCompanyMiniAppList().then(res => {
+        this.companyMiniAppList = res.data || [];
+        const map = {};
+        this.companyMiniAppList.forEach(item => {
+          const appId = item.appid || item.appId;
+          if (appId) {
+            map[appId] = item.name || appId;
+          }
+        });
+        this.miniAppNameMap = map;
+      }).catch(() => {
+        this.companyMiniAppList = [];
+        this.miniAppNameMap = {};
+      });
+    },
+    getMiniAppName(appId) {
+      if (!appId) {
+        return "";
+      }
+      return this.miniAppNameMap[appId] || appId;
+    },
     getList() {
       this.loading = true;
       listDept(this.queryParams).then(response => {
@@ -215,7 +309,6 @@ export default {
         this.loading = false;
       });
     },
-    /** 转换部门数据结构 */
     normalizer(node) {
       if (node.children && !node.children.length) {
         delete node.children;
@@ -226,16 +319,13 @@ export default {
         children: node.children
       };
     },
-    // 字典状态字典翻译
-    statusFormat(row, column) {
+    statusFormat(row) {
       return this.selectDictLabel(this.statusOptions, row.status);
     },
-    // 取消按钮
     cancel() {
       this.open = false;
       this.reset();
     },
-    // 表单重置
     reset() {
       this.form = {
         deptId: undefined,
@@ -245,44 +335,43 @@ export default {
         leader: undefined,
         phone: undefined,
         email: undefined,
-        status: "0"
+        status: "0",
+        miniAppMaster: undefined,
+        miniAppServer: undefined
       };
       this.resetForm("form");
     },
-    /** 搜索按钮操作 */
     handleQuery() {
       this.getList();
     },
-    /** 重置按钮操作 */
     resetQuery() {
       this.resetForm("queryForm");
       this.handleQuery();
     },
-    /** 新增按钮操作 */
     handleAdd(row) {
       this.reset();
+      this.loadCompanyMiniAppList();
       if (row != undefined) {
         this.form.parentId = row.deptId;
       }
       this.open = true;
       this.title = "添加部门";
       listDept().then(response => {
-	        this.deptOptions = this.handleTree(response.data, "deptId");
+        this.deptOptions = this.handleTree(response.data, "deptId");
       });
     },
-    /** 修改按钮操作 */
     handleUpdate(row) {
       this.reset();
+      this.loadCompanyMiniAppList();
       getDept(row.deptId).then(response => {
         this.form = response.data;
         this.open = true;
         this.title = "修改部门";
       });
       listDeptExcludeChild(row.deptId).then(response => {
-	        this.deptOptions = this.handleTree(response.data, "deptId");
+        this.deptOptions = this.handleTree(response.data, "deptId");
       });
     },
-    /** 提交按钮 */
     submitForm: function() {
       this.$refs["form"].validate(valid => {
         if (valid) {
@@ -306,19 +395,84 @@ export default {
         }
       });
     },
-    /** 删除按钮操作 */
     handleDelete(row) {
       this.$confirm('是否确认删除名称为"' + row.deptName + '"的数据项?', "警告", {
-          confirmButtonText: "确定",
-          cancelButtonText: "取消",
-          type: "warning"
-        }).then(function() {
-          return delDept(row.deptId);
-        }).then(() => {
-          this.getList();
-          this.msgSuccess("删除成功");
-        }).catch(function() {});
+        confirmButtonText: "确定",
+        cancelButtonText: "取消",
+        type: "warning"
+      }).then(function() {
+        return delDept(row.deptId);
+      }).then(() => {
+        this.getList();
+        this.msgSuccess("删除成功");
+      }).catch(function() {});
     }
   }
 };
 </script>
+
+<style scoped lang="scss">
+.dept-page {
+  .dept-table {
+    width: 100%;
+  }
+
+  .text-muted {
+    color: #c0c4cc;
+    font-size: 12px;
+  }
+
+  .danger-text {
+    color: #f56c6c;
+  }
+}
+
+.dept-dialog {
+  ::v-deep .el-dialog__body {
+    padding: 16px 24px 8px;
+  }
+}
+
+.dept-form {
+  .form-section {
+    margin-bottom: 8px;
+
+    &:not(:last-child) {
+      padding-bottom: 4px;
+      border-bottom: 1px solid #ebeef5;
+      margin-bottom: 16px;
+    }
+  }
+
+  .form-section-title {
+    font-size: 14px;
+    font-weight: 600;
+    color: #303133;
+    margin-bottom: 12px;
+    padding-left: 8px;
+    border-left: 3px solid #409eff;
+    line-height: 1;
+  }
+
+  .form-section-tip {
+    margin: 0 0 12px 8px;
+    font-size: 12px;
+    color: #909399;
+    line-height: 1.5;
+  }
+
+  ::v-deep .el-form-item {
+    margin-bottom: 18px;
+  }
+
+  ::v-deep .el-form-item__label {
+    white-space: nowrap;
+  }
+}
+
+.option-appid {
+  float: right;
+  color: #909399;
+  font-size: 12px;
+}
+</style>

+ 104 - 10
src/views/crm/components/msg.vue

@@ -14,10 +14,24 @@
       </div>
       <div class="mst-list">
         <el-timeline   >
-          <el-timeline-item  :timestamp="item.createTime" placement="top" v-for="(item, index) in list">
+          <el-timeline-item  :timestamp="item.createTime" placement="top" v-for="(item, index) in list" :key="item.id || index">
             <el-card>
-              <h4>{{item.title}}</h4>
-              <p>{{item.content}}</p>
+              <h4>{{ item.title }}</h4>
+              <div v-if="item._transferDisplay" class="transfer-msg">
+                <p class="msg-summary">{{ item._transferDisplay.summary }}</p>
+                <div v-if="item._transferDisplay.hasGroups" class="transfer-fail-block">
+                  <div class="fail-reason-title">失败原因:</div>
+                  <div
+                    v-for="(group, gi) in item._transferDisplay.groups"
+                    :key="gi"
+                    class="fail-group-item"
+                  >
+                    <div class="fail-group-reason">{{ group.reason }}</div>
+                    <div class="fail-group-names">{{ group.names }}</div>
+                  </div>
+                </div>
+              </div>
+              <p v-else class="msg-content">{{ item.content }}</p>
               <div><el-button type="text" v-if="item.isRead==0" @click="setRead(item)">标记为已读</el-button></div>
             </el-card>
           </el-timeline-item>
@@ -32,6 +46,7 @@
 <script>
 
 import { setAllRead,getMsg,getMsgList,getMsgCount,setRead } from "@/api/crm/msg";
+import { parseTransferContent } from "@/utils/transferMsg";
 
 export default {
   name: "msg",
@@ -55,18 +70,21 @@ export default {
       total: 0,
       // 产品表格数据
       list: [],
-       queryParams: {
+      queryParams: {
         pageNum: 1,
         pageSize: 10,
       },
+      transferPollTimer: null,
+      transferPollCount: 0,
     };
   },
   created() {
-      
       this.getMsg();
+      this.$bus.$on('transfer-submitted', this.onTransferSubmitted);
   },
-  mounted() {
-   
+  beforeDestroy() {
+      this.$bus.$off('transfer-submitted', this.onTransferSubmitted);
+      this.stopTransferPoll();
   },
   methods: {
     loadMore () {
@@ -111,7 +129,11 @@ export default {
       this.loading = true;
       this.queryParams.type=this.currentTypeId;
       getMsgList(this.queryParams).then(response => {
-        this.list=this.list.concat( response.data.list);
+        const items = (response.data.list || []).map(item => ({
+          ...item,
+          _transferDisplay: parseTransferContent(item.content)
+        }));
+        this.list = this.list.concat(items);
         this.total = response.data.total;
         this.loading = false;
         this.isMore=response.data.hasNextPage;
@@ -122,8 +144,40 @@ export default {
         this.msgType = response.counts;
       });
     },
-    
-    
+    /** 转接任务提交后刷新未读数,并轮询「转接通知」(type=4) 直至结果写入 */
+    onTransferSubmitted() {
+      getMsg().then(response => {
+        this.msgType = response.counts;
+        this.$emit('update-count');
+      });
+      this.startTransferPoll();
+    },
+    startTransferPoll() {
+      this.stopTransferPoll();
+      this.transferPollCount = 0;
+      this.transferPollTimer = setInterval(() => {
+        this.transferPollCount += 1;
+        getMsg().then(response => {
+          this.msgType = response.counts;
+          if (this.currentTypeId === 4) {
+            this.queryParams.pageNum = 1;
+            this.list = [];
+            this.getList();
+          }
+          this.$emit('update-count');
+        });
+        if (this.transferPollCount >= 40) {
+          this.stopTransferPoll();
+        }
+      }, 30000);
+    },
+    stopTransferPoll() {
+      if (this.transferPollTimer) {
+        clearInterval(this.transferPollTimer);
+        this.transferPollTimer = null;
+      }
+      this.transferPollCount = 0;
+    },
   }
 };
 </script>
@@ -147,6 +201,46 @@ export default {
 .mst-list{
   margin: 10px 0px;
 }
+.msg-content {
+  word-break: break-word;
+  white-space: pre-wrap;
+  line-height: 1.6;
+  margin: 0;
+}
+.transfer-msg {
+  line-height: 1.6;
+}
+.msg-summary {
+  margin: 0 0 8px;
+  font-weight: 500;
+  color: #303133;
+}
+.transfer-fail-block {
+  margin-top: 4px;
+}
+.fail-reason-title {
+  color: #606266;
+  margin-bottom: 6px;
+}
+.fail-group-item {
+  margin-bottom: 8px;
+  padding: 8px 10px;
+  background: #fef0f0;
+  border-radius: 4px;
+  border-left: 3px solid #f56c6c;
+}
+.fail-group-item:last-child {
+  margin-bottom: 0;
+}
+.fail-group-reason {
+  font-weight: 500;
+  color: #f56c6c;
+  margin-bottom: 4px;
+}
+.fail-group-names {
+  color: #606266;
+  word-break: break-word;
+}
 
 </style>
 <style >

+ 40 - 10
src/views/qw/externalContact/ExternalContactNameCell.vue

@@ -1,17 +1,35 @@
 <template>
   <span class="external-contact-name-cell">
-    <span class="external-contact-name-text" v-if="isBlacklisted" style="color: red">{{ row.name || '-' }}</span>
+    <span
+      class="external-contact-name-text"
+      v-if="isBlacklisted"
+      style="color: red"
+    >{{ row.name || '-' }}</span>
+    <span
+      class="external-contact-name-text"
+      v-else-if="isRemarked"
+      style="color: #67c23a"
+    >{{ row.name || '-' }}</span>
     <span class="external-contact-name-text" v-else>{{ row.name || '-' }}</span>
     <el-tooltip
       v-if="isBlacklisted"
       placement="top"
-      :disabled="!tooltipContent"
+      :disabled="!hasManageRemark"
     >
-      <div slot="content" class="external-contact-blacklist-tooltip">
-        <div v-if="row.manageRemark" class="external-contact-blacklist-remark">{{ row.manageRemark }}</div>
+      <div slot="content" class="external-contact-manage-tooltip">
+        <div v-if="row.manageRemark" class="external-contact-manage-remark">{{ row.manageRemark }}</div>
       </div>
       <i class="el-icon-warning external-contact-blacklist-icon" />
     </el-tooltip>
+    <el-tooltip
+      v-else-if="isRemarked"
+      placement="top"
+    >
+      <div slot="content" class="external-contact-manage-tooltip">
+        <div class="external-contact-manage-remark">{{ row.manageRemark }}</div>
+      </div>
+      <i class="el-icon-warning external-contact-remark-icon" />
+    </el-tooltip>
   </span>
 </template>
 
@@ -28,8 +46,17 @@ export default {
     isBlacklisted() {
       return this.row && Number(this.row.manageStatus) === 2
     },
-    tooltipContent() {
-      return this.isBlacklisted
+    hasManageRemark() {
+      const remark = this.row && this.row.manageRemark
+      return remark != null && String(remark).trim() !== ''
+    },
+    isRemarked() {
+      return (
+        this.row &&
+        this.row.manageStatus != null &&
+        this.hasManageRemark &&
+        !this.isBlacklisted
+      )
     }
   }
 }
@@ -56,12 +83,15 @@ export default {
   flex-shrink: 0;
 }
 
-.external-contact-blacklist-title {
-  font-weight: 600;
-  margin-bottom: 4px;
+.external-contact-remark-icon {
+  color: #67c23a;
+  margin-left: 4px;
+  font-size: 14px;
+  cursor: pointer;
+  flex-shrink: 0;
 }
 
-.external-contact-blacklist-remark {
+.external-contact-manage-remark {
   line-height: 1.5;
   word-break: break-all;
 }

+ 25 - 0
src/views/qw/externalContact/index.vue

@@ -169,6 +169,16 @@
           />
         </el-select>
       </el-form-item>
+      <el-form-item label="拉黑状态" prop="manageStatus">
+        <el-select v-model="queryParams.manageStatus" placeholder="拉黑状态" clearable size="small">
+          <el-option
+            v-for="dict in manageStatusFilterOptions"
+            :key="dict.dictValue"
+            :label="dict.dictLabel"
+            :value="dict.dictValue"
+          />
+        </el-select>
+      </el-form-item>
       <el-form-item label="转接状态" prop="addWay">
 
         <el-select v-model="queryParams.transferStatus" placeholder="转接状态" clearable size="small">
@@ -481,6 +491,12 @@
           <dict-tag :options="statusOptions" :value="scope.row.status"/>
         </template>
       </el-table-column>
+      <el-table-column label="拉黑状态" align="center" prop="manageStatusName" width="120px">
+        <template slot-scope="scope">
+          <span v-if="scope.row.manageStatusName">{{ scope.row.manageStatusName }}</span>
+          <dict-tag v-else-if="scope.row.manageStatus != null" :options="manageStatusOptions" :value="String(scope.row.manageStatus)"/>
+        </template>
+      </el-table-column>
       <el-table-column label="客户等级" align="center" prop="level" width="120px" >
         <template slot-scope="scope">
           <dict-tag :options="ratingType" :value="scope.row.level"/>
@@ -1102,6 +1118,14 @@ export default {
         {dictLabel:"已绑定",dictValue:'isBindMini'},
         {dictLabel:"未绑定",dictValue:'noBindMini'},
       ],
+      manageStatusFilterOptions: [
+        { dictLabel: "否", dictValue: "1" },
+        { dictLabel: "是", dictValue: "2" }
+      ],
+      manageStatusOptions: [
+        { dictLabel: "正常", dictValue: "1" },
+        { dictLabel: "拉黑提示", dictValue: "2" }
+      ],
       //标签弹窗选择
       tagChange:{
         open:false,
@@ -1236,6 +1260,7 @@ export default {
         corpId: null,
         companyId: null,
         status:null,
+        manageStatus: null,
         transferStatus:null,
         isBind:null,
         isBindMini:null,

+ 25 - 0
src/views/qw/externalContact/myExternalContact.vue

@@ -85,6 +85,16 @@
           />
         </el-select>
       </el-form-item>
+      <el-form-item label="拉黑状态" prop="manageStatus">
+        <el-select v-model="queryParams.manageStatus" placeholder="拉黑状态" clearable size="small">
+          <el-option
+            v-for="dict in manageStatusFilterOptions"
+            :key="dict.dictValue"
+            :label="dict.dictLabel"
+            :value="dict.dictValue"
+          />
+        </el-select>
+      </el-form-item>
       <el-form-item label="项目归属" prop="projectId">
         <el-select v-model="queryParams.projectId" placeholder="请选择项目" clearable size="small" @change="handleProjectChange">
           <el-option
@@ -440,6 +450,12 @@
           <dict-tag :options="statusOptions" :value="scope.row.status"/>
         </template>
       </el-table-column>
+      <el-table-column label="拉黑状态" align="center" prop="manageStatusName" width="120px">
+        <template slot-scope="scope">
+          <span v-if="scope.row.manageStatusName">{{ scope.row.manageStatusName }}</span>
+          <dict-tag v-else-if="scope.row.manageStatus != null" :options="manageStatusOptions" :value="String(scope.row.manageStatus)"/>
+        </template>
+      </el-table-column>
       <el-table-column label="客户等级" align="center" prop="level" width="120px" >
         <template slot-scope="scope">
           <dict-tag :options="ratingType" :value="scope.row.level"/>
@@ -1174,6 +1190,14 @@ export default {
         {dictLabel:"已绑定",dictValue:'isBindMini'},
         {dictLabel:"未绑定",dictValue:'noBindMini'},
       ],
+      manageStatusFilterOptions: [
+        { dictLabel: "否", dictValue: "1" },
+        { dictLabel: "是", dictValue: "2" }
+      ],
+      manageStatusOptions: [
+        { dictLabel: "正常", dictValue: "1" },
+        { dictLabel: "拉黑提示", dictValue: "2" }
+      ],
       isBindActiveName:"all",
       remarkMobiles: [],
       inputVisible: false,
@@ -1287,6 +1311,7 @@ export default {
         corpId: null,
         companyId: null,
         status:null,
+        manageStatus: null,
         transferStatus:null,
         isBind:null,
         isBindMini:null,

+ 34 - 26
src/views/qw/externalContactTransfer/deptTransferIndex.vue

@@ -270,10 +270,21 @@
 
       </el-form>
       <div slot="footer" class="dialog-footer">
-        <el-button type="primary" @click="submitForm">确 定</el-button>
-        <el-button @click="cancel">取 消</el-button>
+        <el-button type="primary" @click="submitForm" :loading="submitLoading || preCheckLoading">确 定</el-button>
+        <el-button @click="cancel" :disabled="submitLoading || preCheckLoading">取 消</el-button>
       </div>
     </el-dialog>
+
+    <transfer-pre-check-result
+      :visible.sync="preCheckOpen"
+      :transferable="preCheckData.transferable"
+      :untransferable="preCheckData.untransferable"
+      :transferable-count="preCheckData.transferableCount"
+      :untransferable-count="preCheckData.untransferableCount"
+      :confirm-loading="submitLoading"
+      @confirm="confirmTransfer"
+      @back="backFromPreCheck"
+    />
   </div>
 </template>
 
@@ -283,9 +294,11 @@ import { listTag, getTag, delTag, addTag, updateTag, exportTag } from "@/api/qw/
 import { qwUserList } from "@/api/qw/user";
 import qwUserSelectOne from '@/views/qw/user/qwUserSelectOne.vue'
 import { getMyQwUserList,getMyQwCompanyList } from "@/api/qw/user";
+import transferPreCheckMixin from '@/views/qw/externalContactTransfer/transferPreCheckMixin';
 
 export default {
   name: "deptTransferIndex",
+  mixins: [transferPreCheckMixin],
   components: { qwUserSelectOne },
   data() {
     return {
@@ -293,6 +306,7 @@ export default {
       loading: true,
       // 导出遮罩层
       exportLoading: false,
+      submitLoading: false,
       // 选中数组
       ids: [],
       myQwCompanyList:[],
@@ -503,30 +517,24 @@ export default {
       }
       return obj;
     },
-    /** 提交按钮 */
-    submitForm() {
-      this.$refs["form"].validate(valid => {
-        if (valid) {
-          const form = {
-            filter: this.filter,
-            addType: 2,
-            userId: this.form.userId,
-            corpId: this.queryParams.corpId,
-            content: this.form.content,
-            needClearTag: 0
-          };
-          if (this.filter) {
-            form.param = this.buildTransferParam();
-          } else {
-            form.ids = this.ids;
-          }
-          transfer(form).then(response => {
-            this.msgSuccess(response.msg);
-            this.open = false;
-            this.getList();
-          });
-        }
-      });
+    buildTransferForm() {
+      const form = {
+        filter: this.filter,
+        addType: 2,
+        userId: this.form.userId,
+        corpId: this.queryParams.corpId,
+        content: this.form.content,
+        needClearTag: 0
+      };
+      if (this.filter) {
+        form.param = this.buildTransferParam();
+      } else {
+        form.ids = this.ids;
+      }
+      return form;
+    },
+    doTransfer(form) {
+      return transfer(form);
     },
     /** 删除按钮操作 */
     handleDelete(row) {

+ 35 - 25
src/views/qw/externalContactTransfer/index.vue

@@ -313,10 +313,21 @@
 
       </el-form>
       <div slot="footer" class="dialog-footer">
-        <el-button type="primary" @click="submitForm">确 定</el-button>
-        <el-button @click="cancel">取 消</el-button>
+        <el-button type="primary" @click="submitForm" :loading="submitLoading || preCheckLoading">确 定</el-button>
+        <el-button @click="cancel" :disabled="submitLoading || preCheckLoading">取 消</el-button>
       </div>
     </el-dialog>
+
+    <transfer-pre-check-result
+      :visible.sync="preCheckOpen"
+      :transferable="preCheckData.transferable"
+      :untransferable="preCheckData.untransferable"
+      :transferable-count="preCheckData.transferableCount"
+      :untransferable-count="preCheckData.untransferableCount"
+      :confirm-loading="submitLoading"
+      @confirm="confirmTransfer"
+      @back="backFromPreCheck"
+    />
   </div>
 </template>
 
@@ -326,9 +337,11 @@ import { listTag, tagGroupList, getTag, delTag, addTag, updateTag, exportTag } f
 import { qwUserList } from "@/api/qw/user";
 import qwUserSelectOne from '@/views/qw/user/qwUserSelectOne.vue'
 import { getMyQwUserList,getMyQwCompanyList } from "@/api/qw/user";
+import transferPreCheckMixin from '@/views/qw/externalContactTransfer/transferPreCheckMixin';
 
 export default {
   name: "ExternalContact",
+  mixins: [transferPreCheckMixin],
   components: { qwUserSelectOne },
   data() {
     return {
@@ -336,6 +349,7 @@ export default {
       loading: true,
       // 导出遮罩层
       exportLoading: false,
+      submitLoading: false,
       // 选中数组
       ids: [],
       myQwCompanyList:[],
@@ -665,29 +679,25 @@ export default {
       return obj;
     },
 
-    /** 提交按钮 */
-    submitForm() {
-      this.$refs["form"].validate(valid => {
-        if (valid) {
-          const form = {
-            filter: this.filter,
-            addType: 0,
-            userId: this.form.userId,
-            corpId: this.queryParams.corpId,
-            content: this.form.content,
-            needClearTag: this.form.needClearTag
-          };
-          if (this.filter) {
-            form.param = this.buildTransferParam();
-          } else {
-            form.ids = this.ids;
-          }
-          transfer(form).then(response => {
-            this.msgSuccess(response.msg);
-            this.open = false;
-            this.getList();
-          });        }
-      });
+    /** 构建转接/前置校验请求体 */
+    buildTransferForm() {
+      const form = {
+        filter: this.filter,
+        addType: 0,
+        userId: this.form.userId,
+        corpId: this.queryParams.corpId,
+        content: this.form.content,
+        needClearTag: this.form.needClearTag
+      };
+      if (this.filter) {
+        form.param = this.buildTransferParam();
+      } else {
+        form.ids = this.ids;
+      }
+      return form;
+    },
+    doTransfer(form) {
+      return transfer(form);
     },
     /** 删除按钮操作 */
     handleDelete(row) {

+ 93 - 0
src/views/qw/externalContactTransfer/transferPreCheckMixin.js

@@ -0,0 +1,93 @@
+import { transferPreCheck } from '@/api/qw/externalContact';
+import TransferPreCheckResult from '@/views/qw/externalContactTransfer/transferPreCheckResult.vue';
+
+export default {
+  components: { TransferPreCheckResult },
+  data() {
+    return {
+      preCheckOpen: false,
+      preCheckLoading: false,
+      preCheckData: {
+        transferable: [],
+        untransferable: [],
+        transferableCount: 0,
+        untransferableCount: 0
+      },
+      pendingTransferForm: null
+    };
+  },
+  methods: {
+    /** 子页面实现:构建前置校验/转接请求体 */
+    buildTransferForm() {
+      throw new Error('buildTransferForm must be implemented');
+    },
+    /** 子页面实现:调用 transfer 或 resignedTransfer */
+    doTransfer(form) {
+      throw new Error('doTransfer must be implemented');
+    },
+    /** 分配弹窗点「确定」:先前置校验 */
+    submitForm() {
+      if (this.beforeTransferSubmit) {
+        this.beforeTransferSubmit();
+      }
+      this.$refs['form'].validate(valid => {
+        if (!valid) {
+          return;
+        }
+        if (this.submitLoading || this.preCheckLoading) {
+          return;
+        }
+        const form = this.buildTransferForm();
+        this.pendingTransferForm = form;
+        this.preCheckLoading = true;
+        this.submitLoading = true;
+        transferPreCheck(form).then(response => {
+          const data = response.data || {};
+          this.preCheckData = {
+            transferable: data.transferable || [],
+            untransferable: data.untransferable || [],
+            transferableCount: data.transferableCount != null ? data.transferableCount : (data.transferable || []).length,
+            untransferableCount: data.untransferableCount != null
+              ? data.untransferableCount
+              : (data.untransferable || []).reduce((sum, group) => sum + (group.customers || []).length, 0)
+          };
+          this.open = false;
+          this.preCheckOpen = true;
+        }).finally(() => {
+          this.preCheckLoading = false;
+          this.submitLoading = false;
+        });
+      });
+    },
+    /** 校验结果弹窗点「确认转接」:仅提交可转接客户 id */
+    confirmTransfer() {
+      const transferable = this.preCheckData.transferable || [];
+      if (!transferable.length) {
+        return this.$message.warning('暂无可转接客户');
+      }
+      if (this.submitLoading) {
+        return;
+      }
+      const ids = transferable.map(item => item.id);
+      const form = Object.assign({}, this.pendingTransferForm, {
+        filter: false,
+        ids
+      });
+      delete form.param;
+      this.submitLoading = true;
+      this.doTransfer(form).then(response => {
+        this.msgTransferSubmitted(response.msg);
+        this.preCheckOpen = false;
+        this.pendingTransferForm = null;
+        this.onTransferSubmitDone && this.onTransferSubmitDone();
+      }).finally(() => {
+        this.submitLoading = false;
+        this.getList();
+      });
+    },
+    backFromPreCheck() {
+      this.preCheckOpen = false;
+      this.open = true;
+    }
+  }
+};

+ 179 - 0
src/views/qw/externalContactTransfer/transferPreCheckResult.vue

@@ -0,0 +1,179 @@
+<template>
+  <el-dialog
+    title="转接校验结果"
+    :visible.sync="dialogVisible"
+    width="920px"
+    append-to-body
+    @close="handleClose"
+  >
+    <el-row :gutter="20" class="precheck-result">
+      <el-col :span="12">
+        <div class="precheck-panel precheck-panel--ok">
+          <div class="precheck-panel-title">可转接({{ transferableCount }}人)</div>
+          <div class="precheck-list">
+            <div
+              v-for="item in transferable"
+              :key="item.id"
+              class="precheck-customer"
+            >{{ customerName(item) }}</div>
+            <div v-if="!transferable.length" class="precheck-empty">暂无可转接客户</div>
+          </div>
+        </div>
+      </el-col>
+      <el-col :span="12">
+        <div class="precheck-panel precheck-panel--fail">
+          <div class="precheck-panel-title">不可转接({{ untransferableCount }}人)</div>
+          <div class="precheck-list">
+            <div
+              v-for="(group, index) in untransferable"
+              :key="index"
+              class="precheck-group"
+            >
+              <div class="precheck-group-reason">{{ group.reason }}</div>
+              <div class="precheck-group-names">{{ formatCustomers(group.customers) }}</div>
+            </div>
+            <div v-if="!untransferable.length" class="precheck-empty">无不可转接客户</div>
+          </div>
+        </div>
+      </el-col>
+    </el-row>
+    <div v-if="!transferableCount" class="precheck-tip">
+      暂无可转接客户,请返回修改接替员工或重新选择客户
+    </div>
+    <div slot="footer" class="dialog-footer">
+      <el-button @click="handleClose" :disabled="confirmLoading">返 回</el-button>
+      <el-button
+        type="primary"
+        :loading="confirmLoading"
+        :disabled="!transferableCount"
+        @click="$emit('confirm')"
+      >确认转接</el-button>
+    </div>
+  </el-dialog>
+</template>
+
+<script>
+export default {
+  name: 'TransferPreCheckResult',
+  props: {
+    visible: {
+      type: Boolean,
+      default: false
+    },
+    transferable: {
+      type: Array,
+      default: () => []
+    },
+    untransferable: {
+      type: Array,
+      default: () => []
+    },
+    transferableCount: {
+      type: Number,
+      default: 0
+    },
+    untransferableCount: {
+      type: Number,
+      default: 0
+    },
+    confirmLoading: {
+      type: Boolean,
+      default: false
+    }
+  },
+  computed: {
+    dialogVisible: {
+      get() {
+        return this.visible;
+      },
+      set(val) {
+        this.$emit('update:visible', val);
+      }
+    }
+  },
+  methods: {
+    customerName(item) {
+      return item.name || item.customerName || item.externalUserId || '-';
+    },
+    formatCustomers(customers) {
+      if (!customers || !customers.length) {
+        return '-';
+      }
+      return customers.map(c => this.customerName(c)).join('、');
+    },
+    handleClose() {
+      this.$emit('update:visible', false);
+      this.$emit('back');
+    }
+  }
+};
+</script>
+
+<style scoped>
+.precheck-result {
+  min-height: 320px;
+}
+.precheck-panel {
+  border: 1px solid #ebeef5;
+  border-radius: 4px;
+  height: 360px;
+  display: flex;
+  flex-direction: column;
+}
+.precheck-panel--ok {
+  border-top: 3px solid #67c23a;
+}
+.precheck-panel--fail {
+  border-top: 3px solid #f56c6c;
+}
+.precheck-panel-title {
+  padding: 12px 16px;
+  font-weight: 600;
+  font-size: 15px;
+  border-bottom: 1px solid #ebeef5;
+  background: #fafafa;
+}
+.precheck-list {
+  flex: 1;
+  overflow-y: auto;
+  padding: 12px 16px;
+}
+.precheck-customer {
+  padding: 6px 0;
+  color: #303133;
+  border-bottom: 1px dashed #ebeef5;
+}
+.precheck-customer:last-child {
+  border-bottom: none;
+}
+.precheck-group {
+  margin-bottom: 12px;
+  padding: 10px;
+  background: #fef0f0;
+  border-radius: 4px;
+}
+.precheck-group:last-child {
+  margin-bottom: 0;
+}
+.precheck-group-reason {
+  font-weight: 500;
+  color: #f56c6c;
+  margin-bottom: 6px;
+}
+.precheck-group-names {
+  color: #606266;
+  line-height: 1.6;
+  word-break: break-word;
+}
+.precheck-empty {
+  color: #909399;
+  text-align: center;
+  padding: 40px 0;
+}
+.precheck-tip {
+  margin-top: 12px;
+  color: #e6a23c;
+  font-size: 13px;
+  text-align: center;
+}
+</style>

+ 36 - 26
src/views/qw/externalContactUnassigned/deptUnassignedIndex.vue

@@ -223,10 +223,21 @@
 
       </el-form>
       <div slot="footer" class="dialog-footer">
-        <el-button type="primary" @click="submitForm">确 定</el-button>
-        <el-button @click="cancel">取 消</el-button>
+        <el-button type="primary" @click="submitForm" :loading="submitLoading || preCheckLoading">确 定</el-button>
+        <el-button @click="cancel" :disabled="submitLoading || preCheckLoading">取 消</el-button>
       </div>
     </el-dialog>
+
+    <transfer-pre-check-result
+      :visible.sync="preCheckOpen"
+      :transferable="preCheckData.transferable"
+      :untransferable="preCheckData.untransferable"
+      :transferable-count="preCheckData.transferableCount"
+      :untransferable-count="preCheckData.untransferableCount"
+      :confirm-loading="submitLoading"
+      @confirm="confirmTransfer"
+      @back="backFromPreCheck"
+    />
   </div>
 </template>
 
@@ -245,10 +256,12 @@ import {
 import { listTag, getTag, delTag, addTag, updateTag, exportTag } from "@/api/qw/tag";
 // import { qwUserList } from "@/api/qw/user";
 import qwUserSelectOne from "@/views/qw/user/qwUserSelectOne.vue";
+import transferPreCheckMixin from '@/views/qw/externalContactTransfer/transferPreCheckMixin';
 import { getMyQwUserList,getMyQwCompanyList } from "@/api/qw/user";
 
 export default {
   name: "deptUnassignedIndex",
+  mixins: [transferPreCheckMixin],
   components:{qwUserSelectOne},
   data() {
     return {
@@ -256,6 +269,7 @@ export default {
       loading: true,
       // 导出遮罩层
       exportLoading: false,
+      submitLoading: false,
       // 选中数组
       ids: [],
       // 非单个禁用
@@ -452,30 +466,26 @@ export default {
       }
       return obj;
     },
-    /** 提交按钮 */
-    submitForm() {
-      this.nickName=null;
-      this.$refs["form"].validate(valid => {
-        if (valid) {
-          const form = {
-            filter: this.filter,
-            addType: 2,
-            userId: this.form.userId,
-            corpId: this.queryParams.corpId,
-            needClearTag: this.form.needClearTag
-          };
-          if (this.filter) {
-            form.param = this.buildTransferParam();
-          } else {
-            form.ids = this.ids;
-          }
-          resignedTransfer(form).then(response => {
-            this.msgSuccess(response.msg);
-            this.open = false;
-            this.getList();
-          });
-        }
-      });
+    beforeTransferSubmit() {
+      this.nickName = null;
+    },
+    buildTransferForm() {
+      const form = {
+        filter: this.filter,
+        addType: 2,
+        userId: this.form.userId,
+        corpId: this.queryParams.corpId,
+        needClearTag: this.form.needClearTag
+      };
+      if (this.filter) {
+        form.param = this.buildTransferParam();
+      } else {
+        form.ids = this.ids;
+      }
+      return form;
+    },
+    doTransfer(form) {
+      return resignedTransfer(form);
     },
     /** 删除按钮操作 */
     handleDelete(row) {

+ 41 - 29
src/views/qw/externalContactUnassigned/index.vue

@@ -237,10 +237,21 @@
 
       </el-form>
       <div slot="footer" class="dialog-footer">
-        <el-button type="primary" @click="submitForm">确 定</el-button>
-        <el-button @click="cancel">取 消</el-button>
+        <el-button type="primary" @click="submitForm" :loading="submitLoading || preCheckLoading">确 定</el-button>
+        <el-button @click="cancel" :disabled="submitLoading || preCheckLoading">取 消</el-button>
       </div>
     </el-dialog>
+
+    <transfer-pre-check-result
+      :visible.sync="preCheckOpen"
+      :transferable="preCheckData.transferable"
+      :untransferable="preCheckData.untransferable"
+      :transferable-count="preCheckData.transferableCount"
+      :untransferable-count="preCheckData.untransferableCount"
+      :confirm-loading="submitLoading"
+      @confirm="confirmTransfer"
+      @back="backFromPreCheck"
+    />
   </div>
 </template>
 
@@ -250,9 +261,11 @@ import { listTag, getTag, delTag, addTag, updateTag, exportTag } from "@/api/qw/
 import { qwUserList,userList } from "@/api/qw/user";
 import qwUserSelectOne from "@/views/qw/user/qwUserSelectOne.vue";
 import { getMyQwUserList,getMyQwCompanyList } from "@/api/qw/user";
+import transferPreCheckMixin from '@/views/qw/externalContactTransfer/transferPreCheckMixin';
 
 export default {
   name: "ExternalContact",
+  mixins: [transferPreCheckMixin],
   components:{qwUserSelectOne},
   data() {
     return {
@@ -260,6 +273,7 @@ export default {
       loading: true,
       // 导出遮罩层
       exportLoading: false,
+      submitLoading: false,
       // 选中数组
       ids: [],
       // 非单个禁用
@@ -497,33 +511,31 @@ export default {
       this.title = "分配该员工所有客户";
 
     },
-    /** 提交按钮 */
-    submitForm() {
-      this.nickName=null;
-      this.$refs["form"].validate(valid => {
-        if (valid) {
-          const form = {
-            qwUserName: this.qwUserName,
-            type: this.type,
-            filter: this.filter,
-            addType: 0,
-            userId: this.form.userId,
-            corpId: this.queryParams.corpId,
-            needClearTag: this.form.needClearTag
-          };
-          if (this.filter) {
-            form.param = this.buildTransferParam();
-          } else {
-            form.ids = this.ids;
-          }
-          resignedTransfer(form).then(response => {
-            this.msgSuccess(response.msg);
-            this.open = false;
-            this.getList();
-          });
-        }
-      });
-      this.qwUserName=null;
+    beforeTransferSubmit() {
+      this.nickName = null;
+    },
+    onTransferSubmitDone() {
+      this.qwUserName = null;
+    },
+    buildTransferForm() {
+      const form = {
+        qwUserName: this.qwUserName,
+        type: this.type,
+        filter: this.filter,
+        addType: 0,
+        userId: this.form.userId,
+        corpId: this.queryParams.corpId,
+        needClearTag: this.form.needClearTag
+      };
+      if (this.filter) {
+        form.param = this.buildTransferParam();
+      } else {
+        form.ids = this.ids;
+      }
+      return form;
+    },
+    doTransfer(form) {
+      return resignedTransfer(form);
     },
     /** 删除按钮操作 */
     handleDelete(row) {

+ 154 - 32
src/views/qw/sopTemp/updateSopTemp.vue

@@ -335,31 +335,60 @@
                                           </div>
                                         </el-form-item>
                                         <el-form-item label="内容">
-                                          <el-input :disabled="formType == 3 || !roles.includes('edit_sop_temp_content')" v-if="setList.contentType == 1 || setList.contentType == 15"
-                                                    v-model="setList.value"
-                                                    type="textarea" :rows="3" placeholder="内容"
-                                                    style="width: 90%;margin-top: 10px;"
-                                                    @keydown.native="handleKeydown($event, index, contentIndex, setIndex)"
-                                                    :ref="`textarea-${index}-${contentIndex}-${setIndex}`"
-                                          />
-
-                                          <!-- 修改按钮部分 -->
-                                          <el-link
-                                            v-if="(setList.contentType == 1 || setList.contentType == 15) && roles.includes('edit_sop_temp_content')"
-                                            type="primary"
-                                            @click="toggleSalesCall(index, contentIndex, setIndex)"
-                                            style="margin-top: 10px;"
-                                          >
-                                            {{ setList.isSalesCallAdded ? '移除#销售称呼#' : '添加#销售称呼#' }}
-                                          </el-link>
-                                          <el-link
-                                            v-if="(setList.contentType == 1 || setList.contentType == 15) && roles.includes('edit_sop_temp_content')"
-                                            type="primary"
-                                            @click="toggleUserNameCall(index, contentIndex, setIndex)"
-                                            style="margin-top: 10px; margin-left: 20px"
-                                          >
-                                            {{ setList.isUserNameCallAdded ? '移除#客户称呼#' : '添加#客户称呼#' }}
-                                          </el-link>
+                                          <div v-if="setList.contentType == 1 || setList.contentType == 15">
+                                            <div style="display: flex; align-items: center; margin-bottom: 10px;">
+                                              <span style="margin-right: 10px;">随机发送</span>
+                                              <el-switch
+                                                v-model="setList.isRandomSend"
+                                                :disabled="formType == 3 || !roles.includes('edit_sop_temp_content')"
+                                                active-color="#13ce66"
+                                                inactive-color="#DCDFE6"
+                                                :active-value="1"
+                                                :inactive-value="0"
+                                                @change="handleRandomSendChange(setList)"
+                                              />
+                                              <span style="color: #999; font-size: 12px; margin-left: 10px;">
+                                                开启后可为同一内容配置多条文案,每个客户随机发送其中一条
+                                              </span>
+                                            </div>
+
+                                            <RandomTextListEditor
+                                              v-if="setList.isRandomSend == 1"
+                                              :item="setList"
+                                              :disabled="formType == 3 || !roles.includes('edit_sop_temp_content')"
+                                              :ref-id="`temp-${index}-${contentIndex}-${setIndex}`"
+                                              sales-call-tag="#销售称呼#"
+                                              user-call-tag="#客户称呼#"
+                                            />
+
+                                            <template v-else>
+                                              <el-input :disabled="formType == 3 || !roles.includes('edit_sop_temp_content')"
+                                                        v-model="setList.value"
+                                                        type="textarea" :rows="3" placeholder="内容"
+                                                        style="width: 90%;margin-top: 10px;"
+                                                        @keydown.native="handleKeydown($event, index, contentIndex, setIndex)"
+                                                        :ref="`textarea-${index}-${contentIndex}-${setIndex}`"
+                                              />
+
+                                              <!-- 修改按钮部分 -->
+                                              <el-link
+                                                v-if="roles.includes('edit_sop_temp_content')"
+                                                type="primary"
+                                                @click="toggleSalesCall(index, contentIndex, setIndex)"
+                                                style="margin-top: 10px;"
+                                              >
+                                                {{ setList.isSalesCallAdded ? '移除#销售称呼#' : '添加#销售称呼#' }}
+                                              </el-link>
+                                              <el-link
+                                                v-if="roles.includes('edit_sop_temp_content')"
+                                                type="primary"
+                                                @click="toggleUserNameCall(index, contentIndex, setIndex)"
+                                                style="margin-top: 10px; margin-left: 20px"
+                                              >
+                                                {{ setList.isUserNameCallAdded ? '移除#客户称呼#' : '添加#客户称呼#' }}
+                                              </el-link>
+                                            </template>
+                                          </div>
 
                                           <ImageUpload :disabled="formType == 3 || !roles.includes('edit_sop_temp_content')" v-if="setList.contentType == 2 "
                                                        v-model="setList.imgUrl"
@@ -922,13 +951,14 @@ import {listToLiveNoEnd} from "@/api/live/live";
 import ImageUpload from "@/views/qw/sop/ImageUpload";
 import userVideo from "@/views/qw/userVideo/userVideo.vue";
 import {listReward} from "@/api/qw/luckyBag";
+import RandomTextListEditor from "@/components/RandomTextListEditor/index.vue";
 import {
   getRoles,
 } from "@/api/qw/sop";
 
 export default {
   name: "updateSopTemp",
-  components: {ImageUpload, userVideo, draggable},
+  components: {ImageUpload, userVideo, draggable, RandomTextListEditor},
   data() {
     return {
       queryParams1: {
@@ -1324,6 +1354,9 @@ export default {
               this.courseChange(this.setting[index].content[j], index, j);
             }
           }
+          if (this.setting[index].content[j].setting) {
+            this.setting[index].content[j].setting.forEach(setList => this.normalizeSettingRandomFields(setList));
+          }
         }
       });
     },
@@ -1360,6 +1393,9 @@ export default {
               this.courseChange(this.setting[index].content[j], index, j);
             }
           }
+          if (this.setting[index].content[j].setting) {
+            this.setting[index].content[j].setting.forEach(setList => this.normalizeSettingRandomFields(setList));
+          }
         }
       });
     },
@@ -1432,6 +1468,7 @@ export default {
         e.settingList = e.setting;
         // 在序列化之前,清理 null 值,避免后端接收到字符串 "null"
         e.settingList.forEach(setting => {
+          this.prepareSettingForSave(setting);
           // 将所有值为 null 的字段删除或设置为 undefined
           Object.keys(setting).forEach(key => {
             if (setting[key] === null) {
@@ -1521,9 +1558,18 @@ export default {
 
           if (data.content[j].type != 4 && data.content[j].type != 5) {
             for (let k = 0; k < data.content[j].setting.length; k++) {
-              if (data.content[j].setting[k].contentType == 1 && (data.content[j].setting[k].value == null || data.content[j].setting[k].value == "")) {
-                this.$message.error("内容不能为空")
-                return false;
+              if (data.content[j].setting[k].contentType == 1) {
+                const settingItem = data.content[j].setting[k];
+                if (settingItem.isRandomSend == 1) {
+                  const validRandomValues = (settingItem.randomValueList || []).filter(item => item != null && item.trim() !== '');
+                  if (validRandomValues.length < 2) {
+                    this.$message.error("开启随机发送后,至少需要填写2条文案")
+                    return false;
+                  }
+                } else if (settingItem.value == null || settingItem.value == "") {
+                  this.$message.error("内容不能为空")
+                  return false;
+                }
               }
               if (data.content[j].setting[k].contentType == 2 && (data.content[j].setting[k].imgUrl == null || data.content[j].setting[k].imgUrl == "")) {
                 this.$message.error("图片不能为空")
@@ -1592,9 +1638,17 @@ export default {
                 this.$message.error("客服时间不能为空");
                 return false;
               }
-              if (set.contentType == 1 && (set.value == null || set.value == "")) {
-                this.$message.error("内容不能为空")
-                return false;
+              if (set.contentType == 1) {
+                if (set.isRandomSend == 1) {
+                  const validRandomValues = (set.randomValueList || []).filter(item => item != null && item.trim() !== '');
+                  if (validRandomValues.length < 2) {
+                    this.$message.error("开启随机发送后,至少需要填写2条文案")
+                    return false;
+                  }
+                } else if (set.value == null || set.value == "") {
+                  this.$message.error("内容不能为空")
+                  return false;
+                }
               }
               if (set.contentType == 2 && (set.imgUrl == null || set.imgUrl == "")) {
                 this.$message.error("图片不能为空")
@@ -1975,6 +2029,73 @@ export default {
         textarea.setSelectionRange(newCursorPos, newCursorPos);
       });
     },
+    initRandomSendFields(setList) {
+      if (!setList) {
+        return;
+      }
+      if (setList.isRandomSend === undefined || setList.isRandomSend === null) {
+        this.$set(setList, 'isRandomSend', 0);
+      }
+      if (!Array.isArray(setList.randomValueList)) {
+        const initialValue = setList.value || '';
+        this.$set(setList, 'randomValueList', initialValue ? [initialValue] : ['', '']);
+      }
+      if (!Array.isArray(setList.randomSalesCallAdded)) {
+        this.$set(setList, 'randomSalesCallAdded', setList.randomValueList.map(() => false));
+      }
+      if (!Array.isArray(setList.randomUserNameCallAdded)) {
+        this.$set(setList, 'randomUserNameCallAdded', setList.randomValueList.map(() => false));
+      }
+    },
+    handleRandomSendChange(setList) {
+      this.initRandomSendFields(setList);
+      if (setList.isRandomSend == 1) {
+        if (!setList.value && setList.randomValueList.every(item => !item)) {
+          this.$set(setList, 'randomValueList', ['', '']);
+          this.$set(setList, 'randomSalesCallAdded', [false, false]);
+          this.$set(setList, 'randomUserNameCallAdded', [false, false]);
+        } else if (setList.value && setList.randomValueList.every(item => !item)) {
+          this.$set(setList, 'randomValueList', [setList.value, '']);
+          this.$set(setList, 'randomSalesCallAdded', [false, false]);
+          this.$set(setList, 'randomUserNameCallAdded', [false, false]);
+        } else if (setList.randomValueList.length < 2) {
+          setList.randomValueList.push('');
+          setList.randomSalesCallAdded.push(false);
+          setList.randomUserNameCallAdded.push(false);
+        }
+      } else if (setList.randomValueList && setList.randomValueList.length > 0) {
+        const firstValue = setList.randomValueList.find(item => item && item.trim()) || setList.randomValueList[0] || '';
+        this.$set(setList, 'value', firstValue);
+      }
+    },
+    normalizeSettingRandomFields(setting) {
+      if (!setting || (setting.contentType != 1 && setting.contentType != '1' && setting.contentType != 15 && setting.contentType != '15')) {
+        return;
+      }
+      this.initRandomSendFields(setting);
+      if (setting.isRandomSend == 1 && setting.randomValueList.length < 2) {
+        while (setting.randomValueList.length < 2) {
+          setting.randomValueList.push('');
+          setting.randomSalesCallAdded.push(false);
+          setting.randomUserNameCallAdded.push(false);
+        }
+      }
+    },
+    prepareSettingForSave(setting) {
+      if (!setting) {
+        return;
+      }
+      if (setting.isRandomSend == 1 && Array.isArray(setting.randomValueList)) {
+        setting.randomValueList = setting.randomValueList
+          .map(item => (item == null ? '' : item.trim()))
+          .filter(item => item !== '');
+        if (setting.randomValueList.length > 0) {
+          setting.value = setting.randomValueList[0];
+        }
+      }
+      delete setting.randomSalesCallAdded;
+      delete setting.randomUserNameCallAdded;
+    },
     handleKeydown(event, itemIndex, contentIndex, setIndex) {
 
       const setItem = this.setting[itemIndex].content[contentIndex].setting[setIndex];
@@ -2308,6 +2429,7 @@ export default {
                 }
                 if (content && Array.isArray(content.setting)) {
                   content.setting.forEach(setList => {
+                    this.normalizeSettingRandomFields(setList);
                     if (setList && !Object.hasOwn(setList, 'isSalesCallAdded')) {
                       this.$set(setList, 'isSalesCallAdded', false);
                     }

+ 112 - 32
src/views/qw/sopUserLogsInfo/sendMsgOpenTool.vue

@@ -132,33 +132,48 @@
                         </el-form-item>
                       </el-card>
                     </div>
-                    <el-input
-                      v-if="item.contentType == 1 || item.contentType == 15"
-                      v-model="item.value"
-                      type="textarea"
-                      :rows="3"
-                      placeholder="内容"
-                      style="width: 90%; margin-top: 10px;"
-                      @keydown.native="handleKeydown($event, index)"
-                      :ref="`textarea-${index}`"
-                    >
-                    </el-input>
-                    <el-link
-                      v-if="item.contentType == 1 || item.contentType == 15"
-                      type="primary"
-                      @click="toggleSalesCall(index)"
-                      style="margin-top: 10px;"
-                    >
-                      {{ item.isSalesCallAdded ? '移除#销售称呼#' : '添加#销售称呼#' }}
-                    </el-link>
-                    <el-link
-                      v-if="item.contentType == 1 || item.contentType == 15"
-                      type="primary"
-                      @click="toggleSalesCallCustomer(index)"
-                      style="margin-top: 10px;margin-left: 2%"
-                    >
-                      {{ item.isSalesCallCustomerAdded ? '移除#客户称呼#' : '添加#客户称呼#' }}
-                    </el-link>
+                    <div v-if="item.contentType == 1 || item.contentType == 15">
+                      <div style="display: flex; align-items: center; margin-bottom: 10px;">
+                        <span style="margin-right: 10px;">随机发送</span>
+                        <el-switch
+                          v-model="item.isRandomSend"
+                          active-color="#13ce66"
+                          inactive-color="#DCDFE6"
+                          :active-value="1"
+                          :inactive-value="0"
+                          @change="handleRandomSendChange(item)"
+                        />
+                        <span style="color: #999; font-size: 12px; margin-left: 10px;">
+                          开启后可为同一内容配置多条文案,每个客户随机发送其中一条
+                        </span>
+                      </div>
+
+                      <RandomTextListEditor
+                        v-if="item.isRandomSend == 1"
+                        :item="item"
+                        :ref-id="`setting-${index}`"
+                        sales-call-tag="#销售称呼#"
+                        user-call-tag="#客户称呼#"
+                      />
+
+                      <template v-else>
+                        <el-input
+                          v-model="item.value"
+                          type="textarea"
+                          :rows="3"
+                          placeholder="内容"
+                          style="width: 90%; margin-top: 10px;"
+                          @keydown.native="handleKeydown($event, index)"
+                          :ref="`textarea-${index}`"
+                        />
+                        <el-link type="primary" @click="toggleSalesCall(index)" style="margin-top: 10px;">
+                          {{ item.isSalesCallAdded ? '移除#销售称呼#' : '添加#销售称呼#' }}
+                        </el-link>
+                        <el-link type="primary" @click="toggleSalesCallCustomer(index)" style="margin-top: 10px;margin-left: 2%">
+                          {{ item.isSalesCallCustomerAdded ? '移除#客户称呼#' : '添加#客户称呼#' }}
+                        </el-link>
+                      </template>
+                    </div>
 
 
                     <ImageUpload v-if="item.contentType == 2 " v-model="item.imgUrl" type="image" :num="1"  :width="150" :height="150" />
@@ -417,11 +432,12 @@ import {courseList, videoList} from "@/api/qw/sop";
 import userVideo from "@/views/qw/userVideo/userVideo";
 import {listToLiveNoEnd} from "@/api/live/live";
 import {listReward} from "@/api/qw/luckyBag";
+import RandomTextListEditor from "@/components/RandomTextListEditor/index.vue";
 
 
 export default {
   name: "sendMsgOpenTool",
-  components: {ImageUpload,userVideo},
+  components: {ImageUpload, userVideo, RandomTextListEditor},
   data() {
     return {
       queryParams1: {
@@ -500,7 +516,7 @@ export default {
       },
       // 表单参数
       form: {},
-      setting:[{contentType:'1', value: '',}],
+      setting:[{contentType:'1', value: '', isRandomSend: 0}],
       msgForm:{
         liveId:null,
         videoId:null,
@@ -727,6 +743,7 @@ export default {
       const newSetting = {
         contentType:'1',
         value: '',
+        isRandomSend: 0,
       };
       // 将新设置项添加到 content.setting 数组中
       this.setting.push(newSetting);
@@ -853,6 +870,60 @@ export default {
         textarea.setSelectionRange(cursorPosition, cursorPosition);
       });
     },
+    initRandomSendFields(item) {
+      if (!item) {
+        return;
+      }
+      if (item.isRandomSend === undefined || item.isRandomSend === null) {
+        this.$set(item, 'isRandomSend', 0);
+      }
+      if (!Array.isArray(item.randomValueList)) {
+        const initialValue = item.value || '';
+        this.$set(item, 'randomValueList', initialValue ? [initialValue] : ['', '']);
+      }
+      if (!Array.isArray(item.randomSalesCallAdded)) {
+        this.$set(item, 'randomSalesCallAdded', item.randomValueList.map(() => false));
+      }
+      if (!Array.isArray(item.randomUserNameCallAdded)) {
+        this.$set(item, 'randomUserNameCallAdded', item.randomValueList.map(() => false));
+      }
+    },
+    handleRandomSendChange(item) {
+      this.initRandomSendFields(item);
+      if (item.isRandomSend == 1) {
+        if (!item.value && item.randomValueList.every(v => !v)) {
+          this.$set(item, 'randomValueList', ['', '']);
+          this.$set(item, 'randomSalesCallAdded', [false, false]);
+          this.$set(item, 'randomUserNameCallAdded', [false, false]);
+        } else if (item.value && item.randomValueList.every(v => !v)) {
+          this.$set(item, 'randomValueList', [item.value, '']);
+          this.$set(item, 'randomSalesCallAdded', [false, false]);
+          this.$set(item, 'randomUserNameCallAdded', [false, false]);
+        } else if (item.randomValueList.length < 2) {
+          item.randomValueList.push('');
+          item.randomSalesCallAdded.push(false);
+          item.randomUserNameCallAdded.push(false);
+        }
+      } else if (item.randomValueList && item.randomValueList.length > 0) {
+        const firstValue = item.randomValueList.find(v => v && v.trim()) || item.randomValueList[0] || '';
+        this.$set(item, 'value', firstValue);
+      }
+    },
+    prepareSettingForSave(item) {
+      if (!item) {
+        return;
+      }
+      if (item.isRandomSend == 1 && Array.isArray(item.randomValueList)) {
+        item.randomValueList = item.randomValueList
+          .map(v => (v == null ? '' : v.trim()))
+          .filter(v => v !== '');
+        if (item.randomValueList.length > 0) {
+          item.value = item.randomValueList[0];
+        }
+      }
+      delete item.randomSalesCallAdded;
+      delete item.randomUserNameCallAdded;
+    },
 
     handleContentTypeChange() {
       //如果是链接的才上
@@ -934,6 +1005,7 @@ export default {
 
 
           if (this.msgForm.draftStrategy==1){
+            this.setting.forEach(item => this.prepareSettingForSave(item));
             this.msgForm.setting=JSON.stringify(this.setting)
 
         let jsonSetting = JSON.parse(this.msgForm.setting);
@@ -969,8 +1041,16 @@ export default {
             }
 
             for (let i = 0; i < this.setting.length; i++) {
-              if (this.setting[i].contentType == 1 && (this.setting[i].value == null || this.setting[i].value == "")) {
-                return this.$message.error("内容不能为空")
+              if (this.setting[i].contentType == 1) {
+                this.initRandomSendFields(this.setting[i]);
+                if (this.setting[i].isRandomSend == 1) {
+                  const validRandomValues = (this.setting[i].randomValueList || []).filter(v => v != null && v.trim() !== '');
+                  if (validRandomValues.length < 2) {
+                    return this.$message.error("开启随机发送后,至少需要填写2条文案");
+                  }
+                } else if (this.setting[i].value == null || this.setting[i].value == "") {
+                  return this.$message.error("内容不能为空");
+                }
               }
               if (this.setting[i].contentType == 2 && (this.setting[i].imgUrl == null || this.setting[i].imgUrl == "")) {
                 return this.$message.error("图片不能为空")
@@ -1041,7 +1121,7 @@ export default {
           sendMsgSopType(this.msgForm).then(response => {
             this.msgSuccess("一键群发成功");
             loading.close();
-            this.setting=[];
+            this.setting=[{contentType:'1', value: '', isRandomSend: 0}];
             this.resetSendMsgSop();
           }).finally(()=>{
             loading.close();

+ 112 - 32
src/views/qw/sopUserLogsInfo/sopUserLogsInfoDetails.vue

@@ -328,33 +328,48 @@
                     </el-radio-group>
                   </el-form-item>
                   <el-form-item label="内容" style="margin-bottom: 2%" >
-                    <el-input
-                      v-if="item.contentType == 1 || item.contentType == 15"
-                      v-model="item.value"
-                      type="textarea"
-                      :rows="3"
-                      placeholder="内容"
-                      style="width: 90%; margin-top: 10px;"
-                      @keydown.native="handleKeydown($event, index)"
-                      :ref="`textarea-${index}`"
-                    >
-                    </el-input>
-                    <el-link
-                      v-if="item.contentType == 1 || item.contentType == 15"
-                      type="primary"
-                      @click="toggleSalesCall(index)"
-                      style="margin-top: 10px;"
-                    >
-                      {{ item.isSalesCallAdded ? '移除#客服称呼#' : '添加#客服称呼#' }}
-                    </el-link>
-                    <el-link
-                      v-if="item.contentType == 1 || item.contentType == 15"
-                      type="primary"
-                      @click="toggleSalesCallCustomer(index)"
-                      style="margin-top: 10px;margin-left: 2%"
-                    >
-                      {{ item.isSalesCallCustomerAdded ? '移除#客户称呼#' : '添加#客户称呼#' }}
-                    </el-link>
+                    <div v-if="item.contentType == 1 || item.contentType == 15">
+                      <div style="display: flex; align-items: center; margin-bottom: 10px;">
+                        <span style="margin-right: 10px;">随机发送</span>
+                        <el-switch
+                          v-model="item.isRandomSend"
+                          active-color="#13ce66"
+                          inactive-color="#DCDFE6"
+                          :active-value="1"
+                          :inactive-value="0"
+                          @change="handleRandomSendChange(item)"
+                        />
+                        <span style="color: #999; font-size: 12px; margin-left: 10px;">
+                          开启后可为同一内容配置多条文案,每个客户随机发送其中一条
+                        </span>
+                      </div>
+
+                      <RandomTextListEditor
+                        v-if="item.isRandomSend == 1"
+                        :item="item"
+                        :ref-id="`setting-${index}`"
+                        sales-call-tag="#客服称呼#"
+                        user-call-tag="#客户称呼#"
+                      />
+
+                      <template v-else>
+                        <el-input
+                          v-model="item.value"
+                          type="textarea"
+                          :rows="3"
+                          placeholder="内容"
+                          style="width: 90%; margin-top: 10px;"
+                          @keydown.native="handleKeydown($event, index)"
+                          :ref="`textarea-${index}`"
+                        />
+                        <el-link type="primary" @click="toggleSalesCall(index)" style="margin-top: 10px;">
+                          {{ item.isSalesCallAdded ? '移除#客服称呼#' : '添加#客服称呼#' }}
+                        </el-link>
+                        <el-link type="primary" @click="toggleSalesCallCustomer(index)" style="margin-top: 10px;margin-left: 2%">
+                          {{ item.isSalesCallCustomerAdded ? '移除#客户称呼#' : '添加#客户称呼#' }}
+                        </el-link>
+                      </template>
+                    </div>
 
 
                     <ImageUpload v-if="item.contentType == 2 " v-model="item.imgUrl" type="image" :num="1"  :width="150" :height="150" />
@@ -689,12 +704,13 @@ import {listTag} from "@/api/qw/tag";
 import {searchTags} from "../../../api/qw/tag";
 import userVideo from "@/views/qw/userVideo/userVideo.vue";
 import PaginationMore from "../../../components/PaginationMore/index.vue";
+import RandomTextListEditor from "@/components/RandomTextListEditor/index.vue";
 import {listToLiveNoEnd} from "@/api/live/live";
 import {listReward} from "@/api/qw/luckyBag";
 
 export default {
   name: "sopUserLogsInfoDetails",
-  components: {PaginationMore, userVideo, ImageUpload},
+  components: {PaginationMore, userVideo, ImageUpload, RandomTextListEditor},
   data() {
     return {
       selectExTags: [], // 排除标签选择数组
@@ -814,7 +830,7 @@ export default {
       // 表单参数
       form: {},
       updateLogsInfoFrom:{},
-      setting:[{contentType:'1', value: '',}],
+      setting:[{contentType:'1', value: '', isRandomSend: 0}],
       msgForm:{
         liveId:null,
         // liveMsgType:null,
@@ -1299,6 +1315,7 @@ export default {
       const newSetting = {
         contentType:'1',
         value: '',
+        isRandomSend: 0,
       };
       // 将新设置项添加到 content.setting 数组中
       this.setting.push(newSetting);
@@ -1425,6 +1442,60 @@ export default {
         textarea.setSelectionRange(cursorPosition, cursorPosition);
       });
     },
+    initRandomSendFields(item) {
+      if (!item) {
+        return;
+      }
+      if (item.isRandomSend === undefined || item.isRandomSend === null) {
+        this.$set(item, 'isRandomSend', 0);
+      }
+      if (!Array.isArray(item.randomValueList)) {
+        const initialValue = item.value || '';
+        this.$set(item, 'randomValueList', initialValue ? [initialValue] : ['', '']);
+      }
+      if (!Array.isArray(item.randomSalesCallAdded)) {
+        this.$set(item, 'randomSalesCallAdded', item.randomValueList.map(() => false));
+      }
+      if (!Array.isArray(item.randomUserNameCallAdded)) {
+        this.$set(item, 'randomUserNameCallAdded', item.randomValueList.map(() => false));
+      }
+    },
+    handleRandomSendChange(item) {
+      this.initRandomSendFields(item);
+      if (item.isRandomSend == 1) {
+        if (!item.value && item.randomValueList.every(v => !v)) {
+          this.$set(item, 'randomValueList', ['', '']);
+          this.$set(item, 'randomSalesCallAdded', [false, false]);
+          this.$set(item, 'randomUserNameCallAdded', [false, false]);
+        } else if (item.value && item.randomValueList.every(v => !v)) {
+          this.$set(item, 'randomValueList', [item.value, '']);
+          this.$set(item, 'randomSalesCallAdded', [false, false]);
+          this.$set(item, 'randomUserNameCallAdded', [false, false]);
+        } else if (item.randomValueList.length < 2) {
+          item.randomValueList.push('');
+          item.randomSalesCallAdded.push(false);
+          item.randomUserNameCallAdded.push(false);
+        }
+      } else if (item.randomValueList && item.randomValueList.length > 0) {
+        const firstValue = item.randomValueList.find(v => v && v.trim()) || item.randomValueList[0] || '';
+        this.$set(item, 'value', firstValue);
+      }
+    },
+    prepareSettingForSave(item) {
+      if (!item) {
+        return;
+      }
+      if (item.isRandomSend == 1 && Array.isArray(item.randomValueList)) {
+        item.randomValueList = item.randomValueList
+          .map(v => (v == null ? '' : v.trim()))
+          .filter(v => v !== '');
+        if (item.randomValueList.length > 0) {
+          item.value = item.randomValueList[0];
+        }
+      }
+      delete item.randomSalesCallAdded;
+      delete item.randomUserNameCallAdded;
+    },
 
     handleContentTypeChange() {
 
@@ -1613,6 +1684,7 @@ export default {
       this.$refs["msgForm"].validate(valid => {
         if (valid) {
 
+          this.setting.forEach(item => this.prepareSettingForSave(item));
           this.msgForm.setting=JSON.stringify(this.setting)
           this.msgForm.ids=this.ids;
           this.msgForm.sopId=this.queryParams.sopId;
@@ -1654,8 +1726,16 @@ export default {
           }
 
           for (let i = 0; i < this.setting.length; i++) {
-            if (this.setting[i].contentType == 1 && (this.setting[i].value == null || this.setting[i].value == "")) {
-              return this.$message.error("内容不能为空")
+            if (this.setting[i].contentType == 1) {
+              this.initRandomSendFields(this.setting[i]);
+              if (this.setting[i].isRandomSend == 1) {
+                const validRandomValues = (this.setting[i].randomValueList || []).filter(v => v != null && v.trim() !== '');
+                if (validRandomValues.length < 2) {
+                  return this.$message.error("开启随机发送后,至少需要填写2条文案");
+                }
+              } else if (this.setting[i].value == null || this.setting[i].value == "") {
+                return this.$message.error("内容不能为空");
+              }
             }
             if (this.setting[i].contentType == 2 && (this.setting[i].imgUrl == null || this.setting[i].imgUrl == "")) {
               return this.$message.error("图片不能为空")
@@ -1702,7 +1782,7 @@ export default {
           sendMsgSop(this.msgForm).then(response => {
             this.msgSuccess("一键群发成功");
             loading.close();
-            this.setting=[];
+            this.setting=[{contentType:'1', value: '', isRandomSend: 0}];
             this.msgForm = {
               videoId:null,
               courseId:null,