所有功能已完成,运行OK

This commit is contained in:
JimmysAIPG
2026-03-23 22:31:48 +08:00
commit 44fd84d380
8 changed files with 3495 additions and 0 deletions

193
frontend/index.html Normal file
View File

@@ -0,0 +1,193 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>古诗词阅读网</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<!-- 顶部导航 -->
<header class="header">
<div class="header-inner">
<div class="brand">
<span class="brand-icon"></span>
<div>
<h1>古诗词阅读网</h1>
<p class="tagline">品读经典 · 感悟人生</p>
</div>
</div>
<nav class="nav">
<a href="#" class="nav-item active" data-view="all">全部诗词</a>
<a href="#" class="nav-item" data-view="unread">未读</a>
<a href="#" class="nav-item" data-view="read">已读</a>
<a href="#" class="nav-item" data-action="random">随机一首</a>
</nav>
</div>
</header>
<!-- 主容器 -->
<main class="main">
<!-- 侧边栏 -->
<aside class="sidebar">
<!-- 统计面板 -->
<div class="card stats-card">
<h3 class="card-title">阅读统计</h3>
<div class="stats-grid">
<div class="stat-item">
<span class="stat-value" id="totalPoems">0</span>
<span class="stat-label">总数</span>
</div>
<div class="stat-item read">
<span class="stat-value" id="readCount">0</span>
<span class="stat-label">已读</span>
</div>
<div class="stat-item unread">
<span class="stat-value" id="unreadCount">0</span>
<span class="stat-label">未读</span>
</div>
</div>
<div class="progress">
<div class="progress-bar">
<div class="progress-fill" style="width: 0%"></div>
</div>
<div class="progress-text">
<span>进度:<span id="progressPercent">0</span>%</span>
<span><span id="progressText">0/0</span></span>
</div>
</div>
</div>
<!-- 上传面板 -->
<div class="card upload-card">
<h3 class="card-title">导入诗词</h3>
<div class="upload-area" id="uploadArea">
<input type="file" id="fileInput" accept=".json,.jsonl" />
<label for="fileInput" class="upload-label">
<svg class="upload-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
<span class="upload-text">点击或拖拽上传</span>
<span class="upload-hint">支持 JSON / JSONL 格式</span>
</label>
</div>
<button class="btn btn-primary btn-block" onclick="uploadFile()">导入</button>
<div id="uploadStatus" class="status-msg"></div>
</div>
<!-- 搜索 -->
<div class="card search-card">
<h3 class="card-title">搜索</h3>
<div class="search-box">
<svg class="search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input type="text" id="searchInput" class="search-input" placeholder="搜索标题、作者..." />
</div>
</div>
<!-- 分类筛选 -->
<div class="card filter-card">
<h3 class="card-title">分类筛选</h3>
<div id="categoryTabs" class="category-tabs"></div>
<div id="tagCloud" class="tag-cloud"></div>
<div class="selected-filters" id="selectedFilters"></div>
<button class="btn btn-ghost btn-sm" onclick="clearFilters()" id="clearFiltersBtn" style="display:none">清除筛选</button>
</div>
</aside>
<!-- 内容区 -->
<div class="content">
<!-- 结果头部 -->
<div class="content-header">
<h2 id="resultsTitle">诗词列表</h2>
<span class="badge" id="resultCount">0 首</span>
</div>
<!-- 诗词网格 -->
<div id="poemGrid" class="poem-grid"></div>
<!-- 分页控件 -->
<div id="pagination" class="pagination" style="display:none">
<button class="page-btn" id="prevPage" onclick="goToPage(state.currentPage - 1)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="15 18 9 12 15 6"/>
</svg>
上一页
</button>
<div class="page-info">
<span id="pageInfo">第 1 / 1 页</span>
</div>
<button class="page-btn" id="nextPage" onclick="goToPage(state.currentPage + 1)">
下一页
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"/>
</svg>
</button>
</div>
<!-- 空状态 -->
<div id="emptyState" class="empty-state" style="display:none">
<div class="empty-icon">
<svg viewBox="0 0 120 120" fill="none">
<circle cx="60" cy="60" r="50" fill="#f8f5f0" stroke="#d4c5a9" stroke-width="1"/>
<path d="M40 45 L60 35 L80 45 L80 80 L40 80 Z" fill="#ede8df" stroke="#c4b5a0" stroke-width="1"/>
<line x1="50" y1="52" x2="70" y2="52" stroke="#c4b5a0" stroke-width="1"/>
<line x1="50" y1="60" x2="70" y2="60" stroke="#c4b5a0" stroke-width="1"/>
<line x1="50" y1="68" x2="65" y2="68" stroke="#c4b5a0" stroke-width="1"/>
</svg>
</div>
<h3>暂无诗词</h3>
<p>上传 JSON / JSONL 格式的诗词文件开始阅读</p>
</div>
<!-- 加载状态 -->
<div id="loading" class="loading" style="display:none">
<div class="spinner"></div>
<p>加载中...</p>
</div>
</div>
</main>
<!-- 诗词详情弹窗 -->
<div id="poemModal" class="modal" style="display:none">
<div class="modal-backdrop" onclick="closeModal()"></div>
<div class="modal-panel">
<div class="modal-header">
<div>
<h3 id="modalTitle" class="modal-title"></h3>
<p class="modal-author">作者:<span id="modalAuthor"></span></p>
</div>
<button class="modal-close" onclick="closeModal()" aria-label="关闭">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="modal-body">
<div class="modal-content" id="modalContent"></div>
<div class="modal-section">
<h4 class="section-title">分类标签</h4>
<div id="modalTags" class="modal-tags"></div>
</div>
</div>
<div class="modal-footer">
<label class="read-checkbox">
<input type="checkbox" id="modalReadCheck" onchange="toggleModalRead()" />
<span>标记为已读</span>
</label>
<button class="btn btn-primary" onclick="closeModal()">关闭</button>
</div>
</div>
</div>
<!-- 提示消息 -->
<div id="toast" class="toast" style="display:none">
<span id="toastMessage"></span>
</div>
<script src="script.js"></script>
</body>
</html>

630
frontend/script.js Normal file
View File

@@ -0,0 +1,630 @@
/**
* 古诗词阅读网 - 前端 JavaScript
*/
const API_BASE = window.location.origin;
// 状态
let state = {
poems: [],
currentPage: 1,
totalPages: 1,
pageSize: 20,
total: 0,
filters: {
view: 'all',
search: '',
categories: [] // 格式:[{category: 'genre', tag: '山水田园'}]
},
currentPoem: null,
categories: {}
};
// 初始化
document.addEventListener('DOMContentLoaded', async () => {
await loadCategories();
await loadStats();
await loadPoems();
setupEventListeners();
setupUpload();
});
// 加载分类体系
async function loadCategories() {
try {
const res = await fetch(`${API_BASE}/api/categories`);
if (res.ok) {
state.categories = await res.json();
renderCategoryTabs();
}
} catch (err) {
console.error('加载分类失败:', err);
}
}
// 加载统计
async function loadStats() {
try {
const res = await fetch(`${API_BASE}/api/stats`);
if (res.ok) {
const stats = await res.json();
updateStats(stats);
}
} catch (err) {
console.error('加载统计失败:', err);
}
}
// 加载诗词
async function loadPoems() {
showLoading(true);
try {
const params = new URLSearchParams({
page: state.currentPage,
page_size: state.pageSize
});
// 搜索
if (state.filters.search) {
params.append('search', state.filters.search);
}
// 阅读状态
if (state.filters.view === 'read') {
params.append('is_read', 'true');
} else if (state.filters.view === 'unread') {
params.append('is_read', 'false');
}
// 多类别筛选
if (state.filters.categories.length > 0) {
const catStr = state.filters.categories
.map(c => `${c.category}:${c.tag}`)
.join(',');
params.append('categories', catStr);
}
const res = await fetch(`${API_BASE}/api/poems?${params}`);
if (res.ok) {
const data = await res.json();
state.poems = data.poems;
state.total = data.total;
state.totalPages = data.total_pages;
renderPoems();
updateResultCount();
}
} catch (err) {
console.error('加载诗词失败:', err);
showToast('加载失败', 'error');
} finally {
showLoading(false);
}
}
// 渲染分类标签页
function renderCategoryTabs() {
const container = document.getElementById('categoryTabs');
const categories = Object.entries(state.categories);
// 显示所有分类
container.innerHTML = categories.map(([key, data]) => `
<div class="category-tab" data-category="${key}">
<span class="tab-name">${data.name}</span>
</div>
`).join('');
// 点击事件
container.querySelectorAll('.category-tab').forEach(tab => {
tab.addEventListener('click', () => {
const category = tab.dataset.category;
showTagsForCategory(category);
// 切换激活状态
container.querySelectorAll('.category-tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
});
});
// 默认显示第一个分类的标签
if (categories.length > 0) {
const firstCategory = categories[0][0];
showTagsForCategory(firstCategory);
container.querySelector('.category-tab')?.classList.add('active');
}
}
// 显示某分类的标签
function showTagsForCategory(category) {
const container = document.getElementById('tagCloud');
const data = state.categories[category];
if (!data) return;
container.innerHTML = data.tags.map(tag => `
<span class="tag-item" data-category="${category}" data-tag="${tag}">${tag}</span>
`).join('');
// 点击事件
container.querySelectorAll('.tag-item').forEach(item => {
item.addEventListener('click', (e) => {
e.stopPropagation();
addFilter(category, item.dataset.tag);
});
});
}
// 添加筛选条件
function addFilter(category, tag) {
// 检查是否已存在
const exists = state.filters.categories.some(
c => c.category === category && c.tag === tag
);
if (!exists) {
state.filters.categories.push({ category, tag });
renderSelectedFilters();
state.currentPage = 1;
loadPoems();
}
}
// 渲染已选筛选
function renderSelectedFilters() {
const container = document.getElementById('selectedFilters');
const btn = document.getElementById('clearFiltersBtn');
if (state.filters.categories.length === 0) {
container.innerHTML = '';
btn.style.display = 'none';
return;
}
container.innerHTML = state.filters.categories.map((f, i) => `
<span class="filter-tag">
${state.categories[f.category]?.name || f.category}: ${f.tag}
<button onclick="removeFilter(${i})">×</button>
</span>
`).join('');
btn.style.display = 'inline-block';
}
// 移除筛选
function removeFilter(index) {
state.filters.categories.splice(index, 1);
renderSelectedFilters();
state.currentPage = 1;
loadPoems();
}
// 清除所有筛选
function clearFilters() {
state.filters.categories = [];
state.filters.search = '';
state.filters.view = 'all';
document.getElementById('searchInput').value = '';
renderSelectedFilters();
// 重置导航激活状态
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
document.querySelector('[data-view="all"]').classList.add('active');
loadPoems();
}
// 渲染诗词列表
function renderPoems() {
const grid = document.getElementById('poemGrid');
const empty = document.getElementById('emptyState');
if (state.poems.length === 0) {
grid.innerHTML = '';
empty.style.display = 'block';
return;
}
empty.style.display = 'none';
grid.innerHTML = '';
state.poems.forEach(poem => {
const card = createPoemCard(poem);
grid.appendChild(card);
});
}
// 创建诗词卡片
function createPoemCard(poem) {
const isRead = poem.is_read || false;
const paragraphs = poem.paragraphs || [];
const excerpt = paragraphs.slice(0, 2).join('<br>');
const tags = getTopTags(poem.classifications);
const card = document.createElement('div');
card.className = `poem-card ${isRead ? 'read' : ''}`;
card.dataset.id = poem.id;
card.innerHTML = `
<div class="poem-card-header">
<h3 class="poem-title">${escapeHtml(poem.title)}</h3>
<span class="poem-author">作者:${escapeHtml(poem.author)}</span>
</div>
<div class="poem-excerpt">${excerpt || '暂无内容'}</div>
<div class="poem-tags">${tags}</div>
<div class="poem-card-footer">
<label class="read-toggle">
<input type="checkbox" ${isRead ? 'checked' : ''}
onchange="event.stopPropagation(); toggleRead('${poem.id}', this.checked)" />
<span>${isRead ? '✓ 已读' : '○ 未读'}</span>
</label>
<button class="btn btn-sm" onclick="event.stopPropagation(); showPoemDetail('${poem.id}')">详情</button>
</div>
`;
// 点击卡片显示详情(排除复选框和按钮)
card.addEventListener('click', (e) => {
if (!e.target.closest('.read-toggle') && !e.target.closest('button')) {
showPoemDetail(poem.id);
}
});
return card;
}
// 获取主要标签
function getTopTags(classifications) {
if (!classifications) return '';
// 显示所有分类标签
const priority = ['genre', 'emotion_tone', 'season', 'location', 'time_of_day',
'philosophy', 'nature_scenery', 'plants', 'animals'];
const tags = [];
const shown = new Set();
for (const cat of priority) {
if (classifications[cat] && classifications[cat].length > 0) {
// 每个分类最多显示 2 个标签
classifications[cat].slice(0, 2).forEach(tag => {
if (!shown.has(tag)) {
tags.push(`<span class="tag tag-${cat}">${tag}</span>`);
shown.add(tag);
}
});
if (tags.length >= 6) break;
}
}
return tags.join('');
}
// 显示诗词详情
async function showPoemDetail(poemId) {
try {
const res = await fetch(`${API_BASE}/api/poems/${poemId}`);
if (!res.ok) {
showToast('加载失败', 'error');
return;
}
const poem = await res.json();
state.currentPoem = poem;
// 填充弹窗
document.getElementById('modalTitle').textContent = poem.title;
document.getElementById('modalAuthor').textContent = poem.author;
document.getElementById('modalContent').innerHTML = poem.paragraphs
.map(p => `<p>${escapeHtml(p)}</p>`).join('');
// 渲染标签
document.getElementById('modalTags').innerHTML = renderAllTags(poem.classifications);
// 设置阅读状态
document.getElementById('modalReadCheck').checked = poem.is_read || false;
// 显示弹窗
document.getElementById('poemModal').style.display = 'flex';
document.body.style.overflow = 'hidden';
} catch (err) {
console.error('加载详情失败:', err);
showToast('加载失败', 'error');
}
}
// 渲染所有标签
function renderAllTags(classifications) {
if (!classifications) return '';
const categoryNames = state.categories;
const categoryOrder = ['season', 'solar_terms', 'time_of_day', 'genre', 'emotion_tone',
'emotions', 'nature_scenery', 'plants', 'animals', 'buildings',
'philosophy', 'life_stage', 'social_role', 'technique', 'rhetoric',
'colors', 'sounds', 'location', 'festival'];
return categoryOrder.map(cat => {
if (!classifications[cat] || classifications[cat].length === 0) return '';
const catName = categoryNames[cat]?.name || cat;
const tags = classifications[cat];
return `
<div class="tag-group">
<span class="tag-group-label">${catName}:</span>
<div class="tag-group-tags">
${tags.map(tag => `<span class="tag">${tag}</span>`).join('')}
</div>
</div>
`;
}).filter(html => html.trim() !== '').join('');
}
// 切换弹窗阅读状态
async function toggleModalRead() {
if (!state.currentPoem) return;
const isRead = document.getElementById('modalReadCheck').checked;
try {
const res = await fetch(
`${API_BASE}/api/poems/${state.currentPoem.id}/read?is_read=${isRead}`,
{ method: 'PUT' }
);
if (res.ok) {
state.currentPoem.is_read = isRead;
await loadStats();
await loadPoems();
}
} catch (err) {
console.error('更新状态失败:', err);
}
}
// 关闭弹窗
function closeModal() {
document.getElementById('poemModal').style.display = 'none';
document.body.style.overflow = '';
state.currentPoem = null;
}
// 切换阅读状态
async function toggleRead(poemId, isRead) {
try {
const res = await fetch(
`${API_BASE}/api/poems/${poemId}/read?is_read=${isRead}`,
{ method: 'PUT' }
);
if (res.ok) {
await loadStats();
await loadPoems();
showToast(isRead ? '已标记为已读' : '已标记为未读', 'success');
}
} catch (err) {
console.error('更新失败:', err);
showToast('更新失败', 'error');
}
}
// 随机一首
async function showRandomPoem() {
try {
const res = await fetch(`${API_BASE}/api/poems/random`);
if (res.ok) {
const poem = await res.json();
state.currentPoem = poem;
document.getElementById('modalTitle').textContent = poem.title;
document.getElementById('modalAuthor').textContent = poem.author;
document.getElementById('modalContent').innerHTML = poem.paragraphs
.map(p => `<p>${escapeHtml(p)}</p>`).join('');
document.getElementById('modalTags').innerHTML = renderAllTags(poem.classifications);
document.getElementById('modalReadCheck').checked = poem.is_read || false;
document.getElementById('poemModal').style.display = 'flex';
document.body.style.overflow = 'hidden';
}
} catch (err) {
showToast('获取失败', 'error');
}
}
// 上传文件
async function uploadFile() {
const fileInput = document.getElementById('fileInput');
const file = fileInput.files[0];
if (!file) {
showToast('请选择文件', 'error');
return;
}
const formData = new FormData();
formData.append('file', file);
const status = document.getElementById('uploadStatus');
status.textContent = '上传中...';
status.className = 'status-msg';
try {
const res = await fetch(`${API_BASE}/api/poems/import`, {
method: 'POST',
body: formData
});
const data = await res.json();
if (res.ok) {
status.textContent = `成功导入 ${data.imported} 首,跳过 ${data.skipped}`;
status.className = 'status-msg success';
fileInput.value = '';
await loadStats();
await loadPoems();
} else {
status.textContent = data.detail || '导入失败';
status.className = 'status-msg error';
}
} catch (err) {
status.textContent = '上传失败:' + err.message;
status.className = 'status-msg error';
}
}
// 更新统计显示
function updateStats(stats) {
document.getElementById('totalPoems').textContent = stats.total_poems;
document.getElementById('readCount').textContent = stats.read_count;
document.getElementById('unreadCount').textContent = stats.unread_count;
const percent = stats.reading_progress || 0;
document.getElementById('progressPercent').textContent = percent;
document.getElementById('progressText').textContent = `${stats.read_count}/${stats.total_poems}`;
document.querySelector('.progress-fill').style.width = `${percent}%`;
}
// 更新结果数量
function updateResultCount() {
document.getElementById('resultCount').textContent = `${state.total}`;
updatePagination();
}
// 更新分页控件
function updatePagination() {
const pagination = document.getElementById('pagination');
const prevBtn = document.getElementById('prevPage');
const nextBtn = document.getElementById('nextPage');
const pageInfo = document.getElementById('pageInfo');
if (state.totalPages <= 1) {
pagination.style.display = 'none';
return;
}
pagination.style.display = 'flex';
prevBtn.disabled = state.currentPage <= 1;
nextBtn.disabled = state.currentPage >= state.totalPages;
prevBtn.style.opacity = prevBtn.disabled ? '0.5' : '1';
nextBtn.style.opacity = nextBtn.disabled ? '0.5' : '1';
pageInfo.textContent = `${state.currentPage} / ${state.totalPages} 页,共 ${state.total}`;
}
// 跳转到指定页
function goToPage(page) {
if (page < 1 || page > state.totalPages) return;
state.currentPage = page;
loadPoems();
window.scrollTo({ top: 0, behavior: 'smooth' });
}
// 设置事件监听
function setupEventListeners() {
// 导航
document.querySelectorAll('.nav-item').forEach(item => {
item.addEventListener('click', (e) => {
e.preventDefault();
const view = item.dataset.view;
const action = item.dataset.action;
if (action === 'random') {
showRandomPoem();
return;
}
if (view) {
state.filters.view = view;
state.currentPage = 1;
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
item.classList.add('active');
loadPoems();
}
});
});
// 搜索
let searchTimeout;
document.getElementById('searchInput').addEventListener('input', (e) => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
state.filters.search = e.target.value.trim();
state.currentPage = 1;
loadPoems();
}, 500);
});
}
// 设置上传区域
function setupUpload() {
const area = document.getElementById('uploadArea');
const fileInput = document.getElementById('fileInput');
const uploadStatus = document.getElementById('uploadStatus');
area.addEventListener('dragover', (e) => {
e.preventDefault();
area.classList.add('dragover');
});
area.addEventListener('dragleave', () => {
area.classList.remove('dragover');
});
area.addEventListener('drop', (e) => {
e.preventDefault();
area.classList.remove('dragover');
const files = e.dataTransfer.files;
if (files.length > 0) {
fileInput.files = files;
// 触发 change 事件以更新文件名显示
fileInput.dispatchEvent(new Event('change'));
}
});
// 文件选择后显示文件名
fileInput.addEventListener('change', () => {
const file = fileInput.files[0];
if (file) {
uploadStatus.textContent = `已选择:${file.name}`;
uploadStatus.className = 'status-msg info';
} else {
uploadStatus.textContent = '';
uploadStatus.className = 'status-msg';
}
});
}
// 工具函数
function showLoading(show) {
document.getElementById('loading').style.display = show ? 'flex' : 'none';
}
function showToast(message, type = 'info') {
const toast = document.getElementById('toast');
const msg = document.getElementById('toastMessage');
msg.textContent = message;
toast.style.display = 'block';
toast.className = `toast toast-${type}`;
setTimeout(() => {
toast.style.display = 'none';
}, 3000);
}
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// 导出全局函数
window.toggleRead = toggleRead;
window.showPoemDetail = showPoemDetail;
window.closeModal = closeModal;
window.uploadFile = uploadFile;
window.clearFilters = clearFilters;
window.removeFilter = removeFilter;
window.goToPage = goToPage;

1027
frontend/style.css Normal file

File diff suppressed because it is too large Load Diff