2077 lines
53 KiB
Vue
2077 lines
53 KiB
Vue
<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) => {
|
||
if (!isLoggedIn.value) {
|
||
const shareToken = await getShareToken("card_generate_not_login", "");
|
||
return {
|
||
title: "快来制作新春祝福卡片🎉",
|
||
path: "/pages/make/index?shareToken=" + shareToken,
|
||
imageUrl:
|
||
"https://file.lihailezzc.com/resource/8dd026d76ef7a63d123b7fd698fb989b.png",
|
||
};
|
||
}
|
||
|
||
getShareReward({ scene: "card_generate" });
|
||
if (options.from === "button") {
|
||
// 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;
|
||
|
||
// 缩放上下文,使得后续绘制指令依然可以使用逻辑坐标 (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 {
|
||
// 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️⃣ 输出
|
||
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>
|