568 lines
22 KiB
Python
568 lines
22 KiB
Python
"""API服务"""
|
|
|
|
from flask import Flask, request, jsonify, render_template_string
|
|
from flask_cors import CORS
|
|
import os
|
|
|
|
from .db import db
|
|
from .config import API_HOST, API_PORT, ITEM_TYPES, TODO_STATUS, PRIORITY_LEVELS
|
|
|
|
app = Flask(__name__,
|
|
template_folder=os.path.join(os.path.dirname(__file__), '../web/templates'),
|
|
static_folder=os.path.join(os.path.dirname(__file__), '../web/static'))
|
|
CORS(app)
|
|
|
|
# ============ API 路由 ============
|
|
|
|
@app.route('/api/items', methods=['GET'])
|
|
def list_items():
|
|
"""列出条目"""
|
|
items = db.list_items(
|
|
type=request.args.get('type'),
|
|
status=request.args.get('status'),
|
|
tag=request.args.get('tag'),
|
|
keyword=request.args.get('keyword'),
|
|
limit=int(request.args.get('limit', 50)),
|
|
offset=int(request.args.get('offset', 0))
|
|
)
|
|
return jsonify({'success': True, 'data': items})
|
|
|
|
|
|
@app.route('/api/items', methods=['POST'])
|
|
def create_item():
|
|
"""创建条目"""
|
|
data = request.get_json()
|
|
|
|
if not data:
|
|
return jsonify({'success': False, 'error': '无数据'}), 400
|
|
|
|
item_type = data.get('type', 'text')
|
|
if item_type not in ITEM_TYPES:
|
|
return jsonify({'success': False, 'error': f'无效类型: {item_type}'}), 400
|
|
|
|
try:
|
|
item_id = db.create_item(
|
|
type=item_type,
|
|
title=data.get('title'),
|
|
content=data.get('content'),
|
|
url=data.get('url'),
|
|
source=data.get('source'),
|
|
status=data.get('status', 'pending'),
|
|
priority=data.get('priority', 'medium'),
|
|
due_date=data.get('due_date'),
|
|
note=data.get('note'),
|
|
tags=data.get('tags', [])
|
|
)
|
|
item = db.get_item(item_id)
|
|
return jsonify({'success': True, 'data': item}), 201
|
|
except Exception as e:
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
|
|
|
|
@app.route('/api/items/<int:item_id>', methods=['GET'])
|
|
def get_item(item_id):
|
|
"""获取条目"""
|
|
item = db.get_item(item_id)
|
|
if not item:
|
|
return jsonify({'success': False, 'error': '条目不存在'}), 404
|
|
return jsonify({'success': True, 'data': item})
|
|
|
|
|
|
@app.route('/api/items/<int:item_id>', methods=['PUT'])
|
|
def update_item(item_id):
|
|
"""更新条目"""
|
|
data = request.get_json()
|
|
|
|
if not data:
|
|
return jsonify({'success': False, 'error': '无数据'}), 400
|
|
|
|
try:
|
|
if db.update_item(item_id, **data):
|
|
item = db.get_item(item_id)
|
|
return jsonify({'success': True, 'data': item})
|
|
else:
|
|
return jsonify({'success': False, 'error': '条目不存在或无变化'}), 404
|
|
except Exception as e:
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
|
|
|
|
@app.route('/api/items/<int:item_id>', methods=['DELETE'])
|
|
def delete_item(item_id):
|
|
"""删除条目"""
|
|
if db.delete_item(item_id):
|
|
return jsonify({'success': True})
|
|
return jsonify({'success': False, 'error': '条目不存在'}), 404
|
|
|
|
|
|
@app.route('/api/items/<int:item_id>/done', methods=['POST'])
|
|
def complete_item(item_id):
|
|
"""完成待办"""
|
|
item = db.get_item(item_id)
|
|
if not item:
|
|
return jsonify({'success': False, 'error': '条目不存在'}), 404
|
|
|
|
if item['type'] != 'todo':
|
|
return jsonify({'success': False, 'error': '不是待办事项'}), 400
|
|
|
|
db.update_item(item_id, status='completed')
|
|
item = db.get_item(item_id)
|
|
return jsonify({'success': True, 'data': item})
|
|
|
|
|
|
@app.route('/api/tags', methods=['GET'])
|
|
def list_tags():
|
|
"""列出标签"""
|
|
tags = db.list_tags()
|
|
return jsonify({'success': True, 'data': tags})
|
|
|
|
|
|
@app.route('/api/tags', methods=['POST'])
|
|
def create_tag():
|
|
"""创建标签"""
|
|
data = request.get_json()
|
|
name = data.get('name', '').strip()
|
|
|
|
if not name:
|
|
return jsonify({'success': False, 'error': '标签名不能为空'}), 400
|
|
|
|
try:
|
|
tag_id = db.create_tag(name, data.get('color', '#3498db'))
|
|
return jsonify({'success': True, 'data': {'id': tag_id, 'name': name}}), 201
|
|
except Exception as e:
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
|
|
|
|
@app.route('/api/tags/<int:tag_id>', methods=['DELETE'])
|
|
def delete_tag(tag_id):
|
|
"""删除标签"""
|
|
if db.delete_tag(tag_id=tag_id):
|
|
return jsonify({'success': True})
|
|
return jsonify({'success': False, 'error': '标签不存在'}), 404
|
|
|
|
|
|
@app.route('/api/stats', methods=['GET'])
|
|
def get_stats():
|
|
"""获取统计"""
|
|
stats = db.stats()
|
|
return jsonify({'success': True, 'data': stats})
|
|
|
|
|
|
@app.route('/api/search', methods=['GET'])
|
|
def search_items():
|
|
"""搜索条目"""
|
|
keyword = request.args.get('q', '')
|
|
if not keyword:
|
|
return jsonify({'success': False, 'error': '请提供搜索关键词'}), 400
|
|
|
|
items = db.list_items(
|
|
keyword=keyword,
|
|
type=request.args.get('type'),
|
|
limit=int(request.args.get('limit', 50))
|
|
)
|
|
return jsonify({'success': True, 'data': items})
|
|
|
|
|
|
# ============ Web 页面 ============
|
|
|
|
@app.route('/')
|
|
def index():
|
|
"""主页"""
|
|
return render_template_string(INDEX_TEMPLATE)
|
|
|
|
|
|
# ============ Web 模板 ============
|
|
|
|
INDEX_TEMPLATE = '''
|
|
<!DOCTYPE html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Xian Favor - 收藏系统</title>
|
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⭐</text></svg>">
|
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
|
|
<style>
|
|
body { background: #f8f9fa; }
|
|
.sidebar { height: 100vh; background: #343a40; color: #fff; }
|
|
.sidebar a { color: #adb5bd; text-decoration: none; padding: 10px 20px; display: block; }
|
|
.sidebar a:hover, .sidebar a.active { background: #495057; color: #fff; }
|
|
.content { padding: 20px; }
|
|
.card { margin-bottom: 15px; transition: transform 0.2s; }
|
|
.card:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.15); }
|
|
.tag { margin-right: 5px; }
|
|
.type-text { border-left: 4px solid #17a2b8; }
|
|
.type-link { border-left: 4px solid #28a745; }
|
|
.type-column { border-left: 4px solid #6f42c1; }
|
|
.type-todo { border-left: 4px solid #ffc107; }
|
|
.status-pending { color: #ffc107; }
|
|
.status-in_progress { color: #17a2b8; }
|
|
.status-completed { color: #28a745; text-decoration: line-through; }
|
|
.priority-low { color: #6c757d; }
|
|
.priority-medium { color: #ffc107; }
|
|
.priority-high { color: #fd7e14; }
|
|
.priority-urgent { color: #dc3545; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container-fluid">
|
|
<div class="row">
|
|
<!-- 侧边栏 -->
|
|
<div class="col-md-2 sidebar p-0">
|
|
<div class="p-3 border-bottom border-secondary">
|
|
<h5><i class="bi bi-bookmark-star"></i> Xian Favor</h5>
|
|
</div>
|
|
<nav>
|
|
<a href="#" class="active" data-filter="all"><i class="bi bi-inbox"></i> 全部</a>
|
|
<a href="#" data-filter="text"><i class="bi bi-file-text"></i> 文本</a>
|
|
<a href="#" data-filter="link"><i class="bi bi-link-45deg"></i> 链接</a>
|
|
<a href="#" data-filter="column"><i class="bi bi-newspaper"></i> 专栏</a>
|
|
<a href="#" data-filter="todo"><i class="bi bi-check2-square"></i> 待办</a>
|
|
<hr class="border-secondary">
|
|
<a href="#" data-filter="pending"><i class="bi bi-clock"></i> 待处理</a>
|
|
<a href="#" data-filter="in_progress"><i class="bi bi-arrow-repeat"></i> 进行中</a>
|
|
<a href="#" data-filter="completed"><i class="bi bi-check-circle"></i> 已完成</a>
|
|
</nav>
|
|
</div>
|
|
|
|
<!-- 主内容 -->
|
|
<div class="col-md-10 content">
|
|
<!-- 顶部操作栏 -->
|
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
|
<div class="d-flex gap-2">
|
|
<input type="text" id="searchInput" class="form-control" placeholder="搜索..." style="width: 300px;">
|
|
<select id="typeFilter" class="form-select" style="width: 120px;">
|
|
<option value="">全部类型</option>
|
|
<option value="text">文本</option>
|
|
<option value="link">链接</option>
|
|
<option value="column">专栏</option>
|
|
<option value="todo">待办</option>
|
|
</select>
|
|
</div>
|
|
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addModal">
|
|
<i class="bi bi-plus-lg"></i> 添加
|
|
</button>
|
|
</div>
|
|
|
|
<!-- 统计卡片 -->
|
|
<div class="row mb-4" id="statsCards">
|
|
<div class="col-md-3">
|
|
<div class="card bg-primary text-white">
|
|
<div class="card-body">
|
|
<h6>总条目</h6>
|
|
<h3 id="statTotal">0</h3>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="card bg-warning text-dark">
|
|
<div class="card-body">
|
|
<h6>待处理</h6>
|
|
<h3 id="statPending">0</h3>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="card bg-info text-white">
|
|
<div class="card-body">
|
|
<h6>进行中</h6>
|
|
<h3 id="statProgress">0</h3>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="card bg-success text-white">
|
|
<div class="card-body">
|
|
<h6>已完成</h6>
|
|
<h3 id="statCompleted">0</h3>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 列表 -->
|
|
<div id="itemList"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 添加模态框 -->
|
|
<div class="modal fade" id="addModal" tabindex="-1">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">添加条目</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<form id="addForm">
|
|
<div class="mb-3">
|
|
<label class="form-label">类型</label>
|
|
<select id="addType" class="form-select">
|
|
<option value="text">📝 文本</option>
|
|
<option value="link">🔗 链接</option>
|
|
<option value="column">📰 专栏</option>
|
|
<option value="todo">✅ 待办</option>
|
|
</select>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">标题</label>
|
|
<input type="text" id="addTitle" class="form-control" placeholder="可选">
|
|
</div>
|
|
<div class="mb-3" id="contentGroup">
|
|
<label class="form-label">内容</label>
|
|
<textarea id="addContent" class="form-control" rows="3"></textarea>
|
|
</div>
|
|
<div class="mb-3" id="urlGroup" style="display:none;">
|
|
<label class="form-label">URL</label>
|
|
<input type="url" id="addUrl" class="form-control">
|
|
</div>
|
|
<div class="mb-3" id="sourceGroup" style="display:none;">
|
|
<label class="form-label">来源</label>
|
|
<input type="text" id="addSource" class="form-control">
|
|
</div>
|
|
<div class="mb-3" id="todoFields" style="display:none;">
|
|
<div class="row">
|
|
<div class="col">
|
|
<label class="form-label">状态</label>
|
|
<select id="addStatus" class="form-select">
|
|
<option value="pending">⏳ 待处理</option>
|
|
<option value="in_progress">🔄 进行中</option>
|
|
<option value="completed">✅ 已完成</option>
|
|
</select>
|
|
</div>
|
|
<div class="col">
|
|
<label class="form-label">优先级</label>
|
|
<select id="addPriority" class="form-select">
|
|
<option value="low">🟢 低</option>
|
|
<option value="medium" selected>🟡 中</option>
|
|
<option value="high">🟠 高</option>
|
|
<option value="urgent">🔴 紧急</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="mt-3">
|
|
<label class="form-label">截止日期</label>
|
|
<input type="date" id="addDueDate" class="form-control">
|
|
</div>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">标签 (逗号分隔)</label>
|
|
<input type="text" id="addTags" class="form-control" placeholder="标签1, 标签2">
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">备注</label>
|
|
<input type="text" id="addNote" class="form-control">
|
|
</div>
|
|
</form>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
|
|
<button type="button" class="btn btn-primary" onclick="addItem()">添加</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
|
<script>
|
|
const API_BASE = '/api';
|
|
let currentFilter = { type: '', status: '' };
|
|
|
|
// 初始化
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
loadItems();
|
|
loadStats();
|
|
|
|
// 类型切换时显示/隐藏字段
|
|
document.getElementById('addType').addEventListener('change', (e) => {
|
|
const type = e.target.value;
|
|
document.getElementById('contentGroup').style.display = type === 'text' ? 'block' : 'none';
|
|
document.getElementById('urlGroup').style.display = ['link', 'column'].includes(type) ? 'block' : 'none';
|
|
document.getElementById('sourceGroup').style.display = type === 'column' ? 'block' : 'none';
|
|
document.getElementById('todoFields').style.display = type === 'todo' ? 'block' : 'none';
|
|
});
|
|
|
|
// 搜索
|
|
document.getElementById('searchInput').addEventListener('input', debounce(loadItems, 300));
|
|
|
|
// 类型过滤
|
|
document.getElementById('typeFilter').addEventListener('change', (e) => {
|
|
currentFilter.type = e.target.value;
|
|
loadItems();
|
|
});
|
|
|
|
// 侧边栏过滤
|
|
document.querySelectorAll('.sidebar a').forEach(a => {
|
|
a.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
document.querySelectorAll('.sidebar a').forEach(x => x.classList.remove('active'));
|
|
a.classList.add('active');
|
|
|
|
const filter = a.dataset.filter;
|
|
if (['text', 'link', 'column', 'todo'].includes(filter)) {
|
|
currentFilter = { type: filter, status: '' };
|
|
} else if (['pending', 'in_progress', 'completed'].includes(filter)) {
|
|
currentFilter = { type: 'todo', status: filter };
|
|
} else {
|
|
currentFilter = { type: '', status: '' };
|
|
}
|
|
loadItems();
|
|
});
|
|
});
|
|
});
|
|
|
|
// 加载列表
|
|
async function loadItems() {
|
|
const keyword = document.getElementById('searchInput').value;
|
|
let url = `${API_BASE}/items?limit=100`;
|
|
if (currentFilter.type) url += `&type=${currentFilter.type}`;
|
|
if (currentFilter.status) url += `&status=${currentFilter.status}`;
|
|
if (keyword) url += `&keyword=${encodeURIComponent(keyword)}`;
|
|
|
|
const res = await fetch(url);
|
|
const data = await res.json();
|
|
|
|
if (data.success) {
|
|
renderItems(data.data);
|
|
}
|
|
}
|
|
|
|
// 渲染列表
|
|
function renderItems(items) {
|
|
const container = document.getElementById('itemList');
|
|
if (!items.length) {
|
|
container.innerHTML = '<div class="text-center text-muted py-5">暂无数据</div>';
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = items.map(item => `
|
|
<div class="card type-${item.type}">
|
|
<div class="card-body">
|
|
<div class="d-flex justify-content-between align-items-start">
|
|
<div>
|
|
<h6 class="card-title">
|
|
${getTypeIcon(item.type)} ${item.title || truncate(item.content || item.url, 50)}
|
|
</h6>
|
|
<p class="card-text text-muted small mb-2">
|
|
${item.url ? `<a href="${item.url}" target="_blank">${item.url}</a><br>` : ''}
|
|
${item.content ? truncate(item.content, 100) : ''}
|
|
</p>
|
|
<div>
|
|
${item.tags.map(t => `<span class="badge bg-secondary tag">${t}</span>`).join('')}
|
|
${item.type === 'todo' ? `
|
|
<span class="badge status-${item.status}">${getStatusLabel(item.status)}</span>
|
|
<span class="badge priority-${item.priority}">${getPriorityLabel(item.priority)}</span>
|
|
${item.due_date ? `<span class="badge bg-light text-dark">📅 ${item.due_date}</span>` : ''}
|
|
` : ''}
|
|
</div>
|
|
</div>
|
|
<div class="btn-group btn-group-sm">
|
|
${item.type === 'todo' && item.status !== 'completed' ?
|
|
`<button class="btn btn-outline-success" onclick="completeItem(${item.id})" title="完成"><i class="bi bi-check-lg"></i></button>` : ''}
|
|
<button class="btn btn-outline-danger" onclick="deleteItem(${item.id})" title="删除"><i class="bi bi-trash"></i></button>
|
|
</div>
|
|
</div>
|
|
<div class="text-muted small mt-2">${formatDate(item.created_at)}</div>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
// 加载统计
|
|
async function loadStats() {
|
|
const res = await fetch(`${API_BASE}/stats`);
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
document.getElementById('statTotal').textContent = data.data.total;
|
|
document.getElementById('statPending').textContent = data.data.todo_status?.pending || 0;
|
|
document.getElementById('statProgress').textContent = data.data.todo_status?.in_progress || 0;
|
|
document.getElementById('statCompleted').textContent = data.data.todo_status?.completed || 0;
|
|
}
|
|
}
|
|
|
|
// 添加条目
|
|
async function addItem() {
|
|
const type = document.getElementById('addType').value;
|
|
const data = {
|
|
type,
|
|
title: document.getElementById('addTitle').value,
|
|
content: type === 'text' ? document.getElementById('addContent').value : null,
|
|
url: ['link', 'column'].includes(type) ? document.getElementById('addUrl').value : null,
|
|
source: type === 'column' ? document.getElementById('addSource').value : null,
|
|
status: type === 'todo' ? document.getElementById('addStatus').value : null,
|
|
priority: type === 'todo' ? document.getElementById('addPriority').value : null,
|
|
due_date: type === 'todo' ? document.getElementById('addDueDate').value : null,
|
|
note: document.getElementById('addNote').value,
|
|
tags: document.getElementById('addTags').value.split(',').map(t => t.trim()).filter(t => t)
|
|
};
|
|
|
|
const res = await fetch(`${API_BASE}/items`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(data)
|
|
});
|
|
|
|
if (res.ok) {
|
|
bootstrap.Modal.getInstance(document.getElementById('addModal')).hide();
|
|
document.getElementById('addForm').reset();
|
|
loadItems();
|
|
loadStats();
|
|
}
|
|
}
|
|
|
|
// 完成待办
|
|
async function completeItem(id) {
|
|
await fetch(`${API_BASE}/items/${id}/done`, { method: 'POST' });
|
|
loadItems();
|
|
loadStats();
|
|
}
|
|
|
|
// 删除条目
|
|
async function deleteItem(id) {
|
|
if (!confirm('确认删除?')) return;
|
|
await fetch(`${API_BASE}/items/${id}`, { method: 'DELETE' });
|
|
loadItems();
|
|
loadStats();
|
|
}
|
|
|
|
// 工具函数
|
|
function getTypeIcon(type) {
|
|
const icons = { text: '📝', link: '🔗', column: '📰', todo: '✅' };
|
|
return icons[type] || '📄';
|
|
}
|
|
|
|
function getStatusLabel(status) {
|
|
const labels = { pending: '待处理', in_progress: '进行中', completed: '已完成' };
|
|
return labels[status] || status;
|
|
}
|
|
|
|
function getPriorityLabel(priority) {
|
|
const labels = { low: '低', medium: '中', high: '高', urgent: '紧急' };
|
|
return labels[priority] || priority;
|
|
}
|
|
|
|
function truncate(str, len) {
|
|
return str && str.length > len ? str.substring(0, len) + '...' : str || '';
|
|
}
|
|
|
|
function formatDate(dateStr) {
|
|
return new Date(dateStr).toLocaleString('zh-CN');
|
|
}
|
|
|
|
function debounce(fn, delay) {
|
|
let timer;
|
|
return function(...args) {
|
|
clearTimeout(timer);
|
|
timer = setTimeout(() => fn.apply(this, args), delay);
|
|
};
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|
|
'''
|
|
|
|
|
|
def start_server(host: str = API_HOST, port: int = API_PORT):
|
|
"""启动服务"""
|
|
app.run(host=host, port=port, debug=False) |