还在头疼后台管理页面?Vue3+ElementPlus封装的页面级组件让你5分钟一个页面

​Vue3实现常规后台查询页面。

上一篇我用vue2实现了对后台管理常规查询页面的封装。今天我们来用vue3来实现一下。Vue2的文章大家可以去看看

还在写重复的增删改查的重复代码?还在重复写Ajax请求?Vue2+Element-ui实现常规后台查询展示页面来了

需求我们就不分析那么多了跟vue2的一样,我们这次直接上代码。看看vue3的实现逻辑是怎么样的。

源码地址: github.com/fengligao/v... 也可以扫描下方二维码关注我的公众号或微信获取。

首先我们来看看页面实现的效果:

编辑

可以看到页面中集成了查询条件操作,表格数据展示,表格数据等操作,分页切换等基础功能。

我们可以根据功能分为4个基础操作区域:

编辑

我们先来看看条件查询区域该如何实现Vue3和Vue2写法不同 可以使用组合式api

不墨迹具体实现思路和Vue2的思路一样 还是根据form的数组来动态渲染条件类型。来看代码:

ini 复制代码
import { defineComponent, onBeforeMount, ref } from 'vue'
import { ElForm, ElFormItem, ElButton } from 'element-plus'

import { formProps } from './props'

import type { IFormItem } from './interface'

import MiJiInput from "@/components/miji-input.vue"
import MiJiSelect from '@/components/miji-select.vue'
import MiJiDateRange from '@/components/miji-daterange.vue'
import MiJiDate from '@/components/miji-date.vue'
import MiJiRadio from '@/components/miji-radio.vue'
import MiJiCheckbox from '@/components/miji-checkbox.vue'

import MiJiFormItem from './formItem.vue'

export default defineComponent({
  props: formProps,
  emits: ['search'],
  setup(props, { emit }) {
    const { form, isAsync } = props
    const forms = ref<Array<IFormItem>>([])
    const formData = ref({})
    // 表单 异步数据处理
    const optionsMap = (item: IFormItem) => {
      return new Promise((resolve) => {
        item.getOptions().then((res: any) => {
          const options = res.map(v => {
            return {
              label: v[item.optionsLabel || 'label'],
              value: v[item.optionsValue || 'value']
            }
          });
          resolve(options)
        })
      })
    }
    // 初始化表单项
    const initForm = async (_form) => {
      for (let i = 0; i < _form.length; i++) {
        const newItem: IFormItem = Object.assign(_form[i])
        newItem.weight = newItem.weight || 1; // 保留了用户可以自定义权重排序
        switch (newItem.type) {
          case 'daterange':
            newItem.value = Array.isArray(newItem.defaultValue) ? newItem.defaultValue : [];
            newItem.weight = 2;
            break;
          case 'date':
            newItem.value = newItem.defaultValue || '';
            break;
          case 'text':
            newItem.value = newItem.defaultValue || '';
            break;
          case 'select':
          case 'checkbox':
            newItem.value = newItem.defaultValue || (newItem.type === 'select' ? newItem.multiple ? [] : '' : newItem.multiple ? [] : []); // 多选 默认:空数组
            newItem.options = newItem.options || [];
            if (typeof newItem.getOptions === 'function') {
              if (isAsync || newItem.type === 'checkbox') {
                newItem.options = await optionsMap(newItem)
              } else {
                optionsMap(newItem).then(res => {
                  newItem.options = res
                })
              }
            }
            break;
          case 'radio-button':
          case 'radio':
            newItem.value = newItem.defaultValue || '';
            newItem.options = newItem.options || [];
            if (typeof newItem.getOptions === 'function') {
              newItem.options = await optionsMap(newItem)
            }
            break;
          case 'selectInput':
            // 选择 默认第一个
            newItem[newItem.selectKey] = newItem.selectOptions[0] ? newItem.selectOptions[0].value : '';
            newItem[newItem.selectInputKey] = '';
            break;
          default:
            break;
        }
        forms.value.push(newItem);
      }
    }

    const sortForm = (formlist) => {
      return new Promise((resolve) => {
        // 权重大的前置
        formlist.sort((prev, next) => prev.weight - next.weight);
        // radio checkbox 选项后置
        formlist.sort((prev, next) => {
          if (next.type === 'checkbox' || next.type === 'radio' || next.type === 'radio-button') {
            return -1
          }
        });
        formlist.sort((prev, next) => {
          if (next.label && prev.label) {
            return 0;
          }
          if (next.label && (!prev.label)) {
            return 1;
          }
          if ((!next.label) && prev.label) {
            return -1;
          }
        });

        resolve(formlist)
      })
    }

    // 生成表单的默认数据
    const initFormData = () => {
      const data = {}
      forms.value.forEach(item => {
        switch (item.type) {
          case 'daterange':
            data[item.key] = Array.isArray(item.defaultValue) ? item.defaultValue : [];
            break;
          case 'select':
          case 'checkbox':
            data[item.key] = item.defaultValue || (item.type === 'select' ? item.multiple ? [] : '' : []); // 多选 默认:空数组
            break;
          default:
            data[item.key] = item.defaultValue || '';
            break;
        }
      });
      return data
    }

    onBeforeMount(async () => {
      // 条件排序
      const newForm = await sortForm(form)
      await initForm(newForm)
      formData.value = initFormData()
      console.log('默认值:', formData.value);
      const param = await getParamByForm(formData.value);
      emit('search', param)
    })

    const onChangeFormValue = (value, index) => {
      // forms.value[index].value = value
      formData.value[forms.value[index].key] = value;
    }

    const renderInput = (formItem: IFormItem, formIndex: number) => {
      return <MiJiInput
        value={formData.value[formItem.key]}
        placeholder={formItem.placeholder}
        onInput={(value) => onChangeFormValue(value, formIndex)}
      />
    }
    // 下拉选择框
    const renderSelect = (formItem: IFormItem, formIndex: number) => {
      return <MiJiSelect
        options={formItem.options}
        value={formData.value[formItem.key]}
        placeholder={formItem.placeholder}
        valueFormat={formItem.valueFormat}
        onChange={(value) => onChangeFormValue(value, formIndex)}
      />
    }
    // 时间范围选择器
    const renderDateRange = (formItem: IFormItem, formIndex: number) => {
      return <MiJiDateRange
        value={formData.value[formItem.key]}
        startPlaceholder={formItem.startPlaceholder}
        valueFormat={formItem.valueFormat}
        endPlaceholder={formItem.endPlaceholder}
        onChange={(value) => onChangeFormValue(value, formIndex)}
      />
    }
    // 时间选择器
    const renderDate = (formItem: IFormItem, formIndex: number) => {
      return <MiJiDate
        value={formData.value[formItem.key]}
        placeholder={formItem.placeholder}
        valueFormat={formItem.valueFormat}
        onChange={(value) => onChangeFormValue(value, formIndex)}
      />
    }
    // 单选框组
    const renderRadio = (formItem: IFormItem, formIndex: number) => {
      return <MiJiRadio
        value={formData.value[formItem.key]}
        type={formItem.type}
        options={formItem.options}
        onChange={(value) => onChangeFormValue(value, formIndex)}
      />
    }
    // 多选框组
    const renderCheckbox = (formItem: IFormItem, formIndex: number) => {
      return <MiJiCheckbox
        value={formData.value[formItem.key]}
        options={formItem.options}
        onChange={(value) => onChangeFormValue(value, formIndex)}
      />
    }
    // 渲染表单
    const renderFormItem = (item: IFormItem, i: number) => {
      let formItem = null
      switch (item.type) {
        case 'text':
          formItem = renderInput(item, i)
          break;
        case 'select':
          formItem = renderSelect(item, i)
          break;
        case 'daterange':
          formItem = renderDateRange(item, i)
          break;
        case 'date':
          formItem = renderDate(item, i)
          break;
        case 'radio-button':
          formItem = renderRadio(item, i)
          break;
        case 'radio':
          formItem = renderRadio(item, i)
          break;
        case 'checkbox':
          formItem = renderCheckbox(item, i)
          break;
        default:
          break;
      }
      return <MiJiFormItem
        label={item.label}
        width={item.width}
        type={item.type}
      >
        {formItem}
      </MiJiFormItem>
    }

    const renderForm = () => {
      const formItem: any = []
      for (let i = 0; i < forms.value.length; i++) {
        const item = forms.value[i];
        formItem.push(renderFormItem(item, i))
      }
      return <ElForm
        class="template-page__form"
        inline={true}
        model={formData}
        label-width={'80px'}
      >
        {formItem}
        <ElFormItem style={{ paddingLeft: 0, borderColor: 'rgba(0,0,0,0)' }}>
          <ElButton class="search-btn" type="primary" onClick={onSubmit}>查 询</ElButton>
          <ElButton style={{ height: '100%' }} onClick={onCancel}>重 置</ElButton>
        </ElFormItem>
      </ElForm>
    }

    const getParamByForm = (data) => {
      const param = {}
      for (const i in data) {
        const item: any = forms.value.find(v => v.key === i)
        switch (item.type) {
          // 时间范围
          case 'daterange':
            if (item.isSelectKey) {
              param[item.key] = data[i]
              param['isArrayKey'] = item.key
            } else {
              param[item.startDateKeyName || 'kaiShiSJ'] = Array.isArray(data[i]) ? data[i][0] : '';
              param[item.endDateKeyName || 'jieShuSJ'] = Array.isArray(data[i]) ? data[i][1] : '';
            }
            break;
          case 'checkbox':
            param[item.key] = (data[i] || []).join();
            break;
          case 'select':
            param[item.key] = Array.isArray(data[i]) ? data[i].join() : data[i];
            break;
          default:
            param[item.key] = data[i];
        }
      }
      return param
    }

    const onSubmit = () => {
      const param = getParamByForm(formData.value);
      emit('search', param)
    }

    const onCancel = async () => {
      formData.value = initFormData()
    }

    return () => renderForm()
  }
})

在表单的组件中我有单独分装了一下input、select、checkbox等组件,包括项目中扩展了新增编辑的通用组件,这里也可以直接使用这些表单组件。

表单区域实现以后我们来看下页面的代码

页面模版中分为 条件查询、表格展示、表格分页、插槽内容。

这里我预留了只保留条件查询,查询数据后不使用常规的表格展示使用的自定义内容的插槽。

ini 复制代码
import { defineComponent, ref } from 'vue'
import './index.scss'

import { pageProps } from './props'
import MiJiPage from '../miji-page.vue'

import Form from './form'
import Table from './table'
import Pagination from './pagination.vue'

export default defineComponent({
  name: 'miji-template-page',
  props: pageProps,
  setup(props, ctx) {
    console.log(ctx);
    const {
      url,
      form,
      method,
      beforeRequest,
      afterResponse,
      showPagination,
      columns,
      isAsync,
      tableConfig = {}
    } = props
    const dataSource = ref()

    const requestData = ref()
    const urlQuery = ref()

    const pageNo = ref<number>(1)
    const total = ref<number>(0)
    const pageSize = ref<number>(20)

    const initPage = () => {
      return <MiJiPage className="template-page">
        <Form
          form={form}
          isAsync={isAsync}
          onSearch={(param) => onSearch(param)}
        />
        <div class="template-page__content">
          {
            ctx.slots.tableOptions || tableConfig.title
              ? <div className="content-options">
                <div className="content-options__title">{tableConfig.title || ''}</div>
                <div className="content-options__slot">
                  {ctx.slots.tableOptions ? ctx.slots.tableOptions() : ''}
                </div>
              </div>
              : ''
          }
          {
            ctx.slots.defaultContent ? ctx.slots.defaultContent() : <Table
              dataSource={dataSource.value}
              columns={columns}
            >
              {{
                ...ctx.slots
              }}
            </Table>
          }
        </div>
        {
          !ctx.slots.defaultContent && showPagination && total.value > 0 ? <Pagination
            pageNo={pageNo.value}
            pageSize={pageSize.value}
            total={total.value}
            onCurrentChange={(page: number) => handleCurrentChange(page)}
            onPageSizeChange={(size: number) => handlePageSizeChange(size)}
          /> : ''
        }
      </MiJiPage>
    }

    const handleCurrentChange = (page: number) => {
      pageNo.value = page
      getData()
    }

    const handlePageSizeChange = (size: number) => {
      pageSize.value = size
      pageNo.value = 1
      getData()
    }

    const onSearch = (param) => {
      pageNo.value = 1
      requestData.value = param
      if (method === 'get' && param.isArrayKey) {
        urlQuery.value = param.isArrayKey ? '?' + param[param.isArrayKey].map(v => param.isArrayKey + '=' + v).join(',').replace(',', '&') : ''
        delete requestData.value[param.isArrayKey]
        delete requestData.value.isArrayKey
      }
      getData()
    }

    const getData = () => {
      if (showPagination) {
        const page = {
          current: pageNo.value,
          size: pageSize.value
        }
        Object.assign(requestData.value, page);
      }
      if (beforeRequest) {
        requestData.value = beforeRequest(requestData.value);
        if (!requestData.value) return false; // 如果返回false,则取消当前请求
      }
      const data = method === 'get' ? { params: requestData.value } : requestData.value; // 请求入参
      console.log('最终入参:', data);

      setTimeout(() => {
        let data: { records: any } = { records: [] }
        data = afterResponse ? afterResponse(data) : data;
        console.log('处理后的出参:', data);
        dataSource.value = data.records
        pageNo.value = 1
        pageSize.value = 10
        total.value = 100
      }, 2000)
    }

    return () => initPage()
  }
})

在常规页面中我们使用的表格组件正是我上一篇文章的组件这里也不过多介绍了可以看看这篇文章 Vue3 ElementPlus 二次封装常用表格展示组件 或者直接下载本篇文章的 源代码

下面这个弹层是封装的一个通用新增编辑表单的组件

编辑

组件的实现思路和页面查询表单的动态渲染一样的原理,这里呢 我用了一个element-plus的抽屉组件来当作统一弹层,大家有想用dialog得或者切换页面新增编辑的可以在这个基础之上进行拓展。下面来看看代码:

typescript 复制代码
import { defineComponent, ref, reactive, onMounted } from "vue";
import {
  ElDrawer,
  ElFooter,
  ElForm,
  ElFormItem,
  ElButton,
  ElNotification
} from 'element-plus'

import type { FormInstance, FormRules } from 'element-plus'

import './index.scss'

import IProps from './props'
import MiJiRadio from '@/components/miji-radio.vue'
import MiJiInput from "@/components/miji-input.vue"
import MiJiSelect from '@/components/miji-select.vue'
import MiJiDateRange from '@/components/miji-daterange.vue'
import MiJiDate from '@/components/miji-date.vue'
import MiJiCheckbox from '@/components/miji-checkbox.vue'

import {
  initFormValues,
  initFormRules,
  initFormItems,
} from './form'

export default defineComponent({
  name: 'miji-a-u-drawer', // 右侧抽屉新增编辑形式 a: add u: update
  props: IProps,
  emits: {
    close: null
  },
  setup(props, { emit, slots }) {
    const drawer = ref(true)
    // props
    const {
      method,
      url,
      title,
      size,
      showFooter,
      labelWidth,
      formSize,
      forms,
      labelPosition,
      className,
      formValues,
      cancelText,
      enterText
    } = props
    // 插槽
    const { header } = slots

    // 表单ref
    const ruleFormRef = ref<FormInstance>()

    console.log('新增编辑的数据:', forms);
    const formItems = ref<any>([])

    onMounted(async () => {
      formItems.value = await initFormItems(forms)
      console.log(formItems.value);
    })

    const rulesItem = initFormRules(forms) // 初始化表单校验规则
    const ruleFormItem = initFormValues(forms, formValues) // 初始化表单数据

    const rules = reactive<FormRules>(rulesItem)
    const ruleForm = reactive<any>(ruleFormItem)

    console.log('初始化数据:', ruleForm, rules);

    const handleEnter = (formEl) => {
      console.log('enter option', formEl, ruleFormRef);
      if (!formEl) return
      formEl.validate((valid) => {
        if (valid) {
          console.log('submit!', ruleForm)
          ElNotification({
            title: '提示',
            message: '新增编辑成功',
          })
        } else {
          console.log('error submit!')
          return false
        }
      })
    }
    const handleCancel = () => {
      console.log('cancel option');
      emit('close', false)
    }

    const onChangeFormValue = (value, v) => {
      // forms.value[index].value = value
      ruleForm[v.name] = value;
      // 表单值发生变化后 针对当前字段重新验证
      if (!ruleFormRef.value) return false
      ruleFormRef.value.validateField(v.name)
    }

    const renderFormItem = (v) => {
      let item = null
      switch (v.type) {
        case 'input':
          item = <MiJiInput
            value={ruleForm[v.name]}
            placeholder={v.placeholder}
            onInput={(value) => onChangeFormValue(value, v)}
          />
          break;
        case 'select':
          item = <MiJiSelect
            options={v.options}
            value={ruleForm[v.name]}
            placeholder={v.placeholder}
            onChange={(value) => onChangeFormValue(value, v)}
          />
          break;
        case 'checkbox':
          item = <MiJiCheckbox
            value={ruleForm[v.name]}
            options={v.options}
            onChange={(value) => onChangeFormValue(value, v)}
          />
          break;
        case 'radio':
          item = <MiJiRadio
            value={ruleForm[v.name]}
            type={v.radioType}
            options={v.options}
            onChange={(value) => onChangeFormValue(value, v)}
          />
          break;
        case 'daterange':
          item = <MiJiDateRange
            value={ruleForm[v.name]}
            startPlaceholder={v.startPlaceholder}
            valueFormat={v.valueFormat}
            endPlaceholder={v.endPlaceholder}
            onChange={(value) => onChangeFormValue(value, v)}
          />
          break;
        case 'date':
          item = <MiJiDate
            value={ruleForm[v.name]}
            placeholder={v.placeholder}
            valueFormat={v.valueFormat}
            onChange={(value) => onChangeFormValue(value, v)}
          />
          break;
        default:
          break;
      }
      return <ElFormItem
        label={v.label}
        prop={v.name}
      >
        {item}
      </ElFormItem>
    }

    const renderForm = () => {
      const items: any = []
      console.log(formItems);

      formItems.value.forEach((v: any) => {
        items.push(renderFormItem(v))
      })
      return <ElForm
        ref={ruleFormRef}
        model={ruleForm}
        rules={rules}
        labelPosition={labelPosition}
        labelWidth={labelWidth}
        class={className}
        size={formSize}
      >
        {items}
      </ElForm>
    }

    // 关闭弹窗
    const beforeClose = () => [
      emit('close', false)
    ]

    return () => (
      <ElDrawer
        modelValue={drawer.value}
        title={title || '提示'}
        size={size}
        beforeClose={beforeClose}
        v-slots={{
          header: () => header && header(),
          footer: () => showFooter && <ElFooter>
            <div style={{
              height: '100%',
              alignItems: 'flex-end',
              justifyContent: 'flex-end',
              display: 'flex'
            }}>
              <ElButton onClick={handleCancel}>{cancelText}</ElButton>
              <ElButton
                type="primary"
                onClick={() => handleEnter(ruleFormRef.value)}
              >{enterText}</ElButton>
            </div>
          </ElFooter>
        }}
      >
        {renderForm()}
      </ElDrawer>
    )
  },
})

思路一样也是动态根据数组类型来动态渲染表单。

这里注意的是 我用几个方法 单独处理了一下传递进来的表单数组。

把form需要的初始化数据、form的校验规则、form要渲染的内容处理、需要异步加载的数据。

typescript 复制代码
// 初始化表单认值,
export const initFormValues = (forms, values) => {
  const obj = {}
  for (let i = 0; i < forms.length; i++) {
    const e: any = forms[i];
    switch (e.type) {
      case 'select':
        obj[e.name] = values ? values[e.name] : e.value || (e.multiple ? [] : '')
        break;
      case 'checkbox':
        obj[e.name] = values ? values[e.name] : e.value || []
        break;
      default:
        obj[e.name] = values ? values[e.name] : e.value || ''
        break;
    }
  }
  return obj
}
// 初始化表单规则
export const initFormRules = (forms) => {
  const obj = {}
  for (let i = 0; i < forms.length; i++) {
    const e: any = forms[i];
    obj[e.name] = {
      required: e.required,
      message: e.message,
      trigger: e.trigger,
    }
  }
  return obj
}

// 表单 异步数据处理
const optionsMap = (item) => {
  return new Promise((resolve) => {
    item.getOptions().then((res: any) => {
      const options = res.map(v => {
        return {
          label: v[item.optionsLabel || 'label'],
          value: v[item.optionsValue || 'value']
        }
      });
      resolve(options)
    })
  })
}
// 初始化表单渲染内容中的异步数据
export const initFormItems = async (forms) => {
  const arr: Array<any> = []
  for (let i = 0; i < forms.length; i++) {
    const item = Object.assign({}, forms[i])
    if (typeof item.getOptions === 'function') {
      item.options = await optionsMap(item)
    }
    arr.push(item);
  }
  return arr
}

这里我们看看在页面中如何使用:

组件的全局组册就这样在入口文件引入就好了

javascript 复制代码
import MiJiTable from './components/templatePage/table'
import TemplatePage from './components/templatePage/index'
import MiJiAuDrawer from './components/addOrUpdateItem/index'

const app = createApp(App)

app.component(MiJiTable.name, MiJiTable)
app.component(TemplatePage.name, TemplatePage)
app.component(MiJiAuDrawer.name, MiJiAuDrawer)

在页面中我们可以直接使用我们组件的name属性声明的组件名称

xml 复制代码
<miji-template-page
    method="get"
    url="/api"
    :form="form"
    :columns="columns"
    :beforeRequest="beforeRequest"
    :afterResponse="afterResponse"
    :pageNo="1"
    :pageSize="20"
    :tableConfig="{ title: '表格标题' }"
  >
    <!-- 页面组件自带表格插槽部分 -->
    <template #xingBie="scope">{{ '插槽:' }}</template>
    <!-- 页面组件 表格前插槽部分 -->
    <template #tableOptions>
      <el-button type="primary" @click="add">新增</el-button>
    </template>
    <!-- 自定义内容插槽 -->
    <template #defaultContent>
      // 这里自定义插槽的内容
      <el-tree
        style="max-width: 600px"
        :data="data"
        :props="defaultProps"
        @node-click="handleNodeClick"
      />
    </template>
  </miji-template-page>

页面中可以直接只用内置表格,也可以使用自定义的展示数据的方式:比如树形图结构的

搭配新增编辑的通用组件来实现页面的基本功能

perl 复制代码
<miji-a-u-drawer
    v-if="drawer"
    @close="handleClose"
    @success="handleSuccess"
    url="/api"
    labelWidth="100px"
    labelPosition="top"
    size="50%"
    :forms="[
      {
        type: 'input',
        label: '名称',
        name: 'mingCheng',
        value: '',
        required: true,
        placeholder: '请输入名称',
        message: '名称是不可或缺的入参',
        trigger: 'blur'
      },
      {
        type: 'select',
        label: '下拉',
        name: 'xiaLa',
        value: '',
        required: true,
        placeholder: '请选择下拉',
        message: '下拉是不可或缺的入参',
        trigger: 'change',
        options: [
          {
            value: 'Option1',
            label: 'Option1'
          }
        ],
        optionsLabel: 'name',
        optionsValue: 'id',
        getOptions: getAddOrUpdateOptions
      },
     // ... 等等其他的所需数据
    ]"
    :formValues="formValues"
  >
    <template #header>
      <h4>自定义头</h4>
    </template>
  </miji-a-u-drawer>

以上是简单的使用方式大家看兴趣的可以直接去下载源码看看完整代码。

所以一个常规后台管理页面的组件大大提高我们的开发效率,虽然不及低代码,但是在一定程度上也解决了我们重复代码重复开发的难题。

有兴趣的朋友可以在这个基础上面,配合后端的同学把创建页面和一些操作逻辑通过配置存起来。然后再搭配我这个组件来使用也是一种低代码的实现方式。

近期我会出一个开源的后台管理的项目模版,大家可以关注我的公众号及时获取最新文章。

欢迎大家私信留言,多多点评

可以微信搜索 web秘籍 关注我的公众号 或添加我的微信 2545070038 联系、沟通。

相关推荐
前端小小王31 分钟前
React Hooks
前端·javascript·react.js
迷途小码农零零发40 分钟前
react中使用ResizeObserver来观察元素的size变化
前端·javascript·react.js
娃哈哈哈哈呀1 小时前
vue中的css深度选择器v-deep 配合!important
前端·css·vue.js
旭东怪2 小时前
EasyPoi 使用$fe:模板语法生成Word动态行
java·前端·word
ekskef_sef3 小时前
32岁前端干了8年,是继续做前端开发,还是转其它工作
前端
sunshine6413 小时前
【CSS】实现tag选中对钩样式
前端·css·css3
真滴book理喻4 小时前
Vue(四)
前端·javascript·vue.js
蜜獾云4 小时前
npm淘宝镜像
前端·npm·node.js
dz88i84 小时前
修改npm镜像源
前端·npm·node.js
Jiaberrr4 小时前
解锁 GitBook 的奥秘:从入门到精通之旅
前端·gitbook