Files
spring-festival-greetings/pages/make/index.vue
2026-01-31 11:56:39 +08:00

1392 lines
34 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?.imageUrl"
mode="aspectFill"
/>
<view class="card-overlay">
<view class="watermark">年禧集.马年春节祝福</view>
<!-- <view class="title">
<text class="main">新春快乐</text>
<text class="sub">2026 YEAR OF THE HORSE</text>
</view> -->
<view
class="bubble"
@tap="activeTool = '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="editor-panel">
<view class="drag-handle"></view>
<!-- 底部操作 -->
<view class="bottom-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 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>
<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="5"
/>
</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 === 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 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 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="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="userOffsetX"
min="-200"
max="200"
show-value
@change="(e) => (userOffsetX = e.detail.value)"
activeColor="#ff3b30"
/>
</view>
<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>
</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
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,
} 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 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 = [
"#ffffff",
"#000000",
"#ff3b30",
"#F5A623",
"#8B572A",
"#D0021B",
"#F8E71C",
"#7ED321",
"#4A90E2",
"#9013FE",
"#FFC0CB",
];
const selectedColor = ref("#ffffff");
const signatureColor = ref("#ffffff");
const fontList = [
{ name: "默认", family: "PingFang SC", url: "" },
{
name: "毛笔",
family: "MaoBi",
url: "https://file.lihailezzc.com/MaShanZheng-Regular.ttf", // 示例地址
},
{
name: "手写",
family: "ShouXie",
url: "https://file.lihailezzc.com/ZhiMangXing-Regular.ttf", // 示例地址
},
{
name: "可爱",
family: "KeAi",
url: "https://file.lihailezzc.com/ZCOOLKuaiLe-Regular.ttf", // 示例地址
},
{
name: "草书",
family: "LiuJianMaoCao",
url: "https://file.lihailezzc.com/LiuJianMaoCao-Regular.ttf", // 示例地址
},
];
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();
});
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();
}
});
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 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: currentTemplate.value?.imageUrl || "/static/images/bg.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: "模板", icon: "▦" },
{ type: "text", text: "文字", icon: "文" },
{ type: "position", text: "位置", icon: "图" },
// { type: "avatar", text: "头像挂饰", icon: "饰" },
];
const activeTool = ref("template");
const templates = ref([]);
const currentTemplate = ref(templates.value[0]);
const applyTemplate = (tpl) => {
currentTemplate.value = tpl;
};
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);
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;
});
};
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, {
text: targetName.value + "\n " + blessingText.value.content,
x: 70,
y: 260 + bubbleOffsetY.value,
maxWidth: bubbleMaxWidth.value, // 使用动态宽度
canvasWidth: W, // 传入画布宽度以实现自动居中
fontSize: fontSize.value,
lineHeight: fontSize.value * 1.5,
backgroundColor: "rgba(255,255,255,0.85)",
textColor: selectedColor.value,
fontFamily: selectedFont.value.family,
});
drawUserBubble(ctx, {
x: 160 + userOffsetX.value,
y: H - 136 + userOffsetY.value,
avatarImg: avatarImg, // 传入 Image 对象
username: signatureName.value,
desc: "送上祝福",
textColor: signatureColor.value,
});
// 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) {
const totalWidth = maxLineWidth + padding * 2;
drawX = (canvasWidth - totalWidth) / 2;
}
// 2⃣ 计算气泡尺寸
// const bubbleWidth = maxWidth
// const bubbleHeight = lines.length * lineHeight + padding * 2
// 3⃣ 绘制气泡(圆角矩形)
// drawRoundRect(
// ctx,
// drawX,
// y,
// bubbleWidth,
// bubbleHeight,
// radius,
// backgroundColor
// )
// 4⃣ 绘制文字
ctx.fillStyle = textColor;
lines.forEach((line, index) => {
ctx.fillText(line, drawX + padding, y + padding + index * lineHeight);
});
}
function drawUserBubble(ctx, options) {
const {
x = 40, // 气泡起点 x
y = 860, // 气泡起点 y
avatarImg, // CanvasImage 对象
username = "zzc",
desc = "送上祝福",
avatarSize = 64, // 头像直径
padding = 16, // 气泡内边距
fontSizeName = 24,
fontSizeDesc = 20,
bubbleColor = "rgba(255,255,255,0.18)",
textColor = "#ffffff",
} = 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;
// 1⃣ 绘制气泡(左右半圆)
drawRoundRect(
ctx,
x,
y,
bubbleWidth,
bubbleHeight,
bubbleHeight / 2,
bubbleColor,
);
// 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,
Math.PI * 2,
);
ctx.clip();
if (avatarImg) {
ctx.drawImage(avatarImg, avatarX, avatarY, avatarSize, avatarSize);
}
ctx.restore();
// 3⃣ 绘制文字
const textX = avatarX + avatarSize + padding;
const textY = y + 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.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;
}
.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;
}
/* 顶部提示 */
.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-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);
}
.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;
}
.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);
}
/* 按钮区 */
.bottom-actions {
display: flex;
align-items: center;
justify-content: center;
padding: 20rpx 24rpx 30rpx;
}
.btn {
display: flex;
align-items: center;
justify-content: center;
height: 88rpx;
border-radius: 999rpx;
padding: 0 40rpx;
font-size: 28rpx;
}
.btn view {
margin-left: 10rpx;
}
.btn.secondary {
background: #fff;
color: #000;
border: 1px solid #ccc;
}
.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>