|
@@ -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>
|