跳到主要内容

9.5 完整代码参考

本节目标: 对照完整代码补齐遗漏,并理解每项安全措施解决什么问题。 预计时间: 2-3 小时。 检查点: 从空目录跑通项目,验证类型检查、构建、RLS、日期边界和每日 AI 限额。

下面是「AI 每日聚焦助手」的学习版完整代码。它包含身份验证、RLS 和基本的 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 客户端
│ └── auth.ts # 匿名登录与本地日期
├── .env.local # 环境变量(不要提交到 Git)
├── 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
# 仅供服务端扣减 AI 配额,绝不能加 NEXT_PUBLIC_ 前缀
SUPABASE_SERVICE_ROLE_KEY=your-service-role-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 UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
task_date DATE 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(),
UNIQUE (user_id, task_date, position)
);

ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;

CREATE POLICY "用户读取自己的任务" ON tasks FOR SELECT
USING (auth.uid() = user_id);

CREATE POLICY "用户创建自己的任务" ON tasks FOR INSERT
WITH CHECK (auth.uid() = user_id);

CREATE POLICY "用户更新自己的任务" ON tasks FOR UPDATE
USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id);

CREATE POLICY "用户删除自己的任务" ON tasks FOR DELETE
USING (auth.uid() = user_id);

-- AI 调用额度只允许服务端访问
CREATE TABLE IF NOT EXISTS ai_daily_usage (
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
usage_date DATE NOT NULL,
request_count INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (user_id, usage_date)
);

ALTER TABLE ai_daily_usage ENABLE ROW LEVEL SECURITY;

CREATE OR REPLACE FUNCTION consume_ai_quota(
p_user_id UUID,
p_limit INTEGER DEFAULT 10
) RETURNS BOOLEAN
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
current_count INTEGER;
BEGIN
INSERT INTO ai_daily_usage (user_id, usage_date, request_count)
VALUES (p_user_id, CURRENT_DATE, 1)
ON CONFLICT (user_id, usage_date) DO UPDATE
SET request_count = ai_daily_usage.request_count + 1
WHERE ai_daily_usage.request_count < p_limit
RETURNING request_count INTO current_count;

RETURN current_count IS NOT NULL;
END;
$$;

REVOKE ALL ON FUNCTION consume_ai_quota(UUID, INTEGER) FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION consume_ai_quota(UUID, INTEGER) TO service_role;

核心代码

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/auth.ts

import { supabase } from "./supabase";

export async function getCurrentUser() {
const { data: sessionData } = await supabase.auth.getSession();
if (sessionData.session?.user) return sessionData.session.user;

const { data, error } = await supabase.auth.signInAnonymously();
if (error || !data.user) {
throw new Error(error?.message || "匿名登录失败");
}
return data.user;
}

export function getLocalDate(date = new Date()): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}

src/app/api/motivate/route.ts

import { NextRequest, NextResponse } from "next/server";
import OpenAI from "openai";
import { createClient } from "@supabase/supabase-js";

export async function POST(request: NextRequest) {
try {
const token = request.headers.get("authorization")?.replace("Bearer ", "");
if (!token) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}

const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const authClient = createClient(supabaseUrl, anonKey);
const { data: { user }, error: authError } = await authClient.auth.getUser(token);
if (authError || !user) {
return NextResponse.json({ error: "登录状态无效" }, { status: 401 });
}

const { tasks } = await request.json();
if (!Array.isArray(tasks) || tasks.length !== 3) {
return NextResponse.json(
{ error: "请提供三个任务" },
{ status: 400 }
);
}

// 2. 过滤掉空任务
const validTasks = tasks
.filter((task): task is string => typeof task === "string")
.map((task) => task.trim())
.filter(Boolean);

if (validTasks.length !== 3 || validTasks.some((task) => task.length > 200)) {
return NextResponse.json(
{ error: "请填写三个任务,每项不超过 200 字" },
{ status: 400 }
);
}

const adminClient = createClient(supabaseUrl, serviceRoleKey, {
auth: { persistSession: false, autoRefreshToken: false },
});
const { data: allowed, error: quotaError } = await adminClient.rpc(
"consume_ai_quota",
{ p_user_id: user.id, p_limit: 10 }
);
if (quotaError) throw quotaError;
if (!allowed) {
return NextResponse.json(
{ error: "今天的 AI 使用次数已达上限" },
{ status: 429 }
);
}

const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
const completion = await openai.chat.completions.create({
model: process.env.OPENAI_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,
response_format: { type: "json_object" },
});

const content = completion.choices[0].message.content || "";
let result;

try {
result = JSON.parse(content);
} catch {
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
type="button"
onClick={onToggle}
aria-label={`标记任务 ${index + 1}${isCompleted ? "未完成" : "已完成"}`}
aria-pressed={isCompleted}
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
type="button"
onClick={toggle}
className="p-2 rounded-lg text-xl hover:bg-gray-100 dark:hover:bg-gray-800 transition"
aria-label={isDark ? "切换到亮色模式" : "切换到暗色模式"}
>
{isDark ? "☀️" : "🌙"}
</button>
);
}

src/app/page.tsx(主页面)

"use client";

import { useState, useEffect } from "react";
import { supabase } from "@/lib/supabase";
import { getCurrentUser, getLocalDate } from "@/lib/auth";
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;
}

interface TaskRow {
task_date: string;
is_completed: boolean;
}

function calculateStreak(rows: TaskRow[]): number {
const completedByDate = new Map<string, number>();
rows.forEach((row) => {
if (row.is_completed) {
completedByDate.set(
row.task_date,
(completedByDate.get(row.task_date) || 0) + 1
);
}
});

const cursor = new Date();
if ((completedByDate.get(getLocalDate(cursor)) || 0) < 3) {
cursor.setDate(cursor.getDate() - 1);
}

let count = 0;
while ((completedByDate.get(getLocalDate(cursor)) || 0) >= 3) {
count += 1;
cursor.setDate(cursor.getDate() - 1);
}
return count;
}

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);
const [errorMessage, setErrorMessage] = useState("");

async function refreshStreak(userId: string) {
const since = new Date();
since.setDate(since.getDate() - 90);
const { data, error } = await supabase
.from("tasks")
.select("task_date,is_completed")
.eq("user_id", userId)
.gte("task_date", getLocalDate(since));
if (error) throw error;
setStreak(calculateStreak(data));
}

// 页面加载时从数据库读取今天的任务
useEffect(() => {
async function loadTodayTasks() {
try {
const user = await getCurrentUser();
const today = getLocalDate();

const { data, error } = await supabase
.from("tasks")
.select("*")
.eq("user_id", user.id)
.eq("task_date", today)
.order("position");
if (error) throw error;

if (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);
}

await refreshStreak(user.id);
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : "加载任务失败");
} finally {
setInitialLoading(false);
}
}

loadTodayTasks();
}, []);

// 保存任务到数据库
const saveTasks = async () => {
setSaving(true);
setSaved(false);
setErrorMessage("");

try {
const user = await getCurrentUser();
const today = getLocalDate();
const rows = tasks
.map((content, index) => ({
user_id: user.id,
task_date: today,
content: content.trim(),
is_completed: completed[index],
position: index,
}))
.filter((row) => row.content !== "");

if (rows.length > 0) {
const { error } = await supabase
.from("tasks")
.upsert(rows, { onConflict: "user_id,task_date,position" });
if (error) throw error;
}

const emptyPositions = tasks
.map((content, index) => content.trim() === "" ? index : -1)
.filter((index) => index >= 0);
if (emptyPositions.length > 0) {
const { error } = await supabase
.from("tasks")
.delete()
.eq("user_id", user.id)
.eq("task_date", today)
.in("position", emptyPositions);
if (error) throw error;
}

setSaved(true);
await refreshStreak(user.id);
setTimeout(() => setSaved(false), 2000);
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : "保存任务失败");
} finally {
setSaving(false);
}
};

// 切换完成状态
const toggleComplete = async (index: number) => {
if (!tasks[index].trim()) return;

const newCompleted = [...completed];
newCompleted[index] = !newCompleted[index];
setCompleted(newCompleted);

try {
const user = await getCurrentUser();
const { error } = await supabase.from("tasks").upsert({
user_id: user.id,
task_date: getLocalDate(),
content: tasks[index].trim(),
is_completed: newCompleted[index],
position: index,
}, { onConflict: "user_id,task_date,position" });
if (error) throw error;
await refreshStreak(user.id);
} catch (error) {
setCompleted(completed);
setErrorMessage(error instanceof Error ? error.message : "更新任务失败");
}
};

// 获取 AI 鼓励
const getMotivation = async () => {
setLoadingMotivation(true);
setMotivation(null);

try {
const { data: sessionData } = await supabase.auth.getSession();
const token = sessionData.session?.access_token;
if (!token) throw new Error("登录状态已失效,请刷新页面");

const res = await fetch("/api/motivate", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ tasks }),
});
const data = await res.json();

if (!res.ok) throw new Error(data.error || "生成鼓励失败");
setMotivation(data);
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : "生成鼓励失败");
}

setLoadingMotivation(false);
};

// 计算完成数
const completedCount = completed.filter(Boolean).length;
const hasThreeTasks = tasks.every((task) => task.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>

{errorMessage && (
<p role="alert" className="mb-4 text-sm text-red-600 dark:text-red-400">
{errorMessage}
</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 || !hasThreeTasks}
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 || !hasThreeTasks}
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} />}

{/* 空状态引导 */}
{!hasThreeTasks && (
<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>
);
}

src/app/globals.css

@import "tailwindcss";

@custom-variant dark (&:where(.dark, .dark *));

@theme {
--animate-fade-in: fadeIn 0.3s ease-out;
}

@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

常见问题 FAQ

Q: 启动报错 "NEXT_PUBLIC_SUPABASE_URL is undefined"

A: 检查你的 .env.local 文件是否在项目根目录(和 package.json 同级),并且修改环境变量后要重启开发服务器(Ctrl+C 然后 npm run dev)。

Q: Supabase 保存报错 403

A: 先在 Supabase Dashboard 的 Authentication 设置中启用 Anonymous Sign-Ins,再检查浏览器中是否存在 Session。不要关闭 RLS;重点确认策略使用了 auth.uid() = user_id,并且写入的 user_id 来自当前登录用户。

Q: OpenAI API 报错 401

A: API Key 有问题。检查:

  1. .env.local 里有没有多余的空格或引号
  2. Key 是不是以 sk- 开头
  3. 去 OpenAI 后台检查账户余额

Q: OpenAI API 报错 429

A: 可能是本项目设置的每日 10 次限额,也可能是 OpenAI 账户限额。先看接口返回的信息,再检查 Supabase 的 ai_daily_usage 表和 OpenAI 控制台。不要通过删除限流逻辑解决公开环境中的 429。

Q: 页面在手机上排版错乱

A: 检查 layout.tsx 里有没有设置 viewport:

export const viewport = {
width: "device-width",
initialScale: 1,
};

Q: 暗色模式不生效

A: 确保 globals.css 中定义了 @custom-variant dark,并且 ThemeToggle 组件正确地在 <html> 标签上切换了 dark class。

Q: Vercel 部署成功但页面空白

A: 最常见的原因是环境变量没设。去 Vercel 项目 Settings → Environment Variables 检查三个变量是否都配了。配好后重新部署(Deployments → 最新一次 → Redeploy)。

Q: 怎么看数据库里有什么数据?

A: Supabase 控制台 → Table Editor → 选 tasks 表,就能看到所有数据,还能直接编辑。

Q: 代码改了但浏览器没变化?

A: 试这几个:

  1. 硬刷新:Ctrl+Shift+R(Mac: Cmd+Shift+R)
  2. 清缓存:开发者工具 → Network → Disable cache 勾上
  3. 重启开发服务器

这就是学习版完整代码。 配好环境变量并启用 Anonymous Sign-Ins 后,你可以跑通「AI 每日聚焦助手」。公开运营前仍要补充错误监控、数据库备份和滥用告警,不能把教学示例直接当成生产模板。

如果你想扩展功能,可以继续加:

  • 历史记录页面
  • 每周统计图表
  • 微信登录
  • 每日提醒推送

这些就留给你当课后作业了 🚀