Files
quanyoumi/frontend/src/views/admin/Dashboard.vue
T

921 lines
22 KiB
Vue

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