Files
spring-festival-greetings/pages/make/index.vue
2026-02-01 17:22:54 +08:00

1761 lines
45 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="top-steps">
<view class="step-bar">
<view
v-for="(tool, idx) in tools"
:key="idx"
class="step-item"
:class="{ active: activeTool === tool.type }"
@tap="openTool(tool.type)"
>
<view class="step-num-wrap">
<view class="step-line" v-if="idx > 0"></view>
<view class="step-num">
<text v-if="activeTool === tool.type && showPanel">{{ tool.icon }}</text>
<text v-else>{{ tool.step }}</text>
</view>
</view>
<text class="step-text">{{ tool.text }}</text>
</view>
</view>
</view>
<!-- 预览卡片 -->
<view class="card-preview">
<image
class="card-bg"
:src="currentTemplate?.imageUrl"
mode="aspectFill"
/>
<view class="card-overlay">
<view class="watermark">年禧集.马年春节祝福</view>
<!-- 选中的标题图片 -->
<image
v-if="currentTitle"
class="selected-title-img"
:src="currentTitle.imageUrl"
mode="widthFix"
:style="titleStyle"
@touchstart.stop="handleTitleTouchStart"
@touchmove.stop="handleTitleTouchMove"
@touchend.stop="handleTitleTouchEnd"
/>
<view
class="bubble"
@tap="openTool('text')"
:style="{
marginTop: 230 + bubbleOffsetY + 'rpx',
maxWidth: bubbleMaxWidth + 80 + 'rpx',
}"
>
<text
class="bubble-text"
:style="{
color: selectedColor,
fontFamily: selectedFont.family,
fontSize: fontSize + 'rpx',
lineHeight: fontSize * 1.5 + 'rpx',
}"
>{{ targetName + "\n " + blessingText.content }}</text
>
</view>
<view class="user" :style="{ left: 160 + userOffsetX + 'rpx', bottom: 40 - userOffsetY + 'rpx' }">
<image class="avatar" :src="userAvatar" mode="aspectFill" />
<view class="user-info">
<text class="user-name" :style="{ color: signatureColor }">{{
signatureName
}}</text>
<text class="user-desc" :style="{ color: signatureColor }"
>送上祝福</text
>
</view>
</view>
</view>
</view>
<view class="tip-line">
<text>分享或保存即可去除水印</text>
</view>
<!-- 底部固定按钮 -->
<view class="bottom-actions-fixed">
<view class="main-actions">
<button class="btn secondary" @tap="preview">
<uni-icons type="cloud-download" size="20" color="#888"></uni-icons>
<view>保存</view>
</button>
<button open-type="share" class="btn primary">
<uni-icons
type="paperplane-filled"
size="20"
color="#fff"
></uni-icons>
<view>分享给好友</view>
</button>
</view>
</view>
<!-- 弹出编辑面板 -->
<view class="panel-container" :class="{ show: showPanel }">
<view class="panel-mask" @tap="closePanel"></view>
<view class="panel-content" :class="{ 'glass-effect': activeTool === 'text' || activeTool === 'position' }">
<view class="panel-handle" @tap="closePanel"></view>
<!-- 标题选择区 -->
<view v-if="activeTool === 'title'" class="section">
<view class="section-title">
<text>选择标题</text>
</view>
<view class="tpl-scroll">
<view class="tpl-grid">
<view
v-for="(title, i) in titles"
:key="i"
class="tpl-card title-card"
:class="{ selected: title?.id === currentTitle?.id }"
@tap="selectTitle(title)"
>
<image :src="title.imageUrl" class="title-cover" mode="aspectFit" />
<view v-if="title?.id === currentTitle?.id" class="tpl-check"></view>
</view>
</view>
<view v-if="loadingTitles" class="loading-more">加载中...</view>
<view v-else-if="!hasMoreTitles && titles.length > 0" class="no-more">没有更多了</view>
</view>
<!-- 标题位置调整 -->
<view class="form-item" style="margin-top: 20rpx;">
<text class="label">标题位置 (上下)</text>
<slider
:value="titleOffsetY"
min="-100"
max="300"
show-value
@change="(e) => (titleOffsetY = e.detail.value)"
activeColor="#ff3b30"
/>
</view>
<view class="form-item">
<text class="label">标题位置 (左右)</text>
<slider
:value="titleOffsetX"
min="-200"
max="200"
show-value
@change="(e) => (titleOffsetX = e.detail.value)"
activeColor="#ff3b30"
/>
</view>
<view class="form-item">
<text class="label">标题缩放</text>
<slider
:value="titleScale * 100"
min="50"
max="150"
show-value
@change="(e) => (titleScale = e.detail.value / 100)"
activeColor="#ff3b30"
/>
</view>
</view>
<!-- 模板区 -->
<view v-if="activeTool === 'template'" class="section">
<view class="section-title">
<text>热门模板</text>
</view>
<view class="tpl-scroll">
<view class="tpl-grid">
<view
v-for="(tpl, i) in templates"
:key="i"
class="tpl-card"
:class="{ selected: tpl?.id === currentTemplate?.id }"
@tap="applyTemplate(tpl)"
>
<image :src="tpl.imageUrl" 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>
<view v-if="loadingTemplates" class="loading-more">加载中...</view>
<view
v-else-if="!hasMoreTemplates && templates.length > 0"
class="no-more"
>没有更多了</view
>
</view>
</view>
<!-- 文字编辑 -->
<view v-if="activeTool === 'text'" class="section text-edit-section">
<!-- 祝贺对象 -->
<view class="form-item">
<text class="label">祝福对象</text>
<input
class="input-box"
v-model="targetName"
placeholder="请输入称呼"
placeholder-style="color:#ccc"
maxlength="10"
/>
</view>
<!-- 祝福语库 -->
<view class="form-item">
<view class="label-row">
<text class="label">祝福语库</text>
<view class="refresh-btn" @tap="refreshGreetings">
<text class="refresh-icon"></text> 换一批
</view>
</view>
<scroll-view scroll-x class="greeting-scroll" show-scrollbar="false">
<view class="greeting-list">
<view
v-for="(text, index) in displayedGreetings"
:key="index"
class="greeting-card"
:class="{ active: blessingText.content === text.content }"
@tap="selectGreeting(text)"
>
<text class="greeting-text">{{ text.content }}</text>
<view
v-if="blessingText.content === text.content"
class="check-mark"
></view
>
</view>
</view>
</scroll-view>
</view>
<!-- 署名 -->
<view class="form-item">
<text class="label">署名</text>
<view class="input-wrapper">
<input
class="input-box"
v-model="signatureName"
placeholder="请输入署名"
placeholder-style="color:#ccc"
maxlength="5"
/>
<text class="edit-icon"></text>
</view>
</view>
<!-- 字体选择 -->
<view class="form-item">
<text class="label">字体样式</text>
<scroll-view scroll-x class="font-scroll" show-scrollbar="false">
<view class="font-list">
<view
v-for="(font, index) in fontList"
:key="index"
class="font-item"
:class="{ active: selectedFont.family === font.family }"
@tap="changeFont(font)"
>
<text :style="{ fontFamily: font.family }">{{
font.name
}}</text>
</view>
</view>
</scroll-view>
</view>
<!-- 字体大小 -->
<view class="form-item">
<text class="label">字体大小</text>
<slider
:value="fontSize"
min="24"
max="64"
show-value
@change="(e) => (fontSize = e.detail.value)"
activeColor="#ff3b30"
/>
</view>
<!-- 文字颜色 -->
<view class="form-item">
<text class="label">祝福语颜色</text>
<view class="color-list">
<view
v-for="(color, index) in textColors"
:key="index"
class="color-item"
:style="{ background: color }"
@tap="selectedColor = color"
>
<view v-if="selectedColor === color" class="color-check"></view>
</view>
</view>
</view>
</view>
<!-- 位置调整 -->
<view v-if="activeTool === 'position'" class="section position-section">
<view class="section-title">
<text>调整位置</text>
</view>
<view class="form-item" style="margin-top: 20rpx;">
<text class="label">祝福语气泡 (上下)</text>
<slider
:value="bubbleOffsetY"
min="-200"
max="400"
show-value
@change="(e) => (bubbleOffsetY = e.detail.value)"
activeColor="#ff3b30"
/>
</view>
<view class="form-item">
<text class="label">祝福语气泡宽度</text>
<slider
:value="bubbleMaxWidth"
min="200"
max="460"
show-value
@change="(e) => (bubbleMaxWidth = e.detail.value)"
activeColor="#ff3b30"
/>
</view>
<view class="form-item">
<text class="label">个人信息 (上下)</text>
<slider
:value="userOffsetY"
min="-100"
max="200"
show-value
@change="(e) => (userOffsetY = e.detail.value)"
activeColor="#ff3b30"
/>
</view>
<view class="form-item">
<text class="label">个人信息 (左右)</text>
<slider
:value="userOffsetX"
min="-100"
max="100"
show-value
@change="(e) => (userOffsetX = e.detail.value)"
activeColor="#ff3b30"
/>
</view>
<view class="form-item">
<text class="label">署名颜色</text>
<view class="color-list">
<view
v-for="(color, index) in textColors"
:key="index"
class="color-item"
:style="{ background: color }"
@tap="signatureColor = color"
>
<view v-if="signatureColor === color" class="color-check"></view>
</view>
</view>
</view>
</view>
</view>
</view>
<canvas
type="2d"
id="cardCanvas"
class="hidden-canvas"
style="width: 540px; height: 960px"
/>
<LoginPopup ref="loginPopupRef" @logind="handleLogind" />
</view>
</template>
<script setup>
import { ref, computed } from "vue";
import { getBavBarHeight, getDeviceInfo } from "@/utils/system";
import { generateObjectId } from "@/utils/common";
import {
createCardTmp,
updateCard,
getCardTemplateList,
getCardTemplateContentList,
getCardTemplateTitleList,
} from "@/api/make";
import { createShareToken, abilityCheck, getShareReward } from "@/api/system";
import {
onShareAppMessage,
onLoad,
onReachBottom,
onShow,
} from "@dcloudio/uni-app";
import { useUserStore } from "@/stores/user";
import LoginPopup from "@/components/LoginPopup/LoginPopup.vue";
import { saveRecordRequest, uploadImage } from "@/utils/common.js";
const userStore = useUserStore();
const loginPopupRef = ref(null);
const isLoggedIn = computed(() => !!userStore.userInfo.nickName);
const templatePage = ref(1);
const loadingTemplates = ref(false);
const hasMoreTemplates = ref(true);
const cardId = ref("");
// 标题相关
const titles = ref([]);
const currentTitle = ref(null);
const titlePage = ref(1);
const loadingTitles = ref(false);
const hasMoreTitles = ref(true);
const titleState = ref({
offsetX: 0,
offsetY: 0,
scale: 1,
});
const titleStyle = computed(() => {
return {
transform: `translate(${titleState.value.offsetX}rpx, ${titleState.value.offsetY}rpx) scale(${titleState.value.scale})`,
top: '40rpx',
pointerEvents: 'auto',
transition: 'none'
};
});
// 缓存比例转换
const sysInfo = uni.getSystemInfoSync();
const pxToRpx = 750 / sysInfo.windowWidth;
// 标题触摸交互相关
let startTouches = [];
let initialTitleState = {
offsetX: 0,
offsetY: 0,
scale: 1,
};
const getDistance = (p1, p2) => {
const x = p2.clientX - p1.clientX;
const y = p2.clientY - p1.clientY;
return Math.sqrt(x * x + y * y);
};
const handleTitleTouchStart = (e) => {
if (!currentTitle.value) return;
startTouches = e.touches;
initialTitleState = { ...titleState.value };
};
const handleTitleTouchEnd = () => {
startTouches = [];
};
const handleTitleTouchMove = (e) => {
if (!currentTitle.value || !startTouches.length) return;
if (e.touches.length === 1 && startTouches.length === 1) {
// 单指拖拽
const moveX = e.touches[0].clientX - startTouches[0].clientX;
const moveY = e.touches[0].clientY - startTouches[0].clientY;
titleState.value.offsetX = initialTitleState.offsetX + moveX * pxToRpx;
titleState.value.offsetY = initialTitleState.offsetY + moveY * pxToRpx;
} else if (e.touches.length === 2 && startTouches.length === 2) {
// 双指缩放+平移
const p1 = e.touches[0];
const p2 = e.touches[1];
const startP1 = startTouches[0];
const startP2 = startTouches[1];
// 缩放
const currentDist = getDistance(p1, p2);
const startDist = getDistance(startP1, startP2);
if (startDist > 0) {
const scale = initialTitleState.scale * (currentDist / startDist);
titleState.value.scale = Math.min(Math.max(scale, 0.2), 3.0);
}
// 平移
const currentCenterX = (p1.clientX + p2.clientX) / 2;
const currentCenterY = (p1.clientY + p2.clientY) / 2;
const startCenterX = (startP1.clientX + startP2.clientX) / 2;
const startCenterY = (startP1.clientY + startP2.clientY) / 2;
const moveX = currentCenterX - startCenterX;
const moveY = currentCenterY - startCenterY;
titleState.value.offsetX = initialTitleState.offsetX + moveX * pxToRpx;
titleState.value.offsetY = initialTitleState.offsetY + moveY * pxToRpx;
}
};
const targetName = ref("祝您");
const signatureName = ref(userStore?.userInfo?.nickName || "xxx");
const userAvatar = ref(
userStore?.userInfo?.avatarUrl ||
"https://file.lihailezzc.com/resource/b48c41054c2633c478463ac1b1f1ca23.png",
);
const blessingText = ref({});
const fontSize = ref(32);
const textColors = [
"#000000",
"#ffffff",
"#ff3b30",
"#F5A623",
"#8B572A",
"#D0021B",
"#F8E71C",
"#7ED321",
"#4A90E2",
"#9013FE",
"#FFC0CB",
];
const selectedColor = ref("#000000");
const signatureColor = ref("#000000");
const fontList = [
{ name: "默认", family: "PingFang SC", url: "" },
{
name: "思源宋体",
family: "SontTi SC",
url: "https://file.lihailezzc.com/font/ddcd9621740449a29c329f573bc1d0c6.woff2", // 示例地址
},
{
name: "思源黑体",
family: "HeiTi SC",
url: "https://file.lihailezzc.com/font/ddcd9621740449a29c329f573bc1d0c3.woff2", // 示例地址
},
{
name: "仿宋",
family: "FangSong",
url: "https://file.lihailezzc.com/ddcd9621740449a29c329f573bc1d0c4.woff2", // 示例地址
},
{
name: "中圆",
family: "ZhongYuan",
url: "https://file.lihailezzc.com/ddcd9621740449a29c329f573bc1d0c5.woff2", // 示例地址
}
];
const selectedFont = ref(fontList[0]);
const loadedFonts = ref(new Set()); // 记录已加载的字体
const changeFont = (font) => {
// 1. 如果是默认字体或已加载过的字体,直接应用
if (!font.url || loadedFonts.value.has(font.family)) {
selectedFont.value = font;
return;
}
// 2. 否则加载字体
uni.showLoading({ title: "加载字体中", mask: true });
uni.loadFontFace({
global: true,
family: font.family,
source: `url("${font.url}")`,
scopes: ["webview", "native"],
success: () => {
selectedFont.value = font;
loadedFonts.value.add(font.family); // 标记为已加载
uni.hideLoading();
},
fail: (err) => {
console.error(err);
uni.hideLoading();
// 如果加载失败,可以尝试直接设置(有些情况可能已经缓存或本地支持)
// 或者提示用户
uni.showToast({ title: "字体加载失败", icon: "none" });
},
});
};
const greetingLib = ref([]);
const greetingIndex = ref(0);
const bubbleOffsetY = ref(0);
const bubbleMaxWidth = ref(400); // 默认宽度
const userOffsetX = ref(0);
const userOffsetY = ref(0);
onLoad((options) => {
getTemplateList();
getTemplateContentList();
getTemplateTitleList();
});
onShow(() => {
const recommendData = uni.getStorageSync("RECOMMEND_CARD_DATA");
if (recommendData) {
uni.removeStorageSync("RECOMMEND_CARD_DATA");
if (recommendData.imageUrl) {
const tpl = {
id: recommendData.recommendId,
imageUrl: recommendData.imageUrl,
name: "推荐模板", // 暂时使用通用名称,如果需要可以从接口获取更多信息
};
// 切换到模板 Tab
activeTool.value = "template";
// 应用模板
currentTemplate.value = tpl;
// 如果模板列表中存在,更新引用
const found = templates.value.find((t) => t.id === tpl.id);
if (found) currentTemplate.value = found;
}
}
const tempBlessing = uni.getStorageSync("TEMP_BLESSING_TEXT");
if (tempBlessing) {
blessingText.value = { content: tempBlessing, id: "" };
uni.removeStorageSync("TEMP_BLESSING_TEXT");
activeTool.value = "text";
}
});
onReachBottom(() => {
if (activeTool.value === "template") {
loadMoreTemplates();
} else if (activeTool.value === "title") {
loadMoreTitles();
}
});
const handleLogind = async () => {};
const createCard = () => {
const id = generateObjectId();
createCardTmp({ id });
cardId.value = id;
return id;
};
const getTemplateList = async (isLoadMore = false) => {
if (loadingTemplates.value || (!hasMoreTemplates.value && isLoadMore)) return;
loadingTemplates.value = true;
try {
const res = await getCardTemplateList(templatePage.value);
// 兼容数组或对象列表格式
const list = Array.isArray(res) ? res : res.list || [];
if (list.length > 0) {
if (isLoadMore) {
templates.value = [...templates.value, ...list];
} else {
templates.value = list;
// 初始加载时设置第一个为当前选中
if (list.length > 0 && !currentTemplate.value) {
currentTemplate.value = list[0];
}
}
// 判断是否还有更多
if (typeof res.hasNext !== "undefined") {
hasMoreTemplates.value = res.hasNext;
} else {
// 如果没有 hasNext 字段,根据返回数量简单判断
hasMoreTemplates.value = list.length >= 8; // 假设每页 10 条
}
if (hasMoreTemplates.value) {
templatePage.value++;
}
} else {
if (!isLoadMore) templates.value = [];
hasMoreTemplates.value = false;
}
} catch (error) {
console.error("加载模板失败:", error);
} finally {
loadingTemplates.value = false;
}
};
const getTemplateTitleList = async (isLoadMore = false) => {
if (loadingTitles.value || (!hasMoreTitles.value && isLoadMore)) return;
loadingTitles.value = true;
try {
const res = await getCardTemplateTitleList(titlePage.value);
const list = Array.isArray(res) ? res : res.list || [];
if (list.length > 0) {
if (isLoadMore) {
titles.value = [...titles.value, ...list];
} else {
titles.value = list;
if (list.length > 0 && !currentTitle.value) {
currentTitle.value = list[0];
}
}
if (typeof res.hasNext !== "undefined") {
hasMoreTitles.value = res.hasNext;
} else {
hasMoreTitles.value = list.length >= 8;
}
if (hasMoreTitles.value) {
titlePage.value++;
}
} else {
if (!isLoadMore) titles.value = [];
hasMoreTitles.value = false;
}
} catch (error) {
console.error("加载标题失败:", error);
} finally {
loadingTitles.value = false;
}
};
const loadMoreTitles = () => {
getTemplateTitleList(true);
};
const getTemplateContentList = async () => {
const res = await getCardTemplateContentList();
if (res.length) {
greetingLib.value = res;
displayedGreetings.value = greetingLib.value.slice(0, 2);
if (!blessingText.value.content) {
blessingText.value = greetingLib.value[0] || {};
}
}
};
const loadMoreTemplates = () => {
getTemplateList(true);
};
onShareAppMessage(async () => {
getShareReward({ scene: "card_generate" });
if (!isLoggedIn.value) {
return {
title: "新春祝福",
path: "/pages/index/index",
};
}
// 1. 确保有 cardId (如果内容有变动,最好是新建)
const id = createCard();
if (!id) {
return {
title: "新春祝福",
path: "/pages/index/index",
};
}
const deviceInfo = getDeviceInfo();
const shareTokenRes = await createShareToken({
scene: "card_generate",
targetId: id,
...deviceInfo,
});
shareOrSave(id);
return {
title: "我刚做了一张祝福卡片,送给你",
path: "/pages/detail/index?shareToken=" + shareTokenRes.shareToken,
imageUrl: "/static/images/share.jpg",
};
});
const displayedGreetings = ref([]);
const refreshGreetings = () => {
if (!greetingLib.value.length) return;
const nextIndex = (greetingIndex.value + 2) % greetingLib.value.length;
greetingIndex.value = nextIndex;
let next = greetingLib.value.slice(nextIndex, nextIndex + 2);
if (next.length < 2) {
next = [...next, ...greetingLib.value.slice(0, 2 - next.length)];
}
displayedGreetings.value = next;
};
const selectGreeting = (text) => {
blessingText.value = text;
};
const tools = [
{ type: "template", text: "1. 选模板", icon: "🎨", step: 1 },
{ type: "title", text: "2. 选标题", icon: "🧧", step: 2 },
{ type: "text", text: "3. 改文字", icon: "✍️", step: 3 },
{ type: "position", text: "4. 调位置", icon: "🎯", step: 4 },
];
const activeTool = ref("template");
const showPanel = ref(false);
const openTool = (toolType) => {
activeTool.value = toolType;
showPanel.value = true;
};
const closePanel = () => {
showPanel.value = false;
};
const templates = ref([]);
const currentTemplate = ref(templates.value[0]);
const applyTemplate = (tpl) => {
currentTemplate.value = tpl;
closePanel();
};
const selectTitle = (title) => {
if (currentTitle.value?.id === title?.id) {
currentTitle.value = null;
} else {
currentTitle.value = title;
// 切换标题时重置位置和缩放
titleState.value = {
offsetX: 0,
offsetY: 0,
scale: 1,
};
closePanel();
}
};
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 = async () => {
if (!isLoggedIn.value) {
loginPopupRef.value.open();
return;
}
const abilityRes = await abilityCheck("card_generate");
if (!abilityRes.canUse) {
if (
abilityRes?.blockType === "need_share" &&
abilityRes?.message === "分享可继续"
) {
uni.showToast({
title: "分享给好友可继续使用",
icon: "none",
});
return;
}
uni.showToast({
title: "您今日祝福卡下载次数已用完,直接分享给好友或者明日再试",
icon: "none",
});
return;
}
const tempPath = await saveByCanvas(true);
const id = createCard();
shareOrSave(id);
saveRecordRequest(tempPath, id, "card_generate");
// uni.showToast({ title: '已保存到相册', icon: 'checkmarkempty' })
};
const shareOrSave = async (id) => {
if (!id) id = createCard();
const tempPath = await saveByCanvas(false);
const imageUrl = await uploadImage(tempPath);
updateCard({
id,
imageUrl,
status: 1,
blessingId: blessingText.value?.id || "",
blessingTo: targetName.value,
blessingFrom: signatureName.value,
templateId: currentTemplate.value?.id || "",
});
};
const showMore = () => {
uni.showToast({ title: "更多模板即将上线~", icon: "none" });
};
const saveByCanvas = async (save = true) => {
return new Promise((resolve, reject) => {
const query = uni.createSelectorQuery();
query
.select("#cardCanvas")
.fields({ node: true, size: true })
.exec(async (res) => {
if (!res[0] || !res[0].node) {
reject("Canvas not found");
return;
}
const canvas = res[0].node;
const ctx = canvas.getContext("2d");
// 初始化画布尺寸
const dpr = uni.getSystemInfoSync().pixelRatio;
// 保持 540x960 的逻辑尺寸,为了清晰度可以考虑 * dpr
// 但为了保持和原来一致的输出尺寸,这里先固定物理尺寸
// 如果要高清,可以 set width = 540 * dpr然后 scale(dpr, dpr)
// 这里为了简单兼容原逻辑,我们让物理尺寸等于逻辑尺寸
canvas.width = 540;
canvas.height = 960;
// 画布尺寸rpx 转 px
const W = 540;
const H = 960;
// 辅助函数:加载图片为 Image 对象
const loadCanvasImage = (url) => {
return new Promise((resolve, reject) => {
const img = canvas.createImage();
img.onload = () => resolve(img);
img.onerror = (e) => reject(e);
img.src = url;
});
};
// 辅助函数rpx 转 px (基于预览容器宽度 506rpx 对应 Canvas 540px)
const r2p = (rpx) => (rpx * 540) / 506;
try {
// 1⃣ 画背景
// ⭐ 先加载背景图
const [bgImg, avatarImg, titleImg] = await Promise.all([
loadCanvasImage(currentTemplate?.value?.imageUrl),
loadCanvasImage(userAvatar.value),
currentTitle.value ? loadCanvasImage(currentTitle.value.imageUrl) : Promise.resolve(null),
]);
ctx.drawImage(bgImg, 0, 0, W, H);
// 2⃣ 半透明遮罩
ctx.fillStyle = "rgba(0,0,0,0.08)";
ctx.fillRect(0, 0, W, H);
// 3⃣ 标题图片
if (titleImg) {
const previewBaseWidth = 400; // rpx
const drawWidth = r2p(previewBaseWidth) * titleState.value.scale;
const drawHeight = (titleImg.height / titleImg.width) * drawWidth;
// 计算绘制起点:居中 + 偏移量
const titleX = (W - drawWidth) / 2 + r2p(titleState.value.offsetX);
const titleY = r2p(40) + r2p(titleState.value.offsetY);
ctx.drawImage(titleImg, titleX, titleY, drawWidth, drawHeight);
}
// 4⃣ 祝福语气泡
// 预览中 .bubble 有 padding: 40rpx且 .card-overlay 有 padding: 30rpx
// 意味着文字距离容器边缘至少有 70rpx
drawBubbleText(ctx, {
text: targetName.value + "\n " + blessingText.value.content,
x: 0,
y: r2p(230 + bubbleOffsetY.value),
maxWidth: r2p(bubbleMaxWidth.value), // 预览中 bubble-text 的宽度
canvasWidth: W,
fontSize: r2p(fontSize.value),
lineHeight: r2p(fontSize.value * 1.6), // 预览中是 1.6
padding: r2p(40 + 30), // 内部 padding 40 + 容器 padding 30
backgroundColor: "transparent",
textColor: selectedColor.value,
fontFamily: selectedFont.value.family,
});
// 5⃣ 用户信息
// 预览中 user 是 absolute, left: 160 + offsetX, bottom: 40 - offsetY
drawUserBubble(ctx, {
x: r2p(160 + userOffsetX.value),
bottom: r2p(40 - userOffsetY.value),
canvasHeight: H,
avatarImg: avatarImg,
username: signatureName.value,
desc: "送上祝福",
textColor: signatureColor.value,
avatarSize: r2p(64),
padding: r2p(15),
fontSizeName: r2p(24),
fontSizeDesc: r2p(20),
});
// 6⃣ 输出
uni.canvasToTempFilePath({
canvas: canvas, // Canvas 2D 必须传 canvas 实例
width: W,
height: H,
destWidth: W,
destHeight: H,
success: (res) => {
if (save) saveImage(res.tempFilePath);
resolve(res.tempFilePath);
},
fail: (err) => reject(err),
});
} catch (error) {
console.error("Canvas draw error:", error);
reject(error);
}
});
});
};
const loadImage = (url) => {
// 此函数保留给其他可能用到的地方,但 saveByCanvas 内部使用 loadCanvasImage
return new Promise((resolve, reject) => {
uni.getImageInfo({
src: url,
success: (res) => {
resolve(res.path); // 本地路径
},
fail: (err) => {
reject(err);
},
});
});
};
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 = 24,
lineHeight = 42,
radius = 24,
backgroundColor = "rgba(255,255,255,0.9)",
textColor = "#fff",
fontSize = 32,
fontFamily = "PingFang SC",
canvasWidth, // 新增:画布宽度,用于居中计算
} = options;
if (!text) return;
ctx.fillStyle = textColor;
ctx.font = `${fontSize}px '${fontFamily}'`;
ctx.textAlign = "left";
ctx.textBaseline = "top";
// 1⃣ 文本自动换行
const paragraphs = text.split("\n");
const lines = [];
/** ② 每一段再自动换行 */
paragraphs.forEach((p) => {
let line = "";
for (let char of p) {
const testLine = line + char;
const { width } = ctx.measureText(testLine);
if (width > maxWidth) {
lines.push(line);
line = char;
} else {
line = testLine;
}
}
if (line) lines.push(line);
});
// 计算实际最大宽度以支持居中
let maxLineWidth = 0;
lines.forEach((line) => {
const { width } = ctx.measureText(line);
if (width > maxLineWidth) maxLineWidth = width;
});
// 如果提供了 canvasWidth则自动计算居中的 x 坐标
// 预览中文字是左对齐,但整个气泡容器是水平居中的
let drawX = x;
if (canvasWidth) {
drawX = (canvasWidth - maxLineWidth) / 2;
}
// 4⃣ 绘制文字
ctx.fillStyle = textColor;
lines.forEach((line, index) => {
ctx.fillText(line, drawX, y + padding + index * lineHeight);
});
}
function drawUserBubble(ctx, options) {
const {
x = 40, // 气泡起点 x
y, // 气泡起点 y (如果传了 bottom 则优先计算)
bottom, // 距离底部的距离
canvasHeight,
avatarImg, // CanvasImage 对象
username = "zzc",
desc = "送上祝福",
avatarSize = 64, // 头像直径
padding = 16, // 气泡内边距
fontSizeName = 24,
fontSizeDesc = 20,
bubbleColor = "rgba(255,255,255,0.18)",
textColor = "#000000",
} = options;
// 设置字体
ctx.textBaseline = "top";
ctx.font = `${fontSizeName}px 'PingFang SC'`;
// 测量文字宽度
const nameWidth = ctx.measureText(username).width;
ctx.font = `${fontSizeDesc}px 'PingFang SC'`;
const descWidth = ctx.measureText(desc).width;
// 计算气泡宽度和高度
const textWidth = Math.max(nameWidth, descWidth);
const bubbleHeight =
Math.max(avatarSize, fontSizeName + fontSizeDesc + 4) + padding * 2;
const bubbleWidth = avatarSize + padding + textWidth + padding * 2;
// 计算 y
let drawY = y;
if (typeof bottom !== "undefined" && canvasHeight) {
drawY = canvasHeight - bottom - bubbleHeight;
}
// 1⃣ 绘制气泡(左右半圆)
drawRoundRect(
ctx,
x,
drawY,
bubbleWidth,
bubbleHeight,
bubbleHeight / 2,
bubbleColor,
);
// 2⃣ 绘制头像
const avatarX = x + padding;
const avatarY = drawY + (bubbleHeight - avatarSize) / 2;
ctx.save();
ctx.beginPath();
ctx.arc(
avatarX + avatarSize / 2,
avatarY + avatarSize / 2,
avatarSize / 2,
0,
Math.PI * 2,
);
ctx.clip();
if (avatarImg) {
ctx.drawImage(avatarImg, avatarX, avatarY, avatarSize, avatarSize);
}
ctx.restore();
// 3⃣ 绘制文字
const textX = avatarX + avatarSize + padding;
const textY = drawY + padding;
ctx.fillStyle = textColor;
ctx.font = `${fontSizeName}px 'PingFang SC'`;
ctx.fillText(username, textX, textY);
ctx.font = `${fontSizeDesc}px 'PingFang SC'`;
ctx.globalAlpha = 0.6;
ctx.fillText(desc, textX, textY + fontSizeName + 4);
ctx.globalAlpha = 1;
}
function drawRoundRect(ctx, x, y, w, h, r, color) {
ctx.beginPath();
ctx.fillStyle = color || "rgba(255,255,255,0.18)";
ctx.strokeStyle = "rgba(255,255,255,0.35)";
ctx.lineWidth = 1;
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;
}
/* 顶部提示 */
.tip-line {
text-align: center;
color: #999;
font-size: 22rpx;
margin-bottom: 140rpx; /* 为底部固定按钮留出空间 */
}
/* 卡片预览 */
.card-preview {
margin: 30rpx auto 20rpx;
height: 900rpx;
width: 506rpx; /* 保持 9:16 比例并稍微缩小一点 */
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;
}
.watermark {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(-45deg);
font-size: 36rpx;
color: rgba(255, 255, 255, 0.2);
font-weight: bold;
pointer-events: none;
white-space: nowrap;
z-index: 1;
}
.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;
padding: 40rpx;
max-width: 500rpx;
}
.bubble-text {
font-size: 26rpx;
line-height: 1.6;
white-space: pre-wrap;
text-align: left;
}
.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;
}
/* 顶部步骤条 */
.top-steps {
background: #fff;
padding: 10rpx 0;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.05);
position: relative;
z-index: 10;
}
.step-bar {
display: flex;
justify-content: space-between;
padding: 10rpx 40rpx;
position: relative;
}
/* 底部固定按钮 */
.bottom-actions-fixed {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: #fff;
padding-bottom: env(safe-area-inset-bottom);
z-index: 90;
box-shadow: 0 -10rpx 30rpx rgba(0, 0, 0, 0.05);
}
/* 弹出编辑面板 */
.panel-container {
position: fixed;
inset: 0;
z-index: 100;
visibility: hidden;
pointer-events: none;
transition: all 0.3s;
}
.panel-container.show {
visibility: visible;
pointer-events: auto;
}
.panel-mask {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.4);
opacity: 0;
transition: opacity 0.3s;
}
.panel-container.show .panel-mask {
opacity: 1;
}
.panel-content {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: #fff;
border-radius: 40rpx 40rpx 0 0;
padding: 30rpx 40rpx calc(40rpx + env(safe-area-inset-bottom));
transform: translateY(100%);
transition: transform 0.3s cubic-bezier(0.25, 1, 0.5, 1);
max-height: 80vh;
overflow-y: auto;
}
.panel-container.show .panel-content {
transform: translateY(0);
}
.panel-content.glass-effect {
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(20rpx);
-webkit-backdrop-filter: blur(20rpx);
}
.panel-handle {
width: 80rpx;
height: 8rpx;
background: #ddd;
border-radius: 4rpx;
margin: 0 auto 30rpx;
}
.step-item {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
position: relative;
z-index: 1;
}
.step-num-wrap {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
position: relative;
margin-bottom: 12rpx;
}
.step-line {
position: absolute;
right: 50%;
top: 30rpx;
width: 100%;
height: 2rpx;
background: #eee;
z-index: -1;
}
.step-num {
width: 60rpx;
height: 60rpx;
border-radius: 50%;
background: #f5f5f5;
color: #999;
display: flex;
align-items: center;
justify-content: center;
font-size: 26rpx;
font-weight: bold;
border: 4rpx solid #fff;
transition: all 0.3s;
}
.step-item.active .step-num {
background: #ff3b30;
color: #fff;
transform: scale(1.1);
box-shadow: 0 4rpx 12rpx rgba(255, 59, 48, 0.3);
}
.step-text {
font-size: 24rpx;
color: #999;
font-weight: 500;
transition: all 0.3s;
}
.step-item.active .step-text {
color: #ff3b30;
font-weight: bold;
}
/* 模板区 */
.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-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16rpx;
padding-bottom: 20rpx;
}
.tpl-card {
width: 100%; /* 自适应 grid 宽度 */
border-radius: 12rpx;
overflow: hidden;
background: #fff;
position: relative;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.06);
}
.title-card {
height: 120rpx;
display: flex;
align-items: center;
justify-content: center;
padding: 10rpx;
}
.title-cover {
width: 100%;
height: 100%;
}
.selected-title-img {
position: absolute;
width: 400rpx;
z-index: 2;
/* 移除 transition防止拖拽抖动 */
transition: none;
}
.tpl-card.selected {
outline: 4rpx solid #ff3b30;
}
.tpl-cover {
width: 100%;
height: 368rpx;
}
.tpl-name {
font-size: 20rpx;
color: #333;
padding: 6rpx 8rpx;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tpl-check {
position: absolute;
right: 6rpx;
top: 6rpx;
width: 32rpx;
height: 32rpx;
border-radius: 50%;
background: #ff3b30;
color: #fff;
font-size: 20rpx;
display: flex;
align-items: center;
justify-content: center;
}
.loading-more,
.no-more {
text-align: center;
font-size: 22rpx;
color: #999;
padding: 10rpx 0;
}
/* 文字编辑区 */
.text-edit-section {
padding: 10rpx 24rpx 0;
}
.form-item {
margin-bottom: 32rpx;
}
.label {
font-size: 24rpx;
color: #333;
font-weight: 600;
margin-bottom: 16rpx;
display: block;
}
.input-box {
background: #f9f9f9;
border-radius: 12rpx;
padding: 20rpx 24rpx;
font-size: 28rpx;
color: #333;
}
.label-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16rpx;
}
.refresh-btn {
font-size: 22rpx;
color: #ff3b30;
display: flex;
align-items: center;
}
.refresh-icon {
margin-right: 6rpx;
font-size: 24rpx;
}
.greeting-scroll {
width: 100%;
}
.greeting-list {
display: flex;
padding-bottom: 10rpx;
}
.greeting-card {
flex-shrink: 0;
width: 320rpx;
height: 160rpx;
background: #fff;
border: 2rpx solid #eee;
border-radius: 16rpx;
padding: 20rpx;
margin-right: 20rpx;
position: relative;
box-sizing: border-box;
}
.greeting-card.active {
background: #fff5f5;
border-color: #ff3b30;
}
.greeting-text {
font-size: 24rpx;
color: #666;
line-height: 1.5;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 4;
overflow: hidden;
}
.position-section{
margin-bottom: 40rpx;
}
.greeting-card.active .greeting-text {
color: #ff3b30;
}
.check-mark {
position: absolute;
top: 10rpx;
right: 10rpx;
width: 32rpx;
height: 32rpx;
background: #ff3b30;
border-radius: 50%;
color: #fff;
font-size: 20rpx;
display: flex;
align-items: center;
justify-content: center;
}
.input-wrapper {
position: relative;
}
.edit-icon {
position: absolute;
right: 24rpx;
top: 50%;
transform: translateY(-50%);
color: #999;
}
.font-scroll {
white-space: nowrap;
width: 100%;
}
.font-list {
display: flex;
padding: 4rpx;
}
.font-item {
padding: 12rpx 24rpx;
background: #f5f5f5;
border-radius: 8rpx;
margin-right: 16rpx;
border: 2rpx solid transparent;
transition: all 0.3s;
}
.font-item.active {
background: #fff5f5;
border-color: #ff3b30;
color: #ff3b30;
}
.color-list {
display: flex;
gap: 20rpx;
flex-wrap: wrap;
}
.color-item {
width: 64rpx;
height: 64rpx;
border-radius: 50%;
position: relative;
border: 2rpx solid rgba(0, 0, 0, 0.05);
}
.color-check {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 32rpx;
text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.2);
}
/* 按钮区 */
.main-actions {
display: flex;
align-items: center;
justify-content: center;
padding: 24rpx 32rpx 32rpx;
background: #fff;
position: relative;
}
.btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
height: 96rpx;
border-radius: 48rpx;
margin: 0 12rpx;
font-size: 30rpx;
font-weight: 600;
transition: all 0.2s active;
position: relative;
overflow: hidden;
border: none;
}
.btn::after {
border: none;
}
.btn:active {
transform: scale(0.96);
opacity: 0.9;
}
.btn.secondary {
background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%);
color: #333;
box-shadow: 0 8rpx 20rpx rgba(0, 0, 0, 0.05),
inset 0 0 0 2rpx #eee;
}
.btn.primary {
background: linear-gradient(135deg, #ff6b66 0%, #ff3b30 100%);
color: #fff;
box-shadow: 0 12rpx 30rpx rgba(255, 59, 48, 0.3);
text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.1);
}
.btn view {
margin-left: 12rpx;
}
.hidden-canvas {
position: fixed;
left: -9999px;
top: -9999px;
}
</style>