checkout.vue 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727
  1. <template>
  2. <view class="container">
  3. <view v-if="loading" class="loading-wrap">
  4. <text class="loading-text">加载中...</text>
  5. </view>
  6. <scroll-view v-else scroll-y class="content">
  7. <!-- Address Section -->
  8. <view class="section address-section" @click="goSelectAddress">
  9. <view v-if="selectedAddress" class="address-content">
  10. <view class="address-top">
  11. <text class="receiver-name">{{ selectedAddress.receiverName }}</text>
  12. <text class="receiver-phone">{{ formatPhone(selectedAddress.phone) }}</text>
  13. <text v-if="selectedAddress.isDefault === 1" class="default-badge">默认</text>
  14. </view>
  15. <text class="address-detail">
  16. {{ selectedAddress.province }}{{ selectedAddress.city }}{{ selectedAddress.district }}{{ selectedAddress.street }}
  17. </text>
  18. <text class="address-arrow">›</text>
  19. </view>
  20. <view v-else class="address-empty" @click.stop="goAddAddress">
  21. <text class="address-empty-icon">📍</text>
  22. <text class="address-empty-text">请添加收货地址</text>
  23. <text class="address-arrow">›</text>
  24. </view>
  25. </view>
  26. <!-- Order Items Section -->
  27. <view class="section">
  28. <text class="section-title">商品信息</text>
  29. <view
  30. v-for="item in items"
  31. :key="item.productId"
  32. class="order-item"
  33. >
  34. <image
  35. class="item-image"
  36. :src="item.coverImage || '/static/default-product.png'"
  37. mode="aspectFill"
  38. />
  39. <view class="item-info">
  40. <text class="item-name">{{ item.productName }}</text>
  41. <text class="item-qty">x{{ item.quantity }}</text>
  42. </view>
  43. <text class="item-price">{{ formatPriceWithSymbol(item.unitPrice) }}</text>
  44. </view>
  45. </view>
  46. <!-- Freight Section -->
  47. <view class="section">
  48. <view class="info-row">
  49. <text class="info-label">运费</text>
  50. <text class="info-value">{{ formatPriceWithSymbol(freight) }}</text>
  51. </view>
  52. </view>
  53. <!-- Coupon Section -->
  54. <view class="section coupon-section" @click="openCouponPicker">
  55. <view class="info-row">
  56. <text class="info-label">优惠券</text>
  57. <view class="coupon-selected" v-if="selectedCoupon">
  58. <text class="coupon-discount">-{{ formatPriceWithSymbol(selectedCoupon.value) }}</text>
  59. <text class="coupon-name-tag">{{ selectedCoupon.name }}</text>
  60. </view>
  61. <view class="coupon-placeholder" v-else>
  62. <text class="placeholder-text">选择优惠券</text>
  63. </view>
  64. <text class="address-arrow">›</text>
  65. </view>
  66. </view>
  67. <!-- Remark Section -->
  68. <view class="section">
  69. <view class="info-row">
  70. <text class="info-label">备注</text>
  71. <input
  72. class="remark-input"
  73. v-model="remark"
  74. placeholder="选填,给商家留言"
  75. placeholder-style="color: #ccc; font-size: 26rpx;"
  76. />
  77. </view>
  78. </view>
  79. <view class="bottom-spacer"></view>
  80. <!-- Coupon Picker Bottom Sheet -->
  81. <view class="modal-mask" v-if="showCouponPicker" @click="closeCouponPicker">
  82. <view class="coupon-picker" @click.stop>
  83. <view class="picker-header">
  84. <text class="picker-title">选择优惠券</text>
  85. <text class="picker-close" @click="closeCouponPicker">关闭</text>
  86. </view>
  87. <scroll-view scroll-y class="picker-list">
  88. <view
  89. v-for="coupon in availableCoupons"
  90. :key="coupon.id"
  91. class="picker-item"
  92. :class="selectedCoupon && selectedCoupon.id === coupon.id ? 'picker-item-active' : ''"
  93. @click="selectCoupon(coupon)"
  94. >
  95. <view class="picker-item-left">
  96. <text class="picker-item-value">{{ formatPriceWithSymbol(coupon.value) }}</text>
  97. </view>
  98. <view class="picker-item-right">
  99. <text class="picker-item-name">{{ coupon.name }}</text>
  100. <text class="picker-item-condition">{{ getConditionText(coupon.minSpend) }}</text>
  101. </view>
  102. <view class="picker-item-check" v-if="selectedCoupon && selectedCoupon.id === coupon.id">
  103. <text class="check-icon">✓</text>
  104. </view>
  105. </view>
  106. <view class="picker-empty" v-if="availableCoupons.length === 0">
  107. <text>暂无可用优惠券</text>
  108. </view>
  109. </scroll-view>
  110. <view class="picker-footer" v-if="selectedCoupon">
  111. <button class="picker-btn-remove" @click="removeCoupon">不使用优惠券</button>
  112. </view>
  113. </view>
  114. </view>
  115. </scroll-view>
  116. <view class="bottom-bar">
  117. <view class="bottom-left">
  118. <text class="total-label">合计: </text>
  119. <text class="total-amount">{{ formatPriceWithSymbol(finalAmount) }}</text>
  120. <text v-if="selectedCoupon" class="total-original">{{ formatPriceWithSymbol(actualAmount + freight) }}</text>
  121. </view>
  122. <button
  123. :class="['submit-btn', submitting ? 'disabled' : '']"
  124. :disabled="submitting"
  125. @click="onSubmit"
  126. >提交订单</button>
  127. </view>
  128. </view>
  129. </template>
  130. <script>
  131. import config from '@/config.js'
  132. import { getCouponList } from '../../../utils/api.js'
  133. export default {
  134. data() {
  135. return {
  136. items: [],
  137. selectedAddress: null,
  138. freight: 0,
  139. remark: '',
  140. loading: false,
  141. submitting: false,
  142. actualAmount: 0,
  143. coupons: [],
  144. selectedCoupon: null,
  145. showCouponPicker: false
  146. }
  147. },
  148. computed: {
  149. finalAmount() {
  150. var total = (this.actualAmount || 0) + (this.freight || 0)
  151. if (this.selectedCoupon) {
  152. total = total - this.selectedCoupon.value
  153. if (total < 0) total = 0
  154. }
  155. return total
  156. },
  157. availableCoupons() {
  158. var self = this
  159. return this.coupons.filter(function(c) {
  160. if (c.status && c.status !== 'AVAILABLE') return false
  161. if (c.applicableTo && c.applicableTo !== 'ALL') return false
  162. if (c.minSpend && c.minSpend > self.actualAmount) return false
  163. return true
  164. })
  165. }
  166. },
  167. onLoad() {
  168. var stored = uni.getStorageSync('checkoutItems')
  169. if (stored) {
  170. this.items = stored
  171. this.calcAmount()
  172. }
  173. this.loadDefaultAddress()
  174. this.loadCoupons()
  175. },
  176. methods: {
  177. calcAmount() {
  178. var total = 0
  179. for (var i = 0; i < this.items.length; i++) {
  180. total = total + (this.items[i].unitPrice * this.items[i].quantity)
  181. }
  182. this.actualAmount = total
  183. },
  184. async loadCoupons() {
  185. try {
  186. var res = await getCouponList()
  187. this.coupons = res.data || []
  188. } catch (e) {
  189. this.coupons = []
  190. }
  191. },
  192. openCouponPicker() {
  193. if (this.availableCoupons.length === 0 && !this.selectedCoupon) {
  194. uni.showToast({ title: '暂无可用优惠券', icon: 'none' })
  195. return
  196. }
  197. this.showCouponPicker = true
  198. },
  199. closeCouponPicker() {
  200. this.showCouponPicker = false
  201. },
  202. selectCoupon(coupon) {
  203. this.selectedCoupon = coupon
  204. this.showCouponPicker = false
  205. },
  206. removeCoupon() {
  207. this.selectedCoupon = null
  208. this.showCouponPicker = false
  209. },
  210. getConditionText(minSpend) {
  211. if (!minSpend || minSpend <= 0) return '无门槛'
  212. return '满' + (minSpend / 100).toFixed(2) + '元可用'
  213. },
  214. loadDefaultAddress() {
  215. this.loading = true
  216. var that = this
  217. uni.request({
  218. url: config.api('/api/user/address/list'),
  219. method: 'POST',
  220. data: {},
  221. header: {
  222. 'Content-Type': 'application/json',
  223. 'Authorization': 'Bearer ' + uni.getStorageSync('token')
  224. },
  225. success: function(res) {
  226. that.loading = false
  227. if (res.data && res.data.code === 200) {
  228. var list = res.data.data || []
  229. for (var i = 0; i < list.length; i++) {
  230. if (list[i].isDefault === 1) {
  231. that.selectedAddress = list[i]
  232. break
  233. }
  234. }
  235. if (!that.selectedAddress && list.length > 0) {
  236. that.selectedAddress = list[0]
  237. }
  238. }
  239. },
  240. fail: function() {
  241. that.loading = false
  242. }
  243. })
  244. },
  245. goSelectAddress() {
  246. var self = this
  247. uni.navigateTo({
  248. url: '/pages/shop/address/address?selectMode=1',
  249. events: {
  250. onAddressSelect: function(data) {
  251. self.selectedAddress = data
  252. }
  253. },
  254. success: function(res) {
  255. res.eventChannel.on('onAddressSelect', function(data) {
  256. self.selectedAddress = data
  257. })
  258. }
  259. })
  260. },
  261. goAddAddress() {
  262. uni.navigateTo({ url: '/pages/shop/address-edit/address-edit' })
  263. },
  264. onSubmit() {
  265. if (!this.selectedAddress) {
  266. uni.showToast({ title: '请选择收货地址', icon: 'none' })
  267. return
  268. }
  269. if (this.items.length === 0) {
  270. uni.showToast({ title: '请选择商品', icon: 'none' })
  271. return
  272. }
  273. this.submitting = true
  274. var orderItems = []
  275. for (var i = 0; i < this.items.length; i++) {
  276. orderItems.push({
  277. productId: this.items[i].productId,
  278. quantity: this.items[i].quantity
  279. })
  280. }
  281. var addressSnapshot = JSON.stringify({
  282. receiverName: this.selectedAddress.receiverName,
  283. phone: this.selectedAddress.phone,
  284. province: this.selectedAddress.province,
  285. city: this.selectedAddress.city,
  286. district: this.selectedAddress.district,
  287. street: this.selectedAddress.street
  288. })
  289. var that = this
  290. uni.request({
  291. url: config.api('/api/product/order/create'),
  292. method: 'POST',
  293. data: {
  294. items: orderItems,
  295. remark: this.remark,
  296. addressSnapshot: addressSnapshot
  297. },
  298. header: {
  299. 'Content-Type': 'application/json',
  300. 'Authorization': 'Bearer ' + uni.getStorageSync('token')
  301. },
  302. success: function(res) {
  303. that.submitting = false
  304. if (res.data && res.data.code === 200) {
  305. var order = res.data.data
  306. uni.showToast({ title: '下单成功', icon: 'success' })
  307. uni.removeStorageSync('checkoutItems')
  308. setTimeout(function() {
  309. uni.navigateTo({
  310. url: '/pages/shop/payment/payment?orderNo=' + order.orderNo + '&amount=' + order.totalAmount
  311. })
  312. }, 1000)
  313. } else {
  314. uni.showToast({ title: res.data.message || '下单失败', icon: 'none' })
  315. }
  316. },
  317. fail: function() {
  318. that.submitting = false
  319. uni.showToast({ title: '网络请求失败', icon: 'none' })
  320. }
  321. })
  322. },
  323. formatPrice(price) {
  324. if (price === null || price === undefined) return '0.00'
  325. return (Number(price) / 100).toFixed(2)
  326. },
  327. formatPhone(phone) {
  328. if (!phone) return ''
  329. if (phone.length === 11) {
  330. return phone.substr(0, 3) + '****' + phone.substr(7)
  331. }
  332. return phone
  333. }
  334. }
  335. }
  336. </script>
  337. <style scoped>
  338. .container {
  339. min-height: 100vh;
  340. background: #f5f5f5;
  341. }
  342. .loading-wrap {
  343. display: flex;
  344. justify-content: center;
  345. padding-top: 200rpx;
  346. }
  347. .loading-text {
  348. font-size: 28rpx;
  349. color: #999;
  350. }
  351. .content {
  352. height: calc(100vh - 120rpx);
  353. }
  354. .section {
  355. background: #fff;
  356. margin: 20rpx;
  357. border-radius: 16rpx;
  358. padding: 24rpx;
  359. }
  360. .section-title {
  361. font-size: 28rpx;
  362. font-weight: bold;
  363. color: #333;
  364. margin-bottom: 20rpx;
  365. display: block;
  366. }
  367. .address-section {
  368. position: relative;
  369. }
  370. .address-content {
  371. position: relative;
  372. }
  373. .address-top {
  374. display: flex;
  375. align-items: center;
  376. margin-bottom: 10rpx;
  377. }
  378. .receiver-name {
  379. font-size: 30rpx;
  380. font-weight: bold;
  381. color: #333;
  382. margin-right: 20rpx;
  383. }
  384. .receiver-phone {
  385. font-size: 26rpx;
  386. color: #666;
  387. }
  388. .default-badge {
  389. font-size: 20rpx;
  390. color: #F97316;
  391. background: #fff7e6;
  392. padding: 2rpx 12rpx;
  393. border-radius: 20rpx;
  394. margin-left: 12rpx;
  395. }
  396. .address-detail {
  397. font-size: 26rpx;
  398. color: #999;
  399. line-height: 1.5;
  400. }
  401. .address-arrow {
  402. position: absolute;
  403. right: 0;
  404. top: 50%;
  405. font-size: 40rpx;
  406. color: #ccc;
  407. transform: translateY(-50%);
  408. }
  409. .address-empty {
  410. display: flex;
  411. align-items: center;
  412. justify-content: center;
  413. padding: 30rpx 0;
  414. position: relative;
  415. }
  416. .address-empty-icon {
  417. font-size: 40rpx;
  418. margin-right: 12rpx;
  419. }
  420. .address-empty-text {
  421. font-size: 28rpx;
  422. color: #999;
  423. }
  424. .order-item {
  425. display: flex;
  426. align-items: center;
  427. padding: 16rpx 0;
  428. border-bottom: 1rpx solid #f5f5f5;
  429. }
  430. .order-item:last-child {
  431. border-bottom: none;
  432. }
  433. .item-image {
  434. width: 80rpx;
  435. height: 80rpx;
  436. border-radius: 8rpx;
  437. background: #f0f0f0;
  438. margin-right: 16rpx;
  439. }
  440. .item-info {
  441. flex: 1;
  442. display: flex;
  443. flex-direction: column;
  444. min-width: 0;
  445. }
  446. .item-name {
  447. font-size: 26rpx;
  448. color: #333;
  449. overflow: hidden;
  450. text-overflow: ellipsis;
  451. white-space: nowrap;
  452. margin-bottom: 6rpx;
  453. }
  454. .item-qty {
  455. font-size: 22rpx;
  456. color: #999;
  457. }
  458. .item-price {
  459. font-size: 26rpx;
  460. color: #F97316;
  461. font-weight: bold;
  462. margin-left: 16rpx;
  463. }
  464. .info-row {
  465. display: flex;
  466. justify-content: space-between;
  467. align-items: center;
  468. padding: 8rpx 0;
  469. }
  470. .info-label {
  471. font-size: 26rpx;
  472. color: #999;
  473. }
  474. .info-value {
  475. font-size: 26rpx;
  476. color: #333;
  477. }
  478. .remark-input {
  479. flex: 1;
  480. text-align: right;
  481. font-size: 26rpx;
  482. color: #333;
  483. }
  484. /* ===== Coupon Section ===== */
  485. .coupon-section {
  486. position: relative;
  487. }
  488. .coupon-section:active {
  489. background: #fafafa;
  490. }
  491. .coupon-selected {
  492. display: flex;
  493. align-items: center;
  494. flex: 1;
  495. justify-content: flex-end;
  496. margin-right: 30rpx;
  497. }
  498. .coupon-discount {
  499. font-size: 26rpx;
  500. color: #F97316;
  501. font-weight: 600;
  502. margin-right: 12rpx;
  503. }
  504. .coupon-name-tag {
  505. font-size: 22rpx;
  506. color: #fff;
  507. background: #F97316;
  508. padding: 2rpx 12rpx;
  509. border-radius: 6rpx;
  510. max-width: 200rpx;
  511. overflow: hidden;
  512. text-overflow: ellipsis;
  513. white-space: nowrap;
  514. }
  515. .coupon-placeholder {
  516. flex: 1;
  517. text-align: right;
  518. margin-right: 30rpx;
  519. }
  520. .placeholder-text {
  521. font-size: 26rpx;
  522. color: #ccc;
  523. }
  524. .total-original {
  525. font-size: 22rpx;
  526. color: #bbb;
  527. text-decoration: line-through;
  528. margin-left: 8rpx;
  529. }
  530. /* ===== Coupon Picker Modal ===== */
  531. .modal-mask {
  532. position: fixed;
  533. top: 0;
  534. left: 0;
  535. right: 0;
  536. bottom: 0;
  537. background: rgba(0,0,0,0.5);
  538. z-index: 999;
  539. display: flex;
  540. align-items: flex-end;
  541. }
  542. .coupon-picker {
  543. background: #fff;
  544. border-radius: 24rpx 24rpx 0 0;
  545. width: 100%;
  546. max-height: 70vh;
  547. display: flex;
  548. flex-direction: column;
  549. }
  550. .picker-header {
  551. display: flex;
  552. justify-content: space-between;
  553. align-items: center;
  554. padding: 30rpx;
  555. border-bottom: 1rpx solid #eee;
  556. }
  557. .picker-title {
  558. font-size: 30rpx;
  559. font-weight: 600;
  560. color: #333;
  561. }
  562. .picker-close {
  563. font-size: 26rpx;
  564. color: #999;
  565. }
  566. .picker-list {
  567. max-height: 50vh;
  568. padding: 0 20rpx;
  569. }
  570. .picker-item {
  571. display: flex;
  572. align-items: center;
  573. padding: 24rpx 16rpx;
  574. border-bottom: 1rpx solid #f5f5f5;
  575. }
  576. .picker-item:active {
  577. background: #fafafa;
  578. }
  579. .picker-item-active {
  580. background: #FFF7ED;
  581. }
  582. .picker-item-left {
  583. width: 120rpx;
  584. text-align: center;
  585. flex-shrink: 0;
  586. }
  587. .picker-item-value {
  588. font-size: 32rpx;
  589. font-weight: bold;
  590. color: #F97316;
  591. }
  592. .picker-item-right {
  593. flex: 1;
  594. margin: 0 16rpx;
  595. }
  596. .picker-item-name {
  597. font-size: 26rpx;
  598. color: #333;
  599. display: block;
  600. margin-bottom: 4rpx;
  601. }
  602. .picker-item-condition {
  603. font-size: 22rpx;
  604. color: #999;
  605. }
  606. .picker-item-check {
  607. width: 40rpx;
  608. height: 40rpx;
  609. background: #F97316;
  610. border-radius: 50%;
  611. display: flex;
  612. align-items: center;
  613. justify-content: center;
  614. flex-shrink: 0;
  615. }
  616. .check-icon {
  617. color: #fff;
  618. font-size: 24rpx;
  619. font-weight: bold;
  620. }
  621. .picker-empty {
  622. text-align: center;
  623. padding: 60rpx 0;
  624. font-size: 26rpx;
  625. color: #999;
  626. }
  627. .picker-footer {
  628. padding: 20rpx;
  629. border-top: 1rpx solid #eee;
  630. }
  631. .picker-btn-remove {
  632. background: #f5f5f5;
  633. color: #666;
  634. font-size: 26rpx;
  635. border-radius: 12rpx;
  636. border: none;
  637. }
  638. .picker-btn-remove::after {
  639. border: none;
  640. }
  641. .bottom-spacer {
  642. height: 140rpx;
  643. }
  644. .bottom-bar {
  645. position: fixed;
  646. bottom: 0;
  647. left: 0;
  648. right: 0;
  649. height: 100rpx;
  650. background: #fff;
  651. border-top: 1rpx solid #eee;
  652. display: flex;
  653. align-items: center;
  654. justify-content: space-between;
  655. padding: 0 30rpx;
  656. z-index: 100;
  657. }
  658. .bottom-left {
  659. display: flex;
  660. align-items: baseline;
  661. }
  662. .total-label {
  663. font-size: 26rpx;
  664. color: #666;
  665. }
  666. .total-amount {
  667. font-size: 36rpx;
  668. color: #F97316;
  669. font-weight: bold;
  670. }
  671. .submit-btn {
  672. background: linear-gradient(135deg, #F97316, #FB923C);
  673. color: #fff;
  674. font-size: 28rpx;
  675. font-weight: bold;
  676. padding: 0 60rpx;
  677. height: 72rpx;
  678. line-height: 72rpx;
  679. border-radius: 36rpx;
  680. border: none;
  681. }
  682. .submit-btn::after {
  683. border: none;
  684. }
  685. .submit-btn.disabled {
  686. background: #ddd;
  687. color: #999;
  688. }
  689. </style>