index.vue 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530
  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 } 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. onShow(() => {
  84. const token = getToken()
  85. logger.log('[首页 onShow] token:', token ? '存在' : '不存在')
  86. })
  87. const scanCode = async () => {
  88. if (!isLoggedIn()) {
  89. uni.showModal({
  90. title: '提示',
  91. content: '请先登录后再扫码开门',
  92. showCancel: false,
  93. success: () => {
  94. uni.reLaunch({ url: '/pages/login/login?redirect=/pages/index/index' });
  95. }
  96. });
  97. return;
  98. }
  99. uni.scanCode({
  100. success: async function (res) {
  101. logger.log('扫码结果:', res.result);
  102. let deviceId = '';
  103. try {
  104. const urlPattern = /\/([A-Z0-9]+)(\?|$)/;
  105. const match = res.result.match(urlPattern);
  106. if (match && match[1]) {
  107. deviceId = match[1];
  108. } else {
  109. try {
  110. const qrData = JSON.parse(res.result);
  111. if (qrData.deviceId) deviceId = qrData.deviceId;
  112. } catch (e) {
  113. deviceId = res.result;
  114. }
  115. }
  116. if (!deviceId) throw new Error('无法解析设备ID');
  117. logger.log('提取到设备ID:', deviceId);
  118. } catch (error) {
  119. logger.error('解析设备ID失败:', error);
  120. uni.showToast({ title: '二维码格式错误', icon: 'none' });
  121. return;
  122. }
  123. await prepareAuth(deviceId);
  124. },
  125. fail: function (err) {
  126. logger.log('扫码取消:', err);
  127. }
  128. });
  129. };
  130. const prepareAuth = async (deviceId: string) => {
  131. uni.showLoading({ title: '准备中...', mask: true });
  132. try {
  133. const result = await preCreatePayscoreOrder({ deviceId });
  134. uni.hideLoading();
  135. if (!result.success || !result.package) {
  136. uni.showToast({
  137. title: result.errorMsg || '创建支付分订单失败',
  138. icon: 'none',
  139. });
  140. return;
  141. }
  142. logger.log('[支付分] 预创建成功, package 已就绪');
  143. authDeviceId.value = deviceId;
  144. authPackage.value = result.package;
  145. authReady.value = true;
  146. } catch (error: any) {
  147. uni.hideLoading();
  148. logger.error('[支付分] 预创建失败', error);
  149. uni.showToast({
  150. title: error?.message || '创建支付分订单失败',
  151. icon: 'none',
  152. });
  153. }
  154. };
  155. /**
  156. * 用户点击按钮 → 直接触发 wx.openBusinessView
  157. * 此函数由 button @click 同步调用,满足微信的用户手势要求
  158. */
  159. const invokePayScore = () => {
  160. if (authBusy.value) return;
  161. authBusy.value = true;
  162. const pkg = authPackage.value;
  163. logger.log('[支付分] invokePayScore, package 长度:', pkg?.length);
  164. if (!pkg) {
  165. authBusy.value = false;
  166. uni.showModal({
  167. title: '参数异常',
  168. content: 'package 为空,请重新扫码',
  169. showCancel: false,
  170. });
  171. return;
  172. }
  173. wx.openBusinessView({
  174. businessType: 'wxpayScoreUse',
  175. extraData: {
  176. package: pkg,
  177. },
  178. success: () => {
  179. authBusy.value = false;
  180. authReady.value = false;
  181. logger.log('[支付分] 确认成功,开门');
  182. doOpenDoor(authDeviceId.value);
  183. },
  184. fail: (err: any) => {
  185. authBusy.value = false;
  186. const errMsg = err?.errMsg || JSON.stringify(err);
  187. logger.error('[支付分] openBusinessView fail:', errMsg);
  188. // 用户取消不弹窗
  189. if (errMsg.includes('cancel')) {
  190. return;
  191. }
  192. uni.showModal({
  193. title: '调起支付分失败',
  194. content: errMsg,
  195. showCancel: false,
  196. });
  197. },
  198. complete: () => {
  199. logger.log('[支付分] openBusinessView complete');
  200. },
  201. });
  202. };
  203. const cancelAuth = () => {
  204. authReady.value = false;
  205. authBusy.value = false;
  206. logger.log('[支付分] 用户取消,返回扫码');
  207. };
  208. const doOpenDoor = async (deviceId: string) => {
  209. uni.showLoading({ title: '正在开门...', mask: true });
  210. try {
  211. const response = await scanDoor(deviceId);
  212. uni.hideLoading();
  213. uni.showToast({ title: '开门成功', icon: 'success' });
  214. uni.setStorageSync('currentDeviceId', response.deviceId);
  215. uni.setStorageSync('currentOutTradeNo', response.outTradeNo);
  216. uni.setStorageSync('currentOrderNo', response.orderNo);
  217. uni.removeStorageSync('shoppingPollingActive');
  218. setTimeout(() => {
  219. uni.navigateTo({ url: '/pages/shopping/shopping' });
  220. }, 1000);
  221. } catch (error: any) {
  222. uni.hideLoading();
  223. logger.error('开门失败:', error);
  224. }
  225. };
  226. const goToMy = () => uni.navigateTo({ url: '/pages/my/my' })
  227. const goToOrders = () => uni.navigateTo({ url: '/pages/orders/orders' })
  228. const goToCouponCenter = () => uni.navigateTo({ url: '/pages/couponCenter/couponCenter' })
  229. const onContactSuccess = () => logger.log('[首页] 客服会话已打开')
  230. </script>
  231. <style lang="scss">
  232. .container {
  233. min-height: 100vh;
  234. background: $color-bg-secondary;
  235. display: flex;
  236. flex-direction: column;
  237. padding: $spacing-xxl $spacing-lg;
  238. padding-bottom: calc(100rpx + constant(safe-area-inset-bottom));
  239. padding-bottom: calc(100rpx + env(safe-area-inset-bottom));
  240. box-sizing: border-box;
  241. }
  242. .brand-section {
  243. text-align: center;
  244. padding: $spacing-xxl 0 $spacing-xl;
  245. .brand-title {
  246. font-size: 56rpx;
  247. font-weight: 300;
  248. color: $color-text-primary;
  249. letter-spacing: 8rpx;
  250. display: block;
  251. margin-bottom: $spacing-sm;
  252. }
  253. .brand-slogan {
  254. font-size: 24rpx;
  255. color: $color-text-secondary;
  256. letter-spacing: 4rpx;
  257. }
  258. }
  259. /* ========== 操作区 ========== */
  260. .action-section {
  261. display: flex;
  262. justify-content: center;
  263. align-items: center;
  264. padding: $spacing-xl 0;
  265. }
  266. /* 扫码按钮 */
  267. .scan-button {
  268. position: relative;
  269. width: 400rpx;
  270. height: 400rpx;
  271. border-radius: 50%;
  272. background: transparent;
  273. border: none;
  274. padding: 0;
  275. margin: 0;
  276. overflow: hidden;
  277. &::after { border: none; }
  278. &-inner {
  279. width: 100%;
  280. height: 100%;
  281. border-radius: 50%;
  282. background: linear-gradient(135deg, $color-primary-light 0%, $color-primary 100%);
  283. box-shadow: $shadow-primary;
  284. display: flex;
  285. flex-direction: column;
  286. align-items: center;
  287. justify-content: center;
  288. transition: all $duration-normal $ease-out;
  289. &:active {
  290. transform: scale(0.95);
  291. box-shadow: 0 4rpx 16rpx rgba(255, 193, 7, 0.3);
  292. }
  293. }
  294. &-ripple {
  295. position: absolute;
  296. top: 50%;
  297. left: 50%;
  298. width: 100%;
  299. height: 100%;
  300. border-radius: 50%;
  301. background: $color-primary;
  302. transform: translate(-50%, -50%);
  303. animation: pulse 3s cubic-bezier(0.42, 0, 0.58, 1) infinite;
  304. opacity: 0.2;
  305. z-index: -1;
  306. }
  307. }
  308. .scan-icon {
  309. width: 160rpx;
  310. height: 160rpx;
  311. margin-bottom: $spacing-md;
  312. flex-shrink: 0;
  313. display: block;
  314. }
  315. .scan-text {
  316. font-size: 40rpx;
  317. font-weight: 600;
  318. color: $color-text-primary;
  319. letter-spacing: 4rpx;
  320. }
  321. /* 授权按钮 */
  322. .auth-button {
  323. width: 400rpx;
  324. height: 400rpx;
  325. min-width: 400rpx;
  326. min-height: 400rpx;
  327. border-radius: 50%;
  328. background: transparent;
  329. border: none;
  330. padding: 0;
  331. margin: 0;
  332. overflow: hidden;
  333. &::after { border: none; }
  334. &-inner {
  335. width: 100%;
  336. height: 100%;
  337. border-radius: 50%;
  338. background: linear-gradient(135deg, #07C160 0%, #06AD56 100%);
  339. box-shadow: 0 8rpx 32rpx rgba(7, 193, 96, 0.35);
  340. display: flex;
  341. flex-direction: column;
  342. align-items: center;
  343. justify-content: center;
  344. transition: all 0.3s ease;
  345. &:active {
  346. transform: scale(0.95);
  347. }
  348. }
  349. &[disabled] .auth-button-inner {
  350. opacity: 0.7;
  351. }
  352. }
  353. .auth-btn-icon {
  354. width: 100rpx;
  355. height: 100rpx;
  356. margin-bottom: $spacing-sm;
  357. }
  358. .auth-btn-text {
  359. font-size: 36rpx;
  360. font-weight: 600;
  361. color: #fff;
  362. }
  363. .auth-btn-sub {
  364. font-size: 22rpx;
  365. color: rgba(255, 255, 255, 0.8);
  366. margin-top: 8rpx;
  367. }
  368. /* 取消授权 */
  369. .cancel-auth-wrap {
  370. text-align: center;
  371. margin-top: -$spacing-lg;
  372. }
  373. .cancel-auth-link {
  374. font-size: 26rpx;
  375. color: $color-text-secondary;
  376. padding: $spacing-sm $spacing-lg;
  377. }
  378. /* ========== 快捷功能区 ========== */
  379. .quick-actions {
  380. display: flex;
  381. justify-content: center;
  382. align-items: center;
  383. gap: $spacing-xl;
  384. margin: $spacing-xxl 0;
  385. &-item {
  386. display: flex;
  387. flex-direction: column;
  388. align-items: center;
  389. justify-content: center;
  390. padding: $spacing-md;
  391. position: relative;
  392. flex: 0 0 auto;
  393. &:active { opacity: 0.8; }
  394. }
  395. .action-icon {
  396. width: 120rpx;
  397. height: 120rpx;
  398. background: $color-bg-primary;
  399. border-radius: $radius-lg;
  400. display: flex;
  401. align-items: center;
  402. justify-content: center;
  403. box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
  404. margin-bottom: $spacing-sm;
  405. border: 2rpx solid $color-border;
  406. flex-shrink: 0;
  407. image {
  408. width: 64rpx;
  409. height: 64rpx;
  410. display: block;
  411. }
  412. &:active {
  413. box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.1);
  414. border-color: $color-primary-light;
  415. }
  416. }
  417. .action-label {
  418. font-size: 28rpx;
  419. color: $color-text-primary;
  420. font-weight: 500;
  421. text-align: center;
  422. display: block;
  423. width: auto;
  424. }
  425. }
  426. /* ========== 底部信息卡片 ========== */
  427. .info-card {
  428. background: $color-bg-primary;
  429. border-radius: $radius-lg;
  430. padding: $spacing-lg;
  431. box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
  432. margin-top: auto;
  433. width: 100%;
  434. box-sizing: border-box;
  435. .info-item {
  436. display: flex;
  437. align-items: center;
  438. justify-content: center;
  439. padding: $spacing-sm 0;
  440. min-height: 60rpx;
  441. &:not(:last-child) {
  442. border-bottom: 1rpx solid $color-border;
  443. padding-bottom: $spacing-md;
  444. margin-bottom: $spacing-md;
  445. }
  446. }
  447. .info-icon {
  448. font-size: 28rpx;
  449. margin-right: $spacing-sm;
  450. flex-shrink: 0;
  451. line-height: 1;
  452. &.payscore-icon {
  453. width: 36rpx;
  454. height: 36rpx;
  455. }
  456. }
  457. .info-text {
  458. font-size: 24rpx;
  459. color: $color-text-secondary;
  460. line-height: 1.5;
  461. text-align: center;
  462. }
  463. }
  464. </style>