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