Appearance
🤝交接文档
项目地址: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)
}
}支付
涉及支付的页面有:
- 游泳馆 办卡 微信支付
- 游泳馆 单次票 微信支付
- 羽毛球 办卡 微信支付
- 羽毛球 订场 一卡通支付
- 羽毛球 订场 会员卡支付
- 羽毛球 订场 微信支付
- 足球馆 办卡 微信支付
- 足球馆 订场 会员卡支付
- 足球馆 订场 微信支付
- 充值 一卡通 微信支付
- 充值 游泳卡 微信支付
- 充值 羽毛球卡 微信支付
- 充值 足球卡 微信支付
- 体测机 获取报告 微信支付
- 赛事活动 报名费 微信支付
后端关于微信支付的返回格式一致,皆为
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