数据结构优化
This commit is contained in:
@@ -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
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
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
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
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
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user