forked from natuka/web.puabadge.com
831 lines
20 KiB
Vue
831 lines
20 KiB
Vue
<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>
|
||
<!-- 效果图点云阶段不叠底图,等揭示后再进预览页展示 -->
|
||
</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'
|
||
|
||
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)
|
||
|
||
/** 红框正方形边长:按 app-shell 实际宽度算,避免桌面端用整窗宽导致偏大/错位 */
|
||
function calcStageSide() {
|
||
if (typeof window === 'undefined') return 280
|
||
const shell = document.querySelector('.app-shell') as HTMLElement | null
|
||
const w = shell?.clientWidth || Math.min(window.innerWidth || 375, 540)
|
||
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() {
|
||
// 固定节奏:每 300ms +1%,效果图就绪后冲到 96%,预览前再动画到 100%
|
||
softTimer = setInterval(() => {
|
||
if (showResult.value) return
|
||
const cap = resultUrl.value ? 96 : config.poll.softCap
|
||
if (progress.value >= cap) return
|
||
progress.value = Math.min(cap, progress.value + 1)
|
||
}, 300)
|
||
}
|
||
|
||
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)
|
||
}
|
||
// 不再 10s 强切预览:会掐掉 RESULT_CLOUD_* 效果图点云段
|
||
// 正常走点云状态机 handoff;仅作兜底(给足灰/彩点云播放时间)
|
||
if (fadeTimer) clearTimeout(fadeTimer)
|
||
fadeTimer = setTimeout(() => {
|
||
if (!handoffDone) enterResultView()
|
||
}, 60000)
|
||
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)) {
|
||
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)
|
||
}
|
||
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(100% - 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;
|
||
}
|
||
|
||
.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>
|