优化页面、修复BUG

This commit is contained in:
李琦
2026-06-25 17:57:51 +08:00
parent 20fb0e3203
commit 55597cd8b9
40 changed files with 1680 additions and 324 deletions

View 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>

View 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>

View 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>

View File

@@ -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: '标题',

View 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]
}
})
}

View File

@@ -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()

View File

@@ -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'

View File

@@ -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()

View File

@@ -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()

View File

@@ -8,6 +8,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">
<table class="admin-table">
@@ -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 {

View File

@@ -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`))

View File

@@ -8,6 +8,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">
<table class="admin-table">
@@ -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 {

View File

@@ -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); }

View File

@@ -8,6 +8,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">
<table class="admin-table">
@@ -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 {

View File

@@ -23,6 +23,20 @@
</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">
<table class="admin-table">
@@ -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 {

View File

@@ -2,6 +2,28 @@
<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">
<thead>
@@ -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

View File

@@ -10,6 +10,13 @@
</button>
</div>
<AdminTableFilter
v-model:keyword="keyword"
keyword-placeholder="搜索合作伙伴名称..."
@search="applyFilter"
@reset="resetFilters"
/>
<!-- Partners Table -->
<div class="table-container">
<table class="admin-table">
@@ -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 {

View File

@@ -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>

View File

@@ -8,6 +8,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">
<thead>
@@ -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>

View File

@@ -10,6 +10,13 @@
</button>
</div>
<AdminTableFilter
v-model:keyword="keyword"
keyword-placeholder="搜索标签名称..."
@search="applyFilter"
@reset="resetFilters"
/>
<!-- Tags Table -->
<div class="table-container">
<table class="admin-table">
@@ -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 () => {

View File

@@ -10,6 +10,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">
<table class="admin-table">
@@ -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 {

View File

@@ -34,6 +34,18 @@
</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">
<label for="role">角色</label>
@@ -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) {

View File

@@ -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
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.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(() => {
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;

View File

@@ -8,6 +8,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">
<table class="admin-table">
@@ -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 {

View File

@@ -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 response = await authFetch(`${API_BASE}/admin/posts?${params.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?${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

View File

@@ -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

View File

@@ -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 更新咨询状态

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -24,6 +24,8 @@ func main() {
// repositories.MigrateToBigInt() // 已禁用自动迁移
repositories.MigratePostHistoryFields()
repositories.MigratePostSnippets()
repositories.MigratePostUserID()
repositories.MigrateUserAvatar()
// 初始化ip2region (如果文件不存在将降级为普通IP记录)
// 函数会自动从环境变量或可执行文件目录查找 ip2region.xdb

View File

@@ -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"`

View File

@@ -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"`

View File

@@ -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 '用户角色(兼容旧版)',

View File

@@ -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 {

View File

@@ -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 获取咨询总数

View File

@@ -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`")
}

View File

@@ -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
}

View File

@@ -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).

View File

@@ -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,