优化页面、修复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

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

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

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

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

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

View File

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

View File

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

View File

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

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

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

View File

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

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="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 {

View File

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

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
// 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;

View File

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