|
- <template>
- <div class="min-h-screen bg-[#f3f7fb]">
- <!-- ========== Hero (对齐 V2 渐变风格) ========== -->
- <div class="relative overflow-hidden"
- style="background: linear-gradient(135deg, #ffffff 0%, #edf6ff 58%, #fff7f0 100%)">
- <div class="max-w-7xl mx-auto px-4 sm:px-6 py-8 lg:py-10">
- <div class="lg:flex lg:items-center lg:justify-between gap-8">
- <div class="max-w-[480px]">
- <div class="text-[11px] lg:text-xs font-extrabold tracking-wider text-[#1f8cff]">ANIME AVATAR STUDIO</div>
- <h1 class="mt-3 text-[26px] lg:text-[36px] font-extrabold leading-tight text-[#172033]">上传自拍,生成你的动漫头像</h1>
- <p class="mt-3 text-sm lg:text-base text-[#66758a] leading-relaxed">用真实对比看效果。支持竖图、半身照和普通自拍,脸部清晰会更稳定。</p>
- <div class="mt-5 flex items-center gap-3">
- <button type="button"
- class="h-10 lg:h-11 px-5 rounded-full text-white text-sm font-extrabold bg-[#1f8cff] shadow-[0_10px_20px_rgba(31,140,255,0.22)] hover:bg-[#1979e6] transition-colors"
- @click="openCompare">查看对比</button>
- <button type="button"
- class="h-10 lg:h-11 px-5 rounded-full text-[#1f8cff] text-sm font-extrabold bg-white hover:bg-[#f7faff] transition-colors"
- @click="triggerUpload">上传照片</button>
- </div>
- </div>
- <!-- 预览对比卡片 (所有屏幕可见) -->
- <div v-if="demoPair.original && demoPair.anime" class="mt-6 lg:mt-0 lg:w-[200px] flex-shrink-0 cursor-pointer" @click="openCompare">
- <div class="relative w-[140px] h-[210px] lg:w-[180px] lg:h-[270px] mx-auto rounded-[28px] overflow-hidden shadow-[0_18px_38px_rgba(31,62,112,0.18)] bg-[#dfe8f4]">
- <img :src="demoPair.anime" class="w-full h-full object-cover" alt="anime" />
- <div class="absolute left-0 top-0 bottom-0 w-1/2 overflow-hidden">
- <img :src="demoPair.original" class="w-[140px] lg:w-[180px] h-full object-cover" alt="original" />
- </div>
- <div class="absolute top-0 bottom-0 left-1/2 w-0.5 -ml-px bg-white/95 shadow-[0_0_12px_rgba(20,37,66,0.22)]" />
- <div class="absolute top-3 left-3 px-2 py-0.5 rounded-full text-[10px] font-bold text-white bg-[#0f172a]/50">原图</div>
- <div class="absolute top-3 right-3 px-2 py-0.5 rounded-full text-[10px] font-bold text-white bg-[#0f172a]/50">动漫</div>
- </div>
- </div>
- </div>
- </div>
- </div>
-
- <!-- ========== Main Grid ========== -->
- <div class="max-w-7xl mx-auto px-4 sm:px-6 py-6 lg:py-8">
- <div class="lg:grid lg:grid-cols-12 lg:gap-8">
- <!-- ===== LEFT: Form (col-span-7) ===== -->
- <div class="lg:col-span-7 space-y-5 pb-24 lg:pb-0">
- <!-- 1. 参考照片 Upload Card -->
- <div class="bg-white rounded-2xl p-5 lg:p-6 shadow-[0_10px_28px_rgba(32,56,92,0.05)]">
- <div class="flex items-start justify-between gap-4 mb-4">
- <div class="min-w-0">
- <h3 class="text-base lg:text-lg font-extrabold text-[#172033]">参考照片</h3>
- <p class="mt-1 text-xs text-[#8a97a8]">建议使用正面、上半身、脸部无遮挡的照片</p>
- </div>
- <span class="flex-shrink-0 px-3 py-1 rounded-full text-[11px] font-bold text-[#ff6b6b] bg-[#fff1f1]">必选</span>
- </div>
- <!-- Upload Strip -->
- <div class="rounded-2xl border-2 border-[#e5edf7] bg-[#f7faff] p-4 flex items-center gap-4 cursor-pointer hover:border-[#1f8cff]/40 transition-colors"
- @click="triggerUpload" @dragover.prevent @drop.prevent="onDrop">
- <div class="w-20 h-28 rounded-2xl bg-[#edf3fb] overflow-hidden relative flex-shrink-0">
- <img v-if="previewImage" :src="previewImage" class="w-full h-full object-cover" alt="preview" />
- <div v-else class="w-full h-full flex items-center justify-center">
- <span class="w-9 h-9 rounded-full bg-[#1f8cff] text-white text-2xl font-light flex items-center justify-center">+</span>
- </div>
- <div v-if="uploading" class="absolute inset-0 bg-[#111e34]/60 text-white text-xs flex items-center justify-center">上传中</div>
- </div>
- <div class="flex-1 min-w-0">
- <div class="text-sm lg:text-base font-extrabold text-[#172033]">{{ previewImage ? '已选择照片' : '选择一张清晰自拍' }}</div>
- <div class="mt-1.5 text-xs text-[#8a97a8]">不用裁成正方形,竖图和半身照也可以</div>
- </div>
- <div class="w-16 h-9 rounded-full bg-[#1f8cff] text-white text-xs font-extrabold flex items-center justify-center flex-shrink-0">{{ previewImage ? '更换' : '上传' }}</div>
- </div>
- <input ref="fileInput" type="file" accept="image/*" class="hidden" @change="onFileChange" />
- <!-- Tips -->
- <div class="mt-3 flex gap-2.5">
- <span class="px-3 py-1 rounded-full text-[11px] font-bold text-[#526174] bg-[#f1f5fa]">正面</span>
- <span class="px-3 py-1 rounded-full text-[11px] font-bold text-[#526174] bg-[#f1f5fa]">脸部清晰</span>
- <span class="px-3 py-1 rounded-full text-[11px] font-bold text-[#526174] bg-[#f1f5fa]">避免遮挡</span>
- </div>
- </div>
-
- <!-- 2. 头像风格 -->
- <div class="bg-white rounded-2xl p-5 lg:p-6 shadow-[0_10px_28px_rgba(32,56,92,0.05)]">
- <div class="flex items-start justify-between gap-4">
- <h3 class="text-base lg:text-lg font-extrabold text-[#172033]">头像风格</h3>
- <span class="text-xs text-[#8a97a8] mt-1">点选切换</span>
- </div>
- <div v-if="styleLoading" class="mt-4 flex justify-center py-8">
- <span class="w-6 h-6 border-2 border-[#1f8cff] border-t-transparent rounded-full animate-spin" />
- </div>
- <template v-else>
- <!-- 风格网格:移动 3 列 / 桌面 4 列,默认 2 行折叠 -->
- <div class="mt-4 grid grid-cols-3 lg:grid-cols-4 gap-2.5 lg:gap-3">
- <button v-for="s in visibleStyleList" :key="s.value" type="button"
- class="p-2 rounded-2xl border-2 transition-all text-center"
- :class="form.img2img_style === String(s.value)
- ? 'bg-[#eef7ff] border-[#1f8cff]'
- : 'bg-[#f6f9fd] border-transparent hover:border-[#e5edf7]'"
- @click="selectStyle(s)">
- <div class="w-full aspect-square rounded-xl overflow-hidden bg-[#eaf0f8] flex items-center justify-center">
- <img v-if="s.pic" :src="s.pic" class="w-full h-full object-cover" :alt="s.name" />
- <span v-else class="text-xs text-[#77869a]">{{ s.name }}</span>
- </div>
- <div class="mt-2 text-xs font-bold text-[#334155] truncate">{{ s.name }}</div>
- </button>
- </div>
- <!-- 展开/收起按钮 -->
- <button v-if="hasMoreStyles" type="button"
- class="mt-3 w-full h-10 rounded-xl border border-[#e5edf7] text-[#1f8cff] text-sm font-bold hover:bg-[#eef7ff] transition-colors flex items-center justify-center gap-1"
- @click="styleExpanded = !styleExpanded">
- <span>{{ styleExpanded ? '收起' : '展开全部' }}</span>
- <UIcon :name="styleExpanded ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'" class="size-4" />
- </button>
- </template>
- </div>
-
- <!-- 3. 自由度 -->
- <div class="bg-white rounded-2xl p-5 lg:p-6 shadow-[0_10px_28px_rgba(32,56,92,0.05)]">
- <div class="flex items-start justify-between gap-4">
- <div>
- <h3 class="text-base lg:text-lg font-extrabold text-[#172033]">自由度</h3>
- <p class="mt-1 text-xs text-[#8a97a8]">{{ strengthCopy }}</p>
- </div>
- <span class="text-lg font-extrabold text-[#1f8cff]">{{ form.img2img_strength.toFixed(2) }}</span>
- </div>
- <input type="range" min="0.1" max="1" step="0.01" v-model.number="form.img2img_strength"
- class="mt-4 w-full h-2 rounded-full appearance-none bg-[#e7edf5] accent-[#1f8cff] cursor-pointer" />
- <div class="mt-2 flex justify-between text-xs text-[#8a97a8]">
- <span>更像原图</span><span>更有创意</span>
- </div>
- </div>
-
- <!-- 4. 画面偏好 -->
- <div class="bg-white rounded-2xl p-5 lg:p-6 shadow-[0_10px_28px_rgba(32,56,92,0.05)]">
- <div class="flex items-start justify-between gap-4">
- <h3 class="text-base lg:text-lg font-extrabold text-[#172033]">画面偏好</h3>
- <span class="text-xs text-[#8a97a8] mt-1">可选</span>
- </div>
- <div class="mt-4">
- <label class="block text-xs font-bold text-[#526174] mb-2">描述词</label>
- <input v-model="form.content" type="text" maxlength="100" placeholder="例如:浅色背景、清爽、微笑"
- class="w-full h-12 rounded-xl bg-[#f6f9fd] px-4 text-sm text-[#172033] placeholder:text-[#a0aabb] focus:outline-none focus:ring-2 focus:ring-[#1f8cff]/30" />
- </div>
- <div class="mt-4">
- <label class="block text-xs font-bold text-[#526174] mb-2">反面提示词</label>
- <input v-model="form.no" type="text" maxlength="100" placeholder="例如:模糊、变形、低清晰度"
- class="w-full h-12 rounded-xl bg-[#f6f9fd] px-4 text-sm text-[#172033] placeholder:text-[#a0aabb] focus:outline-none focus:ring-2 focus:ring-[#1f8cff]/30" />
- </div>
- </div>
-
- <!-- 5. Tips -->
- <div class="bg-white rounded-2xl p-5 lg:p-6 shadow-[0_10px_28px_rgba(32,56,92,0.05)] space-y-3">
- <div class="flex items-center">
- <span class="w-7 h-5 mr-3 rounded-[14px] bg-[#eef7ff] text-[#1f8cff] text-[11px] font-extrabold flex items-center justify-center flex-shrink-0">01</span>
- <span class="text-sm text-[#5f6f83] leading-relaxed">上传照片后可直接生成,处理完成会进入作品详情页。</span>
- </div>
- <div class="flex items-center">
- <span class="w-7 h-5 mr-3 rounded-[14px] bg-[#eef7ff] text-[#1f8cff] text-[11px] font-extrabold flex items-center justify-center flex-shrink-0">02</span>
- <span class="text-sm text-[#5f6f83] leading-relaxed">历史记录里可以查看生成进度,也可以继续打开已完成作品。</span>
- </div>
- </div>
-
- <!-- Desktop Submit -->
- <div class="hidden lg:flex items-center gap-3 rounded-2xl bg-white p-4 shadow-[0_10px_28px_rgba(32,56,92,0.05)]">
- <div class="flex-1 min-w-0">
- <div class="text-sm font-bold text-[#172033] whitespace-nowrap">{{ balanceText }}</div>
- <div v-if="isBalanceInsufficient" class="mt-1 text-xs text-[#f04438]">点数不足,请前往充值</div>
- </div>
- <button type="button"
- class="px-10 h-14 rounded-full text-white font-bold text-base shadow-[0_12px_24px_rgba(31,140,255,0.28)] transition-all flex-shrink-0 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center min-w-[160px]"
- :class="submitting ? 'bg-[#1979e6]' : 'bg-gradient-to-r from-[#1f8cff] to-[#41b9ff] hover:opacity-90'"
- :disabled="!canSubmit || submitting" @click="handleSubmit">
- <UIcon v-if="!submitting" name="i-lucide-zap" class="size-4 mr-1.5" />
- <span>{{ submitting ? '生成中...' : submitText }}</span>
- </button>
- </div>
- </div>
-
- <!-- ===== RIGHT: Result (col-span-5, sticky) ===== -->
- <div class="lg:col-span-5">
- <div class="lg:sticky lg:top-6 space-y-4">
- <!-- Empty -->
- <div v-if="!resultState.visible" class="bg-white rounded-2xl p-8 lg:p-10 shadow-sm text-center min-h-[300px] flex flex-col items-center justify-center">
- <UIcon name="i-lucide-image" class="size-12 text-[#a0aabb] mb-4" />
- <div class="text-base font-medium text-[#172033] mb-1">等待生成</div>
- <div class="text-sm text-[#a0aabb]">上传照片并选择风格后</div>
- <div class="text-sm text-[#a0aabb]">点击"立即生成"即可看到效果</div>
- </div>
-
- <!-- Loading -->
- <div v-if="resultState.loading" class="bg-white rounded-2xl p-6 lg:p-8 shadow-sm">
- <div class="text-base font-bold text-[#172033] mb-1">AI 正在创作中</div>
- <div class="text-sm text-[#7f8896] mb-6">预计需要 10-30 秒,请耐心等待...</div>
- <div class="rounded-2xl bg-[#f7f9fd] py-10 flex flex-col items-center">
- <div class="flex gap-1.5 mb-4">
- <span class="w-2.5 h-2.5 rounded-full bg-[#1f8cff] animate-pulse-dot" />
- <span class="w-2.5 h-2.5 rounded-full bg-[#1f8cff] animate-pulse-dot2" />
- <span class="w-2.5 h-2.5 rounded-full bg-[#1f8cff] animate-pulse-dot3" />
- </div>
- <div class="text-sm font-bold text-[#172033]">正在生成你的动漫头像</div>
- <div v-if="pollCountdown > 0" class="mt-2 text-xs text-[#8a95a6]">下次刷新 {{ pollCountdown }}s</div>
- </div>
- </div>
-
- <!-- Error -->
- <div v-if="resultState.error" class="bg-white rounded-2xl p-6 lg:p-8 shadow-sm text-center py-10">
- <UIcon name="i-lucide-circle-alert" class="size-12 text-red-400 mb-3 mx-auto" />
- <div class="text-base font-medium text-[#f04438] mb-2">生成失败</div>
- <div class="text-sm text-[#7f8896] mb-4">{{ resultState.errorMsg }}</div>
- <UButton color="primary" variant="outline" size="sm" @click="handleSubmit">重试</UButton>
- </div>
-
- <!-- Success -->
- <div v-if="resultState.success" class="space-y-4">
- <div class="bg-white rounded-2xl p-5 lg:p-6 shadow-sm">
- <div class="text-base font-bold text-[#172033] mb-4">效果对比</div>
- <div class="grid grid-cols-2 gap-3">
- <div>
- <div class="text-xs text-[#8a95a6] mb-2">原图</div>
- <img v-if="previewImage" :src="previewImage" class="w-full aspect-square rounded-xl object-cover bg-[#f7f9fd]" alt="original" />
- </div>
- <div>
- <div class="text-xs text-[#8a95a6] mb-2">动漫头像</div>
- <img :src="genResultUrl" class="w-full aspect-square rounded-xl object-cover border-2 border-[#1f8cff] bg-[#f7f9fd]" alt="result" />
- </div>
- </div>
- <div class="mt-5 flex gap-3">
- <button type="button"
- class="flex-1 h-12 rounded-full border-2 border-[#1f8cff] text-[#1f8cff] font-bold hover:bg-[#eef7ff] transition-colors flex items-center justify-center"
- @click="openResultCompare">
- <UIcon name="i-lucide-split-square-horizontal" class="size-4 mr-1.5" />
- 对比查看
- </button>
- <button type="button"
- class="flex-1 h-12 rounded-full bg-[#1f8cff] hover:bg-[#1979e6] text-white font-bold text-base shadow-[0_8px_20px_rgba(31,140,255,0.2)] transition-colors flex items-center justify-center"
- @click="downloadResult">
- <UIcon name="i-lucide-download" class="size-4 mr-1.5" />
- 下载头像
- </button>
- </div>
- </div>
- <div class="flex gap-3">
- <button type="button"
- class="flex-1 h-12 rounded-full border-2 border-[#1f8cff] text-[#1f8cff] font-bold hover:bg-[#eef7ff] transition-colors"
- :disabled="submitting" @click="handleSubmit">重新生成</button>
- </div>
- </div>
- </div>
- </div>
- </div>
- </div>
-
- <!-- ========== Mobile Bottom Bar ========== -->
- <div class="lg:hidden fixed left-0 right-0 bottom-0 z-50 bg-white/95 backdrop-blur shadow-[0_-10px_30px_rgba(32,56,92,0.1)]"
- style="padding: 14px 16px calc(14px + env(safe-area-inset-bottom))">
- <div class="flex items-center justify-between mb-2.5 gap-3 text-sm">
- <div class="min-w-0 truncate text-[#7b8797]">{{ balanceText }}</div>
- <button v-if="isBalanceInsufficient" class="text-[#1f8cff] text-xs font-extrabold flex-shrink-0" @click="goRecharge">去充值</button>
- </div>
- <div class="flex gap-3">
- <button class="w-[120px] h-12 rounded-full bg-[#eef7ff] text-[#1f8cff] text-sm font-extrabold" @click="goHistory">历史记录</button>
- <button class="flex-1 h-12 rounded-full text-white text-sm font-extrabold shadow-[0_12px_24px_rgba(31,140,255,0.28)] transition-opacity flex items-center justify-center"
- :class="(canSubmit && !submitting) ? 'bg-gradient-to-r from-[#1f8cff] to-[#41b9ff]' : 'bg-gradient-to-r from-[#1f8cff] to-[#41b9ff] opacity-60'"
- :disabled="!canSubmit || submitting" @click="handleSubmit">
- <UIcon v-if="!submitting" name="i-lucide-zap" class="size-4 mr-1.5" />
- {{ submitting ? '生成中...' : submitText }}
- </button>
- </div>
- </div>
-
- <!-- ========== Compare Modal (大图滑块对比,支持 demo 和生成结果) ========== -->
- <Teleport to="body">
- <Transition name="modal">
- <div v-if="compareVisible" class="fixed inset-0 z-[100] flex items-center justify-center p-4">
- <div class="absolute inset-0 bg-[#0f172a]/60" @click="closeCompare" />
- <div class="relative bg-white rounded-2xl shadow-2xl w-full max-w-2xl overflow-hidden">
- <div class="flex items-center justify-between px-5 py-4 border-b border-gray-100">
- <h3 class="text-base font-extrabold text-[#172033]">{{ compareTarget === 'result' ? '生成结果对比' : '效果对比' }}</h3>
- <button class="text-[#a0aabb] text-2xl leading-none w-8 h-8 flex items-center justify-center rounded-full hover:bg-gray-100" @click="closeCompare">×</button>
- </div>
- <div class="p-5">
- <div ref="largeCompareRef" class="relative w-full aspect-[3/4] max-h-[70vh] rounded-2xl overflow-hidden bg-[#dfe8f4] cursor-ew-resize select-none"
- @mousedown="onCompareStart" @touchstart.passive="onCompareStart">
- <!-- 动漫图(底层,全尺寸) -->
- <img v-if="comparePair.anime" :src="comparePair.anime" class="absolute inset-0 w-full h-full object-cover pointer-events-none" alt="anime" />
- <!-- 原图(上层,用 clip-path 裁剪,与底层完全重叠) -->
- <img v-if="comparePair.original" :src="comparePair.original" class="absolute inset-0 w-full h-full object-cover pointer-events-none"
- :style="{ clipPath: `inset(0 ${100 - comparePercent}% 0 0)` }" alt="original" />
- <!-- 分割线 + 拖拽手柄 -->
- <div class="absolute top-0 bottom-0 w-1 -ml-px bg-white/95 shadow-[0_0_12px_rgba(20,37,66,0.22)] pointer-events-none" :style="{ left: comparePercent + '%' }">
- <div class="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-9 h-14 rounded-full bg-white shadow-[0_8px_18px_rgba(20,37,66,0.22)] flex items-center justify-center text-[#1f8cff] text-lg font-extrabold gap-px">
- <span>‹</span><span>›</span>
- </div>
- </div>
- <div class="absolute top-3 left-3 px-2 py-0.5 rounded-full text-[10px] font-bold text-white bg-[#0f172a]/50 pointer-events-none">原图</div>
- <div class="absolute top-3 right-3 px-2 py-0.5 rounded-full text-[10px] font-bold text-white bg-[#0f172a]/50 pointer-events-none">{{ compareTarget === 'result' ? '生成图' : '动漫图' }}</div>
- </div>
- <div class="mt-3 text-center text-xs text-[#64748b]">左右拖动中间按钮查看变化</div>
- </div>
- </div>
- </div>
- </Transition>
- </Teleport>
- </div>
- </template>
-
- <script setup>
- import { computed, reactive, ref, onMounted, onUnmounted } from 'vue'
-
- const _api = useApi()
- const api = _api // _api 是 { get, post, request },这里直接别名保持调用方式不变
- const user = useUser()
- const router = useRouter()
- const { uploadImage } = useUpload()
- const toast = useToast()
-
- function showToast(message, color = 'red') {
- toast.add({ title: message, color })
- }
-
- const ANIME_BILLING_MODEL = 'tencent_anime'
-
- // ==================== Form ====================
- const form = reactive({
- imgs: [], content: '', ai_type: 3, style_type: 1, ar: '1:1', no: '',
- model: ANIME_BILLING_MODEL, model_type: 6, light: '', text: '', speed: '',
- img2img_style: '201', age: 80, change_sex_type: '0', img2img_strength: 0.6, num: 1,
- app_name: 'anime-avatar',
- })
-
- // ==================== State ====================
- const fileInput = ref(null)
- const previewImage = ref('')
- const uploading = ref(false)
- const submitting = ref(false)
- const styleList = ref([{ value: '201', name: '默认', model: 2, pic: '' }])
- const styleLoading = ref(false)
- const styleExpanded = ref(false)
- const STYLE_COLLAPSED_COUNT = 8 // 2 行 × 4 列(桌面)
- const visibleStyleList = computed(() =>
- styleExpanded.value ? styleList.value : styleList.value.slice(0, STYLE_COLLAPSED_COUNT)
- )
- const hasMoreStyles = computed(() => styleList.value.length > STYLE_COLLAPSED_COUNT)
- const balance = reactive({ points: 0 })
- const quote = reactive({ points: 0 })
- const chatOpenId = ref('')
- const demoPair = ref({ original: '', anime: '' })
-
- const resultState = ref({ visible: false, loading: false, error: false, success: false, errorMsg: '' })
- const genResultUrl = ref('')
-
- let pollTimer = null
- let countTimer = null
- const pollCountdown = ref(3)
-
- // ==================== Computed ====================
- const canSubmit = computed(() => form.imgs.length > 0 && !uploading.value)
- const currentPoints = computed(() => Number(balance.points || 0))
- const isBalanceInsufficient = computed(() =>
- user.isLogin.value && Number(quote.points || 0) > 0 && currentPoints.value >= 0 && currentPoints.value < Number(quote.points || 0)
- )
- const balanceText = computed(() => {
- const c = Number(quote.points || 0)
- if (!user.isLogin.value) return c ? `动漫头像 · ${c}点/张` : '动漫头像'
- return c ? `余额${currentPoints.value}点 · 预计消耗${c}点` : `余额${currentPoints.value}点`
- })
- const submitText = computed(() => {
- const c = Number(quote.points || 0)
- if (!user.isLogin.value) return c ? `登录后生成 · ${c}点` : '登录后生成'
- return c ? `立即生成 · ${c}点` : '立即生成'
- })
- const strengthCopy = computed(() => '推荐 0.6 - 0.8,保留人脸特征')
-
- // ==================== Upload ====================
- function triggerUpload() {
- if (!user.isLogin.value) { showToast('请先登录'); router.push('/login'); return }
- fileInput.value?.click()
- }
- function onFileChange(e) {
- const f = e.target.files?.[0]
- if (f) handleFile(f)
- e.target.value = ''
- }
- function onDrop(e) {
- const f = e.dataTransfer?.files?.[0]
- if (f) handleFile(f)
- }
- async function handleFile(file) {
- if (uploading.value) return
- uploading.value = true
- try {
- previewImage.value = URL.createObjectURL(file)
- const r = await uploadImage(file)
- if (r.data?.url) {
- form.imgs = [r.data.url]
- if (r.data.width) form.width = r.data.width
- if (r.data.height) form.height = r.data.height
- } else {
- previewImage.value = ''
- showToast(r.msg || '上传失败')
- }
- } catch (e) {
- previewImage.value = ''
- form.imgs = []
- showToast(e.msg || e.message || '上传失败')
- } finally {
- uploading.value = false
- }
- }
-
- // ==================== Style List (无需登录) ====================
- async function loadStyles() {
- styleLoading.value = true
- try {
- const r = await api.post('/anime/getstylelist', { model: '2' })
- const rows = Array.isArray(r.list) ? r.list : (Array.isArray(r.data) ? r.data : [])
- const list = rows.map((item) => ({
- ...item, value: String(item.value),
- pic: item.pic || item.img || item.image || item.cover || item.icon || item.url || '',
- }))
- if (list.length) {
- styleList.value = list
- if (!form.img2img_style || !list.some(s => s.value === form.img2img_style)) {
- selectStyle(list[0], false)
- }
- }
- } catch (e) { console.error('[anime-avatar] loadStyles failed:', e) }
- styleLoading.value = false
- }
- function selectStyle(item, resetStrength = true) {
- form.img2img_style = String(item.value)
- form.ai_type = 3
- if (resetStrength) form.img2img_strength = 0.6
- }
-
- // ==================== Demo (for hero compare) ====================
- async function loadDemo() {
- try {
- const r = await api.post('/common/getconfig', { keys: 'anime_demo_v2,anime_demo' })
- const rows = Array.isArray(r.list) ? r.list : []
- const v2 = rows.find(it => it.key === 'anime_demo_v2')
- const old = rows.find(it => it.key === 'anime_demo')
- let list = []
- if (v2 && v2.value) {
- try {
- const data = typeof v2.value === 'string' ? JSON.parse(v2.value) : v2.value
- const arr = Array.isArray(data) ? data : [data]
- list = arr.map(it => ({
- original: it.original || it.before || it.source || '',
- anime: it.anime || it.after || it.result || '',
- })).filter(it => it.original && it.anime).slice(0, 1)
- } catch {}
- }
- if (!list.length && old && old.value) {
- const pics = String(old.value).split(',').map(s => s.trim()).filter(Boolean)
- if (pics.length >= 2) list = [{ original: pics[0], anime: pics[1] }]
- }
- if (list.length) demoPair.value = list[0]
- } catch (e) { console.error('[anime-avatar] loadDemo failed:', e) }
- }
-
- // ==================== Billing ====================
- async function loadBilling() {
- try {
- const r = await api.post('/billing/config', { app_key: 'anime-avatar', model: ANIME_BILLING_MODEL })
- if (r.code === 0 && r.data) {
- quote.points = r.data.quote?.points || 0
- if (r.data.balance) balance.points = r.data.balance.points || r.data.balance.point_balance || 0
- }
- } catch (e) { console.error('[anime-avatar] loadBilling failed:', e) }
- }
- async function loadBalance() {
- try {
- const r = await api.post('/user/getbalance', {})
- if (r.code === 0 && r.data) balance.points = r.data.points || r.data.point_balance || 0
- } catch (e) { console.error('[anime-avatar] loadBalance failed:', e) }
- }
-
- onMounted(() => {
- loadStyles()
- loadBilling()
- loadDemo()
- if (user.isLogin.value) loadBalance()
- })
- onUnmounted(() => clearPoll())
-
- // ==================== Compare Modal ====================
- const compareVisible = ref(false)
- const comparePercent = ref(50)
- const largeCompareRef = ref(null)
- const compareTarget = ref('demo') // 'demo' | 'result'
- let dragging = false
-
- const comparePair = computed(() => {
- if (compareTarget.value === 'result' && previewImage.value && genResultUrl.value) {
- return { original: previewImage.value, anime: genResultUrl.value }
- }
- return demoPair.value
- })
-
- function openCompare() {
- if (!demoPair.value.original || !demoPair.value.anime) { showToast('暂无对比图'); return }
- comparePercent.value = 50
- compareTarget.value = 'demo'
- compareVisible.value = true
- }
- function openResultCompare() {
- if (!previewImage.value || !genResultUrl.value) return
- comparePercent.value = 50
- compareTarget.value = 'result'
- compareVisible.value = true
- }
- function closeCompare() { compareVisible.value = false; dragging = false }
- function onCompareStart(e) {
- dragging = true
- updateComparePercent(e)
- window.addEventListener('mousemove', onCompareMove)
- window.addEventListener('mouseup', onCompareEnd)
- window.addEventListener('touchmove', onCompareMove, { passive: false })
- window.addEventListener('touchend', onCompareEnd)
- }
- function onCompareMove(e) {
- if (!dragging) return
- if (e.cancelable) e.preventDefault()
- updateComparePercent(e.touches?.[0] || e)
- }
- function onCompareEnd() {
- dragging = false
- window.removeEventListener('mousemove', onCompareMove)
- window.removeEventListener('mouseup', onCompareEnd)
- window.removeEventListener('touchmove', onCompareMove)
- window.removeEventListener('touchend', onCompareEnd)
- }
- function updateComparePercent(e) {
- if (!largeCompareRef.value) return
- const rect = largeCompareRef.value.getBoundingClientRect()
- const x = (e.clientX || 0) - rect.left
- const percent = (x / rect.width) * 100
- comparePercent.value = Math.max(2, Math.min(98, Number(percent.toFixed(1))))
- }
-
- // ==================== Submit ====================
- function clearPoll() {
- clearTimeout(pollTimer)
- clearInterval(countTimer)
- }
- async function startPoll() {
- clearPoll()
- pollCountdown.value = 3
- countTimer = setInterval(() => { pollCountdown.value = Math.max(0, pollCountdown.value - 1) }, 1000)
- pollTimer = setTimeout(async () => {
- try {
- const r = await api.post('/anime/getdetail', { chat_open_id: chatOpenId.value })
- const d = r.data || {}
- const s = Number(d.is_suc)
- if (s > 0) {
- genResultUrl.value = d.answer || d.result_url || d.url || ''
- resultState.value = { visible: true, loading: false, error: false, success: true, errorMsg: '' }
- clearPoll()
- loadBalance()
- } else if (s < 0) {
- resultState.value = { visible: true, loading: false, error: true, success: false, errorMsg: d.err_msg || d.msg || '生成失败,请重试' }
- clearPoll()
- } else {
- startPoll()
- }
- } catch (e) {
- resultState.value = { visible: true, loading: false, error: true, success: false, errorMsg: e.msg || e.message || '查询失败' }
- }
- }, 3000)
- }
-
- async function handleSubmit() {
- if (!user.isLogin.value) { showToast('请先登录'); router.push('/login'); return }
- if (uploading.value) { showToast('图片正在上传'); return }
- if (!form.imgs.length) { showToast('请上传参考图'); return }
- if (submitting.value) return
-
- submitting.value = true
- resultState.value = { visible: true, loading: true, error: false, success: false, errorMsg: '' }
- genResultUrl.value = ''
-
- try {
- const params = { ...form, imgs: form.imgs.slice(0, 1).join(',') }
- const r = await api.post('/anime/send', params)
-
- if (r.code === 1011) {
- resultState.value = { visible: true, loading: false, error: true, success: false, errorMsg: '点数不足,请充值后重试' }
- submitting.value = false
- return
- }
- if (r.code === 1012) {
- form.content = ''
- resultState.value = { visible: true, loading: false, error: true, success: false, errorMsg: r.msg || '内容审核不通过,请调整后重试' }
- submitting.value = false
- return
- }
- if (r.code !== 0) {
- resultState.value = { visible: true, loading: false, error: true, success: false, errorMsg: r.msg || '提交失败' }
- submitting.value = false
- return
- }
-
- chatOpenId.value = r.data?.answer_chat_open_id || r.data?.chat_open_id || ''
- if (!chatOpenId.value) {
- resultState.value = { visible: true, loading: false, error: true, success: false, errorMsg: '未获取到任务ID' }
- submitting.value = false
- return
- }
- startPoll()
- } catch (e) {
- resultState.value = { visible: true, loading: false, error: true, success: false, errorMsg: e.msg || e.message || '提交失败' }
- } finally {
- submitting.value = false
- }
- }
-
- async function downloadResult() {
- if (!genResultUrl.value) return
- try {
- const r = await fetch(genResultUrl.value)
- const b = await r.blob()
- const a = document.createElement('a')
- a.href = URL.createObjectURL(b)
- a.download = `anime-avatar-${Date.now()}.png`
- a.click()
- URL.revokeObjectURL(a.href)
- } catch {
- window.open(genResultUrl.value, '_blank')
- }
- }
-
- function goRecharge() { router.push('/recharge') }
- function goHistory() {
- if (!user.isLogin.value) { showToast('请先登录'); router.push('/login'); return }
- router.push('/works')
- }
- </script>
-
- <style scoped>
- .animate-pulse-dot { animation: dotPulse 1.1s ease-in-out infinite; }
- .animate-pulse-dot2 { animation: dotPulse 1.1s ease-in-out 0.15s infinite; }
- .animate-pulse-dot3 { animation: dotPulse 1.1s ease-in-out 0.3s infinite; }
- @keyframes dotPulse {
- 0%, 100% { opacity: 0.35; transform: scale(0.82); }
- 50% { opacity: 1; transform: scale(1); }
- }
- .modal-enter-active, .modal-leave-active { transition: opacity 0.2s ease; }
- .modal-enter-from, .modal-leave-to { opacity: 0; }
- </style>
|