ai.vue 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716
  1. <template>
  2. <view class="chat-container" :style="{
  3. paddingTop: appStore.navbarHeight + 'px',
  4. }">
  5. <view class="mine_ybt_title flex-center bg_color_fff"
  6. :style="{ height: appStore.navbarHeight + 'px',
  7. paddingTop: appStore.statusBarHeight + 'px'}"
  8. >
  9. <!-- <text class="font_size35 bold">AI客服</text> -->
  10. <agentCheck ref="agentCheckRef"></agentCheck>
  11. </view>
  12. <!-- 聊天消息区域 -->
  13. <view class="chat-messages flex-column">
  14. <!-- 工具栏 -->
  15. <tools @addHuiHuaFn="addHuiHuaFn"></tools>
  16. <!-- 消息列表 -->
  17. <scroll-view
  18. class="scrollViewRef flex_1"
  19. scroll-y="true"
  20. :scroll-top="scrollTop"
  21. ref="scrollViewRef"
  22. :scroll-with-animation="true"
  23. @refresherrefresh="handlePullDownRefresh"
  24. :refresher-enabled="true"
  25. :refresher-triggered="triggered"
  26. >
  27. <messagesInfoDefault v-if="messages.length==0"
  28. @sendMessage="sendMessage"
  29. ref="messagesInfoDefaultRef" key="messagesInfoDefaultRef">
  30. </messagesInfoDefault>
  31. <messagesInfo
  32. :messages="messages"
  33. :isLoading="isLoading"
  34. @imageLoaded="scrollToBottom"
  35. :id="`msg-${messagesKey}`">
  36. </messagesInfo>
  37. </scroll-view>
  38. </view>
  39. <!-- 输入区域 -->
  40. <view class="input-area">
  41. <button class="image-btn" :disabled="isLoading" @click.stop.prevent="uploadImage">
  42. <image class="image-icon" src="/static/img/service/tupian.png" model="aspectFit"></image>
  43. </button>
  44. <button v-if="isvoice"
  45. class="record-btn"
  46. :class="{ recording: isRecording }"
  47. @touchstart="startRecord"
  48. @touchend="stopRecord"
  49. @touchmove="handleTouchMove"
  50. @touchcancel="cancelRecord"
  51. :disabled="isRecording && isCancel"
  52. >
  53. <view v-if="!isRecording">按住说话</view>
  54. <view v-if="isRecording && !isCancel">松手发送,上移取消</view>
  55. <view v-if="isRecording && isCancel">松手取消</view>
  56. <text v-if="recordDuration>0"></text>
  57. </button>
  58. <input v-else
  59. class="input-box"
  60. :placeholder="isLoading?'努力回答中...':'发消息或按住说话'"
  61. placeholder-style="color: #999"
  62. v-model.trim="inputText"
  63. :disabled="isLoading"
  64. @confirm="sendMessage({chatType:0,msgContent:inputText})"
  65. />
  66. <button class="isvoice-btn" @click.stop.prevent="isvoice=!isvoice;authorizeRecord()" :disabled="isLoading">
  67. <image class="mic-icon" src="/static/img/service/xiaoxi.png" v-if="isvoice" model="aspectFit"></image>
  68. <image class="mic-icon" src="/static/img/service/maikefengyuyin.png" v-else model="aspectFit"></image>
  69. </button>
  70. <button class="send-btn" @click.stop.prevent="sendMessage({chatType:0,msgContent:inputText})" :disabled="isLoading" v-if="!isvoice">
  71. <image class="send-icon" src="/static/img/service/send-icon.png"></image>
  72. </button>
  73. <view class="ai-tip">内容由AI生成,仅供参考</view>
  74. </view>
  75. <!-- 录音动画/提示 -->
  76. <view
  77. class="record-toast"
  78. v-if="isRecording"
  79. :class="{ cancel: isCancel }"
  80. >
  81. <image
  82. v-if="!isCancel"
  83. src="/static/img/voice/recording.gif"
  84. class="toast-icon"
  85. alt="录音中动画"
  86. ></image>
  87. <image
  88. v-if="isCancel"
  89. src="/static/img/voice/cancel.png"
  90. class="toast-icon"
  91. alt="取消录音图标"
  92. ></image>
  93. <text v-if="!isCancel">正在录音...({{60-recordDuration}})</text>
  94. <text v-if="isCancel">松手取消发送</text>
  95. </view>
  96. </view>
  97. </template>
  98. <script setup>
  99. import { ref, nextTick, watch } from 'vue';
  100. import { onShow,onHide,onLoad } from "@dcloudio/uni-app"
  101. import { chatHistoryDetails } from '@/api/ai.js';
  102. import messagesInfo from "./components/messagesInfo.vue";
  103. import messagesInfoDefault from "./components/messagesInfoDefault.vue";
  104. import tools from "./components/tools.vue";
  105. import agentCheck from "./components/agentCheck.vue";
  106. import {
  107. HTTP_REQUEST_URL,
  108. HTTP_REQUEST_URL_WS,
  109. TOKENNAME
  110. } from '@/config/app';
  111. import { chooseImageOne,checkLoginShowModal,checkAiQuotaDailyModal,getUserInfo } from "@/utils/util.js";
  112. // 封装的websocket
  113. import WSClient from '@/utils/wsUtil.js';
  114. import { useAppStore } from '@/stores/app'
  115. import { useToast } from '@/hooks/useToast'
  116. import dayjs from "dayjs";
  117. const appStore = useAppStore();
  118. const { Toast } = useToast();
  119. const agentCheckRef = ref(null);
  120. const messagesInfoDefaultRef = ref(null);
  121. const newSession = ref(false);//是否新会话
  122. const historySession = ref('');//是否新会话
  123. // 添加滚动相关变量
  124. const scrollTimeout = ref(null);
  125. const scrollViewRef = ref(null);
  126. const scrollTop = ref(0);
  127. const messagesKey = ref(0);
  128. const triggered = ref(false);
  129. // 底部发送功能
  130. const inputText = ref('');
  131. const isLoading = ref(false);//思考中
  132. const isvoice = ref(false);//是否语音输入
  133. const wsClient = ref(null);
  134. // 模拟聊天消息数据
  135. const messages = ref([]);
  136. watch(() => appStore.agentId, (state) => {
  137. messages.value = [];
  138. getAdSearchFn()
  139. });
  140. onLoad((e)=>{
  141. console.log('ai onLoad',e);
  142. })
  143. onShow(async()=>{
  144. if(!await checkLoginShowModal())return;
  145. nextTick(async ()=>{
  146. // 初始化选择智能体
  147. await agentCheckRef.value.initAgentId();
  148. // 初始化完成后获取 agentId
  149. console.log('agentId:', agentCheckRef.value.agentId);
  150. if(appStore.msgContent){
  151. sendMessage({chatType:0,msgContent:appStore.msgContent});
  152. appStore.UPDATE_msgContent('');
  153. }else if(appStore.sessionId){
  154. handlePullDownRefresh();
  155. }else{
  156. // getAdSearchFn()
  157. }
  158. })
  159. aiStartChatFn();
  160. });
  161. onHide(()=>{
  162. isLoading.value = false;
  163. cleanupResources();
  164. });
  165. // 新增:添加回话功能
  166. function addHuiHuaFn(){
  167. newSession.value = true;
  168. messages.value = [];
  169. getAdSearchFn();
  170. }
  171. function getAdSearchFn(){
  172. nextTick(()=>{
  173. setTimeout(()=>{
  174. messagesInfoDefaultRef.value.getAdSearchFn();
  175. },50)
  176. })
  177. }
  178. function authorizeRecord() {
  179. uni.authorize({
  180. scope: 'scope.record',
  181. success() {
  182. // uni.getRecorderManager();
  183. }
  184. })
  185. }
  186. function aiStartChatFn(){
  187. cleanupResources();
  188. wsClient.value = new WSClient({
  189. url: `${HTTP_REQUEST_URL_WS}/api/websocket`,
  190. method: 'POST',
  191. headers: {
  192. "Authorization": 'Bearer '+appStore.token
  193. }
  194. });
  195. // 注册SSE事件
  196. registerEvents();
  197. wsClient.value.open({type:0,message:inputText.value})
  198. }
  199. //
  200. async function sendMessage({chatType,msgContent=""}){
  201. console.log('sendMessage',wsClient.value);
  202. // 登录检测
  203. if(!await checkLoginShowModal())return;
  204. // 今日免费提问次数检测
  205. if(!await checkAiQuotaDailyModal())return;
  206. if (!msgContent) {
  207. Toast({title:'请输入内容'});
  208. return;
  209. }
  210. isLoading.value = true
  211. messages.value.push({
  212. msgContent,
  213. chatType,// 0 (文本),1 (图片)
  214. speakerType:0//0用户消息1AI消息
  215. });
  216. messages.value.push({
  217. msgContent: "",
  218. event: "start",//sse长链接的返回状态
  219. chatType,
  220. speakerType:1,
  221. messageTimeNow:dayjs().format('HH:mm')
  222. });
  223. //type 0 (文本),1 (图片)
  224. wsClient.value.send({
  225. type:chatType,
  226. message:msgContent,
  227. agentId:agentCheckRef.value.agentId,
  228. useBalance:true,
  229. newSession:newSession.value,
  230. historySession:historySession.value
  231. });
  232. messagesKey.value++;
  233. inputText.value = '';
  234. newSession.value = false;
  235. // 每次收到新消息时滚动到底部
  236. // 如果是图片,子组件里图片加载成功后触发
  237. if(chatType!=1)scrollToBottom();
  238. }
  239. async function uploadImage(){
  240. if(!await checkLoginShowModal())return;
  241. const res = await chooseImageOne();
  242. console.log('uploadImage',res);
  243. if(res){
  244. // userInfo.value[key] = res.fileName
  245. sendMessage({chatType:1,msgContent:res.data})
  246. }
  247. }
  248. function cleanupResources(closeSSE=true) {
  249. // 关闭SSE连接
  250. if (wsClient.value) {
  251. // 移除所有事件监听
  252. if (wsClient.value.callbacks) {
  253. Object.keys(wsClient.value.callbacks).forEach(event => {
  254. wsClient.value.callbacks[event] = [];
  255. });
  256. }
  257. wsClient.value.close('clean');
  258. wsClient.value = null;
  259. }
  260. }
  261. function registerEvents(){
  262. wsClient.value.on('open', (data) => {
  263. console.log('ws连接成功',data);
  264. });
  265. wsClient.value.on('answer', (data) => {
  266. console.log('answer',data.segment,messages.value.length - 1);
  267. isLoading.value = false;
  268. const lastIndex = messages.value.length - 1;
  269. // 保存完整内容(累加片段)
  270. // const newFullAnswer = messages.value[lastIndex].msgContent + data.segment;
  271. messages.value[lastIndex].msgContent = messages.value[lastIndex].msgContent + data.segment;
  272. messagesKey.value++;
  273. console.log('answer',messages);
  274. // 每次收到新消息时滚动到底部
  275. scrollToBottom();
  276. nextTick(()=>{
  277. // 获取用户信息
  278. getUserInfo();
  279. })
  280. });
  281. wsClient.value.on('error', (err) => {
  282. isLoading.value = false;
  283. console.log('error',err);
  284. // aiStartChatFn();
  285. Toast({ title: JSON.stringify(err) || "服务器异常" });
  286. });
  287. wsClient.value.on('close', (data) => {
  288. isLoading.value = false;
  289. let lastIndex = messages.value.length - 1;
  290. console.log('close',data,lastIndex);
  291. // 判断关闭原因,取消,删除当前聊天内容,超时提示请求超时
  292. switch (data.reason) {
  293. case 'timeout': {
  294. // 提示超时
  295. // uni.showToast({
  296. // title: '请求超时,请重试',
  297. // icon: 'none'
  298. // });
  299. if(lastIndex>-1)messages.value[lastIndex].msgContent = messages.value[lastIndex].msgContent || "请求超时,请重试";
  300. aiStartChatFn();
  301. break;
  302. }
  303. default:
  304. break;
  305. }
  306. });
  307. }
  308. // 新增:滚动到底部的方法
  309. function scrollToBottom() {
  310. nextTick(() => {
  311. // if (scrollTimeout.value) return;
  312. // scrollTimeout.value = setTimeout(() => {
  313. uni.createSelectorQuery()
  314. .select(`#msg-${messagesKey.value}`)
  315. .boundingClientRect(rect => {
  316. if (rect) {
  317. scrollTop.value = rect.height;
  318. // 使用更平滑的滚动方式
  319. uni.pageScrollTo({
  320. scrollTop: rect.bottom,
  321. duration: 100
  322. })
  323. }
  324. })
  325. .exec();
  326. // scrollTimeout.value = null;
  327. // }, 80); // 延长节流间隔至80ms,减少冲突
  328. });
  329. }
  330. /*******************************************历史数据********************************************************/
  331. const hitstoryParams = ref({
  332. pageNum: 0,
  333. pageSize: 10,
  334. sessionId:appStore.sessionId,
  335. });
  336. // 重命名方法,避免与小程序生命周期冲突
  337. function handlePullDownRefresh() {
  338. console.log('下拉刷新触发');
  339. triggered.value = true;
  340. hitstoryParams.value.pageNum++;
  341. chatHistoryDetailsFn();
  342. }
  343. function chatHistoryDetailsFn(){
  344. hitstoryParams.value.sessionId = appStore.sessionId;
  345. chatHistoryDetails(hitstoryParams.value).then(res=>{
  346. triggered.value = false;
  347. console.log('chatHistoryFn',res);
  348. const rows = res?.rows || [];
  349. if(rows.length==0 && hitstoryParams.value.pageNum>0){
  350. hitstoryParams.value.pageNum--;
  351. // Toast({title:'没有更多历史记录了'});
  352. return;
  353. }
  354. if(messages.value.length==res.total){
  355. Toast({title:'没有更多历史记录了'});
  356. return;
  357. }
  358. messages.value = [...rows, ...messages.value]
  359. })
  360. }
  361. /*******************************************语音功能********************************************************/
  362. const isRecording = ref(false);// 是否正在录音
  363. const isCancel = ref(false); // 是否取消录音
  364. const recordDuration = ref(0); // 录音时长(秒)
  365. const tempFilePath = ref(''); // 录音临时文件路径
  366. const recordTimer = ref(null); // 录音计时定时器
  367. const recorderManager = uni.getRecorderManager(); // 录音管理
  368. // 开始录音(触摸开始)
  369. const startRecord = async (e) => {
  370. if(!await checkLoginShowModal())return;
  371. // 防止冒泡导致的异常
  372. e.stopPropagation();
  373. // 初始化录音参数
  374. const options = {
  375. format: 'mp3', // 录音格式
  376. sampleRate: 44100, // 采样率
  377. numberOfChannels: 1, // 声道数
  378. encodeBitRate: 96000 // 编码比特率
  379. };
  380. // 开始录音
  381. recorderManager.start(options);
  382. isRecording.value = true;
  383. isCancel.value = false;
  384. recordDuration.value = 0;
  385. // 计时逻辑
  386. recordTimer.value = setInterval(() => {
  387. recordDuration.value++;
  388. // 限制最大录音时长(如60秒)
  389. if (recordDuration.value >= 60) {
  390. stopRecord();
  391. }
  392. }, 1000);
  393. // 监听录音错误
  394. recorderManager.onError((err) => {
  395. console.error('录音错误:', err);
  396. cancelRecord();
  397. uni.showToast({ title: '录音失败', icon: 'none' });
  398. });
  399. };
  400. // 停止录音(触摸结束)
  401. const stopRecord = () => {
  402. if (!isRecording.value) return;
  403. // 清除计时
  404. clearInterval(recordTimer.value);
  405. // 停止录音
  406. recorderManager.stop();
  407. recorderManager.onStop((res) => {
  408. tempFilePath.value = res.tempFilePath;
  409. console.log('recorderManager',res.tempFilePath);
  410. // 判断是否取消或录音过短
  411. if (isCancel.value) {
  412. uni.showToast({ title: '已取消发送', icon: 'none' });
  413. } else if (recordDuration.value < 1) {
  414. uni.showToast({ title: '录音时间太短', icon: 'none' });
  415. } else {
  416. // 上传录音并添加到列表
  417. uploadVoice(res.tempFilePath);
  418. }
  419. // 重置状态
  420. resetRecordState();
  421. });
  422. };
  423. // 处理触摸移动(用于判断是否取消)
  424. const handleTouchMove = (e) => {
  425. if (!isRecording.value) return;
  426. // 获取按钮位置和触摸位置
  427. const buttonRect = uni.createSelectorQuery().select('.record-btn').boundingClientRect();
  428. buttonRect.exec((rects) => {
  429. const rect = rects[0];
  430. if (!rect) return;
  431. // 计算触摸点与按钮的垂直距离(向上移动超过50px视为取消)
  432. const touchY = e.touches[0].clientY;
  433. const buttonTop = rect.top;
  434. if (touchY < buttonTop - 50) {
  435. isCancel.value = true;
  436. } else {
  437. isCancel.value = false;
  438. }
  439. });
  440. };
  441. // 取消录音(触摸中断)
  442. const cancelRecord = () => {
  443. if (!isRecording.value) return;
  444. clearInterval(recordTimer.value);
  445. recorderManager.stop();
  446. resetRecordState();
  447. uni.showToast({ title: '已取消发送', icon: 'none' });
  448. };
  449. // 重置录音状态
  450. const resetRecordState = () => {
  451. isRecording.value = false;
  452. isCancel.value = false;
  453. recordDuration.value = 0;
  454. tempFilePath.value = '';
  455. };
  456. // 上传录音到服务器
  457. const uploadVoice = (filePath) => {
  458. if (!filePath) return;
  459. uni.showLoading({ title: '发送中...' });
  460. // 调用上传接口
  461. uni.uploadFile({
  462. url: `${HTTP_REQUEST_URL}/mini/chat/file/voiceUpload`, // 替换为你的后端接口
  463. filePath,
  464. name: 'file', // 后端接收文件的参数名
  465. formData: {
  466. duration: recordDuration.value // 携带录音时长
  467. },
  468. method: 'POST',
  469. header: {
  470. "Authorization": appStore.token
  471. },
  472. success: (res) => {
  473. console.log('上传结果:', res);
  474. const result = JSON.parse(res.data);
  475. if (result.code === 200) {
  476. // 上传成功,添加到消息列表
  477. sendMessage({ chatType:0,msgContent:result.data });
  478. // messages.value.push({
  479. // id: Date.now(),
  480. // isMine: true,
  481. // avatar: '/static/avatar/user.png',
  482. // duration: recordDuration.value,
  483. // url: result.data.url, // 服务器返回的音频地址
  484. // isPlaying: false
  485. // });
  486. } else {
  487. uni.showToast({ title: '发送失败', icon: 'none' });
  488. }
  489. },
  490. fail: (err) => {
  491. console.error('语音上传失败:', err);
  492. uni.showToast({ title: '发送失败', icon: 'none' });
  493. },
  494. complete: () => {
  495. uni.hideLoading();
  496. }
  497. });
  498. };
  499. </script>
  500. <style scoped lang="scss">
  501. .mine_ybt_title{
  502. position: fixed;
  503. top: 0;
  504. left: 0;
  505. width: 100%;
  506. z-index: 1;
  507. }
  508. /* 录音区域 */
  509. .record-btn{
  510. flex: 1;
  511. height: 70rpx;
  512. line-height: 70rpx;
  513. font-size: 30rpx;
  514. color: #333;
  515. }
  516. .record-area {
  517. padding: 30rpx;
  518. display: flex;
  519. flex-direction: column;
  520. align-items: center;
  521. }
  522. .record-btn {
  523. flex: 1;
  524. height: 70rpx;
  525. line-height: 70rpx;
  526. font-size: 30rpx;
  527. color: #333;
  528. background-color: #f2f2f2;
  529. display: flex;
  530. align-items: center;
  531. justify-content: center;
  532. border: none;
  533. }
  534. .record-btn.recording {
  535. background-color: #ff4d4f;
  536. color: #fff;
  537. }
  538. .record-btn.cancel {
  539. background-color: #999;
  540. }
  541. /* 整体容器样式 */
  542. .chat-container {
  543. display: flex;
  544. flex-direction: column;
  545. height: 100vh;
  546. // padding-bottom: 200rpx;
  547. /* background-color: #f5f5f5; */
  548. }
  549. /* 聊天消息区域样式 */
  550. .chat-messages {
  551. flex: 1;
  552. overflow-y: auto;
  553. .scrollViewRef{
  554. // height: 100%;
  555. padding: 16rpx;
  556. overflow-y: auto;
  557. }
  558. // height: calc(100vh - 200rpx);
  559. }
  560. /* 输入区域样式 */
  561. .input-area {
  562. // position: fixed;
  563. // left: 0;
  564. // bottom: 0;
  565. // width: 100%;
  566. display: flex;
  567. align-items: center;
  568. background-color: #fff;
  569. padding: 30rpx 40rpx;
  570. border-top: 1rpx solid #e0e0e0;
  571. position: relative;
  572. }
  573. .ai-tip{
  574. position: absolute;
  575. bottom: 5rpx;
  576. width: calc(100% - 80rpx);
  577. text-align: center;
  578. font-size: 20rpx;
  579. color: #999;
  580. }
  581. /* 图片图标样式 */
  582. .image-btn{
  583. margin-right: 30rpx;
  584. width: 40rpx;
  585. height: 40rpx;
  586. &[disabled]{
  587. .image-icon{
  588. opacity: 0.4;
  589. }
  590. }
  591. .image-icon {
  592. width: 100%;
  593. height:100%;
  594. }
  595. }
  596. /* 输入框样式 */
  597. .input-box {
  598. flex: 1;
  599. height: 70rpx;
  600. font-size: 30rpx;
  601. color: #333;
  602. background-color: transparent;
  603. }
  604. /* 麦克风图标样式 */
  605. .isvoice-btn{
  606. width: 40rpx;
  607. height: 40rpx;
  608. margin: 0 40rpx;
  609. &[disabled]{
  610. .mic-icon{
  611. opacity: 0.4;
  612. }
  613. }
  614. .mic-icon {
  615. width: 100%;
  616. height:100%;
  617. }
  618. }
  619. /* 发送按钮样式 */
  620. .send-btn {
  621. width: 60rpx;
  622. height: 60rpx;
  623. border-radius: 50%;
  624. display: flex;
  625. align-items: center;
  626. justify-content: center;
  627. padding: 0;
  628. &[disabled]{
  629. .send-icon{
  630. opacity: 0.4;
  631. }
  632. }
  633. }
  634. /* 发送图标样式 */
  635. .send-icon {
  636. width: 100%;
  637. height: 100%;
  638. }
  639. /* 录音提示 */
  640. .record-toast {
  641. position: fixed;
  642. top: 50%;
  643. left: 50%;
  644. transform: translate(-50%, -50%);
  645. width: 300rpx;
  646. height: 300rpx;
  647. border-radius: 20rpx;
  648. background-color: rgba(0, 0, 0, 0.7);
  649. color: #fff;
  650. display: flex;
  651. flex-direction: column;
  652. align-items: center;
  653. justify-content: center;
  654. z-index: 999;
  655. }
  656. .record-toast.cancel {
  657. background-color: rgba(255, 77, 79, 0.8);
  658. }
  659. .toast-icon {
  660. width: 200rpx;
  661. height: 200rpx;
  662. margin-bottom: 20rpx;
  663. }
  664. </style>