Skip to content

🤝交接文档

项目地址:https://e.gitee.com/jxfbig/repos/jxfbig/olympic-h5-vue3/

项目技术

主要技术:vue3、vue-router、pinia

网路库:axios

组件库:vant、t-design-mobile

PDF 解析器:pdfjs-dist

扫描二维码观看在线版本

运行

npm run dev 会加载 .env.development

npm run dev2 会加载 .env.devprod

npm run build 会加载 .env.production

.env 文件示例

调试

与 server 调试时,仅需要修改 VITE_API_URL 即可

sh
# 只有 VITE_ 开头的才会被加载进入 import.meta.env

# base url ,配合 nginx 加路径
VITE_DIST_URL=/

# 在所有api前面加前缀
VITE_API_PATH=

# 1 路由使用 hash 模式
VITE_IS_HASH=0

# 显示 vconsole 调试器
VITE_SHOW_CONSOLE=0

# dev 下代理的地址

# 刘怡
VITE_API_URL=http://192.168.2.110:8085

# 向炎
#VITE_API_URL=http://192.168.2.33:8085

# 刘一山
# VITE_API_URL=http://192.168.2.108:8085

目录结构

sh
src
 └─api
 └─assets # 资源目录,如 图片,css
 └─components # 所有组件都在这里
 └─hook
 └─router # 路由
 └─stores # pinia
 └─types
 └─util
 └─views # 页面

api 存放 后端API接口 相关内容

views 中的存放的 页面 几乎是与 router 一一对应的

其余的部分都放在了 components

hook 为 vue 代码封装,主要为 vue3 组合式API(composition api)

views 详解:

sh
src
 └─views
   └─arena/
   └─home/
   └─login/
   └─order/
   └─other/
   └─user/

arena 为 所有场馆目录,包括 羽毛球、游泳馆、足球馆、体育场

home 为首页 三个页面

order 为订单相关页面

user 主要包括:

  • 会员卡
  • 优惠券
  • 体测机
  • 意见反馈
  • 人脸
  • 积分

其余部分全部放在 other

部署

通过 npm run build 时,.env.production中的内容至关重要

首先线上使用 hash 模式

sh
# 1 路由使用 hash 模式
VITE_IS_HASH=1

其次配合后端进行 nginx 部署的修改

sh
# base url ,配合 nginx 加路径
VITE_DIST_URL=/u/

# 在所有api前面加前缀
VITE_API_PATH=/api

最后,如果项目稳定,请关闭 v Console,将 VITE_SHOW_CONSOLE 修改为 0

sh
# 显示 vconsole 调试器
VITE_SHOW_CONSOLE=0

以下 为一些 技术讲解

关于登录

用户通过 公众号 进入 H5 就会使用 微信 静默登录/snsapi_base 获取用户 OpenID

而 登录页的 “微信登录” 是通过 授权登录/snsapi_userinfo 实现的

当用户点击 “微信登录” 就会跳转至 “微信服务器”,代码如下:

ts
const { origin, pathname, hash } = window.location
const redirect_uri = encodeURIComponent(`${origin}${pathname}${hash}`)

const url = getWxAuthURL({
  appid: 'wxa9462c49dd468b45',
  redirect_uri,
  scope: 'snsapi_userinfo',
  response_type: 'code',
  state
})

console.log('微信登录 redirect_uri > ', redirect_uri)
console.log('微信登录 url > ', url)

window.location.href = url

然后微信会带上 code 返回回来。

这里有一个非常严重的问题,微信返回的数据格式为:

url
http://www.cqzmi.com/u/?code=051s4s000k6f8T1f1I000YFPBj3s4s0R&state=#/home

这是因为线上的部方式为 Hash 模式造成的,所以需要对其进行处理。

IOS 和 Android 都可以通过 window.history.replaceState 完成对路由的修改

sh
window.history.replaceState(null, '', `${origin}${pathname}${hash}`)

但是!IOS 的 URL 会改,可不会实际生效!这会导致很多奇怪的问题!!!

比如 JS API 验证不通过

最后只能让 IOS 设备刷新解决:

ts

 setToken(token)

 if (isIOSerAgent()) {
   // IOS 需要单独处理
   // IOS 刷新页面前,需要先恢复页面,在刷新
   handleRouteOnWxLogined()
   setTimeout(isIOSRefresh, 600)
 }

/**
 * 是否为 iOS 浏览器
 */
export function isIOSerAgent() {
  return /iPhone|iPad|iPod/i.test(navigator.userAgent)
}

export function isIOSRefresh() {
  const { origin, pathname, hash } = window.location

  console.log('window.location > ', window.location)
  console.log('目标 url > ', `${origin}${pathname}${hash}`)

  window.location.href = `${origin}${pathname}${hash}`
  window.location.reload()
}

同样,Android 也不太平。

通过 微信登录后,本质上是对页面进行了刷新。

此时的网页的第一个页面是 登陆页 ,在登录成功后会直接前往 redirect(比如登陆前点击的游泳馆,登录成功后就会跳至游泳馆)。

那么此时,就会有一个问题,用户此时返回,返回的是 Login 页,而不是 Home。

现在使用最简单的方式完成:

ts
const handleRouteOnWxLogined = () => {
   const redirect = route.query.redirect

   // 先去 Home 然后在 redirect 对应界面
   router.replace({ name: 'home' })

   if (redirect && typeof redirect === 'string') {
     setTimeout(() => {
       router.push(redirect)
     }, 500)
   }
 }

支付

涉及支付的页面有:

  1. 游泳馆 办卡 微信支付
  2. 游泳馆 单次票 微信支付
  3. 羽毛球 办卡 微信支付
  4. 羽毛球 订场 一卡通支付
  5. 羽毛球 订场 会员卡支付
  6. 羽毛球 订场 微信支付
  7. 足球馆 办卡 微信支付
  8. 足球馆 订场 会员卡支付
  9. 足球馆 订场 微信支付
  10. 充值 一卡通 微信支付
  11. 充值 游泳卡 微信支付
  12. 充值 羽毛球卡 微信支付
  13. 充值 足球卡 微信支付
  14. 体测机 获取报告 微信支付
  15. 赛事活动 报名费 微信支付

后端关于微信支付的返回格式一致,皆为

ts
export interface IResPay {
  appId: string //'wxa9462c49dd468b45'
  nonceStr: string //'gYAObidSwEi7DYsx'
  packageValue: string //'prepay_id=wx0410242604233355fef688a2db13730001'
  paySign: string //'DCCCF08E293FCEF18D980F2C6B885612'
  signType: string //'MD5'
  timeStamp: string //'1730687066'
}

唤起微信支付的代码

ts
export const wxMpPay = (wxPay: IMpWxPay) => {
  // package 为 js 关键字,不能为变量名
  const { appId, timeStamp, nonceStr, package: pg, signType, paySign } = wxPay
  if (!appId) return Promise.reject('缺少appId')
  if (!timeStamp) return Promise.reject('缺少timeStamp')
  if (!nonceStr) return Promise.reject('缺少nonceStr')
  if (!pg) return Promise.reject('缺少package')
  if (!signType) return Promise.reject('缺少signType')
  if (!paySign) return Promise.reject('缺少paySign')

  const param = {
    appId, //公众号ID,由商户传入
    timeStamp, //时间戳,自1970年以来的秒数
    nonceStr, //随机串
    package: pg,
    signType, //微信签名方式:
    paySign //微信签名
  }

  // console.log('微信支付 WeixinJSBridge.invoke 参数 > ', param)

  return new Promise((resolve, reject) => {
    if (!WeixinJSBridge) return reject('WeixinJSBridge 不存在')
    // @ts-ignore
    WeixinJSBridge.invoke(
      'getBrandWCPayRequest',
      param,
      // @ts-ignore
      function (res) {
        const isPaySuccess = res.err_msg === 'get_brand_wcpay_request:ok'
        // console.log('微信支付结果', isPaySuccess, res)
        if (isPaySuccess) {
          resolve(res)
        } else {
          reject(res)
        }
      }
    )
  })
}

interface IMpWxPay {
  /**
   * 公众号ID,由商户传入
   */
  appId: string
  /**
   * 时间戳
   */
  timeStamp: string
  /**
   * 随机串
   */
  nonceStr: string
  /**
   * prepay_id
   */
  package: string
  /**
   * 微信签名方式,如 RSA
   */
  signType: string
  /**
   * 微信签名
   */
  paySign: string
}

JS API

JS API 是微信为 H5 提供的一系列 API。

这个项目是接入了 JS API 的,但是并未使用。

JS API 文档地址:https://developers.weixin.qq.com/doc/offiaccount/OA_Web_Apps/JS-SDK.html

代码具体流程:

请求 API 获取配置 /monitor/wx/info/getJsapiSignature,然后初始化:

ts
interface IWxConfig {
  appId: string
  timestamp: number
  nonceStr: string
  signature: string
}
export const initWxJsApi = (conf: IWxConfig) => {
  return new Promise((resolve, reject) => {
    wx.ready(() => {
      console.log('微信 JSAPI 初始化成功')
      resolve('微信初始化成功')
    })

    wx.error(function (res) {
      console.log('微信 JSAPI 初始化失败', res)
      reject(res)
    })

    console.log('微信 JSAPI 初始化', my)
    wx.config({
      debug: false,
      jsApiList: ['scanQRCode','chooseImage'], // 必填,需要使用的JS接口列表
      openTagList: [],
      ...conf
    })
  })
}

PDF 在线预览

注意

Nginx 服务器需要处理 mjs !!!

Nginx 服务器需要处理 mjs !!!

Nginx 服务器需要处理 mjs !!!

百度 nginx mjs 有相关文献,如 https://segmentfault.com/a/1190000041954539

因为 体测报告的内容并不多,所以处理的很简单。

获取并且初始化 PDF Document 对象。

尽量使用 min 包。min 包为 1.4M,未压缩的有 2.2M。对于没有 CDN 的服务器这也是不小的压力。

ts
import * as pdfjsLib from 'pdfjs-dist'

// PDF Worker configuration
import workerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url'

const loadPDF = async (url: string) => {
  const loadingTask = pdfjsLib.getDocument(url)
  pdfDocument = await loadingTask.promise

  if (state.init === false) {
    state.init = true
    emit('init')
  }

  console.log('todo 如果 pdf 页数太多,考虑虚拟列表' )

  state.totalPages = pdfDocument.numPages

  await nextTick()

  for (let i = 1; i <= state.totalPages; i++) {
    renderPage(i)
  }
}

渲染一页

ts
async function renderPage(pageNum: number) {
  if (!pdfDocument) return
  if (!pdfContainer.value) return

  console.log('pdfContainer', pdfContainer.value.children)

  if (!pdfContainer.value.children) return
  const canvas = pdfContainer.value.children[pageNum - 1] as HTMLCanvasElement
  if (!canvas) return
  const context = canvas.getContext('2d')
  if (!context) return

  const page = await pdfDocument.getPage(pageNum)

  const viewport = page.getViewport({ scale: 1 })

  canvas.height = viewport.height
  canvas.width = viewport.width

  const renderContext = {
    canvasContext: context,
    viewport: viewport
  }

  return page.render(renderContext).promise
}

以下为 代码讲解

跨页面

这个项目的所有跨页面操作,都是采用的 pinia!

所以很多时候数据会放在 store(pinia) 。

举个例子:比如游泳馆买票,在订单页面会涉及 阅读协议、选择优惠券,而这两个都是单独的页面。

如果将 数据放在 页面在来回切换的过程中,数据势必会丢失(页面重载)。在不使用 KeepAlive 的情况下,pinia 是我认为最好的解决方案。

签名

在所有办卡中,都需要签名。

签名使用的开源项目:https://github.com/linjc/smooth-signature

因为每个地方都有签名,所以将签名进行了提取封装。

vue
<template>
      <LabelItem label="签署" must>

        <template #labelAfter>
          <Book color="#333">办卡协议</Book>
        </template>

        <ArrowItem>
          <Signature />
        </ArrowItem>

      </LabelItem>
</template>

<script setup lang="ts">
  const { Signature, sign } = useSignature('badminton')
</script>

签名的所有功能都封装进了 useSignature

其中 sign 是签名的数据,其内容是 base64,可直接用于文件上传。

而 Signature 是签名的 View,通过 tsx 进行逻辑渲染

当没有 sign 返回 “去签名”,当有 sign 时返回 img 显示 签名内容

tsx
// 办卡界面,签名显示
function SignatureView(sign: ImgDataURL, toSignPage: () => void) {
  if (sign) {
    return (
      <div style="text-align: right; padding-right: 6px" onClick={toSignPage}>
        <img src={sign} style={{ height: '32px' }} />
      </div>
    )
  }

  return (
    <div
      style="color: #2975ff; font-size: 16px; text-align: right; padding-right: 6px"
      onClick={toSignPage}
    >
      去签署
    </div>
  )
}

点击后都会跳转至签名界面,即:

ts
const toSignPage = () => {
   router.push({
     name: 'arena-sign-agreement',
     params: {
       type
     }
   })
 }

优惠券

优惠券 与 签名 使用了相同的方式处理

vue
<template>
    <LabelItem label="优惠券" labelSize="14px">
        <ArrowItem>
            <div class="pay-coupon-hint" @click="() => toCouponPage()">
              {{ orderCouponTxt }}
            </div>
        </ArrowItem>
    </LabelItem>
</template>

<script setup lang="ts">
const {
  toCouponPage,
  orderCouponTxt,
  selectCouponId,
  loadCouponList,
  reset: resetCoupon,
  priceOnCoupon
} = useCoupon('badminton-reserve', discount)
</script>

优惠券承载了更多的业务逻辑,包括优惠券加载,优惠券选择,价格计算 等等。

由于前后端是分别开发的,当时为每个场景都设计了,所以现在看来代码有些臃肿,但依旧不建议修改。

ts
export type ICouponType =
  | 'swim-ticket'
  | 'swim-card'
  | 'badminton-card'
  | 'badminton-reserve'
  | 'football-card'
  | 'football-reserve'
  | 'checkup'

switch (type) {
   case 'swim-card': {
     store = useSwimCardCoupon()
     break
   }
   case 'swim-ticket': {
     store = useSwimTicketCoupon()
     break
   }
   case 'badminton-card': {
     store = useBadmintonCardCoupon()
     break
   }
   case 'badminton-reserve': {
     store = useBadmintonReserveCoupon()
     break
   }
   case 'football-card': {
     store = useFootballCardCoupon()
     break
   }
   case 'football-reserve': {
     store = useFootballReserveCoupon()
     break
   }
   case 'checkup': {
     store = useCheckupCoupon()
     break
   }
 }

优惠券需要注意不要减出负数,以及最低为 0.01

ts
/**
  * 优惠券 抵扣后 的 价格
  */
 const priceOnCoupon = computed(() => {
   if (!selectCoupon.value) return rmb(originalPrice.value)

   const couponAmount = selectCoupon.value.amount || 0

   // 理论上,优惠券金额不应该大于原价
   if (couponAmount > originalPrice.value) return rmb(0.01)

   return rmb(subtraction(originalPrice.value, couponAmount))
 })

因为一些原因,现会过滤掉一部分数据。

避免 10块钱 的商品用 20元 的优惠券来减

ts
/**
 * 获取 优惠券 页面数据
 */
const couponData = computed(() =>
  store.coupon.list.filter(({ amount }) => amount < originalPrice.value)
)

最后优惠券选择页面。

需要注意,“订单 > 优惠券选择” 与 “我的 > 优惠券” 是两个页面!

ts
  /**
   * 去 优惠券 页面
   */
  const toCouponPage = () => {
    if (!store.coupon.list.length) return

    router.push({
      params: {
        type,
        originalPrice: originalPrice.value
      },
      name: 'order-coupon'
    })
  }

协议

这个项目的协议内容非常非常非常多。

并且我们项目没有一个统一的协议获取API,这也影响了前端代码。

现在的协议实现方式 与 优惠券、签名一致

vue
<template>
    <ReadAgreement
        :modelValue="buyTicketStore.isAgreement"
        @update:modelValue="buyTicketStore.setAgreement"
        :agreement="state.agreements"
    />
</template>

<script setup lang="ts">

const buyTicketStore = useSwimBuyTicketStore()

const { agreementRef, setAgreement } = useAgreement('swim-tips-4-user')

const state = reactive({
  // ...
  agreements: [agreementRef.value],
  // ...
})
</script>

buyTicketStore.isAgreement 仅仅只是记录阅读状态。

useSwimBuyTicketStore
ts
export const useSwimBuyTicketStore = defineStore('useSwimBuyTicketStore', {
  state() {
    return {
      count: 1,
      isAgreement: false
    }
  },

  actions: {
    reset() {
      this.count = 1
      this.isAgreement = false
    },

    setCount(count: number) {
      this.count = count
    },
    setAgreement(val: boolean) {
      this.isAgreement = val
    }
  }
})

协议需要先 setAgreement ,然后才能正常显示

由于 获取规则 地方千奇百怪,所以没法统一,只能哪里能获取到数据,那就 setAgreement 。

ts
router.push({ name: 'agreement', params: { page } })

end