2026-01-19 16:14:08 +08:00
|
|
|
<template>
|
2026-01-19 21:19:35 +08:00
|
|
|
<Teleport to="body">
|
|
|
|
|
<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">
|
2026-01-19 16:14:08 +08:00
|
|
|
<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>
|
2026-01-19 20:21:09 +08:00
|
|
|
<CustomSelect
|
|
|
|
|
v-model="selectedCategoryId"
|
|
|
|
|
:options="categoryOptions"
|
|
|
|
|
placeholder="请选择分类"
|
|
|
|
|
/>
|
2026-01-19 16:14:08 +08:00
|
|
|
|
|
|
|
|
<!-- 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>
|
2026-01-19 20:21:09 +08:00
|
|
|
<CustomSelect
|
|
|
|
|
v-model="selectedColumnId"
|
|
|
|
|
:options="columnOptions"
|
|
|
|
|
placeholder="不选择专栏"
|
|
|
|
|
/>
|
2026-01-19 16:14:08 +08:00
|
|
|
|
|
|
|
|
<!-- 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"
|
|
|
|
|
/>
|
2026-01-23 16:05:23 +08:00
|
|
|
<div>
|
|
|
|
|
<label class="block text-xs text-art-muted mb-2">封面图片</label>
|
|
|
|
|
<ImageUpload
|
|
|
|
|
v-model="newColumn.cover"
|
|
|
|
|
:categoryId="1"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2026-01-19 16:14:08 +08:00
|
|
|
<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>
|
2026-01-19 21:19:35 +08:00
|
|
|
</Teleport>
|
2026-01-19 16:14:08 +08:00
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
2026-01-19 20:21:09 +08:00
|
|
|
import { ref, watch, onMounted, computed } from 'vue'
|
2026-01-19 16:14:08 +08:00
|
|
|
import {
|
|
|
|
|
fetchCategories,
|
|
|
|
|
fetchColumns,
|
|
|
|
|
fetchTags,
|
|
|
|
|
createCategory as createCategoryApi,
|
|
|
|
|
createColumn as createColumnApi,
|
|
|
|
|
createTag as createTagApi,
|
2026-01-23 16:05:23 +08:00
|
|
|
updatePostRelations,
|
2026-01-19 16:14:08 +08:00
|
|
|
Category,
|
|
|
|
|
Column,
|
|
|
|
|
Tag,
|
|
|
|
|
Post
|
|
|
|
|
} from '../../services/api'
|
|
|
|
|
import { useToast } from '../../composables/useToast'
|
2026-01-19 20:21:09 +08:00
|
|
|
import CustomSelect from '../CustomSelect.vue'
|
2026-01-23 16:05:23 +08:00
|
|
|
import ImageUpload from './ImageUpload.vue'
|
2026-01-19 16:14:08 +08:00
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
2026-01-19 20:21:09 +08:00
|
|
|
// 计算属性:分类选项
|
|
|
|
|
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 }))
|
|
|
|
|
]
|
|
|
|
|
})
|
|
|
|
|
|
2026-01-19 16:14:08 +08:00
|
|
|
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,
|
2026-01-20 09:13:51 +08:00
|
|
|
description: newCategory.value.description || '',
|
|
|
|
|
sortOrder: 0
|
2026-01-19 16:14:08 +08:00
|
|
|
})
|
|
|
|
|
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 {
|
2026-01-23 16:05:23 +08:00
|
|
|
// 只更新关联关系,不更新内容
|
|
|
|
|
await updatePostRelations(props.post.id, {
|
2026-01-19 16:14:08 +08:00
|
|
|
categoryId: selectedCategoryId.value,
|
2026-01-23 16:05:23 +08:00
|
|
|
columnId: selectedColumnId.value > 0 ? selectedColumnId.value : null,
|
|
|
|
|
tagIds: selectedTagIds.value
|
|
|
|
|
})
|
|
|
|
|
|
2026-01-19 16:14:08 +08:00
|
|
|
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>
|