2025-03-08 15:42:36 +08:00

757 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup>
import { ref, reactive, onMounted, onUnmounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, PictureFilled, Delete } from '@element-plus/icons-vue'
import * as echarts from 'echarts'
import { sortArrayByField, reverseArray } from '@/utils/sort'
import { getSpeciesList, addSpecies, updateSpecies, getSpeciesStatistics, updateSpeciesStatus } from '@/api/monitor/species'
import { ElImageViewer } from 'element-plus'
// 查询参数
const queryParams = reactive({
page: 1,
page_size: 10,
species_code: '',
chinese_name: '',
category: undefined,
protection_level: undefined,
status: undefined
})
// 数据列表
const loading = ref(false)
const speciesList = ref([])
const pagination = reactive({
total: 0,
page: 1,
pageSize: 10
})
// 物种类别选项
const categoryOptions = [
{ label: '鸟类', value: 'bird' },
{ label: '哺乳类', value: 'mammal' },
{ label: '鱼类', value: 'fish' },
{ label: '两栖类', value: 'amphibian' },
{ label: '爬行类', value: 'reptile' },
{ label: '昆虫类', value: 'insect' },
{ label: '植物', value: 'plant' }
]
// 保护等级选项
const protectionLevelOptions = [
{ label: '国家一级', value: 'national_first' },
{ label: '国家二级', value: 'national_second' },
{ label: '省级', value: 'provincial' },
{ label: '普通', value: 'normal' }
]
// 弹窗显示控制
const dialogVisible = ref(false)
const dialogType = ref('add') // 'add' 或 'edit'
const dialogTitle = ref('添加物种')
// 表单数据
const formRef = ref()
const formData = ref({
chinese_name: '',
latin_name: '',
category: '',
protection_level: 'national_first',
characteristics: '',
habits: '',
distribution: '',
image_urls: [],
images: [],
fileList: [],
status: 1
})
// 表单校验规则
const rules = {
chinese_name: [
{ required: true, message: '请输入中文名称', trigger: 'blur' },
{ max: 100, message: '长度不能超过100个字符', trigger: 'blur' }
],
latin_name: [
{ required: true, message: '请输入拉丁名称', trigger: 'blur' }
],
category: [
{ required: true, message: '请选择物种类别', trigger: 'change' }
],
protection_level: [
{ required: true, message: '请选择保护等级', trigger: 'change' }
],
characteristics: [
{ required: true, message: '请输入特征描述', trigger: 'blur' }
],
habits: [
{ required: true, message: '请输入生活习性', trigger: 'blur' }
],
distribution: [
{ required: true, message: '请输入分布区域', trigger: 'blur' }
]
}
// 获取列表数据
const getList = async () => {
loading.value = true
try {
// 处理查询参数,移除中文名称,因为我们将在前端过滤
const params = {
...queryParams,
category: queryParams.category || undefined,
protection_level: queryParams.protection_level || undefined,
status: queryParams.status === '' ? undefined : queryParams.status
}
// 移除所有 undefined 的参数和中文名称参数
delete params.chinese_name
Object.keys(params).forEach(key =>
params[key] === undefined && delete params[key]
)
const res = await getSpeciesList(params)
if (res.success && res.data) {
// 确保数据是数组
let list = Array.isArray(res.data.list) ? [...res.data.list] : []
// 如果有中文名称搜索条件,在前端进行过滤
const searchText = queryParams.chinese_name?.trim().toLowerCase()
if (searchText) {
list = list.filter(item =>
item.chinese_name?.toLowerCase().includes(searchText)
)
}
// 反转数组顺序
speciesList.value = reverseArray(list)
// 更新分页信息
if (res.data.pagination) {
// 使用过滤后的数据长度作为总数
pagination.total = searchText ? list.length : Number(res.data.pagination.total) || 0
pagination.page = Number(res.data.pagination.current) || 1
pagination.pageSize = Number(res.data.pagination.page_size) || 10
}
} else {
speciesList.value = []
ElMessage.error(res.message || '获取数据失败')
}
} catch (error) {
console.error('获取物种列表失败:', error)
speciesList.value = []
ElMessage.error('获取物种列表失败')
} finally {
loading.value = false
}
}
// 重置查询
const resetQuery = () => {
queryParams.species_code = ''
queryParams.chinese_name = ''
queryParams.category = undefined
queryParams.protection_level = undefined
queryParams.status = undefined
getList()
}
// 处理添加/编辑
const handleAddOrEdit = (type, row) => {
dialogType.value = type
dialogTitle.value = type === 'add' ? '添加物种' : '编辑物种'
dialogVisible.value = true
if (type === 'edit' && row) {
formData.value = { ...row }
// 为已有图片创建文件列表
const fileList = []
if (row.image_urls && Array.isArray(row.image_urls)) {
row.image_urls.forEach((url, index) => {
const fullUrl = getFullImageUrl(url)
fileList.push({
uid: `-${index}`, // 添加唯一标识
name: url.split('/').pop(),
url: fullUrl,
status: 'success',
response: { url: fullUrl }
})
})
}
formData.value.fileList = fileList
formData.value.image_urls = row.image_urls || []
formData.value.images = []
} else {
formData.value = {
chinese_name: '',
latin_name: '',
category: '',
protection_level: 'national_first',
characteristics: '',
habits: '',
distribution: '',
image_urls: [],
images: [],
fileList: [],
status: 1
}
}
}
// 处理文件上传
const handleFileUpload = (uploadFile) => {
const file = uploadFile.raw || uploadFile
if (!file) return
// 验证文件类型
const isImage = file.type.startsWith('image/')
if (!isImage) {
ElMessage.error('只能上传图片文件!')
return false
}
// 验证文件大小5MB
const isLt5M = file.size / 1024 / 1024 < 5
if (!isLt5M) {
ElMessage.error('图片大小不能超过 5MB')
return false
}
// 添加到图片列表
if (!formData.value.images.some(f => f.uid === file.uid)) {
formData.value.images.push(file)
}
return true
}
// 处理文件移除
const handleFileRemove = (uploadFile) => {
// 如果是新上传的文件从images中移除
const imageIndex = formData.value.images.findIndex(file => file.uid === uploadFile.uid)
if (imageIndex !== -1) {
formData.value.images.splice(imageIndex, 1)
}
// 如果是已有的图片从image_urls中移除
if (uploadFile.url) {
const urlIndex = formData.value.image_urls.findIndex(url => getFullImageUrl(url) === uploadFile.url)
if (urlIndex !== -1) {
formData.value.image_urls.splice(urlIndex, 1)
}
}
}
// 提交表单
const submitForm = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (valid) {
try {
// 创建 FormData 对象
const formDataObj = new FormData()
// 添加基本字段
formDataObj.append('chinese_name', formData.value.chinese_name)
formDataObj.append('latin_name', formData.value.latin_name)
formDataObj.append('category', formData.value.category)
formDataObj.append('protection_level', formData.value.protection_level)
formDataObj.append('characteristics', formData.value.characteristics)
formDataObj.append('habits', formData.value.habits)
formDataObj.append('distribution', formData.value.distribution)
formDataObj.append('status', formData.value.status)
// 添加已有的图片URL
formData.value.image_urls.forEach(url => {
formDataObj.append('existing_image_urls', url)
})
// 添加新上传的图片文件
formData.value.images.forEach((file) => {
formDataObj.append('image_urls', file)
})
// 调用API
const api = dialogType.value === 'add' ? addSpecies : (id) => updateSpecies(formData.value.id, formDataObj)
await api(formDataObj)
ElMessage.success(dialogType.value === 'add' ? '添加成功' : '修改成功')
dialogVisible.value = false
getList()
} catch (error) {
console.error('提交失败:', error)
ElMessage.error('提交失败')
}
}
})
}
// 获取统计信息
const getStatistics = async () => {
try {
const res = await getSpeciesStatistics()
statistics.value = res.data
} catch (error) {
console.error('获取统计信息失败:', error)
}
}
// 处理状态更新
const handleStatusChange = async (row) => {
const newStatus = row.status === 1 ? 0 : 1
try {
await updateSpeciesStatus(row.id, { status: newStatus })
ElMessage.success('状态更新成功')
getList()
} catch (error) {
console.error('状态更新失败:', error)
ElMessage.error('状态更新失败')
}
}
// 处理页码改变
const handleCurrentChange = (val) => {
queryParams.page = Number(val)
getList()
}
// 处理每页条数改变
const handleSizeChange = (val) => {
queryParams.page_size = Number(val)
queryParams.page = 1
getList()
}
// 添加排序处理函数
const handleSortChange = ({ prop, order }) => {
if (prop && order) {
const sortOrder = order === 'ascending' ? 'asc' : 'desc'
const sortedList = sortArrayByField(speciesList.value, prop, sortOrder)
speciesList.value = sortedList
}
}
// 基础URL
const baseUrl = computed(() => import.meta.env.VITE_API_BASE_URL || '')
// 获取完整的图片URL
const getFullImageUrl = (url) => {
if (!url) return ''
if (url.startsWith('http')) return url
if (url.startsWith('data:')) return url
if (url.startsWith('blob:')) return url
// 移除URL开头的斜杠避免重复
const cleanUrl = url.startsWith('/') ? url.slice(1) : url
// 确保不会重复添加 uploads 路径
if (cleanUrl.startsWith('uploads/')) {
return `${baseUrl.value}/${cleanUrl}`
}
return `${baseUrl.value}/uploads/${cleanUrl}`
}
onMounted(() => {
getList()
getStatistics()
})
onUnmounted(() => {
// 不再需要清理图表实例
})
</script>
<template>
<div class="app-container">
<!-- 搜索区域 -->
<el-card class="search-container">
<el-form :model="queryParams" ref="queryForm" :inline="true">
<el-form-item label="中文名称" prop="chinese_name">
<el-input
v-model="queryParams.chinese_name"
placeholder="请输入中文名称"
clearable
style="width: 200px"
/>
</el-form-item>
<el-form-item label="物种类别" prop="category">
<el-select
v-model="queryParams.category"
placeholder="请选择物种类别"
clearable
style="width: 200px"
>
<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="protection_level">
<el-select
v-model="queryParams.protection_level"
placeholder="请选择保护等级"
clearable
style="width: 200px"
>
<el-option
v-for="item in protectionLevelOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="getList">查询</el-button>
<el-button @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 操作按钮区域 -->
<el-card class="table-container">
<template #header>
<el-button type="primary" @click="handleAddOrEdit('add')">添加物种</el-button>
</template>
<!-- 表格区域 -->
<el-table
v-loading="loading"
:data="speciesList"
border
style="width: 100%"
row-key="id"
>
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column prop="species_code" label="物种编号" width="120" align="center" />
<el-table-column prop="chinese_name" label="中文名称" width="150" show-overflow-tooltip align="center"/>
<el-table-column prop="latin_name" label="拉丁名称" width="180" show-overflow-tooltip align="center"/>
<el-table-column prop="category" label="物种类别" width="100" align="center">
<template #default="{ row }">
{{ categoryOptions.find(item => item.value === row.category)?.label || '-' }}
</template>
</el-table-column>
<el-table-column prop="protection_level" label="保护等级" width="120" align="center">
<template #default="{ row }">
{{ protectionLevelOptions.find(item => item.value === row.protection_level)?.label || '-' }}
</template>
</el-table-column>
<el-table-column prop="characteristics" label="特征描述" show-overflow-tooltip align="center"/>
<el-table-column label="图片" width="100" align="center">
<template #default="{ row }">
<el-image
v-if="row.image_urls && row.image_urls.length > 0"
:src="getFullImageUrl(row.image_urls[0])"
:preview-src-list="row.image_urls.map(url => getFullImageUrl(url))"
preview-teleported
:initial-index="0"
fit="cover"
class="species-image"
loading="lazy"
:hide-on-click-modal="false"
crossorigin="anonymous"
referrerpolicy="no-referrer"
>
<template #error>
<div class="image-error">
<el-icon><picture-filled /></el-icon>
</div>
</template>
</el-image>
<div v-else class="no-image">
<el-icon><picture-filled /></el-icon>
</div>
</template>
</el-table-column>
<el-table-column label="状态" width="80" 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 label="操作" width="120" fixed="right" align="center">
<template #default="{ row }">
<el-button type="primary" link @click="handleAddOrEdit('edit', row)">编辑</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-container">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="pagination.total"
:background="true"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</el-card>
<!-- 添加/编辑弹窗 -->
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="700px"
append-to-body
>
<el-form
ref="formRef"
:model="formData"
:rules="rules"
label-width="100px"
>
<el-form-item label="中文名称" prop="chinese_name">
<el-input v-model="formData.chinese_name" placeholder="请输入中文名称" />
</el-form-item>
<el-form-item label="拉丁名称" prop="latin_name">
<el-input v-model="formData.latin_name" placeholder="请输入拉丁名称" />
</el-form-item>
<el-form-item label="物种类别" prop="category">
<el-select v-model="formData.category" placeholder="请选择物种类别" style="width: 100%">
<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="protection_level">
<el-select v-model="formData.protection_level" placeholder="请选择保护等级" style="width: 100%">
<el-option
v-for="item in protectionLevelOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="特征描述" prop="characteristics">
<el-input
v-model="formData.characteristics"
type="textarea"
:rows="3"
placeholder="请输入特征描述"
/>
</el-form-item>
<el-form-item label="生活习性" prop="habits">
<el-input
v-model="formData.habits"
type="textarea"
:rows="3"
placeholder="请输入生活习性"
/>
</el-form-item>
<el-form-item label="分布区域" prop="distribution">
<el-input
v-model="formData.distribution"
type="textarea"
:rows="3"
placeholder="请输入分布区域"
/>
</el-form-item>
<el-form-item label="图片" prop="image_urls">
<el-upload
action="#"
list-type="picture-card"
:auto-upload="false"
:on-change="handleFileUpload"
:on-remove="handleFileRemove"
:file-list="formData.fileList"
accept=".jpg,.jpeg,.png"
:multiple="true"
:before-upload="() => false"
:http-request="() => {}"
>
<el-icon><Plus /></el-icon>
<template #file="{ file }">
<div class="upload-file-card">
<img
:src="file.url"
class="upload-file-image"
crossorigin="anonymous"
referrerpolicy="no-referrer"
/>
<span class="upload-file-actions">
<el-icon @click.stop="handleFileRemove(file)"><Delete /></el-icon>
</span>
</div>
</template>
</el-upload>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="formData.status">
<el-radio :value="1">启用</el-radio>
<el-radio :value="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="dialogVisible = false"> </el-button>
<el-button type="primary" @click="submitForm"> </el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<style lang="scss" scoped>
.app-container {
padding: 20px;
.search-container {
margin-bottom: 20px;
}
.table-container {
margin-bottom: 20px;
}
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
:deep(.el-card__header) {
padding: 10px 20px;
}
.species-image {
width: 60px;
height: 60px;
border-radius: 4px;
cursor: pointer;
object-fit: cover;
transition: all 0.3s;
border: 1px solid #e4e7ed;
&:hover {
transform: scale(1.05);
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}
}
.image-error,
.no-image {
width: 60px;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
background-color: #f5f7fa;
border-radius: 4px;
border: 1px dashed #dcdfe6;
color: #909399;
.el-icon {
font-size: 20px;
}
}
.image-preview {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 10px;
.preview-image {
width: 100px;
height: 100px;
border-radius: 4px;
object-fit: cover;
cursor: pointer;
transition: all 0.3s;
border: 1px solid #e4e7ed;
&:hover {
transform: scale(1.05);
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}
}
}
.upload-file-card {
position: relative;
width: 100%;
height: 100%;
.upload-file-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.upload-file-actions {
position: absolute;
top: 0;
right: 0;
padding: 4px;
background-color: rgba(0, 0, 0, 0.5);
border-radius: 0 4px 0 4px;
opacity: 0;
transition: opacity 0.3s;
.el-icon {
color: #fff;
font-size: 16px;
cursor: pointer;
&:hover {
color: #f56c6c;
}
}
}
&:hover {
.upload-file-actions {
opacity: 1;
}
}
}
}
.dialog-footer {
text-align: right;
padding-top: 20px;
}
:deep(.el-image-viewer__wrapper) {
.el-image-viewer__close {
color: #fff;
font-size: 30px;
&:hover {
color: #409EFF;
}
}
.el-image-viewer__actions {
opacity: 1;
background-color: rgba(0, 0, 0, 0.7);
}
.el-image-viewer__prev,
.el-image-viewer__next {
font-size: 36px;
color: #fff;
&:hover {
color: #409EFF;
}
}
.el-image-viewer__mask {
background-color: rgba(0, 0, 0, 0.8);
}
}
</style>