使用 Phoenix LiveView 构建 Instagram (8)
使用PETAL(Phoenix、Elixir、TailwindCSS、AlpineJS、LiveView)技术栈构建一个简化版的Instagram Web应用程序
在第 7 部分中,我们在顶部标题导航菜单中添加了搜索功能,在这部分中,我们将研究书签功能,并在以下内容向我们的主页添加新帖子时通知用户。您可以赶上Instagram 克隆 GitHub Repo。
当我们尝试创建未选择图像的新帖子时,让我们处理错误,为此,我们需要在内部的保存句柄函数中正确进行模式匹配lib/instagram_clone_web/live/post_live/new.ex
:
def handle_event("save", %{"post" => post_params}, socket) do post = PostUploader.put_image_url(socket, %Post{}) case Posts.create_post(post, post_params, socket.assigns.current_user) do {:ok, %{post: post}} -> # <- THIS LINE WAS UPDATED PostUploader.save(socket) {:noreply, socket |> put_flash(:info, "Post created successfully") |> push_redirect(to: Routes.user_profile_path(socket, :index, socket.assigns.current_user.username))} |> push_redirect(to: Routes.live_path(socket, InstagramCloneWeb.PostLive.Show, post.url_id))} {:error, :post, %Ecto.Changeset{} = changeset, %{}} -> # <- THIS LINE WAS UPDATED {:noreply, assign(socket, changeset: changeset)} end end
因为我们用来Ecto.Multi
更新用户的帖子计数并创建帖子,所以在结果中我们必须进行相应的模式匹配。
现在,在lib/instagram_clone_web/live/post_live/new.html.leex
第 23 行中添加一个 div 来显示错误:photo_url
:
<div class="flex justify-center"> <%= error_tag f, :photo_url, class: "text-red-700 block" %> </div>
每次创建新帖子时,我们都会使用 phoenix pubsub 向主页实时视图发送消息,这样我们就可以显示一个 div,单击该 div 将重新加载实时视图。里面lib/instagram_clone/posts.ex
添加以下内容:
@pubsub_topic "new_posts_added" def pubsub_topic, do: @pubsub_topic def subscribe do InstagramCloneWeb.Endpoint.subscribe(@pubsub_topic) end
在里面lib/instagram_clone_web/live/post_live/new.ex
让我们发送消息:
def handle_event("save", %{"post" => post_params}, socket) do post = PostUploader.put_image_url(socket, %Post{}) case Posts.create_post(post, post_params, socket.assigns.current_user) do {:ok, %{post: post}} -> # <- THIS LINE WAS UPDATED PostUploader.save(socket) send_msg(post) {:noreply, socket |> put_flash(:info, "Post created successfully") |> push_redirect(to: Routes.user_profile_path(socket, :index, socket.assigns.current_user.username))} |> push_redirect(to: Routes.live_path(socket, InstagramCloneWeb.PostLive.Show, post.url_id))} {:error, :post, %Ecto.Changeset{} = changeset, %{}} -> # <- THIS LINE WAS UPDATED {:noreply, assign(socket, changeset: changeset)} end end defp send_msg(post) do # Broadcast that new post was added InstagramCloneWeb.Endpoint.broadcast_from( self(), Posts.pubsub_topic, "new_post", %{ post: post } ) end
在里面lib/instagram_clone_web/live/page_live.ex
让我们处理将要发送的消息:
alias InstagramClone.Posts.Post @impl true def mount(_params, session, socket) do socket = assign_defaults(session, socket) if connected?(socket), do: Posts.subscribe {:ok, socket |> assign(page_title: "InstagraClone") |> assign(new_posts_added: false) |> assign(page: 1, per_page: 15), temporary_assigns: [user_feed: []]} end @impl true def handle_info(%{event: "new_post", payload: %{post: %Post{user_id: post_user_id}}}, socket) do if post_user_id in socket.assigns.following_list do {:noreply, socket |> assign(new_posts_added: true)} else {:noreply, socket} end end
在我们的 mount 函数中,我们订阅 pubsub 主题并分配页面标题,并new_posts_added
确定是否必须在模板中显示 div。在我们的例子中handle_info
,我们接收用户的消息和模式匹配以获取用户 ID,然后检查该用户 ID 是否在分配给套接字的当前用户的以下列表中,如果是,则设为new_posts_addedtrue
在我们下面的列表中。
在第 2 行中lib/instagram_clone_web/live/page_live.html.leex
添加以下内容:
<%= if @new_posts_added do %> <div class="flex justify-center w-3/5 sticky top-14"> <%= live_redirect to: Routes.page_path(@socket, :index), class: "user-profile-follow-btn" do %> <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 inline" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /> </svg> Load New Posts <% end %> </div> <% end %>
现在,当我们关注的用户在我们的主页上添加新帖子时,我们会收到通知。
帖子书签
转到终端,让我们创建一个架构来处理帖子书签:
mix phx.gen.schema Posts.Bookmarks posts_bookmarks user_id:references:users post_id:references:posts
在生成的迁移中:
defmodule InstagramClone.Repo.Migrations.CreatePostsBookmarks do use Ecto.Migration def change do create table(:posts_bookmarks) do add :user_id, references(:users, on_delete: :delete_all) add :post_id, references(:posts, on_delete: :delete_all) timestamps() end create index(:posts_bookmarks, [:user_id]) create index(:posts_bookmarks, [:post_id]) end end
里面lib/instagram_clone/posts/bookmarks.ex
:
defmodule InstagramClone.Posts.Bookmarks do use Ecto.Schema schema "posts_bookmarks" do belongs_to :user, InstagramClone.Accounts.User belongs_to :post, InstagramClone.Posts.Post timestamps() end end
里面lib/instagram_clone/accounts/user.ex和lib/instagram_clone/posts/post.ex
:
has_many :posts_bookmarks, InstagramClone.Posts.Bookmarks
更新lib/instagram_clone/posts.ex
如下:
defmodule InstagramClone.Posts do @moduledoc """ The Posts context. """ import Ecto.Query, warn: false alias InstagramClone.Repo alias InstagramClone.Posts.Post alias InstagramClone.Accounts.User alias InstagramClone.Comments.Comment alias InstagramClone.Likes.Like alias InstagramClone.Posts.Bookmarks @pubsub_topic "new_posts_added" def pubsub_topic, do: @pubsub_topic def subscribe do InstagramCloneWeb.Endpoint.subscribe(@pubsub_topic) end @doc """ Returns the list of posts. ## Examples iex> list_posts() [%Post{}, ...] """ def list_posts do Repo.all(Post) end @doc """ Returns the list of paginated posts of a given user id. ## Examples iex> list_user_posts(page: 1, per_page: 10, user_id: 1) [%{photo_url: "", url_id: ""}, ...] """ def list_profile_posts(page: page, per_page: per_page, user_id: user_id) do Post |> select([p], map(p, [:url_id, :photo_url])) |> where(user_id: ^user_id) |> limit(^per_page) |> offset(^((page - 1) * per_page)) |> order_by(desc: :id) |> Repo.all end def list_saved_profile_posts(page: page, per_page: per_page, user_id: user_id) do Bookmarks |> where(user_id: ^user_id) |> join(:inner, [b], p in assoc(b, :post)) |> select([b, p], %{url_id: p.url_id, photo_url: p.photo_url}) |> limit(^per_page) |> offset(^((page - 1) * per_page)) |> order_by(desc: :id) |> Repo.all end @doc """ Returns the list of paginated posts of a given user id And posts of following list of given user id With user and likes preloaded With 2 most recent comments preloaded with user and likes User, page, and per_page are given with the socket assigns ## Examples iex> get_accounts_feed(following_list, assigns) [%{photo_url: "", url_id: ""}, ...] """ def get_accounts_feed(following_list, assigns) do user = assigns.current_user page = assigns.page per_page = assigns.per_page query = from c in Comment, select: %{id: c.id, row_number: over(row_number(), :posts_partition)}, windows: [posts_partition: [partition_by: :post_id, order_by: [desc: :id]]] comments_query = from c in Comment, join: r in subquery(query), on: c.id == r.id and r.row_number <= 2 likes_query = Like |> select([l], l.user_id) bookmarks_query = Bookmarks |> select([b], b.user_id) Post |> where([p], p.user_id in ^following_list) |> or_where([p], p.user_id == ^user.id) |> limit(^per_page) |> offset(^((page - 1) * per_page)) |> order_by(desc: :id) |> preload([:user, posts_bookmarks: ^bookmarks_query, likes: ^likes_query, comments: ^{comments_query, [:user, likes: likes_query]}]) |> Repo.all() end def get_accounts_feed_total(following_list, assigns) do user = assigns.current_user Post |> where([p], p.user_id in ^following_list) |> or_where([p], p.user_id == ^user.id) |> select([p], count(p.id)) |> Repo.one() end @doc """ Gets a single post. Raises `Ecto.NoResultsError` if the Post does not exist. ## Examples iex> get_post!(123) %Post{} iex> get_post!(456) ** (Ecto.NoResultsError) """ def get_post!(id) do likes_query = Like |> select([l], l.user_id) bookmarks_query = Bookmarks |> select([b], b.user_id) Repo.get!(Post, id) |> Repo.preload([:user, posts_bookmarks: bookmarks_query, likes: likes_query]) end def get_post_feed!(id) do query = from c in Comment, select: %{id: c.id, row_number: over(row_number(), :posts_partition)}, windows: [posts_partition: [partition_by: :post_id, order_by: [desc: :id]]] comments_query = from c in Comment, join: r in subquery(query), on: c.id == r.id and r.row_number <= 2 likes_query = Like |> select([l], l.user_id) bookmarks_query = Bookmarks |> select([b], b.user_id) Post |> preload([:user, posts_bookmarks: ^bookmarks_query, likes: ^likes_query, comments: ^{comments_query, [:user, likes: likes_query]}]) |> Repo.get!(id) end def get_post_by_url!(id) do likes_query = Like |> select([l], l.user_id) bookmarks_query = Bookmarks |> select([b], b.user_id) Repo.get_by!(Post, url_id: id) |> Repo.preload([:user, posts_bookmarks: bookmarks_query, likes: likes_query]) end @doc """ Creates a post. ## Examples iex> create_post(%{field: value}) {:ok, %Post{}} iex> create_post(%{field: bad_value}) {:error, %Ecto.Changeset{}} """ def create_post(%Post{} = post, attrs \\ %{}, user) do post = Ecto.build_assoc(user, :posts, put_url_id(post)) changeset = Post.changeset(post, attrs) update_posts_count = from(u in User, where: u.id == ^user.id) Ecto.Multi.new() |> Ecto.Multi.update_all(:update_posts_count, update_posts_count, inc: [posts_count: 1]) |> Ecto.Multi.insert(:post, changeset) |> Repo.transaction() end # Generates a base64-encoding 8 bytes defp put_url_id(post) do url_id = Base.encode64(:crypto.strong_rand_bytes(8), padding: false) %Post{post | url_id: url_id} end @doc """ Updates a post. ## Examples iex> update_post(post, %{field: new_value}) {:ok, %Post{}} iex> update_post(post, %{field: bad_value}) {:error, %Ecto.Changeset{}} """ def update_post(%Post{} = post, attrs) do post |> Post.changeset(attrs) |> Repo.update() end @doc """ Deletes a post. ## Examples iex> delete_post(post) {:ok, %Post{}} iex> delete_post(post) {:error, %Ecto.Changeset{}} """ def delete_post(%Post{} = post) do Repo.delete(post) end @doc """ Returns an `%Ecto.Changeset{}` for tracking post changes. ## Examples iex> change_post(post) %Ecto.Changeset{data: %Post{}} """ def change_post(%Post{} = post, attrs \\ %{}) do Post.changeset(post, attrs) end # Returns nil if not found def bookmarked?(user_id, post_id) do Repo.get_by(Bookmarks, [user_id: user_id, post_id: post_id]) end def create_bookmark(user, post) do user = Ecto.build_assoc(user, :posts_bookmarks) post = Ecto.build_assoc(post, :posts_bookmarks, user) Repo.insert(post) end def unbookmark(bookmarked?) do Repo.delete(bookmarked?) end def count_user_saved(user) do Bookmarks |> where(user_id: ^user.id) |> select([b], count(b.id)) |> Repo.one end end
添加了以下功能:
list_saved_profile_posts/3
获取所有已分页的已保存帖子。bookmarked?/2
检查书签是否存在。create_bookmark/2
创建书签。unbookmark/1
删除书签。count_user_saved/1
获取给定用户的已保存帖子总数。
另外,对于所有获取帖子功能,我们正在预加载帖子书签列表,因此我们可以将该列表发送到书签组件以设置我们要用于该功能的按钮。
在里面lib/instagram_clone_web/live/post_live/
创建一个名为的文件bookmark_component.ex
并添加以下内容:
defmodule InstagramCloneWeb.PostLive.BookmarkComponent do use InstagramCloneWeb, :live_component alias InstagramClone.Posts @impl true def update(assigns, socket) do get_btn_status(socket, assigns) end @impl true def render(assigns) do ~L""" <button phx-target="<%= @myself %>" phx-click="toggle-status" class="h-8 w-8 ml-auto focus:outline-none"> <%= @icon %> </button> """ end @impl true def handle_event("toggle-status", _params, socket) do current_user = socket.assigns.current_user post = socket.assigns.post bookmarked? = Posts.bookmarked?(current_user.id, post.id) if bookmarked? do unbookmark(socket, bookmarked?) else bookmark(socket, current_user, post) end end defp unbookmark(socket, bookmarked?) do Posts.unbookmark(bookmarked?) {:noreply, socket |> assign(icon: bookmark_icon(socket.assigns))} end defp bookmark(socket, current_user, post) do Posts.create_bookmark(current_user, post) {:noreply, socket |> assign(icon: bookmarked_icon(socket.assigns))} end defp get_btn_status(socket, assigns) do if assigns.current_user.id in assigns.post.posts_bookmarks do get_socket_assigns(socket, assigns, bookmarked_icon(assigns)) else get_socket_assigns(socket, assigns, bookmark_icon(assigns)) end end defp get_socket_assigns(socket, assigns, icon) do {:ok, socket |> assign(assigns) |> assign(icon: icon)} end defp bookmark_icon(assigns) do ~L""" <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" /> </svg> """ end defp bookmarked_icon(assigns) do ~L""" <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"> <path d="M5 4a2 2 0 012-2h6a2 2 0 012 2v14l-5-2.5L5 18V4z" /> </svg> """ end end
在第 94行将lib/instagram_clone_web/live/post_live/show.html.leex
带有书签图标的 div 更改为以下内容:
<%= if @current_user do %> <%= live_component @socket, InstagramCloneWeb.PostLive.BookmarkComponent, id: @post.id, post: @post, current_user: @current_user %> <% else %> <%= link to: Routes.user_session_path(@socket, :new), class: "w-8 h-8 ml-auto focus:outline-none" do %> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" /> </svg> <% end %> <% end %>
在第 41 行内lib/instagram_clone_web/live/page_post_feed_component.html.leex
,将包含书签图标的 div 更改为以下内容:
<%= live_component @socket, InstagramCloneWeb.PostLive.BookmarkComponent, id: @post.id, post: @post, current_user: @current_user %>
在第 72 行内部lib/instagram_clone_web/router.ex
添加以下路由:
live "/:username/saved", UserLive.Profile, :saved
第 102 行内部lib/instagram_clone_web/live/header_nav_component.html.leex
:
<%= live_redirect to: Routes.user_profile_path(@socket, :saved, @current_user.username) do %> <li class="py-2 px-4 hover:bg-gray-50">Saved</li> <% end %>
更新lib/instagram_clone_web/live/user_live/profile.ex
如下:
defmodule InstagramCloneWeb.UserLive.Profile do use InstagramCloneWeb, :live_view alias InstagramClone.Accounts alias InstagramCloneWeb.UserLive.FollowComponent alias InstagramClone.Posts @impl true def mount(%{"username" => username}, session, socket) do socket = assign_defaults(session, socket) user = Accounts.profile(username) {:ok, socket |> assign(page: 1, per_page: 15) |> assign(user: user) |> assign(page_title: "#{user.full_name} (@#{user.username})"), temporary_assigns: [posts: []]} end defp assign_posts(socket) do socket |> assign(posts: Posts.list_profile_posts( page: socket.assigns.page, per_page: socket.assigns.per_page, user_id: socket.assigns.user.id ) ) end defp assign_saved_posts(socket) do socket |> assign(posts: Posts.list_saved_profile_posts( page: socket.assigns.page, per_page: socket.assigns.per_page, user_id: socket.assigns.user.id ) ) end @impl true def handle_event("load-more-profile-posts", _, socket) do {:noreply, socket |> load_posts} end defp load_posts(socket) do total_posts = get_total_posts_count(socket) page = socket.assigns.page per_page = socket.assigns.per_page total_pages = ceil(total_posts / per_page) if page == total_pages do socket else socket |> update(:page, &(&1 + 1)) |> get_posts() end end defp get_total_posts_count(socket) do if socket.assigns.saved_page? do Posts.count_user_saved(socket.assigns.user) else socket.assigns.user.posts_count end end defp get_posts(socket) do if socket.assigns.saved_page? do assign_saved_posts(socket) else assign_posts(socket) end end @impl true def handle_params(_params, _uri, socket) do {:noreply, apply_action(socket, socket.assigns.live_action)} end @impl true def handle_info({FollowComponent, :update_totals, updated_user}, socket) do {:noreply, apply_msg_action(socket, socket.assigns.live_action, updated_user)} end defp apply_msg_action(socket, :follow_component, updated_user) do socket |> assign(user: updated_user) end defp apply_msg_action(socket, _, _updated_user) do socket end defp apply_action(socket, :index) do selected_link_styles = "text-gray-600 border-t-2 border-black -mt-0.5" live_action = get_live_action(socket.assigns.user, socket.assigns.current_user) socket |> assign(selected_index: selected_link_styles) |> assign(selected_saved: "text-gray-400") |> assign(saved_page?: false) |> assign(live_action: live_action) |> show_saved_profile_link?() |> assign_posts() end defp apply_action(socket, :saved) do selected_link_styles = "text-gray-600 border-t-2 border-black -mt-0.5" socket |> assign(selected_index: "text-gray-400") |> assign(selected_saved: selected_link_styles) |> assign(live_action: :edit_profile) |> assign(saved_page?: true) |> show_saved_profile_link?() |> redirect_when_not_my_saved() |> assign_saved_posts() end defp apply_action(socket, :following) do following = Accounts.list_following(socket.assigns.user) socket |> assign(following: following) end defp apply_action(socket, :followers) do followers = Accounts.list_followers(socket.assigns.user) socket |> assign(followers: followers) end defp redirect_when_not_my_saved(socket) do username = socket.assigns.current_user.username if socket.assigns.my_saved? do socket else socket |> push_redirect(to: Routes.user_profile_path(socket, :index, username)) end end defp show_saved_profile_link?(socket) do user = socket.assigns.user current_user = socket.assigns.current_user if current_user && current_user.id == user.id do socket |> assign(my_saved?: true) else socket |> assign(my_saved?: false) end end defp get_live_action(user, current_user) do cond do current_user && current_user.id == user.id -> :edit_profile current_user -> :follow_component true -> :login_btn end end end
添加了以下功能:
assign_posts/1
获取并分配个人资料保存的帖子。apply_action(socket, :saved)
在保存路线页面时分配保存的帖子,并live_action
分配:edit_profile
以显示编辑个人资料按钮。redirect_when_not_my_saved/1
当尝试直接转到不属于当前用户的已保存配置文件时重定向。show_saved_profile_link?/1
指定my_saved?
当前用户是否拥有配置文件。get_total_posts_count/1
以确定我们必须获得的帖子总数。get_posts/1
以确定要获取哪些帖子。
我们不再在挂载函数中分配帖子,而是在索引和保存的操作中完成。此外,在这些函数中,我们分配链接样式,并saved_page?
确定当页脚中的钩子被触发时我们必须加载更多的帖子。
更新lib/instagram_clone_web/live/user_live/profile.html.leex
如下:
<%= if @live_action == :following do %> <%= live_modal @socket, InstagramCloneWeb.UserLive.Profile.FollowingComponent, width: "w-1/4", current_user: @current_user, following: @following, return_to: Routes.user_profile_path(@socket, :index, @user.username) %> <% end %> <%= if @live_action == :followers do %> <%= live_modal @socket, InstagramCloneWeb.UserLive.Profile.FollowersComponent, width: "w-1/4", current_user: @current_user, followers: @followers, return_to: Routes.user_profile_path(@socket, :index, @user.username) %> <% end %> <header class="flex justify-center px-10"> <!-- Profile Picture Section --> <section class="w-1/4"> <%= img_tag @user.avatar_url, class: "w-40 h-40 rounded-full object-cover object-center" %> </section> <!-- END Profile Picture Section --> <!-- Profile Details Section --> <section class="w-3/4"> <div class="flex px-3 pt-3"> <h1 class="truncate md:overflow-clip text-2xl md:text-2xl text-gray-500 mb-3"><%= @user.username %></h1> <span class="ml-11"> <%= if @live_action == :edit_profile do %> <%= live_patch "Edit Profile", to: Routes.live_path(@socket, InstagramCloneWeb.UserLive.Settings), class: "py-1 px-2 border-2 rounded font-semibold hover:bg-gray-50" %> <% end %> <%= if @live_action == :follow_component do %> <%= live_component @socket, InstagramCloneWeb.UserLive.FollowComponent, id: @user.id, user: @user, current_user: @current_user %> <% end %> <%= if @live_action == :login_btn do %> <%= link "Follow", to: Routes.user_session_path(@socket, :new), class: "user-profile-follow-btn" %> <% end %> </span> </div> <div> <ul class="flex p-3"> <li><b><%= @user.posts_count %></b> Posts</li> <%= live_patch to: Routes.user_profile_path(@socket, :followers, @user.username) do %> <li class="ml-11"><b><%= @user.followers_count %></b> Followers</li> <% end %> <%= live_patch to: Routes.user_profile_path(@socket, :following, @user.username) do %> <li class="ml-11"><b><%= @user.following_count %></b> Following</li> <% end %> </ul> </div> <div class="p-3"> <h2 class="text-md text-gray-600 font-bold"><%= @user.full_name %></h2> <%= if @user.bio do %> <p class="max-w-full break-words"><%= @user.bio %></p> <% end %> <%= if @user.website do %> <%= link display_website_uri(@user.website), to: @user.website, target: "_blank", rel: "noreferrer", class: "text-blue-700" %> <% end %> </div> </section> <!-- END Profile Details Section --> </header> <section class="border-t-2 mt-5"> <ul class="flex justify-center text-center space-x-20"> <%= live_redirect to: Routes.user_profile_path(@socket, :index, @user.username) do %> <li class="pt-4 px-1 text-sm <%= @selected_index %>"> POSTS </li> <% end %> <li class="pt-4 px-1 text-sm text-gray-400"> IGTV </li> <%= if @my_saved? do %> <%= live_redirect to: Routes.user_profile_path(@socket, :saved, @user.username) do %> <li class="pt-4 px-1 text-sm <%= @selected_saved %>"> SAVED </li> <% end %> <% end %> <li class="pt-4 px-1 text-sm text-gray-400"> TAGGED </li> </ul> </section> <!-- Gallery Grid --> <div id="posts" phx-update="append" class="mt-9 grid gap-8 grid-cols-3"> <%= for post <- @posts do %> <%= live_redirect img_tag(post.photo_url, class: "object-cover h-80 w-full"), id: post.url_id, to: Routes.live_path(@socket, InstagramCloneWeb.PostLive.Show, post.url_id) %> <% end %> </div> <div id="profile-posts-footer" class="flex justify-center" phx-hook="ProfilePostsScroll"> <svg class="animate-spin mr-3 h-8 w-8 text-gray-300" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> </svg> Loading... </div>
添加了帖子和保存的链接,仅当当前用户拥有该配置文件时才会显示保存的链接,并且我们在加载更多页脚中添加了一个加载图标。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 使用C#创建一个MCP客户端
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· ollama系列1:轻松3步本地部署deepseek,普通电脑可用
· 按钮权限的设计及实现