数据结构优化

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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