<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>🎀 粉色打卡 · Hello Kitty 学习日记</title>
<style>
/* ============================================================
全局重置 & 粉色主题
============================================================ */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, "PingFang SC", "Microsoft YaHei", "Helvetica Neue", sans-serif;
background: #fff5f7;
min-height: 100vh;
padding: 12px 10px 80px;
color: #5a3d3d;
}
/* ---- 主容器 ---- */
.app {
max-width: 900px;
margin: 0 auto;
}
/* ---- 标题栏 ---- */
.header {
background: linear-gradient(135deg, #ffb6c9 0%, #ff8fab 50%, #ff6b8a 100%);
border-radius: 28px 28px 28px 28px;
padding: 18px 22px 16px;
margin-bottom: 18px;
box-shadow: 0 6px 20px rgba(255, 107, 138, 0.30);
text-align: center;
position: relative;
overflow: hidden;
}
.header::before {
content: "🌸 🎀 🐱 🌸 🎀 🐱";
position: absolute;
top: -10px;
right: -10px;
font-size: 28px;
opacity: 0.20;
letter-spacing: 8px;
white-space: nowrap;
pointer-events: none;
}
.header h1 {
font-size: 22px;
font-weight: 700;
color: #ffffff;
text-shadow: 0 2px 8px rgba(180, 50, 80, 0.30);
letter-spacing: 1px;
position: relative;
z-index: 1;
}
.header h1 .emoji {
font-size: 26px;
}
.header .sub {
font-size: 12px;
color: rgba(255, 255, 255, 0.85);
margin-top: 2px;
letter-spacing: 2px;
font-weight: 400;
position: relative;
z-index: 1;
}
/* ---- 分类栏 ---- */
.category-bar {
display: flex;
flex-wrap: wrap;
gap: 8px 10px;
background: #ffffffdd;
backdrop-filter: blur(4px);
border-radius: 40px;
padding: 8px 16px 8px 20px;
margin-bottom: 16px;
box-shadow: 0 2px 12px rgba(255, 150, 180, 0.15);
align-items: center;
border: 1.5px solid #ffd9e4;
}
.category-bar .cat-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
flex: 1;
}
.category-bar .cat-btn {
padding: 6px 18px;
border-radius: 30px;
font-size: 14px;
font-weight: 600;
border: 2px solid transparent;
background: #fff0f3;
color: #b06a7a;
cursor: pointer;
transition: 0.25s;
font-family: inherit;
}
.category-bar .cat-btn:hover {
background: #ffe2e9;
transform: scale(1.02);
}
.category-bar .cat-btn.active {
background: #ff6b8a;
color: #fff;
border-color: #ff4d7a;
box-shadow: 0 2px 12px rgba(255, 75, 122, 0.30);
}
.category-bar .cat-btn .del-cat {
margin-left: 6px;
font-size: 12px;
opacity: 0.5;
cursor: pointer;
transition: 0.2s;
display: inline-block;
}
.category-bar .cat-btn .del-cat:hover {
opacity: 1;
color: #d94f6a;
}
.category-bar .add-cat-btn {
padding: 6px 16px;
border-radius: 30px;
font-size: 13px;
font-weight: 600;
border: 2px dashed #ffb6c9;
background: transparent;
color: #b06a7a;
cursor: pointer;
transition: 0.25s;
font-family: inherit;
white-space: nowrap;
}
.category-bar .add-cat-btn:hover {
background: #ffd9e4;
border-color: #ff6b8a;
}
/* ---- 视图切换 + 日期导航 ---- */
.view-toolbar {
display: flex;
flex-wrap: wrap;
gap: 10px 16px;
align-items: center;
justify-content: space-between;
background: #ffffffdd;
backdrop-filter: blur(4px);
border-radius: 30px;
padding: 10px 18px;
margin-bottom: 16px;
border: 1.5px solid #ffd9e4;
box-shadow: 0 2px 12px rgba(255, 150, 180, 0.10);
}
.view-toolbar .view-group {
display: flex;
gap: 4px;
background: #fff0f3;
border-radius: 30px;
padding: 3px;
}
.view-toolbar .view-btn {
padding: 6px 18px;
border-radius: 30px;
font-size: 13px;
font-weight: 600;
border: none;
background: transparent;
color: #b06a7a;
cursor: pointer;
transition: 0.25s;
font-family: inherit;
}
.view-toolbar .view-btn.active {
background: #ff6b8a;
color: #fff;
box-shadow: 0 2px 10px rgba(255, 75, 122, 0.25);
}
.view-toolbar .view-btn:hover:not(.active) {
background: #ffe2e9;
}
.view-toolbar .nav-group {
display: flex;
align-items: center;
gap: 8px;
}
.view-toolbar .nav-btn {
width: 36px;
height: 36px;
border-radius: 50%;
border: 1.5px solid #ffd9e4;
background: #fff;
color: #b06a7a;
font-size: 18px;
cursor: pointer;
transition: 0.2s;
display: flex;
align-items: center;
justify-content: center;
font-family: inherit;
}
.view-toolbar .nav-btn:hover {
background: #ffd9e4;
border-color: #ff6b8a;
}
.view-toolbar .nav-label {
font-size: 14px;
font-weight: 600;
color: #5a3d3d;
min-width: 120px;
text-align: center;
}
.view-toolbar .nav-label .sub-text {
font-size: 12px;
font-weight: 400;
color: #b06a7a;
}
/* ---- 打卡网格 ---- */
.grid-container {
background: #ffffff;
border-radius: 28px;
padding: 18px 14px 14px;
box-shadow: 0 4px 20px rgba(255, 150, 180, 0.12);
border: 1.5px solid #ffd9e4;
margin-bottom: 16px;
}
.grid-container .grid-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 4px 12px 4px;
border-bottom: 2px dashed #ffe2e9;
margin-bottom: 12px;
}
.grid-container .grid-header .title {
font-size: 15px;
font-weight: 700;
color: #5a3d3d;
}
.grid-container .grid-header .title .cat-name {
color: #ff6b8a;
}
.grid-container .grid-header .hint {
font-size: 12px;
color: #b06a7a;
background: #fff0f3;
padding: 2px 14px;
border-radius: 20px;
}
/* 星期头 */
.weekday-row {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 6px;
margin-bottom: 8px;
}
.weekday-row .wd {
text-align: center;
font-size: 12px;
font-weight: 600;
color: #b06a7a;
padding: 4px 0;
background: #fff8fa;
border-radius: 12px;
}
.weekday-row .wd.weekend {
color: #ff6b8a;
}
/* 日期卡片网格 */
.day-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 6px;
}
.day-card {
aspect-ratio: 1 / 0.9;
background: #fff8fa;
border-radius: 16px;
border: 2px solid #ffecf0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
transition: 0.2s;
padding: 4px 2px;
position: relative;
min-height: 64px;
}
.day-card:hover {
border-color: #ffb6c9;
transform: scale(1.02);
background: #fff5f7;
}
.day-card .day-num {
font-size: 15px;
font-weight: 600;
color: #5a3d3d;
line-height: 1.2;
}
.day-card .day-num.other-month {
opacity: 0.3;
}
.day-card .day-val {
font-size: 18px;
font-weight: 700;
color: #ff6b8a;
line-height: 1.2;
margin-top: 2px;
}
.day-card .day-val.zero {
color: #e0c8cc;
font-weight: 400;
font-size: 14px;
}
.day-card .day-unit {
font-size: 10px;
color: #b06a7a;
font-weight: 400;
}
.day-card .cat-tag {
position: absolute;
top: 3px;
right: 6px;
font-size: 8px;
color: #ffb6c9;
background: #fff0f3;
padding: 0 6px;
border-radius: 8px;
opacity: 0;
transition: 0.2s;
}
.day-card:hover .cat-tag {
opacity: 1;
}
.day-card.today {
border-color: #ff6b8a;
background: #fff0f5;
box-shadow: 0 0 0 3px #ffd9e4;
}
.day-card.today .day-num {
color: #ff4d7a;
}
.day-card.has-record {
border-color: #ffb6c9;
}
.day-card .kitty-paw {
font-size: 14px;
position: absolute;
bottom: 2px;
right: 4px;
opacity: 0.15;
}
/* ---- 统计信息 ---- */
.stats-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
gap: 10px;
margin-top: 14px;
padding-top: 14px;
border-top: 2px dashed #ffe2e9;
}
.stats-row .stat-item {
text-align: center;
background: #fff8fa;
border-radius: 16px;
padding: 8px 6px;
}
.stats-row .stat-item .num {
font-size: 20px;
font-weight: 700;
color: #ff6b8a;
}
.stats-row .stat-item .label {
font-size: 11px;
color: #b06a7a;
margin-top: 1px;
}
.stats-row .stat-item .num.gold {
color: #d4a06a;
}
.stats-row .stat-item .num.pink {
color: #ff6b8a;
}
/* ---- 月视图专用 ---- */
.month-grid .day-card {
aspect-ratio: 1 / 0.85;
min-height: 52px;
}
.month-grid .day-card .day-num {
font-size: 13px;
}
.month-grid .day-card .day-val {
font-size: 15px;
}
/* ---- 模态框 ---- */
.modal-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(90, 50, 60, 0.35);
backdrop-filter: blur(4px);
z-index: 999;
align-items: center;
justify-content: center;
padding: 20px;
animation: fadeIn 0.25s ease;
}
.modal-overlay.open {
display: flex;
}
@keyframes fadeIn {
from { opacity: 0; transform: scale(0.96); }
to { opacity: 1; transform: scale(1); }
}
.modal-box {
background: #ffffff;
border-radius: 40px;
max-width: 380px;
width: 100%;
padding: 30px 28px 26px;
box-shadow: 0 20px 60px rgba(180, 70, 100, 0.25);
border: 2px solid #ffd9e4;
position: relative;
overflow: hidden;
}
.modal-box::before {
content: "🌸 🎀 🐱";
position: absolute;
top: -6px;
right: -6px;
font-size: 36px;
opacity: 0.10;
pointer-events: none;
}
.modal-box .modal-title {
font-size: 18px;
font-weight: 700;
color: #5a3d3d;
margin-bottom: 6px;
display: flex;
align-items: center;
gap: 8px;
}
.modal-box .modal-title .cat-badge {
background: #ff6b8a;
color: #fff;
font-size: 12px;
padding: 2px 14px;
border-radius: 20px;
font-weight: 600;
}
.modal-box .modal-date {
font-size: 14px;
color: #b06a7a;
margin-bottom: 18px;
}
.modal-box .modal-input-group {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
}
.modal-box .modal-input-group input {
flex: 1;
padding: 12px 16px;
border-radius: 30px;
border: 2px solid #ffd9e4;
font-size: 20px;
font-weight: 600;
color: #5a3d3d;
text-align: center;
background: #fff8fa;
font-family: inherit;
outline: none;
transition: 0.2s;
width: 100%;
}
.modal-box .modal-input-group input:focus {
border-color: #ff6b8a;
box-shadow: 0 0 0 4px #ffd9e4;
}
.modal-box .modal-input-group .unit {
font-size: 16px;
font-weight: 600;
color: #b06a7a;
min-width: 20px;
}
.modal-box .modal-hint {
font-size: 12px;
color: #b06a7a;
text-align: center;
margin-bottom: 16px;
}
.modal-box .modal-actions {
display: flex;
gap: 10px;
}
.modal-box .modal-actions button {
flex: 1;
padding: 12px;
border-radius: 30px;
font-size: 15px;
font-weight: 600;
border: none;
cursor: pointer;
transition: 0.2s;
font-family: inherit;
}
.modal-box .modal-actions .btn-cancel {
background: #f0ebe8;
color: #7a5a5a;
}
.modal-box .modal-actions .btn-cancel:hover {
background: #e5ddd8;
}
.modal-box .modal-actions .btn-save {
background: #ff6b8a;
color: #fff;
box-shadow: 0 4px 14px rgba(255, 75, 122, 0.30);
}
.modal-box .modal-actions .btn-save:hover {
background: #ff4d7a;
transform: scale(1.02);
}
.modal-box .modal-actions .btn-delete {
background: #fff0f3;
color: #d94f6a;
flex: 0.6;
}
.modal-box .modal-actions .btn-delete:hover {
background: #ffe2e9;
}
/* ---- Toast ---- */
.toast {
position: fixed;
bottom: 90px;
left: 50%;
transform: translateX(-50%);
background: rgba(60, 40, 40, 0.92);
color: #fff5f7;
padding: 10px 24px;
border-radius: 30px;
font-size: 14px;
font-weight: 500;
pointer-events: none;
opacity: 0;
transition: opacity 0.3s;
z-index: 1000;
backdrop-filter: blur(4px);
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
}
.toast.show {
opacity: 1;
}
/* ---- 底部工具栏 ---- */
.bottom-tools {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
margin-top: 8px;
}
.bottom-tools button {
padding: 8px 18px;
border-radius: 30px;
border: 1.5px solid #ffd9e4;
background: #ffffffdd;
backdrop-filter: blur(4px);
font-size: 12px;
font-weight: 500;
color: #b06a7a;
cursor: pointer;
transition: 0.2s;
font-family: inherit;
}
.bottom-tools button:hover {
background: #ffd9e4;
border-color: #ff6b8a;
}
.bottom-tools button.danger {
color: #d94f6a;
}
.bottom-tools button.danger:hover {
background: #ffe2e9;
border-color: #d94f6a;
}
/* ---- 响应式 ---- */
@media (max-width: 640px) {
.header h1 { font-size: 18px; }
.category-bar { padding: 6px 12px; border-radius: 30px; }
.category-bar .cat-btn { font-size: 12px; padding: 4px 12px; }
.category-bar .add-cat-btn { font-size: 12px; padding: 4px 12px; }
.view-toolbar { padding: 8px 12px; }
.view-toolbar .view-btn { font-size: 12px; padding: 4px 12px; }
.view-toolbar .nav-label { font-size: 12px; min-width: 80px; }
.view-toolbar .nav-btn { width: 30px; height: 30px; font-size: 14px; }
.day-card { min-height: 50px; border-radius: 12px; }
.day-card .day-num { font-size: 13px; }
.day-card .day-val { font-size: 15px; }
.month-grid .day-card { min-height: 44px; }
.month-grid .day-card .day-num { font-size: 11px; }
.month-grid .day-card .day-val { font-size: 13px; }
.grid-container { padding: 12px 8px; }
.stats-row .stat-item .num { font-size: 16px; }
.modal-box { padding: 24px 20px; }
.modal-box .modal-input-group input { font-size: 17px; padding: 10px 12px; }
.weekday-row .wd { font-size: 10px; }
.day-grid { gap: 4px; }
.weekday-row { gap: 4px; }
}
@media (max-width: 420px) {
.day-card { min-height: 40px; border-radius: 10px; }
.day-card .day-num { font-size: 11px; }
.day-card .day-val { font-size: 13px; }
.month-grid .day-card { min-height: 36px; }
.month-grid .day-card .day-num { font-size: 10px; }
.month-grid .day-card .day-val { font-size: 11px; }
.view-toolbar .nav-label { font-size: 10px; min-width: 60px; }
.header h1 { font-size: 16px; }
.category-bar .cat-btn { font-size: 11px; padding: 3px 10px; }
}
/* ---- 滚动条美化 ---- */
::-webkit-scrollbar { width: 4px; height: 4px; }
::-webkit-scrollbar-track { background: #fff5f7; }
::-webkit-scrollbar-thumb { background: #ffb6c9; border-radius: 10px; }
::-webkit-scrollbar-thumb:hover { background: #ff8fab; }
/* ---- 空状态 ---- */
.empty-state {
text-align: center;
padding: 40px 20px;
color: #b06a7a;
}
.empty-state .big-emoji {
font-size: 48px;
display: block;
margin-bottom: 12px;
}
.empty-state .text {
font-size: 15px;
}
.empty-state .sub-text {
font-size: 13px;
color: #d0b0b8;
}
/* ---- 新增分类输入 ---- */
.add-cat-input-group {
display: none;
margin-top: 8px;
width: 100%;
gap: 8px;
align-items: center;
}
.add-cat-input-group.open {
display: flex;
}
.add-cat-input-group input {
flex: 1;
padding: 6px 14px;
border-radius: 30px;
border: 2px solid #ffd9e4;
font-size: 14px;
font-family: inherit;
outline: none;
background: #fff;
transition: 0.2s;
min-width: 80px;
}
.add-cat-input-group input:focus {
border-color: #ff6b8a;
box-shadow: 0 0 0 4px #ffd9e4;
}
.add-cat-input-group .add-confirm {
padding: 6px 16px;
border-radius: 30px;
border: none;
background: #ff6b8a;
color: #fff;
font-weight: 600;
font-size: 13px;
cursor: pointer;
font-family: inherit;
white-space: nowrap;
}
.add-cat-input-group .add-confirm:hover {
background: #ff4d7a;
}
.add-cat-input-group .add-cancel {
padding: 6px 14px;
border-radius: 30px;
border: 1.5px solid #ffd9e4;
background: transparent;
color: #b06a7a;
font-size: 13px;
cursor: pointer;
font-family: inherit;
}
</style>
</head>
<body>
<div class="app" id="app">
<!-- ============================================================
标题栏
============================================================ -->
<div class="header">
<h1>
<span class="emoji">🎀</span>
粉色打卡 · Hello Kitty 学习日记
<span class="emoji">🐱</span>
</h1>
<div class="sub">🌸 记录每一天的成长 · 可爱又自律 🌸</div>
</div>
<!-- ============================================================
分类栏
============================================================ -->
<div class="category-bar" id="categoryBar">
<div class="cat-list" id="catList"></div>
<button class="add-cat-btn" id="addCatBtn">+ 新增分类</button>
<div class="add-cat-input-group" id="addCatInputGroup">
<input type="text" id="newCatInput" placeholder="输入分类名称..." maxlength="12">
<button class="add-confirm" id="addCatConfirm">✅ 添加</button>
<button class="add-cancel" id="addCatCancel">取消</button>
</div>
</div>
<!-- ============================================================
视图工具栏
============================================================ -->
<div class="view-toolbar">
<div class="view-group">
<button class="view-btn active" data-view="week" id="viewWeek">📅 周视图</button>
<button class="view-btn" data-view="month" id="viewMonth">📆 月视图</button>
</div>
<div class="nav-group">
<button class="nav-btn" id="navPrev">‹</button>
<span class="nav-label" id="navLabel">2027年 第1周</span>
<button class="nav-btn" id="navNext">›</button>
</div>
</div>
<!-- ============================================================
打卡网格
============================================================ -->
<div class="grid-container" id="gridContainer">
<div class="grid-header">
<div class="title">
🎀 <span class="cat-name" id="currentCatName">心理</span> · 打卡记录
</div>
<div class="hint">🐱 点击日期修改</div>
</div>
<!-- 星期头 -->
<div class="weekday-row" id="weekdayRow">
<div class="wd">一</div>
<div class="wd">二</div>
<div class="wd">三</div>
<div class="wd">四</div>
<div class="wd">五</div>
<div class="wd weekend">六</div>
<div class="wd weekend">日</div>
</div>
<!-- 日期网格 -->
<div class="day-grid" id="dayGrid"></div>
<!-- 统计 -->
<div class="stats-row" id="statsRow">
<div class="stat-item">
<div class="num" id="statTotal">0</div>
<div class="label">📚 总时长</div>
</div>
<div class="stat-item">
<div class="num" id="statDays">0</div>
<div class="label">📅 打卡天数</div>
</div>
<div class="stat-item">
<div class="num" id="statAvg">0</div>
<div class="label">📊 日均</div>
</div>
<div class="stat-item">
<div class="num gold" id="statMax">0</div>
<div class="label">⭐ 单日最高</div>
</div>
</div>
</div>
<!-- ============================================================
底部工具
============================================================ -->
<div class="bottom-tools">
<button id="exportBtn">📤 导出备份</button>
<button id="importBtn">📥 导入备份</button>
<button class="danger" id="resetBtn">🔄 重置所有数据</button>
<input type="file" id="importFile" accept=".json" style="display:none">
</div>
<!-- ============================================================
模态框
============================================================ -->
<div class="modal-overlay" id="modalOverlay">
<div class="modal-box">
<div class="modal-title">
🎀 打卡 <span class="cat-badge" id="modalCatBadge">心理</span>
</div>
<div class="modal-date" id="modalDate">2027年1月1日</div>
<div class="modal-input-group">
<input type="number" id="modalInput" step="0.5" min="0" max="24" placeholder="0">
<span class="unit">小时</span>
</div>
<div class="modal-hint">💡 输入 0 可清除该天记录</div>
<div class="modal-actions">
<button class="btn-cancel" id="modalCancel">取消</button>
<button class="btn-delete" id="modalDelete">🗑️</button>
<button class="btn-save" id="modalSave">✅ 保存</button>
</div>
</div>
</div>
<!-- ============================================================
Toast
============================================================ -->
<div class="toast" id="toast"></div>
</div>
<!-- ============================================================
JavaScript
============================================================ -->
<script>
(function() {
'use strict';
// ============================================================
// 1. 数据层
// ============================================================
const STORAGE_KEY = 'kitty_checkin_data';
const DEFAULT_CATEGORIES = ['心理', '命理', '计科'];
function getDefaultData() {
return {
categories: [...DEFAULT_CATEGORIES],
records: {}
};
}
function loadData() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw);
// 确保 categories 存在
if (!parsed.categories || !Array.isArray(parsed.categories) || parsed.categories.length === 0) {
parsed.categories = [...DEFAULT_CATEGORIES];
}
// 确保 records 存在
if (!parsed.records || typeof parsed.records !== 'object') {
parsed.records = {};
}
// 确保每个分类在 records 中都有对象
parsed.categories.forEach(cat => {
if (!parsed.records[cat] || typeof parsed.records[cat] !== 'object') {
parsed.records[cat] = {};
}
});
return parsed;
}
} catch (e) {
console.warn('Load data error:', e);
}
const def = getDefaultData();
def.categories.forEach(cat => { def.records[cat] = {}; });
saveData(def);
return def;
}
function saveData(data) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
} catch (e) {
console.warn('Save data error:', e);
}
}
let appData = loadData();
// ============================================================
// 2. 状态
// ============================================================
let currentCategory = appData.categories[0] || '心理';
let currentView = 'week'; // 'week' | 'month'
let currentOffset = 0; // 周偏移或月偏移
// 模态框状态
let modalTarget = null; // { category, date }
// ============================================================
// 3. 工具函数
// ============================================================
function formatDate(date) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return y + '-' + m + '-' + d;
}
function parseDate(str) {
const parts = str.split('-').map(Number);
return new Date(parts[0], parts[1] - 1, parts[2]);
}
function isSameDay(d1, d2) {
return d1.getFullYear() === d2.getFullYear() &&
d1.getMonth() === d2.getMonth() &&
d1.getDate() === d2.getDate();
}
function getWeekStart(date) {
const d = new Date(date);
const day = d.getDay(); // 0=周日
const diff = (day === 0 ? 6 : day - 1); // 周一为0
d.setDate(d.getDate() - diff);
d.setHours(0, 0, 0, 0);
return d;
}
function getWeekEnd(weekStart) {
const d = new Date(weekStart);
d.setDate(d.getDate() + 6);
return d;
}
function getWeekNumber(date) {
const d = new Date(date);
const start = new Date(d.getFullYear(), 0, 1);
const diff = (d - start) / (7 * 24 * 60 * 60 * 1000);
return Math.ceil(diff);
}
function getMonthDays(year, month) {
return new Date(year, month + 1, 0).getDate();
}
function getFirstDayOfMonth(year, month) {
return new Date(year, month, 1).getDay(); // 0=周日
}
function getDateStr(year, month, day) {
return year + '-' + String(month + 1).padStart(2, '0') + '-' + String(day).padStart(2, '0');
}
function getChineseDate(date) {
const d = new Date(date);
return d.getFullYear() + '年' + (d.getMonth() + 1) + '月' + d.getDate() + '日';
}
function getWeekRangeLabel(weekStart) {
const start = new Date(weekStart);
const end = getWeekEnd(start);
const y1 = start.getFullYear();
const m1 = start.getMonth() + 1;
const d1 = start.getDate();
const y2 = end.getFullYear();
const m2 = end.getMonth() + 1;
const d2 = end.getDate();
if (y1 === y2 && m1 === m2) {
return y1 + '年 ' + m1 + '月' + d1 + '日-' + d2 + '日';
} else if (y1 === y2) {
return y1 + '年 ' + m1 + '月' + d1 + '日 - ' + m2 + '月' + d2 + '日';
} else {
return y1 + '年' + m1 + '月' + d1 + '日 - ' + y2 + '年' + m2 + '月' + d2 + '日';
}
}
function getTodayStr() {
return formatDate(new Date());
}
function isToday(dateStr) {
return dateStr === getTodayStr();
}
// ============================================================
// 4. 渲染函数
// ============================================================
function render() {
renderCategories();
renderGrid();
updateStats();
updateNavLabel();
}
function renderCategories() {
const container = document.getElementById('catList');
const html = appData.categories.map(cat => {
const active = cat === currentCategory ? 'active' : '';
const isDefault = DEFAULT_CATEGORIES.includes(cat);
const delBtn = isDefault ? '' : `<span class="del-cat" data-cat="${cat}">✕</span>`;
return `<button class="cat-btn ${active}" data-cat="${cat}">${cat} ${delBtn}</button>`;
}).join('');
container.innerHTML = html;
// 事件绑定
container.querySelectorAll('.cat-btn').forEach(btn => {
btn.addEventListener('click', function(e) {
if (e.target.classList.contains('del-cat')) return;
const cat = this.dataset.cat;
if (cat && cat !== currentCategory) {
currentCategory = cat;
currentOffset = 0;
render();
}
});
// 删除
const del = btn.querySelector('.del-cat');
if (del) {
del.addEventListener('click', function(e) {
e.stopPropagation();
const cat = this.dataset.cat;
if (confirm(`确定要删除分类「${cat}」及其所有打卡数据吗?`)) {
delete appData.records[cat];
appData.categories = appData.categories.filter(c => c !== cat);
if (currentCategory === cat) {
currentCategory = appData.categories[0] || '心理';
}
saveData(appData);
render();
showToast('🗑️ 已删除「' + cat + '」');
}
});
}
});
}
function renderGrid() {
const grid = document.getElementById('dayGrid');
const catName = document.getElementById('currentCatName');
catName.textContent = currentCategory;
if (currentView === 'week') {
renderWeekView(grid);
} else {
renderMonthView(grid);
}
}
function renderWeekView(grid) {
const today = new Date();
const baseDate = new Date(today);
baseDate.setDate(baseDate.getDate() + currentOffset * 7);
const weekStart = getWeekStart(baseDate);
const weekEnd = getWeekEnd(weekStart);
let html = '';
const days = [];
for (let i = 0; i < 7; i++) {
const d = new Date(weekStart);
d.setDate(d.getDate() + i);
days.push(d);
}
const records = appData.records[currentCategory] || {};
days.forEach(d => {
const dateStr = formatDate(d);
const val = records[dateStr] || 0;
const isTodayFlag = isToday(dateStr);
const hasRecord = val > 0;
const dayNum = d.getDate();
const isWeekend = d.getDay() === 0 || d.getDay() === 6;
html += `
<div class="day-card ${isTodayFlag ? 'today' : ''} ${hasRecord ? 'has-record' : ''}"
data-date="${dateStr}"
data-value="${val}"
onclick="openModal('${dateStr}')">
<div class="day-num">${dayNum}</div>
<div class="day-val ${val === 0 ? 'zero' : ''}">${val === 0 ? '-' : val}</div>
<div class="day-unit">${val > 0 ? 'h' : ''}</div>
${isTodayFlag ? '<div class="kitty-paw">🐱</div>' : ''}
</div>
`;
});
grid.innerHTML = html;
grid.classList.remove('month-grid');
}
function renderMonthView(grid) {
const today = new Date();
const baseDate = new Date(today.getFullYear(), today.getMonth(), 1);
baseDate.setMonth(baseDate.getMonth() + currentOffset);
const year = baseDate.getFullYear();
const month = baseDate.getMonth();
const daysInMonth = getMonthDays(year, month);
const firstDay = getFirstDayOfMonth(year, month); // 0=周日
const records = appData.records[currentCategory] || {};
let html = '';
// 补齐前面的空格(周日为0,周一为1... 我们要从周一开始,所以偏移)
// 如果 firstDay = 0 (周日),前面补6格;否则补 firstDay - 1 格
let leadingEmpty = firstDay === 0 ? 6 : firstDay - 1;
for (let i = 0; i < leadingEmpty; i++) {
html += `<div class="day-card" style="background:transparent;border-color:transparent;cursor:default;min-height:44px;"></div>`;
}
for (let d = 1; d <= daysInMonth; d++) {
const dateStr = getDateStr(year, month, d);
const val = records[dateStr] || 0;
const isTodayFlag = isToday(dateStr);
const hasRecord = val > 0;
const isWeekend = (new Date(year, month, d).getDay() === 0 || new Date(year, month, d).getDay() === 6);
html += `
<div class="day-card ${isTodayFlag ? 'today' : ''} ${hasRecord ? 'has-record' : ''}"
data-date="${dateStr}"
data-value="${val}"
onclick="openModal('${dateStr}')">
<div class="day-num">${d}</div>
<div class="day-val ${val === 0 ? 'zero' : ''}">${val === 0 ? '-' : val}</div>
${val > 0 ? '<div class="day-unit">h</div>' : ''}
${isTodayFlag ? '<div class="kitty-paw">🐱</div>' : ''}
</div>
`;
}
grid.innerHTML = html;
grid.classList.add('month-grid');
}
function updateStats() {
const records = appData.records[currentCategory] || {};
let total = 0;
let days = 0;
let maxVal = 0;
let dateCount = 0;
// 获取当前视图的日期范围
const today = new Date();
let startDate, endDate;
if (currentView === 'week') {
const baseDate = new Date(today);
baseDate.setDate(baseDate.getDate() + currentOffset * 7);
const weekStart = getWeekStart(baseDate);
const weekEnd = getWeekEnd(weekStart);
startDate = weekStart;
endDate = weekEnd;
} else {
const baseDate = new Date(today.getFullYear(), today.getMonth(), 1);
baseDate.setMonth(baseDate.getMonth() + currentOffset);
startDate = new Date(baseDate.getFullYear(), baseDate.getMonth(), 1);
endDate = new Date(baseDate.getFullYear(), baseDate.getMonth() + 1, 0);
}
// 遍历日期范围内的记录
const current = new Date(startDate);
while (current <= endDate) {
const dateStr = formatDate(current);
const val = records[dateStr] || 0;
if (val > 0) {
total += val;
days++;
dateCount++;
if (val > maxVal) maxVal = val;
}
current.setDate(current.getDate() + 1);
}
const avg = days > 0 ? (total / days) : 0;
document.getElementById('statTotal').textContent = total.toFixed(1);
document.getElementById('statDays').textContent = days;
document.getElementById('statAvg').textContent = avg.toFixed(1);
document.getElementById('statMax').textContent = maxVal.toFixed(1);
}
function updateNavLabel() {
const label = document.getElementById('navLabel');
const today = new Date();
if (currentView === 'week') {
const baseDate = new Date(today);
baseDate.setDate(baseDate.getDate() + currentOffset * 7);
const weekStart = getWeekStart(baseDate);
const weekNum = getWeekNumber(weekStart);
const range = getWeekRangeLabel(weekStart);
label.innerHTML = `${weekStart.getFullYear()}年 第${weekNum}周 <span class="sub-text">${range}</span>`;
} else {
const baseDate = new Date(today.getFullYear(), today.getMonth(), 1);
baseDate.setMonth(baseDate.getMonth() + currentOffset);
const year = baseDate.getFullYear();
const month = baseDate.getMonth() + 1;
const daysInMonth = getMonthDays(baseDate.getFullYear(), baseDate.getMonth());
label.innerHTML = `${year}年 ${month}月 <span class="sub-text">共${daysInMonth}天</span>`;
}
}
// ============================================================
// 5. 模态框
// ============================================================
window.openModal = function(dateStr) {
modalTarget = {
category: currentCategory,
date: dateStr
};
const records = appData.records[currentCategory] || {};
const val = records[dateStr] || 0;
document.getElementById('modalCatBadge').textContent = currentCategory;
document.getElementById('modalDate').textContent = getChineseDate(parseDate(dateStr));
document.getElementById('modalInput').value = val;
document.getElementById('modalOverlay').classList.add('open');
setTimeout(() => {
document.getElementById('modalInput').focus();
document.getElementById('modalInput').select();
}, 100);
};
function closeModal() {
document.getElementById('modalOverlay').classList.remove('open');
modalTarget = null;
}
function saveModal() {
if (!modalTarget) return;
const input = document.getElementById('modalInput');
let val = parseFloat(input.value);
if (isNaN(val) || val < 0) val = 0;
if (val > 24) val = 24;
const category = modalTarget.category;
const dateStr = modalTarget.date;
if (!appData.records[category]) appData.records[category] = {};
if (val === 0) {
delete appData.records[category][dateStr];
} else {
appData.records[category][dateStr] = val;
}
saveData(appData);
closeModal();
render();
showToast(`✅ ${category} · ${dateStr} 已记录 ${val > 0 ? val + 'h' : '已清除'}`);
}
function deleteModalRecord() {
if (!modalTarget) return;
const category = modalTarget.category;
const dateStr = modalTarget.date;
if (appData.records[category] && appData.records[category][dateStr]) {
delete appData.records[category][dateStr];
saveData(appData);
closeModal();
render();
showToast(`🗑️ 已清除 ${dateStr} 的记录`);
} else {
closeModal();
}
}
// 模态框事件绑定
document.getElementById('modalOverlay').addEventListener('click', function(e) {
if (e.target === this) closeModal();
});
document.getElementById('modalCancel').addEventListener('click', closeModal);
document.getElementById('modalSave').addEventListener('click', saveModal);
document.getElementById('modalDelete').addEventListener('click', deleteModalRecord);
document.getElementById('modalInput').addEventListener('keydown', function(e) {
if (e.key === 'Enter') saveModal();
if (e.key === 'Escape') closeModal();
});
// ============================================================
// 6. 视图切换 & 导航
// ============================================================
document.getElementById('viewWeek').addEventListener('click', function() {
if (currentView === 'week') return;
currentView = 'week';
currentOffset = 0;
document.getElementById('viewWeek').classList.add('active');
document.getElementById('viewMonth').classList.remove('active');
render();
});
document.getElementById('viewMonth').addEventListener('click', function() {
if (currentView === 'month') return;
currentView = 'month';
currentOffset = 0;
document.getElementById('viewMonth').classList.add('active');
document.getElementById('viewWeek').classList.remove('active');
render();
});
document.getElementById('navPrev').addEventListener('click', function() {
if (currentView === 'week') {
currentOffset--;
} else {
currentOffset--;
}
render();
});
document.getElementById('navNext').addEventListener('click', function() {
if (currentView === 'week') {
currentOffset++;
} else {
currentOffset++;
}
render();
});
// ============================================================
// 7. 分类管理
// ============================================================
document.getElementById('addCatBtn').addEventListener('click', function() {
const group = document.getElementById('addCatInputGroup');
const input = document.getElementById('newCatInput');
if (group.classList.contains('open')) {
group.classList.remove('open');
input.value = '';
return;
}
group.classList.add('open');
setTimeout(() => input.focus(), 50);
});
document.getElementById('addCatCancel').addEventListener('click', function() {
document.getElementById('addCatInputGroup').classList.remove('open');
document.getElementById('newCatInput').value = '';
});
document.getElementById('addCatConfirm').addEventListener('click', function() {
const input = document.getElementById('newCatInput');
const name = input.value.trim();
if (!name) {
showToast('⚠️ 请输入分类名称');
return;
}
if (appData.categories.includes(name)) {
showToast('⚠️ 分类已存在');
return;
}
appData.categories.push(name);
appData.records[name] = {};
saveData(appData);
currentCategory = name;
currentOffset = 0;
document.getElementById('addCatInputGroup').classList.remove('open');
input.value = '';
render();
showToast('✅ 已新增分类「' + name + '」');
});
document.getElementById('newCatInput').addEventListener('keydown', function(e) {
if (e.key === 'Enter') document.getElementById('addCatConfirm').click();
if (e.key === 'Escape') document.getElementById('addCatCancel').click();
});
// ============================================================
// 8. 导出 / 导入 / 重置
// ============================================================
document.getElementById('exportBtn').addEventListener('click', function() {
try {
const json = JSON.stringify(appData, null, 2);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `kitty打卡_${new Date().toISOString().slice(0,10)}.json`;
a.click();
URL.revokeObjectURL(url);
showToast('📤 导出成功');
} catch (e) {
showToast('❌ 导出失败');
}
});
document.getElementById('importBtn').addEventListener('click', function() {
document.getElementById('importFile').click();
});
document.getElementById('importFile').addEventListener('change', function(e) {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = function(ev) {
try {
const imported = JSON.parse(ev.target.result);
if (!imported.categories || !Array.isArray(imported.categories) || !imported.records) {
showToast('❌ 文件格式不匹配');
return;
}
// 确保所有分类都有记录对象
imported.categories.forEach(cat => {
if (!imported.records[cat]) imported.records[cat] = {};
});
appData = imported;
if (!appData.categories.includes(currentCategory)) {
currentCategory = appData.categories[0] || '心理';
}
saveData(appData);
render();
showToast('✅ 导入成功');
} catch (err) {
showToast('❌ 文件解析失败');
}
};
reader.readAsText(file);
this.value = '';
});
document.getElementById('resetBtn').addEventListener('click', function() {
if (!confirm('⚠️ 确定要重置所有数据吗?此操作不可恢复!')) return;
if (!confirm('🐱 再确认一次:所有打卡数据将被清除!')) return;
appData = getDefaultData();
appData.categories.forEach(cat => { appData.records[cat] = {}; });
currentCategory = appData.categories[0] || '心理';
currentOffset = 0;
saveData(appData);
render();
showToast('🔄 已重置所有数据');
});
// ============================================================
// 9. Toast
// ============================================================
function showToast(msg) {
const el = document.getElementById('toast');
el.textContent = msg;
el.classList.add('show');
clearTimeout(el._timer);
el._timer = setTimeout(() => el.classList.remove('show'), 2200);
}
// 暴露 showToast 给全局
window.showToast = showToast;
// ============================================================
// 10. 启动
// ============================================================
// 确保当前分类存在
if (!appData.categories.includes(currentCategory)) {
currentCategory = appData.categories[0] || '心理';
}
render();
// 窗口关闭前保存
window.addEventListener('beforeunload', function() {
saveData(appData);
});
})();
</script>
</body>
</html>