1. 修复海报图片
This commit is contained in:
@@ -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-n,2 女方 /hb-nv,3 回门宴 /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()
|
||||
|
||||
Reference in New Issue
Block a user