Эх сурвалжийг харах

添加团队成员分润功能管理后台页面展示

wilhelm wong 2 сар өмнө
parent
commit
9449bf8c54

+ 5 - 0
src/router/index.js

@@ -83,6 +83,11 @@ const router = createRouter({
           name: 'user-share',
           component: () => import('@/views/UserShareView.vue')
         },
+        {
+          path: 'banner',
+          name: 'banner',
+          component: () => import('@/views/BannerView.vue')
+        },
       ]
     },
     {

+ 96 - 0
src/services/api.js

@@ -414,6 +414,48 @@ export const getTeamLeaderStats = async (teamId) => {
   return response.data
 }
 
+// 获取团队成员IP成交率统计(基于personalAgentId)
+export const getTeamMemberIpConversionRate = async () => {
+  const response = await api.get('/team-members/statistics/ip-conversion-rate')
+  return response.data
+}
+
+// 获取团队成员全部统计(基于personalAgentId)
+export const getTeamMemberAllStatistics = async () => {
+  const response = await api.get('/team-members/statistics/all')
+  return response.data
+}
+
+// 获取团队成员每日统计(基于personalAgentId)
+export const getTeamMemberDailyStatistics = async () => {
+  const response = await api.get('/team-members/statistics/daily')
+  return response.data
+}
+
+// 生成/重新生成推广码
+export const generatePromoCode = async (id) => {
+  const response = await api.post(`/team-members/${id}/generate-promo-code`)
+  return response.data
+}
+
+// 获取推广链接
+export const getPromotionLink = async (id) => {
+  const response = await api.get(`/team-members/${id}/promotion-link`)
+  return response.data
+}
+
+// 获取自己的推广信息(便捷接口)
+export const getMyPromotionInfo = async () => {
+  const response = await api.get('/team-members/my/promotion-info')
+  return response.data
+}
+
+// 重新生成自己的推广码(便捷接口)
+export const generateMyPromoCode = async () => {
+  const response = await api.post('/team-members/my/generate-promo-code')
+  return response.data
+}
+
 // ==================== 推广链接相关API ====================
 
 // 创建推广链接
@@ -617,4 +659,58 @@ export const exportUserShareRecords = async (startDate, endDate, username, teamI
     responseType: 'blob'
   })
   return response.data
+}
+
+// ==================== 广告栏相关API ====================
+
+// 创建广告栏
+export const createBanner = async (bannerData) => {
+  const response = await api.post('/banners', bannerData)
+  return response.data
+}
+
+// 获取广告栏列表(管理员)
+export const listBanners = async (page = 0, size = 20, position, title) => {
+  const params = { page, size }
+  if (position) params.position = position
+  if (title) params.title = title
+
+  const response = await api.get('/banners', { params })
+  return response.data
+}
+
+// 根据位置获取广告栏列表(公开接口)
+export const getBannersByPosition = async (position) => {
+  const response = await api.get(`/banners/position/${position}`)
+  return response.data
+}
+
+// 获取单个广告栏详情
+export const getBanner = async (id) => {
+  const response = await api.get(`/banners/${id}`)
+  return response.data
+}
+
+// 更新广告栏
+export const updateBanner = async (id, bannerData) => {
+  const response = await api.put(`/banners/${id}`, bannerData)
+  return response.data
+}
+
+// 删除广告栏
+export const deleteBanner = async (id) => {
+  const response = await api.delete(`/banners/${id}`)
+  return response.data
+}
+
+// 记录广告栏点击
+export const recordBannerClick = async (id) => {
+  const response = await api.post(`/banners/${id}/click`)
+  return response.data
+}
+
+// 获取广告栏统计数据
+export const getBannerStatistics = async () => {
+  const response = await api.get('/banners/statistics/summary')
+  return response.data
 }

+ 764 - 0
src/views/BannerView.vue

@@ -0,0 +1,764 @@
+<script setup>
+import { createBanner, listBanners, updateBanner, deleteBanner, uploadImage, getBannerStatistics } 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 FileUpload from 'primevue/fileupload'
+import FloatLabel from 'primevue/floatlabel'
+import IconField from 'primevue/iconfield'
+import InputIcon from 'primevue/inputicon'
+import InputText from 'primevue/inputtext'
+import InputSwitch from 'primevue/inputswitch'
+import Message from 'primevue/message'
+import Select from 'primevue/select'
+import { useConfirm } from 'primevue/useconfirm'
+import { useToast } from 'primevue/usetoast'
+import { computed, onMounted, ref } from 'vue'
+import { z } from 'zod'
+
+const toast = useToast()
+const confirm = useConfirm()
+
+const tableData = ref({
+  content: [],
+  metadata: {
+    page: 0,
+    size: 20,
+    total: 0
+  }
+})
+
+const searchForm = ref({
+  position: null,
+  title: ''
+})
+
+const loading = ref(false)
+
+const fetchData = async () => {
+  loading.value = true
+  try {
+    const response = await listBanners(
+      tableData.value.metadata.page,
+      tableData.value.metadata.size,
+      searchForm.value.position || undefined,
+      searchForm.value.title || undefined
+    )
+    
+    // 获取统计数据并合并到广告栏数据中
+    try {
+      const statistics = await getBannerStatistics()
+      
+      // 根据文档,统计接口返回格式:{ date, banners: [{ bannerId, todayUniqueIps, totalUniqueIps, ... }], summary }
+      if (statistics && statistics.banners && Array.isArray(statistics.banners)) {
+        // 创建统计数据映射表,以 bannerId 为键
+        const statsMap = {}
+        statistics.banners.forEach((stat) => {
+          if (stat.bannerId) {
+            statsMap[stat.bannerId] = {
+              clickCount: stat.totalUniqueIps || 0,      // 全期独立IP数映射到总点击数
+              todayClickCount: stat.todayUniqueIps || 0   // 今日独立IP数映射到今日点击数
+            }
+          }
+        })
+        
+        // 合并统计数据到广告栏列表
+        if (response.content && Array.isArray(response.content)) {
+          response.content = response.content.map((banner) => {
+            const stat = statsMap[banner.id]
+            return {
+              ...banner,
+              // 优先使用统计数据,如果没有则保持原有数据
+              clickCount: stat?.clickCount ?? banner.clickCount ?? 0,
+              todayClickCount: stat?.todayClickCount ?? banner.todayClickCount ?? 0
+            }
+          })
+        }
+      }
+    } catch (statError) {
+      // 统计接口调用失败不影响主流程,只记录错误
+      console.warn('获取广告栏统计数据失败:', statError)
+    }
+    
+    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 = {
+    position: null,
+    title: ''
+  }
+  tableData.value.metadata.page = 0
+  fetchData()
+}
+
+const formatDate = (date) => {
+  return useDateFormat(new Date(date), 'YYYY-MM-DD HH:mm:ss').value
+}
+
+// 位置选项
+const positionOptions = [
+  { value: 'top', label: '顶部' },
+  { value: 'middle', label: '中部' },
+  { value: 'bottom', label: '底部' }
+]
+
+// 获取位置名称
+const getPositionName = (position) => {
+  const option = positionOptions.find(opt => opt.value === position)
+  return option ? option.label : position
+}
+
+// Banner表单相关
+const bannerDialog = ref(false)
+const isEditMode = ref(false)
+const bannerForm = ref({
+  id: null,
+  image: '',
+  title: '',
+  link: '',
+  position: 'top',
+  enabled: true
+})
+const bannerFormLoading = ref(false)
+const uploading = ref(false)
+
+// 图片预览相关
+const imagePreview = ref(null)
+const imageFile = ref(null)
+
+const bannerFormResolver = computed(() => {
+  return zodResolver(
+    z.object({
+      title: z.string().min(1, { message: '广告标题不能为空' }),
+      link: z.string().min(1, { message: '跳转链接不能为空' }).url({ message: '请输入有效的URL' }),
+      position: z.string().min(1, { message: '请选择广告位置' })
+    }).refine(() => imagePreview.value !== null, {
+      message: '请上传广告图片',
+      path: ['image']
+    })
+  )
+})
+
+const openNewBannerDialog = () => {
+  bannerForm.value = {
+    id: null,
+    image: '',
+    title: '',
+    link: '',
+    position: 'top',
+    enabled: true
+  }
+  // 重置图片预览状态
+  imagePreview.value = null
+  imageFile.value = null
+  isEditMode.value = false
+  bannerDialog.value = true
+}
+
+const openEditBannerDialog = (banner) => {
+  bannerForm.value = {
+    id: banner.id,
+    image: banner.image,
+    title: banner.title,
+    link: banner.link,
+    position: banner.position,
+    enabled: banner.enabled !== undefined ? banner.enabled : true
+  }
+  // 编辑时显示原有图片
+  imagePreview.value = banner.image || null
+  imageFile.value = null
+  isEditMode.value = true
+  bannerDialog.value = true
+}
+
+// 文件选择处理
+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 saveBanner = async ({ valid, values }) => {
+  if (!valid) return
+
+  // 验证图片
+  if (!imagePreview.value) {
+    toast.add({
+      severity: 'error',
+      summary: '错误',
+      detail: '请上传广告图片',
+      life: 3000
+    })
+    return
+  }
+
+  bannerFormLoading.value = true
+  try {
+    // 准备保存的数据
+    const saveData = {
+      title: values.title,
+      link: values.link,
+      position: values.position,
+      enabled: bannerForm.value.enabled
+    }
+
+    // 处理图片逻辑
+    if (imageFile.value) {
+      // 有新选择的图片文件,先上传
+      uploading.value = true
+      try {
+        const result = await uploadImage(imageFile.value)
+        saveData.image = result.data?.url || result.url || result.path || result.data || result
+        toast.add({
+          severity: 'success',
+          summary: '成功',
+          detail: result.message || '图片上传成功',
+          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) {
+      // 如果图片被移除
+      if (isEditMode.value) {
+        // 编辑时允许移除图片,设置为空字符串
+        saveData.image = ''
+      } else {
+        // 新建时不允许无图片
+        toast.add({
+          severity: 'error',
+          summary: '错误',
+          detail: '请上传广告图片',
+          life: 3000
+        })
+        return
+      }
+    } else if (isEditMode.value && imagePreview.value && !imageFile.value) {
+      // 编辑时:如果显示原有图片但没有选择新文件,说明图片未修改
+      // 不传递image参数,让后端保持原值(与LinkView保持一致)
+      // 这样可以避免传递可能过期的URL或base64数据
+      delete saveData.image
+    } else {
+      // 新建时必须有图片文件
+      toast.add({
+        severity: 'error',
+        summary: '错误',
+        detail: '请上传广告图片',
+        life: 3000
+      })
+      return
+    }
+
+    if (isEditMode.value) {
+      await updateBanner(bannerForm.value.id, saveData)
+      toast.add({
+        severity: 'success',
+        summary: '成功',
+        detail: '广告栏更新成功',
+        life: 3000
+      })
+    } else {
+      await createBanner(saveData)
+      toast.add({
+        severity: 'success',
+        summary: '成功',
+        detail: '广告栏创建成功',
+        life: 3000
+      })
+    }
+
+    bannerDialog.value = false
+    fetchData()
+  } catch (error) {
+    const errorMsg = error.message || (isEditMode.value ? '更新广告栏失败' : '创建广告栏失败')
+    toast.add({
+      severity: 'error',
+      summary: '错误',
+      detail: errorMsg,
+      life: 3000
+    })
+  } finally {
+    bannerFormLoading.value = false
+  }
+}
+
+const confirmDelete = (banner) => {
+  confirm.require({
+    message: `确定要删除广告栏 "${banner.title}" 吗?`,
+    header: '确认删除',
+    icon: 'pi pi-exclamation-triangle',
+    accept: () => deleteBannerRecord(banner.id)
+  })
+}
+
+const deleteBannerRecord = async (id) => {
+  try {
+    await deleteBanner(id)
+    toast.add({
+      severity: 'success',
+      summary: '成功',
+      detail: '广告栏已删除',
+      life: 3000
+    })
+    fetchData()
+  } catch (error) {
+    toast.add({
+      severity: 'error',
+      summary: '错误',
+      detail: error.message || '删除广告栏失败',
+      life: 3000
+    })
+  }
+}
+
+onMounted(() => {
+  fetchData()
+})
+</script>
+
+<template>
+  <div class="rounded-lg p-4 bg-[var(--p-content-background)]">
+    <DataTable
+      :value="tableData.content"
+      :loading="loading"
+      :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="search-toolbar">
+          <div class="toolbar-left">
+            <Button icon="pi pi-refresh" @click="fetchData" size="small" label="刷新" />
+            <Button
+              icon="pi pi-plus"
+              @click="openNewBannerDialog"
+              label="新增广告栏"
+              severity="success"
+              size="small"
+            />
+          </div>
+          <div class="toolbar-right">
+            <div class="search-group">
+              <Select
+                v-model="searchForm.position"
+                :options="positionOptions"
+                optionLabel="label"
+                optionValue="value"
+                placeholder="选择位置"
+                size="small"
+                class="search-field"
+                clearable
+              />
+              <InputText
+                v-model="searchForm.title"
+                placeholder="搜索标题"
+                size="small"
+                class="search-field"
+                @keyup.enter="handleSearch"
+              />
+            </div>
+            <div class="action-group">
+              <Button icon="pi pi-search" @click="handleSearch" label="搜索" size="small" severity="secondary" />
+              <Button icon="pi pi-refresh" @click="handleRefresh" label="重置" size="small" />
+            </div>
+          </div>
+        </div>
+      </template>
+      <Column field="id" header="ID" style="width: 80px"></Column>
+      <Column header="图片" style="width: 120px">
+        <template #body="slotProps">
+          <img
+            v-if="slotProps.data.image"
+            :src="slotProps.data.image"
+            :alt="slotProps.data.title"
+            class="banner-image-preview"
+          />
+          <span v-else class="text-gray-400">无图片</span>
+        </template>
+      </Column>
+      <Column field="title" header="标题"></Column>
+      <Column field="link" header="跳转链接" style="min-width: 200px">
+        <template #body="slotProps">
+          <a :href="slotProps.data.link" target="_blank" class="link-text">
+            {{ slotProps.data.link }}
+          </a>
+        </template>
+      </Column>
+      <Column field="position" header="位置" style="width: 100px">
+        <template #body="slotProps">
+          <span class="px-2 py-1 rounded-md text-sm bg-blue-100 text-blue-800">
+            {{ getPositionName(slotProps.data.position) }}
+          </span>
+        </template>
+      </Column>
+      <Column field="enabled" header="状态" style="width: 100px">
+        <template #body="slotProps">
+          <span
+            :class="[
+              'px-2 py-1 rounded-md text-sm',
+              slotProps.data.enabled ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'
+            ]"
+          >
+            {{ slotProps.data.enabled ? '启用' : '禁用' }}
+          </span>
+        </template>
+      </Column>
+      <Column field="clickCount" header="总点击数" style="width: 100px"></Column>
+      <Column field="todayClickCount" header="今日点击数" style="width: 120px"></Column>
+      <Column field="createdAt" header="创建时间" style="min-width: 180px">
+        <template #body="slotProps">
+          {{ formatDate(slotProps.data.createdAt) }}
+        </template>
+      </Column>
+      <Column header="操作" style="min-width: 150px">
+        <template #body="slotProps">
+          <Button
+            icon="pi pi-pencil"
+            severity="info"
+            size="small"
+            text
+            rounded
+            aria-label="编辑"
+            @click="openEditBannerDialog(slotProps.data)"
+          />
+          <Button
+            icon="pi pi-trash"
+            severity="danger"
+            size="small"
+            text
+            rounded
+            aria-label="删除"
+            @click="confirmDelete(slotProps.data)"
+          />
+        </template>
+      </Column>
+    </DataTable>
+
+    <!-- Banner表单对话框 -->
+    <Dialog
+      v-model:visible="bannerDialog"
+      :modal="true"
+      :header="isEditMode ? '编辑广告栏' : '创建广告栏'"
+      :style="{ width: '600px' }"
+      position="center"
+    >
+      <Form v-slot="$form" :resolver="bannerFormResolver" :initialValues="bannerForm" @submit="saveBanner" class="p-fluid">
+        <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 v-if="uploading" class="mt-2 text-sm text-gray-500">上传中...</div>
+          <Message v-if="$form.image?.invalid" severity="error" size="small" variant="simple" class="mt-2">
+            {{ $form.image.error?.message }}
+          </Message>
+        </div>
+
+        <div class="field mt-4">
+          <FloatLabel variant="on">
+            <IconField>
+              <InputIcon class="pi pi-heading" />
+              <InputText id="title" name="title" v-model="bannerForm.title" autocomplete="off" fluid />
+            </IconField>
+            <label for="title">广告标题</label>
+          </FloatLabel>
+          <Message v-if="$form.title?.invalid" severity="error" size="small" variant="simple">
+            {{ $form.title.error?.message }}
+          </Message>
+        </div>
+
+        <div class="field mt-4">
+          <FloatLabel variant="on">
+            <IconField>
+              <InputIcon class="pi pi-link" />
+              <InputText id="link" name="link" v-model="bannerForm.link" autocomplete="off" fluid />
+            </IconField>
+            <label for="link">跳转链接</label>
+          </FloatLabel>
+          <Message v-if="$form.link?.invalid" severity="error" size="small" variant="simple">
+            {{ $form.link.error?.message }}
+          </Message>
+        </div>
+
+        <div class="field mt-4">
+          <FloatLabel variant="on">
+            <Select
+              id="position"
+              name="position"
+              v-model="bannerForm.position"
+              :options="positionOptions"
+              optionLabel="label"
+              optionValue="value"
+              fluid
+            />
+            <label for="position">广告位置</label>
+          </FloatLabel>
+          <Message v-if="$form.position?.invalid" severity="error" size="small" variant="simple">
+            {{ $form.position.error?.message }}
+          </Message>
+        </div>
+
+        <div class="field mt-4">
+          <div class="flex items-center gap-3">
+            <InputSwitch id="enabled" v-model="bannerForm.enabled" />
+            <label for="enabled" class="font-medium">启用状态</label>
+          </div>
+          <small class="text-gray-500 mt-1 block">禁用后该广告将不会在前端显示</small>
+        </div>
+
+        <div class="flex justify-end gap-2 mt-4">
+          <Button
+            label="取消"
+            severity="secondary"
+            type="button"
+            @click="bannerDialog = false"
+            :disabled="bannerFormLoading"
+          />
+          <Button label="保存" type="submit" :loading="bannerFormLoading" />
+        </div>
+      </Form>
+    </Dialog>
+
+    <ConfirmDialog />
+  </div>
+</template>
+
+<style scoped>
+/* 搜索工具栏样式 */
+.search-toolbar {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  justify-content: space-between;
+  gap: 16px;
+  margin-bottom: 20px;
+  padding: 16px;
+  background: #f8fafc;
+  border-radius: 8px;
+  border: 1px solid #e2e8f0;
+}
+
+.toolbar-left {
+  display: flex;
+  gap: 8px;
+  align-items: center;
+}
+
+.toolbar-left .p-button {
+  font-size: 13px;
+  padding: 6px 12px;
+  border-radius: 6px;
+  font-weight: 500;
+}
+
+.toolbar-right {
+  display: flex;
+  gap: 12px;
+  align-items: center;
+  flex-wrap: wrap;
+}
+
+.search-group {
+  display: flex;
+  gap: 8px;
+  align-items: center;
+}
+
+.search-field {
+  width: 200px;
+  font-size: 13px;
+}
+
+.action-group {
+  display: flex;
+  gap: 8px;
+  align-items: center;
+}
+
+.action-group .p-button {
+  font-size: 13px;
+  padding: 6px 12px;
+  border-radius: 6px;
+  font-weight: 500;
+}
+
+.banner-image-preview {
+  width: 80px;
+  height: 50px;
+  object-fit: cover;
+  border-radius: 4px;
+  border: 1px solid #e2e8f0;
+}
+
+.link-text {
+  color: #3b82f6;
+  text-decoration: none;
+  max-width: 300px;
+  display: inline-block;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.link-text:hover {
+  text-decoration: underline;
+}
+
+.image-upload-section {
+  margin-bottom: 8px;
+}
+
+.image-preview {
+  max-width: 100%;
+  max-height: 300px;
+  border-radius: 4px;
+  border: 1px solid #e2e8f0;
+}
+
+/* 响应式设计 */
+@media (max-width: 768px) {
+  .search-toolbar {
+    flex-direction: column;
+    gap: 12px;
+    align-items: stretch;
+    padding: 12px;
+  }
+
+  .toolbar-left {
+    justify-content: center;
+    gap: 8px;
+  }
+
+  .toolbar-left .p-button {
+    flex: 1;
+    max-width: 120px;
+    font-size: 13px;
+    padding: 8px 12px;
+  }
+
+  .toolbar-right {
+    flex-direction: column;
+    gap: 12px;
+    align-items: stretch;
+  }
+
+  .search-group {
+    flex-direction: column;
+    gap: 8px;
+  }
+
+  .search-field {
+    width: 100%;
+    font-size: 14px;
+  }
+
+  .action-group {
+    justify-content: center;
+    gap: 8px;
+  }
+
+  .action-group .p-button {
+    flex: 1;
+    max-width: 140px;
+    font-size: 13px;
+    padding: 8px 12px;
+  }
+}
+</style>
+

+ 196 - 35
src/views/DashboardView.vue

@@ -61,15 +61,49 @@
                 <span class="text-gray-500 text-sm mb-1">分成比例</span>
                 <span class="text-base font-bold text-purple-700">{{ memberData.commissionRate }}%</span>
               </div>
-              <div class="flex flex-col">
+              <div class="flex flex-col sm:col-span-2">
                 <span class="text-gray-500 text-sm mb-1">推广码</span>
-                <span
-                  class="text-base font-bold text-orange-600 font-mono copyable-text cursor-pointer"
-                  :title="memberData.affCode || '-'"
-                  @click="memberData.affCode && copyToClipboard(memberData.affCode)"
-                >
-                  {{ memberData.affCode || '-' }}
-                </span>
+                <div class="flex items-center gap-2">
+                  <span
+                    class="text-base font-bold text-orange-600 font-mono copyable-text cursor-pointer flex-1"
+                    :title="memberData.promoCode || memberData.affCode || '-'"
+                    @click="(memberData.promoCode || memberData.affCode) && copyToClipboard(memberData.promoCode || memberData.affCode)"
+                  >
+                    {{ memberData.promoCode || memberData.affCode || '-' }}
+                  </span>
+                  <Button
+                    icon="pi pi-qrcode"
+                    severity="warning"
+                    size="small"
+                    text
+                    rounded
+                    title="生成推广码"
+                    @click="handleGeneratePromoCode"
+                  />
+                </div>
+              </div>
+              <div class="flex flex-col sm:col-span-2">
+                <span class="text-gray-500 text-sm mb-1">推广链接</span>
+                <div class="flex items-center gap-2">
+                  <span
+                    v-if="promoLink"
+                    class="text-base font-bold text-blue-600 font-mono copyable-text cursor-pointer flex-1 truncate"
+                    :title="promoLink"
+                    @click="copyToClipboard(promoLink)"
+                  >
+                    {{ promoLink }}
+                  </span>
+                  <span v-else class="text-base text-gray-400 flex-1">-</span>
+                  <Button
+                    icon="pi pi-link"
+                    severity="success"
+                    size="small"
+                    text
+                    rounded
+                    title="生成推广链接"
+                    @click="handleGenerateLink"
+                  />
+                </div>
               </div>
               <div class="flex flex-col">
                 <span class="text-gray-500 text-sm mb-1">总用户数</span>
@@ -317,6 +351,49 @@
       </template>
     </Dialog>
 
+    <!-- 推广码/链接展示弹窗(推广员) -->
+    <Dialog
+      v-model:visible="promoDialog"
+      :modal="true"
+      :header="promoDialogTitle"
+      :style="{ width: '500px' }"
+      position="center"
+    >
+      <div class="p-fluid">
+        <div v-if="promoData.promoCode" class="field">
+          <label class="font-medium text-sm mb-2 block">推广码</label>
+          <div class="flex items-center gap-2">
+            <InputText :value="promoData.promoCode" readonly class="flex-1" />
+            <Button
+              icon="pi pi-copy"
+              size="small"
+              @click="copyToClipboard(promoData.promoCode)"
+              title="复制推广码"
+            />
+          </div>
+        </div>
+
+        <div v-if="promoData.promotionLink" class="field mt-4">
+          <label class="font-medium text-sm mb-2 block">推广链接</label>
+          <div class="flex items-center gap-2">
+            <InputText :value="promoData.promotionLink" readonly class="flex-1" />
+            <Button
+              icon="pi pi-copy"
+              size="small"
+              @click="copyToClipboard(promoData.promotionLink)"
+              title="复制推广链接"
+            />
+          </div>
+        </div>
+      </div>
+
+      <template #footer>
+        <div class="flex justify-end gap-3">
+          <Button label="关闭" severity="secondary" @click="promoDialog = false" />
+        </div>
+      </template>
+    </Dialog>
+
     <!-- 团队数据表格 (仅管理员可见) -->
     <div v-if="isAdmin" class="teams-table-container">
       <h2>团队数据一览</h2>
@@ -362,7 +439,7 @@
 import { ref, onMounted, onUnmounted, computed, inject, watch } from 'vue'
 import { useUserStore } from '@/stores/user'
 import { useTeamStore } from '@/stores/team'
-import { getAllTeamStatistics, getIncomeStatistics, listMembers, getTeamIpConversionRate, listTeams, getTeamDomainDailyStatistics, getTeamDomainAllStatistics, updateTeamThemeColor } from '@/services/api'
+import { getAllTeamStatistics, getIncomeStatistics, listMembers, getTeamIpConversionRate, listTeams, getTeamDomainDailyStatistics, getTeamDomainAllStatistics, updateTeamThemeColor, generatePromoCode, getPromotionLink, getMyPromotionInfo, generateMyPromoCode, getTeamMemberIpConversionRate, getTeamMemberAllStatistics, getTeamMemberDailyStatistics } from '@/services/api'
 import { useToast } from 'primevue/usetoast'
 import Chart from 'chart.js/auto'
 import Select from 'primevue/select'
@@ -370,6 +447,7 @@ import DataTable from 'primevue/datatable'
 import Column from 'primevue/column'
 import Button from 'primevue/button'
 import Dialog from 'primevue/dialog'
+import InputText from 'primevue/inputtext'
 
 const userStore = useUserStore()
 const teamStore = useTeamStore()
@@ -391,6 +469,13 @@ const chartInstance = ref(null)
 const selectedChartTeamId = ref(null) // 用于图表的团队选择
 const ipStats = ref(null) // IP成交率数据
 const teamListData = ref(null) // 团队列表数据(包含基础信息如affCode等)
+const promoLink = ref(null) // 推广链接
+const promoDialog = ref(false) // 推广码/链接弹窗
+const promoDialogTitle = ref('')
+const promoData = ref({
+  promoCode: null,
+  promotionLink: null
+})
 
 // 主题选择相关
 const themeDialog = ref(false)
@@ -551,9 +636,11 @@ const teamData = computed(() => {
 const memberData = computed(() => {
   if (isPromoter.value && memberStats.value) {
     return {
+      id: memberStats.value.id || null, // 成员ID,用于生成推广码和链接
       name: memberStats.value.name || '-',
       commissionRate: memberStats.value.commissionRate || 0,
-      affCode: memberStats.value.affCode || null,
+      promoCode: memberStats.value.promoCode || null, // 使用promoCode
+      affCode: memberStats.value.affCode || null, // 保留用于向后兼容
       todayRevenue: memberStats.value.todayRevenue || memberStats.value.todayCommission || 0,
       totalRevenue: memberStats.value.totalRevenue || memberStats.value.totalCommission || 0,
       todayCommission: memberStats.value.todayCommission || 0,
@@ -627,11 +714,81 @@ const copyToClipboard = async (text) => {
   }
 }
 
+// 生成推广码(推广员)
+const handleGeneratePromoCode = async () => {
+  try {
+    const response = await generateMyPromoCode()
+    promoData.value = {
+      promoCode: response.promoCode,
+      promotionLink: response.promotionLink || null
+    }
+    promoDialogTitle.value = `推广码 - ${memberData.value.name}`
+    promoDialog.value = true
+    toast.add({
+      severity: 'success',
+      summary: '成功',
+      detail: response.message || '推广码生成成功',
+      life: 3000
+    })
+    // 刷新数据以更新推广码
+    await loadTeamStats()
+  } catch (error) {
+    const errorMessage = error?.message || error?.detail || '生成推广码失败'
+    toast.add({
+      severity: 'error',
+      summary: '错误',
+      detail: errorMessage,
+      life: 3000
+    })
+  }
+}
+
+// 生成推广链接(推广员)
+const handleGenerateLink = async () => {
+  try {
+    const response = await getMyPromotionInfo()
+    if (response.promotionLink) {
+      promoLink.value = response.promotionLink
+      promoData.value = {
+        promoCode: response.promoCode,
+        promotionLink: response.promotionLink
+      }
+      promoDialogTitle.value = `推广链接 - ${memberData.value.name}`
+      promoDialog.value = true
+      toast.add({
+        severity: 'success',
+        summary: '成功',
+        detail: '获取推广链接成功',
+        life: 3000
+      })
+    } else {
+      toast.add({
+        severity: 'warn',
+        summary: '提示',
+        detail: '推广码不存在,请先生成推广码',
+        life: 3000
+      })
+    }
+  } catch (error) {
+    const errorMessage = error?.message || error?.detail || '获取推广链接失败'
+    toast.add({
+      severity: 'error',
+      summary: '错误',
+      detail: errorMessage,
+      life: 3000
+    })
+  }
+}
+
 // 加载IP成交率数据
 const loadIpConversionRate = async () => {
   if (!isTeam.value) return
   try {
-    const data = await getTeamIpConversionRate()
+    // 优先使用基于personalAgentId的新接口,如果失败则回退到旧接口
+    const data = await getTeamMemberIpConversionRate().catch(() => {
+      // 如果新接口失败,尝试使用旧接口(基于domainId)
+      return getTeamIpConversionRate()
+    })
     ipStats.value = data
   } catch (e) {
     // 团队角色无权限外的错误提示
@@ -732,50 +889,54 @@ const loadTeamStats = async () => {
       }
       teamStats.value = statsData
     } else if (isPromoter.value) {
-      // 推广员角色加载个人统计数据,使用团队成员列表接口和域名统计接口
+      // 推广员角色加载个人统计数据,使用基于personalAgentId的新接口
       const currentUserId = userStore.userInfo?.id
       if (currentUserId) {
-        // 并行请求团队成员数据、域名统计数据和IP成交率数据
+        // 并行请求团队成员基础信息、新统计接口和推广信息
         const promises = [
           listMembers(0, 1, undefined, undefined, currentUserId),
-          getTeamDomainDailyStatistics(),
-          getTeamDomainAllStatistics(),
-          getTeamIpConversionRate().catch(() => null) // 如果接口不支持推广员,返回null
+          getTeamMemberDailyStatistics().catch(() => null), // 每日统计(基于personalAgentId)
+          getTeamMemberAllStatistics().catch(() => null), // 全部统计(基于personalAgentId)
+          getTeamMemberIpConversionRate().catch(() => null), // IP成交率(基于personalAgentId)
+          getMyPromotionInfo().catch(() => null) // 获取推广信息,如果失败返回null
         ]
-        const [membersResponse, dailyStatsResponse, allStatsResponse, ipStatsData] = await Promise.all(promises)
+        const [membersResponse, dailyStatsResponse, allStatsResponse, ipStatsData, promotionInfo] = await Promise.all(promises)
         
-        // 从列表接口返回的数据中提取第一个成员(当前用户)的统计数据
+        // 从列表接口返回的数据中提取第一个成员(当前用户)的基础信息
         let memberDataItem = null
         if (membersResponse.content && membersResponse.content.length > 0) {
           memberDataItem = membersResponse.content[0]
         }
         
-        // 从域名统计数据中提取(绑定的只返回一条数据)
-        const dailyStats = Array.isArray(dailyStatsResponse) && dailyStatsResponse.length > 0 ? dailyStatsResponse[0] : null
-        const allStats = Array.isArray(allStatsResponse) && allStatsResponse.length > 0 ? allStatsResponse[0] : null
-        
-        // 合并数据,优先使用域名统计数据
+        // 合并数据,使用新接口返回的统计数据
         memberStats.value = {
+          id: memberDataItem?.id || null, // 保存成员ID,用于生成推广码和链接
           name: memberDataItem?.name || '-',
           commissionRate: memberDataItem?.commissionRate || 0,
-          affCode: memberDataItem?.affCode || null,
-          // 优先使用域名统计数据,如果没有则使用成员数据
-          todayRevenue: dailyStats?.todayIncome || memberDataItem?.todayRevenue || 0,
-          totalRevenue: allStats?.totalIncome || memberDataItem?.totalRevenue || 0,
-          todayCommission: dailyStats?.todayIncome || memberDataItem?.todayRevenue || 0,
-          totalCommission: allStats?.totalIncome || memberDataItem?.totalRevenue || 0,
-          todaySales: dailyStats?.todaySales || memberDataItem?.todaySales || 0,
-          totalSales: allStats?.totalSales || memberDataItem?.totalSales || 0,
-          todayDAU: dailyStats?.todayActiveUsers || allStats?.todayActiveUsers || 0,
-          totalUsers: allStats?.totalNewUsers || 0,
-          todayNewUsers: dailyStats?.todayNewUsers || 0,
-          // IP成交率数据
+          promoCode: promotionInfo?.promoCode || memberDataItem?.promoCode || null, // 优先使用推广信息接口返回的promoCode
+          affCode: memberDataItem?.affCode || null, // 保留affCode用于向后兼容
+          // 使用新接口返回的统计数据(基于personalAgentId)
+          todayRevenue: dailyStatsResponse?.todayIncome || 0, // 今日收入(personalIncomeAmount)
+          totalRevenue: allStatsResponse?.totalIncome || dailyStatsResponse?.totalIncome || 0, // 总收入(personalIncomeAmount)
+          todayCommission: dailyStatsResponse?.todayIncome || 0, // 今日分成(personalIncomeAmount)
+          totalCommission: allStatsResponse?.totalIncome || dailyStatsResponse?.totalIncome || 0, // 总分成(personalIncomeAmount)
+          todaySales: dailyStatsResponse?.todaySales || 0, // 今日销售额(orderPrice)
+          totalSales: allStatsResponse?.totalSales || dailyStatsResponse?.totalSales || 0, // 总销售额(orderPrice)
+          todayDAU: dailyStatsResponse?.todayActiveUsers || allStatsResponse?.todayActiveUsers || 0, // 今日活跃用户数
+          totalUsers: allStatsResponse?.totalNewUsers || ipStatsData?.totalUsers || 0, // 总用户数
+          todayNewUsers: dailyStatsResponse?.todayNewUsers || 0, // 今日新增用户数
+          // IP成交率数据(基于personalAgentId)
           todayIpConversionRate: ipStatsData?.todayIpConversionRate || null,
           totalIpConversionRate: ipStatsData?.totalIpConversionRate || null,
           totalOrders: 0, // 列表接口没有返回订单数,设为0
           todayOrders: 0 // 列表接口没有返回订单数,设为0
         }
         
+        // 如果推广信息接口返回了推广链接,保存它
+        if (promotionInfo?.promotionLink) {
+          promoLink.value = promotionInfo.promotionLink
+        }
+        
         // 保存IP成交率数据到ipStats,供模板使用
         if (ipStatsData) {
           ipStats.value = ipStatsData

+ 336 - 4
src/views/LinkView.vue

@@ -26,6 +26,14 @@
       />
       <Button icon="pi pi-search" @click="handleSearch" label="搜索" size="small" severity="secondary" />
       <Button icon="pi pi-refresh" @click="handleRefresh" label="刷新" size="small" />
+      <Button
+        v-if="isAdmin || isTeam"
+        icon="pi pi-link"
+        @click="openGenerateLinkDialog"
+        label="生成推广链接"
+        size="small"
+        severity="success"
+      />
       <Button
         v-if="isAdmin || isTeam"
         icon="pi pi-plus"
@@ -275,7 +283,24 @@
             placeholder="选择团队"
             class="w-full"
             showClear
+            @change="handleTeamChange"
+          />
+        </div>
+
+        <div v-if="(isAdmin || isTeam) && !isEdit" class="field mt-4">
+          <label for="edit-teamMemberId" class="font-medium text-sm mb-2 block">选择团队成员(基于推广码生成链接)</label>
+          <Select
+            id="edit-teamMemberId"
+            v-model="editForm.teamMemberId"
+            :options="teamMemberOptions"
+            optionLabel="label"
+            optionValue="value"
+            placeholder="选择团队成员"
+            class="w-full"
+            showClear
+            @change="handleTeamMemberChange"
           />
+          <small class="text-gray-500 mt-1 block">选择团队成员后,将自动生成推广链接</small>
         </div>
 
         <div class="field mt-4">
@@ -318,6 +343,85 @@
         </div>
       </template>
     </Dialog>
+
+    <!-- 生成推广链接弹窗 -->
+    <Dialog
+      v-model:visible="generateLinkDialog"
+      :modal="true"
+      header="生成推广链接"
+      :style="{ width: '500px' }"
+      position="center"
+    >
+      <div class="p-fluid">
+        <div v-if="isAdmin" class="field">
+          <label for="generate-teamId" class="font-medium text-sm mb-2 block">选择团队</label>
+          <Select
+            id="generate-teamId"
+            v-model="generateForm.teamId"
+            :options="teamSelectOptions"
+            optionLabel="label"
+            optionValue="value"
+            placeholder="选择团队"
+            class="w-full"
+            showClear
+            @change="handleGenerateTeamChange"
+          />
+        </div>
+
+        <div class="field mt-4">
+          <label for="generate-teamMemberId" class="font-medium text-sm mb-2 block">选择团队成员</label>
+          <Select
+            id="generate-teamMemberId"
+            v-model="generateForm.teamMemberId"
+            :options="generateTeamMemberOptions"
+            optionLabel="label"
+            optionValue="value"
+            placeholder="选择团队成员"
+            class="w-full"
+            showClear
+          />
+        </div>
+
+        <div v-if="generatePromoData.promoCode" class="field mt-4">
+          <label class="font-medium text-sm mb-2 block">推广码</label>
+          <div class="flex items-center gap-2">
+            <InputText :value="generatePromoData.promoCode" readonly class="flex-1" />
+            <Button
+              icon="pi pi-copy"
+              size="small"
+              @click="copyToClipboard(generatePromoData.promoCode)"
+              title="复制推广码"
+            />
+          </div>
+        </div>
+
+        <div v-if="generatePromoData.promotionLink" class="field mt-4">
+          <label class="font-medium text-sm mb-2 block">推广链接</label>
+          <div class="flex items-center gap-2">
+            <InputText :value="generatePromoData.promotionLink" readonly class="flex-1" />
+            <Button
+              icon="pi pi-copy"
+              size="small"
+              @click="copyToClipboard(generatePromoData.promotionLink)"
+              title="复制推广链接"
+            />
+          </div>
+        </div>
+      </div>
+
+      <template #footer>
+        <div class="flex justify-end gap-3">
+          <Button label="关闭" severity="secondary" @click="generateLinkDialog = false" />
+          <Button
+            label="生成链接"
+            severity="success"
+            @click="handleGenerateLink"
+            :loading="generateLinkLoading"
+            :disabled="!generateForm.teamMemberId"
+          />
+        </div>
+      </template>
+    </Dialog>
   </div>
 </template>
 
@@ -331,7 +435,7 @@ 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 { listLinks, createLink, updateLink, deleteLink, uploadImage, listMembers, getPromotionLink } from '@/services/api'
 import { LinkType } from '@/enums'
 import { useTeamStore } from '@/stores/team'
 import { useUserStore } from '@/stores/user'
@@ -376,7 +480,36 @@ const editForm = ref({
   type: null,
   link: null,
   image: null,
-  teamId: null
+  teamId: null,
+  teamMemberId: null
+})
+
+// 团队成员选项
+const teamMembers = ref([])
+const teamMemberOptions = computed(() => {
+  return teamMembers.value.map((member) => ({
+    label: `${member.name}${member.promoCode ? ` (${member.promoCode})` : ''}`,
+    value: member.id
+  }))
+})
+
+// 生成推广链接相关
+const generateLinkDialog = ref(false)
+const generateLinkLoading = ref(false)
+const generateForm = ref({
+  teamId: null,
+  teamMemberId: null
+})
+const generateTeamMembers = ref([])
+const generateTeamMemberOptions = computed(() => {
+  return generateTeamMembers.value.map((member) => ({
+    label: `${member.name}${member.promoCode ? ` (${member.promoCode})` : ''}`,
+    value: member.id
+  }))
+})
+const generatePromoData = ref({
+  promoCode: null,
+  promotionLink: null
 })
 
 // 搜索表单
@@ -570,6 +703,12 @@ const removeImage = () => {
 const copyToClipboard = async (text) => {
   try {
     await navigator.clipboard.writeText(text)
+    toast.add({
+      severity: 'success',
+      summary: '成功',
+      detail: '已复制到剪贴板',
+      life: 2000
+    })
   } catch {
     const textArea = document.createElement('textarea')
     textArea.value = text
@@ -577,6 +716,12 @@ const copyToClipboard = async (text) => {
     textArea.select()
     document.execCommand('copy')
     document.body.removeChild(textArea)
+    toast.add({
+      severity: 'success',
+      summary: '成功',
+      detail: '已复制到剪贴板',
+      life: 2000
+    })
   }
 }
 
@@ -609,11 +754,16 @@ const openAddDialog = () => {
     type: null,
     link: null,
     image: null,
-    teamId: null
+    teamId: null,
+    teamMemberId: null
   }
   // 重置图片预览状态
   imagePreview.value = null
   imageFile.value = null
+  // 加载团队成员列表
+  if (isTeam.value || isAdmin.value) {
+    fetchTeamMembers()
+  }
   editDialog.value = true
 }
 
@@ -625,14 +775,95 @@ const openEditDialog = (link) => {
     name: link.name || null,
     type: link.type || null,
     link: link.link || null,
-    image: link.image || null
+    image: link.image || null,
+    teamId: link.teamId || null,
+    teamMemberId: null
   }
   // 编辑时显示原有图片
   imagePreview.value = link.image || null
   imageFile.value = null
+  // 加载团队成员列表
+  if (isTeam.value || isAdmin.value) {
+    fetchTeamMembers()
+  }
   editDialog.value = true
 }
 
+// 获取团队成员列表
+const fetchTeamMembers = async () => {
+  try {
+    let response
+    if (isAdmin.value && editForm.value.teamId) {
+      // 管理员选择团队时,获取该团队的成员
+      response = await listMembers(0, 1000, undefined, editForm.value.teamId)
+    } else if (isTeam.value) {
+      // 团队用户获取自己团队的成员
+      const currentTeamId = teamStore.teams?.[0]?.id
+      if (currentTeamId) {
+        response = await listMembers(0, 1000, undefined, currentTeamId)
+      } else {
+        response = await listMembers(0, 1000)
+      }
+    } else {
+      // 其他情况获取所有成员
+      response = await listMembers(0, 1000)
+    }
+    teamMembers.value = response.content || []
+  } catch (error) {
+    console.error('获取团队成员失败:', error)
+    teamMembers.value = []
+  }
+}
+
+// 处理团队选择变化
+const handleTeamChange = () => {
+  // 团队变化时,清空团队成员选择并重新加载
+  editForm.value.teamMemberId = null
+  editForm.value.link = null
+  fetchTeamMembers()
+}
+
+// 处理团队成员选择变化
+const handleTeamMemberChange = async () => {
+  if (editForm.value.teamMemberId) {
+    await handleGenerateLinkFromMember()
+  } else {
+    editForm.value.link = null
+  }
+}
+
+// 基于团队成员生成推广链接
+const handleGenerateLinkFromMember = async () => {
+  if (!editForm.value.teamMemberId) {
+    return
+  }
+  try {
+    const response = await getPromotionLink(editForm.value.teamMemberId)
+    editForm.value.link = response.promotionLink
+    // 如果链接名称为空,自动填充成员名称
+    if (!editForm.value.name) {
+      const member = teamMembers.value.find((m) => m.id === editForm.value.teamMemberId)
+      if (member) {
+        editForm.value.name = `${member.name}的推广链接`
+      }
+    }
+    toast.add({
+      severity: 'success',
+      summary: '成功',
+      detail: '推广链接生成成功',
+      life: 3000
+    })
+  } catch (error) {
+    const errorMessage = error?.message || error?.detail || '获取推广链接失败'
+    toast.add({
+      severity: 'error',
+      summary: '错误',
+      detail: errorMessage,
+      life: 3000
+    })
+  }
+}
+
 // 保存编辑
 const saveEdit = async () => {
   editLoading.value = true
@@ -640,6 +871,9 @@ const saveEdit = async () => {
     // 准备保存的数据
     const saveData = { ...editForm.value }
 
+    // 删除不需要保存的字段
+    delete saveData.teamMemberId
+
     // 非管理员角色不传递团队ID
     if (!isAdmin.value) {
       delete saveData.teamId
@@ -703,6 +937,99 @@ const saveEdit = async () => {
   }
 }
 
+// 打开生成推广链接弹窗
+const openGenerateLinkDialog = () => {
+  generateForm.value = {
+    teamId: null,
+    teamMemberId: null
+  }
+  generatePromoData.value = {
+    promoCode: null,
+    promotionLink: null
+  }
+  // 加载团队成员列表
+  fetchGenerateTeamMembers()
+  generateLinkDialog.value = true
+}
+
+// 获取生成推广链接的团队成员列表
+const fetchGenerateTeamMembers = async () => {
+  try {
+    let response
+    if (isAdmin.value && generateForm.value.teamId) {
+      // 管理员选择团队时,获取该团队的成员
+      response = await listMembers(0, 1000, undefined, generateForm.value.teamId)
+    } else if (isTeam.value) {
+      // 团队用户获取自己团队的成员
+      const currentTeamId = teamStore.teams?.[0]?.id
+      if (currentTeamId) {
+        response = await listMembers(0, 1000, undefined, currentTeamId)
+      } else {
+        response = await listMembers(0, 1000)
+      }
+    } else if (isAdmin.value) {
+      // 管理员未选择团队时,获取所有成员
+      response = await listMembers(0, 1000)
+    } else {
+      // 其他情况获取所有成员
+      response = await listMembers(0, 1000)
+    }
+    generateTeamMembers.value = response.content || []
+  } catch (error) {
+    console.error('获取团队成员失败:', error)
+    generateTeamMembers.value = []
+  }
+}
+
+// 处理生成推广链接的团队选择变化
+const handleGenerateTeamChange = () => {
+  // 团队变化时,清空团队成员选择并重新加载
+  generateForm.value.teamMemberId = null
+  generatePromoData.value = {
+    promoCode: null,
+    promotionLink: null
+  }
+  fetchGenerateTeamMembers()
+}
+
+// 生成推广链接
+const handleGenerateLink = async () => {
+  if (!generateForm.value.teamMemberId) {
+    toast.add({
+      severity: 'warn',
+      summary: '提示',
+      detail: '请选择团队成员',
+      life: 3000
+    })
+    return
+  }
+  
+  generateLinkLoading.value = true
+  try {
+    const response = await getPromotionLink(generateForm.value.teamMemberId)
+    generatePromoData.value = {
+      promoCode: response.promoCode,
+      promotionLink: response.promotionLink
+    }
+    toast.add({
+      severity: 'success',
+      summary: '成功',
+      detail: '推广链接生成成功',
+      life: 3000
+    })
+  } catch (error) {
+    const errorMessage = error?.message || error?.detail || '获取推广链接失败'
+    toast.add({
+      severity: 'error',
+      summary: '错误',
+      detail: errorMessage,
+      life: 3000
+    })
+  } finally {
+    generateLinkLoading.value = false
+  }
+}
+
 // 初始化
 onMounted(async () => {
   if (isAdmin.value) {
@@ -710,6 +1037,11 @@ onMounted(async () => {
   }
 
   fetchData()
+  
+  // 如果是团队用户,预加载团队成员列表
+  if (isTeam.value) {
+    fetchTeamMembers()
+  }
 })
 </script>
 

+ 6 - 0
src/views/MainView.vue

@@ -96,6 +96,12 @@ const allNavItems = [
     name: 'user',
     roles: ['admin']
   },
+  {
+    label: '广告栏管理',
+    icon: 'pi pi-fw pi-images',
+    name: 'banner',
+    roles: ['admin']
+  },
   {
     label: '参数配置',
     icon: 'pi pi-fw pi-cog',

+ 141 - 2
src/views/TeamMembersView.vue

@@ -167,7 +167,7 @@
 
       <Column
         header="操作"
-        style="min-width: 200px; width: 200px"
+        style="min-width: 280px; width: 280px"
         align-frozen="right"
         frozen
         :pt="{
@@ -178,6 +178,26 @@
       >
         <template #body="slotProps">
           <div class="flex justify-center gap-1">
+            <Button
+              icon="pi pi-link"
+              severity="success"
+              size="small"
+              text
+              rounded
+              aria-label="生成链接"
+              title="生成推广链接"
+              @click="handleGenerateLink(slotProps.data)"
+            />
+            <Button
+              icon="pi pi-qrcode"
+              severity="warning"
+              size="small"
+              text
+              rounded
+              aria-label="生成推广码"
+              title="生成推广码"
+              @click="handleGeneratePromoCode(slotProps.data)"
+            />
             <Button
               icon="pi pi-pencil"
               severity="info"
@@ -280,6 +300,49 @@
         </div>
       </template>
     </Dialog>
+
+    <!-- 推广码/链接展示弹窗 -->
+    <Dialog
+      v-model:visible="promoDialog"
+      :modal="true"
+      :header="promoDialogTitle"
+      :style="{ width: '500px' }"
+      position="center"
+    >
+      <div class="p-fluid">
+        <div v-if="promoData.promoCode" class="field">
+          <label class="font-medium text-sm mb-2 block">推广码</label>
+          <div class="flex items-center gap-2">
+            <InputText :value="promoData.promoCode" readonly class="flex-1" />
+            <Button
+              icon="pi pi-copy"
+              size="small"
+              @click="copyToClipboard(promoData.promoCode)"
+              title="复制推广码"
+            />
+          </div>
+        </div>
+
+        <div v-if="promoData.promotionLink" class="field mt-4">
+          <label class="font-medium text-sm mb-2 block">推广链接</label>
+          <div class="flex items-center gap-2">
+            <InputText :value="promoData.promotionLink" readonly class="flex-1" />
+            <Button
+              icon="pi pi-copy"
+              size="small"
+              @click="copyToClipboard(promoData.promotionLink)"
+              title="复制推广链接"
+            />
+          </div>
+        </div>
+      </div>
+
+      <template #footer>
+        <div class="flex justify-end gap-3">
+          <Button label="关闭" severity="secondary" @click="promoDialog = false" />
+        </div>
+      </template>
+    </Dialog>
   </div>
 </template>
 
@@ -295,7 +358,7 @@ import InputNumber from 'primevue/inputnumber'
 import Password from 'primevue/password'
 import { useConfirm } from 'primevue/useconfirm'
 import { useToast } from 'primevue/usetoast'
-import { listMembers, createMember, updateMember, deleteMember, getTeamLeaderStats } from '@/services/api'
+import { listMembers, createMember, updateMember, deleteMember, getTeamLeaderStats, generatePromoCode, getPromotionLink } from '@/services/api'
 import { useTeamStore } from '@/stores/team'
 
 const toast = useToast()
@@ -363,6 +426,14 @@ const editForm = ref({
   commissionRate: null
 })
 
+// 推广码/链接弹窗相关
+const promoDialog = ref(false)
+const promoDialogTitle = ref('')
+const promoData = ref({
+  promoCode: null,
+  promotionLink: null
+})
+
 // 搜索表单
 const searchForm = ref({
   id: null,
@@ -561,6 +632,12 @@ const deleteMemberRecord = async (id) => {
 const copyToClipboard = async (text) => {
   try {
     await navigator.clipboard.writeText(text)
+    toast.add({
+      severity: 'success',
+      summary: '成功',
+      detail: '已复制到剪贴板',
+      life: 2000
+    })
   } catch {
     const textArea = document.createElement('textarea')
     textArea.value = text
@@ -568,6 +645,12 @@ const copyToClipboard = async (text) => {
     textArea.select()
     document.execCommand('copy')
     document.body.removeChild(textArea)
+    toast.add({
+      severity: 'success',
+      summary: '成功',
+      detail: '已复制到剪贴板',
+      life: 2000
+    })
   }
 }
 
@@ -642,6 +725,62 @@ const saveEdit = async () => {
   }
 }
 
+// 生成推广码
+const handleGeneratePromoCode = async (member) => {
+  try {
+    const response = await generatePromoCode(member.id)
+    promoData.value = {
+      promoCode: response.promoCode,
+      promotionLink: null
+    }
+    promoDialogTitle.value = `推广码 - ${member.name}`
+    promoDialog.value = true
+    toast.add({
+      severity: 'success',
+      summary: '成功',
+      detail: response.message || '推广码生成成功',
+      life: 3000
+    })
+    // 刷新数据以更新推广码
+    fetchData()
+  } catch (error) {
+    const errorMessage = error?.message || error?.detail || '生成推广码失败'
+    toast.add({
+      severity: 'error',
+      summary: '错误',
+      detail: errorMessage,
+      life: 3000
+    })
+  }
+}
+
+// 生成推广链接
+const handleGenerateLink = async (member) => {
+  try {
+    const response = await getPromotionLink(member.id)
+    promoData.value = {
+      promoCode: response.promoCode,
+      promotionLink: response.promotionLink
+    }
+    promoDialogTitle.value = `推广链接 - ${member.name}`
+    promoDialog.value = true
+    toast.add({
+      severity: 'success',
+      summary: '成功',
+      detail: '获取推广链接成功',
+      life: 3000
+    })
+  } catch (error) {
+    const errorMessage = error?.message || error?.detail || '获取推广链接失败'
+    toast.add({
+      severity: 'error',
+      summary: '错误',
+      detail: errorMessage,
+      life: 3000
+    })
+  }
+}
+
 onMounted(() => {
   fetchData()
   // 如果是团队用户,预加载统计数据