数据结构优化
@@ -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: '📋' }
|
||||
]
|
||||
}
|
||||
|
||||
204
client/src/components/admin/AttachmentDetailModal.vue
Normal 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>
|
||||
386
client/src/components/admin/ImageUpload.vue
Normal 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">支持 JPG、PNG、GIF 格式</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>
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
98
client/src/pages/admin/AttachmentCategories.vue
Normal 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>
|
||||
130
client/src/pages/admin/AttachmentCategoryForm.vue
Normal 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>
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
555
client/src/pages/admin/OSSConfigs.vue
Normal 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>
|
||||
@@ -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()
|
||||
|
||||
@@ -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 = ``
|
||||
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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
×
|
||||
</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%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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') }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
// 附件分类管理
|
||||
|
||||
176
server/migrations/add_default_oss_configs.sql
Normal 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);
|
||||
3
server/migrations/add_work_links_field.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
-- 为works表添加links字段(如果不存在)
|
||||
ALTER TABLE `works`
|
||||
ADD COLUMN IF NOT EXISTS `links` TEXT NULL COMMENT '作品链接(JSON格式)' AFTER `description`;
|
||||
@@ -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 指定表名
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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\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\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 '是否为精选作品(0:否,1:是)',
|
||||
`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;
|
||||
|
||||
@@ -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{}).
|
||||
|
||||
@@ -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
|
||||
|
||||
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 298 KiB |
|
After Width: | Height: | Size: 232 KiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 206 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 40 KiB |
@@ -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/") {
|
||||
|
||||