1. 修复海报图片

This commit is contained in:
李琦
2026-08-04 08:19:30 +08:00
parent ef1e2ee811
commit 078e621783
7 changed files with 332 additions and 50 deletions

View File

@@ -653,14 +653,14 @@
</a-card>
</section>
<!-- 海报独立 poster_configs1 男方 /hb-n2 女方 /hb-nv3 回门宴 /hb-hm -->
<!-- 海报独立 poster_configs1 男方 /hb-n2 女方 /hb-nv3 新人模板 /hb-xr -->
<section v-if="activeTab === 'poster'">
<a-card :bordered="false" class="!shadow-sm">
<a-alert
type="info"
show-icon
class="mb-4"
message="每一方同时维护竖版(/1)与横版(/2)。例:男方竖版 /hb-n/1横版 /hb-n/2女方 /hb-nv/1|/2回门 /hb-hm/1|/2。"
message="每一方同时维护竖版(/1)与横版(/2)。例:男方竖版 /hb-n/1横版 /hb-n/2女方 /hb-nv/1|/2新人模板 /hb-xr/1|/2。"
/>
<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})`" />
@@ -862,11 +862,17 @@
<a-textarea v-model:value="posterLinesText" :rows="5" placeholder="每行一条正文" />
</a-form-item>
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-6">
<a-form-item label="新郎">
<a-input v-model:value="posterCfg.groomName" />
<a-form-item :label="posterNameLabels.left">
<div class="flex gap-2">
<a-input v-model:value="posterCfg.sonLabel" class="!w-[100px]" :placeholder="posterNameLabels.leftPh" />
<a-input v-model:value="posterCfg.sonName" placeholder="姓名" @update:value="v => posterCfg.groomName = v" />
</div>
</a-form-item>
<a-form-item label="新娘">
<a-input v-model:value="posterCfg.brideName" />
<a-form-item :label="posterNameLabels.right">
<div class="flex gap-2">
<a-input v-model:value="posterCfg.daughterInLawLabel" class="!w-[100px]" :placeholder="posterNameLabels.rightPh" />
<a-input v-model:value="posterCfg.daughterInLawName" placeholder="姓名" @update:value="v => posterCfg.brideName = v" />
</div>
</a-form-item>
<a-form-item label="喜宴时间">
<a-input v-model:value="posterCfg.dateText" />
@@ -882,20 +888,47 @@
<a-input v-model:value="posterCfg.loveFooter" placeholder="Love you" />
</a-form-item>
</template>
<div class="flex gap-2 flex-wrap">
<a-button type="primary" :loading="posterSaving" @click="onSavePosterConfig"
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, posterLayoutTab)">
<i class="fa-solid fa-eye mr-1"></i>打开 {{ currentPosterPreviewPath }} 预览
</a-button>
</div>
</a-form>
</a-card>
<div class="poster-action-bar-spacer" aria-hidden="true"></div>
</section>
<div v-if="activeTab === 'poster'" class="poster-action-bar">
<div class="poster-action-bar-inner">
<div class="poster-action-bar-meta">
<span>{{ currentPosterSide.label }} · {{ currentPosterLayout.label }}</span>
<span class="poster-action-bar-path">{{ currentPosterPreviewPath }}</span>
</div>
<div class="poster-action-bar-btns">
<a-button
v-if="posterUndoSnapshot"
size="small"
@click="undoPosterImport"
>
<i class="fa-solid fa-rotate-left mr-1"></i>撤回导入
</a-button>
<a-button size="small" :loading="posterCopying" @click="copyPosterTemplate">
<i class="fa-solid fa-copy mr-1"></i>复制
</a-button>
<a-button size="small" :loading="posterImporting" @click="importPosterTemplateDirect">
<i class="fa-solid fa-file-import mr-1"></i>导入
</a-button>
<a-button size="small" @click="openPosterPreview(posterSideTab, posterLayoutTab)">
<i class="fa-solid fa-eye mr-1"></i>预览
</a-button>
<a-button
type="primary"
size="small"
:loading="posterSaving"
class="!bg-[#a88c6b] hover:!bg-[#967b5c] !border-[#a88c6b]"
@click="onSavePosterConfig"
>
<i class="fa-solid fa-floppy-disk mr-1"></i>保存
</a-button>
</div>
</div>
</div>
<section v-if="activeTab === 'photos'">
<div class="admin-panel !p-4 mb-3">
<div class="flex justify-between items-center gap-3 flex-wrap">
@@ -1419,7 +1452,7 @@ import {
metaHasFileSize,
} from '@/composables/resizeImage'
import {
defaultPosterBundle, normalizePosterBundle, defaultPosterForLayout,
defaultPosterBundle, normalizePosterBundle, defaultPosterForLayout, normalizePosterConfig,
LOADING_EFFECTS, ENTER_EFFECTS, POSTER_SIDES, POSTER_LAYOUTS, POSTER_TEMPLATES, MOBILE_LAYOUTS,
HERO_FOCUS_GRID, heroFocusGridPoints, clampHeroFocus, heroFocusCss, sideKey, layoutKey,
posterSideMeta, posterLayoutMeta, posterRoute,
@@ -1616,6 +1649,12 @@ const uploadCfg = reactive(defaultUploadConfig())
const posterSideTab = ref('1')
const posterLayoutTab = ref('1')
const posterBundle = reactive(defaultPosterBundle())
const posterImporting = ref(false)
const posterCopying = ref(false)
/** 本会话最近一次复制的模板,导入时优先/兜底使用 */
let posterTemplateCache = null
/** 导入前快照,用于撤回 { s, l, config } */
const posterUndoSnapshot = ref(null)
const posterCfg = computed(() => {
const s = sideKey(posterSideTab.value)
const l = layoutKey(posterLayoutTab.value)
@@ -1737,7 +1776,7 @@ const posterNameLabels = computed(() => {
return { left: '女儿称谓 / 姓名', right: '女婿称谓 / 姓名', leftPh: '女儿:', rightPh: '女婿:' }
}
if (s === '3') {
return { left: '新称谓 / 姓名', right: '新称谓 / 姓名', leftPh: '新郎:', rightPh: '新娘:' }
return { left: '新人左侧称谓 / 姓名', right: '新人右侧称谓 / 姓名', leftPh: '新郎:', rightPh: '新娘:' }
}
return { left: '儿子称谓 / 姓名', right: '儿媳称谓 / 姓名', leftPh: '儿子:', rightPh: '儿媳:' }
})
@@ -2026,9 +2065,17 @@ async function batchResizeBasicImages() {
console.warn('[resize-basic]', slot.key, err)
}
}
if (fail === 0) message.success(`已生成 ${ok} 张展示图`)
else if (ok > 0) message.warning(`成功 ${ok} 张,失败 ${fail} 张(常见原因:跨域无法读取原图)`)
else message.error('缩放上传全部失败,请确认原图可跨域读取')
if (ok > 0) {
try {
await saveConfigSection('basic', pickSectionData('basic'))
if (fail === 0) message.success(`已生成并保存 ${ok} 张展示图`)
else message.warning(`成功 ${ok} 张并已保存,失败 ${fail}`)
} catch (e) {
message.warning(`已生成 ${ok} 张展示图,但自动保存失败:${e?.message || '请手动保存'}`)
}
} else {
message.error('缩放上传全部失败,请确认原图可跨域读取')
}
} finally {
resizingBasic.value = false
resizeProgress.value = ''
@@ -2285,6 +2332,7 @@ async function onSavePosterConfig() {
posterBundle['1'] = bundle['1']
posterBundle['2'] = bundle['2']
posterBundle['3'] = bundle['3']
posterUndoSnapshot.value = null
message.success('海报配置已保存')
} catch (e) {
message.error(e?.message || '海报配置保存失败')
@@ -2293,6 +2341,141 @@ async function onSavePosterConfig() {
}
}
function buildPosterTemplatePayload() {
const s = sideKey(posterSideTab.value)
const l = layoutKey(posterLayoutTab.value)
const slot = ensurePosterSlot(s, l)
return {
version: 1,
kind: 'poster-slot',
side: Number(s),
layout: Number(l),
sideLabel: posterSideMeta(s).label,
layoutLabel: posterLayoutMeta(l).label,
route: posterRoute(s, l),
config: JSON.parse(JSON.stringify(slot)),
}
}
async function copyPosterTemplate() {
posterCopying.value = true
try {
const payload = buildPosterTemplatePayload()
posterTemplateCache = payload
const text = JSON.stringify(payload, null, 2)
if (navigator?.clipboard?.writeText) {
await navigator.clipboard.writeText(text)
} else {
const ta = document.createElement('textarea')
ta.value = text
ta.style.position = 'fixed'
ta.style.left = '-9999px'
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
}
message.success(`已复制「${currentPosterSide.value.label} · ${currentPosterLayout.value.label}`)
} catch (e) {
message.error(e?.message || '复制失败,请检查浏览器剪贴板权限')
} finally {
posterCopying.value = false
}
}
function extractPosterImportConfig(parsed) {
if (!parsed || typeof parsed !== 'object') return null
if (parsed.kind === 'poster-slot' && parsed.config && typeof parsed.config === 'object') {
return parsed.config
}
if (parsed.config && typeof parsed.config === 'object' && !Array.isArray(parsed.config)) {
return parsed.config
}
const s = sideKey(posterSideTab.value)
const l = layoutKey(posterLayoutTab.value)
const sideObj = parsed[s] || parsed[Number(s)]
if (sideObj && typeof sideObj === 'object') {
const slot = sideObj[l] || sideObj[Number(l)]
if (slot && typeof slot === 'object') return slot
}
if ('template' in parsed || 'heroImg' in parsed || 'lines' in parsed || 'sonName' in parsed || 'groomName' in parsed) {
return parsed
}
return null
}
async function readPosterImportSource() {
// 1) 剪贴板 2) 本会话刚复制的缓存
try {
if (navigator?.clipboard?.readText) {
const clip = String(await navigator.clipboard.readText() || '').trim()
if (clip) {
try {
const parsed = JSON.parse(clip)
if (extractPosterImportConfig(parsed)) return parsed
} catch { /* not json */ }
}
}
} catch { /* no permission */ }
if (posterTemplateCache && extractPosterImportConfig(posterTemplateCache)) {
return posterTemplateCache
}
return null
}
function applyImportedConfig(rawCfg) {
const s = sideKey(posterSideTab.value)
const l = layoutKey(posterLayoutTab.value)
posterUndoSnapshot.value = {
s,
l,
config: JSON.parse(JSON.stringify(ensurePosterSlot(s, l))),
}
const next = normalizePosterConfig(rawCfg, s, l)
next.side = Number(s)
next.layout = Number(l)
if (next.sonName && !next.groomName) next.groomName = next.sonName
if (next.daughterInLawName && !next.brideName) next.brideName = next.daughterInLawName
if (next.groomName && !next.sonName) next.sonName = next.groomName
if (next.brideName && !next.daughterInLawName) next.daughterInLawName = next.brideName
posterBundle[s][l] = next
}
async function importPosterTemplateDirect() {
posterImporting.value = true
try {
const parsed = await readPosterImportSource()
if (!parsed) {
message.warning('请先复制模板,再点导入')
return
}
const rawCfg = extractPosterImportConfig(parsed)
if (!rawCfg) {
message.error('无法识别的模板数据')
return
}
applyImportedConfig(rawCfg)
message.success(`已导入到「${currentPosterSide.value.label} · ${currentPosterLayout.value.label}」,可撤回或保存`)
} catch (e) {
message.error(e?.message || '导入失败')
} finally {
posterImporting.value = false
}
}
function undoPosterImport() {
const snap = posterUndoSnapshot.value
if (!snap?.config) {
message.info('没有可撤回的导入')
return
}
posterSideTab.value = snap.s
posterLayoutTab.value = snap.l
posterBundle[snap.s][snap.l] = snap.config
posterUndoSnapshot.value = null
message.success('已撤回导入')
}
function pickSectionData(section) {
const keys = SECTION_KEYS[section] || []
const data = {}
@@ -3514,4 +3697,57 @@ onUnmounted(() => {
pointer-events: none;
z-index: 1;
}
.poster-action-bar-spacer {
height: 72px;
}
.poster-action-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 40;
padding: 10px 12px calc(10px + env(safe-area-inset-bottom, 0px));
background: rgba(255, 255, 255, 0.94);
backdrop-filter: blur(10px);
border-top: 1px solid rgba(168, 140, 107, 0.2);
box-shadow: 0 -8px 24px rgba(88, 68, 42, 0.08);
}
.poster-action-bar-inner {
max-width: 1220px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
}
.admin-shell--top ~ .poster-action-bar .poster-action-bar-inner,
.admin-shell--top .poster-action-bar-inner {
max-width: 80vw;
}
.poster-action-bar-meta {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
font-size: 12px;
color: #5c5348;
}
.poster-action-bar-path {
font-size: 11px;
color: #8A8680;
}
.poster-action-bar-btns {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
margin-left: auto;
}
@media (max-width: 720px) {
.poster-action-bar-meta { width: 100%; }
.poster-action-bar-btns { width: 100%; justify-content: stretch; }
.poster-action-bar-btns :deep(.ant-btn) { flex: 1 1 auto; }
}
</style>