初始化

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

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/vite-tailwindcss/.npmcache/

11
.idea/go.imports.xml generated Normal file
View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GoImports">
<option name="excludedPackages">
<array>
<option value="github.com/pkg/errors" />
<option value="golang.org/x/net/context" />
</array>
</option>
</component>
</project>

6
hunliji-api/.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
</component>
</project>

24
vite-tailwindcss/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

View File

@@ -0,0 +1,5 @@
# Vue 3 + Vite
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>婚礼请柬 · Wedding Invitation</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

3094
vite-tailwindcss/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,31 @@
{
"name": "spa-view",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"tailwindcss": "^4.1.4",
"vue": "^3.5.13",
"@ant-design-vue/pro-layout": "^3.2.5",
"@ant-design/icons-vue": "^7.0.1",
"@fortawesome/fontawesome-free": "^6.7.2",
"@tailwindcss/vite": "^4.1.4",
"@vueuse/head": "^2.0.0",
"ant-design-vue": "^4.1.1",
"axios": "^1.6.2",
"lucide-vue-next": "^0.507.0",
"pinia": "^2.1.7",
"swiper": "^11.0.5",
"vue-router": "^4.2.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.2",
"sass": "^1.71.0",
"vite": "^6.3.1"
}
}

2027
vite-tailwindcss/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,5 @@
allowBuilds:
'@parcel/watcher': set this to true or false
core-js: set this to true or false
esbuild: set this to true or false
vue-demi: set this to true or false

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,21 @@
<template>
<router-view />
</template>
<script setup>
// 根组件:仅作为路由出口。
// 首页请柬 -> src/views/index/index.vue
// 后台登录 -> src/views/admin/AdminLogin.vue
// 内容编辑 -> src/views/admin/AdminEditor.vue
</script>
<style>
html,
body,
#app {
margin: 0;
padding: 0;
height: 100%;
width: 100%;
}
</style>

View File

@@ -0,0 +1,75 @@
import axios from 'axios';
import {message as msg} from "ant-design-vue";
// 创建 Axios 实例
const service = axios.create({
baseURL: '/api/', // 这里可以设置你的 API 基础地址
timeout: 5000 // 请求超时时间
});
// 请求拦截器
service.interceptors.request.use(
config => {
// 从本地存储中获取 token
const token = localStorage.getItem('token');
if (token) {
// 设置请求头中的 Authorization
config.headers['Authorization'] = `Bearer ${token}`;
}
return config;
},
error => {
console.log(error); // 打印错误信息
return Promise.reject(error);
}
);
// 响应拦截器
service.interceptors.response.use(
response => {
const {code, result, message} = response.data;
if (code === 0) {
return result;
} else {
if (code === 401) {
msg.error(message).then(r => {
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.href = '/'
msg.destroy()
})
return;
}
msg.error(message).then(r => {
msg.destroy()
})
}
},
error => {
console.log('err' + error); // 打印错误信息
return Promise.reject(error);
}
);
// 封装 get 请求
const get = (url, params = {}) => {
return service.get(url, { params });
};
// 封装 post 请求
const post = (url, data = {}) => {
return service.post(url, data);
};
// 封装上传文件请求
const upload = (url, file) => {
const formData = new FormData();
formData.append('file', file);
return service.post(url, formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
};
export { get, post, upload };

View File

@@ -0,0 +1,10 @@
import {upload} from '../request.js'
const prefix = 'upload'
export function uploadImageApi(credentials) {
return upload(`${prefix}/image`, credentials)
}
export function uploadVideoApi(credentials) {
return upload(`${prefix}/video`, credentials)
}

View File

@@ -0,0 +1,92 @@
import {get, post} from '../request.js'
const prefix = 'user'
/**
* 登录
* @param credentials
* @returns {Promise<axios.AxiosResponse<any>>}
*/
export function loginApi(credentials) {
return post('login', credentials)
}
/**
* 注册
* @param credentials
* @returns {Promise<axios.AxiosResponse<any>>}
*/
export function registerApi(credentials) {
return post('register', credentials)
}
/**
* 获取用户列表
* @param credentials
* @returns {Promise<axios.AxiosResponse<any>>}
*/
export function userListApi(credentials) {
return get(`${prefix}/list`, credentials)
}
/**
* 获取当前登录用户信息
* @param credentials
* @returns {Promise<axios.AxiosResponse<any>>}
*/
export function userMyInfoApi(credentials) {
return get(`${prefix}/my-info`, credentials)
}
/**
* 获取用户统计信息
* @param credentials
* @returns {Promise<axios.AxiosResponse<any>>}
*/
export function userStatsApi(credentials) {
return get(`${prefix}/user-stats`, credentials)
}
/**
* 创建用户
* @param credentials
* @returns {Promise<axios.AxiosResponse<any>>}
*/
export function createUserApi(credentials) {
return post(`${prefix}/create`, credentials)
}
/**
* 更新用户
* @param credentials
* @returns {Promise<axios.AxiosResponse<any>>}
*/
export function updateUserApi(credentials) {
return post(`${prefix}/update`, credentials)
}
/**
* 删除用户
* @param credentials
* @returns {Promise<axios.AxiosResponse<any>>}
*/
export function deleteUserApi(credentials) {
return post(`${prefix}/delete`, credentials)
}
/**
* 重置密码
* @param credentials
* @returns {Promise<axios.AxiosResponse<any>>}
*/
export function resetPasswordApi(credentials) {
return post(`${prefix}/reset-password`, credentials)
}
/**
* 重置密码
* @param credentials
* @returns {Promise<axios.AxiosResponse<any>>}
*/
export function changePasswordApi(credentials) {
return post(`${prefix}/change-password`, credentials)
}

View File

@@ -0,0 +1,73 @@
import axios from 'axios'
// 婚礼纪业务 API统一走 /api 代理到 Go 后端 (:8080)
// 后端返回约定:{ code: 200, data, msg } 视为成功
const service = axios.create({
baseURL: '/api',
timeout: 15000,
})
// 请求拦截:受保护接口自动携带后台 token
service.interceptors.request.use((config) => {
const token = localStorage.getItem('admin_token')
if (token) {
config.headers = config.headers || {}
config.headers['Authorization'] = `Bearer ${token}`
}
return config
})
// 响应拦截:统一把 { code: 200, data } 形态解包
service.interceptors.response.use(
(response) => {
const body = response.data
// 后端标准包:{ code, data/msg/error }
if (body && typeof body === 'object' && 'code' in body) {
if (body.code === 200) return body
return Promise.reject(new Error(body.msg || body.error || '请求失败'))
}
// 没有 code 包裹(如 401 {error})视为失败
if (body && typeof body === 'object' && 'error' in body) {
return Promise.reject(new Error(body.error))
}
return body
},
(error) => {
const status = error?.response?.status
if (status === 401) {
localStorage.removeItem('admin_token')
localStorage.removeItem('admin_user')
}
return Promise.reject(error)
}
)
export function getConfig() {
return service.get('/config')
}
export function saveConfig(payload) {
return service.post('/config', payload)
}
export function uploadImage(file) {
const form = new FormData()
form.append('file', file)
return service.post('/upload', form, {
headers: { 'Content-Type': 'multipart/form-data' },
})
}
export function submitRsvp(form) {
return service.post('/rsvp', form)
}
export function getRsvpList() {
return service.get('/rsvp/list')
}
export function adminLogin(credentials) {
return service.post('/admin/login', credentials)
}
export default service

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View File

@@ -0,0 +1,26 @@
<template>
<div
class="relative w-[96px] h-[96px] rounded-xl border border-dashed border-[#a88c6b]/40 bg-[#Fdfbf7] overflow-hidden cursor-pointer hover:border-[#a88c6b] transition-colors group"
@click="$emit('pick')"
>
<img v-if="modelValue" :src="modelValue" class="w-full h-full object-cover" />
<div v-else class="w-full h-full flex flex-col items-center justify-center text-[#a88c6b]/70 gap-1">
<i class="fa-solid fa-image text-lg"></i>
<span class="text-[10px]">{{ placeholder }}</span>
</div>
<div class="absolute inset-0 bg-black/35 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center text-white text-[10px]">
<i class="fa-solid fa-cloud-arrow-up mr-1"></i>{{ uploading ? '上传中' : '点击上传' }}
</div>
<div v-if="label" class="absolute left-1 bottom-1 bg-white/80 text-[#a88c6b] text-[9px] px-1 rounded">{{ label }}</div>
</div>
</template>
<script setup>
defineProps({
modelValue: { type: String, default: '' },
label: { type: String, default: '' },
uploading: { type: Boolean, default: false },
placeholder: { type: String, default: '上传' },
})
defineEmits(['pick', 'update:modelValue'])
</script>

View File

@@ -0,0 +1,95 @@
import { ref, onUnmounted } from 'vue'
/**
* 背景音乐控制 composable
* 处理浏览器自动播放限制:首次用户交互后再尝试播放
*/
export function useAudio(initialUrl = '') {
const isPlaying = ref(false)
const isReady = ref(false)
let audio = null
let currentUrl = initialUrl
let unlockHandler = null
const setUrl = (url) => {
currentUrl = url
if (audio) {
const wasPlaying = isPlaying.value
audio.pause()
audio.src = url
isPlaying.value = false
if (wasPlaying && url) play()
}
}
const ensureAudio = () => {
if (audio) return audio
audio = new Audio(currentUrl)
audio.loop = true
audio.volume = 0.5
audio.preload = 'auto'
audio.addEventListener('canplay', () => (isReady.value = true))
audio.addEventListener('play', () => (isPlaying.value = true))
audio.addEventListener('pause', () => (isPlaying.value = false))
return audio
}
const tryPlay = async () => {
const a = ensureAudio()
try {
await a.play()
} catch (e) {
// 被浏览器拦截,等待用户手势
isPlaying.value = false
}
}
const play = async () => {
const a = ensureAudio()
try {
await a.play()
isPlaying.value = true
} catch (e) {
isPlaying.value = false
}
}
const pause = () => {
if (audio) audio.pause()
isPlaying.value = false
}
const toggle = () => {
if (isPlaying.value) pause()
else play()
}
// 首次交互解锁自动播放
const armAutoplay = () => {
const handler = () => {
play()
removeUnlock()
}
unlockHandler = handler
window.addEventListener('pointerdown', handler, { once: true })
window.addEventListener('keydown', handler, { once: true })
}
const removeUnlock = () => {
if (!unlockHandler) return
window.removeEventListener('pointerdown', unlockHandler)
window.removeEventListener('keydown', unlockHandler)
unlockHandler = null
}
onUnmounted(() => {
pause()
removeUnlock()
if (audio) {
audio.src = ''
audio = null
}
})
return { isPlaying, isReady, play, pause, toggle, tryPlay, armAutoplay, setUrl }
}

View File

@@ -0,0 +1,59 @@
import { ref, onMounted, onUnmounted } from 'vue'
/**
* v-reveal 滚动入场指令
*
* 用法:
* v-reveal -> 默认向上淡入
* v-reveal="'left'" -> 从左侧滑入
* v-reveal="{ variant: 'scale', delay: 200 }"
*
* 支持的 variant: up | down | left | right | scale | blur
*/
const VARIANTS = {
up: 'reveal-up',
down: 'reveal-down',
left: 'reveal-left',
right: 'reveal-right',
scale: 'reveal-scale',
blur: 'reveal-blur',
}
function parseBinding(value) {
if (!value) return { variant: 'up', delay: 0 }
if (typeof value === 'string') return { variant: value, delay: 0 }
return {
variant: value.variant || 'up',
delay: value.delay || 0,
}
}
const reveal = {
mounted(el, binding) {
const { variant, delay } = parseBinding(binding.value)
el.classList.add('reveal')
el.classList.add(VARIANTS[variant] || 'reveal-up')
if (delay) el.style.transitionDelay = `${delay}ms`
// 已处于视口(如首屏)时立即显示,避免永远不可见
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
el.classList.add('reveal--in')
observer.unobserve(el)
}
},
{ threshold: 0.12, rootMargin: '0px 0px -8% 0px' }
)
observer.observe(el)
el.__revealObserver = observer
},
unmounted(el) {
if (el.__revealObserver) {
el.__revealObserver.disconnect()
delete el.__revealObserver
}
},
}
export default reveal

View File

@@ -0,0 +1,32 @@
<script setup lang="ts">
</script>
<template>
<header class="bg-gradient-to-r from-blue-500 to-indigo-600 text-white py-4 px-6 flex items-center justify-between">
<div class="flex items-center space-x-4">
<!-- Logo -->
<img src="https://via.placeholder.com/40" alt="Logo" class="h-8 w-8 rounded-full">
<span class="font-bold">My App</span>
</div>
<!-- Menu -->
<nav class="hidden md:flex space-x-4">
<a href="#" class="hover:text-gray-300">首页</a>
<a href="#" class="hover:text-gray-300">About</a>
<a href="#" class="hover:text-gray-300">Services</a>
<a href="#" class="hover:text-gray-300">Contact</a>
</nav>
<!-- User Profile -->
<div class="flex items-center space-x-4">
<button class="text-sm bg-gray-800 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded">
Personal Center
</button>
<img src="https://via.placeholder.com/40" alt="User Avatar" class="h-8 w-8 rounded-full cursor-pointer">
</div>
</header>
</template>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,22 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import Antd from 'ant-design-vue'
import './style.css'
import './styles/animations.css'
import App from './App.vue'
import '@fortawesome/fontawesome-free/css/all.css' // 引入所有样式
// 注册路由
import router from './router'
// 滚动入场指令
import reveal from './directives/reveal'
const app = createApp(App)
// 注册Ant Design组件
app.use(createPinia())
app.use(router)
app.use(Antd)
// 注册全局指令
app.directive('reveal', reveal)
app.mount('#app')

View File

@@ -0,0 +1,39 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAdminStore } from '@/stores/admin'
// 首页请柬为全屏自包含页面,不套用通用布局
const routes = [
{
path: '/',
name: 'Home',
component: () => import('@/views/index/index.vue'),
},
{
path: '/login',
name: 'Login',
component: () => import('@/views/admin/AdminLogin.vue'),
},
{
path: '/admin',
name: 'Admin',
component: () => import('@/views/admin/AdminEditor.vue'),
meta: { requiresAuth: true },
},
]
const router = createRouter({
history: createWebHistory(),
routes,
scrollBehavior() {
return { top: 0 }
},
})
router.beforeEach((to) => {
const adminStore = useAdminStore()
if (to.meta.requiresAuth && !adminStore.isAdmin) {
return { name: 'Login', query: { redirect: to.fullPath } }
}
})
export default router

View File

@@ -0,0 +1,28 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { adminLogin as adminLoginApi } from '@/api/wedding'
export const useAdminStore = defineStore('admin', () => {
const token = ref(localStorage.getItem('admin_token') || '')
const user = ref(JSON.parse(localStorage.getItem('admin_user') || 'null'))
const isAdmin = computed(() => !!token.value)
async function login(credentials) {
const res = await adminLoginApi(credentials)
token.value = res.token
user.value = res.user || { account: credentials.account }
localStorage.setItem('admin_token', token.value)
localStorage.setItem('admin_user', JSON.stringify(user.value))
return res
}
function logout() {
token.value = ''
user.value = null
localStorage.removeItem('admin_token')
localStorage.removeItem('admin_user')
}
return { token, user, isAdmin, login, logout }
})

View File

@@ -0,0 +1,87 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import {loginApi, registerApi} from "@/api/request/user.js";
import {message, notification} from "ant-design-vue";
export const useUserStore = defineStore('user', () => {
const user = ref(JSON.parse(localStorage.getItem('user') || 'null'))
const modal = ref(localStorage.getItem('authModal') || 'false')
// 添加初始化状态校验
const initUser = () => {
const userData = localStorage.getItem('user')
if (!userData) return null
try {
return JSON.parse(userData)
} catch {
localStorage.removeItem('user')
localStorage.removeItem('token')
return null
}
}
const isAuthenticated = computed(() => !!user.value?.nick_name)
function login(credentials, functionName) {
console.log(functionName, 'sssssssssssss')
loginApi({
account: credentials.username,
password: credentials.password,
remember: credentials.remember,
}).then((res) => {
const userData = {
nick_name: res.nick_name,
role_name: res.role_name,
role_value: res.role_value,
}
user.value = userData;
localStorage.setItem('user', JSON.stringify(userData))
localStorage.setItem('token', res.token)
notification.success({
message: '登录成功',
description: `欢迎回来${res.nick_name}`,
})
functionName()
})
}
function register(credentials, functionName) {
registerApi({
account: credentials.username,
password: credentials.password,
email: credentials.email,
code: credentials.code,
}).then((res) => {
const userData = {
nick_name: res.nick_name,
role_name: res.role_name,
role_value: res.role_value,
}
user.value = userData;
localStorage.setItem('user', JSON.stringify(userData))
localStorage.setItem('token', res.token)
message.success('注册成功')
functionName()
})
}
function logout() {
user.value = null
localStorage.removeItem('user')
localStorage.removeItem('token')
}
function setModal(value) {
modal.value = modal.value === 'false' ? 'true' : 'false';
localStorage.setItem('authModal', value)
}
return {
modal,
user,
isAuthenticated,
setModal,
login,
register,
logout
}
})

View File

@@ -0,0 +1 @@
@import "tailwindcss";

View File

@@ -0,0 +1,129 @@
/* ============================================================
婚礼纪 · 动效样式库
集中管理关键帧与可复用动画工具类
============================================================ */
/* ---------- 滚动入场 (配合 v-reveal 指令) ---------- */
.reveal {
opacity: 0;
transition:
opacity 1.1s cubic-bezier(0.25, 0.46, 0.45, 0.94),
transform 1.1s cubic-bezier(0.25, 0.46, 0.45, 0.94),
filter 1.1s ease;
will-change: opacity, transform, filter;
}
.reveal-up { transform: translateY(48px); }
.reveal-down { transform: translateY(-48px); }
.reveal-left { transform: translateX(-56px); }
.reveal-right { transform: translateX(56px); }
.reveal-scale { transform: scale(0.9); }
.reveal-blur { transform: scale(1.04); filter: blur(14px); }
.reveal--in { opacity: 1; transform: none; filter: none; }
@media (prefers-reduced-motion: reduce) {
.reveal { opacity: 1 !important; transform: none !important; filter: none !important; transition: none; }
}
/* ---------- 飘落花瓣 ---------- */
@keyframes petalFall {
0% { transform: translate3d(0, -12vh, 0) rotate(0deg); opacity: 0; }
8% { opacity: 0.9; }
90% { opacity: 0.85; }
100% { transform: translate3d(8vw, 112vh, 0) rotate(420deg); opacity: 0; }
}
.petal {
position: absolute;
top: 0;
pointer-events: none;
will-change: transform, opacity;
animation: petalFall linear infinite;
}
/* ---------- 漂浮爱心 ---------- */
@keyframes heartFloat {
0% { transform: translateY(110%) translateX(0) scale(0.8); opacity: 0; }
10% { opacity: 0.85; }
50% { transform: translateY(45%) translateX(14px) scale(1); }
90% { opacity: 0.7; }
100% { transform: translateY(-15%) translateX(-10px) scale(1.1); opacity: 0; }
}
@keyframes heartSway {
0% { margin-left: -18px; }
100% { margin-left: 18px; }
}
.heart-bubble {
position: absolute;
bottom: 0;
animation:
heartFloat var(--float-duration, 18s) linear infinite,
heartSway var(--sway-duration, 5s) ease-in-out infinite alternate;
}
/* ---------- 心跳脉冲 ---------- */
@keyframes pulseHeart {
0%, 100% { transform: scale(1); }
15% { transform: scale(1.25); }
30% { transform: scale(1); }
45% { transform: scale(1.18); }
60% { transform: scale(1); }
}
.animate-pulse-heart { animation: pulseHeart 2.4s ease-in-out infinite; }
/* ---------- 旋转 ---------- */
@keyframes spin { to { transform: rotate(360deg); } }
.rotate-spin { animation: spin 4s linear infinite; }
/* ---------- 入场揭幕 (封面帘幕) ---------- */
@keyframes curtainRise {
0% { transform: translateY(0); }
100% { transform: translateY(-100%); }
}
.curtain {
animation: curtainRise 1.4s cubic-bezier(0.76, 0, 0.24, 1) 1.6s forwards;
}
@keyframes curtainSeal {
0% { transform: scaleX(0); opacity: 0; }
60% { transform: scaleX(1); opacity: 1; }
100% { transform: scaleX(1); opacity: 1; }
}
.curtain-seal {
transform-origin: center;
animation: curtainSeal 1.2s cubic-bezier(0.22, 1, 0.36, 1) both;
}
/* ---------- 渐变流光文字 ---------- */
@keyframes gradientShift { to { background-position: 200% center; } }
.gradient-text {
background: linear-gradient(90deg, #a88c6b, #e8a5b2, #d4af37, #a88c6b);
background-size: 200% auto;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
animation: gradientShift 6s linear infinite;
}
/* ---------- 柔光呼吸 ---------- */
@keyframes softFloat {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
.float-soft { animation: softFloat 6s ease-in-out infinite; }
/* ---------- 成功提交弹入 ---------- */
@keyframes fadeUp {
0% { opacity: 0; transform: translateY(20px); }
100% { opacity: 1; transform: translateY(0); }
}
.animate-fade-up { animation: fadeUp 0.7s cubic-bezier(0.22, 1, 0.36, 1) both; }
/* ---------- 微光描边 ---------- */
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.shimmer-line {
background: linear-gradient(90deg, transparent, rgba(168,140,107,0.6), transparent);
background-size: 200% 100%;
animation: shimmer 3s linear infinite;
}

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>

View File

@@ -0,0 +1,365 @@
<template>
<div class="wrapper relative w-full h-[100vh] bg-[#Fdfbf7] overflow-hidden text-[#2c2c2c]">
<!-- 入场揭幕帘幕 -->
<div v-show="showIntro" @click="skipIntro"
class="curtain fixed inset-0 z-[100] bg-gradient-to-b from-[#a88c6b] to-[#8a704f] flex flex-col items-center justify-center cursor-pointer">
<div class="curtain-seal text-center text-white">
<div class="text-[10px] tracking-[0.6em] uppercase opacity-80 mb-4">Wedding Invitation</div>
<div class="text-3xl font-light tracking-[0.2em]">{{ data.groom }} <span class="italic font-serif mx-2">&amp;</span> {{ data.bride }}</div>
<div class="w-10 h-[1px] bg-white/60 mx-auto mt-5"></div>
<div class="text-[11px] tracking-[0.4em] mt-5 opacity-70">点击进入</div>
</div>
</div>
<!-- 飘落花瓣层 -->
<div v-if="data.petalEnabled" class="absolute inset-0 pointer-events-none overflow-hidden z-[1]">
<div v-for="p in petals" :key="'p'+p.id" class="petal"
:style="{ left: p.left, width: p.size+'px', height: p.size+'px', opacity: p.opacity, background: p.color, animationDuration: p.duration, animationDelay: p.delay, transform: `rotate(${p.rotate}deg)` }"></div>
</div>
<!-- 漂浮爱心层 -->
<div v-if="data.bubbleEnabled" class="absolute inset-0 pointer-events-none overflow-hidden z-[1]">
<div v-for="b in bubbles" :key="'b'+b.id"
class="heart-bubble flex items-center justify-center"
:style="{ left: b.left, color: b.color, fontSize: b.size+'px', opacity: b.opacity, '--float-duration': b.floatDuration, '--sway-duration': b.swayDuration }"></div>
</div>
<!-- 顶部滚动进度条 -->
<div class="absolute top-0 left-0 h-[2px] bg-[#a88c6b] z-[55] transition-[width] duration-150" :style="{ width: progress * 100 + '%' }"></div>
<!-- 右上角控制台 -->
<div class="absolute top-6 right-5 z-50 flex flex-col gap-4">
<div @click="audio.toggle()" class="control-btn cursor-pointer" :class="{'rotate-spin': audio.isPlaying.value}">
<img v-if="audio.isPlaying.value" src="https://api.iconify.design/lucide:music.svg?color=%23a88c6b" class="w-4 h-4" />
<img v-else src="https://api.iconify.design/lucide:volume-x.svg?color=%23a88c6b" class="w-4 h-4" />
</div>
<div @click="toggleAutoScroll" class="control-btn cursor-pointer transition-all duration-300" :class="isAutoScroll ? 'bg-[#a88c6b]' : 'bg-white/60 shadow-sm border-[#a88c6b]/30'">
<img v-if="isAutoScroll" src="https://api.iconify.design/lucide:pause.svg?color=%23ffffff" class="w-4 h-4" />
<img v-else src="https://api.iconify.design/lucide:play.svg?color=%23a88c6b" class="w-4 h-4 ml-0.5" />
</div>
</div>
<!-- 低调管理入口 -->
<router-link to="/login" class="absolute bottom-4 right-4 z-50 text-[10px] text-[#a88c6b]/50 hover:text-[#a88c6b] tracking-widest">管理后台</router-link>
<!-- 主滚动区域 -->
<div ref="scrollContainerRef"
class="w-full h-full overflow-y-auto no-scrollbar relative scroll-smooth"
:class="data.scrollMode === 'snap' ? 'snap-y snap-mandatory' : ''"
@scroll="handleScroll" @touchstart="handleUserInteraction" @touchmove="handleUserInteraction"
@wheel="handleUserInteraction" @mousedown="handleUserInteraction">
<!-- 1. 封面页 -->
<div class="page-section" :class="data.scrollMode === 'snap' ? 'snap-start' : ''">
<div class="flex flex-col items-center justify-between py-12 h-full w-full relative border-[0.5px] border-[#a88c6b]/20 bg-white/40">
<div v-reveal="'down'" class="text-center mt-6 z-10">
<span class="text-[10px] tracking-[0.5em] uppercase text-[#a88c6b] font-light block">Wedding Invitation</span>
<div class="w-6 h-[0.5px] bg-[#a88c6b] mx-auto opacity-50 mt-4"></div>
</div>
<div v-reveal="{ variant: 'scale', delay: 120 }" class="w-full max-w-[260px] aspect-[3/4] relative z-10">
<div class="absolute inset-0 border border-[#a88c6b] translate-x-3 translate-y-3 opacity-30"></div>
<div class="relative w-full h-full z-10 bg-[#F7F6F2] shadow-md overflow-hidden">
<img :src="data.heroImg" class="w-full h-full object-cover will-change-transform" :style="{ transform: `translateY(${parallax}px) scale(1.12)` }" />
</div>
</div>
<div v-reveal="{ variant: 'up', delay: 200 }" class="text-center z-10 mb-10 space-y-4 px-4">
<div class="flex items-end justify-center gap-6 text-3xl font-light tracking-[0.2em]">
<span>{{ data.groom }}</span>
<span class="text-[#a88c6b] text-xl pb-1 italic font-serif">&amp;</span>
<span>{{ data.bride }}</span>
</div>
<div class="flex flex-col items-center text-[11px] text-[#8A8680] tracking-[0.2em] mt-4 uppercase">
<span>{{ data.date }}</span>
<div class="w-4 h-[0.5px] bg-[#a88c6b]/40 my-2"></div>
<span>{{ data.lunar }}</span>
</div>
</div>
</div>
</div>
<!-- 2. 竖排文案页 -->
<div class="page-section bg-[#F7F6F2] items-center px-8 relative justify-center" :class="data.scrollMode === 'snap' ? 'snap-start' : ''">
<div class="absolute top-0 left-0 w-32 h-32 border-l-[0.5px] border-t-[0.5px] border-[#a88c6b]/30 m-6"></div>
<div class="absolute bottom-0 right-0 w-32 h-32 border-r-[0.5px] border-b-[0.5px] border-[#a88c6b]/30 m-6"></div>
<div v-reveal="'blur'" class="flex flex-col items-center w-full">
<div class="text-3xl opacity-60 mb-14 text-[#a88c6b] font-serif float-soft"></div>
<div class="flex justify-center gap-10 h-72 text-[#3A3A3A] text-lg leading-loose font-light">
<span class="vertical-text">{{ data.formalText1 }}</span>
<span class="vertical-text" style="padding-top: 3rem;">{{ data.formalText2 }}</span>
<span class="vertical-text text-[#8A8680] text-[12px] ml-4 border-l-[0.5px] border-[#a88c6b]/30 pl-6 h-56 mt-8 tracking-[0.3em]">诚挚邀请您见证我们的幸福</span>
</div>
</div>
</div>
<!-- 3-6. 画廊循环 -->
<div v-for="(page, pIdx) in data.photoPages" :key="pIdx" class="page-section p-6 relative" :class="data.scrollMode === 'snap' ? 'snap-start' : ''">
<div class="absolute inset-0 bg-white/40 backdrop-blur-[2px] -z-10"></div>
<div class="absolute top-0 left-0 w-24 h-24 border-l-[0.5px] border-t-[0.5px] border-[#a88c6b]/30 m-6 pointer-events-none z-0"></div>
<div class="absolute bottom-0 right-0 w-24 h-24 border-r-[0.5px] border-b-[0.5px] border-[#a88c6b]/30 m-6 pointer-events-none z-0"></div>
<div class="w-full h-full flex flex-col justify-center py-6 relative z-10">
<div v-reveal="'up'" class="text-center mb-4 px-4">
<span class="text-xl text-[#a88c6b] tracking-[0.3em] font-light block mb-2">{{ page.title }}</span>
<span class="text-[9px] text-[#8A8680] uppercase tracking-[0.4em] block mb-4">{{ page.subtitle }}</span>
<span class="text-[12px] text-[#6b6863] font-light leading-relaxed mx-auto max-w-[280px] tracking-widest block whitespace-pre-wrap">{{ page.desc }}</span>
</div>
<!-- 单图 -->
<div v-if="page.type === 'single'" v-reveal="{ variant: 'scale', delay: 100 }" class="w-full max-w-[300px] mx-auto aspect-[4/5] bg-white p-2 shadow-sm border-[0.5px] border-[#a88c6b]/20 relative mt-8">
<img :src="page.img1" class="w-full h-full object-cover" />
</div>
<!-- 错落双图 -->
<div v-if="page.type === 'overlap'" class="relative w-full h-[50vh] mt-4">
<div v-reveal="{ variant: 'left', delay: 100 }" class="absolute top-0 right-4 w-[70%] h-[65%] shadow-md bg-white p-1 z-10 border border-[#a88c6b]/10">
<img :src="page.img1" class="w-full h-full object-cover" />
</div>
<div v-reveal="{ variant: 'right', delay: 300 }" class="absolute bottom-4 left-4 w-[60%] h-[60%] shadow-lg bg-white p-1 z-20 border border-[#a88c6b]/10">
<img :src="page.img2" class="w-full h-full object-cover" />
</div>
</div>
<!-- 一大五小 -->
<div v-if="page.type === 'one-large-five-small'" class="relative w-full aspect-square mt-6 grid grid-cols-3 grid-rows-3 gap-1.5 z-20">
<div v-reveal="{ variant: 'scale', delay: 100 }" class="col-span-2 row-span-2 relative overflow-hidden bg-[#Fdfbf7] shadow-sm">
<img :src="page.images[0]" class="w-full h-full object-cover hover:scale-105 transition-transform duration-1000" />
</div>
<div v-for="i in [1,2,3,4,5]" :key="i" v-reveal="{ variant: 'up', delay: i * 80 }" class="relative bg-[#Fdfbf7]">
<img :src="page.images[i]" class="w-full h-full object-cover" />
</div>
</div>
<!-- 九宫格 -->
<div v-if="page.type === 'nine-grid'" class="grid grid-cols-3 gap-1.5 px-4 mt-6">
<div v-for="(img, imgIdx) in page.images" :key="imgIdx" v-reveal="{ variant: 'scale', delay: imgIdx * 60 }" class="aspect-square bg-[#Fdfbf7] relative rounded-sm overflow-hidden">
<img :src="img" class="w-full h-full object-cover hover:scale-105 transition-transform duration-700" />
</div>
</div>
</div>
</div>
<!-- 7. 婚礼流程 -->
<div class="page-section bg-transparent px-6 z-10 justify-center relative" :class="data.scrollMode === 'snap' ? 'snap-start' : ''">
<div class="absolute inset-0 bg-[#Fdfbf7]/60 backdrop-blur-[2px] -z-10"></div>
<div v-reveal="'up'" class="text-center mb-14">
<span class="text-2xl text-[#a88c6b] tracking-[0.3em] font-light block">婚礼流程</span>
<span class="text-[10px] text-[#8A8680] font-sans uppercase tracking-[0.4em] mt-3 block">Wedding Schedule</span>
</div>
<div class="relative w-full px-2 max-w-[340px] mx-auto">
<div class="absolute left-1/2 top-2 bottom-4 w-[0.5px] bg-[#a88c6b]/40 -translate-x-1/2"></div>
<div v-for="(item, index) in data.schedule" :key="index" class="flex w-full mb-10 relative z-10" :class="index % 2 === 0 ? 'justify-start' : 'justify-end'">
<div v-reveal="{ variant: index % 2 === 0 ? 'left' : 'right', delay: index * 120 }"
class="w-[45%] flex flex-col relative bg-white/60 p-4 rounded-xl shadow-sm border-[0.5px] border-[#a88c6b]/10"
:class="index % 2 === 0 ? 'text-right items-end' : 'text-left items-start'">
<div class="absolute top-[22px] w-2 h-2 rounded-full bg-[#a88c6b] shadow-[0_0_0_4px_rgba(168,140,107,0.15)]" :class="index % 2 === 0 ? '-right-[28px]' : '-left-[28px]'"></div>
<span class="text-xl font-serif text-[#a88c6b] mb-1 font-light tracking-wider">{{ item.time }}</span>
<span class="text-[13px] tracking-[0.2em] text-[#3a3a3a] font-medium mb-1">{{ item.event }}</span>
<span class="text-[9px] tracking-[0.1em] text-[#8A8680] font-sans uppercase">{{ item.desc }}</span>
</div>
</div>
</div>
</div>
<!-- 8. 尾页 -->
<div class="page-section p-6 flex flex-col items-center justify-center relative" :class="data.scrollMode === 'snap' ? 'snap-start' : ''">
<div class="absolute top-10 bottom-10 left-6 right-6 border-[0.5px] border-[#a88c6b]/40 rounded-t-[10rem] z-0 bg-[#Fdfbf7]/30"></div>
<div class="w-full flex flex-col items-center z-10 relative py-10">
<div v-reveal="{ variant: 'scale', delay: 100 }" class="w-full max-w-[200px] aspect-[3/4] rounded-t-full overflow-hidden mb-10 shadow-lg border-[4px] border-white relative">
<img :src="data.heroImg" class="w-full h-full bg-[#F7F6F2] object-cover" />
</div>
<div v-reveal="{ variant: 'up', delay: 200 }" class="text-center space-y-2 mb-10 px-4">
<span class="text-xl text-[#a88c6b] tracking-[0.3em] font-light block mb-4">期待您的光临</span>
<span class="text-[14px] text-[#3A3A3A] tracking-widest font-medium block">{{ data.hotel }}</span>
<span class="text-[11px] text-[#8A8680] font-light tracking-[0.1em] leading-loose block max-w-[240px] mx-auto whitespace-pre-wrap">{{ data.address }}</span>
</div>
<div v-reveal="{ variant: 'up', delay: 300 }" class="flex flex-col gap-4 w-full px-8 max-w-[280px]">
<div @click="isDrawerOpen = true" class="w-full bg-[#a88c6b] text-white py-4 text-[11px] tracking-[0.2em] flex items-center justify-center gap-3 shadow-md uppercase rounded-full cursor-pointer hover:bg-[#967b5c] transition-all">
<img src="https://api.iconify.design/lucide:send.svg?color=%23ffffff" class="w-3.5 h-3.5" />
<span>填写出席回执</span>
</div>
</div>
</div>
</div>
</div>
<!-- RSVP 抽屉 -->
<div v-if="isDrawerOpen" class="absolute inset-0 z-[60] bg-black/40 backdrop-blur-sm transition-opacity duration-500" @click="closeDrawer"></div>
<div class="absolute bottom-0 left-0 w-full bg-white rounded-t-[2.5rem] shadow-2xl transition-transform duration-500 z-[70] p-10 flex flex-col" :style="{ transform: isDrawerOpen ? 'translateY(0)' : 'translateY(100%)' }">
<div @click="closeDrawer" class="absolute top-6 right-6 p-2 text-[#a88c6b] cursor-pointer hover:text-[#2c2c2c] transition-colors text-xl"></div>
<span class="text-xl text-center text-[#a88c6b] mb-1 tracking-[0.3em] font-light mt-2 uppercase">Guest RSVP</span>
<span class="text-center text-[10px] text-[#8A8680] mb-10 uppercase tracking-[0.4em]">R . S . V . P</span>
<div v-if="!isSubmitted" class="space-y-6">
<div>
<span class="block text-[10px] text-[#a88c6b] mb-1.5 tracking-widest uppercase pl-1">Name</span>
<input v-model="rsvpForm.name" placeholder="您的姓名" class="w-full bg-[#Fdfbf7] border-[0.5px] border-[#a88c6b]/30 rounded-xl px-5 py-4 text-[13px] focus:outline-none focus:border-[#a88c6b] shadow-[inset_0_2px_4px_rgba(0,0,0,0.02)]" />
</div>
<div>
<span class="block text-[10px] text-[#a88c6b] mb-1.5 tracking-widest uppercase pl-1">Attendance</span>
<div @click="isSelectOpen = !isSelectOpen" class="w-full bg-[#Fdfbf7] border-[0.5px] border-[#a88c6b]/30 rounded-xl px-5 py-4 text-[13px] flex items-center justify-between cursor-pointer">
<span>{{ rsvpOptions.find(o => o.value === rsvpForm.guest_count)?.label || '请选择出席人数' }}</span>
<span class="text-[#a88c6b] transition-transform" :style="{ transform: isSelectOpen ? 'rotate(180deg)' : '' }"></span>
</div>
<div v-show="isSelectOpen" class="absolute left-10 right-10 bottom-full mb-3 bg-white border border-[#a88c6b]/20 rounded-2xl shadow-2xl overflow-hidden z-[80]">
<div v-for="opt in rsvpOptions" :key="opt.value" @click="selectRsvpCount(opt.value)" class="px-5 py-4 text-[13px] hover:bg-[#Fdfbf7] cursor-pointer" :class="rsvpForm.guest_count === opt.value ? 'text-[#a88c6b] bg-[#Fdfbf7]/50' : ''">{{ opt.label }}</div>
</div>
</div>
<div>
<span class="block text-[10px] text-[#a88c6b] mb-1.5 tracking-widest uppercase pl-1">Wishes</span>
<textarea v-model="rsvpForm.wishes" placeholder="写下您的祝福..." class="w-full bg-[#Fdfbf7] border-[0.5px] border-[#a88c6b]/30 rounded-xl px-5 py-4 text-[13px] h-24 focus:outline-none resize-none shadow-[inset_0_2px_4px_rgba(0,0,0,0.02)]"></textarea>
</div>
<div @click="handleSubmitRsvp" class="w-full bg-[#a88c6b] text-white py-4 rounded-full text-center tracking-[0.3em] text-[12px] cursor-pointer hover:bg-[#967b5c] transition-all shadow-md">确认发送</div>
</div>
<div v-else class="text-center py-10 flex flex-col items-center animate-fade-up">
<span class="text-3xl text-[#a88c6b] mb-6 animate-pulse-heart"></span>
<span class="text-lg text-[#2c2c2c] mb-3 tracking-[0.2em] font-light">感恩您的祝福</span>
<span class="text-[13px] text-[#8A8680] leading-loose tracking-widest font-light">回执已妥投期待与您共享喜悦</span>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, onUnmounted } from 'vue'
import { getConfig, submitRsvp } from '@/api/wedding'
import { useAudio } from '@/composables/useAudio'
const DEFAULTS = {
scrollMode: 'snap', petalEnabled: true, bubbleEnabled: true, introEnabled: true, musicUrl: '',
}
const data = ref({
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',
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: Array.from({ length: 6 }, () => 'https://images.unsplash.com/photo-1519741497674-611481863552?auto=format&fit=crop&w=600&q=80') },
{ type: 'nine-grid', title: '相守', subtitle: 'FOREVER', desc: '往后余生,风雪是你,平淡是你。', images: Array.from({ length: 9 }, () => 'https://images.unsplash.com/photo-1511285560929-80b456fea0bc?auto=format&fit=crop&w=400&q=80') },
],
schedule: [ { time: '16:00', event: '签到迎宾', desc: 'Welcome' }, { time: '17:08', event: '仪式开始', desc: 'Ceremony' }, { time: '18:00', event: '敬备喜宴', desc: 'Banquet' } ],
...DEFAULTS,
})
const audio = useAudio()
const isAutoScroll = ref(true), isDrawerOpen = ref(false), isSelectOpen = ref(false), isSubmitted = ref(false)
const isInteracting = ref(false), scrollContainerRef = ref(null), progress = ref(0), parallax = ref(0)
const showIntro = ref(false)
const rsvpForm = reactive({ name: '', guest_count: '1', wishes: '' })
const rsvpOptions = [ { value: '1', label: '1 人出席' }, { value: '2', label: '2 人出席' }, { value: '3', label: '3 人及以上' }, { value: '0', label: '遗憾缺席' } ]
let rafId = null, snapTimer = null, pauseTimer = null, introTimer = null
const loveColors = ['#a88c6b', '#e8a5b2', '#f4c2c2', '#d4af37']
const petalColors = ['#f7d9e0', '#fbeee3', '#f3c6d3', '#efd9c4', '#f9e7d6']
const bubbles = Array.from({ length: 15 }).map((_, i) => ({ id: i, left: Math.random() * 100 + '%', color: loveColors[i % 4], size: 14 + Math.random() * 10, opacity: 0.2, floatDuration: 14 + Math.random() * 10 + 's', swayDuration: 4 + Math.random() * 2 + 's' }))
const petals = Array.from({ length: 18 }).map((_, i) => ({ id: i, left: Math.random() * 100 + '%', size: 10 + Math.random() * 14, opacity: 0.4 + Math.random() * 0.4, color: petalColors[i % petalColors.length], duration: 9 + Math.random() * 8 + 's', delay: Math.random() * 10 + 's', rotate: Math.random() * 360 }))
const selectRsvpCount = (val) => { rsvpForm.guest_count = val; isSelectOpen.value = false }
const closeDrawer = () => { isDrawerOpen.value = false; isSelectOpen.value = false }
const handleScroll = () => {
const el = scrollContainerRef.value
if (!el) return
const max = el.scrollHeight - el.clientHeight
progress.value = max > 0 ? el.scrollTop / max : 0
parallax.value = el.scrollTop * 0.25
}
const handleUserInteraction = () => {
isInteracting.value = true
clearTimeout(pauseTimer)
pauseTimer = setTimeout(() => (isInteracting.value = false), 3000)
}
const sectionEls = () => Array.from(scrollContainerRef.value?.querySelectorAll('.page-section') || [])
const goNextSection = () => {
const el = scrollContainerRef.value
if (!el) return
const top = el.scrollTop
const next = sectionEls().find((e) => e.offsetTop > top + 12)
if (next) el.scrollTo({ top: next.offsetTop, behavior: 'smooth' })
else isAutoScroll.value = false
}
const startAutoScroll = () => {
if (data.value.scrollMode === 'snap') {
snapTimer = setInterval(() => {
if (isAutoScroll.value && !isInteracting.value && !isDrawerOpen.value) goNextSection()
}, 4500)
} else {
const step = () => {
const el = scrollContainerRef.value
if (el && isAutoScroll.value && !isInteracting.value && !isDrawerOpen.value) {
el.scrollTop += 0.8
if (el.scrollTop >= el.scrollHeight - el.clientHeight - 5) isAutoScroll.value = false
}
rafId = requestAnimationFrame(step)
}
step()
}
}
const toggleAutoScroll = () => {
isAutoScroll.value = !isAutoScroll.value
const el = scrollContainerRef.value
if (isAutoScroll.value && el && el.scrollTop >= el.scrollHeight - el.clientHeight - 10) el.scrollTop = 0
}
const skipIntro = () => { showIntro.value = false }
const handleSubmitRsvp = async () => {
if (!rsvpForm.name.trim()) return alert('请填写姓名')
try {
const res = await submitRsvp(rsvpForm)
if (res.code === 200) isSubmitted.value = true
else alert(res.error || '提交失败')
} catch (e) { alert('提交失败,请检查后端运行状态') }
}
onMounted(async () => {
try {
const res = await getConfig()
if (res.data && res.data !== '{}') {
const loaded = typeof res.data === 'string' ? JSON.parse(res.data) : res.data
data.value = { ...data.value, ...DEFAULTS, ...loaded }
}
} catch (e) { /* 使用默认配置 */ }
if (data.value.musicUrl) audio.setUrl(data.value.musicUrl)
if (data.value.introEnabled) {
showIntro.value = true
introTimer = setTimeout(() => (showIntro.value = false), 3200)
audio.armAutoplay()
}
startAutoScroll()
handleScroll()
})
onUnmounted(() => {
cancelAnimationFrame(rafId)
clearInterval(snapTimer)
clearTimeout(pauseTimer)
clearTimeout(introTimer)
})
</script>
<style scoped>
.wrapper { font-family: "PingFang SC", sans-serif; }
.control-btn {
width: 38px; height: 38px; display: flex; align-items: center; justify-content: center; border-radius: 9999px;
backdrop-filter: blur(12px); border: 0.5px solid rgba(168, 140, 107, 0.3); color: #a88c6b; background: rgba(255,255,255,0.6);
}
.vertical-text { writing-mode: vertical-rl; letter-spacing: 0.4em; text-orientation: upright; }
.page-section { min-height: 100vh; width: 100%; position: relative; overflow: hidden; display: flex; flex-direction: column; justify-content: center; }
.no-scrollbar::-webkit-scrollbar { display: none; }
/* 花瓣形状 */
.petal { border-radius: 0 100% 0 100%; }
.heart-bubble { animation: heartFloat var(--float-duration, 18s) linear infinite, heartSway var(--sway-duration, 5s) ease-in-out infinite alternate; position: absolute; bottom: 0; }
</style>

View File

@@ -0,0 +1,27 @@
import {defineConfig} from 'vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from "@tailwindcss/vite"
import path from 'path'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
tailwindcss()
],
resolve: {
alias: {
'@': path.resolve(__dirname, './src')
}
},
server: {
port: 8888,
proxy: {
// 婚礼纪 Go 后端运行在 :8080接口统一以 /api 开头,无需重写
'/api': {
target: 'http://127.0.0.1:8080',
changeOrigin: true
}
}
}
})