You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

763 lines
21 KiB

4 weeks ago
  1. <!-- @format -->
  2. <template>
  3. <view class="main">
  4. <!-- 加载中 -->
  5. <h-loading :loading="loading"></h-loading>
  6. <!-- 固定头部 -->
  7. <view class="header_fixed" :style="{ top: iSMT + 'px' }">
  8. <view class="header_content">
  9. <view class="header_back" @click="goBack">
  10. <image src="/static/marketSituation-image/back.png" mode=""></image>
  11. </view>
  12. <view class="header_input_wrapper">
  13. <image class="search_icon" src="/static/marketSituation-image/search.png" mode="" @click="onSearchClick"></image>
  14. <input class="header_input" type="text" placeholder="搜索" placeholder-style="color: #A6A6A6; font-size: 22rpx;" v-model="searchValue" @input="onSearchInput" @confirm="onSearchConfirm" />
  15. </view>
  16. <view class="header_icons">
  17. <view class="header_icon" @click="selected">
  18. <image src="/static/marketSituation-image/mySeclected.png" mode=""></image>
  19. </view>
  20. <view class="header_icon" @click="history">
  21. <image src="/static/marketSituation-image/history.png" mode=""></image>
  22. </view>
  23. </view>
  24. </view>
  25. <view class="warn">
  26. <image src="/static/marketSituation-image/warn.png" mode="aspectFit"></image>
  27. <view class="warn_text_container">
  28. <text :class="warnTextClass">{{ $t("marketSituation.warn") }}</text>
  29. </view>
  30. </view>
  31. </view>
  32. <!-- 内容区域 -->
  33. <scroll-view class="content" :style="{ top: contentTopPosition + 'px' }" scroll-y="true" v-if="isDataLoaded">
  34. <!-- 亚太-中华 -->
  35. <view class="market-section" v-for="(item, parentIndex) in marketSituationStore.gloablCardData" :key="item">
  36. <view class="market-header">
  37. <text class="market-title">{{ item.ac }}</text>
  38. <view class="market-more" @click="viewMore(item.ac)">
  39. <text class="more-text">查看更多</text>
  40. <text class="more-arrow">></text>
  41. </view>
  42. </view>
  43. <view class="cards-grid-three">
  44. <view v-for="(iitem, index) in item.list" :key="iitem" class="card-item">
  45. <IndexCard
  46. :market="iitem.market"
  47. :stockName="iitem.name"
  48. :currentPrice="iitem.currentPrice"
  49. :changeAmount="iitem.changeAmount"
  50. :changePercent="iitem.changePercent"
  51. :isRising="iitem.isRising"
  52. @click="viewIndexDetail(iitem, parentIndex, index)"
  53. />
  54. </view>
  55. </view>
  56. </view>
  57. <!-- 底部安全区域 -->
  58. <view class="bottom-safe-area"></view>
  59. </scroll-view>
  60. </view>
  61. <!-- 底部导航栏 -->
  62. <footerBar class="static-footer" :type="'marketSituation'"></footerBar>
  63. </template>
  64. <script setup>
  65. import { ref, onMounted, onUnmounted, computed, nextTick, watch } from "vue";
  66. import { onShow, onHide } from "@dcloudio/uni-app";
  67. import footerBar from "../../components/footerBar.vue";
  68. import IndexCard from "../../components/IndexCard.vue";
  69. import hLoading from "@/components/h-loading.vue";
  70. import { getRegionalGroupAPI } from "../../api/marketSituation/marketSituation.js";
  71. import { useMarketSituationStore } from "../../stores/modules/marketSituation.js";
  72. const marketSituationStore = useMarketSituationStore();
  73. // 响应式数据
  74. const iSMT = ref(0); // 状态栏高度
  75. const contentHeight = ref(0);
  76. const headerHeight = ref(0); // 头部高度
  77. const searchValue = ref(""); // 搜索值
  78. const isWarnTextOverflow = ref(false); // warn文字是否溢出
  79. const loading = ref(false);
  80. // 数据状态加载
  81. const isDataLoaded = ref(false);
  82. // warn文字的class计算属性
  83. const warnTextClass = computed(() => {
  84. return isWarnTextOverflow.value ? "warn_text scroll-active" : "warn_text";
  85. });
  86. // 检测warn文字是否溢出
  87. const checkWarnTextOverflow = () => {
  88. nextTick(() => {
  89. setTimeout(() => {
  90. const query = uni.createSelectorQuery();
  91. // 同时查询容器和文字元素
  92. query.select(".warn_text_container").boundingClientRect();
  93. query.select(".warn_text").boundingClientRect();
  94. query.exec((res) => {
  95. const containerRect = res[0];
  96. const textRect = res[1];
  97. if (!containerRect || !textRect) {
  98. return;
  99. }
  100. // 判断文字是否超出容器(留一些余量)
  101. const isOverflow = textRect.width > containerRect.width - 10;
  102. isWarnTextOverflow.value = isOverflow;
  103. });
  104. }, 500);
  105. });
  106. };
  107. const globalIndexArray = ref([]);
  108. // 计算属性:内容区域顶部位置
  109. const contentTopPosition = computed(() => {
  110. const statusBarHeight = iSMT.value || 0;
  111. const currentHeaderHeight = headerHeight.value > 0 ? headerHeight.value : 100;
  112. return statusBarHeight + currentHeaderHeight;
  113. });
  114. // 方法:返回上一页
  115. const goBack = () => {
  116. uni.navigateBack();
  117. };
  118. // 方法:搜索输入
  119. const onSearchInput = (e) => {
  120. searchValue.value = e.detail.value;
  121. };
  122. // 方法:清除搜索
  123. const clearSearch = () => {
  124. searchValue.value = "";
  125. };
  126. // 方法:查看更多
  127. const viewMore = (market) => {
  128. console.log("查看更多:", market);
  129. uni.navigateTo({
  130. url: `/pages/marketSituation/marketDetail?market=${market}`,
  131. });
  132. };
  133. // 方法:查看指数详情
  134. const viewIndexDetail = (item, parentIndex, index) => {
  135. console.log("查看指数详情:", item.stockName);
  136. // uni.showToast({
  137. // title: `查看 ${item.stockName} 详情`,
  138. // icon: 'none',
  139. // duration: 2000
  140. // })
  141. // 这里可以跳转到具体的指数详情页面
  142. uni.navigateTo({
  143. url: `/pages/marketSituation/marketCondition?stockInformation=${encodeURIComponent(JSON.stringify(item))}&parentIndex=${parentIndex}&index=${index}&from=globalIndex`,
  144. });
  145. };
  146. const getRegionalGroup = async () => {
  147. try {
  148. loading.value = true;
  149. const result = await getRegionalGroupAPI();
  150. globalIndexArray.value = result.data;
  151. marketSituationStore.gloablCardData = result.data;
  152. loading.value = false;
  153. } catch (e) {
  154. console.log("获取区域指数失败", e);
  155. loading.value = false;
  156. }
  157. };
  158. // TCP相关响应式变量
  159. import tcpConnection, { TCPConnection, TCP_CONFIG } from "@/api/tcpConnection.js";
  160. const tcpConnected = ref(false);
  161. const connectionListener = ref(null);
  162. const messageListener = ref(null);
  163. // 初始化TCP监听器
  164. const initTcpListeners = () => {
  165. // 创建连接状态监听器并保存引用
  166. connectionListener.value = (status, result) => {
  167. tcpConnected.value = status === "connected";
  168. console.log("TCP连接状态变化:", status, tcpConnected.value);
  169. // 显示连接状态提示
  170. // 如果连接,发送获取批量数据
  171. if (status === "connected") {
  172. sendTcpMessage("batch_real_time");
  173. }
  174. };
  175. // 创建消息监听器并保存引用
  176. messageListener.value = (type, message, parsedArray) => {
  177. const messageObj = {
  178. type: type,
  179. content: message,
  180. parsedArray: parsedArray,
  181. timestamp: new Date().toLocaleTimeString(),
  182. direction: "received",
  183. };
  184. // 解析股票数据
  185. parseStockData(message);
  186. };
  187. // 注册监听器
  188. tcpConnection.onConnectionChange(connectionListener.value);
  189. tcpConnection.onMessage(messageListener.value);
  190. };
  191. // 连接TCP服务器
  192. const connectTcp = () => {
  193. console.log("开始连接TCP服务器...");
  194. tcpConnection.connect();
  195. };
  196. // 断开TCP连接
  197. const disconnectTcp = () => {
  198. console.log("断开TCP连接...");
  199. tcpConnection.disconnect();
  200. tcpConnected.value = false;
  201. };
  202. // 发送TCP消息
  203. const sendTcpMessage = (command) => {
  204. let messageData;
  205. let messageDataArray = [];
  206. if (command == "batch_real_time") {
  207. for (let i = 0; i < globalIndexArray.value.length; ++i) {
  208. for (let j = 0; j < globalIndexArray.value[i].list.length; ++j) {
  209. messageDataArray.push(globalIndexArray.value[i].list[j].code);
  210. }
  211. }
  212. }
  213. console.log(messageDataArray);
  214. switch (command) {
  215. // 实时行情推送
  216. case "real_time":
  217. messageData = {
  218. command: "real_time",
  219. stock_code: "SH.000001",
  220. };
  221. break;
  222. // 初始化获取行情历史数据
  223. case "init_real_time":
  224. messageData = {
  225. command: "init_real_time",
  226. stock_code: "SH.000001",
  227. };
  228. break;
  229. case "stop_real_time":
  230. messageData = {
  231. command: "stop_real_time",
  232. };
  233. break;
  234. // 股票列表
  235. case "stock_list":
  236. messageData = {
  237. command: "stock_list",
  238. };
  239. break;
  240. case "batch_real_time":
  241. messageData = {
  242. command: "batch_real_time",
  243. stock_codes: messageDataArray,
  244. };
  245. break;
  246. case "help":
  247. messageData = {
  248. command: "help",
  249. };
  250. break;
  251. }
  252. if (!messageData) {
  253. return;
  254. } else {
  255. try {
  256. // 发送消息
  257. const success = tcpConnection.send(messageData);
  258. if (success) {
  259. console.log("home发送TCP消息:", messageData);
  260. }
  261. } catch (error) {
  262. console.error("发送TCP消息时出错:", error);
  263. }
  264. }
  265. };
  266. // 获取TCP连接状态
  267. const getTcpStatus = () => {
  268. const status = tcpConnection.getConnectionStatus();
  269. uni.showModal({
  270. title: "TCP连接状态",
  271. content: `当前状态: ${status ? "已连接" : "未连接"}`,
  272. showCancel: false,
  273. });
  274. };
  275. let isMorePacket = {
  276. init_batch_real_time: false,
  277. batch_real_time: false,
  278. };
  279. let receivedMessage;
  280. // 解析TCP股票数据
  281. const parseStockData = (message) => {
  282. try {
  283. console.log("进入parseStockData, message类型:", typeof message);
  284. let parsedMessage;
  285. // 如果isMorePacket是true,说明正在接受分包数据,无条件接收
  286. // 如果message是字符串且以{开头,说明是JSON字符串,需要解析
  287. // 如果不属于以上两种情况,说明是普通字符串,不预解析
  288. if (message.includes("欢迎连接到股票数据服务器")) {
  289. console.log("服务器命令列表,不予处理");
  290. return;
  291. }
  292. if ((typeof message === "string" && message.includes("batch_data_start")) || isMorePacket.init_batch_real_time) {
  293. if (typeof message === "string" && message.includes("batch_data_start")) {
  294. console.log("开始接受分包数据");
  295. receivedMessage = "";
  296. } else {
  297. console.log("接收分包数据过程中");
  298. }
  299. isMorePacket.init_batch_real_time = true;
  300. receivedMessage += message;
  301. // 如果当前消息包含},说明收到JSON字符串结尾,结束接收,开始解析
  302. if (receivedMessage.includes("batch_data_complete")) {
  303. console.log("接受分包数据结束");
  304. isMorePacket.init_batch_real_time = false;
  305. console.log("展示数据", receivedMessage);
  306. let startIndex = 0;
  307. let startCount = 0;
  308. let endIndex = receivedMessage.indexOf("batch_data_complete");
  309. for (let i = 0; i < receivedMessage.length; ++i) {
  310. if (receivedMessage[i] == "{") {
  311. startCount++;
  312. if (startCount == 2) {
  313. startIndex = i;
  314. break;
  315. }
  316. }
  317. }
  318. for (let i = receivedMessage.indexOf("batch_data_complete"); i >= 0; --i) {
  319. if (receivedMessage[i] == "}" || startIndex == endIndex) {
  320. endIndex = i;
  321. break;
  322. }
  323. }
  324. if (startIndex >= endIndex) {
  325. throw new Error("JSON字符串格式错误");
  326. }
  327. console.log("message", startIndex, endIndex, receivedMessage[endIndex], receivedMessage[startIndex]);
  328. parsedMessage = JSON.parse(receivedMessage.substring(startIndex, endIndex + 1));
  329. console.log("JSON解析成功,解析后类型:", typeof parsedMessage, parsedMessage);
  330. const stockDataArray = parsedMessage.data;
  331. for (let i = 0; i < globalIndexArray.value.length; ++i) {
  332. for (let j = 0; j < globalIndexArray.value[i].list.length; ++j) {
  333. const stockCode = globalIndexArray.value[i].list[j].code;
  334. marketSituationStore.gloablCardData[i].list[j].currentPrice = stockDataArray[stockCode][0].current_price.toFixed(2);
  335. marketSituationStore.gloablCardData[i].list[j].changeAmount = (stockDataArray[stockCode][0].current_price - stockDataArray[stockCode][0].pre_close).toFixed(2);
  336. marketSituationStore.gloablCardData[i].list[j].changePercent = ((100 * (stockDataArray[stockCode][0].current_price - stockDataArray[stockCode][0].pre_close)) / stockDataArray[stockCode][0].pre_close).toFixed(2) + "%";
  337. marketSituationStore.gloablCardData[i].list[j].isRising = stockDataArray[stockCode][0].current_price - stockDataArray[stockCode][0].pre_close >= 0;
  338. }
  339. }
  340. }
  341. // 数据状态加载完成
  342. isDataLoaded.value = true;
  343. } else if ((typeof message === "string" && message.includes('{"count')) || isMorePacket.batch_real_time) {
  344. if (typeof message === "string" && message.includes('{"count')) {
  345. console.log("开始接受分包数据");
  346. receivedMessage = "";
  347. } else {
  348. console.log("接收分包数据过程中");
  349. }
  350. isMorePacket.batch_real_time = true;
  351. receivedMessage += message;
  352. // 如果当前消息包含},说明收到JSON字符串结尾,结束接收,开始解析
  353. if (receivedMessage.includes("batch_realtime_data")) {
  354. console.log("接受分包数据结束");
  355. isMorePacket.batch_real_time = false;
  356. console.log("展示数据", receivedMessage);
  357. let startIndex = 0;
  358. let endIndex = receivedMessage.length - 1;
  359. for (let i = 0; i < receivedMessage.length; ++i) {
  360. if (receivedMessage[i] == "{") {
  361. startIndex = i;
  362. break;
  363. }
  364. }
  365. for (let i = receivedMessage.length - 1; i >= 0; --i) {
  366. if (receivedMessage[i] == "}" || startIndex == endIndex) {
  367. endIndex = i;
  368. break;
  369. }
  370. }
  371. if (startIndex >= endIndex) {
  372. throw new Error("JSON字符串格式错误");
  373. }
  374. parsedMessage = JSON.parse(receivedMessage.substring(startIndex, endIndex + 1));
  375. console.log("JSON解析成功,解析后类型:", typeof parsedMessage, parsedMessage);
  376. const stockDataArray = parsedMessage.data;
  377. for (let i = 0; i < globalIndexArray.value.length; ++i) {
  378. for (let j = 0; j < globalIndexArray.value[i].list.length; ++j) {
  379. const stockCode = globalIndexArray.value[i].list[j].code;
  380. marketSituationStore.gloablCardData[i].list[j].currentPrice = stockDataArray[stockCode][0].current_price.toFixed(2);
  381. marketSituationStore.gloablCardData[i].list[j].changeAmount = (stockDataArray[stockCode][0].current_price - stockDataArray[stockCode][0].pre_close).toFixed(2);
  382. marketSituationStore.gloablCardData[i].list[j].changePercent = ((100 * (stockDataArray[stockCode][0].current_price - stockDataArray[stockCode][0].pre_close)) / stockDataArray[stockCode][0].pre_close).toFixed(2) + "%";
  383. marketSituationStore.gloablCardData[i].list[j].isRising = stockDataArray[stockCode][0].current_price - stockDataArray[stockCode][0].pre_close >= 0;
  384. }
  385. }
  386. }
  387. } else {
  388. // 没有通过JSON解析判断,说明不是需要的数据
  389. console.log("不是需要的数据,不做处理");
  390. }
  391. } catch (error) {
  392. console.error("解析TCP股票数据失败:", error.message);
  393. console.error("错误详情:", error);
  394. }
  395. };
  396. // 移除TCP监听器
  397. const removeTcpListeners = () => {
  398. if (connectionListener.value) {
  399. tcpConnection.removeConnectionListener(connectionListener.value);
  400. connectionListener.value = null;
  401. console.log("已移除TCP连接状态监听器");
  402. }
  403. if (messageListener.value) {
  404. tcpConnection.removeMessageListener(messageListener.value);
  405. messageListener.value = null;
  406. console.log("已移除TCP消息监听器");
  407. }
  408. };
  409. const startTcp = () => {
  410. try {
  411. removeTcpListeners();
  412. disconnectTcp();
  413. initTcpListeners();
  414. connectTcp();
  415. } catch (error) {
  416. console.error("建立连接并设置监听出错:", error);
  417. }
  418. };
  419. onShow(async () => {
  420. console.log("显示页面");
  421. await getRegionalGroup();
  422. initTcpListeners();
  423. await nextTick();
  424. // 开始连接
  425. startTcp();
  426. });
  427. onHide(() => {
  428. console.log("隐藏页面");
  429. sendTcpMessage("stop_real_time");
  430. removeTcpListeners();
  431. disconnectTcp();
  432. });
  433. onUnmounted(() => {
  434. sendTcpMessage("stop_real_time");
  435. removeTcpListeners();
  436. disconnectTcp();
  437. });
  438. // 生命周期:页面挂载
  439. onMounted(async () => {
  440. // 获取系统信息
  441. const systemInfo = uni.getSystemInfoSync();
  442. iSMT.value = systemInfo.statusBarHeight || 0;
  443. console.log("全球指数页面加载完成");
  444. // 动态计算header实际高度
  445. uni
  446. .createSelectorQuery()
  447. .select(".header_fixed")
  448. .boundingClientRect((rect) => {
  449. if (rect) {
  450. headerHeight.value = rect.height;
  451. console.log("Header实际高度:", headerHeight.value, "px");
  452. }
  453. })
  454. .exec();
  455. // 检测warn文字是否溢出
  456. checkWarnTextOverflow();
  457. });
  458. // 监听headerHeight变化,重新计算contentHeight
  459. watch(headerHeight, (newHeight) => {
  460. if (newHeight > 0) {
  461. const systemInfo = uni.getSystemInfoSync();
  462. const windowHeight = systemInfo.windowHeight;
  463. const statusBarHeight = systemInfo.statusBarHeight || 0;
  464. const footerHeight = 100;
  465. contentHeight.value = windowHeight - statusBarHeight - newHeight - footerHeight;
  466. console.log("重新计算contentHeight:", contentHeight.value);
  467. }
  468. });
  469. </script>
  470. <style lang="scss" scoped>
  471. .main {
  472. position: relative;
  473. height: 100vh;
  474. overflow: hidden;
  475. background-color: #f5f5f5;
  476. }
  477. /* 状态栏占位 */
  478. .top {
  479. position: fixed;
  480. top: 0;
  481. left: 0;
  482. right: 0;
  483. z-index: 1001;
  484. background-color: #ffffff;
  485. }
  486. /* 固定头部样式 */
  487. .header_fixed {
  488. position: fixed;
  489. left: 0;
  490. right: 0;
  491. z-index: 1000;
  492. background-color: #ffffff;
  493. padding: 20rpx 0 0 0;
  494. box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
  495. }
  496. .header_content {
  497. display: flex;
  498. align-items: center;
  499. justify-content: space-between;
  500. height: 80rpx;
  501. padding: 0 20rpx;
  502. margin-bottom: 10rpx;
  503. }
  504. .header_back {
  505. margin-right: 20rpx;
  506. width: 25rpx;
  507. height: 30rpx;
  508. }
  509. .header_back image {
  510. width: 25rpx;
  511. height: 30rpx;
  512. }
  513. .header_input_wrapper {
  514. display: flex;
  515. align-items: center;
  516. width: 100%;
  517. margin: 0 20rpx 0 0;
  518. height: 70rpx;
  519. border-radius: 35rpx;
  520. background-color: #ffffff;
  521. border: 1rpx solid #e9ecef;
  522. padding: 0 80rpx 0 30rpx;
  523. font-size: 28rpx;
  524. color: #5c5c5c;
  525. box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
  526. }
  527. .search_icon {
  528. width: 40rpx;
  529. height: 40rpx;
  530. opacity: 0.6;
  531. }
  532. .header_input {
  533. margin-left: 10rpx;
  534. }
  535. .header_icons {
  536. display: flex;
  537. align-items: center;
  538. gap: 15rpx;
  539. }
  540. .header_icon {
  541. width: 40rpx;
  542. height: 40rpx;
  543. display: flex;
  544. align-items: center;
  545. justify-content: center;
  546. }
  547. .header_icon image {
  548. width: 40rpx;
  549. height: 40rpx;
  550. }
  551. .warn {
  552. display: flex;
  553. align-items: center;
  554. justify-content: flex-start;
  555. gap: 10rpx;
  556. font-size: 28rpx;
  557. color: #666666;
  558. padding: 20rpx;
  559. max-width: 100%;
  560. overflow: hidden;
  561. position: relative;
  562. }
  563. .warn image {
  564. width: 40rpx;
  565. height: 40rpx;
  566. flex-shrink: 0;
  567. /* 防止图片被压缩 */
  568. position: relative;
  569. z-index: 2;
  570. /* 确保图片在最上层 */
  571. }
  572. .warn_text_container {
  573. flex: 1;
  574. overflow: hidden;
  575. position: relative;
  576. min-width: 0;
  577. /* 允许容器收缩 */
  578. }
  579. .warn_text {
  580. display: block;
  581. white-space: nowrap;
  582. will-change: transform;
  583. /* 优化动画性能 */
  584. }
  585. /* 文字滚动动画 */
  586. @keyframes scrollText {
  587. 0% {
  588. transform: translateX(0);
  589. }
  590. 20% {
  591. transform: translateX(0);
  592. }
  593. 80% {
  594. transform: translateX(-85%);
  595. }
  596. 100% {
  597. transform: translateX(-85%);
  598. }
  599. }
  600. /* 当文字超长时启用滚动动画 */
  601. .warn_text.scroll-active {
  602. animation: scrollText 12s linear infinite;
  603. animation-delay: 2s;
  604. /* 延迟2秒开始滚动,让用户先看到开头 */
  605. }
  606. /* 内容区域 */
  607. .content {
  608. position: fixed;
  609. left: 0;
  610. right: 0;
  611. bottom: 120rpx;
  612. background-color: #f5f5f5;
  613. padding: 0;
  614. }
  615. /* 市场分组 */
  616. .market-section {
  617. background-color: white;
  618. border-radius: 20rpx;
  619. }
  620. .market-header {
  621. margin: 20rpx 20rpx 0 20rpx;
  622. display: flex;
  623. align-items: center;
  624. justify-content: space-between;
  625. margin-bottom: 10rpx;
  626. padding-bottom: 10rpx;
  627. border-bottom: 2rpx solid #f0f0f0;
  628. }
  629. .market-title {
  630. font-size: 32rpx;
  631. font-weight: 600;
  632. color: #333;
  633. }
  634. .market-more {
  635. display: flex;
  636. align-items: center;
  637. gap: 8rpx;
  638. }
  639. .more-text {
  640. font-size: 24rpx;
  641. color: #666;
  642. }
  643. .more-arrow {
  644. font-size: 20rpx;
  645. color: #666;
  646. font-weight: bold;
  647. }
  648. /* 三列卡片网格 */
  649. .cards-grid-three {
  650. display: grid;
  651. grid-template-columns: repeat(3, 1fr);
  652. }
  653. .card-item {
  654. background-color: white;
  655. border-radius: 16rpx;
  656. overflow: hidden;
  657. transition: transform 0.2s ease, box-shadow 0.2s ease;
  658. }
  659. .card-item:active {
  660. transform: scale(0.98);
  661. box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.12);
  662. }
  663. /* 底部安全区域 */
  664. .bottom-safe-area {
  665. height: 40rpx;
  666. background-color: transparent;
  667. }
  668. /* 底部导航栏 */
  669. .static-footer {
  670. position: fixed;
  671. bottom: 0;
  672. left: 0;
  673. right: 0;
  674. z-index: 1000;
  675. }
  676. /* 响应式设计 */
  677. @media (max-width: 400rpx) {
  678. .cards-grid-three {
  679. grid-template-columns: repeat(2, 1fr);
  680. }
  681. }
  682. </style>