Enhance application structure with new modules and login improvements

- Add new menu sections for Activity Management and User Feedback
- Update router configuration with new route paths
- Refactor login API and user store for better error handling
- Modify login view to improve navigation and error logging
- Update patrol tasks view with more detailed table columns
- Fix import paths for SCSS variables in patrol views
This commit is contained in:
wzclm 2025-02-22 14:57:43 +08:00
parent 595054eb43
commit f22ce91e1a
20 changed files with 5473 additions and 38 deletions

View File

@ -0,0 +1,87 @@
import request from '@/utils/request'
/**
* 获取课程列表
* @param {Object} params - 查询参数
* @param {number} [params.page=1] - 页码
* @param {number} [params.pageSize=10] - 每页条数
* @param {string} [params.title] - 课程标题
* @param {string} [params.category] - 课程类型
* @param {number} [params.status] - 状态
* @returns {Promise} 返回课程列表数据
*/
export function getCourseList(params = {}) {
return request.get('/api/education/courses', {
params: {
page_num: params.page || 1,
page_size: params.pageSize || 10,
title: params.title || undefined,
category: params.category || undefined,
status: params.status === '' ? undefined : params.status
}
})
}
/**
* 创建课程
* @param {Object} data - 课程数据
* @returns {Promise} 返回创建结果
*/
export function createCourse(data) {
return request.post('/api/education/courses', data)
}
/**
* 获取课程详情
* @param {string|number} id - 课程ID
* @returns {Promise} 返回课程详情数据
*/
export function getCourseDetail(id) {
return request.get(`/api/education/courses/${id}`)
}
/**
* 更新课程
* @param {string|number} id - 课程ID
* @param {Object} data - 更新数据
* @returns {Promise} 返回更新结果
*/
export function updateCourse(id, data) {
return request.put(`/api/education/courses/${id}`, data)
}
/**
* 删除课程
* @param {string|number} id - 课程ID
* @returns {Promise} 返回删除结果
*/
export function deleteCourse(id) {
return request.delete(`/api/education/courses/${id}`)
}
/**
* 更新课程状态
* @param {string|number} id - 课程ID
* @param {number} status - 状态值1-进行中0-已结束
* @returns {Promise} 返回状态更新结果
*/
export function updateCourseStatus(id, status) {
return request.put(`/api/education/courses/${id}/status`, {
status: status === 1 ? 1 : 0 // 确保只发送 1 或 0
})
}
/**
* 获取热门课程
* @param {Object} params - 查询参数
* @param {number} [params.limit=5] - 获取数量
* @returns {Promise} 返回热门课程列表
*/
export function getHotCourses(params = {}) {
return request.get('/api/education/courses/hot', {
params: {
limit: 5,
...params
}
})
}

View File

@ -0,0 +1,95 @@
import request from '@/utils/request'
/**
* 创建知识条目
* @param {Object} data - 知识条目数据
* @returns {Promise} 返回创建结果
*/
export function createKnowledge(data) {
return request.post('/api/education/knowledge', data)
}
/**
* 获取知识列表
* @param {Object} params - 查询参数
* @param {number} [params.page=1] - 页码
* @param {number} [params.pageSize=10] - 每页条数
* @param {string} [params.title] - 标题搜索
* @param {string} [params.category] - 分类筛选
* @param {number} [params.status] - 状态筛选
* @returns {Promise} 返回知识列表数据
*/
export function getKnowledgeList(params = {}) {
const { page = 1, pageSize = 10, ...restParams } = params
return request.get('/api/education/knowledge', {
params: {
page_size: pageSize,
page_num: page,
...Object.fromEntries(
Object.entries(restParams).filter(([_, value]) => value !== '')
)
}
})
}
/**
* 获取知识详情
* @param {string|number} id - 知识条目ID
* @returns {Promise} 返回知识详情数据
*/
export function getKnowledgeDetail(id) {
return request.get(`/api/education/knowledge/${id}`)
}
/**
* 更新知识条目
* @param {string|number} id - 知识条目ID
* @param {Object} data - 更新数据
* @returns {Promise} 返回更新结果
*/
export function updateKnowledge(id, data) {
return request.put(`/api/education/knowledge/${id}`, data)
}
/**
* 更新知识状态
* @param {string|number} id - 知识条目ID
* @param {number} status - 状态值0-草稿1-已发布
* @returns {Promise} 返回状态更新结果
*/
export function updateKnowledgeStatus(id, status) {
return request.put(`/api/education/knowledge/${id}/status`, {
status: status === 1 ? 1 : 0 // 确保只发送 1 或 0
})
}
/**
* 批量更新知识状态
* @param {Array<string|number>} ids - 知识条目ID列表
* @param {number} status - 状态值0-草稿1-已发布
* @returns {Promise} 返回批量状态更新结果
*/
export function batchUpdateKnowledgeStatus(ids, status) {
return request.put('/api/education/knowledge/batch/status', {
ids,
status: status === 1 ? 1 : 0
})
}
/**
* 批量删除知识
* @param {Array<string|number>} ids - 知识条目ID列表
* @returns {Promise} 返回批量删除结果
*/
export function batchDeleteKnowledge(ids) {
return request.post('/api/education/knowledge/batch/delete', { ids })
}
/**
* 删除知识条目
* @param {string|number} id - 知识条目ID
* @returns {Promise} 返回删除结果
*/
export function deleteKnowledge(id) {
return request.delete(`/api/education/knowledge/${id}`)
}

91
src/api/activity/study.js Normal file
View File

@ -0,0 +1,91 @@
import request from '@/utils/request'
/**
* 创建研学活动
* @param {Object} data - 活动数据
* @returns {Promise} 返回创建结果
*/
export function createActivity(data) {
return request.post('/api/education/activities', data)
}
/**
* 获取研学活动列表
* @returns {Promise} 返回活动列表数据
*/
export function getActivityList() {
return request.get('/api/education/activities')
}
/**
* 获取研学活动详情
* @param {string|number} id - 活动ID
* @returns {Promise} 返回活动详情数据
*/
export function getActivityDetail(id) {
return request.get(`/api/education/activities/${id}`)
}
/**
* 取消研学活动
* @param {string|number} id - 活动ID
* @returns {Promise} 返回取消结果
*/
export function cancelActivity(id) {
return request.post(`/api/education/activities/${id}/cancel`)
}
/**
* 批量取消研学活动
* @param {Array<string|number>} ids - 活动ID列表
* @returns {Promise} 返回批量取消结果
*/
export function batchCancelActivities(ids) {
return request.post('/api/education/activities/batch/cancel', { ids })
}
/**
* 更新研学活动
* @param {string|number} id - 活动ID
* @param {Object} data - 更新数据
* @returns {Promise} 返回更新结果
*/
export function updateActivity(id, data) {
return request.put(`/api/education/activities/${id}`, data)
}
/**
* 更新研学活动状态
* @param {string|number} id - 活动ID
* @param {number} status - 状态值
* @returns {Promise} 返回状态更新结果
*/
export function updateActivityStatus(id, status) {
return request.put(`/api/education/activities/${id}/status`, { status })
}
/**
* 检查研学活动容量
* @param {string|number} id - 活动ID
* @returns {Promise} 返回容量检查结果
*/
export function checkActivityCapacity(id) {
return request.get(`/api/education/activities/${id}/capacity`)
}
/**
* 获取指定分类的研学活动
* @param {Object} params - 查询参数
* @param {number} [params.page=1] - 页码
* @param {number} [params.pageSize=10] - 每页条数
* @returns {Promise} 返回分类活动列表
*/
export function getCategoryActivities(params = {}) {
return request.get('/api/education/activities/category/field_study', {
params: {
page: 1,
pageSize: 10,
...params
}
})
}

120
src/api/feedback/index.js Normal file
View File

@ -0,0 +1,120 @@
import request from '@/utils/request'
/**
* 获取意见反馈列表
* @param {Object} params - 查询参数
* @param {number} [params.page=1] - 页码
* @param {number} [params.page_size=10] - 每页条数
* @param {string} [params.keyword] - 搜索关键词
* @param {string} [params.feedback_type] - 反馈类型
* @param {number} [params.status] - 状态筛选
* @param {string} [params.start_date] - 开始日期
* @param {string} [params.end_date] - 结束日期
* @returns {Promise} 返回反馈列表数据
*/
export function getFeedbackList(params = {}) {
return request.get('/api/admin/feedbacks', {
params: {
page: params.page || 1,
page_size: params.page_size || 10,
keyword: params.keyword || undefined,
feedback_type: params.feedback_type || undefined,
status: params.status === '' ? undefined : params.status,
start_date: params.start_date || undefined,
end_date: params.end_date || undefined
}
})
}
/**
* 获取反馈详情
* @param {string|number} id - 反馈ID
* @returns {Promise} 返回反馈详情数据
*/
export function getFeedbackDetail(id) {
return request.get(`/api/admin/feedbacks/${id}`)
}
/**
* 更新反馈状态和处理结果
* @param {string|number} id - 反馈ID
* @param {number} status - 状态值0-待处理1-处理中2-已处理3-已关闭
* @param {string} [handling_result] - 处理结果仅在status为2时需要
* @returns {Promise} 返回更新结果
*/
export function updateFeedbackStatus(id, status, handling_result) {
const data = { status }
// 当状态为已处理(2)时,需要包含处理结果
if (status === 2 && handling_result) {
data.handling_result = handling_result
}
return request.put(`/api/admin/feedbacks/${id}/status`, data)
}
/**
* 获取满意度调查列表
* @param {Object} params - 查询参数
* @param {number} [params.page=1] - 页码
* @param {number} [params.page_size=10] - 每页条数
* @param {string} [params.keyword] - 搜索关键词
* @param {number} [params.satisfaction_level] - 满意度等级
* @param {string} [params.start_date] - 开始日期
* @param {string} [params.end_date] - 结束日期
* @returns {Promise} 返回满意度调查列表数据
*/
export function getSatisfactionList(params = {}) {
return request.get('/api/admin/surveys', {
params: {
page: params.page || 1,
page_size: params.page_size || 10,
keyword: params.keyword || undefined,
satisfaction_level: params.satisfaction_level || undefined,
start_date: params.start_date || undefined,
end_date: params.end_date || undefined
}
})
}
/**
* 创建满意度调查
* @param {Object} data - 调查数据
* @param {string} data.user_name - 用户名称
* @param {string} data.contact - 联系方式
* @param {string} data.course_name - 课程名称
* @param {number} data.satisfaction_level - 满意度等级(1-5)
* @param {string} data.comment - 评价内容
* @returns {Promise} 返回创建结果
*/
export function createSurvey(data) {
return request.post('/api/admin/surveys', data)
}
/**
* 更新满意度调查
* @param {string|number} id - 调查ID
* @param {Object} data - 更新数据
* @returns {Promise} 返回更新结果
*/
export function updateSurvey(id, data) {
return request.put(`/api/admin/surveys/${id}`, data)
}
/**
* 获取满意度调查详情
* @param {string|number} id - 调查ID
* @returns {Promise} 返回调查详情数据
*/
export function getSurveyDetail(id) {
return request.get(`/api/admin/surveys/${id}`)
}
/**
* 删除满意度调查
* @param {string|number} id - 调查ID
* @returns {Promise} 返回删除结果
*/
export function deleteSurvey(id) {
return request.delete(`/api/admin/surveys/${id}`)
}

View File

@ -8,13 +8,8 @@ import request from '@/utils/request'
* @returns {Promise} 返回包含token和用户信息的Promise
*/
export function login(data) {
return request.post('/api/users/login', data)
}
/**
* 退出登录
* @returns {Promise}
*/
export function logout() {
return request.post('/api/users/logout')
return request.post('/api/users/login', {
username: data.username,
password: data.password
})
}

View File

@ -0,0 +1,84 @@
import request from '@/utils/request'
/**
* 获取轮播图列表
* @returns {Promise} 返回轮播图列表数据
*/
export function getCarouselList() {
return request.get('/api/admin/carousels')
}
/**
* 创建轮播图
* @param {Object} data - 轮播图数据
* @returns {Promise} 返回创建结果
*/
export function createCarousel(data) {
return request.post('/api/admin/carousels', data)
}
/**
* 更新轮播图
* @param {string|number} id - 轮播图ID
* @param {Object} data - 更新数据
* @returns {Promise} 返回更新结果
*/
export function updateCarousel(id, data) {
return request.put(`/api/admin/carousels/${id}`, data)
}
/**
* 删除轮播图
* @param {string|number} id - 轮播图ID
* @returns {Promise} 返回删除结果
*/
export function deleteCarousel(id) {
return request.delete(`/api/admin/carousels/${id}`)
}
/**
* 获取轮播图详情
* @param {string|number} id - 轮播图ID
* @returns {Promise} 返回轮播图详情
*/
export function getCarouselDetail(id) {
return request.get(`/api/admin/carousels/${id}`)
}
/**
* 批量更新轮播图排序
* @param {Array<{id: number|string, sort_order: number}>} sortData - 排序数据
* @returns {Promise} 返回排序更新结果
*/
export function batchUpdateCarouselSort(sortData) {
if (!Array.isArray(sortData) || sortData.length === 0) {
return Promise.reject(new Error('排序数据不能为空'))
}
return request.put('/api/admin/carousels/sort/batch', {
sorts: sortData.map(item => ({
id: item.id,
sort_order: Number(item.sort_order)
}))
})
}
/**
* 更新轮播图状态
* @param {string|number} id - 轮播图ID
* @param {number} status - 状态值0-禁用1-启用
* @returns {Promise} 返回状态更新结果
*/
export function updateCarouselStatus(id, status) {
return request.put(`/api/admin/carousels/${id}/status`, {
status: status === 1 ? 1 : 0
})
}
/**
* 批量删除轮播图
* @param {Array<string|number>} ids - 轮播图ID列表
* @returns {Promise} 返回批量删除结果
*/
export function batchDeleteCarousels(ids) {
return request.post('/api/admin/carousels/batch/delete', { ids })
}

View File

@ -15,6 +15,7 @@ import {
Tools,
Document,
DataLine,
Collection,
} from "@element-plus/icons-vue";
const router = useRouter();
@ -34,7 +35,8 @@ const icons = {
DataBoard: markRaw(DataBoard),
Tools: markRaw(Tools),
Document: markRaw(Document),
DataLine: markRaw(DataLine)
DataLine: markRaw(DataLine),
Collection: markRaw(Collection),
};
//
@ -111,6 +113,7 @@ const handleLogout = () => {
<el-menu-item index="/system/settings">系统设置</el-menu-item>
<el-menu-item index="/system/logs">系统日志</el-menu-item>
<el-menu-item index="/system/data">数据管理</el-menu-item>
<el-menu-item index="/system/carousel">轮播图管理</el-menu-item>
</el-sub-menu>
<el-sub-menu index="monitor">
@ -129,6 +132,7 @@ const handleLogout = () => {
</template>
<el-menu-item index="/patrol/tasks">巡护任务</el-menu-item>
<el-menu-item index="/patrol/records">巡护记录</el-menu-item>
<el-menu-item index="/patrol/events">安防事件</el-menu-item>
</el-sub-menu>
<el-sub-menu index="report">
@ -141,6 +145,25 @@ const handleLogout = () => {
<el-menu-item index="/report/analysis">分析报告</el-menu-item>
<el-menu-item index="/report/about">项目背景</el-menu-item>
</el-sub-menu>
<el-sub-menu index="activity">
<template #title>
<el-icon><component :is="icons.Collection" /></el-icon>
<span>活动管理</span>
</template>
<el-menu-item index="/activity/course">课程管理</el-menu-item>
<el-menu-item index="/activity/study">研学管理</el-menu-item>
<el-menu-item index="/activity/knowledge">知识库管理</el-menu-item>
</el-sub-menu>
<el-sub-menu index="feedback">
<template #title>
<el-icon><component :is="icons.DataLine" /></el-icon>
<span>用户反馈</span>
</template>
<el-menu-item index="/feedback/suggestions">意见反馈</el-menu-item>
<el-menu-item index="/feedback/satisfaction">满意度调查</el-menu-item>
</el-sub-menu>
</el-menu>
</el-aside>

View File

@ -64,6 +64,11 @@ const router = createRouter({
name: 'PatrolTasks',
component: () => import('../views/patrol/tasks/index.vue')
},
{
path: 'patrol/events',
name: 'PatrolEvents',
component: () => import('../views/patrol/events/index.vue')
},
{
path: 'patrol/records',
name: 'PatrolRecords',
@ -108,6 +113,43 @@ const router = createRouter({
path: 'system/data',
name: 'DataManagement',
component: () => import('../views/system/data/index.vue')
},
{
path: 'system/carousel',
name: 'SystemCarousel',
component: () => import('../views/system/carousel/index.vue'),
meta: { title: '轮播图管理', icon: 'picture' }
},
{
path: 'activity/course',
name: 'CourseManagement',
component: () => import('../views/activity/course/index.vue')
},
{
path: 'activity/study',
name: 'StudyManagement',
component: () => import('../views/activity/study/index.vue')
},
{
path: 'activity/knowledge',
name: 'KnowledgeManagement',
component: () => import('../views/activity/knowledge/index.vue')
},
{
path: 'feedback/suggestions',
name: 'FeedbackSuggestions',
component: () => import('../views/feedback/suggestions/index.vue')
},
{
path: 'feedback/satisfaction',
name: 'FeedbackSatisfaction',
component: () => import('../views/feedback/satisfaction/index.vue')
},
{
path: 'data',
name: 'SystemData',
component: () => import('@/views/system/data/index.vue'),
meta: { title: '数据管理', icon: 'data' }
}
]
}

View File

@ -1,5 +1,6 @@
import { defineStore } from 'pinia'
import { login as loginApi, logout as logoutApi } from '@/api/login'
import { login as loginApi } from '@/api/login'
import { ElMessage } from 'element-plus'
// 安全的 JSON 解析函数
const safeJSONParse = (str, defaultValue = null) => {
@ -31,36 +32,47 @@ export const useUserStore = defineStore('user', {
async login(username, password) {
try {
// 调用登录接口
const { token, userInfo } = await loginApi({ username, password })
const response = await loginApi({ username, password })
// 检查响应状态
if (!response.success) {
console.error('登录失败:', response.message)
ElMessage.error(response.message || '用户名或密码错误')
return false
}
// 从响应中获取token和用户信息
const { user, token } = response.data
if (!token) {
console.error('登录失败: 未获取到token')
ElMessage.error('登录失败,请稍后重试')
return false
}
// 保存token和用户信息
this.token = token
this.userInfo = userInfo
this.userInfo = user
// 持久化存储
localStorage.setItem('token', token)
localStorage.setItem('userInfo', JSON.stringify(userInfo))
localStorage.setItem('userInfo', JSON.stringify(user))
return true
} catch (error) {
console.error('登录失败:', error)
ElMessage.error(error.response?.data?.message || '登录失败,请稍后重试')
return false
}
},
// 退出登录
async logout() {
try {
await logoutApi()
} catch (error) {
console.error('退出登录失败:', error)
} finally {
// 无论是否成功调用退出接口,都清除本地存储
this.token = null
this.userInfo = null
localStorage.removeItem('token')
localStorage.removeItem('userInfo')
}
logout() {
// 清除本地存储
this.token = null
this.userInfo = null
localStorage.removeItem('token')
localStorage.removeItem('userInfo')
}
},

View File

@ -0,0 +1,964 @@
<script setup>
import { ref, onMounted, computed, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Search, Refresh } from '@element-plus/icons-vue'
import { formatDateTime } from '@/utils/format'
import {
getCourseList,
createCourse,
updateCourse,
deleteCourse,
updateCourseStatus
} from '@/api/activity/course'
//
const categoryOptions = [
{ label: '湿地科普', value: 'wetland' },
{ label: '生态保护', value: 'ecology' },
{ label: '物种知识', value: 'species' },
{ label: '环境保护', value: 'environment' }
]
//
const difficultyOptions = [
{ label: '入门', value: 1 },
{ label: '初级', value: 2 },
{ label: '中级', value: 3 },
{ label: '高级', value: 4 }
]
//
const audienceOptions = [
{ label: '所有人群', value: 'all' },
{ label: '儿童', value: 'children' },
{ label: '青少年', value: 'youth' },
{ label: '成人', value: 'adult' }
]
//
const searchForm = ref({
title: '',
category: '',
status: ''
})
//
const tableData = ref([])
const loading = ref(false)
const allData = ref([]) //
//
const currentPage = ref(1)
const pageSize = ref(10)
const total = ref(0)
//
const filteredData = computed(() => {
let result = [...allData.value]
//
if (searchForm.value.title) {
const keyword = searchForm.value.title.toLowerCase()
result = result.filter(item =>
item.title.toLowerCase().includes(keyword)
)
}
//
if (searchForm.value.category) {
result = result.filter(item =>
item.category === searchForm.value.category
)
}
//
if (searchForm.value.status !== '') {
result = result.filter(item =>
item.status === Number(searchForm.value.status)
)
}
return result
})
//
const getList = async () => {
loading.value = true
try {
const res = await getCourseList({
page: currentPage.value,
pageSize: pageSize.value,
title: searchForm.value.title || undefined,
category: searchForm.value.category || undefined,
status: searchForm.value.status === '' ? undefined : Number(searchForm.value.status)
})
if (res.success) {
//
allData.value = res.data || []
total.value = res.data.length || 0
//
tableData.value = allData.value.map(item => ({
...item,
created_at: formatDateTime(item.created_at),
updated_at: formatDateTime(item.updated_at)
}))
} else {
ElMessage.error(res.message || '获取课程列表失败')
allData.value = []
tableData.value = []
total.value = 0
}
} catch (error) {
console.error('获取课程列表错误:', error)
ElMessage.error('获取课程列表失败')
allData.value = []
tableData.value = []
total.value = 0
} finally {
loading.value = false
}
}
//
const handleCurrentChange = (val) => {
currentPage.value = val
getList()
}
const handleSizeChange = (val) => {
pageSize.value = val
currentPage.value = 1
getList()
}
// watch 使
const watchStopHandle = watch(
[() => searchForm.value.title, () => searchForm.value.category, () => searchForm.value.status],
() => {
currentPage.value = 1
getList()
}
)
//
const handleSearch = () => {
currentPage.value = 1
getList()
}
//
const resetSearch = () => {
searchForm.value = {
title: '',
category: '',
status: ''
}
currentPage.value = 1
getList()
}
//
const handleRefresh = () => {
getList()
}
// /
const dialogVisible = ref(false)
const dialogTitle = ref('')
const formMode = ref('create')
const form = ref({
title: '',
category: '',
cover_image: '',
video: '',
video_name: '',
video_url: '',
video_duration: 0,
video_size: 0,
description: '',
duration: 60,
status: 0,
imageInputType: 'upload',
videoInputType: 'upload'
})
//
const formRules = {
title: [
{ required: true, message: '请输入课程标题', trigger: 'blur' },
{ min: 1, max: 200, message: '长度在 1 到 200 个字符', trigger: 'blur' }
],
category: [
{ required: true, message: '请选择课程类型', trigger: 'change' }
],
duration: [
{ type: 'number', min: 1, message: '时长必须为正整数', trigger: 'blur' }
]
}
//
const handleCoverError = (error) => {
console.error('上传图片错误:', error)
form.value.cover_image = ''
ElMessage.error('上传图片失败,请重试')
}
const handleImageSuccess = (response) => {
if (response.success) {
console.log('上传返回的图片信息:', response.data)
//
const fileName = response.data.url.split('/').pop()
const imageUrl = `http://localhost:3000/uploads/courses/images/${fileName}`
//
form.value = {
...form.value,
cover_image: fileName, //
cover_image_url: imageUrl // URL
}
console.log('设置后的图片信息:', {
cover_image: form.value.cover_image,
cover_image_url: form.value.cover_image_url
})
ElMessage.success('图片上传成功')
} else {
form.value.cover_image = ''
form.value.cover_image_url = ''
ElMessage.error(response.message || '图片上传失败')
}
}
const beforeCoverUpload = (file) => {
const isImage = file.type.startsWith('image/')
const isLt2M = file.size / 1024 / 1024 < 2
if (!isImage) {
ElMessage.error('上传封面图片只能是图片格式!')
return false
}
if (!isLt2M) {
ElMessage.error('上传封面图片大小不能超过 2MB!')
return false
}
return true
}
//
const handleAdd = () => {
formMode.value = 'create'
dialogTitle.value = '新增课程'
form.value = {
title: '',
category: '',
cover_image: '',
video: '',
video_name: '',
video_url: '',
description: '',
duration: 60,
status: 0,
imageInputType: 'upload',
videoInputType: 'upload'
}
dialogVisible.value = true
}
//
const handleEdit = (row) => {
formMode.value = 'edit'
dialogTitle.value = '编辑课程'
form.value = {
id: row.id,
title: row.title,
category: row.category,
cover_image: row.cover_image,
cover_image_url: row.cover_image ? (
row.cover_image.startsWith('http')
? row.cover_image
: `http://localhost:3000/uploads/courses/images/${row.cover_image}`
) : '',
video: row.video || '', //
video_url: row.video ? (
row.video.startsWith('http')
? row.video
: `http://localhost:3000/uploads/courses/videos/${row.video}`
) : '', // URL
video_duration: row.video_duration || 0,
video_size: row.video_size || 0,
description: row.description || '',
duration: row.duration || 60,
status: row.status,
imageInputType: 'upload',
videoInputType: 'upload'
}
console.log('编辑表单数据:', form.value)
dialogVisible.value = true
}
//
const formRef = ref(null)
const handleSubmit = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (valid) {
try {
//
if (!form.value.cover_image) {
ElMessage.warning('请上传课程封面图片')
return
}
if (!form.value.video) {
ElMessage.warning('请上传课程视频')
return
}
//
const submitData = {
title: form.value.title.trim(),
category: form.value.category,
description: form.value.description?.trim() || '',
duration: Number(form.value.duration) || 60,
status: Number(form.value.status),
//
cover_image: form.value.cover_image, //
//
video: form.value.video, //
video_duration: form.value.video_duration || 0,
video_size: form.value.video_size || 0
}
console.log('提交的课程数据:', submitData)
let res
if (formMode.value === 'create') {
res = await createCourse(submitData)
} else {
const id = form.value.id
res = await updateCourse(id, submitData)
}
if (res.success) {
ElMessage.success(`${formMode.value === 'create' ? '新增' : '编辑'}成功`)
dialogVisible.value = false
getList()
} else {
ElMessage.error(res.message || `${formMode.value === 'create' ? '新增' : '编辑'}失败`)
}
} catch (error) {
console.error(`${formMode.value === 'create' ? '新增' : '编辑'}课程错误:`, error)
ElMessage.error('操作失败,请检查输入是否正确')
}
}
})
}
//
const handleDelete = (row) => {
//
if (row.enrolled > 0) {
ElMessage.warning(`该课程已有${row.enrolled}人报名,不能删除`)
return
}
ElMessageBox.confirm('确认删除该课程吗?此操作不可恢复', '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const res = await deleteCourse(row.id)
if (res.success) {
ElMessage.success('删除成功')
getList()
} else {
ElMessage.error(res.message || '删除失败')
}
} catch (error) {
console.error('删除课程错误:', error)
ElMessage.error('删除失败')
}
}).catch(() => {})
}
//
const handleStatusChange = async (row) => {
try {
const newStatus = row.status === 1 ? 0 : 1
const res = await updateCourseStatus(row.id, newStatus)
if (res.success) {
ElMessage.success('状态更新成功')
getList()
} else {
ElMessage.error(res.message || '状态更新失败')
}
} catch (error) {
console.error('更新课程状态错误:', error)
ElMessage.error('状态更新失败')
}
}
// token
const token = computed(() => localStorage.getItem('token') || '')
const uploadHeaders = computed(() => ({
Authorization: `Bearer ${token.value}`
}))
//
const handleImageTypeChange = () => {
form.value.cover_image = ''
}
//
const handleImageError = (e) => {
console.error('图片加载失败:', e)
ElMessage.warning('图片加载失败,但不影响保存')
}
//
const handleVideoSuccess = (response) => {
if (response.success) {
console.log('上传返回的视频信息:', response.data)
//
const fileName = response.data.url.split('/').pop()
const videoUrl = `http://localhost:3000/uploads/courses/videos/${fileName}`
//
form.value = {
...form.value,
video: fileName, //
video_url: videoUrl, // URL
video_duration: response.data.duration || 0,
video_size: response.data.size || 0,
duration: Math.ceil((response.data.duration || 0) / 60) //
}
console.log('设置后的视频信息:', {
video: form.value.video,
video_url: form.value.video_url,
video_duration: form.value.video_duration,
video_size: form.value.video_size,
duration: form.value.duration
})
ElMessage.success('视频上传成功')
} else {
form.value.video = ''
form.value.video_url = ''
form.value.video_duration = 0
form.value.video_size = 0
ElMessage.error(response.message || '视频上传失败')
}
}
const handleVideoError = (error) => {
console.error('上传视频错误:', error)
form.value.video = ''
form.value.video_url = ''
ElMessage.error('上传视频失败,请重试')
}
const beforeVideoUpload = (file) => {
const isVideo = file.type.startsWith('video/')
const isLt500M = file.size / 1024 / 1024 < 500
if (!isVideo) {
ElMessage.error('只能上传视频文件!')
return false
}
if (!isLt500M) {
ElMessage.error('视频大小不能超过 500MB!')
return false
}
return true
}
//
const handleVideoTypeChange = () => {
form.value.video = ''
}
onMounted(() => {
getList()
})
</script>
<template>
<div class="course-management">
<el-card>
<template #header>
<div class="card-header">
<span>课程管理</span>
<div class="header-btns">
<el-button type="primary" :icon="Plus" @click="handleAdd">新增课程</el-button>
<el-button :icon="Refresh" @click="handleRefresh">刷新</el-button>
</div>
</div>
</template>
<!-- 搜索表单 -->
<el-form :model="searchForm" inline class="search-form">
<el-form-item label="课程标题" label-width="80px">
<el-input
v-model="searchForm.title"
placeholder="请输入课程标题"
clearable
@keyup.enter="handleSearch"
/>
</el-form-item>
<el-form-item label="课程类型" label-width="80px">
<el-select
v-model="searchForm.category"
placeholder="请选择类型"
clearable
>
<el-option
v-for="item in categoryOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="状态" label-width="80px">
<el-select
v-model="searchForm.status"
placeholder="请选择状态"
clearable
>
<el-option :value="1" label="已发布" />
<el-option :value="0" label="未发布" />
</el-select>
</el-form-item>
<el-form-item>
<div class="search-buttons">
<el-button type="primary" :icon="Search" @click="handleSearch">搜索</el-button>
<el-button :icon="Refresh" @click="resetSearch">重置</el-button>
</div>
</el-form-item>
</el-form>
<!-- 课程列表 -->
<el-table
v-loading="loading"
:data="tableData"
border
style="width: 100%"
>
<el-table-column type="index" label="序号" width="60" align="center"/>
<el-table-column prop="course_code" label="课程编号" width="120" show-overflow-tooltip/>
<el-table-column prop="title" label="课程标题" min-width="150" show-overflow-tooltip/>
<el-table-column prop="category" label="课程类型" width="120" align="center">
<template #default="{ row }">
<el-tag
:type="row.category === 'wetland' ? 'success' :
row.category === 'ecology' ? 'primary' :
row.category === 'species' ? 'warning' :
row.category === 'nature' ? 'danger' : 'info'"
>
{{ categoryOptions.find(item => item.value === row.category)?.label || '未知' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="duration" label="时长(分钟)" width="100" align="center"/>
<el-table-column prop="view_count" label="浏览量" width="100" align="center"/>
<el-table-column prop="enrollment_count" label="报名人数" width="100" align="center"/>
<el-table-column prop="status" label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'">
{{ row.status === 1 ? '已发布' : '未发布' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="180" align="center" show-overflow-tooltip/>
<el-table-column label="操作" width="180" fixed="right" align="center">
<template #default="{ row }">
<el-button type="primary" link @click="handleEdit(row)">编辑</el-button>
<el-button
type="success"
link
v-if="row.status === 0"
@click="handleStatusChange(row)"
>发布</el-button>
<el-button
type="info"
link
v-else
@click="handleStatusChange(row)"
>下线</el-button>
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页器 -->
<div class="pagination-container">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
background
layout="total, sizes, prev, pager, next"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</el-card>
<!-- 新增/编辑对话框 -->
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="800px"
destroy-on-close
>
<el-form
ref="formRef"
:model="form"
:rules="formRules"
label-width="100px"
>
<el-form-item label="课程标题" prop="title">
<el-input
v-model="form.title"
placeholder="请输入课程标题"
maxlength="200"
show-word-limit
/>
</el-form-item>
<el-form-item label="课程类型" prop="category">
<el-select
v-model="form.category"
placeholder="请选择课程类型"
>
<el-option
v-for="item in categoryOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="课程时长" prop="duration">
<el-input-number
v-model="form.duration"
:min="1"
:max="999"
placeholder="请输入课程时长(分钟)"
/>
</el-form-item>
<el-form-item label="课程封面">
<div class="image-input-container">
<el-radio-group v-model="form.imageInputType" class="mb-4" @change="handleImageTypeChange">
<el-radio :value="'url'">输入图片地址</el-radio>
<el-radio :value="'upload'">上传图片</el-radio>
</el-radio-group>
<template v-if="form.imageInputType === 'url'">
<el-input
v-model="form.cover_image"
placeholder="请输入封面图片地址"
/>
</template>
<template v-else>
<el-upload
class="image-upload"
action="http://localhost:3000/api/education/knowledge/upload"
:headers="uploadHeaders"
name="cover_image"
:show-file-list="false"
:on-success="handleImageSuccess"
:on-error="handleCoverError"
:before-upload="beforeCoverUpload"
>
<template v-if="form.cover_image">
<img
:src="form.cover_image_url"
class="uploaded-image"
crossorigin="anonymous"
@error="handleImageError"
/>
</template>
<div v-else class="upload-placeholder">
<el-icon class="image-upload-icon"><Plus /></el-icon>
<div class="upload-text">点击上传图片</div>
</div>
</el-upload>
<div class="form-tips">支持 jpgpng 格式大小不超过 2MB</div>
</template>
</div>
</el-form-item>
<el-form-item label="课程视频">
<div class="video-input-container">
<el-radio-group v-model="form.videoInputType" class="mb-4" @change="handleVideoTypeChange">
<el-radio :value="'url'">输入视频地址</el-radio>
<el-radio :value="'upload'">上传视频</el-radio>
</el-radio-group>
<template v-if="form.videoInputType === 'url'">
<el-input
v-model="form.video"
placeholder="请输入视频地址"
/>
</template>
<template v-else>
<el-upload
class="video-upload"
action="http://localhost:3000/api/education/courses/upload/video"
:headers="uploadHeaders"
name="video"
:show-file-list="false"
:on-success="handleVideoSuccess"
:on-error="handleVideoError"
:before-upload="beforeVideoUpload"
>
<template v-if="form.video_url">
<div class="video-preview">
<video
:src="form.video_url"
controls
class="uploaded-video"
crossorigin="anonymous"
@error="handleVideoError"
>
您的浏览器不支持视频播放
</video>
</div>
</template>
<div v-else class="upload-placeholder">
<el-icon class="video-upload-icon"><Plus /></el-icon>
<div class="upload-text">点击上传视频</div>
</div>
</el-upload>
<div class="form-tips">支持常见视频格式大小不超过 500MB</div>
</template>
</div>
</el-form-item>
<el-form-item label="课程描述">
<el-input
v-model="form.description"
type="textarea"
:rows="4"
placeholder="请输入课程描述"
/>
</el-form-item>
<el-form-item label="状态">
<el-radio-group v-model="form.status">
<el-radio :value="1">上架</el-radio>
<el-radio :value="0">下架</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<style lang="scss" scoped>
.course-management {
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
span {
font-size: 16px;
font-weight: 500;
}
.header-btns {
display: flex;
gap: 12px;
}
}
.search-form {
margin-bottom: 24px;
padding: 24px;
background-color: #f8f9fa;
border-radius: 8px;
:deep(.el-form-item) {
margin-bottom: 16px;
margin-right: 24px;
&:last-child {
margin-right: 0;
margin-bottom: 0;
}
}
:deep(.el-input),
:deep(.el-select) {
width: 220px;
}
.search-buttons {
display: flex;
gap: 12px;
}
}
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
.text-center {
text-align: center;
}
.image-input-container {
display: flex;
flex-direction: column;
align-items: flex-start;
.mb-4 {
margin-bottom: 16px;
}
.image-upload {
:deep(.el-upload) {
border: 1px dashed #d9d9d9;
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
transition: var(--el-transition-duration-fast);
&:hover {
border-color: var(--el-color-primary);
}
}
}
.upload-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 178px;
height: 178px;
border: 1px dashed #d9d9d9;
border-radius: 6px;
cursor: pointer;
transition: var(--el-transition-duration-fast);
&:hover {
border-color: var(--el-color-primary);
}
.image-upload-icon {
font-size: 28px;
color: #8c939d;
margin-bottom: 8px;
}
.upload-text {
font-size: 14px;
color: #8c939d;
}
}
.uploaded-image {
width: 178px;
height: 178px;
object-fit: cover;
}
.form-tips {
margin-top: 8px;
font-size: 12px;
color: #8c939d;
}
}
.video-input-container {
.video-preview {
width: 320px;
height: 180px;
border-radius: 6px;
overflow: hidden;
background-color: #000;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 10px;
.uploaded-video {
width: 100%;
height: 100%;
object-fit: contain;
}
}
.video-upload {
:deep(.el-upload) {
width: 320px;
height: 180px;
border: 1px dashed #d9d9d9;
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
transition: var(--el-transition-duration-fast);
&:hover {
border-color: var(--el-color-primary);
}
}
}
.upload-placeholder {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
.video-upload-icon {
font-size: 28px;
color: #8c939d;
margin-bottom: 8px;
}
.upload-text {
font-size: 14px;
color: #8c939d;
}
}
.form-tips {
margin-top: 8px;
font-size: 12px;
color: #8c939d;
}
}
.video-preview-cell {
width: 120px;
height: 68px;
margin: 0 auto;
border-radius: 4px;
overflow: hidden;
background-color: #000;
.preview-video {
width: 100%;
height: 100%;
object-fit: contain;
}
}
}
</style>

View File

@ -0,0 +1,844 @@
<script setup>
import { ref, onMounted, computed, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Search, Refresh, Upload, Picture } from '@element-plus/icons-vue'
import { formatDateTime } from '@/utils/format'
import {
getKnowledgeList,
createKnowledge,
updateKnowledge,
deleteKnowledge,
batchDeleteKnowledge,
updateKnowledgeStatus,
batchUpdateKnowledgeStatus
} from '@/api/activity/knowledge'
//
const searchForm = ref({
title: '',
category: '',
status: ''
})
//
const categoryOptions = [
{ label: '湿地科学', value: 'wetland_science' },
{ label: '物种图鉴', value: 'species_guide' },
{ label: '环境保护', value: 'environmental_protection' },
{ label: '生态教育', value: 'ecological_education' }
]
//
const statusOptions = [
{ label: '上架', value: 1 },
{ label: '下架', value: 0 }
]
//
const tableData = ref([])
const loading = ref(false)
const selectedRows = ref([])
//
const currentPage = ref(1)
const pageSize = ref(10)
const total = ref(0)
//
const allData = ref([])
//
const filteredData = computed(() => {
let result = [...allData.value]
//
if (searchForm.value.title) {
const keyword = searchForm.value.title.toLowerCase()
result = result.filter(item =>
item.title.toLowerCase().includes(keyword)
)
}
//
if (searchForm.value.category) {
result = result.filter(item =>
item.category === searchForm.value.category
)
}
//
if (searchForm.value.status !== '') {
result = result.filter(item =>
item.status === Number(searchForm.value.status)
)
}
return result
})
//
const getList = async () => {
loading.value = true
try {
const res = await getKnowledgeList({
page: currentPage.value,
pageSize: pageSize.value,
category: searchForm.value.category //
})
if (res.success) {
//
allData.value = res.data.map(item => ({
...item,
cover_image: item.cover_image
? (item.cover_image.startsWith('http')
? item.cover_image
: `http://localhost:3000${item.cover_image}`)
: '',
created_at: formatDateTime(item.created_at),
updated_at: formatDateTime(item.updated_at)
}))
// 使
const filtered = filteredData.value
//
const start = (currentPage.value - 1) * pageSize.value
const end = start + pageSize.value
tableData.value = filtered.slice(start, end)
total.value = filtered.length
} else {
ElMessage.error(res.message || '获取知识库列表失败')
allData.value = []
tableData.value = []
total.value = 0
}
} catch (error) {
console.error('获取知识库列表错误:', error)
ElMessage.error('获取知识库列表失败')
allData.value = []
tableData.value = []
total.value = 0
} finally {
loading.value = false
}
}
//
const handleCurrentChange = (val) => {
currentPage.value = val
const start = (val - 1) * pageSize.value
const end = start + pageSize.value
tableData.value = filteredData.value.slice(start, end)
}
const handleSizeChange = (val) => {
pageSize.value = val
currentPage.value = 1
const end = pageSize.value
tableData.value = filteredData.value.slice(0, end)
}
//
watch([() => searchForm.value.title, () => searchForm.value.category, () => searchForm.value.status], () => {
currentPage.value = 1 //
const filtered = filteredData.value
tableData.value = filtered.slice(0, pageSize.value)
total.value = filtered.length
})
//
const handleSearch = () => {
currentPage.value = 1
getList()
}
//
const resetSearch = () => {
searchForm.value = {
title: '',
category: '',
status: ''
}
currentPage.value = 1
getList()
}
//
const handleRefresh = () => {
getList()
}
// /
const dialogVisible = ref(false)
const dialogTitle = ref('')
const formMode = ref('create')
const form = ref({
title: '',
category: '',
content: '',
cover_image: '',
tags: [],
status: 1,
imageInputType: 'url'
})
//
const formRules = {
title: [
{ required: true, message: '请输入标题', trigger: 'blur' },
{ min: 2, max: 100, message: '长度在 2 到 100 个字符', trigger: 'blur' }
],
category: [
{ required: true, message: '请选择分类', trigger: 'change' }
],
content: [
{ required: true, message: '请输入内容', trigger: 'blur' }
],
cover_image: [
{
required: true,
validator: (rule, value, callback) => {
if (form.value.imageInputType === 'url' && !value) {
callback(new Error('请输入封面图片地址'))
} else if (form.value.imageInputType === 'upload' && !value) {
callback(new Error('请上传封面图片'))
} else {
callback()
}
},
trigger: ['blur', 'change']
}
]
}
//
const handleAdd = () => {
formMode.value = 'create'
dialogTitle.value = '新增知识'
form.value = {
title: '',
category: '',
content: '',
cover_image: '',
tags: [],
status: 1,
imageInputType: 'url'
}
dialogVisible.value = true
}
//
const handleEdit = (row) => {
formMode.value = 'edit'
dialogTitle.value = '编辑知识'
form.value = {
id: row.id,
title: row.title,
category: row.category,
content: row.content,
cover_image: row.cover_image && !row.cover_image.startsWith('http')
? `http://localhost:3000${row.cover_image}`
: row.cover_image,
tags: row.tags || [],
status: row.status,
imageInputType: 'upload'
}
dialogVisible.value = true
}
//
const formRef = ref(null)
const handleSubmit = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (valid) {
try {
const submitData = {
title: form.value.title.trim(),
category: form.value.category,
content: form.value.content.trim(),
cover_image: form.value.cover_image.replace('http://localhost:3000', ''),
tags: form.value.tags,
status: Number(form.value.status)
}
let res
if (formMode.value === 'create') {
res = await createKnowledge(submitData)
} else {
const id = form.value.id
res = await updateKnowledge(id, submitData)
}
if (res.success) {
ElMessage.success(`${formMode.value === 'create' ? '新增' : '编辑'}成功`)
dialogVisible.value = false
getList()
} else {
ElMessage.error(res.message || `${formMode.value === 'create' ? '新增' : '编辑'}失败`)
}
} catch (error) {
console.error(`${formMode.value === 'create' ? '新增' : '编辑'}知识条目错误:`, error)
ElMessage.error('操作失败,请检查输入是否正确')
}
}
})
}
//
const handleDelete = (row) => {
ElMessageBox.confirm('确认删除该知识条目吗?此操作不可恢复', '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const res = await deleteKnowledge(row.id)
if (res.success) {
ElMessage.success('删除成功')
getList()
} else {
ElMessage.error(res.message || '删除失败')
}
} catch (error) {
console.error('删除知识条目错误:', error)
ElMessage.error('删除失败')
}
}).catch(() => {})
}
//
const handleBatchDelete = () => {
if (selectedRows.value.length === 0) {
ElMessage.warning('请选择要删除的知识条目')
return
}
ElMessageBox.confirm(`确认删除选中的 ${selectedRows.value.length} 个知识条目吗?此操作不可恢复`, '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const ids = selectedRows.value.map(row => row.id)
const res = await batchDeleteKnowledge(ids)
if (res.success) {
ElMessage.success('批量删除成功')
getList()
} else {
ElMessage.error(res.message || '批量删除失败')
}
} catch (error) {
console.error('批量删除知识条目错误:', error)
ElMessage.error('批量删除失败')
}
}).catch(() => {})
}
//
const handleStatusChange = async (row) => {
try {
const res = await updateKnowledgeStatus(row.id, row.status)
if (res.success) {
ElMessage.success('状态更新成功')
getList()
} else {
ElMessage.error(res.message || '状态更新失败')
}
} catch (error) {
console.error('更新知识状态错误:', error)
ElMessage.error('状态更新失败')
}
}
//
const handleBatchUpdateStatus = async (status) => {
if (selectedRows.value.length === 0) {
ElMessage.warning('请选择要更新的知识条目')
return
}
try {
const ids = selectedRows.value.map(row => row.id)
const res = await batchUpdateKnowledgeStatus(ids, status)
if (res.success) {
ElMessage.success('批量更新状态成功')
getList()
} else {
ElMessage.error(res.message || '批量更新状态失败')
}
} catch (error) {
console.error('批量更新知识状态错误:', error)
ElMessage.error('批量更新状态失败')
}
}
//
const handleSelectionChange = (rows) => {
selectedRows.value = rows
}
//
const tagOptions = [
{ label: '湿地保护', value: '湿地保护' },
{ label: '生态系统', value: '生态系统' },
{ label: '生物多样性', value: '生物多样性' },
{ label: '环境教育', value: '环境教育' },
{ label: '科普知识', value: '科普知识' },
{ label: '物种保护', value: '物种保护' },
{ label: '自然观察', value: '自然观察' },
{ label: '生态修复', value: '生态修复' }
]
//
const handleTagsInput = (value) => {
form.value.tags = value
}
const formatTagsForDisplay = (tags) => {
if (Array.isArray(tags)) {
return tags.join(', ')
}
return ''
}
//
const beforeImageUpload = (file) => {
const isJpgOrPng = file.type === 'image/jpeg' || file.type === 'image/png'
const isLt2M = file.size / 1024 / 1024 < 2
if (!isJpgOrPng) {
ElMessage.error('只能上传 JPG 或 PNG 格式的图片!')
return false
}
if (!isLt2M) {
ElMessage.error('图片大小不能超过 2MB!')
return false
}
return true
}
//
const handleImageError = (e) => {
console.error('图片加载失败:', e)
ElMessage.warning('图片加载失败,但不影响保存')
}
//
const handleUploadSuccess = (response) => {
if (response.success) {
const imageUrl = response.data.url
// 使URL
form.value.cover_image = imageUrl.startsWith('http')
? imageUrl
: `http://localhost:3000${imageUrl}`
ElMessage.success('图片上传成功')
} else {
form.value.cover_image = ''
ElMessage.error(response.message || '图片上传失败')
}
}
const handleUploadError = (error) => {
console.error('上传图片错误:', error)
form.value.cover_image = ''
ElMessage.error('上传图片失败,请重试')
}
//
const handleImageTypeChange = () => {
form.value.cover_image = ''
}
// uploadHeaders
const uploadHeaders = computed(() => ({
'Authorization': `Bearer ${localStorage.getItem('token')}`
}))
onMounted(() => {
getList()
})
</script>
<template>
<div class="knowledge-management">
<el-card>
<template #header>
<div class="card-header">
<span>知识库管理</span>
<div class="header-btns">
<el-button type="primary" :icon="Plus" @click="handleAdd">新增知识</el-button>
<el-button :icon="Refresh" @click="handleRefresh">刷新</el-button>
</div>
</div>
</template>
<!-- 搜索表单 -->
<el-form :model="searchForm" inline class="search-form">
<el-form-item label="标题" label-width="80px">
<el-input
v-model="searchForm.title"
placeholder="请输入标题"
clearable
/>
</el-form-item>
<el-form-item label="分类" label-width="80px">
<el-select
v-model="searchForm.category"
placeholder="请选择分类"
clearable
>
<el-option
v-for="item in categoryOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="状态" label-width="80px">
<el-select
v-model="searchForm.status"
placeholder="请选择状态"
clearable
>
<el-option
v-for="item in statusOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item class="search-buttons">
<el-button type="primary" :icon="Search" @click="handleSearch">搜索</el-button>
<el-button @click="resetSearch">重置</el-button>
</el-form-item>
</el-form>
<!-- 知识库列表 -->
<el-table
v-loading="loading"
:data="tableData"
style="width: 100%"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="55" align="center"/>
<el-table-column type="index" label="序号" width="60" align="center"/>
<el-table-column prop="title" label="标题" min-width="200" show-overflow-tooltip align="center" />
<el-table-column prop="category" label="分类" width="120" align="center">
<template #default="{ row }">
<el-tag
:type="row.category === 'wetland_science' ? 'success' :
row.category === 'species_guide' ? 'warning' :
row.category === 'environmental_protection' ? 'info' :
row.category === 'ecological_education' ? 'danger' : ''"
>
{{ categoryOptions.find(item => item.value === row.category)?.label || row.category }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="view_count" label="浏览量" width="100" align="center"/>
<el-table-column prop="like_count" label="点赞数" width="100" align="center"/>
<el-table-column prop="status" label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'">
{{ row.status === 1 ? '上架' : '下架' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="160" align="center"/>
<el-table-column prop="updated_at" label="更新时间" width="160" align="center"/>
<el-table-column prop="tags" label="标签" min-width="200" show-overflow-tooltip align="center">
<template #default="{ row }">
<el-tag
v-for="tag in row.tags"
:key="tag"
class="mx-1"
size="small"
>
{{ tag }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="250" fixed="right" align="center">
<template #default="{ row }">
<el-button type="primary" link @click="handleEdit(row)">编辑</el-button>
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页器 -->
<div class="pagination-container">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
background
layout="total, sizes, prev, pager, next"
/>
</div>
</el-card>
<!-- 新增/编辑对话框 -->
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="800px"
destroy-on-close
>
<el-form
ref="formRef"
:model="form"
:rules="formRules"
label-width="100px"
>
<el-form-item label="标题" prop="title">
<el-input
v-model="form.title"
placeholder="请输入标题"
/>
</el-form-item>
<el-form-item label="分类" prop="category">
<el-select
v-model="form.category"
placeholder="请选择分类"
>
<el-option
v-for="item in categoryOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="内容" prop="content">
<el-input
v-model="form.content"
type="textarea"
:rows="10"
placeholder="请输入内容"
/>
</el-form-item>
<el-form-item label="封面图片" prop="cover_image">
<div class="image-input-container">
<el-radio-group v-model="form.imageInputType" class="mb-4" @change="handleImageTypeChange">
<el-radio :value="'url'">输入图片地址</el-radio>
<el-radio :value="'upload'">上传图片</el-radio>
</el-radio-group>
<template v-if="form.imageInputType === 'url'">
<el-input
v-model="form.cover_image"
placeholder="请输入封面图片地址"
/>
</template>
<template v-else>
<el-upload
class="image-upload"
action="http://localhost:3000/api/education/knowledge/upload"
:headers="uploadHeaders"
name="cover_image"
:show-file-list="false"
:on-success="handleUploadSuccess"
:on-error="handleUploadError"
:before-upload="beforeImageUpload"
>
<template v-if="form.cover_image">
<img
:src="form.cover_image"
class="uploaded-image"
crossorigin="anonymous"
@error="handleImageError"
/>
</template>
<div v-else class="upload-placeholder">
<el-icon class="image-upload-icon"><Upload /></el-icon>
<div class="upload-text">点击上传图片</div>
</div>
</el-upload>
<div class="form-tips">支持 jpgpng 格式大小不超过 2MB</div>
</template>
</div>
</el-form-item>
<el-form-item label="标签">
<el-select
v-model="form.tags"
multiple
filterable
placeholder="请选择标签"
style="width: 100%"
:multiple-limit="5"
>
<el-option
v-for="item in tagOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<div class="form-tips">最多可选择5个标签</div>
</el-form-item>
<el-form-item label="状态" v-if="formMode === 'edit'">
<el-radio-group v-model="form.status">
<el-radio
v-for="item in statusOptions"
:key="item.value"
:value="item.value"
>{{ item.label }}</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<style lang="scss" scoped>
.knowledge-management {
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
span {
font-size: 16px;
font-weight: 500;
}
.header-btns {
display: flex;
gap: 12px;
}
}
.search-form {
margin-bottom: 24px;
padding: 24px;
background-color: #f8f9fa;
border-radius: 8px;
:deep(.el-form-item) {
margin-bottom: 16px;
margin-right: 24px;
&:last-child {
margin-right: 0;
margin-bottom: 0;
}
}
:deep(.el-input),
:deep(.el-select) {
width: 220px;
}
.search-buttons {
display: flex;
gap: 12px;
}
}
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
.form-tips {
font-size: 12px;
color: #909399;
margin-top: 4px;
line-height: 1.4;
}
.mx-1 {
margin: 0 4px;
}
:deep(.el-select) {
width: 100%;
}
.el-tag {
margin: 2px 4px;
}
.image-input-container {
.mb-4 {
margin-bottom: 16px;
display: block;
}
.image-upload {
width: 200px;
height: 200px;
border: 1px dashed var(--el-border-color);
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
transition: var(--el-transition-duration);
&:hover {
border-color: var(--el-color-primary);
}
.upload-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
}
.image-upload-icon {
font-size: 28px;
color: #8c939d;
}
.upload-text {
color: #8c939d;
font-size: 14px;
margin-top: 8px;
}
.uploaded-image {
width: 100%;
height: 100%;
object-fit: cover;
}
}
}
.image-error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #909399;
.el-icon {
font-size: 24px;
margin-bottom: 8px;
}
}
.uploaded-image {
width: 100%;
height: 100%;
object-fit: cover;
}
}
</style>

View File

@ -0,0 +1,757 @@
<script setup>
import { ref, onMounted, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Search, Refresh } from '@element-plus/icons-vue'
import { formatDateTime } from '@/utils/format'
import {
getActivityList,
createActivity,
updateActivity,
cancelActivity,
batchCancelActivities,
updateActivityStatus,
checkActivityCapacity
} from '@/api/activity/study'
//
const formatDate = (date) => {
if (!date) return ''
const d = new Date(date)
const pad = (num) => (num < 10 ? `0${num}` : num)
const year = d.getFullYear()
const month = pad(d.getMonth() + 1)
const day = pad(d.getDate())
const hours = pad(d.getHours())
const minutes = pad(d.getMinutes())
const seconds = pad(d.getSeconds())
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
//
const searchForm = ref({
title: '',
category: '',
status: ''
})
//
const categoryOptions = [
{ label: '实地考察', value: 'field_study' },
{ label: '科普教育', value: 'science' },
{ label: '文化体验', value: 'culture' },
{ label: '环保实践', value: 'environment' }
]
//
const statusOptions = [
{ label: '报名中', value: 1 },
{ label: '进行中', value: 2 },
{ label: '已结束', value: 0 }
]
//
const tableData = ref([])
const allData = ref([]) //
const loading = ref(false)
const selectedRows = ref([])
//
const currentPage = ref(1)
const pageSize = ref(10)
const total = ref(0)
//
const getList = async () => {
loading.value = true
try {
const res = await getActivityList()
if (res.success) {
//
allData.value = res.data.map(item => ({
id: item.id,
title: item.title,
category: item.category,
start_time: item.start_time,
end_time: item.end_time,
location: item.location,
capacity: item.capacity,
enrolled: item.enrolled_count,
description: item.description,
requirements: item.requirements,
cost: item.cost,
status: item.status,
created_at: item.created_at,
updated_at: item.updated_at,
activity_code: item.activity_code
}))
//
filterAndPaginateData()
} else {
ElMessage.error(res.message || '获取研学活动列表失败')
}
} catch (error) {
console.error('获取研学活动列表错误:', error)
ElMessage.error('获取研学活动列表失败')
} finally {
loading.value = false
}
}
//
const filterAndPaginateData = () => {
// 1.
let filteredData = [...allData.value]
//
if (searchForm.value.title) {
const keyword = searchForm.value.title.toLowerCase()
filteredData = filteredData.filter(item =>
item.title.toLowerCase().includes(keyword)
)
}
//
if (searchForm.value.category) {
filteredData = filteredData.filter(item =>
item.category === searchForm.value.category
)
}
// -
if (searchForm.value.status !== '') {
filteredData = filteredData.filter(item =>
item.status === Number(searchForm.value.status)
)
}
// id
filteredData.sort((a, b) => a.id - b.id)
// 2.
total.value = filteredData.length
// 3.
const start = (currentPage.value - 1) * pageSize.value
const end = start + pageSize.value
tableData.value = filteredData.slice(start, end)
}
//
const handleSearch = () => {
currentPage.value = 1 //
filterAndPaginateData()
}
//
const resetSearch = () => {
searchForm.value = {
title: '',
category: '',
status: ''
}
currentPage.value = 1
filterAndPaginateData()
}
//
const handleClear = (field) => {
//
resetSearch()
}
//
watch([currentPage, pageSize], () => {
filterAndPaginateData()
})
//
watch(searchForm, (newVal, oldVal) => {
//
if (oldVal.status !== '' && newVal.status === '') {
//
currentPage.value = 1
//
filterAndPaginateData()
return
}
//
currentPage.value = 1
filterAndPaginateData()
}, { deep: true })
//
const handleRefresh = () => {
getList()
}
// /
const dialogVisible = ref(false)
const dialogTitle = ref('')
const formMode = ref('create')
const form = ref({
title: '',
category: 'field_study',
start_time: '',
end_time: '',
location: '',
capacity: 30,
description: '',
requirements: '',
cost: 50.00,
status: 1
})
//
const formRules = {
title: [
{ required: true, message: '请输入活动名称', trigger: 'blur' },
{ min: 2, max: 50, message: '长度在 2 到 50 个字符', trigger: 'blur' }
],
location: [
{ required: true, message: '请输入活动地点', trigger: 'blur' }
],
capacity: [
{ required: true, message: '请输入人数上限', trigger: 'blur' },
{ type: 'number', min: 1, message: '人数上限必须大于0', trigger: 'blur' }
],
start_time: [
{ required: true, message: '请选择开始时间', trigger: 'change' }
],
end_time: [
{ required: true, message: '请选择结束时间', trigger: 'change' }
],
cost: [
{ required: true, message: '请输入活动费用', trigger: 'blur' },
{ type: 'number', min: 0, message: '费用不能小于0', trigger: 'blur' }
]
}
//
const handleAdd = () => {
formMode.value = 'create'
dialogTitle.value = '新增研学活动'
form.value = {
title: '',
category: 'field_study',
start_time: '',
end_time: '',
location: '',
capacity: 30,
description: '',
requirements: '',
cost: 50.00,
status: 1
}
dialogVisible.value = true
}
//
const handleEdit = (row) => {
formMode.value = 'edit'
dialogTitle.value = '编辑研学活动'
form.value = {
...row,
category: row.category || 'field_study'
}
dialogVisible.value = true
}
//
const formRef = ref(null)
const handleSubmit = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (valid) {
try {
//
const submitData = {
title: form.value.title.trim(),
category: form.value.category,
start_time: form.value.start_time ? formatDate(new Date(form.value.start_time)) : '',
end_time: form.value.end_time ? formatDate(new Date(form.value.end_time)) : '',
location: form.value.location.trim(),
capacity: Number(form.value.capacity),
description: (form.value.description || '').trim(),
requirements: (form.value.requirements || '').trim(),
cost: Number(form.value.cost),
status: Number(form.value.status)
}
//
if (!submitData.title || !submitData.start_time || !submitData.end_time || !submitData.location) {
ElMessage.error('请填写必填字段')
return
}
//
const startTime = new Date(submitData.start_time)
const endTime = new Date(submitData.end_time)
const now = new Date()
//
if (endTime <= startTime) {
ElMessage.error('结束时间必须大于开始时间')
return
}
//
if (formMode.value === 'edit') {
//
const hoursUntilEnd = (endTime - now) / (1000 * 60 * 60)
// 2
if (endTime < now || hoursUntilEnd <= 2) {
ElMessage.warning(endTime < now ? '活动已结束,无法更新活动信息' : '距离活动结束不足2小时无法更新活动信息')
return
}
}
let res
if (formMode.value === 'create') {
res = await createActivity(submitData)
} else {
const id = form.value.id
res = await updateActivity(id, submitData)
}
if (res.success) {
ElMessage.success(`${formMode.value === 'create' ? '新增' : '编辑'}成功`)
dialogVisible.value = false
getList()
} else {
ElMessage.error(res.message || `${formMode.value === 'create' ? '新增' : '编辑'}失败`)
}
} catch (error) {
console.error(`${formMode.value === 'create' ? '新增' : '编辑'}研学活动错误:`, error)
ElMessage.error('操作失败,请检查输入是否正确')
}
}
})
}
//
const handleCancel = (row) => {
//
if (row.status === 0) {
ElMessage.warning('已结束的活动无法取消')
return
}
ElMessageBox.confirm('确认取消该研学活动吗?此操作不可恢复', '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const res = await cancelActivity(row.id)
if (res.success) {
ElMessage.success('取消成功')
getList()
} else {
ElMessage.error(res.message || '取消失败')
}
} catch (error) {
console.error('取消研学活动错误:', error)
ElMessage.error('取消失败')
}
}).catch(() => {})
}
//
const handleBatchCancel = () => {
if (selectedRows.value.length === 0) {
ElMessage.warning('请选择要取消的活动')
return
}
//
const endedActivities = selectedRows.value.filter(row => row.status === 0)
if (endedActivities.length > 0) {
ElMessage.warning('已结束的活动无法取消')
return
}
// ID
const cancelableActivities = selectedRows.value.filter(row => row.status !== 0)
if (cancelableActivities.length === 0) {
ElMessage.warning('所选活动都无法取消')
return
}
ElMessageBox.confirm(`确认取消选中的 ${cancelableActivities.length} 个研学活动吗?此操作不可恢复`, '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const ids = cancelableActivities.map(row => row.id)
const res = await batchCancelActivities(ids)
if (res.success) {
ElMessage.success('批量取消成功')
getList()
} else {
ElMessage.error(res.message || '批量取消失败')
}
} catch (error) {
console.error('批量取消研学活动错误:', error)
ElMessage.error('批量取消失败')
}
}).catch(() => {})
}
//
const handleStatusChange = async (row) => {
try {
const res = await updateActivityStatus(row.id, row.status)
if (res.success) {
ElMessage.success('状态更新成功')
getList()
} else {
ElMessage.error(res.message || '状态更新失败')
}
} catch (error) {
console.error('更新研学活动状态错误:', error)
ElMessage.error('状态更新失败')
}
}
//
const checkCapacity = async (row) => {
try {
const res = await checkActivityCapacity(row.id)
if (res.success) {
ElMessage.success(`当前报名人数:${res.data.currentParticipants}/${res.data.maxParticipants}`)
} else {
ElMessage.error(res.message || '检查容量失败')
}
} catch (error) {
console.error('检查研学活动容量错误:', error)
ElMessage.error('检查容量失败')
}
}
//
const handleSelectionChange = (rows) => {
selectedRows.value = rows
}
onMounted(() => {
getList()
})
</script>
<template>
<div class="study-management">
<el-card>
<template #header>
<div class="card-header">
<span>研学管理</span>
<div class="header-btns">
<el-button type="primary" :icon="Plus" @click="handleAdd">新增活动</el-button>
<el-button :icon="Refresh" @click="handleRefresh">刷新</el-button>
<el-button
v-if="selectedRows.length > 0"
type="danger"
@click="handleBatchCancel"
>批量取消</el-button>
</div>
</div>
</template>
<!-- 搜索表单 -->
<el-form :model="searchForm" inline class="search-form">
<el-form-item label="活动名称" label-width="80px">
<el-input
v-model="searchForm.title"
placeholder="请输入活动名称"
clearable
@clear="handleClear('title')"
/>
</el-form-item>
<el-form-item label="活动类型" label-width="80px">
<el-select
v-model="searchForm.category"
placeholder="请选择类型"
clearable
@clear="handleClear('category')"
>
<el-option
v-for="item in categoryOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="状态" label-width="80px">
<el-select
v-model="searchForm.status"
placeholder="请选择状态"
clearable
@clear="handleClear('status')"
>
<el-option
v-for="item in statusOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item class="search-buttons">
<el-button type="primary" :icon="Search" @click="handleSearch">搜索</el-button>
<el-button @click="resetSearch">重置</el-button>
</el-form-item>
</el-form>
<!-- 研学活动列表 -->
<el-table
v-loading="loading"
:data="tableData"
style="width: 100%"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" :selectable="row => row.status !== 0" width="55" align="center"/>
<el-table-column type="index" label="序号" width="60" align="center"/>
<el-table-column prop="title" label="活动名称" min-width="150" show-overflow-tooltip align="center"/>
<el-table-column prop="category" label="活动类型" width="120" align="center">
<template #default="{ row }">
<el-tag
:type="row.category === 'field_study' ? 'success' :
row.category === 'science' ? 'warning' :
row.category === 'culture' ? 'info' :
row.category === 'environment' ? 'danger' : ''"
>
{{ categoryOptions.find(item => item.value === row.category)?.label || '未知' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="capacity" label="人数上限" width="100" align="center"/>
<el-table-column prop="enrolled" label="已报名" width="100" align="center">
<template #default="{ row }">
<el-button type="primary" link @click="checkCapacity(row)">
{{ row.enrolled }}/{{ row.capacity }}
</el-button>
</template>
</el-table-column>
<el-table-column prop="location" label="活动地点" width="150" show-overflow-tooltip align="center"/>
<el-table-column prop="start_time" label="开始时间" width="160" align="center">
<template #default="{ row }">
{{ formatDateTime(row.start_time) }}
</template>
</el-table-column>
<el-table-column prop="end_time" label="结束时间" width="160" align="center">
<template #default="{ row }">
{{ formatDateTime(row.end_time) }}
</template>
</el-table-column>
<el-table-column prop="cost" label="费用" width="100" align="center">
<template #default="{ row }">
¥{{ row.cost }}
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' :
row.status === 2 ? 'warning' : 'info'">
{{ row.status === 1 ? '报名中' :
row.status === 2 ? '进行中' : '已结束' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="250" fixed="right" align="center">
<template #default="{ row }">
<el-button
type="primary"
link
@click="handleEdit(row)"
>编辑</el-button>
<el-button
v-if="row.status !== 0"
type="danger"
link
@click="handleCancel(row)"
>取消</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页器 -->
<div class="pagination-container">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
background
layout="total, sizes, prev, pager, next"
/>
</div>
</el-card>
<!-- 新增/编辑对话框 -->
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="800px"
destroy-on-close
>
<el-form
ref="formRef"
:model="form"
:rules="formRules"
label-width="100px"
>
<el-form-item label="活动名称" prop="title">
<el-input
v-model="form.title"
placeholder="请输入活动名称"
/>
</el-form-item>
<el-form-item label="活动类型" prop="category">
<el-select
v-model="form.category"
placeholder="请选择活动类型"
>
<el-option
v-for="item in categoryOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="活动地点" prop="location">
<el-input
v-model="form.location"
placeholder="请输入活动地点"
/>
</el-form-item>
<el-form-item label="活动时间" required>
<el-col :span="11">
<el-form-item prop="start_time">
<el-date-picker
v-model="form.start_time"
type="datetime"
placeholder="开始时间"
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :span="2" class="text-center">
<span class="text-gray-500">-</span>
</el-col>
<el-col :span="11">
<el-form-item prop="end_time">
<el-date-picker
v-model="form.end_time"
type="datetime"
placeholder="结束时间"
style="width: 100%"
/>
</el-form-item>
</el-col>
</el-form-item>
<el-form-item label="活动费用">
<el-input
v-model="form.cost"
placeholder="请输入活动费用"
/>
</el-form-item>
<el-form-item label="活动描述">
<el-input
v-model="form.description"
type="textarea"
:rows="4"
placeholder="请输入活动描述"
/>
</el-form-item>
<el-form-item label="报名要求">
<el-input
v-model="form.requirements"
type="textarea"
:rows="4"
placeholder="请输入报名要求"
/>
</el-form-item>
<el-form-item label="活动状态">
<el-select v-model="form.status">
<el-option
v-for="item in statusOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<style lang="scss" scoped>
.study-management {
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
span {
font-size: 16px;
font-weight: 500;
}
.header-btns {
display: flex;
gap: 12px;
}
}
.search-form {
margin-bottom: 24px;
padding: 24px;
background-color: #f8f9fa;
border-radius: 8px;
:deep(.el-form-item) {
margin-bottom: 16px;
margin-right: 24px;
&:last-child {
margin-right: 0;
margin-bottom: 0;
}
}
:deep(.el-input),
:deep(.el-select) {
width: 220px;
}
.search-buttons {
display: flex;
gap: 12px;
}
}
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
.text-center {
text-align: center;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,645 @@
<script setup>
import { ref, onMounted, computed, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Refresh } from '@element-plus/icons-vue'
import { formatDateTime } from '@/utils/format'
import { getFeedbackList, getFeedbackDetail, updateFeedbackStatus } from '@/api/feedback'
//
const feedbackTypeOptions = [
{ label: '设施损坏', value: 'facility_damage' },
{ label: '环境问题', value: 'environment_issue' },
{ label: '安全隐患', value: 'safety_hazard' },
{ label: '服务建议', value: 'service_suggestion' },
{ label: '其他', value: 'other' }
]
//
const searchForm = ref({
keyword: '',
status: '',
feedback_type: '',
date_range: []
})
//
const statusOptions = [
{ label: '待处理', value: 0 },
{ label: '处理中', value: 1 },
{ label: '已处理', value: 2 },
{ label: '已关闭', value: 3 }
]
//
const tableData = ref([])
const loading = ref(false)
const allData = ref([]) //
//
const filteredData = computed(() => {
let result = [...allData.value]
//
if (searchForm.value.keyword) {
const keyword = searchForm.value.keyword.toLowerCase()
result = result.filter(item =>
item.title?.toLowerCase().includes(keyword) ||
item.content?.toLowerCase().includes(keyword)
)
}
//
if (searchForm.value.feedback_type) {
result = result.filter(item =>
item.feedback_type === searchForm.value.feedback_type
)
}
//
if (searchForm.value.status !== '') {
result = result.filter(item =>
item.status === Number(searchForm.value.status)
)
}
//
if (searchForm.value.date_range?.length === 2) {
const startDate = new Date(searchForm.value.date_range[0])
const endDate = new Date(searchForm.value.date_range[1])
result = result.filter(item => {
const itemDate = new Date(item.created_at)
return itemDate >= startDate && itemDate <= endDate
})
}
return result
})
//
const currentPage = ref(1)
const pageSize = ref(10)
const total = ref(0)
//
const getList = async () => {
loading.value = true
try {
const res = await getFeedbackList()
if (res.success) {
//
allData.value = res.data.list.map(item => ({
...item,
image_urls: Array.isArray(item.image_urls) ? item.image_urls :
typeof item.image_urls === 'string' ?
(item.image_urls.startsWith('[') ? JSON.parse(item.image_urls) : [item.image_urls]) :
[]
}))
// 使
const filtered = filteredData.value
//
const start = (currentPage.value - 1) * pageSize.value
const end = start + pageSize.value
tableData.value = filtered.slice(start, end)
total.value = filtered.length
} else {
ElMessage.error(res.message || '获取列表失败')
allData.value = []
tableData.value = []
total.value = 0
}
} catch (error) {
console.error('获取意见反馈列表错误:', error)
ElMessage.error('获取列表失败')
allData.value = []
tableData.value = []
total.value = 0
} finally {
loading.value = false
}
}
//
const handleSizeChange = (val) => {
pageSize.value = val
currentPage.value = 1
const end = pageSize.value
tableData.value = filteredData.value.slice(0, end)
}
const handleCurrentChange = (val) => {
currentPage.value = val
const start = (val - 1) * pageSize.value
const end = start + pageSize.value
tableData.value = filteredData.value.slice(start, end)
}
//
const handleSearch = () => {
currentPage.value = 1
const filtered = filteredData.value
tableData.value = filtered.slice(0, pageSize.value)
total.value = filtered.length
}
//
const resetSearch = () => {
searchForm.value = {
keyword: '',
status: '',
feedback_type: '',
date_range: []
}
currentPage.value = 1
const filtered = filteredData.value
tableData.value = filtered.slice(0, pageSize.value)
total.value = filtered.length
}
//
watch([
() => searchForm.value.keyword,
() => searchForm.value.feedback_type,
() => searchForm.value.status,
() => searchForm.value.date_range
], () => {
currentPage.value = 1 //
const filtered = filteredData.value
tableData.value = filtered.slice(0, pageSize.value)
total.value = filtered.length
}, { deep: true })
//
const handleRefresh = () => {
getList()
}
//
const replyDialogVisible = ref(false)
const currentFeedback = ref(null)
const replyForm = ref({
reply: '',
status: 2 //
})
//
const handleReply = async (row) => {
try {
const res = await getFeedbackDetail(row.id)
if (res.success) {
currentFeedback.value = res.data
replyForm.value.reply = res.data.handling_result || ''
replyForm.value.status = res.data.status
replyDialogVisible.value = true
} else {
ElMessage.error(res.message || '获取反馈详情失败')
}
} catch (error) {
console.error('获取反馈详情错误:', error)
ElMessage.error('获取反馈详情失败')
}
}
//
const handleStatusChange = (newStatus) => {
// (2)(1)
if (currentFeedback.value.status === 2 && newStatus === 1) {
ElMessage.warning('已处理的反馈不能改为处理中状态')
replyForm.value.status = currentFeedback.value.status
return
}
replyForm.value.status = newStatus
}
//
const handleSubmitReply = async () => {
// ""
if (replyForm.value.status === 2 && !replyForm.value.reply.trim()) {
ElMessage.warning('请输入处理结果')
return
}
//
if (replyForm.value.status === currentFeedback.value.status &&
replyForm.value.reply === currentFeedback.value.handling_result) {
replyDialogVisible.value = false
return
}
try {
const res = await updateFeedbackStatus(
currentFeedback.value.id,
replyForm.value.status,
replyForm.value.status === 2 ? replyForm.value.reply : undefined
)
if (res.success) {
ElMessage.success('处理成功')
replyDialogVisible.value = false
getList()
} else {
ElMessage.error(res.message || '处理失败')
}
} catch (error) {
console.error('提交回复错误:', error)
ElMessage.error('处理失败')
}
}
//
const handleClose = (row) => {
ElMessageBox.confirm('确认关闭该反馈吗?', '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const res = await updateFeedbackStatus(row.id, 3) //
if (res.success) {
ElMessage.success('关闭成功')
getList()
} else {
ElMessage.error(res.message || '关闭失败')
}
} catch (error) {
console.error('关闭反馈错误:', error)
ElMessage.error('关闭失败')
}
}).catch(() => {})
}
//
const previewVisible = ref(false)
const previewImages = ref([])
const currentPreviewIndex = ref(0)
const handlePreviewImage = (urls, index = 0) => {
if (!urls || urls.length === 0) return
previewImages.value = typeof urls === 'string' ? JSON.parse(urls) : urls
currentPreviewIndex.value = index
previewVisible.value = true
}
onMounted(() => {
getList()
})
</script>
<template>
<div class="feedback-suggestions">
<el-card>
<template #header>
<div class="card-header">
<span>意见反馈</span>
<div class="header-btns">
<el-button :icon="Refresh" @click="handleRefresh">刷新</el-button>
</div>
</div>
</template>
<!-- 搜索表单 -->
<el-form :model="searchForm" inline class="search-form">
<el-form-item label="关键词" label-width="80px">
<el-input
v-model="searchForm.keyword"
placeholder="请输入标题或内容"
clearable
/>
</el-form-item>
<el-form-item label="反馈类型" label-width="80px">
<el-select
v-model="searchForm.feedback_type"
placeholder="请选择类型"
clearable
>
<el-option
v-for="item in feedbackTypeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="状态" label-width="80px">
<el-select
v-model="searchForm.status"
placeholder="请选择状态"
clearable
>
<el-option
v-for="item in statusOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="提交时间" label-width="80px">
<el-date-picker
v-model="searchForm.date_range"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
/>
</el-form-item>
<el-form-item class="search-buttons">
<el-button type="primary" :icon="Search" @click="handleSearch">搜索</el-button>
<el-button @click="resetSearch">重置</el-button>
</el-form-item>
</el-form>
<!-- 反馈列表 -->
<el-table
v-loading="loading"
:data="tableData"
style="width: 100%"
>
<el-table-column type="index" label="序号" width="60" align="center"/>
<el-table-column prop="feedback_type" label="反馈类型" width="120" align="center">
</el-table-column>
<el-table-column prop="title" label="标题" min-width="150" show-overflow-tooltip/>
<el-table-column prop="content" label="反馈内容" min-width="200" show-overflow-tooltip/>
<el-table-column prop="location_description" label="位置" width="150" show-overflow-tooltip/>
<el-table-column label="图片" width="100" align="center">
<template #default="{ row }">
<el-button
v-if="row.image_urls && row.image_urls.length > 0"
type="primary"
link
@click="handlePreviewImage(row.image_urls)"
>
查看图片({{ row.image_urls.length }})
</el-button>
</template>
</el-table-column>
<el-table-column prop="user_name" label="提交人" width="120" align="center"/>
<el-table-column prop="status" label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag
:type="row.status === 0 ? 'info' :
row.status === 1 ? 'warning' :
row.status === 2 ? 'success' :
'danger'"
>
{{ statusOptions.find(item => item.value === row.status)?.label }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="handler_name" label="处理人" width="120" align="center"/>
<el-table-column prop="handling_time" label="处理时间" width="160" align="center">
<template #default="{ row }">
{{ row.handling_time ? formatDateTime(row.handling_time) : '-' }}
</template>
</el-table-column>
<el-table-column prop="created_at" label="提交时间" width="160" align="center">
<template #default="{ row }">
{{ formatDateTime(row.created_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right" align="center">
<template #default="{ row }">
<el-button
v-if="row.status !== 3"
type="primary"
link
@click="handleReply(row)"
>{{ row.handling_result ? '修改处理' : '处理' }}</el-button>
<el-button
v-if="row.status !== 3"
type="danger"
link
@click="handleClose(row)"
>关闭</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页器 -->
<div class="pagination-container">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
background
layout="total, sizes, prev, pager, next"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</el-card>
<!-- 处理对话框 -->
<el-dialog
v-model="replyDialogVisible"
title="处理反馈"
width="800px"
destroy-on-close
>
<div class="feedback-detail">
<div class="detail-row">
<div class="detail-item">
<strong>反馈标题</strong>
<span>{{ currentFeedback?.title }}</span>
</div>
<div class="detail-item">
<strong>反馈类型</strong>
<span>{{ feedbackTypeOptions.find(item => item.value === currentFeedback?.feedback_type)?.label }}</span>
</div>
</div>
<div class="detail-row">
<div class="detail-item">
<strong>提交人</strong>
<span>{{ currentFeedback?.user_name }}</span>
</div>
<div class="detail-item">
<strong>提交时间</strong>
<span>{{ formatDateTime(currentFeedback?.created_at) }}</span>
</div>
</div>
<div class="detail-row">
<div class="detail-item full-width">
<strong>反馈内容</strong>
<span>{{ currentFeedback?.content }}</span>
</div>
</div>
<div class="detail-row">
<div class="detail-item full-width">
<strong>位置信息</strong>
<span>{{ currentFeedback?.location_description }}</span>
</div>
</div>
<div v-if="currentFeedback?.image_urls && currentFeedback.image_urls.length > 0" class="detail-row">
<div class="detail-item full-width">
<strong>相关图片</strong>
<div class="image-list">
<el-image
v-for="(url, index) in currentFeedback.image_urls"
:key="index"
:src="url"
:preview-src-list="currentFeedback.image_urls"
fit="cover"
class="feedback-image"
/>
</div>
</div>
</div>
</div>
<el-form :model="replyForm" label-width="100px">
<el-form-item label="处理状态" required>
<el-radio-group v-model="replyForm.status" @change="handleStatusChange">
<el-radio :value="1" :disabled="currentFeedback?.status === 2">处理中</el-radio>
<el-radio :value="2">已处理</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="处理结果" required>
<el-input
v-model="replyForm.reply"
type="textarea"
:rows="4"
placeholder="请输入处理结果"
/>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="replyDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmitReply">确定</el-button>
</span>
</template>
</el-dialog>
<!-- 图片预览 -->
<el-dialog v-model="previewVisible" title="图片预览" width="800px">
<div class="preview-container">
<el-carousel
v-if="previewImages.length > 0"
:initial-index="currentPreviewIndex"
height="400px"
indicator-position="outside"
>
<el-carousel-item v-for="(url, index) in previewImages" :key="index">
<el-image
:src="url"
fit="contain"
class="preview-image"
/>
</el-carousel-item>
</el-carousel>
</div>
</el-dialog>
</div>
</template>
<style lang="scss" scoped>
.feedback-suggestions {
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
span {
font-size: 16px;
font-weight: 500;
}
.header-btns {
display: flex;
gap: 12px;
}
}
.search-form {
margin-bottom: 24px;
padding: 24px;
background-color: #f8f9fa;
border-radius: 8px;
:deep(.el-form-item) {
margin-bottom: 16px;
margin-right: 24px;
&:last-child {
margin-right: 0;
margin-bottom: 0;
}
}
:deep(.el-input),
:deep(.el-select) {
width: 220px;
}
.search-buttons {
display: flex;
gap: 12px;
}
}
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
.feedback-detail {
margin-bottom: 20px;
padding: 16px;
background-color: #f8f9fa;
border-radius: 4px;
.detail-row {
display: flex;
margin-bottom: 16px;
&:last-child {
margin-bottom: 0;
}
.detail-item {
flex: 1;
display: flex;
align-items: flex-start;
&.full-width {
flex: 0 0 100%;
}
strong {
margin-right: 8px;
color: #606266;
white-space: nowrap;
}
span {
flex: 1;
line-height: 1.5;
}
}
}
.image-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
.feedback-image {
width: 120px;
height: 120px;
border-radius: 4px;
cursor: pointer;
}
}
}
.preview-container {
.preview-image {
width: 100%;
height: 400px;
}
}
}
</style>

View File

@ -66,9 +66,19 @@ const handleLogin = async (formEl) => {
duration: 2000
});
//
//
await new Promise(resolve => setTimeout(resolve, 100));
//
const redirect = route.query.redirect;
router.push(redirect || "/dashboard");
try {
// 使replacepush
await router.replace(redirect || "/dashboard");
} catch (navigationError) {
console.error('导航错误:', navigationError);
//
window.location.replace(redirect || "/dashboard");
}
} else {
//
systemLogStore.addLog({

View File

@ -0,0 +1,16 @@
<script setup>
</script>
<template>
<div class="event-container">
<!-- 搜索区域 -->
<div class="search-container">
</div>
</div>
</template>
<style>
</style>

View File

@ -318,7 +318,7 @@ const pointTypes = [
</template>
<style lang="scss" scoped>
@use "./styles/variables" as v;
@use "../../../styles/variables.scss" as *;
.point-container {
.el-card {

View File

@ -231,7 +231,7 @@ const handleCurrentChange = (val) => {
</template>
<style lang="scss" scoped>
@use "./styles/variables" as v;
@use "../../../styles/variables.scss" as *;
.record-container {
.el-card {

View File

@ -372,10 +372,13 @@ const resetFilters = () => {
<!-- 更新表格数据源 -->
<el-table :data="filteredTableData" style="width: 100%; margin-top: 20px">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="title" label="任务名称" min-width="150" />
<el-table-column prop="area" label="巡护区域" min-width="120" />
<el-table-column prop="assignee" label="负责人" width="100" />
<el-table-column prop="lan_id" label="巡护计划ID" width="100" />
<el-table-column prop="task_name" label="任务名称" min-width="150" />
<el-table-column prop="task_type" label="任务类型" min-width="150" />
<el-table-column prop="patrol_date" label="任务巡护日" min-width="150" />
<el-table-column prop="start_time" label="开始时间" min-width="150" />
<el-table-column prop="end_time" label="结束时间" min-width="150" />
<el-table-column prop="executor_ids" label="执行人ID列表" min-width="120" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="getStatusType(row.status)">
@ -390,9 +393,6 @@ const resetFilters = () => {
</el-tag>
</template>
</el-table-column>
<el-table-column label="执行时间" min-width="300">
<template #default="{ row }"> {{ row.startTime }} {{ row.endTime }} </template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button type="primary" size="small" @click="handleView(row)">
@ -527,7 +527,7 @@ const resetFilters = () => {
</template>
<style lang="scss" scoped>
@use "./styles/variables" as v;
@use "../../../styles/variables.scss" as *;
.task-container {
.el-card {

View File

@ -0,0 +1,549 @@
<!-- 轮播图管理 -->
<script setup>
import { ref, onMounted, watch, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Search, Refresh, Delete } from '@element-plus/icons-vue'
import { formatDateTime } from '@/utils/format'
import {
getCarouselList,
createCarousel,
updateCarousel,
deleteCarousel,
batchUpdateCarouselSort
} from '@/api/system/carousel'
//
const uploadHeaders = computed(() => {
const token = localStorage.getItem('token')
return {
Authorization: token ? `Bearer ${token}` : ''
}
})
//
const tableData = ref([])
const allData = ref([]) //
const loading = ref(false)
//
const currentPage = ref(1)
const pageSize = ref(10)
const total = ref(0)
//
const searchForm = ref({
title: '',
})
//
const dialogVisible = ref(false)
const dialogTitle = ref('新增轮播图')
const formMode = ref('create')
const form = ref({
title: '',
image_url: '',
link_url: '',
sort: 0,
imageInputType: 'url'
})
//
const formRules = {
title: [
{ required: true, message: '请输入轮播图标题', trigger: 'blur' },
{ min: 2, max: 50, message: '长度在 2 到 50 个字符', trigger: 'blur' }
],
image_url: [
{ required: true, message: '请上传轮播图片', trigger: 'change' }
],
sort: [
{ required: true, message: '请输入排序号', trigger: 'blur' },
{ type: 'number', message: '排序号必须为数字', trigger: 'blur' }
]
}
const formRef = ref(null)
//
const previewVisible = ref(false)
const previewImage = ref('')
//
const handlePreview = (url) => {
previewImage.value = url
previewVisible.value = true
}
//
const getList = async () => {
loading.value = true
try {
const res = await getCarouselList()
if (res.success) {
//
allData.value = res.data.list
filterAndPaginateData()
} else {
ElMessage.error(res.message || '获取列表失败')
}
} catch (error) {
console.error('获取轮播图列表错误:', error)
ElMessage.error('获取列表失败')
} finally {
loading.value = false
}
}
//
const filterAndPaginateData = () => {
// 1.
let filteredData = [...allData.value]
//
if (searchForm.value.title) {
const keyword = searchForm.value.title.toLowerCase()
filteredData = filteredData.filter(item =>
item.title.toLowerCase().includes(keyword)
)
}
// 2.
total.value = filteredData.length
// 3.
const start = (currentPage.value - 1) * pageSize.value
const end = start + pageSize.value
tableData.value = filteredData.slice(start, end)
}
//
const handleSearch = () => {
currentPage.value = 1 //
filterAndPaginateData()
}
//
const resetSearch = () => {
searchForm.value = {
title: ''
}
currentPage.value = 1
filterAndPaginateData()
}
//
const handleRefresh = () => {
getList()
}
//
const handleAdd = () => {
formMode.value = 'create'
dialogTitle.value = '新增轮播图'
form.value = {
title: '',
image_url: '',
link_url: '',
sort: 0,
imageInputType: 'url'
}
dialogVisible.value = true
}
//
const handleEdit = (row) => {
formMode.value = 'edit'
dialogTitle.value = '编辑轮播图'
form.value = {
id: row.id,
title: row.title,
image_url: row.image_url,
link_url: row.link_url,
sort: row.sort_order,
imageInputType: row.image_url?.startsWith('http') ? 'url' : 'upload'
}
dialogVisible.value = true
}
//
const handleSubmit = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (valid) {
try {
const submitData = {
title: form.value.title.trim(),
image_url: form.value.image_url,
link_url: form.value.link_url.trim(),
sort_order: Number(form.value.sort)
}
let res
if (formMode.value === 'create') {
res = await createCarousel(submitData)
} else {
res = await updateCarousel(form.value.id, submitData)
}
if (res.success) {
ElMessage.success(`${formMode.value === 'create' ? '新增' : '编辑'}成功`)
dialogVisible.value = false
getList()
} else {
ElMessage.error(res.message || `${formMode.value === 'create' ? '新增' : '编辑'}失败`)
}
} catch (error) {
console.error(`${formMode.value === 'create' ? '新增' : '编辑'}轮播图错误:`, error)
ElMessage.error('操作失败,请检查输入是否正确')
}
}
})
}
//
const handleDelete = (row) => {
ElMessageBox.confirm('确认删除该轮播图吗?', '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const res = await deleteCarousel(row.id)
if (res.success) {
ElMessage.success('删除成功')
getList()
} else {
ElMessage.error(res.message || '删除失败')
}
} catch (error) {
console.error('删除轮播图错误:', error)
ElMessage.error('删除失败')
}
}).catch(() => {})
}
//
const handleImageTypeChange = () => {
if (formMode.value === 'create') {
form.value.image_url = ''
}
}
//
const handleUploadSuccess = (response) => {
console.log('上传响应:', response) //
try {
if (response.success) {
// URL
const imageUrl = response.data?.url || response.data?.image_url || response.url
if (!imageUrl) {
ElMessage.error('上传成功但未获取到图片地址')
return
}
form.value.image_url = imageUrl.startsWith('http') ? imageUrl : `http://localhost:3000${imageUrl}`
ElMessage.success('图片上传成功')
} else {
ElMessage.error(response.message || '图片上传失败')
}
} catch (error) {
console.error('处理上传响应错误:', error)
ElMessage.error('处理上传响应失败')
}
}
//
const handleUploadError = (error) => {
console.error('上传图片错误:', error)
let errorMessage = '上传失败'
try {
if (typeof error.message === 'string') {
const errorData = JSON.parse(error.message)
errorMessage = errorData.message || '上传失败'
}
} catch (e) {
errorMessage = error.message || '上传失败'
}
ElMessage.error(`上传失败: ${errorMessage}`)
}
//
const beforeUpload = (file) => {
const isImage = file.type.startsWith('image/')
const isLt2M = file.size / 1024 / 1024 < 2
if (!isImage) {
ElMessage.error('只能上传图片文件!')
return false
}
if (!isLt2M) {
ElMessage.error('图片大小不能超过 2MB')
return false
}
return true
}
//
watch([currentPage, pageSize], () => {
filterAndPaginateData()
})
onMounted(() => {
getList()
})
</script>
<template>
<div class="carousel-manage">
<el-card>
<template #header>
<div class="card-header">
<span>轮播图管理</span>
<div class="header-btns">
<el-button type="primary" :icon="Plus" @click="handleAdd">新增轮播图</el-button>
<el-button :icon="Refresh" @click="handleRefresh">刷新</el-button>
</div>
</div>
</template>
<!-- 搜索表单 -->
<el-form :model="searchForm" inline class="search-form" @submit.prevent>
<el-form-item label="标题" label-width="80px">
<el-input
v-model="searchForm.title"
placeholder="请输入标题"
@keyup.enter="handleSearch"
/>
</el-form-item>
<el-form-item class="search-buttons">
<el-button type="primary" :icon="Search" @click="handleSearch">搜索</el-button>
<el-button @click="resetSearch">重置</el-button>
</el-form-item>
</el-form>
<!-- 轮播图列表 -->
<el-table
v-loading="loading"
:data="tableData"
style="width: 100%"
>
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column prop="title" label="标题" min-width="150" show-overflow-tooltip align="center"/>
<el-table-column prop="image_url" label="图片" width="120" align="center">
<template #default="{ row }">
<el-image
:src="row.image_url"
fit="cover"
style="width: 80px; height: 45px; cursor: pointer"
:preview-src-list="[row.image_url]"
:initial-index="0"
crossorigin="anonymous"
preview-teleported
/>
</template>
</el-table-column>
<el-table-column prop="link_url" label="跳转链接" min-width="200" show-overflow-tooltip align="center"/>
<el-table-column prop="sort_order" label="排序" width="100" align="center">
<template #default="{ row }">
{{ row.sort_order || 0 }}
</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right" align="center">
<template #default="{ row }">
<el-button
type="primary"
link
@click="handleEdit(row)"
>编辑</el-button>
<el-button
type="danger"
link
@click="handleDelete(row)"
>删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页器 -->
<div class="pagination-container">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
background
layout="total, sizes, prev, pager, next"
/>
</div>
</el-card>
<!-- 新增/编辑对话框 -->
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="600px"
>
<el-form
ref="formRef"
:model="form"
:rules="formRules"
label-width="100px"
>
<el-form-item label="标题" prop="title">
<el-input v-model="form.title" placeholder="请输入轮播图标题" />
</el-form-item>
<el-form-item label="图片" prop="image_url">
<el-radio-group v-model="form.imageInputType" class="mb-4" @change="handleImageTypeChange">
<el-radio :value="'url'">输入图片地址</el-radio>
<el-radio :value="'upload'">上传图片</el-radio>
</el-radio-group>
<template v-if="form.imageInputType === 'url'">
<el-input
v-model="form.image_url"
placeholder="请输入图片URL地址"
/>
</template>
<template v-else>
<el-upload
class="carousel-uploader"
action="http://localhost:3000/api/admin/carousels/upload"
:headers="uploadHeaders"
:show-file-list="false"
:on-success="handleUploadSuccess"
:on-error="handleUploadError"
:before-upload="beforeUpload"
name="image"
accept="image/*"
>
<img v-if="form.image_url" :src="form.image_url" class="carousel-image" crossorigin="anonymous">
<el-icon v-else class="carousel-uploader-icon"><Plus /></el-icon>
</el-upload>
</template>
<div class="upload-tip">建议尺寸1920x540px大小不超过2MB</div>
</el-form-item>
<el-form-item label="跳转链接" prop="link_url">
<el-input v-model="form.link_url" placeholder="请输入跳转链接" />
</el-form-item>
<el-form-item label="排序" prop="sort">
<el-input-number
v-model="form.sort"
:min="0"
:max="999"
placeholder="请输入排序号"
style="width: 180px"
/>
<div class="form-tip">数字越小越靠前</div>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<style lang="scss" scoped>
.carousel-manage {
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
span {
font-size: 16px;
font-weight: 500;
}
.header-btns {
display: flex;
gap: 12px;
}
}
.search-form {
margin-bottom: 24px;
padding: 24px;
background-color: #f8f9fa;
border-radius: 8px;
:deep(.el-form-item) {
margin-bottom: 16px;
margin-right: 24px;
&:last-child {
margin-right: 0;
margin-bottom: 0;
}
}
:deep(.el-input),
:deep(.el-select) {
width: 220px;
}
.search-buttons {
display: flex;
gap: 12px;
}
}
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
.carousel-uploader {
:deep(.el-upload) {
border: 1px dashed #d9d9d9;
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
transition: var(--el-transition-duration-fast);
&:hover {
border-color: var(--el-color-primary);
}
}
.carousel-image {
width: 360px;
height: 120px;
display: block;
object-fit: cover;
}
.carousel-uploader-icon {
font-size: 28px;
color: #8c939d;
width: 360px;
height: 120px;
text-align: center;
line-height: 120px;
}
}
.upload-tip {
font-size: 12px;
color: #909399;
margin-top: 8px;
}
.mb-4 {
margin-bottom: 16px;
display: block;
}
.form-tip {
font-size: 12px;
color: #909399;
margin-top: 8px;
}
}
</style>