为了账号安全,请及时绑定邮箱和手机立即绑定

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

简介。

调度是现代应用程序的关键功能之一,它可以帮助我们自动化诸如发送提醒、更新数据、安排帖子或自动化工作流等定期任务。

所以,在这篇文章中,我们将构建一个调度器在 dev.to 上发布文章。尽管 dev.to 已经具备了调度功能,但我们打算以我们自己的方式来实现,这种方式可以用来构建任何类型的调度器。

那我们,咱们开始吧。

技术组合

我们将使用如下技术堆栈:

  • React: 我们将使用ViteJS配合React来构建前端。
  • Supabase: 它提供了一站式解决方案来构建应用程序。它包括数据库、认证、存储、边缘函数等。我们将使用Supabase的以下功能:

  • 数据库:用于存储文章信息和预定发布时间。

  • 定时任务:定期运行来触发边缘函数。

  • 边缘函数:会检查是否有文章的预定发布时间与当前时间一致。如果匹配,它将发布文章。

这将足够轻松构建一个调度应用。

应用程序开发

咱们来聊聊应用程序的工作原理,这使得理解应用程序的流程变得很简单。下面一一说明流程:

  1. 通过前端将文章添加到数据库中。
  2. Cron作业会每分钟运行一次来调用边缘函数。
  3. 一个边缘函数会被执行来检查当前时间,如果有计划发布的文章,就发布该文章。
  4. 文章发布表中的文章数据会被更新。 # 构建前端部分

最近前端开发变得比较平静,有很多生成AI。我们打算使用一种名为bolt.new的AI。为什么选择bolt.new?它可以生成带有所有依赖项和配置(如Tailwind CSS)的完整React应用程序。你可以在StackBlitz中直接编辑代码,也可以部署应用程序。需要的话,你还可以下载代码在本地运行。此外,它很好地集成了Supabase,因此,你可以生成一个集成了Supabase的完整React应用程序。

我已经用它制作了首页,下面就是所有页面。

App.tsx

这将负责显示组件并作为着陆页。

        function App() {
          const [posts, setPosts] = useState<ScheduledPost[]>([]);
          const handleSchedulePost = async (data: CreatePostData) => {
            // 在实际应用中,这会调用边缘函数的API
            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(`错误: ${error}`)
            return
          }
          };
          const fetchScheduledPost = async () => {
            const { data, error } = await supabase
          .from('scheduled_posts')
          .select()
          if(error){
            alert(`数据获取错误: ${error}`)
            return
          }
          setPosts(data)
          } 
          useEffect(() => {
            fetchScheduledPost()
          },[])
          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 发帖调度器</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">计划新文章</h2>
                    <PostForm onSubmit={handleSchedulePost} />
                  </div>
                  <div>
                    <ScheduledPosts posts={posts} />
                  </div>
                </div>
              </main>
            </div>
          );
        }
        export default App;

全屏模式 退出全屏

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">定时发布</h2>
              {posts.length === 0 ? (
                <p className="text-gray-500 text-center py-8">目前没有定时发布的内容</p>
              ) : (
                <div className="space-y-4">
                  {posts.map((发布, 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">{发布.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(发布.scheduled_time).toLocaleDateString()}
                            </div>
                            <div className="flex items-center gap-1">
                              <Clock className="h-4 w-4" />
                              {new Date(发布.scheduled_time).toLocaleTimeString()}
                            </div>
                          </div>
                        </div>
                        <StatusIcon status={发布.status} />
                      </div>
                    </div>
                  ))}
                </div>
              )}
            </div>
          );
        }

全屏模式 退出全屏

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">
                  标题
                </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="标题"
                  required
                />
              </div>
              <div>
                <label htmlFor="content" className="block text-sm font-medium text-gray-700 mb-2">
                  内容
                </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="你想发些什么?"
                  required
                />
              </div>
              <div>
                <label htmlFor="scheduledTime" className="block text-sm font-medium text-gray-700 mb-2">
                  预约时间
                </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">
                  标签
                </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">
                  最多可选4个标签
                </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" />
                提交发布
              </button>
            </form>
          );
        }

进入全屏模式,退出全屏

我将在最后提供整个代码的 GitHub 代码库链接。

接下来,我们来看看 Supbase 的整合。

Supabase平台

首先,如果你还没有的话,在 Supabase 上创建一个账户。你可以查看这篇文章了解如何在 Supabase 上创建账户,这篇文章教你如何用 LangChain 和 Supabase 结合自己的数据来使用 ChatGPT

创建名为 'scheduled_post' 的表。你可以使用如下 SQL 代码在 SQL 编辑器中运行来创建表,或者你可以直接通过表编辑器来完成表的创建。

        创建表
          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;
        创建索引 IF NOT EXISTS idx_scheduled_time_status ON public.scheduled_posts USING BTREE (scheduled_time, status) TABLESPACE pg_default;

全屏模式:全屏 退出全屏

边缘计算

Edge Functions 是服务器端的 TypeScript 函数,它们在全球边缘位置部署——靠近您的用户的位置。它们可用于监听 webhooks 或将您的 Supabase 项目与第三方服务(如 Stripe)集成。Edge Functions 是基于 Deno 开发的。

要本地运行以及部署边缘计算功能,你需要以下几点:

  • Supbase CLI:按照this指南,使用npm和npx在本地安装Supbase CLI。很简单。
  • Docker Desktop:从这里安装Docker Desktop。

所以安装完之后,你可以用前端代码目录或其他方式来创建 Supabase 边缘函数。

运行下面的命令来初始化一个Supabase项目:

运行初始化Supabase的命令:`npx supabase init`

进入全屏, 退出全屏

以下命令可用于创建该边缘函数

supabase functions new xscheduler
(此命令用于终端或命令行界面。functions指功能或函数,这里是创建一个新的函数)

进入全屏,退出全屏

上述命令会创建一个在 supabase 里的 functions/xscheduler 目录,在该目录中你可以找到 index.ts。边缘功能使用 Deno 环境。

以下是用于边缘处理的代码:

[此处应为代码段,未显示具体代码]
        import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
        import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
        // 确保这些环境变量已设置
        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") || ""; 
        // 初始化 Supabase 客户端
        const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, {
                auth: {
                  autoRefreshToken: false,
                  persistSession: false,
                },
              });
        // 发布新文章到 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("文章发布成功:", result);
              return {
                success: true,
                articleId: result.id,
              };
            } else {
              const errorBody = await response.text();
              console.error("Dev.to API 错误:", errorBody);
              return {
                success: false,
                error: errorBody,
              };
            }
          } catch (error) {
            console.error("发布到 Dev.to 时出错:", error);
            return {
              success: false,
              error: error.message,
            };
          }
        }
        // 提供一个用于测试的 HTTP 端点
        serve(async (req) => {
          if (req.method !== "POST") {
            return new Response("Method Not Allowed", { status: 405 });
          }
          try {
            // 获取当前时间并四舍五入到最近的分钟
              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}`;

            // 从 Supabase 获取即将在当前时间发布的安排
            const { data: articles, error: fetchError } = await supabase
              .from("scheduled_posts")
              .select()
              .gt("scheduled_time", currentDateTimeString)
              .lt("scheduled_time", nextDateTimeString) // 检查接下来一分钟内的文章
            if (fetchError) {
              console.error("获取安排的文章失败:", fetchError);
              return new Response(JSON.stringify({ message: "获取安排的文章失败" }), { status: 500 });
            }
            if (!articles || articles.length === 0) {
              return new Response(JSON.stringify({ message: "没有安排的文章可以发布。" }), { status: 404 });
            }
            // 将每篇文章发布到 Dev.to 并更新其状态至 Supabase
            for (const article of articles) {
              const result = await postToDevTo(article.title, article.content);
              if (result.success) {
                // 更新文章状态至 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("更新文章状态至 Supabase 失败:", error);
                  return new Response(
                    JSON.stringify({ message: "更新文章状态失败", error: error.message }),
                    { status: 500 }
                  );
                }

                console.log(`文章 ${article.id} 在 Dev.to 上已成功发布。`);
              } else {
                console.error(`发布文章 ${article.id} 失败:`, result.error);
                return new Response(
                  JSON.stringify({ message: `发布文章 ${article.id} 失败`, error: result.error }),
                  { status: 500 }
                );
              }
            }
            return new Response(
              JSON.stringify({ message: "所有安排的文章均已成功发布。" }),
              { status: 200 }
            );
          } catch (error) {
            console.error("意外错误:", error);
            return new Response(
              JSON.stringify({ message: "内部服务器错误", error: error.message }),
              { status: 500 }
            );
          }
        });

点击此处进入全屏模式 点击此处退出全屏模式

对于如 SUPABASE_URL 和 SUPABASE_SERVICE_ROLE_KEY 这样的环境变量(如 ENV),这些变量会自动为你准备好。对于 DEVTO_ACCESS_TOKEN,你可以从 这里 生成该令牌,并在项目设置 → 边缘函数中添加该令牌。该令牌将在 Deno 环境中生效。

你可以使用这个文档部署边缘功能,这是必需的。

Cron 任务

最近,Supbase 更新了定时任务功能。现在你可以在仪表板上创建定时任务了,之前你需要写代码才能实现这一功能。你可以创建一个可以运行以下任务的定时任务:

  • SQL 语句片段
  • 数据库函数
  • HTTP 请求
  • Supbase 边缘计算函数

我们将使用边缘功能。您可以使用Anon密钥作为Bearer Token来添加边缘功能的详细信息,如名称和授权。

Cron任务

应用如何运作

现在,我们已经创建了这个应用,让我们来看看它现在是怎么工作的。你可以使用以下命令来启动前端:

```npm start

或
```yarn start
        npm run dev

运行开发环境的命令。

进入全屏 退出全屏

前端页面的示例

添加标题、内容、时间、标签等详细信息。添加完成后,点击“安排发布”按钮。一旦文章的发布时间与当前时间一致,系统每分钟会检查一次发布时间是否到达,文章就会被发布出去。

当时间范围符合时,文章将会发布到dev.to。

发布在dev.to上的文章

更多功能

使用上述方法,您可以为任何应用(如X、Instagram、LinkedIn等)构建调度程序。您可以进一步开发该应用并添加如下功能:

  • 图片:使用 Supabase 存储来上传和获取用于缩略图的图片。
  • 在 SQL 中调用边缘函数,这样可以让系统更高效。只有文章与当前时间匹配时才会调用边缘函数。

你可以在这里查看该项目的代码这里

结论:

创建一个调度应用程序可以简化自动执行任务,例如发布文章、发送提醒和管理工作流程。我们使用React作为前端和Supabase作为后端,构建了一个可扩展的解决方案,利用数据库、定时任务和边缘计算功能。这种方案可以适应各种场景,实现高效的自动化。您可以根据需求构建强大的调度应用程序。

我希望这篇文章能让你理解定时任务。感谢您的阅读。

点击查看更多内容
TA 点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
  • 推荐
  • 评论
  • 收藏
  • 共同学习,写下你的评论
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
意见反馈 帮助中心 APP下载
官方微信

举报

0/150
提交
取消