Files
spring-festival-greetings/pages/make/index.vue

1348 lines
33 KiB
Vue
Raw Normal View History

2026-01-09 11:24:40 +08:00
<template>
<view class="make-page" :style="{ paddingTop: getBavBarHeight() + 'px' }">
<!-- 预览卡片 -->
<view class="card-preview">
2026-01-21 23:58:21 +08:00
<image
class="card-bg"
:src="currentTemplate?.imageUrl"
mode="aspectFill"
/>
2026-01-09 11:24:40 +08:00
<view class="card-overlay">
<view class="title">
<text class="main">新春快乐</text>
<text class="sub">2026 YEAR OF THE HORSE</text>
</view>
2026-01-22 08:55:00 +08:00
<view
class="bubble"
@tap="activeTool = 'text'"
:style="{ marginTop: 80 + bubbleOffsetY + 'rpx' }"
>
2026-01-22 09:34:08 +08:00
<text
class="bubble-text"
:style="{
color: selectedColor,
fontFamily: selectedFont.family,
2026-01-22 15:08:26 +08:00
fontSize: fontSize + 'rpx',
lineHeight: fontSize * 1.5 + 'rpx',
2026-01-22 09:34:08 +08:00
}"
2026-01-22 22:11:02 +08:00
>{{ targetName + "\n " + blessingText.content }}</text
2026-01-22 09:34:08 +08:00
>
2026-01-09 11:24:40 +08:00
</view>
2026-01-31 11:25:53 +08:00
<view class="user" :style="{ left: 160 + userOffsetX + 'rpx', bottom: 40 - userOffsetY + 'rpx' }">
2026-01-15 08:43:10 +08:00
<image class="avatar" :src="userAvatar" mode="aspectFill" />
2026-01-09 11:24:40 +08:00
<view class="user-info">
2026-01-22 15:08:26 +08:00
<text class="user-name" :style="{ color: signatureColor }">{{
signatureName
}}</text>
<text class="user-desc" :style="{ color: signatureColor }"
>送上祝福</text
>
2026-01-09 11:24:40 +08:00
</view>
</view>
</view>
</view>
<view class="tip-line">
2026-01-31 11:25:53 +08:00
<text>分享或保存即可去除水印</text>
2026-01-09 11:24:40 +08:00
</view>
<!-- 编辑工具区 -->
<view class="editor-panel">
<view class="drag-handle"></view>
2026-01-21 23:58:21 +08:00
<!-- 底部操作 -->
<view class="bottom-actions">
<button class="btn secondary" @tap="preview">
<uni-icons type="cloud-download" size="20" color="#888"></uni-icons>
<view>保存</view>
</button>
2026-01-27 19:35:47 +08:00
<button open-type="share" class="btn primary">
2026-01-21 23:58:21 +08:00
<uni-icons
type="paperplane-filled"
size="20"
color="#fff"
></uni-icons>
<view>分享给好友</view>
</button>
</view>
2026-01-09 11:24:40 +08:00
<!-- 功能入口 -->
<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>
</view>
2026-01-22 00:01:30 +08:00
<view class="tpl-scroll">
2026-01-21 23:58:21 +08:00
<view class="tpl-grid">
2026-01-09 11:24:40 +08:00
<view
v-for="(tpl, i) in templates"
:key="i"
class="tpl-card"
2026-01-21 23:58:21 +08:00
:class="{ selected: tpl?.id === currentTemplate?.id }"
2026-01-09 11:24:40 +08:00
@tap="applyTemplate(tpl)"
>
2026-01-21 23:58:21 +08:00
<image :src="tpl.imageUrl" class="tpl-cover" mode="aspectFill" />
2026-01-09 11:24:40 +08:00
<view class="tpl-name">{{ tpl.name }}</view>
2026-01-21 23:58:21 +08:00
<view v-if="tpl?.id === currentTemplate?.id" class="tpl-check"
2026-01-15 08:43:10 +08:00
></view
>
2026-01-09 11:24:40 +08:00
</view>
</view>
2026-01-21 23:58:21 +08:00
<view v-if="loadingTemplates" class="loading-more">加载中...</view>
<view
v-else-if="!hasMoreTemplates && templates.length > 0"
class="no-more"
>没有更多了</view
>
2026-01-22 00:01:30 +08:00
</view>
2026-01-09 11:24:40 +08:00
</view>
<!-- 文字编辑 -->
2026-01-15 08:43:10 +08:00
<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"
2026-01-22 15:08:26 +08:00
maxlength="5"
2026-01-15 08:43:10 +08:00
/>
</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"
2026-01-22 22:11:02 +08:00
:class="{ active: blessingText === text.content }"
2026-01-15 08:43:10 +08:00
@tap="selectGreeting(text)"
>
2026-01-22 22:11:02 +08:00
<text class="greeting-text">{{ text.content }}</text>
<view
v-if="blessingText.content === text.content"
class="check-mark"
></view
>
2026-01-15 08:43:10 +08:00
</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"
2026-01-22 15:08:26 +08:00
maxlength="5"
2026-01-15 08:43:10 +08:00
/>
<text class="edit-icon"></text>
</view>
</view>
2026-01-22 09:34:08 +08:00
<!-- 字体选择 -->
<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>
2026-01-22 15:08:26 +08:00
<!-- 字体大小 -->
<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>
2026-01-15 08:43:10 +08:00
<!-- 文字颜色 -->
<view class="form-item">
2026-01-22 15:08:26 +08:00
<text class="label">祝福语颜色</text>
2026-01-15 08:43:10 +08:00
<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>
2026-01-22 15:08:26 +08:00
<!-- 署名颜色 -->
<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>
2026-01-09 11:24:40 +08:00
</view>
2026-01-22 08:55:00 +08:00
<!-- 位置调整 -->
<view v-if="activeTool === 'position'" class="section">
<view class="form-item">
<text class="label">祝福语位置 (上下)</text>
<slider
:value="bubbleOffsetY"
min="-200"
max="200"
show-value
@change="(e) => (bubbleOffsetY = e.detail.value)"
activeColor="#ff3b30"
/>
</view>
<view class="form-item">
<text class="label">署名位置 (左右)</text>
<slider
:value="userOffsetX"
min="-200"
max="200"
show-value
@change="(e) => (userOffsetX = e.detail.value)"
activeColor="#ff3b30"
/>
2026-01-09 11:24:40 +08:00
</view>
2026-01-31 11:25:53 +08:00
<view class="form-item">
<text class="label">署名位置 (上下)</text>
<slider
:value="userOffsetY"
min="-200"
max="200"
show-value
@change="(e) => (userOffsetY = e.detail.value)"
activeColor="#ff3b30"
/>
</view>
2026-01-09 11:24:40 +08:00
</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>
<canvas
2026-01-22 11:04:47 +08:00
type="2d"
id="cardCanvas"
2026-01-15 08:43:10 +08:00
class="hidden-canvas"
style="width: 540px; height: 960px"
/>
2026-01-22 22:11:02 +08:00
<LoginPopup ref="loginPopupRef" @logind="handleLogind" />
2026-01-09 11:24:40 +08:00
</view>
</template>
<script setup>
2026-01-22 22:11:02 +08:00
import { ref, computed } from "vue";
2026-01-15 08:43:10 +08:00
import { getBavBarHeight, getDeviceInfo } from "@/utils/system";
2026-01-22 22:11:02 +08:00
import { generateObjectId } from "@/utils/common";
2026-01-22 08:28:34 +08:00
import {
createCardTmp,
2026-01-22 22:11:02 +08:00
updateCard,
2026-01-22 08:28:34 +08:00
getCardTemplateList,
getCardTemplateContentList,
} from "@/api/make";
2026-01-27 18:18:04 +08:00
import { createShareToken, abilityCheck, getShareReward } from "@/api/system";
2026-01-22 23:54:56 +08:00
import {
onShareAppMessage,
onLoad,
onReachBottom,
onShow,
} from "@dcloudio/uni-app";
2026-01-15 08:43:10 +08:00
import { useUserStore } from "@/stores/user";
2026-01-22 22:11:02 +08:00
import LoginPopup from "@/components/LoginPopup/LoginPopup.vue";
2026-01-27 18:18:04 +08:00
import { saveRecordRequest, uploadImage } from "@/utils/common.js";
2026-01-22 22:11:02 +08:00
const userStore = useUserStore();
const loginPopupRef = ref(null);
const isLoggedIn = computed(() => !!userStore.userInfo.nickName);
2026-01-15 08:43:10 +08:00
2026-01-21 23:58:21 +08:00
const templatePage = ref(1);
const loadingTemplates = ref(false);
const hasMoreTemplates = ref(true);
2026-01-15 08:43:10 +08:00
const cardId = ref("");
const targetName = ref("祝您");
const signatureName = ref(userStore?.userInfo?.nickName || "xxx");
const userAvatar = ref(
userStore?.userInfo?.avatarUrl ||
2026-01-21 23:58:21 +08:00
"https://file.lihailezzc.com/resource/b48c41054c2633c478463ac1b1f1ca23.png",
2026-01-15 08:43:10 +08:00
);
2026-01-22 22:11:02 +08:00
const blessingText = ref({});
2026-01-22 15:08:26 +08:00
const fontSize = ref(32);
const textColors = [
"#ffffff",
"#000000",
"#ff3b30",
"#F5A623",
"#8B572A",
"#D0021B",
"#F8E71C",
"#7ED321",
"#4A90E2",
"#9013FE",
"#FFC0CB",
];
2026-01-15 08:43:10 +08:00
const selectedColor = ref("#ffffff");
2026-01-22 15:08:26 +08:00
const signatureColor = ref("#ffffff");
2026-01-15 08:43:10 +08:00
2026-01-22 09:34:08 +08:00
const fontList = [
{ name: "默认", family: "PingFang SC", url: "" },
{
name: "毛笔",
family: "MaoBi",
url: "https://file.lihailezzc.com/MaShanZheng-Regular.ttf", // 示例地址
},
{
name: "手写",
family: "ShouXie",
2026-01-22 11:04:47 +08:00
url: "https://file.lihailezzc.com/ZhiMangXing-Regular.ttf", // 示例地址
2026-01-22 09:34:08 +08:00
},
{
name: "可爱",
family: "KeAi",
2026-01-22 11:04:47 +08:00
url: "https://file.lihailezzc.com/ZCOOLKuaiLe-Regular.ttf", // 示例地址
},
{
name: "草书",
family: "LiuJianMaoCao",
url: "https://file.lihailezzc.com/LiuJianMaoCao-Regular.ttf", // 示例地址
2026-01-22 09:34:08 +08:00
},
];
const selectedFont = ref(fontList[0]);
2026-01-22 14:56:32 +08:00
const loadedFonts = ref(new Set()); // 记录已加载的字体
2026-01-22 09:34:08 +08:00
const changeFont = (font) => {
2026-01-22 14:56:32 +08:00
// 1. 如果是默认字体或已加载过的字体,直接应用
if (!font.url || loadedFonts.value.has(font.family)) {
2026-01-22 09:34:08 +08:00
selectedFont.value = font;
2026-01-22 14:56:32 +08:00
return;
2026-01-22 09:34:08 +08:00
}
2026-01-22 14:56:32 +08:00
// 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" });
},
});
2026-01-22 09:34:08 +08:00
};
2026-01-22 08:40:37 +08:00
const greetingLib = ref([]);
const greetingIndex = ref(0);
2026-01-15 08:43:10 +08:00
2026-01-22 08:55:00 +08:00
const bubbleOffsetY = ref(0);
const userOffsetX = ref(0);
2026-01-31 11:25:53 +08:00
const userOffsetY = ref(0);
2026-01-22 08:55:00 +08:00
2026-01-15 08:43:10 +08:00
onLoad((options) => {
2026-01-21 23:58:21 +08:00
getTemplateList();
2026-01-22 08:28:34 +08:00
getTemplateContentList();
2026-01-15 08:43:10 +08:00
});
2026-01-22 23:54:56 +08:00
onShow(() => {
2026-01-28 15:55:26 +08:00
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;
}
}
2026-01-22 23:54:56 +08:00
const tempBlessing = uni.getStorageSync("TEMP_BLESSING_TEXT");
if (tempBlessing) {
blessingText.value = { content: tempBlessing, id: "" };
uni.removeStorageSync("TEMP_BLESSING_TEXT");
2026-01-28 15:55:26 +08:00
activeTool.value = "text";
2026-01-22 23:54:56 +08:00
}
});
2026-01-22 00:01:30 +08:00
onReachBottom(() => {
if (activeTool.value === "template") {
loadMoreTemplates();
}
});
2026-01-29 17:07:39 +08:00
const handleLogind = async () => {};
2026-01-22 22:11:02 +08:00
const createCard = () => {
const id = generateObjectId();
createCardTmp({ id });
cardId.value = id;
return id;
2026-01-15 08:43:10 +08:00
};
2026-01-21 23:58:21 +08:00
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;
}
};
2026-01-22 08:28:34 +08:00
const getTemplateContentList = async () => {
const res = await getCardTemplateContentList();
if (res.length) {
2026-01-22 22:11:02 +08:00
greetingLib.value = res;
2026-01-22 08:28:34 +08:00
displayedGreetings.value = greetingLib.value.slice(0, 2);
2026-01-22 23:54:56 +08:00
if (!blessingText.value.content) {
blessingText.value = greetingLib.value[0] || {};
}
2026-01-22 08:28:34 +08:00
}
};
2026-01-21 23:58:21 +08:00
const loadMoreTemplates = () => {
getTemplateList(true);
};
2026-01-15 08:43:10 +08:00
onShareAppMessage(async () => {
2026-01-27 18:18:04 +08:00
getShareReward({ scene: "card_generate" });
2026-01-22 22:11:02 +08:00
if (!isLoggedIn.value) {
return {
title: "新春祝福",
path: "/pages/index/index",
};
}
// 1. 确保有 cardId (如果内容有变动,最好是新建)
const id = createCard();
if (!id) {
return {
title: "新春祝福",
path: "/pages/index/index",
};
}
2026-01-15 08:43:10 +08:00
const deviceInfo = getDeviceInfo();
2026-01-22 22:11:02 +08:00
const shareTokenRes = await createShareToken({
scene: "card_generate",
targetId: id,
2026-01-15 08:43:10 +08:00
...deviceInfo,
});
2026-01-27 18:18:04 +08:00
shareOrSave(id);
2026-01-15 08:43:10 +08:00
return {
title: "新春祝福",
path: "/pages/detail/index?shareToken=" + shareTokenRes.shareToken,
2026-01-22 22:11:02 +08:00
imageUrl: currentTemplate.value?.imageUrl || "/static/images/bg.jpg",
2026-01-15 08:43:10 +08:00
};
});
2026-01-22 08:28:34 +08:00
const displayedGreetings = ref([]);
2026-01-15 08:43:10 +08:00
const refreshGreetings = () => {
2026-01-22 08:40:37 +08:00
if (!greetingLib.value.length) return;
const nextIndex = (greetingIndex.value + 2) % greetingLib.value.length;
greetingIndex.value = nextIndex;
let next = greetingLib.value.slice(nextIndex, nextIndex + 2);
2026-01-15 08:43:10 +08:00
if (next.length < 2) {
2026-01-22 08:40:37 +08:00
next = [...next, ...greetingLib.value.slice(0, 2 - next.length)];
2026-01-15 08:43:10 +08:00
}
displayedGreetings.value = next;
};
2026-01-09 11:24:40 +08:00
2026-01-15 08:43:10 +08:00
const selectGreeting = (text) => {
blessingText.value = text;
};
2026-01-09 11:24:40 +08:00
const tools = [
2026-01-15 08:43:10 +08:00
{ type: "template", text: "模板", icon: "▦" },
{ type: "text", text: "文字", icon: "文" },
2026-01-22 08:55:00 +08:00
{ type: "position", text: "位置", icon: "图" },
2026-01-21 23:58:21 +08:00
// { type: "avatar", text: "头像挂饰", icon: "饰" },
2026-01-15 08:43:10 +08:00
];
const activeTool = ref("template");
2026-01-09 11:24:40 +08:00
2026-01-21 23:58:21 +08:00
const templates = ref([]);
2026-01-15 08:43:10 +08:00
const currentTemplate = ref(templates.value[0]);
2026-01-09 11:24:40 +08:00
const applyTemplate = (tpl) => {
2026-01-15 08:43:10 +08:00
currentTemplate.value = tpl;
};
2026-01-09 11:24:40 +08:00
const pickImage = () => {
uni.chooseImage({
count: 1,
success: (res) => {
2026-01-15 08:43:10 +08:00
const path = res.tempFilePaths?.[0];
if (path)
currentTemplate.value = { ...currentTemplate.value, cover: path };
},
});
};
2026-01-09 11:24:40 +08:00
const resetBackground = () => {
2026-01-15 08:43:10 +08:00
currentTemplate.value = templates.value[0];
};
2026-01-09 11:24:40 +08:00
const toggleAvatarDecor = () => {
2026-01-15 08:43:10 +08:00
uni.showToast({ title: "挂饰功能即将上线~", icon: "none" });
};
2026-01-09 11:24:40 +08:00
2026-01-27 16:46:39 +08:00
const preview = async () => {
if (!isLoggedIn.value) {
loginPopupRef.value.open();
return;
}
2026-01-27 18:18:04 +08:00
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;
}
2026-01-27 16:46:39 +08:00
const tempPath = await saveByCanvas(true);
2026-01-27 19:35:47 +08:00
id = createCard();
shareOrSave(id);
saveRecordRequest(tempPath, id, "card_generate");
2026-01-27 16:46:39 +08:00
2026-01-15 08:43:10 +08:00
// uni.showToast({ title: '已保存到相册', icon: 'checkmarkempty' })
};
2026-01-27 18:18:04 +08:00
const shareOrSave = async (id) => {
2026-01-27 19:35:47 +08:00
if (!id) id = createCard();
2026-01-22 22:11:02 +08:00
const tempPath = await saveByCanvas(false);
2026-01-27 18:18:04 +08:00
const imageUrl = await uploadImage(tempPath);
updateCard({
id,
imageUrl,
status: 1,
blessingId: blessingText.value?.id || "",
blessingTo: targetName.value,
blessingFrom: signatureName.value,
templateId: currentTemplate.value?.id || "",
2026-01-22 22:11:02 +08:00
});
2026-01-15 08:43:10 +08:00
};
2026-01-09 11:24:40 +08:00
const showMore = () => {
2026-01-15 08:43:10 +08:00
uni.showToast({ title: "更多模板即将上线~", icon: "none" });
};
2026-01-09 11:24:40 +08:00
2026-01-15 08:43:10 +08:00
const saveByCanvas = async (save = true) => {
2026-01-22 11:04:47 +08:00
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;
}
2026-01-09 11:24:40 +08:00
2026-01-22 11:04:47 +08:00
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;
});
};
try {
// 1⃣ 画背景
// ⭐ 先加载背景图
const [bgImg, avatarImg] = await Promise.all([
loadCanvasImage(currentTemplate?.value?.imageUrl),
loadCanvasImage(userAvatar.value),
]);
ctx.drawImage(bgImg, 0, 0, W, H);
// 2⃣ 半透明遮罩(和你 UI 一致)
ctx.fillStyle = "rgba(0,0,0,0.08)";
ctx.fillRect(0, 0, W, H);
// 3⃣ 标题
ctx.fillStyle = "#ffffff";
ctx.font = "42px sans-serif"; // 默认字体
ctx.textAlign = "center";
ctx.textBaseline = "alphabetic"; // Canvas 2D 默认是 alphabetic
ctx.fillText("新春快乐", W / 2, 120);
ctx.font = "22px sans-serif";
ctx.globalAlpha = 0.9;
ctx.fillText("2026 YEAR OF THE HORSE", W / 2, 165);
ctx.globalAlpha = 1;
// 4⃣ 祝福语气泡
drawBubbleText(ctx, {
2026-01-22 22:11:02 +08:00
text: targetName.value + "\n " + blessingText.value.content,
2026-01-22 11:04:47 +08:00
x: 70,
y: 260 + bubbleOffsetY.value,
maxWidth: 400,
2026-01-22 15:08:26 +08:00
fontSize: fontSize.value,
lineHeight: fontSize.value * 1.5,
2026-01-22 11:04:47 +08:00
backgroundColor: "rgba(255,255,255,0.85)",
textColor: selectedColor.value,
fontFamily: selectedFont.value.family,
});
drawUserBubble(ctx, {
x: 160 + userOffsetX.value,
2026-01-31 11:25:53 +08:00
y: H - 136 + userOffsetY.value,
2026-01-22 11:04:47 +08:00
avatarImg: avatarImg, // 传入 Image 对象
username: signatureName.value,
desc: "送上祝福",
2026-01-22 15:08:26 +08:00
textColor: signatureColor.value,
2026-01-22 11:04:47 +08:00
});
// 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);
}
2026-01-15 08:43:10 +08:00
});
});
};
2026-01-09 11:24:40 +08:00
const loadImage = (url) => {
2026-01-22 11:04:47 +08:00
// 此函数保留给其他可能用到的地方,但 saveByCanvas 内部使用 loadCanvasImage
2026-01-09 11:24:40 +08:00
return new Promise((resolve, reject) => {
uni.getImageInfo({
src: url,
2026-01-15 08:43:10 +08:00
success: (res) => {
resolve(res.path); // 本地路径
2026-01-09 11:24:40 +08:00
},
2026-01-15 08:43:10 +08:00
fail: (err) => {
reject(err);
},
});
});
};
2026-01-09 11:24:40 +08:00
const saveImage = (path) => {
uni.saveImageToPhotosAlbum({
filePath: path,
success() {
2026-01-15 08:43:10 +08:00
uni.showToast({ title: "已保存到相册" });
2026-01-09 11:24:40 +08:00
},
fail() {
uni.showModal({
2026-01-15 08:43:10 +08:00
title: "提示",
content: "请授权保存到相册",
});
},
});
};
2026-01-09 11:24:40 +08:00
function drawBubbleText(ctx, options) {
const {
text,
x,
y,
maxWidth = 400,
2026-01-15 08:43:10 +08:00
padding = 24,
2026-01-09 11:24:40 +08:00
lineHeight = 42,
radius = 24,
2026-01-15 08:43:10 +08:00
backgroundColor = "rgba(255,255,255,0.9)",
textColor = "#fff",
2026-01-09 11:24:40 +08:00
fontSize = 32,
2026-01-15 08:43:10 +08:00
fontFamily = "PingFang SC",
} = options;
if (!text) return;
2026-01-09 11:24:40 +08:00
2026-01-22 11:04:47 +08:00
ctx.fillStyle = textColor;
ctx.font = `${fontSize}px '${fontFamily}'`;
2026-01-15 08:43:10 +08:00
ctx.textAlign = "left";
ctx.textBaseline = "top";
2026-01-09 11:24:40 +08:00
// 1⃣ 文本自动换行
2026-01-15 08:43:10 +08:00
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;
}
2026-01-09 11:24:40 +08:00
}
2026-01-15 08:43:10 +08:00
if (line) lines.push(line);
});
2026-01-09 11:24:40 +08:00
// 2⃣ 计算气泡尺寸
2026-01-15 08:43:10 +08:00
// const bubbleWidth = maxWidth
2026-01-09 11:24:40 +08:00
2026-01-15 08:43:10 +08:00
// const bubbleHeight = lines.length * lineHeight + padding * 2
2026-01-09 11:24:40 +08:00
// 3⃣ 绘制气泡(圆角矩形)
2026-01-15 08:43:10 +08:00
// drawRoundRect(
// ctx,
// x,
// y,
// bubbleWidth,
// bubbleHeight,
// radius,
// backgroundColor
// )
// 4⃣ 绘制文字
2026-01-22 11:04:47 +08:00
ctx.fillStyle = textColor;
2026-01-15 08:43:10 +08:00
lines.forEach((line, index) => {
ctx.fillText(line, x + padding, y + padding + index * lineHeight);
});
}
function drawUserBubble(ctx, options) {
const {
x = 40, // 气泡起点 x
y = 860, // 气泡起点 y
2026-01-22 11:04:47 +08:00
avatarImg, // CanvasImage 对象
2026-01-15 08:43:10 +08:00
username = "zzc",
desc = "送上祝福",
avatarSize = 64, // 头像直径
padding = 16, // 气泡内边距
fontSizeName = 24,
fontSizeDesc = 20,
bubbleColor = "rgba(255,255,255,0.18)",
textColor = "#ffffff",
} = options;
// 设置字体
ctx.textBaseline = "top";
2026-01-22 11:04:47 +08:00
ctx.font = `${fontSizeName}px 'PingFang SC'`;
2026-01-15 08:43:10 +08:00
// 测量文字宽度
const nameWidth = ctx.measureText(username).width;
2026-01-22 11:04:47 +08:00
ctx.font = `${fontSizeDesc}px 'PingFang SC'`;
2026-01-15 08:43:10 +08:00
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;
// 1⃣ 绘制气泡(左右半圆)
2026-01-09 11:24:40 +08:00
drawRoundRect(
ctx,
x,
y,
bubbleWidth,
bubbleHeight,
2026-01-15 08:43:10 +08:00
bubbleHeight / 2,
2026-01-21 23:58:21 +08:00
bubbleColor,
2026-01-15 08:43:10 +08:00
);
// 2⃣ 绘制头像
const avatarX = x + padding;
const avatarY = y + (bubbleHeight - avatarSize) / 2;
ctx.save();
ctx.beginPath();
ctx.arc(
avatarX + avatarSize / 2,
avatarY + avatarSize / 2,
avatarSize / 2,
0,
2026-01-21 23:58:21 +08:00
Math.PI * 2,
2026-01-15 08:43:10 +08:00
);
ctx.clip();
2026-01-22 11:04:47 +08:00
if (avatarImg) {
ctx.drawImage(avatarImg, avatarX, avatarY, avatarSize, avatarSize);
}
2026-01-15 08:43:10 +08:00
ctx.restore();
// 3⃣ 绘制文字
const textX = avatarX + avatarSize + padding;
const textY = y + padding;
2026-01-22 11:04:47 +08:00
ctx.fillStyle = textColor;
ctx.font = `${fontSizeName}px 'PingFang SC'`;
2026-01-15 08:43:10 +08:00
ctx.fillText(username, textX, textY);
2026-01-22 11:04:47 +08:00
ctx.font = `${fontSizeDesc}px 'PingFang SC'`;
ctx.globalAlpha = 0.6;
2026-01-15 08:43:10 +08:00
ctx.fillText(desc, textX, textY + fontSizeName + 4);
2026-01-22 11:04:47 +08:00
ctx.globalAlpha = 1;
2026-01-09 11:24:40 +08:00
}
function drawRoundRect(ctx, x, y, w, h, r, color) {
2026-01-15 08:43:10 +08:00
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();
2026-01-09 11:24:40 +08:00
}
</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;
2026-01-15 08:43:10 +08:00
box-shadow: 0 16rpx 40rpx rgba(0, 0, 0, 0.12);
2026-01-09 11:24:40 +08:00
}
.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 {
2026-01-15 08:43:10 +08:00
display: flex;
flex-direction: column;
align-items: center;
margin-top: 60rpx;
2026-01-09 11:24:40 +08:00
}
.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;
2026-01-15 08:43:10 +08:00
max-width: 500rpx;
2026-01-09 11:24:40 +08:00
}
.bubble-text {
font-size: 26rpx;
line-height: 1.6;
2026-01-15 08:43:10 +08:00
white-space: pre-wrap;
text-align: left;
2026-01-09 11:24:40 +08:00
}
.user {
2026-01-15 08:43:10 +08:00
position: absolute;
bottom: 40rpx;
2026-01-09 11:24:40 +08:00
display: flex;
align-items: center;
background: rgba(255, 255, 255, 0.18);
border: 1rpx solid rgba(255, 255, 255, 0.35);
2026-01-15 08:43:10 +08:00
border-radius: 9999rpx;
2026-01-09 11:24:40 +08:00
padding: 15rpx;
padding-left: 20rpx;
padding-right: 20rpx;
}
.avatar {
width: 64rpx;
height: 64rpx;
border-radius: 50%;
margin-right: 14rpx;
2026-01-15 08:43:10 +08:00
border: 2rpx solid rgba(255, 255, 255, 0.6);
}
.user .user-info {
display: flex;
flex-direction: column;
2026-01-09 11:24:40 +08:00
}
2026-01-15 08:43:10 +08:00
.user-name {
font-size: 24rpx;
font-weight: 600;
}
.user-desc {
font-size: 20rpx;
opacity: 0.6;
2026-01-09 11:24:40 +08:00
}
/* 顶部提示 */
.tip-line {
text-align: center;
color: #999;
font-size: 22rpx;
}
/* 编辑工具区 */
.editor-panel {
margin: 20rpx 24rpx 40rpx;
border-radius: 30rpx 30rpx 0 0;
background: #fff;
2026-01-15 08:43:10 +08:00
box-shadow: 0 -10rpx 30rpx rgba(0, 0, 0, 0.06);
2026-01-09 11:24:40 +08:00
padding-bottom: env(safe-area-inset-bottom);
}
.drag-handle {
2026-01-15 08:43:10 +08:00
width: 120rpx;
height: 8rpx;
border-radius: 999rpx;
background: #eee;
margin: 12rpx auto;
2026-01-09 11:24:40 +08:00
}
/* 工具入口 */
.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 {
2026-01-15 08:43:10 +08:00
width: 64rpx;
height: 64rpx;
2026-01-09 11:24:40 +08:00
border-radius: 16rpx;
background: #fafafa;
2026-01-15 08:43:10 +08:00
display: flex;
align-items: center;
justify-content: center;
2026-01-09 11:24:40 +08:00
margin-bottom: 8rpx;
}
2026-01-15 08:43:10 +08:00
.tool-text {
font-size: 22rpx;
}
2026-01-09 11:24:40 +08:00
/* 模板区 */
2026-01-15 08:43:10 +08:00
.section {
padding: 12rpx 24rpx 0;
}
2026-01-09 11:24:40 +08:00
.section-title {
2026-01-15 08:43:10 +08:00
display: flex;
align-items: center;
2026-01-09 11:24:40 +08:00
}
.section-title .more {
margin-left: auto;
color: #ff3b30;
font-size: 24rpx;
}
2026-01-15 08:43:10 +08:00
.tpl-scroll {
margin-top: 12rpx;
}
2026-01-21 23:58:21 +08:00
.tpl-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16rpx;
padding-bottom: 20rpx;
2026-01-15 08:43:10 +08:00
}
2026-01-09 11:24:40 +08:00
.tpl-card {
2026-01-21 23:58:21 +08:00
width: 100%; /* 自适应 grid 宽度 */
border-radius: 12rpx;
2026-01-15 08:43:10 +08:00
overflow: hidden;
background: #fff;
2026-01-09 11:24:40 +08:00
position: relative;
2026-01-21 23:58:21 +08:00
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.06);
2026-01-15 08:43:10 +08:00
}
.tpl-card.selected {
outline: 4rpx solid #ff3b30;
}
.tpl-cover {
width: 100%;
2026-01-21 23:58:21 +08:00
height: 368rpx;
2026-01-09 11:24:40 +08:00
}
.tpl-name {
2026-01-21 23:58:21 +08:00
font-size: 20rpx;
2026-01-15 08:43:10 +08:00
color: #333;
2026-01-21 23:58:21 +08:00
padding: 6rpx 8rpx;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
2026-01-09 11:24:40 +08:00
}
.tpl-check {
2026-01-15 08:43:10 +08:00
position: absolute;
2026-01-21 23:58:21 +08:00
right: 6rpx;
top: 6rpx;
width: 32rpx;
height: 32rpx;
2026-01-15 08:43:10 +08:00
border-radius: 50%;
background: #ff3b30;
color: #fff;
2026-01-21 23:58:21 +08:00
font-size: 20rpx;
2026-01-15 08:43:10 +08:00
display: flex;
align-items: center;
justify-content: center;
}
2026-01-21 23:58:21 +08:00
.loading-more,
.no-more {
text-align: center;
font-size: 22rpx;
color: #999;
padding: 10rpx 0;
}
2026-01-15 08:43:10 +08:00
/* 文字编辑区 */
.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 {
2026-01-22 09:34:08 +08:00
background: #fff5f5;
2026-01-15 08:43:10 +08:00
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;
}
.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;
}
2026-01-22 09:34:08 +08:00
.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;
}
2026-01-15 08:43:10 +08:00
.color-list {
display: flex;
2026-01-22 09:34:08 +08:00
gap: 20rpx;
2026-01-22 15:08:26 +08:00
flex-wrap: wrap;
2026-01-15 08:43:10 +08:00
}
.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);
2026-01-09 11:24:40 +08:00
}
/* 按钮区 */
.bottom-actions {
2026-01-15 08:43:10 +08:00
display: flex;
align-items: center;
justify-content: center;
2026-01-09 11:24:40 +08:00
padding: 20rpx 24rpx 30rpx;
}
.btn {
2026-01-15 08:43:10 +08:00
display: flex;
align-items: center;
justify-content: center;
height: 88rpx;
border-radius: 999rpx;
padding: 0 40rpx;
2026-01-09 11:24:40 +08:00
font-size: 28rpx;
}
2026-01-15 08:43:10 +08:00
.btn view {
margin-left: 10rpx;
}
2026-01-09 11:24:40 +08:00
.btn.secondary {
2026-01-15 08:43:10 +08:00
background: #fff;
color: #000;
border: 1px solid #ccc;
2026-01-09 11:24:40 +08:00
}
.btn.primary {
2026-01-15 08:43:10 +08:00
background: #ff3b30;
color: #fff;
box-shadow: 0 12rpx 24rpx rgba(255, 59, 48, 0.35);
2026-01-09 11:24:40 +08:00
}
.hidden-canvas {
position: fixed;
left: -9999px;
top: -9999px;
}
2026-01-15 08:43:10 +08:00
</style>