index.vue 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600
  1. <template>
  2. <view class="container">
  3. <!-- 顶部品牌区 -->
  4. <view class="brand-section">
  5. <text class="brand-title">AI零售柜</text>
  6. <text class="brand-slogan">智能视觉 · 即拿即走</text>
  7. </view>
  8. <!-- 核心操作区 -->
  9. <view class="action-section">
  10. <!-- 状态1:扫码开门 -->
  11. <button v-if="!authReady" class="scan-button" @click="scanCode">
  12. <view class="scan-button-inner">
  13. <image class="scan-icon" src="/static/icons/scan.svg" mode="aspectFill"></image>
  14. <text class="scan-text">扫码开门</text>
  15. </view>
  16. <view class="scan-button-ripple"></view>
  17. </button>
  18. <!-- 状态2:API 返回后,展示授权按钮 -->
  19. <button v-else class="auth-button" @click="invokePayScore" :disabled="authBusy">
  20. <view class="auth-button-inner">
  21. <image class="auth-btn-icon" src="/static/icons/pay-socre-logo.png" mode="aspectFit"></image>
  22. <text class="auth-btn-text">{{ authBusy ? '跳转中...' : '确认支付分授权' }}</text>
  23. <text class="auth-btn-sub">点击后调起微信支付分</text>
  24. </view>
  25. </button>
  26. </view>
  27. <!-- 快捷功能区 -->
  28. <view class="quick-actions">
  29. <view class="quick-action-item" @click="goToMy">
  30. <view class="action-icon">
  31. <image src="/static/icons/my.svg" mode="aspectFit"></image>
  32. </view>
  33. <text class="action-label">我的</text>
  34. </view>
  35. <view class="quick-action-item" @click="goToOrders">
  36. <view class="action-icon">
  37. <image src="/static/icons/orders.svg" mode="aspectFit"></image>
  38. </view>
  39. <text class="action-label">订单</text>
  40. </view>
  41. <view class="quick-action-item" @click="goToCouponCenter">
  42. <view class="action-icon">
  43. <image src="/static/icons/coupon.svg" mode="aspectFit"></image>
  44. </view>
  45. <text class="action-label">优惠券</text>
  46. </view>
  47. </view>
  48. <!-- 授权就绪时,取消按钮 -->
  49. <view v-if="authReady" class="cancel-auth-wrap">
  50. <text class="cancel-auth-link" @click="cancelAuth">取消,返回扫码</text>
  51. </view>
  52. <!-- 底部信息卡片 -->
  53. <view class="info-card">
  54. <view class="info-item">
  55. <image class="info-icon payscore-icon" src="/static/icons/pay-socre-logo.png" mode="aspectFit"></image>
  56. <text class="info-text">微信支付分 | 550分及以上优享</text>
  57. </view>
  58. <view class="info-item">
  59. <CustomerServiceButton
  60. mode="inline"
  61. title="首页-联系客服"
  62. :path="'/pages/index/index'"
  63. @contact="onContactSuccess"
  64. >
  65. <text class="info-icon">💬</text>
  66. <text class="info-text">在线客服</text>
  67. </CustomerServiceButton>
  68. </view>
  69. </view>
  70. </view>
  71. </template>
  72. <script setup lang="ts">
  73. import { ref } from 'vue'
  74. import { onShow } from '@dcloudio/uni-app'
  75. import { scanDoor } from '../../api/device'
  76. import { preCreatePayscoreOrder, queryPayscoreByOutNo } from '../../api/payscore'
  77. import { isLoggedIn, getToken } from '../../utils/auth'
  78. import { logger } from '../../utils/logger'
  79. const authReady = ref(false)
  80. const authBusy = ref(false)
  81. const authDeviceId = ref('')
  82. const authPackage = ref('')
  83. const authOutOrderNo = ref('')
  84. let pollTimer: ReturnType<typeof setInterval> | null = null
  85. onShow(() => {
  86. const token = getToken()
  87. logger.log('[首页 onShow] token:', token ? '存在' : '不存在')
  88. })
  89. const scanCode = async () => {
  90. if (!isLoggedIn()) {
  91. uni.showModal({
  92. title: '提示',
  93. content: '请先登录后再扫码开门',
  94. showCancel: false,
  95. success: () => {
  96. uni.reLaunch({ url: '/pages/login/login?redirect=/pages/index/index' });
  97. }
  98. });
  99. return;
  100. }
  101. uni.scanCode({
  102. success: async function (res) {
  103. logger.log('扫码结果:', res.result);
  104. let deviceId = '';
  105. try {
  106. const urlPattern = /\/([A-Z0-9]+)(\?|$)/;
  107. const match = res.result.match(urlPattern);
  108. if (match && match[1]) {
  109. deviceId = match[1];
  110. } else {
  111. try {
  112. const qrData = JSON.parse(res.result);
  113. if (qrData.deviceId) deviceId = qrData.deviceId;
  114. } catch (e) {
  115. deviceId = res.result;
  116. }
  117. }
  118. if (!deviceId) throw new Error('无法解析设备ID');
  119. logger.log('提取到设备ID:', deviceId);
  120. } catch (error) {
  121. logger.error('解析设备ID失败:', error);
  122. uni.showToast({ title: '二维码格式错误', icon: 'none' });
  123. return;
  124. }
  125. await prepareAuth(deviceId);
  126. },
  127. fail: function (err) {
  128. logger.log('扫码取消:', err);
  129. }
  130. });
  131. };
  132. const prepareAuth = async (deviceId: string) => {
  133. uni.showLoading({ title: '准备中...', mask: true });
  134. try {
  135. const result = await preCreatePayscoreOrder({ deviceId });
  136. uni.hideLoading();
  137. if (!result.success || !result.package) {
  138. uni.showToast({
  139. title: result.errorMsg || '创建支付分订单失败',
  140. icon: 'none',
  141. });
  142. return;
  143. }
  144. logger.log('[支付分] 预创建成功, package 已就绪');
  145. authDeviceId.value = deviceId;
  146. authPackage.value = result.package;
  147. authOutOrderNo.value = result.outOrderNo;
  148. authReady.value = true;
  149. } catch (error: any) {
  150. uni.hideLoading();
  151. logger.error('[支付分] 预创建失败', error);
  152. uni.showToast({
  153. title: error?.message || '创建支付分订单失败',
  154. icon: 'none',
  155. });
  156. }
  157. };
  158. /**
  159. * 用户点击按钮 → 直接触发 wx.openBusinessView
  160. * 此函数由 button @click 同步调用,满足微信的用户手势要求
  161. */
  162. const invokePayScore = () => {
  163. if (authBusy.value) return;
  164. authBusy.value = true;
  165. const pkg = authPackage.value;
  166. logger.log('[支付分] invokePayScore, package 长度:', pkg?.length);
  167. if (!pkg) {
  168. authBusy.value = false;
  169. uni.showModal({
  170. title: '参数异常',
  171. content: 'package 为空,请重新扫码',
  172. showCancel: false,
  173. });
  174. return;
  175. }
  176. wx.openBusinessView({
  177. businessType: 'wxpayScoreUse',
  178. extraData: {
  179. package: pkg,
  180. },
  181. success: () => {
  182. // 用户从支付分页面返回,但不确定是确认还是取消
  183. // 轮询后端查支付分订单状态来判断
  184. logger.log('[支付分] 从支付分页面返回,开始轮询确认状态');
  185. pollPayScoreStatus();
  186. },
  187. fail: (err: any) => {
  188. authBusy.value = false;
  189. const errMsg = err?.errMsg || JSON.stringify(err);
  190. logger.error('[支付分] openBusinessView fail:', errMsg);
  191. if (errMsg.includes('cancel')) {
  192. return;
  193. }
  194. uni.showModal({
  195. title: '调起支付分失败',
  196. content: errMsg,
  197. showCancel: false,
  198. });
  199. },
  200. });
  201. };
  202. /**
  203. * 轮询支付分订单状态,判断用户确认还是取消
  204. * DOING = 用户已确认,CREATED = 用户取消/未操作
  205. */
  206. const pollPayScoreStatus = () => {
  207. uni.showLoading({ title: '确认结果...', mask: true });
  208. const outOrderNo = authOutOrderNo.value;
  209. let attempts = 0;
  210. const maxAttempts = 10;
  211. const interval = 1000; // 1s
  212. if (pollTimer) clearInterval(pollTimer);
  213. pollTimer = setInterval(async () => {
  214. attempts++;
  215. try {
  216. const res = await queryPayscoreByOutNo(outOrderNo);
  217. const state = res?.state;
  218. logger.log(`[支付分] 轮询第 ${attempts} 次, state:`, state);
  219. if (state === 'DOING') {
  220. // 用户已确认
  221. clearPollTimer();
  222. uni.hideLoading();
  223. authReady.value = false;
  224. authBusy.value = false;
  225. logger.log('[支付分] 用户已确认,开始开门');
  226. doOpenDoor(authDeviceId.value);
  227. return;
  228. }
  229. if (attempts >= maxAttempts) {
  230. // 超时未确认,视为取消
  231. clearPollTimer();
  232. uni.hideLoading();
  233. authBusy.value = false;
  234. logger.log('[支付分] 轮询超时,用户未确认');
  235. }
  236. } catch (e) {
  237. logger.error('[支付分] 轮询异常', e);
  238. if (attempts >= maxAttempts) {
  239. clearPollTimer();
  240. uni.hideLoading();
  241. authBusy.value = false;
  242. }
  243. }
  244. }, interval);
  245. };
  246. const clearPollTimer = () => {
  247. if (pollTimer) {
  248. clearInterval(pollTimer);
  249. pollTimer = null;
  250. }
  251. };
  252. const cancelAuth = () => {
  253. clearPollTimer();
  254. authReady.value = false;
  255. authBusy.value = false;
  256. logger.log('[支付分] 用户取消,返回扫码');
  257. };
  258. const doOpenDoor = async (deviceId: string) => {
  259. uni.showLoading({ title: '正在开门...', mask: true });
  260. try {
  261. const response = await scanDoor(deviceId);
  262. uni.hideLoading();
  263. uni.showToast({ title: '开门成功', icon: 'success' });
  264. uni.setStorageSync('currentDeviceId', response.deviceId);
  265. uni.setStorageSync('currentOutTradeNo', response.outTradeNo);
  266. uni.setStorageSync('currentOrderNo', response.orderNo);
  267. uni.removeStorageSync('shoppingPollingActive');
  268. setTimeout(() => {
  269. uni.navigateTo({ url: '/pages/shopping/shopping' });
  270. }, 1000);
  271. } catch (error: any) {
  272. uni.hideLoading();
  273. logger.error('开门失败:', error);
  274. }
  275. };
  276. const goToMy = () => uni.navigateTo({ url: '/pages/my/my' })
  277. const goToOrders = () => uni.navigateTo({ url: '/pages/orders/orders' })
  278. const goToCouponCenter = () => uni.navigateTo({ url: '/pages/couponCenter/couponCenter' })
  279. const onContactSuccess = () => logger.log('[首页] 客服会话已打开')
  280. </script>
  281. <style lang="scss">
  282. .container {
  283. min-height: 100vh;
  284. background: $color-bg-secondary;
  285. display: flex;
  286. flex-direction: column;
  287. padding: $spacing-xxl $spacing-lg;
  288. padding-bottom: calc(100rpx + constant(safe-area-inset-bottom));
  289. padding-bottom: calc(100rpx + env(safe-area-inset-bottom));
  290. box-sizing: border-box;
  291. }
  292. .brand-section {
  293. text-align: center;
  294. padding: $spacing-xxl 0 $spacing-xl;
  295. .brand-title {
  296. font-size: 56rpx;
  297. font-weight: 300;
  298. color: $color-text-primary;
  299. letter-spacing: 8rpx;
  300. display: block;
  301. margin-bottom: $spacing-sm;
  302. }
  303. .brand-slogan {
  304. font-size: 24rpx;
  305. color: $color-text-secondary;
  306. letter-spacing: 4rpx;
  307. }
  308. }
  309. /* ========== 操作区 ========== */
  310. .action-section {
  311. display: flex;
  312. justify-content: center;
  313. align-items: center;
  314. padding: $spacing-xl 0;
  315. }
  316. /* 扫码按钮 */
  317. .scan-button {
  318. position: relative;
  319. width: 400rpx;
  320. height: 400rpx;
  321. min-width: 400rpx;
  322. max-width: 400rpx;
  323. min-height: 400rpx;
  324. max-height: 400rpx;
  325. border-radius: 50%;
  326. background: transparent;
  327. border: none;
  328. padding: 0;
  329. margin: 0;
  330. overflow: hidden;
  331. box-sizing: border-box;
  332. flex-shrink: 0;
  333. line-height: 1;
  334. &::after { border: none; }
  335. &-inner {
  336. width: 100%;
  337. height: 100%;
  338. border-radius: 50%;
  339. background: linear-gradient(135deg, $color-primary-light 0%, $color-primary 100%);
  340. box-shadow: $shadow-primary;
  341. display: flex;
  342. flex-direction: column;
  343. align-items: center;
  344. justify-content: center;
  345. transition: all $duration-normal $ease-out;
  346. &:active {
  347. transform: scale(0.95);
  348. box-shadow: 0 4rpx 16rpx rgba(255, 193, 7, 0.3);
  349. }
  350. }
  351. &-ripple {
  352. position: absolute;
  353. top: 50%;
  354. left: 50%;
  355. width: 100%;
  356. height: 100%;
  357. border-radius: 50%;
  358. background: $color-primary;
  359. transform: translate(-50%, -50%);
  360. animation: pulse 3s cubic-bezier(0.42, 0, 0.58, 1) infinite;
  361. opacity: 0.2;
  362. z-index: -1;
  363. }
  364. }
  365. .scan-icon {
  366. width: 160rpx;
  367. height: 160rpx;
  368. margin-bottom: $spacing-md;
  369. flex-shrink: 0;
  370. display: block;
  371. }
  372. .scan-text {
  373. font-size: 40rpx;
  374. font-weight: 600;
  375. color: $color-text-primary;
  376. letter-spacing: 4rpx;
  377. }
  378. /* 授权按钮 */
  379. .auth-button {
  380. width: 400rpx;
  381. height: 400rpx;
  382. min-width: 400rpx;
  383. max-width: 400rpx;
  384. min-height: 400rpx;
  385. max-height: 400rpx;
  386. border-radius: 50%;
  387. background: transparent;
  388. border: none;
  389. padding: 0;
  390. margin: 0;
  391. overflow: hidden;
  392. box-sizing: border-box;
  393. flex-shrink: 0;
  394. line-height: 1;
  395. &::after { border: none; }
  396. &-inner {
  397. width: 100%;
  398. height: 100%;
  399. border-radius: 50%;
  400. background: linear-gradient(135deg, #07C160 0%, #06AD56 100%);
  401. box-shadow: 0 8rpx 32rpx rgba(7, 193, 96, 0.35);
  402. display: flex;
  403. flex-direction: column;
  404. align-items: center;
  405. justify-content: center;
  406. transition: all 0.3s ease;
  407. &:active {
  408. transform: scale(0.95);
  409. }
  410. }
  411. &[disabled] .auth-button-inner {
  412. opacity: 0.7;
  413. }
  414. }
  415. .auth-btn-icon {
  416. width: 100rpx;
  417. height: 100rpx;
  418. margin-bottom: $spacing-sm;
  419. }
  420. .auth-btn-text {
  421. font-size: 36rpx;
  422. font-weight: 600;
  423. color: #fff;
  424. }
  425. .auth-btn-sub {
  426. font-size: 22rpx;
  427. color: rgba(255, 255, 255, 0.8);
  428. margin-top: 8rpx;
  429. }
  430. /* 取消授权 */
  431. .cancel-auth-wrap {
  432. text-align: center;
  433. margin-top: -$spacing-lg;
  434. }
  435. .cancel-auth-link {
  436. font-size: 26rpx;
  437. color: $color-text-secondary;
  438. padding: $spacing-sm $spacing-lg;
  439. }
  440. /* ========== 快捷功能区 ========== */
  441. .quick-actions {
  442. display: flex;
  443. justify-content: center;
  444. align-items: center;
  445. gap: $spacing-xl;
  446. margin: $spacing-xxl 0;
  447. &-item {
  448. display: flex;
  449. flex-direction: column;
  450. align-items: center;
  451. justify-content: center;
  452. padding: $spacing-md;
  453. position: relative;
  454. flex: 0 0 auto;
  455. &:active { opacity: 0.8; }
  456. }
  457. .action-icon {
  458. width: 120rpx;
  459. height: 120rpx;
  460. background: $color-bg-primary;
  461. border-radius: $radius-lg;
  462. display: flex;
  463. align-items: center;
  464. justify-content: center;
  465. box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
  466. margin-bottom: $spacing-sm;
  467. border: 2rpx solid $color-border;
  468. flex-shrink: 0;
  469. image {
  470. width: 64rpx;
  471. height: 64rpx;
  472. display: block;
  473. }
  474. &:active {
  475. box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.1);
  476. border-color: $color-primary-light;
  477. }
  478. }
  479. .action-label {
  480. font-size: 28rpx;
  481. color: $color-text-primary;
  482. font-weight: 500;
  483. text-align: center;
  484. display: block;
  485. width: auto;
  486. }
  487. }
  488. /* ========== 底部信息卡片 ========== */
  489. .info-card {
  490. background: $color-bg-primary;
  491. border-radius: $radius-lg;
  492. padding: $spacing-lg;
  493. box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
  494. margin-top: auto;
  495. width: 100%;
  496. box-sizing: border-box;
  497. .info-item {
  498. display: flex;
  499. align-items: center;
  500. justify-content: center;
  501. padding: $spacing-sm 0;
  502. min-height: 60rpx;
  503. &:not(:last-child) {
  504. border-bottom: 1rpx solid $color-border;
  505. padding-bottom: $spacing-md;
  506. margin-bottom: $spacing-md;
  507. }
  508. }
  509. .info-icon {
  510. font-size: 28rpx;
  511. margin-right: $spacing-sm;
  512. flex-shrink: 0;
  513. line-height: 1;
  514. &.payscore-icon {
  515. width: 36rpx;
  516. height: 36rpx;
  517. }
  518. }
  519. .info-text {
  520. font-size: 24rpx;
  521. color: $color-text-secondary;
  522. line-height: 1.5;
  523. text-align: center;
  524. }
  525. }
  526. </style>