ContentSearchTab.vue 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586
  1. <template>
  2. <div class="content-search-tab">
  3. <!-- 搜索表单 -->
  4. <div class="search-form">
  5. <el-form :model="searchForm" inline>
  6. <el-form-item label="聊天内容" required>
  7. <el-input v-model="searchForm.queryWord" placeholder="请输入关键词(至少2个字符)" clearable />
  8. </el-form-item>
  9. <el-form-item label="聊天类型">
  10. <el-select v-model="searchForm.chatType" placeholder="请选择" clearable @change="onChatTypeChange">
  11. <el-option label="全部" :value="null" />
  12. <el-option label="单聊" :value="1" />
  13. <el-option label="群聊" :value="2" />
  14. </el-select>
  15. </el-form-item>
  16. <!-- 单聊条件:员工 + 客户 -->
  17. <el-form-item v-if="searchForm.chatType === 1" label="员工">
  18. <el-select
  19. v-model="searchForm.staffUserId"
  20. filterable
  21. remote
  22. clearable
  23. reserve-keyword
  24. placeholder="请输入员工名称搜索"
  25. :remote-method="searchStaff"
  26. :loading="staffSearchLoading"
  27. @change="onStaffChange"
  28. >
  29. <el-option
  30. v-for="item in staffOptions"
  31. :key="item.qwUserId"
  32. :label="item.qwUserName"
  33. :value="item.qwUserId"
  34. />
  35. </el-select>
  36. </el-form-item>
  37. <el-form-item v-if="searchForm.chatType === 1" label="客户">
  38. <el-select
  39. v-model="searchForm.customerId"
  40. filterable
  41. clearable
  42. placeholder="请先选择员工,再搜索客户"
  43. :loading="customerPreLoading"
  44. :filter-method="filterCustomerLocal"
  45. @visible-change="onCustomerDropdownVisible"
  46. >
  47. <el-option
  48. v-for="item in filteredCustomerOptions"
  49. :key="item.externalUserId"
  50. :label="item.name"
  51. :value="item.externalUserId"
  52. />
  53. </el-select>
  54. <span v-if="customerNoMatch" style="color:#E6A23C;font-size:12px;">未匹配到客户</span>
  55. </el-form-item>
  56. <!-- 群聊条件:群聊 -->
  57. <el-form-item v-if="searchForm.chatType === 2" label="群聊">
  58. <el-select
  59. v-model="searchForm.chatId"
  60. filterable
  61. remote
  62. clearable
  63. reserve-keyword
  64. placeholder="请输入群聊名称搜索"
  65. :remote-method="searchGroupChat"
  66. :loading="groupChatSearchLoading"
  67. >
  68. <el-option
  69. v-for="item in groupChatOptions"
  70. :key="item.chatId"
  71. :label="item.name"
  72. :value="item.chatId"
  73. />
  74. </el-select>
  75. </el-form-item>
  76. <el-form-item label="发送时间">
  77. <el-date-picker
  78. v-model="dateRange"
  79. type="daterange"
  80. range-separator="至"
  81. start-placeholder="开始日期"
  82. end-placeholder="结束日期"
  83. :picker-options="pickerOptions"
  84. value-format="timestamp"
  85. />
  86. </el-form-item>
  87. <el-form-item>
  88. <el-button type="primary" @click="handleSearch" :loading="searching">查询</el-button>
  89. <el-button @click="resetForm">重置</el-button>
  90. </el-form-item>
  91. </el-form>
  92. </div>
  93. <!-- 搜索结果表格(无限滚动) -->
  94. <div class="result-table-wrapper" ref="scrollContainer" @scroll="handleScroll">
  95. <el-table
  96. v-loading="tableLoading"
  97. :data="messageList"
  98. border
  99. stripe
  100. style="width: 100%"
  101. >
  102. <el-table-column prop="chatTypeName" label="聊天类型" width="80" />
  103. <el-table-column prop="senderTypeName" label="发送人类型" width="100" />
  104. <el-table-column prop="senderName" label="发送人" min-width="150" />
  105. <el-table-column prop="receiverNames" label="接收人" min-width="200">
  106. <template slot-scope="scope">
  107. {{ scope.row.receiverNames.join('、') }}
  108. </template>
  109. </el-table-column>
  110. <el-table-column prop="send_time_str" label="发送时间" width="180" />
  111. <el-table-column prop="msgTypeName" label="消息类型" width="100" />
  112. <el-table-column label="聊天内容" min-width="200">
  113. <template slot-scope="scope">
  114. <span v-if="scope.row.msgtype === 1">{{ scope.row.contentPreview || '[文本消息]' }}</span>
  115. <span v-else>{{ scope.row.msgTypeName }}</span>
  116. </template>
  117. </el-table-column>
  118. <el-table-column label="操作" width="120">
  119. <template slot-scope="scope">
  120. <el-button type="text" @click="openContextDrawer(scope.row)">查看上下文</el-button>
  121. </template>
  122. </el-table-column>
  123. </el-table>
  124. <div v-if="loadingMore" class="loading-more">加载更多...</div>
  125. <div v-if="!hasMore && messageList.length > 0" class="no-more">没有更多了</div>
  126. <div v-if="searchedNoData" class="no-more">未找到相关消息</div>
  127. </div>
  128. <!-- 抽屉:会话上下文 -->
  129. <el-drawer
  130. :visible.sync="drawerVisible"
  131. title="会话上下文"
  132. direction="rtl"
  133. size="80%"
  134. destroy-on-close
  135. >
  136. <div class="drawer-container">
  137. <ConversationPanelPure
  138. :corpId="corpId"
  139. :customerId="currentCustomerId"
  140. :staffUserId="currentStaffUserId"
  141. :chatId="currentChatId"
  142. @logout="handleLogout"
  143. />
  144. </div>
  145. </el-drawer>
  146. </div>
  147. </template>
  148. <script>
  149. import { qwSearchMsg, qwStaffOptions, qwCustomerOptions, qwGroupChatOptions } from "@/api/qw/companySession";
  150. import ConversationPanelPure from "./ConversationPanelPure.vue";
  151. const MSG_TYPE_MAP = {
  152. 0: '暂不支持',
  153. 1: '文本',
  154. 2: '图片',
  155. 3: '表情',
  156. 4: '链接',
  157. 5: '小程序',
  158. 6: '语音',
  159. 7: '视频',
  160. 8: '文件',
  161. 9: '名片',
  162. 10: '转发消息',
  163. 11: '视频号',
  164. 12: '日程',
  165. 13: '红包',
  166. 14: '地理位置',
  167. 15: '快速会议',
  168. 16: '待办',
  169. 17: '投票',
  170. 18: '在线文档',
  171. 19: '图文消息',
  172. 20: '图文混合消息',
  173. 21: '音频存档',
  174. 22: '音视频通话',
  175. 23: '微盘文件',
  176. 24: '同意会话存档',
  177. 25: '拒绝会话存档',
  178. 26: '群接龙',
  179. 27: 'markdown',
  180. 28: '笔记'
  181. };
  182. export default {
  183. name: "ContentSearchTab",
  184. components: { ConversationPanelPure },
  185. props: {
  186. corpId: { type: String, required: true },
  187. },
  188. data() {
  189. return {
  190. searchForm: {
  191. queryWord: "",
  192. chatType: null,
  193. staffUserId: null,
  194. customerId: null,
  195. chatId: null,
  196. startTime: null,
  197. endTime: null,
  198. },
  199. // 下拉选项
  200. staffOptions: [],
  201. customerOptions: [], // 员工的所有客户(预加载后本地过滤)
  202. filteredCustomerOptions: [], // 本地过滤后的展示列表
  203. customerNoMatch: false, // 输入关键字后无匹配
  204. groupChatOptions: [],
  205. staffSearchLoading: false,
  206. customerPreLoading: false, // 预加载员工客户时的 loading
  207. customerSearchLoading: false,
  208. groupChatSearchLoading: false,
  209. dateRange: [],
  210. searching: false,
  211. tableLoading: false,
  212. loadingMore: false,
  213. messageList: [],
  214. cursor: null,
  215. hasMore: false,
  216. searchedNoData: false,
  217. drawerVisible: false,
  218. currentStaffUserId: null,
  219. currentCustomerId: null,
  220. currentChatId: null,
  221. pickerOptions: {
  222. disabledDate(time) {
  223. // 只能选择最近31天内的日期
  224. const today = new Date();
  225. today.setHours(0, 0, 0, 0);
  226. const thirtyOneDaysAgo = new Date();
  227. thirtyOneDaysAgo.setDate(today.getDate() - 31);
  228. thirtyOneDaysAgo.setHours(0, 0, 0, 0);
  229. // 禁止选择今天之后和31天前的日期
  230. return time.getTime() > today.getTime() || time.getTime() < thirtyOneDaysAgo.getTime();
  231. },
  232. },
  233. };
  234. },
  235. watch: {
  236. dateRange(val) {
  237. if (val && val.length === 2) {
  238. this.searchForm.startTime = null;
  239. this.searchForm.endTime = null;
  240. }
  241. },
  242. },
  243. methods: {
  244. async handleSearch() {
  245. if (!this.searchForm.queryWord || this.searchForm.queryWord.length < 2) {
  246. this.$message.warning("聊天内容关键词至少2个字符");
  247. return;
  248. }
  249. if (!this.corpId) {
  250. this.$message.warning("请先选择企微主体");
  251. return;
  252. }
  253. // 计算当前时间和31天前的时间戳(秒)
  254. const nowSec = Math.floor(Date.now() / 1000);
  255. const thirtyOneDaysAgoSec = nowSec - 31 * 24 * 3600;
  256. let startTime = null;
  257. let endTime = null;
  258. if (this.dateRange && this.dateRange.length === 2) {
  259. // 用户选择了日期范围
  260. startTime = Math.floor(this.dateRange[0] / 1000);
  261. let rawEndTime = Math.floor(this.dateRange[1] / 1000);
  262. // 强制 end_time 不能晚于当前时间
  263. if (rawEndTime > nowSec) {
  264. this.$message.warning("结束时间不能晚于当前时间,已自动调整");
  265. rawEndTime = nowSec;
  266. }
  267. endTime = rawEndTime;
  268. // 确保 start_time < end_time
  269. if (startTime >= endTime) {
  270. this.$message.error("开始时间必须早于结束时间,请重新选择");
  271. return;
  272. }
  273. } else {
  274. // 用户未选日期范围:默认查询31天前到当前时间
  275. startTime = thirtyOneDaysAgoSec;
  276. endTime = nowSec;
  277. this.$message.info("未选择时间范围,默认查询最近31天内的消息");
  278. }
  279. // 保存到 searchForm 中,供后续滚动加载使用
  280. this.searchForm.startTime = startTime;
  281. this.searchForm.endTime = endTime;
  282. // 重置状态
  283. this.messageList = [];
  284. this.cursor = null;
  285. this.hasMore = false;
  286. this.searchedNoData = false;
  287. this.tableLoading = true;
  288. await this.loadMoreMessages(true);
  289. this.tableLoading = false;
  290. },
  291. async loadMoreMessages(reset = false) {
  292. if (this.loadingMore) return;
  293. if (!reset && !this.hasMore) return;
  294. this.loadingMore = true;
  295. try {
  296. const params = {
  297. corpId: this.corpId,
  298. queryWord: this.searchForm.queryWord,
  299. chatType: this.searchForm.chatType || undefined,
  300. staffUserId: this.searchForm.staffUserId || undefined,
  301. customerId: this.searchForm.customerId || undefined,
  302. chatId: this.searchForm.chatId || undefined,
  303. startTime: this.searchForm.startTime,
  304. endTime: this.searchForm.endTime,
  305. limit: 50,
  306. cursor: reset ? null : this.cursor,
  307. };
  308. const res = await qwSearchMsg(params);
  309. if (res.code === 200 && res.data) {
  310. const newMessages = res.data.data || [];
  311. if (reset && newMessages.length === 0) {
  312. this.searchedNoData = true;
  313. } else {
  314. this.searchedNoData = false;
  315. }
  316. const enriched = this.enrichMessages(newMessages);
  317. if (reset) {
  318. this.messageList = enriched;
  319. } else {
  320. this.messageList = this.messageList.concat(enriched);
  321. }
  322. this.cursor = res.data.nextCursor || null;
  323. this.hasMore = res.data.hasMore === 1;
  324. } else {
  325. this.$message.error(res.msg || "搜索失败");
  326. if (reset) this.messageList = [];
  327. }
  328. } catch (e) {
  329. console.error("搜索异常", e);
  330. this.$message.error("搜索异常");
  331. if (reset) this.messageList = [];
  332. } finally {
  333. this.loadingMore = false;
  334. }
  335. },
  336. enrichMessages(messages) {
  337. return messages.map(msg => {
  338. let senderTypeName = '';
  339. if (msg.sender.type === 1) senderTypeName = '员工';
  340. else if (msg.sender.type === 2) senderTypeName = '客户';
  341. else if (msg.sender.type === 3) senderTypeName = '机器人';
  342. else senderTypeName = '未知';
  343. // 优先使用后端返回的 name,查不到则用原始 ID 兜底
  344. const senderName = msg.sender.name || msg.sender.id;
  345. const receiverNames = (msg.receiver_list || []).map(r => {
  346. const label = r.type === 1 ? '员工' : r.type === 2 ? '客户' : '';
  347. const name = r.name || r.id;
  348. return label ? `${label}:${name}` : name;
  349. });
  350. const chatTypeName = msg.chatType === 1 ? '单聊' : '群聊';
  351. const msgTypeName = MSG_TYPE_MAP[msg.msgtype] || '未知类型';
  352. let contentPreview = '';
  353. if (msg.msgtype === 1) {
  354. contentPreview = '[文本消息,内容需后端解密]';
  355. }
  356. return {
  357. ...msg,
  358. chatTypeName,
  359. senderTypeName,
  360. senderName,
  361. receiverNames,
  362. msgTypeName,
  363. contentPreview,
  364. };
  365. });
  366. },
  367. resetForm() {
  368. this.searchForm = {
  369. queryWord: "",
  370. chatType: null,
  371. staffUserId: null,
  372. customerId: null,
  373. chatId: null,
  374. startTime: null,
  375. endTime: null,
  376. };
  377. this.staffOptions = [];
  378. this.customerOptions = [];
  379. this.filteredCustomerOptions = [];
  380. this.customerNoMatch = false;
  381. this.groupChatOptions = [];
  382. this.dateRange = [];
  383. this.messageList = [];
  384. this.cursor = null;
  385. this.hasMore = false;
  386. this.searchedNoData = false;
  387. },
  388. onChatTypeChange(val) {
  389. this.searchForm.staffUserId = null;
  390. this.searchForm.customerId = null;
  391. this.searchForm.chatId = null;
  392. this.staffOptions = [];
  393. this.customerOptions = [];
  394. this.filteredCustomerOptions = [];
  395. this.customerNoMatch = false;
  396. this.groupChatOptions = [];
  397. },
  398. async onStaffChange(staffUserId) {
  399. // 切换员工时清空已选客户,预加载该员工全部客户
  400. this.searchForm.customerId = null;
  401. this.customerOptions = [];
  402. this.filteredCustomerOptions = [];
  403. this.customerNoMatch = false;
  404. if (!staffUserId || !this.corpId) return;
  405. this.customerPreLoading = true;
  406. try {
  407. const res = await qwCustomerOptions({ corpId: this.corpId, qwUserId: staffUserId });
  408. if (res.code === 200) {
  409. this.customerOptions = res.data || [];
  410. this.filteredCustomerOptions = this.customerOptions;
  411. }
  412. } catch (e) {
  413. console.error('预加载客户失败', e);
  414. } finally {
  415. this.customerPreLoading = false;
  416. }
  417. },
  418. filterCustomerLocal(keyword) {
  419. // 本地过滤,不调后端
  420. if (!keyword) {
  421. this.filteredCustomerOptions = this.customerOptions;
  422. this.customerNoMatch = false;
  423. } else {
  424. const kw = keyword.toLowerCase();
  425. this.filteredCustomerOptions = this.customerOptions.filter(
  426. c => c.name && c.name.toLowerCase().includes(kw)
  427. );
  428. this.customerNoMatch = this.filteredCustomerOptions.length === 0;
  429. }
  430. },
  431. onCustomerDropdownVisible(visible) {
  432. // 下拉打开时重置为全量
  433. if (visible) {
  434. this.filteredCustomerOptions = this.customerOptions;
  435. this.customerNoMatch = false;
  436. }
  437. },
  438. async searchStaff(keyword) {
  439. if (!this.corpId) return;
  440. this.staffSearchLoading = true;
  441. try {
  442. const res = await qwStaffOptions({ corpId: this.corpId, keyword });
  443. if (res.code === 200) {
  444. this.staffOptions = res.data || [];
  445. }
  446. } catch (e) {
  447. console.error('搜索员工失败', e);
  448. } finally {
  449. this.staffSearchLoading = false;
  450. }
  451. },
  452. async searchCustomer(keyword) {
  453. // 兼容旧调用:本地已预加载则本地过滤,否则调远端
  454. if (this.customerOptions.length > 0) {
  455. this.filterCustomerLocal(keyword);
  456. } else if (this.corpId) {
  457. this.customerSearchLoading = true;
  458. try {
  459. const res = await qwCustomerOptions({ corpId: this.corpId, keyword });
  460. if (res.code === 200) {
  461. this.customerOptions = res.data || [];
  462. this.filteredCustomerOptions = this.customerOptions;
  463. }
  464. } catch (e) {
  465. console.error('搜索客户失败', e);
  466. } finally {
  467. this.customerSearchLoading = false;
  468. }
  469. }
  470. },
  471. async searchGroupChat(keyword) {
  472. if (!this.corpId) return;
  473. this.groupChatSearchLoading = true;
  474. try {
  475. const res = await qwGroupChatOptions({ corpId: this.corpId, keyword });
  476. if (res.code === 200) {
  477. this.groupChatOptions = res.data || [];
  478. }
  479. } catch (e) {
  480. console.error('搜索群聊失败', e);
  481. } finally {
  482. this.groupChatSearchLoading = false;
  483. }
  484. },
  485. handleScroll(e) {
  486. const container = e.target;
  487. if (container.scrollHeight - container.scrollTop - container.clientHeight < 10) {
  488. if (this.hasMore && !this.loadingMore && !this.tableLoading) {
  489. this.loadMoreMessages();
  490. }
  491. }
  492. },
  493. openContextDrawer(row) {
  494. // 群聊:传 chatId 查看整个群的消息
  495. if (row.chatid) {
  496. this.currentStaffUserId = null;
  497. this.currentCustomerId = 'chat_' + row.chatid; // chat_ 前缀在 fetchConversations 中识别
  498. this.currentChatId = row.chatid;
  499. this.drawerVisible = true;
  500. return;
  501. }
  502. // 单聊:传员工+客户
  503. let staffId = null, customerId = null;
  504. if (row.sender.type === 1) staffId = row.sender.id;
  505. if (row.sender.type === 2) customerId = row.sender.id;
  506. for (let recv of (row.receiver_list || [])) {
  507. if (recv.type === 1 && !staffId) staffId = recv.id;
  508. if (recv.type === 2 && !customerId) customerId = recv.id;
  509. }
  510. if (staffId && customerId) {
  511. this.currentStaffUserId = staffId;
  512. this.currentCustomerId = customerId;
  513. this.currentChatId = null;
  514. this.drawerVisible = true;
  515. } else {
  516. this.$message.warning("无法确定会话双方");
  517. }
  518. },
  519. handleLogout() {
  520. this.$message.warning("会话登录已失效,请刷新页面重试");
  521. },
  522. },
  523. };
  524. </script>
  525. <style scoped>
  526. .content-search-tab {
  527. height: 100%;
  528. display: flex;
  529. flex-direction: column;
  530. }
  531. .search-form {
  532. background: #fff;
  533. padding: 16px;
  534. border-radius: 4px;
  535. margin-bottom: 16px;
  536. }
  537. .result-table-wrapper {
  538. flex: 1;
  539. overflow-y: auto;
  540. background: #fff;
  541. border-radius: 4px;
  542. padding: 16px;
  543. }
  544. .loading-more, .no-more {
  545. text-align: center;
  546. padding: 12px;
  547. color: #999;
  548. font-size: 14px;
  549. }
  550. .drawer-container {
  551. height: 100%;
  552. }
  553. </style>