Files
spring-festival-greetings/pages/make/index.vue
2026-01-09 11:24:40 +08:00

599 lines
14 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="make-page" :style="{ paddingTop: getBavBarHeight() + 'px' }">
<!-- 预览卡片 -->
<view class="card-preview">
<image class="card-bg" :src="currentTemplate.cover" mode="aspectFill" />
<view class="card-overlay">
<view class="title">
<text class="main">新春快乐</text>
<text class="sub">2026 YEAR OF THE HORSE</text>
</view>
<view class="bubble" @tap="editBlessing">
<text class="bubble-text">{{ blessingText }}</text>
</view>
<view class="user">
<image class="avatar" src="https://file.lihailezzc.com/resource/1463f294244c11cf274a5eaae115872a.jpeg" mode="aspectFill" />
<view class="user-info">
<text class="user-name">陈小明</text>
<text class="user-desc">送上祝福</text>
</view>
</view>
</view>
</view>
<view class="tip-line">
<text>点击卡片内容即可编辑</text>
</view>
<!-- 编辑工具区 -->
<view class="editor-panel">
<view class="drag-handle"></view>
<!-- 功能入口 -->
<view class="tools">
<view
v-for="(tool, idx) in tools"
:key="idx"
class="tool-item"
:class="{ active: activeTool === tool.type }"
@tap="activeTool = tool.type"
>
<view class="tool-icon">{{ tool.icon }}</view>
<text class="tool-text">{{ tool.text }}</text>
</view>
</view>
<!-- 模板区 -->
<view v-if="activeTool === 'template'" class="section">
<view class="section-title">
<text>热门模板</text>
<text class="more" @tap="showMore">查看更多 ></text>
</view>
<scroll-view scroll-x class="tpl-scroll" show-scrollbar="false">
<view class="tpl-wrap">
<view
v-for="(tpl, i) in templates"
:key="i"
class="tpl-card"
:class="{ selected: tpl.id === currentTemplate.id }"
@tap="applyTemplate(tpl)"
>
<image :src="tpl.cover" class="tpl-cover" mode="aspectFill" />
<view class="tpl-name">{{ tpl.name }}</view>
<view v-if="tpl.id === currentTemplate.id" class="tpl-check"></view>
</view>
</view>
</scroll-view>
</view>
<!-- 文字编辑 -->
<view v-if="activeTool === 'text'" class="section">
<view class="section-title"><text>编辑祝福语</text></view>
<textarea
class="text-area"
v-model="blessingText"
placeholder="请输入你的新春祝福语~"
:maxlength="100"
auto-height
show-confirm-bar="false"
/>
</view>
<!-- 图片/背景 -->
<view v-if="activeTool === 'image'" class="section">
<view class="section-title"><text>替换背景</text></view>
<view class="row">
<button class="btn" @tap="pickImage">从相册选择</button>
<button class="btn" @tap="resetBackground">重置为模板背景</button>
</view>
</view>
<!-- 头像挂饰 -->
<view v-if="activeTool === 'avatar'" class="section">
<view class="section-title"><text>头像挂饰</text></view>
<view class="row">
<button class="btn" @tap="toggleAvatarDecor">切换挂饰</button>
</view>
</view>
<!-- 底部操作 -->
<view class="bottom-actions">
<button class="btn secondary" @tap="preview">预览</button>
<button class="btn primary" @tap="shareOrSave">分享 / 保存</button>
</view>
</view>
<canvas
canvas-id="cardCanvas"
class="hidden-canvas"
style="width: 540px; height: 960px;"
/>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { getBavBarHeight } from '@/utils/system'
const blessingText = ref('祝您在新的一年里:\n身体健康万事如意\n马到成功财源广进\n一马当先前程似锦\n龙马精神阖家安康\n骏马奔腾福运常在\n策马扬鞭步步高升!')
const tools = [
{ type: 'template', text: '模板', icon: '▦' },
{ type: 'text', text: '文字', icon: '文' },
{ type: 'image', text: '图片/背景', icon: '图' },
{ type: 'avatar', text: '头像挂饰', icon: '饰' }
]
const activeTool = ref('template')
const templates = ref([
{ id: 1, name: '金典红金', cover: 'https://file.lihailezzc.com/20260109082842_666_1.jpg' },
{ id: 2, name: '富贵花开', cover: 'https://file.lihailezzc.com/20260108222141_644_1.jpg' },
{ id: 3, name: '大气字法', cover: 'https://file.lihailezzc.com/20260108222141_644_1.jpg' },
{ id: 4, name: '萌趣马年', cover: 'https://file.lihailezzc.com/20260109082842_666_1.jpg' }
])
const currentTemplate = ref(templates.value[0])
const applyTemplate = (tpl) => {
currentTemplate.value = tpl
}
const editBlessing = () => {
uni.showModal({
title: '编辑祝福语',
editable: true,
content: blessingText.value,
success: ({ confirm, content }) => {
if (confirm && content !== undefined) blessingText.value = content
}
})
}
const pickImage = () => {
uni.chooseImage({
count: 1,
success: (res) => {
const path = res.tempFilePaths?.[0]
if (path) currentTemplate.value = { ...currentTemplate.value, cover: path }
}
})
}
const resetBackground = () => {
currentTemplate.value = templates.value[0]
}
const toggleAvatarDecor = () => {
uni.showToast({ title: '挂饰功能即将上线~', icon: 'none' })
}
const preview = () => {
uni.showToast({ title: '预览生成中…', icon: 'none' })
}
const shareOrSave = () => {
saveByCanvas()
uni.showToast({ title: '已保存到相册并可分享', icon: 'none' })
}
const showMore = () => {
uni.showToast({ title: '更多模板即将上线~', icon: 'none' })
}
const saveByCanvas = async () => {
const ctx = uni.createCanvasContext('cardCanvas')
// 画布尺寸rpx 转 px
const W = 540
const H = 960
// 1⃣ 画背景
// ⭐ 先加载背景图
const bgPath = await loadImage(currentTemplate.value.cover)
const avatarPath = await loadImage('https://file.lihailezzc.com/resource/1463f294244c11cf274a5eaae115872a.jpeg')
console.log(111111, bgPath)
ctx.drawImage(
bgPath,
0,
0,
W,
H
)
// 2⃣ 半透明遮罩(和你 UI 一致)
ctx.setFillStyle('rgba(0,0,0,0.08)')
ctx.fillRect(0, 0, W, H)
// 3⃣ 标题
ctx.setFillStyle('#ffffff')
ctx.setFontSize(42)
ctx.setTextAlign('center')
ctx.fillText('新春快乐', W / 2, 120)
ctx.setFontSize(22)
ctx.setGlobalAlpha(0.9)
ctx.fillText('2026 YEAR OF THE HORSE', W / 2, 165)
ctx.setGlobalAlpha(1)
// 4⃣ 祝福语气泡
drawBubbleText(ctx, {
text: blessingText.value,
x: 70,
y: 260,
maxWidth: 400,
fontSize: 32,
lineHeight: 46,
backgroundColor: 'rgba(255,255,255,0.85)'
})
// 5⃣ 用户信息
ctx.save()
ctx.beginPath()
ctx.arc(80, H - 80, 32, 0, Math.PI * 2)
ctx.clip()
ctx.drawImage(
avatarPath,
48,
H - 112,
64,
64
)
ctx.restore()
ctx.setFillStyle('#ffffff')
ctx.setFontSize(24)
ctx.fillText('zzc', 150, H - 85)
ctx.setFontSize(20)
ctx.setGlobalAlpha(0.6)
ctx.fillText('送上祝福', 150, H - 55)
ctx.setGlobalAlpha(1)
// 6⃣ 输出
ctx.draw(false, () => {
uni.canvasToTempFilePath({
canvasId: 'cardCanvas',
success: res => {
saveImage(res.tempFilePath)
}
})
})
}
const loadImage = (url) => {
return new Promise((resolve, reject) => {
uni.getImageInfo({
src: url,
success: res => {
resolve(res.path) // 本地路径
},
fail: err => {
reject(err)
}
})
})
}
const getImageInfo = (url) => {
return new Promise((resolve, reject) => {
uni.getImageInfo({
src: url,
success: resolve,
fail: reject
})
})
}
const drawImageCover = (ctx, imgPath, canvasW, canvasH, imgW, imgH) => {
const scale = Math.max(canvasW / imgW, canvasH / imgH)
const drawW = imgW * scale
const drawH = imgH * scale
const dx = (canvasW - drawW) / 2
const dy = (canvasH - drawH) / 2
ctx.drawImage(imgPath, dx, dy, drawW, drawH)
}
const saveImage = (path) => {
uni.saveImageToPhotosAlbum({
filePath: path,
success() {
uni.showToast({ title: '已保存到相册' })
},
fail() {
uni.showModal({
title: '提示',
content: '请授权保存到相册'
})
}
})
}
function drawBubbleText(ctx, options) {
const {
text,
x,
y,
maxWidth = 400,
padding = 84,
lineHeight = 42,
radius = 24,
backgroundColor = 'rgba(255,255,255,0.9)',
textColor = '#fff',
fontSize = 32,
fontFamily = 'PingFang SC'
} = options
if (!text) return
ctx.setFontSize(fontSize)
ctx.setFillStyle(textColor)
ctx.font = `${fontSize}px ${fontFamily}`
// 1⃣ 文本自动换行
const lines = []
let currentLine = ''
for (let i = 0; i < text.length; i++) {
const testLine = currentLine + text[i]
const metrics = ctx.measureText(testLine)
if (metrics.width > maxWidth - padding * 2) {
lines.push(currentLine)
currentLine = text[i]
} else {
currentLine = testLine
}
}
lines.push(currentLine)
// 2⃣ 计算气泡尺寸
const bubbleWidth =
Math.min(
maxWidth,
Math.max(
...lines.map(line => ctx.measureText(line).width)
) + padding * 2
)
const bubbleHeight = lines.length * lineHeight + padding * 2
// 3⃣ 绘制气泡(圆角矩形)
drawRoundRect(
ctx,
x,
y,
bubbleWidth,
bubbleHeight,
radius,
backgroundColor
)
// 4⃣ 绘制文字
ctx.setFillStyle(textColor)
lines.forEach((line, index) => {
ctx.fillText(
line,
x + padding,
y + padding + (index + 1) * lineHeight - 10
)
})
}
function drawRoundRect(ctx, x, y, w, h, r, color) {
ctx.beginPath()
// ctx.setFillStyle(color)
ctx.fillStyle = 'rgba(255,255,255,0.18)'
ctx.fill()
// 描边(非常关键)
ctx.strokeStyle = 'rgba(255,255,255,0.35)'
ctx.lineWidth = 1
ctx.stroke()
ctx.moveTo(x + r, y)
ctx.lineTo(x + w - r, y)
ctx.arcTo(x + w, y, x + w, y + r, r)
ctx.lineTo(x + w, y + h - r)
ctx.arcTo(x + w, y + h, x + w - r, y + h, r)
ctx.lineTo(x + r, y + h)
ctx.arcTo(x, y + h, x, y + h - r, r)
ctx.lineTo(x, y + r)
ctx.arcTo(x, y, x + r, y, r)
ctx.closePath()
ctx.fill()
}
</script>
<style lang="scss" scoped>
.make-page {
min-height: 100vh;
background: #fff;
box-sizing: border-box;
}
/* 卡片预览 */
.card-preview {
margin: 24rpx auto;
height: 960rpx;
width: 540rpx;
border-radius: 30rpx;
overflow: hidden;
position: relative;
box-shadow: 0 16rpx 40rpx rgba(0,0,0,0.12);
}
.card-bg {
width: 100%;
height: 100%;
}
.card-overlay {
position: absolute;
inset: 0;
padding: 30rpx;
color: #fff;
display: flex;
flex-direction: column;
align-items: center;
}
.card-overlay .title {
display: flex;
flex-direction: column;
align-items: center;
margin-top: 60rpx;
}
.title .main {
font-size: 42rpx;
font-weight: 700;
}
.title .sub {
margin-top: 8rpx;
font-size: 22rpx;
opacity: 0.9;
}
.bubble {
margin-top: 80rpx;
background: rgba(255, 255, 255, 0.18);
border: 1rpx solid rgba(255, 255, 255, 0.35);
border-radius: 26rpx;
padding: 40rpx;
max-width: 560rpx;
backdrop-filter: blur(10rpx);
}
.bubble-text {
font-size: 26rpx;
line-height: 1.6;
}
.user {
position: absolute;
bottom: 40rpx;
display: flex;
align-items: center;
background: rgba(255, 255, 255, 0.18);
border: 1rpx solid rgba(255, 255, 255, 0.35);
border-radius: 9999rpx;
padding: 15rpx;
padding-left: 20rpx;
padding-right: 20rpx;
}
.avatar {
width: 64rpx;
height: 64rpx;
border-radius: 50%;
margin-right: 14rpx;
border: 2rpx solid rgba(255,255,255,0.6);
}
.user .user-info{
display: flex;
flex-direction: column;
}
.user-name { font-size: 24rpx; font-weight: 600; }
.user-desc { font-size: 20rpx; opacity: 0.6; }
/* 顶部提示 */
.tip-line {
text-align: center;
color: #999;
font-size: 22rpx;
}
/* 编辑工具区 */
.editor-panel {
margin: 20rpx 24rpx 40rpx;
border-radius: 30rpx 30rpx 0 0;
background: #fff;
box-shadow: 0 -10rpx 30rpx rgba(0,0,0,0.06);
padding-bottom: env(safe-area-inset-bottom);
}
.drag-handle {
width: 120rpx; height: 8rpx; border-radius: 999rpx;
background: #eee; margin: 12rpx auto;
}
/* 工具入口 */
.tools {
display: grid;
grid-template-columns: repeat(4, 1fr);
padding: 16rpx 24rpx 8rpx;
}
.tool-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 14rpx 0;
border-radius: 18rpx;
color: #666;
}
.tool-item.active {
background: #fff6f5;
color: #ff3b30;
}
.tool-icon {
width: 64rpx; height: 64rpx;
border-radius: 16rpx;
background: #fafafa;
display: flex; align-items: center; justify-content: center;
margin-bottom: 8rpx;
}
.tool-text { font-size: 22rpx; }
/* 模板区 */
.section { padding: 12rpx 24rpx 0; }
.section-title {
display: flex; align-items: center;
}
.section-title .more {
margin-left: auto;
color: #ff3b30;
font-size: 24rpx;
}
.tpl-scroll { margin-top: 12rpx; }
.tpl-wrap { display: flex; }
.tpl-card {
width: 180rpx; height: 240rpx;
border-radius: 18rpx; overflow: hidden;
background: #fff; margin-right: 16rpx;
position: relative;
box-shadow: 0 8rpx 20rpx rgba(0,0,0,0.06);
}
.tpl-card.selected { outline: 4rpx solid #ff3b30; }
.tpl-cover { width: 100%; height: 160rpx; }
.tpl-name {
font-size: 22rpx; color: #333;
padding: 8rpx 12rpx;
}
.tpl-check {
position: absolute; right: 10rpx; top: 10rpx;
width: 36rpx; height: 36rpx; border-radius: 50%;
background: #ff3b30; color: #fff; font-size: 22rpx;
display: flex; align-items: center; justify-content: center;
}
/* 按钮区 */
.bottom-actions {
display: flex; align-items: center; justify-content: space-between;
padding: 20rpx 24rpx 30rpx;
}
.btn {
height: 88rpx; border-radius: 999rpx; padding: 0 40rpx;
font-size: 28rpx;
}
.btn.secondary {
background: #f5f5f5; color: #333;
}
.btn.primary {
background: #ff3b30; color: #fff;
box-shadow: 0 12rpx 24rpx rgba(255,59,48,0.35);
}
.hidden-canvas {
position: fixed;
left: -9999px;
top: -9999px;
}
</style>