This commit is contained in:
2026-01-05 12:47:14 +08:00
commit 1fc846fae3
1614 changed files with 162035 additions and 0 deletions

View File

@@ -0,0 +1,104 @@
import type { ExtractPropTypes } from 'vue'
import { CHANGE_EVENT, CLOSE_EVENT, SELECTED_EVENT, UPDATE_MODEL_EVENT, UPDATE_VISIBLE_EVENT } from '../_constants'
import { commonProps, isBoolean, makeArrayProp, makeNumericProp, makeStringProp, truthProp } from '../_utils'
import { popupProps } from '../popup'
import type { AddressExistRegionData, AddressRegionData, AddressType } from './type'
export const addressProps = {
...popupProps,
...commonProps,
/**
* @description 设置默认选中值
*/
modelValue: makeArrayProp<any>([]),
/**
* @description 是否打开地址选择
*/
visible: Boolean,
/**
* @description 地址选择类型:'exist' | 'custom' | 'custom2'
*/
type: makeStringProp<AddressType>('custom'),
/**
* @description 自定义地址选择标题
*/
customAddressTitle: makeStringProp(''),
/**
* @description 省份数据
*/
province: makeArrayProp<AddressRegionData>([]),
/**
* @description 城市数据
*/
city: makeArrayProp<AddressRegionData>([]),
/**
* @description 县区数据
*/
country: makeArrayProp<AddressRegionData>([]),
/**
* @description 乡镇数据
*/
town: makeArrayProp<AddressRegionData>([]),
/**
* @description 是否显示 '选择其他地区' 按钮。仅在类型为 'exist' 时生效
*/
isShowCustomAddress: truthProp,
/**
* @description 现存地址列表
*/
existAddress: makeArrayProp<AddressExistRegionData>([]),
/**
* @description 已有地址标题
*/
existAddressTitle: makeStringProp(''),
/**
* @description 切换自定义地址和已有地址的按钮标题
*/
customAndExistTitle: makeStringProp(''),
/**
* @description 弹层中内容容器的高度
*/
height: makeNumericProp('200'),
/**
* @description 列提示文字
*/
columnsPlaceholder: {
type: [String, Array],
default: '',
},
}
export type AddressProps = ExtractPropTypes<typeof addressProps>
export const addressEmits = {
[UPDATE_VISIBLE_EVENT]: (val: boolean) => isBoolean(val),
[UPDATE_MODEL_EVENT]: () => true,
[CLOSE_EVENT]: (val: {
data: any
type: string
}) => val instanceof Object,
[CHANGE_EVENT]: (val: {
next?: string
value?: AddressRegionData
custom: string
}) => val instanceof Object,
switchModule: (val: { type: AddressType }) => val instanceof Object,
closeMask: (val: { closeWay: 'self' | 'mask' | 'cross' }) => val instanceof Object,
[SELECTED_EVENT]: (prevExistAdd: AddressExistRegionData, item: AddressExistRegionData, copyExistAdd: AddressExistRegionData[]) => prevExistAdd instanceof Object && item instanceof Object && copyExistAdd instanceof Object,
}
export type AddressEmits = typeof addressEmits

View File

@@ -0,0 +1,443 @@
<script setup lang="ts">
import type { ScrollViewOnScrollEvent } from '@uni-helper/uni-app-types'
import { computed, defineComponent, reactive, ref, watch } from 'vue'
import { CHANGE_EVENT, CLOSE_EVENT, PREFIX, SELECTED_EVENT, UPDATE_MODEL_EVENT, UPDATE_VISIBLE_EVENT } from '../_constants'
import { getMainClass } from '../_utils'
import requestAniFrame from '../_utils/raf'
import { useTranslate } from '../../locale'
import NutElevator from '../elevator/elevator.vue'
import NutIcon from '../icon/icon.vue'
import NutPopup from '../popup/popup.vue'
import { addressEmits, addressProps } from './address'
import type { AddressExistRegionData, AddressRegionData, CustomRegionData } from './type'
const props = defineProps(addressProps)
const emit = defineEmits(addressEmits)
const classes = computed(() => {
return getMainClass(props, componentName)
})
const showPopup = ref(props.visible)
const privateType = ref(props.type)
const tabIndex = ref(0)
const prevTabIndex = ref(0)
const tabName = ref(['province', 'city', 'country', 'town'])
const scrollDis = ref([0, 0, 0, 0])
const scrollTop = ref(0)
const regionData = reactive<Array<AddressRegionData[]>>([])
const regionList = computed(() => {
switch (tabIndex.value) {
case 0:
return props.province
case 1:
return props.city
case 2:
return props.country
default:
return props.town
}
})
function transformData(data: AddressRegionData[]) {
if (!Array.isArray(data))
throw new TypeError('params muse be array.')
if (!data.length)
return []
data.forEach((item: AddressRegionData) => {
if (!item.title)
console.warn('[NutUI] <Address> 请检查数组选项的 title 值是否有设置 ,title 为必填项 .')
})
const newData: CustomRegionData[] = []
data = data.sort((a: AddressRegionData, b: AddressRegionData) => {
return a.title.localeCompare(b.title)
})
data.forEach((item: AddressRegionData) => {
const index = newData.findIndex((value: CustomRegionData) => value.title === item.title)
if (index <= -1) {
newData.push({
title: item.title,
list: ([] as any).concat(item),
})
}
else {
newData[index].list.push(item)
}
})
return newData
}
const selectedRegion = ref<AddressRegionData[]>([])
let selectedExistAddress = reactive({}) // 当前选择的地址
const closeWay = ref<'self' | 'mask' | 'cross'>('self')
// 设置选中省市县
function initCustomSelected() {
regionData[0] = props.province || []
regionData[1] = props.city || []
regionData[2] = props.country || []
regionData[3] = props.town || []
const defaultValue = props.modelValue
const num = defaultValue.length
if (num > 0) {
tabIndex.value = num - 1
if (regionList.value.length === 0) {
tabIndex.value = 0
return
}
for (let index = 0; index < num; index++) {
const arr: AddressRegionData[] = regionData[index]
selectedRegion.value[index] = arr.filter((item: AddressRegionData) => item.id === defaultValue[index])[0]
}
scrollTo()
}
}
function getTabName(item: AddressRegionData | null, index: number) {
if (item && item.name)
return item.name
if (tabIndex.value < index && item)
return item.name
else
return props.columnsPlaceholder[index] || translate('select')
}
// 手动关闭 点击叉号(cross),或者蒙层(mask)
function handClose(type = 'self') {
closeWay.value = type === 'cross' ? 'cross' : 'self'
showPopup.value = false
}
// 点击遮罩层关闭
function clickOverlay() {
closeWay.value = 'mask'
}
// 切换下一级列表
function nextAreaList(item: AddressRegionData) {
const tab = tabIndex.value
prevTabIndex.value = tabIndex.value
const callBackParams: {
next?: string
value?: AddressRegionData
custom: string
} = {
custom: tabName.value[tab],
}
selectedRegion.value[tab] = item
// 删除右边已选择数据
selectedRegion.value.splice(tab + 1, selectedRegion.value.length - (tab + 1))
callBackParams.value = item
if (regionData[tab + 1]?.length > 0) {
tabIndex.value = tab + 1
callBackParams.next = tabName.value[tabIndex.value]
scrollToTop()
}
else {
handClose()
emit(UPDATE_MODEL_EVENT)
}
emit(CHANGE_EVENT, callBackParams)
}
// 切换地区Tab
function changeRegionTab(item: AddressRegionData, index: number) {
prevTabIndex.value = tabIndex.value
if (getTabName(item, index)) {
tabIndex.value = index
scrollTo()
}
}
function scrollChange(e: ScrollViewOnScrollEvent) {
scrollDis.value[tabIndex.value] = e.detail.scrollTop
}
function scrollToTop() {
// scrollTop 不会实时变更。当再次赋值时scrollTop无变化时不会触发滚动
scrollTop.value += 1
requestAniFrame(() => {
setTimeout(() => {
// 直接设置为0无效
scrollTop.value = 0.01
}, 100)
})
}
function scrollTo() {
// scrollTop 不会实时变更。当再次赋值时scrollTop无变化时不会触发滚动
scrollTop.value += 1
requestAniFrame(() => {
setTimeout(() => {
scrollTop.value = scrollDis.value[tabIndex.value]
}, 10)
})
}
// 选择现有地址
function selectedExist(item: AddressExistRegionData) {
const copyExistAdd = props.existAddress
let prevExistAdd: AddressExistRegionData = {} as AddressExistRegionData
copyExistAdd.forEach((list: AddressExistRegionData) => {
if (list && list.selectedAddress)
prevExistAdd = list
list.selectedAddress = false
})
item.selectedAddress = true
selectedExistAddress = item
emit(SELECTED_EVENT, prevExistAdd, item, copyExistAdd)
handClose()
}
// 初始化
function initAddress() {
selectedRegion.value = []
tabIndex.value = 0
scrollTo()
}
// 关闭
function close() {
const data = {
addressIdStr: '',
addressStr: '',
province: selectedRegion.value[0],
city: selectedRegion.value[1],
country: selectedRegion.value[2],
town: selectedRegion.value[3],
}
const callBackParams = {
data: {},
type: privateType.value,
}
if (['custom', 'custom2'].includes(privateType.value)) {
[0, 1, 2, 3].forEach((i) => {
const item = selectedRegion.value[i]
data.addressIdStr += `${i ? '_' : ''}${(item && item.id) || 0}`
data.addressStr += (item && item.name) || ''
})
callBackParams.data = data
}
else {
callBackParams.data = selectedExistAddress
}
initAddress()
if (closeWay.value === 'self')
emit(CLOSE_EVENT, callBackParams)
else
emit('closeMask', { closeWay: closeWay.value })
emit(UPDATE_VISIBLE_EVENT, false)
}
// 选择其他地址
function switchModule() {
const type = privateType.value
privateType.value = type === 'exist' ? 'custom' : 'exist'
initAddress()
emit('switchModule', { type: privateType.value })
}
function handleElevatorItem(key: string, item: AddressRegionData) {
nextAreaList(item)
}
watch(
() => props.visible,
(value) => {
showPopup.value = value
},
)
watch(
() => showPopup.value,
(value) => {
if (value)
initCustomSelected()
},
)
</script>
<script lang="ts">
const componentName = `${PREFIX}-address`
const { translate } = useTranslate(componentName)
export default defineComponent({
name: componentName,
options: {
virtualHost: true,
addGlobalClass: true,
styleIsolation: 'shared',
},
})
</script>
<template>
<NutPopup
v-model:visible="showPopup"
:z-index="zIndex"
position="bottom"
:lock-scroll="lockScroll"
:round="round"
@close="close"
@click-overlay="clickOverlay"
@open="closeWay = 'self'"
>
<view :class="classes" :style="customStyle">
<view class="nut-address__header">
<view class="nut-address__header-back" @click="switchModule">
<slot v-if="type === 'exist' && privateType === 'custom'" name="backIcon">
<NutIcon name="left" size="14px" />
</slot>
</view>
<view class="nut-address__header__title">
{{
privateType === 'custom'
? customAddressTitle || translate('selectRegion')
: existAddressTitle || translate('deliveryTo')
}}
</view>
<view class="nut-address__header-close" @click="handClose('cross')">
<slot name="closeIcon">
<NutIcon name="close" custom-color="#cccccc" size="14px" />
</slot>
</view>
</view>
<!-- 请选择 -->
<view v-if="['custom', 'custom2'].includes(privateType)" class="nut-address__custom">
<view class="nut-address__region">
<view
v-for="(item, index) in selectedRegion"
:key="index"
class="nut-address__region-item "
:class="[index === tabIndex ? 'active' : '']"
@click="changeRegionTab(item, index)"
>
<view>{{ getTabName(item, index) }} </view>
<view class="nut-address__region-line--mini" :class="{ active: index === tabIndex }" />
</view>
<view v-if="tabIndex === selectedRegion.length" class="active nut-address__region-item">
<view>{{ getTabName(null, selectedRegion.length) }} </view>
<view class="nut-address__region-line--mini active" />
</view>
</view>
<view v-if="privateType === 'custom'" class="nut-address__detail">
<div class="nut-address__detail-list">
<scroll-view
:scroll-y="true"
:style="{ height: '100%' }"
:scroll-top="scrollTop"
@scroll="scrollChange"
>
<div
v-for="(item, index) in regionList"
:key="index"
class="nut-address__detail-item"
:class="[selectedRegion[tabIndex]?.id === item.id ? 'active' : '']"
@click="nextAreaList(item)"
>
<view>
<slot v-if="selectedRegion[tabIndex]?.id === item.id" name="icon">
<NutIcon name="Check" custom-class="nut-address-select-icon" width="13px" />
</slot>{{ item.name }}
</view>
</div>
</scroll-view>
</div>
</view>
<view v-else class="nut-address__elevator-group">
<NutElevator
:height="height"
:index-list="transformData(regionList)"
@click-item="handleElevatorItem"
/>
</view>
</view>
<!-- 配送至 -->
<view v-else-if="privateType === 'exist'" class="nut-address__exist">
<div class="nut-address__exist-group">
<ul class="nut-address__exist-group-list">
<li
v-for="(item, index) in existAddress"
:key="index"
class="nut-address__exist-group-item"
:class="[item.selectedAddress ? 'active' : '']"
@click="selectedExist(item)"
>
<slot v-if="!item.selectedAddress" name="unselectedIcon">
<NutIcon name="location2" custom-class="nut-address-select-icon" width="13px" />
</slot>
<slot v-if="item.selectedAddress" name="icon">
<NutIcon name="Check" custom-class="nut-address-select-icon" width="13px" />
</slot>
<div class="nut-address__exist-item-info">
<div v-if="item.name && item.phone" class="nut-address__exist-item-info-top">
<div class="nut-address__exist-item-info-name">
{{ item.name }}
</div>
<div class="nut-address__exist-item-info-phone">
{{ item.phone }}
</div>
</div>
<div class="nut-address__exist-item-info-bottom">
<view>
{{ item.provinceName + item.cityName + item.countyName + item.townName + item.addressDetail }}
</view>
</div>
</div>
</li>
</ul>
</div>
<div v-if="isShowCustomAddress" class="nut-address__exist-choose" @click="switchModule">
<div class="nut-address__exist-choose-btn">
{{
customAndExistTitle || translate('chooseAnotherAddress')
}}
</div>
</div>
<template v-if="!isShowCustomAddress">
<slot name="bottom" />
</template>
</view>
</view>
</NutPopup>
</template>
<style lang="scss">
@import './index';
</style>

View File

@@ -0,0 +1,232 @@
@import '../popup/index';
@import '../elevator/index';
.nut-theme-dark {
.nut-address {
&__header {
color: $dark-color;
&__title {
color: $dark-color;
}
}
.nut-address__custom {
.nut-address__region {
color: $dark-color;
}
.nut-address__detail {
.nut-address__detail-list {
.nut-address__detail-item {
color: $dark-color;
}
}
}
}
.nut-address__exist {
.nut-address__exist-group {
.nut-address__exist-group-list {
.nut-address__exist-group-item {
color: $dark-color;
}
}
}
.nut-address__exist-choose {
border-top: 1px solid $dark-background;
}
}
&-custom-buttom {
border-top: 1px solid $dark-background;
}
}
}
.nut-address {
display: block;
&__header {
display: flex;
align-items: center;
justify-content: space-between;
height: 68px;
padding: 0 20px;
font-weight: bold;
color: #333;
text-align: center;
&__title {
display: block;
font-size: $address-header-title-font-size;
color: $address-header-title-color;
}
}
// 请选择
.nut-address__custom {
display: block;
.nut-address__region {
position: relative;
display: flex;
// margin-top: 32px;
padding: 0 20px;
font-size: $address-region-tab-font-size;
color: $address-region-tab-color;
.nut-address__region-item {
position: relative;
display: block;
min-width: 2px;
margin-right: 30px;
&.active {
font-weight: $address-region-tab-active-item-font-weight;
}
view {
display: block;
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nut-address__region-line--mini {
position: absolute;
bottom: -10px;
left: 0;
display: inline-block;
width: 0;
height: 3px;
margin-top: 5px;
background: $address-region-tab-line;
transition: 0.2s all linear;
&.active {
width: 26px;
}
}
}
.nut-address__region-line {
position: absolute;
bottom: -10px;
left: 20px;
display: inline-block;
width: 26px;
height: 3px;
margin-top: 5px;
background: $address-region-tab-line;
border-radius: $address-region-tab-line-border-radius;
opacity: $address-region-tab-line-opacity;
transition: 0.2s all linear;
}
}
.nut-address__detail {
display: block;
margin: 20px 20px 0;
.nut-address__detail-list {
// overflow-y: auto;
box-sizing: border-box;
height: 270px;
padding: 0;
padding-top: 15px;
.nut-address__detail-item {
display: flex;
align-items: center;
font-size: $address-region-item-font-size;
color: $address-region-item-color;
&.active {
font-weight: bold;
}
> view {
display: flex;
align-items: center;
margin: 10px 0;
}
}
}
}
.nut-address__elevator-group {
display: flex;
margin-top: 20px;
}
}
// 配送至
.nut-address__exist {
display: block;
margin-top: 15px;
.nut-address__exist-group {
height: 279px;
padding: 15px 20px 0;
overflow-y: scroll;
.nut-address__exist-group-list {
box-sizing: border-box;
padding: 0;
.nut-address__exist-group-item {
display: flex;
align-items: center;
margin-bottom: 20px;
font-size: $font-size-1;
line-height: 14px;
color: #333;
&.active {
font-weight: bold;
}
.exist-item-icon {
margin-right: $address-item-margin-right;
color: $address-icon-color !important;
}
// span {
// display: inline-block;
// flex: 1;
// }
}
}
}
.nut-address__exist-choose {
width: 100%;
height: 54px;
padding: 6px 0 0;
border-top: 1px solid #f2f2f2;
.nut-address__exist-choose-btn {
width: 90%;
height: 42px;
margin: auto;
font-size: 15px;
line-height: 42px;
color: $white;
text-align: center;
background: $button-primary-background-color;
border-radius: 21px;
}
}
}
&-select-icon {
margin-right: $address-item-margin-right;
color: $address-icon-color !important;
}
}

View File

@@ -0,0 +1,2 @@
export * from './address'
export * from './type'

View File

@@ -0,0 +1,23 @@
export interface AddressRegionData {
name: string
[key: string]: any
}
export interface CustomRegionData {
title: string
list: any[]
}
export interface AddressExistRegionData {
id?: string | number
provinceName: string
cityName: string
countyName: string
townName: string
addressDetail: string
selectedAddress: boolean
[key: string]: any
}
export const addressType = ['exist', 'custom', 'custom2'] as const
export type AddressType = (typeof addressType)[number]