优化页面、修复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
|
||||
|
||||
Reference in New Issue
Block a user