Browse Source

新增财务状态、收入类型和订单类型的枚举,添加图片上传API,创建推广链接视图,支持链接的搜索、编辑和删除功能。

wuyi 4 months ago
parent
commit
d103cb5cb3
3 changed files with 766 additions and 0 deletions
  1. 26 0
      src/enums/index.js
  2. 13 0
      src/services/api.js
  3. 727 0
      src/views/LinkView.vue

+ 26 - 0
src/enums/index.js

@@ -31,3 +31,29 @@ export const PlatformType = {
   iOS: 'iOS',
   PC: 'PC'
 }
+
+export const FinanceStatus = {
+  withdrawn: '已提现',
+  rejected: '申请驳回',
+  processing: '处理中'
+}
+
+export const IncomeType = {
+  tip: '打赏收入',
+  commission: '返佣收入'
+}
+
+export const OrderType = {
+  single_tip: '单片打赏',
+  hourly_member: '包时会员',
+  weekly_member: '包周会员',
+  monthly_member: '包月会员',
+  yearly_member: '包年会员',
+  lifetime_member: '终身会员'
+}
+
+export const LinkType = {
+  general: '通用链接',
+  super: '超级链接',
+  browser: '浏览器链接'
+}

+ 13 - 0
src/services/api.js

@@ -123,6 +123,19 @@ export const uploadFile = async (file) => {
   return response.data
 }
 
+// 图片上传API
+export const uploadImage = async (file) => {
+  const formData = new FormData()
+  formData.append('file', file)
+
+  const response = await api.post('/files/upload/image', formData, {
+    headers: {
+      'Content-Type': 'multipart/form-data'
+    }
+  })
+  return response.data
+}
+
 // 文件下载API
 export const downloadFile = async (key) => {
   const response = await api.post(

+ 727 - 0
src/views/LinkView.vue

@@ -0,0 +1,727 @@
+<template>
+  <div class="rounded-lg p-4 bg-[var(--p-content-background)]">
+    <!-- 搜索和操作区域 -->
+    <div class="flex flex-wrap items-center gap-2 mb-6">
+      <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"
+      />
+      <Dropdown
+        v-model="searchForm.type"
+        :options="typeOptions"
+        optionLabel="label"
+        optionValue="value"
+        placeholder="链接类型"
+        size="small"
+        class="w-32"
+        :showClear="true"
+      />
+      <Button icon="pi pi-search" @click="handleSearch" label="搜索" size="small" severity="secondary" />
+      <Button icon="pi pi-refresh" @click="handleRefresh" label="刷新" size="small" />
+      <Button icon="pi pi-plus" @click="openAddDialog" label="新增链接" size="small" severity="success" />
+      <div class="flex-1"></div>
+    </div>
+
+    <!-- 卡片展示区域 -->
+    <div class="space-y-8">
+      <!-- 通用链接 -->
+      <div v-if="getLinksByType('general').length > 0">
+        <h3 class="text-lg font-semibold mb-4 text-gray-700">{{ LinkType.general }}</h3>
+        <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
+          <div
+            v-for="link in getLinksByType('general')"
+            :key="link.id"
+            class="link-card bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200 relative"
+          >
+            <!-- 链接名称 -->
+            <div class="p-4 pb-2">
+              <h4 class="font-medium text-gray-800 truncate" :title="link.name">{{ link.name }}</h4>
+            </div>
+
+            <!-- 图片 -->
+            <div class="px-4 py-2">
+              <div class="image-container-card">
+                <img v-if="link.image" :src="link.image" :alt="link.name" class="link-image-card" />
+                <div v-else class="no-image-placeholder">
+                  <i class="pi pi-image text-gray-400"></i>
+                </div>
+              </div>
+            </div>
+
+            <!-- 链接地址 -->
+            <div class="p-4 pt-2 pb-16">
+              <span class="link-url-text-card block" :title="link.link" @click="copyToClipboard(link.link)">
+                {{ link.link }}
+              </span>
+            </div>
+
+            <!-- 操作按钮 - 固定在右下角 -->
+            <div class="absolute bottom-4 right-4 flex gap-2">
+              <Button
+                icon="pi pi-copy"
+                size="small"
+                text
+                rounded
+                @click="copyToClipboard(link.link)"
+                title="复制链接"
+              />
+              <Button icon="pi pi-pencil" size="small" text rounded @click="openEditDialog(link)" title="编辑" />
+              <Button
+                icon="pi pi-trash"
+                size="small"
+                text
+                rounded
+                severity="danger"
+                @click="confirmDelete(link)"
+                title="删除"
+              />
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <!-- 分隔线 -->
+      <div
+        v-if="getLinksByType('general').length > 0 && getLinksByType('super').length > 0"
+        class="border-t border-gray-300 my-8"
+      ></div>
+
+      <!-- 超级链接 -->
+      <div v-if="getLinksByType('super').length > 0">
+        <h3 class="text-lg font-semibold mb-4 text-gray-700">{{ LinkType.super }}</h3>
+        <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
+          <div
+            v-for="link in getLinksByType('super')"
+            :key="link.id"
+            class="link-card bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200 relative"
+          >
+            <!-- 链接名称 -->
+            <div class="p-4 pb-2">
+              <h4 class="font-medium text-gray-800 truncate" :title="link.name">{{ link.name }}</h4>
+            </div>
+
+            <!-- 图片 -->
+            <div class="px-4 py-2">
+              <div class="image-container-card">
+                <img v-if="link.image" :src="link.image" :alt="link.name" class="link-image-card" />
+                <div v-else class="no-image-placeholder">
+                  <i class="pi pi-image text-gray-400"></i>
+                </div>
+              </div>
+            </div>
+
+            <!-- 链接地址 -->
+            <div class="p-4 pt-2 pb-16">
+              <span class="link-url-text-card block" :title="link.link" @click="copyToClipboard(link.link)">
+                {{ link.link }}
+              </span>
+            </div>
+
+            <!-- 操作按钮 - 固定在右下角 -->
+            <div class="absolute bottom-4 right-4 flex gap-2">
+              <Button
+                icon="pi pi-copy"
+                size="small"
+                text
+                rounded
+                @click="copyToClipboard(link.link)"
+                title="复制链接"
+              />
+              <Button icon="pi pi-pencil" size="small" text rounded @click="openEditDialog(link)" title="编辑" />
+              <Button
+                icon="pi pi-trash"
+                size="small"
+                text
+                rounded
+                severity="danger"
+                @click="confirmDelete(link)"
+                title="删除"
+              />
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <!-- 分隔线 -->
+      <div
+        v-if="getLinksByType('super').length > 0 && getLinksByType('browser').length > 0"
+        class="border-t border-gray-300 my-8"
+      ></div>
+
+      <!-- 浏览器链接 -->
+      <div v-if="getLinksByType('browser').length > 0">
+        <h3 class="text-lg font-semibold mb-4 text-gray-700">{{ LinkType.browser }}</h3>
+        <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
+          <div
+            v-for="link in getLinksByType('browser')"
+            :key="link.id"
+            class="link-card bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200 relative"
+          >
+            <!-- 链接名称 -->
+            <div class="p-4 pb-2">
+              <h4 class="font-medium text-gray-800 truncate" :title="link.name">{{ link.name }}</h4>
+            </div>
+
+            <!-- 图片 -->
+            <div class="px-4 py-2">
+              <div class="image-container-card">
+                <img v-if="link.image" :src="link.image" :alt="link.name" class="link-image-card" />
+                <div v-else class="no-image-placeholder">
+                  <i class="pi pi-image text-gray-400"></i>
+                </div>
+              </div>
+            </div>
+
+            <!-- 链接地址 -->
+            <div class="p-4 pt-2 pb-16">
+              <span class="link-url-text-card block" :title="link.link" @click="copyToClipboard(link.link)">
+                {{ link.link }}
+              </span>
+            </div>
+
+            <!-- 操作按钮 - 固定在右下角 -->
+            <div class="absolute bottom-4 right-4 flex gap-2">
+              <Button
+                icon="pi pi-copy"
+                size="small"
+                text
+                rounded
+                @click="copyToClipboard(link.link)"
+                title="复制链接"
+              />
+              <Button icon="pi pi-pencil" size="small" text rounded @click="openEditDialog(link)" title="编辑" />
+              <Button
+                icon="pi pi-trash"
+                size="small"
+                text
+                rounded
+                severity="danger"
+                @click="confirmDelete(link)"
+                title="删除"
+              />
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <!-- 无数据提示 -->
+      <div v-if="filteredLinks.length === 0" class="text-center py-12">
+        <i class="pi pi-link text-6xl text-gray-300 mb-4"></i>
+        <p class="text-gray-500 text-lg">暂无推广链接</p>
+      </div>
+    </div>
+
+    <!-- 新增/编辑弹窗 -->
+    <Dialog
+      v-model:visible="editDialog"
+      :modal="true"
+      :header="isEdit ? '编辑推广链接' : '新增推广链接'"
+      :style="{ width: '600px' }"
+      position="center"
+    >
+      <div class="p-fluid">
+        <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 mt-4">
+          <label for="edit-type" class="font-medium text-sm mb-2 block">链接类型</label>
+          <Dropdown
+            id="edit-type"
+            v-model="editForm.type"
+            :options="typeOptions.filter((option) => option.value !== null)"
+            optionLabel="label"
+            optionValue="value"
+            placeholder="选择链接类型"
+            class="w-full"
+          />
+        </div>
+
+        <div class="field mt-4">
+          <label for="edit-link" class="font-medium text-sm mb-2 block">链接地址</label>
+          <InputText id="edit-link" v-model="editForm.link" class="w-full" />
+        </div>
+
+        <div class="field mt-4">
+          <label class="font-medium text-sm mb-2 block">图片</label>
+          <div class="card flex flex-col items-center gap-6">
+            <FileUpload
+              v-if="!imagePreview"
+              mode="basic"
+              @select="onFileSelect"
+              customUpload
+              auto
+              severity="secondary"
+              class="p-button-outlined"
+              accept="image/*"
+              :maxFileSize="50000000"
+              chooseLabel="选择图片"
+            />
+            <div v-if="imagePreview" class="flex flex-col items-center gap-2">
+              <img
+                :src="imagePreview"
+                alt="Image"
+                class="shadow-md rounded-xl w-full sm:w-64"
+                style="filter: grayscale(100%)"
+              />
+              <Button icon="pi pi-times" size="small" rounded severity="danger" @click="removeImage" title="移除图片" />
+            </div>
+          </div>
+        </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 { ref, onMounted } from 'vue'
+import { useDateFormat } from '@vueuse/core'
+import Button from 'primevue/button'
+import Column from 'primevue/column'
+import DataTable from 'primevue/datatable'
+import Dialog from 'primevue/dialog'
+import Dropdown from 'primevue/dropdown'
+import InputText from 'primevue/inputtext'
+import FileUpload from 'primevue/fileupload'
+import { useConfirm } from 'primevue/useconfirm'
+import { useToast } from 'primevue/usetoast'
+import { usePrimeVue } from 'primevue/config'
+import { listLinks, createLink, updateLink, deleteLink, uploadImage } from '@/services/api'
+import { LinkType } from '@/enums'
+
+const toast = useToast()
+const confirm = useConfirm()
+const $primevue = usePrimeVue()
+
+// 表格数据
+const tableData = ref({
+  content: [],
+  metadata: {
+    page: 0,
+    size: 1000,
+    total: 0
+  }
+})
+
+// 过滤后的链接数据
+const filteredLinks = ref([])
+
+// 加载状态
+const loading = ref(false)
+
+// 编辑相关
+const editDialog = ref(false)
+const editLoading = ref(false)
+const uploading = ref(false)
+const isEdit = ref(false)
+
+// 图片预览相关
+const imagePreview = ref(null)
+const imageFile = ref(null)
+const editForm = ref({
+  id: null,
+  name: null,
+  type: null,
+  link: null,
+  image: null
+})
+
+// 搜索表单
+const searchForm = ref({
+  id: null,
+  name: null,
+  type: null
+})
+
+// 链接类型选项
+const typeOptions = [
+  { label: '全部', value: null },
+  { label: LinkType.general, value: 'general' },
+  { label: LinkType.super, value: 'super' },
+  { label: LinkType.browser, value: 'browser' }
+]
+
+// 获取链接类型文本
+const getLinkTypeText = (type) => {
+  return LinkType[type] || type
+}
+
+// 获取链接类型样式类
+const getLinkTypeClass = (type) => {
+  const classMap = {
+    general: 'link-type-general',
+    super: 'link-type-super',
+    browser: 'link-type-browser'
+  }
+  return classMap[type] || ''
+}
+
+// 获取数据
+const fetchData = async () => {
+  loading.value = true
+  try {
+    const response = await listLinks(
+      tableData.value.metadata.page,
+      tableData.value.metadata.size,
+      searchForm.value.name || undefined,
+      searchForm.value.type || undefined
+    )
+    tableData.value = response
+    applyFilters()
+  } catch {
+    toast.add({
+      severity: 'error',
+      summary: '错误',
+      detail: '获取推广链接失败',
+      life: 3000
+    })
+  } finally {
+    loading.value = false
+  }
+}
+
+// 应用搜索过滤
+const applyFilters = () => {
+  let links = [...tableData.value.content]
+
+  // ID 过滤
+  if (searchForm.value.id) {
+    links = links.filter((link) => link.id.toString().includes(searchForm.value.id))
+  }
+
+  // 名称过滤
+  if (searchForm.value.name) {
+    links = links.filter((link) => link.name && link.name.toLowerCase().includes(searchForm.value.name.toLowerCase()))
+  }
+
+  // 类型过滤
+  if (searchForm.value.type) {
+    links = links.filter((link) => link.type === searchForm.value.type)
+  }
+
+  filteredLinks.value = links
+}
+
+// 按类型获取链接
+const getLinksByType = (type) => {
+  return filteredLinks.value.filter((link) => link.type === type)
+}
+
+// 搜索处理
+const handleSearch = () => {
+  applyFilters()
+}
+
+// 刷新处理
+const handleRefresh = () => {
+  searchForm.value = {
+    id: null,
+    name: null,
+    type: null
+  }
+  fetchData()
+}
+
+// 确认删除
+const confirmDelete = (link) => {
+  confirm.require({
+    message: `确定要删除推广链接 "${link.name}" 吗?`,
+    header: '确认删除',
+    icon: 'pi pi-exclamation-triangle',
+    accept: () => deleteLinkRecord(link.id)
+  })
+}
+
+// 删除推广链接
+const deleteLinkRecord = async (id) => {
+  try {
+    await deleteLink(id)
+    toast.add({
+      severity: 'success',
+      summary: '成功',
+      detail: '删除成功',
+      life: 3000
+    })
+    fetchData()
+  } catch {
+    toast.add({
+      severity: 'error',
+      summary: '错误',
+      detail: '删除失败',
+      life: 3000
+    })
+  }
+}
+
+// 文件选择处理
+const onFileSelect = (event) => {
+  const file = event.files[0]
+  if (!file) return
+
+  // 检查文件类型
+  if (!file.type.startsWith('image/')) {
+    toast.add({
+      severity: 'error',
+      summary: '错误',
+      detail: '请选择图片文件',
+      life: 3000
+    })
+    return
+  }
+
+  // 检查文件大小 (50MB)
+  if (file.size > 50000000) {
+    toast.add({
+      severity: 'error',
+      summary: '错误',
+      detail: '图片文件大小不能超过50MB',
+      life: 3000
+    })
+    return
+  }
+
+  const reader = new FileReader()
+  reader.onload = (e) => {
+    imagePreview.value = e.target.result
+    imageFile.value = file
+  }
+  reader.readAsDataURL(file)
+}
+
+// 移除图片
+const removeImage = () => {
+  imagePreview.value = null
+  imageFile.value = null
+}
+
+// 复制到剪贴板
+const copyToClipboard = async (text) => {
+  try {
+    await navigator.clipboard.writeText(text)
+  } catch {
+    const textArea = document.createElement('textarea')
+    textArea.value = text
+    document.body.appendChild(textArea)
+    textArea.select()
+    document.execCommand('copy')
+    document.body.removeChild(textArea)
+  }
+}
+
+const formatSize = (bytes) => {
+  const k = 1024
+  const dm = 3
+  const sizes = $primevue.config.locale.fileSizeTypes
+
+  if (bytes === 0) {
+    return `0 ${sizes[0]}`
+  }
+
+  const i = Math.floor(Math.log(bytes) / Math.log(k))
+  const formattedSize = parseFloat((bytes / Math.pow(k, i)).toFixed(dm))
+
+  return `${formattedSize} ${sizes[i]}`
+}
+
+// 处理图片加载错误
+const handleImageError = (event) => {
+  event.target.style.display = 'none'
+}
+
+// 打开新增弹窗
+const openAddDialog = () => {
+  isEdit.value = false
+  editForm.value = {
+    id: null,
+    name: null,
+    type: null,
+    link: null,
+    image: null
+  }
+  // 重置图片预览状态
+  imagePreview.value = null
+  imageFile.value = null
+  editDialog.value = true
+}
+
+// 打开编辑弹窗
+const openEditDialog = (link) => {
+  isEdit.value = true
+  editForm.value = {
+    id: link.id,
+    name: link.name || null,
+    type: link.type || null,
+    link: link.link || null,
+    image: link.image || null
+  }
+  // 编辑时显示原有图片
+  imagePreview.value = link.image || null
+  imageFile.value = null
+  editDialog.value = true
+}
+
+// 保存编辑
+const saveEdit = async () => {
+  editLoading.value = true
+  try {
+    // 准备保存的数据
+    const saveData = { ...editForm.value }
+
+    // 处理图片逻辑
+    if (imageFile.value) {
+      // 有新选择的图片文件,先上传
+      uploading.value = true
+      try {
+        const result = await uploadImage(imageFile.value)
+        saveData.image = result.data.url
+        toast.add({
+          severity: 'success',
+          summary: '成功',
+          detail: `${result.message || '图片上传成功'} (${formatSize(result.data.size)})`,
+          life: 3000
+        })
+      } catch (error) {
+        console.error('图片上传失败', error)
+        toast.add({
+          severity: 'error',
+          summary: '错误',
+          detail: '图片上传失败: ' + (error.message || error),
+          life: 3000
+        })
+        return
+      } finally {
+        uploading.value = false
+      }
+    } else if (imagePreview.value === null) {
+      // 如果图片被移除,设置为null
+      saveData.image = null
+    } else if (isEdit.value && imagePreview.value && !imageFile.value) {
+      // 编辑时:如果显示原有图片但没有选择新文件,说明图片未修改,删除image参数
+      delete saveData.image
+    }
+
+    if (isEdit.value) {
+      await updateLink(editForm.value.id, saveData)
+    } else {
+      await createLink(saveData)
+    }
+    toast.add({
+      severity: 'success',
+      summary: '成功',
+      detail: isEdit.value ? '更新成功' : '创建成功',
+      life: 3000
+    })
+    editDialog.value = false
+    fetchData()
+  } catch {
+    toast.add({
+      severity: 'error',
+      summary: '错误',
+      detail: isEdit.value ? '更新失败' : '创建失败',
+      life: 3000
+    })
+  } finally {
+    editLoading.value = false
+  }
+}
+
+// 初始化
+onMounted(() => {
+  fetchData()
+})
+</script>
+
+<style scoped>
+.link-card {
+  border: 1px solid #e5e7eb;
+  transition: all 0.2s ease;
+}
+
+.link-card:hover {
+  border-color: #3b82f6;
+  transform: translateY(-2px);
+}
+
+.image-container-card {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  height: 120px;
+  background-color: #f9fafb;
+  border-radius: 8px;
+  border: 1px solid #e5e7eb;
+}
+
+.link-image-card {
+  max-width: 100%;
+  max-height: 100%;
+  object-fit: contain;
+  border-radius: 4px;
+}
+
+.no-image-placeholder {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  height: 100%;
+  font-size: 2rem;
+}
+
+.link-url-text-card {
+  font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
+  font-size: 0.875rem;
+  color: #6b7280;
+  cursor: pointer;
+  transition: color 0.2s ease;
+  word-break: break-all;
+  line-height: 1.4;
+}
+
+.link-url-text-card:hover {
+  color: #3b82f6;
+}
+
+.preview-image {
+  max-width: 100%;
+  max-height: 200px;
+  object-fit: contain;
+  border-radius: 4px;
+  border: 1px solid #e5e7eb;
+}
+
+.font-medium {
+  font-weight: 500;
+}
+
+.text-sm {
+  font-size: 0.875rem;
+}
+
+.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>