RuoyiOffice头像
关注
7天学会SpringBoot+Vue3企业级项目RuoyiOffice(四):前端篇——从菜单路由到表单列表,完成业务页面封面图

7天学会SpringBoot+Vue3企业级项目RuoyiOffice(四):前端篇——从菜单路由到表单列表,完成业务页面

7天学会SpringBoot+Vue3企业级项目RuoyiOffice(四):前端篇——从菜单路由到表单列表,完成业务页面

🌐 文档地址:https://ruoyioffice.com
👇 文章底部获取源码和演示地址 👇
💬 :17156169080(获取产品咨询)

这是《7天学会SpringBoot+Vue3企业级项目RuoyiOffice》系列第 4 篇。昨天后端把用车申请的接口做好了,今天让用户真正看到它:点开菜单,进入列表,新增一张单据,保存、提交、撤回、删除。读完你能回答三个问题:一个业务页面由哪几个文件组成、菜单与路由到底在哪里把它们连起来、页面上的按钮和字段应该跟着哪些状态变化。

前端篇核心视觉:一个业务页面是怎么拼出来的,左侧是页面线框和六个编号,右侧是每个编号对应的文件与配置

▲ 本篇核心视觉:左边是用车申请单列表的线框,六个编号标出菜单、搜索表单、工具栏、表格列、数据请求和跳转表单;右边写明每个编号落在哪个文件、哪项配置。读完正文,你应该能闭着眼睛把这张图补全。

引言:一个页面到底由什么组成

很多人第一次接手 Vben 项目,会盯着 views 目录问:我要加一个页面,到底该复制哪个文件?这个问题的答案不在某个文件里,而在一条链路里:菜单记录决定入口,组件路径决定加载哪个文件,API 文件决定怎么说话,data.ts 决定长什么样,index.vue 决定怎么动。把这条链路看清楚,复制谁都不会出错。

决策点选择理由
案例页面OA 用车申请单的列表页与表单页同时具备搜索、分页、权限按钮、字典、日期、弹窗选择和审批状态联动
页面拆分list 与 info 两个目录,各自带 data.ts列表和表单是两条独立的职责线,字段定义不混在 .vue 里
路由来源菜单记录里的组件路径,而不是手写路由文件业务菜单由后台配置,授权、隐藏、可见端都在同一处管理
状态联动以流程状态为准,页面只读,不自己再造一套状态与第三篇的状态模型对齐,避免前后端各说各话

说明:本文代码均节选自 ruoyi-office-vben/apps/web-antd/src,为便于阅读省略了部分导入与空行。菜单字段取自开发库静态数据导出文件,具体行以你自己库中的 system_menu 为准。

一、先看入口:菜单记录怎么指向页面

浏览器地址变化之后,路由怎么知道要加载 views/oa/car/carapply/list/index.vue?答案是菜单表里的一行。用车申请有两条菜单记录,一条在侧栏可见,一条隐藏:

菜单项路由路径组件路径组件名是否显示在侧栏
用车申请单管理(列表)car-apply-listoa/car/carapply/list/indexCarApplyList显示
用车申请单详情(表单)car-apply-infooa/car/carapply/info/indexCarApplyInfo隐藏

两条记录的父级相同,都挂在「车辆管理」目录下。表单页之所以做成隐藏菜单,是因为它不需要在侧栏出现,却需要拥有自己的路由和授权。列表页里「新增」和点击单据编号,都是通过 router.push 跳到这个隐藏路由。

菜单管理页面:树形表格展示菜单名称、类型、排序、权限标识、组件路径与操作

▲ 菜单管理:「组件路径」一列就是页面文件相对于 views 目录的位置,「权限标识」一列就是按钮上 auth 里写的那串字符。

菜单记录还有一个容易被忽略的字段:可见端。在新增菜单的弹窗里可以选择 PC、App 或两端,右侧的「影响面」面板会实时提示这条菜单会不会出现在 PC 侧栏、流程中心发起、App 工作台等位置。

新增菜单弹窗:左侧填写上级菜单、名称、类型、路由与可见端,右侧展示影响面提示

▲ 新增菜单弹窗:同一份菜单配置同时决定 PC 与移动端的入口,右侧的「影响面」帮管理员在保存前看清后果。

另外,用车申请在流程模型里还登记了两个路径,用于「在流程中心发起」和「在审批里嵌入详情」:

配置项值谁使用
formCustomCreatePath/oa/car/car-apply-info流程中心发起时跳转到哪个路由
formCustomViewPath/oa/car/carapply/info/index.vue审批详情里内嵌哪个组件

这意味着 info/index.vue 同时要服务三种场景:从列表新建或编辑、从流程中心发起、在审批页里作为只读详情。这也是为什么它的 props 里有 isApproval、viewType 和 processInstance。第六篇会专门展开流程侧,这里只需要记住:路径变更要改菜单或流程模型,不要改页面去适配错误的配置。

二、API 层:前端与后端之间唯一的一份契约

页面不直接写请求地址,所有接口集中在 src/api/oa/car/carapply/index.ts。它做两件事:用 namespace 声明数据结构,用函数封装每个接口。

export namespace CarApplyBillApi {
  /** 用车申请单信息 */
  export interface CarApplyBill {
    id: number; // ID
    billCode?: string; // 单据编号
    processInstanceId: string; // 流程实例编号
    processStatus: number; // 单据状态
    carId: number; // 车辆
    carNo: string; // 车牌号码
    goTime: Dayjs | string; // 出车时间
    returnTime: Dayjs | string; // 回车时间
    // ... 地点、事由、创建者、部门、公司等字段省略
    returnStatus?: number; // 还车状态
    attachments?: AttachmentApi.AttachmentSaveReq[];
    startUserSelectAssignees?: Record<string, number[]>;
  }
}

/** 查询用车申请单分页 */
export function getCarApplyBillPage(params: PageParam) {
  return requestClient.get<PageResult<CarApplyBillApi.CarApplyBill>>(
    '/oa/car-apply-bill/page',
    { params },
  );
}

详情、保存、提交和删除分别对应第三篇里的 /get、/save、/submit 与 /delete:

/** 查询用车申请单详情 */
export function getCarApplyBill(id: number) {
  return requestClient.get<CarApplyBillApi.CarApplyBill>(
    `/oa/car-apply-bill/get?id=${id}`,
  );
}

/** 查询用车申请单详情(BPM 审批嵌页) */
export function getCarApplyBillForBpm(id: number) {
  return requestClient.get<CarApplyBillApi.CarApplyBill>(
    `/oa/car-apply-bill/get-for-bpm?id=${id}`,
  );
}

/** 保存用车申请单  */
export function saveCarApplyBill(data: CarApplyBillApi.CarApplyBill) {
  return requestClient.post(`/oa/car-apply-bill/save`, data);
}

/** 提交用车申请单  */
export function submitCarApplyBill(data: CarApplyBillApi.CarApplyBill) {
  return requestClient.post(`/oa/car-apply-bill/submit`, data);
}

/** 删除用车申请单 */
export function deleteCarApplyBill(id: number) {
  return requestClient.delete(`/oa/car-apply-bill/delete?id=${id}`);
}

有三处值得对着后端检查:

  1. 路径里没有 /admin-api,前缀由请求客户端统一拼接,与第二篇讲的联调代理对得上。
  2. startUserSelectAssignees 是提交时「发起人自选审批人」的载体,只在提交接口里有意义。
  3. 详情有两个接口:/get 给普通页面,/get-for-bpm 给审批嵌页,info/index.vue 根据 isApproval 选择其一。

这里还有一个类型标注与实际数据不一致的地方:goTime 被标成 Dayjs | string,但页面里日期控件使用的是毫秒时间戳(下文第五节),实际在表单中流动的是 number。它不会导致运行错误,因为这些字段没有在 TypeScript 里被当成 Dayjs 去调用方法,但新模块里应当直接写 number,让类型反映真实数据。

三、列表页:搜索、分页、权限按钮

列表页由两个文件组成:list/data.ts 描述「长什么样」,list/index.vue 描述「怎么动」。

用车申请单列表:单据状态用彩色标签区分审批中、已撤回、审批通过、审批不通过

▲ 用车申请单列表:顶部是搜索表单,中间是带复选框的分页表格,右上角是新增、导出和批量删除三个带权限标识的按钮。

3.1 data.ts:字段只写一遍

表格列定义里最有代表性的是三类写法:路由链接列、字典标签列、时间格式化列。

export function useGridColumns(): VxeTableGridOptions<CarApplyBillApi.CarApplyBill>['columns'] {
  return [
    { type: 'checkbox', width: 40 },
    createRouterLinkColumn({
      field: 'billCode',
      title: '单据编号',
      path: '/oa/car/car-apply-info',
      idField: 'id',
      queryParam: 'id',
    }),
    {
      field: 'processStatus',
      title: '单据状态',
      minWidth: 120,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.BPM_PROCESS_INSTANCE_STATUS },
      },
    },
    {
      field: 'goTime',
      title: '出车时间',
      minWidth: 140,
      formatter: 'formatDateTime',

对照表如下,搜索表单里的下拉选项来自同一套字典,所以搜索和展示不会出现两套文案:

字段表格列写法搜索表单写法数据来源
billCodecreateRouterLinkColumn,点击跳到表单页并带上 idInput后端单据号
processStatusCellDict 渲染彩色标签Select + getDictOptions字典 BPM_PROCESS_INSTANCE_STATUS
returnStatusCellDict 渲染标签Select + getDictOptions字典 OA_CAR_RETURN_STATUS
goTime / returnTimeformatter: 'formatDateTime'无毫秒时间戳
createTimeformatter: 'formatDateTime'RangePicker毫秒时间戳

3.2 index.vue:把表格、表单和请求接起来

useVbenVxeGrid 一次返回表格组件和它的控制对象,搜索表单、分页、数据请求全部在同一个配置里:

const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: useGridFormSchema(modalRef),
    wrapperClass: 'grid-cols-4',
    collapsed: true,
  },
  gridOptions: {
    columns: useGridColumns(),
    height: 'auto',
    pagerConfig: {
      enabled: true,
    },
    proxyConfig: {
      ajax: {
        query: async ({ page }, formValues) => {
          return await getCarApplyBillPage({
            pageNo: page.currentPage,
            pageSize: page.pageSize,
            ...formValues,
            companyId: userStore.userInfo?.companyId,
            creator: userStore.userInfo?.id,
          });
        },
      },
    },

注意最后两行:前端把当前用户的公司和用户编号一并传了出去。这只是为了让界面行为一致,真正的约束在后端。第三篇已经证明,后端分页会无视前端传来的 creator,强制改成当前登录用户。所以这里的写法不是安全措施,只是减少一次无意义的“传空值”。

页面外层用 Page 的 auto-content-height 让表格撑满剩余高度,工具栏按钮放进 TableAction:

<Page auto-content-height>
  <Grid table-title="用车申请单列表">
    <template #toolbar-tools>
      <TableAction
        :actions="[
          {
            label: $t('ui.actionTitle.create'),
            type: 'primary',
            icon: ACTION_ICON.ADD,
            auth: ['oa:car-apply-bill:create'],
            onClick: handleCreate,
          },
          {
            label: $t('ui.actionTitle.deleteBatch'),
            type: 'primary',
            danger: true,
            icon: ACTION_ICON.DELETE,
            disabled: isEmpty(checkedIds),
            auth: ['oa:car-apply-bill:delete'],
            onClick: handleDeleteBatch,
          },
        ]"
      />
    </template>

TableAction 里的 auth 会交给前端的访问码判断:没有声明 auth 的按钮一律显示,声明了的按钮在当前用户没有这个权限标识时不显示。这里要看清一个边界:它只负责“看不见”,不负责“调不通”。即使有人绕过界面直接请求接口,拦截它的是后端的 @PreAuthorize,这一点第五篇会专门讲。

行内的删除按钮则用了 ifShow 和状态常量联动:

流程状态数值能否编辑能否删除
未开始(草稿)-1能能
审批中1否否,按钮灰显
审批通过2否否,按钮灰显
审批不通过3能能
已取消4否能
已撤回10能能

这张表来自 BpmProcessInstanceStatusEditValue(未开始、不通过、已撤回)与 BpmProcessInstanceStatusDeleteValue(在可编辑基础上再加已取消)两个常量。页面不自己判断业务规则,只引用同一个常量,将来流程状态调整时,只需要改一个地方。批量删除同样先在前端用这个常量筛一遍,把不允许删除的单据编号提示出来,而不是等后端报错。

四、表单页:保存、提交与状态联动

点击「新增」会走到 info/index.vue。它的外壳是 BasicForm,业务字段由 useFormSchema 提供,下方插槽放附件。

用车申请单详情页:单据信息、审批信息、流程图与流程评论四个页签,状态为审批通过

▲ 同一个 info 页面在审批通过后的样子:顶部显示单据号与状态,表单整体只读,页签里并存审批信息与流程图。

4.1 字段定义里的校验与联动

先看出车时间字段。它同时用到了必填校验、时间戳格式和与回车时间的互相检查:

{
  fieldName: 'goTime',
  label: '出车时间',
  rules: 'required',
  component: 'DatePicker',
  componentProps: {
    showTime: true,
    format: 'YYYY-MM-DD HH:mm:ss',
    valueFormat: 'x',
    placeholder: '请选择出车时间',
  },
  dependencies: {
    triggerFields: ['returnTime'],
    trigger: (values, formApi) => {
      if (
        values.returnTime &&
        values.goTime &&
        values.returnTime <= values.goTime
      ) {
        message.error('出车时间不能晚于或等于回车时间');
        formApi?.setFieldValue('returnTime', undefined);
      }
    },
  },
},

这段配置里有三个要点:

  1. rules: 'required' 来自表单校验体系,提交前由 validateForm 统一触发。
  2. valueFormat: 'x' 表示控件的值是毫秒时间戳,与后端 LocalDateTime 的默认序列化对齐。
  3. dependencies.trigger 是字段联动:对方字段变化时被调用,这里用来拦住“回车早于出车”。

前端校验的意义是让用户立刻知道错在哪,不是业务规则的唯一出口。时间冲突这类涉及其他单据的规则,第三篇讲过,只能由后端判断。

4.2 保存与提交

保存和提交共用同一个函数,区别只在于是否先校验、最后调用哪个接口:

async function handleSaveAndSubmit(
  isSubmit: boolean,
  submitOptions?: { startUserSelectAssignees?: Record<string, number[]> },
) {
  loading.value = true;
  if (!basicFormRef.value) return;

  // 提交前校验
  if (isSubmit) {
    const { valid } = await basicFormRef.value.validateForm();
    if (!valid) {
      loading.value = false;
      return;
    }
  }

  try {
    const formValues =
      (await basicFormRef.value.getFormValues()) as CarApplyBillApi.CarApplyBill;
    const data = {
      ...formData.value,
      ...formValues,
      startUserSelectAssignees: submitOptions?.startUserSelectAssignees,
    };
    id = await (isSubmit ? submitCarApplyBill(data) : saveCarApplyBill(data));
    await loadData(); // 成功提示与 finally 复位 loading 省略
  } catch (error) {
    console.error('保存失败:', error);
  }
}

两个设计值得学:一是保存草稿不校验、提交才校验,符合用户“先存一半”的习惯;二是成功后调用 loadData() 重新拉取详情,页面展示的永远是后端回写后的数据(包含单据号、流程实例编号、流程状态),而不是前端自己拼出来的结果。

4.3 页面状态怎么跟着业务状态走

加载数据之后,readonly 由一个公共函数计算:

async function loadData() {
  if (id === undefined || id === null) {
    formData.value = {
      creator: userStore.userInfo?.id,
      companyId: userStore.userInfo?.companyId,
      deptId: userStore.userInfo?.deptId,
      processStatus: BpmProcessInstanceStatus.NOT_START, // 草稿状态
      attachments: [],
    };
    return;
  }
  const data = await (props.isApproval || props.processInstance
    ? getCarApplyBillForBpm(id)
    : getCarApplyBill(id));
  formData.value = { ...data };
  readonly.value = computeBusinessFormReadonly(
    props.viewType,
    props.isApproval,
    formData.value.processStatus as number,
  );
}

它的判断顺序是:已办、抄送视图一律只读;审批态只读;其余情况看流程状态是否在可编辑集合里。结合上一节的状态表,可以得到下面的页面行为:

场景viewTypeisApprovalprocessStatus页面表现
从列表新建无否-1(新建时前端预置)可编辑,显示保存与提交
提交后查看无否1 审批中只读,可撤回
被驳回后修改无否3 审批不通过可编辑,可重新提交
审批人在待办里查看todo是1 审批中只读,底部隐藏发起人按钮
已办、抄送查看done / copy否任意始终只读

注意新建场景:loadData 在没有 id 时直接写入默认值,其中 processStatus 取的是 BpmProcessInstanceStatus.NOT_START,也就是 -1,这个草稿状态只存在于前端内存里,直到第一次保存才会落库。

五、弹窗选择:车辆怎么选进表单

车辆字段不是下拉框,而是一个只读输入框,点击后弹出车辆选择窗口。这个窗口是独立组件 car-select-modal.vue,内部同样使用 useVbenVxeGrid,外壳使用 useVbenModal:

/** 模态框实例 */
const [Modal, modalApi] = useVbenModal({
  title: '选择车辆',
  class: 'w-3/5 max-w-4xl',
  async onConfirm() {
    return handleConfirm();
  },
});

/** 确认选择 */
async function handleConfirm() {
  if (!formData.selectedCar) {
    message.error('请选择车辆');
    return false;
  }

  emit('select', formData.selectedCar);
  formData.selectedCar = null;
  await modalApi.close();
  return true;
}

defineExpose({ modalApi }); // 暴露给父页面调用 open()

调用链很清晰:表单字段的 onClick 调用 modalRef.value?.modalApi.open() 打开弹窗,用户单选或双击一行后,弹窗通过 select 事件把整行车辆数据交还给表单页,表单页的 handleCarSelect 只写入 carNo 与 carId 两个值,并且只清除 carNo 这个字段的校验错误,不触发其它字段校验。

这里有一个细节:弹窗按“公司编号”过滤车辆(companyId 由父页面传入),这与列表页把 companyId 传给分页接口是同一思路,目的是不让用户在选车时看到不属于自己公司的车。真正的租户隔离由后端保证,这里只是体验层面的收窄。

六、闭环:一次从菜单到提交的完整操作

把前面所有环节串起来,一次完整的用户操作如下:

用车申请页面时序图:用户、菜单与路由、列表页、表单页和后端 API 依次协作

▲ 时序图:路由负责把菜单记录变成组件,列表页负责查询与跳转,表单页负责校验、提交与重新加载,后端 API 只返回数据与状态。

步骤前端动作对应文件对应接口
1点击侧栏「用车申请单管理」菜单记录 → list/index.vue无
2表格自动查询第一页list/index.vue 的 proxyConfigGET /oa/car-apply-bill/page
3点击「新增」,跳到隐藏路由handleCreate → info/index.vue无
4点击车辆框,选择车辆car-select-modal.vueGET 车辆分页
5填写时间、地点、事由,选择附件info/data.ts、AttachmentList无
6点击提交,先校验再请求handleSaveAndSubmit(true)POST /oa/car-apply-bill/submit
7成功后重新加载,页面变为只读loadData、computeBusinessFormReadonlyGET /oa/car-apply-bill/get
8回到列表,状态标签变成“审批中”onActivated 触发 gridApi.query()GET /oa/car-apply-bill/page

最后一行用到了页签激活钩子:onActivated 会在用户切回列表页签时刷新表格,所以不必每次手动点“刷新”。

同样的页面骨架可以复用到还车单,下图是还车申请单审批通过后的详情,可以看到顶部的状态徽标、四个页签与只读表单,结构和用车单完全一致:

还车申请单详情页:顶部显示审批通过,下方为单据信息、审批信息、流程图与流程评论页签,表单为只读

▲ 还车申请单详情:与用车申请单使用同一套 BasicForm 结构,差别只在字段定义和关联单据,这是“按规范拆分 list、info、data.ts”的收益。

七、常见故障矩阵

现象最可能的根因层如何确认正确的修法
点击菜单后空白页或 404菜单的组件路径与文件不一致对照菜单管理里的组件路径与 views 目录改菜单记录,不要为单个页面加路由别名
流程中心发起打开了旧页面流程模型里的 formCustomCreatePath 过期在流程模型里查看自定义表单路径改流程模型或出数据库脚本
按钮没有显示角色缺少对应权限标识角色管理里查看菜单权限,对照 auth 数组给角色授权,而不是把 auth 去掉
按钮显示了但点击报无权限后端 @PreAuthorize 与前端 auth 不一致对照后端 Controller 的权限字符串把两端权限标识统一
日期控件显示 NaN后端返回毫秒时间戳,控件没配 valueFormat: 'x'看接口返回值是数字还是字符串按“时间戳模式”配置控件
表单不能编辑processStatus 不在可编辑集合内看详情返回的 processStatus 与 viewType属于预期;需要修改请先撤回
列表数据看不到别人的单据后端强制按创建人过滤看第三篇 Service 里的分页逻辑属于预期;要看全部单据需要另做管理视图
字典标签显示原始数字字典类型名与常量不一致,或字典未加载看字典管理里的类型名与 DICT_TYPE 常量补字典数据,别在页面里硬编码文案

八、本文发现的几处不足

读源码时顺手记下了几处,供你在新模块里避开:

  1. API 类型把时间字段标成 Dayjs | string,与实际的毫秒时间戳不一致。
  2. 列表页行操作里保留了被注释掉的“查看、编辑”按钮,以及一个永远灰显的“占位删除”按钮,属于历史遗留,新页面不要照搬。
  3. 列表请求把 companyId、creator 传给后端,容易让读代码的人误以为这是安全控制,实际上后端会强制覆盖。
  4. 保存函数里 loading 在判断表单引用之前就被置为真,若引用为空会提前返回而不复位,新写的页面建议先判断、再置 loading。
  5. 失败时只有 console.error,用户是否看到提示取决于请求层,需要在联调时确认。

九、今天学完你应该能做到

  1. 能画出“菜单记录 → 组件路径 → list/index.vue → api → 后端”的链路,并指出每一环由谁维护。
  2. 能说出为什么字段定义放在 data.ts,而不是写在 .vue 里。
  3. 能解释 auth 与 @PreAuthorize 各自管什么,为什么缺一不可。
  4. 能看懂 computeBusinessFormReadonly 的判断顺序,并预测一张单据在各个状态下的页面表现。
  5. 能把 valueFormat: 'x' 与后端 LocalDateTime 的默认序列化对应起来。

常见问题(FAQ)

新增一个业务页面,应该复制哪个目录?

复制一个结构相近、规模适中的模块目录,如用车申请的 list、info 加各自的 data.ts,再配套复制 api 下对应的文件。复制后先改 API 路径和类型,再改字段定义,最后才改交互,顺序反了很容易漏改。

为什么不直接在 router 目录里手写路由?

业务菜单由后台配置,授权、隐藏、可见端和图标都在菜单表里管理。手写路由会让“菜单有、路由没有”或相反的情况重复出现。需要新增页面时,优先新增菜单记录,让组件路径指向页面文件。

auth 和 ifShow 有什么区别?

auth 对应权限标识,由角色授权决定;ifShow 对应业务条件,由数据状态决定。比如删除按钮既要有 oa:car-apply-bill:delete 权限,又要流程状态允许删除,两者要同时满足。

日期字段到底用时间戳还是字符串?

看后端字段类型。LocalDateTime 默认序列化为毫秒时间戳,前端用 showTime: true 加 valueFormat: 'x';LocalDate 后端必须加 @JsonFormat,前端用 YYYY-MM-DD。两端要成对配置。

表单页同时服务发起和审批,会不会很乱?

靠 isApproval、viewType 和公共函数 computeBusinessFormReadonly 控制。页面本身只关心“当前是否只读”,不关心自己被谁打开,这是它能复用的前提。

系列进度与下一篇预告

篇目主题状态
(一)架构篇——一个底座,多端通达已发布
(二)启动篇——从源码到前后端联调,跑通开发环境已发布
(三)后端篇——从业务建模到接口开发,做出一个完整模块已发布
(四)前端篇——从菜单路由到表单列表,完成业务页面本篇
(五)权限篇——把登录、角色、数据权限与租户隔离讲透下一篇
(六)流程篇——接入审批,让业务单据真正流转起来预告
(七)上线篇——从测试验收到部署上线,完成企业项目交付预告

页面已经能用了,但本文反复出现的“前端只负责看不见,后端才负责挡得住”还没有讲透。下一篇进入权限篇:从登录与令牌出发,沿着请求链路看角色菜单、按钮权限、部门数据范围和租户隔离怎样一层一层把住门,并解释为什么数据权限绝不能只靠前端。

如果这篇对你有用,点个「在看」或收藏。

🌐 演示地址:https://ruoyioffice.com/web
📦 GitHub 源码:https://github.com/yuqing2026/ruoyi-office
📦 Gitee 源码:https://gitee.com/yqzy1688/ruoyi-office
💬 微信:17156169080(获取产品咨询)

RuoYi Office 企业管理一体化平台

打开演示地址直接查看系统。

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/zhouzhongyan/article/details/166982287

文章来源转载

评论

赞0

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--