shopping.vue 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595
  1. <template>
  2. <view class="container">
  3. <!-- 阶段一:选购中 (门已开) -->
  4. <view v-if="doorStatus === 'opened'" class="status-section animate-fade-in">
  5. <view class="status-icon-wrapper">
  6. <view class="status-icon door-open">
  7. <svg viewBox="0 0 120 120" xmlns="http://www.w3.org/2000/svg">
  8. <rect x="20" y="10" width="80" height="100" rx="8" fill="#FFF8E1" stroke="#FFC107" stroke-width="3"/>
  9. <path d="M 35 10 L 35 110" stroke="#FFC107" stroke-width="2" stroke-dasharray="4,4"/>
  10. <circle cx="60" cy="60" r="15" fill="#FFC107" opacity="0.3"/>
  11. <path d="M 55 60 L 65 60 M 60 55 L 60 65" stroke="#FFC107" stroke-width="3" stroke-linecap="round"/>
  12. </svg>
  13. </view>
  14. <view class="status-icon-pulse"></view>
  15. </view>
  16. <view class="status-title">门已开,请选购商品</view>
  17. <view class="status-tip">请在60秒内完成选购并关门</view>
  18. <view class="countdown-wrapper">
  19. <view class="countdown" v-if="countdown > 0">
  20. <text class="countdown-number">{{ countdown }}</text>
  21. <text class="countdown-label">秒</text>
  22. </view>
  23. <view class="countdown warning" v-else>
  24. <text class="countdown-number">请尽快关门</text>
  25. </view>
  26. </view>
  27. <CustomerServiceButton
  28. mode="link"
  29. title="购物-门已开"
  30. :path="'/pages/shopping/shopping'"
  31. >
  32. 遇到问题?联系在线客服
  33. </CustomerServiceButton>
  34. </view>
  35. <!-- 阶段二:购物完成 (门已关) -->
  36. <view v-else-if="doorStatus === 'closing'" class="status-section animate-scale-in">
  37. <view class="status-icon-wrapper">
  38. <view class="status-icon success">
  39. <svg viewBox="0 0 120 120" xmlns="http://www.w3.org/2000/svg">
  40. <circle cx="60" cy="60" r="50" fill="#E8F5E9" stroke="#4CAF50" stroke-width="3"/>
  41. <path d="M 35 60 L 50 75 L 85 40" stroke="#4CAF50" stroke-width="5" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
  42. </svg>
  43. </view>
  44. </view>
  45. <view class="status-title">购物已完成</view>
  46. <view class="status-tip">
  47. 订单稍后自动扣款,
  48. <CustomerServiceButton
  49. mode="link"
  50. title="购物-扣款疑问"
  51. :path="'/pages/shopping/shopping'"
  52. >
  53. 如有疑问请联系客服
  54. </CustomerServiceButton>
  55. </view>
  56. <view class="countdown-wrapper">
  57. <view class="countdown-return" v-if="returnCountdown > 0">
  58. <text class="countdown-return-text">{{ returnCountdown }}秒后返回首页</text>
  59. </view>
  60. </view>
  61. <button class="action-button" @click="goHome">
  62. <text class="button-text">回到首页</text>
  63. </button>
  64. </view>
  65. <!-- 阶段三:结算完成 -->
  66. <view v-else-if="doorStatus === 'closed'" class="status-section animate-slide-up">
  67. <view class="status-icon-wrapper">
  68. <view class="status-icon success">
  69. <svg viewBox="0 0 120 120" xmlns="http://www.w3.org/2000/svg">
  70. <circle cx="60" cy="60" r="50" fill="#E8F5E9" stroke="#4CAF50" stroke-width="3"/>
  71. <path d="M 35 60 L 50 75 L 85 40" stroke="#4CAF50" stroke-width="5" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
  72. </svg>
  73. </view>
  74. </view>
  75. <view class="status-title">结算完成</view>
  76. <view class="status-tip">您已成功购买以下商品</view>
  77. <view class="purchase-info">
  78. <view class="product-item" v-for="product in purchasedProducts" :key="product.id">
  79. <view class="product-name">{{ product.name }}</view>
  80. <view class="product-quantity">×{{ product.quantity }}</view>
  81. <view class="product-price">¥{{ product.price }}</view>
  82. </view>
  83. <view class="total-info">
  84. <view class="total-label">总计</view>
  85. <view class="total-price">¥{{ totalPrice }}</view>
  86. </view>
  87. </view>
  88. <button class="action-button primary" @click="goToOrderDetail">
  89. <text class="button-text">查看订单详情</text>
  90. </button>
  91. </view>
  92. <!-- 错误状态 -->
  93. <view v-else-if="doorStatus === 'error'" class="status-section animate-shake">
  94. <view class="status-icon-wrapper">
  95. <view class="status-icon error">
  96. <svg viewBox="0 0 120 120" xmlns="http://www.w3.org/2000/svg">
  97. <circle cx="60" cy="60" r="50" fill="#FFEBEE" stroke="#F44336" stroke-width="3"/>
  98. <path d="M 40 40 L 80 80 M 80 40 L 40 80" stroke="#F44336" stroke-width="5" fill="none" stroke-linecap="round"/>
  99. </svg>
  100. </view>
  101. </view>
  102. <view class="status-title">出错了</view>
  103. <view class="status-tip">{{ errorMessage }}</view>
  104. <CustomerServiceButton
  105. mode="link"
  106. title="购物-错误求助"
  107. :path="'/pages/shopping/shopping'"
  108. >
  109. 联系客服解决
  110. </CustomerServiceButton>
  111. <button class="action-button" @click="goHome">
  112. <text class="button-text">返回首页</text>
  113. </button>
  114. </view>
  115. </view>
  116. </template>
  117. <script setup lang="ts">
  118. import { ref, onMounted, onUnmounted, onActivated, onDeactivated } from 'vue';
  119. import { pollDeviceStatus, pollRecognizeResult, pollOrderInfo } from '../../api/status';
  120. import type { RecognizeResultResponse } from '../../api/status';
  121. import { logger } from '../../utils/logger';
  122. import type { OrderProduct } from '../../api/order';
  123. import CustomerServiceButton from '../../components/CustomerServiceButton.vue';
  124. const doorStatus = ref<'opened' | 'closing' | 'closed' | 'error'>('opened');
  125. const countdown = ref(60);
  126. const returnCountdown = ref(5); // 返回首页倒计时
  127. const purchasedProducts = ref<OrderProduct[]>([]);
  128. const totalPrice = ref(0);
  129. const errorMessage = ref('');
  130. const currentDeviceId = ref('');
  131. const currentActivityId = ref('');
  132. const currentOrderNo = ref('');
  133. let countdownTimer: number | null = null;
  134. let returnCountdownTimer: number | null = null; // 返回首页倒计时定时器
  135. let statusCheckTimer: number | null = null;
  136. let isComponentActive = true; // 组件活跃状态标志
  137. // 统一的定时器清理函数
  138. const cleanupTimers = () => {
  139. if (countdownTimer) {
  140. clearInterval(countdownTimer);
  141. countdownTimer = null;
  142. }
  143. if (returnCountdownTimer) {
  144. clearInterval(returnCountdownTimer);
  145. returnCountdownTimer = null;
  146. }
  147. if (statusCheckTimer) {
  148. clearTimeout(statusCheckTimer);
  149. statusCheckTimer = null;
  150. }
  151. };
  152. onMounted(() => {
  153. // 标记当前页面轮询为活跃状态
  154. uni.setStorageSync('shoppingPollingActive', 'true');
  155. isComponentActive = true;
  156. // 获取存储的设备信息
  157. currentDeviceId.value = uni.getStorageSync('currentDeviceId') || '';
  158. currentOrderNo.value = uni.getStorageSync('currentOrderNo') || '';
  159. if (!currentDeviceId.value) {
  160. doorStatus.value = 'error';
  161. errorMessage.value = '未找到设备信息';
  162. return;
  163. }
  164. // 开始倒计时
  165. startCountdown();
  166. // 开始轮询设备状态
  167. startStatusPolling();
  168. });
  169. onUnmounted(() => {
  170. // 设置组件为非活跃状态,停止所有轮询
  171. isComponentActive = false;
  172. // 清理所有定时器
  173. cleanupTimers();
  174. });
  175. // 页面获得焦点时重新激活轮询
  176. onActivated(() => {
  177. if (doorStatus.value === 'opened' || doorStatus.value === 'closing') {
  178. isComponentActive = true;
  179. // 重新开始轮询
  180. startStatusPolling();
  181. }
  182. });
  183. // 页面失去焦点时暂停轮询
  184. onDeactivated(() => {
  185. isComponentActive = false;
  186. // 清理定时器
  187. cleanupTimers();
  188. });
  189. const startCountdown = () => {
  190. countdownTimer = setInterval(() => {
  191. if (countdown.value > 0) {
  192. countdown.value--;
  193. }
  194. }, 1000);
  195. };
  196. // 开始返回首页倒计时
  197. const startReturnCountdown = () => {
  198. returnCountdown.value = 5; // 重置为5秒
  199. returnCountdownTimer = setInterval(() => {
  200. if (returnCountdown.value > 0) {
  201. returnCountdown.value--;
  202. if (returnCountdown.value === 0) {
  203. // 倒计时结束,自动返回首页
  204. goHome();
  205. }
  206. }
  207. }, 1000);
  208. };
  209. const startStatusPolling = async () => {
  210. // 检查组件是否仍然活跃
  211. if (!isComponentActive) {
  212. return;
  213. }
  214. try {
  215. // 轮询设备状态,等待关门
  216. const status = await pollDeviceStatus(currentDeviceId.value, 120000, 2000);
  217. // 再次检查组件是否仍然活跃
  218. if (!isComponentActive) {
  219. return;
  220. }
  221. logger.log('设备状态更新:', status);
  222. // 检查门是否关闭
  223. if (status.doorStatus === 'close') {
  224. // 门已关,显示购物完成
  225. doorStatus.value = 'closing';
  226. currentActivityId.value = status.activityId;
  227. logger.log('门已关,购物完成,活动ID:', status.activityId);
  228. // 停止选购倒计时
  229. if (countdownTimer) {
  230. clearInterval(countdownTimer);
  231. countdownTimer = null;
  232. }
  233. // 开始返回首页倒计时
  234. startReturnCountdown();
  235. } else {
  236. // 继续等待关门,设置延迟后重新轮询
  237. if (isComponentActive && doorStatus.value === 'opened') {
  238. statusCheckTimer = setTimeout(() => {
  239. if (isComponentActive) {
  240. startStatusPolling();
  241. }
  242. }, 3000);
  243. }
  244. }
  245. } catch (error: any) {
  246. console.error('状态轮询失败:', error);
  247. // 再次检查组件是否仍然活跃
  248. if (!isComponentActive) {
  249. return;
  250. }
  251. // 如果还在开门状态,继续轮询
  252. if (doorStatus.value === 'opened' || doorStatus.value === 'closing') {
  253. statusCheckTimer = setTimeout(() => {
  254. if (isComponentActive) {
  255. startStatusPolling();
  256. }
  257. }, 3000);
  258. } else {
  259. doorStatus.value = 'error';
  260. errorMessage.value = error.message || '获取状态失败';
  261. }
  262. }
  263. };
  264. const showProblem = () => {
  265. uni.vibrateShort({ type: 'light' });
  266. uni.showActionSheet({
  267. itemList: ['辅助远程开门', '报修'],
  268. success: function (res) {
  269. if (res.tapIndex === 0) {
  270. uni.showToast({
  271. title: '正在远程开门...',
  272. icon: 'loading'
  273. });
  274. } else if (res.tapIndex === 1) {
  275. uni.vibrateShort({ type: 'medium' });
  276. uni.showToast({
  277. title: '已提交报修申请',
  278. icon: 'success'
  279. });
  280. }
  281. }
  282. });
  283. };
  284. const goToOrderDetail = () => {
  285. uni.vibrateShort({ type: 'light' });
  286. // 跳转到订单详情页
  287. uni.navigateTo({
  288. url: `/pages/orderDetail/orderDetail?orderNo=${currentOrderNo.value}`
  289. });
  290. };
  291. const goHome = () => {
  292. uni.vibrateShort({ type: 'light' });
  293. uni.reLaunch({
  294. url: '/pages/index/index'
  295. });
  296. };
  297. </script>
  298. <style lang="scss">
  299. .container {
  300. min-height: 100vh;
  301. background: linear-gradient(180deg, $color-bg-secondary 0%, $color-bg-primary 100%);
  302. display: flex;
  303. flex-direction: column;
  304. align-items: center;
  305. justify-content: flex-start;
  306. padding: $spacing-xl;
  307. padding-top: 2vh;
  308. box-sizing: border-box;
  309. }
  310. .status-section {
  311. display: flex;
  312. flex-direction: column;
  313. align-items: center;
  314. justify-content: flex-start;
  315. text-align: center;
  316. width: 100%;
  317. max-width: 600rpx;
  318. }
  319. /* ========== 状态图标 ========== */
  320. .status-icon-wrapper {
  321. position: relative;
  322. width: 200rpx;
  323. height: 200rpx;
  324. margin-bottom: $spacing-xl;
  325. animation: scaleIn 0.6s cubic-bezier(0.68, -0.55, 0.265, 1.55);
  326. }
  327. .status-icon {
  328. width: 100%;
  329. height: 100%;
  330. svg {
  331. width: 100%;
  332. height: 100%;
  333. }
  334. &.door-open {
  335. animation: doorOpen 1s $ease-out;
  336. }
  337. &.success {
  338. animation: successPop 0.6s $bounce;
  339. }
  340. &.error {
  341. animation: shake 0.5s $ease-in-out;
  342. }
  343. }
  344. .status-icon-pulse {
  345. position: absolute;
  346. top: 50%;
  347. left: 50%;
  348. width: 100%;
  349. height: 100%;
  350. border-radius: $radius-circle;
  351. background: $color-primary;
  352. transform: translate(-50%, -50%);
  353. animation: pulse 2s cubic-bezier(0.42, 0, 0.58, 1) infinite;
  354. opacity: 0.2;
  355. z-index: -1;
  356. }
  357. @keyframes doorOpen {
  358. 0% {
  359. transform: scale(0) rotate(-180deg);
  360. opacity: 0;
  361. }
  362. 100% {
  363. transform: scale(1) rotate(0);
  364. opacity: 1;
  365. }
  366. }
  367. @keyframes successPop {
  368. 0% {
  369. transform: scale(0);
  370. opacity: 0;
  371. }
  372. 50% {
  373. transform: scale(1.1);
  374. }
  375. 100% {
  376. transform: scale(1);
  377. opacity: 1;
  378. }
  379. }
  380. /* ========== 状态文本 ========== */
  381. .status-title {
  382. font-size: 48rpx;
  383. font-weight: 600;
  384. margin-bottom: $spacing-sm;
  385. color: $color-text-primary;
  386. animation: slideUp 0.8s cubic-bezier(0.25, 0.1, 0.25, 1);
  387. }
  388. .status-tip {
  389. font-size: 30rpx;
  390. color: $color-text-secondary;
  391. margin-bottom: $spacing-xl;
  392. animation: slideUp 0.9s cubic-bezier(0.25, 0.1, 0.25, 1);
  393. }
  394. /* ========== 倒计时 ========== */
  395. .countdown-wrapper {
  396. margin-bottom: $spacing-xl;
  397. animation: scaleIn 1s cubic-bezier(0.68, -0.55, 0.265, 1.55);
  398. }
  399. .countdown {
  400. display: flex;
  401. align-items: baseline;
  402. gap: 8rpx;
  403. &-number {
  404. font-size: 88rpx;
  405. font-weight: 700;
  406. color: $color-text-primary;
  407. font-variant-numeric: tabular-nums;
  408. }
  409. &-label {
  410. font-size: 32rpx;
  411. color: $color-text-secondary;
  412. }
  413. &.warning {
  414. .countdown-number {
  415. color: $color-error;
  416. animation: blink 1s infinite;
  417. }
  418. }
  419. }
  420. @keyframes blink {
  421. 0%, 100% { opacity: 1; }
  422. 50% { opacity: 0.5; }
  423. }
  424. .countdown-return {
  425. padding: $spacing-md $spacing-lg;
  426. background: $color-bg-tertiary;
  427. border-radius: $radius-lg;
  428. &-text {
  429. font-size: 26rpx;
  430. color: $color-text-secondary;
  431. }
  432. }
  433. /* ========== 问题链接 ========== */
  434. .problem-link {
  435. font-size: 28rpx;
  436. color: $color-primary-dark;
  437. text-decoration: underline;
  438. margin-top: $spacing-lg;
  439. animation: fadeIn 1.2s cubic-bezier(0.25, 0.1, 0.25, 1);
  440. }
  441. /* ========== 商品列表 ========== */
  442. .purchase-info {
  443. width: 100%;
  444. background: $color-bg-primary;
  445. border-radius: $radius-lg;
  446. padding: $spacing-lg;
  447. margin: $spacing-xl 0;
  448. box-shadow: $shadow-sm;
  449. animation: slideUp 1s cubic-bezier(0.25, 0.1, 0.25, 1);
  450. }
  451. .product-item {
  452. display: flex;
  453. justify-content: space-between;
  454. align-items: center;
  455. padding: $spacing-sm 0;
  456. border-bottom: 1rpx solid $color-border;
  457. &:last-child {
  458. border-bottom: none;
  459. }
  460. }
  461. .product-name {
  462. font-size: 28rpx;
  463. color: $color-text-primary;
  464. flex: 1;
  465. text-align: left;
  466. }
  467. .product-quantity {
  468. font-size: 26rpx;
  469. color: $color-text-secondary;
  470. margin: 0 $spacing-md;
  471. }
  472. .product-price {
  473. font-size: 28rpx;
  474. color: $color-text-primary;
  475. font-weight: 500;
  476. }
  477. .total-info {
  478. display: flex;
  479. justify-content: space-between;
  480. align-items: center;
  481. padding-top: $spacing-md;
  482. margin-top: $spacing-md;
  483. border-top: 2rpx solid $color-border;
  484. }
  485. .total-label {
  486. font-size: 32rpx;
  487. font-weight: 500;
  488. color: $color-text-primary;
  489. }
  490. .total-price {
  491. font-size: 36rpx;
  492. font-weight: 600;
  493. color: $color-error;
  494. }
  495. /* ========== 操作按钮 ========== */
  496. .action-button {
  497. background: $color-bg-primary;
  498. color: $color-text-primary;
  499. border: 2rpx solid $color-border;
  500. padding: $spacing-md $spacing-xl;
  501. border-radius: $radius-xl;
  502. font-size: 28rpx;
  503. font-weight: 500;
  504. margin-top: $spacing-lg;
  505. transition: all $duration-fast $ease-out;
  506. &::after {
  507. border: none;
  508. }
  509. &:active {
  510. transform: scale(0.98);
  511. background: $color-bg-secondary;
  512. }
  513. &.primary {
  514. background: linear-gradient(135deg, $color-primary-light 0%, $color-primary 100%);
  515. border: none;
  516. box-shadow: $shadow-primary;
  517. color: $color-text-primary;
  518. &:active {
  519. box-shadow: 0 4rpx 16rpx rgba(255, 193, 7, 0.3);
  520. }
  521. }
  522. .button-text {
  523. font-size: 28rpx;
  524. font-weight: 500;
  525. }
  526. }
  527. </style>