feat(distribution): 实现分销员中心完整功能

- 新增分销业绩统计页面,包含时间范围筛选和自定义日期选择功能
- 重构分销认证页面,改为跳转至招募页面申请分销员身份
- 添加分销商品访问记录功能,支持分享追踪和佣金计算
- 更新分销历史记录页面,优化数据结构和显示字段
- 完全重写分销员首页,包含用户信息、收益统计、等级系统等功能
This commit is contained in:
pikachu1995@126.com
2026-08-05 18:07:27 +08:00
parent 6754a54f1b
commit 7d3daf2f02
34 changed files with 10053 additions and 2530 deletions

View File

@@ -0,0 +1,459 @@
<template>
<view class="share-panel" :style="themeStyle">
<view class="tabs">
<view
class="tab-item"
:class="{ active: activeTab === 'text' }"
@click="activeTab = 'text'"
>
<text>推广图文</text>
<view v-if="activeTab === 'text'" class="tab-line"></view>
</view>
<view
class="tab-item"
:class="{ active: activeTab === 'poster' }"
@click="activeTab = 'poster'"
>
<text>生成海报</text>
<view v-if="activeTab === 'poster'" class="tab-line"></view>
</view>
</view>
<view v-if="loading" class="loading-wrap">
<text class="loading-text">加载中...</text>
</view>
<view v-else-if="activeTab === 'text'" class="content">
<view class="text-card">
<text v-if="shareContext.defaultShareText" class="share-text" :user-select="true">
{{ shareContext.defaultShareText }}
</text>
<view class="share-divider"></view>
<text class="share-text" :user-select="true">{{ shareBodyText }}</text>
<view class="share-divider"></view>
</view>
<view class="media-section">
<scroll-view scroll-x class="image-scroll" v-if="images.length">
<view class="image-list">
<image
v-for="(img, index) in images"
:key="index"
class="goods-image"
:src="img"
mode="aspectFill"
@click="previewImage(index)"
/>
</view>
</scroll-view>
<view class="save-btn-wrap">
<view class="save-btn" @click="saveShareContent">保存图文</view>
</view>
</view>
</view>
<view v-else class="content">
<poster-panel
:images="images"
:goods-name="shareContext.goodsName"
:goods-desc="posterGoodsDesc"
:price="shareContext.price"
:sku-id="skuId"
:goods-id="goodsId"
:distribution-id="distributionId"
:active="activeTab === 'poster'"
/>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useStore } from '@/store'
import config from '@/config/config'
import { getThemeStyle } from '@/utils/theme'
import { getDistributionGoodsShareContext } from '@/api/distribution'
import { unitPrice, parseGoodsImageUrl } from '@/utils/filters.js'
import PosterPanel from './poster-panel.vue'
// #ifdef H5
import { h5Copy } from '@/js_sdk/h5-copy/h5-copy.js'
// #endif
const props = defineProps<{
skuId?: string
goodsId?: string
distributionId?: string
}>()
const store = useStore()
const themeStyle = computed(() => getThemeStyle(store.state.theme))
const activeTab = ref<'text' | 'poster'>('text')
const loading = ref(true)
const shareContext = ref<Record<string, any>>({})
const posterGoodsDesc = computed(() => {
const ctx = shareContext.value
return ctx.goodsSellingPoint || ctx.defaultShareText || ''
})
const images = computed(() => {
const raw: string[] = []
const list = shareContext.value.images
if (Array.isArray(list) && list.length) {
raw.push(...list)
} else if (shareContext.value.thumbnail) {
raw.push(shareContext.value.thumbnail)
}
const parsed = raw
.map((item) => parseGoodsImageUrl(item))
.filter((url) => !!url)
return [...new Set(parsed)]
})
const shareLink = computed(() => {
const link = shareContext.value.miniProgramLink || shareContext.value.sharePath || ''
if (!link) return ''
if (link.startsWith('http')) return link
const base = (config.shareLink || '').replace(/\/$/, '')
return base + link
})
const SHARE_DIVIDER = '- '.repeat(36).trim()
const shareBodyText = computed(() => {
const ctx = shareContext.value
const lines: string[] = []
if (ctx.goodsName) {
lines.push(`🔴 ${ctx.goodsName}`)
}
if (ctx.price !== null && ctx.price !== undefined) {
lines.push(`【到手价】💰 ${unitPrice(ctx.price, '¥')}`)
}
if (shareLink.value) {
lines.push(`点击立即参与:${shareLink.value}`)
}
return lines.join('\n')
})
const shareText = computed(() => {
const ctx = shareContext.value
const lines: string[] = []
if (ctx.defaultShareText) {
lines.push(ctx.defaultShareText)
lines.push('')
}
lines.push(SHARE_DIVIDER)
if (shareBodyText.value) {
lines.push(shareBodyText.value)
}
lines.push(SHARE_DIVIDER)
return lines.join('\n')
})
watch(
() => [props.skuId, props.goodsId, props.distributionId],
() => {
loadShareContext()
},
{ immediate: true }
)
function loadShareContext() {
const { skuId, goodsId, distributionId } = props
if (!skuId || !goodsId || !distributionId) {
loading.value = false
shareContext.value = {}
return
}
loading.value = true
getDistributionGoodsShareContext({ skuId, goodsId, distributionId })
.then((res) => {
if (res.data?.success) {
shareContext.value = res.data.result || {}
} else {
uni.showToast({ title: res.data?.message || '加载失败', icon: 'none' })
}
})
.catch(() => {
uni.showToast({ title: '加载分享内容失败', icon: 'none' })
})
.finally(() => {
loading.value = false
})
}
function previewImage(index: number) {
if (!images.value.length) return
uni.previewImage({
current: images.value[index],
urls: images.value,
})
}
function isClipboardBlocked(errMsg = '') {
return (
errMsg.includes('privacy agreement') ||
errMsg.includes('no permission') ||
errMsg.includes('auth deny')
)
}
function saveShareContent() {
const text = shareText.value
if (!text.trim()) {
uni.showToast({ title: '暂无可保存内容', icon: 'none' })
return
}
// #ifdef H5
if (h5Copy(text) === false) {
uni.showToast({ title: '复制失败,请重试', icon: 'none' })
return
}
continueAfterCopy()
// #endif
// #ifndef H5
const runCopy = () => {
uni.setClipboardData({
data: text,
showToast: false,
success: () => continueAfterCopy(),
fail: (err: any) => {
console.error('setClipboardData fail', err)
const errMsg = err?.errMsg || ''
if (isClipboardBlocked(errMsg)) {
continueAfterCopy()
return
}
uni.showToast({ title: '复制失败,请重试', icon: 'none' })
},
})
}
// #ifdef MP-WEIXIN
const wxApi = (globalThis as any).wx
if (wxApi?.requirePrivacyAuthorize) {
wxApi.requirePrivacyAuthorize({
success: () => runCopy(),
fail: () => continueAfterCopy(),
})
return
}
// #endif
runCopy()
// #endif
}
function continueAfterCopy() {
const imageUrls = images.value
if (!imageUrls.length) {
uni.showToast({ title: '内容已复制', icon: 'none' })
return
}
uni.showLoading({ title: '保存中', mask: true })
saveImages(imageUrls)
.then(() => {
uni.hideLoading()
uni.showToast({ title: '内容已复制', icon: 'none' })
})
.catch((error) => {
uni.hideLoading()
uni.showToast({ title: '图片保存失败', icon: 'none' })
console.error('save images failed', error)
})
}
function saveToAlbum(filePath: string): Promise<void> {
return new Promise((resolve, reject) => {
uni.saveImageToPhotosAlbum({
filePath,
success: () => resolve(),
fail: (err: any) => {
const errMsg = err?.errMsg || ''
const needAuth =
errMsg.includes('auth deny') ||
errMsg.includes('authorize') ||
errMsg.includes('permission')
// #ifdef MP-WEIXIN
if (!needAuth) {
reject(new Error('save failed'))
return
}
uni.showModal({
title: '提示',
content: '需要您授权保存图片到相册',
confirmText: '去设置',
success: (modalRes) => {
if (!modalRes.confirm) {
reject(new Error('auth denied'))
return
}
uni.openSetting({
success: (settingRes) => {
if (settingRes.authSetting['scope.writePhotosAlbum']) {
saveToAlbum(filePath).then(resolve).catch(reject)
} else {
reject(new Error('auth denied'))
}
},
fail: () => reject(new Error('auth denied')),
})
},
fail: () => reject(new Error('auth denied')),
})
// #endif
// #ifndef MP-WEIXIN
reject(new Error('save failed'))
// #endif
},
})
})
}
function saveImages(urls: string[]) {
return urls.reduce<Promise<void>>((chain, url) => {
return chain.then(() => downloadAndSaveImage(url))
}, Promise.resolve())
}
function downloadAndSaveImage(url: string) {
if (!url || !/^https?:\/\//.test(url)) {
return Promise.reject(new Error('invalid url'))
}
return new Promise<void>((resolve, reject) => {
uni.downloadFile({
url,
success: (res) => {
if (res.statusCode !== 200 || !res.tempFilePath) {
reject(new Error('download failed'))
return
}
saveToAlbum(res.tempFilePath).then(resolve).catch(reject)
},
fail: () => reject(new Error('download failed')),
})
})
}
</script>
<style lang="scss" scoped>
.share-panel {
min-height: 100%;
background: #fff;
}
.tabs {
display: flex;
align-items: center;
justify-content: center;
padding: 24rpx 32rpx 0;
border-bottom: 1rpx solid #f0f0f0;
}
.tab-item {
position: relative;
flex: 1;
text-align: center;
padding-bottom: 20rpx;
font-size: 30rpx;
color: #999;
&.active {
color: #333;
font-weight: 600;
}
}
.tab-line {
position: absolute;
left: 50%;
bottom: 0;
transform: translateX(-50%);
width: 56rpx;
height: 6rpx;
border-radius: 6rpx;
background: #ff8f3f;
}
.loading-wrap {
padding: 120rpx 0;
text-align: center;
}
.loading-text {
color: #999;
font-size: 28rpx;
}
.content {
padding: 24rpx 24rpx 48rpx;
}
.text-card {
background: #f7f7f7;
border-radius: 16rpx;
padding: 28rpx;
}
.share-divider {
width: 100%;
height: 0;
margin: 20rpx 0;
border-top: 2rpx dashed #c8c8c8;
}
.share-text {
font-size: 28rpx;
line-height: 1.7;
color: #333;
white-space: pre-wrap;
word-break: break-all;
user-select: text;
-webkit-user-select: text;
}
.media-section {
margin-top: 24rpx;
}
.image-scroll {
width: 100%;
}
.image-list {
display: flex;
flex-direction: row;
white-space: nowrap;
}
.goods-image {
width: 200rpx;
height: 200rpx;
border-radius: 12rpx;
margin-right: 16rpx;
flex-shrink: 0;
background: #f5f5f5;
}
.save-btn-wrap {
display: flex;
justify-content: flex-end;
margin-top: 24rpx;
}
.save-btn {
min-width: 140rpx;
height: 60rpx;
line-height: 60rpx;
text-align: center;
padding: 0 20rpx;
border-radius: 30rpx;
background: linear-gradient(90deg, #ff9f3f, #ff6b35);
color: #fff;
font-size: 26rpx;
font-weight: 400;
box-shadow: 0 6rpx 16rpx rgba(255, 107, 53, 0.2);
}
</style>

View File

@@ -0,0 +1,539 @@
<template>
<view class="poster-panel">
<view class="poster-hint">点击海报预览后可保存到相册</view>
<view v-if="loading" class="loading-wrap">
<text class="loading-text">海报生成中...</text>
</view>
<view v-else-if="loadError" class="loading-wrap">
<text class="loading-text">{{ loadError }}</text>
<view class="retry-btn" @click="generatePosters">重新生成</view>
</view>
<view v-else-if="!images.length" class="loading-wrap">
<text class="loading-text">暂无商品图片</text>
</view>
<template v-else-if="posterList.length">
<view class="poster-content">
<swiper
class="poster-swiper"
:current="current"
circular
@change="onSwiperChange"
>
<swiper-item v-for="(item, index) in posterList" :key="index">
<view class="poster-card-wrap" @click="previewPoster(index)">
<image class="poster-image" :src="item" mode="widthFix" />
</view>
</swiper-item>
</swiper>
<view v-if="posterList.length > 1" class="poster-dots">
<view
v-for="(_, index) in posterList"
:key="index"
:class="['dot', current === index && 'active']"
/>
</view>
<view class="save-btn-wrap">
<view class="save-btn" @click="saveCurrentPoster">保存海报</view>
</view>
</view>
</template>
<view class="canvas-hide">
<!-- #ifdef MP-WEIXIN -->
<canvas id="distributionPosterCanvas" type="2d" style="width: 600px; height: 840px" />
<!-- #endif -->
<!-- #ifndef MP-WEIXIN -->
<canvas
canvas-id="distributionPosterCanvas"
id="distributionPosterCanvas"
style="width: 600px; height: 840px"
/>
<!-- #endif -->
</view>
</view>
</template>
<script setup lang="ts">
import { ref, watch, getCurrentInstance, nextTick } from 'vue'
import DrawPoster from '@/js_sdk/u-draw-poster'
import { getDistributionGoodsPosterContext } from '@/api/distribution'
import { unitPrice } from '@/utils/filters.js'
const CANVAS_SELECTOR = 'distributionPosterCanvas'
const POSTER_WIDTH = 600
const IMAGE_HEIGHT = POSTER_WIDTH
const BOTTOM_HEIGHT = 240
const POSTER_HEIGHT = IMAGE_HEIGHT + BOTTOM_HEIGHT
const QR_SIZE = 120
const QR_MARGIN_RIGHT = 28
const QR_MARGIN_BOTTOM = 28
const GENERATE_TIMEOUT = 45000
const props = defineProps<{
images?: string[]
goodsName?: string
goodsDesc?: string
price?: number | null
skuId?: string
goodsId?: string
distributionId?: string
active?: boolean
}>()
const instance = getCurrentInstance()
const posterList = ref<string[]>([])
const current = ref(0)
const loading = ref(false)
const loadError = ref('')
const hasGenerated = ref(false)
let dp: any = null
// #ifdef MP-WEIXIN
const st2 = (size: number) => size * 2
// #endif
// #ifndef MP-WEIXIN
const st2 = (size: number) => size
// #endif
watch(
() => [props.images?.length, props.skuId, props.goodsId, props.distributionId],
() => {
resetPosters()
if (props.active && props.images?.length) {
generatePosters()
}
}
)
watch(
() => props.active,
(active) => {
if (active && !hasGenerated.value && props.images?.length && !loading.value) {
generatePosters()
}
},
{ immediate: true }
)
function resetPosters() {
hasGenerated.value = false
posterList.value = []
loadError.value = ''
clearDrawPosterCache()
}
function clearDrawPosterCache() {
const pages = getCurrentPages()
const page = pages[pages.length - 1] as any
if (!page) return
delete page[`#${CANVAS_SELECTOR}__dp`]
delete page[`${CANVAS_SELECTOR}__dp`]
}
function onSwiperChange(e: any) {
current.value = e.detail?.current ?? 0
}
function previewPoster(index: number) {
if (!posterList.value.length) return
uni.previewImage({
current: posterList.value[index],
urls: posterList.value,
})
}
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function withTimeout<T>(promise: Promise<T>, ms: number, message: string) {
return Promise.race([
promise,
new Promise<T>((_, reject) => {
setTimeout(() => reject(new Error(message)), ms)
}),
])
}
function getComponentThis() {
return instance?.proxy || instance
}
async function waitCanvasReady() {
await nextTick()
await sleep(150)
}
async function resolveImagePath(url: string) {
if (!url) {
throw new Error('商品图片地址无效')
}
if (
url.startsWith('data:') ||
url.startsWith('wxfile://') ||
url.startsWith('http://tmp/') ||
url.startsWith('https://tmp/')
) {
return url
}
const res = await uni.downloadFile({ url })
if (res.statusCode === 200 && res.tempFilePath) {
return res.tempFilePath
}
throw new Error('商品图片下载失败,请检查图片域名配置')
}
function getImageInfo(src: string): Promise<{ width: number; height: number }> {
return new Promise((resolve, reject) => {
uni.getImageInfo({
src,
success: (res) => resolve({ width: res.width, height: res.height }),
fail: reject,
})
})
}
function calcContainBox(
imgW: number,
imgH: number,
boxX: number,
boxY: number,
boxW: number,
boxH: number
) {
const scale = Math.min(boxW / imgW, boxH / imgH)
const width = imgW * scale
const height = imgH * scale
return {
x: boxX + (boxW - width) / 2,
y: boxY + (boxH - height) / 2,
width,
height,
}
}
async function generatePosters() {
const { skuId, goodsId, distributionId, images } = props
if (!skuId || !goodsId || !distributionId || !images?.length) {
return
}
if (loading.value) {
return
}
// #ifndef MP-WEIXIN
loadError.value = '请在微信小程序中生成海报'
return
// #endif
loading.value = true
loadError.value = ''
posterList.value = []
clearDrawPosterCache()
try {
await withTimeout(runGeneratePosters(images, skuId, goodsId, distributionId), GENERATE_TIMEOUT, '海报生成超时,请重试')
hasGenerated.value = true
} catch (error: any) {
console.error('generate posters failed', error)
loadError.value = error?.message || '海报生成失败,请稍后重试'
clearDrawPosterCache()
} finally {
loading.value = false
}
}
async function runGeneratePosters(
images: string[],
skuId: string,
goodsId: string,
distributionId: string
) {
const posterRes = await getDistributionGoodsPosterContext({ skuId, goodsId, distributionId })
if (!posterRes.data?.success || !posterRes.data?.result?.qrCode) {
throw new Error(posterRes.data?.message || '葵花码生成失败')
}
const qrCode = `data:image/png;base64,${posterRes.data.result.qrCode}`
await waitCanvasReady()
dp = await DrawPoster.build({
selector: CANVAS_SELECTOR,
componentThis: getComponentThis(),
loading: false,
debugging: false,
})
if (!dp?.canvas || !dp?.ctx) {
throw new Error('画布初始化失败,请重试')
}
// #ifdef MP-WEIXIN
dp.canvas.width = st2(POSTER_WIDTH)
dp.canvas.height = st2(POSTER_HEIGHT)
// #endif
const results: string[] = []
for (const image of images) {
const localImage = await resolveImagePath(image)
results.push(await drawPoster(dp, localImage, qrCode))
}
posterList.value = results
}
async function drawPoster(dpInst: any, productImg: string, qrCode: string) {
const goodsName = props.goodsName || '商品推荐'
const goodsDesc = resolveGoodsDesc()
const priceText =
props.price !== null && props.price !== undefined ? unitPrice(props.price, '¥') : ''
await dpInst.draw((ctx: any) => {
ctx.clearRect(st2(0), st2(0), st2(POSTER_WIDTH), st2(POSTER_HEIGHT))
ctx.fillStyle = '#ffffff'
ctx.fillRoundRect(st2(0), st2(0), st2(POSTER_WIDTH), st2(POSTER_HEIGHT), st2(20))
ctx.clip()
})
await dpInst.draw(async (ctx: any) => {
ctx.fillStyle = '#f7f7f7'
ctx.fillRect(st2(0), st2(0), st2(POSTER_WIDTH), st2(IMAGE_HEIGHT))
const { width: imgW, height: imgH } = await getImageInfo(productImg)
const fit = calcContainBox(imgW, imgH, 0, 0, POSTER_WIDTH, IMAGE_HEIGHT)
await ctx.drawImage(
productImg,
st2(fit.x),
st2(fit.y),
st2(fit.width),
st2(fit.height)
)
})
await dpInst.draw(async (ctx: any) => {
ctx.fillStyle = '#ffffff'
ctx.fillRect(st2(0), st2(IMAGE_HEIGHT), st2(POSTER_WIDTH), st2(POSTER_HEIGHT - IMAGE_HEIGHT))
const qrX = POSTER_WIDTH - QR_SIZE - QR_MARGIN_RIGHT
const qrY = POSTER_HEIGHT - QR_SIZE - QR_MARGIN_BOTTOM
ctx.save()
ctx.beginPath()
ctx.arc(
st2(qrX + QR_SIZE / 2),
st2(qrY + QR_SIZE / 2),
st2(QR_SIZE / 2),
0,
Math.PI * 2
)
ctx.clip()
await ctx.drawImage(qrCode, st2(qrX), st2(qrY), st2(QR_SIZE), st2(QR_SIZE))
ctx.restore()
})
await dpInst.draw((ctx: any) => {
const textLeft = 24
const textMaxWidth = POSTER_WIDTH - QR_SIZE - QR_MARGIN_RIGHT - textLeft - 20
const bottomTop = IMAGE_HEIGHT
const bottomBottom = POSTER_HEIGHT - 16
const qrY = POSTER_HEIGHT - QR_SIZE - QR_MARGIN_BOTTOM
const nameLineHeight = 36
const descLineHeight = 28
const priceLineHeight = 36
const textGap = 16
const nameLineCount = Math.min(2, Math.max(1, Math.ceil(goodsName.length / 16)))
const descLineCount = goodsDesc ? Math.min(2, Math.max(1, Math.ceil(goodsDesc.length / 14))) : 0
let textBlockHeight = nameLineCount * nameLineHeight
if (goodsDesc) {
textBlockHeight += textGap + descLineCount * descLineHeight
}
if (priceText) {
textBlockHeight += textGap + priceLineHeight
}
const maxTextHeight = bottomBottom - bottomTop - 12
if (textBlockHeight > maxTextHeight) {
textBlockHeight = maxTextHeight
}
let textY = qrY + (QR_SIZE - textBlockHeight) / 2
textY = Math.max(bottomTop + 12, textY)
if (textY + textBlockHeight > bottomBottom) {
textY = bottomBottom - textBlockHeight
}
ctx.textAlign = 'left'
ctx.textBaseline = 'top'
ctx.fillStyle = '#333333'
ctx.font = `${st2(28)}px PingFang SC`
ctx.fillWarpText({
text: goodsName,
maxWidth: st2(textMaxWidth),
x: st2(textLeft),
y: st2(textY),
layer: 2,
lineHeight: st2(nameLineHeight),
})
textY += nameLineCount * nameLineHeight
if (goodsDesc) {
textY += textGap
ctx.fillStyle = '#999999'
ctx.font = `${st2(22)}px PingFang SC`
ctx.fillWarpText({
text: goodsDesc,
maxWidth: st2(textMaxWidth),
x: st2(textLeft),
y: st2(textY),
layer: 2,
lineHeight: st2(descLineHeight),
})
textY += descLineCount * descLineHeight
}
if (priceText) {
textY += textGap
const priceY = Math.min(textY, bottomBottom - priceLineHeight)
ctx.fillStyle = '#ff3c2a'
ctx.font = `bold ${st2(36)}px PingFang SC`
ctx.fillText(priceText, st2(textLeft), st2(priceY))
}
})
const path = await dpInst.createImagePath()
if (!path || path === '---stop createImagePath---') {
throw new Error('海报图片导出失败')
}
return path
}
function resolveGoodsDesc() {
const desc = (props.goodsDesc || '').trim()
const name = (props.goodsName || '').trim()
if (!desc || desc === name) return ''
return desc.length > 28 ? `${desc.slice(0, 28)}...` : desc
}
function saveCurrentPoster() {
const filePath = posterList.value[current.value]
if (!filePath) return
uni.saveImageToPhotosAlbum({
filePath,
success: () => {
uni.showToast({ title: '已保存到相册', icon: 'none' })
},
fail: (err: any) => {
const errMsg = err?.errMsg || ''
if (errMsg.includes('auth deny') || errMsg.includes('authorize')) {
uni.showModal({
title: '提示',
content: '需要您授权保存图片到相册',
confirmText: '去设置',
success: (modalRes) => {
if (modalRes.confirm) {
uni.openSetting({})
}
},
})
return
}
uni.showToast({ title: '保存失败', icon: 'none' })
},
})
}
</script>
<style lang="scss" scoped>
.poster-panel {
padding: 8rpx 0 48rpx;
}
.poster-hint {
text-align: center;
font-size: 26rpx;
color: #b3b3b3;
margin-bottom: 40rpx;
}
.poster-content {
margin-top: 8rpx;
}
.loading-wrap {
padding: 120rpx 0;
text-align: center;
}
.loading-text {
color: #999;
font-size: 28rpx;
}
.retry-btn,
.save-btn {
display: inline-block;
padding: 12rpx 40rpx;
border-radius: 30rpx;
background: linear-gradient(90deg, #ff9f3f, #ff6b35);
color: #fff;
font-size: 26rpx;
}
.poster-swiper {
height: 1000rpx;
}
.poster-card-wrap {
display: flex;
justify-content: center;
align-items: flex-start;
padding: 0 48rpx;
}
.poster-image {
width: 100%;
max-width: 620rpx;
border-radius: 20rpx;
box-shadow: 0 12rpx 40rpx rgba(0, 0, 0, 0.08);
}
.poster-dots {
display: flex;
justify-content: center;
align-items: center;
gap: 12rpx;
margin-top: 24rpx;
}
.dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
background: #e0e0e0;
&.active {
width: 24rpx;
border-radius: 8rpx;
background: #ff8f3f;
}
}
.save-btn-wrap {
display: flex;
justify-content: center;
margin-top: 28rpx;
}
.canvas-hide {
position: fixed;
left: -9999px;
top: 0;
width: 600px;
height: 840px;
opacity: 0;
pointer-events: none;
}
</style>