Files
spring-festival-greetings/pages/make/index.vue
2026-02-12 01:15:26 +08:00

2087 lines
54 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">
<view class="premium-tag">
<uni-icons type="info" size="12" color="#fff"></uni-icons>
<text>分享或保存即可去除水印</text>
</view>
<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')"
@touchstart.stop="handleBubbleTouchStart"
@touchmove.stop="handleBubbleTouchMove"
@touchend.stop="handleBubbleTouchEnd"
: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',
fontWeight: fontWeight,
}"
>{{
(targetName || "") + "\n " + (blessingText.content || "")
}}</text
>
</view>
<view
class="user"
:style="{
left: 160 + userOffsetX + 'rpx',
bottom: 40 - userOffsetY + 'rpx',
}"
@touchstart.stop="handleUserTouchStart"
@touchmove.stop="handleUserTouchMove"
@touchend.stop="handleUserTouchEnd"
>
<image class="avatar" :src="userAvatar" mode="aspectFill" />
<view class="user-info">
<text
class="user-name"
:style="{ color: signatureColor, fontWeight: fontWeight }"
>{{ signatureName }}</text
>
<text
class="user-desc"
:style="{ color: signatureColor, fontWeight: fontWeight }"
>送上祝福</text
>
</view>
</view>
</view>
</view>
<view class="tip-line">
<view class="interaction-tip">
<uni-icons type="hand-up" size="14" color="#ff3b30"></uni-icons>
<text>标题祝福语个人信息可拖动改变位置双指可缩放标题</text>
</view>
</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>
<scroll-view
scroll-y
class="panel-content"
:class="{
'glass-effect': activeTool === 'text' || activeTool === 'position',
}"
@scrolltolower="onPanelScrollToLower"
>
<view class="panel-inner">
<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="getTitleThumbUrl(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>
<!-- 模板区 -->
<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="getThumbUrl(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"
@blur="handleTargetNameBlur"
/>
</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="10"
@blur="handleSignatureBlur"
/>
<text class="edit-icon"></text>
</view>
</view>
<!-- 祝福语库 -->
<view class="form-item">
<view class="label-row">
<text class="label">祝福语库</text>
</view>
<view class="greeting-grid">
<view
v-for="(text, index) in greetingLib"
: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>
<view v-if="loadingBlessings" class="loading-more"
>加载中...</view
>
<view
v-else-if="!hasMoreBlessings && greetingLib.length > 0"
class="no-more"
>没有更多了</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="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="weight-options">
<view
class="weight-btn"
:class="{ active: fontWeight === 'normal' }"
@tap="fontWeight = 'normal'"
>
<text>常规</text>
</view>
<view
class="weight-btn"
:class="{ active: fontWeight === 'bold' }"
@tap="fontWeight = 'bold'"
>
<text style="font-weight: bold">加粗</text>
</view>
</view>
</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>
<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>
<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 class="form-item">
<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="userOffsetY"
min="-300"
max="600"
show-value
@change="(e) => (userOffsetY = e.detail.value)"
activeColor="#ff3b30"
/>
</view>
<view class="form-item">
<text class="label">个人信息 (左右)</text>
<slider
:value="userOffsetX"
min="-200"
max="400"
show-value
@change="(e) => (userOffsetX = e.detail.value)"
activeColor="#ff3b30"
/>
</view> -->
</view>
</view>
</scroll-view>
</view>
<canvas
type="2d"
id="cardCanvas"
class="hidden-canvas"
style="width: 540px; height: 960px"
/>
<LoginPopup
ref="loginPopupRef"
@logind="handleLogind"
:share-token="shareToken"
/>
</view>
</template>
<script setup>
import { ref, computed, watch } from "vue";
import { getBavBarHeight, getDeviceInfo } from "@/utils/system";
import { generateObjectId, getShareToken } from "@/utils/common";
import {
createCardTmp,
updateCard,
getCardTemplateList,
getCardTemplateContentList,
getCardTemplateTitleList,
} from "@/api/make";
import { abilityCheck, getShareReward, msgCheckApi } from "@/api/system";
import {
onShareAppMessage,
onShareTimeline,
onLoad,
onReachBottom,
onShow,
onPullDownRefresh,
} 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 DEFAULT_AVATAR =
"https://file.lihailezzc.com/resource/96023631c6ab9c3496b7620097af3d6f.png";
const templatePage = ref(1);
const loadingTemplates = ref(false);
const hasMoreTemplates = ref(true);
const cardId = ref("");
// 标题相关
const titles = ref([]);
const currentTitle = ref(titles.value[0]);
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 = [];
};
// 个人信息触摸交互相关
let startUserTouches = [];
let initialUserState = {
offsetX: 0,
offsetY: 0,
};
const handleUserTouchStart = (e) => {
startUserTouches = e.touches;
initialUserState = {
offsetX: userOffsetX.value,
offsetY: userOffsetY.value,
};
};
const handleUserTouchEnd = () => {
startUserTouches = [];
};
const handleUserTouchMove = (e) => {
if (!startUserTouches.length) return;
if (e.touches.length === 1 && startUserTouches.length === 1) {
// 单指拖拽
const moveX = e.touches[0].clientX - startUserTouches[0].clientX;
const moveY = e.touches[0].clientY - startUserTouches[0].clientY;
userOffsetX.value = initialUserState.offsetX + moveX * pxToRpx;
userOffsetY.value = initialUserState.offsetY + moveY * pxToRpx;
}
};
// 祝福语触摸交互相关
let startBubbleTouches = [];
let initialBubbleOffsetY = 0;
const handleBubbleTouchStart = (e) => {
startBubbleTouches = e.touches;
initialBubbleOffsetY = bubbleOffsetY.value;
};
const handleBubbleTouchEnd = () => {
startBubbleTouches = [];
};
const handleBubbleTouchMove = (e) => {
if (!startBubbleTouches.length) return;
if (e.touches.length === 1 && startBubbleTouches.length === 1) {
// 单指拖拽 (仅上下)
const moveY = e.touches[0].clientY - startBubbleTouches[0].clientY;
let newY = initialBubbleOffsetY + moveY * pxToRpx;
// 合理范围限制,参考 slider 的 min/max
bubbleOffsetY.value = Math.min(Math.max(newY, -200), 400);
}
};
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 oldTargetName = ref("祝您");
const signatureName = ref(userStore?.userInfo?.nickName || "xxx");
const oldSignatureName = ref(userStore?.userInfo?.nickName || "xxx");
const handleTargetNameBlur = async () => {
if (!targetName.value || targetName.value === oldTargetName.value) return;
const res = await msgCheckApi(targetName.value);
if (!res.success) {
uni.showToast({
title: res.message || "消息不符合发布规范,请稍作修改后再试",
icon: "none",
});
targetName.value = oldTargetName.value;
} else {
oldTargetName.value = targetName.value;
}
};
const handleSignatureBlur = async () => {
if (!signatureName.value || signatureName.value === oldSignatureName.value)
return;
const res = await msgCheckApi(signatureName.value);
if (!res.success) {
uni.showToast({
title: res.message || "消息不符合发布规范,请稍作修改后再试",
icon: "none",
});
signatureName.value = oldSignatureName.value;
} else {
oldSignatureName.value = signatureName.value;
}
};
const userAvatar = ref(userStore?.userInfo?.avatarUrl || DEFAULT_AVATAR);
const blessingText = ref({});
const fontSize = ref(38);
const fontWeight = ref("normal"); // 默认加粗
const textColors = [
"#F8DA84",
"#B4802C",
"#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 blessingPage = ref(1);
const loadingBlessings = ref(false);
const hasMoreBlessings = ref(true);
const bubbleOffsetY = ref(0);
const bubbleMaxWidth = ref(400); // 默认宽度
const userOffsetX = ref(0);
const userOffsetY = ref(0);
const shareToken = ref("");
onLoad((options) => {
getTemplateList();
getTemplateContentList();
getTemplateTitleList();
if (options.shareToken) {
shareToken.value = options.shareToken;
}
});
const syncUserInfo = () => {
if (isLoggedIn.value) {
if (signatureName.value === "xxx" || !signatureName.value) {
signatureName.value = userStore.userInfo.nickName;
oldSignatureName.value = userStore.userInfo.nickName;
}
if (userAvatar.value === DEFAULT_AVATAR || !userAvatar.value) {
userAvatar.value = userStore.userInfo.avatarUrl;
}
}
};
watch(
() => userStore.userInfo,
(newVal) => {
if (newVal?.nickName) {
syncUserInfo();
}
},
{ deep: true },
);
onShow(() => {
syncUserInfo();
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();
} else if (activeTool.value === "text") {
loadMoreBlessings();
}
});
onPullDownRefresh(async () => {
templatePage.value = 1;
hasMoreTemplates.value = true;
titlePage.value = 1;
hasMoreTitles.value = true;
blessingPage.value = 1;
hasMoreBlessings.value = true;
await Promise.all([
getTemplateList(),
getTemplateTitleList(),
getTemplateContentList(),
]);
uni.stopPullDownRefresh();
uni.showToast({ title: "已为你更新内容", icon: "success" });
});
const handleLogind = async () => {
syncUserInfo();
};
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;
if (!isLoadMore && hasMoreTitles.value) {
getTemplateTitleList(true);
}
}
};
const loadMoreTitles = () => {
getTemplateTitleList(true);
};
const getThumbUrl = (url) => {
return `${url}?imageView2/1/w/340/h/600/q/80`;
};
const getTitleThumbUrl = (url) => {
return `${url}?imageView2/2/w/200/h/200/q/80`;
};
const getTemplateContentList = async (isLoadMore = false) => {
if (loadingBlessings.value || (!hasMoreBlessings.value && isLoadMore)) return;
loadingBlessings.value = true;
try {
const res = await getCardTemplateContentList(blessingPage.value);
const list = Array.isArray(res) ? res : res.list || [];
if (list.length > 0) {
if (isLoadMore) {
greetingLib.value = [...greetingLib.value, ...list];
} else {
greetingLib.value = list;
if (list.length > 0 && !blessingText.value.content) {
blessingText.value = list[0];
}
}
if (typeof res.hasNext !== "undefined") {
hasMoreBlessings.value = res.hasNext;
} else {
hasMoreBlessings.value = list.length >= 8;
}
if (hasMoreBlessings.value) {
blessingPage.value++;
}
} else {
if (!isLoadMore) greetingLib.value = [];
hasMoreBlessings.value = false;
}
} catch (error) {
console.error("加载祝福语失败:", error);
} finally {
loadingBlessings.value = false;
}
};
const loadMoreBlessings = () => {
getTemplateContentList(true);
};
const loadMoreTemplates = () => {
getTemplateList(true);
};
const onPanelScrollToLower = () => {
if (activeTool.value === "template") {
loadMoreTemplates();
} else if (activeTool.value === "title") {
loadMoreTitles();
} else if (activeTool.value === "text") {
loadMoreBlessings();
}
};
onShareAppMessage(async (options) => {
getShareReward({ scene: "card_generate" });
if (options.from === "button") {
if (!isLoggedIn.value) {
loginPopupRef.value.open();
return;
}
// 1. 确保有 cardId (如果内容有变动,最好是新建)
const id = createCard();
const shareToken = await getShareToken("card_generate", id);
shareOrSave(id);
return {
title: "我刚做了一张祝福卡片,送给你",
path: "/pages/detail/index?shareToken=" + shareToken,
imageUrl:
"https://file.lihailezzc.com/resource/bf9faeddb7ff55a5cd3d435779d56556.png",
};
} else {
const shareToken = await getShareToken("card_generate_index", "");
return {
title: "快来制作新春祝福卡片🎉",
path: `/pages/make/index?shareToken=${shareToken}`,
imageUrl:
"https://file.lihailezzc.com/resource/8dd026d76ef7a63d123b7fd698fb989b.png",
};
}
});
onShareTimeline(async () => {
const shareToken = await getShareToken("card_timeline");
return {
title: "送你一张精美的新春祝福卡片 🎊",
query: `shareToken=${shareToken}`,
imageUrl:
"https://file.lihailezzc.com/resource/8dd026d76ef7a63d123b7fd698fb989b.png",
};
});
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 || "",
titleId: currentTitle?.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 || 2;
// 为了高清画质,将物理像素设置为逻辑像素的 dpr 倍
const baseW = 540;
const baseH = 960;
canvas.width = baseW * dpr;
canvas.height = baseH * dpr;
// ⚠️ 关键修复:重置变换矩阵,防止多次调用导致 scale 累积
// 支付宝小程序在设置 width/height 后可能未完全重置 Context 状态
ctx.setTransform(1, 0, 0, 1, 0, 0);
// 清空画布(使用物理像素尺寸)
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 缩放上下文,使得后续绘制指令依然可以使用逻辑坐标 (baseW, baseH)
ctx.scale(dpr, dpr);
// 画布尺寸(逻辑像素)
const W = baseW;
const H = baseH;
// 辅助函数:加载图片为 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 * baseW) / 506;
try {
// #ifdef MP-ALIPAY
// 支付宝环境:显式等待 Canvas 节点就绪,避免过早绘制
await new Promise((resolve) => setTimeout(resolve, 300));
// #endif
// 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),
fontWeight: fontWeight.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,
fontWeight: fontWeight.value,
avatarSize: r2p(64),
padding: r2p(15),
fontSizeName: r2p(24),
fontSizeDesc: r2p(20),
});
// 6⃣ 输出
// #ifdef MP-ALIPAY
// 支付宝环境:等待绘制指令执行完毕,确保渲染完成
await new Promise((resolve) => setTimeout(resolve, 300));
// #endif
uni.canvasToTempFilePath({
canvas: canvas, // Canvas 2D 必须传 canvas 实例
width: canvas.width,
height: canvas.height,
destWidth: canvas.width,
destHeight: canvas.height,
fileType: "jpg", // 使用 PNG 避免 JPG 压缩损耗
quality: 0.85, // 最高质量
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,
fontWeight = "normal",
fontFamily = "PingFang SC",
canvasWidth, // 新增:画布宽度,用于居中计算
} = options;
if (!text) return;
ctx.fillStyle = textColor;
ctx.font = `${fontWeight === "bold" ? "bold " : ""}${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",
fontWeight = "normal",
} = options;
// 设置字体
ctx.textBaseline = "top";
ctx.font = `${fontWeight === "bold" ? "bold " : ""}${fontSizeName}px 'PingFang SC'`;
// 测量文字宽度
const nameWidth = ctx.measureText(username).width;
ctx.font = `${fontWeight === "bold" ? "bold " : ""}${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 totalTextHeight = fontSizeName + fontSizeDesc + 4;
const textStartY = drawY + (bubbleHeight - totalTextHeight) / 2;
ctx.fillStyle = textColor;
ctx.font = `${fontWeight === "bold" ? "bold " : ""}${fontSizeName}px 'PingFang SC'`;
ctx.fillText(username, textX, textStartY);
ctx.font = `${fontWeight === "bold" ? "bold " : ""}${fontSizeDesc}px 'PingFang SC'`;
ctx.globalAlpha = 0.8;
ctx.fillText(desc, textX, textStartY + 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; /* 为底部固定按钮留出空间 */
}
.interaction-tip {
display: flex;
align-items: center;
justify-content: center;
gap: 8rpx;
color: #ff3b30;
font-size: 24rpx;
font-weight: 500;
margin-bottom: 12rpx;
background: rgba(255, 59, 48, 0.05);
padding: 8rpx 0;
border-radius: 8rpx;
}
/* 卡片预览 */
.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);
}
.premium-tag {
position: absolute;
top: 20rpx;
right: -20rpx;
background: linear-gradient(90deg, #ff8d42, #ff3b30);
color: #fff;
font-size: 20rpx;
padding: 8rpx 30rpx 8rpx 20rpx;
border-radius: 20rpx 0 0 20rpx;
z-index: 10;
display: flex;
align-items: center;
gap: 6rpx;
box-shadow: 0 4rpx 12rpx rgba(255, 59, 48, 0.3);
transform: translateX(10rpx);
}
.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;
transform: translateY(100%);
transition: transform 0.3s cubic-bezier(0.25, 1, 0.5, 1);
height: 80vh;
}
.panel-inner {
padding: 30rpx 40rpx calc(40rpx + env(safe-area-inset-bottom));
}
.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-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16rpx;
padding-bottom: 20rpx;
}
.greeting-card {
width: 100%;
height: 160rpx;
background: #fff;
border: 2rpx solid #eee;
border-radius: 16rpx;
padding: 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);
}
.weight-options {
display: flex;
gap: 20rpx;
flex: 1;
}
.weight-btn {
flex: 1;
height: 70rpx;
background: #f5f5f5;
border-radius: 12rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 26rpx;
color: #333;
border: 2rpx solid transparent;
transition: all 0.2s;
}
.weight-btn.active {
background: #fff5f5;
color: #ff3b30;
border-color: #ff3b30;
}
/* 按钮区 */
.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>