前面的话
笔者在做一个完整的博客上线项目, 包括前台 https://xiaohuochai.cc/ , 后台 https://admin.xiaohuochai.cc/ , 后端接口 https://api.xiaohuochai.cc/ 和服务器配置. 本文将详细介绍使用 react 全家桶制作的博客后台管理系统
概述
该项目是基于 react 全家桶 (React,React-router-dom,redux,styled-components) 开发的一套博客后台管理系统, 用于前端小站 https://xiaohuochai.cc/ 的管理, 主要功能包括游客浏览, 文章管理, 类别管理, 评论通知, 推荐设置和用户管理
[访问地址]
域名: https://admin.xiaohuochai.cc/
Github: https://github.com/littlematch0123/blog-admin
[项目介绍]
采用移动优先的响应式布局, 移动端, 桌面端均可适配; 字体大小使用 em 单位, 桌面端的文字相应变大; 移动端大量使用滑屏操作, 桌面端通过光标设置, 自定义滚动条, 回车确定等, 提升交互体验
根据 HTML 标签内容模型, 使用语义化标签, 尽量减少标签层级, 尽量使用 React.Fragment 来代替 div
采用统一的色调处理, 除了黑白两色外, 所有页面共使用了 8 种颜色, 保证了页面颜色素雅, 统一
使用 service worker 实现了离线缓存, 配置了 robots, 禁止搜索引擎抓取页面
使用 styled-components 插件, 实现 css in JS. 所有图标资源均采用 svg 格式, 并存储到 common/BaseImg 组件中, 方便管理, 图片资源均上传到七牛云图床, 使用外链访问. 最终, html,css,image 都使用 js 管理
没有引用第三方组件库, 如 bootstrap 或蚂蚁设计, 而是自己开发了项目中所需的公共组件. 在 common 目录下, 封装了头像, 筛选框, 全屏, loading, 遮罩, 搜索框, 滑屏, 联动选择等组件, 方便开发
功能组件按照功能 (Post,Comment...) 而不是角色 (controllers,models,views) 分类, 将展示组件 component 和容器组件 container 整合为一个文件
状态管理借鉴了 vuex 的管理模式, action-types,action,reducer,selecter,state 整合到每个模块目录的 module.js 文件下. 为了方便扩展, 所有的 state 都设置了 filter 字段
使用配置数据, 实现了数据和应用分离, 配置数据包括 API 调用地址和颜色值, 以常量的形式存储在 constants 目录下
使用 esLint 规范 JS 代码, 代码风格参照 airbnb 规范, 所有命名采用驼峰写法, 公共组件以 Base 为前缀, 函数大多以 get 或 set 为前缀, 事件函数以 on 为前缀, 异步函数以 async 为后缀, 布尔值基本以 do 或 is 为前缀
使用 styleLint 规范 CSS 代码, 按照布局类属性, 盒模型属性, 文本类属性, 修饰类属性的顺序编写代码, 并使用 order 插件进行校验
使用 react 最新版本的方法, 包括 createRef(),getDerivedStateFromProps 生命周期, React.Fragment 语法糖等
进行了代码优化, 包括减少请求数量(文件合并 , 小图片使用 Base64, 使用 301 而不是 302 重定向, 静态资源使用强缓存, 接口资源使用协商缓存, 使用离线缓存, 长缓存优化, CSS 内联), 减小资源大小(文件压缩, andriod 下使用 webp 格式图片, 开启 gzip), 优化网络连接(使用 DNS 预解析, 使用 keep-alive 持久连接, 使用 HTTP2 管道化连接), 优化资源加载(优化资源加载位置, 图片懒加载), 减少重绘回流(减少兄弟选择器, 动画元素硬件渲染, 使用函数节流, 及时清理环境)
该项目的一个隐藏彩蛋是摇一摇功能, 可以直接摇到前台页面, 当然也可以再摇回来
最终优化评分如下所示
功能演示
功能主要包括游客浏览, 评论通知, 用户管理, 推荐设置, 文章管理和类别管理
[游客浏览]
在没有管理员帐号的情况下, 可以点击游客浏览进入后台. 但是, 游客只有浏览权限, 没有操作权限
[评论通知]
有新评论未查看时, 右上角快捷菜单上会出现评论通知的按钮. 查看评论后, 通知按钮消失
[用户管理]
用户管理包括查看所有用户信息, 查看用户点赞情况, 查看用户评论情况, 按用户名拼音排序, 按点赞数排序, 按评论数排序以及设置用户状态
[推荐管理]
推荐管理包括文章推荐和专题推荐两类
1, 文章推荐
- - assets // 存放静态资源, 包括通用 CSS 和图片
- global.css // 全局 CSS
- login_bg.jpg // 登录框背景图
- - common // 存放公共组件
- BaseArticle.js // 文章组件
- BaseAvatar.js // 头像组件
- ...
- - components // 存放功能组件
- Category // 类别组件
- AddCategory.js // 类别添加组件
- DeleteCategory.js // 类别删除组件
- UpdateCategory.js // 类别更新组件
- Category.js // 类别路由组件
- CategoryForm.js // 类别基础组件
- CategoryItem.js // 类别项组件
- CategoryItemList.js // 类别列表组件
- CategoryRootList.js // 类别根列表组件
- module.js // 类别状态管理
- ...
- - constants // 存放常量配置
- API.js // 存放 API 调用地址
- Colors.js // 存放颜色值
- - store // 存放 redux
- index.js
- - utils // 存放工具方法
- async.js // fetch 方法
- history.js // 路由方法
- util.js // 其他工具方法
- BaseArticle.js // 文章组件
- BaseAvatar.js // 头像组件
- BaseBack.js // 返回组件
- BaseBadge.js // 徽章组件
- BaseButton.js // 按钮组件
- BaseCard.js // 卡片组件
- BaseFilterList.js // 筛选框组件
- BaseFullScreen.js // 全屏组件
- BaseImg.js // 图片组件
- BaseInput.js // 输入框组件
- BaseLoading.js // loading 组件
- BaseMask.js // 遮罩组件
- BaseSearchBox.js // 搜索框组件
- BaseSwipeItem.js // 滑屏组件
- BaseTextArea.js // 多行输入框组件
- BaseTitle.js // 标题组件
- BreadCrumb.js // 面包屑组件
- ButtonBox.js // 按钮组组件
- ButtonInverted.js // 反色按钮组件
- ButtonWithAutoWidth.js // 自适应按钮组件
- InputPassword.js // 密码框组件
- LinkageSelector.js // 联动选择组件
- 弹出框(Alert)
- 登录框(Auth)
- 类别管理(Category)
- 评论管理(Comment)
- 主页(Home)
- 点赞管理(Like)
- 文章管理(Post)
- 七牛传图(Qiniu)
- 推荐设置(Recommend)
- 页面尺寸(Size)
- 用户管理(User)
- <section style={{ height: `${wrapHeight}px` }}>
- <HomeHeader />
- <Inner>
- ...
- </Inner>
- <HomeNav />
- </section>
- const Header = styled.header`
- height: 50px;
- `
- const Inner = styled.main`
- height: calc(100% - 100px);
- background: ${PRIMARY_BG_COLOR};
- `
- const List = styled.nav`
- height: 50px;
- `
- // app.js
- render() {
- const { doShowLoading, alertText, hideAlertText } = this.props
- return (
- <React.Fragment>
- { doShowLoading && <AlertWithLoading /> }
- { !!alertText && <AlertWithText text={alertText} onExit={hideAlertText} />}
- <Router history={history}>
- ...
- </Router>
- </React.Fragment>
- )
- }
- // category.js
- const Category = () =>
- (
- <Switch>
- <Route exact path="/categories" component={CategoryRootList} />
- <Route exact path="/categories/:id" component={CategoryItemList} />
- <Route path="/categories/:id/add" component={AddCategory} />
- <Route path="/categories/:id/update" component={UpdateCategory} />
- <Route path="/categories/:id/delete" component={DeleteCategory} />
- </Switch>
- )
- import auth from '@/components/Auth/module'
- import size from '@/components/Size/module'
- import alert from '@/components/Alert/module'
- import categories from '@/components/Category/module'
- import posts from '@/components/Post/PostsModule'
- import post from '@/components/Post/PostModule'
- import comments from '@/components/Comment/module'
- import likes from '@/components/Like/module'
- import qiniu from '@/components/Qiniu/module'
- import users from '@/components/User/module'
- const rootReducer = combineReducers({
- auth, size, alert, categories, posts, post, comments, likes, qiniu, users
- })
- // action-types
- export const SET_COMMENTS_FILTER = 'SET_COMMENTS_FILTER'
- // state
- const initialState = {
- filter: null,
- docs: []
- }
- // action
- export const setCommentsFilter = filter => dispatch => new Promise(resolve => {
- resolve()
- dispatch({ type: SET_COMMENTS_FILTER, filter })
- })
- // reducer
- const comments = (state = initialState, action) => {
- switch (action.type) {
- case SET_COMMENTS_FILTER:
- return { ...state, filter: action.filter }
- }
- export default comments
- // selector
- export const getCommentsFilter = state => state.comments.filter
- // PostRecommendItem
- <BaseSearchBox
- searchText={title}
- datas={posts}
- onInput={this.onInput}
- onBack={() => { this.setState({ doShowSearchBox: false }) }}
- />
- onInput = data => {
- this.setState({ doShowSearchBox: false })
- const { updatePostAsync, showAlertText } = this.props
- const { prevData, datas } = this.statethis.setState({
- datas: datas.map(t => {
- if (t.number === data.number) return data
- return t
- })
- })
- ...
- }
- // BaseSearchBox
- <List innerRef={this.scrollRef}>
- {resultDatas.map(t =>
- <Item key={t._id} onClick={() => { onInput && onInput(t) }}>{t.title}</Item>)}
- {resultDatas.length>= limitNumber && !doNeedMoreDatas &&
- <ExtendedItem > 已经到底了...</ExtendedItem>}
- </List>
- // CommentForm
- constructor(props) {
- super(props)
- const { operate, location } = props
- if (operate === 'update' && location.state) {
- const { content } = location.state.comment
- this.state = { content }
- } else {
- this.state = { content: '' }
- }
- }
- // CommentList
- history.push({ pathname: `${BasePostUrl}/comments/${t._id}/update`, state: { comment: t } })
- //CategoryForm.js
- componentDidMount() {
- const { operate, match, setCategoriesFilter } = this.props
- setCategoriesFilter(Number(match.params.id)).then(() => {
- if (operate === 'update') {
- const { category } = this.props
- const { name, description } = category
- if (name) {
- this.setState({ name, description })
- } else {
- history.push(`/categories/${getParentNumber(Number(match.params.id))}`)
- }
- }
- })
- }
- const mapStateToProps = state => ({
- category: getCategoryByFilter(state)
- })
- export default connect(mapStateToProps, { setCategoriesFilter })(CategoryForm)
- componentDidMount() {
- const { operate, location, match } = this.props
- if (operate === 'update' && !location.state) {
- history.push(`/posts/${match.params.postId}/comments`)
- }
- }
- [reselect]
- export const getRecommendedCategories = createSelector(getCategories,
- datas => datas.filter(t => t.recommend).sort((a, b) => a.index - b.index))
- [promise]
- export const setCategoriesFilter = filter => dispatch => new Promise(resolve => {
- resolve()
- dispatch({ type: SET_CATEGORIES_FILTER, filter })
- })
- const AddCategory = ({ match }) => <CategoryForm match={match} operate="add" />
- const UpdateCategory = ({ match }) => <CategoryForm match={match} operate="update" />
- componentDidMount() {
- this.scrollRef.current.addEventListener('scroll', throttle(this.onScroll))
- }
- componentWillUnmount() {
- this.scrollRef.current.removeEventListener('scroll', throttle(this.onScroll))
- }
- constructor(props) {
- super(props)
- this.state = {}
- }
- componentDidMount() {
- this.test()
- }
- test() {
- this.setState({ name: '' })
- }
- // API.js
- let API_HOSTNAME
- if (process.env.NODE_ENV === 'development') {
- API_HOSTNAME = '/local'
- } else {
- API_HOSTNAME = '/api'
- }
- export const BASE_AUTH_URL = `${API_HOSTNAME}/auth/admin`
- export const BASE_USER_URL = `${API_HOSTNAME}/users`
- export const BASE_POST_URL = `${API_HOSTNAME}/posts`
- export const BASE_TOPIC_URL = `${API_HOSTNAME}/topics`
- export const BASE_CATEGORY_URL = `${API_HOSTNAME}/categories`
- export const BASE_LIKE_URL = `${API_HOSTNAME}/likes`
- export const BASE_COMMENT_URL = `${API_HOSTNAME}/comments`
- export const BASE_RECOMMEND_URL = `${API_HOSTNAME}/recommends`
- export const BASE_QINIU_URL = `${API_HOSTNAME}/qiniu`
- export const STATIC = 'https://static.xiaohuochai.site'
- export const CLIENT_URL = 'https://www.xiaohuochai.cc'
- // Colors.js
- export const PRIMARY_COLOR = '#00a8e5'
- export const DARK_COLOR = '#0066cc'
- export const ERROR_COLOR = '#f67280'
- export const PRIMARY_BG_COLOR = '#fafafa'
- export const TRANSPARENT_BG_COLOR = 'rgba(7, 17, 27, .4)'
- export const DARK_BG_COLOR = '#f5f5f5'
- export const PRIMARY_LINE_COLOR = '#eee'
- export const DARK_LINE_COLOR = '#ebedf0'
- /**
- * 函数节流
- * @param {fn} function test(){}
- * @return {fn} function test(){}
- */
- export const throttle = (fn, wait = 100) => function func(...args) {
- if (fn.timer) return
- fn.timer = setTimeout(() => {
- fn.apply(this, args)
- fn.timer = null
- }, wait)
- }
- <Router history={history}>
- <Switch>
- <Route path="/login" component={AuthLogin} />
- <Route
- path="/"
- render={props => {
- if (sessionStorage.getItem('token') && sessionStorage.getItem('user')) {
- return <Home {...props} />
- }
- return <Redirect to="/login" />
- }}
- />
- </Switch>
- </Router>
- <Label htmlFor="username">用户名:</Label>
- <Label htmlFor="password"> 密码:</Label>
- div {
- position: relative;
- &::after {
- position: absolute;
- left: 0;
- right: 0;
- height: 1px;
- transform: scaleY(.5);
- content: '';
- }
- `
- <StyledMask className={doShowMenuList ? 'mask-show' : ''} />
- <StyledList className={doShowMenuList ? 'transform-show' : ''} />
- const StyledList = styled(HomeMenuList)`
- transform: translateY(-100%);
- transition: .2s;
- `
- const StyledMask = styled(BaseMask)`
- z-index: 2;
- display: none;
- `
- const MenuBox = styled.div`
- cursor: pointer;
- & .transform-show {
- transform: translateY(0);
- }
- & .mask-show {
- display: block;
- }
- `
- // BaseImg.js
- ...
- export const Home = props => (
- <svg height={24} viewBox="0 0 24 24" width={24} {...props}>
- <path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z" />
- <path d="M0 0h24v24H0z" fill="none" />
- </svg>
- )
- static getReg(searchText) {
- return new RegExp(searchText.replace(/[[(){}^$|?*+.\\-]/g, '\\$&'), 'ig')
- }
- static checkSwipe(absMove, duration) {
- const THRESHOLD = 10
- const SHORTESTTIME = 300
- // 距离大于 10, 且时间小于 300ms, 才算做一次滑动
- return Boolean(absMove> THRESHOLD && duration <SHORTESTTIME)
- }
- onTouchStart = e => {
- this.startTime = new Date().getTime()
- this.startX = e.targetTouches[0].pageX
- this.startY = e.targetTouches[0].pageY
- }
- onTouchEnd = e => {
- const { pageX, pageY } = e.changedTouches[0]
- // 如果 y 轴移动距离大于元素高度, 说明手指已经移出元素本身, 则取消滑动
- if (pageY - this.startY> this.clientHeight) {
- return false
- }
- const moveX = pageX - this.startX
- const duration = new Date().getTime() - this.startTime
- // 如果符合滑动要求, 且向左滑动, 则控制条滑出
- if (BaseSwipeItem.checkSwipe(Math.abs(moveX), duration) && moveX <0) {
- this.setState({ doShowControlBox: true })
- } else {
- this.setState({ doShowControlBox: false })
- }
- return true
- }
- <Wrap className={className} {...rest}>
- <StyledInput
- id="password"
- textIndent={textIndent}
- color={color}
- value={value}
- onChange={onChange}
- type={doShowPassword ? 'password' : 'text'}
- />
- { doShowPassword ?
- <Visibility onClick={onChangeStatus} />
- : <VisibilityOff onClick={onChangeStatus} />
- }
- </Wrap>
- import { showLoading, hideLoading, showAlertText, hideAlertText } from '@/components/Alert/module'
- import { logout } from '@/components/Auth/module'
- const async = ({ dispatch, url, method, data, headers, success, fail, doHideAlert }) => {
- // 显示 loading
- dispatch(showLoading())
- let fetchObj = {}
- if (method) {
- fetchObj = {
- method,
- body: JSON.stringify(data),
- headers: new Headers({ ...headers, 'Content-Type': 'application/json' })
- }
- }
- fetch(url, fetchObj).then(res => {
- // 关闭 loading
- dispatch(hideLoading())
- return res.json()
- }).then(json => {
- // 成功
- if (json.code === 0) {
- !doHideAlert && dispatch(showAlertText(json.message))
- setTimeout(() => {
- dispatch(hideAlertText())
- }, 1000)
- success && success(json.result)
- // 自定义错误
- } else if (json.code === 1) {
- dispatch(showAlertText(json.message))
- // 系统错误
- } else if (json.code === 2) {
- dispatch(showAlertText(json.message))
- fail && fail(json.err)
- // 认证失败
- } else if (json.code === 3) {
- dispatch(showAlertText(json.message))
- dispatch(logout)
- // 权限不足
- } else if (json.code === 4) {
- dispatch(showAlertText(json.message))
- }
- }).catch(() => {
- dispatch(showAlertText('服务器故障'))
- })
- }
- export default async
- // utils/history.js
- import createBrowserHistory from 'history/createBrowserHistory'
- const customHistory = createBrowserHistory()
- export default customHistory
- // app.js
- import history from '@/utils/history'
- <Router history={history}>
- <Switch>
- <Route path="/login" component={AuthLogin} />
- <Route
- path="/"
- render={props => {
- if (sessionStorage.getItem('token') && sessionStorage.getItem('user')) {
- return <Home {...props} />
- }
- return <Redirect to="/login" />
- }}
- />
- </Switch>
- </Router>
- import history from '@/utils/history'
- // 跳转到首页
- history.push('/')
- // app.js
- componentDidMount() {
- const { setWrapSize } = this.props
- const { clientHeight, clientWidth } = document.documentElement
- setWrapSize({ clientHeight, clientWidth })
- window.addEventListener('orientationchange', this.setSize)
- }
- // BaseFullScreen
- <Wrap className={className} style={{ height: `${wrapHeight}px` }} {...rest}>{children}</Wrap>
- const BaseInput = ({ value, onChange, ...rest }) =>
- <Input {...rest} value={value} onChange={onChange} autoComplete="off" autoCapitalize="off" />
- * {
- -webkit-tap-highlight-color: rgba(0, 0, 0, 0);
- }
- div {
- -webkit-overflow-scrolling: touch;
- }
来源: https://www.cnblogs.com/xiaohuochai/p/9213436.html