帮一朋友从头搭管理后台,他直呼通达,但基操勿六

18,844 阅读8分钟

本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!

朋友找我帮忙

对,还是那位朋友,一位接近40的澳洲老哥,找我帮忙搞一个管理后台,他刚找到编程的工作,工作内容会用到管理后台,但是他找了一圈开源项目,直接被搞的晕头转向,完全不知如何下手,于是找我帮忙。

image.png

经过了解,有几点是最困扰他的:

  • 项目太大,东西太多,不知啥有用啥没用,看着迷糊
  • 想实现一个功能,但可能项目中已经实现了却不知,而存在重复做轮子的风险
  • 集成度太高,对于他的黑魔法太多,好多未知技术没解锁,看文档一脸懵。
  • 配套的后端项目往往没有,有的又太难,比如ruoyi整体很强,但是后端和前端关系太密切,光看前端看不懂。

而他的初衷又是那么的简单合理,他就想有一个自己玩明白的管理后台项目,这样他便有把控感,可以更有底气的应对以后的工作。

于是,我便开始着手为他从头搭管理后台,尽量朴实无华的让他无压上手。

我让朋友列个愿望清单

我问了一下他希望都用什么技术,列出来,我好整在一起,于是我们针对几个问题进行了商量。

  • React和Vue选一个?
    • Vue3,据说赢麻了
  • 那组件库用啥element,antd?
    • Arco,好看,而且还是字节的,有潜力。
  • webpack还是vite?
    • Vite,听说秒开,开发体验好
  • 状态管理用Vuex还是Pinia?
    • Pinia,用新的
  • 后端怎么安排?
    • Koa2起手项目学习,最后一点一点变成Java

那么技术方案就定下来了:

Vue3+Vite+Pinia+koa2&Java搞一全栈项目。

我精心挑选出几个重要的功能点,先做出来

  • 权限要管
    • 这个对应了角色管理,角色用来配置权限
  • 登录要做
    • 这个对应了用户管理,再把角色配给用户
  • 表单要封
    • 管理后台中,表单用的最为频繁 ,需要进行封装,节省力气,避免重复。
  • 关系要理
    • 路由&菜单&操作紧密关联,并通过权限驱动,逻辑理清

那么初步要做的功能点就定下来了:

  • 角色管理
  • 用户管理
  • 封装表单
  • 权限驱动

开始动手

光配路由就够了,直接生成菜单数据

路由数据先配置好

export const routesData =  [
  {
    name: index.name,
    meta: {
      title: "首页",
      icon: "system",
      noCache: false,
      link: null,
    },
  },
  {
    name: sys.name,
    meta: {
      title: "系统管理",
      icon: "system",
      noCache: false,
      link: null,
    },
    children: [
      {
        name: user.name,
        meta: {
          title: "管理",
          icon: "user",
          noCache: false,
          link: null,
        },
      },
      {
        name: role.name,
        meta: {
          title: "角色管理",
          icon: "role",
          noCache: false,
          link: null,
        },
      },
    ],
  },
]

发现没,配置数据中没有path,也就是匹配路由的地址,因为树状结构都定下来了,那么path就通过算法生成就好了啊,省事儿。

写个算法,补全路由配置中的path

const processRoute = (
  children: RouteRecordRaw[],
  routesData: RouteItemDataT[],
  prefix: string
) => {
  routesData.forEach((routeItem, index) => {
    const { name } = routeItem;
    if (persmissions.includes(name)) {
      let routeData = routesConfig[name] as RouteRecordRaw;
      routeData.name = name;
      // 沿途记录,然后拼接成path
      routeData.path = prefix + "/" + name;
      children!.push(routeData);
      if (routeItem.children!?.length > 0) {
        routeData.children = [];
        processRoute(routeData.children, routeItem.children!, routeData.path);
      }
    }
  });
};

递归一下,沿途记录,然后拼接成path,搞定~~~,省的再去维护了,直接自动生成。

菜单直接根据路由数据生成就好了

路由数据是树状的,那么就把菜单组件写一个递归组件就好了

// 引入自己,然后在模版里递归调用
import MenuItem from './index.vue'
...
<template>
    <template v-if="hasOneShowingChild(itemData.children, itemData)">
        <a-menu-item :key="itemData.name">{{ itemData.meta!.title }}</a-menu-item>
    </template>
    <a-sub-menu v-else :key="itemData.name">
        <template #title>
            <IconCalendar></IconCalendar> {{ itemData.meta!.title }}
        </template>
        <MenuItem v-for="child in itemData.children" :itemData="child" :key="child.name">
        </MenuItem>
    </a-sub-menu>
</template>

递归组件,就是自己递归调用自己,这样给一个树状数据,直接就能递归生成出菜单

标签页安排上,而且是那种有记忆的

ezgif.com-gif-maker (6).gif

标签页通过路由切换进行管理,同时,保证可以正确的关闭和记忆操作 记忆操作,就是实用的vue的keep-alive

但是话说,React怎么keep-alive你知道么? 嘿嘿,我也实现了,实现的原理是通过useOutlet和结合封装一个keep-alive组件就行了。

import React, { useRef, useEffect, useReducer, useMemo, memo } from 'react'
import { useLocation, useOutlet } from 'react-router-dom'

const KeepAlive = (props: any) => {
  const outlet = useOutlet()
  const { include, keys, children } = props
  const { pathname } = useLocation()
  const componentList = useRef(new Map())
  const forceUpdate = useReducer((bool: any) => !bool, true)[1] // 强制渲染
  const cacheKey = useMemo(
    () => pathname + '__' + keys[pathname],
    [pathname, keys]
  ) // eslint-disable-line
  const activeKey = useRef<string>('')

  useEffect(() => {
    componentList.current.forEach(function (value, key) {
      const _key = key.split('__')[0]
      if (!include.includes(_key) || _key === pathname) {
        this.delete(key)
      }
    }, componentList.current)

    activeKey.current = cacheKey
    if (!componentList.current.has(activeKey.current)) {
      componentList.current.set(activeKey.current, outlet)
    }
    forceUpdate()
  }, [cacheKey, include]) // eslint-disable-line

  return (
    <div>
      {Array.from(componentList.current).map(([key, component]) => (
        <div key={key}>
          {key === activeKey.current ? (
            <div>{component}</div>
          ) : (
            <div style={{ display: 'none' }}>{component}</div>
          )}
        </div>
      ))}
    </div>
  )
}

export default memo(KeepAlive)

代码贴出来了,有兴趣的同学可以自行试验,这算是一个不错的技巧,分享给大家。

表单搞起来

表单的场景非常的多,而且,一写就是多个,但不同的表单项大体模式也差不多,所以,如果能够仅仅通过配置数据就显示表单,岂不是很不错,又简单,又灵活,方便扩展。

fromData: [
            {
                id: "测试Input表单",
                title: "测试Input",
                type: FORM_INPUT,
                span: 12,
                config: {}
            },
            {
                id: "测试Tree表单",
                title: "测试Tree",
                type: FORM_TREE,
                span: 24,
                config: {
                    treeOptions: {
                        checkStrictly: true,
                        defaultCheckedKeys: permissions.value!
                    },
                    initValue: allPermissionsData.value,
                }
            },
            {
                id: "测试Select表单",
                title: "测试Select",
                type: FORM_SELECT,
                span: 12,
                config: {
                    options: [
                        { name: "test1", value: "test1" },
                        { name: "test2", value: "test2" },
                    ]
                }
            }
        ]

然后驱动的效果

image.png

能实现的基础,就是我封装好了FormItem

<template>
    <a-form-item  :rules="config?.rules" label-col-flex="70px" :field=id :label="title">
        <SelectItem :formValues="formValues" :form-data="formData" v-if="type == FORM_SELECT"></SelectItem>
        <TreeItem :formValues="formValues" :form-data="formData" v-else-if="type == FORM_TREE" />
        <a-input :placeholder="config?.placeholder" v-else v-model="formValues[id]" />
    </a-form-item>
</template>

通过传入的参数,判断渲染什么表单项目,通过ts类型约束一下,就很nice了。

登录实现了

首先开发一下登录页

登录页就是简单的提供账户密码登录就好,不过也不能太单调,加点花吧~~~

ezgif.com-gif-maker (3).gif 效果是我从开源社区中学着搞的,我感觉效果还是不错的,代码贴出,直接用。

<template>
    <div class="content">
        <pre ref="container" class="container" id="container"></pre>
        <pre ref="container2" ></pre>
    </div>
</template>

<script setup lang="ts">
import { onMounted, ref, toRefs } from 'vue';

const props = defineProps<{ texts: string[] }>()
const { texts } = toRefs(props)

let container = ref()
let container2 = ref()


let defaultRun: boolean = true;
let infinite: boolean = true;
let frameTime: number = 75;
let endWaitStep = 3
let prefixString = "";
let runTexts = [""];
let colorTextLength = 5;
let step = 1;
let colors = [
    "rgb(110,64,170)",
    "rgb(150,61,179)",
    "rgb(191,60,175)",
    "rgb(228,65,157)",
    "rgb(254,75,131)",
    "rgb(255,94,99)",
    "rgb(255,120,71)",
    "rgb(251,150,51)",
    "rgb(226,183,47)",
    "rgb(198,214,60)",
    "rgb(175,240,91)",
    "rgb(127,246,88)",
    "rgb(82,246,103)",
    "rgb(48,239,130)",
    "rgb(29,223,163)",
    "rgb(26,199,194)",
    "rgb(35,171,216)",
    "rgb(54,140,225)",
    "rgb(76,110,219)",
    "rgb(96,84,200)",
];
let inst = {
    text: "",
    prefix: -(prefixString.length + colorTextLength),
    skillI: 0,
    skillP: 0,
    step: step,
    direction: "forward",
    delay: endWaitStep,
};

function randomNum(minNum: number, maxNum: number): number {
    switch (arguments.length) {
        case 1:
            return parseInt((Math.random() * minNum + 1).toString(), 10);
        case 2:
            return parseInt((Math.random() * (maxNum - minNum + 1) + minNum).toString(), 10);
        default:
            return 0;
    }
}
let randomTime: number = randomNum(15, 150);
let destroyed: boolean = false;
let continue2: boolean = false;
let infinite0: boolean = true;

onMounted(() => {
    runTexts = texts.value;
    continue2 = defaultRun;
    infinite0 = infinite;
    inst.delay = endWaitStep;
    if (!infinite0) {
        if (runTexts.length > 1) {
            console.warn(
                "在设置infinite=false的情况下,仅第一个字符串生效,后续字符串不再显示。"
            );
        }
    }
    init();
})

function init(): void {
    setTimeout(() => {
        if (destroyed) {
            return;
        }
        container.value && loop();
    }, randomTime);
}

function render(dom: HTMLDivElement, t: string, ut?: string): void {
    if (inst.step) {
        inst.step--;
    } else {
        inst.step = step;
        if (inst.prefix < prefixString.length) {
            inst.prefix >= 0 &&
                (inst.text += prefixString[inst.prefix]);
            inst.prefix++;
        } else {
            switch (inst.direction) {
                case "forward":
                    if (inst.skillP < t.length) {
                        inst.text += t[inst.skillP];
                        inst.skillP++;
                    } else {
                        if (inst.delay) {
                            inst.delay--;
                        } else {
                            inst.direction = "backward";
                            inst.delay = endWaitStep;
                        }
                    }
                    break;
                case "backward":
                    if (inst.skillP > 0) {
                        inst.text = inst.text.slice(0, -1);
                        inst.skillP--;
                    } else {
                        inst.skillI =
                            (inst.skillI + 1) % runTexts.length;
                        inst.direction = "forward";
                    }
                    break;
                default:
                    break;
            }
        }
    }
    if (ut != null) {
        inst.text = ut.substring(0, inst.skillP);
        if (inst.skillP > ut.length) {
            inst.skillP = ut.length;
        }
    }
    dom.textContent = inst.text;
    let value;
    if (inst.prefix < prefixString.length) {
        value = Math.min(
            colorTextLength,
            colorTextLength + inst.prefix
        );
    } else {
        value = Math.min(colorTextLength, t.length - inst.skillP);
    }
    dom.appendChild(fragment(value));
}

function getNextColor(): string {
    return colors[Math.floor(Math.random() * colors.length)];
}

function getNextChar(): string {
    return String.fromCharCode(94 * Math.random() + 33);
}
function fragment(value: number): DocumentFragment {
    let f = document.createDocumentFragment();
    for (let i = 0; value > i; i++) {
        let span = document.createElement("span");
        span.textContent = getNextChar();
        span.style.color = getNextColor();
        f.appendChild(span);
    }
    return f;
}
function loop(): void {
    if (destroyed) {
        return;
    }
    setTimeout(() => {
        if (continue2 && container.value != null) {
            if (destroyed) {
                return;
            }
            let dom = container.value;
            let index = inst.skillI;
            let originText = texts.value[index];
            let currentText = runTexts[index];
            if (originText != currentText) {
                render(dom, currentText, originText);
                runTexts[index] = originText;
            } else {
                render(dom, currentText);
            }
        }
        if (infinite0) {
            loop();
        } else {
            if (inst.skillP < runTexts[0].length) {
                loop();
            }
        }
    }, frameTime);
}
</script>
        

        
        
<style scoped>
.content {
    color: black;
    height: 100%;
    width: 100%;

}

.container {
    margin: 0;
    padding: 0;
    width: 100%;
    height: 100%;
    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
    white-space: pre-wrap;
    word-wrap: break-word;
}
</style>

koa2实现JWT,完善登录服务

const jwt = require('jsonwebtoken');
const secret = 'moderate-vue-admin'; // 秘钥

function getJWTPayload(token) {
    // 验证并解析JWT
    return jwt.verify(token.split(' ')[1], secret);
}

function getToken(payload = {}) {
    return jwt.sign(payload, secret, { expiresIn: '4h' });
}

module.exports = {
    getJWTPayload,
    getToken
}
// 登录
router.post("/login", async (ctx, next) => {
  const { name } = ctx.request.body;
  let payload = { name }; // 加密的数据
  let permissions
  await new Promise((resolve) => {
    fs.readFile(path.resolve('db/', `${name}_permissions.json`), (err, dataStr) => {
      permissions = JSON.parse(dataStr.toString());
      resolve()
    })
  })
  ctx.response.body = {
    status: 1,
    code: "200",
    data: { token: getToken(payload) },
  }
})

ok,登录搞定了~~~

权限管理才是重头戏

你了解什么是权限管理?他是干什么的?

  • 权限控制的就是路由是否显示
  • 权限控制的就是按钮等交互是否可以操作

所以,如何实现一个简单的权限控制,能够让我的朋友很清晰直接了解权限管理是怎么回事,是很有必要的。

权限管理没必要那么复杂

有一说一,权限管理,我研究好几天,我看了若依的,antd pro等等项目的,我都不太喜欢,主要在于我觉得都过于复杂了,当然他们那么做肯定是有原因的,只不过我都用过,我在用下来之后,对权限能干什么的感知,都是“那些事儿”,那么为了实现“这些事儿”,没必要那么复杂,所以我希望用最少的代码,去实现大部分权限管理的事儿,就是我的目标。

前端和后端在权限管理的分工,用几句话就能说清楚

  • 前端配置自己事儿,后端保存这些事儿能不能做
  • 最后管理员用户,操作前端配置权限,将配置的权限数据保存在后端。

所以说前端配置的无论是菜单还是按钮,对于后端来讲都是事儿,那么配置出来的权限给到后端就是平铺的

[
  "index",
  "user",
  "role",
  "index:Add",
  "index:EDIT",
  "index:DELETE",
  "index:IMPORT",
  "index:EXPORT",
  "user:Add",
  "user:EDIT",
  "user:DELETE",
  "user:IMPORT",
  "user:EXPORT",
  "role:Add",
  "role:EDIT",
  "role:DELETE",
  "role:IMPORT",
  "role:EXPORT"
]

"index"和"user"这样的就是路由。(也可以说成是菜单,这俩一体两面。) "index:Add"和"user:IMPORT"这些就是页面对应的操作 将路由权限和菜单权限配置在一起,简单明了。

不需要写那么多数据,后端不关心前端的内容是啥,你前端自己负责内容是啥,后端只关心的是前端的事儿能不能做,就完了,只能怎么配置,需要前端提供配置页面,也就是角色管理页面

做个角色管理页面

页面大体是这样的。

image.png

配置的页面。

image.png

比如我现在把“用户管理”这个权限勾选了,然后就能看到权限的数据结构。

image.png

那么用户管理就出现了。

image.png

然后通过接口,传到Koa2服务上,进行保存。

image.png

后端接收到前端的数据,根据用户存起来,用户是通过解析token得到用户信息。

image.png 为啥我用fs写入文件?因为我担心我朋友不会mongodb数据库的安装配置,我就先用写入的文件的方式替代,简单直接,辅助学习为主。

然后在登录的时候,再去获取权限。

image.png

这样就形成了,前端提供配置项目->管理员用户配置权限->权限传给后端保存->前端再获取权限从而被驱动,形成了一个闭环,这样一个权限管理就做好了。

效果如下:

ezgif.com-gif-maker (5).gif

项目结构

image.png

项目地址 github.com/DLand-Team/…

结尾

至此,一个对新手友好的管理后台项目就构建好了,而且还在不断完善中,未来会补全Java后端服务项目,敬请期待,有问题可以随时咨询我,或者留言,我整了个群叫闲D岛,群号551406017,结识一帮志同道合的小伙伴,交流技术,欢迎水群(我就会玩qq,整别的,我也不会,比如公众号啥的。。。哈哈哈哈)