基于 Prisma + Remix + Sqlite 设计一个简单的角色菜单系统

一、目标

  • 实现一个基础的角色与菜单系统
  • 基于 Prisma + Remix + antd
  • 角色与菜单关联
  • 菜单需要自引用
  • Primsa 处理数据库
  • Remix 架构前后端
  • Antd 提供 UI 支持

本文适合想要想要学习 React 全栈的开发者。本文不是实现 RABC, 知识实现了基本的角色与菜单关联,目标在表关联操作和数据

二、难点

  • Remix 是一个基于 React 的前端框架,需要了解 React + Node.js 和 Remix 框架相关的知识。
  • Prisma 是下一代 ORM 库,需要简单的数据库的基本知识(本文使用 sqlite)。
  • Ant Design UI 组件库设计。

三、设计

  • 角色表:存放不同的角色的,可以自定义角色
  • 菜单表:存放在菜单列表,树状结构,自引用。
  • 前端UI: 使用 prot-table 展示列表,使用弹出添加辅助 CRUD 操作。

四、初始化

sh 复制代码
npx create-remix@latest sqlite-menu

pnpm i && pnpm add remix-utils
  • remix-utils 使用 ClientOnly 组件渲染在仅仅在客户端渲染的组件。

五、安装 Primsa

sh 复制代码
pnpm add prisma

npx prisma init --datasource-provider sqlite # 使用 sqlite 初始化

定义 prisma

prisma 复制代码
// 定义一个名为 Role 的数据模型
model Role {
  id        Int      @id @default(autoincrement())
  name      String   @unique // 角色名称,唯一
  desc      String?
  // 可以添加其他角色相关的字段
  menus     Menu[]   // 角色可以有多个菜单
}

// 定义一个名为 Menu 的数据模型
model Menu {
  id          Int      @id @default(autoincrement())
  name        String   @unique // 菜单名称,唯一
  path        String
  icon        String
  component   String
  parentMenu  Menu?    @relation("ChildMenus", fields: [parentId], references: [id]) // 指向父菜单
  parentId    Int?        // 存储父菜单的 ID
  roles       Role[]   // 菜单可以被多个角色访问
  childMenus  Menu[]   @relation("ChildMenus") // 子菜单
}

六、prisma 执行迁移

sh 复制代码
npx prisma migrate dev --name init

cd prisma
touch seed.ts # 根据自己的需求配置自己的种子文件

npx prisma studio # 运行 studio

七、Prisma 创建客户端

点击查看:创建全局客户端

ts 复制代码
import { PrismaClient } from "@prisma/client";

let prisma: PrismaClient;

declare global {
  var __db__: PrismaClient;
}

if (process.env.NODE_ENV === "production") {
  prisma = new PrismaClient();
} else {
  if (!global.__db__) {
    global.__db__ = new PrismaClient();
  }
  prisma = global.__db__;
  prisma.$connect();
}

export default prisma;

八、Prisma 操作 role 表

操作以下是 primsa 对表的基本操作:

  • 创建
  • 查找所有 role
  • 根据 id 查找 role
  • 分页查找
  • 根据 id 查询
  • 根据 id 删除
  • 查询数量

点击查看:prisma 操作 role 表

ts 复制代码
import prisma from "./server";

export const createRole = ({ name, desc, menus }: { name: string, desc?: string, menus: any[] }) => {
  return prisma.role.create({
    data: {
      name,
      desc,
      menus: {
        connect:  menus.map(m => ({ id: m.id })), // 根据 id 关联到 menu表
      }
    },
  });
};

export const findRoleAll = async () => {
  const roles = await prisma.role.findMany()
  const count = await prisma.role.count(); // 记录总数

  return {
    roles,
    count
  }
};

export const findRoleById = (id: number) => {
  return prisma.role.findUnique({
    where: { id },
  });
}

export const findRoleByPage = async ({ page, pageSize }: { page: number, pageSize: number }) => {
  const roles = await prisma.role.findMany({
      skip: (page - 1) * pageSize, // 跳过前面的记录
      take: pageSize, // 每页显示的记录数
      orderBy: {
        id: 'desc', // 这里的 'asc' 表示升序,'desc' 表示降序
      },
      include: {
        menus: true
      }
    });
  const count = await prisma.role.count(); // 记录总数

  return {
    roles,
    count
  }
}

export const updateRoleById = async ({ id, desc, menus }: { id: number, desc: string, menus: any[] }) => {
  const menusArr = await prisma.menu.findMany()
  const roles = await prisma.role.update({
    where: { id },
    data: {
      desc,
      menus: {
        connect:  menus.map(m => ({ id: m.id })),
        disconnect: menusArr.map((i) => {
          return menus.some((item) => item.id === i.id) ? '' : i
        }).filter((i) => i !== '') as any
      }
    },
  
  });

  return roles
}

export const deleteRoleById = (id: number) => {
  return prisma.role.delete({
    where: { id },
  })
}

export const deleteRolesByIds = (ids: number[]) => {
  return prisma.role.deleteMany({
    where: { id: {
      in: ids
    } },
  })
}

export const count = () => {
  return prisma.role.count()
}

九、菜单接口

  • 创建菜单
  • 更新菜单
  • 获取所有菜单

点击查看:菜单接口

ts 复制代码
import prisma from "./server";

type CreateMenu = {
  name: string;
  parentId?: number;
  path: string;
  icon: string;
  roles: any[] | string;
  component: string;
};

export const createMenu = ({
  name,
  parentId,
  path,
  icon,
  roles,
  component,
}: CreateMenu) => {
  let _roles: any[] = [];
  if (typeof roles === "string") {
    _roles = roles?.split(",").map((i: any) => parseInt(i, 10));
  } else {
    _roles = roles as any[];
  }
  return prisma.menu.create({
    data: {
      name,
      parentId,
      path,
      icon,
      component,
      roles: {
        connect: _roles.map((roleId: any) => ({ id: roleId })),
      },
    },
    include: {
      roles: true, // 包含关联的角色信息
    },
  });
};

export const updateMenu = (id: number, data: any) => {
  return prisma.menu.update({
    where: { id },
    data,
  });
};

export const findAllMenu = async () => {
  const menu = await prisma.menu.findMany({
    include: {
      roles: true,
    },
  });
  const count = await prisma.menu.count();
  const roles = await prisma.role.findMany();
  return { roles, menu, count };
};

十、仅在页面端渲染

  • _admin.tsx
tsx 复制代码
import { Outlet } from "@remix-run/react";
import { ClientOnly } from "remix-utils/client-only";

export default function Admin() {
  return <ClientOnly>{() => <Outlet />}</ClientOnly>;
} 

十一、角色列表 UI

ts 复制代码
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";

import {
  Form,
  useActionData,
  useLoaderData,
  useSubmit,
} from "@remix-run/react";
import { json } from "@remix-run/node";
import { useEffect, useState } from "react";

import * as _icons from "@ant-design/icons";
import { Button, Popconfirm, Space, Tree, message } from "antd";
import {
  ModalForm,
  ProForm,
  ProFormText,
  ProTable,
} from "@ant-design/pro-components";

import {
  createRole,
  deleteRoleById,
  findRoleAll,
  updateRoleById,
} from "~/db/role";
import { findAllMenu } from "~/db/menu";

const { DeleteOutlined, EditOutlined } = _icons;

export const action = async ({ request }: ActionFunctionArgs) => {
  const method = request.method;
  const dataJson = await request.json();

  switch (method) {
    case "POST":
      try {
        const { name, desc, menus } = dataJson;
        const data = await createRole({ name, desc, menus: menus ?? [] });
        return json({ code: 0, data, message: "ok" });
      } catch (error) {
        return json({ code: 1, data: [], message: error?.toString() });
      }

    case "PUT":
      try {
        const { id, desc, menus } = dataJson;
        const data = await updateRoleById({
          id: Number(id),
          desc,
          menus: menus ?? [],
        });
        return json({ code: 0, data, message: "ok" });
      } catch (error) {
        return json({ code: 1, data: [], message: error?.toString() });
      }

    case "DELETE":
      const { id } = dataJson;
      try {
        const data = await deleteRoleById(Number(id));
        return json({ code: 0, data, message: "ok" });
      } catch (error) {
        return json({ code: 1, data: [], message: error?.toString() });
      }
    case "default":
      return json({ code: 0, data: [], message: "ok" });
  }
};

export const loader = async ({ request }: LoaderFunctionArgs) => {
  const { roles, count } = await findRoleAll();

  const { menu } = await findAllMenu();

  const toTree = (flatData: any[]) => {
    const map: any = {};
    const tree: any[] = [];

    // 将每个项的id作为key建立map以便快速查找
    flatData.forEach((item) => {
      map[item.id] = {
        title: item.name,
        key: item.name,
        ...item,
        children: [],
      };
    });

    // 遍历数据,将每个节点放到其父节点下面
    flatData.forEach((item) => {
      if (item.parentId) {
        map[item.parentId].children.push(map[item.id]);
      } else {
        tree.push(map[item.id]);
      }
    });
    return tree;
  };

  const _menu = toTree(menu);

  return json({ dataSource: roles, total: count, menu: _menu, rawMenu: menu });
};

export default function RoleRoute() {
  const submit = useSubmit();
  const actionData = useActionData<typeof action>();
  const {
    rawMenu = [],
    menu = [],
    dataSource = [],
    total = 0,
  } = useLoaderData<typeof loader>();

  // data
  const [showCreateModel, setShowCreateModel] = useState(false);
  const [record, setRecord] = useState<any>({});
  const [showModModel, setShowModModel] = useState(false);

  const onModRole = async ({
    record,
    values,
  }: {
    record: any;
    values: { name?: string; desc: string; menus: any[] };
  }) => {
    await submit(
      { id: record.id, ...values },
      { method: "PUT", encType: "application/json" }
    );
    return true;
  };

  const onCreateRole = async (values: {
    name: string;
    desc?: string;
    menus: any[];
  }) => {
    await submit(values, { method: "POST", encType: "application/json" });
  };

  const columns = [
    {
      title: "ID",
      dataIndex: "id",
      key: "id",
    },
    {
      title: "Name",
      dataIndex: "name",
      key: "name",
    },
    {
      title: "Desc",
      dataIndex: "desc",
      key: "desc",
    },
    {
      title: "操作",
      render(_: any, record: any) {
        return (
          <Space size="large">
            <Button
              shape="circle"
              icon={<EditOutlined />}
              onClick={() => {
                setRecord(record);
                setShowModModel(true);
              }}
            ></Button>
            <Form>
              <Popconfirm
                title="删除角色"
                description="确定要删除此角色?"
                okText="Yes"
                cancelText="No"
                onConfirm={() => {
                  submit(
                    { id: record.id },
                    {
                      method: "DELETE",
                      encType: "application/json",
                    }
                  );
                }}
              >
                <Button shape="circle" icon={<DeleteOutlined />}></Button>
              </Popconfirm>
            </Form>
          </Space>
        );
      },
    },
  ];

  useEffect(() => {
    if (actionData && actionData.code === 1) {
      message.error(actionData.message);
    }
  }, [actionData]);

  return (
    <>
      <ProTable
        // search={false}
        columns={columns}
        dataSource={dataSource as any}
        toolBarRender={() => [
          <Button
            type="primary"
            key={"create"}
            onClick={() => {
              setShowCreateModel(!showCreateModel);
            }}
          >
            创建角色
          </Button>,
        ]}
        pagination={{
          total,
          defaultPageSize: 9999,
          pageSize: 99999,
          // current: Number(searchParams.get("page") || 1),
          // onChange(page, pageSize) {
          //   navigate(`/role?page=${page}&pageSize=${pageSize}`);
          // },
        }}
        key="id"
      />
      {showCreateModel && (
        <CreateForm
          open={showCreateModel}
          setOpen={setShowCreateModel}
          onCreateRole={onCreateRole}
          menu={menu}
          rawMenu={rawMenu}
        />
      )}
      {showModModel && (
        <ModForm
          record={record}
          open={showModModel}
          setOpen={setShowModModel}
          onModRole={onModRole}
          menu={menu}
          rawMenu={rawMenu}
        />
      )}
    </>
  );
}

function CreateForm(props: {
  open: boolean;
  setOpen: (show: boolean) => void;
  onCreateRole: (value: { name: string, menus: any[] }) => any;
  menu: any;
  rawMenu: any;
}) {
  const [checkedKeys, setCheckedKeys] = useState([]);
  const [checkedNodes, setCheckedNodes] = useState([]);

  const onCheck = (checkedKeys: any, info: any) => {
    setCheckedKeys(checkedKeys);
    setCheckedNodes(info.checkedNodes);
  };

  return (
    <>
      <ModalForm
        title="创建角色"
        open={props.open}
        onFinish={(values: { name: string }) => {
          props.onCreateRole({ ...values, menus: checkedNodes });
          props.setOpen(false);
          return Promise.resolve(true);
        }}
        modalProps={{
          destroyOnClose: true,
          onCancel: () => props.setOpen(false),
        }}
      >
        <ProFormText name="name" label="角色名" />
        <ProFormText name="desc" label="描述" />
        <ProForm.Item name="menus" label="菜单">
          <Tree
            checkable
            treeData={props.menu}
            onCheck={onCheck}
            checkedKeys={checkedKeys}
          />
        </ProForm.Item>
      </ModalForm>
    </>
  );
}

function ModForm(props: {
  record: any;
  open: boolean;
  setOpen: (show: boolean) => void;
  onModRole: ({
    record,
    values,
  }: {
    record: any;
    values: { name?: string; desc: string; menus: any[] };
  }) => any;
  menu: any;
  rawMenu: any;
}) {
  const initKeys = props.record.menus?.map((m: any) => m.name)
  const [checkedKeys, setCheckedKeys] = useState(initKeys || []);
  const [checkedNodes, setCheckedNodes] = useState([]);

  const onCheck = (checkedKeys: any, info: any) => {
    setCheckedKeys(checkedKeys);
    setCheckedNodes(info.checkedNodes);
  };

  return (
    <>
      <ModalForm
        title="修改角色"
        open={props.open}
        initialValues={{
          name: props.record.name,
          desc: props.record.desc,
        }}
        onFinish={(values: { name?: string; desc: string, menus: any[] }) => {
          values.menus = checkedNodes;
          props.onModRole({
            record: props.record,
            values,
          });
          props.setOpen(false);
          return Promise.resolve(true);
        }}
        modalProps={{
          destroyOnClose: true,
          onCancel: () => props.setOpen(false),
        }}
      >
        <ProFormText name="name" label="角色名" disabled />
        <ProFormText name="desc" label="描述" />
        <ProForm.Item label="菜单" name="menus">
          <Tree
            checkable
            treeData={props.menu}
            onCheck={onCheck}
            checkedKeys={checkedKeys}
          />
        </ProForm.Item>
      </ModalForm>
    </>
  );
}

十二、菜单列表 UI

jsx 复制代码
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";

import {
  Form,
  useActionData,
  useLoaderData,
  useSubmit,
} from "@remix-run/react";
import { json } from "@remix-run/node";
import { useEffect, useState } from "react";

import * as _icons from "@ant-design/icons";
import { Button, Popconfirm, Space, Tag, TreeSelect, message } from "antd";
import {
  ModalForm,
  ProFormText,
  ProTable,
  ProFormSelect,
  ProForm,
} from "@ant-design/pro-components";

import { deleteRoleById } from "~/db/role";
import { findAllMenu, createMenu, updateMenu } from "~/db/menu";

const { DeleteOutlined, EditOutlined } = _icons;

export const action = async ({ request }: ActionFunctionArgs) => {
  const method = request.method;
  const formData = await request.formData();

  switch (method) {
    case "POST":
      try {
        const name = formData.get("name") as string;
        const path = formData.get("path") as string;
        const icon = formData.get("icon") as string;
        const component = formData.get("component") as string;
        const parentId = formData.get("parentId") as string;
        const roles = formData.get("roles");
        const _data: any = {
          name,
          icon,
          path,
          component,
          roles: roles,
        };

        if (parentId) {
          _data.parentId = Number(parentId);
        }
        const data = await createMenu(_data);
        return json({ code: 0, data, message: "ok" });
      } catch (error) {
        console.log(error);
        return json({ code: 1, data: [], message: error?.toString() });
      }

    case "PUT":
      try {
        const id = formData.get("id") as string;
        const desc = formData.get("desc") as string;
        const data = await updateMenu(Number(id), {
          desc,
        });
        return json({ code: 0, data, message: "ok" });
      } catch (error) {
        return json({ code: 1, data: [], message: error?.toString() });
      }

    case "DELETE":
      const id = formData.get("id") as string;
      try {
        const data = await deleteRoleById(Number(id));
        return json({ code: 0, data, message: "ok" });
      } catch (error) {
        return json({ code: 1, data: [], message: error?.toString() });
      }
    case "default":
      return json({ code: 0, data: [], message: "ok" });
  }
};

export const loader = async ({ request }: LoaderFunctionArgs) => {
  const { menu, count, roles } = await findAllMenu();

  const toTree = (flatData: any[]) => {
    const map: any = {};
    const tree: any[] = [];

    // 将每个项的id作为key建立map以便快速查找
    flatData.forEach((item) => {
      map[item.id] = {
        key: item.name,
        title: item.name,
        value: item.id,
        ...item,
        children: [],
      };
    });

    // 遍历数据,将每个节点放到其父节点下面
    flatData.forEach((item) => {
      if (item.parentId) {
        map[item.parentId].children.push(map[item.id]);
      } else {
        tree.push(map[item.id]);
      }
    });
    return tree;
  };

  return json({ roles, dataSource: toTree(menu), total: count });
};

type IItem = {
  name?: string;
  desc: string;
  path: string;
  icon: string;
  parentId: number;
  roles: any[];
  component: string;
};

export default function MenuRoute() {
  const submit = useSubmit();
  const { roles, dataSource = [], total = 0 } = useLoaderData<typeof loader>();

  // data
  const [showCreateModel, setShowCreateModel] = useState(false);
  const [record, setRecord] = useState<any>({});
  const [showModModel, setShowModModel] = useState(false);

  const onModMenu = async ({
    record,
    values,
  }: {
    record: any;
    values: IItem;
  }) => {
    await submit({ id: record.id, ...values }, { method: "PUT" });
    return true;
  };

  const onCreateMenu = async (values: IItem) => {
    if (!values.roles) {
      values.roles = [];
    }
    await submit(values, { method: "POST" });
    return true;
  };

  const columns = [
    {
      title: "Name",
      dataIndex: "name",
      key: "name",
    },
    {
      title: "路径",
      dataIndex: "path",
      key: "path",
    },
    {
      title: "图表",
      dataIndex: "icon",
      key: "icon",
    },
    {
      title: "组件",
      dataIndex: "component",
      key: "component",
    },
    {
      title: "角色",
      dataIndex: "roles",
      key: "roles",
      render(_: any, record: any) {
        return (
          <Space>
            {record.roles.map((r: any, index: number) => {
              return <Tag key={index}>{r.desc}</Tag>;
            })}
          </Space>
        );
      },
    },
    {
      title: "id/父id",
      dataIndex: "parentId",
      key: "parentId",
      render(_: any, record: any) {
        return <div>
          <Tag>{record.id}/{record.parentId ?? '无'}</Tag>
        </div>;
      },
    },
    {
      title: "操作",
      render(_: any, record: any) {
        return (
          <Space size="large">
            <Button
              shape="circle"
              icon={<EditOutlined />}
              onClick={() => {
                setRecord(record);
                setShowModModel(true);
              }}
            ></Button>
            <Form>
              <Popconfirm
                title="删除角色"
                description="确定要删除此角色?"
                okText="Yes"
                cancelText="No"
                onConfirm={() => {
                  submit(
                    { id: record.id },
                    {
                      method: "DELETE",
                    }
                  );
                }}
              >
                <Button shape="circle" icon={<DeleteOutlined />}></Button>
              </Popconfirm>
            </Form>
          </Space>
        );
      },
    },
  ];

  useEffect(() => {
    if (actionData && actionData.code === 1) {
      message.error(actionData.message);
    }
  }, [actionData]);

  return (
    <>
      <ProTable
        search={false}
        columns={columns}
        dataSource={dataSource as any}
        toolBarRender={() => [
          <Button
            type="primary"
            key={"create"}
            onClick={() => {
              setShowCreateModel(!showCreateModel);
            }}
          >
            创建菜单
          </Button>,
        ]}
        pagination={{
          total,
          defaultPageSize: 9999,
          pageSize: 99999,
          // pageSize: 10,
          // current: Number(searchParams.get("page") || 1),
          // onChange(page, pageSize) {
          //   navigate(`/role?page=${page}&pageSize=${pageSize}`);
          // },
        }}
        key="id"
      />
      {showCreateModel && (
        <CreateForm
          open={showCreateModel}
          setOpen={setShowCreateModel}
          onCreateMenu={onCreateMenu}
          dataSource={dataSource}
          roles={roles}
        />
      )}
      {showModModel && (
        <ModForm
          record={record}
          open={showModModel}
          setOpen={setShowModModel}
          onModRole={onModMenu}
          dataSource={dataSource}
          roles={roles}
        />
      )}
    </>
  );
}

function CreateForm(props: {
  open: boolean;
  setOpen: (show: boolean) => void;
  onCreateMenu: (value: IItem) => any;
  dataSource: any[];
  roles?: any[];
}) {
  return (
    <>
      <ModalForm
        title="创建菜单项目"
        open={props.open}
        onFinish={(values: IItem) => {
          props.onCreateMenu(values);
          props.setOpen(false);
          return Promise.resolve(true);
        }}
        modalProps={{
          destroyOnClose: true,
          onCancel: () => props.setOpen(false),
        }}
      >
        <ProFormText name="name" label="菜单名" />
        <ProFormText name="path" label="路径" />
        <ProFormText name="icon" label="图标" />
        <ProFormText name="component" label="组件" />
        <ProFormSelect
          name="roles"
          label="角色"
          mode="multiple"
          options={props.roles?.map((item) => {
            return { label: item.name, value: item.id };
          })}
        />
        <ProForm.Item name="parentId" label="父ID">
          <TreeSelect
            showSearch
            style={{ width: "100%" }}
            dropdownStyle={{ maxHeight: 400, overflow: "auto" }}
            placeholder="Please select"
            allowClear
            treeDefaultExpandAll
            treeData={props.dataSource}
          />
        </ProForm.Item>
      </ModalForm>
    </>
  );
}

function ModForm(props: {
  record: any;
  open: boolean;
  setOpen: (show: boolean) => void;
  onModRole: ({ record, values }: { record: any; values: IItem }) => any;
  dataSource: any[];
  roles: any[];
}) {
  return (
    <>
      <ModalForm
        title="修改菜单"
        open={props.open}
        initialValues={{
          name: props.record.name,
          path: props.record.path,
          icon: props.record.icon,
          component: props.record.component,
          roles: props.record.roles.map((r: any) => r.id),
          parentId:
            props.record.parentId === null ? undefined : props.record.parentId,
        }}
        onFinish={(values: IItem) => {
          props.onModRole({ record: props.record, values });
          props.setOpen(false);
          return Promise.resolve(true);
        }}
        modalProps={{
          destroyOnClose: true,
          onCancel: () => props.setOpen(false),
        }}
      >
        <ProFormText name="name" label="菜单名" disabled />
        <ProFormText name="path" label="路径" />
        <ProFormText name="icon" label="图标" />
        <ProFormText name="component" label="组件" />
        <ProFormSelect
          name="roles"
          label="角色"
          mode="multiple"
          options={props.roles?.map((item) => {
            return { label: item.name, value: item.id };
          })}
        />
        <ProForm.Item
          name="parentId"
          label="父 ID"
          rules={[
            {
              required: true,
              message: "Please select nodes",
            },
          ]}
        >
          {/* 使用 TreeSelect 在表单中进行选择 */}
          <TreeSelect
            treeData={props.dataSource}
            placeholder="Please select"
            treeDefaultExpandAll
            treeCheckable
            showCheckedStrategy={TreeSelect.SHOW_ALL}
            style={{ width: "100%" }}
            disabled={props.record.parentId === null ? true : false}
          />
        </ProForm.Item>
      </ModalForm>
    </>
  );
}

十三、录入数据与效果

十四、小结

本文以 Remix + Prisma + Ant Design 设计一个简单的角色和菜单系统。总体上有以下需要主题点:

  • 技术选型(方向是快速的打通前后端)
  • Prisma 模型设计(如何书写模型)
  • 使用 Primsa Client 提供的 API 设计接口
  • Remix 打通前后端数据与页面渲染
  • 菜单数据结构的扁平化与树形结构转换
  • UI 接口对接以及自测
  • 后期可以考虑实现简单版本的 RABC 基于角色管理系统。
相关推荐
王二端茶倒水2 分钟前
大龄程序员兼职跑外卖第五周之亲身感悟
前端·后端·程序员
_oP_i7 分钟前
Web 与 Unity 之间的交互
前端·unity·交互
钢铁小狗侠9 分钟前
前端(1)——快速入门HTML
前端·html
凹凸曼打不赢小怪兽34 分钟前
react 受控组件和非受控组件
前端·javascript·react.js
狂奔solar1 小时前
分享个好玩的,在k8s上部署web版macos
前端·macos·kubernetes
qiyi.sky1 小时前
JavaWeb——Web入门(8/9)- Tomcat:基本使用(下载与安装、目录结构介绍、启动与关闭、可能出现的问题及解决方案、总结)
java·前端·笔记·学习·tomcat
夜色呦1 小时前
现代电商解决方案:Spring Boot框架实践
数据库·spring boot·后端
清云随笔1 小时前
axios 实现 无感刷新方案
前端
鑫宝Code1 小时前
【React】状态管理之Redux
前端·react.js·前端框架
爱敲代码的小冰1 小时前
spring boot 请求
java·spring boot·后端