|
- <template>
- <view class="wrap">
- <view class="hero-card portrait-hero">
- <view class="hero-copy">
- <text class="eyebrow">PORTRAIT PHOTO STUDIO</text>
- <text class="hero-title">生成你的职业形象照</text>
- <text class="hero-desc">从商务形象、普通职业到艺术写真。AI 会重绘服装与背景,五官可能有轻微变化。</text>
- <view class="hero-actions">
- <view v-if="hasDemo" class="hero-pill" @click="openDemoCompare">查看对比</view>
- <view class="hero-link" @click="chooseImage">上传照片</view>
- </view>
- </view>
- <!-- demo 前后对比缩略图(配置项 portrait_photo_demo),点击进大图对比 -->
- <view v-if="hasDemo" class="hero-thumb" @click="openDemoCompare">
- <view class="mini-compare">
- <image class="compare-img" :src="demoPair.result" mode="aspectFill" />
- <view class="compare-before mini-before">
- <image class="compare-img" :src="demoPair.original" mode="aspectFill" />
- </view>
- <view class="compare-line mini-line"></view>
- <!-- 缩略图只有 168rpx 宽,标签用两个字,否则会挤到中线上 -->
- <view class="compare-tag left">原图</view>
- <view class="compare-tag right">艺术</view>
- </view>
- </view>
- <view v-else class="hero-preview">
- <view class="portrait-card-mini main">
- <image v-if="localImage" class="mini-img" :src="localImage" mode="aspectFill" />
- <template v-else>
- <view class="portrait-face"></view>
- <view class="portrait-suit"></view>
- </template>
- </view>
- <view class="portrait-card-mini sub">
- <view class="portrait-face"></view>
- <view class="portrait-suit"></view>
- </view>
- </view>
- </view>
-
- <!-- ==================== 进度 / 结果 ==================== -->
- <view v-if="job.photo_open_id" class="section">
- <view class="section-head">
- <text class="tool-section-title">{{ job.style_name || '生成结果' }}</text>
- <text class="section-tip">{{ progressText }}</text>
- </view>
-
- <view v-if="isPending" class="progress-box">
- <view class="progress-ring"></view>
- <text class="progress-title">{{ pendingTitle }}</text>
- <text class="progress-sub">已等待 {{ waitedSeconds }} 秒,可以留在本页等待,也可以稍后从记录里查看</text>
- </view>
-
- <view v-else-if="job.status < 0" class="fail-box">
- <text class="fail-title">{{ job.error_msg || '生成失败' }}</text>
- <text class="fail-sub">失败的照片不扣点,可以换一张更清晰的照片重试</text>
- </view>
-
- <view v-else class="result-box">
- <view class="result-grid" :class="{ single: resultUrls.length === 1 }">
- <image
- v-for="(url, index) in resultUrls"
- :key="url"
- class="result-img"
- :src="url"
- mode="aspectFill"
- @click="previewResult(index)"
- />
- </view>
- <view class="result-actions">
- <view class="ghost-btn" @click="goDetail">查看详情</view>
- <view class="ghost-btn" @click="saveAll">保存全部</view>
- <view class="ghost-btn" @click="resetJob">再拍一组</view>
- </view>
- </view>
- </view>
-
- <!-- ==================== 上传 ==================== -->
- <view v-if="canEdit" class="upload-card">
- <view class="card-head">
- <view>
- <text class="card-title"><text class="required">*</text>上传照片</text>
- <text class="card-desc">建议选择五官无遮挡、光线均匀的半身照,单张不超过 10M</text>
- </view>
- <text class="card-tip">{{ localImage ? '1/1' : '0/1' }}</text>
- </view>
- <view class="upload-strip" @click="chooseImage">
- <view class="upload-thumb">
- <image v-if="localImage" class="upload-img" :src="localImage" mode="aspectFill" />
- <text v-else class="upload-plus">+</text>
- </view>
- <view class="upload-copy">
- <text class="upload-title">{{ localImage ? '已选择照片,可点击更换' : '上传正面清晰人像照片' }}</text>
- <text class="upload-sub">支持自拍、半身照,生成商务照、职业照或写真</text>
- </view>
- <view class="upload-action">{{ localImage ? '更换' : '上传' }}</view>
- </view>
- </view>
-
- <!-- ==================== 模型(只有一个时不展示) ==================== -->
- <view v-if="canEdit && models.length > 1" class="section">
- <view class="section-head">
- <text class="tool-section-title">生成模型</text>
- <text class="section-tip">不同模型价格可能不同</text>
- </view>
- <view class="option-grid two">
- <view
- v-for="item in models"
- :key="item.key"
- class="option"
- :class="{ active: activeModel === item.key }"
- @click="selectModel(item.key)"
- >
- {{ item.name }}
- </view>
- </view>
- </view>
-
- <!-- ==================== 档位与选项 ==================== -->
- <view v-if="canEdit" class="section">
- <view class="section-head">
- <text class="tool-section-title">照片风格</text>
- <text class="section-tip">选择生成方向</text>
- </view>
-
- <view v-if="configLoading" class="skeleton-list">
- <view v-for="i in 3" :key="i" class="skeleton-row"></view>
- </view>
-
- <template v-else>
- <view class="segmented">
- <view
- v-for="item in styles"
- :key="item.key"
- class="segment"
- :class="{ active: activeStyle === item.key }"
- @click="selectStyle(item.key)"
- >
- {{ item.name }}
- </view>
- </view>
-
- <view class="config-panel">
- <view v-for="group in currentGroups" :key="group.key" class="sub-block">
- <text class="sub-title">{{ group.name }}</text>
- <view class="option-grid" :class="group.items.length <= 2 ? 'two' : 'three'">
- <view
- v-for="item in group.items"
- :key="item.key"
- class="option"
- :class="{ active: options[group.key] === item.key }"
- @click="options[group.key] = item.key"
- >
- {{ item.name }}
- </view>
- </view>
- </view>
- </view>
- </template>
- </view>
-
- <!-- ==================== 比例 ==================== -->
- <view v-if="canEdit" class="section">
- <view class="section-head">
- <text class="tool-section-title">图片比例</text>
- <text class="section-tip">构图尺寸</text>
- </view>
- <view class="ratio-grid">
- <view
- v-for="item in ratios"
- :key="item.key"
- class="ratio"
- :class="{ active: activeRatio === item.key }"
- @click="activeRatio = item.key"
- >
- {{ item.name }}
- </view>
- </view>
- </view>
-
- <!-- ==================== 数量 ==================== -->
- <view v-if="canEdit" class="section">
- <view class="section-head">
- <text class="tool-section-title">生成数量</text>
- <text class="price">{{ unitPoints }}点/张,共{{ totalPoints }}点</text>
- </view>
- <view class="quantity">
- <view class="quantity-btn" @click="decreaseQuantity">-</view>
- <text class="quantity-value">{{ quantity }}</text>
- <view class="quantity-btn" @click="increaseQuantity">+</view>
- </view>
- <text class="note">按单张收费,一次最多生成{{ maxCount }}张,失败的不扣点</text>
- </view>
-
- <!-- ==================== 补充描述 ==================== -->
- <view v-if="canEdit" class="section">
- <view class="section-head">
- <text class="tool-section-title">补充描述</text>
- <text class="section-tip">选填</text>
- </view>
- <textarea
- v-model="extraPrompt"
- class="textarea"
- maxlength="100"
- placeholder="可补充职业、服装、背景等客观信息,例如:程序员、深灰西装"
- />
- <view class="tips">
- <text v-for="tip in tips" :key="tip" class="tip-item">· {{ tip }}</text>
- </view>
- </view>
-
- <!-- ==================== 底部操作 ==================== -->
- <view class="bottom-bar">
- <view class="balance">
- <text class="balance-copy">{{ balanceText }}</text>
- <view v-if="isBalanceInsufficient" class="recharge-link" @click="goRecharge">去充值</view>
- </view>
- <view class="buttons">
- <view class="history-btn" @click="goHistory">生成记录</view>
- <view class="submit-btn" :class="{ disabled: submitDisabled }" @click="submit">
- <image class="submit-icon" src="/static/icons/bolt.svg" mode="aspectFit" />
- <text>{{ submitText }}</text>
- </view>
- </view>
- </view>
-
- <!-- ==================== 前后对比(样式同老照片 / 动漫头像) ==================== -->
- <view v-if="compareVisible" class="compare-modal">
- <view class="compare-backdrop" @click="closeCompare"></view>
- <view class="compare-panel">
- <view class="compare-panel-head">
- <text>{{ comparePair.title }}</text>
- <view class="close-btn" @click="closeCompare">×</view>
- </view>
- <view
- class="large-compare"
- @touchstart.stop="onCompareTouch"
- @touchmove.stop.prevent="onCompareTouch"
- @click.stop="onCompareTouch"
- >
- <image class="compare-img" :src="comparePair.after" mode="aspectFit" />
- <view class="compare-before" :style="{ width: comparePercent + '%' }">
- <image class="compare-img" :src="comparePair.before" mode="aspectFit" />
- </view>
- <view class="compare-line large" :style="{ left: comparePercent + '%' }">
- <view class="compare-handle large">
- <text>‹</text>
- <text>›</text>
- </view>
- </view>
- <view class="compare-tag left">原图</view>
- <view class="compare-tag right">艺术照</view>
- </view>
- <text class="compare-guide">左右滑动中间按钮查看变化</text>
- </view>
- </view>
- </view>
- </template>
-
- <script setup>
- import { computed, reactive, ref, watch } from 'vue'
- import { onLoad, onShow, onUnload } from '@dcloudio/uni-app'
- import { commonApi, portraitApi, userApi } from '@/api/index.js'
- import { createPortrait } from '@/utils/upload.js'
- import { guid } from '@/utils/util.js'
- import { useUserStore } from '@/store/user.js'
- import { useApp } from '@/utils/useApp.js'
-
- const userStore = useUserStore()
- const { toast } = useApp()
-
- const POLL_INTERVAL = 3000
- const POLL_LIMIT = 8 * 60 * 1000
-
- // 首屏示例对比图由配置项 portrait_photo_demo 下发(同 old_photo_demo),不在端上硬编码
- const DEMO_CONFIG_KEY = 'portrait_photo_demo'
-
- const models = ref([])
- const styles = ref([])
- const ratios = ref([])
- const tips = ref([])
- const activeModel = ref('')
- const activeStyle = ref('')
- const activeRatio = ref('')
- const options = reactive({})
- const quantity = ref(1)
- const maxCount = ref(4)
- const unitPoints = ref(0)
- const extraPrompt = ref('')
- const localImage = ref('')
- const balance = ref({ points: 0 })
- const job = ref({})
- const configLoading = ref(true)
- const submitting = ref(false)
- const waitedSeconds = ref(0)
- // 示例对比图(配置下发)与对比弹层的数据源
- const demoPair = ref({ original: '', result: '' })
- const comparePair = ref({ before: '', after: '', title: '效果对比' })
- const compareVisible = ref(false)
- const comparePercent = ref(50)
-
- let pollTimer = null
- let pollStart = 0
-
- const hasDemo = computed(() => Boolean(demoPair.value.original && demoPair.value.result))
- const currentStyle = computed(() => styles.value.find((item) => item.key === activeStyle.value) || {})
- const currentGroups = computed(() => currentStyle.value.groups || [])
- const currentPoints = computed(() => Number(balance.value.points || balance.value.point_balance || 0))
- const totalPoints = computed(() => unitPoints.value * quantity.value)
- const isPending = computed(() => [10, 20].includes(Number(job.value.status)))
- const canEdit = computed(() => !job.value.photo_open_id || Number(job.value.status) < 0)
- const resultUrls = computed(() => job.value.result_urls || [])
- const balanceText = computed(() => {
- if (!userStore.isLogin) return unitPoints.value ? `形象照 · ${unitPoints.value}点/张` : '形象照'
- return totalPoints.value
- ? `余额${currentPoints.value}点 · 预计消耗${totalPoints.value}点`
- : `余额${currentPoints.value}点`
- })
- const isBalanceInsufficient = computed(
- () => userStore.isLogin && totalPoints.value > 0 && currentPoints.value < totalPoints.value
- )
- const submitDisabled = computed(() => submitting.value || isPending.value)
- const submitText = computed(() => {
- if (submitting.value) return '提交中...'
- if (isPending.value) return '正在生成...'
- if (!userStore.isLogin) return totalPoints.value ? `登录后生成 · ${totalPoints.value}点` : '登录后生成'
- return totalPoints.value ? `立即生成 · ${totalPoints.value}点` : '立即生成'
- })
- const pendingTitle = computed(() =>
- Number(job.value.status) === 10 ? '排队中,等待 AI 处理' : 'AI 正在生成形象照'
- )
- const progressText = computed(() => {
- if (!job.value.photo_open_id) return ''
- if (isPending.value) {
- return `${job.value.success_count || 0}/${job.value.count || 0} 张已完成`
- }
- return job.value.status_text || ''
- })
-
- onLoad((options) => {
- loadConfig()
- loadDemo()
- if (options && options.work) loadJob(options.work, true)
- })
-
- onShow(() => {
- if (userStore.isLogin) loadBalance()
- const workId = uni.getStorageSync('portrait_work_id')
- if (workId) {
- uni.removeStorageSync('portrait_work_id')
- loadJob(workId)
- }
- })
-
- onUnload(() => stopPoll())
-
- // 换模型/换张数会影响报价,重新取一次配置
- watch([activeModel, quantity], () => {
- if (!configLoading.value) loadConfig(true)
- })
-
- async function loadConfig(silent = false) {
- try {
- const res = await portraitApi.getConfig({
- style: activeStyle.value || undefined,
- model: activeModel.value || undefined,
- count: quantity.value
- })
- const data = res.data || {}
- models.value = data.models || []
- styles.value = data.styles || []
- ratios.value = data.ratios || []
- tips.value = data.tips || []
- maxCount.value = Number(data.max_count || 4)
- unitPoints.value = Number(data.unit_points || 0)
- if (!activeModel.value) activeModel.value = data.default_model || (models.value[0] || {}).key || ''
- if (!activeRatio.value) {
- activeRatio.value = (ratios.value.find((item) => item.is_default) || ratios.value[0] || {}).key || ''
- }
- if (!activeStyle.value) {
- activeStyle.value = data.default_style || (styles.value[0] || {}).key || ''
- applyStyleDefaults()
- }
- if (data.billing && data.billing.balance) balance.value = data.billing.balance
- } catch (e) {
- if (!silent) toast(e.msg || '配置加载失败')
- } finally {
- configLoading.value = false
- }
- }
-
- /** 切换档位时把该档位的选项重置为默认值 */
- function applyStyleDefaults() {
- Object.keys(options).forEach((key) => delete options[key])
- for (const group of currentGroups.value) {
- const picked = group.items.find((item) => item.is_default) || group.items[0]
- if (picked) options[group.key] = picked.key
- }
- }
-
- function selectStyle(key) {
- if (activeStyle.value === key) return
- activeStyle.value = key
- applyStyleDefaults()
- }
-
- function selectModel(key) {
- if (activeModel.value === key) return
- activeModel.value = key
- }
-
- async function loadBalance() {
- try {
- const res = await userApi.getBalance()
- balance.value = res.data || {}
- } catch (e) {}
- }
-
- /**
- * 示例对比图:配置项 portrait_photo_demo,格式 [{ original, result }]
- * 兼容 before/after 等字段名与「原图,结果图」的逗号分隔写法;配置缺失就回落到占位插画。
- */
- async function loadDemo() {
- try {
- const res = await commonApi.getConfig({ keys: DEMO_CONFIG_KEY })
- const rows = Array.isArray(res.list) ? res.list : []
- const item = rows.find((it) => it.key === DEMO_CONFIG_KEY)
- demoPair.value = normalizeDemo(item && item.value)
- } catch (e) {}
- }
-
- function normalizeDemo(value) {
- const empty = { original: '', result: '' }
- if (!value) return empty
- try {
- const data = typeof value === 'string' ? JSON.parse(value) : value
- const first = Array.isArray(data) ? data[0] : data
- if (!first) return empty
- return {
- original: first.original || first.before || first.source || '',
- result: first.result || first.after || ''
- }
- } catch (e) {
- const pics = String(value).split(',').map((item) => item.trim()).filter(Boolean)
- if (pics.length >= 2) return { original: pics[0], result: pics[1] }
- return empty
- }
- }
-
- function openDemoCompare() {
- if (!hasDemo.value) return
- comparePair.value = {
- before: demoPair.value.original,
- after: demoPair.value.result,
- title: '效果对比'
- }
- comparePercent.value = 50
- compareVisible.value = true
- }
-
- function closeCompare() {
- compareVisible.value = false
- }
-
- function onCompareTouch(e) {
- const touch = (e.touches && e.touches[0]) || (e.changedTouches && e.changedTouches[0]) || e
- const x = touch.clientX || 0
- uni.createSelectorQuery()
- .select('.large-compare')
- .boundingClientRect((rect) => {
- if (!rect || !rect.width) return
- const percent = ((x - rect.left) / rect.width) * 100
- comparePercent.value = Math.max(6, Math.min(94, Number(percent.toFixed(1))))
- })
- .exec()
- }
-
- function chooseImage() {
- if (!requireLogin()) return
- uni.chooseImage({
- count: 1,
- sizeType: ['original'],
- sourceType: ['album', 'camera'],
- success: (res) => {
- const file = (res.tempFiles && res.tempFiles[0]) || {}
- if (Number(file.size || 0) > 10 * 1024 * 1024) {
- toast('图片不能超过 10M')
- return
- }
- localImage.value = res.tempFilePaths[0]
- job.value = {}
- }
- })
- }
-
- async function submit() {
- if (!requireLogin()) return
- if (submitDisabled.value) return
- if (!localImage.value) return toast('请先上传人像照片')
- if (isBalanceInsufficient.value) return showBalanceModal()
-
- submitting.value = true
- uni.showLoading({ title: '提交中...', mask: true })
- try {
- const res = await createPortrait(localImage.value, {
- style: activeStyle.value,
- model: activeModel.value,
- ratio: activeRatio.value,
- count: quantity.value,
- extra_prompt: extraPrompt.value,
- request_id: guid(),
- ...options
- })
- job.value = res.data || {}
- if (Number(job.value.status) === 30) onJobDone()
- else startPoll()
- } catch (e) {
- if (e && e.code === 1011) showBalanceModal()
- else toast((e && e.msg) || '提交失败,请重试')
- } finally {
- submitting.value = false
- uni.hideLoading()
- }
- }
-
- async function loadJob(photoOpenId, silent = false) {
- try {
- const res = await portraitApi.getDetail({ photo_open_id: photoOpenId })
- job.value = res.data || {}
- if (isPending.value) startPoll()
- else if (Number(job.value.status) === 30) onJobDone()
- } catch (e) {
- if (!silent) toast((e && e.msg) || '记录加载失败')
- }
- }
-
- function startPoll() {
- stopPoll()
- pollStart = Date.now()
- waitedSeconds.value = 0
- pollTimer = setInterval(async () => {
- waitedSeconds.value = Math.round((Date.now() - pollStart) / 1000)
- if (Date.now() - pollStart > POLL_LIMIT) {
- stopPoll()
- toast('生成时间较长,可稍后从生成记录查看')
- return
- }
- try {
- const res = await portraitApi.getDetail({ photo_open_id: job.value.photo_open_id })
- const data = res.data || {}
- job.value = data
- if (Number(data.status) === 30) {
- stopPoll()
- onJobDone()
- } else if (Number(data.status) < 0) {
- stopPoll()
- toast(data.error_msg || '生成失败,本次未扣点')
- }
- } catch (e) {
- // 单次失败不中断轮询
- }
- }, POLL_INTERVAL)
- }
-
- function stopPoll() {
- if (pollTimer) clearInterval(pollTimer)
- pollTimer = null
- }
-
- function onJobDone() {
- loadBalance()
- }
-
- function resetJob() {
- stopPoll()
- job.value = {}
- localImage.value = ''
- extraPrompt.value = ''
- }
-
- function previewResult(index) {
- if (!resultUrls.value.length) return
- uni.previewImage({ urls: resultUrls.value, current: resultUrls.value[index] })
- }
-
- function goDetail() {
- if (!job.value.photo_open_id) return
- uni.navigateTo({ url: `/pages/tool/portrait-detail?id=${job.value.photo_open_id}` })
- }
-
- /** 多张依次保存,避免并发触发相册权限弹窗 */
- async function saveAll() {
- if (!resultUrls.value.length) return
- uni.showLoading({ title: '保存中...', mask: true })
- let ok = 0
- for (const url of resultUrls.value) {
- try {
- const file = await new Promise((resolve, reject) => {
- uni.downloadFile({ url, success: (res) => resolve(res.tempFilePath), fail: reject })
- })
- await new Promise((resolve, reject) => {
- uni.saveImageToPhotosAlbum({ filePath: file, success: resolve, fail: reject })
- })
- ok++
- } catch (e) {
- break
- }
- }
- uni.hideLoading()
- toast(ok ? `已保存 ${ok} 张到相册` : '保存失败,请检查相册权限')
- }
-
- function goHistory() {
- if (!requireLogin()) return
- uni.navigateTo({ url: '/pages/tool/portrait-history' })
- }
-
- function requireLogin() {
- if (userStore.isLogin) return true
- uni.navigateTo({ url: '/pages/login/login' })
- return false
- }
-
- function showBalanceModal() {
- uni.showModal({
- title: '温馨提示',
- content: totalPoints.value
- ? `本次生成需要 ${totalPoints.value} 点,当前点数不足,请充值后继续。`
- : '点数不足,请充值后继续。',
- confirmText: '去充值',
- cancelText: '知道了',
- success: (res) => {
- if (res.confirm) uni.navigateTo({ url: '/pages/recharge/recharge' })
- }
- })
- }
-
- function goRecharge() {
- uni.navigateTo({ url: '/pages/recharge/recharge' })
- }
-
- function decreaseQuantity() {
- quantity.value = Math.max(1, quantity.value - 1)
- }
-
- function increaseQuantity() {
- quantity.value = Math.min(maxCount.value, quantity.value + 1)
- }
- </script>
-
- <style lang="scss" scoped>
- .wrap {
- min-height: 100vh;
- padding: 24rpx 28rpx calc(300rpx + env(safe-area-inset-bottom));
- background: #f3f7fb;
- box-sizing: border-box;
- }
-
- .hero-card {
- position: relative;
- padding: 30rpx;
- border-radius: 28rpx;
- color: #fff;
- overflow: hidden;
- display: flex;
- align-items: center;
- box-sizing: border-box;
- }
- .portrait-hero { background: linear-gradient(135deg, #1b2a4a 0%, #3f5f9c 55%, #7aa7f0 100%); }
- .hero-card::before {
- content: '';
- position: absolute;
- right: -90rpx;
- top: -90rpx;
- width: 280rpx;
- height: 280rpx;
- border-radius: 50%;
- background: rgba(255, 255, 255, 0.12);
- }
- .hero-copy { position: relative; z-index: 1; flex: 1; min-width: 0; }
- .eyebrow { display: block; color: rgba(255, 255, 255, 0.68); font-size: 21rpx; font-weight: 800; }
- .hero-title { display: block; margin-top: 14rpx; color: #fff; font-size: 38rpx; font-weight: 900; }
- .hero-desc {
- display: block;
- margin-top: 14rpx;
- color: rgba(255, 255, 255, 0.82);
- font-size: 23rpx;
- line-height: 1.5;
- }
- .hero-actions { margin-top: 22rpx; display: flex; gap: 16rpx; }
- .hero-pill,
- .hero-link {
- height: 60rpx;
- padding: 0 24rpx;
- border-radius: 999rpx;
- font-size: 25rpx;
- font-weight: 800;
- line-height: 60rpx;
- text-align: center;
- }
- .hero-pill { color: #1b2a4a; background: #fff; }
- .hero-link {
- color: #fff;
- background: rgba(255, 255, 255, 0.18);
- border: 2rpx solid rgba(255, 255, 255, 0.42);
- line-height: 56rpx;
- }
-
- .hero-thumb {
- position: relative;
- z-index: 1;
- margin-left: 22rpx;
- flex-shrink: 0;
- }
- /* 对比组件:与老照片 / 动漫头像保持一致 */
- .mini-compare,
- .large-compare {
- position: relative;
- overflow: hidden;
- background: #dfe8f4;
- }
- .mini-compare {
- width: 168rpx;
- height: 252rpx;
- border-radius: 28rpx;
- border: 2rpx solid rgba(255, 255, 255, 0.45);
- box-shadow: 0 18rpx 38rpx rgba(20, 37, 66, 0.28);
- }
- .compare-img { width: 100%; height: 100%; }
- .compare-before {
- position: absolute;
- left: 0;
- top: 0;
- bottom: 0;
- overflow: hidden;
- }
- .mini-before { width: 50%; }
- .compare-before .compare-img { width: 164rpx; }
- .compare-line {
- position: absolute;
- top: 0;
- bottom: 0;
- width: 4rpx;
- margin-left: -2rpx;
- background: rgba(255, 255, 255, 0.94);
- box-shadow: 0 0 12rpx rgba(20, 37, 66, 0.22);
- }
- .compare-line.mini-line { left: 50%; }
- .compare-line.large { width: 6rpx; margin-left: -3rpx; }
- .compare-handle {
- position: absolute;
- left: 50%;
- top: 50%;
- width: 38rpx;
- height: 58rpx;
- margin-left: -19rpx;
- margin-top: -29rpx;
- border-radius: 999rpx;
- background: #ffffff;
- box-shadow: 0 8rpx 18rpx rgba(20, 37, 66, 0.22);
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 1rpx;
- color: #1f8cff;
- font-size: 24rpx;
- font-weight: 900;
- }
- .compare-handle.large {
- width: 54rpx;
- height: 78rpx;
- margin-left: -27rpx;
- margin-top: -39rpx;
- font-size: 32rpx;
- }
- .compare-tag {
- position: absolute;
- top: 12rpx;
- padding: 6rpx 12rpx;
- border-radius: 999rpx;
- color: #ffffff;
- background: rgba(15, 23, 42, 0.46);
- font-size: 20rpx;
- font-weight: 700;
- }
- .compare-tag.left { left: 12rpx; }
- .compare-tag.right { right: 12rpx; }
-
- .hero-preview {
- position: relative;
- z-index: 1;
- margin-left: 22rpx;
- width: 190rpx;
- height: 240rpx;
- flex-shrink: 0;
- }
- .portrait-card-mini {
- position: absolute;
- width: 140rpx;
- height: 190rpx;
- border-radius: 20rpx;
- background: #dfe8f4;
- border: 2rpx solid rgba(255, 255, 255, 0.5);
- overflow: hidden;
- box-shadow: 0 16rpx 30rpx rgba(0, 0, 0, 0.24);
- }
- .portrait-card-mini.main { right: 0; top: 0; z-index: 2; }
- .portrait-card-mini.sub { left: 0; bottom: 0; opacity: 0.85; }
- .mini-img { width: 100%; height: 100%; }
- .portrait-face {
- position: absolute;
- left: 50%;
- top: 34rpx;
- width: 56rpx;
- height: 56rpx;
- margin-left: -28rpx;
- border-radius: 50%;
- background: #b9c8dd;
- }
- .portrait-suit {
- position: absolute;
- left: 50%;
- bottom: 0;
- width: 120rpx;
- height: 90rpx;
- margin-left: -60rpx;
- border-radius: 60rpx 60rpx 0 0;
- background: #2f4468;
- }
-
- .upload-card,
- .section {
- margin-top: 22rpx;
- padding: 28rpx;
- border-radius: 24rpx;
- background: #fff;
- box-shadow: 0 8rpx 28rpx rgba(34, 68, 112, 0.05);
- }
- .card-head,
- .section-head { display: flex; align-items: baseline; }
- .card-title,
- .tool-section-title { display: block; color: #172033; font-size: 31rpx; font-weight: 800; }
- .required { margin-right: 4rpx; color: #ef4444; }
- .card-desc { display: block; margin-top: 8rpx; color: #8a95a6; font-size: 23rpx; line-height: 1.35; }
- .card-tip,
- .section-tip { margin-left: auto; color: #8a95a6; font-size: 23rpx; }
- .price { margin-left: auto; color: #0b8cff; font-size: 24rpx; font-weight: 800; }
-
- .upload-strip {
- margin-top: 22rpx;
- min-height: 142rpx;
- padding: 18rpx;
- border: 2rpx dashed rgba(11, 140, 255, 0.28);
- border-radius: 20rpx;
- background: #f8fbff;
- display: flex;
- align-items: center;
- box-sizing: border-box;
- }
- .upload-thumb {
- width: 104rpx;
- height: 104rpx;
- border-radius: 18rpx;
- background: #eef6ff;
- display: flex;
- align-items: center;
- justify-content: center;
- flex-shrink: 0;
- overflow: hidden;
- }
- .upload-img { width: 100%; height: 100%; }
- .upload-plus { color: #0b8cff; font-size: 48rpx; line-height: 1; }
- .upload-copy { min-width: 0; flex: 1; margin-left: 20rpx; }
- .upload-title { display: block; color: #172033; font-size: 29rpx; font-weight: 800; }
- .upload-sub { display: block; margin-top: 8rpx; color: #7f8896; font-size: 23rpx; line-height: 1.4; }
- .upload-action { margin-left: 18rpx; color: #0b8cff; font-size: 25rpx; font-weight: 800; }
-
- .skeleton-list { margin-top: 20rpx; }
- .skeleton-row {
- height: 76rpx;
- margin-bottom: 14rpx;
- border-radius: 18rpx;
- background: #f1f5fa;
- }
-
- .segmented {
- margin-top: 20rpx;
- padding: 6rpx;
- border-radius: 999rpx;
- background: #f1f5fa;
- display: flex;
- }
- .segment {
- flex: 1;
- height: 68rpx;
- border-radius: 999rpx;
- color: #66758a;
- font-size: 25rpx;
- font-weight: 800;
- line-height: 68rpx;
- text-align: center;
- }
- .segment.active { color: #fff; background: #0b8cff; }
-
- .config-panel { margin-top: 8rpx; }
- .sub-block { margin-top: 24rpx; }
- .sub-title { display: block; color: #172033; font-size: 27rpx; font-weight: 800; }
- .option-grid {
- margin-top: 16rpx;
- display: grid;
- grid-template-columns: repeat(3, minmax(0, 1fr));
- gap: 14rpx;
- }
- .option-grid.two { grid-template-columns: repeat(2, minmax(0, 1fr)); }
- .option-grid.three { grid-template-columns: repeat(3, minmax(0, 1fr)); }
- .option {
- min-height: 76rpx;
- padding: 0 12rpx;
- border-radius: 18rpx;
- color: #596579;
- font-size: 25rpx;
- font-weight: 700;
- line-height: 76rpx;
- text-align: center;
- background: #f7f9fd;
- border: 2rpx solid transparent;
- box-sizing: border-box;
- overflow: hidden;
- white-space: nowrap;
- text-overflow: ellipsis;
- }
- .option.active { color: #0b8cff; background: #eff8ff; border-color: #0b8cff; }
-
- .ratio-grid {
- margin-top: 20rpx;
- display: grid;
- grid-template-columns: repeat(5, minmax(0, 1fr));
- gap: 12rpx;
- }
- .ratio {
- height: 72rpx;
- border-radius: 18rpx;
- color: #596579;
- font-size: 24rpx;
- font-weight: 800;
- line-height: 72rpx;
- text-align: center;
- background: #f7f9fd;
- border: 2rpx solid transparent;
- }
- .ratio.active { color: #0b8cff; background: #eff8ff; border-color: #0b8cff; }
-
- .quantity { margin-top: 22rpx; display: flex; align-items: center; gap: 28rpx; }
- .quantity-btn {
- width: 76rpx;
- height: 76rpx;
- border-radius: 20rpx;
- color: #0b8cff;
- font-size: 36rpx;
- font-weight: 800;
- line-height: 76rpx;
- text-align: center;
- background: #eef7ff;
- }
- .quantity-value { min-width: 60rpx; color: #172033; font-size: 32rpx; font-weight: 900; text-align: center; }
- .note { display: block; margin-top: 16rpx; color: #8a95a6; font-size: 22rpx; }
-
- .textarea {
- margin-top: 20rpx;
- width: 100%;
- min-height: 150rpx;
- padding: 22rpx;
- border-radius: 20rpx;
- color: #172033;
- font-size: 25rpx;
- line-height: 1.5;
- background: #f7f9fd;
- box-sizing: border-box;
- }
- .tips { margin-top: 18rpx; }
- .tip-item { display: block; margin-top: 6rpx; color: #8a95a6; font-size: 22rpx; line-height: 1.5; }
-
- .progress-box { padding: 50rpx 0; display: flex; flex-direction: column; align-items: center; }
- .progress-ring {
- width: 64rpx;
- height: 64rpx;
- border: 6rpx solid #e0f0ff;
- border-top-color: #0b8cff;
- border-radius: 50%;
- animation: spin 0.9s linear infinite;
- }
- @keyframes spin { to { transform: rotate(360deg); } }
- .progress-title { margin-top: 22rpx; color: #172033; font-size: 28rpx; font-weight: 800; }
- .progress-sub {
- margin-top: 10rpx;
- padding: 0 40rpx;
- color: #8a95a6;
- font-size: 23rpx;
- line-height: 1.5;
- text-align: center;
- }
-
- .fail-box { padding: 44rpx 0; display: flex; flex-direction: column; align-items: center; }
- .fail-title { color: #f04438; font-size: 28rpx; font-weight: 800; }
- .fail-sub { margin-top: 10rpx; color: #8a95a6; font-size: 23rpx; }
-
- .result-box { margin-top: 20rpx; }
- .result-grid {
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 14rpx;
- }
- .result-grid.single { grid-template-columns: 1fr; }
- .result-img {
- width: 100%;
- height: 420rpx;
- border-radius: 18rpx;
- background: #f1f5fa;
- }
- .result-grid.single .result-img { height: 720rpx; }
- .result-actions { margin-top: 20rpx; display: flex; gap: 16rpx; }
- .ghost-btn {
- flex: 1;
- height: 76rpx;
- border-radius: 999rpx;
- color: #1f8cff;
- font-size: 25rpx;
- font-weight: 800;
- line-height: 76rpx;
- text-align: center;
- background: #eef7ff;
- }
-
- .bottom-bar {
- position: fixed;
- left: 0;
- right: 0;
- bottom: 0;
- z-index: 80;
- padding: 18rpx 28rpx calc(18rpx + env(safe-area-inset-bottom));
- background: rgba(255, 255, 255, 0.96);
- box-shadow: 0 -10rpx 30rpx rgba(32, 56, 92, 0.1);
- }
- .balance {
- margin-bottom: 14rpx;
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 20rpx;
- color: #7b8797;
- font-size: 24rpx;
- line-height: 1.35;
- }
- .balance-copy { min-width: 0; flex: 1; }
- .recharge-link { color: #0b8cff; font-size: 24rpx; font-weight: 800; flex-shrink: 0; }
- .buttons { display: flex; gap: 22rpx; }
- .history-btn,
- .submit-btn {
- height: 86rpx;
- border-radius: 999rpx;
- font-size: 30rpx;
- font-weight: 800;
- display: flex;
- align-items: center;
- justify-content: center;
- }
- .history-btn { width: 210rpx; color: #1f8cff; background: #eef7ff; }
- .submit-btn {
- flex: 1;
- color: #fff;
- background: linear-gradient(135deg, #1f8cff, #41b9ff);
- box-shadow: 0 12rpx 24rpx rgba(31, 140, 255, 0.28);
- }
- .submit-btn.disabled { opacity: 0.6; }
- .submit-icon { width: 24rpx; height: 24rpx; margin-right: 8rpx; }
-
- /* 对比弹层:与老照片 / 动漫头像保持一致 */
- .compare-modal {
- position: fixed;
- left: 0;
- right: 0;
- top: 0;
- bottom: 0;
- z-index: 120;
- }
- .compare-backdrop {
- position: absolute;
- left: 0;
- right: 0;
- top: 0;
- bottom: 0;
- background: rgba(15, 23, 42, 0.58);
- }
- .compare-panel {
- position: absolute;
- left: 36rpx;
- right: 36rpx;
- top: 50%;
- transform: translateY(-50%);
- padding: 24rpx;
- border-radius: 28rpx;
- background: #ffffff;
- }
- .compare-panel-head {
- margin-bottom: 20rpx;
- display: flex;
- align-items: center;
- justify-content: space-between;
- color: #172033;
- font-size: 32rpx;
- font-weight: 800;
- }
- .close-btn {
- width: 56rpx;
- height: 56rpx;
- border-radius: 50%;
- color: #64748b;
- background: #f1f5f9;
- font-size: 42rpx;
- line-height: 50rpx;
- text-align: center;
- font-weight: 300;
- }
- .large-compare {
- width: 100%;
- height: 900rpx;
- max-height: 70vh;
- border-radius: 22rpx;
- }
- .large-compare .compare-before .compare-img { width: calc(100vw - 120rpx); }
- .compare-guide {
- display: block;
- margin-top: 18rpx;
- color: #64748b;
- font-size: 24rpx;
- text-align: center;
- }
- </style>
|