You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Rails+React博客Post与Tag多对多更新时重复创建同名标签问题

问题描述

我正在使用Ruby on Rails和React搭建个人博客网站,在更新文章并添加新标签时遇到了问题。我的操作界面如下:
操作界面截图

当我在更新表单中新增名为"test"的标签时,预期最终标签列表为"work"和"test"两个标签,但实际运行结果如下:
实际运行结果截图

系统没有仅追加"test"标签,反而重复创建了已有的"work"和新的"test"标签,导致标签列表出现重复数据。后端数据库中的数据情况如下:
数据库数据截图

数据库中存在两条ID不同、名称均为"test"的独立标签记录。我的需求是创建标签时不生成同名重复标签,以实现按标签筛选文章的功能,当前因标签重复问题无法实现该需求。以下是我编写的相关代码:


前端代码(React)

Profile.jsx

作用:通过.map方法渲染多个PostContainer组件,承载文章列表

import { useEffect, useState } from 'react'
import PostContainer from './PostContainers'

export default function Profile({currentUser}){

    const [posts, setPosts] = useState([])
    
    function updatePost(postObj){
        const newPostArray = posts.map((post) => {
            if(post.id === postObj.id){
                return postObj
            }
            return post
        })
        setPosts(newPostArray)
    }
    
    function deletePost(postId){
        const newArray = posts.filter(post => post.id !== postId)
        setPosts(newArray)
    }

    const getPosts = () => {
        fetch(`/post/${currentUser.id}`)
        .then((r) =>{
          if(r.ok){
              r.json().then((posts)=>setPosts(posts))
          }
      })
    }
    
    useEffect(() => {
        getPosts()
    }, [])

    return(
        <div>
            <h1>Hello {currentUser.username}</h1>
            <h3>What's on your mind today?</h3>
            <div className='postWrapper'>
                <h2>Your Posts</h2>
                <div className='postLists'>
                    {posts && posts?.map((post)=>
                        <PostContainer  key={post.id} post={post} updatePost={updatePost} deletePost={deletePost} />
                    )}
                </div>
            </div>
        </div>
    )
}

postContainer.jsx

作用:对应界面中独立的文章卡片组件,包含更新、删除文章功能

import { useState } from 'react';

export default function PostContainer({ post, updatePost, deletePost }){
    const [isShown, setIsShown] = useState(false);
    const [title, setNewTitle] = useState("");
    const [content, setNewContent] = useState("");
    const [tags, setNewTags] = useState('');
    const userId = post.user_id;


    function handleDelete(e){
        e.preventDefault();
        console.log(post.id)
        fetch(`/post/${post.id}`,{
            method: "DELETE",
            headers: {
                'Content-Type':'application/json'
            },
        })
        .then((r) => r.json())
        .then(deletePost(post.id))
    }

    // 发送PATCH更新请求
    function handleSubmit(e){
        e.preventDefault();
        const patchedPost = {
            title,
            content,
            userId,
            tags_attributes: tags.split(',').map((el) => { return {name: el} })
        }

        fetch(`/post/${post.id}`, {
            method: "PATCH",
            headers: {
                "Content-Type": "application/json"
            },
            body: JSON.stringify(patchedPost)
        })
        .then(r => r.json())
        .then(x => updatePost(x))
        .catch(err => alert(err.message))
        setIsShown(false);
    }
 
    // 展示更新表单
    const handleUpdate = (e) =>{
        e.preventDefault();
        setIsShown(current => !current)
        // 预填充原有文章数据
        setNewTitle(post.title);
        setNewContent(post.content);
        setNewTags(post.tags.map((x)=>x.name));
    }

    return(
    <div>
        <div>
        {isShown && (
            <div>
                <form className='update-form' onSubmit={handleSubmit}>
                    <label>Title</label><br/>
                    <textarea type='text' value={title} onChange={(e)=>setNewTitle(e.target.value)}></textarea><br/>
                    <label>Content</label><br/>
                    <textarea type='text' value={content} onChange={(e)=>setNewContent(e.target.value)}></textarea><br/>
                    <label>Tags</label><br/>
                    <textarea type='text' value={tags} onChange={(e)=>setNewTags(e.target.value)}></textarea><br/>
                    <button type='submit'>Update</button>
                    <button onClick={(e)=>{e.preventDefault(); setIsShown(false)}}>Cancel</button>
                </form>
            </div>
        )}
        </div><br/>
        <div className='postContainer'>
            <h1>{post.title}</h1>
            <p className="users-posts-content">{post.content}</p>
            <p>Tags: {post.tags.map((x)=> x.name).join(',')}</p>
            <button onClick={(e)=>handleUpdate(e)}>Update</button> 
            <button onClick={(e)=>handleDelete(e)}>Delete Post</button>
        </div>
    </div>
    )
}

Post.jsx

作用:包含新建文章表单,未在问题截图中展示

import { useState } from "react";
import {useNavigate} from 'react-router-dom';

export default function Post({currentUser}){
    const[title, setTitle] = useState("");
    const[content, setContent] = useState("");
    const[tags, setTags] = useState("");
    const user_id = currentUser.id
    const navigate = useNavigate();

    function handleSubmit(e){
        e.preventDefault();       
        const newPost = {
            title,
            content,
            user_id,
            tags_attributes: tags.split(',').map((el) => { return {name: el} })
        }

        // 提交前校验所有字段已填写
        if(title === "" || content === "" || user_id === "" || tags === ""){
            alert("Please enter all the inputs!")
        }else(
            fetch(`/post`, {
                method: 'POST',
                headers: {"Content-Type": 'application/json'},
                body: JSON.stringify(newPost),   
            }).then((r) =>{
                if (r.ok){
                    r.json().then(navigate('/profile'))
                    alert("New post created!")
                }else{
                    alert("New post creation failed")
                }
            })
        )
        
    }
    return(
        <div className="post-form-container">
            <form className="post-form" onSubmit={handleSubmit}>
                <label>Title</label><br/>
                <input 
                    className='title-input' 
                    type='text' 
                    onChange={(e) => setTitle(e.target.value)}  
                    value={title}>
                </input><br/>

                <label>Content</label><br/>
                <textarea 
                    className="content-input" 
                    type='text' 
                    onChange={(e) => setContent(e.target.value)}  
                    value={content} 
                    placeholder="Start typing~">
                </textarea><br/>

                <label>Tags: seperated by commas</label><br/>
                <input 
                    className="tags-input" 
                    type='text' 
                    onChange={(e) => setTags(e.target.value)} 
                    value={tags}>
                </input><br/>   

                <button className="post-btn" type="submit">Submit</button>
            </form>
        </div>
    )
}

后端代码(Ruby on Rails)

posts_controller.rb

作用:处理文章的新增、更新、删除请求

class PostsController < ApplicationController
    before_action :authorized
    skip_before_action :authorized, only: [:index, :show, :create]
    

    def show
        posts = User.find(params[:id]).posts
        render json: posts, include: :User
    end

    def index      
        posts = Post.all
        render json: posts, include: :User
    end

    def create      
        user = User.find_by(id: session[:user_id])
        post = user.posts.create(post_params)
        if post.valid?
            render json: post, status: :created
        else
            render json: {error: post.errors.full_messages }, status: :unprocessable_entity
        end
      end
    
    def update      
        post = Post.find_by(id: params[:id])
        if post
            # 待实现:更新前对比标签数组,移除文章不存在的旧标签关联
            post.update(post_params)
            render json: post, status: :accepted
        else
            render json: {error: "Post not found"}, status: :not_found
        end
    end

    def destroy
        post = Post.find_by(id: params[:id])
        if post
            post.destroy
            
            render json: {}
        else
            render json: {error: "post not found"}, status: :not_found
        end
    end

    private

    def authorize
        return render json: { error: "Not authorized" }, status: :unauthorized unless session.include? :user_id
    end

    def post_params
        # params.require(:post).permit(:title, :content, tags_attributes: [:name])
        params.permit(:title, :content, tags_attributes: [:name])
    end 
end

Post.rb 模型

class Post < ApplicationRecord
    belongs_to :user
    has_many :post_tags, dependent: :destroy
    has_many :tags, through: :post_tags

    # 允许通过父级模型保存关联记录属性
    accepts_nested_attributes_for :tags
end

Tag.rb 模型

class Tag < ApplicationRecord
    has_many :post_tags
    has_many :posts, through: :post_tags
    
end

post_tags.rb 中间表模型

class PostTag < ApplicationRecord
    belongs_to :post
    belongs_to :tag
end

问题原因&修复方案

核心问题

  1. 标签表Tag没有对name字段加唯一约束,允许创建同名标签
  2. 直接用accepts_nested_attributes_for :tags传标签名时,Rails默认会新建标签记录,不会自动查找已存在的同名标签关联
  3. 更新文章时没有处理旧标签关联的逻辑,会把传入的所有标签都当成新记录创建

修复步骤

  1. 给标签表加唯一索引
    执行命令生成迁移文件:

    rails generate migration AddUniqueIndexToTagsName
    

    编辑生成的迁移文件:

    class AddUniqueIndexToTagsName < ActiveRecord::Migration[7.0]
      def change
        add_index :tags, :name, unique: true
      end
    end
    

    执行迁移:rails db:migrate

  2. 给Tag模型加名称唯一性校验,加上查找或创建同名标签的逻辑
    修改tag.rb:

    class Tag < ApplicationRecord
      has_many :post_tags
      has_many :posts, through: :post_tags
      
      validates :name, presence: true, uniqueness: true
    end
    

    修改Post.rb模型,重写标签属性赋值方法,自动复用已存在的标签:

    class Post < ApplicationRecord
      belongs_to :user
      has_many :post_tags, dependent: :destroy
      has_many :tags, through: :post_tags
    
      # 移除原来的accepts_nested_attributes_for :tags,自定义标签赋值逻辑
      def tags_attributes=(tag_attributes)
        # 清空当前文章的旧标签关联,支持删除不需要的标签
        self.post_tags.clear
        tag_attributes.each do |attr|
          # 去除标签名前后空格,过滤空值
          tag_name = attr[:name].strip
          next if tag_name.blank?
          # 查找已存在的同名标签,不存在则新建
          tag = Tag.find_or_create_by(name: tag_name)
          # 关联标签到当前文章,避免重复关联
          self.tags << tag unless self.tags.include?(tag)
        end
      end
    end
    
  3. 修正前端标签预填充的类型问题
    postContainer.jsx里预填充标签时,setNewTags接收的是数组,但textarea绑定的value是字符串,最好预填充时就转成逗号分隔字符串,避免异常:

    // 原代码
    // setNewTags(post.tags.map((x)=>x.name));
    // 修改为
    setNewTags(post.tags.map((x)=>x.name).join(','));
    

修改完成后重启Rails服务,再测试更新文章添加标签,就不会重复创建同名标签了。


内容的提问来源于stack exchange,提问作者Jeffrey

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.26 20:57:15