初始化

This commit is contained in:
李琦
2026-07-30 18:00:27 +08:00
parent 88d5ca03b6
commit 2a426eebe7
32 changed files with 6820 additions and 0 deletions

View File

@@ -0,0 +1,352 @@
<template>
<div class="min-h-screen bg-[#f6f4ef] text-[#2c2c2c]">
<!-- 顶部栏 -->
<header class="sticky top-0 z-30 bg-white/90 backdrop-blur border-b border-[#a88c6b]/15 px-6 py-3 flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="text-xl text-[#a88c6b] font-serif"></span>
<div>
<div class="text-sm tracking-[0.2em] font-light">婚礼纪 · 内容编辑后台</div>
<div class="text-[10px] text-[#8A8680] tracking-[0.3em] uppercase">Invitation Admin</div>
</div>
</div>
<div class="flex items-center gap-2">
<a-button size="small" @click="openPreview">
<i class="fa-solid fa-eye mr-1 text-[#a88c6b]"></i>预览请柬
</a-button>
<a-button type="primary" size="small" :loading="saving" @click="onSave"
class="!bg-[#a88c6b] hover:!bg-[#967b5c] !border-[#a88c6b]">
<i class="fa-solid fa-floppy-disk mr-1"></i>保存配置
</a-button>
<a-dropdown>
<a-button size="small">{{ adminStore.user?.account || '管理员' }}<i class="fa-solid fa-chevron-down ml-1 text-[10px]"></i></a-button>
<template #overlay>
<a-menu>
<a-menu-item key="logout" @click="onLogout"><i class="fa-solid fa-right-from-bracket mr-2"></i>退出登录</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</header>
<div class="max-w-[960px] mx-auto px-4 py-6">
<a-tabs v-model:activeKey="activeTab" type="card" class="admin-tabs">
<!-- 基础信息 -->
<a-tab-pane key="basic" tab="基础信息">
<a-card :bordered="false" class="!shadow-sm">
<a-form layout="vertical">
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-6">
<a-form-item label="新郎">
<a-input v-model:value="cfg.groom" placeholder="新郎姓名" />
</a-form-item>
<a-form-item label="新娘">
<a-input v-model:value="cfg.bride" placeholder="新娘姓名" />
</a-form-item>
<a-form-item label="公历日期">
<a-input v-model:value="cfg.date" placeholder="如 2026-05-20" />
</a-form-item>
<a-form-item label="农历 / 星期">
<a-input v-model:value="cfg.lunar" placeholder="如 农历四月初四 星期三" />
</a-form-item>
<a-form-item label="酒店名称">
<a-input v-model:value="cfg.hotel" placeholder="宴会酒店" />
</a-form-item>
<a-form-item label="详细地址">
<a-textarea v-model:value="cfg.address" :rows="2" placeholder="可换行" />
</a-form-item>
</div>
<a-form-item label="封面 / 尾页主图">
<image-field v-model="cfg.heroImg" @pick="openUpload(u => cfg.heroImg = u)" :uploading="uploading" />
</a-form-item>
</a-form>
</a-card>
</a-tab-pane>
<!-- 文案 -->
<a-tab-pane key="copy" tab="邀请文案">
<a-card :bordered="false" class="!shadow-sm">
<a-form layout="vertical">
<a-form-item label="竖排文案 · 上联">
<a-input v-model:value="cfg.formalText1" placeholder="如 两姓联姻 一堂缔约" />
</a-form-item>
<a-form-item label="竖排文案 · 下联">
<a-input v-model:value="cfg.formalText2" placeholder="如 良缘永结 匹配同称" />
</a-form-item>
</a-form>
</a-card>
</a-tab-pane>
<!-- 视觉 / 动效 -->
<a-tab-pane key="visual" tab="视觉与动效">
<a-card :bordered="false" class="!shadow-sm">
<a-form layout="vertical">
<a-form-item label="背景音乐链接 (URL)">
<a-input v-model:value="cfg.musicUrl" placeholder="留空则不播放音乐" />
<div class="text-[11px] text-[#8A8680] mt-1">支持 mp3 直链访客首次交互后自动播放可随时暂停</div>
</a-form-item>
<a-form-item label="滚动方式">
<a-radio-group v-model:value="cfg.scrollMode">
<a-radio value="snap">整页 / 分屏吸附滚动</a-radio>
<a-radio value="free">自由滚动</a-radio>
</a-radio-group>
<div class="text-[11px] text-[#8A8680] mt-1">整页模式会带来更有仪式感的逐屏切换效果</div>
</a-form-item>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<a-form-item label="飘落花瓣">
<a-switch v-model:checked="cfg.petalEnabled" />
</a-form-item>
<a-form-item label="漂浮爱心">
<a-switch v-model:checked="cfg.bubbleEnabled" />
</a-form-item>
<a-form-item label="入场揭幕">
<a-switch v-model:checked="cfg.introEnabled" />
</a-form-item>
</div>
</a-form>
</a-card>
</a-tab-pane>
<!-- 相册页 -->
<a-tab-pane key="photos" tab="相册页管理">
<div class="flex justify-between items-center mb-3">
<span class="text-[12px] text-[#8A8680]"> {{ cfg.photoPages.length }} 个相册页</span>
<a-button size="small" @click="addPage"><i class="fa-solid fa-plus mr-1 text-[#a88c6b]"></i>新增相册页</a-button>
</div>
<a-empty v-if="!cfg.photoPages.length" description="暂无相册页" />
<a-card v-for="(page, idx) in cfg.photoPages" :key="idx" :bordered="false" class="!shadow-sm mb-4">
<div class="flex justify-between items-center mb-3">
<span class="text-[#a88c6b] text-sm"> {{ idx + 1 }} </span>
<div class="flex gap-1">
<a-button size="small" :disabled="idx===0" @click="movePage(idx,-1)"><i class="fa-solid fa-arrow-up"></i></a-button>
<a-button size="small" :disabled="idx===cfg.photoPages.length-1" @click="movePage(idx,1)"><i class="fa-solid fa-arrow-down"></i></a-button>
<a-button size="small" danger @click="removePage(idx)"><i class="fa-solid fa-trash"></i></a-button>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-6">
<a-form-item label="版式">
<a-select v-model:value="page.type" @change="() => ensurePageShape(page)">
<a-select-option value="single">单图</a-select-option>
<a-select-option value="overlap">错落双图</a-select-option>
<a-select-option value="one-large-five-small">一大五小</a-select-option>
<a-select-option value="nine-grid">九宫格</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="小标题 (英文)">
<a-input v-model:value="page.subtitle" />
</a-form-item>
<a-form-item label="标题">
<a-input v-model:value="page.title" />
</a-form-item>
</div>
<a-form-item label="描述">
<a-textarea v-model:value="page.desc" :rows="2" />
</a-form-item>
<!-- 图片上传区按版式渲染 -->
<div class="flex flex-wrap gap-3 mt-2">
<template v-if="page.type==='single'">
<image-field v-model="page.img1" @pick="openUpload(u => page.img1 = u)" :uploading="uploading" />
</template>
<template v-else-if="page.type==='overlap'">
<image-field v-model="page.img1" label="图一" @pick="openUpload(u => page.img1 = u)" :uploading="uploading" />
<image-field v-model="page.img2" label="图二" @pick="openUpload(u => page.img2 = u)" :uploading="uploading" />
</template>
<template v-else>
<image-field v-for="(img, iidx) in page.images" :key="iidx"
v-model="page.images[iidx]"
:label="`图${iidx+1}`"
@pick="openUpload(u => page.images[iidx] = u)"
:uploading="uploading" />
</template>
</div>
</a-card>
</a-tab-pane>
<!-- 婚礼流程 -->
<a-tab-pane key="schedule" tab="婚礼流程">
<div class="flex justify-between items-center mb-3">
<span class="text-[12px] text-[#8A8680]"> {{ cfg.schedule.length }} 个环节</span>
<a-button size="small" @click="addSchedule"><i class="fa-solid fa-plus mr-1 text-[#a88c6b]"></i>新增环节</a-button>
</div>
<a-card v-for="(item, idx) in cfg.schedule" :key="idx" :bordered="false" class="!shadow-sm mb-3">
<div class="flex items-end gap-3 flex-wrap">
<div class="flex-1 min-w-[120px]">
<div class="text-[11px] text-[#a88c6b] mb-1">时间</div>
<a-input v-model:value="item.time" placeholder="如 17:08" />
</div>
<div class="flex-1 min-w-[140px]">
<div class="text-[11px] text-[#a88c6b] mb-1">环节</div>
<a-input v-model:value="item.event" placeholder="如 仪式开始" />
</div>
<div class="flex-1 min-w-[140px]">
<div class="text-[11px] text-[#a88c6b] mb-1">英文副标题</div>
<a-input v-model:value="item.desc" placeholder="如 Ceremony" />
</div>
<a-button danger size="small" @click="removeSchedule(idx)"><i class="fa-solid fa-trash"></i></a-button>
</div>
</a-card>
</a-tab-pane>
<!-- 回执列表 -->
<a-tab-pane key="rsvp" tab="出席回执">
<a-table :columns="rsvpColumns" :data-source="rsvpList" :loading="rsvpLoading" row-key="id" size="small" :pagination="{ pageSize: 8 }">
<template #bodyCell="{ column, record }">
<template v-if="column.key==='guest_count'">
<a-tag :color="record.guest_count==='0' ? 'default' : 'pink'">{{ rsvpCountLabel(record.guest_count) }}</a-tag>
</template>
<template v-else-if="column.key==='created_at'">
{{ formatTime(record.created_at) }}
</template>
</template>
</a-table>
</a-tab-pane>
</a-tabs>
</div>
</div>
</template>
<script setup>
import { reactive, ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import { getConfig, saveConfig, uploadImage, getRsvpList } from '@/api/wedding'
import { useAdminStore } from '@/stores/admin'
import ImageField from '@/components/ImageField.vue'
const router = useRouter()
const adminStore = useAdminStore()
const activeTab = ref('basic')
const saving = ref(false)
const uploading = ref(false)
const rsvpLoading = ref(false)
const rsvpList = ref([])
const cfg = reactive(defaultConfig())
function defaultConfig() {
return {
groom: '李明', bride: '王华', date: '2026-05-20', lunar: '农历四月初四 星期三',
hotel: '杭州星光万丽大酒店', address: '杭州市西湖区星光大道88号\n3楼大宴会厅',
formalText1: '两姓联姻 一堂缔约', formalText2: '良缘永结 匹配同称',
heroImg: 'https://images.unsplash.com/photo-1583939003579-730e3918a45a?auto=format&fit=crop&w=800&q=80',
musicUrl: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3',
scrollMode: 'snap', petalEnabled: true, bubbleEnabled: true, introEnabled: true,
photoPages: [
{ type: 'single', title: '初遇', subtitle: 'FIRST MET', desc: '世界那么大,还是遇见了你。\n于千万人之中没有早一步也没有晚一步。', img1: 'https://images.unsplash.com/photo-1606800052052-a08af7148866?auto=format&fit=crop&w=600&q=80' },
{ type: 'overlap', title: '相知', subtitle: 'FALL IN LOVE', desc: '纵然万劫不复,纵然相思入骨。\n我也待你眉眼如初岁月如故。', img1: 'https://images.unsplash.com/photo-1532712938310-34cb3982ef74?auto=format&fit=crop&w=600&q=80', img2: 'https://images.unsplash.com/photo-1511285560929-80b456fea0bc?auto=format&fit=crop&w=600&q=80' },
{ type: 'one-large-five-small', title: '相恋', subtitle: 'ROMANCE', desc: '星光坠入眼眸,晚风轻抚过裙摆。\n浪漫不止于此还有无数个明天。', images: fill(6) },
{ type: 'nine-grid', title: '相守', subtitle: 'FOREVER', desc: '往后余生,风雪是你,平淡是你。', images: fill(9) },
],
schedule: [
{ time: '16:00', event: '签到迎宾', desc: 'Welcome' },
{ time: '17:08', event: '仪式开始', desc: 'Ceremony' },
{ time: '18:00', event: '敬备喜宴', desc: 'Banquet' },
],
}
}
function fill(n) {
return Array.from({ length: n }, () => 'https://images.unsplash.com/photo-1511285560929-80b456fea0bc?auto=format&fit=crop&w=400&q=80')
}
function ensurePageShape(p) {
if (p.type === 'single') { p.img1 = p.img1 || ''; delete p.img2; delete p.images }
else if (p.type === 'overlap') { p.img1 = p.img1 || ''; p.img2 = p.img2 || ''; delete p.images }
else {
const need = p.type === 'one-large-five-small' ? 6 : 9
p.images = Array.isArray(p.images) ? p.images : []
while (p.images.length < need) p.images.push('')
if (p.images.length > need) p.images.length = need
delete p.img1; delete p.img2
}
p.title = p.title || ''; p.subtitle = p.subtitle || ''; p.desc = p.desc || ''
return p
}
// 相册页操作
function addPage() { cfg.photoPages.push(ensurePageShape({ type: 'single', title: '新页面', subtitle: 'NEW', desc: '', img1: '' })) }
function removePage(i) { cfg.photoPages.splice(i, 1) }
function movePage(i, dir) { const j = i + dir; if (j < 0 || j >= cfg.photoPages.length) return; const t = cfg.photoPages[i]; cfg.photoPages[i] = cfg.photoPages[j]; cfg.photoPages[j] = t }
// 流程操作
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()
}
// 保存
async function onSave() {
saving.value = true
try {
await saveConfig(JSON.parse(JSON.stringify(cfg)))
message.success('配置已保存')
} catch (e) {
const msg = e?.response?.status === 401 ? '登录已失效,请重新登录' : (e?.message || '保存失败')
message.error(msg)
if (e?.response?.status === 401) router.replace({ name: 'Login', query: { redirect: '/admin' } })
} finally {
saving.value = false
}
}
function openPreview() { window.open('/', '_blank') }
function onLogout() {
adminStore.logout()
router.replace({ name: 'Login', query: { redirect: '/admin' } })
}
// RSVP
const rsvpColumns = [
{ title: '姓名', dataIndex: 'name', key: 'name' },
{ title: '出席', dataIndex: 'guest_count', key: 'guest_count' },
{ title: '祝福', dataIndex: 'wishes', key: 'wishes', ellipsis: true },
{ title: '提交时间', dataIndex: 'created_at', key: 'created_at' },
]
const rsvpCountLabel = (v) => ({ '1': '1 人出席', '2': '2 人出席', '3': '3 人及以上', '0': '遗憾缺席' }[v] || v)
function formatTime(t) {
if (!t) return ''
const d = new Date(t)
if (isNaN(d)) return t
return d.toLocaleString('zh-CN', { hour12: false })
}
async function loadRsvp() {
rsvpLoading.value = true
try { const res = await getRsvpList(); rsvpList.value = res.data || [] }
catch (e) { /* 忽略 */ }
finally { rsvpLoading.value = false }
}
onMounted(async () => {
try {
const res = await getConfig()
if (res.data && res.data !== '{}') {
const loaded = typeof res.data === 'string' ? JSON.parse(res.data) : res.data
Object.assign(cfg, loaded)
cfg.photoPages = (cfg.photoPages || []).map(ensurePageShape)
}
} catch (e) { /* 使用默认配置 */ }
if (adminStore.isAdmin) loadRsvp()
})
</script>
<style scoped>
.admin-tabs :deep(.ant-tabs-tab.ant-tabs-tab-active .ant-tabs-tab-btn) { color: #a88c6b; }
.admin-tabs :deep(.ant-tabs-ink-bar) { background: #a88c6b; }
.admin-tabs :deep(.ant-tabs-tab:hover .ant-tabs-tab-btn) { color: #967b5c; }
</style>

View File

@@ -0,0 +1,65 @@
<template>
<div class="min-h-screen w-full flex items-center justify-center bg-[#Fdfbf7] relative overflow-hidden">
<!-- 背景装饰 -->
<div class="absolute -top-24 -right-24 w-80 h-80 rounded-full bg-[#a88c6b]/10 blur-3xl"></div>
<div class="absolute -bottom-24 -left-24 w-80 h-80 rounded-full bg-[#e8a5b2]/10 blur-3xl"></div>
<div class="absolute inset-0 pointer-events-none opacity-[0.04]"
style="background-image: radial-gradient(#a88c6b 1px, transparent 1px); background-size: 22px 22px;"></div>
<div class="relative z-10 w-[360px] bg-white/80 backdrop-blur-xl border border-[#a88c6b]/15 rounded-3xl shadow-2xl p-9 animate-fade-up">
<div class="text-center mb-8">
<div class="text-3xl mb-3 text-[#a88c6b] font-serif"></div>
<h1 class="text-xl tracking-[0.3em] text-[#2c2c2c] font-light">婚礼纪 · 后台</h1>
<p class="text-[11px] text-[#8A8680] tracking-[0.3em] mt-2 uppercase">Admin Console</p>
</div>
<a-form :model="form" layout="vertical" @finish="onSubmit">
<a-form-item name="account" :rules="[{ required: true, message: '请输入账号' }]">
<a-input v-model:value="form.account" size="large" placeholder="管理员账号">
<template #prefix><i class="fa-solid fa-user text-[#a88c6b]"></i></template>
</a-input>
</a-form-item>
<a-form-item name="password" :rules="[{ required: true, message: '请输入密码' }]">
<a-input-password v-model:value="form.password" size="large" placeholder="密码">
<template #prefix><i class="fa-solid fa-lock text-[#a88c6b]"></i></template>
</a-input-password>
</a-form-item>
<a-button type="primary" html-type="submit" size="large" block :loading="loading"
class="!bg-[#a88c6b] hover:!bg-[#967b5c] !border-[#a88c6b] !rounded-xl !tracking-[0.2em]">
</a-button>
</a-form>
<div class="mt-6 text-center">
<router-link to="/" class="text-[12px] text-[#a88c6b] hover:underline">返回请柬预览</router-link>
</div>
</div>
</div>
</template>
<script setup>
import { reactive, ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { message } from 'ant-design-vue'
import { useAdminStore } from '@/stores/admin'
const router = useRouter()
const route = useRoute()
const adminStore = useAdminStore()
const loading = ref(false)
const form = reactive({ account: '', password: '' })
const onSubmit = async () => {
loading.value = true
try {
await adminStore.login(form)
message.success('登录成功')
router.replace(route.query.redirect || '/admin')
} catch (e) {
const msg = e?.response?.data?.error || e?.message || '登录失败'
message.error(msg)
} finally {
loading.value = false
}
}
</script>