数据结构优化

This commit is contained in:
李琦
2026-01-19 20:21:09 +08:00
parent 68c4c6df1c
commit 7e918cedd4
39 changed files with 3703 additions and 824 deletions

View File

@@ -234,13 +234,22 @@ const menuItems = ref<MenuItem[]>([
{ title: '角色管理', path: '/admin/roles', icon: '🔒' }
]
},
{
title: '附件管理',
icon: '📎',
isOpen: false,
children: [
{ title: '附件库', path: '/admin/attachments', icon: '📦' },
{ title: '附件分类管理', path: '/admin/attachment-categories', icon: '📂' },
{ title: 'OSS配置', path: '/admin/oss-configs', icon: '☁️' }
]
},
{
title: '系统设置',
icon: '⚙️',
isOpen: false,
children: [
{ title: '全局配置', path: '/admin/settings', icon: '🛠️' },
{ title: '附件管理', path: '/admin/attachments', icon: '📎' },
{ title: '操作日志', path: '/admin/logs', icon: '📋' }
]
}

View File

@@ -0,0 +1,204 @@
<template>
<div v-if="show" class="fixed inset-0 bg-black/50 flex items-center justify-center z-50" @click.self="handleClose">
<div class="admin-card max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-serif italic text-white">附件详情</h2>
<button @click="handleClose" class="text-white/40 hover:text-white transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div v-if="attachment" class="space-y-6">
<!-- 图片预览 -->
<div v-if="attachment.fileType === 'image'" class="w-full rounded-lg overflow-hidden bg-white/5">
<img :src="attachment.fileUrl" :alt="attachment.originalName" class="w-full h-auto max-h-96 object-contain" />
</div>
<!-- 文件信息 -->
<div class="space-y-4">
<div>
<label class="block text-xs font-medium text-art-muted uppercase tracking-wider mb-2">文件名</label>
<p class="text-white">{{ attachment.originalName }}</p>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-xs font-medium text-art-muted uppercase tracking-wider mb-2">文件大小</label>
<p class="text-white">{{ formatFileSize(attachment.fileSize) }}</p>
</div>
<div>
<label class="block text-xs font-medium text-art-muted uppercase tracking-wider mb-2">文件类型</label>
<p class="text-white">{{ getFileTypeLabel(attachment.fileType) }}</p>
</div>
</div>
<div v-if="attachment.mimeType">
<label class="block text-xs font-medium text-art-muted uppercase tracking-wider mb-2">MIME类型</label>
<p class="text-white font-mono text-sm">{{ attachment.mimeType }}</p>
</div>
<div>
<label class="block text-xs font-medium text-art-muted uppercase tracking-wider mb-2">存储类型</label>
<p class="text-white">{{ getStorageTypeLabel(attachment.storageType) }}</p>
</div>
<div>
<label class="block text-xs font-medium text-art-muted uppercase tracking-wider mb-2">分类</label>
<CustomSelect
v-model="localCategoryId"
:options="categoryOptions"
placeholder="无分类"
/>
</div>
<div>
<label class="block text-xs font-medium text-art-muted uppercase tracking-wider mb-2">文件URL</label>
<div class="flex gap-2">
<input
type="text"
:value="attachment.fileUrl"
readonly
class="admin-input flex-1 font-mono text-sm"
/>
<button
@click="handleCopyUrl"
class="admin-btn-secondary px-4 whitespace-nowrap"
>
复制
</button>
</div>
</div>
<div v-if="attachment.createdAt" class="text-xs text-art-muted">
创建时间: {{ formatDate(attachment.createdAt) }}
</div>
</div>
<!-- 操作按钮 -->
<div class="flex gap-3 pt-4 border-t border-white/10">
<button @click="handleClose" class="admin-btn-secondary flex-1">
取消
</button>
<button @click="handleSave" :disabled="saving" class="admin-btn-primary flex-1">
{{ saving ? '保存中...' : '保存' }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import CustomSelect from '../CustomSelect.vue'
interface Attachment {
id: number
originalName: string
fileUrl: string
fileSize: number
fileType: string
categoryId?: number
mimeType?: string
storageType?: string
createdAt?: string
}
interface Category {
id: number
name: string
}
interface Props {
show: boolean
attachment: Attachment | null
categories: Category[]
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:show': [value: boolean]
'save': [data: { categoryId: number | null }]
'copy-url': [url: string]
}>()
const localCategoryId = ref<number>(0)
const saving = ref(false)
// 计算属性:分类选项
const categoryOptions = computed(() => {
return [
{ value: 0, label: '无分类' },
...props.categories.map(cat => ({ value: cat.id, label: cat.name }))
]
})
// 监听attachment变化更新本地分类ID
watch(() => props.attachment, (newAttachment) => {
if (newAttachment) {
localCategoryId.value = newAttachment.categoryId || 0
}
}, { immediate: true })
const handleClose = () => {
emit('update:show', false)
}
const handleSave = () => {
saving.value = true
const categoryId = localCategoryId.value === 0 ? null : localCategoryId.value
emit('save', { categoryId })
// saving状态由父组件控制
}
const handleCopyUrl = () => {
if (props.attachment) {
emit('copy-url', props.attachment.fileUrl)
}
}
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
const getFileTypeLabel = (fileType: string): string => {
const labels: Record<string, string> = {
image: '图片',
video: '视频',
document: '文档',
other: '其他'
}
return labels[fileType] || fileType
}
const getStorageTypeLabel = (storageType?: string): string => {
const labels: Record<string, string> = {
local: '本地存储',
qcloud: '腾讯云COS',
aliyun: '阿里云OSS',
qiniu: '七牛云'
}
return labels[storageType || 'local'] || storageType || '本地存储'
}
const formatDate = (timestamp: string | number): string => {
const date = new Date(typeof timestamp === 'string' ? parseInt(timestamp) * 1000 : timestamp * 1000)
return date.toLocaleString('zh-CN')
}
// 暴露saving状态给父组件
defineExpose({
saving
})
</script>
<style scoped>
/* 使用全局admin样式无需额外样式 */
</style>

View File

@@ -0,0 +1,386 @@
<template>
<div class="image-upload">
<!-- 单个上传模式 -->
<template v-if="!multiple">
<div
v-if="!imageUrl"
@click="triggerFileInput"
@dragover.prevent="handleDragOver"
@dragenter.prevent="handleDragEnter"
@dragleave.prevent="handleDragLeave"
@drop.prevent="handleDrop"
class="upload-area border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors"
:class="[
isDragging ? 'border-art-accent bg-art-accent/5' : 'border-white/20 hover:border-art-accent/50',
disabled ? 'opacity-50 cursor-not-allowed' : ''
]"
>
<input
ref="fileInput"
type="file"
:accept="accept"
:multiple="false"
@change="handleFileSelect"
class="hidden"
:disabled="disabled"
/>
<div class="space-y-3">
<div class="w-12 h-12 mx-auto flex items-center justify-center text-art-muted">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="17 8 12 3 7 8"></polyline>
<line x1="12" y1="3" x2="12" y2="15"></line>
</svg>
</div>
<p class="text-sm text-art-muted">点击或拖拽图片到此处上传</p>
<p class="text-xs text-art-muted/50">支持 JPGPNGGIF 格式</p>
</div>
</div>
<div v-else class="image-preview relative group">
<img :src="imageUrl" alt="Preview" class="w-full h-auto rounded-lg max-h-64 object-contain bg-white/5" />
<div class="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity rounded-lg flex items-center justify-center gap-2">
<button
@click.stop="removeImage"
class="px-4 py-2 bg-red-500/80 text-white rounded hover:bg-red-500 transition-colors text-sm"
:disabled="disabled"
>
删除
</button>
<button
@click.stop="triggerFileInput"
class="px-4 py-2 bg-art-accent text-black rounded hover:opacity-90 transition-colors text-sm"
:disabled="disabled"
>
更换
</button>
</div>
</div>
<div v-if="uploading" class="mt-4 text-center">
<div class="inline-flex items-center gap-2 text-art-accent">
<div class="animate-spin rounded-full h-4 w-4 border-t-2 border-b-2 border-art-accent"></div>
<span class="text-sm">上传中...</span>
</div>
</div>
<div v-if="error" class="mt-4 text-center text-red-400 text-sm">
{{ error }}
</div>
</template>
<!-- 批量上传模式 -->
<template v-else>
<div class="space-y-4">
<!-- 图片列表 -->
<div v-if="imageUrls.length > 0" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
<div
v-for="(url, index) in imageUrls"
:key="index"
class="image-item relative group aspect-square rounded-lg overflow-hidden bg-white/5 border border-white/10"
>
<img :src="url" :alt="`Image ${index + 1}`" class="w-full h-full object-cover" />
<div class="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
<button
@click="removeImage(index)"
class="px-3 py-1.5 bg-red-500/80 text-white rounded hover:bg-red-500 transition-colors text-xs"
:disabled="disabled"
>
删除
</button>
</div>
<div class="absolute top-2 left-2 bg-black/50 text-white text-xs px-2 py-1 rounded">
{{ index + 1 }}
</div>
</div>
</div>
<!-- 上传区域 -->
<div
v-if="imageUrls.length < (maxCount || Infinity)"
@click="triggerFileInput"
@dragover.prevent="handleDragOver"
@dragenter.prevent="handleDragEnter"
@dragleave.prevent="handleDragLeave"
@drop.prevent="handleDrop"
class="upload-area border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors"
:class="[
isDragging ? 'border-art-accent bg-art-accent/5' : 'border-white/20 hover:border-art-accent/50',
disabled ? 'opacity-50 cursor-not-allowed' : ''
]"
>
<input
ref="fileInput"
type="file"
:accept="accept"
:multiple="true"
@change="handleFileSelect"
class="hidden"
:disabled="disabled"
/>
<div class="space-y-2">
<div class="w-10 h-10 mx-auto flex items-center justify-center text-art-muted">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="17 8 12 3 7 8"></polyline>
<line x1="12" y1="3" x2="12" y2="15"></line>
</svg>
</div>
<p class="text-sm text-art-muted">点击或拖拽图片到此处上传</p>
<p class="text-xs text-art-muted/50">
{{ maxCount ? `最多上传 ${maxCount} 张,已上传 ${imageUrls.length}` : `已上传 ${imageUrls.length}` }}
</p>
</div>
</div>
<!-- 上传进度 -->
<div v-if="uploadingCount > 0" class="mt-4 text-center">
<div class="inline-flex items-center gap-2 text-art-accent">
<div class="animate-spin rounded-full h-4 w-4 border-t-2 border-b-2 border-art-accent"></div>
<span class="text-sm">正在上传 {{ uploadingCount }} 张图片...</span>
</div>
</div>
<!-- 错误提示 -->
<div v-if="error" class="mt-4 text-center text-red-400 text-sm">
{{ error }}
</div>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { useToast } from '../../composables/useToast'
import { API_BASE } from '../../services/api'
const props = withDefaults(defineProps<{
modelValue?: string | string[]
multiple?: boolean
maxCount?: number
categoryId?: number
accept?: string
disabled?: boolean
}>(), {
multiple: false,
accept: 'image/*',
disabled: false
})
const emit = defineEmits<{
'update:modelValue': [value: string | string[]]
}>()
const fileInput = ref<HTMLInputElement | null>(null)
const uploading = ref(false)
const uploadingCount = ref(0)
const error = ref('')
const isDragging = ref(false)
const toast = useToast()
// 单个模式的值
const imageUrl = computed({
get: () => {
if (props.multiple) return ''
return (props.modelValue as string) || ''
},
set: (val: string) => {
if (!props.multiple) {
emit('update:modelValue', val)
}
}
})
// 批量模式的值
const imageUrls = computed({
get: () => {
if (!props.multiple) return []
const value = props.modelValue
if (Array.isArray(value)) {
return value
}
return value ? [value] : []
},
set: (val: string[]) => {
if (props.multiple) {
emit('update:modelValue', val)
}
}
})
const triggerFileInput = () => {
if (props.disabled) return
fileInput.value?.click()
}
const handleDragOver = (e: DragEvent) => {
if (props.disabled) return
e.preventDefault()
isDragging.value = true
}
const handleDragEnter = (e: DragEvent) => {
if (props.disabled) return
e.preventDefault()
isDragging.value = true
}
const handleDragLeave = (e: DragEvent) => {
if (props.disabled) return
e.preventDefault()
isDragging.value = false
}
const handleDrop = (e: DragEvent) => {
if (props.disabled) return
e.preventDefault()
isDragging.value = false
if (e.dataTransfer?.files) {
const files = Array.from(e.dataTransfer.files).filter(file => file.type.startsWith('image/'))
if (files.length > 0) {
if (props.multiple) {
handleMultipleFiles(files)
} else {
uploadFile(files[0])
}
}
}
}
const handleFileSelect = (e: Event) => {
const target = e.target as HTMLInputElement
if (target.files && target.files.length > 0) {
const files = Array.from(target.files).filter(file => file.type.startsWith('image/'))
if (files.length > 0) {
if (props.multiple) {
handleMultipleFiles(files)
} else {
uploadFile(files[0])
}
}
}
// 清空input允许重复选择同一文件
if (target) {
target.value = ''
}
}
const handleMultipleFiles = async (files: File[]) => {
if (props.maxCount && imageUrls.value.length + files.length > props.maxCount) {
error.value = `最多只能上传 ${props.maxCount} 张图片`
toast.showToast(error.value, 'error')
return
}
error.value = ''
uploadingCount.value = files.length
const uploadPromises = files.map(file => uploadSingleFile(file))
try {
const results = await Promise.all(uploadPromises)
const successfulUrls = results.filter(url => url !== null) as string[]
if (successfulUrls.length > 0) {
const newUrls = [...imageUrls.value, ...successfulUrls]
imageUrls.value = newUrls
toast.showToast(`成功上传 ${successfulUrls.length} 张图片`, 'success')
}
} catch (err) {
console.error('Batch upload error:', err)
} finally {
uploadingCount.value = 0
}
}
const uploadFile = async (file: File) => {
if (!file.type.startsWith('image/')) {
error.value = '请选择图片文件'
toast.showToast(error.value, 'error')
return
}
uploading.value = true
error.value = ''
try {
const url = await uploadSingleFile(file)
if (url) {
imageUrl.value = url
toast.showToast('图片上传成功', 'success')
}
} catch (err: any) {
error.value = err.message || '上传失败,请重试'
toast.showToast(error.value, 'error')
} finally {
uploading.value = false
}
}
const uploadSingleFile = async (file: File): Promise<string | null> => {
try {
const formData = new FormData()
formData.append('file', file)
if (props.categoryId) {
formData.append('categoryId', props.categoryId.toString())
}
formData.append('storageType', 'local')
const token = localStorage.getItem('token')
const response = await fetch(`${API_BASE}/admin/attachments/upload`, {
method: 'POST',
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {})
},
body: formData
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.message || '上传失败')
}
const data = await response.json()
return data.result.fileUrl
} catch (err: any) {
console.error('Upload error:', err)
throw err
}
}
const removeImage = (index?: number) => {
if (props.disabled) return
if (props.multiple && typeof index === 'number') {
const newUrls = [...imageUrls.value]
newUrls.splice(index, 1)
imageUrls.value = newUrls
} else {
imageUrl.value = ''
if (fileInput.value) {
fileInput.value.value = ''
}
}
}
</script>
<style scoped>
.upload-area {
min-height: 120px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.image-item {
transition: transform 0.2s;
}
.image-item:hover {
transform: scale(1.02);
}
</style>

View File

@@ -21,10 +21,11 @@
+ 新建分类
</button>
</div>
<select v-model="selectedCategoryId" class="admin-input">
<option :value="0">请选择分类</option>
<option v-for="cat in categories" :key="cat.id" :value="cat.id">{{ cat.name }}</option>
</select>
<CustomSelect
v-model="selectedCategoryId"
:options="categoryOptions"
placeholder="请选择分类"
/>
<!-- Quick Create Category -->
<div v-if="showCreateCategory" class="admin-card p-3 space-y-2">
@@ -49,10 +50,11 @@
+ 新建专栏
</button>
</div>
<select v-model="selectedColumnId" class="admin-input">
<option :value="0">不选择专栏</option>
<option v-for="col in columns" :key="col.id" :value="col.id">{{ col.name }}</option>
</select>
<CustomSelect
v-model="selectedColumnId"
:options="columnOptions"
placeholder="不选择专栏"
/>
<!-- Quick Create Column -->
<div v-if="showCreateColumn" class="admin-card p-3 space-y-2">
@@ -132,7 +134,7 @@
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import { ref, watch, onMounted, computed } from 'vue'
import {
fetchCategories,
fetchColumns,
@@ -147,6 +149,7 @@ import {
Post
} from '../../services/api'
import { useToast } from '../../composables/useToast'
import CustomSelect from '../CustomSelect.vue'
const props = defineProps<{
isOpen: boolean
@@ -178,6 +181,22 @@ const newTag = ref({ name: '', slug: '' })
const saving = ref(false)
// 计算属性:分类选项
const categoryOptions = computed(() => {
return [
{ value: 0, label: '请选择分类' },
...categories.value.map(cat => ({ value: cat.id, label: cat.name }))
]
})
// 计算属性:专栏选项
const columnOptions = computed(() => {
return [
{ value: 0, label: '不选择专栏' },
...columns.value.map(col => ({ value: col.id, label: col.name }))
]
})
const close = () => {
emit('update:isOpen', false)
showCreateCategory.value = false

View File

@@ -20,12 +20,10 @@
</div>
<div class="form-group">
<label for="avatar">头像 URL</label>
<input
type="text"
id="avatar"
<label for="avatar">头像</label>
<ImageUpload
v-model="form.avatar"
placeholder="请输入头像链接"
:category-id="1"
/>
</div>
</div>
@@ -146,6 +144,7 @@ import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useToast } from '../../composables/useToast'
import { createAboutProfile, updateAboutProfile, getAdminAboutProfiles, AboutProfile, Experience } from '../../services/api'
import ImageUpload from '../../components/admin/ImageUpload.vue'
const router = useRouter()
const route = useRoute()

View File

@@ -0,0 +1,98 @@
<template>
<div class="w-full animate-reveal">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-serif italic text-white">附件分类管理</h1>
<button @click="router.push('/admin/attachment-categories/create')" class="admin-btn-primary flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
新建分类
</button>
</div>
<div class="admin-card overflow-hidden">
<div class="overflow-x-auto">
<table class="admin-table">
<thead>
<tr>
<th class="w-20">ID</th>
<th>名称</th>
<th>描述</th>
<th>排序</th>
<th>创建时间</th>
<th class="text-right">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="category in categories" :key="category.id" class="group transition-colors duration-200">
<td class="font-mono text-xs text-white/40">#{{ category.id }}</td>
<td class="font-medium text-white group-hover:text-art-accent transition-colors">{{ category.name }}</td>
<td class="text-sm text-white/70 max-w-xs truncate">{{ category.description || '-' }}</td>
<td class="text-xs text-art-muted">{{ category.sortOrder }}</td>
<td class="text-art-muted text-xs">{{ formatDate(category.createdAt) }}</td>
<td class="text-right">
<div class="flex items-center justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
<router-link :to="`/admin/attachment-categories/${category.id}/edit`" class="admin-btn-secondary py-1 px-3 text-xs">
编辑
</router-link>
<button @click="handleDelete(category.id)" class="admin-btn-danger py-1 px-3 text-xs">
删除
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="categories.length === 0" class="p-16 text-center">
<div class="w-16 h-16 bg-white/5 rounded-full flex items-center justify-center mx-auto mb-4 text-2xl">📂</div>
<h3 class="text-white font-medium mb-2">暂无分类</h3>
<p class="text-art-muted text-sm mb-6">创建分类来整理您的附件</p>
<router-link to="/admin/attachment-categories/create" class="admin-btn-secondary inline-flex items-center gap-2">
+ 新建分类
</router-link>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { getAttachmentCategories, deleteAttachmentCategory, AttachmentCategory } from '../../services/api'
import { useToast } from '../../composables/useToast'
const router = useRouter()
const toast = useToast()
const categories = ref<AttachmentCategory[]>([])
const fetchCategories = async () => {
try {
categories.value = await getAttachmentCategories()
} catch (error) {
console.error('Error fetching attachment categories:', error)
toast.showToast('获取分类列表失败', 'error')
}
}
const handleDelete = async (id: number) => {
if (confirm('确定要删除这个分类吗?')) {
try {
await deleteAttachmentCategory(id)
toast.showToast('分类删除成功', 'success')
fetchCategories()
} catch (error) {
console.error('Error deleting attachment category:', error)
toast.showToast('删除分类失败', 'error')
}
}
}
const formatDate = (timestamp: string | number): string => {
const date = new Date(typeof timestamp === 'string' ? parseInt(timestamp) * 1000 : timestamp * 1000)
return date.toLocaleString('zh-CN')
}
onMounted(() => {
fetchCategories()
})
</script>

View File

@@ -0,0 +1,130 @@
<template>
<div class="w-full animate-reveal h-[calc(100vh-8rem)] flex flex-col">
<!-- Top Bar -->
<div class="flex items-center justify-between mb-4 shrink-0">
<div class="flex items-center gap-4">
<h1 class="text-2xl font-serif italic text-white">{{ isEditing ? '编辑附件分类' : '新建附件分类' }}</h1>
</div>
<div class="flex gap-3">
<button type="button" class="admin-btn-secondary" @click="handleCancel">
取消
</button>
<button type="button" class="admin-btn-primary" @click="handleSubmit" :disabled="isSubmitting">
{{ isSubmitting ? '提交中...' : (isEditing ? '保存修改' : '创建分类') }}
</button>
</div>
</div>
<div class="admin-card p-6 max-w-2xl mx-auto w-full">
<form @submit.prevent="handleSubmit" class="space-y-6">
<!-- Name Field -->
<div class="space-y-2">
<label for="name" class="block text-xs font-medium text-art-muted uppercase tracking-wider">名称</label>
<input
type="text"
id="name"
v-model="form.name"
class="admin-input"
placeholder="请输入分类名称"
required
/>
</div>
<!-- Sort Order Field -->
<div class="space-y-2">
<label for="sortOrder" class="block text-xs font-medium text-art-muted uppercase tracking-wider">排序 (越小越靠前)</label>
<input
type="number"
id="sortOrder"
v-model="form.sortOrder"
class="admin-input"
placeholder="0"
/>
</div>
<!-- Description Field -->
<div class="space-y-2">
<label for="description" class="block text-xs font-medium text-art-muted uppercase tracking-wider">描述</label>
<textarea
id="description"
v-model="form.description"
rows="4"
class="admin-input resize-none"
placeholder="简短的分类描述..."
></textarea>
</div>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useToast } from '../../composables/useToast'
import { createAttachmentCategory, updateAttachmentCategory, getAttachmentCategories, AttachmentCategory } from '../../services/api'
const router = useRouter()
const route = useRoute()
const toast = useToast()
const isSubmitting = ref(false)
const isEditing = computed(() => !!route.params.id)
const form = reactive({
name: '',
description: '',
sortOrder: 0
})
const handleSubmit = async () => {
if (!form.name.trim()) {
toast.showToast('名称不能为空', 'error')
return
}
isSubmitting.value = true
try {
if (isEditing.value) {
const id = parseInt(route.params.id as string)
await updateAttachmentCategory(id, form)
toast.showToast('分类更新成功', 'success')
} else {
await createAttachmentCategory(form)
toast.showToast('分类创建成功', 'success')
}
router.push('/admin/attachment-categories')
} catch (error: any) {
console.error('Error submitting form:', error)
toast.showToast(error.message || (isEditing.value ? '更新分类失败' : '创建分类失败'), 'error')
} finally {
isSubmitting.value = false
}
}
const handleCancel = () => {
router.back()
}
onMounted(async () => {
if (isEditing.value) {
try {
const id = parseInt(route.params.id as string)
const categories = await getAttachmentCategories()
const category = categories.find(c => c.id === id)
if (category) {
form.name = category.name
form.description = category.description
form.sortOrder = category.sortOrder
} else {
toast.showToast('未找到该分类', 'error')
router.push('/admin/attachment-categories')
}
} catch (error) {
console.error('Failed to load category:', error)
toast.showToast('加载数据失败', 'error')
}
}
})
</script>

View File

@@ -47,6 +47,7 @@
<div
v-for="attachment in attachments"
:key="attachment.id"
@click="openDetailModal(attachment)"
class="admin-card p-4 group cursor-pointer hover:border-art-accent transition-colors"
>
<div v-if="attachment.fileType === 'image'" class="aspect-square mb-2 rounded overflow-hidden bg-white/5">
@@ -80,6 +81,16 @@
</div>
</div>
<!-- Detail Modal -->
<AttachmentDetailModal
:show="showDetailModal"
:attachment="selectedAttachment"
:categories="categories"
@update:show="showDetailModal = $event"
@save="handleSaveCategory"
@copy-url="copyUrl"
/>
<!-- Upload Modal -->
<div v-if="showUploadModal" class="fixed inset-0 bg-black/50 flex items-center justify-center z-50" @click.self="showUploadModal = false">
<div class="admin-card max-w-md w-full mx-4">
@@ -123,16 +134,16 @@
import { ref, onMounted, computed } from 'vue'
import { useToast } from '../../composables/useToast'
import CustomSelect from '../../components/CustomSelect.vue'
import AttachmentDetailModal from '../../components/admin/AttachmentDetailModal.vue'
import { updateAttachment, type Attachment } from '../../services/api'
const toast = useToast()
interface Attachment {
id: number
originalName: string
fileUrl: string
fileSize: number
fileType: string
interface LocalAttachment extends Attachment {
categoryId?: number
mimeType?: string
storageType?: string
createdAt?: string
}
interface Category {
@@ -140,10 +151,12 @@ interface Category {
name: string
}
const attachments = ref<Attachment[]>([])
const attachments = ref<LocalAttachment[]>([])
const categories = ref<Category[]>([])
const loading = ref(false)
const showUploadModal = ref(false)
const showDetailModal = ref(false)
const selectedAttachment = ref<LocalAttachment | null>(null)
const uploadInput = ref<HTMLInputElement | null>(null)
const uploadCategoryId = ref(0)
const uploading = ref(false)
@@ -177,6 +190,7 @@ const uploadCategoryOptions = computed(() => {
]
})
const API_BASE = 'http://localhost:8081/api'
const getAuthHeaders = () => {
const token = localStorage.getItem('token')
@@ -297,6 +311,35 @@ const formatFileSize = (bytes: number): string => {
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
const openDetailModal = (attachment: LocalAttachment) => {
selectedAttachment.value = attachment
showDetailModal.value = true
}
const handleSaveCategory = async (data: { categoryId: number | null }) => {
if (!selectedAttachment.value) return
try {
await updateAttachment(selectedAttachment.value.id, data)
toast.showToast('分类更新成功', 'success')
// 更新本地数据
if (selectedAttachment.value) {
selectedAttachment.value.categoryId = data.categoryId || undefined
}
// 刷新列表
loadAttachments()
// 关闭模态框
showDetailModal.value = false
selectedAttachment.value = null
} catch (err: any) {
toast.showToast(err.message || '更新失败', 'error')
}
}
onMounted(() => {
loadCategories()
loadAttachments()

View File

@@ -32,13 +32,10 @@
<!-- Cover Field -->
<div class="space-y-2">
<label for="cover" class="block text-xs font-medium text-art-muted uppercase tracking-wider">封面图片 URL</label>
<input
type="text"
id="cover"
v-model="form.cover"
class="admin-input"
placeholder="https://..."
<label for="cover" class="block text-xs font-medium text-art-muted uppercase tracking-wider">封面图片</label>
<ImageUpload
v-model="form.cover"
:category-id="1"
/>
</div>
@@ -87,13 +84,14 @@
<!-- Add Post -->
<div class="flex gap-2 mb-6">
<select v-model="selectedPostId" class="admin-input flex-1">
<option value="">选择文章添加到专栏...</option>
<option v-for="post in allPosts" :key="post.id" :value="post.id">
{{ post.title }} ({{ post.isPublished ? '已发布' : '草稿' }})
</option>
</select>
<button type="button" @click="handleAddPost" class="admin-btn-secondary whitespace-nowrap" :disabled="!selectedPostId">
<div class="flex-1">
<CustomSelect
v-model="selectedPostId"
:options="postOptions"
placeholder="选择文章添加到专栏..."
/>
</div>
<button type="button" @click="handleAddPost" class="admin-btn-secondary whitespace-nowrap" :disabled="!selectedPostId || selectedPostId === 0">
添加文章
</button>
</div>
@@ -123,7 +121,9 @@
import { ref, reactive, onMounted, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useToast } from '../../composables/useToast'
import { createColumn, updateColumn, fetchColumn } from '../../services/api'
import { createColumn, updateColumn, fetchColumn, fetchPosts, fetchColumnPosts, addPostToColumn, removePostFromColumn, Post } from '../../services/api'
import CustomSelect from '../../components/CustomSelect.vue'
import ImageUpload from '../../components/admin/ImageUpload.vue'
const router = useRouter()
const route = useRoute()
@@ -140,6 +140,21 @@ const form = reactive({
sortOrder: 0
})
const allPosts = ref<Post[]>([])
const columnPosts = ref<Post[]>([])
const selectedPostId = ref<number | string>(0)
// 计算属性:文章选项
const postOptions = computed(() => {
return [
{ value: 0, label: '选择文章添加到专栏...' },
...allPosts.value.map(post => ({
value: post.id,
label: `${post.title} (${post.isPublished ? '已发布' : '草稿'})`
}))
]
})
const handleSubmit = async () => {
if (!form.name.trim()) {
toast.showToast('名称不能为空', 'error')
@@ -170,6 +185,57 @@ const handleCancel = () => {
router.back()
}
const loadPosts = async () => {
try {
const posts = await fetchPosts()
allPosts.value = posts
} catch (error) {
console.error('Failed to load posts:', error)
}
}
const loadColumnPosts = async () => {
if (!isEditing.value) return
try {
const id = parseInt(route.params.id as string)
const posts = await fetchColumnPosts(id)
columnPosts.value = posts
} catch (error) {
console.error('Failed to load column posts:', error)
}
}
const handleAddPost = async () => {
if (!selectedPostId.value || selectedPostId.value === 0) return
if (!isEditing.value) return
try {
const id = parseInt(route.params.id as string)
const postId = typeof selectedPostId.value === 'string' ? parseInt(selectedPostId.value) : selectedPostId.value
await addPostToColumn(id, postId)
toast.showToast('文章添加成功', 'success')
selectedPostId.value = 0
await loadColumnPosts()
await loadPosts() // 重新加载以更新选项
} catch (error: any) {
toast.showToast(error.message || '添加文章失败', 'error')
}
}
const handleRemovePost = async (postId: number) => {
if (!isEditing.value) return
try {
const id = parseInt(route.params.id as string)
await removePostFromColumn(id, postId)
toast.showToast('文章移除成功', 'success')
await loadColumnPosts()
await loadPosts() // 重新加载以更新选项
} catch (error: any) {
toast.showToast(error.message || '移除文章失败', 'error')
}
}
onMounted(async () => {
if (isEditing.value) {
try {
@@ -182,10 +248,12 @@ onMounted(async () => {
form.isActive = column.isActive
form.sortOrder = column.sortOrder
}
await loadColumnPosts()
} catch (error) {
console.error('Failed to load column:', error)
toast.showToast('加载数据失败', 'error')
}
}
await loadPosts()
})
</script>

View File

@@ -0,0 +1,555 @@
<template>
<div class="w-full animate-reveal">
<div class="mb-6">
<h1 class="text-2xl font-serif italic text-white">OSS配置管理</h1>
</div>
<!-- Tab导航 -->
<div class="mb-6 border-b border-white/10">
<div class="flex gap-4">
<button
v-for="tab in tabs"
:key="tab.value"
@click="switchTab(tab.value)"
:class="[
'px-4 py-2 text-sm font-medium transition-colors border-b-2',
activeTab === tab.value
? 'text-art-accent border-art-accent'
: 'text-art-muted border-transparent hover:text-white'
]"
>
{{ tab.label }}
</button>
</div>
</div>
<!-- 表单 -->
<div class="admin-card p-6">
<form @submit.prevent="saveOSSConfig" class="space-y-6">
<div class="space-y-2">
<label for="ossName" class="block text-xs font-medium text-art-muted uppercase tracking-wider">配置名称</label>
<input
type="text"
id="ossName"
v-model="ossForm.name"
required
class="admin-input"
:placeholder="`${getStorageTypeLabel(activeTab)}配置名称`"
>
</div>
<div class="space-y-2">
<label for="storageType" class="block text-xs font-medium text-art-muted uppercase tracking-wider">存储类型</label>
<input
type="text"
id="storageType"
:value="getStorageTypeLabel(activeTab)"
disabled
class="admin-input bg-white/5 text-art-muted cursor-not-allowed"
>
</div>
<!-- 阿里云OSS字段 -->
<template v-if="activeTab === 'aliyun'">
<div class="space-y-2">
<label for="ossAccessKeyId" class="block text-xs font-medium text-art-muted uppercase tracking-wider">
OSS Access Key ID <span class="text-red-400">*</span>
</label>
<input
type="text"
id="ossAccessKeyId"
v-model="ossForm.ossAccessKeyId"
required
class="admin-input font-mono"
placeholder="OSS Access Key ID"
>
</div>
<div class="space-y-2">
<label for="ossAccessKeySecret" class="block text-xs font-medium text-art-muted uppercase tracking-wider">
OSS Access Key Secret <span class="text-red-400">*</span>
</label>
<input
type="password"
id="ossAccessKeySecret"
v-model="ossForm.ossAccessKeySecret"
required
class="admin-input font-mono"
placeholder="OSS Access Key Secret"
>
</div>
<div class="space-y-2">
<label for="ossEndpoint" class="block text-xs font-medium text-art-muted uppercase tracking-wider">
OSS Endpoint <span class="text-red-400">*</span>
</label>
<input
type="text"
id="ossEndpoint"
v-model="ossForm.ossEndpoint"
required
class="admin-input"
placeholder="例如: https://oss-cn-beijing.aliyuncs.com"
>
</div>
<div class="space-y-2">
<label for="ossBucket" class="block text-xs font-medium text-art-muted uppercase tracking-wider">
OSS Bucket <span class="text-red-400">*</span>
</label>
<input
type="text"
id="ossBucket"
v-model="ossForm.ossBucket"
required
class="admin-input"
placeholder="存储桶名称"
>
</div>
<div class="space-y-2">
<label for="ossDomain" class="block text-xs font-medium text-art-muted uppercase tracking-wider">OSS Domain</label>
<input
type="text"
id="ossDomain"
v-model="ossForm.ossDomain"
class="admin-input"
placeholder="例如: https://cdn.example.com"
>
</div>
</template>
<!-- 腾讯云COS字段 -->
<template v-if="activeTab === 'qcloud'">
<div class="space-y-2">
<label for="qcloudSecretId" class="block text-xs font-medium text-art-muted uppercase tracking-wider">
QCloud Secret ID <span class="text-red-400">*</span>
</label>
<input
type="text"
id="qcloudSecretId"
v-model="ossForm.qcloudSecretId"
required
class="admin-input font-mono"
placeholder="QCloud Secret ID"
>
</div>
<div class="space-y-2">
<label for="qcloudSecretKey" class="block text-xs font-medium text-art-muted uppercase tracking-wider">
QCloud Secret Key <span class="text-red-400">*</span>
</label>
<input
type="password"
id="qcloudSecretKey"
v-model="ossForm.qcloudSecretKey"
required
class="admin-input font-mono"
placeholder="QCloud Secret Key"
>
</div>
<div class="space-y-2">
<label for="qcloudRegion" class="block text-xs font-medium text-art-muted uppercase tracking-wider">
QCloud Region <span class="text-red-400">*</span>
</label>
<input
type="text"
id="qcloudRegion"
v-model="ossForm.qcloudRegion"
required
class="admin-input"
placeholder="例如: ap-beijing"
>
</div>
<div class="space-y-2">
<label for="qcloudBucket" class="block text-xs font-medium text-art-muted uppercase tracking-wider">
QCloud Bucket <span class="text-red-400">*</span>
</label>
<input
type="text"
id="qcloudBucket"
v-model="ossForm.qcloudBucket"
required
class="admin-input"
placeholder="存储桶名称"
>
</div>
<div class="space-y-2">
<label for="qcloudDomain" class="block text-xs font-medium text-art-muted uppercase tracking-wider">QCloud Domain</label>
<input
type="text"
id="qcloudDomain"
v-model="ossForm.qcloudDomain"
class="admin-input"
placeholder="例如: https://cdn.example.com"
>
</div>
</template>
<!-- 七牛云字段 -->
<template v-if="activeTab === 'qiniu'">
<div class="space-y-2">
<label for="qiniuAccessKey" class="block text-xs font-medium text-art-muted uppercase tracking-wider">
Qiniu Access Key <span class="text-red-400">*</span>
</label>
<input
type="text"
id="qiniuAccessKey"
v-model="ossForm.qiniuAccessKey"
required
class="admin-input font-mono"
placeholder="Qiniu Access Key"
>
</div>
<div class="space-y-2">
<label for="qiniuSecretKey" class="block text-xs font-medium text-art-muted uppercase tracking-wider">
Qiniu Secret Key <span class="text-red-400">*</span>
</label>
<input
type="password"
id="qiniuSecretKey"
v-model="ossForm.qiniuSecretKey"
required
class="admin-input font-mono"
placeholder="Qiniu Secret Key"
>
</div>
<div class="space-y-2">
<label for="qiniuBucket" class="block text-xs font-medium text-art-muted uppercase tracking-wider">
Qiniu Bucket <span class="text-red-400">*</span>
</label>
<input
type="text"
id="qiniuBucket"
v-model="ossForm.qiniuBucket"
required
class="admin-input"
placeholder="存储桶名称"
>
</div>
<div class="space-y-2">
<label for="qiniuRegion" class="block text-xs font-medium text-art-muted uppercase tracking-wider">Qiniu Region</label>
<input
type="text"
id="qiniuRegion"
v-model="ossForm.qiniuRegion"
class="admin-input"
placeholder="例如: z0 (华东), z1 (华北), z2 (华南)"
>
</div>
<div class="space-y-2">
<label for="qiniuDomain" class="block text-xs font-medium text-art-muted uppercase tracking-wider">Qiniu Domain</label>
<input
type="text"
id="qiniuDomain"
v-model="ossForm.qiniuDomain"
class="admin-input"
placeholder="例如: https://cdn.example.com"
>
</div>
</template>
<!-- 本地存储不需要额外字段 -->
<div v-if="activeTab === 'local'" class="p-4 bg-white/5 rounded text-sm text-art-muted">
本地存储不需要配置额外的密钥信息
</div>
<div class="space-y-2">
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
v-model="ossForm.isActive"
:true-value="1"
:false-value="0"
class="w-4 h-4 rounded border-white/20 bg-white/5 text-art-accent focus:ring-art-accent"
>
<span class="text-sm text-art-muted">启用此配置</span>
</label>
</div>
<div class="pt-4 flex justify-between items-center">
<button
v-if="currentConfig && !isDefaultConfig(currentConfig)"
type="button"
@click="deleteOSSConfig"
class="admin-btn-danger"
>
删除配置
</button>
<div v-else></div>
<div class="flex gap-3">
<button type="submit" class="admin-btn-primary" :disabled="isSubmitting">
{{ isSubmitting ? '保存中...' : (currentConfig ? '保存' : '创建') }}
</button>
</div>
</div>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed, watch } from 'vue'
import { getOSSConfigs, createOSSConfig, updateOSSConfig, deleteOSSConfig as deleteOSSConfigApi, OSSConfig } from '../../services/api'
import { useToast } from '../../composables/useToast'
const toast = useToast()
// Tab管理
const activeTab = ref<string>('local')
const tabs = [
{ value: 'local', label: '本地存储' },
{ value: 'aliyun', label: '阿里云' },
{ value: 'qcloud', label: '腾讯云' },
{ value: 'qiniu', label: '七牛云' }
]
// OSS配置管理
const ossConfigs = ref<OSSConfig[]>([])
const isSubmitting = ref(false)
const ossForm = ref({
id: 0,
name: '',
storageType: '',
// 阿里云OSS专用字段
ossAccessKeyId: '',
ossAccessKeySecret: '',
ossEndpoint: '',
ossBucket: '',
ossDomain: '',
// 腾讯云COS专用字段
qcloudSecretId: '',
qcloudSecretKey: '',
qcloudRegion: '',
qcloudBucket: '',
qcloudDomain: '',
// 七牛云专用字段
qiniuAccessKey: '',
qiniuSecretKey: '',
qiniuBucket: '',
qiniuRegion: '',
qiniuDomain: '',
isActive: 0 as number
})
// 存储类型选项
const storageTypeOptions = [
{ value: 'local', label: '本地存储' },
{ value: 'qcloud', label: '腾讯云COS' },
{ value: 'aliyun', label: '阿里云OSS' },
{ value: 'qiniu', label: '七牛云' }
]
// 获取存储类型标签
const getStorageTypeLabel = (type: string): string => {
const option = storageTypeOptions.find(opt => opt.value === type)
return option ? option.label : type
}
// 获取当前tab对应的配置
const currentConfig = computed(() => {
// 优先查找已启用的配置
const activeConfig = ossConfigs.value.find(c => c.storageType === activeTab.value && c.isActive === 1)
if (activeConfig) return activeConfig
// 如果没有已启用的,查找任意配置
return ossConfigs.value.find(c => c.storageType === activeTab.value)
})
// 判断是否为默认配置
const isDefaultConfig = (config: OSSConfig | undefined): boolean => {
if (!config) return false
const defaultNames = ['本地存储', '阿里云OSS', '腾讯云COS', '七牛云']
return defaultNames.includes(config.name)
}
// 切换Tab时加载对应配置
const switchTab = (tabValue: string) => {
activeTab.value = tabValue
loadConfigForTab()
}
// 加载当前tab的配置
const loadConfigForTab = () => {
const config = currentConfig.value
if (config) {
// 填充表单
ossForm.value = {
id: config.id,
name: config.name,
storageType: config.storageType,
ossAccessKeyId: (config as any).ossAccessKeyId === '***' ? '' : (config as any).ossAccessKeyId || '',
ossAccessKeySecret: (config as any).ossAccessKeySecret === '***' ? '' : (config as any).ossAccessKeySecret || '',
ossEndpoint: (config as any).ossEndpoint || '',
ossBucket: (config as any).ossBucket || '',
ossDomain: (config as any).ossDomain || '',
qcloudSecretId: (config as any).qcloudSecretId === '***' ? '' : (config as any).qcloudSecretId || '',
qcloudSecretKey: (config as any).qcloudSecretKey === '***' ? '' : (config as any).qcloudSecretKey || '',
qcloudRegion: (config as any).qcloudRegion || '',
qcloudBucket: (config as any).qcloudBucket || '',
qcloudDomain: (config as any).qcloudDomain || '',
qiniuAccessKey: (config as any).qiniuAccessKey === '***' ? '' : (config as any).qiniuAccessKey || '',
qiniuSecretKey: (config as any).qiniuSecretKey === '***' ? '' : (config as any).qiniuSecretKey || '',
qiniuBucket: (config as any).qiniuBucket || '',
qiniuRegion: (config as any).qiniuRegion || '',
qiniuDomain: (config as any).qiniuDomain || '',
isActive: config.isActive
}
} else {
// 清空表单,设置默认值
const defaultNames: Record<string, string> = {
local: '本地存储',
aliyun: '阿里云OSS',
qcloud: '腾讯云COS',
qiniu: '七牛云'
}
ossForm.value = {
id: 0,
name: defaultNames[activeTab.value] || '',
storageType: activeTab.value,
ossAccessKeyId: '',
ossAccessKeySecret: '',
ossEndpoint: '',
ossBucket: '',
ossDomain: '',
qcloudSecretId: '',
qcloudSecretKey: '',
qcloudRegion: '',
qcloudBucket: '',
qcloudDomain: '',
qiniuAccessKey: '',
qiniuSecretKey: '',
qiniuBucket: '',
qiniuRegion: '',
qiniuDomain: '',
isActive: activeTab.value === 'local' ? 1 : 0 // 本地存储默认启用
}
}
}
// OSS配置相关函数
const fetchOSSConfigs = async () => {
try {
ossConfigs.value = await getOSSConfigs()
// 设置默认tab优先选择已启用的配置
const activeConfig = ossConfigs.value.find(c => c.isActive === 1)
if (activeConfig) {
activeTab.value = activeConfig.storageType
} else {
activeTab.value = 'local' // 默认本地存储
}
// 加载当前tab的配置
loadConfigForTab()
} catch (error) {
console.error('Error fetching OSS configs:', error)
toast.showToast('获取OSS配置失败', 'error')
}
}
const saveOSSConfig = async () => {
// 设置存储类型
ossForm.value.storageType = activeTab.value
// 验证必填字段
if (activeTab.value === 'aliyun') {
if (!ossForm.value.ossAccessKeyId || !ossForm.value.ossAccessKeySecret || !ossForm.value.ossEndpoint || !ossForm.value.ossBucket) {
toast.showToast('请填写所有必填字段', 'error')
return
}
} else if (activeTab.value === 'qcloud') {
if (!ossForm.value.qcloudSecretId || !ossForm.value.qcloudSecretKey || !ossForm.value.qcloudRegion || !ossForm.value.qcloudBucket) {
toast.showToast('请填写所有必填字段', 'error')
return
}
} else if (activeTab.value === 'qiniu') {
if (!ossForm.value.qiniuAccessKey || !ossForm.value.qiniuSecretKey || !ossForm.value.qiniuBucket) {
toast.showToast('请填写所有必填字段', 'error')
return
}
}
isSubmitting.value = true
try {
const updateData: any = {
name: ossForm.value.name,
storageType: ossForm.value.storageType,
isActive: ossForm.value.isActive
}
// 根据存储类型设置对应字段
if (activeTab.value === 'aliyun') {
updateData.ossAccessKeyId = ossForm.value.ossAccessKeyId
updateData.ossAccessKeySecret = ossForm.value.ossAccessKeySecret
updateData.ossEndpoint = ossForm.value.ossEndpoint
updateData.ossBucket = ossForm.value.ossBucket
updateData.ossDomain = ossForm.value.ossDomain || ''
} else if (activeTab.value === 'qcloud') {
updateData.qcloudSecretId = ossForm.value.qcloudSecretId
updateData.qcloudSecretKey = ossForm.value.qcloudSecretKey
updateData.qcloudRegion = ossForm.value.qcloudRegion
updateData.qcloudBucket = ossForm.value.qcloudBucket
updateData.qcloudDomain = ossForm.value.qcloudDomain || ''
} else if (activeTab.value === 'qiniu') {
updateData.qiniuAccessKey = ossForm.value.qiniuAccessKey
updateData.qiniuSecretKey = ossForm.value.qiniuSecretKey
updateData.qiniuBucket = ossForm.value.qiniuBucket
updateData.qiniuRegion = ossForm.value.qiniuRegion || ''
updateData.qiniuDomain = ossForm.value.qiniuDomain || ''
}
if (ossForm.value.id > 0) {
// 更新
await updateOSSConfig(ossForm.value.id, updateData)
toast.showToast('OSS配置更新成功', 'success')
} else {
// 创建
await createOSSConfig({
...updateData,
createdAt: '',
updatedAt: ''
})
toast.showToast('OSS配置创建成功', 'success')
}
await fetchOSSConfigs()
} catch (error: any) {
console.error('Error saving OSS config:', error)
toast.showToast(error.message || (ossForm.value.id > 0 ? '更新OSS配置失败' : '创建OSS配置失败'), 'error')
} finally {
isSubmitting.value = false
}
}
const deleteOSSConfig = async () => {
if (!currentConfig.value) return
if (isDefaultConfig(currentConfig.value)) {
toast.showToast('默认配置不能删除', 'error')
return
}
if (confirm(`确定要删除这个OSS配置吗`)) {
try {
await deleteOSSConfigApi(currentConfig.value.id)
toast.showToast('OSS配置删除成功', 'success')
await fetchOSSConfigs()
} catch (error: any) {
console.error('Error deleting OSS config:', error)
toast.showToast(error.message || '删除OSS配置失败', 'error')
}
}
}
onMounted(() => {
fetchOSSConfigs()
})
</script>

View File

@@ -10,8 +10,11 @@
</div>
<div class="form-group">
<label class="form-label">Logo URL</label>
<input type="text" v-model="formData.logo" class="form-input" required placeholder="https://...">
<label class="form-label">Logo</label>
<ImageUpload
v-model="formData.logo"
:category-id="1"
/>
</div>
<div class="form-group">
@@ -38,6 +41,7 @@ import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useToast } from '../../composables/useToast'
import { createPartner, updatePartner, fetchPartners } from '../../services/api'
import ImageUpload from '../../components/admin/ImageUpload.vue'
const router = useRouter()
const route = useRoute()

View File

@@ -29,7 +29,10 @@
<form @submit.prevent="handleSubmit" class="flex-1 flex overflow-hidden gap-6 relative">
<!-- Left Column: Main Editor -->
<div class="flex-1 flex flex-col min-w-0 transition-all duration-300 ease-in-out h-full">
<div class="admin-card flex-1 flex flex-col overflow-hidden border border-white/10 focus-within:border-art-accent/50 transition-colors">
<div
class="admin-card flex-1 flex flex-col overflow-hidden border border-white/10 focus-within:border-art-accent/50 transition-colors"
@paste="handlePaste"
>
<MdEditor
v-model="form.content"
theme="dark"
@@ -308,6 +311,71 @@ const handleCancel = () => {
router.push('/admin/posts')
}
// Handle paste event for image upload
const handlePaste = async (e: ClipboardEvent) => {
const items = e.clipboardData?.items
if (!items) return
// Check if any item is an image
let hasImage = false
for (let i = 0; i < items.length; i++) {
if (items[i].type.startsWith('image/')) {
hasImage = true
break
}
}
if (!hasImage) return
// Prevent default paste behavior for images
e.preventDefault()
for (let i = 0; i < items.length; i++) {
const item = items[i]
if (item.type.startsWith('image/')) {
const file = item.getAsFile()
if (!file) continue
try {
// Upload image
const formData = new FormData()
formData.append('file', file)
formData.append('categoryId', '1') // 默认上传到分类1
formData.append('storageType', 'local')
const token = localStorage.getItem('token')
const response = await fetch('http://localhost:8081/api/admin/attachments/upload', {
method: 'POST',
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {})
},
body: formData
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.message || '上传失败')
}
const data = await response.json()
const imageUrl = data.result.fileUrl
// Insert markdown image syntax
const imageMarkdown = `![${file.name}](${imageUrl})`
const currentContent = form.content
// Insert at the end of content (md-editor-v3 will handle cursor position)
form.content = currentContent + (currentContent ? '\n\n' : '') + imageMarkdown + '\n'
toast.showToast('图片上传成功', 'success')
} catch (error: any) {
console.error('上传图片失败:', error)
toast.showToast(error.message || '上传图片失败', 'error')
}
break // Only handle first image
}
}
}
// Load initial data
const loadData = async () => {
try {

View File

@@ -4,36 +4,8 @@
<h1 class="text-2xl font-serif italic text-white">系统配置管理</h1>
</div>
<!-- Tab切换 -->
<div class="mb-6 border-b border-white/10">
<div class="flex gap-4">
<button
@click="activeTab = 'settings'"
:class="[
'px-4 py-2 text-sm font-medium transition-colors border-b-2',
activeTab === 'settings'
? 'text-art-accent border-art-accent'
: 'text-art-muted border-transparent hover:text-white'
]"
>
基础配置
</button>
<button
@click="activeTab = 'oss'"
:class="[
'px-4 py-2 text-sm font-medium transition-colors border-b-2',
activeTab === 'oss'
? 'text-art-accent border-art-accent'
: 'text-art-muted border-transparent hover:text-white'
]"
>
OSS配置
</button>
</div>
</div>
<!-- 基础配置Tab -->
<div v-if="activeTab === 'settings'">
<!-- 基础配置 -->
<div>
<div class="admin-card overflow-hidden">
<div class="overflow-x-auto">
@@ -130,241 +102,19 @@
</div>
</div>
</div>
</div>
<!-- OSS配置Tab -->
<div v-if="activeTab === 'oss'">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-medium text-white">OSS存储配置</h2>
<button @click="openOSSModal" class="admin-btn-primary">
+ 新增OSS配置
</button>
</div>
<div class="admin-card overflow-hidden">
<div class="overflow-x-auto">
<table class="admin-table">
<thead>
<tr>
<th class="w-1/6">名称</th>
<th class="w-1/6">存储类型</th>
<th class="w-1/6">Bucket</th>
<th class="w-1/6">区域</th>
<th class="w-1/6">域名</th>
<th class="w-1/12">状态</th>
<th class="text-right w-32">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="config in ossConfigs" :key="config.id" class="group transition-colors duration-200">
<td class="font-medium text-white">{{ config.name }}</td>
<td class="text-art-muted">
<span class="px-2 py-1 text-xs rounded bg-white/5">
{{ getStorageTypeLabel(config.storageType) }}
</span>
</td>
<td class="text-art-muted text-sm">{{ config.bucket || '-' }}</td>
<td class="text-art-muted text-sm">{{ config.region || '-' }}</td>
<td class="text-art-muted text-sm max-w-xs truncate" :title="config.domain">{{ config.domain || '-' }}</td>
<td>
<span
:class="[
'px-2 py-1 text-xs rounded',
config.isActive === 1
? 'bg-green-500/20 text-green-400'
: 'bg-white/5 text-art-muted'
]"
>
{{ config.isActive === 1 ? '已启用' : '已停用' }}
</span>
</td>
<td class="text-right">
<div class="flex items-center justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
<button @click="editOSSConfig(config)" class="admin-btn-secondary py-1 px-3 text-xs">
编辑
</button>
<button @click="deleteOSSConfig(config.id)" class="admin-btn-danger py-1 px-3 text-xs">
删除
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="ossConfigs.length === 0" class="p-16 text-center">
<div class="w-16 h-16 bg-white/5 rounded-full flex items-center justify-center mx-auto mb-4 text-2xl"></div>
<h3 class="text-white font-medium mb-2">暂无OSS配置</h3>
<p class="text-art-muted text-sm mb-4">点击上方按钮添加OSS配置</p>
</div>
</div>
<!-- OSS配置编辑Modal -->
<div v-if="showOSSModal" class="fixed inset-0 z-50 flex items-center justify-center p-4">
<div class="absolute inset-0 bg-black/80 backdrop-blur-sm transition-opacity" @click="closeOSSModal"></div>
<div class="admin-card w-full max-w-2xl relative z-10 flex flex-col max-h-[90vh] shadow-2xl animate-reveal">
<div class="flex items-center justify-between p-6 border-b border-white/5">
<h2 class="text-lg font-medium text-white">{{ editingOSSConfig ? '编辑OSS配置' : '新增OSS配置' }}</h2>
<button @click="closeOSSModal" class="text-white/40 hover:text-white transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
</div>
<div class="p-6 overflow-y-auto">
<form @submit.prevent="saveOSSConfig" class="space-y-4">
<div class="space-y-2">
<label for="ossName" class="block text-xs font-medium text-art-muted uppercase tracking-wider">配置名称</label>
<input
type="text"
id="ossName"
v-model="ossForm.name"
required
class="admin-input"
placeholder="例如: 腾讯云CDN"
>
</div>
<div class="space-y-2">
<label for="storageType" class="block text-xs font-medium text-art-muted uppercase tracking-wider">存储类型</label>
<CustomSelect
v-model="ossForm.storageType"
:options="storageTypeOptions"
placeholder="选择存储类型"
/>
</div>
<div class="space-y-2">
<label for="accessKey" class="block text-xs font-medium text-art-muted uppercase tracking-wider">Access Key</label>
<input
type="text"
id="accessKey"
v-model="ossForm.accessKey"
required
class="admin-input font-mono"
placeholder="访问密钥ID"
>
</div>
<div class="space-y-2">
<label for="secretKey" class="block text-xs font-medium text-art-muted uppercase tracking-wider">Secret Key</label>
<input
type="password"
id="secretKey"
v-model="ossForm.secretKey"
required
class="admin-input font-mono"
placeholder="访问密钥Secret"
>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<label for="bucket" class="block text-xs font-medium text-art-muted uppercase tracking-wider">Bucket</label>
<input
type="text"
id="bucket"
v-model="ossForm.bucket"
class="admin-input"
placeholder="存储桶名称"
>
</div>
<div class="space-y-2">
<label for="region" class="block text-xs font-medium text-art-muted uppercase tracking-wider">区域</label>
<input
type="text"
id="region"
v-model="ossForm.region"
class="admin-input"
placeholder="例如: ap-beijing"
>
</div>
</div>
<div class="space-y-2">
<label for="domain" class="block text-xs font-medium text-art-muted uppercase tracking-wider">访问域名</label>
<input
type="text"
id="domain"
v-model="ossForm.domain"
class="admin-input"
placeholder="例如: https://cdn.example.com"
>
</div>
<div class="space-y-2">
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
v-model="ossForm.isActive"
:true-value="1"
:false-value="0"
class="w-4 h-4 rounded border-white/20 bg-white/5 text-art-accent focus:ring-art-accent"
>
<span class="text-sm text-art-muted">启用此配置</span>
</label>
</div>
<div class="pt-4 flex justify-end gap-3">
<button type="button" @click="closeOSSModal" class="admin-btn-secondary">取消</button>
<button type="submit" class="admin-btn-primary">保存</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { ref, onMounted } from 'vue'
import { getSettings, createSetting, updateSetting, deleteSetting as deleteSettingApi, Setting } from '../../services/api'
import { getOSSConfigs, createOSSConfig, updateOSSConfig, deleteOSSConfig as deleteOSSConfigApi, OSSConfig } from '../../services/api'
import { useToast } from '../../composables/useToast'
import CustomSelect from '../../components/CustomSelect.vue'
const toast = useToast()
const settings = ref<Setting[]>([])
const showModal = ref(false)
const editingSetting = ref(false)
// Tab管理
const activeTab = ref<'settings' | 'oss'>('settings')
// OSS配置管理
const ossConfigs = ref<OSSConfig[]>([])
const showOSSModal = ref(false)
const editingOSSConfig = ref(false)
const ossForm = ref({
id: 0,
name: '',
storageType: '',
accessKey: '',
secretKey: '',
bucket: '',
region: '',
domain: '',
isActive: 0 as number
})
// 存储类型选项
const storageTypeOptions = [
{ value: 'local', label: '本地存储' },
{ value: 'qcloud', label: '腾讯云COS' },
{ value: 'aliyun', label: '阿里云OSS' },
{ value: 'qiniu', label: '七牛云' }
]
// 获取存储类型标签
const getStorageTypeLabel = (type: string): string => {
const option = storageTypeOptions.find(opt => opt.value === type)
return option ? option.label : type
}
const form = ref({
id: 0,
keyName: '',
@@ -437,126 +187,8 @@ const closeModal = () => {
}
}
// OSS配置相关函数
const fetchOSSConfigs = async () => {
try {
ossConfigs.value = await getOSSConfigs()
} catch (error) {
console.error('Error fetching OSS configs:', error)
toast.showToast('获取OSS配置失败', 'error')
}
}
const openOSSModal = () => {
editingOSSConfig.value = false
ossForm.value = {
id: 0,
name: '',
storageType: '',
accessKey: '',
secretKey: '',
bucket: '',
region: '',
domain: '',
isActive: 0
}
showOSSModal.value = true
}
const editOSSConfig = (config: OSSConfig) => {
editingOSSConfig.value = true
ossForm.value = {
id: config.id,
name: config.name,
storageType: config.storageType,
accessKey: config.accessKey === '***' ? '' : config.accessKey, // 如果是加密值,清空让用户重新输入
secretKey: config.secretKey === '***' ? '' : config.secretKey,
bucket: config.bucket,
region: config.region,
domain: config.domain,
isActive: config.isActive
}
showOSSModal.value = true
}
const saveOSSConfig = async () => {
try {
if (editingOSSConfig.value) {
// 更新时,如果密钥是空的,不发送密钥字段
const updateData: any = {
name: ossForm.value.name,
storageType: ossForm.value.storageType,
bucket: ossForm.value.bucket,
region: ossForm.value.region,
domain: ossForm.value.domain,
isActive: ossForm.value.isActive
}
// 只有用户输入了新密钥时才更新
if (ossForm.value.accessKey && ossForm.value.accessKey !== '***') {
updateData.accessKey = ossForm.value.accessKey
}
if (ossForm.value.secretKey && ossForm.value.secretKey !== '***') {
updateData.secretKey = ossForm.value.secretKey
}
await updateOSSConfig(ossForm.value.id, updateData)
toast.showToast('OSS配置更新成功', 'success')
} else {
await createOSSConfig({
name: ossForm.value.name,
storageType: ossForm.value.storageType,
accessKey: ossForm.value.accessKey,
secretKey: ossForm.value.secretKey,
bucket: ossForm.value.bucket,
region: ossForm.value.region,
domain: ossForm.value.domain,
isActive: ossForm.value.isActive,
createdAt: '',
updatedAt: ''
})
toast.showToast('OSS配置创建成功', 'success')
}
closeOSSModal()
fetchOSSConfigs()
} catch (error) {
console.error('Error saving OSS config:', error)
toast.showToast(editingOSSConfig.value ? '更新OSS配置失败' : '创建OSS配置失败', 'error')
}
}
const deleteOSSConfig = async (id: number) => {
if (confirm(`确定要删除这个OSS配置吗`)) {
try {
await deleteOSSConfigApi(id)
toast.showToast('OSS配置删除成功', 'success')
fetchOSSConfigs()
} catch (error) {
console.error('Error deleting OSS config:', error)
toast.showToast('删除OSS配置失败', 'error')
}
}
}
const closeOSSModal = () => {
showOSSModal.value = false
editingOSSConfig.value = false
ossForm.value = {
id: 0,
name: '',
storageType: '',
accessKey: '',
secretKey: '',
bucket: '',
region: '',
domain: '',
isActive: 0
}
}
onMounted(() => {
fetchSettings()
fetchOSSConfigs()
})
</script>

View File

@@ -15,8 +15,11 @@
</div>
<div class="form-group">
<label class="form-label">头像 URL</label>
<input type="text" v-model="formData.avatar" class="form-input" placeholder="https://...">
<label class="form-label">头像</label>
<ImageUpload
v-model="formData.avatar"
:category-id="1"
/>
</div>
<div class="form-group">
@@ -43,6 +46,7 @@ import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useToast } from '../../composables/useToast'
import { createTestimonial, updateTestimonial, fetchTestimonials } from '../../services/api'
import ImageUpload from '../../components/admin/ImageUpload.vue'
const router = useRouter()
const route = useRoute()

View File

@@ -13,6 +13,7 @@
v-model="form.title"
placeholder="请输入作品标题"
required
class="admin-input"
/>
<div class="error-message" v-if="errors.title">
{{ errors.title }}
@@ -28,6 +29,7 @@
v-model="form.category"
placeholder="请输入作品分类"
required
class="admin-input"
/>
<div class="error-message" v-if="errors.category">
{{ errors.category }}
@@ -43,6 +45,7 @@
v-model="form.year"
placeholder="请输入创作年份"
required
class="admin-input"
/>
<div class="error-message" v-if="errors.year">
{{ errors.year }}
@@ -51,13 +54,10 @@
<!-- Hero Image Field -->
<div class="form-group">
<label for="heroImg">作品主图 URL</label>
<input
type="url"
id="heroImg"
<label>作品主图</label>
<ImageUpload
v-model="form.heroImg"
placeholder="请输入作品主图 URL"
required
:category-id="1"
/>
<div class="error-message" v-if="errors.heroImg">
{{ errors.heroImg }}
@@ -73,52 +73,150 @@
placeholder="请输入作品描述"
rows="5"
required
class="admin-input resize-none"
></textarea>
<div class="error-message" v-if="errors.desc">
{{ errors.desc }}
</div>
</div>
<!-- Tech Stack Field (Simplified for now) -->
<!-- Tech Stack Field -->
<div class="form-group">
<label for="techStack">技术栈JSON格式</label>
<textarea
id="techStack"
v-model="techStackJson"
placeholder='请输入技术栈 JSON例如[{"category": "前端", "items": ["Vue 3", "TypeScript"]}]'
rows="3"
required
></textarea>
<div class="flex justify-between items-center mb-2">
<label>技术栈</label>
<button
type="button"
@click="addTechStackCategory"
class="admin-btn-secondary text-xs py-1 px-3"
>
+ 添加分类
</button>
</div>
<div class="space-y-4">
<div
v-for="(item, index) in form.techStack"
:key="index"
class="tech-stack-item p-4 border border-white/10 rounded-lg bg-white/5 relative"
>
<button
type="button"
@click="removeTechStackCategory(index)"
class="absolute top-2 right-2 text-red-400 hover:text-red-300 text-lg"
>
&times;
</button>
<div class="space-y-3">
<div>
<label class="block text-xs text-art-muted mb-1">分类名称</label>
<input
type="text"
v-model="item.category"
placeholder="例如:前端"
class="admin-input text-sm"
/>
</div>
<div>
<div class="flex justify-between items-center mb-1">
<label class="block text-xs text-art-muted">技术标签</label>
<button
type="button"
@click="addTechItem(index)"
class="text-xs text-art-accent hover:text-art-accent/80"
>
+ 添加标签
</button>
</div>
<div class="space-y-2">
<div
v-for="(tech, techIndex) in item.items"
:key="techIndex"
class="flex gap-2"
>
<input
type="text"
v-model="item.items[techIndex]"
placeholder="例如Vue 3"
class="admin-input text-sm flex-1"
/>
<button
type="button"
@click="removeTechItem(index, techIndex)"
class="px-3 py-2 text-red-400 hover:text-red-300 text-sm"
>
删除
</button>
</div>
<div v-if="item.items.length === 0" class="text-center text-art-muted text-sm py-2">
暂无技术标签请点击"添加标签"
</div>
</div>
</div>
</div>
</div>
<div v-if="form.techStack.length === 0" class="text-center text-art-muted py-4 border border-dashed border-white/10 rounded-lg">
暂无技术栈分类请点击"添加分类"
</div>
</div>
<div class="error-message" v-if="errors.techStack">
{{ errors.techStack }}
</div>
</div>
<!-- Gallery Field (Simplified for now) -->
<!-- Gallery Field -->
<div class="form-group">
<label for="gallery">作品图库JSON格式</label>
<textarea
id="gallery"
v-model="galleryJson"
placeholder='请输入图库 JSON例如["image1.jpg", "image2.jpg"]'
rows="3"
required
></textarea>
<label>作品图库</label>
<ImageUpload
v-model="form.gallery"
:multiple="true"
:max-count="20"
:category-id="1"
/>
<div class="error-message" v-if="errors.gallery">
{{ errors.gallery }}
</div>
</div>
<!-- Links Field (Simplified for now) -->
<!-- Links Field -->
<div class="form-group">
<label for="links">链接JSON格式</label>
<textarea
id="links"
v-model="linksJson"
placeholder='请输入链接 JSON例如{"live": "https://example.com"}'
rows="3"
required
></textarea>
<label>链接</label>
<div class="space-y-3">
<div>
<label class="block text-xs text-art-muted mb-1">在线演示链接</label>
<input
type="url"
v-model="form.links.live"
placeholder="https://example.com"
class="admin-input"
/>
</div>
<div>
<label class="block text-xs text-art-muted mb-1">GitHub 链接</label>
<input
type="url"
v-model="form.links.github"
placeholder="https://github.com/..."
class="admin-input"
/>
</div>
<div>
<label class="block text-xs text-art-muted mb-1">演示链接</label>
<input
type="url"
v-model="form.links.demo"
placeholder="https://demo.example.com"
class="admin-input"
/>
</div>
</div>
<div class="error-message" v-if="errors.links">
{{ errors.links }}
</div>
@@ -126,10 +224,10 @@
<!-- Submit Buttons -->
<div class="form-actions">
<button type="button" class="cancel-btn" @click="handleCancel">
<button type="button" class="admin-btn-secondary" @click="handleCancel">
取消
</button>
<button type="submit" class="submit-btn" :disabled="isSubmitting">
<button type="submit" class="admin-btn-primary" :disabled="isSubmitting">
{{ isSubmitting ? '提交中...' : (isEditing ? '更新作品' : '创建作品') }}
</button>
</div>
@@ -139,10 +237,11 @@
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed, watch } from 'vue'
import { ref, reactive, onMounted, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useToast } from '../../composables/useToast'
import { createWork, updateWork, fetchWork } from '../../services/api'
import ImageUpload from '../../components/admin/ImageUpload.vue'
const router = useRouter()
const route = useRoute()
@@ -162,41 +261,32 @@ const form = reactive({
desc: '',
techStack: [] as { category: string; items: string[] }[],
gallery: [] as string[],
links: { live: '' }
links: {
live: '',
github: '',
demo: ''
} as { live?: string; github?: string; demo?: string }
})
// JSON string representations for easy editing
const techStackJson = ref('[]')
const galleryJson = ref('[]')
const linksJson = ref('{"live": ""}')
// Tech Stack management
const addTechStackCategory = () => {
form.techStack.push({
category: '',
items: []
})
}
// Watch JSON strings and update form data
watch(techStackJson, (newVal) => {
try {
form.techStack = JSON.parse(newVal)
delete errors.techStack
} catch (e) {
// Validation will catch this
}
})
const removeTechStackCategory = (index: number) => {
form.techStack.splice(index, 1)
}
watch(galleryJson, (newVal) => {
try {
form.gallery = JSON.parse(newVal)
delete errors.gallery
} catch (e) {
// Validation will catch this
}
})
const addTechItem = (categoryIndex: number) => {
form.techStack[categoryIndex].items.push('')
}
watch(linksJson, (newVal) => {
try {
form.links = JSON.parse(newVal)
delete errors.links
} catch (e) {
// Validation will catch this
}
})
const removeTechItem = (categoryIndex: number, itemIndex: number) => {
form.techStack[categoryIndex].items.splice(itemIndex, 1)
}
// Validation function
const validateForm = (): boolean => {
@@ -225,7 +315,7 @@ const validateForm = (): boolean => {
// Validate hero image
if (!form.heroImg.trim()) {
errors.heroImg = '作品主图 URL 不能为空'
errors.heroImg = '作品主图不能为空'
isValid = false
}
@@ -235,28 +325,32 @@ const validateForm = (): boolean => {
isValid = false
}
// Validate tech stack JSON
try {
JSON.parse(techStackJson.value)
} catch (e) {
errors.techStack = '技术栈 JSON 格式无效'
isValid = false
}
// Validate gallery JSON
try {
JSON.parse(galleryJson.value)
} catch (e) {
errors.gallery = '图库 JSON 格式无效'
isValid = false
}
// Validate links JSON
try {
JSON.parse(linksJson.value)
} catch (e) {
errors.links = '链接 JSON 格式无效'
// Validate tech stack
if (form.techStack.length === 0) {
errors.techStack = '请至少添加一个技术栈分类'
isValid = false
} else {
for (let i = 0; i < form.techStack.length; i++) {
const item = form.techStack[i]
if (!item.category.trim()) {
errors.techStack = `${i + 1} 个分类的名称不能为空`
isValid = false
break
}
if (item.items.length === 0) {
errors.techStack = `${i + 1} 个分类至少需要一个技术标签`
isValid = false
break
}
for (let j = 0; j < item.items.length; j++) {
if (!item.items[j].trim()) {
errors.techStack = `${i + 1} 个分类的第 ${j + 1} 个技术标签不能为空`
isValid = false
break
}
}
if (!isValid) break
}
}
return isValid
@@ -274,18 +368,18 @@ const handleSubmit = async () => {
if (isEditing.value) {
// Update existing work
await updateWork(route.params.id as string, form)
toast.success('作品更新成功')
toast.showToast('作品更新成功', 'success')
} else {
// Create new work
await createWork(form)
toast.success('作品创建成功')
toast.showToast('作品创建成功', 'success')
}
// Redirect to works list
router.push('/admin/works')
} catch (error: any) {
console.error('Error submitting form:', error)
toast.error(error.message || (isEditing.value ? '更新作品失败' : '创建作品失败'))
toast.showToast(error.message || (isEditing.value ? '更新作品失败' : '创建作品失败'), 'error')
} finally {
isSubmitting.value = false
}
@@ -308,17 +402,22 @@ onMounted(async () => {
form.year = work.year
form.heroImg = work.heroImg
form.desc = work.desc
form.techStack = work.techStack
form.gallery = work.gallery
form.links = work.links
form.techStack = work.techStack || []
form.gallery = work.gallery || []
// Update JSON string representations
techStackJson.value = JSON.stringify(work.techStack, null, 2)
galleryJson.value = JSON.stringify(work.gallery, null, 2)
linksJson.value = JSON.stringify(work.links, null, 2)
// Handle links - support both old format (just live) and new format
if (typeof work.links === 'object' && work.links !== null) {
form.links = {
live: work.links.live || '',
github: (work.links as any).github || '',
demo: (work.links as any).demo || ''
}
} else {
form.links = { live: '', github: '', demo: '' }
}
} catch (error: any) {
console.error('Failed to fetch work data:', error)
toast.error('加载作品数据失败: ' + (error.message || '未知错误'))
toast.showToast('加载作品数据失败: ' + (error.message || '未知错误'), 'error')
}
}
})
@@ -364,34 +463,6 @@ onMounted(async () => {
font-family: 'Inter', sans-serif;
}
.form-group input,
.form-group textarea,
.form-group select {
width: 100%;
padding: 0.75rem;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 0.5rem;
font-size: 0.95rem;
background: rgba(255, 255, 255, 0.05);
color: white;
font-family: 'Inter', sans-serif;
resize: vertical;
}
.form-group input::placeholder,
.form-group textarea::placeholder,
.form-group select::placeholder {
color: rgba(255, 255, 255, 0.5);
}
.form-group input:focus,
.form-group textarea:focus,
.form-group select:focus {
outline: none;
border-color: #d4b383;
box-shadow: 0 0 0 3px rgba(212, 179, 131, 0.2);
}
.error-message {
color: #ef4444;
font-size: 0.875rem;
@@ -405,46 +476,12 @@ onMounted(async () => {
margin-top: 2rem;
}
.cancel-btn {
padding: 0.75rem 1.5rem;
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.8);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 0.5rem;
cursor: pointer;
font-weight: 500;
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
font-family: 'Inter', sans-serif;
.tech-stack-item {
transition: all 0.2s;
}
.cancel-btn:hover {
background: rgba(212, 179, 131, 0.1);
border-color: #d4b383;
color: #d4b383;
}
.submit-btn {
padding: 0.75rem 1.5rem;
background-color: #d4b383;
color: #050505;
border: 1px solid #d4b383;
border-radius: 0.5rem;
cursor: pointer;
font-weight: 500;
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
font-family: 'Inter', sans-serif;
}
.submit-btn:hover:not(:disabled) {
background-color: transparent;
color: #d4b383;
transform: translateY(-1px);
box-shadow: 0 0 15px rgba(212, 179, 131, 0.3);
}
.submit-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
.tech-stack-item:hover {
border-color: rgba(255, 255, 255, 0.2);
}
/* Responsive Design */
@@ -457,8 +494,8 @@ onMounted(async () => {
flex-direction: column;
}
.cancel-btn,
.submit-btn {
.admin-btn-secondary,
.admin-btn-primary {
width: 100%;
}
}

View File

@@ -94,7 +94,15 @@ const routes = [
{ path: 'logs', name: 'admin-logs', component: () => import('./pages/admin/Logs.vue') },
// 附件管理
{ path: 'attachments', name: 'admin-attachments', component: () => import('./pages/admin/Attachments.vue') }
{ path: 'attachments', name: 'admin-attachments', component: () => import('./pages/admin/Attachments.vue') },
// 附件分类管理
{ path: 'attachment-categories', name: 'admin-attachment-categories', component: () => import('./pages/admin/AttachmentCategories.vue') },
{ path: 'attachment-categories/create', name: 'admin-attachment-categories-create', component: () => import('./pages/admin/AttachmentCategoryForm.vue') },
{ path: 'attachment-categories/:id/edit', name: 'admin-attachment-categories-edit', component: () => import('./pages/admin/AttachmentCategoryForm.vue') },
// OSS配置管理
{ path: 'oss-configs', name: 'admin-oss-configs', component: () => import('./pages/admin/OSSConfigs.vue') }
]
}
]

View File

@@ -1445,6 +1445,40 @@ export interface Inquiry {
// OSS配置相关类型
export interface OSSConfig {
id: number
name: string
storageType: string
// 通用字段(向后兼容)
accessKey?: string
secretKey?: string
bucket?: string
region?: string
domain?: string
// 阿里云OSS专用字段
ossAccessKeyId?: string
ossAccessKeySecret?: string
ossEndpoint?: string
ossBucket?: string
ossDomain?: string
// 腾讯云COS专用字段
qcloudSecretId?: string
qcloudSecretKey?: string
qcloudRegion?: string
qcloudBucket?: string
qcloudDomain?: string
// 七牛云专用字段
qiniuAccessKey?: string
qiniuSecretKey?: string
qiniuBucket?: string
qiniuRegion?: string
qiniuDomain?: string
isActive: number
createdAt: string
updatedAt: string
}
// 向后兼容的旧接口定义(已废弃,保留用于类型兼容)
export interface OSSConfigOld {
id: number
name: string
storageType: string // local/qcloud/aliyun/qiniu
@@ -1590,3 +1624,122 @@ export const deleteOSSConfig = async (id: number): Promise<void> => {
throw error
}
}
// 附件相关类型
export interface Attachment {
id: number
categoryId?: number
category?: {
id: number
name: string
}
originalName: string
storedName: string
filePath: string
fileUrl: string
fileSize: number
fileType: string
mimeType: string
storageType: string
ossConfigId?: number
createdAt: string
updatedAt: string
}
// 附件分类相关类型
export interface AttachmentCategory {
id: number
name: string
description: string
sortOrder: number
createdAt: string
updatedAt: string
}
// 附件管理API
export const updateAttachment = async (id: number, data: { categoryId?: number | null }): Promise<Attachment> => {
try {
const response = await fetch(`${API_BASE}/admin/attachments/${id}`, {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify(data)
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.message || '更新附件失败')
}
const responseData = await response.json()
return responseData.result
} catch (error) {
console.error('Update attachment error:', error)
throw error
}
}
// 附件分类管理API
export const getAttachmentCategories = async (): Promise<AttachmentCategory[]> => {
try {
const response = await fetch(`${API_BASE}/admin/attachment-categories`, {
headers: getAuthHeaders()
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.message || '获取附件分类列表失败')
}
const data = await response.json()
return data.result || []
} catch (error) {
console.error('Get attachment categories error:', error)
throw error
}
}
export const createAttachmentCategory = async (categoryData: Omit<AttachmentCategory, 'id' | 'createdAt' | 'updatedAt'>): Promise<void> => {
try {
const response = await fetch(`${API_BASE}/admin/attachment-categories`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(categoryData)
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.message || '创建附件分类失败')
}
} catch (error) {
console.error('Create attachment category error:', error)
throw error
}
}
export const updateAttachmentCategory = async (id: number, categoryData: Partial<Omit<AttachmentCategory, 'id' | 'createdAt' | 'updatedAt'>>): Promise<void> => {
try {
const response = await fetch(`${API_BASE}/admin/attachment-categories/${id}`, {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify(categoryData)
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.message || '更新附件分类失败')
}
} catch (error) {
console.error('Update attachment category error:', error)
throw error
}
}
export const deleteAttachmentCategory = async (id: number): Promise<void> => {
try {
const response = await fetch(`${API_BASE}/admin/attachment-categories/${id}`, {
method: 'DELETE',
headers: getAuthHeaders()
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.message || '删除附件分类失败')
}
} catch (error) {
console.error('Delete attachment category error:', error)
throw error
}
}

View File

@@ -60,6 +60,20 @@
@apply border-art-accent ring-1 ring-art-accent outline-none bg-art-surface;
}
/* Fix autofill background color for admin inputs */
.admin-input:-webkit-autofill,
.admin-input:-webkit-autofill:hover,
.admin-input:-webkit-autofill:focus,
.admin-input:-webkit-autofill:active {
-webkit-box-shadow: 0 0 0 30px #0a0a0a inset !important;
-webkit-text-fill-color: white !important;
transition: background-color 5000s ease-in-out 0s;
}
.admin-input:focus:-webkit-autofill {
-webkit-box-shadow: 0 0 0 30px #121214 inset !important;
}
.admin-btn-primary {
@apply bg-art-accent text-black font-medium px-5 py-2.5 rounded-md hover:opacity-90 active:scale-95 transition-all text-sm tracking-wide;
}
@@ -172,3 +186,23 @@ body {
html {
scroll-behavior: smooth;
}
/* Global autofill fix for all inputs (dark theme) */
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus,
input:-webkit-autofill:active {
-webkit-box-shadow: 0 0 0 30px #0a0a0a inset !important;
-webkit-text-fill-color: white !important;
transition: background-color 5000s ease-in-out 0s;
}
/* Specific fix for login page */
.login-input:-webkit-autofill,
.login-input:-webkit-autofill:hover,
.login-input:-webkit-autofill:focus,
.login-input:-webkit-autofill:active {
-webkit-box-shadow: 0 0 0 30px #0a0a0a inset !important;
-webkit-text-fill-color: white !important;
transition: background-color 5000s ease-in-out 0s;
}

View File

@@ -16,27 +16,38 @@ require (
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/BurntSushi/toml v1.3.2 // indirect
github.com/alex-ant/gomath v0.0.0-20160516115720-89013a210a82 // indirect
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible // indirect
github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/clbanning/mxj v1.8.4 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gammazero/toposort v0.1.1 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/gofrs/flock v0.8.1 // indirect
github.com/google/go-querystring v1.0.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/mapstructure v1.4.3 // indirect
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mozillazg/go-httpheader v0.2.1 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/qiniu/go-sdk/v7 v7.25.6 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.54.0 // indirect
github.com/tencentyun/cos-go-sdk-v5 v0.7.72 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
go.uber.org/mock v0.5.0 // indirect
@@ -46,6 +57,8 @@ require (
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.27.0 // indirect
golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.34.0 // indirect
google.golang.org/protobuf v1.36.9 // indirect
modernc.org/fileutil v1.0.0 // indirect
)

View File

@@ -1,11 +1,21 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8=
github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/alex-ant/gomath v0.0.0-20160516115720-89013a210a82 h1:7dONQ3WNZ1zy960TmkxJPuwoolZwL7xKtpcM04MBnt4=
github.com/alex-ant/gomath v0.0.0-20160516115720-89013a210a82/go.mod h1:nLnM0KdK1CmygvjpDUO6m1TjSsiQtL61juhNsvV/JVI=
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible h1:8psS8a+wKfiLt1iVDX79F7Y6wUM49Lcha2FMXt4UM8g=
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible/go.mod h1:T/Aws4fEfogEE9v+HPhhw+CntffsBHJ8nXQCwKr0/g8=
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/clbanning/mxj v1.8.4 h1:HuhwZtbyvyOw+3Z1AowPkU87JkJUSv751ELWaiTpj8I=
github.com/clbanning/mxj v1.8.4/go.mod h1:BVjHeAH+rl9rs6f+QIpeRl0tfu10SXn1pUSa5PVGJng=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/dave/jennifer v1.6.1/go.mod h1:nXbxhEmQfOZhWml3D1cDK5M1FLnMSozpbFN/m3RmGZc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -13,16 +23,24 @@ github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumC
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gammazero/toposort v0.1.1 h1:OivGxsWxF3U3+U80VoLJ+f50HcPU1MIqE1JlKzoJ2Eg=
github.com/gammazero/toposort v0.1.1/go.mod h1:H2cozTnNpMw0hg2VHAYsAxmkHXBYroNangj2NTBQDvw=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.7.0/go.mod h1:xm76BBt941f7yWdGnI2DVPFFg1UK3YY04qifoXU3lOk=
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
@@ -32,13 +50,22 @@ github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw=
github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk=
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
@@ -47,33 +74,60 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lionsoul2014/ip2region/binding/golang v0.0.0-20260109033043-398149f17e54 h1:R0OB+D7w26BOVPdoOva58AgTKmJR8tK2KI15XeeLdG8=
github.com/lionsoul2014/ip2region/binding/golang v0.0.0-20260109033043-398149f17e54/go.mod h1:+mNMTBuDMdEGhWzoQgc6kBdqeaQpWh5ba8zqmp2MxCU=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mitchellh/mapstructure v1.4.3 h1:OVowDSCllw/YjdLkam3/sm7wEtOy59d8ndGgCcyj8cs=
github.com/mitchellh/mapstructure v1.4.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/mozillazg/go-httpheader v0.2.1 h1:geV7TrjbL8KXSyvghnFm+NyTux/hxwueTSrwhe88TQQ=
github.com/mozillazg/go-httpheader v0.2.1/go.mod h1:jJ8xECTlalr6ValeXYdOF8fFUISeBAdw6E61aqQma60=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/qiniu/dyn v1.3.0/go.mod h1:E8oERcm8TtwJiZvkQPbcAh0RL8jO1G0VXJMW3FAWdkk=
github.com/qiniu/go-sdk/v7 v7.25.6 h1:89KQX16Bv2x7MxhwpzWGGvQBOPIlGpAcnPQyfS3tRok=
github.com/qiniu/go-sdk/v7 v7.25.6/go.mod h1:dmKtJ2ahhPWFVi9o1D5GemmWoh/ctuB9peqTowyTO8o=
github.com/qiniu/x v1.10.5/go.mod h1:03Ni9tj+N2h2aKnAz+6N0Xfl8FwMEDRC2PAlxekASDs=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/rs/dnscache v0.0.0-20230804202142-fc85eb664529/go.mod h1:qe5TWALJ8/a1Lqznoc5BDHpYX/8HU60Hm2AwRmqzxqA=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.563/go.mod h1:7sCQWVkxcsR38nffDW057DRGk8mUjK1Ing/EFOK8s8Y=
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/kms v1.0.563/go.mod h1:uom4Nvi9W+Qkom0exYiJ9VWJjXwyxtPYTkKkaLMlfE0=
github.com/tencentyun/cos-go-sdk-v5 v0.7.72 h1:k9aD8ri7Sqy2hYGYo6I2+OslDgY6IT5R0jUOHHSjW5Y=
github.com/tencentyun/cos-go-sdk-v5 v0.7.72/go.mod h1:STbTNaNKq03u+gscPEGOahKzLcGSYOj6Dzc5zNay7Pg=
github.com/tencentyun/qcloud-cos-sts-sdk v0.0.0-20250515025012-e0eec8a5d123/go.mod h1:b18KQa4IxHbxeseW1GcZox53d7J0z39VNONTxvvlkXw=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
@@ -82,25 +136,44 @@ go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
@@ -108,3 +181,5 @@ gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkD
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
modernc.org/fileutil v1.0.0 h1:Z1AFLZwl6BO8A5NldQg/xTSjGLetp+1Ubvl4alfGx8w=
modernc.org/fileutil v1.0.0/go.mod h1:JHsWpkrk/CnVV1H/eGlFf85BEpfkrp56ro8nojIq9Q8=

View File

@@ -56,26 +56,169 @@ func AdminUploadAttachment(c *gin.Context) {
}
if ossConfig != nil {
// 解密AccessKey和SecretKey
accessKey, err := utils.DecryptAES(ossConfig.AccessKey)
if err != nil {
log.Printf("Failed to decrypt access key: %v", err)
utils.Error(c, 500, "Failed to decrypt OSS credentials")
return
}
// 根据存储类型从专用字段或通用字段读取配置
var accessKey, secretKey, bucket, region, domain string
secretKey, err := utils.DecryptAES(ossConfig.SecretKey)
if err != nil {
log.Printf("Failed to decrypt secret key: %v", err)
utils.Error(c, 500, "Failed to decrypt OSS credentials")
return
switch ossConfig.StorageType {
case "aliyun":
// 优先使用专用字段
if ossConfig.OSSAccessKeyID != "" {
decrypted, err := utils.DecryptAES(ossConfig.OSSAccessKeyID)
if err == nil {
accessKey = decrypted
}
}
if ossConfig.OSSAccessKeySecret != "" {
decrypted, err := utils.DecryptAES(ossConfig.OSSAccessKeySecret)
if err == nil {
secretKey = decrypted
}
}
bucket = ossConfig.OSSBucket
domain = ossConfig.OSSDomain
// 如果专用字段为空,使用通用字段(向后兼容)
if accessKey == "" && ossConfig.AccessKey != "" {
decrypted, err := utils.DecryptAES(ossConfig.AccessKey)
if err == nil {
accessKey = decrypted
}
}
if secretKey == "" && ossConfig.SecretKey != "" {
decrypted, err := utils.DecryptAES(ossConfig.SecretKey)
if err == nil {
secretKey = decrypted
}
}
if bucket == "" {
bucket = ossConfig.Bucket
}
if domain == "" {
domain = ossConfig.Domain
}
case "qcloud":
// 优先使用专用字段
if ossConfig.QCloudSecretID != "" {
decrypted, err := utils.DecryptAES(ossConfig.QCloudSecretID)
if err == nil {
accessKey = decrypted
}
}
if ossConfig.QCloudSecretKey != "" {
decrypted, err := utils.DecryptAES(ossConfig.QCloudSecretKey)
if err == nil {
secretKey = decrypted
}
}
bucket = ossConfig.QCloudBucket
region = ossConfig.QCloudRegion
domain = ossConfig.QCloudDomain
// 向后兼容
if accessKey == "" && ossConfig.AccessKey != "" {
decrypted, err := utils.DecryptAES(ossConfig.AccessKey)
if err == nil {
accessKey = decrypted
}
}
if secretKey == "" && ossConfig.SecretKey != "" {
decrypted, err := utils.DecryptAES(ossConfig.SecretKey)
if err == nil {
secretKey = decrypted
}
}
if bucket == "" {
bucket = ossConfig.Bucket
}
if region == "" {
region = ossConfig.Region
}
if domain == "" {
domain = ossConfig.Domain
}
case "qiniu":
// 优先使用专用字段
if ossConfig.QiniuAccessKey != "" {
decrypted, err := utils.DecryptAES(ossConfig.QiniuAccessKey)
if err == nil {
accessKey = decrypted
}
}
if ossConfig.QiniuSecretKey != "" {
decrypted, err := utils.DecryptAES(ossConfig.QiniuSecretKey)
if err == nil {
secretKey = decrypted
}
}
bucket = ossConfig.QiniuBucket
region = ossConfig.QiniuRegion
domain = ossConfig.QiniuDomain
// 向后兼容
if accessKey == "" && ossConfig.AccessKey != "" {
decrypted, err := utils.DecryptAES(ossConfig.AccessKey)
if err == nil {
accessKey = decrypted
}
}
if secretKey == "" && ossConfig.SecretKey != "" {
decrypted, err := utils.DecryptAES(ossConfig.SecretKey)
if err == nil {
secretKey = decrypted
}
}
if bucket == "" {
bucket = ossConfig.Bucket
}
if region == "" {
region = ossConfig.Region
}
if domain == "" {
domain = ossConfig.Domain
}
default:
// 使用通用字段
if ossConfig.AccessKey != "" {
decrypted, err := utils.DecryptAES(ossConfig.AccessKey)
if err == nil {
accessKey = decrypted
}
}
if ossConfig.SecretKey != "" {
decrypted, err := utils.DecryptAES(ossConfig.SecretKey)
if err == nil {
secretKey = decrypted
}
}
bucket = ossConfig.Bucket
region = ossConfig.Region
domain = ossConfig.Domain
}
config.AccessKey = accessKey
config.SecretKey = secretKey
config.Bucket = ossConfig.Bucket
config.Region = ossConfig.Region
config.Domain = ossConfig.Domain
config.Bucket = bucket
config.Region = region
config.Domain = domain
// 填充专用字段
switch ossConfig.StorageType {
case "aliyun":
config.OSSAccessKeyID = accessKey
config.OSSAccessKeySecret = secretKey
config.OSSEndpoint = ossConfig.OSSEndpoint
config.OSSBucket = bucket
config.OSSDomain = domain
case "qcloud":
config.QCloudSecretID = accessKey
config.QCloudSecretKey = secretKey
config.QCloudRegion = region
config.QCloudBucket = bucket
config.QCloudDomain = domain
case "qiniu":
config.QiniuAccessKey = accessKey
config.QiniuSecretKey = secretKey
config.QiniuBucket = bucket
config.QiniuRegion = region
config.QiniuDomain = domain
}
}
// 获取上传器
@@ -184,16 +327,129 @@ func AdminDeleteAttachment(c *gin.Context) {
StorageType: attachment.StorageType,
}
// 解密密钥
if accessKey, err := utils.DecryptAES(ossConfig.AccessKey); err == nil {
config.AccessKey = accessKey
// 根据存储类型从专用字段或通用字段读取配置
var accessKey, secretKey, bucket, region, domain string
switch ossConfig.StorageType {
case "aliyun":
if ossConfig.OSSAccessKeyID != "" {
if decrypted, err := utils.DecryptAES(ossConfig.OSSAccessKeyID); err == nil {
accessKey = decrypted
}
}
if ossConfig.OSSAccessKeySecret != "" {
if decrypted, err := utils.DecryptAES(ossConfig.OSSAccessKeySecret); err == nil {
secretKey = decrypted
}
}
bucket = ossConfig.OSSBucket
domain = ossConfig.OSSDomain
// 向后兼容
if accessKey == "" && ossConfig.AccessKey != "" {
if decrypted, err := utils.DecryptAES(ossConfig.AccessKey); err == nil {
accessKey = decrypted
}
}
if secretKey == "" && ossConfig.SecretKey != "" {
if decrypted, err := utils.DecryptAES(ossConfig.SecretKey); err == nil {
secretKey = decrypted
}
}
if bucket == "" {
bucket = ossConfig.Bucket
}
if domain == "" {
domain = ossConfig.Domain
}
case "qcloud":
if ossConfig.QCloudSecretID != "" {
if decrypted, err := utils.DecryptAES(ossConfig.QCloudSecretID); err == nil {
accessKey = decrypted
}
}
if ossConfig.QCloudSecretKey != "" {
if decrypted, err := utils.DecryptAES(ossConfig.QCloudSecretKey); err == nil {
secretKey = decrypted
}
}
bucket = ossConfig.QCloudBucket
region = ossConfig.QCloudRegion
domain = ossConfig.QCloudDomain
// 向后兼容
if accessKey == "" && ossConfig.AccessKey != "" {
if decrypted, err := utils.DecryptAES(ossConfig.AccessKey); err == nil {
accessKey = decrypted
}
}
if secretKey == "" && ossConfig.SecretKey != "" {
if decrypted, err := utils.DecryptAES(ossConfig.SecretKey); err == nil {
secretKey = decrypted
}
}
if bucket == "" {
bucket = ossConfig.Bucket
}
if region == "" {
region = ossConfig.Region
}
if domain == "" {
domain = ossConfig.Domain
}
case "qiniu":
if ossConfig.QiniuAccessKey != "" {
if decrypted, err := utils.DecryptAES(ossConfig.QiniuAccessKey); err == nil {
accessKey = decrypted
}
}
if ossConfig.QiniuSecretKey != "" {
if decrypted, err := utils.DecryptAES(ossConfig.QiniuSecretKey); err == nil {
secretKey = decrypted
}
}
bucket = ossConfig.QiniuBucket
region = ossConfig.QiniuRegion
domain = ossConfig.QiniuDomain
// 向后兼容
if accessKey == "" && ossConfig.AccessKey != "" {
if decrypted, err := utils.DecryptAES(ossConfig.AccessKey); err == nil {
accessKey = decrypted
}
}
if secretKey == "" && ossConfig.SecretKey != "" {
if decrypted, err := utils.DecryptAES(ossConfig.SecretKey); err == nil {
secretKey = decrypted
}
}
if bucket == "" {
bucket = ossConfig.Bucket
}
if region == "" {
region = ossConfig.Region
}
if domain == "" {
domain = ossConfig.Domain
}
default:
if ossConfig.AccessKey != "" {
if decrypted, err := utils.DecryptAES(ossConfig.AccessKey); err == nil {
accessKey = decrypted
}
}
if ossConfig.SecretKey != "" {
if decrypted, err := utils.DecryptAES(ossConfig.SecretKey); err == nil {
secretKey = decrypted
}
}
bucket = ossConfig.Bucket
region = ossConfig.Region
domain = ossConfig.Domain
}
if secretKey, err := utils.DecryptAES(ossConfig.SecretKey); err == nil {
config.SecretKey = secretKey
}
config.Bucket = ossConfig.Bucket
config.Region = ossConfig.Region
config.Domain = ossConfig.Domain
config.AccessKey = accessKey
config.SecretKey = secretKey
config.Bucket = bucket
config.Region = region
config.Domain = domain
uploader, err := utils.GetOSSUploader(config)
if err == nil {
@@ -211,6 +467,58 @@ func AdminDeleteAttachment(c *gin.Context) {
utils.SuccessWithMsg(c, "Attachment deleted successfully", nil)
}
// AdminUpdateAttachment 更新附件
func AdminUpdateAttachment(c *gin.Context) {
idStr := c.Param("id")
var id uint
if _, err := strconv.ParseUint(idStr, 10, 32); err != nil {
utils.Error(c, 400, "Invalid attachment ID")
return
}
// 获取现有附件
attachment, err := repositories.GetAttachmentByID(id)
if err != nil || attachment == nil {
utils.Error(c, 404, "Attachment not found")
return
}
// 解析请求体
var req struct {
CategoryID *uint `json:"categoryId"`
}
if err := c.ShouldBindJSON(&req); err != nil {
utils.Error(c, 400, "Invalid request")
return
}
// 更新分类ID
if req.CategoryID != nil {
if *req.CategoryID == 0 {
// 如果categoryId为0设置为nil无分类
attachment.CategoryID = nil
} else {
attachment.CategoryID = req.CategoryID
}
}
// 更新附件
if err := repositories.UpdateAttachment(attachment); err != nil {
utils.ServerError(c, err)
return
}
// 重新获取更新后的附件(包含关联的分类信息)
updatedAttachment, err := repositories.GetAttachmentByID(id)
if err != nil {
utils.ServerError(c, err)
return
}
utils.SuccessWithMsg(c, "Attachment updated successfully", updatedAttachment)
}
// AdminGetAttachmentCategories 获取附件分类列表
func AdminGetAttachmentCategories(c *gin.Context) {
categories, err := repositories.GetAttachmentCategories()
@@ -282,6 +590,29 @@ func AdminDeleteAttachmentCategory(c *gin.Context) {
// AdminGetOSSConfigs 获取OSS配置列表
func AdminGetOSSConfigs(c *gin.Context) {
// 检查是否存在默认本地存储配置,如果不存在则创建
defaultConfig, err := repositories.GetDefaultLocalOSSConfig()
if err != nil {
log.Printf("Error checking default local OSS config: %v", err)
} else if defaultConfig == nil {
// 创建默认本地存储配置
// 本地存储不需要AccessKey和SecretKey直接使用空字符串不加密
defaultOSSConfig := &models.OSSConfig{
Name: "本地存储",
StorageType: "local",
AccessKey: "", // 空字符串,不加密
SecretKey: "", // 空字符串,不加密
Bucket: "",
Region: "",
Domain: "",
IsActive: 1,
}
if err := repositories.CreateOSSConfig(defaultOSSConfig); err != nil {
log.Printf("Error creating default local OSS config: %v", err)
}
}
configs, err := repositories.GetOSSConfigs()
if err != nil {
utils.ServerError(c, err)
@@ -292,6 +623,12 @@ func AdminGetOSSConfigs(c *gin.Context) {
for i := range configs {
configs[i].AccessKey = "***"
configs[i].SecretKey = "***"
configs[i].OSSAccessKeyID = "***"
configs[i].OSSAccessKeySecret = "***"
configs[i].QCloudSecretID = "***"
configs[i].QCloudSecretKey = "***"
configs[i].QiniuAccessKey = "***"
configs[i].QiniuSecretKey = "***"
}
utils.Success(c, configs)
@@ -302,12 +639,31 @@ func AdminCreateOSSConfig(c *gin.Context) {
var req struct {
Name string `json:"name" binding:"required"`
StorageType string `json:"storageType" binding:"required"`
AccessKey string `json:"accessKey" binding:"required"`
SecretKey string `json:"secretKey" binding:"required"`
Bucket string `json:"bucket"`
Region string `json:"region"`
Domain string `json:"domain"`
IsActive int `json:"isActive"`
// 通用字段(向后兼容)
AccessKey string `json:"accessKey"`
SecretKey string `json:"secretKey"`
Bucket string `json:"bucket"`
Region string `json:"region"`
Domain string `json:"domain"`
// 阿里云OSS专用字段
OSSAccessKeyID string `json:"ossAccessKeyId"`
OSSAccessKeySecret string `json:"ossAccessKeySecret"`
OSSEndpoint string `json:"ossEndpoint"`
OSSBucket string `json:"ossBucket"`
OSSDomain string `json:"ossDomain"`
// 腾讯云COS专用字段
QCloudSecretID string `json:"qcloudSecretId"`
QCloudSecretKey string `json:"qcloudSecretKey"`
QCloudRegion string `json:"qcloudRegion"`
QCloudBucket string `json:"qcloudBucket"`
QCloudDomain string `json:"qcloudDomain"`
// 七牛云专用字段
QiniuAccessKey string `json:"qiniuAccessKey"`
QiniuSecretKey string `json:"qiniuSecretKey"`
QiniuBucket string `json:"qiniuBucket"`
QiniuRegion string `json:"qiniuRegion"`
QiniuDomain string `json:"qiniuDomain"`
IsActive int `json:"isActive"`
}
if err := c.ShouldBindJSON(&req); err != nil {
@@ -315,28 +671,90 @@ func AdminCreateOSSConfig(c *gin.Context) {
return
}
// 加密密钥
encryptedAccessKey, err := utils.EncryptAES(req.AccessKey)
// 加密函数
encryptKey := func(key string) (string, error) {
if key == "" {
return "", nil
}
return utils.EncryptAES(key)
}
// 加密通用密钥(向后兼容)
encryptedAccessKey, err := encryptKey(req.AccessKey)
if err != nil {
utils.Error(c, 500, "Failed to encrypt access key")
utils.Error(c, 500, "Failed to encrypt access key: "+err.Error())
return
}
encryptedSecretKey, err := encryptKey(req.SecretKey)
if err != nil {
utils.Error(c, 500, "Failed to encrypt secret key: "+err.Error())
return
}
encryptedSecretKey, err := utils.EncryptAES(req.SecretKey)
// 加密阿里云密钥
encryptedOSSAccessKeyID, err := encryptKey(req.OSSAccessKeyID)
if err != nil {
utils.Error(c, 500, "Failed to encrypt secret key")
utils.Error(c, 500, "Failed to encrypt OSS access key ID: "+err.Error())
return
}
encryptedOSSAccessKeySecret, err := encryptKey(req.OSSAccessKeySecret)
if err != nil {
utils.Error(c, 500, "Failed to encrypt OSS access key secret: "+err.Error())
return
}
// 加密腾讯云密钥
encryptedQCloudSecretID, err := encryptKey(req.QCloudSecretID)
if err != nil {
utils.Error(c, 500, "Failed to encrypt QCloud secret ID: "+err.Error())
return
}
encryptedQCloudSecretKey, err := encryptKey(req.QCloudSecretKey)
if err != nil {
utils.Error(c, 500, "Failed to encrypt QCloud secret key: "+err.Error())
return
}
// 加密七牛云密钥
encryptedQiniuAccessKey, err := encryptKey(req.QiniuAccessKey)
if err != nil {
utils.Error(c, 500, "Failed to encrypt Qiniu access key: "+err.Error())
return
}
encryptedQiniuSecretKey, err := encryptKey(req.QiniuSecretKey)
if err != nil {
utils.Error(c, 500, "Failed to encrypt Qiniu secret key: "+err.Error())
return
}
ossConfig := &models.OSSConfig{
Name: req.Name,
StorageType: req.StorageType,
AccessKey: encryptedAccessKey,
SecretKey: encryptedSecretKey,
Bucket: req.Bucket,
Region: req.Region,
Domain: req.Domain,
IsActive: req.IsActive,
// 通用字段(向后兼容)
AccessKey: encryptedAccessKey,
SecretKey: encryptedSecretKey,
Bucket: req.Bucket,
Region: req.Region,
Domain: req.Domain,
// 阿里云OSS专用字段
OSSAccessKeyID: encryptedOSSAccessKeyID,
OSSAccessKeySecret: encryptedOSSAccessKeySecret,
OSSEndpoint: req.OSSEndpoint,
OSSBucket: req.OSSBucket,
OSSDomain: req.OSSDomain,
// 腾讯云COS专用字段
QCloudSecretID: encryptedQCloudSecretID,
QCloudSecretKey: encryptedQCloudSecretKey,
QCloudRegion: req.QCloudRegion,
QCloudBucket: req.QCloudBucket,
QCloudDomain: req.QCloudDomain,
// 七牛云专用字段
QiniuAccessKey: encryptedQiniuAccessKey,
QiniuSecretKey: encryptedQiniuSecretKey,
QiniuBucket: req.QiniuBucket,
QiniuRegion: req.QiniuRegion,
QiniuDomain: req.QiniuDomain,
IsActive: req.IsActive,
}
if err := repositories.CreateOSSConfig(ossConfig); err != nil {
@@ -347,6 +765,12 @@ func AdminCreateOSSConfig(c *gin.Context) {
// 不返回加密的密钥
ossConfig.AccessKey = "***"
ossConfig.SecretKey = "***"
ossConfig.OSSAccessKeyID = "***"
ossConfig.OSSAccessKeySecret = "***"
ossConfig.QCloudSecretID = "***"
ossConfig.QCloudSecretKey = "***"
ossConfig.QiniuAccessKey = "***"
ossConfig.QiniuSecretKey = "***"
utils.SuccessWithMsg(c, "OSS config created successfully", ossConfig)
}
@@ -363,12 +787,31 @@ func AdminUpdateOSSConfig(c *gin.Context) {
var req struct {
Name string `json:"name"`
StorageType string `json:"storageType"`
AccessKey string `json:"accessKey"`
SecretKey string `json:"secretKey"`
Bucket string `json:"bucket"`
Region string `json:"region"`
Domain string `json:"domain"`
IsActive int `json:"isActive"`
// 通用字段(向后兼容)
AccessKey string `json:"accessKey"`
SecretKey string `json:"secretKey"`
Bucket string `json:"bucket"`
Region string `json:"region"`
Domain string `json:"domain"`
// 阿里云OSS专用字段
OSSAccessKeyID string `json:"ossAccessKeyId"`
OSSAccessKeySecret string `json:"ossAccessKeySecret"`
OSSEndpoint string `json:"ossEndpoint"`
OSSBucket string `json:"ossBucket"`
OSSDomain string `json:"ossDomain"`
// 腾讯云COS专用字段
QCloudSecretID string `json:"qcloudSecretId"`
QCloudSecretKey string `json:"qcloudSecretKey"`
QCloudRegion string `json:"qcloudRegion"`
QCloudBucket string `json:"qcloudBucket"`
QCloudDomain string `json:"qcloudDomain"`
// 七牛云专用字段
QiniuAccessKey string `json:"qiniuAccessKey"`
QiniuSecretKey string `json:"qiniuSecretKey"`
QiniuBucket string `json:"qiniuBucket"`
QiniuRegion string `json:"qiniuRegion"`
QiniuDomain string `json:"qiniuDomain"`
IsActive int `json:"isActive"`
}
if err := c.ShouldBindJSON(&req); err != nil {
@@ -383,6 +826,46 @@ func AdminUpdateOSSConfig(c *gin.Context) {
return
}
// 检查是否要启用配置如果是非local类型需要验证AccessKey和SecretKey
if req.IsActive == 1 && existingConfig.IsActive == 0 {
// 检查当前配置的AccessKey和SecretKey需要解密
var accessKey, secretKey string
if existingConfig.AccessKey != "" {
decrypted, err := utils.DecryptAES(existingConfig.AccessKey)
if err == nil {
accessKey = decrypted
}
}
if existingConfig.SecretKey != "" {
decrypted, err := utils.DecryptAES(existingConfig.SecretKey)
if err == nil {
secretKey = decrypted
}
}
// 如果提供了新的AccessKey或SecretKey使用新的
if req.AccessKey != "" && req.AccessKey != "***" {
accessKey = req.AccessKey
}
if req.SecretKey != "" && req.SecretKey != "***" {
secretKey = req.SecretKey
}
// 对于非local类型必须填写AccessKey和SecretKey
if existingConfig.StorageType != "local" && (accessKey == "" || secretKey == "") {
utils.Error(c, 400, "启用非本地存储配置前必须填写AccessKey和SecretKey")
return
}
}
// 加密函数
encryptKey := func(key string) (string, error) {
if key == "" {
return "", nil
}
return utils.EncryptAES(key)
}
// 更新字段
if req.Name != "" {
existingConfig.Name = req.Name
@@ -390,31 +873,93 @@ func AdminUpdateOSSConfig(c *gin.Context) {
if req.StorageType != "" {
existingConfig.StorageType = req.StorageType
}
if req.AccessKey != "" && req.AccessKey != "***" {
encryptedAccessKey, err := utils.EncryptAES(req.AccessKey)
// 处理通用字段(向后兼容)
if req.AccessKey != "***" {
encrypted, err := encryptKey(req.AccessKey)
if err != nil {
utils.Error(c, 500, "Failed to encrypt access key")
utils.Error(c, 500, "Failed to encrypt access key: "+err.Error())
return
}
existingConfig.AccessKey = encryptedAccessKey
existingConfig.AccessKey = encrypted
}
if req.SecretKey != "" && req.SecretKey != "***" {
encryptedSecretKey, err := utils.EncryptAES(req.SecretKey)
if req.SecretKey != "***" {
encrypted, err := encryptKey(req.SecretKey)
if err != nil {
utils.Error(c, 500, "Failed to encrypt secret key")
utils.Error(c, 500, "Failed to encrypt secret key: "+err.Error())
return
}
existingConfig.SecretKey = encryptedSecretKey
existingConfig.SecretKey = encrypted
}
if req.Bucket != "" {
existingConfig.Bucket = req.Bucket
// 更新通用字段(允许空字符串)
existingConfig.Bucket = req.Bucket
existingConfig.Region = req.Region
existingConfig.Domain = req.Domain
// 处理阿里云OSS专用字段
if req.OSSAccessKeyID != "***" {
encrypted, err := encryptKey(req.OSSAccessKeyID)
if err != nil {
utils.Error(c, 500, "Failed to encrypt OSS access key ID: "+err.Error())
return
}
existingConfig.OSSAccessKeyID = encrypted
}
if req.Region != "" {
existingConfig.Region = req.Region
if req.OSSAccessKeySecret != "***" {
encrypted, err := encryptKey(req.OSSAccessKeySecret)
if err != nil {
utils.Error(c, 500, "Failed to encrypt OSS access key secret: "+err.Error())
return
}
existingConfig.OSSAccessKeySecret = encrypted
}
if req.Domain != "" {
existingConfig.Domain = req.Domain
existingConfig.OSSEndpoint = req.OSSEndpoint
existingConfig.OSSBucket = req.OSSBucket
existingConfig.OSSDomain = req.OSSDomain
// 处理腾讯云COS专用字段
if req.QCloudSecretID != "***" {
encrypted, err := encryptKey(req.QCloudSecretID)
if err != nil {
utils.Error(c, 500, "Failed to encrypt QCloud secret ID: "+err.Error())
return
}
existingConfig.QCloudSecretID = encrypted
}
if req.QCloudSecretKey != "***" {
encrypted, err := encryptKey(req.QCloudSecretKey)
if err != nil {
utils.Error(c, 500, "Failed to encrypt QCloud secret key: "+err.Error())
return
}
existingConfig.QCloudSecretKey = encrypted
}
existingConfig.QCloudRegion = req.QCloudRegion
existingConfig.QCloudBucket = req.QCloudBucket
existingConfig.QCloudDomain = req.QCloudDomain
// 处理七牛云专用字段
if req.QiniuAccessKey != "***" {
encrypted, err := encryptKey(req.QiniuAccessKey)
if err != nil {
utils.Error(c, 500, "Failed to encrypt Qiniu access key: "+err.Error())
return
}
existingConfig.QiniuAccessKey = encrypted
}
if req.QiniuSecretKey != "***" {
encrypted, err := encryptKey(req.QiniuSecretKey)
if err != nil {
utils.Error(c, 500, "Failed to encrypt Qiniu secret key: "+err.Error())
return
}
existingConfig.QiniuSecretKey = encrypted
}
existingConfig.QiniuBucket = req.QiniuBucket
existingConfig.QiniuRegion = req.QiniuRegion
existingConfig.QiniuDomain = req.QiniuDomain
existingConfig.IsActive = req.IsActive
if err := repositories.UpdateOSSConfig(existingConfig); err != nil {
@@ -425,6 +970,12 @@ func AdminUpdateOSSConfig(c *gin.Context) {
// 不返回加密的密钥
existingConfig.AccessKey = "***"
existingConfig.SecretKey = "***"
existingConfig.OSSAccessKeyID = "***"
existingConfig.OSSAccessKeySecret = "***"
existingConfig.QCloudSecretID = "***"
existingConfig.QCloudSecretKey = "***"
existingConfig.QiniuAccessKey = "***"
existingConfig.QiniuSecretKey = "***"
utils.SuccessWithMsg(c, "OSS config updated successfully", existingConfig)
}
@@ -438,6 +989,24 @@ func AdminDeleteOSSConfig(c *gin.Context) {
return
}
// 获取配置信息,检查是否为默认本地存储配置
ossConfig, err := repositories.GetOSSConfigByID(id)
if err != nil {
utils.ServerError(c, err)
return
}
if ossConfig == nil {
utils.Error(c, 404, "OSS config not found")
return
}
// 检查是否为默认本地存储配置,不允许删除
if ossConfig.Name == "本地存储" && ossConfig.StorageType == "local" {
utils.Error(c, 400, "默认本地存储配置不能删除")
return
}
if err := repositories.DeleteOSSConfig(id); err != nil {
utils.ServerError(c, err)
return

View File

@@ -1,7 +1,9 @@
package handlers
import (
"encoding/json"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/niangaodev/art-code/models"
@@ -76,36 +78,108 @@ func AdminGetWorks(c *gin.Context) {
// AdminCreateWork 创建作品
func AdminCreateWork(c *gin.Context) {
var work models.Work
if err := c.ShouldBindJSON(&work); err != nil {
var req struct {
models.Work
TechStack []map[string]interface{} `json:"techStack"`
Gallery []string `json:"gallery"`
Links map[string]interface{} `json:"links"`
}
if err := c.ShouldBindJSON(&req); err != nil {
utils.Error(c, 400, "Invalid request")
return
}
if err := repositories.CreateWork(&work); err != nil {
// 生成作品ID如果没有提供
if req.Work.ID == "" {
req.Work.ID = "work_" + strconv.FormatInt(time.Now().UnixNano(), 10)
}
// 将links转换为JSON字符串
if req.Links != nil {
linksJSON, err := json.Marshal(req.Links)
if err != nil {
utils.Error(c, 400, "Invalid links format")
return
}
req.Work.Links = string(linksJSON)
}
// 创建作品
if err := repositories.CreateWork(&req.Work); err != nil {
utils.ServerError(c, err)
return
}
utils.SuccessWithMsg(c, "Work created successfully", gin.H{"id": work.ID})
// 保存技术栈
if req.TechStack != nil && len(req.TechStack) > 0 {
if err := repositories.CreateWorkTechStack(req.Work.ID, req.TechStack); err != nil {
utils.ServerError(c, err)
return
}
}
// 保存图库
if req.Gallery != nil && len(req.Gallery) > 0 {
if err := repositories.CreateWorkGallery(req.Work.ID, req.Gallery); err != nil {
utils.ServerError(c, err)
return
}
}
utils.SuccessWithMsg(c, "Work created successfully", gin.H{"id": req.Work.ID})
}
// AdminUpdateWork 更新作品
func AdminUpdateWork(c *gin.Context) {
id := c.Param("id")
var work models.Work
if err := c.ShouldBindJSON(&work); err != nil {
var req struct {
models.Work
TechStack []map[string]interface{} `json:"techStack"`
Gallery []string `json:"gallery"`
Links map[string]interface{} `json:"links"`
}
if err := c.ShouldBindJSON(&req); err != nil {
utils.Error(c, 400, "Invalid request")
return
}
work.ID = id
if err := repositories.UpdateWork(&work); err != nil {
req.Work.ID = id
// 将links转换为JSON字符串
if req.Links != nil {
linksJSON, err := json.Marshal(req.Links)
if err != nil {
utils.Error(c, 400, "Invalid links format")
return
}
req.Work.Links = string(linksJSON)
}
// 更新作品
if err := repositories.UpdateWork(&req.Work); err != nil {
utils.ServerError(c, err)
return
}
// 更新技术栈(先删除旧的,再创建新的)
if req.TechStack != nil {
if err := repositories.CreateWorkTechStack(id, req.TechStack); err != nil {
utils.ServerError(c, err)
return
}
}
// 更新图库(先删除旧的,再创建新的)
if req.Gallery != nil {
if err := repositories.CreateWorkGallery(id, req.Gallery); err != nil {
utils.ServerError(c, err)
return
}
}
utils.SuccessWithMsg(c, "Work updated successfully", nil)
}

View File

@@ -197,6 +197,7 @@ func main() {
// 附件管理 (复用 settings 权限)
authAdmin.POST("/attachments/upload", middleware.PermissionMiddleware("settings", "create"), handlers.AdminUploadAttachment)
authAdmin.GET("/attachments", middleware.PermissionMiddleware("settings", "read"), handlers.AdminGetAttachments)
authAdmin.PUT("/attachments/:id", middleware.PermissionMiddleware("settings", "update"), handlers.AdminUpdateAttachment)
authAdmin.DELETE("/attachments/:id", middleware.PermissionMiddleware("settings", "delete"), handlers.AdminDeleteAttachment)
// 附件分类管理

View File

@@ -0,0 +1,176 @@
-- 修改oss_configs表结构将access_key和secret_key改为VARCHAR(255)并设置默认值
ALTER TABLE `oss_configs`
MODIFY COLUMN `access_key` VARCHAR(255) NOT NULL DEFAULT '',
MODIFY COLUMN `secret_key` VARCHAR(255) NOT NULL DEFAULT '';
-- 添加阿里云OSS专用字段使用存储过程安全添加如果已存在则跳过
SET @dbname = DATABASE();
SET @tablename = 'oss_configs';
SET @preparedStatement = (SELECT IF(
(SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = @dbname AND TABLE_NAME = @tablename AND COLUMN_NAME = 'oss_access_key_id') > 0,
'SELECT 1',
'ALTER TABLE `oss_configs` ADD COLUMN `oss_access_key_id` VARCHAR(255) NOT NULL DEFAULT '' AFTER `secret_key`'
));
PREPARE alterIfNotExists FROM @preparedStatement;
EXECUTE alterIfNotExists;
DEALLOCATE PREPARE alterIfNotExists;
SET @preparedStatement = (SELECT IF(
(SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = @dbname AND TABLE_NAME = @tablename AND COLUMN_NAME = 'oss_access_key_secret') > 0,
'SELECT 1',
'ALTER TABLE `oss_configs` ADD COLUMN `oss_access_key_secret` VARCHAR(255) NOT NULL DEFAULT '' AFTER `oss_access_key_id`'
));
PREPARE alterIfNotExists FROM @preparedStatement;
EXECUTE alterIfNotExists;
DEALLOCATE PREPARE alterIfNotExists;
SET @preparedStatement = (SELECT IF(
(SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = @dbname AND TABLE_NAME = @tablename AND COLUMN_NAME = 'oss_endpoint') > 0,
'SELECT 1',
'ALTER TABLE `oss_configs` ADD COLUMN `oss_endpoint` VARCHAR(255) NOT NULL DEFAULT '' AFTER `oss_access_key_secret`'
));
PREPARE alterIfNotExists FROM @preparedStatement;
EXECUTE alterIfNotExists;
DEALLOCATE PREPARE alterIfNotExists;
SET @preparedStatement = (SELECT IF(
(SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = @dbname AND TABLE_NAME = @tablename AND COLUMN_NAME = 'oss_bucket') > 0,
'SELECT 1',
'ALTER TABLE `oss_configs` ADD COLUMN `oss_bucket` VARCHAR(255) NOT NULL DEFAULT '' AFTER `oss_endpoint`'
));
PREPARE alterIfNotExists FROM @preparedStatement;
EXECUTE alterIfNotExists;
DEALLOCATE PREPARE alterIfNotExists;
SET @preparedStatement = (SELECT IF(
(SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = @dbname AND TABLE_NAME = @tablename AND COLUMN_NAME = 'oss_domain') > 0,
'SELECT 1',
'ALTER TABLE `oss_configs` ADD COLUMN `oss_domain` VARCHAR(255) NOT NULL DEFAULT '' AFTER `oss_bucket`'
));
PREPARE alterIfNotExists FROM @preparedStatement;
EXECUTE alterIfNotExists;
DEALLOCATE PREPARE alterIfNotExists;
-- 添加腾讯云COS专用字段
SET @preparedStatement = (SELECT IF(
(SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = @dbname AND TABLE_NAME = @tablename AND COLUMN_NAME = 'qcloud_secret_id') > 0,
'SELECT 1',
'ALTER TABLE `oss_configs` ADD COLUMN `qcloud_secret_id` VARCHAR(255) NOT NULL DEFAULT '' AFTER `oss_domain`'
));
PREPARE alterIfNotExists FROM @preparedStatement;
EXECUTE alterIfNotExists;
DEALLOCATE PREPARE alterIfNotExists;
SET @preparedStatement = (SELECT IF(
(SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = @dbname AND TABLE_NAME = @tablename AND COLUMN_NAME = 'qcloud_secret_key') > 0,
'SELECT 1',
'ALTER TABLE `oss_configs` ADD COLUMN `qcloud_secret_key` VARCHAR(255) NOT NULL DEFAULT '' AFTER `qcloud_secret_id`'
));
PREPARE alterIfNotExists FROM @preparedStatement;
EXECUTE alterIfNotExists;
DEALLOCATE PREPARE alterIfNotExists;
SET @preparedStatement = (SELECT IF(
(SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = @dbname AND TABLE_NAME = @tablename AND COLUMN_NAME = 'qcloud_region') > 0,
'SELECT 1',
'ALTER TABLE `oss_configs` ADD COLUMN `qcloud_region` VARCHAR(255) NOT NULL DEFAULT '' AFTER `qcloud_secret_key`'
));
PREPARE alterIfNotExists FROM @preparedStatement;
EXECUTE alterIfNotExists;
DEALLOCATE PREPARE alterIfNotExists;
SET @preparedStatement = (SELECT IF(
(SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = @dbname AND TABLE_NAME = @tablename AND COLUMN_NAME = 'qcloud_bucket') > 0,
'SELECT 1',
'ALTER TABLE `oss_configs` ADD COLUMN `qcloud_bucket` VARCHAR(255) NOT NULL DEFAULT '' AFTER `qcloud_region`'
));
PREPARE alterIfNotExists FROM @preparedStatement;
EXECUTE alterIfNotExists;
DEALLOCATE PREPARE alterIfNotExists;
SET @preparedStatement = (SELECT IF(
(SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = @dbname AND TABLE_NAME = @tablename AND COLUMN_NAME = 'qcloud_domain') > 0,
'SELECT 1',
'ALTER TABLE `oss_configs` ADD COLUMN `qcloud_domain` VARCHAR(255) NOT NULL DEFAULT '' AFTER `qcloud_bucket`'
));
PREPARE alterIfNotExists FROM @preparedStatement;
EXECUTE alterIfNotExists;
DEALLOCATE PREPARE alterIfNotExists;
-- 添加七牛云专用字段
SET @preparedStatement = (SELECT IF(
(SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = @dbname AND TABLE_NAME = @tablename AND COLUMN_NAME = 'qiniu_access_key') > 0,
'SELECT 1',
'ALTER TABLE `oss_configs` ADD COLUMN `qiniu_access_key` VARCHAR(255) NOT NULL DEFAULT '' AFTER `qcloud_domain`'
));
PREPARE alterIfNotExists FROM @preparedStatement;
EXECUTE alterIfNotExists;
DEALLOCATE PREPARE alterIfNotExists;
SET @preparedStatement = (SELECT IF(
(SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = @dbname AND TABLE_NAME = @tablename AND COLUMN_NAME = 'qiniu_secret_key') > 0,
'SELECT 1',
'ALTER TABLE `oss_configs` ADD COLUMN `qiniu_secret_key` VARCHAR(255) NOT NULL DEFAULT '' AFTER `qiniu_access_key`'
));
PREPARE alterIfNotExists FROM @preparedStatement;
EXECUTE alterIfNotExists;
DEALLOCATE PREPARE alterIfNotExists;
SET @preparedStatement = (SELECT IF(
(SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = @dbname AND TABLE_NAME = @tablename AND COLUMN_NAME = 'qiniu_bucket') > 0,
'SELECT 1',
'ALTER TABLE `oss_configs` ADD COLUMN `qiniu_bucket` VARCHAR(255) NOT NULL DEFAULT '' AFTER `qiniu_secret_key`'
));
PREPARE alterIfNotExists FROM @preparedStatement;
EXECUTE alterIfNotExists;
DEALLOCATE PREPARE alterIfNotExists;
SET @preparedStatement = (SELECT IF(
(SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = @dbname AND TABLE_NAME = @tablename AND COLUMN_NAME = 'qiniu_region') > 0,
'SELECT 1',
'ALTER TABLE `oss_configs` ADD COLUMN `qiniu_region` VARCHAR(255) NOT NULL DEFAULT '' AFTER `qiniu_bucket`'
));
PREPARE alterIfNotExists FROM @preparedStatement;
EXECUTE alterIfNotExists;
DEALLOCATE PREPARE alterIfNotExists;
SET @preparedStatement = (SELECT IF(
(SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = @dbname AND TABLE_NAME = @tablename AND COLUMN_NAME = 'qiniu_domain') > 0,
'SELECT 1',
'ALTER TABLE `oss_configs` ADD COLUMN `qiniu_domain` VARCHAR(255) NOT NULL DEFAULT '' AFTER `qiniu_region`'
));
PREPARE alterIfNotExists FROM @preparedStatement;
EXECUTE alterIfNotExists;
DEALLOCATE PREPARE alterIfNotExists;
-- 插入默认OSS配置如果不存在
INSERT INTO `oss_configs` (`name`, `storage_type`, `access_key`, `secret_key`, `bucket`, `region`, `domain`, `is_active`, `deleted_at`, `created_at`, `updated_at`)
SELECT '本地存储', 'local', '', '', '', '', '', 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
WHERE NOT EXISTS (SELECT 1 FROM `oss_configs` WHERE `name` = '本地存储' AND `storage_type` = 'local' AND `deleted_at` = 0);
INSERT INTO `oss_configs` (`name`, `storage_type`, `access_key`, `secret_key`, `bucket`, `region`, `domain`, `is_active`, `deleted_at`, `created_at`, `updated_at`)
SELECT '阿里云OSS', 'aliyun', '', '', '', '', '', 0, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
WHERE NOT EXISTS (SELECT 1 FROM `oss_configs` WHERE `name` = '阿里云OSS' AND `storage_type` = 'aliyun' AND `deleted_at` = 0);
INSERT INTO `oss_configs` (`name`, `storage_type`, `access_key`, `secret_key`, `bucket`, `region`, `domain`, `is_active`, `deleted_at`, `created_at`, `updated_at`)
SELECT '腾讯云COS', 'qcloud', '', '', '', '', '', 0, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
WHERE NOT EXISTS (SELECT 1 FROM `oss_configs` WHERE `name` = '腾讯云COS' AND `storage_type` = 'qcloud' AND `deleted_at` = 0);
INSERT INTO `oss_configs` (`name`, `storage_type`, `access_key`, `secret_key`, `bucket`, `region`, `domain`, `is_active`, `deleted_at`, `created_at`, `updated_at`)
SELECT '七牛云', 'qiniu', '', '', '', '', '', 0, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
WHERE NOT EXISTS (SELECT 1 FROM `oss_configs` WHERE `name` = '七牛云' AND `storage_type` = 'qiniu' AND `deleted_at` = 0);

View File

@@ -0,0 +1,3 @@
-- 为works表添加links字段如果不存在
ALTER TABLE `works`
ADD COLUMN IF NOT EXISTS `links` TEXT NULL COMMENT '作品链接JSON格式' AFTER `description`;

View File

@@ -92,16 +92,38 @@ func (a *Attachment) BeforeUpdate(tx *gorm.DB) error {
type OSSConfig struct {
ID uint `json:"id" gorm:"primaryKey;column:id"`
Name string `json:"name" gorm:"column:name"`
StorageType string `json:"storageType" gorm:"column:storage_type"` // local/qcloud/aliyun/qiniu
AccessKey string `json:"accessKey" gorm:"column:access_key;type:text"` // AES加密存储
SecretKey string `json:"secretKey" gorm:"column:secret_key;type:text"` // AES加密存储
Bucket string `json:"bucket" gorm:"column:bucket"`
Region string `json:"region" gorm:"column:region"`
Domain string `json:"domain" gorm:"column:domain"`
IsActive int `json:"isActive" gorm:"column:is_active;default:0"`
DeletedAt int64 `json:"deletedAt" gorm:"column:deleted_at;default:0"`
CreatedAt int64 `json:"createdAt" gorm:"column:created_at"`
UpdatedAt int64 `json:"updatedAt" gorm:"column:updated_at"`
StorageType string `json:"storageType" gorm:"column:storage_type"` // local/qcloud/aliyun/qiniu
AccessKey string `json:"accessKey" gorm:"column:access_key;type:varchar(255);default:'';not null"` // AES加密存储,默认空字符串(向后兼容)
SecretKey string `json:"secretKey" gorm:"column:secret_key;type:varchar(255);default:'';not null"` // AES加密存储,默认空字符串(向后兼容)
Bucket string `json:"bucket" gorm:"column:bucket"` // 向后兼容
Region string `json:"region" gorm:"column:region"` // 向后兼容
Domain string `json:"domain" gorm:"column:domain"` // 向后兼容
// 阿里云OSS专用字段
OSSAccessKeyID string `json:"ossAccessKeyId" gorm:"column:oss_access_key_id;type:varchar(255);default:'';not null"`
OSSAccessKeySecret string `json:"ossAccessKeySecret" gorm:"column:oss_access_key_secret;type:varchar(255);default:'';not null"`
OSSEndpoint string `json:"ossEndpoint" gorm:"column:oss_endpoint;type:varchar(255);default:'';not null"`
OSSBucket string `json:"ossBucket" gorm:"column:oss_bucket;type:varchar(255);default:'';not null"`
OSSDomain string `json:"ossDomain" gorm:"column:oss_domain;type:varchar(255);default:'';not null"`
// 腾讯云COS专用字段
QCloudSecretID string `json:"qcloudSecretId" gorm:"column:qcloud_secret_id;type:varchar(255);default:'';not null"`
QCloudSecretKey string `json:"qcloudSecretKey" gorm:"column:qcloud_secret_key;type:varchar(255);default:'';not null"`
QCloudRegion string `json:"qcloudRegion" gorm:"column:qcloud_region;type:varchar(255);default:'';not null"`
QCloudBucket string `json:"qcloudBucket" gorm:"column:qcloud_bucket;type:varchar(255);default:'';not null"`
QCloudDomain string `json:"qcloudDomain" gorm:"column:qcloud_domain;type:varchar(255);default:'';not null"`
// 七牛云专用字段
QiniuAccessKey string `json:"qiniuAccessKey" gorm:"column:qiniu_access_key;type:varchar(255);default:'';not null"`
QiniuSecretKey string `json:"qiniuSecretKey" gorm:"column:qiniu_secret_key;type:varchar(255);default:'';not null"`
QiniuBucket string `json:"qiniuBucket" gorm:"column:qiniu_bucket;type:varchar(255);default:'';not null"`
QiniuRegion string `json:"qiniuRegion" gorm:"column:qiniu_region;type:varchar(255);default:'';not null"`
QiniuDomain string `json:"qiniuDomain" gorm:"column:qiniu_domain;type:varchar(255);default:'';not null"`
IsActive int `json:"isActive" gorm:"column:is_active;default:0"`
DeletedAt int64 `json:"deletedAt" gorm:"column:deleted_at;default:0"`
CreatedAt int64 `json:"createdAt" gorm:"column:created_at"`
UpdatedAt int64 `json:"updatedAt" gorm:"column:updated_at"`
}
// TableName 指定表名

View File

@@ -14,6 +14,7 @@ type Work struct {
Year string `json:"year" gorm:"column:year"`
HeroImg string `json:"heroImg" gorm:"column:hero_img"`
Description string `json:"desc" gorm:"column:description;type:text"`
Links string `json:"links" gorm:"column:links;type:text"` // JSON格式存储链接
IsFeatured int `json:"isFeatured" gorm:"column:is_featured;default:0"`
CreatedAt int64 `json:"createdAt" gorm:"column:created_at"`
UpdatedAt int64 `json:"updatedAt" gorm:"column:updated_at"`

View File

@@ -11,7 +11,7 @@
Target Server Version : 80407 (8.4.7)
File Encoding : 65001
Date: 19/01/2026 13:58:42
Date: 19/01/2026 20:20:52
*/
SET NAMES utf8mb4;
@@ -41,7 +41,7 @@ CREATE TABLE `about_profiles` (
-- ----------------------------
-- Records of about_profiles
-- ----------------------------
INSERT IGNORE INTO `about_profiles` (`id`, `name`, `avatar`, `location`, `bio`, `email`, `wechat`, `tech_stack`, `experiences`, `is_primary`, `created_at`, `updated_at`, `deleted_at`) VALUES (3, '年糕崽崽', 'https://api.dicebear.com/7.x/avataaars/svg?seed=NianGao&backgroundColor=b6e3f4', '中国 · 浙江杭州', '嗨!我是年糕崽崽,一名热衷于探索数字边界的前端架构师。我的职业生涯始于对像素级完美的执念。从早期的 jQuery 插件开发到如今的 WebGL 3D 场景构建,我始终认为:<strong>技术是骨架,艺术是灵魂。</strong><br><br>目前,我专注于高性能 B 端应用的体验升级以及探索生成式艺术Generative Art在网页中的应用。闲暇之余我喜欢在杭州的西湖边散步或者捣腾我的客制化机械键盘。', 'liqiworker@gmail.com', 'ngzz_0218', '[\\\"Vue 3\\\",\\\"React\\\",\\\"TypeScript\\\",\\\"Three.js\\\",\\\"Golang\\\",\\\"Tailwind CSS\\\",\\\"Rust\\\",\\\"Wails\\\"]', '[{\\\"year\\\":\\\"2024 - 至今\\\",\\\"role\\\":\\\"技术负责人\\\",\\\"company\\\":\\\"某医疗平台公司\\\"},{\\\"year\\\":\\\"2020 - 2024\\\",\\\"role\\\":\\\"PHP开发工程师\\\",\\\"company\\\":\\\"某电商公司\\\"}]', 0, 0, 0, 0);
INSERT INTO `about_profiles` VALUES (3, '年糕崽崽', 'https://api.dicebear.com/7.x/avataaars/svg?seed=NianGao&backgroundColor=b6e3f4', '中国 · 浙江杭州', '嗨!我是年糕崽崽,一名热衷于探索数字边界的前端架构师。我的职业生涯始于对像素级完美的执念。从早期的 jQuery 插件开发到如今的 WebGL 3D 场景构建,我始终认为:<strong>技术是骨架,艺术是灵魂。</strong><br><br>目前,我专注于高性能 B 端应用的体验升级以及探索生成式艺术Generative Art在网页中的应用。闲暇之余我喜欢在杭州的西湖边散步或者捣腾我的客制化机械键盘。', 'liqiworker@gmail.com', 'ngzz_0218', '[\\\"Vue 3\\\",\\\"React\\\",\\\"TypeScript\\\",\\\"Three.js\\\",\\\"Golang\\\",\\\"Tailwind CSS\\\",\\\"Rust\\\",\\\"Wails\\\"]', '[{\\\"year\\\":\\\"2024 - 至今\\\",\\\"role\\\":\\\"技术负责人\\\",\\\"company\\\":\\\"某医疗平台公司\\\"},{\\\"year\\\":\\\"2020 - 2024\\\",\\\"role\\\":\\\"PHP开发工程师\\\",\\\"company\\\":\\\"某电商公司\\\"}]', 0, 0, 0, 0);
-- ----------------------------
-- Table structure for access_logs
@@ -66,6 +66,66 @@ CREATE TABLE `access_logs` (
-- Records of access_logs
-- ----------------------------
-- ----------------------------
-- Table structure for attachment_categories
-- ----------------------------
DROP TABLE IF EXISTS `attachment_categories`;
CREATE TABLE `attachment_categories` (
`id` int UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '分类名称',
`description` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '分类描述',
`sort_order` int UNSIGNED NULL DEFAULT 0 COMMENT '排序',
`deleted_at` bigint NOT NULL DEFAULT 0,
`created_at` bigint NOT NULL DEFAULT 0,
`updated_at` bigint NOT NULL DEFAULT 0,
PRIMARY KEY (`id`) USING BTREE,
INDEX `idx_sort_order`(`sort_order` ASC) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 2 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '附件分类表' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Records of attachment_categories
-- ----------------------------
INSERT INTO `attachment_categories` VALUES (1, '文章附件', '在博客中上传的附件', 0, 0, 1768812104, 1768812104);
-- ----------------------------
-- Table structure for attachments
-- ----------------------------
DROP TABLE IF EXISTS `attachments`;
CREATE TABLE `attachments` (
`id` bigint UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`category_id` int UNSIGNED NULL DEFAULT NULL COMMENT '附件分类ID',
`original_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原始文件名',
`stored_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '存储文件名',
`file_path` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '文件路径',
`file_url` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '文件访问URL',
`file_size` bigint UNSIGNED NOT NULL DEFAULT 0 COMMENT '文件大小(字节)',
`file_type` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '文件类型: image/video/document/other',
`mime_type` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT 'MIME类型',
`storage_type` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'local' COMMENT '存储类型: local/qcloud/aliyun/qiniu',
`oss_config_id` int UNSIGNED NULL DEFAULT NULL COMMENT 'OSS配置ID',
`deleted_at` bigint NOT NULL DEFAULT 0,
`created_at` bigint NOT NULL DEFAULT 0,
`updated_at` bigint NOT NULL DEFAULT 0,
PRIMARY KEY (`id`) USING BTREE,
INDEX `idx_category_id`(`category_id` ASC) USING BTREE,
INDEX `idx_storage_type`(`storage_type` ASC) USING BTREE,
INDEX `idx_file_type`(`file_type` ASC) USING BTREE,
INDEX `idx_created_at`(`created_at` ASC) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 10 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '附件表' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Records of attachments
-- ----------------------------
INSERT INTO `attachments` VALUES (1, NULL, 'cc_upload_xTUFrRBdm7zWyjQs693a39ea93969.jpg', 'cc_upload_xTUFrRBdm7zWyjQs693a39ea93969.jpg', 'uploads\\2026\\01\\19\\cc_upload_xTUFrRBdm7zWyjQs693a39ea93969_1768807852.jpg', '/uploads/2026/01/19/cc_upload_xTUFrRBdm7zWyjQs693a39ea93969_1768807852.jpg', 40648, 'image', 'image/jpeg', 'local', NULL, 0, 1768807852, 1768807852);
INSERT INTO `attachments` VALUES (2, 1, 'cc_upload_opYFPBlXkfyOSrH269152a21424c4.jpg', 'cc_upload_opYFPBlXkfyOSrH269152a21424c4.jpg', 'uploads\\2026\\01\\19\\cc_upload_opYFPBlXkfyOSrH269152a21424c4_1768811098.jpg', '/uploads/2026/01/19/cc_upload_opYFPBlXkfyOSrH269152a21424c4_1768811098.jpg', 12911, 'image', 'image/jpeg', 'local', NULL, 0, 1768811098, 1768811098);
INSERT INTO `attachments` VALUES (3, 1, 'cc_upload_xTUFrRBdm7zWyjQs693a39ea93969.jpg', 'cc_upload_xTUFrRBdm7zWyjQs693a39ea93969.jpg', 'uploads\\2026\\01\\19\\cc_upload_xTUFrRBdm7zWyjQs693a39ea93969_1768824015.jpg', '/uploads/2026/01/19/cc_upload_xTUFrRBdm7zWyjQs693a39ea93969_1768824015.jpg', 40648, 'image', 'image/jpeg', 'local', NULL, 0, 1768824015, 1768824015);
INSERT INTO `attachments` VALUES (4, 1, 'cc_upload_0NeLXeWx8AmBi7Mx693ce05fea320.jpg', 'cc_upload_0NeLXeWx8AmBi7Mx693ce05fea320.jpg', 'uploads\\2026\\01\\19\\cc_upload_0NeLXeWx8AmBi7Mx693ce05fea320_1768824028.jpg', '/uploads/2026/01/19/cc_upload_0NeLXeWx8AmBi7Mx693ce05fea320_1768824028.jpg', 19875, 'image', 'image/jpeg', 'local', NULL, 0, 1768824029, 1768824029);
INSERT INTO `attachments` VALUES (5, 1, 'cc_upload_62sXZ0HMvINRUYDO693d6c27c7045.jpg', 'cc_upload_62sXZ0HMvINRUYDO693d6c27c7045.jpg', 'uploads\\2026\\01\\19\\cc_upload_62sXZ0HMvINRUYDO693d6c27c7045_1768824028.jpg', '/uploads/2026/01/19/cc_upload_62sXZ0HMvINRUYDO693d6c27c7045_1768824028.jpg', 305101, 'image', 'image/jpeg', 'local', NULL, 0, 1768824029, 1768824029);
INSERT INTO `attachments` VALUES (6, 1, 'cc_upload_9AqkM4FiiRhzl1On693d6c27ce6eb.jpg', 'cc_upload_9AqkM4FiiRhzl1On693d6c27ce6eb.jpg', 'uploads\\2026\\01\\19\\cc_upload_9AqkM4FiiRhzl1On693d6c27ce6eb_1768824028.jpg', '/uploads/2026/01/19/cc_upload_9AqkM4FiiRhzl1On693d6c27ce6eb_1768824028.jpg', 237431, 'image', 'image/jpeg', 'local', NULL, 0, 1768824029, 1768824029);
INSERT INTO `attachments` VALUES (7, 1, 'cc_upload_gXTnJhQ2DSbBr3Ql693d6c27c0b62.jpg', 'cc_upload_gXTnJhQ2DSbBr3Ql693d6c27c0b62.jpg', 'uploads\\2026\\01\\19\\cc_upload_gXTnJhQ2DSbBr3Ql693d6c27c0b62_1768824028.jpg', '/uploads/2026/01/19/cc_upload_gXTnJhQ2DSbBr3Ql693d6c27c0b62_1768824028.jpg', 210572, 'image', 'image/jpeg', 'local', NULL, 0, 1768824029, 1768824029);
INSERT INTO `attachments` VALUES (8, 1, 'cc_upload_etVM7GrPwl33KYzn693cc3016abdf.png', 'cc_upload_etVM7GrPwl33KYzn693cc3016abdf.png', 'uploads\\2026\\01\\19\\cc_upload_etVM7GrPwl33KYzn693cc3016abdf_1768824028.png', '/uploads/2026/01/19/cc_upload_etVM7GrPwl33KYzn693cc3016abdf_1768824028.png', 1161285, 'image', 'image/png', 'local', NULL, 0, 1768824029, 1768824029);
INSERT INTO `attachments` VALUES (9, 1, 'cc_upload_HiPDFg6oL0Q5ijfo693ce1f5eb57c.jpg', 'cc_upload_HiPDFg6oL0Q5ijfo693ce1f5eb57c.jpg', 'uploads\\2026\\01\\19\\cc_upload_HiPDFg6oL0Q5ijfo693ce1f5eb57c_1768825204.jpg', '/uploads/2026/01/19/cc_upload_HiPDFg6oL0Q5ijfo693ce1f5eb57c_1768825204.jpg', 1674408, 'image', 'image/jpeg', 'local', NULL, 0, 1768825204, 1768825204);
-- ----------------------------
-- Table structure for categories
-- ----------------------------
@@ -108,12 +168,11 @@ CREATE TABLE `column_posts` (
-- ----------------------------
-- Records of column_posts
-- ----------------------------
INSERT INTO `column_posts` VALUES (1, 1, 0, 0);
INSERT INTO `column_posts` VALUES (2, 2, 0, 0);
INSERT INTO `column_posts` VALUES (3, 3, 0, 0);
INSERT INTO `column_posts` VALUES (4, 4, 0, 0);
INSERT INTO `column_posts` VALUES (4, 5, 0, 0);
INSERT INTO `column_posts` VALUES (4, 6, 0, 0);
INSERT INTO `column_posts` VALUES (0, 2, 0, 0);
INSERT INTO `column_posts` VALUES (0, 3, 0, 0);
INSERT INTO `column_posts` VALUES (1, 4, 0, 0);
INSERT INTO `column_posts` VALUES (1, 5, 0, 0);
INSERT INTO `column_posts` VALUES (1, 6, 0, 1768809352);
-- ----------------------------
-- Table structure for columns
@@ -191,10 +250,7 @@ CREATE TABLE `inquiries` (
-- ----------------------------
-- Table structure for operation_logs
-- ----------------------------
-- Ensure table is dropped even if it has dependencies
SET FOREIGN_KEY_CHECKS = 0;
DROP TABLE IF EXISTS `operation_logs`;
SET FOREIGN_KEY_CHECKS = 1;
CREATE TABLE `operation_logs` (
`id` bigint UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`user_id` bigint UNSIGNED NOT NULL COMMENT '操作用户ID',
@@ -211,6 +267,40 @@ CREATE TABLE `operation_logs` (
INDEX `idx_user_id`(`user_id` ASC) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 644 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '操作日志表' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Records of operation_logs
-- ----------------------------
-- ----------------------------
-- Table structure for oss_configs
-- ----------------------------
DROP TABLE IF EXISTS `oss_configs`;
CREATE TABLE `oss_configs` (
`id` int UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '配置名称',
`storage_type` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '存储类型: local/qcloud/aliyun/qiniu',
`access_key` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '',
`secret_key` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '',
`bucket` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '存储桶名称',
`region` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '区域',
`domain` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '访问域名',
`is_active` tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否启用',
`deleted_at` bigint NOT NULL DEFAULT 0,
`created_at` bigint NOT NULL DEFAULT 0,
`updated_at` bigint NOT NULL DEFAULT 0,
PRIMARY KEY (`id`) USING BTREE,
INDEX `idx_storage_type`(`storage_type` ASC) USING BTREE,
INDEX `idx_is_active`(`is_active` ASC) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 5 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = 'OSS配置表' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Records of oss_configs
-- ----------------------------
INSERT INTO `oss_configs` VALUES (1, '本地存储', 'local', '', '', '', '', '', 1, 0, 1768820300, 1768820300);
INSERT INTO `oss_configs` VALUES (2, '阿里云OSS', 'aliyun', '', '', '', '', '', 0, 0, 1768820300, 1768820300);
INSERT INTO `oss_configs` VALUES (3, '腾讯云COS', 'qcloud', '', '', '', '', '', 0, 0, 1768820300, 1768820300);
INSERT INTO `oss_configs` VALUES (4, '七牛云', 'qiniu', '', '', '', '', '', 0, 0, 1768820300, 1768820300);
-- ----------------------------
-- Table structure for partners
-- ----------------------------
@@ -307,11 +397,14 @@ CREATE TABLE `post_history` (
PRIMARY KEY (`id`) USING BTREE,
INDEX `idx_post_id`(`post_id` ASC) USING BTREE,
INDEX `idx_version`(`version` ASC) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '文章历史记录表' ROW_FORMAT = DYNAMIC;
) ENGINE = InnoDB AUTO_INCREMENT = 4 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '文章历史记录表' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Records of post_history
-- ----------------------------
INSERT INTO `post_history` VALUES (1, 6, 1, 'Goravel 入门指南 (三)ORM 数据库操作', 4, '掌握 Goravel 强大的 ORM 功能从数据库配置、模型定义到执行增删改查CRUD操作。', '', 1, 1, 1768809133, 1768809133, 0);
INSERT INTO `post_history` VALUES (2, 6, 2, 'Goravel 入门指南 (三)ORM 数据库操作', 4, '掌握 Goravel 强大的 ORM 功能从数据库配置、模型定义到执行增删改查CRUD操作。', '', 1, 1, 1768809352, 1768809352, 0);
INSERT INTO `post_history` VALUES (3, 1, 1, '重构的艺术:如何优雅地处理遗留代码', 4, '在本文中,我们将探讨重构的核心原则和实用技巧,帮助你优雅地处理遗留代码,提高代码质量和可维护性。', '<h2>什么是代码重构?</h2><p>代码重构是在不改变代码外部行为的前提下,优化代码内部结构的过程...</p>\n\n\n![cc_upload_opYFPBlXkfyOSrH269152a21424c4.jpg](/uploads/2026/01/19/cc_upload_opYFPBlXkfyOSrH269152a21424c4_1768811098.jpg)\n', 1, 1, 1768811113, 1768811113, 0);
-- ----------------------------
-- Table structure for post_tags
@@ -328,6 +421,10 @@ CREATE TABLE `post_tags` (
-- ----------------------------
-- Records of post_tags
-- ----------------------------
INSERT INTO `post_tags` VALUES (1, 6, 0);
INSERT INTO `post_tags` VALUES (2, 6, 0);
INSERT INTO `post_tags` VALUES (3, 6, 0);
INSERT INTO `post_tags` VALUES (4, 6, 0);
-- ----------------------------
-- Table structure for posts
@@ -357,12 +454,12 @@ CREATE TABLE `posts` (
-- ----------------------------
-- Records of posts
-- ----------------------------
INSERT IGNORE INTO `posts` (`id`, `original_id`, `title`, `category_id`, `column_id`, `excerpt`, `content`, `read_count`, `is_published`, `deleted_at`, `created_at`, `updated_at`) VALUES (1, 'refactor', '重构的艺术:如何优雅地处理遗留代码', 4, NULL, '在本文中,我们将探讨重构的核心原则和实用技巧,帮助你优雅地处理遗留代码,提高代码质量和可维护性。', '<h2>什么是代码重构?</h2><p>代码重构是在不改变代码外部行为的前提下,优化代码内部结构的过程...</p>', 5, 1, 0, 1768291814, 1768538949);
INSERT IGNORE INTO `posts` (`id`, `original_id`, `title`, `category_id`, `column_id`, `excerpt`, `content`, `read_count`, `is_published`, `deleted_at`, `created_at`, `updated_at`) VALUES (2, 'shader', '着色器魔法:从零开始写一个噪声生成器', 2, NULL, '深入了解WebGL着色器学习如何从零开始实现一个高性能的噪声生成器为你的3D作品增添独特的视觉效果。', ' <p>GLSL (OpenGL Shading Language) 是一门让人生畏但也充满魅力的语言。它运行在 GPU 上,能够并行处理数百万个像素,创造出惊人的视觉效果。</p>\r\n <h2>什么是柏林噪声?</h2>\r\n <p>柏林噪声Perlin Noise是一种梯度噪声它比普通的随机数生成的噪声看起来更自然、更平滑。它常被用来模拟云彩、地形、火焰等自然现象。</p>\r\n <h2>Three.js 中的实现</h2>\r\n <p>在 Three.js 中,我们可以通过 <code>ShaderMaterial</code> 直接编写 GLSL 代码。</p>\r\n <pre><code><span class=\"code-comment\">// 简单的顶点着色器</span>\r\n<span class=\"code-keyword\">varying</span> <span class=\"code-keyword\">vec2</span> vUv;\r\n<span class=\"code-keyword\">void</span> <span class=\"code-func\">main</span>() {\r\n vUv = uv;\r\n gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);\r\n}</code></pre>\r\n <p>通过调整噪声的频率和振幅,我们可以得到各种不同的纹理效果。在我的个人网站背景中,就使用了这种技术来生成流动的极光效果。</p>\r\n ', 1, 1, 0, 1768291815, 1768538949);
INSERT IGNORE INTO `posts` (`id`, `original_id`, `title`, `category_id`, `column_id`, `excerpt`, `content`, `read_count`, `is_published`, `deleted_at`, `created_at`, `updated_at`) VALUES (3, 'ux', '用户体验设计:从认知心理学到交互实践', 3, NULL, '探索用户体验设计的核心原理,结合认知心理学知识,学习如何设计出真正符合用户需求的交互界面。', '<h2>认知心理学在UX设计中的应用</h2><p>了解用户的认知过程是设计良好用户体验的基础...</p>', 0, 1, 0, 1768291816, 1768538949);
INSERT IGNORE INTO `posts` (`id`, `original_id`, `title`, `category_id`, `column_id`, `excerpt`, `content`, `read_count`, `is_published`, `deleted_at`, `created_at`, `updated_at`) VALUES (4, 'goravel-guide-1', 'Goravel 入门指南 (一):环境搭建与 Hello World', 4, 1, '本文将带你了解 Go 语言领域的 Laravel —— Goravel 框架,并演示如何快速搭建环境及运行第一个 Web 服务。', '## 什么是 Goravel\n\nGoravel 是一个基于 Go 语言的 Web 开发框架,它的设计理念深受 PHP Laravel 框架的启发。如果你是一名从 PHP 转 Go 的开发者,或者你喜欢 Laravel 那种“开箱即用、优雅简洁”的开发体验,那么 Goravel 绝对是你的不二之选。\n\n它集成了丰富的功能模块包括但不限于\n- 强大的路由系统\n- ORM基于 GORM 封装)\n- 依赖注入容器\n- 队列与任务调度\n- 缓存与文件存储\n\n## 环境搭建\n\nGoravel 提供了一个名为 `knit` 的命令行工具(类似 Laravel 的 artisan可以帮助我们快速初始化项目。\n\n### 1. 安装 Knit CLI\n\n确保你已经安装了 Go (1.20+),然后运行以下命令:\n\n```bash\ngo install github.com/goravel/knit/cmd/knit@latest\n```\n\n### 2. 创建新项目\n\n使用 `knit new` 命令创建项目:\n\n```bash\nknit new my-goravel-app\ncd my-goravel-app\n```\n\n### 3. 安装依赖\n\n```bash\ngo mod tidy\n```\n\n## 目录结构\n\n打开项目你会发现它的目录结构非常清晰带有浓厚的 Laravel 风格:\n\n- **app/**: 核心业务代码Http 控制器、模型、服务提供者等)\n- **config/**: 配置文件(应用配置、数据库配置等)\n- **routes/**: 路由定义文件\n- **database/**: 数据库迁移与填充\n- **public/**: 静态资源文件\n\n## 运行 Hello World\n\nGoravel 的入口文件是根目录下的 `main.go`。在运行之前,我们先看一眼路由定义。打开 `routes/web.go`\n\n```go\npackage routes\n\nimport (\n \"github.com/goravel/framework/contracts/http\"\n \"github.com/goravel/framework/facades\"\n)\n\nfunc Web() {\n facades.Route().Get(\"/\", func(ctx http.Context) http.Response {\n return ctx.Response().Json(200, http.Json{\n \"Hello\": \"Goravel\",\n })\n })\n}\n```\n\n非常直观现在让我们启动服务\n\n```bash\ngo run .\n```\n\n默认情况下服务会运行在 `http://localhost:3000`。打开浏览器访问,你应该能看到 JSON 响应:\n\n```json\n{\n \"Hello\": \"Goravel\"\n}\n```\n\n至此你已经成功运行了你的第一个 Goravel 应用!在下一篇文章中,我们将深入探讨路由与控制器的使用。', 1210, 1, 0, 1768465932, 1768538949);
INSERT IGNORE INTO `posts` (`id`, `original_id`, `title`, `category_id`, `column_id`, `excerpt`, `content`, `read_count`, `is_published`, `deleted_at`, `created_at`, `updated_at`) VALUES (5, 'goravel-guide-2', 'Goravel 入门指南 (二):路由与控制器', 4, 1, '深入理解 Goravel 的 HTTP 层,学习如何定义 RESTful 路由、创建控制器以及处理 HTTP 请求与响应。', '## 路由系统\n\n在 Goravel 中,路由定义通常位于 `routes/` 目录下。`api.go` 用于定义 API 路由,`web.go` 用于定义网页路由。Goravel 使用 `facades.Route()` 来定义路由,这得益于其强大的依赖注入系统。\n\n### 基础路由\n\n```go\n// GET 请求\nfacades.Route().Get(\"/users\", func(ctx http.Context) http.Response {\n return ctx.Response().String(200, \"User List\")\n})\n\n// POST 请求\nfacades.Route().Post(\"/users\", func(ctx http.Context) http.Response {\n return ctx.Response().Success().Json(http.Json{\"id\": 1})\n})\n```\n\n### 路由参数\n\n获取 URL 中的动态参数非常简单:\n\n```go\nfacades.Route().Get(\"/users/{id}\", func(ctx http.Context) http.Response {\n id := ctx.Request().Input(\"id\")\n return ctx.Response().Success().Json(http.Json{\"user_id\": id})\n})\n```\n\n## 控制器 (Controllers)\n\n随着应用变大我们不可能把所有逻辑都写在路由闭包里。这时候就需要控制器了。\n\n### 创建控制器\n\n使用 `knit` 工具可以快速生成控制器:\n\n```bash\nknit make:controller UserController\n```\n\n这会在 `app/http/controllers` 目录下生成 `user_controller.go`。让我们修改它来添加一个 `Show` 方法:\n\n```go\npackage controllers\n\nimport (\n \"github.com/goravel/framework/contracts/http\"\n)\n\ntype UserController struct {\n // 可以在这里注入服务\n}\n\nfunc NewUserController() *UserController {\n return &UserController{}\n}\n\nfunc (r *UserController) Show(ctx http.Context) http.Response {\n id := ctx.Request().Input(\"id\")\n // 模拟数据库查询\n return ctx.Response().Success().Json(http.Json{\n \"id\": id,\n \"name\": \"Goravel User\",\n })\n}\n```\n\n### 注册控制器路由\n\n回到 `routes/api.go`,我们需要先实例化控制器,然后绑定路由:\n\n```go\nimport \"my-goravel-app/app/http/controllers\"\n\nfunc Api() {\n userController := controllers.NewUserController()\n \n // 绑定到控制器方法\n facades.Route().Get(\"/users/{id}\", userController.Show)\n}\n```\n\n## 请求与响应\n\n在控制器方法中`ctx` (http.Context) 是核心:\n\n- **获取输入**: `ctx.Request().Input(\"key\")`\n- **获取 JSON**: `ctx.Request().Bind(&user)`\n- **返回 JSON**: `ctx.Response().Json(200, data)`\n- **设置状态码**: `ctx.Response().Status(404)`\n\n通过这种方式Goravel 让 HTTP 层的处理变得异常清晰和标准化。下一章,我们将学习如何通过 ORM 操作数据库。', 901, 1, 0, 1768465933, 1768538949);
INSERT IGNORE INTO `posts` (`id`, `original_id`, `title`, `category_id`, `column_id`, `excerpt`, `content`, `read_count`, `is_published`, `deleted_at`, `created_at`, `updated_at`) VALUES (6, 'goravel-guide-3', 'Goravel 入门指南 (三)ORM 数据库操作', 4, 1, '掌握 Goravel 强大的 ORM 功能从数据库配置、模型定义到执行增删改查CRUD操作。', '## Goravel ORM 简介\n\nGoravel 的 ORM 基于著名的 GORM 库构建,但它进行了一层优雅的封装,使其调用方式更接近 Laravel 的 Eloquent ORM。这意味着你可以使用 Facade 模式轻松调用数据库,且支持自动迁移。\n\n## 配置数据库\n\n打开 `config/database.go` 或 `.env` 文件,配置你的 MySQL 连接信息:\n\n```env\nDB_CONNECTION=mysql\nDB_HOST=127.0.0.1\nDB_PORT=3306\nDB_DATABASE=goravel\nDB_USERNAME=root\nDB_PASSWORD=password\n```\n\n## 定义模型\n\n使用 `knit` 生成模型:\n\n```bash\nknit make:model Post\n```\n\n生成的模型文件位于 `app/models/post.go`。我们需要定义结构体字段:\n\n```go\npackage models\n\nimport (\n \"github.com/goravel/framework/database/orm\"\n)\n\ntype Post struct {\n orm.Model\n Title string `gorm:\"size:255;not null\"`\n Content string `gorm:\"type:text\"`\n UserID uint\n}\n```\n\n## 数据库迁移\n\n虽然 GORM 支持 AutoMigrate但 Goravel 推荐使用迁移文件来管理数据库变更。\n\n```bash\nknit make:migration create_posts_table\n```\n\n编辑生成的迁移文件 (`database/migrations/xxxx_create_posts_table.go`),定义表结构,然后运行:\n\n```bash\nknit migrate\n```\n\n## CRUD 操作\n\n有了模型我们就可以愉快地操作数据库了。所有操作都通过 `facades.Orm()` 入口。\n\n### 创建 (Create)\n\n```go\npost := models.Post{\n Title: \"My First Post\",\n Content: \"Content goes here...\",\n}\nerr := facades.Orm().Query().Create(&post)\n```\n\n### 查询 (Read)\n\n```go\nvar post models.Post\n// 根据主键查询\nfacades.Orm().Query().Find(&post, 1)\n\n// 条件查询\nvar posts []models.Post\nfacades.Orm().Query().Where(\"title\", \"My First Post\").Get(&posts)\n```\n\n### 更新 (Update)\n\n```go\nvar post models.Post\nfacades.Orm().Query().Find(&post, 1)\n\npost.Title = \"Updated Title\"\nfacades.Orm().Query().Save(&post)\n```\n\n### 删除 (Delete)\n\n```go\nvar post models.Post\nfacades.Orm().Query().Delete(&post, 1)\n```\n\nGoravel 的 ORM 让 Go 语言的数据库操作不再繁琐,极大地提高了开发效率。配合之前的路由和控制器知识,你现在已经具备了开发完整 RESTful API 的能力!', 1569, 1, 0, 1768465934, 1768538949);
INSERT INTO `posts` VALUES (1, 'refactor', '重构的艺术:如何优雅地处理遗留代码', 4, NULL, '在本文中,我们将探讨重构的核心原则和实用技巧,帮助你优雅地处理遗留代码,提高代码质量和可维护性。', '<h2>什么是代码重构?</h2><p>代码重构是在不改变代码外部行为的前提下,优化代码内部结构的过程...</p>\n\n\n![cc_upload_opYFPBlXkfyOSrH269152a21424c4.jpg](/uploads/2026/01/19/cc_upload_opYFPBlXkfyOSrH269152a21424c4_1768811098.jpg)\n', 9, 1, 0, 1768291814, 1768811113);
INSERT INTO `posts` VALUES (2, 'shader', '着色器魔法:从零开始写一个噪声生成器', 2, NULL, '深入了解WebGL着色器学习如何从零开始实现一个高性能的噪声生成器为你的3D作品增添独特的视觉效果。', ' <p>GLSL (OpenGL Shading Language) 是一门让人生畏但也充满魅力的语言。它运行在 GPU 上,能够并行处理数百万个像素,创造出惊人的视觉效果。</p>\r\n <h2>什么是柏林噪声?</h2>\r\n <p>柏林噪声Perlin Noise是一种梯度噪声它比普通的随机数生成的噪声看起来更自然、更平滑。它常被用来模拟云彩、地形、火焰等自然现象。</p>\r\n <h2>Three.js 中的实现</h2>\r\n <p>在 Three.js 中,我们可以通过 <code>ShaderMaterial</code> 直接编写 GLSL 代码。</p>\r\n <pre><code><span class=\"code-comment\">// 简单的顶点着色器</span>\r\n<span class=\"code-keyword\">varying</span> <span class=\"code-keyword\">vec2</span> vUv;\r\n<span class=\"code-keyword\">void</span> <span class=\"code-func\">main</span>() {\r\n vUv = uv;\r\n gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);\r\n}</code></pre>\r\n <p>通过调整噪声的频率和振幅,我们可以得到各种不同的纹理效果。在我的个人网站背景中,就使用了这种技术来生成流动的极光效果。</p>\r\n ', 1, 1, 0, 1768291815, 1768538949);
INSERT INTO `posts` VALUES (3, 'ux', '用户体验设计:从认知心理学到交互实践', 3, NULL, '探索用户体验设计的核心原理,结合认知心理学知识,学习如何设计出真正符合用户需求的交互界面。', '<h2>认知心理学在UX设计中的应用</h2><p>了解用户的认知过程是设计良好用户体验的基础...</p>', 0, 1, 0, 1768291816, 1768538949);
INSERT INTO `posts` VALUES (4, 'goravel-guide-1', 'Goravel 入门指南 (一):环境搭建与 Hello World', 4, 1, '本文将带你了解 Go 语言领域的 Laravel —— Goravel 框架,并演示如何快速搭建环境及运行第一个 Web 服务。', '## 什么是 Goravel\n\nGoravel 是一个基于 Go 语言的 Web 开发框架,它的设计理念深受 PHP Laravel 框架的启发。如果你是一名从 PHP 转 Go 的开发者,或者你喜欢 Laravel 那种“开箱即用、优雅简洁”的开发体验,那么 Goravel 绝对是你的不二之选。\n\n它集成了丰富的功能模块包括但不限于\n- 强大的路由系统\n- ORM基于 GORM 封装)\n- 依赖注入容器\n- 队列与任务调度\n- 缓存与文件存储\n\n## 环境搭建\n\nGoravel 提供了一个名为 `knit` 的命令行工具(类似 Laravel 的 artisan可以帮助我们快速初始化项目。\n\n### 1. 安装 Knit CLI\n\n确保你已经安装了 Go (1.20+),然后运行以下命令:\n\n```bash\ngo install github.com/goravel/knit/cmd/knit@latest\n```\n\n### 2. 创建新项目\n\n使用 `knit new` 命令创建项目:\n\n```bash\nknit new my-goravel-app\ncd my-goravel-app\n```\n\n### 3. 安装依赖\n\n```bash\ngo mod tidy\n```\n\n## 目录结构\n\n打开项目你会发现它的目录结构非常清晰带有浓厚的 Laravel 风格:\n\n- **app/**: 核心业务代码Http 控制器、模型、服务提供者等)\n- **config/**: 配置文件(应用配置、数据库配置等)\n- **routes/**: 路由定义文件\n- **database/**: 数据库迁移与填充\n- **public/**: 静态资源文件\n\n## 运行 Hello World\n\nGoravel 的入口文件是根目录下的 `main.go`。在运行之前,我们先看一眼路由定义。打开 `routes/web.go`\n\n```go\npackage routes\n\nimport (\n \"github.com/goravel/framework/contracts/http\"\n \"github.com/goravel/framework/facades\"\n)\n\nfunc Web() {\n facades.Route().Get(\"/\", func(ctx http.Context) http.Response {\n return ctx.Response().Json(200, http.Json{\n \"Hello\": \"Goravel\",\n })\n })\n}\n```\n\n非常直观现在让我们启动服务\n\n```bash\ngo run .\n```\n\n默认情况下服务会运行在 `http://localhost:3000`。打开浏览器访问,你应该能看到 JSON 响应:\n\n```json\n{\n \"Hello\": \"Goravel\"\n}\n```\n\n至此你已经成功运行了你的第一个 Goravel 应用!在下一篇文章中,我们将深入探讨路由与控制器的使用。', 1210, 1, 0, 1768465932, 1768538949);
INSERT INTO `posts` VALUES (5, 'goravel-guide-2', 'Goravel 入门指南 (二):路由与控制器', 4, 1, '深入理解 Goravel 的 HTTP 层,学习如何定义 RESTful 路由、创建控制器以及处理 HTTP 请求与响应。', '## 路由系统\n\n在 Goravel 中,路由定义通常位于 `routes/` 目录下。`api.go` 用于定义 API 路由,`web.go` 用于定义网页路由。Goravel 使用 `facades.Route()` 来定义路由,这得益于其强大的依赖注入系统。\n\n### 基础路由\n\n```go\n// GET 请求\nfacades.Route().Get(\"/users\", func(ctx http.Context) http.Response {\n return ctx.Response().String(200, \"User List\")\n})\n\n// POST 请求\nfacades.Route().Post(\"/users\", func(ctx http.Context) http.Response {\n return ctx.Response().Success().Json(http.Json{\"id\": 1})\n})\n```\n\n### 路由参数\n\n获取 URL 中的动态参数非常简单:\n\n```go\nfacades.Route().Get(\"/users/{id}\", func(ctx http.Context) http.Response {\n id := ctx.Request().Input(\"id\")\n return ctx.Response().Success().Json(http.Json{\"user_id\": id})\n})\n```\n\n## 控制器 (Controllers)\n\n随着应用变大我们不可能把所有逻辑都写在路由闭包里。这时候就需要控制器了。\n\n### 创建控制器\n\n使用 `knit` 工具可以快速生成控制器:\n\n```bash\nknit make:controller UserController\n```\n\n这会在 `app/http/controllers` 目录下生成 `user_controller.go`。让我们修改它来添加一个 `Show` 方法:\n\n```go\npackage controllers\n\nimport (\n \"github.com/goravel/framework/contracts/http\"\n)\n\ntype UserController struct {\n // 可以在这里注入服务\n}\n\nfunc NewUserController() *UserController {\n return &UserController{}\n}\n\nfunc (r *UserController) Show(ctx http.Context) http.Response {\n id := ctx.Request().Input(\"id\")\n // 模拟数据库查询\n return ctx.Response().Success().Json(http.Json{\n \"id\": id,\n \"name\": \"Goravel User\",\n })\n}\n```\n\n### 注册控制器路由\n\n回到 `routes/api.go`,我们需要先实例化控制器,然后绑定路由:\n\n```go\nimport \"my-goravel-app/app/http/controllers\"\n\nfunc Api() {\n userController := controllers.NewUserController()\n \n // 绑定到控制器方法\n facades.Route().Get(\"/users/{id}\", userController.Show)\n}\n```\n\n## 请求与响应\n\n在控制器方法中`ctx` (http.Context) 是核心:\n\n- **获取输入**: `ctx.Request().Input(\"key\")`\n- **获取 JSON**: `ctx.Request().Bind(&user)`\n- **返回 JSON**: `ctx.Response().Json(200, data)`\n- **设置状态码**: `ctx.Response().Status(404)`\n\n通过这种方式Goravel 让 HTTP 层的处理变得异常清晰和标准化。下一章,我们将学习如何通过 ORM 操作数据库。', 903, 1, 0, 1768465933, 1768538949);
INSERT INTO `posts` VALUES (6, 'goravel-guide-3', 'Goravel 入门指南 (三)ORM 数据库操作', 4, 1, '掌握 Goravel 强大的 ORM 功能从数据库配置、模型定义到执行增删改查CRUD操作。', '## Goravel ORM 简介\r\nGoravel 的 ORM 基于著名的 GORM 库构建,但它进行了一层优雅的封装,使其调用方式更接近 Laravel 的 Eloquent ORM。这意味着你可以使用 Facade 模式轻松调用数据库,且支持自动迁移。\r\n## 配置数据库\r\n打开 `config/database.go` 或 `.env` 文件,配置你的 MySQL 连接信息:\r\n```env\r\nDB_CONNECTION=mysql\r\nDB_HOST=127.0.0.1\r\nDB_PORT=3306\r\nDB_DATABASE=goravel\r\nDB_USERNAME=root\r\nDB_PASSWORD=password\r\n```\r\n## 定义模型\r\n使用 `knit` 生成模型:\r\n```bash\r\nknit make:model Post\r\n```\r\n生成的模型文件位于 `app/models/post.go`。我们需要定义结构体字段:\r\n```go\r\npackage models\r\nimport (\r\n \\\"github.com/goravel/framework/database/orm\\\"\r\n )\r\ntype Post struct {\r\n orm.Model\r\n Title string `gorm:\\\"size:255;not null\\\"`\r\n Content string `gorm:\\\"type:text\\\"`\r\n UserID uint\r\n }\r\n ```\r\n## 数据库迁移\r\n虽然 GORM 支持 AutoMigrate但 Goravel 推荐使用迁移文件来管理数据库变更。\r\n```bash\r\nknit make:migration create_posts_table\r\n```\r\n编辑生成的迁移文件 (`database/migrations/xxxx_create_posts_table.go`),定义表结构,然后运行:\r\n```bash\r\nknit migrate\r\n```\r\n## CRUD 操作\r\n有了模型我们就可以愉快地操作数据库了。所有操作都通过 `facades.Orm()` 入口。\r\n### 创建 (Create)\r\n```go\r\npost := models.Post{\r\n Title: \\\"My First Post\\\",\r\n Content: \\\"Content goes here...\\\",\r\n }\r\n err := facades.Orm().Query().Create(&post)\r\n ```\r\n### 查询 (Read)\r\n```go\r\nvar post models.Post\r\n// 根据主键查询\r\nfacades.Orm().Query().Find(&post, 1)\r\n// 条件查询\r\nvar posts []models.Post\r\nfacades.Orm().Query().Where(\\\"title\\\", \\\"My First Post\\\").Get(&posts)\r\n```\r\n### 更新 (Update)\r\n```go\r\nvar post models.Post\r\nfacades.Orm().Query().Find(&post, 1)\r\npost.Title = \\\"Updated Title\\\"\r\nfacades.Orm().Query().Save(&post)\r\n```\r\n### 删除 (Delete)\r\n```go\r\nvar post models.Post\r\nfacades.Orm().Query().Delete(&post, 1)\r\n```\r\nGoravel 的 ORM 让 Go 语言的数据库操作不再繁琐,极大地提高了开发效率。配合之前的路由和控制器知识,你现在已经具备了开发完整 RESTful API 的能力!', 1593, 1, 0, 1768465934, 1768809351);
-- ----------------------------
-- Table structure for role_permissions
@@ -444,6 +541,27 @@ INSERT INTO `roles` VALUES (1, 'admin', '系统管理员', 0, 1768452892, 176853
INSERT INTO `roles` VALUES (2, 'editor', '内容编辑', 0, 1768452892, 1768538956);
INSERT INTO `roles` VALUES (3, 'viewer', '普通访客', 0, 1768452892, 1768538956);
-- ----------------------------
-- Table structure for search_logs
-- ----------------------------
DROP TABLE IF EXISTS `search_logs`;
CREATE TABLE `search_logs` (
`id` bigint UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`keyword` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '搜索关键词',
`search_type` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '搜索类型: category/tag/column/keyword',
`user_ip` varchar(45) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '用户IP地址',
`user_location` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '用户归属地',
`deleted_at` bigint NOT NULL DEFAULT 0,
`created_at` bigint NOT NULL DEFAULT 0,
PRIMARY KEY (`id`) USING BTREE,
INDEX `idx_search_type`(`search_type` ASC) USING BTREE,
INDEX `idx_created_at`(`created_at` ASC) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '搜索记录表' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Records of search_logs
-- ----------------------------
-- ----------------------------
-- Table structure for settings
-- ----------------------------
@@ -494,13 +612,11 @@ CREATE TABLE `snippets` (
-- ----------------------------
-- Records of snippets
-- ----------------------------
INSERT INTO `snippets` VALUES ('1', 'React 鼠标追踪 Hook', 'import { useState, useEffect } from \'react\';\r\n\r\nexport const useMousePosition = () => {\r\n const [pos, setPos] = useState({ x: 0, y: 0 });\r\n useEffect(() => {\r\n const update = (e) => setPos({ x: e.clientX, y: e.clientY });\r\n window.addEventListener(\'mousemove\', update);\r\n return () => window.removeEventListener(\'mousemove\', update);\r\n }, []);\r\n return pos;\r\n};', 'mouse', '这是一个鼠标追踪', 2, 0, 1768350739, 1768538952);
INSERT INTO `snippets` VALUES ('1', 'React 鼠标追踪 Hook', 'import { useState, useEffect } from \'react\';\r\n\r\nexport const useMousePosition = () => {\r\n const [pos, setPos] = useState({ x: 0, y: 0 });\r\n useEffect(() => {\r\n const update = (e) => setPos({ x: e.clientX, y: e.clientY });\r\n window.addEventListener(\'mousemove\', update);\r\n return () => window.removeEventListener(\'mousemove\', update);\r\n }, []);\r\n return pos;\r\n};', 'mouse', '这是一个鼠标追踪', 3, 0, 1768350739, 1768538952);
-- ----------------------------
-- Table structure for tags
-- ----------------------------
-- Ensure table is dropped even if it has dependencies
SET FOREIGN_KEY_CHECKS = 0;
DROP TABLE IF EXISTS `tags`;
CREATE TABLE `tags` (
`id` bigint UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
@@ -513,12 +629,15 @@ CREATE TABLE `tags` (
UNIQUE INDEX `name`(`name` ASC) USING BTREE,
UNIQUE INDEX `slug`(`slug` ASC) USING BTREE,
INDEX `idx_slug`(`slug` ASC) USING BTREE COMMENT '按别名查询索引'
) ENGINE = InnoDB AUTO_INCREMENT = 2 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '标签表' ROW_FORMAT = DYNAMIC;
) ENGINE = InnoDB AUTO_INCREMENT = 5 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '标签表' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Records of tags
-- ----------------------------
INSERT IGNORE INTO `tags` (`id`, `name`, `slug`, `deleted_at`, `created_at`, `updated_at`) VALUES (1, 'Goravel', '', 0, 1768550858, 1768550858);
INSERT INTO `tags` VALUES (1, 'Goravel', '', 0, 1768550858, 1768550858);
INSERT INTO `tags` VALUES (2, '入门教程', '入门教程', 0, 1768809322, 1768809322);
INSERT INTO `tags` VALUES (3, 'Golang框架', 'Golang框架', 0, 1768809341, 1768809341);
INSERT INTO `tags` VALUES (4, '前后端分离', '前后端分离', 0, 1768809349, 1768809349);
-- ----------------------------
-- Table structure for testimonials
@@ -568,12 +687,6 @@ CREATE TABLE `user_access_logs` (
-- ----------------------------
-- Records of user_access_logs
-- ----------------------------
INSERT INTO `user_access_logs` VALUES (13, 0, '::1', 'Unknown', 6, 0, 20260116160835);
INSERT INTO `user_access_logs` VALUES (14, 0, '::1', 'Unknown', 6, 0, 20260116165800);
INSERT INTO `user_access_logs` VALUES (15, 0, '::1', 'Unknown', 6, 0, 20260119133107);
INSERT INTO `user_access_logs` VALUES (16, 0, '::1', 'Unknown', 6, 0, 20260119133147);
INSERT INTO `user_access_logs` VALUES (17, 0, '::1', 'Unknown', 6, 0, 20260119133216);
INSERT INTO `user_access_logs` VALUES (18, 0, '::1', 'Internal', 6, 0, 1768801962);
-- ----------------------------
-- Table structure for users
@@ -602,9 +715,9 @@ CREATE TABLE `users` (
-- ----------------------------
-- Records of users
-- ----------------------------
INSERT IGNORE INTO `users` (`id`, `username`, `email`, `password_hash`, `role_id`, `role`, `is_active`, `deleted_at`, `created_at`, `updated_at`) VALUES (1, 'lq', 'liqiworker@gmail.com', '$2a$14$OqZ7svgz3S68yuuXTJWz1.0CuqI2i3WvwC1jIGBy4l0mcJW9Kqvy2', 1, 'admin', 1, 0, 1768291814, 1768538951);
INSERT IGNORE INTO `users` (`id`, `username`, `email`, `password_hash`, `role_id`, `role`, `is_active`, `deleted_at`, `created_at`, `updated_at`) VALUES (2, 'editor', 'editor@example.com', '$2a$14$OqZ7svgz3S68yuuXTJWz1.0CuqI2i3WvwC1jIGBy4l0mcJW9Kqvy2', 2, 'editor', 1, 0, 1768291814, 1768538951);
INSERT IGNORE INTO `users` (`id`, `username`, `email`, `password_hash`, `role_id`, `role`, `is_active`, `deleted_at`, `created_at`, `updated_at`) VALUES (5, 'cs', 'cs@nailaoyun.cn', '$2a$14$OqZ7svgz3S68yuuXTJWz1.0CuqI2i3WvwC1jIGBy4l0mcJW9Kqvy2', 3, 'viewer', 1, 0, 1768462115, 1768538951);
INSERT INTO `users` VALUES (1, 'lq', 'liqiworker@gmail.com', '$2a$10$Bq6rv7714W3jGyXruYx4puXjflMTSkq2QM9kF54x9iZnk.0AD4B1G', 1, 'admin', 1, 0, 1768291814, 1768538951);
INSERT INTO `users` VALUES (2, 'editor', 'editor@example.com', '$2a$14$OqZ7svgz3S68yuuXTJWz1.0CuqI2i3WvwC1jIGBy4l0mcJW9Kqvy2', 2, 'editor', 1, 0, 1768291814, 1768538951);
INSERT INTO `users` VALUES (5, 'cs', 'cs@nailaoyun.cn', '$2a$14$OqZ7svgz3S68yuuXTJWz1.0CuqI2i3WvwC1jIGBy4l0mcJW9Kqvy2', 3, 'viewer', 1, 0, 1768462115, 1768538951);
-- ----------------------------
-- Table structure for work_gallery
@@ -621,15 +734,18 @@ CREATE TABLE `work_gallery` (
PRIMARY KEY (`id`) USING BTREE,
INDEX `idx_work_id`(`work_id` ASC) USING BTREE,
INDEX `idx_sort_order`(`sort_order` ASC) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 5 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '作品图库表' ROW_FORMAT = DYNAMIC;
) ENGINE = InnoDB AUTO_INCREMENT = 8 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '作品图库表' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Records of work_gallery
-- ----------------------------
INSERT INTO `work_gallery` VALUES (1, 'nova', 'https://images.unsplash.com/photo-1642543492481-44e81e3914a7?q=80&w=2070', 1, 'Nova 交易平台首页', 0, 1768291937);
INSERT INTO `work_gallery` VALUES (2, 'nova', 'https://images.unsplash.com/photo-1551288049-bebda4e38f71?q=80&w=2070', 2, 'Nova 交易平台交易界面', 0, 1768291937);
INSERT INTO `work_gallery` VALUES (1, 'nova', 'https://images.unsplash.com/photo-1642543492481-44e81e3914a7?q=80&w=2070', 1, 'Nova 交易平台首页', 1768825221, 1768291937);
INSERT INTO `work_gallery` VALUES (2, 'nova', 'https://images.unsplash.com/photo-1551288049-bebda4e38f71?q=80&w=2070', 2, 'Nova 交易平台交易界面', 1768825221, 1768291937);
INSERT INTO `work_gallery` VALUES (3, 'archdaily', 'https://images.unsplash.com/photo-1503387762-592deb58ef4e?q=80&w=2089', 1, 'ArchDaily 网站首页', 0, 1768291937);
INSERT INTO `work_gallery` VALUES (4, 'archdaily', 'https://images.unsplash.com/photo-1518005020951-ecc859466abc?q=80&w=1920', 2, 'ArchDaily 文章详情页', 0, 1768291937);
INSERT INTO `work_gallery` VALUES (5, 'nova', 'https://images.unsplash.com/photo-1642543492481-44e81e3914a7?q=80&w=2070', 1, '', 0, 1768825221);
INSERT INTO `work_gallery` VALUES (6, 'nova', 'https://images.unsplash.com/photo-1551288049-bebda4e38f71?q=80&w=2070', 2, '', 0, 1768825221);
INSERT INTO `work_gallery` VALUES (7, 'nova', '/uploads/2026/01/19/cc_upload_HiPDFg6oL0Q5ijfo693ce1f5eb57c_1768825204.jpg', 3, '', 0, 1768825221);
-- ----------------------------
-- Table structure for work_tech_stack
@@ -645,20 +761,25 @@ CREATE TABLE `work_tech_stack` (
PRIMARY KEY (`id`) USING BTREE,
INDEX `idx_work_id`(`work_id` ASC) USING BTREE,
INDEX `idx_category`(`category` ASC) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 10 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '作品技术栈表' ROW_FORMAT = DYNAMIC;
) ENGINE = InnoDB AUTO_INCREMENT = 15 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '作品技术栈表' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Records of work_tech_stack
-- ----------------------------
INSERT INTO `work_tech_stack` VALUES (1, 'nova', '前端层', 'React 18', 0, 1768291937);
INSERT INTO `work_tech_stack` VALUES (2, 'nova', '前端层', 'TypeScript', 0, 1768291937);
INSERT INTO `work_tech_stack` VALUES (3, 'nova', '前端层', 'D3.js', 0, 1768291937);
INSERT INTO `work_tech_stack` VALUES (4, 'nova', '后端服务', 'Golang', 0, 1768291937);
INSERT INTO `work_tech_stack` VALUES (5, 'nova', '后端服务', 'gRPC', 0, 1768291937);
INSERT INTO `work_tech_stack` VALUES (1, 'nova', '前端层', 'React 18', 1768825220, 1768291937);
INSERT INTO `work_tech_stack` VALUES (2, 'nova', '前端层', 'TypeScript', 1768825220, 1768291937);
INSERT INTO `work_tech_stack` VALUES (3, 'nova', '前端层', 'D3.js', 1768825220, 1768291937);
INSERT INTO `work_tech_stack` VALUES (4, 'nova', '后端服务', 'Golang', 1768825220, 1768291937);
INSERT INTO `work_tech_stack` VALUES (5, 'nova', '后端服务', 'gRPC', 1768825220, 1768291937);
INSERT INTO `work_tech_stack` VALUES (6, 'archdaily', '核心前端', 'Vue 3', 0, 1768291937);
INSERT INTO `work_tech_stack` VALUES (7, 'archdaily', '核心前端', 'Nuxt.js', 0, 1768291937);
INSERT INTO `work_tech_stack` VALUES (8, 'archdaily', '核心前端', 'GSAP', 0, 1768291937);
INSERT INTO `work_tech_stack` VALUES (9, 'archdaily', 'CMS', 'Strapi', 0, 1768291937);
INSERT INTO `work_tech_stack` VALUES (10, 'nova', '前端层', 'React 18', 0, 1768825220);
INSERT INTO `work_tech_stack` VALUES (11, 'nova', '前端层', 'TypeScript', 0, 1768825220);
INSERT INTO `work_tech_stack` VALUES (12, 'nova', '前端层', 'D3.js', 0, 1768825220);
INSERT INTO `work_tech_stack` VALUES (13, 'nova', '后端服务', 'Golang', 0, 1768825220);
INSERT INTO `work_tech_stack` VALUES (14, 'nova', '后端服务', 'gRPC', 0, 1768825220);
-- ----------------------------
-- Table structure for works
@@ -671,6 +792,7 @@ CREATE TABLE `works` (
`year` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创作年份',
`hero_img` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '作品主图URL',
`description` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '作品详细描述',
`links` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '作品链接JSON格式',
`is_featured` tinyint(1) NULL DEFAULT 0 COMMENT '是否为精选作品01',
`deleted_at` bigint NOT NULL DEFAULT 0,
`created_at` bigint NOT NULL DEFAULT 0,
@@ -684,104 +806,7 @@ CREATE TABLE `works` (
-- ----------------------------
-- Records of works
-- ----------------------------
INSERT IGNORE INTO `works` VALUES ('archdaily', 'ArchDaily 网站重构', '建筑设计', '2022', 'https://images.unsplash.com/photo-1487958449943-2429e8be8625?q=80&w=2070', 'ArchDaily 是全球最受欢迎的建筑网站之一。这次重构的目标是提升移动端体验。我们使用了 Nuxt 3 进行服务端渲染SSR', 1, 0, 1768291814, 1768538952);
INSERT IGNORE INTO `works` VALUES ('nova', 'Nova 交易平台', '金融科技', '2023', 'https://images.unsplash.com/photo-1487958449943-2429e8be8625?q=80&w=2070', 'Nova 是一个专为机构交易员设计的高频交易终端。我们面临的最大挑战是如何在处理毫秒级市场数据的同时,保持界面的流畅响应。我们采用 Web Worker 来处理繁重的数据计算,避免阻塞主线程。', 1, 0, 1768291814, 1768538952);
-- ----------------------------
-- Table structure for search_logs
-- ----------------------------
DROP TABLE IF EXISTS `search_logs`;
CREATE TABLE `search_logs` (
`id` bigint UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`keyword` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '搜索关键词',
`search_type` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '搜索类型: category/tag/column/keyword',
`user_ip` varchar(45) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '用户IP地址',
`user_location` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '用户归属地',
`deleted_at` bigint NOT NULL DEFAULT 0,
`created_at` bigint NOT NULL DEFAULT 0,
PRIMARY KEY (`id`) USING BTREE,
INDEX `idx_search_type`(`search_type` ASC) USING BTREE,
INDEX `idx_created_at`(`created_at` ASC) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '搜索记录表' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Records of search_logs
-- ----------------------------
-- ----------------------------
-- Table structure for attachment_categories
-- ----------------------------
DROP TABLE IF EXISTS `attachment_categories`;
CREATE TABLE `attachment_categories` (
`id` int UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '分类名称',
`description` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '分类描述',
`sort_order` int UNSIGNED NULL DEFAULT 0 COMMENT '排序',
`deleted_at` bigint NOT NULL DEFAULT 0,
`created_at` bigint NOT NULL DEFAULT 0,
`updated_at` bigint NOT NULL DEFAULT 0,
PRIMARY KEY (`id`) USING BTREE,
INDEX `idx_sort_order`(`sort_order` ASC) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '附件分类表' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Records of attachment_categories
-- ----------------------------
-- ----------------------------
-- Table structure for oss_configs
-- ----------------------------
DROP TABLE IF EXISTS `oss_configs`;
CREATE TABLE `oss_configs` (
`id` int UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '配置名称',
`storage_type` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '存储类型: local/qcloud/aliyun/qiniu',
`access_key` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'Access Key (AES加密)',
`secret_key` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'Secret Key (AES加密)',
`bucket` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '存储桶名称',
`region` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '区域',
`domain` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '访问域名',
`is_active` tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否启用',
`deleted_at` bigint NOT NULL DEFAULT 0,
`created_at` bigint NOT NULL DEFAULT 0,
`updated_at` bigint NOT NULL DEFAULT 0,
PRIMARY KEY (`id`) USING BTREE,
INDEX `idx_storage_type`(`storage_type` ASC) USING BTREE,
INDEX `idx_is_active`(`is_active` ASC) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = 'OSS配置表' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Records of oss_configs
-- ----------------------------
-- ----------------------------
-- Table structure for attachments
-- ----------------------------
DROP TABLE IF EXISTS `attachments`;
CREATE TABLE `attachments` (
`id` bigint UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`category_id` int UNSIGNED NULL DEFAULT NULL COMMENT '附件分类ID',
`original_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原始文件名',
`stored_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '存储文件名',
`file_path` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '文件路径',
`file_url` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '文件访问URL',
`file_size` bigint UNSIGNED NOT NULL DEFAULT 0 COMMENT '文件大小(字节)',
`file_type` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '文件类型: image/video/document/other',
`mime_type` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT 'MIME类型',
`storage_type` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'local' COMMENT '存储类型: local/qcloud/aliyun/qiniu',
`oss_config_id` int UNSIGNED NULL DEFAULT NULL COMMENT 'OSS配置ID',
`deleted_at` bigint NOT NULL DEFAULT 0,
`created_at` bigint NOT NULL DEFAULT 0,
`updated_at` bigint NOT NULL DEFAULT 0,
PRIMARY KEY (`id`) USING BTREE,
INDEX `idx_category_id`(`category_id` ASC) USING BTREE,
INDEX `idx_storage_type`(`storage_type` ASC) USING BTREE,
INDEX `idx_file_type`(`file_type` ASC) USING BTREE,
INDEX `idx_created_at`(`created_at` ASC) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '附件表' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Records of attachments
-- ----------------------------
INSERT INTO `works` VALUES ('archdaily', 'ArchDaily 网站重构', '建筑设计', '2022', 'https://images.unsplash.com/photo-1487958449943-2429e8be8625?q=80&w=2070', 'ArchDaily 是全球最受欢迎的建筑网站之一。这次重构的目标是提升移动端体验。我们使用了 Nuxt 3 进行服务端渲染SSR', NULL, 0, 0, 1768291814, 1768812789);
INSERT INTO `works` VALUES ('nova', 'Nova 交易平台', '金融科技', '2023', 'https://images.unsplash.com/photo-1487958449943-2429e8be8625?q=80&w=2070', 'Nova 是一个专为机构交易员设计的高频交易终端。我们面临的最大挑战是如何在处理毫秒级市场数据的同时,保持界面的流畅响应。我们采用 Web Worker 来处理繁重的数据计算,避免阻塞主线程。', '{\"demo\":\"http://localhost:3000/works/nova\",\"github\":\"http://localhost:3000/works/nova\",\"live\":\"http://localhost:3000/works/nova\"}', 0, 0, 1768291814, 1768825220);
SET FOREIGN_KEY_CHECKS = 1;

View File

@@ -78,6 +78,30 @@ func GetAttachmentByID(id uint) (*models.Attachment, error) {
return &attachment, nil
}
// UpdateAttachment 更新附件
func UpdateAttachment(attachment *models.Attachment) error {
updateData := map[string]interface{}{
"updated_at": time.Now().Unix(),
}
if attachment.CategoryID != nil {
updateData["category_id"] = attachment.CategoryID
} else {
// 如果CategoryID为nil设置为NULL
updateData["category_id"] = nil
}
err := config.DB.Model(&models.Attachment{}).
Where("id = ? AND deleted_at = ?", attachment.ID, 0).
Updates(updateData).Error
if err != nil {
log.Printf("Error updating attachment: %v", err)
return err
}
return nil
}
// DeleteAttachment 删除附件(软删除)
func DeleteAttachment(id uint) error {
err := config.DB.Model(&models.Attachment{}).
@@ -213,6 +237,24 @@ func GetActiveOSSConfig(storageType string) (*models.OSSConfig, error) {
return &ossConfig, nil
}
// GetDefaultLocalOSSConfig 获取默认本地存储配置
func GetDefaultLocalOSSConfig() (*models.OSSConfig, error) {
var ossConfig models.OSSConfig
err := config.DB.Model(&models.OSSConfig{}).
Where("deleted_at = ? AND name = ? AND storage_type = ?", 0, "本地存储", "local").
First(&ossConfig).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
log.Printf("Error getting default local OSS config: %v", err)
return nil, err
}
return &ossConfig, nil
}
// UpdateOSSConfig 更新OSS配置
func UpdateOSSConfig(ossConfig *models.OSSConfig) error {
err := config.DB.Model(&models.OSSConfig{}).

View File

@@ -1,6 +1,7 @@
package repositories
import (
"encoding/json"
"log"
"time"
@@ -100,6 +101,17 @@ func BuildWorkResponse(work *models.Work) (*models.WorkResponse, error) {
galleryImages = append(galleryImages, g.ImageURL)
}
// 解析links JSON
var linksData map[string]interface{}
if work.Links != "" {
if err := json.Unmarshal([]byte(work.Links), &linksData); err != nil {
log.Printf("Error parsing links JSON: %v", err)
linksData = map[string]interface{}{"live": ""}
}
} else {
linksData = map[string]interface{}{"live": ""}
}
// 获取下一个作品ID
nextWorkID, err := GetNextWorkID(work.ID)
if err != nil {
@@ -116,10 +128,8 @@ func BuildWorkResponse(work *models.Work) (*models.WorkResponse, error) {
Desc: work.Description,
TechStack: techStackResponse,
Gallery: galleryImages,
Links: map[string]interface{}{
"live": "#",
},
Next: nextWorkID,
Links: linksData,
Next: nextWorkID,
}, nil
}
@@ -155,6 +165,78 @@ func GetNextWorkID(currentID string) (string, error) {
return ids[index+1], nil
}
// CreateWorkTechStack 创建作品技术栈
func CreateWorkTechStack(workID string, techStack []map[string]interface{}) error {
// 先删除旧的技术栈(软删除)
err := config.DB.Model(&models.WorkTechStack{}).
Where("work_id = ?", workID).
Update("deleted_at", time.Now().Unix()).Error
if err != nil {
log.Printf("Error deleting old tech stack: %v", err)
}
// 创建新的技术栈
for _, categoryData := range techStack {
category, ok := categoryData["category"].(string)
if !ok {
continue
}
items, ok := categoryData["items"].([]interface{})
if !ok {
continue
}
for _, item := range items {
itemStr, ok := item.(string)
if !ok || itemStr == "" {
continue
}
techStackItem := models.WorkTechStack{
WorkID: workID,
Category: category,
Item: itemStr,
CreatedAt: time.Now().Unix(),
DeletedAt: 0,
}
if err := config.DB.Create(&techStackItem).Error; err != nil {
log.Printf("Error creating tech stack item: %v", err)
return err
}
}
}
return nil
}
// CreateWorkGallery 创建作品图库
func CreateWorkGallery(workID string, gallery []string) error {
// 先删除旧的图库(软删除)
err := config.DB.Model(&models.WorkGallery{}).
Where("work_id = ?", workID).
Update("deleted_at", time.Now().Unix()).Error
if err != nil {
log.Printf("Error deleting old gallery: %v", err)
}
// 创建新的图库
for index, imageURL := range gallery {
if imageURL == "" {
continue
}
galleryItem := models.WorkGallery{
WorkID: workID,
ImageURL: imageURL,
SortOrder: uint(index + 1),
Description: "",
CreatedAt: time.Now().Unix(),
DeletedAt: 0,
}
if err := config.DB.Create(&galleryItem).Error; err != nil {
log.Printf("Error creating gallery item: %v", err)
return err
}
}
return nil
}
// CreateWork 创建作品
func CreateWork(work *models.Work) error {
err := config.DB.Create(work).Error
@@ -167,17 +249,21 @@ func CreateWork(work *models.Work) error {
// UpdateWork 更新作品
func UpdateWork(work *models.Work) error {
updateData := map[string]interface{}{
"title": work.Title,
"category": work.Category,
"year": work.Year,
"hero_img": work.HeroImg,
"description": work.Description,
"is_featured": work.IsFeatured,
"updated_at": time.Now().Unix(),
}
if work.Links != "" {
updateData["links"] = work.Links
}
err := config.DB.Model(&models.Work{}).
Where("id = ? AND deleted_at = ?", work.ID, 0).
Updates(map[string]interface{}{
"title": work.Title,
"category": work.Category,
"year": work.Year,
"hero_img": work.HeroImg,
"description": work.Description,
"is_featured": work.IsFeatured,
"updated_at": time.Now().Unix(),
}).Error
Updates(updateData).Error
if err != nil {
log.Printf("Error updating work: %v", err)
return err

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View File

@@ -1,13 +1,22 @@
package utils
import (
"bytes"
"context"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"github.com/aliyun/aliyun-oss-go-sdk/oss"
"github.com/qiniu/go-sdk/v7/auth/qbox"
"github.com/qiniu/go-sdk/v7/storage"
cos "github.com/tencentyun/cos-go-sdk-v5"
)
// StorageType 存储类型
@@ -23,11 +32,30 @@ const (
// OSSConfig OSS配置
type OSSConfig struct {
StorageType string
AccessKey string
SecretKey string
Bucket string
Region string
Domain string
// 通用字段(向后兼容)
AccessKey string
SecretKey string
Bucket string
Region string
Domain string
// 阿里云OSS专用字段
OSSAccessKeyID string
OSSAccessKeySecret string
OSSEndpoint string
OSSBucket string
OSSDomain string
// 腾讯云COS专用字段
QCloudSecretID string
QCloudSecretKey string
QCloudRegion string
QCloudBucket string
QCloudDomain string
// 七牛云专用字段
QiniuAccessKey string
QiniuSecretKey string
QiniuBucket string
QiniuRegion string
QiniuDomain string
}
// OSSUploader OSS上传接口
@@ -45,14 +73,173 @@ func GetOSSUploader(config *OSSConfig) (OSSUploader, error) {
BaseURL: "/uploads",
}, nil
case StorageQCloud:
// TODO: 实现腾讯云COS上传
return nil, fmt.Errorf("qcloud storage not implemented yet")
// 优先使用专用字段,如果为空则使用通用字段(向后兼容)
secretID := config.QCloudSecretID
secretKey := config.QCloudSecretKey
region := config.QCloudRegion
bucket := config.QCloudBucket
domain := config.QCloudDomain
if secretID == "" {
secretID = config.AccessKey
}
if secretKey == "" {
secretKey = config.SecretKey
}
if region == "" {
region = config.Region
}
if bucket == "" {
bucket = config.Bucket
}
if domain == "" {
domain = config.Domain
}
if secretID == "" || secretKey == "" || bucket == "" || region == "" {
return nil, fmt.Errorf("qcloud config incomplete: secretID, secretKey, bucket, region are required")
}
u, err := url.Parse(fmt.Sprintf("https://%s.cos.%s.myqcloud.com", bucket, region))
if err != nil {
return nil, fmt.Errorf("invalid qcloud config: %v", err)
}
b := &cos.BaseURL{BucketURL: u}
client := cos.NewClient(b, &http.Client{
Transport: &cos.AuthorizationTransport{
SecretID: secretID,
SecretKey: secretKey,
},
})
return &QCloudUploader{
Client: client,
Bucket: bucket,
Domain: domain,
}, nil
case StorageAliyun:
// TODO: 实现阿里云OSS上传
return nil, fmt.Errorf("aliyun storage not implemented yet")
// 优先使用专用字段,如果为空则使用通用字段(向后兼容)
accessKeyID := config.OSSAccessKeyID
accessKeySecret := config.OSSAccessKeySecret
endpoint := config.OSSEndpoint
bucket := config.OSSBucket
domain := config.OSSDomain
if accessKeyID == "" {
accessKeyID = config.AccessKey
}
if accessKeySecret == "" {
accessKeySecret = config.SecretKey
}
if bucket == "" {
bucket = config.Bucket
}
if domain == "" {
domain = config.Domain
}
if accessKeyID == "" || accessKeySecret == "" || bucket == "" {
return nil, fmt.Errorf("aliyun config incomplete: accessKeyID, accessKeySecret, bucket are required")
}
// 如果endpoint为空从region构建向后兼容
if endpoint == "" {
region := config.Region
if region == "" {
return nil, fmt.Errorf("aliyun config incomplete: endpoint or region is required")
}
endpoint = fmt.Sprintf("https://oss-%s.aliyuncs.com", region)
}
client, err := oss.New(endpoint, accessKeyID, accessKeySecret)
if err != nil {
return nil, fmt.Errorf("failed to create aliyun oss client: %v", err)
}
ossBucket, err := client.Bucket(bucket)
if err != nil {
return nil, fmt.Errorf("failed to get aliyun bucket: %v", err)
}
return &AliyunUploader{
Bucket: ossBucket,
Domain: domain,
}, nil
case StorageQiniu:
// TODO: 实现七牛云上传
return nil, fmt.Errorf("qiniu storage not implemented yet")
// 优先使用专用字段,如果为空则使用通用字段(向后兼容)
accessKey := config.QiniuAccessKey
secretKey := config.QiniuSecretKey
bucket := config.QiniuBucket
region := config.QiniuRegion
domain := config.QiniuDomain
if accessKey == "" {
accessKey = config.AccessKey
}
if secretKey == "" {
secretKey = config.SecretKey
}
if bucket == "" {
bucket = config.Bucket
}
if domain == "" {
domain = config.Domain
}
if accessKey == "" || secretKey == "" || bucket == "" {
return nil, fmt.Errorf("qiniu config incomplete: accessKey, secretKey, bucket are required")
}
mac := qbox.NewMac(accessKey, secretKey)
// 根据region选择Zone如果region为空使用通用字段的region否则默认华东
var zone *storage.Zone
if region != "" {
switch region {
case "z0", "华东":
zone = &storage.ZoneHuadong
case "z1", "华北":
zone = &storage.ZoneHuabei
case "z2", "华南":
zone = &storage.ZoneHuanan
case "na0", "北美":
zone = &storage.ZoneBeimei
case "as0", "东南亚":
zone = &storage.ZoneXinjiapo
default:
zone = &storage.ZoneHuadong // 默认华东
}
} else if config.Region != "" {
// 使用通用字段的region
switch config.Region {
case "z0", "华东":
zone = &storage.ZoneHuadong
case "z1", "华北":
zone = &storage.ZoneHuabei
case "z2", "华南":
zone = &storage.ZoneHuanan
case "na0", "北美":
zone = &storage.ZoneBeimei
case "as0", "东南亚":
zone = &storage.ZoneXinjiapo
default:
zone = &storage.ZoneHuadong
}
} else {
zone = &storage.ZoneHuadong // 默认华东
}
cfg := storage.Config{
Zone: zone,
UseHTTPS: true,
UseCdnDomains: false,
}
formUploader := storage.NewFormUploader(&cfg)
bucketManager := storage.NewBucketManager(mac, &cfg)
return &QiniuUploader{
FormUploader: formUploader,
BucketManager: bucketManager,
Bucket: bucket,
Domain: domain,
Mac: mac,
}, nil
default:
return nil, fmt.Errorf("unsupported storage type: %s", config.StorageType)
}
@@ -122,6 +309,160 @@ func (l *LocalUploader) Delete(filePath string) error {
return os.Remove(filePath)
}
// AliyunUploader 阿里云OSS上传器
type AliyunUploader struct {
Bucket *oss.Bucket
Domain string
}
// Upload 上传文件到阿里云OSS
func (a *AliyunUploader) Upload(file multipart.File, filename string, size int64) (string, string, error) {
// 生成唯一文件名
ext := filepath.Ext(filename)
timestamp := time.Now().Unix()
randomStr := fmt.Sprintf("%d", timestamp)
newFilename := fmt.Sprintf("%s_%s%s", strings.TrimSuffix(filename, ext), randomStr, ext)
// 按日期创建目录
dateDir := time.Now().Format("2006/01/02")
objectKey := fmt.Sprintf("%s/%s", dateDir, newFilename)
// 上传文件
err := a.Bucket.PutObject(objectKey, file)
if err != nil {
return "", "", fmt.Errorf("failed to upload to aliyun oss: %v", err)
}
// 生成访问URL
var fileURL string
if a.Domain != "" {
fileURL = fmt.Sprintf("%s/%s", strings.TrimSuffix(a.Domain, "/"), objectKey)
} else {
// 从endpoint中提取region格式为 https://oss-region.aliyuncs.com
endpoint := a.Bucket.Client.Config.Endpoint
fileURL = fmt.Sprintf("%s/%s", endpoint, objectKey)
}
return objectKey, fileURL, nil
}
// Delete 删除阿里云OSS文件
func (a *AliyunUploader) Delete(objectKey string) error {
err := a.Bucket.DeleteObject(objectKey)
if err != nil {
return fmt.Errorf("failed to delete from aliyun oss: %v", err)
}
return nil
}
// QCloudUploader 腾讯云COS上传器
type QCloudUploader struct {
Client *cos.Client
Bucket string
Domain string
}
// Upload 上传文件到腾讯云COS
func (q *QCloudUploader) Upload(file multipart.File, filename string, size int64) (string, string, error) {
// 生成唯一文件名
ext := filepath.Ext(filename)
timestamp := time.Now().Unix()
randomStr := fmt.Sprintf("%d", timestamp)
newFilename := fmt.Sprintf("%s_%s%s", strings.TrimSuffix(filename, ext), randomStr, ext)
// 按日期创建目录
dateDir := time.Now().Format("2006/01/02")
objectKey := fmt.Sprintf("%s/%s", dateDir, newFilename)
// 上传文件
_, err := q.Client.Object.Put(context.Background(), objectKey, file, nil)
if err != nil {
return "", "", fmt.Errorf("failed to upload to qcloud cos: %v", err)
}
// 生成访问URL
var fileURL string
if q.Domain != "" {
fileURL = fmt.Sprintf("%s/%s", strings.TrimSuffix(q.Domain, "/"), objectKey)
} else {
// 使用BucketURL生成URL
fileURL = fmt.Sprintf("%s/%s", q.Client.BaseURL.BucketURL.String(), objectKey)
}
return objectKey, fileURL, nil
}
// Delete 删除腾讯云COS文件
func (q *QCloudUploader) Delete(objectKey string) error {
_, err := q.Client.Object.Delete(context.Background(), objectKey)
if err != nil {
return fmt.Errorf("failed to delete from qcloud cos: %v", err)
}
return nil
}
// QiniuUploader 七牛云上传器
type QiniuUploader struct {
FormUploader *storage.FormUploader
BucketManager *storage.BucketManager
Bucket string
Domain string
Mac *qbox.Mac
}
// Upload 上传文件到七牛云
func (q *QiniuUploader) Upload(file multipart.File, filename string, size int64) (string, string, error) {
// 生成唯一文件名
ext := filepath.Ext(filename)
timestamp := time.Now().Unix()
randomStr := fmt.Sprintf("%d", timestamp)
newFilename := fmt.Sprintf("%s_%s%s", strings.TrimSuffix(filename, ext), randomStr, ext)
// 按日期创建目录
dateDir := time.Now().Format("2006/01/02")
key := fmt.Sprintf("%s/%s", dateDir, newFilename)
// 生成上传凭证
putPolicy := storage.PutPolicy{
Scope: q.Bucket,
}
upToken := putPolicy.UploadToken(q.Mac)
// 读取文件内容
fileData := make([]byte, size)
_, err := file.Read(fileData)
if err != nil {
return "", "", fmt.Errorf("failed to read file: %v", err)
}
// 上传文件
ret := storage.PutRet{}
err = q.FormUploader.Put(context.Background(), &ret, upToken, key, bytes.NewReader(fileData), size, nil)
if err != nil {
return "", "", fmt.Errorf("failed to upload to qiniu: %v", err)
}
// 生成访问URL
var fileURL string
if q.Domain != "" {
fileURL = fmt.Sprintf("%s/%s", strings.TrimSuffix(q.Domain, "/"), key)
} else {
// 七牛云需要配置域名如果没有配置则返回key
fileURL = key
}
return key, fileURL, nil
}
// Delete 删除七牛云文件
func (q *QiniuUploader) Delete(key string) error {
err := q.BucketManager.Delete(q.Bucket, key)
if err != nil {
return fmt.Errorf("failed to delete from qiniu: %v", err)
}
return nil
}
// GetFileType 根据MIME类型判断文件类型
func GetFileType(mimeType string) string {
if strings.HasPrefix(mimeType, "image/") {