Files
ece.suwa3d.com/src/views/cartoon/index.vue
T

1286 lines
33 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="photo-upload-page">
<div class="badge-size">
<div class="order-type">
<div class="order-type-item" v-if="orderStat.order_no">
<div class="order-type-item-title">
{{ toValueWithout("订单编号") }}
</div>
<div class="order-type-item-title">
{{ orderStat.order_no }}
</div>
</div>
<div class="order-type-item" v-if="prodId">
<div class="order-type-item-title">
{{ toValueWithout("产品类型") }}
</div>
<div class="order-type-item-title" v-if="typeName">
{{ toValueWithout(typeName) }}
</div>
</div>
<div class="order-type-item" v-if="prodId">
<div class="order-type-item-title">
{{ toValueWithout("主体类型") }}
</div>
<div class="order-type-item-title" v-if="subjectName">
{{ toValueWithout(subjectName) }}
</div>
</div>
</div>
<div class="size-title">
{{ toValueWithout("剩余兑换数量") }}
</div>
<div class="size-options">
<div class="size-item" v-for="item in sizeList" :key="item.id">
<div class="size-text">
{{ item.size }}
</div>
<div class="size-count">
({{ toValueWithout("剩余兑换") }}:{{ item.remaining }})
</div>
</div>
</div>
</div>
<div class="badge-info">
<div class="badge-item" @click="goToRecord">
<div class="badge-title">
{{ toValueWithout("设计图集") }}
</div>
<div class="badge-count">
{{ orderStat.create_count || 0 }}{{ toValueWithout("张") }}
</div>
</div>
<div class="badge-item" @click="goToMyOrder">
<div class="badge-title">
{{ toValueWithout("我的订单") }}
</div>
<div class="badge-count">
{{ orderStat.order_count || 0 }}{{ toValueWithout("笔") }}
</div>
</div>
</div>
<div :style="`height: ${config.styles.spacing.itemGap};background: ${config.styles.colors.backgroundSecondary};`"></div>
<div class="step-container">
<div class="step-item active">
<div class="step-num">
1
</div>
<div class="step-content">
<div class="step-title">
{{ toValueWithout("上传正面照片") }}
</div>
<div class="step-desc">
{{ toValueWithout("1张五官清晰的正面照片") }}
</div>
</div>
</div>
<div class="step-item">
<div class="step-num">
2
</div>
<div class="step-content">
<div class="step-title">
{{ toValueWithout("确认下单") }}
</div>
<div class="step-desc">
{{ toValueWithout("选择一个你喜欢的设计") }}
</div>
</div>
</div>
</div>
<div v-if="shouldShowKindList">
<div :style="`font-size: 16px; color: ${config.styles.colors.textPrimary}; font-weight: bold; margin-top: ${config.styles.spacing.sectionMargin};padding: 0 ${config.styles.spacing.pagePadding};`">
{{ toValueWithout("风格类型") }}
</div>
<div class="kind-box">
<div class="kind-box-item" v-for="item in kindList" :key="item.id" :class="{ styleActive: kindId == item.id }">
<div class="kind-item-title" @click="kindChange(item)">
{{ toValueWithout(item.name) }}
</div>
</div>
</div>
</div>
<div v-if="typeId == 11 && count_prop > 0">
<div :style="`font-size: 16px; color: ${config.styles.colors.textPrimary}; font-weight: bold; margin-top: ${config.styles.spacing.sectionMargin};padding: 0 ${config.styles.spacing.pagePadding};`">
{{ toValueWithout("相框标题") }}
</div>
<div :style="`padding: 10px ${config.styles.spacing.pagePadding};`">
<el-input type="text" v-model="scene_prop" :placeholder="toValueWithout('请输入相框标题')" @input="scenePropInputText" />
<div class="photo-upload-tip" style="color: red;">
*{{ toValueWithout("相框标题将用于生成图片,请输入简洁明了的相框标题") }}
</div>
</div>
</div>
<div v-if="isViews == 1">
<div :style="`font-size: 16px; color: ${config.styles.colors.textPrimary}; font-weight: bold; margin-top: ${config.styles.spacing.sectionMargin};padding: 0 ${config.styles.spacing.pagePadding};`">
{{ toValueWithout("多视角参考图") }}
</div>
<div :style="`font-size: 12px; color: ${config.styles.colors.textTertiary}; margin-top: ${config.styles.spacing.itemGap};padding: 0 ${config.styles.spacing.pagePadding};`">
{{ toValueWithout("AI建模具有随机性,上传参考图有助于更好的还原主体特征") }}
</div>
<div class="photo-upload-box-refer">
<van-uploader
v-model="referPicture"
multiple
:max-count="config.upload.maxReferCount"
:max-size="config.upload.maxSize"
@oversize="onOversize"
:deletable="true"
:show-preview="true"
:before-read="beforeRead"
upload-icon="plus" />
</div>
</div>
<div v-if="typeId == 8 && mountsList.length > 0">
<div :style="`font-size: 16px; color: ${config.styles.colors.textPrimary}; font-weight: bold; margin-top: ${config.styles.spacing.sectionMargin};padding: 0 ${config.styles.spacing.pagePadding};`">
{{ toValueWithout("坐骑类型") }}
</div>
<div class="mounts-list-container" :style="`padding: 0 ${config.styles.spacing.pagePadding};`">
<div v-for="item in mountsList" :key="item.id" class="mounts-item" @click="rideChange(item)">
<div class="mounts-item-list">
<img
class="mounts-item-image"
:src="item.image"
:alt="toValueWithout(item.name)"
/>
<van-icon
v-if="item.id == mountsId"
class="mounts-icon"
:color="config.styles.colors.success"
name="checked"
size="18px"
/>
</div>
<div class="mounts-item-text">{{ toValueWithout(item.name) }}</div>
</div>
</div>
</div>
<div v-if="typeId == 9 && count_prop > 0">
<div :style="`font-size: 16px; color: ${config.styles.colors.textPrimary}; font-weight: bold; margin-top: ${config.styles.spacing.sectionMargin};padding: 0 ${config.styles.spacing.pagePadding};`">
{{ toValueWithout("场景道具") }}
</div>
<div :style="`padding: 10px ${config.styles.spacing.pagePadding};`">
<div :style="`margin-top: 16px;`">
<div
v-for="(_, index) in 4"
:key="index"
:style="`display: flex; align-items: center; margin-bottom: 12px;`"
>
<el-input
v-model="scenePropsInputs[index]"
:placeholder="toValueWithout('请输入场景道具')"
:style="`flex: 1; margin-right: 8px;`"
@input="updateScenePropFromInputs"
clearable
/>
</div>
</div>
</div>
</div>
<div class="photo-upload-body" @click="triggerPick">
<div v-if="!picture" class="photo-upload-box">
<div class="photo-upload-area">
<div class="photo-upload-plus">
+
</div>
<div class="photo-upload-text">
{{ toValueWithout("点击上传照片") }}
</div>
<div class="photo-upload-tip">
*{{ toValueWithout("上传照片时建议勾选[原图]") }}
</div>
</div>
<div class="photo-upload-footer">
{{ toValueWithout("请确定您对上传的照片拥有合法使用权利或已取得他人合法授权,且同意本平台分析图片信息以提供生成服务") }}
</div>
</div>
<img class="photo-upload-img" v-if="picture" :src="picture" alt="" srcset="">
<input
ref="fileInput"
type="file"
accept="image/*"
class="photo-upload-input"
@change="onFileChange"
@click.stop
>
</div>
<Teleport to="body">
<div v-if="cropping" class="ct-cropper">
<div class="ct-cropper__stage">
<VueCropper
ref="cropperRef"
:img="cropImg"
:auto-crop="true"
:auto-crop-width="9999"
:auto-crop-height="9999"
:fixed="true"
:fixed-number="config.upload.crop.ratio"
:fixed-box="false"
:center-box="false"
:can-move="true"
:can-move-box="true"
:can-scale="true"
:info="true"
:info-true="true"
:full="false"
:high="true"
:enlarge="config.upload.crop.enlarge"
:output-type="config.upload.crop.outputType"
:output-size="1"
:fill-color="config.upload.crop.fillColor"
mode="contain"
/>
</div>
<div class="ct-cropper__bar">
<button class="ct-cropper__btn" type="button" @click="onCropCancel">{{ toValueWithout("取消") }}</button>
<div class="ct-cropper__zoom">
<button class="ct-cropper__zoom-btn" type="button" aria-label="缩小" @click="onCropZoom(-1)">-</button>
<button class="ct-cropper__zoom-btn" type="button" aria-label="放大" @click="onCropZoom(1)">+</button>
</div>
<button
class="ct-cropper__btn ct-cropper__btn--ok"
type="button"
:disabled="cropConfirming"
@click="onCropConfirm"
>
{{ toValueWithout("确定") }}
</button>
</div>
</div>
</Teleport>
<div :style="`height: 120px;`"></div>
<div class="design-action-bar">
<div class="design-left">
<img class="design-leaf-icon" width="18" height="18" :src="leafIcon" alt="">
<span class="design-remaining">{{ toValueWithout("剩余") }}{{ orderStat.remain_count || 0 }}{{ toValueWithout("次") }}</span>
</div>
<button class="design-btn" @click="goToPreview">
<span>{{ toValueWithout("开始设计") }}</span>
<img class="design-arrow" :src="arrowIcon" alt="">
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { nextTick, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { VueCropper } from 'vue-cropper'
import 'vue-cropper/dist/index.css'
import { showLoadingToast, showToast, closeToast, showFailToast, showSuccessToast } from 'vant';
import type { UploaderFileListItem } from 'vant'
import * as badgeApi from '@/api/badge'
import { useTranslation } from "i18next-vue";
import * as i18n from '@/lang/utils'
import { toValueWithout } from '@/lang/utils'
import { cartoonConfig } from '@/config/cartoon'
import leafIcon from '@/assets/badge/leaf.png'
import arrowIcon from '@/assets/badge/arrow.png'
// 初始化翻译功能
const { t } = useTranslation();
i18n.set(t);
// 使用配置
const config = cartoonConfig
const referPicture = ref<UploaderFileListItem[]>([])
const router = useRouter()
const fileInput = ref<HTMLInputElement | null>(null)
const cropperRef = ref<any>(null)
const cropping = ref(false)
const cropImg = ref('')
const cropConfirming = ref(false)
const imgurl = ref('')
// 跳转设计图集
function goToRecord() {
router.push(config.routes.record)
}
// 跳转我的订单
function goToMyOrder() {
router.push(config.routes.myOrder)
}
// 获取兑换数量
const sizeList = ref([])
const getSizeList = () => {
badgeApi.getOrderPrice({}).then((res: any) => {
sizeList.value = res
})
}
// 获取首页显示的数量和类型
const orderStat = ref({})
const prodId = ref(0)
const prop = ref('')
const typeId = ref(0)
const subjectId = ref(0)
const typeName = ref('')
const subjectName = ref('')
const isViews = ref(0)
const getOrderStat = () => {
badgeApi.getOrderStat({}).then((res: any) => {
orderStat.value = res
prodId.value = res.prod_id
prop.value = res.prop
typeId.value = res.type_id
typeName.value = res.type_name
subjectId.value = res.subject_id
subjectName.value = res.subject_name
isViews.value = res.is_views
getKindList()
if (res.type_id === 8) {
mountsId.value = 0
getmountsList()
} else if (res.type_id === 9) {
scenePropsInputs.value = ['', '', '', '']
scene_prop.value = ''
}
})
}
// 计算属性:是否显示照片示例
const shouldShowPhotoExample = computed(() => {
return config.productLimits.photoExampleTypes.includes(prop.value)
})
// 计算属性:是否显示风格类型
const shouldShowKindList = computed(() => {
return kindList.value.length > 0
})
const kindList = ref([])
const getKindList = () => {
badgeApi.getKindList({
prod_id: prodId.value
}).then((res: any) => {
kindList.value = res.list
kindId.value = res.list && res.list[0]?.id
count_prop.value = res.list && res.list[0]?.count_prop
})
}
const kindId = ref(0)
const count_prop = ref(0)
const kindChange = (item: any) => {
kindId.value = item.id
count_prop.value = item.count_prop
}
// 计算字符单位:中文占2个,英文/数字/空格/符号占1个
const getCharUnits = (str: string) => {
let units = 0
for (const char of str) {
units += /[\u4e00-\u9fff\u3400-\u4dbf]/.test(char) ? 2 : 1
}
return units
}
// 限制最多 12 个字符单位:中文占2个,英文/数字/空格/符号占1个
const scenePropInputText = (val: string) => {
if (getCharUnits(val) > 18) {
let units = 0
let i = 0
for (; i < val.length; i++) {
const add = /[\u4e00-\u9fff\u3400-\u4dbf]/.test(val[i]) ? 2 : 1
if (units + add > 18) break
units += add
}
val = val.slice(0, i)
}
scene_prop.value = val
}
// 形状列表相关
const mountsList = ref<any[]>([])
const mountsId = ref(0)
// 获取图片URL
const getImageUrl = (path: string) => {
if (!path) return ''
return 'https://suwa3d-3dview.oss-cn-shanghai.aliyuncs.com/' + path
}
// 获取形状列表
const getmountsList = () => {
if (!prodId.value || !typeId.value) {
return
}
badgeApi.getMountsList({
prod_id: prodId.value,
type_id: typeId.value,
status: 1,
page: 1,
size: 1000
}).then((res: any) => {
if (res && res.list && res.list.length > 0) {
mountsList.value = res.list
// 默认选中第一个
if (!mountsId.value) {
mountsId.value = res.list[0].id
}
}
}).catch((err: any) => {
console.error('getShapeList', err)
})
}
// 切换形状
const rideChange = (item: any) => {
mountsId.value = item.id
}
const scene_prop = ref('') // 四个输入框的值用;拼接后提交接口
// 场景道具四个输入框
const scenePropsInputs = ref<string[]>(['', '', '', ''])
// 四个输入框有值时,用;拼接更新到 scene_prop
const updateScenePropFromInputs = () => {
scene_prop.value = scenePropsInputs.value
.map(v => (v || '').trim())
.filter(v => v)
.join(';')
}
const isLoading = ref(false)
// 生成图片
function goToPreview() {
if (isLoading.value) {
return
}
if (!imgurl.value) {
showToast(toValueWithout('请先上传照片'))
return
}
if (orderStat.value.remain_count <= 0) {
showToast(toValueWithout('剩余次数不足'))
return
}
isLoading.value = true
getPid()
}
const picture = ref(null)
const imageWidth = ref(0);
const imageHeight = ref(0);
let lockedScrollY = 0
function preventTouchScroll(e: TouchEvent) {
const host = document.querySelector('.ct-cropper')
if (host && e.target instanceof Node && host.contains(e.target)) return
e.preventDefault()
}
function setBodyScrollLocked(locked: boolean) {
const html = document.documentElement
const body = document.body
if (locked) {
lockedScrollY = window.scrollY || window.pageYOffset || 0
html.style.overflow = 'hidden'
body.style.overflow = 'hidden'
body.style.position = 'fixed'
body.style.top = `-${lockedScrollY}px`
body.style.left = '0'
body.style.right = '0'
body.style.width = '100%'
document.addEventListener('touchmove', preventTouchScroll, { passive: false })
} else {
html.style.overflow = ''
body.style.overflow = ''
body.style.position = ''
body.style.top = ''
body.style.left = ''
body.style.right = ''
body.style.width = ''
document.removeEventListener('touchmove', preventTouchScroll)
window.scrollTo(0, lockedScrollY)
}
}
function openCropping(dataUrl: string) {
cropImg.value = dataUrl
cropping.value = true
setBodyScrollLocked(true)
}
function closeCropping() {
cropping.value = false
cropImg.value = ''
cropConfirming.value = false
setBodyScrollLocked(false)
}
function triggerPick() {
fileInput.value?.click()
}
function base64ToBlob(base64: string) {
const arr = base64.split(',');
const mime = arr[0].match(/:(.*?);/)?.[1] || 'image/jpeg';
const bstr = atob(arr[1]);
const n = bstr.length;
const u8arr = new Uint8Array(n);
for (let i = 0; i < n; i++) {
u8arr[i] = bstr.charCodeAt(i);
}
return new Blob([u8arr], {
type: mime
});
}
function applyCroppedData(data: string) {
picture.value = data
const img = new Image()
img.src = data
img.onload = () => {
imageWidth.value = img.naturalWidth
imageHeight.value = img.naturalHeight
if (imageWidth.value < config.upload.minWidth || imageHeight.value < config.upload.minHeight) {
showToast(toValueWithout(`请上传尺寸大于${config.upload.minWidth}*${config.upload.minHeight}像素的照片`))
imgurl.value = null
picture.value = null
return
}
const blob = base64ToBlob(data)
if (blob.size > config.upload.maxSize) {
showToast(toValueWithout(`照片大小不能超过${config.upload.maxSize / 1024 / 1024}M`))
imgurl.value = null
picture.value = null
return
}
imgurl.value = blob
}
img.onerror = () => {
showToast(toValueWithout('裁剪图片加载失败'))
imgurl.value = null
picture.value = null
}
}
function onCropCancel() {
closeCropping()
}
/** 裁剪页缩放:正数放大、负数缩小;也支持双指捏合 */
function onCropZoom(step: number) {
const cropper = cropperRef.value as any
cropper?.changeScale?.(step)
}
function onCropConfirm() {
if (cropConfirming.value) return
const cropper = cropperRef.value as any
if (!cropper?.getCropData) {
showToast(toValueWithout('裁剪组件未就绪'))
return
}
cropConfirming.value = true
cropper.getCropData((data: string) => {
closeCropping()
if (!data) {
showToast(toValueWithout('裁剪失败'))
return
}
applyCroppedData(data)
})
}
function onFileChange(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
if (file.size > config.upload.maxSize) {
showToast(toValueWithout(`照片大小不能超过${config.upload.maxSize / 1024 / 1024}M`))
return
}
const objectUrl = URL.createObjectURL(file)
const img = new Image()
img.src = objectUrl
img.onload = () => {
imageWidth.value = img.naturalWidth
imageHeight.value = img.naturalHeight
URL.revokeObjectURL(objectUrl)
if (imageWidth.value < config.upload.minWidth || imageHeight.value < config.upload.minHeight) {
showToast(toValueWithout(`请上传尺寸大于${config.upload.minWidth}*${config.upload.minHeight}像素的照片`))
return
}
const reader = new FileReader()
reader.onload = (ev) => {
const dataUrl = String(ev.target?.result || '')
if (!dataUrl) {
showToast(toValueWithout('图片读取失败'))
return
}
nextTick(() => openCropping(dataUrl))
}
reader.onerror = () => showToast(toValueWithout('图片读取失败'))
reader.readAsDataURL(file)
}
img.onerror = () => {
URL.revokeObjectURL(objectUrl)
showToast(toValueWithout('图片加载失败'))
}
}
const onOversize = (file: any) => {
if (file.size > config.upload.maxSize) {
showToast(toValueWithout(`照片大小不能超过${config.upload.maxSize / 1024 / 1024}M`))
return false
}
return true
}
const beforeRead = (file: any) => {
const toPreviewAndCheck = (input: any): Promise<UploaderFileListItem> => {
return new Promise((resolve, reject) => {
const raw: File = input.file || input
if (raw.size > config.upload.maxSize) {
showToast(toValueWithout(`照片大小不能超过${config.upload.maxSize / 1024 / 1024}M`))
reject(false)
return
}
const objectUrl = URL.createObjectURL(raw)
const img = new Image()
img.src = objectUrl
img.onload = () => {
const width = img.naturalWidth
const height = img.naturalHeight
imageWidth.value = width
imageHeight.value = height
URL.revokeObjectURL(objectUrl)
if (width < config.upload.minWidth || height < config.upload.minHeight) {
showToast(toValueWithout(`请上传尺寸大于${config.upload.minWidth}×${config.upload.minHeight}像素的照片`))
reject(false)
return
}
const reader = new FileReader()
reader.onload = (e) => {
const url = e.target?.result as string
const out: any = input
out.url = url
resolve(out)
}
reader.onerror = () => {
showToast(toValueWithout('图片读取失败'))
reject(false)
}
reader.readAsDataURL(raw)
}
img.onerror = () => {
showToast(toValueWithout('图片加载失败'))
URL.revokeObjectURL(objectUrl)
reject(false)
}
})
}
if (Array.isArray(file)) {
return Promise.allSettled(file.map(toPreviewAndCheck)).then((results) => {
const passed = results
.filter(r => r.status === 'fulfilled')
.map((r: any) => r.value)
if (passed.length === 0) return Promise.reject(false)
return passed
})
}
return toPreviewAndCheck(file)
}
// 获取Pid
const pid = ref('')
const getPid = async () => {
showLoadingToast({
message: toValueWithout('上传中...'),
forbidClick: true,
loadingType: 'spinner',
duration: 0,
})
const params = {
prod_id: prodId.value,
type_id: typeId.value,
subject_id: subjectId.value
}
try {
const res = await badgeApi.getPid(params) as any
isLoading.value = false
pid.value = res.pid
try {
const uploadTasks = [
sendToOss(imgurl.value, res.url),
]
await Promise.all(uploadTasks)
if (isViews.value == 1 && referPicture.value.length > 0) {
const multiUrlTasks = referPicture.value.map((item) => {
return badgeApi.getMultiUrl({
pid: pid.value,
}).then((res: any) => {
return sendToOss(item.file, res.multi_url)
})
})
await Promise.all(multiUrlTasks)
}
} catch (err) {
closeToast()
console.error('上传失败:', err)
showFailToast({
message: toValueWithout('上传失败'),
duration: 2000,
})
isLoading.value = false
return
}
} catch (err: any) {
isLoading.value = false
closeToast()
showToast({
message: err.message,
duration: 2000,
})
} finally {
isLoading.value = false
}
}
// 上传到OSS
const pendingUploads = ref(0)
const isAnotherAPICalled = ref(false)
const sendToOss = async (src: string | Blob | File, url: string) => {
try {
pendingUploads.value++
const response = await fetch(url, {
method: 'PUT',
headers: {
'Content-Type': 'image/jpeg'
},
body: src
})
if (!response.ok) {
throw new Error('Upload failed')
}
if (--pendingUploads.value === 0 && !isAnotherAPICalled.value) {
isAnotherAPICalled.value = true
createLog()
}
} catch (err: any) {
closeToast()
pendingUploads.value--
showFailToast({
message: err.message,
duration: 2000,
})
}
}
// 创建日志
const createLog = () => {
const params = {
pid: pid.value,
group: 1,
prod_id: prodId.value,
type_id: typeId.value,
kind_id: kindId.value,
subject_id: subjectId.value,
scene_prop: scene_prop.value ? scene_prop.value : undefined,
mounts_id: mountsId.value || undefined,
}
badgeApi.createLog(params).then(() => {
closeToast()
showSuccessToast({
message: toValueWithout('照片上传成功'),
duration: 2000,
})
setTimeout(() => {
closeToast()
router.push({
path: config.routes.preview,
query: {
pid: pid.value,
group: 1,
prod_id: prodId.value,
type_id: typeId.value,
kind_id: kindId.value,
subject_id: subjectId.value
},
})
}, 1000);
}).catch((err: any) => {
showFailToast({
message: err.message,
duration: 2000,
})
}).finally(() => {
closeToast()
})
}
onMounted(() => {
getOrderStat()
getSizeList()
})
onUnmounted(() => {
if (cropping.value) closeCropping()
})
</script>
<style lang="scss" scoped>
.photo-upload-page {
height: auto;
overflow-y: scroll!important;
min-height: 100vh;
background-color: #ffffff;
}
.badge-size {
padding: 16px;
.size-title {
font-size: 14px;
color: #000;
}
.size-options {
.size-item {
margin-top: 8px;
display: flex;
justify-content: space-between;
align-items: center;
.size-text {
font-size: 14px;
color: #333;
text-align: center;
padding: 4px 10px;
background: #F0F2F5;
border-radius: 4px;
margin-right: 5px;
display: inline-block;
}
.size-count {
font-size: 12px;
color: #999;
display: inline-block;
}
}
}
}
.badge-info {
display: flex;
justify-content: space-between;
padding: 0 16px 16px 16px;
.badge-item:first-child {
background-image: url('@/assets/badge/sheji.png');
background-size: cover;
background-position: center;
margin-right: 5px;
}
.badge-item:last-child {
background-image: url('@/assets/badge/order.png');
background-size: cover;
background-position: center;
margin-left: 5px;
}
.badge-item {
padding: 18px 12px;
flex: 1;
text-align: left;
cursor: pointer;
height: 88px;
.badge-title {
font-size: 16px;
color: #000;
font-weight: bold;
}
.badge-count {
font-size: 15px;
color: #333;
font-weight: bold;
margin-top: 8px;
}
}
}
.step-container {
display: flex;
align-items: flex-start;
padding: 16px 0 16px 16px;
}
.step-item {
display: flex;
align-items: center;
flex: 1;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.step-item.active {
flex: 2;
margin-left: 50px;
}
.step-num {
font-size: 48px;
line-height: 1;
position: relative;
margin-right: 8px;
text-shadow: 0 2px 8px #e6f7e6;
color: #CCCCCC;
}
.step-item.active .step-num {
color: #fff;
text-shadow:
1px 1px 0 #000,
-1px -1px 0 #000,
-1px 1px 0 #000,
1px -1px 0 #000,
0 1px 0 #000,
1px 0 0 #000,
0 -1px 0 #000,
-1px 0 0 #000;
}
.step-item.active .step-num::after {
content: '';
position: absolute;
top: 20px;
right: 0;
width: 15px;
height: 15px;
background-color: #50cf54;
opacity: 0.5;
border-radius: 50%;
}
.step-content {
display: flex;
flex-direction: column;
}
.step-title {
font-size: 16px;
color: #808080;
}
.step-desc {
font-size: 12px;
color: #808080;
margin-top: 2px;
}
.photo-upload-body {
position: relative;
width: 80vw;
height: 80vw;
margin: 16px auto 0 auto;
text-align: center;
cursor: pointer;
}
.photo-upload-box {
margin: 16px auto 0 auto;
width: 80vw;
height: 80vw;
border-radius: 12px;
background: #F0F2F5;
position: relative;
display: flex;
flex-direction: column;
align-items: center;
box-sizing: border-box;
}
.photo-upload-input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
pointer-events: none;
}
.photo-upload-img {
max-width: 80vw;
max-height: 80vw;
border-radius: 12px;
}
/* 全屏裁剪层:3:4 + 可缩放 */
.ct-cropper {
position: fixed;
inset: 0;
z-index: 2147483000;
display: flex;
flex-direction: column;
background: #000;
overscroll-behavior: none;
}
.ct-cropper__stage {
flex: 1;
min-height: 0;
width: 100%;
position: relative;
}
.ct-cropper__stage :deep(.vue-cropper) {
width: 100% !important;
height: 100% !important;
background: #111 !important;
touch-action: none;
}
.ct-cropper__stage :deep(.cropper-view-box) {
outline: 2px solid #fff !important;
}
.ct-cropper__stage :deep(.crop-point) {
background-color: #50cf54 !important;
opacity: 0.95;
}
.ct-cropper__bar {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
box-sizing: border-box;
background: #111;
}
.ct-cropper__zoom {
display: flex;
align-items: center;
gap: 10px;
}
.ct-cropper__zoom-btn {
width: 40px;
height: 40px;
border: 1px solid rgba(255, 255, 255, 0.35);
border-radius: 50%;
background: rgba(255, 255, 255, 0.08);
color: #fff;
font-size: 22px;
line-height: 1;
padding: 0;
}
.ct-cropper__btn {
height: 40px;
line-height: 40px;
padding: 0 20px;
border: none;
border-radius: 20px;
background: transparent;
color: #fff;
font-size: 15px;
}
.ct-cropper__btn--ok {
background: #50cf54;
color: #fff;
font-weight: 600;
}
.ct-cropper__btn:disabled {
opacity: 0.6;
}
.photo-upload-header {
width: 100%;
display: flex;
align-items: center;
padding: 12px 16px 0 12px;
font-size: 14px;
color: #888;
justify-content: flex-end;
}
.photo-upload-guide-icon {
font-size: 16px;
margin-right: 4px;
}
.photo-upload-guide-text {
font-size: 13px;
}
.photo-upload-area {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
user-select: none;
}
.photo-upload-plus {
font-size: 48px;
color: #c2c2c2;
margin-bottom: 0;
}
.photo-upload-text {
font-size: 16px;
color: #888;
margin-bottom: 4px;
}
.photo-upload-tip {
font-size: 12px;
color: #b0b0b0;
}
.photo-upload-footer {
position: absolute;
bottom: 0px;
left: 0;
width: 100%;
text-align: center;
font-size: 12px;
color: #b0b0b0;
padding: 0 12px;
line-height: 1.5;
}
.step-line-item-desc {
display: flex;
align-items: center;
justify-content: space-around;
padding: 16px 16px 0 16px;
}
.design-action-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
background: #fff;
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 10;
}
.design-left {
display: flex;
align-items: center;
}
.design-leaf-icon {
margin-right: 4px;
vertical-align: middle;
}
.design-remaining {
color: #222;
font-size: 16px;
font-weight: 500;
}
.design-btn {
display: flex;
align-items: center;
justify-content: center;
border: none;
outline: none;
cursor: pointer;
width: 50vw;
height: 56px;
border-radius: 28px;
font-size: 20px;
font-weight: bold;
background: linear-gradient(90deg, #D1ED8E 0%, #55E668 100%);
color: #222;
box-shadow: 0 2px 8px rgba(80, 207, 84, 0.10);
transition: background 0.2s;
position: relative;
}
.design-btn .design-arrow {
margin-left: 20px;
vertical-align: middle;
position: absolute;
right: 30px;
transition: transform 0.2s;
width: 18px;
height: 12px;
}
.order-type-item {
display: flex;
align-items: center;
justify-content: space-between;
background: #fff;
border-radius: 12px;
margin-bottom: 12px;
font-size: 14px;
}
.kind-box {
display: flex;
align-items: center;
justify-content: flex-start;
flex-wrap: wrap;
padding: 16px;
}
.kind-box-item {
margin-right: 10px;
margin-bottom: 10px;
padding: 6px 10px;
background: #f5f5f5;
border-radius: 8px;
color: #333;
}
.kind-item-title {
font-size: 14px;
}
.kind-box-item.styleActive {
background: #50CF54;
color: #fff;
}
.photo-upload-box-refer {
margin: 16px;
}
.mounts-list-container {
display: flex;
flex-wrap: wrap;
justify-content: flex-start;
margin-top: 12px;
}
.mounts-item {
position: relative;
flex: 0 0 calc(25% - 6px);
margin-right: 8px;
margin-bottom: 16px;
cursor: pointer;
}
.mounts-item:nth-child(4n) {
margin-right: 0;
}
.mounts-item-list {
position: relative;
width: 100%;
padding-bottom: 100%;
border-radius: 8px;
overflow: hidden;
background: #f5f5f5;
}
.mounts-item-image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: contain;
border-radius: 8px;
transition: transform 0.2s;
}
.mounts-item-image-round {
border-radius: 50%;
}
.mounts-item:hover .mounts-item-image {
transform: scale(1.05);
}
.mounts-icon {
position: absolute;
right: 6px;
bottom: 6px;
z-index: 2;
background: rgba(255, 255, 255, 0.9);
border-radius: 50%;
padding: 2px;
}
.mounts-item-text {
font-size: 12px;
text-align: center;
margin-top: 8px;
color: v-bind('config.styles.colors.textSecondary');
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>