数据结构优化

This commit is contained in:
李琦
2026-01-19 16:14:08 +08:00
parent 6d789c7c2a
commit 68c4c6df1c
36 changed files with 4063 additions and 760 deletions

View File

@@ -21,6 +21,14 @@
>
思考
</button>
<button
@click="goTo('/columns')"
class="nav-item text-sm font-medium tracking-wide text-art-muted hover:text-white transition-colors link-underline"
:class="{ 'text-white after:w-full': activeNav === 'columns' }"
data-target="columns"
>
专栏
</button>
<button
@click="goTo('/works')"
class="nav-item text-sm font-medium tracking-wide text-art-muted hover:text-white transition-colors link-underline"

View File

@@ -0,0 +1,159 @@
<template>
<div class="image-uploader">
<div
v-if="!imageUrl"
@click="triggerFileInput"
@dragover.prevent
@dragenter.prevent
@drop.prevent="handleDrop"
class="upload-area border-2 border-dashed border-white/20 rounded-lg p-8 text-center cursor-pointer hover:border-art-accent transition-colors"
:class="{ 'border-art-accent': isDragging }"
>
<input
ref="fileInput"
type="file"
accept="image/*"
@change="handleFileSelect"
class="hidden"
/>
<div class="space-y-2">
<i data-lucide="upload" class="w-12 h-12 mx-auto text-art-muted"></i>
<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" />
<div class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity rounded-lg flex items-center justify-center gap-2">
<button
@click="removeImage"
class="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600 transition-colors"
>
删除
</button>
<button
@click="triggerFileInput"
class="px-4 py-2 bg-art-accent text-white rounded hover:bg-opacity-80 transition-colors"
>
更换
</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-500 text-sm">
{{ error }}
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useToast } from '../composables/useToast'
const props = defineProps<{
modelValue?: string
categoryId?: number
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const fileInput = ref<HTMLInputElement | null>(null)
const imageUrl = ref<string>(props.modelValue || '')
const uploading = ref(false)
const error = ref('')
const isDragging = ref(false)
const toast = useToast()
watch(() => props.modelValue, (newVal) => {
imageUrl.value = newVal || ''
})
const triggerFileInput = () => {
fileInput.value?.click()
}
const handleFileSelect = (e: Event) => {
const target = e.target as HTMLInputElement
if (target.files && target.files[0]) {
uploadFile(target.files[0])
}
}
const handleDrop = (e: DragEvent) => {
isDragging.value = false
if (e.dataTransfer?.files && e.dataTransfer.files[0]) {
uploadFile(e.dataTransfer.files[0])
}
}
const uploadFile = async (file: File) => {
if (!file.type.startsWith('image/')) {
error.value = '请选择图片文件'
return
}
uploading.value = true
error.value = ''
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('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()
imageUrl.value = data.result.fileUrl
emit('update:modelValue', data.result.fileUrl)
toast.showToast('图片上传成功', 'success')
} catch (err: any) {
error.value = err.message || '上传失败,请重试'
toast.showToast(error.value, 'error')
} finally {
uploading.value = false
}
}
const removeImage = () => {
imageUrl.value = ''
emit('update:modelValue', '')
if (fileInput.value) {
fileInput.value.value = ''
}
}
</script>
<style scoped>
.upload-area {
min-height: 200px;
display: flex;
align-items: center;
justify-content: center;
}
</style>

View File

@@ -240,6 +240,7 @@ const menuItems = ref<MenuItem[]>([
isOpen: false,
children: [
{ title: '全局配置', path: '/admin/settings', icon: '🛠️' },
{ title: '附件管理', path: '/admin/attachments', icon: '📎' },
{ title: '操作日志', path: '/admin/logs', icon: '📋' }
]
}

View File

@@ -0,0 +1,347 @@
<template>
<div v-if="isOpen" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm" @click.self="close">
<div class="bg-[#1a1a1a] border border-white/10 rounded-lg shadow-xl w-full max-w-2xl max-h-[90vh] overflow-hidden animate-reveal flex flex-col">
<div class="p-4 border-b border-white/10 flex justify-between items-center shrink-0">
<h3 class="text-lg font-serif italic text-white">管理文章关联</h3>
<button @click="close" class="text-white/50 hover:text-white">
<i data-lucide="x" class="w-5 h-5"></i>
</button>
</div>
<div class="p-6 space-y-6 overflow-y-auto flex-1">
<div>
<p class="text-sm text-art-muted">文章: <span class="text-white font-medium">{{ post?.title }}</span></p>
</div>
<!-- Category Selection -->
<div class="space-y-3">
<div class="flex items-center justify-between">
<label class="block text-sm font-medium text-white">分类</label>
<button @click="showCreateCategory = true" class="text-xs text-art-accent hover:text-white transition-colors">
+ 新建分类
</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>
<!-- Quick Create Category -->
<div v-if="showCreateCategory" class="admin-card p-3 space-y-2">
<input
v-model="newCategory.name"
placeholder="分类名称"
class="admin-input text-sm"
@keyup.enter="createCategory"
/>
<div class="flex gap-2">
<button @click="createCategory" class="admin-btn-primary text-xs px-3 py-1">创建</button>
<button @click="showCreateCategory = false" class="admin-btn-secondary text-xs px-3 py-1">取消</button>
</div>
</div>
</div>
<!-- Column Selection -->
<div class="space-y-3">
<div class="flex items-center justify-between">
<label class="block text-sm font-medium text-white">专栏 <span class="text-art-muted text-xs">(可选)</span></label>
<button @click="showCreateColumn = true" class="text-xs text-art-accent hover:text-white transition-colors">
+ 新建专栏
</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>
<!-- Quick Create Column -->
<div v-if="showCreateColumn" class="admin-card p-3 space-y-2">
<input
v-model="newColumn.name"
placeholder="专栏名称"
class="admin-input text-sm"
@keyup.enter="createColumn"
/>
<input
v-model="newColumn.cover"
placeholder="封面图片URL"
class="admin-input text-sm"
/>
<textarea
v-model="newColumn.description"
placeholder="描述"
rows="2"
class="admin-input text-sm"
/>
<div class="flex gap-2">
<button @click="createColumn" class="admin-btn-primary text-xs px-3 py-1">创建</button>
<button @click="showCreateColumn = false" class="admin-btn-secondary text-xs px-3 py-1">取消</button>
</div>
</div>
</div>
<!-- Tags Selection -->
<div class="space-y-3">
<div class="flex items-center justify-between">
<label class="block text-sm font-medium text-white">标签 <span class="text-art-muted text-xs">(可多选)</span></label>
<button @click="showCreateTag = true" class="text-xs text-art-accent hover:text-white transition-colors">
+ 新建标签
</button>
</div>
<div class="flex flex-wrap gap-2 min-h-[60px] p-3 bg-white/5 rounded border border-white/10">
<button
v-for="tag in tags"
:key="tag.id"
@click="toggleTag(tag.id)"
class="px-3 py-1 text-sm rounded border transition-colors"
:class="selectedTagIds.includes(tag.id)
? 'bg-art-accent/20 border-art-accent text-art-accent'
: 'bg-white/5 border-white/10 text-art-muted hover:border-white/30'"
>
{{ tag.name }}
</button>
<div v-if="tags.length === 0" class="text-xs text-art-muted italic">
暂无标签
</div>
</div>
<!-- Quick Create Tag -->
<div v-if="showCreateTag" class="admin-card p-3 space-y-2">
<input
v-model="newTag.name"
placeholder="标签名称"
class="admin-input text-sm"
@keyup.enter="createTag"
/>
<div class="flex gap-2">
<button @click="createTag" class="admin-btn-primary text-xs px-3 py-1">创建</button>
<button @click="showCreateTag = false" class="admin-btn-secondary text-xs px-3 py-1">取消</button>
</div>
</div>
</div>
</div>
<div class="p-4 border-t border-white/10 flex justify-end gap-3 bg-white/5 shrink-0">
<button @click="close" class="px-4 py-2 text-sm text-white/70 hover:text-white transition-colors">取消</button>
<button @click="save" :disabled="saving" class="admin-btn-primary py-1.5 px-4 text-sm">
{{ saving ? '保存中...' : '保存' }}
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import {
fetchCategories,
fetchColumns,
fetchTags,
createCategory as createCategoryApi,
createColumn as createColumnApi,
createTag as createTagApi,
updatePost,
Category,
Column,
Tag,
Post
} from '../../services/api'
import { useToast } from '../../composables/useToast'
const props = defineProps<{
isOpen: boolean
post: Post | null
}>()
const emit = defineEmits<{
'update:isOpen': [value: boolean]
'saved': []
}>()
const toast = useToast()
const categories = ref<Category[]>([])
const columns = ref<Column[]>([])
const tags = ref<Tag[]>([])
const selectedCategoryId = ref(0)
const selectedColumnId = ref(0)
const selectedTagIds = ref<number[]>([])
const showCreateCategory = ref(false)
const showCreateColumn = ref(false)
const showCreateTag = ref(false)
const newCategory = ref({ name: '', slug: '', description: '' })
const newColumn = ref({ name: '', cover: '', description: '', isActive: 1, sortOrder: 0 })
const newTag = ref({ name: '', slug: '' })
const saving = ref(false)
const close = () => {
emit('update:isOpen', false)
showCreateCategory.value = false
showCreateColumn.value = false
showCreateTag.value = false
}
const loadData = async () => {
try {
const [cats, cols, tagList] = await Promise.all([
fetchCategories(),
fetchColumns(),
fetchTags()
])
categories.value = cats
columns.value = cols
tags.value = tagList
} catch (err: any) {
toast.showToast('加载数据失败', 'error')
}
}
const initForm = () => {
if (props.post) {
selectedCategoryId.value = props.post.categoryId || 0
selectedColumnId.value = props.post.columnId || 0
selectedTagIds.value = props.post.tags?.map(t => t.id) || []
}
}
const toggleTag = (tagId: number) => {
const index = selectedTagIds.value.indexOf(tagId)
if (index === -1) {
selectedTagIds.value.push(tagId)
} else {
selectedTagIds.value.splice(index, 1)
}
}
const createCategory = async () => {
if (!newCategory.value.name) {
toast.showToast('请输入分类名称', 'error')
return
}
try {
await createCategoryApi({
name: newCategory.value.name,
slug: newCategory.value.slug || newCategory.value.name,
description: newCategory.value.description || ''
})
toast.showToast('分类创建成功', 'success')
const createdName = newCategory.value.name
newCategory.value = { name: '', slug: '', description: '' }
showCreateCategory.value = false
await loadData()
// 自动选择新创建的分类
const newCat = categories.value.find(c => c.name === createdName)
if (newCat) {
selectedCategoryId.value = newCat.id
}
} catch (err: any) {
toast.showToast(err.message || '创建失败', 'error')
}
}
const createColumn = async () => {
if (!newColumn.value.name) {
toast.showToast('请输入专栏名称', 'error')
return
}
try {
await createColumnApi(newColumn.value)
toast.showToast('专栏创建成功', 'success')
const createdName = newColumn.value.name
newColumn.value = { name: '', cover: '', description: '', isActive: 1, sortOrder: 0 }
showCreateColumn.value = false
await loadData()
// 自动选择新创建的专栏
const newCol = columns.value.find(c => c.name === createdName)
if (newCol) {
selectedColumnId.value = newCol.id
}
} catch (err: any) {
toast.showToast(err.message || '创建失败', 'error')
}
}
const createTag = async () => {
if (!newTag.value.name) {
toast.showToast('请输入标签名称', 'error')
return
}
try {
await createTagApi({
name: newTag.value.name,
slug: newTag.value.slug || newTag.value.name
})
toast.showToast('标签创建成功', 'success')
const createdName = newTag.value.name
newTag.value = { name: '', slug: '' }
showCreateTag.value = false
await loadData()
// 自动选择新创建的标签
const newTagItem = tags.value.find(t => t.name === createdName)
if (newTagItem && !selectedTagIds.value.includes(newTagItem.id)) {
selectedTagIds.value.push(newTagItem.id)
}
} catch (err: any) {
toast.showToast(err.message || '创建失败', 'error')
}
}
const save = async () => {
if (!props.post) return
saving.value = true
try {
const payload: any = {
title: props.post.title,
categoryId: selectedCategoryId.value,
date: props.post.date,
excerpt: props.post.excerpt || '',
content: props.post.content || '',
isPublished: props.post.isPublished || 1,
tags: selectedTagIds.value.map(id => ({ id }))
}
if (selectedColumnId.value > 0) {
payload.columnId = selectedColumnId.value
} else {
payload.columnId = null
}
await updatePost(props.post.id, payload)
toast.showToast('保存成功', 'success')
emit('saved')
close()
} catch (err: any) {
toast.showToast(err.message || '保存失败', 'error')
} finally {
saving.value = false
}
}
watch(() => props.isOpen, (open) => {
if (open) {
loadData()
initForm()
}
})
watch(() => props.post, () => {
if (props.isOpen) {
initForm()
}
})
onMounted(() => {
if (props.isOpen) {
loadData()
initForm()
}
})
</script>

View File

@@ -6,7 +6,7 @@
<p class="text-art-muted max-w-lg mx-auto mb-8">关于前端技术交互设计以及数字艺术的深度思考</p>
<!-- Search UI -->
<div class="max-w-md mx-auto relative">
<div class="max-w-md mx-auto relative mb-6">
<input
type="text"
v-model="searchQuery"
@@ -28,6 +28,41 @@
<i data-lucide="search" class="w-5 h-5"></i>
</button>
</div>
<!-- Filter UI -->
<div class="max-w-2xl mx-auto flex flex-wrap gap-4 justify-center items-center">
<div class="flex items-center gap-2">
<label class="text-sm text-art-muted">分类:</label>
<div class="w-40">
<CustomSelect
v-model="selectedCategoryId"
:options="categoryOptions"
placeholder="全部"
@update:modelValue="applyFilters"
/>
</div>
</div>
<div class="flex items-center gap-2">
<label class="text-sm text-art-muted">标签:</label>
<div class="w-40">
<CustomSelect
v-model="selectedTagId"
:options="tagOptions"
placeholder="全部"
@update:modelValue="applyFilters"
/>
</div>
</div>
<button
v-if="hasActiveFilters"
@click="clearFilters"
class="text-xs text-art-accent hover:text-white transition-colors px-3 py-1 border border-art-accent/30 rounded hover:border-art-accent"
>
清除筛选
</button>
</div>
</div>
<!-- Loading state -->
@@ -83,29 +118,58 @@
</template>
<script setup lang="ts">
import { ref, onMounted, nextTick } from 'vue'
import { fetchPosts, Post } from '../services/api'
import { ref, onMounted, nextTick, computed } from 'vue'
import { fetchPosts, fetchCategories, fetchTags, Post, Category, Tag } from '../services/api'
import { useScrollAnimation } from '../composables/useScrollAnimation'
import CustomSelect from '../components/CustomSelect.vue'
const blogPosts = ref<Post[]>([])
const loading = ref(false)
const error = ref('')
const searchQuery = ref('')
const isSearching = ref(false)
const selectedCategoryId = ref(0)
const selectedTagId = ref(0)
const categories = ref<Category[]>([])
const tags = ref<Tag[]>([])
const { initObserver } = useScrollAnimation()
const hasActiveFilters = computed(() => {
return selectedCategoryId.value > 0 || selectedTagId.value > 0
})
// 计算属性:分类选项
const categoryOptions = computed(() => {
return [
{ value: 0, label: '全部' },
...categories.value.map(cat => ({ value: cat.id, label: cat.name }))
]
})
// 计算属性:标签选项
const tagOptions = computed(() => {
return [
{ value: 0, label: '全部' },
...tags.value.map(tag => ({ value: tag.id, label: tag.name }))
]
})
const refreshIcons = () => {
if ((window as any).lucide) {
(window as any).lucide.createIcons()
}
}
const fetchBlogPosts = async (query?: string) => {
const fetchBlogPosts = async () => {
loading.value = true
error.value = ''
try {
const posts = await fetchPosts(query)
const query = searchQuery.value.trim() || undefined
const categoryId = selectedCategoryId.value > 0 ? selectedCategoryId.value : undefined
const tagId = selectedTagId.value > 0 ? selectedTagId.value : undefined
const posts = await fetchPosts(query, categoryId, tagId)
blogPosts.value = posts
nextTick(() => {
initObserver()
@@ -122,7 +186,7 @@ const fetchBlogPosts = async (query?: string) => {
const handleSearch = () => {
if (!searchQuery.value.trim() && !isSearching.value) return
isSearching.value = !!searchQuery.value.trim()
fetchBlogPosts(searchQuery.value)
fetchBlogPosts()
}
const clearSearch = () => {
@@ -131,6 +195,29 @@ const clearSearch = () => {
fetchBlogPosts()
}
const applyFilters = () => {
fetchBlogPosts()
}
const clearFilters = () => {
selectedCategoryId.value = 0
selectedTagId.value = 0
fetchBlogPosts()
}
const loadFilters = async () => {
try {
const [cats, tagList] = await Promise.all([
fetchCategories(),
fetchTags()
])
categories.value = cats
tags.value = tagList
} catch (err) {
console.error('Error loading filters:', err)
}
}
const highlightText = (text: string | undefined) => {
if (!text) return ''
if (!isSearching.value || !searchQuery.value) return text
@@ -140,6 +227,7 @@ const highlightText = (text: string | undefined) => {
}
onMounted(() => {
loadFilters()
fetchBlogPosts()
refreshIcons()
})

View File

@@ -68,7 +68,7 @@
<!-- 文章信息 -->
<div class="border-b border-white/5 pb-10">
<div class="flex items-center gap-4 mb-6">
<span class="px-3 py-1 border border-white/20 rounded-full text-xs font-mono text-art-accent uppercase tracking-wider">{{ post.category }}</span>
<span class="px-3 py-1 border border-white/20 rounded-full text-xs font-mono text-art-accent uppercase tracking-wider">{{ post.categoryName || '未分类' }}</span>
<span class="text-sm text-art-muted">{{ post.date }}</span>
</div>
<h1 class="font-serif text-4xl md:text-5xl lg:text-6xl text-white leading-tight mb-8">{{ post.title }}</h1>

View File

@@ -11,24 +11,52 @@
<div class="mb-16 text-center">
<div class="inline-block mb-4 px-3 py-1 rounded-full border border-art-accent/30 text-art-accent text-xs font-mono">专栏</div>
<h2 class="font-serif text-4xl italic text-white mb-6">{{ column.name }}</h2>
<p class="text-art-muted max-w-lg mx-auto leading-relaxed">{{ column.description }}</p>
<p class="text-art-muted max-w-lg mx-auto leading-relaxed mb-6">{{ column.description }}</p>
<!-- 统计信息 -->
<div class="flex items-center justify-center gap-6 text-sm text-art-muted">
<div class="flex items-center gap-2">
<i data-lucide="file-text" class="w-4 h-4"></i>
<span>{{ posts.length }} 篇文章</span>
</div>
<div v-if="lastUpdated" class="flex items-center gap-2">
<i data-lucide="clock" class="w-4 h-4"></i>
<span>最近更新{{ formatDate(lastUpdated) }}</span>
</div>
</div>
</div>
<!-- Posts List (Placeholder for now as we don't have column_posts API integrated fully yet) -->
<!-- Since we don't have GetPostsByColumn API yet, we might need to fetch all posts and filter,
or update backend to support fetching posts by column.
The SQL has `column_posts` table. I need to check if backend `GetColumn` returns posts or if I need a separate endpoint.
-->
<!-- Posts List -->
<div v-if="posts.length > 0" class="space-y-8">
<article
v-for="post in posts"
:key="post.id"
class="group cursor-pointer border-b border-white/5 pb-8"
@click="router.push('/blog/' + post.id)"
>
<div class="flex flex-col md:flex-row gap-6 items-start">
<div class="md:w-1/4 pt-2">
<span class="font-mono text-xs text-art-accent block mb-1">{{ post.categoryName || '未分类' }}</span>
<span class="text-sm text-art-muted">{{ post.date }}</span>
<div v-if="post.tags && post.tags.length > 0" class="flex flex-wrap gap-2 mt-2">
<span v-for="tag in post.tags" :key="tag.id" class="text-xs text-white/40 bg-white/5 px-1 rounded">#{{ tag.name }}</span>
</div>
</div>
<div class="md:w-3/4 space-y-3">
<h3 class="font-serif text-2xl text-white group-hover:text-art-accent transition-colors leading-tight">
{{ post.title }}
</h3>
<p class="text-art-muted font-light leading-relaxed line-clamp-2">
{{ post.excerpt }}
</p>
<div class="text-xs text-art-accent font-medium mt-4 group-hover:text-white transition-colors">阅读全文 -></div>
</div>
</div>
</article>
</div>
<div class="space-y-8">
<div class="text-center py-10 border border-dashed border-white/10 rounded-lg">
<p class="text-art-muted">专栏文章列表功能开发中...</p>
<!--
TODO: Fetch posts associated with this column.
Backend needs to expose this.
For now, I'll just show the column info.
-->
</div>
<div v-else class="text-center py-10 border border-dashed border-white/10 rounded-lg">
<p class="text-art-muted">该专栏暂无文章</p>
</div>
</div>
@@ -40,22 +68,105 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { fetchColumn, Column } from '../services/api'
import { ref, onMounted, nextTick, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { fetchColumn, fetchColumnPosts, Column, Post } from '../services/api'
import { useScrollAnimation } from '../composables/useScrollAnimation'
const route = useRoute()
const router = useRouter()
const column = ref<Column | null>(null)
const posts = ref<Post[]>([])
const loading = ref(true)
const { initObserver } = useScrollAnimation()
// 计算最近更新时间
const lastUpdated = computed(() => {
if (posts.value.length === 0) {
// 如果没有文章,使用专栏的更新时间
if (column.value?.updatedAt) {
const timestamp = typeof column.value.updatedAt === 'string'
? parseInt(column.value.updatedAt)
: column.value.updatedAt
return new Date(timestamp * 1000)
}
return null
}
// 找到所有文章中最新的日期
const dates = posts.value
.map(post => {
// 使用 post.date它通常是格式化的日期字符串
if (!post.date) return null
// 尝试解析日期字符串
const date = new Date(post.date)
if (isNaN(date.getTime())) return null
return date
})
.filter((date): date is Date => date !== null)
if (dates.length === 0) {
// 如果无法解析文章日期,使用专栏更新时间
if (column.value?.updatedAt) {
const timestamp = typeof column.value.updatedAt === 'string'
? parseInt(column.value.updatedAt)
: column.value.updatedAt
return new Date(timestamp * 1000)
}
return null
}
// 返回最新的日期
return new Date(Math.max(...dates.map(d => d.getTime())))
})
// 格式化日期
const formatDate = (date: Date | null): string => {
if (!date) return ''
const now = new Date()
const diff = now.getTime() - date.getTime()
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
if (days === 0) return '今天'
if (days === 1) return '昨天'
if (days < 7) return `${days} 天前`
if (days < 30) return `${Math.floor(days / 7)} 周前`
if (days < 365) return `${Math.floor(days / 30)} 个月前`
// 超过一年,显示具体日期
return date.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' })
}
onMounted(async () => {
try {
const id = parseInt(route.params.id as string)
column.value = await fetchColumn(id)
const [colData, postsData] = await Promise.all([
fetchColumn(id),
fetchColumnPosts(id)
])
column.value = colData
posts.value = postsData
// 初始化动画观察者
await nextTick()
initObserver()
// 刷新图标
if ((window as any).lucide) {
(window as any).lucide.createIcons()
}
} catch (error) {
console.error('Failed to fetch column:', error)
} finally {
loading.value = false
// 确保在加载完成后也初始化动画
await nextTick()
initObserver()
if ((window as any).lucide) {
(window as any).lucide.createIcons()
}
}
})
</script>

View File

@@ -48,23 +48,32 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, onMounted, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { fetchColumns, Column } from '../services/api'
import { useScrollAnimation } from '../composables/useScrollAnimation'
const router = useRouter()
const columns = ref<Column[]>([])
const loading = ref(true)
const { initObserver } = useScrollAnimation()
onMounted(async () => {
try {
const data = await fetchColumns()
// Filter active columns
columns.value = data.filter(c => c.isActive === 1)
// 初始化动画观察者
await nextTick()
initObserver()
} catch (error) {
console.error('Failed to fetch columns:', error)
} finally {
loading.value = false
// 确保在加载完成后也初始化动画
await nextTick()
initObserver()
}
})
</script>

View File

@@ -52,7 +52,7 @@
<div class="absolute inset-0 bg-[url('https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?q=80&w=2564&auto=format&fit=crop')] bg-cover bg-center transition-transform duration-700 group-hover:scale-105 opacity-60 mix-blend-overlay"></div>
<div class="relative z-20 space-y-2">
<div class="flex items-center gap-3">
<span class="px-2 py-1 text-[10px] font-mono border border-white/20 rounded-full text-white backdrop-blur-sm">{{ featuredPost.category }}</span>
<span class="px-2 py-1 text-[10px] font-mono border border-white/20 rounded-full text-white backdrop-blur-sm">{{ featuredPost.categoryName || '未分类' }}</span>
<span class="text-xs text-white/60">{{ featuredPost.date }}</span>
</div>
<h3 class="font-serif text-3xl text-white group-hover:text-art-accent transition-colors">当极简主义遇见复杂数据Dashboard 设计哲学</h3>

View File

@@ -0,0 +1,304 @@
<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="showUploadModal = true" class="admin-btn-primary">
上传附件
</button>
</div>
<!-- Filters -->
<div class="admin-card p-4 mb-6 flex gap-4 items-center">
<div class="flex items-center gap-2">
<label class="text-sm text-art-muted">分类:</label>
<div class="w-48">
<CustomSelect
v-model="filterCategoryId"
:options="categoryOptions"
placeholder="全部"
@update:modelValue="loadAttachments"
/>
</div>
</div>
<div class="flex items-center gap-2">
<label class="text-sm text-art-muted">类型:</label>
<div class="w-48">
<CustomSelect
v-model="filterFileType"
:options="fileTypeOptions"
placeholder="全部"
@update:modelValue="loadAttachments"
/>
</div>
</div>
</div>
<!-- Attachments Grid -->
<div v-if="loading" class="flex justify-center items-center h-64">
<div class="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-art-accent"></div>
</div>
<div v-else-if="attachments.length === 0" class="text-center py-20 text-art-muted">
<p>暂无附件</p>
</div>
<div v-else class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
<div
v-for="attachment in attachments"
:key="attachment.id"
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">
<img :src="attachment.fileUrl" :alt="attachment.originalName" class="w-full h-full object-cover" />
</div>
<div v-else class="aspect-square mb-2 rounded bg-white/5 flex items-center justify-center">
<i data-lucide="file" class="w-12 h-12 text-art-muted"></i>
</div>
<p class="text-xs text-white truncate mb-1" :title="attachment.originalName">
{{ attachment.originalName }}
</p>
<p class="text-xs text-art-muted mb-2">
{{ formatFileSize(attachment.fileSize) }}
</p>
<div class="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
@click.stop="copyUrl(attachment.fileUrl)"
class="flex-1 px-2 py-1 text-xs bg-white/5 hover:bg-white/10 rounded transition-colors"
>
复制链接
</button>
<button
@click.stop="deleteAttachment(attachment.id)"
class="px-2 py-1 text-xs bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded transition-colors"
>
删除
</button>
</div>
</div>
</div>
<!-- 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">
<h2 class="text-xl font-serif italic text-white mb-4">上传附件</h2>
<div class="space-y-4">
<div>
<label class="block text-sm text-art-muted mb-2">选择文件</label>
<input
type="file"
ref="uploadInput"
@change="handleUploadFile"
class="admin-input"
/>
</div>
<div>
<label class="block text-sm text-art-muted mb-2">分类可选</label>
<CustomSelect
v-model="uploadCategoryId"
:options="uploadCategoryOptions"
placeholder="无分类"
/>
</div>
<div class="flex gap-3">
<button @click="showUploadModal = false" class="admin-btn-secondary flex-1">
取消
</button>
<button @click="uploadFile" :disabled="uploading" class="admin-btn-primary flex-1">
{{ uploading ? '上传中...' : '上传' }}
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useToast } from '../../composables/useToast'
import CustomSelect from '../../components/CustomSelect.vue'
const toast = useToast()
interface Attachment {
id: number
originalName: string
fileUrl: string
fileSize: number
fileType: string
categoryId?: number
}
interface Category {
id: number
name: string
}
const attachments = ref<Attachment[]>([])
const categories = ref<Category[]>([])
const loading = ref(false)
const showUploadModal = ref(false)
const uploadInput = ref<HTMLInputElement | null>(null)
const uploadCategoryId = ref(0)
const uploading = ref(false)
const filterCategoryId = ref(0)
const filterFileType = ref('')
// 计算属性:分类选项
const categoryOptions = computed(() => {
return [
{ value: 0, label: '全部' },
...categories.value.map(cat => ({ value: cat.id, label: cat.name }))
]
})
// 计算属性:文件类型选项
const fileTypeOptions = computed(() => {
return [
{ value: '', label: '全部' },
{ value: 'image', label: '图片' },
{ value: 'video', label: '视频' },
{ value: 'document', label: '文档' },
{ value: 'other', label: '其他' }
]
})
// 计算属性:上传分类选项
const uploadCategoryOptions = computed(() => {
return [
{ value: 0, label: '无分类' },
...categories.value.map(cat => ({ value: cat.id, label: cat.name }))
]
})
const API_BASE = 'http://localhost:8081/api'
const getAuthHeaders = () => {
const token = localStorage.getItem('token')
return {
...(token ? { Authorization: `Bearer ${token}` } : {})
}
}
const loadAttachments = async () => {
loading.value = true
try {
let url = `${API_BASE}/admin/attachments?pageSize=100`
if (filterCategoryId.value > 0) {
url += `&categoryId=${filterCategoryId.value}`
}
if (filterFileType.value) {
url += `&fileType=${filterFileType.value}`
}
const response = await fetch(url, {
headers: getAuthHeaders()
})
if (!response.ok) throw new Error('获取附件列表失败')
const data = await response.json()
attachments.value = data.result.list || []
} catch (err: any) {
toast.showToast(err.message || '加载失败', 'error')
} finally {
loading.value = false
}
}
const loadCategories = async () => {
try {
const response = await fetch(`${API_BASE}/admin/attachment-categories`, {
headers: getAuthHeaders()
})
if (!response.ok) throw new Error('获取分类失败')
const data = await response.json()
categories.value = data.result || []
} catch (err) {
console.error('Failed to load categories:', err)
}
}
const handleUploadFile = () => {
// File selection handled in uploadFile
}
const uploadFile = async () => {
if (!uploadInput.value?.files || uploadInput.value.files.length === 0) {
toast.showToast('请选择文件', 'error')
return
}
uploading.value = true
try {
const formData = new FormData()
formData.append('file', uploadInput.value.files[0])
if (uploadCategoryId.value > 0) {
formData.append('categoryId', uploadCategoryId.value.toString())
}
formData.append('storageType', 'local')
const response = await fetch(`${API_BASE}/admin/attachments/upload`, {
method: 'POST',
headers: getAuthHeaders(),
body: formData
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.message || '上传失败')
}
toast.showToast('上传成功', 'success')
showUploadModal.value = false
uploadInput.value.value = ''
uploadCategoryId.value = 0
loadAttachments()
} catch (err: any) {
toast.showToast(err.message || '上传失败', 'error')
} finally {
uploading.value = false
}
}
const deleteAttachment = async (id: number) => {
if (!confirm('确定要删除这个附件吗?')) return
try {
const response = await fetch(`${API_BASE}/admin/attachments/${id}`, {
method: 'DELETE',
headers: getAuthHeaders()
})
if (!response.ok) throw new Error('删除失败')
toast.showToast('删除成功', 'success')
loadAttachments()
} catch (err: any) {
toast.showToast(err.message || '删除失败', 'error')
}
}
const copyUrl = (url: string) => {
navigator.clipboard.writeText(url)
toast.showToast('链接已复制', 'success')
}
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'
}
onMounted(() => {
loadCategories()
loadAttachments()
})
</script>

View File

@@ -95,6 +95,16 @@
</div>
</div>
<!-- Column Field -->
<div class="space-y-2">
<label for="column" class="block text-xs font-medium text-art-muted uppercase tracking-wider">专栏 <span class="text-art-muted text-xs">(可选)</span></label>
<CustomSelect
v-model="form.columnId"
:options="columns"
placeholder="选择专栏(可选)"
/>
</div>
<!-- Tags Field -->
<div class="space-y-2">
<label class="block text-xs font-medium text-art-muted uppercase tracking-wider">标签</label>
@@ -157,7 +167,7 @@ import { MdEditor } from 'md-editor-v3'
import 'md-editor-v3/lib/style.css'
import { useToast } from '../../composables/useToast'
import { createPost, updatePost, fetchPost, fetchCategories, fetchTags, Category, Tag } from '../../services/api'
import { createPost, updatePost, fetchPost, fetchCategories, fetchColumns, fetchTags, Category, Tag } from '../../services/api'
import CustomSelect from '../../components/CustomSelect.vue'
const router = useRouter()
@@ -177,12 +187,14 @@ const errors = reactive<Record<string, string>>({})
// Data sources
const categories = ref<{value: number, label: string}[]>([])
const columns = ref<{value: number, label: string}[]>([])
const availableTags = ref<Tag[]>([])
// Form data
const form = reactive({
title: '',
categoryId: 0,
columnId: 0,
date: new Date().toISOString().split('T')[0],
excerpt: '',
content: '',
@@ -254,7 +266,7 @@ const handleSubmit = async () => {
try {
// Construct payload
const payload = {
const payload: any = {
title: form.title,
categoryId: form.categoryId,
date: form.date,
@@ -264,6 +276,13 @@ const handleSubmit = async () => {
tags: form.tagIds.map(id => ({ id } as any)) // Backend expects objects with ID
}
// Add columnId if selected
if (form.columnId > 0) {
payload.columnId = form.columnId
} else {
payload.columnId = null
}
if (isEditing.value) {
// Update existing post
await updatePost(route.params.id as string, payload)
@@ -292,9 +311,10 @@ const handleCancel = () => {
// Load initial data
const loadData = async () => {
try {
// Fetch categories and tags in parallel
const [cats, tags] = await Promise.all([
// Fetch categories, columns and tags in parallel
const [cats, cols, tagList] = await Promise.all([
fetchCategories(),
fetchColumns(),
fetchTags()
])
@@ -303,7 +323,12 @@ const loadData = async () => {
label: c.name
}))
availableTags.value = tags
columns.value = cols.map(c => ({
value: c.id,
label: c.name
}))
availableTags.value = tagList
// If editing, load post data
if (isEditing.value) {
@@ -313,6 +338,7 @@ const loadData = async () => {
// Populate form with post data
form.title = post.title
form.categoryId = post.categoryId
form.columnId = post.columnId || 0
form.date = post.date
form.excerpt = post.excerpt || ''
form.content = post.content || ''

View File

@@ -16,6 +16,8 @@
<th class="w-20">ID</th>
<th>标题</th>
<th>分类</th>
<th>专栏</th>
<th>标签</th>
<th>发布日期</th>
<th>状态</th>
<th class="text-right">操作</th>
@@ -26,6 +28,25 @@
<td class="font-mono text-xs text-white/40">#{{ post.id }}</td>
<td class="font-medium text-white group-hover:text-art-accent transition-colors">{{ post.title }}</td>
<td><span class="px-2 py-1 rounded bg-white/5 border border-white/5 text-xs text-art-muted">{{ post.categoryName || post.category?.name || '未分类' }}</span></td>
<td>
<span v-if="post.columnName || post.column?.name" class="px-2 py-1 rounded bg-art-accent/10 border border-art-accent/20 text-xs text-art-accent">
{{ post.columnName || post.column?.name }}
</span>
<span v-else class="text-art-muted text-xs">-</span>
</td>
<td>
<div v-if="post.tags && post.tags.length > 0" class="flex flex-wrap gap-1">
<span
v-for="tag in post.tags.slice(0, 3)"
:key="tag.id"
class="px-2 py-0.5 rounded bg-white/5 border border-white/5 text-xs text-art-muted"
>
#{{ tag.name }}
</span>
<span v-if="post.tags.length > 3" class="text-art-muted text-xs">+{{ post.tags.length - 3 }}</span>
</div>
<span v-else class="text-art-muted text-xs">-</span>
</td>
<td class="text-art-muted text-xs">{{ post.date }}</td>
<td>
<button
@@ -38,6 +59,9 @@
</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="openRelationModal(post)" class="admin-btn-secondary py-1 px-3 text-xs">
管理
</button>
<router-link :to="`/admin/posts/${post.id}/edit`" class="admin-btn-secondary py-1 px-3 text-xs">
编辑
</router-link>
@@ -61,33 +85,12 @@
</div>
</div>
<!-- Category Modal -->
<div v-if="isCategoryModalOpen" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm" @click.self="closeCategoryModal">
<div class="bg-[#1a1a1a] border border-white/10 rounded-lg shadow-xl w-full max-w-md overflow-hidden animate-reveal">
<div class="p-4 border-b border-white/10 flex justify-between items-center">
<h3 class="text-lg font-serif italic text-white">修改文章分类</h3>
<button @click="closeCategoryModal" class="text-white/50 hover:text-white">
<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 space-y-4">
<p class="text-sm text-art-muted">正在修改文章: <span class="text-white">{{ editingPost?.title }}</span></p>
<div class="space-y-2">
<label class="block text-xs font-medium text-art-muted uppercase tracking-wider">选择分类</label>
<CustomSelect
v-model="selectedCategoryId"
:options="categories"
placeholder="请选择分类"
/>
</div>
</div>
<div class="p-4 border-t border-white/10 flex justify-end gap-3 bg-white/5">
<button @click="closeCategoryModal" class="px-4 py-2 text-sm text-white/70 hover:text-white transition-colors">取消</button>
<button @click="saveCategory" class="admin-btn-primary py-1.5 px-4 text-sm">保存</button>
</div>
</div>
</div>
<!-- Post Relation Modal -->
<PostRelationModal
v-model:isOpen="isRelationModalOpen"
:post="editingPost"
@saved="fetchPosts"
/>
</div>
</template>
@@ -96,10 +99,18 @@ import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { getAdminPosts, deletePost as deletePostApi, Post, togglePostStatus } from '../../services/api'
import { useToast } from '../../composables/useToast'
import PostRelationModal from '../../components/admin/PostRelationModal.vue'
const router = useRouter()
const toast = useToast()
const posts = ref<Post[]>([])
const isRelationModalOpen = ref(false)
const editingPost = ref<Post | null>(null)
const openRelationModal = (post: Post) => {
editingPost.value = post
isRelationModalOpen.value = true
}
const fetchPosts = async () => {
try {

View File

@@ -2,9 +2,38 @@
<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>
<!-- Optional: Add Create Button if needed -->
<!-- <button @click="openCreateModal" class="admin-btn-primary">+ 新增配置</button> -->
</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 class="admin-card overflow-hidden">
<div class="overflow-x-auto">
@@ -101,19 +130,241 @@
</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 } from 'vue'
import { ref, onMounted, computed } 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: '',
@@ -186,8 +437,126 @@ 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

@@ -4,6 +4,8 @@ const routes = [
{ path: '/', name: 'home', component: () => import('./pages/Home.vue') },
{ path: '/blog', name: 'blog', component: () => import('./pages/Blog.vue') },
{ path: '/blog/:id', name: 'blog-detail', component: () => import('./pages/BlogDetail.vue') },
{ path: '/columns', name: 'columns', component: () => import('./pages/Columns.vue') },
{ path: '/columns/:id', name: 'column-detail', component: () => import('./pages/ColumnDetail.vue') },
{ path: '/works', name: 'works', component: () => import('./pages/Works.vue') },
{ path: '/works/:id', name: 'work-detail', component: () => import('./pages/WorkDetail.vue') },
{ path: '/snippets', name: 'snippets', component: () => import('./pages/Snippets.vue') },
@@ -89,7 +91,10 @@ const routes = [
{ path: 'settings', name: 'admin-settings', component: () => import('./pages/admin/Settings.vue') },
// 操作日志
{ path: 'logs', name: 'admin-logs', component: () => import('./pages/admin/Logs.vue') }
{ path: 'logs', name: 'admin-logs', component: () => import('./pages/admin/Logs.vue') },
// 附件管理
{ path: 'attachments', name: 'admin-attachments', component: () => import('./pages/admin/Attachments.vue') }
]
}
]

View File

@@ -94,6 +94,8 @@ export interface Post {
categoryName?: string // Display name
categorySlug?: string
category?: Category // Optional full object
columnId?: number | null
columnName?: string
tags?: Tag[]
date: string
excerpt?: string
@@ -515,13 +517,14 @@ export const getAdminPosts = async (): Promise<PaginationResponse<Post>> => {
}
}
export const fetchPosts = async (query?: string, categoryId?: number, tagId?: number): Promise<Post[]> => {
export const fetchPosts = async (query?: string, categoryId?: number, tagId?: number, columnId?: number): Promise<Post[]> => {
try {
let url = `${API_BASE}/posts`
const params = new URLSearchParams()
if (query) params.append('q', query)
if (categoryId) params.append('category', categoryId.toString())
if (tagId) params.append('tag', tagId.toString())
if (columnId) params.append('column', columnId.toString())
if (params.toString()) {
url += `?${params.toString()}`
@@ -1440,6 +1443,21 @@ export interface Inquiry {
createdAt?: string
}
// OSS配置相关类型
export interface OSSConfig {
id: number
name: string
storageType: string // local/qcloud/aliyun/qiniu
accessKey: string // 加密存储,返回时显示为 ***
secretKey: string // 加密存储,返回时显示为 ***
bucket: string
region: string
domain: string
isActive: number
createdAt: string
updatedAt: string
}
export interface EmailSuffix {
id: number
suffix: string
@@ -1500,3 +1518,75 @@ export const fetchInquiries = async (): Promise<Inquiry[]> => {
throw error
}
}
// OSS配置相关API
export const getOSSConfigs = async (): Promise<OSSConfig[]> => {
try {
const response = await fetch(`${API_BASE}/admin/oss-configs`, {
headers: getAuthHeaders()
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.message || '获取OSS配置失败')
}
const data = await response.json()
return data.result || []
} catch (error) {
console.error('Get OSS configs error:', error)
throw error
}
}
export const createOSSConfig = async (configData: Omit<OSSConfig, 'id' | 'createdAt' | 'updatedAt'>): Promise<OSSConfig> => {
try {
const response = await fetch(`${API_BASE}/admin/oss-configs`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(configData)
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.message || '创建OSS配置失败')
}
const data = await response.json()
return data.result
} catch (error) {
console.error('Create OSS config error:', error)
throw error
}
}
export const updateOSSConfig = async (id: number, configData: Partial<Omit<OSSConfig, 'id' | 'createdAt' | 'updatedAt'>>): Promise<OSSConfig> => {
try {
const response = await fetch(`${API_BASE}/admin/oss-configs/${id}`, {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify(configData)
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.message || '更新OSS配置失败')
}
const data = await response.json()
return data.result
} catch (error) {
console.error('Update OSS config error:', error)
throw error
}
}
export const deleteOSSConfig = async (id: number): Promise<void> => {
try {
const response = await fetch(`${API_BASE}/admin/oss-configs/${id}`, {
method: 'DELETE',
headers: getAuthHeaders()
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.message || '删除OSS配置失败')
}
} catch (error) {
console.error('Delete OSS config error:', error)
throw error
}
}