• 周三. 4 月 22nd, 2026

物嫩软件资讯网

软件资讯来物嫩

面向中小学生交互式阅读系统 图书资源管理前端

admin@wunen

6 月 5, 2025

项目进度:

大体完成图书管理界面的设计,批量导入新书:支持PDF与txt格式书籍上传,后台自动解析书籍元数据(书名、作者、章节结构),并提取正文内容生成可编辑的文本格式。管理员可手动补充书籍简介、标签(如“七年级必读”“古典文学”)及封面图片。系统提供书籍状态管理(如上架/下架)及内容更新功能。

前后端未进行对接,未测试js

BookUpload.vue

<template>
  <div class="upload-container">
    <div class="upload-area" @dragover.prevent @drop.prevent="handleDrop">
      <input
        type="file"
        multiple
        accept=".pdf,.txt"
        ref="fileInput"
        @change="handleFileSelect"
        class="file-input"
      />
      <div v-if="!uploading">
        <img src="@/assets/icons/upload.svg" alt="Upload" class="upload-icon" />
        <p>拖放文件到这里或点击上传</p>
        <p class="file-types">支持 PDF 和 TXT 格式</p>
        <button class="upload-btn" @click="$refs.fileInput.click()">选择文件</button>
      </div>
      <div v-else class="upload-progress">
        <div class="progress-container">
          <div class="progress-bar" :style="{ width: uploadProgress + '%' }"></div>
        </div>
        <p>上传中... {{ uploadProgress }}%</p>
      </div>
    </div>
    
    <div v-if="fileList.length > 0" class="file-list">
      <h3>已选择的文件:</h3>
      <div class="file-item" v-for="(file, index) in fileList" :key="index">
        <span>{{ file.name }}</span>
        <span class="file-status">{{ file.status }}</span>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, reactive } from 'vue';
import { useUploadStore } from '@/stores/uploadStore';
import { uploadBooks } from '@/services/api';

const uploadStore = useUploadStore();
const fileInput = ref(null);
const fileList = reactive([]);
const uploading = ref(false);
const uploadProgress = ref(0);

const handleFileSelect = (event) => {
  const files = Array.from(event.target.files);
  addFilesToQueue(files);
};

const handleDrop = (event) => {
  const files = Array.from(event.dataTransfer.files);
  addFilesToQueue(files);
};

const addFilesToQueue = (files) => {
  files.forEach(file => {
    if (file.type === 'application/pdf' || file.name.endsWith('.txt')) {
      fileList.push({
        name: file.name,
        file: file,
        status: '待处理'
      });
    }
  });
  fileInput.value.value = '';
};

const startUpload = async () => {
  if (fileList.length === 0) return;
  
  uploading.value = true;
  uploadProgress.value = 0;
  
  const formData = new FormData();
  fileList.forEach(file => {
    formData.append('books file',.file);
  });
  
  try {
    const timer = setInterval(() => {
      if (uploadProgress.value < 100) {
        uploadProgress.value += 1;
      } else {
        clearInterval(timer);
      }
    }, 100);
    
    const response = await uploadBooks(formData);
    uploadStore.updateBookList(response.data);
    showSuccessNotification('上传成功');
  } catch (error) {
    showErrorNotification('上传失败: ' + error.message);
  } finally {
    uploading.value = false;
    fileList.splice(0, fileList.length);
  }
};
</script>

<style scoped>
.upload-container {
  display: flex;
  flex-direction: column;
  gap: 20px;
}

.upload-area {
  border: 2px dashed #ccc;
  border-radius: 8px;
  padding: 30px;
  text-align: center;
  cursor: pointer;
  transition: all 0.3s;
}

.upload-area:hover {
  border-color: #42b983;
  background-color: #f9f9f9;
}

.upload-icon {
  width: 64px;
  height: 64px;
  margin-bottom: 15px;
}

.file-types {
  color: #666;
  margin: 10px 0;
}

.upload-btn {
  background-color: #42b983;
  color: white;
  border: none;
  padding: 10px 20px;
  border-radius: 4px;
  cursor: pointer;
  font-size: 16px;
}

.upload-progress {
  padding: 20px 0;
}

.progress-container {
  width: 100%;
  height: 10px;
  background-color: #eee;
  border-radius: 5px;
  margin: 15px 0;
  overflow: hidden;
}

.progress-bar {
  height: 100%;
  background-color: #42b983;
  transition: width 0.3s;
}

.file-list {
  background-color: #f5f5f5;
  padding: 15px;
  border-radius: 5px;
}

.file-item {
  display: flex;
  justify-content: space-between;
  padding: 8px 0;
  border-bottom: 1px solid #ddd;
}

.file-status {
  color: #666;
}

.file-input {
  display: none;
}
</style>

BookDetail.vue

<template>
  <div class="book-detail-container">
    <div class="book-header">
      <div class="book-cover">
        <img :src="book.coverUrl || defaultCover" alt="书籍封面" />
        <CoverUploader @cover-uploaded="updateCover" />
      </div>
      <div class="book-info">
        <h2>{{ book.title }}</h2>
        <p class="author">作者: {{ book.author }}</p>
        <p class="publish-date">发布日期: {{ formatDate(book.publishDate) }}</p>
        
        <div class="status-controls">
          <select v-model="book.status" @change="updateBookStatus">
            <option value="onShelf">上架</option>
            <option value="offShelf">下架</option>
            <option value="pending">待审核</option>
          </select>
          <button @click="editBook">编辑</button>
        </div>
      </div>
    </div>
    
    <div class="book-meta">
      <div class="meta-section">
        <h3>书籍简介</h3>
        <p v-if="!editing">{{ book.description || '暂无简介' }}</p>
        <textarea 
          v-else 
          v-model="book.description" 
          placeholder="添加书籍简介"
          class="edit-textarea"
        ></textarea>
      </div>
      
      <div class="meta-section">
        <h3>标签</h3>
        <div v-if="!editing" class="tag-list">
          <Tag v-for="(tag, index) in book.tags" :key="index" :tag="tag" />
        </div>
        <div v-else class="tag-editor">
          <TagSelector v-model="selectedTags" :existing-tags="book.tags" />
        </div>
      </div>
    </div>
    
    <div class="book-content">
      <h3>章节列表</h3>
      <ul class="chapter-list">
        <li 
          v-for="(chapter, index) in book.chapters" 
          :key="index" 
          @click="openChapterEditor(index)"
          :class="{ active: activeChapter === index }"
        >
          <span>{{ chapter.title }}</span>
          <button class="edit-btn" @click.stop="openChapterEditor(index)">编辑</button>
        </li>
      </ul>
      
      <div v-if="activeChapter !== null" class="chapter-editor">
        <textarea 
          v-model="book.chapters[activeChapter].content" 
          class="chapter-content"
        ></textarea>
        <div class="editor-actions">
          <button @click="saveChapterChanges">保存</button>
          <button @click="activeChapter = null" class="cancel-btn">取消</button>
        </div>
      </div>
    </div>
    
    <div v-if="editing" class="save-actions">
      <button @click="saveBookChanges">保存修改</button>
      <button @click="cancelEditing" class="cancel-btn">取消</button>
    </div>
  </div>
</template>

<script setup>
import { ref, watch } from 'vue';
import { useBookStore } from '@/stores/bookStore';
import Tag from '@/components/UI/Tag.vue';
import TagSelector from '@/components/UI/TagSelector.vue';
import CoverUploader from '@/components/UI/CoverUploader.vue';

const props = defineProps({
  bookId: {
    type: String,
    required: true
  }
});

const bookStore = useBookStore();
const defaultCover = '/default-book-cover.png';
const editing = ref(false);
const activeChapter = ref(null);
const selectedTags = ref([]);

const book = ref({
  title: '',
  author: '',
  publishDate: '',
  coverUrl: '',
  description: '',
  tags: [],
  status: 'onShelf',
  chapters: []
});

const formatDate = (dateString) => {
  const date = new Date(dateString);
  return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
};

const editBook = () => {
  editing.value = true;
  selectedTags.value = [...book.value.tags];
};

const saveBookChanges = async () => {
  try {
    await bookStore.updateBook(book.value);
    editing.value = false;
    showNotification('书籍信息已更新');
  } catch (error) {
    showErrorNotification('更新失败: ' + error.message);
  }
};

const cancelEditing = () => {
  editing.value = false;
  selectedTags.value = [];
};

const updateCover = (coverUrl) => {
  book.value.coverUrl = coverUrl;
};

const updateBookStatus = async () => {
  try {
    await bookStore.updateBookStatus(book.value.id, book.value.status);
    showNotification('书籍状态已更新');
  } catch (error) {
    showErrorNotification('更新状态失败: ' + error.message);
  }
};

const openChapterEditor = (index) => {
  activeChapter.value = index;
};

const saveChapterChanges = () => {
  activeChapter.value = null;
  showNotification('章节内容已保存');
};

watch(() => props.bookId, async (newId) => {
  if (newId) {
    const fetchedBook = await bookStore.fetchBookById(newId);
    book.value = fetchedBook;
  }
}, { immediate: true });
</script>

<style scoped>
.book-detail-container {
  background-color: #fff;
  border-radius: 8px;
  padding: 20px;
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}

.book-header {
  display: flex;
  gap: 30px;
  margin-bottom: 30px;
}

.book-cover {
  position: relative;
  width: 200px;
  height: 300px;
}

.book-cover img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  border-radius: 5px;
}

.book-info {
  flex: 1;
}

.book-info h2 {
  margin-top: 0;
  margin-bottom: 10px;
  font-size: 24px;
}

.author {
  font-size: 18px;
  color: #555;
  margin-bottom: 5px;
}

.publish-date {
  color: #888;
  margin-bottom: 15px;
}

.status-controls {
  display: flex;
  gap: 10px;
  margin-top: 15px;
}

.status-controls select {
  padding: 8px 12px;
  border-radius: 4px;
  border: 1px solid #ddd;
}

.status-controls button {
  padding: 8px 15px;
  background-color: #42b983;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

.book-meta {
  display: flex;
  gap: 30px;
  margin-bottom: 30px;
}

.meta-section {
  flex: 1;
}

.meta-section h3 {
  margin-top: 0;
  margin-bottom: 15px;
  font-size: 18px;
  border-bottom: 1px solid #eee;
  padding-bottom: 8px;
}

.tag-list {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
}

.chapter-list {
  list-style-type: none;
  padding: 0;
  margin: 0;
}

.chapter-list li {
  display: flex;
  justify-content: space-between;
  padding: 10px 15px;
  border-bottom: 1px solid #eee;
  cursor: pointer;
}

.chapter-list li.active {
  background-color: #f0f8ff;
}

.chapter-list .edit-btn {
  background-color: #2c3e50;
  color: white;
  border: none;
  padding: 3px 8px;
  border-radius: 3px;
  cursor: pointer;
  font-size: 12px;
}

.chapter-editor {
  margin-top: 20px;
  padding: 15px;
  background-color: #f9f9f9;
  border-radius: 5px;
  border: 1px solid #ddd;
}

.chapter-content {
  width: 100%;
  height: 300px;
  padding: 10px;
  border: 1px solid #ccc;
  border-radius: 4px;
  resize: vertical;
  margin-bottom: 10px;
  font-family: inherit;
}

.editor-actions {
  display: flex;
  justify-content: flex-end;
  gap: 10px;
}

.editor-actions button {
  padding: 8px 15px;
  border-radius: 4px;
  cursor: pointer;
}

.editor-actions .cancel-btn {
  background-color: #ccc;
  color: #333;
  border: none;
}

.save-actions {
  margin-top: 20px;
  display: flex;
  justify-content: flex-end;
  gap: 10px;
}

.save-actions button {
  padding: 10px 20px;
  border-radius: 4px;
  cursor: pointer;
}

.save-actions .cancel-btn {
  background-color: #ccc;
  color: #333;
  border: none;
}

.edit-textarea {
  width: 100%;
  height: 150px;
  padding: 10px;
  border: 1px solid #ccc;
  border-radius: 4px;
  resize: vertical;
  margin-bottom: 10px;
  font-family: inherit;
}
</style>

BookManagement.vue

<template>
  <div class="book-management-view">
    <div class="page-header">
      <h1>图书管理系统</h1>
      <div class="header-actions">
        <button @click="openUploadModal" class="upload-btn">
          <img src="@/assets/icons/upload.svg" alt="上传" class="action-icon" />
          批量上传图书
        </button>
        <button @click="openNewBookModal" class="new-book-btn">
          <img src="@/assets/icons/add.svg" alt="添加" class="action-icon" />
          添加单本图书
        </button>
      </div>
    </div>
    
    <div class="filters">
      <div class="filter-group">
        <label for="status-filter">状态:</label>
        <select id="status-filter" v-model="filters.status" @change="applyFilters">
          <option value="">全部</option>
          <option value="onShelf">上架</option>
          <option value="offShelf">下架</option>
          <option value="pending">待审核</option>
        </select>
      </div>
      
      <div class="filter-group">
        <label for="tag-filter">标签:</label>
        <TagSelector 
          id="tag-filter" 
          v-model="filters.tags" 
          @change="applyFilters" 
          :multiple="true" 
        />
      </div>
      
      <div class="filter-group search-group">
        <input 
          type="text" 
          v-model="filters.search" 
          placeholder="搜索书名、作者或内容..." 
          @input="applyFilters"
        />
        <button @click="applyFilters" class="search-btn">
          <img src="@/assets/icons/search.svg" alt="搜索" class="action-icon" />
        </button>
      </div>
    </div>
    
    <div class="books-container">
      <div class="books-grid">
        <BookCard 
          v-for="book in filteredBooks" 
          :key="book.id" 
          :book="book" 
          @edit="openBookDetail(book.id)"
        />
      </div>
      
      <div v-if="filteredBooks.length === 0" class="no-results">
        <img src="@/assets/icons/no-books.svg" alt="No Books" />
        <p>没有找到符合筛选条件的图书</p>
      </div>
    </div>
    
    <Pagination 
      :total="totalBooks" 
      :page="currentPage" 
      :limit="booksPerPage" 
      @page-changed="changePage"
    />
    
    <UploadModal v-model:visible="uploadModalVisible" />
    <NewBookModal v-model:visible="newBookModalVisible" />
    <BookDetailModal v-model:visible="bookDetailModalVisible" :bookId="selectedBookId" />
  </div>
</template>

<script setup>
import { ref, computed, onMounted, watch } from 'vue';
import { useBookStore, useUploadStore } from '@/stores';
import BookCard from '@/components/BookManagement/BookCard.vue';
import UploadModal from '@/components/BookManagement/UploadModal.vue';
import NewBookModal from '@/components/BookManagement/NewBookModal.vue';
import BookDetailModal from '@/components/BookManagement/BookDetailModal.vue';
import Pagination from '@/components/UI/Pagination.vue';
import TagSelector from '@/components/UI/TagSelector.vue';

const bookStore = useBookStore();
const uploadStore = useUploadStore();

const currentPage = ref(1);
const booksPerPage = ref(12);
const uploadModalVisible = ref(false);
const newBookModalVisible = ref(false);
const bookDetailModalVisible = ref(false);
const selectedBookId = ref(null);

const filters = reactive({
  status: '',
  tags: [],
  search: ''
});

const totalBooks = computed(() => bookStore.totalBooks);
const filteredBooks = computed(() => {
  return bookStore.filteredBooks({
    status: filters.status,
    tags: filters.tags,
    search: filters.search,
    page: currentPage.value,
    limit: booksPerPage.value
  });
});

const openUploadModal = () => {
  uploadModalVisible.value = true;
};

const openNewBookModal = () => {
  newBookModalVisible.value = true;
};

const openBookDetail = (bookId) => {
  selectedBookId.value = bookId;
  bookDetailModalVisible.value = true;
};

const applyFilters = () => {
  currentPage.value = 1;
};

const changePage = (page) => {
  currentPage.value = page;
};

watch([currentPage, filters], () => {
  bookStore.applyFilters({
    ...filters,
    page: currentPage.value,
    limit: booksPerPage.value
  });
});

onMounted(async () => {
  await bookStore.fetchBooks({
    page: currentPage.value,
    limit: booksPerPage.value
  });
});
</script>

<style scoped>
.book-management-view {
  padding: 20px;
  max-width: 1400px;
  margin: 0 auto;
}

.page-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 25px;
}

.page-header h1 {
  margin: 0;
  font-size: 28px;
  color: #333;
}

.header-actions {
  display: flex;
  gap: 15px;
}

.upload-btn, .new-book-btn {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 10px 15px;
  border-radius: 4px;
  cursor: pointer;
  font-weight: 500;
  transition: all 0.2s;
}

.upload-btn {
  background-color: #42b983;
  color: white;
  border: none;
}

.new-book-btn {
  background-color: #2c3e50;
  color: white;
  border: none;
}

.upload-btn:hover, .new-book-btn:hover {
  opacity: 0.9;
  transform: translateY(-2px);
}

.filters {
  display: flex;
  gap: 20px;
  margin-bottom: 25px;
  flex-wrap: wrap;
}

.filter-group {
  display: flex;
  align-items: center;
  gap: 8px;
}

.filter-group label {
  font-size: 14px;
  color: #555;
}

.filter-group select {
  padding: 8px 12px;
  border-radius: 4px;
  border: 1px solid #ddd;
  min-width: 150px;
}

.search-group {
  flex: 1;
  min-width: 300px;
}

.search-group input {
  flex: 1;
  padding: 8px 12px;
  border-radius: 4px 0 0 4px;
  border: 1px solid #ddd;
  border-right: none;
}

.search-btn {
  padding: 8px 12px;
  background-color: #42b983;
  color: white;
  border: none;
  border-radius: 0 4px 4px 0;
  cursor: pointer;
}

.books-container {
  position: relative;
}

.books-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(202px, 1fr));
  gap: 25px;
  margin-bottom: 30px;
}

.no-results {
  text-align: center;
  padding: 50px;
  color: #999;
}

.no-results img {
  width: 150px;
  height: 150px;
  margin-bottom: 15px;
}

.action-icon {
  width: 18px;
  height: 18px;
}
</style>

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注