优化页面、修复BUG
This commit is contained in:
134
client/src/components/AuthorInfo.vue
Normal file
134
client/src/components/AuthorInfo.vue
Normal file
@@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<div
|
||||
class="author-info flex items-center gap-2 min-w-0"
|
||||
:class="[
|
||||
variant === 'compact' ? 'gap-2' : 'gap-3 md:gap-4',
|
||||
themeClasses.wrapper
|
||||
]"
|
||||
>
|
||||
<div
|
||||
class="shrink-0 overflow-hidden flex items-center justify-center border rounded-full"
|
||||
:class="[avatarSizeClass, themeClasses.avatarBorder]"
|
||||
>
|
||||
<img
|
||||
v-if="avatar"
|
||||
:src="avatar"
|
||||
:alt="displayName"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="font-semibold uppercase"
|
||||
:class="[placeholderTextClass, themeClasses.placeholder]"
|
||||
>
|
||||
{{ placeholderLetter }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<div
|
||||
class="font-medium truncate"
|
||||
:class="[nameTextClass, themeClasses.name]"
|
||||
>
|
||||
{{ displayName }}
|
||||
</div>
|
||||
<div
|
||||
v-if="showEmail && displayEmail"
|
||||
class="truncate"
|
||||
:class="[emailTextClass, themeClasses.email]"
|
||||
>
|
||||
{{ displayEmail }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="variant === 'full' && !displayEmail && userId"
|
||||
class="truncate opacity-60"
|
||||
:class="[emailTextClass, themeClasses.email]"
|
||||
>
|
||||
用户#{{ userId }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
name?: string
|
||||
email?: string
|
||||
avatar?: string
|
||||
userId?: number
|
||||
size?: 'sm' | 'md'
|
||||
variant?: 'full' | 'compact'
|
||||
theme?: 'default' | 'onDark' | 'onImage'
|
||||
showEmail?: boolean
|
||||
}>(), {
|
||||
size: 'sm',
|
||||
variant: 'compact',
|
||||
theme: 'default',
|
||||
showEmail: true
|
||||
})
|
||||
|
||||
const displayName = computed(() => {
|
||||
if (props.name) return props.name
|
||||
if (props.userId) return `用户#${props.userId}`
|
||||
return '未知作者'
|
||||
})
|
||||
|
||||
const displayEmail = computed(() => props.email || '')
|
||||
|
||||
const placeholderLetter = computed(() => {
|
||||
const source = props.name || (props.userId ? String(props.userId) : '?')
|
||||
return source.charAt(0).toUpperCase()
|
||||
})
|
||||
|
||||
const avatarSizeClass = computed(() => {
|
||||
if (props.variant === 'full') {
|
||||
return props.size === 'md' ? 'w-10 h-10 md:w-12 md:h-12' : 'w-8 h-8 md:w-10 md:h-10'
|
||||
}
|
||||
return props.size === 'md' ? 'w-8 h-8' : 'w-6 h-6'
|
||||
})
|
||||
|
||||
const nameTextClass = computed(() => {
|
||||
if (props.variant === 'full') return 'text-xs md:text-sm'
|
||||
return props.size === 'md' ? 'text-sm' : 'text-xs'
|
||||
})
|
||||
|
||||
const emailTextClass = computed(() => {
|
||||
if (props.variant === 'full') return 'text-[10px] md:text-xs'
|
||||
return 'text-[10px]'
|
||||
})
|
||||
|
||||
const placeholderTextClass = computed(() => {
|
||||
return props.size === 'md' ? 'text-xs' : 'text-[10px]'
|
||||
})
|
||||
|
||||
const themeClasses = computed(() => {
|
||||
switch (props.theme) {
|
||||
case 'onImage':
|
||||
return {
|
||||
wrapper: '',
|
||||
avatarBorder: 'border-white/30 bg-white/10',
|
||||
placeholder: 'text-white/80',
|
||||
name: 'text-white',
|
||||
email: 'text-white/70'
|
||||
}
|
||||
case 'onDark':
|
||||
return {
|
||||
wrapper: '',
|
||||
avatarBorder: 'border-white/20 bg-white/5',
|
||||
placeholder: 'text-art-accent',
|
||||
name: 'text-white',
|
||||
email: 'text-art-muted'
|
||||
}
|
||||
default:
|
||||
return {
|
||||
wrapper: '',
|
||||
avatarBorder: 'border-white/20 bg-white/5',
|
||||
placeholder: 'text-art-accent',
|
||||
name: 'text-white',
|
||||
email: 'text-art-muted'
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
136
client/src/components/admin/AdminTableFilter.vue
Normal file
136
client/src/components/admin/AdminTableFilter.vue
Normal file
@@ -0,0 +1,136 @@
|
||||
<template>
|
||||
<div class="admin-table-filter admin-card mb-6 p-4">
|
||||
<div class="filter-row">
|
||||
<div v-if="showKeyword" class="filter-keyword">
|
||||
<div class="relative flex-1 min-w-[200px]">
|
||||
<input
|
||||
type="text"
|
||||
:value="keyword"
|
||||
class="admin-input w-full pl-10"
|
||||
:placeholder="keywordPlaceholder"
|
||||
@input="onKeywordInput"
|
||||
@keyup.enter="emit('search')"
|
||||
/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="search-icon">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<path d="m21 21-4.35-4.35"></path>
|
||||
</svg>
|
||||
<button
|
||||
v-if="keyword"
|
||||
type="button"
|
||||
class="clear-btn"
|
||||
@click="clearKeyword"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-extra">
|
||||
<slot name="filters" />
|
||||
</div>
|
||||
|
||||
<div class="filter-actions">
|
||||
<button type="button" class="admin-btn-secondary" @click="emit('search')">查询</button>
|
||||
<button type="button" class="admin-btn-secondary opacity-70" @click="emit('reset')">重置</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{
|
||||
keyword?: string
|
||||
keywordPlaceholder?: string
|
||||
showKeyword?: boolean
|
||||
}>(), {
|
||||
keyword: '',
|
||||
keywordPlaceholder: '关键词搜索...',
|
||||
showKeyword: true,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:keyword': [value: string]
|
||||
search: []
|
||||
reset: []
|
||||
}>()
|
||||
|
||||
const onKeywordInput = (e: Event) => {
|
||||
emit('update:keyword', (e.target as HTMLInputElement).value)
|
||||
}
|
||||
|
||||
const clearKeyword = () => {
|
||||
emit('update:keyword', '')
|
||||
emit('search')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.filter-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.filter-keyword {
|
||||
flex: 1 1 220px;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.filter-extra {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.filter-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 0.75rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
position: absolute;
|
||||
right: 0.5rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border-radius: 9999px;
|
||||
border: none;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.clear-btn:hover {
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
:deep(.filter-field) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
:deep(.filter-field label) {
|
||||
font-size: 0.75rem;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
189
client/src/components/admin/ImagePicker.vue
Normal file
189
client/src/components/admin/ImagePicker.vue
Normal file
@@ -0,0 +1,189 @@
|
||||
<template>
|
||||
<div class="image-picker">
|
||||
<label v-if="label" class="block text-sm text-art-muted mb-2">{{ label }}</label>
|
||||
|
||||
<div class="flex items-start gap-4">
|
||||
<!-- 预览区 -->
|
||||
<div
|
||||
class="relative shrink-0 overflow-hidden bg-white/5 border border-white/10 flex items-center justify-center"
|
||||
:class="[
|
||||
variant === 'avatar' ? 'rounded-full' : 'rounded-lg',
|
||||
previewSizeClass
|
||||
]"
|
||||
>
|
||||
<img
|
||||
v-if="imageUrl"
|
||||
:src="imageUrl"
|
||||
alt="Preview"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<span v-else class="text-art-muted/50 text-xs text-center px-2">
|
||||
{{ placeholder }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 操作区 -->
|
||||
<div class="flex-1 min-w-0 space-y-2">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="admin-btn-secondary text-xs"
|
||||
:disabled="disabled || uploading"
|
||||
@click="triggerFileInput"
|
||||
>
|
||||
{{ uploading ? '上传中...' : '上传图片' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="admin-btn-secondary text-xs"
|
||||
:disabled="disabled"
|
||||
@click="openLibraryModal"
|
||||
>
|
||||
从素材库选择
|
||||
</button>
|
||||
<button
|
||||
v-if="imageUrl"
|
||||
type="button"
|
||||
class="text-xs text-red-400 hover:text-red-300 transition-colors"
|
||||
:disabled="disabled"
|
||||
@click="clearImage"
|
||||
>
|
||||
清除
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="hint" class="text-xs text-art-muted/60">{{ hint }}</p>
|
||||
<p v-if="error" class="text-xs text-red-400">{{ error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
:accept="accept"
|
||||
class="hidden"
|
||||
:disabled="disabled"
|
||||
@change="handleFileSelect"
|
||||
/>
|
||||
|
||||
<AttachmentLibraryModal
|
||||
:show="showLibraryModal"
|
||||
:multiple="false"
|
||||
:selected-urls="imageUrl ? [imageUrl] : []"
|
||||
@update:show="showLibraryModal = $event"
|
||||
@confirm="handleLibrarySelect"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useToast } from '../../composables/useToast'
|
||||
import { API_BASE, authFetch, parseApiResponse } from '../../services/api'
|
||||
import AttachmentLibraryModal from './AttachmentLibraryModal.vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue?: string
|
||||
label?: string
|
||||
placeholder?: string
|
||||
hint?: string
|
||||
accept?: string
|
||||
disabled?: boolean
|
||||
variant?: 'avatar' | 'default'
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
categoryId?: number
|
||||
}>(), {
|
||||
modelValue: '',
|
||||
placeholder: '暂无图片',
|
||||
accept: 'image/*',
|
||||
disabled: false,
|
||||
variant: 'default',
|
||||
size: 'md'
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const uploading = ref(false)
|
||||
const error = ref('')
|
||||
const showLibraryModal = ref(false)
|
||||
const toast = useToast()
|
||||
|
||||
const imageUrl = computed({
|
||||
get: () => props.modelValue || '',
|
||||
set: (val: string) => emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
const previewSizeClass = computed(() => {
|
||||
if (props.variant === 'avatar') {
|
||||
return props.size === 'sm' ? 'w-12 h-12' : props.size === 'lg' ? 'w-24 h-24' : 'w-16 h-16'
|
||||
}
|
||||
return props.size === 'sm' ? 'w-20 h-20' : props.size === 'lg' ? 'w-40 h-40' : 'w-28 h-28'
|
||||
})
|
||||
|
||||
const triggerFileInput = () => {
|
||||
if (props.disabled || uploading.value) return
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
const handleFileSelect = (e: Event) => {
|
||||
const target = e.target as HTMLInputElement
|
||||
const file = target.files?.[0]
|
||||
if (file) {
|
||||
uploadFile(file)
|
||||
}
|
||||
if (target) target.value = ''
|
||||
}
|
||||
|
||||
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 formData = new FormData()
|
||||
formData.append('file', file)
|
||||
if (props.categoryId) {
|
||||
formData.append('categoryId', props.categoryId.toString())
|
||||
}
|
||||
formData.append('storageType', 'local')
|
||||
|
||||
const result = await parseApiResponse<{ fileUrl: string }>(await authFetch(`${API_BASE}/admin/attachments/upload`, {
|
||||
method: 'POST',
|
||||
headers: {},
|
||||
body: formData
|
||||
}))
|
||||
imageUrl.value = result.fileUrl
|
||||
toast.showToast('图片上传成功', 'success')
|
||||
} catch (err: any) {
|
||||
error.value = err.message || '上传失败,请重试'
|
||||
toast.showToast(error.value, 'error')
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const clearImage = () => {
|
||||
if (props.disabled) return
|
||||
imageUrl.value = ''
|
||||
if (fileInput.value) fileInput.value.value = ''
|
||||
}
|
||||
|
||||
const openLibraryModal = () => {
|
||||
if (props.disabled) return
|
||||
showLibraryModal.value = true
|
||||
}
|
||||
|
||||
const handleLibrarySelect = (urls: string[]) => {
|
||||
if (urls.length > 0) {
|
||||
imageUrl.value = urls[0]
|
||||
toast.showToast('图片选择成功', 'success')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -43,7 +43,15 @@
|
||||
<span class="text-sm text-white font-medium">v{{ item.version }}</span>
|
||||
<span class="text-xs text-art-muted">{{ item.modifiedAt }}</span>
|
||||
</div>
|
||||
<p class="text-xs text-art-muted mt-1 truncate">{{ item.modifiedByName || `用户#${item.modifiedBy}` }}</p>
|
||||
<AuthorInfo
|
||||
class="mt-2"
|
||||
:name="item.userName || item.modifiedByName"
|
||||
:email="item.userEmail"
|
||||
:avatar="item.userAvatar"
|
||||
:user-id="item.userId || item.modifiedBy"
|
||||
variant="compact"
|
||||
size="sm"
|
||||
/>
|
||||
<p class="text-xs text-white/70 mt-0.5 truncate">{{ item.title }}</p>
|
||||
</button>
|
||||
</div>
|
||||
@@ -66,7 +74,17 @@
|
||||
<div><span class="text-art-muted">专栏:</span><span class="text-white">{{ previewData.columnName || '—' }}</span></div>
|
||||
<div><span class="text-art-muted">标签:</span><span class="text-white">{{ previewData.tagNames?.join(', ') || '—' }}</span></div>
|
||||
<div><span class="text-art-muted">状态:</span><span class="text-white">{{ previewData.isPublished === 1 ? '已发布' : '草稿' }}</span></div>
|
||||
<div><span class="text-art-muted">修改人:</span><span class="text-white">{{ previewData.modifiedByName || previewData.modifiedBy }}</span></div>
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="text-art-muted shrink-0">操作人:</span>
|
||||
<AuthorInfo
|
||||
:name="previewData.userName || previewData.modifiedByName"
|
||||
:email="previewData.userEmail"
|
||||
:avatar="previewData.userAvatar"
|
||||
:user-id="previewData.userId || previewData.modifiedBy"
|
||||
variant="compact"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<div><span class="text-art-muted">摘要:</span><span class="text-white">{{ previewData.excerpt || '—' }}</span></div>
|
||||
<div>
|
||||
<span class="text-art-muted block mb-2">正文:</span>
|
||||
@@ -133,6 +151,7 @@ import {
|
||||
type PostHistoryDiff
|
||||
} from '../../services/api'
|
||||
import { useToast } from '../../composables/useToast'
|
||||
import AuthorInfo from '../AuthorInfo.vue'
|
||||
|
||||
const FIELD_LABELS: Record<string, string> = {
|
||||
title: '标题',
|
||||
|
||||
50
client/src/composables/useLocalTableFilter.ts
Normal file
50
client/src/composables/useLocalTableFilter.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { ref, type Ref } from 'vue'
|
||||
|
||||
export function matchKeyword(
|
||||
item: Record<string, unknown>,
|
||||
fields: string[],
|
||||
keyword: string
|
||||
): boolean {
|
||||
const q = keyword.trim().toLowerCase()
|
||||
if (!q) return true
|
||||
return fields.some((field) => {
|
||||
const value = item[field]
|
||||
if (value == null) return false
|
||||
return String(value).toLowerCase().includes(q)
|
||||
})
|
||||
}
|
||||
|
||||
export function matchKeywordFn<T>(
|
||||
item: T,
|
||||
keyword: string,
|
||||
getter: (item: T) => string
|
||||
): boolean {
|
||||
const q = keyword.trim().toLowerCase()
|
||||
if (!q) return true
|
||||
return getter(item).toLowerCase().includes(q)
|
||||
}
|
||||
|
||||
/** 关键词输入 + 已应用关键词(点击查询后生效) */
|
||||
export function useAppliedKeyword() {
|
||||
const keyword = ref('')
|
||||
const appliedKeyword = ref('')
|
||||
|
||||
const apply = () => {
|
||||
appliedKeyword.value = keyword.value.trim()
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
keyword.value = ''
|
||||
appliedKeyword.value = ''
|
||||
}
|
||||
|
||||
return { keyword, appliedKeyword, apply, reset }
|
||||
}
|
||||
|
||||
export function resetRefFilters(filters: Record<string, Ref<unknown>>, defaults: Record<string, unknown>) {
|
||||
Object.keys(defaults).forEach((key) => {
|
||||
if (filters[key]) {
|
||||
filters[key].value = defaults[key]
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -110,6 +110,15 @@
|
||||
<div class="md:w-1/4 pt-2">
|
||||
<span class="font-mono text-xs text-art-accent block mb-1">{{ post.categoryName || post.category?.name || '未分类' }}</span>
|
||||
<span class="text-sm text-art-muted">{{ post.date }}</span>
|
||||
<AuthorInfo
|
||||
class="mt-3"
|
||||
:name="post.userName"
|
||||
:email="post.userEmail"
|
||||
:avatar="post.userAvatar"
|
||||
:user-id="post.userId"
|
||||
variant="compact"
|
||||
size="sm"
|
||||
/>
|
||||
<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>
|
||||
@@ -145,6 +154,7 @@ import { useScrollAnimation } from '../composables/useScrollAnimation'
|
||||
import { useSearchHistory } from '../composables/useSearchHistory'
|
||||
import CustomSelect from '../components/CustomSelect.vue'
|
||||
import SearchSuggestDropdown from '../components/SearchSuggestDropdown.vue'
|
||||
import AuthorInfo from '../components/AuthorInfo.vue'
|
||||
|
||||
const { getHistory, addHistory, removeHistory, clearHistory } = useSearchHistory()
|
||||
|
||||
|
||||
@@ -152,19 +152,14 @@
|
||||
<span class="text-xs md:text-sm text-art-muted">{{ post.date }}</span>
|
||||
</div>
|
||||
<h1 class="font-serif text-2xl md:text-5xl lg:text-6xl text-white leading-snug md:leading-tight mb-6 md:mb-8 break-words">{{ post.title }}</h1>
|
||||
<div class="flex items-center gap-3 md:gap-4">
|
||||
<div class="w-8 h-8 md:w-10 md:h-10 rounded-full overflow-hidden border border-white/20 flex-shrink-0">
|
||||
<img
|
||||
src="https://api.dicebear.com/7.x/avataaars/svg?seed=NianGao"
|
||||
alt="Avatar"
|
||||
class="w-full h-full object-cover"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs md:text-sm font-medium text-white">年糕崽崽</div>
|
||||
<div class="text-[10px] md:text-xs text-art-muted">前端架构师</div>
|
||||
</div>
|
||||
</div>
|
||||
<AuthorInfo
|
||||
:name="post.userName"
|
||||
:email="post.userEmail"
|
||||
:avatar="post.userAvatar"
|
||||
:user-id="post.userId"
|
||||
variant="full"
|
||||
size="md"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 文章内容 -->
|
||||
@@ -265,11 +260,16 @@
|
||||
<div class="text-sm md:text-base font-medium text-white group-hover:text-art-accent transition-colors mb-2 md:mb-3 line-clamp-2">
|
||||
{{ recPost.title }}
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-[10px] md:text-xs text-art-muted mt-auto">
|
||||
<span v-if="recPost.categoryName" class="px-1.5 py-0.5 rounded border border-white/20">
|
||||
{{ recPost.categoryName }}
|
||||
</span>
|
||||
<span>{{ recPost.date }}</span>
|
||||
<div class="flex items-center justify-between gap-2 mt-auto">
|
||||
<AuthorInfo
|
||||
:name="recPost.userName"
|
||||
:email="recPost.userEmail"
|
||||
:avatar="recPost.userAvatar"
|
||||
:user-id="recPost.userId"
|
||||
variant="compact"
|
||||
size="sm"
|
||||
/>
|
||||
<span class="text-[10px] md:text-xs text-art-muted shrink-0">{{ recPost.date }}</span>
|
||||
</div>
|
||||
</a>
|
||||
</router-link>
|
||||
@@ -383,6 +383,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import { fetchPost, getRecommendedPosts, fetchColumnPosts, fetchSnippet, Post, Snippet, getPublicSettings } from '../services/api'
|
||||
import { useScrollAnimation } from '../composables/useScrollAnimation'
|
||||
import Icon from '../components/Icon.vue'
|
||||
import AuthorInfo from '../components/AuthorInfo.vue'
|
||||
import SnippetModal from '../components/SnippetModal.vue'
|
||||
|
||||
import MarkdownIt from 'markdown-it'
|
||||
|
||||
@@ -38,6 +38,15 @@
|
||||
<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>
|
||||
<AuthorInfo
|
||||
class="mt-3"
|
||||
:name="post.userName"
|
||||
:email="post.userEmail"
|
||||
:avatar="post.userAvatar"
|
||||
:user-id="post.userId"
|
||||
variant="compact"
|
||||
size="sm"
|
||||
/>
|
||||
<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>
|
||||
@@ -72,6 +81,7 @@ 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'
|
||||
import AuthorInfo from '../components/AuthorInfo.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
@@ -57,6 +57,16 @@
|
||||
</div>
|
||||
<h3 class="font-serif text-3xl text-white group-hover:text-art-accent transition-colors">
|
||||
{{ featuredPost.title }}</h3>
|
||||
<AuthorInfo
|
||||
class="mt-3"
|
||||
:name="featuredPost.userName"
|
||||
:email="featuredPost.userEmail"
|
||||
:avatar="featuredPost.userAvatar"
|
||||
:user-id="featuredPost.userId"
|
||||
variant="compact"
|
||||
size="sm"
|
||||
theme="onImage"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -109,6 +119,7 @@ import { ref, onMounted } from 'vue'
|
||||
import { fetchWorks, fetchPosts, Work, Post } from '../services/api'
|
||||
import { useScrollAnimation } from '../composables/useScrollAnimation'
|
||||
import Icon from '../components/Icon.vue'
|
||||
import AuthorInfo from '../components/AuthorInfo.vue'
|
||||
|
||||
const { initObserver } = useScrollAnimation()
|
||||
|
||||
|
||||
@@ -7,6 +7,13 @@
|
||||
新建分类
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AdminTableFilter
|
||||
v-model:keyword="keyword"
|
||||
keyword-placeholder="搜索分类名称..."
|
||||
@search="applyFilter"
|
||||
@reset="resetFilters"
|
||||
/>
|
||||
|
||||
<div class="admin-card overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
@@ -22,7 +29,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="category in categories" :key="category.id" class="group transition-colors duration-200">
|
||||
<tr v-for="category in filteredCategories" :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>
|
||||
@@ -43,7 +50,7 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="categories.length === 0" class="p-16 text-center">
|
||||
<div v-if="filteredCategories.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>
|
||||
@@ -56,14 +63,24 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getAttachmentCategories, deleteAttachmentCategory, AttachmentCategory } from '../../services/api'
|
||||
import { useToast } from '../../composables/useToast'
|
||||
import { useAppliedKeyword, matchKeywordFn } from '../../composables/useLocalTableFilter'
|
||||
import AdminTableFilter from '../../components/admin/AdminTableFilter.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const categories = ref<AttachmentCategory[]>([])
|
||||
const { keyword, appliedKeyword, apply, reset } = useAppliedKeyword()
|
||||
|
||||
const filteredCategories = computed(() => categories.value.filter(c =>
|
||||
matchKeywordFn(c, appliedKeyword.value, item => `${item.name} ${item.description || ''}`)
|
||||
))
|
||||
|
||||
const applyFilter = () => apply()
|
||||
const resetFilters = () => reset()
|
||||
|
||||
const fetchCategories = async () => {
|
||||
try {
|
||||
|
||||
@@ -8,31 +8,33 @@
|
||||
</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">
|
||||
<AdminTableFilter
|
||||
v-model:keyword="searchKeyword"
|
||||
keyword-placeholder="搜索文件名..."
|
||||
@search="loadAttachments"
|
||||
@reset="resetFilters"
|
||||
>
|
||||
<template #filters>
|
||||
<div class="filter-field">
|
||||
<label>分类</label>
|
||||
<CustomSelect
|
||||
v-model="filterCategoryId"
|
||||
:options="categoryOptions"
|
||||
placeholder="全部"
|
||||
@update:modelValue="loadAttachments"
|
||||
style="width: 140px;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm text-art-muted">类型:</label>
|
||||
<div class="w-48">
|
||||
<div class="filter-field">
|
||||
<label>类型</label>
|
||||
<CustomSelect
|
||||
v-model="filterFileType"
|
||||
:options="fileTypeOptions"
|
||||
placeholder="全部"
|
||||
@update:modelValue="loadAttachments"
|
||||
style="width: 120px;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</AdminTableFilter>
|
||||
|
||||
<!-- Attachments Grid -->
|
||||
<div v-if="loading" class="flex justify-center items-center h-64">
|
||||
@@ -149,8 +151,9 @@
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useToast } from '../../composables/useToast'
|
||||
import CustomSelect from '../../components/CustomSelect.vue'
|
||||
import AdminTableFilter from '../../components/admin/AdminTableFilter.vue'
|
||||
import AttachmentDetailModal from '../../components/admin/AttachmentDetailModal.vue'
|
||||
import { updateAttachment, type Attachment, API_BASE, authFetch, parseApiResponse } from '../../services/api'
|
||||
import { getAdminAttachments, updateAttachment, API_BASE, authFetch, parseApiResponse, type Attachment } from '../../services/api'
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
@@ -177,6 +180,7 @@ const uploadCategoryId = ref(0)
|
||||
const uploading = ref(false)
|
||||
const filterCategoryId = ref(0)
|
||||
const filterFileType = ref('')
|
||||
const searchKeyword = ref('')
|
||||
|
||||
// 计算属性:分类选项
|
||||
const categoryOptions = computed(() => {
|
||||
@@ -209,16 +213,14 @@ const uploadCategoryOptions = computed(() => {
|
||||
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 data = await parseApiResponse<{ list: Attachment[] }>(await authFetch(url))
|
||||
attachments.value = data?.list || []
|
||||
const data = await getAdminAttachments({
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
categoryId: filterCategoryId.value > 0 ? filterCategoryId.value : undefined,
|
||||
fileType: filterFileType.value || undefined,
|
||||
keyword: searchKeyword.value.trim() || undefined,
|
||||
})
|
||||
attachments.value = data.list as LocalAttachment[]
|
||||
} catch (err: any) {
|
||||
toast.showToast(err.message || '加载失败', 'error')
|
||||
} finally {
|
||||
@@ -226,6 +228,13 @@ const loadAttachments = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const resetFilters = () => {
|
||||
searchKeyword.value = ''
|
||||
filterCategoryId.value = 0
|
||||
filterFileType.value = ''
|
||||
loadAttachments()
|
||||
}
|
||||
|
||||
const loadCategories = async () => {
|
||||
try {
|
||||
const data = await parseApiResponse<Category[]>(await authFetch(`${API_BASE}/admin/attachment-categories`))
|
||||
|
||||
@@ -7,6 +7,13 @@
|
||||
新建分类
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AdminTableFilter
|
||||
v-model:keyword="keyword"
|
||||
keyword-placeholder="搜索名称或 Slug..."
|
||||
@search="applyFilter"
|
||||
@reset="resetFilters"
|
||||
/>
|
||||
|
||||
<div class="admin-card overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
@@ -23,7 +30,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="category in categories" :key="category.id" class="group transition-colors duration-200">
|
||||
<tr v-for="category in filteredCategories" :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-xs text-art-muted font-mono">{{ category.slug }}</td>
|
||||
@@ -45,7 +52,7 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="categories.length === 0" class="p-16 text-center">
|
||||
<div v-if="filteredCategories.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>
|
||||
@@ -58,14 +65,24 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getAdminCategories, deleteCategory, Category } from '../../services/api'
|
||||
import { useToast } from '../../composables/useToast'
|
||||
import { useAppliedKeyword, matchKeywordFn } from '../../composables/useLocalTableFilter'
|
||||
import AdminTableFilter from '../../components/admin/AdminTableFilter.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const categories = ref<Category[]>([])
|
||||
const { keyword, appliedKeyword, apply, reset } = useAppliedKeyword()
|
||||
|
||||
const filteredCategories = computed(() => categories.value.filter(c =>
|
||||
matchKeywordFn(c, appliedKeyword.value, item => `${item.name} ${item.slug || ''} ${item.description || ''}`)
|
||||
))
|
||||
|
||||
const applyFilter = () => apply()
|
||||
const resetFilters = () => reset()
|
||||
|
||||
const fetchCategories = async () => {
|
||||
try {
|
||||
|
||||
@@ -9,6 +9,20 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AdminTableFilter
|
||||
v-model:keyword="keyword"
|
||||
keyword-placeholder="搜索类型名称..."
|
||||
@search="applyFilter"
|
||||
@reset="resetFilters"
|
||||
>
|
||||
<template #filters>
|
||||
<div class="filter-field">
|
||||
<label>分类</label>
|
||||
<CustomSelect v-model="filterCategory" :options="categoryOptions" style="width: 120px;" />
|
||||
</div>
|
||||
</template>
|
||||
</AdminTableFilter>
|
||||
|
||||
<div class="table-container">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
@@ -20,7 +34,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in codeTypes" :key="item.id">
|
||||
<tr v-for="item in filteredItems" :key="item.id">
|
||||
<td>{{ item.id }}</td>
|
||||
<td>{{ item.name }}</td>
|
||||
<td>
|
||||
@@ -33,23 +47,51 @@
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="codeTypes.length === 0" class="empty-state">暂无代码类型</div>
|
||||
<div v-if="filteredItems.length === 0" class="empty-state">暂无匹配的代码类型</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getAdminCodeTypes, deleteCodeType, CODE_TYPE_CATEGORY_LABELS, type CodeType } from '../../services/api'
|
||||
import { useToast } from '../../composables/useToast'
|
||||
import { useAppliedKeyword, matchKeywordFn } from '../../composables/useLocalTableFilter'
|
||||
import AdminTableFilter from '../../components/admin/AdminTableFilter.vue'
|
||||
import CustomSelect from '../../components/CustomSelect.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const codeTypes = ref<CodeType[]>([])
|
||||
const { keyword, appliedKeyword, apply, reset } = useAppliedKeyword()
|
||||
const filterCategory = ref<number | string>('')
|
||||
const appliedCategory = ref<number | string>('')
|
||||
|
||||
const categoryOptions = [
|
||||
{ value: '', label: '全部分类' },
|
||||
...Object.entries(CODE_TYPE_CATEGORY_LABELS).map(([k, v]) => ({ value: Number(k), label: v }))
|
||||
]
|
||||
|
||||
const categoryLabel = (category: number) => CODE_TYPE_CATEGORY_LABELS[category] ?? '未知'
|
||||
|
||||
const filteredItems = computed(() => codeTypes.value.filter(item => {
|
||||
if (!matchKeywordFn(item, appliedKeyword.value, i => i.name)) return false
|
||||
if (appliedCategory.value !== '' && item.category !== Number(appliedCategory.value)) return false
|
||||
return true
|
||||
}))
|
||||
|
||||
const applyFilter = () => {
|
||||
apply()
|
||||
appliedCategory.value = filterCategory.value
|
||||
}
|
||||
|
||||
const resetFilters = () => {
|
||||
reset()
|
||||
filterCategory.value = ''
|
||||
appliedCategory.value = ''
|
||||
}
|
||||
|
||||
const fetchList = async () => {
|
||||
try {
|
||||
codeTypes.value = await getAdminCodeTypes()
|
||||
@@ -80,9 +122,9 @@ onMounted(fetchList)
|
||||
.btn-primary { background: #d4b383; color: #050505; border: none; padding: 0.5rem 1rem; border-radius: 0.375rem; cursor: pointer; font-weight: 500; }
|
||||
.table-container { background: rgba(255,255,255,0.05); border-radius: 0.5rem; border: 1px solid rgba(255,255,255,0.1); overflow: hidden; }
|
||||
.admin-table { width: 100%; border-collapse: collapse; color: white; }
|
||||
.admin-table th, .admin-table td { padding: 0.75rem 1rem; text-align: left; border-bottom: 1px solid rgba(255,255,255,0.1); }
|
||||
.admin-table th, .admin-table td { padding: 1rem; border-bottom: 1px solid rgba(255,255,255,0.1); text-align: left; }
|
||||
.admin-table th { color: #d4b383; background: rgba(255,255,255,0.08); }
|
||||
.category-badge { padding: 0.125rem 0.5rem; border-radius: 9999px; font-size: 0.75rem; background: rgba(212,179,131,0.15); color: #d4b383; }
|
||||
.category-badge { font-size: 0.75rem; color: #d4b383; }
|
||||
.actions { display: flex; gap: 0.5rem; }
|
||||
.btn-edit, .btn-delete { background: none; border: none; cursor: pointer; font-size: 1rem; }
|
||||
.empty-state { padding: 2rem; text-align: center; color: rgba(255,255,255,0.5); }
|
||||
|
||||
@@ -7,6 +7,20 @@
|
||||
新建专栏
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AdminTableFilter
|
||||
v-model:keyword="keyword"
|
||||
keyword-placeholder="搜索专栏名称..."
|
||||
@search="applyFilter"
|
||||
@reset="resetFilters"
|
||||
>
|
||||
<template #filters>
|
||||
<div class="filter-field">
|
||||
<label>状态</label>
|
||||
<CustomSelect v-model="filterStatus" :options="statusOptions" style="width: 120px;" />
|
||||
</div>
|
||||
</template>
|
||||
</AdminTableFilter>
|
||||
|
||||
<div class="admin-card overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
@@ -24,7 +38,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="column in columns" :key="column.id" class="group transition-colors duration-200">
|
||||
<tr v-for="column in filteredColumns" :key="column.id" class="group transition-colors duration-200">
|
||||
<td class="font-mono text-xs text-white/40">#{{ column.id }}</td>
|
||||
<td>
|
||||
<div v-if="column.cover" class="w-10 h-10 rounded bg-cover bg-center border border-white/10" :style="{ backgroundImage: `url(${column.cover})` }"></div>
|
||||
@@ -54,7 +68,7 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="columns.length === 0" class="p-16 text-center">
|
||||
<div v-if="filteredColumns.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>
|
||||
@@ -67,14 +81,43 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getAdminColumns, deleteColumn, Column } from '../../services/api'
|
||||
import { useToast } from '../../composables/useToast'
|
||||
import { useAppliedKeyword, matchKeywordFn } from '../../composables/useLocalTableFilter'
|
||||
import AdminTableFilter from '../../components/admin/AdminTableFilter.vue'
|
||||
import CustomSelect from '../../components/CustomSelect.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const columns = ref<Column[]>([])
|
||||
const { keyword, appliedKeyword, apply, reset } = useAppliedKeyword()
|
||||
const filterStatus = ref<number | string>('')
|
||||
const appliedStatus = ref<number | string>('')
|
||||
|
||||
const statusOptions = [
|
||||
{ value: '', label: '全部状态' },
|
||||
{ value: 1, label: '启用' },
|
||||
{ value: 0, label: '禁用' },
|
||||
]
|
||||
|
||||
const filteredColumns = computed(() => columns.value.filter(c => {
|
||||
if (!matchKeywordFn(c, appliedKeyword.value, item => `${item.name} ${item.description || ''}`)) return false
|
||||
if (appliedStatus.value !== '' && c.isActive !== Number(appliedStatus.value)) return false
|
||||
return true
|
||||
}))
|
||||
|
||||
const applyFilter = () => {
|
||||
apply()
|
||||
appliedStatus.value = filterStatus.value
|
||||
}
|
||||
|
||||
const resetFilters = () => {
|
||||
reset()
|
||||
filterStatus.value = ''
|
||||
appliedStatus.value = ''
|
||||
}
|
||||
|
||||
const fetchColumns = async () => {
|
||||
try {
|
||||
|
||||
@@ -22,6 +22,20 @@
|
||||
<button type="submit" class="btn-primary whitespace-nowrap">添加</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<AdminTableFilter
|
||||
v-model:keyword="keyword"
|
||||
keyword-placeholder="搜索邮箱后缀..."
|
||||
@search="applyFilter"
|
||||
@reset="resetFilters"
|
||||
>
|
||||
<template #filters>
|
||||
<div class="filter-field">
|
||||
<label>状态</label>
|
||||
<CustomSelect v-model="filterActive" :options="activeOptions" style="width: 120px;" />
|
||||
</div>
|
||||
</template>
|
||||
</AdminTableFilter>
|
||||
|
||||
<!-- Suffixes Table -->
|
||||
<div class="table-container">
|
||||
@@ -36,7 +50,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="s in suffixes" :key="s.id">
|
||||
<tr v-for="s in filteredSuffixes" :key="s.id">
|
||||
<td class="table-cell">{{ s.id }}</td>
|
||||
<td class="table-cell">{{ s.suffix }}</td>
|
||||
<td class="table-cell">{{ s.sortOrder }}</td>
|
||||
@@ -55,7 +69,7 @@
|
||||
</table>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-if="suffixes.length === 0" class="empty-state">
|
||||
<div v-if="filteredSuffixes.length === 0" class="empty-state">
|
||||
<p>暂无配置数据</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -63,14 +77,44 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { API_BASE, authFetchJson, getAuthHeaders, type EmailSuffix } from '../../services/api'
|
||||
import { useToast } from '../../composables/useToast'
|
||||
import { useAppliedKeyword, matchKeywordFn } from '../../composables/useLocalTableFilter'
|
||||
import AdminTableFilter from '../../components/admin/AdminTableFilter.vue'
|
||||
import CustomSelect from '../../components/CustomSelect.vue'
|
||||
|
||||
const toast = useToast()
|
||||
const suffixes = ref<EmailSuffix[]>([])
|
||||
const newSuffix = ref('')
|
||||
const newSortOrder = ref(0)
|
||||
const { keyword, appliedKeyword, apply, reset } = useAppliedKeyword()
|
||||
const filterActive = ref<number | string>('')
|
||||
const appliedActive = ref<number | string>('')
|
||||
|
||||
const activeOptions = [
|
||||
{ value: '', label: '全部状态' },
|
||||
{ value: 1, label: '启用' },
|
||||
{ value: 0, label: '禁用' },
|
||||
]
|
||||
|
||||
const filteredSuffixes = computed(() => suffixes.value.filter(s => {
|
||||
if (!matchKeywordFn(s, appliedKeyword.value, item => item.suffix)) return false
|
||||
if (appliedActive.value === '') return true
|
||||
const active = appliedActive.value === 1 || appliedActive.value === '1'
|
||||
return !!s.isActive === active
|
||||
}))
|
||||
|
||||
const applyFilter = () => {
|
||||
apply()
|
||||
appliedActive.value = filterActive.value
|
||||
}
|
||||
|
||||
const resetFilters = () => {
|
||||
reset()
|
||||
filterActive.value = ''
|
||||
appliedActive.value = ''
|
||||
}
|
||||
|
||||
const fetchSuffixes = async () => {
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,28 @@
|
||||
<template>
|
||||
<div class="inquiries-container">
|
||||
<h1 class="page-title">合作咨询管理</h1>
|
||||
|
||||
<AdminTableFilter
|
||||
v-model:keyword="keyword"
|
||||
keyword-placeholder="搜索姓名、公司或联系方式..."
|
||||
@search="fetchInquiriesList"
|
||||
@reset="resetFilters"
|
||||
>
|
||||
<template #filters>
|
||||
<div class="filter-field">
|
||||
<label>状态</label>
|
||||
<CustomSelect v-model="filterStatus" :options="statusOptions" style="width: 120px;" />
|
||||
</div>
|
||||
<div class="filter-field">
|
||||
<label>开始日期</label>
|
||||
<input v-model="startDate" type="date" class="admin-input" style="width: 150px;" />
|
||||
</div>
|
||||
<div class="filter-field">
|
||||
<label>结束日期</label>
|
||||
<input v-model="endDate" type="date" class="admin-input" style="width: 150px;" />
|
||||
</div>
|
||||
</template>
|
||||
</AdminTableFilter>
|
||||
|
||||
<div class="table-container">
|
||||
<table class="admin-table">
|
||||
@@ -85,24 +107,56 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { API_BASE, authFetchJson, fetchInquiries, getAuthHeaders, type Inquiry } from '../../services/api'
|
||||
import { API_BASE, authFetchJson, fetchInquiriesPaginated, getAuthHeaders, type Inquiry } from '../../services/api'
|
||||
import { useToast } from '../../composables/useToast'
|
||||
import InquiryDetailModal from '../../components/admin/InquiryDetailModal.vue'
|
||||
import AdminTableFilter from '../../components/admin/AdminTableFilter.vue'
|
||||
import CustomSelect from '../../components/CustomSelect.vue'
|
||||
|
||||
const toast = useToast()
|
||||
const inquiries = ref<Inquiry[]>([])
|
||||
const isDetailModalOpen = ref(false)
|
||||
const selectedInquiry = ref<Inquiry | null>(null)
|
||||
const keyword = ref('')
|
||||
const filterStatus = ref<number | string>('')
|
||||
const startDate = ref('')
|
||||
const endDate = ref('')
|
||||
|
||||
const statusOptions = [
|
||||
{ value: '', label: '全部状态' },
|
||||
{ value: 0, label: '未读' },
|
||||
{ value: 1, label: '已读' },
|
||||
{ value: 2, label: '已联系' },
|
||||
]
|
||||
|
||||
const fetchInquiriesList = async () => {
|
||||
try {
|
||||
inquiries.value = await fetchInquiries()
|
||||
const params: Parameters<typeof fetchInquiriesPaginated>[0] = {
|
||||
page: 1,
|
||||
pageSize: 500,
|
||||
keyword: keyword.value.trim() || undefined,
|
||||
startDate: startDate.value || undefined,
|
||||
endDate: endDate.value || undefined,
|
||||
}
|
||||
if (filterStatus.value !== '') {
|
||||
params.status = Number(filterStatus.value)
|
||||
}
|
||||
const data = await fetchInquiriesPaginated(params)
|
||||
inquiries.value = data.list
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch inquiries:', error)
|
||||
toast.error('获取咨询列表失败')
|
||||
}
|
||||
}
|
||||
|
||||
const resetFilters = () => {
|
||||
keyword.value = ''
|
||||
filterStatus.value = ''
|
||||
startDate.value = ''
|
||||
endDate.value = ''
|
||||
fetchInquiriesList()
|
||||
}
|
||||
|
||||
const openDetail = (item: Inquiry) => {
|
||||
selectedInquiry.value = item
|
||||
isDetailModalOpen.value = true
|
||||
|
||||
@@ -9,6 +9,13 @@
|
||||
新建合作伙伴
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AdminTableFilter
|
||||
v-model:keyword="keyword"
|
||||
keyword-placeholder="搜索合作伙伴名称..."
|
||||
@search="applyFilter"
|
||||
@reset="resetFilters"
|
||||
/>
|
||||
|
||||
<!-- Partners Table -->
|
||||
<div class="table-container">
|
||||
@@ -25,7 +32,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="p in partners" :key="p.id">
|
||||
<tr v-for="p in filteredPartners" :key="p.id">
|
||||
<td class="table-cell">{{ p.id }}</td>
|
||||
<td class="table-cell">
|
||||
<img v-if="p.logo" :src="p.logo" alt="Logo" class="w-8 h-8 object-contain bg-white/10 rounded">
|
||||
@@ -53,7 +60,7 @@
|
||||
</table>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-if="partners.length === 0" class="empty-state">
|
||||
<div v-if="filteredPartners.length === 0" class="empty-state">
|
||||
<p>暂无合作伙伴数据</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -61,15 +68,24 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { fetchPartners, deletePartner, type Partner } from '../../services/api'
|
||||
import { useToast } from '../../composables/useToast'
|
||||
import { useAppliedKeyword, matchKeywordFn } from '../../composables/useLocalTableFilter'
|
||||
import AdminTableFilter from '../../components/admin/AdminTableFilter.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
|
||||
const partners = ref<Partner[]>([])
|
||||
const { keyword, appliedKeyword, apply, reset } = useAppliedKeyword()
|
||||
|
||||
const filteredPartners = computed(() => partners.value.filter(p =>
|
||||
matchKeywordFn(p, appliedKeyword.value, item => `${item.name} ${item.description || ''} ${item.website || ''}`)
|
||||
))
|
||||
|
||||
const applyFilter = () => apply()
|
||||
const resetFilters = () => reset()
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
|
||||
@@ -9,36 +9,35 @@
|
||||
</div>
|
||||
|
||||
<!-- 搜索和筛选区域 -->
|
||||
<div class="admin-card mb-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex-1 relative">
|
||||
<input
|
||||
type="text"
|
||||
v-model="searchQuery"
|
||||
@keyup.enter="handleSearch"
|
||||
placeholder="搜索文章标题或内容..."
|
||||
class="admin-input w-full pl-10"
|
||||
/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="absolute left-3 top-1/2 -translate-y-1/2 text-art-muted">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<path d="m21 21-4.35-4.35"></path>
|
||||
</svg>
|
||||
<button
|
||||
v-if="searchQuery"
|
||||
@click="clearSearch"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-art-muted hover:text-white transition-colors"
|
||||
>
|
||||
<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="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<AdminTableFilter
|
||||
v-model:keyword="searchQuery"
|
||||
keyword-placeholder="搜索文章标题或内容..."
|
||||
@search="handleSearch"
|
||||
@reset="resetFilters"
|
||||
>
|
||||
<template #filters>
|
||||
<div class="filter-field">
|
||||
<label>分类</label>
|
||||
<CustomSelect v-model="filterCategoryId" :options="categoryOptions" style="width: 130px;" />
|
||||
</div>
|
||||
<button @click="handleSearch" class="admin-btn-secondary">
|
||||
搜索
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-field">
|
||||
<label>专栏</label>
|
||||
<CustomSelect v-model="filterColumnId" :options="columnOptions" style="width: 130px;" />
|
||||
</div>
|
||||
<div class="filter-field">
|
||||
<label>状态</label>
|
||||
<CustomSelect v-model="filterPublished" :options="publishedOptions" style="width: 110px;" />
|
||||
</div>
|
||||
<div class="filter-field">
|
||||
<label>开始日期</label>
|
||||
<input v-model="startDate" type="date" class="admin-input" style="width: 150px;" />
|
||||
</div>
|
||||
<div class="filter-field">
|
||||
<label>结束日期</label>
|
||||
<input v-model="endDate" type="date" class="admin-input" style="width: 150px;" />
|
||||
</div>
|
||||
</template>
|
||||
</AdminTableFilter>
|
||||
|
||||
<div class="admin-card overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
@@ -50,6 +49,7 @@
|
||||
<th>分类</th>
|
||||
<th>专栏</th>
|
||||
<th>标签</th>
|
||||
<th>作者</th>
|
||||
<th>发布日期</th>
|
||||
<th>状态</th>
|
||||
<th>访问记录</th>
|
||||
@@ -109,6 +109,16 @@
|
||||
无标签
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<AuthorInfo
|
||||
:name="post.userName"
|
||||
:email="post.userEmail"
|
||||
:avatar="post.userAvatar"
|
||||
:user-id="post.userId"
|
||||
variant="compact"
|
||||
size="sm"
|
||||
/>
|
||||
</td>
|
||||
<td class="text-art-muted text-xs">{{ post.date }}</td>
|
||||
<td>
|
||||
<button
|
||||
@@ -223,11 +233,14 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { getAdminPosts, deletePost as deletePostApi, Post, togglePostStatus } from '../../services/api'
|
||||
import { getAdminPosts, deletePost as deletePostApi, Post, togglePostStatus, getAdminCategories, getAdminColumns } from '../../services/api'
|
||||
import { useToast } from '../../composables/useToast'
|
||||
import PostRelationModal from '../../components/admin/PostRelationModal.vue'
|
||||
import PostHistoryModal from '../../components/admin/PostHistoryModal.vue'
|
||||
import AccessLogModal from '../../components/admin/AccessLogModal.vue'
|
||||
import AdminTableFilter from '../../components/admin/AdminTableFilter.vue'
|
||||
import CustomSelect from '../../components/CustomSelect.vue'
|
||||
import AuthorInfo from '../../components/AuthorInfo.vue'
|
||||
|
||||
type RelationFocusSection = 'category' | 'column' | 'tags' | 'all'
|
||||
|
||||
@@ -244,10 +257,33 @@ const selectedPostTitle = ref<string>('')
|
||||
|
||||
// 搜索和分页状态
|
||||
const searchQuery = ref('')
|
||||
const filterCategoryId = ref<number | string>(0)
|
||||
const filterColumnId = ref<number | string>(0)
|
||||
const filterPublished = ref<number | string>('')
|
||||
const startDate = ref('')
|
||||
const endDate = ref('')
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const categories = ref<{ id: number; name: string }[]>([])
|
||||
const columns = ref<{ id: number; name: string }[]>([])
|
||||
|
||||
const categoryOptions = computed(() => [
|
||||
{ value: 0, label: '全部分类' },
|
||||
...categories.value.map(c => ({ value: c.id, label: c.name }))
|
||||
])
|
||||
|
||||
const columnOptions = computed(() => [
|
||||
{ value: 0, label: '全部专栏' },
|
||||
...columns.value.map(c => ({ value: c.id, label: c.name }))
|
||||
])
|
||||
|
||||
const publishedOptions = [
|
||||
{ value: '', label: '全部状态' },
|
||||
{ value: 1, label: '已发布' },
|
||||
{ value: 0, label: '草稿' },
|
||||
]
|
||||
|
||||
// 计算总页数
|
||||
const totalPages = computed(() => {
|
||||
@@ -292,13 +328,20 @@ const openAccessLogModal = (post: Post) => {
|
||||
const fetchPosts = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await getAdminPosts(currentPage.value, pageSize.value, searchQuery.value)
|
||||
// Handle PaginationResponse structure
|
||||
const params: Parameters<typeof getAdminPosts>[2] = {
|
||||
keyword: searchQuery.value.trim() || undefined,
|
||||
startDate: startDate.value || undefined,
|
||||
endDate: endDate.value || undefined,
|
||||
}
|
||||
if (filterCategoryId.value) params.categoryId = Number(filterCategoryId.value)
|
||||
if (filterColumnId.value) params.columnId = Number(filterColumnId.value)
|
||||
if (filterPublished.value !== '') params.isPublished = Number(filterPublished.value)
|
||||
|
||||
const response = await getAdminPosts(currentPage.value, pageSize.value, params)
|
||||
if ('list' in response) {
|
||||
posts.value = response.list
|
||||
total.value = response.total
|
||||
} else {
|
||||
// Fallback if API returns array directly (legacy)
|
||||
posts.value = response as any
|
||||
total.value = (response as any).length || 0
|
||||
}
|
||||
@@ -315,8 +358,13 @@ const handleSearch = () => {
|
||||
fetchPosts()
|
||||
}
|
||||
|
||||
const clearSearch = () => {
|
||||
const resetFilters = () => {
|
||||
searchQuery.value = ''
|
||||
filterCategoryId.value = 0
|
||||
filterColumnId.value = 0
|
||||
filterPublished.value = ''
|
||||
startDate.value = ''
|
||||
endDate.value = ''
|
||||
currentPage.value = 1
|
||||
fetchPosts()
|
||||
}
|
||||
@@ -355,7 +403,12 @@ const deletePost = async (id: number) => {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [cats, cols] = await Promise.all([getAdminCategories(), getAdminColumns()])
|
||||
categories.value = cats
|
||||
columns.value = cols
|
||||
} catch { /* ignore */ }
|
||||
fetchPosts()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -7,6 +7,20 @@
|
||||
+ 新增代码片段
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<AdminTableFilter
|
||||
v-model:keyword="keyword"
|
||||
keyword-placeholder="搜索标题..."
|
||||
@search="applyFilter"
|
||||
@reset="resetFilters"
|
||||
>
|
||||
<template #filters>
|
||||
<div class="filter-field">
|
||||
<label>代码类型</label>
|
||||
<CustomSelect v-model="filterCodeTypeId" :options="codeTypeOptions" style="width: 140px;" />
|
||||
</div>
|
||||
</template>
|
||||
</AdminTableFilter>
|
||||
|
||||
<div class="table-container">
|
||||
<table class="admin-table">
|
||||
@@ -20,11 +34,11 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="snippet in snippets" :key="snippet.id">
|
||||
<tr v-for="snippet in filteredSnippets" :key="snippet.id">
|
||||
<td>{{ snippet.id }}</td>
|
||||
<td>{{ snippet.title }}</td>
|
||||
<td>
|
||||
<span class="type-badge">{{ snippet.type }}</span>
|
||||
<span class="type-badge">{{ snippet.codeType?.name || snippet.type }}</span>
|
||||
</td>
|
||||
<td>{{ snippet.viewCount || 0 }}</td>
|
||||
<td class="actions">
|
||||
@@ -40,23 +54,54 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="snippets.length === 0" class="empty-state">
|
||||
<p>暂无代码片段,请点击上方按钮新增</p>
|
||||
<div v-if="filteredSnippets.length === 0" class="empty-state">
|
||||
<p>{{ appliedKeyword || filterCodeTypeId ? '没有匹配的代码片段' : '暂无代码片段,请点击上方按钮新增' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { getAdminSnippets, deleteSnippet as deleteSnippetApi, Snippet } from '../../services/api'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { getAdminSnippets, getAdminCodeTypes, deleteSnippet as deleteSnippetApi, type Snippet, type CodeType } from '../../services/api'
|
||||
import { useToast } from '../../composables/useToast'
|
||||
import { useAppliedKeyword, matchKeywordFn } from '../../composables/useLocalTableFilter'
|
||||
import AdminTableFilter from '../../components/admin/AdminTableFilter.vue'
|
||||
import CustomSelect from '../../components/CustomSelect.vue'
|
||||
|
||||
const toast = useToast()
|
||||
const snippets = ref<Snippet[]>([])
|
||||
const codeTypes = ref<CodeType[]>([])
|
||||
const { keyword, appliedKeyword, apply, reset } = useAppliedKeyword()
|
||||
const filterCodeTypeId = ref<number | string>(0)
|
||||
const appliedCodeTypeId = ref(0)
|
||||
|
||||
const codeTypeOptions = computed(() => [
|
||||
{ value: 0, label: '全部类型' },
|
||||
...codeTypes.value.map(t => ({ value: t.id, label: t.name }))
|
||||
])
|
||||
|
||||
const filteredSnippets = computed(() => {
|
||||
return snippets.value.filter(s => {
|
||||
if (!matchKeywordFn(s, appliedKeyword.value, item => item.title)) return false
|
||||
if (appliedCodeTypeId.value > 0 && s.codeTypeId !== appliedCodeTypeId.value) return false
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
const applyFilter = () => {
|
||||
apply()
|
||||
appliedCodeTypeId.value = Number(filterCodeTypeId.value) || 0
|
||||
}
|
||||
|
||||
const resetFilters = () => {
|
||||
reset()
|
||||
filterCodeTypeId.value = 0
|
||||
appliedCodeTypeId.value = 0
|
||||
}
|
||||
|
||||
const fetchSnippets = async () => {
|
||||
try {
|
||||
snippets.value = await getAdminSnippets()
|
||||
snippets.value = await getAdminSnippets({ pageSize: 500 })
|
||||
} catch (error) {
|
||||
console.error('Error fetching snippets:', error)
|
||||
toast.error('获取代码片段列表失败')
|
||||
@@ -76,39 +121,47 @@ const deleteSnippet = async (id: number) => {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
try {
|
||||
codeTypes.value = await getAdminCodeTypes()
|
||||
} catch { /* ignore */ }
|
||||
fetchSnippets()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-snippets {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 1.5rem;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1.5rem;
|
||||
color: #d4b383;
|
||||
font-family: 'Inter', sans-serif;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
margin-bottom: 1rem;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #d4b383;
|
||||
color: #050505;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.admin-table {
|
||||
@@ -119,31 +172,23 @@ onMounted(() => {
|
||||
|
||||
.admin-table th,
|
||||
.admin-table td {
|
||||
padding: 0.75rem 1rem;
|
||||
padding: 1rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.admin-table th {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
font-weight: 600;
|
||||
color: #d4b383;
|
||||
}
|
||||
|
||||
.admin-table tr:hover {
|
||||
background: rgba(212, 179, 131, 0.05);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.type-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
padding: 0.2rem 0.5rem;
|
||||
background: rgba(212, 179, 131, 0.15);
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
background-color: rgba(212, 179, 131, 0.2);
|
||||
color: #d4b383;
|
||||
border: 1px solid rgba(212, 179, 131, 0.3);
|
||||
}
|
||||
|
||||
.actions {
|
||||
@@ -151,68 +196,28 @@ onMounted(() => {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-family: 'Inter', sans-serif;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: rgba(212, 179, 131, 0.1);
|
||||
color: #d4b383;
|
||||
border-color: #d4b383;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: transparent;
|
||||
box-shadow: 0 0 15px rgba(212, 179, 131, 0.3);
|
||||
font-size: 0.75rem;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: rgba(212, 179, 131, 0.1);
|
||||
border-color: #d4b383;
|
||||
color: #d4b383;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: rgba(239, 68, 68, 0.2);
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #ef4444;
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background-color: rgba(239, 68, 68, 0.3);
|
||||
border-color: #ef4444;
|
||||
box-shadow: 0 0 15px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
font-family: 'Inter', sans-serif;
|
||||
text-align: center;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -9,6 +9,13 @@
|
||||
新建标签
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AdminTableFilter
|
||||
v-model:keyword="keyword"
|
||||
keyword-placeholder="搜索标签名称..."
|
||||
@search="applyFilter"
|
||||
@reset="resetFilters"
|
||||
/>
|
||||
|
||||
<!-- Tags Table -->
|
||||
<div class="table-container">
|
||||
@@ -24,7 +31,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="tag in tags" :key="tag.id">
|
||||
<tr v-for="tag in filteredTags" :key="tag.id">
|
||||
<td class="table-cell">{{ tag.id }}</td>
|
||||
<td class="table-cell">{{ tag.name }}</td>
|
||||
<td class="table-cell">-</td>
|
||||
@@ -43,7 +50,7 @@
|
||||
</table>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-if="tags.length === 0" class="empty-state">
|
||||
<div v-if="filteredTags.length === 0" class="empty-state">
|
||||
<p>暂无标签数据</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -51,14 +58,22 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { adminGetTags, deleteTag, type Tag } from '../../services/api'
|
||||
import { useAppliedKeyword, matchKeywordFn } from '../../composables/useLocalTableFilter'
|
||||
import AdminTableFilter from '../../components/admin/AdminTableFilter.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 标签数据
|
||||
const tags = ref<Tag[]>([])
|
||||
const { keyword, appliedKeyword, apply, reset } = useAppliedKeyword()
|
||||
|
||||
const filteredTags = computed(() => tags.value.filter(t =>
|
||||
matchKeywordFn(t, appliedKeyword.value, item => item.name)
|
||||
))
|
||||
|
||||
const applyFilter = () => apply()
|
||||
const resetFilters = () => reset()
|
||||
|
||||
// 获取标签列表
|
||||
const fetchTags = async () => {
|
||||
|
||||
@@ -9,6 +9,20 @@
|
||||
新建评价
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AdminTableFilter
|
||||
v-model:keyword="keyword"
|
||||
keyword-placeholder="搜索作者或内容..."
|
||||
@search="applyFilter"
|
||||
@reset="resetFilters"
|
||||
>
|
||||
<template #filters>
|
||||
<div class="filter-field">
|
||||
<label>评分</label>
|
||||
<CustomSelect v-model="filterRating" :options="ratingOptions" style="width: 100px;" />
|
||||
</div>
|
||||
</template>
|
||||
</AdminTableFilter>
|
||||
|
||||
<!-- Testimonials Table -->
|
||||
<div class="table-container">
|
||||
@@ -25,7 +39,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="t in testimonials" :key="t.id">
|
||||
<tr v-for="t in filteredTestimonials" :key="t.id">
|
||||
<td class="table-cell">{{ t.id }}</td>
|
||||
<td class="table-cell">{{ t.author }}</td>
|
||||
<td class="table-cell">{{ t.role }}</td>
|
||||
@@ -47,7 +61,7 @@
|
||||
</table>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-if="testimonials.length === 0" class="empty-state">
|
||||
<div v-if="filteredTestimonials.length === 0" class="empty-state">
|
||||
<p>暂无客户评价数据</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -55,15 +69,42 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { fetchTestimonials, deleteTestimonial, type Testimonial } from '../../services/api'
|
||||
import { useToast } from '../../composables/useToast'
|
||||
import { useAppliedKeyword, matchKeywordFn } from '../../composables/useLocalTableFilter'
|
||||
import AdminTableFilter from '../../components/admin/AdminTableFilter.vue'
|
||||
import CustomSelect from '../../components/CustomSelect.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
|
||||
const testimonials = ref<Testimonial[]>([])
|
||||
const { keyword, appliedKeyword, apply, reset } = useAppliedKeyword()
|
||||
const filterRating = ref<number | string>('')
|
||||
const appliedRating = ref<number | string>('')
|
||||
|
||||
const ratingOptions = [
|
||||
{ value: '', label: '全部评分' },
|
||||
...([5, 4, 3, 2, 1].map(n => ({ value: n, label: `${n} 星` })))
|
||||
]
|
||||
|
||||
const filteredTestimonials = computed(() => testimonials.value.filter(t => {
|
||||
if (!matchKeywordFn(t, appliedKeyword.value, item => `${item.author} ${item.content}`)) return false
|
||||
if (appliedRating.value !== '' && t.rating !== Number(appliedRating.value)) return false
|
||||
return true
|
||||
}))
|
||||
|
||||
const applyFilter = () => {
|
||||
apply()
|
||||
appliedRating.value = filterRating.value
|
||||
}
|
||||
|
||||
const resetFilters = () => {
|
||||
reset()
|
||||
filterRating.value = ''
|
||||
appliedRating.value = ''
|
||||
}
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
|
||||
@@ -33,6 +33,18 @@
|
||||
{{ errors.email }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Avatar Field -->
|
||||
<div class="form-group">
|
||||
<ImagePicker
|
||||
v-model="form.avatar"
|
||||
label="头像"
|
||||
variant="avatar"
|
||||
size="lg"
|
||||
placeholder="暂无头像"
|
||||
hint="支持本地上传或从素材库选择"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Role Field -->
|
||||
<div class="form-group">
|
||||
@@ -80,6 +92,7 @@ import { useRouter, useRoute } from 'vue-router'
|
||||
import { useToast } from '../../composables/useToast'
|
||||
import { createUser, updateUser, fetchUser } from '../../services/api'
|
||||
import CustomSelect from '../../components/CustomSelect.vue'
|
||||
import ImagePicker from '../../components/admin/ImagePicker.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -94,6 +107,7 @@ const errors = reactive<Record<string, string>>({})
|
||||
const form = reactive({
|
||||
username: '',
|
||||
email: '',
|
||||
avatar: '',
|
||||
role: 'viewer',
|
||||
isActive: 1
|
||||
})
|
||||
@@ -183,6 +197,7 @@ onMounted(async () => {
|
||||
const user = await fetchUser(userId)
|
||||
form.username = user.username
|
||||
form.email = user.email
|
||||
form.avatar = user.avatar || ''
|
||||
form.role = user.role
|
||||
form.isActive = user.isActive
|
||||
} catch (error: any) {
|
||||
|
||||
@@ -9,31 +9,23 @@
|
||||
</div>
|
||||
|
||||
<!-- Search and Filter -->
|
||||
<div class="search-filter">
|
||||
<div class="search-box">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索用户名或邮箱"
|
||||
v-model="searchQuery"
|
||||
@input="handleSearch"
|
||||
/>
|
||||
<button class="search-btn">🔍</button>
|
||||
</div>
|
||||
<div class="filter-options">
|
||||
<CustomSelect
|
||||
v-model="roleFilter"
|
||||
:options="roleFilterOptions"
|
||||
@update:modelValue="handleFilter"
|
||||
style="width: 120px; margin-right: 10px;"
|
||||
/>
|
||||
<CustomSelect
|
||||
v-model="statusFilter"
|
||||
:options="statusFilterOptions"
|
||||
@update:modelValue="handleFilter"
|
||||
style="width: 120px;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<AdminTableFilter
|
||||
v-model:keyword="searchQuery"
|
||||
keyword-placeholder="搜索用户名或邮箱"
|
||||
@search="handleSearch"
|
||||
@reset="resetFilters"
|
||||
>
|
||||
<template #filters>
|
||||
<div class="filter-field">
|
||||
<label>角色</label>
|
||||
<CustomSelect v-model="roleFilter" :options="roleFilterOptions" style="width: 120px;" />
|
||||
</div>
|
||||
<div class="filter-field">
|
||||
<label>状态</label>
|
||||
<CustomSelect v-model="statusFilter" :options="statusFilterOptions" style="width: 120px;" />
|
||||
</div>
|
||||
</template>
|
||||
</AdminTableFilter>
|
||||
|
||||
<!-- Users Table -->
|
||||
<div class="table-container">
|
||||
@@ -41,6 +33,7 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>头像</th>
|
||||
<th>用户名</th>
|
||||
<th>邮箱</th>
|
||||
<th>角色</th>
|
||||
@@ -50,8 +43,19 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="user in filteredUsers" :key="user.id">
|
||||
<tr v-for="user in users" :key="user.id">
|
||||
<td>{{ user.id }}</td>
|
||||
<td>
|
||||
<div class="user-avatar-cell">
|
||||
<img
|
||||
v-if="user.avatar"
|
||||
:src="user.avatar"
|
||||
:alt="user.username"
|
||||
class="user-avatar-img"
|
||||
/>
|
||||
<span v-else class="user-avatar-placeholder">{{ user.username.charAt(0).toUpperCase() }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ user.username }}</td>
|
||||
<td>{{ user.email }}</td>
|
||||
<td>
|
||||
@@ -78,7 +82,7 @@
|
||||
</table>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div class="empty-state" v-if="filteredUsers.length === 0">
|
||||
<div class="empty-state" v-if="!isLoading && users.length === 0">
|
||||
<div class="empty-icon">👥</div>
|
||||
<h3>暂无用户数据</h3>
|
||||
<p>点击右上角按钮创建新用户</p>
|
||||
@@ -86,7 +90,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="pagination" v-if="filteredUsers.length > 0">
|
||||
<div class="pagination" v-if="total > 0">
|
||||
<button class="page-btn" @click="currentPage--" :disabled="currentPage === 1">
|
||||
上一页
|
||||
</button>
|
||||
@@ -101,11 +105,12 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useToast } from '../../composables/useToast'
|
||||
import { getUsers, deleteUser, User } from '../../services/api'
|
||||
import { getUsersPaginated, deleteUser, User } from '../../services/api'
|
||||
import CustomSelect from '../../components/CustomSelect.vue'
|
||||
import AdminTableFilter from '../../components/admin/AdminTableFilter.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
@@ -117,6 +122,7 @@ const roleFilter = ref('')
|
||||
const statusFilter = ref('')
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const total = ref(0)
|
||||
const isLoading = ref(false)
|
||||
|
||||
// Select options
|
||||
@@ -133,42 +139,23 @@ const statusFilterOptions = [
|
||||
{ value: '0', label: '禁用' }
|
||||
]
|
||||
|
||||
// Computed properties
|
||||
const filteredUsers = computed(() => {
|
||||
let result = users.value
|
||||
|
||||
// Apply search filter
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
result = result.filter(user =>
|
||||
user.username.toLowerCase().includes(query) ||
|
||||
user.email.toLowerCase().includes(query)
|
||||
)
|
||||
}
|
||||
|
||||
// Apply role filter
|
||||
if (roleFilter.value) {
|
||||
result = result.filter(user => user.role === roleFilter.value)
|
||||
}
|
||||
|
||||
// Apply status filter
|
||||
if (statusFilter.value !== '') {
|
||||
result = result.filter(user => user.isActive === parseInt(statusFilter.value))
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
|
||||
|
||||
const totalPages = computed(() => {
|
||||
return Math.ceil(filteredUsers.value.length / pageSize.value)
|
||||
})
|
||||
|
||||
// Methods
|
||||
const fetchUsers = async () => {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const data = await getUsers()
|
||||
users.value = data
|
||||
const params: Parameters<typeof getUsersPaginated>[0] = {
|
||||
page: currentPage.value,
|
||||
pageSize: pageSize.value,
|
||||
keyword: searchQuery.value.trim() || undefined,
|
||||
role: roleFilter.value || undefined,
|
||||
}
|
||||
if (statusFilter.value !== '') {
|
||||
params.isActive = parseInt(statusFilter.value)
|
||||
}
|
||||
const data = await getUsersPaginated(params)
|
||||
users.value = data.list
|
||||
total.value = data.total
|
||||
} catch (error) {
|
||||
toast.error('获取用户列表失败')
|
||||
console.error('Error fetching users:', error)
|
||||
@@ -178,14 +165,22 @@ const fetchUsers = async () => {
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
// Debounce search if needed
|
||||
currentPage.value = 1
|
||||
fetchUsers()
|
||||
}
|
||||
|
||||
const handleFilter = () => {
|
||||
const resetFilters = () => {
|
||||
searchQuery.value = ''
|
||||
roleFilter.value = ''
|
||||
statusFilter.value = ''
|
||||
currentPage.value = 1
|
||||
fetchUsers()
|
||||
}
|
||||
|
||||
watch(currentPage, () => {
|
||||
fetchUsers()
|
||||
})
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (confirm('确定要删除这个用户吗?')) {
|
||||
try {
|
||||
@@ -385,6 +380,32 @@ onMounted(() => {
|
||||
background: rgba(212, 179, 131, 0.05);
|
||||
}
|
||||
|
||||
/* Avatar */
|
||||
.user-avatar-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.user-avatar-img,
|
||||
.user-avatar-placeholder {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 9999px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.user-avatar-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(212, 179, 131, 0.15);
|
||||
color: #d4b383;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
border: 1px solid rgba(212, 179, 131, 0.25);
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
.role-badge {
|
||||
padding: 0.25rem 0.75rem;
|
||||
|
||||
@@ -7,6 +7,24 @@
|
||||
新增作品
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<AdminTableFilter
|
||||
v-model:keyword="keyword"
|
||||
keyword-placeholder="搜索作品标题..."
|
||||
@search="applyFilter"
|
||||
@reset="resetFilters"
|
||||
>
|
||||
<template #filters>
|
||||
<div class="filter-field">
|
||||
<label>分类</label>
|
||||
<CustomSelect v-model="filterCategory" :options="categoryOptions" style="width: 140px;" />
|
||||
</div>
|
||||
<div class="filter-field">
|
||||
<label>年份</label>
|
||||
<CustomSelect v-model="filterYear" :options="yearOptions" style="width: 100px;" />
|
||||
</div>
|
||||
</template>
|
||||
</AdminTableFilter>
|
||||
|
||||
<div class="admin-card overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
@@ -21,7 +39,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="work in works" :key="work.id" class="group transition-colors duration-200">
|
||||
<tr v-for="work in filteredWorks" :key="work.id" class="group transition-colors duration-200">
|
||||
<td class="font-mono text-xs text-white/40">#{{ work.id }}</td>
|
||||
<td class="font-medium text-white group-hover:text-art-accent transition-colors">{{ work.title }}</td>
|
||||
<td><span class="px-2 py-1 rounded bg-white/5 border border-white/5 text-xs text-art-muted">{{ work.category }}</span></td>
|
||||
@@ -41,7 +59,7 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="works.length === 0" class="p-16 text-center">
|
||||
<div v-if="filteredWorks.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>
|
||||
@@ -54,12 +72,51 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { getAdminWorks, deleteWork as deleteWorkApi, Work } from '../../services/api'
|
||||
import { useToast } from '../../composables/useToast'
|
||||
import { useAppliedKeyword, matchKeywordFn } from '../../composables/useLocalTableFilter'
|
||||
import AdminTableFilter from '../../components/admin/AdminTableFilter.vue'
|
||||
import CustomSelect from '../../components/CustomSelect.vue'
|
||||
|
||||
const toast = useToast()
|
||||
const works = ref<Work[]>([])
|
||||
const { keyword, appliedKeyword, apply, reset } = useAppliedKeyword()
|
||||
const filterCategory = ref('')
|
||||
const filterYear = ref('')
|
||||
const appliedCategory = ref('')
|
||||
const appliedYear = ref('')
|
||||
|
||||
const categoryOptions = computed(() => {
|
||||
const cats = [...new Set(works.value.map(w => w.category).filter(Boolean))]
|
||||
return [{ value: '', label: '全部分类' }, ...cats.map(c => ({ value: c, label: c }))]
|
||||
})
|
||||
|
||||
const yearOptions = computed(() => {
|
||||
const years = [...new Set(works.value.map(w => w.year).filter(Boolean))].sort().reverse()
|
||||
return [{ value: '', label: '全部年份' }, ...years.map(y => ({ value: y, label: y }))]
|
||||
})
|
||||
|
||||
const filteredWorks = computed(() => works.value.filter(w => {
|
||||
if (!matchKeywordFn(w, appliedKeyword.value, item => item.title)) return false
|
||||
if (appliedCategory.value && w.category !== appliedCategory.value) return false
|
||||
if (appliedYear.value && w.year !== appliedYear.value) return false
|
||||
return true
|
||||
}))
|
||||
|
||||
const applyFilter = () => {
|
||||
apply()
|
||||
appliedCategory.value = filterCategory.value
|
||||
appliedYear.value = filterYear.value
|
||||
}
|
||||
|
||||
const resetFilters = () => {
|
||||
reset()
|
||||
filterCategory.value = ''
|
||||
filterYear.value = ''
|
||||
appliedCategory.value = ''
|
||||
appliedYear.value = ''
|
||||
}
|
||||
|
||||
const fetchWorks = async () => {
|
||||
try {
|
||||
|
||||
@@ -78,6 +78,7 @@ export interface User {
|
||||
id: number
|
||||
username: string
|
||||
email: string
|
||||
avatar?: string
|
||||
role: string
|
||||
isActive: number
|
||||
createdAt: string
|
||||
@@ -166,6 +167,10 @@ export interface Post {
|
||||
content?: string
|
||||
isPublished?: number
|
||||
readCount?: number
|
||||
userId?: number
|
||||
userName?: string
|
||||
userAvatar?: string
|
||||
userEmail?: string
|
||||
snippets?: SnippetBrief[]
|
||||
}
|
||||
|
||||
@@ -185,6 +190,10 @@ export interface PostHistory {
|
||||
isPublished: number
|
||||
modifiedBy: number
|
||||
modifiedByName?: string
|
||||
userId?: number
|
||||
userName?: string
|
||||
userAvatar?: string
|
||||
userEmail?: string
|
||||
modifiedAt: string
|
||||
createdAt: string
|
||||
}
|
||||
@@ -343,6 +352,8 @@ export interface GetUsersParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
keyword?: string
|
||||
role?: string
|
||||
isActive?: number
|
||||
}
|
||||
|
||||
export const getUsersPaginated = async (params: GetUsersParams = {}): Promise<PaginationResponse<User>> => {
|
||||
@@ -350,6 +361,8 @@ export const getUsersPaginated = async (params: GetUsersParams = {}): Promise<Pa
|
||||
const pageSize = params.pageSize ?? 10
|
||||
const qs = new URLSearchParams({ page: String(page), pageSize: String(pageSize) })
|
||||
if (params.keyword) qs.set('keyword', params.keyword)
|
||||
if (params.role) qs.set('role', params.role)
|
||||
if (params.isActive !== undefined && params.isActive >= 0) qs.set('isActive', String(params.isActive))
|
||||
const response = await authFetch(`${API_BASE}/admin/users?${qs}`, {
|
||||
headers: getAuthHeaders()
|
||||
})
|
||||
@@ -601,20 +614,38 @@ export const deleteWork = async (id: string): Promise<void> => {
|
||||
}
|
||||
|
||||
// 文章管理API
|
||||
export interface AdminPostsParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
keyword?: string
|
||||
categoryId?: number
|
||||
columnId?: number
|
||||
isPublished?: number
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
}
|
||||
|
||||
export const getAdminPosts = async (
|
||||
page: number = 1,
|
||||
pageSize: number = 20,
|
||||
keyword?: string
|
||||
params: AdminPostsParams | string = {}
|
||||
): Promise<PaginationResponse<Post>> => {
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
params.append('page', page.toString())
|
||||
params.append('pageSize', pageSize.toString())
|
||||
if (keyword) {
|
||||
params.append('keyword', keyword)
|
||||
const query = new URLSearchParams()
|
||||
query.append('page', page.toString())
|
||||
query.append('pageSize', pageSize.toString())
|
||||
|
||||
const opts: AdminPostsParams = typeof params === 'string' ? { keyword: params } : params
|
||||
if (opts.keyword) query.append('keyword', opts.keyword)
|
||||
if (opts.categoryId) query.append('categoryId', String(opts.categoryId))
|
||||
if (opts.columnId) query.append('columnId', String(opts.columnId))
|
||||
if (opts.isPublished !== undefined && opts.isPublished >= 0) {
|
||||
query.append('isPublished', String(opts.isPublished))
|
||||
}
|
||||
if (opts.startDate) query.append('startDate', opts.startDate)
|
||||
if (opts.endDate) query.append('endDate', opts.endDate)
|
||||
|
||||
const response = await authFetch(`${API_BASE}/admin/posts?${params.toString()}`, {
|
||||
const response = await authFetch(`${API_BASE}/admin/posts?${query.toString()}`, {
|
||||
headers: getAuthHeaders()
|
||||
})
|
||||
return await parseApiResponse(response)
|
||||
@@ -1067,9 +1098,21 @@ export const getHotSearches = async (limit = 10, days = 30): Promise<HotSearchKe
|
||||
}
|
||||
|
||||
// 代码片段管理API
|
||||
export const getAdminSnippets = async (): Promise<Snippet[]> => {
|
||||
export interface AdminSnippetsParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
keyword?: string
|
||||
codeTypeId?: number
|
||||
}
|
||||
|
||||
export const getAdminSnippets = async (params: AdminSnippetsParams = {}): Promise<Snippet[]> => {
|
||||
try {
|
||||
const response = await authFetch(`${API_BASE}/admin/snippets`, {
|
||||
const qs = new URLSearchParams()
|
||||
qs.set('page', String(params.page ?? 1))
|
||||
qs.set('pageSize', String(params.pageSize ?? 500))
|
||||
if (params.keyword) qs.set('keyword', params.keyword)
|
||||
if (params.codeTypeId) qs.set('codeTypeId', String(params.codeTypeId))
|
||||
const response = await authFetch(`${API_BASE}/admin/snippets?${qs}`, {
|
||||
headers: getAuthHeaders()
|
||||
})
|
||||
const data = await parseApiResponse<any>(response)
|
||||
@@ -1080,6 +1123,23 @@ export const getAdminSnippets = async (): Promise<Snippet[]> => {
|
||||
}
|
||||
}
|
||||
|
||||
export const getAdminSnippetsPaginated = async (params: AdminSnippetsParams = {}): Promise<PaginationResponse<Snippet>> => {
|
||||
try {
|
||||
const qs = new URLSearchParams()
|
||||
qs.set('page', String(params.page ?? 1))
|
||||
qs.set('pageSize', String(params.pageSize ?? 20))
|
||||
if (params.keyword) qs.set('keyword', params.keyword)
|
||||
if (params.codeTypeId) qs.set('codeTypeId', String(params.codeTypeId))
|
||||
const response = await authFetch(`${API_BASE}/admin/snippets?${qs}`, {
|
||||
headers: getAuthHeaders()
|
||||
})
|
||||
return await parseApiResponse(response)
|
||||
} catch (error) {
|
||||
console.error('Get admin snippets error:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export const fetchSnippets = async (): Promise<Snippet[]> => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/snippets`)
|
||||
@@ -1691,12 +1751,38 @@ export const fetchEmailSuffixes = async (): Promise<EmailSuffix[]> => {
|
||||
}
|
||||
}
|
||||
|
||||
export const fetchInquiries = async (): Promise<Inquiry[]> => {
|
||||
export interface AdminInquiriesParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
keyword?: string
|
||||
status?: number
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
}
|
||||
|
||||
export const fetchInquiries = async (params: AdminInquiriesParams = {}): Promise<Inquiry[]> => {
|
||||
const data = await fetchInquiriesPaginated(params)
|
||||
return data.list
|
||||
}
|
||||
|
||||
export const fetchInquiriesPaginated = async (params: AdminInquiriesParams = {}): Promise<PaginationResponse<Inquiry>> => {
|
||||
try {
|
||||
const response = await authFetch(`${API_BASE}/admin/inquiries`, {
|
||||
const qs = new URLSearchParams()
|
||||
qs.set('page', String(params.page ?? 1))
|
||||
qs.set('pageSize', String(params.pageSize ?? 100))
|
||||
if (params.keyword) qs.set('keyword', params.keyword)
|
||||
if (params.status !== undefined && params.status >= 0) qs.set('status', String(params.status))
|
||||
if (params.startDate) qs.set('startDate', params.startDate)
|
||||
if (params.endDate) qs.set('endDate', params.endDate)
|
||||
const response = await authFetch(`${API_BASE}/admin/inquiries?${qs}`, {
|
||||
headers: getAuthHeaders()
|
||||
})
|
||||
return await parseApiResponse(response) || []
|
||||
const data = await parseApiResponse<any>(response)
|
||||
if (data && typeof data === 'object' && 'list' in data) {
|
||||
return data as PaginationResponse<Inquiry>
|
||||
}
|
||||
const list = Array.isArray(data) ? data : []
|
||||
return { list, total: list.length, page: 1, size: list.length }
|
||||
} catch (error) {
|
||||
console.error('Fetch inquiries error:', error)
|
||||
throw error
|
||||
|
||||
@@ -272,6 +272,7 @@ func AdminGetAttachments(c *gin.Context) {
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
|
||||
categoryIDStr := c.Query("categoryId")
|
||||
fileType := c.Query("fileType")
|
||||
keyword := c.Query("keyword")
|
||||
|
||||
var categoryID *uint
|
||||
if categoryIDStr != "" {
|
||||
@@ -281,7 +282,7 @@ func AdminGetAttachments(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
attachments, total, err := repositories.GetAttachments(page, pageSize, categoryID, fileType)
|
||||
attachments, total, err := repositories.GetAttachments(page, pageSize, categoryID, fileType, keyword)
|
||||
if err != nil {
|
||||
utils.ServerError(c, err)
|
||||
return
|
||||
|
||||
@@ -2,6 +2,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/niangaodev/art-code/models"
|
||||
@@ -41,16 +42,41 @@ func GetEmailSuffixes(c *gin.Context) {
|
||||
|
||||
// AdminGetInquiries 获取咨询列表
|
||||
func AdminGetInquiries(c *gin.Context) {
|
||||
inquiries, err := repositories.GetInquiries()
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "50"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 {
|
||||
pageSize = 50
|
||||
}
|
||||
|
||||
filter := repositories.AdminInquiryFilter{
|
||||
Keyword: c.Query("keyword"),
|
||||
StartDate: c.Query("startDate"),
|
||||
EndDate: c.Query("endDate"),
|
||||
Status: -1,
|
||||
}
|
||||
if statusStr := c.Query("status"); statusStr != "" {
|
||||
if v, err := strconv.Atoi(statusStr); err == nil {
|
||||
filter.Status = v
|
||||
}
|
||||
}
|
||||
|
||||
inquiries, total, err := repositories.GetInquiriesAdmin(page, pageSize, filter)
|
||||
if err != nil {
|
||||
utils.ServerError(c, err)
|
||||
return
|
||||
}
|
||||
if inquiries == nil {
|
||||
utils.Success(c, []interface{}{})
|
||||
} else {
|
||||
utils.Success(c, inquiries)
|
||||
inquiries = []models.Inquiry{}
|
||||
}
|
||||
utils.Success(c, gin.H{
|
||||
"list": inquiries,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"size": pageSize,
|
||||
})
|
||||
}
|
||||
|
||||
// AdminUpdateInquiryStatus 更新咨询状态
|
||||
|
||||
@@ -235,13 +235,34 @@ func AdminGetPosts(c *gin.Context) {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
// 获取搜索关键词
|
||||
keyword := c.Query("keyword")
|
||||
if keyword == "" {
|
||||
keyword = c.Query("q") // 兼容 q 参数
|
||||
// 获取搜索与筛选参数
|
||||
filter := repositories.AdminPostFilter{
|
||||
Keyword: c.Query("keyword"),
|
||||
}
|
||||
if filter.Keyword == "" {
|
||||
filter.Keyword = c.Query("q")
|
||||
}
|
||||
if categoryIDStr := c.Query("categoryId"); categoryIDStr != "" {
|
||||
if id, err := strconv.ParseUint(categoryIDStr, 10, 32); err == nil {
|
||||
filter.CategoryID = uint(id)
|
||||
}
|
||||
}
|
||||
if columnIDStr := c.Query("columnId"); columnIDStr != "" {
|
||||
if id, err := strconv.ParseUint(columnIDStr, 10, 32); err == nil {
|
||||
filter.ColumnID = uint(id)
|
||||
}
|
||||
}
|
||||
if pubStr := c.Query("isPublished"); pubStr != "" {
|
||||
if v, err := strconv.Atoi(pubStr); err == nil {
|
||||
filter.IsPublished = v
|
||||
}
|
||||
} else {
|
||||
filter.IsPublished = -1
|
||||
}
|
||||
filter.StartDate = c.Query("startDate")
|
||||
filter.EndDate = c.Query("endDate")
|
||||
|
||||
posts, total, err := repositories.GetAllPosts(page, pageSize, keyword)
|
||||
posts, total, err := repositories.GetAllPosts(page, pageSize, filter)
|
||||
if err != nil {
|
||||
utils.ServerError(c, err)
|
||||
return
|
||||
@@ -270,6 +291,11 @@ func AdminCreatePost(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 创建文章
|
||||
if userID, ok := c.Get("userID"); ok {
|
||||
if uid, ok := userID.(uint); ok {
|
||||
post.UserID = uid
|
||||
}
|
||||
}
|
||||
if err := repositories.CreatePost(&post); err != nil {
|
||||
utils.ServerError(c, err)
|
||||
return
|
||||
|
||||
@@ -80,7 +80,16 @@ func AdminGetSnippets(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10"))
|
||||
|
||||
snippets, total, err := repositories.GetAdminSnippets(page, pageSize)
|
||||
filter := repositories.AdminSnippetFilter{
|
||||
Keyword: c.Query("keyword"),
|
||||
}
|
||||
if typeIDStr := c.Query("codeTypeId"); typeIDStr != "" {
|
||||
if id, err := strconv.ParseUint(typeIDStr, 10, 32); err == nil {
|
||||
filter.CodeTypeID = uint(id)
|
||||
}
|
||||
}
|
||||
|
||||
snippets, total, err := repositories.GetAdminSnippets(page, pageSize, filter)
|
||||
if err != nil {
|
||||
utils.ServerError(c, err)
|
||||
return
|
||||
|
||||
@@ -19,8 +19,15 @@ func AdminGetUsers(c *gin.Context) {
|
||||
if keyword == "" {
|
||||
keyword = c.Query("q")
|
||||
}
|
||||
role := c.Query("role")
|
||||
isActive := -1
|
||||
if s := c.Query("isActive"); s != "" {
|
||||
if v, err := strconv.Atoi(s); err == nil {
|
||||
isActive = v
|
||||
}
|
||||
}
|
||||
|
||||
users, total, err := repositories.GetUsers(page, pageSize, keyword)
|
||||
users, total, err := repositories.GetUsers(page, pageSize, keyword, role, isActive)
|
||||
if err != nil {
|
||||
utils.ServerError(c, err)
|
||||
return
|
||||
|
||||
@@ -24,6 +24,8 @@ func main() {
|
||||
// repositories.MigrateToBigInt() // 已禁用自动迁移
|
||||
repositories.MigratePostHistoryFields()
|
||||
repositories.MigratePostSnippets()
|
||||
repositories.MigratePostUserID()
|
||||
repositories.MigrateUserAvatar()
|
||||
|
||||
// 初始化ip2region (如果文件不存在,将降级为普通IP记录)
|
||||
// 函数会自动从环境变量或可执行文件目录查找 ip2region.xdb
|
||||
|
||||
@@ -20,6 +20,7 @@ type Post struct {
|
||||
Content string `json:"content" gorm:"column:content;type:text"`
|
||||
ReadCount uint `json:"readCount" gorm:"column:read_count;default:0"`
|
||||
IsPublished int `json:"isPublished" gorm:"column:is_published;default:0"` // 0: draft, 1: published
|
||||
UserID uint `json:"userId" gorm:"column:user_id;default:0"`
|
||||
Tags []Tag `json:"tags" gorm:"many2many:post_tags;joinForeignKey:post_id;joinReferences:tag_id"`
|
||||
CreatedAt int64 `json:"createdAt" gorm:"column:created_at"`
|
||||
UpdatedAt int64 `json:"updatedAt" gorm:"column:updated_at"`
|
||||
@@ -68,6 +69,10 @@ type PostResponse struct {
|
||||
Tags []Tag `json:"tags,omitempty"`
|
||||
IsPublished int `json:"isPublished,omitempty"`
|
||||
ReadCount uint `json:"readCount,omitempty"`
|
||||
UserID uint `json:"userId,omitempty"`
|
||||
UserName string `json:"userName,omitempty"`
|
||||
UserAvatar string `json:"userAvatar,omitempty"`
|
||||
UserEmail string `json:"userEmail,omitempty"`
|
||||
Snippets []SnippetBrief `json:"snippets,omitempty"`
|
||||
}
|
||||
|
||||
@@ -133,6 +138,10 @@ type PostHistoryResponse struct {
|
||||
IsPublished int `json:"isPublished"`
|
||||
ModifiedBy uint `json:"modifiedBy"`
|
||||
ModifiedByName string `json:"modifiedByName,omitempty"`
|
||||
UserID uint `json:"userId"`
|
||||
UserName string `json:"userName,omitempty"`
|
||||
UserAvatar string `json:"userAvatar,omitempty"`
|
||||
UserEmail string `json:"userEmail,omitempty"`
|
||||
ColumnName string `json:"columnName,omitempty"`
|
||||
TagNames []string `json:"tagNames,omitempty"`
|
||||
ModifiedAt string `json:"modifiedAt"`
|
||||
|
||||
@@ -11,6 +11,7 @@ type User struct {
|
||||
ID uint `json:"id" gorm:"primaryKey;column:id"`
|
||||
Username string `json:"username" gorm:"column:username;uniqueIndex;not null"`
|
||||
Email string `json:"email" gorm:"column:email"`
|
||||
Avatar string `json:"avatar" gorm:"column:avatar"`
|
||||
Password string `json:"password,omitempty" gorm:"-"` // Virtual field for input
|
||||
PasswordHash string `json:"-" gorm:"column:password_hash"`
|
||||
RoleID uint `json:"roleId" gorm:"column:role_id"`
|
||||
@@ -52,6 +53,7 @@ type UserResponse struct {
|
||||
ID uint `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Avatar string `json:"avatar,omitempty"`
|
||||
RoleID uint `json:"roleId"`
|
||||
Role string `json:"role"`
|
||||
IsActive int `json:"isActive"`
|
||||
|
||||
@@ -312,6 +312,7 @@ CREATE TABLE `posts` (
|
||||
`content` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '文章内容',
|
||||
`read_count` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '阅读量',
|
||||
`is_published` tinyint(1) NOT NULL DEFAULT 1 COMMENT '是否已发布(0:草稿,1:已发布)',
|
||||
`user_id` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建者用户ID',
|
||||
`deleted_at` bigint NOT NULL DEFAULT 0,
|
||||
`created_at` bigint NOT NULL DEFAULT 0,
|
||||
`updated_at` bigint NOT NULL DEFAULT 0,
|
||||
@@ -319,6 +320,7 @@ CREATE TABLE `posts` (
|
||||
INDEX `idx_category_id`(`category_id` ASC) USING BTREE COMMENT '按分类查询索引',
|
||||
INDEX `idx_column_id`(`column_id` ASC) USING BTREE COMMENT '按专栏查询索引',
|
||||
INDEX `idx_is_published`(`is_published` ASC) USING BTREE COMMENT '按发布状态查询索引',
|
||||
INDEX `idx_user_id`(`user_id` ASC) USING BTREE,
|
||||
INDEX `idx_original_id`(`original_id` ASC) USING BTREE,
|
||||
FULLTEXT INDEX `idx_title_content`(`title`, `content`) COMMENT '标题和内容全文索引,用于搜索'
|
||||
) ENGINE = InnoDB AUTO_INCREMENT = 36 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '博客文章表(新结构)' ROW_FORMAT = DYNAMIC;
|
||||
@@ -478,6 +480,7 @@ CREATE TABLE `users` (
|
||||
`id` bigint UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
`username` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '用户名',
|
||||
`email` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '邮箱地址',
|
||||
`avatar` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '头像URL',
|
||||
`password_hash` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '密码哈希值',
|
||||
`role_id` bigint UNSIGNED NULL DEFAULT NULL COMMENT '角色ID',
|
||||
`role` enum('admin','editor','viewer') CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT 'viewer' COMMENT '用户角色(兼容旧版)',
|
||||
|
||||
@@ -20,7 +20,7 @@ func CreateAttachment(attachment *models.Attachment) error {
|
||||
}
|
||||
|
||||
// GetAttachments 获取附件列表(分页)
|
||||
func GetAttachments(page, pageSize int, categoryID *uint, fileType string) ([]models.Attachment, int64, error) {
|
||||
func GetAttachments(page, pageSize int, categoryID *uint, fileType, keyword string) ([]models.Attachment, int64, error) {
|
||||
offset := (page - 1) * pageSize
|
||||
|
||||
var attachments []models.Attachment
|
||||
@@ -38,6 +38,11 @@ func GetAttachments(page, pageSize int, categoryID *uint, fileType string) ([]mo
|
||||
query = query.Where("file_type = ?", fileType)
|
||||
}
|
||||
|
||||
if keyword != "" {
|
||||
like := "%" + keyword + "%"
|
||||
query = query.Where("original_name LIKE ?", like)
|
||||
}
|
||||
|
||||
// Count total
|
||||
err := query.Count(&total).Error
|
||||
if err != nil {
|
||||
|
||||
@@ -18,17 +18,73 @@ func CreateInquiry(inquiry *models.Inquiry) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetInquiries 获取咨询列表 (Admin)
|
||||
func GetInquiries() ([]models.Inquiry, error) {
|
||||
// AdminInquiryFilter 咨询列表筛选
|
||||
type AdminInquiryFilter struct {
|
||||
Keyword string
|
||||
Status int // -1 表示全部
|
||||
StartDate string
|
||||
EndDate string
|
||||
}
|
||||
|
||||
// GetInquiriesAdmin 获取咨询列表 (Admin,分页)
|
||||
func GetInquiriesAdmin(page, pageSize int, filter AdminInquiryFilter) ([]models.Inquiry, int64, error) {
|
||||
offset := (page - 1) * pageSize
|
||||
var inquiries []models.Inquiry
|
||||
err := config.DB.Model(&models.Inquiry{}).
|
||||
Where("deleted_at = ?", 0).
|
||||
Order("created_at DESC").
|
||||
Find(&inquiries).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
var total int64
|
||||
|
||||
query := config.DB.Model(&models.Inquiry{}).
|
||||
Where("deleted_at = ?", 0)
|
||||
|
||||
if filter.Keyword != "" {
|
||||
like := "%" + filter.Keyword + "%"
|
||||
query = query.Where("(name LIKE ? OR company LIKE ? OR contact_value LIKE ? OR description LIKE ?)",
|
||||
like, like, like, like)
|
||||
}
|
||||
return inquiries, nil
|
||||
if filter.Status >= 0 {
|
||||
query = query.Where("status = ?", filter.Status)
|
||||
}
|
||||
if filter.StartDate != "" {
|
||||
if ts, err := parseInquiryDateStart(filter.StartDate); err == nil {
|
||||
query = query.Where("created_at >= ?", ts)
|
||||
}
|
||||
}
|
||||
if filter.EndDate != "" {
|
||||
if ts, err := parseInquiryDateEnd(filter.EndDate); err == nil {
|
||||
query = query.Where("created_at <= ?", ts)
|
||||
}
|
||||
}
|
||||
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
err := query.Order("created_at DESC").
|
||||
Limit(pageSize).
|
||||
Offset(offset).
|
||||
Find(&inquiries).Error
|
||||
return inquiries, total, err
|
||||
}
|
||||
|
||||
func parseInquiryDateStart(dateStr string) (int64, error) {
|
||||
t, err := time.ParseInLocation("2006-01-02", dateStr, time.Local)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return t.Unix(), nil
|
||||
}
|
||||
|
||||
func parseInquiryDateEnd(dateStr string) (int64, error) {
|
||||
t, err := time.ParseInLocation("2006-01-02", dateStr, time.Local)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return t.Add(24*time.Hour - time.Second).Unix(), nil
|
||||
}
|
||||
|
||||
// GetInquiries 获取咨询列表 (Admin) — 兼容旧调用
|
||||
func GetInquiries() ([]models.Inquiry, error) {
|
||||
list, _, err := GetInquiriesAdmin(1, 10000, AdminInquiryFilter{Status: -1})
|
||||
return list, err
|
||||
}
|
||||
|
||||
// GetInquiryCount 获取咨询总数
|
||||
|
||||
@@ -156,3 +156,22 @@ func MigratePostSnippets() {
|
||||
INDEX idx_sort_order (sort_order)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='文章代码片段关联表'`)
|
||||
}
|
||||
|
||||
// MigratePostUserID adds user_id to posts if missing.
|
||||
func MigratePostUserID() {
|
||||
if columnExists("posts", "user_id") {
|
||||
return
|
||||
}
|
||||
log.Printf("Adding posts.user_id...")
|
||||
execSQL("ALTER TABLE `posts` ADD COLUMN `user_id` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建者用户ID' AFTER `is_published`")
|
||||
execSQL("ALTER TABLE `posts` ADD INDEX `idx_user_id` (`user_id`)")
|
||||
}
|
||||
|
||||
// MigrateUserAvatar adds avatar to users if missing.
|
||||
func MigrateUserAvatar() {
|
||||
if columnExists("users", "avatar") {
|
||||
return
|
||||
}
|
||||
log.Printf("Adding users.avatar...")
|
||||
execSQL("ALTER TABLE `users` ADD COLUMN `avatar` VARCHAR(500) NULL DEFAULT NULL COMMENT '头像URL' AFTER `email`")
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ func GetPostByID(id uint) (*models.Post, error) {
|
||||
var post models.Post
|
||||
// 显式选择所有字段,确保 content 字段被加载
|
||||
err := config.DB.Model(&models.Post{}).
|
||||
Select("id", "title", "category_id", "column_id", "excerpt", "content", "read_count", "is_published", "created_at", "updated_at", "deleted_at").
|
||||
Select("id", "title", "category_id", "column_id", "excerpt", "content", "read_count", "is_published", "user_id", "created_at", "updated_at", "deleted_at").
|
||||
Preload("Category").
|
||||
Preload("Column").
|
||||
Preload("Tags").
|
||||
@@ -107,25 +107,50 @@ func IncrementReadCount(id uint) error {
|
||||
UpdateColumn("read_count", gorm.Expr("read_count + ?", 1)).Error
|
||||
}
|
||||
|
||||
// GetAllPosts 获取所有博客文章(包括未发布的,后台用,支持搜索)
|
||||
func GetAllPosts(page, pageSize int, keyword string) ([]models.Post, int64, error) {
|
||||
// GetAllPosts 获取所有博客文章(包括未发布的,后台用,支持搜索与筛选)
|
||||
type AdminPostFilter struct {
|
||||
Keyword string
|
||||
CategoryID uint
|
||||
ColumnID uint
|
||||
IsPublished int // -1 表示全部
|
||||
StartDate string
|
||||
EndDate string
|
||||
}
|
||||
|
||||
func GetAllPosts(page, pageSize int, filter AdminPostFilter) ([]models.Post, int64, error) {
|
||||
offset := (page - 1) * pageSize
|
||||
|
||||
var posts []models.Post
|
||||
var total int64
|
||||
|
||||
// 构建查询
|
||||
query := config.DB.Model(&models.Post{}).
|
||||
Where("deleted_at = ?", 0)
|
||||
|
||||
// 如果有关键词,添加搜索条件
|
||||
if keyword != "" {
|
||||
likeKeyword := "%" + keyword + "%"
|
||||
if filter.Keyword != "" {
|
||||
likeKeyword := "%" + filter.Keyword + "%"
|
||||
query = query.Where("(MATCH(title, content) AGAINST(? IN BOOLEAN MODE) OR title LIKE ? OR content LIKE ?)",
|
||||
keyword, likeKeyword, likeKeyword)
|
||||
filter.Keyword, likeKeyword, likeKeyword)
|
||||
}
|
||||
if filter.CategoryID > 0 {
|
||||
query = query.Where("category_id = ?", filter.CategoryID)
|
||||
}
|
||||
if filter.ColumnID > 0 {
|
||||
query = query.Where("column_id = ?", filter.ColumnID)
|
||||
}
|
||||
if filter.IsPublished >= 0 {
|
||||
query = query.Where("is_published = ?", filter.IsPublished)
|
||||
}
|
||||
if filter.StartDate != "" {
|
||||
if ts, err := parseDateStart(filter.StartDate); err == nil {
|
||||
query = query.Where("created_at >= ?", ts)
|
||||
}
|
||||
}
|
||||
if filter.EndDate != "" {
|
||||
if ts, err := parseDateEnd(filter.EndDate); err == nil {
|
||||
query = query.Where("created_at <= ?", ts)
|
||||
}
|
||||
}
|
||||
|
||||
// Count total
|
||||
err := query.Count(&total).Error
|
||||
if err != nil {
|
||||
log.Printf("Error counting posts: %v", err)
|
||||
@@ -365,10 +390,10 @@ func BuildPostResponse(post *models.Post, includeContent bool) *models.PostRespo
|
||||
colSlug := ""
|
||||
if post.Column != nil {
|
||||
colName = post.Column.Name
|
||||
// 如果 Column 有 Slug 字段,可以在这里添加
|
||||
// colSlug = post.Column.Slug
|
||||
}
|
||||
|
||||
userName, userAvatar, userEmail := lookupUserBrief(post.UserID)
|
||||
|
||||
response := &models.PostResponse{
|
||||
ID: post.ID,
|
||||
Title: post.Title,
|
||||
@@ -383,6 +408,10 @@ func BuildPostResponse(post *models.Post, includeContent bool) *models.PostRespo
|
||||
Tags: post.Tags,
|
||||
IsPublished: post.IsPublished,
|
||||
ReadCount: post.ReadCount,
|
||||
UserID: post.UserID,
|
||||
UserName: userName,
|
||||
UserAvatar: userAvatar,
|
||||
UserEmail: userEmail,
|
||||
}
|
||||
|
||||
if includeContent {
|
||||
@@ -562,6 +591,7 @@ func GetPostHistoryByVersion(postID uint, version uint) (*models.PostHistory, er
|
||||
|
||||
// BuildPostHistoryResponse 构建历史记录响应
|
||||
func BuildPostHistoryResponse(h *models.PostHistory, includeFull bool) *models.PostHistoryResponse {
|
||||
opName, opAvatar, opEmail := lookupUserBrief(h.ModifiedBy)
|
||||
resp := &models.PostHistoryResponse{
|
||||
ID: h.ID,
|
||||
PostID: h.PostID,
|
||||
@@ -576,7 +606,11 @@ func BuildPostHistoryResponse(h *models.PostHistory, includeFull bool) *models.P
|
||||
Date: time.Unix(h.CreatedAt, 0).Format("2006-01-02"),
|
||||
IsPublished: h.IsPublished,
|
||||
ModifiedBy: h.ModifiedBy,
|
||||
ModifiedByName: lookupUsername(h.ModifiedBy),
|
||||
ModifiedByName: opName,
|
||||
UserID: h.ModifiedBy,
|
||||
UserName: opName,
|
||||
UserAvatar: opAvatar,
|
||||
UserEmail: opEmail,
|
||||
ModifiedAt: time.Unix(h.ModifiedAt, 0).Format("2006-01-02 15:04:05"),
|
||||
CreatedAt: time.Unix(h.CreatedAt, 0).Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
@@ -620,13 +654,24 @@ func lookupTagNames(ids []uint) []string {
|
||||
return names
|
||||
}
|
||||
|
||||
func lookupUsername(id uint) string {
|
||||
func lookupUserBrief(id uint) (username, avatar, email string) {
|
||||
if id == 0 {
|
||||
return ""
|
||||
return "", "", ""
|
||||
}
|
||||
var username string
|
||||
config.DB.Model(&models.User{}).Where("id = ?", id).Pluck("username", &username)
|
||||
return username
|
||||
var user models.User
|
||||
err := config.DB.Model(&models.User{}).
|
||||
Select("username", "avatar", "email").
|
||||
Where("id = ? AND deleted_at = ?", id, 0).
|
||||
First(&user).Error
|
||||
if err != nil {
|
||||
return "", "", ""
|
||||
}
|
||||
return user.Username, user.Avatar, user.Email
|
||||
}
|
||||
|
||||
func lookupUsername(id uint) string {
|
||||
name, _, _ := lookupUserBrief(id)
|
||||
return name
|
||||
}
|
||||
|
||||
func formatPublishedLabel(v int) string {
|
||||
@@ -857,3 +902,19 @@ func GetRecommendedPostsByIP(currentPostID uint, userIP string, limit int) ([]mo
|
||||
|
||||
return sortedPosts, nil
|
||||
}
|
||||
|
||||
func parseDateStart(dateStr string) (int64, error) {
|
||||
t, err := time.ParseInLocation("2006-01-02", dateStr, time.Local)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return t.Unix(), nil
|
||||
}
|
||||
|
||||
func parseDateEnd(dateStr string) (int64, error) {
|
||||
t, err := time.ParseInLocation("2006-01-02", dateStr, time.Local)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return t.Add(24*time.Hour - time.Second).Unix(), nil
|
||||
}
|
||||
|
||||
@@ -133,25 +133,38 @@ func DeleteSnippet(id uint) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// AdminSnippetFilter 后台代码片段筛选
|
||||
type AdminSnippetFilter struct {
|
||||
Keyword string
|
||||
CodeTypeID uint
|
||||
}
|
||||
|
||||
// GetAdminSnippets 获取后台代码片段列表 (分页)
|
||||
func GetAdminSnippets(page, pageSize int) ([]models.Snippet, int, error) {
|
||||
func GetAdminSnippets(page, pageSize int, filter AdminSnippetFilter) ([]models.Snippet, int, error) {
|
||||
offset := (page - 1) * pageSize
|
||||
|
||||
var snippets []models.Snippet
|
||||
var total int64
|
||||
|
||||
// 获取总数
|
||||
err := config.DB.Model(&models.Snippet{}).
|
||||
Where("deleted_at = ?", 0).
|
||||
Count(&total).Error
|
||||
query := config.DB.Model(&models.Snippet{}).
|
||||
Where("deleted_at = ?", 0)
|
||||
|
||||
if filter.Keyword != "" {
|
||||
like := "%" + filter.Keyword + "%"
|
||||
query = query.Where("title LIKE ?", like)
|
||||
}
|
||||
if filter.CodeTypeID > 0 {
|
||||
query = query.Where("code_type_id = ?", filter.CodeTypeID)
|
||||
}
|
||||
|
||||
err := query.Count(&total).Error
|
||||
if err != nil {
|
||||
log.Printf("Error getting snippet count: %v", err)
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// 获取列表
|
||||
err = config.DB.Model(&models.Snippet{}).
|
||||
Where("deleted_at = ?", 0).
|
||||
err = query.
|
||||
Order("created_at DESC").
|
||||
Limit(pageSize).
|
||||
Offset(offset).
|
||||
|
||||
@@ -49,17 +49,24 @@ func GetUserByID(id uint) (*models.User, error) {
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// GetUsers 获取所有用户 (分页,支持 keyword 搜索)
|
||||
func GetUsers(page, pageSize int, keyword string) ([]models.User, int, error) {
|
||||
// GetUsers 获取所有用户 (分页,支持 keyword / role / isActive 搜索)
|
||||
func GetUsers(page, pageSize int, keyword, role string, isActive int) ([]models.User, int, error) {
|
||||
offset := (page - 1) * pageSize
|
||||
|
||||
query := config.DB.Model(&models.User{}).
|
||||
Where("users.deleted_at = ?", 0)
|
||||
Where("users.deleted_at = ?", 0).
|
||||
Joins("LEFT JOIN roles ON users.role_id = roles.id AND roles.deleted_at = 0")
|
||||
|
||||
if keyword != "" {
|
||||
like := "%" + keyword + "%"
|
||||
query = query.Where("users.username LIKE ? OR users.email LIKE ?", like, like)
|
||||
}
|
||||
if role != "" {
|
||||
query = query.Where("COALESCE(roles.name, users.role) = ?", role)
|
||||
}
|
||||
if isActive >= 0 {
|
||||
query = query.Where("users.is_active = ?", isActive)
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
@@ -67,10 +74,23 @@ func GetUsers(page, pageSize int, keyword string) ([]models.User, int, error) {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
listQuery := config.DB.Model(&models.User{}).
|
||||
Where("users.deleted_at = ?", 0).
|
||||
Joins("LEFT JOIN roles ON users.role_id = roles.id AND roles.deleted_at = 0")
|
||||
if keyword != "" {
|
||||
like := "%" + keyword + "%"
|
||||
listQuery = listQuery.Where("users.username LIKE ? OR users.email LIKE ?", like, like)
|
||||
}
|
||||
if role != "" {
|
||||
listQuery = listQuery.Where("COALESCE(roles.name, users.role) = ?", role)
|
||||
}
|
||||
if isActive >= 0 {
|
||||
listQuery = listQuery.Where("users.is_active = ?", isActive)
|
||||
}
|
||||
|
||||
var users []models.User
|
||||
err := query.
|
||||
err := listQuery.
|
||||
Select("users.*, COALESCE(roles.name, users.role) as role").
|
||||
Joins("LEFT JOIN roles ON users.role_id = roles.id").
|
||||
Order("users.created_at DESC").
|
||||
Limit(pageSize).
|
||||
Offset(offset).
|
||||
@@ -116,6 +136,7 @@ func UpdateUser(user *models.User) error {
|
||||
updates := map[string]interface{}{
|
||||
"username": user.Username,
|
||||
"email": user.Email,
|
||||
"avatar": user.Avatar,
|
||||
"role": user.Role,
|
||||
"is_active": user.IsActive,
|
||||
"updated_at": time.Now().Unix(),
|
||||
@@ -187,6 +208,7 @@ func BuildUserResponse(user *models.User) *models.UserResponse {
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Email: user.Email,
|
||||
Avatar: user.Avatar,
|
||||
RoleID: user.RoleID,
|
||||
Role: user.Role,
|
||||
IsActive: user.IsActive,
|
||||
|
||||
Reference in New Issue
Block a user