ソースを参照

重粉展示和客户拉黑显示

xw 4 日 前
コミット
a8df8f8724

+ 18 - 0
src/views/his/user/index.vue

@@ -127,6 +127,12 @@
          </template>
       </el-table-column>
       <el-table-column label="用户备注" align="center" prop="remark" />
+      <el-table-column label="管理状态" align="center" prop="manageStatusName" width="130px">
+        <template slot-scope="scope">
+          {{ scope.row.manageStatusName || (scope.row.manageStatus === 2 ? '已被总部拉黑' : scope.row.manageStatus === 1 ? '正常' : '-') }}
+        </template>
+      </el-table-column>
+      <el-table-column label="管理备注" align="center" prop="manageRemark" show-overflow-tooltip />
       <!--  会员等级    -->
       <el-table-column label="会员等级" align="center" prop="level" width="100">
         <template slot-scope="scope">
@@ -241,6 +247,15 @@
         <el-form-item label="用户备注" prop="remark" required>
           <el-input v-model="form.remark" placeholder="请输入用户备注" type="textarea"/>
         </el-form-item>
+        <el-form-item label="管理状态" prop="manageStatus">
+          <el-select v-model="form.manageStatus" placeholder="请选择管理状态" clearable size="small">
+            <el-option label="正常" :value="1" />
+            <el-option label="已被总部拉黑" :value="2" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="管理备注" prop="manageRemark">
+          <el-input v-model="form.manageRemark" placeholder="请输入管理备注" type="textarea"/>
+        </el-form-item>
       </el-form>
       <div slot="footer" class="dialog-footer">
         <el-button type="primary" @click="submitForm">确 定</el-button>
@@ -460,6 +475,8 @@ export default {
         isDel: null,
         userCode: null,
         remark: null,
+        manageStatus: 1,
+        manageRemark: null,
         createTime: null,
         updateTime: null,
         lastIp: null,
@@ -503,6 +520,7 @@ export default {
         this.form.status = String(this.form.status)
         this.form.level = String(this.form.level)
         this.form.isPromoter = String(this.form.isPromoter)
+        this.form.manageStatus = this.form.manageStatus != null ? Number(this.form.manageStatus) : 1
 
       });
     },

+ 888 - 0
src/views/qw/externalContact/RepeatCourseHistoryDrawer.vue

@@ -0,0 +1,888 @@
+<template>
+  <el-drawer
+    :visible.sync="visible"
+    direction="rtl"
+    size="720px"
+    append-to-body
+    :with-header="false"
+    :wrapper-closable="false"
+    custom-class="repeat-course-history-drawer"
+    @closed="handleClosed"
+  >
+    <div class="rch-drawer" v-loading="loading">
+      <!-- 顶部客户信息区 -->
+      <div class="rch-hero">
+        <button type="button" class="rch-close" @click="visible = false">
+          <i class="el-icon-close"></i>
+        </button>
+        <div class="rch-hero-inner">
+          <div class="rch-avatar-wrap">
+            <img :src="currentRow.avatar || defaultAvatar" alt="" class="rch-avatar" />
+          </div>
+          <div class="rch-hero-info">
+            <div class="rch-name-row">
+              <h3 class="rch-name">{{ currentRow.name || '-' }}</h3>
+            </div>
+            <p v-if="currentRow.remark" class="rch-remark">
+              <i class="el-icon-edit-outline"></i>{{ currentRow.remark }}
+            </p>
+          </div>
+        </div>
+        <div class="rch-stats">
+          <div class="rch-stat">
+            <span class="rch-stat-num">{{ projectList.length }}</span>
+            <span class="rch-stat-label">看课项目</span>
+          </div>
+          <div class="rch-stat-divider"></div>
+          <div class="rch-stat">
+            <span class="rch-stat-num">{{ mockSales.length }}</span>
+            <span class="rch-stat-label">关联销售</span>
+          </div>
+          <div class="rch-stat-divider"></div>
+          <div class="rch-stat">
+            <span class="rch-stat-num">{{ filteredCourses.length }}</span>
+            <span class="rch-stat-label">课程进度</span>
+          </div>
+        </div>
+      </div>
+
+      <!-- 可滚动内容区 -->
+      <div class="rch-body">
+        <!-- 看课项目 -->
+        <section class="rch-panel">
+          <div class="rch-panel-head">
+            <span class="rch-panel-icon rch-panel-icon--project"><i class="el-icon-folder-opened"></i></span>
+            <span class="rch-panel-title">看课项目</span>
+          </div>
+          <div class="rch-project-chips" v-if="projectList.length">
+            <button
+              v-for="item in projectList"
+              :key="item.projectId"
+              type="button"
+              class="rch-chip"
+              :class="{
+                'rch-chip--repeat': item.isRepeat == 1,
+                'rch-chip--active': Number(selectedProjectId) === Number(item.projectId)
+              }"
+              @click="selectProject(item.projectId)"
+            >
+              {{ item.projectName }}
+              <i v-if="item.isRepeat == 1" class="el-icon-warning-outline rch-chip-warn"></i>
+            </button>
+          </div>
+          <div v-else class="rch-empty">暂无看课项目</div>
+          <p v-if="showEquivalentHint" class="rch-hint">
+            <i class="el-icon-info"></i>百膳食养与安康食养为同一项目,课程数据已合并展示
+          </p>
+        </section>
+
+        <!-- 关联销售 -->
+        <section class="rch-panel">
+          <div class="rch-panel-head">
+            <span class="rch-panel-icon rch-panel-icon--sales"><i class="el-icon-user"></i></span>
+            <span class="rch-panel-title">关联销售</span>
+            <span class="rch-panel-count">{{ mockSales.length }} 人</span>
+          </div>
+          <div class="rch-sales-grid" v-if="mockSales.length">
+            <div
+              v-for="(item, idx) in mockSales"
+              :key="idx"
+              class="rch-sales-item"
+              :class="{ 'rch-sales-item--locked': item.hasPermission === false }"
+            >
+              <div class="rch-sales-top">
+                <span class="rch-sales-name">{{ item.qwUserName }}</span>
+                <span
+                  v-if="item.status != null"
+                  class="rch-tag"
+                  :class="salesStatusClass(item.status)"
+                >{{ salesStatusLabel(item.status) }}</span>
+              </div>
+              <div class="rch-sales-meta">
+                <span><i class="el-icon-office-building"></i>{{ item.corpName || '-' }}</span>
+                <span><i class="el-icon-time"></i>{{ item.addTime || '-' }}</span>
+              </div>
+              <span v-if="item.hasPermission === false" class="rch-lock-tag">无权查看</span>
+            </div>
+          </div>
+          <div v-else class="rch-empty">暂无关联销售数据</div>
+        </section>
+
+        <!-- 课程学习进度 -->
+        <section class="rch-panel">
+          <div class="rch-panel-head">
+            <span class="rch-panel-icon rch-panel-icon--course"><i class="el-icon-reading"></i></span>
+            <span class="rch-panel-title">课程学习进度</span>
+            <span v-if="selectedProjectId" class="rch-panel-count">{{ filteredCourses.length }} 门</span>
+          </div>
+          <div class="rch-course-grid" v-if="filteredCourses.length">
+            <div
+              v-for="(course, idx) in filteredCourses"
+              :key="course.courseId || idx"
+              class="rch-course-card"
+              :class="{ 'rch-course-card--done': course.finished }"
+            >
+              <div class="rch-course-top">
+                <div class="rch-course-title">
+                  <span class="rch-course-idx">{{ idx + 1 }}</span>
+                  <span class="rch-course-name">{{ course.courseName }}</span>
+                </div>
+                <span
+                  class="rch-tag"
+                  :class="course.finished ? 'rch-tag--success' : 'rch-tag--warning'"
+                >{{ course.finished ? '已完课' : '学习中' }}</span>
+              </div>
+              <div class="rch-course-progress">
+                <div class="rch-progress-track">
+                  <div
+                    class="rch-progress-fill"
+                    :style="{ width: course.percentage + '%' }"
+                    :class="{ 'rch-progress-fill--done': course.finished }"
+                  ></div>
+                </div>
+                <span class="rch-progress-label">
+                  <strong>{{ course.watchedCount }}</strong>/{{ course.totalCount }} 节 · {{ course.percentage }}%
+                </span>
+              </div>
+              <div v-if="course.latestSection" class="rch-course-latest">
+                <i class="el-icon-video-play"></i>
+                <span>{{ course.latestSection }}</span>
+                <time v-if="course.latestTime">{{ course.latestTime }}</time>
+              </div>
+              <div v-if="course.qwUserName || course.corpName" class="rch-course-source">
+                <span v-if="course.qwUserName"><i class="el-icon-user"></i>{{ course.qwUserName }}</span>
+                <span v-if="course.corpName"><i class="el-icon-office-building"></i>{{ course.corpName }}</span>
+                <span v-if="course.hasPermission === false" class="rch-lock-tag rch-lock-tag--inline">无权查看</span>
+              </div>
+            </div>
+          </div>
+          <div v-else-if="selectedProjectId" class="rch-empty">该项目下暂无课程学习记录</div>
+          <div v-else class="rch-empty">请先选择看课项目</div>
+        </section>
+
+        <!-- 详细看课记录 -->
+        <section class="rch-panel rch-panel--detail">
+          <div class="rch-panel-head rch-panel-head--clickable" @click="detailExpanded = !detailExpanded">
+            <span class="rch-panel-icon rch-panel-icon--log"><i class="el-icon-document"></i></span>
+            <span class="rch-panel-title">详细看课记录</span>
+            <span v-if="detailExpanded && total" class="rch-panel-count">{{ total }} 条</span>
+            <i :class="detailExpanded ? 'el-icon-arrow-up' : 'el-icon-arrow-down'" class="rch-expand-icon"></i>
+          </div>
+          <el-collapse-transition>
+            <div v-show="detailExpanded" class="rch-detail-body">
+              <div class="rch-filter-bar">
+                <el-select
+                  v-model="queryParams.courseId"
+                  filterable
+                  placeholder="筛选课程"
+                  clearable
+                  size="small"
+                  class="rch-filter-select"
+                >
+                  <el-option
+                    v-for="c in filteredCourses"
+                    :key="c.courseId"
+                    :label="c.courseName"
+                    :value="c.courseId"
+                  />
+                </el-select>
+                <el-button type="primary" size="small" icon="el-icon-search" @click="handleQueryWatchLog">查询</el-button>
+              </div>
+              <el-table :data="list" size="small" class="rch-table" stripe>
+                <el-table-column label="企微主体" prop="corpName" min-width="110" show-overflow-tooltip>
+                  <template slot-scope="scope">
+                    <span :class="{ 'rch-no-perm': scope.row.hasPermission === false }">{{ scope.row.corpName }}</span>
+                  </template>
+                </el-table-column>
+                <el-table-column label="所属企微" prop="qwUserName" min-width="90" show-overflow-tooltip>
+                  <template slot-scope="scope">
+                    <span :class="{ 'rch-no-perm': scope.row.hasPermission === false }">{{ scope.row.qwUserName }}</span>
+                  </template>
+                </el-table-column>
+                <el-table-column label="项目" prop="projectName" min-width="90" show-overflow-tooltip />
+                <el-table-column label="课程" prop="courseName" min-width="110" show-overflow-tooltip />
+                <el-table-column label="小节" prop="videoName" min-width="90" show-overflow-tooltip />
+                <el-table-column label="记录时间" prop="createTime" min-width="140" show-overflow-tooltip />
+                <el-table-column label="完课" prop="logType" width="72" align="center">
+                  <template slot-scope="scope">
+                    <span
+                      class="rch-tag rch-tag--mini"
+                      :class="scope.row.logType == 2 ? 'rch-tag--success' : 'rch-tag--muted'"
+                    >{{ scope.row.logType == 2 ? '已完课' : '未完课' }}</span>
+                  </template>
+                </el-table-column>
+                <el-table-column label="完课时间" prop="finishTime" min-width="140" show-overflow-tooltip />
+              </el-table>
+              <pagination
+                v-show="total > 0"
+                :total="total"
+                :page.sync="queryParams.pageNum"
+                :limit.sync="queryParams.pageSize"
+                @pagination="fetchWatchLogList"
+              />
+            </div>
+          </el-collapse-transition>
+        </section>
+      </div>
+    </div>
+  </el-drawer>
+</template>
+
+<script>
+import { getRepeatCourseHistory, getWatchLogList } from '@/api/qw/externalContact'
+
+const SALES_STATUS = { 0: '正常', 1: '离职待接替', 2: '正在接替', 3: '流失', 4: '删除' }
+
+export default {
+  name: 'RepeatCourseHistoryDrawer',
+  data() {
+    return {
+      visible: false,
+      loading: false,
+      list: [],
+      total: 0,
+      detailExpanded: false,
+      currentRow: {},
+      projectList: [],
+      selectedProjectId: null,
+      mockSales: [],
+      mockCourses: [],
+      projectOptions: [],
+      queryParams: {
+        pageNum: 1,
+        pageSize: 10,
+        fsUserId: null,
+        projectId: null,
+        courseId: null,
+      },
+      defaultAvatar: require('@/assets/images/profile.jpg'),
+    }
+  },
+  computed: {
+    filteredCourses() {
+      const courses = this.mockCourses || []
+      const selectedId = this.selectedProjectId
+      if (selectedId == null || selectedId === '') return []
+      const matchIds = this.getMatchProjectIds(selectedId)
+      return courses.filter(c => matchIds.includes(Number(c.projectId)))
+    },
+    showEquivalentHint() {
+      const ids = new Set(this.projectList.map(p => Number(p.projectId)))
+      return ids.has(1) && ids.has(28)
+    },
+  },
+  created() {
+    this.getDicts('sys_course_project').then(res => {
+      this.projectOptions = res.data || []
+    })
+  },
+  methods: {
+    open(row) {
+      if (!row || !row.fsUserId) return
+      this.queryParams.fsUserId = row.fsUserId
+      this.visible = true
+      this.loading = true
+      this.detailExpanded = false
+      this.projectList = []
+      this.selectedProjectId = null
+      this.list = []
+      this.total = 0
+      this.currentRow = {
+        name: row.name,
+        avatar: row.avatar,
+        remark: row.remark,
+        isRepeat: row.isRepeat,
+      }
+      getRepeatCourseHistory(row.fsUserId).then(res => {
+        const data = res.data
+        if (data) {
+          this.currentRow = {
+            name: data.name || row.name,
+            avatar: data.avatar || row.avatar,
+            remark: data.remark || row.remark,
+            isRepeat: data.userRepeat != null ? data.userRepeat : row.isRepeat,
+          }
+          this.projectList = this.buildDisplayProjectList(data.projectList)
+          this.mockSales = (data.salesList || []).map(s => ({
+            qwUserName: s.qwUserName,
+            corpName: s.corpName,
+            addTime: s.addTime,
+            status: s.status,
+            hasPermission: s.hasPermission,
+          }))
+          this.mockCourses = (data.courseList || []).map(c => ({
+            courseId: c.courseId,
+            courseName: c.courseName,
+            projectId: c.projectId != null ? Number(c.projectId) : null,
+            watchedCount: c.watchedCount,
+            totalCount: c.totalCount,
+            percentage: c.percentage,
+            latestSection: c.latestSection,
+            latestTime: c.latestTime,
+            finished: c.finished,
+            qwUserName: c.qwUserName,
+            corpName: c.corpName,
+            hasPermission: c.hasPermission,
+          }))
+          if (this.projectList.length > 0) {
+            this.selectedProjectId = this.projectList[0].projectId
+            this.queryParams.projectId = this.selectedProjectId
+          }
+          this.fetchWatchLogList()
+        }
+        this.loading = false
+      }).catch(() => {
+        this.loading = false
+      })
+    },
+    handleClosed() {
+      this.queryParams.courseId = null
+      this.queryParams.pageNum = 1
+    },
+    getMatchProjectIds(projectId) {
+      const pid = Number(projectId)
+      return pid === 1 || pid === 28 ? [1, 28] : [pid]
+    },
+    getProjectLabelById(projectId) {
+      const id = Number(projectId)
+      const found = this.projectOptions.find(o => Number(o.dictValue) === id)
+      return found ? found.dictLabel : `项目${id}`
+    },
+    buildDisplayProjectList(projectList) {
+      const equivalentMap = { 1: 28, 28: 1 }
+      const list = (projectList || []).map(p => ({
+        projectId: Number(p.projectId),
+        projectName: p.projectName || this.getProjectLabelById(p.projectId),
+        isRepeat: p.isRepeat,
+      }))
+      const idSet = new Set(list.map(p => p.projectId))
+      list.forEach(item => {
+        const eqId = equivalentMap[item.projectId]
+        if (eqId != null && !idSet.has(eqId)) {
+          list.push({
+            projectId: eqId,
+            projectName: this.getProjectLabelById(eqId),
+            isRepeat: item.isRepeat,
+          })
+          idSet.add(eqId)
+        }
+      })
+      return list.sort((a, b) => a.projectId - b.projectId)
+    },
+    selectProject(projectId) {
+      const pid = Number(projectId)
+      this.selectedProjectId = pid
+      this.queryParams.projectId = pid
+      this.queryParams.courseId = null
+      this.queryParams.pageNum = 1
+      this.detailExpanded = true
+      this.fetchWatchLogList()
+    },
+    handleQueryWatchLog() {
+      this.queryParams.pageNum = 1
+      this.fetchWatchLogList()
+    },
+    fetchWatchLogList() {
+      const params = {
+        pageNum: this.queryParams.pageNum,
+        pageSize: this.queryParams.pageSize,
+        fsUserId: this.queryParams.fsUserId,
+        courseId: this.queryParams.courseId || undefined,
+        projectId: this.queryParams.projectId || undefined,
+      }
+      getWatchLogList(params).then(e => {
+        this.list = e.rows || []
+        this.total = e.total || 0
+      })
+    },
+    salesStatusLabel(status) {
+      return SALES_STATUS[status] || '未知'
+    },
+    salesStatusClass(status) {
+      if (status === 0) return 'rch-tag--success'
+      if (status === 1 || status === 2) return 'rch-tag--warning'
+      return 'rch-tag--danger'
+    },
+  },
+}
+</script>
+
+<style>
+.repeat-course-history-drawer .el-drawer__body {
+  padding: 0;
+  overflow: hidden;
+  height: 100%;
+}
+</style>
+
+<style scoped>
+.rch-drawer {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+  background: #f1f5f9;
+  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'PingFang SC', 'Microsoft YaHei', sans-serif;
+}
+
+/* ===== Hero ===== */
+.rch-hero {
+  position: relative;
+  flex-shrink: 0;
+  background: #fff;
+  padding: 20px 24px 0;
+  color: #1e293b;
+  border-bottom: 1px solid #e2e8f0;
+}
+.rch-close {
+  position: absolute;
+  top: 16px;
+  right: 16px;
+  width: 32px;
+  height: 32px;
+  border: none;
+  border-radius: 8px;
+  background: #f1f5f9;
+  color: #64748b;
+  cursor: pointer;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 16px;
+  transition: background 0.2s, color 0.2s;
+  z-index: 2;
+}
+.rch-close:hover {
+  background: #e2e8f0;
+  color: #334155;
+}
+.rch-hero-inner {
+  display: flex;
+  align-items: center;
+  gap: 16px;
+  padding-right: 40px;
+  margin-bottom: 20px;
+}
+.rch-avatar-wrap {
+  position: relative;
+  flex-shrink: 0;
+}
+.rch-avatar {
+  width: 56px;
+  height: 56px;
+  border-radius: 16px;
+  object-fit: cover;
+  border: 3px solid #e2e8f0;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
+}
+.rch-name-row {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  flex-wrap: wrap;
+}
+.rch-name {
+  margin: 0;
+  font-size: 20px;
+  font-weight: 700;
+  letter-spacing: -0.02em;
+  line-height: 1.3;
+  color: #0f172a;
+}
+.rch-remark {
+  margin: 6px 0 0;
+  font-size: 13px;
+  color: #64748b;
+  line-height: 1.5;
+  display: flex;
+  align-items: flex-start;
+  gap: 4px;
+}
+.rch-remark i {
+  margin-top: 2px;
+  flex-shrink: 0;
+}
+.rch-stats {
+  display: flex;
+  align-items: center;
+  background: #f8fafc;
+  border: 1px solid #e2e8f0;
+  border-bottom: none;
+  border-radius: 12px 12px 0 0;
+  padding: 14px 0;
+}
+.rch-stat {
+  flex: 1;
+  text-align: center;
+}
+.rch-stat-num {
+  display: block;
+  font-size: 22px;
+  font-weight: 700;
+  line-height: 1.2;
+  color: #0f172a;
+}
+.rch-stat-label {
+  display: block;
+  font-size: 12px;
+  color: #64748b;
+  margin-top: 2px;
+}
+.rch-stat-divider {
+  width: 1px;
+  height: 32px;
+  background: #e2e8f0;
+}
+
+/* ===== Body ===== */
+.rch-body {
+  flex: 1;
+  overflow-y: auto;
+  padding: 16px;
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+.rch-body::-webkit-scrollbar {
+  width: 6px;
+}
+.rch-body::-webkit-scrollbar-thumb {
+  background: #cbd5e1;
+  border-radius: 3px;
+}
+
+/* ===== Panel ===== */
+.rch-panel {
+  background: #fff;
+  border-radius: 12px;
+  padding: 16px;
+  box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
+}
+.rch-panel-head {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin-bottom: 14px;
+}
+.rch-panel-head--clickable {
+  cursor: pointer;
+  user-select: none;
+  margin-bottom: 0;
+}
+.rch-panel-head--clickable + .rch-detail-body,
+.rch-panel--detail .rch-panel-head--clickable {
+  margin-bottom: 14px;
+}
+.rch-panel-icon {
+  width: 28px;
+  height: 28px;
+  border-radius: 8px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 14px;
+}
+.rch-panel-icon--project { background: #eef2ff; color: #6366f1; }
+.rch-panel-icon--sales { background: #ecfdf5; color: #10b981; }
+.rch-panel-icon--course { background: #fff7ed; color: #f59e0b; }
+.rch-panel-icon--log { background: #f0f9ff; color: #0ea5e9; }
+.rch-panel-title {
+  font-size: 15px;
+  font-weight: 600;
+  color: #1e293b;
+}
+.rch-panel-count {
+  margin-left: auto;
+  font-size: 12px;
+  color: #94a3b8;
+  font-weight: 500;
+}
+.rch-expand-icon {
+  margin-left: 4px;
+  color: #94a3b8;
+  font-size: 14px;
+}
+
+/* ===== Project chips ===== */
+.rch-project-chips {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+}
+.rch-chip {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  padding: 6px 14px;
+  border-radius: 20px;
+  border: 1.5px solid #e2e8f0;
+  background: #f8fafc;
+  color: #475569;
+  font-size: 13px;
+  font-weight: 500;
+  cursor: pointer;
+  transition: all 0.2s;
+  outline: none;
+}
+.rch-chip:hover {
+  border-color: #6366f1;
+  color: #6366f1;
+  background: #eef2ff;
+}
+.rch-chip--active {
+  background: #6366f1;
+  border-color: #6366f1;
+  color: #fff;
+  box-shadow: 0 2px 8px rgba(99, 102, 241, 0.35);
+}
+.rch-chip--repeat:not(.rch-chip--active) {
+  border-color: #fecaca;
+  color: #dc2626;
+  background: #fef2f2;
+}
+.rch-chip--repeat.rch-chip--active {
+  background: #dc2626;
+  border-color: #dc2626;
+  box-shadow: 0 2px 8px rgba(220, 38, 38, 0.35);
+}
+.rch-chip-warn {
+  font-size: 12px;
+}
+.rch-hint {
+  margin: 10px 0 0;
+  padding: 8px 12px;
+  background: #f0f9ff;
+  border-radius: 8px;
+  font-size: 12px;
+  color: #0369a1;
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  line-height: 1.4;
+}
+
+/* ===== Sales ===== */
+.rch-sales-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
+  gap: 10px;
+}
+.rch-sales-item {
+  position: relative;
+  padding: 12px 14px;
+  border-radius: 10px;
+  border: 1px solid #e2e8f0;
+  background: #fafbfc;
+  transition: box-shadow 0.2s, border-color 0.2s;
+}
+.rch-sales-item:hover {
+  border-color: #cbd5e1;
+  box-shadow: 0 2px 8px rgba(15, 23, 42, 0.06);
+}
+.rch-sales-item--locked {
+  opacity: 0.72;
+}
+.rch-sales-top {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 8px;
+  margin-bottom: 8px;
+}
+.rch-sales-name {
+  font-weight: 600;
+  color: #1e293b;
+  font-size: 14px;
+}
+.rch-sales-meta {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+  font-size: 12px;
+  color: #64748b;
+}
+.rch-sales-meta span {
+  display: flex;
+  align-items: center;
+  gap: 5px;
+}
+.rch-sales-meta i {
+  color: #94a3b8;
+  width: 14px;
+  text-align: center;
+}
+
+/* ===== Tags ===== */
+.rch-tag {
+  display: inline-flex;
+  align-items: center;
+  padding: 2px 8px;
+  border-radius: 6px;
+  font-size: 11px;
+  font-weight: 600;
+  white-space: nowrap;
+}
+.rch-tag--mini {
+  padding: 1px 6px;
+  font-size: 11px;
+}
+.rch-tag--success { background: #dcfce7; color: #15803d; }
+.rch-tag--warning { background: #fef3c7; color: #b45309; }
+.rch-tag--danger { background: #fee2e2; color: #b91c1c; }
+.rch-tag--muted { background: #f1f5f9; color: #64748b; }
+.rch-lock-tag {
+  position: absolute;
+  top: 10px;
+  right: 10px;
+  font-size: 11px;
+  color: #94a3b8;
+  background: #f1f5f9;
+  padding: 1px 6px;
+  border-radius: 4px;
+}
+.rch-lock-tag--inline {
+  position: static;
+  margin-left: auto;
+}
+
+/* ===== Courses ===== */
+.rch-course-grid {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+}
+.rch-course-card {
+  padding: 14px 16px;
+  border-radius: 10px;
+  border: 1px solid #e2e8f0;
+  background: #fff;
+  transition: box-shadow 0.2s;
+}
+.rch-course-card:hover {
+  box-shadow: 0 4px 12px rgba(15, 23, 42, 0.08);
+}
+.rch-course-card--done {
+  border-left: 3px solid #10b981;
+}
+.rch-course-top {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  gap: 10px;
+  margin-bottom: 10px;
+}
+.rch-course-title {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  min-width: 0;
+}
+.rch-course-idx {
+  flex-shrink: 0;
+  width: 22px;
+  height: 22px;
+  border-radius: 6px;
+  background: #6366f1;
+  color: #fff;
+  font-size: 11px;
+  font-weight: 700;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+.rch-course-card--done .rch-course-idx {
+  background: #10b981;
+}
+.rch-course-name {
+  font-weight: 600;
+  color: #1e293b;
+  font-size: 14px;
+  line-height: 1.4;
+}
+.rch-course-progress {
+  margin-bottom: 8px;
+}
+.rch-progress-track {
+  height: 6px;
+  background: #e2e8f0;
+  border-radius: 3px;
+  overflow: hidden;
+  margin-bottom: 6px;
+}
+.rch-progress-fill {
+  height: 100%;
+  border-radius: 3px;
+  background: linear-gradient(90deg, #6366f1, #818cf8);
+  transition: width 0.4s ease;
+}
+.rch-progress-fill--done {
+  background: linear-gradient(90deg, #10b981, #34d399);
+}
+.rch-progress-label {
+  font-size: 12px;
+  color: #64748b;
+}
+.rch-progress-label strong {
+  color: #1e293b;
+  font-size: 13px;
+}
+.rch-course-latest {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  font-size: 12px;
+  color: #6366f1;
+  margin-bottom: 6px;
+  flex-wrap: wrap;
+}
+.rch-course-latest i {
+  color: #818cf8;
+}
+.rch-course-latest time {
+  color: #94a3b8;
+  margin-left: auto;
+}
+.rch-course-source {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  font-size: 12px;
+  color: #94a3b8;
+  flex-wrap: wrap;
+}
+.rch-course-source span {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+}
+
+/* ===== Detail ===== */
+.rch-detail-body {
+  padding-top: 4px;
+}
+.rch-filter-bar {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  margin-bottom: 12px;
+}
+.rch-filter-select {
+  flex: 1;
+  max-width: 320px;
+}
+.rch-table {
+  border-radius: 8px;
+  overflow: hidden;
+}
+.rch-no-perm {
+  color: #cbd5e1;
+  font-style: italic;
+}
+
+/* ===== Empty ===== */
+.rch-empty {
+  text-align: center;
+  color: #94a3b8;
+  padding: 24px 0;
+  font-size: 13px;
+}
+</style>

+ 10 - 418
src/views/qw/externalContact/index.vue

@@ -136,7 +136,7 @@
           <el-button
             size="mini"
             type="text"
-            @click="showLog(scope.row)"
+            @click="openRepeatCourseHistory(scope.row)"
           >看课历史
           </el-button>
         </div>
@@ -157,160 +157,19 @@
         @pagination="getList"
     />
 
-    <!-- 重粉看课记录 -->
-    <el-dialog
-      title="重粉看课历史"
-      :visible.sync="log.open"
-      width="900px"
-      append-to-body
-      custom-class="repeat-course-history-dialog"
-      :close-on-click-modal="false"
-    >
-      <div class="repeat-history-content" v-loading="log.loading">
-        <div class="customer-info-card">
-          <div class="customer-avatar">
-            <img :src="log.currentRow.avatar || require('@/assets/images/profile.jpg')" alt="" />
-          </div>
-          <div class="customer-detail">
-            <div class="customer-name-row">
-              <span class="customer-name">{{ log.currentRow.name || '-' }}</span>
-              <el-tag v-if="log.currentRow.isRepeat == 0" type="success" size="small">正常</el-tag>
-              <el-tag v-if="log.currentRow.isRepeat == 1" type="danger" size="small">重粉</el-tag>
-            </div>
-            <div class="customer-meta">
-              <span v-if="log.currentRow.remark">备注:{{ log.currentRow.remark }}</span>
-            </div>
-          </div>
-        </div>
-
-        <div class="section-block">
-          <div class="section-title">
-            <i class="el-icon-user"></i>
-            <span>关联销售</span>
-            <el-tag size="mini" type="info">{{ log.mockSales.length }}人</el-tag>
-          </div>
-          <div class="sales-cards" v-if="log.mockSales.length > 0">
-            <div class="sales-card" v-for="(item, idx) in log.mockSales" :key="idx">
-              <div class="sales-card-top">
-                <span class="sales-name">{{ item.qwUserName }}</span>
-                <el-tag size="mini" v-if="item.status != null" :type="item.status === 0 ? 'success' : item.status === 1 ? 'warning' : item.status === 2 ? '' : 'danger'">{{ {0:'正常',1:'离职待接替',2:'正在接替',3:'流失',4:'删除'}[item.status] || '未知' }}</el-tag>
-                <el-tag size="mini" type="info" v-if="item.hasPermission === false">无权查看</el-tag>
-              </div>
-              <div class="sales-card-body">
-                <div class="sales-card-row">
-                  <span class="label">所属主体</span>
-                  <span class="value">{{ item.corpName }}</span>
-                </div>
-                <div class="sales-card-row">
-                  <span class="label">添加时间</span>
-                  <span class="value">{{ item.addTime }}</span>
-                </div>
-              </div>
-            </div>
-          </div>
-          <div class="empty-tip" v-else>暂无关联销售数据</div>
-        </div>
-
-        <div class="section-block">
-          <div class="section-title">
-            <i class="el-icon-reading"></i>
-            <span>课程学习进度</span>
-            <el-tag size="mini" type="info">{{ log.mockCourses.length }}门</el-tag>
-          </div>
-          <div class="course-list" v-if="log.mockCourses.length > 0">
-            <div class="course-item" v-for="(course, idx) in log.mockCourses" :key="idx">
-              <div class="course-header">
-                <div class="course-name-wrap">
-                  <span class="course-index">{{ idx + 1 }}</span>
-                  <span class="course-name">{{ course.courseName }}</span>
-                </div>
-                <div class="course-status">
-                  <el-tag v-if="course.finished" type="success" size="mini" effect="plain">已完课</el-tag>
-                  <el-tag v-else type="warning" size="mini" effect="plain">学习中</el-tag>
-                </div>
-              </div>
-              <div class="course-progress">
-                <el-progress
-                  :percentage="course.percentage"
-                  :stroke-width="10"
-                  :color="course.finished ? '#67C23A' : '#409EFF'"
-                ></el-progress>
-                <span class="progress-text">已学 <b>{{ course.watchedCount }}</b>/{{ course.totalCount }}节</span>
-              </div>
-              <div class="course-latest">
-                <span class="latest-label">最新学习:</span>
-                <span class="latest-section">{{ course.latestSection }}</span>
-                <span class="latest-time" v-if="course.latestTime">{{ course.latestTime }}</span>
-              </div>
-              <div class="course-source" v-if="course.qwUserName || course.corpName">
-                <span class="source-item" v-if="course.qwUserName"><i class="el-icon-user"></i> {{ course.qwUserName }}</span>
-                <span class="source-item" v-if="course.corpName"><i class="el-icon-office-building"></i> {{ course.corpName }}</span>
-                <el-tag size="mini" type="info" v-if="course.hasPermission === false" style="margin-left: 4px;">无权查看</el-tag>
-              </div>
-            </div>
-          </div>
-          <div class="empty-tip" v-else>暂无课程学习记录</div>
-        </div>
-
-        <div class="section-block">
-          <div class="section-title" @click="log.detailExpanded = !log.detailExpanded" style="cursor: pointer;">
-            <i class="el-icon-document"></i>
-            <span>详细看课记录</span>
-            <i :class="log.detailExpanded ? 'el-icon-arrow-up' : 'el-icon-arrow-down'" style="margin-left: 4px;"></i>
-          </div>
-          <div v-show="log.detailExpanded">
-            <el-form :model="log.queryParams" :inline="true" label-width="80px" size="small">
-              <el-form-item label="课程" prop="courseId">
-                <el-select filterable v-model="log.queryParams.courseId" placeholder="请选择课程" clearable>
-                  <el-option
-                    v-for="c in log.mockCourses"
-                    :key="c.courseId"
-                    :label="c.courseName"
-                    :value="c.courseId"
-                  />
-                </el-select>
-              </el-form-item>
-              <el-form-item>
-                <el-button type="primary" icon="el-icon-search" size="mini" @click="handleQueryWatchLog">搜索</el-button>
-              </el-form-item>
-            </el-form>
-            <el-table :data="log.list" size="small" border>
-              <el-table-column label="所属企微主体" align="center" prop="corpName" min-width="120" />
-              <el-table-column label="所属企微" align="center" prop="qwUserName" min-width="100" />
-              <el-table-column label="项目" align="center" prop="projectName" min-width="100"/>
-              <el-table-column label="课程" align="center" prop="courseName" min-width="120"/>
-              <el-table-column label="小节" align="center" prop="videoName" min-width="100"/>
-              <el-table-column label="记录时间" align="center" prop="createTime" min-width="140"/>
-              <el-table-column label="是否完课" align="center" prop="logType" width="80">
-                <template slot-scope="scope">
-                  <el-tag v-if="scope.row.logType == 2" type="success" size="mini">已完课</el-tag>
-                  <el-tag v-else type="info" size="mini">未完课</el-tag>
-                </template>
-              </el-table-column>
-              <el-table-column label="完课时间" align="center" prop="finishTime" min-width="140"/>
-            </el-table>
-            <pagination
-              v-show="log.total>0"
-              :total="log.total"
-              :page.sync="log.queryParams.pageNum"
-              :limit.sync="log.queryParams.pageSize"
-              @pagination="logList"
-            />
-          </div>
-        </div>
-      </div>
-    </el-dialog>
+    <repeat-course-history-drawer ref="repeatCourseHistoryDrawer" />
 
   </div>
 </template>
 
 <script>
-import { listExternalContact, getWatchLogList, getRepeatCourseHistory } from "@/api/qw/externalContact";
+import { listExternalContact } from "@/api/qw/externalContact";
 import PaginationMore from '@/components/PaginationMore/index.vue'
+import RepeatCourseHistoryDrawer from './RepeatCourseHistoryDrawer.vue'
 
 export default {
   name: "ExternalContact",
-  components: { PaginationMore },
+  components: { PaginationMore, RepeatCourseHistoryDrawer },
   data() {
     return {
       loading: false,
@@ -327,22 +186,6 @@ export default {
       showSearch: true,
       total: 0,
       externalContactList: [],
-      log: {
-        open: false,
-        loading: false,
-        list: [],
-        total: 0,
-        detailExpanded: false,
-        currentRow: {},
-        mockSales: [],
-        mockCourses: [],
-        queryParams: {
-          pageNum: 1,
-          pageSize: 10,
-          fsUserId: null,
-          courseId: null,
-        },
-      },
       queryParams: {
         pageNum: 1,
         pageSize: 10,
@@ -388,70 +231,12 @@ export default {
     });
   },
   methods: {
-    showLog(row) {
-      this.log.queryParams.fsUserId = row.fsUserId;
-      this.log.open = true;
-      this.log.loading = true;
-      this.log.detailExpanded = false;
-      this.log.currentRow = {
-        name: row.name,
-        avatar: row.avatar,
-        remark: row.remark,
-        isRepeat: row.isRepeat,
-      };
-      getRepeatCourseHistory(row.fsUserId).then(res => {
-        const data = res.data;
-        if (data) {
-          this.log.currentRow = {
-            name: data.name || row.name,
-            avatar: data.avatar || row.avatar,
-            remark: data.remark || row.remark,
-            isRepeat: data.userRepeat != null ? data.userRepeat : row.isRepeat,
-          };
-          this.log.mockSales = (data.salesList || []).map(s => ({
-            qwUserName: s.qwUserName,
-            corpName: s.corpName,
-            addTime: s.addTime,
-            status: s.status,
-            hasPermission: s.hasPermission,
-          }));
-          this.log.mockCourses = (data.courseList || []).map(c => ({
-            courseId: c.courseId,
-            courseName: c.courseName,
-            watchedCount: c.watchedCount,
-            totalCount: c.totalCount,
-            percentage: c.percentage,
-            latestSection: c.latestSection,
-            latestTime: c.latestTime,
-            finished: c.finished,
-            qwUserName: c.qwUserName,
-            corpName: c.corpName,
-            hasPermission: c.hasPermission,
-          }));
-        }
-        this.log.loading = false;
-      }).catch(() => {
-        this.log.loading = false;
-      });
-      this.logList();
-    },
-    handleQueryWatchLog() {
-      this.log.queryParams.pageNum = 1;
-      this.logList();
-    },
-    logList() {
-      const params = {
-        pageNum: this.log.queryParams.pageNum,
-        pageSize: this.log.queryParams.pageSize,
-        fsUserId: this.log.queryParams.fsUserId,
-        courseId: this.log.queryParams.courseId || undefined,
-      };
-      getWatchLogList(params).then(e => {
-        this.log.list = e.rows;
-        this.log.total = e.total;
-      });
+    openRepeatCourseHistory(row) {
+      const drawer = this.$refs.repeatCourseHistoryDrawer
+      if (drawer && typeof drawer.open === 'function') {
+        drawer.open(row)
+      }
     },
-
     /** 查询企业微信客户列表 */
     getList() {
       this.loading = true;
@@ -490,196 +275,3 @@ export default {
 };
 </script>
 
-<style scoped>
-</style>
-
-<style>
-.repeat-course-history-dialog {
-  border-radius: 8px;
-}
-.repeat-course-history-dialog .el-dialog__header {
-  border-bottom: 1px solid #ebeef5;
-  padding-bottom: 15px;
-}
-.repeat-course-history-dialog .el-dialog__body {
-  padding: 16px 20px;
-  max-height: 70vh;
-  overflow-y: auto;
-}
-.repeat-history-content {
-  font-size: 14px;
-  color: #303133;
-}
-.customer-info-card {
-  display: flex;
-  align-items: center;
-  padding: 12px 16px;
-  background: #f5f7fa;
-  border-radius: 8px;
-  margin-bottom: 20px;
-}
-.customer-info-card .customer-avatar {
-  width: 48px;
-  height: 48px;
-  border-radius: 50%;
-  overflow: hidden;
-  margin-right: 14px;
-  flex-shrink: 0;
-  border: 2px solid #e4e7ed;
-}
-.customer-info-card .customer-avatar img {
-  width: 100%;
-  height: 100%;
-  object-fit: cover;
-}
-.customer-info-card .customer-detail {
-  flex: 1;
-}
-.customer-info-card .customer-name-row {
-  display: flex;
-  align-items: center;
-  gap: 8px;
-  margin-bottom: 4px;
-}
-.customer-info-card .customer-name {
-  font-size: 16px;
-  font-weight: 600;
-  color: #303133;
-}
-.customer-info-card .customer-meta {
-  color: #909399;
-  font-size: 12px;
-}
-.section-block {
-  margin-bottom: 20px;
-}
-.section-title {
-  display: flex;
-  align-items: center;
-  gap: 6px;
-  font-size: 15px;
-  font-weight: 600;
-  color: #303133;
-  padding-bottom: 10px;
-  border-bottom: 1px solid #ebeef5;
-  margin-bottom: 12px;
-}
-.section-title i:first-child {
-  color: #409EFF;
-  font-size: 16px;
-}
-.sales-cards {
-  display: flex;
-  flex-wrap: wrap;
-  gap: 12px;
-}
-.sales-card {
-  flex: 1;
-  min-width: 220px;
-  max-width: 280px;
-  border: 1px solid #e4e7ed;
-  border-radius: 6px;
-  padding: 12px;
-  background: #fff;
-}
-.sales-card-top {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  margin-bottom: 8px;
-}
-.sales-card-top .sales-name {
-  font-weight: 600;
-  color: #303133;
-  font-size: 14px;
-}
-.sales-card-body {
-  display: flex;
-  flex-direction: column;
-  gap: 4px;
-}
-.sales-card-row {
-  display: flex;
-  align-items: center;
-  font-size: 12px;
-}
-.sales-card-row .label {
-  color: #909399;
-  width: 60px;
-  flex-shrink: 0;
-}
-.sales-card-row .value {
-  color: #606266;
-}
-.course-list {
-  display: flex;
-  flex-direction: column;
-  gap: 10px;
-}
-.course-item {
-  border: 1px solid #e4e7ed;
-  border-radius: 6px;
-  padding: 12px 14px;
-  background: #fff;
-}
-.course-header {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  margin-bottom: 8px;
-}
-.course-name-wrap {
-  display: flex;
-  align-items: center;
-  gap: 8px;
-}
-.course-index {
-  display: inline-flex;
-  align-items: center;
-  justify-content: center;
-  width: 22px;
-  height: 22px;
-  border-radius: 50%;
-  background: #409EFF;
-  color: #fff;
-  font-size: 12px;
-  font-weight: 600;
-}
-.course-name {
-  font-weight: 600;
-  color: #303133;
-  font-size: 14px;
-}
-.course-progress {
-  display: flex;
-  align-items: center;
-  gap: 10px;
-  margin-bottom: 6px;
-}
-.course-progress .el-progress {
-  flex: 1;
-}
-.course-progress .progress-text {
-  font-size: 12px;
-  color: #909399;
-  white-space: nowrap;
-}
-.course-latest {
-  font-size: 12px;
-  color: #606266;
-  margin-bottom: 4px;
-}
-.course-source {
-  font-size: 12px;
-  color: #909399;
-}
-.course-source .source-item {
-  margin-right: 12px;
-}
-.empty-tip {
-  color: #909399;
-  font-size: 13px;
-  text-align: center;
-  padding: 20px 0;
-}
-</style>