头图

使用Vsee开发之初体验

下载案例

访问官方网站首页,点击页面上的下载案例,将会得到一个png图片,将其改为7z格式并解压。解压密码ilovevsee。之后使用npm i进行安装。注意,如果你的网络有问题,可以尝试关闭不必要的VPN代理,或者使用其他方式代理NPM,或者切换NPM的镜像源。等待若干时间后,项目安装完成,即可运行里面的案例项目。或者跟着本篇教程自制你的第一个vsee项目——demo-temp

创建项目模版

projects目录下新建文件夹demo-temp,该文件夹内部文件初始化如下:

# %vsee-demo%/projects/demo-temp 目录结构
- demo-temp                # 子项目目录
    - bootstrap.js        # 引导文件
    - config.js                # 配置文件

当你准备好如上的目录结构后,就可以正式启动了:

# 在项目%vsee-demo%下运行命令启动`demo-temp`项目
npm run dev --project=demo-temp --mock
  • project=demo-temp 告知应用启动哪个项目
  • mock 指定当前项目使用模拟数据进行开发

有关项目的更多启动参数,可以查看这里。这时再查看项目结构,发现多了两个文件夹,这两个文件夹可以手动创建,也可以自动创建:

# %vsee-demo%/projects/demo-temp 目录结构
- demo-temp
    - _                    # 公共模块,作用于`demo-temp`这个项目
    - _layout        # 布局模块,存放外部视图组件的地方
    - bootstrap.js        
    - config.js                

__layout文件夹的作用在后面的案例中会不断清晰,想直接了解可以查看这里。带下划线的文件夹模块将不会按照六层结构的模式去解析。

项目启动好后,请尽量不要访问localhost的服务,由于默认项目启动了https,热更新也会启动https,所以通过你的IP访问项目能得到很好的热更新效果。此时打开网页,你将会看到一个空的项目。由于我们没有数据源,因此页面上啥也看不到。

模拟菜单数据

你可以使用ApiFox、ApiPost等工具进行本地或者在线模拟数据源,但是作为熟练使用代码开发的人来说,使用我们内置的Mock服务会是非常方便的事,在项目的_文件夹中新建mock文件夹和index.js文件:

# %vsee-demo%/projects/demo-temp 目录结构
- demo-temp
    - _                # 公共模块
        - mock        # mock模块
            - index.js    # 执行模拟的文件,叫任何名称都行

然后我们给mock/index.js文件添加点模拟数据:

// %vsee-demo%/projects/demo-temp/_/mock/index.js
import mock from '@mock'

// 模拟一个get请求,请求地址为'/sys/menu/nav',返回一个response json
mock.get('/sys/menu/nav', {
  code: 0,
  msg: '',
  data: [
    {
      id: 100000,
      name: '用户管理',
      url: 'user',
      pid: null
    }
  ]
})

现在应用里增加了一个菜单,但是我们在页面还是什么也看不到,这是因为虽然模拟了数据,但是客户端并没有生成该页面。我们还需要好几步才能看到页面。有关模拟的其他使用方式,请参考这里

先在demo-temp目录下新建如下目录结构:

# %vsee-demo%/projects/demo-temp 目录结构
- demo-temp        # 子项目目录
    - user                # 业务模块——用户管理
        - router            # 路由模块
            - index.vue        # 路由映射文件

user/router/index.vue文件随便写上点什么内容:

<!-- %vsee-demo%/projects/demo-temp/user/router/index.vue -->
<template>
  <h1>用户管理</h1>
</template>

接着,在demo-temp/config.js文件中编辑如下数据:

// %vsee-demo%/projects/demo-temp/config.js
export default {
  // 授权配置
  auth: {
    login: ['/auth/oauth/token'],            // 登陆接口配置
    logout: ['/auth/logout'],                    // 登出接口配置
    user: ['/sys/user/info', {}, {}],    // 用户信息接口配置
    // * 配置菜单接口 *
    menu: ['/sys/menu/nav', {}, []]    
  }
}

除了我们在auth.menu里配置的是菜单接口,其他接口以后留着备用。此时我们可以看到页面正常渲染出来了。有关配置中心的其他配置,请参考这里

模拟用户CRUD

现在,我们需要模拟用户模型的CURD操作,来完成一个小应用。此时,我们可以选择继续在_/mock/index.js里继续进行数据模拟,也可以选择新建user/mock/index.js来进行同上的模拟操作。我们选择使用后者的方式,需要新建如下目录和文件:

# %vsee-demo%/projects/demo-temp 目录结构
- demo-temp
    - user
        - mock                # mock模块
            - index.js        # 模拟数据的文件
        - api                    # api模块
            - index.js        # 接口定义的文件

然后按如下代码实现mock/index.jsapi/index.js里的文件

// %vsee-demo%/projects/demo-temp/user/api/index.js
import { definer } from '@api'

definer({
  name: 'getUserPage',
  url: '/demo-temp/user/page',
  desc: '获取用户分页列表'
})

definer({
  name: 'getUserById',
  url: '/demo-temp/user/:id',
  desc: '根据id获取用户详细信息'
})

definer({
  name: 'addUser',
  url: '/demo-temp/user',
  type: 'post',
  desc: '新增用户'
})

definer({
  name: 'updateUser',
  url: '/demo-temp/user',
  type: 'put',
  desc: '修改用户'
})

definer({
  name: 'delUser',
  url: '/demo-temp/user/:id',
  type: 'delete',
  desc: '删除用户'
})
// 批量删除无法使用`delete`请求实现,改为`post`实现
definer({
  name: 'delUsers',
  url: '/demo-temp/users/del',
  type: 'post',
  desc: '批量删除用户'
})

有关@api的更多使用方式,请参考这里

// %vsee-demo%/projects/demo-temp/user/mock/index.js
import mock from '@mock'
import api from '@api'

// 模拟20到80个随机用户信息
const userList = mock(
  {
    id: '@id',
    code: 'SN@natural(10000000, 999999999)',
    name: '@cname',
    age: '@integer(18, 60)',
    sex: '@integer(0, 1)',
    birthday: '@datetime("T")',
    phone: '1@pick([31, 34, 37, 38, 39, 51, 58, 59, 68, 77, 79, 83, 89])@natural(1000000, 99999999)',
    mobile: '0@natural(10, 999) @natural(1000, 9999)-@natural(1000, 9999)',
    region: '@region',
    county: '@county(true)',
    email: '@email',
    creator: '@pick(["管理员专员", "超级管理员", "系统管理员", "平台管理员"])',
    createDate: '@datetime("T")'
  },
  mock('@int(20, 80)')
)
// 模拟api接口,拦截{page,limit}参数,做分页查询
mock.fetch(
  api.getUserPage,
  ({ page, limit }) => {
    return {
      code: 0,
      msg: '',
      data: {
        list: userList.slice((page - 1) * limit, page * limit),
        total: userList.length
      }
    }
  },
  { timeout: mock('@int(400, 800)') }
)

mock.fetch(
  api.getUserById,
  (id) => {
    return {
      code: 0,
      msg: '',
      data: userList.find((item) => item.id === id)
    }
  },
  { timeout: mock('@int(400, 800)') }
)

mock.fetch(
  api.addUser,
  (user) => {
    const id = mock('@id')
    userList.push({ id, ...user })
    return {
      code: 0,
      msg: '',
      data: userList[userList.length - 1]
    }
  },
  { timeout: mock('@int(400, 800)') }
)

mock.fetch(
  api.updateUser,
  (user) => {
    const userRecord = userList.find((item) => item.id === user.id)
    Object.assign(userRecord, user)
    return {
      code: 0,
      msg: '',
      data: userRecord
    }
  },
  { timeout: mock('@int(400, 800)') }
)

mock.fetch(
  api.delUser,
  (id) => {
    const index = userList.findIndex((item) => item.id === id)
    userList.splice(index, 1)
    return {
      code: 0,
      msg: '',
      data: null
    }
  },
  { timeout: mock('@int(400, 800)') }
)

mock.fetch(
  api.delUsers,
  ({ids}) => {
    for (let i = 0; i < ids.length; i++) {
      const index = userList.findIndex((item) => item.id === ids[i])
      userList.splice(index, 1)
    }
    return {
      code: 0,
      msg: '',
      data: null
    }
  },
  { timeout: mock('@int(400, 800)') }
)

有了基础数据的支撑,我们就可以开始编写我们的视图了。有关@mock的更多使用方式,请参考这里

用户视图CRUD

开发CRUD基础视图

修改user/router/index.vue文件,编写基础的用户信息CRUD页面:

<!-- %vsee-demo%/projects/demo-temp/user/router/index.vue -->
<template>
  <!-- 查询表单 -->
  <a-form>
    <a-row :gutter="16">
      <a-col :span="colSpan">
        <a-form-item label="姓名">
          <a-input v-model:value="searchRecord.name" />
        </a-form-item>
      </a-col>
      <a-col :span="colSpan">
        <a-form-item label="性别">
          <a-select
            v-model:value="searchRecord.sex"
            :options="[
              { value: 0, label: '男' },
              { value: 1, label: '女' }
            ]"
          />
        </a-form-item>
      </a-col>
      <a-col :span="colSpan">
        <a-space>
          <a-button>重置</a-button>
          <a-button type="primary">查询</a-button>
        </a-space>
      </a-col>
    </a-row>
  </a-form>
  <!-- 表格全局控制 -->
  <a-space>
    <a-button type="link" @click="() => (selectedId = null)"> 新增 </a-button>
  </a-space>
  <!-- 表格视图 -->
  <a-table
    row-key="id"
    :data-source="list"
    :pagination="pagination"
    :columns="columns"
    :scroll="{ x: 'max-content' }"
    :loading="fetching"
  >
    <template #headerCell="{ column }">
      {{ column.title }}
    </template>
    <template #bodyCell="{ column }">
      <template v-if="column.key === '$action'">
        <a-button type="link"> 编辑 </a-button>
        <a-popconfirm title="是否确认删除?">
          <a-button type="link">删除</a-button>
        </a-popconfirm>
      </template>
    </template>
  </a-table>
  <!-- 模态表单,用于新增和编辑 -->
  <a-modal
    title="编辑用户"
    :visible="selectedId !== void 0"
    :ok-button-props="{ loading: fetching }"
    @cancel="() => (selectedId = void 0)"
  >
    <a-form ref="modalForm">
      <a-form-item label="姓名" name="name" :rules="[{ required: true, message: '请输入用户姓名' }]">
        <a-input v-model:value="modalRecord.name" placeholder="请输入用户姓名" />
      </a-form-item>
      <a-form-item label="年龄" name="age" :rules="[{ required: true, message: '输入用户年龄' }]">
        <a-input-number v-model:value="modalRecord.age" placeholder="请输入用户年龄" />
      </a-form-item>
    </a-form>
  </a-modal>
</template>

<script>
// 列配置信息
const columns = [
  { key: 'code', dataIndex: 'code', name: 'code', title: '编号', fixed: true },
  { key: 'name', dataIndex: 'name', name: 'name', title: '姓名' },
  { key: 'age', dataIndex: 'age', name: 'age', title: '年龄' },
  { key: 'sex', dataIndex: 'sex', name: 'sex', title: '性别' },
  { key: 'birthday', dataIndex: 'birthday', name: 'birthday', title: '生日' },
  { key: 'phone', dataIndex: 'phone', name: 'phone', title: '电话' },
  { key: 'mobile', dataIndex: 'mobile', name: 'mobile', title: '座机' },
  { key: 'region', dataIndex: 'region', name: 'region', title: '区域' },
  { key: 'county', dataIndex: 'county', name: 'county', title: '国家' },
  { key: 'email', dataIndex: 'email', name: 'email', title: '邮箱' },
  { key: 'creator', dataIndex: 'creator', name: 'creator', title: '创建者' },
  { key: 'createDate', dataIndex: 'createDate', name: 'createDate', title: '创建时间' },
  { key: '$action', dataIndex: '$action', name: '$action', title: '操作', fixed: 'right' }
]
export default defineComponent({
  setup() {
    // 查询表单列宽
    const colSpan = 6
    // 表格数据源
    const list = ref([])
    // 分页数据
    const pagination = ref({ current: 1, pageSize: 10, total: 0 })
    // 查询表单实体
    const searchRecord = ref({})
    // 模态表单实体,用于新增或编辑
    const modalRecord = ref({})
    const modalForm = ref()
    // 已选择实体id,其中`undefined`代表未选择,`null`代表新增,其他存在的值代表编辑
    const selectedId = ref()
    // 是否正在发送请求,如果请求,将可交互UI进行锁定,交互完成后解锁
    const fetching = ref(false)

    return {
      colSpan,
      columns,
      list,
      pagination,
      searchRecord,
      modalRecord,
      modalForm,
      selectedId,
      fetching
    }
  }
})
</script>

此时的页面存在两个问题:

  1. 没有数据支撑
  2. 视图没有美化

要解决第一个问题,我们就要使用@api模块来获取之前定义的mock数据,现在,我们生成如下接口的代码备用:

// %vsee-demo%/projects/demo-temp/user/router/index.vue 部分代码
import api from '@api'

// 获取表格分页数据
function fetch() {
  fetching.value = true
  const page = pagination.value.current
  const limit = pagination.value.pageSize
  api.getUserPage({ page, limit, ...searchRecord.value }).then(([res, err]) => {
    if (!err) {
      list.value = res.list
      pagination.value.total = res.total
      fetching.value = false
    }
  })
}
// 获取单个实体数据
function fetchById(id) {
  fetching.value = true
  api.getUserById(id).then(([data, err]) => {
    if (!err) {
      modalRecord.value = data
    }
    fetching.value = false
  })
}
// 新增或更新实体
function addOrUpdate(id, record) {
  fetching.value = true
  const exec = id ? api.updateUser : api.addUser
  exec(record).then(([, err]) => {
    if (!err) {
      console.log('更新成功')
      selectedId.value = void 0
      fetch()
    }
    fetching.value = false
  })
}
// 删除实体
function remove(id = null) {
  return new Promise((resolve) => {
    fetching.value = true
    api.delUser(id).then(([, err]) => {
      if (!err) {
        console.log('删除成功')
        resolve(!fetch())
      } else {
        resolve(false)
      }
      fetching.value = false
    })
  })
}

然后再来看视图美化的问题,虽然vue3支持多根节点在一个模版中,但这并不是什么好事,对于这个页面的结构来说,看起来并不严谨。我们希望这个页面可以有很好的分层,结构之间的间距也要舒缓一点。首先,我们将此页面的布局换成如下结构:

<!-- %vsee-demo%/projects/demo-temp/user/router/index.vue -->
<template>
    <!-- 内容盒子 -->
  <div class="content-box">
    <!-- 面板盒子 -->
    <div class="panel-box">
      <!-- 表单容器 -->
      <a-form class="form-type-of-compact form-type-of-action-right">
        <a-row>
          <a-col>
            <!-- 表单项 -->
          </a-col>
          <a-col>
            <!-- 表单项 -->
          </a-col>
          <a-col>
            <!-- 表单项 -->
          </a-col>
          <!-- 操作列 -->
          <a-col class="form-column-action">
            <!-- 操作按钮 -->
          </a-col>
        </a-row>
      </a-form>
    </div>
    <!-- 面板盒子 -->
    <div class="panel-box">
      <!-- 表格公共操作空间 -->
      <div class="table-common-action"></div>
      <!-- 表格容器 -->
      <a-table class="table-type-of-compact"/>
    </div>
    <!-- 模态表单 -->
  </div>
</template>

现在我们的视图结构变得非常清晰有层次了,以后有类似的页面都可以按照此布局进行开发了。然后我们需要增加上述所需要的样式。我们可以在projects/demo-temp下新建一个theme.less来进行全局样式的编写:

# %vsee-demo%/projects 目录结构
- projects                # 项目集
    - demo-temp                # 子项目
        - theme.less            # 样式文件
// %vsee-demo%/projects/demo-temp/theme.less
// 内容盒容器
.content-box {
  background: #F5F5F5;
  height: 100%;
  padding: 12px;
  display: flex;
  flex-direction: column;
  & > .panel-box {
    flex: 0 0 auto;
    & ~ .panel-box {
      flex: 1 1 auto;
    }
  }
}
// 面板盒容器
.panel-box {
  padding: 12px;
  background: white;
  border-radius: 4px;
  & ~ & {
    margin-top: 12px;
  }
}

// 紧凑表单
.form-type-of-compact {
  margin-bottom: -8px !important;
  .ant-form-item {
    margin-bottom: 8px;
  }
  &.ant-form-vertical .ant-form-item-label {
    padding-bottom: 0;
  }
}

// 操作距右
.form-type-of-action-right {
  .form-column-action {
    display: flex;
    justify-content: flex-end;
    align-items: flex-start;
    margin-left: auto;
  }
}

// 紧凑表格
.table-type-of-compact {
  &.ant-table-wrapper {
    .ant-table-container table > thead > tr th {
      padding: 8px 8px;
    }
    .ant-table-container table > tbody > tr td {
      padding: 4px 8px;
    }
    .ant-table ~ .ant-pagination {
      margin-top: 8px;
      margin-bottom: 0;
    }
  }
}

有关主题样式的更多参考信息,请点击这里。现在,我们的页面已经优化的差不多了。最后,我们将交互逻辑都绑定上去,看看最后的代码:

<!-- %vsee-demo%/projects/demo-temp/user/router/index.vue -->
<template>
  <div class="content-box">
    <div class="panel-box">
      <!-- 查询表单 -->
      <a-form class="form-type-of-compact form-type-of-action-right">
        <a-row :gutter="16">
          <a-col :span="colSpan">
            <a-form-item label="姓名">
              <a-input v-model:value="searchRecord.name" />
            </a-form-item>
          </a-col>
          <a-col :span="colSpan">
            <a-form-item label="性别">
              <a-select
                v-model:value="searchRecord.sex"
                :options="[
                  { value: 0, label: '男' },
                  { value: 1, label: '女' }
                ]"
              />
            </a-form-item>
          </a-col>
          <a-col class="form-column-action" :span="colSpan">
            <a-space>
              <a-button>重置</a-button>
              <a-button type="primary">查询</a-button>
            </a-space>
          </a-col>
        </a-row>
      </a-form>
    </div>
    <div class="panel-box">
      <!-- 表格全局控制 -->
      <a-space>
        <a-button type="link" @click="() => (selectedId = null)"> 新增 </a-button>
      </a-space>
      <!-- 表格视图 -->
      <a-table
        class="table-type-of-compact"
        row-key="id"
        :data-source="list"
        :pagination="pagination"
        :columns="columns"
        :scroll="{ x: 'max-content' }"
        :loading="fetching"
        @change="onTableChange"
      >
        <template #headerCell="{ column }">
          {{ column.title }}
        </template>
        <template #bodyCell="{ column, record }">
          <template v-if="column.key === '$action'">
            <a-button type="link" @click="fetchById((selectedId = record.id))"> 编辑 </a-button>
            <a-popconfirm title="是否确认删除?" @confirm="remove(record.id)">
              <a-button type="link">删除</a-button>
            </a-popconfirm>
          </template>
        </template>
      </a-table>
    </div>
    <!-- 模态表单,用于新增和编辑 -->
    <a-modal
      title="编辑用户"
      :visible="selectedId !== void 0"
      :ok-button-props="{ loading: fetching }"
      @cancel="onModalCancel"
      @ok="onModalOk"
    >
      <a-form ref="modalForm" :model="modalRecord">
        <a-form-item label="姓名" name="name" :rules="[{ required: true, message: '请输入用户姓名' }]">
          <a-input v-model:value="modalRecord.name" placeholder="请输入用户姓名" />
        </a-form-item>
        <a-form-item label="年龄" name="age" :rules="[{ required: true, message: '输入用户年龄' }]">
          <a-input-number v-model:value="modalRecord.age" placeholder="请输入用户年龄" />
        </a-form-item>
      </a-form>
    </a-modal>
  </div>
</template>

<script>
import api from '@api'

// 列配置信息
const columns = [
  { key: 'code', dataIndex: 'code', name: 'code', title: '编号', fixed: true },
  { key: 'name', dataIndex: 'name', name: 'name', title: '姓名' },
  { key: 'age', dataIndex: 'age', name: 'age', title: '年龄' },
  { key: 'sex', dataIndex: 'sex', name: 'sex', title: '性别' },
  { key: 'birthday', dataIndex: 'birthday', name: 'birthday', title: '生日' },
  { key: 'phone', dataIndex: 'phone', name: 'phone', title: '电话' },
  { key: 'mobile', dataIndex: 'mobile', name: 'mobile', title: '座机' },
  { key: 'region', dataIndex: 'region', name: 'region', title: '区域' },
  { key: 'county', dataIndex: 'county', name: 'county', title: '国家' },
  { key: 'email', dataIndex: 'email', name: 'email', title: '邮箱' },
  { key: 'creator', dataIndex: 'creator', name: 'creator', title: '创建者' },
  { key: 'createDate', dataIndex: 'createDate', name: 'createDate', title: '创建时间' },
  { key: '$action', dataIndex: '$action', name: '$action', title: '操作', fixed: 'right' }
]
export default defineComponent({
  setup() {
    // 查询表单列宽
    const colSpan = 6
    // 表格数据源
    const list = ref([])
    // 分页数据
    const pagination = ref({ current: 1, pageSize: 10, total: 0 })
    // 查询表单实体
    const searchRecord = ref({})
    // 模态表单实体,用于新增或编辑
    const modalRecord = ref({})
    const modalForm = ref()
    // 已选择实体id,其中`undefined`代表未选择,`null`代表新增,其他存在的值代表编辑
    const selectedId = ref()
    // 是否正在发送请求,如果请求,将可交互UI进行锁定,交互完成后解锁
    const fetching = ref(false)

    // 获取表格分页数据
    function fetch() {
      fetching.value = true
      const page = pagination.value.current
      const limit = pagination.value.pageSize
      api.getUserPage({ page, limit, ...searchRecord.value }).then(([res, err]) => {
        if (!err) {
          list.value = res.list
          pagination.value.total = res.total
        }
        fetching.value = false
      })
    }
    // 获取单个实体数据
    function fetchById(id) {
      fetching.value = true
      api.getUserById(id).then(([data, err]) => {
        if (!err) {
          modalRecord.value = data
        }
        fetching.value = false
      })
    }
    // 新增或更新实体
    function addOrUpdate(id, record) {
      fetching.value = true
      const exec = id ? api.updateUser : api.addUser
      exec(record).then(([, err]) => {
        if (!err) {
          console.log('更新成功')
          selectedId.value = void 0
          fetch()
        }
        fetching.value = false
      })
    }
    // 删除实体
    function remove(id = null) {
      return new Promise((resolve) => {
        fetching.value = true
        api.delUser(id).then(([, err]) => {
          if (!err) {
            console.log('删除成功')
            resolve(!fetch())
          } else {
            resolve(false)
          }
          fetching.value = false
        })
      })
    }

    // 重置查询
    function reset() {
      pagination.value.current = 1
      searchRecord.value = {}
      fetch()
    }
    // 表格变化监听
    function onTableChange(page) {
      pagination.value.current = page.current
      pagination.value.pageSize = page.pageSize
    }

    // 模态框取消
    function onModalCancel() {
      selectedId.value = void 0
      modalForm.value.clearValidate()
      modalRecord.value = {}
    }
    // 模态框确认
    function onModalOk() {
      modalForm.value.validate().then((err) => {
        return err && addOrUpdate(selectedId.value, modalRecord.value)
      })
    }
    fetch()

    return {
      // 常量
      colSpan,
      columns,
      //响应式变量
      list,
      pagination,
      searchRecord,
      modalRecord,
      modalForm,
      selectedId,
      fetching,
      // api函数
      fetch,
      fetchById,
      remove,
      addOrUpdate,
      // 交互函数
      reset,
      onTableChange,
      onModalCancel,
      onModalOk
    }
  }
})
</script>

美化整体结构

现在的样子貌似差不多了,但是作为一个应用,好像还缺少一个好看的头部,我们打开项目目录下的配置文件demo-temp/config.js进行修改:

// %vsee-demo%/projects/demo-temp/config.js
export default {
  auth: {
    login: ['/auth/oauth/token'],
    logout: ['/auth/logout'],
    user: ['/sys/user/info', {}, {}],
    menu: ['/sys/menu/nav', {}, []]
  },
  // 主题配置
  theme: {
    header: 'Header'  // 设置头部组件,会自动到`demo-temp/_layout`目录里找
  }
}

现在,我们还看不到效果,需要在项目下新建该文件demo-temp/_layout/Header.vue注意这里组件的命名必须和配置文件里的一致):

<!-- %vsee-demo%/projects/demo-temp/_layout/Header.vue -->
<template>
  <div class="header-box">
    <html5-outlined class="header-logo" />
    <h3 class="header-title">Demo-Temp</h3>
    <div class="header-right">
      <a-dropdown>
        <a-space>
          <a-avatar size="small"></a-avatar>
          <span>Your Name</span>
        </a-space>
        <template #overlay>
          <a-menu>
            <a-menu-item key="to-profile">
              <a href="/" target="_blank"> 回到首页 </a>
            </a-menu-item>
            <a-menu-item key="change-password">
              <a> 修改密码 </a>
            </a-menu-item>
            <a-menu-item key="logout">
              <a> 退出登陆 </a>
            </a-menu-item>
          </a-menu>
        </template>
      </a-dropdown>
    </div>
  </div>
</template>

<script>
import { Html5Outlined } from '@ant-design/icons-vue'

export default defineComponent({
  components: {
    Html5Outlined
  },
  setup() {
    return {}
  }
})
</script>

<style lang="less" scoped>
.header-box {
  width: 100%;
  padding: 0 16px;
  display: flex;
  align-items: center;
  flex-wrap: nowrap;
  flex-direction: row;
  .header-logo {
    font-size: 32px;
    color: #167ffe;
  }
  .header-title {
    font-size: 28px;
    margin: 0;
    padding: 0;
    color: rgba(0, 0, 0, 0.85);
  }
  .header-right {
    margin-left: auto;
    display: flex;
    align-items: center;
  }
}
</style>

现在,我们的应用已经初呈好看的样式,如果你精通CSS,你可以将这些页面更改的更加精美。

视图国际化

现在,我们页面的视图上的文字都是写死的,如果需要做国际化,则需要使用内置的$t或者t函数,例如:

<!-- %vsee-demo%/projects/demo-temp/user/router/index.vue 部分代码 -->
<a-space>
  <a-button>{{ $t('$action.reset') }}</a-button>
  <a-button type="primary">{{ $t('$action.reset') }}</a-button>
</a-space>

但是,我们的表格列头如何做国际化呢?我们可以进行如下改造:

<!-- %vsee-demo%/projects/demo-temp/user/router/index.vue 部分代码 -->
<template>
    <!-- 其他代码 -->
    <a-table
    class="table-type-of-compact"
    row-key="id"
    :data-source="list"
    :pagination="pagination"
    :columns="columns"
    :scroll="{ x: 'max-content' }"
    :loading="fetching"
    @change="onTableChange"
  >
    <template #headerCell="{ column }">
      {{ $t(column.i18nPath) }}
    </template>
    <template #bodyCell="{ column, record }">
      <template v-if="column.key === '$action'">
        <a-button type="link" @click="fetchById((selectedId = record.id))"> {{ $t('$action.edit') }} </a-button>
        <a-popconfirm title="是否确认删除?" @confirm="remove(record.id)">
          <a-button type="link">{{ $t('$action.remove') }}</a-button>
        </a-popconfirm>
      </template>
    </template>
  </a-table>
    <!-- 其他代码 -->
</template>
<script>
import { tableDefiner, tableColumnDefiner } from '@util/model-helper'
  
const columns = tableDefiner(
  [
    tableColumnDefiner('code').mix({ fixed: true }),
    'name',
    'age',
    'sex',
    'birthday',
    'phone',
    'mobile',
    'region',
    'county',
    'email',
    'creator',
    'createDate',
    '$action'
  ],
  'user'
)
</script>

如你所见,修改后,表格的头部都变成了user.xxx的格式,此时$t函数并没有读取到国际化定义,因此,我们需要先定义国际化信息。我们在user模块下新建locales文件夹,然后创建en-US.jszh-CN.js文件:

// %vsee-demo%/projects/demo-temp/user/locales/zh-CN.js
export default {
  user: {
    code: '编号',
    name: '姓名',
    age: '年龄',
    sex: '性别',
    birthday: '生日',
    phone: '电话',
    mobile: '座机',
    region: '区域',
    county: '国家',
    email: '邮箱',
    creator: '创建者',
    createDate: '创建者'
  }
}
// %vsee-demo%/projects/demo-temp/user/locales/en-US.js
export default {
  user: {
    code: 'Code',
    name: 'Name',
    age: 'Age',
    sex: 'Sex',
    birthday: 'Birthday',
    phone: 'Phone',
    mobile: 'Mobile',
    region: 'Region',
    county: 'County',
    email: 'E-Mail',
    creator: 'Creator',
    createDate: 'Create Date'
  }
}

现在你可以通过右边的开发者按钮来切换国际化,看看实际的效果。至于更多的国际化,就由心细的你在页面中进行充分的完善。有关国际化的更过信息,请查看这里

其他插件(一)

前面的例子中,我们都尽量使用了比较传统的vue组件和ant-design-vue组件的写法。诸如分页器表单实体验证器等,除了定义他们自身的ref变量,还要进行额外的数据监听和数据转换。为了解决他们在代码上比较繁重的问题,我们对这个页面再进一步改造为如下代码:

<!-- %vsee-demo%/projects/demo-temp/user/router/index.vue -->
<template>
  <div class="content-box">
    <div class="panel-box">
      <!-- 查询表单 -->
      <a-form class="form-type-of-compact form-type-of-action-right">
        <a-row :gutter="16">
          <a-col v-bind="$bp.inline">
            <a-form-item label="姓名">
              <a-input v-model:value="searchRecord.name" />
            </a-form-item>
          </a-col>
          <a-col v-bind="$bp.inline">
            <a-form-item label="性别">
              <a-select
                v-model:value="searchRecord.sex"
                :options="[
                  { value: 0, label: '男' },
                  { value: 1, label: '女' }
                ]"
              />
            </a-form-item>
          </a-col>
          <a-col class="form-column-action" v-bind="$bp.inline">
            <a-space>
              <a-button @click="reset">重置</a-button>
              <a-button type="primary" @click="fetch">查询</a-button>
            </a-space>
          </a-col>
        </a-row>
      </a-form>
    </div>
    <div class="panel-box">
      <!-- 表格全局控制 -->
      <a-space>
        <a-button type="link" @click="() => (selectedId = null)"> 新增 </a-button>
      </a-space>
      <!-- 表格视图 -->
      <a-table
        class="table-type-of-compact"
        row-key="id"
        :data-source="pagination.list"
        :pagination="pagination"
        :columns="columns"
        :scroll="{ x: 'max-content' }"
        :loading="fetching"
      >
        <template #headerCell="{ column }">
          {{ $t(column.i18nPath) }}
        </template>
        <template #bodyCell="{ column, record }">
          <template v-if="column.key === '$action'">
            <a-button type="link" @click="fetchById((selectedId = record.id))"> {{ $t('$action.edit') }} </a-button>
            <a-popconfirm title="是否确认删除?" @confirm="remove(record.id)">
              <a-button type="link">{{ $t('$action.remove') }}</a-button>
            </a-popconfirm>
          </template>
        </template>
      </a-table>
    </div>
    <!-- 模态表单,用于新增和编辑 -->
    <a-modal
      title="编辑用户"
      :visible="selectedId !== void 0"
      :ok-button-props="{ loading: fetching }"
      @cancel="onModalCancel"
      @ok="onModalOk"
    >
      <a-form ref="modalForm">
        <a-form-item label="姓名" v-bind="validateInfos.name">
          <a-input v-model:value="modalRecord.name" placeholder="请输入用户姓名" />
        </a-form-item>
        <a-form-item label="年龄" v-bind="validateInfos.name">
          <a-input-number v-model:value="modalRecord.age" placeholder="请输入用户年龄" />
        </a-form-item>
      </a-form>
    </a-modal>
  </div>
</template>

<script>
import { usePage, useRecord } from '@use'
import api from '@api'
import { tableDefiner, tableColumnDefiner } from '@util/model-helper'
import $v from '$v'
import $message from '$message'

// 列配置信息
const columns = tableDefiner(
  [
    tableColumnDefiner('code').mix({ fixed: true }),
    'name',
    'age',
    'sex',
    'birthday',
    'phone',
    'mobile',
    'region',
    'county',
    'email',
    'creator',
    'createDate',
    '$action'
  ],
  'user' // 指定国际化路径
)

const rules = {
  name: [$v.required],
  age: [$v.required, $v.range([20, 80])]
}

export default defineComponent({
  setup() {
    // 分页数据
    const pagination = usePage()
    // 查询表单实体
    const searchRecord = useRecord()
    // 模态表单实体,用于新增或编辑
    const modalRecord = useRecord({ rules })
    const { validateInfos } = modalRecord
    // 已选择实体id,其中`undefined`代表未选择,`null`代表新增,其他存在的值代表编辑
    const selectedId = ref()
    // 是否正在发送请求,如果请求,将可交互UI进行锁定,交互完成后解锁
    const fetching = ref(false)

    // 获取表格分页数据
    function fetch() {
      fetching.value = true
      api.getUserPage({ ...pagination.serverPagination, ...searchRecord.valueOf() }).then(([res, err]) => {
        if (!err) {
          pagination.load(res)
        }
        fetching.value = false
      })
    }
    // 获取单个实体数据
    function fetchById(id) {
      fetching.value = true
      api.getUserById(id).then(([data, err]) => {
        if (!err) {
          modalRecord.load(data)
        }
        fetching.value = false
      })
    }
    // 新增或更新实体
    function addOrUpdate(id, record) {
      fetching.value = true
      const exec = id ? api.updateUser : api.addUser
      exec(record).then(([, err]) => {
        if (!err) {
          $message('更新成功')
          selectedId.value = void 0
          fetch()
        }
        fetching.value = false
      })
    }
    // 删除实体
    function remove(id = null) {
      return new Promise((resolve) => {
        api.delUser(id).then(([, err]) => {
          if (!err) {
            $message('删除成功')
            resolve(!fetch())
          } else {
            resolve(false)
          }
        })
      })
    }

    function reset() {
      pagination.reset()
      searchRecord.clear()
      fetch()
    }

    // 模态框取消
    function onModalCancel() {
      selectedId.value = void 0
      modalRecord.clear()
    }
    // 模态框确认
    function onModalOk() {
      modalRecord.validate().then(() => {
        return addOrUpdate(selectedId.value, modalRecord.valueOf())
      })
    }

    pagination.onChange(fetch)

    return {
      // 常量
      columns,
      //响应式变量
      pagination,
      searchRecord,
      modalRecord,
      validateInfos,
      selectedId,
      fetching,
      // api函数
      fetch,
      fetchById,
      remove,
      addOrUpdate,
      // 交互函数
      reset,
      onModalCancel,
      onModalOk
    }
  }
})
</script>

这里视图中有用到$bp变量,你需要在bootstrap.js文件中进行一下注册:

// %vsee-demo%/projects/demo-temp/bootstrap.js
import { uses } from '@app'
import $bp from '$bp'

uses($bp)

今后,具备这样功能的小组件还有很多,敬请期待。

本地数据库

以上,我们构建了一个非常基本的用户CRUD的页面,所使用到的模拟数据,在刷新之后,就会立刻重新刷新。如果你希望本地模拟的数据可以缓存下来,可以使用@db模块,该模块除了可以进行持久化存储,还可以像MongoDB一样进行一些简单数据库操作,详情可以查看这里

我们将mock/index.js稍加改造,变成如下形式:

// %vsee-demo%/projects/demo-temp/user/mock/index.js
import mock from '@mock'
import api from '@api'
import db from '@db'

// 用户数据模型
const userSchema = {
  code: 'SN@natural(10000000, 999999999)',
  name: '@cname',
  age: '@integer(18, 60)',
  sex: '@integer(0, 1)',
  birthday: '@datetime("T")',
  phone: '1@pick([31, 34, 37, 38, 39, 51, 58, 59, 68, 77, 79, 83, 89])@natural(1000000, 99999999)',
  mobile: '0@natural(10, 999) @natural(1000, 9999)-@natural(1000, 9999)',
  region: '@region',
  county: '@county(true)',
  email: '@email',
  creator: '@pick(["管理员专员", "超级管理员", "系统管理员", "平台管理员"])',
  createDate: '@datetime("T")'
}

const userList = db.link('user', mock({ id: '@increment', ...userSchema }, mock('@integer(20, 80)')))

// 模拟api接口,拦截{page,limit}参数,做分页查询
mock.fetch(
  api.getUserPage,
  ({ page, limit }) => {
    return {
      code: 0,
      msg: '',
      data: {
        list: userList.findByPage({ page, limit }),
        total: userList.count()
      }
    }
  },
  { timeout: mock('@int(400, 800)') }
)

mock.fetch(
  api.getUserById,
  (id) => {
    return {
      code: 0,
      msg: '',
      data: userList.findById(id)
    }
  },
  { timeout: mock('@int(400, 800)') }
)

mock.fetch(
  api.addUser,
  (user) => {
    console.log(user)
    return {
      code: 0,
      msg: '',
      data: userList.create(user)
    }
  },
  { timeout: mock('@int(400, 800)') }
)

mock.fetch(
  api.updateUser,
  (user) => {
    return {
      code: 0,
      msg: '',
      data: userList.modify(user)
    }
  },
  { timeout: mock('@int(400, 800)') }
)

mock.fetch(
  api.delUser,
  (id) => {
    const ids = id instanceof Array ? id : [id]
    userList.delete(ids)
    return {
      code: 0,
      msg: '',
      data: null
    }
  },
  { timeout: mock('@int(400, 800)') }
)

mock.fetch(
  api.delUsers,
  ({ ids = [] } = {}) => {
    userList.delete(ids)
    return {
      code: 0,
      msg: '',
      data: null
    }
  },
  { timeout: mock('@int(400, 800)') }
)

此时,无论你如何刷新,页面上的数据都会保留的。在做演示模式的时候非常方便。

其他插件(二)

现在,我们给这个页面增加批量删除和排序功能:

<!-- %vsee-demo%/projects/demo-temp/user/router/index.vue -->
<template>
  <div class="content-box">
    <div class="panel-box">
      <!-- 查询表单 -->
      <a-form class="form-type-of-compact form-type-of-action-right">
        <a-row :gutter="16">
          <a-col v-bind="$bp.inline">
            <a-form-item label="姓名">
              <a-input v-model:value="searchRecord.name" />
            </a-form-item>
          </a-col>
          <a-col v-bind="$bp.inline">
            <a-form-item label="性别">
              <a-select
                v-model:value="searchRecord.sex"
                :options="[
                  { value: 0, label: '男' },
                  { value: 1, label: '女' }
                ]"
              />
            </a-form-item>
          </a-col>
          <a-col class="form-column-action" v-bind="$bp.inline">
            <a-space>
              <a-button @click="reset">重置</a-button>
              <a-button type="primary" @click="fetch">查询</a-button>
            </a-space>
          </a-col>
        </a-row>
      </a-form>
    </div>
    <div class="panel-box">
      <!-- 表格全局控制 -->
      <a-space>
        <a-button type="link" @click="() => (selectedId = null)"> 新增 </a-button>
        <a-popconfirm title="是否确认删除?" @confirm="removeList(rowSelection.selectedRowKeys)">
          <a-button type="link" :disabled="!rowSelection.selectedRowKeys.length">
            {{ $t('$action.remove') }}
          </a-button>
        </a-popconfirm>
      </a-space>
      <!-- 表格视图 -->
      <a-table
        class="table-type-of-compact"
        row-key="id"
        :data-source="pagination.list"
        :pagination="pagination"
        :row-selection="rowSelection"
        :columns="columns.map(sortable)"
        :scroll="{ x: 'max-content' }"
        :loading="fetching"
        @change="onTableChange"
      >
        <template #headerCell="{ column }">
          {{ $t(column.i18nPath) }}
        </template>
        <template #bodyCell="{ column, record }">
                    <template v-if="column.key === 'birthday'">
            {{ $f.getDate(value) }}
          </template>
          <template v-if="column.key === '$action'">
            <a-button type="link" @click="fetchById((selectedId = record.id))"> {{ $t('$action.edit') }} </a-button>
            <a-popconfirm title="是否确认删除?" @confirm="remove(record.id)">
              <a-button type="link">{{ $t('$action.remove') }}</a-button>
            </a-popconfirm>
          </template>
        </template>
      </a-table>
    </div>
    <!-- 模态表单,用于新增和编辑 -->
    <a-modal
      title="编辑用户"
      :visible="selectedId !== void 0"
      :ok-button-props="{ loading: fetching }"
      @cancel="onModalCancel"
      @ok="onModalOk"
    >
      <a-form ref="modalForm">
        <a-form-item label="姓名" v-bind="validateInfos.name">
          <a-input v-model:value="modalRecord.name" placeholder="请输入用户姓名" />
        </a-form-item>
        <a-form-item label="年龄" v-bind="validateInfos.name">
          <a-input-number v-model:value="modalRecord.age" placeholder="请输入用户年龄" />
        </a-form-item>
      </a-form>
    </a-modal>
  </div>
</template>

<script>
import { usePage, useRecord, useRowSelection, useSort } from '@use'
import api from '@api'
import { tableDefiner, tableColumnDefiner } from '@util/model-helper'
import $v from '$v'
import $message from '$message'
// 列配置信息
const columns = tableDefiner(
  [
    tableColumnDefiner('code').mix({ fixed: true }),
    'name',
    'age',
    'sex',
    'birthday',
    'phone',
    'mobile',
    'region',
    'county',
    'email',
    'creator',
    'createDate',
    '$action'
  ],
  'user' // 指定国际化路径
)

const rules = {
  name: [$v.required],
  age: [$v.required, $v.range([20, 80])]
}

export default defineComponent({
  setup() {
    // 分页数据
    const pagination = usePage()
    // 行选择器
    const rowSelection = useRowSelection()
    // 查询表单实体
    const searchRecord = useRecord()
    // 模态表单实体,用于新增或编辑
    const modalRecord = useRecord({ rules })
    const { validateInfos } = modalRecord
    const sortable = useSort({ sortField: ['name', 'age'] })
    // 已选择实体id,其中`undefined`代表未选择,`null`代表新增,其他存在的值代表编辑
    const selectedId = ref()
    // 是否正在发送请求,如果请求,将可交互UI进行锁定,交互完成后解锁
    const fetching = ref(false)

    // 获取表格分页数据
    function fetch() {
      fetching.value = true
      api
        .getUserPage({ ...pagination.serverPagination, ...sortable.serverSorter, ...searchRecord.valueOf() })
        .then(([res, err]) => {
          if (!err) {
            pagination.load(res)
          }
          fetching.value = false
        })
    }
    // 获取单个实体数据
    function fetchById(id) {
      fetching.value = true
      api.getUserById(id).then(([data, err]) => {
        if (!err) {
          modalRecord.load(data)
        }
        fetching.value = false
      })
    }
    // 新增或更新实体
    function addOrUpdate(id, record) {
      fetching.value = true
      const exec = id ? api.updateUser : api.addUser
      exec(record).then(([, err]) => {
        if (!err) {
          $message('更新成功')
          selectedId.value = void 0
          fetch()
        }
        fetching.value = false
      })
    }
    // 删除实体
    function remove(id = null) {
      return new Promise((resolve) => {
        api.delUser(id).then(([, err]) => {
          if (!err) {
            $message('删除成功')
            resolve(!fetch())
          } else {
            resolve(false)
          }
        })
      })
    }
    // 批量删除
    function removeList(ids) {
      return new Promise((resolve) => {
        api.delUsers({ ids }).then(([, err]) => {
          if (!err) {
            $message('删除成功')
            rowSelection.reset()
            resolve(!fetch())
          } else {
            resolve(false)
          }
        })
      })
    }

    function reset() {
      pagination.reset()
      searchRecord.clear()
      sortable.reset()
      fetch()
    }

    //
    function onTableChange(_page, _filter, sorter) {
      sortable.setSorter(sorter)
      fetch()
    }
    // 模态框取消
    function onModalCancel() {
      selectedId.value = void 0
      modalRecord.clear()
    }
    // 模态框确认
    function onModalOk() {
      modalRecord.validate().then(() => {
        return addOrUpdate(selectedId.value, modalRecord.valueOf())
      })
    }

    pagination.onChange(fetch)

    return {
      // 常量
      columns,
      //响应式变量
      pagination,
      rowSelection,
      searchRecord,
      modalRecord,
      sortable,
      validateInfos,
      selectedId,
      fetching,
      // api函数
      fetch,
      fetchById,
      remove,
      removeList,
      addOrUpdate,
      // 交互函数
      reset,
      onTableChange,
      onModalCancel,
      onModalOk
    }
  }
})
</script>

注意,在设置行选择器rowSelection的时候,一定要给table绑定row-key属性,否则将没有效果。有关行选择器的文档请查看这里排序器的文档请查看这里。视图中所用到的格式化器需要在bootstrap.js文件中注册:

// %vsee-demo%/projects/demo-temp/bootstrap.js
import { uses } from '@app'
import $bp from '$bp'
import $f from '$f'

uses($bp, $f)

高级路由

上面的页面我们采用的是最基本的路由——进阶路由,也是一个路由占用一个菜单。如果我们希望在一个菜单下有几个子菜单,那么可以在我们模拟的菜单数据中追加如下数据:

// %vsee-demo%/projects/demo-temp/_/mock/index.js
import mock from '@mock'

mock.get('/sys/menu/nav', {
  code: 0,
  msg: '',
  data: [
    {
      id: 100000,
      name: '用户管理',
      url: 'user',
      pid: null
    },
    {
      id: 200000,
      name: '系统管理',
      url: 'sys',
      pid: null,
      children: [
        {
          id: 20000001,
          name: '角色管理',
          url: 'role',
          pid: 200000
        },
        {
          id: 20000002,
          name: '菜单管理',
          url: 'menu',
          pid: 200000
        }
      ]
    }
  ]
})

同样,我们要在项目demo-temp下新建如下目录结构(自行编写role.vuemenu.vue里的内容):

# %vsee-demo%/projects/demo-temp 目录结构
- demo-temp
    - sys                            # 对应系统管理
        - router
            - role.vue        # 对应角色管理
            - menu.vue        # 对应菜单管理

有关路由的更多信息,请查看这里

登陆授权

一般我们的页面是需要授权才能登陆进来,而不是直接刷新就能看到这个管理界面。此时,我们可以在demo-temp/config.js里配置:

// %vsee-demo%/projects/demo-temp/config.js
import authPlugin from '#/auth/simpleToken.js'

export default {
  auth: {
    login: ['/auth/oauth/token'],
    logout: ['/auth/logout'],
    user: ['/sys/user/info', {}, {}],
    menu: ['/sys/menu/nav', {}, []],
    plugin: authPlugin
  },
  theme: {
    header: 'Header',
    login: 'Login'        // 配置登陆页面,会在`demo-temp/_layout`目录下找
  }
}

然后在我们的视图目录里添加Login.vue文件(注意这里组件的命名必须和配置文件里的一致):

# %vsee-demo%/projects/demo-temp 目录结构
- demo-temp
    - _layout
        - Header.vue    # 头部组件
        - Login.vue        # 登陆组件

并实现登陆页面如下:

<!-- %vsee-demo%/projects/demo-temp/_layout/Login.vue -->
<template>
  <div class="login-wrapper">
    <div class="login-header">
      <LanguageDropdown />
    </div>
    <div class="login-content">
      <a-form layout="vertical" @keypress.enter="submit">
        <a-form-item v-show="false" label="UUID" v-bind="validateInfos.uuid">
          <a-input type="hidden" :value="record.uuid" />
        </a-form-item>
        <a-form-item v-bind="validateInfos.username">
          <a-input v-model:value="record.username" size="large" placeholder="账户:admin" allow-clear>
            <template #prefix>
              <UserOutlined />
            </template>
          </a-input>
        </a-form-item>
        <a-form-item v-bind="validateInfos.password">
          <a-input-password v-model:value="record.password" size="large" placeholder="密码:admin" allow-clear>
            <template #prefix>
              <LockOutlined />
            </template>
          </a-input-password>
        </a-form-item>
        <a-form-item class="login-not-input-item">
          <a-checkbox v-model:checked="remeber"> 自动登录 </a-checkbox>
          <a-button type="link" class="login-link-float-right">忘记密码</a-button>
        </a-form-item>
        <a-form-item>
          <a-button size="large" type="primary" block :loading="fetching" @click="submit">提交</a-button>
        </a-form-item>
      </a-form>
    </div>
  </div>
</template>

<script>
import { pick } from 'ramda'
import { UserOutlined, LockOutlined } from '@ant-design/icons-vue'
import { useStore } from '@use'
import { useRecord } from '@use'
import $v from '$v'
import { getUUID } from '@util'
import $message from '$message'

const rules = {
  username: [$v.required],
  password: [$v.required]
}

export default {
  components: {
    UserOutlined,
    LockOutlined,
    LanguageDropdown: defineAsyncComponent(() => import('#comp/controller/lang/LanguageDropdown'))
  },
  setup() {
    const { record, validateInfos, validate, set } = useRecord({
      model: {
        uuid: getUUID(),
        ...(process.env.NODE_ENV === 'development' ? { username: 'admin', password: 'admin' } : {})
      },
      rules
    })
    const state = reactive({
      fetching: false,
      remeber: false
    })
    const store = useStore({
      getters: ['getLoginCache'],
      mutations: ['cacheLoginInfo', 'cleanLoginInfo'],
      actions: ['login']
    })
    const loginCache = store.getLoginCache()
    if (loginCache) {
      state.member = true
      set(loginCache)
    }
    const route = useRoute()
    return {
      ...toRefs(state),
      record,
      validateInfos,
      submit() {
        validate().then((errors) => {
          if (!errors) {
            state.fetching = true
            store.login(record.valueOf()).then(([, err]) => {
              if (!err) {
                if (state.remeber) {
                  store.cacheLoginInfo(pick(['username', 'password'], record))
                } else {
                  store.cleanLoginInfo()
                }
                const goPage = route.query.redirect
                window.location.href = process.env.NODE_ENV === 'production' ? `/vsee-pro${goPage}` : goPage
              } else {
                $message.error(err.msg)
                state.fetching = false
              }
            })
          }
        })
      }
    }
  }
}
</script>

<style lang="less" scoped>
.login-wrapper {
  width: 100%;
  max-height: 800px;
  overflow-y: scroll;
  a {
    text-decoration: none;
  }
  // 居右按钮
  .login-link-float-right {
    float: right;
    clear: left;
    margin-right: -15px;
  }
  .login-not-input-item {
    line-height: 32px;
    margin-top: -8px;
  }
  .login-header {
    width: 100%;
    display: flex;
    justify-content: flex-end;
    align-items: center;
  }
  .login-content {
    height: calc(100% - 32px);
    display: flex;
    flex-direction: column;
    justify-content: center;
    .login-subtitle {
      font-size: 14px;
      color: fade(black, 45%);
      text-align: center;
    }
    .ant-form {
      width: 360px;
      margin: 0 auto;
    }
  }
}
</style>

这里用到了@use模块里的useStore(并非vuexuseStore,原生的useStore不在自动导入里,在项目里禁止使用),相关使用方法请参考这里

再次刷新页面时,你将不会停留在原来的管理页面了,而是到了登陆页面,而且无论怎么刷新都不会进入到管理页面。此时,我们去给模拟数据模块增加一个登陆的逻辑:

// %vsee-demo%/projects/demo-temp/_/mock/index.js
mock.post(
  '/auth/oauth/token',
  (user) => {
    if (user.username === 'admin' && user.password === 'admin') {
      return {
        data: 'test_auth_token',
        code: 0
      }
    } else {
      return {
        code: 10001,
        msg: '用户名或密码错误'
      }
    }
  },
  { timeout: 2000 }
)

这里做了一个模拟数据的判断,如果用户名密码都是admin则登陆成功,否则登陆失败。我们可以先用登陆失败的方式来测试一下效果。然后再用登陆成功的方式进入系统。最后,我们加上一个退出逻辑:

<!-- %vsee-demo%/projects/demo-temp/_layout/Header.vue 部分代码 -->
<!-- 其他代码 -->
<templat>
  <!-- 其他代码 -->
    <a-menu-item key="logout">
      <router-link to="/logout">退出登陆</router-link>
  </a-menu-item>
  <!-- 其他代码 -->
</templat>

这里提供的退出逻辑是最简单的例子,如果使用基于后端服务的退出,可以在点击菜单项之后编写如下代码:

<!-- %vsee-demo%/projects/demo-temp/_layout/Header.vue 部分代码 -->
<script>
export default defineComponent({
  setup() {
    const store = useStore({
      actions: ['logout']
    })
    store.logout()
  }
})
</script>

后续

自此,使用vsee开发基本的网页已经完成了。剩下的,就是:

  • 查看文档来获取更多帮助
  • 使用组件来丰富你的页面

loong
234 声望35 粉丝

看到问题不代表解决问题,系统化才能挖掘问题的本真!