1. 迎宾海报

This commit is contained in:
李琦
2026-08-04 08:37:04 +08:00
parent 078e621783
commit 91b2658ef0
8 changed files with 1792 additions and 1 deletions

View File

@@ -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

View 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>