1. 修复海报图片

This commit is contained in:
李琦
2026-08-03 16:00:13 +08:00
parent 8543d0bb25
commit 18d7296f01
13 changed files with 1052 additions and 105 deletions

View File

@@ -60,9 +60,15 @@
</a-button>
<template #overlay>
<a-menu>
<a-menu-item v-for="s in POSTER_SIDES" :key="s.key" @click="openPosterPreview(s.key)">
{{ s.label }}({{ s.key }}) {{ s.route }}
</a-menu-item>
<a-menu-item-group v-for="s in POSTER_SIDES" :key="'g-' + s.key" :title="`${s.label}(${s.key})`">
<a-menu-item
v-for="l in POSTER_LAYOUTS"
:key="`${s.key}-${l.value}`"
@click="openPosterPreview(s.key, l.value)"
>
{{ l.label }} {{ s.route }}/{{ l.value }}
</a-menu-item>
</a-menu-item-group>
</a-menu>
</template>
</a-dropdown>
@@ -346,13 +352,51 @@
<a-textarea v-model:value="cfg.address" :rows="2" placeholder="可换行" />
</a-form-item>
</div>
<div class="flex flex-wrap items-center justify-between gap-2 mb-1">
<div class="text-[11px] text-[#8A8680]">
原图用于预览展示图为等比缩放后的体积不裁切前台封面/揭幕/尾页优先用展示图
<span v-if="resizeProgress && resizingBasic" class="text-[#a88c6b] ml-1">{{ resizeProgress }}</span>
</div>
<a-button
size="small"
type="primary"
class="!bg-[#a88c6b] hover:!bg-[#967b5c] !border-[#a88c6b]"
:loading="resizingBasic"
:disabled="uploading || (!cfg.heroImg && !cfg.endImg)"
@click="batchResizeBasicImages"
>
<i class="fa-solid fa-compress mr-1"></i>一键缩放并上传
</a-button>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-6">
<a-form-item label="封面图片">
<image-field v-model="cfg.heroImg" label="封面" @pick="openUpload(u => cfg.heroImg = u)" :uploading="uploading" />
<div class="relative w-[96px]">
<image-field
v-model="cfg.heroImg"
label="封面"
@pick="openUpload(u => setBasicOriginal('heroImg', u))"
:uploading="uploading"
/>
<div v-if="cfg.heroImgResized" class="text-center mt-1">
<div class="text-[9px] text-[#a88c6b]">展示已生成</div>
<div v-if="cfg.heroImgResizedMeta" class="resized-meta">{{ cfg.heroImgResizedMeta }}</div>
</div>
</div>
<div class="text-[11px] text-[#8A8680] mt-2">用于首页封面和入场揭幕背景</div>
</a-form-item>
<a-form-item label="尾页图片">
<image-field v-model="cfg.endImg" label="尾页" @pick="openUpload(u => cfg.endImg = u)" :uploading="uploading" />
<div class="relative w-[96px]">
<image-field
v-model="cfg.endImg"
label="尾页"
@pick="openUpload(u => setBasicOriginal('endImg', u))"
:uploading="uploading"
/>
<div v-if="cfg.endImgResized" class="text-center mt-1">
<div class="text-[9px] text-[#a88c6b]">展示已生成</div>
<div v-if="cfg.endImgResizedMeta" class="resized-meta">{{ cfg.endImgResizedMeta }}</div>
</div>
</div>
<div class="text-[11px] text-[#8A8680] mt-2">用于最后的回执尾页可与封面分开设置</div>
</a-form-item>
</div>
@@ -616,15 +660,23 @@
type="info"
show-icon
class="mb-4"
message="海报配置独立存储。1 男方 /hb-n2 女方 /hb-nv3 回门 /hb-hm;可分别选择模板。"
message="每一方同时维护竖版(/1)与横版(/2)。例:男方竖版 /hb-n/1横版 /hb-n/2女方 /hb-nv/1|/2回门 /hb-hm/1|/2。"
/>
<a-tabs v-model:activeKey="posterSideTab" size="small" type="card" class="mb-3">
<a-tabs v-model:activeKey="posterSideTab" size="small" type="card" class="mb-2">
<a-tab-pane v-for="s in POSTER_SIDES" :key="s.keyStr" :tab="`${s.label}(${s.key})`" />
</a-tabs>
<a-tabs v-model:activeKey="posterLayoutTab" size="small" class="mb-3">
<a-tab-pane
v-for="l in POSTER_LAYOUTS"
:key="l.value"
:tab="`${l.label} (/${l.value})`"
/>
</a-tabs>
<a-form layout="vertical" :class="{ 'opacity-60 pointer-events-none': posterLoading }">
<a-form-item label="所属方">
<a-form-item label="所属方 / 风格">
<a-tag color="gold">{{ currentPosterSide.key }} · {{ currentPosterSide.label }}</a-tag>
<span class="text-[11px] text-[#8A8680] ml-2">路由 {{ currentPosterSide.route }}</span>
<a-tag color="orange">{{ currentPosterLayout.label }}</a-tag>
<span class="text-[11px] text-[#8A8680] ml-2">路由 {{ currentPosterPreviewPath }}</span>
</a-form-item>
<a-form-item label="版式模板">
<a-radio-group v-model:value="posterCfg.template" class="poster-tpl-group">
@@ -831,8 +883,8 @@
class="!bg-[#a88c6b] hover:!bg-[#967b5c] !border-[#a88c6b]">
<i class="fa-solid fa-floppy-disk mr-1"></i>保存海报配置
</a-button>
<a-button @click="openPosterPreview(posterSideTab)">
<i class="fa-solid fa-eye mr-1"></i>打开 {{ currentPosterSide.route }} 预览
<a-button @click="openPosterPreview(posterSideTab, posterLayoutTab)">
<i class="fa-solid fa-eye mr-1"></i>打开 {{ currentPosterPreviewPath }} 预览
</a-button>
</div>
</a-form>
@@ -1322,6 +1374,12 @@
</section>
</main>
</div>
<MediaPickerModal
v-model:open="mediaPickerOpen"
:model-value="mediaPickerCurrent"
@select="onMediaPicked"
/>
</div>
</template>
@@ -1344,14 +1402,22 @@ import {
} from '@/api/wedding'
import { useAdminStore } from '@/stores/admin'
import ImageField from '@/components/ImageField.vue'
import MediaPickerModal from '@/components/MediaPickerModal.vue'
import { BORDER_STYLES, borderClass } from '@/composables/borderStyles'
import { resolveDanmakuSwatch } from '@/utils/danmakuColors'
import { GIFT_TYPES, GIFT_LABEL_MAP, emptyGiftCosts, DEFAULT_GIFT_COSTS } from '@/utils/giftTypes'
import { getSlotResizeSpecs, resizeSlotToFile, probeResizedMeta, metaHasFileSize } from '@/composables/resizeImage'
import {
defaultPosterBundle, normalizePosterBundle, normalizePosterConfig,
LOADING_EFFECTS, ENTER_EFFECTS, POSTER_SIDES, POSTER_TEMPLATES, MOBILE_LAYOUTS,
HERO_FOCUS_GRID, heroFocusGridPoints, clampHeroFocus, heroFocusCss, sideKey, posterSideMeta,
getSlotResizeSpecs,
getBasicImageResizeSpecs,
resizeSlotToFile,
probeResizedMeta,
metaHasFileSize,
} from '@/composables/resizeImage'
import {
defaultPosterBundle, normalizePosterBundle, defaultPosterForLayout,
LOADING_EFFECTS, ENTER_EFFECTS, POSTER_SIDES, POSTER_LAYOUTS, POSTER_TEMPLATES, MOBILE_LAYOUTS,
HERO_FOCUS_GRID, heroFocusGridPoints, clampHeroFocus, heroFocusCss, sideKey, layoutKey,
posterSideMeta, posterLayoutMeta, posterRoute,
} from '@/composables/posterDefaults'
echarts.use([PieChart, TooltipComponent, LegendComponent, CanvasRenderer])
@@ -1412,7 +1478,11 @@ const menuLayoutOptions = [
]
const SECTION_KEYS = {
basic: ['groom', 'bride', 'date', 'lunar', 'calendarDate', 'hotel', 'address', 'heroImg', 'endImg'],
basic: [
'groom', 'bride', 'date', 'lunar', 'calendarDate', 'hotel', 'address',
'heroImg', 'heroImgResized', 'heroImgResizedMeta',
'endImg', 'endImgResized', 'endImgResizedMeta',
],
copy: ['formalText1', 'formalText2', 'formalPageHeight'],
visual: [
'introExitEffect', 'scrollMode', 'freeScrollInterval', 'pageSwitchDuration', 'firstScreenDuration',
@@ -1453,6 +1523,7 @@ const activePhotoTab = ref('0')
const saving = ref(false)
const uploading = ref(false)
const resizingPage = ref(false)
const resizingBasic = ref(false)
const resizeProgress = ref('')
const uploadConfigSaving = ref(false)
const posterSaving = ref(false)
@@ -1538,8 +1609,28 @@ let deviceChart = null
const cfg = reactive(defaultConfig())
const uploadCfg = reactive(defaultUploadConfig())
const posterSideTab = ref('1')
const posterLayoutTab = ref('1')
const posterBundle = reactive(defaultPosterBundle())
const posterCfg = computed(() => posterBundle[sideKey(posterSideTab.value)] || posterBundle['1'])
const posterCfg = computed(() => {
const s = sideKey(posterSideTab.value)
const l = layoutKey(posterLayoutTab.value)
if (!posterBundle[s]) posterBundle[s] = { '1': defaultPosterForLayout(s, 1), '2': defaultPosterForLayout(s, 2) }
if (!posterBundle[s][l]) posterBundle[s][l] = defaultPosterForLayout(s, l)
return posterBundle[s][l]
})
const mediaPickerOpen = ref(false)
const mediaPickerCurrent = ref('')
let mediaPickerOnUrl = null
function ensurePosterSlot(side = posterSideTab.value, layout = posterLayoutTab.value) {
const s = sideKey(side)
const l = layoutKey(layout)
if (!posterBundle[s] || typeof posterBundle[s] !== 'object') {
posterBundle[s] = { '1': defaultPosterForLayout(s, 1), '2': defaultPosterForLayout(s, 2) }
}
if (!posterBundle[s][l]) posterBundle[s][l] = defaultPosterForLayout(s, l)
return posterBundle[s][l]
}
const heroFocusGrid = heroFocusGridPoints(HERO_FOCUS_GRID)
const heroFocusPreviewEl = ref(null)
@@ -1607,6 +1698,8 @@ const posterMusicSelectOptions = computed(() => {
return opts
})
const currentPosterSide = computed(() => posterSideMeta(posterSideTab.value))
const currentPosterLayout = computed(() => posterLayoutMeta(posterLayoutTab.value))
const currentPosterPreviewPath = computed(() => posterRoute(posterSideTab.value, posterLayoutTab.value))
const posterNameLabels = computed(() => {
const s = sideKey(posterSideTab.value)
if (s === '2') {
@@ -1623,23 +1716,26 @@ const posterLinesText = computed({
return Array.isArray(lines) ? lines.join('\n') : ''
},
set: (v) => {
const side = sideKey(posterSideTab.value)
if (!posterBundle[side]) posterBundle[side] = normalizePosterConfig(null, side)
posterBundle[side].lines = String(v || '')
const slot = ensurePosterSlot()
slot.lines = String(v || '')
.split('\n')
.map((s) => s.trimEnd())
},
})
function setPosterHero(url) {
const side = sideKey(posterSideTab.value)
if (!posterBundle[side]) posterBundle[side] = normalizePosterConfig(null, side)
posterBundle[side].heroImg = url
ensurePosterSlot().heroImg = url
}
function setPosterQrCenter(url) {
const side = sideKey(posterSideTab.value)
if (!posterBundle[side]) posterBundle[side] = normalizePosterConfig(null, side)
posterBundle[side].qrCenterImg = url
ensurePosterSlot().qrCenterImg = url
}
watch([posterSideTab, posterLayoutTab], () => {
const slot = ensurePosterSlot()
const l = layoutKey(posterLayoutTab.value)
slot.layout = Number(l)
slot.mobileLayout = l === '2' ? 'landscape' : 'portrait'
})
const currentPhotoIndex = computed(() => Math.min(Number(activePhotoTab.value) || 0, Math.max(cfg.photoPages.length - 1, 0)))
const isConfigTab = computed(() => CONFIG_SECTIONS.includes(activeTab.value))
const visitPagination = computed(() => ({
@@ -1658,7 +1754,11 @@ function defaultConfig() {
formalText1: '两姓联姻 一堂缔约', formalText2: '良缘永结 匹配同称', formalPageHeight: '100vh',
calendarDate: '2026-09-04',
heroImg: 'https://images.unsplash.com/photo-1583939003579-730e3918a45a?auto=format&fit=crop&w=800&q=80',
heroImgResized: '',
heroImgResizedMeta: '',
endImg: 'https://images.unsplash.com/photo-1519741497674-611481863552?auto=format&fit=crop&w=800&q=80',
endImgResized: '',
endImgResizedMeta: '',
musicList: [{ url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3', name: '背景音乐 1' }],
activeMusicUrl: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3',
musicRandomEnabled: false,
@@ -1848,6 +1948,63 @@ function setOriginalAndClearResized(page, key, url) {
page[`${key}ResizedMeta`] = ''
}
function setBasicOriginal(key, url) {
cfg[key] = url
cfg[`${key}Resized`] = ''
cfg[`${key}ResizedMeta`] = ''
}
async function fillMissingBasicResizedMeta() {
await Promise.all(['heroImg', 'endImg'].map(async (key) => {
const original = cfg[key]
const resized = cfg[`${key}Resized`]
const metaKey = `${key}ResizedMeta`
if (!resized || !original || metaHasFileSize(cfg[metaKey])) return
try {
cfg[metaKey] = await probeResizedMeta(original, resized)
} catch { /* 跨域/失效图忽略 */ }
}))
}
async function batchResizeBasicImages() {
const slots = getBasicImageResizeSpecs(cfg).filter((s) => !!s.getOriginal())
if (!slots.length) {
message.warning('请先上传封面或尾页原图')
return
}
resizingBasic.value = true
resizeProgress.value = `0/${slots.length}`
let ok = 0
let fail = 0
try {
for (let i = 0; i < slots.length; i += 1) {
const slot = slots[i]
resizeProgress.value = `${i + 1}/${slots.length}`
try {
const { file, meta } = await resizeSlotToFile(
slot.getOriginal(),
slot.maxWidth,
slot.maxHeight,
`resized-basic-${slot.key}-${Date.now()}.jpg`,
)
const res = await uploadImage(file)
if (!res?.url) throw new Error('上传无返回地址')
slot.setResized(res.url, meta)
ok += 1
} catch (err) {
fail += 1
console.warn('[resize-basic]', slot.key, err)
}
}
if (fail === 0) message.success(`已生成 ${ok} 张展示图`)
else if (ok > 0) message.warning(`成功 ${ok} 张,失败 ${fail} 张(常见原因:跨域无法读取原图)`)
else message.error('缩放上传全部失败,请确认原图可跨域读取')
} finally {
resizingBasic.value = false
resizeProgress.value = ''
}
}
function setOriginalImageAt(page, iidx, url) {
page.images[iidx] = url
syncImagesResized(page)
@@ -1944,23 +2101,15 @@ function movePage(i, dir) {
function addSchedule() { cfg.schedule.push({ time: '', event: '', desc: '' }) }
function removeSchedule(i) { cfg.schedule.splice(i, 1) }
// 上传(图片)
// 选图:弹层图库 + 新上传
function openUpload(onUrl) {
const input = document.createElement('input')
input.type = 'file'; input.accept = 'image/*'
input.onchange = async (e) => {
const file = e.target.files[0]; if (!file) return
uploading.value = true
try {
const res = await uploadImage(file)
onUrl(res.url); message.success('上传成功')
} catch (err) {
message.error('上传失败')
} finally {
uploading.value = false; e.target.value = ''
}
}
input.click()
mediaPickerOnUrl = typeof onUrl === 'function' ? onUrl : null
mediaPickerCurrent.value = ''
mediaPickerOpen.value = true
}
function onMediaPicked(url) {
if (mediaPickerOnUrl) mediaPickerOnUrl(url)
mediaPickerOnUrl = null
}
// 上传(背景音乐,多首)
@@ -2095,6 +2244,13 @@ async function loadPosterConfig() {
async function onSavePosterConfig() {
posterSaving.value = true
try {
// 保存前强制各布局语义
for (const s of ['1', '2', '3']) {
ensurePosterSlot(s, '1').mobileLayout = 'portrait'
ensurePosterSlot(s, '1').layout = 1
ensurePosterSlot(s, '2').mobileLayout = 'landscape'
ensurePosterSlot(s, '2').layout = 2
}
const payload = normalizePosterBundle(posterBundle)
const res = await savePosterConfig(payload)
const bundle = normalizePosterBundle(res.data || payload)
@@ -2134,6 +2290,10 @@ function applySectionData(section, loaded) {
if (section === 'basic') {
loaded.endImg = loaded.endImg || loaded.heroImg || cfg.endImg
loaded.calendarDate = loaded.calendarDate || '2026-09-04'
loaded.heroImgResized = loaded.heroImgResized || ''
loaded.heroImgResizedMeta = loaded.heroImgResizedMeta || ''
loaded.endImgResized = loaded.endImgResized || ''
loaded.endImgResizedMeta = loaded.endImgResizedMeta || ''
}
if (section === 'copy') {
loaded.formalPageHeight = loaded.formalPageHeight || '100vh'
@@ -2433,10 +2593,8 @@ async function onTabActivate(tab) {
}
function openPreview() { window.open('/', '_blank') }
function openPosterPreview(side = 1) {
const s = Number(sideKey(side))
const meta = POSTER_SIDES.find((x) => x.key === s) || POSTER_SIDES[0]
window.open(meta.route, '_blank')
function openPosterPreview(side = 1, layout = 1) {
window.open(posterRoute(side, layout), '_blank')
}
function onLogout() {
@@ -2696,6 +2854,10 @@ watch(
},
)
watch(activeTab, (tab) => {
if (tab === 'basic') fillMissingBasicResizedMeta()
})
const onResize = () => {
regionChart?.resize()
deviceChart?.resize()