first commit

This commit is contained in:
2026-04-08 14:14:10 +08:00
commit f62289c97b
110 changed files with 21888 additions and 0 deletions

View File

@@ -0,0 +1,332 @@
<template>
<wd-popup v-model="popupVisible" position="bottom" close-on-click-modal @close="handleClose">
<view class="bg-white rounded-t-lg p-4">
<view class="flex justify-between items-center mb-4">
<text class="text-gray-400" @click="cancel">取消</text>
<text class="text-lg font-medium">申请成为代理</text>
<text class="text-gray-400" @click="cancel">关闭</text>
</view>
<view>
</view>
<wd-form :model="form" :rules="rules">
<wd-cell-group border>
<!-- 区域选择 -->
<wd-col-picker v-model="region" label="代理区域" label-width="100px" prop="region" placeholder="请选择代理区域"
:columns="columns" :column-change="handleColumnChange" @confirm="handleRegionConfirm"
:display-format="displayFormat" />
<!-- 手机号 -->
<wd-input label="手机号码" label-width="100px" type="number" v-model="form.mobile" prop="mobile"
placeholder="请输入您的手机号" :disabled="true" readonly :rules="[
{ required: true, message: '请输入手机号' },
{ required: true, pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号' }
]" />
<!-- 验证码 -->
<wd-input label="验证码" label-width="100px" type="number" v-model="form.code" prop="code" placeholder="请输入验证码"
:rules="[{ required: true, message: '请输入验证码' }]" use-suffix-slot>
<template #suffix>
<button class="verify-btn" :disabled="countdown > 0" @click.stop="sendVerifyCode">
{{ countdown > 0 ? `${countdown}s` : '获取验证码' }}
</button>
</template>
</wd-input>
</wd-cell-group>
<!-- 同意条款的复选框 -->
<view class="p-4">
<view class="flex items-start">
<wd-checkbox v-model="isAgreed" size="16px" class="flex-shrink-0 mr-2"></wd-checkbox>
<view class="text-xs text-gray-400 leading-tight">
我已阅读并同意
<text class="text-blue-400" @click.stop="toUserAgreement">用户协议</text>
<text class="text-blue-400" @click.stop="toServiceAgreement">信息技术服务合同</text>
<text class="text-blue-400" @click.stop="toAgentManageAgreement">推广方管理制度协议</text>
<view class="text-xs text-gray-400 mt-1">点击勾选即代表您同意上述法律文书的相关条款并签署上述法律文书</view>
<view class="text-xs text-gray-400 mt-1">手机号未在本平台注册账号则申请后将自动生成账号</view>
</view>
</view>
</view>
<view class="p-4">
<wd-button type="primary" block @click="submitForm">提交申请</wd-button>
<wd-button type="default" block class="mt-2" @click="cancel">取消</wd-button>
</view>
</wd-form>
</view>
</wd-popup>
<wd-toast />
</template>
<script setup>
import { ref, computed, watch, onUnmounted, onMounted } from 'vue'
import { useColPickerData } from '../hooks/useColPickerData'
import { useToast } from 'wot-design-uni'
import { getCode } from '@/api/apis.js' // 导入getCode API
const props = defineProps({
show: {
type: Boolean,
default: false
},
ancestor: {
type: String,
default: ''
}
})
const region = ref([]) // 存储地区代码数组
const regionText = ref('') // 存储地区文本
const isAgreed = ref(false) // 用户是否同意协议
// 格式化显示文本
const displayFormat = (selectedItems) => {
if (selectedItems.length === 0) return ''
return selectedItems.map(item => item.label).join(' ')
}
const emit = defineEmits(['update:show', 'submit', 'close'])
const form = ref({
mobile: '',
code: '',
})
// 使用wot-design-ui的toast组件
const toast = useToast()
// 不再需要监听region变化触发表单验证
// watch(region, (newVal) => {
// // 当region变化时触发表单验证
// if (formRef.value) {
// formRef.value.validate(['region'])
// }
// })
const popupVisible = ref(false)
const countdown = ref(0)
let timer = null
// 初始化省市区数据
const { colPickerData, findChildrenByCode } = useColPickerData()
const columns = ref([
colPickerData.map((item) => {
return {
value: item.value,
label: item.text
}
})
])
// 表单验证规则 - 仍然保留以供wd-form组件使用
const rules = {
region: [{
required: true,
message: '请选择代理区域',
validator: (value) => {
// 这里直接检查region.value而不是传入的value
if (Array.isArray(region.value) && region.value.length === 3) {
return Promise.resolve()
}
return Promise.reject('请选择完整的省市区')
}
}],
mobile: [
{ required: true, message: '请输入手机号' },
{ required: true, pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号' }
],
code: [{ required: true, message: '请输入验证码' }],
}
// 协议跳转函数
const toUserAgreement = () => {
uni.navigateTo({
url: '/pages/agreement?type=user'
})
}
const toServiceAgreement = () => {
uni.navigateTo({
url: '/pages/agreement?type=service'
})
}
const toAgentManageAgreement = () => {
uni.navigateTo({
url: '/pages/agreement?type=manage'
})
}
// 监听show属性变化
watch(() => props.show, (newVal) => {
popupVisible.value = newVal
})
// 监听popupVisible同步回props.show
watch(() => popupVisible.value, (newVal) => {
emit('update:show', newVal)
})
// 组件挂载时获取用户信息并填入手机号
onMounted(() => {
const userInfo = uni.getStorageSync('userInfo')
if (userInfo && userInfo.mobile) {
form.value.mobile = userInfo.mobile
}
})
// Popup关闭事件
const handleClose = () => {
emit('close')
}
// 取消按钮
const cancel = () => {
popupVisible.value = false
emit('close')
}
// 处理列变化
const handleColumnChange = ({ selectedItem, resolve, finish }) => {
const children = findChildrenByCode(colPickerData, selectedItem.value)
if (children && children.length) {
resolve(children.map(item => ({
label: item.text,
value: item.value
})))
} else {
finish()
}
}
// 区域选择确认 - 获取选中项的文本值
const handleRegionConfirm = ({ value, selectedItems }) => {
// 存储地区文本
regionText.value = selectedItems.map(item => item.label).join('-')
}
// 判断手机号是否有效
const isPhoneNumberValid = computed(() => {
return /^1[3-9]\d{9}$/.test(form.value.mobile)
})
// 发送验证码
const sendVerifyCode = async () => {
// 验证手机号
if (!form.value.mobile) {
toast.info('请输入手机号')
return
}
if (!isPhoneNumberValid.value) {
toast.info('手机号格式不正确')
return
}
// 发送验证码请求
getCode({
mobile: form.value.mobile,
actionType: 'agentApply',
}).then((res) => {
if (res.code === 200) {
toast.success('验证码已发送')
// 开始倒计时
startCountdown()
} else {
toast.error(res.msg || '发送失败')
}
}).catch((err) => {
toast.error('网络错误')
})
}
// 开始倒计时
const startCountdown = () => {
countdown.value = 60
if (timer) clearInterval(timer)
timer = setInterval(() => {
countdown.value--
if (countdown.value <= 0) {
clearInterval(timer)
timer = null
}
}, 1000)
}
// 组件卸载时清除计时器
onUnmounted(() => {
if (timer) {
clearInterval(timer)
timer = null
}
})
// 提交表单 - 不再使用formRef验证
const submitForm = () => {
try {
// 检查区域是否已选择
if (!Array.isArray(region.value) || region.value.length < 3) {
toast.info('请选择完整的代理区域')
return
}
if (!regionText.value) {
toast.info('请选择代理区域')
return
}
// 检查手机号
if (!form.value.mobile) {
toast.info('请输入手机号')
return
}
if (!isPhoneNumberValid.value) {
toast.info('手机号格式不正确')
return
}
// 检查验证码
if (!form.value.code) {
toast.info('请输入验证码')
return
}
// 检查用户是否同意协议
if (!isAgreed.value) {
toast.info('请阅读并同意相关协议')
return
}
// 所有验证通过,构建表单数据
const formData = {
...form.value,
region: regionText.value,
}
// 提交完整数据
emit('submit', formData)
} catch {
toast.error('系统错误,请稍后重试')
}
}
</script>
<style scoped>
.verify-btn {
height: 32px;
padding: 0 12px;
background-color: #3b82f6;
color: white;
border-radius: 4px;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
}
.verify-btn[disabled] {
background-color: #a0aec0;
}
</style>

View File

@@ -0,0 +1,12 @@
<template>
<view class="flex flex-col items-center justify-center py-16">
<image src="/static/image/empty.svg" mode="aspectFit" class="w-48 h-48 mb-4" />
<text class="text-gray-400 text-base">{{ text }}</text>
</view>
</template>
<script setup>
defineProps({
text: { type: String, default: '暂无数据' }
})
</script>

View File

@@ -0,0 +1,209 @@
<script setup>
import { ref, defineProps, defineEmits, computed } from 'vue'
const props = defineProps({
visible: {
type: Boolean,
default: false
},
type: {
type: String,
default: 'example', // 'example' | 'withdraw'
validator: (value) => ['example', 'withdraw'].includes(value)
}
})
const emit = defineEmits(['close'])
// 根据类型计算显示内容
const modalConfig = computed(() => {
if (props.type === 'withdraw') {
return {
title: '提现说明',
highlight: '点击二维码全屏查看',
instruction: '全屏后长按识别关注公众号',
description: '提现功能请在公众号内操作'
}
}
// 默认是查看示例报告
return {
title: '关注公众号',
highlight: '点击二维码全屏查看',
instruction: '全屏后长按识别关注公众号',
description: '关注公众号查看示例报告'
}
})
function handleClose() {
emit('close')
}
function handleMaskClick() {
handleClose()
}
// 预览图片
function previewImage() {
uni.previewImage({
urls: ['/static/qrcode/fwhqrcode.jpg'],
current: '/static/qrcode/fwhqrcode.jpg'
})
}
</script>
<template>
<view v-if="visible" class="gzh-qrcode-modal">
<view class="modal-mask" @click="handleMaskClick" />
<view class="modal-content">
<view class="modal-header">
<view class="modal-title">{{ modalConfig.title }}</view>
<view class="close-btn" @click="handleClose">
<text class="close-icon">×</text>
</view>
</view>
<view class="qrcode-container">
<image class="qrcode-image" src="/static/qrcode/fwhqrcode.jpg" mode="aspectFit" @click="previewImage" />
</view>
<view class="modal-message">
<text class="highlight">{{ modalConfig.highlight }}</text>
<text class="instruction">{{ modalConfig.instruction }}</text>
<text class="description">{{ modalConfig.description }}</text>
</view>
</view>
</view>
</template>
<style scoped>
.gzh-qrcode-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
.modal-mask {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
}
.modal-content {
background: #fff;
border-radius: 12px;
width: 300px;
max-width: 85%;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
z-index: 1001;
overflow: hidden;
}
.modal-header {
position: relative;
padding: 16px 20px;
border-bottom: 1px solid #f0f0f0;
display: flex;
align-items: center;
justify-content: center;
}
.modal-title {
font-size: 16px;
font-weight: 600;
color: #333;
}
.close-btn {
position: absolute;
right: 16px;
top: 50%;
transform: translateY(-50%);
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.close-icon {
font-size: 20px;
color: #999;
line-height: 1;
}
.qrcode-container {
padding: 20px;
display: flex;
justify-content: center;
align-items: center;
}
.qrcode-image {
width: 200px;
height: 200px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.modal-message {
padding: 0 20px 24px;
text-align: center;
line-height: 1.6;
}
.modal-message text {
display: block;
font-size: 14px;
color: #666;
}
.highlight {
color: #007aff !important;
font-weight: 500;
margin-bottom: 4px;
}
/* 动画效果 */
.gzh-qrcode-modal {
animation: fadeIn 0.3s ease-out;
}
.modal-content {
animation: slideIn 0.3s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideIn {
from {
transform: scale(0.8);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
</style>

180
src/components/Payment.vue Normal file
View File

@@ -0,0 +1,180 @@
<template>
<wd-popup v-model="show" position="bottom" z-index="3000" custom-style="height: 50%; display: flex; flex-direction: column; justify-content: space-between; padding: 24px;">
<view class="text-center">
<text class="text-lg font-bold block">支付</text>
</view>
<view class="text-center">
<text class="font-bold text-xl block">{{ data.product_name }}</text>
<view class="text-3xl text-red-500 font-bold">
<!-- 显示原价和折扣价格 -->
<view v-if="discountPrice" class="line-through text-gray-500 mt-4" :class="{ 'text-2xl': discountPrice }">
¥ {{ data.sell_price }}
</view>
<view>¥ {{ discountPrice ? (data.sell_price * 0.2).toFixed(2) : data.sell_price }}</view>
</view>
<!-- 仅在折扣时显示活动说明 -->
<text v-if="discountPrice" class="text-sm text-red-500 mt-1 block">活动价2折优惠</text>
</view>
<!-- 支付方式选择 -->
<view class="">
<wd-cell-group>
<wd-cell clickable>
<template #title>
<text class="text-lg font-medium">微信支付</text>
</template>
<template #right-icon>
<view class="w-5 h-5 rounded-full border-2 border-green-500 bg-green-500 flex items-center justify-center">
<view class="w-2 h-2 bg-white rounded-full"></view>
</view>
</template>
</wd-cell>
</wd-cell-group>
</view>
<!-- 确认按钮 -->
<view class="">
<view @click="getPayment" class="w-full py-4 bg-green-500 text-center rounded-full shadow-lg active:bg-green-600 transition-colors">
<text class="text-white text-lg font-medium">确认支付</text>
</view>
</view>
</wd-popup>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { payment } from '@/api/apis.js'
import { useToast } from 'wot-design-uni'
const toast = useToast()
const props = defineProps({
data: {
type: Object,
required: true,
},
id: {
type: String,
required: true,
},
type: {
type: String,
required: true,
},
})
const show = defineModel()
const orderNo = ref('')
const discountPrice = ref(false) // 是否应用折扣
async function getPayment() {
if (!props.id) {
toast.error('订单信息异常,请重试')
return
}
try {
const res = await payment({
id: String(props.id),
pay_method: 'wechat',
pay_type: props.type,
})
if (res.data) {
orderNo.value = res.data.order_no
// 微信支付 - 兼容多种返回格式wechatpay-go 返回 appId/timeStamp/nonceStr/package/signType/paySign
const paymentData = res.data.prepay_data || res.data.prepayData || res.data
// 若 prepay_data 是字符串则解析
const data = typeof paymentData === 'string' ? (() => { try { return JSON.parse(paymentData) } catch { return {} } })() : (paymentData || {})
const timeStamp = data.timeStamp || data.timestamp || data.time_stamp
const nonceStr = data.nonceStr || data.noncestr || data.nonce_str
const packageVal = data.package
const signType = data.signType || data.sign_type || 'MD5'
const paySign = data.paySign || data.pay_sign || data.sign
// 校验必要参数
if (!timeStamp || !nonceStr || !packageVal || !paySign) {
toast.error('支付参数异常,请联系客服')
return
}
// #ifdef MP-WEIXIN
uni.requestPayment({
provider: 'wxpay',
timeStamp: String(timeStamp),
nonceStr: String(nonceStr),
package: String(packageVal),
signType: String(signType),
paySign: String(paySign),
success: (result) => {
toast.success('支付成功')
show.value = false
handlePaymentSuccess()
},
fail: (error) => {
toast.error(error.errMsg || '支付失败')
// 用户取消不关闭弹窗,方便重试
if (error.errMsg && !error.errMsg.includes('cancel')) {
show.value = false
}
}
})
// #endif
// #ifdef APP-PLUS
uni.requestPayment({
provider: 'wxpay',
orderInfo: {
appid: paymentData.appid,
noncestr: nonceStr,
package: packageVal,
partnerid: paymentData.partnerid,
prepayid: paymentData.prepayid,
timestamp: timeStamp,
sign: paySign
},
success: (result) => {
toast.success('支付成功')
show.value = false
handlePaymentSuccess()
},
fail: (error) => {
toast.error('支付失败')
show.value = false
}
})
// #endif
// #ifdef H5
if (typeof WeixinJSBridge !== 'undefined') {
WeixinJSBridge.invoke('getBrandWCPayRequest', paymentData, function (result) {
if (result.err_msg === 'get_brand_wcpay_request:ok') {
toast.success('支付成功')
show.value = false
handlePaymentSuccess()
} else {
toast.error('支付失败')
}
})
} else {
toast.error('当前环境不支持微信支付')
}
// #endif
} else {
toast.error(res.msg || '获取支付信息失败')
}
} catch (error) {
toast.error(error?.data?.msg || error?.msg || '支付请求失败')
}
}
function handlePaymentSuccess() {
// 支付成功后的处理
setTimeout(() => {
uni.switchTab({
url: `/pages/me`
})
}, 1000)
}
</script>
<style scoped></style>

View File

@@ -0,0 +1,175 @@
<template>
<wd-popup v-model="show" position="bottom" round close-on-click-modal destroy-on-close>
<div class="min-h-[500px] bg-gray-50 text-gray-600">
<div class="h-10 bg-white flex items-center justify-center font-semibold text-lg">设置客户查询价
</div>
<div class="card m-4">
<div class="flex items-center justify-between">
<div class="text-lg">
客户查询价 ()</div>
</div>
<div class="border-b border-gray-200">
<wd-input v-model="price" type="number" label="¥" label-width="28px" size="large"
:placeholder="`${productConfig.price_range_min} - ${productConfig.price_range_max}`"
@blur="onBlurPrice" custom-class="wd-input" />
</div>
<div class="flex items-center justify-between mt-2">
<div>推广收益为<span class="text-orange-500"> {{ promotionRevenue }} </span></div>
<div>我的成本为<span class="text-orange-500"> {{ costPrice }} </span></div>
</div>
</div>
<div class="card m-4">
<div class="text-lg mb-2">收益与成本说明</div>
<div>推广收益 = 客户查询价 - 我的成本</div>
<div>我的成本 = 提价成本 + 底价成本</div>
<div class="mt-1">提价成本超过平台标准定价部分平台会收取部分成本价</div>
<div class="">设定范围<span class="text-orange-500">{{
productConfig.price_range_min }}</span> - <span class="text-orange-500">{{
productConfig.price_range_max }}</span></div>
</div>
<div class="px-4 pb-4">
<wd-button class="w-full" round type="primary" size="large" @click="onConfirm">确认</wd-button>
</div>
</div>
</wd-popup>
<wd-toast />
</template>
<script setup>
import { ref, computed, watch, toRefs } from 'vue'
import { useToast } from 'wot-design-uni'
const props = defineProps({
defaultPrice: {
type: Number,
required: true
},
productConfig: {
type: Object,
required: true
}
})
const { defaultPrice, productConfig } = toRefs(props)
const emit = defineEmits(["change"])
const show = defineModel("show")
const price = ref(null)
const toast = useToast()
watch(show, () => {
price.value = defaultPrice.value
})
const costPrice = computed(() => {
if (!productConfig.value) return 0.00
// 平台定价成本
let platformPricing = 0
platformPricing += productConfig.value.cost_price
if (price.value > productConfig.value.p_pricing_standard) {
platformPricing += (price.value - productConfig.value.p_pricing_standard) * productConfig.value.p_overpricing_ratio
}
if (productConfig.value.a_pricing_standard > platformPricing && productConfig.value.a_pricing_end > platformPricing && productConfig.value.a_overpricing_ratio > 0) {
if (price.value > productConfig.value.a_pricing_standard) {
if (price.value > productConfig.value.a_pricing_end) {
platformPricing += (productConfig.value.a_pricing_end - productConfig.value.a_pricing_standard) * productConfig.value.a_overpricing_ratio
} else {
platformPricing += (price.value - productConfig.value.a_pricing_standard) * productConfig.value.a_overpricing_ratio
}
}
}
return safeTruncate(platformPricing)
})
const promotionRevenue = computed(() => {
return safeTruncate(price.value - costPrice.value)
});
// 价格校验与修正逻辑
const validatePrice = (currentPrice) => {
const min = productConfig.value.price_range_min;
const max = productConfig.value.price_range_max;
let newPrice = Number(currentPrice);
let message = '';
// 处理无效输入
if (isNaN(newPrice)) {
newPrice = defaultPrice.value;
return { newPrice, message: '输入无效,请输入价格' };
}
// 处理小数位数(兼容科学计数法)
try {
const priceString = newPrice.toString()
const [_, decimalPart = ""] = priceString.split('.');
// 当小数位数超过2位时处理
if (decimalPart.length > 2) {
newPrice = parseFloat(safeTruncate(newPrice));
message = '价格已自动格式化为两位小数';
}
} catch {}
// 范围校验(基于可能格式化后的值)
if (newPrice < min) {
message = `价格不能低于 ${min}`;
newPrice = min;
} else if (newPrice > max) {
message = `价格不能高于 ${max}`;
newPrice = max;
}
return { newPrice, message };
}
function safeTruncate(num, decimals = 2) {
if (isNaN(num) || !isFinite(num)) return "0.00";
const factor = 10 ** decimals;
const scaled = Math.trunc(num * factor);
const truncated = scaled / factor;
return truncated.toFixed(decimals);
}
const isManualConfirm = ref(false)
const onConfirm = () => {
if (isManualConfirm.value) return
const { newPrice, message } = validatePrice(price.value)
if (message) {
price.value = newPrice
toast.show(message)
} else {
emit("change", price.value)
show.value = false
}
}
const onBlurPrice = () => {
const { newPrice, message } = validatePrice(price.value)
if (message) {
isManualConfirm.value = true
price.value = newPrice
toast.show(message)
}
setTimeout(() => {
isManualConfirm.value = false
}, 0)
}
</script>
<style lang="scss" scoped>
.wd-input {
display: flex !important;
align-items: center !important;
justify-content: center !important;
:deep(.wd-input__label-inner) {
font-size: 24px !important; /* 增大label字体 */
}
:deep(.wd-input__inner) {
font-size: 24px !important; /* 增大输入框内字体 */
}
:deep(.wd-input) {
height: auto !important; /* 确保高度自适应 */
padding: 8px 0 !important; /* 增加垂直内边距 */
}
}
</style>

522
src/components/QRcode.vue Normal file
View File

@@ -0,0 +1,522 @@
<template>
<wd-popup v-model="show" position="bottom" round>
<!-- #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"
/>
<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-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"
/>
<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>
<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"
>
<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>
<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>
</view>
<view class="flex flex-col items-center justify-center" @click="copyUrl">
<image src="/static/image/icon_share_url.svg" class="w-10 h-10 rounded-full" />
<view class="text-center mt-1 text-gray-600 text-xs">复制链接</view>
</view>
</view>
</view>
<!-- #endif -->
<!-- #ifndef MP-WEIXIN -->
<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"
/>
</view>
</view>
<view class="divider">分享到好友</view>
<view class="flex items-center justify-around">
<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>
</view>
<view class="flex flex-col items-center justify-center" @click="copyUrl">
<image src="/static/image/icon_share_url.svg" class="w-10 h-10 rounded-full" />
<view class="text-center mt-1 text-gray-600 text-xs">复制链接</view>
</view>
</view>
</view>
<!-- #endif -->
</wd-popup>
</template>
<script setup>
import { ref, computed, toRefs, watch, nextTick, getCurrentInstance } from 'vue'
import { getApiBaseUrl, getAgentTabShareTitle, getShareTitle } from '@/utils/runtimeEnv.js'
import { buildPromotionH5Url } from '@/utils/promotionH5Url.js'
import { setMiniPromotionShareFriend } from '@/utils/miniPromotionSharePayload.js'
// #ifdef MP-WEIXIN
import { getPosterSrcList, drawMergedPosterWeixin } from '@/utils/posterQrWeixin.js'
// #endif
const props = defineProps({
linkIdentifier: {
type: String,
required: true,
},
mode: {
type: String,
default: 'promote',
},
})
const { linkIdentifier, mode } = toRefs(props)
const show = defineModel('show')
const imageWidth = ref(300)
const imageHeight = ref(500)
function generalUrl() {
return buildPromotionH5Url(mode.value, linkIdentifier.value)
}
// #ifndef MP-WEIXIN
const posterImageUrlRemote = computed(() => {
const qrcodeUrl = generalUrl()
return `${getApiBaseUrl()}/agent/promotion/qrcode?qrcode_type=${mode.value}&qrcode_url=${encodeURIComponent(qrcodeUrl)}`
})
// #endif
// #ifdef MP-WEIXIN
const instance = getCurrentInstance()
const posterSrcList = computed(() => getPosterSrcList(mode.value))
const renderedPaths = ref([])
const currentSwiperIndex = ref(0)
const swiperHeightPx = ref(420)
const previewWidthPx = ref(300)
/** 打开/切换模式时递增,强制 swiper 从第 0 页重建,避免受控 current 与手势打架导致卡顿 */
const swiperMountKey = ref(0)
/** 与基础库默认一致,缩短动画减少与异步生成叠在一起时的顿挫感 */
const easeOutCubic = 'easeOutCubic'
const posterPreviewStyle = computed(() => ({
width: `${previewWidthPx.value}px`,
height: `${swiperHeightPx.value}px`,
}))
/**
* 按本地底图宽高比,把整张海报缩进可视区域(避免 widthFix + 固定 swiper 高度裁切)
*/
function updateMpPosterLayout() {
const sys = uni.getSystemInfoSync()
const maxW = Math.min(sys.windowWidth * 0.92, 360)
const maxH = Math.min(sys.windowHeight * 0.62, 580)
const firstSrc = getPosterSrcList(mode.value)[0]
const fallbackRatio = 1920 / 1080
const applyRatio = (ratio) => {
let w = maxW
let h = w * ratio
if (h > maxH) {
h = maxH
w = h / ratio
}
previewWidthPx.value = Math.floor(w)
swiperHeightPx.value = Math.ceil(h)
}
return new Promise((resolve) => {
uni.getImageInfo({
src: firstSrc,
success: (info) => {
const ratio = info.width > 0 ? info.height / info.width : fallbackRatio
applyRatio(ratio)
resolve()
},
fail: () => {
applyRatio(fallbackRatio)
resolve()
},
})
})
}
/** 串行生成,避免多索引共用同一 canvas 竞态 */
let mpGenSeq = Promise.resolve()
function resetMpPosters() {
mpGenSeq = Promise.resolve()
const n = posterSrcList.value.length
renderedPaths.value = Array.from({ length: n }, () => '')
currentSwiperIndex.value = 0
}
function generatePosterMp(index) {
if (renderedPaths.value[index]) return Promise.resolve()
const proxy = instance?.proxy
if (!proxy) return Promise.resolve()
mpGenSeq = mpGenSeq
.catch(() => {})
.then(async () => {
if (renderedPaths.value[index]) return
await new Promise((r) => setTimeout(r, 80))
const canvas = await new Promise((resolve, reject) => {
uni.createSelectorQuery()
.in(proxy)
.select('#mpPosterCanvas')
.fields({ node: true, size: true })
.exec((res) => {
const node = res?.[0]?.node
if (node) resolve(node)
else reject(new Error('未获取到 canvas 节点'))
})
})
const temp = await drawMergedPosterWeixin({
canvas,
linkUrl: generalUrl(),
posterSrc: posterSrcList.value[index],
mode: mode.value,
index,
componentInstance: proxy,
})
const next = [...renderedPaths.value]
next[index] = temp
renderedPaths.value = next
})
return mpGenSeq
}
function onSwiperChange(e) {
const cur = e.detail?.current ?? 0
currentSwiperIndex.value = cur
if (!renderedPaths.value[cur]) {
// 等 swiper 切换动画走一部分再跑 canvas减轻主线程卡顿
setTimeout(() => {
generatePosterMp(cur).catch((err) => {
console.error('生成海报失败', err)
uni.showToast({ title: '海报生成失败', icon: 'none' })
})
}, 160)
}
}
watch(show, (v) => {
if (!v)
return
updateMpPosterLayout().then(() => {
swiperMountKey.value += 1
resetMpPosters()
nextTick(() => {
generatePosterMp(0).catch((err) => {
console.error('生成海报失败', err)
uni.showToast({ title: '海报生成失败', icon: 'none' })
})
})
})
})
watch([mode, linkIdentifier], () => {
if (!show.value)
return
updateMpPosterLayout().then(() => {
swiperMountKey.value += 1
resetMpPosters()
nextTick(() => {
generatePosterMp(0).catch(() => {})
})
})
})
// #endif
// #ifdef MP-WEIXIN
function shareTitleForMode() {
return mode.value === 'invitation' ? getShareTitle() : getAgentTabShareTitle()
}
function onShareFriendPrepare() {
if (!linkIdentifier.value) {
uni.showToast({ title: '请先生成推广内容', icon: 'none' })
return
}
const idx = currentSwiperIndex.value
const img = renderedPaths.value[idx] || ''
setMiniPromotionShareFriend({
title: shareTitleForMode(),
path: `/pages/h5open?m=${mode.value}&id=${encodeURIComponent(linkIdentifier.value)}`,
imageUrl: img,
})
}
// #endif
function calculateImageSize(imgWidth, imgHeight) {
const sysInfo = uni.getSystemInfoSync()
const maxHeight = sysInfo.windowHeight * 0.6
const maxWidth = sysInfo.windowWidth * 0.8
let width = 300
let height = width * (imgHeight / imgWidth)
if (height > maxHeight) {
height = maxHeight
width = height * (imgWidth / imgHeight)
}
if (width > maxWidth) {
width = maxWidth
height = width * (imgHeight / imgWidth)
}
return {
width: Math.floor(width),
height: Math.floor(height),
}
}
// #ifndef MP-WEIXIN
function onImageLoad() {
uni.getImageInfo({
src: posterImageUrlRemote.value,
success: (res) => {
const size = calculateImageSize(res.width, res.height)
imageWidth.value = size.width
imageHeight.value = size.height
},
fail: () => {},
})
}
function onImageError() {
uni.showToast({
title: '图片加载失败',
icon: 'none',
})
}
// #endif
function savePoster() {
uni.showLoading({ title: '正在保存…' })
// #ifdef MP-WEIXIN
const idx = currentSwiperIndex.value
const runSave = (filePath) => {
uni.saveImageToPhotosAlbum({
filePath,
success: () => {
uni.hideLoading()
uni.showToast({ title: '保存成功', icon: 'success' })
},
fail: (err) => {
uni.hideLoading()
if (err.errMsg && err.errMsg.includes('auth')) {
uni.showModal({
title: '提示',
content: '需要您授权保存图片到相册',
success: (r) => {
if (r.confirm) uni.openSetting()
},
})
} else {
uni.showToast({ title: '保存失败', icon: 'none' })
}
},
})
}
const path = renderedPaths.value[idx]
if (path) {
runSave(path)
return
}
generatePosterMp(idx)
.then(() => {
const p = renderedPaths.value[idx]
if (p) runSave(p)
else uni.hideLoading()
})
.catch(() => {
uni.hideLoading()
uni.showToast({ title: '请稍候再试', icon: 'none' })
})
// #endif
// #ifndef MP-WEIXIN
uni.downloadFile({
url: posterImageUrlRemote.value,
success: (downloadRes) => {
uni.saveImageToPhotosAlbum({
filePath: downloadRes.tempFilePath,
success: () => {
uni.hideLoading()
uni.showToast({ title: '保存成功', icon: 'success' })
},
fail: (err) => {
uni.hideLoading()
if (err.errMsg && err.errMsg.includes('auth')) {
uni.showModal({
title: '提示',
content: '需要您授权保存图片到相册',
success: (r) => {
if (r.confirm) uni.openSetting()
},
})
} else {
uni.showToast({ title: '保存失败', icon: 'none' })
}
},
})
},
fail: () => {
uni.hideLoading()
uni.showToast({ title: '图片下载失败', icon: 'none' })
},
})
// #endif
}
function copyUrl() {
uni.setClipboardData({
data: generalUrl(),
success: () => {
uni.showToast({
title: '链接已复制!',
icon: 'success',
})
},
})
}
</script>
<style lang="scss" scoped>
.divider {
position: relative;
display: flex;
align-items: center;
margin: 16px 0;
color: #969799;
font-size: 14px;
&::before,
&::after {
content: '';
height: 1px;
flex: 1;
background-color: #ebedf0;
}
&::before {
margin-right: 16px;
}
&::after {
margin-left: 16px;
}
}
.poster-image {
max-width: 100%;
max-height: 100%;
object-fit: contain;
display: block;
}
/* #ifdef MP-WEIXIN */
.mp-poster-canvas-hidden {
position: fixed;
left: -2000px;
top: 0;
width: 400px;
height: 400px;
z-index: -1;
}
.mp-poster-swiper {
width: 100%;
}
.mp-swiper-item {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
box-sizing: border-box;
}
.mp-poster-item {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.poster-preview-mp {
display: block;
max-width: 100%;
max-height: 100%;
}
.mp-share-actions {
display: flex;
flex-wrap: wrap;
justify-content: space-around;
align-items: flex-start;
row-gap: 18px;
column-gap: 8px;
padding: 0 4px 8px;
}
.share-mp-btn {
margin: 0;
padding: 0;
border: none;
background: transparent;
line-height: 1.2;
font-size: inherit;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.share-mp-btn::after {
border: none;
}
/* #endif */
</style>

View File

@@ -0,0 +1,36 @@
<template>
<view class="card mb-4 relative overflow-hidden" @click="goToVip">
<view class="absolute inset-0 bg-gradient-to-r from-yellow-400 to-yellow-300 opacity-40"></view>
<view class="p-2 relative z-10">
<view class="flex justify-between items-center">
<view>
<view class="text-lg font-bold text-yellow-800">会员专享特权</view>
<view class="text-sm text-yellow-700 mt-1">升级VIP获得更多收益</view>
</view>
<view class="bg-yellow-500 px-3 py-1 rounded-full text-white text-sm shadow-sm">
立即查看
</view>
</view>
</view>
<!-- 装饰元素 -->
<view class="absolute -right-4 -top-4 w-16 h-16 bg-yellow-200 rounded-full opacity-60"></view>
<view class="absolute right-5 -bottom-4 w-12 h-12 bg-yellow-100 rounded-full opacity-40"></view>
</view>
</template>
<script setup>
// 跳转到VIP页面
const goToVip = () => {
uni.navigateTo({
url: '/pages/agentVipApply'
})
}
</script>
<style scoped>
.card {
border-radius: 12px;
background-color: white;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
</style>