hookehuyr

feat(分享功能): 重构分享按钮为组件并添加打卡列表页面

- 将分享按钮提取为独立组件,支持活动分享和海报分享
- 添加打卡列表页面,展示已打卡地点信息
- 更新组件类型声明和路由配置
......@@ -27,6 +27,7 @@ declare module 'vue' {
PrimaryButton: typeof import('./src/components/PrimaryButton.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
ShareButton: typeof import('./src/components/ShareButton/index.vue')['default']
TabBar: typeof import('./src/components/TabBar.vue')['default']
TotalPointsDisplay: typeof import('./src/components/TotalPointsDisplay.vue')['default']
WeRunAuth: typeof import('./src/components/WeRunAuth.vue')['default']
......
......@@ -34,6 +34,7 @@ export default {
'pages/UploadMedia/index',
'pages/FamilyRank/index',
'pages/PosterCheckin/index',
'pages/CheckinList/index',
],
window: {
backgroundTextStyle: 'light',
......
<!--
* @Description: 分享按钮组件 - 点击后弹出分享选项
-->
<template>
<view class="share-button-container">
<!-- 分享按钮 -->
<view @tap="toggleShareOptions" class="share-button">
<text>分享</text>
</view>
<!-- 分享选项气泡弹窗 -->
<view v-if="showOptions" class="share-popover">
<view class="popover-arrow"></view>
<view class="popover-content">
<view @tap="handleShareActivity" class="popover-item">
<text>活动</text>
</view>
<view class="popover-divider"></view>
<view @tap="handleSharePoster" class="popover-item">
<text>海报</text>
</view>
</view>
</view>
<!-- 遮罩层 -->
<view v-if="showOptions" class="popover-mask" @tap="hideShareOptions"></view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import Taro from '@tarojs/taro'
// 组件属性
const props = defineProps({
// 活动数据
activityData: {
type: Object,
default: () => ({})
}
})
// 组件事件
const emit = defineEmits(['shareActivity', 'sharePoster'])
// 响应式数据
const showOptions = ref(false)
/**
* 切换分享选项显示状态
*/
const toggleShareOptions = () => {
showOptions.value = !showOptions.value
}
/**
* 隐藏分享选项
*/
const hideShareOptions = () => {
showOptions.value = false
}
/**
* 处理活动分享
*/
const handleShareActivity = () => {
hideShareOptions()
emit('shareActivity', props.activityData)
}
/**
* 处理海报分享
*/
const handleSharePoster = () => {
hideShareOptions()
emit('sharePoster', props.activityData)
}
</script>
<style lang="less">
.share-button-container {
position: fixed;
top: 40rpx;
right: 40rpx;
z-index: 1000;
}
.share-button {
color: white;
width: 80rpx;
height: 80rpx;
border-radius: 50%;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.2);
transition: all 0.3s ease;
backdrop-filter: blur(10rpx);
&:active {
transform: scale(0.95);
background: rgba(0, 0, 0, 0.8);
}
}
.share-icon {
font-size: 32rpx;
color: white;
}
// 气泡弹窗样式
.share-popover {
position: fixed;
top: 140rpx;
right: 40rpx;
z-index: 9999;
animation: popoverFadeIn 0.2s ease;
}
.popover-arrow {
position: absolute;
top: -12rpx;
right: 30rpx;
width: 0;
height: 0;
border-left: 12rpx solid transparent;
border-right: 12rpx solid transparent;
border-bottom: 12rpx solid white;
}
.popover-content {
background: white;
border-radius: 16rpx;
box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.15);
overflow: hidden;
min-width: 160rpx;
}
.popover-item {
padding: 24rpx 32rpx;
font-size: 28rpx;
color: #333;
text-align: center;
transition: background-color 0.2s ease;
&:active {
background-color: #f5f5f5;
}
}
.popover-divider {
height: 1rpx;
background-color: #f0f0f0;
margin: 0 16rpx;
}
.popover-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9998;
}
// 动画
@keyframes popoverFadeIn {
from {
opacity: 0;
transform: translateY(-10rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>
......@@ -29,26 +29,12 @@
z-index: 0;
}
// 分享按钮
.share-button {
// 分享按钮包装器
.share-button-wrapper {
position: absolute;
top: 40rpx;
right: 40rpx;
width: 80rpx;
height: 80rpx;
background-color: rgba(0, 0, 0, 0.6);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 28rpx;
z-index: 10;
backdrop-filter: blur(10rpx);
&:active {
background-color: rgba(0, 0, 0, 0.8);
}
}
// 底部区域
......
<!--
* @Date: 2022-09-19 14:11:06
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-09-03 13:31:52
* @LastEditTime: 2025-09-03 14:47:33
* @FilePath: /lls_program/src/pages/ActivitiesCover/index.vue
* @Description: 活动海报页面 - 展示活动信息并处理定位授权
-->
......@@ -14,10 +14,13 @@
mode="scaleToFill"
/>
<!-- 分享按钮 -->
<view @tap="shareActivity" class="share-button">
<text>分享</text>
</view>
<!-- 分享按钮组件 -->
<ShareButton
:activity-data="activityData"
@share-activity="onShareActivity"
@share-poster="onSharePoster"
class="share-button-wrapper"
/>
<!-- 底部按钮区域 -->
<view class="bottom-section">
......@@ -41,14 +44,7 @@
<!-- 底部导航 -->
<BottomNav />
<!-- 分享选项弹窗 -->
<nut-action-sheet
v-model:visible="show_share"
:menu-items="share_options"
@choose="onSelectShare"
@cancel="onCancelShare"
cancel-txt="取消"
/>
<!-- 海报预览弹窗 -->
<nut-popup
......@@ -90,6 +86,7 @@ import Taro from '@tarojs/taro'
import "./index.less"
import BottomNav from '../../components/BottomNav.vue'
import PosterBuilder from '../../components/PosterBuilder/index.vue'
import ShareButton from '../../components/ShareButton/index.vue'
// 接口信息
import { getMyFamiliesAPI } from '@/api/family'
......@@ -107,7 +104,6 @@ const userLocation = ref({ lng: null, lat: null }) // 用户位置信息
const hasJoinedFamily = ref(false);
// 海报生成相关状态
const show_share = ref(false) // 显示分享弹窗
const show_post = ref(false) // 显示海报预览
const show_save = ref(false) // 显示保存弹窗
const startDraw = ref(false) // 开始绘制海报
......@@ -115,11 +111,6 @@ const posterPath = ref('') // 海报路径
const nickname = ref('老来赛用户') // 用户昵称
const avatar = ref('https://cdn.ipadbiz.cn/icon/tou@2x.png') // 用户头像
// 分享选项
const share_options = [
{ name: '海报' },
]
// 保存选项
const actions_save = ref([{
name: '保存至相册'
......@@ -272,28 +263,22 @@ const handleJoinActivity = async () => {
}
/**
* 分享活动
*/
const shareActivity = () => {
show_share.value = true
}
/**
* 取消分享
* 处理分享活动事件
*/
const onCancelShare = () => {
show_share.value = false
const onShareActivity = () => {
console.log('分享活动海报')
show_post.value = true
startGeneratePoster()
}
/**
* 选择分享方式
* 处理分享海报事件
*/
const onSelectShare = (item) => {
show_share.value = false
if (item.name === '海报') {
show_post.value = true
startGeneratePoster()
}
const onSharePoster = () => {
console.log('分享海报')
Taro.navigateTo({
url: '/pages/CheckinList/index',
})
}
/**
......
/*
* @Date: 2025-09-03 14:53:06
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-09-03 14:53:18
* @FilePath: /lls_program/src/pages/CheckinList/index.config.js
* @Description: 文件描述
*/
export default {
navigationBarTitleText: '打卡列表',
usingComponents: {
},
}
<!--
* @Date: 2025-01-15 10:00:00
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-09-03 14:54:12
* @FilePath: /lls_program/src/pages/CheckinList/index.vue
* @Description: 打卡列表页面 - 显示已打卡的地点列表
-->
<template>
<view class="checkin-list-container">
<!-- 活动信息 -->
<!-- <view class="activity-info">
<view class="activity-title">{{ activityData.title }}</view>
<view class="activity-subtitle">{{ activityData.subtitle }}</view>
<view class="progress-info">
<text>已打卡 {{ checkedInCount }}/{{ totalLocations }} 个地点</text>
</view>
</view> -->
<!-- 打卡地点列表 -->
<view class="checkin-list">
<view
v-for="(location, index) in locationList"
:key="index"
class="checkin-item"
>
<view class="location-info">
<view class="location-name">{{ location.name }}</view>
<view class="location-address">{{ location.address }}</view>
<view class="checkin-time" v-if="location.checkedIn">
打卡时间:{{ location.checkinTime }}
</view>
</view>
<view class="action-button">
<nut-button
v-if="location.checkedIn && location.hasPhoto"
type="primary"
size="normal"
color="#4d96ea"
@click="viewPhoto(location)"
>
查看照片
</nut-button>
<nut-button
v-else-if="location.checkedIn && !location.hasPhoto"
type="warning"
size="normal"
@click="uploadPhoto(location)"
>
上传照片
</nut-button>
<nut-button
v-else
type="default"
size="normal"
disabled
>
未打卡
</nut-button>
</view>
</view>
</view>
<!-- 底部导航 -->
<BottomNav />
</view>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import Taro from '@tarojs/taro'
import BottomNav from '../../components/BottomNav.vue'
/**
* 打卡列表页面组件
* 功能:显示活动的打卡地点列表,支持查看照片和上传照片
*/
// Mock活动数据
const activityData = ref({
title: '南京路商圈时尚Citywalk',
subtitle: '探索城市魅力,感受时尚脉搏',
dateRange: '2024年1月15日 - 2024年1月31日'
})
// Mock打卡地点数据
const locationList = ref([
{
id: 1,
name: '外滩观景台',
address: '上海市黄浦区中山东一路',
checkedIn: true,
hasPhoto: true,
checkinTime: '2024-01-16 09:30',
photoUrl: 'https://img.yzcdn.cn/vant/cat.jpeg'
},
{
id: 2,
name: '南京路步行街',
address: '上海市黄浦区南京东路',
checkedIn: true,
hasPhoto: false,
checkinTime: '2024-01-16 10:45',
photoUrl: ''
},
{
id: 3,
name: '人民广场',
address: '上海市黄浦区人民大道',
checkedIn: true,
hasPhoto: true,
checkinTime: '2024-01-16 14:20',
photoUrl: 'https://img.yzcdn.cn/vant/cat.jpeg'
},
{
id: 4,
name: '豫园商城',
address: '上海市黄浦区方浜中路',
checkedIn: false,
hasPhoto: false,
checkinTime: '',
photoUrl: ''
},
{
id: 5,
name: '城隍庙',
address: '上海市黄浦区方浜中路',
checkedIn: false,
hasPhoto: false,
checkinTime: '',
photoUrl: ''
}
])
// 计算已打卡数量
const checkedInCount = computed(() => {
return locationList.value.filter(location => location.checkedIn).length
})
// 总地点数量
const totalLocations = computed(() => {
return locationList.value.length
})
/**
* 查看照片 - 跳转到海报打卡页面
*/
const viewPhoto = (location) => {
Taro.navigateTo({
url: `/pages/PosterCheckin/index?locationId=${location.id}&mode=view`
})
}
/**
* 上传照片 - 跳转到海报打卡页面
*/
const uploadPhoto = (location) => {
Taro.navigateTo({
url: `/pages/PosterCheckin/index?locationId=${location.id}&mode=upload`
})
}
/**
* 页面加载时初始化数据
*/
onMounted(() => {
console.log('打卡列表页面加载完成')
// 这里可以调用API获取真实的打卡数据
// loadCheckinData()
})
/**
* 加载打卡数据(预留接口)
*/
const loadCheckinData = async () => {
try {
// 调用API获取打卡数据
// const response = await getCheckinListAPI()
// locationList.value = response.data
console.log('加载打卡数据')
} catch (error) {
console.error('加载打卡数据失败:', error)
Taro.showToast({
title: '加载数据失败',
icon: 'error'
})
}
}
</script>
<style lang="less">
.checkin-list-container {
min-height: 100vh;
background-color: #f5f5f5;
padding-bottom: 120rpx;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx 32rpx;
background-color: #fff;
border-bottom: 1rpx solid #eee;
.back-button {
width: 60rpx;
height: 60rpx;
display: flex;
align-items: center;
justify-content: center;
.back-icon {
font-size: 36rpx;
color: #333;
}
}
.title {
font-size: 36rpx;
font-weight: 600;
color: #333;
}
.placeholder {
width: 60rpx;
}
}
.activity-info {
background-color: #fff;
padding: 32rpx;
margin-bottom: 20rpx;
.activity-title {
font-size: 36rpx;
font-weight: 600;
color: #333;
margin-bottom: 12rpx;
}
.activity-subtitle {
font-size: 28rpx;
color: #666;
margin-bottom: 20rpx;
}
.progress-info {
font-size: 26rpx;
color: #3B82F6;
background-color: #EBF4FF;
padding: 12rpx 20rpx;
border-radius: 20rpx;
display: inline-block;
}
}
.checkin-list {
.checkin-item {
background-color: #fff;
margin-bottom: 20rpx;
padding: 32rpx;
display: flex;
align-items: center;
justify-content: space-between;
.location-info {
flex: 1;
.location-name {
font-size: 32rpx;
font-weight: 600;
color: #333;
margin-bottom: 8rpx;
}
.location-address {
font-size: 26rpx;
color: #666;
margin-bottom: 8rpx;
}
.checkin-time {
font-size: 24rpx;
color: #999;
}
}
.action-button {
margin-left: 20rpx;
}
}
}
</style>