ConversationPanelPure.vue 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774
  1. <template>
  2. <div class="conversation-panel">
  3. <!-- 配置加载中 -->
  4. <div v-if="configLoading" class="loading-tip">
  5. <i class="el-icon-loading"></i>
  6. <p>正在加载配置...</p>
  7. </div>
  8. <!-- 配置加载失败 / 无配置 -->
  9. <div v-else-if="configError" class="empty-tip">
  10. <i class="el-icon-warning"></i>
  11. <p>{{ configErrorMsg || '企微未配置,请先配置企微应用' }}</p>
  12. </div>
  13. <!-- 未登录 -->
  14. <div v-else-if="!isLoggedIn" class="login-area">
  15. <div class="login-tip">请扫码登录企微</div>
  16. <div id="login-container" ref="loginContainer" class="login-container"></div>
  17. </div>
  18. <!-- 已登录但未选择员工(群聊不需要员工) -->
  19. <div v-else-if="!staffUserId && !chatId" class="empty-tip">
  20. <i class="el-icon-info"></i>
  21. <p>请选择员工</p>
  22. </div>
  23. <!-- 已登录但未选择客户(群聊不需要客户) -->
  24. <div v-else-if="!customerId && !chatId" class="empty-tip">
  25. <i class="el-icon-info"></i>
  26. <p>请选择客户查看会话</p>
  27. </div>
  28. <!-- 会话加载中 -->
  29. <div v-else-if="!isReady" class="loading-tip">
  30. <i class="el-icon-loading"></i>
  31. <p>正在加载会话记录...</p>
  32. </div>
  33. <!-- 会话为空 -->
  34. <div v-else-if="msgList.length === 0" class="empty-tip">
  35. <i class="el-icon-info"></i>
  36. <p>暂无会话数据</p>
  37. </div>
  38. <!-- 聊天窗口(含消息类型筛选) -->
  39. <div v-else class="chat-wrapper">
  40. <div class="msg-type-filter">
  41. <span
  42. v-for="opt in msgTypeOptions"
  43. :key="String(opt.value)"
  44. class="filter-item"
  45. :class="{ active: msgTypeFilter === opt.value }"
  46. @click="handleFilterChange(opt.value)"
  47. >{{ opt.label }}</span>
  48. </div>
  49. <div id="chat-container" ref="chatContainer"></div>
  50. </div>
  51. </div>
  52. </template>
  53. <script>
  54. import * as ww from '@wecom/jssdk';
  55. import { qwLogin, qwSignature, qwConversations, getQwSessionConfig } from '@/api/qw/companySession';
  56. // ========== 全局配置缓存 ==========
  57. window._QW_CONFIG_CACHE = window._QW_CONFIG_CACHE || new Map();
  58. window._QW_CONFIG_PENDING = window._QW_CONFIG_PENDING || new Map();
  59. const CACHE_TTL = 5 * 60 * 1000;
  60. // 消息类型筛选选项(value 为 msgtype,null 表示全部)
  61. const MSG_TYPE_FILTER_OPTIONS = [
  62. { value: null, label: '全部' },
  63. { value: 1, label: '文本' },
  64. { value: 6, label: '语音' },
  65. { value: 2, label: '图片' },
  66. { value: 7, label: '视频' },
  67. { value: 4, label: '链接' },
  68. { value: 22, label: '语音通话' }
  69. ];
  70. function getCachedConfig(corpId) {
  71. if (!corpId) return null;
  72. const cached = window._QW_CONFIG_CACHE.get(corpId);
  73. if (cached && (Date.now() - cached.timestamp) < CACHE_TTL) {
  74. return cached.config;
  75. }
  76. window._QW_CONFIG_CACHE.delete(corpId);
  77. return null;
  78. }
  79. function setCachedConfig(corpId, config) {
  80. if (!corpId) return;
  81. window._QW_CONFIG_CACHE.set(corpId, { config, timestamp: Date.now() });
  82. }
  83. export default {
  84. name: 'ConversationPanelPure',
  85. props: {
  86. corpId: { type: String, default: null },
  87. customerId: { type: String, default: null },
  88. staffUserId: { type: String, default: null },
  89. chatId: { type: String, default: null }
  90. },
  91. data() {
  92. return {
  93. isLoggedIn: false,
  94. isReady: false,
  95. msgList: [],
  96. msgTypeFilter: null,
  97. msgTypeOptions: MSG_TYPE_FILTER_OPTIONS,
  98. cursor: '',
  99. hasMore: true,
  100. loadingMore: false,
  101. chatInstance: null,
  102. _sdkInited: false,
  103. config: { corpid: '', agentid: '', domain: '' },
  104. configReady: false,
  105. configError: false,
  106. configLoading: false,
  107. configErrorMsg: '',
  108. tokenCheckTimer: null,
  109. _hasEmittedLogout: false,
  110. _loginPanelCreated: false,
  111. _loginPanelTimer: null
  112. };
  113. },
  114. computed: {
  115. filteredMsgList() {
  116. if (!this.msgTypeFilter) return this.msgList;
  117. return this.msgList.filter(m => m.msgtype === this.msgTypeFilter);
  118. }
  119. },
  120. watch: {
  121. customerId(newId, oldId) {
  122. if (newId && newId !== oldId && this.isLoggedIn && (this.staffUserId || this.chatId) && this.configReady && !this.configError) {
  123. this.resetAndReload();
  124. }
  125. },
  126. staffUserId(newVal, oldVal) {
  127. if (newVal && newVal !== oldVal && this.customerId && this.isLoggedIn && this.configReady && !this.configError) {
  128. this.resetAndReload();
  129. }
  130. },
  131. chatId(newVal, oldVal) {
  132. if (newVal && newVal !== oldVal && this.isLoggedIn && this.configReady && !this.configError) {
  133. this.resetAndReload();
  134. }
  135. },
  136. corpId(newId, oldId) {
  137. if (newId !== oldId) {
  138. this.destroyChat();
  139. this.isReady = false;
  140. this._sdkInited = false;
  141. this._hasEmittedLogout = false;
  142. this._loginPanelCreated = false;
  143. if (this._loginPanelTimer) clearTimeout(this._loginPanelTimer);
  144. this.msgList = [];
  145. this.cursor = '';
  146. this.hasMore = true;
  147. this.loadingMore = false;
  148. this.isLoggedIn = false;
  149. const cached = getCachedConfig(newId);
  150. if (cached) {
  151. this.config = cached;
  152. this.configReady = true;
  153. this.configError = false;
  154. this.configLoading = false;
  155. this.handleAuthAndLoad();
  156. } else {
  157. this.configReady = false;
  158. this.configError = false;
  159. this.configLoading = false;
  160. this.initConfig().then(() => {
  161. if (this.configReady && !this.configError) this.handleAuthAndLoad();
  162. });
  163. }
  164. }
  165. },
  166. isLoggedIn: {
  167. handler(newVal) {
  168. if (!newVal && this.configReady && !this.configError && this._sdkInited && !this._loginPanelCreated) {
  169. this.tryCreateLoginPanel();
  170. }
  171. },
  172. immediate: true
  173. }
  174. },
  175. async mounted() {
  176. this._hasEmittedLogout = false;
  177. this._loginPanelCreated = false;
  178. const cached = getCachedConfig(this.corpId);
  179. if (cached) {
  180. this.config = cached;
  181. this.configReady = true;
  182. this.configError = false;
  183. this.configLoading = false;
  184. await this.handleAuthAndLoad();
  185. } else {
  186. await this.initConfig();
  187. if (this.configReady && !this.configError) await this.handleAuthAndLoad();
  188. }
  189. this.tokenCheckTimer = setInterval(() => {
  190. if (this.isLoggedIn && this.configReady && !this.configError && this._sdkInited) {
  191. this.getAgentConfigSignature().catch(e => console.warn('签名刷新失败', e));
  192. }
  193. }, 90 * 60 * 1000);
  194. },
  195. beforeDestroy() {
  196. if (this.tokenCheckTimer) clearInterval(this.tokenCheckTimer);
  197. if (this._loginPanelTimer) clearTimeout(this._loginPanelTimer);
  198. this.destroyChat();
  199. },
  200. methods: {
  201. // ========== Storage Key ==========
  202. getStorageKey(corpId, suffix = 'expire') {
  203. return `wecom_session_${corpId}_${suffix}`;
  204. },
  205. checkLoginState(corpId) {
  206. const expire = localStorage.getItem(this.getStorageKey(corpId, 'expire'));
  207. return expire && Date.now() < parseInt(expire, 10);
  208. },
  209. storeLoginState(corpId) {
  210. localStorage.setItem(this.getStorageKey(corpId, 'expire'), String(Date.now() + 115 * 60 * 1000));
  211. },
  212. clearLoginState(corpId) {
  213. localStorage.removeItem(this.getStorageKey(corpId, 'expire'));
  214. },
  215. // ========== 配置加载 ==========
  216. async initConfig() {
  217. if (!this.corpId || this.configReady || this.configError) return;
  218. const cached = getCachedConfig(this.corpId);
  219. if (cached) {
  220. this.config = cached;
  221. this.configReady = true;
  222. this.configError = false;
  223. this.configLoading = false;
  224. return;
  225. }
  226. if (window._QW_CONFIG_PENDING.has(this.corpId)) {
  227. return window._QW_CONFIG_PENDING.get(this.corpId);
  228. }
  229. this.configLoading = true;
  230. const promise = (async () => {
  231. try {
  232. const res = await getQwSessionConfig(this.corpId);
  233. let configData = null;
  234. if (res?.code === 200 && res.data?.corpid) configData = res.data;
  235. else if (res?.data?.corpid) configData = res.data;
  236. else if (res?.corpid) configData = res;
  237. if (!configData?.corpid || !configData?.agentid) throw new Error('配置数据不完整');
  238. const cfg = {
  239. corpid: configData.corpid,
  240. agentid: String(configData.agentid),
  241. domain: configData.domain || ''
  242. };
  243. this.config = cfg;
  244. this.configReady = true;
  245. this.configError = false;
  246. setCachedConfig(this.corpId, cfg);
  247. } catch (e) {
  248. alert("请更换企微主体!!!")
  249. console.error('[ConversationPanel] 获取配置失败:', e);
  250. this.configReady = false;
  251. this.configError = true;
  252. this.configErrorMsg = e.message || '获取配置失败';
  253. } finally {
  254. this.configLoading = false;
  255. window._QW_CONFIG_PENDING.delete(this.corpId);
  256. }
  257. })();
  258. window._QW_CONFIG_PENDING.set(this.corpId, promise);
  259. return promise;
  260. },
  261. // ========== 认证与加载 ==========
  262. async handleAuthAndLoad() {
  263. if (this.configError) return;
  264. if (!this.configReady) {
  265. await this.initConfig();
  266. if (this.configError) return;
  267. }
  268. const sdkOk = await this.initSDKOnce();
  269. if (!sdkOk) return;
  270. const urlParams = new URLSearchParams(window.location.search);
  271. const code = urlParams.get('code');
  272. if (code) {
  273. window.history.replaceState({}, '', window.location.origin + window.location.pathname);
  274. try {
  275. await this.handleLogin(code);
  276. this.storeLoginState(this.corpId);
  277. this.isLoggedIn = true;
  278. if ((this.customerId && this.staffUserId) || this.chatId) await this.loadFirstPage();
  279. } catch (err) {
  280. this.clearLoginState(this.corpId);
  281. this.isLoggedIn = false;
  282. this.safeEmitLogout();
  283. }
  284. } else {
  285. if (this.checkLoginState(this.corpId)) {
  286. this.isLoggedIn = true;
  287. if ((this.customerId && this.staffUserId) || this.chatId) await this.loadFirstPage();
  288. } else {
  289. this.isLoggedIn = false;
  290. this.safeEmitLogout();
  291. this.tryCreateLoginPanel();
  292. }
  293. }
  294. },
  295. tryCreateLoginPanel() {
  296. if (this._loginPanelCreated || !this.configReady || this.configError || !this._sdkInited || this.isLoggedIn) return;
  297. if (this._loginPanelTimer) clearTimeout(this._loginPanelTimer);
  298. this.$nextTick(() => this.createLoginPanel());
  299. },
  300. createLoginPanel() {
  301. if (this._loginPanelCreated || !this.configReady || this.configError || !this._sdkInited || this.isLoggedIn) return;
  302. let container = this.$refs.loginContainer || document.getElementById('login-container');
  303. if (!container) {
  304. this._loginPanelTimer = setTimeout(() => this.createLoginPanel(), 1000);
  305. return;
  306. }
  307. if (container.getBoundingClientRect().width === 0 || container.getBoundingClientRect().height === 0) {
  308. this._loginPanelTimer = setTimeout(() => this.createLoginPanel(), 500);
  309. return;
  310. }
  311. container.innerHTML = '';
  312. this._loginPanelCreated = true;
  313. const redirectUri = window.location.origin + window.location.pathname;
  314. try {
  315. ww.createWWLoginPanel({
  316. el: container,
  317. params: {
  318. login_type: 'CorpApp',
  319. appid: this.config.corpid,
  320. agentid: this.config.agentid,
  321. redirect_uri: redirectUri,
  322. redirect_type: 'callback',
  323. state: 'state_' + Date.now()
  324. },
  325. onLoginSuccess: async ({ code }) => {
  326. try {
  327. await this.handleLogin(code);
  328. if ((this.customerId && this.staffUserId) || this.chatId) await this.loadFirstPage();
  329. } catch (e) {
  330. this.clearLoginState(this.corpId);
  331. this.isLoggedIn = false;
  332. this._loginPanelCreated = false;
  333. this.safeEmitLogout();
  334. }
  335. },
  336. onLoginError: () => {
  337. this._loginPanelCreated = false;
  338. this._loginPanelTimer = setTimeout(() => this.createLoginPanel(), 3000);
  339. }
  340. });
  341. } catch (e) {
  342. this._loginPanelCreated = false;
  343. this._loginPanelTimer = setTimeout(() => this.createLoginPanel(), 3000);
  344. }
  345. },
  346. safeEmitLogout() {
  347. if (this._hasEmittedLogout) return;
  348. this._hasEmittedLogout = true;
  349. this.$emit('logout');
  350. },
  351. async handleLogin(code) {
  352. const res = await qwLogin({ code, corpid: this.corpId });
  353. const data = this._extractResponse(res);
  354. if (data.errcode !== 0) throw new Error(data.errmsg || '登录失败');
  355. this.storeLoginState(this.corpId);
  356. this.isLoggedIn = true;
  357. this.$emit('login-success');
  358. },
  359. async getAgentConfigSignature() {
  360. if (!this.configReady || this.configError) throw new Error('配置未就绪');
  361. const currentUrl = window.location.href.split('#')[0];
  362. const res = await qwSignature({ url: currentUrl, corpid: this.corpId });
  363. const data = this._extractResponse(res);
  364. if (data.errcode && data.errcode !== 0) {
  365. this.clearLoginState(this.corpId);
  366. this.isLoggedIn = false;
  367. this.isReady = false;
  368. this.destroyChat();
  369. this._loginPanelCreated = false;
  370. if (!this.configError) this.safeEmitLogout();
  371. throw new Error(`签名失败: ${data.errmsg}`);
  372. }
  373. return { timestamp: data.timestamp, nonceStr: data.nonceStr, signature: data.signature };
  374. },
  375. async initSDKOnce() {
  376. if (this._sdkInited) return true;
  377. if (!this.configReady || this.configError) return false;
  378. try {
  379. await ww.register({
  380. corpId: this.config.corpid,
  381. agentId: this.config.agentid,
  382. jsApiList: ['selectExternalContact', 'shareAppMessage', 'wwapp.invokeJsApiByCallInfo'],
  383. getAgentConfigSignature: () => this.getAgentConfigSignature()
  384. });
  385. await ww.initOpenData();
  386. this._sdkInited = true;
  387. return true;
  388. } catch (e) {
  389. console.error('SDK初始化失败', e);
  390. return false;
  391. }
  392. },
  393. async loadFirstPage() {
  394. if (this.configError) return;
  395. try {
  396. const res = await qwConversations({
  397. customerId: this.customerId,
  398. staffUserId: this.staffUserId,
  399. limit: 100,
  400. cursor: this.cursor,
  401. corpid: this.corpId,
  402. chatId: this.chatId || undefined
  403. });
  404. const data = this._extractResponse(res);
  405. if (data.errcode !== 0) throw new Error(data.errmsg || '拉取失败');
  406. const rawList = data.data || [];
  407. this.msgList = this.processMessages(rawList, false);
  408. this.cursor = data.next_cursor || '';
  409. this.hasMore = data.has_more === 1;
  410. this.isReady = true;
  411. await this.$nextTick();
  412. if (this.msgList.length) await this.renderOrUpdateChat();
  413. } catch (e) {
  414. console.error('加载第一页失败', e);
  415. this.$message.error('加载会话失败:' + e.message);
  416. this.isReady = true;
  417. this.msgList = [];
  418. }
  419. },
  420. async loadMore() {
  421. if (!this.cursor || this.loadingMore || !this.hasMore || this.configError) return;
  422. this.loadingMore = true;
  423. try {
  424. const res = await qwConversations({
  425. customerId: this.customerId,
  426. staffUserId: this.staffUserId,
  427. limit: 50,
  428. cursor: this.cursor,
  429. corpid: this.corpId,
  430. chatId: this.chatId || undefined
  431. });
  432. const data = this._extractResponse(res);
  433. if (data.errcode !== 0) throw new Error(data.errmsg || '拉取更多失败');
  434. const newMessages = data.data || [];
  435. if (newMessages.length) {
  436. const processed = this.processMessages(newMessages, true);
  437. this.msgList.push(...processed);
  438. this.cursor = data.next_cursor || '';
  439. this.hasMore = data.has_more === 1;
  440. if (this.chatInstance) this.chatInstance.setData({ msgList: this.filteredMsgList });
  441. else await this.renderOrUpdateChat();
  442. } else {
  443. this.hasMore = false;
  444. }
  445. } catch (e) {
  446. console.error('加载更多失败', e);
  447. } finally {
  448. this.loadingMore = false;
  449. }
  450. },
  451. processMessages(messages, isAppend = false) {
  452. const prevLastMsg = isAppend && this.msgList.length > 0
  453. ? this.msgList[this.msgList.length - 1]
  454. : null
  455. let prevDate = prevLastMsg ? (prevLastMsg.dateStr || '') : ''
  456. return messages.map(msg => {
  457. const timeStr = msg.send_time_str || ''
  458. const parts = timeStr.split(' ')
  459. const dateStr = parts[0] || ''
  460. const displayTime = parts[1] ? parts[1].substring(0, 5) : ''
  461. const showDate = !!dateStr && dateStr !== prevDate
  462. prevDate = dateStr
  463. return {
  464. ...msg,
  465. dateStr,
  466. displayTime,
  467. showDate
  468. }
  469. })
  470. },
  471. // 消息类型筛选
  472. handleFilterChange(type) {
  473. this.msgTypeFilter = type;
  474. if (this.chatInstance) {
  475. this.chatInstance.setData({
  476. msgList: this.filteredMsgList,
  477. hasMore: this.hasMore,
  478. loadingMore: this.loadingMore
  479. });
  480. }
  481. },
  482. async renderOrUpdateChat() {
  483. if (this.msgList.length === 0 || this.configError) return;
  484. const container = document.getElementById('chat-container');
  485. if (!container) return;
  486. if (this.chatInstance) {
  487. this.chatInstance.setData({ msgList: this.filteredMsgList });
  488. return;
  489. }
  490. const ok = await this.initSDKOnce();
  491. if (!ok) return;
  492. const factory = ww.createOpenDataFrameFactory();
  493. if (!factory) return;
  494. this.chatInstance = factory.createOpenDataFrame({
  495. el: container,
  496. template: `
  497. <scroll-view scroll-y="{{true}}" bindscrolltolower="onScrollToLower" style="height: 100%;">
  498. <view wx:for="{{data.msgList}}" wx:for-item="msg" wx:key="msgid" style="margin-bottom: 15px;">
  499. <!-- 日期分隔线:仅跨天时显示 -->
  500. <view wx:if="{{msg.showDate}}" style="text-align: center; margin-bottom: 12px;">
  501. <span style="background:#e9e9e9; color:#888; padding:3px 12px; border-radius:12px; font-size:11px;">{{ msg.dateStr }}</span>
  502. </view>
  503. <!-- 客户/机器人消息(左对齐) -->
  504. <view wx:if="{{msg.sender.type == 2 || msg.sender.type == 3}}" style="display:flex; flex-direction:row; align-items:flex-start;">
  505. <ww-open-data wx:if="{{msg.sender.type == 2}}" type="externalUserAvatar" openid="{{msg.sender.id}}" class="msg-avatar-left" />
  506. <ww-open-data wx:if="{{msg.sender.type == 3}}" type="userAvatar" openid="{{msg.sender.id}}" class="msg-avatar-left" />
  507. <view style="display:flex; flex-direction:column; max-width:70%; margin-left:10px;">
  508. <view style="display:flex; align-items:center; margin-bottom:4px;">
  509. <ww-open-data wx:if="{{msg.sender.type == 2}}" type="externalUserName" openid="{{msg.sender.id}}" class="sender-name" />
  510. <ww-open-data wx:if="{{msg.sender.type == 3}}" type="userName" openid="{{msg.sender.id}}" class="sender-name" />
  511. <span style="font-size:11px;color:#999;margin-left:8px;">{{ msg.displayTime }}</span>
  512. </view>
  513. <view class="msg-bubble msg-bubble-left">
  514. <ww-open-message message-id="{{msg.msgid}}" secret-key="{{msg.secretKey}}" open-type="viewMessage"/>
  515. </view>
  516. </view>
  517. </view>
  518. <!-- 员工消息(右对齐) -->
  519. <view wx:if="{{msg.sender.type == 1}}" style="display:flex; flex-direction:row; justify-content:flex-end; align-items:flex-start;">
  520. <view style="display:flex; flex-direction:column; align-items:flex-end; max-width:70%; margin-right:10px;">
  521. <view style="display:flex; align-items:center; margin-bottom:4px;">
  522. <span style="font-size:11px;color:#999;margin-right:8px;">{{ msg.displayTime }}</span>
  523. <ww-open-data type="userName" openid="{{msg.sender.id}}" class="sender-name" />
  524. </view>
  525. <view class="msg-bubble msg-bubble-right">
  526. <ww-open-message message-id="{{msg.msgid}}" secret-key="{{msg.secretKey}}" open-type="viewMessage"/>
  527. </view>
  528. </view>
  529. <ww-open-data type="userAvatar" openid="{{msg.sender.id}}" class="msg-avatar-right" />
  530. </view>
  531. <!-- 未知类型消息(居中,无头像) -->
  532. <view wx:if="{{msg.sender.type != 1 && msg.sender.type != 2 && msg.sender.type != 3}}" style="display:flex; justify-content:center;">
  533. <view class="msg-bubble msg-bubble-left">
  534. <ww-open-message message-id="{{msg.msgid}}" secret-key="{{msg.secretKey}}" open-type="viewMessage"/>
  535. </view>
  536. </view>
  537. </view>
  538. <view wx:if="{{data.hasMore && data.loadingMore}}" style="text-align:center; padding:10px; color:#999;">加载更多...</view>
  539. </scroll-view>
  540. `,
  541. style: `
  542. .msg-avatar-left {
  543. width: 36px;
  544. height: 36px;
  545. border-radius: 4px;
  546. flex-shrink: 0;
  547. }
  548. .msg-avatar-right {
  549. width: 36px;
  550. height: 36px;
  551. border-radius: 4px;
  552. flex-shrink: 0;
  553. }
  554. .sender-name {
  555. font-size: 12px;
  556. color: #666;
  557. max-width: 120px;
  558. overflow: hidden;
  559. text-overflow: ellipsis;
  560. white-space: nowrap;
  561. }
  562. .msg-bubble {
  563. padding: 10px 14px;
  564. border-radius: 8px;
  565. word-break: break-all;
  566. display: flex;
  567. align-items: center;
  568. }
  569. .msg-bubble-left {
  570. background: #ffffff;
  571. box-shadow: 0 1px 3px rgba(0,0,0,0.1);
  572. }
  573. .msg-bubble-right {
  574. background: #95ec69;
  575. box-shadow: 0 1px 3px rgba(0,0,0,0.1);
  576. }
  577. `,
  578. data: {
  579. msgList: this.filteredMsgList,
  580. hasMore: this.hasMore,
  581. loadingMore: this.loadingMore
  582. },
  583. methods: {
  584. onScrollToLower: () => {
  585. if (this.hasMore && !this.loadingMore && !this.configError) this.loadMore();
  586. }
  587. },
  588. error: (e) => {
  589. console.error('[企微组件] 错误', e);
  590. if (e && [42006, 42003, 40029].includes(e.errCode)) {
  591. if (!this.configError) {
  592. this.clearLoginState(this.corpId);
  593. this.isLoggedIn = false;
  594. this.isReady = false;
  595. this.destroyChat();
  596. this._loginPanelCreated = false;
  597. this.safeEmitLogout();
  598. }
  599. }
  600. },
  601. handleModal: ({ modalUrl, modalSize }) => {
  602. const mask = document.createElement('div');
  603. mask.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.5);z-index:9999;display:flex;align-items:center;justify-content:center;';
  604. const content = document.createElement('div');
  605. content.style.cssText = `position:relative;max-width:90vw;max-height:90vh;width:${modalSize?.width || 80}%;height:${modalSize?.height || 80}%;background:#fff;border-radius:8px;overflow:hidden;`;
  606. const closeBtn = document.createElement('button');
  607. closeBtn.innerText = '✕';
  608. closeBtn.style.cssText = 'position:absolute;top:10px;right:10px;z-index:10001;width:32px;height:32px;border-radius:50%;border:none;background:rgba(0,0,0,0.5);color:#fff;font-size:20px;cursor:pointer;display:flex;align-items:center;justify-content:center;';
  609. const iframe = document.createElement('iframe');
  610. iframe.src = modalUrl;
  611. iframe.style.cssText = 'width:100%;height:100%;border:none;';
  612. const cleanup = () => {
  613. mask.remove();
  614. document.removeEventListener('keydown', escHandler);
  615. };
  616. closeBtn.onclick = (e) => { e.stopPropagation(); cleanup(); };
  617. mask.onclick = (e) => { if (e.target === mask) cleanup(); };
  618. const escHandler = (e) => { if (e.key === 'Escape') cleanup(); };
  619. document.addEventListener('keydown', escHandler);
  620. content.appendChild(iframe);
  621. content.appendChild(closeBtn);
  622. mask.appendChild(content);
  623. document.body.appendChild(mask);
  624. return true;
  625. }
  626. });
  627. },
  628. async resetAndReload() {
  629. this.destroyChat();
  630. this.msgList = [];
  631. this.cursor = '';
  632. this.hasMore = true;
  633. this.loadingMore = false;
  634. this.isReady = false;
  635. await this.loadFirstPage();
  636. },
  637. destroyChat() {
  638. if (this.chatInstance) this.chatInstance = null;
  639. const container = document.getElementById('chat-container');
  640. if (container) container.innerHTML = '';
  641. },
  642. _extractResponse(res) {
  643. if (!res) return {};
  644. if (res.code !== undefined) return res;
  645. if (res.errcode !== undefined || res.timestamp || res.corpid || res.data) return res;
  646. if (res.data?.data) return res.data.data;
  647. return res;
  648. }
  649. }
  650. };
  651. </script>
  652. <style scoped>
  653. .conversation-panel {
  654. width: 100%;
  655. height: 100%;
  656. display: flex;
  657. flex-direction: column;
  658. background: #f5f6f7;
  659. position: relative;
  660. }
  661. .chat-wrapper {
  662. flex: 1;
  663. min-height: 0;
  664. display: flex;
  665. flex-direction: column;
  666. overflow: hidden;
  667. }
  668. .msg-type-filter {
  669. flex-shrink: 0;
  670. display: flex;
  671. gap: 4px;
  672. padding: 8px 12px;
  673. background: #fff;
  674. border-bottom: 1px solid #e8e8e8;
  675. flex-wrap: wrap;
  676. }
  677. .filter-item {
  678. padding: 4px 12px;
  679. font-size: 13px;
  680. color: #606266;
  681. cursor: pointer;
  682. border-radius: 14px;
  683. user-select: none;
  684. }
  685. .filter-item:hover {
  686. color: #409eff;
  687. }
  688. .filter-item.active {
  689. background: #409eff;
  690. color: #fff;
  691. }
  692. .login-area, .empty-tip, .loading-tip {
  693. display: flex;
  694. flex-direction: column;
  695. align-items: center;
  696. justify-content: center;
  697. height: 100%;
  698. flex: 1;
  699. }
  700. .login-tip {
  701. font-size: 18px;
  702. color: #666;
  703. margin-bottom: 24px;
  704. }
  705. .empty-tip, .loading-tip {
  706. color: #999;
  707. font-size: 16px;
  708. }
  709. .empty-tip i, .loading-tip i {
  710. font-size: 48px;
  711. margin-bottom: 16px;
  712. }
  713. #chat-container {
  714. flex: 1;
  715. min-height: 0;
  716. width: 100%;
  717. background: #f0f2f5;
  718. }
  719. .login-container {
  720. width: 320px;
  721. height: 380px;
  722. display: flex;
  723. align-items: center;
  724. justify-content: center;
  725. }
  726. </style>
  727. <style>
  728. #chat-container {
  729. width: 100% !important;
  730. }
  731. #chat-container > div,
  732. #chat-container iframe {
  733. width: 100% !important;
  734. height: 100% !important;
  735. min-width: 100% !important;
  736. min-height: 100% !important;
  737. overflow: hidden;
  738. }
  739. </style>