Vue+VxeUI的鸟类数据展示网站
标签搜索
侧边栏壁纸
  • 累计撰写 23 篇文章
  • 累计收到 1 条评论

Vue+VxeUI的鸟类数据展示网站

bai1hao
2025-06-18 / 0 评论 / 15 阅读 / 正在检测是否收录...

一.测试flask(暂时用不到,仅安装)

# main.py
from flask import Flask

app = Flask(__name__)

@app.route('/')
def hello_world():
   return 'Hello, World!'

if __name__ == '__main__':
   app.run(debug=True)

测试Flask后端正常安装,可以访问
运行 python main.py 如图

mbyfhqbl.png

mbyfi3ap.png

二.使用json-server模拟后端api

用npm安装json-server作为数据库测试
模拟数据库如下

birds_data_test.json

{
 "bird": [
   {
     "id": 1,
     "scientific_name": "家麻雀",
     "latin_name": "Passer domesticus",
     "order": "雀形目",
     "family": "雀科",
     "genus": "麻雀属",
     "feature_description": "小型鸟类,体长14厘米左右,喙短粗呈圆锥状...",
     "subspecies_distribution": "共有12个亚种,广泛分布于欧洲、亚洲和非洲...",
     "collection_site": "北京市海淀区颐和园",
     "morphological_characteristic": "雄鸟顶冠及颈背灰色,雌鸟色淡,具深色纵纹...",
     "distribution": "世界分布广泛,中国大部分地区均有分布...",
     "information": "常见留鸟,喜群居,主要以谷物和昆虫为食...",
     "picture": "sparrow.jpg"
   },
   {
     "id": 2,
     "scientific_name": "喜鹊",
     "latin_name": "Pica pica",
     "order": "雀形目",
     "family": "鸦科",
     "genus": "喜鹊属",
     "feature_description": "中型鸟类,体长40-50厘米,羽毛黑蓝相间...",
     "subspecies_distribution": "共有10个亚种,分布于欧亚大陆及非洲北部...",
     "collection_site": "上海市浦东新区世纪公园",
     "morphological_characteristic": "头、颈、背至尾均为黑色,并自前往后分别呈现紫色...",
     "distribution": "除南美洲、大洋洲与南极洲外,几乎遍布世界各大陆...",
     "information": "杂食性鸟类,适应性强,在中国文化中常象征吉祥...",
     "picture": "magpie.jpg"
   }
 ]
}

运行json-server
json-server --watch .\birds_data_test.json --port 3000

发现使用12.18.3版Node.js运行报错

更新node为22.16.0稳定版

成功运行
mbyg9fde.png
访问api获取json数据如下
mbyg9rsh.png

三.设计前端页面

由于获取的数据中,多数鸟类没有对应图片,所以需要设计一些图片展示少的,文字介绍多的
需要按照科和目做成二级索引
鸟类有26目,需要考虑分类目录的长度

将以上需求告诉豆包,豆包给出设计如下
主页图片
birds_design.png

鸟类详情页
birds_design_2.png

四.开始前端开发

vue 套装
axios
原生css

创建工程化项目

 npm create vue@latest

header部分css实现

.center {
  width:80%;
  position: relative;
  left:50%;
  transform:translateX(-50%);
  display: flex;
  justify-content: space-between;
  align-items: center;
}

header {
  width: 100%;
  background: white;
  padding:20px 0;
}

.logo {
  font-size: xxx-large;
  color: #353f39;
}

.rounded {
  border-radius: 10px;
}

.search-box {
  display: flex;
  gap:10px;
}
.search-box input {
  padding:10px
}
.search-box button {
  padding:8px 30px;
  background: #42756c;
  color:white;
}

6月16日下午


主页部分涉及复杂表格,打算引入vue组件

安装 vxe table

npm install vxe-table@4.13.39

试用之后发现需要安装vxe-ui才有完整的组件,如搜索,分页等

安装vxe-ui

npm install vxe-pc-ui@4.6.23

最终完整主页代码如下

<script setup>
import {reactive, ref, onMounted} from 'vue'
import XEUtils from 'xe-utils'
import router from "@/router/index.js";
import request from '@/utils/request'
//存储选中的左侧菜单
const currentIndex = ref(0);
const currentOrder = ref("")

const loading = ref(false)

const filterName = ref('')
const list = ref([])
const tableData = ref([])

//全文搜索
const handleSearch = () => {
  const filterVal = String(filterName.value).trim().toLowerCase()
  if (filterVal) {
    //控制分页 关
    pageOn.value = false;
    const filterRE = new RegExp(filterVal, 'gi')
    const searchProps = ['scientific_name', 'latin_name', 'order_name', 'family_name']
    const rest = tableData.value.filter(item => searchProps.some(key => String(item[key]).toLowerCase().indexOf(filterVal) > -1))
    list.value = rest.map(row => {
      // 搜索为克隆数据,不会污染源数据
      const item = XEUtils.clone(row)
      searchProps.forEach(key => {
        item[key] = String(item[key]).replace(filterRE, match => `<span class="keyword-highlight">${match}</span>`)
      })
      return item
    })
  } else {
    list.value = tableData.value
    //控制分页 开
    pageOn.value = true;
  }
}
// 节流函数,间隔500毫秒触发搜索
// const searchEvent = XEUtils.throttle(function () {
//   handleSearch()
// }, 500, {trailing: true, leading: true})
// handleSearch()

//左侧菜单数据
const menuData = ref();
const fetchMenu = () => {
    request.get('/list')
    .then(response => {
      menuData.value = response.data.order_list;
      currentOrder.value = response.data.order_list[0];
      getList(currentOrder.value);
    })
}
onMounted(() => {
  fetchMenu();
});
//按下菜单事件
const selectOrder = (value, index) => {
  if(document.querySelector("#mu-input").checked) {
    document.querySelector("#mu-input").checked = false;
  }
  pageOn.value = true;
  filterName.value = "";
  currentIndex.value = index;
  currentOrder.value = value;
  getList(currentOrder.value);
  if (value === "雀形目") {
    setTimeout(e => {
      loading.value = true
    })
    loading.value = false
  }
}

const getList = (value) => {
  request.get('/all?order=' + value)
    .then(response => {
      tableData.value = response.data.data;
      list.value = response.data.data;
      handlePageData();
    })
}

//搜索关闭分页
const pageOn = ref(true)
//分页
const pageVO = reactive({
  total: 0,
  currentPage: 1,
  pageSize: 10
})
const handlePageData = () => {
  loading.value = true
  setTimeout(() => {
    const {pageSize, currentPage} = pageVO
    pageVO.total = tableData.value.length
    list.value = tableData.value.slice((currentPage - 1) * pageSize, currentPage * pageSize)
    loading.value = false
  }, 1)
}
const pageChange = ({pageSize, currentPage}) => {
  pageVO.currentPage = currentPage
  pageVO.pageSize = pageSize
  handlePageData()
}
handlePageData()

const toDetail = (row) => {
  // 存储当前行数据到 Pinia
  // rowStore.setCurrentRow(row);
  // 导航到详情页
  router.push(`/detail/${row.id}`);
}

</script>
<template>
  <main>
    <div class="center">
      <div class="main-left rounded">
        <input type="checkbox" id="mu-input">
        <label class="mu-button hamb" for="mu-input"> <span class="mu">目</span> <span
          class="hamb-line"></span></label>
        <ul class="main-left-ul">
          <li :class="{ 'active': currentIndex === index }"
              @mousedown="selectOrder(value,index)" v-for="(value,index) in menuData">
            <div><p class="num-mu">{{ index + 1 }}</p>{{ value }}</div>
            <div>&rsaquo;</div>
          </li>
        </ul>
      </div>
      <div class="main-right">
        <div class="right-top">

        </div>

        <div style="height: 100%">
          <div class="search-box">
            <vxe-input class="home-search" v-model="filterName" type="search"
                       placeholder="按下回车" clearable
                       @keyup.enter="handleSearch"></vxe-input>
            <vxe-button @click="handleSearch" status="success" content="搜索表格"></vxe-button>
          </div>

          <vxe-table
            :loading="loading"
            stripe
            border
            class="mylist-table"
            height="80%"
            :column-config="{useKey: true}"
            :row-config="{useKey: true}"
            :data="list">
            <vxe-column field="scientific_name" title="种名" type="html"></vxe-column>
            <vxe-column field="latin_name" width="auto" title="拉丁名" type="html"></vxe-column>
            <vxe-column field="order_name" title="目" type="html"></vxe-column>
            <vxe-column field="family_name" title="科" type="html"></vxe-column>
            <vxe-column field="genus_name" title="属" type="html"></vxe-column>
            <vxe-column field="id" title="详细信息" width="auto" type="html">
              <template #default="{ row }">
                <vxe-button
                  @click="toDetail(row)"
                  status="success"
                  content="详细信息"
                ></vxe-button>
              </template>
            </vxe-column>
          </vxe-table>
          <vxe-pager
            v-show="pageOn"
            v-model:currentPage="pageVO.currentPage"
            v-model:pageSize="pageVO.pageSize"
            :total="pageVO.total"
            @page-change="pageChange">
          </vxe-pager>
        </div>
      </div>
    </div>

  </main>
</template>
<style scoped>
.mu-button {
  display: block;
  height: 84px;
}

.hamb-line {
  background: white;
  display: none;
  height: 4px;
  position: relative;
  width: 35px;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
}

/* Style span tag */
.hamb-line::before,
.hamb-line::after {
  background: white;
  content: '';
  display: block;
  height: 100%;
  position: absolute;
  transition: all .2s ease-out;
  width: 100%;
}

.hamb-line::before {
  top: 8px;
}

.hamb-line::after {
  top: -8px;
}

#mu-input {
  display: none;
}

.search-box {
  display: flex;
  justify-content: center;
  padding: 20px;
  border-radius: 10px 10px 0 0;
  background: #d3e1c8;
}

.home-search {
  border-radius: 5px;
  width: 50%;
}

main {
  width: 100%;
  height: 75%;
}

main > .center {
  gap: 20px;
  margin: 20px 0;
  display: flex;
  height: 100%;
}

.main-left {
  width: 25%;
  height: 100%;
  background: #394d44;
}

.main-right {
  width: 75%;
  height: 100%;
}


.main-left-ul {
  height: 83%;
  display: flex;
  gap: 10px;
  flex-direction: column;
  overflow: auto;
}

.mu {
  font-size: xxx-large;
  color: white;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 3%;
}

.main-left-ul > li {
  cursor: pointer;
  list-style: none;
  padding: 10px 30px;
  display: flex;
  justify-content: space-between;
  align-items: center;
  color: lightgray;
}

.main-left-ul > li > div:first-child {
  display: flex;
  justify-content: left;
  gap: 20px;
  align-items: center;
}

.num-mu {
  color: white;
  font-size: xx-large;
}

.main-left-ul > li:hover {
  background: #aecd7f;
  color: #394d44;
}

.active {
  background: #aecd7f;
}

.active > div {
  color: #394d44;
}

#mu-input:checked ~ nav {
  max-height: 100%;
}

#mu-input:checked ~ .hamb .hamb-line {
  background: transparent;
}

#mu-input:checked ~ .hamb .hamb-line::before {
  transform: rotate(-45deg);
  top: 0;
}

#mu-input:checked ~ .hamb .hamb-line::after {
  transform: rotate(45deg);
  top: 0;
}


@media screen and (max-width: 980px) {
  .mu {
    display: none;
  }

  .hamb-line {
    display: block;
  }

  .num-mu {
    display: none;
  }

  main > .center {
    flex-direction: column;
  }

  .main-left {
    width: 100%;
  }

  .main-left ul {
    transition: all linear 0.3s;
    opacity: 0;
    height: 0;
    transform: translateY(-50%) scaleY(0);
  }

  #mu-input:checked ~ ul {
    opacity: 1;
    height: 83%;
    transform: translateY(0) scaleY(1);
  }

  .main-right {
    width: 100%;
  }
}

</style>
<style lang="scss" scoped>
.mylist-table {
  ::v-deep(.keyword-highlight) {
    background-color: #FFFF00;
  }
}
</style>

完整详情页如下

<template>
  <main>
    <div v-if="loading">
      <div>
        <div style="height: 200px;position: relative;">
          <vxe-loading v-model="loading"></vxe-loading>
        </div>
      </div>
    </div>
    <div v-else-if="rowData" class="center">
      <router-link to="/" class="back_index">< 返回主页</router-link>
      <div class="main-top">

        <div class="name-box">
          <div class="name-line">
            <div v-if="rowData.picture!=='暂无图片'" class="imgbox">
              <img class="bird-img" :src="rowData.picture" alt="">
            </div>
            <div class="name-both">
              <div class="scientific_name name">{{ rowData.scientific_name }}</div>
              <div class="latin_name">{{ rowData.latin_name }}</div>
            </div>
          </div>
        </div>
        <div class="back-img">
          <img class="bird3" src="@/assets/images/bird3.png" alt="">
        </div>
      </div>
      <div class="main-bottom">
        <div class="three-box">
          <div class="box box1">
            <div class="head1">科目属分类</div>
            <div class="mks info">
              <div v-html="rowData.order_name" class="order_name"></div>
              -
              <div v-html="rowData.family_name" class="family_name"></div>
              -
              <div v-html="rowData.genus_name" class="genus_name"></div>
            </div>
          </div>
          <div v-if="rowData.feature_description" class="box box1">
            <div class="head1">
              形态特征
            </div>
            <div v-html="rowData.feature_description" class="info feature_description"></div>
          </div>
          <div v-if="rowData.collection_site"
               class="box box1">
            <div class="head1">
              采集地
            </div>
            <div v-html="rowData.collection_site" class="info feature_description"></div>
          </div>
        </div>
        <div v-if="rowData.information" class="line-box box information">
          <div class="head1">信息</div>
          <div class="info" v-html="rowData.information"></div>
        </div>
        <div v-if="rowData.distribution" class="line-box box distribution">
          <div class="head1">分布</div>
          <div class="info" v-html="rowData.distribution"></div>
        </div>
        <div v-if="rowData.subspecies_distribution" class="line-box box distribution">
          <div class="head1">亚种及分布</div>
          <div class="info" v-html="rowData.subspecies_distribution"></div>
        </div>
      </div>
    </div>
    <div v-else>
      <div>
        <div style="height: 200px;position: relative;">
          <vxe-loading v-model="loading"></vxe-loading>
        </div>
      </div>
    </div>

  </main>
</template>

<style scoped>


.latin_name {
  font-weight: lighter;
}

.imgbox {
  margin-right: 30px;
}

.bird-img {
  height: 10rem;
  border-radius: 10px;
}

.info {
  overflow: auto;
  color: dimgray;
}

.head1 {
  font-size: large;
  font-weight: bold;
}

.box1 {
  display: flex;
  flex-direction: column;
  gap: 10px;
  padding: 2%;
}

.mks {
  display: flex;
}

.bird3 {
  position: relative;
  height: 100%;
}

.main-bottom {
  margin-top: 20px;
  display: flex;
  flex-direction: column;
  gap: 20px;
}

.three-box {
  display: flex;
  gap: 20px;
  width: 100%;
  height: 35%;
}

.line-box {
  display: flex;
  flex-direction: column;
  gap: 20px;
  padding: 3%;
  min-height: 25%;
}

.box {
  display: flex;
  border-radius: 10px;
  width: 100%;
  background: whitesmoke;
}

.back-img {
  display: flex;
  justify-content: right;
  width: 50%;
}

.name-line {
  display: flex;
  align-items: center;
}

.name {
  font-size: xx-large;
}

.name-box {
  width: 50%;
  display: flex;
  justify-content: center;
  flex-direction: column;
}

main > .center {
  height: 100%;
}

.main-top {
  height: 240px;
  display: flex;
  color: white;
}

main {
  padding-bottom: 100px;
  width: 100%;
  min-height: 100%;
  background: #394e40;
  background-size: 40%;
}

@media screen and (max-width: 980px) {
  .back-img {
    display: none;
  }

  .name-box {
    width: 100%;
  }

  .main-top {
    padding-top: 30px;
  }

  .imgbox {
    width: 100%;
    display: flex;
    padding-left: 15px;
  }

  .scientific_name {
    font-size: large;
  }

  .name-both {
    width: 100%;
  }

  .name-line {
    flex-direction: column;
    gap: 10px;
  }

  .three-box {
    display: flex;
    flex-direction: column;
  }
}

</style>
<script setup>
import {ref, onMounted, onBeforeUnmount} from 'vue';
import {useRoute, useRouter} from "vue-router";
import request from "@/utils/request.js";
// import {useRowStore} from "@/stores/rowStore.js";

const route = useRoute();
// const rowStore = useRowStore();

const rowData = ref(null);
const loading = ref(true);
const fetchData = async () => {
  try {
    loading.value = true;

    // // 1. 尝试从 Pinia 获取数据
    // const storedRow = rowStore.getCurrentRow;
    //
    // if (storedRow && storedRow.id.toString() === route.params.id) {
    //   rowData.value = storedRow;
    //   return;
    // }
    request.get("/all?id=" + route.params.id).then(response => {
      rowData.value = response.data.data[0];
    })
  } catch (error) {
    console.error('获取数据失败:', error);
  } finally {
    loading.value = false;
  }
};

// 组件挂载时获取数据
onMounted(fetchData);
</script>

完整搜索页面如下

<template>
  <main>
    <div class="center">
      <router-link to="/" class="back_index">< 返回主页</router-link>

      <vxe-table
        :loading="loading"
        stripe
        border
        class="mylist-table"
        height="80%"
        :column-config="{useKey: true}"
        :row-config="{useKey: true}"
        :data="list">
        <vxe-column field="scientific_name" title="种名" type="html"></vxe-column>
        <vxe-column field="latin_name" width="auto" title="拉丁名" type="html"></vxe-column>
        <vxe-column field="order_name" title="目" type="html"></vxe-column>
        <vxe-column field="family_name" title="科" type="html"></vxe-column>
        <vxe-column field="genus_name" title="属" type="html"></vxe-column>
        <vxe-column field="id" title="详细信息" width="auto" type="html">
          <template #default="{ row }">
            <vxe-button
              @click="toDetail(row)"
              status="success"
              content="详细信息"
            ></vxe-button>
          </template>
        </vxe-column>
      </vxe-table>
      <vxe-pager
        v-model:currentPage="pageVO.currentPage"
        v-model:pageSize="pageVO.pageSize"
        :total="pageVO.total"
        @page-change="pageChange">
      </vxe-pager>
    </div>
  </main>
</template>

<script setup>
import {reactive, ref, onMounted, watch} from 'vue'
import router from "@/router/index.js";
import {useRoute} from "vue-router";
import request from "@/utils/request.js";

const notFount = ref(false);
const route = useRoute();
const loading = ref(true)
const list = ref([])
const tableData = ref([])

//接收搜索参数
const props = defineProps({
  keywords: {
    type: String,
    default: ''
  }
})
//分页
const pageVO = reactive({
  total: 0,
  currentPage: 1,
  pageSize: 10
})
const handlePageData = () => {
  loading.value = true
  setTimeout(() => {
    const {pageSize, currentPage} = pageVO
    pageVO.total = tableData.value.length
    list.value = tableData.value.slice((currentPage - 1) * pageSize, currentPage * pageSize)
    loading.value = false
  }, 1)
}
const pageChange = ({pageSize, currentPage}) => {
  pageVO.currentPage = currentPage
  pageVO.pageSize = pageSize
  handlePageData()
}
handlePageData()


const toDetail = (row) => {
  // 导航到详情页
  router.push(`/detail/${row.id}`);
}

const fetchData = () => {
  loading.value = true;
  request.get(`/search`,{params:{"search":props.keywords}}).then(response => {
    console.log(response.data)
    if (response.data.status === 200) {
      notFount.value = false;
      tableData.value = response.data.data;
    } else {
      tableData.value = [];
    }
    loading.value = false
    handlePageData()
  }).catch(() => {
    console.log("搜索不到数据:"+props.keywords);
    tableData.value = [];
    loading.value = false
    handlePageData()
  })
}
onMounted(() => {
  if (route.query.keywords) {
    loading.value = true
    fetchData()
  }
})
watch(
  () => route.query.keywords,
  (newVal, oldVal) => {
    // 添加空值检查和防抖
    if (newVal && newVal !== oldVal) {
      loading.value = true;
      fetchData();
    }
  },
  {immediate: true} // 可选:初始立即执行(替代onMounted)
);
</script>
<style scoped>
main {
  height: 80%;
  margin-top: 20px;
}

.mylist-table {
  height: 80%;
}
</style>
1

评论

博主关闭了所有页面的评论