Site icon 134340號小行星

React 項目實戰-全球新聞發布管理系統

CNMS2 React 項目實戰-全球新聞發布管理系統

發覺自己好像已經很久沒有做實戰了,用下班之餘和周末時間邊看教學邊做出了一個小小的CRUD project,這篇文章不是用來教大家如何製作這樣一個 project,畢竟我也是看別人的教學影片做出來的,只是這個影片中使用到的第三方庫的版本都不是最新的,有些部分會出錯,所以這篇文章我會分享一些影片中和現在寫法不同的部分,並且順便教各位如何佈署 json-server 作為 API 及佈署網站到 netlify。

實戰教學:千锋前端-React全家桶_React项目全球新闻发布管理系统
DEMO:https://gnms.tk/ (登入帳號: admin 密碼: 123456)
本篇文章同步發表至 CSDN – React項目全球新聞發布管理系統- 新版問題解決方式整理及部署網站至Netlify

網站使用 React 框架,UI組件庫使用 antd,後端API部分使用 json-server 省去我們自己寫後端 code 的時間,最後網站會佈署至 netlify,API會佈署至 heroku。

網站結構及操作流程

網站結構

首頁
用戶管理
 - 用戶列表
權限管理
 - 角色列表
 - 權限列表
新聞管理
 - 撰寫新聞
 - 草稿箱
 - 新聞分類
審核管理
 - 審核新聞
 - 審核列表
發布管理
 - 待發布
 - 已上線
 - 已下線

操作流程

網站使用者:(權限包括: a.超級管理員, b.區域管理員, c.區域編輯)

遊客:(有點類似網站前台)

DEMO影片

專案結構

/src
 ./assets
 ./components
   ../news-manage
   ../publish-manage
   ../sandbox
   ../user-manage
 ./i18n
 ./router
 ./util
 ./views
   ../login
   ../news
   ../sandbox
     .../audit-manage
     .../news-manage
     .../home
     .../nopermission
     .../publish-manage
     .../right-manage
     .../user-manage

問題解決

P4

反向代理 setupProxy.js 改為:

// setupProxy.js
const { createProxyMiddleware } = require('http-proxy-middleware');

module.exports = function(app) {
  app.use(
    '/api',
    createProxyMiddleware({
      target: 'https://i.maoyan.com',
      changeOrigin: true,
    })
  );
};

App.js 中axios 請求地址改為:

// App.js
useEffect(() => {
    axios.get("/api/mmdb/movie/v3/list/hot.json?ct=%E7%B9%81%E6%98%8C%E5%8C%BA&ci=774&channelId=4")
      .then((res) => console.log(res.data));
  }, []);

因為我沒有看到視頻中用的 m.maoyan.com/ajax 那個地址,所以我自己抓了一個,反正是練習用的就不必那麼講究了,測試一下middleware能不能使就行。

P5

15 分處

HashRouter 裡面包 Switch 會出錯,請改為包 Routes (Routes 和Switch 的功用是一樣的,都能做到精準匹配)
Route的 component 屬性改為 element,並且element 中請使用 <> 包裹組件名稱

// IndexRouter.js
import React from "react";
import { HashRouter, Routes, Route } from "react-router-dom";
import Login from "../views/login/Login";
import NewsSandBox from "../views/newssandbox/NewsSandBox";

export default function IndexRouter () {
  return (
    <HashRouter>
      <Routes>
        <Route path="/login" element={<Login/>} />
        <Route path="/" element={<NewsSandBox/>} />
      </Routes>
    </HashRouter>
  );
};

20 分處重定向部分

For react-router-dom v6, simply replace Redirect with Navigate

所以我們需要將Redirect 改為Navigate,並且一樣使用的是 element 而不是render

<Route path="/*" element={localStorage.getItem("token") ?  <NewsSandBox/> : <Navigate to="/login"/>} />

P6

14 分處,一樣將所有Switch 改為Routes, component 改為element,Redirect 改為Navigate

//NewsSandBox.js
<Routes>
  <Route path="home" element={<Home />} />
  <Route path="user-manage/list" element={<UserList />} />
  <Route path="right-manage/role/list" element={<RoleList />} />
  <Route path="right-manage/right/list" element={<RightList />} />
  <Route path="/" element={<Navigate replace from="/" to="home"/>} />
  <Route path="/*" element={<NoPermission/>} />
</Routes>

並且要記住的是 Navigate 只能包裹在 Route 中,Routes中只能有 Route 或者Fragment

P11

使用withRouter 會報錯:

‘withRouter’ is not exported from ‘react-router-dom’.

因為V6 之後沒有 withRouter 了,所以直接改用 useNavigate 會更方便,完整的Code 我直接放出來吧:

import React from "react";
import { Layout, Menu } from "antd";
import { UserOutlined, HomeOutlined, CrownOutlined } from "@ant-design/icons";
import SubMenu from "antd/lib/menu/SubMenu";
import { useNavigate } from "react-router";

const { Sider } = Layout;

const menuList = [
  {
    key: "/home",
    title: "首页",
    icon: <HomeOutlined />,
  },
  {
    key: "/user-manage",
    title: "用户管理",
    icon: <UserOutlined />,
    children: [
      {
        key: "/user-manage/list",
        title: "用户列表",
        icon: <UserOutlined />,
      },
    ],
  },
  {
    key: "/right-manage",
    title: "权限管理",
    icon: <CrownOutlined />,
    children: [
      {
        key: "/right-manage/role/list",
        title: "角色列表",
        icon: <CrownOutlined />,
      },
      {
        key: "/right-manage/right/list",
        title: "权限列表",
        icon: <CrownOutlined />,
      },
    ],
  },
];

export default function SideMenu({ collapsed }) {
  let navigate = useNavigate();
  // Menu
  const renderMenu = (menuList) => {
    return menuList.map((item) => {
      if (item.children) {
        return (
          <SubMenu key={item.key} icon={item.icon} title={item.title}>
            {renderMenu(item.children)}
          </SubMenu>
        );
      }
      return (
        <Menu.Item
          key={item.key}
          icon={item.icon}
          onClick={() => navigate(item.key)}
        >
          {item.title}
        </Menu.Item>
      );
    });
  };

  return (
    <Sider trigger={null} collapsible collapsed={collapsed}>
      <div className="logo" />
      <Menu theme="dark" mode="inline" defaultSelectedKeys={["1"]}>
        {renderMenu(menuList)}
      </Menu>
    </Sider>
  );
}

P15

props.location.pathname 可以用useLocation 鉤子取代,與props.location 是一樣的用法:

import { useNavigate, useLocation } from "react-router";
// ...
let location = useLocation();
const selectKeys = [location.pathname]; // ex: ['/home']
const openKeys = ["/" + location.pathname.split("/")[1]];
// ...
<Menu theme="dark" mode="inline" selectedKeys={selectKeys} defaultOpenKeys={openKeys}>
  {renderMenu(menu)}
</Menu>

P17

直接寫 res.data[0].children = "" 的話一旦array 內容順序有所變更就會錯誤,因此建議透過遍歷的方式尋找children 長度為0 的元素,將它改為空字串。

useEffect(() => {
  axios.get("http://localhost:8000/rights?_embed=children")
  .then((res) => {
    res.data.forEach((item) => item.children?.length === 0 ? item.children = "" : item.children);
    setDataSource(res.data);
  })
}, []);

P18

個人認為刪除後頁面要會auto refresh,所以我改寫了一下,這樣只要刪除權限時就會修改refresh 的狀態,而refresh 狀態一變更就會重新call API 獲取數據。

// RightList.js
const [dataSource, setDataSource] = useState([]);
const [refresh, setRefresh] = useState(false);

useEffect(() => {
  axios.get("http://localhost:8000/rights?_embed=children").then((res) => {
    res.data.forEach((item) =>
      item.children?.length === 0 ? (item.children = "") : item.children
    );
    setDataSource(res.data);
  });
}, [refresh]);

const columns = [
    {
      title: "ID",
      dataIndex: "id",
    },
    {
      title: "权限名称",
      dataIndex: "title",
    },
    {
      title: "权限路径",
      dataIndex: "key",
      render: (key) => {
        return <Tag color="volcano">{key}</Tag>;
      },
    },
    {
      title: "操作",
      render: (item) => {
        return (
          <div>
            <Button
              danger
              shape="circle"
              icon={<DeleteOutlined />}
              style={{ marginRight: 10 }}
              onClick={() => confirmMethod(item)}
            />
            <Button type="primary" shape="circle" icon={<EditOutlined />} />
          </div>
        );
      },
    },
  ];
  
const confirmMethod = (item) => {
    confirm({
      title: "你确定要删除?",
      icon: <ExclamationCircleOutlined />,
      // content: "Some descriptions",
      onOk() {
        deleteMethod(item);
      },
      onCancel() {
        console.log("Cancel");
      },
    });
  };

const deleteMethod = (item) => {
  if (item.grade === 1) {
    axios.delete(`http://localhost:8000/rights/${item.id}`)
      .then(setRefresh)
      .catch((e) => console.log(e))
  } else {
    axios.delete(`http://localhost:8000/children/${item.id}`)
      .then(setRefresh)
      .catch((e) => console.log(e))
  }
}

P22

分配權限的寫法一樣是改成auto refresh,而不是改變狀態。之後同樣的處理我都不會再提了。

const [refresh, setRefresh] = useState(false);
// ...
useEffect(() => {
    axios
      .get("http://localhost:8000/roles")
      .then((res) => setDataSource(res.data))
      .catch((e) => console.log(e));
    axios
      .get("http://localhost:8000/rights?_embed=children")
      .then((res) => setRightList(res.data))
      .catch((e) => console.log(e));
  }, [refresh]);
  
const handleOk = () => {
    setIsModalVisible(false);
    axios.patch(`http://localhost:8000/roles/${currentId}`, {
      rights: currentRights
    })
      .then(setRefresh)
      .catch((e) => console.log(e))
  };

P29

一樣將Redirect 重定向改為使用 useNavigate

// components/sandbox/TopHeader.js
import { useNavigate } from "react-router";
// ...
let navigate = useNavigate();
// ...
<Menu.Item danger onClick={() => {
   localStorage.removeItem("token")
   navigate("/login");
}}>
 退出
</Menu.Item>

順便分享一個乾貨,瀏覽器的devTools (chrome預設為F12 ) – Application – Local Storage可以看到你存在本地的所有東西,也就是你使用 localStorage.setItem 所記錄下來的內容,你可以直接在這裡設置或清除token,也可以在console 寫代碼修改,都是可以的。

P30

這個粒子庫安裝指令:

npm i tsparticles --save

如果按照官方文檔使用 npm i react-particles-js 會報 Can't resolve 'tsparticles' in 'D:\...\...\node_modules\react-particles-js\cjs' 的錯誤。

P34

動態路由這部分因為Switch 改為Routes 且Route 裡面的component 變為element,組件帶入的方式也不一樣,因此LocalRouterMaparray 的value 要用<>包裹組件:

import React, { useState, useEffect } from "react";
import Home from "../../views/sandbox/home/Home";
import RightList from "../../views/sandbox/right-manage/RightList";
import RoleList from "../../views/sandbox/right-manage/RoleList";
import UserList from "../../views/sandbox/user-manage/UserList";
import NewsCategory from "../../views/sandbox/news-manage/NewsCategory";
import NewsAdd from "../../views/sandbox/news-manage/NewsAdd";
import NewsDraft from "../../views/sandbox/news-manage/NewsDraft";
import NoPermission from "../../views/sandbox/nopermission/NoPermission";
import Audit from "../../views/sandbox/audit-manage/Audit";
import AuditList from "../../views/sandbox/audit-manage/AuditList";
import Unpublished from "../../views/sandbox/publish-manage/Unpublished";
import Published from "../../views/sandbox/publish-manage/Published";
import Sunset from "../../views/sandbox/publish-manage/Sunset";
import { Routes, Route, Navigate } from "react-router-dom";
import axios from "axios";
const LocalRouterMap = {
  "/home": <Home/>,
  "/user-manage/list": <UserList/>,
  "/right-manage/role/list": <RoleList/>,
  "/right-manage/right/list": <RightList/>,
  "/news-manage/add": <NewsAdd/>,
  "/news-manage/draft": <NewsDraft/>,
  "/news-manage/category": <NewsCategory/>,
  "/audit-manage/audit": <Audit/>,
  "/audit-manage/list": <AuditList/>,
  "/publish-manage/unpublished": <Unpublished/>,
  "/publish-manage/published": <Published/>,
  "/publish-manage/sunset": <Sunset/>,
};

export default function NewsRouter() {
  const [backRouteList, setbackRouteList] = useState([]);

  useEffect(() => {
    Promise.all([
      axios.get("http://localhost:8000/rights"),
      axios.get("http://localhost:8000/children"),
    ]).then((res) => {
      setbackRouteList([...res[0].data, ...res[1].data]);
    });
  }, []);

  return (
    <Routes>
      {backRouteList.map((item) => (
        <Route
          path={item.key}
          key={item.key}
          element={LocalRouterMap[item.key]}
        />
      ))}
      <Route path="/" element={<Navigate replace from="/" to="/home" />} />
      <Route path="*" element={<NoPermission />} />
    </Routes>
  );
}

另外視頻中說要在Route 加上 exact 精準匹配,但如果你是安裝最新版React router dom ( V6↑ ) 就不需要加,本身就會精準匹配。

P38

我用className 的方式不成功,如果有跟我一樣用className 沒辦法隱藏其他步驟內容的可以改成使用style 直接設內聯樣式:

<div style={{ display: current === 0 ? "" : "none" }}>
    111
</div>

<div style={{ display: current === 1 ? "" : "none" }}>
    222
</div>

<div style={{ display: current === 2 ? "" : "none" }}>
    333
</div>

P41

可以把 props.history.push 改成使用useNavigate

import { useNavigate } from "react-router";
const navigate = useNavigate();
// ...
navigate(auditState === 0 ? "/news-manage/draft" : "/audit-manage/list");

P43

如果你和我一樣無法使用 props.match.params.id 可以改為使用 useParams 這個hook:

import React, { useEffect } from "react";
import { useParams } from "react-router";
import { PageHeader, Descriptions } from "antd";

export default function NewsPreview(props) {
  const params = useParams();
  useEffect(() => {
    console.log(params.id); // 3
  }, []);
  //...
}

P45

props.history.goBack()可以改為navigate(-1)

import { useNavigate } from "react-router";
const navigate = useNavigate();

<PageHeader
  className="site-page-header"
  title={I18n.t("AddNew")}
  onBack={() => navigate(-1)}
/>
</PageHeader>

P50

之前userlist 的篩選部分好像有漏掉,...list.filter((item) => item.username === username)是加上自己的user data,...list.filter((item) => item.region === region && roleObj[item.roleId] === "editor" )又包括自己的user data,所以需要加一個判斷&& item.username !== username 避免重複把自己的user data 放進去。

// views/sandbox/audit-manage/Audit.js
setdataSource(
  roleObj[roleId] === "superadmin"
    ? list
    : [
        ...list.filter((item) => item.username === username),
        ...list.filter(
          (item) =>
            item.region === region &&
            roleObj[item.roleId] === "editor" &&
            item.username !== username // add this line
        ),
      ]
);

P67

不能重複點讚的部分我是用localStorage 簡單實現的,會有很多弊端,不過暫時能用就行。

// Detail.js
import { useParams } from "react-router";
// ...
const params = useParams();
const [newsInfo, setNewsInfo] = useState([]);
let star = localStorage.getItem("star") || [];
// ...
const handleStar = () => {
  if (!star.includes(params.id.toString())) {
    updateNews(params.id, {
      star: newsInfo.star + 1,
    })
      .then(() => {
        setRefresh();
        const arr = [...star];
        localStorage.setItem("star", arr.concat(params.id));
      })
      .catch((e) => console.log(e));
  } else {
    notification.info({
      message: I18n.t("error"),
      description: I18n.t("starError"),
      placement: "bottomRight",
    });
  }
};

進階: 多語系網站

多語系的部分我個人是習慣用I18n,安裝指令如下:

npm install i18n-js

在src 底下新增一個i18n 資料夾,裡面包括:i18n.jsen.jszh-cn.jszh-tw.js

// i18n.js
import I18n from "i18n-js";
import zhTW from "./zh-tw";
import zhCN from "./zh-cn";
import en from "./en";

I18n.missingTranslation = (scope) => {
  return scope;
};
I18n.translations = {
  "zh-tw": zhTW,
  "zh-cn": zhCN,
  en,
};

export default I18n;
// zh-cn.js
export default {
    // Login
    Title: "全球新闻发布管理系统",
    Login: "登入",
    Username: "帐号",
    Password: "密码",
    Remember: "记住帐号",
    Forgot: "忘记密码",
    Or: "或者",
    Register: "注册"
}

然後把網站中所有會使用到的文字翻譯成對應的語言存放在語系檔案中,接著在需要使用到翻譯的地方引入I18n 就能夠轉換語系:

import I18n from "../../i18n/i18n";
// for example
<h2>{I18n.t("Title")}</h2>

我的習慣是寫一個切換語系的menu在使用者操作處,點擊語系之後就會調用下面這個方法:

const setLanguage = (locale) => {
    localStorage.setItem("locale", locale);
    window.location.reload();
  };

如此一來本地的locale 就會更改,然後在 App.js 中我會寫一個useEffect 用於判別目前本地有沒有儲存語系,如果沒有就使用瀏覽器語系navigator.language

import React, { useEffect } from "react";
import "./App.css";
import IndexRouter from "./router/IndexRouter";
import I18n from "i18n-js";

function App() {

  useEffect(() => {
    if (localStorage.getItem("locale")) {
      I18n.locale = localStorage.getItem("locale");
    } else {
      I18n.locale = window.navigator.language.toLowerCase();
    }
  }, []);

  return (
    <IndexRouter></IndexRouter>
  );
}

export default App;

由於內容過多,佈署的部分請閱讀下一頁。

佈署網站及API

前置:需先註冊netlify(佈署網站) 及heroku (佈署接口) 帳號及新增一個Github repo。

首先確保 package.json 中的 dependenciesjson-server,如果沒有請記得安裝:

npm i --save json-server

並且將專案中所有使用axios 請求的網址後面加上 /api 比如 http://localhost:5000/news 改為 http://localhost:5000/api/news

接著在project 文件夾中新增netlify.toml:這個 YourAppName 取決於你 heroku app 的名稱

[build]
  command = "CI= npm run build"

[[redirects]]
  from = "/api/*"
  to = "https://<YourAppName>.herokuapp.com/api/:splat"
  status = 200

[[redirects]]
  from = "/*"
  to = "/index.html"
  status = 200

如此一來接口就會被佈署到heroku 上,打開 https://<YourAppName>.herokuapp.com/api/news 就可以看到news 接口中的數據。

netlify.toml 中會重定向請求地址包括 api 的連結到https://<YourAppName>.herokuapp.com/api/,比如原本在本地使用json-server 請求news 接口需要使用 http://localhost:<port>/news 現在可以透過 https://<YourAppName>.herokuapp.com/api/news 來獲取news 接口數據。

接著在project根目錄底下的 package.json 裡面scripts 處加上 json-server-dev 及json-server-prod

"scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject",
    "json-server-dev": "nodemon mock-api/server.js",
    "json-server-prod": "node mock-api/server.js"
  },

在根目錄底下新增 Procfile (注意沒有任何副檔名):

web: npm run json-server-prod

在根目錄底下新增 mock-api 文件夾,並把json-server 用到的 db.json 放入,再新增一個server.js

const jsonServer = require("json-server");
const server = jsonServer.create();
const router = jsonServer.router("mock-api/db.json");
const middlewares = jsonServer.defaults();
const port = process.env.PORT || 8000; // 自行改为你的接口 port

server.use(middlewares);
server.use("/api", router);

server.listen(port);

最後將整個project push 到Github 上(ex: yourUserName/NewsSystem),然後到 netlify 新增一個site,選擇連接Github 的NewsSystem repo,並且選擇你使用的branch,Build Command輸入 CI= npm run buildpublish directory寫上 build,然後等待 netlify 佈署完你的網站就能正常瀏覽了。

但此時你的接口還沒有佈署上去,所以網站也只是個空殼,所以我們需要到 heroku Create New App,新增完之後到Deploy – Github連接你的Github NewsSystem repo 並且記得修改branch,選擇完branch 之後啟用自動佈署Enable Automatic Deploys,最後按下 Deploy Branch 將接口佈署到Heroku。

部署成功之後就能透過heroku app 網址後面加上 /app 來訪問接口:

待heroku 和netlify 兩個都佈署完畢之後,你就可以透過netlify 提供的網址瀏覽器的網站啦!

因為你開啟了自動佈署,所以往後你只需要將修改的內容push 到Github 就會自動佈署到netlify 和heroku,就不需要再兩邊都重新佈署,省時省力!

修改域名

你可以選擇購買一個域名或者使用 freenom 提供的免費 tk 域名(目前 freenom 好像沒開放註冊),在 netlify 新增自己要使用的域名。

接著到 netlify 管理域名的頁面,點擊 Check DNS configuration

如果這邊沒有提供你 A 紀錄的 IP 位址,請點擊 documentation

文檔中會提供 netlify 的 A紀錄 IP 位址,將它添加到你的域名紀錄中:

大概等待 10 ~ 20 分鐘域名就能綁定成功,你就可以使用自己的域名訪問自己的網站啦!

順帶一提,netlify 也提供免費的 https 服務,只需要在域名管理頁面最下方找到 HTTPS 並點擊 Verify DNS configuration 就能自動替你的網站啟用 HTTPS:

大功告成!

Github:https://reurl.cc/dxNzQD


netlify 佈署的部分我之前發架設 hugo 的教程中有比較詳細的提到,有興趣可以看:

Exit mobile version