hookehuyr

feat(ActivitiesCover): 添加活动分享功能及海报生成组件

- 新增分享按钮及分享弹窗组件
- 实现海报生成功能,支持保存至相册
- 添加分享选项包括微信分享和海报生成
- 优化图片下载工具函数,增加重试机制
- 更新babel配置支持TypeScript
- 添加相关SVG图标资源
......@@ -4,7 +4,7 @@ module.exports = {
presets: [
['taro', {
framework: 'vue3',
ts: false,
ts: true,
compiler: 'webpack5',
}]
]
......
......@@ -37,6 +37,7 @@
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.7.7",
"@nutui/icons-vue": "^0.1.1",
"@nutui/icons-vue-taro": "^0.0.9",
"@nutui/nutui-taro": "^4.3.13",
"@tarojs/components": "4.1.2",
......@@ -64,6 +65,7 @@
"@tarojs/cli": "4.1.2",
"@tarojs/taro-loader": "4.1.2",
"@tarojs/webpack5-runner": "4.1.2",
"@types/node": "^24.3.0",
"@types/webpack-env": "^1.13.6",
"@vue/babel-plugin-jsx": "^1.0.6",
"@vue/compiler-sfc": "^3.0.0",
......@@ -75,6 +77,7 @@
"postcss": "^8.5.6",
"style-loader": "1.3.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.9.2",
"unplugin-vue-components": "^0.26.0",
"vue-loader": "^17.0.0",
"weapp-tailwindcss": "^4.1.10",
......
This diff could not be displayed because it is too large.
<svg width="949" height="108" viewBox="0 0 949 108" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="949" height="2" y="53" fill="#E5E7EB"/>
<circle cx="100" cy="54" r="8" fill="#3B82F6"/>
<circle cx="200" cy="54" r="6" fill="#10B981"/>
<circle cx="300" cy="54" r="8" fill="#F59E0B"/>
<circle cx="400" cy="54" r="6" fill="#EF4444"/>
<circle cx="500" cy="54" r="8" fill="#8B5CF6"/>
<circle cx="600" cy="54" r="6" fill="#06B6D4"/>
<circle cx="700" cy="54" r="8" fill="#84CC16"/>
<circle cx="800" cy="54" r="6" fill="#F97316"/>
</svg>
\ No newline at end of file
<svg width="35" height="40" viewBox="0 0 35 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17.5 0C7.835 0 0 7.835 0 17.5C0 30.625 17.5 40 17.5 40S35 30.625 35 17.5C35 7.835 27.165 0 17.5 0Z" fill="#4CAF50"/>
<circle cx="17.5" cy="17.5" r="7" fill="#fff"/>
</svg>
\ No newline at end of file
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 16.08C17.24 16.08 16.56 16.38 16.04 16.85L8.91 12.7C8.96 12.47 9 12.24 9 12C9 11.76 8.96 11.53 8.91 11.3L15.96 7.19C16.5 7.69 17.21 8 18 8C19.66 8 21 6.66 21 5C21 3.34 19.66 2 18 2C16.34 2 15 3.34 15 5C15 5.24 15.04 5.47 15.09 5.7L8.04 9.81C7.5 9.31 6.79 9 6 9C4.34 9 3 10.34 3 12C3 13.66 4.34 15 6 15C6.79 15 7.5 14.69 8.04 14.19L15.16 18.34C15.11 18.55 15.08 18.77 15.08 19C15.08 20.61 16.39 21.92 18 21.92C19.61 21.92 20.92 20.61 20.92 19C20.92 17.39 19.61 16.08 18 16.08Z" fill="currentColor"/>
</svg>
\ No newline at end of file
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="20" cy="20" r="18" fill="#FF6B35" stroke="#fff" stroke-width="2"/>
<path d="M20 8v12l8 4" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
\ No newline at end of file
/* eslint-disable prefer-destructuring */
import Taro, { CanvasContext, CanvasGradient } from '@tarojs/taro';
import Taro from '@tarojs/taro'
declare const wx: any;
// 全局变量声明
// eslint-disable-next-line no-undef
const wx = globalThis.wx || {};
/**
* @description 生成随机字符串
......@@ -128,28 +130,70 @@ export const toRpx = (px, factor = getFactor()) =>
/**
* 下载图片资源
* @param { string } url
* @param { number } retryCount - 重试次数
* @returns { Promise }
*/
export function downImage(url) {
return new Promise<string>((resolve, reject) => {
export function downImage(url, retryCount = 2) {
return new Promise((resolve, reject) => {
// eslint-disable-next-line no-undef
if (/^http/.test(url) && !new RegExp(wx.env.USER_DATA_PATH).test(url)) {
// wx.env.USER_DATA_PATH 文件系统中的用户目录路径
const attemptDownload = (attempt = 0) => {
Taro.downloadFile({
url: mapHttpToHttps(url),
timeout: 10000, // 设置10秒超时
success: (res) => {
if (res.statusCode === 200) {
resolve(res.tempFilePath);
} else {
console.log('下载失败', res);
// console.log('下载失败', res);
if (attempt < retryCount) {
// console.log(`重试下载图片,第${attempt + 1}次重试`);
setTimeout(() => attemptDownload(attempt + 1), 1000);
} else {
reject(res);
}
}
},
fail(err) {
console.log('下载失败了', err);
// console.log('下载失败了', err);
// 如果是代理连接问题,尝试使用原始URL
if (err.errMsg && err.errMsg.includes('127.0.0.1:7890')) {
// console.log('检测到代理问题,尝试使用原始URL');
if (attempt < retryCount) {
setTimeout(() => {
Taro.downloadFile({
url: url, // 使用原始URL,不转换https
timeout: 10000,
success: (res) => {
if (res.statusCode === 200) {
resolve(res.tempFilePath);
} else {
reject(res);
}
},
fail: () => {
if (attempt + 1 < retryCount) {
attemptDownload(attempt + 1);
} else {
reject(err);
}
}
});
}, 1000);
} else {
reject(err);
}
} else if (attempt < retryCount) {
// console.log(`重试下载图片,第${attempt + 1}次重试`);
setTimeout(() => attemptDownload(attempt + 1), 1000);
} else {
reject(err);
}
}
});
};
attemptDownload();
} else {
resolve(url); // 支持本地地址
}
......@@ -191,7 +235,7 @@ export const getImageInfo = (item, index) =>
borderColor: item.borderColor,
borderRadiusGroup: item.borderRadiusGroup,
zIndex: typeof zIndex !== 'undefined' ? zIndex : index,
imgPath: url,
imgPath: imgPath, // 使用下载后的临时文件路径
sx,
sy,
sw: imgWidth - sx * 2,
......@@ -204,7 +248,7 @@ export const getImageInfo = (item, index) =>
resolve(result);
})
.catch((err) => {
console.log('读取图片信息失败', err);
// console.log('读取图片信息失败', err);
reject(err);
})
);
......@@ -222,7 +266,7 @@ export const getImageInfo = (item, index) =>
*/
// TODO: 待优化, 支持所有角度,多个颜色的线性渐变
export function getLinearColor(
ctx: CanvasContext,
ctx,
color,
startX,
startY,
......@@ -238,7 +282,7 @@ export function getLinearColor(
console.warn('坐标或者宽高只支持数字');
return color;
}
let grd: CanvasGradient | string = color;
let grd = color;
if (color.includes('linear-gradient')) {
// fillStyle 不支持线性渐变色
const colorList = color.match(/\((\d+)deg,\s(.+)\s\d+%,\s(.+)\s\d+%/);
......@@ -263,8 +307,8 @@ export function getLinearColor(
} else {
throw new Error('只支持0 <= 颜色弧度 <= 180');
}
(grd as CanvasGradient).addColorStop(0, color1);
(grd as CanvasGradient).addColorStop(1, color2);
grd.addColorStop(0, color1);
grd.addColorStop(1, color2);
}
return grd;
}
......
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 16.08C17.24 16.08 16.56 16.38 16.04 16.85L8.91 12.7C8.96 12.47 9 12.24 9 12C9 11.76 8.96 11.53 8.91 11.3L15.96 7.19C16.5 7.69 17.21 8 18 8C19.66 8 21 6.66 21 5C21 3.34 19.66 2 18 2C16.34 2 15 3.34 15 5C15 5.24 15.04 5.47 15.09 5.7L8.04 9.81C7.5 9.31 6.79 9 6 9C4.34 9 3 10.34 3 12C3 13.66 4.34 15 6 15C6.79 15 7.5 14.69 8.04 14.19L15.16 18.34C15.11 18.55 15.08 18.77 15.08 19C15.08 20.61 16.39 21.92 18 21.92C19.61 21.92 20.92 20.61 20.92 19C20.92 17.39 19.61 16.08 18 16.08Z" fill="#666666"/>
</svg>
\ No newline at end of file
<svg width="949" height="108" viewBox="0 0 949 108" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<pattern id="dots" patternUnits="userSpaceOnUse" width="20" height="20">
<circle cx="10" cy="10" r="2" fill="#E5E5E5"/>
</pattern>
</defs>
<rect width="949" height="108" fill="url(#dots)"/>
<line x1="0" y1="54" x2="949" y2="54" stroke="#CCCCCC" stroke-width="2" stroke-dasharray="10,5"/>
</svg>
\ No newline at end of file
......@@ -55,6 +55,27 @@
}
}
// 分享按钮
.share-button {
position: absolute;
top: 40rpx;
right: 40rpx;
width: 80rpx;
height: 80rpx;
background-color: rgba(0, 0, 0, 0.5);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 32rpx;
z-index: 10;
&:active {
background-color: rgba(0, 0, 0, 0.7);
}
}
// 详情区域
.details-section {
flex: 1;
......@@ -189,7 +210,112 @@
box-shadow: 0 4rpx 12rpx rgba(24, 144, 255, 0.3);
}
}
}
// 弹窗样式
.share-popup {
.nut-popup__content {
border-radius: 24rpx 24rpx 0 0;
padding: 40rpx;
}
}
.share-title {
font-size: 32rpx;
font-weight: bold;
text-align: center;
margin-bottom: 40rpx;
color: #333;
}
.share-options {
display: flex;
justify-content: space-around;
margin-bottom: 40rpx;
}
.share-option {
display: flex;
flex-direction: column;
align-items: center;
padding: 20rpx;
border-radius: 12rpx;
&:active {
background-color: #f5f5f5;
}
}
.share-icon {
width: 80rpx;
height: 80rpx;
margin-bottom: 16rpx;
border-radius: 12rpx;
background-color: #1890ff;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 36rpx;
}
.share-text {
font-size: 24rpx;
color: #666;
}
.cancel-button {
width: 100%;
height: 88rpx;
border-radius: 44rpx;
font-size: 32rpx;
background-color: #f5f5f5;
color: #666;
border: none;
}
// 海报预览弹窗
.poster-preview-popup {
.nut-popup__content {
width: 90%;
max-width: 600rpx;
border-radius: 24rpx;
padding: 40rpx;
background-color: white;
}
}
.poster-preview {
width: 100%;
border-radius: 12rpx;
margin-bottom: 40rpx;
}
.preview-actions {
display: flex;
gap: 20rpx;
}
.preview-button {
flex: 1;
height: 80rpx;
border-radius: 40rpx;
font-size: 28rpx;
&.primary {
background-color: #1890ff;
color: white;
border: none;
}
&.secondary {
background-color: #f5f5f5;
color: #666;
border: none;
}
}
.join-button {
&.nut-button--loading {
opacity: 0.7;
}
......
<!--
* @Date: 2022-09-19 14:11:06
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-08-28 15:16:04
* @LastEditTime: 2025-08-29 00:53:27
* @FilePath: /lls_program/src/pages/ActivitiesCover/index.vue
* @Description: 活动海报页面 - 展示活动信息并处理定位授权
-->
......@@ -21,6 +21,12 @@
<view class="activity-subtitle">{{ activityData.subtitle }}</view>
<view class="activity-date">{{ activityData.dateRange }}</view>
</view>
<!-- 分享按钮 -->
<view @tap="shareActivity" class="share-button">
<!-- <nut-icon name="share" color="white" size="20" /> -->
<text>分享</text>
</view>
</view>
<!-- 活动详情区域 -->
......@@ -73,6 +79,76 @@
<!-- 底部导航 -->
<BottomNav />
<!-- 分享选项弹窗 -->
<nut-popup
v-model:visible="show_share"
position="bottom"
:style="{ height: '40%' }"
class="share-popup"
>
<div class="share-title">立即分享给好友</div>
<div class="share-options">
<div
v-for="option in share_options"
:key="option.name"
class="share-option"
@click="onSelectShare({ detail: option })"
>
<div class="share-icon">{{ option.icon }}</div>
<div class="share-text">{{ option.name }}</div>
</div>
</div>
<nut-button class="cancel-button" @click="onCancelShare">
取消
</nut-button>
</nut-popup>
<!-- 海报预览弹窗 -->
<nut-popup
v-model:visible="show_post"
position="center"
class="poster-preview-popup"
>
<view class="wrapper">
<view class="preview-area" @click="onClickPost">
<image v-if="posterPath" :src="posterPath" mode="widthFix" />
</view>
</view>
</nut-popup>
<!-- 海报生成组件 -->
<PosterBuilder
v-if="startDraw"
custom-style="position: fixed; left: 200%;"
:config="base"
@success="drawSuccess"
@fail="drawFail"
/>
<!-- 保存选项弹窗 -->
<nut-popup
v-model:visible="show_save"
position="bottom"
:style="{ height: '30%' }"
class="share-popup"
>
<div class="share-title">保存选项</div>
<div class="share-options">
<div
v-for="option in actions_save"
:key="option.name"
class="share-option"
@click="onSelectSave({ detail: option })"
>
<div class="share-icon">💾</div>
<div class="share-text">{{ option.name }}</div>
</div>
</div>
<nut-button class="cancel-button" @click="onCancelSave">
取消
</nut-button>
</nut-popup>
</view>
</template>
......@@ -82,6 +158,8 @@ import { ref, onMounted } from "vue"
import Taro from '@tarojs/taro'
import "./index.less"
import BottomNav from '../../components/BottomNav.vue'
import PosterBuilder from '../../components/PosterBuilder/index.vue'
import icon_share from '@/images/icon/share.svg'
/**
* 活动海报页面组件
......@@ -93,6 +171,30 @@ const hasLocationAuth = ref(false) // 是否已授权定位
const isJoining = ref(false) // 是否正在加入活动
const userLocation = ref({ lng: null, lat: null }) // 用户位置信息
// 海报生成相关状态
const show_share = ref(false) // 显示分享弹窗
const show_post = ref(false) // 显示海报预览
const show_save = ref(false) // 显示保存弹窗
const startDraw = ref(false) // 开始绘制海报
const posterPath = ref('') // 海报路径
const nickname = ref('老来赛用户') // 用户昵称
const avatar = ref('https://cdn.ipadbiz.cn/icon/tou@2x.png') // 用户头像
// 分享选项
const share_options = [
{ name: '微信', icon: 'wechat', openType: 'share' },
{ name: '生成海报', icon: icon_share },
]
// 保存选项
const actions_save = ref([{
name: '保存至相册'
}])
// 海报配置
let base = {}
let qrcode_url = 'https://cdn.ipadbiz.cn/space/068a790496c87cb8d2ed6e551401c544.png' // Mock二维码
// Mock活动数据
const activityData = ref({
title: '南京路商圈时尚Citywalk',
......@@ -210,6 +312,329 @@ const handleJoinActivity = async () => {
}
}
/**
* 分享活动
*/
const shareActivity = () => {
show_share.value = true
}
/**
* 取消分享
*/
const onCancelShare = () => {
show_share.value = false
}
/**
* 选择分享方式
*/
const onSelectShare = ({ detail }) => {
show_share.value = false
if (detail.name === '生成海报') {
show_post.value = true
startGeneratePoster()
}
}
/**
* 点击海报预览
*/
const onClickPost = () => {
show_save.value = true
}
/**
* 取消保存
*/
const onCancelSave = () => {
show_save.value = false
show_post.value = false
}
/**
* 选择保存方式
*/
const onSelectSave = ({ detail }) => {
if (detail.name === '保存至相册') {
show_save.value = false
show_post.value = false
savePoster()
}
}
/**
* 开始生成海报
*/
const startGeneratePoster = async () => {
// 配置海报参数
base = {
width: 1024,
height: 1334,
backgroundColor: '',
debug: false,
blocks: [
{ // 上部分canvas画布高度
x: 40,
y: 20,
width: 950,
height: 950,
paddingLeft: 0,
paddingRight: 0,
borderWidth: 1,
borderColor: '#fff',
backgroundColor: '#fff',
borderRadiusGroup: [16, 16, 0, 0],
},
{ // 活动时间背景图
x: 40,
y: 730,
height: 75,
paddingLeft: 80,
paddingRight: 0,
borderWidth: 0,
text: {
x: 0,
y: 0,
text: activityData.value.dateRange,
fontSize: 40,
color: '#222',
opacity: 1,
baseLine: 'top',
lineHeight: 48,
lineNum: 2,
textAlign: 'left',
zIndex: 0,
},
backgroundColor: '#FFF9F3',
borderRadiusGroup: [0, 25, 25, 0],
},
{ // 活动地点背景图
x: 40,
y: 830,
height: 75,
paddingLeft: 80,
paddingRight: 0,
borderWidth: 0,
text: {
x: 0,
y: 0,
text: '上海市黄浦区南京东路',
fontSize: 40,
color: '#222',
opacity: 1,
baseLine: 'top',
lineHeight: 48,
lineNum: 2,
textAlign: 'left',
zIndex: 0,
},
backgroundColor: '#FFF9F3',
borderRadiusGroup: [0, 25, 25, 0],
},
{ // 下部分canvas画布高度
x: 40,
y: 1060,
width: 950,
height: 250,
paddingLeft: 0,
paddingRight: 0,
borderWidth: 1,
borderColor: '#fff',
backgroundColor: '#fff',
borderRadiusGroup: [0, 0, 16, 16],
}
],
texts: [
{
x: 80,
y: 630,
text: activityData.value.title,
fontSize: 50,
color: '#000',
opacity: 1,
baseLine: 'middle',
lineHeight: 60,
lineNum: 2,
textAlign: 'left',
width: 800,
zIndex: 999,
fontFamily: 'Monospace',
},
{
x: 135,
y: 770,
text: activityData.value.dateRange,
fontSize: 40,
color: '#222',
opacity: 1,
baseLine: 'middle',
lineHeight: 48,
lineNum: 2,
textAlign: 'left',
zIndex: 999,
},
{
x: 135,
y: 870,
text: '上海市黄浦区南京东路',
fontSize: 40,
color: '#222',
opacity: 1,
baseLine: 'middle',
lineHeight: 48,
lineNum: 2,
textAlign: 'left',
zIndex: 999,
},
{
x: 300,
y: 1150,
text: nickname.value,
fontSize: 50,
color: '#333',
opacity: 1,
baseLine: 'middle',
textAlign: 'left',
lineHeight: 50,
lineNum: 1,
zIndex: 999,
},
{
x: 300,
y: 1220,
text: '邀请你一起来活动!',
fontSize: 42,
color: '#8F9399',
opacity: 1,
baseLine: 'middle',
textAlign: 'left',
lineHeight: 42,
lineNum: 1,
zIndex: 999,
}
],
images: [
{
url: qrcode_url,
width: 949,
height: 108,
x: 40,
y: 960,
zIndex: 10,
},
{
url: qrcode_url,
width: 950,
height: 500,
x: 40,
y: 20,
borderRadiusGroup: [18, 18, 0, 0],
zIndex: 10,
},
{
url: qrcode_url,
width: 40,
height: 40,
x: 80,
y: 750,
borderRadius: 100,
borderWidth: 0,
zIndex: 10,
},
{
url: qrcode_url,
width: 35,
height: 40,
x: 80,
y: 850,
borderRadius: 100,
borderWidth: 0,
zIndex: 10,
},
{
url: qrcode_url,
width: 170,
height: 170,
x: 80,
y: 1090,
borderRadius: 100,
borderWidth: 0,
zIndex: 10,
},
{
url: qrcode_url,
width: 170,
height: 170,
x: 750,
y: 1090,
borderRadius: 100,
borderWidth: 0,
zIndex: 10,
},
],
lines: []
}
startDraw.value = true
if (!posterPath.value) Taro.showLoading({ title: '生成海报中...' })
}
/**
* 海报绘制成功回调
*/
const drawSuccess = (result) => {
console.log('绘制成功', result)
const { tempFilePath, errMsg } = result
if (errMsg === 'canvasToTempFilePath:ok') {
posterPath.value = tempFilePath
Taro.hideLoading()
} else {
Taro.hideLoading()
Taro.showToast({
title: '生成失败,请稍后重试',
icon: 'none',
duration: 2500
})
}
}
/**
* 海报绘制失败回调
*/
const drawFail = (result) => {
console.log('绘制失败', result)
Taro.hideLoading()
Taro.showToast({
title: '生成失败,请稍后重试',
icon: 'none',
duration: 2500
})
}
/**
* 保存海报到相册
*/
const savePoster = () => {
Taro.saveImageToPhotosAlbum({
filePath: posterPath.value,
success() {
Taro.showToast({
title: '已保存到相册',
icon: 'success',
duration: 2000
})
},
fail() {
Taro.showToast({
title: '保存失败',
icon: 'none',
duration: 2000
})
}
})
}
// 页面挂载时检查定位授权状态
onMounted(() => {
checkLocationAuth()
......
{
"compilerOptions": {
"target": "ES2018",
"lib": [
"ES6",
"ES2017",
"ES2018",
"ES2019"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": false,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"types": [
"node"
]
},
"include": [
"./src/**/*",
"./types/**/*"
],
"exclude": [
"node_modules",
"dist",
"**/*.js"
]
}
\ No newline at end of file