forked from natuka/web.puabadge.com
新增:京东下单页面
This commit is contained in:
1 parent
8e8cde9ef5
commit
007c675603
25 files changed
+3264
-25
No files matched your search
@@ -0,0 +1,347 @@
|
||||
<template>
|
||||
<div class="composer">
|
||||
<div v-if="editing" class="composer__edit-bar">
|
||||
<span class="composer__edit-left">
|
||||
<el-icon :size="14"><EditPen /></el-icon>
|
||||
<span>{{ editingHint }}</span>
|
||||
</span>
|
||||
<button class="composer__exit" type="button" @click="emit('exit-edit')">
|
||||
<el-icon :size="12"><Close /></el-icon>
|
||||
<span>{{ exitEditLabel }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div ref="boxRef" class="composer__box">
|
||||
<div v-if="previewUrl" ref="previewRef" class="composer__preview">
|
||||
<img class="composer__preview-img" :src="previewUrl" alt="已选图片" />
|
||||
<button class="composer__preview-close" type="button" aria-label="移除图片" @click="emit('clear-preview')">
|
||||
<el-icon :size="10" color="#fff"><Close /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
ref="textareaRef"
|
||||
class="composer__input"
|
||||
rows="2"
|
||||
:value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
@input="onInput"
|
||||
/>
|
||||
|
||||
<div class="composer__toolbar">
|
||||
<button
|
||||
class="composer__upload"
|
||||
type="button"
|
||||
aria-label="上传图片"
|
||||
:disabled="disabled || editing || inspecting"
|
||||
@click="emit('upload')"
|
||||
>
|
||||
<el-icon v-if="inspecting" class="is-loading" :size="20"><Loading /></el-icon>
|
||||
<el-icon v-else :size="22"><Picture /></el-icon>
|
||||
</button>
|
||||
|
||||
<div class="composer__tags">
|
||||
<span v-for="tag in tagItems" :key="tag.key" class="composer__tag" :title="tag.label">
|
||||
<el-icon class="composer__tag-icon" :size="12"><component :is="tag.icon" /></el-icon>
|
||||
<span class="composer__tag-text">{{ tag.label }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="composer__send"
|
||||
type="button"
|
||||
aria-label="发送"
|
||||
:disabled="disabled || !canSend"
|
||||
@click="emit('send')"
|
||||
>
|
||||
<el-icon :size="18"><Top /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { Close, EditPen, Loading, Picture, Present, Star, Top, User } from '@element-plus/icons-vue'
|
||||
|
||||
export type ComposerTag = {
|
||||
key: string
|
||||
label: string
|
||||
icon?: 'user' | 'star' | 'present'
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue?: string
|
||||
placeholder?: string
|
||||
previewUrl?: string
|
||||
tags?: ComposerTag[]
|
||||
editing?: boolean
|
||||
editingHint?: string
|
||||
exitEditLabel?: string
|
||||
disabled?: boolean
|
||||
canSend?: boolean
|
||||
/** 图片识别 / 主体识别进行中 */
|
||||
inspecting?: boolean
|
||||
}>(),
|
||||
{
|
||||
modelValue: '',
|
||||
placeholder: '',
|
||||
previewUrl: '',
|
||||
tags: () => [],
|
||||
editing: false,
|
||||
editingHint: '',
|
||||
exitEditLabel: '退出编辑',
|
||||
disabled: false,
|
||||
canSend: false,
|
||||
inspecting: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string): void
|
||||
(e: 'upload'): void
|
||||
(e: 'clear-preview'): void
|
||||
(e: 'send'): void
|
||||
(e: 'exit-edit'): void
|
||||
}>()
|
||||
|
||||
const iconMap = {
|
||||
user: User,
|
||||
star: Star,
|
||||
present: Present,
|
||||
}
|
||||
|
||||
const tagItems = computed(() =>
|
||||
(props.tags || [])
|
||||
.filter((tag) => tag && String(tag.label || '').trim())
|
||||
.map((tag) => ({
|
||||
key: tag.key || tag.label,
|
||||
label: String(tag.label).trim(),
|
||||
icon: iconMap[tag.icon || 'present'] || Present,
|
||||
})),
|
||||
)
|
||||
|
||||
const textareaRef = ref<HTMLTextAreaElement | null>(null)
|
||||
const boxRef = ref<HTMLElement | null>(null)
|
||||
const previewRef = ref<HTMLElement | null>(null)
|
||||
|
||||
function onInput(e: Event) {
|
||||
const target = e.target as HTMLTextAreaElement
|
||||
emit('update:modelValue', target.value)
|
||||
}
|
||||
|
||||
function focus() {
|
||||
textareaRef.value?.focus()
|
||||
}
|
||||
|
||||
function getPreviewEl() {
|
||||
return previewRef.value
|
||||
}
|
||||
|
||||
function getFlyTargetEl() {
|
||||
return previewRef.value || boxRef.value
|
||||
}
|
||||
|
||||
defineExpose({ focus, getPreviewEl, getFlyTargetEl })
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.composer {
|
||||
flex-shrink: 0;
|
||||
padding: 8px 12px calc(10px + env(safe-area-inset-bottom));
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.composer__edit-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 12px;
|
||||
background: #eaf6ec;
|
||||
color: #3a8e4d;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.composer__edit-left {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.composer__exit {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
flex-shrink: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #3a8e4d;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.composer__box {
|
||||
padding: 12px 12px 10px;
|
||||
border-radius: 18px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #ebebeb;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.composer__preview {
|
||||
position: relative;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
margin-bottom: 8px;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: #f0f0f0;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.composer__preview-close {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.composer__input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
max-height: 96px;
|
||||
margin: 0 0 10px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
resize: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: #1a1a1a;
|
||||
font-family: inherit;
|
||||
|
||||
&::placeholder {
|
||||
color: #b0b0b0;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.composer__toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.composer__upload {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.is-loading {
|
||||
animation: jd-spin 0.9s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes jd-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.composer__tags {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.composer__tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 88px;
|
||||
height: 26px;
|
||||
padding: 0 8px;
|
||||
border-radius: 999px;
|
||||
background: #f2f2f2;
|
||||
color: #888;
|
||||
font-size: 12px;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.composer__tag-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.composer__tag-text {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.composer__send {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
flex-shrink: 0;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
background: #3a8e4d;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,237 @@
|
||||
<template>
|
||||
<section class="jd-stage" aria-live="polite">
|
||||
<div class="jd-stage__visual">
|
||||
<img
|
||||
class="jd-stage__gif"
|
||||
:src="loadingGif"
|
||||
alt=""
|
||||
draggable="false"
|
||||
:style="{ opacity: gifOpacity }"
|
||||
/>
|
||||
<img
|
||||
v-if="resultUrl"
|
||||
class="jd-stage__result"
|
||||
:src="resultUrl"
|
||||
alt=""
|
||||
:style="{ opacity: reveal }"
|
||||
@load="onResultLoad"
|
||||
/>
|
||||
<p class="jd-stage__status" :style="{ opacity: Math.max(0, 1 - reveal * 1.4) }">
|
||||
{{ statusLabel }}
|
||||
</p>
|
||||
<span class="jd-stage__percent" aria-label="生成进度" :style="{ opacity: Math.max(0, 1 - reveal * 1.2) }">
|
||||
{{ progressLabel }}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import loadingGif from '@/assets/jingdong/picture_cloud.gif'
|
||||
|
||||
const props = defineProps<{
|
||||
photoUrl?: string
|
||||
resultUrl?: string
|
||||
startedAt?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'done'): void
|
||||
}>()
|
||||
|
||||
const WAIT_STATUS = ['正在创建图像', '正在构思构图', '正在丰富细节', '正在打磨光影'] as const
|
||||
const REVEAL_MS = 1600
|
||||
|
||||
const phase = ref<'waiting' | 'reveal'>('waiting')
|
||||
const progress = ref(0)
|
||||
const displayProgress = ref(0)
|
||||
const reveal = ref(0)
|
||||
const gifOpacity = ref(1)
|
||||
const clock = ref(performance.now())
|
||||
const resultReady = ref(false)
|
||||
|
||||
let raf = 0
|
||||
let finished = false
|
||||
let startedAt = performance.now()
|
||||
let revealStarted = 0
|
||||
|
||||
const progressLabel = computed(() => {
|
||||
const value = Number.isFinite(displayProgress.value) ? displayProgress.value : 0
|
||||
return `${Math.max(0, Math.min(100, Math.round(value)))}%`
|
||||
})
|
||||
|
||||
const statusLabel = computed(() => {
|
||||
if (phase.value === 'reveal') return '即将完成'
|
||||
const elapsed = Math.max(0, clock.value - startedAt)
|
||||
const index = Math.floor(elapsed / 3200) % WAIT_STATUS.length
|
||||
return WAIT_STATUS[index]
|
||||
})
|
||||
|
||||
function safeProgress(value: number) {
|
||||
if (!Number.isFinite(value)) return 0
|
||||
return Math.max(0, Math.min(100, value))
|
||||
}
|
||||
|
||||
function waitingProgress(elapsedMs: number) {
|
||||
const seconds = Math.max(0, elapsedMs) / 1000
|
||||
return Math.min(88, 88 * (1 - Math.exp(-seconds / 18)))
|
||||
}
|
||||
|
||||
function smoother(value: number) {
|
||||
const amount = Math.max(0, Math.min(1, value))
|
||||
return amount * amount * amount * (amount * (amount * 6 - 15) + 10)
|
||||
}
|
||||
|
||||
function finish() {
|
||||
if (finished) return
|
||||
finished = true
|
||||
progress.value = 100
|
||||
displayProgress.value = 100
|
||||
gifOpacity.value = 0
|
||||
reveal.value = 1
|
||||
emit('done')
|
||||
}
|
||||
|
||||
function tick(now: number) {
|
||||
clock.value = now
|
||||
if (phase.value === 'waiting') {
|
||||
const elapsed = Math.max(0, now - startedAt)
|
||||
progress.value = safeProgress(waitingProgress(elapsed))
|
||||
gifOpacity.value = 1
|
||||
reveal.value = 0
|
||||
if (resultReady.value && props.resultUrl) {
|
||||
phase.value = 'reveal'
|
||||
revealStarted = now
|
||||
}
|
||||
} else if (phase.value === 'reveal') {
|
||||
const amount = smoother((now - revealStarted) / REVEAL_MS)
|
||||
reveal.value = amount
|
||||
gifOpacity.value = Math.max(0, 1 - amount)
|
||||
progress.value = safeProgress(88 + amount * 12)
|
||||
if (amount >= 1) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const gap = progress.value - displayProgress.value
|
||||
displayProgress.value = safeProgress(displayProgress.value + gap * 0.12)
|
||||
if (Math.abs(gap) < 0.05) displayProgress.value = progress.value
|
||||
|
||||
if (!finished) raf = window.requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
function onResultLoad() {
|
||||
resultReady.value = true
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.resultUrl,
|
||||
(url) => {
|
||||
if (!url) {
|
||||
resultReady.value = false
|
||||
return
|
||||
}
|
||||
// 可能已缓存,直接进入 reveal
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
resultReady.value = true
|
||||
}
|
||||
img.src = url
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
startedAt = props.startedAt && props.startedAt > 0 ? props.startedAt : performance.now()
|
||||
// startedAt 可能是 Date.now(),与 performance.now() 混用时做校正
|
||||
if (startedAt > 1e12) {
|
||||
const wallElapsed = Date.now() - startedAt
|
||||
startedAt = performance.now() - Math.max(0, wallElapsed)
|
||||
}
|
||||
progress.value = 0
|
||||
displayProgress.value = 0
|
||||
gifOpacity.value = 1
|
||||
reveal.value = 0
|
||||
finished = false
|
||||
raf = window.requestAnimationFrame(tick)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.cancelAnimationFrame(raf)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.jd-stage {
|
||||
width: min(280px, 72vw);
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.jd-stage__visual {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
max-width: 360px;
|
||||
overflow: hidden;
|
||||
border-radius: 12px;
|
||||
background: #f0f4f9;
|
||||
}
|
||||
|
||||
.jd-stage__gif,
|
||||
.jd-stage__result {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.jd-stage__gif {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.jd-stage__result {
|
||||
z-index: 3;
|
||||
object-fit: contain;
|
||||
background: #f0f4f9;
|
||||
}
|
||||
|
||||
.jd-stage__status {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: 10px;
|
||||
z-index: 4;
|
||||
margin: 0;
|
||||
color: #0b1220;
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
line-height: 1.3;
|
||||
text-shadow: 0 1px 2px rgba(255, 255, 255, 0.9);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.jd-stage__percent {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
bottom: 10px;
|
||||
z-index: 4;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 46px;
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
color: #0b1220;
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
line-height: 1;
|
||||
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.12);
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,98 @@
|
||||
<template>
|
||||
<header class="jd-nav">
|
||||
<div class="jd-nav__left">
|
||||
<img class="jd-nav__logo" :src="logo" alt="速哇" />
|
||||
<h1 class="jd-nav__title">{{ title }}</h1>
|
||||
</div>
|
||||
<button class="jd-nav__remain" type="button" @click="emit('remain')">
|
||||
<el-icon class="jd-nav__remain-icon" :size="14"><MagicStick /></el-icon>
|
||||
<span>生图剩余 {{ remain }}次</span>
|
||||
</button>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { MagicStick } from '@element-plus/icons-vue'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
title?: string
|
||||
remain?: number
|
||||
logo: string
|
||||
}>(),
|
||||
{
|
||||
title: '速哇手办制作Agent',
|
||||
remain: 0,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'remain'): void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.jd-nav {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-height: 48px;
|
||||
padding: 6px 12px;
|
||||
padding-top: calc(6px + env(safe-area-inset-top));
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.jd-nav__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.jd-nav__logo {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
object-fit: contain;
|
||||
object-position: center 35%;
|
||||
background: #fff;
|
||||
box-sizing: border-box;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.jd-nav__title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
color: #1a1a1a;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.jd-nav__remain {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
height: 28px;
|
||||
padding: 0 10px 0 8px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: #eaf6ec;
|
||||
color: #3a8e4d;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.jd-nav__remain-icon {
|
||||
color: #3a8e4d;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,169 @@
|
||||
<template>
|
||||
<div class="result-card" :class="{ 'is-editing': editing }">
|
||||
<div class="result-card__media">
|
||||
<img ref="imgRef" class="result-card__img" :src="image" alt="效果图" />
|
||||
<button class="result-card__view" type="button" @click="emit('view')">
|
||||
<span>{{ viewLabel }}</span>
|
||||
<el-icon :size="12"><FullScreen /></el-icon>
|
||||
</button>
|
||||
<button
|
||||
class="result-card__edit"
|
||||
type="button"
|
||||
@click="onEditClick"
|
||||
>
|
||||
<template v-if="editing">
|
||||
<el-icon :size="12"><Close /></el-icon>
|
||||
<span>{{ exitEditLabel }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-icon :size="12"><EditPen /></el-icon>
|
||||
<span>{{ editLabel }}</span>
|
||||
</template>
|
||||
</button>
|
||||
<button class="result-card__download" type="button" aria-label="下载" @click="emit('download')">
|
||||
<el-icon :size="16"><Download /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
<div class="result-card__meta">{{ meta }}</div>
|
||||
<button v-if="showBuy" class="result-card__buy" type="button" @click="emit('buy')">
|
||||
<el-icon :size="16"><ShoppingCart /></el-icon>
|
||||
<span>{{ buyLabel }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { Close, Download, EditPen, FullScreen, ShoppingCart } from '@element-plus/icons-vue'
|
||||
import { rectFromElement, type FlyRect } from '@/utils/fly-image'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
image: string
|
||||
meta: string
|
||||
editing?: boolean
|
||||
showBuy?: boolean
|
||||
viewLabel?: string
|
||||
editLabel?: string
|
||||
exitEditLabel?: string
|
||||
buyLabel?: string
|
||||
}>(),
|
||||
{
|
||||
editing: false,
|
||||
showBuy: true,
|
||||
viewLabel: '查看',
|
||||
editLabel: '编辑',
|
||||
exitEditLabel: '退出编辑',
|
||||
buyLabel: '立即购买',
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'view'): void
|
||||
(e: 'edit', payload: { sourceRect: FlyRect | null }): void
|
||||
(e: 'exit-edit'): void
|
||||
(e: 'download'): void
|
||||
(e: 'buy'): void
|
||||
}>()
|
||||
|
||||
const imgRef = ref<HTMLImageElement | null>(null)
|
||||
|
||||
function onEditClick() {
|
||||
if (props.editing) {
|
||||
emit('exit-edit')
|
||||
return
|
||||
}
|
||||
emit('edit', { sourceRect: rectFromElement(imgRef.value) })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.result-card {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.result-card__media {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 14px;
|
||||
background: #f0f0f0;
|
||||
border: 1.5px solid transparent;
|
||||
}
|
||||
|
||||
.result-card.is-editing .result-card__media {
|
||||
border-color: #3a8e4d;
|
||||
}
|
||||
|
||||
.result-card__img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
object-fit: cover;
|
||||
object-position: center top;
|
||||
}
|
||||
|
||||
.result-card__view,
|
||||
.result-card__edit,
|
||||
.result-card__download {
|
||||
position: absolute;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
color: #fff;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.result-card__view {
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
height: 26px;
|
||||
padding: 0 8px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.result-card__edit {
|
||||
left: 10px;
|
||||
bottom: 10px;
|
||||
height: 26px;
|
||||
padding: 0 8px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.result-card__download {
|
||||
right: 10px;
|
||||
bottom: 10px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.result-card__meta {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.result-card__buy {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
margin-top: 10px;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
background: #3a8e4d;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user