介绍
React全球新闻发布管理系统
# React全球新闻发布管理系统
预览地址http://43.138.16.164:88/
管理员账户admin密码123456
# 1.项目模块
- 登录模块
- 数据分析
- 权限管理
- 角色管理
- 用户管理
- 新闻管理
- 新闻管理
- 审核管理
- 发布管理
# 2.前端跨域问题
配置反向代理
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
server:{
proxy:{
"/api":{
target:"http://localhost:3000",
changeOrigin:true,
rewrite(path) {
return path.replace(/^\/api/,'')
},
}
}
}
})
在 vite 导出的配置里边:
- 添加
server
配置项。 - 在
server
配置项下边添加proxy
配置项,值为一个对象,属性名为要代理的 URL 路径段,值为相关的配置。 - 这里属性名设置为
/api
,来配置转发前端http://localhost:3000/api
开头的所有请求路径。
在 proxy 配置对象中:
target
,为实际的后端 URL,它会追加到属性名配置的/api
这个片段的前面,例如访问/api/some_end_point
会转换为http://localhost:3001/api/some_end_point
。changeOrigin
,是否改写 origin,设置为 true 之后,就会把请求 API header 中的 origin,改成跟target
里边的域名一样了。rewrite
可以把请求的 URL 进行重写,这里因为假设后端的 API 路径不带/api
段,所以我们使用rewrite
去掉/api
。给rewrite
传递一个函数,函数的参数path
是前端请求的 API 路径,后面直接使用了 replace() 方法,在里面写一个正则表达式,把/api
开头的这一段替换为空。
这样 vite 的代理就配置好了。在实际前端请求的过程中,就可以直接使用 /api/some_endpoint
这样的形式了:
fetch("/api/posts");
前面的 http 协议、域名和端口就都可以省略掉了,并且也没有了跨域的问题。
# 3.项目启动
# 3.1路由架构
在项目src目录下新建目录router然后新建路由管理文件indexRouter.tsx
import { Route, Routes } from "react-router-dom"
import {HashRouter} from 'react-router-dom'
import { NeedAuth } from "../components/NeedAuth/needAuth"
import { Login } from "../views/Login/login"
import { NewsSandBox } from "../views/NewsSandBox/newsSandBox"
export const IndexRouter=()=>{
return (
<HashRouter>
<Routes>
<Route path="/login" index element={<Login></Login>}></Route>
<Route path="/" element={
/* 需要权限 */
<NeedAuth>
<NewsSandBox></NewsSandBox>
</NeedAuth>
}></Route>
</Routes>
</HashRouter>
)
}
NeedAuth组件
import { PropsWithChildren } from "react";
import {Navigate} from 'react-router-dom'
export const NeedAuth=(props:PropsWithChildren)=>{
return <>
{localStorage.getItem('token')?props.children:<Navigate to={'/login'}></Navigate>}
</>
}
# 二级路由
import { Route, Routes } from "react-router-dom"
import {HashRouter} from 'react-router-dom'
import { NeedAuth } from "../components/needAuth/NeedAuth"
import { Login } from "../views/login/Login"
import { Home } from "../views/NewsSandBox/home/Home"
import { NewsSandBox } from "../views/NewsSandBox/NewsSandBox"
import { NoPermission } from "../views/NewsSandBox/noPermission/NoPermission"
import { RightList } from "../views/NewsSandBox/right-manage/RightList"
import { RoleList } from "../views/NewsSandBox/right-manage/RoleList"
import { UserList } from "../views/NewsSandBox/user-manage/UserList"
export const IndexRouter=()=>{
return (
<HashRouter>
<Routes>
<Route path="/login" index element={<Login></Login>}></Route>
<Route path="/" element={
/* 需要权限 */
<NeedAuth>
<NewsSandBox></NewsSandBox>
</NeedAuth>
}>
{/* 子路由 */}
<Route path="home" element={<Home></Home>}></Route>
<Route path="user-manage/list" element={<UserList></UserList>}></Route>
<Route path="right-manage/role/list" element={<RoleList></RoleList>}></Route>
<Route path="right-manage/right/list" element={<RightList></RightList>}></Route>
{/* 匹配之外的 无权限或403 notFound*/}
<Route path="*" element={<NoPermission></NoPermission>}></Route>
</Route>
</Routes>
</HashRouter>
)
}
newsSandBox设置重定向
import { useEffect } from "react"
import { Navigate, Outlet, useLocation, useNavigate } from "react-router-dom"
import { SideMenu } from "../../components/newsSandBox/SideMenu"
import { TopHeader } from "../../components/newsSandBox/TopHeader"
export const NewsSandBox=()=>{
const navigate=useNavigate()
const location=useLocation()
//重定向到首页
useEffect(()=>{
if(location.pathname==='/'){
navigate('/home')
}
},[location.pathname])
return <div>
<SideMenu></SideMenu>
<TopHeader></TopHeader>
<Outlet></Outlet>
</div>
}
# 引入antd
安装依赖
pnpm add antd
配置一下路径别名
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
resolve:{
alias:{
"~antd":"./node_modules/antd"
}
}
})
在app.css中引入antd.css
@import '~antd/dist/antd.css';
# 4.前端基本骨架页面
newsSandBox.tsx
import { useEffect } from "react";
import { Outlet, useLocation, useNavigate } from "react-router-dom";
import { SideMenu } from "../../components/newsSandBox/SideMenu";
import { TopHeader } from "../../components/newsSandBox/TopHeader";
import { Layout } from "antd";
import './NewsSandBox.css'
const { Content } = Layout;
export const NewsSandBox = () => {
const navigate = useNavigate();
const location = useLocation();
//重定向到首页
useEffect(() => {
if (location.pathname === "/") {
navigate("/home");
}
}, [location.pathname]);
return (
<Layout>
{/* 侧边菜单 */}
<SideMenu></SideMenu>
<Layout className="site-layout">
{/* 头部 */}
<TopHeader></TopHeader>
{/* 主体内容 */}
<Content
className="site-layout-background"
style={{
margin: "24px 16px",
padding: 24,
minHeight: 280,
}}
>
<Outlet></Outlet>
</Content>
</Layout>
</Layout>
);
};
TopHeader.tsx
import { Avatar, Dropdown, Layout, Menu, Space } from "antd";
const { Header } = Layout;
import {
MenuFoldOutlined,
MenuUnfoldOutlined,
DownOutlined,
UserOutlined
} from "@ant-design/icons";
import { useState } from "react";
import menu from "antd/lib/menu";
export const TopHeader = () => {
const [collapsed, setCollapsed] = useState(false);
//改变折叠事件
const changeCollapsed = () => setCollapsed((preValue) => !preValue);
//下拉菜单
const menu = (
<Menu
items={[
{
key: "1",
label: '超级管理员',
},
{
key: "2",
danger: true,
label: "退出登录",
},
]}
/>
);
return (
<Header className="site-layout-background" style={{ padding: "0 16px" }}>
{collapsed ? (
<MenuUnfoldOutlined onClick={changeCollapsed}></MenuUnfoldOutlined>
) : (
<MenuFoldOutlined onClick={changeCollapsed}></MenuFoldOutlined>
)}
<div style={{ float: "right" }}>
<span>欢迎admin回来</span>
<Dropdown overlay={menu}>
<Space>
<Avatar icon={<UserOutlined />} />
</Space>
</Dropdown>
</div>
</Header>
);
};
SideMenu.tsx
import { Layout, Menu } from 'antd';
import {
UploadOutlined,
UserOutlined,
} from "@ant-design/icons";
import './index.css'
import { ItemType } from 'antd/lib/menu/hooks/useItems';
import { useNavigate } from 'react-router-dom';
const { Sider } = Layout;
export const SideMenu = () => {
//模拟数组结构
const items:ItemType[]=[
{
key: "/home",
icon: <UserOutlined />,
label: "首页",
},
{
key: "/user-manage",
icon: <UploadOutlined />,
label: "用户管理",
children: [
{ label: '用户列表', key: '/user-manage/list' }
],
},
{
key:'/right-manage',
label:'权限管理',
children:[
{key:'/right-manage/role/list',label:'角色列表'},
{key:'/right-manage/right/list',label:'权限列表'}
]
}
]
//选择菜单,跳转
const navigate=useNavigate()
const menuSelectHandle=(info:{key:string})=>{
navigate(info.key)
}
return (
<Sider trigger={null} collapsible collapsed={false}>
<div className="logo">
全球新闻发布管理系统
</div>
<Menu
theme="dark"
mode="inline"
defaultSelectedKeys={["/home"]}
onSelect={menuSelectHandle}
items={items}
/>
</Sider>
);
};
# 5.JSONServer使用
安装依赖
pnpm add json-server -g
创建一个json文件
{
"posts":[
{"id":1,"title":"1111","author":"kerwin"}
],
"comments":[
{"id":1,"body":"1111111","postId":1}
]
}
监听文件自动生成restful Api
json-server --watch ./test.json --port 8000
向下关联表
url?_exbed=表名
向上关联表
url?_expand=表名
向上找不加s
# 6.侧边栏
SideMenu.tsx
import { Layout, Menu } from 'antd';
import {
UserOutlined,
} from "@ant-design/icons";
import './index.css'
import { useLocation, useNavigate } from 'react-router-dom';
import axios from 'axios'
import { useEffect, useState } from 'react';
import style from './SideMenu.module.css'
const { Sider } = Layout;
interface ListData{
children: ListData[]
grade: number
id:number
key:string
pagepermission:number
rightId:number
title:string
}
export const SideMenu = () => {
const [menu,setMenu]=useState([])
//图标映射
const iconMap={
"/home":<UserOutlined />,
"/user-manage":<span className='iconfont icon-yonghuguanli'></span>,
"/user-manage/list":<span className='iconfont icon-yonghuliebiao'></span>,
"/right-manage/role/list":<span className='iconfont icon-role-list'></span>,
"/right-manage/right/list":<span className='iconfont icon-quanxianliebiao'></span>,
"/right-manage":<span className='iconfont icon-quanxianguanli'></span>,
"/news-manage":<span className='iconfont icon-a-14xinwenguanli'></span>,
"/news-manage/add":<span className='iconfont icon-xinwen'></span>,
"/news-manage/draft":<span className='iconfont icon-caogaoxiang'></span>,
"/news-manage/category":<span className='iconfont icon-fenlei'></span>,
"/audit-manage":<span className='iconfont icon-shenheguanli'></span>,
"/audit-manage/audit":<span className='iconfont icon-xinwen1'></span>,
"/audit-manage/list":<span className='iconfont icon-liebiao'></span>,
"/publish-manage":<span className='iconfont icon-fabuguanli'></span>,
"/publish-manage/unpublished":<span className='iconfont icon-daifabu'></span>,
"/publish-manage/published":<span className='iconfont icon-shangxian'></span>,
"/publish-manage/sunset":<span className='iconfont icon-xiaxian'></span>,
}
//选择菜单,跳转
const navigate=useNavigate()
const menuSelectHandle=(info:{key:string})=>{
navigate(info.key)
}
//渲染菜单栏
function changeFormat(list:ListData[]){
return list.map(item=>{
if(!item.children?.length || item.children.length===0){
return {
key:item.key,
label:item.title,
icon:iconMap[item.key]
}
}
return {
key:item.key,
label:item.title,
icon:iconMap[item.key],
children:changeFormat(item.children)
}
})
}
//获取数据
useEffect(()=>{
axios.get('/right').then(res=>{
console.log(res)
setMenu(changeFormat(res.data.data))
})
},[])
//让菜单默认展开为当前页面路径
const location=useLocation()
const openKeys=[location.pathname]
const selectKeys=[location.pathname.match(/^\/([\w-]+)(?=\/)/i)! ? location.pathname.match(/^\/([\w-]+)(?=\/)/i)![0]:'']
return (
<Sider trigger={null} collapsible collapsed={false}>
<div className={style.box}>
<div className="logo">
全球新闻发布管理系统
</div>
<Menu
theme="dark"
mode="inline"
selectedKeys={openKeys}
defaultOpenKeys={selectKeys}
onSelect={menuSelectHandle}
items={menu}
className={style.menu}
/>
</div>
</Sider>
);
};
sideMenu.module.css
# 设置滚动条样式
.box{
display: flex;
flex-direction: column;
height: 100%;
}
.menu{
flex: auto;
overflow: auto;
}
.menu::-webkit-scrollbar{
width: 4px;
position: absolute;
}
.menu::-webkit-scrollbar-thumb{
background-color: rgb(56, 139, 233);
}
.menu::-webkit-scrollbar-track{
background-color: rgba(207, 204, 211, 0.815);
}
# 7.权限控制
# RightList.tsx
import { Button, Table, Tag ,Modal, Popover, Switch} from "antd";
import axios from "axios";
import {DeleteOutlined, EditOutlined,ExclamationCircleOutlined} from '@ant-design/icons'
import { useEffect, useState } from "react";
import { ListData } from "../../../components/newsSandBox/SideMenu";
const {confirm}=Modal
export const RightList=()=>{
const [dataSource,setDataSource]=useState<ListData[]>([])
const columns = [
{
title: 'ID',
dataIndex: 'id',
key: 'id',
render:(id)=>{
return <strong>{id}</strong>
}
},
{
title: '权限名称',
dataIndex: 'title',
key: 'title',
},
{
title: '权限路径',
dataIndex: 'key',
key: 'key',
render:(key)=>{
return <Tag color="orange">{key}</Tag>
}
},
{
title:'操作',
render:(item)=>{
return <>
<Button onClick={()=>MyConfirm(item.id)} danger icon={<DeleteOutlined />} shape="circle"/>
<Popover content={<div style={{textAlign:'center'}}><Switch checked={item.pagepermission} onChange={()=>switchChangeHandle(item)}></Switch></div>} title="页面配置项" trigger={item.pagepermission!=undefined ?"click":""}>
<Button type="primary" disabled={item.pagepermission==undefined} icon={<EditOutlined />} shape="circle"/>
</Popover>
</>
}
},
];
const switchChangeHandle=(item)=>{
const data=item.pagepermission==1 ? 0 : 1
/* const newDataSource=[...dataSource]
if(item.grade==1){
newDataSource.find(i=>i.id===item.id)!.pagepermission=data
}else{
newDataSource.find(i=>i.id==item.rightId)!.children!.find(i=>i.id===item.id)!.pagepermission=data
} */
item.pagepermission=data
setDataSource([...dataSource])
axios.patch(`/right/${item.id}`,{pagepermission:data})
}
useEffect(()=>{
axios.get('/right/without').then(res=>{
res.data.data.forEach(item=>{
if(item.children.length===0){
delete item.children
}
})
setDataSource(res.data.data)
})
},[])
const MyConfirm=(id:number)=>{
confirm({
title: '你想要删除权限吗?',
icon: <ExclamationCircleOutlined />,
content: '删除权限将无法挽回!',
onOk() {
deleteMethod(id)
}
})
}
//递归删除item项
const deleteItem=(list:ListData[],id:number)=>{
if(list.findIndex(item=>item.id===id)!==-1){
list.splice(list.findIndex(item=>item.id===id),1)
}else{
list.forEach(item=>{
if(item.children?.length!>=0){
deleteItem(item.children!,id)
}
//如果删除完children长度为零则直接删除
if(item.children && item.children.length===0){
delete item.children
}
})
}
}
const deleteMethod=async(id:number)=>{
const newDataSource=[...dataSource]
deleteItem(newDataSource,id)
setDataSource(newDataSource)
const res=await axios.delete(`/right/${id}`)
console.log(res)
}
return <div>
<Table dataSource={dataSource} columns={columns}
pagination={
{
pageSize:3
}
}
/>
</div>
}
# RoleList.tsx
import {
DeleteOutlined,
ExclamationCircleOutlined,
UnorderedListOutlined,
} from "@ant-design/icons";
import { Button, Table, Modal, Tree } from "antd";
import axios from "axios";
import { Key, useEffect, useState } from "react";
const { confirm } = Modal;
interface IRoleData {
id: number;
rights: { key: string }[];
roleType: number;
rolename: string;
}
export const RoleList = () => {
const [dataSource, setDataSource] = useState<IRoleData[]>([]);
const [treeData, setTreeData] = useState([]);
const [currentRight, setCurrentRight] = useState<string[]>([]);
const [currentId,setCurrentId]=useState<number>()
const columns = [
{
title: "ID",
dataIndex: "id",
render: (id) => {
return <strong>{id}</strong>;
},
},
{
title: "角色名称",
dataIndex: "rolename",
},
{
title: "操作",
render: (item: IRoleData) => {
return (
<div style={{ display: "flex", justifyContent: "center" }}>
<Button
onClick={() => MyConfirm(item.id)}
style={{ marginRight: "20px" }}
danger
icon={<DeleteOutlined />}
shape="circle"
/>
<Button
onClick={() => {
showModal();
setCurrentRight(item.rights.map((i) => i.key));
setCurrentId(item.id)
}}
type="primary"
icon={<UnorderedListOutlined />}
shape="circle"
/>
</div>
);
},
},
];
const deleteMethod = async (id: number) => {
await axios.delete(`/role/${id}`);
const newDataSource = [...dataSource];
newDataSource.splice(
newDataSource.findIndex((item) => item.id === id),
1
);
setDataSource(newDataSource);
};
const MyConfirm = (id: number) => {
confirm({
title: "确定要删除该角色吗?",
icon: <ExclamationCircleOutlined />,
content: "删除该角色将造成无法挽回的后果。",
onOk: () => {
deleteMethod(id);
},
});
};
//获取角色数据
useEffect(() => {
axios.get("/role").then((res) => setDataSource(res.data.data));
}, []);
//获取权限数据
useEffect(() => {
axios.get("/right/without").then((res) => setTreeData(res.data.data));
}, []);
const [isModalOpen, setIsModalOpen] = useState(false);
const showModal = () => {
setIsModalOpen(true);
};
const handleOk = async() => {
setIsModalOpen(false);
//更新数据
await axios.put(`/role/${currentId}`,{rights:currentRight})
//从服务端拿数据更新
axios.get("/role").then((res) => setDataSource(res.data.data));
};
const handleCancel = () => {
setIsModalOpen(false);
};
const checkedHandle=(checkedKeys)=>{
setCurrentRight(checkedKeys.checked)
}
return (
<div>
<Table
dataSource={dataSource}
rowKey={(item) => item.id}
columns={columns}
></Table>
<Modal
title="Basic Modal"
open={isModalOpen}
onOk={handleOk}
onCancel={handleCancel}
>
<Tree
onCheck={checkedHandle}
checkStrictly
treeData={treeData}
checkable
checkedKeys={currentRight}></Tree>
</Modal>
</div>
);
};
# UserForm.tsx
import { Input, Select ,Form, FormInstance} from "antd"
import { useEffect, useState } from "react";
const {Option}=Select
interface Role {
id: number;
rolename: string;
roleType: number;
}
interface Region {
id: number;
title: string;
value: string;
}
export const UserForm=(props:{
form:FormInstance<any>,
regionList:Region[],
roleList:Role[],
isDisabled:boolean,
setIsDisabled:React.Dispatch<React.SetStateAction<boolean>>
})=>{
/* const [isDisabled,setIsDisabled]=useState(false) */
//监听props.isUpdateDisabled变化,动态设置表单禁用状态
/* useEffect(()=>{
setIsDisabled(props.isUpdateDisabled!)
console.log('2222')
},[props.isUpdateDisabled]) */
return <Form
form={props.form}
layout="vertical"
name="form_in_modal"
initialValues={{ modifier: "public" }}
>
<Form.Item
name="username"
label="用户名"
rules={[
{
required: true,
message: "请输入用户名!",
},
]}
>
<Input type="text"/>
</Form.Item>
<Form.Item name="password" label="密码" rules={[
{
required:true,
message:'请输入密码!'
}
]}>
<Input type="text" />
</Form.Item>
<Form.Item name="region" label="区域" rules={[{
required:true,
message:'请选择区域'
}]}>
<Select disabled={props.isDisabled}>
{props.regionList.map(item=><Option key={item.id} value={item.value}>{item.value}</Option>)}
</Select>
</Form.Item>
<Form.Item name="roleId" label="角色" rules={[{
required:true,
message:'请选择角色'
}]}>
<Select onChange={(id)=>{
if(id===1){
props.setIsDisabled(true)
props.form.setFieldValue('region',"全球")
}else{
props.setIsDisabled(false)
}
}}>
{props.roleList.map(item=><Option key={item.id} value={item.id}>{item.rolename}</Option>)}
</Select>
</Form.Item>
</Form>
}
# UserList.tsx
用户列表增删改查
import {
DeleteOutlined,
ExclamationCircleOutlined,
UnorderedListOutlined,
} from "@ant-design/icons";
import { Button, Table, Modal, Switch, Form } from "antd";
import axios from "axios";
import { useEffect, useState } from "react";
import { UserForm } from "../../../components/user-manage/UserForm";
const { confirm } = Modal;
interface RootObject {
id: number;
username: string;
default: boolean;
roleState: boolean;
region: string;
roleId: number;
Role: Role;
}
interface Role {
id: number;
rolename: string;
roleType: number;
}
interface Region {
id: number;
title: string;
value: string;
}
export const UserList = () => {
const [dataSource, setDataSource] = useState<RootObject[]>([]);
//区域列表
const [regionList, setRegionList] = useState<Region[]>([]);
const [roleList, setRoleList] = useState<Role[]>([]);
const columns = [
{
title: "区域",
dataIndex: "region",
//过滤筛选
filters:[...regionList.map(item=>({text:item.title,value:item.value}))],
onFilter:(value,item:RootObject)=>{
return item.region===value
},
render: (region) => {
return <strong>{region}</strong>;
},
},
{
title: "角色名称",
render: (item) => {
return item.Role.rolename;
},
},
{
title: "用户名",
dataIndex: "username",
},
{
title: "用户状态",
render: (item: RootObject) => {
return (
<Switch
checked={item.roleState}
disabled={item.default}
onChange={() => switchCheckHandle(item)}
></Switch>
);
},
},
{
title: "操作",
render: (item: RootObject) => {
return (
<div style={{ display: "flex", justifyContent: "center" }}>
{/* 删除 */}
<Button
onClick={() => MyConfirm(item.id)}
style={{ marginRight: "20px" }}
danger
disabled={item.default}
icon={<DeleteOutlined />}
shape="circle"
/>
{/* 更新 */}
<Button
disabled={item.default}
onClick={()=>onClickHandle(item)}
type="primary"
icon={<UnorderedListOutlined />}
shape="circle"
/>
</div>
);
},
},
];
//更新相关
const [isUpdateOpen,setIsUpdateOpen]=useState(false)
const [updateForm]=Form.useForm()
const [updateId,setUpdateId]=useState<number>()
const [isUpdateDisabled,setIsUpdateDisabled]=useState(false)
const onClickHandle=(item:RootObject)=>{
setIsUpdateOpen(true)
setUpdateId(item.id)
//如果是超级管理员禁用区域表单
if(item.roleId===1){
setIsUpdateDisabled(true)
}else{
setIsUpdateDisabled(false)
}
updateForm.setFieldsValue(item)
}
const updateFormOK=()=>{
updateForm.validateFields().then(async(values)=>{
//重置表单数据
updateForm.resetFields()
setIsUpdateOpen(false)
const res=await axios.put(`/user/${updateId}`,values)
const newDataSource=[...dataSource]
newDataSource.splice(newDataSource.findIndex(item=>item.id===updateId),1,res.data.data)
setDataSource(newDataSource)
}).catch((info) => {
console.log("Validate Failed:", info);
});
}
//开关改变用户状态
const switchCheckHandle = async (item: RootObject) => {
const newDataSource = [...dataSource];
newDataSource.find((i) => i.id == item.id)!.roleState = !item.roleState;
setDataSource(newDataSource);
await axios.patch(`/user/${item.id}`, { roleState: item.roleState });
};
const deleteMethod = async (id: number) => {
//删除用户
await axios.delete(`/user/${id}`);
const newDataSource = [...dataSource];
newDataSource.splice(
newDataSource.findIndex((item) => item.id === id),
1
);
setDataSource(newDataSource);
};
const MyConfirm = (id: number) => {
confirm({
title: "确定要删除该用户吗?",
icon: <ExclamationCircleOutlined />,
content: "删除该用户将造成无法挽回的后果。",
onOk: () => {
deleteMethod(id);
},
});
};
useEffect(() => {
axios.get("/user").then((res) => setDataSource(res.data.data));
}, []);
//表单
const [form] = Form.useForm();
const [isOpen, setIsOpen] = useState(false);
const [isDisabled,setIsDisabled]=useState(false)
useEffect(() => {
axios.get("/region").then((res) => setRegionList(res.data.data));
}, []);
useEffect(() => {
axios.get("/role").then((res) => setRoleList(res.data.data));
}, []);
//添加用户方法
const addFormOK = () => {
form
.validateFields()
.then(async(values) => {
//重置表单
form.resetFields();
setIsOpen(false);
//校验成功创建用户
const res=await axios.post('/user',values)
const newDataSource=[...dataSource]
newDataSource.push(res.data.data)
setDataSource(newDataSource)
})
.catch((info) => {
console.log("Validate Failed:", info);
});
};
return (
<div>
<Button type="primary" onClick={() => setIsOpen(true)}>
添加用户
</Button>
<Table
dataSource={dataSource}
rowKey={(item) => item.id}
columns={columns}
pagination={{
pageSize:5
}}
></Table>
{/* 创建表单 */}
<Modal
open={isOpen}
title="添加用户"
okText="确定"
cancelText="取消"
onCancel={() => setIsOpen(false)}
onOk={addFormOK}
>
<UserForm
form={form}
regionList={regionList}
roleList={roleList}
isDisabled={isDisabled}
setIsDisabled={setIsDisabled}
></UserForm>
</Modal>
{/* 更新用户 */}
<Modal
open={isUpdateOpen}
title="更新用户"
okText="更新"
cancelText="取消"
onCancel={() => {
setIsUpdateOpen(false)
}}
onOk={updateFormOK}
>
<UserForm
form={updateForm}
regionList={regionList}
roleList={roleList}
isDisabled={isUpdateDisabled}
setIsDisabled={setIsUpdateDisabled}
></UserForm>
</Modal>
</div>
);
};
# 8.登录
# Login.tsx
设置登录粒子效果
粒子效果可以到tsparticles官网找
import { LockOutlined, UserOutlined } from "@ant-design/icons";
import { Input, Button, Form ,message} from "antd";
import style from "./Login.module.css";
import Particles from "react-tsparticles";
import { loadFull } from "tsparticles";
import { useCallback } from "react";
import axios from "axios";
import { useNavigate } from "react-router-dom";
import { observer } from "mobx-react";
import { useStore } from "../../store";
export const Login = observer(() => {
const {userInfo}=useStore()
const navigate=useNavigate()
//成功回调
const onFinish = async (values: any) => {
try {
const res = await axios.post("/login", values);
if(res.status===200){
localStorage.setItem('token',res.data.data.token)
userInfo.setToken(res.data.data.token)
message.success('登录成功')
navigate('/home',{replace:true})
}
} catch (error:any) {
const res=error.response
if(res.status==401){
message.warn('该用户不存在')
}
if(res.status==402){
message.error('密码错误')
}
if(res.status==403){
message.error('该用户没有权限')
}
}
};
const particlesInit = useCallback(async (engine) => {
/* console.log(engine); */
await loadFull(engine);
}, []);
const particlesLoaded = useCallback(async (container) => {
await container;
}, []);
return (
<div className={style.box}>
<Particles
init={particlesInit}
loaded={particlesLoaded}
options={{
fpsLimit: 120,
interactivity: {
events: {
onClick: {
enable: true,
mode: "push",
},
onHover: {
enable: false,
mode: "repulse",
},
resize: true,
},
modes: {
push: {
quantity: 3,
},
repulse: {
distance: 200,
duration: 0.4,
},
},
},
particles: {
color: {
value: "#ffffff",
},
links: {
color: "#ffffff",
distance: 150,
enable: true,
opacity: 0.5,
width: 1,
},
collisions: {
enable: true,
},
move: {
direction: "none",
enable: true,
outModes: {
default: "bounce",
},
random: false,
speed: 2,
straight: false,
},
number: {
density: {
enable: false,
area: 800,
},
value: 80,
},
opacity: {
value: 0.5,
},
shape: {
type: "circle",
},
size: {
value: { min: 1, max: 5 },
},
},
detectRetina: false,
}}
/>
<Form
name="normal_login"
className={style.form}
initialValues={{ remember: true }}
onFinish={onFinish}
>
<p className={style.title}>全球新闻发布管理系统</p>
<Form.Item
className={style.item}
name="username"
rules={[{ required: true, message: "请输入用户名" }]}
>
<Input
prefix={<UserOutlined className="site-form-item-icon" />}
placeholder="Username"
/>
</Form.Item>
<Form.Item
className={style.item}
name="password"
rules={[{ required: true, message: "请输入密码" }]}
>
<Input
prefix={<LockOutlined className="site-form-item-icon" />}
type="password"
placeholder="Password"
/>
</Form.Item>
<Form.Item>
<Button
type="primary"
htmlType="submit"
className="login-form-button"
>
登录
</Button>
</Form.Item>
</Form>
</div>
);
});
# 使用mobx
# store/user.ts
保存用户状态
import { makeAutoObservable } from "mobx";
interface UserInfo {
id: number;
username: string;
password: string;
default: boolean;
roleState: boolean;
region: string;
roleId: number;
Role: Role;
}
interface Role {
id: number;
rolename: string;
roleType: number;
rights: Right[];
}
interface Right {
key: string;
}
class UserStore {
public user={} as UserInfo
public token=localStorage.getItem('token')
constructor() {
makeAutoObservable(this, {}, { autoBind: true });
}
initUserInfo(user:UserInfo){
this.user=user
}
get rights(){
return this.user.Role?.rights?.map(item=>item.key)
}
setToken(token:string){
this.token=token
}
}
export const user=new UserStore()
做统一管理
# store/index.ts
import { createContext, useContext } from 'react';
import { user } from './user';
const store={
userInfo:user
}
const context=createContext(store)
export const useStore=()=>{
return useContext(context)
}
# NewsSandBox.tsx
在进入的一瞬间根据token返回用户信息
import { useEffect } from "react";
import { Outlet, useLocation, useNavigate } from "react-router-dom";
import { SideMenu } from "../../components/newsSandBox/SideMenu";
import { TopHeader } from "../../components/newsSandBox/TopHeader";
import { Layout } from "antd";
import {observer} from 'mobx-react'
import './NewsSandBox.css'
import axios from "axios";
import { useStore } from "../../store";
const { Content } = Layout;
export const NewsSandBox = observer(() => {
const navigate = useNavigate();
const location = useLocation();
const store=useStore()
//重定向到首页
useEffect(() => {
if (location.pathname === "/") {
navigate("/home");
}
}, [location.pathname]);
//获取用户信息
useEffect(()=>{
axios.get('/login',{
headers:{
'Authorization':localStorage.getItem('token')
}
}).then(res=>store.userInfo.initUserInfo(res.data.data))
},[])
return (
<Layout>
{/* 侧边菜单 */}
<SideMenu></SideMenu>
<Layout className="site-layout">
{/* 头部 */}
<TopHeader></TopHeader>
{/* 主体内容 */}
<Content
className="site-layout-background"
style={{
margin: "24px 16px",
padding: 24,
minHeight: 280,
overflow:'auto'
}}
>
<Outlet></Outlet>
</Content>
</Layout>
</Layout>
);
});
# 9.动态渲染路由
# indexRouter.tsx
import axios from "axios"
import { observer } from "mobx-react"
import { useEffect, useState } from "react"
import { Route, Routes } from "react-router-dom"
import {HashRouter} from 'react-router-dom'
import { NeedAuth } from "../components/needAuth/NeedAuth"
import { useStore } from "../store"
import { Login } from "../views/login/Login"
import { NewsSandBox } from "../views/NewsSandBox/NewsSandBox"
import { NoPermission } from "../views/NewsSandBox/noPermission/NoPermission"
import { routers } from "./routers"
export const IndexRouter=observer(()=>{
const [rightsList,setRightsList]=useState<string[]>([])
const {userInfo}=useStore()
useEffect(()=>{
if(userInfo.token){
axios.get('/login',{
headers:{
'Authorization':userInfo.token
}
}).then(res=>setRightsList(res.data.data.Role.rights.map(item=>item.key.replace(/\//i,''))))
}
},[userInfo.token,userInfo.user.roleId])
return (
<HashRouter>
<Routes>
<Route path="/login" index element={<Login></Login>}></Route>
<Route path="/" element={
/* 需要权限 */
<NeedAuth>
<NewsSandBox></NewsSandBox>
</NeedAuth>
}>
{/* 子路由 */}
{rightsList.map(item=><Route key={item} path={item} element={routers[item]}></Route>)}
{/* 匹配之外的 无权限或403 notFound*/}
<Route path="*" element={<NoPermission></NoPermission>}></Route>
</Route>
</Routes>
</HashRouter>
)
})
# routers.tsx
import { AuditList } from "../views/NewsSandBox/audit-manage/AuditList";
import { AuditNews } from "../views/NewsSandBox/audit-manage/AuditNews";
import { Home } from "../views/NewsSandBox/home/Home";
import { Category } from "../views/NewsSandBox/news-manage/Category";
import { Draft } from "../views/NewsSandBox/news-manage/Drafts";
import { WriteNews } from "../views/NewsSandBox/news-manage/WriteNews";
import { Published } from "../views/NewsSandBox/publish-manage/Published";
import { Sunset } from "../views/NewsSandBox/publish-manage/Sunset";
import { Unpublished } from "../views/NewsSandBox/publish-manage/Unpublished";
import { RightList } from "../views/NewsSandBox/right-manage/RightList";
import { RoleList } from "../views/NewsSandBox/right-manage/RoleList";
import { UserList } from "../views/NewsSandBox/user-manage/UserList";
//本地路由映射
export const routers = {
'home': <Home></Home>,
"user-manage/list": <UserList></UserList>,
"right-manage/role/list": <RoleList></RoleList>,
"right-manage/right/list": <RightList></RightList>,
"news-manage/add":<WriteNews></WriteNews>,
"news-manage/draft":<Draft></Draft>,
"news-manage/category":<Category></Category>,
"audit-manage/audit":<AuditNews></AuditNews>,
"audit-manage/list":<AuditList></AuditList>,
"publish-manage/unpublished":<Unpublished></Unpublished>,
"publish-manage/published":<Published></Published>,
"publish-manage/sunset":<Sunset></Sunset>
};
# 主逻辑
通过一张映射表,根据服务器动态返回的权限列表数据动态渲染子路由
侧面菜单栏也要根据用户权限渲染
# SideMenu.tsx
import { Layout, Menu } from 'antd';
import {
UserOutlined,
} from "@ant-design/icons";
import './index.css'
import { useLocation, useNavigate } from 'react-router-dom';
import axios from 'axios'
import { useEffect, useState } from 'react';
import style from './SideMenu.module.css'
import { observer } from 'mobx-react';
import { useStore } from '../../store';
const { Sider } = Layout;
export interface ListData{
children?: ListData[]
grade: number
id:number
key:string
pagepermission:number|null
rightId:number
title:string
}
export const SideMenu = observer(() => {
const {userInfo}=useStore()
const [menu,setMenu]=useState([])
//图标映射
const iconMap={
"/home":<UserOutlined />,
"/user-manage":<span className='iconfont icon-yonghuguanli'></span>,
"/user-manage/list":<span className='iconfont icon-yonghuliebiao'></span>,
"/right-manage/role/list":<span className='iconfont icon-role-list'></span>,
"/right-manage/right/list":<span className='iconfont icon-quanxianliebiao'></span>,
"/right-manage":<span className='iconfont icon-quanxianguanli'></span>,
"/news-manage":<span className='iconfont icon-a-14xinwenguanli'></span>,
"/news-manage/add":<span className='iconfont icon-xinwen'></span>,
"/news-manage/draft":<span className='iconfont icon-caogaoxiang'></span>,
"/news-manage/category":<span className='iconfont icon-fenlei'></span>,
"/audit-manage":<span className='iconfont icon-shenheguanli'></span>,
"/audit-manage/audit":<span className='iconfont icon-xinwen1'></span>,
"/audit-manage/list":<span className='iconfont icon-liebiao'></span>,
"/publish-manage":<span className='iconfont icon-fabuguanli'></span>,
"/publish-manage/unpublished":<span className='iconfont icon-daifabu'></span>,
"/publish-manage/published":<span className='iconfont icon-shangxian'></span>,
"/publish-manage/sunset":<span className='iconfont icon-xiaxian'></span>,
}
//选择菜单,跳转
const navigate=useNavigate()
const menuSelectHandle=(info:{key:string})=>{
navigate(info.key)
}
//渲染菜单栏
function changeFormat(list:ListData[],keys:string[]){
return list.map(item=>{
if(!item.children?.length || item.children.length===0){
if(keys.includes(item.key))return {
key:item.key,
label:item.title,
icon:iconMap[item.key]
}
}
if(keys.includes(item.key))return {
key:item.key,
label:item.title,
icon:iconMap[item.key],
children:changeFormat(item.children!,keys)
}
})
}
//获取数据
useEffect(()=>{
axios.get('/right').then(res=>{
if(userInfo.rights?.length>=0){
setMenu(changeFormat(res.data.data,userInfo.rights))
}
})
},[userInfo.rights])
//让菜单默认展开为当前页面路径
const location=useLocation()
const openKeys=[location.pathname]
const selectKeys=[location.pathname.match(/^\/([\w-]+)(?=\/)/i)! ? location.pathname.match(/^\/([\w-]+)(?=\/)/i)![0]:'']
return (
<Sider trigger={null} collapsible collapsed={false}>
<div className={style.box}>
<div className="logo">
全球新闻发布管理系统
</div>
<Menu
theme="dark"
mode="inline"
selectedKeys={openKeys}
defaultOpenKeys={selectKeys}
onSelect={menuSelectHandle}
items={menu}
className={style.menu}
/>
</div>
</Sider>
);
});
# 10.进度条
pnpm add nprogress
在路由发生变化的时候会重新渲染
NewsSandBox.tsx
import { useEffect } from "react";
import { Outlet, useLocation, useNavigate } from "react-router-dom";
import { SideMenu } from "../../components/newsSandBox/SideMenu";
import { TopHeader } from "../../components/newsSandBox/TopHeader";
import { Layout } from "antd";
import {observer} from 'mobx-react'
import './NewsSandBox.css'
import axios from "axios";
import { useStore } from "../../store";
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'
const { Content } = Layout;
export const NewsSandBox = observer(() => {
//开始进度条
NProgress.start()
const navigate = useNavigate();
const location = useLocation();
const store=useStore()
//重定向到首页
useEffect(() => {
if (location.pathname === "/") {
navigate("/home");
}
}, [location.pathname]);
//获取用户信息
useEffect(()=>{
axios.get('/login',{
headers:{
'Authorization':localStorage.getItem('token')
}
}).then(res=>store.userInfo.initUserInfo(res.data.data))
},[])
//进度条
useEffect(()=>{
//关闭进度条
NProgress.done()
})
return (
<Layout>
{/* 侧边菜单 */}
<SideMenu></SideMenu>
<Layout className="site-layout">
{/* 头部 */}
<TopHeader></TopHeader>
{/* 主体内容 */}
<Content
className="site-layout-background"
style={{
margin: "24px 16px",
padding: 24,
minHeight: 280,
overflow:'auto'
}}
>
<Outlet></Outlet>
</Content>
</Layout>
</Layout>
);
});
# 11.axios配置
util/http.ts
import axios from "axios"
axios.defaults.baseURL='/api'
//请求拦截器
axios.interceptors.request.use((config)=>{
const token=localStorage.getItem('token')
if(config.url!=='/login'){
config.headers={
'Authorization':token
}
}
return config
})
vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
resolve:{
alias:{
"~antd":"./node_modules/antd"
}
},
server:{
/* 配置反向代理 */
proxy:{
'/api':{
target:'http://localhost:3000/',
changeOrigin:true,
rewrite(path) {
return path.replace(/^\/api/g,'')
},
}
}
}
})
axios配置基本路径加上vite配置反向代理,能实现比较舒适的跨域请求解决方案
# 适配问题可以使用postcss-px-to-viewport插件解决
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
//安装css转化插件
import postCssPxToViewport from 'postcss-px-to-viewport'
export default defineConfig({
plugins: [vue()],
//配置css插件
css:{
postcss:{
plugins:[
postCssPxToViewport({
unitToConvert: 'px', // 需要转换的单位,默认为"px"
viewportWidth: 1920, // 设计稿的视口宽度
unitPrecision: 5, // 单位转换后保留的精度
propList: ['*'], // 能转化为vw的属性列表
viewportUnit: 'vw', // 希望使用的视口单位
fontViewportUnit: 'vw', // 字体使用的视口单位
selectorBlackList: [], // 需要忽略的CSS选择器,不会转为视口单位,使用原有的px等单位。
minPixelValue: 1, // 设置最小的转换数值,如果为1的话,只有大于1的值会被转换
mediaQuery: false, // 媒体查询里的单位是否需要转换单位
replace: true, // 是否直接更换属性值,而不添加备用属性
exclude: undefined, // 忽略某些文件夹下的文件或特定文件,例如 'node_modules' 下的文件
include: undefined, // 如果设置了include,那将只有匹配到的文件才会被转换
landscape: false, // 是否添加根据 landscapeWidth 生成的媒体查询条件 @media (orientation: landscape)
landscapeUnit: 'vw', // 横屏时使用的单位
landscapeWidth: 1920 // 横屏时使用的视口宽度
})
]
}
}
})
# 12.新闻
# 字段和审核发布逻辑
# 富文本编辑器
pnpm add react-draft-wysiwyg
pnpm add draft-js
pnpm add draftjs-to-html
pnpm add html-to-draftjs
注意得配置一下vite
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
resolve:{
alias:{
"~antd":"./node_modules/antd"
}
},
server:{
/* 配置反向代理 */
proxy:{
'/api':{
target:'http://localhost:3000/',
changeOrigin:true,
rewrite(path) {
return path.replace(/^\/api/g,'')
},
}
}
},
//配置一下gloabl,不然draft.js会报错
define:{
global:{}
}
})
# NewsEditor.tsx
把富文本用的部分封装成组件
import '/node_modules/react-draft-wysiwyg/dist/react-draft-wysiwyg.css';
import { Editor } from 'react-draft-wysiwyg';
import { EditorState, convertToRaw } from 'draft-js';
import draftToHtml from 'draftjs-to-html';
import htmlToDraft from 'html-to-draftjs'
import { useState } from 'react'
export const NewsEditor=(props:{
getEditorValue:(str:string)=>void
})=>{
const [editorState,setEditorState]=useState<EditorState>()
const handleEditorChange=(value)=>{
setEditorState(value)
}
return <div>
<Editor
editorState={editorState}
wrapperClassName="demo-wrapper"
editorClassName="demo-editor"
onEditorStateChange={handleEditorChange}
onBlur={()=>{
//draft转html
const strValue=draftToHtml(convertToRaw(editorState!.getCurrentContent()))
props.getEditorValue(strValue)
}}
/>
</div>
}
# 新闻编写
WriteNews.tsx
import { LoadingOutlined, PlusOutlined } from "@ant-design/icons";
import {
Button,
Form,
Input,
message,
notification,
PageHeader,
Result,
Select,
Steps,
Upload,
} from "antd";
import {
RcFile,
UploadChangeParam,
UploadFile,
UploadProps,
} from "antd/lib/upload";
import axios from "axios";
import { observer } from "mobx-react";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { NewsEditor } from "../../../components/newsEditor/NewsEditor";
import { useStore } from "../../../store";
import style from "./WriteNews.module.css";
const { Step } = Steps;
const { Option } = Select;
interface Category {
id: number;
title: string;
value: string;
}
export const WriteNews = observer(() => {
const [steps, setSteps] = useState(0);
const [categoryList, setCategoryList] = useState<Category[]>([]);
const [imageFile, setImageFile] = useState<File | null>(null);
const [previewImage, setPreviewImage] = useState<string | null>(null);
const [formData, setFormData] = useState<{
title: string;
categoryId: number;
}>();
const [editorValue, setEditorValue] = useState("");
const navigate = useNavigate();
useEffect(() => {
axios.get("/category").then((res) => setCategoryList(res.data.data));
}, []);
const toBase64 = (file: File) => {
const fileReader = new FileReader();
fileReader.readAsDataURL(file);
fileReader.onload = (ev) => {
setPreviewImage(ev.target?.result! as string);
};
};
const imageOnChange: React.ChangeEventHandler<HTMLInputElement> = (e) => {
setImageFile(e.target.files![0]);
toBase64(e.target.files![0]);
};
const [form] = Form.useForm();
const { userInfo } = useStore();
const saveHandle = (auditState: number) => {
const data = {
title: formData?.title!,
categoryId: formData?.categoryId + "",
article: editorValue!,
coverImage: imageFile!,
region: userInfo.user.region,
roleId: userInfo.user.roleId + "",
author: userInfo.user.username,
auditState: auditState + "",
};
//发送文件用FormData
const newsFormData = new FormData();
const dataEntries = Object.entries(data);
dataEntries.forEach((item) => {
newsFormData.set(item[0], item[1]);
});
//发生数据
axios.post("/news", newsFormData).then((res) => {
navigate(auditState === 0 ? "/news-manage/draft" : "/audit-manage/list", {
replace: true,
});
notification.info({
message: "通知",
description: `您可以到${
auditState === 0 ? "草稿箱" : "审核列表"
}中查看您的新闻`,
placement: "topRight",
duration: 3,
});
});
};
return (
<div>
{/* 标题 */}
<PageHeader title="撰写新闻" subTitle="开始编写新闻吧" />
{/* 步骤条 */}
<Steps size="small" current={steps}>
<Step title="基本信息" description="新闻标题,新闻分类" />
<Step title="新闻内容" description="新闻主体内容" />
<Step title="新闻提交" description="保存草稿或提交审核" />
</Steps>
{/* 内容区域 */}
<div className={steps === 0 ? "" : style.hidden}>
<Form
style={{ margin: "30px 0" }}
name="basic"
wrapperCol={{ span: 20 }}
initialValues={{ remember: true }}
form={form}
autoComplete="off"
>
<Form.Item
label="新闻标题"
name="title"
rules={[{ required: true, message: "请输入标题!" }]}
>
<Input />
</Form.Item>
<Form.Item
label="新闻分类"
name="categoryId"
rules={[{ required: true, message: "请选择分类!" }]}
>
<Select placeholder="请选择分类">
{categoryList.map((item) => (
<Option key={item.id} value={item.id}>
{item.title}
</Option>
))}
</Select>
</Form.Item>
<div className={style.coverImage}>
<input
type="file"
id="coverImage"
style={{ display: "none" }}
onChange={imageOnChange}
/>
<label className={style.iconBox} htmlFor="coverImage">
<p>上传封面图片:</p>
<div>
<p>{<PlusOutlined />}</p>
<p>Upload</p>
</div>
</label>
{previewImage ? (
<img className={style.image} src={previewImage!} alt="" />
) : (
""
)}
</div>
</Form>
</div>
<div className={steps === 1 ? "" : style.hidden}>
<NewsEditor
getEditorValue={(htmlStr: string) => {
setEditorValue(htmlStr);
}}
></NewsEditor>
</div>
<div className={steps === 2 ? "" : style.hidden}>
<Result
status="success"
title="撰写新闻完成"
subTitle="请选择保存草稿箱或者提交审核"
extra={[
<Button type="primary" key="console" onClick={() => saveHandle(0)}>
保存草稿箱
</Button>,
<Button key="buy" onClick={() => saveHandle(1)}>提交审核</Button>,
]}
/>
</div>
{/* 按钮区域 */}
<div className={style.buttonBox}>
{steps !== 2 ? (
<Button
type="primary"
size="small"
onClick={() => {
if (steps === 0) {
form.validateFields().then((values) => {
setFormData(values);
setSteps((preValue) => preValue + 1);
});
} else {
console.log(editorValue);
if (editorValue.trim() == "<p></p>" || editorValue == "") {
return message.error("文章内容不能为空");
} else {
setSteps((preValue) => preValue + 1);
}
}
}}
>
下一步
</Button>
) : (
""
)}
{steps !== 0 ? (
<Button
size="small"
onClick={() => setSteps((preValue) => preValue - 1)}
>
上一步
</Button>
) : (
""
)}
</div>
</div>
);
});
# 小小总结
学会了如何使用富文本编辑器,以及新闻编写步骤条,上传文件等
# 草稿箱新闻预览
import { PageHeader, Button, Descriptions } from "antd";
import axios from "axios";
import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { INews } from "./Drafts";
export const NewsPreview = () => {
const params = useParams();
const [newsInfo, setNewsInfo] = useState<INews>();
useEffect(() => {
axios.get(`/draft/${params.id}`).then((res) => setNewsInfo(res.data.data));
}, [params.id]);
const time = Date.now();
time.toLocaleString("zh-cn", {});
return (
<div>
{newsInfo && (<>
<PageHeader
onBack={() => window.history.back()}
title={newsInfo.title}
subTitle={newsInfo.Category.title}
>
<Descriptions size="small" column={3}>
<Descriptions.Item label="创建者">
{newsInfo.author}
</Descriptions.Item>
<Descriptions.Item label="创建时间">
{new Date(newsInfo.createTime!).toLocaleString("zh-CN", {
month: "long",
year: "2-digit",
day: "numeric",
hour:'2-digit',
minute:'2-digit'
})}
</Descriptions.Item>
<Descriptions.Item label="发布时间">
{newsInfo?.publishTime
? new Date(newsInfo?.publishTime!).toLocaleString("zh-Cn", {
year: "2-digit",
month: "long",
day: "numeric",
hour:'2-digit',
minute:'2-digit'
})
: "-"}
</Descriptions.Item>
<Descriptions.Item label="区域">
{newsInfo.region}
</Descriptions.Item>
<Descriptions.Item label="审核状态">
{newsInfo.auditState === 0 ? (
<span style={{ color: "red" }}>未审核</span>
) : newsInfo.auditState === 1 ? (
<span style={{ color: "blue" }}>审核中</span>
) : newsInfo.auditState === 2 ? (
<span style={{ color: "green" }}>审核通过</span>
) : (
<span style={{ color: "green" }}>审核未通过</span>
)}
</Descriptions.Item>
<Descriptions.Item label="发布状态">
{newsInfo.publishState === 0 ? (
<span style={{ color: "red" }}>未发布</span>
) : newsInfo.publishState === 1 ? (
<span style={{ color: "blue" }}>待发布</span>
) : newsInfo.publishState === 2 ? (
<span style={{ color: "green" }}>已发布</span>
) : (<span style={{ color: "green" }}>已下线</span>)}
</Descriptions.Item>
<Descriptions.Item label="访问数量">
<span style={{color:'green'}}>{newsInfo.view}</span>
</Descriptions.Item>
<Descriptions.Item label="点赞数量">
<span style={{color:'green'}}>{newsInfo.star}</span>
</Descriptions.Item>
<Descriptions.Item label="评论数量">
<span style={{color:'green'}}>0</span>
</Descriptions.Item>
</Descriptions>
</PageHeader>
{/* 内容区域 */}
<div dangerouslySetInnerHTML={{
__html:newsInfo.article
}} style={{
border:"1px solid #ccc",
padding:"0 24px"
}}>
</div>
</>
)}
</div>
);
};
# 新闻更新
import { PlusOutlined } from "@ant-design/icons";
import {
Button,
Form,
Input,
message,
notification,
PageHeader,
Result,
Select,
Steps,
} from "antd";
import axios from "axios";
import { observer } from "mobx-react";
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { NewsEditor } from "../../../components/newsEditor/NewsEditor";
import { INews } from "./Drafts";
import style from "./WriteNews.module.css";
const { Step } = Steps;
const { Option } = Select;
export interface Category {
id: number;
title: string;
value: string;
}
export const NewsUpdate = () => {
const params=useParams()
//获取新闻数据
useEffect(() => {
axios.get(`/draft/${params.id}`).then((res) => {
const {categoryId,title,coverImage,article}=res.data.data as INews
form.setFieldsValue({categoryId,title})
setPreviewImage('/api/'+coverImage!)
setEditorValue(article)
});
}, [params.id]);
const [steps, setSteps] = useState(0);
const [categoryList, setCategoryList] = useState<Category[]>([]);
const [imageFile, setImageFile] = useState<File | null>(null);
const [previewImage, setPreviewImage] = useState<string | null>(null);
const [formData, setFormData] = useState<{
title: string;
categoryId: number;
}>();
const [editorValue, setEditorValue] = useState("");
const navigate = useNavigate();
useEffect(() => {
axios.get("/category").then((res) => setCategoryList(res.data.data));
}, []);
const toBase64 = (file: File) => {
const fileReader = new FileReader();
fileReader.readAsDataURL(file);
fileReader.onload = (ev) => {
setPreviewImage(ev.target?.result! as string);
};
};
const imageOnChange: React.ChangeEventHandler<HTMLInputElement> = (e) => {
setImageFile(e.target.files![0]);
toBase64(e.target.files![0]);
};
const [form] = Form.useForm();
const saveHandle = (auditState: number) => {
const data = {
title: formData?.title!,
categoryId: formData?.categoryId + "",
article: editorValue!,
coverImage: imageFile!,
auditState: auditState + "",
};
//发送文件用FormData
const newsFormData = new FormData();
const dataEntries = Object.entries(data);
dataEntries.forEach((item) => {
newsFormData.set(item[0], item[1]);
});
//发生数据
axios.post(`/draft/${params.id}`, newsFormData).then((res) => {
navigate(auditState === 0 ? "/news-manage/draft" : "/audit-manage/list", {
replace: true,
});
notification.success({
message: "通知",
description: `更新成功,您可以到${
auditState === 0 ? "草稿箱" : "审核列表"
}中查看您的新闻`,
placement: "topRight",
duration: 3,
});
});
};
return (
<div>
{/* 标题 */}
<PageHeader title="更细新闻" onBack={()=>window.history.back()} />
{/* 步骤条 */}
<Steps size="small" current={steps}>
<Step title="基本信息" description="新闻标题,新闻分类" />
<Step title="新闻内容" description="新闻主体内容" />
<Step title="新闻提交" description="保存草稿或提交审核" />
</Steps>
{/* 内容区域 */}
<div className={steps === 0 ? "" : style.hidden}>
<Form
style={{ margin: "30px 0" }}
name="basic"
wrapperCol={{ span: 20 }}
initialValues={{ remember: true }}
form={form}
autoComplete="off"
>
<Form.Item
label="新闻标题"
name="title"
rules={[{ required: true, message: "请输入标题!" }]}
>
<Input />
</Form.Item>
<Form.Item
label="新闻分类"
name="categoryId"
rules={[{ required: true, message: "请选择分类!" }]}
>
<Select placeholder="请选择分类">
{categoryList.map((item) => (
<Option key={item.id} value={item.id}>
{item.title}
</Option>
))}
</Select>
</Form.Item>
<div className={style.coverImage}>
<input
type="file"
id="coverImage"
style={{ display: "none" }}
onChange={imageOnChange}
/>
<label className={style.iconBox} htmlFor="coverImage">
<p>上传封面图片:</p>
<div>
<p>{<PlusOutlined />}</p>
<p>Upload</p>
</div>
</label>
{previewImage ? (
<img className={style.image} src={previewImage!} alt="" />
) : (
""
)}
</div>
</Form>
</div>
<div className={steps === 1 ? "" : style.hidden}>
<NewsEditor
content={editorValue}
getEditorValue={(htmlStr: string) => {
setEditorValue(htmlStr);
}}
></NewsEditor>
</div>
<div className={steps === 2 ? "" : style.hidden}>
<Result
status="success"
title="更新新闻完成"
subTitle="请选择保存草稿箱或者提交审核"
extra={[
<Button type="primary" key="console" onClick={() => saveHandle(0)}>
保存草稿箱
</Button>,
<Button key="buy" onClick={() => saveHandle(1)}>提交审核</Button>,
]}
/>
</div>
{/* 按钮区域 */}
<div className={style.buttonBox}>
{steps !== 2 ? (
<Button
type="primary"
size="small"
onClick={() => {
if (steps === 0) {
form.validateFields().then((values) => {
setFormData(values);
setSteps((preValue) => preValue + 1);
});
} else {
if (editorValue.trim() == "<p></p>" || editorValue == "") {
return message.error("文章内容不能为空");
} else {
setSteps((preValue) => preValue + 1);
}
}
}}
>
下一步
</Button>
) : (
""
)}
{steps !== 0 ? (
<Button
size="small"
onClick={() => setSteps((preValue) => preValue - 1)}
>
上一步
</Button>
) : (
""
)}
</div>
</div>
);
};
# 13.审核
# 1.审核列表
import { DeleteOutlined, EditOutlined } from "@ant-design/icons"
import { Button, notification, Popover, Switch, Table, Tag } from "antd"
import axios from "axios"
import { observer } from "mobx-react"
import { useEffect, useState } from "react"
import { Link, useNavigate } from "react-router-dom"
import { useStore } from "../../../store"
import { INews } from "../news-manage/Drafts"
export const AuditList=observer(()=>{
const navigate=useNavigate()
const {userInfo}=useStore()
const [dataSource,setDataSource]=useState<INews[]>()
useEffect(()=>{
axios.get('/audit?author='+userInfo.user.username).then(res=>{
setDataSource(res.data.data)
})
},[userInfo.user.username])
const TagList=[
'',
<Tag color="orange">审核中</Tag>,
<Tag color="green">已通过</Tag>,
<Tag color="red">未通过</Tag>
]
const btn1 = (
<Button type="primary" size="small" onClick={() => {
notification.close(key)
navigate('/news-manage/draft')
}}>
确认
</Button>
);
const revokeHandle=async(item:INews)=>{
await axios.post(`/push_audit/${item.id}`,{auditState:0})
notification.info({
message: "通知",
description: `更新成功,您可以点击确认到草稿箱中查看您的新闻`,
placement: "topRight",
key,
btn:btn1,
duration: 3,
})
setDataSource(dataSource?.filter(i=>i.id!==item.id))
}
const key = `open${Date.now()}`;
const btn = (
<Button type="primary" size="small" onClick={() => {
notification.close(key)
navigate('/publish-manage/published')
}}>
确认
</Button>
);
const publishHandle=async(item:INews)=>{
await axios.post(`/push_audit/${item.id}`,{publishState:2,publishTime:new Date()})
notification.info({
message: "通知",
description: `发布成功,您可以点击确认到【发布管理/已发布】中查看您的新闻`,
placement: "topRight",
btn,
key,
duration:3
})
setDataSource(dataSource?.filter(i=>i.id!==item.id))
}
const columns = [
{
title: '新闻标题',
dataIndex: 'title',
render:(title,item:INews)=>{
return <Link to={`/news-manage/preview/${item.id}`}>{title}</Link>
}
},
{
title:'作者',
dataIndex:'author'
},
{
title: '新闻分类',
render:(item:INews)=>{
return item.Category.title
}
},
{
title: '审核状态',
render:(item:INews)=>{
return TagList[item.auditState]
}
},
{
title:'操作',
render:(item:INews)=>{
const ButtonList=[
'',
<Button type="primary" onClick={()=>{
revokeHandle(item)
}}>撤销</Button>,
<Button onClick={()=>publishHandle(item)} danger >发布</Button>,
<Button onClick={()=>navigate('/news-manage/update/'+item.id)}>修改</Button>
]
return <div style={{display:'flex',justifyContent:'center'}}>
{ButtonList[item.auditState]}
</div>
}
},
];
return <div>
<Table dataSource={dataSource} columns={columns} rowKey={(item)=>item.id} pagination={{
pageSize:5
}}></Table>
</div>
})
# 2.审核新闻
import { CheckOutlined, CloseOutlined } from "@ant-design/icons"
import { Button, notification, Table } from "antd"
import axios from "axios"
import { observer } from "mobx-react"
import { useEffect, useState } from "react"
import { Link, useNavigate } from "react-router-dom"
import { useStore } from "../../../store"
import { INews } from "../news-manage/Drafts"
export const AuditNews=observer(()=>{
const navigate=useNavigate()
const {userInfo}=useStore()
const [dataSource,setDataSource]=useState<INews[]>()
useEffect(()=>{
if(userInfo.user.roleId===1){
axios.get('/audit-list').then(res=>setDataSource(res.data.data))
}else{
axios.get(`/audit-list?roleId=${3}®ion=${userInfo.user.region}`).then(res=>setDataSource(res.data.data))
}
},[userInfo.user])
const key = `open${Date.now()}`;
const btn = (
<Button type="primary" size="small" onClick={() => {
notification.close(key)
navigate('/audit-manage/list')
}}>
确认
</Button>
);
const auditHandle=async (item:INews,auditState:number)=>{
const data={auditState:auditState,publishState:0}
if(auditState===2){
data.publishState=1
}
await axios.post(`/push_audit/${item.id}`,data)
setDataSource(dataSource?.filter(i=>i.id!==item.id))
notification.info({
message: "通知",
description: `更新成功,您可以点击确认到【审核管理/审核列表】中查看您的新闻`,
placement: "topRight",
key,
btn,
duration: 3,
})
}
const columns = [
{
title: '新闻标题',
dataIndex: 'title',
render:(title,item:INews)=>{
return <Link to={`/news-manage/preview/${item.id}`}>{title}</Link>
}
},
{
title:'作者',
dataIndex:'author'
},
{
title: '新闻分类',
render:(item:INews)=>{
return item.Category.title
}
},
{
title:'操作',
render:(item:INews)=>{
return <div style={{display:'flex',justifyContent:'center'}}>
<Button onClick={()=>auditHandle(item,2)} style={{margin:'0 20px'}} type="primary" shape="circle" icon={<CheckOutlined />}></Button>
<Button onClick={()=>auditHandle(item,3)} type="primary" shape="circle" danger icon={<CloseOutlined />}></Button>
</div>
}
},
];
return <div>
<Table dataSource={dataSource} columns={columns} rowKey={(item)=>item.id} pagination={{
pageSize:5
}}></Table>
</div>
})
# 14.发布管理
# 1.封装成通用组件和自定义hooks
NewsPublish.tsx
import { Table } from "antd"
import { ReactNode } from "react";
import { Link } from "react-router-dom";
import { INews } from "../../views/NewsSandBox/news-manage/Drafts"
export const NewsPublish=(props:{
dataSource:INews[],
button:(id:number)=>ReactNode
})=>{
const columns = [
{
title: '新闻标题',
dataIndex: 'title',
render:(title,item:INews)=>{
return <Link to={`/news-manage/preview/${item.id}`}>{title}</Link>
}
},
{
title:'作者',
dataIndex:'author'
},
{
title: '新闻分类',
render:(item:INews)=>{
return item.Category.title
}
},
{
title:'操作',
render:(item:INews)=>{
return <div style={{display:'flex',justifyContent:'center'}}>
{props.button(item.id)}
</div>
}
},
];
return <div>
<Table dataSource={props.dataSource} columns={columns}
rowKey={(item)=>item.id}
pagination={
{
pageSize:5
}
}
/>
</div>
}
usePublish.tsx
import { Button, notification } from "antd"
import axios from "axios"
import { useState, useEffect } from "react"
import { useNavigate } from "react-router-dom"
import { useStore } from "../../store"
import { INews } from "../../views/NewsSandBox/news-manage/Drafts"
export const usePublish=(publishState:number)=>{
const navigate=useNavigate()
const {userInfo}=useStore()
const [dataSource,setDataSource]=useState<INews[]>([])
useEffect(()=>{
axios.get(`/publish-list?author=${userInfo.user.username}&publishState=${publishState}`).then(res=>setDataSource(res.data.data))
},[userInfo.user.username])
const key = `open${Date.now()}`;
const btn = (
<Button type="primary" size="small" onClick={() => {
notification.close(key)
navigate('/publish-manage/published')
}}>
确认
</Button>
);
const btn1 = (
<Button type="primary" size="small" onClick={() => {
notification.close(key)
navigate('/publish-manage/sunset')
}}>
确认
</Button>
);
const publishHandle=async(id:number)=>{
await axios.post(`/push_audit/${id}`,{publishState:2,publishTime:new Date()})
notification.info({
message: "通知",
description: `上线成功,您可以点击确认到【发布管理/已发布】中查看您的新闻`,
placement: "topRight",
btn,
key,
duration:3
})
setDataSource(dataSource.filter(i=>i.id!==id))
}
const sunsetHandle=async(id:number)=>{
await axios.post(`/push_audit/${id}`,{publishState:3,publishTime:new Date()})
notification.info({
message: "通知",
description: `发布成功,您可以点击确认到【发布管理/已下线】中查看您的新闻`,
placement: "topRight",
btn:btn1,
key,
duration:3
})
setDataSource(dataSource.filter(i=>i.id!==id))
}
const deleteHandle=async(id:number)=>{
await axios.delete(`/draft/${id}`)
notification.info({
message: "通知",
description: `您已经删除了您的新闻`,
placement: "topRight",
duration:3
})
setDataSource(dataSource.filter(i=>i.id!==id))
}
return {
dataSource,
publishHandle,
sunsetHandle,
deleteHandle
}
}
sunset.tsx
import { Button } from "antd"
import { NewsPublish } from "../../../components/publish-manage/NewsPublish"
import { usePublish } from "../../../components/publish-manage/usePublish"
export const Sunset=()=>{
const {dataSource,deleteHandle,publishHandle}=usePublish(3)
return <div>
{/* 妙之处*/}
<NewsPublish dataSource={dataSource} button={
(id)=><>
<Button danger style={{margin:'0 20px'}} onClick={()=>deleteHandle(id)}>删除</Button>
<Button type="primary" onClick={()=>publishHandle(id)}>上线</Button>
</>
}></NewsPublish>
</div>
}
# 总结
在使用自定义hooks时可能某些方法不好获取参数,可以利用闭包和高阶函数传递参数,就是传递一个函数而不是写死的ReactNode
# 15.mobx状态管理
可以参考我的博客中mobx学习
collpased.ts
import { makeAutoObservable } from "mobx";
class CollapsedStore{
public collapsed:boolean=localStorage.getItem('collapsed')==='false'?false:true
constructor(){
makeAutoObservable(this,{},{autoBind:true})
}
changeCollapsed=()=>{
this.collapsed=!this.collapsed
localStorage.setItem('collapsed',this.collapsed+'')
}
}
export const collapsedState=new CollapsedStore
配合axios拦截器发挥奇效
import axios from "axios"
import { loadingState } from "../store/loading"
axios.defaults.baseURL='/api'
//请求拦截器
axios.interceptors.request.use((config)=>{
const token=localStorage.getItem('token')
if(config.url!=='/login'){
config.headers={
'Authorization':token
}
}
return config
})
//显示与隐藏加载框
axios.interceptors.request.use((config)=>{
loadingState.changeLoading(true)
return config
})
axios.interceptors.response.use((response)=>{
loadingState.changeLoading(false)
return response
},error=>{
loadingState.changeLoading(false)
return Promise.reject(error)
})
# 16.首页
import {
SettingOutlined,
EditOutlined,
EllipsisOutlined,
PieChartOutlined,
} from "@ant-design/icons";
import { Avatar, Button, Card, Col, Drawer, List, Row } from "antd";
import Meta from "antd/lib/card/Meta";
import axios from "axios";
import { observer } from "mobx-react";
import { MutableRefObject, useEffect, useRef, useState } from "react";
import { Link } from "react-router-dom";
import { useStore } from "../../../store";
import { INews } from "../news-manage/Drafts";
import * as Echarts from 'echarts'
import { Category } from "../news-manage/WriteNews";
export const Home = observer(() => {
const [isOpen,setIsOpen]=useState(false)
const {userInfo}=useStore()
const [viewListData,setViewListData]=useState<INews[]>([])
const [starListData,setStarListData]=useState<INews[]>([])
const [categoryList,setCategoryList]=useState<({
_count:number
} &Category)[]>([])
const [myCategoryList,setMyCategoryList]=useState<({
_count:number
} &Category)[]>([])
useEffect(()=>{
axios.get('/most-view').then(res=>setViewListData(res.data.data))
axios.get('/most-star').then(res=>setStarListData(res.data.data))
},[])
useEffect(()=>{
axios.get(`/category-list?author=${userInfo.user.username}`).then(res=>setMyCategoryList(res.data.data))
axios.get('/category-list').then(res=>setCategoryList(res.data.data))
},[])
const ref=useRef() as MutableRefObject<HTMLDivElement>
const drawerRef=useRef() as MutableRefObject<HTMLDivElement>
/* 渲染柱状图 */
const renderBar=()=>{
var myChart = Echarts.init(ref.current);
// 指定图表的配置项和数据
var option = {
title: {
text: '新闻分类图示'
},
tooltip: {},
legend: {
data: ['数量']
},
xAxis: {
data: categoryList.map(item=>item.title),
axisLabel:{
interval:0,
rotate:45
}
},
yAxis: {
minInterval: 1
},
series: [
{
name: '数量',
type: 'bar',
data: categoryList.map(item=>item._count)
}
]
};
// 使用刚指定的配置项和数据显示图表。
myChart.setOption(option);
window.onresize=()=>{
myChart.resize()
}
}
/* 饼状图 */
const colorRandom=()=>{
return '#'+Math.ceil((Math.random()*0xffffff)).toString(16)
}
colorRandom()
const [pieChart,setPieChart]=useState()
const renderPai=async()=>{
let myChart;
let colors
if(!pieChart){
myChart=Echarts.init(drawerRef.current)
colors=myCategoryList.map(()=>colorRandom())
setPieChart(myChart)
}else{
myChart=pieChart
}
const option = {
legend: { show: false },
series: [
{
name: '当前用户新闻分类图示',
type: 'pie',
radius: ['40%', '60%'],
avoidLabelOverlap: true,
itemStyle: { borderColor: '#fff', borderWidth: 2 },
color: colors,
label: {
formatter: function (e) {
let {
data: { value, name},
} = e;
return `{x|}{a|${name}}\n{b|${value}个}`;
},
minMargin: 5,
lineHeight: 15,
rich: {
x: { width: 10, height: 10, backgroundColor: 'inherit', borderRadius: 5 },
a: { fontSize: 14, color: 'inherit', padding: [0, 20, 0, 8] },
b: { fontSize: 12, align: 'left', color: '#666666', padding: [8, 0, 0, 18] },
c: { fontSize: 12, align: 'left', color: '#666666', padding: [8, 0, 0, 8] },
},
},
data: myCategoryList.map(item=>{
return {
value:item._count,
name:item.title,
}
}),
},
],
};
myChart.setOption(option)
}
useEffect(()=>{
renderBar()
//销毁
return ()=>{
window.onresize=null
}
},[categoryList])
return (
<div className="site-card-wrapper">
{/* 抽屉 */}
<Drawer width='600px' title="个人新闻分类" placement="right" onClose={()=>setIsOpen(false)} open={isOpen}>
{/* 饼状图 */}
<div ref={drawerRef} style={{height:'500px',width:'100%',marginTop:'30px'}}></div>
</Drawer>
<Row gutter={16}>
<Col span={8}>
<Card title="用户最常浏览" bordered={true}>
<List
size="small"
dataSource={viewListData}
rowKey={item=>item.id}
renderItem={(item:INews) => (
<List.Item>
<Link to={`/news-manage/preview/${item.id}`}>{item.title}</Link>
</List.Item>
)}
/>
</Card>
</Col>
<Col span={8}>
<Card title="用户点赞最多" bordered={true}>
<List
size="small"
dataSource={starListData}
rowKey={item=>item.id}
renderItem={(item) => (
<List.Item>
<Link to={`/news-manage/preview/${item.id}`}>{item.title}</Link>
</List.Item>
)}
/>
</Card>
</Col>
<Col span={8}>
<Card
cover={
<img
alt="example"
src="https://gw.alipayobjects.com/zos/rmsportal/JiqGstEfoWAOHiTxclqi.png"
/>
}
actions={[
<PieChartOutlined key="setting" onClick={()=>{
setIsOpen(true)
setTimeout(()=>{
renderPai()
},0)
}} />,
<EditOutlined key="edit" />,
<EllipsisOutlined key="ellipsis" />,
]}
>
<Meta
avatar={<Avatar src="https://joeschmoe.io/api/v1/random" />}
title={userInfo.user.username}
description={<div>
<strong>{userInfo.user.region} </strong>
<em>{userInfo.user.Role.rolename}</em>
</div>}
/>
</Card>
</Col>
</Row>
{/* 柱状图 */}
<div ref={ref} style={{height:'400px',width:'100%',marginTop:'30px'}}></div>
</div>
);
});
首页使用echarts做数据展示
# 17.游客系统
# News.tsx
import { Card, Col, PageHeader, Row } from "antd";
import Meta from "antd/lib/card/Meta";
import axios from "axios";
import { useEffect, useState } from "react";
import { INews } from "../NewsSandBox/news-manage/Drafts";
import image from '../../assets/2056017.jpg'
import { useNavigate } from "react-router-dom";
export const News = () => {
const [dataSource,setDataSource]=useState<INews[]>([])
const navigate=useNavigate()
useEffect(() => {
axios.get("/tourist-news").then((res) => setDataSource(res.data.data));
}, []);
return (
<div>
<PageHeader
className="site-page-header"
title="全球大标题"
subTitle="查看新闻"
/>
<div className="site-card-wrapper" style={{width:'95%',margin:'0 auto'}}>
<Row gutter={16}>
{dataSource.map(item=>{
return <Col span={8} key={item.id}>
<Card
hoverable
style={{ width:'80%',margin:'20px auto'}}
cover={
<img
src={item.coverImage ?'/image/'+item.coverImage:image}
/>
}
onClick={()=>{
navigate('/detail/'+item.id)
}}
>
<Meta
title={item.title}
description={<strong>{item.Category.title}</strong>}
/>
</Card>
</Col>
})}
</Row>
</div>
</div>
);
};
# Detail.tsx
import { HeartTwoTone } from "@ant-design/icons"
import { PageHeader, Descriptions } from "antd"
import axios from "axios"
import { useEffect, useState } from "react"
import { useParams } from "react-router-dom"
import { INews } from "../NewsSandBox/news-manage/Drafts"
export const Detail=()=>{
const params=useParams()
const [newsInfo, setNewsInfo] = useState<INews>();
useEffect(()=>{
axios.get('/tourist-detail/'+params.id).then(res=>{
return res
}).then(
(res)=>{
axios.post('/tourist-detail/'+params.id,{view:res.data.data.view+1}).then(res=>{
setNewsInfo(res.data.data)
})
}
)
},[params.id])
const clickHandle=(id:number)=>()=>{
axios.post('/tourist-detail/'+params.id,{star:newsInfo?.star!+1}).then(res=>{
setNewsInfo(res.data.data)
})
}
return <div>
{newsInfo && (<>
<PageHeader
onBack={() => window.history.back()}
title={newsInfo.title}
subTitle={<div>
<span style={{margin:'0 10px'}}>{newsInfo.Category.title}</span>
<HeartTwoTone twoToneColor="#eb2f96" onClick={clickHandle(+params.id!)} />
</div>}
>
<Descriptions size="small" column={3}>
<Descriptions.Item label="作者">
{newsInfo.author}
</Descriptions.Item>
<Descriptions.Item label="发布时间">
{newsInfo?.publishTime
? new Date(newsInfo?.publishTime!).toLocaleString("zh-Cn", {
year: "2-digit",
month: "long",
day: "numeric",
hour:'2-digit',
minute:'2-digit'
})
: "-"}
</Descriptions.Item>
<Descriptions.Item label="区域">
{newsInfo.region}
</Descriptions.Item>
<Descriptions.Item label="访问数量">
<span style={{color:'green'}}>{newsInfo.view}</span>
</Descriptions.Item>
<Descriptions.Item label="点赞数量">
<span style={{color:'green'}}>{newsInfo.star}</span>
</Descriptions.Item>
<Descriptions.Item label="评论数量">
<span style={{color:'green'}}>0</span>
</Descriptions.Item>
</Descriptions>
</PageHeader>
{/* 内容区域 */}
<div dangerouslySetInnerHTML={{
__html:newsInfo.article
}} style={{
/* border:"1px solid #ccc", */
padding:"0 24px"
}}>
</div>
</>
)}
</div>
}
# 总结
哪有什么大佬,唯有熟能生巧而