Răsfoiți Sursa

添加记录管理功能,包括记录列表、创建、编辑和删除记录的API,更新相关视图和组件,增加确认对话框和文件上传功能。

wui 7 luni în urmă
părinte
comite
7a8011ef8f
9 a modificat fișierele cu 548 adăugiri și 2 ștergeri
  1. 1 1
      .env
  2. 1 1
      .env.production
  3. 2 0
      src/App.vue
  4. 2 0
      src/main.js
  5. 5 0
      src/router/index.js
  6. 46 0
      src/services/api.js
  7. 5 0
      src/views/MainView.vue
  8. 467 0
      src/views/RecordsView.vue
  9. 19 0
      src/views/RecordsViewView.vue

+ 1 - 1
.env

@@ -1 +1 @@
-VITE_API_URL=http://localhost:3010/api
+VITE_API_URL=http://localhost:3010/api

+ 1 - 1
.env.production

@@ -1 +1 @@
-VITE_API_URL=https://dl-telegram.org/api
+VITE_API_URL=https://dl-telegram.org/api

+ 2 - 0
src/App.vue

@@ -1,9 +1,11 @@
 <script setup>
 import { RouterView } from 'vue-router'
 import Toast from 'primevue/toast'
+import ConfirmDialog from 'primevue/confirmdialog'
 </script>
 
 <template>
   <RouterView />
   <Toast />
+  <ConfirmDialog />
 </template>

+ 2 - 0
src/main.js

@@ -4,6 +4,7 @@ import { createApp } from 'vue'
 import { createPinia } from 'pinia'
 import PrimeVue from 'primevue/config'
 import ToastService from 'primevue/toastservice'
+import ConfirmService from 'primevue/confirmationservice'
 import Aura from '@primeuix/themes/aura';
 import 'primeicons/primeicons.css'
 
@@ -16,5 +17,6 @@ app.use(createPinia())
 app.use(router)
 app.use(PrimeVue, { ripple: true, theme: { preset: Aura } })
 app.use(ToastService)
+app.use(ConfirmService)
 
 app.mount('#app')

+ 5 - 0
src/router/index.js

@@ -29,6 +29,11 @@ const router = createRouter({
           path: 'user',
           name: 'user',
           component: () => import('@/views/UserView.vue')
+        },
+        {
+          path: 'records',
+          name: 'records',
+          component: () => import('@/views/RecordsView.vue')
         }
       ]
     },

+ 46 - 0
src/services/api.js

@@ -120,3 +120,49 @@ export const updateUserApi = async (userData) => {
   const response = await api.post('/users/update', userData)
   return response.data
 }
+
+// Records API
+export const listRecords = async (page, size, url, description) => {
+  const response = await api.get('/records', {
+    params: {
+      page,
+      size,
+      url,
+      description
+    }
+  })
+  return response.data
+}
+
+export const getRecordById = async (id) => {
+  const response = await api.get(`/records/${id}`)
+  return response.data
+}
+
+export const createRecord = async (recordData) => {
+  const response = await api.post('/records', recordData)
+  return response.data
+}
+
+export const updateRecord = async (recordData) => {
+  const response = await api.post('/records/update', recordData)
+  return response.data
+}
+
+export const deleteRecord = async (id) => {
+  const response = await api.get(`/records/delete/${id}`)
+  return response.data
+}
+
+// 文件上传API
+export const uploadFile = async (file) => {
+  const formData = new FormData()
+  formData.append('file', file)
+  
+  const response = await api.post('/files/upload', formData, {
+    headers: {
+      'Content-Type': 'multipart/form-data'
+    }
+  })
+  return response.data
+}

+ 5 - 0
src/views/MainView.vue

@@ -32,6 +32,11 @@ const navItems = [
     icon: 'pi pi-fw pi-home',
     name: 'dashboard'
   },
+  {
+    label: '记录管理',
+    icon: 'pi pi-fw pi-list',
+    name: 'records'
+  },
   {
     label: '用户管理',
     icon: 'pi pi-fw pi-user',

+ 467 - 0
src/views/RecordsView.vue

@@ -0,0 +1,467 @@
+<script setup>
+import { createRecord, deleteRecord, getRecordById, listRecords, updateRecord, uploadFile } from '@/services/api'
+import { Form } from '@primevue/forms'
+import { zodResolver } from '@primevue/forms/resolvers/zod'
+import { useDateFormat } from '@vueuse/core'
+import Button from 'primevue/button'
+import Column from 'primevue/column'
+import ConfirmDialog from 'primevue/confirmdialog'
+import DataTable from 'primevue/datatable'
+import Dialog from 'primevue/dialog'
+import FloatLabel from 'primevue/floatlabel'
+import IconField from 'primevue/iconfield'
+import InputIcon from 'primevue/inputicon'
+import InputText from 'primevue/inputtext'
+import Message from 'primevue/message'
+import { useConfirm } from 'primevue/useconfirm'
+import { useToast } from 'primevue/usetoast'
+import { computed, onMounted, ref, nextTick } from 'vue'
+import { z } from 'zod'
+
+const toast = useToast()
+const confirm = useConfirm()
+
+// 表格数据
+const tableData = ref({
+  content: [],
+  metadata: {
+    page: 0,
+    size: 20,
+    total: 0
+  }
+})
+
+// 搜索条件
+const search = ref('')
+
+// 表单相关
+const recordDialog = ref(false)
+const isEditMode = ref(false)
+const recordForm = ref({
+  id: null,
+  url: '',
+  description: ''
+})
+const recordFormLoading = ref(false)
+
+// 文件上传相关
+const fileInputRef = ref(null)
+const isUploading = ref(false)
+const formRef = ref(null)
+const formKey = ref(0)
+
+// 表单验证规则
+const recordFormResolver = computed(() => {
+  return zodResolver(
+    z.object({
+      url: z.string().min(1, { message: 'URL不能为空' }),
+      description: z.string().min(1, { message: '描述不能为空' })
+    })
+  )
+})
+
+// 获取数据
+const fetchData = async () => {
+  try {
+    const response = await listRecords(
+      tableData.value.metadata.page,
+      tableData.value.metadata.size,
+      search.value || undefined,
+      search.value || undefined
+    )
+    tableData.value = response
+  } catch (error) {
+    toast.add({
+      severity: 'error',
+      summary: '错误',
+      detail: '获取记录列表失败',
+      life: 3000
+    })
+  }
+}
+
+// 分页处理
+const handlePageChange = (event) => {
+  tableData.value.metadata.page = event.page
+  tableData.value.metadata.size = event.rows
+  fetchData()
+}
+
+// 格式化日期
+const formatDate = (date) => {
+  return useDateFormat(new Date(date), 'YYYY-MM-DD HH:mm:ss').value
+}
+
+// 格式化URL,优先展示头尾
+function formatUrl(url) {
+  if (!url) return ''
+  if (url.length <= 30) return url
+  return url.slice(0, 15) + '...' + url.slice(-20)
+}
+
+// 打开新增对话框
+const openNewRecordDialog = () => {
+  recordForm.value = {
+    id: null,
+    url: '',
+    description: ''
+  }
+  isEditMode.value = false
+  recordDialog.value = true
+}
+
+// 打开编辑对话框
+const openEditRecordDialog = async (record) => {
+  try {
+    const response = await getRecordById(record.id)
+    recordForm.value = {
+      id: response.record.id,
+      url: response.record.url,
+      description: response.record.description
+    }
+    isEditMode.value = true
+    recordDialog.value = true
+  } catch (error) {
+    toast.add({
+      severity: 'error',
+      summary: '错误',
+      detail: '获取记录详情失败',
+      life: 3000
+    })
+  }
+}
+
+// 保存记录
+const saveRecord = async ({ valid, values }) => {
+  if (!valid) return
+
+  recordFormLoading.value = true
+  try {
+    if (isEditMode.value) {
+      await updateRecord({
+        id: recordForm.value.id,
+        url: values.url,
+        description: values.description
+      })
+      toast.add({
+        severity: 'success',
+        summary: '成功',
+        detail: '记录更新成功',
+        life: 3000
+      })
+    } else {
+      await createRecord({
+        url: values.url,
+        description: values.description
+      })
+      toast.add({
+        severity: 'success',
+        summary: '成功',
+        detail: '记录创建成功',
+        life: 3000
+      })
+    }
+
+    recordDialog.value = false
+    fetchData() // 刷新列表
+  } catch (error) {
+    const errorMsg = error.message || (isEditMode.value ? '更新记录失败' : '创建记录失败')
+    toast.add({
+      severity: 'error',
+      summary: '错误',
+      detail: errorMsg,
+      life: 3000
+    })
+  } finally {
+    recordFormLoading.value = false
+  }
+}
+
+// 删除记录
+const handleDeleteRecord = (record) => {
+  confirm.require({
+    message: `确定要删除记录 "${record.description}" 吗?`,
+    header: '确认删除',
+    icon: 'pi pi-exclamation-triangle',
+    accept: async () => {
+      try {
+        await deleteRecord(record.id)
+        toast.add({
+          severity: 'success',
+          summary: '成功',
+          detail: '记录删除成功',
+          life: 3000
+        })
+        fetchData() // 刷新列表
+      } catch (error) {
+        toast.add({
+          severity: 'error',
+          summary: '错误',
+          detail: '删除记录失败',
+          life: 3000
+        })
+      }
+    }
+  })
+}
+
+// 复制URL到剪贴板
+const copyUrl = (url) => {
+  navigator.clipboard.writeText(url).then(() => {
+    toast.add({
+      severity: 'info',
+      summary: '已复制',
+      detail: 'URL已复制到剪贴板',
+      life: 2000
+    })
+  })
+}
+
+// 文件上传处理函数
+const handleFileUpload = async (event) => {
+  const file = event.target.files[0]
+  if (!file) return
+
+  isUploading.value = true
+  try {
+    const response = await uploadFile(file)
+    // 根据实际返回的数据结构提取URL
+    const newUrl = response.data?.url || ''
+    
+    // 重新设置整个表单对象来触发验证
+    recordForm.value = {
+      ...recordForm.value,
+      url: newUrl
+    }
+    
+    // 更新formKey来强制表单重新渲染
+    formKey.value++
+    
+    // 添加调试信息
+    console.log('文件上传成功,URL已设置:', newUrl)
+    console.log('当前表单值:', recordForm.value)
+    
+    toast.add({
+      severity: 'success',
+      summary: '上传成功',
+      detail: '文件上传成功,URL已自动填充',
+      life: 3000
+    })
+  } catch (error) {
+    toast.add({
+      severity: 'error',
+      summary: '上传失败',
+      detail: error.message || '文件上传失败',
+      life: 3000
+    })
+  } finally {
+    isUploading.value = false
+    // 清空文件输入框
+    if (fileInputRef.value) {
+      fileInputRef.value.value = ''
+    }
+  }
+}
+
+// 触发文件选择
+const triggerFileSelect = () => {
+  fileInputRef.value?.click()
+}
+
+onMounted(() => {
+  fetchData()
+})
+</script>
+
+<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
+    >
+      <template #header>
+        <div class="flex flex-wrap items-center">
+          <Button icon="pi pi-refresh" @click="fetchData" label="刷新" size="small" />
+          <Button
+            icon="pi pi-plus"
+            @click="openNewRecordDialog"
+            label="新增记录"
+            severity="success"
+            size="small"
+            class="ml-2"
+          />
+          <div class="flex-1"></div>
+        </div>
+      </template>
+
+      <Column field="id" header="ID" style="width: 80px">
+        <template #body="slotProps">
+          <span class="font-mono text-sm">{{ slotProps.data.id }}</span>
+        </template>
+      </Column>
+
+      <Column field="url" header="URL" style="min-width: 500px">
+        <template #body="slotProps">
+          <div class="flex items-center gap-2">
+            <a
+              :href="slotProps.data.url"
+              target="_blank"
+              class="text-blue-600 hover:text-blue-800 underline truncate max-w-xs"
+              :title="slotProps.data.url"
+            >
+              {{ formatUrl(slotProps.data.url) }}
+            </a>
+            <Button
+              icon="pi pi-copy"
+              size="small"
+              text
+              rounded
+              @click="copyUrl(slotProps.data.url)"
+              :title="'复制URL'"
+            />
+          </div>
+        </template>
+      </Column>
+
+      <Column field="description" header="描述" style="min-width: 200px">
+        <template #body="slotProps">
+          <div class="max-w-xs truncate" :title="slotProps.data.description">
+            {{ slotProps.data.description }}
+          </div>
+        </template>
+      </Column>
+
+      <Column field="createdAt" header="创建时间" style="min-width: 200px">
+        <template #body="slotProps">
+          {{ formatDate(slotProps.data.createdAt) }}
+        </template>
+      </Column>
+
+      <Column header="操作" style="min-width: 150px">
+        <template #body="slotProps">
+          <div class="flex gap-1">
+            <Button
+              icon="pi pi-pencil"
+              severity="info"
+              size="small"
+              text
+              rounded
+              aria-label="编辑"
+              @click="openEditRecordDialog(slotProps.data)"
+            />
+            <Button
+              icon="pi pi-trash"
+              severity="danger"
+              size="small"
+              text
+              rounded
+              aria-label="删除"
+              @click="handleDeleteRecord(slotProps.data)"
+            />
+          </div>
+        </template>
+      </Column>
+    </DataTable>
+
+    <!-- 记录表单对话框 -->
+    <Dialog
+      v-model:visible="recordDialog"
+      :modal="true"
+      :header="isEditMode ? '编辑记录' : '创建记录'"
+      :style="{ width: '450px' }"
+      position="center"
+    >
+      <Form ref="formRef" :key="formKey" v-slot="$form" :resolver="recordFormResolver" :initialValues="recordForm" @submit="saveRecord" class="p-fluid">
+        <div class="field mt-4">
+          <FloatLabel variant="on">
+            <div class="flex gap-2">
+              <IconField class="flex-1">
+                <InputIcon class="pi pi-link" />
+                <InputText 
+                  id="url" 
+                  name="url" 
+                  v-model="recordForm.url" 
+                  autocomplete="off" 
+                  fluid 
+                />
+              </IconField>
+              <Button
+                icon="pi pi-upload"
+                @click="triggerFileSelect"
+                :loading="isUploading"
+                :disabled="isUploading"
+                size="small"
+                severity="secondary"
+                :title="'上传文件'"
+              />
+            </div>
+            <label for="url">URL</label>
+          </FloatLabel>
+          <Message v-if="$form.url?.invalid" severity="error" size="small" variant="simple">
+            {{ $form.url.error?.message }}
+          </Message>
+        </div>
+
+        <!-- 隐藏的文件输入框 -->
+        <input
+          ref="fileInputRef"
+          type="file"
+          @change="handleFileUpload"
+          style="display: none"
+          accept="*/*"
+        />
+
+        <div class="field mt-4">
+          <FloatLabel variant="on">
+            <IconField>
+              <InputIcon class="pi pi-file-edit" />
+              <InputText 
+                id="description" 
+                name="description" 
+                v-model="recordForm.description" 
+                autocomplete="off" 
+                fluid 
+              />
+            </IconField>
+            <label for="description">描述</label>
+          </FloatLabel>
+          <Message v-if="$form.description?.invalid" severity="error" size="small" variant="simple">
+            {{ $form.description.error?.message }}
+          </Message>
+        </div>
+
+        <div class="flex justify-end gap-2 mt-4">
+          <Button
+            label="取消"
+            severity="secondary"
+            type="button"
+            @click="recordDialog = false"
+            :disabled="recordFormLoading"
+          />
+          <Button label="保存" type="submit" :loading="recordFormLoading" />
+        </div>
+      </Form>
+    </Dialog>
+
+    <!-- 确认对话框 -->
+    <ConfirmDialog />
+  </div>
+</template>
+
+<style scoped>
+.p-datatable-sm .p-datatable-tbody > tr > td {
+  padding: 0.5rem;
+}
+
+.p-datatable-sm .p-datatable-thead > tr > th {
+  padding: 0.5rem;
+}
+</style> 

+ 19 - 0
src/views/RecordsViewView.vue

@@ -0,0 +1,19 @@
+<template>
+  <!-- No changes to template section -->
+</template>
+
+<script>
+import { ref } from 'vue'
+
+// 文件上传相关
+const fileInputRef = ref(null)
+const isUploading = ref(false)
+const formRef = ref(null)
+const formKey = ref(0) // 添加表单key来强制重新渲染
+
+// ... existing code ...
+</script>
+
+<style>
+  /* No changes to style section */
+</style>