mirror of
https://gitee.com/niucloud-team/niucloud.git
synced 2026-09-14 07:38:36 +00:00
up uni-app
This commit is contained in:
parent
0022acba80
commit
195cea7bd4
@ -4,7 +4,8 @@
|
|||||||
<view v-show="requestData.status == 1 && requestData.error && requestData.error.length === 0 && !diy.getLoading()" class="diy-template-wrap">
|
<view v-show="requestData.status == 1 && requestData.error && requestData.error.length === 0 && !diy.getLoading()" class="diy-template-wrap">
|
||||||
<diy-group ref="diyGroupRef" :data="diyFormData"/>
|
<diy-group ref="diyGroupRef" :data="diyFormData"/>
|
||||||
</view>
|
</view>
|
||||||
<view class="flex flex-col" v-if="requestData.error && requestData.error.length > 0">
|
<!-- 目前只有自定义表单才展示错误信息,其他类型暂时不控制 -->
|
||||||
|
<view class="flex flex-col" v-if="requestData.error && requestData.error.length > 0 && diy.data == 'DIY_FORM'">
|
||||||
<view class="flex-1 flex flex-col items-center pt-[20rpx]" v-for="(item, index) in requestData.error.slice(0, 1)" :key="index">
|
<view class="flex-1 flex flex-col items-center pt-[20rpx]" v-for="(item, index) in requestData.error.slice(0, 1)" :key="index">
|
||||||
<text class="nc-iconfont nc-icon-tanhaoV6mm text-[#ccc] mb-[30rpx] !text-[100rpx]"></text>
|
<text class="nc-iconfont nc-icon-tanhaoV6mm text-[#ccc] mb-[30rpx] !text-[100rpx]"></text>
|
||||||
<view class="text-[38rpx] font-bold mt-3">{{ item.title }}</view>
|
<view class="text-[38rpx] font-bold mt-3">{{ item.title }}</view>
|
||||||
|
|||||||
@ -122,6 +122,9 @@
|
|||||||
<diy-form-file ref="diyFormFileRef" :component="component" :global="data.global" :index="index" />
|
<diy-form-file ref="diyFormFileRef" :component="component" :global="data.global" :index="index" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- 以下是addon文件夹下的自定义组件 -->
|
||||||
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
@ -132,6 +135,7 @@
|
|||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
|
||||||
import topTabbar from '@/components/top-tabbar/top-tabbar.vue'
|
import topTabbar from '@/components/top-tabbar/top-tabbar.vue'
|
||||||
import popAds from '@/components/pop-ads/pop-ads.vue'
|
import popAds from '@/components/pop-ads/pop-ads.vue'
|
||||||
import { useDiyGroup } from './useDiyGroup'
|
import { useDiyGroup } from './useDiyGroup'
|
||||||
|
|||||||
@ -8,6 +8,88 @@
|
|||||||
<text class="text-[#ec0003]">{{ diyComponent.field.required ? '*' : '' }}</text>
|
<text class="text-[#ec0003]">{{ diyComponent.field.required ? '*' : '' }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
<view>
|
||||||
|
<!-- <uni-table border>
|
||||||
|
<uni-tr v-for="(column, columnIndex) in diyComponent.columnList" :key="columnIndex">
|
||||||
|
<uni-th>{{ column.name }}</uni-th>
|
||||||
|
<uni-td>
|
||||||
|
<view class="layout-two-content"
|
||||||
|
v-if="column.type === 'text' || column.type === 'number' || column.type === 'idcard'">
|
||||||
|
<input :type="column.type" class="layout-one-content"
|
||||||
|
placeholderClass="layout-one-input-placeholder"
|
||||||
|
:placeholder-style="{'font-size': (diyComponent.fontSize * 2) + 'rpx' }"
|
||||||
|
:style="{'color': diyComponent.textColor,'font-size': (diyComponent.fontSize * 2) + 'rpx'}"
|
||||||
|
v-model="column.value" :disabled="isDisabled" />
|
||||||
|
</view>
|
||||||
|
<view class="layout-two-content"
|
||||||
|
v-else-if="column.type === 'mobile'">
|
||||||
|
<input type="tel" class="layout-one-content"
|
||||||
|
placeholderClass="layout-one-input-placeholder"
|
||||||
|
:placeholder-style="{'font-size': (diyComponent.fontSize * 2) + 'rpx' }"
|
||||||
|
:style="{'color': diyComponent.textColor,'font-size': (diyComponent.fontSize * 2) + 'rpx'}"
|
||||||
|
v-model="column.value" :disabled="isDisabled" />
|
||||||
|
</view>
|
||||||
|
<view v-else-if="column.type === 'gender'">
|
||||||
|
<view @click="openSex(column)"
|
||||||
|
class="px-[16rpx] box-border h-[80rpx] flex items-center justify-between border-solid border-[2rpx] border-[#e6e6e6] rounded-[10rpx] w-[100%]">
|
||||||
|
<view>
|
||||||
|
<text class="mr-[10rpx] text-[28rpx]"
|
||||||
|
:style="{'color': diyComponent.textColor,'font-size': (diyComponent.fontSize * 2) + 'rpx'}">
|
||||||
|
{{ getSexName(column) }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<text class="nc-iconfont nc-icon-xiaV6xx pull-down-arrow text-[#666]"
|
||||||
|
:class="{'selected': selectShow[columnIndex]}"
|
||||||
|
:style="{'font-size': (diyComponent.fontSize * 2 + 2) + 'rpx !important'}"></text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view v-else-if="column.type === 'date'">
|
||||||
|
<view class="layout-one-content" @click="openCalendar(column)">
|
||||||
|
<view
|
||||||
|
class="nc-iconfont nc-icon-a-riliV6xx-36 !text-[32rpx] text-[#999] mr-[16rpx]">
|
||||||
|
</view>
|
||||||
|
<view class="flex-1 text-overflow-ellipsis flex"
|
||||||
|
:class="{'!text-[#999]' : !diyComponent.field.value.date && !diyComponent.defaultControl}"
|
||||||
|
:style="{'color': diyComponent.textColor,'font-size': (diyComponent.fontSize * 2) + 'rpx' }">
|
||||||
|
{{ startDate }}
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view v-else-if="column.type === 'address'">
|
||||||
|
<view class="flex layout-one-content justify-between items-center">
|
||||||
|
<input type="text" class="flex-1" :placeholder="inputPlaceholder(column)"
|
||||||
|
placeholderClass="layout-one-input-placeholder"
|
||||||
|
:placeholder-style="{'font-size': (diyComponent.fontSize * 2) + 'rpx' }"
|
||||||
|
:style="{'color': diyComponent.textColor,'font-size': (diyComponent.fontSize * 2) + 'rpx'}"
|
||||||
|
v-model="column.value" :disabled="isDisabled" @click="selectArea(column)" />
|
||||||
|
<view class="text-[var(--primary-color)]" v-if="column.value" @click="column.value=''">
|
||||||
|
清除
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<textarea v-if="column.addressFormat=='province/city/district/address'" type="textarea" class="layout-one-content mt-2 w-full" placeholderClass="layout-one-input-placeholder" :placeholder-style="{'font-size': (diyComponent.fontSize * 2) + 'rpx' }" placeholder="详细地址(如小区门牌号)" :disabled="isDisabled"></textarea>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!– 仅当字段类型为 'radio' 时才显示 –>
|
||||||
|
<view class="layout-two-content" v-else-if="column.type === 'radio'">
|
||||||
|
<view @click="openPicker(columnIndex)"
|
||||||
|
class="px-[16rpx] box-border h-[80rpx] flex items-center justify-between border-solid border-[2rpx] border-[#e6e6e6] rounded-[10rpx] w-[100%]">
|
||||||
|
<view v-if="column.value">
|
||||||
|
<text class="mr-[10rpx] text-[28rpx]"
|
||||||
|
:style="{'color': diyComponent.textColor,'font-size': (diyComponent.fontSize * 2) + 'rpx'}">
|
||||||
|
{{ getSelectRadioName(columnIndex) }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<text v-else class="text-[28rpx] text-[#999]"
|
||||||
|
:style="{'font-size': (diyComponent.fontSize * 2) + 'rpx'}">请选择</text>
|
||||||
|
<text class="nc-iconfont nc-icon-xiaV6xx pull-down-arrow text-[#666]"
|
||||||
|
:class="{'selected': selectShow[columnIndex]}"
|
||||||
|
:style="{'font-size': (diyComponent.fontSize * 2 + 2) + 'rpx !important'}"></text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</uni-td>
|
||||||
|
</uni-tr>
|
||||||
|
</uni-table>-->
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 下拉弹窗 -->
|
<!-- 下拉弹窗 -->
|
||||||
@ -50,7 +132,10 @@
|
|||||||
import { ref, computed, watch, onMounted } from 'vue';
|
import { ref, computed, watch, onMounted } from 'vue';
|
||||||
import useDiyStore from '@/app/stores/diy';
|
import useDiyStore from '@/app/stores/diy';
|
||||||
import { img, timeStampTurnTime, timeTurnTimeStamp } from '@/utils/common';
|
import { img, timeStampTurnTime, timeTurnTimeStamp } from '@/utils/common';
|
||||||
|
// import uniTable from '@/addon/o2o/components/uni-table/components/uni-table/uni-table.vue'
|
||||||
|
// import uniTr from '@/addon/o2o/components/uni-table/components/uni-tr/uni-tr.vue'
|
||||||
|
// import uniTh from '@/addon/o2o/components/uni-table/components/uni-th/uni-th.vue'
|
||||||
|
// import uniTd from '@/addon/o2o/components/uni-table/components/uni-td/uni-td.vue'
|
||||||
const props = defineProps(['component', 'index', 'global']);
|
const props = defineProps(['component', 'index', 'global']);
|
||||||
const diyStore = useDiyStore();
|
const diyStore = useDiyStore();
|
||||||
const formData: any = ref({
|
const formData: any = ref({
|
||||||
@ -251,8 +336,8 @@
|
|||||||
|
|
||||||
|
|
||||||
const warpCss = computed(() => {
|
const warpCss = computed(() => {
|
||||||
var style = '';
|
let style = '';
|
||||||
style += 'position:relative;';
|
style += 'position:relative;';
|
||||||
if (diyComponent.value.componentStartBgColor) {
|
if (diyComponent.value.componentStartBgColor) {
|
||||||
if (diyComponent.value.componentStartBgColor && diyComponent.value.componentEndBgColor) style += `background:linear-gradient(${diyComponent.value.componentGradientAngle},${diyComponent.value.componentStartBgColor},${diyComponent.value.componentEndBgColor});`;
|
if (diyComponent.value.componentStartBgColor && diyComponent.value.componentEndBgColor) style += `background:linear-gradient(${diyComponent.value.componentGradientAngle},${diyComponent.value.componentStartBgColor},${diyComponent.value.componentEndBgColor});`;
|
||||||
else style += 'background-color:' + diyComponent.value.componentStartBgColor + ';';
|
else style += 'background-color:' + diyComponent.value.componentStartBgColor + ';';
|
||||||
|
|||||||
@ -10,13 +10,13 @@
|
|||||||
<view class="text-[26rpx] leading-[39rpx] text-[var(--text-color-light6)] mt-[24rpx] mb-[90rpx]">{{ t('bindMobileTip') }}</view>
|
<view class="text-[26rpx] leading-[39rpx] text-[var(--text-color-light6)] mt-[24rpx] mb-[90rpx]">{{ t('bindMobileTip') }}</view>
|
||||||
<u-form labelPosition="left" :model="formData" errorType='toast' :rules="rules" ref="formRef">
|
<u-form labelPosition="left" :model="formData" errorType='toast' :rules="rules" ref="formRef">
|
||||||
<view
|
<view
|
||||||
class="h-[90rpx] flex w-full items-center px-[30rpx] rounded-[var(--goods-rounded-mid)] box-border bg-[#F6F6F6]">
|
class="h-[90rpx] flex w-full items-center px-[30rpx] rounded-[40rpx] box-border bg-[#F6F6F6]">
|
||||||
<u-form-item label="" prop="mobile" :border-bottom="false">
|
<u-form-item label="" prop="mobile" :border-bottom="false">
|
||||||
<u-input v-model="formData.mobile" type="number" maxlength="11" border="none" :placeholder="t('mobilePlaceholder')" class="!bg-transparent" :disabled="real_name_input" fontSize="26rpx" placeholderClass="!text-[var(--text-color-light9)] text-[26rpx]" />
|
<u-input v-model="formData.mobile" type="number" maxlength="11" border="none" :placeholder="t('mobilePlaceholder')" class="!bg-transparent" :disabled="real_name_input" fontSize="26rpx" placeholderClass="!text-[var(--text-color-light9)] text-[26rpx]" />
|
||||||
</u-form-item>
|
</u-form-item>
|
||||||
</view>
|
</view>
|
||||||
<view
|
<view
|
||||||
class="h-[90rpx] flex w-full items-center px-[30rpx] rounded-[var(--goods-rounded-mid)] box-border bg-[#F6F6F6] mt-[40rpx]">
|
class="h-[90rpx] flex w-full items-center px-[30rpx] rounded-[40rpx] box-border bg-[#F6F6F6] mt-[40rpx]">
|
||||||
<u-form-item label="" prop="mobile_code" :border-bottom="false">
|
<u-form-item label="" prop="mobile_code" :border-bottom="false">
|
||||||
<u-input v-model="formData.mobile_code" type="number" maxlength="4" border="none" :placeholder="t('codePlaceholder')" class="!bg-transparent" :disabled="real_name_input" fontSize="26rpx" placeholderClass="!text-[var(--text-color-light9)]">
|
<u-input v-model="formData.mobile_code" type="number" maxlength="4" border="none" :placeholder="t('codePlaceholder')" class="!bg-transparent" :disabled="real_name_input" fontSize="26rpx" placeholderClass="!text-[var(--text-color-light9)]">
|
||||||
<template #suffix>
|
<template #suffix>
|
||||||
@ -84,18 +84,11 @@ const formData: any = reactive({
|
|||||||
|
|
||||||
const real_name_input = ref(true);
|
const real_name_input = ref(true);
|
||||||
|
|
||||||
const wxPrivacyPopupRef: any = ref(null)
|
|
||||||
|
|
||||||
onLoad(() => {
|
onLoad(() => {
|
||||||
// 防止浏览器自动填充
|
// 防止浏览器自动填充
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
real_name_input.value = false;
|
real_name_input.value = false;
|
||||||
}, 800)
|
}, 800)
|
||||||
// #ifdef MP
|
|
||||||
nextTick(() => {
|
|
||||||
if (wxPrivacyPopupRef.value) wxPrivacyPopupRef.value.proactive();
|
|
||||||
})
|
|
||||||
// #endif
|
|
||||||
|
|
||||||
uni.getStorageSync('openid') && (Object.assign(formData, { openid: uni.getStorageSync('openid') }))
|
uni.getStorageSync('openid') && (Object.assign(formData, { openid: uni.getStorageSync('openid') }))
|
||||||
uni.getStorageSync('pid') && (Object.assign(formData, { pid: uni.getStorageSync('pid') }))
|
uni.getStorageSync('pid') && (Object.assign(formData, { pid: uni.getStorageSync('pid') }))
|
||||||
|
|||||||
@ -18,7 +18,7 @@
|
|||||||
<u-input v-model="formData.username" border="none" maxlength="40"
|
<u-input v-model="formData.username" border="none" maxlength="40"
|
||||||
:placeholder="t('usernamePlaceholder')" autocomplete="off" class="!bg-transparent"
|
:placeholder="t('usernamePlaceholder')" autocomplete="off" class="!bg-transparent"
|
||||||
:disabled="real_name_input" fontSize="26rpx"
|
:disabled="real_name_input" fontSize="26rpx"
|
||||||
placeholderClass="!text-[var(--text-color-light6)] text-[26rpx]" />
|
placeholderClass="!text-[var(--text-color-light9)] text-[26rpx]" />
|
||||||
</u-form-item>
|
</u-form-item>
|
||||||
</view>
|
</view>
|
||||||
<view class="h-[88rpx] flex w-full items-center px-[30rpx] rounded-[40rpx] box-border bg-[#F6F6F6] mt-[40rpx]">
|
<view class="h-[88rpx] flex w-full items-center px-[30rpx] rounded-[40rpx] box-border bg-[#F6F6F6] mt-[40rpx]">
|
||||||
@ -26,7 +26,7 @@
|
|||||||
<u-input v-model="formData.password" border="none" :password="isPassword" maxlength="40"
|
<u-input v-model="formData.password" border="none" :password="isPassword" maxlength="40"
|
||||||
:placeholder="t('passwordPlaceholder')" autocomplete="new-password"
|
:placeholder="t('passwordPlaceholder')" autocomplete="new-password"
|
||||||
class="!bg-transparent" :disabled="real_name_input" fontSize="26rpx"
|
class="!bg-transparent" :disabled="real_name_input" fontSize="26rpx"
|
||||||
placeholderClass="!text-[var(--text-color-light6)] text-[26rpx]">
|
placeholderClass="!text-[var(--text-color-light9)] text-[26rpx]">
|
||||||
<template #suffix>
|
<template #suffix>
|
||||||
<view @click="changePassword" v-if="formData.password">
|
<view @click="changePassword" v-if="formData.password">
|
||||||
<u-icon :name="isPassword?'eye-off':'eye-fill'" color="#b9b9b9" size="20"></u-icon>
|
<u-icon :name="isPassword?'eye-off':'eye-fill'" color="#b9b9b9" size="20"></u-icon>
|
||||||
@ -42,15 +42,15 @@
|
|||||||
<u-input v-model="formData.mobile" type="number" maxlength="11" border="none"
|
<u-input v-model="formData.mobile" type="number" maxlength="11" border="none"
|
||||||
:placeholder="t('mobilePlaceholder')" autocomplete="off" class="!bg-transparent"
|
:placeholder="t('mobilePlaceholder')" autocomplete="off" class="!bg-transparent"
|
||||||
:disabled="real_name_input" fontSize="26rpx"
|
:disabled="real_name_input" fontSize="26rpx"
|
||||||
placeholderClass="!text-[var(--text-color-light6)] text-[26rpx]" />
|
placeholderClass="!text-[var(--text-color-light9)] text-[26rpx]" />
|
||||||
</u-form-item>
|
</u-form-item>
|
||||||
</view>
|
</view>
|
||||||
<view class="h-[88rpx] flex w-full items-center px-[30rpx] rounded-[40rpx] box-border bg-[#F6F6F6] mt-[40rpx] text-[26rpx]">
|
<view class="h-[88rpx] flex w-full items-center px-[30rpx] rounded-[40rpx] box-border bg-[#F6F6F6] mt-[40rpx]">
|
||||||
<u-form-item label="" prop="mobile_code" :border-bottom="false">
|
<u-form-item label="" prop="mobile_code" :border-bottom="false">
|
||||||
<u-input v-model="formData.mobile_code" type="number" maxlength="4" border="none"
|
<u-input v-model="formData.mobile_code" type="number" maxlength="4" border="none"
|
||||||
class="!bg-transparent" fontSize="26rpx" :disabled="real_name_input"
|
class="!bg-transparent" fontSize="26rpx" :disabled="real_name_input"
|
||||||
:placeholder="t('codePlaceholder')"
|
:placeholder="t('codePlaceholder')"
|
||||||
placeholderClass="!text-[var(--text-color-light6)] text-[26rpx]">
|
placeholderClass="!text-[var(--text-color-light9)] text-[26rpx]">
|
||||||
<template #suffix>
|
<template #suffix>
|
||||||
<sms-code v-if="configStore.login.agreement_show" :mobile="formData.mobile" type="login" v-model="formData.mobile_key" :isAgree="isAgree"></sms-code>
|
<sms-code v-if="configStore.login.agreement_show" :mobile="formData.mobile" type="login" v-model="formData.mobile_key" :isAgree="isAgree"></sms-code>
|
||||||
<sms-code v-else :mobile="formData.mobile" type="login" v-model="formData.mobile_key"></sms-code>
|
<sms-code v-else :mobile="formData.mobile" type="login" v-model="formData.mobile_key"></sms-code>
|
||||||
|
|||||||
@ -39,7 +39,7 @@
|
|||||||
</view>
|
</view>
|
||||||
<view class="flex-1">
|
<view class="flex-1">
|
||||||
<view class="text-[22rpx] text-[var(--text-color-light9)] mb-[10rpx] leading-[30rpx]">核销员</view>
|
<view class="text-[22rpx] text-[var(--text-color-light9)] mb-[10rpx] leading-[30rpx]">核销员</view>
|
||||||
<view class="text-[26rpx] text-[#303133] leading-[36rpx]">{{ item.member ? item.member.nickname : '--' }}</view>
|
<view class="text-[26rpx] text-[#303133] leading-[36rpx]">{{ (item.is_admin == 1 ? '后台核销' : item.member?.nickname) || '--' }}</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|||||||
@ -17,16 +17,16 @@
|
|||||||
</view>
|
</view>
|
||||||
<view class="flex-1 pr-[10rpx]" v-else></view>
|
<view class="flex-1 pr-[10rpx]" v-else></view>
|
||||||
</view>
|
</view>
|
||||||
<scroll-view scroll-y="true" class="h-[50vh]">
|
<scroll-view scroll-y="true" class="h-[50vh]" :scroll-top="scrollTop" scroll-with-animation @touchmove.stop>
|
||||||
<view class="flex p-[30rpx] pt-[0] text-sm font-500">
|
<view class="flex p-[30rpx] pt-[0] text-sm font-500 h-[50vh]">
|
||||||
<view v-if="areaList.province.length" class="flex-1 pr-[10rpx]" :style="{ opacity: currSelect == 'province' ? 1 : 0, pointerEvents: currSelect == 'province' ? 'auto' : 'none' }">
|
<view v-if="areaList.province.length" class="flex-1 pr-[10rpx]" :style="{ opacity: currSelect == 'province' ? 1 : 0, pointerEvents: currSelect == 'province' ? 'auto' : 'none',height: currSelect == 'province' ? 'auto' : '0',overflow: currSelect == 'province' ? 'auto' : 'hidden' }">
|
||||||
<view v-for="item in areaList.province" class="h-[80rpx] flex items-center" :class="{'text-[var(--primary-color)]': selected.province && selected.province.id == item.id }" @click="selected.province = item">{{ item.name }}</view>
|
<view v-for="(item, index) in areaList.province" :key="item.id" class="h-[80rpx] flex items-center" :class="{'text-[var(--primary-color)]': selected.province && selected.province.id == item.id }" @click="handleProvinceClick(item)">{{ item.name }}</view>
|
||||||
</view>
|
</view>
|
||||||
<view v-if="areaList.city.length" class="flex-1 pr-[10rpx]" :style="{ opacity: currSelect == 'city' ? 1 : 0, pointerEvents: currSelect == 'city' ? 'auto' : 'none' }">
|
<view v-if="areaList.city.length" class="flex-1 pr-[10rpx]" :style="{ opacity: currSelect == 'city' ? 1 : 0, pointerEvents: currSelect == 'city' ? 'auto' : 'none',height: currSelect == 'city' ? 'auto' : '0',overflow: currSelect == 'city' ? 'auto' : 'hidden' }">
|
||||||
<view v-for="item in areaList.city" class="h-[80rpx] flex items-center" :class="{'text-[var(--primary-color)]': selected.city && selected.city.id == item.id }" @click="selected.city = item">{{ item.name }}</view>
|
<view v-for="(item, index) in areaList.city" :key="item.id" class="h-[80rpx] flex items-center" :class="{'text-[var(--primary-color)]': selected.city && selected.city.id == item.id }" @click="handleCityClick(item)">{{ item.name }}</view>
|
||||||
</view>
|
</view>
|
||||||
<view v-if="areaList.district.length" class="flex-1 pr-[10rpx]" :style="{ opacity: currSelect == 'district' ? 1 : 0, pointerEvents: currSelect == 'district' ? 'auto' : 'none' }">
|
<view v-if="areaList.district.length" class="flex-1 pr-[10rpx]" :style="{ opacity: currSelect == 'district' ? 1 : 0, pointerEvents: currSelect == 'district' ? 'auto' : 'none',height: currSelect == 'district' ? 'auto' : '0',overflow: currSelect == 'district' ? 'auto' : 'hidden' }">
|
||||||
<view v-for="item in areaList.district" class="h-[80rpx] flex items-center " :class="{'text-[var(--primary-color)]': selected.district && selected.district.id == item.id }" @click="selected.district = item">{{ item.name }}</view>
|
<view v-for="(item, index) in areaList.district" :key="item.id" class="h-[80rpx] flex items-center " :class="{'text-[var(--primary-color)]': selected.district && selected.district.id == item.id }" @click="selected.district = item">{{ item.name }}</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="flex-1 pr-[10rpx]" v-else></view>
|
<view class="flex-1 pr-[10rpx]" v-else></view>
|
||||||
</view>
|
</view>
|
||||||
@ -36,7 +36,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, watch } from 'vue'
|
import { ref, reactive, watch, nextTick } from 'vue'
|
||||||
import { getAreaListByPid, getAreaByCode } from '@/app/api/system'
|
import { getAreaListByPid, getAreaByCode } from '@/app/api/system'
|
||||||
|
|
||||||
const prop = defineProps({
|
const prop = defineProps({
|
||||||
@ -60,6 +60,9 @@ const selected = reactive({
|
|||||||
district: null
|
district: null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 滚动控制
|
||||||
|
const scrollTop = ref(0)
|
||||||
|
|
||||||
getAreaListByPid(0).then(({ data }) => {
|
getAreaListByPid(0).then(({ data }) => {
|
||||||
areaList.province = data
|
areaList.province = data
|
||||||
}).catch()
|
}).catch()
|
||||||
@ -86,14 +89,21 @@ watch(() => selected.province, () => {
|
|||||||
|
|
||||||
if (selected.city) {
|
if (selected.city) {
|
||||||
let isExist = false
|
let isExist = false
|
||||||
|
let selectedIndex = -1
|
||||||
for (let i = 0; i < data.length; i++) {
|
for (let i = 0; i < data.length; i++) {
|
||||||
if (selected.city.id == data[i].id) {
|
if (selected.city.id == data[i].id) {
|
||||||
isExist = true
|
isExist = true
|
||||||
|
selectedIndex = i
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!isExist) {
|
if (!isExist) {
|
||||||
selected.city = null
|
selected.city = null
|
||||||
|
} else {
|
||||||
|
// 滚动到选中的城市位置
|
||||||
|
setTimeout(() => {
|
||||||
|
scrollToSelected('city', selectedIndex)
|
||||||
|
}, 100)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}).catch()
|
}).catch()
|
||||||
@ -110,14 +120,21 @@ watch(() => selected.city, (nval) => {
|
|||||||
|
|
||||||
if (selected.district) {
|
if (selected.district) {
|
||||||
let isExist = false
|
let isExist = false
|
||||||
|
let selectedIndex = -1
|
||||||
for (let i = 0; i < data.length; i++) {
|
for (let i = 0; i < data.length; i++) {
|
||||||
if (selected.district.id == data[i].id) {
|
if (selected.district.id == data[i].id) {
|
||||||
isExist = true
|
isExist = true
|
||||||
|
selectedIndex = i
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!isExist) {
|
if (!isExist) {
|
||||||
selected.district = null
|
selected.district = null
|
||||||
|
} else {
|
||||||
|
// 滚动到选中的区县位置
|
||||||
|
setTimeout(() => {
|
||||||
|
scrollToSelected('district', selectedIndex)
|
||||||
|
}, 100)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!data.length) {
|
if (!data.length) {
|
||||||
@ -135,6 +152,44 @@ watch(() => selected.city, (nval) => {
|
|||||||
|
|
||||||
const emits = defineEmits(['complete'])
|
const emits = defineEmits(['complete'])
|
||||||
|
|
||||||
|
// 滚动到选中项位置
|
||||||
|
const scrollToSelected = (type: string, selectedIndex: number) => {
|
||||||
|
// 计算目标位置,让选中项显示在列表的下半部分
|
||||||
|
const itemHeight = 80 // 每项高度 80rpx
|
||||||
|
const targetScrollTop = Math.max(0, (selectedIndex - 2) * itemHeight) // 让选中项显示在第3个位置左右
|
||||||
|
scrollTop.value = targetScrollTop
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置滚动位置
|
||||||
|
const resetScrollTop = () => {
|
||||||
|
scrollTop.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听当前选择标签变化,重置滚动位置并滚动到选中项
|
||||||
|
watch(() => currSelect.value, (newVal) => {
|
||||||
|
// 先重置滚动位置
|
||||||
|
resetScrollTop()
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (newVal === 'province' && selected.province) {
|
||||||
|
const index = areaList.province.findIndex((item: any) => item.id === selected.province.id)
|
||||||
|
if (index >= 0) {
|
||||||
|
scrollToSelected('province', index)
|
||||||
|
}
|
||||||
|
} else if (newVal === 'city' && selected.city) {
|
||||||
|
const index = areaList.city.findIndex((item: any) => item.id === selected.city.id)
|
||||||
|
if (index >= 0) {
|
||||||
|
scrollToSelected('city', index)
|
||||||
|
}
|
||||||
|
} else if (newVal === 'district' && selected.district) {
|
||||||
|
const index = areaList.district.findIndex((item: any) => item.id === selected.district.id)
|
||||||
|
if (index >= 0) {
|
||||||
|
scrollToSelected('district', index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 150)
|
||||||
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 监听区县变更
|
* 监听区县变更
|
||||||
*/
|
*/
|
||||||
@ -146,6 +201,24 @@ watch(() => selected.district, (nval) => {
|
|||||||
}
|
}
|
||||||
}, { deep: true })
|
}, { deep: true })
|
||||||
|
|
||||||
|
// 处理省份点击
|
||||||
|
const handleProvinceClick = (item: any) => {
|
||||||
|
selected.province = item
|
||||||
|
// 立即滚动到顶部,确保新数据可见
|
||||||
|
nextTick(() => {
|
||||||
|
resetScrollTop()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理城市点击
|
||||||
|
const handleCityClick = (item: any) => {
|
||||||
|
selected.city = item
|
||||||
|
// 立即滚动到顶部,确保新数据可见
|
||||||
|
nextTick(() => {
|
||||||
|
resetScrollTop()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const open = () => {
|
const open = () => {
|
||||||
show.value = true
|
show.value = true
|
||||||
if(prop.areaId){
|
if(prop.areaId){
|
||||||
|
|||||||
191
uni-app/src/components/avatar-swiper/avatar-swiper.vue
Normal file
191
uni-app/src/components/avatar-swiper/avatar-swiper.vue
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
<template>
|
||||||
|
<view class="carousel-container">
|
||||||
|
<view class="carousel-track" :style="trackStyle">
|
||||||
|
<view
|
||||||
|
v-for="(item, index) in displayList"
|
||||||
|
:key="item.key"
|
||||||
|
class="carousel-item"
|
||||||
|
:style="itemStyle(index)"
|
||||||
|
:class="{ 'scale-in': item.isNew }"
|
||||||
|
>
|
||||||
|
<u--image width="30rpx" height="30rpx" radius="15rpx" :src="img(item.src)" mode="aspectFill">
|
||||||
|
<template #error>
|
||||||
|
<image
|
||||||
|
class="w-[30rpx] h-[30rpx] rounded-full"
|
||||||
|
:src="img('static/resource/images/default_headimg.png')"
|
||||||
|
mode="aspectFill"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</u--image>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted, computed, nextTick } from 'vue';
|
||||||
|
import { img } from '@/utils/common';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
avatars: string[];
|
||||||
|
interval?: number; // 动画间隔时间(默认2000ms)
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// 配置参数(优化动画连贯性)
|
||||||
|
const VISIBLE_COUNT = 3;
|
||||||
|
const ITEM_WIDTH = 30;
|
||||||
|
const OVERLAP = 10;
|
||||||
|
const ANIMATION_DURATION = 500; // 稍长动画,减少急促感
|
||||||
|
const EASING = 'cubic-bezier(0.25, 0.1, 0.25, 1)'; // 平滑曲线
|
||||||
|
const slideDistance = ITEM_WIDTH - OVERLAP;
|
||||||
|
|
||||||
|
// 状态管理
|
||||||
|
const displayList = ref<{
|
||||||
|
src: string;
|
||||||
|
isNew: boolean;
|
||||||
|
key: string; // 唯一key,避免v-for复用导致的闪烁
|
||||||
|
}[]>([]);
|
||||||
|
let currentIndex = 0;
|
||||||
|
let timer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
const isTransitioning = ref(false);
|
||||||
|
const trackOffset = ref(0);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化列表:确保初始状态稳定
|
||||||
|
*/
|
||||||
|
const initList = () => {
|
||||||
|
if (props.avatars.length <= VISIBLE_COUNT) {
|
||||||
|
displayList.value = props.avatars.map((src, i) => ({
|
||||||
|
src,
|
||||||
|
isNew: true,
|
||||||
|
key: `init-${i}-${src}` // 唯一key
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
displayList.value = props.avatars.slice(0, VISIBLE_COUNT).map((src, i) => ({
|
||||||
|
src,
|
||||||
|
isNew: true,
|
||||||
|
key: `init-${i}-${src}`
|
||||||
|
}));
|
||||||
|
currentIndex = VISIBLE_COUNT;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单个头像样式:同步动画参数
|
||||||
|
*/
|
||||||
|
const itemStyle = (index: number) => ({
|
||||||
|
zIndex: VISIBLE_COUNT + 1 - index,
|
||||||
|
transition: `transform ${ANIMATION_DURATION}ms ${EASING}`, // 与轨道动画完全同步
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 轨道样式:启用硬件加速,优化过渡
|
||||||
|
*/
|
||||||
|
const trackStyle = computed(() => ({
|
||||||
|
transform: `translateX(${trackOffset.value}rpx)`,
|
||||||
|
transition: isTransitioning.value
|
||||||
|
? `transform ${ANIMATION_DURATION}ms ${EASING}`
|
||||||
|
: 'none',
|
||||||
|
display: 'flex',
|
||||||
|
// 硬件加速:减少重绘闪烁
|
||||||
|
willChange: isTransitioning.value ? 'transform' : 'auto',
|
||||||
|
backfaceVisibility: 'hidden',
|
||||||
|
perspective: '1000px',
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 滑动动画:优化时序,避免闪烁
|
||||||
|
*/
|
||||||
|
const slide = async () => {
|
||||||
|
if (props.avatars.length <= VISIBLE_COUNT || isTransitioning.value) return;
|
||||||
|
isTransitioning.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 提前插入新头像(带唯一key,避免复用闪烁)
|
||||||
|
const nextKey = `slide-${currentIndex}-${Date.now()}`; // 用时间戳确保唯一
|
||||||
|
const nextAvatar = {
|
||||||
|
src: props.avatars[currentIndex % props.avatars.length],
|
||||||
|
isNew: false, // 初始隐藏
|
||||||
|
key: nextKey
|
||||||
|
};
|
||||||
|
displayList.value.unshift(nextAvatar);
|
||||||
|
await nextTick(); // 等待DOM稳定渲染
|
||||||
|
|
||||||
|
// 2. 重置轨道位置(无动画,为滑动做准备)
|
||||||
|
trackOffset.value = -slideDistance;
|
||||||
|
isTransitioning.value = false;
|
||||||
|
await nextTick(); // 确保位置重置生效
|
||||||
|
|
||||||
|
// 3. 启动向右滑动动画(核心步骤)
|
||||||
|
isTransitioning.value = true;
|
||||||
|
trackOffset.value = 0; // 向右移动到目标位置
|
||||||
|
|
||||||
|
// 4. 同步显示新头像(与滑动动画同步开始)
|
||||||
|
setTimeout(() => {
|
||||||
|
nextAvatar.isNew = true; // 新头像缩放显示,与滑动同步
|
||||||
|
}, 500); // 轻微延迟,模拟“滑入时逐渐显示”
|
||||||
|
|
||||||
|
// 5. 等待动画完全结束(关键:确保动画结束后再操作)
|
||||||
|
await new Promise(resolve => {
|
||||||
|
setTimeout(resolve, ANIMATION_DURATION);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 6. 无痕移除最右侧旧头像(动画结束后,无视觉闪烁)
|
||||||
|
displayList.value.pop();
|
||||||
|
|
||||||
|
// 7. 更新索引,为下次动画准备
|
||||||
|
currentIndex = (currentIndex + 1) % props.avatars.length;
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
isTransitioning.value = false; // 解锁
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 组件挂载与清理
|
||||||
|
*/
|
||||||
|
onMounted(() => {
|
||||||
|
if (props.avatars?.length) {
|
||||||
|
initList();
|
||||||
|
if (props.avatars.length > VISIBLE_COUNT) {
|
||||||
|
const delay = props.interval || 2000;
|
||||||
|
timer = setInterval(slide, delay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (timer) clearInterval(timer);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.carousel-container {
|
||||||
|
width: 70rpx;
|
||||||
|
height: 30rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end; // 不足3个时右对齐
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-track {
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-item {
|
||||||
|
width: 30rpx;
|
||||||
|
height: 30rpx;
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
&:not(:first-child) {
|
||||||
|
margin-left: -10rpx; // 固定重叠,避免计算误差
|
||||||
|
}
|
||||||
|
transform: scale(0); // 初始隐藏
|
||||||
|
}
|
||||||
|
|
||||||
|
// 缩放动画与滑动动画完全同步
|
||||||
|
.carousel-item.scale-in {
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
107
uni-app/src/components/barrage/barrage-item.vue
Normal file
107
uni-app/src/components/barrage/barrage-item.vue
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
@ -1,108 +0,0 @@
|
||||||
|
<template>
|
||||||
|
<view :style="itemStyle">
|
||||||
|
<view class="danmu-item" :style="{ backgroundColor: bgColor, borderRadius: rounded, color: textColor }">
|
||||||
|
<image class="w-[40rpx] h-[40rpx] rounded-[100rpx]" :src="data.headimg?img(data.headimg):img('static/resource/images/default_headimg.png')" :mode="'aspectFill'"
|
||||||
|
/>
|
||||||
|
<text class="username max-w-[160rpx] truncate">{{ data.nickname }}</text>
|
||||||
|
<text class="time whitespace-nowrap">{{ data.time }}下单</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { img } from '@/utils/common';
|
||||||
|
const props = defineProps({
|
||||||
|
bgColor: {
|
||||||
|
type: String,
|
||||||
|
default: '#B0B0B0'
|
||||||
|
},
|
||||||
|
opacity: {
|
||||||
|
type: String,
|
||||||
|
default: '1'
|
||||||
|
},
|
||||||
|
textColor: {
|
||||||
|
type: String,
|
||||||
|
default: '#FFFFFF'
|
||||||
|
},
|
||||||
|
rounded: {
|
||||||
|
type: Number,
|
||||||
|
default: 20
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
delay: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
|
speed: {
|
||||||
|
type: Number,
|
||||||
|
default: 8
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const itemStyle = computed(() => {
|
||||||
|
return {
|
||||||
|
'--speed': `${props.speed}s`,
|
||||||
|
'--delay': `${props.delay}s`,
|
||||||
|
'--height': `-300rpx`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.danmu-item {
|
||||||
|
position: absolute;
|
||||||
|
left: 30rpx;
|
||||||
|
bottom: 0;
|
||||||
|
min-width: 180rpx;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 10rpx;
|
||||||
|
padding-right: 20rpx;
|
||||||
|
background-color: rgba(255, 255, 255, 0.9);
|
||||||
|
border-radius: 40rpx;
|
||||||
|
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.1);
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #333;
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 0;
|
||||||
|
animation: moveUp linear var(--speed) forwards;
|
||||||
|
animation-delay: var(--delay);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.username {
|
||||||
|
margin-left: 8rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time {
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes moveUp {
|
||||||
|
0% {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
10% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
90% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
transform: translateY(var(--height));
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
176
uni-app/src/components/barrage/container.vue
Normal file
176
uni-app/src/components/barrage/container.vue
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
<template>
|
||||||
|
<view class="danmu-container">
|
||||||
|
<barrage-item v-for="(item, index) in danmuList" :key="`${item.id}-${item.timestamp}`" :data="item" :delay="item.delay" :speed="item.speed"
|
||||||
|
:textColor="props.textColor" :bgColor="props.bgColor" :rounded="props.rounded" />
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, computed,nextTick, watch, onUpdated, onUnmounted } from 'vue'
|
||||||
|
import barrageItem from '@/components/barrage/barrage-item.vue'
|
||||||
|
const props = defineProps({
|
||||||
|
bgColor: {
|
||||||
|
type: String,
|
||||||
|
default: '#B0B0B0'
|
||||||
|
},
|
||||||
|
textColor: {
|
||||||
|
type: String,
|
||||||
|
default: '#FFFFFF'
|
||||||
|
},
|
||||||
|
rounded: {
|
||||||
|
type: Number,
|
||||||
|
default: 20
|
||||||
|
},
|
||||||
|
maxCount: {
|
||||||
|
type: Number,
|
||||||
|
default: 6
|
||||||
|
},
|
||||||
|
speed: {
|
||||||
|
type: Number,
|
||||||
|
default: 6
|
||||||
|
},
|
||||||
|
interval: {
|
||||||
|
type: Number,
|
||||||
|
default: 1.5
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
type: Object,
|
||||||
|
default: []
|
||||||
|
},
|
||||||
|
play:{ // 是否播放弹幕,true:播放,false:不播放
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const danmuList = ref([]) // 当前弹幕列表
|
||||||
|
const waitList = ref([]) // 等待弹幕列表
|
||||||
|
const initTimeId = ref([]) // 初始化时间id
|
||||||
|
const loopTimeId = ref(null) // 初始化时间id
|
||||||
|
|
||||||
|
|
||||||
|
// 初始化弹幕数据
|
||||||
|
const initDanmu = () => {
|
||||||
|
nextTick(() => {
|
||||||
|
danmuList.value = props.data.map((item, index) => {
|
||||||
|
let obj = {}
|
||||||
|
const timestamp = Date.now() + Math.random()
|
||||||
|
obj.id = item.id ? `danmu-${item.id}-${timestamp}` : `danmu-${timestamp}`
|
||||||
|
obj.timestamp = timestamp
|
||||||
|
obj.nickname = item.nickname
|
||||||
|
obj.headimg = item.headimg
|
||||||
|
obj.time = formatTimestampToHumanTime(item.time)
|
||||||
|
obj.delay = index * props.interval,
|
||||||
|
obj.speed = props.speed
|
||||||
|
return obj
|
||||||
|
})
|
||||||
|
|
||||||
|
danmuList.value.forEach((item, index) => {
|
||||||
|
initTimeId.value[index] = setTimeout(() => {
|
||||||
|
waitList.value.push(item)
|
||||||
|
danmuList.value.shift();
|
||||||
|
loopTriggerFn()
|
||||||
|
}, (item.delay + item.speed) * 1000)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// 循环触发
|
||||||
|
const loopTriggerFn = () => {
|
||||||
|
if (!danmuList.value.length) {
|
||||||
|
loopTimeId.value = setTimeout(() => {
|
||||||
|
// 创建新的数据对象,避免组件复用
|
||||||
|
danmuList.value = waitList.value.map(item => {
|
||||||
|
const timestamp = Date.now() + Math.random()
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
id: `${item.id}-loop-${timestamp}`,
|
||||||
|
timestamp: timestamp
|
||||||
|
}
|
||||||
|
})
|
||||||
|
waitList.value = []
|
||||||
|
danmuList.value.forEach((item, index) => {
|
||||||
|
initTimeId.value[index] = setTimeout(() => {
|
||||||
|
waitList.value.push(item)
|
||||||
|
danmuList.value.shift();
|
||||||
|
loopTriggerFn()
|
||||||
|
}, (item.delay + item.speed) * 1000)
|
||||||
|
})
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
watch(() => props.play, (newVal, oldVal) => {
|
||||||
|
nextTick(() => {
|
||||||
|
if (newVal) {
|
||||||
|
initTimeId.value.forEach((item, index) => {
|
||||||
|
if (item) {
|
||||||
|
clearTimeout(item)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
clearTimeout(loopTimeId.value)
|
||||||
|
waitList.value = []
|
||||||
|
danmuList.value = []
|
||||||
|
initDanmu()
|
||||||
|
}else{
|
||||||
|
initTimeId.value.forEach((item, index) => {
|
||||||
|
if (item) {
|
||||||
|
clearTimeout(item)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
clearTimeout(loopTimeId.value)
|
||||||
|
waitList.value = []
|
||||||
|
danmuList.value = []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
initDanmu()
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将时间戳转换为最合适的可读时间单位(分钟、小时、天等),并舍去小数部分
|
||||||
|
* @param {number} timestamp - 时间戳(毫秒)
|
||||||
|
* @returns {string} 人类可读的相对时间字符串
|
||||||
|
*/
|
||||||
|
const formatTimestampToHumanTime = (timestamp) => {
|
||||||
|
const now = Date.now();
|
||||||
|
let seconds = Math.round((now - timestamp) / 1000); // 保留时间差为秒数,四舍五入
|
||||||
|
|
||||||
|
// 如果是未来的时间
|
||||||
|
if (seconds < 0) {
|
||||||
|
return '未来';
|
||||||
|
}
|
||||||
|
|
||||||
|
const intervals = [
|
||||||
|
{ label: '年', seconds: 31536000 }, // 60 * 60 * 24 * 365
|
||||||
|
{ label: '个月', seconds: 2592000 }, // 60 * 60 * 24 * 30
|
||||||
|
{ label: '周', seconds: 604800 }, // 60 * 60 * 24 * 7
|
||||||
|
{ label: '天', seconds: 86400 }, // 60 * 60 * 24
|
||||||
|
{ label: '小时', seconds: 3600 }, // 60 * 60
|
||||||
|
{ label: '分钟', seconds: 60 }
|
||||||
|
];
|
||||||
|
|
||||||
|
for (let i = 0; i < intervals.length; i++) {
|
||||||
|
const interval = intervals[i];
|
||||||
|
const count = Math.floor(seconds / interval.seconds); // 舍去小数部分
|
||||||
|
if (count >= 1) {
|
||||||
|
return `${count}${interval.label}前`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '刚刚';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.danmu-container {
|
||||||
|
position: fixed;
|
||||||
|
top: 160rpx;
|
||||||
|
left: 0;
|
||||||
|
height: 500rpx;
|
||||||
|
width: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 9;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
253
uni-app/src/components/firework-effect/README.md
Normal file
253
uni-app/src/components/firework-effect/README.md
Normal file
@ -0,0 +1,253 @@
|
|||||||
|
# 🎆 简单烟花特效组件
|
||||||
|
|
||||||
|
一个轻量级的uni-app烟花特效组件,专注于提供最佳的用户体验。
|
||||||
|
|
||||||
|
## 📁 组件结构
|
||||||
|
|
||||||
|
```
|
||||||
|
firework-effect/
|
||||||
|
├── firework-effect.vue # 主要的烟花特效组件
|
||||||
|
├── simple-firework.vue # 简单烟花特效组件
|
||||||
|
└── README.md # 说明文档
|
||||||
|
```
|
||||||
|
|
||||||
|
## ✨ 特效特点
|
||||||
|
|
||||||
|
### 🎇 简单烟花 (simple-firework)
|
||||||
|
- 从底部发射,空中爆炸成彩色粒子
|
||||||
|
- 自动发射,可定义持续时间
|
||||||
|
- **不阻挡用户操作** - 设置了 `pointer-events: none`
|
||||||
|
- **自动停止** - 默认30秒,可自定义
|
||||||
|
- **无黑色阴影** - 使用透明背景
|
||||||
|
- **兼容性最好** - 使用传统Canvas API
|
||||||
|
|
||||||
|
### 🧧 红包雨 (red-packet-rain)
|
||||||
|
- **新增功能** ⭐
|
||||||
|
- 从屏幕顶部飘落的红包雨效果
|
||||||
|
- **点击获得金额** - 点击红包显示随机金额提示
|
||||||
|
- **不阻挡用户操作** - 设置了 `pointer-events: none`,不会挡住底部按钮
|
||||||
|
- **自定义时间** - 可设置持续时间
|
||||||
|
- **自定义密度** - 可调节红包数量
|
||||||
|
- **自定义速度** - 可调节下落速度
|
||||||
|
- **自定义大小** - 可设置红包大小范围
|
||||||
|
- **自定义金额** - 可设置金额范围
|
||||||
|
- **精美动画** - 金额提示有向上飘动的动画效果
|
||||||
|
- **自动绘制红包** - 无需图片资源,自动绘制精美红包
|
||||||
|
|
||||||
|
## 🎯 使用方法
|
||||||
|
|
||||||
|
### 1. 基本使用
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<view>
|
||||||
|
<!-- 你的页面内容 -->
|
||||||
|
<!-- 基础配置 -->
|
||||||
|
<firework-effect
|
||||||
|
ref="fireworkRef"
|
||||||
|
:duration="20000"
|
||||||
|
:no-trail="true"
|
||||||
|
:red-packet-density="5"
|
||||||
|
:red-packet-speed="3"
|
||||||
|
:red-packet-min-size="80"
|
||||||
|
:red-packet-max-size="120"
|
||||||
|
:red-packet-min-amount="0.1"
|
||||||
|
:red-packet-max-amount="50.0"
|
||||||
|
:red-packet-clickable="true"
|
||||||
|
></firework-effect>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import FireworkEffect from '@/components/firework-effect/firework-effect.vue';
|
||||||
|
|
||||||
|
const fireworkRef = ref(null);
|
||||||
|
|
||||||
|
// 显示简单烟花特效
|
||||||
|
const showFireworks = () => {
|
||||||
|
fireworkRef.value?.handleShowEffect({
|
||||||
|
type: 'simple-firework',
|
||||||
|
duration: 15000 // 15秒后自动停止,可选参数
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 显示红包雨特效(新功能)
|
||||||
|
const showRedPacketRain = () => {
|
||||||
|
fireworkRef.value?.handleShowEffect({
|
||||||
|
type: 'red-packet-rain',
|
||||||
|
duration: 10000 // 10秒后自动停止
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 使用默认30秒
|
||||||
|
const showDefaultFireworks = () => {
|
||||||
|
fireworkRef.value?.handleShowEffect({ type: 'simple-firework' });
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. API 说明
|
||||||
|
|
||||||
|
#### FireworkEffect 组件
|
||||||
|
|
||||||
|
**Props:**
|
||||||
|
- `duration`: 特效持续时间(毫秒),默认30000(30秒)
|
||||||
|
- `noTrail`: 是否禁用拖尾效果(避免黑色阴影),默认true
|
||||||
|
- `redPacketImages`: 红包图片数组,如:`['/static/images/hongbao1.png']`
|
||||||
|
- `redPacketUseImages`: 是否使用图片模式,默认false(使用绘制模式)
|
||||||
|
- `redPacketDensity`: 红包密度(每次生成数量),默认3
|
||||||
|
- `redPacketSpeed`: 红包下落速度,默认2
|
||||||
|
- `redPacketMinSize`: 红包最小大小(px),默认60
|
||||||
|
- `redPacketMaxSize`: 红包最大大小(px),默认100
|
||||||
|
- `redPacketMinAmount`: 红包最小金额,默认0.01
|
||||||
|
- `redPacketMaxAmount`: 红包最大金额,默认10.00
|
||||||
|
- `redPacketClickable`: 是否可点击红包,默认true
|
||||||
|
|
||||||
|
**方法:**
|
||||||
|
- `handleShowEffect(params)`: 显示特效
|
||||||
|
- `params.type`: 特效类型
|
||||||
|
- `'simple-firework'`: 简单烟花特效
|
||||||
|
- `'red-packet-rain'` 或 `'hongbao'`: 红包雨特效
|
||||||
|
- `params.duration`: 自定义持续时间(可选)
|
||||||
|
### 🚫 解决黑色阴影问题
|
||||||
|
|
||||||
|
如果您看到黑色阴影,请确保:
|
||||||
|
|
||||||
|
1. **设置 `no-trail="true"`**(默认已启用)
|
||||||
|
2. **避免使用拖尾效果**,这会产生半透明覆盖
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<!-- ✅ 推荐:无阴影版本 -->
|
||||||
|
<firework-effect :no-trail="true"></firework-effect>
|
||||||
|
|
||||||
|
<!-- ❌ 不推荐:可能有阴影 -->
|
||||||
|
<firework-effect :no-trail="false"></firework-effect>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 📋 注意事项
|
||||||
|
|
||||||
|
1. 确保页面有足够的渲染性能
|
||||||
|
2. 在低端设备上可能需要减少粒子数量
|
||||||
|
3. 长时间运行建议定期清理资源
|
||||||
|
4. 某些小程序平台可能对 Canvas API 有限制
|
||||||
|
5. **只支持 `simple-firework` 类型**,不会阻挡用户操作
|
||||||
|
|
||||||
|
### 🎁 红包雨详细配置
|
||||||
|
|
||||||
|
#### 方式一:使用自定义图片
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<!-- 使用自定义红包图片 -->
|
||||||
|
<firework-effect
|
||||||
|
ref="fireworkRef"
|
||||||
|
:red-packet-images="[
|
||||||
|
'/static/images/hongbao1.png',
|
||||||
|
'/static/images/hongbao2.png',
|
||||||
|
'/static/images/hongbao3.png'
|
||||||
|
]"
|
||||||
|
:red-packet-use-images="true" <!-- 启用图片模式 -->
|
||||||
|
:red-packet-min-size="100" <!-- 红包最小100px -->
|
||||||
|
:red-packet-max-size="150" <!-- 红包最大150px -->
|
||||||
|
:red-packet-min-amount="1.0" <!-- 最小1元 -->
|
||||||
|
:red-packet-max-amount="100.0" <!-- 最大100元 -->
|
||||||
|
:red-packet-density="6" <!-- 每次生成6个红包 -->
|
||||||
|
:red-packet-speed="1.5" <!-- 较慢的下落速度 -->
|
||||||
|
:red-packet-clickable="true" <!-- 启用点击功能 -->
|
||||||
|
></firework-effect>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 方式二:使用代码绘制(默认)
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<!-- 使用代码绘制的红包 -->
|
||||||
|
<firework-effect
|
||||||
|
ref="fireworkRef"
|
||||||
|
:red-packet-use-images="false" <!-- 使用绘制模式(默认) -->
|
||||||
|
:red-packet-min-size="80" <!-- 红包最小80px -->
|
||||||
|
:red-packet-max-size="120" <!-- 红包最大120px -->
|
||||||
|
:red-packet-min-amount="0.1" <!-- 最小0.1元 -->
|
||||||
|
:red-packet-max-amount="50.0" <!-- 最大50元 -->
|
||||||
|
:red-packet-density="4" <!-- 每次生成4个红包 -->
|
||||||
|
:red-packet-speed="2.5" <!-- 下落速度 -->
|
||||||
|
:red-packet-clickable="true" <!-- 启用点击功能 -->
|
||||||
|
></firework-effect>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 方式三:使用网络图片
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<!-- 使用网络红包图片 -->
|
||||||
|
<firework-effect
|
||||||
|
ref="fireworkRef"
|
||||||
|
:red-packet-images="[
|
||||||
|
'https://example.com/hongbao1.png',
|
||||||
|
'https://example.com/hongbao2.png'
|
||||||
|
]"
|
||||||
|
:red-packet-use-images="true"
|
||||||
|
:red-packet-min-size="100"
|
||||||
|
:red-packet-max-size="150"
|
||||||
|
></firework-effect>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 🖼️ 红包图片要求
|
||||||
|
|
||||||
|
#### 图片格式
|
||||||
|
- **PNG**(推荐,支持透明背景)
|
||||||
|
- **JPG/JPEG**
|
||||||
|
- **WebP**
|
||||||
|
|
||||||
|
#### 尺寸建议
|
||||||
|
- **推荐尺寸**: 200x160px(宽高比5:4)
|
||||||
|
- **最小尺寸**: 100x80px
|
||||||
|
- **最大尺寸**: 400x320px
|
||||||
|
|
||||||
|
#### 文件大小
|
||||||
|
- **建议**: 小于100KB
|
||||||
|
- **最大**: 不超过500KB
|
||||||
|
|
||||||
|
#### 图片准备步骤
|
||||||
|
1. 将红包图片放在 `uni-app/public/static/images/` 目录
|
||||||
|
2. 文件命名建议:`hongbao1.png`, `hongbao2.png` 等
|
||||||
|
3. 确保图片背景透明(PNG格式)
|
||||||
|
4. 图片内容应该是完整的红包设计
|
||||||
|
|
||||||
|
### 📏 红包大小控制
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<!-- 小红包 -->
|
||||||
|
:red-packet-min-size="60"
|
||||||
|
:red-packet-max-size="80"
|
||||||
|
|
||||||
|
<!-- 中等红包 -->
|
||||||
|
:red-packet-min-size="80"
|
||||||
|
:red-packet-max-size="120"
|
||||||
|
|
||||||
|
<!-- 大红包 -->
|
||||||
|
:red-packet-min-size="120"
|
||||||
|
:red-packet-max-size="180"
|
||||||
|
|
||||||
|
<!-- 超大红包 -->
|
||||||
|
:red-packet-min-size="150"
|
||||||
|
:red-packet-max-size="200"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 🎯 红包点击功能
|
||||||
|
|
||||||
|
- **点击检测**: 自动检测用户点击的红包
|
||||||
|
- **金额显示**: 显示随机金额(在设定范围内)
|
||||||
|
- **动画效果**: 金额提示向上飘动并淡出
|
||||||
|
- **防重复**: 已点击的红包变为半透明,不可重复点击
|
||||||
|
- **响应式**: 支持触摸和鼠标点击
|
||||||
|
|
||||||
|
### 🔧 兼容性
|
||||||
|
|
||||||
|
- 支持 Vue 2 和 Vue 3
|
||||||
|
- 支持 uni-app 各平台(H5、小程序、App)
|
||||||
|
- 使用传统 Canvas API,兼容性最佳
|
||||||
155
uni-app/src/components/firework-effect/firework-effect.vue
Normal file
155
uni-app/src/components/firework-effect/firework-effect.vue
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
<template>
|
||||||
|
<!-- 简单烟花特效 -->
|
||||||
|
<simple-firework ref="simpleFireworkRef" :duration="fireworkDuration" :no-trail="props.noTrail"></simple-firework>
|
||||||
|
<!-- 红包雨特效 -->
|
||||||
|
<red-packet-rain
|
||||||
|
ref="redPacketRainRef"
|
||||||
|
:duration="fireworkDuration"
|
||||||
|
:images="redPacketImages"
|
||||||
|
:density="redPacketDensity"
|
||||||
|
:speed="redPacketSpeed"
|
||||||
|
:min-size="redPacketMinSize"
|
||||||
|
:max-size="redPacketMaxSize"
|
||||||
|
:min-amount="redPacketMinAmount"
|
||||||
|
:max-amount="redPacketMaxAmount"
|
||||||
|
:clickable="redPacketClickable"
|
||||||
|
:use-images="redPacketUseImages"
|
||||||
|
></red-packet-rain>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
// 导入特效组件
|
||||||
|
import SimpleFirework from './simple-firework.vue'
|
||||||
|
import RedPacketRain from './red-packet-rain.vue'
|
||||||
|
// #ifdef VUE3
|
||||||
|
import {
|
||||||
|
ref,
|
||||||
|
computed
|
||||||
|
} from 'vue';
|
||||||
|
// #endif
|
||||||
|
// #ifndef VUE3
|
||||||
|
import {
|
||||||
|
ref,
|
||||||
|
computed
|
||||||
|
} from '@vue/composition-api';
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
// 定义props
|
||||||
|
const props = defineProps({
|
||||||
|
duration: {
|
||||||
|
type: Number,
|
||||||
|
default: 30000 // 默认30秒
|
||||||
|
},
|
||||||
|
noTrail: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true // 默认无拖尾,避免黑色阴影
|
||||||
|
},
|
||||||
|
// 红包雨相关配置
|
||||||
|
redPacketImages: {
|
||||||
|
type: Array,
|
||||||
|
default: () => ['/static/images/hongbao.png']
|
||||||
|
},
|
||||||
|
redPacketDensity: {
|
||||||
|
type: Number,
|
||||||
|
default: 3 // 每次生成的红包数量
|
||||||
|
},
|
||||||
|
redPacketSpeed: {
|
||||||
|
type: Number,
|
||||||
|
default: 2 // 红包下落速度
|
||||||
|
},
|
||||||
|
// 红包大小配置
|
||||||
|
redPacketMinSize: {
|
||||||
|
type: Number,
|
||||||
|
default: 60 // 红包最小大小
|
||||||
|
},
|
||||||
|
redPacketMaxSize: {
|
||||||
|
type: Number,
|
||||||
|
default: 100 // 红包最大大小
|
||||||
|
},
|
||||||
|
// 红包金额配置
|
||||||
|
redPacketMinAmount: {
|
||||||
|
type: Number,
|
||||||
|
default: 0.01 // 最小金额
|
||||||
|
},
|
||||||
|
redPacketMaxAmount: {
|
||||||
|
type: Number,
|
||||||
|
default: 10.00 // 最大金额
|
||||||
|
},
|
||||||
|
// 是否可点击
|
||||||
|
redPacketClickable: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true // 默认可点击
|
||||||
|
},
|
||||||
|
// 是否使用图片模式
|
||||||
|
redPacketUseImages: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false // 默认使用绘制模式
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 特效组件引用
|
||||||
|
const simpleFireworkRef = ref(null)
|
||||||
|
const redPacketRainRef = ref(null)
|
||||||
|
|
||||||
|
// 计算烟花持续时间
|
||||||
|
const fireworkDuration = computed(() => props.duration)
|
||||||
|
|
||||||
|
const handleShowEffect = (params) => {
|
||||||
|
const {
|
||||||
|
type,
|
||||||
|
duration
|
||||||
|
} = params;
|
||||||
|
|
||||||
|
// 支持的特效类型
|
||||||
|
if (type == 'simple-firework') {
|
||||||
|
handleShowSimpleFirework(duration)
|
||||||
|
} else if (type == 'red-packet-rain' || type == 'hongbao') {
|
||||||
|
handleShowRedPacketRain(duration)
|
||||||
|
} else {
|
||||||
|
console.warn('🎆 支持的特效类型: simple-firework, red-packet-rain,当前类型:', type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示简单烟花特效
|
||||||
|
const handleShowSimpleFirework = (customDuration) => {
|
||||||
|
if (simpleFireworkRef.value) {
|
||||||
|
simpleFireworkRef.value.startFireworks(customDuration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示红包雨特效
|
||||||
|
const handleShowRedPacketRain = (customDuration) => {
|
||||||
|
if (redPacketRainRef.value) {
|
||||||
|
redPacketRainRef.value.startRedPacketRain(customDuration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 停止烟花特效
|
||||||
|
const stopFireworks = () => {
|
||||||
|
if (simpleFireworkRef.value && typeof simpleFireworkRef.value.stopFireworks === 'function') {
|
||||||
|
simpleFireworkRef.value.stopFireworks();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 停止红包雨特效
|
||||||
|
const stopRedPacketRain = () => {
|
||||||
|
if (redPacketRainRef.value && typeof redPacketRainRef.value.stopRedPacketRain === 'function') {
|
||||||
|
redPacketRainRef.value.stopRedPacketRain();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 停止所有特效
|
||||||
|
const stopAllEffects = () => {
|
||||||
|
stopFireworks();
|
||||||
|
stopRedPacketRain();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 暴露方法给父组件
|
||||||
|
defineExpose({
|
||||||
|
handleShowEffect,
|
||||||
|
stopFireworks,
|
||||||
|
stopRedPacketRain,
|
||||||
|
stopAllEffects
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
</script>
|
||||||
682
uni-app/src/components/firework-effect/red-packet-rain.vue
Normal file
682
uni-app/src/components/firework-effect/red-packet-rain.vue
Normal file
@ -0,0 +1,682 @@
|
|||||||
|
<template>
|
||||||
|
<view class="red-packet-container" v-if="isVisible">
|
||||||
|
<canvas
|
||||||
|
class="red-packet-canvas"
|
||||||
|
canvas-id="redPacketRain"
|
||||||
|
id="redPacketRain"
|
||||||
|
></canvas>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<!-- 红包元素(图片模式或绘制模式) -->
|
||||||
|
<view
|
||||||
|
v-for="packet in redPackets"
|
||||||
|
:key="packet.id"
|
||||||
|
class="red-packet-item"
|
||||||
|
:style="{
|
||||||
|
left: packet.x + 'px',
|
||||||
|
top: packet.y + 'px',
|
||||||
|
width: packet.width + 'px',
|
||||||
|
height: packet.height + 'px',
|
||||||
|
transform: `rotate(${packet.rotation}rad)`,
|
||||||
|
opacity: packet.opacity
|
||||||
|
}"
|
||||||
|
@click="handlePacketClick(packet, $event)"
|
||||||
|
>
|
||||||
|
<!-- 图片模式 -->
|
||||||
|
<image
|
||||||
|
v-if="props.useImages && packet.image"
|
||||||
|
:src="packet.image"
|
||||||
|
class="red-packet-image"
|
||||||
|
mode="aspectFit"
|
||||||
|
@load="onRedPacketImageLoad"
|
||||||
|
@error="onRedPacketImageError"
|
||||||
|
/>
|
||||||
|
<!-- 绘制模式 -->
|
||||||
|
<view
|
||||||
|
v-else
|
||||||
|
class="red-packet-drawn"
|
||||||
|
:style="{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%'
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<view class="red-packet-body"></view>
|
||||||
|
<view class="red-packet-text">福</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 预加载图片(隐藏) -->
|
||||||
|
<image
|
||||||
|
v-for="(imagePath, index) in props.images"
|
||||||
|
:key="'preload-' + index"
|
||||||
|
:src="imagePath"
|
||||||
|
class="preload-image"
|
||||||
|
@load="onImageLoad"
|
||||||
|
@error="onImageError"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 金额提示弹窗 -->
|
||||||
|
<view
|
||||||
|
v-for="tip in amountTips"
|
||||||
|
:key="tip.id"
|
||||||
|
class="amount-tip"
|
||||||
|
:style="{
|
||||||
|
left: tip.x + 'px',
|
||||||
|
top: tip.y + 'px',
|
||||||
|
opacity: tip.opacity
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<view class="tip-content">
|
||||||
|
<text class="amount">+¥{{ tip.amount }}</text>
|
||||||
|
<text class="label">{{ tip.label }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, getCurrentInstance, onUnmounted } from 'vue';
|
||||||
|
|
||||||
|
// 定义props
|
||||||
|
const props = defineProps({
|
||||||
|
duration: {
|
||||||
|
type: Number,
|
||||||
|
default: 30000 // 默认30秒
|
||||||
|
},
|
||||||
|
images: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [
|
||||||
|
// 可以是网络图片或本地图片路径
|
||||||
|
// 'https://example.com/hongbao1.png',
|
||||||
|
// '/static/images/hongbao.png',
|
||||||
|
// 如果为空数组,则使用绘制的红包
|
||||||
|
]
|
||||||
|
},
|
||||||
|
// 是否使用图片模式
|
||||||
|
useImages: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false // 默认使用绘制模式
|
||||||
|
},
|
||||||
|
density: {
|
||||||
|
type: Number,
|
||||||
|
default: 3 // 每次生成的红包数量
|
||||||
|
},
|
||||||
|
speed: {
|
||||||
|
type: Number,
|
||||||
|
default: 2 // 下落速度
|
||||||
|
},
|
||||||
|
// 红包大小配置
|
||||||
|
minSize: {
|
||||||
|
type: Number,
|
||||||
|
default: 60 // 最小红包大小
|
||||||
|
},
|
||||||
|
maxSize: {
|
||||||
|
type: Number,
|
||||||
|
default: 100 // 最大红包大小
|
||||||
|
},
|
||||||
|
// 金额配置
|
||||||
|
minAmount: {
|
||||||
|
type: Number,
|
||||||
|
default: 0.01 // 最小金额
|
||||||
|
},
|
||||||
|
maxAmount: {
|
||||||
|
type: Number,
|
||||||
|
default: 10.00 // 最大金额
|
||||||
|
},
|
||||||
|
// 是否启用点击功能
|
||||||
|
clickable: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const app = getCurrentInstance();
|
||||||
|
let ctx = null;
|
||||||
|
let animationId = null;
|
||||||
|
let isRunning = false;
|
||||||
|
let canvasWidth = 375;
|
||||||
|
let canvasHeight = 667;
|
||||||
|
let autoStopTimer = null;
|
||||||
|
let createTimer = null;
|
||||||
|
let loadedImages = new Map(); // 存储加载的图片
|
||||||
|
let tipIdCounter = 0; // 提示ID计数器
|
||||||
|
|
||||||
|
const isVisible = ref(false);
|
||||||
|
const amountTips = ref([]); // 金额提示数组
|
||||||
|
const redPackets = ref([]); // 红包数组(响应式)
|
||||||
|
|
||||||
|
// 红包类
|
||||||
|
class RedPacket {
|
||||||
|
constructor(x, y, image) {
|
||||||
|
this.x = x;
|
||||||
|
this.y = y;
|
||||||
|
// 使用props中的大小范围
|
||||||
|
this.width = props.minSize + Math.random() * (props.maxSize - props.minSize);
|
||||||
|
this.height = this.width * 0.8; // 保持比例
|
||||||
|
this.speed = props.speed + Math.random() * 2; // 随机速度
|
||||||
|
this.rotation = Math.random() * 0.2 - 0.1; // 轻微旋转
|
||||||
|
this.rotationSpeed = (Math.random() - 0.5) * 0.02;
|
||||||
|
this.image = image;
|
||||||
|
this.opacity = 0.9 + Math.random() * 0.1;
|
||||||
|
// 随机金额
|
||||||
|
this.amount = (props.minAmount + Math.random() * (props.maxAmount - props.minAmount)).toFixed(2);
|
||||||
|
this.clicked = false; // 是否已被点击
|
||||||
|
this.id = Date.now() + Math.random(); // 唯一ID
|
||||||
|
}
|
||||||
|
|
||||||
|
update() {
|
||||||
|
this.y += this.speed;
|
||||||
|
this.rotation += this.rotationSpeed;
|
||||||
|
|
||||||
|
// 轻微的左右摆动
|
||||||
|
this.x += Math.sin(this.y * 0.01) * 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
draw() {
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalAlpha = this.opacity;
|
||||||
|
ctx.translate(this.x + this.width / 2, this.y + this.height / 2);
|
||||||
|
ctx.rotate(this.rotation);
|
||||||
|
|
||||||
|
// 根据模式选择绘制方式
|
||||||
|
if (props.useImages && this.image) {
|
||||||
|
console.log('🧧 使用图片模式绘制红包:', this.image);
|
||||||
|
this.drawImage();
|
||||||
|
} else {
|
||||||
|
console.log('🧧 使用代码绘制红包');
|
||||||
|
this.drawRedPacket();
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绘制图片红包
|
||||||
|
drawImage() {
|
||||||
|
const halfWidth = this.width / 2;
|
||||||
|
const halfHeight = this.height / 2;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (this.image && typeof this.image === 'string') {
|
||||||
|
console.log('🧧 绘制图片红包:', this.image);
|
||||||
|
|
||||||
|
// 在uni-app中,使用ctx.drawImage绘制图片
|
||||||
|
// 参数:图片路径, x, y, width, height
|
||||||
|
ctx.drawImage(this.image, -halfWidth, -halfHeight, this.width, this.height);
|
||||||
|
|
||||||
|
console.log('🧧 图片绘制完成');
|
||||||
|
} else {
|
||||||
|
console.log('🧧 图片无效,使用绘制模式');
|
||||||
|
this.drawRedPacket();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('🧧 图片绘制失败,降级到绘制模式:', error);
|
||||||
|
this.drawRedPacket();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
drawRedPacket() {
|
||||||
|
const halfWidth = this.width / 2;
|
||||||
|
const halfHeight = this.height / 2;
|
||||||
|
|
||||||
|
// 绘制红包主体(圆角矩形)
|
||||||
|
ctx.setFillStyle('#ff4444');
|
||||||
|
ctx.fillRect(-halfWidth, -halfHeight, this.width, this.height);
|
||||||
|
|
||||||
|
// 绘制金色装饰边框
|
||||||
|
ctx.setStrokeStyle('#ffd700');
|
||||||
|
ctx.setLineWidth(2);
|
||||||
|
ctx.strokeRect(-halfWidth, -halfHeight, this.width, this.height);
|
||||||
|
|
||||||
|
// 绘制中间的"福"字或装饰
|
||||||
|
ctx.setFillStyle('#ffd700');
|
||||||
|
try {
|
||||||
|
ctx.setFontSize(this.width * 0.4);
|
||||||
|
ctx.setTextAlign('center');
|
||||||
|
ctx.fillText('福', 0, this.height * 0.1);
|
||||||
|
} catch (error) {
|
||||||
|
// 如果文本绘制失败,绘制一个圆形装饰
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(0, 0, this.width * 0.15, 0, 2 * Math.PI);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绘制顶部的蝴蝶结
|
||||||
|
ctx.setFillStyle('#ffaa00');
|
||||||
|
ctx.fillRect(-halfWidth * 0.6, -halfHeight, this.width * 0.6, this.height * 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
isOffScreen() {
|
||||||
|
return this.y > canvasHeight + this.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测点击
|
||||||
|
isClicked(touchX, touchY) {
|
||||||
|
if (this.clicked) return false;
|
||||||
|
|
||||||
|
const centerX = this.x + this.width / 2;
|
||||||
|
const centerY = this.y + this.height / 2;
|
||||||
|
const distance = Math.sqrt(
|
||||||
|
Math.pow(touchX - centerX, 2) + Math.pow(touchY - centerY, 2)
|
||||||
|
);
|
||||||
|
|
||||||
|
return distance <= Math.max(this.width, this.height) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标记为已点击
|
||||||
|
markAsClicked() {
|
||||||
|
this.clicked = true;
|
||||||
|
// 点击后变为半透明
|
||||||
|
this.opacity = 0.3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 图片加载成功事件
|
||||||
|
const onImageLoad = (event) => {
|
||||||
|
console.log('🧧 预加载图片成功:', event.target.src);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 图片加载失败事件
|
||||||
|
const onImageError = (event) => {
|
||||||
|
console.error('🧧 预加载图片失败:', event.target.src);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 红包图片加载成功事件
|
||||||
|
const onRedPacketImageLoad = () => {
|
||||||
|
// 图片加载成功,无需处理
|
||||||
|
};
|
||||||
|
|
||||||
|
// 红包图片加载失败事件
|
||||||
|
const onRedPacketImageError = () => {
|
||||||
|
// 图片加载失败,将使用绘制模式
|
||||||
|
};
|
||||||
|
|
||||||
|
// 加载图片(简化版,依赖image标签预加载)
|
||||||
|
const loadImages = async () => {
|
||||||
|
if (!props.useImages || props.images.length === 0) {
|
||||||
|
loadedImages.set('default', 'default');
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 直接将图片路径添加到loadedImages中
|
||||||
|
// 实际的预加载由template中的image标签完成
|
||||||
|
for (const imagePath of props.images) {
|
||||||
|
loadedImages.set(imagePath, imagePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 初始化Canvas
|
||||||
|
const initCanvas = () => {
|
||||||
|
try {
|
||||||
|
ctx = uni.createCanvasContext('redPacketRain', app.proxy);
|
||||||
|
|
||||||
|
if (ctx) {
|
||||||
|
// 获取屏幕尺寸
|
||||||
|
const systemInfo = uni.getSystemInfoSync();
|
||||||
|
canvasWidth = systemInfo.windowWidth || 375;
|
||||||
|
canvasHeight = systemInfo.windowHeight || 667;
|
||||||
|
|
||||||
|
// 初始化透明背景
|
||||||
|
ctx.setFillStyle('rgba(0, 0, 0, 0)');
|
||||||
|
ctx.fillRect(0, 0, canvasWidth, canvasHeight);
|
||||||
|
ctx.draw();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 创建红包
|
||||||
|
const createRedPackets = () => {
|
||||||
|
if (!isRunning) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < props.density; i++) {
|
||||||
|
const x = Math.random() * (canvasWidth - 100);
|
||||||
|
const y = -100 - Math.random() * 200; // 从屏幕上方开始
|
||||||
|
|
||||||
|
let image = null;
|
||||||
|
|
||||||
|
if (props.useImages && loadedImages.size > 0) {
|
||||||
|
// 随机选择一张图片
|
||||||
|
const allKeys = Array.from(loadedImages.keys());
|
||||||
|
const imageKeys = allKeys.filter(key => key !== 'default');
|
||||||
|
|
||||||
|
if (imageKeys.length > 0) {
|
||||||
|
const randomImage = imageKeys[Math.floor(Math.random() * imageKeys.length)];
|
||||||
|
image = loadedImages.get(randomImage);
|
||||||
|
} else {
|
||||||
|
// 如果没有过滤出图片,直接使用第一个
|
||||||
|
if (allKeys.length > 0) {
|
||||||
|
const firstKey = allKeys[0];
|
||||||
|
image = loadedImages.get(firstKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const packet = new RedPacket(x, y, image);
|
||||||
|
redPackets.value.push(packet);
|
||||||
|
}
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
// 动画循环
|
||||||
|
const animate = () => {
|
||||||
|
if (!isRunning) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 更新红包位置(HTML元素会自动重新渲染)
|
||||||
|
redPackets.value = redPackets.value.filter(packet => {
|
||||||
|
packet.update();
|
||||||
|
return !packet.isOffScreen();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 继续动画
|
||||||
|
if (isRunning) {
|
||||||
|
setTimeout(animate, 16); // 约60fps
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (isRunning) {
|
||||||
|
setTimeout(animate, 50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 开始动画
|
||||||
|
const startAnimation = () => {
|
||||||
|
isRunning = true;
|
||||||
|
animate();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 停止动画
|
||||||
|
const stopAnimation = () => {
|
||||||
|
isRunning = false;
|
||||||
|
if (animationId) {
|
||||||
|
clearTimeout(animationId);
|
||||||
|
animationId = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 开始创建红包的定时器
|
||||||
|
const startCreating = () => {
|
||||||
|
if (createTimer) {
|
||||||
|
clearInterval(createTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 立即创建一批红包
|
||||||
|
createRedPackets();
|
||||||
|
|
||||||
|
// 每隔一段时间创建新红包
|
||||||
|
createTimer = setInterval(() => {
|
||||||
|
if (isRunning && createTimer) { // 确保在停止创建后不再生成新红包
|
||||||
|
createRedPackets();
|
||||||
|
}
|
||||||
|
}, 1000); // 每秒创建新红包
|
||||||
|
};
|
||||||
|
|
||||||
|
// 停止创建红包
|
||||||
|
const stopCreating = () => {
|
||||||
|
if (createTimer) {
|
||||||
|
clearInterval(createTimer);
|
||||||
|
createTimer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 启动红包雨
|
||||||
|
const startRedPacketRain = async (customDuration) => {
|
||||||
|
// 显示组件
|
||||||
|
isVisible.value = true;
|
||||||
|
|
||||||
|
// 加载图片
|
||||||
|
await loadImages();
|
||||||
|
|
||||||
|
// 初始化Canvas(可选,仅用于背景)
|
||||||
|
initCanvas();
|
||||||
|
|
||||||
|
// 开始动画和创建红包
|
||||||
|
startAnimation();
|
||||||
|
startCreating();
|
||||||
|
|
||||||
|
// 设置自动停止定时器
|
||||||
|
const duration = customDuration || props.duration;
|
||||||
|
|
||||||
|
if (autoStopTimer) {
|
||||||
|
clearTimeout(autoStopTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
autoStopTimer = setTimeout(() => {
|
||||||
|
stopRedPacketRain();
|
||||||
|
}, duration);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 停止红包雨
|
||||||
|
const stopRedPacketRain = () => {
|
||||||
|
// 停止创建新红包,但继续动画让现有红包落完
|
||||||
|
stopCreating();
|
||||||
|
|
||||||
|
// 清除自动停止定时器
|
||||||
|
if (autoStopTimer) {
|
||||||
|
clearTimeout(autoStopTimer);
|
||||||
|
autoStopTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 等待现有红包全部落下后再停止动画和隐藏组件
|
||||||
|
const waitForPacketsToFall = () => {
|
||||||
|
if (redPackets.value.length === 0) {
|
||||||
|
// 所有红包都落下了,停止动画
|
||||||
|
stopAnimation();
|
||||||
|
amountTips.value = [];
|
||||||
|
|
||||||
|
// 延迟隐藏组件
|
||||||
|
setTimeout(() => {
|
||||||
|
isVisible.value = false;
|
||||||
|
}, 1000);
|
||||||
|
} else {
|
||||||
|
// 还有红包在飘落,继续等待
|
||||||
|
setTimeout(waitForPacketsToFall, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
waitForPacketsToFall();
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// 处理红包图片点击
|
||||||
|
const handlePacketClick = (packet, event) => {
|
||||||
|
if (!props.clickable || packet.clicked) return;
|
||||||
|
|
||||||
|
console.log('🧧 点击红包图片,金额:', packet.amount);
|
||||||
|
|
||||||
|
// 标记红包为已点击
|
||||||
|
packet.markAsClicked();
|
||||||
|
|
||||||
|
// 获取点击位置
|
||||||
|
const touchX = event.currentTarget.offsetLeft + packet.width / 2;
|
||||||
|
const touchY = event.currentTarget.offsetTop + packet.height / 2;
|
||||||
|
|
||||||
|
// 显示金额提示
|
||||||
|
showAmountTip(touchX, touchY, packet.amount);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 显示金额提示
|
||||||
|
const showAmountTip = (x, y, amount) => {
|
||||||
|
const tipId = ++tipIdCounter;
|
||||||
|
const tip = {
|
||||||
|
id: tipId,
|
||||||
|
x: x - 50, // 居中显示
|
||||||
|
y: y - 30,
|
||||||
|
amount: amount,
|
||||||
|
label: '恭喜获得',
|
||||||
|
opacity: 1
|
||||||
|
};
|
||||||
|
|
||||||
|
amountTips.value.push(tip);
|
||||||
|
|
||||||
|
// 动画效果:向上移动并淡出
|
||||||
|
let animationStep = 0;
|
||||||
|
const animate = () => {
|
||||||
|
animationStep++;
|
||||||
|
const progress = animationStep / 60; // 60帧动画
|
||||||
|
|
||||||
|
if (progress >= 1) {
|
||||||
|
// 动画结束,移除提示
|
||||||
|
const index = amountTips.value.findIndex(t => t.id === tipId);
|
||||||
|
if (index !== -1) {
|
||||||
|
amountTips.value.splice(index, 1);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新提示位置和透明度
|
||||||
|
const tipIndex = amountTips.value.findIndex(t => t.id === tipId);
|
||||||
|
if (tipIndex !== -1) {
|
||||||
|
amountTips.value[tipIndex].y = y - 30 - progress * 50; // 向上移动50px
|
||||||
|
amountTips.value[tipIndex].opacity = 1 - progress; // 淡出
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(animate, 16); // 约60fps
|
||||||
|
};
|
||||||
|
|
||||||
|
animate();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 组件卸载时清理资源
|
||||||
|
onUnmounted(() => {
|
||||||
|
console.log('🧧 红包雨组件卸载,清理资源');
|
||||||
|
stopRedPacketRain();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 暴露方法
|
||||||
|
defineExpose({
|
||||||
|
startRedPacketRain,
|
||||||
|
stopRedPacketRain
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.red-packet-container {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 998; /* 比烟花稍低的层级 */
|
||||||
|
pointer-events: none; /* 容器本身不阻挡点击事件 */
|
||||||
|
|
||||||
|
.red-packet-canvas {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
pointer-events: none; /* Canvas 背景不阻挡点击事件 */
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.test-red-packet {
|
||||||
|
border: 2px solid red;
|
||||||
|
}
|
||||||
|
|
||||||
|
.red-packet-image {
|
||||||
|
position: absolute;
|
||||||
|
pointer-events: auto; /* 红包图片可以被点击 */
|
||||||
|
z-index: 999;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.red-packet-item {
|
||||||
|
position: absolute;
|
||||||
|
pointer-events: auto; /* 红包元素可以被点击 */
|
||||||
|
z-index: 999;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.red-packet-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.red-packet-drawn {
|
||||||
|
position: relative;
|
||||||
|
background: linear-gradient(135deg, #ff4444, #cc0000);
|
||||||
|
border: 2px solid #ffd700;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -8px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 60%;
|
||||||
|
height: 12px;
|
||||||
|
background: linear-gradient(135deg, #ffaa00, #ff8800);
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #ffd700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.red-packet-text {
|
||||||
|
color: #ffd700;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: bold;
|
||||||
|
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preload-image {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount-tip {
|
||||||
|
position: fixed;
|
||||||
|
pointer-events: none; /* 金额提示不阻挡点击事件 */
|
||||||
|
z-index: 1000; /* 确保金额提示在最上层显示 */
|
||||||
|
transition: all 0.3s ease-out;
|
||||||
|
|
||||||
|
.tip-content {
|
||||||
|
background: linear-gradient(135deg, #ff6b6b, #ff8e8e);
|
||||||
|
color: white;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 20px;
|
||||||
|
box-shadow: 0 4px 12px rgba(255, 107, 107, 0.4);
|
||||||
|
text-align: center;
|
||||||
|
min-width: 100px;
|
||||||
|
|
||||||
|
.amount {
|
||||||
|
display: block;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
378
uni-app/src/components/firework-effect/simple-firework.vue
Normal file
378
uni-app/src/components/firework-effect/simple-firework.vue
Normal file
@ -0,0 +1,378 @@
|
|||||||
|
<template>
|
||||||
|
<view class="firework-container" v-if="isVisible">
|
||||||
|
<canvas
|
||||||
|
class="firework-canvas"
|
||||||
|
canvas-id="simpleFirework"
|
||||||
|
id="simpleFirework"
|
||||||
|
></canvas>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
// #ifdef VUE3
|
||||||
|
import {
|
||||||
|
ref,
|
||||||
|
onMounted,
|
||||||
|
onUnmounted,
|
||||||
|
getCurrentInstance
|
||||||
|
} from 'vue';
|
||||||
|
// #endif
|
||||||
|
// #ifndef VUE3
|
||||||
|
import {
|
||||||
|
ref,
|
||||||
|
onMounted,
|
||||||
|
onUnmounted,
|
||||||
|
getCurrentInstance
|
||||||
|
} from '@vue/composition-api';
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
// 定义props
|
||||||
|
const props = defineProps({
|
||||||
|
duration: {
|
||||||
|
type: Number,
|
||||||
|
default: 30000 // 默认30秒
|
||||||
|
},
|
||||||
|
autoStart: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
noTrail: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true // 默认不显示拖尾,避免黑色阴影
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const app = getCurrentInstance();
|
||||||
|
let ctx = null;
|
||||||
|
let animationId = null;
|
||||||
|
let fireworks = [];
|
||||||
|
let isRunning = false;
|
||||||
|
let canvasWidth = 375;
|
||||||
|
let canvasHeight = 667;
|
||||||
|
let autoStopTimer = null;
|
||||||
|
let autoLaunchTimer = null;
|
||||||
|
|
||||||
|
const isVisible = ref(false);
|
||||||
|
|
||||||
|
// 获取随机颜色
|
||||||
|
const getRandomColor = () => {
|
||||||
|
const colors = ['#ff0000', '#00ff00', '#0000ff', '#ffff00', '#ff00ff', '#00ffff'];
|
||||||
|
return colors[Math.floor(Math.random() * colors.length)];
|
||||||
|
};
|
||||||
|
|
||||||
|
// 简化的烟花类
|
||||||
|
class SimpleFirework {
|
||||||
|
constructor(startX, startY, targetX, targetY) {
|
||||||
|
this.x = startX;
|
||||||
|
this.y = startY;
|
||||||
|
this.targetX = targetX;
|
||||||
|
this.targetY = targetY;
|
||||||
|
this.color = getRandomColor();
|
||||||
|
this.particles = [];
|
||||||
|
this.exploded = false;
|
||||||
|
this.speed = 5;
|
||||||
|
|
||||||
|
// 计算方向
|
||||||
|
const dx = targetX - startX;
|
||||||
|
const dy = targetY - startY;
|
||||||
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
this.vx = (dx / distance) * this.speed;
|
||||||
|
this.vy = (dy / distance) * this.speed;
|
||||||
|
}
|
||||||
|
|
||||||
|
update() {
|
||||||
|
if (!this.exploded) {
|
||||||
|
this.x += this.vx;
|
||||||
|
this.y += this.vy;
|
||||||
|
|
||||||
|
// 检查是否到达目标
|
||||||
|
const distance = Math.sqrt((this.targetX - this.x) ** 2 + (this.targetY - this.y) ** 2);
|
||||||
|
if (distance < 20 || this.y <= this.targetY) {
|
||||||
|
this.explode();
|
||||||
|
this.exploded = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 更新粒子
|
||||||
|
this.particles = this.particles.filter(particle => {
|
||||||
|
particle.x += particle.vx;
|
||||||
|
particle.y += particle.vy;
|
||||||
|
particle.vy += 0.1; // 重力
|
||||||
|
particle.alpha -= 0.02;
|
||||||
|
return particle.alpha > 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
explode() {
|
||||||
|
// 创建粒子
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
const angle = (Math.PI * 2 * i) / 20;
|
||||||
|
const speed = Math.random() * 4 + 2;
|
||||||
|
this.particles.push({
|
||||||
|
x: this.x,
|
||||||
|
y: this.y,
|
||||||
|
vx: Math.cos(angle) * speed,
|
||||||
|
vy: Math.sin(angle) * speed,
|
||||||
|
alpha: 1,
|
||||||
|
color: this.color
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
draw() {
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!this.exploded) {
|
||||||
|
// 绘制发射的烟花
|
||||||
|
if (ctx.setFillStyle) {
|
||||||
|
ctx.setFillStyle(this.color);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(this.x, this.y, 3, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 绘制爆炸粒子
|
||||||
|
this.particles.forEach(particle => {
|
||||||
|
if (ctx.setGlobalAlpha && ctx.setFillStyle) {
|
||||||
|
ctx.setGlobalAlpha(particle.alpha);
|
||||||
|
ctx.setFillStyle(particle.color);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(particle.x, particle.y, 2, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (ctx.setGlobalAlpha) {
|
||||||
|
ctx.setGlobalAlpha(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// 绘制错误,忽略
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isDead() {
|
||||||
|
return this.exploded && this.particles.length === 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化Canvas
|
||||||
|
const initCanvas = () => {
|
||||||
|
try {
|
||||||
|
// 尝试创建Canvas上下文
|
||||||
|
ctx = uni.createCanvasContext('simpleFirework', app.proxy);
|
||||||
|
|
||||||
|
if (ctx) {
|
||||||
|
// 获取屏幕尺寸
|
||||||
|
const systemInfo = uni.getSystemInfoSync();
|
||||||
|
canvasWidth = systemInfo.windowWidth || 375;
|
||||||
|
canvasHeight = systemInfo.windowHeight || 667;
|
||||||
|
|
||||||
|
// 初始化透明背景
|
||||||
|
ctx.setFillStyle('rgba(255, 255, 255, 0.01)');
|
||||||
|
ctx.fillRect(0, 0, canvasWidth, canvasHeight);
|
||||||
|
|
||||||
|
// 绘制一个测试点来验证Canvas是否工作
|
||||||
|
ctx.setFillStyle('#ff0000');
|
||||||
|
ctx.fillRect(canvasWidth / 2, canvasHeight / 2, 10, 10);
|
||||||
|
|
||||||
|
ctx.draw(); // 立即绘制初始背景
|
||||||
|
|
||||||
|
// 开始动画(延迟更长时间确保Canvas完全准备好)
|
||||||
|
setTimeout(() => {
|
||||||
|
startAnimation();
|
||||||
|
}, 1000);
|
||||||
|
} else {
|
||||||
|
// 重试机制
|
||||||
|
setTimeout(() => {
|
||||||
|
initCanvas();
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// 重试机制
|
||||||
|
setTimeout(() => {
|
||||||
|
initCanvas();
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 动画循环
|
||||||
|
const animate = () => {
|
||||||
|
if (!isRunning || !ctx) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 根据props决定清除方式
|
||||||
|
if (props.noTrail) {
|
||||||
|
// 完全清除,无拖尾效果,无阴影
|
||||||
|
ctx.clearRect && ctx.clearRect(0, 0, canvasWidth, canvasHeight);
|
||||||
|
// 如果不支持clearRect,使用极淡的白色
|
||||||
|
if (!ctx.clearRect) {
|
||||||
|
ctx.setFillStyle('rgba(255, 255, 255, 0.02)');
|
||||||
|
ctx.fillRect(0, 0, canvasWidth, canvasHeight);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 半透明清除,产生拖尾效果
|
||||||
|
ctx.setFillStyle('rgba(255, 255, 255, 0.08)');
|
||||||
|
ctx.fillRect(0, 0, canvasWidth, canvasHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新和绘制烟花
|
||||||
|
fireworks = fireworks.filter(firework => {
|
||||||
|
firework.update();
|
||||||
|
firework.draw();
|
||||||
|
return !firework.isDead();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 绘制到画布
|
||||||
|
ctx.draw();
|
||||||
|
|
||||||
|
// 继续动画
|
||||||
|
setTimeout(animate, 16); // 约60fps
|
||||||
|
} catch (error) {
|
||||||
|
if (isRunning) {
|
||||||
|
setTimeout(animate, 50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 开始动画
|
||||||
|
const startAnimation = () => {
|
||||||
|
isRunning = true;
|
||||||
|
animate();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 停止动画
|
||||||
|
const stopAnimation = () => {
|
||||||
|
isRunning = false;
|
||||||
|
fireworks = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
// 创建烟花
|
||||||
|
const createFirework = (targetX, targetY) => {
|
||||||
|
if (!ctx) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startX = Math.random() * canvasWidth;
|
||||||
|
const startY = canvasHeight;
|
||||||
|
const finalTargetX = targetX || Math.random() * canvasWidth;
|
||||||
|
const finalTargetY = targetY || Math.random() * canvasHeight * 0.5;
|
||||||
|
|
||||||
|
fireworks.push(new SimpleFirework(startX, startY, finalTargetX, finalTargetY));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 自动发射
|
||||||
|
const startAutoLaunch = () => {
|
||||||
|
if (autoLaunchTimer) {
|
||||||
|
clearInterval(autoLaunchTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
autoLaunchTimer = setInterval(() => {
|
||||||
|
if (isRunning && autoLaunchTimer) { // 确保在停止发射后不再生成新烟花
|
||||||
|
createFirework();
|
||||||
|
}
|
||||||
|
}, 800); // 每0.8秒发射一个烟花
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopAutoLaunch = () => {
|
||||||
|
if (autoLaunchTimer) {
|
||||||
|
clearInterval(autoLaunchTimer);
|
||||||
|
autoLaunchTimer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 移除点击和触摸处理,避免阻挡用户操作
|
||||||
|
|
||||||
|
// 启动烟花
|
||||||
|
const startFireworks = (customDuration) => {
|
||||||
|
// 显示组件
|
||||||
|
isVisible.value = true;
|
||||||
|
|
||||||
|
if (!isRunning) {
|
||||||
|
startAnimation();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 立即创建一个测试烟花
|
||||||
|
createFirework();
|
||||||
|
|
||||||
|
// 开始自动发射
|
||||||
|
startAutoLaunch();
|
||||||
|
|
||||||
|
// 设置自动停止定时器
|
||||||
|
const duration = customDuration || props.duration;
|
||||||
|
|
||||||
|
if (autoStopTimer) {
|
||||||
|
clearTimeout(autoStopTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
autoStopTimer = setTimeout(() => {
|
||||||
|
stopFireworks();
|
||||||
|
}, duration);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 停止烟花
|
||||||
|
const stopFireworks = () => {
|
||||||
|
// 停止自动发射新烟花,但继续动画让现有烟花完成
|
||||||
|
stopAutoLaunch();
|
||||||
|
|
||||||
|
// 清除自动停止定时器
|
||||||
|
if (autoStopTimer) {
|
||||||
|
clearTimeout(autoStopTimer);
|
||||||
|
autoStopTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 等待现有烟花全部完成后再停止动画和隐藏组件
|
||||||
|
const waitForFireworksToFinish = () => {
|
||||||
|
if (fireworks.length === 0) {
|
||||||
|
// 所有烟花都完成了,停止动画
|
||||||
|
stopAnimation();
|
||||||
|
|
||||||
|
// 延迟隐藏组件
|
||||||
|
setTimeout(() => {
|
||||||
|
isVisible.value = false;
|
||||||
|
}, 1000);
|
||||||
|
} else {
|
||||||
|
// 还有烟花在进行,继续等待
|
||||||
|
setTimeout(waitForFireworksToFinish, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
waitForFireworksToFinish();
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
initCanvas();
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
stopFireworks();
|
||||||
|
});
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
startFireworks,
|
||||||
|
stopFireworks,
|
||||||
|
createFirework
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.firework-container {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
pointer-events: none; /* 不阻挡用户操作 */
|
||||||
|
z-index: 999; /* 降低层级,避免遮挡重要UI */
|
||||||
|
|
||||||
|
.firework-canvas {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
pointer-events: none; /* Canvas也不接收事件,完全不阻挡操作 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -22,7 +22,7 @@
|
|||||||
<u-form-item :label=" t('nickname')" prop="nickname" :border-bottom="true">
|
<u-form-item :label=" t('nickname')" prop="nickname" :border-bottom="true">
|
||||||
<input type="nickname" v-model="formData.nickname" :placeholder="t('nicknamePlaceholder')" placeholderClass="text-[28rpx]" class="text-[28rpx]" @blur="bindNickname" @click="checkAuth($event, 'nickname')"/>
|
<input type="nickname" v-model="formData.nickname" :placeholder="t('nicknamePlaceholder')" placeholderClass="text-[28rpx]" class="text-[28rpx]" @blur="bindNickname" @click="checkAuth($event, 'nickname')"/>
|
||||||
</u-form-item>
|
</u-form-item>
|
||||||
<u-form-item :label="t('mobile')" prop="mobile" :border-bottom="true" v-if="isBindMobile">
|
<u-form-item :label="t('mobile')" prop="mobile" :border-bottom="true" v-if="isBindMobile || config.login.is_bind_mobile">
|
||||||
<input type="mobile" v-model="formData.mobile" :disabled="true" v-if="formData.mobile">
|
<input type="mobile" v-model="formData.mobile" :disabled="true" v-if="formData.mobile">
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<u-button v-if="info" :customStyle="{border:'none',color: 'var(--primary-color)',width:'140rpx', textAlign:'left',margin:'0rpx'}" :text="t('getMobile')" open-type="getPhoneNumber" @getphonenumber="memberStore.bindMobile"></u-button>
|
<u-button v-if="info" :customStyle="{border:'none',color: 'var(--primary-color)',width:'140rpx', textAlign:'left',margin:'0rpx'}" :text="t('getMobile')" open-type="getPhoneNumber" @getphonenumber="memberStore.bindMobile"></u-button>
|
||||||
|
|||||||
355
uni-app/src/components/music/music-container.vue
Normal file
355
uni-app/src/components/music/music-container.vue
Normal file
@ -0,0 +1,355 @@
|
|||||||
|
<template>
|
||||||
|
<view class="music-container" :class="{ playing: isPlaying }" :style="musicStyle">
|
||||||
|
<view class="music-control" :style="defaultStyleCss" @click="togglePlay">
|
||||||
|
<view class="music-disc " :class="{ rotate: isPlaying }">
|
||||||
|
<image v-if="discImage" class="disc-image" :src="discImage" mode="aspectFill"></image>
|
||||||
|
<block v-else>
|
||||||
|
<text class="default-style iconfont iconyinfuV6mm" v-if="isPlaying"></text>
|
||||||
|
<text class="default-style disable iconfont iconyinfuV6mm" v-else></text>
|
||||||
|
</block>
|
||||||
|
</view>
|
||||||
|
<view class="music-icon">
|
||||||
|
<text v-if="isPlaying" class="iconfont icon-pause"></text>
|
||||||
|
<text v-else class="iconfont icon-play"></text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref, watch, onMounted, onBeforeUnmount, computed } from 'vue';
|
||||||
|
import { img } from '@/utils/common';
|
||||||
|
// 定义组件属性
|
||||||
|
const props = defineProps({
|
||||||
|
// 音乐文件地址
|
||||||
|
src: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
// 是否自动播放
|
||||||
|
autoplay: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
// 是否循环播放
|
||||||
|
loop: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
// 自定义唱片图片
|
||||||
|
discImage: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
defaultBgColor: {
|
||||||
|
type: String,
|
||||||
|
default: '#B0B0B0'
|
||||||
|
},
|
||||||
|
defaultTextColor: {
|
||||||
|
type: String,
|
||||||
|
default: '#FFFFFF'
|
||||||
|
},
|
||||||
|
defaultRounded: {
|
||||||
|
type: Number,
|
||||||
|
default: 20
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 默认唱片图片
|
||||||
|
const defaultDiscImage = img('/addon/seckill/goods/music1.png');
|
||||||
|
const defaultDiscImage2 = img('/addon/seckill/goods/music2.png');
|
||||||
|
|
||||||
|
// 播放状态
|
||||||
|
const isPlaying = ref(false);
|
||||||
|
// 音频实例
|
||||||
|
let audioContext: any = null;
|
||||||
|
|
||||||
|
// 创建音频实例
|
||||||
|
const createAudioContext = () => {
|
||||||
|
console.log('创建音频实例,音乐地址:', props.src);
|
||||||
|
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
audioContext = uni.createInnerAudioContext();
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
// #ifdef APP-PLUS || H5
|
||||||
|
audioContext = uni.createInnerAudioContext();
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
if (!audioContext) {
|
||||||
|
console.error('无法创建音频实例');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
audioContext.src = props.src;
|
||||||
|
audioContext.loop = props.loop;
|
||||||
|
|
||||||
|
// 监听播放事件
|
||||||
|
audioContext.onPlay(() => {
|
||||||
|
console.log('音乐开始播放');
|
||||||
|
isPlaying.value = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听暂停事件
|
||||||
|
audioContext.onPause(() => {
|
||||||
|
console.log('音乐暂停播放');
|
||||||
|
isPlaying.value = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听停止事件
|
||||||
|
audioContext.onStop(() => {
|
||||||
|
console.log('音乐停止播放');
|
||||||
|
isPlaying.value = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听结束事件
|
||||||
|
audioContext.onEnded(() => {
|
||||||
|
console.log('音乐播放结束');
|
||||||
|
isPlaying.value = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听错误事件
|
||||||
|
audioContext.onError((res: any) => {
|
||||||
|
console.error('音频播放错误:', res.errMsg || res);
|
||||||
|
isPlaying.value = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 自动播放
|
||||||
|
if (props.autoplay && props.src) {
|
||||||
|
// #ifdef H5
|
||||||
|
// H5环境下不自动播放
|
||||||
|
console.log('H5环境,不自动播放音乐');
|
||||||
|
isPlaying.value = false;
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
// #ifndef H5
|
||||||
|
// 非H5环境正常自动播放
|
||||||
|
console.log('准备自动播放音乐');
|
||||||
|
// 延迟一下确保各平台兼容性
|
||||||
|
setTimeout(() => {
|
||||||
|
playMusic();
|
||||||
|
}, 300);
|
||||||
|
// #endif
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const defaultStyleCss = computed(() => {
|
||||||
|
let style = '';
|
||||||
|
style += `background:${props.defaultBgColor};`;
|
||||||
|
if(props.defaultTextColor){ style += `color:${props.defaultTextColor};`; }
|
||||||
|
if(props.defaultRounded){
|
||||||
|
style += `border-border-top-left-radius:${props.defaultRounded * 2}rpx;`;
|
||||||
|
style += `border-border-bottom-left-radius:${props.defaultRounded * 2}rpx;`;
|
||||||
|
}
|
||||||
|
return style;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 播放音乐
|
||||||
|
const playMusic = () => {
|
||||||
|
if (!audioContext || !props.src) {
|
||||||
|
console.error('无法播放音乐,audioContext或src为空');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('尝试播放音乐:', props.src);
|
||||||
|
|
||||||
|
// 尝试播放音乐
|
||||||
|
audioContext.play();
|
||||||
|
|
||||||
|
// 强制设置播放状态
|
||||||
|
isPlaying.value = true;
|
||||||
|
|
||||||
|
// 微信小程序环境特殊处理
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
try {
|
||||||
|
const systemInfo = uni.getSystemInfoSync();
|
||||||
|
if (systemInfo.platform === 'ios') {
|
||||||
|
// iOS端需要特殊处理微信小程序音频播放
|
||||||
|
uni.showToast({
|
||||||
|
title: '音乐播放中...',
|
||||||
|
icon: 'none',
|
||||||
|
duration: 1000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取系统信息失败:', error);
|
||||||
|
}
|
||||||
|
// #endif
|
||||||
|
};
|
||||||
|
|
||||||
|
// 暂停音乐
|
||||||
|
const pauseMusic = () => {
|
||||||
|
if (!audioContext) return;
|
||||||
|
console.log('暂停音乐');
|
||||||
|
audioContext.pause();
|
||||||
|
isPlaying.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 切换播放状态
|
||||||
|
const togglePlay = () => {
|
||||||
|
console.log('切换播放状态,当前状态:', isPlaying.value);
|
||||||
|
if (isPlaying.value) {
|
||||||
|
pauseMusic();
|
||||||
|
} else {
|
||||||
|
playMusic();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 监听音乐地址变化
|
||||||
|
watch(() => props.src, (newSrc) => {
|
||||||
|
console.log('音乐地址变化:', newSrc);
|
||||||
|
if (!audioContext) return;
|
||||||
|
|
||||||
|
const wasPlaying = isPlaying.value;
|
||||||
|
|
||||||
|
// 更新音乐地址
|
||||||
|
audioContext.src = newSrc;
|
||||||
|
|
||||||
|
// 如果之前正在播放,则继续播放新音乐
|
||||||
|
if (wasPlaying && newSrc) {
|
||||||
|
playMusic();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听自动播放状态变化
|
||||||
|
watch(() => props.autoplay, (newAutoplay) => {
|
||||||
|
console.log('自动播放状态变化:', newAutoplay);
|
||||||
|
if (newAutoplay && audioContext && !isPlaying.value && props.src) {
|
||||||
|
playMusic();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 组件挂载时创建音频实例
|
||||||
|
onMounted(() => {
|
||||||
|
console.log('音乐组件挂载,初始化音频');
|
||||||
|
createAudioContext();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 组件卸载前释放资源
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
console.log('音乐组件卸载,释放资源');
|
||||||
|
if (audioContext) {
|
||||||
|
// 停止播放
|
||||||
|
if (isPlaying.value) {
|
||||||
|
audioContext.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 释放资源
|
||||||
|
audioContext.destroy();
|
||||||
|
audioContext = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const musicStyle = computed(() => {
|
||||||
|
return {
|
||||||
|
'--color': `${props.defaultTextColor}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 暴露方法给父组件使用
|
||||||
|
defineExpose({
|
||||||
|
play: playMusic,
|
||||||
|
pause: pauseMusic,
|
||||||
|
toggle: togglePlay,
|
||||||
|
isPlaying: () => isPlaying.value
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.music-container {
|
||||||
|
position: fixed;
|
||||||
|
right: 0;
|
||||||
|
top: calc(120rpx + var(--status-bar-height));
|
||||||
|
z-index: 999;
|
||||||
|
|
||||||
|
.music-control {
|
||||||
|
width: 80rpx;
|
||||||
|
height: 80rpx;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
border-top-left-radius: 20rpx;
|
||||||
|
border-bottom-left-radius: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.music-disc {
|
||||||
|
transition: all 0.3s;
|
||||||
|
|
||||||
|
&.rotate {
|
||||||
|
animation: rotate 5s linear infinite;
|
||||||
|
animation-play-state: running;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disc-image {
|
||||||
|
width: 60rpx;
|
||||||
|
height: 60rpx;
|
||||||
|
// background-color: #ffffff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.music-icon {
|
||||||
|
position: absolute;
|
||||||
|
width: 80rpx;
|
||||||
|
height: 80rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.iconfont {
|
||||||
|
font-size: 40rpx;
|
||||||
|
color: #ffffff;
|
||||||
|
text-shadow: 0 0 5rpx rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rotate {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 适配不同平台 */
|
||||||
|
/* #ifdef MP-WEIXIN */
|
||||||
|
.music-container {
|
||||||
|
z-index: 900;
|
||||||
|
}
|
||||||
|
/* #endif */
|
||||||
|
|
||||||
|
/* #ifdef APP-PLUS */
|
||||||
|
.music-container {
|
||||||
|
z-index: 999;
|
||||||
|
}
|
||||||
|
/* #endif */
|
||||||
|
|
||||||
|
/* #ifdef H5 */
|
||||||
|
.music-container {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 999;
|
||||||
|
}
|
||||||
|
/* #endif */
|
||||||
|
.default-style{
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 60rpx;
|
||||||
|
height: 60rpx;
|
||||||
|
font-size: 34rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
&.disable:after{
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
height: 50rpx;
|
||||||
|
width: 2rpx;
|
||||||
|
background: var(--color);
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%) rotate(-45deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
69
uni-app/src/components/online-service/online-service.vue
Normal file
69
uni-app/src/components/online-service/online-service.vue
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
<template>
|
||||||
|
<view @touchmove.prevent.stop>
|
||||||
|
<u-popup class="popup-type" mode="center" :show="servicesDataShow" @close="servicesDataShow = false" bgColor="transparent" overlayOpacity="0.8">
|
||||||
|
<view @touchmove.prevent.stop class="flex flex-colitems-center">
|
||||||
|
<view class="bg-[#ffff] rounded-[34rpx]">
|
||||||
|
<view class="text-center text-[30rpx] mt-[40rpx] mb-[40rpx]">联系在线客服</view>
|
||||||
|
<scroll-view scroll-y="true" class="w-[570rpx] pb-[60rpx] max-h-[580rpx] overflow-y-auto">
|
||||||
|
<view class="flex flex-col items-center justify-center px-[30rpx]" v-if="data.customer_phone">
|
||||||
|
<text class="text-[45rpx]">{{ data.customer_phone }}</text>
|
||||||
|
<view class="rounded-[30rpx] mt-[30rpx] bg-[#EF000C] text-[#fff] h-[60rpx] leading-[60rpx] w-[240rpx] text-center text-[24rpx]" @click="makePhoneCallFn(data.customer_phone)">一键拨打</view>
|
||||||
|
</view>
|
||||||
|
<view v-if="data.customer_phone" class="w-full h-[1rpx] bg-dashed-style my-[40rpx]"></view>
|
||||||
|
<view class="px-[24rpx] flex flex-col items-center justify-center" v-if="data.customer_qrcode">
|
||||||
|
<view class="border border-[#eee] border-solid rounded-[10rpx] border-[1rpx] w-[240rpx] h-[240rpx] flex items-center justify-center">
|
||||||
|
<image class="w-[226rpx] h-[226rpx]" :src="img(data.customer_qrcode)" mode="widthFix" />
|
||||||
|
</view>
|
||||||
|
<view class="text-[26rpx] text-[#333] mt-[20rpx]">扫描二维码添加客服</view>
|
||||||
|
</view>
|
||||||
|
<view class="px-[50rpx] mt-[16rpx] text-[22rpx] text-[#999] leading-[1.5]" style="text-indent: 2em;">{{ data.customer_guided_words }}</view>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
|
<!-- <view @click="servicesDataShow = false" class="mt-[50rpx] nc-iconfont nc-icon-cuohaoV6xx1 !text-[50rpx] text-[#fff]"></view> -->
|
||||||
|
</view>
|
||||||
|
</u-popup>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import { redirect, img } from '@/utils/common'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
data: {
|
||||||
|
type: Object,
|
||||||
|
default: {}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const servicesDataShow = ref<boolean>(false)
|
||||||
|
const data = computed(() => {
|
||||||
|
return props.data;
|
||||||
|
})
|
||||||
|
|
||||||
|
const open = () => {
|
||||||
|
servicesDataShow.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const makePhoneCallFn = (data: any)=>{
|
||||||
|
uni.makePhoneCall({
|
||||||
|
phoneNumber: data,
|
||||||
|
success: (res) => {
|
||||||
|
},
|
||||||
|
fail: (res) => {
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
open
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.bg-dashed-style {
|
||||||
|
background: linear-gradient(to right, #eee 60%, transparent 40%);
|
||||||
|
background-size: 24rpx 100%; /* 控制线段长度(20rpx)和间隔 */
|
||||||
|
background-repeat: repeat-x;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<!-- 分享弹窗 -->
|
<!-- 分享弹窗 -->
|
||||||
<view @touchmove.prevent.stop class="share-popup">
|
<view @touchmove.prevent.stop class="share-popup">
|
||||||
<u-popup :show="sharePopupShow" type="bottom" @close="sharePopupClose" overlayOpacity="0.8">
|
<u-popup :show="sharePopupShow" @close="sharePopupClose" overlayOpacity="0.8">
|
||||||
<view @touchmove.prevent.stop>
|
<view @touchmove.prevent.stop>
|
||||||
<view class="poster-img-wrap" :style="{'top': shareTop}">
|
<view class="poster-img-wrap" :style="{'top': shareTop}">
|
||||||
<image v-if="isPosterAnimation" class="poster-animation" :src="img('addon/shop/poster_animation.gif')" mode="aspectFit"/>
|
<image v-if="isPosterAnimation" class="poster-animation" :src="img('addon/shop/poster_animation.gif')" mode="aspectFit"/>
|
||||||
@ -61,7 +61,7 @@ import useSystemStore from "@/stores/system";
|
|||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
posterId: {
|
posterId: {
|
||||||
type: String || Number,
|
type: [String, Number],
|
||||||
default: 0
|
default: 0
|
||||||
},
|
},
|
||||||
posterType: {
|
posterType: {
|
||||||
@ -79,6 +79,10 @@ const props = defineProps({
|
|||||||
copyUrlParam: {
|
copyUrlParam: {
|
||||||
type: String,
|
type: String,
|
||||||
default: ''
|
default: ''
|
||||||
|
},
|
||||||
|
isPreload: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -117,7 +121,7 @@ const isPosterImg = ref(false)
|
|||||||
// 获取分享海报
|
// 获取分享海报
|
||||||
const poster = ref('');
|
const poster = ref('');
|
||||||
const loadPoster = () => {
|
const loadPoster = () => {
|
||||||
if (poster.value) {
|
if (poster.value && props.isPreload) {
|
||||||
// 预加载
|
// 预加载
|
||||||
isPosterAnimation.value = false;
|
isPosterAnimation.value = false;
|
||||||
isPosterImg.value = true;
|
isPosterImg.value = true;
|
||||||
@ -222,9 +226,10 @@ defineExpose({
|
|||||||
</script>
|
</script>
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.share-popup {
|
.share-popup {
|
||||||
:deep(.u-transition), :deep(.u-popup__content) {
|
// 苹果手机下要白色背景
|
||||||
background-color: transparent;
|
// :deep(.u-transition), :deep(.u-popup__content) {
|
||||||
}
|
// background-color: transparent;
|
||||||
|
// }
|
||||||
|
|
||||||
.share-content {
|
.share-content {
|
||||||
border-top-left-radius: 40rpx;
|
border-top-left-radius: 40rpx;
|
||||||
|
|||||||
@ -17,7 +17,7 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view v-if="topStatusBarData.style == 'style-3'" :style="navbarInnerStyle" class="content-wrap">
|
<view v-if="topStatusBarData.style == 'style-3'" :style="navbarInnerStyle" class="content-wrap">
|
||||||
<view class="back-wrap -ml-[16rpx] text-[26px] nc-iconfont nc-icon-zuoV6xx" :style="{ color: titleTextColor }" @tap="goBack" :class="{'!text-transparent': !isBackShow}"></view>
|
<view v-if="isBackShow" class="back-wrap -ml-[16rpx] text-[26px] nc-iconfont nc-icon-zuoV6xx" :style="{ color: titleTextColor }" @tap="goBack" :class="{'!text-transparent': !isBackShow}"></view>
|
||||||
<view class="title-wrap" @click="diyStore.toRedirect(topStatusBarData.link)">
|
<view class="title-wrap" @click="diyStore.toRedirect(topStatusBarData.link)">
|
||||||
<image :src="img(topStatusBarData.imgUrl)" mode="heightFix"/>
|
<image :src="img(topStatusBarData.imgUrl)" mode="heightFix"/>
|
||||||
</view>
|
</view>
|
||||||
|
|||||||
@ -202,7 +202,7 @@ export function useDiy(params: any = {}) {
|
|||||||
if (e.scrollTop > 0) {
|
if (e.scrollTop > 0) {
|
||||||
diyStore.scrollTop = e.scrollTop;
|
diyStore.scrollTop = e.scrollTop;
|
||||||
}
|
}
|
||||||
uni.$emit('scroll')
|
// uni.$emit('scroll')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -140,6 +140,7 @@ export function useDiyForm(params: any = {}) {
|
|||||||
if (requestData.value) {
|
if (requestData.value) {
|
||||||
diyData.pageMode = requestData.mode;
|
diyData.pageMode = requestData.mode;
|
||||||
diyData.title = requestData.title;
|
diyData.title = requestData.title;
|
||||||
|
diyData.type = requestData.type;
|
||||||
|
|
||||||
diyStore.id = requestData.form_id;
|
diyStore.id = requestData.form_id;
|
||||||
let sources = requestData.value;
|
let sources = requestData.value;
|
||||||
|
|||||||
@ -22,13 +22,14 @@ export function useLogin() {
|
|||||||
const systemStore = useSystemStore()
|
const systemStore = useSystemStore()
|
||||||
|
|
||||||
// #ifdef MP-WEIXIN
|
// #ifdef MP-WEIXIN
|
||||||
if (!uni.getStorageSync('autoLoginLock') && uni.getStorageSync('openid') && config.login.is_bind_mobile) {
|
|
||||||
|
if (!uni.getStorageSync('autoLoginLock') && config.login.is_bind_mobile) {
|
||||||
uni.setStorageSync('isBindMobile', true) // 强制绑定手机号标识
|
uni.setStorageSync('isBindMobile', true) // 强制绑定手机号标识
|
||||||
}
|
}
|
||||||
// #endif
|
// #endif
|
||||||
|
|
||||||
// #ifdef H5
|
// #ifdef H5
|
||||||
if (!uni.getStorageSync('autoLoginLock') && isWeixinBrowser() && uni.getStorageSync('openid') && config.login.is_bind_mobile) {
|
if (!uni.getStorageSync('autoLoginLock') && isWeixinBrowser() && config.login.is_bind_mobile) {
|
||||||
uni.setStorageSync('isBindMobile', true) // 强制绑定手机号标识
|
uni.setStorageSync('isBindMobile', true) // 强制绑定手机号标识
|
||||||
}
|
}
|
||||||
// #endif
|
// #endif
|
||||||
@ -213,7 +214,7 @@ export function useLogin() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
if (err.msg == -1) {
|
if (err.code == -1) {
|
||||||
getAuthCode({ scopes: 'snsapi_userinfo' })
|
getAuthCode({ scopes: 'snsapi_userinfo' })
|
||||||
} else {
|
} else {
|
||||||
uni.showToast({ title: err.msg, icon: 'none' })
|
uni.showToast({ title: err.msg, icon: 'none' })
|
||||||
|
|||||||
@ -40,6 +40,7 @@ export const useShare = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const setShare = (options: any = {}) => {
|
const setShare = (options: any = {}) => {
|
||||||
|
console.log('setShare options',options)
|
||||||
if (currRoute() == '' || currRoute().indexOf('app/pages/index/close') != -1 || currRoute().indexOf('app/pages/index/nosite') != -1) return;
|
if (currRoute() == '' || currRoute().indexOf('app/pages/index/close') != -1 || currRoute().indexOf('app/pages/index/nosite') != -1) return;
|
||||||
|
|
||||||
let queryStr = getQuery();
|
let queryStr = getQuery();
|
||||||
@ -60,10 +61,9 @@ export const useShare = () => {
|
|||||||
query: queryStr.join('&'),
|
query: queryStr.join('&'),
|
||||||
}
|
}
|
||||||
// #endif
|
// #endif
|
||||||
|
|
||||||
if (options && Object.keys(options).length) {
|
if (options && Object.keys(options).length) {
|
||||||
|
|
||||||
if (options.wechat) {
|
if (options.wechat) {
|
||||||
|
|
||||||
// #ifdef H5
|
// #ifdef H5
|
||||||
wechatOptions.title = options.wechat.title || ''
|
wechatOptions.title = options.wechat.title || ''
|
||||||
wechatOptions.link = options.wechat.link || h5Link
|
wechatOptions.link = options.wechat.link || h5Link
|
||||||
@ -89,37 +89,38 @@ export const useShare = () => {
|
|||||||
|
|
||||||
}
|
}
|
||||||
getShareInfo({
|
getShareInfo({
|
||||||
route: '/' + currRoute(),
|
route: '/' + currRoute(),
|
||||||
params: JSON.stringify(currShareRoute().params)
|
params: JSON.stringify(currShareRoute().params)
|
||||||
}).then((res: any) => {
|
}).then((res: any) => {
|
||||||
|
let data = res.data;
|
||||||
|
|
||||||
let data = res.data;
|
// #ifdef H5
|
||||||
|
let wechat = data.wechat;
|
||||||
|
if (wechat) {
|
||||||
|
wechatOptions.title = wechat.title
|
||||||
|
wechatOptions.desc = wechat.desc
|
||||||
|
wechatOptions.imgUrl = wechat.url ? img(wechat.url) : ''
|
||||||
|
} else {
|
||||||
|
wechatOptions.title = document.title;
|
||||||
|
wechatOptions.desc = ''
|
||||||
|
}
|
||||||
|
wechatShare()
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
let weapp = data.weapp;
|
||||||
|
if (weapp) {
|
||||||
|
weappOptions.title = weapp.title
|
||||||
|
weappOptions.imageUrl = weapp.url ? img(weapp.url) : ''
|
||||||
|
}
|
||||||
|
// #endif
|
||||||
|
if(!weappOptions.title && !weappOptions.imageUrl){
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
uni.setStorageSync('weappOptions', weappOptions)
|
||||||
|
})
|
||||||
|
|
||||||
// #ifdef H5
|
|
||||||
let wechat = data.wechat;
|
|
||||||
if (wechat) {
|
|
||||||
wechatOptions.title = wechat.title
|
|
||||||
wechatOptions.desc = wechat.desc
|
|
||||||
wechatOptions.imgUrl = wechat.url ? img(wechat.url) : ''
|
|
||||||
} else {
|
|
||||||
wechatOptions.title = document.title;
|
|
||||||
wechatOptions.desc = ''
|
|
||||||
}
|
|
||||||
wechatShare()
|
|
||||||
// #endif
|
|
||||||
|
|
||||||
// #ifdef MP-WEIXIN
|
|
||||||
let weapp = data.weapp;
|
|
||||||
if (weapp) {
|
|
||||||
weappOptions.title = weapp.title
|
|
||||||
weappOptions.imageUrl = weapp.url ? img(weapp.url) : ''
|
|
||||||
}
|
|
||||||
// #endif
|
|
||||||
if(!weappOptions.title && !weappOptions.imageUrl){
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
uni.setStorageSync('weappOptions', weappOptions)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 小程序分享,分享给好友
|
// 小程序分享,分享给好友
|
||||||
@ -149,10 +150,41 @@ export const useShare = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 禁用当前页面的分享功能(同时支持小程序和公众号)
|
||||||
|
const disableShare = () => {
|
||||||
|
// 公众号(H5)禁用分享
|
||||||
|
// #ifdef H5
|
||||||
|
if (isWeixinBrowser()) {
|
||||||
|
// 确保SDK初始化后再禁用
|
||||||
|
wechat.init(() => {
|
||||||
|
wechat.disableShare();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
// 小程序禁用分享
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
// 隐藏分享菜单(转发给朋友、朋友圈)
|
||||||
|
uni.hideShareMenu({
|
||||||
|
menus: ['shareAppMessage', 'shareTimeline'],
|
||||||
|
success: () => {
|
||||||
|
console.log('小程序分享已禁用');
|
||||||
|
},
|
||||||
|
fail: (err) => {
|
||||||
|
console.error('小程序禁用分享失败:', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// 覆盖分享方法,返回空对象
|
||||||
|
onShareAppMessage(() => ({}));
|
||||||
|
onShareTimeline(() => ({}));
|
||||||
|
// #endif
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
wechatInit: wechatInit,
|
wechatInit: wechatInit,
|
||||||
setShare: setShare,
|
setShare: setShare,
|
||||||
onShareAppMessage: shareApp,
|
onShareAppMessage: shareApp,
|
||||||
onShareTimeline: shareTime,
|
onShareTimeline: shareTime,
|
||||||
|
disableShare: disableShare,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,41 +0,0 @@
|
|||||||
.account-info-wrap{
|
|
||||||
@apply bg-[#F5F6FA] min-h-[100vh];
|
|
||||||
.account-info-head{
|
|
||||||
@apply relative h-40;
|
|
||||||
.name{
|
|
||||||
@apply ml-4 pt-7 text-white text-lg mb-3;
|
|
||||||
}
|
|
||||||
.content{
|
|
||||||
@apply absolute bg-white left-3 right-3 rounded-lg p-5;
|
|
||||||
.money{
|
|
||||||
@apply text-xl font-bold;
|
|
||||||
}
|
|
||||||
.text{
|
|
||||||
@apply text-xs text-slate-500 mt-2;
|
|
||||||
}
|
|
||||||
.money-wrap{
|
|
||||||
@apply mt-5 flex;
|
|
||||||
.money-item{
|
|
||||||
@apply flex-1;
|
|
||||||
}
|
|
||||||
.money{
|
|
||||||
@apply text-lg;
|
|
||||||
}
|
|
||||||
.text{
|
|
||||||
@apply mt-1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.account-info-btn{
|
|
||||||
@apply flex mt-24 ml-3 mr-3;
|
|
||||||
.btn{
|
|
||||||
&:first-of-type{
|
|
||||||
@apply mr-1 rounded;
|
|
||||||
}
|
|
||||||
&:last-of-type{
|
|
||||||
@apply ml-1 rounded;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -552,4 +552,18 @@ button[type='primary'],uni-button[type='primary']{
|
|||||||
}
|
}
|
||||||
.information-filling .u-line{
|
.information-filling .u-line{
|
||||||
border-color: #dddddd !important;
|
border-color: #dddddd !important;
|
||||||
|
}
|
||||||
|
.order-grey-hollow-btn{
|
||||||
|
border: 2rpx solid #ccc;
|
||||||
|
height: 56rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
background-color: #fff;
|
||||||
|
border-radius: 30rpx;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0 24rpx;
|
||||||
|
min-width: 144rpx;
|
||||||
|
color: #333;
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,25 +0,0 @@
|
|||||||
page{
|
|
||||||
background-color: #f5f6fa;
|
|
||||||
@apply pt-4;
|
|
||||||
}
|
|
||||||
.member-record-detail{
|
|
||||||
@apply m-4 mt-0 bg-white rounded-md px-4 py-6;
|
|
||||||
.money-wrap{
|
|
||||||
@apply flex items-center flex-col mb-6;
|
|
||||||
text:first-of-type{
|
|
||||||
@apply text-3xl font-bold mt-1;
|
|
||||||
}
|
|
||||||
text:last-of-type{
|
|
||||||
@apply text-sm mt-3;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.line-wrap{
|
|
||||||
@apply flex justify-between text-sm mt-3;
|
|
||||||
.label{
|
|
||||||
@apply text-[#878787];
|
|
||||||
}
|
|
||||||
.value{
|
|
||||||
@apply text-[#222];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
.member-record-list{
|
|
||||||
@apply min-h-[100vh];
|
|
||||||
.member-record-item{
|
|
||||||
@apply relative sidebar-margin border-solid border-t-0 border-l-0 border-r-0 border-b-1 border-[#ECEBEC] py-3 mx-[var(--sidebar-m)];
|
|
||||||
.name{
|
|
||||||
@apply text-sm;
|
|
||||||
}
|
|
||||||
.desc{
|
|
||||||
@apply text-xs text-[#8D8C8D] mt-1;
|
|
||||||
}
|
|
||||||
.text-active{
|
|
||||||
color: #FF0D3E;
|
|
||||||
}
|
|
||||||
.money{
|
|
||||||
@apply absolute right-0 top-4 text-base font-bold;
|
|
||||||
}
|
|
||||||
.state{
|
|
||||||
@apply absolute right-0 top-11 text-[#8D8C8D] text-xs;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -401,53 +401,132 @@ export function timeStampTurnTime(timeStamp: any, type = "") {
|
|||||||
* @param dateStr
|
* @param dateStr
|
||||||
*/
|
*/
|
||||||
export function timeTurnTimeStamp(dateStr: string) {
|
export function timeTurnTimeStamp(dateStr: string) {
|
||||||
let timestamp;
|
// 输入验证
|
||||||
let date;
|
if (!dateStr || typeof dateStr !== 'string' || dateStr.trim() === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// 尝试解析 'YYYY年M月D日'
|
const trimmedDateStr = dateStr.trim();
|
||||||
try {
|
|
||||||
let dateStr1 = dateStr.replace('年', '-').replace('月', '-').replace('日', '');
|
// 定义支持的日期格式转换规则
|
||||||
date = new Date(dateStr1);
|
const formatRules = [
|
||||||
timestamp = date.getTime();
|
// 'YYYY年M月D日' -> 'YYYY-MM-DD'
|
||||||
} catch (e) {
|
{
|
||||||
// 尝试解析 'YYYY-MM-DD'
|
pattern: /(\d{4})年(\d{1,2})月(\d{1,2})日/,
|
||||||
try {
|
transform: (str: string) => str.replace(/(\d{4})年(\d{1,2})月(\d{1,2})日/, '$1-$2-$3')
|
||||||
date = new Date(dateStr);
|
},
|
||||||
timestamp = date.getTime();
|
// 'YYYY年M月D日 HH时mm分' -> 'YYYY-MM-DD HH:mm'
|
||||||
} catch (e) {
|
{
|
||||||
// 尝试解析 'YYYY/MM/DD'
|
pattern: /(\d{4})年(\d{1,2})月(\d{1,2})日\s+(\d{1,2})时(\d{1,2})分/,
|
||||||
try {
|
transform: (str: string) => str.replace(/(\d{4})年(\d{1,2})月(\d{1,2})日\s+(\d{1,2})时(\d{1,2})分/, '$1-$2-$3 $4:$5')
|
||||||
date = new Date(dateStr.replace(/\//g, "-"));
|
},
|
||||||
timestamp = date.getTime();
|
// 'YYYY/MM/DD' -> 'YYYY-MM-DD'
|
||||||
} catch (e) {
|
{
|
||||||
// 尝试解析 'YYYY年M月D日 HH时mm分'
|
pattern: /^\d{4}\/\d{1,2}\/\d{1,2}(\s+\d{1,2}:\d{1,2}(:\d{1,2})?)?$/,
|
||||||
try {
|
transform: (str: string) => str.replace(/\//g, '-')
|
||||||
let dateStr1 = dateStr.replace('年', '-').replace('月', '-').replace('日', ' ').replace('时', ':').replace('分', '');
|
},
|
||||||
date = new Date(dateStr1);
|
// 标准格式,无需转换
|
||||||
timestamp = date.getTime();
|
{
|
||||||
} catch (e) {
|
pattern: /^\d{4}-\d{1,2}-\d{1,2}(\s+\d{1,2}:\d{1,2}(:\d{1,2})?)?$/,
|
||||||
// 尝试解析 'YYYY-MM-DD HH:mm'
|
transform: (str: string) => str
|
||||||
try {
|
}
|
||||||
date = new Date(dateStr);
|
];
|
||||||
timestamp = date.getTime();
|
|
||||||
} catch (e) {
|
// 尝试匹配并转换格式
|
||||||
// 尝试解析 'YYYY/MM/DD HH:mm'
|
let normalizedDateStr = null;
|
||||||
try {
|
for (const rule of formatRules) {
|
||||||
date = new Date(dateStr.replace(/\//g, "-"));
|
if (rule.pattern.test(trimmedDateStr)) {
|
||||||
timestamp = date.getTime();
|
normalizedDateStr = rule.transform(trimmedDateStr);
|
||||||
} catch (e) {
|
break;
|
||||||
// 如果所有格式都失败,返回null
|
|
||||||
console.error("无法解析日期字符串:", dateStr);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return (timestamp / 1000);
|
|
||||||
|
// 如果没有匹配的格式,直接尝试原始字符串
|
||||||
|
if (!normalizedDateStr) {
|
||||||
|
normalizedDateStr = trimmedDateStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建日期对象并验证
|
||||||
|
const date = new Date(normalizedDateStr);
|
||||||
|
|
||||||
|
// 检查日期是否有效
|
||||||
|
if (isNaN(date.getTime())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回秒级时间戳
|
||||||
|
return Math.floor(date.getTime() / 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 日期格式转时间戳 (兼容 iOS)
|
||||||
|
* @param dateStr
|
||||||
|
*/
|
||||||
|
export function timeTurnTimeStampTwo(dateStr: string) {
|
||||||
|
if (!dateStr || typeof dateStr !== 'string' || dateStr.trim() === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let trimmedDateStr = dateStr.trim();
|
||||||
|
|
||||||
|
// 定义支持的日期格式转换规则
|
||||||
|
const formatRules = [
|
||||||
|
// 'YYYY年M月D日'
|
||||||
|
{
|
||||||
|
pattern: /(\d{4})年(\d{1,2})月(\d{1,2})日/,
|
||||||
|
transform: (str: string) =>
|
||||||
|
str.replace(/(\d{4})年(\d{1,2})月(\d{1,2})日/, '$1/$2/$3'),
|
||||||
|
},
|
||||||
|
// 'YYYY年M月D日 HH时mm分'
|
||||||
|
{
|
||||||
|
pattern: /(\d{4})年(\d{1,2})月(\d{1,2})日\s+(\d{1,2})时(\d{1,2})分/,
|
||||||
|
transform: (str: string) =>
|
||||||
|
str.replace(
|
||||||
|
/(\d{4})年(\d{1,2})月(\d{1,2})日\s+(\d{1,2})时(\d{1,2})分/,
|
||||||
|
'$1/$2/$3 $4:$5'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
// 'YYYY-MM-DD HH:mm:ss' -> 'YYYY/MM/DD HH:mm:ss' (iOS兼容)
|
||||||
|
{
|
||||||
|
pattern: /^\d{4}-\d{1,2}-\d{1,2}\s+\d{1,2}:\d{1,2}(:\d{1,2})?$/,
|
||||||
|
transform: (str: string) => str.replace(/-/g, '/'),
|
||||||
|
},
|
||||||
|
// 'YYYY-MM-DD' -> 'YYYY/MM/DD' (iOS兼容)
|
||||||
|
{
|
||||||
|
pattern: /^\d{4}-\d{1,2}-\d{1,2}$/,
|
||||||
|
transform: (str: string) => str.replace(/-/g, '/'),
|
||||||
|
},
|
||||||
|
// 'YYYY/MM/DD' / 'YYYY/MM/DD HH:mm:ss'
|
||||||
|
{
|
||||||
|
pattern: /^\d{4}\/\d{1,2}\/\d{1,2}(\s+\d{1,2}:\d{1,2}(:\d{1,2})?)?$/,
|
||||||
|
transform: (str: string) => str,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 尝试匹配并转换
|
||||||
|
let normalizedDateStr: string | null = null;
|
||||||
|
for (const rule of formatRules) {
|
||||||
|
if (rule.pattern.test(trimmedDateStr)) {
|
||||||
|
normalizedDateStr = rule.transform(trimmedDateStr);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!normalizedDateStr) {
|
||||||
|
normalizedDateStr = trimmedDateStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建日期对象
|
||||||
|
const date = new Date(normalizedDateStr);
|
||||||
|
|
||||||
|
if (isNaN(date.getTime())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.floor(date.getTime() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 复制
|
* 复制
|
||||||
* @param {Object} value
|
* @param {Object} value
|
||||||
|
|||||||
@ -97,6 +97,7 @@ const loadShare = () => {
|
|||||||
// 分享其它页面时,需要设置当前页面为白名单
|
// 分享其它页面时,需要设置当前页面为白名单
|
||||||
const shareWhiteList = [
|
const shareWhiteList = [
|
||||||
'addon/cms/pages/detail',
|
'addon/cms/pages/detail',
|
||||||
|
'addon/seckill/pages/goods/detail',
|
||||||
'addon/shop/pages/goods/detail',
|
'addon/shop/pages/goods/detail',
|
||||||
'addon/shop/pages/point/detail',
|
'addon/shop/pages/point/detail',
|
||||||
'addon/shop_fenxiao/pages/promote_code',
|
'addon/shop_fenxiao/pages/promote_code',
|
||||||
|
|||||||
@ -24,7 +24,7 @@ class Wechat {
|
|||||||
timestamp: data.timestamp, // 必填,生成签名的时间戳
|
timestamp: data.timestamp, // 必填,生成签名的时间戳
|
||||||
nonceStr: data.nonceStr, // 必填,生成签名的随机串
|
nonceStr: data.nonceStr, // 必填,生成签名的随机串
|
||||||
signature: data.signature,// 必填,签名
|
signature: data.signature,// 必填,签名
|
||||||
jsApiList: ['chooseWXPay', 'updateAppMessageShareData', 'updateTimelineShareData', 'scanQRCode', 'getLocation'] // 必填,需要使用的JS接口列表
|
jsApiList: ['chooseWXPay', 'updateAppMessageShareData', 'updateTimelineShareData', 'scanQRCode', 'getLocation','hideMenuItems'] // 必填,需要使用的JS接口列表
|
||||||
});
|
});
|
||||||
if (callback) callback();
|
if (callback) callback();
|
||||||
})
|
})
|
||||||
@ -138,6 +138,52 @@ class Wechat {
|
|||||||
})
|
})
|
||||||
// #endif
|
// #endif
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* 禁用分享(小程序和公众号)
|
||||||
|
*/
|
||||||
|
public disableShare() {
|
||||||
|
// 公众号(H5)禁用分享
|
||||||
|
// #ifdef H5
|
||||||
|
if (isWeixinBrowser()) {
|
||||||
|
wx.ready(() => {
|
||||||
|
// 先检查是否有权限
|
||||||
|
wx.checkJsApi({
|
||||||
|
jsApiList: ['hideMenuItems'],
|
||||||
|
success: (res) => {
|
||||||
|
// 若有权限,执行隐藏
|
||||||
|
if (res.checkResult.hideMenuItems) {
|
||||||
|
wx.hideMenuItems({
|
||||||
|
menuList: [
|
||||||
|
"menuItem:share:appMessage",
|
||||||
|
"menuItem:share:timeline",
|
||||||
|
"menuItem:share:qq",
|
||||||
|
"menuItem:share:QZone",
|
||||||
|
"menuItem:share:weiboApp",
|
||||||
|
"menuItem:favorite"
|
||||||
|
],
|
||||||
|
success: () => console.log("公众号分享已禁用"),
|
||||||
|
fail: (err) => console.error("隐藏菜单失败:", err)
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.warn("无hideMenuItems权限,无法禁用分享");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: (err) => console.error("检查权限失败:", err)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
|
||||||
|
// 小程序禁用分享
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
wx.hideShareMenu({
|
||||||
|
menus: ['shareAppMessage', 'shareTimeline'], // 隐藏转发给朋友、朋友圈
|
||||||
|
success: () => console.log("小程序分享已禁用"),
|
||||||
|
fail: (err) => console.error("小程序禁用分享失败:", err)
|
||||||
|
});
|
||||||
|
// #endif
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new Wechat()
|
export default new Wechat()
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user