This commit is contained in:
2026-04-18 12:06:06 +08:00
parent f62289c97b
commit 1e82051ca5
24 changed files with 315 additions and 214 deletions

View File

@@ -48,6 +48,7 @@ declare global {
const getApiPrefix: typeof import('./utils/runtimeEnv.js')['getApiPrefix'] const getApiPrefix: typeof import('./utils/runtimeEnv.js')['getApiPrefix']
const getAppDebug: typeof import('./utils/runtimeEnv.js')['getAppDebug'] const getAppDebug: typeof import('./utils/runtimeEnv.js')['getAppDebug']
const getAppName: typeof import('./utils/runtimeEnv.js')['getAppName'] const getAppName: typeof import('./utils/runtimeEnv.js')['getAppName']
const getCachedMergedPosterWeixin: typeof import('./utils/posterQrWeixin.js')['getCachedMergedPosterWeixin']
const getCompanyName: typeof import('./utils/runtimeEnv.js')['getCompanyName'] const getCompanyName: typeof import('./utils/runtimeEnv.js')['getCompanyName']
const getCurrentInstance: typeof import('vue')['getCurrentInstance'] const getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentScope: typeof import('vue')['getCurrentScope'] const getCurrentScope: typeof import('vue')['getCurrentScope']
@@ -55,6 +56,7 @@ declare global {
const getCustomerServiceUrl: typeof import('./utils/runtimeEnv.js')['getCustomerServiceUrl'] const getCustomerServiceUrl: typeof import('./utils/runtimeEnv.js')['getCustomerServiceUrl']
const getInviteChannelKey: typeof import('./utils/runtimeEnv.js')['getInviteChannelKey'] const getInviteChannelKey: typeof import('./utils/runtimeEnv.js')['getInviteChannelKey']
const getMeShareTitle: typeof import('./utils/runtimeEnv.js')['getMeShareTitle'] const getMeShareTitle: typeof import('./utils/runtimeEnv.js')['getMeShareTitle']
const getPosterOrigin: typeof import('./utils/runtimeEnv.js')['getPosterOrigin']
const getPosterSrcList: typeof import('./utils/posterQrWeixin.js')['getPosterSrcList'] const getPosterSrcList: typeof import('./utils/posterQrWeixin.js')['getPosterSrcList']
const getShareTitle: typeof import('./utils/runtimeEnv.js')['getShareTitle'] const getShareTitle: typeof import('./utils/runtimeEnv.js')['getShareTitle']
const getSiteOrigin: typeof import('./utils/runtimeEnv.js')['getSiteOrigin'] const getSiteOrigin: typeof import('./utils/runtimeEnv.js')['getSiteOrigin']
@@ -393,6 +395,7 @@ declare module 'vue' {
readonly getApiPrefix: UnwrapRef<typeof import('./utils/runtimeEnv.js')['getApiPrefix']> readonly getApiPrefix: UnwrapRef<typeof import('./utils/runtimeEnv.js')['getApiPrefix']>
readonly getAppDebug: UnwrapRef<typeof import('./utils/runtimeEnv.js')['getAppDebug']> readonly getAppDebug: UnwrapRef<typeof import('./utils/runtimeEnv.js')['getAppDebug']>
readonly getAppName: UnwrapRef<typeof import('./utils/runtimeEnv.js')['getAppName']> readonly getAppName: UnwrapRef<typeof import('./utils/runtimeEnv.js')['getAppName']>
readonly getCachedMergedPosterWeixin: UnwrapRef<typeof import('./utils/posterQrWeixin.js')['getCachedMergedPosterWeixin']>
readonly getCompanyName: UnwrapRef<typeof import('./utils/runtimeEnv.js')['getCompanyName']> readonly getCompanyName: UnwrapRef<typeof import('./utils/runtimeEnv.js')['getCompanyName']>
readonly getCurrentInstance: UnwrapRef<typeof import('vue')['getCurrentInstance']> readonly getCurrentInstance: UnwrapRef<typeof import('vue')['getCurrentInstance']>
readonly getCurrentScope: UnwrapRef<typeof import('vue')['getCurrentScope']> readonly getCurrentScope: UnwrapRef<typeof import('vue')['getCurrentScope']>
@@ -400,6 +403,7 @@ declare module 'vue' {
readonly getCustomerServiceUrl: UnwrapRef<typeof import('./utils/runtimeEnv.js')['getCustomerServiceUrl']> readonly getCustomerServiceUrl: UnwrapRef<typeof import('./utils/runtimeEnv.js')['getCustomerServiceUrl']>
readonly getInviteChannelKey: UnwrapRef<typeof import('./utils/runtimeEnv.js')['getInviteChannelKey']> readonly getInviteChannelKey: UnwrapRef<typeof import('./utils/runtimeEnv.js')['getInviteChannelKey']>
readonly getMeShareTitle: UnwrapRef<typeof import('./utils/runtimeEnv.js')['getMeShareTitle']> readonly getMeShareTitle: UnwrapRef<typeof import('./utils/runtimeEnv.js')['getMeShareTitle']>
readonly getPosterOrigin: UnwrapRef<typeof import('./utils/runtimeEnv.js')['getPosterOrigin']>
readonly getPosterSrcList: UnwrapRef<typeof import('./utils/posterQrWeixin.js')['getPosterSrcList']> readonly getPosterSrcList: UnwrapRef<typeof import('./utils/posterQrWeixin.js')['getPosterSrcList']>
readonly getShareTitle: UnwrapRef<typeof import('./utils/runtimeEnv.js')['getShareTitle']> readonly getShareTitle: UnwrapRef<typeof import('./utils/runtimeEnv.js')['getShareTitle']>
readonly getSiteOrigin: UnwrapRef<typeof import('./utils/runtimeEnv.js')['getSiteOrigin']> readonly getSiteOrigin: UnwrapRef<typeof import('./utils/runtimeEnv.js')['getSiteOrigin']>

View File

@@ -3,52 +3,29 @@
<!-- #ifdef MP-WEIXIN --> <!-- #ifdef MP-WEIXIN -->
<view class="max-h-[calc(100vh-100px)] m-4"> <view class="max-h-[calc(100vh-100px)] m-4">
<!-- canvas 离屏绘制避免 swiper 未挂载项取不到节点 --> <!-- canvas 离屏绘制避免 swiper 未挂载项取不到节点 -->
<canvas <canvas id="mpPosterCanvas" canvas-id="mpPosterCanvas" type="2d" class="mp-poster-canvas-hidden" />
id="mpPosterCanvas"
canvas-id="mpPosterCanvas"
type="2d"
class="mp-poster-canvas-hidden"
/>
<view class="p-2 flex justify-center"> <view class="p-2 flex justify-center">
<swiper <swiper :key="swiperMountKey" class="mp-poster-swiper w-full" :style="{ height: swiperHeightPx + 'px' }"
:key="swiperMountKey" :current="currentSwiperIndex" :duration="280" :easing-function="easeOutCubic" @change="onSwiperChange">
class="mp-poster-swiper w-full"
:style="{ height: swiperHeightPx + 'px' }"
:duration="280"
:easing-function="easeOutCubic"
@change="onSwiperChange"
>
<swiper-item v-for="(_, idx) in posterSrcList" :key="idx" class="mp-swiper-item"> <swiper-item v-for="(_, idx) in posterSrcList" :key="idx" class="mp-swiper-item">
<view class="mp-poster-item"> <view class="mp-poster-item">
<image <image v-if="renderedPaths[idx]" :src="renderedPaths[idx]" mode="aspectFit"
v-if="renderedPaths[idx]" class="rounded-xl shadow poster-preview-mp" :style="posterPreviewStyle" />
:src="renderedPaths[idx]"
mode="aspectFit"
class="rounded-xl shadow poster-preview-mp"
:style="posterPreviewStyle"
/>
<view v-else class="text-gray-400 text-sm py-16">生成海报中</view> <view v-else class="text-gray-400 text-sm py-16">生成海报中</view>
</view> </view>
</swiper-item> </swiper-item>
</swiper> </swiper>
</view> </view>
<view <view v-if="mode === 'promote'" class="text-center text-gray-500 text-xs mb-2">
v-if="mode === 'promote'"
class="text-center text-gray-500 text-xs mb-2"
>
左右滑动切换海报 左右滑动切换海报
</view> </view>
<view class="divider">分享与保存</view> <view class="divider">分享与保存</view>
<view class="mp-share-actions"> <view class="mp-share-actions">
<button <!-- <button class="share-mp-btn flex flex-col items-center justify-center" open-type="share" plain
class="share-mp-btn flex flex-col items-center justify-center" @tap="onShareFriendPrepare">
open-type="share"
plain
@tap="onShareFriendPrepare"
>
<image src="/static/image/icon_share_friends.svg" class="w-10 h-10 rounded-full" /> <image src="/static/image/icon_share_friends.svg" class="w-10 h-10 rounded-full" />
<text class="text-center mt-1 text-gray-600 text-xs">分享给好友</text> <text class="text-center mt-1 text-gray-600 text-xs">分享给好友</text>
</button> </button> -->
<view class="flex flex-col items-center justify-center" @click="savePoster"> <view class="flex flex-col items-center justify-center" @click="savePoster">
<image src="/static/image/icon_share_img.svg" class="w-10 h-10 rounded-full" /> <image src="/static/image/icon_share_img.svg" class="w-10 h-10 rounded-full" />
<view class="text-center mt-1 text-gray-600 text-xs">保存图片</view> <view class="text-center mt-1 text-gray-600 text-xs">保存图片</view>
@@ -65,14 +42,9 @@
<view class="max-h-[calc(100vh-100px)] m-4"> <view class="max-h-[calc(100vh-100px)] m-4">
<view class="p-4 flex justify-center"> <view class="p-4 flex justify-center">
<view class="max-h-[70vh] rounded-xl overflow-hidden"> <view class="max-h-[70vh] rounded-xl overflow-hidden">
<image <image :src="posterImageUrlRemote" class="rounded-xl shadow poster-image"
:src="posterImageUrlRemote" :style="{ width: imageWidth + 'px', height: imageHeight + 'px' }" mode="aspectFit" @load="onImageLoad"
class="rounded-xl shadow poster-image" @error="onImageError" />
:style="{ width: imageWidth + 'px', height: imageHeight + 'px' }"
mode="aspectFit"
@load="onImageLoad"
@error="onImageError"
/>
</view> </view>
</view> </view>
<view class="divider">分享到好友</view> <view class="divider">分享到好友</view>
@@ -97,7 +69,7 @@ import { getApiBaseUrl, getAgentTabShareTitle, getShareTitle } from '@/utils/run
import { buildPromotionH5Url } from '@/utils/promotionH5Url.js' import { buildPromotionH5Url } from '@/utils/promotionH5Url.js'
import { setMiniPromotionShareFriend } from '@/utils/miniPromotionSharePayload.js' import { setMiniPromotionShareFriend } from '@/utils/miniPromotionSharePayload.js'
// #ifdef MP-WEIXIN // #ifdef MP-WEIXIN
import { getPosterSrcList, drawMergedPosterWeixin } from '@/utils/posterQrWeixin.js' import { getPosterSrcList, drawMergedPosterWeixin, getCachedMergedPosterWeixin } from '@/utils/posterQrWeixin.js'
// #endif // #endif
const props = defineProps({ const props = defineProps({
@@ -184,12 +156,40 @@ function updateMpPosterLayout() {
/** 串行生成,避免多索引共用同一 canvas 竞态 */ /** 串行生成,避免多索引共用同一 canvas 竞态 */
let mpGenSeq = Promise.resolve() let mpGenSeq = Promise.resolve()
const mpRenderStateCache = new Map()
function getRenderCacheKey() {
return `${mode.value}::${linkIdentifier.value || ''}`
}
function updateRenderCache(partial) {
const key = getRenderCacheKey()
const prev = mpRenderStateCache.get(key) || { paths: [], lastIndex: 0 }
const next = {
paths: Array.isArray(partial.paths) ? [...partial.paths] : prev.paths,
lastIndex: Number.isFinite(partial.lastIndex) ? partial.lastIndex : prev.lastIndex,
}
mpRenderStateCache.set(key, next)
}
function resetMpPosters() { function resetMpPosters() {
mpGenSeq = Promise.resolve() mpGenSeq = Promise.resolve()
const n = posterSrcList.value.length const n = posterSrcList.value.length
renderedPaths.value = Array.from({ length: n }, () => '') const cache = mpRenderStateCache.get(getRenderCacheKey())
currentSwiperIndex.value = 0 const initialPaths = Array.from({ length: n }, (_, idx) => {
const saved = cache?.paths?.[idx]
if (saved) return saved
return getCachedMergedPosterWeixin({
linkUrl: generalUrl(),
posterSrc: posterSrcList.value[idx],
mode: mode.value,
index: idx,
}) || ''
})
renderedPaths.value = initialPaths
const cachedIndex = cache?.lastIndex ?? 0
currentSwiperIndex.value = Math.min(Math.max(cachedIndex, 0), Math.max(n - 1, 0))
updateRenderCache({ paths: initialPaths, lastIndex: currentSwiperIndex.value })
} }
function generatePosterMp(index) { function generatePosterMp(index) {
@@ -198,7 +198,7 @@ function generatePosterMp(index) {
if (!proxy) return Promise.resolve() if (!proxy) return Promise.resolve()
mpGenSeq = mpGenSeq mpGenSeq = mpGenSeq
.catch(() => {}) .catch(() => { })
.then(async () => { .then(async () => {
if (renderedPaths.value[index]) return if (renderedPaths.value[index]) return
await new Promise((r) => setTimeout(r, 80)) await new Promise((r) => setTimeout(r, 80))
@@ -224,6 +224,7 @@ function generatePosterMp(index) {
const next = [...renderedPaths.value] const next = [...renderedPaths.value]
next[index] = temp next[index] = temp
renderedPaths.value = next renderedPaths.value = next
updateRenderCache({ paths: next })
}) })
return mpGenSeq return mpGenSeq
} }
@@ -231,6 +232,7 @@ function generatePosterMp(index) {
function onSwiperChange(e) { function onSwiperChange(e) {
const cur = e.detail?.current ?? 0 const cur = e.detail?.current ?? 0
currentSwiperIndex.value = cur currentSwiperIndex.value = cur
updateRenderCache({ lastIndex: cur })
if (!renderedPaths.value[cur]) { if (!renderedPaths.value[cur]) {
// 等 swiper 切换动画走一部分再跑 canvas减轻主线程卡顿 // 等 swiper 切换动画走一部分再跑 canvas减轻主线程卡顿
setTimeout(() => { setTimeout(() => {
@@ -249,7 +251,7 @@ watch(show, (v) => {
swiperMountKey.value += 1 swiperMountKey.value += 1
resetMpPosters() resetMpPosters()
nextTick(() => { nextTick(() => {
generatePosterMp(0).catch((err) => { generatePosterMp(currentSwiperIndex.value).catch((err) => {
console.error('生成海报失败', err) console.error('生成海报失败', err)
uni.showToast({ title: '海报生成失败', icon: 'none' }) uni.showToast({ title: '海报生成失败', icon: 'none' })
}) })
@@ -264,7 +266,7 @@ watch([mode, linkIdentifier], () => {
swiperMountKey.value += 1 swiperMountKey.value += 1
resetMpPosters() resetMpPosters()
nextTick(() => { nextTick(() => {
generatePosterMp(0).catch(() => {}) generatePosterMp(currentSwiperIndex.value).catch(() => { })
}) })
}) })
}) })
@@ -319,7 +321,7 @@ function onImageLoad() {
imageWidth.value = size.width imageWidth.value = size.width
imageHeight.value = size.height imageHeight.value = size.height
}, },
fail: () => {}, fail: () => { },
}) })
} }
@@ -518,5 +520,6 @@ function copyUrl() {
.share-mp-btn::after { .share-mp-btn::after {
border: none; border: none;
} }
/* #endif */ /* #endif */
</style> </style>

View File

@@ -1,6 +1,7 @@
{ {
"apiUrl": "http://127.0.0.1:8888", "apiUrl": "http://127.0.0.1:8888",
"apiPrefix": "/api/v1", "apiPrefix": "/api/v1",
"posterUrl": "http://127.0.0.1:8888/posts",
"siteOrigin": "http://127.0.0.1:8888", "siteOrigin": "http://127.0.0.1:8888",
"appName": "赤眉", "appName": "赤眉",
"companyName": "戎行技术有限公司有限公司", "companyName": "戎行技术有限公司有限公司",
@@ -11,4 +12,4 @@
"customerServiceCorpId": "", "customerServiceCorpId": "",
"inviteChannelKey": "8e3e7a2f60edb49221e953b9c029ed10", "inviteChannelKey": "8e3e7a2f60edb49221e953b9c029ed10",
"appDebug": true "appDebug": true
} }

View File

@@ -1,6 +1,7 @@
{ {
"apiUrl": "https://chimei.ronsafe.cn", "apiUrl": "https://chimei.ronsafe.cn",
"apiPrefix": "/api/v1", "apiPrefix": "/api/v1",
"posterUrl": "https://chimei.ronsafe.cn/posts",
"siteOrigin": "https://chimei.ronsafe.cn", "siteOrigin": "https://chimei.ronsafe.cn",
"appName": "赤眉", "appName": "赤眉",
"companyName": "戎行技术有限公司有限公司", "companyName": "戎行技术有限公司有限公司",
@@ -11,4 +12,4 @@
"customerServiceCorpId": "", "customerServiceCorpId": "",
"inviteChannelKey": "8e3e7a2f60edb49221e953b9c029ed10", "inviteChannelKey": "8e3e7a2f60edb49221e953b9c029ed10",
"appDebug": false "appDebug": false
} }

1
src/env.d.ts vendored
View File

@@ -4,6 +4,7 @@
interface BdrpRuntimeConfig { interface BdrpRuntimeConfig {
apiUrl: string apiUrl: string
apiPrefix: string apiPrefix: string
posterUrl: string
siteOrigin: string siteOrigin: string
appName: string appName: string
companyName: string companyName: string

View File

@@ -117,4 +117,4 @@
} }
} }
} }
} }

View File

@@ -164,6 +164,5 @@
} }
] ]
}, },
"__esModule": true,
"subPackages": [] "subPackages": []
} }

View File

@@ -111,21 +111,12 @@
import { ref, reactive, computed, onMounted, watch } from 'vue' import { ref, reactive, computed, onMounted, watch } from 'vue'
import { getAgentMembershipUserConfig, saveAgentMembershipUserConfig, getProductConfig } from '@/apis/agent' import { getAgentMembershipUserConfig, saveAgentMembershipUserConfig, getProductConfig } from '@/apis/agent'
// 报告类型选项 - 与 webview 保持一致,支持从后端动态加载 // 报告类型选项:完全由后端返回
const reportOptions = ref([ const reportOptions = ref([])
{ label: '入职风险', value: 1 },
{ label: '小微企业', value: 2 },
{ label: '家政风险', value: 3 },
{ label: '婚恋风险', value: 4 },
{ label: '贷前风险', value: 5 },
{ label: '租赁风险', value: 6 },
{ label: '个人风险', value: 7 },
{ label: '个人大数据', value: 27 },
])
// 状态管理 // 状态管理
const showPicker = ref(false) const showPicker = ref(false)
const selectedReportId = ref(1) const selectedReportId = ref(null)
const selectedReportText = computed(() => { const selectedReportText = computed(() => {
const opt = reportOptions.value.find((o) => o.value === selectedReportId.value) const opt = reportOptions.value.find((o) => o.value === selectedReportId.value)
return opt ? opt.label : '请选择' return opt ? opt.label : '请选择'
@@ -248,6 +239,8 @@ const validateRatio = () => {
// 获取配置 // 获取配置
const getConfig = async () => { const getConfig = async () => {
if (!selectedReportId.value)
return
try { try {
const res = await getAgentMembershipUserConfig({ product_id: selectedReportId.value }) const res = await getAgentMembershipUserConfig({ product_id: selectedReportId.value })
if (res.code === 200) { if (res.code === 200) {
@@ -357,37 +350,30 @@ const closeRangeError = () => {
}, 2000) }, 2000)
} }
// 从后端加载可选报告列表(可选,若失败则使用默认列表) // 从后端加载可选报告列表
const loadReportOptions = async () => { const loadReportOptions = async () => {
try { try {
const res = await getProductConfig() const res = await getProductConfig()
if (res.code !== 200 || !res.data) return if (res.code !== 200 || !res.data)
return
const list = res.data.agent_product_config || res.data.AgentProductConfig || [] const list = res.data.agent_product_config || res.data.AgentProductConfig || []
if (list.length) { reportOptions.value = list
const productNameMap = { .map(p => ({
1: '入职风险', label: p.product_name || `报告${p.product_id}`,
2: '小微企业',
3: '家政风险',
4: '婚恋风险',
5: '贷前风险',
6: '租赁风险',
7: '个人风险',
27: '个人大数据',
}
reportOptions.value = list.map((p) => ({
label: productNameMap[p.product_id] || `报告${p.product_id}`,
value: p.product_id, value: p.product_id,
})) }))
if (!reportOptions.value.find((o) => o.value === selectedReportId.value)) { .filter(p => p.value != null)
if (reportOptions.value.length > 0) {
if (!reportOptions.value.find(o => o.value === selectedReportId.value))
selectedReportId.value = reportOptions.value[0].value selectedReportId.value = reportOptions.value[0].value
}
} }
} catch {} } catch {}
} }
onMounted(async () => { onMounted(async () => {
await loadReportOptions() await loadReportOptions()
getConfig() if (selectedReportId.value)
getConfig()
}) })
</script> </script>

View File

@@ -33,13 +33,13 @@
<wd-form :model="formData" ref="promotionForm"> <wd-form :model="formData" ref="promotionForm">
<wd-cell-group border> <wd-cell-group border>
<!-- 报告类型 --> <!-- 报告类型 -->
<wd-picker label="报告类型" label-width="100px" v-model="formData.productType" :columns="[reportTypes]" <wd-picker label="报告类型" label-width="100px" v-model="formData.productType" :columns="reportTypes"
title="选择报告类型" prop="productType" placeholder="请选择报告类型" @confirm="onConfirmType" title="选择报告类型" prop="productType" placeholder="请选择报告类型" @confirm="onConfirmType"
:rules="[{ required: true, message: '请选择报告类型' }]" /> :rules="[{ required: true, message: '请选择报告类型' }]" />
<!-- 定价 --> <!-- 定价 -->
<wd-input label="客户查询价" label-width="100px" v-model="formData.clientPrice" placeholder="请输入价格" readonly <wd-input label="客户查询价" label-width="100px" v-model="formData.clientPrice" placeholder="请输入价格" readonly
clickable @click="showPricePicker = true" prop="clientPrice" suffix-icon="arrow-right" clickable @click="showPricePicker = true" prop="clientPrice" suffix-icon="arrow-right"
:rules="[{ required: true, message: '请输入客户查询价' }]" /> :rules="[{ required: true, message: '请输入客户查询价' }]" />
</wd-cell-group> </wd-cell-group>
@@ -60,7 +60,7 @@
:product-config="pickerProductConfig" @change="onPriceChange" /> :product-config="pickerProductConfig" @change="onPriceChange" />
<QRcode v-model:show="showQRcode" :linkIdentifier="linkIdentifier" /> <QRcode v-model:show="showQRcode" :linkIdentifier="linkIdentifier" />
<GzhQrcode :visible="showGzhQrcode" @close="showGzhQrcode = false" /> <GzhQrcode :visible="showGzhQrcode" @close="showGzhQrcode = false" />
</view> </view>
</template> </template>
@@ -76,17 +76,6 @@ import GzhQrcode from '@/components/GzhQrcode.vue'
usePromotionShareHandlers({ defaultTitle: getAgentTabShareTitle() }) usePromotionShareHandlers({ defaultTitle: getAgentTabShareTitle() })
// 报告类型
const reportTypes = [
{ label: '人事背调', value: 'backgroundcheck', id: 1 },
{ label: '老板企业报告', value: 'companyinfo', id: 2 },
{ label: '家政风险', value: 'homeservice', id: 3 },
{ label: '婚恋风险', value: 'marriage', id: 4 },
{ label: '贷前背调', value: 'preloanbackgroundcheck', id: 5 },
{ label: '租赁风险', value: 'rentalrisk', id: 6 },
{ label: '个人风险', value: 'riskassessment', id: 7 }
]
// 状态管理 // 状态管理
const promotionForm = ref(null) const promotionForm = ref(null)
const showPricePicker = ref(false) const showPricePicker = ref(false)
@@ -102,6 +91,18 @@ const formData = ref({
clientPrice: null clientPrice: null
}) })
const reportTypes = computed(() => {
if (!productConfig.value?.length)
return []
return productConfig.value
.map(item => ({
id: item.product_id,
label: item.product_name || `产品${item.product_id}`,
value: item.product_en || '',
}))
.filter(item => !!item.value)
})
// 计算成本价格 // 计算成本价格
const costPrice = computed(() => { const costPrice = computed(() => {
if (!pickerProductConfig.value) return '0.00' if (!pickerProductConfig.value) return '0.00'
@@ -153,16 +154,6 @@ const generatePromotionCode = async () => {
} }
try { try {
// 获取选中产品的完整信息
const reportType = reportTypes.find(item => item.value === formData.value.productType)
if (!reportType) {
uni.showToast({
title: '请选择有效的报告类型',
icon: 'none'
})
return
}
const res = await generatePromotionLink({ const res = await generatePromotionLink({
product: formData.value.productType, product: formData.value.productType,
price: formData.value.clientPrice price: formData.value.clientPrice
@@ -187,20 +178,30 @@ const generatePromotionCode = async () => {
// 选择类型 // 选择类型
const selectProductType = (reportTypeValue) => { const selectProductType = (reportTypeValue) => {
const reportType = reportTypes.find(item => item.id === reportTypeValue || item.value === reportTypeValue) const reportType = reportTypes.value.find(item => item.id === reportTypeValue || item.value === reportTypeValue)
if (!reportType) return if (!reportType) {
pickerProductConfig.value = null
formData.value.productType = ''
formData.value.clientPrice = null
return
}
formData.value.productType = reportType.value formData.value.productType = reportType.value
if (productConfig.value) { if (!productConfig.value) {
for (let i of productConfig.value) { pickerProductConfig.value = null
if (i.product_id === reportType.id) { formData.value.clientPrice = null
pickerProductConfig.value = i return
formData.value.clientPrice = i.p_pricing_standard.toString()
}
}
} }
const matchedConfig = productConfig.value.find(item => item.product_id === reportType.id)
if (!matchedConfig) {
pickerProductConfig.value = null
formData.value.clientPrice = null
return
}
pickerProductConfig.value = matchedConfig
formData.value.clientPrice = Number(matchedConfig.p_pricing_standard)
} }
// 获取产品配置 // 获取产品配置
@@ -209,9 +210,19 @@ const getPromoteConfig = async () => {
const res = await getProductConfig() const res = await getProductConfig()
if (res.code === 200) { if (res.code === 200) {
productConfig.value = res.data.AgentProductConfig productConfig.value = res.data.AgentProductConfig || []
// 选择第一个报告类型 const firstType = reportTypes.value[0]
selectProductType(1) // 使用ID 1选择第一个报告类型 if (firstType) {
selectProductType(firstType.value)
} else {
pickerProductConfig.value = null
formData.value.productType = ''
formData.value.clientPrice = null
uni.showToast({
title: '暂无可推广产品',
icon: 'none'
})
}
} else { } else {
uni.showToast({ uni.showToast({
title: res.msg || '获取配置失败', title: res.msg || '获取配置失败',
@@ -233,11 +244,9 @@ const onPriceChange = (price) => {
// 类型选择确认 // 类型选择确认
const onConfirmType = (e) => { const onConfirmType = (e) => {
// picker在单列模式下返回的是选中项的值 const selectedValue = e?.value?.[0] ?? e?.value ?? e?.selectedOptions?.[0]?.value
if (e && e.value && e.value.length > 0) { if (selectedValue !== undefined && selectedValue !== null && selectedValue !== '')
const selectedValue = e.value[0]
selectProductType(selectedValue) selectProductType(selectedValue)
}
} }
// 显示公众号二维码 // 显示公众号二维码

Binary file not shown.

Before

Width:  |  Height:  |  Size: 262 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 807 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 409 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 415 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 572 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 618 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 392 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 297 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 585 KiB

View File

@@ -1,7 +1,8 @@
/** /**
* 微信小程序:本地海报底图 + uQRCode 合成(与 webview QRcode.vue 配置对齐) * 微信小程序:服务端海报底图 + uQRCode 合成(与 webview QRcode.vue 配置对齐)
*/ */
import UQRCode from 'uqrcodejs' import UQRCode from "uqrcodejs";
import { getPosterOrigin } from "@/utils/runtimeEnv.js";
/** 与 webview src/components/QRcode.vue 中 qrCodePositions 一致 */ /** 与 webview src/components/QRcode.vue 中 qrCodePositions 一致 */
export const POSTER_QR_POSITIONS = { export const POSTER_QR_POSITIONS = {
@@ -16,43 +17,58 @@ export const POSTER_QR_POSITIONS = {
{ x: 255, y: 940, size: 250 }, { x: 255, y: 940, size: 250 },
], ],
invitation: [{ x: 360, y: -1370, size: 360 }], invitation: [{ x: 360, y: -1370, size: 360 }],
};
function buildPosterUrl(filename) {
return `${getPosterOrigin()}/${filename}`;
} }
export function getPosterSrcList(mode) { export function getPosterSrcList(mode) {
if (mode === 'invitation') { if (mode === "invitation") {
return ['/static/poster/yq_qrcode_1.png'] return [buildPosterUrl("yq_qrcode_1.png")];
} }
return [ return [
'/static/poster/tg_qrcode_1.png', buildPosterUrl("tg_qrcode_1.png"),
'/static/poster/tg_qrcode_2.png', buildPosterUrl("tg_qrcode_2.png"),
'/static/poster/tg_qrcode_3.png', buildPosterUrl("tg_qrcode_3.png"),
'/static/poster/tg_qrcode_4.png', buildPosterUrl("tg_qrcode_4.png"),
'/static/poster/tg_qrcode_5.png', buildPosterUrl("tg_qrcode_5.png"),
'/static/poster/tg_qrcode_6.png', buildPosterUrl("tg_qrcode_6.png"),
'/static/poster/tg_qrcode_7.png', buildPosterUrl("tg_qrcode_7.png"),
'/static/poster/tg_qrcode_8.png', buildPosterUrl("tg_qrcode_8.png"),
] ];
} }
function getWx() { function getWx() {
return typeof wx !== 'undefined' ? wx : null return typeof wx !== "undefined" ? wx : null;
}
const mergedPosterCache = new Map();
function buildMergedPosterCacheKey({ linkUrl, posterSrc, mode, index }) {
return `${mode || "promote"}::${index || 0}::${posterSrc || ""}::${linkUrl || ""}`;
}
export function getCachedMergedPosterWeixin(opts) {
const key = buildMergedPosterCacheKey(opts || {});
return mergedPosterCache.get(key) || "";
} }
/** /**
* 与 webview 一致:优先 png不存在则尝试同名的 jpg避免仅提交了 jpg 时小程序侧失败) * 与 webview 一致:优先 png不存在则尝试同名的 jpg避免仅提交了 jpg 时小程序侧失败)
* @param {string} posterSrc 如 /static/poster/tg_qrcode_2.png * @param {string} posterSrc 如 https://example.com/posts/tg_qrcode_2.png
*/ */
function getPosterSrcCandidates(posterSrc) { function getPosterSrcCandidates(posterSrc) {
const base = posterSrc.replace(/\.(png|jpg|jpeg)$/i, '') const base = posterSrc.replace(/\.(png|jpg|jpeg)$/i, "");
return [`${base}.png`, `${base}.jpg`, `${base}.jpeg`] return [`${base}.png`, `${base}.jpg`, `${base}.jpeg`];
} }
/** /**
* @returns {Promise<{ info: Record<string, unknown>, resolvedSrc: string }>} * @returns {Promise<{ info: Record<string, unknown>, resolvedSrc: string }>}
*/ */
async function resolvePosterImageInfo(posterSrc) { async function resolvePosterImageInfo(posterSrc) {
const candidates = getPosterSrcCandidates(posterSrc) const candidates = getPosterSrcCandidates(posterSrc);
let lastErr = '' let lastErr = "";
for (const src of candidates) { for (const src of candidates) {
try { try {
const info = await new Promise((resolve, reject) => { const info = await new Promise((resolve, reject) => {
@@ -60,16 +76,16 @@ async function resolvePosterImageInfo(posterSrc) {
src, src,
success: resolve, success: resolve,
fail: (e) => reject(e), fail: (e) => reject(e),
}) });
}) });
return { info, resolvedSrc: src } return { info, resolvedSrc: src };
} catch (e) { } catch (e) {
lastErr = (e && (e.errMsg || e.message)) || String(e) lastErr = (e && (e.errMsg || e.message)) || String(e);
} }
} }
throw new Error( throw new Error(
`海报底图无法加载(${lastErr})。请 tg_qrcode_1~8、yq_qrcode_1 等底图放入小程序目录 src/static/poster/,与同仓库 webview 的 assets 命名一致(支持 .png 或 .jpg。当前已尝试${candidates.join('、')}`, `海报底图无法加载(${lastErr})。请检查服务端 /posts/ 下是否存在 tg_qrcode_1~8、yq_qrcode_1 等底图,并确认小程序已配置该域名为合法下载域名(支持 .png 或 .jpg。当前已尝试${candidates.join("、")}`,
) );
} }
/** /**
@@ -77,21 +93,21 @@ async function resolvePosterImageInfo(posterSrc) {
* 应用根路径 /static/... 作为 img.src 更可靠,失败再退回 info.path * 应用根路径 /static/... 作为 img.src 更可靠,失败再退回 info.path
*/ */
function loadCanvasImage(canvas, resolvedSrc, infoPath) { function loadCanvasImage(canvas, resolvedSrc, infoPath) {
const img = canvas.createImage() const img = canvas.createImage();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const tryPath = (p, isFallback) => { const tryPath = (p, isFallback) => {
img.onload = () => resolve(img) img.onload = () => resolve(img);
img.onerror = () => { img.onerror = () => {
if (!isFallback && infoPath && p !== infoPath) { if (!isFallback && infoPath && p !== infoPath) {
tryPath(infoPath, true) tryPath(infoPath, true);
} else { } else {
reject(new Error(`canvas 图片加载失败: ${p}`)) reject(new Error(`canvas 图片加载失败: ${p}`));
} }
} };
img.src = p img.src = p;
} };
tryPath(resolvedSrc, false) tryPath(resolvedSrc, false);
}) });
} }
/** /**
@@ -105,60 +121,67 @@ function loadCanvasImage(canvas, resolvedSrc, infoPath) {
* @returns {Promise<string>} tempFilePath * @returns {Promise<string>} tempFilePath
*/ */
export async function drawMergedPosterWeixin(opts) { export async function drawMergedPosterWeixin(opts) {
const { canvas, linkUrl, posterSrc, mode, index, componentInstance } = opts const { canvas, linkUrl, posterSrc, mode, index, componentInstance } = opts;
const ctx = canvas.getContext('2d') const cacheKey = buildMergedPosterCacheKey({ linkUrl, posterSrc, mode, index });
const cachedPath = mergedPosterCache.get(cacheKey);
if (cachedPath) {
return cachedPath;
}
const ctx = canvas.getContext("2d");
if (!ctx) { if (!ctx) {
throw new Error('canvas 2d 不可用') throw new Error("canvas 2d 不可用");
} }
const wxApi = getWx() const wxApi = getWx();
if (!wxApi?.createOffscreenCanvas) { if (!wxApi?.createOffscreenCanvas) {
throw new Error('当前微信基础库不支持 createOffscreenCanvas请升级基础库') throw new Error("当前微信基础库不支持 createOffscreenCanvas请升级基础库");
} }
const { info, resolvedSrc } = await resolvePosterImageInfo(posterSrc) const { info, resolvedSrc } = await resolvePosterImageInfo(posterSrc);
const infoPath = typeof info.path === 'string' ? info.path : '' const infoPath = typeof info.path === "string" ? info.path : "";
const img = await loadCanvasImage(canvas, resolvedSrc, infoPath) const img = await loadCanvasImage(canvas, resolvedSrc, infoPath);
const w = img.width const w = img.width;
const h = img.height const h = img.height;
const sys = uni.getSystemInfoSync() const dpr = 1;
const dpr = sys.pixelRatio || 2
canvas.width = w * dpr canvas.width = w * dpr;
canvas.height = h * dpr canvas.height = h * dpr;
ctx.scale(dpr, dpr) ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, w, h) ctx.clearRect(0, 0, w, h);
ctx.drawImage(img, 0, 0, w, h) ctx.drawImage(img, 0, 0, w, h);
const positions = POSTER_QR_POSITIONS[mode] || POSTER_QR_POSITIONS.promote const positions = POSTER_QR_POSITIONS[mode] || POSTER_QR_POSITIONS.promote;
const position = positions[index] || positions[0] const position = positions[index] || positions[0];
const qrY = position.y < 0 ? h + position.y : position.y const qrY = position.y < 0 ? h + position.y : position.y;
const s = position.size const s = position.size;
const off = wxApi.createOffscreenCanvas({ type: '2d', width: s, height: s }) const off = wxApi.createOffscreenCanvas({ type: "2d", width: s, height: s });
const octx = off.getContext('2d') const octx = off.getContext("2d");
const qr = new UQRCode() const qr = new UQRCode();
qr.data = linkUrl qr.data = linkUrl;
qr.size = s qr.size = s;
qr.margin = 0 qr.margin = 0;
qr.make() qr.make();
qr.canvasContext = octx qr.canvasContext = octx;
await qr.drawCanvas() await qr.drawCanvas();
ctx.drawImage(off, position.x, qrY, s, s) ctx.drawImage(off, position.x, qrY, s, s);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const conf = { const conf = {
canvas, canvas,
fileType: 'png', fileType: "png",
quality: 1, quality: 1,
success: (res) => resolve(res.tempFilePath), success: (res) => {
mergedPosterCache.set(cacheKey, res.tempFilePath);
resolve(res.tempFilePath);
},
fail: reject, fail: reject,
} };
if (componentInstance) { if (componentInstance) {
uni.canvasToTempFilePath(conf, componentInstance) uni.canvasToTempFilePath(conf, componentInstance);
} else { } else {
uni.canvasToTempFilePath(conf) uni.canvasToTempFilePath(conf);
} }
}) });
} }

View File

@@ -8,6 +8,58 @@ const BASE_URL = getApiPrefix()
// #ifndef H5 // #ifndef H5
const BASE_URL = getApiBaseUrl() const BASE_URL = getApiBaseUrl()
// #endif // #endif
const TEMP_USER_INVALID_CODE = 100012
let wxMiniReauthPromise = null
const clearAuthData = () => {
uni.removeStorageSync('token')
uni.removeStorageSync('refreshAfter')
uni.removeStorageSync('accessExpire')
uni.removeStorageSync('userInfo')
uni.removeStorageSync('agentInfo')
}
const silentWxMiniReAuth = () => {
if (wxMiniReauthPromise)
return wxMiniReauthPromise
wxMiniReauthPromise = new Promise((resolve, reject) => {
clearAuthData()
uni.login({
success: (loginRes) => {
if (!loginRes.code) {
reject(new Error('wx.login failed'))
return
}
uni.request({
url: `${BASE_URL}/user/wxMiniAuth`,
method: 'POST',
data: { code: loginRes.code },
header: {
'X-Platform': 'wxmini',
},
success: (authRes) => {
if (authRes.statusCode === 200 && authRes.data?.code === 200 && authRes.data?.data) {
uni.setStorageSync('token', authRes.data.data.accessToken)
uni.setStorageSync('refreshAfter', authRes.data.data.refreshAfter)
uni.setStorageSync('accessExpire', authRes.data.data.accessExpire)
resolve(authRes.data)
return
}
reject(authRes.data || new Error('wxMiniAuth failed'))
},
fail: reject,
})
},
fail: reject,
})
}).finally(() => {
wxMiniReauthPromise = null
})
return wxMiniReauthPromise
}
function request(options) { function request(options) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
// 请求拦截器逻辑 // 请求拦截器逻辑
@@ -38,6 +90,24 @@ function request(options) {
success: (res) => { success: (res) => {
// 响应拦截器逻辑 // 响应拦截器逻辑
if (res.statusCode === 200) { if (res.statusCode === 200) {
if (res.data?.code === TEMP_USER_INVALID_CODE) {
if (!options.__retriedAfterReauth) {
silentWxMiniReAuth()
.then(() => {
request({
...options,
hideLoading: true,
__retriedAfterReauth: true,
}).then(resolve).catch(reject)
})
.catch(() => {
reject(res.data)
})
return
}
reject(res.data)
return
}
resolve(res.data) resolve(res.data)
} }
else if (res.statusCode === 401) { else if (res.statusCode === 401) {

View File

@@ -1,68 +1,72 @@
/** /**
* 最简配置:两份 json 用相对路径 importimport.meta.env.PROD 由 Vite 构建期替换成字面量。 * 最简配置:两份 json 用相对路径 importimport.meta.env.PROD 由 Vite 构建期替换成字面量。
*/ */
import dev from '../config/runtime.development.json' import dev from "../config/runtime.development.json";
import prod from '../config/runtime.production.json' import prod from "../config/runtime.production.json";
const _e = import.meta.env.PROD ? prod : dev const _e = import.meta.env.PROD ? prod : dev;
function nonempty(name) { function nonempty(name) {
const v = _e[name] const v = _e[name];
if (v === undefined || v === '') { if (v === undefined || v === "") {
throw new Error(`缺少运行时配置: ${name}(请检查 src/config/runtime.*.json`) throw new Error(
`缺少运行时配置: ${name}(请检查 src/config/runtime.*.json`,
);
} }
return String(v) return String(v);
} }
export function getApiOrigin() { export function getApiOrigin() {
return nonempty('apiUrl').replace(/\/$/, '') return nonempty("apiUrl").replace(/\/$/, "");
}
export function getPosterOrigin() {
return nonempty("posterUrl").replace(/\/$/, "");
} }
export function getApiPrefix() { export function getApiPrefix() {
const p = nonempty('apiPrefix') const p = nonempty("apiPrefix");
return p.startsWith('/') ? p : `/${p}` return p.startsWith("/") ? p : `/${p}`;
} }
export function getApiBaseUrl() { export function getApiBaseUrl() {
return `${getApiOrigin()}${getApiPrefix()}` return `${getApiOrigin()}${getApiPrefix()}`;
} }
export function getSiteOrigin() { export function getSiteOrigin() {
return nonempty('siteOrigin').replace(/\/$/, '') return nonempty("siteOrigin").replace(/\/$/, "");
} }
export function getCustomerServiceUrl() { export function getCustomerServiceUrl() {
return nonempty('customerServiceUrl') return nonempty("customerServiceUrl");
} }
export function getCustomerServiceCorpId() { export function getCustomerServiceCorpId() {
return String(_e.customerServiceCorpId || '') return String(_e.customerServiceCorpId || "");
} }
export function getInviteChannelKey() { export function getInviteChannelKey() {
return nonempty('inviteChannelKey') return nonempty("inviteChannelKey");
} }
export function getAppName() { export function getAppName() {
return nonempty('appName') return nonempty("appName");
} }
export function getCompanyName() { export function getCompanyName() {
return nonempty('companyName') return nonempty("companyName");
} }
export function getShareTitle() { export function getShareTitle() {
return nonempty('shareTitle') return nonempty("shareTitle");
} }
export function getMeShareTitle() { export function getMeShareTitle() {
return nonempty('shareTitleMe') return nonempty("shareTitleMe");
} }
export function getAgentTabShareTitle() { export function getAgentTabShareTitle() {
return nonempty('shareTitleAgent') return nonempty("shareTitleAgent");
} }
export function getAppDebug() { export function getAppDebug() {
return Boolean(_e.appDebug) return Boolean(_e.appDebug);
} }