fa
Some checks failed
CI / lint (push) Has been cancelled
CI / build (push) Has been cancelled

This commit is contained in:
2026-02-26 21:29:54 +08:00
commit 2c4549ad79
85 changed files with 14664 additions and 0 deletions

View File

@@ -0,0 +1,98 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { motion } from 'framer-motion';
import { ArrowLeft, BarChart3, Info } from 'lucide-react';
import { Todo } from '@/lib/types';
import { useSettings } from '@/contexts/SettingsContext';
import { translations } from '@/lib/translations';
import dynamic from 'next/dynamic';
// 动态导入图表组件以支持 SSR
const AnalyticsDashboard = dynamic(() => import('@/components/AnalyticsDashboard'), {
loading: () => {
const { settings } = useSettings();
const t = translations[settings.language];
return (
<div className="h-96 flex flex-col items-center justify-center gap-4">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500" />
<p className="text-slate-400 font-bold uppercase tracking-widest text-xs">{t.analyticsLoading}</p>
</div>
);
},
ssr: false
});
export default function AnalyticsPage() {
const router = useRouter();
const { settings } = useSettings();
const t = translations[settings.language];
const [todos, setTodos] = useState<Todo[]>([]);
const [siteStats, setSiteStats] = useState({ pv: 0, uv: 0 });
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
async function fetchData() {
try {
const [todosRes, statsRes] = await Promise.all([
fetch('/api/todos'),
fetch('/api/stats')
]);
const todosData = await todosRes.json();
const statsData = await statsRes.json();
setTodos(todosData);
setSiteStats(statsData);
} catch (error) {
console.error('Failed to fetch data:', error);
} finally {
setIsLoading(false);
}
}
fetchData();
}, []);
return (
<main className="min-h-screen bg-light-primary selection:bg-blue-500/30">
<div className="max-w-5xl mx-auto px-4 sm:px-6 py-12 sm:py-20">
{/* Header Pro Max */}
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
className="flex items-center justify-between mb-12 sm:mb-16"
>
<div className="flex items-center gap-6">
<button
onClick={() => router.push('/')}
className="p-4 bg-white dark:bg-slate-800 rounded-2xl shadow-lg border border-slate-200 dark:border-slate-700 hover:scale-110 active:scale-95 transition-all cursor-pointer group"
>
<ArrowLeft size={24} className="group-hover:-translate-x-1 transition-transform" />
</button>
<div>
<h1 className="text-4xl sm:text-5xl font-black tracking-tighter text-slate-900 dark:text-white uppercase italic flex items-center gap-4">
<BarChart3 className="text-blue-500" size={40} strokeWidth={3} />
{t.insights}
</h1>
<div className="h-1 w-12 bg-blue-500 rounded-full mt-2" />
</div>
</div>
<div className="hidden sm:flex items-center gap-3 px-4 py-2 rounded-2xl bg-blue-500/10 border border-blue-500/20 text-blue-600 dark:text-blue-400">
<Info size={16} strokeWidth={3} />
<span className="text-[10px] font-black uppercase tracking-widest">{t.liveDataEngine}</span>
</div>
</motion.div>
{isLoading ? (
<div className="h-96 flex flex-col items-center justify-center gap-4">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500" />
<p className="text-slate-400 font-bold uppercase tracking-widest text-xs">{t.aggregatingRecords}</p>
</div>
) : (
<AnalyticsDashboard todos={todos} siteStats={siteStats} />
)}
</div>
</main>
);
}

362
src/app/api-docs/page.tsx Normal file
View File

@@ -0,0 +1,362 @@
'use client';
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { useRouter } from 'next/navigation';
import {
ArrowLeft, Copy, Check, Lock, Unlock, Send,
FileJson, Code2, Zap, Shield, Clock, Terminal
} from 'lucide-react';
import { useSettings } from '@/contexts/SettingsContext';
import { translations } from '@/lib/translations';
interface Endpoint {
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
path: string;
description: string;
descriptionZh: string;
auth: boolean;
requestBody?: {
type: string;
properties: { name: string; type: string; required: boolean; description: string }[];
};
responseExample: object;
curlExample: string;
}
const endpoints: Endpoint[] = [
{
method: 'GET',
path: '/api/todos',
description: 'Get all active todos',
descriptionZh: '获取所有活跃的任务',
auth: false,
responseExample: [
{ id: 'uuid', text: 'Task content', completed: false, createdAt: 1706000000000 }
],
curlExample: 'curl https://your-domain.com/api/todos',
},
{
method: 'POST',
path: '/api/todos',
description: 'Create a new todo',
descriptionZh: '创建新任务',
auth: true,
requestBody: {
type: 'application/json',
properties: [
{ name: 'text', type: 'string', required: true, description: 'Task content' },
{ name: 'createdAt', type: 'number', required: false, description: 'Custom timestamp (ms)' },
],
},
responseExample: { id: 'uuid', text: 'New task', completed: false, createdAt: 1706000000000 },
curlExample: `curl -X POST https://your-domain.com/api/todos \\
-H "Content-Type: application/json" \\
-H "X-API-Key: your-password" \\
-d '{"text": "New task"}'`,
},
{
method: 'PUT',
path: '/api/todos',
description: 'Update a todo',
descriptionZh: '更新任务',
auth: true,
requestBody: {
type: 'application/json',
properties: [
{ name: 'id', type: 'string', required: true, description: 'Todo ID' },
{ name: 'text', type: 'string', required: false, description: 'New content' },
{ name: 'completed', type: 'boolean', required: false, description: 'Completion status' },
{ name: 'createdAt', type: 'number', required: false, description: 'Override created time' },
{ name: 'completedAt', type: 'number', required: false, description: 'Custom completion time' },
],
},
responseExample: { id: 'uuid', text: 'Updated', completed: true, createdAt: 1706000000000, completedAt: 1706100000000 },
curlExample: `curl -X PUT https://your-domain.com/api/todos \\
-H "Content-Type: application/json" \\
-H "X-API-Key: your-password" \\
-d '{"id": "todo-id", "completed": true}'`,
},
{
method: 'DELETE',
path: '/api/todos',
description: 'Soft delete a todo',
descriptionZh: '删除任务(软删除)',
auth: true,
responseExample: { success: true, id: 'deleted-id' },
curlExample: `curl -X DELETE "https://your-domain.com/api/todos?id=todo-id" \\
-H "X-API-Key: your-password"`,
},
{
method: 'GET',
path: '/api/stats',
description: 'Get site statistics (PV/UV)',
descriptionZh: '获取站点统计PV/UV',
auth: false,
responseExample: { pv: 1234, uv: 56 },
curlExample: 'curl https://your-domain.com/api/stats',
},
{
method: 'POST',
path: '/api/auth',
description: 'Verify password',
descriptionZh: '验证密码',
auth: false,
requestBody: {
type: 'application/json',
properties: [
{ name: 'password', type: 'string', required: true, description: 'Password to verify' },
],
},
responseExample: { success: true },
curlExample: `curl -X POST https://your-domain.com/api/auth \\
-H "Content-Type: application/json" \\
-d '{"password": "your-password"}'`,
},
];
const methodColors: Record<string, { bg: string; text: string; border: string }> = {
GET: { bg: 'bg-emerald-500/10', text: 'text-emerald-500', border: 'border-emerald-500/30' },
POST: { bg: 'bg-blue-500/10', text: 'text-blue-500', border: 'border-blue-500/30' },
PUT: { bg: 'bg-amber-500/10', text: 'text-amber-500', border: 'border-amber-500/30' },
DELETE: { bg: 'bg-red-500/10', text: 'text-red-500', border: 'border-red-500/30' },
};
export default function ApiDocsPage() {
const router = useRouter();
const { settings } = useSettings();
const t = translations[settings.language];
const [expandedIndex, setExpandedIndex] = useState<number | null>(null);
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
const copyToClipboard = async (text: string, index: number) => {
await navigator.clipboard.writeText(text);
setCopiedIndex(index);
setTimeout(() => setCopiedIndex(null), 2000);
};
return (
<main className="min-h-screen bg-light-primary selection:bg-blue-500/30">
<div className="max-w-5xl mx-auto px-4 sm:px-6 py-12 sm:py-20">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="flex items-center gap-6 mb-12"
>
<button
onClick={() => router.push('/')}
className="p-4 bg-white dark:bg-slate-800 rounded-2xl shadow-lg border border-slate-200 dark:border-slate-700 hover:scale-110 active:scale-95 transition-all cursor-pointer group"
>
<ArrowLeft size={24} className="group-hover:-translate-x-1 transition-transform" />
</button>
<div className="flex-1">
<div className="flex items-center gap-3">
<div className="p-2 bg-gradient-to-br from-blue-500 to-purple-600 rounded-xl">
<Code2 size={24} className="text-white" />
</div>
<h1 className="text-3xl sm:text-4xl font-black tracking-tighter text-slate-900 dark:text-white">
API {settings.language === 'zh' ? '文档' : 'Documentation'}
</h1>
</div>
<p className="text-sm text-slate-500 mt-2 font-medium">
{settings.language === 'zh'
? 'RESTful API 接口说明 · 支持自定义时间戳'
: 'RESTful API Reference · Custom Timestamps Supported'}
</p>
</div>
</motion.div>
{/* Auth Info Card */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="glass-card p-6 rounded-3xl mb-8 border-l-4 border-amber-500"
>
<div className="flex items-start gap-4">
<div className="p-3 bg-amber-500/10 rounded-2xl">
<Shield size={24} className="text-amber-500" />
</div>
<div>
<h3 className="font-bold text-slate-900 dark:text-white mb-1">
{settings.language === 'zh' ? '认证方式' : 'Authentication'}
</h3>
<p className="text-sm text-slate-500 mb-3">
{settings.language === 'zh'
? '需要认证的接口请在请求头中添加 API Key'
: 'For protected endpoints, add API key to request headers:'}
</p>
<div className="flex flex-wrap gap-2">
<code className="px-3 py-1.5 bg-slate-100 dark:bg-slate-800 rounded-lg text-xs font-mono text-slate-700 dark:text-slate-300">
X-API-Key: your-password
</code>
<span className="text-slate-400 self-center">{settings.language === 'zh' ? '或' : 'or'}</span>
<code className="px-3 py-1.5 bg-slate-100 dark:bg-slate-800 rounded-lg text-xs font-mono text-slate-700 dark:text-slate-300">
Authorization: Bearer your-password
</code>
</div>
</div>
</div>
</motion.div>
{/* Endpoints List */}
<div className="space-y-4">
{endpoints.map((endpoint, index) => {
const colors = methodColors[endpoint.method];
const isExpanded = expandedIndex === index;
return (
<motion.div
key={`${endpoint.method}-${endpoint.path}`}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 + index * 0.05 }}
className="glass-card rounded-3xl overflow-hidden"
>
{/* Endpoint Header */}
<button
onClick={() => setExpandedIndex(isExpanded ? null : index)}
className="w-full p-5 flex items-center gap-4 cursor-pointer hover:bg-slate-50/50 dark:hover:bg-slate-800/30 transition-colors"
>
<span className={`px-3 py-1.5 rounded-lg text-xs font-black ${colors.bg} ${colors.text} border ${colors.border}`}>
{endpoint.method}
</span>
<code className="font-mono text-sm text-slate-700 dark:text-slate-300 flex-1 text-left">
{endpoint.path}
</code>
{endpoint.auth ? (
<Lock size={16} className="text-amber-500" />
) : (
<Unlock size={16} className="text-emerald-500" />
)}
<span className="text-sm text-slate-500 hidden sm:block">
{settings.language === 'zh' ? endpoint.descriptionZh : endpoint.description}
</span>
<motion.div
animate={{ rotate: isExpanded ? 180 : 0 }}
transition={{ duration: 0.2 }}
>
<Zap size={18} className={`transition-colors ${isExpanded ? 'text-blue-500' : 'text-slate-400'}`} />
</motion.div>
</button>
{/* Expanded Content */}
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.3 }}
className="border-t border-slate-200 dark:border-slate-700/50"
>
<div className="p-5 space-y-5">
{/* Description on mobile */}
<p className="text-sm text-slate-600 dark:text-slate-400 sm:hidden">
{settings.language === 'zh' ? endpoint.descriptionZh : endpoint.description}
</p>
{/* Request Body */}
{endpoint.requestBody && (
<div>
<h4 className="text-xs font-black uppercase tracking-widest text-slate-400 mb-3 flex items-center gap-2">
<FileJson size={14} />
Request Body
</h4>
<div className="bg-slate-50 dark:bg-slate-900/50 rounded-2xl p-4 overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs uppercase tracking-wider text-slate-400">
<th className="pb-2 pr-4">Field</th>
<th className="pb-2 pr-4">Type</th>
<th className="pb-2 pr-4">Required</th>
<th className="pb-2">Description</th>
</tr>
</thead>
<tbody className="font-mono">
{endpoint.requestBody.properties.map((prop) => (
<tr key={prop.name} className="border-t border-slate-200 dark:border-slate-700/50">
<td className="py-2 pr-4 text-blue-500">{prop.name}</td>
<td className="py-2 pr-4 text-emerald-500">{prop.type}</td>
<td className="py-2 pr-4">
{prop.required ? (
<span className="text-red-400">*</span>
) : (
<span className="text-slate-400">-</span>
)}
</td>
<td className="py-2 text-slate-500 font-sans text-xs">{prop.description}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Response Example */}
<div>
<h4 className="text-xs font-black uppercase tracking-widest text-slate-400 mb-3 flex items-center gap-2">
<Send size={14} />
Response Example
</h4>
<pre className="bg-slate-900 text-slate-100 rounded-2xl p-4 overflow-x-auto text-xs font-mono">
{JSON.stringify(endpoint.responseExample, null, 2)}
</pre>
</div>
{/* cURL Example */}
<div>
<div className="flex items-center justify-between mb-3">
<h4 className="text-xs font-black uppercase tracking-widest text-slate-400 flex items-center gap-2">
<Terminal size={14} />
cURL Example
</h4>
<button
onClick={() => copyToClipboard(endpoint.curlExample, index)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-slate-100 dark:bg-slate-800 text-slate-500 hover:text-slate-900 dark:hover:text-white transition-colors cursor-pointer text-xs font-bold"
>
{copiedIndex === index ? (
<>
<Check size={14} className="text-emerald-500" />
Copied!
</>
) : (
<>
<Copy size={14} />
Copy
</>
)}
</button>
</div>
<pre className="bg-slate-900 text-emerald-400 rounded-2xl p-4 overflow-x-auto text-xs font-mono whitespace-pre-wrap">
{endpoint.curlExample}
</pre>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
})}
</div>
{/* Footer */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.5 }}
className="mt-12 text-center"
>
<p className="text-xs text-slate-400 font-medium">
{settings.language === 'zh'
? '基于 Next.js API Routes 构建 · 数据持久化于 JSON'
: 'Built with Next.js API Routes · Data persisted in JSON'}
</p>
</motion.div>
</div>
</main>
);
}

37
src/app/api/auth/route.ts Normal file
View File

@@ -0,0 +1,37 @@
import { NextResponse } from 'next/server';
// 预设密码,可通过环境变量 AUTH_PASSWORD 配置
// 默认密码: stark123
const AUTH_PASSWORD = process.env.AUTH_PASSWORD || 'stark123';
export async function POST(request: Request) {
try {
const { password } = await request.json();
if (!password) {
return NextResponse.json(
{ success: false, message: 'Password is required' },
{ status: 400 }
);
}
const isValid = password === AUTH_PASSWORD;
if (isValid) {
console.log('[Auth] User authenticated successfully');
return NextResponse.json({ success: true });
} else {
console.log('[Auth] Invalid password attempt');
return NextResponse.json(
{ success: false, message: 'Invalid password' },
{ status: 401 }
);
}
} catch (error) {
console.error('[Auth] Error:', error);
return NextResponse.json(
{ success: false, message: 'Server error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,82 @@
import { NextResponse } from 'next/server';
import { getGroups, saveGroups, Group, getTodos, saveTodos } from '@/lib/storage';
const AUTH_PASSWORD = process.env.AUTH_PASSWORD || 'stark123';
function verifyApiKey(request: Request): boolean {
const apiKey = request.headers.get('X-API-Key') || request.headers.get('Authorization')?.replace('Bearer ', '');
return apiKey === AUTH_PASSWORD;
}
function unauthorizedResponse() {
return NextResponse.json(
{
error: 'Unauthorized',
message: 'Valid API key required.'
},
{ status: 401 }
);
}
export async function GET() {
try {
const groups = getGroups();
return NextResponse.json(groups);
} catch (error) {
return NextResponse.json({ error: 'Failed to fetch groups' }, { status: 500 });
}
}
export async function POST(request: Request) {
try {
if (!verifyApiKey(request)) return unauthorizedResponse();
const { name } = await request.json();
if (!name) return NextResponse.json({ error: 'Name is required' }, { status: 400 });
const groups = getGroups();
const newGroup: Group = {
id: crypto.randomUUID(),
name,
createdAt: Date.now(),
};
groups.push(newGroup);
saveGroups(groups);
return NextResponse.json(newGroup, { status: 201 });
} catch (error) {
return NextResponse.json({ error: 'Failed to create group' }, { status: 500 });
}
}
export async function DELETE(request: Request) {
try {
if (!verifyApiKey(request)) return unauthorizedResponse();
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
if (!id || id === 'default') {
return NextResponse.json({ error: 'Valid ID is required' }, { status: 400 });
}
const groups = getGroups();
const filteredGroups = groups.filter((g) => g.id !== id);
saveGroups(filteredGroups);
// 迁移该分组下的任务到默认分组
const todos = getTodos();
const updatedTodos = todos.map(todo => {
if (todo.groupId === id) {
return { ...todo, groupId: 'default' };
}
return todo;
});
saveTodos(updatedTodos);
return NextResponse.json({ success: true, id });
} catch (error) {
return NextResponse.json({ error: 'Failed to delete group' }, { status: 500 });
}
}

View File

@@ -0,0 +1,34 @@
import { NextResponse } from 'next/server';
import { getStats, updateStats } from '@/lib/storage';
export async function GET() {
try {
const stats = getStats();
return NextResponse.json(stats);
} catch (error) {
console.error('[API Stats GET] Error:', error);
return NextResponse.json({ error: 'Failed to fetch stats' }, { status: 500 });
}
}
export async function POST(request: Request) {
try {
let isNewVisitor = false;
const rawBody = await request.text();
if (rawBody.trim()) {
try {
const body = JSON.parse(rawBody) as { isNewVisitor?: unknown };
isNewVisitor = !!body.isNewVisitor;
} catch {
// Ignore invalid JSON and fall back to default behavior.
}
}
const stats = updateStats(isNewVisitor);
return NextResponse.json(stats);
} catch (error) {
console.error('[API Stats POST] Error:', error);
return NextResponse.json({ error: 'Failed to update stats' }, { status: 500 });
}
}

237
src/app/api/todos/route.ts Normal file
View File

@@ -0,0 +1,237 @@
import { NextResponse } from 'next/server';
import { getTodos, saveTodos, Todo, checkRateLimit, recordTaskAdd } from '@/lib/storage';
import headers from 'next/headers';
// API 密码验证
const AUTH_PASSWORD = process.env.AUTH_PASSWORD || 'stark123';
function verifyApiKey(request: Request): boolean {
const apiKey = request.headers.get('X-API-Key') || request.headers.get('Authorization')?.replace('Bearer ', '');
return apiKey === AUTH_PASSWORD;
}
function unauthorizedResponse() {
return NextResponse.json(
{
error: 'Unauthorized',
message: 'Valid API key required. Use header: X-API-Key: <password> or Authorization: Bearer <password>'
},
{ status: 401 }
);
}
/**
* 获取客户端标识符
* 优先使用 X-Forwarded-For代理/负载均衡器),其次使用 X-Real-IP最后使用生成的唯一ID
*/
function getClientId(request: Request): string {
// 尝试从请求头获取真实IP
const forwardedFor = request.headers.get('X-Forwarded-For');
const realIp = request.headers.get('X-Real-IP');
const cfConnectingIp = request.headers.get('CF-Connecting-Ip'); // Cloudflare
if (forwardedFor) {
// X-Forwarded-For 可能包含多个IP取第一个
return forwardedFor.split(',')[0].trim();
}
if (realIp) {
return realIp.trim();
}
if (cfConnectingIp) {
return cfConnectingIp.trim();
}
// 如果无法获取IP使用请求的Cookie或生成一个临时ID
// 对于匿名用户,会在前端生成并存储在 localStorage 和 Cookie 中
const anonymousId = request.headers.get('X-Anonymous-ID');
if (anonymousId) {
return `anon_${anonymousId}`;
}
// 如果都没有,使用一个通用标识(实际使用中不建议)
return 'unknown';
}
// GET - 获取所有任务(无需认证)
export async function GET() {
try {
const todos = getTodos();
// 只返回未删除的任务
const activeTodos = todos.filter(t => !t.deleted);
console.log(`[API GET] Returning ${activeTodos.length} active todos`);
return NextResponse.json(activeTodos);
} catch (error) {
console.error('[API GET] Error:', error);
return NextResponse.json({ error: 'Failed to fetch todos' }, { status: 500 });
}
}
// POST - 创建新任务(支持匿名用户,但有限流)
export async function POST(request: Request) {
try {
const { text, createdAt, groupId, priority } = await request.json();
if (!text) {
return NextResponse.json({ error: 'Text is required' }, { status: 400 });
}
const isAuthenticated = verifyApiKey(request);
// 如果未认证,应用限流检查
if (!isAuthenticated) {
const clientId = getClientId(request);
const rateLimitCheck = checkRateLimit(clientId);
if (!rateLimitCheck.canAdd) {
// 限流触发
if (rateLimitCheck.reason === 'RATE_LIMIT_INTERVAL') {
return NextResponse.json(
{
error: 'Rate Limit Exceeded',
message: `Please wait ${rateLimitCheck.retryAfter} seconds before adding another task`,
reason: 'INTERVAL',
retryAfter: rateLimitCheck.retryAfter,
dailyRemaining: rateLimitCheck.dailyRemaining,
},
{ status: 429 }
);
} else if (rateLimitCheck.reason === 'RATE_LIMIT_DAILY') {
return NextResponse.json(
{
error: 'Daily Limit Reached',
message: 'You have reached the maximum of 6 tasks for today. Please try again tomorrow.',
reason: 'DAILY',
dailyRemaining: 0,
},
{ status: 429 }
);
}
}
// 记录匿名用户添加任务
recordTaskAdd(clientId);
}
const todos = getTodos();
const newTodo: Todo = {
id: crypto.randomUUID(),
text,
completed: false,
createdAt: createdAt || Date.now(), // 支持自定义创建时间
groupId: groupId || 'default',
priority: priority || 'P2',
};
todos.push(newTodo);
saveTodos(todos);
const authStatus = isAuthenticated ? 'authenticated' : 'anonymous';
console.log(`[API POST] Created todo (${authStatus}): ${newTodo.id} - "${text}"`);
return NextResponse.json(newTodo, { status: 201 });
} catch (error) {
console.error('[API POST] Error:', error);
return NextResponse.json({ error: 'Failed to create todo' }, { status: 500 });
}
}
// PUT - 更新任务(需要认证)
export async function PUT(request: Request) {
try {
// 验证 API Key
if (!verifyApiKey(request)) {
return unauthorizedResponse();
}
const { id, completed, text, createdAt, completedAt, groupId, priority } = await request.json();
if (!id) {
return NextResponse.json({ error: 'ID is required' }, { status: 400 });
}
const todos = getTodos();
const index = todos.findIndex((t) => t.id === id);
if (index === -1) {
console.warn(`[API PUT] Todo not found: ${id}`);
return NextResponse.json({ error: 'Todo not found' }, { status: 404 });
}
// 更新文本
if (text !== undefined) {
todos[index].text = text;
}
// 更新创建时间(可选)
if (createdAt !== undefined) {
todos[index].createdAt = createdAt;
}
// 更新完成状态
if (completed !== undefined) {
todos[index].completed = completed;
if (completed) {
// 支持自定义完成时间,否则使用当前时间
todos[index].completedAt = completedAt || Date.now();
} else {
delete todos[index].completedAt;
}
} else if (completedAt !== undefined && todos[index].completed) {
// 仅更新完成时间
todos[index].completedAt = completedAt;
}
// 更新分组和优先级
if (groupId !== undefined) {
todos[index].groupId = groupId;
}
if (priority !== undefined) {
todos[index].priority = priority;
}
saveTodos(todos);
console.log(`[API PUT] Updated todo: ${id}`);
return NextResponse.json(todos[index]);
} catch (error) {
console.error('[API PUT] Error:', error);
return NextResponse.json({ error: 'Failed to update todo' }, { status: 500 });
}
}
// DELETE - 删除任务(需要认证)
export async function DELETE(request: Request) {
try {
// 验证 API Key
if (!verifyApiKey(request)) {
return unauthorizedResponse();
}
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
if (!id) {
return NextResponse.json({ error: 'ID is required' }, { status: 400 });
}
const todos = getTodos();
const index = todos.findIndex((t) => t.id === id);
if (index === -1) {
console.warn(`[API DELETE] Todo not found: ${id}`);
return NextResponse.json({ error: 'Todo not found' }, { status: 404 });
}
// 执行逻辑删除
todos[index].deleted = true;
todos[index].deletedAt = Date.now();
saveTodos(todos);
console.log(`[API DELETE] Deleted todo: ${id}`);
return NextResponse.json({ success: true, id });
} catch (error) {
console.error('[API DELETE] Error:', error);
return NextResponse.json({ error: 'Failed to delete todo' }, { status: 500 });
}
}

85
src/app/globals.css Normal file
View File

@@ -0,0 +1,85 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Light Mode (Default) - Pro Max Refined */
:root {
--background: #ffffff;
--background-secondary: #f8fafc;
--foreground: #0f172a;
--foreground-muted: #475569;
--border: #e2e8f0;
--card-bg: rgba(255, 255, 255, 0.85);
--shadow: rgba(0, 0, 0, 0.03) 0px 2px 4px 0px, rgba(0, 0, 0, 0.05) 0px 1px 2px 0px;
--primary: #2563eb;
--primary-hover: #1d4ed8;
--accent: #f97316;
}
/* Dark Mode - Pro Max OLED Refined */
:root.dark {
--background: #020617;
--background-secondary: #0f172a;
--foreground: #f1f5f9;
--foreground-muted: #94a3b8;
--border: #1e293b;
--card-bg: rgba(15, 23, 42, 0.6);
--shadow: rgba(0, 0, 0, 0.5) 0px 4px 6px -1px, rgba(0, 0, 0, 0.3) 0px 2px 4px -1px;
--primary: #3b82f6;
--primary-hover: #60a5fa;
--accent: #fb923c;
}
body {
color: var(--foreground);
background: var(--background);
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
overflow-x: hidden;
letter-spacing: -0.011em; /* Pro Max Typography Refinement */
}
/* Glassmorphism Pro Max */
.glass-card {
@apply backdrop-blur-xl border border-white/20 dark:border-slate-700/50 shadow-lg;
background: var(--card-bg);
}
/* Professional Background Gradients */
.bg-light-primary {
background: radial-gradient(circle at 50% 0%, #f8fafc 0%, #ffffff 100%);
}
.dark .bg-light-primary {
background: radial-gradient(circle at 50% 0%, #0f172a 0%, #020617 100%);
}
/* Stable Hover Feedback */
.hover-lift {
@apply transition-all duration-300 ease-out hover:-translate-y-1 hover:shadow-xl active:translate-y-0 active:scale-95;
}
/* Custom scrollbar Pro Max */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
@apply bg-slate-200 dark:bg-slate-800 rounded-full hover:bg-slate-300 dark:hover:bg-slate-700 transition-colors;
}
/* Respect user's motion preferences */
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}

50
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,50 @@
import type { Metadata } from "next";
import "./globals.css";
import { SettingsProvider } from "@/contexts/SettingsContext";
import { AuthProvider } from "@/contexts/AuthContext";
import StatsTracker from "@/components/StatsTracker";
import AuthModal from "@/components/AuthModal";
export const metadata: Metadata = {
title: "蛋定-项目待办",
description: "现代化、高效的待办事项管理工具",
icons: {
icon: [
{ url: '/egg-icon.ico', sizes: 'any' },
],
apple: [
{ url: '/egg-icon.ico', sizes: 'any' },
],
},
manifest: '/manifest.json',
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="zh-CN" suppressHydrationWarning>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0, user-scalable=yes" />
<meta name="theme-color" content="#3b82f6" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap"
rel="stylesheet"
/>
</head>
<body className="antialiased" suppressHydrationWarning>
<SettingsProvider>
<AuthProvider>
<StatsTracker />
<AuthModal />
{children}
</AuthProvider>
</SettingsProvider>
</body>
</html>
);
}

1007
src/app/page.tsx Normal file

File diff suppressed because it is too large Load Diff

248
src/app/settings/page.tsx Normal file
View File

@@ -0,0 +1,248 @@
'use client';
import { motion, AnimatePresence } from 'framer-motion';
import { useRouter } from 'next/navigation';
import { useState, useEffect } from 'react';
import { ArrowLeft, Globe, Type, Clock, Sun, Moon, Monitor, Check, ShieldCheck, ShieldOff } from 'lucide-react';
import { useSettings } from '@/contexts/SettingsContext';
import { useAuth } from '@/contexts/AuthContext';
import { translations, Language } from '@/lib/translations';
import { TIMEZONES } from '@/lib/timezones';
export default function SettingsPage() {
const router = useRouter();
const { settings, updateSettings } = useSettings();
const { isAuthenticated, requestAuth } = useAuth();
const [tempLogoText, setTempLogoText] = useState(settings.logoText);
const [showSaved, setShowSaved] = useState(false);
const t = translations[settings.language];
// Update tempLogoText when settings.logoText changes
useEffect(() => {
setTempLogoText(settings.logoText);
}, [settings.logoText]);
const handleSave = () => {
// 检查权限
if (!isAuthenticated) {
requestAuth();
return;
}
updateSettings({ logoText: tempLogoText });
showSaveNotification();
};
const showSaveNotification = () => {
setShowSaved(true);
setTimeout(() => setShowSaved(false), 2000);
};
const handleLanguageChange = (lang: Language) => {
updateSettings({ language: lang });
showSaveNotification();
};
const handleTimezoneChange = (timezone: string) => {
// 检查权限
if (!isAuthenticated) {
requestAuth();
return;
}
updateSettings({ timezone });
showSaveNotification();
};
const handleThemeChange = (theme: 'light' | 'dark' | 'system') => {
updateSettings({ theme });
showSaveNotification();
};
return (
<main className="min-h-screen bg-light-primary selection:bg-blue-500/30">
<div className="max-w-2xl mx-auto px-4 sm:px-6 py-12 sm:py-20">
{/* Save Notification Pro Max */}
<AnimatePresence>
{showSaved && (
<motion.div
initial={{ opacity: 0, y: -20, scale: 0.9, filter: 'blur(10px)' }}
animate={{ opacity: 1, y: 0, scale: 1, filter: 'blur(0px)' }}
exit={{ opacity: 0, scale: 0.9, filter: 'blur(10px)' }}
className="fixed top-8 left-1/2 -translate-x-1/2 z-50 bg-slate-900 dark:bg-white text-white dark:text-slate-900 px-8 py-4 rounded-3xl shadow-2xl flex items-center gap-3 ring-1 ring-white/20"
>
<div className="bg-emerald-500 rounded-full p-1">
<Check className="text-white" size={16} strokeWidth={4} />
</div>
<span className="font-bold uppercase tracking-widest text-xs">
{t.settingsSaved}
</span>
</motion.div>
)}
</AnimatePresence>
{/* Header Pro Max */}
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
className="flex items-center gap-6 mb-12 sm:mb-16"
>
<button
onClick={() => router.push('/')}
className="p-4 bg-white dark:bg-slate-800 rounded-2xl shadow-lg border border-slate-200 dark:border-slate-700 hover:scale-110 active:scale-95 transition-all cursor-pointer group"
>
<ArrowLeft size={24} className="group-hover:-translate-x-1 transition-transform" />
</button>
<div className="flex-1">
<h1 className="text-4xl sm:text-5xl font-black tracking-tighter text-slate-900 dark:text-white uppercase italic">
{t.settings}
</h1>
<div className="h-1 w-12 bg-blue-500 rounded-full mt-2" />
</div>
{/* Auth Status */}
<button
onClick={requestAuth}
className={`p-3 rounded-2xl border shadow-lg transition-all cursor-pointer ${
isAuthenticated
? 'bg-emerald-500/10 dark:bg-emerald-500/20 border-emerald-300 dark:border-emerald-700'
: 'bg-amber-500/10 dark:bg-amber-500/20 border-amber-300 dark:border-amber-700'
}`}
title={isAuthenticated ? t.authenticated : t.authRequired}
>
{isAuthenticated ? (
<ShieldCheck size={22} className="text-emerald-600 dark:text-emerald-400" />
) : (
<ShieldOff size={22} className="text-amber-600 dark:text-amber-400" />
)}
</button>
</motion.div>
{/* Settings Sections Pro Max */}
<div className="space-y-6 sm:space-y-8">
{[
{
id: 'language',
icon: Globe,
title: t.language,
desc: t.languageDesc,
color: 'blue',
content: (
<div className="flex gap-3">
{(['zh', 'en'] as Language[]).map((lang) => (
<button
key={lang}
onClick={() => handleLanguageChange(lang)}
className={`flex-1 py-4 rounded-2xl font-black uppercase tracking-widest text-xs transition-all cursor-pointer ring-1 ring-inset ${
settings.language === lang
? 'bg-slate-900 dark:bg-white text-white dark:text-slate-900 shadow-xl ring-transparent'
: 'bg-white dark:bg-slate-800/50 text-slate-400 dark:text-slate-500 hover:text-slate-600 dark:hover:text-slate-300 ring-slate-200 dark:ring-slate-700'
}`}
>
{lang === 'zh' ? '中文' : 'English'}
</button>
))}
</div>
)
},
{
id: 'logo',
icon: Type,
title: t.logoCustomization,
desc: t.logoCustomizationDesc,
color: 'purple',
content: (
<div className="flex gap-3">
<input
type="text"
value={tempLogoText}
onChange={(e) => setTempLogoText(e.target.value.toUpperCase().slice(0, 10))}
placeholder={t.logoTextPlaceholder}
maxLength={10}
className="flex-1 bg-slate-100 dark:bg-slate-900/50 border-none rounded-2xl px-6 py-4 text-sm font-bold text-slate-900 dark:text-white focus:ring-2 focus:ring-blue-500 transition-all uppercase tracking-widest"
/>
<button
onClick={handleSave}
disabled={tempLogoText === settings.logoText || !tempLogoText.trim()}
className="px-8 bg-blue-600 hover:bg-blue-700 disabled:opacity-20 text-white rounded-2xl font-black uppercase tracking-widest text-xs transition-all cursor-pointer shadow-lg hover:shadow-xl active:scale-95"
>
{t.save}
</button>
</div>
)
},
{
id: 'timezone',
icon: Clock,
title: t.timezone,
desc: t.timezoneDesc,
color: 'emerald',
content: (
<select
value={settings.timezone}
onChange={(e) => handleTimezoneChange(e.target.value)}
className="w-full bg-slate-100 dark:bg-slate-900/50 border-none rounded-2xl px-6 py-4 text-sm font-bold text-slate-900 dark:text-white focus:ring-2 focus:ring-blue-500 transition-all cursor-pointer appearance-none"
>
{TIMEZONES.map((tz) => (
<option key={tz.value} value={tz.value}>
{tz.label}
</option>
))}
</select>
)
},
{
id: 'theme',
icon: Sun,
title: t.themeMode,
desc: t.themeModeDesc,
color: 'orange',
content: (
<div className="grid grid-cols-3 gap-3">
{[
{ value: 'light' as const, icon: Sun, label: t.themeLight },
{ value: 'dark' as const, icon: Moon, label: t.themeDark },
{ value: 'system' as const, icon: Monitor, label: t.themeSystem },
].map(({ value, icon: Icon, label }) => (
<button
key={value}
onClick={() => handleThemeChange(value)}
className={`flex flex-col items-center gap-3 p-5 rounded-2xl transition-all cursor-pointer ring-1 ring-inset ${
settings.theme === value
? 'bg-slate-900 dark:bg-white text-white dark:text-slate-900 shadow-xl ring-transparent'
: 'bg-white dark:bg-slate-800/50 text-slate-400 dark:text-slate-500 ring-slate-200 dark:ring-slate-700'
}`}
>
<Icon size={20} strokeWidth={2.5} />
<span className="text-[10px] font-black uppercase tracking-widest">{label}</span>
</button>
))}
</div>
)
}
].map((section, idx) => (
<motion.div
key={section.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: idx * 0.1 }}
className="glass-card p-6 sm:p-8 rounded-[2.5rem] ring-1 ring-black/5 dark:ring-white/5"
>
<div className="flex items-center gap-4 mb-6">
<div className={`p-3 bg-${section.color}-500/10 rounded-2xl`}>
<section.icon className={`text-${section.color}-500`} size={24} strokeWidth={2.5} />
</div>
<div>
<h3 className="text-sm font-black uppercase tracking-widest text-slate-900 dark:text-white">
{section.title}
</h3>
<p className="text-[10px] font-bold uppercase tracking-tight text-slate-400">
{section.desc}
</p>
</div>
</div>
{section.content}
</motion.div>
))}
</div>
</div>
</main>
);
}

View File

@@ -0,0 +1,260 @@
'use client';
import React, { useMemo, useState } from 'react';
import {
LineChart, Line, AreaChart, Area, XAxis, YAxis, CartesianGrid,
Tooltip, ResponsiveContainer, BarChart, Bar, Cell, PieChart, Pie
} from 'recharts';
import { motion } from 'framer-motion';
import { format, subDays, isWithinInterval, startOfDay, endOfDay, eachDayOfInterval } from 'date-fns';
import { enUS, zhCN } from 'date-fns/locale';
import { Todo } from '@/lib/types';
import { useSettings } from '@/contexts/SettingsContext';
import { translations } from '@/lib/translations';
import { Calendar, TrendingUp, CheckCircle2, Clock, Filter, ChevronLeft, ChevronRight, Eye, Users } from 'lucide-react';
interface AnalyticsDashboardProps {
todos: Todo[];
siteStats: { pv: number; uv: number };
}
type Range = '7d' | '30d' | 'all';
export default function AnalyticsDashboard({ todos, siteStats }: AnalyticsDashboardProps) {
const { settings } = useSettings();
const t = translations[settings.language];
const [range, setRange] = useState<Range>('7d');
const filteredData = useMemo(() => {
const now = new Date();
let startDate: Date;
if (range === '7d') startDate = subDays(now, 6);
else if (range === '30d') startDate = subDays(now, 29);
else return todos;
return todos.filter(todo =>
todo.createdAt >= startDate.getTime()
);
}, [todos, range]);
const dailyStats = useMemo(() => {
const now = new Date();
const days = range === '7d' ? 7 : range === '30d' ? 30 : 45;
const interval = {
start: startOfDay(subDays(now, days - 1)),
end: endOfDay(now)
};
const dayList = eachDayOfInterval(interval);
const locale = settings.language === 'zh' ? zhCN : enUS;
return dayList.map(day => {
const dayStart = startOfDay(day).getTime();
const dayEnd = endOfDay(day).getTime();
const createdCount = todos.filter(t => t.createdAt >= dayStart && t.createdAt <= dayEnd).length;
const completedCount = todos.filter(t => t.completedAt && t.completedAt >= dayStart && t.completedAt <= dayEnd).length;
return {
date: format(day, 'MMM dd', { locale }),
created: createdCount,
completed: completedCount,
};
});
}, [todos, range, settings.language]);
const stats = useMemo(() => {
const completed = filteredData.filter(t => t.completed).length;
const total = filteredData.length;
const rate = total > 0 ? Math.round((completed / total) * 100) : 0;
return { total, completed, rate };
}, [filteredData]);
// Pro Max Custom Tooltip
const CustomTooltip = ({ active, payload, label }: any) => {
if (active && payload && payload.length) {
return (
<div className="bg-white/90 dark:bg-slate-900/90 backdrop-blur-xl p-4 rounded-2xl shadow-2xl border border-white/20 dark:border-slate-700/50 ring-1 ring-black/5">
<p className="text-xs font-black uppercase tracking-widest text-slate-400 mb-2">{label}</p>
{payload.map((entry: any, index: number) => (
<div key={index} className="flex items-center gap-2 mb-1">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: entry.color }} />
<p className="text-sm font-bold text-slate-900 dark:text-white">
<span className="opacity-60 capitalize">{entry.name}:</span> {entry.value}
</p>
</div>
))}
</div>
);
}
return null;
};
return (
<div className="space-y-8 pb-20">
{/* Range Selector Pro Max */}
<div className="flex justify-center">
<div className="bg-slate-100/50 dark:bg-slate-900/50 p-1.5 rounded-2xl flex gap-1 border border-slate-200/50 dark:border-slate-800/50 shadow-inner">
{(['7d', '30d', 'all'] as const).map((r) => (
<button
key={r}
onClick={() => setRange(r)}
className={`px-6 py-2 rounded-xl text-xs font-black uppercase tracking-widest transition-all duration-300 cursor-pointer ${
range === r
? 'bg-white dark:bg-slate-800 text-slate-900 dark:text-white shadow-md ring-1 ring-black/5'
: 'text-slate-400 hover:text-slate-600 dark:hover:text-slate-300'
}`}
>
{r === '7d' ? t.past7Days : r === '30d' ? t.pastMonth : t.allTime}
</button>
))}
</div>
</div>
{/* KPI Grid Pro Max - Improved Alignment & Consistency */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3 sm:gap-4 lg:gap-6">
{[
{ label: t.pv, value: siteStats.pv, icon: Eye, color: 'indigo', bg: 'bg-indigo-500/10', text: 'text-indigo-500' },
{ label: t.uv, value: siteStats.uv, icon: Users, color: 'purple', bg: 'bg-purple-500/10', text: 'text-purple-500' },
{ label: t.totalCreated, value: stats.total, icon: Calendar, color: 'blue', bg: 'bg-blue-500/10', text: 'text-blue-500' },
{ label: t.completed, value: stats.completed, icon: CheckCircle2, color: 'emerald', bg: 'bg-emerald-500/10', text: 'text-emerald-500' },
{ label: t.successRate, value: `${stats.rate}%`, icon: TrendingUp, color: 'orange', bg: 'bg-orange-500/10', text: 'text-orange-500' },
].map((item, idx) => (
<motion.div
key={`${item.label}-${idx}`}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: idx * 0.1 }}
className="glass-card p-4 sm:p-5 lg:p-6 rounded-2xl sm:rounded-[2rem] flex flex-col sm:flex-row items-start sm:items-center gap-3 sm:gap-4 hover-lift"
>
<div className={`p-3 sm:p-4 ${item.bg} ${item.text} rounded-xl sm:rounded-2xl shrink-0 transition-all duration-500`}>
<item.icon size={20} className="sm:w-6 sm:h-6" strokeWidth={2.5} />
</div>
<div className="flex flex-col justify-center w-full">
<p className="text-[9px] sm:text-[10px] font-black uppercase tracking-wide text-slate-400 mb-1 leading-tight whitespace-nowrap">
{item.label}
</p>
<p className="text-xl sm:text-2xl lg:text-3xl font-black tabular-nums tracking-tighter text-slate-900 dark:text-white">
{typeof item.value === 'number' ? item.value.toLocaleString() : item.value}
</p>
</div>
</motion.div>
))}
</div>
{/* Charts Section */}
<div className="grid grid-cols-1 gap-8">
{/* Line Chart: Daily Activity */}
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="glass-card p-8 rounded-[3rem] overflow-hidden relative"
>
<div className="mb-8">
<h3 className="text-lg font-black uppercase tracking-tighter text-slate-900 dark:text-white flex items-center gap-2">
{t.dailyActivity}
<div className="px-2 py-0.5 bg-blue-500/10 text-blue-500 text-[10px] rounded-full">{t.trend}</div>
</h3>
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest mt-1">{t.creationVsCompletion}</p>
</div>
<div className="h-[350px] w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={dailyStats}>
<defs>
<linearGradient id="colorCreated" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3}/>
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0}/>
</linearGradient>
<linearGradient id="colorCompleted" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#10b981" stopOpacity={0.3}/>
<stop offset="95%" stopColor="#10b981" stopOpacity={0}/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e2e8f0" className="dark:stroke-slate-800" />
<XAxis
dataKey="date"
axisLine={false}
tickLine={false}
tick={{ fontSize: 10, fontWeight: 700, fill: '#94a3b8' }}
dy={10}
/>
<YAxis
axisLine={false}
tickLine={false}
tick={{ fontSize: 10, fontWeight: 700, fill: '#94a3b8' }}
/>
<Tooltip content={<CustomTooltip />} />
<Area
type="monotone"
dataKey="created"
name={t.createdLabel}
stroke="#3b82f6"
strokeWidth={4}
fillOpacity={1}
fill="url(#colorCreated)"
animationDuration={1500}
/>
<Area
type="monotone"
dataKey="completed"
name={t.completedLabel}
stroke="#10b981"
strokeWidth={4}
fillOpacity={1}
fill="url(#colorCompleted)"
animationDuration={2000}
/>
</AreaChart>
</ResponsiveContainer>
</div>
</motion.div>
{/* Simplified Timeline (Gantt-like) */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="glass-card p-8 rounded-[3rem]"
>
<div className="mb-8">
<h3 className="text-lg font-black uppercase tracking-tighter text-slate-900 dark:text-white">
{t.recentTaskTimeline}
</h3>
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest mt-1">{t.completionDuration}</p>
</div>
<div className="space-y-4">
{todos.filter(t => t.completedAt).slice(0, 8).map((todo, idx) => {
const duration = todo.completedAt! - todo.createdAt;
const hours = Math.round(duration / (1000 * 60 * 60));
const percentage = Math.min(Math.max((duration / (1000 * 60 * 60 * 24)) * 100, 10), 100);
return (
<div key={todo.id} className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm font-bold text-slate-700 dark:text-slate-300 truncate max-w-[200px]">
{todo.text}
</span>
<span className="text-[10px] font-black uppercase tracking-widest text-emerald-500">
{t.took} {hours}{t.language === 'zh' ? t.hours : 'h'}
</span>
</div>
<div className="h-3 w-full bg-slate-100 dark:bg-slate-800 rounded-full overflow-hidden">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${percentage}%` }}
transition={{ delay: 0.5 + idx * 0.1, duration: 1 }}
className="h-full bg-gradient-to-r from-blue-500 to-emerald-500 rounded-full"
/>
</div>
</div>
);
})}
</div>
</motion.div>
</div>
</div>
);
}

View File

@@ -0,0 +1,155 @@
'use client';
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Lock, X, Eye, EyeOff, ShieldCheck, ShieldAlert } from 'lucide-react';
import { useAuth } from '@/contexts/AuthContext';
import { useSettings } from '@/contexts/SettingsContext';
import { translations } from '@/lib/translations';
export default function AuthModal() {
const { showAuthModal, authenticate, closeAuthModal } = useAuth();
const { settings } = useSettings();
const t = translations[settings.language];
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!password.trim()) {
setError(t.passwordRequired || 'Password is required');
return;
}
setIsLoading(true);
setError('');
const success = await authenticate(password);
if (!success) {
setError(t.invalidPassword || 'Invalid password');
}
setIsLoading(false);
};
const handleClose = () => {
setPassword('');
setError('');
closeAuthModal();
};
return (
<AnimatePresence>
{showAuthModal && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
onClick={handleClose}
>
<motion.div
initial={{ opacity: 0, scale: 0.9, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 20 }}
transition={{ type: 'spring', bounce: 0.3, duration: 0.5 }}
className="w-full max-w-md glass-card p-8 rounded-[2rem] ring-1 ring-black/5 dark:ring-white/10 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div className="flex items-center gap-4">
<div className="p-3 bg-amber-500/10 dark:bg-amber-500/20 rounded-2xl">
<Lock className="w-6 h-6 text-amber-600 dark:text-amber-400" strokeWidth={2.5} />
</div>
<div>
<h2 className="text-xl font-bold text-slate-900 dark:text-white">
{t.authRequired || 'Authentication Required'}
</h2>
<p className="text-sm text-slate-500 dark:text-slate-400">
{t.authDescription || 'Enter password to modify tasks'}
</p>
</div>
</div>
<button
onClick={handleClose}
className="p-2 rounded-xl hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors cursor-pointer"
>
<X className="w-5 h-5 text-slate-400" />
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-6">
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => {
setPassword(e.target.value);
setError('');
}}
placeholder={t.enterPassword || 'Enter password'}
className="w-full bg-slate-100 dark:bg-slate-900/50 border-none rounded-2xl px-6 py-4 pr-12 text-slate-900 dark:text-white placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-amber-500/50"
autoFocus
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-4 top-1/2 -translate-y-1/2 p-1 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 transition-colors cursor-pointer"
>
{showPassword ? <EyeOff size={20} /> : <Eye size={20} />}
</button>
</div>
{/* Error Message */}
<AnimatePresence>
{error && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="flex items-center gap-2 px-4 py-3 bg-red-500/10 dark:bg-red-500/20 rounded-xl"
>
<ShieldAlert className="w-5 h-5 text-red-500" />
<span className="text-sm font-medium text-red-600 dark:text-red-400">
{error}
</span>
</motion.div>
)}
</AnimatePresence>
{/* Submit Button */}
<button
type="submit"
disabled={isLoading || !password.trim()}
className="w-full flex items-center justify-center gap-3 bg-slate-900 dark:bg-white text-white dark:text-slate-900 py-4 rounded-2xl font-bold transition-all duration-300 disabled:opacity-40 disabled:cursor-not-allowed hover:shadow-xl active:scale-[0.98] cursor-pointer"
>
{isLoading ? (
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 1, repeat: Infinity, ease: 'linear' }}
>
<ShieldCheck className="w-5 h-5" />
</motion.div>
) : (
<ShieldCheck className="w-5 h-5" />
)}
<span>{isLoading ? (t.verifying || 'Verifying...') : (t.verify || 'Verify')}</span>
</button>
</form>
{/* Footer Hint */}
<p className="mt-6 text-center text-xs text-slate-400">
{t.authHint || 'Contact administrator if you forgot the password'}
</p>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}

View File

@@ -0,0 +1,141 @@
'use client';
import React from 'react';
import { motion } from 'framer-motion';
import { Zap } from 'lucide-react';
import { useSettings } from '@/contexts/SettingsContext';
import { translations } from '@/lib/translations';
interface StarkLogoProps {
logoText?: string;
}
const StarkLogo: React.FC<StarkLogoProps> = ({ logoText: customLogoText }) => {
const { settings } = useSettings();
const t = translations[settings.language];
const logoText = customLogoText || settings.logoText || '蛋定-项目待办';
const letters = logoText.split('');
const contactUrl = 'https://qm.qq.com/q/IOiQPWjEwW';
const letterVariants = {
hidden: { opacity: 0, y: 20, filter: 'blur(10px)' },
visible: (i: number) => ({
opacity: 1,
y: 0,
filter: 'blur(0px)',
transition: {
delay: i * 0.08,
duration: 0.8,
ease: [0.22, 1, 0.36, 1] as const, // 使用 as const 修复类型校验
},
}),
};
const iconVariants = {
hidden: { scale: 0.8, opacity: 0, rotate: -15 },
visible: {
scale: 1,
opacity: 1,
rotate: 0,
transition: {
delay: 0.4,
duration: 0.7,
ease: [0.22, 1, 0.36, 1] as const, // 使用 as const 修复类型校验
},
},
};
return (
<div className="flex flex-col items-center justify-center py-8 sm:py-12 md:py-16 select-none">
{/* Icon + Text Logo */}
<div className="flex items-center gap-3 sm:gap-4 md:gap-6 mb-4 sm:mb-6">
<motion.div
variants={iconVariants}
initial="hidden"
animate="visible"
className="relative group"
>
{/* Animated background glow Pro Max */}
<motion.div
animate={{
scale: [1, 1.15, 1],
opacity: [0.3, 0.6, 0.3],
}}
transition={{
duration: 4,
repeat: Infinity,
ease: 'easeInOut',
}}
className="absolute -inset-4 bg-blue-500/20 dark:bg-blue-400/10 rounded-full blur-2xl"
/>
{/* Icon Pro Max */}
<div className="relative bg-gradient-to-br from-blue-600 via-blue-500 to-blue-700 dark:from-blue-500 dark:to-blue-600 p-3 sm:p-4 rounded-2xl shadow-2xl shadow-blue-500/20 ring-1 ring-white/20">
<img src="/egg-icon.ico" alt="Logo" className="w-7 h-7 sm:w-8 sm:h-8" />
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 8, repeat: Infinity, ease: "linear" }}
className="absolute -top-1 -right-1"
>
<Zap className="text-amber-300 fill-amber-300" size={14} />
</motion.div>
</div>
</motion.div>
{/* Text Logo with modern font Pro Max */}
<div className="flex items-center">
{letters.map((letter, i) => (
<motion.span
key={`${letter}-${i}`}
custom={i}
variants={letterVariants}
initial="hidden"
animate="visible"
className="text-5xl sm:text-6xl md:text-7xl lg:text-8xl font-black tracking-tight bg-gradient-to-b from-slate-900 via-slate-800 to-slate-950 dark:from-white dark:via-slate-200 dark:to-slate-400 bg-clip-text text-transparent drop-shadow-[0_4px_4px_rgba(0,0,0,0.1)]"
style={{
fontFamily: "'Inter Tight', 'Inter', system-ui, sans-serif",
fontWeight: 900,
}}
>
{letter}
</motion.span>
))}
</div>
</div>
{/* Subtitle Pro Max */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 1, duration: 0.8 }}
className="px-6 py-2 rounded-full bg-slate-100/50 dark:bg-slate-800/30 border border-slate-200/50 dark:border-slate-700/30 backdrop-blur-md"
>
{settings.language === 'zh' ? (
<p className="text-xs sm:text-sm font-semibold tracking-wide text-slate-600 dark:text-slate-300">
<a
href={contactUrl}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 dark:text-blue-400 underline underline-offset-2"
>
</a>
</p>
) : (
<div className="flex items-center gap-3">
<span className="text-xs sm:text-sm font-semibold tracking-wider uppercase text-slate-500 dark:text-slate-400">
{t.subtitle1}
</span>
<div className="w-1 h-1 rounded-full bg-slate-300 dark:bg-slate-600" />
<span className="text-xs sm:text-sm font-semibold tracking-wider uppercase text-blue-600 dark:text-blue-400">
{t.subtitle2}
</span>
</div>
)}
</motion.div>
</div>
);
};
export default StarkLogo;

View File

@@ -0,0 +1,34 @@
'use client';
import { useEffect } from 'react';
export default function StatsTracker() {
useEffect(() => {
const trackStats = async () => {
try {
// Check if user has visited before using localStorage
const hasVisited = localStorage.getItem('stark_visited');
const isNewVisitor = !hasVisited;
if (isNewVisitor) {
localStorage.setItem('stark_visited', 'true');
}
// Increment PV and UV (if new)
await fetch('/api/stats', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ isNewVisitor }),
});
} catch (error) {
console.error('[StatsTracker] Failed to track stats:', error);
}
};
trackStats();
}, []);
return null; // This component doesn't render anything
}

View File

@@ -0,0 +1,124 @@
'use client';
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
interface AuthContextType {
isAuthenticated: boolean;
showAuthModal: boolean;
authenticate: (password: string) => Promise<boolean>;
logout: () => void;
requestAuth: () => void;
closeAuthModal: () => void;
getAuthHeaders: () => Record<string, string>;
}
const AUTH_STORAGE_KEY = 'stark-todo-auth';
const AUTH_PASSWORD_KEY = 'stark-todo-pwd';
// 默认值,用于 SSR
const defaultContextValue: AuthContextType = {
isAuthenticated: false,
showAuthModal: false,
authenticate: async () => false,
logout: () => {},
requestAuth: () => {},
closeAuthModal: () => {},
getAuthHeaders: () => ({}),
};
const AuthContext = createContext<AuthContextType>(defaultContextValue);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [showAuthModal, setShowAuthModal] = useState(false);
const [isClient, setIsClient] = useState(false);
const [storedPassword, setStoredPassword] = useState<string | null>(null);
// 客户端初始化
useEffect(() => {
setIsClient(true);
const savedAuth = localStorage.getItem(AUTH_STORAGE_KEY);
const savedPwd = localStorage.getItem(AUTH_PASSWORD_KEY);
if (savedAuth === 'true' && savedPwd) {
setIsAuthenticated(true);
setStoredPassword(savedPwd);
}
}, []);
// 验证密码
const authenticate = useCallback(async (password: string): Promise<boolean> => {
try {
const response = await fetch('/api/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
});
const data = await response.json();
if (data.success) {
setIsAuthenticated(true);
setStoredPassword(password);
localStorage.setItem(AUTH_STORAGE_KEY, 'true');
localStorage.setItem(AUTH_PASSWORD_KEY, password);
setShowAuthModal(false);
return true;
}
return false;
} catch (error) {
console.error('[Auth] Verification failed:', error);
return false;
}
}, []);
// 登出
const logout = useCallback(() => {
setIsAuthenticated(false);
setStoredPassword(null);
localStorage.removeItem(AUTH_STORAGE_KEY);
localStorage.removeItem(AUTH_PASSWORD_KEY);
}, []);
// 请求验证(显示弹窗)
const requestAuth = useCallback(() => {
if (!isAuthenticated) {
setShowAuthModal(true);
}
}, [isAuthenticated]);
// 关闭弹窗
const closeAuthModal = useCallback(() => {
setShowAuthModal(false);
}, []);
// 获取认证请求头
const getAuthHeaders = useCallback((): Record<string, string> => {
if (storedPassword) {
return { 'X-API-Key': storedPassword };
}
return {};
}, [storedPassword]);
// 始终提供 Context确保子组件可以访问
const contextValue: AuthContextType = isClient
? {
isAuthenticated,
showAuthModal,
authenticate,
logout,
requestAuth,
closeAuthModal,
getAuthHeaders,
}
: defaultContextValue;
return (
<AuthContext.Provider value={contextValue}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
return useContext(AuthContext);
}

View File

@@ -0,0 +1,154 @@
'use client';
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
export interface Settings {
language: 'zh' | 'en';
logoText: string;
timezone: string;
theme: 'light' | 'dark' | 'system';
}
interface SettingsContextType {
settings: Settings;
updateSettings: (newSettings: Partial<Settings>) => void;
}
const defaultSettings: Settings = {
language: 'zh',
logoText: '蛋定-项目待办',
timezone: 'Asia/Shanghai',
theme: 'system',
};
const SettingsContext = createContext<SettingsContextType | undefined>(undefined);
export function SettingsProvider({ children }: { children: ReactNode }) {
const [settings, setSettings] = useState<Settings>(defaultSettings);
const [isClient, setIsClient] = useState(false);
useEffect(() => {
setIsClient(true);
// Load settings from localStorage
const savedSettings = localStorage.getItem('stark-settings');
if (savedSettings) {
try {
const parsed = JSON.parse(savedSettings);
// Merge with default settings to ensure all fields exist
const mergedSettings = { ...defaultSettings, ...parsed };
setSettings(mergedSettings);
console.log('[Settings] Loaded from localStorage:', mergedSettings);
// Apply theme immediately after loading
requestAnimationFrame(() => {
applyTheme(mergedSettings.theme);
});
} catch (error) {
console.error('[Settings] Failed to parse settings:', error);
// Use default settings on error
setSettings(defaultSettings);
applyTheme(defaultSettings.theme);
}
} else {
console.log('[Settings] No saved settings, using defaults:', defaultSettings);
// Save default settings to localStorage
localStorage.setItem('stark-settings', JSON.stringify(defaultSettings));
applyTheme(defaultSettings.theme);
}
}, []);
useEffect(() => {
if (!isClient) return;
// Save settings to localStorage whenever they change
try {
localStorage.setItem('stark-settings', JSON.stringify(settings));
console.log('[Settings] Saved to localStorage:', settings);
} catch (error) {
console.error('[Settings] Failed to save settings:', error);
}
// Apply theme
applyTheme(settings.theme);
}, [settings, isClient]);
useEffect(() => {
if (!isClient) return;
// Listen to system theme changes
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = () => {
if (settings.theme === 'system') {
applyTheme('system');
}
};
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, [settings.theme, isClient]);
const applyTheme = (theme: 'light' | 'dark' | 'system') => {
if (typeof window === 'undefined') return;
const root = document.documentElement;
// 强制移除过渡效果,确保立即切换
root.style.setProperty('transition', 'none');
if (theme === 'system') {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (prefersDark) {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
} else if (theme === 'dark') {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
// 强制重绘,确保立即生效
void root.offsetHeight;
// 恢复过渡效果
requestAnimationFrame(() => {
root.style.removeProperty('transition');
});
console.log(`[Theme] Applied theme: ${theme}, dark class: ${root.classList.contains('dark')}`);
};
const updateSettings = (newSettings: Partial<Settings>) => {
setSettings(prev => {
const updated = { ...prev, ...newSettings };
console.log('[Settings] Updating settings:', { old: prev, new: newSettings, updated });
// 如果更新了主题,立即应用
if (newSettings.theme !== undefined) {
requestAnimationFrame(() => {
applyTheme(newSettings.theme!);
});
}
return updated;
});
};
return (
<SettingsContext.Provider value={{ settings, updateSettings }}>
{children}
</SettingsContext.Provider>
);
}
export function useSettings() {
const context = useContext(SettingsContext);
if (!context) {
throw new Error('useSettings must be used within SettingsProvider');
}
return context;
}

44
src/lib/stats.ts Normal file
View File

@@ -0,0 +1,44 @@
import fs from 'fs';
import path from 'path';
const DATA_DIR = process.env.DATA_DIR || process.cwd();
const STATS_FILE = path.join(DATA_DIR, 'stats.json');
export interface Stats {
pv: number;
uv: number;
visitors: string[]; // Store hashed IPs or session IDs
}
export const getStats = (): Stats => {
try {
if (!fs.existsSync(STATS_FILE)) {
const initialStats: Stats = { pv: 0, uv: 0, visitors: [] };
fs.writeFileSync(STATS_FILE, JSON.stringify(initialStats, null, 2));
return initialStats;
}
const data = fs.readFileSync(STATS_FILE, 'utf-8');
return JSON.parse(data);
} catch (error) {
console.error('[Stats] Error reading stats:', error);
return { pv: 0, uv: 0, visitors: [] };
}
};
export const recordHit = (visitorId: string): Stats => {
try {
const stats = getStats();
stats.pv += 1;
if (!stats.visitors.includes(visitorId)) {
stats.visitors.push(visitorId);
stats.uv = stats.visitors.length;
}
fs.writeFileSync(STATS_FILE, JSON.stringify(stats, null, 2));
return stats;
} catch (error) {
console.error('[Stats] Error recording hit:', error);
return getStats();
}
};

332
src/lib/storage.ts Normal file
View File

@@ -0,0 +1,332 @@
import fs from 'fs';
import path from 'path';
import { Todo, Group, Stats, DEFAULT_GROUP_ID } from './types';
// 支持 Docker 数据目录 and 本地开发
const DATA_DIR = process.env.DATA_DIR || process.cwd();
const DATA_FILE = path.join(DATA_DIR, 'todos.json');
const STATS_FILE = path.join(DATA_DIR, 'stats.json');
const GROUPS_FILE = path.join(DATA_DIR, 'groups.json');
const RATE_LIMITS_FILE = path.join(DATA_DIR, 'rate-limits.json');
// 限流数据结构
export interface RateLimitData {
lastAddTime: number; // 上次添加任务的时间戳
dailyCount: number; // 当天已添加的任务数
lastResetDate: string; // 上次重置日期 (YYYY-MM-DD)
history: number[]; // 添加任务的历史时间戳
}
export interface RateLimits {
[clientId: string]: RateLimitData;
}
// 限流配置
export const RATE_LIMIT_CONFIG = {
INTERVAL_MS: 10 * 60 * 1000, // 10分钟
DAILY_MAX: 6, // 每天6个任务
};
export { DEFAULT_GROUP_ID };
export type { Todo, Group, Stats };
export const getTodos = (): Todo[] => {
try {
// 确保目录存在
const dir = path.dirname(DATA_FILE);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
// 如果文件不存在,创建空数组
if (!fs.existsSync(DATA_FILE)) {
fs.writeFileSync(DATA_FILE, '[]', 'utf-8');
console.log(`[Storage] Created new todos file: ${DATA_FILE}`);
return [];
}
const data = fs.readFileSync(DATA_FILE, 'utf-8');
let todos: Todo[] = JSON.parse(data);
// 迁移逻辑:确保所有任务都有 groupId如果没有则归入默认分组
let needsSave = false;
todos = todos.map(todo => {
if (!todo.groupId) {
todo.groupId = DEFAULT_GROUP_ID;
needsSave = true;
}
return todo;
});
if (needsSave) {
saveTodos(todos);
}
console.log(`[Storage] Loaded ${todos.length} todos from ${DATA_FILE}`);
return todos;
} catch (error) {
console.error('[Storage] Error reading todos:', error);
return [];
}
};
export const saveTodos = (todos: Todo[]) => {
try {
// 确保目录存在
const dir = path.dirname(DATA_FILE);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(DATA_FILE, JSON.stringify(todos, null, 2), 'utf-8');
console.log(`[Storage] Saved ${todos.length} todos to ${DATA_FILE}`);
} catch (error) {
console.error('[Storage] Error saving todos:', error);
throw error; // 抛出错误以便 API 能捕获
}
};
export const getGroups = (): Group[] => {
try {
const dir = path.dirname(GROUPS_FILE);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
if (!fs.existsSync(GROUPS_FILE)) {
const defaultGroups: Group[] = [{ id: DEFAULT_GROUP_ID, name: 'Default', createdAt: Date.now() }];
fs.writeFileSync(GROUPS_FILE, JSON.stringify(defaultGroups, null, 2), 'utf-8');
return defaultGroups;
}
const data = fs.readFileSync(GROUPS_FILE, 'utf-8');
return JSON.parse(data);
} catch (error) {
console.error('[Storage] Error reading groups:', error);
return [{ id: DEFAULT_GROUP_ID, name: 'Default', createdAt: Date.now() }];
}
};
export const saveGroups = (groups: Group[]) => {
try {
const dir = path.dirname(GROUPS_FILE);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(GROUPS_FILE, JSON.stringify(groups, null, 2), 'utf-8');
} catch (error) {
console.error('[Storage] Error saving groups:', error);
throw error;
}
};
export const getStats = (): Stats => {
try {
// 确保数据目录存在
const dir = path.dirname(STATS_FILE);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
console.log(`[Storage] Created data directory: ${dir}`);
}
if (!fs.existsSync(STATS_FILE)) {
const initialStats = { pv: 0, uv: 0 };
fs.writeFileSync(STATS_FILE, JSON.stringify(initialStats, null, 2), 'utf-8');
console.log(`[Storage] Created new stats file: ${STATS_FILE}`);
return initialStats;
}
const data = fs.readFileSync(STATS_FILE, 'utf-8');
const stats = JSON.parse(data);
console.log(`[Storage] Loaded stats from ${STATS_FILE}: PV=${stats.pv}, UV=${stats.uv}`);
return stats;
} catch (error) {
console.error('[Storage] Error reading stats:', error);
return { pv: 0, uv: 0 };
}
};
export const updateStats = (isNewVisitor: boolean): Stats => {
try {
// 确保数据目录存在
const dir = path.dirname(STATS_FILE);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
const stats = getStats();
stats.pv += 1;
if (isNewVisitor) {
stats.uv += 1;
}
fs.writeFileSync(STATS_FILE, JSON.stringify(stats, null, 2), 'utf-8');
console.log(`[Storage] Updated stats: PV=${stats.pv}, UV=${stats.uv}`);
return stats;
} catch (error) {
console.error('[Storage] Error updating stats:', error);
return { pv: 0, uv: 0 };
}
};
// ============ 限流管理函数 ============
/**
* 获取所有限流数据
*/
export const getRateLimits = (): RateLimits => {
try {
const dir = path.dirname(RATE_LIMITS_FILE);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
if (!fs.existsSync(RATE_LIMITS_FILE)) {
const initialData: RateLimits = {};
fs.writeFileSync(RATE_LIMITS_FILE, JSON.stringify(initialData, null, 2), 'utf-8');
console.log(`[Storage] Created new rate limits file: ${RATE_LIMITS_FILE}`);
return initialData;
}
const data = fs.readFileSync(RATE_LIMITS_FILE, 'utf-8');
const rateLimits = JSON.parse(data);
console.log(`[Storage] Loaded rate limits for ${Object.keys(rateLimits).length} clients`);
return rateLimits;
} catch (error) {
console.error('[Storage] Error reading rate limits:', error);
return {};
}
};
/**
* 保存限流数据
*/
export const saveRateLimits = (rateLimits: RateLimits): void => {
try {
const dir = path.dirname(RATE_LIMITS_FILE);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(RATE_LIMITS_FILE, JSON.stringify(rateLimits, null, 2), 'utf-8');
console.log(`[Storage] Saved rate limits for ${Object.keys(rateLimits).length} clients`);
} catch (error) {
console.error('[Storage] Error saving rate limits:', error);
throw error;
}
};
/**
* 获取当前日期字符串 (YYYY-MM-DD)
*/
const getCurrentDate = (): string => {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
/**
* 清理过期的限流历史记录保留最近24小时
*/
const cleanupOldHistory = (history: number[], currentTime: number): number[] => {
const oneDayAgo = currentTime - (24 * 60 * 60 * 1000);
return history.filter(timestamp => timestamp > oneDayAgo);
};
/**
* 检查客户端是否可以添加任务
* @param clientId 客户端标识IP地址或唯一ID
* @returns { canAdd: boolean, reason?: string, retryAfter?: number }
*/
export const checkRateLimit = (clientId: string): {
canAdd: boolean;
reason?: string;
retryAfter?: number;
dailyRemaining?: number;
} => {
const rateLimits = getRateLimits();
const currentTime = Date.now();
const currentDate = getCurrentDate();
// 获取或初始化客户端数据
let clientData = rateLimits[clientId];
if (!clientData) {
clientData = {
lastAddTime: 0,
dailyCount: 0,
lastResetDate: currentDate,
history: [],
};
rateLimits[clientId] = clientData;
}
// 检查日期是否变更,如果是则重置计数
if (clientData.lastResetDate !== currentDate) {
clientData.dailyCount = 0;
clientData.lastResetDate = currentDate;
clientData.history = [];
}
// 清理过期的历史记录
clientData.history = cleanupOldHistory(clientData.history, currentTime);
// 检查1时间间隔限制10分钟
const timeSinceLastAdd = currentTime - clientData.lastAddTime;
if (timeSinceLastAdd < RATE_LIMIT_CONFIG.INTERVAL_MS) {
const retryAfter = Math.ceil((RATE_LIMIT_CONFIG.INTERVAL_MS - timeSinceLastAdd) / 1000); // 秒
return {
canAdd: false,
reason: 'RATE_LIMIT_INTERVAL',
retryAfter,
dailyRemaining: RATE_LIMIT_CONFIG.DAILY_MAX - clientData.dailyCount,
};
}
// 检查2每日限制6个任务
if (clientData.dailyCount >= RATE_LIMIT_CONFIG.DAILY_MAX) {
return {
canAdd: false,
reason: 'RATE_LIMIT_DAILY',
dailyRemaining: 0,
};
}
// 可以添加
return {
canAdd: true,
dailyRemaining: RATE_LIMIT_CONFIG.DAILY_MAX - clientData.dailyCount - 1,
};
};
/**
* 记录客户端添加任务
* @param clientId 客户端标识
*/
export const recordTaskAdd = (clientId: string): void => {
const rateLimits = getRateLimits();
const currentTime = Date.now();
const currentDate = getCurrentDate();
let clientData = rateLimits[clientId];
if (!clientData) {
clientData = {
lastAddTime: 0,
dailyCount: 0,
lastResetDate: currentDate,
history: [],
};
}
clientData.lastAddTime = currentTime;
clientData.dailyCount += 1;
clientData.history.push(currentTime);
clientData.history = cleanupOldHistory(clientData.history, currentTime);
rateLimits[clientId] = clientData;
saveRateLimits(rateLimits);
console.log(`[Storage] Recorded task add for client ${clientId}: ` +
`dailyCount=${clientData.dailyCount}, ` +
`dailyRemaining=${RATE_LIMIT_CONFIG.DAILY_MAX - clientData.dailyCount}`);
};

16
src/lib/timezones.ts Normal file
View File

@@ -0,0 +1,16 @@
export const TIMEZONES = [
{ value: 'UTC', label: 'UTC (UTC+0)' },
{ value: 'America/New_York', label: 'New York (UTC-5)' },
{ value: 'America/Los_Angeles', label: 'Los Angeles (UTC-8)' },
{ value: 'America/Chicago', label: 'Chicago (UTC-6)' },
{ value: 'Europe/London', label: 'London (UTC+0)' },
{ value: 'Europe/Paris', label: 'Paris (UTC+1)' },
{ value: 'Europe/Berlin', label: 'Berlin (UTC+1)' },
{ value: 'Asia/Dubai', label: 'Dubai (UTC+4)' },
{ value: 'Asia/Shanghai', label: 'Shanghai (UTC+8)' },
{ value: 'Asia/Tokyo', label: 'Tokyo (UTC+9)' },
{ value: 'Asia/Hong_Kong', label: 'Hong Kong (UTC+8)' },
{ value: 'Asia/Singapore', label: 'Singapore (UTC+8)' },
{ value: 'Australia/Sydney', label: 'Sydney (UTC+11)' },
{ value: 'Pacific/Auckland', label: 'Auckland (UTC+13)' },
];

187
src/lib/translations.ts Normal file
View File

@@ -0,0 +1,187 @@
export const translations = {
zh: {
// Common
save: '保存',
cancel: '取消',
close: '关闭',
// Home
addTask: '添加新任务',
addTaskPlaceholder: '添加新任务...',
all: '全部',
allTasks: '全部任务',
active: '进行中',
completed: '已完成',
noTasks: '暂无任务,开始记录你的灵感吧',
created: '创建',
completedAt: '完成',
subtitle1: '极简任务管理',
subtitle2: '高效生活方式',
// Settings
settings: '设置',
language: '语言',
languageDesc: '选择界面语言',
logoCustomization: 'Logo 自定义',
logoCustomizationDesc: '自定义主页 Logo 文字',
logoTextLabel: 'Logo 文字',
logoTextPlaceholder: '输入自定义文字(如 STARK',
timezone: '时区',
timezoneDesc: '选择显示时区',
themeMode: '主题模式',
themeModeDesc: '选择外观主题',
themeLight: '浅色模式',
themeDark: '深色模式',
themeSystem: '跟随系统',
// Analytics
insights: '数据分析',
analyticsLoading: '正在加载分析数据...',
aggregatingRecords: '正在汇总数据...',
past7Days: '过去 7 天',
pastMonth: '过去一个月',
allTime: '所有时间',
totalCreated: '总创建数',
successRate: '完成率',
dailyActivity: '每日动态',
creationVsCompletion: '创建与完成对比',
recentTaskTimeline: '近期任务时间轴',
completionDuration: '任务完成耗时分析',
took: '耗时约',
hours: '小时',
liveDataEngine: '实时数据引擎',
trend: '趋势',
createdLabel: '已创建',
completedLabel: '已完成',
// Notifications & Status
settingsSaved: '设置已保存',
syncingTasks: '正在同步任务...',
noActiveTasks: '暂无进行中的任务',
noCompletedTasks: '暂无已完成的任务',
pv: '访问量 (PV)',
uv: '访客数 (UV)',
// Auth
authRequired: '需要验证权限',
authDescription: '请输入密码以修改任务',
enterPassword: '请输入密码',
passwordRequired: '请输入密码',
invalidPassword: '密码错误',
verify: '验证',
verifying: '验证中...',
authHint: '如忘记密码,请联系管理员',
authSuccess: '验证成功',
logout: '退出登录',
authenticated: '已验证',
notAuthenticated: '未验证',
// Groups & Priority
groups: '分组',
addGroup: '添加分组',
deleteGroup: '删除分组',
defaultGroup: '默认分组',
selectGroup: '选择分组',
priority: '优先级',
p0: '高优先级 (P0)',
p1: '中优先级 (P1)',
p2: '低优先级 (P2)',
manageGroups: '管理分组',
newGroupName: '新分组名称',
noPriority: '无优先级',
},
en: {
// Common
save: 'Save',
cancel: 'Cancel',
close: 'Close',
// Home
addTask: 'Add Task',
addTaskPlaceholder: 'Add a new task...',
all: 'All',
allTasks: 'All Tasks',
active: 'Active',
completed: 'Completed',
noTasks: 'No tasks yet. Start tracking your ideas!',
created: 'Created',
completedAt: 'Completed',
subtitle1: 'Minimal Task Management',
subtitle2: 'Efficient Lifestyle',
// Settings
settings: 'Settings',
language: 'Language',
languageDesc: 'Choose interface language',
logoCustomization: 'Logo Customization',
logoCustomizationDesc: 'Customize homepage logo text',
logoTextLabel: 'Logo Text',
logoTextPlaceholder: 'Enter custom text (e.g., STARK)',
timezone: 'Timezone',
timezoneDesc: 'Select display timezone',
themeMode: 'Theme Mode',
themeModeDesc: 'Choose appearance theme',
themeLight: 'Light Mode',
themeDark: 'Dark Mode',
themeSystem: 'Follow System',
// Analytics
insights: 'Insights',
analyticsLoading: 'Loading analytics data...',
aggregatingRecords: 'Aggregating records...',
past7Days: 'Past 7 Days',
pastMonth: 'Past Month',
allTime: 'All Time',
totalCreated: 'Total Created',
successRate: 'Success Rate',
dailyActivity: 'Daily Activity',
creationVsCompletion: 'Creation vs Completion',
recentTaskTimeline: 'Recent Task Timeline',
completionDuration: 'Completion Time Analysis',
took: 'Took ~',
hours: 'h',
liveDataEngine: 'Live Data Engine',
trend: 'Trend',
createdLabel: 'Created',
completedLabel: 'Completed',
// Notifications & Status
settingsSaved: 'Settings Saved',
syncingTasks: 'Syncing tasks...',
noActiveTasks: 'No active tasks',
noCompletedTasks: 'No completed tasks',
pv: 'Page Views (PV)',
uv: 'Unique Visitors (UV)',
// Auth
authRequired: 'Authentication Required',
authDescription: 'Enter password to modify tasks',
enterPassword: 'Enter password',
passwordRequired: 'Password is required',
invalidPassword: 'Invalid password',
verify: 'Verify',
verifying: 'Verifying...',
authHint: 'Contact administrator if you forgot the password',
authSuccess: 'Verified successfully',
logout: 'Logout',
authenticated: 'Authenticated',
notAuthenticated: 'Not authenticated',
// Groups & Priority
groups: 'Groups',
addGroup: 'Add Group',
deleteGroup: 'Delete Group',
defaultGroup: 'Default Group',
selectGroup: 'Select Group',
priority: 'Priority',
p0: 'High (P0)',
p1: 'Medium (P1)',
p2: 'Low (P2)',
manageGroups: 'Manage Groups',
newGroupName: 'New Group Name',
noPriority: 'No Priority',
},
};
export type Language = keyof typeof translations;
export type TranslationKey = keyof typeof translations.zh;

26
src/lib/types.ts Normal file
View File

@@ -0,0 +1,26 @@
export type Priority = 'P0' | 'P1' | 'P2';
export interface Todo {
id: string;
text: string;
completed: boolean;
createdAt: number;
completedAt?: number;
deleted?: boolean;
deletedAt?: number;
groupId?: string;
priority?: Priority;
}
export interface Group {
id: string;
name: string;
createdAt: number;
}
export const DEFAULT_GROUP_ID = 'default';
export interface Stats {
pv: number;
uv: number;
}