forked from natuka/web.puabadge.com
修改:新增抖音下单页面
This commit is contained in:
1 parent
024c8a5155
commit
73971e4a08
38 files changed
+14447
-35505
No files matched your search
@@ -0,0 +1,855 @@
|
||||
<template>
|
||||
<div class="gen-page" :class="{ 'is-result': showResult }">
|
||||
<!-- ===== 生成中 ===== -->
|
||||
<template v-if="!showResult">
|
||||
<DyNavBar
|
||||
theme="dark"
|
||||
title="生成中"
|
||||
back-text="‹ 返回"
|
||||
right-text="记录"
|
||||
border
|
||||
@back="goHome"
|
||||
@right="goRecords"
|
||||
/>
|
||||
|
||||
<div class="stage-area">
|
||||
<div class="stage-center">
|
||||
<div class="stage-shell" :style="shellStyle">
|
||||
<div class="stage-box" :style="stageStyle">
|
||||
<div class="stage-layer">
|
||||
<ImagePointCloud
|
||||
point-shape="circle"
|
||||
:side="stageSide"
|
||||
:fading="cloudFading"
|
||||
:image-url="sourceUrl"
|
||||
:mask-url="maskUrl"
|
||||
:result-url="resultUrl"
|
||||
@phasechange="onPhaseChange"
|
||||
@handoff="onHandoff"
|
||||
/>
|
||||
</div>
|
||||
<img
|
||||
v-if="resultUrl"
|
||||
class="stage-layer result-layer"
|
||||
:src="resultUrl"
|
||||
alt=""
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="errorTip" class="error-tip">{{ errorTip }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="progress-dock">
|
||||
<div class="bar"><div class="inner" :style="{ width: `${progress}%` }"></div></div>
|
||||
<div class="meta">
|
||||
<div>
|
||||
<div class="tip">{{ tip }}</div>
|
||||
<div class="sub">{{ subTip }}</div>
|
||||
</div>
|
||||
<span>{{ Math.floor(progress) }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ===== 效果预览(对齐设计图) ===== -->
|
||||
<template v-else>
|
||||
<DyNavBar
|
||||
theme="light"
|
||||
title="效果预览"
|
||||
right-text="生图记录"
|
||||
border
|
||||
@back="goBack"
|
||||
@right="goRecords"
|
||||
/>
|
||||
|
||||
<div class="result-body">
|
||||
<div class="result-head">
|
||||
<div class="status-row">
|
||||
<span class="status-dot" />
|
||||
<span class="status-text">生成完成</span>
|
||||
</div>
|
||||
<h1 class="headline">专属效果已就绪</h1>
|
||||
<p class="subline">确认满意后即可提交制作</p>
|
||||
</div>
|
||||
|
||||
<div class="preview-card" @click="openZoom">
|
||||
<img v-if="resultUrl" class="preview-img" :src="resultUrl" alt="效果图">
|
||||
<div v-if="sourceUrl" class="source-polaroid" aria-hidden="true">
|
||||
<img :src="sourceUrl" alt="">
|
||||
</div>
|
||||
<button class="zoom-btn" type="button" @click.stop="openZoom">点击放大</button>
|
||||
</div>
|
||||
|
||||
<p class="save-hint">效果已保存到生图记录 · 可随时回来查看</p>
|
||||
<div v-if="errorTip" class="error-tip light">{{ errorTip }}</div>
|
||||
</div>
|
||||
|
||||
<div class="result-footer">
|
||||
<button class="btn-remake" type="button" @click="goHome">重新制作</button>
|
||||
<button
|
||||
class="btn-submit"
|
||||
type="button"
|
||||
:disabled="ordering || !taskId"
|
||||
@click="openConfirmModal"
|
||||
>
|
||||
满意,提交制作
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 确认弹窗 -->
|
||||
<div v-if="showConfirmModal" class="confirm-mask" @click.self="showConfirmModal = false">
|
||||
<div class="confirm-sheet">
|
||||
<div class="confirm-title">确认效果满意</div>
|
||||
<div class="confirm-body">
|
||||
<p>请仔细确认效果是否满意后再提交</p>
|
||||
<p class="confirm-sub">提交制作后将进入制作流程,请确认效果满意后再继续</p>
|
||||
</div>
|
||||
<div class="confirm-actions">
|
||||
<button class="btn-cancel" type="button" @click="showConfirmModal = false">返回查看</button>
|
||||
<button
|
||||
class="btn-ok"
|
||||
type="button"
|
||||
:disabled="ordering"
|
||||
@click="confirmSubmit"
|
||||
>
|
||||
提交制作
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { showImagePreview, showToast } from 'vant'
|
||||
import * as douyinApi from '@/api/douyin'
|
||||
import { douyinConfig as config } from '@/config/douyin'
|
||||
import ImagePointCloud from './components/ImagePointCloud.vue'
|
||||
import DyNavBar from './components/DyNavBar.vue'
|
||||
import { takePendingUpload } from './pendingUpload'
|
||||
|
||||
const SUBMITTED_KEY = 'douyin_order_submitted'
|
||||
|
||||
function isSubmittedBizError(msg: string) {
|
||||
return /已提交制作|不能继续上传|不能继续生图/.test(String(msg || ''))
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const taskId = ref(String(route.query.task_id || sessionStorage.getItem('douyin_task_id') || ''))
|
||||
const pid = ref(String(route.query.pid || sessionStorage.getItem('douyin_pid') || ''))
|
||||
const sourceUrl = ref(sessionStorage.getItem('douyin_source_preview') || '')
|
||||
const maskUrl = ref('')
|
||||
const resultUrl = ref('')
|
||||
const imageId = ref('')
|
||||
const tip = ref('准备中')
|
||||
const subTip = ref('正在启动')
|
||||
const progress = ref(2)
|
||||
const showResult = ref(false)
|
||||
const cloudFading = ref(false)
|
||||
const ordering = ref(false)
|
||||
const errorTip = ref('')
|
||||
const showConfirmModal = ref(false)
|
||||
|
||||
/** 红框正方形边长:首屏即算好,不依赖 CSS min() */
|
||||
function calcStageSide() {
|
||||
if (typeof window === 'undefined') return 280
|
||||
const w = window.innerWidth || 375
|
||||
const h = window.innerHeight || 667
|
||||
// 约屏宽 72%、屏高 40%,上限 320,贴近预览卡片区域
|
||||
return Math.max(180, Math.round(Math.min(w * 0.72, h * 0.4, 320)))
|
||||
}
|
||||
|
||||
const stageSide = ref(calcStageSide())
|
||||
const stageStyle = computed(() => ({
|
||||
width: `${stageSide.value}px`,
|
||||
height: `${stageSide.value}px`,
|
||||
}))
|
||||
const shellStyle = computed(() => ({
|
||||
width: showResult.value ? `${stageSide.value + 32}px` : `${stageSide.value}px`,
|
||||
}))
|
||||
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
let softTimer: ReturnType<typeof setInterval> | null = null
|
||||
let handoffDone = false
|
||||
let maskAborted = false
|
||||
let uploading = false
|
||||
let fadeTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function onResize() {
|
||||
stageSide.value = calcStageSide()
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
router.replace(config.routes.home)
|
||||
}
|
||||
|
||||
/** 预览顶栏返回:有历史则回上一页,否则回首页 */
|
||||
function goBack() {
|
||||
if (window.history.length > 1) {
|
||||
router.back()
|
||||
return
|
||||
}
|
||||
router.replace(config.routes.home)
|
||||
}
|
||||
|
||||
function goRecords() {
|
||||
router.push(config.routes.records)
|
||||
}
|
||||
|
||||
let progressAnimTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function clearTimers() {
|
||||
if (pollTimer) clearInterval(pollTimer)
|
||||
if (softTimer) clearInterval(softTimer)
|
||||
if (fadeTimer) clearTimeout(fadeTimer)
|
||||
if (progressAnimTimer) clearInterval(progressAnimTimer)
|
||||
pollTimer = null
|
||||
softTimer = null
|
||||
fadeTimer = null
|
||||
progressAnimTimer = null
|
||||
}
|
||||
|
||||
function onPhaseChange(payload: { phase: string; tip: string; subTip: string }) {
|
||||
tip.value = payload.tip || tip.value
|
||||
subTip.value = payload.subTip || subTip.value
|
||||
const phase = payload.phase
|
||||
if (phase?.startsWith('SUBJECT')) progress.value = Math.max(progress.value, 58)
|
||||
if (phase?.startsWith('RESULT_CLOUD')) progress.value = Math.max(progress.value, 90)
|
||||
if (phase === 'RESULT_IMAGE_REVEAL' && resultUrl.value) {
|
||||
startSmoothReveal()
|
||||
}
|
||||
if (phase === 'RESULT_IMAGE_REVEAL' || phase === 'RESULT_PREVIEW' || phase === 'NAVIGATING') {
|
||||
// 预览前由 startSmoothReveal 动画到 100,这里兜底
|
||||
if (showResult.value) progress.value = 100
|
||||
}
|
||||
}
|
||||
|
||||
/** 平滑加速到目标进度 */
|
||||
function animateProgressTo(target: number, durationMs = 480): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (progressAnimTimer) {
|
||||
clearInterval(progressAnimTimer)
|
||||
progressAnimTimer = null
|
||||
}
|
||||
const from = progress.value
|
||||
const to = Math.max(from, Math.min(100, target))
|
||||
if (to <= from + 0.2) {
|
||||
progress.value = to
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
const start = Date.now()
|
||||
progressAnimTimer = setInterval(() => {
|
||||
const t = Math.min(1, (Date.now() - start) / durationMs)
|
||||
// ease-out
|
||||
const eased = 1 - (1 - t) * (1 - t)
|
||||
progress.value = from + (to - from) * eased
|
||||
if (t >= 1) {
|
||||
if (progressAnimTimer) clearInterval(progressAnimTimer)
|
||||
progressAnimTimer = null
|
||||
progress.value = to
|
||||
resolve()
|
||||
}
|
||||
}, 32)
|
||||
})
|
||||
}
|
||||
|
||||
function startSmoothReveal() {
|
||||
if (showResult.value || !resultUrl.value) return
|
||||
// 先把进度条跑到 100%,再切效果预览,保证用户能看到满格
|
||||
animateProgressTo(100, 520).then(() => {
|
||||
if (showResult.value) return
|
||||
cloudFading.value = true
|
||||
requestAnimationFrame(() => {
|
||||
showResult.value = true
|
||||
})
|
||||
sessionStorage.setItem('douyin_task_id', taskId.value)
|
||||
sessionStorage.setItem('douyin_image_id', imageId.value)
|
||||
sessionStorage.setItem('douyin_result_url', resultUrl.value)
|
||||
})
|
||||
}
|
||||
|
||||
function enterResultView() {
|
||||
if (handoffDone) return
|
||||
handoffDone = true
|
||||
// 停轮询/软进度,但保留进度冲满动画
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
if (softTimer) {
|
||||
clearInterval(softTimer)
|
||||
softTimer = null
|
||||
}
|
||||
if (fadeTimer) {
|
||||
clearTimeout(fadeTimer)
|
||||
fadeTimer = null
|
||||
}
|
||||
startSmoothReveal()
|
||||
}
|
||||
|
||||
function onHandoff() {
|
||||
enterResultView()
|
||||
}
|
||||
|
||||
function startSoftProgress() {
|
||||
softTimer = setInterval(() => {
|
||||
if (showResult.value) return
|
||||
// 效果图已到:快速冲到 96%,留给揭示动画收尾到 100%
|
||||
if (resultUrl.value) {
|
||||
if (progress.value < 96) {
|
||||
progress.value = Math.min(96, progress.value + 3.2)
|
||||
}
|
||||
return
|
||||
}
|
||||
const cap = config.poll.softCap
|
||||
if (progress.value >= cap) return
|
||||
// 整体加快:前段更快,后段仍有余量
|
||||
let step = 2.4
|
||||
if (progress.value >= 35) step = 1.4
|
||||
if (progress.value >= 60) step = 0.85
|
||||
if (progress.value >= 75) step = 0.45
|
||||
progress.value = Math.min(cap, progress.value + step)
|
||||
}, 280)
|
||||
}
|
||||
|
||||
function startMaskFetch() {
|
||||
if (!pid.value) return
|
||||
maskAborted = false
|
||||
douyinApi
|
||||
.waitMaskUrl({ pid: pid.value, intervalMs: 2000, timeoutMs: 60000 })
|
||||
.then((res) => {
|
||||
if (maskAborted || showResult.value) return
|
||||
if (res && res.url) {
|
||||
maskUrl.value = res.url
|
||||
sessionStorage.setItem('douyin_mask_url', res.url)
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
function pollStatus() {
|
||||
if (!taskId.value) return
|
||||
douyinApi.getImageGenStatus(taskId.value).then((res: any) => {
|
||||
const parsed = douyinApi.parseImageGenStatus(res, taskId.value)
|
||||
if (parsed.status === 'failed') {
|
||||
clearTimers()
|
||||
errorTip.value = parsed.message || '生成失败,请返回重试'
|
||||
return
|
||||
}
|
||||
if (parsed.status === 'success' && parsed.resultUrl) {
|
||||
resultUrl.value = parsed.resultUrl
|
||||
// make/submit 必须用效果图 image_id,禁止回退成 task_id
|
||||
if (parsed.imageId) {
|
||||
imageId.value = parsed.imageId
|
||||
sessionStorage.setItem('douyin_image_id', parsed.imageId)
|
||||
}
|
||||
if (fadeTimer) clearTimeout(fadeTimer)
|
||||
fadeTimer = setTimeout(() => {
|
||||
if (!handoffDone) enterResultView()
|
||||
}, 10000)
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
async function beginPendingUpload() {
|
||||
if (uploading) return
|
||||
const { file, preview } = takePendingUpload()
|
||||
if (!file) {
|
||||
errorTip.value = '未找到待上传图片,请返回重新选择'
|
||||
return
|
||||
}
|
||||
uploading = true
|
||||
if (preview) sourceUrl.value = preview
|
||||
tip.value = '上传中'
|
||||
subTip.value = '请稍候'
|
||||
progress.value = Math.max(progress.value, 8)
|
||||
|
||||
try {
|
||||
const res = await douyinApi.startGeneratePipeline(file, {
|
||||
filename: file.name,
|
||||
content_type: file.type || 'image/jpeg',
|
||||
style: config.messages.effectTitle,
|
||||
n: 1,
|
||||
})
|
||||
taskId.value = res.taskId
|
||||
if (res.pid != null) {
|
||||
pid.value = String(res.pid)
|
||||
sessionStorage.setItem('douyin_pid', String(res.pid))
|
||||
}
|
||||
sessionStorage.setItem('douyin_task_id', res.taskId)
|
||||
if (res.upload_id) sessionStorage.setItem('douyin_upload_id', res.upload_id)
|
||||
if (res.file_url) {
|
||||
sessionStorage.setItem('douyin_file_url', res.file_url)
|
||||
sourceUrl.value = res.file_url
|
||||
}
|
||||
tip.value = '生成中'
|
||||
subTip.value = '请稍候'
|
||||
progress.value = Math.max(progress.value, 18)
|
||||
startMaskFetch()
|
||||
pollStatus()
|
||||
pollTimer = setInterval(pollStatus, config.poll.interval)
|
||||
} catch (err: any) {
|
||||
const msg = err?.message || '上传失败,请返回重试'
|
||||
errorTip.value = msg
|
||||
// 已提交制作:不停留等待页,标记锁定并回首页
|
||||
if (isSubmittedBizError(msg)) {
|
||||
sessionStorage.setItem(SUBMITTED_KEY, '1')
|
||||
showToast(msg)
|
||||
setTimeout(() => {
|
||||
router.replace(config.routes.home)
|
||||
}, 600)
|
||||
}
|
||||
} finally {
|
||||
uploading = false
|
||||
}
|
||||
}
|
||||
|
||||
function openZoom() {
|
||||
if (!resultUrl.value) return
|
||||
showImagePreview({
|
||||
images: [resultUrl.value],
|
||||
closeable: true,
|
||||
closeIconPosition: 'top-right',
|
||||
})
|
||||
}
|
||||
|
||||
function openConfirmModal() {
|
||||
if (ordering.value || !taskId.value) return
|
||||
showConfirmModal.value = true
|
||||
}
|
||||
|
||||
async function confirmSubmit() {
|
||||
if (ordering.value || !taskId.value) return
|
||||
ordering.value = true
|
||||
errorTip.value = ''
|
||||
try {
|
||||
const res = await douyinApi.submitProduction({
|
||||
task_id: taskId.value,
|
||||
image_id: imageId.value,
|
||||
})
|
||||
if (res.imageId) {
|
||||
imageId.value = res.imageId
|
||||
sessionStorage.setItem('douyin_image_id', res.imageId)
|
||||
}
|
||||
sessionStorage.setItem(SUBMITTED_KEY, '1')
|
||||
showConfirmModal.value = false
|
||||
router.replace(config.routes.success)
|
||||
} catch (err: any) {
|
||||
errorTip.value = err?.message || '提交失败,请重试'
|
||||
showConfirmModal.value = false
|
||||
} finally {
|
||||
ordering.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
stageSide.value = calcStageSide()
|
||||
window.addEventListener('resize', onResize)
|
||||
startSoftProgress()
|
||||
|
||||
const pending = route.query.pending === '1'
|
||||
if (pending) {
|
||||
beginPendingUpload()
|
||||
return
|
||||
}
|
||||
|
||||
if (!taskId.value) {
|
||||
errorTip.value = '缺少任务参数,请返回重新生成'
|
||||
return
|
||||
}
|
||||
const fileUrl = sessionStorage.getItem('douyin_file_url')
|
||||
if (fileUrl) sourceUrl.value = fileUrl
|
||||
startMaskFetch()
|
||||
pollStatus()
|
||||
pollTimer = setInterval(pollStatus, config.poll.interval)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
maskAborted = true
|
||||
clearTimers()
|
||||
window.removeEventListener('resize', onResize)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.gen-page {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
background: #000000;
|
||||
color: #f2f2f2;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
transition: background-color 0.85s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.gen-page.is-result {
|
||||
background: #ffffff;
|
||||
color: #1a1a1a;
|
||||
padding-bottom: calc(88px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.stage-area {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
min-height: 240px;
|
||||
}
|
||||
|
||||
.stage-center {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
top: 46%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: max-content;
|
||||
max-width: calc(100vw - 32px);
|
||||
}
|
||||
|
||||
.stage-shell {
|
||||
margin: 0 auto;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.stage-box {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: #000000;
|
||||
}
|
||||
|
||||
.stage-layer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stage-layer > :deep(.ipc) {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.result-layer {
|
||||
object-fit: contain;
|
||||
opacity: 0.35;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.error-tip {
|
||||
margin-top: 12px;
|
||||
max-width: 280px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: #ff8a8a;
|
||||
}
|
||||
|
||||
.error-tip.light {
|
||||
color: #e05a5a;
|
||||
margin: 8px auto 0;
|
||||
}
|
||||
|
||||
.progress-dock {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 4;
|
||||
padding: 16px 24px calc(20px + env(safe-area-inset-bottom));
|
||||
background: linear-gradient(180deg, transparent 0%, rgba(0, 0, 0, 0.88) 40%);
|
||||
}
|
||||
|
||||
.bar {
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.inner {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, #8fd96f 0%, #5fbf45 100%);
|
||||
transition: width 0.35s ease;
|
||||
}
|
||||
|
||||
.meta {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
.tip {
|
||||
font-size: 14px;
|
||||
color: #f2f2f2;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sub {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
/* —— 效果预览(设计图) —— */
|
||||
.result-body {
|
||||
padding: 4px 18px 24px;
|
||||
animation: resultIn 0.55s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
@keyframes resultIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.result-head {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: #5fbf45;
|
||||
box-shadow: 0 0 0 3px rgba(95, 191, 69, 0.22);
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #5fbf45;
|
||||
}
|
||||
|
||||
.headline {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #111;
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.subline {
|
||||
margin: 6px 0 0;
|
||||
font-size: 13px;
|
||||
color: #8a9586;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.preview-card {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 3 / 3.55;
|
||||
max-height: 58vh;
|
||||
border-radius: 28px;
|
||||
overflow: hidden;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.08), 0 2px 6px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.preview-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.source-polaroid {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
top: 18%;
|
||||
width: 72px;
|
||||
padding: 5px 5px 14px;
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18);
|
||||
transform: rotate(-8deg);
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.source-polaroid img {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.zoom-btn {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
z-index: 3;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
padding: 7px 12px;
|
||||
background: rgba(40, 44, 48, 0.55);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.save-hint {
|
||||
margin: 14px 0 0;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #9aa394;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.result-footer {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0) 0%, #ffffff 28%);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.btn-remake {
|
||||
flex: 0 0 108px;
|
||||
height: 48px;
|
||||
border: none;
|
||||
border-radius: 24px;
|
||||
background: #e2eedd;
|
||||
color: #4cae3a;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-submit {
|
||||
flex: 1;
|
||||
height: 48px;
|
||||
border: none;
|
||||
border-radius: 24px;
|
||||
background: linear-gradient(90deg, #7ed45a 0%, #5fbf45 100%);
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 6px 16px rgba(95, 191, 69, 0.35);
|
||||
}
|
||||
|
||||
.btn-submit:disabled {
|
||||
opacity: 0.55;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* 确认弹窗 */
|
||||
.confirm-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.confirm-sheet {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
box-shadow: 6px 8px 0 rgba(0, 0, 0, 0.55);
|
||||
border: 2px solid #1a1a1a;
|
||||
}
|
||||
|
||||
.confirm-title {
|
||||
background: #5fbf45;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.confirm-body {
|
||||
padding: 20px 18px 8px;
|
||||
text-align: center;
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.confirm-sub {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.confirm-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 12px 16px 18px;
|
||||
}
|
||||
|
||||
.btn-cancel,
|
||||
.btn-ok {
|
||||
flex: 1;
|
||||
height: 42px;
|
||||
border-radius: 21px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
border: 1.5px solid #8fd96f;
|
||||
background: #f3faf0;
|
||||
color: #5fbf45;
|
||||
}
|
||||
|
||||
.btn-ok {
|
||||
border: none;
|
||||
background: #5fbf45;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-ok:disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user