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

1803 lines
46 KiB
Vue
Raw Normal View History

2026-01-09 11:24:40 +08:00
<template>
<view class="make-page" :style="{ paddingTop: getBavBarHeight() + 'px' }">
2026-02-01 09:21:43 +08:00
<!-- 顶部步骤条 -->
<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>
2026-01-09 11:24:40 +08:00
<!-- 预览卡片 -->
<view class="card-preview">
2026-02-01 17:38:40 +08:00
<view class="premium-tag">
<uni-icons type="info" size="12" color="#fff"></uni-icons>
<text>分享或保存即可去除水印</text>
</view>
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">
2026-01-31 11:33:22 +08:00
<view class="watermark">年禧集.马年春节祝福</view>
2026-02-01 01:18:16 +08:00
<!-- 选中的标题图片 -->
<image
v-if="currentTitle"
class="selected-title-img"
:src="currentTitle.imageUrl"
mode="widthFix"
2026-02-01 17:20:13 +08:00
:style="titleStyle"
@touchstart.stop="handleTitleTouchStart"
@touchmove.stop="handleTitleTouchMove"
@touchend.stop="handleTitleTouchEnd"
2026-02-01 01:18:16 +08:00
/>
2026-01-22 08:55:00 +08:00
<view
class="bubble"
2026-02-01 09:21:43 +08:00
@tap="openTool('text')"
2026-01-31 11:56:39 +08:00
:style="{
marginTop: 230 + bubbleOffsetY + 'rpx',
maxWidth: bubbleMaxWidth + 80 + 'rpx',
}"
2026-01-22 08:55:00 +08:00
>
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-02-01 17:34:28 +08:00
<view
class="user"
:style="{ left: 160 + userOffsetX + 'rpx', bottom: 40 - userOffsetY + 'rpx' }"
@touchstart.stop="handleUserTouchStart"
@touchmove.stop="handleUserTouchMove"
@touchend.stop="handleUserTouchEnd"
>
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-02-01 17:34:28 +08:00
<view class="interaction-tip">
<uni-icons type="hand-up" size="14" color="#ff3b30"></uni-icons>
<text>点击标题或个人信息可拖动双指可缩放标题</text>
</view>
2026-01-09 11:24:40 +08:00
</view>
2026-02-01 09:21:43 +08:00
<!-- 底部固定按钮 -->
<view class="bottom-actions-fixed">
2026-02-01 01:18:16 +08:00
<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>
2026-02-01 09:21:43 +08:00
</view>
2026-02-01 01:18:16 +08:00
2026-02-01 09:21:43 +08:00
<!-- 弹出编辑面板 -->
<view class="panel-container" :class="{ show: showPanel }">
<view class="panel-mask" @tap="closePanel"></view>
<view class="panel-content" :class="{ 'glass-effect': activeTool === 'text' || activeTool === 'position' }">
<view class="panel-handle" @tap="closePanel"></view>
2026-01-09 11:24:40 +08:00
2026-02-01 01:18:16 +08:00
<!-- 标题选择区 -->
<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 }"
2026-02-01 09:40:56 +08:00
@tap="selectTitle(title)"
2026-02-01 01:18:16 +08:00
>
<image :src="title.imageUrl" class="title-cover" mode="aspectFit" />
<view v-if="title?.id === currentTitle?.id" class="tpl-check"></view>
</view>
</view>
<view v-if="loadingTitles" class="loading-more">加载中...</view>
<view v-else-if="!hasMoreTitles && titles.length > 0" class="no-more">没有更多了</view>
</view>
2026-01-15 08:43:10 +08:00
</view>
2026-02-01 09:21:43 +08:00
<!-- 模板区 -->
<view v-if="activeTool === 'template'" class="section">
<view class="section-title">
<text>热门模板</text>
2026-01-15 08:43:10 +08:00
</view>
2026-02-01 09:21:43 +08:00
<view class="tpl-scroll">
<view class="tpl-grid">
2026-01-15 08:43:10 +08:00
<view
2026-02-01 09:21:43 +08:00
v-for="(tpl, i) in templates"
:key="i"
class="tpl-card"
:class="{ selected: tpl?.id === currentTemplate?.id }"
@tap="applyTemplate(tpl)"
2026-01-15 08:43:10 +08:00
>
2026-02-01 09:21:43 +08:00
<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"
2026-01-22 22:11:02 +08:00
></view
>
2026-01-15 08:43:10 +08:00
</view>
</view>
2026-02-01 09:21:43 +08:00
<view v-if="loadingTemplates" class="loading-more">加载中...</view>
<view
v-else-if="!hasMoreTemplates && templates.length > 0"
class="no-more"
>没有更多了</view
>
</view>
2026-02-01 09:40:56 +08:00
2026-01-15 08:43:10 +08:00
</view>
2026-02-01 09:21:43 +08:00
<!-- 文字编辑 -->
<view v-if="activeTool === 'text'" class="section text-edit-section">
<!-- 祝贺对象 -->
<view class="form-item">
2026-02-01 16:32:37 +08:00
<text class="label">祝福对象</text>
2026-01-15 08:43:10 +08:00
<input
class="input-box"
2026-02-01 09:21:43 +08:00
v-model="targetName"
placeholder="请输入称呼"
2026-01-15 08:43:10 +08:00
placeholder-style="color:#ccc"
2026-02-01 16:32:37 +08:00
maxlength="10"
2026-01-15 08:43:10 +08:00
/>
</view>
2026-02-01 09:21:43 +08:00
<!-- 祝福语库 -->
<view class="form-item">
<view class="label-row">
<text class="label">祝福语库</text>
<view class="refresh-btn" @tap="refreshGreetings">
<text class="refresh-icon"></text> 换一批
</view>
</view>
<scroll-view scroll-x class="greeting-scroll" show-scrollbar="false">
<view class="greeting-list">
<view
v-for="(text, index) in displayedGreetings"
:key="index"
class="greeting-card"
:class="{ active: blessingText.content === text.content }"
@tap="selectGreeting(text)"
>
<text class="greeting-text">{{ text.content }}</text>
<view
v-if="blessingText.content === text.content"
class="check-mark"
></view
>
</view>
</view>
</scroll-view>
</view>
<!-- 署名 -->
<view class="form-item">
<text class="label">署名</text>
<view class="input-wrapper">
<input
class="input-box"
v-model="signatureName"
placeholder="请输入署名"
placeholder-style="color:#ccc"
maxlength="5"
/>
<text class="edit-icon"></text>
</view>
</view>
<!-- 字体选择 -->
<view class="form-item">
<text class="label">字体样式</text>
<scroll-view scroll-x class="font-scroll" show-scrollbar="false">
<view class="font-list">
<view
v-for="(font, index) in fontList"
:key="index"
class="font-item"
:class="{ active: selectedFont.family === font.family }"
@tap="changeFont(font)"
>
<text :style="{ fontFamily: font.family }">{{
font.name
}}</text>
</view>
</view>
</scroll-view>
</view>
<!-- 字体大小 -->
<view class="form-item">
<text class="label">字体大小</text>
<slider
:value="fontSize"
min="24"
max="64"
show-value
@change="(e) => (fontSize = e.detail.value)"
activeColor="#ff3b30"
/>
</view>
<!-- 文字颜色 -->
<view class="form-item">
<text class="label">祝福语颜色</text>
<view class="color-list">
2026-01-22 09:34:08 +08:00
<view
2026-02-01 09:21:43 +08:00
v-for="(color, index) in textColors"
2026-01-22 09:34:08 +08:00
:key="index"
2026-02-01 09:21:43 +08:00
class="color-item"
:style="{ background: color }"
@tap="selectedColor = color"
2026-01-22 09:34:08 +08:00
>
2026-02-01 09:21:43 +08:00
<view v-if="selectedColor === color" class="color-check"></view>
2026-01-22 09:34:08 +08:00
</view>
</view>
2026-02-01 09:21:43 +08:00
</view>
2026-01-22 15:08:26 +08:00
</view>
2026-02-01 09:21:43 +08:00
<!-- 位置调整 -->
<view v-if="activeTool === 'position'" class="section position-section">
<view class="section-title">
<text>调整位置</text>
2026-01-15 08:43:10 +08:00
</view>
2026-01-22 15:08:26 +08:00
2026-02-01 09:21:43 +08:00
<view class="form-item" style="margin-top: 20rpx;">
<text class="label">祝福语气泡 (上下)</text>
<slider
:value="bubbleOffsetY"
min="-200"
max="400"
show-value
@change="(e) => (bubbleOffsetY = e.detail.value)"
activeColor="#ff3b30"
/>
2026-01-22 15:08:26 +08:00
</view>
2026-02-01 00:27:11 +08:00
2026-02-01 09:21:43 +08:00
<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>
2026-01-09 11:24:40 +08:00
2026-02-01 09:21:43 +08:00
<view class="form-item">
<text class="label">个人信息 (上下)</text>
<slider
:value="userOffsetY"
2026-02-01 17:34:28 +08:00
min="-300"
max="600"
2026-02-01 09:21:43 +08:00
show-value
@change="(e) => (userOffsetY = e.detail.value)"
activeColor="#ff3b30"
/>
</view>
2026-01-09 11:24:40 +08:00
2026-02-01 09:21:43 +08:00
<view class="form-item">
<text class="label">个人信息 (左右)</text>
<slider
:value="userOffsetX"
2026-02-01 17:34:28 +08:00
min="-200"
max="400"
2026-02-01 09:21:43 +08:00
show-value
@change="(e) => (userOffsetX = e.detail.value)"
activeColor="#ff3b30"
/>
</view>
<view class="form-item">
<text class="label">署名颜色</text>
<view class="color-list">
<view
v-for="(color, index) in textColors"
:key="index"
class="color-item"
:style="{ background: color }"
@tap="signatureColor = color"
>
<view v-if="signatureColor === color" class="color-check"></view>
</view>
</view>
</view>
2026-01-09 11:24:40 +08:00
</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,
2026-02-01 01:18:16 +08:00
getCardTemplateTitleList,
2026-01-22 08:28:34 +08:00
} 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("");
2026-02-01 01:18:16 +08:00
// 标题相关
const titles = ref([]);
const currentTitle = ref(null);
const titlePage = ref(1);
const loadingTitles = ref(false);
const hasMoreTitles = ref(true);
2026-02-01 17:20:13 +08:00
const titleState = ref({
offsetX: 0,
offsetY: 0,
scale: 1,
});
const titleStyle = computed(() => {
return {
2026-02-01 17:22:54 +08:00
transform: `translate(${titleState.value.offsetX}rpx, ${titleState.value.offsetY}rpx) scale(${titleState.value.scale})`,
2026-02-01 17:20:13 +08:00
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 = [];
};
2026-02-01 17:34:28 +08:00
// 个人信息触摸交互相关
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;
}
};
2026-02-01 17:20:13 +08:00
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) {
2026-02-01 17:22:54 +08:00
// 双指缩放+平移
2026-02-01 17:20:13 +08:00
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;
}
};
2026-02-01 01:18:16 +08:00
2026-01-15 08:43:10 +08:00
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 = [
"#000000",
2026-02-01 00:18:13 +08:00
"#ffffff",
2026-01-22 15:08:26 +08:00
"#ff3b30",
"#F5A623",
"#8B572A",
"#D0021B",
"#F8E71C",
"#7ED321",
"#4A90E2",
"#9013FE",
"#FFC0CB",
];
2026-02-01 00:18:13 +08:00
const selectedColor = ref("#000000");
const signatureColor = ref("#000000");
2026-01-15 08:43:10 +08:00
2026-01-22 09:34:08 +08:00
const fontList = [
{ name: "默认", family: "PingFang SC", url: "" },
{
2026-02-01 16:32:37 +08:00
name: "思源宋体",
family: "SontTi SC",
url: "https://file.lihailezzc.com/font/ddcd9621740449a29c329f573bc1d0c6.woff2", // 示例地址
2026-01-22 09:34:08 +08:00
},
{
2026-02-01 16:32:37 +08:00
name: "思源黑体",
family: "HeiTi SC",
url: "https://file.lihailezzc.com/font/ddcd9621740449a29c329f573bc1d0c3.woff2", // 示例地址
2026-01-22 09:34:08 +08:00
},
{
2026-02-01 16:32:37 +08:00
name: "仿宋",
family: "FangSong",
url: "https://file.lihailezzc.com/ddcd9621740449a29c329f573bc1d0c4.woff2", // 示例地址
2026-01-22 11:04:47 +08:00
},
{
2026-02-01 16:32:37 +08:00
name: "中圆",
family: "ZhongYuan",
url: "https://file.lihailezzc.com/ddcd9621740449a29c329f573bc1d0c5.woff2", // 示例地址
}
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);
2026-01-31 11:56:39 +08:00
const bubbleMaxWidth = ref(400); // 默认宽度
2026-01-22 08:55:00 +08:00
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-02-01 01:18:16 +08:00
getTemplateTitleList();
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-02-01 01:18:16 +08:00
} else if (activeTool.value === "title") {
loadMoreTitles();
2026-01-22 00:01:30 +08:00
}
});
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-02-01 01:18:16 +08:00
const getTemplateTitleList = async (isLoadMore = false) => {
if (loadingTitles.value || (!hasMoreTitles.value && isLoadMore)) return;
loadingTitles.value = true;
try {
const res = await getCardTemplateTitleList(titlePage.value);
const list = Array.isArray(res) ? res : res.list || [];
if (list.length > 0) {
if (isLoadMore) {
titles.value = [...titles.value, ...list];
} else {
titles.value = list;
if (list.length > 0 && !currentTitle.value) {
currentTitle.value = list[0];
}
}
if (typeof res.hasNext !== "undefined") {
hasMoreTitles.value = res.hasNext;
} else {
hasMoreTitles.value = list.length >= 8;
}
if (hasMoreTitles.value) {
titlePage.value++;
}
} else {
if (!isLoadMore) titles.value = [];
hasMoreTitles.value = false;
}
} catch (error) {
console.error("加载标题失败:", error);
} finally {
loadingTitles.value = false;
}
};
const loadMoreTitles = () => {
getTemplateTitleList(true);
};
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 {
2026-01-31 23:31:40 +08:00
title: "我刚做了一张祝福卡片,送给你",
2026-01-15 08:43:10 +08:00
path: "/pages/detail/index?shareToken=" + shareTokenRes.shareToken,
2026-02-01 00:14:19 +08:00
imageUrl: "/static/images/share.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-02-01 00:27:11 +08:00
{ type: "template", text: "1. 选模板", icon: "🎨", step: 1 },
2026-02-01 01:18:16 +08:00
{ type: "title", text: "2. 选标题", icon: "🧧", step: 2 },
{ type: "text", text: "3. 改文字", icon: "✍️", step: 3 },
{ type: "position", text: "4. 调位置", icon: "🎯", step: 4 },
2026-01-15 08:43:10 +08:00
];
const activeTool = ref("template");
2026-02-01 09:21:43 +08:00
const showPanel = ref(false);
const openTool = (toolType) => {
activeTool.value = toolType;
showPanel.value = true;
};
const closePanel = () => {
showPanel.value = false;
};
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-02-01 09:40:56 +08:00
closePanel();
};
const selectTitle = (title) => {
if (currentTitle.value?.id === title?.id) {
currentTitle.value = null;
} else {
currentTitle.value = title;
2026-02-01 17:20:13 +08:00
// 切换标题时重置位置和缩放
titleState.value = {
offsetX: 0,
offsetY: 0,
scale: 1,
};
2026-02-01 09:40:56 +08:00
closePanel();
}
2026-01-15 08:43:10 +08:00
};
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-02-01 01:18:16 +08:00
const id = createCard();
2026-01-27 19:35:47 +08:00
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;
});
};
2026-02-01 16:49:23 +08:00
// 辅助函数rpx 转 px (基于预览容器宽度 506rpx 对应 Canvas 540px)
const r2p = (rpx) => (rpx * 540) / 506;
try {
// 1⃣ 画背景
// ⭐ 先加载背景图
const [bgImg, avatarImg, titleImg] = await Promise.all([
loadCanvasImage(currentTemplate?.value?.imageUrl),
loadCanvasImage(userAvatar.value),
currentTitle.value ? loadCanvasImage(currentTitle.value.imageUrl) : Promise.resolve(null),
]);
ctx.drawImage(bgImg, 0, 0, W, H);
// 2⃣ 半透明遮罩
ctx.fillStyle = "rgba(0,0,0,0.08)";
ctx.fillRect(0, 0, W, H);
// 3⃣ 标题图片
if (titleImg) {
const previewBaseWidth = 400; // rpx
2026-02-01 17:20:13 +08:00
const drawWidth = r2p(previewBaseWidth) * titleState.value.scale;
2026-02-01 16:49:23 +08:00
const drawHeight = (titleImg.height / titleImg.width) * drawWidth;
2026-02-01 17:20:13 +08:00
2026-02-01 17:22:54 +08:00
// 计算绘制起点:居中 + 偏移量
const titleX = (W - drawWidth) / 2 + r2p(titleState.value.offsetX);
const titleY = r2p(40) + r2p(titleState.value.offsetY);
2026-02-01 17:20:13 +08:00
2026-02-01 17:22:54 +08:00
ctx.drawImage(titleImg, titleX, titleY, drawWidth, drawHeight);
2026-02-01 16:49:23 +08:00
}
// 4⃣ 祝福语气泡
// 预览中 .bubble 有 padding: 40rpx且 .card-overlay 有 padding: 30rpx
// 意味着文字距离容器边缘至少有 70rpx
drawBubbleText(ctx, {
text: targetName.value + "\n " + blessingText.value.content,
x: 0,
y: r2p(230 + bubbleOffsetY.value),
maxWidth: r2p(bubbleMaxWidth.value), // 预览中 bubble-text 的宽度
canvasWidth: W,
fontSize: r2p(fontSize.value),
lineHeight: r2p(fontSize.value * 1.6), // 预览中是 1.6
padding: r2p(40 + 30), // 内部 padding 40 + 容器 padding 30
backgroundColor: "transparent",
textColor: selectedColor.value,
fontFamily: selectedFont.value.family,
});
// 5⃣ 用户信息
// 预览中 user 是 absolute, left: 160 + offsetX, bottom: 40 - offsetY
drawUserBubble(ctx, {
x: r2p(160 + userOffsetX.value),
bottom: r2p(40 - userOffsetY.value),
canvasHeight: H,
avatarImg: avatarImg,
username: signatureName.value,
desc: "送上祝福",
textColor: signatureColor.value,
avatarSize: r2p(64),
padding: r2p(15),
fontSizeName: r2p(24),
fontSizeDesc: r2p(20),
});
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",
2026-01-31 11:56:39 +08:00
canvasWidth, // 新增:画布宽度,用于居中计算
2026-01-15 08:43:10 +08:00
} = 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
2026-01-31 11:56:39 +08:00
// 计算实际最大宽度以支持居中
let maxLineWidth = 0;
lines.forEach((line) => {
const { width } = ctx.measureText(line);
if (width > maxLineWidth) maxLineWidth = width;
});
// 如果提供了 canvasWidth则自动计算居中的 x 坐标
2026-02-01 16:49:23 +08:00
// 预览中文字是左对齐,但整个气泡容器是水平居中的
2026-01-31 11:56:39 +08:00
let drawX = x;
if (canvasWidth) {
2026-02-01 16:49:23 +08:00
drawX = (canvasWidth - maxLineWidth) / 2;
2026-01-31 11:56:39 +08:00
}
2026-01-15 08:43:10 +08:00
// 4⃣ 绘制文字
2026-01-22 11:04:47 +08:00
ctx.fillStyle = textColor;
2026-01-15 08:43:10 +08:00
lines.forEach((line, index) => {
2026-02-01 16:49:23 +08:00
ctx.fillText(line, drawX, y + padding + index * lineHeight);
2026-01-15 08:43:10 +08:00
});
}
function drawUserBubble(ctx, options) {
const {
x = 40, // 气泡起点 x
2026-02-01 16:49:23 +08:00
y, // 气泡起点 y (如果传了 bottom 则优先计算)
bottom, // 距离底部的距离
canvasHeight,
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)",
2026-02-01 00:18:13 +08:00
textColor = "#000000",
2026-01-15 08:43:10 +08:00
} = 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;
2026-02-01 16:49:23 +08:00
// 计算 y
let drawY = y;
if (typeof bottom !== "undefined" && canvasHeight) {
drawY = canvasHeight - bottom - bubbleHeight;
}
2026-01-15 08:43:10 +08:00
// 1⃣ 绘制气泡(左右半圆)
2026-01-09 11:24:40 +08:00
drawRoundRect(
ctx,
x,
2026-02-01 16:49:23 +08:00
drawY,
2026-01-09 11:24:40 +08:00
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;
2026-02-01 16:49:23 +08:00
const avatarY = drawY + (bubbleHeight - avatarSize) / 2;
2026-01-15 08:43:10 +08:00
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;
2026-02-01 16:49:23 +08:00
const textY = drawY + 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();
2026-02-01 09:21:43 +08:00
ctx.fillStyle = color || "rgba(255,255,255,0.18)";
2026-01-15 08:43:10 +08:00
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();
2026-01-09 11:24:40 +08:00
}
</script>
<style lang="scss" scoped>
.make-page {
min-height: 100vh;
background: #fff;
box-sizing: border-box;
}
2026-02-01 09:21:43 +08:00
/* 顶部提示 */
.tip-line {
text-align: center;
color: #999;
font-size: 22rpx;
margin-bottom: 140rpx; /* 为底部固定按钮留出空间 */
}
2026-02-01 17:34:28 +08:00
.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;
}
2026-01-09 11:24:40 +08:00
/* 卡片预览 */
.card-preview {
2026-02-01 09:21:43 +08:00
margin: 30rpx auto 20rpx;
height: 900rpx;
width: 506rpx; /* 保持 9:16 比例并稍微缩小一点 */
2026-01-09 11:24:40 +08:00
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
}
2026-02-01 17:38:40 +08:00
.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);
}
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;
}
2026-01-31 11:33:22 +08:00
.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;
}
2026-01-09 11:24:40 +08:00
.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
}
2026-02-01 09:21:43 +08:00
/* 顶部步骤条 */
.top-steps {
2026-01-09 11:24:40 +08:00
background: #fff;
2026-02-01 09:21:43 +08:00
padding: 10rpx 0;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.05);
position: relative;
z-index: 10;
2026-01-09 11:24:40 +08:00
}
2026-02-01 00:27:11 +08:00
.step-bar {
display: flex;
justify-content: space-between;
2026-02-01 09:21:43 +08:00
padding: 10rpx 40rpx;
2026-02-01 00:27:11 +08:00
position: relative;
2026-01-09 11:24:40 +08:00
}
2026-02-01 09:21:43 +08:00
/* 底部固定按钮 */
.bottom-actions-fixed {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: #fff;
padding-bottom: env(safe-area-inset-bottom);
z-index: 90;
box-shadow: 0 -10rpx 30rpx rgba(0, 0, 0, 0.05);
}
/* 弹出编辑面板 */
.panel-container {
position: fixed;
inset: 0;
z-index: 100;
visibility: hidden;
pointer-events: none;
transition: all 0.3s;
}
.panel-container.show {
visibility: visible;
pointer-events: auto;
}
.panel-mask {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.4);
opacity: 0;
transition: opacity 0.3s;
}
.panel-container.show .panel-mask {
opacity: 1;
}
.panel-content {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: #fff;
border-radius: 40rpx 40rpx 0 0;
padding: 30rpx 40rpx calc(40rpx + env(safe-area-inset-bottom));
transform: translateY(100%);
transition: transform 0.3s cubic-bezier(0.25, 1, 0.5, 1);
max-height: 80vh;
overflow-y: auto;
}
.panel-container.show .panel-content {
transform: translateY(0);
}
.panel-content.glass-effect {
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(20rpx);
-webkit-backdrop-filter: blur(20rpx);
}
.panel-handle {
width: 80rpx;
height: 8rpx;
background: #ddd;
border-radius: 4rpx;
margin: 0 auto 30rpx;
}
2026-02-01 00:27:11 +08:00
.step-item {
2026-01-09 11:24:40 +08:00
display: flex;
flex-direction: column;
align-items: center;
2026-02-01 00:27:11 +08:00
flex: 1;
position: relative;
z-index: 1;
2026-01-09 11:24:40 +08:00
}
2026-02-01 00:27:11 +08:00
.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 {
2026-01-09 11:24:40 +08:00
color: #ff3b30;
2026-02-01 00:27:11 +08:00
font-weight: bold;
2026-01-09 11:24:40 +08:00
}
2026-02-01 00:27:11 +08:00
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
}
2026-02-01 01:18:16 +08:00
.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;
2026-02-01 17:20:13 +08:00
/* 移除 transition防止拖拽抖动 */
transition: none;
2026-02-01 01:18:16 +08:00
}
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;
}
2026-01-31 20:59:51 +08:00
.position-section{
margin-bottom: 40rpx;
}
2026-01-15 08:43:10 +08:00
.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
}
/* 按钮区 */
2026-02-01 01:18:16 +08:00
.main-actions {
2026-01-15 08:43:10 +08:00
display: flex;
align-items: center;
justify-content: center;
2026-02-01 01:18:16 +08:00
padding: 24rpx 32rpx 32rpx;
background: #fff;
position: relative;
2026-01-09 11:24:40 +08:00
}
.btn {
2026-02-01 01:18:16 +08:00
flex: 1;
2026-01-15 08:43:10 +08:00
display: flex;
align-items: center;
justify-content: center;
2026-02-01 01:18:16 +08:00
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;
2026-01-09 11:24:40 +08:00
}
2026-02-01 01:18:16 +08:00
.btn::after {
border: none;
}
.btn:active {
transform: scale(0.96);
opacity: 0.9;
2026-01-15 08:43:10 +08:00
}
2026-02-01 01:18:16 +08:00
2026-01-09 11:24:40 +08:00
.btn.secondary {
2026-02-01 01:18:16 +08:00
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;
2026-01-09 11:24:40 +08:00
}
2026-02-01 01:18:16 +08:00
2026-01-09 11:24:40 +08:00
.btn.primary {
2026-02-01 01:18:16 +08:00
background: linear-gradient(135deg, #ff6b66 0%, #ff3b30 100%);
2026-01-15 08:43:10 +08:00
color: #fff;
2026-02-01 01:18:16 +08:00
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;
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>