]> acesimba.cloud Git - words.git/commitdiff
feat: 学习日历功能
authorwords <words@local>
Thu, 30 Jul 2026 03:45:55 +0000 (11:45 +0800)
committerwords <words@local>
Thu, 30 Jul 2026 03:45:55 +0000 (11:45 +0800)
- 后端新增 GET /api/calendar: 按日期聚合 study_logs 返回活动记录
- 前端新建 StudyCalendar 组件: 周一起始日历网格, 当前月置顶, 其余月份倒序
- 有学习记录的日期浅蓝底, 无记录灰底, 今天蓝边框
- 单日全量列出活动: 学/测/R1/R2/R3 + group
- 颜色: 学测橙色突出, R1/R2/R3 暗淡蓝绿
- 月份标题显示当月学习 group 数(小字号灰蓝色)
- Home.js 加'学习日历'入口按钮, App.js 加 /calendar 路由

backend/main.py
frontend/src/App.js
frontend/src/components/Home.js
frontend/src/components/StudyCalendar.css [new file with mode: 0644]
frontend/src/components/StudyCalendar.js [new file with mode: 0644]

index ec6850fe7eaa95be1fd24241f756dec61120b351..dbf72325f430fb1c3254cbb65b6afcfe5fb41406 100644 (file)
@@ -266,6 +266,32 @@ async def update_progress(data: ProgressUpdate):
     except Exception as e:
         raise HTTPException(status_code=500, detail=str(e))
 
+@app.get("/api/calendar")
+async def get_calendar():
+    # 按日期聚合 study_logs,供学习日历使用
+    try:
+        conn = sqlite3.connect(DATABASE_PATH)
+        cursor = conn.cursor()
+        cursor.execute("""
+            SELECT substr(created_at,1,10) as day, op_type, group_id
+            FROM study_logs
+            WHERE status='completed'
+            ORDER BY day, op_type, CAST(group_id AS INTEGER)
+        """)
+        day_map = {}
+        for day, op_type, group_id in cursor.fetchall():
+            day_map.setdefault(day, {}).setdefault(op_type, []).append(str(group_id))
+        conn.close()
+        op_order = ['study', 'test', 'review1', 'review2', 'review3']
+        result = []
+        for day in sorted(day_map.keys()):
+            acts = day_map[day]
+            activities = [{"op_type": op, "groups": acts[op]} for op in op_order if op in acts]
+            result.append({"date": day, "activities": activities})
+        return result
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=str(e))
+
 @app.get("/")
 async def root():
     return {"message": "English Lexicon API is running"}
index ff960f27a8fbcc18a36dc1809c37ddc4b0020f41..205187a450552d916b9599a39d97684e2ba74cea 100644 (file)
@@ -4,6 +4,7 @@ import Home from './components/Home';
 import StudyMode from './components/StudyMode';
 import TestMode from './components/TestMode';
 import GroupView from './components/GroupView';
+import StudyCalendar from './components/StudyCalendar';
 
 function App() {
     return (
@@ -13,6 +14,7 @@ function App() {
                 <Route path="/study/:groupId" element={<StudyMode />} />
                 <Route path="/test/:groupId" element={<TestMode />} />
                 <Route path="/group/:groupId" element={<GroupView />} />
+                <Route path="/calendar" element={<StudyCalendar />} />
             </Routes>
         </Router>
     );
index 4778bf2422d6ba38cd0dee8b03dec4449017ffd3..afe151a76ccd4a1c82b691a45457317743ce07b6 100644 (file)
@@ -80,6 +80,9 @@ function Home() {
                 <button className="toggle-btn" onClick={() => setShowAll(!showAll)}>
                     {showAll ? "➖ 隐藏已完成组" : "➕ 显示已完成的组"}
                 </button>
+                <button className="toggle-btn" onClick={() => navigate('/calendar')}>
+                    📅 学习日历
+                </button>
             </div>
 
             <div className="table-container">
diff --git a/frontend/src/components/StudyCalendar.css b/frontend/src/components/StudyCalendar.css
new file mode 100644 (file)
index 0000000..d2ec6b8
--- /dev/null
@@ -0,0 +1,146 @@
+/* StudyCalendar.css - 学习日历 */
+
+.cal-page {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    padding: 20px;
+    background-color: #f0f7ff;
+    min-height: 100vh;
+}
+
+.cal-container {
+    width: 95%;
+    max-width: 1100px;
+    display: flex;
+    flex-direction: column;
+    gap: 24px;
+}
+
+.cal-title {
+    text-align: center;
+    font-size: 22px;
+    color: #1e40af;
+    margin: 0;
+}
+
+.cal-month {
+    background: white;
+    border-radius: 16px;
+    box-shadow: 0 8px 24px rgba(17, 24, 39, 0.06);
+    padding: 20px;
+}
+
+.cal-month-title {
+    font-size: 18px;
+    font-weight: 600;
+    color: #1e40af;
+    margin-bottom: 12px;
+    text-align: center;
+}
+
+.cal-month-count {
+    color: #64748b;
+    font-size: 14px;
+    font-weight: 500;
+}
+
+.cal-grid {
+    display: grid;
+    grid-template-columns: repeat(7, 1fr);
+    gap: 4px;
+}
+
+.cal-weekday {
+    text-align: center;
+    font-size: 13px;
+    font-weight: 600;
+    color: #1e40af;
+    background: #eff6ff;
+    padding: 8px 0;
+    border-radius: 6px;
+}
+
+.cal-day {
+    min-height: 110px;
+    border: 1px solid #e2e8f0;
+    border-radius: 8px;
+    padding: 6px;
+    background: #f8fafc;
+    display: flex;
+    flex-direction: column;
+    gap: 3px;
+    overflow: hidden;
+}
+
+.cal-day.empty {
+    background: transparent;
+    border: none;
+}
+
+.cal-day.active {
+    background: #eff6ff;
+    border-color: #bfdbfe;
+}
+
+.cal-day.today {
+    border: 2px solid #1d4ed8;
+}
+
+.cal-day-num {
+    font-size: 13px;
+    font-weight: 700;
+    color: #475569;
+    text-align: right;
+}
+
+.cal-day.today .cal-day-num {
+    color: #1d4ed8;
+}
+
+.cal-act {
+    font-size: 12px;
+    line-height: 1.3;
+    font-weight: 500;
+}
+
+.back-home {
+    margin-top: 20px;
+    background: none;
+    border: none;
+    color: #64748b;
+    text-decoration: underline;
+    cursor: pointer;
+    font-size: 14px;
+}
+
+/* 响应式 */
+@media (max-width: 768px) {
+    .cal-container {
+        width: 98%;
+    }
+    .cal-day {
+        min-height: 80px;
+        padding: 4px;
+    }
+    .cal-act {
+        font-size: 10px;
+        line-height: 1.2;
+    }
+    .cal-day-num {
+        font-size: 11px;
+    }
+    .cal-weekday {
+        font-size: 11px;
+        padding: 6px 0;
+    }
+    .cal-month-title {
+        font-size: 16px;
+    }
+    .cal-month-count {
+        font-size: 12px;
+    }
+    .cal-title {
+        font-size: 20px;
+    }
+}
diff --git a/frontend/src/components/StudyCalendar.js b/frontend/src/components/StudyCalendar.js
new file mode 100644 (file)
index 0000000..33940f1
--- /dev/null
@@ -0,0 +1,101 @@
+import React, { useState, useEffect } from 'react';
+import { useNavigate } from 'react-router-dom';
+import axios from 'axios';
+import './StudyCalendar.css';
+
+const API_BASE = "http://124.222.157.124:1060";
+
+const OP_LABEL = { study: '学', test: '测', review1: 'R1', review2: 'R2', review3: 'R3' };
+const OP_COLOR = { study: '#ea580c', test: '#f97316', review1: '#5b7c99', review2: '#6b8e7a', review3: '#8a9ba8' };
+const WEEKDAYS = ['一', '二', '三', '四', '五', '六', '日'];
+
+function pad(n) { return String(n).padStart(2, '0'); }
+
+function StudyCalendar() {
+    const navigate = useNavigate();
+    const [dayMap, setDayMap] = useState({});
+
+    useEffect(() => {
+        axios.get(`${API_BASE}/api/calendar`)
+            .then(res => {
+                const m = {};
+                res.data.forEach(item => { m[item.date] = item.activities; });
+                setDayMap(m);
+            })
+            .catch(err => console.error("获取日历失败:", err));
+    }, []);
+
+    const today = new Date();
+    const todayStr = `${today.getFullYear()}-${pad(today.getMonth() + 1)}-${pad(today.getDate())}`;
+    const currentMonth = `${today.getFullYear()}-${pad(today.getMonth() + 1)}`;
+
+    // 月份渲染顺序: 当前月 + 有记录月份(去除当前月, 倒序)
+    const recordMonths = Object.keys(dayMap).map(d => d.slice(0, 7));
+    const uniqueRecordMonths = Array.from(new Set(recordMonths)).sort().reverse();
+    const renderMonths = [currentMonth, ...uniqueRecordMonths.filter(m => m !== currentMonth)];
+
+    function renderMonth(monthStr) {
+        const [yStr, mStr] = monthStr.split('-');
+        const y = parseInt(yStr), m = parseInt(mStr);
+        const firstDay = new Date(y, m - 1, 1).getDay();      // 0=Sun
+        const offset = (firstDay + 6) % 7;                      // 周一起始的空格数
+        const daysInMonth = new Date(y, m, 0).getDate();
+        // 统计当月学习的 group 数(op_type='study' 的不同 group)
+        const studiedGroups = new Set();
+        Object.keys(dayMap).forEach(dateStr => {
+            if (dateStr.slice(0, 7) === monthStr) {
+                dayMap[dateStr].forEach(a => {
+                    if (a.op_type === 'study') a.groups.forEach(g => studiedGroups.add(g));
+                });
+            }
+        });
+        const studiedCount = studiedGroups.size;
+        const cells = [];
+        for (let i = 0; i < offset; i++) cells.push(null);
+        for (let d = 1; d <= daysInMonth; d++) cells.push(d);
+        while (cells.length % 7 !== 0) cells.push(null);
+
+        return (
+            <div className="cal-month" key={monthStr}>
+                <div className="cal-month-title">
+                    {y}年 {m}月 · <span className="cal-month-count">(学习了{studiedCount}个group)</span>
+                </div>
+                <div className="cal-grid">
+                    {WEEKDAYS.map(w => <div className="cal-weekday" key={w}>{w}</div>)}
+                    {cells.map((d, i) => {
+                        if (d === null) return <div className="cal-day empty" key={i} />;
+                        const dateStr = `${monthStr}-${pad(d)}`;
+                        const acts = dayMap[dateStr];
+                        const isActive = !!acts;
+                        const isToday = dateStr === todayStr;
+                        const cls = ['cal-day'];
+                        if (isActive) cls.push('active');
+                        if (isToday) cls.push('today');
+                        return (
+                            <div className={cls.join(' ')} key={i}>
+                                <div className="cal-day-num">{d}</div>
+                                {acts && acts.map((a, j) => (
+                                    <div className="cal-act" key={j} style={{ color: OP_COLOR[a.op_type] || '#475569' }}>
+                                        {OP_LABEL[a.op_type] || a.op_type} G{a.groups.join(',')}
+                                    </div>
+                                ))}
+                            </div>
+                        );
+                    })}
+                </div>
+            </div>
+        );
+    }
+
+    return (
+        <div className="cal-page">
+            <div className="cal-container">
+                <h1 className="cal-title">📅 学习日历</h1>
+                {renderMonths.map(renderMonth)}
+            </div>
+            <button className="back-home" onClick={() => navigate('/')}>返回首页</button>
+        </div>
+    );
+}
+
+export default StudyCalendar;