Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d267bcfc5 | ||
|
|
f4adf95e7a | ||
|
|
5f267c1e76 | ||
|
|
223a199ae8 | ||
|
|
0398148db7 | ||
|
|
6412d3be5c | ||
|
|
b22de4d6b2 | ||
|
|
d7fbf72df4 | ||
|
|
150b8b3ac7 | ||
|
|
bce6da0a0f |
No files matched your search
@@ -17,16 +17,16 @@ VITE_PWD_ENC_KEY='pigxpigxpigxpigx'
|
||||
VITE_OAUTH2_APP_CLIENT='mini:mini'
|
||||
|
||||
# --------------------------- start 开发环境 start ----------------------------------
|
||||
VITE_APP_DEV_SERVICE = https://stgl.pj.gov.cn/api
|
||||
VITE_APP_DEV_SERVICE = https://www.pjzhhq.cn/api
|
||||
# VITE_APP_DEV_SERVICE = http://localhost:9999
|
||||
# VITE_APP_DEV_SERVICE = https://dzc-test-1.fy.takin.cc
|
||||
|
||||
# VITE_IMAGE_DEV_PATH = http://localhost:9999
|
||||
# VITE_IMAGE_DEV_PATH = https://dzc-test-1.fy.takin.cc/api
|
||||
VITE_IMAGE_DEV_PATH = https://stgl.pj.gov.cn/api
|
||||
VITE_IMAGE_DEV_PATH = https://www.pjzhhq.cn/api
|
||||
# ----------------------------- end 开发环境 end ------------------------------------
|
||||
|
||||
# --------------------------- start 生产环境 start ----------------------------------
|
||||
VITE_APP_PROD_SERVICE = https://stgl.pj.gov.cn/api
|
||||
VITE_APP_PROD_IMAGE = https://stgl.pj.gov.cn/api
|
||||
VITE_APP_PROD_SERVICE = https://www.pjzhhq.cn/api
|
||||
VITE_APP_PROD_IMAGE = https://www.pjzhhq.cn/api
|
||||
# ----------------------------- end 生产环境 end ------------------------------------
|
||||
@@ -1,20 +1,22 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<script>
|
||||
var coverSupport = 'CSS' in window && typeof CSS.supports === 'function' && (CSS.supports('top: env(a)') ||
|
||||
CSS.supports('top: constant(a)'))
|
||||
document.write(
|
||||
'<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0' +
|
||||
(coverSupport ? ', viewport-fit=cover' : '') + '" />')
|
||||
</script>
|
||||
<title></title>
|
||||
<!--preload-links-->
|
||||
<!--app-context-->
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"><!--app-html--></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<script>
|
||||
var coverSupport =
|
||||
"CSS" in window && typeof CSS.supports === "function" && (CSS.supports("top: env(a)") || CSS.supports("top: constant(a)"))
|
||||
document.write(
|
||||
'<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0' +
|
||||
(coverSupport ? ", viewport-fit=cover" : "") +
|
||||
'" />',
|
||||
)
|
||||
</script>
|
||||
<title></title>
|
||||
<!--preload-links-->
|
||||
<!--app-context-->
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"><!--app-html--></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "",
|
||||
"appid": "",
|
||||
"appid": "wx11771c7bf2913cb2",
|
||||
"description": "",
|
||||
"versionName": "1.0.0",
|
||||
"versionCode": "100",
|
||||
@@ -50,7 +50,7 @@
|
||||
"quickapp": {},
|
||||
/* 小程序特有相关 */
|
||||
"mp-weixin": {
|
||||
"appid": "",
|
||||
"appid": "wx11771c7bf2913cb2",
|
||||
"setting": {
|
||||
"urlCheck": false
|
||||
},
|
||||
|
||||
@@ -96,7 +96,9 @@
|
||||
{
|
||||
"path": "pages/root/web-view",
|
||||
"style": {
|
||||
"navigationBarTitleText": "webview页面",
|
||||
"navigationBarBackgroundColor": "#ffffff",
|
||||
"navigationBarTitleText": "美好商城",
|
||||
"navigationStyle": "default",
|
||||
"disableScroll": true
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<crud-layout title="通知公告" :bg="filePath(SERVICE_IMAGE.blueBg)">
|
||||
<crud-layout mainIsScroll title="通知公告" :bg="filePath(SERVICE_IMAGE.blueBg)">
|
||||
<view class="notice-detail">
|
||||
<view class="detail-header">
|
||||
<view class="title">
|
||||
@@ -66,8 +66,36 @@ const goGetCoupon = () => {
|
||||
uni.navigateTo({ url: `/pages/merchant/merchant-activity?actId=${actDetail.value.actId}&actType=${actDetail.value.actType}` })
|
||||
}
|
||||
|
||||
const normalizeRichText = (html: string) => {
|
||||
const origin = "https://stgl.pj.gov.cn"
|
||||
return html.replace(/<img\b[^>]*>/gi, (imgTag) => {
|
||||
let next = imgTag
|
||||
|
||||
next = next.replace(/\bsrc\s*=\s*(['"])(.*?)\1/i, (_m, quote: string, src: string) => {
|
||||
let resolved = src
|
||||
if (resolved?.startsWith("/api")) resolved = `${origin}${resolved}`
|
||||
if (resolved?.startsWith("//")) resolved = `https:${resolved}`
|
||||
return `src=${quote}${resolved}${quote}`
|
||||
})
|
||||
|
||||
const styleMatch = next.match(/\bstyle\s*=\s*(['"])([\s\S]*?)\1/i)
|
||||
const required = "max-width: 100%; height: auto; display: block;"
|
||||
if (styleMatch) {
|
||||
const quote = styleMatch[1]
|
||||
const styleValue = styleMatch[2] ?? ""
|
||||
if (styleValue.includes("max-width") || styleValue.includes("width")) return next
|
||||
const merged = styleValue.trim().length ? `${styleValue.replace(/;?\s*$/, "; ")}${required}` : required
|
||||
return next.replace(/\bstyle\s*=\s*(['"])([\s\S]*?)\1/i, `style=${quote}${merged}${quote}`)
|
||||
}
|
||||
|
||||
return next.replace(/<img\b/i, `<img style="${required}"`)
|
||||
})
|
||||
}
|
||||
|
||||
const nodes = computed(() => {
|
||||
return detail.value.description?.replaceAll(`<img src="/api`, `<img src="https://stgl.pj.gov.cn/api`)
|
||||
const html = detail.value.description
|
||||
if (!html) return ""
|
||||
return normalizeRichText(html)
|
||||
})
|
||||
|
||||
function getTypeLabel(type: string) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<crud-layout title="通知公告" :bg="filePath(SERVICE_IMAGE.blueBg)">
|
||||
<view class="h-30rpx" />
|
||||
<view class="notice-container">
|
||||
<view class="notice-list">
|
||||
<view v-if="noticeList.length === 0" class="empty-tip"> 暂无公告 </view>
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
<template>
|
||||
<view class="banner-detail">
|
||||
<view class="detail-header">
|
||||
<view class="title">{{ detail.title || "加载中..." }}</view>
|
||||
<crud-layout title="详情" mainIsScroll :bg="filePath(SERVICE_IMAGE.blueBg)" ref="crudLayoutRef">
|
||||
<view class="banner-detail">
|
||||
<view class="detail-header">
|
||||
<view class="title">{{ detail.title || "加载中..." }}</view>
|
||||
</view>
|
||||
<view v-if="loading" class="loading-tip">加载中...</view>
|
||||
<view v-else-if="error" class="error-tip">{{ error }}</view>
|
||||
<view v-else class="detail-content">
|
||||
<rich-text :nodes="richTextNodes || ''" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="loading" class="loading-tip">加载中...</view>
|
||||
<view v-else-if="error" class="error-tip">{{ error }}</view>
|
||||
<view v-else class="detail-content">
|
||||
<rich-text :nodes="richTextNodes || ''" />
|
||||
</view>
|
||||
</view>
|
||||
</crud-layout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue"
|
||||
import { onLoad } from "@dcloudio/uni-app"
|
||||
import sysBannerApi from "@/sheep/api/sys-banner"
|
||||
import { filePath } from "@/sheep/utils/file"
|
||||
import { filePath, parseHtml } from "@/sheep/utils/file"
|
||||
import { SERVICE_IMAGE } from "@@/constant/index"
|
||||
|
||||
const detail = ref<{ title?: string; richTextContent?: string }>({})
|
||||
const loading = ref(false)
|
||||
@@ -31,11 +33,7 @@ const toAbsoluteUrl = (url?: string | null) => {
|
||||
const richTextNodes = computed(() => {
|
||||
const html = detail.value.richTextContent
|
||||
if (!html) return ""
|
||||
return html.replace(/<img\s+[^>]*src=["']([^"']+)["']/gi, (match, src) => {
|
||||
const fullSrc = toAbsoluteUrl(src)
|
||||
if (!fullSrc) return match
|
||||
return match.replace(src, fullSrc)
|
||||
})
|
||||
return parseHtml(html)
|
||||
})
|
||||
|
||||
onLoad(async (options: any) => {
|
||||
@@ -66,12 +64,6 @@ onLoad(async (options: any) => {
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.banner-detail {
|
||||
padding: 32rpx;
|
||||
background: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
|
||||
@@ -2,19 +2,25 @@
|
||||
import { reactive, ref, computed } from "vue"
|
||||
import { onLoad, onShow } from "@dcloudio/uni-app"
|
||||
|
||||
import * as hooks from "@@/hooks"
|
||||
import { serviceEnum } from "@@/constant"
|
||||
|
||||
const { actCouponTypeEnum } = serviceEnum
|
||||
|
||||
const emit = defineEmits(["doVisibleQrcode"])
|
||||
const emit = defineEmits(["doVisibleQrcode", "doUse"])
|
||||
|
||||
const props = withDefaults(defineProps<{ detail: any }>(), {
|
||||
const props = withDefaults(defineProps<{ mode?: 'list' | 'select', detail: any }>(), {
|
||||
mode: "list",
|
||||
detail: {},
|
||||
})
|
||||
|
||||
const doVisibleQrcode = () => {
|
||||
emit("doVisibleQrcode", { type: 'ACT_COUPON', detail: props.detail })
|
||||
}
|
||||
|
||||
const doUse = () => {
|
||||
emit("doUse", { type: 'ACT_COUPON', detail: props.detail })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -31,20 +37,28 @@ const doVisibleQrcode = () => {
|
||||
class="iconfont icon-weishiyong1 absolute font-size-80rpx! color-#cccccc" />
|
||||
<!-- 已过期 -->
|
||||
<view v-if="detail.status == 3" class="iconfont icon-yiguoqi absolute font-size-80rpx! color-#cccccc" />
|
||||
<!-- 已核销 -->
|
||||
<view v-if="detail.status == 4" class="iconfont icon-yiguoqi absolute font-size-80rpx! color-#cccccc" />
|
||||
</view>
|
||||
<view class="info-box">
|
||||
<view class="price-row">
|
||||
<view v-if="detail.faceValue" class="flex items-center">
|
||||
<text class="symbol">¥</text>
|
||||
<text class="amount">{{ detail.faceValue }}</text>
|
||||
<view v-if="detail.faceValue">
|
||||
<view v-if="detail.couponType == '2'" class="flex items-center">
|
||||
<text class="symbol">立减</text>
|
||||
<text class="amount">{{ detail.faceValue }}%</text>
|
||||
</view>
|
||||
<view v-else class="flex items-center">
|
||||
<text class="symbol">¥</text>
|
||||
<text class="amount">{{ hooks.useMoney(detail.faceValue) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="name">{{ detail.couponName }}</text>
|
||||
</view>
|
||||
<view v-if="detail.minPoint" class="condition">
|
||||
满{{ detail.minPoint }}可用
|
||||
<view>满{{ hooks.useMoney(detail.minPoint) }}可用</view>
|
||||
<view class="ml-10rpx">{{ actCouponTypeEnum[detail.couponType] }}</view>
|
||||
</view>
|
||||
<view class="scope-text flex-col">
|
||||
<text>{{ actCouponTypeEnum[detail.couponType] }}</text>
|
||||
<text>在{{ detail.startTime }}至{{ detail.endTime }}时间段使用</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -56,10 +70,18 @@ const doVisibleQrcode = () => {
|
||||
<view class="circle-bottom"></view>
|
||||
</view>
|
||||
<!-- 右侧按钮 -->
|
||||
<view class="coupon-right">
|
||||
<view v-if="detail.status == 1" class="action-btn" @tap="doVisibleQrcode">核销码</view>
|
||||
<view v-if="detail.status == 2" class="action-btn disabled">已使用</view>
|
||||
<view v-if="detail.status == 3" class="action-btn disabled">已过期</view>
|
||||
<view class="flex-row-center">
|
||||
<view v-if="mode == 'list'" class="coupon-right">
|
||||
<view v-if="detail.status == 1">
|
||||
<view v-if="detail.couponType == '4'" class="action-btn" @tap="doVisibleQrcode">核销码</view>
|
||||
<view v-else class="action-btn" @tap="doUse">去使用</view>
|
||||
</view>
|
||||
<view v-if="detail.status == 2" class="action-btn disabled">已使用</view>
|
||||
<view v-if="detail.status == 3" class="action-btn disabled">已过期</view>
|
||||
</view>
|
||||
<view v-else-if="mode == 'select'">
|
||||
<view class="action-btn">使用</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
@@ -117,7 +139,7 @@ const doVisibleQrcode = () => {
|
||||
}
|
||||
|
||||
.amount {
|
||||
font-size: 48rpx;
|
||||
font-size: 28rpx;
|
||||
font-weight: bold;
|
||||
margin: 0 8rpx 0 2rpx;
|
||||
line-height: 1;
|
||||
@@ -134,6 +156,8 @@ const doVisibleQrcode = () => {
|
||||
}
|
||||
|
||||
.condition {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 24rpx;
|
||||
color: #0094ff;
|
||||
opacity: 0.8;
|
||||
@@ -188,7 +212,7 @@ const doVisibleQrcode = () => {
|
||||
|
||||
/* 右侧按钮区 */
|
||||
.coupon-right {
|
||||
width: 160rpx;
|
||||
width: 140rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
<template>
|
||||
<crud-layout title="活动详情" mainIsScroll :bg="filePath(SERVICE_IMAGE.blueBg)">
|
||||
<view v-if="state.actId" class="px-16rpx py-32rpx">
|
||||
<crud-layout title="商户活动" mainIsScroll :bg="filePath(SERVICE_IMAGE.blueBg)" ref="crudLayoutRef">
|
||||
<view v-if="state.actId" class="py-32rpx">
|
||||
<view v-if="state.actType == 'CANCEL_ACT'"
|
||||
class="card rounded-lb-0! rounded-rb-0! w-full h-400rpx relative activity-card overflow-hidden">
|
||||
<view class="ribbon">兑换券</view>
|
||||
<view class="px-64rpx pt-40rpx flex justify-between items-center font-bold">
|
||||
<text class="text-32 c-#FF4F3F">限时领取</text>
|
||||
<text class="text-30 c-#FF4F3F px-8rpx rounded-full bg-#eeeeee44">
|
||||
<text class="text-32rpx c-#FF4F3F">限时领取</text>
|
||||
<text class="text-30rpx c-#FF4F3F px-8rpx rounded-full bg-#eeeeee44">
|
||||
{{ (state.actDetail.startTime?.split(" ")[0] || "") + "~" +
|
||||
(state.actDetail.expireTime?.split(" ")[0] || "") }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="w-4/5 px-64rpx pt-32rpx text-28 c-#fff">{{ state.actDetail.actTitle }}</view>
|
||||
<view class="absolute bottom-100 left-60 text-40 italic">
|
||||
<view class="w-4/5 px-64rpx pt-32rpx text-28rpx c-#fff">{{ state.actDetail.actTitle }}</view>
|
||||
<view class="absolute bottom-100rpx left-60rpx text-40rpx italic">
|
||||
<text class="color-red mr-16rpx">¥{{ hooks.useMoney(state.actDetail.activeCancelPrice) }}</text>
|
||||
<text class="text-30 line-through color-red">¥{{ hooks.useMoney(state.actDetail.activeOrgPrice)
|
||||
}}</text>
|
||||
<text class="text-30rpx line-through color-red">
|
||||
¥{{ hooks.useMoney(state.actDetail.activeOrgPrice) }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="absolute bottom-40 left-60 text-28 bg-#eeeeee44 c-#fff px-16rpx rounded-full">限量领取 先领先得
|
||||
<view class="absolute bottom-40 left-60 text-28rpx bg-#eeeeee44 c-#fff px-16rpx rounded-full">限量领取 先领先得
|
||||
</view>
|
||||
</view>
|
||||
<view class="card relative rounded-lb-0! rounded-rb-0! w-full activity-sr-card overflow-hidden"
|
||||
@@ -31,33 +32,44 @@
|
||||
class="card relative rounded-lb-0! rounded-rb-0! w-full activity-card overflow-hidden">
|
||||
<view class="ribbon">{{ actCouponTypeEnum[state.actDetail.type] }}</view>
|
||||
<view class="px-64rpx pt-40rpx flex justify-between items-center font-bold">
|
||||
<text class="text-32 c-#FF4F3F">{{ state.actDetail.name }}</text>
|
||||
<text class="text-30 c-#FF4F3F px-8rpx rounded-full bg-#eeeeee44">
|
||||
<view v-if="state.actDetail.validType == 1" class="flex items-center">
|
||||
<text>{{ state.actDetail.validStartTime }}</text>
|
||||
<text>~</text>
|
||||
<text>{{ state.actDetail.validEndTime }}</text>
|
||||
</view>
|
||||
<text class="text28 c-#FF4F3F">{{ state.actDetail.name }}</text>
|
||||
<view v-if="state.actDetail.totalCount" class="coupon-stock-badge">
|
||||
<view class="coupon-stock-dot" />
|
||||
<text class="coupon-stock-label">剩余</text>
|
||||
<text class="coupon-stock-value">{{ state.actDetail.surplusCount }}</text>
|
||||
<text class="coupon-stock-split">/</text>
|
||||
<text class="coupon-stock-total">{{ state.actDetail.totalCount }}</text>
|
||||
<text class="coupon-stock-unit">张</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="px-32rpx pt-32rpx text-26rpx c-#fff indent-1em">
|
||||
<view v-if="state.actDetail.minPoint">
|
||||
<text>满{{ hooks.useMoney(state.actDetail.minPoint) }}元,</text>
|
||||
<text v-if="['2'].includes(state.actDetail.type)">
|
||||
立减{{ state.actDetail.faceValue }}%
|
||||
</text>
|
||||
</view>
|
||||
<text v-else-if="['1'].includes(state.actDetail.type)">
|
||||
满减{{ hooks.useMoney(state.actDetail.faceValue) }}元
|
||||
</text>
|
||||
<text v-else-if="['3'].includes(state.actDetail.type)">
|
||||
立减{{ hooks.useMoney(state.actDetail.faceValue) }}元
|
||||
</text>
|
||||
</view>
|
||||
<view class="w-4/5 px-64rpx pt-32rpx text-28 c-#fff">
|
||||
<view v-if="state.actDetail.validType == '1'">
|
||||
<view class="px-32rpx pb-32rpx pt-8rpx text-26rpx c-#fff">
|
||||
<view v-if="state.actDetail.validType == '1'" class="indent-1em">
|
||||
<text>领取后请在</text>
|
||||
<text>{{ state.actDetail.validStartTime }}</text>
|
||||
<text>~</text>
|
||||
<text>{{ state.actDetail.validEndTime }}</text>
|
||||
<text>时间段内使用</text>
|
||||
</view>
|
||||
<view v-if="state.actDetail.validType == '2'">
|
||||
<view v-if="state.actDetail.validType == '2'" class="indent-1em">
|
||||
<text>领取后请在</text>
|
||||
<text>{{ state.actDetail.validDays }}</text>
|
||||
<text>天内使用</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="state.actDetail.description"
|
||||
class="m-l-20rpx m-y-20rpx w-55% text-28 bg-#eeeeee44 c-#fff p-16rpx rounded-16rpx">
|
||||
{{ state.actDetail.description }}
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="state.couponInfo.actStatus == '2'"
|
||||
class="text-center card rounded-lt-0! rounded-rt-0! p-24rpx sub-text-30">
|
||||
@@ -72,10 +84,13 @@
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
<view class="detail-content">
|
||||
<rich-text :nodes="nodes || ''"></rich-text>
|
||||
</view>
|
||||
<template #main-bottom>
|
||||
<view class="w-full flex-col-center">
|
||||
<tm-button class="px-256rpx m-16rpx" width="90%" @click="doQrcode">我的礼券</tm-button>
|
||||
<view class="h-10rpx"></view>
|
||||
<view class="h-30rpx" />
|
||||
</view>
|
||||
</template>
|
||||
</crud-layout>
|
||||
@@ -83,13 +98,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, nextTick } from "vue"
|
||||
import { reactive, ref, nextTick, computed } from "vue"
|
||||
import { onLoad } from "@dcloudio/uni-app"
|
||||
|
||||
import { actCancel, actComApi, actCouponApi, userApi } from "@/sheep/api"
|
||||
import * as hooks from "@@/hooks"
|
||||
import * as router from "@@/router"
|
||||
import { serviceEnum, storageConstant, SERVICE_IMAGE } from "@@/constant"
|
||||
import { hooks, router } from "@/sheep"
|
||||
import { serviceEnum, storageConstant } from "@@/constant";
|
||||
import { SERVICE_IMAGE } from "@@/constant/index"
|
||||
import { filePath } from "@@/utils/file"
|
||||
|
||||
import ModifyUser from "@@/components/modify-user.vue"
|
||||
@@ -106,6 +121,10 @@ const state = reactive<any>({
|
||||
userDetail: {},
|
||||
})
|
||||
|
||||
const nodes = computed(() => {
|
||||
return state.actDetail.description?.replaceAll(`<img src="/api`, `<img src="https://stgl.pj.gov.cn/api`)
|
||||
})
|
||||
|
||||
onLoad((option: any) => {
|
||||
const { actId, actType } = option
|
||||
if (!actId || !actType) return uni.showToast({ title: "参数错误", icon: "error" })
|
||||
@@ -245,20 +264,21 @@ const modifyUserClose = () => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.activity-card {
|
||||
background-image: url("../../static/page/activity-card-bg.png");
|
||||
background-repeat: no-repeat;
|
||||
background-size: contain;
|
||||
background-position: 150%;
|
||||
background-color: #1879ff77;
|
||||
.detail-content {
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 32rpx;
|
||||
font-size: 24rpx;
|
||||
color: #333;
|
||||
line-height: 1.6;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.activity-sr-card {
|
||||
/* background-image: url("../../static/page/act_li_quan.jpg");
|
||||
.activity-card {
|
||||
background-repeat: no-repeat;
|
||||
background-size: contain;
|
||||
background-position: 150%;
|
||||
background-color: red; */
|
||||
background-position: 130%;
|
||||
background-color: #1879ff77;
|
||||
}
|
||||
|
||||
.ribbon {
|
||||
@@ -273,4 +293,47 @@ const modifyUserClose = () => {
|
||||
color: #fff;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.coupon-stock-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8rpx 18rpx;
|
||||
border-radius: 9999rpx;
|
||||
background: rgba(238, 238, 238, 0.28);
|
||||
backdrop-filter: blur(2px);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.coupon-stock-dot {
|
||||
width: 12rpx;
|
||||
height: 12rpx;
|
||||
margin-right: 10rpx;
|
||||
border-radius: 9999rpx;
|
||||
background: #0094ff;
|
||||
}
|
||||
|
||||
.coupon-stock-label,
|
||||
.coupon-stock-unit,
|
||||
.coupon-stock-split,
|
||||
.coupon-stock-total {
|
||||
font-size: 24rpx;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.coupon-stock-value {
|
||||
margin: 0 4rpx 0 8rpx;
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.coupon-stock-total {
|
||||
margin-left: 4rpx;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.coupon-stock-unit {
|
||||
margin-left: 6rpx;
|
||||
opacity: 0.9;
|
||||
}
|
||||
</style>
|
||||
@@ -2,16 +2,15 @@
|
||||
import { reactive, ref, computed } from "vue"
|
||||
import { onLoad, onShow } from "@dcloudio/uni-app"
|
||||
|
||||
import * as router from "@@/router"
|
||||
import { SERVICE_IMAGE } from "@@/constant/index"
|
||||
import { filePath } from "@@/utils/file"
|
||||
import { router } from "@/sheep"
|
||||
|
||||
import uqrcode from "@@/utils/uqrcode"
|
||||
import * as CryptoJS from "crypto-js"
|
||||
|
||||
import { actCancel, actComApi, actCouponApi, applyApi } from "@@/api"
|
||||
import * as hooks from "@@/hooks"
|
||||
import { serviceEnum } from "@@/constant"
|
||||
import { hooks } from "@/sheep"
|
||||
import { serviceEnum, SERVICE_IMAGE } from "@@/constant"
|
||||
import { filePath } from "@@/utils/file"
|
||||
|
||||
import zTabs from "@@/components/other/z-tabs/z-tabs.vue"
|
||||
|
||||
@@ -20,6 +19,8 @@ import CouponCard from "./components/coupon-card.vue"
|
||||
|
||||
const { actCouponTypeEnum } = serviceEnum
|
||||
|
||||
const { scanCodeAction, processUrlScanResult, processBase64ScanResult } = hooks.useScanCode(false)
|
||||
|
||||
const pageRef = ref<any>(null)
|
||||
|
||||
const showQrCodeDialog = ref(false)
|
||||
@@ -72,6 +73,22 @@ const doTab = (e: any) => {
|
||||
getUserTotal()
|
||||
}
|
||||
|
||||
// 去使用
|
||||
const doUse = async (detail: any) => {
|
||||
const { type, detail: actDetail } = detail
|
||||
console.log("去使用:", type, actDetail);
|
||||
const scanResult = await scanCodeAction()
|
||||
if (!scanResult) return uni.showToast({ title: "扫码错误", icon: "none" })
|
||||
// 判断扫码结果
|
||||
if (scanResult.startsWith("http")) {
|
||||
// 扫码结果是url链接
|
||||
processUrlScanResult(scanResult.replace("/#", ""))
|
||||
} else {
|
||||
// 扫码结果是base64字符串
|
||||
processBase64ScanResult(scanResult)
|
||||
}
|
||||
}
|
||||
|
||||
const doVisibleQrcode = async (detail: any) => {
|
||||
const { type, detail: actDetail } = detail
|
||||
console.log("生成二维码:", type, actDetail);
|
||||
@@ -143,7 +160,7 @@ const genVerificationCode = async (params: Record<string, any>) => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<crud-layout title="我参加的活动" mainIsScroll :bg="filePath(SERVICE_IMAGE.blueBg)">
|
||||
<crud-layout title="我的礼券" mainIsScroll :bg="filePath(SERVICE_IMAGE.blueBg)">
|
||||
<z-paging ref="pageRef" height="100%" :fixed="false" v-model="dynamicPageHook.list" :default-page-size="10"
|
||||
safe-area-inset-bottom :loading-more-enabled="true" :show-scrollbar="false" @query="query"
|
||||
:auto-clean-list-when-reload="false" :auto-scroll-to-top-when-reload="false"
|
||||
@@ -157,7 +174,7 @@ const genVerificationCode = async (params: Record<string, any>) => {
|
||||
<view class="h-20rpx" />
|
||||
<view v-if="state.tabId == 1">
|
||||
<coupon-card v-for="item in dynamicPageHook.list" :key="item.id" :detail="item"
|
||||
@do-visible-qrcode="doVisibleQrcode" />
|
||||
@do-visible-qrcode="doVisibleQrcode" @doUse="doUse" />
|
||||
</view>
|
||||
<view v-if="state.tabId == 2">
|
||||
<cancel-card v-for="item in dynamicPageHook.list" :key="item.id" :detail="item"
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
<script setup lang="ts">
|
||||
import * as hooks from "@@/hooks"
|
||||
|
||||
const emit = defineEmits(["doSelect"])
|
||||
|
||||
const props = withDefaults(defineProps<{ selectId?: any, detail: any, disabled?: boolean }>(), {
|
||||
selectId: null,
|
||||
detail: {},
|
||||
disabled: false,
|
||||
})
|
||||
|
||||
const doSelect = () => {
|
||||
if (props.disabled) return
|
||||
emit("doSelect", { type: 'ACT_COUPON', detail: props.detail })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="border-4rpx border-solid border-#efefef coupon-item"
|
||||
:class="{ 'border-4rpx border-solid border-blue!': selectId == detail.id && !disabled, 'coupon-item-disabled': disabled }"
|
||||
@tap="doSelect">
|
||||
<!-- 左侧主体 -->
|
||||
<view class="coupon-left relative">
|
||||
<image class="size-60rpx rounded-8rpx flex-shrink-0 m-r-10rpx bg-#f0f0f0" src="/static/icon/act_coupon.png"
|
||||
mode="aspectFill" />
|
||||
<view class="absolute top-0rpx right-80rpx">
|
||||
<!-- 未使用 -->
|
||||
<view v-if="detail.status == 1" class="iconfont icon-weishiyong absolute font-size-80rpx! color-blue" />
|
||||
<!-- 已使用 -->
|
||||
<view v-if="detail.status == 2"
|
||||
class="iconfont icon-weishiyong1 absolute font-size-80rpx! color-#cccccc" />
|
||||
<!-- 已过期 -->
|
||||
<view v-if="detail.status == 3" class="iconfont icon-yiguoqi absolute font-size-80rpx! color-#cccccc" />
|
||||
<!-- 已核销 -->
|
||||
<view v-if="detail.status == 4" class="iconfont icon-yiguoqi absolute font-size-80rpx! color-#cccccc" />
|
||||
</view>
|
||||
<view class="info-box">
|
||||
<view class="price-row">
|
||||
<view v-if="detail.faceValue">
|
||||
<view v-if="detail.couponType == '2'" class="flex items-center">
|
||||
<text class="symbol">立减</text>
|
||||
<text class="amount">{{ detail.faceValue }}%</text>
|
||||
</view>
|
||||
<view v-else class="flex items-center">
|
||||
<text class="symbol">¥</text>
|
||||
<text class="amount">{{ hooks.useMoney(detail.faceValue) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="name">{{ detail.couponName }}</text>
|
||||
</view>
|
||||
<view v-if="detail.minPoint" class="condition">
|
||||
满{{ hooks.useMoney(detail.minPoint) }}可用
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 虚线分割与缺口 -->
|
||||
<view class="split-line">
|
||||
<view class="circle-top"></view>
|
||||
<view class="dashed-line"></view>
|
||||
<view class="circle-bottom"></view>
|
||||
</view>
|
||||
<!-- 右侧按钮 -->
|
||||
<view class="flex-row-center">
|
||||
<view class="action-btn" :class="{ disabled }">{{ disabled ? "不可用" : "使用" }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.coupon-item {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
margin: 8rpx 0;
|
||||
border-radius: 16rpx;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.coupon-item-disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* 左侧布局 */
|
||||
.coupon-left {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12rpx 0 12rpx 12rpx;
|
||||
background: radial-gradient(circle at right top, transparent 12rpx, #fff 0) top left / 100% 51% no-repeat,
|
||||
radial-gradient(circle at right bottom, transparent 12rpx, #fff 0) bottom left / 100% 51% no-repeat;
|
||||
/* 备用背景色,防止渐变失效 */
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.goods-img {
|
||||
width: 60rpx;
|
||||
height: 60rpx;
|
||||
border-radius: 8rpx;
|
||||
background-color: #f0f0f0;
|
||||
margin-right: 20rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
height: 80rpx;
|
||||
}
|
||||
|
||||
.price-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #0094ff;
|
||||
}
|
||||
|
||||
.symbol {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.amount {
|
||||
font-size: 28rpx;
|
||||
font-weight: bold;
|
||||
margin: 0 8rpx 0 2rpx;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 200rpx;
|
||||
}
|
||||
|
||||
.condition {
|
||||
font-size: 24rpx;
|
||||
color: #0094ff;
|
||||
opacity: 0.8;
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
|
||||
.scope-text {
|
||||
font-size: 22rpx;
|
||||
color: #999;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
/* 中间分割线区域 */
|
||||
.split-line {
|
||||
width: 30rpx;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.dashed-line {
|
||||
width: 0;
|
||||
height: 80%;
|
||||
border-left: 2rpx dashed #0094ff44;
|
||||
}
|
||||
|
||||
/* 右侧按钮区 */
|
||||
.coupon-right {
|
||||
width: 140rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: radial-gradient(circle at left top, transparent 12rpx, #fff 0) top left / 100% 51% no-repeat,
|
||||
radial-gradient(circle at left bottom, transparent 12rpx, #fff 0) bottom left / 100% 51% no-repeat;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
background: #0094ff;
|
||||
color: #fff;
|
||||
font-size: 26rpx;
|
||||
padding: 10rpx 24rpx;
|
||||
border-radius: 30rpx;
|
||||
font-weight: 500;
|
||||
|
||||
&.disabled {
|
||||
background: #cccccc;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<crud-layout title="支付成功" :bg="filePath(SERVICE_IMAGE.blueBg)" ref="crudLayoutRef">
|
||||
<view class="flex-col-center p-30rpx">
|
||||
<view class="flex-col-center p-30rpx m-t-30rpx card">
|
||||
<view class="flex-col-center m-y-50rpx">
|
||||
<svg t="1742978804614" class="icon" viewBox="0 0 1024 1024" version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg" p-id="1708" width="64" height="64">
|
||||
@@ -27,9 +27,8 @@
|
||||
<text class="sub-text-30">扫一扫</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="p-40rpx fixed bottom-30rpx left-0 right-0 font-bold">
|
||||
<tm-button :block="true" @click="goBack">完成</tm-button>
|
||||
<div class="h-100rpx" />
|
||||
<tm-button :block="true" class="w-full p-40rpx" @tap="goBack">完成</tm-button>
|
||||
</view>
|
||||
</crud-layout>
|
||||
</template>
|
||||
|
||||
@@ -1,19 +1,32 @@
|
||||
<template>
|
||||
<crud-layout :title="scanPayState.mchName" :bg="filePath(SERVICE_IMAGE.blueBg)" ref="crudLayoutRef">
|
||||
<crud-layout mainIsScroll :title="scanPayState.mchName" :bg="filePath(SERVICE_IMAGE.blueBg)" ref="crudLayoutRef">
|
||||
<view class="h-screen flex flex-col">
|
||||
<tm-sheet :margin="[0, 10]" class="flex flex-row">
|
||||
<span class="text-2xl text-[#333] mr-8rpx line-height-normal">¥</span>
|
||||
<span class="text-3xl font-medium text-[#333]">{{ displayAmount }}</span>
|
||||
<tm-sheet :margin="[0, 10]" class="flex flex-col ">
|
||||
<view class="flex flex-row items-center">
|
||||
<span class="text-48rpx text-[#333] mr-8rpx line-height-normal">¥</span>
|
||||
<span class="text-72rpx font-medium text-[#333]">{{ displayAmount }}</span>
|
||||
</view>
|
||||
<view v-if="couponDiscountAmount > 0" class="mt-10rpx flex items-center text-24rpx">
|
||||
<view class="px-12rpx py-4rpx rounded-full bg-#0094ff1a c-#0094ff font-600">
|
||||
已优惠 ¥{{ couponDiscountDisplay }}
|
||||
</view>
|
||||
<view class="ml-12rpx c-#666">
|
||||
券后实付
|
||||
<text class="c-#0094ff font-600">¥{{ couponPayableDisplay }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else-if="selectCoupon.id && couponUnavailableText" class="mt-10rpx text-24rpx c-#999">
|
||||
{{ couponUnavailableText }}
|
||||
</view>
|
||||
</tm-sheet>
|
||||
|
||||
<tm-sheet v-if="walletList.length > 0" :margin="[0, 10]" class="flex flex-col">
|
||||
<view class="text-gray-700 font-bold text-28rpx mb-8rpx">选择支付钱包:</view>
|
||||
<view v-for="(v, index) in walletList" :key="index"
|
||||
class="border-6 border-solid border-#efefef my-16rpx duration-200 flex justify-between items-center p-24rpx rounded-2"
|
||||
class="border-6 border-solid border-#efefef my-8rpx duration-200 flex justify-between items-center p-18rpx rounded-2"
|
||||
:class="{ 'border-6 border-solid border-blue!': selectWallet.id == v.id }"
|
||||
@click="selectWallet = v">
|
||||
<view class="flex items-center">
|
||||
<image class="size-70rpx mr-16rpx" :src="filePath(v.walletTypeIcon)" />
|
||||
<image class="size-50rpx mr-8rpx" :src="filePath(v.walletTypeIcon)" />
|
||||
<view class="text-32rpx">{{ v.walletTypeName }}</view>
|
||||
</view>
|
||||
<view class="text-28rpx flex items-center">
|
||||
@@ -22,29 +35,36 @@
|
||||
</view>
|
||||
</view>
|
||||
</tm-sheet>
|
||||
|
||||
<tm-sheet v-if="actCouponList.length > 0" :margin="[0, 10]" class="flex flex-col">
|
||||
<view class="text-gray-700 font-bold text-28rpx mb-8rpx">选择优惠券:</view>
|
||||
<select-coupon-card :selectId="selectCoupon.id" v-for="item in actCouponList" :key="item.id"
|
||||
:detail="item" :disabled="!isCouponAvailable(item)" @do-select="doSelectActCoupon" />
|
||||
</tm-sheet>
|
||||
<view class="h-30rpx" />
|
||||
<tm-button class="mt-30rpx" :block="true" @click="handlePay()" :disabled="loading">支 付</tm-button>
|
||||
|
||||
<view class="h-30rpx" />
|
||||
<number-keyboard v-if="numberKeyboardShow" v-model="amount" />
|
||||
</view>
|
||||
</crud-layout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, reactive, onMounted, nextTick } from "vue"
|
||||
import { ref, computed, reactive, onMounted, nextTick, watch } from "vue"
|
||||
import { onLoad, onShow } from "@dcloudio/uni-app"
|
||||
|
||||
import payApi from "@@/api/pay"
|
||||
import { merchantApi, orderApi, walletApi } from "@/sheep/api"
|
||||
import { merchantApi, orderApi, walletApi, actCouponApi } from "@/sheep/api"
|
||||
import * as hooks from "@@/hooks"
|
||||
import * as router from "@@/router"
|
||||
import { filePath } from "@/sheep/utils/file"
|
||||
import { displayPrice, storePrice } from "@/sheep/utils/format-money"
|
||||
import { usePayStore } from "@/sheep/stores/pay"
|
||||
import { isEmpty } from "radash"
|
||||
import NumberKeyboard from "@/sheep/components/number-keyboard.vue"
|
||||
import { SERVICE_IMAGE } from "@@/constant/index"
|
||||
|
||||
import NumberKeyboard from "@/sheep/components/number-keyboard.vue"
|
||||
import SelectCouponCard from "./components/select-coupon-card.vue"
|
||||
|
||||
const crudLayoutRef = ref<any>(null)
|
||||
const loading = ref(false)
|
||||
const scanPayState = reactive({
|
||||
@@ -54,7 +74,9 @@ const scanPayState = reactive({
|
||||
action: "", // action === "harQrCodeScanPay":扫收款机上生成的码支付
|
||||
})
|
||||
const walletList = ref<any>([]) // 钱包列表
|
||||
const actCouponList = ref<any>([]) // 优惠券列表
|
||||
const selectWallet = ref<any>({}) // 当前选中的钱包
|
||||
const selectCoupon = ref<any>({}) // 当前选中的优惠券
|
||||
const orderInfo = ref<any>({}) // 订单信息
|
||||
const amount = ref("") // 键盘输入的金额
|
||||
const numberKeyboardShow = ref(true)
|
||||
@@ -87,6 +109,7 @@ onLoad(async (options: any) => {
|
||||
onShow(async () => {
|
||||
try {
|
||||
await getWalletList()
|
||||
await getActCouponApi()
|
||||
} finally {
|
||||
uni.hideLoading()
|
||||
}
|
||||
@@ -104,6 +127,36 @@ const getMchDetail = async (mchId: string) => {
|
||||
scanPayState.mchName = data.name
|
||||
}
|
||||
|
||||
const doSelectActCoupon = (e: any) => {
|
||||
const { detail } = e
|
||||
if (!isCouponAvailable(detail)) {
|
||||
uni.showToast({ title: `当前优惠券满${priceCentToYuan(detail.minPoint).toFixed(2)}元可用`, icon: "none" })
|
||||
return
|
||||
}
|
||||
// 如果id一样就取消选择
|
||||
if (selectCoupon.value?.id == detail.id) {
|
||||
selectCoupon.value = {}
|
||||
return
|
||||
}
|
||||
selectCoupon.value = detail
|
||||
}
|
||||
|
||||
// 获取优惠券
|
||||
const getActCouponApi = async () => {
|
||||
try {
|
||||
const { code, data }: any = await actCouponApi.mchList({
|
||||
scopeId: scanPayState.mchId
|
||||
})
|
||||
if (code !== 0) return
|
||||
actCouponList.value = data ?? []
|
||||
if (actCouponList.value.length > 0) {
|
||||
selectCoupon.value = actCouponList.value.find((item: any) => isCouponAvailable(item)) || {}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("获取优惠券意外的错误error:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取钱包列表
|
||||
const getWalletList = async () => {
|
||||
const { code, data } = await walletApi.all()
|
||||
@@ -127,6 +180,71 @@ const displayAmount = computed(() => {
|
||||
return amount.value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",")
|
||||
})
|
||||
|
||||
const amountNumber = computed(() => {
|
||||
const value = Number(String(amount.value || 0).replace(/,/g, ""))
|
||||
return Number.isNaN(value) ? 0 : value
|
||||
})
|
||||
|
||||
const priceCentToYuan = (price: any) => {
|
||||
return Number(displayPrice(Number(price || 0), "", 2))
|
||||
}
|
||||
|
||||
const isCouponAvailable = (coupon: any) => {
|
||||
if (!coupon) return false
|
||||
const minPoint = priceCentToYuan(coupon.minPoint)
|
||||
if (minPoint > 0 && amountNumber.value < minPoint) return false
|
||||
return true
|
||||
}
|
||||
const couponPayableAmount = computed(() => {
|
||||
if (!selectCoupon.value?.id || amountNumber.value <= 0) return amountNumber.value
|
||||
const couponType = String(selectCoupon.value.couponType || "")
|
||||
const minPoint = priceCentToYuan(selectCoupon.value.minPoint)
|
||||
const useAmount = priceCentToYuan(selectCoupon.value.useAmount)
|
||||
// 满减券: 不足门槛不优惠, 否则按 useAmount 作为券后金额
|
||||
if (couponType === "1") {
|
||||
const faceValue = priceCentToYuan(selectCoupon.value.faceValue)
|
||||
if (minPoint > 0 && amountNumber.value < minPoint) return amountNumber.value
|
||||
const payable = useAmount > 0 ? useAmount : amountNumber.value - faceValue
|
||||
return Math.max(0, Number(payable.toFixed(2)))
|
||||
}
|
||||
// 折扣券: 不足门槛不优惠, totalPrice * faceValue / 100
|
||||
if (couponType === "2") {
|
||||
const faceValue = Number(selectCoupon.value.faceValue || 0)
|
||||
if (minPoint > 0 && amountNumber.value < minPoint) return amountNumber.value
|
||||
const _p = Math.max(0, Number(((amountNumber.value * (100 - faceValue)) / 100).toFixed(2)))
|
||||
return _p
|
||||
}
|
||||
// 立减券: 按 useAmount 作为券后金额
|
||||
if (couponType === "3") {
|
||||
const faceValue = priceCentToYuan(selectCoupon.value.faceValue)
|
||||
const payable = useAmount > 0 ? useAmount : amountNumber.value - faceValue
|
||||
return Math.max(0, Number(payable.toFixed(2)))
|
||||
}
|
||||
return amountNumber.value
|
||||
})
|
||||
|
||||
const couponDiscountAmount = computed(() => {
|
||||
return Math.max(0, Number((amountNumber.value - couponPayableAmount.value).toFixed(2)))
|
||||
})
|
||||
|
||||
const couponDiscountDisplay = computed(() => couponDiscountAmount.value.toFixed(2))
|
||||
const couponPayableDisplay = computed(() => couponPayableAmount.value.toFixed(2))
|
||||
|
||||
const couponUnavailableText = computed(() => {
|
||||
if (!selectCoupon.value?.id || amountNumber.value <= 0) return ""
|
||||
const minPoint = priceCentToYuan(selectCoupon.value.minPoint)
|
||||
if (minPoint > 0 && amountNumber.value < minPoint) {
|
||||
return `当前优惠券满${minPoint.toFixed(2)}元可用`
|
||||
}
|
||||
return ""
|
||||
})
|
||||
|
||||
watch(amountNumber, () => {
|
||||
if (selectCoupon.value?.id && !isCouponAvailable(selectCoupon.value)) {
|
||||
selectCoupon.value = {}
|
||||
}
|
||||
})
|
||||
|
||||
// 点击支付
|
||||
const handlePay = () => {
|
||||
if (!scanPayState.mchId) return uni.showToast({ title: "扫码参数不存在", icon: "none" })
|
||||
@@ -158,6 +276,7 @@ const pay = async () => {
|
||||
merchantId: scanPayState.mchId,
|
||||
customPrice: storePrice(Number(amount.value)),
|
||||
walletId: selectWallet.value.id,
|
||||
actCouponRecordId: selectCoupon.value?.id || "",
|
||||
})
|
||||
if (code != 0) return uni.showToast({ title: msg || "支付失败", icon: "error" })
|
||||
}
|
||||
@@ -166,7 +285,7 @@ const pay = async () => {
|
||||
usePayStore().setScanPayData({
|
||||
mchId: scanPayState.mchId,
|
||||
mchName: scanPayState.mchName,
|
||||
payPrice: Number(amount.value).toFixed(2),
|
||||
payPrice: couponPayableDisplay.value ?? Number(amount.value).toFixed(2),
|
||||
})
|
||||
router.jump("/pages/pay/payComplete", {}, 2)
|
||||
} catch (error: any) {
|
||||
|
||||
@@ -40,7 +40,6 @@
|
||||
<view class="font-bold mt-48rpx">
|
||||
<tm-button block @click="confirmTransfer" v-if="payStore.sourceWalletData.walletTypeId">确定转账</tm-button>
|
||||
</view>
|
||||
|
||||
<number-keyboard v-model="amount" />
|
||||
</crud-layout>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { ref, reactive, computed, nextTick, onMounted } from "vue"
|
||||
import { onHide, onShow } from "@dcloudio/uni-app"
|
||||
|
||||
import { router, constant } from "@@/index"
|
||||
import { router } from "@@/index"
|
||||
import { noticeApi, appApi } from "@@/api/index"
|
||||
import { getAppInfo } from "@@/utils/app"
|
||||
import { SERVICE_IMAGE } from "@@/constant/index"
|
||||
@@ -48,7 +48,9 @@ onShow(() => {
|
||||
if (!ceheckLogin()) {
|
||||
uni.showToast({ title: "登录已过期,请重新登录", icon: "none" })
|
||||
uni.navigateTo({ url: "/pages/root/login" })
|
||||
return
|
||||
}
|
||||
getSwiperList()
|
||||
getNoticeList()
|
||||
getAppMenuList()
|
||||
})
|
||||
@@ -68,10 +70,32 @@ const doMenu = async (v: any) => {
|
||||
router.jump("/pages/third-app/happyMall")
|
||||
return
|
||||
}
|
||||
// 是否是具体页面
|
||||
const routePath = v.page.split("?")[0]
|
||||
if (v.page.startsWith("/") && __ROUTES__.some((route) => route.path === routePath)) {
|
||||
// 直接页面入口也要先组装所属业务 Tabbar,再进入目标页面。
|
||||
await tabbarStore.componetTabbarList(v.page)
|
||||
router.jump(v.page)
|
||||
return
|
||||
}
|
||||
await tabbarStore.componetTabbarList(v.page)
|
||||
tabbarStore.jumpFirstTabbar()
|
||||
}
|
||||
|
||||
// 获取轮播图
|
||||
const getSwiperList = async () => {
|
||||
try {
|
||||
const { data, code }: any = await noticeApi.getSwiperList()
|
||||
console.log("获取轮播图,code:", code);
|
||||
if (code != 0) {
|
||||
throw new Error()
|
||||
}
|
||||
state.swiperList = (data || []).filter(Boolean)
|
||||
} catch (error) {
|
||||
console.log("获取轮播图意外的错误error:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取通知公告列表
|
||||
const getNoticeList = async () => {
|
||||
try {
|
||||
@@ -79,6 +103,7 @@ const getNoticeList = async () => {
|
||||
current: 1,
|
||||
size: 9
|
||||
})
|
||||
console.log("获取通知公告列表,code:", code);
|
||||
if (code != 0) {
|
||||
throw new Error()
|
||||
}
|
||||
@@ -97,7 +122,10 @@ const getAppMenuList = async () => {
|
||||
platformEnum: platform
|
||||
}
|
||||
const { data, code }: any = await appApi.appList(params)
|
||||
if (code != 0) {
|
||||
console.log("获取应用菜单列表,code:", code);
|
||||
if (code == 424) {
|
||||
|
||||
} else if (code != 0) {
|
||||
throw new Error()
|
||||
}
|
||||
state.appMenuList = (data || []).filter(Boolean)
|
||||
@@ -105,6 +133,12 @@ const getAppMenuList = async () => {
|
||||
console.log("获取应用菜单列表意外的错误error:", error);
|
||||
}
|
||||
}
|
||||
|
||||
const doSwiper = async (v: any) => {
|
||||
if (!v) return
|
||||
const target = (v.jumpValue || "").replace(/\s+/g, "")
|
||||
uni.navigateTo({ url: target })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -112,8 +146,8 @@ const getAppMenuList = async () => {
|
||||
<view class="h-20rpx" />
|
||||
<swiper class="w-full h-280rpx rounded-16rpx overflow-hidden" circular :indicator-dots="true"
|
||||
indicator-active-color="#0779FD" indicator-color="#ffffff" :autoplay="true">
|
||||
<swiper-item v-for="(v, i) in state?.swiperList" :key="i">
|
||||
<image class="w-full h-full" :src="v.image" mode="aspectFill" />
|
||||
<swiper-item v-for="(v, i) in state?.swiperList" :key="v.id" @tap="doSwiper(v)">
|
||||
<image class="w-full h-full" :src="v.imageUrl" mode="aspectFill" />
|
||||
</swiper-item>
|
||||
</swiper>
|
||||
<view class="h-20rpx" />
|
||||
@@ -135,7 +169,8 @@ const getAppMenuList = async () => {
|
||||
<view class="w-full">
|
||||
<view
|
||||
class="h-100rpx flex justify-between items-center p-x-16rpx p-y-20rpx border-b-1 border-color-#f0f0f0"
|
||||
v-for="(v, idx) in group" :key="idx">
|
||||
v-for="(v, idx) in group" :key="idx"
|
||||
@tap="() => router.jump('/pages/integrated/notice/detail', { id: v?.id })">
|
||||
<view class="flex-1 sub-text-26 font-normal! truncate">{{ v.title }}</view>
|
||||
<view class="w-250rpx second-text-24 m-l-10rpx">{{ v.createTime }}</view>
|
||||
</view>
|
||||
@@ -169,7 +204,7 @@ const getAppMenuList = async () => {
|
||||
<empty text="暂无App" />
|
||||
</view>
|
||||
</view>
|
||||
<view class="h-30rpx" />
|
||||
<view class="h-80rpx" />
|
||||
</crud-layout>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -120,7 +120,14 @@ const affirmConfirm = () => {
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<affirm-check v-model="state.isAgree" class="absolute bottom-30rpx" />
|
||||
<view class="absolute bottom-60rpx">
|
||||
<view class="sub-text-28 color-red! indent-2em">
|
||||
<text>小程序只提供给有"浦江县机关事务智慧后勤一体化平台"账号的用户使用,其余用户不能使用该小程序</text>
|
||||
</view>
|
||||
<view class="h-10rpx" />
|
||||
<affirm-check v-model="state.isAgree" />
|
||||
</view>
|
||||
|
||||
<affirm-modal ref="affirmModalRef" @confirm="affirmConfirm" />
|
||||
<service-text ref="serviceTextRef" :showPhoneText="false" />
|
||||
<tm-modal :show="state.loginErrorVisible" title="登录错误" cancelText="取消" confirmText="联系客服" height="auto"
|
||||
|
||||
@@ -63,7 +63,8 @@ const doSubmit = async () => {
|
||||
uni.setStorageSync(tokenKey, {
|
||||
accessToken: res.data.access_token,
|
||||
refreshToken: res.data.refresh_token,
|
||||
accessTokenExpiresIn: new Date(res.data.expires_in + Date.now()).toISOString(),
|
||||
// OAuth expires_in 的单位是秒,Date.now() 的单位是毫秒,必须先乘 1000。
|
||||
accessTokenExpiresIn: new Date(Date.now() + Number(res.data.expires_in) * 1000).toISOString(),
|
||||
refreshTokenExpiresIn: "",
|
||||
} as GlobalSetting.TokenStorage)
|
||||
|
||||
@@ -133,7 +134,13 @@ const affirmModalConfirm = () => {
|
||||
<text></text>
|
||||
<text class="color-blue font-size-sm" @click="router.jump('/pages/root/login')">手机号一键登录</text>
|
||||
</view>
|
||||
<affirm-check v-model="state.isAgree" class="absolute bottom-30rpx" />
|
||||
<view class="absolute bottom-60rpx">
|
||||
<view class="sub-text-28 color-red! indent-2em">
|
||||
<text>小程序只提供给有"浦江县机关事务智慧后勤一体化平台"账号的用户使用,其余用户不能使用该小程序</text>
|
||||
</view>
|
||||
<view class="h-10rpx" />
|
||||
<affirm-check v-model="state.isAgree" />
|
||||
</view>
|
||||
<affirm-modal ref="affirmModalRef" @confirm="affirmModalConfirm" />
|
||||
</crud-layout>
|
||||
</template>
|
||||
|
||||
@@ -1,24 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from "vue"
|
||||
import { onLoad, onShow } from "@dcloudio/uni-app"
|
||||
import { reactive } from "vue"
|
||||
import { onLoad } from "@dcloudio/uni-app"
|
||||
|
||||
const state = reactive({
|
||||
url: ""
|
||||
url: "",
|
||||
})
|
||||
|
||||
onLoad((options: any) => {
|
||||
if (options.url) {
|
||||
state.url = decodeURIComponent(options.url)
|
||||
// 只记录地址结构,不输出 token,便于定位微信 WebView 域名校验问题。
|
||||
try {
|
||||
const url = new URL(state.url)
|
||||
console.log("[web-view] 加载地址:", {
|
||||
origin: url.origin,
|
||||
pathname: url.pathname,
|
||||
queryKeys: Array.from(url.searchParams.keys()),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("[web-view] 地址解析失败:", error)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const handleWebViewLoad = (event: any) => {
|
||||
console.log("[web-view] 加载完成:", event?.detail?.src || state.url)
|
||||
}
|
||||
|
||||
const handleWebViewError = (event: any) => {
|
||||
console.error("[web-view] 加载失败:", {
|
||||
url: state.url,
|
||||
detail: event?.detail,
|
||||
})
|
||||
uni.showToast({ title: "第三方页面加载失败,请检查业务域名配置", icon: "none" })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<crud-layout mainIsScroll tabBarType="just-flot-back">
|
||||
<web-view :update-title="false" :fullscreen="false" :webview-styles="{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: '#fff'
|
||||
}" :src="state.url" />
|
||||
<web-view class="mall-web-view" :update-title="false" :fullscreen="false" @load="handleWebViewLoad"
|
||||
@error="handleWebViewError" :webview-styles="{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: '#fff'
|
||||
}" :src="state.url" />
|
||||
</crud-layout>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mall-web-view {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -22,6 +22,9 @@ onLoad(() => {
|
||||
})
|
||||
|
||||
onShow(async () => {
|
||||
console.log("systemInfoSync:", uni.getSystemInfoSync());
|
||||
console.log("menuButtonBoundingClientRect:", uni.getMenuButtonBoundingClientRect());
|
||||
|
||||
if (pageRef.value) {
|
||||
pageRef.value.reload(false)
|
||||
}
|
||||
|
||||
@@ -42,36 +42,36 @@ const todoCount = ref(0)
|
||||
const crudLayoutRef = ref<any>(null)
|
||||
|
||||
const services = [
|
||||
{
|
||||
name: "todo",
|
||||
label: "工作待办",
|
||||
icon: "/static/icon/todo.png",
|
||||
url: "/pages/integrated/todo/index",
|
||||
},
|
||||
{
|
||||
name: "meeting-booking",
|
||||
label: "会议预定",
|
||||
icon: "/static/icon/meeting-booking.png",
|
||||
url: "/pages/integrated/meeting/index",
|
||||
},
|
||||
{
|
||||
name: "lost-and-found",
|
||||
label: "失物招领",
|
||||
icon: "/static/icon/service_1.png",
|
||||
url: "/pages/integrated/lostAndFound/index",
|
||||
},
|
||||
// {
|
||||
// name: "todo",
|
||||
// label: "工作待办",
|
||||
// icon: "/static/icon/todo.png",
|
||||
// url: "/pages/integrated/todo/index",
|
||||
// },
|
||||
// {
|
||||
// name: "meeting-booking",
|
||||
// label: "会议预定",
|
||||
// icon: "/static/icon/meeting-booking.png",
|
||||
// url: "/pages/integrated/meeting/index",
|
||||
// },
|
||||
// {
|
||||
// name: "lost-and-found",
|
||||
// label: "失物招领",
|
||||
// icon: "/static/icon/service_1.png",
|
||||
// url: "/pages/integrated/lostAndFound/index",
|
||||
// },
|
||||
{
|
||||
name: "feedback",
|
||||
label: "意见反馈",
|
||||
icon: "/static/icon/feedback.png",
|
||||
url: "/pages/integrated/feedback/index",
|
||||
},
|
||||
{
|
||||
name: "repair-report",
|
||||
label: "物业维修",
|
||||
icon: "/static/icon/service_2.png",
|
||||
url: "/pages/integrated/repair/index",
|
||||
},
|
||||
// {
|
||||
// name: "repair-report",
|
||||
// label: "物业维修",
|
||||
// icon: "/static/icon/service_2.png",
|
||||
// url: "/pages/integrated/repair/index",
|
||||
// },
|
||||
{
|
||||
name: "notice",
|
||||
label: "通知公告",
|
||||
|
||||
@@ -82,6 +82,7 @@ import { isEmpty } from "radash"
|
||||
import dayjs from "dayjs"
|
||||
import { filePath } from "@@/utils/file"
|
||||
import { getAppInfo } from "@@/utils/app"
|
||||
import { normalizeTokenStorage } from "@@/utils/request"
|
||||
import * as hooks from "@@/hooks"
|
||||
import * as router from "@@/router"
|
||||
import * as constant from "@@/constant"
|
||||
@@ -101,7 +102,7 @@ const state = reactive<any>({
|
||||
{ text: "我参加的活动", icon: "/static/icon/act.png", path: "/pages/merchant/my-act-list" },
|
||||
{ text: "我的钱包", icon: "/static/icon/service_3.png", path: "/pages/wallet/list" },
|
||||
{ text: "我的人脸", icon: "/static/icon/service_5.png", path: "/pages/human-face/list" },
|
||||
{ text: "车辆管理", icon: "/static/icon/service_6.png", path: "/pages/car/user-car/list" },
|
||||
// { text: "车辆管理", icon: "/static/icon/service_6.png", path: "/pages/car/user-car/list" },
|
||||
{ text: "清除缓存", icon: "/static/icon/clear-storage.png", path: "" },
|
||||
],
|
||||
appInfo: "",
|
||||
@@ -136,7 +137,11 @@ const storage = {
|
||||
|
||||
// 保存token到存储
|
||||
saveToken: (tokenData: any) => {
|
||||
uni.setStorageSync(STORAGE_KEYS.TOKEN, tokenData)
|
||||
// SSO 接口可能返回嵌套 token,统一落成请求层使用的扁平结构。
|
||||
const normalizedToken = normalizeTokenStorage(tokenData)
|
||||
if (!normalizedToken) return false
|
||||
uni.setStorageSync(STORAGE_KEYS.TOKEN, normalizedToken)
|
||||
return true
|
||||
},
|
||||
|
||||
// 清除用户登录相关的存储
|
||||
@@ -202,7 +207,11 @@ const login = {
|
||||
const response = await userApi.authsh({ accountId, sessionId })
|
||||
|
||||
if (response.code == 0) {
|
||||
storage.saveToken({ accessToken: response.data.accessToken })
|
||||
if (!storage.saveToken(response.data)) {
|
||||
uni.hideLoading()
|
||||
uni.showToast({ title: "登录返回数据异常", icon: "none" })
|
||||
return
|
||||
}
|
||||
uni.hideLoading()
|
||||
uni.showToast({ title: "登录成功", icon: "none" })
|
||||
|
||||
@@ -226,7 +235,10 @@ const login = {
|
||||
const res = await userApi.auth(token)
|
||||
|
||||
if (res.code == 0) {
|
||||
storage.saveToken(res.data)
|
||||
if (!storage.saveToken(res.data)) {
|
||||
uni.showToast({ title: "登录返回数据异常", icon: "none" })
|
||||
return
|
||||
}
|
||||
uni.hideLoading()
|
||||
uni.showToast({ title: "登录成功", icon: "none" })
|
||||
|
||||
|
||||
@@ -123,7 +123,14 @@ const doRefund = (v: any) => {
|
||||
class="second-text-30 mr-16rpx">
|
||||
共 {{ v.carList.length }} 件
|
||||
</text>
|
||||
<text class="main-text-30 color-red!">¥ {{ hooks.useMoney(v.totalPrice) }}</text>
|
||||
<view class="flex-col items-center">
|
||||
<text v-if="v.couponPrice || v.extraPrice" class="main-text-26 mr-10rpx line-through">
|
||||
¥{{ hooks.useMoney(v.totalPrice) }}
|
||||
</text>
|
||||
<text class="main-text-30 color-red!">
|
||||
¥{{ hooks.useMoney(v.payPrice) }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -27,6 +27,58 @@ import { filePath } from "@@/utils/file"
|
||||
|
||||
const show = ref(false)
|
||||
|
||||
function normalizeMallUrl(rawUrl: string) {
|
||||
const matched = rawUrl.match(/^(https:\/\/(?:[a-z0-9-]+\.)*nbcb\.com\.cn)(\/[^?#]*)?(\?[^#]*)?(#.*)?$/i)
|
||||
if (!matched) throw new Error("商城登录地址不合法")
|
||||
|
||||
const [, origin, rawPathname = "/", search = "", hash = ""] = matched
|
||||
const queryItems = search.slice(1).split("&").filter(Boolean)
|
||||
const decodeQueryPart = (value: string) => {
|
||||
try {
|
||||
return decodeURIComponent(value)
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
const queryKeys = queryItems.map((item) => decodeQueryPart(item.split("=")[0] || ""))
|
||||
const hasToken = queryItems.some((item) => {
|
||||
const [key, ...valueParts] = item.split("=")
|
||||
return decodeQueryPart(key || "").toLowerCase() === "token" && valueParts.join("=")
|
||||
})
|
||||
let pathname = rawPathname
|
||||
|
||||
// 宁波银行正常登录页是 /h5/thirdApp;部分环境会错误返回带 token 的根路径。
|
||||
if (pathname === "/" && hasToken) {
|
||||
pathname = "/h5/thirdApp"
|
||||
}
|
||||
|
||||
console.log("[happyMall] 商城登录地址:", {
|
||||
origin,
|
||||
pathname,
|
||||
queryKeys,
|
||||
})
|
||||
|
||||
if (pathname === "/" || !hasToken) {
|
||||
throw new Error("商城登录地址异常,请联系管理员")
|
||||
}
|
||||
|
||||
// 宁波银行 H5 通过 platform 判断是否运行在微信小程序中。设置为 XR 后,
|
||||
// H5 左上角返回按钮会调用 wx.miniProgram.navigateBack(),直接返回小程序上一页,
|
||||
// 不会先消耗宁波银行 H5 自己的 history。覆盖旧值,避免后端返回其他平台参数。
|
||||
const queryWithoutPlatform = queryItems.filter((item) => {
|
||||
const key = decodeQueryPart(item.split("=")[0] || "").toLowerCase()
|
||||
return key !== "platform"
|
||||
})
|
||||
queryWithoutPlatform.push("platform=XR")
|
||||
const normalizedSearch = `?${queryWithoutPlatform.join("&")}`
|
||||
console.log("[happyMall] 跳转平台:", {
|
||||
platform: "XR",
|
||||
queryKeys: queryWithoutPlatform.map((item) => decodeQueryPart(item.split("=")[0] || "")),
|
||||
})
|
||||
|
||||
return `${origin}${pathname}${normalizedSearch}${hash}`
|
||||
}
|
||||
|
||||
onLoad(async () => {
|
||||
const isLogin = await hooks.useCheckLogin()
|
||||
if (isLogin) {
|
||||
@@ -42,10 +94,11 @@ const thirdAppJump = async () => {
|
||||
if (res.code == 0) {
|
||||
show.value = false
|
||||
if (!res.data) return uni.showToast({ title: "url错误", icon: "none" })
|
||||
router.jump("/pages/root/web-view", { url: encodeURIComponent(res.data) }, 2)
|
||||
const mallUrl = normalizeMallUrl(res.data)
|
||||
router.jump("/pages/root/web-view", { url: encodeURIComponent(mallUrl) }, 2)
|
||||
}
|
||||
} catch (error: any) {
|
||||
uni.showToast({ title: error, icon: "none" })
|
||||
uni.showToast({ title: error?.message || String(error), icon: "none" })
|
||||
} finally {
|
||||
uni.hideLoading()
|
||||
}
|
||||
|
||||
@@ -19,4 +19,13 @@ export default {
|
||||
method: "GET",
|
||||
params,
|
||||
}),
|
||||
/**
|
||||
* 用户领取核销券列表
|
||||
*/
|
||||
mchList: (data: any) =>
|
||||
request({
|
||||
url: "/wallet/act-coupon-record/app/mch-list",
|
||||
method: "POST",
|
||||
data,
|
||||
}),
|
||||
}
|
||||
@@ -1,6 +1,16 @@
|
||||
import request from "@/sheep/utils/request"
|
||||
|
||||
export default {
|
||||
// ========== 轮播图相关接口 ==========
|
||||
/**
|
||||
* 获取轮播图列表
|
||||
*/
|
||||
getSwiperList: () =>
|
||||
request({
|
||||
url: "/integrated/sysBanner/app/slides",
|
||||
method: "GET",
|
||||
}),
|
||||
|
||||
// 公告分页列表
|
||||
getNoticePage: (params: { current: number; size: number }) =>
|
||||
request({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import base64 from "base64-utf8"
|
||||
|
||||
import request from "@/sheep/utils/request"
|
||||
import { encryption } from "../utils/encryption"
|
||||
|
||||
// 将 Basic Auth 字符串提取为常量
|
||||
const BASIC_AUTH = "Basic " + base64.encode(import.meta.env.VITE_OAUTH2_APP_CLIENT)
|
||||
@@ -48,21 +49,31 @@ export default {
|
||||
// 账号密码登录
|
||||
login: (params: any) => {
|
||||
const { username, password, randomStr, code, grant_type, scope } = params
|
||||
const encKey = import.meta.env.VITE_PWD_ENC_KEY
|
||||
// 未配置密钥时只能明文(需与 auth 客户端 encFlag 一致);或显式 VITE_OAUTH_PWD_PLAIN=true
|
||||
const plainPwd = import.meta.env.VITE_OAUTH_PWD_PLAIN === "true" || !encKey
|
||||
const pwdForWire = plainPwd ? password : encryption(password, encKey)
|
||||
const query: Record<string, string> = {
|
||||
username: username ?? "",
|
||||
grant_type: grant_type ?? "",
|
||||
scope: scope ?? "",
|
||||
}
|
||||
if (randomStr != null && String(randomStr) !== "") {
|
||||
query.randomStr = String(randomStr)
|
||||
}
|
||||
if (code != null && String(code) !== "") {
|
||||
query.code = String(code)
|
||||
}
|
||||
return request({
|
||||
url: `/auth/oauth2/token`,
|
||||
method: "POST",
|
||||
params: {
|
||||
username,
|
||||
randomStr,
|
||||
code,
|
||||
grant_type,
|
||||
scope,
|
||||
},
|
||||
data: { password },
|
||||
params: query,
|
||||
data: `password=${encodeURIComponent(pwdForWire)}`,
|
||||
// data: `password=`+password,
|
||||
header: {
|
||||
skipToken: true,
|
||||
Authorization: BASIC_AUTH,
|
||||
"content-type": "application/x-www-form-urlencoded",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
})
|
||||
},
|
||||
@@ -112,7 +123,7 @@ export default {
|
||||
}),
|
||||
|
||||
// 设备认证
|
||||
verifyDevice: (data: { mobilePhone: string; code: string, uniqueCode?: string, deviceJson?: string }) =>
|
||||
verifyDevice: (data: { mobilePhone: string; code: string; deviceUniqueCode?: string }) =>
|
||||
request({
|
||||
url: "/thirdparty/thirdPartyOAth2/mini-app/verify-device",
|
||||
method: "POST",
|
||||
@@ -120,7 +131,7 @@ export default {
|
||||
}),
|
||||
|
||||
// 判断该设备是否被验证
|
||||
isVerifyDevice: (data: { mobile: string; userId: string, uniqueCode?: string, deviceJson?: string }) =>
|
||||
isVerifyDevice: (data: { mobile: string; userId: string; uniqueCode?: string; deviceUniqueCode?: string }) =>
|
||||
request({
|
||||
url: "/thirdparty/thirdPartyOAth2/is-verify-device",
|
||||
method: "POST",
|
||||
|
||||
@@ -46,6 +46,7 @@ function getNavTop() {
|
||||
}
|
||||
|
||||
function doToDashboard() {
|
||||
tabbarStore.setRootActiveIndex(0)
|
||||
router.jump("/pages/root/dashboard", {}, 3)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -46,6 +46,7 @@ function doBackClick() {
|
||||
}
|
||||
|
||||
function doToDashboard() {
|
||||
tabbarStore.setRootActiveIndex(0)
|
||||
router.jump("/pages/root/dashboard", {}, 3)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -11,17 +11,29 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
})
|
||||
|
||||
function getHeight() {
|
||||
const { top, bottom } = uni.getSystemInfoSync().safeAreaInsets!
|
||||
let _statusBarHeight = 0;
|
||||
let _bottom = 0;
|
||||
const { statusBarHeight, safeAreaInsets } = uni.getSystemInfoSync()
|
||||
const { bottom = 0 } = safeAreaInsets ?? {}
|
||||
if (statusBarHeight) {
|
||||
_statusBarHeight = statusBarHeight
|
||||
}
|
||||
if (_statusBarHeight <= 20) {
|
||||
_statusBarHeight = 20
|
||||
}
|
||||
if (bottom) {
|
||||
_bottom = bottom
|
||||
}
|
||||
if (_bottom <= 8) {
|
||||
_bottom = 8
|
||||
}
|
||||
if (props.type && props.type === "bottom") {
|
||||
return bottom + "px"
|
||||
return _bottom + "px"
|
||||
}
|
||||
if (props.type && props.type === "top") {
|
||||
if (top < 22) {
|
||||
return "22px"
|
||||
}
|
||||
return top + "px"
|
||||
return _statusBarHeight + "px"
|
||||
}
|
||||
return 22 + "px"
|
||||
return 20 + "px"
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { router, hooks, constant } from "@@/index"
|
||||
import { useTabbarStore } from "@@/stores/tabbar"
|
||||
import { jump } from "@/sheep/router";
|
||||
import { userApi } from "@@/api/index"
|
||||
import { buildUserDeviceFingerprint } from "@@/utils/device-unique"
|
||||
|
||||
import VerifyDeviceImpModel from "../../verify-device-imp-model.vue"
|
||||
|
||||
@@ -92,6 +93,10 @@ function initBackStyle() {
|
||||
function tabbarChange(index: number) {
|
||||
const target = tabbarStore.tabbarList[index]
|
||||
if (!target) return
|
||||
if (target.pagePath === "/pages/root/dashboard") {
|
||||
// 从业务导航返回工作台时,让首页根 Tabbar 同步选中“首页”。
|
||||
tabbarStore.setRootActiveIndex(0)
|
||||
}
|
||||
jump(target.pagePath, {}, 3)
|
||||
}
|
||||
|
||||
@@ -133,9 +138,11 @@ async function checkVerifyDevice() {
|
||||
if (constant.DEVICE_VERIFY_WRITE_LIST.includes(userInfo.sysUser.phone)) {
|
||||
return
|
||||
}
|
||||
const fingerprint = await buildUserDeviceFingerprint(true)
|
||||
const _params = {
|
||||
mobile: userInfo.sysUser.phone,
|
||||
userId: userInfo.sysUser.userId,
|
||||
deviceUniqueCode: fingerprint?.fingerprint,
|
||||
}
|
||||
try {
|
||||
const { data, code }: any = await userApi.isVerifyDevice(_params)
|
||||
@@ -182,12 +189,14 @@ defineExpose({
|
||||
<slot name="main-bottom"></slot>
|
||||
</view>
|
||||
</view>
|
||||
<view class="layout_foot">
|
||||
<tm-tabbar v-if="tabbarStore.isTabbar && tabbarStore.rootActiveIndex != 2" class="layout_foot"
|
||||
:activeIndex="tabbarStore.activeIndex" :list="tabbarStore.pageTabbarList" :outIndex="-1" fontSize="12px"
|
||||
iconSize="24px" bgColor="#F6F5FA" :zIndex="99999" darkBgColor="#F6F5FA" borderColor="#DBDDDE"
|
||||
color="#8E8E8E" selected-color="#0094ff" position="fixed" @change="tabbarChange" />
|
||||
<tm-tabbar v-else class="layout_foot" :activeIndex="tabbarStore.rootActiveIndex"
|
||||
<view v-if="tabbarStore.isTabbar && tabbarStore.rootActiveIndex != 2" class="layout_foot">
|
||||
<tm-tabbar class="layout_foot" :activeIndex="tabbarStore.activeIndex" :list="tabbarStore.pageTabbarList"
|
||||
:outIndex="-1" fontSize="12px" iconSize="24px" bgColor="#F6F5FA" :zIndex="99999" darkBgColor="#F6F5FA"
|
||||
borderColor="#DBDDDE" color="#8E8E8E" selected-color="#0094ff" position="fixed"
|
||||
@change="tabbarChange" />
|
||||
</view>
|
||||
<view class="layout_foot" v-else-if="tabbarStore.isDashboardPage">
|
||||
<tm-tabbar class="layout_foot" :activeIndex="tabbarStore.rootActiveIndex"
|
||||
:list="tabbarStore.rootPageTabbarList" :outIndex="1" fontSize="12px" iconSize="24px" bgColor="#F6F5FA"
|
||||
:zIndex="99999" darkBgColor="#F6F5FA" borderColor="#DBDDDE" color="#8E8E8E" outBgColor="#0094ff"
|
||||
selected-color="#0094ff" position="fixed" @change="rootTabbarChange" />
|
||||
|
||||
@@ -1,33 +1,35 @@
|
||||
<template>
|
||||
<view class="bg-[#f5f5f5] px-8rpx py-16rpx rounded-4 fixed bottom-0 left-0 right-0 w-full">
|
||||
<view class="flex justify-end">
|
||||
<image v-if="show" class="w-[50rpx] h-[50rpx] mx-16rpx mb-8rpx" src="@/static/page/fold.svg"
|
||||
@click="show = !show" />
|
||||
<image v-if="!show" class="w-[50rpx] h-[50rpx] mx-16rpx mb-8rpx" src="@/static/page/unfold.svg"
|
||||
@click="show = !show" />
|
||||
</view>
|
||||
<view v-if="show" class="flex mb-20rpx" v-for="(row, rowIndex) in keyboardLayout" :key="rowIndex">
|
||||
<view
|
||||
class="flex-1 h-[90rpx] flex items-center justify-center bg-white text-2xl text-[#333] mx-[5px] rounded-lg cursor-pointer select-none active:bg-[#f0f0f0]"
|
||||
v-for="(key, keyIndex) in row" :key="keyIndex" @click="handleKeyPress(key.toString())">
|
||||
{{ key }}
|
||||
<view class="keyboard-wrap">
|
||||
<view class="keyboard-panel" :class="{ 'keyboard-panel--open': show }">
|
||||
<view class="keyboard-toggle" @tap.stop="toggleShow">
|
||||
<image class="keyboard-toggle__icon"
|
||||
:src="show ? '/static/page/fold.svg' : '/static/page/unfold.svg'" />
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="show" class="flex mb-0rpx">
|
||||
<view
|
||||
class="flex-1 h-[90rpx] flex items-center justify-center bg-white text-2xl text-[#333] mx-[5px] rounded-lg cursor-pointer select-none active:bg-[#f0f0f0]"
|
||||
@click="handleKeyPress('.')">
|
||||
.
|
||||
</view>
|
||||
<view
|
||||
class="flex-auto h-[90rpx] flex items-center justify-center bg-white text-2xl text-[#333] mx-[5px] rounded-lg cursor-pointer select-none active:bg-[#f0f0f0]"
|
||||
@click="handleKeyPress('0')">
|
||||
0
|
||||
</view>
|
||||
<view
|
||||
class="flex-1 h-[90rpx] flex items-center justify-center bg-white mx-[5px] rounded-lg cursor-pointer select-none active:bg-[#f0f0f0]"
|
||||
@click="handleDelete">
|
||||
<image class="size-45rpx" src="@/static/page/keyboard-delete.svg" />
|
||||
<view v-show="show" class="keyboard-content">
|
||||
<view class="flex mb-20rpx" v-for="(row, rowIndex) in keyboardLayout" :key="rowIndex">
|
||||
<view
|
||||
class="flex-1 h-[90rpx] flex items-center justify-center bg-white text-2xl text-[#333] mx-[5px] rounded-lg cursor-pointer select-none active:bg-[#f0f0f0]"
|
||||
v-for="(key, keyIndex) in row" :key="keyIndex" @tap="handleKeyPress(key.toString())">
|
||||
{{ key }}
|
||||
</view>
|
||||
</view>
|
||||
<view class="flex mb-0rpx">
|
||||
<view
|
||||
class="flex-1 h-[90rpx] flex items-center justify-center bg-white text-2xl text-[#333] mx-[5px] rounded-lg cursor-pointer select-none active:bg-[#f0f0f0]"
|
||||
@tap="handleKeyPress('.')">
|
||||
.
|
||||
</view>
|
||||
<view
|
||||
class="flex-auto h-[90rpx] flex items-center justify-center bg-white text-2xl text-[#333] mx-[5px] rounded-lg cursor-pointer select-none active:bg-[#f0f0f0]"
|
||||
@tap="handleKeyPress('0')">
|
||||
0
|
||||
</view>
|
||||
<view
|
||||
class="flex-1 h-[90rpx] flex items-center justify-center bg-white mx-[5px] rounded-lg cursor-pointer select-none active:bg-[#f0f0f0]"
|
||||
@tap="handleDelete">
|
||||
<image class="size-45rpx" src="@/static/page/keyboard-delete.svg" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -45,6 +47,10 @@ const keyboardLayout = ref([
|
||||
])
|
||||
const show = ref(true)
|
||||
|
||||
const toggleShow = () => {
|
||||
show.value = !show.value
|
||||
}
|
||||
|
||||
// 按下键盘
|
||||
const handleKeyPress = (key) => {
|
||||
if (key === "." && amount.value.includes(".")) return
|
||||
@@ -74,3 +80,58 @@ const handleDelete = () => {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.keyboard-wrap {
|
||||
--keyboard-toggle-size: 82rpx;
|
||||
--keyboard-expanded-height: 538rpx;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
z-index: 1000001;
|
||||
}
|
||||
|
||||
.keyboard-panel {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: calc(var(--keyboard-expanded-height) - var(--keyboard-toggle-size));
|
||||
width: 82rpx;
|
||||
padding: 12rpx 0 12rpx 12rpx;
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
pointer-events: auto;
|
||||
transition: width 0.2s ease, padding 0.2s ease, background-color 0.2s ease, bottom 0.2s ease;
|
||||
|
||||
&--open {
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
padding: 12rpx;
|
||||
background: #f5f5f5;
|
||||
border-radius: 24rpx 24rpx 0 0;
|
||||
box-shadow: 0 0 24rpx rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.keyboard-toggle {
|
||||
width: 82rpx;
|
||||
height: 82rpx;
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f5f5f5;
|
||||
border-radius: 24rpx 0 0 24rpx;
|
||||
box-shadow: 0 0 24rpx rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.keyboard-toggle__icon {
|
||||
width: 50rpx;
|
||||
height: 50rpx;
|
||||
}
|
||||
|
||||
.keyboard-content {
|
||||
margin-top: 12rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -31,6 +31,7 @@ import { ref, reactive, watch, onUnmounted } from "vue"
|
||||
import { constant } from "@@/index"
|
||||
import { userApi } from "@@/api"
|
||||
import { getMiniUniqueCode, getDeviceKey } from "@@/utils/mini-tool"
|
||||
import { buildUserDeviceFingerprint } from "@@/utils/device-unique"
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -109,9 +110,11 @@ const doSubmit = async () => {
|
||||
if (!isPass) return
|
||||
|
||||
uni.showLoading({ title: "验证中...", mask: true })
|
||||
const fingerprint = await buildUserDeviceFingerprint(false)
|
||||
const params = {
|
||||
mobilePhone: form.mobilePhone,
|
||||
code: form.code,
|
||||
deviceUniqueCode: fingerprint?.fingerprint,
|
||||
}
|
||||
const res: any = await userApi.verifyDevice(params)
|
||||
if (res.code != 0) {
|
||||
|
||||
@@ -76,6 +76,7 @@ const actUserReceiveEnum = {
|
||||
"4": "已领取,核销码在我的礼券中",
|
||||
"5": "用户不能参加此活动",
|
||||
"6": "其他错误",
|
||||
"7": "已领完",
|
||||
"99": "用户可以参加活动",
|
||||
// 特殊
|
||||
"-100": "仅限生日当月使用",
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { router } from ".."
|
||||
import { getUrlParamsObj } from "../utils/app"
|
||||
import { getUrlParamsObj, isDevEnv } from "../utils/app"
|
||||
|
||||
const normalizeScanResult = (scanResult: string) => scanResult.replace("https://stgl.pj.gov.cn", "https://www.pjzhhq.cn")
|
||||
|
||||
export function useScanCode(actModalShow) {
|
||||
async function scanCodeAction(): Promise<string | undefined> {
|
||||
if (isDevEnv()) {
|
||||
const testQrCode = "https://stgl.pj.gov.cn/h5/#/pages/pay/scanPay?id=1931916121241079810"
|
||||
uni.showModal({ content: "开发环境不是浙政钉和诗画环境,扫码仅供测试" })
|
||||
return normalizeScanResult(testQrCode)
|
||||
}
|
||||
const scanResult = await new Promise<string>((resolve, reject) => {
|
||||
uni.scanCode({
|
||||
success: (res) => {
|
||||
@@ -15,7 +22,7 @@ export function useScanCode(actModalShow) {
|
||||
},
|
||||
})
|
||||
})
|
||||
return scanResult
|
||||
return normalizeScanResult(scanResult || "")
|
||||
}
|
||||
|
||||
// 处理url扫码结果
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import storageConstant from "../constant/storage-constant"
|
||||
import { jump } from "../router/index"
|
||||
import { isDevEnv } from "../utils/app"
|
||||
|
||||
export const useToken = () => {
|
||||
const tokenKey = storageConstant.TOKEN
|
||||
const tokenStorage = uni.getStorageSync(tokenKey)
|
||||
return tokenStorage?.access_token ? `Bearer ${tokenStorage.access_token}` : ""
|
||||
// 登录与刷新接口历史上存在两种字段格式,上传等原生请求也必须读取同一个有效 token。
|
||||
const rawToken = tokenStorage?.accessToken ?? tokenStorage?.access_token
|
||||
const tokenValue = typeof rawToken === "string" ? rawToken : rawToken?.tokenValue
|
||||
return tokenValue ? `Bearer ${tokenValue}` : ""
|
||||
}
|
||||
|
||||
export const useDefaultHeader = () => {
|
||||
@@ -18,17 +20,12 @@ export const useDefaultHeader = () => {
|
||||
}
|
||||
|
||||
export const useStaticCheckLogin = () => {
|
||||
const tokenKey = storageConstant.TOKEN
|
||||
const tokenStorage = uni.getStorageSync(tokenKey)
|
||||
if (tokenStorage) return true
|
||||
return false
|
||||
// 不能只判断缓存对象存在,刷新失败留下的空对象不代表已登录。
|
||||
return Boolean(useToken())
|
||||
}
|
||||
|
||||
export const useCheckLogin = async (): Promise<boolean> => {
|
||||
const tokenKey = storageConstant.TOKEN
|
||||
const tokenStorage = uni.getStorageSync(tokenKey)
|
||||
|
||||
if (tokenStorage) return true
|
||||
if (useToken()) return true
|
||||
uni.showModal({
|
||||
title: "提示",
|
||||
content: "您还未登录,请先登录",
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
import { throttle } from "radash"
|
||||
|
||||
function normalizePath(path = "") {
|
||||
if (!path) return ""
|
||||
const purePath = path.split("?")[0] || ""
|
||||
if (!purePath) return ""
|
||||
return purePath.startsWith("/") ? purePath : `/${purePath}`
|
||||
}
|
||||
|
||||
function getCurrentPagePath() {
|
||||
const currentPage = getCurrentPage()
|
||||
return normalizePath(currentPage?.route || "")
|
||||
}
|
||||
|
||||
function paramsToQuery(params: { [key: string]: string }) {
|
||||
let query = []
|
||||
for (let key in params) {
|
||||
@@ -14,6 +26,10 @@ function _jump(path = "", params = {}, type = 1) {
|
||||
if (!path) {
|
||||
console.error("router::jump>>path 不能为空")
|
||||
}
|
||||
const targetPath = normalizePath(path)
|
||||
if (targetPath && getCurrentPagePath() === targetPath) {
|
||||
return
|
||||
}
|
||||
if (hasTabbar(path)) {
|
||||
uni.switchTab({ url: path })
|
||||
return
|
||||
|
||||
@@ -118,16 +118,62 @@ const TABBAR_CONSTANT: Array<TabbarItem> = [
|
||||
launchParam: ["integrated"],
|
||||
auth: ["user"],
|
||||
},
|
||||
{
|
||||
pagePath: "/pages/integrated/notice/index",
|
||||
text: "通知公告",
|
||||
iconPath: "/static/tabbar/notice.png",
|
||||
selectedIconPath: "/static/tabbar/notice-select.png",
|
||||
launchParam: ["notice"],
|
||||
auth: ["user"],
|
||||
},
|
||||
{
|
||||
pagePath: "/pages/integrated/feedback/index",
|
||||
text: "意见反馈",
|
||||
iconPath: "/static/tabbar/feedback.png",
|
||||
selectedIconPath: "/static/tabbar/feedback-select.png",
|
||||
launchParam: ["feedback"],
|
||||
auth: ["user"],
|
||||
},
|
||||
{
|
||||
pagePath: "/pages/tabbar/my",
|
||||
text: "我的",
|
||||
iconPath: "/static/tabbar/my.png",
|
||||
selectedIconPath: "/static/tabbar/my-select.png",
|
||||
launchParam: ["canteen", "selfMerchant", "onlineShop", "coopMerchant", "integrated", "prosperityShop", "prosperityShopMchList"],
|
||||
launchParam: [
|
||||
"canteen",
|
||||
"selfMerchant",
|
||||
"onlineShop",
|
||||
"coopMerchant",
|
||||
"integrated",
|
||||
"prosperityShop",
|
||||
"prosperityShopMchList",
|
||||
"notice",
|
||||
"feedback",
|
||||
],
|
||||
auth: ["user", "merchant", "doorAdmin"],
|
||||
},
|
||||
]
|
||||
|
||||
const WORKBENCH_TABBAR_ITEM: TabbarItem = {
|
||||
pagePath: "/pages/root/dashboard",
|
||||
text: "工作台",
|
||||
iconPath: "/static/tabbar/home.png",
|
||||
selectedIconPath: "/static/tabbar/home-select.png",
|
||||
launchParam: [],
|
||||
auth: [],
|
||||
}
|
||||
|
||||
function getTabbarLaunchParam(url?: string) {
|
||||
const launchParam = getLaunchParam(url)
|
||||
if (launchParam) return launchParam
|
||||
if (!url) return ""
|
||||
|
||||
// 常用功能也可能直接配置 Tabbar 页面路径,需要由页面反推出所属业务场景。
|
||||
const pagePath = url.split("?")[0]
|
||||
const tabbarItem = TABBAR_CONSTANT.find((item) => item.pagePath === pagePath)
|
||||
return tabbarItem?.launchParam[0] || ""
|
||||
}
|
||||
|
||||
const defaultRootTabbarList: Array<TabbarItem> = [
|
||||
{
|
||||
pagePath: "/pages/root/dashboard",
|
||||
@@ -165,11 +211,18 @@ export const useTabbarStore = defineStore("tabbarStore", {
|
||||
currentRoute: "/pages/root/dashboard",
|
||||
}),
|
||||
getters: {
|
||||
isDashboardPage(state) {
|
||||
const currentRoute = state.currentRoute.startsWith("/") ? state.currentRoute : `/${state.currentRoute}`
|
||||
return currentRoute === "/pages/root/dashboard" || currentRoute === "/pages/tabbar/my"
|
||||
},
|
||||
isTabbar(state) {
|
||||
return state.tabbarList.some((item) => item.pagePath === `/${state.currentRoute}`)
|
||||
const currentRoute = state.currentRoute.startsWith("/") ? state.currentRoute : `/${state.currentRoute}`
|
||||
// 工作台只作为业务 Tabbar 的返回入口,进入页面后仍展示首页自己的根 Tabbar。
|
||||
return currentRoute !== WORKBENCH_TABBAR_ITEM.pagePath && state.tabbarList.some((item) => item.pagePath === currentRoute)
|
||||
},
|
||||
activeIndex(state) {
|
||||
return state.tabbarList.findIndex((item) => item.pagePath === `/${state.currentRoute}`)
|
||||
const currentRoute = state.currentRoute.startsWith("/") ? state.currentRoute : `/${state.currentRoute}`
|
||||
return state.tabbarList.findIndex((item) => item.pagePath === currentRoute)
|
||||
},
|
||||
pageTabbarList(state) {
|
||||
return state.tabbarList.map((item) => {
|
||||
@@ -193,22 +246,25 @@ export const useTabbarStore = defineStore("tabbarStore", {
|
||||
},
|
||||
actions: {
|
||||
async componetTabbarList(launchParam?: string) {
|
||||
let _launchParam = ""
|
||||
if (launchParam) {
|
||||
_launchParam = getLaunchParam(launchParam)
|
||||
} else {
|
||||
_launchParam = getLaunchParam()
|
||||
}
|
||||
if (this.launchParam === _launchParam) return
|
||||
const _launchParam = getTabbarLaunchParam(launchParam)
|
||||
const hasMyTabbar = this.tabbarList.some((item) => item.pagePath === "/pages/tabbar/my")
|
||||
const hasWorkbenchTabbar = this.tabbarList.some((item) => item.pagePath === WORKBENCH_TABBAR_ITEM.pagePath)
|
||||
// 热更新或旧状态中可能已有相同场景但尚未加入工作台,此时仍需重新组装。
|
||||
if (this.launchParam === _launchParam && (!hasMyTabbar || hasWorkbenchTabbar)) return
|
||||
const appInfo = await getAppInfo()
|
||||
if (!_launchParam || !appInfo) return
|
||||
const tabbarList: Array<TabbarItem> = []
|
||||
TABBAR_CONSTANT.forEach((item, index) => {
|
||||
TABBAR_CONSTANT.forEach((item) => {
|
||||
const visible = item.launchParam.includes(_launchParam)
|
||||
if (visible) {
|
||||
tabbarList.push(item)
|
||||
}
|
||||
})
|
||||
const myTabbarIndex = tabbarList.findIndex((item) => item.pagePath === "/pages/tabbar/my")
|
||||
if (myTabbarIndex !== -1) {
|
||||
// 仅对带“我的”的业务导航生效,并保证“工作台”紧邻其左侧。
|
||||
tabbarList.splice(myTabbarIndex, 0, WORKBENCH_TABBAR_ITEM)
|
||||
}
|
||||
this.tabbarList = tabbarList
|
||||
this.launchParam = _launchParam
|
||||
},
|
||||
@@ -217,7 +273,7 @@ export const useTabbarStore = defineStore("tabbarStore", {
|
||||
if (!firstTabbar) {
|
||||
return
|
||||
}
|
||||
jump(firstTabbar.pagePath, {}, 3)
|
||||
jump(firstTabbar.pagePath, {})
|
||||
},
|
||||
setCurrentRoute(route: string) {
|
||||
if (route === this.currentRoute) {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import CryptoJS from "crypto-js"
|
||||
import { constant } from "@@/index"
|
||||
|
||||
const INSTALL_ID_KEY = "DEVICE_INSTALL_ID"
|
||||
|
||||
export async function buildUserDeviceFingerprint(force?: boolean) {
|
||||
if (!force) {
|
||||
const cacheResult = uni.getStorageSync(INSTALL_ID_KEY)
|
||||
if (cacheResult) {
|
||||
return cacheResult
|
||||
}
|
||||
}
|
||||
const userInfo = uni.getStorageSync(constant.storageConstant.USERINFO)
|
||||
const systemInfo = await uni.getSystemInfo()
|
||||
const payload = {
|
||||
userId: userInfo.sysUser.userId || "",
|
||||
uniPlatform: systemInfo.uniPlatform || "",
|
||||
hostName: systemInfo.hostName || "",
|
||||
osName: systemInfo.osName || "",
|
||||
osVersion: systemInfo.osVersion || "",
|
||||
deviceModel: systemInfo.deviceModel || systemInfo.model || "",
|
||||
deviceType: systemInfo.deviceType || "",
|
||||
devicePixelRatio: String(systemInfo.devicePixelRatio || ""),
|
||||
pixelRatio: String(systemInfo.pixelRatio || ""),
|
||||
screenWidth: String(systemInfo.screenWidth || ""),
|
||||
screenHeight: String(systemInfo.screenHeight || ""),
|
||||
windowWidth: String(systemInfo.windowWidth || ""),
|
||||
windowHeight: String(systemInfo.windowHeight || ""),
|
||||
}
|
||||
const raw = JSON.stringify(payload)
|
||||
const fingerprint = CryptoJS.MD5(raw).toString()
|
||||
const result = {
|
||||
fingerprint,
|
||||
payload,
|
||||
}
|
||||
uni.setStorageSync(INSTALL_ID_KEY, result)
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import * as CryptoJS from "crypto-js"
|
||||
|
||||
/**
|
||||
*加密处理
|
||||
*/
|
||||
export function encryption(src: string, keyWord: string) {
|
||||
const key = CryptoJS.enc.Utf8.parse(keyWord)
|
||||
// 加密
|
||||
var encrypted = CryptoJS.AES.encrypt(src, key, {
|
||||
iv: key,
|
||||
mode: CryptoJS.mode.CFB,
|
||||
padding: CryptoJS.pad.NoPadding,
|
||||
})
|
||||
return encrypted.toString()
|
||||
}
|
||||
@@ -22,3 +22,29 @@ export const filePath = (path?: string | null) => {
|
||||
|
||||
return `${base}${ensureLeadingSlash(rawPath)}`
|
||||
}
|
||||
|
||||
export const parseHtml = (html: string) => {
|
||||
const origin = "https://stgl.pj.gov.cn"
|
||||
return html.replace(/<img\b[^>]*>/gi, (imgTag) => {
|
||||
let next = imgTag
|
||||
|
||||
next = next.replace(/\bsrc\s*=\s*(['"])(.*?)\1/i, (_m, quote: string, src: string) => {
|
||||
let resolved = src
|
||||
if (resolved?.startsWith("/api")) resolved = `${origin}${resolved}`
|
||||
if (resolved?.startsWith("//")) resolved = `https:${resolved}`
|
||||
return `src=${quote}${resolved}${quote}`
|
||||
})
|
||||
|
||||
const styleMatch = next.match(/\bstyle\s*=\s*(['"])([\s\S]*?)\1/i)
|
||||
const required = "max-width: 100%; height: auto; display: block;"
|
||||
if (styleMatch) {
|
||||
const quote = styleMatch[1]
|
||||
const styleValue = styleMatch[2] ?? ""
|
||||
if (styleValue.includes("max-width") || styleValue.includes("width")) return next
|
||||
const merged = styleValue.trim().length ? `${styleValue.replace(/;?\s*$/, "; ")}${required}` : required
|
||||
return next.replace(/\bstyle\s*=\s*(['"])([\s\S]*?)\1/i, `style=${quote}${merged}${quote}`)
|
||||
}
|
||||
|
||||
return next.replace(/<img\b/i, `<img style="${required}"`)
|
||||
})
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { isEmpty } from "radash"
|
||||
|
||||
import * as router from "@@/router"
|
||||
import * as constant from "@@/constant"
|
||||
import { createCooldown, getBaseApiUrl } from "@@/utils/app"
|
||||
import { getBaseApiUrl } from "@@/utils/app"
|
||||
|
||||
const TOKEN_KEY = constant.storageConstant.TOKEN
|
||||
const USER_INFO_KEY = constant.storageConstant.USERINFO
|
||||
@@ -12,14 +12,22 @@ const SKIP_AUTH_URLS = [
|
||||
"/thirdparty/thirdPartyOAth2",
|
||||
"/sys/auth/refresh-token",
|
||||
"/auth/oauth2/token",
|
||||
"/third/third-mini-app-oath/auto-admin-login",
|
||||
"/admin/user/forgot-password",
|
||||
"/admin/stats/access/track",
|
||||
]
|
||||
const SKIP_REFRESH_TOKEN = ["/sys/auth/refresh-token", "/auth/token/check_token"]
|
||||
const AUTH_EXPIRED_CODES = [401, 424]
|
||||
const REFRESH_AHEAD_MS = 60 * 1000
|
||||
|
||||
let refreshTokenLock = true
|
||||
const runLoginRedirect = createCooldown(1500)
|
||||
let refreshTokenPromise: Promise<GlobalSetting.TokenStorage | null> | null = null
|
||||
let loginExpiredHandled = false
|
||||
const BASIC_AUTH = "Basic " + base64.encode(import.meta.env.VITE_OAUTH2_APP_CLIENT)
|
||||
const buildErrorResponse = (error: any, fallbackMsg: string = "请求失败") => ({
|
||||
code: error?.statusCode || -1,
|
||||
data: error?.data ?? null,
|
||||
msg: error?.data?.msg || error?.errMsg || error?.message || fallbackMsg,
|
||||
})
|
||||
|
||||
const https = new Request({
|
||||
baseURL: getBaseApiUrl(),
|
||||
@@ -53,18 +61,31 @@ https.interceptors.request.use(async (config) => {
|
||||
// 判断是否需要设置认证头
|
||||
if (!SKIP_AUTH_URLS.some((url) => config.url?.startsWith(url))) {
|
||||
if (!isEmpty(tokenStorage)) {
|
||||
// token存在,检查是否过期
|
||||
if (isTokenExpires(tokenStorage) && !SKIP_REFRESH_TOKEN.some((url) => config.url?.startsWith(url)) && refreshTokenLock) {
|
||||
// 过期,刷新token
|
||||
// 提前一分钟刷新,避免 token 在请求传输或服务端处理过程中到期。
|
||||
if (isTokenExpires(tokenStorage) && !SKIP_REFRESH_TOKEN.some((url) => config.url?.startsWith(url))) {
|
||||
// 所有并发请求都会等待同一个 refreshTokenPromise,不能让后续请求继续携带旧 token。
|
||||
const newTokenStorage = await refreshToken()
|
||||
if (newTokenStorage) {
|
||||
const tokenValue = getTokenValue(newTokenStorage)
|
||||
if (tokenValue) config_header.Authorization = tokenValue
|
||||
tokenValue = getTokenValue(newTokenStorage)
|
||||
} else {
|
||||
config.custom = { ...(config.custom || {}), authRefreshAttempted: true }
|
||||
return Promise.reject({
|
||||
statusCode: 424,
|
||||
errMsg: "refresh token failed",
|
||||
data: { msg: "登录已过期,请重新登录" },
|
||||
config,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
config_header["Authorization"] = tokenValue
|
||||
if (tokenValue) {
|
||||
config_header.Authorization = tokenValue
|
||||
loginExpiredHandled = false
|
||||
} else {
|
||||
delete config_header.Authorization
|
||||
}
|
||||
}
|
||||
config.header = config_header
|
||||
return config
|
||||
}, Promise.reject)
|
||||
|
||||
@@ -79,75 +100,157 @@ https.interceptors.response.use(
|
||||
msg: null,
|
||||
})
|
||||
}
|
||||
|
||||
// 部分网关以 HTTP 200 + 业务码返回 token 过期,也要进入同一套刷新和重试流程。
|
||||
if (
|
||||
AUTH_EXPIRED_CODES.includes(Number(response.data?.code)) &&
|
||||
!SKIP_AUTH_URLS.some((url) => response.config.url?.startsWith(url))
|
||||
) {
|
||||
return recoverExpiredRequest({
|
||||
statusCode: Number(response.data.code),
|
||||
data: response.data,
|
||||
config: response.config,
|
||||
})
|
||||
}
|
||||
return Promise.resolve(response.data)
|
||||
},
|
||||
async (error) => {
|
||||
uni.hideLoading()
|
||||
// 处理 424 错误(token过期)
|
||||
if (error.statusCode === 424) {
|
||||
// 判断是否在登录页面
|
||||
const currentPage = router.getCurrentPage()
|
||||
const currentRoute = currentPage?.route
|
||||
if (currentRoute === "pages/root/login" || currentRoute === "/pages/root/login") {
|
||||
return
|
||||
}
|
||||
uni.removeStorageSync(TOKEN_KEY)
|
||||
uni.removeStorageSync(USER_INFO_KEY)
|
||||
runLoginRedirect(() => {
|
||||
uni.showToast({ title: "登录已过期,请重新登录", icon: "none" })
|
||||
uni.navigateTo({ url: "/pages/root/login" })
|
||||
})
|
||||
// HTTP 401/424 先刷新并重放原请求,只有刷新失败才要求重新登录。
|
||||
if (
|
||||
AUTH_EXPIRED_CODES.includes(Number(error.statusCode)) &&
|
||||
!SKIP_AUTH_URLS.some((url) => error.config?.url?.startsWith(url))
|
||||
) {
|
||||
return recoverExpiredRequest(error)
|
||||
}
|
||||
// 显示错误信息
|
||||
else if (error?.data?.msg) {
|
||||
uni.showToast({ title: error.data.msg, icon: "none", mask: true })
|
||||
}
|
||||
return Promise.resolve(error.data)
|
||||
return Promise.resolve(buildErrorResponse(error))
|
||||
},
|
||||
)
|
||||
|
||||
const unwrapTokenValue = (value: any): string | null => {
|
||||
if (typeof value === "string" && value.trim()) return value.trim()
|
||||
if (value && typeof value.tokenValue === "string" && value.tokenValue.trim()) return value.tokenValue.trim()
|
||||
return null
|
||||
}
|
||||
|
||||
// 从tokenStorage中读取tokenValue值
|
||||
const getTokenValue = (tokenStorage: GlobalSetting.TokenStorage): string | null => {
|
||||
return tokenStorage?.accessToken ? `Bearer ${tokenStorage?.accessToken}` : null
|
||||
const value = unwrapTokenValue(tokenStorage?.accessToken) || unwrapTokenValue((tokenStorage as any)?.access_token)
|
||||
return value ? `Bearer ${value}` : null
|
||||
}
|
||||
|
||||
const getRefreshTokenValue = (tokenStorage: GlobalSetting.TokenStorage): string | null => {
|
||||
return unwrapTokenValue(tokenStorage?.refreshToken) || unwrapTokenValue((tokenStorage as any)?.refresh_token)
|
||||
}
|
||||
|
||||
const getExpiresAt = (tokenStorage: any, tokenKey: "accessToken" | "refreshToken", expiresKey: string): number => {
|
||||
const raw = tokenStorage?.[expiresKey] || tokenStorage?.[tokenKey]?.expiresAt
|
||||
if (!raw) return Number.NaN
|
||||
return new Date(raw).getTime()
|
||||
}
|
||||
|
||||
// 检查tokenStorage中tokenValue是否过期
|
||||
const isTokenExpires = (tokenStorage: GlobalSetting.TokenStorage): boolean => {
|
||||
const { accessTokenExpiresIn } = tokenStorage
|
||||
const expiresDate = new Date(accessTokenExpiresIn).getTime()
|
||||
return new Date().getTime() >= expiresDate
|
||||
const expiresAt = getExpiresAt(tokenStorage, "accessToken", "accessTokenExpiresIn")
|
||||
// 老版本缓存没有过期时间时交给服务端 401/424 兜底,避免把仍可用的 token 直接踢下线。
|
||||
if (!Number.isFinite(expiresAt)) return false
|
||||
return Date.now() + REFRESH_AHEAD_MS >= expiresAt
|
||||
}
|
||||
|
||||
async function refreshToken(): Promise<any> {
|
||||
refreshTokenLock = false
|
||||
try {
|
||||
const tokenStorage = uni.getStorageSync(TOKEN_KEY) as GlobalSetting.TokenStorage
|
||||
const res: any = await https.middleware({
|
||||
url: "/auth/oauth2/token",
|
||||
header: {
|
||||
skipToken: "true",
|
||||
Authorization: BASIC_AUTH,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
method: "POST",
|
||||
params: { refresh_token: tokenStorage.refreshToken, grant_type: "refresh_token", scope: "server" },
|
||||
})
|
||||
const { data } = res || {}
|
||||
if (data) {
|
||||
tokenStorage.accessToken = data?.accessToken?.tokenValue
|
||||
tokenStorage.refreshToken = data?.refreshToken?.tokenValue
|
||||
tokenStorage.accessTokenExpiresIn = data?.accessToken?.expiresAt
|
||||
tokenStorage.refreshTokenExpiresIn = data?.refreshToken?.expiresAt
|
||||
uni.setStorageSync(TOKEN_KEY, tokenStorage)
|
||||
return tokenStorage
|
||||
const expiresInSecondsToIso = (seconds: any): string => {
|
||||
const value = Number(seconds)
|
||||
return Number.isFinite(value) && value > 0 ? new Date(Date.now() + value * 1000).toISOString() : ""
|
||||
}
|
||||
|
||||
/** 将 OAuth 下划线格式和后台嵌套格式统一为项目使用的 TokenStorage。 */
|
||||
export const normalizeTokenStorage = (data: any, previous?: GlobalSetting.TokenStorage): GlobalSetting.TokenStorage | null => {
|
||||
const accessToken = unwrapTokenValue(data?.accessToken) || unwrapTokenValue(data?.access_token)
|
||||
if (!accessToken) return null
|
||||
|
||||
const refreshToken =
|
||||
unwrapTokenValue(data?.refreshToken) || unwrapTokenValue(data?.refresh_token) || (previous ? getRefreshTokenValue(previous) : null) || ""
|
||||
const accessTokenExpiresIn =
|
||||
data?.accessTokenExpiresIn || data?.accessToken?.expiresAt || expiresInSecondsToIso(data?.expires_in)
|
||||
const refreshTokenExpiresIn =
|
||||
data?.refreshTokenExpiresIn ||
|
||||
data?.refreshToken?.expiresAt ||
|
||||
expiresInSecondsToIso(data?.refresh_expires_in) ||
|
||||
previous?.refreshTokenExpiresIn ||
|
||||
""
|
||||
|
||||
return { accessToken, refreshToken, accessTokenExpiresIn, refreshTokenExpiresIn }
|
||||
}
|
||||
|
||||
async function refreshToken(): Promise<GlobalSetting.TokenStorage | null> {
|
||||
if (refreshTokenPromise) return refreshTokenPromise
|
||||
refreshTokenPromise = (async () => {
|
||||
try {
|
||||
const tokenStorage = uni.getStorageSync(TOKEN_KEY) as GlobalSetting.TokenStorage
|
||||
const refreshTokenValue = getRefreshTokenValue(tokenStorage)
|
||||
if (!refreshTokenValue) return null
|
||||
|
||||
const refreshExpiresAt = getExpiresAt(tokenStorage, "refreshToken", "refreshTokenExpiresIn")
|
||||
if (Number.isFinite(refreshExpiresAt) && Date.now() >= refreshExpiresAt) return null
|
||||
|
||||
const res: any = await https.middleware({
|
||||
url: "/auth/oauth2/token",
|
||||
header: {
|
||||
skipToken: "true",
|
||||
Authorization: BASIC_AUTH,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
method: "POST",
|
||||
params: { refresh_token: refreshTokenValue, grant_type: "refresh_token", scope: "server" },
|
||||
})
|
||||
const newTokenStorage = res?.code === 0 ? normalizeTokenStorage(res.data, tokenStorage) : null
|
||||
if (!newTokenStorage) return null
|
||||
|
||||
uni.setStorageSync(TOKEN_KEY, newTokenStorage)
|
||||
loginExpiredHandled = false
|
||||
return newTokenStorage
|
||||
} catch (error) {
|
||||
return null
|
||||
} finally {
|
||||
refreshTokenPromise = null
|
||||
}
|
||||
})()
|
||||
return refreshTokenPromise
|
||||
}
|
||||
|
||||
const clearExpiredSession = () => {
|
||||
// 多个并发请求同时失败时只提示和跳转一次。
|
||||
if (loginExpiredHandled) return
|
||||
loginExpiredHandled = true
|
||||
uni.removeStorageSync(TOKEN_KEY)
|
||||
uni.removeStorageSync(USER_INFO_KEY)
|
||||
uni.showToast({ title: "登录已过期,请重新登录", icon: "none" })
|
||||
router.jump("/pages/root/login")
|
||||
}
|
||||
|
||||
const recoverExpiredRequest = async (error: any) => {
|
||||
const config = error?.config || {}
|
||||
const requestUrl = config.url || ""
|
||||
const custom = config.custom || {}
|
||||
const canRefresh = !SKIP_AUTH_URLS.some((url) => requestUrl.startsWith(url)) && !custom.authRefreshAttempted
|
||||
|
||||
if (canRefresh) {
|
||||
const newTokenStorage = await refreshToken()
|
||||
const authorization = newTokenStorage ? getTokenValue(newTokenStorage) : null
|
||||
if (authorization) {
|
||||
// 标记只重试一次,避免服务端持续返回 401/424 时形成请求死循环。
|
||||
return https.middleware({
|
||||
...config,
|
||||
header: { ...(config.header || {}), Authorization: authorization },
|
||||
custom: { ...custom, authRefreshAttempted: true },
|
||||
})
|
||||
}
|
||||
return null
|
||||
} catch (error) {
|
||||
return false
|
||||
} finally {
|
||||
refreshTokenLock = true
|
||||
}
|
||||
|
||||
clearExpiredSession()
|
||||
return buildErrorResponse(error, "登录已过期,请重新登录")
|
||||
}
|
||||
|
||||
// 请求函数
|
||||
|
||||
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
@@ -347,11 +347,13 @@ watchEffect(() => {
|
||||
})
|
||||
onMounted(() => {
|
||||
let sys = uni.getWindowInfo()
|
||||
safeHeight.value = sys.safeAreaInsets.bottom || 16
|
||||
safeHeight.value = sys.safeAreaInsets.bottom
|
||||
if (safeHeight.value < 8) {
|
||||
safeHeight.value = 8
|
||||
}
|
||||
})
|
||||
onMounted(() => {
|
||||
getList()
|
||||
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||