2 İşlemeler 1cfbf4cf1d ... ea99cdab2b

Yazar SHA1 Mesaj Tarih
  xw ea99cdab2b 直播飄飄和評論指定 1 hafta önce
  xw e00df8b36a 商城 2 hafta önce

+ 26 - 0
src/api/live/commentFeature.js

@@ -0,0 +1,26 @@
+import request from '@/utils/request'
+
+/** 获取全站直播评论飘屏/置顶全局配置 */
+export function getCommentFeatureConfig() {
+  return request({
+    url: '/live/commentFeature/config',
+    method: 'get'
+  })
+}
+
+/** 保存全站直播评论飘屏/置顶全局配置 */
+export function updateCommentFeatureConfig(data) {
+  return request({
+    url: '/live/commentFeature/config',
+    method: 'put',
+    data
+  })
+}
+
+/** 可飘屏/可置顶角色候选(distinct role_name) */
+export function getCommentFeatureRoles() {
+  return request({
+    url: '/live/commentFeature/roles',
+    method: 'get'
+  })
+}

+ 34 - 0
src/api/live/commentPin.js

@@ -0,0 +1,34 @@
+import request from '@/utils/request'
+
+/** 全站当前生效置顶列表(监控) */
+export function listActivePinMonitor() {
+  return request({
+    url: '/live/commentPin/monitor/active',
+    method: 'get'
+  })
+}
+
+/** 置顶操作记录分页 */
+export function listCommentPinLog(query) {
+  return request({
+    url: '/live/commentPin/log/list',
+    method: 'get',
+    params: query
+  })
+}
+
+/** 某直播间当前置顶列表 */
+export function listActivePinByLiveId(liveId) {
+  return request({
+    url: '/live/commentPin/active/' + liveId,
+    method: 'get'
+  })
+}
+
+/** 后台强制取消生效置顶 */
+export function forceCancelActivePin(activeId) {
+  return request({
+    url: '/live/commentPin/active/' + activeId + '/forceCancel',
+    method: 'post'
+  })
+}

+ 10 - 0
src/api/live/floatMsgLog.js

@@ -0,0 +1,10 @@
+import request from '@/utils/request'
+
+/** 飘屏发送记录分页 */
+export function listFloatMsgLog(query) {
+  return request({
+    url: '/live/floatMsgLog/list',
+    method: 'get',
+    params: query
+  })
+}

+ 39 - 9
src/views/course/courseRedPacketLog/index.vue

@@ -84,6 +84,7 @@
           :remote-method="remoteMethod"
           :loading="loadingPeriod"
           @focus="handleFocus"
+          @change="periodChange"
         >
           <el-option
             v-for="dict in periodLists"
@@ -227,7 +228,7 @@
 </template>
 
 <script>
-import { courseList,videoList,getCourseRedPacketLog, delCourseRedPacketLog, addCourseRedPacketLog, updateCourseRedPacketLog, exportCourseRedPacketLog,listCourseRedPacketLogPage } from "@/api/course/courseRedPacketLog";
+import { courseList, videoList, qwVideoList, getCourseRedPacketLog, delCourseRedPacketLog, addCourseRedPacketLog, updateCourseRedPacketLog, exportCourseRedPacketLog, listCourseRedPacketLogPage } from "@/api/course/courseRedPacketLog";
 import { getCompanyList } from "@/api/company/company";
 import { periodList } from "@/api/course/userCoursePeriod";
 import {treeselect} from "../../../api/company/companyDept";
@@ -386,17 +387,44 @@ export default {
 	      }
 
 	    },
-	courseChange(row){
-		if(row==""){
-			this.videoList=[]
-			this.queryParams.videoId=null
-		}else{
-			videoList(row).then(response => {
+	// 根据当前“是否选择营期”加载小节列表
+	async loadVideoListByPeriod() {
+		const courseId = this.queryParams.courseId
+		if (courseId === null || courseId === '' || courseId === undefined) {
+			this.videoList = []
+			this.queryParams.videoId = null
+			return
+		}
 
-				this.videoList=response.list
-			});
+		const periodId = this.queryParams.periodId
+		// 未选择营期 -> 走 qwvideoList/{id}
+		// 选择了营期 -> 走 videoList/{courseId}
+		if (periodId === null || periodId === '' || periodId === undefined) {
+			const response = await qwVideoList(courseId)
+			this.videoList = response.list || []
+		} else {
+			const response = await videoList(courseId)
+			this.videoList = response.list || []
 		}
+	},
 
+	// 切换营期:清空小节并按当前 courseId 刷新
+	async periodChange() {
+		this.videoList = []
+		this.queryParams.videoId = null
+		await this.loadVideoListByPeriod()
+	},
+
+	// 切换课程:清空小节并按当前 periodId 刷新
+	async courseChange(row) {
+		if (row === '' || row === null || row === undefined) {
+			this.videoList = []
+			this.queryParams.videoId = null
+			return
+		}
+		this.videoList = []
+		this.queryParams.videoId = null
+		await this.loadVideoListByPeriod()
 	},
     // 取消按钮
     cancel() {
@@ -433,6 +461,8 @@ export default {
       this.queryParams.pageNum = 1;    // Reset to first page
       this.queryParams.pageSize = 10;  // Reset to default page size
 	  this.queryParams.periodId=null;
+      this.videoList = []
+      this.queryParams.videoId = null
       this.handleQuery();
     },
     // 多选框选中数据

+ 60 - 1
src/views/hisStore/storeOrder/healthStoreList.vue

@@ -11,6 +11,24 @@
           @keyup.enter.native="handleQuery"
         />
       </el-form-item> -->
+      <el-form-item label="公司名" prop="companyId">
+        <el-select
+          v-model="queryParams.companyId"
+          clearable
+          filterable
+          placeholder="请选择公司名"
+          size="small"
+          @change="companyChange"
+        >
+          <el-option
+            v-for="item in companys"
+            :key="item.companyId"
+            :label="item.companyName"
+            :value="item.companyId"
+          />
+        </el-select>
+      </el-form-item>
+
       <el-form-item label="订单号" prop="orderCodes">
         <div class="tag-input-container">
           <!-- 标签显示区域 -->
@@ -106,6 +124,19 @@
         />
       </el-form-item>
 
+      <el-form-item label="制单方式" prop="orderMakerType">
+        <el-select
+          v-model="queryParams.orderMakerType"
+          clearable
+          placeholder="请选择制单方式"
+          size="small"
+          @change="handleQuery"
+        >
+          <el-option label="用户下单" :value="0" />
+          <el-option label="销售制单" :value="1" />
+        </el-select>
+      </el-form-item>
+
       <el-form-item label="产品" prop="productName">
         <el-input
 
@@ -470,7 +501,7 @@
       <el-tab-pane label="已退款" name="-2"></el-tab-pane>
       <el-tab-pane label="已取消" name="-3"></el-tab-pane>
     </el-tabs>
-    <el-table ref="orderTable" v-loading="loading" :data="storeOrderList" border height="500" @selection-change="handleSelectionChange"
+    <el-table ref="orderTable" v-loading="loading" :data="filteredStoreOrderList" border height="500" @selection-change="handleSelectionChange"
     @sort-change="handleSortChange" :default-sort="{prop: 'createTime', order: 'descending'}">
       <el-table-column align="center" type="selection" width="55"/>
       <el-table-column align="center" label="订单号" prop="orderCode" width="200px"/>
@@ -494,6 +525,12 @@
           <span>{{ scope.row.realName }} </span>
         </template>
       </el-table-column>
+
+      <el-table-column align="center" label="下单方式" width="110px">
+        <template slot-scope="scope">
+          <span>{{ scope.row.companyUserNickName ? '销售制单' : '用户下单' }}</span>
+        </template>
+      </el-table-column>
       <!-- <el-table-column label="商品" align="center" width="300px" >
           <template slot-scope="scope">
               <div  v-for="(item, index) in scope.row.items" class="items"  >
@@ -1405,6 +1442,9 @@ export default {
       queryParams: {
         pageNum: 1,
         pageSize: 10,
+        companyId: null,
+        // 0=用户下单(公司销售为空);1=销售制单(公司销售不为空)
+        orderMakerType: null,
         orderCode: null,
         orderCodes:[],
         bankTransactionId: null,
@@ -1535,12 +1575,31 @@ export default {
         '&shipmentType=' +
         this.ruleForm.shipmentType;
     }
+    ,
+    // 本地根据“制单方式”筛选表格展示数据
+    filteredStoreOrderList() {
+      const type = this.queryParams.orderMakerType
+      const list = Array.isArray(this.storeOrderList) ? this.storeOrderList : []
+
+      if (type === 0 || type === '0') {
+        // 为空:用户下单
+        return list.filter(row => !row.companyUserNickName)
+      }
+
+      if (type === 1 || type === '1') {
+        // 不为空:销售制单
+        return list.filter(row => !!row.companyUserNickName)
+      }
+
+      return list
+    }
   },
   created() {
     getCompanyList().then(response => {
       this.companys = response.data
       if (this.companys != null && this.companys.length > 0) {
         this.companyId = this.companys[0].companyId
+        this.queryParams.companyId = this.companyId
         this.getTreeselect()
       }
     })

+ 104 - 0
src/views/live/comment/floatMsgLog.vue

@@ -0,0 +1,104 @@
+<template>
+  <div class="app-container">
+    <el-form
+      :model="queryParams"
+      ref="queryForm"
+      :inline="true"
+      v-show="showSearch"
+      label-width="88px"
+    >
+      <el-form-item label="直播ID" prop="liveId">
+        <el-input
+          v-model="queryParams.liveId"
+          placeholder="直播ID"
+          clearable
+          size="small"
+          @keyup.enter.native="handleQuery"
+        />
+      </el-form-item>
+      <el-form-item label="用户ID" prop="userId">
+        <el-input
+          v-model="queryParams.userId"
+          placeholder="用户ID"
+          clearable
+          size="small"
+          @keyup.enter.native="handleQuery"
+        />
+      </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" />
+    </el-row>
+
+    <el-table border v-loading="loading" :data="logList">
+      <el-table-column label="记录ID" align="center" prop="logId" width="90" />
+      <el-table-column label="直播ID" align="center" prop="liveId" width="100" />
+      <el-table-column label="用户ID" align="center" prop="userId" width="100" />
+      <el-table-column label="昵称" align="center" prop="nickName" min-width="100" show-overflow-tooltip />
+      <el-table-column label="消息ID" align="center" prop="msgId" width="100" />
+      <el-table-column label="角色" align="center" prop="liveRoleCode" width="100" />
+      <el-table-column label="飘屏内容" align="center" prop="msgContent" min-width="160" show-overflow-tooltip />
+      <el-table-column label="发送时间" align="center" prop="createTime" width="160">
+        <template slot-scope="scope">
+          <span>{{ scope.row.createTime ? parseTime(scope.row.createTime) : '—' }}</span>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <pagination
+      v-show="total > 0"
+      :total="total"
+      :page.sync="queryParams.pageNum"
+      :limit.sync="queryParams.pageSize"
+      @pagination="getList"
+    />
+  </div>
+</template>
+
+<script>
+import { listFloatMsgLog } from '@/api/live/floatMsgLog'
+
+export default {
+  name: 'LiveFloatMsgLog',
+  data() {
+    return {
+      loading: true,
+      showSearch: true,
+      total: 0,
+      logList: [],
+      queryParams: {
+        pageNum: 1,
+        pageSize: 10,
+        liveId: null,
+        userId: null
+      }
+    }
+  },
+  created() {
+    this.getList()
+  },
+  methods: {
+    getList() {
+      this.loading = true
+      listFloatMsgLog(this.queryParams).then((response) => {
+        this.logList = response.rows
+        this.total = response.total
+        this.loading = false
+      })
+    },
+    handleQuery() {
+      this.queryParams.pageNum = 1
+      this.getList()
+    },
+    resetQuery() {
+      this.resetForm('queryForm')
+      this.handleQuery()
+    }
+  }
+}
+</script>

+ 281 - 0
src/views/live/comment/globalConfig.vue

@@ -0,0 +1,281 @@
+<template>
+  <div class="app-container">
+    <el-alert
+      title="以下配置对全站所有直播间生效。"
+      type="warning"
+      :closable="false"
+      show-icon
+      class="mb16"
+    />
+    <!-- App 端需监听 WebSocket cmd=liveCommentConfig,data 为 JSON(仅全局 config),与总后台无直接耦合 -->
+    <el-card shadow="never">
+      <el-form
+        ref="form"
+        v-loading="loading"
+        :model="form"
+        :rules="rules"
+        label-width="160px"
+      >
+        <el-divider content-position="left">飘屏</el-divider>
+        <el-form-item label="飘屏总开关" prop="floatEnabled">
+          <el-switch
+            v-model="form.floatEnabled"
+            :active-value="1"
+            :inactive-value="0"
+          />
+        </el-form-item>
+        <el-form-item label="用户飘屏冷却" prop="floatCooldownSec">
+          <el-input-number
+            v-model="form.floatCooldownSec"
+            :min="0"
+            :precision="0"
+            controls-position="right"
+            placeholder="秒"
+          />
+          <span class="form-tip">同一用户连续飘屏的最小间隔(秒)</span>
+        </el-form-item>
+        <el-form-item label="可飘屏角色" prop="floatRoleCodes">
+          <el-select
+            v-model="floatRoleList"
+            multiple
+            filterable
+            placeholder="请选择角色"
+            style="width: 420px"
+            :loading="rolesLoading"
+            @visible-change="ensureRolesLoaded"
+          >
+            <el-option
+              v-for="r in roleOptions"
+              :key="r"
+              :label="r"
+              :value="r"
+            />
+          </el-select>
+          <span class="form-tip">选项来自企业角色;保存为英文逗号分隔的 role_name</span>
+        </el-form-item>
+
+        <el-divider content-position="left">评论置顶</el-divider>
+        <el-form-item label="单房间最大置顶数" prop="pinMaxPerRoom">
+          <el-input-number
+            v-model="form.pinMaxPerRoom"
+            :min="1"
+            :precision="0"
+            controls-position="right"
+          />
+        </el-form-item>
+        <el-form-item label="可选置顶时长" prop="pinDurationOptions">
+          <el-select
+            v-model="pinDurationList"
+            multiple
+            placeholder="分钟;-1 表示永久"
+            style="width: 420px"
+          >
+            <el-option label="5 分钟" :value="5" />
+            <el-option label="10 分钟" :value="10" />
+            <el-option label="15 分钟" :value="15" />
+            <el-option label="30 分钟" :value="30" />
+            <el-option label="60 分钟" :value="60" />
+            <el-option label="永久 (-1)" :value="-1" />
+          </el-select>
+          <span class="form-tip">保存为逗号分隔数字,如 5,10,30,-1</span>
+        </el-form-item>
+        <el-form-item label="可置顶角色" prop="pinRoleCodes">
+          <el-select
+            v-model="pinRoleList"
+            multiple
+            filterable
+            placeholder="请选择角色"
+            style="width: 420px"
+            :loading="rolesLoading"
+            @visible-change="ensureRolesLoaded"
+          >
+            <el-option
+              v-for="r in roleOptions"
+              :key="r"
+              :label="r"
+              :value="r"
+            />
+          </el-select>
+        </el-form-item>
+
+        <el-divider content-position="left">其他</el-divider>
+        <el-form-item label="备注" prop="remark">
+          <el-input v-model="form.remark" type="textarea" :rows="2" placeholder="备注" />
+        </el-form-item>
+        <el-form-item label="最近更新人">
+          <span>{{ form.updateBy || '—' }}</span>
+        </el-form-item>
+        <el-form-item label="最近更新时间">
+          <span>{{ form.updateTime ? parseTime(form.updateTime) : '—' }}</span>
+        </el-form-item>
+        <el-form-item>
+          <el-button
+            type="primary"
+            :loading="saving"
+            @click="submitForm"
+            v-hasPermi="['live:commentFeature:edit']"
+          >保 存</el-button>
+          <el-button @click="loadConfig">重 置</el-button>
+        </el-form-item>
+      </el-form>
+    </el-card>
+  </div>
+</template>
+
+<script>
+import {
+  getCommentFeatureConfig,
+  updateCommentFeatureConfig,
+  getCommentFeatureRoles
+} from '@/api/live/commentFeature'
+
+const splitCodes = (s) => {
+  if (s == null || String(s).trim() === '') return []
+  return String(s)
+    .split(',')
+    .map((x) => x.trim())
+    .filter(Boolean)
+}
+
+const joinCodes = (arr) => (Array.isArray(arr) ? arr.map((x) => String(x).trim()).filter(Boolean) : []).join(',')
+
+export default {
+  name: 'LiveCommentGlobalConfig',
+  data() {
+    return {
+      loading: false,
+      saving: false,
+      /** 下拉候选项(GET /live/commentFeature/roles),并与已选值合并以便回显 */
+      roleOptions: [],
+      rolesLoaded: false,
+      rolesLoading: false,
+      floatRoleList: [],
+      pinRoleList: [],
+      pinDurationList: [],
+      form: {
+        configId: 1,
+        floatEnabled: 0,
+        floatCooldownSec: 0,
+        floatRoleCodes: '',
+        pinMaxPerRoom: 1,
+        pinDurationOptions: '',
+        pinRoleCodes: '',
+        remark: '',
+        updateBy: '',
+        updateTime: null
+      },
+      rules: {
+        floatCooldownSec: [{ required: true, message: '请输入冷却秒数', trigger: 'blur' }],
+        pinMaxPerRoom: [{ required: true, message: '请输入单房间最大置顶数', trigger: 'blur' }]
+      }
+    }
+  },
+  created() {
+    this.loadConfig()
+  },
+  methods: {
+    /** 将已选 role_name 合并进 options,避免接口未返回历史已选项时标签不显示 */
+    mergeSelectedIntoRoleOptions() {
+      const set = new Set(Array.isArray(this.roleOptions) ? this.roleOptions : [])
+      ;(this.floatRoleList || []).forEach((x) => {
+        const s = x != null ? String(x).trim() : ''
+        if (s) set.add(s)
+      })
+      ;(this.pinRoleList || []).forEach((x) => {
+        const s = x != null ? String(x).trim() : ''
+        if (s) set.add(s)
+      })
+      this.roleOptions = Array.from(set)
+    },
+    /** 首次展开下拉时拉取角色列表;空数据不报错 */
+    ensureRolesLoaded(visible) {
+      if (!visible) return
+      if (this.rolesLoaded) {
+        this.mergeSelectedIntoRoleOptions()
+        return
+      }
+      this.rolesLoading = true
+      getCommentFeatureRoles()
+        .then((response) => {
+          const list = response && response.data
+          this.roleOptions = Array.isArray(list) ? list.slice() : []
+          this.rolesLoaded = true
+          this.mergeSelectedIntoRoleOptions()
+        })
+        .catch(() => {
+          this.roleOptions = []
+          this.rolesLoaded = true
+          this.mergeSelectedIntoRoleOptions()
+        })
+        .finally(() => {
+          this.rolesLoading = false
+        })
+    },
+    loadConfig() {
+      this.loading = true
+      getCommentFeatureConfig()
+        .then((response) => {
+          const d = response.data || {}
+          this.form = {
+            configId: d.configId != null ? d.configId : 1,
+            floatEnabled: Number(d.floatEnabled) === 1 ? 1 : 0,
+            floatCooldownSec: d.floatCooldownSec != null ? Number(d.floatCooldownSec) : 0,
+            floatRoleCodes: d.floatRoleCodes || '',
+            pinMaxPerRoom: d.pinMaxPerRoom != null ? Number(d.pinMaxPerRoom) : 1,
+            pinDurationOptions: d.pinDurationOptions || '',
+            pinRoleCodes: d.pinRoleCodes || '',
+            remark: d.remark || '',
+            updateBy: d.updateBy || '',
+            updateTime: d.updateTime
+          }
+          this.floatRoleList = splitCodes(this.form.floatRoleCodes)
+          this.pinRoleList = splitCodes(this.form.pinRoleCodes)
+          this.pinDurationList = splitCodes(this.form.pinDurationOptions).map((x) => {
+            const n = Number(x)
+            return Number.isNaN(n) ? x : n
+          })
+          this.mergeSelectedIntoRoleOptions()
+        })
+        .finally(() => {
+          this.loading = false
+        })
+    },
+    submitForm() {
+      this.$refs.form.validate((valid) => {
+        if (!valid) return
+        const pinDur = joinCodes(this.pinDurationList)
+        const payload = {
+          configId: this.form.configId,
+          floatEnabled: this.form.floatEnabled,
+          floatCooldownSec: this.form.floatCooldownSec,
+          floatRoleCodes: joinCodes(this.floatRoleList),
+          pinMaxPerRoom: this.form.pinMaxPerRoom,
+          pinDurationOptions: pinDur,
+          pinRoleCodes: joinCodes(this.pinRoleList),
+          remark: (this.form.remark || '').trim()
+        }
+        this.saving = true
+        updateCommentFeatureConfig(payload)
+          .then(() => {
+            this.msgSuccess('保存成功')
+            this.loadConfig()
+          })
+          .finally(() => {
+            this.saving = false
+          })
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.mb16 {
+  margin-bottom: 16px;
+}
+.form-tip {
+  margin-left: 12px;
+  color: #909399;
+  font-size: 12px;
+}
+</style>

+ 58 - 0
src/views/live/comment/index.vue

@@ -0,0 +1,58 @@
+<template>
+  <div class="app-container">
+    <el-tabs v-model="activeName" type="card" @tab-click="handleTabClick">
+      <el-tab-pane label="评论飘屏全局配置" name="global">
+        <GlobalConfig v-if="activeName === 'global'" />
+      </el-tab-pane>
+      <el-tab-pane label="飘屏发送记录" name="floatMsgLog">
+        <FloatMsgLog v-if="activeName === 'floatMsgLog'" />
+      </el-tab-pane>
+      <el-tab-pane label="置顶实时监控" name="pinMonitor">
+        <PinMonitor v-if="activeName === 'pinMonitor'" />
+      </el-tab-pane>
+      <el-tab-pane label="置顶操作记录" name="pinLog">
+        <PinLog v-if="activeName === 'pinLog'" />
+      </el-tab-pane>
+      <el-tab-pane label="直播间当前置顶" name="pinActiveByLive">
+        <PinActiveByLive v-if="activeName === 'pinActiveByLive'" />
+      </el-tab-pane>
+    </el-tabs>
+  </div>
+</template>
+
+<script>
+import GlobalConfig from './globalConfig.vue'
+import FloatMsgLog from './floatMsgLog.vue'
+import PinMonitor from './pinMonitor.vue'
+import PinLog from './pinLog.vue'
+import PinActiveByLive from './pinActiveByLive.vue'
+
+export default {
+  name: 'LiveCommentFeatureIndex',
+  components: {
+    GlobalConfig,
+    FloatMsgLog,
+    PinMonitor,
+    PinLog,
+    PinActiveByLive
+  },
+  data() {
+    return {
+      activeName: 'global'
+    }
+  },
+  created() {
+    const q = (this.$route && this.$route.query) || {}
+    const tabs = ['global', 'floatMsgLog', 'pinMonitor', 'pinLog', 'pinActiveByLive']
+    if (q.tab && typeof q.tab === 'string' && tabs.includes(q.tab)) {
+      this.activeName = q.tab
+    }
+  },
+  methods: {
+    handleTabClick() {
+      // 仅用于触发懒加载(子组件 v-if 已处理),无需额外逻辑
+    }
+  }
+}
+</script>
+

+ 140 - 0
src/views/live/comment/pinActiveByLive.vue

@@ -0,0 +1,140 @@
+<template>
+  <div class="app-container">
+    <el-alert
+      title="查询指定直播间当前生效中的置顶列表(可选功能,liveId 可来自路由 query 或下方输入)。"
+      type="info"
+      :closable="false"
+      show-icon
+      class="mb16"
+    />
+    <el-form :inline="true" class="mb8">
+      <el-form-item label="直播ID">
+        <el-input v-model="liveIdInput" placeholder="请输入直播ID" clearable size="small" style="width: 220px" />
+      </el-form-item>
+      <el-form-item>
+        <el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">查询</el-button>
+      </el-form-item>
+    </el-form>
+
+    <el-table border v-loading="loading" :data="list">
+      <el-table-column label="生效ID" align="center" width="100">
+        <template slot-scope="scope">
+          {{ activeIdOf(scope.row) }}
+        </template>
+      </el-table-column>
+      <el-table-column label="消息ID" align="center" width="100">
+        <template slot-scope="scope">
+          {{ msgIdOf(scope.row) }}
+        </template>
+      </el-table-column>
+      <el-table-column label="评论内容" align="center" prop="msgContent" min-width="200" show-overflow-tooltip />
+      <el-table-column label="昵称" align="center" prop="msgNickName" width="120" show-overflow-tooltip />
+      <el-table-column label="过期时间" align="center" width="170">
+        <template slot-scope="scope">
+          {{ expireText(scope.row) }}
+        </template>
+      </el-table-column>
+      <el-table-column label="操作" align="center" width="120">
+        <template slot-scope="scope">
+          <el-button
+            size="mini"
+            type="text"
+            @click="handleForceCancel(scope.row)"
+            v-hasPermi="['live:commentPin:forceCancel']"
+          >强制取消</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+  </div>
+</template>
+
+<script>
+import { listActivePinByLiveId, forceCancelActivePin } from '@/api/live/commentPin'
+
+export default {
+  name: 'LivePinActiveByLive',
+  data() {
+    return {
+      loading: false,
+      list: [],
+      liveIdInput: ''
+    }
+  },
+  created() {
+    const q = this.$route.query.liveId
+    if (q != null && String(q).trim() !== '') {
+      this.liveIdInput = String(q)
+      this.fetchList()
+    }
+  },
+  methods: {
+    normalizeList(raw) {
+      if (Array.isArray(raw)) return raw
+      if (raw && Array.isArray(raw.rows)) return raw.rows
+      if (raw && Array.isArray(raw.list)) return raw.list
+      return []
+    },
+    handleQuery() {
+      const id = String(this.liveIdInput || '').trim()
+      if (!id) {
+        this.$message.warning('请输入直播ID')
+        return
+      }
+      this.fetchList()
+    },
+    fetchList() {
+      const id = String(this.liveIdInput || '').trim()
+      if (!id) return
+      this.loading = true
+      listActivePinByLiveId(id)
+        .then((response) => {
+          this.list = this.normalizeList(response.data)
+        })
+        .finally(() => {
+          this.loading = false
+        })
+    },
+    activeIdOf(row) {
+      if (row.active && row.active.id != null) return row.active.id
+      if (row.activeId != null) return row.activeId
+      return row.id
+    },
+    msgIdOf(row) {
+      if (row.active && row.active.msgId != null) return row.active.msgId
+      return row.msgId
+    },
+    expireText(row) {
+      const exp = row.active && row.active.expireAt != null ? row.active.expireAt : row.expireAt
+      if (exp == null || exp === '') return '永久/未设置'
+      return this.parseTime(exp)
+    },
+    handleForceCancel(row) {
+      const activeId = this.activeIdOf(row)
+      if (activeId == null) {
+        this.msgError('无法解析生效置顶 ID')
+        return
+      }
+      this.$confirm('确认强制取消该条置顶?', '提示', {
+        confirmButtonText: '确定',
+        cancelButtonText: '取消',
+        type: 'warning'
+      })
+        .then(() => forceCancelActivePin(activeId))
+        .then(() => {
+          this.msgSuccess('已提交强制取消')
+          this.fetchList()
+        })
+        .catch(() => {})
+    }
+  }
+}
+</script>
+
+<style scoped>
+.mb16 {
+  margin-bottom: 16px;
+}
+.mb8 {
+  margin-bottom: 8px;
+}
+</style>

+ 139 - 0
src/views/live/comment/pinLog.vue

@@ -0,0 +1,139 @@
+<template>
+  <div class="app-container">
+    <el-form
+      :model="queryParams"
+      ref="queryForm"
+      :inline="true"
+      v-show="showSearch"
+      label-width="100px"
+    >
+      <el-form-item label="直播ID" prop="liveId">
+        <el-input
+          v-model="queryParams.liveId"
+          placeholder="直播ID"
+          clearable
+          size="small"
+          @keyup.enter.native="handleQuery"
+        />
+      </el-form-item>
+      <el-form-item label="消息ID" prop="msgId">
+        <el-input
+          v-model="queryParams.msgId"
+          placeholder="消息ID"
+          clearable
+          size="small"
+          @keyup.enter.native="handleQuery"
+        />
+      </el-form-item>
+      <el-form-item label="操作人用户ID" prop="operatorUserId">
+        <el-input
+          v-model="queryParams.operatorUserId"
+          placeholder="操作人用户ID"
+          clearable
+          size="small"
+          @keyup.enter.native="handleQuery"
+        />
+      </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" />
+    </el-row>
+
+    <el-table border v-loading="loading" :data="logList">
+      <el-table-column label="日志ID" align="center" prop="logId" width="90" />
+      <el-table-column label="直播ID" align="center" prop="liveId" width="100" />
+      <el-table-column label="消息ID" align="center" prop="msgId" width="100" />
+      <el-table-column label="操作人用户ID" align="center" prop="operatorUserId" width="120" />
+      <el-table-column label="置顶时长(分)" align="center" width="110">
+        <template slot-scope="scope">
+          {{ formatDuration(scope.row.durationMinutes) }}
+        </template>
+      </el-table-column>
+      <el-table-column label="结束原因" align="center" prop="endReason" width="110">
+        <template slot-scope="scope">
+          {{ formatEndReason(scope.row.endReason) }}
+        </template>
+      </el-table-column>
+      <el-table-column label="操作时间" align="center" prop="createTime" width="160">
+        <template slot-scope="scope">
+          <span>{{ scope.row.createTime ? parseTime(scope.row.createTime) : '—' }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="备注" align="center" prop="remark" min-width="120" show-overflow-tooltip />
+    </el-table>
+
+    <pagination
+      v-show="total > 0"
+      :total="total"
+      :page.sync="queryParams.pageNum"
+      :limit.sync="queryParams.pageSize"
+      @pagination="getList"
+    />
+  </div>
+</template>
+
+<script>
+import { listCommentPinLog } from '@/api/live/commentPin'
+
+const END_REASON_MAP = {
+  1: '到期',
+  2: 'App 取消',
+  3: '后台强制'
+}
+
+export default {
+  name: 'LivePinLog',
+  data() {
+    return {
+      loading: true,
+      showSearch: true,
+      total: 0,
+      logList: [],
+      queryParams: {
+        pageNum: 1,
+        pageSize: 10,
+        liveId: null,
+        msgId: null,
+        operatorUserId: null
+      }
+    }
+  },
+  created() {
+    this.getList()
+  },
+  methods: {
+    formatEndReason(v) {
+      if (v == null || v === '') return '—'
+      const n = Number(v)
+      return END_REASON_MAP[n] != null ? END_REASON_MAP[n] : String(v)
+    },
+    formatDuration(v) {
+      if (v == null || v === '') return '—'
+      const n = Number(v)
+      if (n === -1) return '永久'
+      return v
+    },
+    getList() {
+      this.loading = true
+      listCommentPinLog(this.queryParams).then((response) => {
+        this.logList = response.rows
+        this.total = response.total
+        this.loading = false
+      })
+    },
+    handleQuery() {
+      this.queryParams.pageNum = 1
+      this.getList()
+    },
+    resetQuery() {
+      this.resetForm('queryForm')
+      this.handleQuery()
+    }
+  }
+}
+</script>

+ 177 - 0
src/views/live/comment/pinMonitor.vue

@@ -0,0 +1,177 @@
+<template>
+  <div class="app-container">
+    <el-alert
+      title="展示全站当前生效中的评论置顶;保存全局配置或样式后,后端会通过 WebSocket 向 App 端广播 liveCommentConfig。"
+      type="info"
+      :closable="false"
+      show-icon
+      class="mb16"
+    />
+    <el-row :gutter="10" class="mb8">
+      <el-col :span="1.5">
+        <el-button type="primary" plain icon="el-icon-refresh" size="mini" @click="fetchList">手动刷新</el-button>
+      </el-col>
+      <el-col :span="8">
+        <el-switch v-model="autoRefresh" active-text="每 30 秒自动刷新" @change="onAutoRefreshChange" />
+      </el-col>
+    </el-row>
+
+    <el-table border v-loading="loading" :data="list">
+      <el-table-column label="生效ID" align="center" width="100">
+        <template slot-scope="scope">
+          {{ activeIdOf(scope.row) }}
+        </template>
+      </el-table-column>
+      <el-table-column label="直播ID" align="center" width="100">
+        <template slot-scope="scope">
+          {{ liveIdOf(scope.row) }}
+        </template>
+      </el-table-column>
+      <el-table-column label="消息ID" align="center" width="100">
+        <template slot-scope="scope">
+          {{ msgIdOf(scope.row) }}
+        </template>
+      </el-table-column>
+      <el-table-column label="置顶记录ID" align="center" width="110">
+        <template slot-scope="scope">
+          {{ pinLogIdOf(scope.row) }}
+        </template>
+      </el-table-column>
+      <el-table-column label="评论昵称" align="center" prop="msgNickName" min-width="100" show-overflow-tooltip />
+      <el-table-column label="评论内容" align="center" prop="msgContent" min-width="180" show-overflow-tooltip />
+      <el-table-column label="过期时间" align="center" width="170">
+        <template slot-scope="scope">
+          <span>{{ expireText(scope.row) }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="剩余秒数" align="center" width="100">
+        <template slot-scope="scope">
+          {{ remainingText(scope.row) }}
+        </template>
+      </el-table-column>
+      <el-table-column label="创建时间" align="center" width="160">
+        <template slot-scope="scope">
+          <span>{{ createTimeOf(scope.row) }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="操作" align="center" width="120" fixed="right">
+        <template slot-scope="scope">
+          <el-button
+            size="mini"
+            type="text"
+            @click="handleForceCancel(scope.row)"
+            v-hasPermi="['live:commentPin:forceCancel']"
+          >强制取消</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+  </div>
+</template>
+
+<script>
+import { listActivePinMonitor, forceCancelActivePin } from '@/api/live/commentPin'
+
+export default {
+  name: 'LivePinMonitor',
+  data() {
+    return {
+      loading: false,
+      list: [],
+      autoRefresh: false,
+      refreshTimer: null
+    }
+  },
+  created() {
+    this.fetchList()
+  },
+  beforeDestroy() {
+    this.clearTimer()
+  },
+  methods: {
+    normalizeList(raw) {
+      if (Array.isArray(raw)) return raw
+      if (raw && Array.isArray(raw.rows)) return raw.rows
+      if (raw && Array.isArray(raw.list)) return raw.list
+      return []
+    },
+    fetchList() {
+      this.loading = true
+      listActivePinMonitor()
+        .then((response) => {
+          this.list = this.normalizeList(response.data)
+        })
+        .finally(() => {
+          this.loading = false
+        })
+    },
+    activeIdOf(row) {
+      if (row.active && row.active.id != null) return row.active.id
+      if (row.activeId != null) return row.activeId
+      return row.id
+    },
+    liveIdOf(row) {
+      if (row.active && row.active.liveId != null) return row.active.liveId
+      return row.liveId
+    },
+    msgIdOf(row) {
+      if (row.active && row.active.msgId != null) return row.active.msgId
+      return row.msgId
+    },
+    pinLogIdOf(row) {
+      if (row.active && row.active.pinLogId != null) return row.active.pinLogId
+      return row.pinLogId
+    },
+    createTimeOf(row) {
+      const t = row.active && row.active.createTime != null ? row.active.createTime : row.createTime
+      return t ? this.parseTime(t) : '—'
+    },
+    expireText(row) {
+      const exp = row.active && row.active.expireAt != null ? row.active.expireAt : row.expireAt
+      if (exp == null || exp === '') return '—'
+      return this.parseTime(exp)
+    },
+    remainingText(row) {
+      if (row.remainingSeconds == null) return '永久'
+      return row.remainingSeconds
+    },
+    handleForceCancel(row) {
+      const activeId = this.activeIdOf(row)
+      if (activeId == null) {
+        this.msgError('无法解析生效置顶 ID')
+        return
+      }
+      this.$confirm('确认强制取消该条置顶?', '提示', {
+        confirmButtonText: '确定',
+        cancelButtonText: '取消',
+        type: 'warning'
+      })
+        .then(() => forceCancelActivePin(activeId))
+        .then(() => {
+          this.msgSuccess('已提交强制取消')
+          this.fetchList()
+        })
+        .catch(() => {})
+    },
+    clearTimer() {
+      if (this.refreshTimer) {
+        clearInterval(this.refreshTimer)
+        this.refreshTimer = null
+      }
+    },
+    onAutoRefreshChange(on) {
+      this.clearTimer()
+      if (on) {
+        this.refreshTimer = setInterval(() => {
+          this.fetchList()
+        }, 30000)
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.mb16 {
+  margin-bottom: 16px;
+}
+</style>