7.5 完整代码参考
下面是「AI 每日聚焦助手」的完整可运行代码。你可以直接复制粘贴使用。
项目结构
daily-focus/
├── src/
│ ├── app/
│ │ ├── api/
│ │ │ └── motivate/
│ │ │ └── route.ts # AI 鼓励 API
│ │ ├── layout.tsx # 全局布局
│ │ ├── page.tsx # 主页面
│ │ └── globals.css # 全局样式
│ ├── components/
│ │ ├── TaskInput.tsx # 任务输入组件
│ │ ├── MotivationCard.tsx # AI 鼓励卡片
│ │ ├── Stats.tsx # 统计组件
│ │ └── ThemeToggle.tsx # 暗色模式切换
│ └── lib/
│ ├── supabase.ts # Supabase 客户端
│ └── user.ts # 匿名用户管理
├── .env.local # 环境变量(不要提交到 Git)
├── tailwind.config.ts # Tailwind 配置
├── package.json
└── README.md
环境配置
1. 创建 .env.local
# Supabase - 在 supabase.com 的 Settings > API 中获取
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
# OpenAI - 在 platform.openai.com/api-keys 创建
OPENAI_API_KEY=sk-your-key
2. 安装依赖
npm install @supabase/supabase-js openai
3. 数据库 Schema
在 Supabase 控制台的 SQL Editor 中运行:
-- 创建任务表
CREATE TABLE IF NOT EXISTS tasks (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
user_id TEXT NOT NULL,
content TEXT NOT NULL,
is_completed BOOLEAN DEFAULT FALSE,
position INTEGER NOT NULL CHECK (position IN (0, 1, 2)),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 按用户和日期查询的索引
CREATE INDEX IF NOT EXISTS idx_tasks_user_date
ON tasks(user_id, created_at DESC);
-- 开发阶段关闭 RLS(上线前记得开启并配置规则)
ALTER TABLE tasks DISABLE ROW LEVEL SECURITY;
核心代码
src/lib/supabase.ts
import { createClient } from "@supabase/supabase-js";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
// 创建 Supabase 客户端实例
// 这个实例在整个应用中共享使用
export const supabase = createClient(supabaseUrl, supabaseKey);
src/lib/user.ts
// 管理匿名用户 ID
// 使用 localStorage 持久化,确保同一浏览器始终使用同一 ID
const USER_ID_KEY = "daily-focus-user-id";
export function getUserId(): string {
if (typeof window === "undefined") return "";
let userId = localStorage.getItem(USER_ID_KEY);
if (!userId) {
// 生成一个随机的匿名 ID
userId = "anon-" + Math.random().toString(36).substring(2, 15);
localStorage.setItem(USER_ID_KEY, userId);
}
return userId;
}
// 获取今天的日期字符串,用于数据库查询
export function getTodayString(): string {
return new Date().toISOString().split("T")[0]; // "2024-01-15"
}
src/app/api/motivate/route.ts
import { NextRequest, NextResponse } from "next/server";
import OpenAI from "openai";
// 初始化 OpenAI 客户端
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
export async function POST(request: NextRequest) {
try {
// 1. 从请求体中获取用户的三个任务
const { tasks } = await request.json();
if (!tasks || tasks.length !== 3) {
return NextResponse.json(
{ error: "请提供三个任务" },
{ status: 400 }
);
}
// 2. 过滤掉空任务
const validTasks = tasks.filter((t: string) => t.trim() !== "");
if (validTasks.length === 0) {
return NextResponse.json(
{ error: "请至少填写一个任务" },
{ status: 400 }
);
}
// 3. 调用 OpenAI API 生成鼓励语
const completion = await openai.chat.completions.create({
model: "gpt-4o-mini", // 便宜且够用
messages: [
{
role: "system",
content: `你是一个温暖、积极的鼓励教练。
用户今天设定了以下任务,你需要:
1. 写一段简短的鼓励语(50字以内),语气温暖但不油腻
2. 给一个实用的建议,帮助用户高效完成今天的任务(50字以内)
请用 JSON 格式返回:{"message": "鼓励语", "tip": "建议"}`,
},
{
role: "user",
content: `我今天的任务是:${validTasks.join("、")}`,
},
],
temperature: 0.8, // 稍高一点,让回复更有趣
max_tokens: 300,
});
// 4. 解析 AI 的回复
const content = completion.choices[0].message.content || "";
let result;
try {
result = JSON.parse(content);
} catch {
// 如果 AI 返回的不是标准 JSON,做兜底处理
result = {
message: "今天也要加油!每完成一件小事,都是在向目标靠近 💪",
tip: "先从最简单的任务开始,完成一个就给自己一个小奖励 🎁",
};
}
return NextResponse.json(result);
} catch (error) {
console.error("AI motivate error:", error);
return NextResponse.json(
{ error: "生成鼓励失败,请稍后重试" },
{ status: 500 }
);
}
}
src/components/TaskInput.tsx
"use client";
interface TaskInputProps {
index: number; // 任务序号 (0, 1, 2)
value: string; // 任务内容
isCompleted: boolean; // 是否已完成
onChange: (value: string) => void;
onToggle: () => void;
}
export default function TaskInput({
index,
value,
isCompleted,
onChange,
onToggle,
}: TaskInputProps) {
return (
<div className="flex items-center gap-3 group">
{/* 复选框 */}
<button
onClick={onToggle}
className={`w-6 h-6 rounded-full border-2 flex items-center justify-center
transition-all duration-200 flex-shrink-0
${
isCompleted
? "bg-orange-500 border-orange-500 scale-110"
: "border-gray-300 dark:border-gray-600 hover:border-orange-400"
}`}
>
{isCompleted && (
<svg className="w-3.5 h-3.5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
)}
</button>
{/* 输入框 */}
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={`今天最重要的事 #${index + 1}`}
className={`flex-1 px-4 py-3 rounded-xl border transition-all duration-200
bg-white dark:bg-gray-800
border-gray-200 dark:border-gray-700
focus:outline-none focus:ring-2 focus:ring-orange-300 focus:border-transparent
placeholder:text-gray-400 dark:placeholder:text-gray-500
text-gray-800 dark:text-gray-100
${isCompleted ? "line-through text-gray-400 dark:text-gray-500" : ""}`}
/>
</div>
);
}
src/components/MotivationCard.tsx
"use client";
interface MotivationCardProps {
message: string;
tip: string;
}
export default function MotivationCard({ message, tip }: MotivationCardProps) {
return (
<div className="mt-6 p-5 rounded-2xl bg-gradient-to-br from-orange-50 to-amber-50
dark:from-orange-900/20 dark:to-amber-900/20
border border-orange-100 dark:border-orange-800/30
animate-fade-in">
{/* 鼓励语 */}
<p className="text-gray-800 dark:text-gray-100 text-base leading-relaxed">
💬 {message}
</p>
{/* 分隔线 */}
<div className="my-3 border-t border-orange-100 dark:border-orange-800/30" />
{/* 实用建议 */}
<p className="text-gray-600 dark:text-gray-300 text-sm leading-relaxed">
💡 {tip}
</p>
</div>
);
}
src/components/Stats.tsx
"use client";
interface StatsProps {
completedCount: number; // 今天已完成数
totalCount: number; // 今天总任务数
streak: number; // 连续完成天数
}
export default function Stats({ completedCount, totalCount, streak }: StatsProps) {
const allDone = completedCount === totalCount && totalCount > 0;
return (
<div className="flex items-center justify-between mb-8">
{/* 完成进度 */}
<div className="flex items-center gap-2">
<span className="text-2xl font-bold text-orange-500">
{completedCount}/{totalCount}
</span>
<span className="text-sm text-gray-500 dark:text-gray-400">已完成</span>
{allDone && <span className="text-2xl animate-bounce">🎉</span>}
</div>
{/* 连续天数 */}
{streak > 0 && (
<div className="flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400">
<span>🔥</span>
<span>连续 <strong className="text-orange-500">{streak}</strong> 天</span>
</div>
)}
</div>
);
}
src/components/ThemeToggle.tsx
"use client";
import { useState, useEffect } from "react";
export default function ThemeToggle() {
const [isDark, setIsDark] = useState(false);
// 从 localStorage 读取偏好,或跟随系统设置
useEffect(() => {
const saved = localStorage.getItem("theme");
if (saved === "dark" || (!saved && window.matchMedia("(prefers-color-scheme: dark)").matches)) {
setIsDark(true);
document.documentElement.classList.add("dark");
}
}, []);
const toggle = () => {
const next = !isDark;
setIsDark(next);
document.documentElement.classList.toggle("dark");
localStorage.setItem("theme", next ? "dark" : "light");
};
return (
<button
onClick={toggle}
className="p-2 rounded-lg text-xl hover:bg-gray-100 dark:hover:bg-gray-800 transition"
title={isDark ? "切换到亮色模式" : "切换到暗色模式"}
>
{isDark ? "☀️" : "🌙"}
</button>
);
}
src/app/page.tsx(主页面)
"use client";
import { useState, useEffect } from "react";
import { supabase } from "@/lib/supabase";
import { getUserId, getTodayString } from "@/lib/user";
import TaskInput from "@/components/TaskInput";
import MotivationCard from "@/components/MotivationCard";
import Stats from "@/components/Stats";
import ThemeToggle from "@/components/ThemeToggle";
interface Motivation {
message: string;
tip: string;
}
export default function Home() {
// 任务内容状态
const [tasks, setTasks] = useState<string[]>(["", "", ""]);
// 任务完成状态
const [completed, setCompleted] = useState<boolean[]>([false, false, false]);
// AI 鼓励内容
const [motivation, setMotivation] = useState<Motivation | null>(null);
// 各种加载状态
const [saving, setSaving] = useState(false);
const [loadingMotivation, setLoadingMotivation] = useState(false);
const [saved, setSaved] = useState(false);
// 连续天数
const [streak, setStreak] = useState(0);
// 页面初始加载
const [initialLoading, setInitialLoading] = useState(true);
// 页面加载时从数据库读取今天的任务
useEffect(() => {
async function loadTodayTasks() {
const userId = getUserId();
const today = getTodayString();
const { data, error } = await supabase
.from("tasks")
.select("*")
.eq("user_id", userId)
.gte("created_at", `${today}T00:00:00`)
.lt("created_at", `${today}T23:59:59`)
.order("position");
if (data && data.length > 0) {
const loadedTasks = ["", "", ""];
const loadedCompleted = [false, false, false];
data.forEach((row) => {
loadedTasks[row.position] = row.content;
loadedCompleted[row.position] = row.is_completed;
});
setTasks(loadedTasks);
setCompleted(loadedCompleted);
}
setInitialLoading(false);
}
loadTodayTasks();
}, []);
// 保存任务到数据库
const saveTasks = async () => {
const userId = getUserId();
setSaving(true);
setSaved(false);
// 先删除今天的旧数据
const today = getTodayString();
await supabase
.from("tasks")
.delete()
.eq("user_id", userId)
.gte("created_at", `${today}T00:00:00`)
.lt("created_at", `${today}T23:59:59`);
// 插入新数据(只保存非空任务)
const rows = tasks
.map((content, index) => ({
user_id: userId,
content,
is_completed: completed[index],
position: index,
}))
.filter((row) => row.content.trim() !== "");
if (rows.length > 0) {
await supabase.from("tasks").insert(rows);
}
setSaving(false);
setSaved(true);
setTimeout(() => setSaved(false), 2000); // 2秒后隐藏"已保存"提示
};
// 切换完成状态
const toggleComplete = async (index: number) => {
const newCompleted = [...completed];
newCompleted[index] = !newCompleted[index];
setCompleted(newCompleted);
// 实时同步到数据库
const userId = getUserId();
const today = getTodayString();
await supabase
.from("tasks")
.update({ is_completed: newCompleted[index] })
.eq("user_id", userId)
.eq("position", index)
.gte("created_at", `${today}T00:00:00`)
.lt("created_at", `${today}T23:59:59`);
};
// 获取 AI 鼓励
const getMotivation = async () => {
setLoadingMotivation(true);
setMotivation(null);
try {
const res = await fetch("/api/motivate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tasks }),
});
const data = await res.json();
if (res.ok) {
setMotivation(data);
} else {
setMotivation({
message: "今天也要加油!每完成一件小事,都是进步 💪",
tip: "先从最简单的任务开始吧 🚀",
});
}
} catch {
setMotivation({
message: "今天也要加油!每完成一件小事,都是进步 💪",
tip: "先从最简单的任务开始吧 🚀",
});
}
setLoadingMotivation(false);
};
// 计算完成数
const completedCount = completed.filter(Boolean).length;
const hasAnyTask = tasks.some((t) => t.trim() !== "");
// 骨架屏
if (initialLoading) {
return (
<main className="min-h-screen flex items-center justify-center bg-gradient-to-b from-orange-50 to-white dark:from-gray-900 dark:to-gray-950">
<div className="max-w-md w-full px-6 py-10 space-y-4">
<div className="h-8 bg-gray-200 dark:bg-gray-800 rounded-lg animate-pulse" />
<div className="h-4 bg-gray-200 dark:bg-gray-800 rounded-lg animate-pulse w-2/3 mx-auto" />
<div className="h-14 bg-gray-200 dark:bg-gray-800 rounded-xl animate-pulse mt-8" />
<div className="h-14 bg-gray-200 dark:bg-gray-800 rounded-xl animate-pulse" />
<div className="h-14 bg-gray-200 dark:bg-gray-800 rounded-xl animate-pulse" />
<div className="h-12 bg-gray-200 dark:bg-gray-800 rounded-xl animate-pulse mt-4" />
</div>
</main>
);
}
return (
<main className="min-h-screen bg-gradient-to-b from-orange-50 to-white dark:from-gray-900 dark:to-gray-950 transition-colors">
{/* 右上角:暗色模式切换 */}
<div className="absolute top-4 right-4">
<ThemeToggle />
</div>
<div className="max-w-md mx-auto px-6 py-12">
{/* 标题 */}
<h1 className="text-3xl font-bold text-center text-orange-600 dark:text-orange-400">
🎯 AI 每日聚焦助手
</h1>
<p className="text-center text-gray-500 dark:text-gray-400 mt-2 mb-8">
每天聚焦 3 件最重要的事
</p>
{/* 统计区域 */}
<Stats completedCount={completedCount} totalCount={3} streak={streak} />
{/* 任务输入区域 */}
<div className="space-y-3">
{tasks.map((task, index) => (
<TaskInput
key={index}
index={index}
value={task}
isCompleted={completed[index]}
onChange={(value) => {
const newTasks = [...tasks];
newTasks[index] = value;
setTasks(newTasks);
}}
onToggle={() => toggleComplete(index)}
/>
))}
</div>
{/* 保存按钮 */}
<button
onClick={saveTasks}
disabled={saving || !hasAnyTask}
className="w-full mt-6 py-3 rounded-xl font-medium transition-all duration-200
bg-orange-500 hover:bg-orange-600 active:scale-[0.98]
text-white disabled:opacity-50 disabled:cursor-not-allowed"
>
{saving ? "保存中..." : saved ? "已保存 ✓" : "保存今日聚焦"}
</button>
{/* AI 鼓励按钮 */}
<button
onClick={getMotivation}
disabled={loadingMotivation || !hasAnyTask}
className="w-full mt-3 py-3 rounded-xl font-medium transition-all duration-200
bg-white dark:bg-gray-800 border border-orange-200 dark:border-orange-800/30
text-orange-600 dark:text-orange-400
hover:bg-orange-50 dark:hover:bg-gray-700
disabled:opacity-50 disabled:cursor-not-allowed"
>
{loadingMotivation ? (
<span className="flex items-center justify-center gap-2">
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
正在思考...
</span>
) : (
"✨ 获取今日鼓励"
)}
</button>
{/* AI 鼓励内容 */}
{motivation && <MotivationCard message={motivation.message} tip={motivation.tip} />}
{/* 空状态引导 */}
{!hasAnyTask && (
<div className="mt-8 text-center">
<p className="text-4xl mb-3">📝 🌅 ✨</p>
<p className="text-gray-400 dark:text-gray-500 text-sm">
新的一天开始了!设定你今天最重要的 3 件事吧
</p>
</div>
)}
</div>
</main>
);
}
src/app/layout.tsx
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "AI 每日聚焦助手",
description: "每天聚焦 3 件最重要的事,获得 AI 鼓励",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="zh-CN" suppressHydrationWarning>
<body className={inter.className}>{children}</body>
</html>
);
}
tailwind.config.ts
import type { Config } from "tailwindcss";
const config: Config = {
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
darkMode: "class", // 用 class 切换暗色模式
theme: {
extend: {
animation: {
"fade-in": "fadeIn 0.3s ease-out",
},
keyframes: {
fadeIn: {
"0%": { opacity: "0", transform: "translateY(10px)" },
"100%": { opacity: "1", transform: "translateY(0)" },
},
},
},
},
plugins: [],
};
export default config;
常见问题 FAQ
Q: 启动报错 "NEXT_PUBLIC_SUPABASE_URL is undefined"
A: 检查你的 .env.local 文件是否在项目根目录(和 package.json 同级),并且修改环境变量后要重启开发服务器(Ctrl+C 然后 npm run dev)。
Q: Supabase 保存报错 403
A: 检查是否关闭了 Row Level Security。在 Supabase SQL Editor 运行:
ALTER TABLE tasks DISABLE ROW LEVEL SECURITY;
Q: OpenAI API 报错 401
A: API Key 有问题。检查:
.env.local里有没有多余的空格或引号- Key 是不是以
sk-开头 - 去 OpenAI 后台检查账户余额
Q: OpenAI API 报错 429
A: 请求太频繁了。开发阶段没问题,等几秒再试。如果持续报错,检查是不是在循环里调用了 API。
Q: 页面在手机上排版错乱
A: 检查 layout.tsx 里有没有设置 viewport:
export const viewport = {
width: "device-width",
initialScale: 1,
};
Q: 暗色模式不生效
A: 确保 tailwind.config.ts 里配置了 darkMode: "class",并且 ThemeToggle 组件正确地在 <html> 标签上切换了 dark class。
Q: Vercel 部署成功但页面空白
A: 最常见的原因是环境变量没设。去 Vercel 项目 Settings → Environment Variables 检查三个变量是否都配了。配好后重新部署(Deployments → 最新一次 → Redeploy)。
Q: 怎么看数据库里有什么数据?
A: Supabase 控制台 → Table Editor → 选 tasks 表,就能看到所有数据,还能直接编辑。
Q: 代码改了但浏览器没变化?
A: 试这几个:
- 硬刷新:Ctrl+Shift+R(Mac: Cmd+Shift+R)
- 清缓存:开发者工具 → Network → Disable cache 勾上
- 重启开发服务器
这就是完整的代码。 复制这些文件,配好环境变量,你就能跑起来一个完整的「AI 每日聚焦助手」。
如果你想扩展功能,可以继续加:
- 历史记录页面
- 每周统计图表
- 微信登录
- 每日提醒推送
这些就留给你当课后作业了 🚀