Forráskód Böngészése

feat: 销售端一键群发日志与登录/下载修复

新增群发日志页;登录接口免 Token;下载改为带鉴权 blob;群发传 SOP 名称。

Co-authored-by: Cursor <cursoragent@cursor.com>
吴树波 1 hete
szülő
commit
96d6b6b159

+ 18 - 3
src/api/login.js

@@ -1,7 +1,7 @@
 import request from '@/utils/request'
 import request from '@/utils/request'
 
 
 
 
-// 登录方法
+// 登录方法(不携带旧 Token,避免鉴权干扰登录接口)
 export function login(username, password, code, uuid) {
 export function login(username, password, code, uuid) {
   const data = {
   const data = {
     username,
     username,
@@ -11,6 +11,9 @@ export function login(username, password, code, uuid) {
   }
   }
   return request({
   return request({
     url: '/login',
     url: '/login',
+    headers: {
+      isToken: false
+    },
     method: 'post',
     method: 'post',
     data: data
     data: data
   })
   })
@@ -36,10 +39,13 @@ export function logout() {
 export function getCodeImg() {
 export function getCodeImg() {
   return request({
   return request({
     url: '/captchaImage',
     url: '/captchaImage',
+    headers: {
+      isToken: false
+    },
     method: 'get'
     method: 'get'
   })
   })
 }
 }
-// 判定是否首次登录
+// 判定是否首次登录(登录成功后需携带新 Token)
 export function getFirstLogin() {
 export function getFirstLogin() {
   return request({
   return request({
     url: '/getFirstLogin',
     url: '/getFirstLogin',
@@ -50,6 +56,9 @@ export function getFirstLogin() {
 export function getWechatQrCode(data) {
 export function getWechatQrCode(data) {
   return request({
   return request({
     url: '/getWechatQrCode',
     url: '/getWechatQrCode',
+    headers: {
+      isToken: false
+    },
     method: 'post',
     method: 'post',
     data: data
     data: data
   })
   })
@@ -58,12 +67,15 @@ export function getWechatQrCode(data) {
 export function checkWechatScan(ticket) {
 export function checkWechatScan(ticket) {
   return request({
   return request({
     url: '/checkWechatScan',
     url: '/checkWechatScan',
+    headers: {
+      isToken: false
+    },
     method: 'get',
     method: 'get',
     params: { ticket }
     params: { ticket }
   })
   })
 }
 }
 
 
-//检查是否需要验证码验证
+// 检查是否需要验证码验证(登录前接口,不携带旧 Token)
 export function checkIsNeedCheck(username, password, code, uuid) {
 export function checkIsNeedCheck(username, password, code, uuid) {
   const data = {
   const data = {
     username,
     username,
@@ -73,6 +85,9 @@ export function checkIsNeedCheck(username, password, code, uuid) {
   }
   }
   return request({
   return request({
     url: '/checkIsNeedCheck',
     url: '/checkIsNeedCheck',
+    headers: {
+      isToken: false
+    },
     method: 'post',
     method: 'post',
     data: data
     data: data
   })
   })

+ 18 - 0
src/api/qw/massSendLog.js

@@ -0,0 +1,18 @@
+import request from '@/utils/request'
+
+// 查询一键群发点击日志列表
+export function listMassSendLog(query) {
+  return request({
+    url: '/qw/massSendLog/list',
+    method: 'get',
+    params: query
+  })
+}
+
+// 查询一键群发点击日志详情
+export function getMassSendLog(id) {
+  return request({
+    url: '/qw/massSendLog/' + id,
+    method: 'get'
+  })
+}

+ 45 - 3
src/utils/common.js

@@ -3,6 +3,10 @@
  * Copyright (c) 2019 fs
  * Copyright (c) 2019 fs
  */
  */
 
 
+import axios from 'axios'
+import { Message } from 'element-ui'
+import { getToken } from '@/utils/auth'
+
 const baseURL = process.env.VUE_APP_BASE_API
 const baseURL = process.env.VUE_APP_BASE_API
 
 
 /**
 /**
@@ -186,9 +190,47 @@ export function selectDictLabels(datas, value, separator) {
 	return actions.join('').substring(0, actions.join('').length - 1);
 	return actions.join('').substring(0, actions.join('').length - 1);
 }
 }
 
 
-// 通用下载方法
-export function download(fileName) {
-	window.location.href = baseURL + "/common/download?fileName=" + encodeURI(fileName) + "&delete=" + true;
+// 通用下载方法(带 Token 的 blob 下载;与总后台一致,避免无鉴权 location.href 失败)
+export function download(fileName, isDelete) {
+	const deleteFlag = typeof isDelete === 'undefined' ? true : isDelete
+	const token = getToken()
+	axios({
+		method: 'get',
+		url: baseURL + '/common/download?fileName=' + encodeURIComponent(fileName) + '&delete=' + deleteFlag,
+		responseType: 'blob',
+		headers: {
+			Authorization: 'Bearer ' + token,
+			'X-Frontend-Type': 'company'
+		}
+	}).then(async (res) => {
+		const contentType = (res.headers && res.headers['content-type']) || ''
+		if (contentType.indexOf('application/json') !== -1) {
+			const text = await res.data.text()
+			try {
+				const data = JSON.parse(text)
+				Message.error(data.msg || '下载失败')
+			} catch (e) {
+				Message.error('下载失败')
+			}
+			return
+		}
+		const blob = new Blob([res.data])
+		let downloadName = fileName
+		const underIdx = fileName.indexOf('_')
+		if (underIdx > -1) {
+			downloadName = fileName.substring(underIdx + 1)
+		}
+		const link = document.createElement('a')
+		link.style.display = 'none'
+		link.href = URL.createObjectURL(blob)
+		link.setAttribute('download', downloadName)
+		document.body.appendChild(link)
+		link.click()
+		URL.revokeObjectURL(link.href)
+		document.body.removeChild(link)
+	}).catch(() => {
+		Message.error('下载文件失败,请确认已登录后重试')
+	})
 }
 }
 
 
 // 字符串格式化(%s )
 // 字符串格式化(%s )

+ 154 - 10
src/views/course/coursePlaySourceConfig/index.vue

@@ -274,7 +274,7 @@
                       type="text"
                       type="text"
                       icon="el-icon-edit"
                       icon="el-icon-edit"
                       class="row-action-btn"
                       class="row-action-btn"
-                      title="修改状态"
+                      title="修改配置"
                       @click="handleUpdate(scope.row)"
                       @click="handleUpdate(scope.row)"
                     />
                     />
                     <el-button
                     <el-button
@@ -311,9 +311,9 @@
       </el-col>
       </el-col>
     </el-row>
     </el-row>
 
 
-    <!-- 修改小程序状态 -->
-    <el-dialog :title="title" :visible.sync="open" width="500px" append-to-body>
-      <el-form ref="form" :model="form" :rules="rules" label-width="80px">
+    <!-- 修改小程序状态 + 自定义授权) -->
+    <el-dialog :title="title" :visible.sync="open" width="560px" append-to-body>
+      <el-form ref="form" :model="form" :rules="rules" label-width="110px" size="small">
         <el-form-item label="状态" prop="status">
         <el-form-item label="状态" prop="status">
           <el-select
           <el-select
             v-model="form.status"
             v-model="form.status"
@@ -330,6 +330,42 @@
             />
             />
           </el-select>
           </el-select>
         </el-form-item>
         </el-form-item>
+
+        <el-divider content-position="left">自定义授权配置</el-divider>
+        <el-form-item label="自定义授权" prop="customAuthEnabled">
+          <el-switch
+            v-model="form.customAuthEnabled"
+            :active-value="1"
+            :inactive-value="0"
+            active-text="启用"
+            inactive-text="关闭"
+          />
+          <div class="form-item-tip">启用后可独立配置该小程序的授权方式,不使用全局配置</div>
+        </el-form-item>
+        <el-form-item
+          v-if="form.customAuthEnabled === 1"
+          label="授权方式"
+          prop="miniAppAuthType"
+        >
+          <el-radio-group v-model="form.miniAppAuthType">
+            <el-radio :label="1">小程序授权</el-radio>
+            <el-radio :label="2">服务号授权</el-radio>
+          </el-radio-group>
+          <div class="form-item-tip">小程序授权:使用微信小程序原生授权组件获取用户信息</div>
+          <div class="form-item-tip">服务号授权:跳转 H5 页面通过服务号授权获取用户信息</div>
+        </el-form-item>
+        <el-form-item
+          v-if="form.customAuthEnabled === 1 && form.miniAppAuthType === 2"
+          label="授权域名"
+          prop="userCourseAuthDomain"
+        >
+          <el-input
+            v-model="form.userCourseAuthDomain"
+            placeholder="例如:https://scrm.xxx.cn/weixinOauth?appid=wx..."
+            clearable
+          />
+          <div class="form-item-tip">看课时跳转 H5 服务号授权的域名地址</div>
+        </el-form-item>
       </el-form>
       </el-form>
       <div slot="footer" class="dialog-footer">
       <div slot="footer" class="dialog-footer">
         <el-button type="primary" @click="submitForm">确 定</el-button>
         <el-button type="primary" @click="submitForm">确 定</el-button>
@@ -358,6 +394,42 @@
         <el-form-item label="图标" prop="img">
         <el-form-item label="图标" prop="img">
           <image-upload v-model="addForm.img" :file-type='["png", "jpg", "jpeg"]' :limit="1" />
           <image-upload v-model="addForm.img" :file-type='["png", "jpg", "jpeg"]' :limit="1" />
         </el-form-item>
         </el-form-item>
+
+        <el-divider content-position="left">自定义授权配置</el-divider>
+        <el-form-item label="自定义授权" prop="customAuthEnabled">
+          <el-switch
+            v-model="addForm.customAuthEnabled"
+            :active-value="1"
+            :inactive-value="0"
+            active-text="启用"
+            inactive-text="关闭"
+          />
+          <div class="form-item-tip">启用后可独立配置该小程序的授权方式,不使用全局配置</div>
+        </el-form-item>
+        <el-form-item
+          v-if="addForm.customAuthEnabled === 1"
+          label="授权方式"
+          prop="miniAppAuthType"
+        >
+          <el-radio-group v-model="addForm.miniAppAuthType">
+            <el-radio :label="1">小程序授权</el-radio>
+            <el-radio :label="2">服务号授权</el-radio>
+          </el-radio-group>
+          <div class="form-item-tip">小程序授权:使用微信小程序原生授权组件获取用户信息</div>
+          <div class="form-item-tip">服务号授权:跳转 H5 页面通过服务号授权获取用户信息</div>
+        </el-form-item>
+        <el-form-item
+          v-if="addForm.customAuthEnabled === 1 && addForm.miniAppAuthType === 2"
+          label="授权域名"
+          prop="userCourseAuthDomain"
+        >
+          <el-input
+            v-model="addForm.userCourseAuthDomain"
+            placeholder="例如:https://scrm.xxx.cn/weixinOauth?appid=wx..."
+            clearable
+          />
+          <div class="form-item-tip">看课时跳转 H5 服务号授权的域名地址</div>
+        </el-form-item>
       </el-form>
       </el-form>
       <div slot="footer" class="dialog-footer">
       <div slot="footer" class="dialog-footer">
         <el-button type="primary" :loading="addSaving" @click="submitAdd">确 定</el-button>
         <el-button type="primary" :loading="addSaving" @click="submitAdd">确 定</el-button>
@@ -378,6 +450,7 @@
           <el-upload
           <el-upload
             ref="verifyUpload"
             ref="verifyUpload"
             action=""
             action=""
+            accept=".txt"
             :auto-upload="false"
             :auto-upload="false"
             :limit="1"
             :limit="1"
             :file-list="verifyFileList"
             :file-list="verifyFileList"
@@ -546,6 +619,12 @@ export default {
       rules: {
       rules: {
         status: [
         status: [
           { required: true, message: '状态不能为空', trigger: 'change' }
           { required: true, message: '状态不能为空', trigger: 'change' }
+        ],
+        miniAppAuthType: [
+          { required: true, message: '请选择授权方式', trigger: 'change' }
+        ],
+        userCourseAuthDomain: [
+          { required: true, message: '请填写授权域名', trigger: 'blur' }
         ]
         ]
       },
       },
       addOpen: false,
       addOpen: false,
@@ -560,7 +639,13 @@ export default {
         originalId: [{ required: true, message: '原始ID不能为空', trigger: 'blur' }],
         originalId: [{ required: true, message: '原始ID不能为空', trigger: 'blur' }],
         appid: [{ required: true, message: 'AppID不能为空', trigger: 'blur' }],
         appid: [{ required: true, message: 'AppID不能为空', trigger: 'blur' }],
         secret: [{ required: true, message: 'Secret不能为空', trigger: 'blur' }],
         secret: [{ required: true, message: 'Secret不能为空', trigger: 'blur' }],
-        img: [{ required: true, message: '图标不能为空', trigger: 'change' }]
+        img: [{ required: true, message: '图标不能为空', trigger: 'change' }],
+        miniAppAuthType: [
+          { required: true, message: '请选择授权方式', trigger: 'change' }
+        ],
+        userCourseAuthDomain: [
+          { required: true, message: '请填写授权域名', trigger: 'blur' }
+        ]
       },
       },
       verifyOpen: false,
       verifyOpen: false,
       verifySaving: false,
       verifySaving: false,
@@ -622,6 +707,40 @@ export default {
     },
     },
     filteredList() {
     filteredList() {
       this.listPageNum = 1
       this.listPageNum = 1
+    },
+    'addForm.customAuthEnabled'(val) {
+      if (val !== 1) {
+        this.addForm.miniAppAuthType = null
+        this.addForm.userCourseAuthDomain = null
+        if (this.$refs.addForm) {
+          this.$refs.addForm.clearValidate(['miniAppAuthType', 'userCourseAuthDomain'])
+        }
+      }
+    },
+    'addForm.miniAppAuthType'(val) {
+      if (val !== 2) {
+        this.addForm.userCourseAuthDomain = null
+        if (this.$refs.addForm) {
+          this.$refs.addForm.clearValidate(['userCourseAuthDomain'])
+        }
+      }
+    },
+    'form.customAuthEnabled'(val) {
+      if (val !== 1) {
+        this.form.miniAppAuthType = null
+        this.form.userCourseAuthDomain = null
+        if (this.$refs.form) {
+          this.$refs.form.clearValidate(['miniAppAuthType', 'userCourseAuthDomain'])
+        }
+      }
+    },
+    'form.miniAppAuthType'(val) {
+      if (val !== 2) {
+        this.form.userCourseAuthDomain = null
+        if (this.$refs.form) {
+          this.$refs.form.clearValidate(['userCourseAuthDomain'])
+        }
+      }
     }
     }
   },
   },
   created() {
   created() {
@@ -656,7 +775,10 @@ export default {
         appid: null,
         appid: null,
         secret: null,
         secret: null,
         img: null,
         img: null,
-        status: 0
+        status: 0,
+        customAuthEnabled: 0,
+        miniAppAuthType: null,
+        userCourseAuthDomain: null
       }
       }
     },
     },
     handleAdd() {
     handleAdd() {
@@ -692,7 +814,12 @@ export default {
           aesKey: this.addDefaultAesKey,
           aesKey: this.addDefaultAesKey,
           msgDataFormat: this.addDefaultMsgDataFormat,
           msgDataFormat: this.addDefaultMsgDataFormat,
           img: this.addForm.img,
           img: this.addForm.img,
-          status: 0
+          status: 0,
+          customAuthEnabled: this.addForm.customAuthEnabled || 0,
+          miniAppAuthType: this.addForm.customAuthEnabled === 1 ? this.addForm.miniAppAuthType : null,
+          userCourseAuthDomain: this.addForm.customAuthEnabled === 1 && this.addForm.miniAppAuthType === 2
+            ? this.addForm.userCourseAuthDomain
+            : null
         }
         }
         add(payload).then(response => {
         add(payload).then(response => {
           if (response.code !== 200) {
           if (response.code !== 200) {
@@ -990,12 +1117,15 @@ export default {
     },
     },
     handleUpdate(row) {
     handleUpdate(row) {
       get(row.id).then(response => {
       get(row.id).then(response => {
+        const data = response.data || {}
         this.form = {
         this.form = {
-          ...response.data,
-          type: response.data.type != null ? response.data.type.toString() : response.data.type
+          ...data,
+          type: data.type != null ? data.type.toString() : data.type,
+          customAuthEnabled: data.customAuthEnabled == null ? 0 : data.customAuthEnabled,
+          miniAppAuthType: data.miniAppAuthType == null ? null : Number(data.miniAppAuthType)
         }
         }
         this.open = true
         this.open = true
-        this.title = '修改小程序状态'
+        this.title = '修改小程序配置'
       })
       })
     },
     },
     submitForm() {
     submitForm() {
@@ -1006,6 +1136,13 @@ export default {
           this.msgError('获取销售公司失败')
           this.msgError('获取销售公司失败')
           return
           return
         }
         }
+        // 关闭自定义授权时清空关联字段,避免脏数据残留
+        if (this.form.customAuthEnabled !== 1) {
+          this.form.miniAppAuthType = null
+          this.form.userCourseAuthDomain = null
+        } else if (this.form.miniAppAuthType !== 2) {
+          this.form.userCourseAuthDomain = null
+        }
         this.form.companyIds = [companyIdNum]
         this.form.companyIds = [companyIdNum]
         this.form.companyId = companyIdNum
         this.form.companyId = companyIdNum
         update(this.form).then(response => {
         update(this.form).then(response => {
@@ -1372,4 +1509,11 @@ export default {
   color: #c0c4cc;
   color: #c0c4cc;
   font-size: 12px;
   font-size: 12px;
 }
 }
+
+.form-item-tip {
+  margin-top: 4px;
+  line-height: 1.4;
+  font-size: 12px;
+  color: #909399;
+}
 </style>
 </style>

+ 291 - 0
src/views/qw/massSendLog/index.vue

@@ -0,0 +1,291 @@
+<template>
+  <div class="app-container">
+    <el-form :model="queryParams" ref="queryForm" :inline="true" v-show="showSearch" label-width="80px">
+      <el-form-item label="销售" prop="companyUserName">
+        <el-input v-model="queryParams.companyUserName" placeholder="销售昵称" clearable size="small" @keyup.enter.native="handleQuery" />
+      </el-form-item>
+      <el-form-item label="课程" prop="courseName">
+        <el-input v-model="queryParams.courseName" placeholder="课程名称" clearable size="small" @keyup.enter.native="handleQuery" />
+      </el-form-item>
+      <el-form-item label="SOP" prop="sopName">
+        <el-input v-model="queryParams.sopName" placeholder="SOP名称" clearable size="small" @keyup.enter.native="handleQuery" />
+      </el-form-item>
+      <el-form-item label="来源" prop="sendSource">
+        <el-select v-model="queryParams.sendSource" placeholder="发送来源" clearable size="small">
+          <el-option label="营期明细群发" :value="1" />
+          <el-option label="营期类型群发" :value="2" />
+          <el-option label="SOP群发" :value="3" />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="发送时间" prop="createTime">
+        <el-date-picker v-model="createTime" size="small" style="width: 240px" value-format="yyyy-MM-dd" type="daterange" range-separator="-" start-placeholder="开始日期" end-placeholder="结束日期" @change="changeDate" />
+      </el-form-item>
+      <el-form-item>
+        <el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
+        <el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
+      </el-form-item>
+    </el-form>
+
+    <el-row :gutter="10" class="mb8">
+      <right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
+    </el-row>
+
+    <el-table border v-loading="loading" :data="dataList">
+      <el-table-column label="销售" align="center" prop="companyUserName" min-width="100" show-overflow-tooltip />
+      <el-table-column label="客户人数" align="center" prop="customerCount" width="90" />
+      <el-table-column label="课程" align="center" prop="courseName" min-width="140" show-overflow-tooltip />
+      <el-table-column label="课节/小结" align="center" prop="videoName" min-width="140" show-overflow-tooltip />
+      <el-table-column label="SOP名称" align="center" prop="sopName" min-width="140" show-overflow-tooltip />
+      <el-table-column label="发送来源" align="center" prop="sendSource" width="120">
+        <template slot-scope="scope">
+          <span>{{ formatSource(scope.row.sendSource) }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="创建时间" align="center" prop="createTime" width="160" />
+      <el-table-column label="操作" align="center" width="120">
+        <template slot-scope="scope">
+          <el-button size="mini" type="text" @click="handleDetail(scope.row)" v-hasPermi="['qw:massSendLog:query']">查看详情</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <pagination v-show="total > 0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize" @pagination="getList" />
+
+    <el-dialog title="群发详情" :visible.sync="detailOpen" width="720px" append-to-body>
+      <el-form label-width="100px" size="mini">
+        <el-form-item label="销售">{{ detail.companyUserName || '-' }}</el-form-item>
+        <el-form-item label="客户人数">{{ detail.customerCount }}</el-form-item>
+        <el-form-item label="课程">{{ detail.courseName || '-' }}</el-form-item>
+        <el-form-item label="课节/小结">{{ detail.videoName || '-' }}</el-form-item>
+        <el-form-item label="SOP名称">{{ detail.sopName || '-' }}</el-form-item>
+        <el-form-item label="发送来源">{{ formatSource(detail.sendSource) }}</el-form-item>
+        <el-form-item label="创建时间">{{ detail.createTime || '-' }}</el-form-item>
+        <el-form-item label="发送内容">
+          <div v-if="!contentItems || contentItems.length === 0">-</div>
+          <div v-else>
+            <!-- 展示结构对齐销售端规则执行详情-消息详情(不含生成短链) -->
+            <div v-for="(item, index) in contentItems" :key="index">
+              <el-card class="box-card" style="margin-top: 2%">
+                <div slot="header" class="clearfix">
+                  <span>类型:</span>
+                  <span v-if="item.contentType == 1">文本</span>
+                  <span v-if="item.contentType == 2">图片</span>
+                  <span v-if="item.contentType == 3">卡片</span>
+                  <span v-if="item.contentType == 4">小程序</span>
+                  <span v-if="item.contentType == 5">文件</span>
+                  <span v-if="item.contentType == 6">视频</span>
+                  <span v-if="item.contentType == 7">语音</span>
+                  <span v-if="item.contentType == 8">视频号</span>
+                  <span v-if="item.contentType == 9">APP</span>
+                  <span v-if="item.contentType == 17">飞书</span>
+                  <span v-if="item.contentType == 10">自定义小程序</span>
+                  <span v-if="item.contentType == 11">群公告</span>
+                </div>
+                <div v-if="item.contentType == 1" v-html="item.value"></div>
+                <div v-if="item.contentType == 2">
+                  <el-image
+                    style="width: 100px; height: 100px"
+                    :src="item.imgUrl"
+                    fit="contain"
+                    @click.prevent="openImageViewer(item.imgUrl)" />
+                </div>
+                <div v-if="item.contentType == 3 || item.contentType == 9 || item.contentType == 17" class="message-style">
+                  <div
+                    style="background-color: white; padding: 10px; width: 100%"
+                    @click="openLink(item.linkUrl)">
+                    <span>{{ item.linkTitle }}</span>
+                    <div style="display: flex; justify-content: space-between; width: 100%">
+                      <span style="font-size: 13px; flex: 1">{{ item.linkDescribe }}</span>
+                      <el-image
+                        style="width: 50px; height: 50px; flex-shrink: 0"
+                        :src="item.linkImageUrl"
+                        fit="contain"
+                        @click.stop="openImageViewer(item.linkImageUrl)" />
+                    </div>
+                  </div>
+                </div>
+                <div v-if="item.contentType == 4 || item.contentType == 10" class="message-style">
+                  <div style="display: flex; justify-content: space-between; width: 100%">
+                    <span style="font-size: 13px; flex: 1">{{ item.miniprogramTitle }}</span>
+                    <el-image
+                      style="width: 50px; height: 50px; flex-shrink: 0"
+                      :src="item.miniprogramPicUrl"
+                      fit="contain"
+                      @click.stop="openImageViewer(item.miniprogramPicUrl)" />
+                  </div>
+                </div>
+                <div v-if="item.contentType == 5" class="message-style">
+                  <el-link type="primary" :href="item.fileUrl" download style="padding: 10px">
+                    {{ item.fileUrl }}
+                  </el-link>
+                </div>
+                <div v-if="item.contentType == 6" class="message-style">
+                  <video
+                    :src="item.videoUrl"
+                    controls
+                    style="width: 200px; height: 100px">
+                  </video>
+                </div>
+                <div v-if="item.contentType == 7">
+                  <span>{{ item.value || item.voiceText }}</span>
+                  <div v-if="!item.voiceUrl" style="margin-top: 3px">
+                    <span style="color: red">无语音地址</span>
+                  </div>
+                </div>
+                <div v-if="item.contentType == 8" class="message-style">
+                  <el-card class="box-card" v-if="item.coverUrl">
+                    <el-form-item label="封面标题:" label-width="100px">
+                      <el-input v-model="item.nickname" style="width: 90%;margin-bottom: 1%" disabled />
+                    </el-form-item>
+                    <el-form-item label="头像:" label-width="100px">
+                      <el-image
+                        v-if="item.avatar != null"
+                        :src="item.avatar"
+                        :preview-src-list="[item.avatar]"
+                        :style="{ width: '50px', height: '50px' }" />
+                    </el-form-item>
+                    <el-form-item label="封面:" label-width="100px">
+                      <el-image
+                        v-if="item.coverUrl != null"
+                        :src="item.coverUrl"
+                        :preview-src-list="[item.coverUrl]"
+                        :style="{ width: '200px', height: '200px' }" />
+                    </el-form-item>
+                    <el-form-item label="简介:" label-width="100px">
+                      <el-input type="textarea" :rows="3" v-model="item.desc" style="width: 90%;margin-top: 1%;" disabled />
+                    </el-form-item>
+                    <el-form-item label="视频地址:" label-width="100px" style="margin-top: 1%">
+                      <el-input v-model="item.url" style="width: 90%;" disabled />
+                    </el-form-item>
+                  </el-card>
+                </div>
+                <div v-if="item.contentType == 11">
+                  <div v-html="item.value"></div>
+                </div>
+              </el-card>
+            </div>
+          </div>
+        </el-form-item>
+      </el-form>
+      <div slot="footer" class="dialog-footer">
+        <el-button @click="detailOpen = false">关闭</el-button>
+      </div>
+    </el-dialog>
+
+    <el-dialog :visible.sync="imageDialogVisible" :modal="false" width="500" append-to-body>
+      <img :src="dialogImageUrl" style="display: block; max-width: 100%; margin: 0 auto" />
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { listMassSendLog, getMassSendLog } from "@/api/qw/massSendLog";
+
+export default {
+  name: "MassSendLog",
+  data() {
+    return {
+      loading: true,
+      showSearch: true,
+      total: 0,
+      dataList: [],
+      createTime: null,
+      detailOpen: false,
+      detail: {},
+      contentItems: [],
+      imageDialogVisible: false,
+      dialogImageUrl: null,
+      queryParams: {
+        pageNum: 1,
+        pageSize: 10,
+        companyUserName: null,
+        courseName: null,
+        sopName: null,
+        sendSource: null,
+        sTime: null,
+        eTime: null
+      }
+    };
+  },
+  created() {
+    this.getList();
+  },
+  methods: {
+    getList() {
+      this.loading = true;
+      listMassSendLog(this.queryParams).then(response => {
+        this.dataList = response.rows;
+        this.total = response.total;
+        this.loading = false;
+      }).catch(() => {
+        this.loading = false;
+      });
+    },
+    changeDate() {
+      if (this.createTime != null) {
+        this.queryParams.sTime = this.createTime[0];
+        this.queryParams.eTime = this.createTime[1];
+      } else {
+        this.queryParams.sTime = null;
+        this.queryParams.eTime = null;
+      }
+    },
+    handleQuery() {
+      this.queryParams.pageNum = 1;
+      this.getList();
+    },
+    resetQuery() {
+      this.resetForm("queryForm");
+      this.createTime = null;
+      this.queryParams.sTime = null;
+      this.queryParams.eTime = null;
+      this.queryParams.sendSource = null;
+      this.queryParams.sopName = null;
+      this.handleQuery();
+    },
+    formatSource(source) {
+      if (source === 1) return "营期明细群发";
+      if (source === 2) return "营期类型群发";
+      if (source === 3) return "SOP群发";
+      return source == null ? "-" : source;
+    },
+    /**
+     * 兼容 contentJson 为 setting 数组,或 { setting: [...] }
+     */
+    parseContentItems(contentJson) {
+      if (!contentJson) {
+        return [];
+      }
+      try {
+        const sanitized = String(contentJson).replace(/[\u0000-\u001F\u007F]/g, "");
+        const parsed = JSON.parse(sanitized);
+        if (Array.isArray(parsed)) {
+          return parsed;
+        }
+        if (parsed && Array.isArray(parsed.setting)) {
+          return parsed.setting;
+        }
+        return [];
+      } catch (e) {
+        return [];
+      }
+    },
+    openImageViewer(url) {
+      this.dialogImageUrl = url;
+      this.imageDialogVisible = true;
+    },
+    openLink(url) {
+      if (url) {
+        window.open(url, "_blank");
+      }
+    },
+    handleDetail(row) {
+      getMassSendLog(row.id).then(response => {
+        this.detail = response.data || {};
+        this.contentItems = this.parseContentItems(this.detail.contentJson);
+        this.detailOpen = true;
+      });
+    }
+  }
+};
+</script>

+ 5 - 1
src/views/qw/sop/deptSop.vue

@@ -1116,8 +1116,11 @@ export default {
      */
      */
     handleCampSendMsg(){
     handleCampSendMsg(){
 
 
+      const firstSopName = (this.selectionMap && this.selectionMap.length > 0)
+        ? this.selectionMap[0].name
+        : null;
       setTimeout(() => {
       setTimeout(() => {
-        this.$refs.sendMsgSopOpenTool.oneClickGroupSending(this.ids,2,this.queryParams.corpId,0);
+        this.$refs.sendMsgSopOpenTool.oneClickGroupSending(this.ids,2,this.queryParams.corpId,0, null, firstSopName);
       }, 500);
       }, 500);
 
 
     },
     },
@@ -1797,6 +1800,7 @@ export default {
         if (valid) {
         if (valid) {
           this.msgForm.setting=JSON.stringify(this.setting)
           this.msgForm.setting=JSON.stringify(this.setting)
           this.msgForm.sopId=this.sendMsgOpen.row.id;
           this.msgForm.sopId=this.sendMsgOpen.row.id;
+          this.msgForm.sopName=this.sendMsgOpen.row.name;
           this.msgForm.corpId=this.sendMsgOpen.row.corpId;
           this.msgForm.corpId=this.sendMsgOpen.row.corpId;
           this.msgForm.filterMode=this.sendMsgOpen.row.filterMode;
           this.msgForm.filterMode=this.sendMsgOpen.row.filterMode;
 
 

+ 5 - 1
src/views/qw/sop/mySop.vue

@@ -1115,8 +1115,11 @@ export default {
      */
      */
     handleCampSendMsg(){
     handleCampSendMsg(){
 
 
+      const firstSopName = (this.selectionMap && this.selectionMap.length > 0)
+        ? this.selectionMap[0].name
+        : null;
       setTimeout(() => {
       setTimeout(() => {
-        this.$refs.sendMsgSopOpenTool.oneClickGroupSending(this.ids,2,this.queryParams.corpId,1);
+        this.$refs.sendMsgSopOpenTool.oneClickGroupSending(this.ids,2,this.queryParams.corpId,1, null, firstSopName);
       }, 500);
       }, 500);
 
 
     },
     },
@@ -1796,6 +1799,7 @@ export default {
         if (valid) {
         if (valid) {
           this.msgForm.setting=JSON.stringify(this.setting)
           this.msgForm.setting=JSON.stringify(this.setting)
           this.msgForm.sopId=this.sendMsgOpen.row.id;
           this.msgForm.sopId=this.sendMsgOpen.row.id;
+          this.msgForm.sopName=this.sendMsgOpen.row.name;
           this.msgForm.corpId=this.sendMsgOpen.row.corpId;
           this.msgForm.corpId=this.sendMsgOpen.row.corpId;
           this.msgForm.filterMode=this.sendMsgOpen.row.filterMode;
           this.msgForm.filterMode=this.sendMsgOpen.row.filterMode;
 
 

+ 6 - 1
src/views/qw/sop/sop.vue

@@ -1579,8 +1579,11 @@ export default {
           .filter(e => e.filterMode == 1 && e.qwUserIds)
           .filter(e => e.filterMode == 1 && e.qwUserIds)
           .flatMap(e => e.qwUserIds.split(","))
           .flatMap(e => e.qwUserIds.split(","))
       )];
       )];
+      const firstSopName = (this.selectionMap && this.selectionMap.length > 0)
+        ? this.selectionMap[0].name
+        : null;
       setTimeout(() => {
       setTimeout(() => {
-        this.$refs.sendMsgSopOpenTool.oneClickGroupSending(this.ids,2,this.queryParams.corpId,0,qwUserIds);
+        this.$refs.sendMsgSopOpenTool.oneClickGroupSending(this.ids,2,this.queryParams.corpId,0,qwUserIds, firstSopName);
       }, 500);
       }, 500);
 
 
     },
     },
@@ -2358,6 +2361,7 @@ export default {
       this.msgForm.filterMode = row.filterMode;
       this.msgForm.filterMode = row.filterMode;
       this.msgForm.corpId = row.corpId;
       this.msgForm.corpId = row.corpId;
       this.msgForm.sopId = row.id;
       this.msgForm.sopId = row.id;
+      this.msgForm.sopName = row.name;
       if (!this.setting || this.setting.length === 0) {
       if (!this.setting || this.setting.length === 0) {
         this.setting = [{
         this.setting = [{
           contentType: '1',
           contentType: '1',
@@ -2396,6 +2400,7 @@ export default {
       this.$refs["msgForm"].validate(valid => {
       this.$refs["msgForm"].validate(valid => {
         if (valid) {
         if (valid) {
           this.msgForm.sopId=this.sendMsgOpen.row.id;
           this.msgForm.sopId=this.sendMsgOpen.row.id;
+          this.msgForm.sopName=this.sendMsgOpen.row.name;
           this.msgForm.corpId=this.sendMsgOpen.row.corpId;
           this.msgForm.corpId=this.sendMsgOpen.row.corpId;
           this.msgForm.filterMode=this.sendMsgOpen.row.filterMode;
           this.msgForm.filterMode=this.sendMsgOpen.row.filterMode;
 
 

+ 2 - 2
src/views/qw/sopUserLogs/sopUserLogsSchedule.vue

@@ -608,7 +608,7 @@ export default {
     handleCampSendMsg() {
     handleCampSendMsg() {
 
 
       setTimeout(() => {
       setTimeout(() => {
-        this.$refs.sendMsgOpenTool.oneClickGroupSending(this.ids, 2, this.queryParams.corpId)
+        this.$refs.sendMsgOpenTool.oneClickGroupSending(this.ids, 2, this.queryParams.corpId, this.queryParams.sopId, this.sopName)
       }, 500)
       }, 500)
 
 
     },
     },
@@ -670,7 +670,7 @@ export default {
       this.logsInfoDetailsOpen.open = true
       this.logsInfoDetailsOpen.open = true
       const externalUserName = this.queryParams.externalUserName
       const externalUserName = this.queryParams.externalUserName
       setTimeout(() => {
       setTimeout(() => {
-        this.$refs.SopUserLogsInfoDetails.selectSopUserLogsInfo(val, externalUserName)
+        this.$refs.SopUserLogsInfoDetails.selectSopUserLogsInfo(val, externalUserName, this.sopName)
       }, 500)
       }, 500)
 
 
     },
     },

+ 2 - 2
src/views/qw/sopUserLogs/sopUserLogsScheduleOld.vue

@@ -392,7 +392,7 @@ export default {
     handleCampSendMsg(){
     handleCampSendMsg(){
 
 
       setTimeout(() => {
       setTimeout(() => {
-        this.$refs.sendMsgOpenTool.oneClickGroupSending(this.ids,2,this.queryParams.corpId);
+        this.$refs.sendMsgOpenTool.oneClickGroupSending(this.ids,2,this.queryParams.corpId, this.queryParams.sopId, this.sopName);
       }, 500);
       }, 500);
 
 
     },
     },
@@ -472,7 +472,7 @@ export default {
       this.logsInfoDetailsOpen.open=true;
       this.logsInfoDetailsOpen.open=true;
       const externalUserName = this.queryParams.externalUserName;
       const externalUserName = this.queryParams.externalUserName;
       setTimeout(() => {
       setTimeout(() => {
-        this.$refs.SopUserLogsInfoDetails.selectSopUserLogsInfo(val,externalUserName);
+        this.$refs.SopUserLogsInfoDetails.selectSopUserLogsInfo(val,externalUserName, this.sopName);
       }, 500);
       }, 500);
 
 
     },
     },

+ 9 - 1
src/views/qw/sopUserLogsInfo/sendMsgOpenTool.vue

@@ -536,6 +536,7 @@ export default {
         type:null,
         type:null,
         corpId:null,
         corpId:null,
         sopId: null,
         sopId: null,
+        sopName: null,
         startTime: null,
         startTime: null,
         sendTime: null,
         sendTime: null,
         draftStrategy:1,
         draftStrategy:1,
@@ -614,12 +615,19 @@ export default {
       this.videoNumOptions.open = false;
       this.videoNumOptions.open = false;
 
 
     },
     },
-    oneClickGroupSending(val,type,corpId){
+    oneClickGroupSending(val,type,corpId,sopId,sopName){
 
 
       this.sendMsgOpen.open= true;
       this.sendMsgOpen.open= true;
       this.msgForm.ids = val;
       this.msgForm.ids = val;
       this.msgForm.type = type;
       this.msgForm.type = type;
       this.msgForm.corpId = corpId;
       this.msgForm.corpId = corpId;
+      // 营期群发补传 SOP,供群发日志落库 sop_name
+      if (sopId) {
+        this.msgForm.sopId = sopId;
+      }
+      if (sopName) {
+        this.msgForm.sopName = sopName;
+      }
 
 
     },
     },
     courseChange() {
     courseChange() {

+ 9 - 1
src/views/qw/sopUserLogsInfo/sendMsgSopOpenTool.vue

@@ -535,6 +535,7 @@ export default {
         type:null,
         type:null,
         corpId:null,
         corpId:null,
         sopId: null,
         sopId: null,
+        sopName: null,
         sopIds: null,
         sopIds: null,
         startTime: null,
         startTime: null,
         sendTime: null,
         sendTime: null,
@@ -635,7 +636,7 @@ export default {
       this.videoNumOptions.open = false;
       this.videoNumOptions.open = false;
 
 
     },
     },
-    oneClickGroupSending(val,type,corpId,isMine,qwUserIds){
+    oneClickGroupSending(val,type,corpId,isMine,qwUserIds,sopName){
 
 
       this.sendMsgOpen.open= true;
       this.sendMsgOpen.open= true;
       this.msgForm.sopIds = val;
       this.msgForm.sopIds = val;
@@ -643,6 +644,13 @@ export default {
       this.msgForm.corpId = corpId;
       this.msgForm.corpId = corpId;
       this.msgForm.isMine = isMine;
       this.msgForm.isMine = isMine;
       this.msgForm.qwUserIds = qwUserIds;
       this.msgForm.qwUserIds = qwUserIds;
+      // 批量选 SOP 时补传首个 sopId/sopName,供群发日志落库
+      if (val && val.length > 0) {
+        this.msgForm.sopId = Array.isArray(val) ? val[0] : val;
+      }
+      if (sopName) {
+        this.msgForm.sopName = sopName;
+      }
     },
     },
     /** 内容类别 APP,与后端 contentType 字符串 "9" 对齐(兼容字典 number / string) */
     /** 内容类别 APP,与后端 contentType 字符串 "9" 对齐(兼容字典 number / string) */
     isAppContentType(ct) {
     isAppContentType(ct) {

+ 7 - 1
src/views/qw/sopUserLogsInfo/sopUserLogsInfoDetails.vue

@@ -863,6 +863,7 @@ export default {
         setting:null,
         setting:null,
         ids:null,
         ids:null,
         sopId: null,
         sopId: null,
+        sopName: null,
         startTime: null,
         startTime: null,
       },
       },
       // 表单校验
       // 表单校验
@@ -1063,10 +1064,11 @@ export default {
         content.miniprogramPicUrl = '';
         content.miniprogramPicUrl = '';
       }
       }
     },
     },
-    selectSopUserLogsInfo(val,externalUserName){
+    selectSopUserLogsInfo(val,externalUserName,sopName){
 
 
       this.loading = true;
       this.loading = true;
       this.queryParams.sopId=val.sopId;
       this.queryParams.sopId=val.sopId;
+      this.queryParams.sopName = sopName || val.name || val.sopName || this.queryParams.sopName || null;
       this.queryParams.filterMode=val.filterMode;
       this.queryParams.filterMode=val.filterMode;
       this.queryParams.userLogsId=val.id;
       this.queryParams.userLogsId=val.id;
       this.queryParams.chatId=val.chatId;
       this.queryParams.chatId=val.chatId;
@@ -1716,6 +1718,10 @@ export default {
           this.msgForm.setting=JSON.stringify(this.setting)
           this.msgForm.setting=JSON.stringify(this.setting)
           this.msgForm.ids=this.ids;
           this.msgForm.ids=this.ids;
           this.msgForm.sopId=this.queryParams.sopId;
           this.msgForm.sopId=this.queryParams.sopId;
+          // SOP 名称:详情入参 / 路由均可,供群发日志直接落库
+          this.msgForm.sopName = this.queryParams.sopName
+            || (this.$route && this.$route.query && this.$route.query.name)
+            || null;
           this.msgForm.userIdParam=this.queryParams.userIdParam;
           this.msgForm.userIdParam=this.queryParams.userIdParam;
           this.msgForm.startTime=this.queryParams.startTimeParam;
           this.msgForm.startTime=this.queryParams.startTimeParam;
           this.msgForm.corpId=this.queryParams.corpIdParam;
           this.msgForm.corpId=this.queryParams.corpIdParam;