使用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.js
和api/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>
此时的页面存在两个问题:
- 没有数据支撑
- 视图没有美化
要解决第一个问题,我们就要使用@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.js
和zh-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.vue
和menu.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
(并非vuex
的useStore
,原生的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
开发基本的网页已经完成了。剩下的,就是:
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。