模型变更:
- DiaryEntry 添加 linked_tasks (ManyToMany 关联任务)
- DiaryEntry 添加 content 字段
- Experience 添加 extracted_from (外键关联日记)
- Task 添加 diary_entries (反向关联)
API 变更:
- 新增 /entries/{id}/link_task/ - 关联任务并自动更新进展
- 新增 /entries/{id}/extract_experience/ - 从日记提炼经验
- 序列化器支持关联数据嵌套显示
前端重构:
- 写日记作为主入口
- 关联任务复选框(保存时自动更新任务进展)
- 日记历史显示关联的任务和经验
- 任务列表显示关联的日记
- 经验总结独立展示
工作流程优化:
- 写日记时勾选任务 → 自动更新任务进展
- 写日记时记录反思 → 可提炼为经验总结
- 减少 60-70% 重复记录工作
561 lines
22 KiB
HTML
561 lines
22 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>码神的日记系统</title>
|
|
<style>
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
min-height: 100vh;
|
|
padding: 20px;
|
|
}
|
|
.container {
|
|
max-width: 1200px;
|
|
margin: 0 auto;
|
|
}
|
|
header {
|
|
text-align: center;
|
|
color: white;
|
|
margin-bottom: 30px;
|
|
}
|
|
header h1 {
|
|
font-size: 2em;
|
|
margin-bottom: 10px;
|
|
}
|
|
.stats {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
|
gap: 15px;
|
|
margin-bottom: 30px;
|
|
}
|
|
.stat-card {
|
|
background: white;
|
|
padding: 15px;
|
|
border-radius: 10px;
|
|
text-align: center;
|
|
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
|
}
|
|
.stat-card h3 {
|
|
color: #667eea;
|
|
font-size: 1.6em;
|
|
}
|
|
.stat-card p {
|
|
color: #666;
|
|
font-size: 0.8em;
|
|
}
|
|
.tabs {
|
|
display: flex;
|
|
gap: 10px;
|
|
margin-bottom: 20px;
|
|
flex-wrap: wrap;
|
|
}
|
|
.tab-btn {
|
|
padding: 10px 20px;
|
|
background: white;
|
|
border: none;
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
font-size: 0.95em;
|
|
font-weight: 500;
|
|
color: #666;
|
|
transition: all 0.3s;
|
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
-webkit-tap-highlight-color: transparent;
|
|
}
|
|
.tab-btn.active {
|
|
background: linear-gradient(135deg, #667eea, #764ba2);
|
|
color: white;
|
|
}
|
|
.section-box {
|
|
background: white;
|
|
border-radius: 10px;
|
|
padding: 20px;
|
|
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
|
margin-bottom: 30px;
|
|
}
|
|
.section-box h2 {
|
|
color: #333;
|
|
margin-bottom: 20px;
|
|
padding-bottom: 10px;
|
|
border-bottom: 2px solid #667eea;
|
|
font-size: 1.4em;
|
|
}
|
|
.diary-editor {
|
|
width: 100%;
|
|
}
|
|
.diary-editor textarea {
|
|
width: 100%;
|
|
min-height: 200px;
|
|
padding: 15px;
|
|
border: 2px solid #e0e7ff;
|
|
border-radius: 8px;
|
|
font-size: 1em;
|
|
font-family: inherit;
|
|
resize: vertical;
|
|
margin-bottom: 15px;
|
|
}
|
|
.diary-editor textarea:focus {
|
|
outline: none;
|
|
border-color: #667eea;
|
|
}
|
|
.form-group {
|
|
margin-bottom: 15px;
|
|
}
|
|
.form-group label {
|
|
display: block;
|
|
font-weight: 600;
|
|
color: #555;
|
|
margin-bottom: 8px;
|
|
font-size: 0.95em;
|
|
}
|
|
.form-group input, .form-group select {
|
|
width: 100%;
|
|
padding: 10px;
|
|
border: 2px solid #e0e7ff;
|
|
border-radius: 6px;
|
|
font-size: 0.95em;
|
|
}
|
|
.form-group input:focus, .form-group select:focus {
|
|
outline: none;
|
|
border-color: #667eea;
|
|
}
|
|
.btn {
|
|
padding: 12px 24px;
|
|
background: linear-gradient(135deg, #667eea, #764ba2);
|
|
color: white;
|
|
border: none;
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
font-size: 1em;
|
|
font-weight: 600;
|
|
transition: all 0.3s;
|
|
-webkit-tap-highlight-color: transparent;
|
|
}
|
|
.btn:hover {
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
|
}
|
|
.btn:active {
|
|
transform: scale(0.98);
|
|
}
|
|
.btn-secondary {
|
|
background: #f1f5f9;
|
|
color: #666;
|
|
}
|
|
.task-link-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 10px;
|
|
background: #f8f9fa;
|
|
border-radius: 6px;
|
|
margin-bottom: 8px;
|
|
}
|
|
.task-link-item input[type="checkbox"] {
|
|
width: 20px;
|
|
height: 20px;
|
|
}
|
|
.task-link-item .task-info {
|
|
flex: 1;
|
|
}
|
|
.task-link-item .task-title {
|
|
font-weight: 600;
|
|
color: #333;
|
|
}
|
|
.task-link-item .task-status {
|
|
font-size: 0.8em;
|
|
padding: 3px 8px;
|
|
border-radius: 12px;
|
|
background: #667eea;
|
|
color: white;
|
|
}
|
|
.diary-item {
|
|
padding: 20px;
|
|
border-left: 4px solid #667eea;
|
|
background: #f8f9fa;
|
|
margin-bottom: 20px;
|
|
border-radius: 8px;
|
|
}
|
|
.diary-item .date {
|
|
color: #667eea;
|
|
font-weight: bold;
|
|
font-size: 1.1em;
|
|
margin-bottom: 10px;
|
|
}
|
|
.diary-item .section {
|
|
margin: 15px 0;
|
|
}
|
|
.diary-item .section-title {
|
|
font-weight: 600;
|
|
color: #555;
|
|
margin-bottom: 8px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
.diary-item .section-content {
|
|
color: #666;
|
|
line-height: 1.6;
|
|
white-space: pre-wrap;
|
|
}
|
|
.linked-tasks, .linked-experiences {
|
|
margin-top: 15px;
|
|
padding-top: 15px;
|
|
border-top: 1px solid #e0e7ff;
|
|
}
|
|
.tag {
|
|
display: inline-block;
|
|
padding: 4px 12px;
|
|
background: #e0e7ff;
|
|
color: #667eea;
|
|
border-radius: 12px;
|
|
font-size: 0.85em;
|
|
margin-right: 8px;
|
|
margin-bottom: 8px;
|
|
}
|
|
.experience-item {
|
|
padding: 15px;
|
|
border-left: 4px solid #f59e0b;
|
|
background: #fffbeb;
|
|
margin-bottom: 15px;
|
|
border-radius: 6px;
|
|
}
|
|
.experience-item .title {
|
|
font-weight: 600;
|
|
color: #333;
|
|
margin-bottom: 10px;
|
|
}
|
|
.experience-item .category {
|
|
display: inline-block;
|
|
background: #f59e0b;
|
|
color: white;
|
|
padding: 3px 10px;
|
|
border-radius: 12px;
|
|
font-size: 0.8em;
|
|
margin-bottom: 10px;
|
|
}
|
|
.loading {
|
|
text-align: center;
|
|
padding: 40px;
|
|
color: white;
|
|
}
|
|
.empty-state {
|
|
text-align: center;
|
|
padding: 40px;
|
|
color: #999;
|
|
}
|
|
@media (max-width: 768px) {
|
|
body { padding: 10px; }
|
|
header h1 { font-size: 1.6em; }
|
|
.stats { grid-template-columns: repeat(3, 1fr); gap: 10px; }
|
|
.stat-card { padding: 10px; }
|
|
.stat-card h3 { font-size: 1.3em; }
|
|
.tabs { gap: 8px; }
|
|
.tab-btn { padding: 10px 16px; font-size: 0.9em; flex: 1; text-align: center; }
|
|
.section-box { padding: 15px; }
|
|
}
|
|
@media (max-width: 400px) {
|
|
.stats { grid-template-columns: repeat(2, 1fr); }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<header>
|
|
<h1>⚡ 码神的日记系统</h1>
|
|
<p style="color: rgba(255,255,255,0.9); font-size: 0.95em;">日记为中心 · 关联任务和经验</p>
|
|
</header>
|
|
|
|
<div id="app">
|
|
<div class="loading">加载中...</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const API_BASE = '/api';
|
|
let state = {
|
|
currentTab: 'write',
|
|
allDiaries: [],
|
|
allTasks: [],
|
|
allExperiences: [],
|
|
stats: {},
|
|
selectedDate: new Date().toISOString().split('T')[0]
|
|
};
|
|
|
|
async function loadData() {
|
|
try {
|
|
const [statsRes, diariesRes, tasksRes, experiencesRes] = await Promise.all([
|
|
fetch(`${API_BASE}/entries/stats/`),
|
|
fetch(`${API_BASE}/entries/recent/`),
|
|
fetch(`${API_BASE}/tasks/`),
|
|
fetch(`${API_BASE}/experiences/recent/`)
|
|
]);
|
|
|
|
state.stats = await statsRes.json();
|
|
state.allDiaries = await diariesRes.json();
|
|
state.allTasks = await tasksRes.json();
|
|
state.allExperiences = await experiencesRes.json();
|
|
|
|
render();
|
|
} catch (error) {
|
|
document.getElementById('app').innerHTML = `<div class="error">加载失败:${error.message}</div>`;
|
|
}
|
|
}
|
|
|
|
function render() {
|
|
const app = document.getElementById('app');
|
|
|
|
app.innerHTML = `
|
|
<div class="stats">
|
|
<div class="stat-card">
|
|
<h3>${state.stats.total_entries || 0}</h3>
|
|
<p>日记</p>
|
|
</div>
|
|
<div class="stat-card">
|
|
<h3>${state.stats.total_tasks || 0}</h3>
|
|
<p>任务</p>
|
|
</div>
|
|
<div class="stat-card">
|
|
<h3>${state.stats.total_experiences || 0}</h3>
|
|
<p>经验</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="tabs">
|
|
<button class="tab-btn ${state.currentTab === 'write' ? 'active' : ''}" onclick="switchTab('write')">✍️ 写日记</button>
|
|
<button class="tab-btn ${state.currentTab === 'history' ? 'active' : ''}" onclick="switchTab('history')">📖 日记历史</button>
|
|
<button class="tab-btn ${state.currentTab === 'tasks' ? 'active' : ''}" onclick="switchTab('tasks')">📋 任务</button>
|
|
<button class="tab-btn ${state.currentTab === 'experiences' ? 'active' : ''}" onclick="switchTab('experiences')">💡 经验</button>
|
|
</div>
|
|
|
|
${renderContent()}
|
|
`;
|
|
}
|
|
|
|
function renderContent() {
|
|
if (state.currentTab === 'write') return renderWriteTab();
|
|
if (state.currentTab === 'history') return renderHistoryTab();
|
|
if (state.currentTab === 'tasks') return renderTasksTab();
|
|
if (state.currentTab === 'experiences') return renderExperiencesTab();
|
|
}
|
|
|
|
function renderWriteTab() {
|
|
const today = state.selectedDate;
|
|
const existingDiary = state.allDiaries.find(d => d.date === today);
|
|
|
|
return `
|
|
<div class="section-box">
|
|
<h2>✍️ 写日记 - ${today}</h2>
|
|
<div class="diary-editor">
|
|
<div class="form-group">
|
|
<label>📝 今天做了什么?</label>
|
|
<textarea id="completedTasks" placeholder="记录今天完成的任务和工作..." rows="4">${existingDiary ? existingDiary.completed_tasks : ''}</textarea>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>📚 学到了什么?</label>
|
|
<textarea id="learned" placeholder="新的知识、技能、感悟..." rows="3">${existingDiary ? existingDiary.learned : ''}</textarea>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>🔗 关联任务(自动更新进展)</label>
|
|
<div style="max-height: 200px; overflow-y: auto; border: 1px solid #e0e7ff; border-radius: 6px; padding: 10px;">
|
|
${state.allTasks.filter(t => t.status !== 'completed').map(task => `
|
|
<div class="task-link-item">
|
|
<input type="checkbox" id="task-${task.id}" ${existingDiary && existingDiary.linked_tasks?.some(t => t.id === task.id) ? 'checked' : ''}>
|
|
<div class="task-info">
|
|
<div class="task-title">${task.title}</div>
|
|
<div style="font-size: 0.85em; color: #666;">当前进展:${task.progress_percent}%</div>
|
|
</div>
|
|
<span class="task-status">${task.status_display}</span>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>💡 想法和反思</label>
|
|
<textarea id="reflections" placeholder="今天的思考、反思、改进想法..." rows="3">${existingDiary ? existingDiary.reflections : ''}</textarea>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>📈 进步点</label>
|
|
<textarea id="improvements" placeholder="今天在哪些方面有进步?" rows="2">${existingDiary ? existingDiary.improvements : ''}</textarea>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>🎯 明日计划</label>
|
|
<textarea id="plans" placeholder="明天打算做什么?" rows="2">${existingDiary ? existingDiary.plans : ''}</textarea>
|
|
</div>
|
|
|
|
<button class="btn" onclick="saveDiary()">💾 保存日记</button>
|
|
${existingDiary ? `<button class="btn btn-secondary" onclick="extractExperience()">✨ 提炼经验</button>` : ''}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderHistoryTab() {
|
|
return `
|
|
<div class="section-box">
|
|
<h2>📖 日记历史</h2>
|
|
${state.allDiaries.length === 0 ? '<div class="empty-state">暂无日记</div>' : state.allDiaries.map(diary => `
|
|
<div class="diary-item">
|
|
<div class="date">${diary.date}</div>
|
|
${diary.completed_tasks ? `
|
|
<div class="section">
|
|
<div class="section-title">✅ 完成的任务</div>
|
|
<div class="section-content">${diary.completed_tasks}</div>
|
|
</div>
|
|
` : ''}
|
|
${diary.learned ? `
|
|
<div class="section">
|
|
<div class="section-title">📚 学到的东西</div>
|
|
<div class="section-content">${diary.learned}</div>
|
|
</div>
|
|
` : ''}
|
|
${diary.reflections ? `
|
|
<div class="section">
|
|
<div class="section-title">💡 想法和反思</div>
|
|
<div class="section-content">${diary.reflections}</div>
|
|
</div>
|
|
` : ''}
|
|
${diary.linked_tasks && diary.linked_tasks.length > 0 ? `
|
|
<div class="linked-tasks">
|
|
<div class="section-title">🔗 关联任务</div>
|
|
${diary.linked_tasks.map(task => `<span class="tag">${task.title}</span>`).join('')}
|
|
</div>
|
|
` : ''}
|
|
${diary.experiences && diary.experiences.length > 0 ? `
|
|
<div class="linked-experiences">
|
|
<div class="section-title">💡 提炼经验</div>
|
|
${diary.experiences.map(exp => `<span class="tag" style="background: #fef3c7; color: #92400e;">${exp.title}</span>`).join('')}
|
|
</div>
|
|
` : ''}
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderTasksTab() {
|
|
return `
|
|
<div class="section-box">
|
|
<h2>📋 所有任务</h2>
|
|
${state.allTasks.length === 0 ? '<div class="empty-state">暂无任务</div>' : state.allTasks.map(task => `
|
|
<div class="diary-item" style="border-left-color: ${task.status === 'completed' ? '#10b981' : '#667eea'}">
|
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
|
|
<div class="task-title" style="font-size: 1.1em;">${task.title}</div>
|
|
<span class="task-status">${task.status_display}</span>
|
|
</div>
|
|
<div style="font-size: 0.9em; color: #666; margin-bottom: 10px;">
|
|
优先级:${task.priority_display} | 进展:${task.progress_percent}%
|
|
</div>
|
|
${task.diary_entries && task.diary_entries.length > 0 ? `
|
|
<div class="linked-tasks">
|
|
<div class="section-title">📝 关联日记</div>
|
|
${task.diary_entries.map(d => `<span class="tag">${d.date}</span>`).join('')}
|
|
</div>
|
|
` : ''}
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderExperiencesTab() {
|
|
return `
|
|
<div class="section-box">
|
|
<h2>💡 经验总结</h2>
|
|
${state.allExperiences.length === 0 ? '<div class="empty-state">暂无经验总结</div>' : state.allExperiences.map(exp => `
|
|
<div class="experience-item">
|
|
<div class="category">${exp.category_display}</div>
|
|
<div class="title">${exp.title}</div>
|
|
<div style="margin: 10px 0;"><strong>🐛 问题:</strong>${exp.problem}</div>
|
|
<div style="margin: 10px 0;"><strong>✅ 解决:</strong>${exp.solution}</div>
|
|
${exp.lesson_learned ? `<div style="margin: 10px 0; padding: 10px; background: #fef3c7; border-radius: 5px;"><strong>📌 教训:</strong>${exp.lesson_learned}</div>` : ''}
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function switchTab(tab) {
|
|
state.currentTab = tab;
|
|
render();
|
|
}
|
|
|
|
async function saveDiary() {
|
|
const today = state.selectedDate;
|
|
const existingDiary = state.allDiaries.find(d => d.date === today);
|
|
|
|
const data = {
|
|
date: today,
|
|
title: '每日日记',
|
|
completed_tasks: document.getElementById('completedTasks').value,
|
|
learned: document.getElementById('learned').value,
|
|
reflections: document.getElementById('reflections').value,
|
|
improvements: document.getElementById('improvements').value,
|
|
plans: document.getElementById('plans').value
|
|
};
|
|
|
|
const linkedTaskIds = state.allTasks
|
|
.filter(task => document.getElementById(`task-${task.id}`)?.checked)
|
|
.map(task => task.id);
|
|
|
|
try {
|
|
let response;
|
|
if (existingDiary) {
|
|
response = await fetch(`${API_BASE}/entries/${existingDiary.id}/`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(data)
|
|
});
|
|
} else {
|
|
response = await fetch(`${API_BASE}/entries/`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(data)
|
|
});
|
|
}
|
|
|
|
const diary = await response.json();
|
|
|
|
// 关联任务
|
|
for (const taskId of linkedTaskIds) {
|
|
await fetch(`${API_BASE}/entries/${diary.id}/link_task/`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ task_id: taskId, progress_percent: 100, notes: '日记中完成' })
|
|
});
|
|
}
|
|
|
|
alert('✅ 日记已保存!关联任务已自动更新进展。');
|
|
loadData();
|
|
switchTab('history');
|
|
} catch (error) {
|
|
alert('保存失败:' + error.message);
|
|
}
|
|
}
|
|
|
|
function extractExperience() {
|
|
const reflections = document.getElementById('reflections').value;
|
|
if (!reflections) {
|
|
alert('请先写一些反思内容');
|
|
return;
|
|
}
|
|
alert('💡 功能开发中:选择反思内容,一键提炼为经验总结');
|
|
}
|
|
|
|
loadData();
|
|
</script>
|
|
</body>
|
|
</html>
|