1. 简介
前面介绍了基本的内容比如如何获取动态数据,下面就到登录进来后的页面实现了,相信各位读者或多或少都有 element-ui+js 的实战经历,那么 vuestic-ui+ts 实现的页面又该如何写呢?带着疑问开启今天的学习(声明由于作者本人也不是搞前端的,所以不一定了解所有知识,这里只教大家如何快速上手以及自身遇到的问题)
2. 页面嵌入实现
在做的项目需要嵌入一个第三方页面,最常见的方法就是使用iframe(当然还有其他的实现,感兴趣的读者可以自行研究)我就以nacos的监控页面为例子,定义如下的 index.vue 页面
<template>
<iframe
id="page"
src="http://localhost:8848/nacos"
style="width: 100%; height:100%"
></iframe>
</template>
然后成功运行到网页端测试,发现页面并不能正常显示,console中报错信息如下,hash出问题又是跨域出问题,一开始就给我搞蒙了查了各种资料都不嫩解决问题,而且在浏览器中输入网址又显示正常
细看网址发现当输入前缀后会自动跳转,而iframe中引用的src无法发起跨域请求,自然就会报错。
输入 http://localhost:8848/nacos/ 会自动跳转到如下链接(注意#)
http://localhost:8848/nacos/#/configurationManagement?dataId=&group=&appName=&namespace=&pageSize=&pageNo=
在网址中,井号(#)后面的部分被称为URL的片段标识符(fragment identifier),常用于指定网页中的某个特定的位置或者元素。当访问一个带有"#"后面内容的网站时,浏览器会自动滚动到与该标识符相对应的页面区域。例如,在单页应用(SPA)中,不同的页面内容往往对应不同的标识符,用户通过改变URL中的哈希值可以无需重新加载页面而直接导航至相应的内容区域。
所以做出如下改进,此时页面已经能正常显示出来了,但是显示的大小不对,如下图所示,这么大的版面只给我显示这一点,明明设置的style高宽都是100%啊
<template>
<iframe
id="child"
src="http://localhost:8848/nacos/#/configurationManagement?dataId=&group=&appName=&namespace=&pageSize=&pageNo="
style="width: 100%; height:100%"
></iframe>
</template>
经过查阅资料发现设置是有讲究的,常见的两种区别如下
- 100vh : vh是一个相对单位,表示视口高度的1%。因此,height: 100vh意味着元素的高设置为视口高度的100%,即元素将占据整个视口的高度。无论该元素的父级元素有多大,或者该元素是否包含内容,使用height: 100vh都会使元素高度与视口高度一致,即使没有内容时也会撑开至屏幕高度。
- 100%:百分比值%是相对于父元素的尺寸来计算的。当使用height: 100%时,元素的高将设置为直接父元素高度的100%,这意味着元素将尽可能地占据父元素的高度。如果父元素的大小没有明确定义或者小于视口大小,那么该元素的实际高度可能会小于视口高度。
在我们这个例子中因为不能确定父级元素的大小,所以推荐使用vh而不是% 为度量单位,修改过后就能正常大小显示啦
3. 页面编写展示动态数据
直接仿照他给好的页面users实例,我们可以自己实现一个类似的页面来动态获取数据,整体代码架构如下图(UserAvatar未作大修改),可以看到其实是跟给定案例的架构差不多的,右边的是之前讲到过的动态与后端交互的一些api方法
3.1 index页面
首先通过useUsers方法(3.2)定义好的获取UsersTable渲染所需要的一些数据,比如原始数据users,做分页的pagination , 可供模糊查询的filters , 还有每列上排序的组件sorting ,以及对数据增删改的api(这里api的命名自定义,在当前页面中唯一使用),具体的定义意义可以看代码中的注释,基本上都有提及,下面就重点讲讲自定义组件的实现
对于代码
const userToEdit = ref<UserData | null>(null)
在 TypeScript 中十分常见,ref 是一个用于创建响应式引用的函数。ref函数的主要应用场景是在需要对简单类型值进行响应式处理时。例如,当你有一个基本类型的变量(如数字、字符串等),并希望它的改变能够触发视图或其他部分的自动更新时,你可以使用ref。这是因为Proxy对象只能拦截对象属性的访问,而不是直接对一个变量的改变进行拦截。通过ref,你可以将简单类型的值包装在一个形式为{ value: T }的对象中,这样在修改值时就可以通过.value属性来触发响应式更新。
在这个例子中,userToEdit 是一个响应式引用,它的类型是 UserData | null。这意味着 userToEdit 可以存储一个 UserData 类型的对象或者 null。
<script setup lang="ts">
import { ref } from 'vue'
import UsersTable from './widgets/UsersTable.vue'
import EditUserForm from './widgets/EditUserForm.vue'
import { UserData } from '@/api/system/sysUser/types'
import { useUsers } from '../sysUser/composables/useUsers'
import { useModal, useToast } from 'vuestic-ui'
import { onPageRender } from '@/utils/tokenMonitor'
const doShowEditUserModal = ref(false)
let { users, isLoading , filters, sorting, pagination , ...api } = useUsers()
// 当前需要修改的数据,edit就是当前行,而add是null,如下两个function
const userToEdit = ref<UserData | null>(null)
// 动态刷新token
window.addEventListener('load', () => {
onPageRender();
});
// 展示的是edit
const showEditUserModal = (user: UserData) => {
userToEdit.value = user
doShowEditUserModal.value = true
}
// 展示的是add
const showAddUserModal = () => {
userToEdit.value = null
doShowEditUserModal.value = true
}
const { init: notify } = useToast()
// 添加/修改方法
const onUserSaved = async (user: UserData) => {
console.log(userToEdit.value)
if (userToEdit.value) {
console.log("edit")
api.update(user).then(data =>
notify({
message: `${user.name} has been update`,
color: 'success',
})
).catch(() =>
notify({
message: `${user.name} updated fail`,
color: 'dangerous',
})
)
} else {
api.add(user).then(data =>
notify({
message: `${user.name} has been add`,
color: 'success',
})
).catch(() =>
notify({
message: `${user.name} add fail`,
color: 'dangerous',
})
)
}
}
// 删除方法
const onUserDelete = async (user: UserData) => {
api.remove(user.id).then(data =>
notify({
message: `${user.name} has been delete`,
color: 'success',
})
).catch(() =>
notify({
message: `${user.name} deleted fail`,
color: 'dangerous',
})
)
}
// 点击cancel按钮后弹出的友好提示
const editFormRef = ref()
const { confirm } = useModal()
const beforeEditFormModalClose = async (hide: () => unknown) => {
if (editFormRef.value.isFormHasUnsavedChanges) {
const agreed = await confirm({
maxWidth: '380px',
message: 'Form has unsaved changes. Are you sure you want to close it?',
size: 'small',
})
if (agreed) {
hide()
}
} else {
hide()
}
}
</script>
<template>
<h1 class="page-title">Users</h1>
<VaCard>
<VaCardContent>
<div class="flex flex-col md:flex-row gap-2 mb-2 justify-between">
<div class="flex flex-col md:flex-row gap-2 justify-start">
<VaButtonToggle
v-model="filters.isActive "
color="background-element"
border-color="background-element"
:options="[
{ label: 'Active', value: 1 },
{ label: 'Inactive', value: 0 },
]"
/>
<VaInput v-model="filters.search" placeholder="Search">
<template #prependInner>
<VaIcon name="search" color="secondary" size="small" />
</template>
</VaInput>
</div>
<VaButton @click="showAddUserModal">Add User</VaButton>
</div>
<UsersTable
v-model:sorting-order="sorting.sortingOrder"
v-model:sort-by="sorting.sortBy"
:users="users"
:loading="isLoading"
:pagination="pagination"
@editUser="showEditUserModal"
@deleteUser="onUserDelete"
/>
</VaCardContent>
</VaCard>
<VaModal
v-slot="{ cancel, ok }"
v-model="doShowEditUserModal"
size="small"
mobile-fullscreen
close-button
hide-default-actions
:before-cancel="beforeEditFormModalClose"
>
<h1 class="va-h5">{{ userToEdit ? 'Edit user' : 'Add user' }}</h1>
<EditUserForm
ref="editFormRef"
:user="userToEdit"
:save-button-label="userToEdit ? 'Save' : 'Add'"
@close="cancel"
@save="
(user) => {
onUserSaved(user)
ok()
}
"
/>
</VaModal>
</template>
自定义的组件在当前的vue页面中体现在 <EditUserForm> <UsersTable>
也就是前面代码架构时提到的一些组件(对于自定义组件的实现需要跳转到定义出看更容易理解) 而在下图的实例中最上面的三个红色框(UsersTable之上的)定义在VaCardContent的第2个div中实现,具体需要注意的就是VaButtonToggle绑定的option value值应该是number类型(为了与数据库中定义的类型对应,当然读者想换成boolean也可以的,只需要全局上下文保持一致就好)
3.2 UsersTable组件定义
原有的table组件不够美观,想要自己定义的时候就可以用该模板,对于模板的实例如上图我用红色框框框出来的就是几个加入的组件,下面就分别在代码中实现
3.3 defineVaDataTableColumns 、defineProps 、 defineEmits
-
第一个columns常量,它使用defineVaDataTableColumns函数来创建一个数据表格的列配置。这个函数接收一个包含多个对象的数组,每个对象代表一个列的配置信息。注意key要与后端封装的实体类命名保持一致(sortable 表示该列是否选择排序)
-
第二个props常量,它使用defineProps函数来定义组件的属性。这个函数接收一个对象,对象中的每个键值对表示一个属性的定义。(在应用的时候使用
:key="value"
的格式实现,这里的value为引用对象),这里截了一张图供读者对应参考实现放在后面讲 -
第三个emit常量,它使用defineEmits函数来定义组件可以触发的事件。这个函数接收一个对象,对象中的每个键值对表示一个事件的定义。在这个例子中,定义了4个事件,分别是edit-user、delete-user、update:sortBy和update:sortingOrder。
3.4 VaPagination 做分页
根据其提供好的组件来实现分页功能,双向绑定的值是我们在props定义的一些pagination
<script setup lang="ts">
import { defineVaDataTableColumns, useModal } from 'vuestic-ui'
import { UserData } from '@/api/system/sysUser/types'
import UserAvatar from './UserAvatar.vue'
import { PropType, computed, toRef } from 'vue'
import { Pagination, Sorting } from '@/api/system/sysUser/types'
import { useVModel } from '@vueuse/core'
const columns = defineVaDataTableColumns([
{ label: 'Full Name', key: 'name', sortable: true },
{ label: 'Username', key: 'username', sortable: true },
{ label: 'Email', key: 'email', sortable: true },
{ label: 'Phone', key: 'phone', sortable: true },
{ label: 'Role', key: 'roleList', sortable: true },
{ label: ' ', key: 'actions', align: 'right' },
])
const props = defineProps({
users: {
type: Array as PropType<UserData[]>,
required: true,
},
loading: { type: Boolean, default: false },
pagination: { type: Object as PropType<Pagination>, required: true },
sortBy: { type: String as PropType<Sorting['sortBy']>, required: true },
sortingOrder: { type: String as PropType<Sorting['sortingOrder']>, required: true },
})
const emit = defineEmits<{
(event: 'edit-user', user: UserData): void
(event: 'delete-user', user: UserData): void
(event: 'update:sortBy', sortBy: Sorting['sortBy']): void
(event: 'update:sortingOrder', sortingOrder: Sorting['sortingOrder']): void
}>()
// 使用pros传进来的对象来渲染页面
const users = toRef(props, 'users')
const sortByVModel = useVModel(props, 'sortBy', emit)
const sortingOrderVModel = useVModel(props, 'sortingOrder', emit)
//每次引用时候使用compute计算当前的页数
const totalPages = computed(() => Math.ceil(props.pagination.total / props.pagination.perPage))
const { confirm } = useModal()
// 点击cancel取消的友好提示
const onUserDelete = async (user: UserData) => {
const agreed = await confirm({
title: 'Delete user',
message: `Are you sure you want to delete ${user.username}?`,
okText: 'Delete',
cancelText: 'Cancel',
size: 'small',
maxWidth: '380px',
})
if (agreed) {
emit('delete-user', user)
}
}
</script>
<template>
<VaDataTable
v-model:sort-by="sortByVModel"
v-model:sorting-order="sortingOrderVModel"
:columns="columns"
:items="users"
:loading="$props.loading"
>
<template #cell(name)="{ rowData }">
<div class="flex items-center gap-2 max-w-[230px] ellipsis">
<UserAvatar :user="rowData as UserData" size="small" />
{{ rowData.name }}
</div>
</template>
<template #cell(username)="{ rowData }">
<div class="max-w-[120px] ellipsis">
{{ rowData.username }}
</div>
</template>
<template #cell(email)="{ rowData }">
<div class="ellipsis max-w-[230px]">
{{ rowData.email }}
</div>
</template>
<template #cell(roleList)="{ rowData }">
<div class="ellipsis max-w-[230px]">
<template v-for="roleItem in rowData.roleList" :key="roleItem.id">
<div>
<VaBadge :text="roleItem.roleName"></VaBadge>
</div>
</template>
</div>
</template>
<template #cell(phone)="{ rowData }">
<div class="ellipsis max-w-[230px]">
{{ rowData.phone }}
</div>
</template>
<template #cell(actions)="{ rowData }">
<div class="flex gap-2 justify-end">
<VaButton
preset="primary"
size="small"
icon="mso-edit"
aria-label="Edit user"
@click="$emit('edit-user', rowData as UserData)"
/>
<VaButton
preset="primary"
size="small"
icon="mso-delete"
color="danger"
aria-label="Delete user"
@click="onUserDelete(rowData as UserData)"
/>
</div>
</template>
</VaDataTable>
<div class="flex flex-col-reverse md:flex-row gap-2 justify-between items-center py-2">
<div>
<b>{{ $props.pagination.total }} results.</b>
Results per page
<VaSelect v-model="$props.pagination.perPage" class="!w-20" :options="[10, 50, 100]" />
</div>
<div v-if="totalPages > 1" class="flex">
<VaButton
preset="secondary"
icon="va-arrow-left"
aria-label="Previous page"
:disabled="$props.pagination.page === 1"
@click="$props.pagination.page--"
/>
<VaButton
class="mr-2"
preset="secondary"
icon="va-arrow-right"
aria-label="Next page"
:disabled="$props.pagination.page === totalPages"
@click="$props.pagination.page++"
/>
<VaPagination
v-model="$props.pagination.page"
buttons-preset="secondary"
:pages="totalPages"
:visible-pages="5"
:boundary-links="false"
:direction-links="false"
/>
</div>
</div>
</template>
<style lang="scss" scoped>
.va-data-table {
::v-deep(.va-data-table__table-tr) {
border-bottom: 1px solid var(--va-background-border);
}
}
</style>
3.3 types定义
上面的代码中提到了特别多的类型比如UserData 、Pagination 、Sorting 等等,下面就给出这些类型的定义(如下代码定义在api/system/sysUser/types.ts 中)前面两个完全根据后端的实体类对应,而下面三个就是渲染所需要用到的一些类型定义
import { RoleData } from "../sysRole/types"
/* sysUser参数类型 */
export interface UserData {
id: number,
username: string,
email: string,
password: string,
phone: string,
headUrl: string,
name: string,
status: number,
roleList: RoleData[]
}
/* sysUser参数类型
export interface RoleData {
id: number,
roleName: string,
roleCode: string,
description: string
}
*/
// Simulate API calls
export type Pagination = {
page: number
perPage: number
total: number
}
export type Sorting = {
sortBy: keyof UserData | undefined
sortingOrder: 'asc' | 'desc' | null
}
export type Filters = {
isActive: number //与数据库status类型保持一致
search: string
}
3.4 EditUserForm 实现
下面就来到实现点击add 或者 edit按钮弹窗组件实现了,实现与UserTable其实差不多
注意在默认用户defaultNewUser中定义的参数与后端一一对应,如果不想定义那么多参数可以使用一个UserVo实体类,前端封装自己想要的UserVo实体(比如我就想要name和email)后端接收到该数据再new User对其进行封装,没有传递的参数就是null, 这样就解决了ts中报错类型参数不一致问题
<script setup lang="ts">
import { PropType, computed, ref, watch } from 'vue'
import { useForm } from 'vuestic-ui'
import { UserData } from '@/api/system/sysUser/types'
import RoleApi from '@/api/system/sysRole'
import { useToast } from 'vuestic-ui'
import UserAvatar from './UserAvatar.vue'
import { validators } from '@/services/utils'
// 定义传递的对象 user即是用户数据,saveButtonLabel即是判断当前的操作类型
const props = defineProps({
user: {
type: Object as PropType<UserData | null>,
default: null,
},
saveButtonLabel: {
type: String,
default: 'Save',
},
})
const { init: notify } = useToast()
// 默认用户
const defaultNewUser: UserData = {
id: -1,
username: '',
name: '',
password: '123456',
phone: '',
headUrl: '',
email: '',
status: 1,
roleList: [],
}
const newUser = ref<UserData>({ ...defaultNewUser })
// 是否有修改过表单
const isFormHasUnsavedChanges = computed(() => {
return Object.keys(newUser.value).some((key) => {
if (key === 'avatar' ) {
return false
}
return newUser.value[key as keyof UserData] !== (props.user ?? defaultNewUser)?.[key as keyof UserData]
})
})
defineExpose({
isFormHasUnsavedChanges,
})
// 监控修改当前用户的头像
watch(
() => props.user,
() => {
if (!props.user) {
return
}
newUser.value = {
...props.user,
headUrl: props.user.headUrl || '',
}
},
{ immediate: true },
)
const avatar = ref<File>()
const makeAvatarBlobUrl = (avatar: File) => {
return URL.createObjectURL(avatar)
}
watch(avatar, (newAvatar) => {
newUser.value.headUrl = newAvatar ? makeAvatarBlobUrl(newAvatar) : ''
})
const form = useForm('add-user-form')
const emit = defineEmits(['close', 'save'])
const onSave = () => {
if (form.validate()) {
emit('save', newUser.value)
}
}
</script>
<template>
<VaForm v-slot="{ isValid }" ref="add-user-form" class="flex-col justify-start items-start gap-4 inline-flex w-full">
<VaFileUpload
v-model="avatar"
type="single"
hide-file-list
class="self-stretch justify-start items-center gap-4 inline-flex"
>
<UserAvatar :user="newUser" size="large" />
<VaButton preset="primary" size="small">Add image</VaButton>
<VaButton
v-if="avatar"
preset="primary"
color="danger"
size="small"
icon="delete"
class="z-10"
@click.stop="avatar = undefined"
/>
</VaFileUpload>
<div class="self-stretch flex-col justify-start items-start gap-4 flex">
<div class="flex gap-4 flex-col sm:flex-row w-full">
<VaInput
v-model="newUser.name"
label="Name"
class="w-full sm:w-1/2"
:rules="[validators.required]"
name="name"
/>
<VaInput
v-model="newUser.username"
label="Username"
class="w-full sm:w-1/2"
:rules="[validators.required]"
name="username"
/>
</div>
<div class="flex gap-4 flex-col sm:flex-row w-full">
<VaInput
v-model="newUser.email"
label="Email"
class="w-full sm:w-1/2"
:rules="[validators.required, validators.email]"
name="email"
/>
<VaInput
v-model="newUser.phone"
label="Phone"
class="w-full sm:w-1/2"
:rules="[validators.required]"
name="phone"
/>
</div>
<div class="flex items-center w-1/2 mt-4">
<VaCheckbox v-model.number="newUser.status" label="Active" class="w-full" name="status" />
</div>
</div>
<div class="flex gap-2 flex-col-reverse items-stretch justify-end w-full sm:flex-row sm:items-center">
<VaButton preset="secondary" color="secondary" @click="$emit('close')">Cancel</VaButton>
<VaButton :disabled="!isValid" @click="onSave">{{ saveButtonLabel }}</VaButton>
</div>
</div>
</VaForm>
</template>
3.5 useUser api编写
主要实现的就是useUser这个方法,方法中又调用了getUsers 来动态获取数据库数据await UserApi.listAll()
,并根据index中传回来的props属性生成当前的Pagination Filters Sorting三个参数(每一次改变都会重新生成)
import { Ref, ref, unref, watch } from 'vue'
import { UserData , type Filters, Pagination, Sorting } from '@/api/system/sysUser/types'
import UserApi from '@/api/system/sysUser'
import { watchIgnorable } from '@vueuse/core'
const makePaginationRef = () => ref<Pagination>({ page: 1, perPage: 10, total: 0 })
const makeSortingRef = () => ref<Sorting>({ sortBy: 'name', sortingOrder: null })
const makeFiltersRef = () => ref<Partial<Filters>>({ isActive: 1, search: '' })
const getSortItem = (obj: any, sortBy: string) => {
return obj[sortBy]
}
export const getUsers = async (filters: Partial<Filters & Pagination & Sorting>) => {
try {
let users: UserData[] = []
// Fetch the user data using UserApi.listAll()
const data = await UserApi.listAll()
console.log("-1>>>>",data)
users = data
let filteredUsers = users
console.log("0>>>>",filteredUsers)
const { isActive, search, sortBy, sortingOrder } = filters
// Apply filter based on isActive
filteredUsers = filteredUsers.filter((user) => user.status === isActive)
console.log("1>>>>",filteredUsers)
// Apply filter based on search
if (search) {
filteredUsers = filteredUsers.filter((user) => user.name.toLowerCase().includes(search.toLowerCase()))
}
console.log("2>>>>",filteredUsers)
// Apply sorting
if (sortBy && sortingOrder) {
filteredUsers = filteredUsers.sort((a, b) => {
const first = getSortItem(a, sortBy)
const second = getSortItem(b, sortBy)
if (first > second) {
return sortingOrder === 'asc' ? 1 : -1
}
if (first < second) {
return sortingOrder === 'asc' ? -1 : 1
}
return 0
})
}
console.log("3>>>>",filteredUsers)
const { page = 1, perPage = 10 } = filters || {}
// Return the filtered and paginated data
return {
data: filteredUsers.slice((page - 1) * perPage, page * perPage),
pagination: {
page,
perPage,
total: filteredUsers.length,
},
}
} catch (error) {
// Handle error here
}
}
export const useUsers = (options?: {
pagination?: Ref<Pagination>
sorting?: Ref<Sorting>
filters?: Ref<Partial<Filters>>
}) => {
const isLoading = ref(false)
const users = ref<UserData[]>([])
const { filters = makeFiltersRef(), sorting = makeSortingRef(), pagination = makePaginationRef() } = options || {}
const fetch = async () => {
isLoading.value = true
const result = await getUsers({
...unref(filters),
...unref(sorting),
...unref(pagination),
})
if (result) {
const { data, pagination: newPagination } = result;
// Use 'data' and 'newPagination' here...
users.value = data
ignoreUpdates(() => {
pagination.value = newPagination
})
} else {
// Handle the case when 'result' is undefined
}
isLoading.value = false
}
const { ignoreUpdates } = watchIgnorable([pagination, sorting], fetch, { deep: true })
watch(
filters,
() => {
// Reset pagination to first page when filters changed
pagination.value.page = 1
fetch()
},
{ deep: true },
)
fetch()
return {
isLoading,
filters,
sorting,
pagination,
users,
fetch,
async add(user: UserData) {
isLoading.value = true
await UserApi.addUser(user)
await fetch()
isLoading.value = false
},
async update(user: UserData) {
isLoading.value = true
await UserApi.updateUser(user)
await fetch()
isLoading.value = false
},
async remove(id : number) {
isLoading.value = true
await UserApi.deleteUser(id)
await fetch()
isLoading.value = false
},
}
}
同时最下面除了导出所需要的一些对象,还导出了三个异步方法,在这三个方法中才调用axios异步与后端交互,当成功时候自然会重新调用fetch方法渲染数据,保持当前的结果一致性(使用isLoading来阻塞table,页面显示就是转圈圈正在加载中)到此基本就编写完成