Initial commit: 全友米项目完整代码

This commit is contained in:
Yvan
2026-04-09 09:40:45 +08:00
commit 89960b1cb8
54 changed files with 14574 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
# 高德地图API Key
VITE_AMAP_KEY=your_amap_key_here
# 高德地图安全密钥(securityJsCode)
# 获取方式:登录 https://console.amap.com/ -> 应用管理 -> 查看应用详情 -> 安全密钥
VITE_AMAP_SECURITY_CODE=your_security_code_here
# API基础地址
VITE_API_BASE_URL=/api
+19
View File
@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>泉友米车友会</title>
<script>
// 运行时配置(可以通过环境变量注入)
window.AMAP_CONFIG = {
key: 'YOUR_AMAP_KEY_PLACEHOLDER',
securityCode: 'YOUR_AMAP_SECURITY_CODE_PLACEHOLDER'
};
</script>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+201
View File
@@ -0,0 +1,201 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>高德地图搜索测试</title>
<style>
body {
font-family: Arial, sans-serif;
padding: 20px;
max-width: 800px;
margin: 0 auto;
}
#container {
width: 100%;
height: 400px;
margin: 20px 0;
border: 1px solid #ccc;
}
.search-box {
margin: 20px 0;
}
input {
padding: 10px;
width: 300px;
font-size: 14px;
}
button {
padding: 10px 20px;
font-size: 14px;
cursor: pointer;
}
.results {
margin-top: 20px;
}
.result-item {
padding: 10px;
border-bottom: 1px solid #eee;
cursor: pointer;
}
.result-item:hover {
background: #f5f5f5;
}
.log {
background: #f5f5f5;
padding: 10px;
margin-top: 20px;
max-height: 300px;
overflow-y: auto;
font-family: monospace;
font-size: 12px;
}
</style>
</head>
<body>
<h1>高德地图搜索功能测试</h1>
<div class="search-box">
<input type="text" id="keyword" placeholder="输入地点名称,如:泉州市政府" />
<button onclick="searchPlace()">搜索</button>
</div>
<div id="container"></div>
<div class="results" id="results"></div>
<div class="log" id="log"></div>
<script>
// 替换为你的高德地图Key和安全密钥
const AMAP_KEY = 'fd5b77136ba34fe6b4f0f86dca7782d9';
const SECURITY_CODE = 'b2e67bb4d5eed8848f0b8aae6ca667fc';
let map = null;
let placeSearch = null;
let marker = null;
function log(message) {
const logDiv = document.getElementById('log');
const time = new Date().toLocaleTimeString();
logDiv.innerHTML += `[${time}] ${message}<br>`;
logDiv.scrollTop = logDiv.scrollHeight;
console.log(message);
}
function initMap() {
log('开始初始化地图...');
map = new AMap.Map('container', {
zoom: 13,
center: [118.6762, 24.8742] // 泉州市中心
});
log('地图初始化成功');
// 初始化PlaceSearch
AMap.plugin(['AMap.PlaceSearch'], function() {
placeSearch = new AMap.PlaceSearch({
pageSize: 10,
pageIndex: 1,
city: '泉州',
citylimit: false,
extensions: 'base'
});
log('PlaceSearch插件加载成功');
log('配置信息:city=泉州, citylimit=false, extensions=base');
});
}
function searchPlace() {
const keyword = document.getElementById('keyword').value.trim();
if (!keyword) {
alert('请输入搜索关键词');
return;
}
if (!placeSearch) {
log('错误:PlaceSearch未初始化');
alert('地图搜索功能未就绪,请刷新页面重试');
return;
}
log(`开始搜索:${keyword}`);
placeSearch.search(keyword, function(status, result) {
log(`搜索状态:${status}`);
const resultsDiv = document.getElementById('results');
resultsDiv.innerHTML = '';
if (status === 'complete' && result.info === 'OK') {
if (result.poiList && result.poiList.pois && result.poiList.pois.length > 0) {
log(`找到 ${result.poiList.pois.length} 个结果`);
result.poiList.pois.forEach((poi, index) => {
const div = document.createElement('div');
div.className = 'result-item';
div.innerHTML = `
<strong>${index + 1}. ${poi.name}</strong><br>
地址:${poi.address || '无'}<br>
坐标:${poi.location.lng}, ${poi.location.lat}
`;
div.onclick = () => showOnMap(poi);
resultsDiv.appendChild(div);
});
} else {
log('搜索结果为空');
resultsDiv.innerHTML = '<p>未找到相关地点</p>';
}
} else if (status === 'no_data') {
log('无搜索结果');
resultsDiv.innerHTML = '<p>未找到相关地点</p>';
} else {
log(`搜索失败:${status} - ${result.info || '未知错误'}`);
log(`完整结果:${JSON.stringify(result)}`);
resultsDiv.innerHTML = `<p style="color: red;">搜索失败:${result.info || status}</p>`;
}
});
}
function showOnMap(poi) {
const position = [poi.location.lng, poi.location.lat];
if (marker) {
marker.setPosition(position);
} else {
marker = new AMap.Marker({
position: position,
map: map
});
}
map.setCenter(position);
map.setZoom(16);
log(`在地图上显示:${poi.name}`);
}
// 配置安全密钥(必须在加载地图前配置)
window._AMapSecurityConfig = {
securityJsCode: SECURITY_CODE
};
log(`配置安全密钥:${SECURITY_CODE}`);
// 加载高德地图脚本
const script = document.createElement('script');
script.src = `https://webapi.amap.com/maps?v=2.0&key=${AMAP_KEY}`;
script.onload = () => {
log('高德地图脚本加载成功');
initMap();
};
script.onerror = () => {
log('错误:高德地图脚本加载失败');
alert('地图加载失败,请检查网络连接或API Key配置');
};
document.head.appendChild(script);
</script>
</body>
</html>
+1135
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
{
"name": "quanyoumi-frontend",
"version": "1.0.0",
"description": "泉友米车友会前端",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.3.4",
"vue-router": "^4.2.5",
"vant": "^4.7.1",
"axios": "^1.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.4.0",
"vite": "^4.5.0"
}
}
Binary file not shown.
+19
View File
@@ -0,0 +1,19 @@
<template>
<router-view />
</template>
<script setup>
</script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: #f7f8fa;
}
</style>
+69
View File
@@ -0,0 +1,69 @@
import axios from 'axios';
const api = axios.create({
baseURL: '/api',
timeout: 10000
});
api.interceptors.request.use(config => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
export default {
// 活动相关
getActivities: (params) => api.get('/activities', { params }),
getActivity: (id) => api.get(`/activities/${id}`),
// 报名相关
submitRegistration: (data) => api.post('/registrations', data),
getRegistration: (id) => api.get(`/registrations/${id}`),
getActivityRegistrations: (id) => api.get(`/activities/${id}/registrations`),
// 用户上传照片
uploadUserPhotos: (id, data) => api.post(`/activities/${id}/photos`, data, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 300000 // 5分钟超时
}),
// 用户验证登录
verifyUser: (activityId, data) => api.post(`/auth/verify/${activityId}`, data),
// 管理员相关
adminLogin: (data) => api.post('/admin/login', data),
createActivity: (data) => api.post('/admin/activities', data),
updateActivity: (id, data) => api.put(`/admin/activities/${id}`, data),
approveRegistration: (id, status) => api.put(`/admin/registrations/${id}`, { status }),
updateRegistration: (id, data) => api.put(`/admin/registrations/${id}`, data),
checkInRegistration: (id) => api.put(`/admin/registrations/${id}/checkin`),
getAllRegistrations: (params) => api.get('/admin/registrations', { params }),
addExpense: (data) => api.post('/admin/expenses', data, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 60000
}),
getExpenses: (params) => api.get('/admin/expenses', { params }),
updateExpense: (id, data) => api.put(`/admin/expenses/${id}`, data, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 60000
}),
deleteExpense: (id) => api.delete(`/admin/expenses/${id}`),
uploadPhotos: (data) => api.post('/admin/photos', data, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 300000 // 5分钟超时,适配大文件上传
}),
getPendingPhotos: () => api.get('/admin/photos/pending'),
approvePhoto: (id, status) => api.put(`/admin/photos/${id}`, { status }),
deletePhoto: (id) => api.delete(`/admin/photos/${id}`),
addSponsor: (data) => api.post('/admin/sponsors', data, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 60000 // 1分钟超时
}),
updateSponsor: (id, data) => api.put(`/admin/sponsors/${id}`, data, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 60000
}),
deleteSponsor: (id) => api.delete(`/admin/sponsors/${id}`)
};
+118
View File
@@ -0,0 +1,118 @@
import { createApp } from 'vue';
import App from './App.vue';
import router from './router';
import Vant from 'vant';
import 'vant/lib/index.css';
// 全局样式 - 防止移动端输入框自动缩放
const globalStyle = document.createElement('style');
globalStyle.textContent = `
/* 防止iOS输入框自动缩放 */
input, textarea, select {
font-size: 16px !important;
}
/* 防止页面缩放 */
* {
touch-action: manipulation;
}
/* 优化输入框体验 */
input:focus, textarea:focus, select:focus {
font-size: 16px !important;
}
`;
document.head.appendChild(globalStyle);
// 获取高德地图配置(优先使用运行时配置,其次使用构建时环境变量)
const getAmapKey = () => {
if (window.AMAP_CONFIG && window.AMAP_CONFIG.key && window.AMAP_CONFIG.key !== 'YOUR_AMAP_KEY_PLACEHOLDER') {
return window.AMAP_CONFIG.key;
}
return import.meta.env.VITE_AMAP_KEY;
};
const getAmapSecurityCode = () => {
if (window.AMAP_CONFIG && window.AMAP_CONFIG.securityCode && window.AMAP_CONFIG.securityCode !== 'YOUR_AMAP_SECURITY_CODE_PLACEHOLDER') {
return window.AMAP_CONFIG.securityCode;
}
return import.meta.env.VITE_AMAP_SECURITY_CODE || 'b2e67bb4d5eed8848f0b8aae6ca667fc';
};
// 配置高德地图安全密钥(必须在加载地图前配置)
window._AMapSecurityConfig = {
securityJsCode: getAmapSecurityCode()
};
console.log('高德地图配置:', {
hasKey: !!getAmapKey(),
keySource: window.AMAP_CONFIG && window.AMAP_CONFIG.key !== 'YOUR_AMAP_KEY_PLACEHOLDER' ? 'runtime' : 'build-time',
hasSecurityCode: !!getAmapSecurityCode()
});
const app = createApp(App);
app.use(router);
app.use(Vant);
app.mount('#app');
// 动态加载高德地图(含插件)
const loadAMap = () => {
return new Promise((resolve, reject) => {
const amapKey = getAmapKey();
if (!amapKey || amapKey === 'YOUR_AMAP_KEY_PLACEHOLDER' || amapKey === 'YOUR_AMAP_KEY') {
console.error('高德地图Key未配置!');
console.error('请在 docker-compose.yml 中配置 AMAP_KEY 环境变量');
console.error('当前 Key 值:', amapKey);
reject(new Error('高德地图Key未配置'));
return;
}
if (window.AMap) {
// 如果已加载,检查插件是否可用
if (window.AMap.Geocoder && window.AMap.PlaceSearch) {
console.log('高德地图已加载,插件可用');
resolve(window.AMap);
} else {
// 插件未加载,异步加载
console.log('加载高德地图插件...');
window.AMap.plugin(['AMap.Geocoder', 'AMap.PlaceSearch', 'AMap.AutoComplete'], () => {
console.log('高德地图插件加载完成');
resolve(window.AMap);
});
}
return;
}
console.log('开始加载高德地图脚本...');
const script = document.createElement('script');
script.src = `https://webapi.amap.com/maps?v=2.0&key=${amapKey}&plugin=AMap.Geocoder,AMap.PlaceSearch,AMap.AutoComplete`;
script.async = true;
script.onload = () => {
if (window.AMap) {
console.log('高德地图脚本加载成功');
// 脚本加载后再确保插件加载
window.AMap.plugin(['AMap.Geocoder', 'AMap.PlaceSearch', 'AMap.AutoComplete'], () => {
console.log('高德地图插件初始化完成');
resolve(window.AMap);
});
} else {
console.error('高德地图加载失败:window.AMap未定义');
reject(new Error('高德地图加载失败'));
}
};
script.onerror = (error) => {
console.error('高德地图脚本加载失败:', error);
reject(new Error('高德地图脚本加载失败'));
};
document.head.appendChild(script);
});
};
// 导出加载函数供其他组件使用
window.loadAMap = loadAMap;
// 预加载地图
loadAMap().catch(err => {
console.error('地图预加载失败:', err);
});
+61
View File
@@ -0,0 +1,61 @@
import { createRouter, createWebHistory } from 'vue-router';
const routes = [
{
path: '/',
name: 'Home',
component: () => import('../views/Home.vue')
},
{
path: '/landing',
name: 'LandingPage',
component: () => import('../views/LandingPage.vue')
},
{
path: '/activity/:id',
name: 'ActivityDetail',
component: () => import('../views/ActivityDetail.vue')
},
{
path: '/register/:id',
name: 'Register',
component: () => import('../views/Register.vue')
},
{
path: '/share/:id',
name: 'SharePage',
component: () => import('../views/SharePage.vue')
},
{
path: '/admin/login',
name: 'AdminLogin',
component: () => import('../views/admin/Login.vue')
},
{
path: '/admin',
name: 'Admin',
component: () => import('../views/admin/Dashboard.vue'),
meta: { requiresAuth: true }
},
{
path: '/admin/activity/:id',
name: 'ActivityManage',
component: () => import('../views/admin/ActivityManage.vue'),
meta: { requiresAuth: true }
}
];
const router = createRouter({
history: createWebHistory(),
routes
});
router.beforeEach((to, from, next) => {
if (to.meta.requiresAuth && !localStorage.getItem('token')) {
next('/admin/login');
} else {
next();
}
});
export default router;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+314
View File
@@ -0,0 +1,314 @@
<template>
<div class="home">
<div class="hero">
<van-swipe class="hero-swipe" :autoplay="3000" indicator-color="white">
<van-swipe-item v-for="(image, index) in bannerImages" :key="index">
<div class="swipe-item" :style="{ backgroundImage: `url(${image})` }"></div>
</van-swipe-item>
</van-swipe>
<div class="hero-content">
<h1 class="club-name">泉友米车友会</h1>
<p class="club-slogan">驾驭激情 · 共享旅程</p>
<van-button
type="primary"
round
size="small"
@click="$router.push('/landing')"
class="landing-btn"
>
查看宣传页
</van-button>
</div>
</div>
<van-tabs v-model:active="activeTab" @change="loadActivities" class="custom-tabs">
<van-tab title="精彩活动" name="upcoming"></van-tab>
<van-tab title="往期回顾" name="completed"></van-tab>
</van-tabs>
<div class="activity-list">
<div v-if="loading" class="loading-state">
<van-loading size="24px">加载中...</van-loading>
</div>
<div v-else-if="activities.length === 0" class="empty-state">
<van-empty description="暂无活动" />
</div>
<div v-else class="activity-cards">
<div
v-for="activity in activities"
:key="activity.id"
class="activity-card"
@click="$router.push(`/activity/${activity.id}`)"
>
<div class="card-image" :style="{ backgroundImage: `url(${activity.coverImage || 'https://s1.xiaomiev.com/activity-outer-assets/0328/images/su7_ultra_20250227/pc/5.jpg'})` }">
<div class="card-badge">{{ activity.status === 'upcoming' ? '报名中' : '已结束' }}</div>
</div>
<div class="card-content">
<h3 class="card-title">{{ activity.title }}</h3>
<p class="card-desc">{{ activity.description }}</p>
<div class="card-meta">
<div class="meta-item">
<van-icon name="clock-o" />
<span>{{ formatDate(activity.startTime) }}</span>
</div>
<div class="meta-item">
<van-icon name="location-o" />
<span>{{ activity.location }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
<footer class="footer">
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer" class="beian-link">
闽ICP备2021011414号-1
</a>
</footer>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import api from '../api';
const router = useRouter();
const activeTab = ref('upcoming');
const activities = ref([]);
const loading = ref(false);
const finished = ref(false);
// 轮播图图片 - 小米汽车官方高清图片
const bannerImages = ref([
'https://s1.xiaomiev.com/activity-outer-assets/0328/images/su7_20260319/pc/2-1.png',
'https://s1.xiaomiev.com/activity-outer-assets/0328/images/su7_ultra_20250227/pc/1.jpg',
'https://s1.xiaomiev.com/activity-outer-assets/0328/images/yu7_20250626/pc/1.png',
'https://s1.xiaomiev.com/activity-outer-assets/0328/images/su7_20260319/pc/4-1.png'
]);
const loadActivities = async () => {
loading.value = true;
try {
const { data } = await api.getActivities({ status: activeTab.value });
activities.value = data;
finished.value = true;
} catch (error) {
console.error(error);
}
loading.value = false;
};
const formatDate = (date) => {
return new Date(date).toLocaleDateString('zh-CN');
};
loadActivities();
</script>
<style scoped>
.home {
min-height: 100vh;
background: linear-gradient(180deg, #f8f9fa 0%, #fff 100%);
}
.hero {
position: relative;
height: 240px;
overflow: hidden;
}
.hero-swipe {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
height: 100%;
}
.swipe-item {
width: 100%;
height: 100%;
background-size: cover;
background-position: center;
position: relative;
}
.swipe-item::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(180deg, rgba(0,0,0,0.3) 0%, rgba(0,0,0,0.5) 100%);
}
.swipe-item::after {
content: '';
position: absolute;
bottom: -2px;
left: 0;
right: 0;
height: 60px;
background: #f8f9fa;
border-radius: 50% 50% 0 0 / 100% 100% 0 0;
}
.hero-content {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #fff;
text-align: center;
}
.club-name {
font-size: 32px;
font-weight: bold;
margin: 0;
text-shadow: 0 2px 8px rgba(0,0,0,0.2);
letter-spacing: 2px;
}
.club-slogan {
font-size: 14px;
margin: 12px 0 0;
opacity: 0.9;
letter-spacing: 4px;
}
.landing-btn {
margin-top: 20px;
background: linear-gradient(135deg, #FF6700 0%, #FF8533 100%);
border: none;
font-size: 14px;
font-weight: 600;
padding: 0 24px;
box-shadow: 0 4px 12px rgba(255, 103, 0, 0.4);
}
.custom-tabs {
margin-top: -30px;
position: relative;
z-index: 2;
}
.activity-list {
padding: 16px;
}
.loading-state,
.empty-state {
padding: 60px 0;
text-align: center;
}
.activity-cards {
display: flex;
flex-direction: column;
gap: 16px;
}
.activity-card {
background: #fff;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
transition: transform 0.3s, box-shadow 0.3s;
}
.activity-card:active {
transform: scale(0.98);
}
.card-image {
height: 180px;
background-size: cover;
background-position: center;
background-color: #e8e8e8;
position: relative;
}
.card-badge {
position: absolute;
top: 12px;
right: 12px;
background: linear-gradient(135deg, #FF6700 0%, #FF8533 100%);
color: #fff;
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: bold;
box-shadow: 0 2px 8px rgba(255, 103, 0, 0.3);
}
.card-content {
padding: 16px;
}
.card-title {
font-size: 18px;
font-weight: bold;
margin: 0 0 8px;
color: #333;
}
.card-desc {
font-size: 14px;
color: #666;
margin: 0 0 12px;
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.card-meta {
display: flex;
gap: 16px;
}
.meta-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #999;
}
.meta-item .van-icon {
font-size: 14px;
color: #FF6700;
}
.footer {
padding: 24px 16px;
text-align: center;
background: #fff;
border-top: 1px solid #f0f0f0;
margin-top: 32px;
}
.beian-link {
font-size: 13px;
color: #999;
text-decoration: none;
transition: color 0.3s ease;
}
.beian-link:hover {
color: #FF6700;
}
.beian-link:active {
color: #FF8533;
}
</style>
+957
View File
@@ -0,0 +1,957 @@
<template>
<div class="landing-page">
<!-- 背景音乐控制 -->
<div class="music-control" @click="toggleMusic">
<van-icon :name="isPlaying ? 'volume-o' : 'volume'" :class="{ playing: isPlaying }" />
</div>
<audio ref="audioPlayer" loop>
<source src="/are-you-ok.mp3" type="audio/mpeg">
</audio>
<!-- 顶部导航栏 -->
<div class="top-nav">
<van-icon name="arrow-left" class="nav-back" @click="$router.push('/')" />
<span class="nav-title">小米汽车车友会</span>
<div class="nav-placeholder"></div>
</div>
<!-- Hero轮播区 -->
<div class="hero-section">
<van-swipe class="hero-swipe" :autoplay="4000" indicator-color="#FF6700">
<van-swipe-item v-for="(image, index) in heroImages" :key="index">
<div class="swipe-item" :style="{ backgroundImage: `url(${image})` }">
<div class="swipe-overlay"></div>
</div>
</van-swipe-item>
</van-swipe>
<div class="hero-content">
<div class="hero-badge">小米汽车车友会</div>
<h1 class="hero-title">驾驭未来 · 智享生活</h1>
<p class="hero-subtitle">Are You OK? 一起探索小米汽车的无限可能</p>
<div class="hero-stats">
<div class="stat-item">
<div class="stat-number">1000+</div>
<div class="stat-label">车友</div>
</div>
<div class="stat-divider"></div>
<div class="stat-item">
<div class="stat-number">50+</div>
<div class="stat-label">活动</div>
</div>
<div class="stat-divider"></div>
<div class="stat-item">
<div class="stat-number">20+</div>
<div class="stat-label">城市</div>
</div>
</div>
</div>
</div>
<!-- 特色卡片区 -->
<div class="features-section">
<h2 class="section-title">
<span class="title-icon">✨</span>
车友会特色
</h2>
<div class="feature-cards">
<div
v-for="(feature, index) in features"
:key="index"
class="feature-card"
:style="{ backgroundImage: `url(${feature.image})` }"
>
<div class="card-overlay"></div>
<div class="card-content">
<div class="card-icon">{{ feature.icon }}</div>
<h3 class="card-title">{{ feature.title }}</h3>
<p class="card-desc">{{ feature.desc }}</p>
</div>
</div>
</div>
</div>
<!-- 活动预览区 -->
<div class="activities-section">
<h2 class="section-title">
<span class="title-icon">🎯</span>
精彩活动
</h2>
<div v-if="loading" class="loading-state">
<van-loading size="24px">加载中...</van-loading>
</div>
<div v-else-if="activities.length === 0" class="empty-state">
<van-empty description="敬请期待更多精彩活动" />
</div>
<div v-else class="activity-preview">
<div
v-for="activity in activities.slice(0, 3)"
:key="activity.id"
class="activity-card"
@click="$router.push(`/activity/${activity.id}`)"
>
<div class="activity-image" :style="{ backgroundImage: `url(${activity.coverImage || heroImages[0]})` }">
<div class="activity-badge">报名中</div>
</div>
<div class="activity-info">
<h3 class="activity-title">{{ activity.title }}</h3>
<div class="activity-meta">
<van-icon name="clock-o" />
<span>{{ formatDate(activity.startTime) }}</span>
</div>
<div class="activity-meta">
<van-icon name="location-o" />
<span>{{ activity.location }}</span>
</div>
</div>
</div>
</div>
<van-button
type="primary"
block
round
size="large"
@click="$router.push('/')"
class="view-more-btn"
>
查看全部活动
<van-icon name="arrow" />
</van-button>
</div>
<!-- 赞助商区域 -->
<div class="sponsors-section">
<h2 class="section-title">
<span class="title-icon">🤝</span>
合作伙伴
</h2>
<div class="sponsors-grid">
<div class="sponsor-card">
<div class="sponsor-logo xiaomi-logo">
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<rect x="10" y="10" width="80" height="80" rx="20" fill="#FF6700"/>
<text x="50" y="65" font-family="Arial, sans-serif" font-size="40" font-weight="bold" fill="white" text-anchor="middle">MI</text>
</svg>
</div>
<div class="sponsor-info">
<h3 class="sponsor-name">小米汽车泉州信兴汽车城授权服务中心</h3>
<p class="sponsor-desc">泉州德劲新能源科技有限公司</p>
</div>
</div>
<div class="sponsor-card">
<div class="sponsor-logo europark-logo">
<svg viewBox="0 0 200 80" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="europark-gradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#4A90E2;stop-opacity:1" />
<stop offset="100%" style="stop-color:#7B68EE;stop-opacity:1" />
</linearGradient>
</defs>
<rect width="200" height="80" rx="12" fill="url(#europark-gradient)"/>
<text x="100" y="50" font-family="Microsoft YaHei, Arial, sans-serif" font-size="32" font-weight="bold" fill="white" text-anchor="middle">欧乐堡</text>
</svg>
</div>
<div class="sponsor-info">
<h3 class="sponsor-name">泉州欧乐堡</h3>
<p class="sponsor-desc">童话世界 · 海洋王国</p>
</div>
</div>
</div>
</div>
<!-- CTA区域 -->
<div class="cta-section">
<div class="cta-content">
<h2 class="cta-title">加入我们</h2>
<p class="cta-desc">与千位小米汽车车主一起,探索智能出行新体验</p>
<van-button
type="primary"
round
size="large"
@click="$router.push('/')"
class="cta-btn"
>
立即加入车友会
</van-button>
</div>
<div class="cta-bg"></div>
</div>
<!-- 页脚 -->
<footer class="footer">
<div class="footer-content">
<div class="footer-logo">
<div class="logo-icon">🚗</div>
<div class="logo-text">
<div class="logo-name">小米汽车车友会</div>
<div class="logo-slogan">驾驭未来 · 智享生活</div>
</div>
</div>
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer" class="beian-link">
闽ICP备2021011414号-1
</a>
</div>
</footer>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import { useRouter } from 'vue-router';
import api from '../api';
const router = useRouter();
const audioPlayer = ref(null);
const isPlaying = ref(false);
const activities = ref([]);
const loading = ref(false);
// Hero轮播图 - 小米汽车官方高清图片
const heroImages = ref([
'https://s1.xiaomiev.com/activity-outer-assets/0328/images/su7_20260319/pc/2-1.png',
'https://s1.xiaomiev.com/activity-outer-assets/0328/images/su7_ultra_20250227/pc/1.jpg',
'https://s1.xiaomiev.com/activity-outer-assets/0328/images/yu7_20250626/pc/1.png',
'https://s1.xiaomiev.com/activity-outer-assets/0328/images/su7_20260319/pc/4-1.png',
'https://s1.xiaomiev.com/activity-outer-assets/0328/images/su7_ultra_20250227/pc/4.jpg'
]);
// 特色功能 - 使用小米汽车真实图片
const features = ref([
{
icon: '🚀',
title: '极致性能',
desc: '零百加速2.78秒,800V超级快充,续航800km+',
image: 'https://s1.xiaomiev.com/activity-outer-assets/0328/images/su7_ultra_20250227/pc/5.jpg'
},
{
icon: '🤝',
title: '车友互动',
desc: '定期组织自驾游、车友聚会、技术交流等活动',
image: 'https://s1.xiaomiev.com/activity-outer-assets/0328/images/yu7_20250522/pc/3.jpg'
},
{
icon: '🎁',
title: '专属福利',
desc: '车友专属优惠、保养折扣、配件优惠等福利',
image: 'https://s1.xiaomiev.com/activity-outer-assets/0328/images/su7_20260319/pc/8-1.png'
},
{
icon: '🌟',
title: '智能生态',
desc: '小米全生态互联,打造智能出行新体验',
image: 'https://s1.xiaomiev.com/activity-outer-assets/0328/images/yu7_20250626/pc/15.png'
}
]);
// 音乐控制
const toggleMusic = () => {
if (!audioPlayer.value) return;
if (isPlaying.value) {
audioPlayer.value.pause();
isPlaying.value = false;
} else {
audioPlayer.value.play().catch(err => {
console.log('音乐播放需要用户交互:', err);
});
isPlaying.value = true;
}
};
// 尝试自动播放音乐(需要用户交互)
const tryAutoPlay = () => {
if (!audioPlayer.value || isPlaying.value) return;
audioPlayer.value.play().then(() => {
isPlaying.value = true;
// 移除事件监听
document.removeEventListener('click', tryAutoPlay);
document.removeEventListener('touchstart', tryAutoPlay);
}).catch(() => {
// 自动播放失败,等待用户点击
});
};
// 加载活动
const loadActivities = async () => {
loading.value = true;
try {
const { data } = await api.getActivities({ status: 'upcoming' });
activities.value = data;
} catch (error) {
console.error('加载活动失败:', error);
}
loading.value = false;
};
const formatDate = (date) => {
return new Date(date).toLocaleDateString('zh-CN', {
month: '2-digit',
day: '2-digit'
});
};
onMounted(() => {
loadActivities();
// 监听用户交互,尝试自动播放音乐
document.addEventListener('click', tryAutoPlay, { once: true });
document.addEventListener('touchstart', tryAutoPlay, { once: true });
// 3秒后如果还没播放,尝试一次
setTimeout(() => {
if (!isPlaying.value) {
tryAutoPlay();
}
}, 3000);
});
onUnmounted(() => {
if (audioPlayer.value && isPlaying.value) {
audioPlayer.value.pause();
}
// 清理事件监听
document.removeEventListener('click', tryAutoPlay);
document.removeEventListener('touchstart', tryAutoPlay);
});
</script>
<style scoped>
.landing-page {
min-height: 100vh;
background: #fff;
}
/* 顶部导航栏 */
.top-nav {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 46px;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
z-index: 999;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
}
.nav-back {
font-size: 20px;
color: #333;
cursor: pointer;
padding: 8px;
}
.nav-title {
font-size: 16px;
font-weight: 600;
color: #333;
}
.nav-placeholder {
width: 36px;
}
/* 音乐控制 */
.music-control {
position: fixed;
top: 60px;
right: 20px;
z-index: 1000;
width: 44px;
height: 44px;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(10px);
color: #FF6700;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
transition: all 0.3s;
}
.music-control:hover {
background: rgba(255, 255, 255, 1);
box-shadow: 0 4px 16px rgba(255, 103, 0, 0.2);
}
.music-control:active {
transform: scale(0.95);
}
.music-control .van-icon {
font-size: 22px;
transition: transform 0.3s;
}
.music-control .van-icon.playing {
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.1); }
}
.music-text {
font-size: 14px;
font-weight: 500;
}
/* Hero区域 */
.hero-section {
position: relative;
height: 100vh;
min-height: 600px;
overflow: hidden;
margin-top: 46px;
}
.hero-swipe {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
height: 100%;
}
.swipe-item {
width: 100%;
height: 100%;
background-size: cover;
background-position: center;
position: relative;
}
.swipe-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(180deg, rgba(0,0,0,0.3) 0%, rgba(0,0,0,0.6) 100%);
}
.hero-content {
position: relative;
z-index: 10;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #fff;
text-align: center;
padding: 20px;
}
.hero-badge {
display: inline-block;
background: rgba(255, 103, 0, 0.9);
padding: 8px 24px;
border-radius: 30px;
font-size: 14px;
font-weight: 600;
margin-bottom: 24px;
letter-spacing: 2px;
box-shadow: 0 4px 16px rgba(255, 103, 0, 0.4);
animation: fadeInDown 1s;
}
.hero-title {
font-size: 48px;
font-weight: bold;
margin: 0 0 16px;
text-shadow: 0 4px 16px rgba(0,0,0,0.3);
letter-spacing: 4px;
animation: fadeInUp 1s 0.2s both;
}
.hero-subtitle {
font-size: 18px;
margin: 0 0 48px;
opacity: 0.95;
letter-spacing: 2px;
animation: fadeInUp 1s 0.4s both;
}
.hero-stats {
display: flex;
gap: 32px;
align-items: center;
animation: fadeInUp 1s 0.6s both;
}
.stat-item {
text-align: center;
}
.stat-number {
font-size: 36px;
font-weight: bold;
color: #FF6700;
margin-bottom: 8px;
}
.stat-label {
font-size: 14px;
opacity: 0.9;
}
.stat-divider {
width: 1px;
height: 40px;
background: rgba(255,255,255,0.3);
}
@keyframes fadeInDown {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 特色区域 */
.features-section,
.activities-section {
padding: 80px 16px;
max-width: 1200px;
margin: 0 auto;
}
.section-title {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
font-size: 32px;
font-weight: bold;
color: #333;
margin: 0 0 48px;
text-align: center;
}
.title-icon {
font-size: 36px;
}
.feature-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 24px;
margin-bottom: 32px;
}
.feature-card {
position: relative;
height: 320px;
border-radius: 24px;
overflow: hidden;
background-size: cover;
background-position: center;
cursor: pointer;
transition: transform 0.3s, box-shadow 0.3s;
}
.feature-card:hover {
transform: translateY(-8px);
box-shadow: 0 12px 32px rgba(0,0,0,0.15);
}
.card-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(180deg, rgba(0,0,0,0.2) 0%, rgba(0,0,0,0.7) 100%);
}
.card-content {
position: relative;
z-index: 1;
height: 100%;
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: 24px;
color: #fff;
}
.card-icon {
font-size: 48px;
margin-bottom: 16px;
}
.card-title {
font-size: 24px;
font-weight: bold;
margin: 0 0 12px;
}
.card-desc {
font-size: 14px;
line-height: 1.6;
opacity: 0.95;
margin: 0;
}
/* 活动预览 */
.activity-preview {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 24px;
margin-bottom: 32px;
}
.activity-card {
background: #fff;
border-radius: 20px;
overflow: hidden;
box-shadow: 0 4px 16px rgba(0,0,0,0.08);
cursor: pointer;
transition: transform 0.3s, box-shadow 0.3s;
}
.activity-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0,0,0,0.12);
}
.activity-image {
height: 200px;
background-size: cover;
background-position: center;
position: relative;
}
.activity-badge {
position: absolute;
top: 16px;
right: 16px;
background: #FF6700;
color: #fff;
padding: 6px 16px;
border-radius: 20px;
font-size: 13px;
font-weight: 600;
box-shadow: 0 2px 8px rgba(255, 103, 0, 0.3);
}
.activity-info {
padding: 20px;
}
.activity-title {
font-size: 18px;
font-weight: bold;
color: #333;
margin: 0 0 16px;
}
.activity-meta {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: #666;
margin-bottom: 8px;
}
.activity-meta .van-icon {
color: #FF6700;
}
.view-more-btn {
background: linear-gradient(135deg, #FF6700 0%, #FF8533 100%);
border: none;
font-size: 17px;
font-weight: 600;
height: 52px;
box-shadow: 0 4px 16px rgba(255, 103, 0, 0.3);
}
.view-more-btn .van-icon {
margin-left: 8px;
}
/* 赞助商区域 */
.sponsors-section {
padding: 60px 16px;
max-width: 900px;
margin: 0 auto;
background: #fafafa;
}
.sponsors-grid {
display: flex;
justify-content: center;
align-items: stretch;
gap: 16px;
margin-top: 32px;
flex-wrap: wrap;
}
.sponsor-card {
background: #fff;
border-radius: 12px;
padding: 12px;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
transition: transform 0.3s, box-shadow 0.3s;
flex: 0 1 140px;
max-width: 150px;
min-width: 120px;
}
.sponsor-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
}
.sponsor-logo {
width: 45px;
height: 45px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 8px;
}
.sponsor-logo svg {
width: 100%;
height: 100%;
}
.xiaomi-logo svg {
filter: drop-shadow(0 2px 8px rgba(255, 103, 0, 0.2));
}
.europark-logo svg {
filter: drop-shadow(0 2px 8px rgba(74, 144, 226, 0.2));
}
.sponsor-info {
width: 100%;
}
.sponsor-name {
font-size: 11px;
font-weight: 600;
color: #333;
margin: 0 0 4px;
line-height: 1.3;
word-break: break-all;
}
.sponsor-desc {
font-size: 10px;
color: #999;
margin: 0;
line-height: 1.4;
word-break: break-all;
}
/* CTA区域 */
.cta-section {
position: relative;
padding: 100px 20px;
text-align: center;
overflow: hidden;
background: linear-gradient(135deg, #FF6700 0%, #FF8533 100%);
}
.cta-bg {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
opacity: 0.1;
background-image: url('https://s1.xiaomiev.com/activity-outer-assets/0328/images/su7_ultra_20250227/pc/7-1-1.jpeg');
background-size: cover;
background-position: center;
}
.cta-content {
position: relative;
z-index: 1;
max-width: 600px;
margin: 0 auto;
}
.cta-title {
font-size: 40px;
font-weight: bold;
color: #fff;
margin: 0 0 16px;
}
.cta-desc {
font-size: 18px;
color: rgba(255,255,255,0.95);
margin: 0 0 32px;
line-height: 1.6;
}
.cta-btn {
background: #fff;
color: #FF6700;
font-size: 17px;
font-weight: 600;
height: 52px;
padding: 0 48px;
box-shadow: 0 4px 16px rgba(0,0,0,0.2);
}
/* 页脚 */
.footer {
background: #1a1a1a;
padding: 48px 20px;
}
.footer-content {
max-width: 1200px;
margin: 0 auto;
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
}
.footer-logo {
display: flex;
align-items: center;
gap: 16px;
}
.logo-icon {
font-size: 48px;
}
.logo-text {
text-align: left;
}
.logo-name {
font-size: 20px;
font-weight: bold;
color: #fff;
margin-bottom: 4px;
}
.logo-slogan {
font-size: 14px;
color: #999;
letter-spacing: 2px;
}
.beian-link {
font-size: 13px;
color: #666;
text-decoration: none;
transition: color 0.3s;
}
.beian-link:hover {
color: #FF6700;
}
.loading-state,
.empty-state {
padding: 60px 0;
text-align: center;
}
/* 响应式 */
@media (max-width: 768px) {
.hero-title {
font-size: 32px;
}
.hero-subtitle {
font-size: 16px;
}
.hero-stats {
gap: 20px;
}
.stat-number {
font-size: 28px;
}
.section-title {
font-size: 28px;
}
.feature-cards {
grid-template-columns: 1fr;
}
.activity-preview {
grid-template-columns: 1fr;
}
.cta-title {
font-size: 32px;
}
.sponsors-grid {
gap: 12px;
}
.sponsor-card {
flex: 0 1 calc(20% - 10px);
min-width: 100px;
max-width: 130px;
padding: 10px;
}
.sponsor-logo {
width: 40px;
height: 40px;
margin-bottom: 6px;
}
.sponsor-name {
font-size: 10px;
}
.sponsor-desc {
font-size: 9px;
}
}
</style>
+370
View File
@@ -0,0 +1,370 @@
<template>
<div class="register">
<div class="register-header">
<div class="header-back" @click="$router.back()">
<van-icon name="arrow-left" />
</div>
<h1 class="header-title">活动报名</h1>
</div>
<div class="register-content">
<div class="form-card">
<van-form @submit="onSubmit">
<div class="form-section">
<div class="section-label">基本信息</div>
<van-field
v-model="form.name"
label="姓名"
placeholder="请输入您的姓名"
required
:rules="[{ required: true, message: '请输入姓名' }]"
/>
<van-field
v-model="form.phone"
label="手机号"
type="tel"
placeholder="请输入手机号"
maxlength="11"
required
:rules="[{ required: true, message: '请输入手机号' }, { pattern: /^1\d{10}$/, message: '请输入正确的手机号' }]"
/>
<van-field
v-model.number="form.peopleCount"
label="人数"
type="number"
placeholder="请输入参加人数"
required
:rules="[{ required: true, message: '请输入人数' }]"
/>
</div>
<div class="form-section">
<div class="section-label">车辆信息</div>
<van-field
v-model="form.carModel"
label="车型"
placeholder="请选择车型"
readonly
is-link
@click="showCarModelPicker = true"
/>
<van-field
label="车牌号"
:rules="[{ required: true, message: '请输入车牌号' }]"
class="car-number-field"
>
<template #input>
<div class="license-plate-input">
<span class="plate-prefix">闽</span>
<input
ref="letterInput"
v-model="carLetter"
@input="handleLetterInput"
placeholder="_"
maxlength="1"
class="plate-letter"
/>
<span class="plate-dot">·</span>
<input
ref="digitsInput"
v-model="carDigits"
@input="handleDigitsInput"
placeholder="______"
maxlength="6"
class="plate-digits"
/>
</div>
</template>
</van-field>
</div>
<div class="form-section">
<div class="section-label">其他信息</div>
<van-field
v-model="form.note"
label="备注"
type="textarea"
placeholder="有什么想说的吗?"
rows="3"
maxlength="200"
show-word-limit
/>
</div>
<div class="submit-btn">
<van-button round block type="primary" native-type="submit" size="large" color="#FF6700">
提交报名
</van-button>
</div>
</van-form>
</div>
</div>
<!-- 车型选择器 -->
<van-popup v-model:show="showCarModelPicker" position="bottom" round>
<van-picker
:columns="carModels"
@confirm="onCarModelConfirm"
@cancel="showCarModelPicker = false"
/>
</van-popup>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { showToast, showDialog } from 'vant';
import api from '../api';
const route = useRoute();
const router = useRouter();
const showCarModelPicker = ref(false);
const carModels = ref([
{ text: '小米SU7', value: '小米SU7' },
{ text: '小米YU7', value: '小米YU7' },
{ text: '小米SU7 Ultra', value: '小米SU7 Ultra' }
]);
const form = ref({
name: '',
phone: '',
peopleCount: 1,
carModel: '',
carNumber: '',
note: ''
});
const carLetter = ref('');
const carDigits = ref('');
const letterInput = ref(null);
const digitsInput = ref(null);
const handleLetterInput = (e) => {
const value = e.target.value.toUpperCase().replace(/[^A-Z]/g, '');
carLetter.value = value;
form.value.carNumber = value + carDigits.value;
// 输入完1位字母后自动跳到数字输入框
if (value.length === 1) {
setTimeout(() => {
digitsInput.value?.focus();
}, 0);
}
};
const handleDigitsInput = (e) => {
const value = e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, '');
carDigits.value = value;
form.value.carNumber = carLetter.value + value;
};
// 车型选择确认
const onCarModelConfirm = ({ selectedOptions }) => {
form.value.carModel = selectedOptions[0].value;
showCarModelPicker.value = false;
};
// 车牌号格式化已被handleLetterInput和handleDigitsInput替代
// 从缓存加载数据
onMounted(() => {
const cached = localStorage.getItem('userRegistrationInfo');
if (cached) {
try {
const data = JSON.parse(cached);
form.value = { ...form.value, ...data };
} catch (e) {
console.error('加载缓存失败', e);
}
}
});
const onSubmit = async () => {
try {
// 添加"闽"前缀到车牌号
const fullCarNumber = form.value.carNumber ? `闽${form.value.carNumber}` : '';
// 保存到缓存
localStorage.setItem('userRegistrationInfo', JSON.stringify({
name: form.value.name,
phone: form.value.phone,
carModel: form.value.carModel,
carNumber: fullCarNumber
}));
const { data } = await api.submitRegistration({
activityId: route.params.id,
...form.value,
carNumber: fullCarNumber
});
// 保存报名ID和登录信息
const registrations = JSON.parse(localStorage.getItem('myRegistrations') || '[]');
registrations.push({
activityId: route.params.id,
registrationId: data.id,
date: new Date().toISOString()
});
localStorage.setItem('myRegistrations', JSON.stringify(registrations));
// 自动保存登录状态
localStorage.setItem(`user_reg_${route.params.id}`, JSON.stringify({
id: data.id,
name: form.value.name,
phone: form.value.phone,
carNumber: fullCarNumber,
carModel: form.value.carModel,
status: 'pending',
activityId: route.params.id
}));
await showDialog({
title: '报名成功',
message: `您的报名ID: ${data.id}\n已自动登录,请等待管理员审核`
});
router.back();
} catch (error) {
showToast('报名失败');
}
};
</script>
<style scoped>
.register {
min-height: 100vh;
background: linear-gradient(180deg, #FF6700 0%, #FF8533 100%);
}
.register-header {
position: relative;
padding: 16px;
color: #fff;
}
.header-back {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255,255,255,0.2);
border-radius: 50%;
font-size: 20px;
backdrop-filter: blur(10px);
margin-bottom: 16px;
}
.header-title {
font-size: 28px;
font-weight: bold;
margin: 0;
}
.register-content {
padding: 0 16px 16px;
}
.form-card {
background: #fff;
border-radius: 20px;
padding: 24px;
box-shadow: 0 8px 24px rgba(0,0,0,0.15);
}
.form-section {
margin-bottom: 24px;
}
.form-section:last-of-type {
margin-bottom: 32px;
}
.section-label {
font-size: 14px;
font-weight: bold;
color: #333;
margin-bottom: 12px;
padding-left: 8px;
border-left: 3px solid #FF6700;
}
/* 车牌号字段特殊样式 - 垂直居中 */
.car-number-field :deep(.van-cell) {
align-items: center !important;
}
.car-number-field :deep(.van-field__label) {
padding-top: 5px !important;
}
.submit-btn {
margin-top: 24px;
}
/* 车牌输入样式 - 橙色主题 */
.license-plate-input {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
background: linear-gradient(135deg, #FF6700 0%, #FF8533 100%);
border-radius: 6px;
box-shadow: 0 2px 8px rgba(255, 103, 0, 0.3);
width: fit-content;
margin-left: auto;
}
.plate-prefix {
color: #fff;
font-weight: 700;
font-size: 16px;
text-shadow: 0 1px 2px rgba(0,0,0,0.2);
}
.plate-letter {
width: 22px;
height: 28px;
border: 2px solid rgba(255,255,255,0.9);
border-radius: 3px;
background: #fff;
text-align: center;
font-size: 16px;
font-weight: 700;
color: #333;
outline: none;
text-transform: uppercase;
padding: 0;
}
.plate-dot {
color: #fff;
font-size: 18px;
font-weight: 700;
margin: 0 1px;
}
.plate-digits {
width: 110px;
height: 28px;
border: 2px solid rgba(255,255,255,0.9);
border-radius: 3px;
background: #fff;
text-align: center;
font-size: 16px;
font-weight: 700;
color: #333;
outline: none;
letter-spacing: 1px;
text-transform: uppercase;
padding: 0;
}
.plate-letter::placeholder,
.plate-digits::placeholder {
color: #ddd;
font-weight: 400;
}
</style>
+450
View File
@@ -0,0 +1,450 @@
<template>
<div class="share-page">
<div v-if="loading" class="loading-container">
<van-loading size="32px">加载中...</van-loading>
</div>
<div v-else-if="activity.id" class="share-content">
<div class="share-header" :style="{ backgroundImage: `url(${activity.coverImage || 'https://s1.xiaomiev.com/activity-outer-assets/0328/images/su7_ultra_20250227/pc/5.jpg'})` }">
<div class="header-overlay"></div>
<div class="header-back" @click="$router.push('/')">
<van-icon name="arrow-left" />
</div>
<div class="header-info">
<div class="club-badge">泉友米车友会</div>
<h1 class="activity-title">{{ activity.title }}</h1>
<div class="activity-status">
<span class="status-badge" :class="activity.status">
{{ activity.status === 'upcoming' ? '🔥 报名中' : '✅ 已结束' }}
</span>
</div>
</div>
</div>
<div class="share-body">
<div class="info-cards">
<div class="info-card">
<van-icon name="clock-o" class="card-icon" />
<div class="card-content">
<div class="card-label">活动时间</div>
<div class="card-value">{{ formatDate(activity.startTime) }}</div>
</div>
</div>
<div class="info-card">
<van-icon name="location-o" class="card-icon" />
<div class="card-content">
<div class="card-label">活动地点</div>
<div class="card-value">{{ activity.location }}</div>
</div>
</div>
<div class="info-card">
<van-icon name="calendar-o" class="card-icon" />
<div class="card-content">
<div class="card-label">报名截止</div>
<div class="card-value">{{ formatDate(activity.registrationDeadline) }}</div>
</div>
</div>
</div>
<div class="description-section">
<h3 class="section-title">
<van-icon name="notes-o" />
活动介绍
</h3>
<p class="description-text">{{ activity.description }}</p>
</div>
<div v-if="registrations.length" class="members-section">
<h3 class="section-title">
<van-icon name="friends-o" />
已报名 <span class="member-count">{{ registrations.length }}人</span>
</h3>
<div class="member-avatars">
<div v-for="(reg, index) in registrations.slice(0, 10)" :key="reg.id" class="member-avatar" :style="{ zIndex: 10 - index }">
{{ reg.name.charAt(0) }}
</div>
<div v-if="registrations.length > 10" class="member-more">
+{{ registrations.length - 10 }}
</div>
</div>
</div>
<div class="action-section">
<van-button
type="primary"
block
round
size="large"
@click="goToDetail"
class="action-btn"
>
<van-icon name="arrow" />
查看详情并报名
</van-button>
<div class="share-tip">
<van-icon name="info-o" />
点击按钮查看完整活动信息
</div>
</div>
</div>
<div class="share-footer">
<div class="footer-logo">
<div class="logo-icon">🚗</div>
<div class="logo-text">
<div class="logo-name">泉友米车友会</div>
<div class="logo-slogan">驾驭激情 · 共享旅程</div>
</div>
</div>
</div>
</div>
<div v-else class="error-state">
<van-empty description="活动不存在或已删除" />
<van-button type="primary" round @click="$router.push('/')">返回首页</van-button>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import api from '../api';
const route = useRoute();
const router = useRouter();
const activity = ref({});
const registrations = ref([]);
const loading = ref(true);
const loadData = async () => {
try {
const activityId = route.params.id || route.query.id;
if (!activityId) {
loading.value = false;
return;
}
const { data } = await api.getActivity(activityId);
activity.value = data.activity;
const { data: regs } = await api.getActivityRegistrations(activityId);
registrations.value = regs;
} catch (error) {
console.error('加载失败:', error);
} finally {
loading.value = false;
}
};
const formatDate = (date) => {
return new Date(date).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
};
const goToDetail = () => {
router.push(`/activity/${activity.value.id}`);
};
onMounted(loadData);
</script>
<style scoped>
.share-page {
min-height: 100vh;
background: linear-gradient(180deg, #f8f9fa 0%, #fff 100%);
}
.loading-container,
.error-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
gap: 20px;
padding: 20px;
}
.share-header {
position: relative;
height: 320px;
background-size: cover;
background-position: center;
display: flex;
align-items: flex-end;
padding: 24px;
}
.header-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(180deg, rgba(0,0,0,0.2) 0%, rgba(0,0,0,0.7) 100%);
}
.header-back {
position: absolute;
top: 16px;
left: 16px;
z-index: 2;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255,255,255,0.3);
border-radius: 50%;
color: #fff;
font-size: 20px;
backdrop-filter: blur(10px);
cursor: pointer;
}
.header-info {
position: relative;
z-index: 1;
width: 100%;
}
.club-badge {
display: inline-block;
background: rgba(255,255,255,0.25);
backdrop-filter: blur(10px);
color: #fff;
padding: 6px 16px;
border-radius: 20px;
font-size: 13px;
font-weight: 500;
margin-bottom: 12px;
border: 1px solid rgba(255,255,255,0.3);
}
.activity-title {
font-size: 28px;
font-weight: bold;
color: #fff;
margin: 0 0 12px;
text-shadow: 0 2px 12px rgba(0,0,0,0.4);
line-height: 1.3;
}
.activity-status {
display: flex;
gap: 8px;
}
.status-badge {
display: inline-block;
padding: 6px 16px;
border-radius: 20px;
font-size: 14px;
font-weight: 600;
backdrop-filter: blur(10px);
}
.status-badge.upcoming {
background: rgba(255,107,107,0.9);
color: #fff;
box-shadow: 0 4px 12px rgba(255,107,107,0.4);
}
.status-badge.completed {
background: rgba(76,175,80,0.9);
color: #fff;
}
.share-body {
padding: 20px 16px;
}
.info-cards {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 24px;
}
.info-card {
display: flex;
align-items: center;
gap: 16px;
background: #fff;
padding: 16px;
border-radius: 16px;
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
}
.card-icon {
font-size: 28px;
color: #667eea;
}
.card-content {
flex: 1;
}
.card-label {
font-size: 13px;
color: #999;
margin-bottom: 4px;
}
.card-value {
font-size: 15px;
color: #333;
font-weight: 500;
}
.description-section,
.members-section {
background: #fff;
padding: 20px;
border-radius: 16px;
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
margin-bottom: 16px;
}
.section-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 17px;
font-weight: bold;
color: #333;
margin: 0 0 16px;
}
.section-title .van-icon {
font-size: 20px;
color: #667eea;
}
.member-count {
color: #667eea;
font-size: 15px;
}
.description-text {
font-size: 15px;
line-height: 1.8;
color: #666;
margin: 0;
white-space: pre-line;
}
.member-avatars {
display: flex;
align-items: center;
}
.member-avatar {
width: 44px;
height: 44px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: bold;
border: 3px solid #fff;
margin-left: -12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.member-avatar:first-child {
margin-left: 0;
}
.member-more {
width: 44px;
height: 44px;
border-radius: 50%;
background: #f0f0f0;
color: #666;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 600;
border: 3px solid #fff;
margin-left: -12px;
}
.action-section {
margin-top: 24px;
}
.action-btn {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
font-size: 17px;
font-weight: 600;
height: 52px;
box-shadow: 0 4px 16px rgba(102,126,234,0.4);
}
.action-btn .van-icon {
margin-left: 8px;
}
.share-tip {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
margin-top: 12px;
font-size: 13px;
color: #999;
}
.share-tip .van-icon {
font-size: 16px;
}
.share-footer {
padding: 32px 16px;
border-top: 1px solid #f0f0f0;
background: #fff;
}
.footer-logo {
display: flex;
align-items: center;
gap: 16px;
justify-content: center;
}
.logo-icon {
font-size: 48px;
}
.logo-text {
text-align: left;
}
.logo-name {
font-size: 18px;
font-weight: bold;
color: #333;
margin-bottom: 4px;
}
.logo-slogan {
font-size: 13px;
color: #999;
letter-spacing: 2px;
}
</style>
File diff suppressed because it is too large Load Diff
+920
View File
@@ -0,0 +1,920 @@
<template>
<div class="dashboard">
<div class="dashboard-header">
<div class="header-content">
<h1>管理后台</h1>
<p>泉友米车友会</p>
</div>
<van-button class="logout-btn" size="small" round @click="logout">
<van-icon name="logout" /> 退出
</van-button>
</div>
<div class="dashboard-content">
<div class="content-header">
<h2>活动管理</h2>
<van-button type="primary" round icon="plus" @click="createNewActivity">
创建活动
</van-button>
</div>
<div class="activity-cards">
<div v-for="activity in activities" :key="activity.id" class="activity-card" @click="viewActivityDetail(activity)">
<div class="card-header">
<h3>{{ activity.title }}</h3>
<van-tag :type="activity.status === 'upcoming' ? 'primary' : 'default'">
{{ activity.status === 'upcoming' ? '进行中' : '已结束' }}
</van-tag>
</div>
<p class="card-desc">{{ activity.description }}</p>
<div class="card-info">
<div class="info-row">
<van-icon name="location-o" />
<span>{{ activity.location }}</span>
</div>
<div class="info-row">
<van-icon name="clock-o" />
<span>{{ formatDate(activity.startTime) }}</span>
</div>
</div>
<div class="card-footer">
<van-icon name="arrow" />
</div>
</div>
<van-empty v-if="!activities.length" description="暂无活动" />
</div>
</div>
<!-- 活动表单弹窗 -->
<van-popup v-model:show="showActivityForm" position="bottom" round>
<div class="form-popup">
<div class="popup-header">
<h3>创建活动</h3>
<van-icon name="cross" @click="closeActivityForm" />
</div>
<van-form @submit="submitActivity" class="popup-form">
<van-cell-group>
<van-field
v-model="activityForm.title"
label="活动标题"
placeholder="请输入活动标题"
required
:rules="[{ required: true, message: '请输入活动标题' }]"
/>
<van-field
v-model="activityForm.description"
label="活动描述"
type="textarea"
placeholder="请输入活动描述,包括活动内容、注意事项等"
rows="3"
autosize
maxlength="500"
show-word-limit
/>
<van-field
v-model="activityForm.price"
label="人均费用"
type="number"
placeholder="请输入人均费用(选填)"
>
<template #left-icon>
<van-icon name="gold-coin-o" style="margin-right: 8px;" />
</template>
<template #button>
<span style="color: #999;">元</span>
</template>
</van-field>
<van-field
v-model="activityForm.budget"
label="活动预算"
type="number"
placeholder="请输入预算金额(选填)"
>
<template #left-icon>
<van-icon name="balance-o" style="margin-right: 8px;" />
</template>
</van-field>
<van-field
v-model="activityForm.location"
label="活动地点"
placeholder="点击选择地点"
readonly
is-link
required
:rules="[{ required: true, message: '请选择活动地点' }]"
@click="openMapPicker"
>
<template #left-icon>
<van-icon name="location-o" style="margin-right: 8px;" />
</template>
</van-field>
<van-field
v-model="startTimeDisplay"
label="开始时间"
placeholder="请选择开始时间"
readonly
is-link
required
:rules="[{ required: true, message: '请选择开始时间' }]"
@click="showStartTimePicker = true"
>
<template #left-icon>
<van-icon name="clock-o" style="margin-right: 8px;" />
</template>
</van-field>
<van-field
v-model="deadlineDisplay"
label="报名截止"
placeholder="请选择报名截止时间"
readonly
is-link
required
:rules="[{ required: true, message: '请选择报名截止时间' }]"
@click="showDeadlinePicker = true"
>
<template #left-icon>
<van-icon name="calendar-o" style="margin-right: 8px;" />
</template>
</van-field>
</van-cell-group>
<div class="form-actions">
<van-button round block type="primary" native-type="submit" size="large">
创建活动
</van-button>
</div>
</van-form>
</div>
</van-popup>
<!-- 开始时间选择器 -->
<van-popup v-model:show="showStartTimePicker" position="bottom" round>
<div class="datetime-picker-popup">
<div class="picker-header">
<van-button size="small" @click="showStartTimePicker = false">取消</van-button>
<h3>选择开始时间</h3>
<van-button size="small" type="primary" @click="confirmStartTime">确定</van-button>
</div>
<div class="picker-body">
<input
type="datetime-local"
v-model="startTimeInput"
class="datetime-input"
/>
</div>
</div>
</van-popup>
<!-- 报名截止时间选择器 -->
<van-popup v-model:show="showDeadlinePicker" position="bottom" round>
<div class="datetime-picker-popup">
<div class="picker-header">
<van-button size="small" @click="showDeadlinePicker = false">取消</van-button>
<h3>选择报名截止时间</h3>
<van-button size="small" type="primary" @click="confirmDeadline">确定</van-button>
</div>
<div class="picker-body">
<input
type="datetime-local"
v-model="deadlineInput"
class="datetime-input"
/>
</div>
</div>
</van-popup>
<!-- 地图选点弹窗 -->
<van-popup v-model:show="showMapPicker" position="bottom" round :style="{ height: '80%' }">
<div class="map-picker">
<div class="map-picker-header">
<van-icon name="arrow-left" @click="showMapPicker = false" />
<h3>选择地点</h3>
<van-button type="primary" size="small" @click="confirmLocation">确定</van-button>
</div>
<div class="map-search">
<van-search
v-model="searchKeyword"
placeholder="搜索地点"
@search="searchLocation"
@clear="clearSearch"
show-action
action-text="搜索"
/>
</div>
<div v-if="searchResults.length" class="search-results">
<div
v-for="(result, index) in searchResults"
:key="index"
class="search-result-item"
@click="selectSearchResult(result)"
>
<div class="result-name">{{ result.name }}</div>
<div class="result-address">{{ result.address }}</div>
</div>
</div>
<div id="map-picker-container" class="map-container"></div>
<div class="selected-location">
<van-icon name="location-o" />
<span>{{ selectedLocation.name || '请在地图上选择位置' }}</span>
</div>
</div>
</van-popup>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue';
import { useRouter } from 'vue-router';
import { showToast } from 'vant';
import api from '../../api';
const router = useRouter();
const activities = ref([]);
const showActivityForm = ref(false);
const showMapPicker = ref(false);
const showStartTimePicker = ref(false);
const showDeadlinePicker = ref(false);
const searchKeyword = ref('');
const searchResults = ref([]);
const selectedLocation = ref({});
let pickerMap = null;
let pickerMarker = null;
let geocoder = null;
const activityForm = ref({
title: '',
description: '',
budget: '',
location: '',
locationLat: null,
locationLng: null,
startTime: '',
registrationDeadline: ''
});
// 日期时间选择器的值
const startTimeInput = ref('');
const deadlineInput = ref('');
// 显示格式化的日期时间
const startTimeDisplay = computed(() => {
if (!activityForm.value.startTime) return '';
return formatDateTime(activityForm.value.startTime);
});
const deadlineDisplay = computed(() => {
if (!activityForm.value.registrationDeadline) return '';
return formatDateTime(activityForm.value.registrationDeadline);
});
const formatDate = (date) => {
return new Date(date).toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
};
// 格式化日期时间显示
const formatDateTime = (dateTime) => {
if (!dateTime) return '';
const date = new Date(dateTime);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}`;
};
// 开始时间确认
const confirmStartTime = () => {
if (!startTimeInput.value) {
showToast('请选择开始时间');
return;
}
const selectedDate = new Date(startTimeInput.value);
activityForm.value.startTime = selectedDate.toISOString();
showStartTimePicker.value = false;
};
// 报名截止时间确认
const confirmDeadline = () => {
if (!deadlineInput.value) {
showToast('请选择报名截止时间');
return;
}
const selectedDate = new Date(deadlineInput.value);
if (activityForm.value.startTime && selectedDate > new Date(activityForm.value.startTime)) {
showToast('报名截止时间不能晚于活动开始时间');
return;
}
activityForm.value.registrationDeadline = selectedDate.toISOString();
showDeadlinePicker.value = false;
};
// 打开地图选择器
const openMapPicker = () => {
showMapPicker.value = true;
};
// 关闭活动表单
const closeActivityForm = () => {
showActivityForm.value = false;
activityForm.value = {
title: '',
description: '',
budget: '',
location: '',
locationLat: null,
locationLng: null,
startTime: '',
registrationDeadline: ''
};
startTimeInput.value = '';
deadlineInput.value = '';
};
const loadActivities = async () => {
try {
const { data } = await api.getActivities();
activities.value = data;
} catch (error) {
showToast('加载失败');
}
};
const createNewActivity = () => {
activityForm.value = {
title: '',
description: '',
budget: '',
location: '',
locationLat: null,
locationLng: null,
startTime: '',
registrationDeadline: ''
};
startTimeInput.value = '';
deadlineInput.value = '';
showActivityForm.value = true;
};
const viewActivityDetail = (activity) => {
router.push(`/admin/activity/${activity.id}`);
};
const initMapPicker = async () => {
try {
console.log('开始初始化地图选择器...');
const AMap = await window.loadAMap();
console.log('地图API加载完成');
if (pickerMap) {
console.log('销毁旧地图实例');
pickerMap.destroy();
}
const center = activityForm.value.locationLat && activityForm.value.locationLng
? [activityForm.value.locationLng, activityForm.value.locationLat]
: [118.6762, 24.8742];
console.log('地图中心点:', center);
pickerMap = new AMap.Map('map-picker-container', {
zoom: 15,
center: center
});
console.log('地图实例创建完成');
geocoder = new AMap.Geocoder();
console.log('地理编码器创建完成');
pickerMarker = new AMap.Marker({
position: center,
map: pickerMap,
draggable: true
});
console.log('地图标记创建完成');
if (activityForm.value.location) {
selectedLocation.value = {
name: activityForm.value.location,
lat: activityForm.value.locationLat,
lng: activityForm.value.locationLng
};
}
pickerMap.on('click', (e) => {
const { lng, lat } = e.lnglat;
pickerMarker.setPosition([lng, lat]);
selectedLocation.value = {
name: '获取地址中...',
lat: lat,
lng: lng
};
if (geocoder) {
geocoder.getAddress([lng, lat], (status, result) => {
if (status === 'complete' && result.info === 'OK') {
selectedLocation.value.name = result.regeocode.formattedAddress;
} else {
console.warn('逆地理编码失败:', status, result);
selectedLocation.value.name = `泉州市区 (${lat.toFixed(4)}, ${lng.toFixed(4)})`;
}
});
} else {
selectedLocation.value.name = `泉州市区 (${lat.toFixed(4)}, ${lng.toFixed(4)})`;
}
});
pickerMarker.on('dragend', (e) => {
const { lng, lat } = e.lnglat;
selectedLocation.value = {
name: '获取地址中...',
lat: lat,
lng: lng
};
if (geocoder) {
geocoder.getAddress([lng, lat], (status, result) => {
if (status === 'complete' && result.info === 'OK') {
selectedLocation.value.name = result.regeocode.formattedAddress;
} else {
console.warn('逆地理编码失败:', status, result);
selectedLocation.value.name = `泉州市区 (${lat.toFixed(4)}, ${lng.toFixed(4)})`;
}
});
} else {
selectedLocation.value.name = `泉州市区 (${lat.toFixed(4)}, ${lng.toFixed(4)})`;
}
});
console.log('地图初始化完成');
} catch (error) {
console.error('地图初始化失败:', error);
showToast('地图加载失败: ' + error.message);
}
};
const searchLocation = async () => {
if (!searchKeyword.value.trim()) {
showToast('请输入搜索关键词');
return;
}
try {
console.log('开始搜索地点:', searchKeyword.value);
const AMap = await window.loadAMap();
if (!AMap.PlaceSearch) {
console.error('PlaceSearch插件未加载');
showToast('地图搜索功能未就绪');
return;
}
const placeSearch = new AMap.PlaceSearch({
pageSize: 10,
pageIndex: 1,
city: '泉州',
citylimit: false,
extensions: 'base'
});
placeSearch.search(searchKeyword.value, (status, result) => {
console.log('搜索状态:', status);
console.log('搜索结果:', result);
if (status === 'complete' && result.info === 'OK') {
if (result.poiList && result.poiList.pois && result.poiList.pois.length > 0) {
searchResults.value = result.poiList.pois.map(poi => ({
name: poi.name,
address: poi.address || poi.district || '',
lat: poi.location.lat,
lng: poi.location.lng
}));
console.log('搜索成功,找到', searchResults.value.length, '个结果');
} else {
searchResults.value = [];
showToast('未找到相关地点');
}
} else if (status === 'no_data') {
searchResults.value = [];
showToast('未找到相关地点');
} else {
console.error('搜索失败:', status, result);
searchResults.value = [];
showToast('搜索失败: ' + (result.info || status));
}
});
} catch (error) {
console.error('搜索异常:', error);
showToast('搜索失败: ' + error.message);
}
};
const selectSearchResult = (result) => {
selectedLocation.value = result;
pickerMarker.setPosition([result.lng, result.lat]);
pickerMap.setCenter([result.lng, result.lat]);
searchResults.value = [];
searchKeyword.value = '';
};
const clearSearch = () => {
searchResults.value = [];
};
const confirmLocation = () => {
if (selectedLocation.value.lat && selectedLocation.value.lng) {
activityForm.value.location = selectedLocation.value.name;
activityForm.value.locationLat = selectedLocation.value.lat;
activityForm.value.locationLng = selectedLocation.value.lng;
showMapPicker.value = false;
} else {
showToast('请在地图上选择位置');
}
};
watch(showMapPicker, (val) => {
if (val) {
setTimeout(() => {
initMapPicker();
}, 300);
}
});
const submitActivity = async () => {
try {
const formData = { ...activityForm.value };
// 清理空字符串,转换为null
if (formData.budget === '' || formData.budget === undefined) {
formData.budget = null;
}
if (formData.price === '' || formData.price === undefined) {
formData.price = null;
}
await api.createActivity(formData);
showToast('创建成功');
closeActivityForm();
loadActivities();
} catch (error) {
showToast('创建失败');
}
};
const logout = () => {
localStorage.removeItem('token');
router.push('/admin/login');
};
onMounted(() => {
loadActivities();
});
</script>
<style scoped>
.dashboard {
min-height: 100vh;
background: #f8f9fa;
}
.dashboard-header {
background: #fff;
padding: 24px 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
display: flex;
justify-content: space-between;
align-items: center;
}
.header-content h1 {
font-size: 24px;
margin: 0 0 4px;
font-weight: 700;
color: #1a1a1a;
}
.header-content p {
font-size: 14px;
margin: 0;
color: #666;
}
.logout-btn {
background: #f5f5f5;
border: none;
color: #666;
font-weight: 500;
}
.logout-btn:active {
background: #e8e8e8;
}
.dashboard-content {
padding: 20px 16px;
}
.content-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.content-header h2 {
font-size: 20px;
margin: 0;
color: #1a1a1a;
font-weight: 700;
}
.activity-cards {
display: flex;
flex-direction: column;
gap: 12px;
}
.activity-card {
background: #fff;
border-radius: 16px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
border: 1px solid #f0f0f0;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
}
.activity-card:active {
transform: scale(0.98);
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
gap: 12px;
}
.card-header h3 {
font-size: 17px;
margin: 0;
color: #1a1a1a;
font-weight: 700;
line-height: 1.4;
flex: 1;
}
.card-desc {
font-size: 14px;
color: #666;
margin: 0 0 12px;
line-height: 1.6;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.card-info {
display: flex;
flex-direction: column;
gap: 8px;
padding-top: 12px;
border-top: 1px solid #f5f5f5;
}
.info-row {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #666;
}
.info-row .van-icon {
font-size: 16px;
color: #FF6700;
}
.card-footer {
position: absolute;
right: 20px;
bottom: 20px;
color: #ddd;
font-size: 16px;
}
/* 弹窗 */
.form-popup {
max-height: 85vh;
display: flex;
flex-direction: column;
background: #fff;
border-radius: 16px 16px 0 0;
}
.popup-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
border-bottom: 1px solid #f0f0f0;
flex-shrink: 0;
}
.popup-header h3 {
font-size: 18px;
margin: 0;
color: #1a1a1a;
font-weight: 700;
}
.popup-header .van-icon {
font-size: 20px;
color: #999;
cursor: pointer;
}
.popup-form {
padding: 0;
overflow-y: auto;
flex: 1;
}
.popup-form .van-cell-group {
margin: 0;
background: #fff;
}
.popup-form .van-cell {
padding: 14px 20px;
}
.popup-form .van-field__label {
width: 90px;
color: #333;
font-weight: 600;
font-size: 14px;
}
.popup-form .van-field__control {
color: #1a1a1a;
font-size: 14px;
}
.popup-form .van-field__control::placeholder {
color: #bbb;
}
.form-actions {
margin: 0;
padding: 16px 20px 20px;
flex-shrink: 0;
background: #fff;
border-top: 1px solid #f5f5f5;
}
.form-actions .van-button {
height: 48px;
font-size: 16px;
font-weight: 600;
}
/* 地图选点 */
.map-picker {
height: 100%;
display: flex;
flex-direction: column;
}
.map-picker-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-bottom: 1px solid #f0f0f0;
}
.map-picker-header h3 {
flex: 1;
text-align: center;
font-size: 16px;
margin: 0;
color: #333;
}
.map-picker-header .van-icon {
font-size: 20px;
color: #333;
}
.map-search {
padding: 12px 16px;
background: #fff;
}
.search-results {
max-height: 200px;
overflow-y: auto;
background: #fff;
border-bottom: 1px solid #f0f0f0;
}
.search-result-item {
padding: 12px 16px;
border-bottom: 1px solid #f5f5f5;
cursor: pointer;
}
.search-result-item:active {
background: #f5f5f5;
}
.result-name {
font-size: 14px;
color: #333;
margin-bottom: 4px;
}
.result-address {
font-size: 12px;
color: #999;
}
.map-container {
flex: 1;
width: 100%;
}
.selected-location {
padding: 12px 16px;
background: #fff;
border-top: 1px solid #f0f0f0;
display: flex;
align-items: center;
gap: 8px;
}
.selected-location .van-icon {
color: #FF6700;
font-size: 18px;
}
.selected-location span {
flex: 1;
font-size: 14px;
color: #333;
}
.datetime-picker-popup {
background: #fff;
border-radius: 16px 16px 0 0;
overflow: hidden;
}
.picker-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #f0f0f0;
}
.picker-header h3 {
font-size: 16px;
margin: 0;
color: #333;
font-weight: 600;
}
.picker-body {
padding: 20px;
}
.datetime-input {
width: 100%;
padding: 12px;
font-size: 16px;
border: 1px solid #e8e8e8;
border-radius: 8px;
outline: none;
color: #333;
}
.datetime-input:focus {
border-color: #FF6700;
}
</style>
+226
View File
@@ -0,0 +1,226 @@
<template>
<div class="login">
<div class="login-bg">
<div class="bg-circle circle-1"></div>
<div class="bg-circle circle-2"></div>
<div class="bg-circle circle-3"></div>
</div>
<div class="login-content">
<div class="logo">
<div class="logo-icon">
<van-icon name="manager-o" />
</div>
<h1>泉友米管理后台</h1>
<p>欢迎回来</p>
</div>
<van-form @submit="onSubmit" class="login-form">
<van-cell-group inset>
<van-field
v-model="form.username"
label="用户名"
placeholder="请输入用户名"
required
left-icon="user-o"
clearable
/>
<van-field
v-model="form.password"
type="password"
label="密码"
placeholder="请输入密码"
required
left-icon="lock"
clearable
/>
</van-cell-group>
<div class="form-actions">
<van-button round block type="primary" native-type="submit" size="large">
登录
</van-button>
</div>
</van-form>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { showToast } from 'vant';
import api from '../../api';
const router = useRouter();
const form = ref({
username: '',
password: ''
});
const onSubmit = async () => {
try {
const { data } = await api.adminLogin(form.value);
localStorage.setItem('token', data.token);
showToast('登录成功');
router.push('/admin');
} catch (error) {
showToast('登录失败');
}
};
</script>
<style scoped>
.login {
min-height: 100vh;
background: linear-gradient(135deg, #FF6700 0%, #FF8533 100%);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
position: relative;
overflow: hidden;
}
.login-bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
overflow: hidden;
}
.bg-circle {
position: absolute;
border-radius: 50%;
background: rgba(255,255,255,0.1);
animation: float 20s ease-in-out infinite;
}
.circle-1 {
width: 300px;
height: 300px;
top: -100px;
right: -100px;
animation-delay: 0s;
}
.circle-2 {
width: 200px;
height: 200px;
bottom: -50px;
left: -50px;
animation-delay: 5s;
}
.circle-3 {
width: 150px;
height: 150px;
top: 50%;
left: 10%;
animation-delay: 10s;
}
@keyframes float {
0%, 100% {
transform: translate(0, 0) scale(1);
opacity: 0.3;
}
50% {
transform: translate(30px, -30px) scale(1.1);
opacity: 0.5;
}
}
.login-content {
width: 100%;
max-width: 400px;
position: relative;
z-index: 1;
}
.logo {
text-align: center;
margin-bottom: 48px;
}
.logo-icon {
width: 80px;
height: 80px;
margin: 0 auto 20px;
background: rgba(255,255,255,0.25);
border-radius: 24px;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(10px);
box-shadow: 0 8px 24px rgba(0,0,0,0.15);
}
.logo-icon .van-icon {
font-size: 42px;
color: #fff;
}
.logo h1 {
color: #fff;
font-size: 28px;
font-weight: 800;
margin: 0 0 8px;
text-shadow: 0 2px 12px rgba(0,0,0,0.2);
letter-spacing: 1px;
}
.logo p {
color: rgba(255,255,255,0.9);
font-size: 15px;
margin: 0;
font-weight: 500;
}
.login-form {
position: relative;
}
.login-form .van-cell-group {
border-radius: 20px;
overflow: hidden;
box-shadow: 0 8px 32px rgba(0,0,0,0.15);
backdrop-filter: blur(10px);
background: rgba(255,255,255,0.95);
}
.login-form .van-field {
padding: 16px;
}
.login-form .van-field__label {
font-weight: 600;
color: #333;
font-size: 14px;
}
.login-form .van-field__control {
font-size: 15px;
}
.form-actions {
margin-top: 32px;
}
.form-actions .van-button {
height: 52px;
font-size: 17px;
font-weight: 700;
box-shadow: 0 8px 24px rgba(0,0,0,0.2);
background: linear-gradient(135deg, #FF6700 0%, #FF8533 100%);
border: none;
transition: all 0.3s ease;
}
.form-actions .van-button:active {
transform: translateY(2px);
box-shadow: 0 4px 16px rgba(0,0,0,0.25);
}
</style>
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true
}
}
}
});