1. 迎宾海报
This commit is contained in:
@@ -1349,6 +1349,11 @@
|
||||
</a-modal>
|
||||
</section>
|
||||
|
||||
<!-- 酒店迎宾海报 -->
|
||||
<section v-if="activeTab === 'hotelWelcome'">
|
||||
<HotelWelcomePosterAdmin ref="hotelWelcomeRef" />
|
||||
</section>
|
||||
|
||||
<!-- 弹幕审核 -->
|
||||
<section v-if="activeTab === 'danmaku'">
|
||||
<div class="admin-panel">
|
||||
@@ -1441,6 +1446,7 @@ import {
|
||||
import { useAdminStore } from '@/stores/admin'
|
||||
import ImageField from '@/components/ImageField.vue'
|
||||
import MediaPickerModal from '@/components/MediaPickerModal.vue'
|
||||
import HotelWelcomePosterAdmin from '@/views/admin/HotelWelcomePosterAdmin.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'
|
||||
@@ -1466,7 +1472,7 @@ const adminStore = useAdminStore()
|
||||
const TAB_KEY = 'wedding_admin_active_tab'
|
||||
const MENU_LAYOUT_KEY = 'wedding_admin_menu_layout'
|
||||
const CONFIG_SECTIONS = ['basic', 'copy', 'visual', 'music', 'photos', 'schedule']
|
||||
const VALID_TABS = ['overview', 'gifts', 'visits', ...CONFIG_SECTIONS, 'upload', 'poster', 'rsvp', 'cash', 'danmaku']
|
||||
const VALID_TABS = ['overview', 'gifts', 'visits', ...CONFIG_SECTIONS, 'upload', 'poster', 'hotelWelcome', 'rsvp', 'cash', 'danmaku']
|
||||
|
||||
/** 后台菜单分组 */
|
||||
const adminMenuGroups = [
|
||||
@@ -1505,6 +1511,7 @@ const adminMenuGroups = [
|
||||
label: '海报与系统',
|
||||
items: [
|
||||
{ key: 'poster', label: '海报', icon: 'fa-solid fa-scroll' },
|
||||
{ key: 'hotelWelcome', label: '迎宾易拉宝', icon: 'fa-solid fa-hotel' },
|
||||
{ key: 'upload', label: '上传设置', icon: 'fa-solid fa-cloud-arrow-up' },
|
||||
],
|
||||
},
|
||||
@@ -1549,6 +1556,7 @@ function readStoredMenuLayout() {
|
||||
|
||||
const activeTab = ref(readStoredTab())
|
||||
const menuLayout = ref(readStoredMenuLayout())
|
||||
const hotelWelcomeRef = ref(null)
|
||||
watch(menuLayout, (v) => {
|
||||
try { localStorage.setItem(MENU_LAYOUT_KEY, v) } catch { /* ignore */ }
|
||||
})
|
||||
@@ -2790,6 +2798,10 @@ async function onTabActivate(tab) {
|
||||
await Promise.all([loadPosterConfig(), loadPosterMusicOptions()])
|
||||
return
|
||||
}
|
||||
if (tab === 'hotelWelcome') {
|
||||
hotelWelcomeRef.value?.refresh?.()
|
||||
return
|
||||
}
|
||||
if (tab === 'rsvp') {
|
||||
await loadRsvp()
|
||||
return
|
||||
|
||||
988
vite-tailwindcss/src/views/admin/HotelWelcomePosterAdmin.vue
Normal file
988
vite-tailwindcss/src/views/admin/HotelWelcomePosterAdmin.vue
Normal file
@@ -0,0 +1,988 @@
|
||||
<template>
|
||||
<div class="hw-admin">
|
||||
<!-- 列表:不展示原图 -->
|
||||
<div v-if="view === 'list'" class="admin-panel">
|
||||
<div class="flex justify-between items-center mb-4 gap-3 flex-wrap">
|
||||
<div>
|
||||
<div class="text-sm text-[#2c2c2c]">婚礼迎宾易拉宝</div>
|
||||
<div class="text-[11px] text-[#8A8680] mt-0.5">
|
||||
共 {{ list.length }} 张 · 酒店门口欢迎海报(新人姓名 / 日期 / WEDDING),非按贵宾制
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a-button size="small" type="primary" class="!bg-[#a88c6b] !border-[#a88c6b]" @click="openCreate">
|
||||
<i class="fa-solid fa-plus mr-1"></i>新建海报
|
||||
</a-button>
|
||||
<a-button size="small" :loading="listLoading" @click="loadList">
|
||||
<i class="fa-solid fa-rotate-right mr-1"></i>刷新
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
class="admin-table"
|
||||
:columns="columns"
|
||||
:data-source="list"
|
||||
:loading="listLoading"
|
||||
row-key="id"
|
||||
size="middle"
|
||||
:pagination="{ pageSize: 10, hideOnSinglePage: true }"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'template'">
|
||||
<span class="admin-pill admin-pill--gold">{{ templateLabel(record.template) }}</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'updated_at'">
|
||||
<span class="text-[12px] text-[#8A8680]">{{ formatTime(record.updated_at) }}</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<a-space>
|
||||
<a-button size="small" type="link" class="!text-[#a88c6b] !px-1" @click="openDetail(record)">编辑</a-button>
|
||||
<a-button size="small" danger type="text" @click="onDelete(record)">删除</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
|
||||
<!-- 详情:一图一页 -->
|
||||
<div v-else class="hw-detail">
|
||||
<div class="admin-panel mb-3">
|
||||
<div class="flex justify-between items-center gap-3 flex-wrap">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<a-button size="small" @click="backToList">
|
||||
<i class="fa-solid fa-arrow-left mr-1"></i>返回列表
|
||||
</a-button>
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm text-[#2c2c2c] truncate">{{ detail?.name || '迎宾易拉宝' }}</div>
|
||||
<div class="text-[11px] text-[#8A8680] mt-0.5">改新人姓名与日期 · 拖拽排版 · 导出酒店门口立牌 PNG</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<a-button size="small" :loading="exporting" @click="exportPng">
|
||||
<i class="fa-solid fa-download mr-1"></i>保存海报
|
||||
</a-button>
|
||||
<a-button
|
||||
size="small"
|
||||
type="primary"
|
||||
class="!bg-[#a88c6b] !border-[#a88c6b]"
|
||||
:loading="saving"
|
||||
@click="saveDetail"
|
||||
>
|
||||
<i class="fa-solid fa-floppy-disk mr-1"></i>保存配置
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="detailLoading" class="admin-panel text-center text-[#8A8680] py-16">加载中…</div>
|
||||
|
||||
<div v-else-if="detail" class="hw-editor-grid">
|
||||
<div class="admin-panel hw-canvas-panel">
|
||||
<div
|
||||
ref="stageRef"
|
||||
class="hw-stage"
|
||||
:class="{ 'is-empty': !detail.image_url }"
|
||||
data-hotel-welcome-capture
|
||||
@pointerdown.self="selectedId = ''"
|
||||
>
|
||||
<img
|
||||
v-if="detail.image_url"
|
||||
:src="detail.image_url"
|
||||
alt=""
|
||||
class="hw-stage-img"
|
||||
crossorigin="anonymous"
|
||||
draggable="false"
|
||||
/>
|
||||
<div v-else class="hw-stage-empty">暂无原图</div>
|
||||
|
||||
<div
|
||||
v-for="t in texts"
|
||||
:key="t.id"
|
||||
class="hw-text"
|
||||
:class="{
|
||||
'is-selected': selectedId === t.id,
|
||||
'is-vertical': t.vertical,
|
||||
'is-shadow': t.shadow,
|
||||
}"
|
||||
:style="textStyle(t)"
|
||||
@pointerdown.stop="onTextPointerDown($event, t)"
|
||||
>
|
||||
{{ t.content || ' ' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-[10px] text-[#8A8680] mt-2 text-center">点击文字选中并拖拽;点击空白取消选中</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-panel hw-side-panel">
|
||||
<a-form layout="vertical" size="small">
|
||||
<a-form-item label="海报名称">
|
||||
<a-input v-model:value="detail.name" :maxlength="100" placeholder="如:门口主立牌" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="排版模板">
|
||||
<button type="button" class="hw-tpl-trigger" @click="openTemplatePicker('detail')">
|
||||
<div class="hw-tpl-mini" aria-hidden="true">
|
||||
<span
|
||||
v-for="(z, i) in (currentTemplateMeta?.zones || []).slice(0, 4)"
|
||||
:key="i"
|
||||
class="hw-tpl-mini-zone"
|
||||
:class="{ vertical: z.vertical }"
|
||||
:style="zoneStyle(z)"
|
||||
/>
|
||||
<span v-if="!(currentTemplateMeta?.zones || []).length" class="hw-tpl-mini-empty">空白</span>
|
||||
</div>
|
||||
<div class="hw-tpl-trigger-meta">
|
||||
<div class="hw-tpl-trigger-title">{{ templateLabel(detail.template) }}</div>
|
||||
<div class="hw-tpl-trigger-desc">{{ currentTemplateDesc }}</div>
|
||||
</div>
|
||||
<i class="fa-solid fa-th-large hw-tpl-trigger-icon" />
|
||||
</button>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="婚纱照底图">
|
||||
<ImageField
|
||||
v-model="detail.image_url"
|
||||
label="婚纱照底图"
|
||||
:uploading="uploading"
|
||||
@pick="pickImage"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<div class="text-[12px] text-[#7a6550]">文字图层({{ texts.length }})</div>
|
||||
<a-button size="small" @click="addText">
|
||||
<i class="fa-solid fa-plus mr-1"></i>添加文字
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<div v-if="!texts.length" class="text-[11px] text-[#8A8680] mb-3">暂无文字,可切换模板或手动添加</div>
|
||||
|
||||
<div
|
||||
v-for="t in texts"
|
||||
:key="'row-' + t.id"
|
||||
class="hw-text-row"
|
||||
:class="{ active: selectedId === t.id }"
|
||||
@click="selectedId = t.id"
|
||||
>
|
||||
<div class="flex justify-between gap-2 mb-2">
|
||||
<span class="text-[11px] text-[#8A8680] truncate">{{ t.content || '(空)' }}</span>
|
||||
<a-button size="small" danger type="text" class="!px-1" @click.stop="removeText(t.id)">删除</a-button>
|
||||
</div>
|
||||
<a-input v-model:value="t.content" placeholder="文字内容" class="mb-2" :maxlength="80" />
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<a-form-item label="字号" class="!mb-2">
|
||||
<a-input-number v-model:value="t.fontSize" :min="10" :max="120" class="w-full" />
|
||||
</a-form-item>
|
||||
<a-form-item label="字距" class="!mb-2">
|
||||
<a-input-number v-model:value="t.letterSpacing" :min="-2" :max="40" class="w-full" />
|
||||
</a-form-item>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<a-form-item label="颜色" class="!mb-2">
|
||||
<input v-model="t.color" type="color" class="hw-color" />
|
||||
</a-form-item>
|
||||
<a-form-item label="对齐" class="!mb-2">
|
||||
<a-select
|
||||
v-model:value="t.align"
|
||||
class="w-full"
|
||||
:options="[
|
||||
{ value: 'left', label: '左' },
|
||||
{ value: 'center', label: '中' },
|
||||
{ value: 'right', label: '右' },
|
||||
]"
|
||||
/>
|
||||
</a-form-item>
|
||||
</div>
|
||||
<div class="flex gap-4 mb-1">
|
||||
<a-checkbox v-model:checked="t.vertical">竖排</a-checkbox>
|
||||
<a-checkbox v-model:checked="t.shadow">阴影</a-checkbox>
|
||||
</div>
|
||||
<div class="text-[10px] text-[#8A8680]">位置 X {{ Math.round(t.x) }}% · Y {{ Math.round(t.y) }}%</div>
|
||||
</div>
|
||||
</a-form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新建:必须填名称 + 上传原图 -->
|
||||
<a-modal
|
||||
v-model:open="createOpen"
|
||||
title="新建迎宾易拉宝"
|
||||
ok-text="创建"
|
||||
cancel-text="取消"
|
||||
:confirm-loading="creating"
|
||||
@ok="submitCreate"
|
||||
>
|
||||
<a-form layout="vertical" class="mt-2">
|
||||
<a-form-item label="海报名称" required>
|
||||
<a-input v-model:value="createForm.name" placeholder="如:酒店门口主立牌 / 签到区易拉宝" :maxlength="100" />
|
||||
</a-form-item>
|
||||
<a-form-item label="排版模板">
|
||||
<button type="button" class="hw-tpl-trigger" @click="openTemplatePicker('create')">
|
||||
<div class="hw-tpl-mini" aria-hidden="true">
|
||||
<span
|
||||
v-for="(z, i) in (createTemplateMeta?.zones || []).slice(0, 4)"
|
||||
:key="i"
|
||||
class="hw-tpl-mini-zone"
|
||||
:class="{ vertical: z.vertical }"
|
||||
:style="zoneStyle(z)"
|
||||
/>
|
||||
<span v-if="!(createTemplateMeta?.zones || []).length" class="hw-tpl-mini-empty">空白</span>
|
||||
</div>
|
||||
<div class="hw-tpl-trigger-meta">
|
||||
<div class="hw-tpl-trigger-title">{{ templateLabel(createForm.template) }}</div>
|
||||
<div class="hw-tpl-trigger-desc">点击打开模板库选择文字区域</div>
|
||||
</div>
|
||||
<i class="fa-solid fa-th-large hw-tpl-trigger-icon" />
|
||||
</button>
|
||||
</a-form-item>
|
||||
<a-form-item label="婚纱照底图" required>
|
||||
<ImageField
|
||||
v-model="createForm.image_url"
|
||||
label="婚纱照"
|
||||
:uploading="uploading"
|
||||
@pick="pickCreateImage"
|
||||
/>
|
||||
<div class="text-[10px] text-[#8A8680] mt-1">创建后改新人姓名、日期,并拖拽排版后导出</div>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<!-- 模板选择:可视化文字区域 -->
|
||||
<a-modal
|
||||
v-model:open="templatePickerOpen"
|
||||
title="选择文字区域模板"
|
||||
:footer="null"
|
||||
:width="920"
|
||||
destroy-on-close
|
||||
class="hw-tpl-modal"
|
||||
>
|
||||
<div class="text-[11px] text-[#8A8680] mb-3">
|
||||
每个卡片展示文字落点区域,点击即可选用;之后仍可在画布上拖拽微调
|
||||
</div>
|
||||
<div v-for="group in templateGroupList" :key="group.label" class="hw-tpl-group">
|
||||
<div class="hw-tpl-group-title">{{ group.label }}</div>
|
||||
<div class="hw-tpl-grid">
|
||||
<button
|
||||
v-for="tpl in group.items"
|
||||
:key="tpl.value"
|
||||
type="button"
|
||||
class="hw-tpl-card"
|
||||
:class="{ active: pickerSelected === tpl.value }"
|
||||
@click="pickTemplate(tpl.value)"
|
||||
>
|
||||
<div class="hw-tpl-preview">
|
||||
<div class="hw-tpl-preview-bg" />
|
||||
<span
|
||||
v-for="(z, i) in tpl.zones"
|
||||
:key="i"
|
||||
class="hw-tpl-zone"
|
||||
:class="{ vertical: z.vertical }"
|
||||
:style="zoneStyle(z)"
|
||||
>{{ z.label }}</span>
|
||||
<span v-if="!tpl.zones?.length" class="hw-tpl-preview-empty">自定义空白</span>
|
||||
</div>
|
||||
<div class="hw-tpl-card-label">{{ tpl.label }}</div>
|
||||
<div class="hw-tpl-card-desc">{{ tpl.desc }}</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
|
||||
<MediaPickerModal
|
||||
v-model:open="mediaPickerOpen"
|
||||
:model-value="mediaPickerCurrent"
|
||||
@select="onMediaPicked"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import html2canvas from 'html2canvas'
|
||||
import {
|
||||
createHotelWelcome,
|
||||
deleteHotelWelcome,
|
||||
getHotelWelcomeDetail,
|
||||
getHotelWelcomeList,
|
||||
updateHotelWelcome,
|
||||
} from '@/api/wedding'
|
||||
import ImageField from '@/components/ImageField.vue'
|
||||
import MediaPickerModal from '@/components/MediaPickerModal.vue'
|
||||
import {
|
||||
clampPct,
|
||||
createTextLayer,
|
||||
defaultTextsForTemplate,
|
||||
normalizeHotelWelcomeConfig,
|
||||
resolveTemplate,
|
||||
serializeHotelWelcomeConfig,
|
||||
templateGroups,
|
||||
templateLabel,
|
||||
templateMeta,
|
||||
} from '@/composables/hotelWelcomeDefaults'
|
||||
|
||||
const view = ref('list')
|
||||
const list = ref([])
|
||||
const listLoading = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detail = ref(null)
|
||||
const texts = ref([])
|
||||
const selectedId = ref('')
|
||||
const saving = ref(false)
|
||||
const creating = ref(false)
|
||||
const exporting = ref(false)
|
||||
const uploading = ref(false)
|
||||
const createOpen = ref(false)
|
||||
const createForm = reactive({
|
||||
name: '',
|
||||
image_url: '',
|
||||
template: 'banner-bottom',
|
||||
})
|
||||
|
||||
const templatePickerOpen = ref(false)
|
||||
const templatePickerTarget = ref('detail') // create | detail
|
||||
const pickerSelected = ref('banner-bottom')
|
||||
const templateGroupList = templateGroups()
|
||||
|
||||
const mediaPickerOpen = ref(false)
|
||||
const mediaPickerCurrent = ref('')
|
||||
let mediaPickerTarget = 'create' // create | detail
|
||||
|
||||
const stageRef = ref(null)
|
||||
let dragState = null
|
||||
|
||||
const columns = [
|
||||
{ title: '名称', dataIndex: 'name', key: 'name' },
|
||||
{ title: '模板', dataIndex: 'template', key: 'template', width: 120 },
|
||||
{ title: '更新时间', dataIndex: 'updated_at', key: 'updated_at', width: 170 },
|
||||
{ title: '操作', key: 'actions', width: 140 },
|
||||
]
|
||||
|
||||
const currentTemplateMeta = computed(() => templateMeta(detail.value?.template))
|
||||
const createTemplateMeta = computed(() => templateMeta(createForm.template))
|
||||
const currentTemplateDesc = computed(() => currentTemplateMeta.value?.desc || '')
|
||||
|
||||
function zoneStyle(z) {
|
||||
return {
|
||||
left: `${z.x}%`,
|
||||
top: `${z.y}%`,
|
||||
width: `${z.w}%`,
|
||||
height: `${z.h}%`,
|
||||
}
|
||||
}
|
||||
|
||||
function openTemplatePicker(target = 'detail') {
|
||||
templatePickerTarget.value = target
|
||||
pickerSelected.value = target === 'create'
|
||||
? resolveTemplate(createForm.template)
|
||||
: resolveTemplate(detail.value?.template)
|
||||
templatePickerOpen.value = true
|
||||
}
|
||||
|
||||
function applyTemplateValue(val, { confirmIfDirty = false } = {}) {
|
||||
const key = resolveTemplate(val)
|
||||
const apply = () => {
|
||||
if (templatePickerTarget.value === 'create') {
|
||||
createForm.template = key
|
||||
} else if (detail.value) {
|
||||
detail.value.template = key
|
||||
texts.value = defaultTextsForTemplate(key)
|
||||
selectedId.value = texts.value[0]?.id || ''
|
||||
}
|
||||
templatePickerOpen.value = false
|
||||
}
|
||||
if (
|
||||
confirmIfDirty
|
||||
&& templatePickerTarget.value === 'detail'
|
||||
&& texts.value.length
|
||||
&& key !== resolveTemplate(detail.value?.template)
|
||||
) {
|
||||
Modal.confirm({
|
||||
title: '应用该文字区域模板?',
|
||||
content: '将用预设文字与位置覆盖当前图层(可再拖拽微调)',
|
||||
okText: '应用',
|
||||
cancelText: '取消',
|
||||
onOk: apply,
|
||||
})
|
||||
return
|
||||
}
|
||||
apply()
|
||||
}
|
||||
|
||||
function pickTemplate(val) {
|
||||
pickerSelected.value = val
|
||||
applyTemplateValue(val, { confirmIfDirty: templatePickerTarget.value === 'detail' })
|
||||
}
|
||||
|
||||
function formatTime(t) {
|
||||
if (!t) return ''
|
||||
const d = new Date(t)
|
||||
if (Number.isNaN(d.getTime())) return String(t)
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
}
|
||||
|
||||
async function loadList() {
|
||||
listLoading.value = true
|
||||
try {
|
||||
const res = await getHotelWelcomeList()
|
||||
list.value = Array.isArray(res.data?.list) ? res.data.list : []
|
||||
} catch (e) {
|
||||
list.value = []
|
||||
message.error(e?.message || '加载失败')
|
||||
} finally {
|
||||
listLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
createForm.name = ''
|
||||
createForm.image_url = ''
|
||||
createForm.template = 'banner-bottom'
|
||||
createOpen.value = true
|
||||
}
|
||||
|
||||
function pickCreateImage() {
|
||||
mediaPickerTarget = 'create'
|
||||
mediaPickerCurrent.value = createForm.image_url
|
||||
mediaPickerOpen.value = true
|
||||
}
|
||||
|
||||
function pickImage() {
|
||||
mediaPickerTarget = 'detail'
|
||||
mediaPickerCurrent.value = detail.value?.image_url || ''
|
||||
mediaPickerOpen.value = true
|
||||
}
|
||||
|
||||
function onMediaPicked(url) {
|
||||
const u = String(url || '').trim()
|
||||
if (!u) return
|
||||
if (mediaPickerTarget === 'detail' && detail.value) {
|
||||
detail.value.image_url = u
|
||||
} else {
|
||||
createForm.image_url = u
|
||||
}
|
||||
mediaPickerOpen.value = false
|
||||
}
|
||||
|
||||
async function submitCreate() {
|
||||
const name = String(createForm.name || '').trim()
|
||||
const imageURL = String(createForm.image_url || '').trim()
|
||||
if (!name) {
|
||||
message.warning('请填写图片名称')
|
||||
return Promise.reject()
|
||||
}
|
||||
if (!imageURL) {
|
||||
message.warning('请上传原图')
|
||||
return Promise.reject()
|
||||
}
|
||||
creating.value = true
|
||||
try {
|
||||
const cfg = serializeHotelWelcomeConfig({
|
||||
texts: defaultTextsForTemplate(createForm.template),
|
||||
})
|
||||
const res = await createHotelWelcome({
|
||||
name,
|
||||
image_url: imageURL,
|
||||
template: createForm.template,
|
||||
config_data: cfg,
|
||||
})
|
||||
message.success('已创建')
|
||||
createOpen.value = false
|
||||
const id = res.data?.id
|
||||
await loadList()
|
||||
if (id) await openDetail({ id })
|
||||
} catch (e) {
|
||||
message.error(e?.message || '创建失败')
|
||||
return Promise.reject(e)
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openDetail(record) {
|
||||
if (!record?.id) return
|
||||
view.value = 'detail'
|
||||
detailLoading.value = true
|
||||
detail.value = null
|
||||
texts.value = []
|
||||
selectedId.value = ''
|
||||
try {
|
||||
const res = await getHotelWelcomeDetail(record.id)
|
||||
const data = res.data || {}
|
||||
detail.value = {
|
||||
id: data.id,
|
||||
name: data.name || '',
|
||||
image_url: data.image_url || '',
|
||||
template: resolveTemplate(data.template),
|
||||
}
|
||||
const cfg = normalizeHotelWelcomeConfig(data.config_data)
|
||||
texts.value = cfg.texts
|
||||
if (!texts.value.length && detail.value.template !== 'custom') {
|
||||
texts.value = defaultTextsForTemplate(detail.value.template)
|
||||
}
|
||||
} catch (e) {
|
||||
message.error(e?.message || '加载详情失败')
|
||||
view.value = 'list'
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function backToList() {
|
||||
view.value = 'list'
|
||||
detail.value = null
|
||||
texts.value = []
|
||||
selectedId.value = ''
|
||||
loadList()
|
||||
}
|
||||
|
||||
function addText() {
|
||||
const t = createTextLayer({ content: '新文字', x: 50, y: 50, fontSize: 24 })
|
||||
texts.value.push(t)
|
||||
selectedId.value = t.id
|
||||
}
|
||||
|
||||
function removeText(id) {
|
||||
texts.value = texts.value.filter((t) => t.id !== id)
|
||||
if (selectedId.value === id) selectedId.value = ''
|
||||
}
|
||||
|
||||
function textStyle(t) {
|
||||
const align = t.align || 'center'
|
||||
const transform = t.vertical
|
||||
? 'translate(-50%, -50%)'
|
||||
: align === 'left'
|
||||
? 'translate(0, -50%)'
|
||||
: align === 'right'
|
||||
? 'translate(-100%, -50%)'
|
||||
: 'translate(-50%, -50%)'
|
||||
return {
|
||||
left: `${clampPct(t.x)}%`,
|
||||
top: `${clampPct(t.y)}%`,
|
||||
transform,
|
||||
fontSize: `${t.fontSize || 24}px`,
|
||||
color: t.color || '#fff',
|
||||
fontFamily: t.fontFamily === 'serif'
|
||||
? '"Ma Shan Zheng", "Songti SC", serif'
|
||||
: t.fontFamily || 'serif',
|
||||
fontWeight: t.fontWeight || '600',
|
||||
letterSpacing: `${t.letterSpacing || 0}px`,
|
||||
textAlign: align,
|
||||
writingMode: t.vertical ? 'vertical-rl' : 'horizontal-tb',
|
||||
}
|
||||
}
|
||||
|
||||
function onTextPointerDown(e, t) {
|
||||
selectedId.value = t.id
|
||||
const stage = stageRef.value
|
||||
if (!stage) return
|
||||
const rect = stage.getBoundingClientRect()
|
||||
dragState = {
|
||||
id: t.id,
|
||||
w: rect.width || 1,
|
||||
h: rect.height || 1,
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
}
|
||||
try {
|
||||
e.currentTarget?.setPointerCapture?.(e.pointerId)
|
||||
} catch { /* ignore */ }
|
||||
window.addEventListener('pointermove', onPointerMove)
|
||||
window.addEventListener('pointerup', onPointerUp)
|
||||
window.addEventListener('pointercancel', onPointerUp)
|
||||
}
|
||||
|
||||
function onPointerMove(e) {
|
||||
if (!dragState) return
|
||||
const x = ((e.clientX - dragState.left) / dragState.w) * 100
|
||||
const y = ((e.clientY - dragState.top) / dragState.h) * 100
|
||||
const layer = texts.value.find((t) => t.id === dragState.id)
|
||||
if (!layer) return
|
||||
layer.x = clampPct(x)
|
||||
layer.y = clampPct(y)
|
||||
}
|
||||
|
||||
function onPointerUp() {
|
||||
dragState = null
|
||||
window.removeEventListener('pointermove', onPointerMove)
|
||||
window.removeEventListener('pointerup', onPointerUp)
|
||||
window.removeEventListener('pointercancel', onPointerUp)
|
||||
}
|
||||
|
||||
async function saveDetail() {
|
||||
if (!detail.value?.id) return
|
||||
const name = String(detail.value.name || '').trim()
|
||||
if (!name) {
|
||||
message.warning('请填写图片名称')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
await updateHotelWelcome(detail.value.id, {
|
||||
name,
|
||||
image_url: detail.value.image_url,
|
||||
template: detail.value.template,
|
||||
config_data: serializeHotelWelcomeConfig({ texts: texts.value }),
|
||||
})
|
||||
message.success('配置已保存')
|
||||
} catch (e) {
|
||||
message.error(e?.message || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function exportPng() {
|
||||
const el = stageRef.value || document.querySelector('[data-hotel-welcome-capture]')
|
||||
if (!el) return
|
||||
const prevSelected = selectedId.value
|
||||
selectedId.value = ''
|
||||
exporting.value = true
|
||||
try {
|
||||
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))
|
||||
try { await document.fonts?.ready } catch { /* ignore */ }
|
||||
const baseW = Math.max(1, el.getBoundingClientRect().width || el.offsetWidth || 390)
|
||||
const scale = Math.min(3, Math.max(2.5, 1600 / baseW))
|
||||
const canvas = await html2canvas(el, {
|
||||
useCORS: true,
|
||||
allowTaint: true,
|
||||
backgroundColor: '#111111',
|
||||
scale,
|
||||
logging: false,
|
||||
imageTimeout: 20000,
|
||||
removeContainer: true,
|
||||
})
|
||||
const blob = await new Promise((resolve, reject) => {
|
||||
canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('导出失败'))), 'image/png')
|
||||
})
|
||||
const a = document.createElement('a')
|
||||
const safeName = String(detail.value?.name || '迎宾易拉宝').replace(/[\\/:*?"<>|]/g, '_')
|
||||
a.download = `婚礼迎宾易拉宝-${safeName}-${Date.now()}.png`
|
||||
a.href = URL.createObjectURL(blob)
|
||||
a.click()
|
||||
setTimeout(() => URL.revokeObjectURL(a.href), 4000)
|
||||
message.success('海报已导出')
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
message.error('导出失败(跨域图片可能导致无法保存)')
|
||||
} finally {
|
||||
selectedId.value = prevSelected
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete(record) {
|
||||
if (!record?.id) return
|
||||
Modal.confirm({
|
||||
title: '删除这张迎宾易拉宝?',
|
||||
content: `将删除「${record.name || record.id}」,不可恢复`,
|
||||
okText: '删除',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
async onOk() {
|
||||
try {
|
||||
await deleteHotelWelcome(record.id)
|
||||
message.success('已删除')
|
||||
await loadList()
|
||||
} catch (e) {
|
||||
message.error(e?.message || '删除失败')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadList()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
onPointerUp()
|
||||
})
|
||||
|
||||
defineExpose({ loadList, refresh: loadList })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-panel {
|
||||
background: #fff;
|
||||
border: 1px solid rgba(168, 140, 107, 0.14);
|
||||
border-radius: 16px;
|
||||
padding: 20px 20px 16px;
|
||||
box-shadow: 0 8px 28px rgba(88, 68, 42, 0.05);
|
||||
}
|
||||
.admin-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 1px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.admin-pill--gold {
|
||||
color: #8a7048;
|
||||
background: rgba(168, 140, 107, 0.14);
|
||||
border-color: rgba(168, 140, 107, 0.28);
|
||||
}
|
||||
.admin-table :deep(.ant-table-thead > tr > th) {
|
||||
background: #f7f3ec !important;
|
||||
color: #7a6550;
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
}
|
||||
.hw-editor-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(280px, 340px);
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
}
|
||||
@media (max-width: 960px) {
|
||||
.hw-editor-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.hw-canvas-panel {
|
||||
min-width: 0;
|
||||
text-align: center;
|
||||
}
|
||||
/* 容器由图片撑开,仅受面板宽度限制,不固定比例/高度 */
|
||||
.hw-stage {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
vertical-align: top;
|
||||
background: #f3f1ec;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
line-height: 0;
|
||||
}
|
||||
.hw-stage.is-empty {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 220px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: normal;
|
||||
}
|
||||
.hw-stage-img {
|
||||
display: block;
|
||||
width: auto;
|
||||
height: auto;
|
||||
max-width: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
.hw-stage-empty {
|
||||
color: #8a8680;
|
||||
font-size: 13px;
|
||||
line-height: normal;
|
||||
}
|
||||
.hw-text {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
max-width: 90%;
|
||||
padding: 2px 4px;
|
||||
line-height: 1.25;
|
||||
white-space: pre-wrap;
|
||||
cursor: grab;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.hw-text.is-selected {
|
||||
border-color: rgba(212, 175, 55, 0.85);
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
cursor: grabbing;
|
||||
}
|
||||
.hw-text.is-shadow {
|
||||
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.55), 0 0 2px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.hw-text.is-vertical {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.hw-side-panel {
|
||||
max-height: calc(100vh - 180px);
|
||||
overflow: auto;
|
||||
}
|
||||
.hw-text-row {
|
||||
border: 1px solid rgba(168, 140, 107, 0.2);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
margin-bottom: 10px;
|
||||
background: #fdfbf7;
|
||||
cursor: pointer;
|
||||
}
|
||||
.hw-text-row.active {
|
||||
border-color: #a88c6b;
|
||||
box-shadow: 0 0 0 1px rgba(168, 140, 107, 0.25);
|
||||
}
|
||||
.hw-color {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
border: 1px solid rgba(168, 140, 107, 0.35);
|
||||
border-radius: 6px;
|
||||
padding: 2px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hw-tpl-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid rgba(168, 140, 107, 0.28);
|
||||
border-radius: 10px;
|
||||
background: #fdfbf7;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.hw-tpl-trigger:hover {
|
||||
border-color: #a88c6b;
|
||||
box-shadow: 0 0 0 1px rgba(168, 140, 107, 0.2);
|
||||
}
|
||||
.hw-tpl-mini {
|
||||
position: relative;
|
||||
width: 44px;
|
||||
height: 72px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(160deg, #c4b5a0 0%, #8a7a68 45%, #5c5044 100%);
|
||||
}
|
||||
.hw-tpl-mini-zone {
|
||||
position: absolute;
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
border-radius: 1px;
|
||||
}
|
||||
.hw-tpl-mini-empty,
|
||||
.hw-tpl-preview-empty {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
font-size: 10px;
|
||||
}
|
||||
.hw-tpl-trigger-meta {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.hw-tpl-trigger-title {
|
||||
font-size: 13px;
|
||||
color: #2c2c2c;
|
||||
}
|
||||
.hw-tpl-trigger-desc {
|
||||
margin-top: 2px;
|
||||
font-size: 10px;
|
||||
color: #8a8680;
|
||||
line-height: 1.35;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.hw-tpl-trigger-icon {
|
||||
color: #a88c6b;
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.hw-tpl-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.hw-tpl-group-title {
|
||||
font-size: 12px;
|
||||
color: #7a6550;
|
||||
letter-spacing: 0.08em;
|
||||
margin-bottom: 8px;
|
||||
padding-left: 2px;
|
||||
}
|
||||
.hw-tpl-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
@media (max-width: 860px) {
|
||||
.hw-tpl-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.hw-tpl-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
.hw-tpl-card {
|
||||
border: 1px solid rgba(168, 140, 107, 0.2);
|
||||
border-radius: 12px;
|
||||
padding: 10px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: border-color 0.15s, box-shadow 0.15s, transform 0.12s;
|
||||
}
|
||||
.hw-tpl-card:hover {
|
||||
border-color: #a88c6b;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.hw-tpl-card.active {
|
||||
border-color: #a88c6b;
|
||||
box-shadow: 0 0 0 2px rgba(168, 140, 107, 0.22);
|
||||
}
|
||||
.hw-tpl-preview {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 9 / 16;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.hw-tpl-preview-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(ellipse at 40% 35%, rgba(255, 255, 255, 0.18), transparent 45%),
|
||||
linear-gradient(165deg, #d2c4b0 0%, #9a8774 42%, #5a4e42 100%);
|
||||
}
|
||||
.hw-tpl-zone {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
color: #5a4a3a;
|
||||
font-size: 9px;
|
||||
line-height: 1;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
letter-spacing: 0.02em;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
.hw-tpl-zone.vertical {
|
||||
writing-mode: vertical-rl;
|
||||
white-space: nowrap;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
.hw-tpl-card-label {
|
||||
font-size: 12px;
|
||||
color: #2c2c2c;
|
||||
font-weight: 500;
|
||||
}
|
||||
.hw-tpl-card-desc {
|
||||
margin-top: 2px;
|
||||
font-size: 10px;
|
||||
color: #8a8680;
|
||||
line-height: 1.35;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user