董国庆
2026-02-26 fdd9125cecebc4d980fa797d6542312eef28b841
banner管理和权限
4个文件已添加
4个文件已修改
1072 ■■■■■ 已修改文件
src/service/api/banner.ts 61 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/userContent/active/coupon/BatchSendModal.vue 191 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/userContent/active/coupon/index.vue 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/userContent/banner/api.txt 109 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/userContent/banner/components/BannerActionCell.vue 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/userContent/banner/components/BannerFormModal.vue 304 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/userContent/banner/index.vue 341 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/userContent/contentCenter/index.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/service/api/banner.ts
New file
@@ -0,0 +1,61 @@
import { request } from '../http'
export interface Banner {
  id: number
  bannerTitle: string
  bannerPictures: string // JSON string: [{"id":"...","name":"...","url":"..."}]
  startTime: string
  endTime: string
  status: number // 0=未开始, 1=进行中, 2=已结束 (展示用)
  bannerStatus: number // 1=上架, 2=下架 (控制用)
  jumpType: number // 1=小程序内页面, 2=小程序地址, 3=网页地址
  appletName?: string
  appletId?: string
  jumpUrl?: string
  createTime?: string
  createBy?: string
  updateBy?: string
  updateTime?: string
}
export interface BannerSearchParams {
  bannerTitle?: string
  status?: number | null
  pageNum: number
  pageSize: number
}
/**
 * 获取 Banner 分页列表
 */
export function fetchBannerPageList(data: BannerSearchParams) {
  return request.Post<Service.ResponseResult<{ records: Banner[], total: number }>>('/operation/api/v1/banner/pageList', data)
}
/**
 * 新增 Banner
 */
export function addBanner(data: Partial<Banner>) {
  return request.Post<Service.ResponseResult<any>>('/operation/api/v1/banner/saveBanner', data)
}
/**
 * 编辑 Banner
 */
export function updateBanner(data: Partial<Banner>) {
  return request.Post<Service.ResponseResult<any>>('/operation/api/v1/banner/updateBanner', data)
}
/**
 * 获取 Banner 详情
 */
export function getBannerDetail(id: number) {
  return request.Get<Service.ResponseResult<Banner>>('/operation/api/v1/banner/detailBanner', { params: { id } })
}
/**
 * 删除 Banner
 */
export function deleteBanner(id: number) {
  return request.Delete<Service.ResponseResult<any>>('/operation/api/v1/banner/deleteBanner?id=' + id, )
}
src/views/userContent/active/coupon/BatchSendModal.vue
New file
@@ -0,0 +1,191 @@
<script setup lang="tsx">
import { ref, computed } from 'vue'
import type { DataTableColumns } from 'naive-ui'
import { NTag } from 'naive-ui'
interface Props {
  show: boolean
}
interface Emits {
  (e: 'update:show', value: boolean): void
  (e: 'send-all'): void
  (e: 'send-selected', rows: User[]): void
}
const props = defineProps<Props>()
const emits = defineEmits<Emits>()
const showModal = computed({
  get: () => props.show,
  set: (val) => emits('update:show', val),
})
interface User {
  id: number
  userName: string
  whiteListAccount: string
  status: 'Normal' | 'Frozen'
  priorityDispatch: string
  creator: string
  createTime: string
}
const searchModel = ref({
  whiteListAccount: '',
  source: null,
  bindingStatus: null,
})
const sourceOptions = [
  { label: '来源1', value: 'source1' },
  { label: '来源2', value: 'source2' },
]
const bindingStatusOptions = [
  { label: '已绑定', value: 'bound' },
  { label: '未绑定', value: 'unbound' },
]
const handleSearch = () => {
  console.log('搜索', searchModel.value)
}
const handleReset = () => {
  searchModel.value = {
    whiteListAccount: '',
    source: null,
    bindingStatus: null,
  }
}
const checkedRowKeys = ref<number[]>([])
const list = ref<User[]>([
  { id: 1, userName: '杨佩芳', whiteListAccount: '13367609906', status: 'Normal', priorityDispatch: '是', creator: '杨佩芳', createTime: '2021/07/29 10:59' },
  { id: 2, userName: '曹敏倩', whiteListAccount: '15576590476', status: 'Frozen', priorityDispatch: '是', creator: '曹敏倩', createTime: '2021/07/29 10:59' },
  { id: 3, userName: '郭贵妃', whiteListAccount: '18878751881', status: 'Normal', priorityDispatch: '是', creator: '郭贵妃', createTime: '2021/07/29 10:59' },
  { id: 4, userName: '丁汉臻', whiteListAccount: '文字内容', status: 'Frozen', priorityDispatch: '是', creator: '丁汉臻', createTime: '2021/07/29 10:59' },
  { id: 5, userName: '朱政廷', whiteListAccount: '文字内容', status: 'Normal', priorityDispatch: '否', creator: '朱政廷', createTime: '2021/07/29 10:59' },
  { id: 6, userName: '方一强', whiteListAccount: '文字内容', status: 'Normal', priorityDispatch: '否', creator: '方一强', createTime: '2021/07/29 10:59' },
  { id: 7, userName: '倪怡芳', whiteListAccount: '文字内容', status: 'Normal', priorityDispatch: '是', creator: '倪怡芳', createTime: '2021/07/29 10:59' },
  { id: 8, userName: '黄丽昆', whiteListAccount: '文字内容', status: 'Normal', priorityDispatch: '否', creator: '黄丽昆', createTime: '2021/07/29 10:59' },
  { id: 9, userName: '吴韵如', whiteListAccount: '文字内容', status: 'Frozen', priorityDispatch: '否', creator: '吴韵如', createTime: '2021/07/29 10:59' },
  { id: 10, userName: '刘翊惠', whiteListAccount: '文字内容', status: 'Frozen', priorityDispatch: '否', creator: '刘翊惠', createTime: '2021/07/29 10:59' },
])
const currentPage = ref(1)
const pageSize = ref(10)
const total = ref(1000)
const columns: DataTableColumns<User> = [
  { type: 'selection' },
  { title: '序号', key: 'id', width: 80, align: 'center' },
  { title: '用户名称', key: 'userName', width: 150 },
  { title: '白名单账号', key: 'whiteListAccount' },
  {
    title: '状态',
    key: 'status',
    width: 100,
    render(row) {
      return (
        <NTag type={row.status === 'Normal' ? 'info' : 'warning'} size="small" bordered={false} style={{ color: row.status === 'Normal' ? '#1890FF' : '#FAAD14', backgroundColor: row.status === 'Normal' ? '#E6F7FF' : '#FFFBE6' }}>
          {row.status === 'Normal' ? '正常' : '冻结'}
        </NTag>
      )
    },
  },
  { title: '优先派单', key: 'priorityDispatch', width: 100 },
  { title: '创建人', key: 'creator', width: 120 },
  { title: '创建时间', key: 'createTime', width: 180 },
]
const handleSendAll = () => {
  emits('send-all')
  showModal.value = false
}
const handleSendSelected = () => {
  const selectedRows = list.value.filter((item) => checkedRowKeys.value.includes(item.id))
  emits('send-selected', selectedRows)
  showModal.value = false
}
</script>
<template>
  <n-modal
    v-model:show="showModal"
    style="width: 1000px"
    title="选择用户"
    preset="card"
    size="huge"
    content-style="padding: 16px 24px"
    :header-style="{ borderBottom: '1px solid #efeff5', padding: '16px 24px' }"
    :footer-style="{ padding: '16px 24px' }"
  >
    <div class="flex flex-col gap-4">
      <!-- Search Area -->
      <div class="flex justify-between items-start">
        <n-form :model="searchModel" label-placement="top" inline :show-feedback="false" class="flex-1">
          <n-form-item label="白名单账号" path="whiteListAccount">
            <n-input v-model:value="searchModel.whiteListAccount" placeholder="请输入" clearable />
          </n-form-item>
          <n-form-item label="来源" path="source" class="ml-4">
            <n-select
              v-model:value="searchModel.source"
              :options="sourceOptions"
              placeholder="请搜索"
              clearable
              style="width: 200px"
            />
          </n-form-item>
          <n-form-item label="绑定状态" path="bindingStatus" class="ml-4">
            <n-select
              v-model:value="searchModel.bindingStatus"
              :options="bindingStatusOptions"
              placeholder="请搜索"
              clearable
              style="width: 200px"
            />
          </n-form-item>
        </n-form>
        <div class="flex gap-2 mt-6">
          <n-button @click="handleReset">重置</n-button>
          <n-button type="primary" @click="handleSearch">查询</n-button>
        </div>
      </div>
      <!-- Table Area -->
      <n-data-table
        v-model:checked-row-keys="checkedRowKeys"
        :columns="columns"
        :data="list"
        :row-key="(row) => row.id"
        :pagination="{
          page: currentPage,
          pageSize: pageSize,
          itemCount: total,
          showSizePicker: true,
          pageSizes: [10, 20, 50, 100],
          prefix: ({ itemCount }) => `第 ${currentPage} - ${Math.min(currentPage * pageSize, itemCount)} 条 / 总共 ${itemCount} 条`
        }"
      />
    </div>
    <template #action>
      <div class="flex justify-end gap-4 mr--4">
        <n-button type="primary" @click="handleSendAll">一键发放所有用户</n-button>
        <n-button type="primary" @click="handleSendSelected">确认发送选中用户</n-button>
      </div>
    </template>
  </n-modal>
</template>
<style scoped>
:deep(.n-card-header) {
  font-weight: bold;
}
:deep(.n-card__footer) {
  padding: 16px 24px !important;
}
</style>
src/views/userContent/active/coupon/index.vue
@@ -2,6 +2,7 @@
import { ref, computed } from 'vue'
import type { DataTableColumns } from 'naive-ui'
import SearchPanel from '@/components/custom/SearchPanel.vue'
import BatchSendModal from './BatchSendModal.vue'
interface Coupon {
  id: number
@@ -31,8 +32,20 @@
  console.log('新增')
}
const showBatchSendModal = ref(false)
const handleBatchSend = () => {
  console.log('批量发券')
  showBatchSendModal.value = true
}
const handleBatchSendAll = () => {
  console.log('一键发放所有用户')
  // Implement logic here
}
const handleBatchSendSelected = (rows: any[]) => {
  console.log('确认发送选中用户', rows)
  // Implement logic here
}
const list = ref<Coupon[]>([
@@ -93,9 +106,9 @@
    </n-card>
    <n-card style="border-radius: 6px; margin-top: 12px" content-style="padding: 12px">
      <div class="mb-4 flex justify-end gap-2">
      <div class="mb-4 flex justify-between">
        <n-button style="width: 84px; height: 32px" @click="handleBatchSend">批量发券</n-button>
        <n-button style="width: 84px; height: 32px; background-color: #E6F4FF; color: #0068FF; border: 1px solid #7ABDFF" @click="handleAdd">新增</n-button>
        <n-button style="width: 84px; height: 32px; background-color: #E6F4FF; color: #0068FF; border: 1px solid #7ABDFF" @click="handleAdd">添加</n-button>
      </div>
      <n-data-table
@@ -112,6 +125,12 @@
        :scroll-x="1000"
      />
    </n-card>
    <BatchSendModal
      v-model:show="showBatchSendModal"
      @send-all="handleBatchSendAll"
      @send-selected="handleBatchSendSelected"
    />
  </div>
</template>
src/views/userContent/banner/api.txt
New file
@@ -0,0 +1,109 @@
operation/api/v1/banner/pageList   banner分页列表  post json
请求参数:{
        "bannerTitle": "string", 广告标题
        "pageNum": 0,
        "pageSize": 0,
        "status": 0  状态(0=未开始,1=进行中,2=已结束)
        }
响应: {
        "code": 0,
        "data": {
            "current": 0,
            "hasNextPage": true,
            "hasPrevPage": true,
            "pages": 0,
            "records": [
            {
                "appletId": "string", 小程序id
                "appletName": "string", 小程序名称
                "bannerPictures": "string", 广告图片
                "bannerStatus": 0, 广告状态 1上架 2下架
                "bannerTitle": "string", 广告标题
                "createBy": "string",
                "createTime": "2026-02-26T07:41:11.483Z",
                "deleted": true,
                "endTime": "2026-02-26T07:41:11.483Z", 广告结束时间
                "id": 0,
                "jumpType": 0, 跳转类型1小程序内页面 2小程序地址 3网页地址
                "jumpUrl": "string", 跳转URL地址
                "startTime": "2026-02-26T07:41:11.483Z", 广告开始时间
                "status": 0, 状态(0=未开始,1=进行中,2=已结束)
                "updateBy": "string",
                "updateTime": "2026-02-26T07:41:11.483Z"
            }
            ],
            "size": 0,
            "startIndex": 0,
            "total": 0
        },
        "msg": "string"
        }
operation/api/v1/banner/saveBanner   新增  post json
请求:{
    "appletId": "string",
    "appletName": "string",
    "bannerPictures": "string",
    "bannerStatus": 0,
    "bannerTitle": "string",
    "endTime": "2026-02-26T07:36:34.206Z",
    "jumpType": 0,
    "jumpUrl": "string",
    "startTime": "2026-02-26T07:36:34.206Z",
    "status": 0,
    }
响应:{
    "code": 0,
    "data": {},
    "msg": "string"
    }
operation/api/v1/banner/updateBanner 编辑  post  json
请求:{
    "appletId": "string",
    "appletName": "string",
    "bannerPictures": "string",
    "bannerStatus": 0,
    "bannerTitle": "string",
    "endTime": "2026-02-26T07:36:34.206Z",
    "jumpType": 0,
    id:'',
    "jumpUrl": "string",
    "startTime": "2026-02-26T07:36:34.206Z",
    "status": 0,
    }
响应:{
    "code": 0,
    "data": {},
    "msg": "string"
    }
​operation/api​/v1​/banner​/detailBanner  详情   get  params
请求  id
响应:{
        "code": 0,
        "data": {
            "appletId": "string",
            "appletName": "string",
            "bannerPictures": "string",
            "bannerStatus": 0,
            "bannerTitle": "string",
            "createBy": "string",
            "createTime": "2026-02-26T07:45:33.715Z",
            "deleted": true,
            "endTime": "2026-02-26T07:45:33.715Z",
            "id": 0,
            "jumpType": 0,
            "jumpUrl": "string",
            "startTime": "2026-02-26T07:45:33.715Z",
            "status": 0,
            "updateBy": "string",
            "updateTime": "2026-02-26T07:45:33.715Z"
        },
        "msg": "string"
        }
operation/api/v1/banner/deleteBanner  删除  delete  params
请求:id
src/views/userContent/banner/components/BannerActionCell.vue
New file
@@ -0,0 +1,39 @@
<script setup lang="ts">
interface Props {
  row: any
}
const props = defineProps<Props>()
const emit = defineEmits<{
  (e: 'view', row: any): void
  (e: 'edit', row: any): void
  (e: 'delete', row: any): void
}>()
function onView() {
  emit('view', props.row)
}
function onEdit() {
  emit('edit', props.row)
}
function onDelete() {
  emit('delete', props.row)
}
</script>
<template>
  <div class="flex gap-3">
    <span v-permission="30223" style="color: #0068FF; cursor: pointer" @click="onView">
      查看
    </span>
    <span v-permission="30224" style="color: #0068FF; cursor: pointer" @click="onEdit">
      编辑
    </span>
    <span v-permission="30225" style="color: #0068FF; cursor: pointer" @click="onDelete">
      删除
    </span>
  </div>
  </template>
<style scoped>
</style>
src/views/userContent/banner/components/BannerFormModal.vue
@@ -1,59 +1,66 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import type { FormInst, FormRules, UploadFileInfo } from 'naive-ui'
import { ref, watch, computed } from 'vue'
import type { FormInst, FormRules, UploadFileInfo, UploadCustomRequestOptions } from 'naive-ui'
import { useMessage } from 'naive-ui'
import { addBanner, updateBanner, type Banner } from '@/service/api/banner'
import { customUploadRequest } from '@/utils/upload'
import dayjs from 'dayjs'
interface Props {
  show: boolean
  mode?: 'add' | 'edit' | 'view'
}
interface FormModel {
  image: string
  title: string
  startTime: number | null
  endTime: number | null
  jumpType: string
  miniProgramName: string
  miniProgramId: string
  jumpUrl: string
  status: boolean
  rowData?: Banner | null
}
const props = withDefaults(defineProps<Props>(), {
  mode: 'add'
  mode: 'add',
  rowData: null
})
const emit = defineEmits<{
  'update:show': [value: boolean]
  'confirm': [data: FormModel]
  'confirm': []
}>()
const message = useMessage()
const formRef = ref<FormInst | null>(null)
const fileList = ref<UploadFileInfo[]>([])
interface FormModel {
  bannerTitle: string
  bannerPictures: string
  startTime: number | null
  endTime: number | null
  jumpType: number
  appletName: string
  appletId: string
  jumpUrl: string
  bannerStatus: boolean
}
const formModel = ref<FormModel>({
  image: '',
  title: '',
  bannerTitle: '',
  bannerPictures: '',
  startTime: null,
  endTime: null,
  jumpType: '小程序内页面',
  miniProgramName: '',
  miniProgramId: '',
  jumpType: 1,
  appletName: '',
  appletId: '',
  jumpUrl: '',
  status: true
  bannerStatus: true
})
const jumpTypeOptions = [
  { label: '小程序内页面', value: '小程序内页面' },
  { label: '小程序地址', value: '小程序地址' },
  { label: '网页地址', value: '网页地址' }
  { label: '小程序内页面', value: 1 },
  { label: '小程序地址', value: 2 },
  { label: '网页地址', value: 3 }
]
const rules: FormRules = {
  image: [
  bannerPictures: [
    { required: true, message: '请上传广告图', trigger: 'change' }
  ],
  title: [
  bannerTitle: [
    { required: true, message: '请输入广告标题', trigger: 'blur' }
  ],
  startTime: [
@@ -62,23 +69,66 @@
  endTime: [
    { required: true, type: 'number', message: '请选择广告结束时间', trigger: 'change' }
  ],
  miniProgramName: [
    { required: true, message: '请输入小程序名称', trigger: 'blur' }
  jumpType: [
    { required: true, type: 'number', message: '请选择跳转类型', trigger: 'change' }
  ],
  miniProgramId: [
    { required: true, message: '请输入小程序ID', trigger: 'blur' }
  appletName: [
    {
      required: true,
      validator(_rule, value) {
        if (formModel.value.jumpType === 2 && !value) {
          return new Error('请输入小程序名称')
        }
        return true
      },
      trigger: 'blur'
    }
  ],
  appletId: [
    {
      required: true,
      validator(_rule, value) {
        if (formModel.value.jumpType === 2 && !value) {
          return new Error('请输入小程序ID')
        }
        return true
      },
      trigger: 'blur'
    }
  ],
  jumpUrl: [
    {
      required: true,
      validator(_rule, value) {
        if ((formModel.value.jumpType === 1 || formModel.value.jumpType === 3) && !value) {
          return new Error('请输入跳转URL地址')
        }
        return true
      },
      trigger: 'blur'
    }
  ]
}
const handleUpload = (options: { file: UploadFileInfo }) => {
  console.log('上传文件', options.file)
  // 这里应该调用上传接口
  formModel.value.image = 'https://07akioni.oss-cn-beijing.aliyuncs.com/07akioni.jpeg'
  return false
const handleCustomUpload = async (options: UploadCustomRequestOptions) => {
  const result = await customUploadRequest(options as any)
  if (result) {
    // 构建 JSON 数组字符串
    const pictureData = [{
      id: result.id,
      name: result.name,
      url: result.url
    }]
    formModel.value.bannerPictures = JSON.stringify(pictureData)
    // 触发校验
    formRef.value?.validate(errors => {
      if (!errors) return
    }, (rule) => rule?.key === 'bannerPictures')
  }
}
const handleRemove = () => {
  formModel.value.image = ''
  formModel.value.bannerPictures = ''
  fileList.value = []
}
@@ -89,28 +139,90 @@
const handleConfirm = async () => {
  try {
    await formRef.value?.validate()
    emit('confirm', formModel.value)
    emit('update:show', false)
    const params = {
      ...formModel.value,
      bannerStatus: formModel.value.bannerStatus ? 1 : 2,
      startTime: dayjs(formModel.value.startTime).format('YYYY-MM-DD HH:mm:ss'),
      endTime: dayjs(formModel.value.endTime).format('YYYY-MM-DD HH:mm:ss')
    }
    let error = null
    let code = 0
    if (props.mode === 'add') {
      const res = await addBanner(params) as any
      error = res.error
      code = res.code
    } else {
      const res = await updateBanner({
        ...params,
        id: props.rowData?.id
      }) as any
      error = res.error
      code = res.code
    }
    if (!error && code === 200) {
      message.success(props.mode === 'add' ? '新增成功' : '编辑成功')
      emit('confirm')
      emit('update:show', false)
    }
  } catch (error) {
    console.log('验证失败', error)
  }
}
watch(() => props.show, (newVal) => {
  if (!newVal) {
    formRef.value?.restoreValidation()
    formModel.value = {
      image: '',
      title: '',
      startTime: null,
      endTime: null,
      jumpType: '小程序内页面',
      miniProgramName: '',
      miniProgramId: '',
      jumpUrl: '',
      status: true
  if (newVal) {
    if (props.mode !== 'add' && props.rowData) {
      // 编辑/查看模式,回显数据
      const row = props.rowData
      // 解析图片
      try {
        const pictures = JSON.parse(row.bannerPictures || '[]')
        if (Array.isArray(pictures) && pictures.length > 0) {
          fileList.value = [{
            id: pictures[0].id,
            name: pictures[0].name,
            status: 'finished',
            url: pictures[0].url
          }]
        } else {
          fileList.value = []
        }
      } catch (e) {
        fileList.value = []
      }
      formModel.value = {
        bannerTitle: row.bannerTitle,
        bannerPictures: row.bannerPictures,
        startTime: row.startTime ? dayjs(row.startTime).valueOf() : null,
        endTime: row.endTime ? dayjs(row.endTime).valueOf() : null,
        jumpType: row.jumpType || 1,
        appletName: row.appletName || '',
        appletId: row.appletId || '',
        jumpUrl: row.jumpUrl || '',
        bannerStatus: row.bannerStatus === 1
      }
    } else {
      // 新增模式
      formModel.value = {
        bannerTitle: '',
        bannerPictures: '',
        startTime: null,
        endTime: null,
        jumpType: 1,
        appletName: '',
        appletId: '',
        jumpUrl: '',
        bannerStatus: true
      }
      fileList.value = []
    }
    fileList.value = []
  } else {
    formRef.value?.restoreValidation()
  }
})
</script>
@@ -121,25 +233,34 @@
    :mask-closable="false"
    preset="card"
    :title="mode === 'add' ? '添加广告' : mode === 'edit' ? '编辑广告' : '查看广告'"
    class="banner-modal"
    style="width: 600px;"
    :segmented="{
      content: true,
      action: true
    }"
    :content-style="{
      padding: '16px 24px 0px 24px',
      maxHeight: '60vh',
      overflow: 'auto'
    }"
    @close="handleClose"
  >
    <n-form
      ref="formRef"
      :model="formModel"
      :rules="rules"
      label-placement="left"
      label-width="120"
      label-placement="top"
      require-mark-placement="left"
      :disabled="mode === 'view'"
    >
      <n-form-item label="广告图" path="image" required>
      <n-form-item label="广告图" path="bannerPictures" required>
        <div class="flex flex-col">
          <n-upload
            v-model:file-list="fileList"
            :max="1"
            list-type="image-card"
            :custom-request="handleUpload"
            :custom-request="handleCustomUpload"
            :disabled="mode === 'view'"
            @remove="handleRemove"
          >
@@ -155,8 +276,8 @@
        </div>
      </n-form-item>
      <n-form-item label="广告标题" path="title" required>
        <n-input v-model:value="formModel.title" placeholder="请输入" />
      <n-form-item label="广告标题" path="bannerTitle" required>
        <n-input v-model:value="formModel.bannerTitle" placeholder="请输入" />
      </n-form-item>
      <n-form-item label="广告开始时间" path="startTime" required>
@@ -179,7 +300,7 @@
        />
      </n-form-item>
      <n-form-item label="跳转类型">
      <n-form-item label="跳转类型" path="jumpType" required>
        <n-select
          v-model:value="formModel.jumpType"
          :options="jumpTypeOptions"
@@ -187,31 +308,72 @@
        />
      </n-form-item>
      <n-form-item label="小程序名称" path="miniProgramName" required>
        <n-input v-model:value="formModel.miniProgramName" placeholder="请输入" />
      <n-form-item v-if="formModel.jumpType === 2" label="小程序名称" path="appletName" required>
        <n-input v-model:value="formModel.appletName" placeholder="请输入" />
      </n-form-item>
      <n-form-item label="小程序ID" path="miniProgramId" required>
        <n-input v-model:value="formModel.miniProgramId" placeholder="请输入" />
      <n-form-item v-if="formModel.jumpType === 2" label="小程序ID" path="appletId" required>
        <n-input v-model:value="formModel.appletId" placeholder="请输入" />
      </n-form-item>
      <n-form-item label="跳转URL地址">
      <n-form-item v-if="formModel.jumpType === 1 || formModel.jumpType === 3" label="跳转URL地址" path="jumpUrl" required>
        <n-input v-model:value="formModel.jumpUrl" placeholder="请输入" />
      </n-form-item>
      <n-form-item label="广告上架状态" required>
        <n-switch v-model:value="formModel.status" />
        <n-switch v-model:value="formModel.bannerStatus" />
      </n-form-item>
    </n-form>
    <template #footer>
      <div class="flex justify-end gap-3">
        <n-button @click="handleClose">{{ mode === 'view' ? '关闭' : '取消' }}</n-button>
        <n-button v-if="mode !== 'view'" type="primary" @click="handleConfirm">确定</n-button>
      </div>
    <template #action>
      <n-space justify="end" :size="8">
        <n-button @click="handleClose" style="padding: 7px 29px; border-radius: 4px;">
          {{ mode === 'view' ? '关闭' : '取消' }}
        </n-button>
        <n-button v-if="mode !== 'view'" type="primary" @click="handleConfirm" style="padding: 7px 29px; border-radius: 4px;">
          保存
        </n-button>
      </n-space>
    </template>
  </n-modal>
</template>
<style scoped>
<style lang="scss">
.banner-modal .n-card__content {
  &::-webkit-scrollbar {
    width: 6px;
    height: 6px;
  }
  &::-webkit-scrollbar-track {
    background-color: transparent;
  }
  &::-webkit-scrollbar-thumb {
    background-color: transparent;
    border-radius: 3px;
  }
  &:hover::-webkit-scrollbar-thumb {
    background-color: rgba(0, 0, 0, 0.2);
  }
  &::-webkit-scrollbar-thumb:hover {
    background-color: rgba(0, 0, 0, 0.4);
  }
}
</style>
<style scoped lang="scss">
:deep(.n-form-item-label) {
  padding-bottom: 8px !important;
}
:deep(.n-card__action) {
  background-color: transparent !important;
}
:deep(.n-form-item) {
  margin-bottom: 8px !important;
}
</style>
src/views/userContent/banner/index.vue
@@ -1,27 +1,29 @@
<script setup lang="ts">
import { ref, h } from 'vue'
<script setup lang="tsx">
import { ref, h, onMounted } from 'vue'
import type { DataTableColumns } from 'naive-ui'
import { NButton, NImage, NSwitch, NSpace, NTag } from 'naive-ui'
import { NImage, NSwitch, NTag, useDialog, useMessage } from 'naive-ui'
import SearchPanel from '@/components/custom/SearchPanel.vue'
import BannerFormModal from './components/BannerFormModal.vue'
import { fetchBannerPageList, deleteBanner, type Banner } from '@/service/api/banner'
import { useBoolean } from '@/hooks'
interface BannerItem {
  id: number
  image: string
  title: string
  startTime: string
  endTime: string
  status: number
  link: string
  createTime: string
}
const message = useMessage()
const dialog = useDialog()
const selectedRowKeys = ref<number[]>([])
const { bool: loading, setTrue: startLoading, setFalse: endLoading } = useBoolean(false)
const searchForm = ref({
  title: '',
  status: null as number | null
  status: null as number | null,
  pageNum: 1,
  pageSize: 10
})
const totalCount = ref(0)
const tableData = ref<Banner[]>([])
const showFormModal = ref(false)
const formMode = ref<'add' | 'edit' | 'view'>('add')
const currentRow = ref<Banner | null>(null)
const statusOptions = [
  { label: '全部', value: null },
@@ -30,66 +32,43 @@
  { label: '已结束', value: 2 }
]
const tableData = ref<BannerItem[]>([
  {
    id: 1,
    image: 'https://07akioni.oss-cn-beijing.aliyuncs.com/07akioni.jpeg',
    title: '会员显示消息',
    startTime: '2024-05-05 12:25:33',
    endTime: '2024-06-01 12:25:33',
    status: 1,
    link: 'www.baidu.com',
    createTime: '2024-06-05 12:15:11'
  },
  {
    id: 2,
    image: 'https://07akioni.oss-cn-beijing.aliyuncs.com/07akioni.jpeg',
    title: '共迎端午感恩更新',
    startTime: '2024-05-05 12:25:33',
    endTime: '2024-06-01 12:25:33',
    status: 0,
    link: 'www.baidu.com',
    createTime: '2024-06-05 12:15:11'
  },
  {
    id: 3,
    image: 'https://07akioni.oss-cn-beijing.aliyuncs.com/07akioni.jpeg',
    title: '长假活动',
    startTime: '2024-05-05 12:25:33',
    endTime: '2024-06-01 12:25:33',
    status: 2,
    link: 'www.baidu.com',
    createTime: '2024-06-05 12:15:11'
  }
])
const columns: DataTableColumns<BannerItem> = [
const columns: DataTableColumns<Banner> = [
  {
    type: 'selection'
  },
  {
    title: '广告图',
    key: 'image',
    key: 'bannerPictures',
    width: 150,
    render: (row) => {
      let url = ''
      try {
        const pictures = JSON.parse(row.bannerPictures)
        if (Array.isArray(pictures) && pictures.length > 0) {
          url = pictures[0].url
        }
      } catch (e) {
        // console.error('解析图片失败', e)
      }
      return h(NImage, { 
        src: row.image,
        width: 120,
        height: 60,
        src: url,
        width: 60,
        height: 30,
        objectFit: 'cover',
        previewSrc: row.image
        fallbackSrc: 'https://07akioni.oss-cn-beijing.aliyuncs.com/07akioni.jpeg' // 默认图
      })
    }
  },
  {
    title: '广告标题',
    key: 'title',
    key: 'bannerTitle',
    width: 150
  },
  {
    title: '显示时间',
    key: 'time',
    width: 200,
    width: 320,
    render: (row) => {
      return `${row.startTime} 至 ${row.endTime}`
    }
@@ -104,7 +83,7 @@
        1: { text: '进行中', type: 'success' as const },
        2: { text: '已结束', type: 'default' as const }
      }
      const statusInfo = statusMap[row.status as keyof typeof statusMap]
      const statusInfo = statusMap[row.status as keyof typeof statusMap] || { text: '未知', type: 'default' }
      return h(NTag, { type: statusInfo.type }, 
        { default: () => statusInfo.text }
      )
@@ -112,16 +91,44 @@
  },
  {
    title: '上架状态',
    key: 'switch',
    key: 'bannerStatus',
    width: 100,
    render: (row) => {
      return h(NSwitch, { value: row.status === 1 })
      return h(NSwitch, {
        value: row.bannerStatus === 1,
        disabled: true // 列表页暂不支持直接切换,需在编辑中修改,或者后续添加切换接口
      })
    }
  },
  {
    title: '跳转类型',
    key: 'jumpType',
    width: 120,
    render: (row) => {
      const map: Record<number, string> = {
        1: '小程序内页面',
        2: '小程序地址',
        3: '网页地址'
      }
      return map[row.jumpType] || '未知'
    }
  },
  {
    title: '跳转地址',
    key: 'link',
    width: 150
    key: 'jumpTarget',
    width: 220,
    ellipsis: true,
    render: (row) => {
      if (row.jumpType === 2) {
        const name = row.appletName || '小程序'
        const id = row.appletId ? `(${row.appletId})` : ''
        return `${name}${id}`
      }
      if (row.jumpType === 1) {
        return row.jumpUrl || '小程序内页面'
      }
      return row.jumpUrl || '-'
    }
  },
  {
    title: '创建时间',
@@ -131,106 +138,232 @@
  {
    title: '操作',
    key: 'actions',
    align: 'center',
    width: 200,
    fixed: 'right',
    render: (row) => {
      return h(NSpace, null, {
        default: () => [
          h(NButton, { text: true, type: 'primary', onClick: () => handleView(row) },
            { default: () => '查看' }
          ),
          h(NButton, { text: true, type: 'primary', onClick: () => handleEdit(row) },
            { default: () => '编辑' }
          ),
          h(NButton, { text: true, type: 'error', onClick: () => handleDelete(row) },
            { default: () => '删除' }
          )
        ]
      })
    }
    render: (row) => (
      <div class="flex justify-center gap-3">
        <span
          v-permission="30223"
          style={{ color: '#0068FF', cursor: 'pointer' }}
          onClick={() => handleView(row)}
        >
          查看
        </span>
        <span
          v-permission="30224"
          style={{ color: '#0068FF', cursor: 'pointer' }}
          onClick={() => handleEdit(row)}
        >
          编辑
        </span>
        <span
          v-permission="30225"
          style={{ color: '#0068FF', cursor: 'pointer' }}
          onClick={() => handleDelete(row)}
        >
          删除
        </span>
      </div>
    )
  }
]
// 分页配置
const pagination = computed(() => ({
  page: searchForm.value.pageNum,
  pageSize: searchForm.value.pageSize,
  itemCount: totalCount.value,
  showSizePicker: true,
  pageSizes: [10, 20, 50, 100],
  prefix: ({ itemCount }) => `共 ${itemCount || 0} 条`,
  onChange: (page: number) => {
    searchForm.value.pageNum = page
    fetchData()
  },
  onUpdatePageSize: (pageSize: number) => {
    searchForm.value.pageSize = pageSize
    searchForm.value.pageNum = 1
    fetchData()
  }
}))
const fetchData = async () => {
  startLoading()
  try {
    const { data, error, code } = await fetchBannerPageList({
      bannerTitle: searchForm.value.title,
      status: searchForm.value.status,
      pageNum: searchForm.value.pageNum,
      pageSize: searchForm.value.pageSize
    }) as any
    if (!error && code === 200 && data) {
      tableData.value = data.records || []
      totalCount.value = data.total || 0
    }
  } finally {
    endLoading()
  }
}
const handleSearch = () => {
  console.log('搜索', searchForm.value)
  searchForm.value.pageNum = 1
  fetchData()
}
const handleReset = () => {
  searchForm.value = {
    title: '',
    status: null
    status: null,
    pageNum: 1,
    pageSize: 10
  }
  fetchData()
}
const handleAdd = () => {
  formMode.value = 'add'
  currentRow.value = null
  showFormModal.value = true
}
const handleView = (row: BannerItem) => {
const handleView = (row: Banner) => {
  formMode.value = 'view'
  currentRow.value = row
  showFormModal.value = true
  console.log('查看', row)
}
const handleEdit = (row: BannerItem) => {
const handleEdit = (row: Banner) => {
  formMode.value = 'edit'
  currentRow.value = row
  showFormModal.value = true
  console.log('编辑', row)
}
const handleDelete = (row: BannerItem) => {
  console.log('删除', row)
const handleDelete = (row: Banner) => {
  dialog.warning({
    title: '确认删除',
    content: `确认删除广告 "${row.bannerTitle}" 吗?`,
    positiveText: '确定',
    negativeText: '取消',
    onPositiveClick: async () => {
      const { error, code } = await deleteBanner(row.id) as any
      if (!error && code === 200) {
        message.success('删除成功')
        fetchData()
      }
    }
  })
}
const handleConfirmForm = (data: any) => {
  console.log('保存数据', data)
  window.$message?.success('保存成功')
const handleBatchDelete = () => {
  if (selectedRowKeys.value.length === 0) return
  dialog.warning({
    title: '确认删除',
    content: `确认删除选中的 ${selectedRowKeys.value.length} 条广告吗?`,
    positiveText: '确定',
    negativeText: '取消',
    onPositiveClick: async () => {
      // 并发删除
      try {
        const promises = selectedRowKeys.value.map(id => deleteBanner(id))
        const results = await Promise.all(promises) as any[]
        // 检查所有请求是否都成功 (code === 200)
        const allSuccess = results.every((res: any) => !res.error && res.code === 200)
        if (allSuccess) {
          message.success('批量删除成功')
          selectedRowKeys.value = []
          fetchData()
        } else {
          message.error('部分删除失败,请重试')
          fetchData()
        }
      } catch (e) {
        console.error(e)
        message.error('删除失败,请重试')
      }
    }
  })
}
const handleCheck = (rowKeys: any[]) => {
  selectedRowKeys.value = rowKeys as number[]
}
const handleConfirmForm = () => {
  fetchData()
}
onMounted(() => {
  fetchData()
})
</script>
<template>
  <div class="h-full flex flex-col">
    <n-card style="border-radius: 6px" content-style="padding: 12px">
      <n-form :model="searchForm" label-placement="left" inline :show-feedback="false">
        <div class="flex justify-between w-full">
          <div class="flex gap-4">
        <SearchPanel>
          <n-gi>
            <n-form-item label="广告标题">
              <n-input v-model:value="searchForm.title" placeholder="请输入" clearable style="width: 200px;" />
              <n-input v-model:value="searchForm.title" placeholder="请输入广告标题" clearable />
            </n-form-item>
          </n-gi>
          <n-gi>
            <n-form-item label="状态">
              <n-select
                v-model:value="searchForm.status"
                :options="statusOptions as any"
                placeholder="请选择"
              <n-select
                v-model:value="searchForm.status"
                :options="statusOptions as any"
                placeholder="请选择状态"
                clearable
                style="width: 150px;"
              />
            </n-form-item>
          </div>
          <div class="flex gap-3">
            <n-button style="width: 84px; height: 32px" @click="handleReset">重置</n-button>
            <n-button type="primary" style="width: 84px; height: 32px" @click="handleSearch">查询</n-button>
          </div>
        </div>
          </n-gi>
          <template #action>
            <n-button style="padding: 7px 16px; height: 32px" @click="handleReset">重置</n-button>
            <n-button type="primary" style="padding: 7px 16px; height: 32px" @click="handleSearch">查询</n-button>
          </template>
        </SearchPanel>
      </n-form>
    </n-card>
    <n-card style="border-radius: 6px; margin-top: 12px;" content-style="padding: 12px">
      <div class="mb-4 flex justify-between">
        <n-button type="error" style="width: 100px; height: 32px">批量删除</n-button>
        <n-button style="width: 84px; height: 32px; background-color: #E6F4FF; color: #0068FF; border: 1px solid #7ABDFF;" @click="handleAdd">添加</n-button>
      <div class="mb-4 flex justify-between items-center">
        <div class="flex gap-2">
          <n-button
            v-permission="30221"
            :disabled="selectedRowKeys.length === 0"
            style="padding: 7px 16px; height: 32px"
            @click="handleBatchDelete"
          >
            批量删除
          </n-button>
        </div>
        <div class="flex gap-2">
          <n-button
          v-permission="30222"
            @click="handleAdd"
            style="padding: 7px 16px; height: 32px; background-color: #E6F4FF; color: #0068FF; border: 1px solid #7ABDFF;"
          >
            新增广告
          </n-button>
        </div>
      </div>
      <n-data-table
        :columns="columns"
        :data="tableData"
        :loading="loading"
        :row-key="(row: any) => row.id"
        :scroll-x="1400"
        :single-line="false"
        :pagination="pagination"
        @update:checked-row-keys="handleCheck"
      />
    </n-card>
    <BannerFormModal v-model:show="showFormModal" :mode="formMode" @confirm="handleConfirmForm" />
    <BannerFormModal v-model:show="showFormModal" :mode="formMode" :row-data="currentRow" @confirm="handleConfirmForm" />
  </div>
</template>
src/views/userContent/contentCenter/index.vue
@@ -326,7 +326,7 @@
      </n-tabs>
    </n-card>
    <div class="flex justify-end absolute bottom-0 left-0 right-0" style="background-color: white; padding: 16px 24px; border-radius: 6px; margin-top: 12px;">
    <div v-permission="30211" class="flex justify-end absolute bottom-0 left-0 right-0" style="background-color: white; padding: 16px 24px; border-radius: 6px; margin-top: 12px;">
      <n-button type="primary" style="width: 84px; height: 32px" @click="handleSave">保存</n-button>
    </div>
  </div>