feat: 证书页面添加过滤动画,空状态样式,搜索反馈

This commit is contained in:
QDKF 2025-09-17 22:41:42 +08:00
parent 3c3c2063b0
commit 8de56bd07c
3 changed files with 410 additions and 56 deletions

26
pnpm-lock.yaml generated
View File

@ -8,6 +8,9 @@ importers:
.:
dependencies:
'@types/sortablejs':
specifier: ^1.15.8
version: 1.15.8
'@vicons/ionicons5':
specifier: ^0.13.0
version: 0.13.0
@ -59,6 +62,9 @@ importers:
vue-router:
specifier: ^4.5.1
version: 4.5.1(vue@3.5.18(typescript@5.9.2))
vuedraggable:
specifier: ^4.1.0
version: 4.1.0(vue@3.5.18(typescript@5.9.2))
devDependencies:
'@types/dplayer':
specifier: ^1.25.5
@ -589,6 +595,9 @@ packages:
'@types/node@24.2.1':
resolution: {integrity: sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ==}
'@types/sortablejs@1.15.8':
resolution: {integrity: sha512-b79830lW+RZfwaztgs1aVPgbasJ8e7AXtZYHTELNXZPsERt4ymJdjV4OccDbHQAvHrCcFpbF78jkm0R6h/pZVg==}
'@uppy/companion-client@2.2.2':
resolution: {integrity: sha512-5mTp2iq97/mYSisMaBtFRry6PTgZA6SIL7LePteOV5x0/DxKfrZW3DEiQERJmYpHzy7k8johpm2gHnEKto56Og==}
@ -1451,6 +1460,9 @@ packages:
resolution: {integrity: sha512-ig5qOnCDbugFntKi6c7Xlib8bA6xiJVk8O+WdFrV3wxbMqeHO0hXFQC4nAhPVWfZfi8255lcZkNhtIBINCc4+Q==}
engines: {node: '>=12.17.0'}
sortablejs@1.14.0:
resolution: {integrity: sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==}
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@ -1644,6 +1656,11 @@ packages:
typescript:
optional: true
vuedraggable@4.1.0:
resolution: {integrity: sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww==}
peerDependencies:
vue: ^3.0.1
vueuc@0.4.64:
resolution: {integrity: sha512-wlJQj7fIwKK2pOEoOq4Aro8JdPOGpX8aWQhV8YkTW9OgWD2uj2O8ANzvSsIGjx7LTOc7QbS7sXdxHi6XvRnHPA==}
peerDependencies:
@ -2085,6 +2102,8 @@ snapshots:
dependencies:
undici-types: 7.10.0
'@types/sortablejs@1.15.8': {}
'@uppy/companion-client@2.2.2':
dependencies:
'@uppy/utils': 4.1.3
@ -3077,6 +3096,8 @@ snapshots:
snabbdom@3.6.2: {}
sortablejs@1.14.0: {}
source-map-js@1.2.1: {}
speakingurl@14.0.1: {}
@ -3242,6 +3263,11 @@ snapshots:
optionalDependencies:
typescript: 5.9.2
vuedraggable@4.1.0(vue@3.5.18(typescript@5.9.2)):
dependencies:
sortablejs: 1.14.0
vue: 3.5.18(typescript@5.9.2)
vueuc@0.4.64(vue@3.5.18(typescript@5.9.2)):
dependencies:
'@css-render/vue3-ssr': 0.15.14(vue@3.5.18(typescript@5.9.2))

View File

@ -54,38 +54,46 @@
<button class="btn btn-primary" @click="showIssuanceModal = true">颁发证书</button>
<button class="btn btn-danger">删除</button>
<div class="filter-dropdown">
<select class="filter-select">
<select v-model="selectedClass" @change="handleFilterChange" class="filter-select">
<option value="">班级名称</option>
<option value="class1">班级名称1</option>
<option value="class2">班级名称2</option>
</select>
</div>
<div class="search-box">
<input type="text" placeholder="请输入关键词" class="search-input" />
<input type="text" placeholder="请输入关键词" v-model="searchKeyword" @input="handleSearchInput"
class="search-input" />
<button class="btn btn-search">搜索</button>
</div>
</div>
</div>
<!-- 证书颁发记录表格 -->
<div class="record-table">
<n-data-table
:columns="columns"
:data="issuanceRecords"
:pagination="false"
:bordered="false"
:single-line="false"
:row-key="(row) => row.id"
/>
</div>
<!-- 搜索无结果空状态 -->
<div v-if="issuanceRecords.length === 0 && searchKeyword" class="empty-state search-empty">
<h3>未找到相关记录</h3>
<p>没有找到包含"{{ searchKeyword }}"的颁发记录请尝试其他关键词</p>
<button class="btn btn-secondary" @click="clearSearch">清除搜索</button>
</div>
<!-- 无记录空状态 -->
<div v-else-if="issuanceRecords.length === 0" class="empty-state">
<h3>暂无颁发记录</h3>
<p>还没有颁发任何证书点击"颁发证书"开始颁发</p>
<button class="btn btn-primary" @click="showIssuanceModal = true">颁发证书</button>
</div>
<!-- 证书颁发记录表格 -->
<div v-else class="record-table">
<transition-group name="table-fade" tag="div" :key="listKey">
<n-data-table :key="'table'" :columns="columns" :data="issuanceRecords" :pagination="false"
:bordered="false" :single-line="false" :row-key="(row) => row.id" />
</transition-group>
</div>
</div>
</div>
<!-- 证书颁发模态框 -->
<CertificateIssuanceModal
v-model:show="showIssuanceModal"
@confirm="handleIssuanceConfirm"
/>
<CertificateIssuanceModal v-model:show="showIssuanceModal" @confirm="handleIssuanceConfirm" />
</div>
</template>
@ -164,6 +172,15 @@ const issuanceRecords = ref<IssuanceRecord[]>([
//
const showIssuanceModal = ref(false)
//
const searchKeyword = ref('')
//
const selectedClass = ref('')
// key
const listKey = ref(0)
//
const columns = [
{
@ -285,6 +302,25 @@ const handleDelete = (record: IssuanceRecord) => {
message.success('删除成功')
}
//
const clearSearch = () => {
searchKeyword.value = ''
message.info('已清除搜索条件')
}
//
const handleSearchInput = () => {
//
listKey.value++
}
//
const handleFilterChange = () => {
//
listKey.value++
message.info('筛选条件已更新')
}
//
const handleIssuanceConfirm = (selectedExams: any[]) => {
console.log('选中的考试/学习项目:', selectedExams)
@ -604,8 +640,86 @@ const handleIssuanceConfirm = (selectedExams: any[]) => {
font-size: 12px;
}
th, td {
th,
td {
padding: 8px 4px;
}
}
/* ========== 过滤动画效果 ========== */
/* 表格过渡动画 */
.table-fade-enter-active,
.table-fade-leave-active {
transition: all 0.3s ease;
}
.table-fade-enter-from {
opacity: 0;
transform: translateY(20px);
}
.table-fade-leave-to {
opacity: 0;
transform: translateY(-20px);
}
.table-fade-move {
transition: transform 0.3s ease;
}
/* ========== 空状态样式 ========== */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 300px;
background: #fff;
margin: 20px;
border-radius: 8px;
text-align: center;
padding: 40px 20px;
}
.empty-state h3 {
color: #333;
font-size: 18px;
margin: 0 0 12px 0;
font-weight: 500;
}
.empty-state p {
color: #666;
font-size: 14px;
margin: 0 0 20px 0;
line-height: 1.5;
max-width: 280px;
}
.empty-state .btn {
padding: 8px 20px;
font-size: 14px;
}
/* 搜索空状态样式 */
.search-empty h3 {
color: #666;
}
.search-empty p {
color: #999;
}
.btn-secondary {
background: #f5f5f5;
color: #666;
}
.btn-secondary:hover {
background: #e6e6e6;
border-color: #bfbfbf;
}
</style>

View File

@ -6,7 +6,7 @@
<div class="toolbar-actions">
<button class="btn btn-primary" @click="addCertificate">添加证书</button>
<div class="filter-dropdown">
<select v-model="selectedExam" class="filter-select">
<select v-model="selectedExam" @change="handleFilterChange" class="filter-select">
<option value="">考试</option>
<option value="exam1">期末考试</option>
<option value="exam2">期中考试</option>
@ -14,44 +14,79 @@
</select>
</div>
<div class="search-box">
<input type="text" placeholder="请输入关键词" v-model="searchKeyword" />
<input type="text" placeholder="请输入关键词" v-model="searchKeyword" @input="handleSearchInput" />
<button class="btn btn-search" @click="searchCertificates">搜索</button>
</div>
</div>
</div>
<!-- 证书网格展示 -->
<div class="certificate-grid">
<div v-for="certificate in filteredCertificates" :key="certificate.id" class="certificate-card"
@click="viewCertificateDetail(certificate)">
<div class="certificate-thumbnail">
<img :src="certificate.thumbnail" :alt="certificate.name" class="certificate-image" />
<!-- 搜索中状态 -->
<div v-if="isSearching" class="search-feedback">
<div class="search-spinner"></div>
<p>搜索中...</p>
</div>
</div>
<div class="certificate-info">
<div class="certificate-name">{{ certificate.name }}</div>
<div class="certificate-category">证书分类: {{ certificate.category }}</div>
</div>
<div class="file-menu">
<button class="file-menu-btn" @click.stop="toggleFileMenu(certificate.id)">
<img src="/images/teacher/more.png" alt="更多操作" class="more-icon" />
</button>
<div v-if="activeFileMenu === certificate.id" class="file-menu-dropdown">
<div class="menu-item" @click.stop="downloadCertificate(certificate)">
<img src="/images/teacher/download.png" alt="下载" class="menu-icon" />
<span>下载</span>
</div>
<div class="menu-item" @click.stop="editCertificate(certificate)">
<img src="/images/teacher/edit.png" alt="编辑" class="menu-icon" />
<span>编辑</span>
</div>
<div class="menu-item" @click.stop="deleteCertificate(certificate)">
<img src="/images/teacher/delete.png" alt="删除" class="menu-icon" />
<span>删除</span>
<!-- 筛选中状态 -->
<div v-else-if="isFiltering" class="search-feedback">
<div class="search-spinner"></div>
<p>筛选中...</p>
</div>
<!-- 搜索无结果空状态 -->
<div v-else-if="filteredCertificates.length === 0 && searchKeyword" class="empty-state search-empty">
<h3>未找到相关证书</h3>
<p>没有找到包含"{{ searchKeyword }}"的证书请尝试其他关键词</p>
<button class="btn btn-secondary" @click="clearSearch">清除搜索</button>
</div>
<!-- 筛选无结果空状态 -->
<div v-else-if="filteredCertificates.length === 0 && selectedExam" class="empty-state search-empty">
<h3>未找到相关证书</h3>
<p>没有找到符合筛选条件的证书请尝试其他筛选条件</p>
<button class="btn btn-secondary" @click="clearFilter">清除筛选</button>
</div>
<!-- 无证书空状态 -->
<div v-else-if="filteredCertificates.length === 0" class="empty-state">
<h3>暂无证书</h3>
<p>还没有创建任何证书点击"添加证书"开始创建</p>
<button class="btn btn-primary" @click="addCertificate">添加证书</button>
</div>
<!-- 证书网格展示 -->
<div v-else class="certificate-grid">
<transition-group name="certificate-fade" tag="div" class="certificate-list" :key="listKey">
<div v-for="(certificate, index) in filteredCertificates" :key="`${certificate.id}-${index}`"
class="certificate-card" @click="viewCertificateDetail(certificate)">
<div class="certificate-thumbnail">
<img :src="certificate.thumbnail" :alt="certificate.name" class="certificate-image" />
</div>
<div class="certificate-info">
<div class="certificate-name">{{ certificate.name }}</div>
<div class="certificate-category">证书分类: {{ certificate.category }}</div>
</div>
<div class="file-menu">
<button class="file-menu-btn" @click.stop="toggleFileMenu(certificate.id)">
<img src="/images/teacher/more.png" alt="更多操作" class="more-icon" />
</button>
<div v-if="activeFileMenu === certificate.id" class="file-menu-dropdown">
<div class="menu-item" @click.stop="downloadCertificate(certificate)">
<img src="/images/teacher/download.png" alt="下载" class="menu-icon" />
<span>下载</span>
</div>
<div class="menu-item" @click.stop="editCertificate(certificate)">
<img src="/images/teacher/edit.png" alt="编辑" class="menu-icon" />
<span>编辑</span>
</div>
<div class="menu-item" @click.stop="deleteCertificate(certificate)">
<img src="/images/teacher/delete.png" alt="删除" class="menu-icon" />
<span>删除</span>
</div>
</div>
</div>
</div>
</div>
</transition-group>
</div>
<!-- 添加证书模态框 -->
@ -103,7 +138,7 @@
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, watch, nextTick } from 'vue'
import { useMessage, NModal, NIcon, NAlert } from 'naive-ui'
import { useRouter, useRoute } from 'vue-router'
@ -115,6 +150,15 @@ const route = useRoute()
const searchKeyword = ref('')
const selectedExam = ref('')
// key
const listKey = ref(0)
//
const isSearching = ref(false)
//
const isFiltering = ref(false)
//
const showAddModal = ref(false)
const activeFileMenu = ref<number | null>(null)
@ -213,6 +257,48 @@ const searchCertificates = () => {
message.info('搜索证书: ' + searchKeyword.value)
}
const clearSearch = () => {
searchKeyword.value = ''
message.info('已清除搜索条件')
}
const clearFilter = () => {
selectedExam.value = ''
message.info('已清除筛选条件')
}
//
watch(filteredCertificates, async () => {
await nextTick()
listKey.value++
}, { deep: true })
//
const handleSearchInput = async () => {
if (!searchKeyword.value.trim()) {
isSearching.value = false
return
}
isSearching.value = true
//
await new Promise(resolve => setTimeout(resolve, 300))
isSearching.value = false
}
//
const handleFilterChange = async () => {
isFiltering.value = true
//
await new Promise(resolve => setTimeout(resolve, 300))
isFiltering.value = false
message.info('筛选条件已更新')
}
const toggleFileMenu = (id: number) => {
console.log('点击了更多操作按钮ID:', id)
activeFileMenu.value = activeFileMenu.value === id ? null : id
@ -405,6 +491,10 @@ document.addEventListener('click', closeFileMenu)
padding: 30px;
}
.certificate-list {
display: contents;
}
/* 证书卡片 */
.certificate-card {
padding: 50px 20px 10px 20px;
@ -676,6 +766,7 @@ document.addEventListener('click', closeFileMenu)
border-color: #40a9ff;
color: #40a9ff;
}
/* 响应式设计 */
@media (max-width: 1400px) {
.certificate-grid {
@ -747,4 +838,127 @@ document.addEventListener('click', closeFileMenu)
padding: 10px;
}
}
/* ========== 过滤动画效果 ========== */
/* 过渡动画 */
.certificate-fade-enter-active {
transition: all 0.5s ease;
}
.certificate-fade-leave-active {
transition: all 0.3s ease;
}
.certificate-fade-enter-from {
opacity: 0;
transform: translateY(30px) scale(0.9);
}
.certificate-fade-leave-to {
opacity: 0;
transform: translateY(-30px) scale(0.9);
}
.certificate-fade-move {
transition: transform 0.5s ease;
}
/* 确保动画可见 */
.certificate-card {
transition: all 0.3s ease;
}
/* ========== 搜索反馈样式 ========== */
.search-feedback {
display: flex;
justify-content: center;
align-items: center;
min-height: 200px;
background: #fff;
margin: 30px;
border-radius: 8px;
flex-direction: column;
}
.search-spinner {
width: 32px;
height: 32px;
border: 3px solid #f3f3f3;
border-top: 3px solid #0288D1;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 12px;
}
.search-feedback p {
color: #666;
font-size: 14px;
margin: 0;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* ========== 空状态样式 ========== */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 400px;
background: #fff;
margin: 30px;
border-radius: 8px;
text-align: center;
padding: 40px 20px;
}
.empty-state h3 {
color: #333;
font-size: 20px;
margin: 0 0 12px 0;
font-weight: 500;
}
.empty-state p {
color: #666;
font-size: 14px;
margin: 0 0 24px 0;
line-height: 1.5;
max-width: 300px;
}
.empty-state .btn {
padding: 10px 24px;
font-size: 14px;
}
/* 搜索空状态样式 */
.search-empty h3 {
color: #666;
}
.search-empty p {
color: #999;
}
.btn-secondary {
background: #0288D1;
color: white;
}
.btn-secondary:hover {
background: #40a9ff;
}
</style>