初始化
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/vite-tailwindcss/.npmcache/
|
||||
11
.idea/go.imports.xml
generated
Normal file
11
.idea/go.imports.xml
generated
Normal 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
6
hunliji-api/.idea/vcs.xml
generated
Normal 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
24
vite-tailwindcss/.gitignore
vendored
Normal 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?
|
||||
3
vite-tailwindcss/.vscode/extensions.json
vendored
Normal file
3
vite-tailwindcss/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
5
vite-tailwindcss/README.md
Normal file
5
vite-tailwindcss/README.md
Normal 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).
|
||||
13
vite-tailwindcss/index.html
Normal file
13
vite-tailwindcss/index.html
Normal 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
3094
vite-tailwindcss/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
vite-tailwindcss/package.json
Normal file
31
vite-tailwindcss/package.json
Normal 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
2027
vite-tailwindcss/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
5
vite-tailwindcss/pnpm-workspace.yaml
Normal file
5
vite-tailwindcss/pnpm-workspace.yaml
Normal 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
|
||||
1
vite-tailwindcss/public/vite.svg
Normal file
1
vite-tailwindcss/public/vite.svg
Normal 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 |
21
vite-tailwindcss/src/App.vue
Normal file
21
vite-tailwindcss/src/App.vue
Normal 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>
|
||||
75
vite-tailwindcss/src/api/request.js
Normal file
75
vite-tailwindcss/src/api/request.js
Normal 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 };
|
||||
10
vite-tailwindcss/src/api/request/upload.js
Normal file
10
vite-tailwindcss/src/api/request/upload.js
Normal 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)
|
||||
}
|
||||
92
vite-tailwindcss/src/api/request/user.js
Normal file
92
vite-tailwindcss/src/api/request/user.js
Normal 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)
|
||||
}
|
||||
73
vite-tailwindcss/src/api/wedding.js
Normal file
73
vite-tailwindcss/src/api/wedding.js
Normal 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
|
||||
1
vite-tailwindcss/src/assets/vue.svg
Normal file
1
vite-tailwindcss/src/assets/vue.svg
Normal 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 |
26
vite-tailwindcss/src/components/ImageField.vue
Normal file
26
vite-tailwindcss/src/components/ImageField.vue
Normal 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>
|
||||
95
vite-tailwindcss/src/composables/useAudio.js
Normal file
95
vite-tailwindcss/src/composables/useAudio.js
Normal 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 }
|
||||
}
|
||||
59
vite-tailwindcss/src/directives/reveal.js
Normal file
59
vite-tailwindcss/src/directives/reveal.js
Normal 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
|
||||
32
vite-tailwindcss/src/layouts/Header.vue
Normal file
32
vite-tailwindcss/src/layouts/Header.vue
Normal 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>
|
||||
22
vite-tailwindcss/src/main.js
Normal file
22
vite-tailwindcss/src/main.js
Normal 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')
|
||||
39
vite-tailwindcss/src/router/index.js
Normal file
39
vite-tailwindcss/src/router/index.js
Normal 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
|
||||
28
vite-tailwindcss/src/stores/admin.js
Normal file
28
vite-tailwindcss/src/stores/admin.js
Normal 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 }
|
||||
})
|
||||
87
vite-tailwindcss/src/stores/user.js
Normal file
87
vite-tailwindcss/src/stores/user.js
Normal 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
|
||||
}
|
||||
})
|
||||
1
vite-tailwindcss/src/style.css
Normal file
1
vite-tailwindcss/src/style.css
Normal file
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
129
vite-tailwindcss/src/styles/animations.css
Normal file
129
vite-tailwindcss/src/styles/animations.css
Normal 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;
|
||||
}
|
||||
352
vite-tailwindcss/src/views/admin/AdminEditor.vue
Normal file
352
vite-tailwindcss/src/views/admin/AdminEditor.vue
Normal 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>
|
||||
65
vite-tailwindcss/src/views/admin/AdminLogin.vue
Normal file
65
vite-tailwindcss/src/views/admin/AdminLogin.vue
Normal 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>
|
||||
365
vite-tailwindcss/src/views/index/index.vue
Normal file
365
vite-tailwindcss/src/views/index/index.vue
Normal 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">&</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">&</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>
|
||||
27
vite-tailwindcss/vite.config.js
Normal file
27
vite-tailwindcss/vite.config.js
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user