add: 首页添加公告和右下角浮动按钮

This commit is contained in:
ctwj
2025-10-20 01:52:19 +08:00
parent fdc75705aa
commit 0df7d8bf23
13 changed files with 985 additions and 252 deletions

View File

@@ -1,6 +1,7 @@
package converter
import (
"encoding/json"
"strconv"
"time"
@@ -90,6 +91,25 @@ func SystemConfigToResponse(configs []entity.SystemConfig) *dto.SystemConfigResp
response.MeilisearchMasterKey = config.Value
case entity.ConfigKeyMeilisearchIndexName:
response.MeilisearchIndexName = config.Value
case entity.ConfigKeyEnableAnnouncements:
if val, err := strconv.ParseBool(config.Value); err == nil {
response.EnableAnnouncements = val
}
case entity.ConfigKeyAnnouncements:
if config.Value == "" || config.Value == "[]" {
response.Announcements = ""
} else {
// 在响应时保持为字符串,后续由前端处理
response.Announcements = config.Value
}
case entity.ConfigKeyEnableFloatButtons:
if val, err := strconv.ParseBool(config.Value); err == nil {
response.EnableFloatButtons = val
}
case entity.ConfigKeyWechatSearchImage:
response.WechatSearchImage = config.Value
case entity.ConfigKeyTelegramQrImage:
response.TelegramQrImage = config.Value
}
}
@@ -221,6 +241,31 @@ func RequestToSystemConfig(req *dto.SystemConfigRequest) []entity.SystemConfig {
updatedKeys = append(updatedKeys, entity.ConfigKeyMeilisearchIndexName)
}
// 界面配置处理
if req.EnableAnnouncements != nil {
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyEnableAnnouncements, Value: strconv.FormatBool(*req.EnableAnnouncements), Type: entity.ConfigTypeBool})
updatedKeys = append(updatedKeys, entity.ConfigKeyEnableAnnouncements)
}
if req.Announcements != nil {
// 将数组转换为JSON字符串
if jsonBytes, err := json.Marshal(*req.Announcements); err == nil {
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyAnnouncements, Value: string(jsonBytes), Type: entity.ConfigTypeJSON})
updatedKeys = append(updatedKeys, entity.ConfigKeyAnnouncements)
}
}
if req.EnableFloatButtons != nil {
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyEnableFloatButtons, Value: strconv.FormatBool(*req.EnableFloatButtons), Type: entity.ConfigTypeBool})
updatedKeys = append(updatedKeys, entity.ConfigKeyEnableFloatButtons)
}
if req.WechatSearchImage != nil {
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyWechatSearchImage, Value: *req.WechatSearchImage, Type: entity.ConfigTypeString})
updatedKeys = append(updatedKeys, entity.ConfigKeyWechatSearchImage)
}
if req.TelegramQrImage != nil {
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyTelegramQrImage, Value: *req.TelegramQrImage, Type: entity.ConfigTypeString})
updatedKeys = append(updatedKeys, entity.ConfigKeyTelegramQrImage)
}
// 记录更新的配置项
if len(updatedKeys) > 0 {
utils.Info("配置更新 - 被修改的配置项: %v", updatedKeys)
@@ -332,6 +377,24 @@ func SystemConfigToPublicResponse(configs []entity.SystemConfig) map[string]inte
response[entity.ConfigResponseFieldMeilisearchMasterKey] = config.Value
case entity.ConfigKeyMeilisearchIndexName:
response[entity.ConfigResponseFieldMeilisearchIndexName] = config.Value
case entity.ConfigKeyEnableAnnouncements:
if val, err := strconv.ParseBool(config.Value); err == nil {
response["enable_announcements"] = val
}
case entity.ConfigKeyAnnouncements:
if config.Value == "" || config.Value == "[]" {
response["announcements"] = ""
} else {
response["announcements"] = config.Value
}
case entity.ConfigKeyEnableFloatButtons:
if val, err := strconv.ParseBool(config.Value); err == nil {
response["enable_float_buttons"] = val
}
case entity.ConfigKeyWechatSearchImage:
response["wechat_search_image"] = config.Value
case entity.ConfigKeyTelegramQrImage:
response["telegram_qr_image"] = config.Value
}
}
@@ -372,5 +435,10 @@ func getDefaultConfigResponse() *dto.SystemConfigResponse {
MeilisearchPort: entity.ConfigDefaultMeilisearchPort,
MeilisearchMasterKey: entity.ConfigDefaultMeilisearchMasterKey,
MeilisearchIndexName: entity.ConfigDefaultMeilisearchIndexName,
EnableAnnouncements: false,
Announcements: "",
EnableFloatButtons: false,
WechatSearchImage: entity.ConfigDefaultWechatSearchImage,
TelegramQrImage: entity.ConfigDefaultTelegramQrImage,
}
}

View File

@@ -42,6 +42,13 @@ type SystemConfigRequest struct {
MeilisearchPort *string `json:"meilisearch_port,omitempty"`
MeilisearchMasterKey *string `json:"meilisearch_master_key,omitempty"`
MeilisearchIndexName *string `json:"meilisearch_index_name,omitempty"`
// 界面配置
EnableAnnouncements *bool `json:"enable_announcements,omitempty"`
Announcements *[]map[string]interface{} `json:"announcements,omitempty"`
EnableFloatButtons *bool `json:"enable_float_buttons,omitempty"`
WechatSearchImage *string `json:"wechat_search_image,omitempty"`
TelegramQrImage *string `json:"telegram_qr_image,omitempty"`
}
// SystemConfigResponse 系统配置响应
@@ -90,6 +97,13 @@ type SystemConfigResponse struct {
MeilisearchPort string `json:"meilisearch_port"`
MeilisearchMasterKey string `json:"meilisearch_master_key"`
MeilisearchIndexName string `json:"meilisearch_index_name"`
// 界面配置
EnableAnnouncements bool `json:"enable_announcements"`
Announcements string `json:"announcements"`
EnableFloatButtons bool `json:"enable_float_buttons"`
WechatSearchImage string `json:"wechat_search_image"`
TelegramQrImage string `json:"telegram_qr_image"`
}
// SystemConfigItem 单个配置项

View File

@@ -56,6 +56,13 @@ const (
ConfigKeyTelegramProxyPort = "telegram_proxy_port"
ConfigKeyTelegramProxyUsername = "telegram_proxy_username"
ConfigKeyTelegramProxyPassword = "telegram_proxy_password"
// 界面配置
ConfigKeyEnableAnnouncements = "enable_announcements"
ConfigKeyAnnouncements = "announcements"
ConfigKeyEnableFloatButtons = "enable_float_buttons"
ConfigKeyWechatSearchImage = "wechat_search_image"
ConfigKeyTelegramQrImage = "telegram_qr_image"
)
// ConfigType 配置类型常量
@@ -183,4 +190,11 @@ const (
ConfigDefaultTelegramProxyPort = "8080"
ConfigDefaultTelegramProxyUsername = ""
ConfigDefaultTelegramProxyPassword = ""
// 界面配置默认值
ConfigDefaultEnableAnnouncements = "false"
ConfigDefaultAnnouncements = ""
ConfigDefaultEnableFloatButtons = "false"
ConfigDefaultWechatSearchImage = ""
ConfigDefaultTelegramQrImage = ""
)

View File

@@ -133,6 +133,11 @@ func (r *SystemConfigRepositoryImpl) GetOrCreateDefault() ([]entity.SystemConfig
{Key: entity.ConfigKeyMeilisearchPort, Value: entity.ConfigDefaultMeilisearchPort, Type: entity.ConfigTypeString},
{Key: entity.ConfigKeyMeilisearchMasterKey, Value: entity.ConfigDefaultMeilisearchMasterKey, Type: entity.ConfigTypeString},
{Key: entity.ConfigKeyMeilisearchIndexName, Value: entity.ConfigDefaultMeilisearchIndexName, Type: entity.ConfigTypeString},
{Key: entity.ConfigKeyEnableAnnouncements, Value: entity.ConfigDefaultEnableAnnouncements, Type: entity.ConfigTypeBool},
{Key: entity.ConfigKeyAnnouncements, Value: entity.ConfigDefaultAnnouncements, Type: entity.ConfigTypeJSON},
{Key: entity.ConfigKeyEnableFloatButtons, Value: entity.ConfigDefaultEnableFloatButtons, Type: entity.ConfigTypeBool},
{Key: entity.ConfigKeyWechatSearchImage, Value: entity.ConfigDefaultWechatSearchImage, Type: entity.ConfigTypeString},
{Key: entity.ConfigKeyTelegramQrImage, Value: entity.ConfigDefaultTelegramQrImage, Type: entity.ConfigTypeString},
}
err = r.UpsertConfigs(defaultConfigs)
@@ -169,6 +174,11 @@ func (r *SystemConfigRepositoryImpl) GetOrCreateDefault() ([]entity.SystemConfig
entity.ConfigKeyMeilisearchPort: {Key: entity.ConfigKeyMeilisearchPort, Value: entity.ConfigDefaultMeilisearchPort, Type: entity.ConfigTypeString},
entity.ConfigKeyMeilisearchMasterKey: {Key: entity.ConfigKeyMeilisearchMasterKey, Value: entity.ConfigDefaultMeilisearchMasterKey, Type: entity.ConfigTypeString},
entity.ConfigKeyMeilisearchIndexName: {Key: entity.ConfigKeyMeilisearchIndexName, Value: entity.ConfigDefaultMeilisearchIndexName, Type: entity.ConfigTypeString},
entity.ConfigKeyEnableAnnouncements: {Key: entity.ConfigKeyEnableAnnouncements, Value: entity.ConfigDefaultEnableAnnouncements, Type: entity.ConfigTypeBool},
entity.ConfigKeyAnnouncements: {Key: entity.ConfigKeyAnnouncements, Value: entity.ConfigDefaultAnnouncements, Type: entity.ConfigTypeJSON},
entity.ConfigKeyEnableFloatButtons: {Key: entity.ConfigKeyEnableFloatButtons, Value: entity.ConfigDefaultEnableFloatButtons, Type: entity.ConfigTypeBool},
entity.ConfigKeyWechatSearchImage: {Key: entity.ConfigKeyWechatSearchImage, Value: entity.ConfigDefaultWechatSearchImage, Type: entity.ConfigTypeString},
entity.ConfigKeyTelegramQrImage: {Key: entity.ConfigKeyTelegramQrImage, Value: entity.ConfigDefaultTelegramQrImage, Type: entity.ConfigTypeString},
}
// 检查现有配置中是否有缺失的配置项

View File

@@ -125,6 +125,7 @@ func GetSystemConfig(c *gin.Context) {
func UpdateSystemConfig(c *gin.Context) {
var req dto.SystemConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.Error("JSON绑定失败: %v", err)
ErrorResponse(c, "请求参数错误", http.StatusBadRequest)
return
}
@@ -141,31 +142,53 @@ func UpdateSystemConfig(c *gin.Context) {
}
// 验证参数 - 只验证提交的字段
if req.SiteTitle != nil && (len(*req.SiteTitle) < 1 || len(*req.SiteTitle) > 100) {
utils.Info("开始验证参数")
if req.SiteTitle != nil {
utils.Info("验证SiteTitle: '%s', 长度: %d", *req.SiteTitle, len(*req.SiteTitle))
if len(*req.SiteTitle) < 1 || len(*req.SiteTitle) > 100 {
ErrorResponse(c, "网站标题长度必须在1-100字符之间", http.StatusBadRequest)
return
}
}
if req.AutoProcessInterval != nil && (*req.AutoProcessInterval < 1 || *req.AutoProcessInterval > 1440) {
if req.AutoProcessInterval != nil {
utils.Info("验证AutoProcessInterval: %d", *req.AutoProcessInterval)
if *req.AutoProcessInterval < 1 || *req.AutoProcessInterval > 1440 {
ErrorResponse(c, "自动处理间隔必须在1-1440分钟之间", http.StatusBadRequest)
return
}
}
if req.PageSize != nil && (*req.PageSize < 10 || *req.PageSize > 500) {
if req.PageSize != nil {
utils.Info("验证PageSize: %d", *req.PageSize)
if *req.PageSize < 10 || *req.PageSize > 500 {
ErrorResponse(c, "每页显示数量必须在10-500之间", http.StatusBadRequest)
return
}
}
// 验证自动转存配置
if req.AutoTransferLimitDays != nil && (*req.AutoTransferLimitDays < 0 || *req.AutoTransferLimitDays > 365) {
if req.AutoTransferLimitDays != nil {
utils.Info("验证AutoTransferLimitDays: %d", *req.AutoTransferLimitDays)
if *req.AutoTransferLimitDays < 0 || *req.AutoTransferLimitDays > 365 {
ErrorResponse(c, "自动转存限制天数必须在0-365之间", http.StatusBadRequest)
return
}
}
if req.AutoTransferMinSpace != nil && (*req.AutoTransferMinSpace < 100 || *req.AutoTransferMinSpace > 1024) {
if req.AutoTransferMinSpace != nil {
utils.Info("验证AutoTransferMinSpace: %d", *req.AutoTransferMinSpace)
if *req.AutoTransferMinSpace < 100 || *req.AutoTransferMinSpace > 1024 {
ErrorResponse(c, "最小存储空间必须在100-1024GB之间", http.StatusBadRequest)
return
}
}
// 验证公告相关字段
if req.Announcements != nil {
utils.Info("验证Announcements: '%s'", *req.Announcements)
// 可以在这里添加更详细的验证逻辑
}
// 转换为实体
configs := converter.RequestToSystemConfig(&req)

1
web/components.d.ts vendored
View File

@@ -34,6 +34,7 @@ declare module 'vue' {
NInputNumber: typeof import('naive-ui')['NInputNumber']
NList: typeof import('naive-ui')['NList']
NListItem: typeof import('naive-ui')['NListItem']
NMarquee: typeof import('naive-ui')['NMarquee']
NMessageProvider: typeof import('naive-ui')['NMessageProvider']
NModal: typeof import('naive-ui')['NModal']
NNotificationProvider: typeof import('naive-ui')['NNotificationProvider']

View File

@@ -0,0 +1,157 @@
<template>
<div class="space-y-4">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-2">
<label class="text-lg font-semibold text-gray-800 dark:text-gray-200">公告配置</label>
<span class="text-xs text-gray-500 dark:text-gray-400">开启后可在网站显示公告信息</span>
</div>
<n-button v-if="modelValue.enable_announcements" @click="addAnnouncement" type="primary" size="small">
<template #icon>
<i class="fas fa-plus"></i>
</template>
添加公告
</n-button>
</div>
<n-switch v-model:value="enableAnnouncements" />
<!-- 公告列表 -->
<div v-if="modelValue.enable_announcements && modelValue.announcements && modelValue.announcements.length > 0" class="announcement-list space-y-3">
<div v-for="(announcement, index) in modelValue.announcements" :key="index" class="announcement-item border rounded-lg p-3 bg-gray-50 dark:bg-gray-800">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center space-x-3">
<h4 class="text-sm font-medium text-gray-900 dark:text-gray-100">公告 {{ index + 1 }}</h4>
<n-switch v-model:value="announcement.enabled" size="small" />
</div>
<div class="flex items-center space-x-1">
<n-button text @click="moveAnnouncementUp(index)" :disabled="index === 0" size="small">
<template #icon>
<i class="fas fa-arrow-up"></i>
</template>
</n-button>
<n-button text @click="moveAnnouncementDown(index)" :disabled="index === modelValue.announcements.length - 1" size="small">
<template #icon>
<i class="fas fa-arrow-down"></i>
</template>
</n-button>
<n-button text @click="removeAnnouncement(index)" type="error" size="small">
<template #icon>
<i class="fas fa-trash"></i>
</template>
</n-button>
</div>
</div>
<div class="space-y-2">
<n-input
v-model:value="announcement.content"
placeholder="公告内容支持HTML标签"
type="textarea"
:rows="2"
size="small"
/>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
// 公告接口
interface Announcement {
content: string
enabled: boolean
}
// 配置数据接口
interface ConfigData {
enable_announcements: boolean
announcements: Announcement[]
}
// Props
const props = defineProps<{
modelValue: ConfigData
}>()
// Emits
const emit = defineEmits<{
'update:modelValue': [value: ConfigData]
}>()
// 计算属性用于双向绑定
const enableAnnouncements = computed({
get: () => props.modelValue.enable_announcements,
set: (value: boolean) => {
emit('update:modelValue', {
enable_announcements: value,
announcements: props.modelValue.announcements
})
}
})
console.log(props.modelValue)
// 更新数据
const updateValue = (newValue: ConfigData) => {
emit('update:modelValue', newValue)
}
// 添加公告
const addAnnouncement = () => {
const newAnnouncements = [...props.modelValue.announcements, {
content: '',
enabled: true
}]
updateValue({
enable_announcements: props.modelValue.enable_announcements,
announcements: newAnnouncements
})
}
// 删除公告
const removeAnnouncement = (index: number) => {
const currentAnnouncements = Array.isArray(props.modelValue.announcements) ? props.modelValue.announcements : []
const newAnnouncements = currentAnnouncements.filter((_, i) => i !== index)
updateValue({
enable_announcements: props.modelValue.enable_announcements,
announcements: newAnnouncements
})
}
// 上移公告
const moveAnnouncementUp = (index: number) => {
if (index > 0) {
const currentAnnouncements = Array.isArray(props.modelValue.announcements) ? props.modelValue.announcements : []
const newAnnouncements = [...currentAnnouncements]
const temp = newAnnouncements[index]
newAnnouncements[index] = newAnnouncements[index - 1]
newAnnouncements[index - 1] = temp
updateValue({
enable_announcements: props.modelValue.enable_announcements,
announcements: newAnnouncements
})
}
}
// 下移公告
const moveAnnouncementDown = (index: number) => {
const currentAnnouncements = Array.isArray(props.modelValue.announcements) ? props.modelValue.announcements : []
if (index < currentAnnouncements.length - 1) {
const newAnnouncements = [...currentAnnouncements]
const temp = newAnnouncements[index]
newAnnouncements[index] = newAnnouncements[index + 1]
newAnnouncements[index + 1] = temp
updateValue({
enable_announcements: props.modelValue.enable_announcements,
announcements: newAnnouncements
})
}
}
</script>
<style scoped>
.announcement-list {
max-height: 400px;
overflow-y: auto;
}
</style>

View File

@@ -0,0 +1,171 @@
<template>
<div class="space-y-4">
<div class="flex items-center space-x-2">
<label class="text-lg font-semibold text-gray-800 dark:text-gray-200">浮动按钮配置</label>
<span class="text-xs text-gray-500 dark:text-gray-400">开启后显示右下角浮动按钮</span>
</div>
<n-switch v-model:value="enableFloatButtons" />
<!-- 浮动按钮设置 -->
<div v-if="modelValue.enable_float_buttons" class="float-buttons-config space-y-4">
<!-- 微信搜一搜图片 -->
<div class="space-y-2">
<div class="flex items-center space-x-2">
<label class="text-base font-semibold text-gray-800 dark:text-gray-200">微信搜一搜图片</label>
<span class="text-xs text-gray-500 dark:text-gray-400">选择微信搜一搜的二维码图片</span>
</div>
<div class="flex items-center space-x-4">
<div v-if="modelValue.wechat_search_image" class="flex-shrink-0">
<n-image
:src="getImageUrl(modelValue.wechat_search_image)"
alt="微信搜一搜"
width="80"
height="80"
object-fit="cover"
class="rounded-lg border"
/>
</div>
<div class="flex-1">
<n-button type="primary" @click="openWechatSelector">
<template #icon>
<i class="fas fa-image"></i>
</template>
{{ modelValue.wechat_search_image ? '更换图片' : '选择图片' }}
</n-button>
<n-button v-if="modelValue.wechat_search_image" @click="clearWechatImage" class="ml-2">
<template #icon>
<i class="fas fa-times"></i>
</template>
清除
</n-button>
</div>
</div>
</div>
<!-- Telegram二维码 -->
<div class="space-y-2">
<div class="flex items-center space-x-2">
<label class="text-base font-semibold text-gray-800 dark:text-gray-200">Telegram二维码</label>
<span class="text-xs text-gray-500 dark:text-gray-400">选择Telegram群组的二维码图片</span>
</div>
<div class="flex items-center space-x-4">
<div v-if="modelValue.telegram_qr_image" class="flex-shrink-0">
<n-image
:src="getImageUrl(modelValue.telegram_qr_image)"
alt="Telegram二维码"
width="80"
height="80"
object-fit="cover"
class="rounded-lg border"
/>
</div>
<div class="flex-1">
<n-button type="primary" @click="openTelegramSelector">
<template #icon>
<i class="fas fa-image"></i>
</template>
{{ modelValue.telegram_qr_image ? '更换图片' : '选择图片' }}
</n-button>
<n-button v-if="modelValue.telegram_qr_image" @click="clearTelegramImage" class="ml-2">
<template #icon>
<i class="fas fa-times"></i>
</template>
清除
</n-button>
</div>
</div>
</div>
</div>
</div>
</template>
// 使用图片URL composable
const { getImageUrl } = useImageUrl()
<script setup lang="ts">
import ImageSelectorModal from '~/components/Admin/ImageSelectorModal.vue'
// 配置数据接口
interface ConfigData {
enable_float_buttons: boolean
wechat_search_image: string
telegram_qr_image: string
}
// Props
const props = defineProps<{
modelValue: ConfigData
}>()
// Emits
const emit = defineEmits<{
'update:modelValue': [value: ConfigData]
'openWechatSelector': []
'openTelegramSelector': []
}>()
// 计算属性用于双向绑定
const enableFloatButtons = computed({
get: () => props.modelValue.enable_float_buttons,
set: (value: boolean) => {
emit('update:modelValue', {
enable_float_buttons: value,
wechat_search_image: props.modelValue.wechat_search_image,
telegram_qr_image: props.modelValue.telegram_qr_image
})
}
})
// 使用图片URL composable
const { getImageUrl } = useImageUrl()
// 选择器状态
const showWechatSelector = ref(false)
const showTelegramSelector = ref(false)
// 清除微信图片
const clearWechatImage = () => {
emit('update:modelValue', {
enable_float_buttons: props.modelValue.enable_float_buttons,
wechat_search_image: '',
telegram_qr_image: props.modelValue.telegram_qr_image
})
}
// 清除Telegram图片
const clearTelegramImage = () => {
emit('update:modelValue', {
enable_float_buttons: props.modelValue.enable_float_buttons,
wechat_search_image: props.modelValue.wechat_search_image,
telegram_qr_image: ''
})
}
// 打开微信选择器
const openWechatSelector = () => {
emit('openWechatSelector')
}
// 打开Telegram选择器
const openTelegramSelector = () => {
emit('openTelegramSelector')
}
// 处理微信图片选择
const handleWechatImageSelect = (file: any) => {
emit('update:modelValue', {
enable_float_buttons: props.modelValue.enable_float_buttons,
wechat_search_image: file.access_url,
telegram_qr_image: props.modelValue.telegram_qr_image
})
}
// 处理Telegram图片选择
const handleTelegramImageSelect = (file: any) => {
emit('update:modelValue', {
enable_float_buttons: props.modelValue.enable_float_buttons,
wechat_search_image: props.modelValue.wechat_search_image,
telegram_qr_image: file.access_url
})
}
</script>

View File

@@ -0,0 +1,256 @@
<template>
<n-modal v-model:show="showModal" preset="card" :title="title" style="width: 90vw; max-width: 1200px; max-height: 80vh;">
<div class="space-y-4">
<!-- 搜索 -->
<div class="flex gap-4">
<n-input
v-model:value="searchKeyword"
placeholder="搜索文件名..."
@keyup.enter="handleSearch"
class="flex-1"
clearable
>
<template #prefix>
<i class="fas fa-search"></i>
</template>
</n-input>
<n-button type="primary" @click="handleSearch" class="w-20">
<template #icon>
<i class="fas fa-search"></i>
</template>
搜索
</n-button>
</div>
<!-- 文件列表 -->
<div v-if="loading" class="flex items-center justify-center py-8">
<n-spin size="large" />
</div>
<div v-else-if="fileList.length === 0" class="text-center py-8">
<i class="fas fa-file-upload text-4xl text-gray-400 mb-4"></i>
<p class="text-gray-500">暂无图片文件</p>
</div>
<div v-else class="file-grid">
<div
v-for="file in fileList"
:key="file.id"
class="file-item cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-lg p-3 transition-colors"
:class="{ 'bg-blue-50 dark:bg-blue-900/20 border-2 border-blue-300 dark:border-blue-600': selectedFileId === file.id }"
@click="selectFile(file)"
>
<div class="image-preview">
<n-image
:src="getImageUrl(file.access_url)"
:alt="file.original_name"
:lazy="false"
object-fit="cover"
class="preview-image rounded"
@error="handleImageError"
@load="handleImageLoad"
/>
<div class="image-info mt-2">
<div class="file-name text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
{{ file.original_name }}
</div>
<div class="file-size text-xs text-gray-500 dark:text-gray-400">
{{ formatFileSize(file.file_size) }}
</div>
</div>
</div>
</div>
</div>
<!-- 分页 -->
<div class="pagination-wrapper">
<n-pagination
v-model:page="pagination.page"
v-model:page-size="pagination.pageSize"
:page-count="Math.ceil(pagination.total / pagination.pageSize)"
:page-sizes="pagination.pageSizes"
show-size-picker
@update:page="handlePageChange"
@update:page-size="handlePageSizeChange"
/>
</div>
</div>
<template #footer>
<n-space justify="end">
<n-button @click="closeModal">取消</n-button>
<n-button
type="primary"
@click="confirmSelection"
:disabled="!selectedFileId"
>
确认选择
</n-button>
</n-space>
</template>
</n-modal>
</template>
<script setup lang="ts">
// Props
const props = defineProps<{
show: boolean
title: string
}>()
// Emits
const emit = defineEmits<{
'update:show': [value: boolean]
'select': [file: any]
}>()
// 使用图片URL composable
const { getImageUrl } = useImageUrl()
// 响应式数据
const showModal = computed({
get: () => props.show,
set: (value) => emit('update:show', value)
})
const loading = ref(false)
const fileList = ref<any[]>([])
const selectedFileId = ref<number | null>(null)
const searchKeyword = ref('')
const pagination = ref({
page: 1,
pageSize: 20,
total: 0,
pageSizes: [10, 20, 50, 100]
})
// 监听show变化重新加载数据
watch(() => props.show, (newValue) => {
if (newValue) {
loadFileList()
} else {
// 重置状态
selectedFileId.value = null
searchKeyword.value = ''
}
})
// 加载文件列表
const loadFileList = async () => {
try {
loading.value = true
const { useFileApi } = await import('~/composables/useFileApi')
const fileApi = useFileApi()
const response = await fileApi.getFileList({
page: pagination.value.page,
pageSize: pagination.value.pageSize,
search: searchKeyword.value,
fileType: 'image',
status: 'active'
}) as any
if (response && response.data) {
fileList.value = response.data.files || []
pagination.value.total = response.data.total || 0
}
} catch (error) {
console.error('获取文件列表失败:', error)
} finally {
loading.value = false
}
}
// 搜索处理
const handleSearch = () => {
pagination.value.page = 1
loadFileList()
}
// 分页处理
const handlePageChange = (page: number) => {
pagination.value.page = page
loadFileList()
}
const handlePageSizeChange = (pageSize: number) => {
pagination.value.pageSize = pageSize
pagination.value.page = 1
loadFileList()
}
// 文件选择
const selectFile = (file: any) => {
selectedFileId.value = file.id
}
// 确认选择
const confirmSelection = () => {
if (selectedFileId.value) {
const file = fileList.value.find(f => f.id === selectedFileId.value)
if (file) {
emit('select', file)
closeModal()
}
}
}
// 关闭模态框
const closeModal = () => {
showModal.value = false
}
// 文件大小格式化
const formatFileSize = (size: number) => {
if (size < 1024) return size + ' B'
if (size < 1024 * 1024) return (size / 1024).toFixed(1) + ' KB'
if (size < 1024 * 1024 * 1024) return (size / (1024 * 1024)).toFixed(1) + ' MB'
return (size / (1024 * 1024 * 1024)).toFixed(1) + ' GB'
}
// 图片加载处理
const handleImageError = (event: any) => {
console.error('图片加载失败:', event)
}
const handleImageLoad = (event: any) => {
console.log('图片加载成功:', event)
}
</script>
<style scoped>
.file-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
max-height: 400px;
overflow-y: auto;
}
.file-item {
border: 1px solid #e5e7eb;
transition: all 0.2s ease;
}
.file-item:hover {
transform: translateY(-2px);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
.preview-image {
width: 100%;
height: 120px;
object-fit: cover;
border: 1px solid #e5e7eb;
border-radius: 4px;
}
.pagination-wrapper {
display: flex;
justify-content: center;
margin-top: 1rem;
}
</style>

View File

@@ -0,0 +1,118 @@
<template>
<div v-if="shouldShowAnnouncement" class="announcement-container px-3 py-1">
<div class="flex items-center justify-between min-h-[24px]">
<div class="flex items-center gap-2 flex-1 overflow-hidden">
<i class="fas fa-bullhorn text-blue-600 dark:text-blue-400 text-sm flex-shrink-0"></i>
<div class="announcement-content overflow-hidden">
<div class="announcement-item active">
<span class="text-sm text-gray-700 dark:text-gray-300 truncate">{{ validAnnouncements[currentIndex].content }}</span>
</div>
</div>
</div>
<div class="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400 flex-shrink-0 ml-2">
<span>{{ (currentIndex + 1) }}/{{ validAnnouncements.length }}</span>
<button @click="nextAnnouncement" class="hover:text-blue-500 transition-colors">
<i class="fas fa-chevron-right text-xs"></i>
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
// 使用系统配置store获取公告数据
import { useSystemConfigStore } from '~/stores/systemConfig'
const systemConfigStore = useSystemConfigStore()
await systemConfigStore.initConfig(false, false)
const systemConfig = computed(() => systemConfigStore.config)
interface AnnouncementItem {
content: string
enabled: boolean
}
const currentIndex = ref(0)
const interval = ref<NodeJS.Timeout | null>(null)
// 计算有效公告(开启状态且有内容的公告)
const validAnnouncements = computed(() => {
if (!systemConfig.value?.announcements) return []
const announcements = Array.isArray(systemConfig.value.announcements)
? systemConfig.value.announcements
: JSON.parse(systemConfig.value.announcements || '[]')
return announcements.filter((item: AnnouncementItem) =>
item.enabled && item.content && item.content.trim()
)
})
// 判断是否应该显示公告
const shouldShowAnnouncement = computed(() => {
return systemConfig.value?.enable_announcements && validAnnouncements.value.length > 0
})
// 自动切换公告
const startAutoSwitch = () => {
if (validAnnouncements.value.length > 1) {
interval.value = setInterval(() => {
currentIndex.value = (currentIndex.value + 1) % validAnnouncements.value.length
}, 4000) // 每4秒切换一次
}
}
// 手动切换到下一条公告
const nextAnnouncement = () => {
currentIndex.value = (currentIndex.value + 1) % validAnnouncements.value.length
}
// 监听公告数据变化,重新开始自动切换
watch(() => validAnnouncements.value.length, (newLength) => {
if (newLength > 0) {
currentIndex.value = 0
stopAutoSwitch()
startAutoSwitch()
}
})
// 清理定时器
const stopAutoSwitch = () => {
if (interval.value) {
clearInterval(interval.value)
interval.value = null
}
}
onMounted(() => {
if (shouldShowAnnouncement.value) {
startAutoSwitch()
}
})
onUnmounted(() => {
stopAutoSwitch()
})
</script>
<style scoped>
.announcement-content {
position: relative;
height: 20px; /* 固定高度 */
}
.announcement-item {
opacity: 0;
transform: translateY(5px);
transition: all 0.5s ease-in-out;
}
.announcement-item.active {
opacity: 1;
transform: translateY(0);
}
/* 暗色主题适配 */
.dark-theme .announcement-container {
background: transparent;
}
</style>

View File

@@ -30,7 +30,7 @@
"@pinia/nuxt": "^0.5.0",
"@vicons/ionicons5": "^0.12.0",
"chart.js": "^4.5.0",
"naive-ui": "^2.37.0",
"naive-ui": "^2.42.0",
"pinia": "^2.1.0",
"vfonts": "^0.0.3",
"vue": "^3.3.0",

View File

@@ -176,103 +176,59 @@
</n-form>
</div>
</n-tab-pane>
<n-tab-pane name="ui" tab="界面配置">
<div class="tab-content-container">
<n-form
ref="formRef"
:model="configForm"
:rules="rules"
label-placement="left"
label-width="auto"
require-mark-placement="right-hanging"
>
<div class="space-y-6">
<!-- 公告配置组件 -->
<AnnouncementConfig
v-model="announcementConfig"
@update:modelValue="handleAnnouncementUpdate"
/>
<!-- 浮动按钮配置组件 -->
<FloatButtonsConfig
v-model="floatButtonsConfig"
@update:modelValue="handleFloatButtonsUpdate"
@openWechatSelector="showWechatSelector = true"
@openTelegramSelector="showTelegramSelector = true"
/>
<!-- 微信图片选择器 -->
<ImageSelectorModal
v-model:show="showWechatSelector"
title="选择微信搜一搜图片"
@select="handleWechatImageSelect"
/>
<!-- Telegram图片选择器 -->
<ImageSelectorModal
v-model:show="showTelegramSelector"
title="选择Telegram二维码图片"
@select="handleTelegramImageSelect"
/>
</div>
</n-form>
</div>
</n-tab-pane>
</n-tabs>
</div>
</template>
</AdminPageLayout>
<!-- Logo选择模态框 -->
<n-modal v-model:show="showLogoSelector" preset="card" title="选择Logo图片" style="width: 90vw; max-width: 1200px; max-height: 80vh;">
<div class="space-y-4">
<!-- 搜索 -->
<div class="flex gap-4">
<n-input
v-model:value="searchKeyword"
placeholder="搜索文件名..."
@keyup.enter="handleSearch"
class="flex-1"
clearable
>
<template #prefix>
<i class="fas fa-search"></i>
</template>
</n-input>
<n-button type="primary" @click="handleSearch" class="w-20">
<template #icon>
<i class="fas fa-search"></i>
</template>
搜索
</n-button>
</div>
<!-- 文件列表 -->
<div v-if="loading" class="flex items-center justify-center py-8">
<n-spin size="large" />
</div>
<div v-else-if="fileList.length === 0" class="text-center py-8">
<i class="fas fa-file-upload text-4xl text-gray-400 mb-4"></i>
<p class="text-gray-500">暂无图片文件</p>
</div>
<div v-else class="file-grid">
<div
v-for="file in fileList"
:key="file.id"
class="file-item cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-lg p-3 transition-colors"
:class="{ 'bg-blue-50 dark:bg-blue-900/20 border-2 border-blue-300 dark:border-blue-600': selectedFileId === file.id }"
@click="selectFile(file)"
>
<div class="image-preview">
<n-image
:src="getImageUrl(file.access_url)"
:alt="file.original_name"
:lazy="false"
object-fit="cover"
class="preview-image rounded"
@error="handleImageError"
@load="handleImageLoad"
<!-- ImageSelectorModal 组件 -->
<ImageSelectorModal
v-model:show="showLogoSelector"
title="选择Logo图片"
@select="handleLogoSelect"
/>
<div class="image-info mt-2">
<div class="file-name text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
{{ file.original_name }}
</div>
<div class="file-size text-xs text-gray-500 dark:text-gray-400">
{{ formatFileSize(file.file_size) }}
</div>
</div>
</div>
</div>
</div>
<!-- 分页 -->
<div class="pagination-wrapper">
<n-pagination
v-model:page="pagination.page"
v-model:page-size="pagination.pageSize"
:page-count="Math.ceil(pagination.total / pagination.pageSize)"
:page-sizes="pagination.pageSizes"
show-size-picker
@update:page="handlePageChange"
@update:page-size="handlePageSizeChange"
/>
</div>
</div>
<template #footer>
<n-space justify="end">
<n-button @click="showLogoSelector = false">取消</n-button>
<n-button
type="primary"
@click="confirmSelection"
:disabled="!selectedFileId"
>
确认选择
</n-button>
</n-space>
</template>
</n-modal>
</template>
<script setup lang="ts">
@@ -285,6 +241,9 @@ definePageMeta({
import { useImageUrl } from '~/composables/useImageUrl'
import { useConfigChangeDetection } from '~/composables/useConfigChangeDetection'
import AnnouncementConfig from '~/components/Admin/AnnouncementConfig.vue'
import FloatButtonsConfig from '~/components/Admin/FloatButtonsConfig.vue'
import ImageSelectorModal from '~/components/Admin/ImageSelectorModal.vue'
const notification = useNotification()
const { getImageUrl } = useImageUrl()
@@ -294,18 +253,16 @@ const activeTab = ref('basic')
// Logo选择器相关数据
const showLogoSelector = ref(false)
const loading = ref(false)
const fileList = ref<any[]>([])
const selectedFileId = ref<number | null>(null)
const searchKeyword = ref('')
// 分页
const pagination = ref({
page: 1,
pageSize: 20,
total: 0,
pageSizes: [10, 20, 50, 100]
})
// 微信和Telegram选择器相关数据
const showWechatSelector = ref(false)
const showTelegramSelector = ref(false)
// 公告类型接口
interface Announcement {
content: string
enabled: boolean
}
// 配置表单数据类型
interface SiteConfigForm {
@@ -317,10 +274,39 @@ interface SiteConfigForm {
maintenance_mode: boolean
enable_register: boolean
forbidden_words: string
enable_sitemap: boolean
sitemap_update_frequency: string
enable_announcements: boolean
announcements: Announcement[]
enable_float_buttons: boolean
wechat_search_image: string
telegram_qr_image: string
}
// 公告配置子组件数据
const announcementConfig = computed({
get: () => ({
enable_announcements: configForm.value.enable_announcements,
announcements: configForm.value.announcements
}),
set: (value: any) => {
configForm.value.enable_announcements = value.enable_announcements
configForm.value.announcements = value.announcements
}
})
// 浮动按钮配置子组件数据
const floatButtonsConfig = computed({
get: () => ({
enable_float_buttons: configForm.value.enable_float_buttons,
wechat_search_image: configForm.value.wechat_search_image,
telegram_qr_image: configForm.value.telegram_qr_image
}),
set: (value: any) => {
configForm.value.enable_float_buttons = value.enable_float_buttons
configForm.value.wechat_search_image = value.wechat_search_image
configForm.value.telegram_qr_image = value.telegram_qr_image
}
})
// 使用配置改动检测
const {
setOriginalConfig,
@@ -342,11 +328,16 @@ const {
maintenance_mode: 'maintenance_mode',
enable_register: 'enable_register',
forbidden_words: 'forbidden_words',
enable_sitemap: 'enable_sitemap',
sitemap_update_frequency: 'sitemap_update_frequency'
enable_announcements: 'enable_announcements',
announcements: 'announcements',
enable_float_buttons: 'enable_float_buttons',
wechat_search_image: 'wechat_search_image',
telegram_qr_image: 'telegram_qr_image'
}
})
// 公告类型选项(如果需要的话可以保留,但根据反馈暂时移除)
// 配置表单数据
const configForm = ref<SiteConfigForm>({
site_title: '',
@@ -357,8 +348,11 @@ const configForm = ref<SiteConfigForm>({
maintenance_mode: false,
enable_register: false,
forbidden_words: '',
enable_sitemap: false,
sitemap_update_frequency: 'daily'
enable_announcements: false,
announcements: [],
enable_float_buttons: false,
wechat_search_image: '',
telegram_qr_image: ''
})
@@ -394,8 +388,11 @@ const fetchConfig = async () => {
maintenance_mode: response.maintenance_mode || false,
enable_register: response.enable_register || false,
forbidden_words: response.forbidden_words || '',
enable_sitemap: response.enable_sitemap || false,
sitemap_update_frequency: response.sitemap_update_frequency || 'daily'
enable_announcements: response.enable_announcements || false,
announcements: response.announcements ? JSON.parse(response.announcements) : [],
enable_float_buttons: response.enable_float_buttons || false,
wechat_search_image: response.wechat_search_image || '',
telegram_qr_image: response.telegram_qr_image || ''
}
// 设置表单数据和原始数据
@@ -421,18 +418,7 @@ const saveConfig = async () => {
saving.value = true
// 更新当前配置数据
updateCurrentConfig({
site_title: configForm.value.site_title,
site_description: configForm.value.site_description,
keywords: configForm.value.keywords,
copyright: configForm.value.copyright,
site_logo: configForm.value.site_logo,
maintenance_mode: configForm.value.maintenance_mode,
enable_register: configForm.value.enable_register,
forbidden_words: configForm.value.forbidden_words,
enable_sitemap: configForm.value.enable_sitemap,
sitemap_update_frequency: configForm.value.sitemap_update_frequency
})
updateCurrentConfig(configForm.value)
const { useSystemConfigApi } = await import('~/composables/useApi')
const systemConfigApi = useSystemConfigApi()
@@ -481,99 +467,40 @@ const saveConfig = async () => {
// Logo选择器方法
const openLogoSelector = () => {
showLogoSelector.value = true
loadFileList()
}
const clearLogo = () => {
configForm.value.site_logo = ''
}
const loadFileList = async () => {
try {
loading.value = true
const { useFileApi } = await import('~/composables/useFileApi')
const fileApi = useFileApi()
const response = await fileApi.getFileList({
page: pagination.value.page,
pageSize: pagination.value.pageSize,
search: searchKeyword.value,
fileType: 'image', // 只获取图片文件
status: 'active' // 只获取正常状态的文件
}) as any
if (response && response.data) {
fileList.value = response.data.files || []
pagination.value.total = response.data.total || 0
console.log('获取到的图片文件:', fileList.value) // 调试信息
// 添加图片URL处理调试
fileList.value.forEach(file => {
console.log('图片文件详情:', {
id: file.id,
name: file.original_name,
accessUrl: file.access_url,
processedUrl: getImageUrl(file.access_url),
fileType: file.file_type,
mimeType: file.mime_type
})
})
}
} catch (error) {
console.error('获取文件列表失败:', error)
notification.error({
content: '获取文件列表失败',
duration: 3000
})
} finally {
loading.value = false
}
// 子组件更新处理方法
const handleAnnouncementUpdate = (newValue: any) => {
configForm.value.enable_announcements = newValue.enable_announcements
configForm.value.announcements = newValue.announcements
}
const handleSearch = () => {
pagination.value.page = 1
loadFileList()
const handleFloatButtonsUpdate = (newValue: any) => {
configForm.value.enable_float_buttons = newValue.enable_float_buttons
configForm.value.wechat_search_image = newValue.wechat_search_image
configForm.value.telegram_qr_image = newValue.telegram_qr_image
}
const handlePageChange = (page: number) => {
pagination.value.page = page
loadFileList()
}
const handlePageSizeChange = (pageSize: number) => {
pagination.value.pageSize = pageSize
pagination.value.page = 1
loadFileList()
}
const selectFile = (file: any) => {
selectedFileId.value = file.id
}
const confirmSelection = () => {
if (selectedFileId.value) {
const file = fileList.value.find(f => f.id === selectedFileId.value)
if (file) {
// Logo选择处理
const handleLogoSelect = (file: any) => {
configForm.value.site_logo = file.access_url
showLogoSelector.value = false
selectedFileId.value = null
}
}
}
const formatFileSize = (size: number) => {
if (size < 1024) return size + ' B'
if (size < 1024 * 1024) return (size / 1024).toFixed(1) + ' KB'
if (size < 1024 * 1024 * 1024) return (size / (1024 * 1024)).toFixed(1) + ' MB'
return (size / (1024 * 1024 * 1024)).toFixed(1) + ' GB'
// 微信图片选择处理
const handleWechatImageSelect = (file: any) => {
configForm.value.wechat_search_image = file.access_url
showWechatSelector.value = false
}
const handleImageError = (event: any) => {
console.error('图片加载失败:', event)
}
const handleImageLoad = (event: any) => {
console.log('图片加载成功:', event)
// Telegram图片选择处理
const handleTelegramImageSelect = (file: any) => {
configForm.value.telegram_qr_image = file.access_url
showTelegramSelector.value = false
}
// 页面加载时获取配置
@@ -610,35 +537,4 @@ onMounted(() => {
padding-bottom: 1rem;
}
.file-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
max-height: 400px;
overflow-y: auto;
}
.file-item {
border: 1px solid #e5e7eb;
transition: all 0.2s ease;
}
.file-item:hover {
transform: translateY(-2px);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
.preview-image {
width: 100%;
height: 120px;
object-fit: cover;
border: 1px solid #e5e7eb;
border-radius: 4px;
}
.pagination-wrapper {
display: flex;
justify-content: center;
margin-top: 1rem;
}
</style>

View File

@@ -73,6 +73,11 @@
</nav>
</div>
<!-- 公告信息 -->
<div class="w-full max-w-3xl mx-auto mb-2 px-2 sm:px-0">
<Announcement />
</div>
<!-- 搜索区域 -->
<div class="w-full max-w-3xl mx-auto mb-4 sm:mb-8 px-2 sm:px-0">
<ClientOnly>