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 getAppDebug: typeof import('./utils/runtimeEnv.js')['getAppDebug']
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 getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentScope: typeof import('vue')['getCurrentScope']
@@ -55,6 +56,7 @@ declare global {
const getCustomerServiceUrl: typeof import('./utils/runtimeEnv.js')['getCustomerServiceUrl']
const getInviteChannelKey: typeof import('./utils/runtimeEnv.js')['getInviteChannelKey']
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 getShareTitle: typeof import('./utils/runtimeEnv.js')['getShareTitle']
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 getAppDebug: UnwrapRef<typeof import('./utils/runtimeEnv.js')['getAppDebug']>
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 getCurrentInstance: UnwrapRef<typeof import('vue')['getCurrentInstance']>
readonly getCurrentScope: UnwrapRef<typeof import('vue')['getCurrentScope']>
@@ -400,6 +403,7 @@ declare module 'vue' {
readonly getCustomerServiceUrl: UnwrapRef<typeof import('./utils/runtimeEnv.js')['getCustomerServiceUrl']>
readonly getInviteChannelKey: UnwrapRef<typeof import('./utils/runtimeEnv.js')['getInviteChannelKey']>
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 getShareTitle: UnwrapRef<typeof import('./utils/runtimeEnv.js')['getShareTitle']>
readonly getSiteOrigin: UnwrapRef<typeof import('./utils/runtimeEnv.js')['getSiteOrigin']>

View File

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

View File

@@ -1,6 +1,7 @@
{
"apiUrl": "http://127.0.0.1:8888",
"apiPrefix": "/api/v1",
"posterUrl": "http://127.0.0.1:8888/posts",
"siteOrigin": "http://127.0.0.1:8888",
"appName": "赤眉",
"companyName": "戎行技术有限公司有限公司",

View File

@@ -1,6 +1,7 @@
{
"apiUrl": "https://chimei.ronsafe.cn",
"apiPrefix": "/api/v1",
"posterUrl": "https://chimei.ronsafe.cn/posts",
"siteOrigin": "https://chimei.ronsafe.cn",
"appName": "赤眉",
"companyName": "戎行技术有限公司有限公司",

1
src/env.d.ts vendored
View File

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

View File

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

View File

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

View File

@@ -33,7 +33,7 @@
<wd-form :model="formData" ref="promotionForm">
<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"
:rules="[{ required: true, message: '请选择报告类型' }]" />
@@ -76,17 +76,6 @@ import GzhQrcode from '@/components/GzhQrcode.vue'
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 showPricePicker = ref(false)
@@ -102,6 +91,18 @@ const formData = ref({
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(() => {
if (!pickerProductConfig.value) return '0.00'
@@ -153,16 +154,6 @@ const generatePromotionCode = async () => {
}
try {
// 获取选中产品的完整信息
const reportType = reportTypes.find(item => item.value === formData.value.productType)
if (!reportType) {
uni.showToast({
title: '请选择有效的报告类型',
icon: 'none'
})
return
}
const res = await generatePromotionLink({
product: formData.value.productType,
price: formData.value.clientPrice
@@ -187,20 +178,30 @@ const generatePromotionCode = async () => {
// 选择类型
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
if (productConfig.value) {
for (let i of productConfig.value) {
if (i.product_id === reportType.id) {
pickerProductConfig.value = i
formData.value.clientPrice = i.p_pricing_standard.toString()
}
if (!productConfig.value) {
pickerProductConfig.value = null
formData.value.clientPrice = null
return
}
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()
if (res.code === 200) {
productConfig.value = res.data.AgentProductConfig
// 选择第一个报告类型
selectProductType(1) // 使用ID 1选择第一个报告类型
productConfig.value = res.data.AgentProductConfig || []
const firstType = reportTypes.value[0]
if (firstType) {
selectProductType(firstType.value)
} else {
pickerProductConfig.value = null
formData.value.productType = ''
formData.value.clientPrice = null
uni.showToast({
title: '暂无可推广产品',
icon: 'none'
})
}
} else {
uni.showToast({
title: res.msg || '获取配置失败',
@@ -233,11 +244,9 @@ const onPriceChange = (price) => {
// 类型选择确认
const onConfirmType = (e) => {
// picker在单列模式下返回的是选中项的值
if (e && e.value && e.value.length > 0) {
const selectedValue = e.value[0]
const selectedValue = e?.value?.[0] ?? e?.value ?? e?.selectedOptions?.[0]?.value
if (selectedValue !== undefined && selectedValue !== null && 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 一致 */
export const POSTER_QR_POSITIONS = {
@@ -16,43 +17,58 @@ export const POSTER_QR_POSITIONS = {
{ x: 255, y: 940, size: 250 },
],
invitation: [{ x: 360, y: -1370, size: 360 }],
};
function buildPosterUrl(filename) {
return `${getPosterOrigin()}/${filename}`;
}
export function getPosterSrcList(mode) {
if (mode === 'invitation') {
return ['/static/poster/yq_qrcode_1.png']
if (mode === "invitation") {
return [buildPosterUrl("yq_qrcode_1.png")];
}
return [
'/static/poster/tg_qrcode_1.png',
'/static/poster/tg_qrcode_2.png',
'/static/poster/tg_qrcode_3.png',
'/static/poster/tg_qrcode_4.png',
'/static/poster/tg_qrcode_5.png',
'/static/poster/tg_qrcode_6.png',
'/static/poster/tg_qrcode_7.png',
'/static/poster/tg_qrcode_8.png',
]
buildPosterUrl("tg_qrcode_1.png"),
buildPosterUrl("tg_qrcode_2.png"),
buildPosterUrl("tg_qrcode_3.png"),
buildPosterUrl("tg_qrcode_4.png"),
buildPosterUrl("tg_qrcode_5.png"),
buildPosterUrl("tg_qrcode_6.png"),
buildPosterUrl("tg_qrcode_7.png"),
buildPosterUrl("tg_qrcode_8.png"),
];
}
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 时小程序侧失败)
* @param {string} posterSrc 如 /static/poster/tg_qrcode_2.png
* @param {string} posterSrc 如 https://example.com/posts/tg_qrcode_2.png
*/
function getPosterSrcCandidates(posterSrc) {
const base = posterSrc.replace(/\.(png|jpg|jpeg)$/i, '')
return [`${base}.png`, `${base}.jpg`, `${base}.jpeg`]
const base = posterSrc.replace(/\.(png|jpg|jpeg)$/i, "");
return [`${base}.png`, `${base}.jpg`, `${base}.jpeg`];
}
/**
* @returns {Promise<{ info: Record<string, unknown>, resolvedSrc: string }>}
*/
async function resolvePosterImageInfo(posterSrc) {
const candidates = getPosterSrcCandidates(posterSrc)
let lastErr = ''
const candidates = getPosterSrcCandidates(posterSrc);
let lastErr = "";
for (const src of candidates) {
try {
const info = await new Promise((resolve, reject) => {
@@ -60,16 +76,16 @@ async function resolvePosterImageInfo(posterSrc) {
src,
success: resolve,
fail: (e) => reject(e),
})
})
return { info, resolvedSrc: src }
});
});
return { info, resolvedSrc: src };
} catch (e) {
lastErr = (e && (e.errMsg || e.message)) || String(e)
lastErr = (e && (e.errMsg || e.message)) || String(e);
}
}
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
*/
function loadCanvasImage(canvas, resolvedSrc, infoPath) {
const img = canvas.createImage()
const img = canvas.createImage();
return new Promise((resolve, reject) => {
const tryPath = (p, isFallback) => {
img.onload = () => resolve(img)
img.onload = () => resolve(img);
img.onerror = () => {
if (!isFallback && infoPath && p !== infoPath) {
tryPath(infoPath, true)
tryPath(infoPath, true);
} else {
reject(new Error(`canvas 图片加载失败: ${p}`))
reject(new Error(`canvas 图片加载失败: ${p}`));
}
}
img.src = p
}
tryPath(resolvedSrc, false)
})
};
img.src = p;
};
tryPath(resolvedSrc, false);
});
}
/**
@@ -105,60 +121,67 @@ function loadCanvasImage(canvas, resolvedSrc, infoPath) {
* @returns {Promise<string>} tempFilePath
*/
export async function drawMergedPosterWeixin(opts) {
const { canvas, linkUrl, posterSrc, mode, index, componentInstance } = opts
const ctx = canvas.getContext('2d')
const { canvas, linkUrl, posterSrc, mode, index, componentInstance } = opts;
const cacheKey = buildMergedPosterCacheKey({ linkUrl, posterSrc, mode, index });
const cachedPath = mergedPosterCache.get(cacheKey);
if (cachedPath) {
return cachedPath;
}
const ctx = canvas.getContext("2d");
if (!ctx) {
throw new Error('canvas 2d 不可用')
throw new Error("canvas 2d 不可用");
}
const wxApi = getWx()
const wxApi = getWx();
if (!wxApi?.createOffscreenCanvas) {
throw new Error('当前微信基础库不支持 createOffscreenCanvas请升级基础库')
throw new Error("当前微信基础库不支持 createOffscreenCanvas请升级基础库");
}
const { info, resolvedSrc } = await resolvePosterImageInfo(posterSrc)
const infoPath = typeof info.path === 'string' ? info.path : ''
const img = await loadCanvasImage(canvas, resolvedSrc, infoPath)
const { info, resolvedSrc } = await resolvePosterImageInfo(posterSrc);
const infoPath = typeof info.path === "string" ? info.path : "";
const img = await loadCanvasImage(canvas, resolvedSrc, infoPath);
const w = img.width
const h = img.height
const sys = uni.getSystemInfoSync()
const dpr = sys.pixelRatio || 2
const w = img.width;
const h = img.height;
const dpr = 1;
canvas.width = w * dpr
canvas.height = h * dpr
ctx.scale(dpr, dpr)
ctx.clearRect(0, 0, w, h)
ctx.drawImage(img, 0, 0, w, h)
canvas.width = w * dpr;
canvas.height = h * dpr;
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, w, h);
ctx.drawImage(img, 0, 0, w, h);
const positions = POSTER_QR_POSITIONS[mode] || POSTER_QR_POSITIONS.promote
const position = positions[index] || positions[0]
const qrY = position.y < 0 ? h + position.y : position.y
const s = position.size
const positions = POSTER_QR_POSITIONS[mode] || POSTER_QR_POSITIONS.promote;
const position = positions[index] || positions[0];
const qrY = position.y < 0 ? h + position.y : position.y;
const s = position.size;
const off = wxApi.createOffscreenCanvas({ type: '2d', width: s, height: s })
const octx = off.getContext('2d')
const qr = new UQRCode()
qr.data = linkUrl
qr.size = s
qr.margin = 0
qr.make()
qr.canvasContext = octx
await qr.drawCanvas()
ctx.drawImage(off, position.x, qrY, s, s)
const off = wxApi.createOffscreenCanvas({ type: "2d", width: s, height: s });
const octx = off.getContext("2d");
const qr = new UQRCode();
qr.data = linkUrl;
qr.size = s;
qr.margin = 0;
qr.make();
qr.canvasContext = octx;
await qr.drawCanvas();
ctx.drawImage(off, position.x, qrY, s, s);
return new Promise((resolve, reject) => {
const conf = {
canvas,
fileType: 'png',
fileType: "png",
quality: 1,
success: (res) => resolve(res.tempFilePath),
success: (res) => {
mergedPosterCache.set(cacheKey, res.tempFilePath);
resolve(res.tempFilePath);
},
fail: reject,
}
};
if (componentInstance) {
uni.canvasToTempFilePath(conf, componentInstance)
uni.canvasToTempFilePath(conf, componentInstance);
} else {
uni.canvasToTempFilePath(conf)
uni.canvasToTempFilePath(conf);
}
})
});
}

View File

@@ -8,6 +8,58 @@ const BASE_URL = getApiPrefix()
// #ifndef H5
const BASE_URL = getApiBaseUrl()
// #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) {
return new Promise((resolve, reject) => {
// 请求拦截器逻辑
@@ -38,6 +90,24 @@ function request(options) {
success: (res) => {
// 响应拦截器逻辑
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)
}
else if (res.statusCode === 401) {

View File

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