登录
微信授权登录: 通过wx.login()获取code登录凭证, 通过特定类型按钮获取用户手机号, 实现授权登录
import type { LoginResult } from '@/types/member'
import { http } from '@/utils/http'
type LoginParams = {
code: string
encryptedData: string
iv: string
}
// 微信登录
export const postLoginWxMin = (data: LoginParams) => {
return http<LoginResult>({
method: 'POST',
url: '/login/wxMin',
data,
})
}
// 模拟微信登录
export const postLoginWxMinSimpleAPI = (phoneNumber: string) => {
return http<LoginResult>({
method: 'POST',
url: '/login/wxMin/simple',
data: {
phoneNumber,
},
})
}
// 用户信息类型文件
/** 小程序登录 登录用户信息 */
export type LoginResult = {
/** 用户ID */
id: number
/** 头像 */
avatar: string
/** 账户名 */
account: string
/** 昵称 */
nickname?: string
/** 手机号 */
mobile: string
/** 登录凭证 */
token: string
}
<script setup lang="ts">
import { postLoginWxMin } from '@/services/login'
import { onLoad } from '@dcloudio/uni-app'
// 获取code
let code = ''
onLoad(async () => {
const res = await wx.login()
code = res.code
})
// 微信登录
const onGetPhoneNumber: UniHelper.ButtonOnGetphonenumber = async (ev) => {
const encryptedData = ev.detail!.encryptedData!
const iv = ev.detail!.iv!
const res = await postLoginWxMin({
encryptedData,
iv,
code,
})
console.log(res)
}
</script>
<template>
<view class="viewport">
<view class="logo">
<image
src="https://pcapi-xiaotuxian-front-devtest.itheima.net/miniapp/images/logo_icon.png"
></image>
</view>
<view class="login">
<!-- 小程序端授权登录 -->
<button class="button phone" open-type="getPhoneNumber" @getphonenumber="onGetPhoneNumber">
<text class="icon icon-phone"></text>
手机号快捷登录
</button>
<view class="tips">登录/注册即视为你同意《服务条款》和《小兔鲜儿隐私协议》</view>
</view>
</view>
</template>
- 使用企业小程序appid, 并且把微信号添加到开发者列表中, 才能获取到用户手机号
- ! 非空断言排除的是前面的空值, 有需要的话可以使用多次
模拟登录: 获取手机号功能针对非个人开发者, 且认证完成的小程序开发
<sript setup lang="ts">
import { useMemberStore } from '@/stores'
import { postLoginWxMinSimpleAPI } from '@/services/login'
// 模拟微信登录
const onGetPhoneNumberSimple = async () => {
const res = await postLoginWxMinSimpleAPI('15553266208')
console.log(res)
}
</script>
<template>
<view class="viewport">
<view class="logo">
<image
src="https://pcapi-xiaotuxian-front-devtest.itheima.net/miniapp/images/logo_icon.png"
></image>
</view>
<view class="login">
<!-- 小程序端授权登录 -->
<button class="button phone">
<text class="icon icon-phone"></text>
手机号快捷登录
</button>
<view class="extra">
<view class="caption">
<text>其他登录方式</text>
</view>
<view class="options">
<!-- 通用模拟登录 -->
<button @tap="onGetPhoneNumberSimple">
<text class="icon icon-phone">模拟快捷登录</text>
</button>
</view>
</view>
<view class="tips">登录/注册即视为你同意《服务条款》和《小兔鲜儿隐私协议》</view>
</view>
</view>
</template>
保存登录信息
<sript setup lang="ts">
import { useMemberStore } from '@/stores'
import { postLoginWxMinSimpleAPI } from '@/services/login'
// 模拟微信登录
const onGetPhoneNumberSimple = async () => {
const res = await postLoginWxMinSimpleAPI('15553266208')
// 保存用户信息
const memberStore = useMemberStore()
memberStore.setProfile(res.result)
// 提示
uni.showToast({
icon: 'none',
title: '登录成功',
})
// 跳转
setTimeout(() => {
uni.navigateBack()
}, 500)
}
</script>
import { defineStore } from 'pinia'
import { ref } from 'vue'
// 定义 Store
export const useMemberStore = defineStore(
'member',
() => {
// 会员信息
const profile = ref<LoginResult>()
// 保存会员信息,登录时使用
const setProfile = (val: LoginResult) => {
profile.value = val
}
// 清理会员信息,退出时使用
const clearProfile = () => {
profile.value = undefined
}
// 记得 return
return {
profile,
setProfile,
clearProfile,
}
},
// TODO: 持久化
persist: {
storage: {
getItem(key) {
return uni.getStorageSync(key)
},
setItem(key, value) {
uni.setStorageSync(key, value)
},
},
},
},
)
我的页面
创建我的页面, 渲染用户信息
"pages": [
{
"path": "pages/my/my",
"style": {
"navigationStyle": "custom", // 隐藏默认导航
"navigationBarTextStyle": "white",
"navigationBarTitleText": "我的"
}
}
]
<script setup lang="ts">
import { useMemberStore } from '@/stores'
import { ref } from 'vue'
import { useGuessList } from '@/composables/index'
// 获取屏幕边界到安全区域距离
const { safeAreaInsets } = uni.getSystemInfoSync()
// 订单选项
const orderTypes = [
{ type: 1, text: '待付款', icon: 'icon-currency' },
{ type: 2, text: '待发货', icon: 'icon-gift' },
{ type: 3, text: '待收货', icon: 'icon-check' },
{ type: 4, text: '待评价', icon: 'icon-comment' },
]
// 获取会员信息
const memberStore = useMemberStore()
</script>
<template>
<scroll-view class="viewport" scroll-y enable-back-to-top @scrolltolower="onScorlltolower">
<!-- 个人资料 -->
<view class="profile" :style="{ paddingTop: safeAreaInsets!.top + 'px' }">
<!-- 情况1:已登录 -->
<view class="overview" v-if="memberStore.profile">
<navigator url="/pagesMember/profile/profile" hover-class="none">
<image class="avatar" mode="aspectFill" :src="memberStore.profile.avatar"></image>
</navigator>
<view class="meta">
<view class="nickname">
{{ memberStore.profile.nickname || memberStore.profile.account }}
</view>
<navigator class="extra" url="/pagesMember/profile/profile" hover-class="none">
<text class="update">更新头像昵称</text>
</navigator>
</view>
</view>
<!-- 情况2:未登录 -->
<view class="overview" v-else>
<navigator url="/pages/login/login" hover-class="none">
<image
class="avatar gray"
mode="aspectFill"
src="http://yjy-xiaotuxian-dev.oss-cn-beijing.aliyuncs.com/picture/2021-04-06/db628d42-88a7-46e7-abb8-659448c33081.png"
></image>
</navigator>
<view class="meta">
<navigator url="/pages/login/login" hover-class="none" class="nickname">
未登录
</navigator>
<view class="extra">
<text class="tips">点击登录账号</text>
</view>
</view>
</view>
<navigator class="settings" url="/pagesMember/settings/settings" hover-class="none">
设置
</navigator>
</view>
<!-- 我的订单 -->
<view class="orders">
<view class="title">
我的订单
<navigator class="navigator" url="/pagesOrder/list/list?type=0" hover-class="none">
查看全部订单<text class="icon-right"></text>
</navigator>
</view>
<view class="section">
<!-- 订单 -->
<navigator
v-for="item in orderTypes"
:key="item.type"
:class="item.icon"
:url="`/pagesOrder/list/list?type=${item.type}`"
class="navigator"
hover-class="none"
>
{{ item.text }}
</navigator>
<!-- 客服 -->
<button class="contact icon-handset" open-type="contact">售后</button>
</view>
</view>
</scroll-view>
</template>
猜你喜欢分页加载
<script setup lang="ts">
import { useGuessList } from '@/composables/index'
// 调用组合式函数,实现猜你喜欢分页加载
const { guessRef, onScorlltolower } = useGuessList()
</script>
<template>
<scroll-view class="viewport" scroll-y enable-back-to-top @scrolltolower="onScorlltolower">
<!-- 个人资料 -->
<view class="profile" :style="{ paddingTop: safeAreaInsets!.top + 'px' }">
... ...
</view>
<!-- 猜你喜欢 -->
<view class="guess">
<XtxGuess ref="guessRef" />
</view>
</scroll-view>
</template>
import type { XtxGuessInstance } from '@/types/component'
import { ref } from 'vue'
/**
* 猜你喜欢组合式函数
*/
export const useGuessList = () => {
// 猜你喜欢的组件实例
const guessRef = ref<XtxGuessInstance>()
// 滚动触底事件
const onScorlltolower = () => {
guessRef.value.getMore()
}
// 返回ref 和 事件处理函数
return {
guessRef,
onScorlltolower,
}
}
- 组合式函数是vue3组合式API支持的语法, 进一步封装vue组件中的函数
- 组合式函数一般以use开头进行命名
效果展示
设置页面
新建分包页面, 配置分包预下载
分包: 将小程序的核心页面和次要页面进行分割,打包成多个小程序包,减少小程序的加载时间 ,提高用户体验
预下载: 在进入小程序的指定页面时,由框架自动提前下载分包资源. 提升进入分包页面的启动速度
<template>
<view class="viewport">
<!-- 列表1 -->
<view class="list" v-if="true">
<navigator url="/pagesMember/address/address" hover-class="none" class="item arrow">
我的收货地址
</navigator>
</view>
<!-- 列表2 -->
<view class="list">
<button hover-class="none" class="item arrow" open-type="openSetting">授权管理</button>
<button hover-class="none" class="item arrow" open-type="feedback">问题反馈</button>
<button hover-class="none" class="item arrow" open-type="contact">联系我们</button>
</view>
<!-- 列表3 -->
<view class="list">
<navigator hover-class="none" class="item arrow" url=" ">关于小兔鲜儿</navigator>
</view>
<!-- 操作按钮 -->
<view class="action">
<view class="button">退出登录</view>
</view>
</view>
</template>
- 存放: 主包页面放在pages中管理, 分包单独创建文件夹管理
- 新建: 编辑器都有快捷新建分包页面的功能
配置分包预下载规则
{
// 主包页面配置
"pages": [
...
],
// 分包页面配置
"subPackages": [
{
// 子包的根目录
"root": "pagesMember",
// 页面路径和窗口表现
"pages": [
{
"path": "settings/settings",
"style": {
"navigationBarTitleText": "设置"
}
},
]
},
],
// 分包预下载规则
"preloadRule": {
// 进入页面时预下载的分包
"pages/my/my": {
// 网络
"network": "all",
// 预下载的分包
"packages": [
"pagesMember"
]
}
}
}
退出登录
<script setup lang="ts">
import { useMemberStore } from '@/stores'
// 退出登录
const memberStore = useMemberStore()
const onLogout = () => {
// 确认框
uni.showModal({
content: '是否退出登录?',
success: (res) => {
if (res.confirm) {
// 清理用户信息
memberStore.clearProfile()
// 页面跳转
uni.navigateBack()
}
},
})
}
</script>
<template>
<view class="viewport">
<!-- 列表1 -->
<view class="list" v-if="memberStore.profile">
<navigator url="/pagesMember/address/address" hover-class="none" class="item arrow">
我的收货地址
</navigator>
</view>
<!-- 列表2 -->
<view class="list">
<button hover-class="none" class="item arrow" open-type="openSetting">授权管理</button>
<button hover-class="none" class="item arrow" open-type="feedback">问题反馈</button>
<button hover-class="none" class="item arrow" open-type="contact">联系我们</button>
</view>
<!-- 列表3 -->
<view class="list">
<navigator hover-class="none" class="item arrow" url=" ">关于小兔鲜儿</navigator>
</view>
<!-- 操作按钮 -->
<view class="action">
<view class="button" @tap="onLogout" v-if="memberStore.profile">退出登录</view>
</view>
</view>
</template>
实现效果
个人信息
准备分包页面, 初始化渲染
<script setup lang="ts">
import { getMemberProfileAPI } from '@/services/profile'
import type { ProfileDetail } from '@/types/member'
import { onLoad } from '@dcloudio/uni-app'
import { ref } from 'vue'
// 获取屏幕边界到安全区域距离
const { safeAreaInsets } = uni.getSystemInfoSync()
// 获取个人信息
const profile = ref({} as ProfileDetail)
const getMemberProfileData = async () => {
const res = await getMemberProfileAPI()
profile.value = res.result
}
onLoad(() => {
getMemberProfileData()
})
</script>
<template>
<view class="viewport">
<!-- 导航栏 -->
<view class="navbar" :style="{ paddingTop: safeAreaInsets?.top + 'px' }">
<navigator open-type="navigateBack" class="back icon-left" hover-class="none"></navigator>
<view class="title">个人信息</view>
</view>
<!-- 头像 -->
<view class="avatar">
<view class="avatar-content" @tap="onAvatarChange">
<image class="image" :src="profile?.avatar" mode="aspectFill" />
<text class="text">点击修改头像</text>
</view>
</view>
<!-- 表单 -->
<view class="form">
<!-- 表单内容 -->
<view class="form-content">
<view class="form-item">
<text class="label">账号</text>
<text class="account">{{ profile?.account }}</text>
</view>
<view class="form-item">
<text class="label">昵称</text>
<input class="input" type="text" placeholder="请填写昵称" v-model="profile!.nickname" />
</view>
<view class="form-item">
<text class="label">性别</text>
<radio-group @change="onGenderChange">
<label class="radio">
<radio value="男" color="#27ba9b" :checked="profile?.gender === '男'" />
男
</label>
<label class="radio">
<radio value="女" color="#27ba9b" :checked="profile?.gender === '女'" />
女
</label>
</radio-group>
</view>
<view class="form-item">
<text class="label">生日</text>
<picker
class="picker"
mode="date"
start="1900-01-01"
:end="new Date()"
@change="onBirthdayChange"
:value="profile?.birthday"
>
<view v-if="profile?.birthday">{{ profile?.birthday }}</view>
<view class="placeholder" v-else>请选择日期</view>
</picker>
</view>
<view class="form-item">
<text class="label">城市</text>
<picker
class="picker"
mode="region"
:value="profile?.fullLocation?.split(' ')"
@change="onFullLocationChange"
>
<view v-if="profile?.fullLocation">{{ profile?.fullLocation }}</view>
<view class="placeholder" v-else>请选择城市</view>
</picker>
</view>
<view class="form-item">
<text class="label">职业</text>
<input class="input" type="text" placeholder="请填写职业" v-model="profile.profession" />
</view>
</view>
<!-- 提交按钮 -->
<button class="form-button" @tap="onSubmit">保 存</button>
</view>
</view>
</template>
import type { ProfileDetail, ProfileParams } from '@/types/member'
import { http } from '@/utils/http'
/**
* 获取个人信息
*/
export const getMemberProfileAPI = () => {
return http<ProfileDetail>({
method: 'GET',
url: '/member/profile',
})
}
// 用户信息类型文件
/** 封装通用信息 */
type BaseProfile = {
/** 用户ID */
id: number
/** 头像 */
avatar: string
/** 账户名 */
account: string
/** 昵称 */
nickname?: string
}
/** 小程序登录 登录用户信息 */
export type LoginResult = BaseProfile & {
/** 手机号 */
mobile: string
/** 登录凭证 */
token: string
}
/** 个人信息 用户详情信息 */
export type ProfileDetail = BaseProfile & {
/** 性别 */
gender?: Gender
/** 生日 */
birthday?: string
/** 省市区 */
fullLocation?: string
/** 职业 */
profession?: string
}
/** 性别 */
export type Gender = '女' | '男'
- 使用联合类型 & 进一步封装类型声明
头像上传
<script setup lang="ts">
import { getMemberProfileAPI, putMemberProfileAPI } from '@/services/profile'
import type { ProfileDetail, Gender } from '@/types/member'
import { ref } from 'vue'
import { useMemberStore } from '@/stores'
// 点击头像
const memberStore = useMemberStore()
const onAvatarChange = () => {
// 调用拍照/选择图片
// #ifdef H5 || APP-PLUS
// 微信小程序从基础库 2.21.0 开始, wx.chooseImage 停止维护,请使用 uni.chooseMedia 代替
uni.chooseImage({
count: 1,
success: (res) => {
// 文件路径
const tempFilePaths = res.tempFilePaths
// 上传
uploadFile(tempFilePaths[0])
},
})
// #endif
// #ifdef MP-WEIXIN
// uni.chooseMedia 仅支持微信小程序端
uni.chooseMedia({
// 文件个数
count: 1,
// 文件类型
mediaType: ['image'],
success: (res) => {
// 本地路径
const { tempFilePath } = res.tempFiles[0]
// 上传
uploadFile(tempFilePath)
},
})
// #endif
}
// 文件上传-兼容小程序端、H5端、App端
const uploadFile = (file: string) => {
// 文件上传
uni.uploadFile({
url: '/member/profile/avatar',
name: 'file',
filePath: file,
success: (res) => {
if (res.statusCode === 200) {
const avatar = JSON.parse(res.data).result.avatar
// 个人信息页数据更新
profile.value!.avatar = avatar
// Store头像更新
memberStore.profile!.avatar = avatar
uni.showToast({ icon: 'success', title: '更新成功' })
} else {
uni.showToast({ icon: 'error', title: '出现错误' })
}
},
})
}
</script>
<template>
<view class="viewport">
...
<!-- 头像 -->
<view class="avatar">
<view class="avatar-content" @tap="onAvatarChange">
<image class="image" :src="profile?.avatar" mode="aspectFill" />
<text class="text">点击修改头像</text>
</view>
</view>
...
</view>
</view>
</template>
修改用户昵称
import type { ProfileDetail, ProfileParams } from '@/types/member'
import { http } from '@/utils/http'
/**
* 修改个人信息
*/
export const putMemberProfileAPI = (data: ProfileParams) => {
return http<ProfileDetail>({
method: 'PUT',
url: '/member/profile',
data,
})
}
// 用户信息类型文件
/** 封装通用信息 */
type BaseProfile = {
/** 用户ID */
id: number
/** 头像 */
avatar: string
/** 账户名 */
account: string
/** 昵称 */
nickname?: string
}
/** 个人信息 用户详情信息 */
export type ProfileDetail = BaseProfile & {
/** 性别 */
gender?: Gender
/** 生日 */
birthday?: string
/** 省市区 */
fullLocation?: string
/** 职业 */
profession?: string
}
/** 性别 */
export type Gender = '女' | '男'
/** 个人信息 修改请求体参数 */
export type ProfileParams = Pick<
ProfileDetail,
'nickname' | 'gender' | 'birthday' | 'profession'
> & {
/** 省份编码 */
provinceCode?: string
/** 城市编码 */
cityCode?: string
/** 区/县编码 */
countyCode?: string
}
- Pick<目标类型, 选取的属性>泛型工具方法是TS提供的, 用于提取已有类型中的参数, 组合成新的类型
<script setup lang="ts">
import { putMemberProfileAPI } from '@/services/profile'
import type { ProfileDetail } from '@/types/member'
import { ref } from 'vue'
// 获取个人信息
const profile = ref({} as ProfileDetail)
// 表单提交
const onSubmit = async () => {
const res = await putMemberProfileAPI({
nickname: profile.value.nickname,
})
// 更新stroe的昵称
memberStore.profile!.nickname = res.result.nickname
uni.showToast({
icon: 'success',
titlelist: '修改成功',
})
// 返回上一页
setTimeout(() => {
uni.navigateBack()
}, 400)
}
</script>
<template>
<view class="viewport">
<!-- 表单 -->
<view class="form">
<!-- 表单内容 -->
<view class="form-content">
<view class="form-item">
<text class="label">账号</text>
<text class="account">{{ profile?.account }}</text>
</view>
<view class="form-item">
<text class="label">昵称</text>
<input class="input" type="text" placeholder="请填写昵称" v-model="profile!.nickname" />
</view>
</view>
<!-- 提交按钮 -->
<button class="form-button" @tap="onSubmit">保 存</button>
</view>
</view>
</template>
- 在TS中对数据的类型限制非常严格, 一个对象中的属性是不能随便添加的, 要和类型声明保持一致
- 仅做展示的对象数据, 一般只限制类型, 不给对象的定义初始值, 这样比较方便
- 对于需要双向绑定的数据, 就要使用 as类型断言, 告诉TS这个对象以后是什么样子, 这个对象才能顺利的进行数据双向绑定, 也就相当于给对象进行了初始值的定义
修改性别信息
<script setup lang="ts">
import { putMemberProfileAPI } from '@/services/profile'
import type { ProfileDetail, Gender } from '@/types/member'
import { ref } from 'vue'
// 获取个人信息
const profile = ref({} as ProfileDetail)
// 男女选项变化
const onGenderChange: UniHelper.RadioGroupOnChange = (ev) => {
profile.value.gender = ev.detail.value as Gender
}
// 表单提交
const onSubmit = async () => {
const res = await putMemberProfileAPI({
gender: profile.value.gender,
})
// 更新stroe的昵称
memberStore.profile!.nickname = res.result.nickname
uni.showToast({
icon: 'success',
titlelist: '修改成功',
})
setTimeout(() => {
uni.navigateBack()
}, 400)
}
</script>
<template>
<view class="viewport">
... ...
<!-- 表单 -->
<view class="form">
<!-- 表单内容 -->
<view class="form-content">
<view class="form-item">
<text class="label">性别</text>
<radio-group @change="onGenderChange">
<label class="radio">
<radio value="男" color="#27ba9b" :checked="profile?.gender === '男'" />
男
</label>
<label class="radio">
<radio value="女" color="#27ba9b" :checked="profile?.gender === '女'" />
女
</label>
</radio-group>
</view>
</view>
<!-- 提交按钮 -->
<button class="form-button" @tap="onSubmit">保 存</button>
</view>
</view>
</template>
- ev.detail.value 拿到的是string类型数据
- profile.value.gender 限制的数据类型是 gender
- 如果直接赋值TS会爆红, 所以要通过类型断言告诉TS, ev拿到的就是gender类型
修改生日信息
<script setup lang="ts">
import { putMemberProfileAPI } from '@/services/profile'
import type { ProfileDetail, Gender } from '@/types/member'
import { ref } from 'vue'
// 获取个人信息
const profile = ref({} as ProfileDetail)
// 修改生日
const onBirthdayChange: UniHelper.DatePickerOnChange = (ev) => {
profile.value.birthday = ev.detail.value
}
// 表单提交
const onSubmit = async () => {
const res = await putMemberProfileAPI({
birthday: profile.value.birthday,
})
// 更新stroe的昵称
memberStore.profile!.nickname = res.result.nickname
uni.showToast({
icon: 'success',
titlelist: '修改成功',
})
setTimeout(() => {
uni.navigateBack()
}, 400)
}
</script>
<template>
<view class="viewport">
... ...
<!-- 表单 -->
<view class="form">
<!-- 表单内容 -->
<view class="form-content">
<view class="form-item">
<text class="label">生日</text>
<picker
class="picker"
mode="date"
start="1900-01-01"
:end="new Date()"
@change="onBirthdayChange"
:value="profile?.birthday"
>
<view v-if="profile?.birthday">{{ profile?.birthday }}</view>
<view class="placeholder" v-else>请选择日期</view>
</picker>
</view>
</view>
<!-- 提交按钮 -->
<button class="form-button" @tap="onSubmit">保 存</button>
</view>
</view>
</template>
修改用户所在城市
<script setup lang="ts">
import { putMemberProfileAPI } from '@/services/profile'
import type { ProfileDetail } from '@/types/member'
import { ref } from 'vue'
// 获取个人信息
const profile = ref({} as ProfileDetail)
// 修改城市
let fullLocationCode: [string, string, string] = ['', '', '']
const onFullLocationChange: UniHelper.RegionPickerOnChange = (ev) => {
// 修改前端界面
profile.value.fullLocation = ev.detail.value.join(' ')
// 提交后端更新
fullLocationCode = ev.detail.code!
}
// 表单提交
const onSubmit = async () => {
const res = await putMemberProfileAPI({
provinceCode: fullLocationCode[0],
cityCode: fullLocationCode[1],
countyCode: fullLocationCode[2],
})
// 更新stroe的昵称
memberStore.profile!.nickname = res.result.nickname
uni.showToast({
icon: 'success',
titlelist: '修改成功',
})
setTimeout(() => {
uni.navigateBack()
}, 400)
}
</script>
<template>
<view class="viewport">
...
<!-- 表单 -->
<view class="form">
<!-- 表单内容 -->
<view class="form-content">
...
<view class="form-item">
<text class="label">城市</text>
<picker
class="picker"
mode="region"
:value="profile?.fullLocation?.split(' ')"
@change="onFullLocationChange"
>
<view v-if="profile?.fullLocation">{{ profile?.fullLocation }}</view>
<view class="placeholder" v-else>请选择城市</view>
</picker>
</view>
</view>
<!-- 提交按钮 -->
<button class="form-button" @tap="onSubmit">保 存</button>
</view>
</view>
</template>
- 在TS中, 长度和类型都已经确定的数组称为元组
实现效果