发布于 2026-01-06 1 阅读
0

使用 React 和 Supabase 构建自定义调度器 简介 技术栈 应用开发 Supabase Edge 函数 Cron 作业 应用运行 其他功能 结论 由 Mux 呈现的 DEV's Worldwide Show and Tell Challenge:展示你的项目!

使用 React 和 Supabase 构建自定义调度器

介绍

技术栈

正在开发应用程序

苏帕巴

边缘函数

定时任务

应用程序的工作原理

附加功能

结论

由 Mux 赞助的 DEV 全球展示挑战赛:展示你的项目!

介绍

调度是现代应用程序的关键功能之一。它使我们能够运行可自动化的周期性任务,例如发送提醒、安排帖子发布、更新数据或自动化工作流程。

因此,在本文中,我们将构建一个调度器,用于在 dev.to 上发布文章。虽然 dev.to 本身具有调度功能,但我们将以我们自己的方式实现这些功能,这种方式可以用于构建任何类型的调度应用程序。

那么,我们开始吧。

技术栈

我们将使用以下技术栈:

  • React:我们将使用 React,特别是 ViteJS 与 React 结合来构建前端。
  • Supabase:它提供了一套完整的应用程序构建解决方案,包括数据库、身份验证、存储、边缘计算等诸多功能。我们将使用 Supabase 的以下功能:
    • 数据库:用于存储文章信息和日程安排时间。
    • 定时任务:用于定期运行以调用 Edge 函数
    • 边缘函数:此函数会检查是否有文章的预定发布时间与当前时间一致。如果一致,则会发布该文章。

这足以轻松构建一个调度程序应用程序。

正在开发应用程序

我们来讨论一下这个应用程序的工作原理,这样就很容易理解它的运行流程了。以下是流程图:

  1. 通过前端向数据库添加文章。
  2. Cron 作业将每分钟运行一次,以调用边缘函数。
  3. 系统将执行一个边缘函数,检查当前时间是否为已安排的文章发布时间。如果存在已安排的文章,则会发布该文章。
  4. 文章数据将在文章表格中更新。# 构建前端

最近,前端开发领域涌现出大量生成式人工智能工具,开发活动也随之减少。我们将要使用的其中一款人工智能工具是bolt.new。为什么选择 bolt.new 呢?因为它能够生成完整的 React 应用,包括所有依赖项和配置,例如 Tailwind CSS。您可以直接使用 StackBlitz 编辑文章并部署应用。如有需要,您还可以下载代码在本地运行。更棒的是,它与 Supabase 集成得非常好,因此您可以利用 Supabase 集成生成可运行的 React 应用。

我用它生成了首页。以下是所有页面。

App.tsx

这将处理用于显示组件和提供登录页面的页面。

    function App() {
      const [posts, setPosts] = useState<ScheduledPost[]>([]);
      const handleSchedulePost = async (data: CreatePostData) => {
        // In a real app, this would make an API call to your edge function
        const newPost: ScheduledPost = {
          content: data.content,
          scheduled_time: data.scheduledTime,
          status: 'pending',
          title: data.title,
          tags: data.tags
        };
        const { error } = await supabase
      .from('scheduled_posts')
      .insert(newPost)
      if (error){
        alert(`Erorr: ${error}`)
        return
      }
        // setPosts((prev) => [...prev, newPost]);
      };
      const fetchScheduedPost = async () => {
        const { data, error } = await supabase
      .from('scheduled_posts')
      .select()
      if(error){
        alert(`Erorr Fetching Data: ${error}`)
        return
      }
      setPosts(data)
      } 
      useEffect(() => {
        fetchScheduedPost()
      },[])
      return (
        <div className="min-h-screen bg-gray-50">
          <header className="bg-white shadow-sm">
            <div className="max-w-4xl mx-auto px-4 py-4">
              <div className="flex items-center gap-2">
                <Newspaper className="h-8 w-8 text-blue-500" />
                <h1 className="text-xl font-bold text-gray-900">Dev.to Post Scheduler</h1>
              </div>
            </div>
          </header>
          <main className="max-w-4xl mx-auto px-4 py-8">
            <div className="grid gap-8 md:grid-cols-2">
              <div>
                <h2 className="text-xl font-semibold text-gray-800 mb-4">Schedule New Post</h2>
                <PostForm onSubmit={handleSchedulePost} />
              </div>
              <div>
                <ScheduledPosts posts={posts} />
              </div>
            </div>
          </main>
        </div>
      );
    }
    export default App;
Enter fullscreen mode Exit fullscreen mode

SchudledPost.tsx

此处显示已安排的文章。

    const StatusIcon = ({ status }: { status: ScheduledPost['status'] }) => {
      switch (status) {
        case 'posted':
          return <CheckCircle className="h-5 w-5 text-green-500" />;
        case 'failed':
          return <XCircle className="h-5 w-5 text-red-500" />;
        default:
          return <Clock3 className="h-5 w-5 text-yellow-500" />;
      }
    };
    export function ScheduledPosts({ posts }: ScheduledPostsProps) {
      return (
        <div className="space-y-4">
          <h2 className="text-xl font-semibold text-gray-800">Scheduled Posts</h2>
          {posts.length === 0 ? (
            <p className="text-gray-500 text-center py-8">No scheduled posts yet</p>
          ) : (
            <div className="space-y-4">
              {posts.map((post, index) => (
                <div
                  key={index}
                  className="bg-white p-4 rounded-lg shadow-md border border-gray-100"
                >
                  <div className="flex items-start justify-between">
                    <div className="flex-1">
                      <p className="text-gray-800 mb-2">{post.title}</p>
                      <div className="flex items-center gap-4 text-sm text-gray-500">
                        <div className="flex items-center gap-1">
                          <Calendar className="h-4 w-4" />
                          {new Date(post.scheduled_time).toLocaleDateString()}
                        </div>
                        <div className="flex items-center gap-1">
                          <Clock className="h-4 w-4" />
                          {new Date(post.scheduled_time).toLocaleTimeString()}
                        </div>
                      </div>
                    </div>
                    <StatusIcon status={post.status} />
                  </div>
                </div>
              ))}
            </div>
          )}
        </div>
      );
    }
Enter fullscreen mode Exit fullscreen mode

PostForm.tsx

这将处理用户可以提供有关文章信息的表单。

    export function PostForm({ onSubmit }: PostFormProps) {
      const [content, setContent] = useState('');
      const [title, setTitle] = useState('');
      const [tags, setTags] = useState<string[]>(['javascript', 'react']);
      const [scheduledTime, setScheduledTime] = useState('');
      const handleSubmit = async (e: React.FormEvent) => {
        e.preventDefault();
        onSubmit({ content, title, scheduledTime, tags });
        setContent('');
        setTitle('');
        setScheduledTime('');
        setTags([]);
      };
      const handleTagChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
        const selectedOptions = Array.from(e.target.selectedOptions);
        const selectedTags = selectedOptions.map(option => option.value);
        if(tags.length<4){
    setTags(prevTags => {
          const newTags = selectedTags.filter(tag => !prevTags.includes(tag));
          return [...prevTags, ...newTags];
        });
        }

      };
      const removeTag = (tagToRemove: string) => {
        setTags(tags.filter(tag => tag !== tagToRemove));
      };
      return (
        <form onSubmit={handleSubmit} className="space-y-4 bg-white p-6 rounded-lg shadow-md">
          <div>
            <label htmlFor="title" className="block text-sm font-medium text-gray-700 mb-2">
              Post Title
            </label>
            <input
              type="text"
              id="title"
              value={title}
              onChange={(e) => setTitle(e.target.value)}
              className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
              placeholder="Title of the post"
              required
            />
          </div>
          <div>
            <label htmlFor="content" className="block text-sm font-medium text-gray-700 mb-2">
              Post Content
            </label>
            <textarea
              id="content"
              value={content}
              onChange={(e) => setContent(e.target.value)}
              className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
              rows={4}
              maxLength={280}
              placeholder="What's happening?"
              required
            />
          </div>
          <div>
            <label htmlFor="scheduledTime" className="block text-sm font-medium text-gray-700 mb-2">
              Schedule Time
            </label>
            <div className="relative">
              <input
                type="datetime-local"
                id="scheduledTime"
                value={scheduledTime}
                onChange={(e) => setScheduledTime(e.target.value)}
                className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent pl-10"
                required
              />
              <Calendar className="absolute left-3 top-3.5 h-5 w-5 text-gray-400" />
            </div>
          </div>
          <div>
            <label htmlFor="tags" className="block text-sm font-medium text-gray-700 mb-2">
              Tags
            </label>
            <div className="flex flex-wrap gap-2">
              {tags.map((tag, index) => (
                <span
                  key={index}
                  className="inline-flex items-center px-2 py-1 bg-blue-100 text-blue-700 rounded-full text-xs"
                >
                  {tag}
                  <button
                    type="button"
                    className="ml-1 text-gray-500 hover:text-gray-700"
                    onClick={() => removeTag(tag)}
                  >
                    x
                  </button>
                </span>
              ))}
              <select
                id="tags"
                value={tags}
                onChange={handleTagChange}
                multiple
                className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
                size={4}
                required
              >
                {tagOptions.map((tag) => (
                  <option key={tag.value} value={tag.value}>
                    {tag.label}
                  </option>
                ))}
              </select>
            </div>
            <div className="text-sm text-gray-500 mt-1">
              Select up to 4 tags
            </div>
          </div>
          <button
            type="submit"
            className="w-full bg-blue-500 text-white py-3 px-4 rounded-lg hover:bg-blue-600 transition-colors flex items-center justify-center gap-2"
          >
            <Send className="h-5 w-5" />
            Schedule Post
          </button>
        </form>
      );
    }
Enter fullscreen mode Exit fullscreen mode

最后我会把全部代码以 GitHub 仓库的形式提供给大家。

现在,我们来看看Supbase集成。

苏帕巴

首先,如果您还没有 Supabase 账户,请创建一个。您可以参考这篇文章了解如何在 Supabase 上创建账户:使用 LangChain 和 Supabase 将 ChatGPT 与您自己的数据结合使用

创建表 scheduled_post。您可以使用以下 SQL 代码在 SQL 编辑器中运行以创建表,也可以使用表编辑器创建表。

    create table
      public.scheduled_posts (
        id serial not null,
        content text not null,
        scheduled_time timestamp with time zone not null,
        status text null default 'pending'::text,
        created_at timestamp without time zone null default now(),
        title character varying null,
        devto_article_id character varying null,
        posted_at character varying null,
        tags character varying[] null,
        error_message character varying null,
        constraint scheduled_posts_pkey primary key (id)
      ) tablespace pg_default;
    create index if not exists idx_scheduled_time_status on public.scheduled_posts using btree (scheduled_time, status) tablespace pg_default;
Enter fullscreen mode Exit fullscreen mode

边缘函数

边缘函数是服务器端的 TypeScript 函数,它们分布在全球边缘——靠近用户。它们可用于监听 Webhook 或将您的 Supabase 项目与Stripe 等第三方服务集成。边缘函数使用Deno开发

要在本地运行和部署边缘函数,您需要具备以下条件:

  • Supbase CLI:您可以按照指南在本地安装 CLI。只需使用 npm 和 npx 即可轻松完成。
  • Docker Desktop:从这里安装 Docker Desktop

因此,安装完成后,您可以使用前端代码目录或其他目录来创建 Supabase Edge 函数。

运行以下命令以启动一个 Superbase 项目:

    npx supabase init
Enter fullscreen mode Exit fullscreen mode

可以使用以下命令创建 Edge 函数

    supabase functions new xscheduler
Enter fullscreen mode Exit fullscreen mode

上述命令将在 supabase 目录下创建 functions/xscheduler 目录。您可以在该目录中找到 index.ts 文件。Edge 函数使用 Deno 环境。

以下代码用于计算边缘函数:

    import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
    import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
    // Ensure these are set in your environment variables
    const SUPABASE_URL = Deno.env.get("SUPABASE_URL") || "";
    const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") || "";
    const DEVTO_ACCESS_TOKEN = Deno.env.get("DEVTO_ACCESS_TOKEN") || ""; 
    // Initialize Supabase client
    const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, {
            auth: {
              autoRefreshToken: false,
              persistSession: false,
            },
          });
    // Function to post a new article to Dev.to
    async function postToDevTo(title: string, content: string) {
      const url = "https://dev.to/api/articles";

      try {
        const response = await fetch(url, {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
            "api-key": DEVTO_ACCESS_TOKEN,
          },
          body: JSON.stringify({ article: { title, body_markdown: content, published: true } })
        });
        if (response.ok) {
          const result = await response.json();
          console.log("Article posted successfully:", result);
          return {
            success: true,
            articleId: result.id,
          };
        } else {
          const errorBody = await response.text();
          console.error("Dev.to API Error:", errorBody);
          return {
            success: false,
            error: errorBody,
          };
        }
      } catch (error) {
        console.error("Error posting to Dev.to:", error);
        return {
          success: false,
          error: error.message,
        };
      }
    }
    // Serve an HTTP endpoint for testing
    serve(async (req) => {
      if (req.method !== "POST") {
        return new Response("Method Not Allowed", { status: 405 });
      }
      try {
        // Get the current timestamp rounded to the nearest minute
          const currentDate = new Date();
    const currentHour = currentDate.getHours();
    const currentMinute = currentDate.getMinutes();
    const currentDay = currentDate.getDate();
    const currentMonth = currentDate.getMonth() + 1; 
    const currentYear = currentDate.getFullYear();
    const currentDateTimeString = `${currentYear}-${currentMonth}-${currentDay} ${currentHour}:${currentMinute - 1}`;
    const nextDateTimeString = `${currentYear}-${currentMonth}-${currentDay} ${currentHour}:${currentMinute + 1}`;

        // Fetch articles scheduled for the current time from Supabase
        const { data: articles, error: fetchError } = await supabase
          .from("scheduled_posts")
          .select()
          .gt("scheduled_time", currentDateTimeString)
          .lt("scheduled_time", nextDateTimeString) // Check within the next minute
        if (fetchError) {
          console.error("Error fetching scheduled articles:", fetchError);
          return new Response(JSON.stringify({ message: "Error fetching scheduled articles" }), { status: 500 });
        }
        if (!articles || articles.length === 0) {
          return new Response(JSON.stringify({ message: "No articles scheduled for posting." }), { status: 404 });
        }
        // Post each article to Dev.to and update its status in Supabase
        for (const article of articles) {
          const result = await postToDevTo(article.title, article.content);
          if (result.success) {
            // Update the article status in Supabase
            const { error } = await supabase
              .from("scheduled_posts")
              .update({
                status: "posted",
                devto_article_id: result.articleId,
                posted_at: new Date().toISOString(),
              })
              .eq("id", article.id);
            if (error) {
              console.error("Failed to update article status in Supabase", error);
              return new Response(
                JSON.stringify({ message: "Failed to update article status", error: error.message }),
                { status: 500 }
              );
            }

            console.log(`Article ${article.id} posted successfully on Dev.to.`);
          } else {
            console.error(`Failed to post article ${article.id}:`, result.error);
            return new Response(
              JSON.stringify({ message: `Failed to post article ${article.id}`, error: result.error }),
              { status: 500 }
            );
          }
        }
        return new Response(
          JSON.stringify({ message: "All scheduled articles posted successfully." }),
          { status: 200 }
        );
      } catch (error) {
        console.error("Unexpected error:", error);
        return new Response(
          JSON.stringify({ message: "Internal Server Error", error: error.message }),
          { status: 500 }
        );
      }
    });
Enter fullscreen mode Exit fullscreen mode

SUPABASE_URL 和 SUPABASE_SERVICE_ROLE_KEY 等 ENV 参数会自动提供给您。DEVTO_ACCESS_TOKEN 参数您可以从此处生成,然后前往“项目设置”→“边缘函数”添加该令牌。此令牌将在 Deno 环境中可用。

您可以参考指南来部署所需的边缘功能。

定时任务

Supbase 最近更新了 Cron 作业功能。现在您可以使用控制面板创建 Cron 作业,以前您需要编写代码才能完成此操作。您可以创建可以运行以下内容的作业:

  • SQL 代码片段
  • 数据库功能
  • HTTP 请求
  • 下边缘函数

我们将使用边缘函数,您可以添加边缘函数的详细信息,例如名称和授权,并将匿名密钥作为 Bearer Token。

定时任务

应用程序的工作原理

现在我们已经创建了应用程序,接下来让我们看看它是如何运行的。使用以下命令运行前端:

    npm run dev
Enter fullscreen mode Exit fullscreen mode

前端

添加标题、内容、时间和标签等详细信息。添加完成后,点击“定时发布”。定时任务会在文章预定发布时间与当前时间匹配时每分钟运行一次,文章随即发布。

当时间范围匹配时,文章将发布在 dev.to 上。

在 dev.to 上发布

附加功能

使用上述方法,您可以为任何平台(例如 X、Instagram、LinkedIn 等)构建日程安排应用程序。您可以对其进行开发并添加以下功能:

  • 图片:使用 supabase 存储上传和获取缩略图图片。
  • 从 SQL 调用边缘函数:您可以通过从 SQL 代码片段或数据库函数调用边缘函数来进一步提高效率。这样,只有当文章与当前时间匹配时,才会调用边缘函数。

您可以在这里查看该项目在 GitHub 上的代码

结论

创建日程安排应用程序可以简化自动化任务,例如发布文章、发送提醒和管理工作流程。我们使用 React 作为前端,Supabase 作为后端,构建了一个可扩展的解决方案,该方案充分利用了数据库、定时任务和边缘函数。这种方法可以适应各种用例,从而实现高效的自动化。借助这些工具,您可以构建功能强大的、满足您需求的日程安排应用程序。

希望这篇文章能帮助你理解定时任务(cron job)。感谢阅读。

文章来源:https://dev.to/surajondev/building-a-custom-scheduler-using-react-and-supabase-859